@opengeni/api-router 0.5.3 → 0.5.5

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 (49) hide show
  1. package/dist/app.d.ts +9 -1
  2. package/dist/app.js +7 -1
  3. package/dist/{chunk-3HIA43CC.js → chunk-HBEJMWD3.js} +5470 -2223
  4. package/dist/chunk-HBEJMWD3.js.map +1 -0
  5. package/dist/index.d.ts +2 -1
  6. package/dist/index.js +279 -55
  7. package/dist/index.js.map +1 -1
  8. package/package.json +20 -20
  9. package/src/app.ts +583 -166
  10. package/src/auth/managed-auth.ts +32 -16
  11. package/src/http/auth.ts +8 -1
  12. package/src/http/common.ts +6 -2
  13. package/src/http/sse.ts +84 -8
  14. package/src/index.ts +178 -75
  15. package/src/integrations/oauth-client.ts +403 -120
  16. package/src/integrations/provider-domain.ts +4 -1
  17. package/src/mcp/documents.ts +173 -94
  18. package/src/mcp/server.ts +1600 -693
  19. package/src/mcp/session-view.ts +8 -2
  20. package/src/mcp/toolspace.ts +175 -84
  21. package/src/observability.ts +7 -1
  22. package/src/routes/api-keys.ts +39 -23
  23. package/src/routes/billing.ts +180 -65
  24. package/src/routes/capabilities.ts +17 -8
  25. package/src/routes/catalog-assets.ts +5 -2
  26. package/src/routes/codex.ts +244 -63
  27. package/src/routes/connections.ts +71 -33
  28. package/src/routes/documents.ts +242 -92
  29. package/src/routes/enrollments.ts +100 -70
  30. package/src/routes/environments.ts +205 -136
  31. package/src/routes/files.ts +164 -39
  32. package/src/routes/github.ts +123 -50
  33. package/src/routes/install.ts +9 -2
  34. package/src/routes/machines.ts +9 -8
  35. package/src/routes/packs.ts +141 -89
  36. package/src/routes/rigs.ts +189 -0
  37. package/src/routes/scheduled-tasks.ts +51 -9
  38. package/src/routes/sessions.ts +870 -328
  39. package/src/routes/social.ts +50 -38
  40. package/src/routes/workspace-capture.ts +238 -0
  41. package/src/routes/workspaces.ts +146 -13
  42. package/src/sandbox/access.ts +11 -3
  43. package/src/sandbox/auth-callout.ts +5 -1
  44. package/src/sandbox/channel-a.ts +104 -27
  45. package/src/sandbox/enrollment.ts +13 -3
  46. package/src/sandbox/machines.ts +68 -59
  47. package/src/sandbox/metrics-ingestion.ts +238 -17
  48. package/src/sandbox/viewer.ts +172 -46
  49. package/dist/chunk-3HIA43CC.js.map +0 -1
@@ -4,6 +4,8 @@ import {
4
4
  ClearSessionContextRequest,
5
5
  ClientSessionEvent,
6
6
  CompactSessionContextRequest,
7
+ DeleteSessionQueueItemRequest,
8
+ EditSessionQueueItemRequest,
7
9
  FsDeleteRequest,
8
10
  FsListRequest,
9
11
  FsMkdirRequest,
@@ -14,15 +16,19 @@ import {
14
16
  GitLogRequest,
15
17
  GitShowRequest,
16
18
  GitStatusRequest,
19
+ MoveSessionQueueItemRequest,
17
20
  PtyCloseRequest,
18
21
  PtyOpenRequest,
19
22
  PtyResizeRequest,
20
23
  PtyWriteRequest,
21
- ReorderSessionTurnsRequest,
24
+ SessionControlRequest,
25
+ SaveComposerDraftRequest,
26
+ SteerSessionQueueItemRequest,
27
+ SteerSessionMessageRequest,
22
28
  TerminalExecRequest,
29
+ UpdateSessionPinRequest,
23
30
  UpdateSessionGoalRequest,
24
31
  UpdateSessionRequest,
25
- UpdateSessionTurnRequest,
26
32
  ViewerHeartbeatRequest,
27
33
  type SandboxBackend,
28
34
  type Session,
@@ -30,57 +36,86 @@ import {
30
36
  type TerminalPtyOutputDeltaPayload,
31
37
  type TerminalPtyStartedPayload,
32
38
  } from "@opengeni/contracts";
33
- import { resolveContextCompactionMode, streamTokenDegraded } from "@opengeni/config";
39
+ import { streamTokenDegraded } from "@opengeni/config";
34
40
  import {
35
- cancelQueuedSessionTurn,
41
+ acceptSessionApprovalDecision,
42
+ clearSessionGoal,
36
43
  clearSessionContext,
37
44
  closePtySession,
38
45
  getOpenPtySession,
39
46
  getSandbox,
40
47
  getSession,
48
+ getSessionForSubject,
41
49
  getSessionGoal,
50
+ getSessionQueueSnapshot,
42
51
  getStreamAcknowledgment,
43
52
  insertPtySession,
44
53
  listSessionEvents,
45
54
  listSessionIdsInGroup,
46
- listSessions,
55
+ listSessionsForSubject,
47
56
  listSessionTurns,
48
57
  recordStreamAcknowledgment,
49
- reorderQueuedSessionTurns,
50
58
  requestSessionCompaction,
51
- requireSession,
52
59
  setSessionCodexPin,
60
+ withCodexCapacityMutation,
61
+ setSessionPin,
62
+ SessionPinVersionConflictError,
63
+ SessionPinAccessError,
64
+ SessionListAccessError,
65
+ SessionListCursorError,
66
+ decodeSessionListCursor,
53
67
  revokeViewer,
54
68
  setSessionGoalStatus,
55
69
  updatePtySessionActivity,
56
- updateQueuedSessionTurn,
70
+ QueueCommandConflictError,
71
+ SessionCommandIdempotencyError,
72
+ SessionControlConflictError,
73
+ SessionContextBusyError,
74
+ latestWorkspaceCapture,
75
+ workspaceCaptureAtRevision,
57
76
  type AppendEventInput,
58
77
  } from "@opengeni/db";
59
- import { appendAndPublishEvents, coalesceSessionEventDeltas } from "@opengeni/events";
78
+ import {
79
+ appendAndPublishEvents,
80
+ coalesceSessionEventDeltas,
81
+ publishDurableSessionEvents,
82
+ } from "@opengeni/events";
83
+ import { z } from "zod";
60
84
  import { withChannelA } from "../sandbox/channel-a";
61
85
  import { negotiateCapabilities } from "@opengeni/runtime/sandbox";
62
86
  import type { Context, Hono } from "hono";
63
87
  import { HTTPException } from "hono/http-exception";
64
88
  import { requireAccessGrant } from "@opengeni/core";
65
89
  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
90
  import {
69
- normalizeResources,
70
- validateFileResources,
71
- validateGitHubRepositorySelection,
72
- validateToolRefs,
73
- } from "@opengeni/core";
91
+ attachViewer,
92
+ detachViewer,
93
+ heartbeatViewer,
94
+ mintDesktopStream,
95
+ mintTerminalStream,
96
+ readGroupLease,
97
+ resolveActiveDesktopTransport,
98
+ viewerHeartbeatIntervalMs,
99
+ type DesktopStreamMint,
100
+ type TerminalStreamMint,
101
+ } from "../sandbox/viewer";
74
102
  import {
75
103
  acceptSessionUserMessage,
76
- assertConfiguredModel,
104
+ controlHumanSessionWorkstream,
77
105
  createSessionForRequest,
78
- requireQueuedTurnForApi,
106
+ deleteHumanQueuePrompt,
107
+ editHumanQueuePrompt,
108
+ getHumanComposerDraft,
109
+ moveHumanQueuePrompt,
110
+ readSessionLineage,
111
+ saveHumanComposerDraft,
112
+ steerHumanQueuePrompt,
79
113
  updateSessionTitle,
80
114
  workflowIdForSession,
81
115
  } from "@opengeni/core";
82
116
  import { assertSessionExists, boundedLimit } from "../http/common";
83
117
  import { sseSessionStream } from "../http/sse";
118
+ import { serveWorkspaceCapture, serveWorkspaceCaptureFile } from "./workspace-capture";
84
119
 
85
120
  export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
86
121
  const { settings, db, bus, workflowClient, objectStorage } = deps;
@@ -94,20 +129,97 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
94
129
 
95
130
  app.get("/v1/workspaces/:workspaceId/sessions", async (c) => {
96
131
  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"))));
132
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
133
+ const pageView = c.req.query("view") === "page";
134
+ const query = sessionListQuery(c.req.query(), pageView);
135
+ let page: Awaited<ReturnType<typeof listSessionsForSubject>>;
136
+ try {
137
+ page = await listSessionsForSubject(db, workspaceId, {
138
+ subjectId: grant.subjectId,
139
+ limit: boundedLimit(query.limit),
140
+ ...(query.cursor ? { cursor: query.cursor } : {}),
141
+ ...(query.search ? { search: query.search } : {}),
142
+ ...(query.parentSessionId !== undefined ? { parentSessionId: query.parentSessionId } : {}),
143
+ });
144
+ } catch (error) {
145
+ if (error instanceof SessionListAccessError) {
146
+ throw new HTTPException(403, { message: error.message });
147
+ }
148
+ if (error instanceof SessionListCursorError) {
149
+ throw new HTTPException(400, { message: error.message });
150
+ }
151
+ throw error;
152
+ }
153
+ if (pageView) {
154
+ return c.json(page);
155
+ }
156
+ // Same-major compatibility: listSessions() has historically returned an
157
+ // array. Preserve that wire shape while adding personal pin metadata/order;
158
+ // cursor consumers opt into the additive page view. A query flag rather
159
+ // than a /sessions/page path is deliberate: an older API safely ignores it
160
+ // and returns its historical array instead of treating "page" as a UUID.
161
+ return c.json([...page.pinned, ...page.sessions]);
99
162
  });
100
163
 
101
164
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId", async (c) => {
102
165
  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"));
166
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
167
+ const sessionId = c.req.param("sessionId");
168
+ if (!z.string().uuid().safeParse(sessionId).success) {
169
+ throw new HTTPException(404, { message: "session not found" });
170
+ }
171
+ const session = await getSessionForSubject(db, workspaceId, sessionId, grant.subjectId);
105
172
  if (!session) {
106
173
  throw new HTTPException(404, { message: "session not found" });
107
174
  }
108
175
  return c.json(session);
109
176
  });
