@opengeni/api-router 0.7.3 → 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.
@@ -27,19 +27,26 @@ import {
27
27
  SessionEventPayloadMode,
28
28
  SessionEventReadDirection,
29
29
  SessionEventReadMode,
30
+ SessionEventLatestClass,
31
+ SessionEventResultMode,
30
32
  SessionEventSemanticClass,
31
33
  SessionEventType,
34
+ SessionMcpServerId,
35
+ compactSessionEventResult,
36
+ sessionEventLatestClassToSemanticClass,
32
37
  SaveComposerDraftRequest,
33
38
  SteerSessionQueueItemRequest,
34
39
  SteerSessionMessageRequest,
35
40
  TerminalExecRequest,
36
41
  UpdateSessionPinRequest,
37
42
  UpdateSessionGoalRequest,
43
+ UpdateSessionMcpApprovalPolicyRequest,
38
44
  UpdateSessionRequest,
39
45
  ViewerHeartbeatRequest,
40
46
  WORKSPACE_CONTROL_ACTOR_MAX_BYTES,
41
47
  workspaceControlUtf8Bytes,
42
48
  type SandboxBackend,
49
+ type LineageNode,
43
50
  type Session,
44
51
  type SessionAuthorizationOperation,
45
52
  type SessionQueueSnapshot,
@@ -53,13 +60,14 @@ import {
53
60
  acceptSessionHumanInputResponse,
54
61
  clearSessionGoal,
55
62
  clearSessionContext,
56
- closePtySession,
57
63
  getOpenPtySession,
64
+ getRetainedProcess,
58
65
  getSandbox,
59
66
  getSession,
60
67
  getSessionForSubject,
61
68
  getSessionGoal,
62
69
  getSessionHumanInputRequest,
70
+ getSessionGoalWithContinuation,
63
71
  getSessionQueueSnapshot,
64
72
  getStreamAcknowledgment,
65
73
  insertPtySession,
@@ -79,11 +87,14 @@ import {
79
87
  SessionPinAccessError,
80
88
  SessionListAccessError,
81
89
  SessionListCursorError,
90
+ SessionListCursorExpiredError,
91
+ SessionListSnapshotLimitError,
82
92
  decodeSessionListCursor,
83
93
  revokeViewer,
84
- setSessionGoalStatus,
94
+ setSessionGoalStatusWithEvent,
85
95
  updatePtySessionActivity,
86
96
  QueueCommandConflictError,
97
+ NewSessionDraftConflictError,
87
98
  SessionCommandIdempotencyError,
88
99
  SessionControlConflictError,
89
100
  SessionContextBusyError,
@@ -91,6 +102,9 @@ import {
91
102
  latestWorkspaceCapture,
92
103
  workspaceCaptureAtRevision,
93
104
  type AppendEventInput,
105
+ type SandboxOpenPtySessionRow,
106
+ type SandboxPtyProcessIdentity,
107
+ type SandboxRetainedProcess,
94
108
  } from "@opengeni/db";
95
109
  import {
96
110
  appendAndPublishEvents,
@@ -98,8 +112,8 @@ import {
98
112
  coalesceSessionEventDeltas,
99
113
  publishDurableSessionEvents,
100
114
  } from "@opengeni/events";
101
- import { z } from "zod";
102
- import { withChannelA } from "../sandbox/channel-a";
115
+ import { z, ZodError } from "zod";
116
+ import { withChannelA, type ChannelAContext, type ChannelAHandle } from "../sandbox/channel-a";
103
117
  import { negotiateCapabilities } from "@opengeni/runtime/sandbox";
104
118
  import type { Context, Hono, MiddlewareHandler } from "hono";
105
119
  import { HTTPException } from "hono/http-exception";
@@ -124,6 +138,7 @@ import {
124
138
  viewerHeartbeatIntervalMs,
125
139
  type DesktopStreamMint,
126
140
  type TerminalStreamMint,
141
+ type ViewerServices,
127
142
  } from "../sandbox/viewer";
128
143
  import {
129
144
  acceptSessionUserMessage,
@@ -131,20 +146,125 @@ import {
131
146
  createSessionForRequest,
132
147
  deleteHumanQueuePrompt,
133
148
  editHumanQueuePrompt,
149
+ getActorNewSessionDraft,
134
150
  getHumanComposerDraft,
135
151
  moveHumanQueuePrompt,
136
152
  readSessionLineage,
137
153
  saveHumanComposerDraft,
154
+ saveActorNewSessionDraft,
155
+ SessionSpawnDeniedError,
156
+ sessionSpawnDenialEnvelope,
138
157
  steerHumanQueuePrompt,
158
+ updateSessionMcpApprovalPolicy,
139
159
  updateSessionTitle,
140
160
  workflowIdForSession,
161
+ sessionWithEffectiveToolPolicy,
162
+ workspaceSessionToolPolicyDefaultServerIds,
163
+ workspaceSessionToolPolicyServerIds,
141
164
  } from "@opengeni/core";
142
165
  import { assertSessionExists, boundedLimit } from "../http/common";
143
166
  import { sseSessionStream } from "../http/sse";
144
167
  import { serveWorkspaceCapture, serveWorkspaceCaptureFile } from "./workspace-capture";
145
168
 
146
- export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
169
+ type SessionRouteDeps = ApiRouteDeps & Pick<ViewerServices, "establishSandboxSession">;
170
+
171
+ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
147
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
+ };
148
268
  const requestSessionAuthorization = new WeakMap<Request, ResolvedSessionAuthorization>();
149
269
  const relatedSessionAccessFor = (c: Context): "target" | "root" =>
150
270
  requestSessionAuthorization.get(c.req.raw)?.relatedSessionAccess ?? "root";
@@ -200,11 +320,94 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
200
320
  };
201
321
  app.use("/v1/workspaces/:workspaceId/sessions/:sessionId/*", authorizeSessionHttp);
202
322
 
323
+ const viewerServices: ViewerServices = {
324
+ db,
325
+ settings,
326
+ bus,
327
+ ...(deps.establishSandboxSession
328
+ ? { establishSandboxSession: deps.establishSandboxSession }
329
+ : {}),
330
+ };
331
+
203
332
  app.post("/v1/workspaces/:workspaceId/sessions", async (c) => {
204
333
  const workspaceId = c.req.param("workspaceId");
205
334
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:create");
206
- const session = await createSessionForRequest(deps, grant, workspaceId, await c.req.json());
207
- return c.json(session, 202);
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.
356
+ return c.json(await withEffectivePolicy(deps, workspaceId, session), 202);
357
+ });
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
+ }
208
411
  });
