@opengeni/api-router 0.5.3 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/{chunk-3HIA43CC.js → chunk-DO2G3JSB.js} +5184 -2195
  3. package/dist/chunk-DO2G3JSB.js.map +1 -0
  4. package/dist/index.d.ts +2 -1
  5. package/dist/index.js +297 -54
  6. package/dist/index.js.map +1 -1
  7. package/package.json +20 -20
  8. package/src/app.ts +415 -147
  9. package/src/auth/managed-auth.ts +32 -16
  10. package/src/http/auth.ts +8 -1
  11. package/src/http/common.ts +6 -2
  12. package/src/http/sse.ts +27 -6
  13. package/src/index.ts +196 -74
  14. package/src/integrations/oauth-client.ts +403 -120
  15. package/src/integrations/provider-domain.ts +4 -1
  16. package/src/mcp/documents.ts +173 -94
  17. package/src/mcp/server.ts +1517 -692
  18. package/src/mcp/session-view.ts +8 -2
  19. package/src/mcp/toolspace.ts +175 -84
  20. package/src/observability.ts +7 -1
  21. package/src/routes/api-keys.ts +39 -23
  22. package/src/routes/billing.ts +180 -65
  23. package/src/routes/capabilities.ts +17 -8
  24. package/src/routes/catalog-assets.ts +5 -2
  25. package/src/routes/codex.ts +244 -63
  26. package/src/routes/connections.ts +71 -33
  27. package/src/routes/documents.ts +242 -92
  28. package/src/routes/enrollments.ts +100 -70
  29. package/src/routes/environments.ts +205 -136
  30. package/src/routes/files.ts +164 -39
  31. package/src/routes/github.ts +123 -50
  32. package/src/routes/install.ts +9 -2
  33. package/src/routes/machines.ts +9 -8
  34. package/src/routes/packs.ts +141 -89
  35. package/src/routes/rigs.ts +189 -0
  36. package/src/routes/scheduled-tasks.ts +51 -9
  37. package/src/routes/sessions.ts +839 -329
  38. package/src/routes/social.ts +50 -38
  39. package/src/routes/workspace-capture.ts +238 -0
  40. package/src/routes/workspaces.ts +159 -13
  41. package/src/sandbox/access.ts +11 -3
  42. package/src/sandbox/auth-callout.ts +5 -1
  43. package/src/sandbox/channel-a.ts +104 -27
  44. package/src/sandbox/enrollment.ts +13 -3
  45. package/src/sandbox/machines.ts +68 -59
  46. package/src/sandbox/metrics-ingestion.ts +238 -17
  47. package/src/sandbox/viewer.ts +172 -46
  48. package/dist/chunk-3HIA43CC.js.map +0 -1
@@ -2,6 +2,7 @@ import {
2
2
  AcknowledgeStreamRequest,
3
3
  AttachViewerRequest,
4
4
  ClearSessionContextRequest,
5
+ CancelSessionQueueItemRequest,
5
6
  ClientSessionEvent,
6
7
  CompactSessionContextRequest,
7
8
  FsDeleteRequest,
@@ -18,11 +19,12 @@ import {
18
19
  PtyOpenRequest,
19
20
  PtyResizeRequest,
20
21
  PtyWriteRequest,
21
- ReorderSessionTurnsRequest,
22
+ SessionControlRequest,
23
+ SteerSessionMessageRequest,
22
24
  TerminalExecRequest,
25
+ UpdateSessionPinRequest,
23
26
  UpdateSessionGoalRequest,
24
27
  UpdateSessionRequest,
25
- UpdateSessionTurnRequest,
26
28
  ViewerHeartbeatRequest,
27
29
  type SandboxBackend,
28
30
  type Session,
@@ -30,57 +32,79 @@ import {
30
32
  type TerminalPtyOutputDeltaPayload,
31
33
  type TerminalPtyStartedPayload,
32
34
  } from "@opengeni/contracts";
33
- import { resolveContextCompactionMode, streamTokenDegraded } from "@opengeni/config";
35
+ import { streamTokenDegraded } from "@opengeni/config";
34
36
  import {
35
- cancelQueuedSessionTurn,
37
+ cancelQueuedSessionTurnWithVersion,
38
+ acceptSessionApprovalDecision,
39
+ clearSessionGoal,
36
40
  clearSessionContext,
37
41
  closePtySession,
38
42
  getOpenPtySession,
39
43
  getSandbox,
40
44
  getSession,
45
+ getSessionForSubject,
41
46
  getSessionGoal,
47
+ getSessionQueueSnapshot,
42
48
  getStreamAcknowledgment,
43
49
  insertPtySession,
44
50
  listSessionEvents,
45
51
  listSessionIdsInGroup,
46
- listSessions,
52
+ listSessionsForSubject,
47
53
  listSessionTurns,
48
54
  recordStreamAcknowledgment,
49
- reorderQueuedSessionTurns,
50
55
  requestSessionCompaction,
51
- requireSession,
52
56
  setSessionCodexPin,
57
+ withCodexCapacityMutation,
58
+ setSessionPin,
59
+ SessionPinVersionConflictError,
60
+ SessionPinAccessError,
61
+ SessionListAccessError,
62
+ SessionListCursorError,
63
+ decodeSessionListCursor,
53
64
  revokeViewer,
54
65
  setSessionGoalStatus,
55
66
  updatePtySessionActivity,
56
- updateQueuedSessionTurn,
67
+ requestSessionControl,
68
+ SessionQueueConflictError,
69
+ SessionContextBusyError,
70
+ latestWorkspaceCapture,
71
+ workspaceCaptureAtRevision,
57
72
  type AppendEventInput,
58
73
  } from "@opengeni/db";
59
- import { appendAndPublishEvents, coalesceSessionEventDeltas } from "@opengeni/events";
74
+ import {
75
+ appendAndPublishEvents,
76
+ coalesceSessionEventDeltas,
77
+ publishDurableSessionEvents,
78
+ } from "@opengeni/events";
79
+ import { z } from "zod";
60
80
  import { withChannelA } from "../sandbox/channel-a";
61
81
  import { negotiateCapabilities } from "@opengeni/runtime/sandbox";
62
82
  import type { Context, Hono } from "hono";
63
83
  import { HTTPException } from "hono/http-exception";
64
84
  import { requireAccessGrant } from "@opengeni/core";
65
85
  import type { ApiRouteDeps } from "@opengeni/core";
66
- import { attachViewer, detachViewer, heartbeatViewer, mintDesktopStream, mintTerminalStream, readGroupLease, resolveActiveDesktopTransport, viewerHeartbeatIntervalMs, type DesktopStreamMint, type TerminalStreamMint } from "../sandbox/viewer";
67
- import { settingsWithEnabledCapabilityMcpServers, settingsWithSessionMcpServerMetadata } from "@opengeni/core";
68
86
  import {
69
- normalizeResources,
70
- validateFileResources,
71
- validateGitHubRepositorySelection,
72
- validateToolRefs,
73
- } from "@opengeni/core";
87
+ attachViewer,
88
+ detachViewer,
89
+ heartbeatViewer,
90
+ mintDesktopStream,
91
+ mintTerminalStream,
92
+ readGroupLease,
93
+ resolveActiveDesktopTransport,
94
+ viewerHeartbeatIntervalMs,
95
+ type DesktopStreamMint,
96
+ type TerminalStreamMint,
97
+ } from "../sandbox/viewer";
74
98
  import {
75
99
  acceptSessionUserMessage,
76
- assertConfiguredModel,
77
100
  createSessionForRequest,
78
- requireQueuedTurnForApi,
101
+ readSessionLineage,
79
102
  updateSessionTitle,
80
103
  workflowIdForSession,
81
104
  } from "@opengeni/core";
82
105
  import { assertSessionExists, boundedLimit } from "../http/common";
83
106
  import { sseSessionStream } from "../http/sse";
107
+ import { serveWorkspaceCapture, serveWorkspaceCaptureFile } from "./workspace-capture";
84
108
 
85
109
  export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
86
110
  const { settings, db, bus, workflowClient, objectStorage } = deps;
@@ -94,20 +118,97 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
94
118
 
95
119
  app.get("/v1/workspaces/:workspaceId/sessions", async (c) => {
96
120
  const workspaceId = c.req.param("workspaceId");
97
- await requireAccessGrant(c, deps, workspaceId, "sessions:read");
98
- return c.json(await listSessions(db, workspaceId, boundedLimit(c.req.query("limit"))));
121
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
122
+ const pageView = c.req.query("view") === "page";
123
+ const query = sessionListQuery(c.req.query(), pageView);
124
+ let page: Awaited<ReturnType<typeof listSessionsForSubject>>;
125
+ try {
126
+ page = await listSessionsForSubject(db, workspaceId, {
127
+ subjectId: grant.subjectId,
128
+ limit: boundedLimit(query.limit),
129
+ ...(query.cursor ? { cursor: query.cursor } : {}),
130
+ ...(query.search ? { search: query.search } : {}),
131
+ ...(query.parentSessionId !== undefined ? { parentSessionId: query.parentSessionId } : {}),
132
+ });
133
+ } catch (error) {
134
+ if (error instanceof SessionListAccessError) {
135
+ throw new HTTPException(403, { message: error.message });
136
+ }
137
+ if (error instanceof SessionListCursorError) {
138
+ throw new HTTPException(400, { message: error.message });
139
+ }
140
+ throw error;
141
+ }
142
+ if (pageView) {
143
+ return c.json(page);
144
+ }
145
+ // Same-major compatibility: listSessions() has historically returned an
146
+ // array. Preserve that wire shape while adding personal pin metadata/order;
147
+ // cursor consumers opt into the additive page view. A query flag rather
148
+ // than a /sessions/page path is deliberate: an older API safely ignores it
149
+ // and returns its historical array instead of treating "page" as a UUID.
150
+ return c.json([...page.pinned, ...page.sessions]);
99
151
  });
100
152
 
101
153
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId", async (c) => {
102
154
  const workspaceId = c.req.param("workspaceId");
103
- await requireAccessGrant(c, deps, workspaceId, "sessions:read");
104
- const session = await getSession(db, workspaceId, c.req.param("sessionId"));
155
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
156
+ const sessionId = c.req.param("sessionId");
157
+ if (!z.string().uuid().safeParse(sessionId).success) {
158
+ throw new HTTPException(404, { message: "session not found" });
159
+ }
160
+ const session = await getSessionForSubject(db, workspaceId, sessionId, grant.subjectId);
105
161
  if (!session) {
106
162
  throw new HTTPException(404, { message: "session not found" });
107
163
  }
108
164
  return c.json(session);
109
165
  });