110
177
 
178
+ // Personal pin only: this is organization state for the authenticated member,
179
+ // not a mutation of the shared session. It deliberately requires read access
180
+ // (not session control) and returns 404 for a foreign/inaccessible session.
181
+ app.put("/v1/workspaces/:workspaceId/sessions/:sessionId/pin", async (c) => {
182
+ const workspaceId = c.req.param("workspaceId");
183
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
184
+ const sessionId = c.req.param("sessionId");
185
+ if (!z.string().uuid().safeParse(sessionId).success) {
186
+ throw new HTTPException(404, { message: "session not found" });
187
+ }
188
+ const parsed = UpdateSessionPinRequest.safeParse(await c.req.json().catch(() => null));
189
+ if (!parsed.success) {
190
+ throw new HTTPException(400, { message: "invalid session pin request" });
191
+ }
192
+ try {
193
+ const session = await setSessionPin(db, {
194
+ workspaceId,
195
+ subjectId: grant.subjectId,
196
+ sessionId,
197
+ ...parsed.data,
198
+ });
199
+ if (!session) {
200
+ throw new HTTPException(404, { message: "session not found" });
201
+ }
202
+ return c.json(session);
203
+ } catch (error) {
204
+ if (error instanceof SessionPinAccessError) {
205
+ throw new HTTPException(403, { message: error.message });
206
+ }
207
+ if (error instanceof SessionPinVersionConflictError) {
208
+ return c.json(
209
+ { message: "session pin changed in another client", current: error.current },
210
+ 409,
211
+ );
212
+ }
213
+ throw error;
214
+ }
215
+ });
216
+
217
+ app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/lineage", async (c) => {
218
+ const workspaceId = c.req.param("workspaceId");
219
+ await requireAccessGrant(c, deps, workspaceId, "sessions:read");
220
+ return c.json(await readSessionLineage(db, workspaceId, c.req.param("sessionId")));
221
+ });
222
+
111
223
  // Pin (or unpin) the session's Codex account. body { target: "auto" | "<id>" }:
112
224
  // "auto" clears the pin (the session follows the workspace active pointer); a
113
225
  // uuid pins the session to that specific account. The pin applies to the NEXT
@@ -120,13 +232,41 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
120
232
  const body = (await c.req.json()) as { target?: string };
121
233
  const target = typeof body.target === "string" ? body.target : "";
122
234
  if (!target) {
123
- throw new HTTPException(400, { message: "target is required (\"auto\" or an account id)" });
235
+ throw new HTTPException(400, { message: 'target is required ("auto" or an account id)' });
124
236
  }
125
237
  const pinned = target === "auto" ? null : target;
126
- const ok = await setSessionCodexPin(db, workspaceId, sessionId, pinned);
238
+ const mutation = await withCodexCapacityMutation(
239
+ db,
240
+ { workspaceId, reason: "codex_manual_session_pin_changed" },
241
+ async (tx) => {
242
+ const changed = await setSessionCodexPin(tx, workspaceId, sessionId, pinned);
243
+ return { result: changed, changed };
244
+ },
245
+ );
246
+ const ok = mutation.result;
127
247
  if (!ok) {
128
248
  throw new HTTPException(404, { message: "session or codex account not found" });
129
249
  }
250
+ await Promise.allSettled(
251
+ mutation.wakeTargets.map((wake) =>
252
+ workflowClient.signalCodexCapacity
253
+ ? workflowClient.signalCodexCapacity({
254
+ accountId: wake.accountId,
255
+ workspaceId: wake.workspaceId,
256
+ sessionId: wake.sessionId,
257
+ workflowId: wake.workflowId,
258
+ wakeRevision: wake.wakeRevision,
259
+ workflowWakeRevision: wake.workflowWakeRevision,
260
+ })
261
+ : workflowClient.wakeSessionWorkflow({
262
+ accountId: wake.accountId,
263
+ workspaceId: wake.workspaceId,
264
+ sessionId: wake.sessionId,
265
+ workflowId: wake.workflowId,
266
+ wakeRevision: wake.workflowWakeRevision,
267
+ }),
268
+ ),
269
+ );
130
270
  return c.json({ pinned: target === "auto" ? "auto" : target });
131
271
  });
132
272
 
@@ -135,12 +275,15 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
135
275
  // agent writes. Returns the refreshed session, mirroring GET detail.
136
276
  app.patch("/v1/workspaces/:workspaceId/sessions/:sessionId", async (c) => {
137
277
  const workspaceId = c.req.param("workspaceId");
138
- await requireAccessGrant(c, deps, workspaceId, "sessions:control");
278
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
139
279
  const sessionId = c.req.param("sessionId");
140
280
  await assertSessionExists(db, workspaceId, sessionId);
141
281
  const payload = UpdateSessionRequest.parse(await c.req.json());
142
282
  await updateSessionTitle({ db, bus }, workspaceId, sessionId, payload.title, "user");
143
- const session = await getSession(db, workspaceId, sessionId);
283
+ // A session-returning member route must preserve the caller's private pin
284
+ // projection. Returning the generic mapSession() default here would reset a
285
+ // pinned React consumer to false/version 0 after a harmless rename.
286
+ const session = await getSessionForSubject(db, workspaceId, sessionId, grant.subjectId);
144
287
  if (!session) {
145
288
  throw new HTTPException(404, { message: "session not found" });
146
289
  }
@@ -170,7 +313,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
170
313
  throw new HTTPException(404, { message: "session goal not found" });
171
314
  }
172
315
  if (existing.status === "completed") {
173
- throw new HTTPException(409, { message: "session goal is completed; set a new goal instead" });
316
+ throw new HTTPException(409, {
317
+ message: "session goal is completed; set a new goal instead",
318
+ });
174
319
  }
175
320
  if (payload.status === "paused") {
176
321
  const { goal, changed } = await setSessionGoalStatus(db, workspaceId, sessionId, {
@@ -179,45 +324,86 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
179
324
  pausedReason: "api",
180
325
  });
181
326
  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,
327
+ await appendAndPublishEvents(db, bus, workspaceId, sessionId, [
328
+ {
329
+ type: "goal.paused",
330
+ payload: {
331
+ goalId: goal.id,
332
+ actor: "api",
333
+ reason: "api",
334
+ ...(payload.rationale ? { rationale: payload.rationale } : {}),
335
+ autoContinuations: goal.autoContinuations,
336
+ noProgressStreak: goal.noProgressStreak,
337
+ },
191
338
  },
192
- }]);
339
+ ]);
193
340
  }
