@roj-ai/standalone-server 0.1.36 → 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.
- package/dist/main.js +1 -4
- package/dist/main.js.map +1 -1
- package/dist/platform-api.d.ts +26 -5
- package/dist/platform-api.d.ts.map +1 -1
- package/dist/platform-api.js +331 -55
- package/dist/platform-api.js.map +1 -1
- package/dist/preview-proxy.d.ts +28 -3
- package/dist/preview-proxy.d.ts.map +1 -1
- package/dist/preview-proxy.js +69 -16
- package/dist/preview-proxy.js.map +1 -1
- package/dist/server.d.ts +5 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +83 -37
- package/dist/server.js.map +1 -1
- package/dist/user-config-loader.d.ts.map +1 -1
- package/dist/user-config-loader.js +2 -1
- package/dist/user-config-loader.js.map +1 -1
- package/package.json +9 -7
- package/src/main.ts +1 -4
- package/src/platform-api.ts +445 -111
- package/src/preview-proxy.ts +86 -18
- package/src/server.ts +82 -39
- package/src/user-config-loader.ts +2 -1
package/src/platform-api.ts
CHANGED
|
@@ -14,42 +14,248 @@
|
|
|
14
14
|
* - instances.archive — no-op; shutdown the server instead
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import type {
|
|
18
|
-
import {
|
|
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:
|
|
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:
|
|
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
|
-
|
|
62
|
+
getPublicBaseUrl(): string
|
|
38
63
|
}
|
|
39
64
|
|
|
40
|
-
|
|
41
|
-
method
|
|
42
|
-
input
|
|
43
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
|
151
|
-
resourceId: string
|
|
152
|
-
slug: string
|
|
153
|
-
name: string | null
|
|
432
|
+
interface ResolvedFile {
|
|
154
433
|
fileId: string
|
|
155
|
-
|
|
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
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
function
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
175
|
-
if (
|
|
176
|
-
|
|
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
|
-
|
|
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
|
|
193
|
-
if (
|
|
194
|
-
|
|
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
|
|
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
|
-
|
|
214
|
-
mimeType: file.mimeType,
|
|
506
|
+
metadata: { slug: resource.slug, name: resource.name ?? resource.slug },
|
|
215
507
|
}
|
|
216
508
|
}
|
|
217
509
|
|
|
218
|
-
async function
|
|
219
|
-
const file = await deps.registry.readFileById(
|
|
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
|
-
|
|
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:
|
|
227
|
-
mimeType:
|
|
526
|
+
filename: file.meta.filename,
|
|
527
|
+
mimeType: file.meta.mimeType,
|
|
228
528
|
size: file.buffer.length,
|
|
229
529
|
fileBuffer: file.buffer,
|
|
230
|
-
metadata:
|
|
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
|
-
|
|
535
|
+
fileId: selection.fileId,
|
|
236
536
|
error: result.error,
|
|
237
537
|
})
|
|
238
|
-
throw new Error(`Failed to inject
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
deps,
|
|
245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
666
|
+
'resources.list': async (deps, _input) => ({
|
|
324
667
|
resources: deps.registry.listResources(),
|
|
325
668
|
}),
|
|
326
669
|
|
|
327
|
-
'resources.delete': async (deps, input
|
|
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.
|
|
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
|
},
|