@opengeni/api-router 0.26.1 → 0.30.0
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 +1 -0
- package/dist/app.js +5 -3
- package/dist/auth/canonical-human-session-admission.d.ts +27 -0
- package/dist/browser-auth-broker.d.ts +20 -0
- package/dist/browser-controller-authority.d.ts +19 -1
- package/dist/browser-network-route.d.ts +8 -0
- package/dist/{chunk-JIKNR5YL.js → chunk-2PQMSRCB.js} +15518 -6863
- package/dist/chunk-2PQMSRCB.js.map +1 -0
- package/dist/controller-data-plane.d.ts +12 -0
- package/dist/http/auth.d.ts +1 -1
- package/dist/http/sse.d.ts +18 -1
- package/dist/index.js +1 -1
- package/dist/integrations/api-integrations.d.ts +2 -2
- package/dist/integrations/fiken.d.ts +233 -0
- package/dist/integrations/google-drive.d.ts +6 -6
- package/dist/integrations/provider-oauth.d.ts +0 -2
- package/dist/integrations/slack-bot.d.ts +171 -1
- package/dist/integrations/slack-interactions.d.ts +17 -5
- package/dist/interaction-holder-heartbeat.d.ts +17 -0
- package/dist/interaction-metrics.d.ts +11 -0
- package/dist/mcp/server.d.ts +3 -2
- package/dist/model-catalog.d.ts +1 -0
- package/dist/routes/canonical-human-identities.d.ts +3 -0
- package/dist/routes/channels.d.ts +3 -0
- package/dist/routes/integration-facets.d.ts +3 -0
- package/dist/routes/interaction-resources.d.ts +5 -0
- package/dist/routes/organization-memberships.d.ts +3 -0
- package/dist/routes/sessions.d.ts +2 -1
- package/dist/routes/slack-task-policy.d.ts +3 -0
- package/dist/routes/supergrok.d.ts +3 -0
- package/dist/sandbox/channel-a.d.ts +8 -1
- package/dist/sandbox/machines.d.ts +2 -2
- package/dist/sandbox/viewer.d.ts +3 -1
- package/dist/slack-reaction-files.d.ts +27 -0
- package/dist/transcription/providers/xai-subscription.d.ts +8 -0
- package/dist/xai-realtime.d.ts +26 -0
- package/dist/xai-subscription-auth.d.ts +23 -0
- package/package.json +17 -16
- package/src/app.ts +352 -49
- package/src/auth/canonical-human-session-admission.ts +116 -0
- package/src/auth/managed-auth.ts +101 -0
- package/src/browser-auth-broker.ts +155 -0
- package/src/browser-controller-authority.ts +50 -1
- package/src/browser-network-route.ts +34 -0
- package/src/controller-data-plane.ts +35 -0
- package/src/http/auth.ts +27 -9
- package/src/http/sse.ts +229 -5
- package/src/integrations/api-integrations.ts +57 -42
- package/src/integrations/fiken.ts +1230 -0
- package/src/integrations/google-drive.ts +31 -26
- package/src/integrations/provider-oauth.ts +70 -91
- package/src/integrations/slack-bot.ts +638 -9
- package/src/integrations/slack-interactions.ts +1583 -73
- package/src/interaction-holder-heartbeat.ts +95 -0
- package/src/interaction-metrics.ts +159 -0
- package/src/mcp/documents.ts +115 -6
- package/src/mcp/server.ts +865 -242
- package/src/model-catalog.ts +19 -6
- package/src/routes/api-integrations.ts +32 -29
- package/src/routes/browser-identities.ts +24 -0
- package/src/routes/browser-sessions.ts +2122 -183
- package/src/routes/canonical-human-identities.ts +156 -0
- package/src/routes/channels.ts +90 -0
- package/src/routes/computer-sessions.ts +441 -86
- package/src/routes/connections.ts +141 -4
- package/src/routes/{integration-features.ts → integration-facets.ts} +63 -63
- package/src/routes/interaction-resources.ts +595 -0
- package/src/routes/organization-memberships.ts +53 -0
- package/src/routes/plugins.ts +2 -1
- package/src/routes/sessions.ts +419 -43
- package/src/routes/skills.ts +92 -10
- package/src/routes/slack-task-policy.ts +115 -0
- package/src/routes/supergrok.ts +717 -0
- package/src/routes/transcription-recordings.ts +9 -2
- package/src/routes/transcriptions.ts +2 -1
- package/src/routes/video-generation.ts +34 -4
- package/src/routes/workspaces.ts +22 -4
- package/src/sandbox/channel-a.ts +29 -2
- package/src/sandbox/machines.ts +5 -5
- package/src/sandbox/viewer.ts +10 -3
- package/src/slack-reaction-files.ts +181 -0
- package/src/transcription/providers/xai-subscription.ts +110 -0
- package/src/transcription/service.ts +17 -2
- package/src/xai-realtime.ts +216 -0
- package/src/xai-subscription-auth.ts +132 -0
- package/dist/chunk-JIKNR5YL.js.map +0 -1
- package/dist/routes/integration-features.d.ts +0 -3
package/src/routes/sessions.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
AcknowledgeStreamRequest,
|
|
3
|
+
ApplySessionGoalRevisionRequest,
|
|
3
4
|
ActivateCodexRealtimeConnectionRequest,
|
|
4
5
|
AttachViewerRequest,
|
|
5
6
|
BeginSessionRealtimeRequest,
|
|
@@ -47,6 +48,7 @@ import {
|
|
|
47
48
|
SteerSessionQueueItemRequest,
|
|
48
49
|
SteerSessionMessageRequest,
|
|
49
50
|
TerminalExecRequest,
|
|
51
|
+
UpdateSessionChannelRequest,
|
|
50
52
|
UpdateSessionPinRequest,
|
|
51
53
|
UpdateSessionGoalRequest,
|
|
52
54
|
UpdateSessionMcpApprovalPolicyRequest,
|
|
@@ -60,6 +62,7 @@ import {
|
|
|
60
62
|
type SandboxBackend,
|
|
61
63
|
type LineageNode,
|
|
62
64
|
type Session,
|
|
65
|
+
type SessionGoalRevision,
|
|
63
66
|
type AgentTopologyPageResponse,
|
|
64
67
|
type ErrorCode,
|
|
65
68
|
type SessionAuthorizationOperation,
|
|
@@ -75,6 +78,7 @@ import {
|
|
|
75
78
|
clearSessionGoal,
|
|
76
79
|
clearSessionContext,
|
|
77
80
|
getOpenPtySession,
|
|
81
|
+
getEnrollment,
|
|
78
82
|
getRetainedProcess,
|
|
79
83
|
getSandbox,
|
|
80
84
|
getSession,
|
|
@@ -83,11 +87,13 @@ import {
|
|
|
83
87
|
getSessionGoal,
|
|
84
88
|
getSessionHumanInputRequest,
|
|
85
89
|
getSessionGoalWithContinuation,
|
|
90
|
+
getSessionGoalRevision,
|
|
86
91
|
getSessionQueueSnapshot,
|
|
87
92
|
getStreamAcknowledgment,
|
|
88
93
|
insertPtySession,
|
|
89
94
|
listSessionEventPage,
|
|
90
95
|
listSessionHumanInputRequests,
|
|
96
|
+
listSessionGoalRevisions,
|
|
91
97
|
listSessionIdsInGroup,
|
|
92
98
|
listSessionDiscoverySummaries,
|
|
93
99
|
listSessionDiscoveryAncestorPaths,
|
|
@@ -100,6 +106,8 @@ import {
|
|
|
100
106
|
requestSessionCompaction,
|
|
101
107
|
setSessionCodexPinInTransaction,
|
|
102
108
|
withSessionCodexCapacityMutation,
|
|
109
|
+
setSessionChannel,
|
|
110
|
+
ChannelNotFoundError,
|
|
103
111
|
setSessionPin,
|
|
104
112
|
SessionPinVersionConflictError,
|
|
105
113
|
SessionPinAccessError,
|
|
@@ -110,6 +118,7 @@ import {
|
|
|
110
118
|
decodeSessionListCursor,
|
|
111
119
|
revokeViewer,
|
|
112
120
|
setSessionGoalStatusWithEvent,
|
|
121
|
+
upsertSessionGoalWithEvent,
|
|
113
122
|
updatePtySessionActivity,
|
|
114
123
|
QueueCommandConflictError,
|
|
115
124
|
beginSessionRealtimeInTransaction,
|
|
@@ -150,6 +159,7 @@ import {
|
|
|
150
159
|
createGatewayRealtimeConnectionSecret,
|
|
151
160
|
GatewayRealtimeBrokerError,
|
|
152
161
|
} from "../gateway-realtime";
|
|
162
|
+
import { createXaiRealtimeConnectionSecret, XaiRealtimeBrokerError } from "../xai-realtime";
|
|
153
163
|
import { z, ZodError } from "zod";
|
|
154
164
|
import {
|
|
155
165
|
runConcurrentChannelAReads,
|
|
@@ -159,7 +169,12 @@ import {
|
|
|
159
169
|
type ChannelAHandle,
|
|
160
170
|
type ChannelAOperation,
|
|
161
171
|
} from "../sandbox/channel-a";
|
|
162
|
-
import {
|
|
172
|
+
import {
|
|
173
|
+
NatsControlRpc,
|
|
174
|
+
negotiateCapabilities,
|
|
175
|
+
negotiateSelfhostedCapabilities,
|
|
176
|
+
SelfhostedSession,
|
|
177
|
+
} from "@opengeni/runtime/sandbox";
|
|
163
178
|
import type { Context, Hono, MiddlewareHandler } from "hono";
|
|
164
179
|
import { HTTPException } from "hono/http-exception";
|
|
165
180
|
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
@@ -169,6 +184,7 @@ import {
|
|
|
169
184
|
requirePermission,
|
|
170
185
|
requireSessionAuthorization,
|
|
171
186
|
requireSessionAuthorizationListScope,
|
|
187
|
+
withResolvedSessionAuthorization,
|
|
172
188
|
SESSION_AUTHORIZATION_DEFAULT_REAUTHORIZE_MS,
|
|
173
189
|
SessionAuthorizationDeniedError,
|
|
174
190
|
SessionAuthorizationUnavailableError,
|
|
@@ -211,6 +227,7 @@ import {
|
|
|
211
227
|
sessionWithEffectiveToolPolicy,
|
|
212
228
|
workspaceSessionToolPolicyDefaultServerIds,
|
|
213
229
|
workspaceSessionToolPolicyServerIds,
|
|
230
|
+
relayConfigFromSettings,
|
|
214
231
|
} from "@opengeni/core";
|
|
215
232
|
import { assertSessionExists, boundedLimit } from "../http/common";
|
|
216
233
|
import { sseSessionStream } from "../http/sse";
|
|
@@ -237,7 +254,12 @@ function requireViewerLifecyclePermission(grant: AccessGrant): void {
|
|
|
237
254
|
|
|
238
255
|
export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
239
256
|
const { settings, db, bus, workflowClient, objectStorage } = deps;
|
|
240
|
-
const channelAServices = {
|
|
257
|
+
const channelAServices = {
|
|
258
|
+
db,
|
|
259
|
+
settings,
|
|
260
|
+
bus,
|
|
261
|
+
observability: deps.observability,
|
|
262
|
+
};
|
|
241
263
|
const workspaceCaptureManifestCache = new WorkspaceCaptureManifestCache();
|
|
242
264
|
const ptyIdentity = (pty: SandboxOpenPtySessionRow): SandboxPtyProcessIdentity => ({
|
|
243
265
|
leaseId: pty.leaseId,
|
|
@@ -384,10 +406,6 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
384
406
|
if (!operation) {
|
|
385
407
|
throw sessionAuthorizationHttpError(new SessionAuthorizationUnavailableError());
|
|
386
408
|
}
|
|
387
|
-
if (operation === "session.codex_account.write" && !deps.sessionAuthorization) {
|
|
388
|
-
await next();
|
|
389
|
-
return;
|
|
390
|
-
}
|
|
391
409
|
const grant = await requireAccessGrant(c, deps, workspaceId);
|
|
392
410
|
try {
|
|
393
411
|
const authorization = await requireSessionAuthorization(deps, grant, {
|
|
@@ -396,6 +414,10 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
396
414
|
surface: "http",
|
|
397
415
|
});
|
|
398
416
|
if (authorization) requestSessionAuthorization.set(c.req.raw, authorization);
|
|
417
|
+
if (authorization) {
|
|
418
|
+
await withResolvedSessionAuthorization(authorization, next);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
399
421
|
} catch (error) {
|
|
400
422
|
throw sessionAuthorizationHttpError(error);
|
|
401
423
|
}
|
|
@@ -692,7 +714,9 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
692
714
|
}
|
|
693
715
|
const parsed = BeginSessionRealtimeRequest.safeParse(await c.req.json().catch(() => null));
|
|
694
716
|
if (!parsed.success) {
|
|
695
|
-
throw new HTTPException(400, {
|
|
717
|
+
throw new HTTPException(400, {
|
|
718
|
+
message: "invalid session realtime request",
|
|
719
|
+
});
|
|
696
720
|
}
|
|
697
721
|
try {
|
|
698
722
|
const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) =>
|
|
@@ -723,11 +747,15 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
723
747
|
!z.string().uuid().safeParse(sessionId).success ||
|
|
724
748
|
!z.string().uuid().safeParse(realtimeId).success
|
|
725
749
|
) {
|
|
726
|
-
throw new HTTPException(400, {
|
|
750
|
+
throw new HTTPException(400, {
|
|
751
|
+
message: "invalid realtime lifecycle id",
|
|
752
|
+
});
|
|
727
753
|
}
|
|
728
754
|
const parsed = RenewSessionRealtimeRequest.safeParse(await c.req.json().catch(() => null));
|
|
729
755
|
if (!parsed.success) {
|
|
730
|
-
throw new HTTPException(400, {
|
|
756
|
+
throw new HTTPException(400, {
|
|
757
|
+
message: "invalid realtime heartbeat request",
|
|
758
|
+
});
|
|
731
759
|
}
|
|
732
760
|
try {
|
|
733
761
|
const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) =>
|
|
@@ -757,11 +785,15 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
757
785
|
!z.string().uuid().safeParse(sessionId).success ||
|
|
758
786
|
!z.string().uuid().safeParse(realtimeId).success
|
|
759
787
|
) {
|
|
760
|
-
throw new HTTPException(400, {
|
|
788
|
+
throw new HTTPException(400, {
|
|
789
|
+
message: "invalid realtime lifecycle id",
|
|
790
|
+
});
|
|
761
791
|
}
|
|
762
792
|
const parsed = EndSessionRealtimeRequest.safeParse(await c.req.json().catch(() => null));
|
|
763
793
|
if (!parsed.success) {
|
|
764
|
-
throw new HTTPException(400, {
|
|
794
|
+
throw new HTTPException(400, {
|
|
795
|
+
message: "invalid realtime end request",
|
|
796
|
+
});
|
|
765
797
|
}
|
|
766
798
|
try {
|
|
767
799
|
const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) =>
|
|
@@ -874,7 +906,10 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
874
906
|
deps.codexFetch,
|
|
875
907
|
);
|
|
876
908
|
try {
|
|
877
|
-
const answer = await broker({
|
|
909
|
+
const answer = await broker({
|
|
910
|
+
request: providerRequest,
|
|
911
|
+
signal: c.req.raw.signal,
|
|
912
|
+
});
|
|
878
913
|
const completed = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
|
|
879
914
|
scopedDb.transaction(async (tx) =>
|
|
880
915
|
completeSessionRealtimeConnectionInTransaction(tx as unknown as Database, {
|
|
@@ -967,7 +1002,9 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
967
1002
|
}
|
|
968
1003
|
const parsed = GatewayRealtimeConnectRequest.safeParse(await c.req.json().catch(() => null));
|
|
969
1004
|
if (!parsed.success) {
|
|
970
|
-
throw new HTTPException(422, {
|
|
1005
|
+
throw new HTTPException(422, {
|
|
1006
|
+
message: "invalid Gateway realtime request",
|
|
1007
|
+
});
|
|
971
1008
|
}
|
|
972
1009
|
c.header("cache-control", "private, no-store");
|
|
973
1010
|
const {
|
|
@@ -1072,6 +1109,121 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1072
1109
|
}
|
|
1073
1110
|
});
|
|
1074
1111
|
|
|
1112
|
+
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/realtime/supergrok", async (c) => {
|
|
1113
|
+
const workspaceId = c.req.param("workspaceId");
|
|
1114
|
+
const sessionId = c.req.param("sessionId");
|
|
1115
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
|
|
1116
|
+
if (!z.string().uuid().safeParse(sessionId).success) {
|
|
1117
|
+
throw new HTTPException(404, { message: "session not found" });
|
|
1118
|
+
}
|
|
1119
|
+
const parsed = GatewayRealtimeConnectRequest.safeParse(await c.req.json().catch(() => null));
|
|
1120
|
+
if (!parsed.success) {
|
|
1121
|
+
throw new HTTPException(422, { message: "invalid SuperGrok realtime request" });
|
|
1122
|
+
}
|
|
1123
|
+
c.header("cache-control", "private, no-store");
|
|
1124
|
+
const {
|
|
1125
|
+
realtimeId,
|
|
1126
|
+
operationId,
|
|
1127
|
+
browserInstanceId,
|
|
1128
|
+
ownerKey,
|
|
1129
|
+
expectedVersion,
|
|
1130
|
+
expectedConnectionEpoch,
|
|
1131
|
+
rotate,
|
|
1132
|
+
} = parsed.data;
|
|
1133
|
+
let claim: Awaited<ReturnType<typeof claimSessionRealtimeConnectionInTransaction>> | null =
|
|
1134
|
+
null;
|
|
1135
|
+
let connectionCompleted = false;
|
|
1136
|
+
try {
|
|
1137
|
+
claim = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
|
|
1138
|
+
scopedDb.transaction(async (tx) =>
|
|
1139
|
+
claimSessionRealtimeConnectionInTransaction(tx as unknown as Database, {
|
|
1140
|
+
workspaceId,
|
|
1141
|
+
sessionId,
|
|
1142
|
+
realtimeId,
|
|
1143
|
+
operationId,
|
|
1144
|
+
ownerSubjectId: grant.subjectId,
|
|
1145
|
+
browserInstanceId,
|
|
1146
|
+
ownerKey,
|
|
1147
|
+
expectedVersion,
|
|
1148
|
+
expectedConnectionEpoch,
|
|
1149
|
+
rotate,
|
|
1150
|
+
promotionMode: "staged",
|
|
1151
|
+
}),
|
|
1152
|
+
),
|
|
1153
|
+
);
|
|
1154
|
+
if (claim.replay) {
|
|
1155
|
+
throw new SessionRealtimeConflictError(
|
|
1156
|
+
"REALTIME_CONNECTION_STATE_CHANGED",
|
|
1157
|
+
"SuperGrok realtime tokens are single-use; reconnect with a new operation",
|
|
1158
|
+
);
|
|
1159
|
+
}
|
|
1160
|
+
const secret = await createXaiRealtimeConnectionSecret({
|
|
1161
|
+
db,
|
|
1162
|
+
settings,
|
|
1163
|
+
accountId: grant.accountId,
|
|
1164
|
+
workspaceId,
|
|
1165
|
+
subjectId: grant.subjectId,
|
|
1166
|
+
sessionId,
|
|
1167
|
+
model: claim.mode.model,
|
|
1168
|
+
fetchImpl: deps.xaiFetch ?? fetch,
|
|
1169
|
+
});
|
|
1170
|
+
const claimed = claim;
|
|
1171
|
+
const completed = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
|
|
1172
|
+
scopedDb.transaction(async (tx) =>
|
|
1173
|
+
completeSessionRealtimeConnectionInTransaction(tx as unknown as Database, {
|
|
1174
|
+
workspaceId,
|
|
1175
|
+
sessionId,
|
|
1176
|
+
realtimeId,
|
|
1177
|
+
connectionId: claimed.connection.id,
|
|
1178
|
+
operationId,
|
|
1179
|
+
connectionEpoch: claimed.connection.connectionEpoch,
|
|
1180
|
+
sdpAnswer: "supergrok-client-secret-minted",
|
|
1181
|
+
}),
|
|
1182
|
+
),
|
|
1183
|
+
);
|
|
1184
|
+
connectionCompleted = true;
|
|
1185
|
+
return c.json({
|
|
1186
|
+
...secret,
|
|
1187
|
+
connectionId: completed.connection.id,
|
|
1188
|
+
connectionEpoch: completed.connection.connectionEpoch,
|
|
1189
|
+
startupFenceSequence: completed.connection.startupFenceSequence,
|
|
1190
|
+
modeVersion: claimed.modeVersion,
|
|
1191
|
+
replay: false as const,
|
|
1192
|
+
});
|
|
1193
|
+
} catch (error) {
|
|
1194
|
+
if (claim !== null && !claim.replay && !connectionCompleted) {
|
|
1195
|
+
const claimed = claim;
|
|
1196
|
+
await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
|
|
1197
|
+
scopedDb.transaction(async (tx) =>
|
|
1198
|
+
failSessionRealtimeConnectionInTransaction(tx as unknown as Database, {
|
|
1199
|
+
workspaceId,
|
|
1200
|
+
sessionId,
|
|
1201
|
+
realtimeId,
|
|
1202
|
+
connectionId: claimed.connection.id,
|
|
1203
|
+
operationId,
|
|
1204
|
+
connectionEpoch: claimed.connection.connectionEpoch,
|
|
1205
|
+
failureCode: error instanceof XaiRealtimeBrokerError ? error.code : "supergrok_error",
|
|
1206
|
+
}),
|
|
1207
|
+
),
|
|
1208
|
+
).catch(() => undefined);
|
|
1209
|
+
}
|
|
1210
|
+
if (error instanceof SessionRealtimeConflictError) throw sessionRealtimeHttpError(error);
|
|
1211
|
+
if (!(error instanceof XaiRealtimeBrokerError)) throw error;
|
|
1212
|
+
const status = error.code === "credential_unavailable" ? 409 : 502;
|
|
1213
|
+
return c.json(
|
|
1214
|
+
{
|
|
1215
|
+
error: {
|
|
1216
|
+
status,
|
|
1217
|
+
code: `SUPERGROK_REALTIME_${error.code.toUpperCase()}`,
|
|
1218
|
+
message: error.message,
|
|
1219
|
+
retryable: error.code === "provider_error",
|
|
1220
|
+
},
|
|
1221
|
+
},
|
|
1222
|
+
status,
|
|
1223
|
+
);
|
|
1224
|
+
}
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1075
1227
|
app.post(
|
|
1076
1228
|
"/v1/workspaces/:workspaceId/sessions/:sessionId/realtime/:realtimeId/connections/:connectionId/activate",
|
|
1077
1229
|
async (c) => {
|
|
@@ -1085,13 +1237,17 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1085
1237
|
!z.string().uuid().safeParse(realtimeId).success ||
|
|
1086
1238
|
!z.string().uuid().safeParse(connectionId).success
|
|
1087
1239
|
) {
|
|
1088
|
-
throw new HTTPException(400, {
|
|
1240
|
+
throw new HTTPException(400, {
|
|
1241
|
+
message: "invalid realtime connection id",
|
|
1242
|
+
});
|
|
1089
1243
|
}
|
|
1090
1244
|
const parsed = ActivateCodexRealtimeConnectionRequest.safeParse(
|
|
1091
1245
|
await c.req.json().catch(() => null),
|
|
1092
1246
|
);
|
|
1093
1247
|
if (!parsed.success) {
|
|
1094
|
-
throw new HTTPException(422, {
|
|
1248
|
+
throw new HTTPException(422, {
|
|
1249
|
+
message: "invalid realtime connection activation",
|
|
1250
|
+
});
|
|
1095
1251
|
}
|
|
1096
1252
|
try {
|
|
1097
1253
|
const result = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
|
|
@@ -1131,7 +1287,9 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1131
1287
|
await c.req.json().catch(() => null),
|
|
1132
1288
|
);
|
|
1133
1289
|
if (!parsed.success) {
|
|
1134
|
-
throw new HTTPException(422, {
|
|
1290
|
+
throw new HTTPException(422, {
|
|
1291
|
+
message: "invalid realtime ledger sync request",
|
|
1292
|
+
});
|
|
1135
1293
|
}
|
|
1136
1294
|
try {
|
|
1137
1295
|
const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) =>
|
|
@@ -1284,6 +1442,43 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1284
1442
|
return c.json({ pinned: target === "auto" ? "auto" : target });
|
|
1285
1443
|
});
|
|
1286
1444
|
|
|
1445
|
+
// Re-file the session into a workspace channel (rail organization only;
|
|
1446
|
+
// null = back to the unfiled inbox). Shared, workspace-visible state, so it
|
|
1447
|
+
// requires sessions:control like rename. Returns the refreshed session.
|
|
1448
|
+
app.put("/v1/workspaces/:workspaceId/sessions/:sessionId/channel", async (c) => {
|
|
1449
|
+
const workspaceId = c.req.param("workspaceId");
|
|
1450
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
|
|
1451
|
+
const sessionId = c.req.param("sessionId");
|
|
1452
|
+
await assertSessionExists(db, workspaceId, sessionId);
|
|
1453
|
+
const payload = UpdateSessionChannelRequest.parse(await c.req.json());
|
|
1454
|
+
try {
|
|
1455
|
+
const updated = await setSessionChannel(db, {
|
|
1456
|
+
workspaceId,
|
|
1457
|
+
sessionId,
|
|
1458
|
+
channelId: payload.channelId,
|
|
1459
|
+
});
|
|
1460
|
+
if (!updated) {
|
|
1461
|
+
throw new HTTPException(404, { message: "session not found" });
|
|
1462
|
+
}
|
|
1463
|
+
} catch (error) {
|
|
1464
|
+
if (error instanceof ChannelNotFoundError) {
|
|
1465
|
+
throw new HTTPException(422, { message: error.message });
|
|
1466
|
+
}
|
|
1467
|
+
throw error;
|
|
1468
|
+
}
|
|
1469
|
+
const session = await getSessionForSubject(
|
|
1470
|
+
db,
|
|
1471
|
+
workspaceId,
|
|
1472
|
+
sessionId,
|
|
1473
|
+
grant.subjectId,
|
|
1474
|
+
relatedSessionAccessFor(c),
|
|
1475
|
+
);
|
|
1476
|
+
if (!session) {
|
|
1477
|
+
throw new HTTPException(404, { message: "session not found" });
|
|
1478
|
+
}
|
|
1479
|
+
return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
|
|
1480
|
+
});
|
|
1481
|
+
|
|
1287
1482
|
// Manual rename. A user-set title is permanent: the db write is
|
|
1288
1483
|
// unconditional (source='user'), so it always pins the session over later
|
|
1289
1484
|
// agent writes. Returns the refreshed session, mirroring GET detail.
|
|
@@ -1373,6 +1568,77 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1373
1568
|
return c.json(goal);
|
|
1374
1569
|
});
|
|
1375
1570
|
|
|
1571
|
+
app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/goal/revisions", async (c) => {
|
|
1572
|
+
const workspaceId = c.req.param("workspaceId");
|
|
1573
|
+
await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
1574
|
+
const sessionId = c.req.param("sessionId");
|
|
1575
|
+
await assertSessionExists(db, workspaceId, sessionId);
|
|
1576
|
+
return c.json(await listSessionGoalRevisions(db, workspaceId, sessionId));
|
|
1577
|
+
});
|
|
1578
|
+
|
|
1579
|
+
app.post(
|
|
1580
|
+
"/v1/workspaces/:workspaceId/sessions/:sessionId/goal/revisions/:revisionId/apply",
|
|
1581
|
+
async (c) => {
|
|
1582
|
+
const workspaceId = c.req.param("workspaceId");
|
|
1583
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
|
|
1584
|
+
const sessionId = c.req.param("sessionId");
|
|
1585
|
+
await assertSessionExists(db, workspaceId, sessionId);
|
|
1586
|
+
const payload = ApplySessionGoalRevisionRequest.parse(await c.req.json());
|
|
1587
|
+
const revision = await getSessionGoalRevision(
|
|
1588
|
+
db,
|
|
1589
|
+
workspaceId,
|
|
1590
|
+
sessionId,
|
|
1591
|
+
c.req.param("revisionId"),
|
|
1592
|
+
);
|
|
1593
|
+
if (!revision || revision.disposition !== "proposed") {
|
|
1594
|
+
throw new HTTPException(404, {
|
|
1595
|
+
message: "goal rewrite proposal not found",
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
if (!goalProposalMatchesExpectedRevision(revision, payload.expectedObjectiveRevision)) {
|
|
1599
|
+
throw new HTTPException(409, {
|
|
1600
|
+
message: `goal proposal was based on objective revision ${revision.baseObjectiveRevision}; requested fence ${payload.expectedObjectiveRevision} cannot apply it`,
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
try {
|
|
1604
|
+
const { goal, workflowWakeRevision, events } = await upsertSessionGoalWithEvent(db, {
|
|
1605
|
+
accountId: grant.accountId,
|
|
1606
|
+
workspaceId,
|
|
1607
|
+
sessionId,
|
|
1608
|
+
text: revision.text,
|
|
1609
|
+
successCriteria: revision.successCriteria,
|
|
1610
|
+
mutationPolicy: revision.mutationPolicy,
|
|
1611
|
+
expectedObjectiveRevision: payload.expectedObjectiveRevision,
|
|
1612
|
+
expectedGoalId: revision.goalId,
|
|
1613
|
+
changeKind: revision.changeKind,
|
|
1614
|
+
changeRationale: payload.rationale ?? `Applied goal proposal ${revision.id}`,
|
|
1615
|
+
sourceProposalId: revision.id,
|
|
1616
|
+
createdBy: "api",
|
|
1617
|
+
actor: "api",
|
|
1618
|
+
});
|
|
1619
|
+
await publishDurableSessionEvents(bus, workspaceId, sessionId, events);
|
|
1620
|
+
if (workflowWakeRevision !== null) {
|
|
1621
|
+
await workflowClient.wakeSessionWorkflow({
|
|
1622
|
+
accountId: grant.accountId,
|
|
1623
|
+
workspaceId,
|
|
1624
|
+
sessionId,
|
|
1625
|
+
workflowId: workflowIdForSession(sessionId),
|
|
1626
|
+
wakeRevision: workflowWakeRevision,
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
return c.json((await getSessionGoalWithContinuation(db, workspaceId, sessionId)) ?? goal);
|
|
1630
|
+
} catch (error) {
|
|
1631
|
+
if (error instanceof SessionControlConflictError) {
|
|
1632
|
+
throw new HTTPException(409, {
|
|
1633
|
+
message: error.message,
|
|
1634
|
+
cause: error,
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
throw error;
|
|
1638
|
+
}
|
|
1639
|
+
},
|
|
1640
|
+
);
|
|
1641
|
+
|
|
1376
1642
|
app.patch("/v1/workspaces/:workspaceId/sessions/:sessionId/goal", async (c) => {
|
|
1377
1643
|
const workspaceId = c.req.param("workspaceId");
|
|
1378
1644
|
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
|
|
@@ -1383,6 +1649,48 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1383
1649
|
if (!existing) {
|
|
1384
1650
|
throw new HTTPException(404, { message: "session goal not found" });
|
|
1385
1651
|
}
|
|
1652
|
+
if (!("status" in payload)) {
|
|
1653
|
+
try {
|
|
1654
|
+
const { goal, workflowWakeRevision, events } = await upsertSessionGoalWithEvent(db, {
|
|
1655
|
+
accountId: grant.accountId,
|
|
1656
|
+
workspaceId,
|
|
1657
|
+
sessionId,
|
|
1658
|
+
text: payload.text,
|
|
1659
|
+
successCriteria:
|
|
1660
|
+
payload.successCriteria !== undefined
|
|
1661
|
+
? payload.successCriteria
|
|
1662
|
+
: existing.successCriteria,
|
|
1663
|
+
maxAutoContinuations: existing.maxAutoContinuations,
|
|
1664
|
+
mutationPolicy: payload.mutationPolicy ?? existing.mutationPolicy,
|
|
1665
|
+
expectedObjectiveRevision: payload.expectedObjectiveRevision,
|
|
1666
|
+
changeKind: "replacement",
|
|
1667
|
+
changeRationale: payload.rationale,
|
|
1668
|
+
createdBy: "api",
|
|
1669
|
+
actor: "api",
|
|
1670
|
+
});
|
|
1671
|
+
if (events.length > 0) {
|
|
1672
|
+
await bus.publish(workspaceId, sessionId, events);
|
|
1673
|
+
}
|
|
1674
|
+
if (workflowWakeRevision !== null) {
|
|
1675
|
+
await workflowClient.wakeSessionWorkflow({
|
|
1676
|
+
accountId: grant.accountId,
|
|
1677
|
+
workspaceId,
|
|
1678
|
+
sessionId,
|
|
1679
|
+
workflowId: workflowIdForSession(sessionId),
|
|
1680
|
+
wakeRevision: workflowWakeRevision,
|
|
1681
|
+
});
|
|
1682
|
+
}
|
|
1683
|
+
return c.json((await getSessionGoalWithContinuation(db, workspaceId, sessionId)) ?? goal);
|
|
1684
|
+
} catch (error) {
|
|
1685
|
+
if (error instanceof SessionControlConflictError) {
|
|
1686
|
+
throw new HTTPException(409, {
|
|
1687
|
+
message: error.message,
|
|
1688
|
+
cause: error,
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
throw error;
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1386
1694
|
if (existing.status === "completed") {
|
|
1387
1695
|
throw new HTTPException(409, {
|
|
1388
1696
|
message: "session goal is completed; set a new goal instead",
|
|
@@ -2167,6 +2475,15 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2167
2475
|
if (!session) {
|
|
2168
2476
|
throw new HTTPException(404, { message: "session not found" });
|
|
2169
2477
|
}
|
|
2478
|
+
// Capability truth follows the ACTIVE placement, not the session's managed
|
|
2479
|
+
// home lease. A connected machine has no Modal group lease; treating that
|
|
2480
|
+
// absence as `cold` makes the client suppress the very viewer attach that
|
|
2481
|
+
// would mint its relay terminal/desktop cells. Resolve the active target once
|
|
2482
|
+
// and use the selfhosted liveness probe when it is a connected machine.
|
|
2483
|
+
const activeSandbox = session.activeSandboxId
|
|
2484
|
+
? await getSandbox(db, workspaceId, session.activeSandboxId)
|
|
2485
|
+
: null;
|
|
2486
|
+
const selfhostedActive = activeSandbox?.kind === "selfhosted";
|
|
2170
2487
|
const lease = await readGroupLease(
|
|
2171
2488
|
{ db, settings },
|
|
2172
2489
|
{ workspaceId, sandboxGroupId: session.sandboxGroupId },
|
|
@@ -2190,36 +2507,64 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2190
2507
|
// startup, port exposure, or short-lived bearer mint. Besides enforcing least
|
|
2191
2508
|
// privilege, that keeps the 120-second stream credentials from aging before a
|
|
2192
2509
|
// user opens their surface. POST /viewers is the sole credential grant.
|
|
2193
|
-
const
|
|
2510
|
+
const commonNegotiation = {
|
|
2194
2511
|
sessionId,
|
|
2195
|
-
backend: session.sandboxBackend as SandboxBackend,
|
|
2196
|
-
os: session.sandboxOs,
|
|
2197
|
-
liveness: lease?.liveness ?? "cold",
|
|
2198
|
-
leaseEpoch: lease?.leaseEpoch ?? 0,
|
|
2199
|
-
workspaceGeneration: lease?.workspaceGeneration ?? null,
|
|
2200
|
-
archiveGeneration: lease?.archiveGeneration ?? null,
|
|
2201
|
-
archiveComplete: lease?.archiveComplete ?? false,
|
|
2202
2512
|
desktopEnabled: settings.sandboxDesktopEnabled,
|
|
2203
2513
|
// Human take-control: when the desktop is available + this policy is on
|
|
2204
2514
|
// (default), the cell is mode "interactive" — the noVNC viewer drives :0
|
|
2205
2515
|
// (x11vnc runs without -viewonly). Off → mode "read-only" (client disables
|
|
2206
2516
|
// take-control). Independent of the agent's computerUseReadOnly.
|
|
2207
2517
|
desktopInteractive: settings.sandboxDesktopInteractive,
|
|
2208
|
-
// P4.3 computer-use: the agent drives
|
|
2518
|
+
// P4.3 computer-use: the agent drives the active display; availability
|
|
2209
2519
|
// tracks the desktop tier + a desktop-capable backend.
|
|
2210
2520
|
computerUseEnabled: settings.computerUseEnabled,
|
|
2211
2521
|
computerUseReadOnly: settings.computerUseReadOnly,
|
|
2212
|
-
// Graceful degrade
|
|
2213
|
-
// secret is resolvable, the desktop cell reports transport:null rather
|
|
2214
|
-
// than advertising a plane we can never authorize.
|
|
2522
|
+
// Graceful degrade when scoped stream credentials cannot be minted.
|
|
2215
2523
|
streamTokenSecretAvailable: !streamTokenDegraded(settings),
|
|
2216
2524
|
desktopAcknowledged: acknowledged,
|
|
2217
2525
|
shared,
|
|
2218
2526
|
sharedSessionIds: visibleSharedSessionIds,
|
|
2219
|
-
// Plane policy only. Live addresses are intentionally absent on a
|
|
2220
|
-
// descriptor read and arrive from an authorized viewer grant.
|
|
2221
2527
|
terminalEnabled: settings.sandboxTerminalEnabled,
|
|
2222
|
-
}
|
|
2528
|
+
} as const;
|
|
2529
|
+
|
|
2530
|
+
let capabilities;
|
|
2531
|
+
if (selfhostedActive && activeSandbox.enrollmentId) {
|
|
2532
|
+
const enrollment = await getEnrollment(db, workspaceId, activeSandbox.enrollmentId);
|
|
2533
|
+
let probeResponded = false;
|
|
2534
|
+
if (enrollment?.status === "active") {
|
|
2535
|
+
const machine = new SelfhostedSession({
|
|
2536
|
+
workspaceId,
|
|
2537
|
+
agentId: enrollment.id,
|
|
2538
|
+
controlRpc: new NatsControlRpc(async () => bus.getRequestConnection()),
|
|
2539
|
+
relay: relayConfigFromSettings(settings),
|
|
2540
|
+
epoch: session.activeEpoch,
|
|
2541
|
+
timeoutMs: settings.sandboxSelfhostedControlTimeoutMs,
|
|
2542
|
+
});
|
|
2543
|
+
try {
|
|
2544
|
+
probeResponded = await machine.ping();
|
|
2545
|
+
} catch {
|
|
2546
|
+
probeResponded = false;
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
capabilities = await negotiateSelfhostedCapabilities({
|
|
2550
|
+
...commonNegotiation,
|
|
2551
|
+
os: enrollment?.os ?? session.sandboxOs,
|
|
2552
|
+
leaseEpoch: session.activeEpoch,
|
|
2553
|
+
enrollment,
|
|
2554
|
+
probeResponded,
|
|
2555
|
+
});
|
|
2556
|
+
} else {
|
|
2557
|
+
capabilities = negotiateCapabilities({
|
|
2558
|
+
...commonNegotiation,
|
|
2559
|
+
backend: session.sandboxBackend as SandboxBackend,
|
|
2560
|
+
os: session.sandboxOs,
|
|
2561
|
+
liveness: lease?.liveness ?? "cold",
|
|
2562
|
+
leaseEpoch: lease?.leaseEpoch ?? 0,
|
|
2563
|
+
workspaceGeneration: lease?.workspaceGeneration ?? null,
|
|
2564
|
+
archiveGeneration: lease?.archiveGeneration ?? null,
|
|
2565
|
+
archiveComplete: lease?.archiveComplete ?? false,
|
|
2566
|
+
});
|
|
2567
|
+
}
|
|
2223
2568
|
|
|
2224
2569
|
const repositoryRoots = [
|
|
2225
2570
|
...new Set(
|
|
@@ -2258,11 +2603,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2258
2603
|
},
|
|
2259
2604
|
};
|
|
2260
2605
|
if (capabilities.DesktopStream.transport !== null) {
|
|
2261
|
-
const activeSandbox = session.activeSandboxId
|
|
2262
|
-
? await getSandbox(db, workspaceId, session.activeSandboxId)
|
|
2263
|
-
: null;
|
|
2264
2606
|
const wire = resolveActiveDesktopTransport(
|
|
2265
|
-
|
|
2607
|
+
selfhostedActive,
|
|
2266
2608
|
settings.sandboxDesktopInteractive !== false,
|
|
2267
2609
|
);
|
|
2268
2610
|
responseCapabilities = {
|
|
@@ -2348,7 +2690,9 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2348
2690
|
// v2 flag switches to exact-plane semantics.
|
|
2349
2691
|
const wantTerminal = parsed.data.terminal ?? (!hasExactPlaneSet && !wantDesktop);
|
|
2350
2692
|
if (!wantDesktop && !wantTerminal && !wantFiles) {
|
|
2351
|
-
throw new HTTPException(400, {
|
|
2693
|
+
throw new HTTPException(400, {
|
|
2694
|
+
message: "viewer attach requires a live plane",
|
|
2695
|
+
});
|
|
2352
2696
|
}
|
|
2353
2697
|
if (wantDesktop) requirePermission(grant, "stream:view");
|
|
2354
2698
|
if (wantTerminal) requirePermission(grant, "terminal:attach");
|
|
@@ -2494,7 +2838,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2494
2838
|
terminalUrl: terminal?.url ?? null,
|
|
2495
2839
|
terminalToken: terminal?.token ?? null,
|
|
2496
2840
|
terminalExpiresAt: terminal?.expiresAt ?? null,
|
|
2497
|
-
terminalTransport: terminal
|
|
2841
|
+
terminalTransport: terminal?.transport ?? null,
|
|
2498
2842
|
} satisfies AttachViewerResponse;
|
|
2499
2843
|
return c.json(response, 201);
|
|
2500
2844
|
});
|
|
@@ -3093,6 +3437,13 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
3093
3437
|
});
|
|
3094
3438
|
}
|
|
3095
3439
|
|
|
3440
|
+
export function goalProposalMatchesExpectedRevision(
|
|
3441
|
+
revision: Pick<SessionGoalRevision, "baseObjectiveRevision">,
|
|
3442
|
+
expectedObjectiveRevision: number,
|
|
3443
|
+
): boolean {
|
|
3444
|
+
return revision.baseObjectiveRevision === expectedObjectiveRevision;
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3096
3447
|
function eventListLimit(raw: string | undefined, max = 2000, fallback = 500): number {
|
|
3097
3448
|
const limit = Number(raw ?? fallback);
|
|
3098
3449
|
if (!Number.isFinite(limit)) {
|
|
@@ -3162,6 +3513,7 @@ export function sessionAuthorizationOperationForHttp(
|
|
|
3162
3513
|
return null;
|
|
3163
3514
|
}
|
|
3164
3515
|
if (suffix === "/pin" && verb === "PUT") return "session.pin.write";
|
|
3516
|
+
if (suffix === "/channel" && verb === "PUT") return "session.channel.write";
|
|
3165
3517
|
if (suffix === "/tool-policy" && verb === "PUT") return "session.tool_policy.write";
|
|
3166
3518
|
if (/^\/mcp-servers\/[^/]+\/approval-policy$/.test(suffix) && verb === "PATCH") {
|
|
3167
3519
|
return "session.mcp.approval_policy.write";
|
|
@@ -3176,6 +3528,9 @@ export function sessionAuthorizationOperationForHttp(
|
|
|
3176
3528
|
if (suffix === "/realtime/gateway" && verb === "POST") {
|
|
3177
3529
|
return "session.realtime.start";
|
|
3178
3530
|
}
|
|
3531
|
+
if (suffix === "/realtime/supergrok" && verb === "POST") {
|
|
3532
|
+
return "session.realtime.start";
|
|
3533
|
+
}
|
|
3179
3534
|
if (suffix === "/realtime" && verb === "POST") {
|
|
3180
3535
|
return "session.realtime.start";
|
|
3181
3536
|
}
|
|
@@ -3198,6 +3553,10 @@ export function sessionAuthorizationOperationForHttp(
|
|
|
3198
3553
|
? "session.goal.write"
|
|
3199
3554
|
: null;
|
|
3200
3555
|
}
|
|
3556
|
+
if (suffix === "/goal/revisions" && verb === "GET") return "session.goal.read";
|
|
3557
|
+
if (/^\/goal\/revisions\/[^/]+\/apply$/.test(suffix) && verb === "POST") {
|
|
3558
|
+
return "session.goal.write";
|
|
3559
|
+
}
|
|
3201
3560
|
if (suffix === "/context/clear" || suffix === "/context/compact") {
|
|
3202
3561
|
return verb === "POST" ? "session.context.write" : null;
|
|
3203
3562
|
}
|
|
@@ -3388,7 +3747,9 @@ export function encodeAgentTopologyCursor(value: AgentTopologyCursorEnvelope): s
|
|
|
3388
3747
|
|
|
3389
3748
|
function decodeAgentTopologyCursor(value: string): AgentTopologyCursorEnvelope {
|
|
3390
3749
|
if (value.length > 2_048) {
|
|
3391
|
-
throw new HTTPException(400, {
|
|
3750
|
+
throw new HTTPException(400, {
|
|
3751
|
+
message: "agent topology cursor is invalid",
|
|
3752
|
+
});
|
|
3392
3753
|
}
|
|
3393
3754
|
try {
|
|
3394
3755
|
const parsed = z
|
|
@@ -3421,7 +3782,9 @@ function decodeAgentTopologyCursor(value: string): AgentTopologyCursorEnvelope {
|
|
|
3421
3782
|
}
|
|
3422
3783
|
return parsed;
|
|
3423
3784
|
} catch {
|
|
3424
|
-
throw new HTTPException(400, {
|
|
3785
|
+
throw new HTTPException(400, {
|
|
3786
|
+
message: "agent topology cursor is invalid",
|
|
3787
|
+
});
|
|
3425
3788
|
}
|
|
3426
3789
|
}
|
|
3427
3790
|
|
|
@@ -3434,7 +3797,9 @@ export function agentTopologyQuery(query: Record<string, string>): {
|
|
|
3434
3797
|
const rawLimit = query.limit;
|
|
3435
3798
|
const limit = rawLimit === undefined ? 25 : Number(rawLimit);
|
|
3436
3799
|
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
3437
|
-
throw new HTTPException(400, {
|
|
3800
|
+
throw new HTTPException(400, {
|
|
3801
|
+
message: "limit must be an integer between 1 and 100",
|
|
3802
|
+
});
|
|
3438
3803
|
}
|
|
3439
3804
|
const rawParent = query.parentSessionId;
|
|
3440
3805
|
if (
|
|
@@ -3449,17 +3814,23 @@ export function agentTopologyQuery(query: Record<string, string>): {
|
|
|
3449
3814
|
const parentSessionId = rawParent === undefined || rawParent === "null" ? null : rawParent;
|
|
3450
3815
|
const search = query.search?.trim();
|
|
3451
3816
|
if (search && search.length > 200) {
|
|
3452
|
-
throw new HTTPException(400, {
|
|
3817
|
+
throw new HTTPException(400, {
|
|
3818
|
+
message: "search must be at most 200 characters",
|
|
3819
|
+
});
|
|
3453
3820
|
}
|
|
3454
3821
|
if (search && rawParent !== undefined) {
|
|
3455
|
-
throw new HTTPException(400, {
|
|
3822
|
+
throw new HTTPException(400, {
|
|
3823
|
+
message: "search cannot be combined with parentSessionId",
|
|
3824
|
+
});
|
|
3456
3825
|
}
|
|
3457
3826
|
const envelope = query.cursor ? decodeAgentTopologyCursor(query.cursor) : undefined;
|
|
3458
3827
|
if (
|
|
3459
3828
|
envelope &&
|
|
3460
3829
|
(envelope.parentSessionId !== parentSessionId || envelope.search !== (search || null))
|
|
3461
3830
|
) {
|
|
3462
|
-
throw new HTTPException(400, {
|
|
3831
|
+
throw new HTTPException(400, {
|
|
3832
|
+
message: "agent topology cursor does not match its filters",
|
|
3833
|
+
});
|
|
3463
3834
|
}
|
|
3464
3835
|
return {
|
|
3465
3836
|
limit,
|
|
@@ -3494,6 +3865,11 @@ function optionalEventSequence(raw: string | undefined): number | undefined {
|
|
|
3494
3865
|
|
|
3495
3866
|
/** Stable, value-free JSON errors for only the create-session boundary. */
|
|
3496
3867
|
export function sessionCreateErrorResponse(c: Context, error: unknown): Response {
|
|
3868
|
+
if (error instanceof ChannelNotFoundError) {
|
|
3869
|
+
// Covers the create-vs-channel-delete race the pre-validation cannot: the
|
|
3870
|
+
// insert's FK rejection surfaces as the same 422 an unknown id gets.
|
|
3871
|
+
return c.json({ code: "SESSION_CREATE_REJECTED", message: error.message }, 422);
|
|
3872
|
+
}
|
|
3497
3873
|
if (error instanceof SessionSpawnDeniedError) {
|
|
3498
3874
|
return c.json(
|
|
3499
3875
|
sessionSpawnDenialEnvelope(error),
|