194
341
  return c.json(goal);
195
342
  }
196
343
  // Resume: only valid from paused; resets counters and re-arms the loop.
197
344
  if (existing.status !== "paused") {
198
- throw new HTTPException(409, { message: `session goal is ${existing.status}; only paused goals can be resumed` });
345
+ throw new HTTPException(409, {
346
+ message: `session goal is ${existing.status}; only paused goals can be resumed`,
347
+ });
199
348
  }
200
- const { goal, changed } = await setSessionGoalStatus(db, workspaceId, sessionId, { status: "active" });
349
+ const { goal, changed, workflowWakeRevision } = await setSessionGoalStatus(
350
+ db,
351
+ workspaceId,
352
+ sessionId,
353
+ {
354
+ status: "active",
355
+ },
356
+ );
201
357
  // `changed` guards the racing-PATCH case: both requests can pass the
202
358
  // status pre-check, but only the transition winner emits and wakes.
203
359
  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",
360
+ await appendAndPublishEvents(db, bus, workspaceId, sessionId, [
361
+ {
362
+ type: "goal.resumed",
363
+ payload: {
364
+ goalId: goal.id,
365
+ text: goal.text,
366
+ ...(goal.successCriteria ? { successCriteria: goal.successCriteria } : {}),
367
+ version: goal.version,
368
+ actor: "api",
369
+ },
212
370
  },
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) });
371
+ ]);
372
+ // signalWithStart restarts an eligible idle workflow so maybeContinueGoal
373
+ // fires. A closed workspace/session gate keeps the resumed goal durable
374
+ // and inert until that gate's own Resume mutation commits its wake.
375
+ if (workflowWakeRevision !== null) {
376
+ await workflowClient.wakeSessionWorkflow({
377
+ accountId: grant.accountId,
378
+ workspaceId,
379
+ sessionId,
380
+ workflowId: workflowIdForSession(sessionId),
381
+ wakeRevision: workflowWakeRevision,
382
+ });
383
+ }
217
384
  }
218
385
  return c.json(goal);
219
386
  });
220
387
 
388
+ app.delete("/v1/workspaces/:workspaceId/sessions/:sessionId/goal", async (c) => {
389
+ const workspaceId = c.req.param("workspaceId");
390
+ await requireAccessGrant(c, deps, workspaceId, "sessions:control");
391
+ const sessionId = c.req.param("sessionId");
392
+ await assertSessionExists(db, workspaceId, sessionId);
393
+ const { event } = await clearSessionGoal(db, workspaceId, sessionId);
394
+ if (event) {
395
+ try {
396
+ await bus.publish(workspaceId, sessionId, [event]);
397
+ } catch (error) {
398
+ console.warn(
399
+ `[api] live publish failed for cleared goal ${workspaceId}/${sessionId}; event is durable and reconciles on replay`,
400
+ error,
401
+ );
402
+ }
403
+ }
404
+ return c.body(null, 204);
405
+ });
406
+
221
407
  // Operator context controls (slash-command palette: /clear, /compact). These
222
408
  // are session/operator actions — NOT a structured channel to the agent. Both
223
409
  // require sessions:control.
@@ -232,51 +418,53 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
232
418
  // missing/false confirm is a client error (400), not a server fault.
233
419
  const clearBody = ClearSessionContextRequest.safeParse(await c.req.json().catch(() => ({})));
