@deksden-com/dd-flow-cli 0.9.0-beta.13 → 0.9.0-beta.133

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 (204) hide show
  1. package/CHANGELOG.md +666 -0
  2. package/README.md +101 -1
  3. package/dist/build-info.json +10 -10
  4. package/dist/cli/argument-values.js +38 -0
  5. package/dist/cli/command-inputs.js +306 -0
  6. package/dist/cli/help.js +88 -12
  7. package/dist/cli/hook-ingress.js +78 -0
  8. package/dist/cli/input-preparation.js +116 -0
  9. package/dist/cli/native-hook-ingress.js +80 -0
  10. package/dist/cli/run-cli.js +1465 -450
  11. package/dist/cli/runtime-maintenance-diagnostics.js +46 -0
  12. package/dist/cli/runtime-maintenance-ingress.js +52 -0
  13. package/dist/cli/runtime-process.js +153 -0
  14. package/dist/cli.js +17 -2
  15. package/dist/domain/stage-catalog.js +1 -1
  16. package/dist/harness-runtime/DD-ZCODE.md +99 -0
  17. package/dist/harness-runtime/bin/dd-agy.mjs +56 -0
  18. package/dist/harness-runtime/bin/dd-codex.mjs +27 -0
  19. package/dist/harness-runtime/bin/dd-droid.mjs +34 -0
  20. package/dist/harness-runtime/bin/dd-grok.mjs +40 -0
  21. package/dist/harness-runtime/bin/dd-opencode.mjs +22 -0
  22. package/dist/harness-runtime/bin/dd-zcode.mjs +87 -0
  23. package/dist/harness-runtime/lib/adapter-timeouts.d.mts +5 -0
  24. package/dist/harness-runtime/lib/adapter-timeouts.mjs +34 -0
  25. package/dist/harness-runtime/lib/agy-hooks.d.mts +1 -0
  26. package/dist/harness-runtime/lib/agy-hooks.mjs +9 -0
  27. package/dist/harness-runtime/lib/codex-capacity-policy.d.mts +10 -0
  28. package/dist/harness-runtime/lib/codex-capacity-policy.mjs +89 -0
  29. package/dist/harness-runtime/lib/daemon-observation.d.mts +2 -0
  30. package/dist/harness-runtime/lib/daemon-observation.mjs +163 -0
  31. package/dist/harness-runtime/lib/daemon-operations.mjs +370 -0
  32. package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +6 -0
  33. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +693 -0
  34. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  35. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  36. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +190 -0
  37. package/dist/harness-runtime/lib/dd-codex.d.mts +1 -0
  38. package/dist/harness-runtime/lib/dd-codex.mjs +805 -0
  39. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +128 -0
  40. package/dist/harness-runtime/lib/dd-droid.mjs +561 -0
  41. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +490 -0
  42. package/dist/harness-runtime/lib/dd-grok.mjs +177 -0
  43. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +255 -0
  44. package/dist/harness-runtime/lib/dd-opencode.mjs +128 -0
  45. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +703 -0
  46. package/dist/harness-runtime/lib/dd-zcode.mjs +1108 -0
  47. package/dist/harness-runtime/lib/delegation-instructions.d.mts +13 -0
  48. package/dist/harness-runtime/lib/delegation-instructions.mjs +144 -0
  49. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  50. package/dist/harness-runtime/lib/driver-recovery.mjs +139 -0
  51. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  52. package/dist/harness-runtime/lib/lease-renewal.d.mts +8 -0
  53. package/dist/harness-runtime/lib/lease-renewal.mjs +36 -0
  54. package/dist/harness-runtime/lib/lifecycle-command.d.mts +20 -0
  55. package/dist/harness-runtime/lib/lifecycle-command.mjs +272 -0
  56. package/dist/harness-runtime/lib/lifecycle-contract.d.mts +10 -0
  57. package/dist/harness-runtime/lib/lifecycle-contract.mjs +19 -0
  58. package/dist/harness-runtime/lib/managed-daemon.d.mts +12 -0
  59. package/dist/harness-runtime/lib/managed-daemon.mjs +619 -0
  60. package/dist/harness-runtime/lib/model-observations.mjs +101 -0
  61. package/dist/harness-runtime/lib/native-children.d.mts +13 -0
  62. package/dist/harness-runtime/lib/native-children.mjs +78 -0
  63. package/dist/harness-runtime/lib/native-hook-command.d.mts +7 -0
  64. package/dist/harness-runtime/lib/native-hook-command.mjs +251 -0
  65. package/dist/harness-runtime/lib/native-streams.mjs +45 -0
  66. package/dist/harness-runtime/lib/observation-clock.d.mts +18 -0
  67. package/dist/harness-runtime/lib/observation-clock.mjs +92 -0
  68. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  69. package/dist/harness-runtime/lib/operation-errors.d.mts +14 -0
  70. package/dist/harness-runtime/lib/operation-errors.mjs +116 -0
  71. package/dist/harness-runtime/lib/operation-progress.d.mts +7 -0
  72. package/dist/harness-runtime/lib/operation-progress.mjs +112 -0
  73. package/dist/harness-runtime/lib/process-json.d.mts +5 -0
  74. package/dist/harness-runtime/lib/process-json.mjs +201 -0
  75. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  76. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  77. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  78. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  79. package/dist/harness-runtime/lib/setup-progress.d.mts +9 -0
  80. package/dist/harness-runtime/lib/setup-progress.mjs +51 -0
  81. package/dist/harness-runtime/lib/tool-observations.d.mts +21 -0
  82. package/dist/harness-runtime/lib/tool-observations.mjs +228 -0
  83. package/dist/harness-runtime/lib/zcode-usage.mjs +55 -0
  84. package/dist/protocol/local-files.js +18 -9
  85. package/dist/runtime/context.js +8 -4
  86. package/dist/schemas/agent-profile.schema.json +1 -1
  87. package/dist/schemas/code-review-result.schema.json +1 -1
  88. package/dist/schemas/code-verification.schema.json +7 -5
  89. package/dist/schemas/code-work-batch.schema.json +2 -2
  90. package/dist/schemas/flow-run.schema.json +2 -0
  91. package/dist/schemas/plan-review-decision.schema.json +3 -3
  92. package/dist/schemas/plan-review-result.schema.json +2 -2
  93. package/dist/schemas/run-control-receipt.schema.json +118 -0
  94. package/dist/schemas/run-control-request.schema.json +36 -0
  95. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  96. package/dist/services/branch-context.js +3 -3
  97. package/dist/services/canon.js +11 -8
  98. package/dist/services/check-runner-evidence.js +121 -0
  99. package/dist/services/cleanup.js +133 -40
  100. package/dist/services/cli-operation-classifier.js +22 -24
  101. package/dist/services/code-checks.js +851 -109
  102. package/dist/services/codex-capacity.js +367 -0
  103. package/dist/services/codex-hook-delivery.js +32 -0
  104. package/dist/services/command-context.js +80 -0
  105. package/dist/services/compatibility-preflight.js +13 -3
  106. package/dist/services/config.js +11 -8
  107. package/dist/services/continuation-outcome.js +20 -0
  108. package/dist/services/controller-fanout.js +119 -0
  109. package/dist/services/dashboard.js +135 -56
  110. package/dist/services/engines.js +245 -119
  111. package/dist/services/eval-snapshots.js +1146 -105
  112. package/dist/services/execution-policy.js +181 -0
  113. package/dist/services/external-work-launch.js +230 -0
  114. package/dist/services/harness-adapter.js +329 -36
  115. package/dist/services/harness-config.js +2 -0
  116. package/dist/services/hooks.js +747 -325
  117. package/dist/services/lanes.js +78 -65
  118. package/dist/services/lifecycle-command.js +1 -288
  119. package/dist/services/lifecycle-contract.js +1 -0
  120. package/dist/services/lifecycle-invocations.js +1774 -0
  121. package/dist/services/managed-daemon-binding.js +38 -0
  122. package/dist/services/managed-lease-monitor.js +133 -0
  123. package/dist/services/managed-processes.js +409 -51
  124. package/dist/services/merge-queue.js +228 -149
  125. package/dist/services/merge-server.js +163 -35
  126. package/dist/services/migrations.js +37 -16
  127. package/dist/services/native-daemon-history.js +49 -0
  128. package/dist/services/native-session-control.js +39 -0
  129. package/dist/services/plan-runtime.js +26 -11
  130. package/dist/services/plans.js +28 -25
  131. package/dist/services/projects.js +20 -15
  132. package/dist/services/prompts.js +28 -17
  133. package/dist/services/protocols.js +163 -87
  134. package/dist/services/recovery-observation-budget.js +121 -0
  135. package/dist/services/recovery-snapshot-database.js +145 -0
  136. package/dist/services/repair-intents.js +42 -0
  137. package/dist/services/review-copy.js +157 -0
  138. package/dist/services/review-deferral.js +23 -0
  139. package/dist/services/run-control-receipt.js +56 -0
  140. package/dist/services/run-control-worker.js +411 -0
  141. package/dist/services/run-control.js +985 -0
  142. package/dist/services/run-controller-adapter.js +399 -0
  143. package/dist/services/run-controller-capture.js +179 -0
  144. package/dist/services/run-controller-process.js +187 -0
  145. package/dist/services/run-controller-recovery.js +289 -0
  146. package/dist/services/run-controller-state.js +97 -0
  147. package/dist/services/run-controller.js +1035 -0
  148. package/dist/services/run-engine-bindings.js +1 -1
  149. package/dist/services/run-fork.js +147 -0
  150. package/dist/services/run-observations.js +152 -0
  151. package/dist/services/run-projection.js +22 -64
  152. package/dist/services/run-recovery-guard.js +75 -0
  153. package/dist/services/run-recovery-runtime.js +112 -0
  154. package/dist/services/run-recovery.js +349 -0
  155. package/dist/services/runs.js +558 -217
  156. package/dist/services/runtime-budget.js +430 -0
  157. package/dist/services/runtime-command.js +43 -0
  158. package/dist/services/runtime-scope-capture.js +52 -0
  159. package/dist/services/runtime-scope-control.js +468 -0
  160. package/dist/services/runtime-scope-resume.js +599 -0
  161. package/dist/services/runtime-scope-stop.js +180 -0
  162. package/dist/services/runtime-scope-worker.js +287 -0
  163. package/dist/services/runtime-service.js +96 -0
  164. package/dist/services/schema-validation.js +10 -8
  165. package/dist/services/scope-writer-cleanup.js +27 -0
  166. package/dist/services/sessions.js +35 -69
  167. package/dist/services/stage-blocker.js +17 -7
  168. package/dist/services/stage-context.js +185 -19
  169. package/dist/services/stage-lifecycle.js +98 -107
  170. package/dist/services/stage-pause.js +89 -75
  171. package/dist/services/stage-report-renderer.js +158 -6
  172. package/dist/services/stage-work-graph.js +35 -0
  173. package/dist/services/tooling.js +32 -23
  174. package/dist/services/usage.js +49 -101
  175. package/dist/services/verification-matrix.js +374 -0
  176. package/dist/services/vnext-code-review.js +222 -87
  177. package/dist/services/vnext-code.js +324 -251
  178. package/dist/services/vnext-contracts.js +10 -1
  179. package/dist/services/vnext-execution-profile.js +79 -4
  180. package/dist/services/vnext-fanout.js +177 -17
  181. package/dist/services/vnext-merge.js +335 -113
  182. package/dist/services/vnext-plan-review.js +276 -126
  183. package/dist/services/vnext-plan.js +227 -102
  184. package/dist/services/vnext-protocolize.js +335 -82
  185. package/dist/services/vnext-specify.js +119 -81
  186. package/dist/services/work-registry.js +1038 -192
  187. package/dist/services/workspace-bootstrap.js +87 -0
  188. package/dist/services/workspace-files.js +85 -0
  189. package/dist/services/worktrees.js +98 -44
  190. package/dist/shared/entity-references.js +8 -0
  191. package/dist/shared/errors.js +17 -0
  192. package/dist/storage/agent-output.js +26 -0
  193. package/dist/storage/database.js +781 -37
  194. package/dist/storage/foreign-key-transaction.js +39 -0
  195. package/dist/storage/immutable-input.js +45 -0
  196. package/dist/storage/import-guard.js +102 -0
  197. package/dist/storage/native-cleanup.js +52 -0
  198. package/dist/storage/paths.js +17 -4
  199. package/dist/storage/projection-lock.js +73 -0
  200. package/dist/storage/work-references.js +23 -0
  201. package/dist/storage/writer-contract.js +77 -0
  202. package/dist/storage/writer-migration.js +85 -0
  203. package/package.json +30 -13
  204. package/tools/repair-paused-run-status.mjs +84 -0
@@ -1,25 +1,90 @@
1
1
  import crypto from "node:crypto";
2
+ import { readRunnerEvidence, readRunnerProgress, runnerContractsValid, runnerEvidencePass, runnerObservationDurations } from "./check-runner-evidence.js";
3
+ export { assertSourceRepairEligible, checkRepairDisposition } from "./check-runner-evidence.js";
4
+ import { isGitlessWorkspace, ownedWorkspaceHookPath, projectServicePathFilter, workspaceGitFiles } from "./workspace-files.js";
2
5
  import { spawn, spawnSync } from "node:child_process";
6
+ import { ObservationClock, validateObservationDuration } from "../harness-runtime/lib/observation-clock.mjs";
3
7
  import fs from "node:fs";
4
8
  import path from "node:path";