110
166
 
167
+ // Personal pin only: this is organization state for the authenticated member,
168
+ // not a mutation of the shared session. It deliberately requires read access
169
+ // (not session control) and returns 404 for a foreign/inaccessible session.
170
+ app.put("/v1/workspaces/:workspaceId/sessions/:sessionId/pin", async (c) => {
171
+ const workspaceId = c.req.param("workspaceId");
172
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
173
+ const sessionId = c.req.param("sessionId");
174
+ if (!z.string().uuid().safeParse(sessionId).success) {
175
+ throw new HTTPException(404, { message: "session not found" });
176
+ }
177
+ const parsed = UpdateSessionPinRequest.safeParse(await c.req.json().catch(() => null));
178
+ if (!parsed.success) {
179
+ throw new HTTPException(400, { message: "invalid session pin request" });
180
+ }
181
+ try {
182
+ const session = await setSessionPin(db, {
183
+ workspaceId,
184
+ subjectId: grant.subjectId,
185
+ sessionId,
186
+ ...parsed.data,
187
+ });
188
+ if (!session) {
189
+ throw new HTTPException(404, { message: "session not found" });
190
+ }
191
+ return c.json(session);
192
+ } catch (error) {
193
+ if (error instanceof SessionPinAccessError) {
194
+ throw new HTTPException(403, { message: error.message });
195
+ }
196
+ if (error instanceof SessionPinVersionConflictError) {
197
+ return c.json(
198
+ { message: "session pin changed in another client", current: error.current },
199
+ 409,
200
+ );
201
+ }
202
+ throw error;
203
+ }
204
+ });
205
+
206
+ app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/lineage", async (c) => {
207
+ const workspaceId = c.req.param("workspaceId");
208
+ await requireAccessGrant(c, deps, workspaceId, "sessions:read");
209
+ return c.json(await readSessionLineage(db, workspaceId, c.req.param("sessionId")));
210
+ });
211
+
111
212
  // Pin (or unpin) the session's Codex account. body { target: "auto" | "<id>" }:
112
213
  // "auto" clears the pin (the session follows the workspace active pointer); a
113
214
  // uuid pins the session to that specific account. The pin applies to the NEXT
@@ -120,13 +221,41 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
120
221
  const body = (await c.req.json()) as { target?: string };
121
222
  const target = typeof body.target === "string" ? body.target : "";
122
223
  if (!target) {
123
- throw new HTTPException(400, { message: "target is required (\"auto\" or an account id)" });
224
+ throw new HTTPException(400, { message: 'target is required ("auto" or an account id)' });
124
225
  }
125
226
  const pinned = target === "auto" ? null : target;
126
- const ok = await setSessionCodexPin(db, workspaceId, sessionId, pinned);
227
+ const mutation = await withCodexCapacityMutation(
228
+ db,
229
+ { workspaceId, reason: "codex_manual_session_pin_changed" },
230
+ async (tx) => {
231
+ const changed = await setSessionCodexPin(tx, workspaceId, sessionId, pinned);
232
+ return { result: changed, changed };
233
+ },
234
+ );
235
+ const ok = mutation.result;
127
236
  if (!ok) {
128
237
  throw new HTTPException(404, { message: "session or codex account not found" });
129
238
  }
239
+ await Promise.allSettled(
240
+ mutation.wakeTargets.map((wake) =>
241
+ workflowClient.signalCodexCapacity
242
+ ? workflowClient.signalCodexCapacity({
243
+ accountId: wake.accountId,
244
+ workspaceId: wake.workspaceId,
245
+ sessionId: wake.sessionId,
246
+ workflowId: wake.workflowId,
247
+ wakeRevision: wake.wakeRevision,
248
+ workflowWakeRevision: wake.workflowWakeRevision,
249
+ })
250
+ : workflowClient.wakeSessionWorkflow({
251
+ accountId: wake.accountId,
252
+ workspaceId: wake.workspaceId,
253
+ sessionId: wake.sessionId,
254
+ workflowId: wake.workflowId,
255
+ wakeRevision: wake.workflowWakeRevision,
256
+ }),
257
+ ),
258
+ );
130
259
  return c.json({ pinned: target === "auto" ? "auto" : target });
131
260
  });
132
261
 
@@ -135,12 +264,15 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
135
264
  // agent writes. Returns the refreshed session, mirroring GET detail.
136
265
  app.patch("/v1/workspaces/:workspaceId/sessions/:sessionId", async (c) => {
137
266
  const workspaceId = c.req.param("workspaceId");
138
- await requireAccessGrant(c, deps, workspaceId, "sessions:control");
267
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
139
268
  const sessionId = c.req.param("sessionId");
140
269
  await assertSessionExists(db, workspaceId, sessionId);
141
270
  const payload = UpdateSessionRequest.parse(await c.req.json());
142
271
  await updateSessionTitle({ db, bus }, workspaceId, sessionId, payload.title, "user");
143
- const session = await getSession(db, workspaceId, sessionId);
272
+ // A session-returning member route must preserve the caller's private pin
273
+ // projection. Returning the generic mapSession() default here would reset a
274
+ // pinned React consumer to false/version 0 after a harmless rename.
275
+ const session = await getSessionForSubject(db, workspaceId, sessionId, grant.subjectId);
144
276
  if (!session) {
145
277
  throw new HTTPException(404, { message: "session not found" });
146
278
  }
@@ -170,7 +302,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
170
302
  throw new HTTPException(404, { message: "session goal not found" });
171
303
  }
172
304
  if (existing.status === "completed") {
173
- throw new HTTPException(409, { message: "session goal is completed; set a new goal instead" });
305
+ throw new HTTPException(409, {
306
+ message: "session goal is completed; set a new goal instead",
307
+ });
174
308
  }
175
309
  if (payload.status === "paused") {
176
310
  const { goal, changed } = await setSessionGoalStatus(db, workspaceId, sessionId, {
@@ -179,45 +313,86 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
179
313
  pausedReason: "api",
180
314
  });
181
315
  if (changed) {
182
- await appendAndPublishEvents(db, bus, workspaceId, sessionId, [{
183
- type: "goal.paused",
184
- payload: {
185
- goalId: goal.id,
186
- actor: "api",
187
- reason: "api",
188
- ...(payload.rationale ? { rationale: payload.rationale } : {}),
189
- autoContinuations: goal.autoContinuations,
190
- noProgressStreak: goal.noProgressStreak,
316
+ await appendAndPublishEvents(db, bus, workspaceId, sessionId, [
317
+ {
318
+ type: "goal.paused",
319
+ payload: {
320
+ goalId: goal.id,
321
+ actor: "api",
322
+ reason: "api",
323
+ ...(payload.rationale ? { rationale: payload.rationale } : {}),
324
+ autoContinuations: goal.autoContinuations,
325
+ noProgressStreak: goal.noProgressStreak,
326
+ },
191
327
  },
192
- }]);
328
+ ]);
193
329
  }
194
330
  return c.json(goal);
195
331
  }
196
332
  // Resume: only valid from paused; resets counters and re-arms the loop.
197
333
  if (existing.status !== "paused") {
198
- throw new HTTPException(409, { message: `session goal is ${existing.status}; only paused goals can be resumed` });
334
+ throw new HTTPException(409, {
335
+ message: `session goal is ${existing.status}; only paused goals can be resumed`,
336
+ });
199
337
  }
200
- const { goal, changed } = await setSessionGoalStatus(db, workspaceId, sessionId, { status: "active" });
338
+ const { goal, changed, workflowWakeRevision } = await setSessionGoalStatus(
339
+ db,
340
+ workspaceId,
341
+ sessionId,
342
+ {
343
+ status: "active",
344
+ },
345
+ );
201
346
  // `changed` guards the racing-PATCH case: both requests can pass the
202
347
  // status pre-check, but only the transition winner emits and wakes.
203
348
  if (changed) {
204
- await appendAndPublishEvents(db, bus, workspaceId, sessionId, [{
205
- type: "goal.resumed",
206
- payload: {
207
- goalId: goal.id,
208
- text: goal.text,
209
- ...(goal.successCriteria ? { successCriteria: goal.successCriteria } : {}),
210
- version: goal.version,
211
- actor: "api",
349
+ await appendAndPublishEvents(db, bus, workspaceId, sessionId, [
350
+ {
351
+ type: "goal.resumed",
352
+ payload: {
353
+ goalId: goal.id,
354
+ text: goal.text,
355
+ ...(goal.successCriteria ? { successCriteria: goal.successCriteria } : {}),
356
+ version: goal.version,
357
+ actor: "api",
358
+ },
212
359
  },
213
- }]);
214
- // signalWithStart restarts a completed workflow whose first claim finds no
215
- // queued turn, so maybeContinueGoal fires resume works on an idle session.
216
- await workflowClient.wakeSessionWorkflow({ accountId: grant.accountId, workspaceId, sessionId, workflowId: workflowIdForSession(sessionId) });
360
+ ]);
361
+ // signalWithStart restarts an eligible idle workflow so maybeContinueGoal
362
+ // fires. A closed workspace/session gate keeps the resumed goal durable
363
+ // and inert until that gate's own Resume mutation commits its wake.
364
+ if (workflowWakeRevision !== null) {
365
+ await workflowClient.wakeSessionWorkflow({
366
+ accountId: grant.accountId,
367
+ workspaceId,
368
+ sessionId,
369
+ workflowId: workflowIdForSession(sessionId),
370
+ wakeRevision: workflowWakeRevision,
371
+ });
372
+ }
217
373
  }
218
374
  return c.json(goal);
219
375
  });
220
376
 
377
+ app.delete("/v1/workspaces/:workspaceId/sessions/:sessionId/goal", async (c) => {
378
+ const workspaceId = c.req.param("workspaceId");
379
+ await requireAccessGrant(c, deps, workspaceId, "sessions:control");
380
+ const sessionId = c.req.param("sessionId");
381
+ await assertSessionExists(db, workspaceId, sessionId);
382
+ const { event } = await clearSessionGoal(db, workspaceId, sessionId);
383
+ if (event) {
384
+ try {
385
+ await bus.publish(workspaceId, sessionId, [event]);
386
+ } catch (error) {
387
+ console.warn(
388
+ `[api] live publish failed for cleared goal ${workspaceId}/${sessionId}; event is durable and reconciles on replay`,
389
+ error,
390
+ );
391
+ }
392
+ }
393
+ return c.body(null, 204);
394
+ });
395
+
221
396
  // Operator context controls (slash-command palette: /clear, /compact). These
222
397
  // are session/operator actions — NOT a structured channel to the agent. Both
223
398
  // require sessions:control.
@@ -232,51 +407,53 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
232
407
  // missing/false confirm is a client error (400), not a server fault.
233
408
  const clearBody = ClearSessionContextRequest.safeParse(await c.req.json().catch(() => ({})));
234
409
  if (!clearBody.success) {
235
- throw new HTTPException(400, { message: "context clear requires an explicit { confirm: true }" });
236
- }
237
- const session = await requireSession(db, workspaceId, sessionId);
238
- // Clearing mid-turn would strand the in-flight RunState (and, in
239
- // requires_action, an awaiting approval whose resume needs that blob).
240
- // Refuse, mirroring the goal 409 guards.
241
- if (session.status === "queued" || session.status === "running" || session.status === "requires_action") {
242
- throw new HTTPException(409, { message: `session is ${session.status}; cannot clear context mid-turn — stop the turn first` });
410
+ throw new HTTPException(400, {
411
+ message: "context clear requires an explicit { confirm: true }",
412
+ });
243
413
  }
244
- const result = await clearSessionContext(db, { accountId: grant.accountId, workspaceId, sessionId });
245
- await appendAndPublishEvents(db, bus, workspaceId, sessionId, [{
246
- type: "session.context.cleared",
247
- payload: {
248
- clearedBy: "api",
249
- supersededItems: result.supersededItems,
250
- markerPosition: result.markerPosition,
414
+ // The database checks this under workspace/session locks so a turn cannot
415
+ // start between an API precheck and the history rewrite.
416
+ const result = await clearSessionContext(db, {
417
+ accountId: grant.accountId,
418
+ workspaceId,
419
+ sessionId,
420
+ }).catch((error: unknown) => {
421
+ if (error instanceof SessionContextBusyError) {
422
+ throw new HTTPException(409, { message: error.message });
423
+ }
424
+ throw error;
425
+ });
426
+ await appendAndPublishEvents(db, bus, workspaceId, sessionId, [
427
+ {
428
+ type: "session.context.cleared",
429
+ payload: {
430
+ clearedBy: "api",
431
+ supersededItems: result.supersededItems,
432
+ markerPosition: result.markerPosition,
433
+ },
251
434
  },
252
- }]);
435
+ ]);
253
436
  return c.body(null, 204);
254
437
  });
