@sema-agent/server 1.316.0 → 1.317.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 (50) hide show
  1. package/README.md +9 -0
  2. package/README.zh-CN.md +7 -0
  3. package/dist/config-types.d.ts +1 -1
  4. package/dist/config.d.ts +14 -0
  5. package/dist/config.js +148 -45
  6. package/dist/elicitation.js +2 -2
  7. package/dist/http/route-ctx.d.ts +51 -2
  8. package/dist/http/routes/approvals-assistant.d.ts +11 -0
  9. package/dist/http/routes/approvals-assistant.js +530 -0
  10. package/dist/http/routes/attachments.js +7 -7
  11. package/dist/http/routes/fleet.d.ts +4 -0
  12. package/dist/http/routes/fleet.js +147 -0
  13. package/dist/http/routes/images.js +29 -29
  14. package/dist/http/routes/leader.d.ts +4 -0
  15. package/dist/http/routes/leader.js +48 -0
  16. package/dist/http/routes/memory-policy.js +10 -10
  17. package/dist/http/routes/notify-wake.d.ts +4 -0
  18. package/dist/http/routes/notify-wake.js +133 -0
  19. package/dist/http/routes/observability.js +5 -5
  20. package/dist/http/routes/runs.d.ts +19 -0
  21. package/dist/http/routes/runs.js +967 -0
  22. package/dist/http/routes/session-sync.js +28 -28
  23. package/dist/http/routes/sessions-list.js +8 -8
  24. package/dist/http/routes/sessions.js +47 -47
  25. package/dist/http/routes/side-query.d.ts +4 -0
  26. package/dist/http/routes/side-query.js +88 -0
  27. package/dist/http/routes/tasks.d.ts +4 -0
  28. package/dist/http/routes/tasks.js +632 -0
  29. package/dist/http/routes/trace-usage.d.ts +4 -0
  30. package/dist/http/routes/trace-usage.js +239 -0
  31. package/dist/http/routes/workflows.d.ts +5 -0
  32. package/dist/http/routes/workflows.js +337 -0
  33. package/dist/http/run-meta.d.ts +11 -0
  34. package/dist/http/run-meta.js +16 -0
  35. package/dist/http/send.d.ts +1 -0
  36. package/dist/http/send.js +15 -0
  37. package/dist/http/server.d.ts +6 -5
  38. package/dist/http/server.js +241 -3166
  39. package/dist/http/sse-log.js +2 -2
  40. package/dist/http/wire-types.d.ts +6 -0
  41. package/dist/leader/endpoint.js +4 -4
  42. package/dist/main.js +5 -5
  43. package/dist/plugins/remote-env-host.js +4 -3
  44. package/dist/question.js +2 -2
  45. package/dist/run-local.js +1 -1
  46. package/dist/tool-approval.js +2 -2
  47. package/dist/trace/ledger-sink.js +1 -1
  48. package/dist/trace/project.d.ts +1 -0
  49. package/dist/trace/project.js +3 -0
  50. package/package.json +1 -1