5
- import { AppError } from "../shared/errors.js";
6
- import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, processIsAlive, registerManagedProcess, reservePorts } from "./managed-processes.js";
9
+ import { isDeepStrictEqual } from "node:util";
10
+ import { parse } from "shell-quote";
11
+ import { assertRunMutationAllowed } from "./run-recovery.js";
12
+ import { AppError, errorRecord } from "../shared/errors.js";
13
+ import { stopProcessGroup } from "../harness-runtime/lib/managed-daemon.mjs";
14
+ import { createRenewalState, maintenanceRetryable, renewalConfirmed, renewalDelay, renewalFailure, renewalRemaining, RENEWAL_POLICY } from "../harness-runtime/lib/lease-renewal.mjs";
15
+ import { confirmManagedProcess, finishManagedProcess, renewManagedProcess, managedProcessStatus, managedProcessRuntimeHome, managedOwnerIsAlive, processTreeIsAlive, registerManagedProcess, reservePorts } from "./managed-processes.js";
16
+ import { admitRuntimeCheck, runtimeBudgetForRun } from "./runtime-budget.js";
17
+ import { getResourceDatabase } from "../storage/database.js";
7
18
  const profileRelativePath = path.join(".memory-bank", "spec", "engineering", "code-check-profile.json");
8
19
  // Receipt reservation precedes child spawn so an interrupted CLI leaves an
9
20
  // auditable attempt. A second caller must not mistake that small handoff window
10
21
  // for a dead check and overwrite its outcome.
11
22
  const receiptStartingGraceMs = 30_000;