209
412
 
210
413
  app.get("/v1/workspaces/:workspaceId/sessions", async (c) => {
@@ -223,8 +426,10 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
223
426
  page = await listSessionsForSubject(db, workspaceId, {
224
427
  subjectId: grant.subjectId,
225
428
  limit: boundedLimit(query.limit),
429
+ materializeSnapshot: pageView,
226
430
  ...(query.cursor ? { cursor: query.cursor } : {}),
227
431
  ...(query.search ? { search: query.search } : {}),
432
+ ...(query.pinsOnly ? { pinsOnly: true } : {}),
228
433
  ...(query.parentSessionId !== undefined ? { parentSessionId: query.parentSessionId } : {}),
229
434
  ...(authorizationScope ? { authorizationScope } : {}),
230
435
  });
@@ -232,24 +437,46 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
232
437
  if (error instanceof SessionListAccessError) {
233
438
  throw new HTTPException(403, { message: error.message });
234
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
+ }
235
447
  if (error instanceof SessionListCursorError) {
236
448
  throw new HTTPException(400, { message: error.message });
237
449
  }
450
+ if (error instanceof SessionListSnapshotLimitError) {
451
+ c.header("Retry-After", "5");
452
+ throw new HTTPException(429, { message: error.message });
453
+ }
238
454
  throw error;
239
455
  }
240
456
  // The page body carries this fact directly. Preserve the historical array
241
457
  // body for older clients while still making its older-pin omission visible
242
458
  // to raw HTTP consumers without changing that response shape.
243
459
  c.header("x-opengeni-pinned-truncated", page.pinnedTruncated === true ? "true" : "false");
460
+ const policy = await loadEffectivePolicyContext(deps, workspaceId);
461
+ const decorate = (session: Session): Session =>
462
+ sessionWithEffectiveToolPolicy(
463
+ session,
464
+ policy.workspaceServerIds,
465
+ policy.workspaceDefaultServerIds,
466
+ );
244
467
  if (pageView) {
245
- return c.json(page);
468
+ return c.json({
469
+ ...page,
470
+ pinned: page.pinned.map(decorate),
471
+ sessions: page.sessions.map(decorate),
472
+ });
246
473
  }
247
474
  // Same-major compatibility: listSessions() has historically returned an
248
475
  // array. Preserve that wire shape while adding personal pin metadata/order;
249
476
  // cursor consumers opt into the additive page view. A query flag rather
250
477
  // than a /sessions/page path is deliberate: an older API safely ignores it
251
478
  // and returns its historical array instead of treating "page" as a UUID.
252
- return c.json([...page.pinned, ...page.sessions]);
479
+ return c.json([...page.pinned, ...page.sessions].map(decorate));
253
480
  });
