@roj-ai/standalone-server 0.1.35 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,42 +14,248 @@
14
14
  * - instances.archive — no-op; shutdown the server instead
15
15
  */
16
16
 
17
- import type { Logger, Preset, SessionManager } from '@roj-ai/sdk'
18
- import { SessionId } from '@roj-ai/sdk'
17
+ import type { MethodInput, MethodOutput, PlatformMethodName, PlatformMethods } from '@roj-ai/client/platform'
18
+ import type { Logger, Preset, Result, Session } from '@roj-ai/sdk'
19
+ import { SessionId, sessionMetadataSchema } from '@roj-ai/sdk'
20
+ import z from 'zod/v4'
19
21
  import { randomUUID } from 'node:crypto'
20
22
  import { Hono } from 'hono'
21
- import type { GitInstanceFs } from './git-instance-fs.js'
22
23
  import type { InstanceState } from './instance.js'
23
24
  import type { LocalRegistry } from './local-registry.js'
24
25
  import { signFileToken } from './signed-token.js'
25
26
 
27
+ interface RpcError {
28
+ type: string
29
+ message: string
30
+ httpStatus: number
31
+ }
32
+
33
+ interface PlatformSessionManager {
34
+ callManagerMethod(method: string, input: unknown): Promise<Result<unknown, RpcError>>
35
+ callPluginMethod(sessionId: SessionId, method: string, input: unknown): Promise<Result<unknown, RpcError>>
36
+ withSessionLease(
37
+ sessionId: SessionId,
38
+ reason: string,
39
+ operation: (session: Pick<Session, 'close'>) => Promise<Result<void, RpcError>>,
40
+ ): Promise<Result<void, RpcError>>
41
+ getStats(): Promise<{
42
+ sessions: Array<{ id: SessionId; presetId: string; status: string }>
43
+ }>
44
+ }
45
+
46
+ interface SessionGitFs {
47
+ addSessionWorktree(instanceId: string, sessionId: string): Promise<string>
48
+ removeSessionWorktree(instanceId: string, sessionId: string): Promise<void>
49
+ }
50
+
26
51
  interface Deps {
27
52
  instance: InstanceState
28
- sessionManager: SessionManager
53
+ sessionManager: PlatformSessionManager
29
54
  logger: Logger
30
55
  presets: Preset[]
31
56
  registry: LocalRegistry
32
57
  /** Per-instance bare repo + per-session worktree manager. */
33
- gitFs: GitInstanceFs
58
+ gitFs: SessionGitFs
34
59
  /** HMAC secret for signing download tokens (`sessionFiles.createDownloadUrl`). */
35
60
  tokenSecret: string
36
61
  /** Externally-reachable base URL (e.g. `http://localhost:8765`) used when minting download URLs. */
37
- publicBaseUrl: string
62
+ getPublicBaseUrl(): string
38
63
  }
39
64
 