12
- export function codeExecutionEnvironment(workspaceRoot) { const suffix = crypto.createHash("sha256").update(path.resolve(workspaceRoot)).digest("hex").slice(0, 12); return { ...process.env, DD_FLOW_LOCAL_DATABASE_SUFFIX: suffix }; }
23
+ /** The check writer proves an empty tree before marking its registration terminal. */
24
+ export function terminalCheckSettled(context, record) {
25
+ if (record.kind !== "check" || !record.check_id || record.owner_id !== `check:${record.check_id}` || !record.project_id || !record.run_id
26
+ || !record.pid || !record.pid_started_at || !["stopped", "failed"].includes(record.state) || !record.finished_at)
27
+ return null;
28
+ let metadata;
29
+ try {
30
+ metadata = JSON.parse(record.metadata_json);
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ if (metadata.role !== "check" || managedProcessRuntimeHome(record, context) !== context.ddFlowHome)
36
+ return null;
37
+ let resource;
38
+ try {
39
+ resource = getResourceDatabase(context.env.DD_FLOW_RESOURCE_HOME ?? context.ddFlowHome, "read_existing");
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ try {
45
+ const current = resource.get("SELECT * FROM managed_processes WHERE id = ?", [record.id]);
46
+ if (!current || current.lease_token !== record.lease_token || current.owner_id !== record.owner_id || current.pid !== record.pid
47
+ || current.pid_started_at !== record.pid_started_at || current.metadata_json !== record.metadata_json || current.state !== record.state
48
+ || current.project_id !== record.project_id || current.run_id !== record.run_id || current.work_id !== record.work_id
49
+ || current.check_id !== record.check_id || current.finished_at !== record.finished_at)
50
+ return null;
51
+ const receipt = context.db.get("SELECT status, finished_at, work_id FROM check_receipts WHERE id = ? AND project_id = ? AND run_id = ?", [record.check_id, record.project_id, record.run_id]);
52
+ return receipt && receipt.work_id === record.work_id && ["passed", "failed", "aborted"].includes(receipt.status) && receipt.finished_at
53
+ ? { status: receipt.status, finished_at: receipt.finished_at } : null;
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ finally {
59
+ resource.close?.();
60
+ }
61
+ }
62
+ export function codeExecutionEnvironment(workspaceRoot) {
63
+ const suffix = crypto.createHash("sha256").update(path.resolve(workspaceRoot)).digest("hex").slice(0, 12);
64
+ // The managed CLI's Node must win over a provider app's PATH: a Rosetta
65
+ // provider can otherwise run x64 checks against arm64-installed modules.
66
+ const environment = { ...process.env, PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH ?? ""}`, DD_FLOW_LOCAL_DATABASE_SUFFIX: suffix };
67
+ delete environment.NODE_PATH;
68
+ for (const key of ["DD_FLOW_CHECK_RUNNER_EVIDENCE_PATH", "DD_FLOW_CHECK_RUNNER_PROGRESS_PATH", "DD_FLOW_CHECK_INVOCATION_ID", "DD_FLOW_CHECK_ID", "DD_FLOW_CHECK_INPUT_HASH", "DD_FLOW_CHECK_VERIFICATION_EPOCH", "DD_FLOW_CHECK_COMMAND"])
69
+ delete environment[key];
70
+ return environment;
71
+ }
72
+ export function workspaceOperationId(workspaceRoot) { return `workspace:${crypto.createHash("sha256").update(path.resolve(workspaceRoot)).digest("hex")}`; }
13
73
  export function readCodeCheckProfile(workspaceRoot) {
14
74
  const file = path.join(workspaceRoot, profileRelativePath);
15
75
  if (!fs.existsSync(file))
16
76
  return { profile: null, hash: null, file };
17
77
  const bytes = fs.readFileSync(file);
18
78
  const value = JSON.parse(bytes.toString("utf8"));
19
- if (value.schema_id !== "dd-flow/code-check-profile@6")
20
- throw new AppError("invalid_code_check_profile", "CODE check profile has an unsupported schema", 2, { file, schema_id: value.schema_id ?? null, required: "dd-flow/code-check-profile@6" });
21
- if (!isStringRecord(value.aliases) || !isStringRecord(value.require_alias_for) || !isGateRecord(value.mandatory_by_gate) || !isPortAliasRecord(value.ports_by_alias))
22
- throw new AppError("invalid_code_check_profile", "CODE check profile aliases, require_alias_for, mandatory_by_gate or ports_by_alias are invalid", 2, { file });
79
+ if (!["dd-flow/code-check-profile@6", "dd-flow/code-check-profile@7"].includes(value.schema_id ?? ""))
80
+ throw new AppError("invalid_code_check_profile", "CODE check profile has an unsupported schema", 2, { file, schema_id: value.schema_id ?? null, supported: ["dd-flow/code-check-profile@6", "dd-flow/code-check-profile@7"] });
81
+ if (!runnerContractsValid(value.runner_evidence_by_alias) || value.schema_id === "dd-flow/code-check-profile@6" && value.runner_evidence_by_alias !== undefined)
82
+ throw new AppError("invalid_code_check_profile", "Runner evidence requires valid profile@7 relative artifact contracts", 2, { file });
83
+ for (const alias of Object.keys(value.runner_evidence_by_alias ?? {}))
84
+ if (!value.aliases?.[alias])
85
+ throw new AppError("invalid_code_check_profile", "Runner evidence references an unknown alias", 2, { file, alias });
86
+ if (!isStringRecord(value.aliases) || !isStringRecord(value.require_alias_for) || !isGateRecord(value.mandatory_by_gate) || !isPortAliasRecord(value.ports_by_alias) || !isInputAliasRecord(workspaceRoot, value.inputs_by_alias))
87
+ throw new AppError("invalid_code_check_profile", "CODE check profile aliases, require_alias_for, mandatory_by_gate, ports_by_alias or inputs_by_alias are invalid", 2, { file });
23
88
  for (const [gate, aliases] of Object.entries(value.mandatory_by_gate))
24
89
  for (const alias of aliases ?? [])
25
90
  if (!value.aliases[alias])
@@ -27,19 +92,76 @@ export function readCodeCheckProfile(workspaceRoot) {
27
92
  for (const alias of Object.keys(value.ports_by_alias ?? {}))
28
93
  if (!value.aliases[alias])
29
94
  throw new AppError("invalid_code_check_profile", "A port declaration references an unknown alias", 2, { file, alias });
95
+ for (const alias of Object.keys(value.inputs_by_alias ?? {}))
96
+ if (!value.aliases[alias])
97
+ throw new AppError("invalid_code_check_profile", "An input declaration references an unknown alias", 2, { file, alias });
30
98
  return { profile: value, hash: crypto.createHash("sha256").update(bytes).digest("hex"), file };
31
99
  }
100
+ /**
101
+ * Planned aliases are qualification-owned extensions: their provider Work is
102
+ * expected to materialize them after PLAN. Freeze the project policy and
103
+ * existing aliases, while still validating every planned alias definition at
104
+ * execution time. Unknown edits and changes to existing policy remain part
105
+ * of the hash and therefore fail closed.
106
+ */
107
+ export function codeCheckProfileHash(workspaceRoot, checks = []) {
108
+ const { profile } = readCodeCheckProfile(workspaceRoot);
109
+ if (!profile)
110
+ return null;
111
+ const planned = new Set(checks.filter(check => check.availability === "planned" && check.command.startsWith("@check/")).map(check => check.command));
112
+ const serialize = (value) => profile.schema_id === "dd-flow/code-check-profile@7"
113
+ ? JSON.stringify(value, (_key, item) => item && typeof item === "object" && !Array.isArray(item) ? Object.fromEntries(Object.entries(item).sort(([left], [right]) => left.localeCompare(right))) : item)
114
+ : JSON.stringify(value);
115
+ if (!planned.size)
116
+ return crypto.createHash("sha256").update(serialize(profile)).digest("hex");
117
+ const canonical = {
118
+ ...profile,
119
+ aliases: Object.fromEntries(Object.entries(profile.aliases).filter(([alias]) => !planned.has(alias))),
120
+ ports_by_alias: Object.fromEntries(Object.entries(profile.ports_by_alias ?? {}).filter(([alias]) => !planned.has(alias))),
121
+ inputs_by_alias: Object.fromEntries(Object.entries(profile.inputs_by_alias ?? {}).filter(([alias]) => !planned.has(alias))),
122
+ ...(profile.schema_id === "dd-flow/code-check-profile@7" ? { runner_evidence_by_alias: Object.fromEntries(Object.entries(profile.runner_evidence_by_alias ?? {}).filter(([alias]) => !planned.has(alias))) } : {})
123
+ };
124
+ return crypto.createHash("sha256").update(serialize(canonical)).digest("hex");
125
+ }
126
+ /** The integration target starts at its baseline profile; the reviewed source
127
+ * may add aliases, but must not change existing check policy or bindings. */
128
+ export function mergeCheckProfileHash(sourceWorkspace, targetWorkspace, checks) {
129
+ const source = readCodeCheckProfile(sourceWorkspace).profile;
130
+ const target = readCodeCheckProfile(targetWorkspace).profile;
131
+ if (!source && !target)
132
+ return null;
133
+ if (!source || !target)
134
+ throw new AppError("check_profile_drift", "Source and integration check profiles differ", 1);
135
+ const retained = structuredClone(source);
136
+ for (const field of ["aliases", "ports_by_alias", "inputs_by_alias", "runner_evidence_by_alias"]) {
137
+ const old = target[field];
138
+ if (!old) {
139
+ delete retained[field];
140
+ continue;
141
+ }
142
+ const current = source[field] ?? {};
143
+ retained[field] = Object.fromEntries(Object.keys(old).filter(key => key in current).map(key => [key, current[key]]));
144
+ }
145
+ const existingAliases = new Set(Object.keys(target.aliases));
146
+ const changedExistingMetadata = ["ports_by_alias", "inputs_by_alias", "runner_evidence_by_alias"].some(field => Object.keys(source[field] ?? {}).some(alias => existingAliases.has(alias) && !(alias in (target[field] ?? {}))));
147
+ if (changedExistingMetadata || !isDeepStrictEqual(retained, target)) {
148
+ throw new AppError("check_profile_drift", "Source check profile changes existing integration policy", 1, { source_profile_hash: codeCheckProfileHash(sourceWorkspace, checks), target_profile_hash: codeCheckProfileHash(targetWorkspace, checks) });
149
+ }
150
+ return codeCheckProfileHash(sourceWorkspace, checks);
151
+ }
32
152
  export function validateCodeCheckCommands(workspaceRoot, commands) {
33
153
  const { profile, file } = readCodeCheckProfile(workspaceRoot);
34
- if (!profile)
35
- return [...commands];
36
- return commands.map((command) => { if (command.startsWith("@check/")) {
37
- if (!profile.aliases[command]?.trim())
38
- throw new AppError("unknown_code_check_alias", "CODE check alias is not defined by the project profile", 2, { alias: command, file });
154
+ return commands.map((command) => {
155
+ if (command.startsWith("@check/")) {
156
+ if (!profile?.aliases[command]?.trim())
157
+ throw new AppError("unknown_code_check_alias", "CODE check alias is not defined by the project profile", 2, { alias: command, file });
158
+ return command;
159
+ }
160
+ for (const [prefix, alias] of Object.entries(profile?.require_alias_for ?? {}))
161
+ if (command === prefix || command.startsWith(`${prefix} `))
162
+ throw new AppError("raw_code_check_forbidden", "CODE check must use the project alias instead of a raw guarded command", 2, { command, required_alias: alias, file });
39
163
  return command;
40
- } for (const [prefix, alias] of Object.entries(profile.require_alias_for))
41
- if (command === prefix || command.startsWith(`${prefix} `))
42
- throw new AppError("raw_code_check_forbidden", "CODE check must use the project alias instead of a raw guarded command", 2, { command, required_alias: alias, file }); return command; });
164
+ });
43
165
  }
44
166
  export function resolveCodeCheckCommands(workspaceRoot, runId, commands) {
45
167
  const validated = validateCodeCheckCommands(workspaceRoot, commands);
@@ -50,7 +172,7 @@ export function resolveCodeCheckCommands(workspaceRoot, runId, commands) {
50
172
  }
51
173
  export function projectPolicyCheckDeclarations(workspaceRoot, gate) {
52
174
  const { profile } = readCodeCheckProfile(workspaceRoot);
53
- return (profile?.mandatory_by_gate[gate] ?? []).map((alias) => { const ref = `POLICY/${gate}/${alias.slice(7).replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").toLowerCase()}`; return { id: ref, canonical_ref: ref, source: "project_policy", command: alias, purpose: `Mandatory project ${gate} gate.`, run_at: gate, availability: "available", ...(profile?.ports_by_alias?.[alias] ? { ports: profile.ports_by_alias[alias] } : {}) }; });
175
+ return (profile?.mandatory_by_gate[gate] ?? []).map((alias) => { const ref = `POLICY/${gate}/${alias.slice(7).replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").toLowerCase()}`; return { id: ref, canonical_ref: ref, source: "project_policy", command: alias, purpose: `Mandatory project ${gate} gate.`, run_at: gate, availability: "available", ...(profile?.ports_by_alias?.[alias] ? { ports: profile.ports_by_alias[alias] } : {}), ...(profile?.inputs_by_alias?.[alias] ? { inputs: profile.inputs_by_alias[alias] } : {}) }; });
54
176
  }
55
177
  export function effectiveCheckDeclarations(workspaceRoot, declared, gates) { return gates.flatMap((gate) => [...declared.filter((check) => check.run_at === gate), ...projectPolicyCheckDeclarations(workspaceRoot, gate)]); }
56
178
  export function aggregateCheckDeclarations(workspaceRoot) { return projectPolicyCheckDeclarations(workspaceRoot, "code"); }
@@ -60,12 +182,19 @@ export function validateCheckPlacement(workspaceRoot, checks) { const { profile
60
182
  if (check.run_at === "work" && aggregateCommands.has(check.command))
61
183
  throw new AppError("aggregate_check_requires_code_gate", "A project aggregate check must run at CODE or readiness, not inside one scoped Work", 2, { check_id: check.id, command: check.command, run_at: check.run_at }); }
62
184
  export function validateCheckDeclaration(workspaceRoot, check) {
63
- if (check.run_at === "external")
64
- return;
185
+ if (!["work", "code", "readiness", "merge", "release"].includes(check.run_at))
186
+ throw new AppError("unsupported_check_gate", "Check requires an autonomous executable gate; replan the incompatible declaration", 2, { check_id: check.id, run_at: check.run_at });
187
+ if (check.runner_evidence !== undefined && !runnerContractsValid({ "@check/declaration": check.runner_evidence }))
188
+ throw new AppError("invalid_code_check_profile", "Check runner evidence contract is invalid", 2, { check_id: check.id });
65
189
  const { profile } = readCodeCheckProfile(workspaceRoot);
190
+ const resolvedCommand = profile?.aliases[check.command] ?? check.command;
191
+ validateTerminatingCheckCommand(resolvedCommand, check.id);
66
192
  if (check.availability === "planned" && check.command.startsWith("@check/") && !profile?.aliases[check.command])
67
193
  throw new AppError("planned_check_not_materialized", "A planned check alias was not materialized by its provider Work", 2, { check_id: check.id, command: check.command, provider: check.provided_by ?? null });
68
194
  validateCodeCheckCommands(workspaceRoot, [check.command]);
195
+ if (check.availability === "available" && !check.command.startsWith("@check/"))
196
+ assertDirectCheckExecutable(workspaceRoot, check.command, check.id);
197
+ validateCheckInputs(workspaceRoot, check.inputs ?? []);
69
198
  if (check.command.startsWith("@check/") && check.definition && profile?.aliases[check.command] !== check.definition)
70
199
  throw new AppError("check_definition_drift", "The accepted check alias definition differs from the current project profile", 2, { check_id: check.id, alias: check.command, expected: check.definition, actual: profile?.aliases[check.command] ?? null });
71
200
  if (check.availability !== "planned")
@@ -77,50 +206,189 @@ export function validateCheckDeclaration(workspaceRoot, check) {
77
206
  if (!profile?.aliases[check.command])
78
207
  throw new AppError("planned_check_not_materialized", "A planned check alias was not materialized by its provider Work", 2, { check_id: check.id, command: check.command, provider: check.provided_by ?? null });
79
208
  }
209
+ export function validateTerminatingCheckCommand(command, checkId) {
210
+ if (isBareServiceScript(command))
211
+ throw new AppError("non_terminating_check", "A check must terminate; run a finite test instead of a bare dev/start/serve/watch service script", 2, { check_id: checkId, command });
212
+ }
213
+ function isBareServiceScript(command) {
214
+ const words = parse(command.trim());
215
+ if (words.some((word) => typeof word !== "string"))
216
+ return false;
217
+ let args = words;
218
+ while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(args[0] ?? ""))
219
+ args = args.slice(1);
220
+ const manager = path.basename(args.shift() ?? "");
221
+ if (!["pnpm", "npm", "yarn", "bun"].includes(manager))
222
+ return false;
223
+ while (args.length && ["--filter", "-F", "--dir", "-C", "--cwd"].includes(args[0]))
224
+ args.splice(0, 2);
225
+ if (args[0] === "run")
226
+ args.shift();
227
+ return ["dev", "start", "serve", "watch"].includes(args[0] ?? "");
228
+ }
229
+ /** Reject a plan-time typo or a fictitious "manual" command before it becomes
230
+ * a late shell exit. Compound shell programs remain valid: only a simple,
231
+ * directly named executable is preflighted here. */
232
+ function assertDirectCheckExecutable(workspaceRoot, command, checkId) {
233
+ const trimmed = command.trim();
234
+ // A shell operator or expansion makes the launch path dynamic. Do not
235
+ // guess there; this preflight is deliberately limited to a direct program
236
+ // with ordinary arguments and optional literal environment assignments.
237
+ if (/[|&;<>()`$\n\r~]/.test(trimmed))
238
+ return;
239
+ const parsed = parse(trimmed);
240
+ if (parsed.some(word => typeof word !== "string"))
241
+ return;
242
+ const words = parsed;
243
+ let index = 0;
244
+ const env = codeExecutionEnvironment(workspaceRoot);
245
+ while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[index] ?? "")) {
246
+ const assignment = words[index++];
247
+ const equals = assignment.indexOf("=");
248
+ env[assignment.slice(0, equals)] = assignment.slice(equals + 1);
249
+ }
250
+ const program = words[index];
251
+ if (!program)
252
+ return;
253
+ const probe = spawnSync("/bin/sh", ["-c", "command -v -- \"$1\" >/dev/null", "dd-flow-check", program], { cwd: workspaceRoot, env, encoding: "utf8", timeout: 5000 });
254
+ if (probe.error || probe.signal)
255
+ throw new AppError("check_command_probe_unavailable", "Executable probe could not be observed; availability is unknown", 2, { check_id: checkId, executable: program, cause: probe.error?.message, signal: probe.signal });
256
+ if (probe.status !== 0)
257
+ throw new AppError("check_command_unavailable", "Available CODE check names an executable that is unavailable in this environment", 2, { check_id: checkId, command, executable: program });
258
+ }
80
259
  export async function runCodeChecks(context, input) {
81
- reconcileUnfinishedChecks(context, input);
260
+ const generation = assertRunMutationAllowed(context, input.projectId, input.runId);
261
+ let gate;
262
+ for (;;) {
263
+ assertRunMutationAllowed(context, input.projectId, input.runId, generation);
264
+ reconcileUnfinishedChecks(context, input);
265
+ reconcileWorkspaceOwnership(context, input.workspaceRoot);
266
+ try {
267
+ gate = acquireWorkspaceGate(context, input);
268
+ break;
269
+ }
270
+ catch (error) {
271
+ const sameRunGate = managedProcessStatus(context).find(item => item.kind === "workspace-gate" && item.operation_id === workspaceOperationId(input.workspaceRoot) && ["starting", "running", "stopping"].includes(item.state) && item.project_id === input.projectId && item.run_id === input.runId);
272
+ if (input.scope !== "work" || !input.workId || !(error instanceof AppError) || !["check_in_progress", "managed_operation_in_progress"].includes(error.code) || !sameRunGate)
273
+ throw error;
274
+ await new Promise(resolve => setTimeout(resolve, 500));
275
+ }
276
+ }
277
+ try {
278
+ return await runCodeChecksOwned(context, input, gate.id, generation);
279
+ }
280
+ finally {
281
+ finishManagedProcess(context, { id: gate.id, leaseToken: gate.lease_token, state: "stopped", reason: "workspace_gate_settled" });
282
+ }
283
+ }
284
+ async function runCodeChecksOwned(context, input, workspaceGateId, generation) {
285
+ assertRunMutationAllowed(context, input.projectId, input.runId, generation);
82
286
  for (const check of input.checks)
83
287
  validateCheckDeclaration(input.workspaceRoot, check);
84
- const { hash: profileHash, profile } = readCodeCheckProfile(input.workspaceRoot);
85
- const checks = input.checks.map((check) => { const ports = profile?.ports_by_alias?.[check.command]; return check.ports || !ports ? check : { ...check, ports }; });
288
+ const { profile } = readCodeCheckProfile(input.workspaceRoot);
289
+ const profileHash = codeCheckProfileHash(input.workspaceRoot, input.checks);
290
+ if (input.expectedProfileHash !== undefined && input.expectedProfileHash !== profileHash)
291
+ throw new AppError("check_profile_drift", "The frozen check profile differs from the workspace profile", 1, { expected_profile_hash: input.expectedProfileHash, actual_profile_hash: profileHash });
292
+ const checks = input.checks.map(check => {
293
+ const ports = profile?.ports_by_alias?.[check.command], inputs = profile?.inputs_by_alias?.[check.command];
294
+ const runner = profile?.runner_evidence_by_alias?.[check.command] ?? check.runner_evidence;
295
+ if (runner && !runnerContractsValid({ "@check/declaration": runner }))
296
+ throw new AppError("invalid_code_check_profile", "Check runner evidence contract is invalid", 2, { check_id: check.id });
297
+ return { ...check, ...(check.ports || !ports ? {} : { ports }), ...(check.inputs || !inputs ? {} : { inputs }), ...(runner ? { runner_evidence: runner, required_artifacts: [...new Set([...(check.required_artifacts ?? []), runner.path])] } : {}) };
298
+ });
86
299
  const resolved = resolveCodeCheckCommands(input.workspaceRoot, input.runId, checks.map((check) => check.command));
87
- const groups = deduplicate(checks, resolved);
88
- const receipts = [];
300
+ assertConsistentDeclarations(checks, resolved);
301
+ const groups = deduplicate(checks, resolved), receipts = [];
302
+ const retryIds = [...new Set([...(input.retryReceiptId ? [input.retryReceiptId] : []), ...(input.retryReceiptIds ?? [])])];
303
+ const retries = new Map();
304
+ for (const receiptId of retryIds) {
305
+ const predecessor = validateFailedCheckRetry(context, { projectId: input.projectId, runId: input.runId, receiptId, declarations: checks, reason: input.retryReason });
306
+ const snapshot = workspaceState(input.workspaceRoot, [input.runHome]);
307
+ const group = groups.find(candidate => candidate.declarations.some(check => check.run_at === predecessor.gate)
308
+ && checkInputHash(input.workspaceRoot, { ...candidate, gate: predecessor.gate }, profileHash, workspaceInputFingerprint(input.workspaceRoot, snapshot.files, candidate.inputs, [input.runHome])) === predecessor.input_hash);
309
+ if (!group || retries.has(group))
310
+ throw new AppError("retry_check_invalid", "Retry must match exactly one current execution contract", 2, { check_receipt_id: receiptId });
311
+ // Preserve the identity of a pre-deduplication failed execution when an
312
+ // upgraded RUN retries it; the new receipt still proves every grouped ref.
313
+ group.gate = predecessor.gate;
314
+ retries.set(group, receiptId);
315
+ }
89
316
  for (let index = 0; index < groups.length; index += 1) {
90
317
  const group = groups[index];
91
318
  const before = workspaceState(input.workspaceRoot, [input.runHome]);
92
319
  const startedAt = context.now();
93
- const inputHash = crypto.createHash("sha256").update(JSON.stringify({ gate: group.gate, command: group.command, environment: String(codeExecutionEnvironment(input.workspaceRoot).DD_FLOW_LOCAL_DATABASE_SUFFIX), required_artifacts: group.requiredArtifacts, ports: [...group.ports].sort() })).digest("hex");
320
+ const inputFingerprint = workspaceInputFingerprint(input.workspaceRoot, before.files, group.inputs, [input.runHome]);
321
+ const inputHash = checkInputHash(input.workspaceRoot, group, profileHash, inputFingerprint);
94
322
  const epoch = crypto.createHash("sha256").update(`${group.gate}\0${before.fingerprint}`).digest("hex");
95
- const reserved = reserveReceipt(context, { ...input, group, before, profileHash, inputHash, epoch, startedAt });
323
+ const reservationInput = { ...input, retryReceiptIds: retries.has(group) ? [retries.get(group)] : [], generation, group, before, profileHash, inputHash, epoch, startedAt };
324
+ delete reservationInput.retryReceiptId;
325
+ const decision = reserveOrReuseReceipt(context, reservationInput);
326
+ if (decision.receipt) {
327
+ receipts.push(bindReceipt(decision.receipt, group));
328
+ input.progress?.(`check ${index + 1}/${groups.length} ${decision.receipt.status === "passed" ? "reused" : "requires repair"}: ${group.command}`);
329
+ continue;
330
+ }
331
+ const reserved = decision.reserved;
96
332
  const { id, evidenceDir, stdoutPath, stderrPath, receiptPath, completionPath } = reserved;
97
333
  let resources = {};
98
334
  let result = { exitCode: null, error: "check did not start", aborted: true };
99
335
  try {
100
336
  fs.mkdirSync(evidenceDir, { recursive: true });
101
- writeReceipt(receiptPath, receiptFor({ ...reserved, resources, result, status: "running", after: before, mutationPaths: [], artifacts: [] }));
102
- const managed = registerManagedProcess(context, { kind: "check", ownerId: `check:${id}`, projectId: input.projectId, runId: input.runId, workId: input.workId ?? null, checkId: id, stdoutPath, stderrPath, metadata: { command: group.command } });
337
+ const startingReceipt = receiptFor({ ...reserved, resources, result, status: "running", after: before, mutationPaths: [], artifacts: [] });
338
+ if (group.runnerEvidence)
339
+ startingReceipt.runner_evidence_required = group.runnerEvidence;
340
+ writeReceipt(receiptPath, startingReceipt);
341
+ const budget = runtimeBudgetForRun(context, input.projectId, input.runId);
342
+ if (group.runnerEvidence) {
343
+ // Reject configuration before acquiring a check lease/ports, so there
344
+ // is no unconfirmed managed child to strand on a validation error.
345
+ runnerObservationDurations(process.env);
346
+ }
347
+ const managed = registerManagedProcess(context, { kind: "check", ownerPid: process.pid, ownerId: `check:${id}`, operationId: workspaceOperationId(input.workspaceRoot), projectId: input.projectId, runId: input.runId, workId: input.workId ?? null, checkId: id, stdoutPath, stderrPath, metadata: { command: group.command, role: "check", workspace_root: input.workspaceRoot, workspace_gate_id: workspaceGateId, budget } });
103
348
  const allocation = await reservePorts(context, { ownerId: managed.owner_id, processId: managed.id, names: group.ports });
104
349
  resources = allocation.ports;
105
- input.progress?.(`check ${index + 1}/${groups.length} started: ${group.command}`);
350
+ try {
351
+ input.progress?.(`check ${index + 1}/${groups.length} preparing: ${group.command}`);
352
+ assertRunMutationAllowed(context, input.projectId, input.runId, generation);
353
+ }
354
+ catch (error) {
355
+ allocation.release();
356
+ finishManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, state: "failed", reason: "dispatch_fenced_before_spawn" });
357
+ throw error;
358
+ }
106
359
  const stdout = fs.openSync(stdoutPath, "a");
107
360
  const stderr = fs.openSync(stderrPath, "a");
361
+ let confirmedExpiry = null;
108
362
  try {
109
- result = await runCheck(group.command, input.workspaceRoot, { ...codeExecutionEnvironment(input.workspaceRoot), DD_FLOW_EVIDENCE_DIR: evidenceDir, DD_FLOW_CHECK_COMPLETION_FILE: completionPath, ...portEnvironment(resources) }, stdout, stderr, (elapsed) => input.progress?.(`check ${index + 1}/${groups.length} still running (${elapsed}s): ${group.command}`), (pid) => confirmManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, pid, processGroupId: process.platform === "win32" ? null : pid }), () => heartbeatManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token }));
363
+ const runnerEnvironment = group.runnerEvidence ? { DD_FLOW_CHECK_RUNNER_EVIDENCE_PATH: path.join(evidenceDir, group.runnerEvidence.path), DD_FLOW_CHECK_RUNNER_PROGRESS_PATH: path.join(evidenceDir, "runner-progress.jsonl"), DD_FLOW_CHECK_INVOCATION_ID: id, DD_FLOW_CHECK_ID: id, DD_FLOW_CHECK_INPUT_HASH: inputHash, DD_FLOW_CHECK_VERIFICATION_EPOCH: epoch } : {};
364
+ if (group.runnerEvidence)
365
+ fs.mkdirSync(path.dirname(path.join(evidenceDir, group.runnerEvidence.path)), { recursive: true });
366
+ result = await runCheck(group.command, input.workspaceRoot, { ...codeExecutionEnvironment(input.workspaceRoot), DD_FLOW_EVIDENCE_DIR: evidenceDir, DD_FLOW_CHECK_COMPLETION_FILE: completionPath, ...runnerEnvironment, ...portEnvironment(resources) }, stdout, stderr, (elapsed) => input.progress?.(`check ${index + 1}/${groups.length} still running (${elapsed}s): ${group.command}`), (pid) => {
367
+ confirmedExpiry = confirmManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, pid, processGroupId: process.platform === "win32" ? null : pid }).lease_expires_at;
368
+ assertRunMutationAllowed(context, input.projectId, input.runId, generation);
369
+ if (budget)
370
+ admitRuntimeCheck(context, managed.id, managed.lease_token);
371
+ }, attemptMs => renewManagedProcess({ ...context, resourceDatabaseMode: "prepared_existing", databaseDeadlineAt: Date.now() + attemptMs }, { id: managed.id, leaseToken: managed.lease_token }), { leaseExpiresAt: () => confirmedExpiry });
110
372
  }
111
373
  finally {
112
- allocation.release();
113
374
  fs.closeSync(stdout);
114
375
  fs.closeSync(stderr);
115
- finishManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, state: result.aborted || result.exitCode !== 0 || Boolean(result.error) ? "failed" : "stopped", reason: result.error });
116
376
  }
377
+ const current = managedProcessStatus(context).find(item => item.id === managed.id);
378
+ if (processTreeIsAlive(current))
379
+ throw new AppError("check_in_progress", "The check shell exited but its process tree is still live; retain this receipt and its resources until settlement", 2, { process_id: managed.id, check_receipt_id: id });
380
+ allocation.release();
381
+ if (!result.cleanupUnconfirmed)
382
+ finishManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, state: result.aborted || result.exitCode !== 0 || Boolean(result.error) ? "failed" : "stopped", reason: result.error });
117
383
  }
118
384
  catch (error) {
385
+ if (error instanceof AppError && error.code === "check_in_progress")
386
+ throw error;
119
387
  result = { exitCode: null, error: error instanceof Error ? error.message : String(error), aborted: true };
120
388
  }
121
389
  const after = workspaceState(input.workspaceRoot, [input.runHome]);
122
390
  const mutationPaths = changedPaths(before.files, after.files);
123
- const artifacts = collectRequiredArtifacts(evidenceDir, group.requiredArtifacts);
391
+ const artifacts = collectCheckArtifacts(evidenceDir, group.requiredArtifacts);
124
392
  if (fs.existsSync(stderrPath)) {
125
393
  if (!artifacts.complete)
126
394
  fs.appendFileSync(stderrPath, `\nmissing required evidence artifacts: ${artifacts.missing.join(", ")}\n`);
@@ -129,13 +397,28 @@ export async function runCodeChecks(context, input) {
129
397
  if (mutationPaths.length)
130
398
  fs.appendFileSync(stderrPath, `\ncheck mutated project workspace: ${mutationPaths.join(", ")}\n`);
131
399
  }
132
- const status = result.aborted ? "aborted" : result.exitCode === 0 && !result.error && artifacts.complete && mutationPaths.length === 0 ? "passed" : "failed";
400
+ const runnerEvidence = group.runnerEvidence ? readRunnerEvidence(evidenceDir, group.runnerEvidence, { invocation_id: id, check_id: id, command: group.command, input_hash: inputHash, verification_epoch: epoch }) : undefined;
401
+ const status = result.aborted ? "aborted" : result.exitCode === 0 && !result.error && artifacts.complete && mutationPaths.length === 0 && (!group.runnerEvidence || runnerEvidencePass(runnerEvidence)) ? "passed" : "failed";
133
402
  const receipt = receiptFor({ ...reserved, resources, result, status, after, mutationPaths, artifacts: artifacts.items });
403
+ if (group.runnerEvidence) {
404
+ receipt.runner_evidence_required = group.runnerEvidence;
405
+ receipt.runner_evidence = runnerEvidence ?? null;
406
+ }
134
407
  context.db.run("UPDATE check_receipts SET status = ?, exit_code = ?, after_fingerprint = ?, mutation_paths_json = ?, artifacts_json = ?, finished_at = ? WHERE id = ? AND project_id = ? AND status = 'running'", [receipt.status, receipt.exit_code, receipt.after_fingerprint, JSON.stringify(receipt.mutation_paths), JSON.stringify(receipt.artifacts), receipt.finished_at, id, input.projectId]);
135
408
  writeReceipt(receiptPath, receipt);
136
409
  receipts.push(receipt);
137
410
  input.progress?.(`check ${index + 1}/${groups.length} ${status}: ${group.command}`);
138
411
  }
412
+ if (receipts.every(receipt => receipt.status === "passed")) {
413
+ const current = workspaceState(input.workspaceRoot, [input.runHome]), currentProfileHash = codeCheckProfileHash(input.workspaceRoot, input.checks);
414
+ for (const receipt of receipts) {
415
+ const group = groups.find(candidate => candidate.declarations.some(check => receipt.check_refs.includes(checkRef(check))));
416
+ const currentHash = group ? checkInputHash(input.workspaceRoot, group, currentProfileHash, workspaceInputFingerprint(input.workspaceRoot, current.files, group.inputs, [input.runHome])) : null;
417
+ if (!group || !checkReceiptEvidenceValid(receipt) || receipt.input_hash !== currentHash)
418
+ throw new AppError("check_evidence_stale", "Check evidence changed before the gate could be accepted", 1, { check_receipt_id: receipt.id });
419
+ }
420
+ assertRunMutationAllowed(context, input.projectId, input.runId, generation);
421
+ }
139
422
  return receipts;
140
423
  }
141
424
  /**
@@ -144,62 +427,110 @@ export async function runCodeChecks(context, input) {
144
427
  * a dead/terminal process becomes an explicit aborted receipt that can retry.
145
428
  */
146
429
  export function reconcileUnfinishedChecks(context, input) {
147
- const where = input.workId ? "project_id = ? AND run_id = ? AND work_id = ? AND status = 'running'" : "project_id = ? AND run_id = ? AND status = 'running'";
430
+ let where = input.workId ? "project_id = ? AND run_id = ? AND work_id = ? AND status = 'running'" : "project_id = ? AND run_id = ? AND status = 'running'";
148
431
  const params = input.workId ? [input.projectId, input.runId, input.workId] : [input.projectId, input.runId];
432
+ if (input.receiptId) {
433
+ where += " AND id = ?";
434
+ params.push(input.receiptId);
435
+ }
149
436
  const pending = context.db.all(`SELECT id, receipt_path, started_at FROM check_receipts WHERE ${where}`, params);
150
437
  if (pending.length === 0)
151
438
  return [];
152
439
  const processes = managedProcessStatus(context);
153
440
  const reconciled = [];
154
441
  for (const pendingReceipt of pending) {
155
- const process = processes.filter((item) => item.check_id === pendingReceipt.id).at(-1);
442
+ const process = processes.filter((item) => item.check_id === pendingReceipt.id && item.project_id === input.projectId && item.run_id === input.runId).at(-1);
156
443
  const receipt = readReceipt(pendingReceipt.receipt_path);
157
444
  const completion = receipt ? readCompletion(receipt.completion_path || path.join(path.dirname(pendingReceipt.receipt_path), "completion.json")) : null;
158
445
  // A stale lease after host sleep is not permission to duplicate a living
159
446
  // check. Its durable completion marker or explicit reconciliation decides
160
447
  // the outcome before another invocation may start.
161
- if (process && ["starting", "running", "stopping"].includes(process.state) && (process.state === "starting" || processIsAlive(process))) {
448
+ // A confirmed PID plus its birth time is sufficient to prove that this
449
+ // exact check writer is gone. Do not retain a dead receipt merely because
450
+ // older registrations lack the optional parent-owner identity.
451
+ const processProvenDead = Boolean(process?.pid && process.pid_started_at && !processTreeIsAlive(process));
452
+ if (process && !processProvenDead && (processTreeIsAlive(process) || (["starting", "running", "stopping", "orphaned"].includes(process.state) && managedOwnerIsAlive(process) !== false))) {
162
453
  throw new AppError("check_in_progress", "A previous check invocation is still running", 1, { check_receipt_id: pendingReceipt.id, process_id: process.id, pid: process.pid });
163
454
  }
164
455
  if (!process && !completion && Date.now() - Date.parse(pendingReceipt.started_at) < receiptStartingGraceMs) {
165
456
  throw new AppError("check_in_progress", "A previous check invocation is still initializing", 1, { check_receipt_id: pendingReceipt.id, initializing: true });
166
457
  }
167
458
  const after = workspaceState(input.workspaceRoot, input.runHome ? [input.runHome] : []);
168
- const artifacts = receipt ? collectRequiredArtifacts(path.join(path.dirname(receipt.completion_path || pendingReceipt.receipt_path), "artifacts"), receipt.required_artifacts ?? []) : { complete: false, missing: [], items: [] };
459
+ const artifacts = receipt ? collectCheckArtifacts(path.join(path.dirname(receipt.completion_path || pendingReceipt.receipt_path), "artifacts"), receipt.required_artifacts ?? []) : { complete: false, missing: [], items: [] };
169
460
  const workspaceUnchanged = receipt?.before_fingerprint === after.fingerprint;
170
- const status = completion === null ? "aborted" : completion.exit_code === 0 && workspaceUnchanged && artifacts.complete ? "passed" : "failed";
461
+ const runnerEvidence = receipt?.runner_evidence_required ? readRunnerEvidence(path.join(path.dirname(pendingReceipt.receipt_path), "artifacts"), receipt.runner_evidence_required, { invocation_id: receipt.id, check_id: receipt.id, command: receipt.command, input_hash: receipt.input_hash, verification_epoch: receipt.verification_epoch }) : undefined;
462
+ const status = completion === null ? "aborted" : completion.exit_code === 0 && workspaceUnchanged && artifacts.complete && (!receipt?.runner_evidence_required || runnerEvidencePass(runnerEvidence)) ? "passed" : "failed";
171
463
  const exitCode = completion?.exit_code ?? null;
172
464
  const finishedAt = completion?.finished_at ?? context.now();
173
465
  context.db.run("UPDATE check_receipts SET status = ?, exit_code = ?, after_fingerprint = ?, mutation_paths_json = ?, artifacts_json = ?, finished_at = ? WHERE id = ? AND project_id = ? AND status = 'running'", [status, exitCode, after.fingerprint, JSON.stringify(workspaceUnchanged ? [] : ["<workspace changed while detached check ran>"]), JSON.stringify(artifacts.items), finishedAt, pendingReceipt.id, input.projectId]);
174
466
  if (process)
175
467
  finishManagedProcess(context, { id: process.id, leaseToken: process.lease_token, state: status === "passed" ? "stopped" : "failed", reason: status === "passed" ? "reconciled_completion" : completion === null ? "completion_marker_missing" : "reconciled_check_failed" });
176
468
  if (receipt) {
177
- const repaired = { ...receipt, completion_path: receipt.completion_path || path.join(path.dirname(pendingReceipt.receipt_path), "completion.json"), required_artifacts: receipt.required_artifacts ?? [], status, exit_code: exitCode, after_fingerprint: after.fingerprint, mutation_paths: workspaceUnchanged ? [] : ["<workspace changed while detached check ran>"], artifacts: artifacts.items, finished_at: finishedAt };
469
+ const repaired = { ...receipt, ...(receipt.runner_evidence_required ? { runner_evidence: runnerEvidence ?? null } : {}), completion_path: receipt.completion_path || path.join(path.dirname(pendingReceipt.receipt_path), "completion.json"), required_artifacts: receipt.required_artifacts ?? [], ...(status === "aborted" ? { abort_reason: "completion_marker_missing" } : {}), status, exit_code: exitCode, after_fingerprint: after.fingerprint, mutation_paths: workspaceUnchanged ? [] : ["<workspace changed while detached check ran>"], artifacts: artifacts.items, finished_at: finishedAt };
178
470
  writeReceipt(pendingReceipt.receipt_path, repaired);
179
471
  reconciled.push(repaired);
180
472
  }
181
473
  }
182
474
  return reconciled;
183
475
  }
184
- function reserveReceipt(context, input) {
185
- context.db.exec("BEGIN IMMEDIATE");
186
- try {
187
- const rows = context.db.all("SELECT id FROM check_receipts WHERE work_id IS ? AND run_id = ?", [input.workId ?? null, input.runId]);
188
- const ordinal = rows.reduce((max, row) => Math.max(max, Number(/\/RCP-(\d+)$/.exec(row.id)?.[1] ?? 0)), 0) + 1;
189
- const localId = `RCP-${String(ordinal).padStart(3, "0")}`;
190
- const id = `${input.workId ?? input.runId}/${localId}`;
476
+ function reserveOrReuseReceipt(context, input) {
477
+ // Receipt files and artifact hashes are prepared before reserving SQLite.
478
+ // The compact SQL snapshot below fences races with another check settlement.
479
+ const snapshotSql = "SELECT id, status, exit_code, input_hash, profile_hash, command, gate, retry_of_receipt_id, receipt_path FROM check_receipts WHERE project_id = ? AND run_id = ? ORDER BY reservation_sequence, rowid";
480
+ const snapshot = JSON.stringify(context.db.all(snapshotSql, [input.projectId, input.runId]));
481
+ const receipts = checkReceipts(context, { projectId: input.projectId, runId: input.runId });
482
+ if (JSON.stringify(context.db.all(snapshotSql, [input.projectId, input.runId])) !== snapshot)
483
+ throw new AppError("check_receipt_changed", "Check receipts changed during evidence preparation", 1);
484
+ const prior = receipts.filter((receipt) => receipt.input_hash === input.inputHash && receipt.profile_hash === input.profileHash).at(-1) ?? null;
485
+ const reusablePrior = prior?.status === "passed" && checkReceiptEvidenceValid(prior);
486
+ const retryIds = new Set([...(input.retryReceiptId ? [input.retryReceiptId] : []), ...(input.retryReceiptIds ?? [])]);
487
+ const successor = retryIds.size === 1 ? receipts.find((receipt) => receipt.retry_of_receipt_id === [...retryIds][0] && receipt.input_hash === input.inputHash && receipt.profile_hash === input.profileHash) : null;
488
+ const reusableSuccessor = successor?.status === "passed" && checkReceiptEvidenceValid(successor);
489
+ return context.db.writeTransaction(() => {
490
+ assertRunMutationAllowed(context, input.projectId, input.runId, input.generation);
491
+ if (JSON.stringify(context.db.all(snapshotSql, [input.projectId, input.runId])) !== snapshot)
492
+ throw new AppError("check_receipt_changed", "Check receipts changed before reservation", 1);
493
+ if (prior?.status === "running")
494
+ throw new AppError("check_in_progress", "A matching check invocation is still running", 1, { check_receipt_id: prior.id });
495
+ if (retryIds.size > 1)
496
+ throw new AppError("retry_check_invalid", "A check execution accepts exactly one retry predecessor", 2);
497
+ const retryId = [...retryIds][0];
498
+ if (retryId) {
499
+ if (!input.retryReason?.trim())
500
+ throw new AppError("retry_check_invalid", "A check retry requires the restored-condition reason", 2, { check_receipt_id: retryId });
501
+ const predecessor = receipts.find(receipt => receipt.id === retryId);
502
+ if (!predecessor || !["failed", "aborted"].includes(predecessor.status) || predecessor.input_hash !== input.inputHash || predecessor.profile_hash !== input.profileHash || predecessor.command !== input.group.command || predecessor.gate !== input.group.gate)
503
+ throw new AppError("retry_check_invalid", "Retry predecessor does not match this exact check contract", 2, { check_receipt_id: retryId });
504
+ if (prior?.id !== (successor?.id ?? predecessor.id))
505
+ throw new AppError("retry_check_invalid", "This retry was superseded by a newer check outcome; inspect the latest receipt", 2, { check_receipt_id: retryId, latest_check_receipt_id: prior?.id ?? null, latest_status: prior?.status ?? null });
506
+ if (successor?.status === "running")
507
+ throw new AppError("check_in_progress", "The requested retry is already running", 1, { check_receipt_id: successor.id, retry_of_receipt_id: retryId });
508
+ if (successor) {
509
+ if (reusableSuccessor)
510
+ return { receipt: successor };
511
+ throw new AppError("retry_check_invalid", "The requested retry already has a terminal outcome; choose its failed receipt for another retry", 2, { check_receipt_id: successor.id, retry_of_receipt_id: retryId, status: successor.status });
512
+ }
513
+ }
514
+ // An unchanged failed contract needs a concrete restored-condition retry,
515
+ // not repeated coordinator finish until it happens to become green.
516
+ if (prior && ["failed", "aborted"].includes(prior.status) && !retryId)
517
+ return { receipt: prior };
518
+ if (prior?.status === "passed" && !retryIds.has(prior.id) && input.group.reusable && reusablePrior)
519
+ return { receipt: prior };
520
+ let ordinal = (context.db.get("SELECT COALESCE(MAX(reservation_sequence), 0) + 1 AS sequence FROM check_receipts WHERE project_id = ? AND run_id = ?", [input.projectId, input.runId])?.sequence ?? 1);
521
+ let localId = `RCP-${String(ordinal).padStart(3, "0")}`, id = `${input.workId ?? input.runId}/${localId}`;
522
+ while (context.db.get("SELECT 1 FROM check_receipts WHERE id = ?", [id])) {
523
+ ordinal += 1;
524
+ localId = `RCP-${String(ordinal).padStart(3, "0")}`;
525
+ id = `${input.workId ?? input.runId}/${localId}`;
526
+ }
191
527
  const directory = path.join(input.runHome, input.artifactDir ?? "05-code", "checks", localId);
192
- const reserved = { id, localId, directory, evidenceDir: path.join(directory, "artifacts"), stdoutPath: path.join(directory, "stdout.log"), stderrPath: path.join(directory, "stderr.log"), receiptPath: path.join(directory, "receipt.json"), completionPath: path.join(directory, "completion.json"), group: input.group, before: input.before, profileHash: input.profileHash, inputHash: input.inputHash, epoch: input.epoch, startedAt: input.startedAt, scope: input.scope, ...(input.workId ? { workId: input.workId } : {}), projectId: input.projectId, runId: input.runId };
193
- context.db.run("INSERT INTO check_receipts (id, project_id, run_id, work_id, scope, declaration_id, check_refs_json, gate, command, input_hash, verification_epoch, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, before_fingerprint, after_fingerprint, profile_hash, mutation_paths_json, artifacts_json, started_at, finished_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', NULL, ?, ?, ?, ?, ?, ?, ?, '[]', '[]', ?, ?)", [id, input.projectId, input.runId, input.workId ?? null, input.scope, input.group.declarations[0].id, JSON.stringify(input.group.declarations.map(checkRef)), input.group.gate, input.group.command, input.inputHash, input.epoch, reserved.stdoutPath, reserved.stderrPath, reserved.receiptPath, input.before.fingerprint, input.before.fingerprint, input.before.fingerprint, input.profileHash, input.startedAt, input.startedAt]);
194
- context.db.exec("COMMIT");
195
- return reserved;
196
- }
197
- catch (error) {
198
- context.db.exec("ROLLBACK");
199
- throw error;
200
- }
528
+ const reserved = { id, localId, directory, evidenceDir: path.join(directory, "artifacts"), stdoutPath: path.join(directory, "stdout.log"), stderrPath: path.join(directory, "stderr.log"), receiptPath: path.join(directory, "receipt.json"), completionPath: path.join(directory, "completion.json"), group: input.group, before: input.before, profileHash: input.profileHash, inputHash: input.inputHash, epoch: input.epoch, startedAt: input.startedAt, scope: input.scope, ...(retryId ? { retryOf: retryId, retryReason: input.retryReason } : {}), ...(input.workId ? { workId: input.workId } : {}), projectId: input.projectId, runId: input.runId };
529
+ context.db.run("INSERT INTO check_receipts (id, project_id, run_id, work_id, scope, declaration_id, check_refs_json, gate, command, input_hash, verification_epoch, reservation_sequence, retry_of_receipt_id, retry_reason, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, before_fingerprint, after_fingerprint, profile_hash, mutation_paths_json, artifacts_json, started_at, finished_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', NULL, ?, ?, ?, ?, ?, ?, ?, '[]', '[]', ?, ?)", [id, input.projectId, input.runId, input.workId ?? null, input.scope, input.group.declarations[0].id, JSON.stringify(input.group.declarations.map(checkRef).sort()), input.group.gate, input.group.command, input.inputHash, input.epoch, ordinal, reserved.retryOf ?? null, reserved.retryReason ?? null, reserved.stdoutPath, reserved.stderrPath, reserved.receiptPath, input.before.fingerprint, input.before.fingerprint, input.before.fingerprint, input.profileHash, input.startedAt, input.startedAt]);
530
+ return { reserved };
531
+ }, "checks.reserveOrReuseReceipt");
201
532
  }
202
- function receiptFor(input) { return { id: input.id, local_id: input.localId, declaration_id: input.group.declarations[0].id, check_refs: input.group.declarations.map(checkRef), gate: input.group.gate, scope: input.scope, work_id: input.workId ?? null, command: input.group.command, input_hash: input.inputHash, verification_epoch: input.epoch, status: input.status, exit_code: input.status === "running" ? null : input.result.exitCode, stdout_path: input.stdoutPath, stderr_path: input.stderrPath, receipt_path: input.receiptPath, completion_path: input.completionPath, required_artifacts: input.group.requiredArtifacts, workspace_fingerprint: input.before.fingerprint, before_fingerprint: input.before.fingerprint, after_fingerprint: input.after.fingerprint, profile_hash: input.profileHash, mutation_paths: input.mutationPaths, artifacts: input.artifacts, resources: { ports: input.resources }, started_at: input.startedAt, finished_at: input.status === "running" ? input.startedAt : new Date().toISOString() }; }
533
+ function receiptFor(input) { return { id: input.id, local_id: input.localId, declaration_id: input.group.declarations[0].id, check_refs: input.group.declarations.map(checkRef), gate: input.group.gate, scope: input.scope, work_id: input.workId ?? null, command: input.group.command, input_hash: input.inputHash, verification_epoch: input.epoch, retry_of_receipt_id: input.retryOf ?? null, retry_reason: input.retryReason ?? null, status: input.status, ...(input.status === "aborted" ? { abort_reason: input.result.error } : {}), exit_code: input.status === "running" ? null : input.result.exitCode, stdout_path: input.stdoutPath, stderr_path: input.stderrPath, receipt_path: input.receiptPath, completion_path: input.completionPath, required_artifacts: input.group.requiredArtifacts, inputs: input.group.inputs, workspace_fingerprint: input.before.fingerprint, before_fingerprint: input.before.fingerprint, after_fingerprint: input.after.fingerprint, profile_hash: input.profileHash, mutation_paths: input.mutationPaths, artifacts: input.artifacts, resources: { ports: input.resources }, started_at: input.startedAt, finished_at: input.status === "running" ? input.startedAt : new Date().toISOString() }; }
203
534
  function writeReceipt(file, receipt) { const temporary = `${file}.tmp-${process.pid}-${crypto.randomUUID()}`; fs.writeFileSync(temporary, `${JSON.stringify(receipt, null, 2)}\n`); fs.renameSync(temporary, file); }
204
535
  function readReceipt(file) { try {
205
536
  return JSON.parse(fs.readFileSync(file, "utf8"));
@@ -215,30 +546,280 @@ function readCompletion(file) { try {
215
546
  catch {
216
547
  return null;
217
548
  } }
218
- export function unchangedFinalGateFailures(context, input) { const wanted = new Set(input.declarations.flatMap((check) => [check.id, checkRef(check)])); const current = workspaceFingerprint(input.workspaceRoot); const latest = new Map(); for (const receipt of checkReceipts(context, { projectId: input.projectId, runId: input.runId }).filter((item) => item.scope === "aggregate" && item.check_refs.some((ref) => wanted.has(ref))))
219
- for (const ref of receipt.check_refs)
220
- latest.set(ref, receipt); return [...new Set(latest.values())].filter((receipt) => receipt.status === "failed" && receipt.before_fingerprint === current); }
549
+ /** Validate an explicit retry; the caller still executes a fresh real gate and retains a new receipt. */
550
+ export function validateFailedCheckRetry(context, input) {
551
+ if (!input.reason?.trim())
552
+ throw new AppError("retry_check_invalid", "--retry-check requires --reason describing the restored environment", 2);
553
+ const receipt = checkReceipts(context, { projectId: input.projectId, runId: input.runId }).find((item) => item.id === input.receiptId);
554
+ if (!receipt || !["failed", "aborted"].includes(receipt.status))
555
+ throw new AppError("retry_check_invalid", "--retry-check must name a failed or aborted receipt in this RUN", 2, { check_receipt_id: input.receiptId });
556
+ const declared = new Set(input.declarations.flatMap((check) => [check.id, checkRef(check)]));
557
+ if (!receipt.check_refs.some((ref) => declared.has(ref)))
558
+ throw new AppError("retry_check_invalid", "--retry-check does not belong to this stage gate", 2, { check_receipt_id: input.receiptId, check_refs: receipt.check_refs });
559
+ return receipt;
560
+ }
221
561
  export function checkReceipts(context, input) {
222
562
  const where = input.workId ? "project_id = ? AND run_id = ? AND work_id = ?" : "project_id = ? AND run_id = ?";
223
563
  const params = input.workId ? [input.projectId, input.runId, input.workId] : [input.projectId, input.runId];
224
- return context.db.all(`SELECT id, declaration_id, check_refs_json, gate, scope, work_id, command, input_hash, verification_epoch, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, before_fingerprint, after_fingerprint, profile_hash, mutation_paths_json, artifacts_json, started_at, finished_at FROM check_receipts WHERE ${where} ORDER BY started_at, id`, params).map((row) => ({ ...row, local_id: row.id.slice(row.id.lastIndexOf("/") + 1), check_refs: parseArray(row.check_refs_json, [row.declaration_id]), mutation_paths: parseArray(row.mutation_paths_json, []), artifacts: JSON.parse(row.artifacts_json), resources: readReceiptResources(row.receipt_path) }));
564
+ return context.db.all(`SELECT id, declaration_id, check_refs_json, gate, scope, work_id, command, input_hash, verification_epoch, retry_of_receipt_id, retry_reason, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, before_fingerprint, after_fingerprint, profile_hash, mutation_paths_json, artifacts_json, started_at, finished_at FROM check_receipts WHERE ${where} ORDER BY reservation_sequence, rowid`, params).map((row) => ({ ...row, local_id: row.id.slice(row.id.lastIndexOf("/") + 1), check_refs: parseArray(row.check_refs_json, [row.declaration_id]), mutation_paths: parseArray(row.mutation_paths_json, []), resources: readReceiptResources(row.receipt_path), ...resolvedReceiptArtifacts(row.receipt_path, JSON.parse(row.artifacts_json), row) }));
565
+ }
566
+ /** File bytes remain immutable; only the reader projects their current location. */
567
+ function resolvedReceiptArtifacts(receiptPath, artifacts, binding) {
568
+ let original = {};
569
+ try {
570
+ original = JSON.parse(fs.readFileSync(receiptPath, "utf8"));
571
+ }
572
+ catch { /* Historical missing receipts remain visibly incomplete. */ }
573
+ const currentRoot = path.dirname(receiptPath);
574
+ const oldRoot = original.receipt_path ? path.dirname(original.receipt_path) : currentRoot;
575
+ const relocate = (file) => {
576
+ const relative = path.relative(oldRoot, file);
577
+ return !path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`) ? path.join(currentRoot, relative) : file;
578
+ };
579
+ let evidence = null;
580
+ if (original.runner_evidence_required && original.id && original.command && original.input_hash && original.verification_epoch) {
581
+ const root = path.join(currentRoot, "artifacts");
582
+ const retained = collectCheckArtifacts(root, [original.runner_evidence_required.path]);
583
+ if (retained.complete && JSON.stringify(retained.items) === JSON.stringify(artifacts))
584
+ evidence = readRunnerEvidence(root, original.runner_evidence_required, { invocation_id: binding.id, check_id: binding.id, command: binding.command, input_hash: binding.input_hash, verification_epoch: binding.verification_epoch });
585
+ }
586
+ return { completion_path: path.join(currentRoot, "completion.json"), required_artifacts: original.required_artifacts ?? [], inputs: original.inputs ?? [], artifacts: artifacts.map((artifact) => ({ ...artifact, path: relocate(artifact.path) })), ...(original.runner_evidence_required ? { runner_evidence_required: original.runner_evidence_required, runner_evidence: evidence } : {}) };
225
587
  }
226
588
  export function workspaceFingerprint(root) { return workspaceState(root).fingerprint; }
227
- export function workspaceChangedPaths(root) { const status = spawnSync("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); if (status.status !== 0)
228
- return null; return status.stdout.split("\0").filter(Boolean).map((entry) => entry.slice(3)).filter(Boolean).sort(); }
229
- function deduplicate(checks, commands) { const groups = new Map(); checks.forEach((check, index) => { const artifacts = [...(check.required_artifacts ?? [])].sort(); const ports = [...(check.ports ?? [])].sort(); const key = JSON.stringify([check.run_at, commands[index], artifacts, ports]); const current = groups.get(key); if (current)
230
- current.declarations.push(check);
231
- else
232
- groups.set(key, { gate: check.run_at, command: commands[index], declarations: [check], requiredArtifacts: artifacts, ports }); }); return [...groups.values()]; }
233
- function workspaceState(root, ignoredRoots = []) { const git = spawnSync("git", ["ls-files", "-co", "--exclude-standard", "-z"], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); const ignored = ignoredRoots.map((entry) => path.resolve(entry)); const listing = (git.status === 0 ? git.stdout.split("\0").filter(Boolean).sort() : listFiles(root)).filter((relative) => { const file = path.resolve(root, relative); return !ignored.some((ignoredRoot) => file === ignoredRoot || file.startsWith(`${ignoredRoot}${path.sep}`)); }); const files = new Map(); const hash = crypto.createHash("sha256"); for (const relative of listing) {
234
- const file = path.join(root, relative);
235
- const content = fs.existsSync(file) && fs.statSync(file).isFile() ? fs.readFileSync(file) : Buffer.from("<missing>");
236
- const digest = crypto.createHash("sha256").update(content).digest("hex");
237
- files.set(relative, digest);
238
- hash.update(relative).update("\0").update(digest).update("\0");
239
- } return { fingerprint: hash.digest("hex"), files }; }
589
+ /** One NUL-aware source of truth for paths differing from HEAD. Rename/copy
590
+ * records carry a second path; retain both because the old side is a deletion. */
591
+ export function workspaceChangedPaths(root) {
592
+ const status = spawnSync("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
593
+ // A workspace can be intentionally gitless. Keep the same conservative
594
+ // file inventory used for fingerprints instead of making result validation
595
+ // fail before the work contract can report its actual error.
596
+ if (isGitlessWorkspace(root, status))
597
+ return listFiles(root);
598
+ if (status.status !== 0 || status.error)
599
+ throw new AppError("workspace_status_failed", "Could not determine changed workspace paths", 1, { workspace_root: root, stderr: status.stderr || null, cause: status.error?.message ?? null });
600
+ const records = status.stdout.split("\0");
601
+ const paths = [];
602
+ for (let index = 0; index < records.length; index += 1) {
603
+ const record = records[index];
604
+ if (!record)
605
+ continue;
606
+ if (record.length < 4 || record[2] !== " ")
607
+ throw new AppError("workspace_status_invalid", "Git returned an invalid porcelain-v1 record", 1, { workspace_root: root, record });
608
+ paths.push(normalizeWorkspacePath(root, record.slice(3)));
609
+ if (record[0] === "R" || record[0] === "C" || record[1] === "R" || record[1] === "C") {
610
+ const prior = records[++index];
611
+ if (!prior)
612
+ throw new AppError("workspace_status_invalid", "Git rename/copy record is missing its second path", 1, { workspace_root: root, record });
613
+ paths.push(normalizeWorkspacePath(root, prior));
614
+ }
615
+ }
616
+ const ownedHook = paths.includes(".agents/hooks.json") ? ownedWorkspaceHookPath(root) : null;
617
+ return [...new Set(paths)].filter(file => file !== ownedHook).sort();
618
+ }
619
+ /** Ignored machine state cannot be reported as a product change. Tracked paths
620
+ * remain eligible regardless of their directory name. */
621
+ export function ignoredWorkspacePaths(root, paths) {
622
+ const normalized = [...new Set(paths.map((item) => normalizeWorkspacePath(root, item)))];
623
+ if (!normalized.length)
624
+ return [];
625
+ const status = spawnSync("git", ["check-ignore", "-z", "--stdin"], { cwd: root, encoding: "utf8", input: `${normalized.join("\0")}\0`, maxBuffer: 64 * 1024 * 1024 });
626
+ // The canonical owned hook has one meaning across Git status, check cache
627
+ // and MERGE, without changing a sibling worktree's shared Git excludes.
628
+ const gitless = isGitlessWorkspace(root, status);
629
+ if (!gitless && (![0, 1].includes(status.status ?? -1) || status.error))
630
+ throw new AppError("workspace_ignore_check_failed", "Could not classify reported workspace paths", 1, { workspace_root: root, stderr: status.stderr || null, cause: status.error?.message ?? null });
631
+ const ownedHook = normalized.includes(".agents/hooks.json") ? ownedWorkspaceHookPath(root, gitless ? [] : undefined) : null;
632
+ if (gitless)
633
+ return ownedHook ? [ownedHook] : [];
634
+ return [...new Set([...status.stdout.split("\0").filter(Boolean).map((item) => normalizeWorkspacePath(root, item)), ...(ownedHook ? [ownedHook] : [])])].sort();
635
+ }
636
+ export function normalizeWorkspacePath(root, value) {
637
+ if (typeof value !== "string" || !value || value.includes("\0") || path.isAbsolute(value))
638
+ throw new AppError("workspace_path_invalid", "Workspace path must be a non-empty relative path", 2, { path: value });
639
+ const resolvedRoot = path.resolve(root);
640
+ const resolved = path.resolve(resolvedRoot, value);
641
+ const relative = path.relative(resolvedRoot, resolved);
642
+ if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
643
+ throw new AppError("workspace_path_invalid", "Workspace path escapes its root", 2, { path: value, workspace_root: root });
644
+ return relative.split(path.sep).join("/");
645
+ }
646
+ function deduplicate(checks, commands) {
647
+ const groups = new Map();
648
+ checks.forEach((check, index) => {
649
+ const artifacts = [...(check.required_artifacts ?? [])].sort(), ports = [...(check.ports ?? [])].sort(), inputs = normalizedInputs(check.inputs ?? []);
650
+ const reusable = check.reuse === "deterministic";
651
+ // Stage labels identify obligations, not different processes in this gate.
652
+ // Cross-invocation reuse still requires the explicit deterministic contract.
653
+ const key = JSON.stringify([commands[index], artifacts, ports, inputs, reusable, check.runner_evidence ?? null]);
654
+ const current = groups.get(key);
655
+ if (current)
656
+ current.declarations.push(check);
657
+ else
658
+ groups.set(key, { gate: check.run_at, command: commands[index], declarations: [check], requiredArtifacts: artifacts, ports, inputs, reusable, ...(check.runner_evidence ? { runnerEvidence: check.runner_evidence } : {}) });
659
+ });
660
+ return [...groups.values()].map(group => ({ ...group, gate: [...group.declarations].map(check => check.run_at).sort()[0], declarations: [...group.declarations].sort((left, right) => checkRef(left).localeCompare(checkRef(right))) }));
661
+ }
662
+ function workspaceState(root, ignoredRoots = []) {
663
+ const git = workspaceGitFiles(root, ["-co", "--exclude-standard"]);
664
+ const ignored = ignoredRoots.map(entry => path.resolve(entry));
665
+ const listing = [...new Set(git ?? listFiles(root))].sort();
666
+ const ownedHook = listing.includes(".agents/hooks.json") ? ownedWorkspaceHookPath(root, git ? undefined : []) : null;
667
+ const files = new Map(), hash = crypto.createHash("sha256");
668
+ for (const relative of listing) {
669
+ if (relative === ownedHook)
670
+ continue;
671
+ const file = path.resolve(root, relative);
672
+ if (ignored.some(ignoredRoot => file === ignoredRoot || file.startsWith(`${ignoredRoot}${path.sep}`)))
673
+ continue;
674
+ // Deleted tracked paths are absent content, both before and after commit.
675
+ // Do not mask other IO errors as an unchanged workspace.
676
+ let stat;
677
+ try {
678
+ stat = fs.lstatSync(file);
679
+ }
680
+ catch (error) {
681
+ if (error.code === "ENOENT")
682
+ continue;
683
+ throw error;
684
+ }
685
+ const content = stat.isSymbolicLink() ? Buffer.from(`symlink\0${fs.readlinkSync(file)}`) : stat.isFile() ? fs.readFileSync(file) : Buffer.from("<directory>");
686
+ const digest = crypto.createHash("sha256").update(content).digest("hex");
687
+ files.set(relative, digest);
688
+ hash.update(relative).update("\0").update(digest).update("\0");
689
+ }
690
+ return { fingerprint: hash.digest("hex"), files };
691
+ }
692
+ function workspaceInputFingerprint(root, files, inputs, ignoredRoots) {
693
+ const selected = new Map(inputs.length === 0 || inputs.includes(".") ? files : []);
694
+ for (const [file, digest] of explicitInputFiles(root, inputs.filter(input => input !== "."), ignoredRoots))
695
+ selected.set(file, digest);
696
+ const hash = crypto.createHash("sha256");
697
+ for (const [file, digest] of [...selected].sort(([left], [right]) => left.localeCompare(right)))
698
+ hash.update(file).update("\0").update(digest).update("\0");
699
+ return hash.digest("hex");
700
+ }
240
701
  function changedPaths(before, after) { return [...new Set([...before.keys(), ...after.keys()])].filter((key) => before.get(key) !== after.get(key)).sort(); }
241
702
  function checkRef(check) { return check.canonical_ref ?? check.id; }
703
+ function assertConsistentDeclarations(checks, commands) {
704
+ const seen = new Map();
705
+ for (const [index, check] of checks.entries()) {
706
+ const contract = JSON.stringify([check.run_at, commands[index], [...(check.required_artifacts ?? [])].sort(), [...(check.ports ?? [])].sort(), normalizedInputs(check.inputs ?? []), check.reuse ?? null, check.runner_evidence ?? null]);
707
+ const ref = checkRef(check), prior = seen.get(ref);
708
+ if (prior && prior !== contract)
709
+ throw new AppError("check_declaration_conflict", "The same check reference has conflicting execution definitions", 2, { check_ref: ref });
710
+ seen.set(ref, contract);
711
+ }
712
+ }
713
+ function environmentFingerprint(workspaceRoot) {
714
+ const environment = codeExecutionEnvironment(workspaceRoot);
715
+ return crypto.createHash("sha256").update(JSON.stringify(Object.entries(environment).filter(([, value]) => value !== undefined).sort(([left], [right]) => left.localeCompare(right)))).digest("hex");
716
+ }
717
+ function acquireWorkspaceGate(context, input) {
718
+ const operationId = workspaceOperationId(input.workspaceRoot);
719
+ const active = managedProcessStatus(context).find(item => item.operation_id === operationId && ["starting", "running", "stopping"].includes(item.state));
720
+ if (active)
721
+ throw new AppError("check_in_progress", "The workspace is owned by another bootstrap or check; observe it before starting a new check", 1, { process_id: active.id, operation_id: operationId });
722
+ return registerManagedProcess(context, { kind: "workspace-gate", ownerPid: process.pid, ownerId: `workspace-gate:${crypto.randomUUID()}`, operationId, uniqueActiveOperation: true, projectId: input.projectId, runId: input.runId, metadata: { workspace_root: input.workspaceRoot, role: "check_gate" } });
723
+ }
724
+ export function reconcileWorkspaceOwnership(context, workspaceRoot) {
725
+ const operationId = workspaceOperationId(workspaceRoot);
726
+ const active = managedProcessStatus(context).filter(item => item.operation_id === operationId && ["starting", "running", "stopping", "orphaned"].includes(item.state));
727
+ if (active.some(item => processTreeIsAlive(item) || managedOwnerIsAlive(item) !== false))
728
+ return;
729
+ // Registry IDs are host-wide, whereas receipts belong to one runtime DB.
730
+ // An unknown/foreign home must not be settled against this client's rows.
731
+ if (active.some(item => managedProcessRuntimeHome(item, context) !== context.ddFlowHome))
732
+ return;
733
+ for (const child of active.filter(item => item.kind !== "workspace-gate")) {
734
+ if (child.kind !== "check" || !child.project_id || !child.run_id || !child.check_id)
735
+ return;
736
+ const run = context.db.get("SELECT run_root FROM runs WHERE project_id = ? AND id = ?", [child.project_id, child.run_id]);
737
+ if (!run?.run_root || !context.db.get("SELECT 1 FROM check_receipts WHERE id = ? AND project_id = ? AND run_id = ?", [child.check_id, child.project_id, child.run_id]))
738
+ return;
739
+ reconcileUnfinishedChecks(context, { projectId: child.project_id, runId: child.run_id, workspaceRoot, runHome: run.run_root, receiptId: child.check_id });
740
+ // A crash may occur after receipt settlement but before registry settlement.
741
+ const current = managedProcessStatus(context).find(item => item.id === child.id);
742
+ if (!current || current.lease_token !== child.lease_token)
743
+ return;
744
+ if (!["stopped", "failed"].includes(current.state) && !finishManagedProcess(context, { id: child.id, leaseToken: child.lease_token, state: "stopped", reason: "workspace_check_owner_reconciled" }))
745
+ return;
746
+ }
747
+ for (const gate of active.filter(item => item.kind === "workspace-gate"))
748
+ finishManagedProcess(context, { id: gate.id, leaseToken: gate.lease_token, state: "stopped", reason: "workspace_gate_owner_reconciled" });
749
+ }
750
+ function normalizedInputs(inputs) { for (const input of inputs)
751
+ validateCheckInputSyntax(input); return [...new Set(inputs.map((input) => { const normalized = path.posix.normalize(input.replaceAll("\\", "/")).replace(/^\.\/$/, ""); return normalized === "" || normalized === "." ? "." : normalized.replace(/\/$/, ""); }))].sort(); }
752
+ function validateCheckInputs(workspaceRoot, inputs) { const root = fs.realpathSync.native(workspaceRoot); for (const input of normalizedInputs(inputs)) {
753
+ if (input === ".git" || input.startsWith(".git/"))
754
+ throw new AppError("invalid_check_input", "Check inputs cannot include Git internals", 2, { input });
755
+ const candidate = path.resolve(workspaceRoot, input);
756
+ if (candidate !== workspaceRoot && !candidate.startsWith(`${workspaceRoot}${path.sep}`))
757
+ throw new AppError("invalid_check_input", "Check inputs must stay within the workspace", 2, { input });
758
+ if (fs.existsSync(candidate))
759
+ validateInputTree(root, candidate, input, new Set());
760
+ else
761
+ validateMissingInputParent(root, candidate, input);
762
+ } }
763
+ function validateCheckInputSyntax(input) { if (typeof input !== "string" || !input.trim() || path.isAbsolute(input) || input.split(/[\\/]/).includes(".."))
764
+ throw new AppError("invalid_check_input", "Check inputs must be non-empty relative workspace paths", 2, { input }); }
765
+ function validateMissingInputParent(root, candidate, input) { let parent = candidate; while (!fs.existsSync(parent))
766
+ parent = path.dirname(parent); const resolved = fs.realpathSync.native(parent); if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`))
767
+ throw new AppError("invalid_check_input", "Check input parent symlink escapes the workspace", 2, { input }); }
768
+ function validateInputTree(root, candidate, input, seen) {
769
+ const resolved = fs.realpathSync.native(candidate);
770
+ if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`))
771
+ throw new AppError("invalid_check_input", "Check input symlink escapes the workspace", 2, { input });
772
+ if (seen.has(resolved))
773
+ throw new AppError("invalid_check_input", "Check input contains a symbolic-link cycle", 2, { input });
774
+ seen.add(resolved);
775
+ const stat = fs.statSync(candidate);
776
+ if (stat.isDirectory())
777
+ for (const entry of fs.readdirSync(candidate))
778
+ validateInputTree(root, path.join(candidate, entry), input, seen);
779
+ seen.delete(resolved);
780
+ }
781
+ function explicitInputFiles(root, inputs, ignoredRoots) {
782
+ const rootReal = fs.realpathSync.native(root), ignored = ignoredRoots.map(entry => path.resolve(entry)), result = new Map();
783
+ const visit = (file, relative, seen) => {
784
+ if (ignored.some(entry => file === entry || file.startsWith(`${entry}${path.sep}`)))
785
+ return;
786
+ if (!fs.existsSync(file)) {
787
+ result.set(relative, "<missing>");
788
+ return;
789
+ }
790
+ const resolved = fs.realpathSync.native(file);
791
+ if (resolved !== rootReal && !resolved.startsWith(`${rootReal}${path.sep}`))
792
+ throw new AppError("invalid_check_input", "Check input symlink escapes the workspace", 2, { input: relative });
793
+ if (seen.has(resolved))
794
+ throw new AppError("invalid_check_input", "Check input contains a symbolic-link cycle", 2, { input: relative });
795
+ const stat = fs.statSync(file);
796
+ if (stat.isDirectory()) {
797
+ seen.add(resolved);
798
+ for (const entry of fs.readdirSync(file).sort())
799
+ visit(path.join(file, entry), path.posix.join(relative, entry), seen);
800
+ seen.delete(resolved);
801
+ }
802
+ else if (stat.isFile())
803
+ result.set(relative, crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"));
804
+ };
805
+ for (const input of inputs)
806
+ visit(path.resolve(root, input), input, new Set());
807
+ return [...result.entries()];
808
+ }
809
+ function checkInputHash(workspaceRoot, group, profileHash, inputFingerprint) { return crypto.createHash("sha256").update(JSON.stringify({ workspace_root: path.resolve(workspaceRoot), gate: group.reusable ? null : group.gate, command: group.command, required_artifacts: group.requiredArtifacts, ports: group.ports, inputs: group.inputs, ...(group.runnerEvidence ? { runner_evidence: group.runnerEvidence } : {}), profile_hash: profileHash, environment: environmentFingerprint(workspaceRoot), input_fingerprint: inputFingerprint })).digest("hex"); }
810
+ function bindReceipt(receipt, group) { return { ...receipt, gate: group.gate, declaration_id: group.declarations[0].id, check_refs: group.declarations.map(checkRef), required_artifacts: group.requiredArtifacts, inputs: group.inputs }; }
811
+ export function checkReceiptEvidenceValid(receipt) {
812
+ if (receipt.status !== "passed" || receipt.exit_code !== 0 || receipt.before_fingerprint !== receipt.after_fingerprint || !fs.existsSync(receipt.completion_path) || readCompletion(receipt.completion_path)?.exit_code !== 0)
813
+ return false;
814
+ const stored = readReceipt(receipt.receipt_path);
815
+ if (!stored || stored.status !== "passed" || stored.input_hash !== receipt.input_hash || stored.profile_hash !== receipt.profile_hash)
816
+ return false;
817
+ const root = path.join(path.dirname(receipt.receipt_path), "artifacts");
818
+ if (stored.runner_evidence_required && !runnerEvidencePass(readRunnerEvidence(root, stored.runner_evidence_required, { invocation_id: receipt.id, check_id: receipt.id, command: receipt.command, input_hash: receipt.input_hash, verification_epoch: receipt.verification_epoch })))
819
+ return false;
820
+ const artifacts = collectCheckArtifacts(root, stored.required_artifacts ?? []);
821
+ return artifacts.complete && JSON.stringify(artifacts.items) === JSON.stringify(stored.artifacts ?? []);
822
+ }
242
823
  function parseArray(value, fallback) { try {
243
824
  const parsed = JSON.parse(value);
244
825
  return Array.isArray(parsed) && parsed.every((item) => typeof item === "string") ? parsed : fallback;
@@ -247,8 +828,14 @@ catch {
247
828
  return fallback;
248
829
  } }
249
830
  function isStringRecord(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.values(value).every((item) => typeof item === "string" && item.trim().length > 0); }
250
- function isGateRecord(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.entries(value).every(([gate, aliases]) => ["work", "code", "readiness", "merge", "release", "external"].includes(gate) && Array.isArray(aliases) && aliases.every((item) => typeof item === "string" && item.startsWith("@check/"))); }
831
+ function isGateRecord(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.entries(value).every(([gate, aliases]) => ["work", "code", "readiness", "merge", "release"].includes(gate) && Array.isArray(aliases) && aliases.every((item) => typeof item === "string" && item.startsWith("@check/"))); }
251
832
  function isPortAliasRecord(value) { return value === undefined || (Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.entries(value).every(([alias, ports]) => alias.startsWith("@check/") && Array.isArray(ports) && ports.length > 0 && new Set(ports).size === ports.length && ports.every((port) => typeof port === "string" && /^[A-Za-z][A-Za-z0-9_]*$/.test(port)))); }
833
+ function isInputAliasRecord(workspaceRoot, value) { try {
834
+ return value === undefined || (Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.entries(value).every(([alias, inputs]) => alias.startsWith("@check/") && Array.isArray(inputs) && (validateCheckInputs(workspaceRoot, inputs), true)));
835
+ }
836
+ catch {
837
+ return false;
838
+ } }
252
839
  function portEnvironment(ports) { return Object.fromEntries(Object.entries(ports).map(([name, value]) => [`DD_FLOW_PORT_${name.toUpperCase()}`, String(value)])); }
253
840
  function readReceiptResources(file) { try {
254
841
  const value = JSON.parse(fs.readFileSync(file, "utf8"));
@@ -257,47 +844,202 @@ function readReceiptResources(file) { try {
257
844
  catch {
258
845
  return { ports: {} };
259
846
  } }
260
- function collectRequiredArtifacts(root, required) { const missing = []; const items = []; for (const relative of required) {
261
- if (path.isAbsolute(relative) || relative.split(/[\\/]/).includes(".."))
262
- throw new AppError("invalid_evidence_path", "Required evidence paths must be relative to DD_FLOW_EVIDENCE_DIR", 2, { path: relative });
263
- const file = path.join(root, relative);
264
- if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
265
- missing.push(relative);
266
- continue;
847
+ function collectCheckArtifacts(root, required) {
848
+ for (const relative of required) {
849
+ if (path.isAbsolute(relative) || relative.split(/[\\/]/).includes(".."))
850
+ throw new AppError("invalid_evidence_path", "Required evidence paths must be relative to DD_FLOW_EVIDENCE_DIR", 2, { path: relative });
267
851
  }
268
- items.push({ path: relative, sha256: crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex") });
269
- } return { complete: missing.length === 0, missing, items }; }
270
- function listFiles(root, current = root) { return fs.readdirSync(current, { withFileTypes: true }).flatMap((entry) => { if ([".git", "node_modules"].includes(entry.name))
271
- return []; const absolute = path.join(current, entry.name); return entry.isDirectory() ? listFiles(root, absolute) : [path.relative(root, absolute)]; }).sort(); }
272
- function runCheck(command, cwd, environment, stdout, stderr, heartbeat, onSpawn, renewLease) { return new Promise((resolve) => { const started = Date.now(); const child = spawn("/bin/sh", ["-lc", checkShellScript()], { cwd, env: { ...environment, DD_FLOW_CHECK_COMMAND: command }, stdio: ["ignore", stdout, stderr], detached: process.platform !== "win32" }); if (!child.pid) {
273
- resolve({ exitCode: null, error: "check process did not provide a PID", aborted: true });
274
- return;
275
- } try {
276
- onSpawn(child.pid);
277
- }
278
- catch (error) {
279
- terminateProcessGroup(child.pid, "SIGTERM");
280
- resolve({ exitCode: null, error: error instanceof Error ? error.message : String(error), aborted: true });
281
- return;
282
- } let leaseLost = false; let error = null; let escalation; const progress = setInterval(() => { const elapsed = Math.floor((Date.now() - started) / 1000); if (!renewLease()) {
283
- error = "managed process lease lost";
284
- leaseLost = true;
285
- terminateProcessGroup(child.pid, "SIGTERM");
286
- escalation = setTimeout(() => terminateProcessGroup(child.pid, "SIGKILL"), 5_000);
287
- return;
288
- } heartbeat(elapsed); }, 15_000); child.once("error", (value) => { error = value.message; }); child.once("close", (exitCode, signal) => { clearInterval(progress); if (escalation)
289
- clearTimeout(escalation); resolve({ exitCode, error: error ?? (signal ? `terminated by ${signal}` : null), aborted: leaseLost || Boolean(signal) || Boolean(error) }); }); }); }
852
+ // Required paths are minimum obligations; retain all explicit check evidence.
853
+ // Never follow symlinks, including a replaced evidence root.
854
+ const items = [];
855
+ const visit = (directory) => {
856
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
857
+ const file = path.join(directory, entry.name);
858
+ if (entry.isDirectory())
859
+ visit(file);
860
+ else if (entry.isFile())
861
+ items.push({ path: path.relative(root, file), sha256: crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex") });
862
+ }
863
+ };
864
+ if (fs.existsSync(root) && fs.lstatSync(root).isDirectory())
865
+ visit(root);
866
+ items.sort((a, b) => a.path.localeCompare(b.path));
867
+ const retained = new Set(items.map((item) => item.path));
868
+ const missing = required.filter((relative) => !retained.has(path.normalize(relative)));
869
+ return { complete: missing.length === 0, missing, items };
870
+ }
871
+ function listFiles(root) {
872
+ const allowed = projectServicePathFilter(root, [], []);
873
+ const ownedHook = ownedWorkspaceHookPath(root, []);
874
+ const visit = (current) => fs.readdirSync(current, { withFileTypes: true }).flatMap(entry => {
875
+ const absolute = path.join(current, entry.name);
876
+ if ([".git", "node_modules"].includes(entry.name) || !allowed(absolute))
877
+ return [];
878
+ return entry.isDirectory() ? visit(absolute) : [path.relative(root, absolute)];
879
+ });
880
+ return visit(root).filter(file => file.split(path.sep).join("/") !== ownedHook).sort();
881
+ }
882
+ export function runCheck(command, cwd, environment, stdout, stderr, heartbeat, onSpawn, renewLease, observation = {}) {
883
+ const inactivityTimeoutMs = validateObservationDuration(observation.inactivityTimeoutMs ?? 30 * 60_000);
884
+ const { startupMs: runnerStartupMs, executionMs: runnerExecutionMs } = runnerObservationDurations(environment.DD_FLOW_CHECK_RUNNER_PROGRESS_PATH ? environment : {});
885
+ return new Promise((resolve) => {
886
+ const started = Date.now();
887
+ const child = spawn("/bin/sh", ["-c", checkShellScript()], { cwd, env: { ...environment, DD_FLOW_CHECK_COMMAND: command }, stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32" });
888
+ let error = null;
889
+ let stopping = false;
890
+ let closed = false;
891
+ let progress;
892
+ let cleanup;
893
+ let cleanupUnconfirmed = false;
894
+ const renewal = createRenewalState();
895
+ let renewalTimer;
896
+ const clock = new ObservationClock({ timeoutMs: inactivityTimeoutMs });
897
+ let outputCursor = 0;
898
+ let runnerSequence = 0;
899
+ let runnerPhase = "startup";
900
+ const runnerProgress = environment.DD_FLOW_CHECK_RUNNER_PROGRESS_PATH;
901
+ const runnerClock = runnerProgress ? new ObservationClock({ timeoutMs: runnerStartupMs }) : null;
902
+ const outputs = [fs.createWriteStream("", { fd: stdout, autoClose: false }), fs.createWriteStream("", { fd: stderr, autoClose: false })];
903
+ const flushed = outputs.map(output => new Promise(resolve => { output.once("finish", resolve); output.once("error", () => resolve()); }));
904
+ const stop = (cause) => {
905
+ if (stopping)
906
+ return;
907
+ stopping = true;
908
+ error = cause instanceof Error ? cause.message : String(cause);
909
+ if (progress) {
910
+ clearInterval(progress);
911
+ progress = undefined;
912
+ }
913
+ if (renewalTimer)
914
+ clearTimeout(renewalTimer);
915
+ if (observationTimer)
916
+ clearInterval(observationTimer);
917
+ cleanup = stopProcessGroup(child, 5_000).catch(cause => {
918
+ cleanupUnconfirmed = true;
919
+ error = `${error}; cleanup: ${JSON.stringify(errorRecord(cause))}`;
920
+ resolve({ exitCode: null, error, aborted: true, cleanupUnconfirmed });
921
+ });
922
+ };
923
+ const observationTimer = setInterval(() => {
924
+ if (stopping || closed || observation.phase?.() === "serving")
925
+ return;
926
+ if (runnerClock && runnerProgress) {
927
+ for (const event of readRunnerProgress(runnerProgress, environment.DD_FLOW_CHECK_INVOCATION_ID ?? "", runnerSequence)) {
928
+ runnerSequence = event.sequence;
929
+ runnerPhase = event.phase;
930
+ runnerClock.sample(runnerSequence, Date.parse(event.at));
931
+ }
932
+ // The outer guard includes the runner's bounded cleanup tail.
933
+ runnerClock.timeoutMs = runnerPhase === "execution" ? runnerExecutionMs : runnerStartupMs;
934
+ if (runnerClock.sample(runnerSequence))
935
+ stop(new AppError("operation_observation_lost", "Structured test runner stopped making progress", 1, { phase: runnerPhase, sequence: runnerSequence }));
936
+ return;
937
+ }
938
+ if (clock.sample(outputCursor))
939
+ stop(new AppError("operation_observation_lost", "Owned command has no confirmed progress within its inactivity window", 1, { phase: observation.phase?.() ?? "finite", inactivity_timeout_ms: inactivityTimeoutMs }));
940
+ }, Math.min(1000, inactivityTimeoutMs));
941
+ // Register handlers before checking pid or calling persistence callbacks.
942
+ // Failure is not completion until the spawned process has actually closed.
943
+ child.once("error", (value) => { error = value.message; });
944
+ for (const [index, stream] of [child.stdout, child.stderr].entries()) {
945
+ outputs[index].once("error", stop);
946
+ stream.on("data", () => { if (!runnerClock) {
947
+ outputCursor++;
948
+ clock.sample(outputCursor);
949
+ } });
950
+ stream.pipe(outputs[index]);
951
+ }
952
+ child.stdin.on("error", stop);
953
+ child.once("close", async (exitCode, signal) => {
954
+ closed = true;
955
+ if (progress)
956
+ clearInterval(progress);
957
+ if (renewalTimer)
958
+ clearTimeout(renewalTimer);
959
+ if (observationTimer)
960
+ clearInterval(observationTimer);
961
+ await cleanup;
962
+ await Promise.all(flushed);
963
+ resolve({ exitCode, error: error ?? (signal ? `terminated by ${signal}` : null), aborted: stopping || Boolean(signal) || Boolean(error), ...(cleanupUnconfirmed ? { cleanupUnconfirmed } : {}) });
964
+ });
965
+ if (!child.pid) {
966
+ error = "check process did not provide a PID";
967
+ return;
968
+ }
969
+ try {
970
+ onSpawn(child.pid);
971
+ }
972
+ catch (cause) {
973
+ stop(cause);
974
+ return;
975
+ }
976
+ if (observation.leaseExpiresAt) {
977
+ const expiry = observation.leaseExpiresAt();
978
+ if (!expiry || !renewalConfirmed(renewal, performance.now(), expiry)) {
979
+ stop("check confirmation has no current authority");
980
+ return;
981
+ }
982
+ }
983
+ child.stdin.end("admitted\n");
984
+ clock.sample(outputCursor);
985
+ const renew = () => {
986
+ if (stopping || closed)
987
+ return;
988
+ if (renewalTimer) {
989
+ clearTimeout(renewalTimer);
990
+ renewalTimer = undefined;
991
+ }
992
+ try {
993
+ renewal.uncertainSince ??= performance.now();
994
+ const remaining = renewalRemaining(renewal);
995
+ if (!remaining) {
996
+ stop("managed process renewal budget exhausted");
997
+ return;
998
+ }
999
+ const receipt = renewLease(Math.min(RENEWAL_POLICY.attemptMs, remaining));
1000
+ if (receipt === false || typeof receipt === "object" && !receipt.owned) {
1001
+ stop("managed process lease lost");
1002
+ return;
1003
+ }
1004
+ if (typeof receipt === "object" && receipt.owned) {
1005
+ const expiry = Date.parse(receipt.lease_expires_at);
1006
+ if (!Number.isFinite(expiry))
1007
+ throw new AppError("managed_lease_receipt_invalid", "Check renewal returned invalid expiry", 1);
1008
+ if (expiry <= Date.now())
1009
+ throw Object.assign(new Error("Check renewal receipt expired before acknowledgement"), { code: "process_maintenance_timeout" });
1010
+ }
1011
+ if (!renewalConfirmed(renewal, performance.now(), typeof receipt === "object" && receipt.owned ? receipt.lease_expires_at : null)) {
1012
+ stop("managed process renewal budget exhausted");
1013
+ return;
1014
+ }
1015
+ }
1016
+ catch (cause) {
1017
+ if (!maintenanceRetryable(cause) || !renewalFailure(renewal)) {
1018
+ stop(cause);
1019
+ return;
1020
+ }
1021
+ renewalTimer = setTimeout(renew, Math.min(renewalDelay(renewal), renewalRemaining(renewal)));
1022
+ return;
1023
+ }
1024
+ // A progress callback error is not a retryable renewal failure.
1025
+ try {
1026
+ heartbeat(Math.floor((Date.now() - started) / 1000));
1027
+ }
1028
+ catch (cause) {
1029
+ stop(cause);
1030
+ }
1031
+ };
1032
+ progress = setInterval(renew, 15_000);
1033
+ });
1034
+ }
290
1035
  function checkShellScript() {
291
1036
  return [
1037
+ // The actual command cannot run before PID ownership and admission persist.
1038
+ 'IFS= read -r admission && [ "$admission" = admitted ] || exit 125',
292
1039
  'completion="${DD_FLOW_CHECK_COMPLETION_FILE:-}"',
293
- '/bin/sh -lc "$DD_FLOW_CHECK_COMMAND"',
1040
+ '/bin/sh -c "$DD_FLOW_CHECK_COMMAND"',
294
1041
  "code=$?",
295
1042
  'if [ -n "$completion" ]; then temporary="$completion.tmp.$$"; printf \'{"exit_code":%s,"finished_at":"%s"}\\n\' "$code" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$temporary" && mv "$temporary" "$completion"; fi',
296
1043
  'exit "$code"'
297
1044
  ].join("\n");
298
1045
  }
299
- function terminateProcessGroup(pid, signal) { if (!pid)
300
- return; try {
301
- process.kill(process.platform === "win32" ? pid : -pid, signal);
302
- }
303
- catch { /* already exited */ } }