@opengeni/api-router 0.9.0 → 0.11.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/app.js +1 -1
- package/dist/{chunk-QOYQBYHM.js → chunk-7PQKPKW5.js} +9874 -9276
- package/dist/chunk-7PQKPKW5.js.map +1 -0
- package/dist/index.js +1 -1
- package/package.json +10 -10
- package/src/mcp/server.ts +91 -77
- package/src/routes/machines.ts +22 -2
- package/src/routes/sessions.ts +482 -142
- package/src/sandbox/channel-a.ts +54 -59
- package/src/sandbox/machines.ts +7 -0
- package/src/sandbox/rematerialize.ts +287 -0
- package/src/sandbox/viewer.ts +200 -128
- package/dist/chunk-QOYQBYHM.js.map +0 -1
package/src/routes/sessions.ts
CHANGED
|
@@ -60,13 +60,14 @@ import {
|
|
|
60
60
|
acceptSessionHumanInputResponse,
|
|
61
61
|
clearSessionGoal,
|
|
62
62
|
clearSessionContext,
|
|
63
|
-
closePtySession,
|
|
64
63
|
getOpenPtySession,
|
|
64
|
+
getRetainedProcess,
|
|
65
65
|
getSandbox,
|
|
66
66
|
getSession,
|
|
67
67
|
getSessionForSubject,
|
|
68
68
|
getSessionGoal,
|
|
69
69
|
getSessionHumanInputRequest,
|
|
70
|
+
getSessionGoalWithContinuation,
|
|
70
71
|
getSessionQueueSnapshot,
|
|
71
72
|
getStreamAcknowledgment,
|
|
72
73
|
insertPtySession,
|
|
@@ -86,11 +87,14 @@ import {
|
|
|
86
87
|
SessionPinAccessError,
|
|
87
88
|
SessionListAccessError,
|
|
88
89
|
SessionListCursorError,
|
|
90
|
+
SessionListCursorExpiredError,
|
|
91
|
+
SessionListSnapshotLimitError,
|
|
89
92
|
decodeSessionListCursor,
|
|
90
93
|
revokeViewer,
|
|
91
|
-
|
|
94
|
+
setSessionGoalStatusWithEvent,
|
|
92
95
|
updatePtySessionActivity,
|
|
93
96
|
QueueCommandConflictError,
|
|
97
|
+
NewSessionDraftConflictError,
|
|
94
98
|
SessionCommandIdempotencyError,
|
|
95
99
|
SessionControlConflictError,
|
|
96
100
|
SessionContextBusyError,
|
|
@@ -98,6 +102,9 @@ import {
|
|
|
98
102
|
latestWorkspaceCapture,
|
|
99
103
|
workspaceCaptureAtRevision,
|
|
100
104
|
type AppendEventInput,
|
|
105
|
+
type SandboxOpenPtySessionRow,
|
|
106
|
+
type SandboxPtyProcessIdentity,
|
|
107
|
+
type SandboxRetainedProcess,
|
|
101
108
|
} from "@opengeni/db";
|
|
102
109
|
import {
|
|
103
110
|
appendAndPublishEvents,
|
|
@@ -105,8 +112,8 @@ import {
|
|
|
105
112
|
coalesceSessionEventDeltas,
|
|
106
113
|
publishDurableSessionEvents,
|
|
107
114
|
} from "@opengeni/events";
|
|
108
|
-
import { z } from "zod";
|
|
109
|
-
import { withChannelA } from "../sandbox/channel-a";
|
|
115
|
+
import { z, ZodError } from "zod";
|
|
116
|
+
import { withChannelA, type ChannelAContext, type ChannelAHandle } from "../sandbox/channel-a";
|
|
110
117
|
import { negotiateCapabilities } from "@opengeni/runtime/sandbox";
|
|
111
118
|
import type { Context, Hono, MiddlewareHandler } from "hono";
|
|
112
119
|
import { HTTPException } from "hono/http-exception";
|
|
@@ -131,6 +138,7 @@ import {
|
|
|
131
138
|
viewerHeartbeatIntervalMs,
|
|
132
139
|
type DesktopStreamMint,
|
|
133
140
|
type TerminalStreamMint,
|
|
141
|
+
type ViewerServices,
|
|
134
142
|
} from "../sandbox/viewer";
|
|
135
143
|
import {
|
|
136
144
|
acceptSessionUserMessage,
|
|
@@ -138,10 +146,14 @@ import {
|
|
|
138
146
|
createSessionForRequest,
|
|
139
147
|
deleteHumanQueuePrompt,
|
|
140
148
|
editHumanQueuePrompt,
|
|
149
|
+
getActorNewSessionDraft,
|
|
141
150
|
getHumanComposerDraft,
|
|
142
151
|
moveHumanQueuePrompt,
|
|
143
152
|
readSessionLineage,
|
|
144
153
|
saveHumanComposerDraft,
|
|
154
|
+
saveActorNewSessionDraft,
|
|
155
|
+
SessionSpawnDeniedError,
|
|
156
|
+
sessionSpawnDenialEnvelope,
|
|
145
157
|
steerHumanQueuePrompt,
|
|
146
158
|
updateSessionMcpApprovalPolicy,
|
|
147
159
|
updateSessionTitle,
|
|
@@ -154,8 +166,105 @@ import { assertSessionExists, boundedLimit } from "../http/common";
|
|
|
154
166
|
import { sseSessionStream } from "../http/sse";
|
|
155
167
|
import { serveWorkspaceCapture, serveWorkspaceCaptureFile } from "./workspace-capture";
|
|
156
168
|
|
|
157
|
-
|
|
169
|
+
type SessionRouteDeps = ApiRouteDeps & Pick<ViewerServices, "establishSandboxSession">;
|
|
170
|
+
|
|
171
|
+
export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
158
172
|
const { settings, db, bus, workflowClient, objectStorage } = deps;
|
|
173
|
+
const ptyIdentity = (pty: SandboxOpenPtySessionRow): SandboxPtyProcessIdentity => ({
|
|
174
|
+
leaseId: pty.leaseId,
|
|
175
|
+
sandboxGroupId: pty.sandboxGroupId,
|
|
176
|
+
retainedProcessId: pty.retainedProcessId,
|
|
177
|
+
openAdmissionId: pty.openAdmissionId,
|
|
178
|
+
execSessionId: pty.execSessionId,
|
|
179
|
+
leaseEpoch: pty.leaseEpoch,
|
|
180
|
+
providerBackend: pty.providerBackend,
|
|
181
|
+
providerInstanceId: pty.providerInstanceId,
|
|
182
|
+
routeKind: pty.routeKind,
|
|
183
|
+
routeTargetId: pty.routeTargetId,
|
|
184
|
+
routeEpoch: pty.routeEpoch,
|
|
185
|
+
});
|
|
186
|
+
const adoptPtyProcess = async (
|
|
187
|
+
ctx: ChannelAContext,
|
|
188
|
+
handle: ChannelAHandle,
|
|
189
|
+
pty: SandboxOpenPtySessionRow,
|
|
190
|
+
): Promise<SandboxRetainedProcess> => {
|
|
191
|
+
const process = await getRetainedProcess(db, {
|
|
192
|
+
workspaceId: ctx.workspaceId,
|
|
193
|
+
sessionId: ctx.session.id,
|
|
194
|
+
processId: pty.retainedProcessId,
|
|
195
|
+
});
|
|
196
|
+
if (
|
|
197
|
+
!process ||
|
|
198
|
+
process.state !== "active" ||
|
|
199
|
+
process.ownerActorKind !== "direct" ||
|
|
200
|
+
process.accountId !== ctx.accountId ||
|
|
201
|
+
process.leaseId !== pty.leaseId ||
|
|
202
|
+
process.sandboxGroupId !== pty.sandboxGroupId ||
|
|
203
|
+
process.parentAdmissionId !== pty.openAdmissionId ||
|
|
204
|
+
process.leaseEpoch !== pty.leaseEpoch ||
|
|
205
|
+
process.providerBackend !== pty.providerBackend ||
|
|
206
|
+
process.providerInstanceId !== pty.providerInstanceId ||
|
|
207
|
+
process.routeKind !== pty.routeKind ||
|
|
208
|
+
process.routeTargetId !== pty.routeTargetId ||
|
|
209
|
+
process.routeEpoch !== pty.routeEpoch ||
|
|
210
|
+
process.providerSessionId !== pty.execSessionId ||
|
|
211
|
+
// Only a persistable home backend can currently be reconstructed by an
|
|
212
|
+
// API request without consulting the mutable active pointer.
|
|
213
|
+
process.routeTargetId !== null ||
|
|
214
|
+
handle.lease.id !== process.leaseId ||
|
|
215
|
+
handle.lease.sandboxGroupId !== process.sandboxGroupId ||
|
|
216
|
+
handle.lease.leaseEpoch !== process.leaseEpoch ||
|
|
217
|
+
handle.lease.backend !== process.providerBackend ||
|
|
218
|
+
handle.lease.instanceId !== process.providerInstanceId
|
|
219
|
+
) {
|
|
220
|
+
throw new HTTPException(409, {
|
|
221
|
+
message: "pty retained-process identity is stale; reopen the terminal",
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
handle.routingSession.adoptRetainedProcess({
|
|
225
|
+
process: { id: process.id, providerSessionId: process.providerSessionId },
|
|
226
|
+
backend: {
|
|
227
|
+
sandboxId: null,
|
|
228
|
+
leaseEpoch: process.leaseEpoch,
|
|
229
|
+
providerInstanceId: process.providerInstanceId,
|
|
230
|
+
activeEpoch: process.routeEpoch,
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
return process;
|
|
234
|
+
};
|
|
235
|
+
const emitPtyExited = async (
|
|
236
|
+
ctx: ChannelAContext,
|
|
237
|
+
ptyId: string,
|
|
238
|
+
process: SandboxRetainedProcess,
|
|
239
|
+
): Promise<void> => {
|
|
240
|
+
const exited: TerminalPtyExitedPayload = {
|
|
241
|
+
ptyId,
|
|
242
|
+
exitCode: process.exitCode,
|
|
243
|
+
reason: process.state === "exited" ? "exit" : "lost",
|
|
244
|
+
};
|
|
245
|
+
await appendAndPublishEvents(db, bus, ctx.workspaceId, ctx.session.id, [
|
|
246
|
+
{ type: "terminal.pty.exited", payload: exited },
|
|
247
|
+
]);
|
|
248
|
+
};
|
|
249
|
+
const drainOpenedPty = async (handle: ChannelAHandle, execSessionId: number): Promise<void> => {
|
|
250
|
+
let chars = "\u0004";
|
|
251
|
+
while (handle.routingSession.hasRetainedProcess(execSessionId)) {
|
|
252
|
+
await handle.routingSession.writeStdinForProcessControl({
|
|
253
|
+
sessionId: execSessionId,
|
|
254
|
+
chars,
|
|
255
|
+
yieldTimeMs: 250,
|
|
256
|
+
maxOutputTokens: 128,
|
|
257
|
+
});
|
|
258
|
+
chars = "";
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
const failPtyPersistenceAndDrain = (persistenceError: unknown, drainError: unknown): never => {
|
|
262
|
+
throw new AggregateError(
|
|
263
|
+
[persistenceError, drainError],
|
|
264
|
+
"PTY persistence failed and the exact opened process could not be drained",
|
|
265
|
+
{ cause: drainError },
|
|
266
|
+
);
|
|
267
|
+
};
|
|
159
268
|
const requestSessionAuthorization = new WeakMap<Request, ResolvedSessionAuthorization>();
|
|
160
269
|
const relatedSessionAccessFor = (c: Context): "target" | "root" =>
|
|
161
270
|
requestSessionAuthorization.get(c.req.raw)?.relatedSessionAccess ?? "root";
|
|
@@ -211,13 +320,96 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
211
320
|
};
|
|
212
321
|
app.use("/v1/workspaces/:workspaceId/sessions/:sessionId/*", authorizeSessionHttp);
|
|
213
322
|
|
|
323
|
+
const viewerServices: ViewerServices = {
|
|
324
|
+
db,
|
|
325
|
+
settings,
|
|
326
|
+
bus,
|
|
327
|
+
...(deps.establishSandboxSession
|
|
328
|
+
? { establishSandboxSession: deps.establishSandboxSession }
|
|
329
|
+
: {}),
|
|
330
|
+
};
|
|
331
|
+
|
|
214
332
|
app.post("/v1/workspaces/:workspaceId/sessions", async (c) => {
|
|
215
333
|
const workspaceId = c.req.param("workspaceId");
|
|
216
334
|
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:create");
|
|
217
|
-
|
|
335
|
+
let payload: unknown;
|
|
336
|
+
try {
|
|
337
|
+
payload = await c.req.json();
|
|
338
|
+
} catch {
|
|
339
|
+
return c.json(
|
|
340
|
+
{
|
|
341
|
+
code: "INVALID_SESSION_CREATE_REQUEST",
|
|
342
|
+
message: "Invalid session create request: request body must contain valid JSON",
|
|
343
|
+
},
|
|
344
|
+
422,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
let session: Session;
|
|
348
|
+
try {
|
|
349
|
+
session = await createSessionForRequest(deps, grant, workspaceId, payload);
|
|
350
|
+
} catch (error) {
|
|
351
|
+
return sessionCreateErrorResponse(c, error);
|
|
352
|
+
}
|
|
353
|
+
// Creation has committed by this point. Keep response projection outside
|
|
354
|
+
// the create-rejection boundary so a post-commit policy read cannot be
|
|
355
|
+
// misreported as though the session itself was rejected.
|
|
218
356
|
return c.json(await withEffectivePolicy(deps, workspaceId, session), 202);
|
|
219
357
|
});
|
|
220
358
|
|
|
359
|
+
app.get("/v1/workspaces/:workspaceId/new-session-draft", async (c) => {
|
|
360
|
+
const workspaceId = c.req.param("workspaceId");
|
|
361
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
362
|
+
return c.json(await getActorNewSessionDraft({ settings, db }, grant, workspaceId));
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
app.put("/v1/workspaces/:workspaceId/new-session-draft", async (c) => {
|
|
366
|
+
const workspaceId = c.req.param("workspaceId");
|
|
367
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:create");
|
|
368
|
+
let payload: unknown;
|
|
369
|
+
try {
|
|
370
|
+
payload = await c.req.json();
|
|
371
|
+
} catch {
|
|
372
|
+
return c.json(
|
|
373
|
+
{
|
|
374
|
+
code: "INVALID_NEW_SESSION_DRAFT_REQUEST",
|
|
375
|
+
message: "Invalid new-session draft request: request body must contain valid JSON",
|
|
376
|
+
},
|
|
377
|
+
422,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
try {
|
|
381
|
+
return c.json(
|
|
382
|
+
await saveActorNewSessionDraft(
|
|
383
|
+
{ settings, db, objectStorage },
|
|
384
|
+
grant,
|
|
385
|
+
workspaceId,
|
|
386
|
+
payload,
|
|
387
|
+
),
|
|
388
|
+
);
|
|
389
|
+
} catch (error) {
|
|
390
|
+
if (error instanceof NewSessionDraftConflictError) {
|
|
391
|
+
return c.json(
|
|
392
|
+
{
|
|
393
|
+
code: "NEW_SESSION_DRAFT_CONFLICT",
|
|
394
|
+
message: error.message,
|
|
395
|
+
currentRevision: error.currentRevision,
|
|
396
|
+
},
|
|
397
|
+
409,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
if (error instanceof ZodError) {
|
|
401
|
+
return c.json(
|
|
402
|
+
{
|
|
403
|
+
code: "INVALID_NEW_SESSION_DRAFT_REQUEST",
|
|
404
|
+
message: `Invalid new-session draft request: ${zodErrorFields(error)} failed schema validation`,
|
|
405
|
+
},
|
|
406
|
+
422,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
throw error;
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
|
|
221
413
|
app.get("/v1/workspaces/:workspaceId/sessions", async (c) => {
|
|
222
414
|
const workspaceId = c.req.param("workspaceId");
|
|
223
415
|
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
@@ -234,8 +426,10 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
234
426
|
page = await listSessionsForSubject(db, workspaceId, {
|
|
235
427
|
subjectId: grant.subjectId,
|
|
236
428
|
limit: boundedLimit(query.limit),
|
|
429
|
+
materializeSnapshot: pageView,
|
|
237
430
|
...(query.cursor ? { cursor: query.cursor } : {}),
|
|
238
431
|
...(query.search ? { search: query.search } : {}),
|
|
432
|
+
...(query.pinsOnly ? { pinsOnly: true } : {}),
|
|
239
433
|
...(query.parentSessionId !== undefined ? { parentSessionId: query.parentSessionId } : {}),
|
|
240
434
|
...(authorizationScope ? { authorizationScope } : {}),
|
|
241
435
|
});
|
|
@@ -243,9 +437,20 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
243
437
|
if (error instanceof SessionListAccessError) {
|
|
244
438
|
throw new HTTPException(403, { message: error.message });
|
|
245
439
|
}
|
|
440
|
+
if (error instanceof SessionListCursorExpiredError) {
|
|
441
|
+
// The caller's short-lived snapshot is no longer usable. Keep this
|
|
442
|
+
// distinct from auth, network, and validation failures so clients can
|
|
443
|
+
// rebase a retained continuation exactly once instead of retrying the
|
|
444
|
+
// expired cursor forever.
|
|
445
|
+
throw new HTTPException(410, { message: error.message });
|
|
446
|
+
}
|
|
246
447
|
if (error instanceof SessionListCursorError) {
|
|
247
448
|
throw new HTTPException(400, { message: error.message });
|
|
248
449
|
}
|
|
450
|
+
if (error instanceof SessionListSnapshotLimitError) {
|
|
451
|
+
c.header("Retry-After", "5");
|
|
452
|
+
throw new HTTPException(429, { message: error.message });
|
|
453
|
+
}
|
|
249
454
|
throw error;
|
|
250
455
|
}
|
|
251
456
|
// The page body carries this fact directly. Preserve the historical array
|
|
@@ -471,7 +676,7 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
471
676
|
await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
472
677
|
const sessionId = c.req.param("sessionId");
|
|
473
678
|
await assertSessionExists(db, workspaceId, sessionId);
|
|
474
|
-
const goal = await
|
|
679
|
+
const goal = await getSessionGoalWithContinuation(db, workspaceId, sessionId);
|
|
475
680
|
if (!goal) {
|
|
476
681
|
throw new HTTPException(404, { message: "session goal not found" });
|
|
477
682
|
}
|
|
@@ -494,27 +699,21 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
494
699
|
});
|
|
495
700
|
}
|
|
496
701
|
if (payload.status === "paused") {
|
|
497
|
-
const { goal,
|
|
702
|
+
const { goal, events } = await setSessionGoalStatusWithEvent(db, workspaceId, sessionId, {
|
|
498
703
|
status: "paused",
|
|
499
704
|
...(payload.rationale ? { rationale: payload.rationale } : {}),
|
|
500
705
|
pausedReason: "api",
|
|
706
|
+
event: {
|
|
707
|
+
type: "goal.paused",
|
|
708
|
+
actor: "api",
|
|
709
|
+
reason: "api",
|
|
710
|
+
...(payload.rationale ? { rationale: payload.rationale } : {}),
|
|
711
|
+
},
|
|
501
712
|
});
|
|
502
|
-
if (
|
|
503
|
-
await
|
|
504
|
-
{
|
|
505
|
-
type: "goal.paused",
|
|
506
|
-
payload: {
|
|
507
|
-
goalId: goal.id,
|
|
508
|
-
actor: "api",
|
|
509
|
-
reason: "api",
|
|
510
|
-
...(payload.rationale ? { rationale: payload.rationale } : {}),
|
|
511
|
-
autoContinuations: goal.autoContinuations,
|
|
512
|
-
noProgressStreak: goal.noProgressStreak,
|
|
513
|
-
},
|
|
514
|
-
},
|
|
515
|
-
]);
|
|
713
|
+
if (events.length > 0) {
|
|
714
|
+
await bus.publish(workspaceId, sessionId, events);
|
|
516
715
|
}
|
|
517
|
-
return c.json(goal);
|
|
716
|
+
return c.json((await getSessionGoalWithContinuation(db, workspaceId, sessionId)) ?? goal);
|
|
518
717
|
}
|
|
519
718
|
// Resume: only valid from paused; resets counters and re-arms the loop.
|
|
520
719
|
if (existing.status !== "paused") {
|
|
@@ -522,32 +721,24 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
522
721
|
message: `session goal is ${existing.status}; only paused goals can be resumed`,
|
|
523
722
|
});
|
|
524
723
|
}
|
|
525
|
-
const { goal, changed, workflowWakeRevision } = await
|
|
724
|
+
const { goal, changed, workflowWakeRevision, events } = await setSessionGoalStatusWithEvent(
|
|
526
725
|
db,
|
|
527
726
|
workspaceId,
|
|
528
727
|
sessionId,
|
|
529
728
|
{
|
|
530
729
|
status: "active",
|
|
730
|
+
event: { type: "goal.resumed", actor: "api" },
|
|
531
731
|
},
|
|
532
732
|
);
|
|
533
733
|
// `changed` guards the racing-PATCH case: both requests can pass the
|
|
534
734
|
// status pre-check, but only the transition winner emits and wakes.
|
|
535
735
|
if (changed) {
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
...(goal.successCriteria ? { successCriteria: goal.successCriteria } : {}),
|
|
543
|
-
version: goal.version,
|
|
544
|
-
actor: "api",
|
|
545
|
-
},
|
|
546
|
-
},
|
|
547
|
-
]);
|
|
548
|
-
// signalWithStart restarts an eligible idle workflow so maybeContinueGoal
|
|
549
|
-
// fires. A closed workspace/session gate keeps the resumed goal durable
|
|
550
|
-
// and inert until that gate's own Resume mutation commits its wake.
|
|
736
|
+
if (events.length > 0) {
|
|
737
|
+
await bus.publish(workspaceId, sessionId, events);
|
|
738
|
+
}
|
|
739
|
+
// signalWithStart restarts an eligible idle workflow so the durable goal
|
|
740
|
+
// revision is evaluated. A closed workspace/session gate keeps the
|
|
741
|
+
// revision inert until that gate's own Resume mutation commits its wake.
|
|
551
742
|
if (workflowWakeRevision !== null) {
|
|
552
743
|
await workflowClient.wakeSessionWorkflow({
|
|
553
744
|
accountId: grant.accountId,
|
|
@@ -558,7 +749,7 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
558
749
|
});
|
|
559
750
|
}
|
|
560
751
|
}
|
|
561
|
-
return c.json(goal);
|
|
752
|
+
return c.json((await getSessionGoalWithContinuation(db, workspaceId, sessionId)) ?? goal);
|
|
562
753
|
});
|
|
563
754
|
|
|
564
755
|
app.delete("/v1/workspaces/:workspaceId/sessions/:sessionId/goal", async (c) => {
|
|
@@ -1351,6 +1542,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1351
1542
|
os: session.sandboxOs,
|
|
1352
1543
|
liveness: lease?.liveness ?? "cold",
|
|
1353
1544
|
leaseEpoch: lease?.leaseEpoch ?? 0,
|
|
1545
|
+
workspaceGeneration: lease?.workspaceGeneration ?? null,
|
|
1546
|
+
archiveGeneration: lease?.archiveGeneration ?? null,
|
|
1547
|
+
archiveComplete: lease?.archiveComplete ?? false,
|
|
1354
1548
|
desktopEnabled: settings.sandboxDesktopEnabled,
|
|
1355
1549
|
// Human take-control: when the desktop is available + this policy is on
|
|
1356
1550
|
// (default), the cell is mode "interactive" — the noVNC viewer drives :0
|
|
@@ -1538,6 +1732,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1538
1732
|
viewerId,
|
|
1539
1733
|
liveness: "warm",
|
|
1540
1734
|
leaseEpoch: session.activeEpoch,
|
|
1735
|
+
workspaceGeneration: null,
|
|
1736
|
+
archiveGeneration: null,
|
|
1737
|
+
archiveComplete: false,
|
|
1541
1738
|
sandboxGroupId: session.sandboxGroupId,
|
|
1542
1739
|
viewerHeartbeatIntervalMs: viewerHeartbeatIntervalMs(settings),
|
|
1543
1740
|
dataPlaneUrl: null,
|
|
@@ -1547,40 +1744,31 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1547
1744
|
!streamTokenDegraded(settings)
|
|
1548
1745
|
) {
|
|
1549
1746
|
if (wantDesktop && settings.sandboxDesktopEnabled) {
|
|
1550
|
-
stream = await mintDesktopStream(
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
// No Modal lease for selfhosted-active; the mint routes to the relay.
|
|
1558
|
-
},
|
|
1559
|
-
);
|
|
1747
|
+
stream = await mintDesktopStream(viewerServices, {
|
|
1748
|
+
accountId: grant.accountId,
|
|
1749
|
+
workspaceId,
|
|
1750
|
+
session,
|
|
1751
|
+
viewerId,
|
|
1752
|
+
// No Modal lease for selfhosted-active; the mint routes to the relay.
|
|
1753
|
+
});
|
|
1560
1754
|
}
|
|
1561
1755
|
if (settings.sandboxTerminalEnabled) {
|
|
1562
|
-
terminal = await mintTerminalStream(
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
// No Modal lease for selfhosted-active; the mint routes to the relay.
|
|
1570
|
-
},
|
|
1571
|
-
);
|
|
1756
|
+
terminal = await mintTerminalStream(viewerServices, {
|
|
1757
|
+
accountId: grant.accountId,
|
|
1758
|
+
workspaceId,
|
|
1759
|
+
session,
|
|
1760
|
+
viewerId,
|
|
1761
|
+
// No Modal lease for selfhosted-active; the mint routes to the relay.
|
|
1762
|
+
});
|
|
1572
1763
|
}
|
|
1573
1764
|
}
|
|
1574
1765
|
} else {
|
|
1575
|
-
result = await attachViewer(
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
...(parsed.data.viewerId ? { viewerId: parsed.data.viewerId } : {}),
|
|
1582
|
-
},
|
|
1583
|
-
);
|
|
1766
|
+
result = await attachViewer(viewerServices, {
|
|
1767
|
+
accountId: grant.accountId,
|
|
1768
|
+
workspaceId,
|
|
1769
|
+
session,
|
|
1770
|
+
...(parsed.data.viewerId ? { viewerId: parsed.data.viewerId } : {}),
|
|
1771
|
+
});
|
|
1584
1772
|
|
|
1585
1773
|
// P4.2 — the viewer now holds a WARM box; mint the real pixel cell IN-PROCESS
|
|
1586
1774
|
// (resume by id → ensureDisplayStack → exposeStreamPort) scoped to THIS
|
|
@@ -1602,31 +1790,25 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1602
1790
|
// (and consented above). A terminal-only attach skips it — the box is warm,
|
|
1603
1791
|
// the terminal mint below still runs.
|
|
1604
1792
|
if (wantDesktop && settings.sandboxDesktopEnabled) {
|
|
1605
|
-
stream = await mintDesktopStream(
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
lease,
|
|
1613
|
-
},
|
|
1614
|
-
);
|
|
1793
|
+
stream = await mintDesktopStream(viewerServices, {
|
|
1794
|
+
accountId: grant.accountId,
|
|
1795
|
+
workspaceId,
|
|
1796
|
+
session,
|
|
1797
|
+
viewerId: result.viewerId,
|
|
1798
|
+
lease,
|
|
1799
|
+
});
|
|
1615
1800
|
}
|
|
1616
1801
|
// P5.t — the same warm-box viewer attach also mints the REAL PTY terminal
|
|
1617
1802
|
// address (independent of the desktop toggle). A degraded mint leaves the
|
|
1618
1803
|
// terminal fields null → the client falls back to the sse-events firehose.
|
|
1619
1804
|
if (settings.sandboxTerminalEnabled) {
|
|
1620
|
-
terminal = await mintTerminalStream(
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
lease,
|
|
1628
|
-
},
|
|
1629
|
-
);
|
|
1805
|
+
terminal = await mintTerminalStream(viewerServices, {
|
|
1806
|
+
accountId: grant.accountId,
|
|
1807
|
+
workspaceId,
|
|
1808
|
+
session,
|
|
1809
|
+
viewerId: result.viewerId,
|
|
1810
|
+
lease,
|
|
1811
|
+
});
|
|
1630
1812
|
}
|
|
1631
1813
|
}
|
|
1632
1814
|
}
|
|
@@ -1967,23 +2149,83 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1967
2149
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty", async (c) => {
|
|
1968
2150
|
const ctx = await channelAPreamble(c, "terminal:attach");
|
|
1969
2151
|
const req = await parseChannelABody(c, PtyOpenRequest);
|
|
2152
|
+
if (ctx.session.sandboxBackend === "selfhosted" || ctx.session.activeSandboxId !== null) {
|
|
2153
|
+
throw new HTTPException(409, {
|
|
2154
|
+
message:
|
|
2155
|
+
"durable interactive terminals require the session-home provider route and are unavailable on active swaps or non-persistable routes; use synchronous exec or attach the session home sandbox",
|
|
2156
|
+
});
|
|
2157
|
+
}
|
|
1970
2158
|
const ptyId = crypto.randomUUID();
|
|
1971
|
-
const out = await withChannelA({ db, settings, bus }, ctx, async (
|
|
2159
|
+
const out = await withChannelA({ db, settings, bus }, ctx, async (handle) => {
|
|
2160
|
+
const { service } = handle;
|
|
1972
2161
|
const opened = await service.ptyOpen(req, ptyId);
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
2162
|
+
const execSessionId = opened.execSessionId;
|
|
2163
|
+
const retained =
|
|
2164
|
+
execSessionId === null
|
|
2165
|
+
? null
|
|
2166
|
+
: handle.routingSession.retainedProcessIdentity(execSessionId);
|
|
2167
|
+
const process = retained
|
|
2168
|
+
? await getRetainedProcess(db, {
|
|
2169
|
+
workspaceId: ctx.workspaceId,
|
|
2170
|
+
sessionId: ctx.session.id,
|
|
2171
|
+
processId: retained.id,
|
|
2172
|
+
})
|
|
2173
|
+
: null;
|
|
2174
|
+
if (
|
|
2175
|
+
execSessionId === null ||
|
|
2176
|
+
!retained ||
|
|
2177
|
+
!process ||
|
|
2178
|
+
process.state !== "active" ||
|
|
2179
|
+
process.ownerActorKind !== "direct" ||
|
|
2180
|
+
process.providerSessionId !== execSessionId ||
|
|
2181
|
+
process.routeTargetId !== null ||
|
|
2182
|
+
process.leaseId !== handle.lease.id ||
|
|
2183
|
+
process.sandboxGroupId !== handle.lease.sandboxGroupId ||
|
|
2184
|
+
process.leaseEpoch !== handle.lease.leaseEpoch ||
|
|
2185
|
+
process.providerBackend !== handle.lease.backend ||
|
|
2186
|
+
process.providerInstanceId !== handle.lease.instanceId
|
|
2187
|
+
) {
|
|
2188
|
+
if (execSessionId !== null && handle.routingSession.hasRetainedProcess(execSessionId)) {
|
|
2189
|
+
await drainOpenedPty(handle, execSessionId);
|
|
2190
|
+
}
|
|
2191
|
+
throw new HTTPException(409, {
|
|
2192
|
+
message: "interactive terminal did not acquire durable process authority",
|
|
2193
|
+
});
|
|
2194
|
+
}
|
|
2195
|
+
const identity: SandboxPtyProcessIdentity = {
|
|
2196
|
+
leaseId: process.leaseId,
|
|
2197
|
+
sandboxGroupId: process.sandboxGroupId,
|
|
2198
|
+
retainedProcessId: process.id,
|
|
2199
|
+
openAdmissionId: process.parentAdmissionId,
|
|
2200
|
+
execSessionId: process.providerSessionId,
|
|
2201
|
+
leaseEpoch: process.leaseEpoch,
|
|
2202
|
+
providerBackend: process.providerBackend,
|
|
2203
|
+
providerInstanceId: process.providerInstanceId,
|
|
2204
|
+
routeKind: process.routeKind,
|
|
2205
|
+
routeTargetId: process.routeTargetId,
|
|
2206
|
+
routeEpoch: process.routeEpoch,
|
|
2207
|
+
};
|
|
2208
|
+
try {
|
|
2209
|
+
await insertPtySession(db, {
|
|
2210
|
+
id: ptyId,
|
|
2211
|
+
accountId: ctx.accountId,
|
|
2212
|
+
workspaceId: ctx.workspaceId,
|
|
2213
|
+
sessionId: ctx.session.id,
|
|
2214
|
+
identity,
|
|
2215
|
+
cols: req.cols,
|
|
2216
|
+
rows: req.rows,
|
|
2217
|
+
shell: opened.shell,
|
|
2218
|
+
cwd: req.cwd,
|
|
2219
|
+
openedBy: ctx.subjectId,
|
|
2220
|
+
});
|
|
2221
|
+
} catch (persistenceError) {
|
|
2222
|
+
try {
|
|
2223
|
+
await drainOpenedPty(handle, execSessionId);
|
|
2224
|
+
} catch (drainError) {
|
|
2225
|
+
failPtyPersistenceAndDrain(persistenceError, drainError);
|
|
2226
|
+
}
|
|
2227
|
+
throw persistenceError;
|
|
2228
|
+
}
|
|
1987
2229
|
// Emit terminal.pty.started + any initial banner output on A1.
|
|
1988
2230
|
const started: TerminalPtyStartedPayload = {
|
|
1989
2231
|
ptyId,
|
|
@@ -2011,24 +2253,52 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
2011
2253
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty/write", async (c) => {
|
|
2012
2254
|
const ctx = await channelAPreamble(c, "terminal:attach");
|
|
2013
2255
|
const req = await parseChannelABody(c, PtyWriteRequest);
|
|
2014
|
-
const pty = await getOpenPtySession(db,
|
|
2256
|
+
const pty = await getOpenPtySession(db, {
|
|
2257
|
+
workspaceId: ctx.workspaceId,
|
|
2258
|
+
sessionId: ctx.session.id,
|
|
2259
|
+
ptyId: req.ptyId,
|
|
2260
|
+
});
|
|
2015
2261
|
if (!pty) {
|
|
2016
2262
|
throw new HTTPException(404, { message: "pty not found or closed" });
|
|
2017
2263
|
}
|
|
2018
|
-
if (pty.execSessionId === null) {
|
|
2019
|
-
throw new HTTPException(409, {
|
|
2020
|
-
message: "interactive terminal unsupported on this backend",
|
|
2021
|
-
});
|
|
2022
|
-
}
|
|
2023
2264
|
let seq = 1;
|
|
2024
|
-
await withChannelA({ db, settings, bus }, ctx, async (
|
|
2025
|
-
|
|
2026
|
-
|
|
2265
|
+
await withChannelA({ db, settings, bus }, ctx, async (handle) => {
|
|
2266
|
+
await adoptPtyProcess(ctx, handle, pty);
|
|
2267
|
+
let output: string;
|
|
2268
|
+
try {
|
|
2269
|
+
output = await handle.service.ptyWrite(req, pty.execSessionId, req.data);
|
|
2270
|
+
} catch (error) {
|
|
2271
|
+
const terminal = await getRetainedProcess(db, {
|
|
2272
|
+
workspaceId: ctx.workspaceId,
|
|
2273
|
+
sessionId: ctx.session.id,
|
|
2274
|
+
processId: pty.retainedProcessId,
|
|
2275
|
+
});
|
|
2276
|
+
if (terminal && terminal.state !== "active") {
|
|
2277
|
+
await emitPtyExited(ctx, req.ptyId, terminal);
|
|
2278
|
+
}
|
|
2279
|
+
throw error;
|
|
2280
|
+
}
|
|
2281
|
+
const updated = await updatePtySessionActivity(db, {
|
|
2027
2282
|
accountId: ctx.accountId,
|
|
2028
2283
|
workspaceId: ctx.workspaceId,
|
|
2284
|
+
sessionId: ctx.session.id,
|
|
2029
2285
|
ptyId: req.ptyId,
|
|
2030
|
-
|
|
2286
|
+
identity: ptyIdentity(pty),
|
|
2031
2287
|
});
|
|
2288
|
+
if (!updated) {
|
|
2289
|
+
const terminal = await getRetainedProcess(db, {
|
|
2290
|
+
workspaceId: ctx.workspaceId,
|
|
2291
|
+
sessionId: ctx.session.id,
|
|
2292
|
+
processId: pty.retainedProcessId,
|
|
2293
|
+
});
|
|
2294
|
+
if (terminal && terminal.state !== "active") {
|
|
2295
|
+
await emitPtyExited(ctx, req.ptyId, terminal);
|
|
2296
|
+
return;
|
|
2297
|
+
}
|
|
2298
|
+
throw new HTTPException(409, {
|
|
2299
|
+
message: "pty identity changed while input was in flight; reopen the terminal",
|
|
2300
|
+
});
|
|
2301
|
+
}
|
|
2032
2302
|
if (output) {
|
|
2033
2303
|
const delta: TerminalPtyOutputDeltaPayload = {
|
|
2034
2304
|
ptyId: req.ptyId,
|
|
@@ -2047,21 +2317,31 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
2047
2317
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty/resize", async (c) => {
|
|
2048
2318
|
const ctx = await channelAPreamble(c, "terminal:attach");
|
|
2049
2319
|
const req = await parseChannelABody(c, PtyResizeRequest);
|
|
2050
|
-
const pty = await getOpenPtySession(db,
|
|
2320
|
+
const pty = await getOpenPtySession(db, {
|
|
2321
|
+
workspaceId: ctx.workspaceId,
|
|
2322
|
+
sessionId: ctx.session.id,
|
|
2323
|
+
ptyId: req.ptyId,
|
|
2324
|
+
});
|
|
2051
2325
|
if (!pty) {
|
|
2052
2326
|
throw new HTTPException(404, { message: "pty not found or closed" });
|
|
2053
2327
|
}
|
|
2054
|
-
|
|
2055
|
-
await
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2328
|
+
await withChannelA({ db, settings, bus }, ctx, async (handle) => {
|
|
2329
|
+
await adoptPtyProcess(ctx, handle, pty);
|
|
2330
|
+
await handle.service.ptyResize(req, pty.execSessionId);
|
|
2331
|
+
const updated = await updatePtySessionActivity(db, {
|
|
2332
|
+
accountId: ctx.accountId,
|
|
2333
|
+
workspaceId: ctx.workspaceId,
|
|
2334
|
+
sessionId: ctx.session.id,
|
|
2335
|
+
ptyId: req.ptyId,
|
|
2336
|
+
identity: ptyIdentity(pty),
|
|
2337
|
+
cols: req.cols,
|
|
2338
|
+
rows: req.rows,
|
|
2339
|
+
});
|
|
2340
|
+
if (!updated) {
|
|
2341
|
+
throw new HTTPException(409, {
|
|
2342
|
+
message: "pty identity changed while resize was in flight; reopen the terminal",
|
|
2343
|
+
});
|
|
2344
|
+
}
|
|
2065
2345
|
});
|
|
2066
2346
|
return c.body(null, 204);
|
|
2067
2347
|
});
|
|
@@ -2069,25 +2349,28 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
2069
2349
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty/close", async (c) => {
|
|
2070
2350
|
const ctx = await channelAPreamble(c, "terminal:attach");
|
|
2071
2351
|
const req = await parseChannelABody(c, PtyCloseRequest);
|
|
2072
|
-
const pty = await getOpenPtySession(db,
|
|
2352
|
+
const pty = await getOpenPtySession(db, {
|
|
2353
|
+
workspaceId: ctx.workspaceId,
|
|
2354
|
+
sessionId: ctx.session.id,
|
|
2355
|
+
ptyId: req.ptyId,
|
|
2356
|
+
});
|
|
2073
2357
|
// Idempotent: closing an already-closed/absent PTY is a 204 no-op.
|
|
2074
2358
|
if (pty) {
|
|
2075
|
-
await withChannelA({ db, settings, bus }, ctx, (
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2359
|
+
await withChannelA({ db, settings, bus }, ctx, async (handle) => {
|
|
2360
|
+
await adoptPtyProcess(ctx, handle, pty);
|
|
2361
|
+
await handle.service.ptyClose(req, pty.execSessionId);
|
|
2362
|
+
const terminal = await getRetainedProcess(db, {
|
|
2363
|
+
workspaceId: ctx.workspaceId,
|
|
2364
|
+
sessionId: ctx.session.id,
|
|
2365
|
+
processId: pty.retainedProcessId,
|
|
2366
|
+
});
|
|
2367
|
+
if (!terminal || terminal.state === "active") {
|
|
2368
|
+
throw new HTTPException(409, {
|
|
2369
|
+
message: "pty close is pending exact provider exit proof; retry",
|
|
2370
|
+
});
|
|
2371
|
+
}
|
|
2372
|
+
await emitPtyExited(ctx, req.ptyId, terminal);
|
|
2082
2373
|
});
|
|
2083
|
-
const exited: TerminalPtyExitedPayload = {
|
|
2084
|
-
ptyId: req.ptyId,
|
|
2085
|
-
exitCode: 0,
|
|
2086
|
-
reason: "exit",
|
|
2087
|
-
};
|
|
2088
|
-
await appendAndPublishEvents(db, bus, ctx.workspaceId, ctx.session.id, [
|
|
2089
|
-
{ type: "terminal.pty.exited", payload: exited },
|
|
2090
|
-
]);
|
|
2091
2374
|
}
|
|
2092
2375
|
return c.body(null, 204);
|
|
2093
2376
|
});
|
|
@@ -2255,6 +2538,7 @@ function sessionListQuery(
|
|
|
2255
2538
|
parentSessionId: string | null | undefined;
|
|
2256
2539
|
cursor: ReturnType<typeof decodeSessionListCursor> | undefined;
|
|
2257
2540
|
search: string | undefined;
|
|
2541
|
+
pinsOnly: boolean;
|
|
2258
2542
|
} {
|
|
2259
2543
|
const parentSessionId = query.parentSessionId;
|
|
2260
2544
|
// "null" = roots only; a uuid = children of that session; anything else is
|
|
@@ -2280,6 +2564,18 @@ function sessionListQuery(
|
|
|
2280
2564
|
message: "search must be at most 200 characters",
|
|
2281
2565
|
});
|
|
2282
2566
|
}
|
|
2567
|
+
if (query.pinsOnly !== undefined && query.pinsOnly !== "true") {
|
|
2568
|
+
throw new HTTPException(400, { message: 'pinsOnly must be the literal "true"' });
|
|
2569
|
+
}
|
|
2570
|
+
const pinsOnly = query.pinsOnly === "true";
|
|
2571
|
+
if (pinsOnly && !allowCursor) {
|
|
2572
|
+
throw new HTTPException(400, { message: 'pinsOnly requires view="page"' });
|
|
2573
|
+
}
|
|
2574
|
+
if (pinsOnly && (rawCursor || parentSessionId !== undefined || search)) {
|
|
2575
|
+
throw new HTTPException(400, {
|
|
2576
|
+
message: "pinsOnly cannot be combined with cursor, parentSessionId, or search",
|
|
2577
|
+
});
|
|
2578
|
+
}
|
|
2283
2579
|
return {
|
|
2284
2580
|
limit: query.limit,
|
|
2285
2581
|
parentSessionId:
|
|
@@ -2290,6 +2586,7 @@ function sessionListQuery(
|
|
|
2290
2586
|
: parentSessionId,
|
|
2291
2587
|
cursor,
|
|
2292
2588
|
search: search || undefined,
|
|
2589
|
+
pinsOnly,
|
|
2293
2590
|
};
|
|
2294
2591
|
}
|
|
2295
2592
|
|
|
@@ -2330,6 +2627,49 @@ function userMessagePayloadHasOwnProperty(value: unknown, key: string): boolean
|
|
|
2330
2627
|
return hasOwnProperty(payload, key);
|
|
2331
2628
|
}
|
|
2332
2629
|
|
|
2630
|
+
/** Stable, value-free JSON errors for only the create-session boundary. */
|
|
2631
|
+
export function sessionCreateErrorResponse(c: Context, error: unknown): Response {
|
|
2632
|
+
if (error instanceof SessionSpawnDeniedError) {
|
|
2633
|
+
return c.json(
|
|
2634
|
+
sessionSpawnDenialEnvelope(error),
|
|
2635
|
+
error.denial.code === "nested_agent_depth_override_forbidden" ? 403 : 409,
|
|
2636
|
+
);
|
|
2637
|
+
}
|
|
2638
|
+
if (error instanceof ZodError) {
|
|
2639
|
+
return c.json(
|
|
2640
|
+
{
|
|
2641
|
+
code: "INVALID_SESSION_CREATE_REQUEST",
|
|
2642
|
+
message: `Invalid session create request: ${zodErrorFields(error)} failed schema validation`,
|
|
2643
|
+
},
|
|
2644
|
+
422,
|
|
2645
|
+
);
|
|
2646
|
+
}
|
|
2647
|
+
if (error instanceof HTTPException && error.status === 422) {
|
|
2648
|
+
return c.json(
|
|
2649
|
+
{
|
|
2650
|
+
code: "SESSION_CREATE_REJECTED",
|
|
2651
|
+
message: error.message,
|
|
2652
|
+
},
|
|
2653
|
+
422,
|
|
2654
|
+
);
|
|
2655
|
+
}
|
|
2656
|
+
throw error;
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
function zodErrorFields(error: ZodError): string {
|
|
2660
|
+
const paths = [
|
|
2661
|
+
...new Set(
|
|
2662
|
+
error.issues.map((issue) => {
|
|
2663
|
+
const path = issue.path.map(String).join(".");
|
|
2664
|
+
return path || "request";
|
|
2665
|
+
}),
|
|
2666
|
+
),
|
|
2667
|
+
];
|
|
2668
|
+
const shown = paths.slice(0, 5);
|
|
2669
|
+
const remainder = paths.length - shown.length;
|
|
2670
|
+
return `${shown.join(", ")}${remainder > 0 ? `, and ${remainder} more` : ""}`;
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2333
2673
|
function commandConflictResponse(c: Context, error: unknown): Response {
|
|
2334
2674
|
if (error instanceof QueueCommandConflictError) {
|
|
2335
2675
|
return c.json({ code: error.code, message: error.message, current: error.current }, 409);
|