cool-workflow 0.1.98 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (306) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/README.md +11 -2
  4. package/apps/architecture-review/app.json +1 -1
  5. package/apps/architecture-review-fast/app.json +1 -1
  6. package/apps/end-to-end-golden-path/app.json +1 -1
  7. package/apps/pr-review-fix-ci/app.json +1 -1
  8. package/apps/release-cut/app.json +1 -1
  9. package/apps/research-synthesis/app.json +1 -1
  10. package/dist/cli/dispatch.js +236 -0
  11. package/dist/cli/entry.js +120 -0
  12. package/dist/cli/io.js +21 -4
  13. package/dist/cli/parseargv.js +157 -0
  14. package/dist/cli.js +6 -33
  15. package/dist/core/capability-table.js +3534 -0
  16. package/dist/core/format/help.js +314 -0
  17. package/dist/{state-explosion/format.js → core/format/state-explosion-text.js} +10 -1
  18. package/dist/core/hash.js +137 -0
  19. package/dist/core/multi-agent/candidate-scoring.js +219 -0
  20. package/dist/core/multi-agent/collaboration.js +481 -0
  21. package/dist/core/multi-agent/coordinator.js +515 -0
  22. package/dist/core/multi-agent/eval-replay.js +306 -0
  23. package/dist/core/multi-agent/runtime.js +929 -0
  24. package/dist/core/multi-agent/topology.js +298 -0
  25. package/dist/core/multi-agent/trust-policy.js +197 -0
  26. package/dist/core/pipeline/commit-gate.js +320 -0
  27. package/dist/{pipeline-contract.js → core/pipeline/contract.js} +24 -37
  28. package/dist/core/pipeline/dispatch.js +103 -0
  29. package/dist/core/pipeline/drive-decide.js +227 -0
  30. package/dist/core/pipeline/error-feedback.js +161 -0
  31. package/dist/core/pipeline/loop-expansion.js +124 -0
  32. package/dist/{result-normalize.js → core/pipeline/result-normalize.js} +14 -37
  33. package/dist/core/pipeline/runner.js +230 -0
  34. package/dist/{contract-migration.js → core/state/contract-migration.js} +68 -92
  35. package/dist/{state-migrations.js → core/state/migrations.js} +103 -96
  36. package/dist/core/state/node-projection.js +95 -0
  37. package/dist/core/state/node-snapshot.js +230 -0
  38. package/dist/core/state/run-paths.js +93 -0
  39. package/dist/{schema-validate.js → core/state/schema-validate.js} +35 -28
  40. package/dist/core/state/schema.js +50 -0
  41. package/dist/core/state/state-explosion/digest.js +243 -0
  42. package/dist/core/state/state-explosion/graph.js +527 -0
  43. package/dist/{state-explosion → core/state/state-explosion}/helpers.js +34 -3
  44. package/dist/core/state/state-explosion/report.js +187 -0
  45. package/dist/{state-explosion → core/state/state-explosion}/size.js +25 -7
  46. package/dist/{state-node.js → core/state/state-node.js} +90 -113
  47. package/dist/core/state/types.js +19 -0
  48. package/dist/{validation.js → core/state/validation.js} +64 -131
  49. package/dist/core/trust/evidence-grounding.js +134 -0
  50. package/dist/core/trust/ledger.js +199 -0
  51. package/dist/{telemetry-attestation.js → core/trust/telemetry-attestation.js} +94 -68
  52. package/dist/core/trust/telemetry-ledger.js +127 -0
  53. package/dist/core/types.js +20 -0
  54. package/dist/core/version.js +16 -0
  55. package/dist/{workflow-app-framework.js → core/workflow-apps/app-schema.js} +337 -426
  56. package/dist/mcp/dispatch.js +104 -0
  57. package/dist/mcp/server.js +144 -0
  58. package/dist/mcp-server.js +6 -84
  59. package/dist/{agent-config.js → shell/agent-config.js} +118 -71
  60. package/dist/shell/app-run-cli.js +88 -0
  61. package/dist/shell/audit-cli.js +259 -0
  62. package/dist/shell/audit-provenance.js +83 -0
  63. package/dist/shell/candidate-scoring-io.js +459 -0
  64. package/dist/shell/collaboration-io.js +264 -0
  65. package/dist/shell/commit-summary.js +104 -0
  66. package/dist/shell/commit.js +290 -0
  67. package/dist/shell/coordinator-io.js +476 -0
  68. package/dist/shell/demo-cli.js +19 -0
  69. package/dist/shell/dispatch.js +162 -0
  70. package/dist/{doctor.js → shell/doctor.js} +123 -56
  71. package/dist/shell/drive.js +873 -0
  72. package/dist/shell/error-feedback-io.js +305 -0
  73. package/dist/shell/eval-io.js +473 -0
  74. package/dist/{multi-agent-eval/format.js → shell/eval-text.js} +78 -49
  75. package/dist/{evidence-reasoning.js → shell/evidence-reasoning.js} +124 -255
  76. package/dist/shell/exec-backend-cli.js +88 -0
  77. package/dist/shell/execution-backend/agent.js +475 -0
  78. package/dist/shell/execution-backend/ci.js +15 -0
  79. package/dist/shell/execution-backend/container.js +69 -0
  80. package/dist/shell/execution-backend/envelopes.js +55 -0
  81. package/dist/shell/execution-backend/local.js +113 -0
  82. package/dist/shell/execution-backend/probes.js +175 -0
  83. package/dist/shell/execution-backend/registry.js +402 -0
  84. package/dist/shell/execution-backend/remote.js +128 -0
  85. package/dist/shell/execution-backend/types.js +11 -0
  86. package/dist/shell/feedback-cli.js +81 -0
  87. package/dist/shell/feedback-operations.js +48 -0
  88. package/dist/shell/fs-atomic.js +276 -0
  89. package/dist/shell/harness.js +98 -0
  90. package/dist/shell/ledger-cli.js +212 -0
  91. package/dist/shell/ledger-io.js +169 -0
  92. package/dist/shell/man-cli.js +89 -0
  93. package/dist/shell/metrics-cli.js +98 -0
  94. package/dist/shell/multi-agent-cli.js +1002 -0
  95. package/dist/shell/multi-agent-host.js +563 -0
  96. package/dist/shell/multi-agent-io.js +387 -0
  97. package/dist/{multi-agent-operator-ux.js → shell/multi-agent-operator-ux.js} +234 -191
  98. package/dist/shell/node-store.js +124 -0
  99. package/dist/{observability/format.js → shell/observability-format.js} +7 -1
  100. package/dist/{observability/intake.js → shell/observability-intake.js} +79 -56
  101. package/dist/{observability.js → shell/observability.js} +159 -332
  102. package/dist/{onramp.js → shell/onramp.js} +11 -0
  103. package/dist/{operator-ux/format.js → shell/operator-ux-text.js} +250 -239
  104. package/dist/shell/operator-ux.js +431 -0
  105. package/dist/shell/orchestrator.js +231 -0
  106. package/dist/shell/pipeline-cli.js +667 -0
  107. package/dist/shell/pipeline.js +217 -0
  108. package/dist/shell/reclamation-io.js +1366 -0
  109. package/dist/shell/registry-cli.js +329 -0
  110. package/dist/{remote-source.js → shell/remote-source.js} +2 -2
  111. package/dist/shell/report-cli.js +101 -0
  112. package/dist/shell/report-view-cli.js +117 -0
  113. package/dist/{orchestrator → shell}/report.js +289 -282
  114. package/dist/shell/reporter.js +62 -0
  115. package/dist/shell/run-export-cli.js +106 -0
  116. package/dist/shell/run-export.js +680 -0
  117. package/dist/shell/run-registry-io.js +1014 -0
  118. package/dist/shell/run-store.js +164 -0
  119. package/dist/{sandbox-profile.js → shell/sandbox-profile.js} +134 -95
  120. package/dist/{scheduler.js → shell/scheduler-io.js} +248 -48
  121. package/dist/shell/scheduling-io.js +311 -0
  122. package/dist/shell/state-cli.js +181 -0
  123. package/dist/shell/state-explosion-cli.js +197 -0
  124. package/dist/shell/telemetry-cli.js +85 -0
  125. package/dist/{telemetry-demo.js → shell/telemetry-demo.js} +149 -119
  126. package/dist/shell/telemetry-ledger-io.js +132 -0
  127. package/dist/{term.js → shell/term.js} +36 -46
  128. package/dist/shell/topology-io.js +361 -0
  129. package/dist/shell/trust-audit.js +471 -0
  130. package/dist/{multi-agent-trust.js → shell/trust-policy-io.js} +67 -205
  131. package/dist/shell/verifier.js +48 -0
  132. package/dist/shell/workbench-host.js +250 -0
  133. package/dist/shell/workbench-text.js +18 -0
  134. package/dist/shell/workbench.js +175 -0
  135. package/dist/shell/worker-cli.js +124 -0
  136. package/dist/shell/worker-isolation.js +852 -0
  137. package/dist/shell/workflow-app-loader.js +650 -0
  138. package/docs/agent-delegation-drive.7.md +2 -0
  139. package/docs/cli-mcp-parity.7.md +280 -219
  140. package/docs/contract-migration-tooling.7.md +2 -0
  141. package/docs/control-plane-scheduling.7.md +2 -0
  142. package/docs/durable-state-and-locking.7.md +2 -0
  143. package/docs/evidence-adoption-reasoning-chain.7.md +2 -0
  144. package/docs/execution-backends.7.md +2 -0
  145. package/docs/multi-agent-cli-mcp-surface.7.md +2 -0
  146. package/docs/multi-agent-eval-replay-harness.7.md +2 -0
  147. package/docs/multi-agent-operator-ux.7.md +2 -0
  148. package/docs/node-snapshot-diff-replay.7.md +2 -0
  149. package/docs/observability-cost-accounting.7.md +2 -0
  150. package/docs/project-index.md +132 -71
  151. package/docs/real-execution-backends.7.md +2 -0
  152. package/docs/release-and-migration.7.md +2 -0
  153. package/docs/release-tooling.7.md +2 -0
  154. package/docs/run-registry-control-plane.7.md +2 -0
  155. package/docs/run-retention-reclamation.7.md +23 -0
  156. package/docs/state-explosion-management.7.md +2 -0
  157. package/docs/team-collaboration.7.md +2 -0
  158. package/docs/web-desktop-workbench.7.md +2 -0
  159. package/manifest/plugin.manifest.json +1 -1
  160. package/manifest/source-context-profiles.json +9 -13
  161. package/package.json +1 -1
  162. package/scripts/agents/cw-attest-wrap.js +2 -2
  163. package/scripts/bump-version.js +4 -3
  164. package/scripts/canonical-apps.js +4 -4
  165. package/scripts/dogfood-architecture-review.js +2 -3
  166. package/scripts/dogfood-release.js +3 -3
  167. package/scripts/gen-parity-doc.js +15 -2
  168. package/scripts/golden-path.js +4 -4
  169. package/scripts/onramp-check.js +1 -1
  170. package/scripts/parity-check.js +38 -21
  171. package/scripts/release-flow.js +2 -2
  172. package/scripts/sync-project-index.js +51 -27
  173. package/scripts/validate-run-state-schema.js +2 -2
  174. package/scripts/version-sync-check.js +30 -30
  175. package/dist/candidate-scoring.js +0 -729
  176. package/dist/capability-core.js +0 -1189
  177. package/dist/capability-registry.js +0 -885
  178. package/dist/cli/command-surface.js +0 -494
  179. package/dist/cli/format.js +0 -56
  180. package/dist/cli/handlers/audit.js +0 -82
  181. package/dist/cli/handlers/blackboard.js +0 -81
  182. package/dist/cli/handlers/candidate.js +0 -40
  183. package/dist/cli/handlers/clones.js +0 -34
  184. package/dist/cli/handlers/collaboration.js +0 -61
  185. package/dist/cli/handlers/eval.js +0 -40
  186. package/dist/cli/handlers/ledger.js +0 -169
  187. package/dist/cli/handlers/maintenance.js +0 -107
  188. package/dist/cli/handlers/multi-agent.js +0 -165
  189. package/dist/cli/handlers/node.js +0 -41
  190. package/dist/cli/handlers/operational.js +0 -155
  191. package/dist/cli/handlers/operator.js +0 -146
  192. package/dist/cli/handlers/registry.js +0 -68
  193. package/dist/cli/handlers/run.js +0 -153
  194. package/dist/cli/handlers/scheduling.js +0 -132
  195. package/dist/cli/handlers/workbench.js +0 -41
  196. package/dist/cli/handlers/worker.js +0 -45
  197. package/dist/cli/run-summary.js +0 -45
  198. package/dist/clones.js +0 -162
  199. package/dist/collaboration.js +0 -726
  200. package/dist/commit.js +0 -592
  201. package/dist/compare.js +0 -18
  202. package/dist/coordinator/classify.js +0 -45
  203. package/dist/coordinator/paths.js +0 -42
  204. package/dist/coordinator/util.js +0 -126
  205. package/dist/coordinator.js +0 -990
  206. package/dist/daemon.js +0 -44
  207. package/dist/dispatch.js +0 -250
  208. package/dist/drive.js +0 -864
  209. package/dist/error-feedback.js +0 -419
  210. package/dist/evidence-grounding.js +0 -184
  211. package/dist/execution-backend/agent.js +0 -354
  212. package/dist/execution-backend/probes.js +0 -112
  213. package/dist/execution-backend/util.js +0 -47
  214. package/dist/execution-backend.js +0 -961
  215. package/dist/gates.js +0 -48
  216. package/dist/harness.js +0 -61
  217. package/dist/ledger.js +0 -313
  218. package/dist/loop-expansion.js +0 -60
  219. package/dist/mcp/tool-call.js +0 -470
  220. package/dist/mcp/tool-definitions.js +0 -1066
  221. package/dist/mcp-surface.js +0 -30
  222. package/dist/multi-agent/graph.js +0 -84
  223. package/dist/multi-agent/helpers.js +0 -141
  224. package/dist/multi-agent/ids.js +0 -20
  225. package/dist/multi-agent/paths.js +0 -22
  226. package/dist/multi-agent-eval/normalize.js +0 -51
  227. package/dist/multi-agent-eval.js +0 -678
  228. package/dist/multi-agent-host.js +0 -777
  229. package/dist/multi-agent.js +0 -984
  230. package/dist/node-projection.js +0 -59
  231. package/dist/node-snapshot.js +0 -260
  232. package/dist/operator-ux.js +0 -631
  233. package/dist/orchestrator/app-operations.js +0 -211
  234. package/dist/orchestrator/audit-operations.js +0 -182
  235. package/dist/orchestrator/candidate-operations.js +0 -117
  236. package/dist/orchestrator/cli-options.js +0 -294
  237. package/dist/orchestrator/collaboration-operations.js +0 -86
  238. package/dist/orchestrator/feedback-operations.js +0 -81
  239. package/dist/orchestrator/host-operations.js +0 -78
  240. package/dist/orchestrator/lifecycle-operations.js +0 -650
  241. package/dist/orchestrator/migration-operations.js +0 -44
  242. package/dist/orchestrator/multi-agent-operations.js +0 -362
  243. package/dist/orchestrator/topology-operations.js +0 -84
  244. package/dist/orchestrator.js +0 -925
  245. package/dist/pipeline-runner.js +0 -285
  246. package/dist/reclamation/hash.js +0 -72
  247. package/dist/reclamation.js +0 -812
  248. package/dist/reporter.js +0 -67
  249. package/dist/run-export.js +0 -815
  250. package/dist/run-registry/derive.js +0 -175
  251. package/dist/run-registry/format.js +0 -124
  252. package/dist/run-registry/gc.js +0 -251
  253. package/dist/run-registry/policy.js +0 -16
  254. package/dist/run-registry/queue.js +0 -115
  255. package/dist/run-registry.js +0 -850
  256. package/dist/run-state-schema.js +0 -68
  257. package/dist/scheduling.js +0 -184
  258. package/dist/state-explosion.js +0 -1014
  259. package/dist/state.js +0 -367
  260. package/dist/telemetry-ledger.js +0 -196
  261. package/dist/topology.js +0 -565
  262. package/dist/triggers.js +0 -184
  263. package/dist/trust-audit.js +0 -644
  264. package/dist/types/blackboard.js +0 -2
  265. package/dist/types/candidate.js +0 -2
  266. package/dist/types/collaboration.js +0 -2
  267. package/dist/types/core.js +0 -2
  268. package/dist/types/drive.js +0 -10
  269. package/dist/types/error-feedback.js +0 -2
  270. package/dist/types/evidence-reasoning.js +0 -2
  271. package/dist/types/execution-backend.js +0 -2
  272. package/dist/types/multi-agent.js +0 -2
  273. package/dist/types/observability.js +0 -2
  274. package/dist/types/pipeline.js +0 -2
  275. package/dist/types/reclamation.js +0 -8
  276. package/dist/types/report-bundle.js +0 -6
  277. package/dist/types/result.js +0 -2
  278. package/dist/types/run-registry.js +0 -2
  279. package/dist/types/run.js +0 -2
  280. package/dist/types/sandbox.js +0 -2
  281. package/dist/types/schedule.js +0 -2
  282. package/dist/types/state-node.js +0 -2
  283. package/dist/types/topology.js +0 -2
  284. package/dist/types/trust.js +0 -2
  285. package/dist/types/workbench.js +0 -2
  286. package/dist/types/worker.js +0 -2
  287. package/dist/types/workflow-app.js +0 -2
  288. package/dist/types.js +0 -44
  289. package/dist/util/fingerprint.js +0 -19
  290. package/dist/util/fingerprint.test.js +0 -27
  291. package/dist/verifier.js +0 -78
  292. package/dist/version.js +0 -8
  293. package/dist/workbench-host.js +0 -192
  294. package/dist/workbench.js +0 -192
  295. package/dist/worker-accept/acceptance.js +0 -114
  296. package/dist/worker-accept/blackboard-fanout.js +0 -80
  297. package/dist/worker-accept/blackboard-linkage.js +0 -19
  298. package/dist/worker-accept/context.js +0 -2
  299. package/dist/worker-accept/telemetry-ledger.js +0 -126
  300. package/dist/worker-accept/validation.js +0 -77
  301. package/dist/worker-accept/verifier-completion.js +0 -73
  302. package/dist/worker-isolation/helpers.js +0 -51
  303. package/dist/worker-isolation/paths.js +0 -46
  304. package/dist/worker-isolation.js +0 -656
  305. package/dist/workflow-api.js +0 -131
  306. /package/dist/{types → core/types}/boundary.js +0 -0
