@claudexor/control-api 3.1.2 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/dist/authorization.d.ts +3 -0
  2. package/dist/authorization.d.ts.map +1 -0
  3. package/dist/authorization.js +15 -0
  4. package/dist/authorization.js.map +1 -0
  5. package/dist/binary-response.d.ts +5 -0
  6. package/dist/binary-response.d.ts.map +1 -0
  7. package/dist/binary-response.js +31 -0
  8. package/dist/binary-response.js.map +1 -0
  9. package/dist/catalog-query.d.ts +310 -0
  10. package/dist/catalog-query.d.ts.map +1 -0
  11. package/dist/catalog-query.js +36 -0
  12. package/dist/catalog-query.js.map +1 -0
  13. package/dist/daemon-server.d.ts +28 -17
  14. package/dist/daemon-server.d.ts.map +1 -1
  15. package/dist/daemon-server.js +115 -223
  16. package/dist/daemon-server.js.map +1 -1
  17. package/dist/decision-accept-risk.d.ts +36 -0
  18. package/dist/decision-accept-risk.d.ts.map +1 -0
  19. package/dist/decision-accept-risk.js +79 -0
  20. package/dist/decision-accept-risk.js.map +1 -0
  21. package/dist/decision-rerun.d.ts +2 -1
  22. package/dist/decision-rerun.d.ts.map +1 -1
  23. package/dist/decision-rerun.js +72 -48
  24. package/dist/decision-rerun.js.map +1 -1
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +1 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/maintenance-routes.d.ts +2 -2
  30. package/dist/maintenance-routes.d.ts.map +1 -1
  31. package/dist/maintenance-routes.js +11 -10
  32. package/dist/maintenance-routes.js.map +1 -1
  33. package/dist/operation-catalog.d.ts +9 -9
  34. package/dist/operation-catalog.d.ts.map +1 -1
  35. package/dist/operation-catalog.js +22 -79
  36. package/dist/operation-catalog.js.map +1 -1
  37. package/dist/operation-draft.d.ts +3 -0
  38. package/dist/operation-draft.d.ts.map +1 -0
  39. package/dist/operation-draft.js +2 -0
  40. package/dist/operation-draft.js.map +1 -0
  41. package/dist/operation-parameters.d.ts.map +1 -1
  42. package/dist/operation-summaries.d.ts +3 -0
  43. package/dist/operation-summaries.d.ts.map +1 -0
  44. package/dist/operation-summaries.js +75 -0
  45. package/dist/operation-summaries.js.map +1 -0
  46. package/dist/primary-output.d.ts +3 -3
  47. package/dist/primary-output.d.ts.map +1 -1
  48. package/dist/primary-output.js +70 -19
  49. package/dist/primary-output.js.map +1 -1
  50. package/dist/problem-response.d.ts.map +1 -1
  51. package/dist/problem-response.js +14 -9
  52. package/dist/problem-response.js.map +1 -1
  53. package/dist/project-routes.d.ts +21 -13
  54. package/dist/project-routes.d.ts.map +1 -1
  55. package/dist/project-routes.js +80 -34
  56. package/dist/project-routes.js.map +1 -1
  57. package/dist/remote-operation-descriptors.d.ts +36 -0
  58. package/dist/remote-operation-descriptors.d.ts.map +1 -0
  59. package/dist/remote-operation-descriptors.js +35 -0
  60. package/dist/remote-operation-descriptors.js.map +1 -0
  61. package/dist/resource-routes.d.ts +4 -3
  62. package/dist/resource-routes.d.ts.map +1 -1
  63. package/dist/resource-routes.js +63 -39
  64. package/dist/resource-routes.js.map +1 -1
  65. package/dist/route-stages.d.ts +15 -0
  66. package/dist/route-stages.d.ts.map +1 -0
  67. package/dist/route-stages.js +24 -0
  68. package/dist/route-stages.js.map +1 -0
  69. package/dist/run-facts-projection.d.ts +35 -0
  70. package/dist/run-facts-projection.d.ts.map +1 -0
  71. package/dist/run-facts-projection.js +133 -0
  72. package/dist/run-facts-projection.js.map +1 -0
  73. package/dist/run-list-fingerprint.d.ts +7 -0
  74. package/dist/run-list-fingerprint.d.ts.map +1 -0
  75. package/dist/run-list-fingerprint.js +68 -0
  76. package/dist/run-list-fingerprint.js.map +1 -0
  77. package/dist/run-record.d.ts +4 -0
  78. package/dist/run-record.d.ts.map +1 -1
  79. package/dist/run-record.js.map +1 -1
  80. package/dist/run-retry-routes.d.ts +3 -2
  81. package/dist/run-retry-routes.d.ts.map +1 -1
  82. package/dist/run-retry-routes.js +103 -47
  83. package/dist/run-retry-routes.js.map +1 -1
  84. package/dist/run-start.d.ts +13 -1
  85. package/dist/run-start.d.ts.map +1 -1
  86. package/dist/run-start.js +85 -18
  87. package/dist/run-start.js.map +1 -1
  88. package/dist/run-timeline.d.ts.map +1 -1
  89. package/dist/run-timeline.js +16 -9
  90. package/dist/run-timeline.js.map +1 -1
  91. package/dist/thread-lifecycle-routes.d.ts +1 -1
  92. package/dist/thread-lifecycle-routes.d.ts.map +1 -1
  93. package/dist/thread-lifecycle-routes.js +3 -4
  94. package/dist/thread-lifecycle-routes.js.map +1 -1
  95. package/dist/thread-mutation.d.ts +6 -0
  96. package/dist/thread-mutation.d.ts.map +1 -1
  97. package/dist/thread-mutation.js +23 -0
  98. package/dist/thread-mutation.js.map +1 -1
  99. package/dist/thread-projection.d.ts.map +1 -1
  100. package/dist/thread-projection.js +9 -0
  101. package/dist/thread-projection.js.map +1 -1
  102. package/dist/thread-recovery.d.ts +54 -0
  103. package/dist/thread-recovery.d.ts.map +1 -0
  104. package/dist/thread-recovery.js +71 -0
  105. package/dist/thread-recovery.js.map +1 -0
  106. package/dist/thread-turn-routes.d.ts +22 -8
  107. package/dist/thread-turn-routes.d.ts.map +1 -1
  108. package/dist/thread-turn-routes.js +73 -49
  109. package/dist/thread-turn-routes.js.map +1 -1
  110. package/package.json +6 -6