255
438
 
256
439
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/context/compact", async (c) => {
257
440
  const workspaceId = c.req.param("workspaceId");
258
- await requireAccessGrant(c, deps, workspaceId, "sessions:control");
441
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
259
442
  const sessionId = c.req.param("sessionId");
260
443
  await assertSessionExists(db, workspaceId, sessionId);
261
444
  CompactSessionContextRequest.parse((await c.req.json().catch(() => ({}))) ?? {});
262
- // /compact is only a TRIGGER it never duplicates the compaction engine.
263
- // Client-managed (Azure) path: set a durable request flag the worker honors
264
- // before the next turn (forced compaction). Server-managed provider / off:
265
- // compaction is automatic or disabled, so this is an honest no-op.
266
- //
267
- // Integration seam with provider-aware-compaction: when that work exposes a
268
- // synchronous "compact now" entry callable from the API process, this route
269
- // should call it directly and return its result; until then the flag +
270
- // worker maybeCompactContext(force) is the minimal honored interface.
271
- const mode = resolveContextCompactionMode(settings);
272
- if (mode === "client") {
273
- await requestSessionCompaction(db, workspaceId, sessionId);
274
- return c.json({ status: "queued", message: "Compaction will run before the next turn." });
275
- }
276
- if (mode === "server") {
277
- return c.json({ status: "noop", message: "This session's provider compacts context automatically; no manual compaction is needed." });
278
- }
279
- return c.json({ status: "noop", message: "Context compaction is disabled for this session." });
445
+ // /compact sets one durable request. The worker clears it only in the same
446
+ // fenced transaction that installs replacement history, so failed or stale
447
+ // attempts cannot lose the request.
448
+ const requested = await requestSessionCompaction(db, workspaceId, sessionId);
449
+ await workflowClient.wakeSessionWorkflow({
450
+ accountId: grant.accountId,
451
+ workspaceId,
452
+ sessionId,
453
+ workflowId: requested.temporalWorkflowId,
454
+ wakeRevision: requested.wakeRevision,
455
+ });
456
+ return c.json({ status: "pending", message: "Compaction will run at the next safe boundary." });
280
457
  });
281
458
 
282
459
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/events", async (c) => {
@@ -302,7 +479,14 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
302
479
  const sessionId = c.req.param("sessionId");
303
480
  await assertSessionExists(db, workspaceId, sessionId);
304
481
  const after = Number(c.req.query("after") ?? c.req.header("Last-Event-ID") ?? 0);
305
- return sseSessionStream(db, bus, workspaceId, sessionId, Number.isFinite(after) ? after : 0, c.req.raw.signal);
482
+ return sseSessionStream(
483
+ db,
484
+ bus,
485
+ workspaceId,
486
+ sessionId,
487
+ Number.isFinite(after) ? after : 0,
488
+ c.req.raw.signal,
489
+ );
306
490
  });
307
491
 
308
492
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/turns", async (c) => {
@@ -310,78 +494,157 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
310
494
  await requireAccessGrant(c, deps, workspaceId, "sessions:read");
311
495
  const sessionId = c.req.param("sessionId");
312
496
  await assertSessionExists(db, workspaceId, sessionId);
313
- return c.json(await listSessionTurns(db, workspaceId, sessionId, boundedLimit(c.req.query("limit"))));
497
+ return c.json(
498
+ await listSessionTurns(db, workspaceId, sessionId, boundedLimit(c.req.query("limit"))),
499
+ );
314
500
  });
315
501
 
316
- app.patch("/v1/workspaces/:workspaceId/sessions/:sessionId/turns/:turnId", async (c) => {
502
+ app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/queue", async (c) => {
317
503
  const workspaceId = c.req.param("workspaceId");
318
- await requireAccessGrant(c, deps, workspaceId, "sessions:control");
504
+ await requireAccessGrant(c, deps, workspaceId, "sessions:read");
505
+ const sessionId = c.req.param("sessionId");
506
+ const snapshot = await getSessionQueueSnapshot(db, workspaceId, sessionId);
507
+ if (!snapshot) throw new HTTPException(404, { message: "session not found" });
508
+ return c.json(snapshot);
509
+ });
510
+
511
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/queue/:turnId/cancel", async (c) => {
512
+ const workspaceId = c.req.param("workspaceId");
513
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
319
514
  const sessionId = c.req.param("sessionId");
320
- const turnId = c.req.param("turnId");
321
515
  await assertSessionExists(db, workspaceId, sessionId);
322
- const existing = await requireQueuedTurnForApi(db, workspaceId, sessionId, turnId);
323
- const payload = UpdateSessionTurnRequest.parse(await c.req.json());
324
- // A turn-update may switch the queued turn's model; reject one the host
325
- // does not expose (omitted leaves the existing model unchanged).
326
- assertConfiguredModel(settings, payload.model);
327
- const session = await requireSession(db, workspaceId, sessionId);
328
- const runtimeSettings = settingsWithSessionMcpServerMetadata(
329
- await settingsWithEnabledCapabilityMcpServers(db, workspaceId, settings),
330
- session.mcpServers,
331
- );
332
- const resources = payload.resources !== undefined ? normalizeResources(payload.resources) : existing.resources;
333
- const tools = payload.tools !== undefined ? validateToolRefs(payload.tools, runtimeSettings) : existing.tools;
334
- if (resources.some((resource) => resource.kind === "file") && !objectStorage) {
335
- throw new HTTPException(503, { message: "object storage is not configured" });
516
+ const payload = CancelSessionQueueItemRequest.parse(await c.req.json());
517
+ try {
518
+ const result = await cancelQueuedSessionTurnWithVersion(
519
+ db,
520
+ workspaceId,
521
+ sessionId,
522
+ c.req.param("turnId"),
523
+ payload.expectedQueueVersion,
524
+ payload.expectedItemVersion,
525
+ grant.subjectId,
526
+ payload.reason ?? null,
527
+ );
528
+ await bus.publish(workspaceId, sessionId, result.events);
529
+ if (result.shouldWake) {
530
+ if (result.workflowWakeRevision === null) {
531
+ throw new Error("Queue continuation has no workflow wake revision");
532
+ }
533
+ await workflowClient.wakeSessionWorkflow({
534
+ accountId: grant.accountId,
535
+ workspaceId,
536
+ sessionId,
537
+ workflowId: workflowIdForSession(sessionId),
538
+ wakeRevision: result.workflowWakeRevision,
539
+ });
540
+ }
541
+ return c.json(result);
542
+ } catch (error) {
543
+ throwQueueConflict(error);
336
544
  }
337
- await validateFileResources(db, workspaceId, resources);
338
- await validateGitHubRepositorySelection(db, workspaceId, [...session.resources, ...resources]);
339
- const turn = await updateQueuedSessionTurn(db, workspaceId, turnId, {
340
- ...(payload.prompt !== undefined ? { prompt: payload.prompt.trim() } : {}),
341
- ...(payload.model !== undefined ? { model: payload.model } : {}),
342
- ...(payload.reasoningEffort !== undefined ? { reasoningEffort: payload.reasoningEffort } : {}),
343
- ...(payload.sandboxBackend !== undefined ? { sandboxBackend: payload.sandboxBackend } : {}),
344
- ...(payload.metadata !== undefined ? { metadata: payload.metadata } : {}),
345
- resources,
346
- tools,
347
- });
348
- await appendAndPublishEvents(db, bus, workspaceId, sessionId, [{
349
- type: "turn.updated",
350
- turnId: turn.id,
351
- payload: { turnId: turn.id },
352
- }]);
353
- return c.json(turn);
354
545
  });