254
481
 
255
482
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId", async (c) => {
@@ -269,7 +496,7 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
269
496
  if (!session) {
270
497
  throw new HTTPException(404, { message: "session not found" });
271
498
  }
272
- return c.json(session);
499
+ return c.json(await withEffectivePolicy(deps, workspaceId, session));
273
500
  });
274
501
 
275
502
  // Personal pin only: this is organization state for the authenticated member,
@@ -296,7 +523,13 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
296
523
  if (!session) {
297
524
  throw new HTTPException(404, { message: "session not found" });
298
525
  }
299
- return c.json(projectSessionForRelatedAccess(session, relatedSessionAccessFor(c)));
526
+ return c.json(
527
+ await withEffectivePolicy(
528
+ deps,
529
+ workspaceId,
530
+ projectSessionForRelatedAccess(session, relatedSessionAccessFor(c)),
531
+ ),
532
+ );
300
533
  } catch (error) {
301
534
  if (error instanceof SessionPinAccessError) {
302
535
  throw new HTTPException(403, { message: error.message });
@@ -317,7 +550,19 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
317
550
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/lineage", async (c) => {
318
551
  const workspaceId = c.req.param("workspaceId");
319
552
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
320
- return c.json(await readSessionLineage(deps, grant, c.req.param("sessionId")));
553
+ const lineage = await readSessionLineage(deps, grant, c.req.param("sessionId"));
554
+ const policy = await loadEffectivePolicyContext(deps, workspaceId);
555
+ return c.json({
556
+ ...lineage,
557
+ ancestors: lineage.ancestors.map((session) =>
558
+ sessionWithEffectiveToolPolicy(
559
+ session,
560
+ policy.workspaceServerIds,
561
+ policy.workspaceDefaultServerIds,
562
+ ),
563
+ ),
564
+ children: mapLineageNodes(lineage.children, policy),
565
+ });
321
566
  });
322
567
 
323
568
  // Pin (or unpin) the session's Codex account. body { target: "auto" | "<id>" }:
@@ -397,15 +642,41 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
397
642
  if (!session) {
398
643
  throw new HTTPException(404, { message: "session not found" });
399
644
  }
400
- return c.json(session);
645
+ return c.json(await withEffectivePolicy(deps, workspaceId, session));
401
646
  });
402
647
 
648
+ app.patch(
649
+ "/v1/workspaces/:workspaceId/sessions/:sessionId/mcp-servers/:serverId/approval-policy",
650
+ async (c) => {
651
+ const workspaceId = c.req.param("workspaceId");
652
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
653
+ const sessionId = c.req.param("sessionId");
654
+ const parsedServerId = SessionMcpServerId.safeParse(c.req.param("serverId"));
655
+ const payload = UpdateSessionMcpApprovalPolicyRequest.safeParse(
656
+ await c.req.json().catch(() => null),
657
+ );
658
+ if (!parsedServerId.success || !payload.success) {
659
+ throw new HTTPException(400, { message: "invalid MCP approval-policy request" });
660
+ }
661
+ await assertSessionExists(db, workspaceId, sessionId);
662
+ return c.json(
663
+ await updateSessionMcpApprovalPolicy(
664
+ deps,
665
+ grant,
666
+ sessionId,
667
+ parsedServerId.data,
668
+ payload.data.requireApproval,
669
+ ),
670
+ );
671
+ },
672
+ );
673
+
403
674
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/goal", async (c) => {
404
675
  const workspaceId = c.req.param("workspaceId");
405
676
  await requireAccessGrant(c, deps, workspaceId, "sessions:read");
406
677
  const sessionId = c.req.param("sessionId");
407
678
  await assertSessionExists(db, workspaceId, sessionId);