@@ -1,5 +1,5 @@
1
1
  import { timingSafeEqual } from "node:crypto";
2
- import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync, } from "node:fs";
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync, } from "node:fs";
3
3
  import { createServer } from "node:http";
4
4
  import { basename, extname, join } from "node:path";
5
5
  import { deriveApplyEligibility, revertInPlaceFromAnchor, validateApplyGate, verifyAndDeliver, } from "@claudexor/delivery";
@@ -12,8 +12,8 @@ import { budgetValuationFromEvents, cashEstimatedFromLedgerEvent, normalizeLegac
12
12
  import { controlWebEvidence, eventPayload, evidenceLevel, latestPlanProgress, readRunEvents, readRunEventsWithIntegrity, timelineEvents, } from "./run-timeline.js";
13
13
  import { projectTurnRunCards } from "./thread-projection.js";
14
14
  import { projectSession, projectThread, projectTurn } from "./thread-projection.js";
15
- import { chainThreadMutation, handleThreadTurnCreate, handleThreadTurnRetry, } from "./thread-turn-routes.js";
16
- import { assertThreadIdle, threadIdOfRun } from "./thread-mutation.js";
15
+ import { handleThreadTurnCreate, handleThreadTurnRetry, } from "./thread-turn-routes.js";
16
+ import { chainIdleRunMutation, chainThreadMutation } from "./thread-mutation.js";
17
17
  import { handleThreadLifecycleRoutes, } from "./thread-lifecycle-routes.js";
18
18
  import * as runStart from "./run-start.js";
19
19
  import { cancelDelegationFamily } from "./delegation-control.js";
@@ -21,22 +21,30 @@ import { directDelegatedChildrenFromRecords, delegatedDescendantsFromRecords, pa
21
21
  import { handleRunRetryRoute } from "./run-retry-routes.js";
22
22
  import { handleRunApplyRoutes, runIdempotentDelivery, } from "./run-apply-routes.js";
23
23
  import { rerunWithFeedback } from "./decision-rerun.js";
24
+ import { acceptRiskDecision } from "./decision-accept-risk.js";
24
25
  export { normalizeRunStartRequest } from "./run-start.js";
25
26
  import { candidatesFor } from "./candidates.js";
26
27
  import { handleProjectRoute } from "./project-routes.js";
28
+ import { writeBinaryResponse } from "./binary-response.js";
29
+ export { inlineContentDisposition } from "./binary-response.js";
27
30
  import { handleRecoveryRoute } from "./recovery-routes.js";
28
31
  import { handleJournalEventRoute } from "./journal-event-routes.js";
29
32
  import { handleMaintenanceRoute } from "./maintenance-routes.js";
30
33
  import { handleResourceRoute } from "./resource-routes.js";
31
34
  import { handleArtifactServeRoute, listArtifacts, resolveProjectRoot, } from "./artifact-serve-routes.js";
32
35
  import { requiredGateSpecsFromTaskArtifact } from "./task-contract-gates.js";
33
- import { assertOnlyQueryParams, optionalBooleanQuery, singleQuery } from "./query.js";
36
+ import { bearerCredential } from "./authorization.js";
37
+ import { assertOnlyQueryParams, singleQuery } from "./query.js";
38
+ import { parseCredentialProfilesSnapshotQuery, parseHarnessListQuery, parseRunApplicabilityQuery, } from "./catalog-query.js";
34
39
  import { lruGet, lruSet, parseRunListQuery, selectRunListPage, } from "./run-list.js";
40
+ import { summaryFingerprint } from "./run-list-fingerprint.js";
41
+ export { resetRunListFingerprintProbeCountForTests, runListFingerprintProbeCountForTests, } from "./run-list-fingerprint.js";
35
42
  import { controlProblemError, normalizeRequestValidationError, revertRefusedProblem, thrownProblemCode, } from "./problem-response.js";
36
43
  import { handleSecurityRoute } from "./security-routes.js";
37
- 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, Id, ModeKind, RoutingGoal, ReviewFinding, RunDeliveryState, RunFailure, RunTelemetry, StructuredOutputConformance, TaskContract, ProtectedPathApproval, isTerminalLifecycle, needsDecision, needsOperatorAttention, requiredActionsFor, outcomeBanner, outcomeFactsFromFailure, TestCommandInvocation, WorkProduct, } from "@claudexor/schema";
44
+ import { effectiveTerminalFacts, expectedRunFacts, projectRunFactsForDetail, } from "./run-facts-projection.js";
45
+ 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, ControlCredentialProfileCreateRequest, ControlCredentialProfileCreateResponse, ControlCredentialProfileUpdateRequest, ControlCredentialProfileUpdateResponse, ControlCredentialProfileDeleteResponse, ControlInteractionAnswerRequest, ControlInteractionAnswerResponse, ControlRunDecisionRequest, ControlRunDecisionResponse, ControlRunApplicabilityResponse, ControlThreadCreateRequest, ControlThreadTurnRequest, ControlThreadUpdateRequest, ControlThreadDetail, ControlThreadListResponse, DecisionRecord, Id, ModeKind, RoutingGoal, ReviewFinding, RunDeliveryState, RunFailure, RunTelemetry, StructuredOutputConformance, TaskContract, ProtectedPathApproval, isTerminalLifecycle, needsDecision, needsOperatorAttention, outcomeBanner, outcomeFactsFromFailure, TestCommandInvocation, WorkProduct, } from "@claudexor/schema";
38
46
  import { resolveControlProtocol } from "./operation-catalog.js";
