@claudexor/control-api 1.0.1
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/LICENSE +21 -0
- package/README.md +7 -0
- package/dist/artifact-paths.d.ts +3 -0
- package/dist/artifact-paths.d.ts.map +1 -0
- package/dist/artifact-paths.js +36 -0
- package/dist/artifact-paths.js.map +1 -0
- package/dist/candidates.d.ts +3 -0
- package/dist/candidates.d.ts.map +1 -0
- package/dist/candidates.js +77 -0
- package/dist/candidates.js.map +1 -0
- package/dist/daemon-server.d.ts +196 -0
- package/dist/daemon-server.d.ts.map +1 -0
- package/dist/daemon-server.js +1971 -0
- package/dist/daemon-server.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/run-events-stream.d.ts +28 -0
- package/dist/run-events-stream.d.ts.map +1 -0
- package/dist/run-events-stream.js +146 -0
- package/dist/run-events-stream.js.map +1 -0
- package/dist/run-start.d.ts +14 -0
- package/dist/run-start.d.ts.map +1 -0
- package/dist/run-start.js +99 -0
- package/dist/run-start.js.map +1 -0
- package/dist/run-timeline.d.ts +22 -0
- package/dist/run-timeline.d.ts.map +1 -0
- package/dist/run-timeline.js +155 -0
- package/dist/run-timeline.js.map +1 -0
- package/dist/sse-shared.d.ts +11 -0
- package/dist/sse-shared.d.ts.map +1 -0
- package/dist/sse-shared.js +47 -0
- package/dist/sse-shared.js.map +1 -0
- package/dist/thread-projection.d.ts +12 -0
- package/dist/thread-projection.d.ts.map +1 -0
- package/dist/thread-projection.js +83 -0
- package/dist/thread-projection.js.map +1 -0
- package/dist/thread-turn-routes.d.ts +63 -0
- package/dist/thread-turn-routes.d.ts.map +1 -0
- package/dist/thread-turn-routes.js +317 -0
- package/dist/thread-turn-routes.js.map +1 -0
- package/package.json +45 -0
|
@@ -0,0 +1,1971 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { basename, extname, join, relative, sep } from "node:path";
|
|
5
|
+
import { checkPatch, deliver, deriveApplyEligibility, revertInPlace, validateApplyGate } from "@claudexor/delivery";
|
|
6
|
+
import { appendRunEvent, lastSeqInFile } from "@claudexor/event-log";
|
|
7
|
+
import { safeArtifactPath, safeArtifactRoot } from "./artifact-paths.js";
|
|
8
|
+
import { TERMINAL_STATES, redactedSseLine } from "./sse-shared.js";
|
|
9
|
+
import { streamRunEvents } from "./run-events-stream.js";
|
|
10
|
+
import { eventPayload, latestPlanProgress, readRunEvents, timelineEvents } from "./run-timeline.js";
|
|
11
|
+
import { projectSession, projectThread, projectTurn, turnRunCard } from "./thread-projection.js";
|
|
12
|
+
import { handleThreadTurnCreate, handleThreadTurnRetry, recordTurnEnqueueFailure } from "./thread-turn-routes.js";
|
|
13
|
+
import { normalizeRunStart, validateAbsoluteRepoRoot, validateDirectRunAttachments } from "./run-start.js";
|
|
14
|
+
export { normalizeRunStartRequest } from "./run-start.js";
|
|
15
|
+
import { candidatesFor } from "./candidates.js";
|
|
16
|
+
import { AccessProfile, ControlWebEvidence, ControlApplyCheckRequest, ControlApplyRequest, AgentCapabilityCatalog, ControlHarnessListResponse, ControlHarnessModelsResponse, ControlSetupJob, ControlSetupJobConfirmRequest, ControlSetupJobCreateRequest, ControlSetupJobEvent, ControlSetupJobListResponse, ControlSpecFreezeRequest, ControlSpecQuestionsRequest, ControlSecretListResponse, ControlRunStartRequest, ControlRunStartInfo, ControlQueuedRunInfo, ControlRunControlRequest, ControlRunControlResponse, ControlReviewerPanelEntry, ControlRunDetail, ControlRunSummary, ControlRunResult, ControlPrimaryOutput, ControlBudgetSnapshot, ControlSettingsSnapshot, ControlSettingsUpdateRequest, ControlTrustListResponse, ControlTrustState, ControlTrustUpdateRequest, ControlInteractionAnswerRequest, ControlInteractionAnswerResponse, ControlRunDecisionRequest, ControlRunDecisionResponse, ControlThreadCreateRequest, ControlThreadUpdateRequest, ControlThreadApplyRequest, ControlThreadApplyResponse, ControlThreadDetail, ControlThreadListResponse, DecisionRecord, ModeKind, OrchestratePlanProgress, Portfolio, ReviewFinding, RunFailure, RunTelemetry, TaskContract, ProtectedPathApproval, WorkProduct, } from "@claudexor/schema";
|
|
17
|
+
import { assertNoInlineSecretValues, containsSecretLikeToken, errorCode, noProjectRepoRoot, nowIso, redactSecrets, sha256 } from "@claudexor/util";
|
|
18
|
+
import { MANAGED_SECRET_NAMES, isManagedSecretName } from "@claudexor/secrets";
|
|
19
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
20
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
|
|
21
|
+
// TERMINAL_STATES/readNewLines/redactedSseLine live in sse-shared.ts (used by
|
|
22
|
+
// both the per-run stream module and this server).
|
|
23
|
+
/** Artifact fetch cap: large logs are read from disk, not streamed through the facade. */
|
|
24
|
+
const MAX_ARTIFACT_FETCH_BYTES = 4 * 1024 * 1024;
|
|
25
|
+
/** Larger cap for binary artifacts (images, etc.): they are naturally bounded and
|
|
26
|
+
* the small cap exists to protect the event loop from multi-MB text logs, not binaries. */
|
|
27
|
+
const MAX_ARTIFACT_BINARY_FETCH_BYTES = 32 * 1024 * 1024;
|
|
28
|
+
const NO_PROJECT_ROOT = noProjectRepoRoot();
|
|
29
|
+
function hostIsLoopback(hostHeader) {
|
|
30
|
+
if (!hostHeader)
|
|
31
|
+
return false;
|
|
32
|
+
const h = hostHeader.trim();
|
|
33
|
+
const host = h.startsWith("[") ? h.slice(1, h.indexOf("]")) : (h.split(":")[0] ?? "");
|
|
34
|
+
return LOOPBACK_HOSTS.has(host.toLowerCase());
|
|
35
|
+
}
|
|
36
|
+
function originIsLoopback(origin) {
|
|
37
|
+
if (!origin)
|
|
38
|
+
return true;
|
|
39
|
+
try {
|
|
40
|
+
const host = new URL(origin).hostname.toLowerCase().replace(/^\[(.*)\]$/, "$1");
|
|
41
|
+
return LOOPBACK_HOSTS.has(host);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* HTTP/SSE facade over the durable daemon. Canonical run/job state comes from the
|
|
49
|
+
* daemon (`jobs.json` over the unix-socket JSON-RPC API). This server is a live
|
|
50
|
+
* viewport only: POST/GET/cancel delegate to daemon, and event streams replay/tail
|
|
51
|
+
* the canonical `.claudexor/runs/<runId>/events.jsonl` file.
|
|
52
|
+
*/
|
|
53
|
+
export class DaemonControlApiServer {
|
|
54
|
+
opts;
|
|
55
|
+
server;
|
|
56
|
+
sseClients = new Set();
|
|
57
|
+
/** Per-thread turn submission chains (serialize head_run_id lineage updates). */
|
|
58
|
+
threadTurnChains = new Map();
|
|
59
|
+
constructor(opts) {
|
|
60
|
+
this.opts = opts;
|
|
61
|
+
}
|
|
62
|
+
async start() {
|
|
63
|
+
const host = this.opts.host ?? "127.0.0.1";
|
|
64
|
+
const port = this.opts.port ?? 0;
|
|
65
|
+
await new Promise((resolve, reject) => {
|
|
66
|
+
this.server = createServer((req, res) => this.onRequest(req, res));
|
|
67
|
+
this.server.once("error", reject);
|
|
68
|
+
this.server.listen(port, host, () => resolve());
|
|
69
|
+
});
|
|
70
|
+
const addr = this.server?.address();
|
|
71
|
+
return { host, port: typeof addr === "object" && addr ? addr.port : port };
|
|
72
|
+
}
|
|
73
|
+
async stop() {
|
|
74
|
+
for (const res of this.sseClients) {
|
|
75
|
+
try {
|
|
76
|
+
res.end();
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
/* closed */
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
this.sseClients.clear();
|
|
83
|
+
await new Promise((resolve) => {
|
|
84
|
+
if (!this.server)
|
|
85
|
+
return resolve();
|
|
86
|
+
this.server.close(() => resolve());
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
tokenMatches(provided) {
|
|
90
|
+
if (!provided)
|
|
91
|
+
return false;
|
|
92
|
+
const a = Buffer.from(provided);
|
|
93
|
+
const b = Buffer.from(this.opts.token);
|
|
94
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
95
|
+
}
|
|
96
|
+
authorized(req) {
|
|
97
|
+
if (!hostIsLoopback(req.headers.host) || !originIsLoopback(req.headers.origin))
|
|
98
|
+
return false;
|
|
99
|
+
const m = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? "");
|
|
100
|
+
return this.tokenMatches(m?.[1]?.trim());
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* ONE owner of the bad-request envelope: status from the typed error,
|
|
104
|
+
* message text, and the machine-readable `code` (e.g.
|
|
105
|
+
* inline_secret_rejected) when the error carries one.
|
|
106
|
+
*/
|
|
107
|
+
requestError(res, err) {
|
|
108
|
+
const status = err && typeof err === "object" && "status" in err ? Number(err.status) : 400;
|
|
109
|
+
const code = errorCode(err);
|
|
110
|
+
this.json(res, status, { error: err instanceof Error ? err.message : "bad request", ...(code ? { code } : {}) });
|
|
111
|
+
}
|
|
112
|
+
json(res, status, body) {
|
|
113
|
+
const text = JSON.stringify(body);
|
|
114
|
+
res.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(text) });
|
|
115
|
+
res.end(text);
|
|
116
|
+
}
|
|
117
|
+
async readBody(req) {
|
|
118
|
+
const chunks = [];
|
|
119
|
+
let size = 0;
|
|
120
|
+
for await (const chunk of req) {
|
|
121
|
+
size += chunk.length;
|
|
122
|
+
if (size > 10 * 1024 * 1024)
|
|
123
|
+
throw Object.assign(new Error("request body too large"), { status: 413 });
|
|
124
|
+
chunks.push(chunk);
|
|
125
|
+
}
|
|
126
|
+
if (chunks.length === 0)
|
|
127
|
+
return {};
|
|
128
|
+
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
|
129
|
+
if (!raw)
|
|
130
|
+
return {};
|
|
131
|
+
try {
|
|
132
|
+
return JSON.parse(raw);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
throw Object.assign(new Error("invalid JSON body"), { status: 400 });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
onRequest(req, res) {
|
|
139
|
+
void this.handle(req, res).catch((err) => {
|
|
140
|
+
if (!res.headersSent) {
|
|
141
|
+
const status = typeof err.status === "number" ? Number(err.status) : 500;
|
|
142
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
143
|
+
this.json(res, status, { error: redactSecrets(message) });
|
|
144
|
+
}
|
|
145
|
+
else
|
|
146
|
+
res.end();
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
async handle(req, res) {
|
|
150
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
151
|
+
const path = url.pathname;
|
|
152
|
+
const method = req.method ?? "GET";
|
|
153
|
+
if (method === "GET" && path === "/healthz") {
|
|
154
|
+
if (!hostIsLoopback(req.headers.host))
|
|
155
|
+
return this.json(res, 403, { error: "forbidden" });
|
|
156
|
+
return this.json(res, 200, { ok: true });
|
|
157
|
+
}
|
|
158
|
+
if (!this.authorized(req))
|
|
159
|
+
return this.json(res, 401, { error: "unauthorized" });
|
|
160
|
+
if (method === "POST" && path === "/runs") {
|
|
161
|
+
let params;
|
|
162
|
+
try {
|
|
163
|
+
const body = await this.readBody(req);
|
|
164
|
+
assertNoInlineSecretValues(body);
|
|
165
|
+
const parsed = ControlRunStartRequest.parse(body);
|
|
166
|
+
params = normalizeRunStart(parsed);
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
return this.requestError(res, err);
|
|
170
|
+
}
|
|
171
|
+
// 202-queued lineage race: a thread-anchored direct enqueue is a TURN
|
|
172
|
+
// of that conversation. The daemon runner binds the turn only at onRunStart,
|
|
173
|
+
// so a run that sits QUEUED (another turn on the thread is active) would be
|
|
174
|
+
// observable headless (GET /runs, GET /threads) with NO turn record and a
|
|
175
|
+
// stale head_run_id until it starts. Mirror the rerun_with_feedback / turns
|
|
176
|
+
// ordering: single-writer — pre-create the turn (run_id=null) BEFORE enqueue
|
|
177
|
+
// and pass its id, so the queued run is recorded on its thread deterministically
|
|
178
|
+
// before it can be seen. A pre-created turnId (the /threads/:id/turns path)
|
|
179
|
+
// already did this, so we only fill the gap for a bare threadId.
|
|
180
|
+
const directThreadId = typeof params.threadId === "string" && params.threadId ? params.threadId : null;
|
|
181
|
+
// turnId is the INTERNAL single-writer handoff (control-api pre-creates
|
|
182
|
+
// the turn, the daemon runner binds the run to it). A client-supplied
|
|
183
|
+
// turnId could rebind any thread's turn lineage to an unrelated run
|
|
184
|
+
// — reject it at the boundary; the /threads/:id/turns path is
|
|
185
|
+
// the public way to create a turn.
|
|
186
|
+
if (params.turnId) {
|
|
187
|
+
return this.json(res, 400, {
|
|
188
|
+
error: "turnId is not accepted on POST /runs; create the turn via POST /threads/:id/turns",
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
// planRunId only has an owner on thread turns: POST /threads/:id/turns
|
|
192
|
+
// reads final/plan.md, prefixes it into the prompt, and forces agent
|
|
193
|
+
// mode. A direct POST /runs — WITH OR WITHOUT a threadId — skips that
|
|
194
|
+
// pipeline, so the turn would record a plan contract the run never
|
|
195
|
+
// consumed. Reject unconditionally.
|
|
196
|
+
if (params.planRunId) {
|
|
197
|
+
return this.json(res, 400, {
|
|
198
|
+
error: "planRunId is not accepted on POST /runs; use POST /threads/:id/turns (the turn pipeline implements the plan)",
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
let enqueueParams = params;
|
|
202
|
+
if (directThreadId && !params.turnId) {
|
|
203
|
+
const createTurnSvc = this.opts.services?.createThreadTurn;
|
|
204
|
+
if (createTurnSvc) {
|
|
205
|
+
const detailSvc = this.opts.services?.threadDetail;
|
|
206
|
+
// Validate the thread exists (404) BEFORE enqueue: the daemon runner
|
|
207
|
+
// swallows a failed createTurn, so an enqueue against a missing thread
|
|
208
|
+
// would otherwise orphan the run. Fail loudly here instead.
|
|
209
|
+
if (detailSvc) {
|
|
210
|
+
try {
|
|
211
|
+
await detailSvc(directThreadId);
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
const status = err && typeof err === "object" && "status" in err ? Number(err.status) : 404;
|
|
215
|
+
return this.json(res, status, { error: err instanceof Error ? err.message : `no such thread: ${directThreadId}` });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const turn = (await createTurnSvc(directThreadId, String(params.prompt ?? ""), {
|
|
219
|
+
parentRunId: params.parentRunId ?? null,
|
|
220
|
+
planRunId: params.planRunId ?? null,
|
|
221
|
+
// Resolve inbound attachment bytes onto the turn (scoped 0600 paths),
|
|
222
|
+
// same as the /threads/:id/turns path — so the base64 `data` below is
|
|
223
|
+
// stripped from the enqueued params and never persists in jobs.json.
|
|
224
|
+
attachments: params.attachments,
|
|
225
|
+
}));
|
|
226
|
+
const { attachments: _att, ...rest } = params;
|
|
227
|
+
enqueueParams = { ...rest, turnId: turn.id };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// Every failure UP TO a successful enqueue must land ON the pre-created
|
|
231
|
+
// turn (if any): a validation/enqueue throw would otherwise orphan it as
|
|
232
|
+
// the exact silent empty bubble the refusal record exists to eliminate.
|
|
233
|
+
// retryable=false — no job exists to replay, so clients keep drafts.
|
|
234
|
+
const preCreatedTurnId = enqueueParams.turnId;
|
|
235
|
+
let job;
|
|
236
|
+
try {
|
|
237
|
+
enqueueParams = validateDirectRunAttachments(enqueueParams);
|
|
238
|
+
job = await this.opts.daemon.enqueue(enqueueParams);
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
recordTurnEnqueueFailure(this.opts.services?.setTurnEnqueueError, preCreatedTurnId, err);
|
|
242
|
+
// Untyped throws here are INFRA failures (daemon socket down mid-
|
|
243
|
+
// enqueue) — 500, not a client "bad request". Validation paths attach
|
|
244
|
+
// their own typed 400 status.
|
|
245
|
+
const status = err && typeof err === "object" && "status" in err ? Number(err.status) : 500;
|
|
246
|
+
return this.json(res, status, {
|
|
247
|
+
error: err instanceof Error ? err.message : "enqueue failed",
|
|
248
|
+
...(preCreatedTurnId ? { turnId: preCreatedTurnId, retryable: false } : {}),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
// POST-ENQUEUE: the job EXISTS. A status-poll throw is observation
|
|
252
|
+
// loss, NOT a refusal — record nothing (the runner hook records real
|
|
253
|
+
// pre-start deaths) and never claim retryable:false.
|
|
254
|
+
let rec;
|
|
255
|
+
try {
|
|
256
|
+
rec = await this.waitForRunStart(job.id);
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
return this.json(res, 500, {
|
|
260
|
+
error: `job ${job.id} was accepted but its start could not be observed: ${err instanceof Error ? err.message : String(err)}`,
|
|
261
|
+
jobId: job.id,
|
|
262
|
+
...(preCreatedTurnId ? { turnId: preCreatedTurnId } : {}),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
if (rec.runId && rec.runDir) {
|
|
266
|
+
return this.json(res, 200, ControlRunStartInfo.parse({ jobId: rec.id, runId: rec.runId, taskId: rec.taskId, runDir: rec.runDir }));
|
|
267
|
+
}
|
|
268
|
+
// Long-queued jobs remain canonical in the daemon. Don't fail the request
|
|
269
|
+
// while leaving an orphaned queued job behind; return the job id for polling.
|
|
270
|
+
const status = TERMINAL_STATES.has(rec.state) ? 500 : 202;
|
|
271
|
+
return this.json(res, status, ControlQueuedRunInfo.parse({ jobId: rec.id, state: rec.state, error: rec.error }));
|
|
272
|
+
}
|
|
273
|
+
if (method === "GET" && path === "/runs") {
|
|
274
|
+
const runs = await this.opts.daemon.list();
|
|
275
|
+
return this.json(res, 200, { runs: runs.map((r) => this.summarizeRunOrDiagnostic(r)) });
|
|
276
|
+
}
|
|
277
|
+
const runDetailMatch = /^\/runs\/([^/]+)$/.exec(path);
|
|
278
|
+
if (method === "GET" && runDetailMatch) {
|
|
279
|
+
const rec = await this.findRun(decodeURIComponent(runDetailMatch[1]));
|
|
280
|
+
if (!rec)
|
|
281
|
+
return this.json(res, 404, { error: "no such run" });
|
|
282
|
+
// Fence order: cursor FIRST, every projection (pending interactions
|
|
283
|
+
// included) after it — see the detailFor doc comment.
|
|
284
|
+
const lastSeq = rec.runDir ? lastSeqInFile(join(rec.runDir, "events.jsonl")) : 0;
|
|
285
|
+
return this.json(res, 200, detailFor(rec, this.pendingInteractionsFor(rec), lastSeq));
|
|
286
|
+
}
|
|
287
|
+
const interactionAnswerMatch = /^\/runs\/([^/]+)\/interactions\/([^/]+)\/answer$/.exec(path);
|
|
288
|
+
if (method === "POST" && interactionAnswerMatch) {
|
|
289
|
+
const rec = await this.findRun(decodeURIComponent(interactionAnswerMatch[1]));
|
|
290
|
+
if (!rec)
|
|
291
|
+
return this.json(res, 404, { error: "no such run" });
|
|
292
|
+
const answerService = this.opts.services?.answerInteraction;
|
|
293
|
+
if (!answerService)
|
|
294
|
+
return this.json(res, 501, { error: "interaction answers are not supported by this engine build" });
|
|
295
|
+
let body;
|
|
296
|
+
try {
|
|
297
|
+
const raw = await this.readBody(req);
|
|
298
|
+
assertNoInlineSecretValues(raw);
|
|
299
|
+
body = ControlInteractionAnswerRequest.parse(raw);
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
return this.requestError(res, err);
|
|
303
|
+
}
|
|
304
|
+
const interactionId = decodeURIComponent(interactionAnswerMatch[2]);
|
|
305
|
+
const answerSet = {
|
|
306
|
+
interaction_id: interactionId,
|
|
307
|
+
answers: body.answers.map((a) => ({
|
|
308
|
+
question_id: a.questionId,
|
|
309
|
+
selected_labels: a.selectedLabels,
|
|
310
|
+
free_text: a.freeText,
|
|
311
|
+
})),
|
|
312
|
+
};
|
|
313
|
+
const result = answerService(rec.runId ?? rec.id, interactionId, answerSet);
|
|
314
|
+
const accepted = result.status === "delivered";
|
|
315
|
+
return this.json(res, accepted ? 200 : result.status === "not_found" ? 404 : 409, ControlInteractionAnswerResponse.parse({ accepted, status: result.status, message: result.message }));
|
|
316
|
+
}
|
|
317
|
+
if (method === "GET" && path === "/events") {
|
|
318
|
+
return this.streamGlobalEvents(req, res);
|
|
319
|
+
}
|
|
320
|
+
// ---- Threads (chat/session-first): the Thread is the conversation SSOT;
|
|
321
|
+
// runs are turns inside it; native CLI sessions resume across turns. ----
|
|
322
|
+
if (method === "POST" && path === "/threads") {
|
|
323
|
+
const svc = this.opts.services?.createThread;
|
|
324
|
+
if (!svc)
|
|
325
|
+
return this.json(res, 501, { error: "threads are not supported by this engine build" });
|
|
326
|
+
try {
|
|
327
|
+
const body = await this.readBody(req);
|
|
328
|
+
assertNoInlineSecretValues(body);
|
|
329
|
+
const parsed = ControlThreadCreateRequest.parse(body);
|
|
330
|
+
// Same project-root boundary validation as run start: a durable thread
|
|
331
|
+
// with a relative/nonexistent root would only fail at its first turn.
|
|
332
|
+
let repoRoot = null;
|
|
333
|
+
if (parsed.scope.kind === "project") {
|
|
334
|
+
repoRoot = parsed.scope.root.trim();
|
|
335
|
+
const absoluteRepoError = validateAbsoluteRepoRoot(repoRoot);
|
|
336
|
+
if (absoluteRepoError)
|
|
337
|
+
throw Object.assign(new Error(absoluteRepoError), { status: 400 });
|
|
338
|
+
if (!existsSync(repoRoot) || !lstatSync(repoRoot).isDirectory()) {
|
|
339
|
+
throw Object.assign(new Error(`project root does not exist or is not a directory: ${repoRoot}`), { status: 400 });
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const thread = await svc({
|
|
343
|
+
title: parsed.title,
|
|
344
|
+
repoRoot,
|
|
345
|
+
mode: parsed.mode,
|
|
346
|
+
workspace: parsed.workspace,
|
|
347
|
+
authPreference: parsed.authPreference,
|
|
348
|
+
primaryHarness: parsed.primaryHarness ?? null,
|
|
349
|
+
eligibleHarnesses: parsed.eligibleHarnesses,
|
|
350
|
+
});
|
|
351
|
+
return this.json(res, 200, projectThread(thread, false));
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
return this.requestError(res, err);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (method === "GET" && path === "/threads") {
|
|
358
|
+
const svc = this.opts.services?.listThreads;
|
|
359
|
+
if (!svc)
|
|
360
|
+
return this.json(res, 501, { error: "threads are not supported by this engine build" });
|
|
361
|
+
const { threads } = await svc();
|
|
362
|
+
const runs = await this.opts.daemon.list();
|
|
363
|
+
// needs-human clears once the operator has decided: a blocked run with a
|
|
364
|
+
// persisted operator_decision is no longer in the "needs me" inbox.
|
|
365
|
+
const blocked = new Set(runs.filter((r) => r.state === "blocked" && readValidOperatorDecision(r) === null).map((r) => r.runId ?? r.id));
|
|
366
|
+
return this.json(res, 200, ControlThreadListResponse.parse({
|
|
367
|
+
threads: threads.map((t) => projectThread(t, blocked.has(t.head_run_id ?? ""))),
|
|
368
|
+
}));
|
|
369
|
+
}
|
|
370
|
+
const threadDetailMatch = /^\/threads\/([^/]+)$/.exec(path);
|
|
371
|
+
if (method === "GET" && threadDetailMatch) {
|
|
372
|
+
const svc = this.opts.services?.threadDetail;
|
|
373
|
+
if (!svc)
|
|
374
|
+
return this.json(res, 501, { error: "threads are not supported by this engine build" });
|
|
375
|
+
try {
|
|
376
|
+
const detail = await svc(decodeURIComponent(threadDetailMatch[1]));
|
|
377
|
+
const runs = await this.opts.daemon.list();
|
|
378
|
+
const byRun = new Map(runs.map((r) => [r.runId ?? r.id, r]));
|
|
379
|
+
const thread = detail.thread;
|
|
380
|
+
// Build a run card per turn so the chat renders the conversation (state +
|
|
381
|
+
// honest outcome) from this one response — no N+1 run-detail fetch.
|
|
382
|
+
const cards = new Map();
|
|
383
|
+
for (const turn of detail.turns) {
|
|
384
|
+
const runId = turn.run_id ?? null;
|
|
385
|
+
if (runId && !cards.has(runId)) {
|
|
386
|
+
const rec = byRun.get(runId);
|
|
387
|
+
if (rec)
|
|
388
|
+
cards.set(runId, turnRunCard(this.summarizeRunOrDiagnostic(rec)));
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const headRec = byRun.get(thread.head_run_id ?? "");
|
|
392
|
+
const headNeedsHuman = headRec?.state === "blocked" && readValidOperatorDecision(headRec) === null;
|
|
393
|
+
return this.json(res, 200, ControlThreadDetail.parse({
|
|
394
|
+
thread: projectThread(detail.thread, headNeedsHuman),
|
|
395
|
+
sessions: detail.sessions.map(projectSession),
|
|
396
|
+
turns: detail.turns.map((t) => projectTurn(t, cards)),
|
|
397
|
+
}));
|
|
398
|
+
}
|
|
399
|
+
catch (err) {
|
|
400
|
+
return this.requestError(res, err);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
// PATCH /threads/:id — rename / archive; ThreadState is active|closed.
|
|
404
|
+
if (method === "PATCH" && threadDetailMatch) {
|
|
405
|
+
const svc = this.opts.services?.updateThread;
|
|
406
|
+
if (!svc)
|
|
407
|
+
return this.json(res, 501, { error: "threads are not supported by this engine build" });
|
|
408
|
+
try {
|
|
409
|
+
const raw = await this.readBody(req);
|
|
410
|
+
assertNoInlineSecretValues(raw);
|
|
411
|
+
const patch = ControlThreadUpdateRequest.parse(raw);
|
|
412
|
+
const thread = await svc(decodeURIComponent(threadDetailMatch[1]), {
|
|
413
|
+
title: patch.title,
|
|
414
|
+
state: patch.state,
|
|
415
|
+
primaryHarness: patch.primaryHarness,
|
|
416
|
+
eligibleHarnesses: patch.eligibleHarnesses,
|
|
417
|
+
});
|
|
418
|
+
return this.json(res, 200, projectThread(thread, false));
|
|
419
|
+
}
|
|
420
|
+
catch (err) {
|
|
421
|
+
return this.requestError(res, err);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
// POST /threads/:id/apply — deliver an isolated thread's accumulated worktree
|
|
425
|
+
// diff to the project (in-place threads write the live tree directly, so they
|
|
426
|
+
// never need this).
|
|
427
|
+
const threadApplyMatch = /^\/threads\/([^/]+)\/apply$/.exec(path);
|
|
428
|
+
if (method === "POST" && threadApplyMatch) {
|
|
429
|
+
const detailSvc = this.opts.services?.threadDetail;
|
|
430
|
+
const applySvc = this.opts.services?.applyThread;
|
|
431
|
+
if (!detailSvc || !applySvc)
|
|
432
|
+
return this.json(res, 501, { error: "threads are not supported by this engine build" });
|
|
433
|
+
try {
|
|
434
|
+
const raw = await this.readBody(req);
|
|
435
|
+
assertNoInlineSecretValues(raw);
|
|
436
|
+
const body = ControlThreadApplyRequest.parse(raw);
|
|
437
|
+
const threadId = decodeURIComponent(threadApplyMatch[1]);
|
|
438
|
+
// Head-run state gate (INV-113): the cumulative thread
|
|
439
|
+
// diff spans turns, so there is no single WorkProduct to hash-bind —
|
|
440
|
+
// but a thread whose HEAD run is blocked (NEEDS_HUMAN) or failed must
|
|
441
|
+
// NOT deliver with one POST unless a typed operator decision exists.
|
|
442
|
+
// This closes the last undocumented bypass of the apply-gate doctrine.
|
|
443
|
+
const detail = await detailSvc(threadId);
|
|
444
|
+
const headRunId = detail.thread.head_run_id ?? null;
|
|
445
|
+
if (headRunId) {
|
|
446
|
+
const headRec = (await this.opts.daemon.list()).find((r) => (r.runId ?? r.id) === headRunId);
|
|
447
|
+
// A recorded head run whose record was PRUNED from jobs.json
|
|
448
|
+
// (maxHistory) has an unknowable state — the gate must fail closed,
|
|
449
|
+
// not silently wave the apply through.
|
|
450
|
+
if (!headRec) {
|
|
451
|
+
return this.json(res, 409, {
|
|
452
|
+
error: `thread head run ${headRunId} is no longer in the daemon history; its state cannot be verified — rerun the turn before applying the thread diff`,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
if (headRec.state === "blocked" || headRec.state === "failed") {
|
|
456
|
+
const decision = readValidOperatorDecision(headRec);
|
|
457
|
+
if (!decision) {
|
|
458
|
+
appendRunAuditEvent(headRec, "control.rejected", {
|
|
459
|
+
control: "thread_apply",
|
|
460
|
+
thread_id: threadId,
|
|
461
|
+
reason: `head run is ${headRec.state} without a typed operator decision`,
|
|
462
|
+
});
|
|
463
|
+
return this.json(res, 409, {
|
|
464
|
+
// State-specific remediation: decisions unblock only BLOCKED
|
|
465
|
+
// runs; a failed head needs a fixing rerun, not an override.
|
|
466
|
+
error: headRec.state === "blocked"
|
|
467
|
+
? `thread head run ${headRunId} is blocked; apply requires a typed operator decision first (POST /runs/${headRunId}/decision)`
|
|
468
|
+
: `thread head run ${headRunId} failed; rerun the turn (rerun_with_feedback) or fix the failure before applying the thread diff`,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
const result = await applySvc(threadId, { mode: body.mode, branch: body.branch, message: body.message });
|
|
474
|
+
return this.json(res, 200, ControlThreadApplyResponse.parse(result));
|
|
475
|
+
}
|
|
476
|
+
catch (err) {
|
|
477
|
+
return this.requestError(res, err);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const threadTurnMatch = /^\/threads\/([^/]+)\/turns$/.exec(path);
|
|
481
|
+
if (method === "POST" && threadTurnMatch) {
|
|
482
|
+
if (!this.opts.services?.threadDetail || !this.opts.services?.createThreadTurn) {
|
|
483
|
+
return this.json(res, 501, { error: "threads are not supported by this engine build" });
|
|
484
|
+
}
|
|
485
|
+
const threadId = decodeURIComponent(threadTurnMatch[1]);
|
|
486
|
+
// Read the body BEFORE chaining: a request body is a one-shot stream, so
|
|
487
|
+
// reading it inside the chain would block on the previous turn and could
|
|
488
|
+
// time out. Parse/secret-scan eagerly, then serialize the rest.
|
|
489
|
+
let body;
|
|
490
|
+
try {
|
|
491
|
+
body = ((await this.readBody(req)) ?? {});
|
|
492
|
+
assertNoInlineSecretValues(body);
|
|
493
|
+
}
|
|
494
|
+
catch (err) {
|
|
495
|
+
return this.requestError(res, err);
|
|
496
|
+
}
|
|
497
|
+
return handleThreadTurnCreate(this.threadTurnRouteCtx(), res, threadId, body);
|
|
498
|
+
}
|
|
499
|
+
const turnRetryMatch = /^\/threads\/([^/]+)\/turns\/([^/]+)\/retry$/.exec(path);
|
|
500
|
+
if (method === "POST" && turnRetryMatch) {
|
|
501
|
+
if (!this.opts.services?.threadDetail)
|
|
502
|
+
return this.json(res, 501, { error: "threads are not supported by this engine build" });
|
|
503
|
+
return handleThreadTurnRetry(this.threadTurnRouteCtx(), res, decodeURIComponent(turnRetryMatch[1]), decodeURIComponent(turnRetryMatch[2]));
|
|
504
|
+
}
|
|
505
|
+
const artifactsRootMatch = /^\/runs\/([^/]+)\/artifacts$/.exec(path);
|
|
506
|
+
if (method === "GET" && artifactsRootMatch) {
|
|
507
|
+
const rec = await this.findRun(decodeURIComponent(artifactsRootMatch[1]));
|
|
508
|
+
if (!rec?.runDir)
|
|
509
|
+
return this.json(res, 404, { error: "no such run" });
|
|
510
|
+
return this.json(res, 200, { runId: rec.runId ?? rec.id, artifacts: listArtifacts(rec.runDir) });
|
|
511
|
+
}
|
|
512
|
+
const artifactFetchMatch = /^\/runs\/([^/]+)\/artifacts\/(.+)$/.exec(path);
|
|
513
|
+
if (method === "GET" && artifactFetchMatch) {
|
|
514
|
+
const rec = await this.findRun(decodeURIComponent(artifactFetchMatch[1]));
|
|
515
|
+
if (!rec?.runDir)
|
|
516
|
+
return this.json(res, 404, { error: "no such run" });
|
|
517
|
+
const target = safeArtifactPath(rec.runDir, decodeURIComponent(artifactFetchMatch[2]));
|
|
518
|
+
if (!target || !existsSync(target) || lstatSync(target).isDirectory())
|
|
519
|
+
return this.json(res, 404, { error: "no such artifact" });
|
|
520
|
+
// Size cap: a multi-MB events.jsonl must not block the event loop or the
|
|
521
|
+
// client; refuse loudly with the real size so callers can range/tail it.
|
|
522
|
+
// Binary artifacts (images) are naturally bounded and get a larger cap —
|
|
523
|
+
// the small cap only ever protected the event loop from huge text logs.
|
|
524
|
+
const stats = lstatSync(target);
|
|
525
|
+
const isText = isTextArtifact(target);
|
|
526
|
+
const cap = isText ? MAX_ARTIFACT_FETCH_BYTES : MAX_ARTIFACT_BINARY_FETCH_BYTES;
|
|
527
|
+
if (stats.size > cap) {
|
|
528
|
+
return this.json(res, 413, {
|
|
529
|
+
error: `artifact is ${stats.size} bytes (limit ${cap}); read it from disk at ${target}`,
|
|
530
|
+
bytes: stats.size,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
let data = readFileSync(target);
|
|
534
|
+
if (isPatchArtifact(target) && containsSecretLikeToken(data.toString("utf8"))) {
|
|
535
|
+
return this.json(res, 409, { error: "artifact contains secret-like token; refusing to serve patch" });
|
|
536
|
+
}
|
|
537
|
+
if (isTextArtifact(target)) {
|
|
538
|
+
data = Buffer.from(redactSecrets(data.toString("utf8")), "utf8");
|
|
539
|
+
}
|
|
540
|
+
res.writeHead(200, { "content-type": contentType(target), "content-length": data.length });
|
|
541
|
+
res.end(data);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
// Produced PROJECT files — the run's real OUTPUTS (the repo's `artifacts/`
|
|
545
|
+
// convention dir where agents drop visuals like a rendered preview), distinct
|
|
546
|
+
// from the orchestration tree served by /artifacts (decision.yaml, telemetry,
|
|
547
|
+
// …) which belongs in Run Detail/Diagnostics. repoRoot comes from the TYPED
|
|
548
|
+
// run scope (producedRepoRoot — null for no-project, never the home dir);
|
|
549
|
+
// content is traversal-scoped to <repoRoot>/artifacts (never the whole repo).
|
|
550
|
+
const producedRootMatch = /^\/runs\/([^/]+)\/produced$/.exec(path);
|
|
551
|
+
if (method === "GET" && producedRootMatch) {
|
|
552
|
+
const rec = await this.findRun(decodeURIComponent(producedRootMatch[1]));
|
|
553
|
+
if (!rec?.runDir)
|
|
554
|
+
return this.json(res, 404, { error: "no such run" });
|
|
555
|
+
const repoRoot = producedRepoRoot(rec);
|
|
556
|
+
const artifacts = repoRoot ? listArtifacts(join(repoRoot, "artifacts")) : [];
|
|
557
|
+
return this.json(res, 200, { runId: rec.runId ?? rec.id, artifacts });
|
|
558
|
+
}
|
|
559
|
+
const producedFetchMatch = /^\/runs\/([^/]+)\/produced\/(.+)$/.exec(path);
|
|
560
|
+
if (method === "GET" && producedFetchMatch) {
|
|
561
|
+
const rec = await this.findRun(decodeURIComponent(producedFetchMatch[1]));
|
|
562
|
+
if (!rec?.runDir)
|
|
563
|
+
return this.json(res, 404, { error: "no such run" });
|
|
564
|
+
const repoRoot = producedRepoRoot(rec);
|
|
565
|
+
if (!repoRoot)
|
|
566
|
+
return this.json(res, 404, { error: "no project root for run" });
|
|
567
|
+
const target = safeArtifactPath(join(repoRoot, "artifacts"), decodeURIComponent(producedFetchMatch[2]));
|
|
568
|
+
if (!target || !existsSync(target) || lstatSync(target).isDirectory())
|
|
569
|
+
return this.json(res, 404, { error: "no such artifact" });
|
|
570
|
+
const stats = lstatSync(target);
|
|
571
|
+
const cap = isTextArtifact(target) ? MAX_ARTIFACT_FETCH_BYTES : MAX_ARTIFACT_BINARY_FETCH_BYTES;
|
|
572
|
+
if (stats.size > cap)
|
|
573
|
+
return this.json(res, 413, { error: `artifact is ${stats.size} bytes (limit ${cap}); read it from disk at ${target}`, bytes: stats.size });
|
|
574
|
+
let data = readFileSync(target);
|
|
575
|
+
if (isPatchArtifact(target) && containsSecretLikeToken(data.toString("utf8")))
|
|
576
|
+
return this.json(res, 409, { error: "artifact contains secret-like token; refusing to serve patch" });
|
|
577
|
+
if (isTextArtifact(target))
|
|
578
|
+
data = Buffer.from(redactSecrets(data.toString("utf8")), "utf8");
|
|
579
|
+
res.writeHead(200, { "content-type": contentType(target), "content-length": data.length });
|
|
580
|
+
res.end(data);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
const applyCheckMatch = /^\/runs\/([^/]+)\/apply\/check$/.exec(path);
|
|
584
|
+
if (method === "POST" && applyCheckMatch) {
|
|
585
|
+
const rec = await this.findRun(decodeURIComponent(applyCheckMatch[1]));
|
|
586
|
+
if (!rec?.runDir)
|
|
587
|
+
return this.json(res, 404, { error: "no such run" });
|
|
588
|
+
let body;
|
|
589
|
+
try {
|
|
590
|
+
const raw = await this.readBody(req);
|
|
591
|
+
assertNoInlineSecretValues(raw);
|
|
592
|
+
body = ControlApplyCheckRequest.parse(raw);
|
|
593
|
+
}
|
|
594
|
+
catch (err) {
|
|
595
|
+
return this.requestError(res, err);
|
|
596
|
+
}
|
|
597
|
+
const patch = readPatch(rec);
|
|
598
|
+
if (patch === null)
|
|
599
|
+
return this.json(res, 404, { error: "no patch artifact for this run" });
|
|
600
|
+
if (containsSecretLikeToken(patch))
|
|
601
|
+
return this.json(res, 409, { error: "patch contains secret-like token; refusing apply check" });
|
|
602
|
+
const repoRoot = applyTargetRoot(body.target, rec);
|
|
603
|
+
if (!repoRoot)
|
|
604
|
+
return this.json(res, 400, { error: "project root is required for apply check" });
|
|
605
|
+
const absoluteRepoError = validateAbsoluteRepoRoot(repoRoot);
|
|
606
|
+
if (absoluteRepoError)
|
|
607
|
+
return this.json(res, 400, { error: absoluteRepoError });
|
|
608
|
+
const gateError = applyGateError(rec, patch, repoRoot);
|
|
609
|
+
if (gateError)
|
|
610
|
+
return this.json(res, 409, { error: gateError });
|
|
611
|
+
return this.json(res, 200, await checkPatch(repoRoot, patch));
|
|
612
|
+
}
|
|
613
|
+
const applyMatch = /^\/runs\/([^/]+)\/apply$/.exec(path);
|
|
614
|
+
if (method === "POST" && applyMatch) {
|
|
615
|
+
const rec = await this.findRun(decodeURIComponent(applyMatch[1]));
|
|
616
|
+
if (!rec?.runDir)
|
|
617
|
+
return this.json(res, 404, { error: "no such run" });
|
|
618
|
+
let body;
|
|
619
|
+
try {
|
|
620
|
+
const raw = await this.readBody(req);
|
|
621
|
+
assertNoInlineSecretValues(raw);
|
|
622
|
+
body = ControlApplyRequest.parse(raw);
|
|
623
|
+
}
|
|
624
|
+
catch (err) {
|
|
625
|
+
return this.requestError(res, err);
|
|
626
|
+
}
|
|
627
|
+
const patch = readPatch(rec);
|
|
628
|
+
if (patch === null)
|
|
629
|
+
return this.json(res, 404, { error: "no patch artifact for this run" });
|
|
630
|
+
if (containsSecretLikeToken(patch))
|
|
631
|
+
return this.json(res, 409, { error: "patch contains secret-like token; refusing apply" });
|
|
632
|
+
const repoRoot = applyTargetRoot(body.target, rec);
|
|
633
|
+
if (!repoRoot)
|
|
634
|
+
return this.json(res, 400, { error: "project root is required for apply" });
|
|
635
|
+
const absoluteRepoError = validateAbsoluteRepoRoot(repoRoot);
|
|
636
|
+
if (absoluteRepoError)
|
|
637
|
+
return this.json(res, 400, { error: absoluteRepoError });
|
|
638
|
+
const gateError = applyGateError(rec, patch, repoRoot);
|
|
639
|
+
if (gateError)
|
|
640
|
+
return this.json(res, 409, { error: gateError });
|
|
641
|
+
return this.json(res, 200, await deliver(repoRoot, patch, { mode: body.mode, branch: body.branch, message: body.message }));
|
|
642
|
+
}
|
|
643
|
+
// Operator decision on a NEEDS_HUMAN-blocked run (review queue actions):
|
|
644
|
+
// a typed, auditable unblock path instead of a read-only dead end.
|
|
645
|
+
const decisionMatch = /^\/runs\/([^/]+)\/decision$/.exec(path);
|
|
646
|
+
if (method === "POST" && decisionMatch) {
|
|
647
|
+
const rec = await this.findRun(decodeURIComponent(decisionMatch[1]));
|
|
648
|
+
if (!rec?.runDir)
|
|
649
|
+
return this.json(res, 404, { error: "no such run" });
|
|
650
|
+
let body;
|
|
651
|
+
try {
|
|
652
|
+
const raw = await this.readBody(req);
|
|
653
|
+
assertNoInlineSecretValues(raw);
|
|
654
|
+
body = ControlRunDecisionRequest.parse(raw);
|
|
655
|
+
}
|
|
656
|
+
catch (err) {
|
|
657
|
+
return this.requestError(res, err);
|
|
658
|
+
}
|
|
659
|
+
if (body.action === "accept_risk" || body.action === "override_needs_human") {
|
|
660
|
+
// The override exists to unblock a BLOCKED run (the only state the
|
|
661
|
+
// apply gate honors it for); recording one elsewhere would claim an
|
|
662
|
+
// apply permission that does not exist.
|
|
663
|
+
if (rec.state !== "blocked") {
|
|
664
|
+
return this.json(res, 409, {
|
|
665
|
+
error: rec.state === "succeeded"
|
|
666
|
+
? "run already succeeded; apply it directly (no risk override needed)"
|
|
667
|
+
: `run is ${rec.state}; risk overrides only unblock blocked runs (use rerun_with_feedback instead)`,
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
const patch = readPatch(rec);
|
|
671
|
+
if (patch === null)
|
|
672
|
+
return this.json(res, 409, { error: "no patch artifact; there is nothing to unblock for apply" });
|
|
673
|
+
const written = writeOperatorDecision(rec, {
|
|
674
|
+
action: body.action,
|
|
675
|
+
finding_ids: body.findingIds,
|
|
676
|
+
accepted_risks: body.acceptedRisks,
|
|
677
|
+
patch_sha256: sha256(patch),
|
|
678
|
+
decided_at: nowIso(),
|
|
679
|
+
});
|
|
680
|
+
if (!written)
|
|
681
|
+
return this.json(res, 500, { error: "cannot resolve run artifact root" });
|
|
682
|
+
appendRunAuditEvent(rec, "control.applied", { decision: body.action, finding_ids: body.findingIds, accepted_risks: body.acceptedRisks });
|
|
683
|
+
return this.json(res, 200, ControlRunDecisionResponse.parse({ accepted: true, status: "applied", message: `${body.action} recorded; apply is now permitted for this exact patch` }));
|
|
684
|
+
}
|
|
685
|
+
if (body.action === "revert_run") {
|
|
686
|
+
// Server-owned revert of an in-place turn's live mutation. Restores the
|
|
687
|
+
// tree to the recorded pre-turn snapshot, refusing (fail loud) if the tree
|
|
688
|
+
// has diverged from the recorded post-turn state (the user edited since).
|
|
689
|
+
const result = controlRunResult(rec);
|
|
690
|
+
if (!result.revertable || !result.preTurnSha || !result.postTurnSha) {
|
|
691
|
+
return this.json(res, 409, { error: "this run produced no revertable in-place change" });
|
|
692
|
+
}
|
|
693
|
+
const repoRoot = applyTargetRoot({ kind: "original_project" }, rec);
|
|
694
|
+
if (!repoRoot)
|
|
695
|
+
return this.json(res, 400, { error: "cannot resolve the in-place project root to revert" });
|
|
696
|
+
const absoluteRepoError = validateAbsoluteRepoRoot(repoRoot);
|
|
697
|
+
if (absoluteRepoError)
|
|
698
|
+
return this.json(res, 400, { error: absoluteRepoError });
|
|
699
|
+
let revert;
|
|
700
|
+
try {
|
|
701
|
+
revert = await revertInPlace(repoRoot, result.preTurnSha, result.postTurnSha);
|
|
702
|
+
}
|
|
703
|
+
catch (err) {
|
|
704
|
+
return this.json(res, 500, { error: `revert failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
705
|
+
}
|
|
706
|
+
if (!revert.reverted) {
|
|
707
|
+
appendRunAuditEvent(rec, "control.rejected", { decision: "revert_run", reason: revert.reason ?? "revert refused" });
|
|
708
|
+
return this.json(res, 409, ControlRunDecisionResponse.parse({ accepted: false, status: "rejected", message: revert.reason ?? "revert refused" }));
|
|
709
|
+
}
|
|
710
|
+
markRunReverted(rec);
|
|
711
|
+
appendRunAuditEvent(rec, "control.applied", { decision: "revert_run", removed: revert.removed });
|
|
712
|
+
return this.json(res, 200, ControlRunDecisionResponse.parse({
|
|
713
|
+
accepted: true,
|
|
714
|
+
status: "applied",
|
|
715
|
+
message: `reverted to the pre-turn state${revert.removed.length ? ` (removed ${revert.removed.length} turn-added file(s))` : ""}`,
|
|
716
|
+
}));
|
|
717
|
+
}
|
|
718
|
+
if (body.action === "accept_clean_patch") {
|
|
719
|
+
const patch = readPatch(rec);
|
|
720
|
+
if (patch === null)
|
|
721
|
+
return this.json(res, 404, { error: "no patch artifact for this run" });
|
|
722
|
+
if (containsSecretLikeToken(patch))
|
|
723
|
+
return this.json(res, 409, { error: "patch contains secret-like token; refusing apply" });
|
|
724
|
+
const repoRoot = applyTargetRoot(body.target ?? { kind: "original_project" }, rec);
|
|
725
|
+
if (!repoRoot)
|
|
726
|
+
return this.json(res, 400, { error: "project root is required for apply" });
|
|
727
|
+
const absoluteRepoError = validateAbsoluteRepoRoot(repoRoot);
|
|
728
|
+
if (absoluteRepoError)
|
|
729
|
+
return this.json(res, 400, { error: absoluteRepoError });
|
|
730
|
+
const gateError = applyGateError(rec, patch, repoRoot);
|
|
731
|
+
if (gateError)
|
|
732
|
+
return this.json(res, 409, { error: gateError });
|
|
733
|
+
const delivered = await deliver(repoRoot, patch, { mode: body.applyMode ?? "apply" });
|
|
734
|
+
appendRunAuditEvent(rec, "control.applied", { decision: body.action, mode: body.applyMode ?? "apply", applied: delivered.applied });
|
|
735
|
+
return this.json(res, 200, ControlRunDecisionResponse.parse({ accepted: delivered.applied, status: delivered.applied ? "applied" : "rejected", message: delivered.detail ?? undefined }));
|
|
736
|
+
}
|
|
737
|
+
// rerun_with_feedback: enqueue a follow-up run seeded with the reviewer feedback.
|
|
738
|
+
if (!body.feedback || !body.feedback.trim()) {
|
|
739
|
+
return this.json(res, 400, { error: "feedback is required for rerun_with_feedback" });
|
|
740
|
+
}
|
|
741
|
+
const p = paramsRecord(rec);
|
|
742
|
+
const originalPrompt = typeof p["prompt"] === "string" ? p["prompt"] : "";
|
|
743
|
+
// Turn/plan ids are SERVER-owned: strip the originals so the rebuild
|
|
744
|
+
// can't re-bind the rerun to the old turn (a fresh decision turn is
|
|
745
|
+
// created below when the run is thread-anchored).
|
|
746
|
+
const { turnId: _turnId, planRunId: _planRunId, ...pRest } = p;
|
|
747
|
+
let params;
|
|
748
|
+
try {
|
|
749
|
+
params = normalizeRunStart(ControlRunStartRequest.parse({
|
|
750
|
+
...pRest,
|
|
751
|
+
prompt: `${originalPrompt}\n\n## Reviewer feedback to address (operator decision)\n${body.feedback}`,
|
|
752
|
+
parentRunId: rec.runId ?? rec.id,
|
|
753
|
+
}));
|
|
754
|
+
}
|
|
755
|
+
catch (err) {
|
|
756
|
+
return this.json(res, 400, { error: `cannot rebuild run params for rerun: ${err instanceof Error ? err.message : String(err)}` });
|
|
757
|
+
}
|
|
758
|
+
// A thread-anchored rerun is a TURN of that conversation. Single-writer:
|
|
759
|
+
// create the decision turn (run_id=null) BEFORE enqueue and pass its id, so
|
|
760
|
+
// the daemon runner binds the rerun and head_run_id/needsHuman move off the
|
|
761
|
+
// old blocked head — no post-hoc turn that could fail to reconcile.
|
|
762
|
+
const threadId = typeof p["threadId"] === "string" ? p["threadId"] : null;
|
|
763
|
+
const createTurnSvc = this.opts.services?.createThreadTurn;
|
|
764
|
+
let rerunTurnId;
|
|
765
|
+
if (threadId && createTurnSvc) {
|
|
766
|
+
const turn = (await createTurnSvc(threadId, String(params.prompt ?? ""), {
|
|
767
|
+
kind: "decision",
|
|
768
|
+
parentRunId: rec.runId ?? rec.id,
|
|
769
|
+
}));
|
|
770
|
+
rerunTurnId = turn.id;
|
|
771
|
+
}
|
|
772
|
+
let rerunJob;
|
|
773
|
+
try {
|
|
774
|
+
rerunJob = await this.opts.daemon.enqueue({ ...params, ...(rerunTurnId ? { turnId: rerunTurnId } : {}) });
|
|
775
|
+
}
|
|
776
|
+
catch (err) {
|
|
777
|
+
// The decision turn was pre-created: a failed rerun ENQUEUE must be
|
|
778
|
+
// an inline refusal on that turn, not a silent orphan bubble.
|
|
779
|
+
// retryable=false mirrors the recorded refusal (no job to replay).
|
|
780
|
+
recordTurnEnqueueFailure(this.opts.services?.setTurnEnqueueError, rerunTurnId, err);
|
|
781
|
+
const status = err && typeof err === "object" && "status" in err ? Number(err.status) : 500;
|
|
782
|
+
return this.json(res, status, {
|
|
783
|
+
error: err instanceof Error ? err.message : "rerun enqueue failed",
|
|
784
|
+
...(rerunTurnId ? { turnId: rerunTurnId, retryable: false } : {}),
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
// POST-ENQUEUE: observation loss is not a refusal — record nothing.
|
|
788
|
+
let newRec;
|
|
789
|
+
try {
|
|
790
|
+
newRec = await this.waitForRunStart(rerunJob.id);
|
|
791
|
+
}
|
|
792
|
+
catch (err) {
|
|
793
|
+
return this.json(res, 500, {
|
|
794
|
+
error: `rerun job ${rerunJob.id} was accepted but its start could not be observed: ${err instanceof Error ? err.message : String(err)}`,
|
|
795
|
+
jobId: rerunJob.id,
|
|
796
|
+
...(rerunTurnId ? { turnId: rerunTurnId } : {}),
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
appendRunAuditEvent(rec, "control.applied", { decision: body.action, new_run_id: newRec.runId ?? newRec.id });
|
|
800
|
+
return this.json(res, 200, ControlRunDecisionResponse.parse({
|
|
801
|
+
accepted: true,
|
|
802
|
+
status: "requeued",
|
|
803
|
+
newRunId: newRec.runId ?? newRec.id,
|
|
804
|
+
message: "follow-up run enqueued with reviewer feedback",
|
|
805
|
+
}));
|
|
806
|
+
}
|
|
807
|
+
if (method === "GET" && path === "/harnesses")
|
|
808
|
+
return this.service(res, "harnesses", undefined, ControlHarnessListResponse);
|
|
809
|
+
if (method === "GET" && path === "/agent-capabilities")
|
|
810
|
+
return this.service(res, "agentCapabilities", undefined, AgentCapabilityCatalog);
|
|
811
|
+
const harnessModelsMatch = /^\/harnesses\/([^/]+)\/models$/.exec(path);
|
|
812
|
+
if (method === "GET" && harnessModelsMatch) {
|
|
813
|
+
return this.service(res, "harnessModels", { harnessId: decodeURIComponent(harnessModelsMatch[1]) }, ControlHarnessModelsResponse);
|
|
814
|
+
}
|
|
815
|
+
if (method === "GET" && path === "/setup/jobs")
|
|
816
|
+
return this.service(res, "listSetupJobs", undefined, ControlSetupJobListResponse);
|
|
817
|
+
if (method === "POST" && path === "/setup/jobs") {
|
|
818
|
+
let body;
|
|
819
|
+
try {
|
|
820
|
+
const raw = await this.readBody(req);
|
|
821
|
+
assertNoInlineSecretValues(raw);
|
|
822
|
+
body = ControlSetupJobCreateRequest.parse(raw);
|
|
823
|
+
}
|
|
824
|
+
catch (err) {
|
|
825
|
+
return this.requestError(res, err);
|
|
826
|
+
}
|
|
827
|
+
return this.service(res, "createSetupJob", body, ControlSetupJob);
|
|
828
|
+
}
|
|
829
|
+
const setupJobMatch = /^\/setup\/jobs\/([^/]+)$/.exec(path);
|
|
830
|
+
if (method === "GET" && setupJobMatch) {
|
|
831
|
+
return this.service(res, "setupJobStatus", { jobId: decodeURIComponent(setupJobMatch[1]) }, ControlSetupJob);
|
|
832
|
+
}
|
|
833
|
+
const setupJobCancelMatch = /^\/setup\/jobs\/([^/]+)\/cancel$/.exec(path);
|
|
834
|
+
if (method === "POST" && setupJobCancelMatch) {
|
|
835
|
+
return this.service(res, "cancelSetupJob", { jobId: decodeURIComponent(setupJobCancelMatch[1]) }, ControlSetupJob);
|
|
836
|
+
}
|
|
837
|
+
const setupJobConfirmMatch = /^\/setup\/jobs\/([^/]+)\/confirm$/.exec(path);
|
|
838
|
+
if (method === "POST" && setupJobConfirmMatch) {
|
|
839
|
+
try {
|
|
840
|
+
const raw = await this.readBody(req);
|
|
841
|
+
assertNoInlineSecretValues(raw);
|
|
842
|
+
const body = ControlSetupJobConfirmRequest.parse(raw);
|
|
843
|
+
return this.service(res, "confirmSetupJob", { jobId: decodeURIComponent(setupJobConfirmMatch[1]), confirmed: body.confirmed }, ControlSetupJob);
|
|
844
|
+
}
|
|
845
|
+
catch (err) {
|
|
846
|
+
return this.requestError(res, err);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
const setupJobEventsMatch = /^\/setup\/jobs\/([^/]+)\/events$/.exec(path);
|
|
850
|
+
if (method === "GET" && setupJobEventsMatch) {
|
|
851
|
+
return this.streamSetupJobEvents(decodeURIComponent(setupJobEventsMatch[1]), req, res);
|
|
852
|
+
}
|
|
853
|
+
// User-level trust (INV-122: sensitive powers live OUTSIDE versioned repo
|
|
854
|
+
// config). GET lists per-repo trust files; POST is deliberately NARROW —
|
|
855
|
+
// exactly {repoRoot, allowFullAccess} (strict), everything else CLI-only.
|
|
856
|
+
if (method === "GET" && path === "/trust")
|
|
857
|
+
return this.service(res, "listTrust", undefined, ControlTrustListResponse);
|
|
858
|
+
if (method === "POST" && path === "/trust") {
|
|
859
|
+
let body;
|
|
860
|
+
try {
|
|
861
|
+
const raw = await this.readBody(req);
|
|
862
|
+
assertNoInlineSecretValues(raw);
|
|
863
|
+
body = ControlTrustUpdateRequest.parse(raw);
|
|
864
|
+
}
|
|
865
|
+
catch (err) {
|
|
866
|
+
return this.requestError(res, err);
|
|
867
|
+
}
|
|
868
|
+
return this.service(res, "updateTrust", body, ControlTrustState);
|
|
869
|
+
}
|
|
870
|
+
if (method === "GET" && path === "/settings")
|
|
871
|
+
return this.service(res, "settings", undefined, ControlSettingsSnapshot);
|
|
872
|
+
if (method === "POST" && path === "/settings") {
|
|
873
|
+
let body;
|
|
874
|
+
try {
|
|
875
|
+
const raw = await this.readBody(req);
|
|
876
|
+
assertNoInlineSecretValues(raw);
|
|
877
|
+
body = ControlSettingsUpdateRequest.parse(raw);
|
|
878
|
+
}
|
|
879
|
+
catch (err) {
|
|
880
|
+
return this.requestError(res, err);
|
|
881
|
+
}
|
|
882
|
+
return this.service(res, "updateSettings", body);
|
|
883
|
+
}
|
|
884
|
+
// (legacy /auth alias removed: it duplicated GET /harnesses byte-for-byte)
|
|
885
|
+
if (method === "GET" && path === "/secrets")
|
|
886
|
+
return this.service(res, "listSecrets", undefined, ControlSecretListResponse);
|
|
887
|
+
if (method === "POST" && path === "/secrets") {
|
|
888
|
+
const body = await this.readBody(req);
|
|
889
|
+
if (!validSecretSetBody(body))
|
|
890
|
+
return this.json(res, 400, { error: `secret name must be one of: ${MANAGED_SECRET_NAMES.join(", ")}` });
|
|
891
|
+
return this.service(res, "setSecret", body);
|
|
892
|
+
}
|
|
893
|
+
const secretDeleteMatch = /^\/secrets\/([^/]+)$/.exec(path);
|
|
894
|
+
if (method === "DELETE" && secretDeleteMatch) {
|
|
895
|
+
const name = decodeURIComponent(secretDeleteMatch[1]);
|
|
896
|
+
if (!isAllowedSecretName(name))
|
|
897
|
+
return this.json(res, 400, { error: `secret name must be one of: ${MANAGED_SECRET_NAMES.join(", ")}` });
|
|
898
|
+
return this.service(res, "deleteSecret", name);
|
|
899
|
+
}
|
|
900
|
+
if (method === "POST" && path === "/spec/questions") {
|
|
901
|
+
try {
|
|
902
|
+
const raw = await this.readBody(req);
|
|
903
|
+
assertNoSpecBodySecrets(raw);
|
|
904
|
+
const body = ControlSpecQuestionsRequest.parse(raw);
|
|
905
|
+
return this.service(res, "specQuestions", body);
|
|
906
|
+
}
|
|
907
|
+
catch (err) {
|
|
908
|
+
return this.requestError(res, err);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
if (method === "POST" && path === "/spec/freeze") {
|
|
912
|
+
try {
|
|
913
|
+
const raw = await this.readBody(req);
|
|
914
|
+
assertNoSpecBodySecrets(raw);
|
|
915
|
+
const body = ControlSpecFreezeRequest.parse(raw);
|
|
916
|
+
return this.service(res, "specFreeze", body);
|
|
917
|
+
}
|
|
918
|
+
catch (err) {
|
|
919
|
+
return this.requestError(res, err);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
const controlMatch = /^\/runs\/([^/]+)\/control$/.exec(path);
|
|
923
|
+
if (method === "POST" && controlMatch) {
|
|
924
|
+
const rec = await this.findRun(decodeURIComponent(controlMatch[1]));
|
|
925
|
+
if (!rec)
|
|
926
|
+
return this.json(res, 404, { error: "no such run" });
|
|
927
|
+
let body;
|
|
928
|
+
try {
|
|
929
|
+
const raw = await this.readBody(req);
|
|
930
|
+
assertNoInlineSecretValues(raw);
|
|
931
|
+
body = ControlRunControlRequest.parse(raw);
|
|
932
|
+
}
|
|
933
|
+
catch (err) {
|
|
934
|
+
return this.requestError(res, err);
|
|
935
|
+
}
|
|
936
|
+
appendRunAuditEvent(rec, "control.requested", { control: body.control });
|
|
937
|
+
// Honesty: a control action on a TERMINAL job has no process to stop;
|
|
938
|
+
// claiming "applied" would fabricate an effect that never happened.
|
|
939
|
+
if (rec.state !== "queued" && rec.state !== "running") {
|
|
940
|
+
appendRunAuditEvent(rec, "control.rejected", { control: body.control, reason: `run is terminal (${rec.state})` });
|
|
941
|
+
return this.json(res, 409, { error: `run is ${rec.state}; ${body.control.kind} has nothing to stop` });
|
|
942
|
+
}
|
|
943
|
+
await this.opts.daemon.cancel(rec.id);
|
|
944
|
+
appendRunAuditEvent(rec, "control.applied", { control: body.control });
|
|
945
|
+
return this.json(res, 200, ControlRunControlResponse.parse({
|
|
946
|
+
accepted: true,
|
|
947
|
+
status: "applied",
|
|
948
|
+
runId: rec.runId ?? rec.id,
|
|
949
|
+
message: `${body.control.kind} requested`,
|
|
950
|
+
}));
|
|
951
|
+
}
|
|
952
|
+
const eventsMatch = /^\/runs\/([^/]+)\/events$/.exec(path);
|
|
953
|
+
if (method === "GET" && eventsMatch) {
|
|
954
|
+
const id = decodeURIComponent(eventsMatch[1]);
|
|
955
|
+
const last = this.lastEventId(req, url);
|
|
956
|
+
return this.streamEvents(id, last, req, res);
|
|
957
|
+
}
|
|
958
|
+
return this.json(res, 404, { error: "not found" });
|
|
959
|
+
}
|
|
960
|
+
async service(res, name, arg, schema) {
|
|
961
|
+
const fn = this.opts.services?.[name];
|
|
962
|
+
if (!fn)
|
|
963
|
+
return this.json(res, 501, { error: `${name} service is not configured` });
|
|
964
|
+
try {
|
|
965
|
+
const value = await fn(arg);
|
|
966
|
+
return this.json(res, 200, schema ? schema.parse(value) : value);
|
|
967
|
+
}
|
|
968
|
+
catch (err) {
|
|
969
|
+
// Honest status codes: services attach a typed `status` (e.g. 404 for a
|
|
970
|
+
// missing setup job); a schema-invalid service RESULT is an internal 500.
|
|
971
|
+
// Flattening everything to 400 made client-side error handling guesswork.
|
|
972
|
+
const message = redactSecrets(err instanceof Error ? err.message : String(err));
|
|
973
|
+
const typedStatus = err && typeof err === "object" && "status" in err ? Number(err.status) : Number.NaN;
|
|
974
|
+
// A schema-invalid service RESULT is a server bug (500), cross-realm-safe via the error name.
|
|
975
|
+
const status = Number.isFinite(typedStatus) ? typedStatus : err instanceof Error && err.name === "ZodError" ? 500 : 400;
|
|
976
|
+
return this.json(res, status, { error: message });
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Live setup-job lifecycle stream: emits a `status` event on every state or
|
|
981
|
+
* message transition until the job reaches a terminal state. Backed by
|
|
982
|
+
* polling the job service (the manager has no event bus), which is exactly
|
|
983
|
+
* what the previous one-shot stub forced every client to reimplement.
|
|
984
|
+
*/
|
|
985
|
+
async streamSetupJobEvents(jobId, req, res) {
|
|
986
|
+
const fn = this.opts.services?.setupJobStatus;
|
|
987
|
+
if (!fn)
|
|
988
|
+
return this.json(res, 501, { error: "setupJobStatus service is not configured" });
|
|
989
|
+
let job;
|
|
990
|
+
try {
|
|
991
|
+
job = ControlSetupJob.parse(await fn({ jobId }));
|
|
992
|
+
}
|
|
993
|
+
catch (err) {
|
|
994
|
+
return this.json(res, 404, { error: redactSecrets(err instanceof Error ? err.message : String(err)) });
|
|
995
|
+
}
|
|
996
|
+
res.writeHead(200, {
|
|
997
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
998
|
+
"cache-control": "no-cache, no-transform",
|
|
999
|
+
connection: "keep-alive",
|
|
1000
|
+
});
|
|
1001
|
+
this.sseClients.add(res);
|
|
1002
|
+
const TERMINAL_JOB_STATES = new Set(["succeeded", "failed", "cancelled", "not_supported"]);
|
|
1003
|
+
let seq = 0;
|
|
1004
|
+
let closed = false;
|
|
1005
|
+
let lastSnapshot = "";
|
|
1006
|
+
const cleanup = () => {
|
|
1007
|
+
closed = true;
|
|
1008
|
+
clearInterval(timer);
|
|
1009
|
+
clearInterval(heartbeat);
|
|
1010
|
+
this.sseClients.delete(res);
|
|
1011
|
+
};
|
|
1012
|
+
const emit = (current) => {
|
|
1013
|
+
const snapshot = JSON.stringify([current.state, current.message, current.firstOutputAt, current.lastOutputAt, current.finishedAt]);
|
|
1014
|
+
if (snapshot === lastSnapshot)
|
|
1015
|
+
return false;
|
|
1016
|
+
lastSnapshot = snapshot;
|
|
1017
|
+
seq += 1;
|
|
1018
|
+
const event = ControlSetupJobEvent.parse({
|
|
1019
|
+
jobId: current.jobId,
|
|
1020
|
+
seq,
|
|
1021
|
+
time: nowIso(),
|
|
1022
|
+
kind: "status",
|
|
1023
|
+
state: current.state,
|
|
1024
|
+
message: current.message,
|
|
1025
|
+
});
|
|
1026
|
+
res.write(`id: ${seq}\nevent: setup\ndata: ${JSON.stringify(event)}\n\n`);
|
|
1027
|
+
return true;
|
|
1028
|
+
};
|
|
1029
|
+
const finish = () => {
|
|
1030
|
+
if (closed)
|
|
1031
|
+
return;
|
|
1032
|
+
res.write("event: end\ndata: {}\n\n");
|
|
1033
|
+
res.end();
|
|
1034
|
+
cleanup();
|
|
1035
|
+
};
|
|
1036
|
+
const heartbeat = setInterval(() => {
|
|
1037
|
+
if (!closed)
|
|
1038
|
+
res.write(`: ping ${Date.now()}\n\n`);
|
|
1039
|
+
}, this.opts.heartbeatMs ?? 15_000);
|
|
1040
|
+
heartbeat.unref?.();
|
|
1041
|
+
const tick = async () => {
|
|
1042
|
+
if (closed)
|
|
1043
|
+
return;
|
|
1044
|
+
try {
|
|
1045
|
+
job = ControlSetupJob.parse(await fn({ jobId }));
|
|
1046
|
+
}
|
|
1047
|
+
catch {
|
|
1048
|
+
finish();
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
emit(job);
|
|
1052
|
+
if (TERMINAL_JOB_STATES.has(job.state))
|
|
1053
|
+
finish();
|
|
1054
|
+
};
|
|
1055
|
+
const timer = setInterval(() => void tick(), this.opts.pollMs ?? 250);
|
|
1056
|
+
timer.unref?.();
|
|
1057
|
+
req.on("close", cleanup);
|
|
1058
|
+
res.on("close", cleanup);
|
|
1059
|
+
emit(job);
|
|
1060
|
+
if (TERMINAL_JOB_STATES.has(job.state))
|
|
1061
|
+
finish();
|
|
1062
|
+
}
|
|
1063
|
+
async waitForRunStart(jobId) {
|
|
1064
|
+
const pollMs = this.opts.pollMs ?? 50;
|
|
1065
|
+
const deadline = Date.now() + (this.opts.runStartTimeoutMs ?? 30_000);
|
|
1066
|
+
let last = null;
|
|
1067
|
+
for (;;) {
|
|
1068
|
+
const rec = await this.opts.daemon.status(jobId);
|
|
1069
|
+
last = rec;
|
|
1070
|
+
if (rec.runId && rec.runDir)
|
|
1071
|
+
return rec;
|
|
1072
|
+
if (TERMINAL_STATES.has(rec.state))
|
|
1073
|
+
return rec;
|
|
1074
|
+
if (Date.now() > deadline)
|
|
1075
|
+
return last;
|
|
1076
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
async findRun(id) {
|
|
1080
|
+
const runs = await this.opts.daemon.list();
|
|
1081
|
+
return runs.find((r) => r.id === id || r.runId === id) ?? null;
|
|
1082
|
+
}
|
|
1083
|
+
/** Bound helper context for the extracted thread-turn write routes.
|
|
1084
|
+
* Callers guard the required services (threadDetail/createThreadTurn)
|
|
1085
|
+
* before routing here. */
|
|
1086
|
+
threadTurnRouteCtx() {
|
|
1087
|
+
const services = this.opts.services ?? {};
|
|
1088
|
+
return {
|
|
1089
|
+
json: (res, status, body) => this.json(res, status, body),
|
|
1090
|
+
waitForRunStart: (jobId) => this.waitForRunStart(jobId),
|
|
1091
|
+
readRunArtifactText: (runId, rel) => this.readRunArtifactText(runId, rel),
|
|
1092
|
+
normalizeStart: normalizeRunStart,
|
|
1093
|
+
isTerminalState: (state) => TERMINAL_STATES.has(state),
|
|
1094
|
+
daemon: this.opts.daemon,
|
|
1095
|
+
threadDetail: services.threadDetail,
|
|
1096
|
+
createThreadTurn: services.createThreadTurn,
|
|
1097
|
+
setTurnEnqueueError: services.setTurnEnqueueError,
|
|
1098
|
+
threadTurnChains: this.threadTurnChains,
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
/** Read a run's text artifact (e.g. final/plan.md) for the "Implement plan" turn. */
|
|
1102
|
+
async readRunArtifactText(runId, rel) {
|
|
1103
|
+
const rec = await this.findRun(runId);
|
|
1104
|
+
if (!rec)
|
|
1105
|
+
return null;
|
|
1106
|
+
try {
|
|
1107
|
+
return readRawTextArtifact(rec, rel);
|
|
1108
|
+
}
|
|
1109
|
+
catch {
|
|
1110
|
+
return null;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
summaryCache = new Map();
|
|
1114
|
+
/**
|
|
1115
|
+
* GET /runs is the app's main screen and used to re-read every artifact for
|
|
1116
|
+
* every retained job on every poll (O(jobs x file size) sync I/O). Terminal
|
|
1117
|
+
* runs change only when their artifacts change, so summaries are cached on a
|
|
1118
|
+
* state+artifact-mtime fingerprint.
|
|
1119
|
+
*/
|
|
1120
|
+
summarizeRunCached(rec) {
|
|
1121
|
+
const fingerprint = summaryFingerprint(rec);
|
|
1122
|
+
const hit = this.summaryCache.get(rec.id);
|
|
1123
|
+
if (hit && hit.fingerprint === fingerprint)
|
|
1124
|
+
return hit.summary;
|
|
1125
|
+
const summary = summarizeRun(rec);
|
|
1126
|
+
if (this.summaryCache.size > 1_000)
|
|
1127
|
+
this.summaryCache.clear(); // bounded; repopulates on the next poll
|
|
1128
|
+
this.summaryCache.set(rec.id, { fingerprint, summary });
|
|
1129
|
+
return summary;
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Cached artifact projection + LIVE waiting_on_user overlay. Pending
|
|
1133
|
+
* interactions are in-process daemon state, not an artifact, so they must
|
|
1134
|
+
* never be frozen into the fingerprint cache.
|
|
1135
|
+
*/
|
|
1136
|
+
summarizeRunLive(rec) {
|
|
1137
|
+
const summary = this.summarizeRunCached(rec);
|
|
1138
|
+
const waiting = this.pendingInteractionsFor(rec).length > 0;
|
|
1139
|
+
return summary.waitingOnUser === waiting ? summary : { ...summary, waitingOnUser: waiting };
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* Degrade contract shared by GET /runs and the thread-detail turn cards:
|
|
1143
|
+
* one unprojectable job record becomes a diagnostic row, never a 500 for
|
|
1144
|
+
* the whole list/thread.
|
|
1145
|
+
*/
|
|
1146
|
+
summarizeRunOrDiagnostic(rec) {
|
|
1147
|
+
try {
|
|
1148
|
+
return this.summarizeRunLive(rec);
|
|
1149
|
+
}
|
|
1150
|
+
catch (err) {
|
|
1151
|
+
return ControlRunSummary.parse({
|
|
1152
|
+
jobId: rec.id,
|
|
1153
|
+
runId: rec.runId ?? rec.id,
|
|
1154
|
+
state: "failed",
|
|
1155
|
+
error: `unprojectable job record: ${err instanceof Error ? err.message : String(err)}`,
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
lastEventId(req, url) {
|
|
1160
|
+
const rawHeader = req.headers["last-event-id"];
|
|
1161
|
+
const headerId = rawHeader !== undefined ? Number(rawHeader) : Number.NaN;
|
|
1162
|
+
const rawQuery = url.searchParams.get("lastEventId");
|
|
1163
|
+
const queryId = rawQuery !== null ? Number(rawQuery) : Number.NaN;
|
|
1164
|
+
return Number.isFinite(headerId) ? headerId : Number.isFinite(queryId) ? queryId : 0;
|
|
1165
|
+
}
|
|
1166
|
+
async streamEvents(id, lastEventId, req, res) {
|
|
1167
|
+
return streamRunEvents({ findRun: (runId) => this.findRun(runId), json: (r, code, v) => this.json(r, code, v), opts: this.opts, sseClients: this.sseClients }, id, lastEventId, req, res);
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* Global live-only event multiplex (GET /events): every run's events as they
|
|
1171
|
+
* happen, tagged with run_id, no replay. Documented asymmetry vs the per-run
|
|
1172
|
+
* stream: reconnecting clients re-snapshot /runs first, then resume per-run
|
|
1173
|
+
* streams (which DO replay via persisted seq) where they need gap-free state.
|
|
1174
|
+
*/
|
|
1175
|
+
streamGlobalEvents(req, res) {
|
|
1176
|
+
if (!this.opts.bus) {
|
|
1177
|
+
this.json(res, 501, { error: "global event stream requires the daemon event bus" });
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
res.writeHead(200, {
|
|
1181
|
+
"content-type": "text/event-stream",
|
|
1182
|
+
"cache-control": "no-cache, no-transform",
|
|
1183
|
+
connection: "keep-alive",
|
|
1184
|
+
});
|
|
1185
|
+
res.write(": connected\n\n");
|
|
1186
|
+
this.sseClients.add(res);
|
|
1187
|
+
let closed = false;
|
|
1188
|
+
const heartbeat = setInterval(() => {
|
|
1189
|
+
if (!closed)
|
|
1190
|
+
res.write(`: ping ${Date.now()}\n\n`);
|
|
1191
|
+
}, this.opts.heartbeatMs ?? 15_000);
|
|
1192
|
+
heartbeat.unref?.();
|
|
1193
|
+
const unsubscribe = this.opts.bus.subscribe((event) => {
|
|
1194
|
+
if (closed)
|
|
1195
|
+
return;
|
|
1196
|
+
try {
|
|
1197
|
+
const raw = JSON.stringify(event);
|
|
1198
|
+
const type = String(event.type ?? "run");
|
|
1199
|
+
const seq = event.seq;
|
|
1200
|
+
res.write(`${typeof seq === "number" ? `id: ${seq}\n` : ""}event: ${type}\ndata: ${redactedSseLine(raw)}\n\n`);
|
|
1201
|
+
}
|
|
1202
|
+
catch {
|
|
1203
|
+
/* one bad event must not kill the stream */
|
|
1204
|
+
}
|
|
1205
|
+
});
|
|
1206
|
+
const cleanup = () => {
|
|
1207
|
+
closed = true;
|
|
1208
|
+
clearInterval(heartbeat);
|
|
1209
|
+
unsubscribe();
|
|
1210
|
+
this.sseClients.delete(res);
|
|
1211
|
+
};
|
|
1212
|
+
req.on("close", cleanup);
|
|
1213
|
+
res.on("close", cleanup);
|
|
1214
|
+
}
|
|
1215
|
+
pendingInteractionsFor(rec) {
|
|
1216
|
+
try {
|
|
1217
|
+
return this.opts.services?.pendingInteractions?.(rec.runId ?? rec.id) ?? [];
|
|
1218
|
+
}
|
|
1219
|
+
catch {
|
|
1220
|
+
return [];
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
/* ---- Thread projections (engine snake_case -> control camelCase) ---- */
|
|
1225
|
+
function paramsRecord(rec) {
|
|
1226
|
+
return rec.params && typeof rec.params === "object" && !Array.isArray(rec.params) ? rec.params : {};
|
|
1227
|
+
}
|
|
1228
|
+
function projectMetadata(rec) {
|
|
1229
|
+
const p = paramsRecord(rec);
|
|
1230
|
+
const scope = p["scope"];
|
|
1231
|
+
if (scope && typeof scope === "object" && !Array.isArray(scope)) {
|
|
1232
|
+
const s = scope;
|
|
1233
|
+
if (s["kind"] === "none")
|
|
1234
|
+
return { kind: "none", root: null, projectName: null, context: "off" };
|
|
1235
|
+
if (s["kind"] === "project" && typeof s["root"] === "string") {
|
|
1236
|
+
return { kind: "project", root: s["root"], projectName: basename(s["root"]), context: "auto" };
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
const repoRoot = runRepoRoot(rec);
|
|
1240
|
+
const noProject = repoRoot === NO_PROJECT_ROOT;
|
|
1241
|
+
return {
|
|
1242
|
+
kind: noProject ? "none" : "project",
|
|
1243
|
+
root: noProject ? null : repoRoot,
|
|
1244
|
+
projectName: noProject || !repoRoot ? null : basename(repoRoot),
|
|
1245
|
+
context: noProject ? "off" : "auto",
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
function readFailure(rec) {
|
|
1249
|
+
const fromArtifact = safeReadStructuredArtifact(rec, "final/failure.yaml", RunFailure);
|
|
1250
|
+
if (fromArtifact)
|
|
1251
|
+
return fromArtifact;
|
|
1252
|
+
if (!rec.error)
|
|
1253
|
+
return null;
|
|
1254
|
+
return RunFailure.parse({
|
|
1255
|
+
category: "unknown",
|
|
1256
|
+
safeMessage: rec.error,
|
|
1257
|
+
runDir: rec.runDir ?? null,
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
function appendRunAuditEvent(rec, type, payload) {
|
|
1261
|
+
if (!rec.runDir)
|
|
1262
|
+
return;
|
|
1263
|
+
try {
|
|
1264
|
+
// Single-counter invariant: while the run is active its EventLog owns the
|
|
1265
|
+
// seq space, so audit records MUST route through it (appendRunEvent does;
|
|
1266
|
+
// file-tail stamping only applies once the run is terminal). A tail-read
|
|
1267
|
+
// here would duplicate ids and break SSE Last-Event-ID resume.
|
|
1268
|
+
appendRunEvent(join(rec.runDir, "events.jsonl"), rec.runId ?? rec.id, rec.taskId ?? "unknown", type, payload);
|
|
1269
|
+
}
|
|
1270
|
+
catch {
|
|
1271
|
+
/* audit append must not change control behavior */
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
function summaryFingerprint(rec) {
|
|
1275
|
+
const mtime = (rel) => {
|
|
1276
|
+
if (!rec.runDir)
|
|
1277
|
+
return 0;
|
|
1278
|
+
const path = safeArtifactPath(rec.runDir, rel);
|
|
1279
|
+
if (!path)
|
|
1280
|
+
return 0;
|
|
1281
|
+
try {
|
|
1282
|
+
return statSync(path).mtimeMs;
|
|
1283
|
+
}
|
|
1284
|
+
catch {
|
|
1285
|
+
return 0;
|
|
1286
|
+
}
|
|
1287
|
+
};
|
|
1288
|
+
return [
|
|
1289
|
+
rec.state,
|
|
1290
|
+
paramsFingerprint(rec),
|
|
1291
|
+
rec.finishedAt ?? "",
|
|
1292
|
+
rec.error ?? "",
|
|
1293
|
+
mtime("events.jsonl"),
|
|
1294
|
+
mtime("arbitration/decision.yaml"),
|
|
1295
|
+
mtime("final/telemetry.yaml"),
|
|
1296
|
+
mtime("final/failure.yaml"),
|
|
1297
|
+
mtime("final/summary.md"),
|
|
1298
|
+
// Primary outputs feed outputReadyState; a summary cached before the
|
|
1299
|
+
// answer/plan/report/patch landed must invalidate when it does.
|
|
1300
|
+
mtime("final/answer.md"),
|
|
1301
|
+
mtime("final/plan.md"),
|
|
1302
|
+
mtime("final/explore.md"),
|
|
1303
|
+
mtime("final/report.md"),
|
|
1304
|
+
mtime("final/patch.diff"),
|
|
1305
|
+
// Orchestrate output is the prose plan + the typed plan; a summary cached
|
|
1306
|
+
// before they land (or after a revert re-stamps work_product) must invalidate.
|
|
1307
|
+
mtime("final/orchestration.md"),
|
|
1308
|
+
mtime("final/orchestration.yaml"),
|
|
1309
|
+
// Executor progress (auto_safe/auto_full) lands after the plan; a detail
|
|
1310
|
+
// cached before steps ran (or as they advance) must invalidate.
|
|
1311
|
+
mtime("final/orchestration_progress.yaml"),
|
|
1312
|
+
// The honest outcome (result kind / diffstat / adopted / apply_state) is
|
|
1313
|
+
// projected from work_product.yaml; a summary cached before it landed (or
|
|
1314
|
+
// before a revert flipped apply_state) must invalidate.
|
|
1315
|
+
mtime("final/work_product.yaml"),
|
|
1316
|
+
].join("|");
|
|
1317
|
+
}
|
|
1318
|
+
function paramsFingerprint(rec) {
|
|
1319
|
+
try {
|
|
1320
|
+
return sha256(JSON.stringify(rec.params ?? null));
|
|
1321
|
+
}
|
|
1322
|
+
catch {
|
|
1323
|
+
return "unserializable-params";
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
/** v0.9 strategy flags projected back so surfaces can tell a race from a repair loop. */
|
|
1327
|
+
function strategyFromParams(p) {
|
|
1328
|
+
if (p["untilClean"] === true)
|
|
1329
|
+
return "until_clean";
|
|
1330
|
+
if (typeof p["attempts"] === "number" && p["attempts"] > 0)
|
|
1331
|
+
return "attempts";
|
|
1332
|
+
if (p["create"] === true)
|
|
1333
|
+
return "create";
|
|
1334
|
+
if (p["swarm"] === true)
|
|
1335
|
+
return "swarm";
|
|
1336
|
+
if (typeof p["n"] === "number" && p["n"] > 1)
|
|
1337
|
+
return "race";
|
|
1338
|
+
return null;
|
|
1339
|
+
}
|
|
1340
|
+
/**
|
|
1341
|
+
* Honest terminal outcome, projected from final/work_product.yaml's meta (the
|
|
1342
|
+
* orchestrator-owned record of what the turn produced). Answers the v0.9 "is the
|
|
1343
|
+
* game done?" gap: plan runs report kind=plan with a null diffStat (no files
|
|
1344
|
+
* changed), patches report a real diffStat, and a race-adopted patch reports
|
|
1345
|
+
* adopted=true.
|
|
1346
|
+
*/
|
|
1347
|
+
function controlRunResult(rec) {
|
|
1348
|
+
const wp = safeReadStructuredArtifact(rec, "final/work_product.yaml", WorkProduct);
|
|
1349
|
+
const meta = (wp?.meta ?? {});
|
|
1350
|
+
const kindRaw = meta["result_kind"];
|
|
1351
|
+
const kind = kindRaw === "patch" || kindRaw === "answer" || kindRaw === "plan" || kindRaw === "report" ? kindRaw : "none";
|
|
1352
|
+
const ds = meta["diffstat"];
|
|
1353
|
+
const diffStat = ds && typeof ds.files === "number"
|
|
1354
|
+
? { files: ds.files, additions: typeof ds.additions === "number" ? ds.additions : 0, deletions: typeof ds.deletions === "number" ? ds.deletions : 0 }
|
|
1355
|
+
: null;
|
|
1356
|
+
const applyStateRaw = meta["apply_state"];
|
|
1357
|
+
const applyState = applyStateRaw === "applied" || applyStateRaw === "applied_review_blocked" || applyStateRaw === "reverted"
|
|
1358
|
+
? applyStateRaw
|
|
1359
|
+
: "not_applied";
|
|
1360
|
+
const preTurnSha = typeof meta["pre_turn_sha"] === "string" ? meta["pre_turn_sha"] : null;
|
|
1361
|
+
const postTurnSha = typeof meta["post_turn_sha"] === "string" ? meta["post_turn_sha"] : null;
|
|
1362
|
+
// A run is revertable when it actually mutated the live tree this turn and the
|
|
1363
|
+
// pre/post snapshots needed for a safe restore exist. The daemon revert handler
|
|
1364
|
+
// still re-checks the tree hasn't diverged from post_turn_sha before acting.
|
|
1365
|
+
const revertable = (applyState === "applied" || applyState === "applied_review_blocked") && preTurnSha !== null && postTurnSha !== null;
|
|
1366
|
+
return ControlRunResult.parse({
|
|
1367
|
+
kind,
|
|
1368
|
+
diffStat,
|
|
1369
|
+
blockers: typeof meta["blockers"] === "number" ? meta["blockers"] : 0,
|
|
1370
|
+
adopted: typeof meta["adopted"] === "boolean" ? meta["adopted"] : null,
|
|
1371
|
+
applyState,
|
|
1372
|
+
preTurnSha,
|
|
1373
|
+
postTurnSha,
|
|
1374
|
+
revertable,
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
/** Flip the persisted work_product apply_state to `reverted` after a successful
|
|
1378
|
+
* revert so the run stops advertising a Revert affordance (single source: the
|
|
1379
|
+
* work_product meta that controlRunResult projects). Best-effort: the revert
|
|
1380
|
+
* already happened; a metadata write failure must not 500 the response. */
|
|
1381
|
+
function markRunReverted(rec) {
|
|
1382
|
+
try {
|
|
1383
|
+
if (!rec.runDir)
|
|
1384
|
+
return;
|
|
1385
|
+
const root = safeArtifactRoot(rec.runDir);
|
|
1386
|
+
if (!root)
|
|
1387
|
+
return;
|
|
1388
|
+
const wpPath = join(root, "final", "work_product.yaml");
|
|
1389
|
+
if (!existsSync(wpPath))
|
|
1390
|
+
return;
|
|
1391
|
+
const doc = (parseYaml(readFileSync(wpPath, "utf8")) ?? {});
|
|
1392
|
+
const meta = (doc["meta"] && typeof doc["meta"] === "object" ? doc["meta"] : {});
|
|
1393
|
+
meta["apply_state"] = "reverted";
|
|
1394
|
+
doc["meta"] = meta;
|
|
1395
|
+
// Atomic tmp+rename: a crash mid-write must never leave work_product.yaml
|
|
1396
|
+
// half-written (it would degrade the run projection to kind: none).
|
|
1397
|
+
const tmp = `${wpPath}.tmp-${process.pid}`;
|
|
1398
|
+
writeFileSync(tmp, stringifyYaml(doc), "utf8");
|
|
1399
|
+
renameSync(tmp, wpPath);
|
|
1400
|
+
}
|
|
1401
|
+
catch {
|
|
1402
|
+
/* best-effort: the revert succeeded regardless of this metadata flip */
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
function summarizeRun(rec) {
|
|
1406
|
+
const p = paramsRecord(rec);
|
|
1407
|
+
// safeParse everywhere: one malformed job record (e.g. an old/foreign mode id)
|
|
1408
|
+
// must degrade to an unknown field, never 500 the whole run list forever.
|
|
1409
|
+
const parsedMode = ModeKind.safeParse(p["mode"]);
|
|
1410
|
+
const parsedPortfolio = Portfolio.safeParse(p["portfolio"]);
|
|
1411
|
+
const parsedAccess = parseAccessMaybe(p["access"]);
|
|
1412
|
+
const task = safeReadStructuredArtifact(rec, "context/task.yaml", TaskContract);
|
|
1413
|
+
const telemetry = safeReadStructuredArtifact(rec, "final/telemetry.yaml", RunTelemetry);
|
|
1414
|
+
// Access truth comes from engine artifacts ONLY (contract/telemetry); client
|
|
1415
|
+
// params can request but never assert what was effectively enforced.
|
|
1416
|
+
const requestedAccess = telemetry?.requested_access ?? task?.access.requested_profile ?? parsedAccess;
|
|
1417
|
+
const effectiveAccess = telemetry?.effective_access ?? task?.access.effective_profile;
|
|
1418
|
+
const externalContextPolicy = telemetry?.external_context_policy ?? task?.external_context.policy;
|
|
1419
|
+
const webEvidence = controlWebEvidence(telemetry, task);
|
|
1420
|
+
const decision = safeReadStructuredArtifact(rec, "arbitration/decision.yaml", DecisionRecord);
|
|
1421
|
+
const budget = budgetSnapshot(rec, decision);
|
|
1422
|
+
const parsedReviewerPanel = Array.isArray(p["reviewerPanel"])
|
|
1423
|
+
? ControlReviewerPanelEntry.array().safeParse(p["reviewerPanel"])
|
|
1424
|
+
: null;
|
|
1425
|
+
const parsedProtectedPathApprovals = Array.isArray(p["protectedPathApprovals"])
|
|
1426
|
+
? ProtectedPathApproval.array().safeParse(p["protectedPathApprovals"])
|
|
1427
|
+
: null;
|
|
1428
|
+
const requestTests = Array.isArray(p["tests"])
|
|
1429
|
+
? p["tests"].filter((x) => typeof x === "string")
|
|
1430
|
+
: undefined;
|
|
1431
|
+
const contractTests = task?.tests.commands
|
|
1432
|
+
.map((command) => command.command)
|
|
1433
|
+
.filter((command) => command.trim().length > 0);
|
|
1434
|
+
return ControlRunSummary.parse({
|
|
1435
|
+
jobId: rec.id,
|
|
1436
|
+
runId: rec.runId ?? rec.id,
|
|
1437
|
+
taskId: rec.taskId,
|
|
1438
|
+
state: rec.state,
|
|
1439
|
+
runDir: rec.runDir,
|
|
1440
|
+
error: rec.error,
|
|
1441
|
+
failure: readFailure(rec),
|
|
1442
|
+
project: projectMetadata(rec),
|
|
1443
|
+
mode: parsedMode.success ? parsedMode.data : undefined,
|
|
1444
|
+
strategy: strategyFromParams(p),
|
|
1445
|
+
prompt: typeof p["prompt"] === "string" ? redactPrompt(p["prompt"]) : undefined,
|
|
1446
|
+
harnesses: Array.isArray(p["harnesses"]) ? p["harnesses"].filter((x) => typeof x === "string") : undefined,
|
|
1447
|
+
primaryHarness: typeof p["primaryHarness"] === "string" ? p["primaryHarness"] : undefined,
|
|
1448
|
+
portfolio: parsedPortfolio.success ? parsedPortfolio.data : undefined,
|
|
1449
|
+
model: typeof p["model"] === "string" ? p["model"] : undefined,
|
|
1450
|
+
reviewerPanel: parsedReviewerPanel?.success ? parsedReviewerPanel.data : undefined,
|
|
1451
|
+
protectedPathApprovals: parsedProtectedPathApprovals?.success
|
|
1452
|
+
? parsedProtectedPathApprovals.data
|
|
1453
|
+
: undefined,
|
|
1454
|
+
n: typeof p["n"] === "number" ? p["n"] : undefined,
|
|
1455
|
+
// Engine-effective cap: the contract carries config-defaulted caps that
|
|
1456
|
+
// request params never knew about.
|
|
1457
|
+
maxUsd: typeof p["maxUsd"] === "number" ? p["maxUsd"] : task?.budget.max_usd ?? (p["maxUsd"] === null ? null : undefined),
|
|
1458
|
+
spendUsd: budget.spendUsd,
|
|
1459
|
+
spendEstimated: budget.estimated,
|
|
1460
|
+
access: effectiveAccess ?? parsedAccess,
|
|
1461
|
+
requestedAccess,
|
|
1462
|
+
effectiveAccess,
|
|
1463
|
+
externalContextPolicy,
|
|
1464
|
+
webRequired: telemetry?.web_required ?? task?.external_context.web_required,
|
|
1465
|
+
webMode: telemetry?.effective_web_mode ?? task?.external_context.effective_mode,
|
|
1466
|
+
webEvidence,
|
|
1467
|
+
toolPermissionPolicy: task?.tool_permission_policy,
|
|
1468
|
+
outputReadyState: outputReadyState(rec),
|
|
1469
|
+
toolWarningsTotal: telemetry?.tool_warnings_total ?? 0,
|
|
1470
|
+
result: controlRunResult(rec),
|
|
1471
|
+
route: controlRoute(telemetry, p),
|
|
1472
|
+
tests: requestTests ?? (contractTests?.length ? contractTests : undefined),
|
|
1473
|
+
specId: typeof p["specId"] === "string" ? p["specId"] : undefined,
|
|
1474
|
+
specHash: typeof p["specHash"] === "string" ? p["specHash"] : undefined,
|
|
1475
|
+
createdAt: rec.createdAt,
|
|
1476
|
+
startedAt: rec.startedAt,
|
|
1477
|
+
finishedAt: rec.finishedAt,
|
|
1478
|
+
});
|
|
1479
|
+
}
|
|
1480
|
+
/**
|
|
1481
|
+
* Project the orchestrator-owned telemetry artifact into the control DTO. The
|
|
1482
|
+
* control plane NEVER recomputes web evidence from raw events: a run without
|
|
1483
|
+
* `final/telemetry.yaml` (legacy or still running) renders `available: false`
|
|
1484
|
+
* so surfaces show "telemetry unavailable" instead of a recomputed guess.
|
|
1485
|
+
*/
|
|
1486
|
+
function controlWebEvidence(telemetry, task) {
|
|
1487
|
+
if (!telemetry) {
|
|
1488
|
+
return ControlWebEvidence.parse({
|
|
1489
|
+
required: task?.external_context.web_required ?? false,
|
|
1490
|
+
mode: task?.external_context.policy ?? "auto",
|
|
1491
|
+
effectiveMode: task?.external_context.effective_mode ?? task?.external_context.policy ?? "auto",
|
|
1492
|
+
available: false,
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
return ControlWebEvidence.parse({
|
|
1496
|
+
required: telemetry.web.required,
|
|
1497
|
+
mode: telemetry.web.policy,
|
|
1498
|
+
effectiveMode: telemetry.web.effective_mode,
|
|
1499
|
+
attempted: telemetry.web.attempted,
|
|
1500
|
+
satisfied: telemetry.web.satisfied,
|
|
1501
|
+
status: telemetry.web.status,
|
|
1502
|
+
tool: telemetry.web.tool,
|
|
1503
|
+
target: telemetry.web.target,
|
|
1504
|
+
errorSummary: telemetry.web.error_summary,
|
|
1505
|
+
rawDetailRef: "final/telemetry.yaml",
|
|
1506
|
+
available: true,
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1509
|
+
/**
|
|
1510
|
+
* Run-level route evidence: observed model comes ONLY from telemetry (the
|
|
1511
|
+
* harness stream's own disclosure); the requested model from run params.
|
|
1512
|
+
* `verified` is never inferred from the request alone.
|
|
1513
|
+
*/
|
|
1514
|
+
function controlRoute(telemetry, p) {
|
|
1515
|
+
if (!telemetry)
|
|
1516
|
+
return null;
|
|
1517
|
+
const finalAttempt = telemetry.final_attempt_id
|
|
1518
|
+
? telemetry.attempts.find((a) => a.attempt_id === telemetry.final_attempt_id)
|
|
1519
|
+
: undefined;
|
|
1520
|
+
const observed = finalAttempt?.observed_model ?? telemetry.attempts.find((a) => a.observed_model)?.observed_model ?? null;
|
|
1521
|
+
const harnessId = finalAttempt?.harness_id ?? telemetry.attempts.find((a) => a.observed_model)?.harness_id ?? null;
|
|
1522
|
+
return {
|
|
1523
|
+
// Scalar-only by design until per-candidate route evidence lands:
|
|
1524
|
+
// map-only pool members show requestedModel null here, honestly.
|
|
1525
|
+
requestedModel: typeof p["model"] === "string" ? p["model"] : null,
|
|
1526
|
+
observedModel: observed,
|
|
1527
|
+
harnessId,
|
|
1528
|
+
verified: observed !== null,
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
function detailFor(rec, pendingInteractions = [], cursor) {
|
|
1532
|
+
// Snapshot fence: capture the event cursor BEFORE building any projection.
|
|
1533
|
+
// The fence promise is "every event with seq <= lastSeq is reflected" — a
|
|
1534
|
+
// cursor read AFTER the projections could skip an event that landed in
|
|
1535
|
+
// between (the projections would not reflect it, and a client resuming from
|
|
1536
|
+
// that cursor would never see it). A pre-projection cursor errs the other
|
|
1537
|
+
// way: an in-between event is both reflected AND replayed, which clients
|
|
1538
|
+
// absorb (event application is reconciled against the newer snapshot).
|
|
1539
|
+
const lastSeq = cursor ?? (rec.runDir ? lastSeqInFile(join(rec.runDir, "events.jsonl")) : 0);
|
|
1540
|
+
const failure = readFailure(rec);
|
|
1541
|
+
const decision = safeReadStructuredArtifact(rec, "arbitration/decision.yaml", DecisionRecord);
|
|
1542
|
+
// Project ONLY a VALID operator decision (action is an unblock AND patch-hash
|
|
1543
|
+
// matches the current patch) — same gate as the needs-human inbox — so a stale
|
|
1544
|
+
// or mutated operator_decision.yaml can't make a surface render an apply
|
|
1545
|
+
// affordance for a run the server still considers blocked.
|
|
1546
|
+
const operator = readValidOperatorDecision(rec);
|
|
1547
|
+
const operatorDecisionRaw = operator
|
|
1548
|
+
? (() => {
|
|
1549
|
+
try {
|
|
1550
|
+
const doc = parseYaml(readTextArtifact(rec, "arbitration/operator_decision.yaml") ?? "");
|
|
1551
|
+
return { action: operator.action, decidedAt: typeof doc?.["decided_at"] === "string" ? doc["decided_at"] : null };
|
|
1552
|
+
}
|
|
1553
|
+
catch {
|
|
1554
|
+
return { action: operator.action, decidedAt: null };
|
|
1555
|
+
}
|
|
1556
|
+
})()
|
|
1557
|
+
: null;
|
|
1558
|
+
const summary = summarizeRun(rec);
|
|
1559
|
+
return ControlRunDetail.parse({
|
|
1560
|
+
summary: { ...summary, waitingOnUser: pendingInteractions.length > 0 },
|
|
1561
|
+
lastSeq,
|
|
1562
|
+
artifacts: rec.runDir ? listArtifacts(rec.runDir) : [],
|
|
1563
|
+
primaryOutput: primaryOutput(rec),
|
|
1564
|
+
timeline: timelineEvents(rec),
|
|
1565
|
+
budget: budgetSnapshot(rec, decision),
|
|
1566
|
+
finalSummary: readTextArtifact(rec, "final/summary.md"),
|
|
1567
|
+
decision,
|
|
1568
|
+
operatorDecision: operatorDecisionRaw,
|
|
1569
|
+
workProduct: safeReadStructuredArtifact(rec, "final/work_product.yaml", WorkProduct),
|
|
1570
|
+
// Derived apply-gate verdict (single producer: delivery's
|
|
1571
|
+
// deriveApplyEligibility) — null when the run has no patch artifact.
|
|
1572
|
+
applyEligibility: applyEligibilityFor(rec),
|
|
1573
|
+
reviewFindings: readReviewFindings(rec),
|
|
1574
|
+
pendingInteractions,
|
|
1575
|
+
// Typed executor progress for an orchestrate auto_safe/auto_full run; null
|
|
1576
|
+
// for suggest / non-orchestrate runs. Thin projection of the engine artifact.
|
|
1577
|
+
orchestrate: safeReadStructuredArtifact(rec, "final/orchestration_progress.yaml", OrchestratePlanProgress),
|
|
1578
|
+
// Per-candidate evidence cards: projected from the run's attempt/
|
|
1579
|
+
// review artifacts; empty for single-envelope modes.
|
|
1580
|
+
candidates: rec.runDir ? candidatesFor(rec.runDir, decision) : [],
|
|
1581
|
+
// Live plan checklist: the winner's (else last) plan.progress items.
|
|
1582
|
+
planProgress: latestPlanProgress(rec, decision?.winner ?? null),
|
|
1583
|
+
failure,
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1586
|
+
function readTextArtifact(rec, relPath, redact = true) {
|
|
1587
|
+
const text = readRawTextArtifact(rec, relPath);
|
|
1588
|
+
return text === null ? null : redact ? redactSecrets(text) : text;
|
|
1589
|
+
}
|
|
1590
|
+
function readRawTextArtifact(rec, relPath) {
|
|
1591
|
+
if (!rec.runDir)
|
|
1592
|
+
return null;
|
|
1593
|
+
const path = safeArtifactPath(rec.runDir, relPath);
|
|
1594
|
+
if (!path)
|
|
1595
|
+
return null;
|
|
1596
|
+
const st = lstatSync(path);
|
|
1597
|
+
if (st.isSymbolicLink() || st.isDirectory())
|
|
1598
|
+
return null;
|
|
1599
|
+
return readFileSync(path, "utf8");
|
|
1600
|
+
}
|
|
1601
|
+
function primaryOutput(rec) {
|
|
1602
|
+
const p = paramsRecord(rec);
|
|
1603
|
+
const mode = typeof p["mode"] === "string" ? p["mode"] : "";
|
|
1604
|
+
const candidates = mode === "ask"
|
|
1605
|
+
? [{ kind: "answer", path: "final/answer.md" }]
|
|
1606
|
+
: mode === "plan"
|
|
1607
|
+
? [{ kind: "plan", path: "final/plan.md" }]
|
|
1608
|
+
: mode === "audit"
|
|
1609
|
+
? [
|
|
1610
|
+
{ kind: "report", path: "final/report.md" },
|
|
1611
|
+
{ kind: "report", path: "final/explore.md" },
|
|
1612
|
+
{ kind: "summary", path: "final/summary.md" },
|
|
1613
|
+
]
|
|
1614
|
+
: mode === "orchestrate"
|
|
1615
|
+
? [{ kind: "report", path: "final/orchestration.md" }, { kind: "summary", path: "final/summary.md" }]
|
|
1616
|
+
: [
|
|
1617
|
+
// An agent answer-only turn (empty diff + prose) writes final/answer.md;
|
|
1618
|
+
// it must win over the arbitration summary so the chat shows the answer,
|
|
1619
|
+
// not "# Run … no_op … Candidates …" (review #1).
|
|
1620
|
+
{ kind: "answer", path: "final/answer.md" },
|
|
1621
|
+
{ kind: "summary", path: "final/summary.md" },
|
|
1622
|
+
{ kind: "patch", path: "final/patch.diff" },
|
|
1623
|
+
];
|
|
1624
|
+
for (const candidate of candidates) {
|
|
1625
|
+
const text = readTextArtifact(rec, candidate.path);
|
|
1626
|
+
if (text && text.trim()) {
|
|
1627
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
1628
|
+
return ControlPrimaryOutput.parse({ ...candidate, text, bytes });
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
const failure = readFailure(rec);
|
|
1632
|
+
if (failure) {
|
|
1633
|
+
return ControlPrimaryOutput.parse({
|
|
1634
|
+
kind: "diagnostic",
|
|
1635
|
+
path: failure.rawDetailRef ?? "final/failure.yaml",
|
|
1636
|
+
text: failure.safeMessage,
|
|
1637
|
+
bytes: Buffer.byteLength(failure.safeMessage, "utf8"),
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
return null;
|
|
1641
|
+
}
|
|
1642
|
+
function outputReadyState(rec) {
|
|
1643
|
+
const primary = primaryOutput(rec);
|
|
1644
|
+
if (primary?.kind === "diagnostic")
|
|
1645
|
+
return "diagnostic";
|
|
1646
|
+
if (primary?.text && primary.text.trim())
|
|
1647
|
+
return "ready";
|
|
1648
|
+
if (TERMINAL_STATES.has(rec.state))
|
|
1649
|
+
return readFailure(rec) ? "diagnostic" : "finalizing";
|
|
1650
|
+
return "pending";
|
|
1651
|
+
}
|
|
1652
|
+
function parseAccessMaybe(value) {
|
|
1653
|
+
if (typeof value !== "string")
|
|
1654
|
+
return undefined;
|
|
1655
|
+
const parsed = AccessProfile.safeParse(value);
|
|
1656
|
+
return parsed.success ? parsed.data : undefined;
|
|
1657
|
+
}
|
|
1658
|
+
/** Typed severity per event type — no string matching over event names. */
|
|
1659
|
+
function budgetSnapshot(rec, decision) {
|
|
1660
|
+
const p = paramsRecord(rec);
|
|
1661
|
+
// The ENGINE-EFFECTIVE cap lives in the immutable contract (request input,
|
|
1662
|
+
// surface default, or the configured global per-run default); request params
|
|
1663
|
+
// alone under-report a config-defaulted cap as "no cap".
|
|
1664
|
+
const contractCap = safeReadStructuredArtifact(rec, "context/task.yaml", TaskContract)?.budget.max_usd ?? null;
|
|
1665
|
+
const maxUsd = typeof p["maxUsd"] === "number" ? p["maxUsd"] : contractCap;
|
|
1666
|
+
let spendUsd = decision?.budget_summary?.spend_usd ?? null;
|
|
1667
|
+
let estimated = decision?.budget_summary?.estimated ?? false;
|
|
1668
|
+
let source = spendUsd === null ? "unknown" : "decision";
|
|
1669
|
+
if (spendUsd === null) {
|
|
1670
|
+
// budget.observation is the engine's authoritative spend stream (it covers
|
|
1671
|
+
// harness AND reviewer-panel spend, e.g. plan review which never writes a
|
|
1672
|
+
// decision record). Fall back to raw usage events only for legacy runs
|
|
1673
|
+
// that predate observations — never sum both (each usage cost is mirrored
|
|
1674
|
+
// as one observation).
|
|
1675
|
+
let observationSpend = 0;
|
|
1676
|
+
let sawObservation = false;
|
|
1677
|
+
let eventSpend = 0;
|
|
1678
|
+
let sawCost = false;
|
|
1679
|
+
let sawUsage = false;
|
|
1680
|
+
for (const ev of readRunEvents(rec)) {
|
|
1681
|
+
const payload = eventPayload(ev);
|
|
1682
|
+
if (ev["type"] === "budget.observation" && payload["kind"] === "spend") {
|
|
1683
|
+
const usd = payload["usd"];
|
|
1684
|
+
if (typeof usd === "number" && Number.isFinite(usd)) {
|
|
1685
|
+
observationSpend += usd;
|
|
1686
|
+
sawObservation = true;
|
|
1687
|
+
}
|
|
1688
|
+
if (payload["estimated"] === true)
|
|
1689
|
+
estimated = true;
|
|
1690
|
+
continue;
|
|
1691
|
+
}
|
|
1692
|
+
if (ev["type"] !== "harness.event")
|
|
1693
|
+
continue;
|
|
1694
|
+
const usage = payload["usage"];
|
|
1695
|
+
if (usage && typeof usage === "object" && !Array.isArray(usage)) {
|
|
1696
|
+
sawUsage = true;
|
|
1697
|
+
const cost = usage["cost_usd"];
|
|
1698
|
+
if (typeof cost === "number" && Number.isFinite(cost)) {
|
|
1699
|
+
eventSpend += cost;
|
|
1700
|
+
sawCost = true;
|
|
1701
|
+
}
|
|
1702
|
+
if (usage["estimated"] === true)
|
|
1703
|
+
estimated = true;
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
if (sawObservation) {
|
|
1707
|
+
spendUsd = observationSpend;
|
|
1708
|
+
source = "events";
|
|
1709
|
+
}
|
|
1710
|
+
else if (sawCost) {
|
|
1711
|
+
spendUsd = eventSpend;
|
|
1712
|
+
source = "events";
|
|
1713
|
+
}
|
|
1714
|
+
else if (sawUsage) {
|
|
1715
|
+
source = "events";
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
const remainingUsd = maxUsd !== null && spendUsd !== null ? Math.max(0, maxUsd - spendUsd) : null;
|
|
1719
|
+
return ControlBudgetSnapshot.parse({ maxUsd, spendUsd, remainingUsd, estimated, source });
|
|
1720
|
+
}
|
|
1721
|
+
function readStructured(text, ext, schema) {
|
|
1722
|
+
if (text === null)
|
|
1723
|
+
return null;
|
|
1724
|
+
if (ext === ".json") {
|
|
1725
|
+
return schema.parse(JSON.parse(text));
|
|
1726
|
+
}
|
|
1727
|
+
if (ext === ".yaml" || ext === ".yml") {
|
|
1728
|
+
return schema.parse(parseYaml(text));
|
|
1729
|
+
}
|
|
1730
|
+
throw new Error(`unsupported structured artifact extension: ${ext}`);
|
|
1731
|
+
}
|
|
1732
|
+
function listArtifacts(root) {
|
|
1733
|
+
const safeRoot = safeArtifactRoot(root);
|
|
1734
|
+
if (!safeRoot)
|
|
1735
|
+
return [];
|
|
1736
|
+
const out = [];
|
|
1737
|
+
const walk = (dir) => {
|
|
1738
|
+
for (const name of readdirSync(dir)) {
|
|
1739
|
+
const abs = join(dir, name);
|
|
1740
|
+
const st = lstatSync(abs);
|
|
1741
|
+
if (st.isSymbolicLink())
|
|
1742
|
+
continue;
|
|
1743
|
+
const rel = relative(safeRoot, abs).split(sep).join("/");
|
|
1744
|
+
out.push({ path: rel, kind: st.isDirectory() ? "directory" : "file", bytes: st.isDirectory() ? undefined : st.size, mime: st.isDirectory() ? undefined : artifactMime(rel) });
|
|
1745
|
+
if (st.isDirectory())
|
|
1746
|
+
walk(abs);
|
|
1747
|
+
}
|
|
1748
|
+
};
|
|
1749
|
+
walk(safeRoot);
|
|
1750
|
+
return out.sort((a, b) => a.path.localeCompare(b.path));
|
|
1751
|
+
}
|
|
1752
|
+
/** A run's project root, taken from its TYPED scope — NOT by path-slicing the
|
|
1753
|
+
* runDir. Slicing on `.claudexor` would resolve a no-project run (whose run dir
|
|
1754
|
+
* is `~/.claudexor/runs/<id>`) to the user's HOME and let /produced list
|
|
1755
|
+
* `~/artifacts` (review-flagged). Null for scope `none` ⇒ no produced outputs. */
|
|
1756
|
+
export function producedRepoRoot(rec) {
|
|
1757
|
+
const scope = rec.params?.scope;
|
|
1758
|
+
return scope?.kind === "project" && typeof scope.root === "string" && scope.root.trim() ? scope.root : null;
|
|
1759
|
+
}
|
|
1760
|
+
function readPatch(rec) {
|
|
1761
|
+
return readRawTextArtifact(rec, "final/patch.diff");
|
|
1762
|
+
}
|
|
1763
|
+
function runRepoRoot(rec) {
|
|
1764
|
+
const p = paramsRecord(rec);
|
|
1765
|
+
const scope = p["scope"];
|
|
1766
|
+
if (scope && typeof scope === "object" && !Array.isArray(scope)) {
|
|
1767
|
+
const s = scope;
|
|
1768
|
+
if (s["kind"] === "project" && typeof s["root"] === "string")
|
|
1769
|
+
return s["root"];
|
|
1770
|
+
if (s["kind"] === "none")
|
|
1771
|
+
return NO_PROJECT_ROOT;
|
|
1772
|
+
}
|
|
1773
|
+
let task = null;
|
|
1774
|
+
try {
|
|
1775
|
+
task = readStructured(readRawTextArtifact(rec, "context/task.yaml"), ".yaml", { parse: (value) => value });
|
|
1776
|
+
}
|
|
1777
|
+
catch {
|
|
1778
|
+
task = null;
|
|
1779
|
+
}
|
|
1780
|
+
if (task && typeof task === "object" && !Array.isArray(task)) {
|
|
1781
|
+
const repo = task["repo"];
|
|
1782
|
+
if (repo && typeof repo === "object" && !Array.isArray(repo)) {
|
|
1783
|
+
const root = repo["root"];
|
|
1784
|
+
if (typeof root === "string")
|
|
1785
|
+
return root;
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
return null;
|
|
1789
|
+
}
|
|
1790
|
+
function applyTargetRoot(target, rec) {
|
|
1791
|
+
if (target.kind === "project")
|
|
1792
|
+
return target.root;
|
|
1793
|
+
return runRepoRoot(rec);
|
|
1794
|
+
}
|
|
1795
|
+
/** Project the run record into the delivery package's single-owner apply gate. */
|
|
1796
|
+
function applyGateError(rec, patch, targetRepoRoot) {
|
|
1797
|
+
return validateApplyGate(applyGateInputFor(rec, patch, targetRepoRoot));
|
|
1798
|
+
}
|
|
1799
|
+
function applyGateInputFor(rec, patch, targetRepoRoot) {
|
|
1800
|
+
return {
|
|
1801
|
+
state: rec.state,
|
|
1802
|
+
decision: safeReadStructuredArtifact(rec, "arbitration/decision.yaml", DecisionRecord),
|
|
1803
|
+
workProduct: safeReadStructuredArtifact(rec, "final/work_product.yaml", WorkProduct),
|
|
1804
|
+
patch,
|
|
1805
|
+
originalRepoRoot: runRepoRoot(rec),
|
|
1806
|
+
targetRepoRoot,
|
|
1807
|
+
operatorDecision: readOperatorDecision(rec),
|
|
1808
|
+
};
|
|
1809
|
+
}
|
|
1810
|
+
/**
|
|
1811
|
+
* The GET /runs/:id projection of the apply gate: null when the run has no
|
|
1812
|
+
* patch artifact (nothing to apply); otherwise the derived verdict against
|
|
1813
|
+
* the run's own original project root.
|
|
1814
|
+
*/
|
|
1815
|
+
function applyEligibilityFor(rec) {
|
|
1816
|
+
const patch = readPatch(rec);
|
|
1817
|
+
if (patch === null || patch.trim() === "")
|
|
1818
|
+
return null;
|
|
1819
|
+
const root = runRepoRoot(rec);
|
|
1820
|
+
if (!root)
|
|
1821
|
+
return null;
|
|
1822
|
+
return deriveApplyEligibility(applyGateInputFor(rec, patch, root));
|
|
1823
|
+
}
|
|
1824
|
+
/**
|
|
1825
|
+
* SINGLE writer of the operator unblock artifact. Every accept_risk /
|
|
1826
|
+
* override decision routes through here so the on-disk shape and the path
|
|
1827
|
+
* resolution (server-fixed name, validated run-dir root) exist in exactly one
|
|
1828
|
+
* place — the mirror of readOperatorDecision below. Returns false when the run
|
|
1829
|
+
* artifact root cannot be resolved (caller fails loudly). */
|
|
1830
|
+
function writeOperatorDecision(rec, record) {
|
|
1831
|
+
// The artifact name is server-fixed (no client path input); only the run dir
|
|
1832
|
+
// root needs validating before the write.
|
|
1833
|
+
const root = rec.runDir ? safeArtifactRoot(rec.runDir) : null;
|
|
1834
|
+
if (!root)
|
|
1835
|
+
return false;
|
|
1836
|
+
mkdirSync(join(root, "arbitration"), { recursive: true });
|
|
1837
|
+
writeFileSync(join(root, "arbitration", "operator_decision.yaml"), stringifyYaml(record), "utf8");
|
|
1838
|
+
return true;
|
|
1839
|
+
}
|
|
1840
|
+
/** The persisted operator unblock decision (accept_risk / override), if any. */
|
|
1841
|
+
function readOperatorDecision(rec) {
|
|
1842
|
+
try {
|
|
1843
|
+
const raw = readTextArtifact(rec, "arbitration/operator_decision.yaml");
|
|
1844
|
+
if (!raw)
|
|
1845
|
+
return null;
|
|
1846
|
+
const doc = parseYaml(raw);
|
|
1847
|
+
if (!doc || typeof doc["action"] !== "string")
|
|
1848
|
+
return null;
|
|
1849
|
+
return { action: doc["action"], patch_sha256: typeof doc["patch_sha256"] === "string" ? doc["patch_sha256"] : undefined };
|
|
1850
|
+
}
|
|
1851
|
+
catch {
|
|
1852
|
+
return null;
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
/**
|
|
1856
|
+
* A VALID operator unblock decision: the action is a genuine unblock
|
|
1857
|
+
* (accept_risk / override_needs_human) AND its recorded patch_sha256 still
|
|
1858
|
+
* matches the current final/patch.diff. Used to clear the needs-human inbox —
|
|
1859
|
+
* mirrors the apply gate (delivery validateApplyGate) so a mutated decision
|
|
1860
|
+
* artifact or a mutated patch can NEVER silently hide a blocked run from the
|
|
1861
|
+
* operator. Returns null when the decision is absent, the wrong action, or stale.
|
|
1862
|
+
*/
|
|
1863
|
+
function readValidOperatorDecision(rec) {
|
|
1864
|
+
const decision = readOperatorDecision(rec);
|
|
1865
|
+
if (!decision)
|
|
1866
|
+
return null;
|
|
1867
|
+
if (decision.action !== "accept_risk" && decision.action !== "override_needs_human")
|
|
1868
|
+
return null;
|
|
1869
|
+
if (typeof decision.patch_sha256 !== "string" || decision.patch_sha256.length === 0)
|
|
1870
|
+
return null;
|
|
1871
|
+
const patch = readTextArtifact(rec, "final/patch.diff", false);
|
|
1872
|
+
if (patch === null || decision.patch_sha256 !== sha256(patch))
|
|
1873
|
+
return null;
|
|
1874
|
+
return decision;
|
|
1875
|
+
}
|
|
1876
|
+
function safeReadStructuredArtifact(rec, relPath, schema) {
|
|
1877
|
+
try {
|
|
1878
|
+
return readStructured(readTextArtifact(rec, relPath), extname(relPath), schema);
|
|
1879
|
+
}
|
|
1880
|
+
catch {
|
|
1881
|
+
return null;
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
function readReviewFindings(rec) {
|
|
1885
|
+
if (!rec.runDir)
|
|
1886
|
+
return [];
|
|
1887
|
+
const reviewsDir = safeArtifactPath(rec.runDir, "reviews");
|
|
1888
|
+
if (!reviewsDir || !lstatSync(reviewsDir).isDirectory())
|
|
1889
|
+
return [];
|
|
1890
|
+
const out = [];
|
|
1891
|
+
for (const name of readdirSync(reviewsDir).sort()) {
|
|
1892
|
+
const ext = extname(name);
|
|
1893
|
+
if (ext !== ".yaml" && ext !== ".yml" && ext !== ".json")
|
|
1894
|
+
continue;
|
|
1895
|
+
try {
|
|
1896
|
+
const rel = `reviews/${name}`;
|
|
1897
|
+
const raw = readTextArtifact(rec, rel);
|
|
1898
|
+
if (!raw)
|
|
1899
|
+
continue;
|
|
1900
|
+
const doc = ext === ".json" ? JSON.parse(raw) : parseYaml(raw);
|
|
1901
|
+
const findings = doc && typeof doc === "object" && !Array.isArray(doc) ? doc["findings"] : [];
|
|
1902
|
+
if (!Array.isArray(findings))
|
|
1903
|
+
continue;
|
|
1904
|
+
for (const finding of findings)
|
|
1905
|
+
out.push(ReviewFinding.parse(finding));
|
|
1906
|
+
}
|
|
1907
|
+
catch {
|
|
1908
|
+
/* malformed review artifact: omit from UI projection, artifact remains fetchable for diagnostics */
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
return out;
|
|
1912
|
+
}
|
|
1913
|
+
function contentType(path) {
|
|
1914
|
+
switch (extname(path).toLowerCase()) {
|
|
1915
|
+
case ".json": return "application/json; charset=utf-8";
|
|
1916
|
+
case ".md":
|
|
1917
|
+
case ".txt":
|
|
1918
|
+
case ".jsonl":
|
|
1919
|
+
case ".log":
|
|
1920
|
+
case ".diff":
|
|
1921
|
+
case ".patch":
|
|
1922
|
+
case ".yaml":
|
|
1923
|
+
case ".yml": return "text/plain; charset=utf-8";
|
|
1924
|
+
case ".html":
|
|
1925
|
+
case ".htm": return "text/html; charset=utf-8";
|
|
1926
|
+
case ".png": return "image/png";
|
|
1927
|
+
case ".jpg":
|
|
1928
|
+
case ".jpeg": return "image/jpeg";
|
|
1929
|
+
case ".gif": return "image/gif";
|
|
1930
|
+
case ".webp": return "image/webp";
|
|
1931
|
+
case ".svg": return "image/svg+xml";
|
|
1932
|
+
case ".pdf": return "application/pdf";
|
|
1933
|
+
default: return "application/octet-stream";
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
/** Clean MIME (no charset) for the artifact listing DTO. */
|
|
1937
|
+
function artifactMime(path) {
|
|
1938
|
+
return contentType(path).split(";")[0];
|
|
1939
|
+
}
|
|
1940
|
+
function isPatchArtifact(path) {
|
|
1941
|
+
const ext = extname(path);
|
|
1942
|
+
return ext === ".diff" || ext === ".patch";
|
|
1943
|
+
}
|
|
1944
|
+
function isTextArtifact(path) {
|
|
1945
|
+
const type = contentType(path);
|
|
1946
|
+
return type.startsWith("text/") || type.startsWith("application/json");
|
|
1947
|
+
}
|
|
1948
|
+
// Single allowlist shared with the CLI — includes claude_oauth, which
|
|
1949
|
+
// the claude adapter reads for subscription-route auth.
|
|
1950
|
+
function isAllowedSecretName(name) {
|
|
1951
|
+
return isManagedSecretName(name);
|
|
1952
|
+
}
|
|
1953
|
+
function validSecretSetBody(body) {
|
|
1954
|
+
return Boolean(body &&
|
|
1955
|
+
typeof body === "object" &&
|
|
1956
|
+
!Array.isArray(body) &&
|
|
1957
|
+
typeof body["name"] === "string" &&
|
|
1958
|
+
isAllowedSecretName(String(body["name"])));
|
|
1959
|
+
}
|
|
1960
|
+
function assertNoSpecBodySecrets(body) {
|
|
1961
|
+
assertNoInlineSecretValues(body, "$", "spec body");
|
|
1962
|
+
const serialized = JSON.stringify(body ?? null);
|
|
1963
|
+
if (containsSecretLikeToken(serialized)) {
|
|
1964
|
+
throw Object.assign(new Error("secret-like value is not accepted in spec body; store secrets by ref and keep specs durable/sanitized"), { status: 400 });
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
function redactPrompt(prompt) {
|
|
1968
|
+
const redacted = redactSecrets(prompt);
|
|
1969
|
+
return redacted.length > 240 ? `${redacted.slice(0, 240)}...` : redacted;
|
|
1970
|
+
}
|
|
1971
|
+
//# sourceMappingURL=daemon-server.js.map
|