408
- const goal = await getSessionGoal(db, workspaceId, sessionId);
679
+ const goal = await getSessionGoalWithContinuation(db, workspaceId, sessionId);
409
680
  if (!goal) {
410
681
  throw new HTTPException(404, { message: "session goal not found" });
411
682
  }
@@ -428,27 +699,21 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
428
699
  });
429
700
  }
430
701
  if (payload.status === "paused") {
431
- const { goal, changed } = await setSessionGoalStatus(db, workspaceId, sessionId, {
702
+ const { goal, events } = await setSessionGoalStatusWithEvent(db, workspaceId, sessionId, {
432
703
  status: "paused",
433
704
  ...(payload.rationale ? { rationale: payload.rationale } : {}),
434
705
  pausedReason: "api",
706
+ event: {
707
+ type: "goal.paused",
708
+ actor: "api",
709
+ reason: "api",
710
+ ...(payload.rationale ? { rationale: payload.rationale } : {}),
711
+ },
435
712
  });
436
- if (changed) {
437
- await appendAndPublishEvents(db, bus, workspaceId, sessionId, [
438
- {
439
- type: "goal.paused",
440
- payload: {
441
- goalId: goal.id,
442
- actor: "api",
443
- reason: "api",
444
- ...(payload.rationale ? { rationale: payload.rationale } : {}),
445
- autoContinuations: goal.autoContinuations,
446
- noProgressStreak: goal.noProgressStreak,
447
- },
448
- },
449
- ]);
713
+ if (events.length > 0) {
714
+ await bus.publish(workspaceId, sessionId, events);
450
715
  }
451
- return c.json(goal);
716
+ return c.json((await getSessionGoalWithContinuation(db, workspaceId, sessionId)) ?? goal);
452
717
  }
453
718
  // Resume: only valid from paused; resets counters and re-arms the loop.
454
719
  if (existing.status !== "paused") {
@@ -456,32 +721,24 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
456
721
  message: `session goal is ${existing.status}; only paused goals can be resumed`,
457
722
  });
458
723
  }
459
- const { goal, changed, workflowWakeRevision } = await setSessionGoalStatus(
724
+ const { goal, changed, workflowWakeRevision, events } = await setSessionGoalStatusWithEvent(
460
725
  db,
461
726
  workspaceId,
462
727
  sessionId,
463
728
  {
464
729
  status: "active",
730
+ event: { type: "goal.resumed", actor: "api" },
465
731
  },
466
732
  );
467
733
  // `changed` guards the racing-PATCH case: both requests can pass the
468
734
  // status pre-check, but only the transition winner emits and wakes.
469
735
  if (changed) {
470
- await appendAndPublishEvents(db, bus, workspaceId, sessionId, [
471
- {
472
- type: "goal.resumed",
473
- payload: {
474
- goalId: goal.id,
475
- text: goal.text,
476
- ...(goal.successCriteria ? { successCriteria: goal.successCriteria } : {}),
477
- version: goal.version,
478
- actor: "api",
479
- },
480
- },
481
- ]);
482
- // signalWithStart restarts an eligible idle workflow so maybeContinueGoal
483
- // fires. A closed workspace/session gate keeps the resumed goal durable
484
- // 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.
485
742
  if (workflowWakeRevision !== null) {
486
743
  await workflowClient.wakeSessionWorkflow({
487
744
  accountId: grant.accountId,
@@ -492,7 +749,7 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
492
749
  });
493
750
  }
494
751
  }
495
- return c.json(goal);
752
+ return c.json((await getSessionGoalWithContinuation(db, workspaceId, sessionId)) ?? goal);
496
753
  });
497
754
 
