cool-workflow 0.1.97 → 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 (309) 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} +12 -5
  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 +4 -0
  139. package/docs/cli-mcp-parity.7.md +284 -211
  140. package/docs/contract-migration-tooling.7.md +4 -0
  141. package/docs/control-plane-scheduling.7.md +4 -0
  142. package/docs/cross-agent-ledger.7.md +217 -0
  143. package/docs/designs/handoff-ledger.md +145 -0
  144. package/docs/durable-state-and-locking.7.md +4 -0
  145. package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
  146. package/docs/execution-backends.7.md +4 -0
  147. package/docs/handoff-setup.md +120 -0
  148. package/docs/multi-agent-cli-mcp-surface.7.md +4 -0
  149. package/docs/multi-agent-eval-replay-harness.7.md +4 -0
  150. package/docs/multi-agent-operator-ux.7.md +4 -0
  151. package/docs/node-snapshot-diff-replay.7.md +4 -0
  152. package/docs/observability-cost-accounting.7.md +4 -0
  153. package/docs/project-index.md +143 -71
  154. package/docs/real-execution-backends.7.md +4 -0
  155. package/docs/release-and-migration.7.md +4 -0
  156. package/docs/release-tooling.7.md +4 -0
  157. package/docs/run-registry-control-plane.7.md +4 -0
  158. package/docs/run-retention-reclamation.7.md +25 -0
  159. package/docs/state-explosion-management.7.md +4 -0
  160. package/docs/team-collaboration.7.md +4 -0
  161. package/docs/web-desktop-workbench.7.md +4 -0
  162. package/manifest/plugin.manifest.json +1 -1
  163. package/manifest/source-context-profiles.json +9 -13
  164. package/package.json +1 -1
  165. package/scripts/agents/codex-agent.js +34 -4
  166. package/scripts/agents/cw-attest-wrap.js +2 -2
  167. package/scripts/bump-version.js +4 -3
  168. package/scripts/canonical-apps.js +4 -4
  169. package/scripts/children/batch-delegate-child.js +30 -10
  170. package/scripts/dogfood-architecture-review.js +2 -3
  171. package/scripts/dogfood-release.js +3 -3
  172. package/scripts/gen-parity-doc.js +15 -2
  173. package/scripts/golden-path.js +4 -4
  174. package/scripts/onramp-check.js +1 -1
  175. package/scripts/parity-check.js +38 -21
  176. package/scripts/release-flow.js +9 -3
  177. package/scripts/sync-project-index.js +51 -27
  178. package/scripts/validate-run-state-schema.js +2 -2
  179. package/scripts/version-sync-check.js +30 -30
  180. package/dist/candidate-scoring.js +0 -729
  181. package/dist/capability-core.js +0 -1186
  182. package/dist/capability-registry.js +0 -879
  183. package/dist/cli/command-surface.js +0 -490
  184. package/dist/cli/format.js +0 -56
  185. package/dist/cli/handlers/audit.js +0 -82
  186. package/dist/cli/handlers/blackboard.js +0 -81
  187. package/dist/cli/handlers/candidate.js +0 -40
  188. package/dist/cli/handlers/clones.js +0 -34
  189. package/dist/cli/handlers/collaboration.js +0 -61
  190. package/dist/cli/handlers/eval.js +0 -40
  191. package/dist/cli/handlers/maintenance.js +0 -107
  192. package/dist/cli/handlers/multi-agent.js +0 -165
  193. package/dist/cli/handlers/node.js +0 -41
  194. package/dist/cli/handlers/operational.js +0 -155
  195. package/dist/cli/handlers/operator.js +0 -146
  196. package/dist/cli/handlers/registry.js +0 -68
  197. package/dist/cli/handlers/run.js +0 -153
  198. package/dist/cli/handlers/scheduling.js +0 -132
  199. package/dist/cli/handlers/workbench.js +0 -41
  200. package/dist/cli/handlers/worker.js +0 -45
  201. package/dist/cli/run-summary.js +0 -45
  202. package/dist/clones.js +0 -162
  203. package/dist/collaboration.js +0 -726
  204. package/dist/commit.js +0 -592
  205. package/dist/compare.js +0 -18
  206. package/dist/coordinator/classify.js +0 -45
  207. package/dist/coordinator/paths.js +0 -42
  208. package/dist/coordinator/util.js +0 -126
  209. package/dist/coordinator.js +0 -990
  210. package/dist/daemon.js +0 -44
  211. package/dist/dispatch.js +0 -250
  212. package/dist/drive.js +0 -827
  213. package/dist/error-feedback.js +0 -419
  214. package/dist/evidence-grounding.js +0 -184
  215. package/dist/execution-backend/agent.js +0 -294
  216. package/dist/execution-backend/probes.js +0 -112
  217. package/dist/execution-backend/util.js +0 -47
  218. package/dist/execution-backend.js +0 -961
  219. package/dist/gates.js +0 -48
  220. package/dist/harness.js +0 -61
  221. package/dist/loop-expansion.js +0 -60
  222. package/dist/mcp/tool-call.js +0 -434
  223. package/dist/mcp/tool-definitions.js +0 -1040
  224. package/dist/mcp-surface.js +0 -30
  225. package/dist/multi-agent/graph.js +0 -84
  226. package/dist/multi-agent/helpers.js +0 -141
  227. package/dist/multi-agent/ids.js +0 -20
  228. package/dist/multi-agent/paths.js +0 -22
  229. package/dist/multi-agent-eval/normalize.js +0 -51
  230. package/dist/multi-agent-eval.js +0 -678
  231. package/dist/multi-agent-host.js +0 -777
  232. package/dist/multi-agent.js +0 -984
  233. package/dist/node-projection.js +0 -59
  234. package/dist/node-snapshot.js +0 -260
  235. package/dist/operator-ux.js +0 -631
  236. package/dist/orchestrator/app-operations.js +0 -211
  237. package/dist/orchestrator/audit-operations.js +0 -182
  238. package/dist/orchestrator/candidate-operations.js +0 -117
  239. package/dist/orchestrator/cli-options.js +0 -294
  240. package/dist/orchestrator/collaboration-operations.js +0 -86
  241. package/dist/orchestrator/feedback-operations.js +0 -81
  242. package/dist/orchestrator/host-operations.js +0 -78
  243. package/dist/orchestrator/lifecycle-operations.js +0 -626
  244. package/dist/orchestrator/migration-operations.js +0 -44
  245. package/dist/orchestrator/multi-agent-operations.js +0 -362
  246. package/dist/orchestrator/topology-operations.js +0 -84
  247. package/dist/orchestrator.js +0 -917
  248. package/dist/pipeline-runner.js +0 -285
  249. package/dist/reclamation/hash.js +0 -72
  250. package/dist/reclamation.js +0 -812
  251. package/dist/reporter.js +0 -67
  252. package/dist/run-export.js +0 -784
  253. package/dist/run-registry/derive.js +0 -175
  254. package/dist/run-registry/format.js +0 -124
  255. package/dist/run-registry/gc.js +0 -251
  256. package/dist/run-registry/policy.js +0 -16
  257. package/dist/run-registry/queue.js +0 -115
  258. package/dist/run-registry.js +0 -850
  259. package/dist/run-state-schema.js +0 -68
  260. package/dist/scheduling.js +0 -184
  261. package/dist/state-explosion.js +0 -1014
  262. package/dist/state.js +0 -367
  263. package/dist/telemetry-ledger.js +0 -196
  264. package/dist/topology.js +0 -565
  265. package/dist/triggers.js +0 -184
  266. package/dist/trust-audit.js +0 -644
  267. package/dist/types/blackboard.js +0 -2
  268. package/dist/types/candidate.js +0 -2
  269. package/dist/types/collaboration.js +0 -2
  270. package/dist/types/core.js +0 -2
  271. package/dist/types/drive.js +0 -10
  272. package/dist/types/error-feedback.js +0 -2
  273. package/dist/types/evidence-reasoning.js +0 -2
  274. package/dist/types/execution-backend.js +0 -2
  275. package/dist/types/multi-agent.js +0 -2
  276. package/dist/types/observability.js +0 -2
  277. package/dist/types/pipeline.js +0 -2
  278. package/dist/types/reclamation.js +0 -8
  279. package/dist/types/report-bundle.js +0 -6
  280. package/dist/types/result.js +0 -2
  281. package/dist/types/run-registry.js +0 -2
  282. package/dist/types/run.js +0 -2
  283. package/dist/types/sandbox.js +0 -2
  284. package/dist/types/schedule.js +0 -2
  285. package/dist/types/state-node.js +0 -2
  286. package/dist/types/topology.js +0 -2
  287. package/dist/types/trust.js +0 -2
  288. package/dist/types/workbench.js +0 -2
  289. package/dist/types/worker.js +0 -2
  290. package/dist/types/workflow-app.js +0 -2
  291. package/dist/types.js +0 -44
  292. package/dist/util/fingerprint.js +0 -19
  293. package/dist/util/fingerprint.test.js +0 -27
  294. package/dist/verifier.js +0 -78
  295. package/dist/version.js +0 -8
  296. package/dist/workbench-host.js +0 -182
  297. package/dist/workbench.js +0 -192
  298. package/dist/worker-accept/acceptance.js +0 -114
  299. package/dist/worker-accept/blackboard-fanout.js +0 -80
  300. package/dist/worker-accept/blackboard-linkage.js +0 -19
  301. package/dist/worker-accept/context.js +0 -2
  302. package/dist/worker-accept/telemetry-ledger.js +0 -126
  303. package/dist/worker-accept/validation.js +0 -77
  304. package/dist/worker-accept/verifier-completion.js +0 -73
  305. package/dist/worker-isolation/helpers.js +0 -51
  306. package/dist/worker-isolation/paths.js +0 -46
  307. package/dist/worker-isolation.js +0 -656
  308. package/dist/workflow-api.js +0 -131
  309. /package/dist/{types → core/types}/boundary.js +0 -0
