@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.
- package/README.md +9 -0
- package/README.zh-CN.md +7 -0
- package/dist/config-types.d.ts +1 -1
- package/dist/config.d.ts +14 -0
- package/dist/config.js +148 -45
- package/dist/elicitation.js +2 -2
- package/dist/http/route-ctx.d.ts +51 -2
- package/dist/http/routes/approvals-assistant.d.ts +11 -0
- package/dist/http/routes/approvals-assistant.js +530 -0
- package/dist/http/routes/attachments.js +7 -7
- package/dist/http/routes/fleet.d.ts +4 -0
- package/dist/http/routes/fleet.js +147 -0
- package/dist/http/routes/images.js +29 -29
- package/dist/http/routes/leader.d.ts +4 -0
- package/dist/http/routes/leader.js +48 -0
- package/dist/http/routes/memory-policy.js +10 -10
- package/dist/http/routes/notify-wake.d.ts +4 -0
- package/dist/http/routes/notify-wake.js +133 -0
- package/dist/http/routes/observability.js +5 -5
- package/dist/http/routes/runs.d.ts +19 -0
- package/dist/http/routes/runs.js +967 -0
- package/dist/http/routes/session-sync.js +28 -28
- package/dist/http/routes/sessions-list.js +8 -8
- package/dist/http/routes/sessions.js +47 -47
- package/dist/http/routes/side-query.d.ts +4 -0
- package/dist/http/routes/side-query.js +88 -0
- package/dist/http/routes/tasks.d.ts +4 -0
- package/dist/http/routes/tasks.js +632 -0
- package/dist/http/routes/trace-usage.d.ts +4 -0
- package/dist/http/routes/trace-usage.js +239 -0
- package/dist/http/routes/workflows.d.ts +5 -0
- package/dist/http/routes/workflows.js +337 -0
- package/dist/http/run-meta.d.ts +11 -0
- package/dist/http/run-meta.js +16 -0
- package/dist/http/send.d.ts +1 -0
- package/dist/http/send.js +15 -0
- package/dist/http/server.d.ts +6 -5
- package/dist/http/server.js +241 -3166
- package/dist/http/sse-log.js +2 -2
- package/dist/http/wire-types.d.ts +6 -0
- package/dist/leader/endpoint.js +4 -4
- package/dist/main.js +5 -5
- package/dist/plugins/remote-env-host.js +4 -3
- package/dist/question.js +2 -2
- package/dist/run-local.js +1 -1
- package/dist/tool-approval.js +2 -2
- package/dist/trace/ledger-sink.js +1 -1
- package/dist/trace/project.d.ts +1 -0
- package/dist/trace/project.js +3 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16
|
+
sendError(res, 401, "auth.unauthorized", "unauthorized");
|
|
17
17
|
return;
|
|
18
18
|
}
|
|
19
19
|
if (deps.config.memoryEngineBackend === "file" || !deps.memoryExport) {
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
+
sendError(res, 401, "auth.unauthorized", "unauthorized");
|
|
46
46
|
return;
|
|
47
47
|
}
|
|
48
48
|
if (deps.config.memoryEngineBackend === "file" || !deps.memorySync) {
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|