@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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -0
  3. package/dist/artifact-paths.d.ts +3 -0
  4. package/dist/artifact-paths.d.ts.map +1 -0
  5. package/dist/artifact-paths.js +36 -0
  6. package/dist/artifact-paths.js.map +1 -0
  7. package/dist/candidates.d.ts +3 -0
  8. package/dist/candidates.d.ts.map +1 -0
  9. package/dist/candidates.js +77 -0
  10. package/dist/candidates.js.map +1 -0
  11. package/dist/daemon-server.d.ts +196 -0
  12. package/dist/daemon-server.d.ts.map +1 -0
  13. package/dist/daemon-server.js +1971 -0
  14. package/dist/daemon-server.js.map +1 -0
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +2 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/run-events-stream.d.ts +28 -0
  20. package/dist/run-events-stream.d.ts.map +1 -0
  21. package/dist/run-events-stream.js +146 -0
  22. package/dist/run-events-stream.js.map +1 -0
  23. package/dist/run-start.d.ts +14 -0
  24. package/dist/run-start.d.ts.map +1 -0
  25. package/dist/run-start.js +99 -0
  26. package/dist/run-start.js.map +1 -0
  27. package/dist/run-timeline.d.ts +22 -0
  28. package/dist/run-timeline.d.ts.map +1 -0
  29. package/dist/run-timeline.js +155 -0
  30. package/dist/run-timeline.js.map +1 -0
  31. package/dist/sse-shared.d.ts +11 -0
  32. package/dist/sse-shared.d.ts.map +1 -0
  33. package/dist/sse-shared.js +47 -0
  34. package/dist/sse-shared.js.map +1 -0
  35. package/dist/thread-projection.d.ts +12 -0
  36. package/dist/thread-projection.d.ts.map +1 -0
  37. package/dist/thread-projection.js +83 -0
  38. package/dist/thread-projection.js.map +1 -0
  39. package/dist/thread-turn-routes.d.ts +63 -0
  40. package/dist/thread-turn-routes.d.ts.map +1 -0
  41. package/dist/thread-turn-routes.js +317 -0
  42. package/dist/thread-turn-routes.js.map +1 -0
  43. 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