@sema-agent/server 1.315.0 → 1.316.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.
Files changed (97) hide show
  1. package/dist/http/idempotency.d.ts +1 -0
  2. package/dist/http/idempotency.js +3 -0
  3. package/dist/http/principal-gate.d.ts +12 -0
  4. package/dist/http/principal-gate.js +52 -0
  5. package/dist/http/route-ctx.d.ts +67 -0
  6. package/dist/http/route-ctx.js +2 -0
  7. package/dist/http/routes/attachments.d.ts +4 -0
  8. package/dist/http/routes/attachments.js +106 -0
  9. package/dist/http/routes/capabilities.d.ts +4 -0
  10. package/dist/http/routes/capabilities.js +111 -0
  11. package/dist/http/routes/images.d.ts +20 -0
  12. package/dist/http/routes/images.js +572 -0
  13. package/dist/http/routes/memory-policy.d.ts +4 -0
  14. package/dist/http/routes/memory-policy.js +105 -0
  15. package/dist/http/routes/observability.d.ts +4 -0
  16. package/dist/http/routes/observability.js +63 -0
  17. package/dist/http/routes/session-sync.d.ts +36 -0
  18. package/dist/http/routes/session-sync.js +513 -0
  19. package/dist/http/routes/sessions-list.d.ts +4 -0
  20. package/dist/http/routes/sessions-list.js +117 -0
  21. package/dist/http/routes/sessions.d.ts +17 -0
  22. package/dist/http/routes/sessions.js +746 -0
  23. package/dist/http/send.d.ts +6 -0
  24. package/dist/http/send.js +19 -0
  25. package/dist/http/server.d.ts +6 -7
  26. package/dist/http/server.js +125 -2413
  27. package/dist/http/sse-log.d.ts +25 -0
  28. package/dist/http/sse-log.js +59 -0
  29. package/dist/http/workspace-content.d.ts +3 -0
  30. package/dist/http/workspace-content.js +20 -0
  31. package/dist/plugins/approval-store-sql.d.ts +52 -0
  32. package/dist/plugins/approval-store-sql.js +119 -0
  33. package/dist/plugins/breaker-state-sql.d.ts +29 -0
  34. package/dist/plugins/breaker-state-sql.js +104 -0
  35. package/dist/plugins/file-snapshot-store-sql.d.ts +38 -0
  36. package/dist/plugins/file-snapshot-store-sql.js +210 -0
  37. package/dist/plugins/image-index-sql.d.ts +33 -0
  38. package/dist/plugins/image-index-sql.js +328 -0
  39. package/dist/plugins/outcome-ledger-sql.d.ts +70 -0
  40. package/dist/plugins/outcome-ledger-sql.js +158 -0
  41. package/dist/plugins/pg-approval-store.d.ts +1 -27
  42. package/dist/plugins/pg-approval-store.js +1 -83
  43. package/dist/plugins/pg-breaker-state.d.ts +1 -19
  44. package/dist/plugins/pg-breaker-state.js +1 -88
  45. package/dist/plugins/pg-file-snapshot-store.d.ts +1 -27
  46. package/dist/plugins/pg-file-snapshot-store.js +1 -190
  47. package/dist/plugins/pg-image-index.d.ts +1 -21
  48. package/dist/plugins/pg-image-index.js +1 -217
  49. package/dist/plugins/pg-outcome-ledger.d.ts +2 -22
  50. package/dist/plugins/pg-outcome-ledger.js +1 -104
  51. package/dist/plugins/pg-resume-anchor-store.d.ts +1 -13
  52. package/dist/plugins/pg-resume-anchor-store.js +1 -22
  53. package/dist/plugins/pg-run-store.d.ts +1 -75
  54. package/dist/plugins/pg-run-store.js +1 -287
  55. package/dist/plugins/pg-session-policy-store.d.ts +1 -11
  56. package/dist/plugins/pg-session-policy-store.js +1 -90
  57. package/dist/plugins/pg-tool-result-store.d.ts +1 -15
  58. package/dist/plugins/pg-tool-result-store.js +1 -52
  59. package/dist/plugins/pg-workflow-journal-store.d.ts +1 -20
  60. package/dist/plugins/pg-workflow-journal-store.js +1 -34
  61. package/dist/plugins/pg-workflow-run-store.d.ts +1 -55
  62. package/dist/plugins/pg-workflow-run-store.js +1 -220
  63. package/dist/plugins/resume-anchor-store-sql.d.ts +23 -0
  64. package/dist/plugins/resume-anchor-store-sql.js +38 -0
  65. package/dist/plugins/run-store-sql.d.ts +89 -0
  66. package/dist/plugins/run-store-sql.js +397 -0
  67. package/dist/plugins/session-policy-store-sql.d.ts +22 -0
  68. package/dist/plugins/session-policy-store-sql.js +109 -0
  69. package/dist/plugins/tidb-approval-store.d.ts +1 -39
  70. package/dist/plugins/tidb-approval-store.js +1 -71
  71. package/dist/plugins/tidb-breaker-state.d.ts +1 -17
  72. package/dist/plugins/tidb-breaker-state.js +1 -74
  73. package/dist/plugins/tidb-file-snapshot-store.d.ts +1 -27
  74. package/dist/plugins/tidb-file-snapshot-store.js +1 -192
  75. package/dist/plugins/tidb-image-index.d.ts +1 -19
  76. package/dist/plugins/tidb-image-index.js +1 -149
  77. package/dist/plugins/tidb-outcome-ledger.d.ts +1 -57
  78. package/dist/plugins/tidb-outcome-ledger.js +1 -97
  79. package/dist/plugins/tidb-resume-anchor-store.d.ts +1 -13
  80. package/dist/plugins/tidb-resume-anchor-store.js +1 -22
  81. package/dist/plugins/tidb-run-store.d.ts +1 -75
  82. package/dist/plugins/tidb-run-store.js +1 -306
  83. package/dist/plugins/tidb-session-policy-store.d.ts +1 -11
  84. package/dist/plugins/tidb-session-policy-store.js +1 -86
  85. package/dist/plugins/tidb-tool-result-store.d.ts +1 -13
  86. package/dist/plugins/tidb-tool-result-store.js +1 -38
  87. package/dist/plugins/tidb-workflow-journal-store.d.ts +1 -20
  88. package/dist/plugins/tidb-workflow-journal-store.js +1 -34
  89. package/dist/plugins/tidb-workflow-run-store.d.ts +1 -61
  90. package/dist/plugins/tidb-workflow-run-store.js +1 -231
  91. package/dist/plugins/tool-result-store-sql.d.ts +25 -0
  92. package/dist/plugins/tool-result-store-sql.js +69 -0
  93. package/dist/plugins/workflow-journal-store-sql.d.ts +30 -0
  94. package/dist/plugins/workflow-journal-store-sql.js +53 -0
  95. package/dist/plugins/workflow-run-store-sql.d.ts +85 -0
  96. package/dist/plugins/workflow-run-store-sql.js +299 -0
  97. package/package.json +1 -1
@@ -33,7 +33,21 @@ import { projectArtifacts } from "../trace/artifacts.js";
33
33
  import { composeSupervisorCost, infraCost, infraUsageFromEvents, hasInfraPricing } from "../observability/cost-taxonomy.js";
34
34
  import { redactSecrets, redactDeep, redactedPreview } from "../trace/redact.js";
35
35
  import { usageSummary, usageSeries, usageBreakdown } from "../usage-analytics.js";
36
- import { IdempotencyCache } from "./idempotency.js";
36
+ import { IdempotencyCache, scopedIdempotencyKey } from "./idempotency.js";
37
+ export { scopedIdempotencyKey };
38
+ import { streamSseLog, sleep } from "./sse-log.js";
39
+ import { handleCapabilities } from "./routes/capabilities.js";
40
+ import { handleObservability } from "./routes/observability.js";
41
+ import { handleMemoryPolicy } from "./routes/memory-policy.js";
42
+ import { handleSessionsList } from "./routes/sessions-list.js";
43
+ import { handleSessions, createSessionsLocal } from "./routes/sessions.js";
44
+ import { handleSessionSync, createSessionSyncLocal } from "./routes/session-sync.js";
45
+ import { handleAttachments } from "./routes/attachments.js";
46
+ import { handleImages, createImagesLocal, coarseStatusForState, errorCodeForExit, IMAGE_DIGEST_RE, IMAGE_PROFILE_RE, BAKE_ID_RE, BAKE_EVENTS_RE, BAKE_CANCEL_RE, BAKE_CLAIM_RE, BAKE_INGEST_RE } from "./routes/images.js";
47
+ export { coarseStatusForState, errorCodeForExit };
48
+ import { sendJson, sendError, msg, sseHeaders } from "./send.js";
49
+ import { headerStr, safeEqual, authorized, systemFor, bearerPresentedButUnverified, gatedPrincipal, explicitOperatorOk, isOperator, explicitOperator } from "./principal-gate.js";
50
+ export { explicitOperatorOk, isOperator, explicitOperator };
37
51
  import { pgHasUnstorable } from "../plugins/pg-safe-json.js";
38
52
  export function flattenServiceDeps(deps) {
39
53
  const { stores, coordinators, seams, observability, governance, deployment, knobs, ...flat } = deps;
@@ -72,42 +86,6 @@ export function validateUserSkills(skills) {
72
86
  }
73
87
  return null;
74
88
  }