498
755
  app.delete("/v1/workspaces/:workspaceId/sessions/:sessionId/goal", async (c) => {
@@ -597,12 +854,27 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
597
854
  "mode",
598
855
  explicitReplay ? "forensic" : "monitoring",
599
856
  );
600
- const latestClass = eventEnumValue(
857
+ const latestRequested = eventEnumValue(
601
858
  c.req.query("latest"),
602
- SessionEventSemanticClass,
859
+ SessionEventLatestClass,
603
860
  "latest",
604
861
  undefined,
605
862
  );
863
+ const latestClass =
864
+ latestRequested === undefined
865
+ ? undefined
866
+ : sessionEventLatestClassToSemanticClass(latestRequested);
867
+ const resultMode = eventEnumValue(
868
+ c.req.query("resultMode") ?? c.req.query("result"),
869
+ SessionEventResultMode,
870
+ "resultMode",
871
+ "events",
872
+ );
873
+ if (resultMode === "compact" && latestClass === undefined) {
874
+ throw new HTTPException(400, {
875
+ message: "resultMode=compact requires latest",
876
+ });
877
+ }
606
878
  if (
607
879
  latestClass &&
608
880
  ["includeTypes", "excludeTypes", "includeClasses", "excludeClasses"].some(
@@ -660,19 +932,39 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
660
932
  compact ? 5000 : mode === "monitoring" ? 250 : 2000,
661
933
  mode === "monitoring" ? 40 : 500,
662
934
  );
935
+ const dbPayloadMode = resultMode === "compact" ? ("full" as const) : payloadMode;
663
936
  const dbPage = await listSessionEventPage(db, workspaceId, sessionId, {
664
937
  after,
665
938
  ...(before !== undefined ? { before } : {}),
666
939
  limit,
667
940
  direction,
668
- payloadMode,
941
+ payloadMode: dbPayloadMode,
669
942
  includeTypes,
670
943
  excludeTypes,
671
944
  includeClasses: latestClass ? [latestClass] : includeClasses,
672
945
  excludeClasses,
673
946
  ...(mode === "monitoring" ? { defaultExcludeTypes: SESSION_EVENT_RAW_DELTA_TYPES } : {}),
947
+ ...(latestClass ? { authoritativeLatest: true } : {}),
674
948
  });
675
949
  const events = dbPage.events;
950
+ if (resultMode === "compact") {
951
+ const event = events[0];
952
+ c.header("X-OpenGeni-Event-Result-Mode", "compact");
953
+ c.header("X-OpenGeni-Event-Result", event ? "found" : "not_found");
954
+ c.header("X-OpenGeni-Event-Mode", mode);
955
+ c.header("X-OpenGeni-Event-Direction", direction);
956
+ c.header("X-OpenGeni-Payload-Mode", "full");
957
+ c.header("X-OpenGeni-Forensic-Exact", "false");
958
+ if (!event) return c.json(null, 200);
959
+ const result = compactSessionEventResult(
960
+ event,
961
+ latestClass!,
962
+ dbPage.coveredSequence ?? { first: event.sequence, last: event.sequence },
963
+ );
964
+ c.header("X-OpenGeni-Covered-First", String(result.coveredSequence.first));
965
+ c.header("X-OpenGeni-Covered-Last", String(result.coveredSequence.last));
966
+ return c.json(result);
967
+ }
676
968
  const projected = compact ? coalesceSessionEventDeltas(events) : events;
677
969
  const page = boundSessionEventHttpPage(projected, {
678
970
  direction,
@@ -1250,6 +1542,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1250
1542
  os: session.sandboxOs,
1251
1543
  liveness: lease?.liveness ?? "cold",
1252
1544
  leaseEpoch: lease?.leaseEpoch ?? 0,
1545
+ workspaceGeneration: lease?.workspaceGeneration ?? null,
1546
+ archiveGeneration: lease?.archiveGeneration ?? null,
1547
+ archiveComplete: lease?.archiveComplete ?? false,
1253
1548
  desktopEnabled: settings.sandboxDesktopEnabled,
1254
1549
  // Human take-control: when the desktop is available + this policy is on
1255
1550
  // (default), the cell is mode "interactive" — the noVNC viewer drives :0
@@ -1437,6 +1732,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1437
1732
  viewerId,
1438
1733
  liveness: "warm",
1439
1734
  leaseEpoch: session.activeEpoch,
1735
+ workspaceGeneration: null,
1736
+ archiveGeneration: null,
1737
+ archiveComplete: false,
1440
1738
  sandboxGroupId: session.sandboxGroupId,
1441
1739
  viewerHeartbeatIntervalMs: viewerHeartbeatIntervalMs(settings),
1442
1740
  dataPlaneUrl: null,
@@ -1446,40 +1744,31 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1446
1744
  !streamTokenDegraded(settings)
1447
1745
  ) {
1448
1746
  if (wantDesktop && settings.sandboxDesktopEnabled) {
1449
- stream = await mintDesktopStream(
1450
- { db, settings, bus },
1451
- {
1452
- accountId: grant.accountId,
1453
- workspaceId,
1454
- session,
1455
- viewerId,
1456
- // No Modal lease for selfhosted-active; the mint routes to the relay.
1457
- },
1458
- );
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
+ });
1459
1754
  }
1460
1755
  if (settings.sandboxTerminalEnabled) {
1461
- terminal = await mintTerminalStream(
1462
- { db, settings, bus },
1463
- {
1464
- accountId: grant.accountId,
1465
- workspaceId,
1466
- session,
1467
- viewerId,
1468
- // No Modal lease for selfhosted-active; the mint routes to the relay.
1469
- },
1470
- );
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
+ });
1471
1763
  }
1472
1764
  }
