@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.
- package/dist/app.d.ts +9 -1
- package/dist/app.js +7 -1
- package/dist/{chunk-3HIA43CC.js → chunk-HBEJMWD3.js} +5470 -2223
- package/dist/chunk-HBEJMWD3.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +279 -55
- package/dist/index.js.map +1 -1
- package/package.json +20 -20
- package/src/app.ts +583 -166
- package/src/auth/managed-auth.ts +32 -16
- package/src/http/auth.ts +8 -1
- package/src/http/common.ts +6 -2
- package/src/http/sse.ts +84 -8
- package/src/index.ts +178 -75
- package/src/integrations/oauth-client.ts +403 -120
- package/src/integrations/provider-domain.ts +4 -1
- package/src/mcp/documents.ts +173 -94
- package/src/mcp/server.ts +1600 -693
- package/src/mcp/session-view.ts +8 -2
- package/src/mcp/toolspace.ts +175 -84
- package/src/observability.ts +7 -1
- package/src/routes/api-keys.ts +39 -23
- package/src/routes/billing.ts +180 -65
- package/src/routes/capabilities.ts +17 -8
- package/src/routes/catalog-assets.ts +5 -2
- package/src/routes/codex.ts +244 -63
- package/src/routes/connections.ts +71 -33
- package/src/routes/documents.ts +242 -92
- package/src/routes/enrollments.ts +100 -70
- package/src/routes/environments.ts +205 -136
- package/src/routes/files.ts +164 -39
- package/src/routes/github.ts +123 -50
- package/src/routes/install.ts +9 -2
- package/src/routes/machines.ts +9 -8
- package/src/routes/packs.ts +141 -89
- package/src/routes/rigs.ts +189 -0
- package/src/routes/scheduled-tasks.ts +51 -9
- package/src/routes/sessions.ts +870 -328
- package/src/routes/social.ts +50 -38
- package/src/routes/workspace-capture.ts +238 -0
- package/src/routes/workspaces.ts +146 -13
- package/src/sandbox/access.ts +11 -3
- package/src/sandbox/auth-callout.ts +5 -1
- package/src/sandbox/channel-a.ts +104 -27
- package/src/sandbox/enrollment.ts +13 -3
- package/src/sandbox/machines.ts +68 -59
- package/src/sandbox/metrics-ingestion.ts +238 -17
- package/src/sandbox/viewer.ts +172 -46
- package/dist/chunk-3HIA43CC.js.map +0 -1
package/src/routes/sessions.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
39
|
+
import { streamTokenDegraded } from "@opengeni/config";
|
|
34
40
|
import {
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
+
QueueCommandConflictError,
|
|
71
|
+
SessionCommandIdempotencyError,
|
|
72
|
+
SessionControlConflictError,
|
|
73
|
+
SessionContextBusyError,
|
|
74
|
+
latestWorkspaceCapture,
|
|
75
|
+
workspaceCaptureAtRevision,
|
|
57
76
|
type AppendEventInput,
|
|
58
77
|
} from "@opengeni/db";
|
|
59
|
-
import {
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
104
|
+
controlHumanSessionWorkstream,
|
|
77
105
|
createSessionForRequest,
|
|
78
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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, {
|
|
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(
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
215
|
-
//
|
|
216
|
-
|
|
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, {
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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(
|
|
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(
|
|
508
|
+
return c.json(
|
|
509
|
+
await listSessionTurns(db, workspaceId, sessionId, boundedLimit(c.req.query("limit"))),
|
|
510
|
+
);
|
|
314
511
|
});
|
|
315
512
|
|
|
316
|
-
app.
|
|
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:
|
|
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
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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/
|
|
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 =
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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.
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
424
|
-
} else {
|
|
425
|
-
await workflowClient.signalInterrupt({
|
|
705
|
+
const accepted = await acceptSessionApprovalDecision(db, {
|
|
426
706
|
accountId: grant.accountId,
|
|
427
707
|
workspaceId,
|
|
428
708
|
sessionId,
|
|
429
|
-
|
|
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, {
|
|
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(
|
|
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, {
|
|
492
|
-
|
|
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
|
-
|
|
507
|
-
&&
|
|
508
|
-
|
|
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(
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
532
|
-
|
|
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(
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
|
615
|
-
|
|
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(
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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, {
|
|
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
|
|
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
|
-
|
|
1049
|
+
(settings.sandboxDesktopEnabled || settings.sandboxTerminalEnabled) &&
|
|
1050
|
+
!streamTokenDegraded(settings)
|
|
718
1051
|
) {
|
|
719
1052
|
if (wantDesktop && settings.sandboxDesktopEnabled) {
|
|
720
|
-
stream = await mintDesktopStream(
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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(
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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(
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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
|
-
|
|
1096
|
+
(settings.sandboxDesktopEnabled || settings.sandboxTerminalEnabled) &&
|
|
1097
|
+
!streamTokenDegraded(settings)
|
|
756
1098
|
) {
|
|
757
|
-
const lease = await readGroupLease(
|
|
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(
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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(
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
|
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(
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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(
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
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(
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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>(
|
|
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 }) =>
|
|
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 }) =>
|
|
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 }) =>
|
|
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 }) =>
|
|
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 }) =>
|
|
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 }) =>
|
|
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 }) =>
|
|
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 }) =>
|
|
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 }) =>
|
|
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 }) =>
|
|
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 }) =>
|
|
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 = {
|
|
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 = {
|
|
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, {
|
|
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 = {
|
|
1059
|
-
|
|
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 }) =>
|
|
1540
|
+
await withChannelA({ db, settings, bus }, ctx, ({ service }) =>
|
|
1541
|
+
service.ptyResize(req, pty.execSessionId!),
|
|
1542
|
+
);
|
|
1074
1543
|
}
|
|
1075
|
-
await updatePtySessionActivity(db, {
|
|
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 }) =>
|
|
1086
|
-
|
|
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, [
|
|
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(
|
|
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
|
+
}
|