@@ -0,0 +1,1366 @@
1
+ "use strict";
2
+ // shell/reclamation-io.ts — gc plan/run/verify's write-ahead reclamation
3
+ // transaction, plus the orphan-run sweep and the clone cache gc.
4
+ //
5
+ // MILESTONE 10 (v2/PLAN.md build order, step 10). Byte-exact port of the
6
+ // old build's src/reclamation.ts + src/reclamation/hash.ts +
7
+ // src/run-registry/{gc,orphans}.ts + src/clones.ts. Reuses
8
+ // shell/fs-atomic.ts's `withFileLock` directly (no reimplementation, per
9
+ // the task's instruction) and core/state/node-projection.ts's
10
+ // `replayStableStringify`/`nodeProjectionDigestInput` so the tombstone
11
+ // hash-chain input shares the exact same canonical bytes as node-
12
+ // snapshot.ts (never re-derived).
13
+ //
14
+ // WRITE-AHEAD ORDER (the safety property; SPEC/scheduling-registry.md
15
+ // section E, "Rebuild risks" #2): extractSkeleton -> validateSkeleton +
16
+ // validateSkeletonAgainstRun (refuse skeleton-incomplete) -> under the
17
+ // per-run lock: planReclamation + buildTombstone + commitTombstone
18
+ // (durable fsync) -> prepareFree (re-point node artifacts off scratch,
19
+ // persist state.json durably, prove no dangling reference; refuse
20
+ // repoint-incomplete) -> freeBulk. A crash between any two steps leaves
21
+ // EITHER the full run OR a complete tombstone, never half-deleted.
22
+ //
23
+ // tombstoneHash reproducibility: `freeable` MUST be sorted by path bytes
24
+ // BEFORE it is hashed (this is exactly what the tombstonesort-*.case.js
25
+ // conformance cases pin) — see `planReclamation`'s explicit sort below.
26
+ //
27
+ // Evidence: SPEC/scheduling-registry.md sections E, F, G;
28
+ // plugins/cool-workflow/src/reclamation.ts, src/reclamation/hash.ts,
29
+ // src/run-registry/{gc,orphans}.ts, src/clones.ts (byte-exact source).
30
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
31
+ if (k2 === undefined) k2 = k;
32
+ var desc = Object.getOwnPropertyDescriptor(m, k);
33
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
34
+ desc = { enumerable: true, get: function() { return m[k]; } };
35
+ }
36
+ Object.defineProperty(o, k2, desc);
37
+ }) : (function(o, m, k, k2) {
38
+ if (k2 === undefined) k2 = k;
39
+ o[k2] = m[k];
40
+ }));
41
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
42
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
43
+ }) : function(o, v) {
44
+ o["default"] = v;
45
+ });
46
+ var __importStar = (this && this.__importStar) || (function () {
47
+ var ownKeys = function(o) {
48
+ ownKeys = Object.getOwnPropertyNames || function (o) {
49
+ var ar = [];
50
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
51
+ return ar;
52
+ };
53
+ return ownKeys(o);
54
+ };
55
+ return function (mod) {
56
+ if (mod && mod.__esModule) return mod;
57
+ var result = {};
58
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
59
+ __setModuleDefault(result, mod);
60
+ return result;
61
+ };
62
+ })();
63
+ Object.defineProperty(exports, "__esModule", { value: true });
64
+ exports.DEFAULT_ORPHAN_MIN_AGE_MINUTES = exports.SKELETON_REQUIRED_KEYS = exports.ReclamationAbort = exports.ReclamationError = void 0;
65
+ exports.sha256OfString = sha256OfString;
66
+ exports.sha256OfFile = sha256OfFile;
67
+ exports.dirBytes = dirBytes;
68
+ exports.contentDigest = contentDigest;
69
+ exports.reclaimedLogPath = reclaimedLogPath;
70
+ exports.loadReclamationLog = loadReclamationLog;
71
+ exports.extractSkeleton = extractSkeleton;
72
+ exports.validateSkeleton = validateSkeleton;
73
+ exports.validateSkeletonAgainstRun = validateSkeletonAgainstRun;
74
+ exports.planReclamation = planReclamation;
75
+ exports.genesisPrevHash = genesisPrevHash;
76
+ exports.computeTombstoneHash = computeTombstoneHash;
77
+ exports.buildTombstone = buildTombstone;
78
+ exports.commitTombstone = commitTombstone;
79
+ exports.prepareFree = prepareFree;
80
+ exports.freeBulk = freeBulk;
81
+ exports.runReclamation = runReclamation;
82
+ exports.reconstructArtifact = reconstructArtifact;
83
+ exports.verifyReclamation = verifyReclamation;
84
+ exports.reclamationPolicy = reclamationPolicy;
85
+ exports.reclaimEligibility = reclaimEligibility;
86
+ exports.gcPlan = gcPlan;
87
+ exports.gcRun = gcRun;
88
+ exports.gcVerify = gcVerify;
89
+ exports.formatGcPlan = formatGcPlan;
90
+ exports.formatGcRun = formatGcRun;
91
+ exports.formatGcVerify = formatGcVerify;
92
+ exports.listOrphanRuns = listOrphanRuns;
93
+ exports.gcOrphanRuns = gcOrphanRuns;
94
+ exports.formatOrphanRunsList = formatOrphanRunsList;
95
+ exports.formatOrphanRunsGc = formatOrphanRunsGc;
96
+ exports.listClones = listClones;
97
+ exports.gcClones = gcClones;
98
+ exports.formatClonesList = formatClonesList;
99
+ exports.formatClonesGc = formatClonesGc;
100
+ const fs = __importStar(require("node:fs"));
101
+ const os = __importStar(require("node:os"));
102
+ const path = __importStar(require("node:path"));
103
+ const fs_atomic_1 = require("./fs-atomic");
104
+ const trust_audit_1 = require("./trust-audit");
105
+ const node_store_1 = require("./node-store");
106
+ const node_snapshot_1 = require("../core/state/node-snapshot");
107
+ const node_projection_1 = require("../core/state/node-projection");
108
+ const hash_1 = require("../core/hash");
109
+ const run_registry_io_1 = require("./run-registry-io");
110
+ // ---------------------------------------------------------------------------
111
+ // Content addressing + byte measurement (in-process, no `du`) — carried
112
+ // forward from src/reclamation/hash.ts.
113
+ // ---------------------------------------------------------------------------
114
+ function sha256OfString(value) {
115
+ return (0, hash_1.sha256)(value);
116
+ }
117
+ function sha256OfFile(file) {
118
+ return `sha256:${(0, hash_1.sha256Bytes)(fs.readFileSync(file))}`;
119
+ }
120
+ /** Walk a path and sum file sizes IN-PROCESS. Returns 0 if absent. */
121
+ function dirBytes(p) {
122
+ let stat;
123
+ try {
124
+ stat = fs.statSync(p);
125
+ }
126
+ catch {
127
+ return 0;
128
+ }
129
+ if (stat.isFile())
130
+ return stat.size;
131
+ if (!stat.isDirectory())
132
+ return 0;
133
+ let total = 0;
134
+ for (const entry of fs.readdirSync(p, { withFileTypes: true })) {
135
+ total += dirBytes(path.join(p, entry.name));
136
+ }
137
+ return total;
138
+ }
139
+ /** Stable content digest of a path (file = its bytes; dir = digest over
140
+ * each member's relative path + bytes, sorted). */
141
+ function contentDigest(p) {
142
+ const stat = fs.statSync(p);
143
+ if (stat.isFile())
144
+ return sha256OfFile(p);
145
+ const parts = [];
146
+ const walk = (dir, rel) => {
147
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => (0, run_registry_io_1.compareBytes)(a.name, b.name))) {
148
+ const abs = path.join(dir, entry.name);
149
+ const r = path.join(rel, entry.name);
150
+ if (entry.isDirectory())
151
+ walk(abs, r);
152
+ else
153
+ parts.push(`${r}:${sha256OfFile(abs)}`);
154
+ }
155
+ };
156
+ walk(p, "");
157
+ return sha256OfString(parts.join("\n"));
158
+ }
159
+ /** Fail-closed refusal: a real reason reclamation froze nothing. */
160
+ class ReclamationError extends Error {
161
+ code;
162
+ details;
163
+ constructor(code, message, details) {
164
+ super(message);
165
+ this.name = "ReclamationError";
166
+ this.code = code;
167
+ this.details = details;
168
+ }
169
+ }
170
+ exports.ReclamationError = ReclamationError;
171
+ /** Synthetic abort thrown by runReclamation({ faultAfter }) — a TESTABLE
172
+ * crash injection that never kills the process. */
173
+ class ReclamationAbort extends Error {
174
+ step;
175
+ constructor(step) {
176
+ super(`ReclamationAbort after step: ${step}`);
177
+ this.name = "ReclamationAbort";
178
+ this.step = step;
179
+ }
180
+ }
181
+ exports.ReclamationAbort = ReclamationAbort;
182
+ exports.SKELETON_REQUIRED_KEYS = [
183
+ "runId",
184
+ "finalVerdict",
185
+ "commits",
186
+ "evidenceDigests",
187
+ "attestationChain",
188
+ "costRecord",
189
+ "auditLog",
190
+ "collaborationLog",
191
+ "stateDigest",
192
+ ];
193
+ function persistRunDurable(run) {
194
+ run.updatedAt = new Date().toISOString();
195
+ (0, fs_atomic_1.writeJson)(run.paths.state, run, { durable: true });
196
+ }
197
+ function withRunLock(run, fn) {
198
+ return (0, fs_atomic_1.withFileLock)(reclaimedLogPath(run), fn);
199
+ }
200
+ function reclaimedLogPath(run) {
201
+ return path.join(run.paths.runDir, "reclaimed.json");
202
+ }
203
+ /** Fail-OPEN on absence/corruption: a malformed overlay must never brick
204
+ * the run (SPEC/scheduling-registry.md "Rebuild risks" #1). */
205
+ function loadReclamationLog(run) {
206
+ const file = reclaimedLogPath(run);
207
+ if (!fs.existsSync(file))
208
+ return { schemaVersion: 1, runId: run.id, tombstones: [] };
209
+ try {
210
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
211
+ if (!parsed || typeof parsed !== "object" || parsed.schemaVersion !== 1 || !Array.isArray(parsed.tombstones)) {
212
+ return { schemaVersion: 1, runId: run.id, tombstones: [] };
213
+ }
214
+ return { schemaVersion: 1, runId: run.id, tombstones: parsed.tombstones };
215
+ }
216
+ catch {
217
+ return { schemaVersion: 1, runId: run.id, tombstones: [] };
218
+ }
219
+ }
220
+ // ---------------------------------------------------------------------------
221
+ // Skeleton extraction
222
+ // ---------------------------------------------------------------------------
223
+ function deriveTerminalLifecycle(run) {
224
+ const tasks = run.tasks || [];
225
+ const running = tasks.filter((t) => t.status === "running").length;
226
+ const failed = tasks.filter((t) => t.status === "failed").length;
227
+ const completed = tasks.filter((t) => t.status === "completed").length;
228
+ const total = tasks.length;
229
+ const pending = tasks.filter((t) => t.status === "pending").length;
230
+ const openFeedback = (run.feedback || []).filter((f) => f.status === "open" || f.status === "tasked").length;
231
+ const verifierGated = (run.commits || []).filter((c) => c.verifierGated).length;
232
+ if (running > 0)
233
+ return "running";
234
+ if (openFeedback > 0)
235
+ return "blocked";
236
+ if (failed > 0)
237
+ return "failed";
238
+ if (total > 0 && completed === total)
239
+ return "completed";
240
+ if (verifierGated > 0 && pending === 0)
241
+ return "completed";
242
+ if (completed > 0)
243
+ return "running";
244
+ return "queued";
245
+ }
246
+ function auditEventLogPath(run) {
247
+ return run.audit?.eventLogPath || path.join(run.paths.auditDir || path.join(run.paths.runDir, "audit"), "events.jsonl");
248
+ }
249
+ function digestEvidenceEntry(entry) {
250
+ const ref = entry.locator || entry.path || entry.summary || entry.id;
251
+ if (!ref)
252
+ return undefined;
253
+ const candidatePath = entry.path || entry.locator;
254
+ if (candidatePath && typeof candidatePath === "string" && !candidatePath.includes(":") && fs.existsSync(candidatePath)) {
255
+ try {
256
+ const stat = fs.statSync(candidatePath);
257
+ if (stat.isFile())
258
+ return { ref, digest: sha256OfFile(candidatePath) };
259
+ }
260
+ catch {
261
+ /* fall through to locator digest */
262
+ }
263
+ }
264
+ return { ref, digest: sha256OfString(ref) };
265
+ }
266
+ /** STEP 1: extract + seal the skeleton. Pure read over the run; never
267
+ * mutates. */
268
+ function extractSkeleton(run) {
269
+ const lifecycle = deriveTerminalLifecycle(run);
270
+ const commits = (run.commits || []).map((commit) => ({
271
+ id: commit.id,
272
+ verifierGated: Boolean(commit.verifierGated),
273
+ checkpoint: Boolean(commit.checkpoint),
274
+ candidateId: commit.candidateId,
275
+ selectionId: commit.selectionId,
276
+ verifierNodeId: commit.verifierNodeId,
277
+ evidenceCount: (commit.evidence || []).length,
278
+ acceptanceRationale: commit.acceptanceRationale,
279
+ }));
280
+ const evidenceSources = [];
281
+ for (const node of run.nodes || [])
282
+ for (const e of node.evidence || [])
283
+ evidenceSources.push(e);
284
+ for (const candidate of run.candidates || [])
285
+ for (const e of candidate.evidence || [])
286
+ evidenceSources.push(e);
287
+ for (const selection of run.candidateSelections || [])
288
+ for (const e of selection.evidence || [])
289
+ evidenceSources.push(e);
290
+ for (const commit of run.commits || [])
291
+ for (const e of commit.evidence || [])
292
+ evidenceSources.push(e);
293
+ const evidenceMap = new Map();
294
+ for (const e of evidenceSources) {
295
+ const digested = digestEvidenceEntry(e);
296
+ if (digested)
297
+ evidenceMap.set(digested.ref, digested.digest);
298
+ }
299
+ const evidenceDigests = [...evidenceMap.entries()].map(([ref, digest]) => ({ ref, digest })).sort((a, b) => (0, run_registry_io_1.compareBytes)(a.ref, b.ref));
300
+ const eventLog = auditEventLogPath(run);
301
+ const auditLogDigest = fs.existsSync(eventLog) ? sha256OfFile(eventLog) : sha256OfString("");
302
+ const events = fs.existsSync(eventLog)
303
+ ? fs
304
+ .readFileSync(eventLog, "utf8")
305
+ .split(/\n/g)
306
+ .map((line) => line.trim())
307
+ .filter(Boolean)
308
+ .map((line) => {
309
+ try {
310
+ const e = JSON.parse(line);
311
+ return { id: e.id || "", kind: e.kind || "", decision: e.decision || "", createdAt: e.createdAt || "" };
312
+ }
313
+ catch {
314
+ return { id: "", kind: "malformed", decision: "", createdAt: "" };
315
+ }
316
+ })
317
+ : [];
318
+ const metricsReport = path.join(run.paths.runDir, "metrics", "metrics-report.json");
319
+ const costRecord = {
320
+ tasks: (run.tasks || []).map((task) => ({ taskId: task.id, model: task.model, source: task.agentType })),
321
+ metricsDigest: fs.existsSync(metricsReport) ? sha256OfFile(metricsReport) : undefined,
322
+ };
323
+ const collaboration = run.collaboration;
324
+ const collaborationLog = {
325
+ digest: sha256OfString((0, node_projection_1.replayStableStringify)(collaboration || {})),
326
+ approvals: collaboration?.approvals?.length || 0,
327
+ comments: collaboration?.comments?.length || 0,
328
+ handoffs: collaboration?.handoffs?.length || 0,
329
+ };
330
+ const stateDigest = fs.existsSync(run.paths.state) ? sha256OfFile(run.paths.state) : "";
331
+ return {
332
+ schemaVersion: 1,
333
+ runId: run.id,
334
+ finalVerdict: {
335
+ lifecycle,
336
+ loopStage: run.loopStage,
337
+ terminal: lifecycle === "completed" || lifecycle === "failed",
338
+ commitGated: (run.commits || []).some((c) => c.verifierGated),
339
+ },
340
+ commits,
341
+ evidenceDigests,
342
+ attestationChain: { auditLogDigest, eventCount: events.length, events },
343
+ costRecord,
344
+ auditLog: { path: path.relative(run.paths.runDir, eventLog), digest: auditLogDigest },
345
+ collaborationLog,
346
+ stateDigest,
347
+ };
348
+ }
349
+ function validateSkeleton(skeleton) {
350
+ const missing = [];
351
+ if (!skeleton)
352
+ return [...exports.SKELETON_REQUIRED_KEYS];
353
+ for (const key of exports.SKELETON_REQUIRED_KEYS) {
354
+ const value = skeleton[key];
355
+ if (value === undefined || value === null) {
356
+ missing.push(key);
357
+ continue;
358
+ }
359
+ if (key === "runId" && !String(value).trim())
360
+ missing.push(key);
361
+ if (key === "stateDigest" && !String(value).trim())
362
+ missing.push(key);
363
+ if (key === "finalVerdict" && (typeof value !== "object" || !value.lifecycle))
364
+ missing.push(key);
365
+ if (key === "auditLog" && (typeof value !== "object" || !value.digest))
366
+ missing.push(key);
367
+ if (key === "attestationChain" && (typeof value !== "object" || typeof value.auditLogDigest !== "string"))
368
+ missing.push(key);
369
+ if (key === "commits" && !Array.isArray(value))
370
+ missing.push(key);
371
+ if (key === "evidenceDigests" && !Array.isArray(value))
372
+ missing.push(key);
373
+ }
374
+ return missing;
375
+ }
376
+ /** Refuse if extraction dropped audit content the run actually has. */
377
+ function validateSkeletonAgainstRun(run, skeleton) {
378
+ const failures = [];
379
+ const runCommits = (run.commits || []).length;
380
+ if (runCommits > 0 && skeleton.commits.length !== runCommits) {
381
+ failures.push(`commits-dropped(run=${runCommits},sealed=${skeleton.commits.length})`);
382
+ }
383
+ const runHasEvidence = (run.nodes || []).some((n) => (n.evidence || []).length) ||
384
+ (run.candidates || []).some((c) => (c.evidence || []).length) ||
385
+ (run.candidateSelections || []).some((s) => (s.evidence || []).length) ||
386
+ (run.commits || []).some((c) => (c.evidence || []).length);
387
+ if (runHasEvidence && skeleton.evidenceDigests.length === 0) {
388
+ failures.push("evidence-dropped");
389
+ }
390
+ if (!skeleton.finalVerdict || !skeleton.finalVerdict.lifecycle)
391
+ failures.push("verdict-missing");
392
+ return failures;
393
+ }
394
+ function snapshotProjectionDigest(node) {
395
+ return sha256OfString((0, node_projection_1.nodeProjectionDigestInput)(node));
396
+ }
397
+ function nodeBodyDigest(node) {
398
+ return sha256OfString((0, node_projection_1.nodeProjectionDigestInput)(node));
399
+ }
400
+ /** Build the retention plan: which paths are freeable under `policy`, of
401
+ * what kind, how many bytes, and the resulting capability downgrade. */
402
+ function planReclamation(run, policy = {}) {
403
+ const runDir = run.paths.runDir;
404
+ const freeable = [];
405
+ const rel = (abs) => path.relative(runDir, abs);
406
+ // (1) Worker scratch dirs — pure scratch with zero audit value.
407
+ let reclaimedScratch = false;
408
+ if (!policy.keepScratch) {
409
+ for (const scope of run.workers || []) {
410
+ const workerDir = scope.workerDir;
411
+ if (!workerDir || !fs.existsSync(workerDir))
412
+ continue;
413
+ const task = (run.tasks || []).find((t) => t.id === scope.taskId);
414
+ const resultNodeId = scope.resultNodeId || task?.resultNodeId;
415
+ const resultsCopy = task?.resultPath;
416
+ if (!resultNodeId || !resultsCopy || !fs.existsSync(resultsCopy))
417
+ continue;
418
+ const bytes = dirBytes(workerDir);
419
+ if (bytes <= 0)
420
+ continue;
421
+ freeable.push({ path: rel(workerDir), absPath: workerDir, kind: "scratch", bytes, repointResultNodeId: resultNodeId });
422
+ reclaimedScratch = true;
423
+ }
424
+ }
425
+ const repointNodeIds = new Set(freeable.filter((f) => f.repointResultNodeId).map((f) => f.repointResultNodeId));
426
+ // (2) Reconstructable node snapshots.
427
+ let reclaimedSnapshot = false;
428
+ let reconstructableSnapshot = false;
429
+ if (!policy.keepSnapshots) {
430
+ const nodesDir = run.paths.stateNodesDir || path.join(runDir, "nodes");
431
+ const snapshotsRoot = path.join(nodesDir, "snapshots");
432
+ if (fs.existsSync(snapshotsRoot)) {
433
+ for (const nodeDirName of fs.readdirSync(snapshotsRoot, { withFileTypes: true })) {
434
+ if (!nodeDirName.isDirectory())
435
+ continue;
436
+ const nodeDir = path.join(snapshotsRoot, nodeDirName.name);
437
+ for (const file of fs.readdirSync(nodeDir, { withFileTypes: true })) {
438
+ if (!file.isFile() || !file.name.endsWith(".json"))
439
+ continue;
440
+ const snapFile = path.join(nodeDir, file.name);
441
+ let snap;
442
+ try {
443
+ snap = JSON.parse(fs.readFileSync(snapFile, "utf8"));
444
+ }
445
+ catch {
446
+ continue;
447
+ }
448
+ if (!snap || typeof snap !== "object" || typeof snap.nodeId !== "string")
449
+ continue;
450
+ const nodeId = snap.nodeId;
451
+ const node = (run.nodes || []).find((n) => n.id === nodeId);
452
+ if (!node)
453
+ continue;
454
+ if (repointNodeIds.has(node.id))
455
+ continue;
456
+ const bytes = dirBytes(snapFile);
457
+ if (bytes <= 0)
458
+ continue;
459
+ const inputDigest = nodeBodyDigest(node);
460
+ const recipe = {
461
+ recipeKind: "node-snapshot-projection",
462
+ inputDigests: [inputDigest],
463
+ inputsDigest: sha256OfString((0, node_projection_1.replayStableStringify)([inputDigest])),
464
+ expectDigest: snapshotProjectionDigest(node),
465
+ sourceRef: node.id,
466
+ };
467
+ freeable.push({ path: rel(snapFile), absPath: snapFile, kind: "reconstructable-snapshot", bytes, recipe });
468
+ reclaimedSnapshot = true;
469
+ reconstructableSnapshot = true;
470
+ }
471
+ }
472
+ }
473
+ }
474
+ // Determinism (HARD constraint): sort by path BEFORE anything hashes it,
475
+ // so tombstoneHash is reproducible across hosts regardless of
476
+ // fs.readdirSync's filesystem-dependent order.
477
+ freeable.sort((a, b) => (0, run_registry_io_1.compareBytes)(a.path, b.path));
478
+ const byKind = {};
479
+ let bytesToFree = 0;
480
+ for (const entry of freeable) {
481
+ byKind[entry.kind] = (byKind[entry.kind] || 0) + entry.bytes;
482
+ bytesToFree += entry.bytes;
483
+ }
484
+ let capability = "re-runnable";
485
+ let capabilityReason = "scratch-only-reclaimed";
486
+ if (reclaimedSnapshot && reconstructableSnapshot) {
487
+ capability = "re-runnable-by-reconstruction";
488
+ capabilityReason = "inputs-and-expectdigest-retained";
489
+ }
490
+ else if (reclaimedSnapshot) {
491
+ capability = "verify-only";
492
+ capabilityReason = "snapshot-reclaimed-no-reconstruction";
493
+ }
494
+ else if (reclaimedScratch) {
495
+ capability = "re-runnable";
496
+ capabilityReason = "scratch-only-reclaimed";
497
+ }
498
+ return { freeable, bytesToFree, byKind, capability, capabilityReason };
499
+ }
500
+ // ---------------------------------------------------------------------------
501
+ // Tombstone construction + hash chain
502
+ // ---------------------------------------------------------------------------
503
+ function policyDigestOf(policy) {
504
+ return sha256OfString((0, node_projection_1.replayStableStringify)(policy));
505
+ }
506
+ /** genesis prevTombstoneHash = sha256 of the sealed skeleton. */
507
+ function genesisPrevHash(skeleton) {
508
+ return sha256OfString((0, node_projection_1.replayStableStringify)(skeleton));
509
+ }
510
+ function tombstoneHashInput(t) {
511
+ return (0, node_projection_1.replayStableStringify)({
512
+ runId: t.runId,
513
+ tombstoneId: t.tombstoneId,
514
+ reclaimedAt: t.reclaimedAt,
515
+ actor: t.actor || null,
516
+ policyDigest: t.policyDigest,
517
+ freed: t.freed.map((f) => ({ path: f.path, kind: f.kind, bytes: f.bytes, sha256: f.sha256, recipe: f.recipe || null })),
518
+ bytesFreed: t.bytesFreed,
519
+ skeletonDigest: sha256OfString((0, node_projection_1.replayStableStringify)(t.skeleton)),
520
+ capability: t.capability,
521
+ capabilityReason: t.capabilityReason,
522
+ prevTombstoneHash: t.prevTombstoneHash,
523
+ });
524
+ }
525
+ function computeTombstoneHash(t) {
526
+ return sha256OfString(tombstoneHashInput(t));
527
+ }
528
+ function tombstoneId(seq) {
529
+ return `tomb-${String(seq).padStart(3, "0")}`;
530
+ }
531
+ /** STEP 2: build the FULL tombstone (pre-deletion sha256 per freed path +
532
+ * the hash chain). Reads the freed files (still present); mutates
533
+ * nothing on disk. */
534
+ function buildTombstone(run, skeleton, plan, options = {}) {
535
+ const now = options.now || new Date().toISOString();
536
+ const prior = loadReclamationLog(run).tombstones;
537
+ const prevTombstoneHash = prior.length ? prior[prior.length - 1].tombstoneHash : genesisPrevHash(skeleton);
538
+ const freed = plan.freeable.map((entry) => ({
539
+ path: entry.path,
540
+ kind: entry.kind,
541
+ bytes: entry.bytes,
542
+ sha256: contentDigest(entry.absPath),
543
+ recipe: entry.recipe,
544
+ }));
545
+ const base = {
546
+ schemaVersion: 1,
547
+ runId: run.id,
548
+ tombstoneId: tombstoneId(prior.length + 1),
549
+ reclaimedAt: now,
550
+ actor: options.actor,
551
+ policyDigest: policyDigestOf(options.policy || {}),
552
+ freed,
553
+ bytesFreed: freed.reduce((sum, f) => sum + f.bytes, 0),
554
+ skeleton,
555
+ capability: plan.capability,
556
+ capabilityReason: plan.capabilityReason,
557
+ prevTombstoneHash,
558
+ };
559
+ return { ...base, tombstoneHash: computeTombstoneHash(base) };
560
+ }
561
+ /** STEP 3: commit the tombstone DURABLY into the append-only overlay
562
+ * (temp -> fsync -> rename) and record the attestation. No byte is freed
563
+ * here — write-ahead order is the safety property. */
564
+ function commitTombstone(run, tombstone) {
565
+ const log = loadReclamationLog(run);
566
+ log.tombstones.push(tombstone);
567
+ (0, fs_atomic_1.writeJson)(reclaimedLogPath(run), log, { durable: true });
568
+ try {
569
+ (0, trust_audit_1.recordTrustAuditEvent)(run, {
570
+ kind: "run.reclamation",
571
+ decision: "recorded",
572
+ source: "cw-validated",
573
+ metadata: {
574
+ tombstoneId: tombstone.tombstoneId,
575
+ tombstoneHash: tombstone.tombstoneHash,
576
+ prevTombstoneHash: tombstone.prevTombstoneHash,
577
+ bytesFreed: tombstone.bytesFreed,
578
+ freedPaths: tombstone.freed.length,
579
+ capability: tombstone.capability,
580
+ capabilityReason: tombstone.capabilityReason,
581
+ actor: tombstone.actor,
582
+ },
583
+ });
584
+ }
585
+ catch {
586
+ // The tombstone is already durable; an audit-append hiccup must not unwind it.
587
+ }
588
+ }
589
+ /** STEP 4: re-point every surviving node's artifacts off the scratch
590
+ * paths about to vanish, DURABLY persist that state.json change, and
591
+ * PROVE no surviving node still references a freed path — BEFORE a
592
+ * single byte is freed. Fail closed (`repoint-incomplete`) if the proof
593
+ * does not hold. */
594
+ function prepareFree(run, tombstone) {
595
+ const runDir = run.paths.runDir;
596
+ const scratchDirs = tombstone.freed.filter((f) => f.kind === "scratch").map((f) => (0, fs_atomic_1.realResolve)(path.join(runDir, f.path)));
597
+ if (!scratchDirs.length)
598
+ return;
599
+ const repointed = new Set();
600
+ for (const scratchDir of scratchDirs) {
601
+ for (const id of repointResultNodeArtifacts(run, scratchDir))
602
+ repointed.add(id);
603
+ }
604
+ persistRunDurable(run);
605
+ for (const node of run.nodes || []) {
606
+ for (const artifact of node.artifacts || []) {
607
+ if (!artifact.path)
608
+ continue;
609
+ const resolved = (0, fs_atomic_1.realResolve)(artifact.path);
610
+ for (const scratchDir of scratchDirs) {
611
+ if (resolved === scratchDir || resolved.startsWith(scratchDir + path.sep)) {
612
+ throw new ReclamationError("repoint-incomplete", `node ${node.id} artifact ${artifact.id} still references freed scratch path ${artifact.path}`, { nodeId: node.id, artifactId: artifact.id, path: artifact.path });
613
+ }
614
+ }
615
+ }
616
+ }
617
+ for (const nodeId of repointed) {
618
+ try {
619
+ const fresh = (0, node_store_1.snapshotNode)(run, nodeId, { persist: false });
620
+ const { freshness } = (0, node_snapshot_1.loadNodeSnapshot)(run, fresh);
621
+ if (freshness === "absent") {
622
+ throw new ReclamationError("repoint-incomplete", `re-pointed node ${nodeId} snapshot is absent (dangling artifact)`, { nodeId });
623
+ }
624
+ }
625
+ catch (error) {
626
+ if (error instanceof ReclamationError)
627
+ throw error;
628
+ throw new ReclamationError("repoint-incomplete", `could not prove re-pointed node ${nodeId} stays valid: ${error.message}`, {
629
+ nodeId,
630
+ });
631
+ }
632
+ }
633
+ }
634
+ /** STEP 5: free the bulk DATA bytes. Pure deletion — every re-point is
635
+ * already done and DURABLY persisted by prepareFree(). */
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 = path.join(runDir, entry.path);
641
+ const before = dirBytes(abs);
642
+ fs.rmSync(abs, { recursive: true, force: true });
643
+ freedBytes += before;
644
+ }
645
+ return freedBytes;
646
+ }
647
+ function repointResultNodeArtifacts(run, freedScratchDir) {
648
+ const freedReal = (0, fs_atomic_1.realResolve)(freedScratchDir);
649
+ const freedPrefix = freedReal + path.sep;
650
+ const changedIds = [];
651
+ for (const node of run.nodes || []) {
652
+ if (!node.artifacts)
653
+ continue;
654
+ let changed = false;
655
+ for (const artifact of node.artifacts) {
656
+ if (!artifact.path)
657
+ continue;
658
+ const resolved = (0, fs_atomic_1.realResolve)(artifact.path);
659
+ if (resolved === freedReal || resolved.startsWith(freedPrefix)) {
660
+ const retained = node.artifacts.find((a) => a.id === "result" && a.path && fs.existsSync(a.path));
661
+ if (retained && retained.path) {
662
+ artifact.path = retained.path;
663
+ changed = true;
664
+ }
665
+ }
666
+ }
667
+ if (changed) {
668
+ node.updatedAt = new Date().toISOString();
669
+ changedIds.push(node.id);
670
+ }
671
+ }
672
+ return changedIds;
673
+ }
674
+ /** Execute the write-ahead, fail-closed reclamation transaction. */
675
+ function runReclamation(run, options = {}) {
676
+ const skeleton = extractSkeleton(run);
677
+ const missing = validateSkeleton(skeleton);
678
+ if (missing.length) {
679
+ throw new ReclamationError("skeleton-incomplete", `Skeleton missing required keys: ${missing.join(", ")}`, { missing });
680
+ }
681
+ const contentLoss = validateSkeletonAgainstRun(run, skeleton);
682
+ if (contentLoss.length) {
683
+ throw new ReclamationError("skeleton-incomplete", `Skeleton dropped audit content: ${contentLoss.join(", ")}`, { contentLoss });
684
+ }
685
+ if (options.faultAfter === "skeleton")
686
+ throw new ReclamationAbort("skeleton");
687
+ const { plan, tombstone } = withRunLock(run, () => {
688
+ const builtPlan = planReclamation(run, options.reclaimPolicy || {});
689
+ const builtTombstone = buildTombstone(run, skeleton, builtPlan, { now: options.now, actor: options.actor, policy: options.policy });
690
+ if (options.faultAfter === "tombstone-write")
691
+ throw new ReclamationAbort("tombstone-write");
692
+ commitTombstone(run, builtTombstone);
693
+ return { plan: builtPlan, tombstone: builtTombstone };
694
+ });
695
+ if (options.faultAfter === "tombstone-commit")
696
+ throw new ReclamationAbort("tombstone-commit");
697
+ prepareFree(run, tombstone);
698
+ const bytesFreed = freeBulk(run, tombstone);
699
+ return { tombstone, bytesFreed, plan };
700
+ }
701
+ // ---------------------------------------------------------------------------
702
+ // Reconstruction + verification
703
+ // ---------------------------------------------------------------------------
704
+ function reconstructArtifact(run, recipe) {
705
+ if (recipe.recipeKind === "node-snapshot-projection") {
706
+ const node = (run.nodes || []).find((n) => n.id === recipe.sourceRef);
707
+ if (!node) {
708
+ return { inputsDigest: sha256OfString("absent"), expectDigest: sha256OfString("absent") };
709
+ }
710
+ const inputDigest = nodeBodyDigest(node);
711
+ const inputsDigest = sha256OfString((0, node_projection_1.replayStableStringify)([inputDigest]));
712
+ const expectDigest = snapshotProjectionDigest(node);
713
+ return { inputsDigest, expectDigest };
714
+ }
715
+ return { inputsDigest: sha256OfString("unknown-recipe"), expectDigest: sha256OfString("unknown-recipe") };
716
+ }
717
+ /** Re-prove the whole reclamation chain for a run. Recomputes every hash
718
+ * independently — never trusts the stored value. */
719
+ function verifyReclamation(run) {
720
+ const log = loadReclamationLog(run);
721
+ const tombstones = log.tombstones;
722
+ const checks = [];
723
+ if (!tombstones.length) {
724
+ return { reclaimed: false, verified: false, checks: [{ name: "reclaimed", pass: false, code: "not-reclaimed" }], tombstones };
725
+ }
726
+ let chainOk = true;
727
+ for (let i = 0; i < tombstones.length; i++) {
728
+ const expectedPrev = i === 0 ? genesisPrevHash(tombstones[0].skeleton) : tombstones[i - 1].tombstoneHash;
729
+ const pass = tombstones[i].prevTombstoneHash === expectedPrev;
730
+ if (!pass)
731
+ chainOk = false;
732
+ checks.push({ name: `chain-link[${i}]`, pass, code: pass ? undefined : "tombstone-chain-broken" });
733
+ }
734
+ let digestsOk = true;
735
+ for (let i = 0; i < tombstones.length; i++) {
736
+ const { tombstoneHash, ...rest } = tombstones[i];
737
+ const recomputed = computeTombstoneHash(rest);
738
+ const pass = recomputed === tombstoneHash;
739
+ if (!pass)
740
+ digestsOk = false;
741
+ checks.push({ name: `tombstone-hash[${i}]`, pass, code: pass ? undefined : "tombstone-digest-mismatch" });
742
+ }
743
+ let skeletonOk = true;
744
+ for (let i = 0; i < tombstones.length; i++) {
745
+ const missing = validateSkeleton(tombstones[i].skeleton);
746
+ const pass = missing.length === 0;
747
+ if (!pass)
748
+ skeletonOk = false;
749
+ checks.push({ name: `skeleton[${i}]`, pass, code: pass ? undefined : "skeleton-incomplete", detail: missing.join(",") || undefined });
750
+ }
751
+ let reconstructionOk = true;
752
+ for (let i = 0; i < tombstones.length; i++) {
753
+ for (const entry of tombstones[i].freed) {
754
+ if (!entry.recipe)
755
+ continue;
756
+ const recomputed = reconstructArtifact(run, entry.recipe);
757
+ const inputsMatch = recomputed.inputsDigest === entry.recipe.inputsDigest;
758
+ const expectMatch = recomputed.expectDigest === entry.recipe.expectDigest;
759
+ const pass = inputsMatch && expectMatch;
760
+ if (!pass)
761
+ reconstructionOk = false;
762
+ checks.push({
763
+ name: `reconstruct[${i}]:${entry.path}`,
764
+ pass,
765
+ code: pass ? undefined : "reconstruction-digest-mismatch",
766
+ detail: pass ? undefined : `inputs=${inputsMatch} expect=${expectMatch}`,
767
+ });
768
+ }
769
+ }
770
+ const verified = chainOk && digestsOk && skeletonOk && reconstructionOk;
771
+ return { reclaimed: true, verified, checks, tombstones };
772
+ }
773
+ function reclamationPolicy(overrides = {}) {
774
+ return { ...run_registry_io_1.DEFAULT_RUN_REGISTRY_POLICY, ...overrides };
775
+ }
776
+ /** Fail-closed eligibility, checked IN ORDER (SPEC "Rebuild risks" #6):
777
+ * already-reclaimed -> non-terminal -> open-feedback -> not-archived ->
778
+ * within-retention. `null` means eligible. */
779
+ function reclaimEligibility(record, policy, nowMs) {
780
+ if (record.tier === "reclaimed")
781
+ return "already-reclaimed";
782
+ const terminalStates = policy.reclaimStates && policy.reclaimStates.length ? policy.reclaimStates : ["completed", "failed"];
783
+ if (record.derivedLifecycle !== "completed" && record.derivedLifecycle !== "failed")
784
+ return "non-terminal";
785
+ if (!terminalStates.includes(record.derivedLifecycle))
786
+ return "non-terminal";
787
+ if (record.openFeedbackCount > 0)
788
+ return "open-feedback";
789
+ if (!record.archived)
790
+ return "not-archived";
791
+ const days = policy.reclaimAfterArchiveDays ?? 0;
792
+ if (days > 0) {
793
+ const archivedAtMs = record.archivedAt ? Date.parse(record.archivedAt) : NaN;
794
+ if (!Number.isFinite(archivedAtMs))
795
+ return "within-retention";
796
+ if (archivedAtMs > nowMs - days * 24 * 60 * 60 * 1000)
797
+ return "within-retention";
798
+ }
799
+ return null;
800
+ }
801
+ function recordsForRunId(host, runId, scope) {
802
+ const located = host.locate(runId, scope);
803
+ return located ? [located.record] : [];
804
+ }
805
+ function gcPlan(host, options = {}) {
806
+ const scope = options.scope || "home";
807
+ const policy = reclamationPolicy(options.policy);
808
+ const nowIso = options.now || new Date().toISOString();
809
+ const nowMs = Date.parse(nowIso);
810
+ const records = options.runId ? recordsForRunId(host, options.runId, scope) : host.buildIndex(scope).records;
811
+ const entries = [];
812
+ let bytesToFree = 0;
813
+ let eligibleCount = 0;
814
+ for (const record of records) {
815
+ const refusal = reclaimEligibility(record, policy, nowMs);
816
+ let plan;
817
+ try {
818
+ const run = host.loadRun(record.repo, record.runId);
819
+ plan = planReclamation(run, { keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots });
820
+ }
821
+ catch {
822
+ entries.push({
823
+ runId: record.runId,
824
+ repo: record.repo,
825
+ eligible: false,
826
+ reason: "unreadable",
827
+ tier: record.tier || "live",
828
+ capability: record.capability || "re-runnable",
829
+ capabilityReason: record.capabilityReason || "live-full",
830
+ bytesToFree: 0,
831
+ byKind: {},
832
+ freeable: [],
833
+ });
834
+ continue;
835
+ }
836
+ const eligible = refusal === null;
837
+ const entry = {
838
+ runId: record.runId,
839
+ repo: record.repo,
840
+ eligible,
841
+ reason: eligible ? "eligible" : refusal,
842
+ tier: record.tier || "live",
843
+ capability: plan.capability,
844
+ capabilityReason: plan.capabilityReason,
845
+ bytesToFree: eligible ? plan.bytesToFree : 0,
846
+ byKind: eligible ? plan.byKind : {},
847
+ freeable: eligible ? plan.freeable.map((f) => ({ path: f.path, kind: f.kind, bytes: f.bytes })) : [],
848
+ };
849
+ entries.push(entry);
850
+ if (eligible) {
851
+ eligibleCount += 1;
852
+ bytesToFree += plan.bytesToFree;
853
+ }
854
+ }
855
+ return {
856
+ schemaVersion: 1,
857
+ scope,
858
+ generatedAt: nowIso,
859
+ policy: {
860
+ reclaimAfterArchiveDays: policy.reclaimAfterArchiveDays ?? 0,
861
+ keepSnapshots: Boolean(policy.keepSnapshots),
862
+ keepScratch: Boolean(policy.keepScratch),
863
+ reclaimStates: policy.reclaimStates && policy.reclaimStates.length ? policy.reclaimStates : ["completed", "failed"],
864
+ },
865
+ total: entries.length,
866
+ eligibleCount,
867
+ bytesToFree,
868
+ entries,
869
+ nextAction: eligibleCount ? "node scripts/cw.js gc run" : "node scripts/cw.js run search",
870
+ };
871
+ }
872
+ function gcRun(host, options = {}) {
873
+ const scope = options.scope || "home";
874
+ const policy = reclamationPolicy(options.policy);
875
+ const nowIso = options.now || new Date().toISOString();
876
+ const nowMs = Date.parse(nowIso);
877
+ const records = options.runId ? recordsForRunId(host, options.runId, scope) : host.buildIndex(scope).records;
878
+ const maxRuns = options.limit ?? (policy.maxReclaimRuns || 0);
879
+ const maxBytes = policy.maxReclaimBytes || 0;
880
+ const reclaimed = [];
881
+ const refused = [];
882
+ let totalBytesFreed = 0;
883
+ for (const record of records) {
884
+ const refusal = reclaimEligibility(record, policy, nowMs);
885
+ if (refusal) {
886
+ refused.push({ runId: record.runId, code: refusal });
887
+ continue;
888
+ }
889
+ if (maxRuns > 0 && reclaimed.length >= maxRuns)
890
+ break;
891
+ let run;
892
+ try {
893
+ run = host.loadRun(record.repo, record.runId);
894
+ }
895
+ catch {
896
+ refused.push({ runId: record.runId, code: "unreadable" });
897
+ continue;
898
+ }
899
+ try {
900
+ const result = runReclamation(run, {
901
+ now: nowIso,
902
+ actor: options.actor,
903
+ policy: { reclaimAfterArchiveDays: policy.reclaimAfterArchiveDays, keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots },
904
+ reclaimPolicy: { keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots },
905
+ });
906
+ reclaimed.push({
907
+ runId: record.runId,
908
+ bytesFreed: result.bytesFreed,
909
+ tombstoneHash: result.tombstone.tombstoneHash,
910
+ capability: result.tombstone.capability,
911
+ capabilityReason: result.tombstone.capabilityReason,
912
+ });
913
+ (0, trust_audit_1.recordTrustAuditEvent)(run, {
914
+ kind: "run.reclaimed",
915
+ decision: "recorded",
916
+ source: "cw-validated",
917
+ metadata: { tombstoneHash: result.tombstone.tombstoneHash, bytesFreed: result.bytesFreed, capability: result.tombstone.capability },
918
+ });
919
+ totalBytesFreed += result.bytesFreed;
920
+ if (maxBytes > 0 && totalBytesFreed >= maxBytes)
921
+ break;
922
+ }
923
+ catch (error) {
924
+ if (error instanceof ReclamationError)
925
+ refused.push({ runId: record.runId, code: error.code });
926
+ else
927
+ throw error;
928
+ }
929
+ }
930
+ return {
931
+ schemaVersion: 1,
932
+ scope,
933
+ generatedAt: nowIso,
934
+ dryRun: false,
935
+ reclaimed,
936
+ refused,
937
+ totalBytesFreed,
938
+ nextAction: reclaimed.length ? "node scripts/cw.js gc verify <run-id>" : "node scripts/cw.js gc plan",
939
+ };
940
+ }
941
+ function gcVerify(host, runId, options = {}) {
942
+ const scope = options.scope || "home";
943
+ const located = host.locate(runId, scope);
944
+ if (!located) {
945
+ return {
946
+ schemaVersion: 1,
947
+ runId,
948
+ reclaimed: false,
949
+ verified: false,
950
+ tier: "live",
951
+ capability: "re-runnable",
952
+ chainLength: 0,
953
+ checks: [{ name: "located", pass: false, code: "not-reclaimed", detail: "run source not found" }],
954
+ nextAction: "node scripts/cw.js registry refresh" + (scope === "home" ? " --scope home" : ""),
955
+ };
956
+ }
957
+ const run = host.loadRun(located.record.repo, runId);
958
+ const result = verifyReclamation(run);
959
+ const checks = result.checks.map((c) => ({ name: c.name, pass: c.pass, code: c.code, detail: c.detail }));
960
+ let eligibleWhenReclaimed = result.reclaimed;
961
+ for (const tombstone of result.tombstones) {
962
+ const terminal = tombstone.skeleton.finalVerdict?.terminal === true;
963
+ if (!terminal) {
964
+ eligibleWhenReclaimed = false;
965
+ checks.push({ name: `eligible-when-reclaimed:${tombstone.tombstoneId}`, pass: false, code: "ineligible-when-reclaimed", detail: "non-terminal verdict sealed" });
966
+ }
967
+ }
968
+ const last = result.tombstones[result.tombstones.length - 1];
969
+ const witnessed = (0, trust_audit_1.listTrustAuditEvents)(run).some((event) => event.kind === "run.reclaimed");
970
+ const proofDeleted = witnessed && !result.reclaimed;
971
+ if (proofDeleted) {
972
+ checks.push({ name: "reclaim-witness", pass: false, code: "reclaim-proof-deleted", detail: "trust-audit attests reclamation but reclaimed.json is missing/empty" });
973
+ }
974
+ const reclaimed = result.reclaimed || proofDeleted;
975
+ const verified = result.verified && eligibleWhenReclaimed && !proofDeleted;
976
+ return {
977
+ schemaVersion: 1,
978
+ runId,
979
+ reclaimed,
980
+ verified,
981
+ tier: located.record.tier || (reclaimed ? "reclaimed" : "live"),
982
+ capability: located.record.capability || "re-runnable",
983
+ capabilityReason: located.record.capabilityReason,
984
+ tombstoneHash: last?.tombstoneHash,
985
+ chainLength: result.tombstones.length,
986
+ checks,
987
+ nextAction: verified ? "node scripts/cw.js run show " + runId : "node scripts/cw.js gc plan",
988
+ };
989
+ }
990
+ // ---------------------------------------------------------------------------
991
+ // Human formatting for gc (CLI-only)
992
+ // ---------------------------------------------------------------------------
993
+ function formatGcPlan(result) {
994
+ const lines = [
995
+ `GC Plan (${result.scope}): ${result.eligibleCount}/${result.total} eligible, ${result.bytesToFree} byte(s) would be freed [DRY-RUN, frees nothing]`,
996
+ ` policy: reclaimAfterArchiveDays=${result.policy.reclaimAfterArchiveDays} keepScratch=${result.policy.keepScratch} keepSnapshots=${result.policy.keepSnapshots}`,
997
+ ];
998
+ for (const entry of result.entries) {
999
+ if (entry.eligible) {
1000
+ const kinds = Object.entries(entry.byKind)
1001
+ .map(([k, v]) => `${k}=${v}`)
1002
+ .join(" ");
1003
+ lines.push(` [eligible] ${entry.runId} -> ${entry.capability} (${entry.capabilityReason}) ${entry.bytesToFree}B {${kinds}}`);
1004
+ }
1005
+ else {
1006
+ lines.push(` [skip:${entry.reason}] ${entry.runId} (tier=${entry.tier})`);
1007
+ }
1008
+ }
1009
+ if (!result.entries.length)
1010
+ lines.push(" (no runs in scope)");
1011
+ return lines.join("\n");
1012
+ }
1013
+ function formatGcRun(result) {
1014
+ const lines = [`GC Run (${result.scope}): reclaimed ${result.reclaimed.length} run(s), freed ${result.totalBytesFreed} byte(s)`];
1015
+ for (const r of result.reclaimed)
1016
+ lines.push(` [reclaimed] ${r.runId} -> ${r.capability} (${r.capabilityReason}) ${r.bytesFreed}B tombstone=${r.tombstoneHash.slice(0, 19)}`);
1017
+ for (const r of result.refused)
1018
+ lines.push(` [refused:${r.code}] ${r.runId}`);
1019
+ if (!result.reclaimed.length && !result.refused.length)
1020
+ lines.push(" (nothing eligible)");
1021
+ return lines.join("\n");
1022
+ }
1023
+ function formatGcVerify(result) {
1024
+ const lines = [
1025
+ `GC Verify ${result.runId}: reclaimed=${result.reclaimed} verified=${result.verified} tier=${result.tier} capability=${result.capability}${result.tombstoneHash ? ` tombstone=${result.tombstoneHash.slice(0, 19)}` : ""}`,
1026
+ ];
1027
+ for (const check of result.checks)
1028
+ lines.push(` ${check.pass ? "PASS" : "FAIL"} ${check.name}${check.code ? ` [${check.code}]` : ""}${check.detail ? ` (${check.detail})` : ""}`);
1029
+ return lines.join("\n");
1030
+ }
1031
+ exports.DEFAULT_ORPHAN_MIN_AGE_MINUTES = 60;
1032
+ function resolveNowMs(now) {
1033
+ if (now === undefined)
1034
+ return Date.now();
1035
+ const ms = new Date(now).getTime();
1036
+ if (!Number.isFinite(ms))
1037
+ throw new Error(`--now must be a valid ISO date (got ${now})`);
1038
+ return ms;
1039
+ }
1040
+ /** Walk a directory tree; return total bytes + the newest mtime found
1041
+ * anywhere in it (including the directory itself). Best-effort. */
1042
+ function walk(dir) {
1043
+ let bytes = 0;
1044
+ let newestMs = 0;
1045
+ const bump = (p) => {
1046
+ let st;
1047
+ try {
1048
+ st = fs.lstatSync(p);
1049
+ }
1050
+ catch {
1051
+ return;
1052
+ }
1053
+ if (st.mtimeMs > newestMs)
1054
+ newestMs = st.mtimeMs;
1055
+ if (st.isDirectory()) {
1056
+ let names;
1057
+ try {
1058
+ names = fs.readdirSync(p);
1059
+ }
1060
+ catch {
1061
+ return;
1062
+ }
1063
+ for (const name of names)
1064
+ bump(path.join(p, name));
1065
+ }
1066
+ else {
1067
+ bytes += st.size;
1068
+ }
1069
+ };
1070
+ bump(dir);
1071
+ return { bytes, newestMs };
1072
+ }
1073
+ function runsDirFor(repo) {
1074
+ return path.join(repo, ".cw", "runs");
1075
+ }
1076
+ function candidatesFor(repo, knownDirs, nowMs) {
1077
+ const runsDir = runsDirFor(repo);
1078
+ let dirents;
1079
+ try {
1080
+ dirents = fs.readdirSync(runsDir, { withFileTypes: true });
1081
+ }
1082
+ catch {
1083
+ return [];
1084
+ }
1085
+ const out = [];
1086
+ for (const entry of dirents) {
1087
+ if (!entry.isDirectory())
1088
+ continue;
1089
+ const dir = path.join(runsDir, entry.name);
1090
+ if (knownDirs.has(path.resolve(dir)))
1091
+ continue;
1092
+ if (fs.existsSync(path.join(dir, "state.json")))
1093
+ continue; // gc.ts's territory
1094
+ const { bytes, newestMs } = walk(dir);
1095
+ const ageMinutes = Math.max(0, Math.round((nowMs - newestMs) / 60000));
1096
+ out.push({ repo, runId: entry.name, path: dir, ageMinutes, bytes });
1097
+ }
1098
+ return out;
1099
+ }
1100
+ function scan(host, scope, nowMs) {
1101
+ const index = host.buildIndex(scope);
1102
+ const known = new Set(index.records.map((r) => path.resolve(r.runDir)));
1103
+ const entries = [];
1104
+ for (const repo of index.repos)
1105
+ entries.push(...candidatesFor(repo, known, nowMs));
1106
+ return { repos: index.repos, entries };
1107
+ }
1108
+ /** `cw orphans list` (read-only). */
1109
+ function listOrphanRuns(host, options = {}) {
1110
+ const scope = options.scope || "home";
1111
+ const { repos, entries } = scan(host, scope, resolveNowMs(options.now));
1112
+ return {
1113
+ schemaVersion: 1,
1114
+ scope,
1115
+ repos,
1116
+ count: entries.length,
1117
+ totalBytes: entries.reduce((sum, e) => sum + e.bytes, 0),
1118
+ entries,
1119
+ };
1120
+ }
1121
+ /** `cw orphans gc [--min-age-minutes N] [--all]` — reclaim orphan run
1122
+ * directories. The re-check (state.json still absent) and the delete
1123
+ * run inside the SAME `state.json.lock` held by `saveCheckpoint`, via
1124
+ * `withFileLock` reused directly from shell/fs-atomic.ts. */
1125
+ function gcOrphanRuns(host, options = {}) {
1126
+ const scope = options.scope || "home";
1127
+ const all = Boolean(options.all);
1128
+ let minAgeMinutes = null;
1129
+ if (!all) {
1130
+ minAgeMinutes = options.minAgeMinutes ?? exports.DEFAULT_ORPHAN_MIN_AGE_MINUTES;
1131
+ if (!Number.isFinite(minAgeMinutes) || minAgeMinutes < 0) {
1132
+ throw new Error(`--min-age-minutes must be a non-negative number (got ${String(options.minAgeMinutes)})`);
1133
+ }
1134
+ }
1135
+ const nowMs = resolveNowMs(options.now);
1136
+ const { entries } = scan(host, scope, nowMs);
1137
+ const removed = [];
1138
+ let freedBytes = 0;
1139
+ for (const entry of entries) {
1140
+ if (!all && entry.ageMinutes < minAgeMinutes)
1141
+ continue;
1142
+ const runsDirResolved = path.resolve(runsDirFor(entry.repo));
1143
+ const resolved = path.resolve(entry.path);
1144
+ if (!resolved.startsWith(runsDirResolved + path.sep))
1145
+ continue; // containment, fail closed
1146
+ const statePath = path.join(resolved, "state.json");
1147
+ const deleted = (0, fs_atomic_1.withFileLock)(statePath, () => {
1148
+ if (fs.existsSync(statePath))
1149
+ return false; // a checkpoint landed between scan and here
1150
+ fs.rmSync(resolved, { recursive: true, force: true });
1151
+ return true;
1152
+ });
1153
+ if (!deleted)
1154
+ continue;
1155
+ removed.push({ repo: entry.repo, runId: entry.runId, path: entry.path, bytes: entry.bytes });
1156
+ freedBytes += entry.bytes;
1157
+ }
1158
+ return {
1159
+ schemaVersion: 1,
1160
+ scope,
1161
+ removed,
1162
+ freedBytes,
1163
+ keptCount: entries.length - removed.length,
1164
+ minAgeMinutes,
1165
+ all,
1166
+ };
1167
+ }
1168
+ function humanBytesLocal(n) {
1169
+ if (n < 1024)
1170
+ return `${n}B`;
1171
+ const units = ["KiB", "MiB", "GiB"];
1172
+ let v = n / 1024;
1173
+ let i = 0;
1174
+ while (v >= 1024 && i < units.length - 1) {
1175
+ v /= 1024;
1176
+ i += 1;
1177
+ }
1178
+ return `${v.toFixed(1)}${units[i]}`;
1179
+ }
1180
+ function formatOrphanRunsList(result) {
1181
+ if (!result.count)
1182
+ return `No orphan run(s) (${result.scope}): every ".cw/runs/" entry across ${result.repos.length} repo(s) is known to the registry.`;
1183
+ const lines = [`Orphan Runs (${result.scope}): ${result.count} in ${result.repos.length} repo(s), ${humanBytesLocal(result.totalBytes)} total`];
1184
+ for (const e of result.entries)
1185
+ lines.push(` ${e.runId} (${e.repo}) age=${e.ageMinutes}m ${humanBytesLocal(e.bytes)}`);
1186
+ lines.push(`\nReclaim with: cw orphans gc --min-age-minutes ${exports.DEFAULT_ORPHAN_MIN_AGE_MINUTES} (or --all)`);
1187
+ return lines.join("\n");
1188
+ }
1189
+ function formatOrphanRunsGc(result) {
1190
+ const scope = result.all ? "all orphan candidates" : `orphans older than ${result.minAgeMinutes} minute(s)`;
1191
+ if (!result.removed.length)
1192
+ return `Nothing to reclaim (${scope}); ${result.keptCount} kept (${result.scope}).`;
1193
+ const lines = [`Reclaimed ${result.removed.length} orphan run(s) (${scope}) — freed ${humanBytesLocal(result.freedBytes)}; ${result.keptCount} kept`];
1194
+ for (const r of result.removed)
1195
+ lines.push(` ${r.runId} (${r.repo}) ${humanBytesLocal(r.bytes)}`);
1196
+ return lines.join("\n");
1197
+ }
1198
+ function isTrue(value) {
1199
+ return value === true || value === "true" || value === "1" || value === 1;
1200
+ }
1201
+ function optionalNumber(value) {
1202
+ if (value === undefined || value === null || value === "")
1203
+ return undefined;
1204
+ const n = Number(value);
1205
+ return Number.isFinite(n) ? n : undefined;
1206
+ }
1207
+ function clonesRoot(env = process.env) {
1208
+ return path.join(resolveCwHomeForClones(env), "clones");
1209
+ }
1210
+ // Local, tiny re-implementation of run-registry-io.ts's resolveCwHome so
1211
+ // this module has no import-time dependency on that file beyond the type
1212
+ // re-exports already pulled in above (keeps the module boundary the same
1213
+ // shape as the old build's clones.ts -> run-registry.ts single-function
1214
+ // import).
1215
+ function resolveCwHomeForClones(env) {
1216
+ if (env.CW_HOME && String(env.CW_HOME).trim())
1217
+ return path.resolve(String(env.CW_HOME));
1218
+ if (env.XDG_STATE_HOME && String(env.XDG_STATE_HOME).trim()) {
1219
+ return path.join(path.resolve(String(env.XDG_STATE_HOME)), "cool-workflow");
1220
+ }
1221
+ return path.join(os.homedir(), ".local", "state", "cool-workflow");
1222
+ }
1223
+ function dirSize(dir) {
1224
+ let total = 0;
1225
+ const walkDir = (d) => {
1226
+ let names;
1227
+ try {
1228
+ names = fs.readdirSync(d);
1229
+ }
1230
+ catch {
1231
+ return;
1232
+ }
1233
+ for (const name of names) {
1234
+ const p = path.join(d, name);
1235
+ let st;
1236
+ try {
1237
+ st = fs.lstatSync(p);
1238
+ }
1239
+ catch {
1240
+ continue;
1241
+ }
1242
+ if (st.isDirectory())
1243
+ walkDir(p);
1244
+ else
1245
+ total += st.size;
1246
+ }
1247
+ };
1248
+ walkDir(dir);
1249
+ return total;
1250
+ }
1251
+ function readCloneEntries(root) {
1252
+ let names = [];
1253
+ try {
1254
+ names = fs.readdirSync(root);
1255
+ }
1256
+ catch {
1257
+ return [];
1258
+ }
1259
+ const entries = [];
1260
+ for (const hash of names) {
1261
+ if (hash.startsWith("."))
1262
+ continue;
1263
+ const dir = path.join(root, hash);
1264
+ let st;
1265
+ try {
1266
+ st = fs.statSync(dir);
1267
+ }
1268
+ catch {
1269
+ continue;
1270
+ }
1271
+ if (!st.isDirectory())
1272
+ continue;
1273
+ let meta = {};
1274
+ try {
1275
+ meta = JSON.parse(fs.readFileSync(path.join(dir, ".cw-clone-meta.json"), "utf8"));
1276
+ }
1277
+ catch {
1278
+ /* legacy/partial entry without meta — still listable/reclaimable */
1279
+ }
1280
+ entries.push({
1281
+ hash,
1282
+ url: typeof meta.url === "string" ? meta.url : "(unknown)",
1283
+ kind: typeof meta.kind === "string" ? meta.kind : "git",
1284
+ ref: typeof meta.ref === "string" ? meta.ref : null,
1285
+ fetchedAt: typeof meta.fetchedAt === "string" ? meta.fetchedAt : null,
1286
+ commit: typeof meta.commit === "string" ? meta.commit : null,
1287
+ bytes: dirSize(dir),
1288
+ });
1289
+ }
1290
+ entries.sort((a, b) => (a.fetchedAt || "").localeCompare(b.fetchedAt || ""));
1291
+ return entries;
1292
+ }
1293
+ /** `cw clones list` — every cached remote checkout with its origin,
1294
+ * commit, age, and size. */
1295
+ function listClones(env = process.env) {
1296
+ const root = clonesRoot(env);
1297
+ const entries = readCloneEntries(root);
1298
+ return { schemaVersion: 1, clonesDir: root, count: entries.length, totalBytes: entries.reduce((sum, e) => sum + e.bytes, 0), entries };
1299
+ }
1300
+ /** `cw clones gc [--older-than-days N] [--all]` — reclaim cached
1301
+ * checkouts. Default keeps entries fetched within the last 30 days;
1302
+ * `--all` removes every entry. Deletes ONLY paths proven inside the
1303
+ * clones root (fail closed). */
1304
+ function gcClones(options = {}, env = process.env) {
1305
+ const root = clonesRoot(env);
1306
+ const all = isTrue(options.all);
1307
+ let olderThanDays = null;
1308
+ if (!all) {
1309
+ const raw = options.olderThanDays;
1310
+ olderThanDays = optionalNumber(raw) ?? 30;
1311
+ if (!Number.isFinite(olderThanDays) || olderThanDays < 0) {
1312
+ throw new Error(`--older-than-days must be a non-negative number (got ${String(raw)})`);
1313
+ }
1314
+ }
1315
+ let now = Date.now();
1316
+ if (options.now !== undefined) {
1317
+ now = new Date(String(options.now)).getTime();
1318
+ if (!Number.isFinite(now))
1319
+ throw new Error(`--now must be a valid ISO date (got ${String(options.now)})`);
1320
+ }
1321
+ const cutoff = olderThanDays != null ? now - olderThanDays * 24 * 60 * 60 * 1000 : Infinity;
1322
+ const rootResolved = path.resolve(root);
1323
+ const removed = [];
1324
+ let freedBytes = 0;
1325
+ const entries = readCloneEntries(root);
1326
+ for (const entry of entries) {
1327
+ if (!all) {
1328
+ if (!entry.fetchedAt)
1329
+ continue;
1330
+ const age = new Date(entry.fetchedAt).getTime();
1331
+ if (!Number.isFinite(age) || age > cutoff)
1332
+ continue;
1333
+ }
1334
+ const dir = path.join(root, entry.hash);
1335
+ if (!path.resolve(dir).startsWith(rootResolved + path.sep))
1336
+ continue; // containment, fail closed
1337
+ fs.rmSync(dir, { recursive: true, force: true });
1338
+ removed.push({ hash: entry.hash, url: entry.url, bytes: entry.bytes });
1339
+ freedBytes += entry.bytes;
1340
+ }
1341
+ return { schemaVersion: 1, clonesDir: root, removed, freedBytes, keptCount: entries.length - removed.length, olderThanDays, all };
1342
+ }
1343
+ function formatClonesList(result) {
1344
+ if (result.count === 0)
1345
+ return `No cached remote checkouts in ${result.clonesDir}.`;
1346
+ const rows = result.entries.map((e) => {
1347
+ const when = e.fetchedAt ? e.fetchedAt.replace("T", " ").replace(/\..*$/, "Z") : "unknown";
1348
+ return ` ${e.kind.padEnd(7)} ${humanBytesLocal(e.bytes).padStart(8)} ${when} ${e.url}${e.ref ? `@${e.ref}` : ""}`;
1349
+ });
1350
+ return [
1351
+ `${result.count} cached checkout${result.count === 1 ? "" : "s"} — ${humanBytesLocal(result.totalBytes)} in ${result.clonesDir}`,
1352
+ " KIND SIZE FETCHED SOURCE",
1353
+ ...rows,
1354
+ `\nReclaim with: cw clones gc --older-than-days 30 (or --all)`,
1355
+ ].join("\n");
1356
+ }
1357
+ function formatClonesGc(result) {
1358
+ const scope = result.all ? "all entries" : `entries older than ${result.olderThanDays} day(s)`;
1359
+ if (result.removed.length === 0)
1360
+ return `Nothing to reclaim (${scope}); ${result.keptCount} kept in ${result.clonesDir}.`;
1361
+ const rows = result.removed.map((r) => ` ${humanBytesLocal(r.bytes).padStart(8)} ${r.url}`);
1362
+ return [
1363
+ `Reclaimed ${result.removed.length} checkout${result.removed.length === 1 ? "" : "s"} (${scope}) — freed ${humanBytesLocal(result.freedBytes)}; ${result.keptCount} kept`,
1364
+ ...rows,
1365
+ ].join("\n");
1366
+ }