@opengeni/api-router 0.9.0 → 0.11.2

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.
@@ -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
- setSessionGoalStatus,
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
- 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 {
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
- const session = await createSessionForRequest(deps, grant, workspaceId, await c.req.json());
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 getSessionGoal(db, workspaceId, sessionId);
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, changed } = await setSessionGoalStatus(db, workspaceId, sessionId, {
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 (changed) {
503
- await appendAndPublishEvents(db, bus, workspaceId, sessionId, [
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 setSessionGoalStatus(
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
- await appendAndPublishEvents(db, bus, workspaceId, sessionId, [
537
- {
538
- type: "goal.resumed",
539
- payload: {
540
- goalId: goal.id,
541
- text: goal.text,
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
- { db, settings, bus },
1552
- {
1553
- accountId: grant.accountId,
1554
- workspaceId,
1555
- session,
1556
- viewerId,
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
- { db, settings, bus },
1564
- {
1565
- accountId: grant.accountId,
1566
- workspaceId,
1567
- session,
1568
- viewerId,
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
- { db, settings },
1577
- {
1578
- accountId: grant.accountId,
1579
- workspaceId,
1580
- session,
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
- { db, settings, bus },
1607
- {
1608
- accountId: grant.accountId,
1609
- workspaceId,
1610
- session,
1611
- viewerId: result.viewerId,
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
- { db, settings, bus },
1622
- {
1623
- accountId: grant.accountId,
1624
- workspaceId,
1625
- session,
1626
- viewerId: result.viewerId,
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 ({ service, lease }) => {
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
- // Persist the ptyId<->exec-session map fenced to the box's epoch.
1974
- await insertPtySession(db, {
1975
- id: ptyId,
1976
- accountId: ctx.accountId,
1977
- workspaceId: ctx.workspaceId,
1978
- sessionId: ctx.session.id,
1979
- execSessionId: opened.execSessionId,
1980
- leaseEpoch: lease.leaseEpoch,
1981
- cols: req.cols,
1982
- rows: req.rows,
1983
- shell: opened.shell,
1984
- cwd: req.cwd,
1985
- openedBy: ctx.subjectId,
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, ctx.workspaceId, req.ptyId);
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 ({ service }) => {
2025
- const output = await service.ptyWrite(req, pty.execSessionId!, req.data);
2026
- 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, {
2027
2282
  accountId: ctx.accountId,
2028
2283
  workspaceId: ctx.workspaceId,
2284
+ sessionId: ctx.session.id,
2029
2285
  ptyId: req.ptyId,
2030
- execSessionId: pty.execSessionId,
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, ctx.workspaceId, req.ptyId);
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
- if (pty.execSessionId !== null) {
2055
- await withChannelA({ db, settings, bus }, ctx, ({ service }) =>
2056
- service.ptyResize(req, pty.execSessionId!),
2057
- );
2058
- }
2059
- await updatePtySessionActivity(db, {
2060
- accountId: ctx.accountId,
2061
- workspaceId: ctx.workspaceId,
2062
- ptyId: req.ptyId,
2063
- cols: req.cols,
2064
- 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
+ }
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, ctx.workspaceId, req.ptyId);
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, ({ service }) =>
2076
- service.ptyClose(req, pty.execSessionId),
2077
- );
2078
- await closePtySession(db, {
2079
- accountId: ctx.accountId,
2080
- workspaceId: ctx.workspaceId,
2081
- 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);
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);