40
- interface RpcEnvelope {
41
- method?: string
42
- input?: unknown
43
- batch?: Array<{ method: string; input?: unknown }>
44
- }
65
+ const RpcCallSchema = z.strictObject({
66
+ method: z.string(),
67
+ input: z.unknown().optional(),
68
+ })
69
+
70
+ const RpcEnvelopeSchema = z.union([
71
+ RpcCallSchema,
72
+ z.strictObject({ batch: z.array(RpcCallSchema) }),
73
+ ])
74
+
75
+ const AutoCreateSessionSchema = z.strictObject({
76
+ presetId: z.string().min(1),
77
+ blocking: z.boolean().optional(),
78
+ initialPrompt: z.string().optional(),
79
+ resourceIds: z.array(z.string()).optional(),
80
+ fileIds: z.array(z.string()).optional(),
81
+ })
82
+
83
+ const inputSchemas = {
84
+ 'instances.create': z.strictObject({
85
+ templateSlug: z.string(),
86
+ bundleSlug: z.string().optional(),
87
+ bundleRevisionId: z.string().optional(),
88
+ name: z.string(),
89
+ vcsType: z.enum(['github', 'gitLocal', 'none']).optional(),
90
+ metadata: z.record(z.string(), z.unknown()).optional(),
91
+ autoCreateSession: AutoCreateSessionSchema.optional(),
92
+ }),
93
+ 'instances.get': z.strictObject({ instanceId: z.string() }),
94
+ 'instances.list': z.strictObject({
95
+ limit: z.number().optional(),
96
+ offset: z.number().optional(),
97
+ }),
98
+ 'instances.status': z.strictObject({ instanceId: z.string() }),
99
+ 'instances.archive': z.strictObject({ instanceId: z.string() }),
100
+ 'sessions.create': z.strictObject({
101
+ instanceId: z.string(),
102
+ presetId: z.string().min(1),
103
+ blocking: z.boolean().optional(),
104
+ origin: z.string().optional(),
105
+ expiresIn: z.number().optional(),
106
+ initialPrompt: z.string().optional(),
107
+ }),
108
+ 'sessions.list': z.strictObject({ instanceId: z.string() }),
109
+ 'tokens.create': z.strictObject({
110
+ instanceId: z.string(),
111
+ origin: z.string().optional(),
112
+ expiresIn: z.number().optional(),
113
+ meta: z.record(z.string(), z.unknown()).optional(),
114
+ }),
115
+ 'resources.create': z.strictObject({
116
+ slug: z.string(),
117
+ name: z.string().optional(),
118
+ description: z.string().optional(),
119
+ fileId: z.string(),
120
+ label: z.string().optional(),
121
+ }),
122
+ 'resources.addRevision': z.strictObject({
123
+ resourceId: z.string().optional(),
124
+ resourceSlug: z.string().optional(),
125
+ fileId: z.string(),
126
+ label: z.string().optional(),
127
+ }),
128
+ 'resources.get': z.strictObject({
129
+ resourceId: z.string().optional(),
130
+ resourceSlug: z.string().optional(),
131
+ }),
132
+ 'resources.list': z.strictObject({
133
+ limit: z.number().optional(),
134
+ offset: z.number().optional(),
135
+ }),
136
+ 'resources.delete': z.strictObject({ resourceId: z.string() }),
137
+ 'sessionFiles.createDownloadUrl': z.strictObject({
138
+ instanceId: z.string(),
139
+ sessionId: z.string(),
140
+ scope: z.enum(['workspace', 'session']),
141
+ path: z.string(),
142
+ ttlSeconds: z.number().optional(),
143
+ }),
144
+ } satisfies Partial<Record<PlatformMethodName, z.ZodType>>
145
+
146
+ type ImplementedMethod = keyof typeof inputSchemas
147
+
148
+ const InstanceSummaryOutputSchema = z.strictObject({
149
+ instanceId: z.string(),
150
+ name: z.string(),
151
+ status: z.string(),
152
+ templateSlug: z.string(),
153
+ bundleSlug: z.string(),
154
+ bundleRevisionId: z.string(),
155
+ vcsType: z.string(),
156
+ metadata: z.record(z.string(), z.unknown()).nullable(),
157
+ createdAt: z.string(),
158
+ })
159
+
160
+ const SessionSummaryOutputSchema = z.strictObject({
161
+ id: z.string(),
162
+ presetId: z.string().nullable(),
163
+ status: z.string(),
164
+ createdAt: z.string(),
165
+ })
166
+
167
+ const ResourceOutputSchema = z.strictObject({
168
+ id: z.string(),
169
+ slug: z.string(),
170
+ name: z.string().nullable(),
171
+ description: z.string().nullable(),
172
+ latestRevision: z.strictObject({
173
+ id: z.string(),
174
+ label: z.string().nullable(),
175
+ file: z.strictObject({
176
+ id: z.string(),
177
+ filename: z.string(),
178
+ mimeType: z.string(),
179
+ size: z.number(),
180
+ }),
181
+ createdAt: z.string(),
182
+ }).nullable(),
183
+ createdAt: z.string(),
184
+ })
185
+
186
+ const outputSchemas = {
187
+ 'instances.create': z.strictObject({
188
+ instanceId: z.string(),
189
+ status: z.enum(['created', 'initializing', 'ready']),
190
+ sessionId: z.string().optional(),
191
+ wsToken: z.string().optional(),
192
+ }),
193
+ 'instances.get': InstanceSummaryOutputSchema,
194
+ 'instances.list': z.strictObject({
195
+ instances: z.array(InstanceSummaryOutputSchema),
196
+ total: z.number(),
197
+ }),
198
+ 'instances.status': z.strictObject({
199
+ instanceId: z.string(),
200
+ status: z.string(),
201
+ sandbox: z.strictObject({
202
+ state: z.enum(['stopped', 'starting', 'running', 'pausing', 'paused', 'failed']),
203
+ e2bId: z.string().optional(),
204
+ lastActivityAt: z.string().optional(),
205
+ versions: z.strictObject({
206
+ sdk: z.string(),
207
+ runtime: z.strictObject({ name: z.string(), version: z.string() }).nullable(),
208
+ }).optional(),
209
+ }).nullable(),
210
+ sessions: z.array(SessionSummaryOutputSchema),
211
+ lifecycleEvents: z.array(z.strictObject({
212
+ event: z.string(),
213
+ detail: z.string().optional(),
214
+ createdAt: z.string(),
215
+ })),
216
+ serviceUrls: z.array(z.strictObject({
217
+ code: z.string(),
218
+ sessionId: z.string().nullable(),
219
+ serviceType: z.string().nullable(),
220
+ port: z.number(),
221
+ })),
222
+ }),
223
+ 'instances.archive': z.strictObject({ ok: z.boolean() }),
224
+ 'sessions.create': z.strictObject({
225
+ sessionId: z.string(),
226
+ status: z.enum(['creating', 'active']),
227
+ wsToken: z.string().optional(),
228
+ }),
229
+ 'sessions.list': z.strictObject({ sessions: z.array(SessionSummaryOutputSchema) }),
230
+ 'tokens.create': z.strictObject({ token: z.string(), expiresAt: z.string() }),
231
+ 'resources.create': z.strictObject({ resourceId: z.string(), revisionId: z.string() }),
232
+ 'resources.addRevision': z.strictObject({
233
+ revisionId: z.string(),
234
+ noop: z.boolean().optional(),
235
+ }),
236
+ 'resources.get': ResourceOutputSchema,
237
+ 'resources.list': z.strictObject({ resources: z.array(ResourceOutputSchema) }),
238
+ 'resources.delete': z.strictObject({ ok: z.boolean() }),
239
+ 'sessionFiles.createDownloadUrl': z.strictObject({ url: z.string(), expiresAt: z.string() }),
240
+ } satisfies { [M in ImplementedMethod]: z.ZodType<MethodOutput<PlatformMethods, M>> }
45
241
 