39
- import { assertNoInlineSecretValues, containsSecretLikeToken, errorCode, noProjectRepoRoot, nowIso, redactSecrets, sha256, } from "@claudexor/util";
47
+ import { assertNoInlineSecretValues, containsSecretLikeToken, errorCode, noProjectRepoRoot, nowIso, redactSecrets, safeProblemContext, safeProblemRequiredActions, sha256, } from "@claudexor/util";
40
48
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
41
49
  const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
42
50
  const NO_PROJECT_ROOT = noProjectRepoRoot();
@@ -63,6 +71,8 @@ function stringArrayProperty(error, key) {
63
71
  if (!error || typeof error !== "object" || !(key in error))
64
72
  return [];
65
73
  const value = error[key];
74
+ if (key === "requiredActions")
75
+ return safeProblemRequiredActions(value);
66
76
  return Array.isArray(value)
67
77
  ? value
68
78
  .filter((item) => typeof item === "string" && item.length > 0)
@@ -100,7 +110,7 @@ function problemBody(error, fallbackCode, fallbackRetryable, fallbackMessage) {
100
110
  requiredActions: stringArrayProperty(error, "requiredActions"),
101
111
  evidenceRefs: stringArrayProperty(error, "evidenceRefs"),
102
112
  context: error && typeof error === "object" && "context" in error
103
- ? (error.context ?? {})
113
+ ? safeProblemContext(error.context)
104
114
  : {},
105
115
  });
106
116
  }
@@ -256,15 +266,12 @@ export class DaemonControlApiServer {
256
266
  if (!hostIsLoopback(req.headers.host) ||
257
267
  !originIsLoopback(req.headers.origin))
258
268
  return false;
259
- const m = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? "");
260
- return this.tokenMatches(m?.[1]?.trim());
269
+ return this.tokenMatches(bearerCredential(req.headers.authorization));
261
270
  }
262
- requestError(res, err) {
263
- // QA-053: at the request boundary, project a raw ZodError into structured
264
- // fieldErrors + a single-line human message (see normalizeRequestValidationError).
265
- // Typed errors and non-Zod errors pass through unchanged.
266
- const normalized = normalizeRequestValidationError(err);
267
- this.problem(res, finiteHttpStatus(normalized, 400), normalized, "invalid_request", false, "bad request");
271
+ requestError(res, err, fallbackStatus = 400) {
272
+ // QA-053: normalize raw Zod issues only at the client-request boundary.
273
+ const normalized = fallbackStatus === 400 ? normalizeRequestValidationError(err) : err;
274
+ this.problem(res, finiteHttpStatus(normalized, fallbackStatus), normalized, fallbackStatus === 400 ? "invalid_request" : "internal_error", false, fallbackStatus === 400 ? "bad request" : "service failed");
268
275
  }
269
276
  json(res, status, body, contentType = "application/json") {
270
277
  if (status >= 400 && contentType === "application/json") {
@@ -379,21 +386,22 @@ export class DaemonControlApiServer {
379
386
  services: this.opts.services,
380
387
  readBody: (request) => this.readBody(request),
381
388
  json: (response, status, body) => this.json(response, status, body),
382
- requestError: (response, error) => this.requestError(response, error),
389
+ requestError: (response, error, fallback) => this.requestError(response, error, fallback),
383
390
  }, method, path, req, res))
384
391
  return;
385
392
  if (await handleMaintenanceRoute({
386
393
  services: this.opts.services,
387
394
  readBody: (request) => this.readBody(request),
388
395
  json: (response, status, body) => this.json(response, status, body),
389
- requestError: (response, error) => this.requestError(response, error),
396
+ requestError: (response, error, fallback) => this.requestError(response, error, fallback),
390
397
  }, method, path, req, res))
391
398
  return;
392
399
  if (await handleProjectRoute({
393
400
  services: this.opts.services,
394
401
  readBody: (request) => this.readBody(request),
395
402
  json: (response, status, body) => this.json(response, status, body),
396
- requestError: (response, error) => this.requestError(response, error),
403
+ requestError: (response, error, fallback) => this.requestError(response, error, fallback),
404
+ binary: writeBinaryResponse,
397
405
  }, method, path, req, res))
398
406
  return;
399
407
  if (method === "POST" && path === "/runs") {
@@ -415,8 +423,9 @@ export class DaemonControlApiServer {
415
423
  services: this.opts.services,
416
424
  findRun: (id) => this.findRun(id),
417
425
  waitForRunStart: (id) => this.waitForRunStart(id),
426
+ serializeThreadMutation: (threadId, work) => chainThreadMutation(this.threadTurnChains, threadId, work),
418
427
  json: (response, status, body) => this.json(response, status, body),
419
- requestError: (response, error) => this.requestError(response, error),
428
+ requestError: (response, error, fallbackStatus) => this.requestError(response, error, fallbackStatus),
420
429
  }, method, path, req, res))
