@roj-ai/standalone-server 0.1.11 → 0.1.13

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.
Files changed (48) hide show
  1. package/dist/files-upload-route.d.ts +36 -0
  2. package/dist/files-upload-route.d.ts.map +1 -0
  3. package/dist/files-upload-route.js +50 -0
  4. package/dist/files-upload-route.js.map +1 -0
  5. package/dist/git-instance-fs.d.ts +60 -0
  6. package/dist/git-instance-fs.d.ts.map +1 -0
  7. package/dist/git-instance-fs.js +164 -0
  8. package/dist/git-instance-fs.js.map +1 -0
  9. package/dist/instance.d.ts +6 -0
  10. package/dist/instance.d.ts.map +1 -1
  11. package/dist/instance.js +5 -0
  12. package/dist/instance.js.map +1 -1
  13. package/dist/local-registry.d.ts +109 -0
  14. package/dist/local-registry.d.ts.map +1 -0
  15. package/dist/local-registry.js +252 -0
  16. package/dist/local-registry.js.map +1 -0
  17. package/dist/main.js +7 -1
  18. package/dist/main.js.map +1 -1
  19. package/dist/platform-api.d.ts +11 -1
  20. package/dist/platform-api.d.ts.map +1 -1
  21. package/dist/platform-api.js +176 -13
  22. package/dist/platform-api.js.map +1 -1
  23. package/dist/server.d.ts +7 -1
  24. package/dist/server.d.ts.map +1 -1
  25. package/dist/server.js +31 -1
  26. package/dist/server.js.map +1 -1
  27. package/dist/session-file-route.d.ts +24 -0
  28. package/dist/session-file-route.d.ts.map +1 -0
  29. package/dist/session-file-route.js +72 -0
  30. package/dist/session-file-route.js.map +1 -0
  31. package/dist/signed-token.d.ts +25 -0
  32. package/dist/signed-token.d.ts.map +1 -0
  33. package/dist/signed-token.js +50 -0
  34. package/dist/signed-token.js.map +1 -0
  35. package/dist/user-config-loader.d.ts.map +1 -1
  36. package/dist/user-config-loader.js +37 -1
  37. package/dist/user-config-loader.js.map +1 -1
  38. package/package.json +5 -5
  39. package/src/files-upload-route.ts +64 -0
  40. package/src/git-instance-fs.ts +187 -0
  41. package/src/instance.ts +12 -1
  42. package/src/local-registry.ts +334 -0
  43. package/src/main.ts +7 -1
  44. package/src/platform-api.ts +251 -13
  45. package/src/server.ts +47 -2
  46. package/src/session-file-route.ts +99 -0
  47. package/src/signed-token.ts +68 -0
  48. package/src/user-config-loader.ts +41 -2
package/src/instance.ts CHANGED
@@ -4,6 +4,10 @@
4
4
  * A standalone server hosts exactly one "instance" — a workspace containing
5
5
  * sessions. The instance id is either supplied via config or auto-generated
6
6
  * on startup and remains stable for the process lifetime.
7
+ *
8
+ * `metadata` is mutated by `instances.create` calls (last write wins). It is
9
+ * a single-tenant local-dev convenience; consumers that need per-instance
10
+ * isolation must run separate processes.
7
11
  */
8
12
 