46
242
  export function createPlatformApi(deps: Deps): Hono {
47
243
  const app = new Hono()
48
244
 
49
245
  app.post('/rpc', async (c) => {
50
- const body = await c.req.json<RpcEnvelope>().catch(() => ({} as RpcEnvelope))
246
+ let rawBody: unknown
247
+ try {
248
+ rawBody = await c.req.json()
249
+ } catch {
250
+ return c.json({ ok: false, error: { type: 'invalid_request', message: 'Invalid JSON body' } }, 400)
251
+ }
252
+ const parsedBody = RpcEnvelopeSchema.safeParse(rawBody)
253
+ if (!parsedBody.success) {
254
+ return c.json({ ok: false, error: { type: 'invalid_request', message: parsedBody.error.message } }, 400)
255
+ }
256
+ const body = parsedBody.data
51
257
 
52
- if (Array.isArray(body.batch)) {
258
+ if ('batch' in body) {
53
259
  const results = []
54
260
  for (const call of body.batch) {
55
261
  results.push(await dispatch(deps, call.method, call.input))
@@ -57,10 +263,6 @@ export function createPlatformApi(deps: Deps): Hono {
57
263
  return c.json({ results })
58
264
  }
59
265
 
60
- if (typeof body.method !== 'string') {
61
- return c.json({ ok: false, error: { type: 'invalid_request', message: 'Missing method' } }, 400)
62
- }
63
-
64
266
  const result = await dispatch(deps, body.method, body.input)
65
267
  return c.json(result)
66
268
  })
@@ -73,33 +275,107 @@ async function dispatch(
73
275
  method: string,
74
276
  input: unknown,
75
277
  ): Promise<{ ok: true; value: unknown } | { ok: false; error: { type: string; message: string } }> {
76
- const handler = handlers[method]
77
- if (!handler) {
78
- return { ok: false, error: { type: 'method_not_found', message: `Method not supported in standalone: ${method}` } }
79
- }
80
-
81
278
  try {
82
- const value = await handler(deps, input ?? {})
279
+ const value = await callHandler(deps, method, input ?? {})
83
280
  return { ok: true, value }
84
281
  } catch (err) {
282
+ if (err instanceof UnsupportedMethodError) {
283
+ return { ok: false, error: { type: 'method_not_found', message: err.message } }
284
+ }
85
285
  const message = err instanceof Error ? err.message : String(err)
86
286
  deps.logger.error(`Platform RPC handler failed: ${method}`, err instanceof Error ? err : new Error(message))
87
287
  return { ok: false, error: { type: 'handler_error', message } }
88
288
  }
89
289
  }
90
290
 
91
- type Handler = (deps: Deps, input: any) => Promise<unknown>
291
+ async function callHandler(deps: Deps, method: string, input: unknown): Promise<unknown> {
292
+ switch (method) {
293
+ case 'instances.create':
294
+ return outputSchemas['instances.create'].parse(
295
+ await handlers['instances.create'](deps, inputSchemas['instances.create'].parse(input)),
296
+ )
297
+ case 'instances.get':
298
+ return outputSchemas['instances.get'].parse(
299
+ await handlers['instances.get'](deps, inputSchemas['instances.get'].parse(input)),
300
+ )
301
+ case 'instances.list':
302
+ return outputSchemas['instances.list'].parse(
303
+ await handlers['instances.list'](deps, inputSchemas['instances.list'].parse(input)),
304
+ )
305
+ case 'instances.status':
306
+ return outputSchemas['instances.status'].parse(
307
+ await handlers['instances.status'](deps, inputSchemas['instances.status'].parse(input)),
308
+ )
309
+ case 'instances.archive':
310
+ return outputSchemas['instances.archive'].parse(
311
+ await handlers['instances.archive'](deps, inputSchemas['instances.archive'].parse(input)),
312
+ )
313
+ case 'sessions.create':
314
+ return outputSchemas['sessions.create'].parse(
315
+ await handlers['sessions.create'](deps, inputSchemas['sessions.create'].parse(input)),
316
+ )
317
+ case 'sessions.list':
318
+ return outputSchemas['sessions.list'].parse(
319
+ await handlers['sessions.list'](deps, inputSchemas['sessions.list'].parse(input)),
320
+ )
321
+ case 'tokens.create':
322
+ return outputSchemas['tokens.create'].parse(
323
+ await handlers['tokens.create'](deps, inputSchemas['tokens.create'].parse(input)),
324
+ )
325
+ case 'resources.create':
326
+ return outputSchemas['resources.create'].parse(
327
+ await handlers['resources.create'](deps, inputSchemas['resources.create'].parse(input)),
328
+ )
329
+ case 'resources.addRevision':
330
+ return outputSchemas['resources.addRevision'].parse(
331
+ await handlers['resources.addRevision'](deps, inputSchemas['resources.addRevision'].parse(input)),
332
+ )
333
+ case 'resources.get':
334
+ return outputSchemas['resources.get'].parse(
335
+ await handlers['resources.get'](deps, inputSchemas['resources.get'].parse(input)),
336
+ )
337
+ case 'resources.list':
338
+ return outputSchemas['resources.list'].parse(
339
+ await handlers['resources.list'](deps, inputSchemas['resources.list'].parse(input)),
340
+ )
341
+ case 'resources.delete':
342
+ return outputSchemas['resources.delete'].parse(
343
+ await handlers['resources.delete'](deps, inputSchemas['resources.delete'].parse(input)),
344
+ )
345
+ case 'sessionFiles.createDownloadUrl':
346
+ return outputSchemas['sessionFiles.createDownloadUrl'].parse(
347
+ await handlers['sessionFiles.createDownloadUrl'](
348
+ deps,
349
+ inputSchemas['sessionFiles.createDownloadUrl'].parse(input),
350
+ ),
351
+ )
352
+ default:
353
+ throw new UnsupportedMethodError(method)
354
+ }
355
+ }
92
356
 
93
- interface AutoCreateSessionInput {
94
- presetId: string
95
- initialPrompt?: string
96
- resourceIds?: string[]
97
- fileIds?: string[]
98
- blocking?: boolean
357
+ class UnsupportedMethodError extends Error {
358
+ constructor(readonly method: string) {
359
+ super(`Method not supported in standalone: ${method}`)
360
+ }
99
361
  }
100
362
 
101
- // Shared by `instances.create.autoCreateSession` and `sessions.create`. Creates
102
- // a session via the SDK session manager, injects any matching local resources,
363
+ /**
364
+ * A handler for one platform method, typed against the shared contract.
365
+ *
366
+ * `MethodInput`/`MethodOutput` come from @roj-ai/client/platform, so renaming a
367
+ * method or reshaping its payload there is a compile error here instead of a
368
+ * runtime `method_not_found` or a wrong-shaped JSON body. Two divergences had
369
+ * already shipped before this was wired up.
370
+ */
371
+ type Handler<M extends PlatformMethodName> = (
372
+ deps: Deps,
373
+ input: MethodInput<PlatformMethods, M>,
374
+ ) => Promise<MethodOutput<PlatformMethods, M>>
375
+
376
+ type PlatformHandlers = { [M in ImplementedMethod]: Handler<M> }
377
+
378
+ // Creates a session via the SDK session manager, injects selected local files,
103
379
  // then (if set) pushes `initialPrompt` as a user-chat message — order matters:
104
380
  // resources must land in the workspace before the agent's first inference so
105
381
  // the agent sees a non-empty workspace. Mirrors roj-platform's project-init
@@ -110,8 +386,8 @@ interface AutoCreateSessionInput {
110
386
  // — the worktree path is then passed through as `workspaceDir`.
111
387
  async function startSession(
112
388
  deps: Deps,
113
- input: { presetId: string; initialPrompt?: string; resourceIds?: string[] },
114
- ): Promise<{ sessionId: string }> {
389
+ input: { presetId: string; initialPrompt?: string; resourceIds?: string[]; fileIds?: string[] },
390
+ ): Promise<{ sessionId: string; status: 'active' }> {
115
391
  const sessionId = randomUUID()
116
392
  const workspaceDir = await deps.gitFs.addSessionWorktree(deps.instance.id, sessionId)
117
393
 
@@ -121,15 +397,18 @@ async function startSession(
121
397
  workspaceDir,
122
398
  })
123
399
  if (!created.ok) {
124
- // Roll back the worktree we just created so a retry isn't blocked by an
125
- // orphaned dir. Best-effort — failure here is logged inside removeSessionWorktree.
126
- await deps.gitFs.removeSessionWorktree(deps.instance.id, sessionId)
400
+ await removeWorktreeAfterFailure(deps, sessionId)
127
401
  throw new Error(created.error.message)
128
402
  }
129
403
 
130
- const resources = resolveSessionResources(deps, input.presetId, input.resourceIds)
131
- for (const resource of resources) {
132
- await injectRegistryResource(deps, sessionId, resource)
404
+ try {
405
+ const selections = resolveSessionFiles(deps, input.presetId, input.resourceIds, input.fileIds)
406
+ for (const selection of selections) {
407
+ await injectRegistryFile(deps, sessionId, selection)
408
+ }
409
+ } catch (error) {
410
+ await rollbackCreatedSession(deps, sessionId)
411
+ throw error
133
412
  }
134
413
 
135
414
  if (input.initialPrompt) {
@@ -144,36 +423,44 @@ async function startSession(
144
423
  }
145
424
  }
146
425
 
147
- return { sessionId }
426
+ // Creation is synchronous here — there is no provisioning step to wait on,
427
+ // so the session is active by the time this returns. The contract's other
428
+ // value, 'creating', belongs to the CF platform.
429
+ return { sessionId, status: 'active' }
148
430
  }
149
431
 
150
- interface ResolvedResource {
151
- resourceId: string
152
- slug: string
153
- name: string | null
432
+ interface ResolvedFile {
154
433
  fileId: string
155
- filename: string
156
- mimeType: string
434
+ metadata?: { slug?: string; name?: string }
157
435
  }
158
436
 
159
437
  // Resolution mirrors roj-platform's project-init.ts:206:
160
- // 1. explicit input.resourceIds — matched against the local registry by ID
161
- // first, then by slug (since local-dev callers often pass slugs as ids).
162
- // 2. fallback to preset.defaultResourceSlugs (looked up by slug).
163
- function resolveSessionResources(
438
+ // 1. explicit resourceIds, followed by explicit fileIds.
439
+ // 2. preset.defaultResourceSlugs only when no explicit IDs were supplied.
440
+ // Repeated selections of the same registry file are injected once.
441
+ function resolveSessionFiles(
164
442
  deps: Deps,
165
443
  presetId: string,
166
444
  inputResourceIds: string[] | undefined,
167
- ): ResolvedResource[] {
168
- if (inputResourceIds && inputResourceIds.length > 0) {
169
- const matched: ResolvedResource[] = []
445
+ inputFileIds: string[] | undefined,
446
+ ): ResolvedFile[] {
447
+ const hasExplicitFiles = (inputResourceIds?.length ?? 0) > 0 || (inputFileIds?.length ?? 0) > 0
448
+ const resolved: ResolvedFile[] = []
449
+ const seenFileIds = new Set<string>()
450
+
451
+ if (hasExplicitFiles) {
170
452
  const unmatched: string[] = []
171
- for (const id of inputResourceIds) {
453
+ for (const id of inputResourceIds ?? []) {
172
454
  const resource =
173
455
  deps.registry.getResource({ resourceId: id }) ?? deps.registry.getResource({ resourceSlug: id })
174
- const resolved = toResolvedResource(resource)
175
- if (resolved) matched.push(resolved)
176
- else unmatched.push(id)
456
+ const selection = toResolvedFile(resource)
457
+ if (!selection) {
458
+ unmatched.push(id)
459
+ continue
460
+ }
461
+ if (seenFileIds.has(selection.fileId)) continue
462
+ seenFileIds.add(selection.fileId)
463
+ resolved.push(selection)
177
464
  }
178
465
  if (unmatched.length > 0) {
179
466
  deps.logger.warn('Some input resourceIds did not match the local registry; ignoring', {
@@ -181,17 +468,26 @@ function resolveSessionResources(
181
468
  availableSlugs: deps.registry.listResources().map(r => r.slug),
182
469
  })
183
470
  }
184
- if (matched.length > 0) return matched
471
+ for (const fileId of inputFileIds ?? []) {
472
+ if (seenFileIds.has(fileId)) continue
473
+ seenFileIds.add(fileId)
474
+ resolved.push({ fileId })
475
+ }
476
+ return resolved
185
477
  }
186
478
 
187
479
  const preset = deps.presets.find(p => p.id === presetId)
188
480
  const slugs = preset?.defaultResourceSlugs ?? []
189
- const resolved: ResolvedResource[] = []
190
481
  const missing: string[] = []
191
482
  for (const slug of slugs) {
192
- const r = toResolvedResource(deps.registry.getResource({ resourceSlug: slug }))
193
- if (r) resolved.push(r)
194
- else missing.push(slug)
483
+ const selection = toResolvedFile(deps.registry.getResource({ resourceSlug: slug }))
484
+ if (!selection) {
485
+ missing.push(slug)
486
+ continue
487
+ }
488
+ if (seenFileIds.has(selection.fileId)) continue
489
+ seenFileIds.add(selection.fileId)
490
+ resolved.push(selection)
195
491
  }
196
492
  if (missing.length > 0) {
197
493
  deps.logger.warn(
@@ -202,48 +498,90 @@ function resolveSessionResources(
202
498
  return resolved
203
499
  }
204
500
 
205
- function toResolvedResource(resource: ReturnType<LocalRegistry['getResource']>): ResolvedResource | null {
501
+ function toResolvedFile(resource: ReturnType<LocalRegistry['getResource']>): ResolvedFile | null {
206
502
  if (!resource || !resource.latestRevision) return null
207
503
  const file = resource.latestRevision.file
208
504
  return {
209
- resourceId: resource.id,
210
- slug: resource.slug,
211
- name: resource.name,
212
505
  fileId: file.id,
213
- filename: file.filename,
214
- mimeType: file.mimeType,
506
+ metadata: { slug: resource.slug, name: resource.name ?? resource.slug },
215
507
  }
216
508
  }
217
509
 
218
- async function injectRegistryResource(deps: Deps, sessionId: string, resource: ResolvedResource): Promise<void> {
219
- const file = await deps.registry.readFileById(resource.fileId)
510
+ async function injectRegistryFile(deps: Deps, sessionId: string, selection: ResolvedFile): Promise<void> {
511
+ const file = await deps.registry.readFileById(selection.fileId)
220
512
  if (!file) {
221
- throw new Error(`Registry file not found for resource ${resource.slug} (fileId=${resource.fileId})`)
513
+ if (selection.metadata?.slug) {
514
+ throw new Error(
515
+ `Registry file not found for resource ${selection.metadata.slug} (fileId=${selection.fileId})`,
516
+ )
517
+ }
518
+ deps.logger.warn('Selected fileId did not match the local registry; ignoring', {
519
+ fileId: selection.fileId,
520
+ })
521
+ return
222
522
  }
223
523
 
224
524
  const result = await deps.sessionManager.callPluginMethod(SessionId(sessionId), 'resources.inject', {
225
525
  sessionId,
226
- filename: resource.filename,
227
- mimeType: resource.mimeType,
526
+ filename: file.meta.filename,
527
+ mimeType: file.meta.mimeType,
228
528
  size: file.buffer.length,
229
529
  fileBuffer: file.buffer,
230
- metadata: { slug: resource.slug, name: resource.name ?? resource.slug },
530
+ metadata: selection.metadata,
231
531
  })
232
532
  if (!result.ok) {
233
533
  deps.logger.error('Registry resource injection failed', undefined, {
234
534
  sessionId,
235
- slug: resource.slug,
535
+ fileId: selection.fileId,
236
536
  error: result.error,
237
537
  })
238
- throw new Error(`Failed to inject resource '${resource.slug}': ${result.error.message ?? result.error.type}`)
538
+ throw new Error(`Failed to inject registry file '${selection.fileId}': ${result.error.message}`)
539
+ }
540
+ }
541
+
542
+ async function rollbackCreatedSession(deps: Deps, sessionId: string): Promise<void> {
543
+ let closeAttempted = false
544
+ try {
545
+ const closeResult = await deps.sessionManager.withSessionLease(
546
+ SessionId(sessionId),
547
+ 'standalone:rollback',
548
+ async (session) => {
549
+ closeAttempted = true
550
+ return session.close()
551
+ },
552
+ )
553
+ if (!closeResult.ok) {
554
+ deps.logger.error(
555
+ closeAttempted ? 'Failed to close session during rollback' : 'Failed to load session for rollback',
556
+ undefined,
557
+ { sessionId, error: closeResult.error },
558
+ )
559
+ }
560
+ } catch (error) {
561
+ deps.logger.error(
562
+ closeAttempted ? 'Failed to close session during rollback' : 'Failed to load session for rollback',
563
+ error instanceof Error ? error : new Error(String(error)),
564
+ { sessionId },
565
+ )
239
566
  }
567
+
568
+ await removeWorktreeAfterFailure(deps, sessionId)
240
569
  }
241
570
 
242
- const handlers: Record<string, Handler> = {
243
- 'instances.create': async (
244
- deps,
245
- input: { metadata?: Record<string, unknown>; autoCreateSession?: AutoCreateSessionInput },
246
- ) => {
571
+ async function removeWorktreeAfterFailure(deps: Deps, sessionId: string): Promise<void> {
572
+ try {
573
+ await deps.gitFs.removeSessionWorktree(deps.instance.id, sessionId)
574
+ } catch (error) {
575
+ deps.logger.error(
576
+ 'Failed to remove session worktree during rollback',
577
+ error instanceof Error ? error : new Error(String(error)),
578
+ { sessionId },
579
+ )
580
+ }
581
+ }
582
+
583
+ const handlers: PlatformHandlers = {
584
+ 'instances.create': async (deps, input) => {
247
585
  if (input.metadata !== undefined) {
248
586
  deps.instance.metadata = input.metadata
249
587
  }
@@ -254,6 +592,7 @@ const handlers: Record<string, Handler> = {
254
592
  presetId: input.autoCreateSession.presetId,
255
593
  initialPrompt: input.autoCreateSession.initialPrompt,
256
594
  resourceIds: input.autoCreateSession.resourceIds,
595
+ fileIds: input.autoCreateSession.fileIds,
257
596
  })
258
597
  sessionId = result.sessionId
259
598
  }
@@ -291,51 +630,46 @@ const handlers: Record<string, Handler> = {
291
630
 
292
631
  'instances.archive': async () => ({ ok: true }),
293
632
 
294
- 'sessions.create': async (
295
- deps,
296
- input: { presetId: string; initialPrompt?: string; resourceIds?: string[] },
297
- ) => startSession(deps, input),
633
+ 'sessions.create': async (deps, input) => startSession(deps, input),
298
634
 
299
635
  'sessions.list': async ({ sessionManager }) => {
300
636
  const result = await sessionManager.callManagerMethod('sessions.list', {})
301
637
  if (!result.ok) throw new Error(result.error.message)
302
- return result.value as { sessions: unknown[]; total: number }
638
+ // callManagerMethod is typed Result<unknown>, so the plugin's output schema
639
+ // does not reach us — validate here rather than assert.
640
+ const listed = z.object({ sessions: z.array(sessionMetadataSchema) }).parse(result.value)
641
+ return {
642
+ sessions: listed.sessions.map(s => ({
643
+ id: String(s.sessionId),
644
+ presetId: s.presetId,
645
+ status: s.status,
646
+ createdAt: new Date(s.createdAt).toISOString(),
647
+ })),
648
+ }
303
649
  },
304
650
 
305
- 'tokens.create': async () => ({ token: '' }),
651
+ // Standalone has no auth: the token is empty and never checked. expiresAt is
652
+ // still part of the contract, so hand back a real timestamp rather than
653
+ // omitting the field and hoping no caller reads it.
654
+ 'tokens.create': async () => ({ token: '', expiresAt: new Date(Date.now() + 3600_000).toISOString() }),
306
655
 
307
- 'resources.create': async (
308
- deps,
309
- input: { slug: string; name?: string; description?: string; fileId: string; label?: string },
310
- ) => deps.registry.createResource(input),
656
+ 'resources.create': async (deps, input) => deps.registry.createResource(input),
311
657
 
312
- 'resources.addRevision': async (
313
- deps,
314
- input: { resourceId?: string; resourceSlug?: string; fileId: string; label?: string },
315
- ) => deps.registry.addRevision(input),
658
+ 'resources.addRevision': async (deps, input) => deps.registry.addRevision(input),
316
659
 
317
- 'resources.get': async (deps, input: { resourceId?: string; resourceSlug?: string }) => {
660
+ 'resources.get': async (deps, input) => {
318
661
  const resource = deps.registry.getResource(input)
319
662
  if (!resource) throw new Error('Resource not found')
320
663
  return resource
321
664
  },
322
665
 
323
- 'resources.list': async (deps, _input: { limit?: number; offset?: number }) => ({
666
+ 'resources.list': async (deps, _input) => ({
324
667
  resources: deps.registry.listResources(),
325
668
  }),
326
669
 
327
- 'resources.delete': async (deps, input: { resourceId: string }) => deps.registry.deleteResource(input.resourceId),
328
-
329
- 'sessionFiles.createDownloadUrl': async (
330
- deps,
331
- input: {
332
- instanceId: string
333
- sessionId: string
334
- scope: 'workspace' | 'session'
335
- path: string
336
- ttlSeconds?: number
337
- },
338
- ) => {
670
+ 'resources.delete': async (deps, input) => deps.registry.deleteResource(input.resourceId),
671
+
672
+ 'sessionFiles.createDownloadUrl': async (deps, input) => {
339
673
  if (input.scope !== 'workspace' && input.scope !== 'session') {
340
674
  throw new Error(`Invalid scope: ${input.scope}`)
341
675
  }
@@ -358,7 +692,7 @@ const handlers: Record<string, Handler> = {
358
692
  .split('/')
359
693
  .map(seg => encodeURIComponent(seg))
360
694
  .join('/')
361
- const url = `${deps.publicBaseUrl}/api/v1/instances/${input.instanceId}/sessions/${input.sessionId}/files/${input.scope}/${encodedPath}?token=${encodeURIComponent(token)}`
695
+ const url = `${deps.getPublicBaseUrl()}/api/v1/instances/${input.instanceId}/sessions/${input.sessionId}/files/${input.scope}/${encodedPath}?token=${encodeURIComponent(token)}`
362
696
 
363
697
  return { url, expiresAt: new Date(expiresAt).toISOString() }
364
698
  },