@@ -1,812 +0,0 @@
1
- "use strict";
2
- // Run Retention & Provable Reclamation (v0.1.39) — the core write-ahead, fail-closed
3
- // reclamation transaction. Frees disk WITHOUT violating the audit/replay moat:
4
- // freeing bytes leaves behind a hash-chained tombstone proving what was freed is
5
- // reconstructable-or-worthless and that the audit-essential subset is sealed.
6
- //
7
- // BSD / Unix discipline (each tenet is a hard constraint; load-bearing ones flagged):
8
- //
9
- // - THE INVARIANT [LOAD-BEARING]: never delete what is audit-essential AND
10
- // irreproducible. A byte is freeable ONLY if it is (1) reconstructable from
11
- // retained inputs + a recorded recipe + an `expectDigest`, or (2) pure scratch
12
- // with zero audit value — AND referenced by no surviving evidence locator or
13
- // audit event. Any UNCLASSIFIED path defaults to RETAINED.
14
- // - WRITE-AHEAD, FAIL-CLOSED SEQUENCING [LOAD-BEARING]: extract+seal skeleton →
15
- // write full tombstone (pre-deletion sha256 per path) → fsync/commit into the
16
- // append-only overlay → ONLY THEN free the bulk. A crash between any steps
17
- // leaves EITHER the full run OR a complete tombstone — never half-deleted.
18
- // - APPEND-ONLY [LOAD-BEARING]: the tombstone is a NEW `reclaimed.json` overlay;
19
- // only bulk DATA bytes are freed — no existing audit/state/commit record is ever
20
- // rewritten. Hash-chained: tombstoneHash recomputed from freed-manifest + sealed
21
- // skeleton + prevTombstoneHash (genesis = sha256 of the sealed skeleton).
22
- // - CAPABILITY DOWNGRADE IS EXPLICIT [LOAD-BEARING for replay]: reclaiming a
23
- // snapshot downgrades re-runnable → verify-only (or re-runnable-by-reconstruction
24
- // when inputs + expectDigest are retained), surfaced as a closed-enum reason.
25
- //
26
- // This module is LOW-LEVEL (no import of run-registry); the registry composes
27
- // these primitives. See docs/run-retention-reclamation.7.md.
28
- var __importDefault = (this && this.__importDefault) || function (mod) {
29
- return (mod && mod.__esModule) ? mod : { "default": mod };
30
- };
31
- Object.defineProperty(exports, "__esModule", { value: true });
32
- exports.dirBytes = exports.sha256OfFile = exports.sha256OfString = exports.ReclamationError = exports.ReclamationAbort = exports.SKELETON_REQUIRED_KEYS = void 0;
33
- exports.reclaimedLogPath = reclaimedLogPath;
34
- exports.loadReclamationLog = loadReclamationLog;
35
- exports.extractSkeleton = extractSkeleton;
36
- exports.validateSkeleton = validateSkeleton;
37
- exports.validateSkeletonAgainstRun = validateSkeletonAgainstRun;
38
- exports.planReclamation = planReclamation;
39
- exports.genesisPrevHash = genesisPrevHash;
40
- exports.computeTombstoneHash = computeTombstoneHash;
41
- exports.buildTombstone = buildTombstone;
42
- exports.commitTombstone = commitTombstone;
43
- exports.prepareFree = prepareFree;
44
- exports.freeBulk = freeBulk;
45
- exports.runReclamation = runReclamation;
46
- exports.reconstructArtifact = reconstructArtifact;
47
- exports.verifyReclamation = verifyReclamation;
48
- exports.dominantFailureCode = dominantFailureCode;
49
- const node_fs_1 = __importDefault(require("node:fs"));
50
- const node_path_1 = __importDefault(require("node:path"));
51
- const hash_1 = require("./reclamation/hash");
52
- Object.defineProperty(exports, "dirBytes", { enumerable: true, get: function () { return hash_1.dirBytes; } });
53
- Object.defineProperty(exports, "sha256OfFile", { enumerable: true, get: function () { return hash_1.sha256OfFile; } });
54
- Object.defineProperty(exports, "sha256OfString", { enumerable: true, get: function () { return hash_1.sha256OfString; } });
55
- const multi_agent_eval_1 = require("./multi-agent-eval");
56
- const node_projection_1 = require("./node-projection");
57
- const node_snapshot_1 = require("./node-snapshot");
58
- const state_1 = require("./state");
59
- const trust_audit_1 = require("./trust-audit");
60
- const compare_1 = require("./compare");
61
- /** The skeleton schema is the contract for what MUST survive every reclamation.
62
- * Machine-checkable via validateSkeleton(). If extraction can't produce all of
63
- * these, reclamation fails closed and frees nothing. */
64
- exports.SKELETON_REQUIRED_KEYS = [
65
- "runId",
66
- "finalVerdict",
67
- "commits",
68
- "evidenceDigests",
69
- "attestationChain",
70
- "costRecord",
71
- "auditLog",
72
- "collaborationLog",
73
- "stateDigest"
74
- ];
75
- /** Synthetic abort thrown by runReclamation({ faultAfter }) — a TESTABLE crash
76
- * injection that never kills the process. */
77
- class ReclamationAbort extends Error {
78
- step;
79
- constructor(step) {
80
- super(`ReclamationAbort after step: ${step}`);
81
- this.name = "ReclamationAbort";
82
- this.step = step;
83
- }
84
- }
85
- exports.ReclamationAbort = ReclamationAbort;
86
- /** Fail-closed refusal: a real reason reclamation freed nothing (distinct code). */
87
- class ReclamationError extends Error {
88
- code;
89
- details;
90
- constructor(code, message, details) {
91
- super(message);
92
- this.name = "ReclamationError";
93
- this.code = code;
94
- this.details = details;
95
- }
96
- }
97
- exports.ReclamationError = ReclamationError;
98
- /** Persist a run's authoritative state.json DURABLY (atomic temp → fsync →
99
- * rename). The re-point that scratch reclamation depends on MUST be persisted
100
- * this way BEFORE any byte is freed — see prepareFree(). */
101
- function persistRunDurable(run) {
102
- run.updatedAt = new Date().toISOString();
103
- (0, state_1.writeJson)(run.paths.state, run, { durable: true });
104
- }
105
- /** Run `fn` while holding the per-run reclamation lock (serializes the
106
- * reclaimed.json read-modify-write so a concurrent reclaimer can never lose a
107
- * tombstone). Generalized into state.ts's portable withFileLock (P1-C/P1-D). */
108
- function withRunLock(run, fn) {
109
- return (0, state_1.withFileLock)(reclaimedLogPath(run), fn);
110
- }
111
- // ---------------------------------------------------------------------------
112
- // The per-run reclamation log (`reclaimed.json`) — an append-only chain of
113
- // tombstones, a PEER of archive.json, in the ALLOW-LIST (never freed).
114
- // ---------------------------------------------------------------------------
115
- function reclaimedLogPath(run) {
116
- return node_path_1.default.join(run.paths.runDir, "reclaimed.json");
117
- }
118
- function loadReclamationLog(run) {
119
- const file = reclaimedLogPath(run);
120
- if (!node_fs_1.default.existsSync(file))
121
- return { schemaVersion: 1, runId: run.id, tombstones: [] };
122
- try {
123
- const parsed = JSON.parse(node_fs_1.default.readFileSync(file, "utf8"));
124
- if (!parsed || typeof parsed !== "object" || parsed.schemaVersion !== 1 || !Array.isArray(parsed.tombstones)) {
125
- return { schemaVersion: 1, runId: run.id, tombstones: [] };
126
- }
127
- return { schemaVersion: 1, runId: run.id, tombstones: parsed.tombstones };
128
- }
129
- catch {
130
- // A malformed overlay must NOT brick the run — fail closed to an empty chain.
131
- return { schemaVersion: 1, runId: run.id, tombstones: [] };
132
- }
133
- }
134
- // ---------------------------------------------------------------------------
135
- // Skeleton extraction — the audit-essential subset that must survive.
136
- // ---------------------------------------------------------------------------
137
- function deriveTerminalLifecycle(run) {
138
- const tasks = run.tasks || [];
139
- const running = tasks.filter((t) => t.status === "running").length;
140
- const failed = tasks.filter((t) => t.status === "failed").length;
141
- const completed = tasks.filter((t) => t.status === "completed").length;
142
- const total = tasks.length;
143
- const pending = tasks.filter((t) => t.status === "pending").length;
144
- const openFeedback = (run.feedback || []).filter((f) => f.status === "open" || f.status === "tasked").length;
145
- const verifierGated = (run.commits || []).filter((c) => c.verifierGated).length;
146
- if (running > 0)
147
- return "running";
148
- if (openFeedback > 0)
149
- return "blocked";
150
- if (failed > 0)
151
- return "failed";
152
- if (total > 0 && completed === total)
153
- return "completed";
154
- if (verifierGated > 0 && pending === 0)
155
- return "completed";
156
- if (completed > 0)
157
- return "running";
158
- return "queued";
159
- }
160
- function auditEventLogPath(run) {
161
- return run.audit?.eventLogPath || node_path_1.default.join(run.paths.auditDir || node_path_1.default.join(run.paths.runDir, "audit"), "events.jsonl");
162
- }
163
- function digestEvidenceEntry(entry) {
164
- const ref = entry.locator || entry.path || entry.summary || entry.id;
165
- if (!ref)
166
- return undefined;
167
- // Prefer the file's content digest when the locator resolves to a real path.
168
- const candidatePath = entry.path || entry.locator;
169
- if (candidatePath && typeof candidatePath === "string" && !candidatePath.includes(":") && node_fs_1.default.existsSync(candidatePath)) {
170
- try {
171
- const stat = node_fs_1.default.statSync(candidatePath);
172
- if (stat.isFile())
173
- return { ref, digest: (0, hash_1.sha256OfFile)(candidatePath) };
174
- }
175
- catch {
176
- /* fall through to locator digest */
177
- }
178
- }
179
- return { ref, digest: (0, hash_1.sha256OfString)(ref) };
180
- }
181
- /** STEP 1: extract + seal the skeleton. Pure read over the run; never mutates. */
182
- function extractSkeleton(run) {
183
- const lifecycle = deriveTerminalLifecycle(run);
184
- const commits = (run.commits || []).map((commit) => ({
185
- id: commit.id,
186
- verifierGated: Boolean(commit.verifierGated),
187
- checkpoint: Boolean(commit.checkpoint),
188
- candidateId: commit.candidateId,
189
- selectionId: commit.selectionId,
190
- verifierNodeId: commit.verifierNodeId,
191
- evidenceCount: (commit.evidence || []).length,
192
- acceptanceRationale: commit.acceptanceRationale
193
- }));
194
- const evidenceSources = [];
195
- for (const node of run.nodes || [])
196
- for (const e of node.evidence || [])
197
- evidenceSources.push(e);
198
- for (const candidate of run.candidates || [])
199
- for (const e of candidate.evidence || [])
200
- evidenceSources.push(e);
201
- for (const selection of run.candidateSelections || [])
202
- for (const e of selection.evidence || [])
203
- evidenceSources.push(e);
204
- for (const commit of run.commits || [])
205
- for (const e of commit.evidence || [])
206
- evidenceSources.push(e);
207
- const evidenceMap = new Map();
208
- for (const e of evidenceSources) {
209
- const digested = digestEvidenceEntry(e);
210
- if (digested)
211
- evidenceMap.set(digested.ref, digested.digest);
212
- }
213
- const evidenceDigests = [...evidenceMap.entries()]
214
- .map(([ref, digest]) => ({ ref, digest }))
215
- .sort((a, b) => (0, compare_1.compareBytes)(a.ref, b.ref));
216
- const eventLog = auditEventLogPath(run);
217
- const auditLogDigest = node_fs_1.default.existsSync(eventLog) ? (0, hash_1.sha256OfFile)(eventLog) : (0, hash_1.sha256OfString)("");
218
- const events = node_fs_1.default.existsSync(eventLog)
219
- ? node_fs_1.default
220
- .readFileSync(eventLog, "utf8")
221
- .split(/\n/g)
222
- .map((line) => line.trim())
223
- .filter(Boolean)
224
- .map((line) => {
225
- try {
226
- const e = JSON.parse(line);
227
- return { id: e.id || "", kind: e.kind || "", decision: e.decision || "", createdAt: e.createdAt || "" };
228
- }
229
- catch {
230
- return { id: "", kind: "malformed", decision: "", createdAt: "" };
231
- }
232
- })
233
- : [];
234
- const metricsReport = node_path_1.default.join(run.paths.runDir, "metrics", "metrics-report.json");
235
- const costRecord = {
236
- tasks: (run.tasks || []).map((task) => ({ taskId: task.id, model: task.usage?.model, source: task.usage?.source })),
237
- metricsDigest: node_fs_1.default.existsSync(metricsReport) ? (0, hash_1.sha256OfFile)(metricsReport) : undefined
238
- };
239
- const collaboration = run.collaboration;
240
- const collaborationLog = {
241
- digest: (0, hash_1.sha256OfString)((0, multi_agent_eval_1.replayStableStringify)(collaboration || {})),
242
- approvals: collaboration?.approvals?.length || 0,
243
- comments: collaboration?.comments?.length || 0,
244
- handoffs: collaboration?.handoffs?.length || 0
245
- };
246
- // Empty (not a hash-of-empty) when state.json is absent, so the skeleton fails
247
- // closed — you cannot seal a run whose authoritative state is gone.
248
- const stateDigest = node_fs_1.default.existsSync(run.paths.state) ? (0, hash_1.sha256OfFile)(run.paths.state) : "";
249
- return {
250
- schemaVersion: 1,
251
- runId: run.id,
252
- finalVerdict: {
253
- lifecycle,
254
- loopStage: run.loopStage,
255
- terminal: lifecycle === "completed" || lifecycle === "failed",
256
- commitGated: (run.commits || []).some((c) => c.verifierGated)
257
- },
258
- commits,
259
- evidenceDigests,
260
- attestationChain: { auditLogDigest, eventCount: events.length, events },
261
- costRecord,
262
- auditLog: { path: node_path_1.default.relative(run.paths.runDir, eventLog), digest: auditLogDigest },
263
- collaborationLog,
264
- stateDigest
265
- };
266
- }
267
- /** Return the list of SKELETON_REQUIRED_KEYS that are missing/empty. Empty array
268
- * ⇒ schema-complete. The runId + a populated finalVerdict are load-bearing. */
269
- function validateSkeleton(skeleton) {
270
- const missing = [];
271
- if (!skeleton)
272
- return [...exports.SKELETON_REQUIRED_KEYS];
273
- for (const key of exports.SKELETON_REQUIRED_KEYS) {
274
- const value = skeleton[key];
275
- if (value === undefined || value === null) {
276
- missing.push(key);
277
- continue;
278
- }
279
- if (key === "runId" && !String(value).trim())
280
- missing.push(key);
281
- if (key === "stateDigest" && !String(value).trim())
282
- missing.push(key);
283
- if (key === "finalVerdict" && (typeof value !== "object" || !value.lifecycle))
284
- missing.push(key);
285
- if (key === "auditLog" && (typeof value !== "object" || !value.digest))
286
- missing.push(key);
287
- if (key === "attestationChain" && (typeof value !== "object" || typeof value.auditLogDigest !== "string"))
288
- missing.push(key);
289
- if (key === "commits" && !Array.isArray(value))
290
- missing.push(key);
291
- if (key === "evidenceDigests" && !Array.isArray(value))
292
- missing.push(key);
293
- }
294
- return missing;
295
- }
296
- /** P2-A content fidelity (v0.1.40): a complete-SHAPED skeleton is not enough —
297
- * reclamation must REFUSE if extraction dropped audit content the run actually
298
- * has. When the run carries commits/evidence, the sealed skeleton MUST carry
299
- * them too (extraction maps 1:1). Returns the content-loss reasons, empty when
300
- * faithful. This is the run-aware counterpart to validateSkeleton's shape check. */
301
- function validateSkeletonAgainstRun(run, skeleton) {
302
- const failures = [];
303
- const runCommits = (run.commits || []).length;
304
- if (runCommits > 0 && skeleton.commits.length !== runCommits) {
305
- failures.push(`commits-dropped(run=${runCommits},sealed=${skeleton.commits.length})`);
306
- }
307
- const runHasEvidence = (run.nodes || []).some((n) => (n.evidence || []).length) ||
308
- (run.candidates || []).some((c) => (c.evidence || []).length) ||
309
- (run.candidateSelections || []).some((s) => (s.evidence || []).length) ||
310
- (run.commits || []).some((c) => (c.evidence || []).length);
311
- if (runHasEvidence && skeleton.evidenceDigests.length === 0) {
312
- failures.push("evidence-dropped");
313
- }
314
- if (!skeleton.finalVerdict || !skeleton.finalVerdict.lifecycle)
315
- failures.push("verdict-missing");
316
- return failures;
317
- }
318
- // ---------------------------------------------------------------------------
319
- // Reference graph — the load-bearing classifier guard. A candidate/blackboard
320
- // path referenced by ANY surviving evidence locator / audit event forces
321
- // retention (fail closed). Scratch is the carved exception: its raw result.md is
322
- // referenced by the result node, but that reference is REPOINTED (not retained).
323
- // ---------------------------------------------------------------------------
324
- function buildReferenceGraph(run) {
325
- const refs = new Set();
326
- const add = (value) => {
327
- if (typeof value === "string" && value.trim())
328
- refs.add(value.trim());
329
- };
330
- for (const node of run.nodes || []) {
331
- for (const e of node.evidence || []) {
332
- add(e.locator);
333
- add(e.path);
334
- add(e.id);
335
- }
336
- }
337
- for (const candidate of run.candidates || [])
338
- for (const e of candidate.evidence || [])
339
- add(e.locator);
340
- for (const commit of run.commits || [])
341
- for (const e of commit.evidence || [])
342
- add(e.locator);
343
- for (const artifact of run.blackboard?.artifacts || []) {
344
- add(artifact.id);
345
- add(artifact.path);
346
- }
347
- for (const message of run.blackboard?.messages || [])
348
- add(message.id);
349
- return refs;
350
- }
351
- /** expectDigest of a node's deterministic projection. Re-uses the SHARED
352
- * node-projection field set (node-projection.ts) so reconstruction matches
353
- * node-snapshot.ts's body byte-for-byte — the projection can no longer drift. */
354
- function snapshotProjectionDigest(node) {
355
- return (0, hash_1.sha256OfString)((0, node_projection_1.nodeProjectionDigestInput)(node));
356
- }
357
- /** Body digest of the RETAINED node (lives in state.json). The reconstruction
358
- * verifier re-derives the projection from this retained input. Same shared field
359
- * set / canonical bytes as snapshotProjectionDigest. */
360
- function nodeBodyDigest(node) {
361
- return (0, hash_1.sha256OfString)((0, node_projection_1.nodeProjectionDigestInput)(node));
362
- }
363
- /** Build the retention plan: which paths are freeable under `policy`, of what
364
- * kind, how many bytes, and the resulting capability downgrade. */
365
- function planReclamation(run, policy = {}) {
366
- const runDir = run.paths.runDir;
367
- const freeable = [];
368
- const rel = (abs) => node_path_1.default.relative(runDir, abs);
369
- // (1) Worker scratch dirs — pure scratch with zero audit value. result.md is
370
- // already copied to results/<task>.md (evidence-gated). The whole workerDir is
371
- // freeable once the result node's worker-result artifact is re-pointed.
372
- let reclaimedScratch = false;
373
- if (!policy.keepScratch) {
374
- for (const scope of run.workers || []) {
375
- const workerDir = scope.workerDir;
376
- if (!workerDir || !node_fs_1.default.existsSync(workerDir))
377
- continue;
378
- // Only reclaim a worker whose output was accepted (result retained under results/).
379
- const task = (run.tasks || []).find((t) => t.id === scope.taskId);
380
- const resultNodeId = scope.resultNodeId || task?.resultNodeId;
381
- const resultsCopy = task?.resultPath;
382
- if (!resultNodeId || !resultsCopy || !node_fs_1.default.existsSync(resultsCopy))
383
- continue;
384
- const bytes = (0, hash_1.dirBytes)(workerDir);
385
- if (bytes <= 0)
386
- continue;
387
- freeable.push({
388
- path: rel(workerDir),
389
- absPath: workerDir,
390
- kind: "scratch",
391
- bytes,
392
- repointResultNodeId: resultNodeId
393
- });
394
- reclaimedScratch = true;
395
- }
396
- }
397
- // A node whose scratch is being re-pointed THIS pass must NOT also have its
398
- // snapshot freed in the same pass — re-pointing mutates the node body, which
399
- // would make the snapshot's reconstruction recipe mismatch. Fail closed: retain
400
- // such snapshots (a later pass, after the body settles, can reclaim them).
401
- const repointNodeIds = new Set(freeable.filter((f) => f.repointResultNodeId).map((f) => f.repointResultNodeId));
402
- // (2) Reconstructable node snapshots — deterministic projection of a RETAINED
403
- // node (state.json). Reclaim the persisted snapshot file; retain the recipe +
404
- // expectDigest so the projection re-derives without the freed bytes.
405
- let reclaimedSnapshot = false;
406
- let reconstructableSnapshot = false;
407
- if (!policy.keepSnapshots) {
408
- const nodesDir = run.paths.stateNodesDir || node_path_1.default.join(runDir, "nodes");
409
- const snapshotsRoot = node_path_1.default.join(nodesDir, "snapshots");
410
- if (node_fs_1.default.existsSync(snapshotsRoot)) {
411
- for (const nodeDirName of node_fs_1.default.readdirSync(snapshotsRoot, { withFileTypes: true })) {
412
- if (!nodeDirName.isDirectory())
413
- continue;
414
- const nodeDir = node_path_1.default.join(snapshotsRoot, nodeDirName.name);
415
- for (const file of node_fs_1.default.readdirSync(nodeDir, { withFileTypes: true })) {
416
- if (!file.isFile() || !file.name.endsWith(".json"))
417
- continue;
418
- const snapFile = node_path_1.default.join(nodeDir, file.name);
419
- let snap;
420
- try {
421
- snap = JSON.parse(node_fs_1.default.readFileSync(snapFile, "utf8"));
422
- }
423
- catch {
424
- continue; // unreadable snapshot → retain (fail closed)
425
- }
426
- if (!snap || typeof snap !== "object" || typeof snap.nodeId !== "string")
427
- continue;
428
- const nodeId = snap.nodeId;
429
- const node = (run.nodes || []).find((n) => n.id === nodeId);
430
- if (!node)
431
- continue; // source node gone → cannot reconstruct → retain
432
- if (repointNodeIds.has(node.id))
433
- continue; // body will be re-pointed → retain
434
- const bytes = (0, hash_1.dirBytes)(snapFile);
435
- if (bytes <= 0)
436
- continue;
437
- const inputDigest = nodeBodyDigest(node);
438
- const recipe = {
439
- recipeKind: "node-snapshot-projection",
440
- inputDigests: [inputDigest],
441
- inputsDigest: (0, hash_1.sha256OfString)((0, multi_agent_eval_1.replayStableStringify)([inputDigest])),
442
- expectDigest: snapshotProjectionDigest(node),
443
- sourceRef: node.id
444
- };
445
- freeable.push({ path: rel(snapFile), absPath: snapFile, kind: "reconstructable-snapshot", bytes, recipe });
446
- reclaimedSnapshot = true;
447
- reconstructableSnapshot = true;
448
- }
449
- }
450
- }
451
- }
452
- // (3 / 4) candidate + reference-free blackboard artifacts are RETAINED by
453
- // default in v0.1.39 (fail closed): a referenced blackboard digest forces
454
- // retention, and we do not yet auto-capture reconstruction recipes for them.
455
- // The reference graph is consulted so the door is closed, not merely unbuilt.
456
- void buildReferenceGraph;
457
- // Determinism (HARD constraint): the snapshot candidates above are gathered in
458
- // fs.readdirSync order, which is filesystem-dependent. freeable feeds the freed
459
- // manifest that buildTombstone binds into tombstoneHash (and the prevTombstoneHash
460
- // chain), so an unsorted order makes the tombstone hash irreproducible across
461
- // hosts. Sort by path — the same compareBytes discipline the directory reads at
462
- // :128 and the reference list at :243 already use — before anything hashes it.
463
- freeable.sort((a, b) => (0, compare_1.compareBytes)(a.path, b.path));
464
- const byKind = {};
465
- let bytesToFree = 0;
466
- for (const entry of freeable) {
467
- byKind[entry.kind] = (byKind[entry.kind] || 0) + entry.bytes;
468
- bytesToFree += entry.bytes;
469
- }
470
- // Capability projection (closed enum). Reclaiming a reconstructable snapshot →
471
- // re-runnable-by-reconstruction; a non-reconstructable snapshot → verify-only;
472
- // scratch/none → re-runnable (scratch is pure waste, replay is unaffected).
473
- let capability = "re-runnable";
474
- let capabilityReason = "scratch-only-reclaimed";
475
- if (reclaimedSnapshot && reconstructableSnapshot) {
476
- capability = "re-runnable-by-reconstruction";
477
- capabilityReason = "inputs-and-expectdigest-retained";
478
- }
479
- else if (reclaimedSnapshot) {
480
- capability = "verify-only";
481
- capabilityReason = "snapshot-reclaimed-no-reconstruction";
482
- }
483
- else if (reclaimedScratch) {
484
- capability = "re-runnable";
485
- capabilityReason = "scratch-only-reclaimed";
486
- }
487
- return { freeable, bytesToFree, byKind, capability, capabilityReason };
488
- }
489
- function policyDigestOf(policy) {
490
- return (0, hash_1.sha256OfString)((0, multi_agent_eval_1.replayStableStringify)(policy));
491
- }
492
- /** genesis prevTombstoneHash = sha256 of the sealed skeleton. */
493
- function genesisPrevHash(skeleton) {
494
- return (0, hash_1.sha256OfString)((0, multi_agent_eval_1.replayStableStringify)(skeleton));
495
- }
496
- /** The canonical bytes a tombstoneHash binds: freed-manifest + sealed skeleton +
497
- * prevTombstoneHash + capability. Recomputed independently by `gc verify`. */
498
- function tombstoneHashInput(t) {
499
- return (0, multi_agent_eval_1.replayStableStringify)({
500
- runId: t.runId,
501
- tombstoneId: t.tombstoneId,
502
- reclaimedAt: t.reclaimedAt,
503
- actor: t.actor || null,
504
- policyDigest: t.policyDigest,
505
- freed: t.freed.map((f) => ({ path: f.path, kind: f.kind, bytes: f.bytes, sha256: f.sha256, recipe: f.recipe || null })),
506
- bytesFreed: t.bytesFreed,
507
- skeletonDigest: (0, hash_1.sha256OfString)((0, multi_agent_eval_1.replayStableStringify)(t.skeleton)),
508
- capability: t.capability,
509
- capabilityReason: t.capabilityReason,
510
- prevTombstoneHash: t.prevTombstoneHash
511
- });
512
- }
513
- function computeTombstoneHash(t) {
514
- return (0, hash_1.sha256OfString)(tombstoneHashInput(t));
515
- }
516
- function tombstoneId(seq) {
517
- // Deterministic (FreeBSD-audit L13): the chain POSITION, not a process-global
518
- // counter or wall-clock stamp — tombstoneId is bound into the tombstoneHash
519
- // chain that `gc verify` recomputes, so it must be reproducible.
520
- return `tomb-${String(seq).padStart(3, "0")}`;
521
- }
522
- /** STEP 2: build the FULL tombstone (pre-deletion sha256 per freed path + the
523
- * hash chain). Reads the freed files (still present); mutates nothing on disk. */
524
- function buildTombstone(run, skeleton, plan, options = {}) {
525
- const now = options.now || new Date().toISOString();
526
- const prior = loadReclamationLog(run).tombstones;
527
- const prevTombstoneHash = prior.length ? prior[prior.length - 1].tombstoneHash : genesisPrevHash(skeleton);
528
- const freed = plan.freeable.map((entry) => ({
529
- path: entry.path,
530
- kind: entry.kind,
531
- bytes: entry.bytes,
532
- sha256: (0, hash_1.contentDigest)(entry.absPath),
533
- recipe: entry.recipe
534
- }));
535
- const base = {
536
- schemaVersion: 1,
537
- runId: run.id,
538
- tombstoneId: tombstoneId(prior.length + 1),
539
- reclaimedAt: now,
540
- actor: options.actor,
541
- policyDigest: policyDigestOf(options.policy || {}),
542
- freed,
543
- bytesFreed: freed.reduce((sum, f) => sum + f.bytes, 0),
544
- skeleton,
545
- capability: plan.capability,
546
- capabilityReason: plan.capabilityReason,
547
- prevTombstoneHash
548
- };
549
- return { ...base, tombstoneHash: computeTombstoneHash(base) };
550
- }
551
- /** STEP 3: commit the tombstone DURABLY into the append-only overlay (temp →
552
- * fsync → rename) and record the attestation through the append-only audit log.
553
- * No byte is freed here — write-ahead order is the safety property. */
554
- function commitTombstone(run, tombstone) {
555
- const log = loadReclamationLog(run);
556
- log.tombstones.push(tombstone);
557
- (0, state_1.writeJson)(reclaimedLogPath(run), log, { durable: true });
558
- try {
559
- (0, trust_audit_1.recordTrustAuditEvent)(run, {
560
- kind: "run.reclamation",
561
- decision: "recorded",
562
- source: "cw-validated",
563
- metadata: {
564
- tombstoneId: tombstone.tombstoneId,
565
- tombstoneHash: tombstone.tombstoneHash,
566
- prevTombstoneHash: tombstone.prevTombstoneHash,
567
- bytesFreed: tombstone.bytesFreed,
568
- freedPaths: tombstone.freed.length,
569
- capability: tombstone.capability,
570
- capabilityReason: tombstone.capabilityReason,
571
- actor: tombstone.actor
572
- }
573
- });
574
- }
575
- catch {
576
- // The tombstone is already durable; an audit-append hiccup must not unwind it.
577
- }
578
- }
579
- /** STEP 4 (preparation, P1-A + P1-B): re-point every surviving node's artifacts
580
- * off the scratch paths about to vanish, DURABLY persist that state.json change,
581
- * and PROVE no surviving node still references a freed path (and that each
582
- * re-pointed result node's snapshot stays `valid`) — BEFORE a single byte is
583
- * freed. Fail closed (`repoint-incomplete`) if the proof does not hold, so a
584
- * crash can never leave state.json pointing at a freed path. */
585
- function prepareFree(run, tombstone) {
586
- const runDir = run.paths.runDir;
587
- // Symlink-hardened (v0.1.40 self-audit P1): realResolve follows symlinks so the
588
- // containment proofs below cannot be bypassed by an artifact symlinked across
589
- // the freed/retained boundary.
590
- const scratchDirs = tombstone.freed.filter((f) => f.kind === "scratch").map((f) => (0, state_1.realResolve)(node_path_1.default.join(runDir, f.path)));
591
- if (!scratchDirs.length)
592
- return; // nothing references a freed path; no state change needed.
593
- const repointed = new Set();
594
- for (const scratchDir of scratchDirs) {
595
- for (const id of repointResultNodeArtifacts(run, scratchDir))
596
- repointed.add(id);
597
- }
598
- // Durably persist the re-point so it survives a crash BEFORE the free runs.
599
- persistRunDurable(run);
600
- // PROOF 1: no surviving node artifact may resolve inside any freed scratch dir.
601
- for (const node of run.nodes || []) {
602
- for (const artifact of node.artifacts || []) {
603
- if (!artifact.path)
604
- continue;
605
- const resolved = (0, state_1.realResolve)(artifact.path);
606
- for (const scratchDir of scratchDirs) {
607
- if (resolved === scratchDir || resolved.startsWith(scratchDir + node_path_1.default.sep)) {
608
- throw new ReclamationError("repoint-incomplete", `node ${node.id} artifact ${artifact.id} still references freed scratch path ${artifact.path}`, {
609
- nodeId: node.id,
610
- artifactId: artifact.id,
611
- path: artifact.path
612
- });
613
- }
614
- }
615
- }
616
- }
617
- // PROOF 2: each re-pointed result node's snapshot stays `valid` (not `absent`).
618
- for (const nodeId of repointed) {
619
- try {
620
- const fresh = (0, node_snapshot_1.snapshotNode)(run, nodeId, { persist: false });
621
- const { freshness } = (0, node_snapshot_1.loadNodeSnapshot)(run, fresh);
622
- if (freshness === "absent") {
623
- throw new ReclamationError("repoint-incomplete", `re-pointed node ${nodeId} snapshot is absent (dangling artifact)`, { nodeId });
624
- }
625
- }
626
- catch (error) {
627
- if (error instanceof ReclamationError)
628
- throw error;
629
- throw new ReclamationError("repoint-incomplete", `could not prove re-pointed node ${nodeId} stays valid: ${error.message}`, { nodeId });
630
- }
631
- }
632
- }
633
- /** STEP 5: free the bulk DATA bytes. Pure deletion — every re-point is already
634
- * done and DURABLY persisted by prepareFree(), so a crash here can never leave a
635
- * surviving node referencing a freed path. */
636
- function freeBulk(run, tombstone) {
637
- const runDir = run.paths.runDir;
638
- let freedBytes = 0;
639
- for (const entry of tombstone.freed) {
640
- const abs = node_path_1.default.join(runDir, entry.path);
641
- const before = (0, hash_1.dirBytes)(abs);
642
- node_fs_1.default.rmSync(abs, { recursive: true, force: true });
643
- freedBytes += before;
644
- }
645
- return freedBytes;
646
- }
647
- /** Re-point a node's artifacts off `freedScratchDir` to the retained `result`
648
- * copy. Returns the ids of nodes actually changed (for the validity proof). */
649
- function repointResultNodeArtifacts(run, freedScratchDir) {
650
- const freedReal = (0, state_1.realResolve)(freedScratchDir);
651
- const freedPrefix = freedReal + node_path_1.default.sep;
652
- const changedIds = [];
653
- for (const node of run.nodes || []) {
654
- if (!node.artifacts)
655
- continue;
656
- let changed = false;
657
- for (const artifact of node.artifacts) {
658
- if (!artifact.path)
659
- continue;
660
- const resolved = (0, state_1.realResolve)(artifact.path);
661
- if (resolved === freedReal || resolved.startsWith(freedPrefix)) {
662
- // Re-point to the retained results/<task>.md copy (the `result` artifact).
663
- const retained = node.artifacts.find((a) => a.id === "result" && a.path && node_fs_1.default.existsSync(a.path));
664
- if (retained && retained.path) {
665
- artifact.path = retained.path;
666
- changed = true;
667
- }
668
- }
669
- }
670
- if (changed) {
671
- node.updatedAt = new Date().toISOString();
672
- changedIds.push(node.id);
673
- }
674
- }
675
- return changedIds;
676
- }
677
- /** Execute the write-ahead, fail-closed reclamation transaction. Ordering is the
678
- * safety property: extract+seal skeleton → [under the per-run lock: build
679
- * tombstone → commit (fsync)] → re-point + DURABLY persist state + prove no
680
- * dangling reference → free bulk. The lock (P1-C) makes the chain read-modify-
681
- * write atomic so a concurrent reclaimer can never lose a tombstone. The durable
682
- * re-point BEFORE free (P1-A) means a crash can never leave state.json pointing
683
- * at a freed path. `faultAfter` aborts after the named step so crash-safety is
684
- * testable by design — a crash leaves EITHER the full run OR a complete
685
- * tombstone, never a half-deleted run with no proof. */
686
- function runReclamation(run, options = {}) {
687
- // STEP 1 — extract + seal skeleton. Fail closed if incomplete (free nothing).
688
- const skeleton = extractSkeleton(run);
689
- const missing = validateSkeleton(skeleton);
690
- if (missing.length) {
691
- throw new ReclamationError("skeleton-incomplete", `Skeleton missing required keys: ${missing.join(", ")}`, { missing });
692
- }
693
- // P2-A: also refuse if extraction dropped audit content the run actually has.
694
- const contentLoss = validateSkeletonAgainstRun(run, skeleton);
695
- if (contentLoss.length) {
696
- throw new ReclamationError("skeleton-incomplete", `Skeleton dropped audit content: ${contentLoss.join(", ")}`, { contentLoss });
697
- }
698
- if (options.faultAfter === "skeleton")
699
- throw new ReclamationAbort("skeleton");
700
- // STEPS 2-3 — under the per-run lock so the chain's read (prevTombstoneHash) and
701
- // append are atomic: build the full tombstone (pre-deletion sha256 + chain) and
702
- // commit it durably (fsync) into the append-only overlay.
703
- const { plan, tombstone } = withRunLock(run, () => {
704
- const builtPlan = planReclamation(run, options.reclaimPolicy || {});
705
- const builtTombstone = buildTombstone(run, skeleton, builtPlan, { now: options.now, actor: options.actor, policy: options.policy });
706
- if (options.faultAfter === "tombstone-write")
707
- throw new ReclamationAbort("tombstone-write");
708
- commitTombstone(run, builtTombstone);
709
- return { plan: builtPlan, tombstone: builtTombstone };
710
- });
711
- if (options.faultAfter === "tombstone-commit")
712
- throw new ReclamationAbort("tombstone-commit");
713
- // STEP 4 — re-point surviving nodes off the scratch, DURABLY persist that
714
- // state change, and PROVE no node references a freed path — all before freeing.
715
- prepareFree(run, tombstone);
716
- // STEP 5 — ONLY NOW free the bulk bytes.
717
- const bytesFreed = freeBulk(run, tombstone);
718
- return { tombstone, bytesFreed, plan };
719
- }
720
- // ---------------------------------------------------------------------------
721
- // Reconstruction — re-derive a freed artifact from its RETAINED inputs, NEVER
722
- // the freed source bytes. Distinct code path from live verifyNodeReplay.
723
- // ---------------------------------------------------------------------------
724
- /** Re-derive a reconstructable artifact's expectDigest from the retained run.
725
- * Returns the recomputed digest (to compare to recipe.expectDigest). */
726
- function reconstructArtifact(run, recipe) {
727
- if (recipe.recipeKind === "node-snapshot-projection") {
728
- const node = (run.nodes || []).find((n) => n.id === recipe.sourceRef);
729
- if (!node) {
730
- return { inputsDigest: (0, hash_1.sha256OfString)("absent"), expectDigest: (0, hash_1.sha256OfString)("absent") };
731
- }
732
- const inputDigest = nodeBodyDigest(node);
733
- const inputsDigest = (0, hash_1.sha256OfString)((0, multi_agent_eval_1.replayStableStringify)([inputDigest]));
734
- const expectDigest = snapshotProjectionDigest(node);
735
- return { inputsDigest, expectDigest };
736
- }
737
- // Unknown recipe kind → fail closed (digest can't match expectDigest).
738
- return { inputsDigest: (0, hash_1.sha256OfString)("unknown-recipe"), expectDigest: (0, hash_1.sha256OfString)("unknown-recipe") };
739
- }
740
- /** Re-prove the whole reclamation chain for a run: skeleton schema-complete,
741
- * tombstoneHash/prevTombstoneHash chain recomputed-and-untampered, and each
742
- * reconstructable artifact re-derived from RETAINED inputs to its expectDigest.
743
- * Recomputes every hash independently — never trusts the stored value. */
744
- function verifyReclamation(run) {
745
- const log = loadReclamationLog(run);
746
- const tombstones = log.tombstones;
747
- const checks = [];
748
- if (!tombstones.length) {
749
- return { reclaimed: false, verified: false, checks: [{ name: "reclaimed", pass: false, code: "not-reclaimed" }], tombstones };
750
- }
751
- // (a) chain linkage FIRST (priority): genesis = sha256 of the (first) skeleton.
752
- let chainOk = true;
753
- for (let i = 0; i < tombstones.length; i++) {
754
- const expectedPrev = i === 0 ? genesisPrevHash(tombstones[0].skeleton) : tombstones[i - 1].tombstoneHash;
755
- const pass = tombstones[i].prevTombstoneHash === expectedPrev;
756
- if (!pass)
757
- chainOk = false;
758
- checks.push({ name: `chain-link[${i}]`, pass, code: pass ? undefined : "tombstone-chain-broken" });
759
- }
760
- // (b) per-tombstone independent hash recompute (digest integrity).
761
- let digestsOk = true;
762
- for (let i = 0; i < tombstones.length; i++) {
763
- const { tombstoneHash, ...rest } = tombstones[i];
764
- const recomputed = computeTombstoneHash(rest);
765
- const pass = recomputed === tombstoneHash;
766
- if (!pass)
767
- digestsOk = false;
768
- checks.push({ name: `tombstone-hash[${i}]`, pass, code: pass ? undefined : "tombstone-digest-mismatch" });
769
- }
770
- // (c) skeleton schema completeness (each tombstone seals a complete skeleton).
771
- let skeletonOk = true;
772
- for (let i = 0; i < tombstones.length; i++) {
773
- const missing = validateSkeleton(tombstones[i].skeleton);
774
- const pass = missing.length === 0;
775
- if (!pass)
776
- skeletonOk = false;
777
- checks.push({ name: `skeleton[${i}]`, pass, code: pass ? undefined : "skeleton-incomplete", detail: missing.join(",") || undefined });
778
- }
779
- // (d) reconstruction — re-derive each reconstructable artifact from RETAINED
780
- // inputs (NOT the freed source) to its expectDigest.
781
- let reconstructionOk = true;
782
- for (let i = 0; i < tombstones.length; i++) {
783
- for (const entry of tombstones[i].freed) {
784
- if (!entry.recipe)
785
- continue;
786
- const recomputed = reconstructArtifact(run, entry.recipe);
787
- const inputsMatch = recomputed.inputsDigest === entry.recipe.inputsDigest;
788
- const expectMatch = recomputed.expectDigest === entry.recipe.expectDigest;
789
- const pass = inputsMatch && expectMatch;
790
- if (!pass)
791
- reconstructionOk = false;
792
- checks.push({
793
- name: `reconstruct[${i}]:${entry.path}`,
794
- pass,
795
- code: pass ? undefined : "reconstruction-digest-mismatch",
796
- detail: pass ? undefined : `inputs=${inputsMatch} expect=${expectMatch}`
797
- });
798
- }
799
- }
800
- const verified = chainOk && digestsOk && skeletonOk && reconstructionOk;
801
- return { reclaimed: true, verified, checks, tombstones };
802
- }
803
- /** Pick the priority failure code from a check list (chain > digest >
804
- * reconstruction > skeleton). Used to surface the single dominant code. */
805
- function dominantFailureCode(checks) {
806
- const order = ["tombstone-chain-broken", "tombstone-digest-mismatch", "reconstruction-digest-mismatch", "skeleton-incomplete", "not-reclaimed"];
807
- for (const code of order) {
808
- if (checks.some((c) => !c.pass && c.code === code))
809
- return code;
810
- }
811
- return undefined;
812
- }