355
546
 
356
- app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/turns/reorder", async (c) => {
547
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/control", async (c) => {
357
548
  const workspaceId = c.req.param("workspaceId");
358
549
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
359
550
  const sessionId = c.req.param("sessionId");
360
- await assertSessionExists(db, workspaceId, sessionId);
361
- const payload = ReorderSessionTurnsRequest.parse(await c.req.json());
362
- const turns = await reorderQueuedSessionTurns(db, workspaceId, sessionId, payload.turnIds);
363
- await appendAndPublishEvents(db, bus, workspaceId, sessionId, [{
364
- type: "turn.updated",
365
- payload: { reorderedTurnIds: payload.turnIds },
366
- }]);
367
- await workflowClient.wakeSessionWorkflow({ accountId: grant.accountId, workspaceId, sessionId, workflowId: workflowIdForSession(sessionId) });
368
- return c.json(turns);
551
+ const payload = SessionControlRequest.parse(await c.req.json());
552
+ let result;
553
+ try {
554
+ result = await requestSessionControl(db, {
555
+ accountId: grant.accountId,
556
+ workspaceId,
557
+ sessionId,
558
+ actor: grant.subjectId,
559
+ mode: payload.mode,
560
+ reason: payload.reason ?? null,
561
+ clientEventId: payload.clientEventId ?? null,
562
+ ...(payload.expectedControlState !== undefined
563
+ ? { expectedControlState: payload.expectedControlState }
564
+ : {}),
565
+ ...(payload.expectedControlGeneration !== undefined
566
+ ? { expectedControlGeneration: payload.expectedControlGeneration }
567
+ : {}),
568
+ ...(payload.expectedWorkspaceInferenceGeneration !== undefined
569
+ ? {
570
+ expectedWorkspaceInferenceGeneration: payload.expectedWorkspaceInferenceGeneration,
571
+ }
572
+ : {}),
573
+ });
574
+ } catch (error) {
575
+ throwQueueConflict(error);
576
+ }
577
+ await bus.publish(workspaceId, sessionId, result.events);
578
+ const workflowId = workflowIdForSession(sessionId);
579
+ if (result.shouldSignalControl) {
580
+ if (result.workflowWakeRevision === null) {
581
+ throw new Error("Session control has no workflow wake revision");
582
+ }
583
+ await workflowClient.signalSessionControl({
584
+ accountId: grant.accountId,
585
+ workspaceId,
586
+ sessionId,
587
+ eventId: result.event.id,
588
+ workflowId,
589
+ workflowWakeRevision: result.workflowWakeRevision,
590
+ });
591
+ } else if (result.shouldWake) {
592
+ if (result.workflowWakeRevision === null) {
593
+ throw new Error("Session resume has no workflow wake revision");
594
+ }
595
+ await workflowClient.wakeSessionWorkflow({
596
+ accountId: grant.accountId,
597
+ workspaceId,
598
+ sessionId,
599
+ workflowId,
600
+ wakeRevision: result.workflowWakeRevision,
601
+ });
602
+ }
603
+ return c.json(
604
+ {
605
+ operationId: result.operationId,
606
+ event: result.event,
607
+ controlState: result.controlState,
608
+ controlGeneration: result.controlGeneration,
609
+ expectedActiveTurnId: result.expectedActiveTurnId,
610
+ expectedExecutionGeneration: result.expectedExecutionGeneration,
611
+ expectedAttemptId: result.expectedAttemptId,
612
+ deliveryEventId: result.deliveryEventId,
613
+ shouldSignalControl: result.shouldSignalControl,
614
+ shouldWake: result.shouldWake,
615
+ },
616
+ 202,
617
+ );
369
618
  });
370
619
 
371
- app.delete("/v1/workspaces/:workspaceId/sessions/:sessionId/turns/:turnId", async (c) => {
620
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/steer", async (c) => {
372
621
  const workspaceId = c.req.param("workspaceId");
373
- await requireAccessGrant(c, deps, workspaceId, "sessions:control");
622
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
374
623
  const sessionId = c.req.param("sessionId");
375
- const turnId = c.req.param("turnId");
376
624
  await assertSessionExists(db, workspaceId, sessionId);
377
- await requireQueuedTurnForApi(db, workspaceId, sessionId, turnId);
378
- const turn = await cancelQueuedSessionTurn(db, workspaceId, turnId);
379
- await appendAndPublishEvents(db, bus, workspaceId, sessionId, [{
380
- type: "turn.cancelled",
381
- turnId: turn.id,
382
- payload: { turnId: turn.id, triggerEventId: turn.triggerEventId },
383
- }]);
384
- return c.json(turn);
625
+ const raw = await c.req.json();
626
+ const payload = SteerSessionMessageRequest.parse(raw);
627
+ const result = await acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
628
+ text: payload.text,
629
+ resources: payload.resources,
630
+ tools: payload.tools,
631
+ toolsProvided: userMessagePayloadHasOwnProperty({ payload: raw }, "tools"),
632
+ model: payload.model ?? null,
633
+ reasoningEffort: payload.reasoningEffort ?? null,
634
+ mcpCredentialUpdates: payload.mcpCredentialUpdates ?? [],
635
+ delivery: "steer",
636
+ origin: "human",
637
+ ...(payload.expectedControlGeneration !== undefined
638
+ ? { expectedControlGeneration: payload.expectedControlGeneration }
639
+ : {}),
640
+ ...(payload.expectedWorkspaceInferenceGeneration !== undefined
641
+ ? {
642
+ expectedWorkspaceInferenceGeneration: payload.expectedWorkspaceInferenceGeneration,
643
+ }
644
+ : {}),
645
+ ...(payload.clientEventId ? { clientEventId: payload.clientEventId } : {}),
646
+ });
647
+ return c.json(result, 202);
385
648
  });
386
649
 
387
650
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/events", async (c) => {
@@ -404,33 +667,31 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
404
667
  return c.json(accepted, 202);
405
668
  }
406
669
 
407
- const session = await requireSession(db, workspaceId, sessionId);
408
- if (event.type === "user.approvalDecision" && session.status !== "requires_action") {
409
- throw new HTTPException(409, { message: `session is ${session.status}; no approval is pending` });
410
- }
411
- const eventsToAppend: AppendEventInput[] = [{
412
- type: event.type,
413
- payload: event.payload,
414
- ...(event.clientEventId ? { clientEventId: event.clientEventId } : {}),
415
- }];
416
- const appended = await appendAndPublishEvents(db, bus, workspaceId, sessionId, eventsToAppend);
417
- const accepted = appended[0];
418
- if (!accepted) {
419
- throw new HTTPException(500, { message: "failed to append client event" });
420
- }
421
- const workflowId = workflowIdForSession(sessionId);
422
670
  if (event.type === "user.approvalDecision") {
423
- await workflowClient.signalApprovalDecision({ sessionId, eventId: accepted.id, workflowId });
424
- } else {
425
- await workflowClient.signalInterrupt({
671
+ const accepted = await acceptSessionApprovalDecision(db, {
672
+ accountId: grant.accountId,
673
+ workspaceId,
674
+ sessionId,
675
+ payload: event.payload,
676
+ clientEventId: event.clientEventId ?? null,
677
+ });
678
+ if (accepted.action === "conflict") {
679
+ throw new HTTPException(409, {
680
+ message: `session is ${accepted.sessionStatus}; no unhandled approval is pending`,
681
+ });
682
+ }
683
+ await publishDurableSessionEvents(bus, workspaceId, sessionId, accepted.events);
684
+ const workflowId = workflowIdForSession(sessionId);
685
+ await workflowClient.signalApprovalDecision({
426
686
  accountId: grant.accountId,
427
687
  workspaceId,
428
688
  sessionId,
429
- eventId: accepted.id,
689
+ eventId: accepted.event.id,
430
690
  workflowId,
691
+ workflowWakeRevision: accepted.workflowWakeRevision,
431
692
  });
693
+ return c.json(accepted.event, 202);
432
694
  }
433
- return c.json(accepted, 202);
434
695
  });
435
696
 
436
697
  // ── API-direct stream capabilities + viewer attach (P1.4) ─────────────────
@@ -449,7 +710,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
449
710
  // The viewer-holder lifecycle rides the sandbox lease, which is dormant
450
711
  // until the flag flips per-environment. A 404 (not 403) keeps the route
451
712
  // invisible while disabled — it does not exist for this deployment yet.
452
- throw new HTTPException(404, { message: "sandbox ownership is not enabled for this deployment" });
713
+ throw new HTTPException(404, {
714
+ message: "sandbox ownership is not enabled for this deployment",
715
+ });
453
716
  }
454
717
  }
455
718
 