234
420
  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` });
421
+ throw new HTTPException(400, {
422
+ message: "context clear requires an explicit { confirm: true }",
423
+ });
243
424
  }
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,
425
+ // The database checks this under workspace/session locks so a turn cannot
426
+ // start between an API precheck and the history rewrite.
427
+ const result = await clearSessionContext(db, {
428
+ accountId: grant.accountId,
429
+ workspaceId,
430
+ sessionId,
431
+ }).catch((error: unknown) => {
432
+ if (error instanceof SessionContextBusyError) {
433
+ throw new HTTPException(409, { message: error.message });
434
+ }
435
+ throw error;
436
+ });
437
+ await appendAndPublishEvents(db, bus, workspaceId, sessionId, [
438
+ {
439
+ type: "session.context.cleared",
440
+ payload: {
441
+ clearedBy: "api",
442
+ supersededItems: result.supersededItems,
443
+ markerPosition: result.markerPosition,
444
+ },
251
445
  },
252
- }]);
446
+ ]);
253
447
  return c.body(null, 204);
254
448
  });
255
449
 
256
450
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/context/compact", async (c) => {
257
451
  const workspaceId = c.req.param("workspaceId");
258
- await requireAccessGrant(c, deps, workspaceId, "sessions:control");
452
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
259
453
  const sessionId = c.req.param("sessionId");
260
454
  await assertSessionExists(db, workspaceId, sessionId);
261
455
  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." });
456
+ // /compact sets one durable request. The worker clears it only in the same
457
+ // fenced transaction that installs replacement history, so failed or stale
458
+ // attempts cannot lose the request.
459
+ const requested = await requestSessionCompaction(db, workspaceId, sessionId);
460
+ await workflowClient.wakeSessionWorkflow({
461
+ accountId: grant.accountId,
462
+ workspaceId,
463
+ sessionId,
464
+ workflowId: requested.temporalWorkflowId,
465
+ wakeRevision: requested.wakeRevision,
466
+ });
467
+ return c.json({ status: "pending", message: "Compaction will run at the next safe boundary." });
280
468
  });
281
469
 
282
470
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/events", async (c) => {
@@ -302,7 +490,14 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
302
490
  const sessionId = c.req.param("sessionId");
303
491
  await assertSessionExists(db, workspaceId, sessionId);
304
492
  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);
493
+ return sseSessionStream(
494
+ db,
495
+ bus,
496
+ workspaceId,
497
+ sessionId,
498
+ Number.isFinite(after) ? after : 0,
499
+ c.req.raw.signal,
500
+ );
306
501
  });
307
502
 
308
503
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/turns", async (c) => {
@@ -310,78 +505,174 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
310
505
  await requireAccessGrant(c, deps, workspaceId, "sessions:read");
311
506
  const sessionId = c.req.param("sessionId");
312
507
  await assertSessionExists(db, workspaceId, sessionId);
313
- return c.json(await listSessionTurns(db, workspaceId, sessionId, boundedLimit(c.req.query("limit"))));
508
+ return c.json(
509
+ await listSessionTurns(db, workspaceId, sessionId, boundedLimit(c.req.query("limit"))),
510
+ );
314
511
  });
315
512
 
316
- app.patch("/v1/workspaces/:workspaceId/sessions/:sessionId/turns/:turnId", async (c) => {
513
+ app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/queue", async (c) => {
317
514
  const workspaceId = c.req.param("workspaceId");
318
- await requireAccessGrant(c, deps, workspaceId, "sessions:control");
515
+ await requireAccessGrant(c, deps, workspaceId, "sessions:read");
516
+ const sessionId = c.req.param("sessionId");
517
+ const snapshot = await getSessionQueueSnapshot(db, workspaceId, sessionId);
518
+ if (!snapshot) throw new HTTPException(404, { message: "session not found" });
519
+ return c.json(snapshot);
520
+ });
521
+
522
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/queue/:turnId/move", async (c) => {
523
+ const workspaceId = c.req.param("workspaceId");
524
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
319
525
  const sessionId = c.req.param("sessionId");
320
- const turnId = c.req.param("turnId");
321
526
  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" });
527
+ const payload = MoveSessionQueueItemRequest.parse(await c.req.json());
528
+ try {
529
+ return c.json(
530
+ await moveHumanQueuePrompt(
531
+ { db, bus },
532
+ { accountId: grant.accountId, workspaceId, sessionId, subjectId: grant.subjectId },
533
+ c.req.param("turnId"),
534
+ payload,
535
+ ),
536
+ );
537
+ } catch (error) {
538
+ return commandConflictResponse(c, error);
336
539
  }
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
540
  });
355
541
 
356
- app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/turns/reorder", async (c) => {
542
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/queue/:turnId/edit", async (c) => {
357
543
  const workspaceId = c.req.param("workspaceId");
358
544
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
359
545
  const sessionId = c.req.param("sessionId");
360
546
  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);
547
+ const payload = EditSessionQueueItemRequest.parse(await c.req.json());
548
+ try {
549
+ return c.json(
550
+ await editHumanQueuePrompt(
551
+ { db, bus },
552
+ { accountId: grant.accountId, workspaceId, sessionId, subjectId: grant.subjectId },
553
+ c.req.param("turnId"),
554
+ payload,
555
+ ),
556
+ );
557
+ } catch (error) {
558
+ return commandConflictResponse(c, error);
559
+ }
369
560
  });
370
561
 
371
- app.delete("/v1/workspaces/:workspaceId/sessions/:sessionId/turns/:turnId", async (c) => {
562
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/queue/:turnId/steer", async (c) => {
372
563
  const workspaceId = c.req.param("workspaceId");
373
- await requireAccessGrant(c, deps, workspaceId, "sessions:control");
564
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
374
565
  const sessionId = c.req.param("sessionId");
375
- const turnId = c.req.param("turnId");
376
566
  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);
567
+ const payload = SteerSessionQueueItemRequest.parse(await c.req.json());
568
+ try {
569
+ return c.json(
570
+ await steerHumanQueuePrompt(
571
+ { db, bus },
572
+ { accountId: grant.accountId, workspaceId, sessionId, subjectId: grant.subjectId },
573
+ c.req.param("turnId"),
574
+ payload,
575
+ ),
576
+ );
577
+ } catch (error) {
578
+ return commandConflictResponse(c, error);
579
+ }
580
+ });
581
+
582
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/queue/:turnId/delete", async (c) => {
583
+ const workspaceId = c.req.param("workspaceId");
584
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
585
+ const sessionId = c.req.param("sessionId");
586
+ await assertSessionExists(db, workspaceId, sessionId);
587
+ const payload = DeleteSessionQueueItemRequest.parse(await c.req.json());
588
+ try {
589
+ return c.json(
590
+ await deleteHumanQueuePrompt(
591
+ { db, bus },
592
+ { accountId: grant.accountId, workspaceId, sessionId, subjectId: grant.subjectId },
593
+ c.req.param("turnId"),
594
+ payload,
595
+ ),
596
+ );
597
+ } catch (error) {
598
+ return commandConflictResponse(c, error);
599
+ }
600
+ });
601
+
602
+ app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/composer-draft", async (c) => {
603
+ const workspaceId = c.req.param("workspaceId");
604
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
605
+ const sessionId = c.req.param("sessionId");
606
+ return c.json(
607
+ await getHumanComposerDraft(db, {
608
+ accountId: grant.accountId,
609
+ workspaceId,
610
+ sessionId,
611
+ subjectId: grant.subjectId,
612
+ }),
613
+ );
614
+ });
615
+
616
+ app.put("/v1/workspaces/:workspaceId/sessions/:sessionId/composer-draft", async (c) => {
617
+ const workspaceId = c.req.param("workspaceId");
618
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
619
+ const sessionId = c.req.param("sessionId");
620
+ const payload = SaveComposerDraftRequest.parse(await c.req.json());
621
+ try {
622
+ return c.json(
623
+ await saveHumanComposerDraft(
624
+ db,
625
+ { accountId: grant.accountId, workspaceId, sessionId, subjectId: grant.subjectId },
626
+ payload,
627
+ ),
628
+ );
629
+ } catch (error) {
630
+ return commandConflictResponse(c, error);
631
+ }
632
+ });
633
+
634
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/control", async (c) => {
635
+ const workspaceId = c.req.param("workspaceId");
636
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
637
+ const sessionId = c.req.param("sessionId");
638
+ const payload = SessionControlRequest.parse(await c.req.json());
639
+ try {
640
+ return c.json(
641
+ await controlHumanSessionWorkstream(
642
+ { db, bus, workflowClient },
643
+ { accountId: grant.accountId, workspaceId, sessionId, subjectId: grant.subjectId },
644
+ payload,
645
+ ),
646
+ );
647
+ } catch (error) {
648
+ return commandConflictResponse(c, error);
649
+ }
650
+ });
651
+
652
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/steer", async (c) => {
653
+ const workspaceId = c.req.param("workspaceId");
654
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
655
+ const sessionId = c.req.param("sessionId");
656
+ await assertSessionExists(db, workspaceId, sessionId);
657
+ const raw = await c.req.json();
658
+ const payload = SteerSessionMessageRequest.parse(raw);
659
+ const result = await acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
660
+ text: payload.text,
661
+ resources: payload.resources,
662
+ tools: payload.tools,
663
+ toolsProvided: userMessagePayloadHasOwnProperty({ payload: raw }, "tools"),
664
+ model: payload.model ?? null,
665
+ reasoningEffort: payload.reasoningEffort ?? null,
666
+ mcpCredentialUpdates: payload.mcpCredentialUpdates ?? [],
667
+ delivery: "steer",
668
+ origin: "human",
669
+ ...(payload.controlEtag !== undefined ? { controlEtag: payload.controlEtag } : {}),
670
+ ...(payload.expectedDraftRevision !== undefined
671
+ ? { expectedDraftRevision: payload.expectedDraftRevision }
672
+ : {}),
673
+ ...(payload.clientEventId ? { clientEventId: payload.clientEventId } : {}),
674
+ });
675
+ return c.json(result, 202);
385
676
  });
386
677
 
387
678
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/events", async (c) => {
@@ -399,38 +690,42 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
399
690
  model: event.payload.model ?? null,
400
691
  reasoningEffort: event.payload.reasoningEffort ?? null,
401
692
  mcpCredentialUpdates: event.payload.mcpCredentialUpdates ?? [],
693
+ ...(event.payload.controlEtag !== undefined
694
+ ? { controlEtag: event.payload.controlEtag }
695
+ : {}),
696
+ ...(event.payload.expectedDraftRevision !== undefined
697
+ ? { expectedDraftRevision: event.payload.expectedDraftRevision }
698
+ : {}),
402
699
  ...(event.clientEventId ? { clientEventId: event.clientEventId } : {}),
403
700
  });
404
701
  return c.json(accepted, 202);
405
702
  }
406
703
 
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
704
  if (event.type === "user.approvalDecision") {
423
- await workflowClient.signalApprovalDecision({ sessionId, eventId: accepted.id, workflowId });
424
- } else {
425
- await workflowClient.signalInterrupt({
705
+ const accepted = await acceptSessionApprovalDecision(db, {
426
706
  accountId: grant.accountId,
427
707
  workspaceId,
428
708
  sessionId,
429
- eventId: accepted.id,
709
+ payload: event.payload,
710
+ clientEventId: event.clientEventId ?? null,
711
+ });
712
+ if (accepted.action === "conflict") {
713
+ throw new HTTPException(409, {
714
+ message: `session is ${accepted.sessionStatus}; no unhandled approval is pending`,
715
+ });
716
+ }
717
+ await publishDurableSessionEvents(bus, workspaceId, sessionId, accepted.events);
718
+ const workflowId = workflowIdForSession(sessionId);
719
+ await workflowClient.signalApprovalDecision({
720
+ accountId: grant.accountId,
721
+ workspaceId,
722
+ sessionId,
723
+ eventId: accepted.event.id,
430
724
  workflowId,
725
+ workflowWakeRevision: accepted.workflowWakeRevision,
431
726
  });
727
+ return c.json(accepted.event, 202);
432
728
  }
433
- return c.json(accepted, 202);
434
729
  });
435
730
 
436
731
  // ── API-direct stream capabilities + viewer attach (P1.4) ─────────────────
@@ -449,7 +744,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
449
744
  // The viewer-holder lifecycle rides the sandbox lease, which is dormant
450
745
  // until the flag flips per-environment. A 404 (not 403) keeps the route
451
746
  // 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" });
747
+ throw new HTTPException(404, {
748
+ message: "sandbox ownership is not enabled for this deployment",
749
+ });
453
750
  }
454
751
  }
455
752
 
@@ -479,7 +776,10 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
479
776
  if (!session) {
480
777
  throw new HTTPException(404, { message: "session not found" });
481
778
  }
482
- const lease = await readGroupLease({ db, settings }, { workspaceId, sandboxGroupId: session.sandboxGroupId });
779
+ const lease = await readGroupLease(
780
+ { db, settings },
781
+ { workspaceId, sandboxGroupId: session.sandboxGroupId },
782
+ );
483
783
  const { shared, sharedSessionIds } = await resolveSharedExposure(workspaceId, session);
484
784
  // Per-principal acknowledgment: A acknowledging does not consent for B. The
485
785
  // un-redacted desktop stream ALWAYS requires the un-redacted ack; a shared box
@@ -488,8 +788,14 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
488
788
  // WITHOUT un-redacted consent could be handed a live VNC URL + scoped token
489
789
  // from this read path while being correctly 409'd on attach (a consent-gate
490
790
  // 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;
791
+ const ack = await getStreamAcknowledgment(db, {
792
+ workspaceId,
793
+ sandboxGroupId: session.sandboxGroupId,
794
+ subjectId: grant.subjectId,
795
+ });
796
+ const acknowledged = ack
797
+ ? ack.acknowledgedUnredacted && (!shared || ack.acknowledgedShared)
798
+ : false;
493
799
 
494
800
  // P4.2 — the pixel DATA PLANE, served API-direct. When the backend is
495
801
  // desktop-capable AND sandboxDesktopEnabled AND the (shared, if shared)
@@ -502,22 +808,27 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
502
808
  // (no secret / display-stack or tunnel failure) returns null → transport:null.
503
809
  let desktopStream: DesktopStreamMint | null = null;
504
810
  const desktopUnlocked =
505
- settings.sandboxDesktopEnabled
506
- && !streamTokenDegraded(settings)
507
- && acknowledged
508
- && (session.activeSandboxId != null || lease?.liveness === "warm" || lease?.liveness === "draining");
811
+ settings.sandboxDesktopEnabled &&
812
+ !streamTokenDegraded(settings) &&
813
+ acknowledged &&
814
+ (session.activeSandboxId != null ||
815
+ lease?.liveness === "warm" ||
816
+ lease?.liveness === "draining");
509
817
  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
- });
818
+ desktopStream = await mintDesktopStream(
819
+ { db, settings, bus },
820
+ {
821
+ accountId: grant.accountId,
822
+ workspaceId,
823
+ session,
824
+ // The handshake's token is scoped to the calling principal (it is a read,
825
+ // not a viewer-holder acquire); the per-holder token is re-minted on
826
+ // POST /viewers. A previousEpoch != current would have rotated already
827
+ // via the warming-commit; the read does not itself drive rotation.
828
+ viewerId: grant.subjectId,
829
+ ...(lease ? { lease } : {}),
830
+ },
831
+ );
521
832
  }
522
833
 
523
834
  // P5.t — the REAL PTY terminal cell, served API-DIRECT. Independent of the
@@ -527,17 +838,22 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
527
838
  // returns null → the Terminal cell falls back to the sse-events firehose.
528
839
  let terminalStream: TerminalStreamMint | null = null;
529
840
  const terminalUnlocked =
530
- settings.sandboxTerminalEnabled
531
- && !streamTokenDegraded(settings)
532
- && (session.activeSandboxId != null || lease?.liveness === "warm" || lease?.liveness === "draining");
841
+ settings.sandboxTerminalEnabled &&
842
+ !streamTokenDegraded(settings) &&
843
+ (session.activeSandboxId != null ||
844
+ lease?.liveness === "warm" ||
845
+ lease?.liveness === "draining");
533
846
  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
- });
847
+ terminalStream = await mintTerminalStream(
848
+ { db, settings, bus },
849
+ {
850
+ accountId: grant.accountId,
851
+ workspaceId,
852
+ session,
853
+ viewerId: grant.subjectId,
854
+ ...(lease ? { lease } : {}),
855
+ },
856
+ );
541
857
  }
542
858
 
543
859
  const capabilities = negotiateCapabilities({
@@ -611,8 +927,13 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
611
927
  // place (resolveActiveDesktopTransport), covering BOTH swap directions.
612
928
  let responseCapabilities = capabilities;
613
929
  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);
930
+ const activeSandbox = session.activeSandboxId
931
+ ? await getSandbox(db, workspaceId, session.activeSandboxId)
932
+ : null;
933
+ const wire = resolveActiveDesktopTransport(
934
+ activeSandbox?.kind === "selfhosted",
935
+ settings.sandboxDesktopInteractive !== false,
936
+ );
616
937
  responseCapabilities = {
617
938
  ...capabilities,
618
939
  DesktopStream: { ...capabilities.DesktopStream, ...wire },
@@ -626,29 +947,35 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
626
947
  // shared-exposure disclosure). Reuses the acknowledgment machinery — gated on
627
948
  // stream:acknowledge, no new permission. Until this is recorded the
628
949
  // 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
- });
950
+ app.post(
951
+ "/v1/workspaces/:workspaceId/sessions/:sessionId/stream-capabilities/acknowledge",
952
+ async (c) => {
953
+ const workspaceId = c.req.param("workspaceId");
954
+ const grant = await requireAccessGrant(c, deps, workspaceId, "stream:acknowledge");
955
+ assertOwnershipEnabled();
956
+ const sessionId = c.req.param("sessionId");
957
+ const session = await getSession(db, workspaceId, sessionId);
958
+ if (!session) {
959
+ throw new HTTPException(404, { message: "session not found" });
960
+ }
961
+ const parsed = AcknowledgeStreamRequest.safeParse(await c.req.json().catch(() => ({})));
962
+ if (!parsed.success) {
963
+ throw new HTTPException(400, { message: "invalid stream acknowledgment request" });
964
+ }
965
+ const recorded = await recordStreamAcknowledgment(db, {
966
+ accountId: grant.accountId,
967
+ workspaceId,
968
+ sandboxGroupId: session.sandboxGroupId,
969
+ subjectId: grant.subjectId,
970
+ acknowledgeUnredacted: parsed.data.acknowledgeUnredacted,
971
+ acknowledgeShared: parsed.data.acknowledgeShared,
972
+ });
973
+ return c.json({
974
+ acknowledged: recorded.acknowledgedUnredacted,
975
+ acknowledgedShared: recorded.acknowledgedShared,
976
+ });
977
+ },
978
+ );
652
979
 
653
980
  // POST .../viewers — acquire a viewer holder on the desktop-stream (un-redacted
654
981
  // pixel) path. Gated on stream:view (strictly broader than sessions:read: the
@@ -684,7 +1011,11 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
684
1011
  const wantDesktop = parsed.data.desktop ?? false;
685
1012
  const { shared } = await resolveSharedExposure(workspaceId, session);
686
1013
  if (wantDesktop) {
687
- const ack = await getStreamAcknowledgment(db, { workspaceId, sandboxGroupId: session.sandboxGroupId, subjectId: grant.subjectId });
1014
+ const ack = await getStreamAcknowledgment(db, {
1015
+ workspaceId,
1016
+ sandboxGroupId: session.sandboxGroupId,
1017
+ subjectId: grant.subjectId,
1018
+ });
688
1019
  if (!ack?.acknowledgedUnredacted) {
689
1020
  throw new HTTPException(409, { message: "stream_acknowledgment_required" });
690
1021
  }
@@ -695,7 +1026,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
695
1026
  // SELFHOSTED ACTIVE: when the session's active sandbox is selfhosted, skip
696
1027
  // attachViewer (it warms the Modal group box — the wrong target). Synthesize a
697
1028
  // result shaped like ViewerAttachResult and mint relay cells directly.
698
- const activeSandbox = session.activeSandboxId ? await getSandbox(db, workspaceId, session.activeSandboxId) : null;
1029
+ const activeSandbox = session.activeSandboxId
1030
+ ? await getSandbox(db, workspaceId, session.activeSandboxId)
1031
+ : null;
699
1032
  const selfhostedActive = activeSandbox?.kind === "selfhosted";
700
1033
 
701
1034
  let stream: DesktopStreamMint | null = null;
@@ -713,35 +1046,44 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
713
1046
  dataPlaneUrl: null,
714
1047
  };
715
1048
  if (
716
- (settings.sandboxDesktopEnabled || settings.sandboxTerminalEnabled)
717
- && !streamTokenDegraded(settings)
1049
+ (settings.sandboxDesktopEnabled || settings.sandboxTerminalEnabled) &&
1050
+ !streamTokenDegraded(settings)
718
1051
  ) {
719
1052
  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
- });
1053
+ stream = await mintDesktopStream(
1054
+ { db, settings, bus },
1055
+ {
1056
+ accountId: grant.accountId,
1057
+ workspaceId,
1058
+ session,
1059
+ viewerId,
1060
+ // No Modal lease for selfhosted-active; the mint routes to the relay.
1061
+ },
1062
+ );
727
1063
  }
728
1064
  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
- });
1065
+ terminal = await mintTerminalStream(
1066
+ { db, settings, bus },
1067
+ {
1068
+ accountId: grant.accountId,
1069
+ workspaceId,
1070
+ session,
1071
+ viewerId,
1072
+ // No Modal lease for selfhosted-active; the mint routes to the relay.
1073
+ },
1074
+ );
736
1075
  }
737
1076
  }
738
1077
  } else {
739
- result = await attachViewer({ db, settings }, {
740
- accountId: grant.accountId,
741
- workspaceId,
742
- session,
743
- ...(parsed.data.viewerId ? { viewerId: parsed.data.viewerId } : {}),
744
- });
1078
+ result = await attachViewer(
1079
+ { db, settings },
1080
+ {
1081
+ accountId: grant.accountId,
1082
+ workspaceId,
1083
+ session,
1084
+ ...(parsed.data.viewerId ? { viewerId: parsed.data.viewerId } : {}),
1085
+ },
1086
+ );
745
1087
 
746
1088
  // P4.2 — the viewer now holds a WARM box; mint the real pixel cell IN-PROCESS
747
1089
  // (resume by id → ensureDisplayStack → exposeStreamPort) scoped to THIS
@@ -751,34 +1093,43 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
751
1093
  // box is warm here (attachViewer spun it up or attached), so the handshake's
752
1094
  // never-spin-up rule does not apply.
753
1095
  if (
754
- (settings.sandboxDesktopEnabled || settings.sandboxTerminalEnabled)
755
- && !streamTokenDegraded(settings)
1096
+ (settings.sandboxDesktopEnabled || settings.sandboxTerminalEnabled) &&
1097
+ !streamTokenDegraded(settings)
756
1098
  ) {
757
- const lease = await readGroupLease({ db, settings }, { workspaceId, sandboxGroupId: session.sandboxGroupId });
1099
+ const lease = await readGroupLease(
1100
+ { db, settings },
1101
+ { workspaceId, sandboxGroupId: session.sandboxGroupId },
1102
+ );
758
1103
  if (lease) {
759
1104
  // The pixel cell is minted only when the caller asked for the desktop plane
760
1105
  // (and consented above). A terminal-only attach skips it — the box is warm,
761
1106
  // the terminal mint below still runs.
762
1107
  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
- });
1108
+ stream = await mintDesktopStream(
1109
+ { db, settings, bus },
1110
+ {
1111
+ accountId: grant.accountId,
1112
+ workspaceId,
1113
+ session,
1114
+ viewerId: result.viewerId,
1115
+ lease,
1116
+ },
1117
+ );
770
1118
  }
771
1119
  // P5.t — the same warm-box viewer attach also mints the REAL PTY terminal
772
1120
  // address (independent of the desktop toggle). A degraded mint leaves the
773
1121
  // terminal fields null → the client falls back to the sse-events firehose.
774
1122
  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
- });
1123
+ terminal = await mintTerminalStream(
1124
+ { db, settings, bus },
1125
+ {
1126
+ accountId: grant.accountId,
1127
+ workspaceId,
1128
+ session,
1129
+ viewerId: result.viewerId,
1130
+ lease,
1131
+ },
1132
+ );
782
1133
  }
783
1134
  }
784
1135
  }
@@ -795,7 +1146,11 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
795
1146
  // (vnc-ws/novnc). Hardcoding vnc-ws here handed a machine's relay URL to the
796
1147
  // noVNC renderer (and vice-versa on the swap-away case) → "closed before it
797
1148
  // opened". Key off the SAME selfhostedActive the mint routed on.
798
- transport: stream ? (selfhostedActive ? ("relay-frames" as const) : ("vnc-ws" as const)) : null,
1149
+ transport: stream
1150
+ ? selfhostedActive
1151
+ ? ("relay-frames" as const)
1152
+ : ("vnc-ws" as const)
1153
+ : null,
799
1154
  client: stream ? (selfhostedActive ? ("frames" as const) : ("novnc" as const)) : null,
800
1155
  // The REAL PTY terminal address (pty-ws), null when degraded.
801
1156
  terminalUrl: terminal?.url ?? null,
@@ -809,28 +1164,34 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
809
1164
 
810
1165
  // POST .../viewers/:viewerId/heartbeat — refresh the holder TTL (epoch-fenced).
811
1166
  // 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
- });
1167
+ app.post(
1168
+ "/v1/workspaces/:workspaceId/sessions/:sessionId/viewers/:viewerId/heartbeat",
1169
+ async (c) => {
1170
+ const workspaceId = c.req.param("workspaceId");
1171
+ const grant = await requireAccessGrant(c, deps, workspaceId, "stream:view");
1172
+ assertOwnershipEnabled();
1173
+ const sessionId = c.req.param("sessionId");
1174
+ const session = await getSession(db, workspaceId, sessionId);
1175
+ if (!session) {
1176
+ throw new HTTPException(404, { message: "session not found" });
1177
+ }
1178
+ const parsed = ViewerHeartbeatRequest.safeParse(await c.req.json().catch(() => ({})));
1179
+ if (!parsed.success) {
1180
+ throw new HTTPException(400, { message: "viewer heartbeat requires { leaseEpoch }" });
1181
+ }
1182
+ const alive = await heartbeatViewer(
1183
+ { db, settings },
1184
+ {
1185
+ accountId: grant.accountId,
1186
+ workspaceId,
1187
+ sandboxGroupId: session.sandboxGroupId,
1188
+ viewerId: c.req.param("viewerId"),
1189
+ expectedEpoch: parsed.data.leaseEpoch,
1190
+ },
1191
+ );
1192
+ return c.json({ alive });
1193
+ },
1194
+ );
834
1195
 
835
1196
  // DELETE .../viewers/:viewerId — release the holder (idempotent).
836
1197
  app.delete("/v1/workspaces/:workspaceId/sessions/:sessionId/viewers/:viewerId", async (c) => {
@@ -842,12 +1203,15 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
842
1203
  if (!session) {
843
1204
  throw new HTTPException(404, { message: "session not found" });
844
1205
  }
845
- await detachViewer({ db, settings }, {
846
- accountId: grant.accountId,
847
- workspaceId,
848
- sandboxGroupId: session.sandboxGroupId,
849
- viewerId: c.req.param("viewerId"),
850
- });
1206
+ await detachViewer(
1207
+ { db, settings },
1208
+ {
1209
+ accountId: grant.accountId,
1210
+ workspaceId,
1211
+ sandboxGroupId: session.sandboxGroupId,
1212
+ viewerId: c.req.param("viewerId"),
1213
+ },
1214
+ );
851
1215
  return c.body(null, 204);
852
1216
  });
853
1217
 
@@ -857,25 +1221,28 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
857
1221
  // group-refcount liveness). Gated on stream:view (no new permission). The
858
1222
  // live-RFB force-disconnect of an already-open socket is a P4 follow-up; the
859
1223
  // 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
- });
1224
+ app.post(
1225
+ "/v1/workspaces/:workspaceId/sessions/:sessionId/viewers/:viewerId/revoke",
1226
+ async (c) => {
1227
+ const workspaceId = c.req.param("workspaceId");
1228
+ const grant = await requireAccessGrant(c, deps, workspaceId, "stream:view");
1229
+ assertOwnershipEnabled();
1230
+ const sessionId = c.req.param("sessionId");
1231
+ const session = await getSession(db, workspaceId, sessionId);
1232
+ if (!session) {
1233
+ throw new HTTPException(404, { message: "session not found" });
1234
+ }
1235
+ const result = await revokeViewer(db, {
1236
+ accountId: grant.accountId,
1237
+ workspaceId,
1238
+ sandboxGroupId: session.sandboxGroupId,
1239
+ viewerId: c.req.param("viewerId"),
1240
+ idleGraceMs: settings.sandboxIdleGraceMs,
1241
+ });
1242
+ // null ⇒ the lease was already cold-and-reaped (revoke is an idempotent no-op).
1243
+ return c.json({ liveness: result?.liveness ?? null, refcount: result?.refcount ?? null });
1244
+ },
1245
+ );
879
1246
 
880
1247
  // ══════════════════════ Channel-A structured services (P4.4) ══════════════
881
1248
  //
@@ -917,7 +1284,10 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
917
1284
  return { accountId: grant.accountId, workspaceId, session, subjectId: grant.subjectId };
918
1285
  }
919
1286
 
920
- async function parseChannelABody<T>(c: Context, schema: { safeParse: (v: unknown) => { success: true; data: T } | { success: false } }): Promise<T> {
1287
+ async function parseChannelABody<T>(
1288
+ c: Context,
1289
+ schema: { safeParse: (v: unknown) => { success: true; data: T } | { success: false } },
1290
+ ): Promise<T> {
921
1291
  const raw = await c.req.json().catch(() => undefined);
922
1292
  const result = schema.safeParse(raw ?? {});
923
1293
  if (!result.success) {
@@ -930,42 +1300,54 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
930
1300
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/list", async (c) => {
931
1301
  const ctx = await channelAPreamble(c, "files:read");
932
1302
  const req = await parseChannelABody(c, FsListRequest);
933
- const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.fsList(req));
1303
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) =>
1304
+ service.fsList(req),
1305
+ );
934
1306
  return c.json(out);
935
1307
  });
936
1308
 
937
1309
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/read", async (c) => {
938
1310
  const ctx = await channelAPreamble(c, "files:read");
939
1311
  const req = await parseChannelABody(c, FsReadRequest);
940
- const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.fsRead(req));
1312
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) =>
1313
+ service.fsRead(req),
1314
+ );
941
1315
  return c.json(out);
942
1316
  });
943
1317
 
944
1318
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/write", async (c) => {
945
1319
  const ctx = await channelAPreamble(c, "files:write");
946
1320
  const req = await parseChannelABody(c, FsWriteRequest);
947
- const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.fsWrite(req));
1321
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) =>
1322
+ service.fsWrite(req),
1323
+ );
948
1324
  return c.json(out);
949
1325
  });
950
1326
 
951
1327
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/delete", async (c) => {
952
1328
  const ctx = await channelAPreamble(c, "files:write");
953
1329
  const req = await parseChannelABody(c, FsDeleteRequest);
954
- const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.fsDelete(req));
1330
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) =>
1331
+ service.fsDelete(req),
1332
+ );
955
1333
  return c.json(out);
956
1334
  });
957
1335
 
958
1336
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/move", async (c) => {
959
1337
  const ctx = await channelAPreamble(c, "files:write");
960
1338
  const req = await parseChannelABody(c, FsMoveRequest);
961
- const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.fsMove(req));
1339
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) =>
1340
+ service.fsMove(req),
1341
+ );
962
1342
  return c.json(out);
963
1343
  });
964
1344
 
965
1345
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/mkdir", async (c) => {
966
1346
  const ctx = await channelAPreamble(c, "files:write");
967
1347
  const req = await parseChannelABody(c, FsMkdirRequest);
968
- const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.fsMkdir(req));
1348
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) =>
1349
+ service.fsMkdir(req),
1350
+ );
969
1351
  return c.json(out);
970
1352
  });
971
1353
 
@@ -973,36 +1355,98 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
973
1355
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/status", async (c) => {
974
1356
  const ctx = await channelAPreamble(c, "files:read");
975
1357
  const req = await parseChannelABody(c, GitStatusRequest);
976
- const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.gitStatus(req));
1358
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) =>
1359
+ service.gitStatus(req),
1360
+ );
977
1361
  return c.json(out);
978
1362
  });
979
1363
 
980
1364
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/diff", async (c) => {
981
1365
  const ctx = await channelAPreamble(c, "files:read");
982
1366
  const req = await parseChannelABody(c, GitDiffRequest);
983
- const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.gitDiff(req));
1367
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) =>
1368
+ service.gitDiff(req),
1369
+ );
984
1370
  return c.json(out);
985
1371
  });
986
1372
 
987
1373
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/log", async (c) => {
988
1374
  const ctx = await channelAPreamble(c, "files:read");
989
1375
  const req = await parseChannelABody(c, GitLogRequest);
990
- const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.gitLog(req));
1376
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) =>
1377
+ service.gitLog(req),
1378
+ );
991
1379
  return c.json(out);
992
1380
  });
993
1381
 
994
1382
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/show", async (c) => {
995
1383
  const ctx = await channelAPreamble(c, "files:read");
996
1384
  const req = await parseChannelABody(c, GitShowRequest);
997
- const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.gitShow(req));
1385
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) =>
1386
+ service.gitShow(req),
1387
+ );
998
1388
  return c.json(out);
999
1389
  });
1000
1390
 
1391
+ // ── Workspace capture (read-only; served from DB + object storage, NO box) ──
1392
+ // Grant-first (files:read) then a pure DB/storage read — deliberately NOT the
1393
+ // channelAPreamble path: a capture is the durable turn-end snapshot, served
1394
+ // without warming a machine (the <200ms cold paint). No ownership-flag gate:
1395
+ // absent captures return {available:false} (200) so the client falls back to
1396
+ // the live/wake path — the feature degrades to today's behavior, never worse.
1397
+ app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/workspace/capture", async (c) => {
1398
+ const workspaceId = c.req.param("workspaceId") ?? "";
1399
+ await requireAccessGrant(c, deps, workspaceId, "files:read");
1400
+ const sessionId = c.req.param("sessionId") ?? "";
1401
+ const session = await getSession(db, workspaceId, sessionId);
1402
+ if (!session) {
1403
+ throw new HTTPException(404, { message: "session not found" });
1404
+ }
1405
+ if (!objectStorage) {
1406
+ // No storage configured → no captures can exist. Cold-fallback, not an error.
1407
+ return c.json({ available: false });
1408
+ }
1409
+ const row = await latestWorkspaceCapture(db, workspaceId, sessionId);
1410
+ return c.json(await serveWorkspaceCapture(row, objectStorage));
1411
+ });
1412
+
1413
+ app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/workspace/capture/file", async (c) => {
1414
+ const workspaceId = c.req.param("workspaceId") ?? "";
1415
+ await requireAccessGrant(c, deps, workspaceId, "files:read");
1416
+ const sessionId = c.req.param("sessionId") ?? "";
1417
+ const path = c.req.query("path");
1418
+ if (!path) {
1419
+ throw new HTTPException(400, { message: "path query parameter is required" });
1420
+ }
1421
+ const session = await getSession(db, workspaceId, sessionId);
1422
+ if (!session) {
1423
+ throw new HTTPException(404, { message: "session not found" });
1424
+ }
1425
+ if (!objectStorage) {
1426
+ throw new HTTPException(404, { message: "capture not found" });
1427
+ }
1428
+ // Explicit ?revision pins a specific capture; omitted → latest.
1429
+ const revisionParam = c.req.query("revision");
1430
+ let row;
1431
+ if (revisionParam !== undefined && revisionParam !== "") {
1432
+ const revision = Number(revisionParam);
1433
+ if (!Number.isInteger(revision) || revision < 0) {
1434
+ throw new HTTPException(400, { message: "revision must be a non-negative integer" });
1435
+ }
1436
+ row = await workspaceCaptureAtRevision(db, workspaceId, sessionId, revision);
1437
+ } else {
1438
+ row = await latestWorkspaceCapture(db, workspaceId, sessionId);
1439
+ }
1440
+ return c.json(await serveWorkspaceCaptureFile(row, path, objectStorage));
1441
+ });
1442
+
1001
1443
  // ── Terminal: synchronous exec ────────────────────────────────────────────
1002
1444
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/exec", async (c) => {
1003
1445
  const ctx = await channelAPreamble(c, "terminal:attach");
1004
1446
  const req = await parseChannelABody(c, TerminalExecRequest);
1005
- const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.terminalExec(req));
1447
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) =>
1448
+ service.terminalExec(req),
1449
+ );
1006
1450
  return c.json(out);
1007
1451
  });
1008
1452
 
@@ -1028,10 +1472,21 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1028
1472
  openedBy: ctx.subjectId,
1029
1473
  });
1030
1474
  // 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 };
1475
+ const started: TerminalPtyStartedPayload = {
1476
+ ptyId,
1477
+ cols: req.cols,
1478
+ rows: req.rows,
1479
+ shell: opened.shell,
1480
+ cwd: req.cwd,
1481
+ };
1032
1482
  const events: AppendEventInput[] = [{ type: "terminal.pty.started", payload: started }];
1033
1483
  if (opened.initialOutput) {
1034
- const delta: TerminalPtyOutputDeltaPayload = { ptyId, stream: "stdout", chunk: opened.initialOutput, seq: 0 };
1484
+ const delta: TerminalPtyOutputDeltaPayload = {
1485
+ ptyId,
1486
+ stream: "stdout",
1487
+ chunk: opened.initialOutput,
1488
+ seq: 0,
1489
+ };
1035
1490
  events.push({ type: "terminal.pty.output.delta", payload: delta });
1036
1491
  }
1037
1492
  await appendAndPublishEvents(db, bus, ctx.workspaceId, ctx.session.id, events);
@@ -1053,10 +1508,22 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1053
1508
  let seq = 1;
1054
1509
  await withChannelA({ db, settings, bus }, ctx, async ({ service }) => {
1055
1510
  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 });
1511
+ await updatePtySessionActivity(db, {
1512
+ accountId: ctx.accountId,
1513
+ workspaceId: ctx.workspaceId,
1514
+ ptyId: req.ptyId,
1515
+ execSessionId: pty.execSessionId,
1516
+ });
1057
1517
  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 }]);
1518
+ const delta: TerminalPtyOutputDeltaPayload = {
1519
+ ptyId: req.ptyId,
1520
+ stream: "stdout",
1521
+ chunk: output,
1522
+ seq: seq++,
1523
+ };
1524
+ await appendAndPublishEvents(db, bus, ctx.workspaceId, ctx.session.id, [
1525
+ { type: "terminal.pty.output.delta", payload: delta },
1526
+ ]);
1060
1527
  }
1061
1528
  });
1062
1529
  return c.body(null, 204);
@@ -1070,9 +1537,17 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1070
1537
  throw new HTTPException(404, { message: "pty not found or closed" });
1071
1538
  }
1072
1539
  if (pty.execSessionId !== null) {
1073
- await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.ptyResize(req, pty.execSessionId!));
1540
+ await withChannelA({ db, settings, bus }, ctx, ({ service }) =>
1541
+ service.ptyResize(req, pty.execSessionId!),
1542
+ );
1074
1543
  }
1075
- await updatePtySessionActivity(db, { accountId: ctx.accountId, workspaceId: ctx.workspaceId, ptyId: req.ptyId, cols: req.cols, rows: req.rows });
1544
+ await updatePtySessionActivity(db, {
1545
+ accountId: ctx.accountId,
1546
+ workspaceId: ctx.workspaceId,
1547
+ ptyId: req.ptyId,
1548
+ cols: req.cols,
1549
+ rows: req.rows,
1550
+ });
1076
1551
  return c.body(null, 204);
1077
1552
  });
1078
1553
 
@@ -1082,10 +1557,18 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1082
1557
  const pty = await getOpenPtySession(db, ctx.workspaceId, req.ptyId);
1083
1558
  // Idempotent: closing an already-closed/absent PTY is a 204 no-op.
1084
1559
  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 });
1560
+ await withChannelA({ db, settings, bus }, ctx, ({ service }) =>
1561
+ service.ptyClose(req, pty.execSessionId),
1562
+ );
1563
+ await closePtySession(db, {
1564
+ accountId: ctx.accountId,
1565
+ workspaceId: ctx.workspaceId,
1566
+ ptyId: req.ptyId,
1567
+ });
1087
1568
  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 }]);
1569
+ await appendAndPublishEvents(db, bus, ctx.workspaceId, ctx.session.id, [
1570
+ { type: "terminal.pty.exited", payload: exited },
1571
+ ]);
1089
1572
  }
1090
1573
  return c.body(null, 204);
1091
1574
  });
@@ -1099,6 +1582,50 @@ function eventListLimit(raw: string | undefined, max = 2000): number {
1099
1582
  return Math.min(max, Math.max(1, Math.floor(limit)));
1100
1583
  }
1101
1584
 
1585
+ function sessionListQuery(
1586
+ query: Record<string, string>,
1587
+ allowCursor = true,
1588
+ ): {
1589
+ limit: string | undefined;
1590
+ parentSessionId: string | null | undefined;
1591
+ cursor: ReturnType<typeof decodeSessionListCursor> | undefined;
1592
+ search: string | undefined;
1593
+ } {
1594
+ const parentSessionId = query.parentSessionId;
1595
+ // "null" = roots only; a uuid = children of that session; anything else is
1596
+ // a client error (an unvalidated value would surface as a Postgres uuid cast
1597
+ // failure -> 500 rather than an honest 400).
1598
+ if (
1599
+ parentSessionId !== undefined &&
1600
+ parentSessionId !== "null" &&
1601
+ !z.string().uuid().safeParse(parentSessionId).success
1602
+ ) {
1603
+ throw new HTTPException(400, {
1604
+ message: 'parentSessionId must be a session id or the literal "null"',
1605
+ });
1606
+ }
1607
+ const rawCursor = allowCursor ? query.cursor : undefined;
1608
+ const cursor = rawCursor ? decodeSessionListCursor(rawCursor) : undefined;
1609
+ if (rawCursor && !cursor) {
1610
+ throw new HTTPException(400, { message: "cursor is invalid" });
1611
+ }
1612
+ const search = query.search?.trim();
1613
+ if (search && search.length > 200) {
1614
+ throw new HTTPException(400, { message: "search must be at most 200 characters" });
1615
+ }
1616
+ return {
1617
+ limit: query.limit,
1618
+ parentSessionId:
1619
+ parentSessionId === undefined
1620
+ ? undefined
1621
+ : parentSessionId === "null"
1622
+ ? null
1623
+ : parentSessionId,
1624
+ cursor,
1625
+ search: search || undefined,
1626
+ };
1627
+ }
1628
+
1102
1629
  function compactEvents(raw: string | undefined): boolean {
1103
1630
  return raw === "1" || raw === "true";
1104
1631
  }
@@ -1123,7 +1650,9 @@ function optionalEventSequence(raw: string | undefined): number | undefined {
1123
1650
  }
1124
1651
 
1125
1652
  function hasOwnProperty(value: unknown, key: string): boolean {
1126
- return Boolean(value && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, key));
1653
+ return Boolean(
1654
+ value && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, key),
1655
+ );
1127
1656
  }
1128
1657
 
1129
1658
  function userMessagePayloadHasOwnProperty(value: unknown, key: string): boolean {
@@ -1133,3 +1662,16 @@ function userMessagePayloadHasOwnProperty(value: unknown, key: string): boolean
1133
1662
  const payload = (value as { payload?: unknown }).payload;
1134
1663
  return hasOwnProperty(payload, key);
1135
1664
  }
1665
+
1666
+ function commandConflictResponse(c: Context, error: unknown): Response {
1667
+ if (error instanceof QueueCommandConflictError) {
1668
+ return c.json({ code: error.code, message: error.message, current: error.current }, 409);
1669
+ }
1670
+ if (error instanceof SessionControlConflictError) {
1671
+ return c.json({ code: error.code, message: error.message }, 409);
1672
+ }
1673
+ if (error instanceof SessionCommandIdempotencyError) {
1674
+ return c.json({ code: error.code, message: error.message }, 409);
1675
+ }
1676
+ throw error;
1677
+ }