421
430
  return;
422
431
  const runDetailMatch = /^\/runs\/([^/]+)$/.exec(path);
@@ -487,13 +496,7 @@ export class DaemonControlApiServer {
487
496
  const idempotencyKey = runStart.requiredIdempotencyKey(req);
488
497
  let repoRoot = null;
489
498
  if (parsed.scope.kind === "project") {
490
- repoRoot = parsed.scope.root.trim();
491
- const absoluteRepoError = runStart.validateAbsoluteRepoRoot(repoRoot);
492
- if (absoluteRepoError)
493
- throw Object.assign(new Error(absoluteRepoError), { status: 400 });
494
- if (!existsSync(repoRoot) || !lstatSync(repoRoot).isDirectory()) {
495
- throw Object.assign(new Error(`project root does not exist or is not a directory: ${repoRoot}`), { status: 400 });
496
- }
499
+ repoRoot = runStart.normalizeExistingProjectRoot(parsed.scope.root);
497
500
  }
498
501
  const thread = await svc({
499
502
  title: parsed.title,
@@ -631,70 +634,17 @@ export class DaemonControlApiServer {
631
634
  return this.requestError(res, err);
632
635
  }
633
636
  if (body.action === "accept_risk" || body.action === "override_needs_human") {
634
- const decisionBody = body;
635
- const decisionAction = body.action;
636
637
  try {
637
- return await this.chainRunMutation(rec, async () => {
638
- // D-16: a work_state veto (the model attested needs_input / incomplete)
639
- // is NON-OVERRIDABLE — a risk acceptance cannot supply missing input,
640
- // and the delivery gate refuses the override on that axis. Reject
641
- // accept_risk with a TYPED problem here instead of recording a false
642
- // "Apply is now available" ACK that the gate would then refuse.
643
- const workVeto = this.runWorkStateVeto(rec);
644
- if (workVeto) {
645
- return this.json(res, 409, {
646
- error: workVeto === "needs_input"
647
- ? "run reported it needs more input; a risk override cannot supply the missing input — re-run with the input (rerun_with_feedback)"
648
- : "run reported the work is incomplete; a risk override cannot finish it — re-run until it completes (rerun_with_feedback)",
649
- code: "work_state_needs_input",
650
- });
651
- }
652
- // The override unblocks a NEEDS-DECISION run (review blocked /
653
- // checks failed, D8); recording one elsewhere would claim an apply
654
- // permission that does not exist.
655
- if (!this.runNeedsDecision(rec)) {
656
- return this.json(res, 409, {
657
- error: rec.state === "succeeded"
658
- ? "run does not need a decision; apply it directly if its review is clean (no risk override needed)"
659
- : `run is ${rec.state}; risk overrides only unblock needs-decision runs (use rerun_with_feedback instead)`,
660
- });
661
- }
662
- const patch = readPatch(rec);
663
- if (patch === null)
664
- return this.json(res, 409, {
665
- error: "no patch artifact; there is nothing to unblock for apply",
666
- });
667
- const decision = this.recordOperatorDecision(rec, {
668
- action: decisionAction,
669
- findingIds: decisionBody.findingIds,
670
- acceptedRisks: decisionBody.acceptedRisks,
671
- patchSha256: sha256(patch),
672
- decidedAt: nowIso(),
673
- }, {
674
- key: decisionKey,
675
- client: "control-api",
676
- request: { runId: rec.runId ?? rec.id, body: decisionBody },
677
- });
678
- try {
679
- writeOperatorDecisionProjection(rec, decision);
680
- appendRunAuditEvent(rec, "control.applied", {
681
- decision: decisionAction,
682
- finding_ids: decisionBody.findingIds,
683
- accepted_risks: decisionBody.acceptedRisks,
684
- });
685
- }
686
- catch {
687
- // Journal authority remains queryable and replayable by Idempotency-Key.
688
- }
689
- return this.json(res, 200, ControlRunDecisionResponse.parse({
690
- accepted: true,
691
- status: "applied",
692
- // Honest ACK (QA-032B): nothing is mutated at decision time. The
693
- // risk is accepted for this exact patch; Apply is now available
694
- // and a fresh final check runs just-in-time before any mutation.
695
- message: `${decisionAction} recorded for this exact patch; Apply is now available and will run a fresh final check before changing the project`,
696
- }));
697
- });
638
+ return await acceptRiskDecision({
639
+ services: this.opts.services,
640
+ chainMutation: (record, work) => this.chainRunMutation(record, work),
641
+ workStateVeto: (record) => this.runWorkStateVeto(record),
642
+ needsDecision: (record) => this.runNeedsDecision(record),
643
+ readPatch,
644
+ writeProjection: writeOperatorDecisionProjection,
645
+ appendAudit: (record, payload) => appendRunAuditEvent(record, "control.applied", payload),
646
+ json: (response, status, responseBody) => this.json(response, status, responseBody),
647
+ }, rec, body, decisionKey, res);
698
648
  }
699
649
  catch (error) {
700
650
  return this.requestError(res, error);
@@ -812,28 +762,23 @@ export class DaemonControlApiServer {
812
762
  return this.requestError(res, error);
813
763
  }
814
764
  }
815
- return rerunWithFeedback({
816
- daemon: this.opts.daemon,
817
- services: this.opts.services,
818
- waitForRunStart: (id) => this.waitForRunStart(id),
819
- appendAudit: (record, payload) => appendRunAuditEvent(record, "control.applied", payload),
820
- json: (response, status, responseBody) => this.json(response, status, responseBody),
821
- }, rec, body, decisionKey, res);
765
+ try {
766
+ return await rerunWithFeedback({
767
+ daemon: this.opts.daemon,
768
+ services: this.opts.services,
769
+ serializeThreadMutation: (threadId, work) => chainThreadMutation(this.threadTurnChains, threadId, work),
770
+ waitForRunStart: (id) => this.waitForRunStart(id),
771
+ appendAudit: (record, payload) => appendRunAuditEvent(record, "control.applied", payload),
772
+ json: (response, status, responseBody) => this.json(response, status, responseBody),
773
+ }, rec, body, decisionKey, res);
774
+ }
775
+ catch (error) {
776
+ return this.requestError(res, error, 500);
777
+ }
822
778
  }
823
779
  if (method === "GET" && path === "/harnesses") {
824
780
  try {
825
- assertOnlyQueryParams(url, ["fresh", "all", "harness"]);
826
- const fresh = optionalBooleanQuery(url, "fresh");
827
- const includeFakes = optionalBooleanQuery(url, "all");
828
- const harnessIds = url.searchParams
829
- .getAll("harness")
830
- .map((id) => id.trim())
831
- .filter(Boolean);
832
- return this.service(res, "harnesses", {
833
- ...(fresh === undefined ? {} : { fresh }),
834
- ...(includeFakes === undefined ? {} : { includeFakes }),
835
- ...(harnessIds.length === 0 ? {} : { harnessIds }),
836
- }, ControlHarnessListResponse);
781
+ return this.service(res, "harnesses", parseHarnessListQuery(url), ControlHarnessListResponse);
837
782
  }
838
783
  catch (err) {
839
784
  return this.requestError(res, err);
@@ -841,6 +786,14 @@ export class DaemonControlApiServer {
841
786
  }
842
787
  if (method === "GET" && path === "/agent-capabilities")
843
788
  return this.service(res, "agentCapabilities", undefined, AgentCapabilityCatalog);
789
+ if (method === "GET" && path === "/run-applicability") {
790
+ try {
791
+ return this.service(res, "runApplicability", parseRunApplicabilityQuery(url), ControlRunApplicabilityResponse);
792
+ }
793
+ catch (error) {
794
+ return this.requestError(res, error);
795
+ }
796
+ }
844
797
  const harnessModelsMatch = /^\/harnesses\/([^/]+)\/models$/.exec(path);
845
798
  if (method === "GET" && harnessModelsMatch) {
846
799
  try {
@@ -974,8 +927,15 @@ export class DaemonControlApiServer {
974
927
  return this.service(res, "quota", undefined, ControlQuotaResponse);
975
928
  if (method === "POST" && path === "/quota")
976
929
  return this.service(res, "refreshQuota", undefined, ControlQuotaResponse);
977
- if (method === "GET" && path === "/credential-profiles")
978
- return this.service(res, "credentialProfiles", undefined, ControlCredentialProfilesResponse);
930
+ if (method === "GET" && path === "/credential-profiles") {
931
+ try {
932
+ const query = parseCredentialProfilesSnapshotQuery(url);
933
+ return this.service(res, "credentialProfiles", query.input, query.schema);
934
+ }
935
+ catch (error) {
936
+ return this.requestError(res, error);
937
+ }
938
+ }
979
939
  if (method === "POST" && path === "/credential-profiles") {
980
940
  let body;
981
941
  try {
@@ -1410,23 +1370,8 @@ export class DaemonControlApiServer {
1410
1370
  const state = facts.work_state?.state;
1411
1371
  return state === "needs_input" || state === "incomplete" ? state : null;
1412
1372
  }
1413
- recordOperatorDecision(rec, decision, idempotency) {
1414
- const record = this.opts.services?.recordOperatorDecision;
1415
- if (!record) {
1416
- throw Object.assign(new Error("operator decisions are not supported by this engine build"), {
1417
- status: 501,
1418
- });
1419
- }
1420
- return record(rec.runId ?? rec.id, rec.params, decision, idempotency);
1421
- }
1422
1373
  chainRunMutation(rec, work) {
1423
- const threadId = threadIdOfRun(rec);
1424
- if (!threadId)
1425
- return work();
1426
- return chainThreadMutation(this.threadTurnRouteCtx(), threadId, async () => {
1427
- await assertThreadIdle(rec, () => this.opts.daemon.list());
1428
- return work();
1429
- });
1374
+ return chainIdleRunMutation(this.threadTurnChains, this.opts.daemon, rec, work);
1430
1375
  }
1431
1376
  threadTurnRouteCtx() {
1432
1377
  const services = this.opts.services ?? {};
@@ -1437,10 +1382,12 @@ export class DaemonControlApiServer {
1437
1382
  resolveRunArtifactPath: async (runId, rel) => this.resolveRunArtifactPath(runId, rel),
1438
1383
  normalizeStart: runStart.normalizeRunStart,
1439
1384
  preflightRunRequirements: services.preflightRunRequirements,
1385
+ preflightThreadRunRequirements: services.preflightThreadRunRequirements,
1440
1386
  isTerminalState: (state) => TERMINAL_STATES.has(state),
1441
1387
  daemon: this.opts.daemon,
1442
1388
  threadDetail: services.threadDetail,
1443
1389
  createThreadTurn: services.createThreadTurn,
1390
+ findThreadTurnByIdempotency: services.findThreadTurnByIdempotency,
1444
1391
  setTurnEnqueueError: services.setTurnEnqueueError,
1445
1392
  threadTurnChains: this.threadTurnChains,
1446
1393
  };
@@ -1654,83 +1601,6 @@ function appendRunAuditEvent(rec, type, payload) {
1654
1601
  /* audit append must not change control behavior */
1655
1602
  }
1656
1603
  }
1657
- /**
1658
- * QA-052 observability: how many artifact-path probes the run-list fingerprinter
1659
- * has performed (each probe is a synchronous filesystem stat-family lookup). The
1660
- * single-parse test pattern (eventsParseCount) proves warm-list work is BOUNDED
1661
- * — by page size (only sliced records are fingerprinted) and, for terminal runs,
1662
- * by the one-probe short-circuit below (1 path instead of 12).
1663
- */
1664
- let fingerprintProbeCount = 0;
1665
- export function runListFingerprintProbeCountForTests() {
1666
- return fingerprintProbeCount;
1667
- }
1668
- export function resetRunListFingerprintProbeCountForTests() {
1669
- fingerprintProbeCount = 0;
1670
- }
1671
- function summaryFingerprint(rec) {
1672
- const mtime = (rel) => {
1673
- fingerprintProbeCount++;
1674
- if (!rec.runDir)
1675
- return 0;
1676
- const path = safeArtifactPath(rec.runDir, rel);
1677
- if (!path)
1678
- return 0;
1679
- try {
1680
- return statSync(path).mtimeMs;
1681
- }
1682
- catch {
1683
- return 0;
1684
- }
1685
- };
1686
- const identity = [rec.state, paramsFingerprint(rec), rec.finishedAt ?? "", rec.error ?? ""];
1687
- // QA-052 terminal short-circuit: a TERMINAL run's every projected artifact
1688
- // (events.jsonl, arbitration, telemetry, failure, the final/* outputs,
1689
- // work_product) is written before the daemon marks the run terminal and never
1690
- // changes afterward. The SOLE post-terminal mutation is the delivery/apply
1691
- // overlay (final/delivery_state.yaml), which the apply/revert route is the one
1692
- // writer of [B7]. So a warm re-list of a terminal run fingerprints ONE path
1693
- // instead of twelve — the cheap probe still detects an apply/revert, and every
1694
- // other axis is frozen. Active (queued/running) runs keep the full
1695
- // artifact-mtime fingerprint because their events.jsonl and final/* land while
1696
- // the run is still live.
1697
- if (TERMINAL_STATES.has(rec.state)) {
1698
- return [...identity, mtime("final/delivery_state.yaml")].join("|");
1699
- }
1700
- return [
1701
- ...identity,
1702
- mtime("events.jsonl"),
1703
- mtime("arbitration/decision.yaml"),
1704
- mtime("final/telemetry.yaml"),
1705
- mtime("final/failure.yaml"),
1706
- mtime("final/summary.md"),
1707
- // Primary outputs feed outputReadyState; a summary cached before the
1708
- // answer/plan/report/patch landed must invalidate when it does.
1709
- mtime("final/answer.md"),
1710
- mtime("final/plan.md"),
1711
- mtime("final/explore.md"),
1712
- mtime("final/report.md"),
1713
- mtime("final/patch.diff"),
1714
- // The honest outcome (result kind / diffstat / adopted) is projected from
1715
- // work_product.yaml; a summary cached before it landed must invalidate.
1716
- mtime("final/work_product.yaml"),
1717
- // The MUTABLE delivery state (applied / reverted / not_applied) is written
1718
- // to delivery_state.yaml by markRunApplyState, and controlRunResult prefers
1719
- // that overlay over work_product.yaml's immutable apply_state. It must be in
1720
- // the fingerprint set or a summary cached before an apply/revert keeps
1721
- // reporting the stale applyState (the dedicated apply route writes only this
1722
- // file, so no other fingerprinted mtime would move). [B7]
1723
- mtime("final/delivery_state.yaml"),
1724
- ].join("|");
1725
- }
1726
- function paramsFingerprint(rec) {
1727
- try {
1728
- return sha256(JSON.stringify(rec.params ?? null));
1729
- }
1730
- catch {
1731
- return "unserializable-params";
1732
- }
1733
- }
1734
1604
  /** v0.9 strategy flags projected back so surfaces can tell a race from a repair loop. */
1735
1605
  function strategyFromParams(p) {
1736
1606
  if (p["untilClean"] === true)
@@ -1852,6 +1722,13 @@ function runOutcomeFacts(rec, decision, failure,
1852
1722
  * folded into a DECISION-LESS terminal so a needs_input/incomplete ask
1853
1723
  * carries its veto axis just like an arbitrated agent run. */
1854
1724
  workState = null) {
1725
+ const legacy = legacyRunOutcomeFacts(rec, decision, failure, workState);
1726
+ if (!legacy)
1727
+ return null;
1728
+ return effectiveTerminalFacts(rec.runDir, legacy, decision, expectedRunFacts(rec)).outcomeFacts;
1729
+ }
1730
+ /** Legacy-only terminal projection retained for active/pre-RunFacts records. */
1731
+ function legacyRunOutcomeFacts(rec, decision, failure, workState = null) {
1855
1732
  if (!isTerminalLifecycle(rec.state))
1856
1733
  return null;
1857
1734
  // Arbitrated runs already carry work_state on decision.facts (INV-116).
@@ -1901,6 +1778,15 @@ function summarizeRun(rec, eventsSnapshot) {
1901
1778
  const externalContextPolicy = telemetry?.external_context_policy ?? task?.external_context.policy;
1902
1779
  const webEvidence = controlWebEvidence(telemetry, task);
1903
1780
  const decision = safeReadStructuredArtifact(rec, "arbitration/decision.yaml", DecisionRecord);
1781
+ const failure = readFailure(rec);
1782
+ const workState = telemetry?.final_attempt_id
1783
+ ? (telemetry.attempts.find((a) => a.attempt_id === telemetry.final_attempt_id)?.outcome
1784
+ .work_state ?? null)
1785
+ : null;
1786
+ const legacyOutcome = legacyRunOutcomeFacts(rec, decision, failure, workState);
1787
+ const terminalFacts = legacyOutcome
1788
+ ? effectiveTerminalFacts(rec.runDir, legacyOutcome, decision, expectedRunFacts(rec))
1789
+ : { runFacts: null, outcomeFacts: null, decision };
1904
1790
  const budget = budgetSnapshot(rec, decision, eventsSnapshot);
1905
1791
  const parsedReviewerPanel = Array.isArray(p["reviewerPanel"])
1906
1792
  ? ControlReviewerPanelEntry.array().safeParse(p["reviewerPanel"])
@@ -1931,7 +1817,7 @@ function summarizeRun(rec, eventsSnapshot) {
1931
1817
  state: rec.state,
1932
1818
  runDir: rec.runDir,
1933
1819
  error: rec.error,
1934
- failure: readFailure(rec),
1820
+ failure,
1935
1821
  project: projectMetadata(rec),
1936
1822
  mode: parsedMode.success ? parsedMode.data : undefined,
1937
1823
  strategy: strategyFromParams(p),
@@ -1958,7 +1844,6 @@ function summarizeRun(rec, eventsSnapshot) {
1958
1844
  // The single engine validator's receipt, projected verbatim — surfaces
1959
1845
  // never re-validate the answer (null = no structured-output contract).
1960
1846
  outputConformance: outputConformance?.status ?? null,
1961
- // Route receipt projected verbatim (INV-061 disclosure); never re-derived.
1962
1847
  authRoute: telemetry?.auth_route
1963
1848
  ? {
1964
1849
  requested: telemetry.auth_route.requested,
@@ -1967,6 +1852,7 @@ function summarizeRun(rec, eventsSnapshot) {
1967
1852
  reason: telemetry.auth_route.reason,
1968
1853
  harnessId: telemetry.auth_route.harness_id,
1969
1854
  attemptId: telemetry.auth_route.attempt_id,
1855
+ profileId: telemetry.auth_route.profile_id,
1970
1856
  modelMismatch: telemetry.auth_route.model_mismatch,
1971
1857
  }
1972
1858
  : null,
@@ -1979,13 +1865,10 @@ function summarizeRun(rec, eventsSnapshot) {
1979
1865
  webEvidence,
1980
1866
  requestRequirements: telemetry?.request_requirements ?? [],
1981
1867
  toolPermissionPolicy: task?.tool_permission_policy,
1982
- outputReadyState: outputReadyState(rec, parsedMode.success ? parsedMode.data : null, readFailure(rec)),
1868
+ outputReadyState: outputReadyState(rec, parsedMode.success ? parsedMode.data : null, failure, terminalFacts.runFacts),
1983
1869
  toolWarningsTotal: telemetry?.tool_warnings_total ?? 0,
1984
1870
  result: controlRunResult(rec),
1985
- outcomeFacts: runOutcomeFacts(rec, decision, readFailure(rec), telemetry?.final_attempt_id
1986
- ? (telemetry.attempts.find((a) => a.attempt_id === telemetry.final_attempt_id)?.outcome
1987
- .work_state ?? null)
1988
- : null),
1871
+ outcomeFacts: terminalFacts.outcomeFacts,
1989
1872
  route: controlRoute(telemetry, p),
1990
1873
  // QA-034: routing rationale projected verbatim from the engine telemetry —
1991
1874
  // surfaces never reconstruct the order from prose.
@@ -2043,23 +1926,29 @@ function detailFor(rec, pendingInteractions = [], cursor, operator = null, child
2043
1926
  ? readRunEventsWithIntegrity(rec)
2044
1927
  : { events: [], integrity: undefined };
2045
1928
  const summary = summarizeRun(rec, events);
2046
- // Non-null exactly when the run has an applyable patch — the authoritative
2047
- // "something to apply" banner signal (a convergence patch may carry kind:patch
2048
- // with no meta.result_kind, so result.kind alone would miss it).
2049
- const applyEligibility = applyEligibilityFor(rec, operator);
1929
+ // applyEligibility is non-null exactly when the run has an applyable patch —
1930
+ // the authoritative "something to apply" banner signal (a convergence patch
1931
+ // may carry kind:patch with no meta.result_kind, so result.kind alone would
1932
+ // miss it).
1933
+ const { runFacts, outcomeFacts, applyEligibility, requiredActions } = projectRunFactsForDetail(rec.runDir, summary.outcomeFacts ?? null, operator !== null, () => applyEligibilityFor(rec, operator), expectedRunFacts(rec));
2050
1934
  const planProjection = planProjectionFor(rec, summary.mode);
2051
1935
  return ControlRunDetail.parse({
2052
- summary: { ...summary, waitingOnUser: pendingInteractions.length > 0 },
1936
+ summary: {
1937
+ ...summary,
1938
+ outcomeFacts,
1939
+ waitingOnUser: pendingInteractions.length > 0,
1940
+ },
2053
1941
  children,
1942
+ runFacts,
2054
1943
  // Server-owned outcome headline (D18), from the single projection owner —
2055
1944
  // surfaces render it verbatim above model prose, never re-derive it.
2056
- outcomeBanner: outcomeBanner(summary.outcomeFacts ?? null, {
1945
+ outcomeBanner: outcomeBanner(outcomeFacts, {
2057
1946
  applyState: summary.result.applyState,
2058
1947
  hasApplyableChange: applyEligibility !== null,
2059
1948
  }),
2060
1949
  lastSeq,
2061
1950
  artifacts: rec.runDir ? listArtifacts(rec.runDir) : [],
2062
- primaryOutput: primaryOutput(rec, summary.mode, failure),
1951
+ primaryOutput: primaryOutput(rec, summary.mode, failure, runFacts),
2063
1952
  timeline: timelineEvents(rec, events, integrity),
2064
1953
  budget: budgetSnapshot(rec, decision, events, integrity),
2065
1954
  finalSummary: boundedArtifactText(rec, "final/summary.md"),
@@ -2083,8 +1972,9 @@ function detailFor(rec, pendingInteractions = [], cursor, operator = null, child
2083
1972
  // Minimal typed required-actions (GH #29) for a succeeded-but-blocked run,
2084
1973
  // from the single status-projection owner: review-blocked / checks-failed /
2085
1974
  // needs-decision / work_state needs_input, keyed to the same validated
2086
- // operator decision the needs-decision + apply gates consult.
2087
- requiredActions: requiredActionsFor(summary.outcomeFacts ?? null, operator !== null),
1975
+ // operator decision the needs-decision + apply gates consult — the
1976
+ // canonical terminal actions plus legitimate post-terminal overlays.
1977
+ requiredActions,
2088
1978
  });
2089
1979
  }
2090
1980
  function readTextArtifact(rec, relPath, redact = true) {
@@ -2272,9 +2162,10 @@ function applyGateError(rec, patch, targetRepoRoot, operatorDecision, finalVerif
2272
2162
  }
2273
2163
  function applyGateInputFor(rec, patch, targetRepoRoot, operatorDecision) {
2274
2164
  const decision = safeReadStructuredArtifact(rec, "arbitration/decision.yaml", DecisionRecord);
2165
+ const terminal = effectiveTerminalFacts(rec.runDir, decision?.facts ?? null, decision, expectedRunFacts(rec));
2275
2166
  return {
2276
2167
  state: rec.state,
2277
- decision,
2168
+ decision: terminal.decision,
2278
2169
  workProduct: safeReadStructuredArtifact(rec, "final/work_product.yaml", WorkProduct),
2279
2170
  patch,
2280
2171
  originalRepoRoot: runRepoRoot(rec),
@@ -2287,9 +2178,10 @@ function applyGateInputFor(rec, patch, targetRepoRoot, operatorDecision) {
2287
2178
  // an already-applied / reverted run gets a terminal eligibility disposition
2288
2179
  // instead of a stale "rerun a fresh check" (QA-021).
2289
2180
  applyState: controlRunResult(rec).applyState,
2290
- // D-16 work_state veto (INV-116): thread the model-attested work outcome so
2291
- // the gate refuses a needs_input/incomplete winner even with a clean review.
2292
- workState: decision?.facts?.work_state ?? null,
2181
+ // D-16 work_state veto (INV-116): thread the model-attested work outcome
2182
+ // (the canonical terminal facts) so the gate refuses a needs_input/
2183
+ // incomplete winner even with a clean legacy review.
2184
+ workState: terminal.outcomeFacts?.work_state ?? null,
2293
2185
  };
2294
2186
  }
2295
2187
  /**