1473
1765
  } else {
1474
- result = await attachViewer(
1475
- { db, settings },
1476
- {
1477
- accountId: grant.accountId,
1478
- workspaceId,
1479
- session,
1480
- ...(parsed.data.viewerId ? { viewerId: parsed.data.viewerId } : {}),
1481
- },
1482
- );
1766
+ result = await attachViewer(viewerServices, {
1767
+ accountId: grant.accountId,
1768
+ workspaceId,
1769
+ session,
1770
+ ...(parsed.data.viewerId ? { viewerId: parsed.data.viewerId } : {}),
1771
+ });
1483
1772
 
1484
1773
  // P4.2 — the viewer now holds a WARM box; mint the real pixel cell IN-PROCESS
1485
1774
  // (resume by id → ensureDisplayStack → exposeStreamPort) scoped to THIS
@@ -1501,31 +1790,25 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1501
1790
  // (and consented above). A terminal-only attach skips it — the box is warm,
1502
1791
  // the terminal mint below still runs.
1503
1792
  if (wantDesktop && settings.sandboxDesktopEnabled) {
1504
- stream = await mintDesktopStream(
1505
- { db, settings, bus },
1506
- {
1507
- accountId: grant.accountId,
1508
- workspaceId,
1509
- session,
1510
- viewerId: result.viewerId,
1511
- lease,
1512
- },
1513
- );
1793
+ stream = await mintDesktopStream(viewerServices, {
1794
+ accountId: grant.accountId,
1795
+ workspaceId,
1796
+ session,
1797
+ viewerId: result.viewerId,
1798
+ lease,
1799
+ });
1514
1800
  }
1515
1801
  // P5.t — the same warm-box viewer attach also mints the REAL PTY terminal
1516
1802
  // address (independent of the desktop toggle). A degraded mint leaves the
1517
1803
  // terminal fields null → the client falls back to the sse-events firehose.
1518
1804
  if (settings.sandboxTerminalEnabled) {
1519
- terminal = await mintTerminalStream(
1520
- { db, settings, bus },
1521
- {
1522
- accountId: grant.accountId,
1523
- workspaceId,
1524
- session,
1525
- viewerId: result.viewerId,
1526
- lease,
1527
- },
1528
- );
1805
+ terminal = await mintTerminalStream(viewerServices, {
1806
+ accountId: grant.accountId,
1807
+ workspaceId,
1808
+ session,
1809
+ viewerId: result.viewerId,
1810
+ lease,
1811
+ });
1529
1812
  }
1530
1813
  }
1531
1814
  }
@@ -1866,23 +2149,83 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1866
2149
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty", async (c) => {
1867
2150
  const ctx = await channelAPreamble(c, "terminal:attach");
1868
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
+ }
1869
2158
  const ptyId = crypto.randomUUID();