@@ -479,7 +742,10 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
479
742
  if (!session) {
480
743
  throw new HTTPException(404, { message: "session not found" });
481
744
  }
482
- const lease = await readGroupLease({ db, settings }, { workspaceId, sandboxGroupId: session.sandboxGroupId });
745
+ const lease = await readGroupLease(
746
+ { db, settings },
747
+ { workspaceId, sandboxGroupId: session.sandboxGroupId },
748
+ );
483
749
  const { shared, sharedSessionIds } = await resolveSharedExposure(workspaceId, session);
484
750
  // Per-principal acknowledgment: A acknowledging does not consent for B. The
485
751
  // un-redacted desktop stream ALWAYS requires the un-redacted ack; a shared box
@@ -488,8 +754,14 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
488
754
  // WITHOUT un-redacted consent could be handed a live VNC URL + scoped token
489
755
  // from this read path while being correctly 409'd on attach (a consent-gate
490
756
  // bypass of the un-redacted pixel plane).
491
- const ack = await getStreamAcknowledgment(db, { workspaceId, sandboxGroupId: session.sandboxGroupId, subjectId: grant.subjectId });
492
- const acknowledged = ack ? (ack.acknowledgedUnredacted && (!shared || ack.acknowledgedShared)) : false;
757
+ const ack = await getStreamAcknowledgment(db, {
758
+ workspaceId,
759
+ sandboxGroupId: session.sandboxGroupId,
760
+ subjectId: grant.subjectId,
761
+ });
762
+ const acknowledged = ack
763
+ ? ack.acknowledgedUnredacted && (!shared || ack.acknowledgedShared)
764
+ : false;
493
765
 
494
766
  // P4.2 — the pixel DATA PLANE, served API-direct. When the backend is
495
767
  // desktop-capable AND sandboxDesktopEnabled AND the (shared, if shared)
@@ -502,22 +774,27 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
502
774
  // (no secret / display-stack or tunnel failure) returns null → transport:null.
503
775
  let desktopStream: DesktopStreamMint | null = null;
504
776
  const desktopUnlocked =
505
- settings.sandboxDesktopEnabled
506
- && !streamTokenDegraded(settings)
507
- && acknowledged
508
- && (session.activeSandboxId != null || lease?.liveness === "warm" || lease?.liveness === "draining");
777
+ settings.sandboxDesktopEnabled &&
778
+ !streamTokenDegraded(settings) &&
779
+ acknowledged &&
780
+ (session.activeSandboxId != null ||
781
+ lease?.liveness === "warm" ||
782
+ lease?.liveness === "draining");
509
783
  if (desktopUnlocked) {
510
- desktopStream = await mintDesktopStream({ db, settings, bus }, {
511
- accountId: grant.accountId,
512
- workspaceId,
513
- session,
514
- // The handshake's token is scoped to the calling principal (it is a read,
515
- // not a viewer-holder acquire); the per-holder token is re-minted on
516
- // POST /viewers. A previousEpoch != current would have rotated already
517
- // via the warming-commit; the read does not itself drive rotation.
518
- viewerId: grant.subjectId,
519
- ...(lease ? { lease } : {}),
520
- });
784
+ desktopStream = await mintDesktopStream(
785
+ { db, settings, bus },
786
+ {
787
+ accountId: grant.accountId,
788
+ workspaceId,
789
+ session,
790
+ // The handshake's token is scoped to the calling principal (it is a read,
791
+ // not a viewer-holder acquire); the per-holder token is re-minted on
792
+ // POST /viewers. A previousEpoch != current would have rotated already
793
+ // via the warming-commit; the read does not itself drive rotation.
794
+ viewerId: grant.subjectId,
795
+ ...(lease ? { lease } : {}),
796
+ },
797
+ );
521
798
  }
522
799
 
523
800
  // P5.t — the REAL PTY terminal cell, served API-DIRECT. Independent of the
@@ -527,17 +804,22 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
527
804
  // returns null → the Terminal cell falls back to the sse-events firehose.
528
805
  let terminalStream: TerminalStreamMint | null = null;
529
806
  const terminalUnlocked =
530
- settings.sandboxTerminalEnabled
531
- && !streamTokenDegraded(settings)
532
- && (session.activeSandboxId != null || lease?.liveness === "warm" || lease?.liveness === "draining");
807
+ settings.sandboxTerminalEnabled &&
808
+ !streamTokenDegraded(settings) &&
809
+ (session.activeSandboxId != null ||
810
+ lease?.liveness === "warm" ||
811
+ lease?.liveness === "draining");
533
812
  if (terminalUnlocked) {
534
- terminalStream = await mintTerminalStream({ db, settings, bus }, {
535
- accountId: grant.accountId,
536
- workspaceId,
537
- session,
538
- viewerId: grant.subjectId,
539
- ...(lease ? { lease } : {}),
540
- });
813
+ terminalStream = await mintTerminalStream(
814
+ { db, settings, bus },
815
+ {
816
+ accountId: grant.accountId,
817
+ workspaceId,
818
+ session,
819
+ viewerId: grant.subjectId,
820
+ ...(lease ? { lease } : {}),
821
+ },
822
+ );
541
823
  }
542
824
 