@@ -0,0 +1,147 @@
1
+ import { taskNotificationFoldKey } from "../../orchestration/workflow-completion-inbox.js";
2
+ import { sendJson, sendError, sseHeaders } from "../send.js";
3
+ import { gatedPrincipal } from "../principal-gate.js";
4
+ export async function handleFleet(req, res, url, ctx) {
5
+ const miss = { fell: false };
6
+ await handleFleetBody(req, res, url, ctx, miss);
7
+ return !miss.fell;
8
+ }
9
+ async function handleFleetBody(req, res, url, ctx, miss) {
10
+ const { deps } = ctx;
11
+ const { sessionOwnerScope } = ctx.helpers;
12
+ void url;
13
+ if (req.method === "GET" && new URL(req.url ?? "", "http://x").pathname === "/v1/fleet/stream") {
14
+ if (!deps.fleetBus) {
15
+ sendError(res, 501, "capability.self_orchestration_required", "fleet stream requires self-orchestration / fleet wiring");
16
+ return;
17
+ }
18
+ const { fleetWide } = sessionOwnerScope(req);
19
+ const principal = gatedPrincipal(req, deps.config);
20
+ if (deps.config.requirePrincipal && !principal && !fleetWide) {
21
+ sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
22
+ return;
23
+ }
24
+ const fleetParams = new URL(req.url ?? "", "http://x").searchParams;
25
+ const callerSession = fleetParams.get("session");
26
+ const observeOnly = fleetParams.get("observe") === "1";
27
+ await streamFleet(req, res, deps.fleetBus, fleetWide ? null : (principal ?? "default"), callerSession || null, observeOnly ? undefined : deps.workflowCompletionInbox);
28
+ return;
29
+ }
30
+ miss.fell = true;
31
+ }
32
+ function streamFleet(req, res, bus, callerScope, callerSession = null, completionInbox) {
33
+ return new Promise((resolve) => {
34
+ sseHeaders(res);
35
+ res.write(`event: meta\ndata: ${JSON.stringify({ version: 1, scoped: callerScope !== null, sessionScoped: callerSession !== null, bgNotifyFailClosed: true })}\n\n`);
36
+ const seenTasks = new Set();
37
+ const seenWf = new Set();
38
+ const sessionOk = (rowSession) => callerSession === null || rowSession === undefined || rowSession === callerSession;
39
+ const visT = (r) => (callerScope === null || r.scope === callerScope) && sessionOk(r.sessionId);
40
+ const visW = (r) => (callerScope === null || r.scope === callerScope) && sessionOk(r.sessionId);
41
+ const stripT = (r) => {
42
+ const { scope: _s, sessionId: _sid, ...rest } = r;
43
+ if (callerSession !== null && r.sessionId === undefined) {
44
+ const { description: _d, agentType: _at, agentName: _an, currentAction: _ca, ...keep } = rest;
45
+ return { ...keep, name: "(unattributed)" };
46
+ }
47
+ return rest;
48
+ };
49
+ const stripW = (r) => {
50
+ const { scope: _s, sessionId: _sid, ...rest } = r;
51
+ if (callerSession !== null && r.sessionId === undefined) {
52
+ const { description: _d, ...keep } = rest;
53
+ return { ...keep, name: "(unattributed)" };
54
+ }
55
+ return rest;
56
+ };
57
+ const send = (event, data) => { if (!res.writableEnded)
58
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); };
59
+ const snap = bus.snapshot();
60
+ const tasks = snap.tasks.filter(visT);
61
+ const workflows = snap.workflows.filter(visW);
62
+ tasks.forEach((r) => seenTasks.add(r.id));
63
+ workflows.forEach((r) => seenWf.add(r.id));
64
+ send("snapshot", { tasks: tasks.map(stripT), workflows: workflows.map(stripW), ts: snap.ts });
65
+ const unsubscribe = bus.subscribe((frame) => {
66
+ switch (frame.type) {
67
+ case "task":
68
+ if (visT(frame.row)) {
69
+ seenTasks.add(frame.row.id);
70
+ send("task", { row: stripT(frame.row), ts: frame.ts });
71
+ }
72
+ break;
73
+ case "workflow":
74
+ if (visW(frame.row)) {
75
+ seenWf.add(frame.row.id);
76
+ send("workflow", { row: stripW(frame.row), ts: frame.ts });
77
+ }
78
+ break;
79
+ case "task_remove":
80
+ if (seenTasks.delete(frame.id))
81
+ send("task_remove", { id: frame.id, ts: frame.ts });
82
+ break;
83
+ case "workflow_remove":
84
+ if (seenWf.delete(frame.id))
85
+ send("workflow_remove", { id: frame.id, ts: frame.ts });
86
+ break;
87
+ case "hook_notice": {
88
+ const hn = frame.notice;
89
+ const hScopeOk = callerScope === null || hn.ownerScope === callerScope;
90
+ const hSessionOk = callerSession === null || hn.ownerSessionId === callerSession;
91
+ if (hScopeOk && hSessionOk) {
92
+ const { ownerScope: _hs, ownerSessionId: _hsid, ...wire } = hn;
93
+ send("hook_notice", { type: "hook_notice", ...wire, ts: frame.ts });
94
+ }
95
+ break;
96
+ }
97
+ case "bg_notification": {
98
+ const n = frame.notification;
99
+ const scopeOk = callerScope === null || n.ownerScope === callerScope;
100
+ const sessionInjectOk = callerSession === null || n.ownerSessionId === callerSession;
101
+ if (scopeOk && sessionInjectOk) {
102
+ const { ownerScope: _os, ownerSessionId: _osid, ...wire } = n;
103
+ send("bg_notification", { type: "bg_notification", ...wire, ts: frame.ts });
104
+ if (completionInbox && n.ownerSessionId && callerSession !== null && callerSession === n.ownerSessionId && !res.writableEnded) {
105
+ const ownerSid = n.ownerSessionId;
106
+ const family = taskNotificationFoldKey({ task_id: n.taskId, status: n.status, ...(n.seq !== undefined ? { seq: n.seq } : {}) });
107
+ const familyUuid = n.sessionId !== undefined && n.sessionId !== n.taskId
108
+ ? taskNotificationFoldKey({ task_id: n.sessionId, status: n.status, ...(n.seq !== undefined ? { seq: n.seq } : {}) })
109
+ : undefined;
110
+ const ackIfAlive = () => {
111
+ if (res.writableEnded || res.socket?.destroyed)
112
+ return;
113
+ void completionInbox.markTerminalServed(ownerSid, family).catch(() => undefined);
114
+ if (familyUuid !== undefined)
115
+ void completionInbox.markTerminalServed(ownerSid, familyUuid).catch(() => undefined);
116
+ };
117
+ res.write(`: ack
118
+
119
+ `, ackIfAlive);
120
+ }
121
+ }
122
+ break;
123
+ }
124
+ default:
125
+ break;
126
+ }
127
+ });
128
+ const hb = setInterval(() => { if (!res.writableEnded)
129
+ res.write(`event: heartbeat\ndata: {}\n\n`); }, 15_000);
130
+ if (typeof hb.unref === "function")
131
+ hb.unref();
132
+ let done = false;
133
+ const shutdown = () => {
134
+ if (done)
135
+ return;
136
+ done = true;
137
+ clearInterval(hb);
138
+ unsubscribe();
139
+ if (!res.writableEnded)
140
+ res.end();
141
+ resolve();
142
+ };
143
+ req.on("close", shutdown);
144
+ res.on("close", shutdown);
145
+ });
146
+ }
147
+ //# sourceMappingURL=fleet.js.map
@@ -62,26 +62,26 @@ async function handleImagesBody(req, res, url, ctx, miss) {
62
62
  const isRunner = source !== undefined && source !== null && source === cfg.runnerPrincipal;
63
63
  if (req.method === "POST" && url === "/v1/images/bakes") {
64
64
  if (!explicitOperator) {
65
- sendJson(res, 403, { error: "operator only" });
65
+ sendError(res, 403, "auth.operator_only", "operator only");
66
66
  return;
67
67
  }
68
68
  const rl = bakeSubmitLimiter.check(`bake:${principal}`);
69
69
  if (!rl.allowed) {
70
70
  res.setHeader("retry-after", String(rl.retryAfterSec));
71
- sendJson(res, 429, { error: "bake submission rate exceeded", retryAfterSec: rl.retryAfterSec });
71
+ sendError(res, 429, "limit.rate_exceeded", "bake submission rate exceeded", { retryAfterSec: rl.retryAfterSec });
72
72
  return;
73
73
  }
74
74
  const body = (await readJson(req));
75
75
  const v = validateBake(body);
76
76
  if (!v.ok) {
77
- sendJson(res, v.status, { error: v.error, ...(v.conflictBands ? { errorCode: "band-conflict", conflictBands: v.conflictBands } : {}) });
77
+ sendError(res, v.status, v.conflictBands ? "band-conflict" : v.status === 501 ? "capability.unavailable" : "request.field_invalid", v.error, { ...(v.conflictBands ? { conflictBands: v.conflictBands } : {}) });
78
78
  return;
79
79
  }
80
80
  let effectiveBaseRef;
81
81
  if (v.baseRef) {
82
82
  const baseEntry = idx ? await idx.getByDigest(v.baseRef) : null;
83
83
  if (!baseEntry || baseEntry.status !== "published" || baseEntry.profile !== SANDBOX_BASE_PROFILE) {
84
- sendJson(res, 400, { error: "baseRef is not a known published sandbox-base digest" });
84
+ sendError(res, 400, "request.unknown_reference", "baseRef is not a known published sandbox-base digest");
85
85
  return;
86
86
  }
87
87
  effectiveBaseRef = v.baseRef;
@@ -90,7 +90,7 @@ async function handleImagesBody(req, res, url, ctx, miss) {
90
90
  effectiveBaseRef = cfg.defaultBaseRef ?? (idx ? (await idx.latestPublished(SANDBOX_BASE_PROFILE, { operator: true }))?.digest ?? null : null);
91
91
  }
92
92
  if (!v.dryRun && !effectiveBaseRef) {
93
- sendJson(res, 503, { error: "no sandbox-base available to build from (set SANDBOX_BASE_REF or publish a sandbox-base image)" });
93
+ sendError(res, 503, "state.no_sandbox_base", "no sandbox-base available to build from (set SANDBOX_BASE_REF or publish a sandbox-base image)");
94
94
  return;
95
95
  }
96
96
  const argv = buildBakeArgv(v, { baseRef: effectiveBaseRef, cacheBase: cfg.cacheBase });
@@ -121,7 +121,7 @@ async function handleImagesBody(req, res, url, ctx, miss) {
121
121
  return;
122
122
  }
123
123
  const busy = await bakes.findActiveAny();
124
- sendJson(res, 409, { error: "a bake is already in progress", ...(busy ? { activeBakeId: busy.bakeId, eventsUrl: `/v1/images/bakes/${busy.bakeId}/events` } : {}) });
124
+ sendError(res, 409, "conflict.bake_in_progress", "a bake is already in progress", { ...(busy ? { activeBakeId: busy.bakeId, eventsUrl: `/v1/images/bakes/${busy.bakeId}/events` } : {}) });
125
125
  return;
126
126
  }
127
127
  sendJson(res, 202, { bakeId: r.bake.bakeId, eventsUrl: `/v1/images/bakes/${r.bake.bakeId}/events`, status: r.bake.status, state: r.bake.state });
@@ -134,13 +134,13 @@ async function handleImagesBody(req, res, url, ctx, miss) {
134
134
  const evMatch = req.method === "GET" ? BAKE_EVENTS_RE.exec(url) : null;
135
135
  if (evMatch) {
136
136
  if (!explicitOperator) {
137
- sendJson(res, 403, { error: "operator only" });
137
+ sendError(res, 403, "auth.operator_only", "operator only");
138
138
  return;
139
139
  }
140
140
  const bakeId = decodeURIComponent(evMatch[1]);
141
141
  const bake = await bakes.getBake(bakeId);
142
142
  if (!bake) {
143
- sendJson(res, 404, { error: "bake not found" });
143
+ sendError(res, 404, "not_found.bake", "bake not found");
144
144
  return;
145
145
  }
146
146
  await streamBakeEvents(req, res, bakes, bakeId, cfg.staleMs);
@@ -149,13 +149,13 @@ async function handleImagesBody(req, res, url, ctx, miss) {
149
149
  const cancelMatch = req.method === "POST" ? BAKE_CANCEL_RE.exec(url) : null;
150
150
  if (cancelMatch) {
151
151
  if (!explicitOperator) {
152
- sendJson(res, 403, { error: "operator only" });
152
+ sendError(res, 403, "auth.operator_only", "operator only");
153
153
  return;
154
154
  }
155
155
  const bakeId = decodeURIComponent(cancelMatch[1]);
156
156
  const bake = await bakes.getBake(bakeId);
157
157
  if (!bake) {
158
- sendJson(res, 404, { error: "bake not found" });
158
+ sendError(res, 404, "not_found.bake", "bake not found");
159
159
  return;
160
160
  }
161
161
  const flagged = await bakes.requestCancel(bakeId);
@@ -164,7 +164,7 @@ async function handleImagesBody(req, res, url, ctx, miss) {
164
164
  }
165
165
  if (req.method === "POST" && url === "/v1/images/bakes/claim") {
166
166
  if (!isRunner) {
167
- sendJson(res, 403, { error: "bake-runner only" });
167
+ sendError(res, 403, "auth.bake_runner_only", "bake-runner only");
168
168
  return;
169
169
  }
170
170
  const body = (await readJson(req));
@@ -186,7 +186,7 @@ async function handleImagesBody(req, res, url, ctx, miss) {
186
186
  const claimMatch = req.method === "POST" ? BAKE_CLAIM_RE.exec(url) : null;
187
187
  if (claimMatch) {
188
188
  if (!isRunner) {
189
- sendJson(res, 403, { error: "bake-runner only" });
189
+ sendError(res, 403, "auth.bake_runner_only", "bake-runner only");
190
190
  return;
191
191
  }
192
192
  const bakeId = decodeURIComponent(claimMatch[1]);
@@ -194,7 +194,7 @@ async function handleImagesBody(req, res, url, ctx, miss) {
194
194
  const runnerId = typeof body.runnerId === "string" && body.runnerId.length > 0 ? body.runnerId : (principal ?? source ?? cfg.runnerPrincipal);
195
195
  const claimed = await bakes.claimBake(bakeId, runnerId, cfg.leaseMs);
196
196
  if (!claimed) {
197
- sendJson(res, 409, { error: "bake not claimable (already claimed or the build lease is held)" });
197
+ sendError(res, 409, "conflict.bake_not_claimable", "bake not claimable (already claimed or the build lease is held)");
198
198
  return;
199
199
  }
200
200
  sendJson(res, 200, claimResponse(claimed));
@@ -203,30 +203,30 @@ async function handleImagesBody(req, res, url, ctx, miss) {
203
203
  const ingestMatch = req.method === "POST" ? BAKE_INGEST_RE.exec(url) : null;
204
204
  if (ingestMatch) {
205
205
  if (!isRunner) {
206
- sendJson(res, 403, { error: "bake-runner only" });
206
+ sendError(res, 403, "auth.bake_runner_only", "bake-runner only");
207
207
  return;
208
208
  }
209
209
  const bakeId = decodeURIComponent(ingestMatch[1]);
210
210
  const bake = await bakes.getBake(bakeId);
211
211
  if (!bake) {
212
- sendJson(res, 404, { error: "bake not found" });
212
+ sendError(res, 404, "not_found.bake", "bake not found");
213
213
  return;
214
214
  }
215
215
  const presented = headerStr(req.headers["x-bake-ingest-secret"]);
216
216
  if (!bake.ingestSecret || !presented || !safeEqual(presented, bake.ingestSecret)) {
217
- sendJson(res, 403, { error: "invalid ingest secret" });
217
+ sendError(res, 403, "auth.ingest_secret_invalid", "invalid ingest secret");
218
218
  return;
219
219
  }
220
220
  const line = (await readJson(req));
221
221
  if (line.event === "heartbeat") {
222
222
  const fresh = await bakes.getBake(bakeId);
223
223
  if (!fresh || fresh.status !== "running") {
224
- sendJson(res, 409, { accepted: false, leaseValid: false, cancelRequested: fresh?.cancelRequested ?? false });
224
+ sendJson(res, 409, { accepted: false, leaseValid: false, cancelRequested: fresh?.cancelRequested ?? false, errorCode: "conflict.bake_not_running" });
225
225
  return;
226
226
  }
227
227
  const ok = bake.runnerId ? await bakes.heartbeatLease(bakeId, bake.runnerId, cfg.leaseMs) : false;
228
228
  if (!ok) {
229
- sendJson(res, 409, { accepted: false, leaseValid: false, cancelRequested: fresh.cancelRequested });
229
+ sendJson(res, 409, { accepted: false, leaseValid: false, cancelRequested: fresh.cancelRequested, errorCode: "conflict.bake_lease_lost" });
230
230
  return;
231
231
  }
232
232
  sendJson(res, 200, { accepted: true, leaseValid: true, cancelRequested: fresh.cancelRequested });
@@ -234,7 +234,7 @@ async function handleImagesBody(req, res, url, ctx, miss) {
234
234
  }
235
235
  const fresh = await bakes.getBake(bakeId);
236
236
  if (!fresh || fresh.status !== "running") {
237
- sendJson(res, 409, { accepted: false, leaseValid: false, cancelRequested: fresh?.cancelRequested ?? false });
237
+ sendJson(res, 409, { accepted: false, leaseValid: false, cancelRequested: fresh?.cancelRequested ?? false, errorCode: "conflict.bake_not_running" });
238
238
  return;
239
239
  }
240
240
  if (fresh.runnerId) {
@@ -248,19 +248,19 @@ async function handleImagesBody(req, res, url, ctx, miss) {
248
248
  const idMatch = req.method === "GET" ? BAKE_ID_RE.exec(url) : null;
249
249
  if (idMatch) {
250
250
  if (!explicitOperator) {
251
- sendJson(res, 403, { error: "operator only" });
251
+ sendError(res, 403, "auth.operator_only", "operator only");
252
252
  return;
253
253
  }
254
254
  const bakeId = decodeURIComponent(idMatch[1]);
255
255
  const bake = await bakes.getBake(bakeId);
256
256
  if (!bake) {
257
- sendJson(res, 404, { error: "bake not found" });
257
+ sendError(res, 404, "not_found.bake", "bake not found");
258
258
  return;
259
259
  }
260
260
  sendJson(res, 200, { bake: bakeView(bake) });
261
261
  return;
262
262
  }
263
- sendJson(res, 404, { error: "not found" });
263
+ sendError(res, 404, "not_found.route", "not found");
264
264
  return;
265
265
  }
266
266
  if (deps.imageIndex && url.startsWith("/v1/images")) {
@@ -287,12 +287,12 @@ async function handleImagesBody(req, res, url, ctx, miss) {
287
287
  const body = (await readJson(req));
288
288
  const profile = typeof body.profile === "string" ? body.profile : "";
289
289
  if (!profile) {
290
- sendJson(res, 400, { error: "profile is required" });
290
+ sendError(res, 400, "request.field_invalid", "profile is required");
291
291
  return;
292
292
  }
293
293
  const entry = await idx.latestPublished(profile, viewer);
294
294
  if (!entry) {
295
- sendJson(res, 404, { error: `no published image for profile '${profile}' visible to this principal` });
295
+ sendError(res, 404, "not_found.image", `no published image for profile '${profile}' visible to this principal`);
296
296
  return;
297
297
  }
298
298
  const needed = Array.isArray(body.capabilitiesNeeded)
@@ -301,7 +301,7 @@ async function handleImagesBody(req, res, url, ctx, miss) {
301
301
  const caps = entry.capabilities;
302
302
  const missing = needed.filter((c) => !caps[c]);
303
303
  if (missing.length > 0) {
304
- sendJson(res, 409, { error: "capabilities not satisfied by the image", missing });
304
+ sendError(res, 409, "conflict.image_capabilities_unsatisfied", "capabilities not satisfied by the image", { missing });
305
305
  return;
306
306
  }
307
307
  sendJson(res, 200, {
@@ -317,12 +317,12 @@ async function handleImagesBody(req, res, url, ctx, miss) {
317
317
  }
318
318
  if (req.method === "POST" && url === "/v1/images/register") {
319
319
  if (!explicitOperatorOk(principal, deps.config.operatorPrincipals)) {
320
- sendJson(res, 403, { error: "operator only" });
320
+ sendError(res, 403, "auth.operator_only", "operator only");
321
321
  return;
322
322
  }
323
323
  const b = (await readJson(req));
324
324
  if (typeof b.profile !== "string" || typeof b.repo !== "string" || typeof b.digest !== "string") {
325
- sendJson(res, 400, { error: "profile, repo, digest are required" });
325
+ sendError(res, 400, "request.field_invalid", "profile, repo, digest are required");
326
326
  return;
327
327
  }
328
328
  const id = await idx.registerBuilding({
@@ -352,7 +352,7 @@ async function handleImagesBody(req, res, url, ctx, miss) {
352
352
  if (digMatch) {
353
353
  const entry = await idx.getByDigest(decodeURIComponent(digMatch[1]));
354
354
  if (!entry || (!viewer.operator && entry.visibility === "tenant" && entry.tenantId !== viewer.tenantId)) {
355
- sendJson(res, 404, { error: "not found" });
355
+ sendError(res, 404, "not_found.image", "not found");
356
356
  return;
357
357
  }
358
358
  sendJson(res, 200, { image: entry });
@@ -362,7 +362,7 @@ async function handleImagesBody(req, res, url, ctx, miss) {
362
362
  if (profMatch) {
363
363
  const entry = await idx.latestPublished(decodeURIComponent(profMatch[1]), viewer);
364
364
  if (!entry) {
365
- sendJson(res, 404, { error: "not found" });
365
+ sendError(res, 404, "not_found.image", "not found");
366
366
  return;
367
367
  }
368
368
  sendJson(res, 200, { image: entry });
@@ -0,0 +1,4 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { RouteCtx } from "../route-ctx.js";
3
+ export declare function handleLeader(req: IncomingMessage, res: ServerResponse, url: string, ctx: RouteCtx): Promise<boolean>;
4
+ //# sourceMappingURL=leader.d.ts.map
@@ -0,0 +1,48 @@
1
+ import { sendJson, sendError } from "../send.js";
2
+ import { gatedPrincipal } from "../principal-gate.js";
3
+ export async function handleLeader(req, res, url, ctx) {
4
+ const miss = { fell: false };
5
+ await handleLeaderBody(req, res, url, ctx, miss);
6
+ return !miss.fell;
7
+ }
8
+ async function handleLeaderBody(req, res, url, ctx, miss) {
9
+ const { deps } = ctx;
10
+ const { readJson, rateLimited, quotaExceeded, leaseDenied } = ctx.helpers;
11
+ if (deps.leaderEndpoint) {
12
+ const isLeaderPost = req.method === "POST" && url === "/v1/leader";
13
+ const isLeaderGet = req.method === "GET" && /^\/v1\/leader\/[^/]+$/.test(url);
14
+ if (isLeaderPost || isLeaderGet) {
15
+ if (deps.config.requirePrincipal && !gatedPrincipal(req, deps.config)) {
16
+ sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
17
+ return;
18
+ }
19
+ const requester = gatedPrincipal(req, deps.config) ?? null;
20
+ if (isLeaderPost) {
21
+ if (rateLimited(req, res) || quotaExceeded(req, res) || (await leaseDenied(req, res)))
22
+ return;
23
+ let body;
24
+ try {
25
+ body = await readJson(req);
26
+ }
27
+ catch {
28
+ sendError(res, 400, "request.invalid_json", "invalid JSON body");
29
+ return;
30
+ }
31
+ const r = deps.leaderEndpoint.handle("POST", url, body, requester);
32
+ if (r) {
33
+ sendJson(res, r.status, r.body);
34
+ return;
35
+ }
36
+ }
37
+ else {
38
+ const r = deps.leaderEndpoint.handle("GET", url, undefined, requester);
39
+ if (r) {
40
+ sendJson(res, r.status, r.body);
41
+ return;
42
+ }
43
+ }
44
+ }
45
+ }
46
+ miss.fell = true;
47
+ }
48
+ //# sourceMappingURL=leader.js.map
@@ -13,20 +13,20 @@ async function handleMemoryPolicyBody(req, res, url, ctx, miss) {
13
13
  if (url === "/v1/memory/export" && req.method === "GET") {
14
14
  const principal = gatedPrincipal(req, deps.config);
15
15
  if (!principal) {
16
- sendJson(res, 401, { error: "unauthorized" });
16
+ sendError(res, 401, "auth.unauthorized", "unauthorized");
17
17
  return;
18
18
  }
19
19
  if (deps.config.memoryEngineBackend === "file" || !deps.memoryExport) {
20
- sendJson(res, 501, { error: "memory export requires a DB memory backend (MEMORY_ENGINE_BACKEND=pg|tidb)" });
20
+ sendError(res, 501, "capability.memory_store_required", "memory export requires a DB memory backend (MEMORY_ENGINE_BACKEND=pg|tidb)");
21
21
  return;
22
22
  }
23
23
  const scope = new URL(req.url ?? "", "http://x").searchParams.get("scope");
24
24
  if (!scope) {
25
- sendJson(res, 400, { error: "missing ?scope=<key>" });
25
+ sendError(res, 400, "request.query_invalid", "missing ?scope=<key>");
26
26
  return;
27
27
  }
28
28
  if (scope !== formatUserScope(principal) && !explicitOperatorOk(principal, deps.config.operatorPrincipals)) {
29
- sendJson(res, 404, { error: "not found" });
29
+ sendError(res, 404, "not_found.memory_scope", "not found");
30
30
  return;
31
31
  }
32
32
  try {
@@ -35,18 +35,18 @@ async function handleMemoryPolicyBody(req, res, url, ctx, miss) {
35
35
  }
36
36
  catch (err) {
37
37
  deps.logger?.warn?.("memory_export_failed", { scope, err: String(err) });
38
- sendJson(res, 500, { error: "memory export failed" });
38
+ sendError(res, 500, "internal.error", "memory export failed");
39
39
  }
40
40
  return;
41
41
  }
42
42
  if (req.method === "POST" && url.startsWith("/v1/memory/sync/")) {
43
43
  const principal = gatedPrincipal(req, deps.config);
44
44
  if (!principal) {
45
- sendJson(res, 401, { error: "unauthorized" });
45
+ sendError(res, 401, "auth.unauthorized", "unauthorized");
46
46
  return;
47
47
  }
48
48
  if (deps.config.memoryEngineBackend === "file" || !deps.memorySync) {
49
- sendJson(res, 501, { error: "memory sync requires a DB memory backend (MEMORY_ENGINE_BACKEND=pg|tidb)" });
49
+ sendError(res, 501, "capability.memory_store_required", "memory sync requires a DB memory backend (MEMORY_ENGINE_BACKEND=pg|tidb)");
50
50
  return;
51
51
  }
52
52
  let scope;
@@ -62,7 +62,7 @@ async function handleMemoryPolicyBody(req, res, url, ctx, miss) {
62
62
  return;
63
63
  }
64
64
  if (scope !== formatUserScope(principal) && !explicitOperatorOk(principal, deps.config.operatorPrincipals)) {
65
- sendJson(res, 404, { error: "not found" });
65
+ sendError(res, 404, "not_found.memory_scope", "not found");
66
66
  return;
67
67
  }
68
68
  const body = await readJson(req);
@@ -77,14 +77,14 @@ async function handleMemoryPolicyBody(req, res, url, ctx, miss) {
77
77
  }
78
78
  catch (err) {
79
79
  deps.logger?.warn?.("memory_sync_failed", { scope, peer: parsed.value.peer, err: String(err) });
80
- sendJson(res, 500, { error: "memory sync failed" });
80
+ sendError(res, 500, "internal.error", "memory sync failed");
81
81
  }
82
82
  return;
83
83
  }
84
84
  if (url === "/v1/policy" && req.method === "GET") {
85
85
  const principal = gatedPrincipal(req, deps.config);
86
86
  if (deps.config.requirePrincipal && !principal) {
87
- sendJson(res, 401, { error: `missing principal header '${deps.config.principalHeader}'` });
87
+ sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
88
88
  return;
89
89
  }
90
90
  sendJson(res, 200, {
@@ -0,0 +1,4 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { RouteCtx } from "../route-ctx.js";
3
+ export declare function handleNotifyWake(req: IncomingMessage, res: ServerResponse, url: string, ctx: RouteCtx): Promise<boolean>;
4
+ //# sourceMappingURL=notify-wake.d.ts.map
@@ -0,0 +1,133 @@
1
+ import { taskNotificationInboxEntry } from "../../orchestration/workflow-completion-inbox.js";
2
+ import { sendJson, sendError } from "../send.js";
3
+ import { gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
4
+ export async function handleNotifyWake(req, res, url, ctx) {
5
+ const miss = { fell: false };
6
+ await handleNotifyWakeBody(req, res, url, ctx, miss);
7
+ return !miss.fell;
8
+ }
9
+ async function handleNotifyWakeBody(req, res, url, ctx, miss) {
10
+ const { deps } = ctx;
11
+ const { steerableRuns } = ctx.registry;
12
+ const { readJson, rateLimited, quotaExceeded, leaseDenied } = ctx.helpers;
13
+ const { resumeWake } = ctx.legs;
14
+ if (req.method === "POST" && /^\/v1\/sessions\/[^/]+\/notify$/.test(url)) {
15
+ if (rateLimited(req, res) || quotaExceeded(req, res))
16
+ return;
17
+ const principal = gatedPrincipal(req, deps.config);
18
+ if (deps.config.requirePrincipal && principal === undefined) {
19
+ sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
20
+ return;
21
+ }
22
+ const notifySession = decodeURIComponent(url.split("/")[3]);
23
+ let nb;
24
+ try {
25
+ nb = (await readJson(req));
26
+ }
27
+ catch {
28
+ sendError(res, 400, "request.body_shape", "body must be JSON: { task_id, status, summary, result?, seq?, source? }");
29
+ return;
30
+ }
31
+ if (typeof nb.task_id !== "string" || nb.task_id.trim().length === 0 || nb.task_id.length > 190) {
32
+ sendError(res, 400, "request.field_invalid", "task_id must be a non-empty string of at most 190 characters");
33
+ return;
34
+ }
35
+ const NOTIFY_STATUSES = ["completed", "failed", "killed", "cancelled", "event"];
36
+ if (typeof nb.status !== "string" || !NOTIFY_STATUSES.includes(nb.status)) {
37
+ sendError(res, 400, "request.field_invalid", `status must be one of ${NOTIFY_STATUSES.join("/")}`);
38
+ return;
39
+ }
40
+ if (typeof nb.summary !== "string" || nb.summary.trim().length === 0) {
41
+ sendError(res, 400, "request.field_invalid", "summary must be a non-empty string");
42
+ return;
43
+ }
44
+ if (nb.result !== undefined && typeof nb.result !== "string") {
45
+ sendError(res, 400, "request.field_invalid", "result must be a string when present");
46
+ return;
47
+ }
48
+ if (nb.seq !== undefined && (typeof nb.seq !== "number" || !Number.isInteger(nb.seq) || nb.seq < 1)) {
49
+ sendError(res, 400, "request.field_invalid", "seq must be a positive integer when present");
50
+ return;
51
+ }
52
+ if (nb.source !== undefined && (typeof nb.source !== "string" || nb.source.length > 190)) {
53
+ sendError(res, 400, "request.field_invalid", "source must be a string of at most 190 characters when present");
54
+ return;
55
+ }
56
+ if (!deps.sessionStorage?.ownerOf) {
57
+ sendError(res, 501, "capability.session_ownership_required", "external notify requires the session-ownership face (sessionStorage.ownerOf)");
58
+ return;
59
+ }
60
+ const notifyOwner = await deps.sessionStorage.ownerOf(notifySession);
61
+ if (notifyOwner === undefined) {
62
+ sendError(res, 404, "not_found.session", "session not found");
63
+ return;
64
+ }
65
+ const notifyOperator = explicitOperatorOk(principal, deps.config.operatorPrincipals);
66
+ if (!notifyOperator && notifyOwner !== null && notifyOwner !== principal) {
67
+ sendError(res, 404, "not_found.session", "session not found");
68
+ return;
69
+ }
70
+ const payload = {
71
+ task_id: nb.task_id,
72
+ status: nb.status,
73
+ summary: nb.summary,
74
+ ...(nb.result !== undefined ? { result: nb.result } : {}),
75
+ ...(nb.seq !== undefined ? { seq: nb.seq } : {}),
76
+ ...(nb.source !== undefined ? { source: nb.source } : {}),
77
+ };
78
+ const liveTaskId = await deps.runStore?.getActiveTaskId?.(notifySession).catch(() => undefined);
79
+ const liveStream = liveTaskId !== undefined && liveTaskId !== null ? steerableRuns.get(liveTaskId) : undefined;
80
+ if (liveStream) {
81
+ try {
82
+ await liveStream.notify(payload);
83
+ sendJson(res, 200, { sessionId: notifySession, delivery: "live" });
84
+ return;
85
+ }
86
+ catch (e) {
87
+ const code = e.code;
88
+ if (typeof code === "string" && code.startsWith("notify.")) {
89
+ sendError(res, 400, code, e instanceof Error ? e.message : "invalid notification");
90
+ return;
91
+ }
92
+ }
93
+ }
94
+ if (!deps.workflowCompletionInbox) {
95
+ sendError(res, 501, "capability.workflow_store_required", "external notify park requires the workflow completion inbox (WORKFLOW_RUN_STORE)");
96
+ return;
97
+ }
98
+ 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()));
99
+ 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" });
100
+ return;
101
+ }
102
+ if (req.method === "POST" && /^\/v1\/sessions\/[^/]+\/wake$/.test(url)) {
103
+ if (!deps.checkpointStore) {
104
+ sendError(res, 501, "capability.checkpoint_store_required", "wake requires the checkpoint store");
105
+ return;
106
+ }
107
+ if (rateLimited(req, res) || quotaExceeded(req, res) || (await leaseDenied(req, res)))
108
+ return;
109
+ const principal = gatedPrincipal(req, deps.config);
110
+ if (deps.config.requirePrincipal && principal === undefined) {
111
+ sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
112
+ return;
113
+ }
114
+ const wakeSession = decodeURIComponent(url.split("/")[3]);
115
+ let wakeBody;
116
+ try {
117
+ wakeBody = (await readJson(req));
118
+ }
119
+ catch {
120
+ sendError(res, 400, "request.body_shape", "body must be JSON: { message?: string }");
121
+ return;
122
+ }
123
+ if (wakeBody.message !== undefined && (typeof wakeBody.message !== "string" || wakeBody.message.length === 0)) {
124
+ sendError(res, 400, "request.field_invalid", "message must be a non-empty string when present");
125
+ return;
126
+ }
127
+ const out = await resumeWake(wakeSession, wakeBody.message, principal, req);
128
+ sendJson(res, out.status, out.body);
129
+ return;
130
+ }
131
+ miss.fell = true;
132
+ }
133
+ //# sourceMappingURL=notify-wake.js.map