9
13
  export interface InstanceState {
@@ -11,13 +15,20 @@ export interface InstanceState {
11
15
  name: string
12
16
  createdAt: string
13
17
  presetIds: string[]
18
+ metadata: Record<string, unknown> | null
14
19
  }
15
20
 
16
- export function createInstance(options: { id?: string; name?: string; presetIds: string[] }): InstanceState {
21
+ export function createInstance(options: {
22
+ id?: string
23
+ name?: string
24
+ presetIds: string[]
25
+ metadata?: Record<string, unknown> | null
26
+ }): InstanceState {
17
27
  return {
18
28
  id: options.id ?? 'default',
19
29
  name: options.name ?? 'standalone',
20
30
  createdAt: new Date().toISOString(),
21
31
  presetIds: options.presetIds,
32
+ metadata: options.metadata ?? null,
22
33
  }
23
34
  }
@@ -0,0 +1,334 @@
1
+ /**
2
+ * On-disk file + resource registry for the standalone server.
3
+ *
4
+ * Stands in for the platform's Contember-backed file/resource service. State
5
+ * lives in `{dataPath}/.roj-platform-state.json` (JSON manifest) plus
6
+ * `{dataPath}/.roj-platform-state/files/{fileId}` (raw bytes). Operations are
7
+ * append-only at the resource level (revisions accumulate), so re-running
8
+ * `roj-standalone` between sessions preserves uploads and revisions added
9
+ * during a previous run.
10
+ *
11
+ * Files are content-addressable by SHA-256 — re-uploading the same bytes
12
+ * returns the existing fileId with `deduped: true`, mirroring the platform's
13
+ * /files/upload contract used by `@roj-ai/client/platform.files.upload`.
14
+ */
15
+
16
+ import { createHash, randomUUID } from 'node:crypto'
17
+ import { existsSync } from 'node:fs'
18
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
19
+ import { basename, extname, join } from 'node:path'
20
+ import type { LocalResource, Logger } from '@roj-ai/sdk'
21
+
22
+ interface FileMeta {
23
+ id: string
24
+ filename: string
25
+ mimeType: string
26
+ size: number
27
+ contentHash: string
28
+ createdAt: string
29
+ }
30
+
31
+ interface ResourceRevision {
32
+ id: string
33
+ fileId: string
34
+ label: string | null
35
+ createdAt: string
36
+ }
37
+
38
+ interface ResourceRecord {
39
+ id: string
40
+ slug: string
41
+ name: string | null
42
+ description: string | null
43
+ revisions: ResourceRevision[]
44
+ createdAt: string
45
+ }
46
+
47
+ interface RegistryState {
48
+ files: Record<string, FileMeta>
49
+ resources: Record<string, ResourceRecord>
50
+ }
51
+
52
+ export interface ResourceOutput {
53
+ id: string
54
+ slug: string
55
+ name: string | null
56
+ description: string | null
57
+ latestRevision: {
58
+ id: string
59
+ label: string | null
60
+ file: { id: string; filename: string; mimeType: string; size: number }
61
+ createdAt: string
62
+ } | null
63
+ createdAt: string
64
+ }
65
+
66
+ export interface UploadResult {
67
+ fileId: string
68
+ filename: string
69
+ mimeType: string
70
+ size: number
71
+ deduped: boolean
72
+ }
73
+
74
+ export class LocalRegistry {
75
+ private state: RegistryState = { files: {}, resources: {} }
76
+ private statePath: string
77
+ private filesDir: string
78
+
79
+ constructor(
80
+ private readonly dataPath: string,
81
+ private readonly logger: Logger,
82
+ ) {
83
+ this.statePath = join(dataPath, '.roj-platform-state.json')
84
+ this.filesDir = join(dataPath, '.roj-platform-state', 'files')
85
+ }
86
+
87
+ async init(): Promise<void> {
88
+ await mkdir(this.filesDir, { recursive: true })
89
+ if (existsSync(this.statePath)) {
90
+ try {
91
+ const raw = await readFile(this.statePath, 'utf8')
92
+ this.state = JSON.parse(raw)
93
+ } catch (err) {
94
+ this.logger.warn('Local registry manifest is corrupt, starting fresh', {
95
+ err: err instanceof Error ? err.message : String(err),
96
+ })
97
+ this.state = { files: {}, resources: {} }
98
+ }
99
+ }
100
+ }
101
+
102
+ async uploadFile(args: {
103
+ buffer?: Buffer
104
+ filename: string
105
+ mimeType: string
106
+ contentHash?: string
107
+ }): Promise<UploadResult | { error: 'file-required'; contentHash: string }> {
108
+ // Phase 1 — preflight by hash. If we already have the bytes, return the
109
+ // existing fileId without needing the body.
110
+ if (args.contentHash) {
111
+ const existing = Object.values(this.state.files).find(f => f.contentHash === args.contentHash)
112
+ if (existing) {
113
+ return {
114
+ fileId: existing.id,
115
+ filename: existing.filename,
116
+ mimeType: existing.mimeType,
117
+ size: existing.size,
118
+ deduped: true,
119
+ }
120
+ }
121
+ if (!args.buffer) {
122
+ return { error: 'file-required', contentHash: args.contentHash }
123
+ }
124
+ }
125
+
126
+ if (!args.buffer) {
127
+ throw new Error('uploadFile requires a buffer when contentHash is unknown')
128
+ }
129
+
130
+ const contentHash = args.contentHash ?? sha256Hex(args.buffer)
131
+
132
+ // Re-check after computing — the client may not have provided a hash but
133
+ // we still want dedupe.
134
+ const matched = Object.values(this.state.files).find(f => f.contentHash === contentHash)
135
+ if (matched) {
136
+ return {
137
+ fileId: matched.id,
138
+ filename: matched.filename,
139
+ mimeType: matched.mimeType,
140
+ size: matched.size,
141
+ deduped: true,
142
+ }
143
+ }
144
+
145
+ const id = randomUUID()
146
+ const meta: FileMeta = {
147
+ id,
148
+ filename: args.filename,
149
+ mimeType: args.mimeType,
150
+ size: args.buffer.length,
151
+ contentHash,
152
+ createdAt: new Date().toISOString(),
153
+ }
154
+ await writeFile(join(this.filesDir, id), args.buffer)
155
+ this.state.files[id] = meta
156
+ await this.persist()
157
+ return { fileId: id, filename: meta.filename, mimeType: meta.mimeType, size: meta.size, deduped: false }
158
+ }
159
+
160
+ async readFileById(fileId: string): Promise<{ meta: FileMeta; buffer: Buffer } | null> {
161
+ const meta = this.state.files[fileId]
162
+ if (!meta) return null
163
+ try {
164
+ const buffer = await readFile(join(this.filesDir, fileId))
165
+ return { meta, buffer }
166
+ } catch {
167
+ return null
168
+ }
169
+ }
170
+
171
+ async createResource(args: {
172
+ slug: string
173
+ name?: string
174
+ description?: string
175
+ fileId: string
176
+ label?: string
177
+ }): Promise<{ resourceId: string; revisionId: string }> {
178
+ if (!this.state.files[args.fileId]) {
179
+ throw new Error(`File not found: ${args.fileId}`)
180
+ }
181
+ const existing = this.findBySlug(args.slug)
182
+ if (existing) {
183
+ throw new Error(`Resource with slug already exists: ${args.slug}`)
184
+ }
185
+ const resourceId = randomUUID()
186
+ const revisionId = randomUUID()
187
+ const now = new Date().toISOString()
188
+ this.state.resources[resourceId] = {
189
+ id: resourceId,
190
+ slug: args.slug,
191
+ name: args.name ?? null,
192
+ description: args.description ?? null,
193
+ revisions: [{ id: revisionId, fileId: args.fileId, label: args.label ?? null, createdAt: now }],
194
+ createdAt: now,
195
+ }
196
+ await this.persist()
197
+ return { resourceId, revisionId }
198
+ }
199
+
200
+ async addRevision(args: {
201
+ resourceId?: string
202
+ resourceSlug?: string
203
+ fileId: string
204
+ label?: string
205
+ }): Promise<{ revisionId: string; noop: boolean }> {
206
+ const record = this.findResource(args)
207
+ if (!record) {
208
+ throw new Error('Resource not found')
209
+ }
210
+ if (!this.state.files[args.fileId]) {
211
+ throw new Error(`File not found: ${args.fileId}`)
212
+ }
213
+ const latest = record.revisions[record.revisions.length - 1]
214
+ const incomingLabel = args.label ?? null
215
+ if (latest && latest.fileId === args.fileId && latest.label === incomingLabel) {
216
+ return { revisionId: latest.id, noop: true }
217
+ }
218
+ const revisionId = randomUUID()
219
+ record.revisions.push({
220
+ id: revisionId,
221
+ fileId: args.fileId,
222
+ label: incomingLabel,
223
+ createdAt: new Date().toISOString(),
224
+ })
225
+ await this.persist()
226
+ return { revisionId, noop: false }
227
+ }
228
+
229
+ getResource(args: { resourceId?: string; resourceSlug?: string }): ResourceOutput | null {
230
+ const record = this.findResource(args)
231
+ return record ? toResourceOutput(record, this.state.files) : null
232
+ }
233
+
234
+ listResources(): ResourceOutput[] {
235
+ return Object.values(this.state.resources).map(r => toResourceOutput(r, this.state.files))
236
+ }
237
+
238
+ async deleteResource(resourceId: string): Promise<{ ok: boolean }> {
239
+ if (!this.state.resources[resourceId]) return { ok: true }
240
+ delete this.state.resources[resourceId]
241
+ await this.persist()
242
+ return { ok: true }
243
+ }
244
+
245
+ /**
246
+ * Idempotent: ensures every config-declared local resource exists in the
247
+ * registry. Skips slugs that already have a record (so previously uploaded
248
+ * revisions survive restarts).
249
+ */
250
+ async bootstrapLocalResources(localResources: LocalResource[]): Promise<void> {
251
+ for (const lr of localResources) {
252
+ if (this.findBySlug(lr.slug)) {
253
+ this.logger.info('Local resource already in registry, skipping bootstrap', { slug: lr.slug })
254
+ continue
255
+ }
256
+ const buffer = await readFile(lr.path)
257
+ const filename = basename(lr.path)
258
+ const mimeType = guessMimeType(filename)
259
+ const upload = await this.uploadFile({ buffer, filename, mimeType })
260
+ if ('error' in upload) {
261
+ throw new Error(`Bootstrap upload failed for ${lr.slug}: ${upload.error}`)
262
+ }
263
+ await this.createResource({
264
+ slug: lr.slug,
265
+ name: lr.name ?? lr.slug,
266
+ fileId: upload.fileId,
267
+ })
268
+ this.logger.info('Bootstrapped local resource', { slug: lr.slug, fileId: upload.fileId, size: upload.size })
269
+ }
270
+ }
271
+
272
+ private async persist(): Promise<void> {
273
+ const tmp = `${this.statePath}.tmp`
274
+ await writeFile(tmp, JSON.stringify(this.state, null, 2), 'utf8')
275
+ await rename(tmp, this.statePath)
276
+ }
277
+
278
+ private findResource(args: { resourceId?: string; resourceSlug?: string }): ResourceRecord | null {
279
+ if (args.resourceId) return this.state.resources[args.resourceId] ?? null
280
+ if (args.resourceSlug) return this.findBySlug(args.resourceSlug)
281
+ return null
282
+ }
283
+
284
+ private findBySlug(slug: string): ResourceRecord | null {
285
+ for (const r of Object.values(this.state.resources)) {
286
+ if (r.slug === slug) return r
287
+ }
288
+ return null
289
+ }
290
+ }
291
+
292
+ function toResourceOutput(record: ResourceRecord, files: Record<string, FileMeta>): ResourceOutput {
293
+ const latest = record.revisions[record.revisions.length - 1]
294
+ const file = latest ? files[latest.fileId] : undefined
295
+ return {
296
+ id: record.id,
297
+ slug: record.slug,
298
+ name: record.name,
299
+ description: record.description,
300
+ latestRevision:
301
+ latest && file
302
+ ? {
303
+ id: latest.id,
304
+ label: latest.label,
305
+ file: { id: file.id, filename: file.filename, mimeType: file.mimeType, size: file.size },
306
+ createdAt: latest.createdAt,
307
+ }
308
+ : null,
309
+ createdAt: record.createdAt,
310
+ }
311
+ }
312
+
313
+ function sha256Hex(buf: Buffer): string {
314
+ return createHash('sha256').update(buf).digest('hex')
315
+ }
316
+
317
+ function guessMimeType(filename: string): string {
318
+ switch (extname(filename).toLowerCase()) {
319
+ case '.zip':
320
+ return 'application/zip'
321
+ case '.json':
322
+ return 'application/json'
323
+ default:
324
+ return 'application/octet-stream'
325
+ }
326
+ }
327
+
328
+ // Exported for tests / smoke scripts that want to nuke local state cleanly.
329
+ export async function clearLocalRegistry(dataPath: string): Promise<void> {
330
+ const statePath = join(dataPath, '.roj-platform-state.json')
331
+ const filesDir = join(dataPath, '.roj-platform-state')
332
+ if (existsSync(statePath)) await rm(statePath)
333
+ if (existsSync(filesDir)) await rm(filesDir, { recursive: true })
334
+ }
package/src/main.ts CHANGED
@@ -20,13 +20,19 @@ async function main() {
20
20
  userConfig = await loadUserConfig(absoluteConfigPath)
21
21
  console.log(`Loaded config from: ${absoluteConfigPath}`)
22
22
  console.log(` Presets: ${userConfig.presets.map(p => p.id).join(', ')}`)
23
+ if (userConfig.localResources?.length) {
24
+ console.log(` Local resources: ${userConfig.localResources.map(r => r.slug).join(', ')}`)
25
+ }
23
26
  } catch (error) {
24
27
  console.error(`Failed to load config from ${absoluteConfigPath}:`)
25
28
  console.error(error instanceof Error ? error.message : String(error))
26
29
  process.exit(1)
27
30
  }
28
31
 
29
- await startStandaloneServer({ presets: userConfig.presets })
32
+ await startStandaloneServer({
33
+ presets: userConfig.presets,
34
+ localResources: userConfig.localResources,
35
+ })
30
36
  }
31
37
 
32
38
  main().catch((error) => {
@@ -14,14 +14,27 @@
14
14
  * - instances.archive — no-op; shutdown the server instead
15
15
  */
16
16
 
17
- import type { Logger, SessionManager } from '@roj-ai/sdk'
17
+ import type { Logger, Preset, SessionManager } from '@roj-ai/sdk'
18
+ import { SessionId } from '@roj-ai/sdk'
19
+ import { randomUUID } from 'node:crypto'
18
20
  import { Hono } from 'hono'
21
+ import type { GitInstanceFs } from './git-instance-fs.js'
19
22
  import type { InstanceState } from './instance.js'
23
+ import type { LocalRegistry } from './local-registry.js'
24
+ import { signFileToken } from './signed-token.js'
20
25
 
21
26
  interface Deps {
22
27
  instance: InstanceState
23
28
  sessionManager: SessionManager
24
29
  logger: Logger
30
+ presets: Preset[]
31
+ registry: LocalRegistry
32
+ /** Per-instance bare repo + per-session worktree manager. */
33
+ gitFs: GitInstanceFs
34
+ /** HMAC secret for signing download tokens (`sessionFiles.createDownloadUrl`). */
35
+ tokenSecret: string
36
+ /** Externally-reachable base URL (e.g. `http://localhost:8765`) used when minting download URLs. */
37
+ publicBaseUrl: string
25
38
  }
26
39
 
27
40
  interface RpcEnvelope {
@@ -77,11 +90,180 @@ async function dispatch(
77
90
 
78
91
  type Handler = (deps: Deps, input: any) => Promise<unknown>
79
92
 
93
+ interface AutoCreateSessionInput {
94
+ presetId: string
95
+ initialPrompt?: string
96
+ resourceIds?: string[]
97
+ fileIds?: string[]
98
+ blocking?: boolean
99
+ }
100
+
101
+ // Shared by `instances.create.autoCreateSession` and `sessions.create`. Creates
102
+ // a session via the SDK session manager, injects any matching local resources,
103
+ // then (if set) pushes `initialPrompt` as a user-chat message — order matters:
104
+ // resources must land in the workspace before the agent's first inference so
105
+ // the agent sees a non-empty workspace. Mirrors roj-platform's project-init
106
+ // (inject-resources → activatePendingSession with initialPrompt).
107
+ //
108
+ // SessionId is minted up-front (rather than letting the SDK generate one) so we
109
+ // can create the matching git worktree before the SDK initializes the session
110
+ // — the worktree path is then passed through as `workspaceDir`.
111
+ async function startSession(
112
+ deps: Deps,
113
+ input: { presetId: string; initialPrompt?: string; resourceIds?: string[] },
114
+ ): Promise<{ sessionId: string }> {
115
+ const sessionId = randomUUID()
116
+ const workspaceDir = await deps.gitFs.addSessionWorktree(deps.instance.id, sessionId)
117
+
118
+ const created = await deps.sessionManager.callManagerMethod('sessions.create', {
119
+ presetId: input.presetId,
120
+ sessionId,
121
+ workspaceDir,
122
+ })
123
+ 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)
127
+ throw new Error(created.error.message)
128
+ }
129
+
130
+ const resources = resolveSessionResources(deps, input.presetId, input.resourceIds)
131
+ for (const resource of resources) {
132
+ await injectRegistryResource(deps, sessionId, resource)
133
+ }
134
+
135
+ if (input.initialPrompt) {
136
+ const sent = await deps.sessionManager.callPluginMethod(SessionId(sessionId), 'user-chat.sendMessage', {
137
+ content: input.initialPrompt,
138
+ })
139
+ if (!sent.ok) {
140
+ deps.logger.warn('Failed to deliver initialPrompt', {
141
+ sessionId,
142
+ error: sent.error,
143
+ })
144
+ }
145
+ }
146
+
147
+ return { sessionId }
148
+ }
149
+
150
+ interface ResolvedResource {
151
+ resourceId: string
152
+ slug: string
153
+ name: string | null
154
+ fileId: string
155
+ filename: string
156
+ mimeType: string
157
+ }
158
+
159
+ // 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(
164
+ deps: Deps,
165
+ presetId: string,
166
+ inputResourceIds: string[] | undefined,
167
+ ): ResolvedResource[] {
168
+ if (inputResourceIds && inputResourceIds.length > 0) {
169
+ const matched: ResolvedResource[] = []
170
+ const unmatched: string[] = []
171
+ for (const id of inputResourceIds) {
172
+ const resource =
173
+ 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)
177
+ }
178
+ if (unmatched.length > 0) {
179
+ deps.logger.warn('Some input resourceIds did not match the local registry; ignoring', {
180
+ unmatched,
181
+ availableSlugs: deps.registry.listResources().map(r => r.slug),
182
+ })
183
+ }
184
+ if (matched.length > 0) return matched
185
+ }
186
+
187
+ const preset = deps.presets.find(p => p.id === presetId)
188
+ const slugs = preset?.defaultResourceSlugs ?? []
189
+ const resolved: ResolvedResource[] = []
190
+ const missing: string[] = []
191
+ 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)
195
+ }
196
+ if (missing.length > 0) {
197
+ deps.logger.warn(
198
+ "Preset declares defaultResourceSlugs not present in the local registry; those slots will be skipped",
199
+ { presetId, missing, availableSlugs: deps.registry.listResources().map(r => r.slug) },
200
+ )
201
+ }
202
+ return resolved
203
+ }
204
+
205
+ function toResolvedResource(resource: ReturnType<LocalRegistry['getResource']>): ResolvedResource | null {
206
+ if (!resource || !resource.latestRevision) return null
207
+ const file = resource.latestRevision.file
208
+ return {
209
+ resourceId: resource.id,
210
+ slug: resource.slug,
211
+ name: resource.name,
212
+ fileId: file.id,
213
+ filename: file.filename,
214
+ mimeType: file.mimeType,
215
+ }
216
+ }
217
+
218
+ async function injectRegistryResource(deps: Deps, sessionId: string, resource: ResolvedResource): Promise<void> {
219
+ const file = await deps.registry.readFileById(resource.fileId)
220
+ if (!file) {
221
+ throw new Error(`Registry file not found for resource ${resource.slug} (fileId=${resource.fileId})`)
222
+ }
223
+
224
+ const result = await deps.sessionManager.callPluginMethod(SessionId(sessionId), 'resources.inject', {
225
+ sessionId,
226
+ filename: resource.filename,
227
+ mimeType: resource.mimeType,
228
+ size: file.buffer.length,
229
+ fileBuffer: file.buffer,
230
+ metadata: { slug: resource.slug, name: resource.name ?? resource.slug },
231
+ })
232
+ if (!result.ok) {
233
+ deps.logger.error('Registry resource injection failed', undefined, {
234
+ sessionId,
235
+ slug: resource.slug,
236
+ error: result.error,
237
+ })
238
+ throw new Error(`Failed to inject resource '${resource.slug}': ${result.error.message ?? result.error.type}`)
239
+ }
240
+ }
241
+
80
242
  const handlers: Record<string, Handler> = {
81
- 'instances.create': async ({ instance }) => ({
82
- instanceId: instance.id,
83
- status: 'ready',
84
- }),
243
+ 'instances.create': async (
244
+ deps,
245
+ input: { metadata?: Record<string, unknown>; autoCreateSession?: AutoCreateSessionInput },
246
+ ) => {
247
+ if (input.metadata !== undefined) {
248
+ deps.instance.metadata = input.metadata
249
+ }
250
+
251
+ let sessionId: string | undefined
252
+ if (input.autoCreateSession?.presetId) {
253
+ const result = await startSession(deps, {
254
+ presetId: input.autoCreateSession.presetId,
255
+ initialPrompt: input.autoCreateSession.initialPrompt,
256
+ resourceIds: input.autoCreateSession.resourceIds,
257
+ })
258
+ sessionId = result.sessionId
259
+ }
260
+
261
+ return {
262
+ instanceId: deps.instance.id,
263
+ status: 'ready',
264
+ ...(sessionId ? { sessionId } : {}),
265
+ }
266
+ },
85
267
 
86
268
  'instances.list': async ({ instance }) => ({
87
269
  instances: [instanceSummary(instance)],
@@ -109,13 +291,10 @@ const handlers: Record<string, Handler> = {
109
291
 
110
292
  'instances.archive': async () => ({ ok: true }),
111
293
 
112
- 'sessions.create': async ({ sessionManager }, input: { presetId: string; initialPrompt?: string }) => {
113
- const result = await sessionManager.callManagerMethod('sessions.create', {
114
- presetId: input.presetId,
115
- })
116
- if (!result.ok) throw new Error(result.error.message)
117
- return result.value as { sessionId: string }
118
- },
294
+ 'sessions.create': async (
295
+ deps,
296
+ input: { presetId: string; initialPrompt?: string; resourceIds?: string[] },
297
+ ) => startSession(deps, input),
119
298
 
120
299
  'sessions.list': async ({ sessionManager }) => {
121
300
  const result = await sessionManager.callManagerMethod('sessions.list', {})
@@ -124,6 +303,65 @@ const handlers: Record<string, Handler> = {
124
303
  },
125
304
 
126
305
  'tokens.create': async () => ({ token: '' }),
306
+
307
+ 'resources.create': async (
308
+ deps,
309
+ input: { slug: string; name?: string; description?: string; fileId: string; label?: string },
310
+ ) => deps.registry.createResource(input),
311
+
312
+ 'resources.addRevision': async (
313
+ deps,
314
+ input: { resourceId?: string; resourceSlug?: string; fileId: string; label?: string },
315
+ ) => deps.registry.addRevision(input),
316
+
317
+ 'resources.get': async (deps, input: { resourceId?: string; resourceSlug?: string }) => {
318
+ const resource = deps.registry.getResource(input)
319
+ if (!resource) throw new Error('Resource not found')
320
+ return resource
321
+ },
322
+
323
+ 'resources.list': async (deps, _input: { limit?: number; offset?: number }) => ({
324
+ resources: deps.registry.listResources(),
325
+ }),
326
+
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
+ ) => {
339
+ if (input.scope !== 'workspace' && input.scope !== 'session') {
340
+ throw new Error(`Invalid scope: ${input.scope}`)
341
+ }
342
+ if (!input.path || input.path.includes('..')) {
343
+ throw new Error('Path traversal not allowed')
344
+ }
345
+
346
+ const ttl = Math.min(Math.max(input.ttlSeconds ?? 300, 1), 3600)
347
+ const expiresAt = Date.now() + ttl * 1000
348
+
349
+ const token = signFileToken(deps.tokenSecret, {
350
+ instanceId: input.instanceId,
351
+ sessionId: input.sessionId,
352
+ scope: input.scope,
353
+ path: input.path,
354
+ expiresAt,
355
+ })
356
+
357
+ const encodedPath = input.path
358
+ .split('/')
359
+ .map(seg => encodeURIComponent(seg))
360
+ .join('/')
361
+ const url = `${deps.publicBaseUrl}/api/v1/instances/${input.instanceId}/sessions/${input.sessionId}/files/${input.scope}/${encodedPath}?token=${encodeURIComponent(token)}`
362
+
363
+ return { url, expiresAt: new Date(expiresAt).toISOString() }
364
+ },
127
365
  }
128
366
 
129
367
  function instanceSummary(instance: InstanceState) {
@@ -135,7 +373,7 @@ function instanceSummary(instance: InstanceState) {
135
373
  bundleSlug: 'standalone',
136
374
  bundleRevisionId: '',
137
375
  vcsType: 'none',
138
- metadata: null,
376
+ metadata: instance.metadata,
139
377
  createdAt: instance.createdAt,
140
378
  }
141
379
  }