543
825
  const capabilities = negotiateCapabilities({
@@ -611,8 +893,13 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
611
893
  // place (resolveActiveDesktopTransport), covering BOTH swap directions.
612
894
  let responseCapabilities = capabilities;
613
895
  if (capabilities.DesktopStream.transport !== null) {
614
- const activeSandbox = session.activeSandboxId ? await getSandbox(db, workspaceId, session.activeSandboxId) : null;
615
- const wire = resolveActiveDesktopTransport(activeSandbox?.kind === "selfhosted", settings.sandboxDesktopInteractive !== false);
896
+ const activeSandbox = session.activeSandboxId
897
+ ? await getSandbox(db, workspaceId, session.activeSandboxId)
898
+ : null;
899
+ const wire = resolveActiveDesktopTransport(
900
+ activeSandbox?.kind === "selfhosted",
901
+ settings.sandboxDesktopInteractive !== false,
902
+ );
616
903
  responseCapabilities = {
617
904
  ...capabilities,
618
905
  DesktopStream: { ...capabilities.DesktopStream, ...wire },
@@ -626,29 +913,35 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
626
913
  // shared-exposure disclosure). Reuses the acknowledgment machinery — gated on
627
914
  // stream:acknowledge, no new permission. Until this is recorded the
628
915
  // desktop-stream (viewer attach) path returns 409 (P3.2 consent gate).
629
- app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/stream-capabilities/acknowledge", async (c) => {
630
- const workspaceId = c.req.param("workspaceId");
631
- const grant = await requireAccessGrant(c, deps, workspaceId, "stream:acknowledge");
632
- assertOwnershipEnabled();
633
- const sessionId = c.req.param("sessionId");
634
- const session = await getSession(db, workspaceId, sessionId);
635
- if (!session) {
636
- throw new HTTPException(404, { message: "session not found" });
637
- }
638
- const parsed = AcknowledgeStreamRequest.safeParse(await c.req.json().catch(() => ({})));
639
- if (!parsed.success) {
640
- throw new HTTPException(400, { message: "invalid stream acknowledgment request" });
641
- }
642
- const recorded = await recordStreamAcknowledgment(db, {
643
- accountId: grant.accountId,
644
- workspaceId,
645
- sandboxGroupId: session.sandboxGroupId,
646
- subjectId: grant.subjectId,
647
- acknowledgeUnredacted: parsed.data.acknowledgeUnredacted,
648
- acknowledgeShared: parsed.data.acknowledgeShared,
649
- });
650
- return c.json({ acknowledged: recorded.acknowledgedUnredacted, acknowledgedShared: recorded.acknowledgedShared });
651
- });
916
+ app.post(
917
+ "/v1/workspaces/:workspaceId/sessions/:sessionId/stream-capabilities/acknowledge",
918
+ async (c) => {
919
+ const workspaceId = c.req.param("workspaceId");
920
+ const grant = await requireAccessGrant(c, deps, workspaceId, "stream:acknowledge");
921
+ assertOwnershipEnabled();
922
+ const sessionId = c.req.param("sessionId");
923
+ const session = await getSession(db, workspaceId, sessionId);
924
+ if (!session) {
925
+ throw new HTTPException(404, { message: "session not found" });
926
+ }
927
+ const parsed = AcknowledgeStreamRequest.safeParse(await c.req.json().catch(() => ({})));
928
+ if (!parsed.success) {
929
+ throw new HTTPException(400, { message: "invalid stream acknowledgment request" });
930
+ }
931
+ const recorded = await recordStreamAcknowledgment(db, {
932
+ accountId: grant.accountId,
933
+ workspaceId,
934
+ sandboxGroupId: session.sandboxGroupId,
935
+ subjectId: grant.subjectId,
936
+ acknowledgeUnredacted: parsed.data.acknowledgeUnredacted,
937
+ acknowledgeShared: parsed.data.acknowledgeShared,
938
+ });
939
+ return c.json({
940
+ acknowledged: recorded.acknowledgedUnredacted,
941
+ acknowledgedShared: recorded.acknowledgedShared,
942
+ });
943
+ },
944
+ );
652
945
 
653
946
  // POST .../viewers — acquire a viewer holder on the desktop-stream (un-redacted
654
947
  // pixel) path. Gated on stream:view (strictly broader than sessions:read: the
@@ -684,7 +977,11 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
684
977
  const wantDesktop = parsed.data.desktop ?? false;
685
978
  const { shared } = await resolveSharedExposure(workspaceId, session);
686
979
  if (wantDesktop) {
687
- const ack = await getStreamAcknowledgment(db, { workspaceId, sandboxGroupId: session.sandboxGroupId, subjectId: grant.subjectId });
980
+ const ack = await getStreamAcknowledgment(db, {
981
+ workspaceId,
982
+ sandboxGroupId: session.sandboxGroupId,
983
+ subjectId: grant.subjectId,
984
+ });
688
985
  if (!ack?.acknowledgedUnredacted) {
689
986
  throw new HTTPException(409, { message: "stream_acknowledgment_required" });
690
987
  }
@@ -695,7 +992,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
695
992
  // SELFHOSTED ACTIVE: when the session's active sandbox is selfhosted, skip
696
993
  // attachViewer (it warms the Modal group box — the wrong target). Synthesize a
697
994
  // result shaped like ViewerAttachResult and mint relay cells directly.
698
- const activeSandbox = session.activeSandboxId ? await getSandbox(db, workspaceId, session.activeSandboxId) : null;
995
+ const activeSandbox = session.activeSandboxId
996
+ ? await getSandbox(db, workspaceId, session.activeSandboxId)
997
+ : null;
699
998
  const selfhostedActive = activeSandbox?.kind === "selfhosted";
700
999
 
701
1000
  let stream: DesktopStreamMint | null = null;
@@ -713,35 +1012,44 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
713
1012
  dataPlaneUrl: null,
714
1013
  };
715
1014
  if (
716
- (settings.sandboxDesktopEnabled || settings.sandboxTerminalEnabled)
717
- && !streamTokenDegraded(settings)
1015
+ (settings.sandboxDesktopEnabled || settings.sandboxTerminalEnabled) &&
1016
+ !streamTokenDegraded(settings)
718
1017
  ) {
719
1018
  if (wantDesktop && settings.sandboxDesktopEnabled) {
720
- stream = await mintDesktopStream({ db, settings, bus }, {
721
- accountId: grant.accountId,
722
- workspaceId,
723
- session,
724
- viewerId,
725
- // No Modal lease for selfhosted-active; the mint routes to the relay.
726
- });
1019
+ stream = await mintDesktopStream(
1020
+ { db, settings, bus },
1021
+ {
1022
+ accountId: grant.accountId,
1023
+ workspaceId,
1024
+ session,
1025
+ viewerId,
1026
+ // No Modal lease for selfhosted-active; the mint routes to the relay.
1027
+ },
1028
+ );
727
1029
  }
728
1030
  if (settings.sandboxTerminalEnabled) {
729
- terminal = await mintTerminalStream({ db, settings, bus }, {
730
- accountId: grant.accountId,
731
- workspaceId,
732
- session,
733
- viewerId,
734
- // No Modal lease for selfhosted-active; the mint routes to the relay.
735
- });
1031
+ terminal = await mintTerminalStream(
1032
+ { db, settings, bus },
1033
+ {
1034
+ accountId: grant.accountId,
1035
+ workspaceId,
1036
+ session,
1037
+ viewerId,
1038
+ // No Modal lease for selfhosted-active; the mint routes to the relay.
1039
+ },
1040
+ );
736
1041
  }
737
1042
  }
738
1043
  } else {
739
- result = await attachViewer({ db, settings }, {
740
- accountId: grant.accountId,
741
- workspaceId,
742
- session,
743
- ...(parsed.data.viewerId ? { viewerId: parsed.data.viewerId } : {}),
744
- });
1044
+ result = await attachViewer(
1045
+ { db, settings },
1046
+ {
1047
+ accountId: grant.accountId,
1048
+ workspaceId,
1049
+ session,
1050
+ ...(parsed.data.viewerId ? { viewerId: parsed.data.viewerId } : {}),
1051
+ },
1052
+ );
745
1053
 
746
1054
  // P4.2 — the viewer now holds a WARM box; mint the real pixel cell IN-PROCESS
747
1055
  // (resume by id → ensureDisplayStack → exposeStreamPort) scoped to THIS
@@ -751,34 +1059,43 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
751
1059
  // box is warm here (attachViewer spun it up or attached), so the handshake's
752
1060
  // never-spin-up rule does not apply.
753
1061
  if (
754
- (settings.sandboxDesktopEnabled || settings.sandboxTerminalEnabled)
755
- && !streamTokenDegraded(settings)
1062
+ (settings.sandboxDesktopEnabled || settings.sandboxTerminalEnabled) &&
1063
+ !streamTokenDegraded(settings)
756
1064
  ) {
757
- const lease = await readGroupLease({ db, settings }, { workspaceId, sandboxGroupId: session.sandboxGroupId });
1065
+ const lease = await readGroupLease(
1066
+ { db, settings },
1067
+ { workspaceId, sandboxGroupId: session.sandboxGroupId },
1068
+ );
758
1069
  if (lease) {
759
1070
  // The pixel cell is minted only when the caller asked for the desktop plane
760
1071
  // (and consented above). A terminal-only attach skips it — the box is warm,
761
1072
  // the terminal mint below still runs.
762
1073
  if (wantDesktop && settings.sandboxDesktopEnabled) {
763
- stream = await mintDesktopStream({ db, settings, bus }, {
764
- accountId: grant.accountId,
765
- workspaceId,
766
- session,
767
- viewerId: result.viewerId,
768
- lease,
769
- });
1074
+ stream = await mintDesktopStream(
1075
+ { db, settings, bus },
1076
+ {
1077
+ accountId: grant.accountId,
1078
+ workspaceId,
1079
+ session,
1080
+ viewerId: result.viewerId,
1081
+ lease,
1082
+ },
1083
+ );
770
1084
  }
771
1085
  // P5.t — the same warm-box viewer attach also mints the REAL PTY terminal
772
1086
  // address (independent of the desktop toggle). A degraded mint leaves the
773
1087
  // terminal fields null → the client falls back to the sse-events firehose.
774
1088
  if (settings.sandboxTerminalEnabled) {
775
- terminal = await mintTerminalStream({ db, settings, bus }, {
776
- accountId: grant.accountId,
777
- workspaceId,
778
- session,
779
- viewerId: result.viewerId,
780
- lease,
781
- });
1089
+ terminal = await mintTerminalStream(
1090
+ { db, settings, bus },
1091
+ {
1092
+ accountId: grant.accountId,
1093
+ workspaceId,
1094
+ session,
1095
+ viewerId: result.viewerId,
1096
+ lease,
1097
+ },
1098
+ );
782
1099
  }
783
1100
  }
784
1101
  }
@@ -795,7 +1112,11 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
795
1112
  // (vnc-ws/novnc). Hardcoding vnc-ws here handed a machine's relay URL to the
796
1113
  // noVNC renderer (and vice-versa on the swap-away case) → "closed before it
797
1114
  // opened". Key off the SAME selfhostedActive the mint routed on.
798
- transport: stream ? (selfhostedActive ? ("relay-frames" as const) : ("vnc-ws" as const)) : null,
1115
+ transport: stream
1116
+ ? selfhostedActive
1117
+ ? ("relay-frames" as const)
1118
+ : ("vnc-ws" as const)
1119
+ : null,
799
1120
  client: stream ? (selfhostedActive ? ("frames" as const) : ("novnc" as const)) : null,
800
1121
  // The REAL PTY terminal address (pty-ws), null when degraded.
801
1122
  terminalUrl: terminal?.url ?? null,
@@ -809,28 +1130,34 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
809
1130
 
810
1131
  // POST .../viewers/:viewerId/heartbeat — refresh the holder TTL (epoch-fenced).
811
1132
  // The desktop-stream lifecycle is gated on stream:view (the un-redacted plane).
812
- app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/viewers/:viewerId/heartbeat", async (c) => {
813
- const workspaceId = c.req.param("workspaceId");
814
- const grant = await requireAccessGrant(c, deps, workspaceId, "stream:view");
815
- assertOwnershipEnabled();
816
- const sessionId = c.req.param("sessionId");
817
- const session = await getSession(db, workspaceId, sessionId);
818
- if (!session) {
819
- throw new HTTPException(404, { message: "session not found" });
820
- }
821
- const parsed = ViewerHeartbeatRequest.safeParse(await c.req.json().catch(() => ({})));
822
- if (!parsed.success) {
823
- throw new HTTPException(400, { message: "viewer heartbeat requires { leaseEpoch }" });
824
- }
825
- const alive = await heartbeatViewer({ db, settings }, {
826
- accountId: grant.accountId,
827
- workspaceId,
828
- sandboxGroupId: session.sandboxGroupId,
829
- viewerId: c.req.param("viewerId"),
830
- expectedEpoch: parsed.data.leaseEpoch,
831
- });
832
- return c.json({ alive });
833
- });
1133
+ app.post(
1134
+ "/v1/workspaces/:workspaceId/sessions/:sessionId/viewers/:viewerId/heartbeat",
1135
+ async (c) => {
1136
+ const workspaceId = c.req.param("workspaceId");
1137
+ const grant = await requireAccessGrant(c, deps, workspaceId, "stream:view");
1138
+ assertOwnershipEnabled();
1139
+ const sessionId = c.req.param("sessionId");
1140
+ const session = await getSession(db, workspaceId, sessionId);
1141
+ if (!session) {
1142
+ throw new HTTPException(404, { message: "session not found" });
1143
+ }
1144
+ const parsed = ViewerHeartbeatRequest.safeParse(await c.req.json().catch(() => ({})));
1145
+ if (!parsed.success) {
1146
+ throw new HTTPException(400, { message: "viewer heartbeat requires { leaseEpoch }" });
1147
+ }
1148
+ const alive = await heartbeatViewer(
1149
+ { db, settings },
1150
+ {
1151
+ accountId: grant.accountId,
1152
+ workspaceId,
1153
+ sandboxGroupId: session.sandboxGroupId,
1154
+ viewerId: c.req.param("viewerId"),
1155
+ expectedEpoch: parsed.data.leaseEpoch,
1156
+ },
1157
+ );
1158
+ return c.json({ alive });
1159
+ },
1160
+ );
834
1161
 
835
1162
  // DELETE .../viewers/:viewerId — release the holder (idempotent).
