@claudexor/control-api 3.0.4 → 3.1.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 (56) hide show
  1. package/dist/artifact-serve-routes.d.ts +46 -1
  2. package/dist/artifact-serve-routes.d.ts.map +1 -1
  3. package/dist/artifact-serve-routes.js +172 -8
  4. package/dist/artifact-serve-routes.js.map +1 -1
  5. package/dist/candidates.d.ts.map +1 -1
  6. package/dist/candidates.js +14 -1
  7. package/dist/candidates.js.map +1 -1
  8. package/dist/daemon-server.d.ts +67 -3
  9. package/dist/daemon-server.d.ts.map +1 -1
  10. package/dist/daemon-server.js +349 -50
  11. package/dist/daemon-server.js.map +1 -1
  12. package/dist/operation-catalog.d.ts +10 -1
  13. package/dist/operation-catalog.d.ts.map +1 -1
  14. package/dist/operation-catalog.js +150 -18
  15. package/dist/operation-catalog.js.map +1 -1
  16. package/dist/operation-parameters.d.ts +34 -0
  17. package/dist/operation-parameters.d.ts.map +1 -0
  18. package/dist/operation-parameters.js +52 -0
  19. package/dist/operation-parameters.js.map +1 -0
  20. package/dist/problem-response.d.ts +43 -0
  21. package/dist/problem-response.d.ts.map +1 -1
  22. package/dist/problem-response.js +192 -0
  23. package/dist/problem-response.js.map +1 -1
  24. package/dist/project-routes.d.ts +1 -0
  25. package/dist/project-routes.d.ts.map +1 -1
  26. package/dist/project-routes.js +18 -1
  27. package/dist/project-routes.js.map +1 -1
  28. package/dist/run-apply-routes.d.ts +6 -1
  29. package/dist/run-apply-routes.d.ts.map +1 -1
  30. package/dist/run-apply-routes.js +32 -2
  31. package/dist/run-apply-routes.js.map +1 -1
  32. package/dist/run-events-stream.d.ts.map +1 -1
  33. package/dist/run-events-stream.js +99 -64
  34. package/dist/run-events-stream.js.map +1 -1
  35. package/dist/run-list.d.ts +74 -0
  36. package/dist/run-list.d.ts.map +1 -0
  37. package/dist/run-list.js +156 -0
  38. package/dist/run-list.js.map +1 -0
  39. package/dist/run-retry-routes.d.ts.map +1 -1
  40. package/dist/run-retry-routes.js +78 -2
  41. package/dist/run-retry-routes.js.map +1 -1
  42. package/dist/run-start.d.ts +14 -1
  43. package/dist/run-start.d.ts.map +1 -1
  44. package/dist/run-start.js +60 -1
  45. package/dist/run-start.js.map +1 -1
  46. package/dist/run-timeline.d.ts +44 -3
  47. package/dist/run-timeline.d.ts.map +1 -1
  48. package/dist/run-timeline.js +143 -24
  49. package/dist/run-timeline.js.map +1 -1
  50. package/dist/thread-projection.d.ts.map +1 -1
  51. package/dist/thread-projection.js +6 -0
  52. package/dist/thread-projection.js.map +1 -1
  53. package/dist/thread-turn-routes.d.ts.map +1 -1
  54. package/dist/thread-turn-routes.js +20 -23
  55. package/dist/thread-turn-routes.js.map +1 -1
  56. package/package.json +6 -6
@@ -8,7 +8,7 @@ import { safeArtifactPath, safeArtifactRoot } from "./artifact-paths.js";
8
8
  import { TERMINAL_STATES } from "./sse-shared.js";
9
9
  import { streamRunEvents } from "./run-events-stream.js";
10
10
  import { boundedArtifactText, outputReadyState, primaryOutput } from "./primary-output.js";
11
- import { controlWebEvidence, eventPayload, latestPlanProgress, readRunEvents, timelineEvents, } from "./run-timeline.js";
11
+ import { controlWebEvidence, eventPayload, evidenceLevel, latestPlanProgress, readRunEvents, readRunEventsWithIntegrity, timelineEvents, } from "./run-timeline.js";
12
12
  import { projectSession, projectThread, projectTurn, turnRunCard } from "./thread-projection.js";
13
13
  import { chainThreadMutation, handleThreadTurnCreate, handleThreadTurnRetry, } from "./thread-turn-routes.js";
14
14
  import { assertThreadIdle, threadIdOfRun } from "./thread-mutation.js";
@@ -26,10 +26,11 @@ import { handleMaintenanceRoute } from "./maintenance-routes.js";
26
26
  import { handleResourceRoute } from "./resource-routes.js";
27
27
  import { handleArtifactServeRoute, listArtifacts, resolveProjectRoot, } from "./artifact-serve-routes.js";
28
28
  import { requiredGateSpecsFromTaskArtifact } from "./task-contract-gates.js";
29
- import { assertOnlyQueryParams, optionalBooleanQuery } from "./query.js";
30
- import { controlProblemError } from "./problem-response.js";
29
+ import { assertOnlyQueryParams, optionalBooleanQuery, singleQuery } from "./query.js";
30
+ import { lruGet, lruSet, parseRunListQuery, selectRunListPage, } from "./run-list.js";
31
+ import { controlProblemError, normalizeRequestValidationError, revertRefusedProblem, thrownProblemCode, } from "./problem-response.js";
31
32
  import { handleSecurityRoute } from "./security-routes.js";
