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