836
1163
  app.delete("/v1/workspaces/:workspaceId/sessions/:sessionId/viewers/:viewerId", async (c) => {
@@ -842,12 +1169,15 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
842
1169
  if (!session) {
843
1170
  throw new HTTPException(404, { message: "session not found" });
844
1171
  }
845
- await detachViewer({ db, settings }, {
846
- accountId: grant.accountId,
847
- workspaceId,
848
- sandboxGroupId: session.sandboxGroupId,
849
- viewerId: c.req.param("viewerId"),
850
- });
1172
+ await detachViewer(
1173
+ { db, settings },
1174
+ {
1175
+ accountId: grant.accountId,
1176
+ workspaceId,
1177
+ sandboxGroupId: session.sandboxGroupId,
1178
+ viewerId: c.req.param("viewerId"),
1179
+ },
1180
+ );
851
1181
  return c.body(null, 204);
852
1182
  });
853
1183
 
@@ -857,25 +1187,28 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
857
1187
  // group-refcount liveness). Gated on stream:view (no new permission). The
858
1188
  // live-RFB force-disconnect of an already-open socket is a P4 follow-up; the
859
1189
  // holder-drop (so the box can drain) is the v1 deliverable.
860
- app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/viewers/:viewerId/revoke", async (c) => {
861
- const workspaceId = c.req.param("workspaceId");
862
- const grant = await requireAccessGrant(c, deps, workspaceId, "stream:view");
863
- assertOwnershipEnabled();
864
- const sessionId = c.req.param("sessionId");
865
- const session = await getSession(db, workspaceId, sessionId);
866
- if (!session) {
867
- throw new HTTPException(404, { message: "session not found" });
868
- }
869
- const result = await revokeViewer(db, {
870
- accountId: grant.accountId,
871
- workspaceId,
872
- sandboxGroupId: session.sandboxGroupId,
873
- viewerId: c.req.param("viewerId"),
874
- idleGraceMs: settings.sandboxIdleGraceMs,
875
- });
876
- // null ⇒ the lease was already cold-and-reaped (revoke is an idempotent no-op).
877
- return c.json({ liveness: result?.liveness ?? null, refcount: result?.refcount ?? null });
878
- });
1190
+ app.post(
1191
+ "/v1/workspaces/:workspaceId/sessions/:sessionId/viewers/:viewerId/revoke",
1192
+ async (c) => {
1193
+ const workspaceId = c.req.param("workspaceId");
1194
+ const grant = await requireAccessGrant(c, deps, workspaceId, "stream:view");
1195
+ assertOwnershipEnabled();
1196
+ const sessionId = c.req.param("sessionId");
1197
+ const session = await getSession(db, workspaceId, sessionId);
1198
+ if (!session) {
1199
+ throw new HTTPException(404, { message: "session not found" });
1200
+ }
1201
+ const result = await revokeViewer(db, {
1202
+ accountId: grant.accountId,
1203
+ workspaceId,
1204
+ sandboxGroupId: session.sandboxGroupId,
1205
+ viewerId: c.req.param("viewerId"),
1206
+ idleGraceMs: settings.sandboxIdleGraceMs,
1207
+ });
1208
+ // null ⇒ the lease was already cold-and-reaped (revoke is an idempotent no-op).
1209
+ return c.json({ liveness: result?.liveness ?? null, refcount: result?.refcount ?? null });
1210
+ },
1211
+ );
879
1212
 
880
1213
  // ══════════════════════ Channel-A structured services (P4.4) ══════════════
881
1214
  //
@@ -917,7 +1250,10 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
917
1250
  return { accountId: grant.accountId, workspaceId, session, subjectId: grant.subjectId };
918
1251
  }
919
1252
 
920
- async function parseChannelABody<T>(c: Context, schema: { safeParse: (v: unknown) => { success: true; data: T } | { success: false } }): Promise<T> {
1253
+ async function parseChannelABody<T>(
1254
+ c: Context,
1255
+ schema: { safeParse: (v: unknown) => { success: true; data: T } | { success: false } },
1256
+ ): Promise<T> {
921
1257
  const raw = await c.req.json().catch(() => undefined);
922
1258
  const result = schema.safeParse(raw ?? {});
923
1259
  if (!result.success) {
@@ -930,42 +1266,54 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
930
1266
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/list", async (c) => {
931
1267
  const ctx = await channelAPreamble(c, "files:read");
932
1268
  const req = await parseChannelABody(c, FsListRequest);
933
- const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.fsList(req));
1269
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) =>
1270
+ service.fsList(req),
1271
+ );
934
1272
  return c.json(out);
935
1273
  });
936
1274
 
937
1275
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/read", async (c) => {
938
1276
  const ctx = await channelAPreamble(c, "files:read");
939
1277
  const req = await parseChannelABody(c, FsReadRequest);
940
- const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.fsRead(req));
1278
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) =>
1279
+ service.fsRead(req),
1280
+ );
941
1281
  return c.json(out);
942
1282
  });
943
1283
 
944
1284
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/write", async (c) => {
945
1285
  const ctx = await channelAPreamble(c, "files:write");
946
1286
  const req = await parseChannelABody(c, FsWriteRequest);
947
- const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.fsWrite(req));
1287
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) =>
1288
+ service.fsWrite(req),
1289
+ );
948
1290
  return c.json(out);
949
1291
  });
950
1292
 
951
1293
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/delete", async (c) => {
952
1294
  const ctx = await channelAPreamble(c, "files:write");
953
1295
  const req = await parseChannelABody(c, FsDeleteRequest);
954
- const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.fsDelete(req));
1296
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) =>
1297
+ service.fsDelete(req),
1298
+ );
955
1299
  return c.json(out);
956
1300
  });
957
1301
 
958
1302
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/move", async (c) => {
959
1303
  const ctx = await channelAPreamble(c, "files:write");
960
1304
  const req = await parseChannelABody(c, FsMoveRequest);
961
- const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.fsMove(req));
1305
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) =>
1306
+ service.fsMove(req),
1307
+ );
962
1308
  return c.json(out);
963
1309
  });
964
1310
 
965
1311
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/mkdir", async (c) => {
966
1312
  const ctx = await channelAPreamble(c, "files:write");
967
1313
  const req = await parseChannelABody(c, FsMkdirRequest);
968
- const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.fsMkdir(req));
1314
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) =>
1315
+ service.fsMkdir(req),
1316
+ );
969
1317
  return c.json(out);
970
1318
  });
971
1319
 
@@ -973,36 +1321,98 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
973
1321
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/status", async (c) => {
974
1322
  const ctx = await channelAPreamble(c, "files:read");
975
1323
  const req = await parseChannelABody(c, GitStatusRequest);
976
- const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.gitStatus(req));
1324
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) =>
1325
+ service.gitStatus(req),
1326
+ );
977
1327
  return c.json(out);
978
1328
  });
979
1329
 
980
1330
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/diff", async (c) => {
981
1331
  const ctx = await channelAPreamble(c, "files:read");
982
1332
  const req = await parseChannelABody(c, GitDiffRequest);
983
- const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.gitDiff(req));
1333
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) =>
1334
+ service.gitDiff(req),
1335
+ );
984
1336
  return c.json(out);
985
1337
  });
986
1338
 
987
1339
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/log", async (c) => {
988
1340
  const ctx = await channelAPreamble(c, "files:read");
989
1341
  const req = await parseChannelABody(c, GitLogRequest);
990
- const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.gitLog(req));
1342
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) =>
1343
+ service.gitLog(req),
1344
+ );
991
1345
  return c.json(out);
992
1346
  });
993
1347
 
994
1348
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/show", async (c) => {
995
1349
  const ctx = await channelAPreamble(c, "files:read");
996
1350
  const req = await parseChannelABody(c, GitShowRequest);
997
- const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.gitShow(req));
1351
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) =>
1352
+ service.gitShow(req),
1353
+ );
998
1354
  return c.json(out);
999
1355
  });
1000
1356
 
1357
+ // ── Workspace capture (read-only; served from DB + object storage, NO box) ──
1358
+ // Grant-first (files:read) then a pure DB/storage read — deliberately NOT the
1359
+ // channelAPreamble path: a capture is the durable turn-end snapshot, served
1360
+ // without warming a machine (the <200ms cold paint). No ownership-flag gate:
1361
+ // absent captures return {available:false} (200) so the client falls back to
1362
+ // the live/wake path — the feature degrades to today's behavior, never worse.
1363
+ app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/workspace/capture", async (c) => {
1364
+ const workspaceId = c.req.param("workspaceId") ?? "";
1365
+ await requireAccessGrant(c, deps, workspaceId, "files:read");
1366
+ const sessionId = c.req.param("sessionId") ?? "";
1367
+ const session = await getSession(db, workspaceId, sessionId);
1368
+ if (!session) {
1369
+ throw new HTTPException(404, { message: "session not found" });
1370
+ }
1371
+ if (!objectStorage) {
1372
+ // No storage configured → no captures can exist. Cold-fallback, not an error.
1373
+ return c.json({ available: false });
1374
+ }
1375
+ const row = await latestWorkspaceCapture(db, workspaceId, sessionId);
1376
+ return c.json(await serveWorkspaceCapture(row, objectStorage));
1377
+ });
1378
+
1379
+ app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/workspace/capture/file", async (c) => {
1380
+ const workspaceId = c.req.param("workspaceId") ?? "";
1381
+ await requireAccessGrant(c, deps, workspaceId, "files:read");
1382
+ const sessionId = c.req.param("sessionId") ?? "";
1383
+ const path = c.req.query("path");
1384
+ if (!path) {
1385
+ throw new HTTPException(400, { message: "path query parameter is required" });
1386
+ }
1387
+ const session = await getSession(db, workspaceId, sessionId);
1388
+ if (!session) {
1389
+ throw new HTTPException(404, { message: "session not found" });
1390
+ }
1391
+ if (!objectStorage) {
1392
+ throw new HTTPException(404, { message: "capture not found" });
1393
+ }
1394
+ // Explicit ?revision pins a specific capture; omitted → latest.
1395
+ const revisionParam = c.req.query("revision");
1396
+ let row;
1397
+ if (revisionParam !== undefined && revisionParam !== "") {
1398
+ const revision = Number(revisionParam);
1399
+ if (!Number.isInteger(revision) || revision < 0) {
1400
+ throw new HTTPException(400, { message: "revision must be a non-negative integer" });
1401
+ }
1402
+ row = await workspaceCaptureAtRevision(db, workspaceId, sessionId, revision);
1403
+ } else {
1404
+ row = await latestWorkspaceCapture(db, workspaceId, sessionId);
1405
+ }
1406
+ return c.json(await serveWorkspaceCaptureFile(row, path, objectStorage));
1407
+ });
1408
+
1001
1409
  // ── Terminal: synchronous exec ────────────────────────────────────────────