32
- import { PlanQuestionsArtifact, CouncilProjection, derivePlanReadiness, ControlAuthReadinessRefreshRequest, ControlAuthReadinessRefreshResponse, ControlProblem, AccessProfile, AgentCapabilityCatalog, ControlHarnessListResponse, ControlHarnessModelsResponse, ControlSetupJob, ControlSetupJobCreateRequest, ControlSetupJobEvent, ControlSetupJobSnapshot, isTerminalControlSetupJobState, ControlSetupJobListFilter, ControlSetupJobListResponse, ControlRunStartInfo, ControlQueuedRunInfo, ControlRunControlRequest, ControlRunControlResponse, ControlReviewerPanelEntry, ControlRunDetail, ControlRunListResponse, ControlRunSummary, ControlRunResult, ControlBudgetSnapshot, PaidBudget, ControlSettingsSnapshot, ControlSettingsUpdateRequest, ControlQuotaResponse, ControlCredentialProfilesResponse, ControlCredentialProfileCreateRequest, ControlCredentialProfileCreateResponse, ControlCredentialProfileUpdateRequest, ControlCredentialProfileUpdateResponse, ControlCredentialProfileDeleteResponse, ControlInteractionAnswerRequest, ControlInteractionAnswerResponse, ControlRunDecisionRequest, ControlRunDecisionResponse, ControlThreadCreateRequest, ControlThreadTurnRequest, ControlThreadUpdateRequest, ControlThreadDetail, ControlThreadListResponse, DecisionRecord, ModeKind, RoutingGoal, ReviewFinding, RunDeliveryState, RunFailure, RunTelemetry, StructuredOutputConformance, TaskContract, ProtectedPathApproval, isTerminalLifecycle, needsDecision, outcomeBanner, outcomeFactsFromFailure, TestCommandInvocation, WorkProduct, } from "@claudexor/schema";
33
+ import { PlanQuestionsArtifact, CouncilProjection, derivePlanReadiness, ControlAuthReadinessRefreshRequest, ControlAuthReadinessRefreshResponse, ControlProblem, AccessProfile, AgentCapabilityCatalog, ControlHarnessListResponse, ControlHarnessModelsResponse, ControlSetupJob, ControlSetupJobCreateRequest, ControlSetupJobEvent, ControlSetupJobSnapshot, isTerminalControlSetupJobState, ControlSetupJobListFilter, ControlSetupJobListResponse, ControlRunStartInfo, ControlRunControlRequest, ControlRunControlResponse, ControlReviewerPanelEntry, ControlRunDetail, ControlRunListResponse, ControlRunSummary, ControlRunResult, ControlBudgetSnapshot, PaidBudget, ControlSettingsSnapshot, ControlSettingsUpdateRequest, ControlQuotaResponse, ControlCredentialProfilesResponse, ControlCredentialProfileCreateRequest, ControlCredentialProfileCreateResponse, ControlCredentialProfileUpdateRequest, ControlCredentialProfileUpdateResponse, ControlCredentialProfileDeleteResponse, ControlInteractionAnswerRequest, ControlInteractionAnswerResponse, ControlRunDecisionRequest, ControlRunDecisionResponse, ControlThreadCreateRequest, ControlThreadTurnRequest, ControlThreadUpdateRequest, ControlThreadDetail, ControlThreadListResponse, DecisionRecord, ModeKind, RoutingGoal, ReviewFinding, RunDeliveryState, RunFailure, RunTelemetry, StructuredOutputConformance, TaskContract, ProtectedPathApproval, isTerminalLifecycle, needsDecision, needsOperatorAttention, requiredActionsFor, outcomeBanner, outcomeFactsFromFailure, TestCommandInvocation, WorkProduct, } from "@claudexor/schema";
33
34
  import { resolveControlProtocol } from "./operation-catalog.js";
34
35
  import { assertNoInlineSecretValues, containsSecretLikeToken, errorCode, noProjectRepoRoot, nowIso, redactSecrets, sha256, } from "@claudexor/util";
35
36
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
@@ -41,6 +42,13 @@ function setupEventProtocolError(message) {
41
42
  requiredActions: ["resnapshot"],
42
43
  });
43
44
  }