75
- export function explicitOperatorOk(principal, operatorPrincipals) {
76
- return operatorPrincipals.length > 0 && principal !== undefined && operatorPrincipals.includes(principal);
77
- }
78
- export function coarseStatusForState(state) {
79
- if (state === "COMPLETE")
80
- return "done";
81
- if (state === "FAILED" || state === "CANCELLED")
82
- return "failed";
83
- return "running";
84
- }
85
- export function errorCodeForExit(exitCode, cancelled) {
86
- if (cancelled)
87
- return "cancelled";
88
- switch (exitCode) {
89
- case 9:
90
- return "band-conflict";
91
- case 10:
92
- return "duplicate";
93
- case 124:
94
- return "timeout";
95
- case 507:
96
- return "disk";
97
- default:
98
- return null;
99
- }
100
- }
101
- const RUNNER_ERROR_CODES = new Set(["band-conflict", "duplicate", "disk", "timeout", "cancelled"]);
102
- const SANDBOX_BASE_PROFILE = "sandbox-base";
103
- export function isOperator(principal, operatorPrincipals) {
104
- if (operatorPrincipals.length === 0)
105
- return true;
106
- return principal !== undefined && operatorPrincipals.includes(principal);
107
- }
108
- export function explicitOperator(principal, operatorPrincipals) {
109
- return operatorPrincipals.length > 0 && principal !== undefined && operatorPrincipals.includes(principal);
110
- }
111
89
  export function cascadeConfig(ladder, maxCostUsd) {
112
90
  return {
113
91
  ladder: ladder.map((model) => ({ model })),
@@ -118,9 +96,6 @@ export function clampVerifyRounds(v) {
118
96
  const n = typeof v === "number" && Number.isFinite(v) ? Math.floor(v) : 2;
119
97
  return Math.min(5, Math.max(1, n));
120
98
  }
121
- export function scopedIdempotencyKey(raw, source, principal) {
122
- return raw ? `${source ?? ""}\x1f${principal ?? ""}\x1f${raw}` : undefined;
123
- }
124
99
  export function isQuestionAnswer(v) {
125
100
  if (!v || typeof v !== "object" || Array.isArray(v))
126
101
  return false;
@@ -141,10 +116,6 @@ export function isQuestionAnswer(v) {
141
116
  const MAX_BODY = 8 * 1024 * 1024;
142
117
  const MAX_IMAGES_PER_REQUEST = 20;
143
118
  const MAX_IMAGE_BASE64_BYTES = 6 * 1024 * 1024;
144
- const SYNC_BLOB_MAX_BYTES = 64 * 1024 * 1024;
145
- const SYNC_NDJSON_LINE_MAX_BYTES = 8 * 1024 * 1024;
146
- const SYNC_IMPORT_BATCH = 500;
147
- const SHA256_HEX_RE = /^[0-9a-f]{64}$/;
148
119
  const RUN_ID_RE = /^\/v1\/runs\/([^/]+)(\/events)?$/;
149
120
  const RUN_CANCEL_RE = /^\/v1\/runs\/([^/]+)\/cancel$/;
150
121
  const RUN_STEER_RE = /^\/v1\/runs\/([^/]+)\/steer$/;
@@ -164,13 +135,6 @@ const TOOL_APPROVAL_RESPOND_RE = /^\/v1\/tool-approvals\/([^/]+)\/respond$/;
164
135
  const ASSISTANT_PREEMPT_RE = /^\/v1\/assistant\/tasks\/([^/]+)\/preempt$/;
165
136
  const ASSISTANT_RESUME_RE = /^\/v1\/assistant\/tasks\/([^/]+)\/resume$/;
166
137
  const ASSISTANT_PLAN_REVIEW_RE = /^\/v1\/assistant\/tasks\/([^/]+)\/plan_review$/;
167
- const IMAGE_DIGEST_RE = /^\/v1\/images\/digests\/([^/]+)$/;
168
- const IMAGE_PROFILE_RE = /^\/v1\/images\/([^/]+)$/;
169
- const BAKE_ID_RE = /^\/v1\/images\/bakes\/([^/]+)$/;
170
- const BAKE_EVENTS_RE = /^\/v1\/images\/bakes\/([^/]+)\/events$/;
171
- const BAKE_CANCEL_RE = /^\/v1\/images\/bakes\/([^/]+)\/cancel$/;
172
- const BAKE_CLAIM_RE = /^\/v1\/images\/bakes\/([^/]+)\/claim$/;
173
- const BAKE_INGEST_RE = /^\/v1\/images\/bakes\/([^/]+)\/ingest$/;
174
138
  const TRACE_RE = /^\/v1\/tasks(?:\/([^/]+)(\/turns|\/stream|\/artifacts))?$/;
175
139
  const WORKFLOWS_RE = /^\/v1\/workflows(?:\/([^/]+)(\/stream|\/journal)?)?$/;
176
140
  const WORKFLOW_AGENT_STEER_RE = /^\/v1\/workflows\/([^/]+)\/agents\/([^/]+)\/steer$/;
@@ -184,38 +148,28 @@ const HAND_TOOL_NAMES = Object.keys(HAND_TOOL_EFFECTS);
184
148
  function isCheckpointReopenedFailure(r) {
185
149
  return r.status === "failed" && (r.errorCode === "resume.env_failed" || r.errorCode === "resume.tool_unavailable");
186
150
  }
187
- function gatedPrincipal(req, config) {
188
- if (bearerPresentedButUnverified(req, config))
189
- return undefined;
190
- return verifiedPrincipal(req, config);
191
- }
192
- function bearerPresentedButUnverified(req, config) {
193
- const h = req.headers.authorization;
194
- if (typeof h !== "string" || !/^(Bearer|token) /i.test(h))
195
- return false;
196
- if (ssoVerifiedPrincipal(req))
197
- return false;
198
- if (systemFor(req, config) !== undefined)
199
- return false;
200
- if (config.traceToken && authorized(req, config.traceToken))
201
- return false;
202
- return true;
203
- }
151
+ const ROUTE_DOMAINS = [
152
+ handleCapabilities,
153
+ handleImages,
154
+ handleObservability,
155
+ handleMemoryPolicy,
156
+ handleSessionsList,
157
+ handleSessions,
158
+ handleSessionSync,
159
+ handleAttachments,
160
+ ];
161
+ void ROUTE_DOMAINS;
204
162
  export function createHttpServer(rawDeps) {
205
163
  const deps = flattenServiceDeps(rawDeps);
206
164
  const idemCache = new IdempotencyCache();
207
- let sseConnections = 0;
208
- let sseOwnerLookups = 0;
209
- const bakeSubmitLimiter = new RateLimiter(deps.config.imageBakes?.submitRateMax ?? 0, (deps.config.imageBakes?.submitRateWindowSec ?? 60) * 1000);
210
165
  const inflightRuns = new Map();
211
166
  const cancelledViaVerb = new Set();
212
167
  const preemptableRuns = new Map();
213
168
  const steerableRuns = new Map();
214
169
  const wakeParkMints = new Map();
215
- let uncountedBillableInflight = 0;
216
- let admittedInflight = 0;
170
+ const counters = { uncountedBillableInflight: 0, admittedInflight: 0 };
217
171
  if (deps.drainState)
218
- deps.drainState.inflight = () => new Set([...inflightRuns.keys(), ...steerableRuns.keys()]).size + uncountedBillableInflight + admittedInflight + (deps.leaderEndpoint?.inflight?.() ?? 0);
172
+ deps.drainState.inflight = () => new Set([...inflightRuns.keys(), ...steerableRuns.keys()]).size + counters.uncountedBillableInflight + counters.admittedInflight + (deps.leaderEndpoint?.inflight?.() ?? 0);
219
173
  let lastBillableActivityAt = performance.now();
220
174
  const noteActivity = (url) => {
221
175
  if (url === "/health" || url.startsWith("/metrics"))
@@ -261,22 +215,6 @@ export function createHttpServer(rawDeps) {
261
215
  }
262
216
  };
263
217
  }
264
- const mcpStatusCache = new Map();
265
- const MCP_STATUS_TTL_MS = 5_000;
266
- const MCP_MATERIALIZE_TIMEOUT_MS = 10_000;
267
- const pendingStagings = new Map();
268
- const importLeases = new Map();
269
- const touchImportLease = (sessionId) => {
270
- const cur = importLeases.get(sessionId);
271
- if (cur)
272
- importLeases.set(sessionId, { ...cur, at: Date.now() });
273
- };
274
- const cleanupStaging = (stagingId) => {
275
- const p = pendingStagings.get(stagingId);
276
- if (p)
277
- importLeases.delete(p.realSessionId);
278
- pendingStagings.delete(stagingId);
279
- };
280
218
  const server = http.createServer((req, res) => {
281
219
  void handle(req, res).catch((err) => {
282
220
  if (err instanceof HttpError) {
@@ -293,6 +231,12 @@ export function createHttpServer(rawDeps) {
293
231
  res.end();
294
232
  });
295
233
  });
234
+ const routeCtxBase = {
235
+ deps,
236
+ registry: { idemCache, inflightRuns, preemptableRuns, cancelledViaVerb, steerableRuns, wakeParkMints, counters },
237
+ helpers: { readJson, readRawBody, rateLimited, quotaExceeded, leaseDenied, safeDecode, isFleetWide, runOwnerOk, sessionOwnerScope, sessionOwnerScopeForWrite, runSessionAcceptOk },
238
+ local: { images: createImagesLocal(deps), sessions: createSessionsLocal(), sessionSync: createSessionSyncLocal() },
239
+ };
296
240
  async function handle(req, res) {
297
241
  const startedAt = Date.now();
298
242
  const method = req.method ?? "GET";
@@ -300,16 +244,16 @@ export function createHttpServer(rawDeps) {
300
244
  noteActivity(url);
301
245
  const admitted = method !== "OPTIONS" && url !== "/health" && !url.startsWith("/metrics");
302
246
  if (admitted)
303
- admittedInflight++;
247
+ counters.admittedInflight++;
304
248
  let logged = false;
305
- let streamTaskId;
306
- let streamDetached = false;
249
+ const reqState = { streamTaskId: undefined, streamDetached: false, source: null };
250
+ const ctx = { deps: routeCtxBase.deps, registry: routeCtxBase.registry, helpers: routeCtxBase.helpers, local: routeCtxBase.local, req: reqState };
307
251
  const record = (viaClose) => {
308
252
  if (logged)
309
253
  return;
310
254
  logged = true;
311
255
  if (admitted)
312
- admittedInflight--;
256
+ counters.admittedInflight--;
313
257
  const route = routeLabel(method, url);
314
258
  const seconds = (Date.now() - startedAt) / 1000;
315
259
  const aborted = viaClose && !res.writableEnded;
@@ -317,7 +261,7 @@ export function createHttpServer(rawDeps) {
317
261
  deps.metrics?.inc("http_requests_total", { route, method, status });
318
262
  deps.metrics?.observe("http_request_duration_seconds", seconds, { route });
319
263
  const log = route === "/health" || route === "/metrics" ? deps.logger?.debug : deps.logger?.info;
320
- log?.call(deps.logger, "request", { method, route, status, ms: Date.now() - startedAt, ...(aborted ? { aborted: true, ...(streamTaskId ? { taskId: streamTaskId } : {}), ...(streamDetached ? { detached: true } : {}) } : {}) });
264
+ log?.call(deps.logger, "request", { method, route, status, ms: Date.now() - startedAt, ...(aborted ? { aborted: true, ...(reqState.streamTaskId ? { taskId: reqState.streamTaskId } : {}), ...(reqState.streamDetached ? { detached: true } : {}) } : {}) });
321
265
  };
322
266
  res.on("finish", () => record(false));
323
267
  res.on("close", () => record(true));
@@ -630,6 +574,7 @@ export function createHttpServer(rawDeps) {
630
574
  source = sys;
631
575
  }
632
576
  }
577
+ reqState.source = source;
633
578
  if (req.method === "POST" &&
634
579
  !anyServiceAuth &&
635
580
  !deps.config.allowUnauthedWrites &&
@@ -658,103 +603,8 @@ export function createHttpServer(rawDeps) {
658
603
  });
659
604
  return;
660
605
  }
661
- if (req.method === "GET" && url.startsWith("/v1/capabilities/scenarios/")) {
662
- const name = url.slice("/v1/capabilities/scenarios/".length).split("?")[0];
663
- const detail = deps.scenarioDetails?.[name];
664
- if (!detail) {
665
- sendError(res, 404, "scenario_not_found", "scenario not found");
666
- return;
667
- }
668
- sendJson(res, 200, detail);
669
- return;
670
- }
671
- if (req.method === "GET" && url === "/v1/capabilities") {
672
- sendJson(res, 200, {
673
- ...deps.capabilities,
674
- asyncRuns: Boolean(deps.runStore),
675
- artifacts: Boolean(deps.runStore),
676
- approvals: Boolean(deps.checkpointStore),
677
- sessions: Boolean(deps.sessionAudit),
678
- sessionEvents: Boolean(deps.sessionWatch && deps.sessionStorage?.getLeafId && deps.sessionStorage?.ownerOf),
679
- fleet: deps.fleetBus
680
- ? { stream: true, sessionScope: true, observe: true, resume: "snapshot", bgNotifyFailClosed: true, steerPriority: "advisory" }
681
- : false,
682
- workspace: (() => {
683
- const wfs = deps.fileSnapshotStore;
684
- return wfs && typeof wfs.listKeys === "function" && typeof wfs.exportManifest === "function" && typeof wfs.getBlob === "function" && Boolean(deps.sessionStorage?.ownerOf)
685
- ? { browse: true, archive: true, maxFileBytes: deps.config.workspaceFileMaxBytes ?? 8 * 1024 * 1024 }
686
- : false;
687
- })(),
688
- leader: Boolean(deps.leaderEndpoint),
689
- memory: false,
690
- memoryWrite: false,
691
- policy: true,
692
- usage: Boolean(deps.costQuota),
693
- permissionModeWrite: false,
694
- modelSelection: true,
695
- effortSelection: true,
696
- toolOutput: true,
697
- messageIdentity: true,
698
- forwardSubagentEvents: true,
699
- scratchpadDir: true,
700
- subagentSteer: Boolean(deps.subagentSteerRegistry) && Boolean(deps.runStore),
701
- subagentResume: Boolean(deps.subagentSteerRegistry) && Boolean(deps.runStore),
702
- taskSettings: { permissions: true, permissionMode: true, model: true, outputStyle: true, env: false, hooks: deps.config.requirePrincipal !== true },
703
- appendSystemPrompt: true,
704
- compactionModel: true,
705
- subagentOutput: Boolean(deps.subagentTaskOutput) && Boolean(deps.runStore),
706
- subagentStream: Boolean(deps.subagentTaskOutput) && Boolean(deps.runStore),
707
- taskHandles: Boolean(deps.taskHandleOutput) && Boolean(deps.taskHandleStop) && Boolean(deps.runStore),
708
- taskAgents: deps.config.requirePrincipal !== true,
709
- retainBackgroundProcesses: deps.config.requirePrincipal !== true,
710
- interactiveTools: true,
711
- projectContext: cwdHonored(deps.config),
712
- mcpInjection: mcpInjectionHonored(deps.config),
713
- modelUsage: Boolean(deps.runStore && deps.modelUsage),
714
- sessionInit: true,
715
- mcp: true,
716
- mcpElicitation: Boolean(deps.elicitation),
717
- askUserQuestion: Boolean(deps.question),
718
- toolApproval: Boolean(deps.toolApproval),
719
- sideQuery: true,
720
- sessionList: Boolean(deps.sessionStorage?.listSessions ?? deps.runStore?.listSessions),
721
- sessionSearch: Boolean(deps.sessionStorage?.listSessions ?? deps.runStore?.listSessions),
722
- sessionFork: Boolean(deps.sessionStorage?.fork),
723
- sessionDelete: Boolean(deps.purgeSession),
724
- resumeAt: Boolean(deps.resumeAnchorStore && deps.sessionStorage?.getLeafId),
725
- promptSuggestions: true,
726
- sessionPolicy: Boolean(deps.sessionPolicyStore),
727
- rewindFiles: Boolean(deps.fileSnapshotStore),
728
- rewindFilesTo: Boolean(deps.fileSnapshotStore && deps.resumeAnchorStore && deps.sessionStorage?.getLeafId),
729
- scheduler: Boolean(deps.config.schedulerEnabled && deps.config.requirePrincipal !== true && deps.config.remoteExec?.provider === "host"),
730
- sessionSync: Boolean(deps.backend && deps.sessionStorage?.exportEntries && deps.fileSnapshotStore),
731
- sendUserFile: Boolean(deps.sendUserFile && deps.config.sendUserFile?.publicEndpoint),
732
- s3PublicEndpoint: deps.config.sendUserFile?.publicEndpoint ?? null,
733
- sendUserFileLedger: Boolean(deps.sendFileLedger),
734
- workflows: deps.workflowsCapable ?? Boolean(deps.workflowRunStore),
735
- workflowsList: Boolean(deps.workflowRunStore),
736
- manualCompact: Boolean(deps.runStore),
737
- });
738
- return;
739
- }
740
- if (req.method === "GET" && url === "/v1/models") {
741
- const models = Object.entries(deps.config.models)
742
- .filter(([name]) => name !== "default")
743
- .map(([name, m]) => ({
744
- name,
745
- id: m.id,
746
- provider: m.provider,
747
- reasoning: m.reasoning,
748
- vision: Array.isArray(m.input) ? m.input.includes("image") : false,
749
- ...(m.contextWindow ? { contextWindow: m.contextWindow } : {}),
750
- ...(m.maxTokens ? { maxOutputTokens: m.maxTokens } : {}),
751
- ...(m.reasoning ? { supportedEffortLevels: [...DEFAULT_EFFORT_LEVELS] } : {}),
752
- }));
753
- const nonAlias = Object.entries(deps.config.models).filter(([n]) => n !== "default");
754
- const defaultName = (nonAlias.find(([, m]) => m === deps.config.model) ?? nonAlias.find(([, m]) => m.id === deps.config.model.id))?.[0];
755
- sendJson(res, 200, { models, default: deps.config.model.id, defaultModel: { id: deps.config.model.id, ...(defaultName ? { name: defaultName } : {}) } });
606
+ if (await handleCapabilities(req, res, url, ctx))
756
607
  return;
757
- }
758
608
  if (req.method === "POST" && url === "/v1/side-query") {
759
609
  const ac = new AbortController();
760
610
  const onClose = () => ac.abort();
@@ -870,9 +720,9 @@ export function createHttpServer(rawDeps) {
870
720
  sendJson(res, 400, { error: "x-detach-on-disconnect requires a durable run (a run store + sessionId) — without one the detached result would be unqueryable" });
871
721
  return;
872
722
  }
873
- streamTaskId = earlyDurableTid;
723
+ reqState.streamTaskId = earlyDurableTid;
874
724
  let usageKey;
875
- streamDetached = detachOnDisconnect;
725
+ reqState.streamDetached = detachOnDisconnect;
876
726
  sseHeaders(res, earlyDurableTid ? { "x-task-id": earlyDurableTid } : undefined);
877
727
  if (earlyDurableTid) {
878
728
  res.write(`event: meta\ndata: ${JSON.stringify({ type: "meta", taskId: earlyDurableTid, ...(prepared.spec.sessionId ? { sessionId: prepared.spec.sessionId } : {}) })}\n\n`);
@@ -907,7 +757,7 @@ export function createHttpServer(rawDeps) {
907
757
  }, 15_000);
908
758
  let ranLive = false;
909
759
  let resp;
910
- uncountedBillableInflight++;
760
+ counters.uncountedBillableInflight++;
911
761
  try {
912
762
  resp = await idemCache.run(idemKey, async () => {
913
763
  ranLive = true;
@@ -1329,7 +1179,7 @@ export function createHttpServer(rawDeps) {
1329
1179
  throw e;
1330
1180
  }
1331
1181
  finally {
1332
- uncountedBillableInflight--;
1182
+ counters.uncountedBillableInflight--;
1333
1183
  clearInterval(hb);
1334
1184
  if (usageKey)
1335
1185
  deps.modelUsage?.clear(usageKey);
@@ -1383,7 +1233,7 @@ export function createHttpServer(rawDeps) {
1383
1233
  const specWithSignal = { ...prepared.spec, signal: syncCancelCtrl.signal };
1384
1234
  let result;
1385
1235
  let cancelVerbLabel = false;
1386
- uncountedBillableInflight++;
1236
+ counters.uncountedBillableInflight++;
1387
1237
  try {
1388
1238
  result = await withPrincipal(principal, () => prepared.verify
1389
1239
  ? runWithVerification(deps.runner, specWithSignal, prepared.verify)
@@ -1392,7 +1242,7 @@ export function createHttpServer(rawDeps) {
1392
1242
  : deps.runner.runTask(specWithSignal));
1393
1243
  }
1394
1244
  finally {
1395
- uncountedBillableInflight--;
1245
+ counters.uncountedBillableInflight--;
1396
1246
  if (durableHeartbeat)
1397
1247
  clearInterval(durableHeartbeat);
1398
1248
  if (durableTaskId && inflightRuns.get(durableTaskId) === syncCancelCtrl) {
@@ -2433,323 +2283,8 @@ export function createHttpServer(rawDeps) {
2433
2283
  }
2434
2284
  }
2435
2285
  }
2436
- if (deps.imageBakes && url.startsWith("/v1/images/bakes")) {
2437
- const bakes = deps.imageBakes;
2438
- const idx = deps.imageIndex;
2439
- const principal = gatedPrincipal(req, deps.config);
2440
- const operators = deps.config.operatorPrincipals;
2441
- const explicitOperator = explicitOperatorOk(principal, operators);
2442
- const cfg = deps.config.imageBakes;
2443
- const isRunner = source !== undefined && source !== null && source === cfg.runnerPrincipal;
2444
- if (req.method === "POST" && url === "/v1/images/bakes") {
2445
- if (!explicitOperator) {
2446
- sendJson(res, 403, { error: "operator only" });
2447
- return;
2448
- }
2449
- const rl = bakeSubmitLimiter.check(`bake:${principal}`);
2450
- if (!rl.allowed) {
2451
- res.setHeader("retry-after", String(rl.retryAfterSec));
2452
- sendJson(res, 429, { error: "bake submission rate exceeded", retryAfterSec: rl.retryAfterSec });
2453
- return;
2454
- }
2455
- const body = (await readJson(req));
2456
- const v = validateBake(body);
2457
- if (!v.ok) {
2458
- sendJson(res, v.status, { error: v.error, ...(v.conflictBands ? { errorCode: "band-conflict", conflictBands: v.conflictBands } : {}) });
2459
- return;
2460
- }
2461
- let effectiveBaseRef;
2462
- if (v.baseRef) {
2463
- const baseEntry = idx ? await idx.getByDigest(v.baseRef) : null;
2464
- if (!baseEntry || baseEntry.status !== "published" || baseEntry.profile !== SANDBOX_BASE_PROFILE) {
2465
- sendJson(res, 400, { error: "baseRef is not a known published sandbox-base digest" });
2466
- return;
2467
- }
2468
- effectiveBaseRef = v.baseRef;
2469
- }
2470
- else {
2471
- effectiveBaseRef = cfg.defaultBaseRef ?? (idx ? (await idx.latestPublished(SANDBOX_BASE_PROFILE, { operator: true }))?.digest ?? null : null);
2472
- }
2473
- if (!v.dryRun && !effectiveBaseRef) {
2474
- sendJson(res, 503, { error: "no sandbox-base available to build from (set SANDBOX_BASE_REF or publish a sandbox-base image)" });
2475
- return;
2476
- }
2477
- const argv = buildBakeArgv(v, { baseRef: effectiveBaseRef, cacheBase: cfg.cacheBase });
2478
- const rawIdem = headerStr(req.headers["idempotency-key"]) ?? (typeof body.idempotencyKey === "string" ? body.idempotencyKey : undefined);
2479
- const idemKey = rawIdem ? scopedIdempotencyKey(rawIdem, source, principal) ?? null : null;
2480
- const createInput = {
2481
- profile: v.profile,
2482
- bands: v.bands,
2483
- baseRef: effectiveBaseRef,
2484
- push: v.push,
2485
- dryRun: v.dryRun,
2486
- logs: v.logs,
2487
- argv,
2488
- idemKey,
2489
- requestedBy: principal ?? null,
2490
- };
2491
- if (!v.dryRun) {
2492
- const sameArgv = await bakes.findActiveByArgv(argv);
2493
- if (sameArgv) {
2494
- sendJson(res, 202, { bakeId: sameArgv.bakeId, eventsUrl: `/v1/images/bakes/${sameArgv.bakeId}/events`, status: sameArgv.status, state: sameArgv.state });
2495
- return;
2496
- }
2497
- const r = await bakes.createBakeIfIdle(createInput);
2498
- if (!r.created) {
2499
- const sameNow = await bakes.findActiveByArgv(argv);
2500
- if (sameNow) {
2501
- sendJson(res, 202, { bakeId: sameNow.bakeId, eventsUrl: `/v1/images/bakes/${sameNow.bakeId}/events`, status: sameNow.status, state: sameNow.state });
2502
- return;
2503
- }
2504
- const busy = await bakes.findActiveAny();
2505
- sendJson(res, 409, { error: "a bake is already in progress", ...(busy ? { activeBakeId: busy.bakeId, eventsUrl: `/v1/images/bakes/${busy.bakeId}/events` } : {}) });
2506
- return;
2507
- }
2508
- sendJson(res, 202, { bakeId: r.bake.bakeId, eventsUrl: `/v1/images/bakes/${r.bake.bakeId}/events`, status: r.bake.status, state: r.bake.state });
2509
- return;
2510
- }
2511
- const { bake } = await bakes.createBake(createInput);
2512
- sendJson(res, 202, { bakeId: bake.bakeId, eventsUrl: `/v1/images/bakes/${bake.bakeId}/events`, status: bake.status, state: bake.state });
2513
- return;
2514
- }
2515
- const evMatch = req.method === "GET" ? BAKE_EVENTS_RE.exec(url) : null;
2516
- if (evMatch) {
2517
- if (!explicitOperator) {
2518
- sendJson(res, 403, { error: "operator only" });
2519
- return;
2520
- }
2521
- const bakeId = decodeURIComponent(evMatch[1]);
2522
- const bake = await bakes.getBake(bakeId);
2523
- if (!bake) {
2524
- sendJson(res, 404, { error: "bake not found" });
2525
- return;
2526
- }
2527
- await streamBakeEvents(req, res, bakes, bakeId, cfg.staleMs);
2528
- return;
2529
- }
2530
- const cancelMatch = req.method === "POST" ? BAKE_CANCEL_RE.exec(url) : null;
2531
- if (cancelMatch) {
2532
- if (!explicitOperator) {
2533
- sendJson(res, 403, { error: "operator only" });
2534
- return;
2535
- }
2536
- const bakeId = decodeURIComponent(cancelMatch[1]);
2537
- const bake = await bakes.getBake(bakeId);
2538
- if (!bake) {
2539
- sendJson(res, 404, { error: "bake not found" });
2540
- return;
2541
- }
2542
- const flagged = await bakes.requestCancel(bakeId);
2543
- sendJson(res, 202, { bakeId, cancelRequested: true, note: flagged ? "cancel requested — the runner kills the build at the next heartbeat" : "bake already terminal — cancel is a no-op" });
2544
- return;
2545
- }
2546
- if (req.method === "POST" && url === "/v1/images/bakes/claim") {
2547
- if (!isRunner) {
2548
- sendJson(res, 403, { error: "bake-runner only" });
2549
- return;
2550
- }
2551
- const body = (await readJson(req));
2552
- const runnerId = typeof body.runnerId === "string" && body.runnerId.length > 0 ? body.runnerId : (principal ?? source ?? cfg.runnerPrincipal);
2553
- let claimed = null;
2554
- for (let attempt = 0; attempt < 4 && !claimed; attempt++) {
2555
- const nextId = await bakes.findNextQueuedId();
2556
- if (!nextId)
2557
- break;
2558
- claimed = await bakes.claimBake(nextId, runnerId, cfg.leaseMs);
2559
- }
2560
- if (!claimed) {
2561
- res.writeHead(204).end();
2562
- return;
2563
- }
2564
- sendJson(res, 200, claimResponse(claimed));
2565
- return;
2566
- }
2567
- const claimMatch = req.method === "POST" ? BAKE_CLAIM_RE.exec(url) : null;
2568
- if (claimMatch) {
2569
- if (!isRunner) {
2570
- sendJson(res, 403, { error: "bake-runner only" });
2571
- return;
2572
- }
2573
- const bakeId = decodeURIComponent(claimMatch[1]);
2574
- const body = (await readJson(req));
2575
- const runnerId = typeof body.runnerId === "string" && body.runnerId.length > 0 ? body.runnerId : (principal ?? source ?? cfg.runnerPrincipal);
2576
- const claimed = await bakes.claimBake(bakeId, runnerId, cfg.leaseMs);
2577
- if (!claimed) {
2578
- sendJson(res, 409, { error: "bake not claimable (already claimed or the build lease is held)" });
2579
- return;
2580
- }
2581
- sendJson(res, 200, claimResponse(claimed));
2582
- return;
2583
- }
2584
- const ingestMatch = req.method === "POST" ? BAKE_INGEST_RE.exec(url) : null;
2585
- if (ingestMatch) {
2586
- if (!isRunner) {
2587
- sendJson(res, 403, { error: "bake-runner only" });
2588
- return;
2589
- }
2590
- const bakeId = decodeURIComponent(ingestMatch[1]);
2591
- const bake = await bakes.getBake(bakeId);
2592
- if (!bake) {
2593
- sendJson(res, 404, { error: "bake not found" });
2594
- return;
2595
- }
2596
- const presented = headerStr(req.headers["x-bake-ingest-secret"]);
2597
- if (!bake.ingestSecret || !presented || !safeEqual(presented, bake.ingestSecret)) {
2598
- sendJson(res, 403, { error: "invalid ingest secret" });
2599
- return;
2600
- }
2601
- const line = (await readJson(req));
2602
- if (line.event === "heartbeat") {
2603
- const fresh = await bakes.getBake(bakeId);
2604
- if (!fresh || fresh.status !== "running") {
2605
- sendJson(res, 409, { accepted: false, leaseValid: false, cancelRequested: fresh?.cancelRequested ?? false });
2606
- return;
2607
- }
2608
- const ok = bake.runnerId ? await bakes.heartbeatLease(bakeId, bake.runnerId, cfg.leaseMs) : false;
2609
- if (!ok) {
2610
- sendJson(res, 409, { accepted: false, leaseValid: false, cancelRequested: fresh.cancelRequested });
2611
- return;
2612
- }
2613
- sendJson(res, 200, { accepted: true, leaseValid: true, cancelRequested: fresh.cancelRequested });
2614
- return;
2615
- }
2616
- const fresh = await bakes.getBake(bakeId);
2617
- if (!fresh || fresh.status !== "running") {
2618
- sendJson(res, 409, { accepted: false, leaseValid: false, cancelRequested: fresh?.cancelRequested ?? false });
2619
- return;
2620
- }
2621
- if (fresh.runnerId) {
2622
- await bakes.heartbeatLease(bakeId, fresh.runnerId, cfg.leaseMs).catch(() => undefined);
2623
- }
2624
- const result = await ingestBakeLine(bakes, idx, fresh, line, cfg.registry);
2625
- const after = await bakes.getBake(bakeId);
2626
- sendJson(res, result.status, { ...result.body, cancelRequested: after?.cancelRequested ?? false, leaseValid: true });
2627
- return;
2628
- }
2629
- const idMatch = req.method === "GET" ? BAKE_ID_RE.exec(url) : null;
2630
- if (idMatch) {
2631
- if (!explicitOperator) {
2632
- sendJson(res, 403, { error: "operator only" });
2633
- return;
2634
- }
2635
- const bakeId = decodeURIComponent(idMatch[1]);
2636
- const bake = await bakes.getBake(bakeId);
2637
- if (!bake) {
2638
- sendJson(res, 404, { error: "bake not found" });
2639
- return;
2640
- }
2641
- sendJson(res, 200, { bake: bakeView(bake) });
2642
- return;
2643
- }
2644
- sendJson(res, 404, { error: "not found" });
2286
+ if (await handleImages(req, res, url, ctx))
2645
2287
  return;
2646
- }
2647
- if (deps.imageIndex && url.startsWith("/v1/images")) {
2648
- const idx = deps.imageIndex;
2649
- const principal = gatedPrincipal(req, deps.config);
2650
- const operator = explicitOperator(principal, deps.config.operatorPrincipals);
2651
- const viewer = { operator, tenantId: principal ?? null };
2652
- const q = new URL(req.url ?? "", "http://x").searchParams;
2653
- if (req.method === "GET" && url === "/v1/images") {
2654
- const limRaw = q.get("limit");
2655
- const { entries, nextCursor } = await idx.list({
2656
- ...viewer,
2657
- profile: q.get("profile") ?? undefined,
2658
- status: q.get("status") ?? undefined,
2659
- capability: q.get("capability") ?? undefined,
2660
- latestOnly: q.get("latest") === "true",
2661
- limit: limRaw ? Number(limRaw) : undefined,
2662
- cursor: q.get("cursor") ?? undefined,
2663
- });
2664
- sendJson(res, 200, { images: entries, nextCursor });
2665
- return;
2666
- }
2667
- if (req.method === "POST" && url === "/v1/images/select") {
2668
- const body = (await readJson(req));
2669
- const profile = typeof body.profile === "string" ? body.profile : "";
2670
- if (!profile) {
2671
- sendJson(res, 400, { error: "profile is required" });
2672
- return;
2673
- }
2674
- const entry = await idx.latestPublished(profile, viewer);
2675
- if (!entry) {
2676
- sendJson(res, 404, { error: `no published image for profile '${profile}' visible to this principal` });
2677
- return;
2678
- }
2679
- const needed = Array.isArray(body.capabilitiesNeeded)
2680
- ? body.capabilitiesNeeded.filter((c) => typeof c === "string")
2681
- : [];
2682
- const caps = entry.capabilities;
2683
- const missing = needed.filter((c) => !caps[c]);
2684
- if (missing.length > 0) {
2685
- sendJson(res, 409, { error: "capabilities not satisfied by the image", missing });
2686
- return;
2687
- }
2688
- sendJson(res, 200, {
2689
- profile: entry.profile,
2690
- digest: entry.digest,
2691
- repo: entry.repo,
2692
- ref: `${entry.repo}@${entry.digest}`,
2693
- podContract: entry.podContract,
2694
- capabilities: entry.capabilities,
2695
- manifestSha: entry.manifestSha,
2696
- });
2697
- return;
2698
- }
2699
- if (req.method === "POST" && url === "/v1/images/register") {
2700
- if (!explicitOperatorOk(principal, deps.config.operatorPrincipals)) {
2701
- sendJson(res, 403, { error: "operator only" });
2702
- return;
2703
- }
2704
- const b = (await readJson(req));
2705
- if (typeof b.profile !== "string" || typeof b.repo !== "string" || typeof b.digest !== "string") {
2706
- sendJson(res, 400, { error: "profile, repo, digest are required" });
2707
- return;
2708
- }
2709
- const id = await idx.registerBuilding({
2710
- profile: b.profile,
2711
- bands: Array.isArray(b.bands) ? b.bands : [],
2712
- repo: b.repo,
2713
- tag: typeof b.tag === "string" ? b.tag : "",
2714
- digest: b.digest,
2715
- toolchainVersions: b.toolchainVersions ?? {},
2716
- capabilities: b.capabilities ?? {},
2717
- podContract: b.podContract ?? {},
2718
- sizeBytes: typeof b.sizeBytes === "number" ? b.sizeBytes : null,
2719
- status: (typeof b.status === "string" ? b.status : "building"),
2720
- visibility: b.visibility === "tenant" ? "tenant" : "public",
2721
- tenantId: typeof b.tenantId === "string" ? b.tenantId : null,
2722
- manifestSha: typeof b.manifestSha === "string" ? b.manifestSha : null,
2723
- recipeGitSha: typeof b.recipeGitSha === "string" ? b.recipeGitSha : null,
2724
- generatorVersion: typeof b.generatorVersion === "string" ? b.generatorVersion : null,
2725
- buildDate: typeof b.buildDate === "string" ? b.buildDate : null,
2726
- supersedes: typeof b.supersedes === "string" ? b.supersedes : null,
2727
- signed: b.signed === true,
2728
- });
2729
- sendJson(res, 200, { id });
2730
- return;
2731
- }
2732
- const digMatch = req.method === "GET" ? IMAGE_DIGEST_RE.exec(url) : null;
2733
- if (digMatch) {
2734
- const entry = await idx.getByDigest(decodeURIComponent(digMatch[1]));
2735
- if (!entry || (!viewer.operator && entry.visibility === "tenant" && entry.tenantId !== viewer.tenantId)) {
2736
- sendJson(res, 404, { error: "not found" });
2737
- return;
2738
- }
2739
- sendJson(res, 200, { image: entry });
2740
- return;
2741
- }
2742
- const profMatch = req.method === "GET" ? IMAGE_PROFILE_RE.exec(url) : null;
2743
- if (profMatch) {
2744
- const entry = await idx.latestPublished(decodeURIComponent(profMatch[1]), viewer);
2745
- if (!entry) {
2746
- sendJson(res, 404, { error: "not found" });
2747
- return;
2748
- }
2749
- sendJson(res, 200, { image: entry });
2750
- return;
2751
- }
2752
- }
2753
2288
  if (deps.checkpointStore && (url.startsWith("/v1/approvals") || url.startsWith("/v1/assistant"))) {
2754
2289
  const cs = deps.checkpointStore;
2755
2290
  const principal = gatedPrincipal(req, deps.config);
@@ -3190,1597 +2725,116 @@ export function createHttpServer(rawDeps) {
3190
2725
  sendJson(res, 404, { error: "not found" });
3191
2726
  return;
3192
2727
  }
3193
- if (url === "/v1/outcomes" && req.method === "GET") {
3194
- if (deps.config.requirePrincipal) {
3195
- const principal = gatedPrincipal(req, deps.config);
3196
- if (!explicitOperatorOk(principal, deps.config.operatorPrincipals)) {
3197
- sendJson(res, 403, { error: "outcomes view is operator-only on a multi-tenant worker" });
3198
- return;
3199
- }
3200
- }
3201
- if (!deps.outcomeSink?.summary) {
3202
- sendJson(res, 501, { error: "no queryable outcome ledger on this worker (File sink — read the JSONL dataset directly)" });
3203
- return;
3204
- }
3205
- const q = new URL(req.url ?? "/", "http://localhost").searchParams;
3206
- const sig = q.get("signature") ?? undefined;
3207
- try {
3208
- const rows = await deps.outcomeSink.summary({ ...(sig ? { taskSignature: sig } : {}), includeAborted: q.get("includeAborted") === "true" });
3209
- sendJson(res, 200, { outcomes: rows });
3210
- }
3211
- catch (err) {
3212
- deps.logger?.warn?.("outcomes_summary_failed", { err: String(err) });
3213
- sendJson(res, 500, { error: "outcome summary failed" });
3214
- }
2728
+ if (await handleObservability(req, res, url, ctx))
3215
2729
  return;
3216
- }
3217
- if (url === "/v1/usage" && req.method === "GET") {
2730
+ if (await handleMemoryPolicy(req, res, url, ctx))
2731
+ return;
2732
+ if (await handleSessionsList(req, res, url, ctx))
2733
+ return;
2734
+ if (await handleSessions(req, res, url, ctx))
2735
+ return;
2736
+ if (await handleSessionSync(req, res, url, ctx))
2737
+ return;
2738
+ if (await handleAttachments(req, res, url, ctx))
2739
+ return;
2740
+ if (req.method === "POST" && /^\/v1\/sessions\/[^/]+\/notify$/.test(url)) {
2741
+ if (rateLimited(req, res) || quotaExceeded(req, res))
2742
+ return;
3218
2743
  const principal = gatedPrincipal(req, deps.config);
3219
- if (!principal) {
2744
+ if (deps.config.requirePrincipal && principal === undefined) {
3220
2745
  sendJson(res, 401, { error: `missing principal header '${deps.config.principalHeader}'` });
3221
2746
  return;
3222
2747
  }
3223
- const windowSec = deps.config.costQuotaWindowSec;
3224
- if (!deps.costQuota) {
3225
- sendJson(res, 200, { enabled: false, windowSec, maxTaskCostUsd: deps.config.maxTaskCostUsd, maxTaskTokens: deps.config.maxTaskTokens });
3226
- return;
2748
+ const notifySession = decodeURIComponent(url.split("/")[3]);
2749
+ let nb;
2750
+ try {
2751
+ nb = (await readJson(req));
3227
2752
  }
3228
- const d = deps.costQuota.check(principal);
3229
- sendJson(res, 200, {
3230
- enabled: true,
3231
- windowSec,
3232
- usedMicroUsd: d.usedMicroUsd,
3233
- usedUsd: d.usedMicroUsd / 1_000_000,
3234
- limitMicroUsd: d.limitMicroUsd,
3235
- limitUsd: d.limitMicroUsd / 1_000_000,
3236
- remainingMicroUsd: Math.max(0, d.limitMicroUsd - d.usedMicroUsd),
3237
- overLimit: !d.allowed,
3238
- retryAfterSec: d.retryAfterSec,
3239
- maxTaskCostUsd: deps.config.maxTaskCostUsd,
3240
- maxTaskTokens: deps.config.maxTaskTokens,
3241
- });
3242
- return;
3243
- }
3244
- if (url === "/v1/memory/export" && req.method === "GET") {
3245
- const principal = gatedPrincipal(req, deps.config);
3246
- if (!principal) {
3247
- sendJson(res, 401, { error: "unauthorized" });
2753
+ catch {
2754
+ sendJson(res, 400, { error: "body must be JSON: { task_id, status, summary, result?, seq?, source? }" });
3248
2755
  return;
3249
2756
  }
3250
- if (deps.config.memoryEngineBackend === "file" || !deps.memoryExport) {
3251
- sendJson(res, 501, { error: "memory export requires a DB memory backend (MEMORY_ENGINE_BACKEND=pg|tidb)" });
2757
+ if (typeof nb.task_id !== "string" || nb.task_id.trim().length === 0 || nb.task_id.length > 190) {
2758
+ sendJson(res, 400, { error: "task_id must be a non-empty string of at most 190 characters" });
3252
2759
  return;
3253
2760
  }
3254
- const scope = new URL(req.url ?? "", "http://x").searchParams.get("scope");
3255
- if (!scope) {
3256
- sendJson(res, 400, { error: "missing ?scope=<key>" });
2761
+ const NOTIFY_STATUSES = ["completed", "failed", "killed", "cancelled", "event"];
2762
+ if (typeof nb.status !== "string" || !NOTIFY_STATUSES.includes(nb.status)) {
2763
+ sendJson(res, 400, { error: `status must be one of ${NOTIFY_STATUSES.join("/")}` });
3257
2764
  return;
3258
2765
  }
3259
- if (scope !== formatUserScope(principal) && !explicitOperatorOk(principal, deps.config.operatorPrincipals)) {
3260
- sendJson(res, 404, { error: "not found" });
2766
+ if (typeof nb.summary !== "string" || nb.summary.trim().length === 0) {
2767
+ sendJson(res, 400, { error: "summary must be a non-empty string" });
3261
2768
  return;
3262
2769
  }
3263
- try {
3264
- const entries = await deps.memoryExport(scope);
3265
- sendJson(res, 200, { scope, exportedAt: new Date().toISOString(), entries });
3266
- }
3267
- catch (err) {
3268
- deps.logger?.warn?.("memory_export_failed", { scope, err: String(err) });
3269
- sendJson(res, 500, { error: "memory export failed" });
3270
- }
3271
- return;
3272
- }
3273
- if (req.method === "POST" && url.startsWith("/v1/memory/sync/")) {
3274
- const principal = gatedPrincipal(req, deps.config);
3275
- if (!principal) {
3276
- sendJson(res, 401, { error: "unauthorized" });
2770
+ if (nb.result !== undefined && typeof nb.result !== "string") {
2771
+ sendJson(res, 400, { error: "result must be a string when present" });
3277
2772
  return;
3278
2773
  }
3279
- if (deps.config.memoryEngineBackend === "file" || !deps.memorySync) {
3280
- sendJson(res, 501, { error: "memory sync requires a DB memory backend (MEMORY_ENGINE_BACKEND=pg|tidb)" });
2774
+ if (nb.seq !== undefined && (typeof nb.seq !== "number" || !Number.isInteger(nb.seq) || nb.seq < 1)) {
2775
+ sendJson(res, 400, { error: "seq must be a positive integer when present" });
3281
2776
  return;
3282
2777
  }
3283
- let scope;
3284
- try {
3285
- scope = decodeURIComponent(url.slice("/v1/memory/sync/".length));
3286
- }
3287
- catch {
3288
- sendError(res, 400, "memory_sync_invalid_scope", "malformed percent-encoding in :scope", { code: "memory_sync_invalid_scope" });
2778
+ if (nb.source !== undefined && (typeof nb.source !== "string" || nb.source.length > 190)) {
2779
+ sendJson(res, 400, { error: "source must be a string of at most 190 characters when present" });
3289
2780
  return;
3290
2781
  }
3291
- if (scope.length === 0) {
3292
- sendError(res, 400, "memory_sync_invalid_scope", "missing :scope", { code: "memory_sync_invalid_scope" });
2782
+ if (!deps.sessionStorage?.ownerOf) {
2783
+ sendJson(res, 501, { error: "external notify requires the session-ownership face (sessionStorage.ownerOf)" });
3293
2784
  return;
3294
2785
  }
3295
- if (scope !== formatUserScope(principal) && !explicitOperatorOk(principal, deps.config.operatorPrincipals)) {
3296
- sendJson(res, 404, { error: "not found" });
2786
+ const notifyOwner = await deps.sessionStorage.ownerOf(notifySession);
2787
+ if (notifyOwner === undefined) {
2788
+ sendJson(res, 404, { error: "session not found" });
3297
2789
  return;
3298
2790
  }
3299
- const body = await readJson(req);
3300
- const parsed = parseMemorySyncRequest(body, scope);
3301
- if (!parsed.ok) {
3302
- sendError(res, 422, "memory_sync_invalid_body", parsed.error, { code: "memory_sync_invalid_body" });
2791
+ const notifyOperator = explicitOperatorOk(principal, deps.config.operatorPrincipals);
2792
+ if (!notifyOperator && notifyOwner !== null && notifyOwner !== principal) {
2793
+ sendJson(res, 404, { error: "session not found" });
3303
2794
  return;
3304
2795
  }
3305
- try {
3306
- const out = await deps.memorySync(scope, parsed.value);
3307
- sendJson(res, 200, out);
3308
- }
3309
- catch (err) {
3310
- deps.logger?.warn?.("memory_sync_failed", { scope, peer: parsed.value.peer, err: String(err) });
3311
- sendJson(res, 500, { error: "memory sync failed" });
2796
+ const payload = {
2797
+ task_id: nb.task_id,
2798
+ status: nb.status,
2799
+ summary: nb.summary,
2800
+ ...(nb.result !== undefined ? { result: nb.result } : {}),
2801
+ ...(nb.seq !== undefined ? { seq: nb.seq } : {}),
2802
+ ...(nb.source !== undefined ? { source: nb.source } : {}),
2803
+ };
2804
+ const liveTaskId = await deps.runStore?.getActiveTaskId?.(notifySession).catch(() => undefined);
2805
+ const liveStream = liveTaskId !== undefined && liveTaskId !== null ? steerableRuns.get(liveTaskId) : undefined;
2806
+ if (liveStream) {
2807
+ try {
2808
+ await liveStream.notify(payload);
2809
+ sendJson(res, 200, { sessionId: notifySession, delivery: "live" });
2810
+ return;
2811
+ }
2812
+ catch (e) {
2813
+ const code = e.code;
2814
+ if (typeof code === "string" && code.startsWith("notify.")) {
2815
+ sendError(res, 400, code, e instanceof Error ? e.message : "invalid notification");
2816
+ return;
2817
+ }
2818
+ }
3312
2819
  }
3313
- return;
3314
- }
3315
- if (url === "/v1/policy" && req.method === "GET") {
3316
- const principal = gatedPrincipal(req, deps.config);
3317
- if (deps.config.requirePrincipal && !principal) {
3318
- sendJson(res, 401, { error: `missing principal header '${deps.config.principalHeader}'` });
2820
+ if (!deps.workflowCompletionInbox) {
2821
+ sendJson(res, 501, { error: "external notify park requires the workflow completion inbox (WORKFLOW_RUN_STORE)" });
3319
2822
  return;
3320
2823
  }
3321
- sendJson(res, 200, {
3322
- autonomy: deps.config.autonomy ?? null,
3323
- commandPolicy: deps.config.commandPolicy ?? [],
3324
- approvalRequire: deps.config.approvalRequire,
3325
- limits: {
3326
- maxTaskCostUsd: deps.config.maxTaskCostUsd,
3327
- maxTaskTokens: deps.config.maxTaskTokens,
3328
- maxPrincipalCostUsd: deps.config.maxPrincipalCostUsd,
3329
- costQuotaWindowSec: deps.config.costQuotaWindowSec,
3330
- },
3331
- });
2824
+ await deps.workflowCompletionInbox.enqueue(taskNotificationInboxEntry(notifySession, notifyOwner ?? principal ?? null, { task_id: payload.task_id, task_type: "external", status: payload.status, summary: payload.summary, ...(payload.result !== undefined ? { result: payload.result } : {}), ...(payload.seq !== undefined ? { seq: payload.seq } : {}), ...(payload.source !== undefined ? { source: payload.source } : {}) }, Date.now()));
2825
+ sendJson(res, 202, { sessionId: notifySession, delivery: "parked", note: "no live stream — parked in the session inbox; drained as a task_notification on the next stream open" });
3332
2826
  return;
3333
2827
  }
3334
- if (req.method === "GET" && url === "/v1/sessions") {
3335
- const { fleetWide, gateOwner } = sessionOwnerScope(req);
3336
- if (!fleetWide && gateOwner === null) {
3337
- sendJson(res, 401, { error: "unauthorized" });
2828
+ if (req.method === "POST" && /^\/v1\/sessions\/[^/]+\/wake$/.test(url)) {
2829
+ if (!deps.checkpointStore) {
2830
+ sendJson(res, 501, { error: "wake requires the checkpoint store" });
3338
2831
  return;
3339
2832
  }
3340
- const lister = deps.sessionStorage?.listSessions?.bind(deps.sessionStorage) ?? deps.runStore?.listSessions?.bind(deps.runStore);
3341
- if (!lister) {
3342
- sendJson(res, 501, { error: "session list requires a durable session store (SESSION_BACKEND=tidb)" });
3343
- return;
3344
- }
3345
- await handleSessionList(res, lister, new URL(req.url ?? "", "http://x").searchParams, gateOwner);
3346
- return;
3347
- }
3348
- if (req.method === "GET" && url === "/v1/sendfile-links") {
3349
- const { fleetWide, gateOwner } = sessionOwnerScope(req);
3350
- if (!fleetWide && gateOwner === null) {
3351
- sendJson(res, 401, { error: "unauthorized" });
3352
- return;
3353
- }
3354
- if (!deps.sendFileLedger) {
3355
- sendJson(res, 501, { error: "sendfile ledger requires a store backend (DB_BACKEND=tidb|pg|local) and the SendUserFile issuer configured" });
3356
- return;
3357
- }
3358
- const query = new URL(req.url ?? "", "http://x").searchParams;
3359
- const limitRaw = query.get("limit");
3360
- if (limitRaw !== null && !(/^[0-9]+$/.test(limitRaw) && Number(limitRaw) >= 1 && Number(limitRaw) <= 100)) {
3361
- sendJson(res, 400, { error: "limit must be a decimal integer in 1..100" });
3362
- return;
3363
- }
3364
- const limit = limitRaw === null ? 50 : Number(limitRaw);
3365
- const beforeRaw = query.get("before");
3366
- if (beforeRaw !== null && !isUuidV7(beforeRaw)) {
3367
- sendJson(res, 400, { error: "before must be a uuidv7 link id (the `id`/`nextBefore` of a previous page)" });
3368
- return;
3369
- }
3370
- const before = beforeRaw ?? undefined;
3371
- let scope;
3372
- if (gateOwner !== null) {
3373
- scope = gateOwner;
3374
- }
3375
- else {
3376
- const scopeRaw = query.get("scope");
3377
- if (scopeRaw === null) {
3378
- scope = "_";
3379
- }
3380
- else {
3381
- const s = scopeRaw.trim();
3382
- if (s === "" || s.length > 190) {
3383
- sendJson(res, 400, { error: "scope must be a non-blank tenant principal of at most 190 characters (the sendfile_link.scope column width)" });
3384
- return;
3385
- }
3386
- scope = s;
3387
- }
3388
- }
3389
- const rows = await deps.sendFileLedger.listByScope(scope, limit + 1, before);
3390
- const hasMore = rows.length > limit;
3391
- const page = hasMore ? rows.slice(0, limit) : rows;
3392
- sendJson(res, 200, {
3393
- links: page.map((r) => ({
3394
- id: r.id,
3395
- filename: r.filename,
3396
- size: r.size,
3397
- ttlSec: r.ttlSec,
3398
- track: r.track,
3399
- bucket: r.bucket,
3400
- key: r.objectKey,
3401
- createdAt: new Date(r.createdAt).toISOString(),
3402
- ...(r.taskId !== undefined ? { taskId: r.taskId } : {}),
3403
- ...(r.sessionId !== undefined ? { sessionId: r.sessionId } : {}),
3404
- })),
3405
- ...(hasMore ? { nextBefore: page[page.length - 1].id } : {}),
3406
- });
3407
- return;
3408
- }
3409
- {
3410
- const im = /^\/v1\/sessions\/([^/]+)\/init$/.exec(url);
3411
- if (im && req.method === "GET") {
3412
- const principal = gatedPrincipal(req, deps.config);
3413
- if (deps.config.requirePrincipal && !principal) {
3414
- sendJson(res, 401, { error: `missing principal header '${deps.config.principalHeader}'` });
3415
- return;
3416
- }
3417
- sendJson(res, 200, {
3418
- sessionId: im[1],
3419
- model: { provider: deps.config.model.provider, modelId: deps.config.model.id },
3420
- ...(deps.config.autonomy ? { mode: deps.config.autonomy } : {}),
3421
- });
3422
- return;
3423
- }
3424
- }
3425
- {
3426
- const stm = /^\/v1\/sessions\/([^/]+)\/settings$/.exec(url);
3427
- if (stm && req.method === "GET") {
3428
- const principal = gatedPrincipal(req, deps.config);
3429
- if (deps.config.requirePrincipal && !principal) {
3430
- sendJson(res, 401, { error: `missing principal header '${deps.config.principalHeader}'` });
3431
- return;
3432
- }
3433
- sendJson(res, 200, {
3434
- effective: {
3435
- model: deps.config.model.id,
3436
- ...(deps.config.autonomy ? { mode: deps.config.autonomy } : {}),
3437
- approvalRequire: deps.config.approvalRequire,
3438
- commandPolicy: deps.config.commandPolicy ?? [],
3439
- limits: { maxTaskCostUsd: deps.config.maxTaskCostUsd, maxTaskTokens: deps.config.maxTaskTokens },
3440
- },
3441
- applied: { model: deps.config.model.id },
3442
- });
3443
- return;
3444
- }
3445
- }
3446
- {
3447
- const mm = /^\/v1\/sessions\/([^/]+)\/mcp$/.exec(url);
3448
- if (mm && req.method === "GET") {
3449
- if (deps.config.requirePrincipal && !gatedPrincipal(req, deps.config)) {
3450
- sendJson(res, 401, { error: `missing principal header '${deps.config.principalHeader}'` });
3451
- return;
3452
- }
3453
- const specs = mcpForScenario(deps.config.mcpServers, deps.config.defaultScenario);
3454
- if (!specs || specs.length === 0) {
3455
- sendJson(res, 200, { servers: [], asOf: new Date().toISOString() });
3456
- return;
3457
- }
3458
- const principal = gatedPrincipal(req, deps.config);
3459
- const cacheKey = `${deps.config.defaultScenario}::${principal ?? ""}`;
3460
- const cached = mcpStatusCache.get(cacheKey);
3461
- if (!cached || Date.now() - cached.at > MCP_STATUS_TTL_MS) {
3462
- if (mcpStatusCache.size > 1024)
3463
- mcpStatusCache.clear();
3464
- const panel = (async () => {
3465
- const matP = materializeMcpTools(specs, principal);
3466
- let timer;
3467
- const mat = await Promise.race([matP.catch(() => null), new Promise((r) => { timer = setTimeout(() => r(null), MCP_MATERIALIZE_TIMEOUT_MS); })]);
3468
- if (timer)
3469
- clearTimeout(timer);
3470
- if (!mat) {
3471
- void matP.then((m) => m?.dispose().catch(() => undefined)).catch(() => undefined);
3472
- return { servers: [], asOf: new Date().toISOString(), degraded: true };
3473
- }
3474
- try {
3475
- return {
3476
- asOf: new Date().toISOString(),
3477
- servers: mat.statuses.map((s) => ({
3478
- name: s.name,
3479
- status: s.status,
3480
- ...(s.serverInfo ? { serverInfo: s.serverInfo } : {}),
3481
- ...(s.toolNames ? { toolNames: s.toolNames } : {}),
3482
- ...(s.error ? { error: redactSecrets(String(s.error)).slice(0, 200) } : {}),
3483
- })),
3484
- };
3485
- }
3486
- finally {
3487
- await mat.dispose().catch(() => undefined);
3488
- }
3489
- })();
3490
- mcpStatusCache.set(cacheKey, { at: Date.now(), panel });
3491
- }
3492
- sendJson(res, 200, await mcpStatusCache.get(cacheKey).panel);
3493
- return;
3494
- }
3495
- }
3496
- {
3497
- const hm = req.method === "GET" ? /^\/v1\/sessions\/([^/]+)\/head$/.exec(url) : null;
3498
- if (hm) {
3499
- const getLeafId = deps.sessionStorage?.getLeafId?.bind(deps.sessionStorage);
3500
- const ownerOf = deps.sessionStorage?.ownerOf?.bind(deps.sessionStorage);
3501
- if (!getLeafId || !ownerOf) {
3502
- sendJson(res, 501, { error: "session head probe requires a durable session store (SESSION_BACKEND=tidb|pg|local)" });
3503
- return;
3504
- }
3505
- const headSession = decodeURIComponent(hm[1]);
3506
- const getHead = deps.sessionStorage?.getHead?.bind(deps.sessionStorage);
3507
- let owner;
3508
- let leafId;
3509
- if (getHead) {
3510
- const head = await getHead(headSession);
3511
- if (head === undefined) {
3512
- sendJson(res, 404, { error: "session not found" });
3513
- return;
3514
- }
3515
- owner = head.owner;
3516
- if (!runOwnerOk(req, res, owner ?? null))
3517
- return;
3518
- leafId = head.leafId;
3519
- }
3520
- else {
3521
- owner = await ownerOf(headSession);
3522
- if (owner === undefined) {
3523
- sendJson(res, 404, { error: "session not found" });
3524
- return;
3525
- }
3526
- if (!runOwnerOk(req, res, owner ?? null))
3527
- return;
3528
- leafId = await getLeafId(headSession);
3529
- }
3530
- const etag = `"${leafId ?? "empty"}"`;
3531
- if (req.headers["if-none-match"] === etag) {
3532
- res.writeHead(304, { etag }).end();
3533
- return;
3534
- }
3535
- res.setHeader("etag", etag);
3536
- sendJson(res, 200, { sessionId: headSession, leafId });
3537
- return;
3538
- }
3539
- }
3540
- {
3541
- const em = req.method === "GET" ? /^\/v1\/sessions\/([^/]+)\/events$/.exec(url) : null;
3542
- if (em) {
3543
- const ownerOf = deps.sessionStorage?.ownerOf?.bind(deps.sessionStorage);
3544
- const watch = deps.sessionWatch;
3545
- if (!deps.sessionStorage?.getLeafId || !ownerOf || !watch) {
3546
- sendJson(res, 501, { error: "session events require a durable session store (SESSION_BACKEND=tidb|pg|local)" });
3547
- return;
3548
- }
3549
- const evSession = decodeURIComponent(em[1]);
3550
- if (sseConnections >= (deps.sessionEventsMaxConnections ?? 256)) {
3551
- res.writeHead(503, { "retry-after": "5", "content-type": "application/json" });
3552
- res.end(JSON.stringify({ error: "session-events connection cap reached — fall back to /head polling" }));
3553
- return;
3554
- }
3555
- sseConnections++;
3556
- let hb;
3557
- let unsubscribe;
3558
- let closed = false;
3559
- const cleanup = () => {
3560
- if (closed)
3561
- return;
3562
- closed = true;
3563
- sseConnections--;
3564
- if (hb)
3565
- clearInterval(hb);
3566
- unsubscribe?.();
3567
- };
3568
- res.on("close", cleanup);
3569
- req.on("close", cleanup);
3570
- try {
3571
- const stallBound = deps.sessionEventsStallMs ?? 60_000;
3572
- if (sseOwnerLookups >= 64) {
3573
- cleanup();
3574
- res.writeHead(503, { "retry-after": "5", "content-type": "application/json" });
3575
- res.end(JSON.stringify({ error: "session-events owner-lookup concurrency cap — retry" }));
3576
- return;
3577
- }
3578
- sseOwnerLookups++;
3579
- const ownerP = ownerOf(evSession);
3580
- void ownerP.catch(() => undefined).then(() => { sseOwnerLookups--; });
3581
- let stallTimer;
3582
- let owner;
3583
- try {
3584
- owner = await Promise.race([
3585
- ownerP,
3586
- new Promise((r) => { stallTimer = setTimeout(() => r("__stall__"), stallBound); stallTimer.unref?.(); }),
3587
- ]);
3588
- }
3589
- finally {
3590
- if (stallTimer)
3591
- clearTimeout(stallTimer);
3592
- }
3593
- if (closed)
3594
- return;
3595
- if (owner === "__stall__") {
3596
- cleanup();
3597
- res.writeHead(503, { "retry-after": "5", "content-type": "application/json" });
3598
- res.end(JSON.stringify({ error: "session-events owner lookup stalled — retry" }));
3599
- return;
3600
- }
3601
- if (owner === undefined) {
3602
- cleanup();
3603
- sendJson(res, 404, { error: "session not found" });
3604
- return;
3605
- }
3606
- if (!runOwnerOk(req, res, owner ?? null)) {
3607
- cleanup();
3608
- return;
3609
- }
3610
- const q = new URL(req.url ?? url, "http://x").searchParams;
3611
- const lastLeafId = q.get("lastLeafId");
3612
- let first = true;
3613
- let lastAccepted;
3614
- let lastWritten;
3615
- let writeBlocked = false;
3616
- let blockedAt = 0;
3617
- let pendingHead;
3618
- const stallMs = deps.sessionEventsStallMs ?? 60_000;
3619
- const writeRaw = (chunk) => {
3620
- if (closed)
3621
- return;
3622
- try {
3623
- const okW = res.write(chunk);
3624
- if (!okW && !writeBlocked) {
3625
- writeBlocked = true;
3626
- blockedAt = Date.now();
3627
- res.once("drain", () => {
3628
- writeBlocked = false;
3629
- const p = pendingHead;
3630
- pendingHead = undefined;
3631
- if (p && !closed && p.v !== lastWritten)
3632
- doWrite(p.v);
3633
- });
3634
- }
3635
- }
3636
- catch { }
3637
- };
3638
- const doWrite = (leafId) => {
3639
- lastWritten = leafId;
3640
- writeRaw(`event: head\ndata: ${JSON.stringify({ sessionId: evSession, leafId })}\n\n`);
3641
- };
3642
- const emit = (leafId) => {
3643
- if (closed || leafId === lastAccepted)
3644
- return;
3645
- lastAccepted = leafId;
3646
- const suppress = first && lastLeafId !== null && lastLeafId === (leafId ?? "null");
3647
- first = false;
3648
- if (suppress) {
3649
- lastWritten = leafId;
3650
- return;
3651
- }
3652
- if (writeBlocked) {
3653
- pendingHead = { v: leafId };
3654
- if (Date.now() - blockedAt > stallMs) {
3655
- cleanup();
3656
- try {
3657
- res.end();
3658
- }
3659
- catch { }
3660
- }
3661
- return;
3662
- }
3663
- doWrite(leafId);
3664
- };
3665
- let started = false;
3666
- let preVal;
3667
- const onHead = (leafId) => {
3668
- if (!started)
3669
- preVal = { v: leafId };
3670
- else
3671
- emit(leafId);
3672
- };
3673
- const onEnd = () => { cleanup(); try {
3674
- res.end();
3675
- }
3676
- catch { } };
3677
- unsubscribe = watch.subscribe(evSession, owner ?? null, onHead, onEnd);
3678
- if (!unsubscribe) {
3679
- cleanup();
3680
- res.writeHead(503, { "retry-after": "5", "content-type": "application/json" });
3681
- res.end(JSON.stringify({ error: "session-events probe cap reached — fall back to /head polling" }));
3682
- return;
3683
- }
3684
- res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache, no-transform", connection: "keep-alive", "x-accel-buffering": "no" });
3685
- writeRaw(": connected\n\n");
3686
- started = true;
3687
- if (preVal !== undefined)
3688
- emit(preVal.v);
3689
- const hbMs = deps.sessionEventsHeartbeatMs ?? 25_000;
3690
- let ownerCheckPending = false;
3691
- let ownerCheckStartedAt = 0;
3692
- hb = setInterval(() => {
3693
- if (closed)
3694
- return;
3695
- if (writeBlocked && Date.now() - blockedAt > stallMs) {
3696
- onEnd();
3697
- return;
3698
- }
3699
- if (!writeBlocked)
3700
- writeRaw(": hb\n\n");
3701
- if (ownerCheckPending) {
3702
- if (Date.now() - ownerCheckStartedAt > stallMs)
3703
- onEnd();
3704
- return;
3705
- }
3706
- ownerCheckPending = true;
3707
- ownerCheckStartedAt = Date.now();
3708
- void ownerOf(evSession).then((o) => {
3709
- ownerCheckPending = false;
3710
- if (!closed && o !== owner)
3711
- onEnd();
3712
- }).catch(() => { ownerCheckPending = false; });
3713
- }, hbMs);
3714
- hb.unref?.();
3715
- }
3716
- catch (e) {
3717
- cleanup();
3718
- if (!res.headersSent)
3719
- sendJson(res, 500, { error: `session events failed: ${e instanceof Error ? e.message : "unknown"}` });
3720
- else
3721
- try {
3722
- res.end();
3723
- }
3724
- catch { }
3725
- }
3726
- return;
3727
- }
3728
- }
3729
- if (deps.sessionAudit && req.method === "GET") {
3730
- const sm = /^\/v1\/sessions\/([^/]+)$/.exec(url);
3731
- if (sm) {
3732
- const audit = await deps.sessionAudit(sm[1]);
3733
- if (!audit) {
3734
- sendJson(res, 404, { error: "session not found" });
3735
- return;
3736
- }
3737
- if (!runOwnerOk(req, res, audit.owner))
3738
- return;
3739
- const q = new URL(req.url ?? url, "http://x").searchParams;
3740
- const qi = (k) => {
3741
- const v = q.get(k);
3742
- if (v === null)
3743
- return undefined;
3744
- const n = Number(v);
3745
- return Number.isFinite(n) && n >= 0 ? n : undefined;
3746
- };
3747
- const msgIndex = qi("message");
3748
- if (msgIndex !== undefined) {
3749
- const messages = audit.messages;
3750
- if (msgIndex >= messages.length) {
3751
- sendJson(res, 404, { error: "message index out of range", total: messages.length });
3752
- return;
3753
- }
3754
- sendJson(res, 200, { sessionId: audit.sessionId, floorEntryId: audit.floorEntryId, index: msgIndex, message: messages[msgIndex] });
3755
- return;
3756
- }
3757
- const tail = qi("tail");
3758
- const before = qi("before");
3759
- const blobCap = qi("blobCap");
3760
- if (tail === undefined && before === undefined && blobCap === undefined) {
3761
- sendJson(res, 200, audit);
3762
- return;
3763
- }
3764
- let messages = audit.messages;
3765
- let window;
3766
- if (tail !== undefined || before !== undefined) {
3767
- const w = windowMessages(messages, { ...(tail !== undefined ? { tail } : {}), ...(before !== undefined ? { before } : {}), ...(qi("limit") !== undefined ? { limit: qi("limit") } : {}) });
3768
- messages = w.messages;
3769
- window = w.window;
3770
- }
3771
- if (blobCap !== undefined && blobCap > 0)
3772
- messages = truncateMessageBlobs(messages, blobCap);
3773
- sendJson(res, 200, { ...audit, messages, ...(window ? { window } : {}) });
3774
- return;
3775
- }
3776
- }
3777
- {
3778
- const fm = req.method === "POST" ? /^\/v1\/sessions\/([^/]+)\/fork$/.exec(url) : null;
3779
- if (fm) {
3780
- const fork = deps.sessionStorage?.fork?.bind(deps.sessionStorage);
3781
- const ownerOf = deps.sessionStorage?.ownerOf?.bind(deps.sessionStorage);
3782
- if (!fork || !ownerOf) {
3783
- sendJson(res, 501, { error: "session fork requires a durable session store (SESSION_BACKEND=tidb)" });
3784
- return;
3785
- }
3786
- const scope = sessionOwnerScopeForWrite(req);
3787
- if (!scope.fleetWide && scope.gateOwner === null) {
3788
- sendJson(res, 401, { error: "unauthorized" });
3789
- return;
3790
- }
3791
- const sourceId = safeDecode(fm[1]);
3792
- if (sourceId === null || !isUuidV7(sourceId)) {
3793
- sendJson(res, 400, { error: "invalid session id (must be a uuidv7)" });
3794
- return;
3795
- }
3796
- const owner = await ownerOf(sourceId);
3797
- if (owner === undefined) {
3798
- sendJson(res, 404, { error: "session not found" });
3799
- return;
3800
- }
3801
- if (!scope.fleetWide && owner !== scope.gateOwner) {
3802
- sendJson(res, 404, { error: "session not found" });
3803
- return;
3804
- }
3805
- const newOwner = scope.fleetWide ? (owner ?? null) : scope.gateOwner;
3806
- const newId = await fork(sourceId, newOwner);
3807
- if (newId === null) {
3808
- sendJson(res, 404, { error: "session not found" });
3809
- return;
3810
- }
3811
- sendJson(res, 200, { sessionId: newId });
3812
- return;
3813
- }
3814
- }
3815
- {
3816
- const dm = req.method === "DELETE" ? /^\/v1\/sessions\/([^/]+)$/.exec(url) : null;
3817
- if (dm) {
3818
- const purge = deps.purgeSession;
3819
- const ownerOf = deps.sessionStorage?.ownerOf?.bind(deps.sessionStorage);
3820
- if (!purge || !ownerOf) {
3821
- sendJson(res, 501, { error: "session delete requires a durable session store (SESSION_BACKEND=tidb)" });
3822
- return;
3823
- }
3824
- const scope = sessionOwnerScopeForWrite(req);
3825
- if (!scope.fleetWide && scope.gateOwner === null) {
3826
- sendJson(res, 401, { error: "unauthorized" });
3827
- return;
3828
- }
3829
- const sessionId = safeDecode(dm[1]);
3830
- if (sessionId === null || !isUuidV7(sessionId)) {
3831
- sendJson(res, 400, { error: "invalid session id (must be a uuidv7)" });
3832
- return;
3833
- }
3834
- const owner = await ownerOf(sessionId);
3835
- if (owner === undefined) {
3836
- sendJson(res, 200, { deleted: false });
3837
- return;
3838
- }
3839
- if (!scope.fleetWide && owner !== scope.gateOwner) {
3840
- sendJson(res, 200, { deleted: false });
3841
- return;
3842
- }
3843
- const activeTaskId = await deps.runStore?.getActiveTaskId(sessionId);
3844
- if (activeTaskId) {
3845
- sendJson(res, 409, { error: "session has an active run; cancel it before deleting", activeTaskId });
3846
- return;
3847
- }
3848
- const result = await purge(sessionId, owner);
3849
- if ("active" in result) {
3850
- sendJson(res, 409, { error: "session has an active run; cancel it before deleting", activeTaskId: result.active });
3851
- return;
3852
- }
3853
- sendJson(res, 200, result);
3854
- return;
3855
- }
3856
- }
3857
- {
3858
- const pm = req.method === "GET" || req.method === "PUT" ? /^\/v1\/sessions\/([^/]+)\/policy$/.exec(url) : null;
3859
- if (pm) {
3860
- const store = deps.sessionPolicyStore;
3861
- const ownerOf = deps.sessionStorage?.ownerOf?.bind(deps.sessionStorage);
3862
- if (!store || !ownerOf) {
3863
- sendJson(res, 501, { error: "session policy requires a session-store backend" });
3864
- return;
3865
- }
3866
- const principal = gatedPrincipal(req, deps.config);
3867
- if (deps.config.requirePrincipal && principal === undefined) {
3868
- sendJson(res, 401, { error: "unauthorized" });
3869
- return;
3870
- }
3871
- const sessionId = safeDecode(pm[1]);
3872
- if (sessionId === null || !isUuidV7(sessionId)) {
3873
- sendJson(res, 400, { error: "invalid session id (must be a uuidv7)" });
3874
- return;
3875
- }
3876
- const owner = await ownerOf(sessionId);
3877
- if (owner === undefined) {
3878
- sendJson(res, 404, { error: "session not found" });
3879
- return;
3880
- }
3881
- if (owner === null) {
3882
- sendJson(res, 409, { error: "session policy requires an owned session (set REQUIRE_PRINCIPAL / attach a principal); this session is ownerless" });
3883
- return;
3884
- }
3885
- const operator = explicitOperatorOk(principal, deps.config.operatorPrincipals);
3886
- if (!operator && owner !== (principal ?? null)) {
3887
- sendJson(res, 404, { error: "session not found" });
3888
- return;
3889
- }
3890
- const target = owner;
3891
- if (req.method === "GET") {
3892
- sendJson(res, 200, { rules: (await store.getRules(sessionId, target)) ?? { rev: 0 } });
3893
- return;
3894
- }
3895
- let body;
3896
- try {
3897
- body = (await readJson(req));
3898
- }
3899
- catch {
3900
- sendJson(res, 400, { error: "invalid JSON body" });
3901
- return;
3902
- }
3903
- if (typeof body.rules !== "object" || body.rules === null || Array.isArray(body.rules)) {
3904
- sendJson(res, 400, { error: "rules must be a SessionPermissionRules object" });
3905
- return;
3906
- }
3907
- if (body.expectedRev !== undefined && (typeof body.expectedRev !== "number" || !Number.isInteger(body.expectedRev) || body.expectedRev < 0)) {
3908
- sendJson(res, 400, { error: "expectedRev must be a non-negative integer" });
3909
- return;
3910
- }
3911
- const raw = body.rules;
3912
- const rules = {};
3913
- for (const f of ["toolAllow", "toolDeny", "allowDirs", "commandAllow", "commandDeny"]) {
3914
- if (raw[f] === undefined)
3915
- continue;
3916
- if (!Array.isArray(raw[f]) || !raw[f].every((x) => typeof x === "string")) {
3917
- sendJson(res, 400, { error: `${f} must be an array of strings` });
3918
- return;
3919
- }
3920
- rules[f] = raw[f];
3921
- }
3922
- try {
3923
- const stored = await store.putRules(sessionId, target, rules, { ...(typeof body.expectedRev === "number" ? { expectedRev: body.expectedRev } : {}), operator });
3924
- sendJson(res, 200, { rules: stored });
3925
- }
3926
- catch (e) {
3927
- if (e instanceof SessionPolicyError) {
3928
- sendError(res, e.code === "conflict" ? 409 : 403, e.code, e.message);
3929
- return;
3930
- }
3931
- throw e;
3932
- }
3933
- return;
3934
- }
3935
- }
3936
- {
3937
- const wm = req.method === "GET" ? /^\/v1\/sessions\/([^/]+)\/workspace(?:\/([^/]+)(?:\/(tree|file|archive))?)?$/.exec(url) : null;
3938
- if (wm) {
3939
- const fs = deps.fileSnapshotStore;
3940
- const ownerOf = deps.sessionStorage?.ownerOf?.bind(deps.sessionStorage);
3941
- if (!fs || typeof fs.listKeys !== "function" || typeof fs.exportManifest !== "function" || typeof fs.getBlob !== "function" || !ownerOf) {
3942
- sendJson(res, 501, { error: "workspace browse requires a durable snapshot store (rewindFiles wiring) and a durable session store" });
3943
- return;
3944
- }
3945
- const scope = sessionOwnerScope(req);
3946
- if (!scope.fleetWide && scope.gateOwner === null) {
3947
- sendJson(res, 401, { error: "unauthorized" });
3948
- return;
3949
- }
3950
- const wsSession = safeDecode(wm[1]);
3951
- if (wsSession === null || !isUuidShape(wsSession)) {
3952
- sendJson(res, 400, { error: "invalid session id (must be a canonical uuid)" });
3953
- return;
3954
- }
3955
- const wsOwner = await ownerOf(wsSession);
3956
- if (wsOwner === undefined || (!scope.fleetWide && wsOwner !== scope.gateOwner)) {
3957
- sendJson(res, 404, { error: "session not found" });
3958
- return;
3959
- }
3960
- const q = new URL(req.url ?? "", "http://x").searchParams;
3961
- const keys = (await fs.listKeys(wsSession)).sort().reverse();
3962
- const sub = wm[3];
3963
- if (wm[2] === undefined) {
3964
- const limRaw = q.get("limit");
3965
- if (limRaw !== null && !(/^[0-9]+$/.test(limRaw) && Number(limRaw) >= 1 && Number(limRaw) <= 100)) {
3966
- sendJson(res, 400, { error: "limit must be a decimal integer in 1..100" });
3967
- return;
3968
- }
3969
- const lim = limRaw === null ? 20 : Number(limRaw);
3970
- const page = keys.slice(0, lim);
3971
- const snapshots = [];
3972
- for (const k of page) {
3973
- const m = await fs.exportManifest(wsSession, k);
3974
- if (m === null)
3975
- continue;
3976
- let bytes;
3977
- if (typeof fs.blobSizes === "function") {
3978
- const sizes = await fs.blobSizes([...new Set(m.values())]);
3979
- bytes = 0;
3980
- for (const h of m.values())
3981
- bytes += sizes.get(h) ?? 0;
3982
- }
3983
- snapshots.push({ key: k, files: m.size, ...(bytes !== undefined ? { bytes } : {}) });
3984
- }
3985
- sendJson(res, 200, {
3986
- sessionId: wsSession,
3987
- snapshots,
3988
- ...(snapshots.length > 0 ? { latest: snapshots[0].key } : {}),
3989
- total: snapshots.length,
3990
- });
3991
- return;
3992
- }
3993
- const keyRaw = safeDecode(wm[2]);
3994
- if (keyRaw === null) {
3995
- sendJson(res, 400, { error: "malformed snapshot key" });
3996
- return;
3997
- }
3998
- const key = keyRaw === "latest" ? keys[0] : keyRaw;
3999
- const manifest = key !== undefined ? await fs.exportManifest(wsSession, key) : null;
4000
- if (key === undefined || manifest === null) {
4001
- sendJson(res, 404, { error: "snapshot not found" });
4002
- return;
4003
- }
4004
- if (sub === "tree") {
4005
- const limRaw = q.get("limit");
4006
- if (limRaw !== null && !(/^[0-9]+$/.test(limRaw) && Number(limRaw) >= 1 && Number(limRaw) <= 2000)) {
4007
- sendJson(res, 400, { error: "limit must be a decimal integer in 1..2000" });
4008
- return;
4009
- }
4010
- const offRaw = q.get("offset");
4011
- if (offRaw !== null && !/^[0-9]+$/.test(offRaw)) {
4012
- sendJson(res, 400, { error: "offset must be a non-negative integer" });
4013
- return;
4014
- }
4015
- const lim = limRaw === null ? 500 : Number(limRaw);
4016
- const off = offRaw === null ? 0 : Number(offRaw);
4017
- const all = [...manifest.entries()].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
4018
- const page = all.slice(off, off + lim);
4019
- const sizes = typeof fs.blobSizes === "function" ? await fs.blobSizes([...new Set(page.map(([, h]) => h))]) : undefined;
4020
- sendJson(res, 200, {
4021
- sessionId: wsSession,
4022
- key,
4023
- entries: page.map(([path, hash]) => ({ path, hash, ...(sizes?.get(hash) !== undefined ? { size: sizes.get(hash) } : {}) })),
4024
- total: all.length,
4025
- ...(off + lim < all.length ? { nextOffset: off + lim } : {}),
4026
- });
4027
- return;
4028
- }
4029
- if (sub === "file") {
4030
- const pathParam = q.get("path");
4031
- if (!pathParam) {
4032
- sendJson(res, 400, { error: "missing ?path=<relPath from the tree>" });
4033
- return;
4034
- }
4035
- const hash = manifest.get(pathParam);
4036
- if (hash === undefined) {
4037
- sendJson(res, 404, { error: "no such file in this snapshot (paths are the tree's relPaths, verbatim)" });
4038
- return;
4039
- }
4040
- const cap = deps.config.workspaceFileMaxBytes ?? 8 * 1024 * 1024;
4041
- if (typeof fs.blobSizes === "function") {
4042
- const known = (await fs.blobSizes([hash])).get(hash);
4043
- if (known !== undefined && known > cap) {
4044
- sendError(res, 413, "workspace_file_too_large", "file exceeds the single-file read limit — download it via the archive endpoint", { code: "workspace_file_too_large", sizeBytes: known, limit: cap });
4045
- return;
4046
- }
4047
- }
4048
- const bytes = await fs.getBlob(hash);
4049
- if (bytes === undefined) {
4050
- sendJson(res, 404, { error: "file content no longer available (snapshot raced a reap)" });
4051
- return;
4052
- }
4053
- if (bytes.byteLength > cap) {
4054
- sendError(res, 413, "workspace_file_too_large", "file exceeds the single-file read limit — download it via the archive endpoint", { code: "workspace_file_too_large", sizeBytes: bytes.byteLength, limit: cap });
4055
- return;
4056
- }
4057
- const ct = workspaceContentType(pathParam);
4058
- const bin = looksBinary(bytes);
4059
- res.writeHead(200, {
4060
- "content-type": ct,
4061
- "content-length": bytes.byteLength,
4062
- "x-content-type-options": "nosniff",
4063
- "x-sema-content-binary": bin ? "true" : "false",
4064
- });
4065
- res.end(Buffer.from(bytes));
4066
- return;
4067
- }
4068
- if (sub === "archive") {
4069
- const entries = [...manifest.entries()].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
4070
- const bad = entries.filter(([p]) => splitTarPath(p) === null).map(([p]) => p);
4071
- if (bad.length > 0) {
4072
- sendJson(res, 422, { error: `snapshot contains ${bad.length} path(s) not representable in ustar (>255 bytes or unsplittable)`, paths: bad.slice(0, 5) });
4073
- return;
4074
- }
4075
- if (typeof fs.hasBlobs === "function") {
4076
- const present = await fs.hasBlobs([...new Set(entries.map(([, h]) => h))]);
4077
- const missing = entries.filter(([, h]) => !present.has(h)).length;
4078
- if (missing > 0) {
4079
- sendJson(res, 502, { error: `snapshot is missing ${missing} blob(s) (raced a reap or a partial import) — retry, or pick another snapshot` });
4080
- return;
4081
- }
4082
- }
4083
- res.writeHead(200, {
4084
- "content-type": "application/x-tar",
4085
- "content-disposition": `attachment; filename="workspace-${key}.tar"`,
4086
- "x-content-type-options": "nosniff",
4087
- });
4088
- const write = async (buf) => {
4089
- if (res.writableEnded || res.destroyed)
4090
- throw new Error("client gone");
4091
- if (!res.write(buf)) {
4092
- await new Promise((resolve, reject) => {
4093
- const onDrain = () => { cleanup(); resolve(); };
4094
- const onGone = () => { cleanup(); reject(new Error("client gone (closed while draining)")); };
4095
- const cleanup = () => { res.off("drain", onDrain); res.off("close", onGone); res.off("error", onGone); };
4096
- res.once("drain", onDrain);
4097
- res.once("close", onGone);
4098
- res.once("error", onGone);
4099
- });
4100
- }
4101
- };
4102
- try {
4103
- for (const [p, h] of entries) {
4104
- const bytes = await fs.getBlob(h);
4105
- if (bytes === undefined)
4106
- throw new Error(`blob vanished mid-stream: ${h}`);
4107
- await write(tarHeader(p, bytes.byteLength, 0));
4108
- await write(Buffer.from(bytes));
4109
- await write(tarPadding(bytes.byteLength));
4110
- }
4111
- await write(TAR_END);
4112
- res.end();
4113
- }
4114
- catch {
4115
- res.destroy();
4116
- }
4117
- return;
4118
- }
4119
- sendJson(res, 404, { error: "unknown workspace verb — use /tree, /file?path=, or /archive" });
4120
- return;
4121
- }
4122
- }
4123
- {
4124
- const sm = /^\/v1\/sessions\/([^/]+)\/sync\/(manifest|entries|import|plan|blobs(?:\/([^/]+))?|snapshots\/([^/]+)\/blobs\/([^/]+)|import\/([^/]+)\/entries)$/.exec(url);
4125
- if (sm) {
4126
- const ownerOf = deps.sessionStorage?.ownerOf?.bind(deps.sessionStorage);
4127
- if (!deps.backend || !ownerOf) {
4128
- sendJson(res, 501, { error: "session sync requires a durable session store" });
4129
- return;
4130
- }
4131
- const scope = sessionOwnerScopeForWrite(req);
4132
- if (!scope.fleetWide && scope.gateOwner === null) {
4133
- sendJson(res, 401, { error: "unauthorized" });
4134
- return;
4135
- }
4136
- const sessionId = safeDecode(sm[1]);
4137
- if (sessionId === null || !isUuidShape(sessionId)) {
4138
- sendJson(res, 400, { error: "invalid session id (must be a canonical uuid)" });
4139
- return;
4140
- }
4141
- const owner = await ownerOf(sessionId);
4142
- const ownsOrFleet = (o) => scope.fleetWide || o === scope.gateOwner;
4143
- const pullGateOk = () => {
4144
- if (owner === undefined) {
4145
- sendJson(res, 404, { error: "session not found" });
4146
- return false;
4147
- }
4148
- if (!ownsOrFleet(owner)) {
4149
- sendJson(res, 404, { error: "session not found" });
4150
- return false;
4151
- }
4152
- return true;
4153
- };
4154
- const pushGateOk = () => {
4155
- if (owner !== undefined && !ownsOrFleet(owner)) {
4156
- sendJson(res, 404, { error: "session not found" });
4157
- return false;
4158
- }
4159
- return true;
4160
- };
4161
- if (sm[2] === "manifest" && req.method === "GET") {
4162
- if (!pullGateOk())
4163
- return;
4164
- const out = await exportSessionManifest(sessionId, deps.backend);
4165
- if (out === null) {
4166
- sendJson(res, 404, { error: "session not found" });
4167
- return;
4168
- }
4169
- sendJson(res, 200, { manifest: out.manifest });
4170
- return;
4171
- }
4172
- if (sm[2] === "entries" && req.method === "GET") {
4173
- if (!pullGateOk())
4174
- return;
4175
- const afterSeqRaw = new URL(req.url ?? "", "http://x").searchParams.get("afterSeq");
4176
- let afterSeq;
4177
- if (afterSeqRaw !== null) {
4178
- const n = Number(afterSeqRaw);
4179
- if (!Number.isInteger(n) || n < 0) {
4180
- sendJson(res, 400, { error: "afterSeq must be a non-negative integer" });
4181
- return;
4182
- }
4183
- afterSeq = n;
4184
- }
4185
- const stream = deps.sessionStorage?.exportEntriesStream?.bind(deps.sessionStorage);
4186
- if (!stream) {
4187
- sendJson(res, 501, { error: "session entry streaming requires a durable session store" });
4188
- return;
4189
- }
4190
- const it = await stream(sessionId, afterSeq === undefined ? undefined : { afterSeq });
4191
- if (it === null) {
4192
- sendJson(res, 404, { error: "session not found" });
4193
- return;
4194
- }
4195
- res.writeHead(200, { "content-type": "application/x-ndjson", "transfer-encoding": "chunked" });
4196
- const write = async (line) => { if (!res.write(line))
4197
- await once(res, "drain"); };
4198
- let count = 0;
4199
- try {
4200
- await write(`${JSON.stringify({ __sync: "begin", sessionId })}\n`);
4201
- for await (const entry of it) {
4202
- await write(`${JSON.stringify(entry)}\n`);
4203
- count++;
4204
- }
4205
- await write(`${JSON.stringify({ __sync: "end", count })}\n`);
4206
- res.end();
4207
- }
4208
- catch (e) {
4209
- deps.logger?.error?.("sync entries stream failed", { sessionId, count, error: msg(e) });
4210
- res.end();
4211
- }
4212
- return;
4213
- }
4214
- if (sm[4] !== undefined && req.method === "GET") {
4215
- if (!pullGateOk())
4216
- return;
4217
- const key = safeDecode(sm[4]);
4218
- const hash = safeDecode(sm[5]);
4219
- if (key === null || hash === null) {
4220
- sendJson(res, 400, { error: "invalid snapshot key or blob hash" });
4221
- return;
4222
- }
4223
- if (!SHA256_HEX_RE.test(hash)) {
4224
- sendJson(res, 400, { error: "blob hash must be 64 lowercase-hex chars" });
4225
- return;
4226
- }
4227
- const fs = deps.fileSnapshotStore;
4228
- const manifest = await fs?.exportManifest?.(sessionId, key);
4229
- if (!manifest || ![...manifest.values()].includes(hash)) {
4230
- sendJson(res, 404, { error: "blob not found" });
4231
- return;
4232
- }
4233
- const bytes = await fs?.getBlob?.(hash);
4234
- if (!bytes) {
4235
- sendJson(res, 404, { error: "blob not found" });
4236
- return;
4237
- }
4238
- res.writeHead(200, { "content-type": "application/octet-stream" });
4239
- res.end(Buffer.from(bytes));
4240
- return;
4241
- }
4242
- if (sm[3] !== undefined && req.method === "PUT") {
4243
- if (!pushGateOk())
4244
- return;
4245
- const hash = safeDecode(sm[3]);
4246
- if (hash === null || !SHA256_HEX_RE.test(hash)) {
4247
- sendJson(res, 400, { error: "blob hash must be 64 lowercase-hex chars" });
4248
- return;
4249
- }
4250
- const fs = deps.fileSnapshotStore;
4251
- if (typeof fs?.putBlob !== "function") {
4252
- sendJson(res, 501, { error: "blob upload requires a durable snapshot store" });
4253
- return;
4254
- }
4255
- const body = await readRawBody(req, SYNC_BLOB_MAX_BYTES);
4256
- if (deps.snapshotBlobSqlCapBytes !== undefined && body.byteLength > deps.snapshotBlobSqlCapBytes) {
4257
- sendError(res, 413, "blob_too_large_for_sql", `blob of ${body.byteLength} bytes exceeds this deployment's SQL snapshot-blob cap (${deps.snapshotBlobSqlCapBytes} bytes — TiDB's txn-entry-size-limit is the low wall at 6MiB by default; the mysql-protocol packet limit sits above it) — configure MinIO object storage (MINIO_ENDPOINT/MINIO_ACCESS_KEY/MINIO_SECRET_KEY) for large snapshot blobs, or raise SNAPSHOT_BLOB_SQL_MAX_BYTES if your deployment lifted those limits`, { code: "blob_too_large_for_sql" });
4258
- return;
4259
- }
4260
- if (createHash("sha256").update(body).digest("hex") !== hash) {
4261
- sendJson(res, 400, { error: "blob hash mismatch" });
4262
- return;
4263
- }
4264
- const put = await fs.putBlob(hash, new Uint8Array(body));
4265
- if (!put.ok) {
4266
- sendError(res, 502, put.error.code, "blob store write failed", { code: put.error.code });
4267
- return;
4268
- }
4269
- res.writeHead(204).end();
4270
- return;
4271
- }
4272
- if (sm[2] === "import" && req.method === "POST") {
4273
- if (!pushGateOk())
4274
- return;
4275
- if (deps.sessionMirrorRuling && !scope.fleetWide && scope.gateOwner !== null) {
4276
- const auditPrincipal = scope.gateOwner;
4277
- void deps.sessionMirrorRuling(auditPrincipal).then((mirror) => {
4278
- if (mirror?.required) {
4279
- deps.metrics?.inc("session_mirror_required_push_total");
4280
- deps.logger?.info("session_mirror_required_push_observed", { principal: auditPrincipal, engineUrl: mirror.engineUrl, sessionId });
4281
- }
4282
- }).catch(() => { });
4283
- }
4284
- if (!deps.sessionStorage?.beginImportStaging) {
4285
- sendJson(res, 501, { error: "staged session import requires a durable session store" });
4286
- return;
4287
- }
4288
- let body;
4289
- try {
4290
- body = (await readJson(req));
4291
- }
4292
- catch {
4293
- sendJson(res, 400, { error: "invalid JSON body" });
4294
- return;
4295
- }
4296
- if (!Array.isArray(body.entryIds) || !body.entryIds.every((x) => typeof x === "string")) {
4297
- sendJson(res, 400, { error: "entryIds must be an array of strings" });
4298
- return;
4299
- }
4300
- const snapshotsOk = Array.isArray(body.snapshots) && body.snapshots.every((s) => typeof s === "object" && s !== null && typeof s.key === "string" && Array.isArray(s.manifest));
4301
- if (!snapshotsOk || !Array.isArray(body.policy) || !Array.isArray(body.anchors)) {
4302
- sendJson(res, 400, { error: "malformed import metadata" });
4303
- return;
4304
- }
4305
- if (body.resolution !== undefined && body.resolution !== "overwrite-dst") {
4306
- sendJson(res, 400, { error: "resolution must be 'overwrite-dst'" });
4307
- return;
4308
- }
4309
- const resolution = body.resolution;
4310
- const srcLogDigest = typeof body.logDigest === "string" && body.logDigest.length > 0 ? body.logDigest : undefined;
4311
- const entryIds = body.entryIds;
4312
- const snapshots = body.snapshots;
4313
- const policy = body.policy;
4314
- const anchors = body.anchors;
4315
- const dstIds = (await deps.sessionStorage.listEntryIds?.(sessionId)) ?? null;
4316
- const relA = classifySyncRelationshipByIds(entryIds, dstIds);
4317
- if ((relA.relation === "fork" || relA.relation === "stale") && resolution !== "overwrite-dst") {
4318
- sendError(res, 409, "conflict", "sync conflict", { relation: relA });
4319
- return;
4320
- }
4321
- let payloadVerified = false;
4322
- let digestSaysDiffer = false;
4323
- if (relA.relation === "identical" && srcLogDigest !== undefined) {
4324
- const dstEntries = await deps.sessionStorage.exportEntries?.(sessionId).catch(() => null) ?? null;
4325
- const dstDigest = dstEntries === null ? undefined : sessionLogDigest(dstEntries);
4326
- if (sessionLogDigestsComparable(srcLogDigest, dstDigest)) {
4327
- if (srcLogDigest === dstDigest)
4328
- payloadVerified = true;
4329
- else
4330
- digestSaysDiffer = true;
4331
- }
4332
- if (!payloadVerified)
4333
- digestSaysDiffer = true;
4334
- }
4335
- if (relA.relation === "identical" && !digestSaysDiffer) {
4336
- const fss = deps.fileSnapshotStore;
4337
- if (fss?.importManifest && fss.getBlob && snapshots.length > 0) {
4338
- const getBlobA = (h) => fss.getBlob(h);
4339
- for (const snap of snapshots) {
4340
- const r = await fss.importManifest(sessionId, snap.key, new Map(snap.manifest), getBlobA);
4341
- if (!r.ok) {
4342
- sendError(res, 422, r.error.code === "restore_failed" ? "restore_failed" : "missing_blob", `snapshot import failed (${r.error.code})`);
4343
- return;
4344
- }
4345
- }
4346
- }
4347
- const opOk = explicitOperatorOk(gatedPrincipal(req, deps.config), deps.config.operatorPrincipals);
4348
- if (deps.sessionPolicyStore)
4349
- for (const rec of policy)
4350
- await deps.sessionPolicyStore.putRules(sessionId, rec.principal, rec.rules, { operator: opOk });
4351
- const importingP = scope.fleetWide ? (owner ?? null) : scope.gateOwner;
4352
- if (deps.resumeAnchorStore)
4353
- for (const a of anchors)
4354
- await deps.resumeAnchorStore.put(sessionId, a.eventId, a.entryId, importingP);
4355
- sendJson(res, 200, {
4356
- relation: "identical",
4357
- basis: payloadVerified ? "entry-ids+digest" : "entry-ids",
4358
- payloadVerified,
4359
- });
4360
- return;
4361
- }
4362
- const activeTaskId = await deps.runStore?.getActiveTaskId(sessionId);
4363
- if (activeTaskId) {
4364
- sendError(res, 409, "session_active", "session has an active run; sync after it settles", { activeTaskId });
4365
- return;
4366
- }
4367
- const heldLease = importLeases.get(sessionId);
4368
- if (heldLease) {
4369
- const staleSecRaw = deps.config.syncImportLeaseStaleSec;
4370
- const staleSec = typeof staleSecRaw === "number" && Number.isFinite(staleSecRaw) ? staleSecRaw : 600;
4371
- const staleMs = staleSec * 1000;
4372
- const ageMs = Date.now() - heldLease.at;
4373
- if (staleMs > 0 && ageMs >= staleMs) {
4374
- deps.logger?.warn?.("sync_import_lease_taken_over", { sessionId, ageMs, staleMs, stagingId: heldLease.stagingId });
4375
- cleanupStaging(heldLease.stagingId);
4376
- importLeases.delete(sessionId);
4377
- }
4378
- else {
4379
- const retryAfterSec = staleMs > 0 ? Math.max(1, Math.ceil((staleMs - ageMs) / 1000)) : undefined;
4380
- sendError(res, 409, "import_in_flight", "an import is already in flight for this session"
4381
- + (retryAfterSec !== undefined ? ` (a stalled holder is taken over after ${staleSec}s of inactivity; retry in ~${retryAfterSec}s)` : ""), retryAfterSec !== undefined ? { retryAfterSec } : undefined);
4382
- return;
4383
- }
4384
- }
4385
- const fss = deps.fileSnapshotStore;
4386
- const canImportSnapshots = typeof fss?.importManifest === "function";
4387
- if (canImportSnapshots) {
4388
- const distinct = new Set();
4389
- for (const snap of snapshots)
4390
- for (const [, hash] of snap.manifest)
4391
- distinct.add(hash);
4392
- const all = [...distinct];
4393
- if (typeof fss?.hasBlobs === "function") {
4394
- const present = await fss.hasBlobs(all);
4395
- const missing = all.find((h) => !present.has(h));
4396
- if (missing !== undefined) {
4397
- sendError(res, 422, "missing_blob", "missing blob; PUT all referenced blobs before import");
4398
- return;
4399
- }
4400
- }
4401
- else {
4402
- for (const hash of all) {
4403
- if (!(await deps.fileSnapshotStore.getBlob(hash))) {
4404
- sendError(res, 422, "missing_blob", "missing blob; PUT all referenced blobs before import");
4405
- return;
4406
- }
4407
- }
4408
- }
4409
- }
4410
- const importingPrincipal = scope.fleetWide ? (owner ?? null) : scope.gateOwner;
4411
- const operatorOk = explicitOperatorOk(gatedPrincipal(req, deps.config), deps.config.operatorPrincipals);
4412
- const token = randomBytes(8).toString("hex");
4413
- const handle = deps.sessionStorage.beginImportStaging(sessionId, token);
4414
- const stagingId = handle.stagingId;
4415
- importLeases.set(sessionId, { at: Date.now(), stagingId });
4416
- pendingStagings.set(stagingId, {
4417
- handle, realSessionId: sessionId, owner: importingPrincipal, ...(resolution ? { resolution } : {}), operatorOk,
4418
- snapshots, policy, anchors, declaredEntryIds: entryIds, openedBy: scope.gateOwner, openedFleetWide: scope.fleetWide,
4419
- });
4420
- sendJson(res, 200, { stagingId, relation: relA.relation });
4421
- return;
4422
- }
4423
- if (sm[6] !== undefined && req.method === "POST") {
4424
- const stagingId = safeDecode(sm[6]);
4425
- if (stagingId === null) {
4426
- sendJson(res, 400, { error: "invalid staging id" });
4427
- return;
4428
- }
4429
- const pending = pendingStagings.get(stagingId);
4430
- if (!pending || pending.realSessionId !== sessionId) {
4431
- sendJson(res, 404, { error: "staging not found" });
4432
- return;
4433
- }
4434
- if (!pending.openedFleetWide && !scope.fleetWide && pending.openedBy !== scope.gateOwner) {
4435
- sendJson(res, 404, { error: "staging not found" });
4436
- return;
4437
- }
4438
- const getBlob = (h) => deps.fileSnapshotStore.getBlob(h);
4439
- const canImportSnapshots = typeof deps.fileSnapshotStore?.importManifest === "function";
4440
- const validator = new StreamingImportValidator();
4441
- let lineBuf = "";
4442
- let pendingBatch = [];
4443
- const flush = async () => { if (pendingBatch.length) {
4444
- await pending.handle.appendBatch(pendingBatch);
4445
- pendingBatch = [];
4446
- } };
4447
- class PrecheckError extends Error {
4448
- status;
4449
- constructor(status, msg) {
4450
- super(msg);
4451
- this.status = status;
4452
- }
4453
- }
4454
- const streamedIds = new Set();
4455
- const onEntry = (e) => {
4456
- if (!e || typeof e !== "object" || typeof e.id !== "string")
4457
- throw new PrecheckError(422, "malformed entry (missing id)");
4458
- streamedIds.add(e.id);
4459
- validator.step(e);
4460
- };
4461
- const utf8 = new StringDecoder("utf8");
4462
- try {
4463
- for await (const chunk of req) {
4464
- touchImportLease(pending.realSessionId);
4465
- lineBuf += utf8.write(chunk);
4466
- if (Buffer.byteLength(lineBuf, "utf8") > SYNC_NDJSON_LINE_MAX_BYTES)
4467
- throw new PrecheckError(413, "NDJSON line too large");
4468
- let nl;
4469
- while ((nl = lineBuf.indexOf("\n")) >= 0) {
4470
- const line = lineBuf.slice(0, nl).trim();
4471
- lineBuf = lineBuf.slice(nl + 1);
4472
- if (line.length === 0)
4473
- continue;
4474
- let e;
4475
- try {
4476
- e = JSON.parse(line);
4477
- }
4478
- catch {
4479
- throw new PrecheckError(400, "malformed NDJSON line");
4480
- }
4481
- onEntry(e);
4482
- pendingBatch.push(e);
4483
- if (pendingBatch.length >= SYNC_IMPORT_BATCH)
4484
- await flush();
4485
- }
4486
- }
4487
- lineBuf += utf8.end();
4488
- const tail = lineBuf.trim();
4489
- if (tail.length > 0) {
4490
- let e;
4491
- try {
4492
- e = JSON.parse(tail);
4493
- }
4494
- catch {
4495
- throw new PrecheckError(400, "malformed NDJSON line");
4496
- }
4497
- onEntry(e);
4498
- pendingBatch.push(e);
4499
- }
4500
- await flush();
4501
- validator.finish();
4502
- if (streamedIds.size !== pending.declaredEntryIds.length || !pending.declaredEntryIds.every((id) => streamedIds.has(id))) {
4503
- throw new PrecheckError(422, "streamed entries do not match the Phase-A declared entryIds");
4504
- }
4505
- }
4506
- catch (e) {
4507
- if (e instanceof PrecheckError) {
4508
- await pending.handle.abort().catch(() => undefined);
4509
- cleanupStaging(stagingId);
4510
- sendError(res, e.status, "invalid_entries", e.message);
4511
- return;
4512
- }
4513
- if (e instanceof SessionError && e.code === "invalid_session") {
4514
- await pending.handle.abort().catch(() => undefined);
4515
- cleanupStaging(stagingId);
4516
- sendError(res, 422, "invalid_entries", "staged session log failed validation");
4517
- return;
4518
- }
4519
- await pending.handle.abort().catch(() => undefined);
4520
- cleanupStaging(stagingId);
4521
- throw e;
4522
- }
4523
- const activeAtCommit = await deps.runStore?.getActiveTaskId(sessionId);
4524
- if (activeAtCommit) {
4525
- await pending.handle.abort().catch(() => undefined);
4526
- cleanupStaging(stagingId);
4527
- sendError(res, 409, "session_active", "session started an active run while the import streamed; sync after it settles", { activeTaskId: activeAtCommit });
4528
- return;
4529
- }
4530
- try {
4531
- const committed = await pending.handle.commit(pending.owner, pending.resolution ? { resolution: pending.resolution } : undefined);
4532
- if (canImportSnapshots) {
4533
- const importManifest = deps.fileSnapshotStore.importManifest.bind(deps.fileSnapshotStore);
4534
- for (const snap of pending.snapshots) {
4535
- const r = await importManifest(sessionId, snap.key, new Map(snap.manifest), getBlob);
4536
- if (!r.ok) {
4537
- sendError(res, 422, r.error.code === "restore_failed" ? "restore_failed" : "missing_blob", `snapshot import failed (${r.error.code})`);
4538
- cleanupStaging(stagingId);
4539
- return;
4540
- }
4541
- }
4542
- }
4543
- const policyStore = deps.sessionPolicyStore;
4544
- if (policyStore)
4545
- for (const rec of pending.policy)
4546
- await policyStore.putRules(sessionId, rec.principal, rec.rules, { operator: pending.operatorOk });
4547
- const anchorStore = deps.resumeAnchorStore;
4548
- if (anchorStore)
4549
- for (const a of pending.anchors)
4550
- await anchorStore.put(sessionId, a.eventId, a.entryId, pending.owner);
4551
- deps.sessionStorage.evict?.(sessionId);
4552
- cleanupStaging(stagingId);
4553
- sendJson(res, 200, { relation: committed.relation });
4554
- }
4555
- catch (e) {
4556
- if (e instanceof SyncConflictError) {
4557
- await pending.handle.abort().catch(() => undefined);
4558
- cleanupStaging(stagingId);
4559
- sendError(res, 409, "conflict", "sync conflict", { relation: e.relation });
4560
- return;
4561
- }
4562
- if (e instanceof SessionPolicyError) {
4563
- cleanupStaging(stagingId);
4564
- sendError(res, e.code === "loosen_forbidden" ? 403 : 409, e.code, e.message);
4565
- return;
4566
- }
4567
- cleanupStaging(stagingId);
4568
- throw e;
4569
- }
4570
- return;
4571
- }
4572
- if (sm[2] === "plan" && req.method === "POST") {
4573
- if (!pushGateOk())
4574
- return;
4575
- let body;
4576
- try {
4577
- body = (await readJson(req));
4578
- }
4579
- catch {
4580
- sendJson(res, 400, { error: "invalid JSON body" });
4581
- return;
4582
- }
4583
- if (!Array.isArray(body.entryIds) || !body.entryIds.every((x) => typeof x === "string")) {
4584
- sendJson(res, 400, { error: "entryIds must be an array of strings" });
4585
- return;
4586
- }
4587
- const dst = await deps.sessionStorage?.exportEntries?.(sessionId);
4588
- const relation = classifySyncRelationshipByIds(body.entryIds, dst ? dst.map((e) => e.id) : null);
4589
- sendJson(res, 200, relation);
4590
- return;
4591
- }
4592
- sendJson(res, 404, { error: "not found" });
4593
- return;
4594
- }
4595
- }
4596
- {
4597
- const am = /^\/v1\/attachments(?:\/([^/?]+))?$/.exec(url);
4598
- if (am) {
4599
- if (rateLimited(req, res) || quotaExceeded(req, res))
4600
- return;
4601
- const store = deps.taskAttachmentStore;
4602
- if (!store) {
4603
- sendJson(res, 501, { error: "attachment store not configured" });
4604
- return;
4605
- }
4606
- const principal = gatedPrincipal(req, deps.config);
4607
- if (deps.config.requirePrincipal && principal === undefined) {
4608
- sendJson(res, 401, { error: `missing principal header '${deps.config.principalHeader}'` });
4609
- return;
4610
- }
4611
- const owner = principal ?? "default";
4612
- const attId = am[1] !== undefined ? safeDecode(am[1]) : undefined;
4613
- if (attId === undefined && req.method === "POST") {
4614
- const q = new URL(req.url ?? "", "http://x").searchParams;
4615
- const rawName = q.get("name");
4616
- const base = ((rawName ?? "").split(/[/\\]/).filter(Boolean).pop() ?? "").slice(0, 128);
4617
- if (!rawName || base === "" || base === "." || base === ".." || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(base)) {
4618
- sendJson(res, 400, { error: "query param `name` is required and must reduce to a safe basename ([A-Za-z0-9._-], not starting with a dot)" });
4619
- return;
4620
- }
4621
- const name = sanitizePathComponent(base);
4622
- const mime = String(req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
4623
- if (!/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(mime) || mime.length > 128) {
4624
- sendJson(res, 400, { error: "content-type must be a well-formed mime type (type/subtype)" });
4625
- return;
4626
- }
4627
- const allow = deps.config.attachmentMimeAllowlist;
4628
- if (allow && !allow.some((a) => a === mime || (a.endsWith("/*") && mime.startsWith(a.slice(0, -1))))) {
4629
- sendError(res, 415, "attachment.mime_not_allowed", `mime '${mime}' is not in this deployment's allowlist`, { code: "attachment.mime_not_allowed", allowed: allow });
4630
- return;
4631
- }
4632
- const max = deps.config.attachmentMaxBytes ?? 32 * 1024 * 1024;
4633
- let body;
4634
- try {
4635
- body = await readRawBody(req, max);
4636
- }
4637
- catch (err) {
4638
- if (err instanceof HttpError && err.status === 413) {
4639
- sendError(res, 413, "attachment.too_large", `attachment exceeds the per-file limit (${max} bytes)`, { code: "attachment.too_large", maxBytes: max });
4640
- return;
4641
- }
4642
- throw err;
4643
- }
4644
- const rec = {
4645
- id: uuidv7(),
4646
- owner,
4647
- name,
4648
- mime,
4649
- sha256: createHash("sha256").update(body).digest("hex"),
4650
- sizeBytes: body.length,
4651
- createdAtMs: Date.now(),
4652
- };
4653
- await store.put({ ...rec, content: new Uint8Array(body) });
4654
- deps.metrics?.inc("attachment_uploads_total");
4655
- sendJson(res, 201, { id: rec.id, name: rec.name, mime: rec.mime, sha256: rec.sha256, sizeBytes: rec.sizeBytes });
4656
- return;
4657
- }
4658
- if (attId !== undefined && attId !== null && req.method === "GET") {
4659
- const meta = await store.get(owner, attId);
4660
- const bytes = meta ? await store.getContent(owner, attId) : null;
4661
- if (!meta || !bytes) {
4662
- sendJson(res, 404, { error: "not found" });
4663
- return;
4664
- }
4665
- res.writeHead(200, {
4666
- "content-type": meta.mime,
4667
- "content-length": String(bytes.byteLength),
4668
- "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(meta.name)}`,
4669
- });
4670
- res.end(Buffer.from(bytes));
4671
- return;
4672
- }
4673
- if (attId !== undefined && attId !== null && req.method === "DELETE") {
4674
- const ok = await store.delete(owner, attId);
4675
- if (!ok) {
4676
- sendJson(res, 404, { error: "not found" });
4677
- return;
4678
- }
4679
- res.writeHead(204).end();
4680
- return;
4681
- }
4682
- sendJson(res, 404, { error: "not found" });
4683
- return;
4684
- }
4685
- }
4686
- if (req.method === "POST" && /^\/v1\/sessions\/[^/]+\/notify$/.test(url)) {
4687
- if (rateLimited(req, res) || quotaExceeded(req, res))
4688
- return;
4689
- const principal = gatedPrincipal(req, deps.config);
4690
- if (deps.config.requirePrincipal && principal === undefined) {
4691
- sendJson(res, 401, { error: `missing principal header '${deps.config.principalHeader}'` });
4692
- return;
4693
- }
4694
- const notifySession = decodeURIComponent(url.split("/")[3]);
4695
- let nb;
4696
- try {
4697
- nb = (await readJson(req));
4698
- }
4699
- catch {
4700
- sendJson(res, 400, { error: "body must be JSON: { task_id, status, summary, result?, seq?, source? }" });
4701
- return;
4702
- }
4703
- if (typeof nb.task_id !== "string" || nb.task_id.trim().length === 0 || nb.task_id.length > 190) {
4704
- sendJson(res, 400, { error: "task_id must be a non-empty string of at most 190 characters" });
4705
- return;
4706
- }
4707
- const NOTIFY_STATUSES = ["completed", "failed", "killed", "cancelled", "event"];
4708
- if (typeof nb.status !== "string" || !NOTIFY_STATUSES.includes(nb.status)) {
4709
- sendJson(res, 400, { error: `status must be one of ${NOTIFY_STATUSES.join("/")}` });
4710
- return;
4711
- }
4712
- if (typeof nb.summary !== "string" || nb.summary.trim().length === 0) {
4713
- sendJson(res, 400, { error: "summary must be a non-empty string" });
4714
- return;
4715
- }
4716
- if (nb.result !== undefined && typeof nb.result !== "string") {
4717
- sendJson(res, 400, { error: "result must be a string when present" });
4718
- return;
4719
- }
4720
- if (nb.seq !== undefined && (typeof nb.seq !== "number" || !Number.isInteger(nb.seq) || nb.seq < 1)) {
4721
- sendJson(res, 400, { error: "seq must be a positive integer when present" });
4722
- return;
4723
- }
4724
- if (nb.source !== undefined && (typeof nb.source !== "string" || nb.source.length > 190)) {
4725
- sendJson(res, 400, { error: "source must be a string of at most 190 characters when present" });
4726
- return;
4727
- }
4728
- if (!deps.sessionStorage?.ownerOf) {
4729
- sendJson(res, 501, { error: "external notify requires the session-ownership face (sessionStorage.ownerOf)" });
4730
- return;
4731
- }
4732
- const notifyOwner = await deps.sessionStorage.ownerOf(notifySession);
4733
- if (notifyOwner === undefined) {
4734
- sendJson(res, 404, { error: "session not found" });
4735
- return;
4736
- }
4737
- const notifyOperator = explicitOperatorOk(principal, deps.config.operatorPrincipals);
4738
- if (!notifyOperator && notifyOwner !== null && notifyOwner !== principal) {
4739
- sendJson(res, 404, { error: "session not found" });
4740
- return;
4741
- }
4742
- const payload = {
4743
- task_id: nb.task_id,
4744
- status: nb.status,
4745
- summary: nb.summary,
4746
- ...(nb.result !== undefined ? { result: nb.result } : {}),
4747
- ...(nb.seq !== undefined ? { seq: nb.seq } : {}),
4748
- ...(nb.source !== undefined ? { source: nb.source } : {}),
4749
- };
4750
- const liveTaskId = await deps.runStore?.getActiveTaskId?.(notifySession).catch(() => undefined);
4751
- const liveStream = liveTaskId !== undefined && liveTaskId !== null ? steerableRuns.get(liveTaskId) : undefined;
4752
- if (liveStream) {
4753
- try {
4754
- await liveStream.notify(payload);
4755
- sendJson(res, 200, { sessionId: notifySession, delivery: "live" });
4756
- return;
4757
- }
4758
- catch (e) {
4759
- const code = e.code;
4760
- if (typeof code === "string" && code.startsWith("notify.")) {
4761
- sendError(res, 400, code, e instanceof Error ? e.message : "invalid notification");
4762
- return;
4763
- }
4764
- }
4765
- }
4766
- if (!deps.workflowCompletionInbox) {
4767
- sendJson(res, 501, { error: "external notify park requires the workflow completion inbox (WORKFLOW_RUN_STORE)" });
4768
- return;
4769
- }
4770
- await deps.workflowCompletionInbox.enqueue(taskNotificationInboxEntry(notifySession, notifyOwner ?? principal ?? null, { task_id: payload.task_id, task_type: "external", status: payload.status, summary: payload.summary, ...(payload.result !== undefined ? { result: payload.result } : {}), ...(payload.seq !== undefined ? { seq: payload.seq } : {}), ...(payload.source !== undefined ? { source: payload.source } : {}) }, Date.now()));
4771
- sendJson(res, 202, { sessionId: notifySession, delivery: "parked", note: "no live stream — parked in the session inbox; drained as a task_notification on the next stream open" });
4772
- return;
4773
- }
4774
- if (req.method === "POST" && /^\/v1\/sessions\/[^/]+\/wake$/.test(url)) {
4775
- if (!deps.checkpointStore) {
4776
- sendJson(res, 501, { error: "wake requires the checkpoint store" });
4777
- return;
4778
- }
4779
- if (rateLimited(req, res) || quotaExceeded(req, res) || (await leaseDenied(req, res)))
4780
- return;
4781
- const principal = gatedPrincipal(req, deps.config);
4782
- if (deps.config.requirePrincipal && principal === undefined) {
4783
- sendJson(res, 401, { error: `missing principal header '${deps.config.principalHeader}'` });
2833
+ if (rateLimited(req, res) || quotaExceeded(req, res) || (await leaseDenied(req, res)))
2834
+ return;
2835
+ const principal = gatedPrincipal(req, deps.config);
2836
+ if (deps.config.requirePrincipal && principal === undefined) {
2837
+ sendJson(res, 401, { error: `missing principal header '${deps.config.principalHeader}'` });
4784
2838
  return;
4785
2839
  }
4786
2840
  const wakeSession = decodeURIComponent(url.split("/")[3]);
@@ -6052,35 +4106,6 @@ async function handleTaskList(res, runStore, query, forceOwner) {
6052
4106
  const nextCursor = hasMore && last ? encodeCursor({ createdAt: last.createdAt, taskId: last.taskId }) : undefined;
6053
4107
  sendJson(res, 200, { tasks, ...(nextCursor ? { nextCursor } : {}) });
6054
4108
  }
6055
- function encodeSessionCursor(c) {
6056
- return Buffer.from(JSON.stringify(c)).toString("base64url");
6057
- }
6058
- function decodeSessionCursor(s) {
6059
- if (!s)
6060
- return undefined;
6061
- try {
6062
- const o = JSON.parse(Buffer.from(s, "base64url").toString());
6063
- return o.lastActivityAt && o.sessionId && !Number.isNaN(Date.parse(o.lastActivityAt))
6064
- ? { lastActivityAt: o.lastActivityAt, sessionId: o.sessionId }
6065
- : undefined;
6066
- }
6067
- catch {
6068
- return undefined;
6069
- }
6070
- }
6071
- async function handleSessionList(res, lister, query, forceOwner) {
6072
- const owner = forceOwner != null ? forceOwner : (query.get("owner") ?? undefined);
6073
- const limit = Math.min(100, Math.max(1, Number(query.get("limit") ?? 50) || 50));
6074
- const cursor = decodeSessionCursor(query.get("cursor"));
6075
- const qRaw = (query.get("q") ?? "").trim();
6076
- const q = qRaw ? qRaw.slice(0, 256) : undefined;
6077
- const rows = await lister({ ...(owner ? { owner } : {}), ...(cursor ? { cursor } : {}), limit: limit + 1, ...(q ? { q } : {}) });
6078
- const hasMore = rows.length > limit;
6079
- const page = hasMore ? rows.slice(0, limit) : rows;
6080
- const last = page[page.length - 1];
6081
- const nextCursor = hasMore && last ? encodeSessionCursor({ lastActivityAt: last.lastActivityAt, sessionId: last.sessionId }) : undefined;
6082
- sendJson(res, 200, { sessions: page, ...(nextCursor ? { nextCursor } : {}) });
6083
- }
6084
4109
  async function handleTaskArtifacts(res, runStore, taskId) {
6085
4110
  const run = await runStore.getRun(taskId);
6086
4111
  if (!run) {
@@ -6217,60 +4242,6 @@ export async function streamApprovals(req, res, cs, scope, pollMs = APPROVALS_ST
6217
4242
  }
6218
4243
  res.end();
6219
4244
  }
6220
- async function streamSseLog(req, res, provider, id, staleMs) {
6221
- const lastId = Number(req.headers["last-event-id"] ?? new URL(req.url ?? "", "http://x").searchParams.get("from") ?? 0);
6222
- let from = Number.isFinite(lastId) ? lastId : 0;
6223
- if (from > 0) {
6224
- const retainedFrom = await provider.retainedFrom(id);
6225
- if (retainedFrom > from + 1) {
6226
- sendJson(res, 416, { error: "resume point evicted past retention", retainedFrom });
6227
- return;
6228
- }
6229
- }
6230
- sseHeaders(res);
6231
- let closed = false;
6232
- req.on("close", () => {
6233
- closed = true;
6234
- });
6235
- const start = Date.now();
6236
- const MAX_MS = 15 * 60 * 1000;
6237
- let lastBeat = Date.now();
6238
- const write = (events) => {
6239
- for (const ev of events) {
6240
- const f = provider.formatEvent(ev);
6241
- res.write(`id: ${f.id}\nevent: ${f.event}\ndata: ${JSON.stringify(f.data)}\n\n`);
6242
- from = ev.seq;
6243
- }
6244
- };
6245
- while (!closed) {
6246
- const [events, row] = await Promise.all([provider.getEvents(id, from), provider.statusOf(id)]);
6247
- write(events);
6248
- if (!row)
6249
- break;
6250
- if (row.status !== "running") {
6251
- write(await provider.getEvents(id, from));
6252
- break;
6253
- }
6254
- const stale = Date.now() - new Date(row.updatedAt).getTime() > staleMs;
6255
- if (stale && events.length === 0) {
6256
- const sf = provider.staleFrame();
6257
- res.write(`event: ${sf.event}\ndata: ${JSON.stringify(sf.data)}\n\n`);
6258
- break;
6259
- }
6260
- if (Date.now() - start > MAX_MS) {
6261
- res.write(`event: error\ndata: ${JSON.stringify({ type: "error", code: "STREAM_MAX_DURATION", message: "stream reached its 15-minute cap — reconnect with Last-Event-ID to continue (the run is still active)" })}\n\n`);
6262
- break;
6263
- }
6264
- if (events.length === 0) {
6265
- if (Date.now() - lastBeat > 15_000) {
6266
- res.write(`event: heartbeat\ndata: {}\n\n`);
6267
- lastBeat = Date.now();
6268
- }
6269
- await sleep(250);
6270
- }
6271
- }
6272
- res.end();
6273
- }
6274
4245
  async function streamRunEvents(req, res, runStore, taskId, staleMs) {
6275
4246
  await streamSseLog(req, res, {
6276
4247
  statusOf: async (id) => {
@@ -6283,210 +4254,6 @@ async function streamRunEvents(req, res, runStore, taskId, staleMs) {
6283
4254
  staleFrame: () => ({ event: "failed", data: { type: "failed", errorMessage: "run stalled (instance lost?)" } }),
6284
4255
  }, taskId, staleMs);
6285
4256
  }
6286
- async function streamBakeEvents(req, res, bakes, bakeId, staleMs) {
6287
- await streamSseLog(req, res, {
6288
- statusOf: async (id) => {
6289
- const bake = await bakes.getBake(id);
6290
- return bake ? { status: bake.status, updatedAt: bake.updatedAt } : undefined;
6291
- },
6292
- getEvents: (id, after) => bakes.getEvents(id, after),
6293
- retainedFrom: (id) => bakes.retainedFrom(id),
6294
- formatEvent: (ev) => ({
6295
- id: ev.seq,
6296
- event: ev.kind,
6297
- data: { kind: ev.kind, seq: ev.seq, ts: ev.ts, ...(ev.level ? { level: ev.level } : {}), ...(ev.data ?? {}) },
6298
- }),
6299
- staleFrame: () => ({ event: "done", data: { kind: "done", status: "FAILED", error: "bake-runner lost (no heartbeat)", digest: null, indexId: null, repo: null, ref: null } }),
6300
- }, bakeId, staleMs);
6301
- }
6302
- function bakeView(b) {
6303
- return {
6304
- bakeId: b.bakeId,
6305
- status: b.status,
6306
- state: b.state,
6307
- profile: b.profile,
6308
- bands: b.bands,
6309
- baseRef: b.baseRef,
6310
- push: b.push,
6311
- dryRun: b.dryRun,
6312
- logs: b.logs,
6313
- digest: b.digest,
6314
- repo: b.repo,
6315
- ref: b.ref,
6316
- indexId: b.indexId,
6317
- exitCode: b.exitCode,
6318
- error: b.error,
6319
- errorCode: b.errorCode,
6320
- manifestSha: b.manifestSha,
6321
- tag: b.tag,
6322
- requestedBy: b.requestedBy,
6323
- createdAt: b.createdAt,
6324
- updatedAt: b.updatedAt,
6325
- };
6326
- }
6327
- function claimResponse(claimed) {
6328
- return {
6329
- bakeId: claimed.bakeId,
6330
- argv: claimed.argv,
6331
- push: claimed.push,
6332
- dryRun: claimed.dryRun,
6333
- logs: claimed.logs,
6334
- profile: claimed.profile,
6335
- bands: claimed.bands,
6336
- ingestSecret: claimed.ingestSecret,
6337
- leaseUntil: claimed.leaseUntil,
6338
- };
6339
- }
6340
- async function ingestBakeLine(bakes, idx, bake, line, registry) {
6341
- const bakeId = bake.bakeId;
6342
- const event = typeof line.event === "string" ? line.event : "";
6343
- const lineOrd = typeof line.lineOrd === "number" ? line.lineOrd : typeof line.line_ord === "number" ? line.line_ord : null;
6344
- if (event !== "done") {
6345
- if (event === "state") {
6346
- const state = typeof line.state === "string" ? line.state : null;
6347
- if (state)
6348
- await bakes.setState(bakeId, state);
6349
- const seq = await bakes.appendEvent(bakeId, "state", { state }, { lineOrd });
6350
- return { status: 200, body: { accepted: true, seq } };
6351
- }
6352
- const kind = event === "manifest" ? "manifest" : event;
6353
- const level = event === "log" && line.level === "raw" ? "raw" : null;
6354
- const { event: _e, lineOrd: _lo, line_ord: _li, ...data } = line;
6355
- const seq = await bakes.appendEvent(bakeId, kind, redactDeep(data), { lineOrd, level });
6356
- return { status: 200, body: { accepted: true, seq } };
6357
- }
6358
- const doneStatus = typeof line.status === "string" ? line.status : "FAILED";
6359
- const exitCode = typeof line.exitCode === "number" ? line.exitCode : doneStatus === "COMPLETE" ? 0 : null;
6360
- const cancelled = doneStatus === "CANCELLED";
6361
- const errorText = typeof line.error === "string" ? redactSecrets(line.error) : null;
6362
- const tag = typeof line.tag === "string" ? line.tag : bake.tag;
6363
- let digest = typeof line.digest === "string" && line.digest.length > 0 ? line.digest : null;
6364
- let indexId = bake.indexId;
6365
- let repo = bake.repo;
6366
- let ref = bake.ref;
6367
- let manifestSha = bake.manifestSha;
6368
- let finalStatus = doneStatus;
6369
- let finalError = errorText;
6370
- let finalExit = exitCode;
6371
- let registerDowngraded = false;
6372
- const registerSteps = [];
6373
- if (doneStatus === "COMPLETE" && digest && idx) {
6374
- const manifestBody = (line.manifestBody ?? line.manifest);
6375
- const fresh = await bakes.getBake(bakeId);
6376
- const nonTerminal = !!fresh && (fresh.status === "queued" || fresh.status === "running");
6377
- if (manifestBody && typeof manifestBody === "object" && nonTerminal) {
6378
- const profile = bake.profile && bake.profile !== "custom" ? bake.profile : (manifestBody.profile && manifestBody.profile !== "custom" ? manifestBody.profile : "custom");
6379
- repo = registry ? `${registry}/${profile}` : `sema-images/${profile}`;
6380
- manifestSha = typeof line.manifestSha === "string" ? line.manifestSha : (typeof line.manifest?.sha256 === "string" ? String(line.manifest.sha256) : bake.manifestSha);
6381
- if (pgHasUnstorable(digest) || pgHasUnstorable(repo) || pgHasUnstorable(manifestSha)) {
6382
- indexId = null;
6383
- registerSteps.push({ step: "register", status: "skipped", reason: "identity fields contained unstorable bytes" });
6384
- }
6385
- else {
6386
- registerSteps.push({ step: "register", status: "running" });
6387
- try {
6388
- const entry = manifestToIndexEntry(manifestBody, { repo, digest, status: "building", manifestSha, sizeBytes: manifestBody.sizeBytes ?? null, signed: false });
6389
- entry.visibility = "public";
6390
- entry.tenantId = null;
6391
- indexId = await idx.registerBuilding(entry);
6392
- ref = `${repo}@${digest}`;
6393
- const bound = await bakes.setIndexId(bakeId, indexId);
6394
- if (!bound)
6395
- indexId = null;
6396
- registerSteps.push({ step: "register", status: "ok" });
6397
- }
6398
- catch (err) {
6399
- indexId = null;
6400
- ref = `${repo}@${digest}`;
6401
- finalStatus = "FAILED";
6402
- registerDowngraded = true;
6403
- finalExit = exitCode ?? 0;
6404
- finalError = redactSecrets(`index register failed after a COMPLETE build (built-but-unregistered partial): ${String(err?.message ?? err)}`);
6405
- registerSteps.push({ step: "register", status: "fail", reason: "index register failed (TiDB?)" });
6406
- }
6407
- }
6408
- }
6409
- }
6410
- const suppliedCode = typeof line.errorCode === "string" && RUNNER_ERROR_CODES.has(line.errorCode)
6411
- ? line.errorCode
6412
- : undefined;
6413
- const errorCode = cancelled
6414
- ? "cancelled"
6415
- : suppliedCode !== undefined && !registerDowngraded
6416
- ? suppliedCode
6417
- : errorCodeForExit(finalExit, cancelled);
6418
- const conflictBands = errorCode === "band-conflict" && Array.isArray(line.conflictBands)
6419
- ? line.conflictBands.filter((b) => typeof b === "string")
6420
- : null;
6421
- const identityInvalid = (typeof digest === "string" && pgHasUnstorable(digest)) ||
6422
- (typeof repo === "string" && pgHasUnstorable(repo)) ||
6423
- (typeof ref === "string" && pgHasUnstorable(ref)) ||
6424
- (typeof manifestSha === "string" && pgHasUnstorable(manifestSha));
6425
- if (identityInvalid) {
6426
- finalStatus = "FAILED";
6427
- digest = null;
6428
- repo = null;
6429
- ref = null;
6430
- manifestSha = null;
6431
- indexId = null;
6432
- finalError = finalError ? `${finalError}; identity fields contained unstorable bytes` : "terminal frame identity fields contained unstorable bytes";
6433
- }
6434
- const finalState = (finalStatus === "COMPLETE" ? "COMPLETE" : finalStatus === "CANCELLED" ? "CANCELLED" : "FAILED");
6435
- const coarse = coarseStatusForState(finalState);
6436
- let affectedRows = 0;
6437
- const terminal = {
6438
- status: coarse === "done" ? "done" : "failed",
6439
- state: finalState,
6440
- digest,
6441
- repo,
6442
- ref,
6443
- indexId,
6444
- exitCode: finalExit,
6445
- error: finalError,
6446
- errorCode,
6447
- manifestSha,
6448
- tag,
6449
- };
6450
- let lastErr;
6451
- for (let attempt = 0; attempt < 3; attempt += 1) {
6452
- try {
6453
- affectedRows = await bakes.setTerminal(bakeId, terminal);
6454
- lastErr = undefined;
6455
- break;
6456
- }
6457
- catch (err) {
6458
- lastErr = err;
6459
- }
6460
- }
6461
- if (lastErr !== undefined) {
6462
- return { status: 200, body: { accepted: true, terminal: false, needsFlip: true } };
6463
- }
6464
- if (affectedRows >= 1) {
6465
- for (const s of registerSteps)
6466
- await bakes.appendEvent(bakeId, "step", s);
6467
- await bakes.appendEvent(bakeId, "done", {
6468
- status: finalStatus,
6469
- digest,
6470
- repo,
6471
- ref,
6472
- indexId,
6473
- tag,
6474
- manifestSha,
6475
- sizeBytes: typeof line.sizeBytes === "number" ? line.sizeBytes : null,
6476
- exitCode: finalExit,
6477
- error: finalError,
6478
- errorCode,
6479
- ...(conflictBands ? { conflictBands } : {}),
6480
- }, { lineOrd });
6481
- }
6482
- return { status: 200, body: { accepted: true, terminal: affectedRows >= 1, indexId } };
6483
- }
6484
- function sleep(ms) {
6485
- return new Promise((r) => setTimeout(r, ms));
6486
- }
6487
- function headerStr(v) {
6488
- return Array.isArray(v) ? v[0] : v;
6489
- }
6490
4257
  function isBillableSubmitPath(url) {
6491
4258
  return (url === "/v1/side-query" ||
6492
4259
  url === "/v1/tasks" ||
@@ -6752,14 +4519,6 @@ function streamFleet(req, res, bus, callerScope, callerSession = null, completio
6752
4519
  res.on("close", shutdown);
6753
4520
  });
6754
4521
  }
6755
- function sseHeaders(res, extra) {
6756
- res.writeHead(200, {
6757
- "content-type": "text/event-stream",
6758
- "cache-control": "no-cache",
6759
- connection: "keep-alive",
6760
- ...extra,
6761
- });
6762
- }
6763
4522
  function routeLabel(_method, url) {
6764
4523
  if (RUN_STEER_RE.test(url))
6765
4524
  return "/v1/runs/:id/steer";
@@ -6806,43 +4565,6 @@ function routeLabel(_method, url) {
6806
4565
  return url;
6807
4566
  return "other";
6808
4567
  }
6809
- function workspaceContentType(relPath) {
6810
- const base = relPath.slice(relPath.lastIndexOf("/") + 1);
6811
- const dot = base.lastIndexOf(".");
6812
- const ext = dot > 0 ? base.slice(dot + 1).toLowerCase() : "";
6813
- const TABLE = {
6814
- txt: "text/plain; charset=utf-8", md: "text/plain; charset=utf-8", log: "text/plain; charset=utf-8",
6815
- json: "application/json; charset=utf-8", csv: "text/csv; charset=utf-8",
6816
- png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp",
6817
- pdf: "application/pdf",
6818
- };
6819
- return TABLE[ext] ?? "application/octet-stream";
6820
- }
6821
- function looksBinary(bytes) {
6822
- const n = Math.min(bytes.byteLength, 8192);
6823
- for (let i = 0; i < n; i++)
6824
- if (bytes[i] === 0)
6825
- return true;
6826
- return false;
6827
- }
6828
- function authorized(req, token) {
6829
- const h = req.headers.authorization ?? "";
6830
- return safeEqual(h, `Bearer ${token}`) || safeEqual(h, `token ${token}`);
6831
- }
6832
- function systemFor(req, config) {
6833
- for (const [tok, system] of Object.entries(config.authTokens ?? {})) {
6834
- if (authorized(req, tok))
6835
- return system;
6836
- }
6837
- if (config.authToken && authorized(req, config.authToken))
6838
- return null;
6839
- return undefined;
6840
- }
6841
- function safeEqual(a, b) {
6842
- const ab = Buffer.from(a, "utf8");
6843
- const bb = Buffer.from(b, "utf8");
6844
- return ab.length === bb.length && timingSafeEqual(ab, bb);
6845
- }
6846
4568
  function cors(res, req, origins, principalHeader) {
6847
4569
  if (!origins || origins.length === 0)
6848
4570
  return;
@@ -6861,14 +4583,4 @@ function cors(res, req, origins, principalHeader) {
6861
4583
  res.setHeader("access-control-allow-methods", "GET, POST, OPTIONS");
6862
4584
  res.setHeader("access-control-allow-headers", `content-type, authorization, last-event-id, ${principalHeader}`);
6863
4585
  }
6864
- function sendJson(res, status, body) {
6865
- res.writeHead(status, { "content-type": "application/json" });
6866
- res.end(JSON.stringify(body));
6867
- }
6868
- function sendError(res, status, errorCode, message, extra) {
6869
- sendJson(res, status, { error: message, errorCode, ...extra });
6870
- }
6871
- function msg(e) {
6872
- return e instanceof Error ? e.message : String(e);
6873
- }
6874
4586
  //# sourceMappingURL=server.js.map