1002
1410
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/exec", async (c) => {
1003
1411
  const ctx = await channelAPreamble(c, "terminal:attach");
1004
1412
  const req = await parseChannelABody(c, TerminalExecRequest);
1005
- const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.terminalExec(req));
1413
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) =>
1414
+ service.terminalExec(req),
1415
+ );
1006
1416
  return c.json(out);
1007
1417
  });
1008
1418
 
@@ -1028,10 +1438,21 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1028
1438
  openedBy: ctx.subjectId,
1029
1439
  });
1030
1440
  // Emit terminal.pty.started + any initial banner output on A1.
1031
- const started: TerminalPtyStartedPayload = { ptyId, cols: req.cols, rows: req.rows, shell: opened.shell, cwd: req.cwd };
1441
+ const started: TerminalPtyStartedPayload = {
1442
+ ptyId,
1443
+ cols: req.cols,
1444
+ rows: req.rows,
1445
+ shell: opened.shell,
1446
+ cwd: req.cwd,
1447
+ };
1032
1448
  const events: AppendEventInput[] = [{ type: "terminal.pty.started", payload: started }];
1033
1449
  if (opened.initialOutput) {
1034
- const delta: TerminalPtyOutputDeltaPayload = { ptyId, stream: "stdout", chunk: opened.initialOutput, seq: 0 };
1450
+ const delta: TerminalPtyOutputDeltaPayload = {
1451
+ ptyId,
1452
+ stream: "stdout",
1453
+ chunk: opened.initialOutput,
1454
+ seq: 0,
1455
+ };
1035
1456
  events.push({ type: "terminal.pty.output.delta", payload: delta });
1036
1457
  }
1037
1458
  await appendAndPublishEvents(db, bus, ctx.workspaceId, ctx.session.id, events);
@@ -1053,10 +1474,22 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1053
1474
  let seq = 1;
1054
1475
  await withChannelA({ db, settings, bus }, ctx, async ({ service }) => {
1055
1476
  const output = await service.ptyWrite(req, pty.execSessionId!, req.data);
1056
- await updatePtySessionActivity(db, { accountId: ctx.accountId, workspaceId: ctx.workspaceId, ptyId: req.ptyId, execSessionId: pty.execSessionId });
1477
+ await updatePtySessionActivity(db, {
1478
+ accountId: ctx.accountId,
1479
+ workspaceId: ctx.workspaceId,
1480
+ ptyId: req.ptyId,
1481
+ execSessionId: pty.execSessionId,
1482
+ });
1057
1483
  if (output) {
1058
- const delta: TerminalPtyOutputDeltaPayload = { ptyId: req.ptyId, stream: "stdout", chunk: output, seq: seq++ };
1059
- await appendAndPublishEvents(db, bus, ctx.workspaceId, ctx.session.id, [{ type: "terminal.pty.output.delta", payload: delta }]);
1484
+ const delta: TerminalPtyOutputDeltaPayload = {
1485
+ ptyId: req.ptyId,
1486
+ stream: "stdout",
1487
+ chunk: output,
1488
+ seq: seq++,
1489
+ };
1490
+ await appendAndPublishEvents(db, bus, ctx.workspaceId, ctx.session.id, [
1491
+ { type: "terminal.pty.output.delta", payload: delta },
1492
+ ]);
1060
1493
  }
1061
1494
  });
1062
1495
  return c.body(null, 204);
@@ -1070,9 +1503,17 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1070
1503
  throw new HTTPException(404, { message: "pty not found or closed" });
1071
1504
  }
1072
1505
  if (pty.execSessionId !== null) {
1073
- await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.ptyResize(req, pty.execSessionId!));
1506
+ await withChannelA({ db, settings, bus }, ctx, ({ service }) =>
1507
+ service.ptyResize(req, pty.execSessionId!),
1508
+ );
1074
1509
  }
1075
- await updatePtySessionActivity(db, { accountId: ctx.accountId, workspaceId: ctx.workspaceId, ptyId: req.ptyId, cols: req.cols, rows: req.rows });
1510
+ await updatePtySessionActivity(db, {
1511
+ accountId: ctx.accountId,
1512
+ workspaceId: ctx.workspaceId,
1513
+ ptyId: req.ptyId,
1514
+ cols: req.cols,
1515
+ rows: req.rows,
1516
+ });
1076
1517
  return c.body(null, 204);
1077
1518
  });
1078
1519
 
@@ -1082,10 +1523,18 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1082
1523
  const pty = await getOpenPtySession(db, ctx.workspaceId, req.ptyId);
1083
1524
  // Idempotent: closing an already-closed/absent PTY is a 204 no-op.
1084
1525
  if (pty) {
1085
- await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.ptyClose(req, pty.execSessionId));
1086
- await closePtySession(db, { accountId: ctx.accountId, workspaceId: ctx.workspaceId, ptyId: req.ptyId });
1526
+ await withChannelA({ db, settings, bus }, ctx, ({ service }) =>
1527
+ service.ptyClose(req, pty.execSessionId),
1528
+ );
1529
+ await closePtySession(db, {
1530
+ accountId: ctx.accountId,
1531
+ workspaceId: ctx.workspaceId,
1532
+ ptyId: req.ptyId,
1533
+ });
1087
1534
  const exited: TerminalPtyExitedPayload = { ptyId: req.ptyId, exitCode: 0, reason: "exit" };
1088
- await appendAndPublishEvents(db, bus, ctx.workspaceId, ctx.session.id, [{ type: "terminal.pty.exited", payload: exited }]);
1535
+ await appendAndPublishEvents(db, bus, ctx.workspaceId, ctx.session.id, [
1536
+ { type: "terminal.pty.exited", payload: exited },
1537
+ ]);
1089
1538
  }
1090
1539
  return c.body(null, 204);
1091
1540
  });
@@ -1099,6 +1548,50 @@ function eventListLimit(raw: string | undefined, max = 2000): number {
1099
1548
  return Math.min(max, Math.max(1, Math.floor(limit)));
1100
1549
  }
1101
1550
 
1551
+ function sessionListQuery(
1552
+ query: Record<string, string>,
1553
+ allowCursor = true,
1554
+ ): {
1555
+ limit: string | undefined;
1556
+ parentSessionId: string | null | undefined;
1557
+ cursor: ReturnType<typeof decodeSessionListCursor> | undefined;
1558
+ search: string | undefined;
1559
+ } {
1560
+ const parentSessionId = query.parentSessionId;
1561
+ // "null" = roots only; a uuid = children of that session; anything else is
1562
+ // a client error (an unvalidated value would surface as a Postgres uuid cast
1563
+ // failure -> 500 rather than an honest 400).
1564
+ if (
1565
+ parentSessionId !== undefined &&
1566
+ parentSessionId !== "null" &&
1567
+ !z.string().uuid().safeParse(parentSessionId).success
1568
+ ) {
1569
+ throw new HTTPException(400, {
1570
+ message: 'parentSessionId must be a session id or the literal "null"',
1571
+ });
1572
+ }
1573
+ const rawCursor = allowCursor ? query.cursor : undefined;
1574
+ const cursor = rawCursor ? decodeSessionListCursor(rawCursor) : undefined;
1575
+ if (rawCursor && !cursor) {
1576
+ throw new HTTPException(400, { message: "cursor is invalid" });
1577
+ }
1578
+ const search = query.search?.trim();
1579
+ if (search && search.length > 200) {
1580
+ throw new HTTPException(400, { message: "search must be at most 200 characters" });
1581
+ }
1582
+ return {
1583
+ limit: query.limit,
1584
+ parentSessionId:
1585
+ parentSessionId === undefined
1586
+ ? undefined
1587
+ : parentSessionId === "null"
1588
+ ? null
1589
+ : parentSessionId,
1590
+ cursor,
1591
+ search: search || undefined,
1592
+ };
1593
+ }
1594
+
1102
1595
  function compactEvents(raw: string | undefined): boolean {
1103
1596
  return raw === "1" || raw === "true";
1104
1597
  }
@@ -1123,7 +1616,9 @@ function optionalEventSequence(raw: string | undefined): number | undefined {
1123
1616
  }
1124
1617
 
1125
1618
  function hasOwnProperty(value: unknown, key: string): boolean {
1126
- return Boolean(value && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, key));
1619
+ return Boolean(
1620
+ value && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, key),
1621
+ );
1127
1622
  }
1128
1623
 
1129
1624
  function userMessagePayloadHasOwnProperty(value: unknown, key: string): boolean {
@@ -1133,3 +1628,18 @@ function userMessagePayloadHasOwnProperty(value: unknown, key: string): boolean
1133
1628
  const payload = (value as { payload?: unknown }).payload;
1134
1629
  return hasOwnProperty(payload, key);
1135
1630
  }
1631
+
1632
+ function throwQueueConflict(error: unknown): never {
1633
+ if (error instanceof SessionQueueConflictError) {
1634
+ throw new HTTPException(409, {
1635
+ message: JSON.stringify({
1636
+ message: error.message,
1637
+ currentQueueVersion: error.currentQueueVersion,
1638
+ ...(error.currentItemVersion !== undefined
1639
+ ? { currentItemVersion: error.currentItemVersion }
1640
+ : {}),
1641
+ }),
1642
+ });
1643
+ }
1644
+ throw error;
1645
+ }