45
+ function invalidRunCursor(message) {
46
+ return Object.assign(new Error(message), {
47
+ status: 400,
48
+ code: "invalid_run_event_cursor",
49
+ requiredActions: ["resnapshot"],
50
+ });
51
+ }
44
52
  function finiteHttpStatus(error, fallback) {
45
53
  if (!error || typeof error !== "object" || !("status" in error))
46
54
  return fallback;
@@ -248,7 +256,11 @@ export class DaemonControlApiServer {
248
256
  return this.tokenMatches(m?.[1]?.trim());
249
257
  }
250
258
  requestError(res, err) {
251
- this.problem(res, finiteHttpStatus(err, 400), err, "invalid_request", false, "bad request");
259
+ // QA-053: at the request boundary, project a raw ZodError into structured
260
+ // fieldErrors + a single-line human message (see normalizeRequestValidationError).
261
+ // Typed errors and non-Zod errors pass through unchanged.
262
+ const normalized = normalizeRequestValidationError(err);
263
+ this.problem(res, finiteHttpStatus(normalized, 400), normalized, "invalid_request", false, "bad request");
252
264
  }
253
265
  json(res, status, body, contentType = "application/json") {
254
266
  if (status >= 400 && contentType === "application/json") {
@@ -276,7 +288,25 @@ export class DaemonControlApiServer {
276
288
  }
277
289
  if (chunks.length === 0)
278
290
  return {};
279
- const raw = Buffer.concat(chunks).toString("utf8").trim();
291
+ // QA-056: decode the complete byte body STRICTLY. `Buffer.toString("utf8")`
292
+ // is non-fatal — it silently replaces malformed bytes with U+FFFD, so an
293
+ // invalid octet (FF, a lone continuation byte, overlong/ truncated sequence)
294
+ // would slip through as a valid JS string and could pass Zod, the secret
295
+ // fence, idempotency hashing and a durable mutation storing a value that
296
+ // differs from the wire bytes. TextDecoder({fatal:true}) throws on any
297
+ // malformed byte; concatenating first keeps valid multibyte chars split
298
+ // across HTTP chunks intact.
299
+ let decoded;
300
+ try {
301
+ decoded = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks));
302
+ }
303
+ catch {
304
+ throw Object.assign(new Error("request body must be valid UTF-8"), {
305
+ status: 400,
306
+ code: "invalid_encoding",
307
+ });
308
+ }
309
+ const raw = decoded.trim();
280
310
  if (!raw)
281
311
  return {};
282
312
  try {
@@ -287,12 +317,11 @@ export class DaemonControlApiServer {
287
317
  }
288
318
  }
289
319
  onRequest(req, res) {
290
- let tracked;
291
- tracked = this.handle(req, res)
320
+ const tracked = this.handle(req, res)
292
321
  .catch((err) => {
293
322
  try {
294
323
  if (!res.headersSent) {
295
- this.problem(res, finiteHttpStatus(err, 500), err, "internal_error", false, "internal server error");
324
+ this.problem(res, finiteHttpStatus(err, 500), err, thrownProblemCode(err), false, "internal server error");
296
325
  }
297
326
  else
298
327
  res.end();
@@ -375,10 +404,7 @@ export class DaemonControlApiServer {
375
404
  }, req, res);
376
405
  }
377
406
  if (method === "GET" && path === "/runs") {
378
- const runs = await this.opts.daemon.list();
379
- return this.json(res, 200, ControlRunListResponse.parse({
380
- runs: runs.map((r) => this.summarizeRunOrDiagnostic(r)),
381
- }));
407
+ return this.handleRunList(url, res);
382
408
  }
383
409
  if (await handleRunRetryRoute({
384
410
  daemon: this.opts.daemon,
@@ -491,7 +517,7 @@ export class DaemonControlApiServer {
491
517
  return this.json(res, 501, { error: "threads are not supported by this engine build" });
492
518
  const { threads, problems } = await svc();
493
519
  const runs = await this.opts.daemon.list();
494
- const blocked = new Set(runs.filter((r) => this.runNeedsDecision(r)).map((r) => r.runId ?? r.id));
520
+ const blocked = new Set(runs.filter((r) => this.runNeedsAttention(r)).map((r) => r.runId ?? r.id));
495
521
  return this.json(res, 200, ControlThreadListResponse.parse({
496
522
  threads: threads.map((t) => projectThread(t, blocked.has(t.head_run_id ?? ""))),
497
523
  problems: problems ?? [],
@@ -517,7 +543,7 @@ export class DaemonControlApiServer {
517
543
  }
518
544
  }
519
545
  const headRec = byRun.get(thread.head_run_id ?? "");
520
- const headNeedsHuman = headRec ? this.runNeedsDecision(headRec) : false;
546
+ const headNeedsHuman = headRec ? this.runNeedsAttention(headRec) : false;
521
547
  return this.json(res, 200, ControlThreadDetail.parse({
522
548
  thread: projectThread(detail.thread, headNeedsHuman),
523
549
  sessions: detail.sessions.map(projectSession),
@@ -584,6 +610,7 @@ export class DaemonControlApiServer {
584
610
  if (await handleArtifactServeRoute({
585
611
  findRun: (id) => this.findRun(id),
586
612
  resolveProjectRoot: (id) => resolveProjectRoot(this.opts.services?.listProjects, id),
613
+ resolveThreadWorkspace: (id) => this.resolveThreadWorkspace(id),
587
614
  json: (response, status, body) => this.json(response, status, body),
588
615
  }, method, path, res))
589
616
  return;
@@ -610,6 +637,20 @@ export class DaemonControlApiServer {
610
637
  const decisionAction = body.action;
611
638
  try {
612
639
  return await this.chainRunMutation(rec, async () => {
640
+ // D-16: a work_state veto (the model attested needs_input / incomplete)
641
+ // is NON-OVERRIDABLE — a risk acceptance cannot supply missing input,
642
+ // and the delivery gate refuses the override on that axis. Reject
643
+ // accept_risk with a TYPED problem here instead of recording a false
644
+ // "Apply is now available" ACK that the gate would then refuse.
645
+ const workVeto = this.runWorkStateVeto(rec);
646
+ if (workVeto) {
647
+ return this.json(res, 409, {
648
+ error: workVeto === "needs_input"
649
+ ? "run reported it needs more input; a risk override cannot supply the missing input — re-run with the input (rerun_with_feedback)"
650
+ : "run reported the work is incomplete; a risk override cannot finish it — re-run until it completes (rerun_with_feedback)",
651
+ code: "work_state_needs_input",
652
+ });
653
+ }
613
654
  // The override unblocks a NEEDS-DECISION run (review blocked /
614
655
  // checks failed, D8); recording one elsewhere would claim an apply
615
656
  // permission that does not exist.
@@ -650,7 +691,10 @@ export class DaemonControlApiServer {
650
691
  return this.json(res, 200, ControlRunDecisionResponse.parse({
651
692
  accepted: true,
652
693
  status: "applied",
653
- message: `${decisionAction} recorded; apply is now permitted for this exact patch`,
694
+ // Honest ACK (QA-032B): nothing is mutated at decision time. The
695
+ // risk is accepted for this exact patch; Apply is now available
696
+ // and a fresh final check runs just-in-time before any mutation.
697
+ message: `${decisionAction} recorded for this exact patch; Apply is now available and will run a fresh final check before changing the project`,
654
698
  }));
655
699
  });
656
700
  }
@@ -682,13 +726,23 @@ export class DaemonControlApiServer {
682
726
  }
683
727
  const revert = await revertInPlaceFromAnchor(repoRoot, result.revertAnchorId);
684
728
  if (!revert.reverted) {
729
+ // QA-051: emit a FULL ControlProblem. The raw workspace reason
730
+ // concatenates a stable English explanation with locale-
731
+ // dependent Git stderr; that vendor diagnostic must not BE the
732
+ // semantic message. Present a stable English message + typed
733
+ // reason code, and keep the redacted, bounded stderr as
734
+ // context evidence the client can inspect deliberately.
735
+ const problem = revertRefusedProblem(revert.reason, revert.reasonCode);
685
736
  appendRunAuditEvent(rec, "control.rejected", {
686
737
  decision: "revert_run",
687
- reason: revert.reason ?? "revert refused",
738
+ reason: problem.context.reason,
739
+ detail: problem.context.detail,
688
740
  });
689
- throw Object.assign(new Error(revert.reason ?? "revert refused"), {
741
+ throw Object.assign(new Error(problem.message), {
690
742
  status: 409,
691
743
  code: "revert_refused",
744
+ retryable: false,
745
+ context: problem.context,
692
746
  });
693
747
  }
694
748
  markRunApplyState(rec, "reverted");
@@ -874,7 +928,17 @@ export class DaemonControlApiServer {
874
928
  }
875
929
  const setupJobExtendMatch = /^\/setup\/jobs\/([^/]+)\/extend$/.exec(path);
876
930
  if (method === "POST" && setupJobExtendMatch) {
877
- return this.service(res, "extendSetupJob", { jobId: decodeURIComponent(setupJobExtendMatch[1]) }, ControlSetupJob);
931
+ // QA-075/Ф2: Idempotency-Key is OPTIONAL (installed macOS Extend sends
932
+ // none): present → replay-safe, absent → non-idempotent, malformed → 400.
933
+ let idempotencyKey;
934
+ try {
935
+ idempotencyKey = runStart.optionalIdempotencyKey(req);
936
+ }
937
+ catch (err) {
938
+ return this.requestError(res, err);
939
+ }
940
+ const jobId = decodeURIComponent(setupJobExtendMatch[1]);
941
+ return this.service(res, "extendSetupJob", { jobId, ...(idempotencyKey !== undefined ? { idempotencyKey } : {}) }, ControlSetupJob);
878
942
  }
879
943
  const setupJobEventsMatch = /^\/setup\/jobs\/([^/]+)\/events$/.exec(path);
880
944
  if (method === "GET" && setupJobEventsMatch) {
@@ -988,7 +1052,15 @@ export class DaemonControlApiServer {
988
1052
  const eventsMatch = /^\/runs\/([^/]+)\/events$/.exec(path);
989
1053
  if (method === "GET" && eventsMatch) {
990
1054
  const id = decodeURIComponent(eventsMatch[1]);
991
- const last = this.lastEventId(req, url);
1055
+ let last;
1056
+ try {
1057
+ last = this.lastEventId(req, url);
1058
+ }
1059
+ catch (err) {
1060
+ // Validate the resume cursor BEFORE committing SSE headers so a
1061
+ // malformed cursor is a typed 400, not a silent full replay (QA-061).
1062
+ return this.requestError(res, err);
1063
+ }
992
1064
  return this.streamEvents(id, last, req, res);
993
1065
  }
994
1066
  return this.json(res, 404, { error: "not found" });
@@ -1003,7 +1075,7 @@ export class DaemonControlApiServer {
1003
1075
  value = await fn(arg);
1004
1076
  }
1005
1077
  catch (err) {
1006
- return this.problem(res, finiteHttpStatus(err, 500), err, "internal_error", false, "service failed");
1078
+ return this.problem(res, finiteHttpStatus(err, 500), err, thrownProblemCode(err), false, "service failed");
1007
1079
  }
1008
1080
  try {
1009
1081
  return this.json(res, 200, schema ? schema.parse(value) : value);
@@ -1219,14 +1291,61 @@ export class DaemonControlApiServer {
1219
1291
  runDir: rec.runDir,
1220
1292
  }));
1221
1293
  }
1222
- const status = TERMINAL_STATES.has(rec.state) ? 500 : 202;
1223
- const body = ControlQueuedRunInfo.parse({ jobId: rec.id, state: rec.state, error: rec.error });
1294
+ const { status, body } = runStart.unboundRunStartResponse(rec, TERMINAL_STATES.has(rec.state));
1224
1295
  return this.json(res, status, body);
1225
1296
  }
1226
1297
  async findRun(id) {
1227
1298
  const runs = await this.opts.daemon.list();
1228
1299
  return runs.find((r) => r.id === id || r.runId === id) ?? null;
1229
1300
  }
1301
+ /**
1302
+ * GET /v2/runs (QA-052): a bounded, newest-first, keyset-paginated page of run
1303
+ * summaries. Ordering, filtering, and slicing all happen on the raw daemon
1304
+ * records BEFORE any summary is materialized, so the expensive per-run
1305
+ * artifact fingerprint/projection work is bounded by `limit` (page size), not
1306
+ * by the total retained-record count. `limit`/`state`/`cursor` are typed and
1307
+ * strict — an unknown or malformed param is a typed 400, never silently
1308
+ * ignored. The bare (unparametered) call is still valid and now returns the
1309
+ * newest `RUN_LIST_DEFAULT_LIMIT` runs with `hasMore`/`nextCursor` to page the
1310
+ * rest.
1311
+ */
1312
+ async handleRunList(url, res) {
1313
+ let query;
1314
+ try {
1315
+ query = parseRunListQuery(url);
1316
+ }
1317
+ catch (error) {
1318
+ return this.requestError(res, error);
1319
+ }
1320
+ const { page, hasMore, nextCursor } = selectRunListPage(await this.opts.daemon.list(), query);
1321
+ return this.json(res, 200, ControlRunListResponse.parse({
1322
+ runs: page.map((r) => this.summarizeRunOrDiagnostic(r)),
1323
+ nextCursor,
1324
+ hasMore,
1325
+ }));
1326
+ }
1327
+ /** Resolve a thread's workspace record (mode + isolated worktree path) from the
1328
+ * thread journal — the honest source for QA-038 isolated /produced resolution.
1329
+ * Trashed/purged threads are retained by getThread (purge nulls worktree_path),
1330
+ * so an isolated run's execution tree is diagnosable, never silently the live
1331
+ * project. Absent service or an unknown thread ⇒ null (non-thread fallback). */
1332
+ async resolveThreadWorkspace(threadId) {
1333
+ const svc = this.opts.services?.threadDetail;
1334
+ // No thread service / no workspace record ⇒ null (in-place / one-shot: live
1335
+ // root is correct). A THROWN lookup is NOT swallowed to null: for a run bound
1336
+ // to a thread that fails OPEN to the live project (QA-038), so it propagates
1337
+ // and `resolveProducedRoot` fails CLOSED with a typed authority-unavailable.
1338
+ if (!svc)
1339
+ return null;
1340
+ const { thread } = await svc(threadId);
1341
+ const ws = thread?.workspace;
1342
+ if (!ws)
1343
+ return null;
1344
+ return {
1345
+ mode: ws.mode === "isolated" ? "isolated" : "in_place",
1346
+ worktreePath: typeof ws.worktree_path === "string" && ws.worktree_path.trim() ? ws.worktree_path : null,
1347
+ };
1348
+ }
1230
1349
  operatorDecisionFor(rec) {
1231
1350
  return this.opts.services?.operatorDecision?.(rec.runId ?? rec.id, rec.params) ?? null;
1232
1351
  }
@@ -1237,16 +1356,40 @@ export class DaemonControlApiServer {
1237
1356
  const patch = readTextArtifact(rec, "final/patch.diff", false);
1238
1357
  return patch !== null && decision.patchSha256 === sha256(patch) ? decision : null;
1239
1358
  }
1240
- /** The needs-me / inbox signal (D8), via the ONE projection owner: a terminal
1241
- * run whose review is blocked or checks failed, with no valid operator
1242
- * decision recorded — the axes replacement for the ex state==="blocked". */
1359
+ /** The RISK-OVERRIDABLE needs-decision signal: a terminal run whose review is
1360
+ * blocked or checks failed, with no valid operator decision recorded. This is
1361
+ * the gate for accept_risk / override_needs_human — a D-16 work_state veto is
1362
+ * NOT included here (a risk override cannot supply missing input); see
1363
+ * `runWorkStateVeto`. */
1243
1364
  runNeedsDecision(rec) {
1244
1365
  const arb = safeReadStructuredArtifact(rec, "arbitration/decision.yaml", DecisionRecord);
1245
- const facts = runOutcomeFacts(rec, arb, readFailure(rec));
1366
+ const facts = runOutcomeFacts(rec, arb, readFailure(rec), winnerWorkStateFor(rec));
1246
1367
  if (!facts)
1247
1368
  return false;
1248
1369
  return needsDecision(facts, this.validOperatorDecisionFor(rec) !== null);
1249
1370
  }
1371
+ /** The needs-me / inbox signal (D8): EITHER a risk-overridable needs-decision
1372
+ * OR a non-overridable D-16 work_state needs-input veto. Inbox/head surfaces
1373
+ * fold both so a needs_input run still surfaces as needing the operator. */
1374
+ runNeedsAttention(rec) {
1375
+ const arb = safeReadStructuredArtifact(rec, "arbitration/decision.yaml", DecisionRecord);
1376
+ const facts = runOutcomeFacts(rec, arb, readFailure(rec), winnerWorkStateFor(rec));
1377
+ if (!facts)
1378
+ return false;
1379
+ return needsOperatorAttention(facts, this.validOperatorDecisionFor(rec) !== null);
1380
+ }
1381
+ /** D-16: the non-overridable work_state veto on a succeeded run — the model
1382
+ * attested it needs input / is incomplete. A risk override cannot resolve it,
1383
+ * so the decision endpoint rejects accept_risk with a typed problem instead of
1384
+ * a false "Apply is now available" ACK the delivery gate would then refuse. */
1385
+ runWorkStateVeto(rec) {
1386
+ const arb = safeReadStructuredArtifact(rec, "arbitration/decision.yaml", DecisionRecord);
1387
+ const facts = runOutcomeFacts(rec, arb, readFailure(rec), winnerWorkStateFor(rec));
1388
+ if (!facts || facts.lifecycle !== "succeeded")
1389
+ return null;
1390
+ const state = facts.work_state?.state;
1391
+ return state === "needs_input" || state === "incomplete" ? state : null;
1392
+ }
1250
1393
  recordOperatorDecision(rec, decision, idempotency) {
1251
1394
  const record = this.opts.services?.recordOperatorDecision;
1252
1395
  if (!record) {
@@ -1296,6 +1439,7 @@ export class DaemonControlApiServer {
1296
1439
  chainMutation: (record, work) => this.chainRunMutation(record, work),
1297
1440
  appendAudit: appendRunAuditEvent,
1298
1441
  markApplied: (record) => markRunApplyState(record, "applied"),
1442
+ deliveredApplyState: (record) => controlRunResult(record).applyState,
1299
1443
  };
1300
1444
  }
1301
1445
  threadLifecycleRouteCtx() {
@@ -1339,17 +1483,24 @@ export class DaemonControlApiServer {
1339
1483
  * runs change only when their artifacts change, so summaries are cached on a
1340
1484
  * state+artifact-mtime fingerprint.
1341
1485
  */
1486
+ static SUMMARY_CACHE_MAX = 1_000;
1342
1487
  summarizeRunCached(rec) {
1343
1488
  const fingerprint = summaryFingerprint(rec);
1344
- const hit = this.summaryCache.get(rec.id);
1489
+ const hit = lruGet(this.summaryCache, rec.id);
1345
1490
  if (hit && hit.fingerprint === fingerprint)
1346
1491
  return hit.summary;
1347
1492
  const summary = summarizeRun(rec);
1348
- if (this.summaryCache.size > 1_000)
1349
- this.summaryCache.clear(); // bounded; repopulates on the next poll
1350
- this.summaryCache.set(rec.id, { fingerprint, summary });
1493
+ // QA-052: LRU-bounded eviction (oldest entry drops) instead of the old
1494
+ // wholesale clear at the guard, so a large retained set never pays a full
1495
+ // cache rehydration wave.
1496
+ lruSet(this.summaryCache, rec.id, { fingerprint, summary }, DaemonControlApiServer.SUMMARY_CACHE_MAX);
1351
1497
  return summary;
1352
1498
  }
1499
+ /** Test-only observability: current summary-cache entry count. Proves QA-052
1500
+ * LRU boundedness (the cache never grows past SUMMARY_CACHE_MAX). */
1501
+ summaryCacheSizeForTests() {
1502
+ return this.summaryCache.size;
1503
+ }
1353
1504
  /**
1354
1505
  * Cached artifact projection + journal-backed waiting_on_user overlay. Pending
1355
1506
  * interactions are not part of the run artifact fingerprint, so they must
@@ -1378,12 +1529,30 @@ export class DaemonControlApiServer {
1378
1529
  });
1379
1530
  }
1380
1531
  }
1532
+ /**
1533
+ * Strict per-run SSE resume cursor (QA-061). The run cursor is the durable
1534
+ * nonnegative integer event `seq` (ControlRunDetail.lastSeq). Mirrors the
1535
+ * global/project/setup streams' typed contract instead of permissive
1536
+ * `Number()` + fallback-0:
1537
+ * absent -> 0 (intentional full replay)
1538
+ * canonical decimal 0+ -> that safe integer
1539
+ * present but invalid -> typed HTTP 400 (before SSE headers)
1540
+ * A present-but-invalid header is NOT silently erased in favour of the query
1541
+ * alias; the header wins when present, the query is used only when it is absent.
1542
+ */
1381
1543
  lastEventId(req, url) {
1544
+ assertOnlyQueryParams(url, ["lastEventId"]);
1382
1545
  const rawHeader = req.headers["last-event-id"];
1383
- const headerId = rawHeader !== undefined ? Number(rawHeader) : Number.NaN;
1384
- const rawQuery = url.searchParams.get("lastEventId");
1385
- const queryId = rawQuery !== null ? Number(rawQuery) : Number.NaN;
1386
- return Number.isFinite(headerId) ? headerId : Number.isFinite(queryId) ? queryId : 0;
1546
+ if (Array.isArray(rawHeader))
1547
+ throw invalidRunCursor("Last-Event-ID may be specified only once");
1548
+ const rawQuery = singleQuery(url, "lastEventId");
1549
+ const raw = rawHeader !== undefined ? rawHeader : rawQuery;
1550
+ if (raw === undefined)
1551
+ return 0;
1552
+ if (!/^(0|[1-9][0-9]*)$/.test(raw) || !Number.isSafeInteger(Number(raw))) {
1553
+ throw invalidRunCursor("run event cursor must be a nonnegative integer event seq; refetch the run snapshot and resume from its lastSeq");
1554
+ }
1555
+ return Number(raw);
1387
1556
  }
1388
1557
  async streamEvents(id, lastEventId, req, res) {
1389
1558
  return streamRunEvents({
@@ -1459,8 +1628,23 @@ function appendRunAuditEvent(rec, type, payload) {
1459
1628
  /* audit append must not change control behavior */
1460
1629
  }
1461
1630
  }
1631
+ /**
1632
+ * QA-052 observability: how many artifact-path probes the run-list fingerprinter
1633
+ * has performed (each probe is a synchronous filesystem stat-family lookup). The
1634
+ * single-parse test pattern (eventsParseCount) proves warm-list work is BOUNDED
1635
+ * — by page size (only sliced records are fingerprinted) and, for terminal runs,
1636
+ * by the one-probe short-circuit below (1 path instead of 12).
1637
+ */
1638
+ let fingerprintProbeCount = 0;
1639
+ export function runListFingerprintProbeCountForTests() {
1640
+ return fingerprintProbeCount;
1641
+ }
1642
+ export function resetRunListFingerprintProbeCountForTests() {
1643
+ fingerprintProbeCount = 0;
1644
+ }
1462
1645
  function summaryFingerprint(rec) {
1463
1646
  const mtime = (rel) => {
1647
+ fingerprintProbeCount++;
1464
1648
  if (!rec.runDir)
1465
1649
  return 0;
1466
1650
  const path = safeArtifactPath(rec.runDir, rel);
@@ -1473,11 +1657,22 @@ function summaryFingerprint(rec) {
1473
1657
  return 0;
1474
1658
  }
1475
1659
  };
1660
+ const identity = [rec.state, paramsFingerprint(rec), rec.finishedAt ?? "", rec.error ?? ""];
1661
+ // QA-052 terminal short-circuit: a TERMINAL run's every projected artifact
1662
+ // (events.jsonl, arbitration, telemetry, failure, the final/* outputs,
1663
+ // work_product) is written before the daemon marks the run terminal and never
1664
+ // changes afterward. The SOLE post-terminal mutation is the delivery/apply
1665
+ // overlay (final/delivery_state.yaml), which the apply/revert route is the one
1666
+ // writer of [B7]. So a warm re-list of a terminal run fingerprints ONE path
1667
+ // instead of twelve — the cheap probe still detects an apply/revert, and every
1668
+ // other axis is frozen. Active (queued/running) runs keep the full
1669
+ // artifact-mtime fingerprint because their events.jsonl and final/* land while
1670
+ // the run is still live.
1671
+ if (TERMINAL_STATES.has(rec.state)) {
1672
+ return [...identity, mtime("final/delivery_state.yaml")].join("|");
1673
+ }
1476
1674
  return [
1477
- rec.state,
1478
- paramsFingerprint(rec),
1479
- rec.finishedAt ?? "",
1480
- rec.error ?? "",
1675
+ ...identity,
1481
1676
  mtime("events.jsonl"),
1482
1677
  mtime("arbitration/decision.yaml"),
1483
1678
  mtime("final/telemetry.yaml"),
@@ -1626,13 +1821,42 @@ function markRunApplyState(rec, state) {
1626
1821
  * lifecycle (a decision-less plan/readonly/crash terminal). Null while the run
1627
1822
  * is not terminal. Lives here as the ONE control-plane projection; surfaces
1628
1823
  * read `outcomeFacts`, never re-derive. */
1629
- function runOutcomeFacts(rec, decision, failure) {
1824
+ function runOutcomeFacts(rec, decision, failure,
1825
+ /** D-16: the winning read-only/plan attempt's work_state (from telemetry),
1826
+ * folded into a DECISION-LESS terminal so a needs_input/incomplete ask
1827
+ * carries its veto axis just like an arbitrated agent run. */
1828
+ workState = null) {
1630
1829
  if (!isTerminalLifecycle(rec.state))
1631
1830
  return null;
1831
+ // Arbitrated runs already carry work_state on decision.facts (INV-116).
1632
1832
  if (decision)
1633
1833
  return decision.facts;
1634
1834
  const lifecycle = rec.state;
1635
- return outcomeFactsFromFailure(lifecycle, failure?.category);
1835
+ const base = outcomeFactsFromFailure(lifecycle, failure?.category);
1836
+ if (!workState || lifecycle !== "succeeded")
1837
+ return base;
1838
+ const vetoed = workState.state === "needs_input" || workState.state === "incomplete";
1839
+ return {
1840
+ ...base,
1841
+ work_state: workState,
1842
+ ...(vetoed
1843
+ ? {
1844
+ reason: base.reason ??
1845
+ (workState.state === "needs_input" ? "input_required" : "work_incomplete"),
1846
+ }
1847
+ : {}),
1848
+ };
1849
+ }
1850
+ /** D-16: the winning attempt's work_state from a run's telemetry.yaml, used to
1851
+ * fold the veto axis into a decision-less terminal's outcome facts. */
1852
+ function winnerWorkStateFor(rec) {
1853
+ const telemetry = safeReadStructuredArtifact(rec, "final/telemetry.yaml", RunTelemetry);
1854
+ if (!telemetry)
1855
+ return null;
1856
+ const winner = telemetry.final_attempt_id
1857
+ ? telemetry.attempts.find((a) => a.attempt_id === telemetry.final_attempt_id)
1858
+ : undefined;
1859
+ return winner?.outcome.work_state ?? null;
1636
1860
  }
1637
1861
  function summarizeRun(rec, eventsSnapshot) {
1638
1862
  const p = paramsRecord(rec);
@@ -1725,8 +1949,14 @@ function summarizeRun(rec, eventsSnapshot) {
1725
1949
  outputReadyState: outputReadyState(rec, parsedMode.success ? parsedMode.data : null, readFailure(rec)),
1726
1950
  toolWarningsTotal: telemetry?.tool_warnings_total ?? 0,
1727
1951
  result: controlRunResult(rec),
1728
- outcomeFacts: runOutcomeFacts(rec, decision, readFailure(rec)),
1952
+ outcomeFacts: runOutcomeFacts(rec, decision, readFailure(rec), telemetry?.final_attempt_id
1953
+ ? (telemetry.attempts.find((a) => a.attempt_id === telemetry.final_attempt_id)?.outcome
1954
+ .work_state ?? null)
1955
+ : null),
1729
1956
  route: controlRoute(telemetry, p),
1957
+ // QA-034: routing rationale projected verbatim from the engine telemetry —
1958
+ // surfaces never reconstruct the order from prose.
1959
+ routingRationale: telemetry?.routing_rationale ?? null,
1730
1960
  tests: requestTests ?? (contractTests?.length ? contractTests : undefined),
1731
1961
  createdAt: rec.createdAt,
1732
1962
  startedAt: rec.startedAt,
@@ -1776,7 +2006,9 @@ function detailFor(rec, pendingInteractions = [], cursor, operator = null) {
1776
2006
  // RunDetail perf (D15): parse events.jsonl ONCE per detail request and thread
1777
2007
  // the snapshot through every event-consuming projection (timeline, budget
1778
2008
  // fallback, plan progress) — before this each field re-parsed the whole log.
1779
- const events = rec.runDir ? readRunEvents(rec) : [];
2009
+ const { events, integrity } = rec.runDir
2010
+ ? readRunEventsWithIntegrity(rec)
2011
+ : { events: [], integrity: undefined };
1780
2012
  const summary = summarizeRun(rec, events);
1781
2013
  // Non-null exactly when the run has an applyable patch — the authoritative
1782
2014
  // "something to apply" banner signal (a convergence patch may carry kind:patch
@@ -1794,8 +2026,8 @@ function detailFor(rec, pendingInteractions = [], cursor, operator = null) {
1794
2026
  lastSeq,
1795
2027
  artifacts: rec.runDir ? listArtifacts(rec.runDir) : [],
1796
2028
  primaryOutput: primaryOutput(rec, summary.mode, failure),
1797
- timeline: timelineEvents(rec, events),
1798
- budget: budgetSnapshot(rec, decision, events),
2029
+ timeline: timelineEvents(rec, events, integrity),
2030
+ budget: budgetSnapshot(rec, decision, events, integrity),
1799
2031
  finalSummary: boundedArtifactText(rec, "final/summary.md"),
1800
2032
  decision,
1801
2033
  operatorDecision: operatorDecisionRaw,
@@ -1812,8 +2044,13 @@ function detailFor(rec, pendingInteractions = [], cursor, operator = null) {
1812
2044
  // review artifacts; empty for single-envelope modes.
1813
2045
  candidates: rec.runDir ? candidatesFor(rec.runDir, decision) : [],
1814
2046
  // Live plan checklist: the winner's (else last) plan.progress items.
1815
- planProgress: latestPlanProgress(rec, decision?.winner ?? null, events),
2047
+ planProgress: latestPlanProgress(rec, decision?.winner ?? null, events, integrity),
1816
2048
  failure,
2049
+ // Minimal typed required-actions (GH #29) for a succeeded-but-blocked run,
2050
+ // from the single status-projection owner: review-blocked / checks-failed /
2051
+ // needs-decision / work_state needs_input, keyed to the same validated
2052
+ // operator decision the needs-decision + apply gates consult.
2053
+ requiredActions: requiredActionsFor(summary.outcomeFacts ?? null, operator !== null),
1817
2054
  });
1818
2055
  }
1819
2056
  function readTextArtifact(rec, relPath, redact = true) {
@@ -1837,8 +2074,44 @@ function parseAccessMaybe(value) {
1837
2074
  const parsed = AccessProfile.safeParse(value);
1838
2075
  return parsed.success ? parsed.data : undefined;
1839
2076
  }
2077
+ /**
2078
+ * Pure subscription-VALUATION fold over a run's events (QA-023c/QA-017b),
2079
+ * independent of the cash truth. The owner-locked ledger settles native-
2080
+ * subscription work to cash $0 while accumulating its token valuation on
2081
+ * `budget.cash.valuation_usd` (last-wins, cumulative). An UNKNOWN valuation (no
2082
+ * usage ever reported — e.g. a Cursor draft) stays NULL and is never coerced to
2083
+ * a fake $0. The legacy fallback for runs predating budget.cash is the summed
2084
+ * `budget.observation` spend. Vendor token valuation is at best an estimate.
2085
+ */
2086
+ export function budgetValuationFromEvents(events) {
2087
+ let valuationUsd = null;
2088
+ let observation = 0;
2089
+ let sawObservation = false;
2090
+ for (const ev of events) {
2091
+ const payload = eventPayload(ev);
2092
+ if (ev["type"] === "budget.cash") {
2093
+ const val = payload["valuation_usd"];
2094
+ if (typeof val === "number" && Number.isFinite(val))
2095
+ valuationUsd = val;
2096
+ continue;
2097
+ }
2098
+ if (ev["type"] === "budget.observation" && payload["kind"] === "spend") {
2099
+ const usd = payload["usd"];
2100
+ if (typeof usd === "number" && Number.isFinite(usd)) {
2101
+ observation += usd;
2102
+ sawObservation = true;
2103
+ }
2104
+ }
2105
+ }
2106
+ if (valuationUsd === null && sawObservation)
2107
+ valuationUsd = observation;
2108
+ return {
2109
+ valuationUsd,
2110
+ valuationKnowledge: valuationUsd === null ? "unknown" : "estimated",
2111
+ };
2112
+ }
1840
2113
  /** Typed severity per event type — no string matching over event names. */
1841
- function budgetSnapshot(rec, decision, eventsSnapshot) {
2114
+ function budgetSnapshot(rec, decision, eventsSnapshot, integrity) {
1842
2115
  const p = paramsRecord(rec);
1843
2116
  // The ENGINE-EFFECTIVE cap lives in the immutable contract (request input,
1844
2117
  // surface default, or the configured global per-run default); request params
@@ -1847,9 +2120,13 @@ function budgetSnapshot(rec, decision, eventsSnapshot) {
1847
2120
  .paid_budget;
1848
2121
  const paidBudget = PaidBudget.safeParse(p["paidBudget"]).data ??
1849
2122
  contractBudget ?? { kind: "unlimited" };
2123
+ const evs = eventsSnapshot ?? readRunEvents(rec);
1850
2124
  let spendUsd = decision?.budget_summary?.spend_usd ?? null;
1851
2125
  let estimated = decision?.budget_summary?.estimated ?? false;
1852
2126
  let source = spendUsd === null ? "unknown" : "decision";
2127
+ // Subscription VALUATION (QA-023c/QA-017b) beside cash — computed by the pure
2128
+ // event fold so an UNKNOWN valuation stays null, never a fabricated $0.
2129
+ const { valuationUsd, valuationKnowledge } = budgetValuationFromEvents(evs);
1853
2130
  if (spendUsd === null) {
1854
2131
  // The CASH truth for a decision-less run (plan/ask/explore — they never
1855
2132
  // write a decision record) is the ledger's own `budget.cash` disclosure:
@@ -1866,7 +2143,7 @@ function budgetSnapshot(rec, decision, eventsSnapshot) {
1866
2143
  let eventSpend = 0;
1867
2144
  let sawCost = false;
1868
2145
  let sawUsage = false;
1869
- for (const ev of eventsSnapshot ?? readRunEvents(rec)) {
2146
+ for (const ev of evs) {
1870
2147
  const payload = eventPayload(ev);
1871
2148
  if (ev["type"] === "budget.cash") {
1872
2149
  const cash = payload["cash_spend_usd"];
@@ -1921,7 +2198,20 @@ function budgetSnapshot(rec, decision, eventsSnapshot) {
1921
2198
  const remainingUsd = paidBudget.kind === "finite" && spendUsd !== null
1922
2199
  ? Math.max(0, paidBudget.maxUsd - spendUsd)
1923
2200
  : null;
1924
- return ControlBudgetSnapshot.parse({ paidBudget, spendUsd, remainingUsd, estimated, source });
2201
+ // Disclose when the spend fallback read incomplete/unreadable canonical
2202
+ // events (QA-074): a partial event set can silently drop a spend tick, so the
2203
+ // projected spend must never present as clean when its evidence was not.
2204
+ const evidence = integrity ? evidenceLevel(integrity) : "complete";
2205
+ return ControlBudgetSnapshot.parse({
2206
+ paidBudget,
2207
+ spendUsd,
2208
+ valuationUsd,
2209
+ valuationKnowledge,
2210
+ remainingUsd,
2211
+ estimated,
2212
+ source,
2213
+ evidence,
2214
+ });
1925
2215
  }
1926
2216
  function readStructured(text, ext, schema) {
1927
2217
  if (text === null)
@@ -1979,9 +2269,10 @@ function applyGateError(rec, patch, targetRepoRoot, operatorDecision, finalVerif
1979
2269
  });
1980
2270
  }
1981
2271
  function applyGateInputFor(rec, patch, targetRepoRoot, operatorDecision) {
2272
+ const decision = safeReadStructuredArtifact(rec, "arbitration/decision.yaml", DecisionRecord);
1982
2273
  return {
1983
2274
  state: rec.state,
1984
- decision: safeReadStructuredArtifact(rec, "arbitration/decision.yaml", DecisionRecord),
2275
+ decision,
1985
2276
  workProduct: safeReadStructuredArtifact(rec, "final/work_product.yaml", WorkProduct),
1986
2277
  patch,
1987
2278
  originalRepoRoot: runRepoRoot(rec),
@@ -1989,6 +2280,14 @@ function applyGateInputFor(rec, patch, targetRepoRoot, operatorDecision) {
1989
2280
  operatorDecision: operatorDecision
1990
2281
  ? { action: operatorDecision.action, patch_sha256: operatorDecision.patchSha256 }
1991
2282
  : null,
2283
+ // Effective mutable delivery/apply state from the SAME owner that projects
2284
+ // summary.result.applyState (delivery_state overlay → work_product snapshot):
2285
+ // an already-applied / reverted run gets a terminal eligibility disposition
2286
+ // instead of a stale "rerun a fresh check" (QA-021).
2287
+ applyState: controlRunResult(rec).applyState,
2288
+ // D-16 work_state veto (INV-116): thread the model-attested work outcome so
2289
+ // the gate refuses a needs_input/incomplete winner even with a clean review.
2290
+ workState: decision?.facts?.work_state ?? null,
1992
2291
  };
1993
2292
  }
1994
2293
  /**