1870
- const out = await withChannelA({ db, settings, bus }, ctx, async ({ service, lease }) => {
2159
+ const out = await withChannelA({ db, settings, bus }, ctx, async (handle) => {
2160
+ const { service } = handle;
1871
2161
  const opened = await service.ptyOpen(req, ptyId);
1872
- // Persist the ptyId<->exec-session map fenced to the box's epoch.
1873
- await insertPtySession(db, {
1874
- id: ptyId,
1875
- accountId: ctx.accountId,
1876
- workspaceId: ctx.workspaceId,
1877
- sessionId: ctx.session.id,
1878
- execSessionId: opened.execSessionId,
1879
- leaseEpoch: lease.leaseEpoch,
1880
- cols: req.cols,
1881
- rows: req.rows,
1882
- shell: opened.shell,
1883
- cwd: req.cwd,
1884
- openedBy: ctx.subjectId,
1885
- });
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
+ }
1886
2229
  // Emit terminal.pty.started + any initial banner output on A1.
1887
2230
  const started: TerminalPtyStartedPayload = {
1888
2231
  ptyId,
@@ -1910,24 +2253,52 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1910
2253
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty/write", async (c) => {
1911
2254
  const ctx = await channelAPreamble(c, "terminal:attach");
1912
2255
  const req = await parseChannelABody(c, PtyWriteRequest);
1913
- const pty = await getOpenPtySession(db, ctx.workspaceId, req.ptyId);
2256
+ const pty = await getOpenPtySession(db, {
2257
+ workspaceId: ctx.workspaceId,
2258
+ sessionId: ctx.session.id,
2259
+ ptyId: req.ptyId,
2260
+ });
1914
2261
  if (!pty) {
1915
2262
  throw new HTTPException(404, { message: "pty not found or closed" });
1916
2263
  }
1917
- if (pty.execSessionId === null) {
1918
- throw new HTTPException(409, {
1919
- message: "interactive terminal unsupported on this backend",
1920
- });
1921
- }
1922
2264
  let seq = 1;
1923
- await withChannelA({ db, settings, bus }, ctx, async ({ service }) => {
1924
- const output = await service.ptyWrite(req, pty.execSessionId!, req.data);
1925
- await updatePtySessionActivity(db, {
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, {
1926
2282
  accountId: ctx.accountId,
1927
2283
  workspaceId: ctx.workspaceId,
2284
+ sessionId: ctx.session.id,
1928
2285
  ptyId: req.ptyId,
1929
- execSessionId: pty.execSessionId,
2286
+ identity: ptyIdentity(pty),
1930
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
+ }
1931
2302
  if (output) {
1932
2303
  const delta: TerminalPtyOutputDeltaPayload = {
1933
2304
  ptyId: req.ptyId,
@@ -1946,21 +2317,31 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1946
2317
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty/resize", async (c) => {
1947
2318
  const ctx = await channelAPreamble(c, "terminal:attach");
1948
2319
  const req = await parseChannelABody(c, PtyResizeRequest);
1949
- const pty = await getOpenPtySession(db, ctx.workspaceId, req.ptyId);
2320
+ const pty = await getOpenPtySession(db, {
2321
+ workspaceId: ctx.workspaceId,
2322
+ sessionId: ctx.session.id,
2323
+ ptyId: req.ptyId,
2324
+ });
1950
2325
  if (!pty) {
1951
2326
  throw new HTTPException(404, { message: "pty not found or closed" });
1952
2327
  }
1953
- if (pty.execSessionId !== null) {
1954
- await withChannelA({ db, settings, bus }, ctx, ({ service }) =>
1955
- service.ptyResize(req, pty.execSessionId!),
1956
- );
1957
- }
1958
- await updatePtySessionActivity(db, {
1959
- accountId: ctx.accountId,
1960
- workspaceId: ctx.workspaceId,
1961
- ptyId: req.ptyId,
1962
- cols: req.cols,
1963
- rows: req.rows,
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
+ }
1964
2345
  });
1965
2346
  return c.body(null, 204);
1966
2347
  });
@@ -1968,25 +2349,28 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1968
2349
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty/close", async (c) => {
1969
2350
  const ctx = await channelAPreamble(c, "terminal:attach");
1970
2351
  const req = await parseChannelABody(c, PtyCloseRequest);
1971
- const pty = await getOpenPtySession(db, ctx.workspaceId, req.ptyId);
2352
+ const pty = await getOpenPtySession(db, {
2353
+ workspaceId: ctx.workspaceId,
2354
+ sessionId: ctx.session.id,
2355
+ ptyId: req.ptyId,
2356
+ });
1972
2357
  // Idempotent: closing an already-closed/absent PTY is a 204 no-op.
1973
2358
  if (pty) {
1974
- await withChannelA({ db, settings, bus }, ctx, ({ service }) =>
1975
- service.ptyClose(req, pty.execSessionId),
1976
- );
1977
- await closePtySession(db, {
1978
- accountId: ctx.accountId,
1979
- workspaceId: ctx.workspaceId,
1980
- ptyId: req.ptyId,
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);
1981
2373
  });
1982
- const exited: TerminalPtyExitedPayload = {
1983
- ptyId: req.ptyId,
1984
- exitCode: 0,
1985
- reason: "exit",
1986
- };
1987
- await appendAndPublishEvents(db, bus, ctx.workspaceId, ctx.session.id, [
1988
- { type: "terminal.pty.exited", payload: exited },
1989
- ]);
1990
2374
  }
1991
2375
  return c.body(null, 204);
1992
2376
  });
@@ -2022,6 +2406,9 @@ export function sessionAuthorizationOperationForHttp(
2022
2406
  return null;
2023
2407
  }
2024
2408
  if (suffix === "/pin" && verb === "PUT") return "session.pin.write";
2409
+ if (/^\/mcp-servers\/[^/]+\/approval-policy$/.test(suffix) && verb === "PATCH") {
2410
+ return "session.mcp.approval_policy.write";
2411
+ }
2025
2412
  if (suffix === "/lineage" && verb === "GET") return "session.lineage.read";
2026
2413
  if (suffix === "/codex-account" && verb === "POST") {
2027
2414
  return "session.codex_account.write";
@@ -2151,6 +2538,7 @@ function sessionListQuery(
2151
2538
  parentSessionId: string | null | undefined;
2152
2539
  cursor: ReturnType<typeof decodeSessionListCursor> | undefined;
2153
2540
  search: string | undefined;
2541
+ pinsOnly: boolean;
2154
2542
  } {
2155
2543
  const parentSessionId = query.parentSessionId;
2156
2544
  // "null" = roots only; a uuid = children of that session; anything else is
@@ -2176,6 +2564,18 @@ function sessionListQuery(
2176
2564
  message: "search must be at most 200 characters",
2177
2565
  });
2178
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
+ }
2179
2579
  return {
2180
2580
  limit: query.limit,
2181
2581
  parentSessionId:
@@ -2186,6 +2586,7 @@ function sessionListQuery(
2186
2586
  : parentSessionId,
2187
2587
  cursor,
2188
2588
  search: search || undefined,
2589
+ pinsOnly,
2189
2590
  };
2190
2591
  }
2191
2592
 
@@ -2226,6 +2627,49 @@ function userMessagePayloadHasOwnProperty(value: unknown, key: string): boolean
2226
2627
  return hasOwnProperty(payload, key);
2227
2628
  }
2228
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
+
2229
2673
  function commandConflictResponse(c: Context, error: unknown): Response {
2230
2674
  if (error instanceof QueueCommandConflictError) {
2231
2675
  return c.json({ code: error.code, message: error.message, current: error.current }, 409);
@@ -2238,3 +2682,44 @@ function commandConflictResponse(c: Context, error: unknown): Response {
2238
2682
  }
2239
2683
  throw error;
2240
2684
  }
2685
+
2686
+ type EffectivePolicyContext = {
2687
+ workspaceServerIds: string[];
2688
+ workspaceDefaultServerIds: string[];
2689
+ };
2690
+
2691
+ async function loadEffectivePolicyContext(
2692
+ deps: ApiRouteDeps,
2693
+ workspaceId: string,
2694
+ ): Promise<EffectivePolicyContext> {
2695
+ const [workspaceServerIds, workspaceDefaultServerIds] = await Promise.all([
2696
+ workspaceSessionToolPolicyServerIds(deps.db, workspaceId, deps.settings),
2697
+ workspaceSessionToolPolicyDefaultServerIds(deps.db, workspaceId, deps.settings),
2698
+ ]);
2699
+ return { workspaceServerIds, workspaceDefaultServerIds };
2700
+ }
2701
+
2702
+ async function withEffectivePolicy(
2703
+ deps: ApiRouteDeps,
2704
+ workspaceId: string,
2705
+ session: Session,
2706
+ ): Promise<Session> {
2707
+ const policy = await loadEffectivePolicyContext(deps, workspaceId);
2708
+ return sessionWithEffectiveToolPolicy(
2709
+ session,
2710
+ policy.workspaceServerIds,
2711
+ policy.workspaceDefaultServerIds,
2712
+ );
2713
+ }
2714
+
2715
+ function mapLineageNodes(nodes: LineageNode[], policy: EffectivePolicyContext): LineageNode[] {
2716
+ return nodes.map((node) => ({
2717
+ ...node,
2718
+ session: sessionWithEffectiveToolPolicy(
2719
+ node.session as Session,
2720
+ policy.workspaceServerIds,
2721
+ policy.workspaceDefaultServerIds,
2722
+ ),
2723
+ children: mapLineageNodes(node.children, policy),
2724
+ }));
2725
+ }