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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +11 -2
- package/apps/architecture-review/app.json +1 -1
- package/apps/architecture-review-fast/app.json +1 -1
- package/apps/end-to-end-golden-path/app.json +1 -1
- package/apps/pr-review-fix-ci/app.json +1 -1
- package/apps/release-cut/app.json +1 -1
- package/apps/research-synthesis/app.json +1 -1
- package/dist/cli/dispatch.js +236 -0
- package/dist/cli/entry.js +120 -0
- package/dist/cli/io.js +21 -4
- package/dist/cli/parseargv.js +157 -0
- package/dist/cli.js +6 -33
- package/dist/core/capability-table.js +3534 -0
- package/dist/core/format/help.js +314 -0
- package/dist/{state-explosion/format.js → core/format/state-explosion-text.js} +10 -1
- package/dist/core/hash.js +137 -0
- package/dist/core/multi-agent/candidate-scoring.js +219 -0
- package/dist/core/multi-agent/collaboration.js +481 -0
- package/dist/core/multi-agent/coordinator.js +515 -0
- package/dist/core/multi-agent/eval-replay.js +306 -0
- package/dist/core/multi-agent/runtime.js +929 -0
- package/dist/core/multi-agent/topology.js +298 -0
- package/dist/core/multi-agent/trust-policy.js +197 -0
- package/dist/core/pipeline/commit-gate.js +320 -0
- package/dist/{pipeline-contract.js → core/pipeline/contract.js} +24 -37
- package/dist/core/pipeline/dispatch.js +103 -0
- package/dist/core/pipeline/drive-decide.js +227 -0
- package/dist/core/pipeline/error-feedback.js +161 -0
- package/dist/core/pipeline/loop-expansion.js +124 -0
- package/dist/{result-normalize.js → core/pipeline/result-normalize.js} +14 -37
- package/dist/core/pipeline/runner.js +230 -0
- package/dist/{contract-migration.js → core/state/contract-migration.js} +68 -92
- package/dist/{state-migrations.js → core/state/migrations.js} +103 -96
- package/dist/core/state/node-projection.js +95 -0
- package/dist/core/state/node-snapshot.js +230 -0
- package/dist/core/state/run-paths.js +93 -0
- package/dist/{schema-validate.js → core/state/schema-validate.js} +35 -28
- package/dist/core/state/schema.js +50 -0
- package/dist/core/state/state-explosion/digest.js +243 -0
- package/dist/core/state/state-explosion/graph.js +527 -0
- package/dist/{state-explosion → core/state/state-explosion}/helpers.js +34 -3
- package/dist/core/state/state-explosion/report.js +187 -0
- package/dist/{state-explosion → core/state/state-explosion}/size.js +25 -7
- package/dist/{state-node.js → core/state/state-node.js} +90 -113
- package/dist/core/state/types.js +19 -0
- package/dist/{validation.js → core/state/validation.js} +64 -131
- package/dist/core/trust/evidence-grounding.js +134 -0
- package/dist/core/trust/ledger.js +199 -0
- package/dist/{telemetry-attestation.js → core/trust/telemetry-attestation.js} +94 -68
- package/dist/core/trust/telemetry-ledger.js +127 -0
- package/dist/core/types.js +20 -0
- package/dist/core/version.js +16 -0
- package/dist/{workflow-app-framework.js → core/workflow-apps/app-schema.js} +337 -426
- package/dist/mcp/dispatch.js +104 -0
- package/dist/mcp/server.js +144 -0
- package/dist/mcp-server.js +6 -84
- package/dist/{agent-config.js → shell/agent-config.js} +118 -71
- package/dist/shell/app-run-cli.js +88 -0
- package/dist/shell/audit-cli.js +259 -0
- package/dist/shell/audit-provenance.js +83 -0
- package/dist/shell/candidate-scoring-io.js +459 -0
- package/dist/shell/collaboration-io.js +264 -0
- package/dist/shell/commit-summary.js +104 -0
- package/dist/shell/commit.js +290 -0
- package/dist/shell/coordinator-io.js +476 -0
- package/dist/shell/demo-cli.js +19 -0
- package/dist/shell/dispatch.js +162 -0
- package/dist/{doctor.js → shell/doctor.js} +123 -56
- package/dist/shell/drive.js +873 -0
- package/dist/shell/error-feedback-io.js +305 -0
- package/dist/shell/eval-io.js +473 -0
- package/dist/{multi-agent-eval/format.js → shell/eval-text.js} +78 -49
- package/dist/{evidence-reasoning.js → shell/evidence-reasoning.js} +124 -255
- package/dist/shell/exec-backend-cli.js +88 -0
- package/dist/shell/execution-backend/agent.js +475 -0
- package/dist/shell/execution-backend/ci.js +15 -0
- package/dist/shell/execution-backend/container.js +69 -0
- package/dist/shell/execution-backend/envelopes.js +55 -0
- package/dist/shell/execution-backend/local.js +113 -0
- package/dist/shell/execution-backend/probes.js +175 -0
- package/dist/shell/execution-backend/registry.js +402 -0
- package/dist/shell/execution-backend/remote.js +128 -0
- package/dist/shell/execution-backend/types.js +11 -0
- package/dist/shell/feedback-cli.js +81 -0
- package/dist/shell/feedback-operations.js +48 -0
- package/dist/shell/fs-atomic.js +276 -0
- package/dist/shell/harness.js +98 -0
- package/dist/shell/ledger-cli.js +212 -0
- package/dist/shell/ledger-io.js +169 -0
- package/dist/shell/man-cli.js +89 -0
- package/dist/shell/metrics-cli.js +98 -0
- package/dist/shell/multi-agent-cli.js +1002 -0
- package/dist/shell/multi-agent-host.js +563 -0
- package/dist/shell/multi-agent-io.js +387 -0
- package/dist/{multi-agent-operator-ux.js → shell/multi-agent-operator-ux.js} +234 -191
- package/dist/shell/node-store.js +124 -0
- package/dist/{observability/format.js → shell/observability-format.js} +7 -1
- package/dist/{observability/intake.js → shell/observability-intake.js} +79 -56
- package/dist/{observability.js → shell/observability.js} +159 -332
- package/dist/{onramp.js → shell/onramp.js} +11 -0
- package/dist/{operator-ux/format.js → shell/operator-ux-text.js} +250 -239
- package/dist/shell/operator-ux.js +431 -0
- package/dist/shell/orchestrator.js +231 -0
- package/dist/shell/pipeline-cli.js +667 -0
- package/dist/shell/pipeline.js +217 -0
- package/dist/shell/reclamation-io.js +1366 -0
- package/dist/shell/registry-cli.js +329 -0
- package/dist/{remote-source.js → shell/remote-source.js} +12 -5
- package/dist/shell/report-cli.js +101 -0
- package/dist/shell/report-view-cli.js +117 -0
- package/dist/{orchestrator → shell}/report.js +289 -282
- package/dist/shell/reporter.js +62 -0
- package/dist/shell/run-export-cli.js +106 -0
- package/dist/shell/run-export.js +680 -0
- package/dist/shell/run-registry-io.js +1014 -0
- package/dist/shell/run-store.js +164 -0
- package/dist/{sandbox-profile.js → shell/sandbox-profile.js} +134 -95
- package/dist/{scheduler.js → shell/scheduler-io.js} +248 -48
- package/dist/shell/scheduling-io.js +311 -0
- package/dist/shell/state-cli.js +181 -0
- package/dist/shell/state-explosion-cli.js +197 -0
- package/dist/shell/telemetry-cli.js +85 -0
- package/dist/{telemetry-demo.js → shell/telemetry-demo.js} +149 -119
- package/dist/shell/telemetry-ledger-io.js +132 -0
- package/dist/{term.js → shell/term.js} +36 -46
- package/dist/shell/topology-io.js +361 -0
- package/dist/shell/trust-audit.js +471 -0
- package/dist/{multi-agent-trust.js → shell/trust-policy-io.js} +67 -205
- package/dist/shell/verifier.js +48 -0
- package/dist/shell/workbench-host.js +250 -0
- package/dist/shell/workbench-text.js +18 -0
- package/dist/shell/workbench.js +175 -0
- package/dist/shell/worker-cli.js +124 -0
- package/dist/shell/worker-isolation.js +852 -0
- package/dist/shell/workflow-app-loader.js +650 -0
- package/docs/agent-delegation-drive.7.md +4 -0
- package/docs/cli-mcp-parity.7.md +284 -211
- package/docs/contract-migration-tooling.7.md +4 -0
- package/docs/control-plane-scheduling.7.md +4 -0
- package/docs/cross-agent-ledger.7.md +217 -0
- package/docs/designs/handoff-ledger.md +145 -0
- package/docs/durable-state-and-locking.7.md +4 -0
- package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
- package/docs/execution-backends.7.md +4 -0
- package/docs/handoff-setup.md +120 -0
- package/docs/multi-agent-cli-mcp-surface.7.md +4 -0
- package/docs/multi-agent-eval-replay-harness.7.md +4 -0
- package/docs/multi-agent-operator-ux.7.md +4 -0
- package/docs/node-snapshot-diff-replay.7.md +4 -0
- package/docs/observability-cost-accounting.7.md +4 -0
- package/docs/project-index.md +143 -71
- package/docs/real-execution-backends.7.md +4 -0
- package/docs/release-and-migration.7.md +4 -0
- package/docs/release-tooling.7.md +4 -0
- package/docs/run-registry-control-plane.7.md +4 -0
- package/docs/run-retention-reclamation.7.md +25 -0
- package/docs/state-explosion-management.7.md +4 -0
- package/docs/team-collaboration.7.md +4 -0
- package/docs/web-desktop-workbench.7.md +4 -0
- package/manifest/plugin.manifest.json +1 -1
- package/manifest/source-context-profiles.json +9 -13
- package/package.json +1 -1
- package/scripts/agents/codex-agent.js +34 -4
- package/scripts/agents/cw-attest-wrap.js +2 -2
- package/scripts/bump-version.js +4 -3
- package/scripts/canonical-apps.js +4 -4
- package/scripts/children/batch-delegate-child.js +30 -10
- package/scripts/dogfood-architecture-review.js +2 -3
- package/scripts/dogfood-release.js +3 -3
- package/scripts/gen-parity-doc.js +15 -2
- package/scripts/golden-path.js +4 -4
- package/scripts/onramp-check.js +1 -1
- package/scripts/parity-check.js +38 -21
- package/scripts/release-flow.js +9 -3
- package/scripts/sync-project-index.js +51 -27
- package/scripts/validate-run-state-schema.js +2 -2
- package/scripts/version-sync-check.js +30 -30
- package/dist/candidate-scoring.js +0 -729
- package/dist/capability-core.js +0 -1186
- package/dist/capability-registry.js +0 -879
- package/dist/cli/command-surface.js +0 -490
- package/dist/cli/format.js +0 -56
- package/dist/cli/handlers/audit.js +0 -82
- package/dist/cli/handlers/blackboard.js +0 -81
- package/dist/cli/handlers/candidate.js +0 -40
- package/dist/cli/handlers/clones.js +0 -34
- package/dist/cli/handlers/collaboration.js +0 -61
- package/dist/cli/handlers/eval.js +0 -40
- package/dist/cli/handlers/maintenance.js +0 -107
- package/dist/cli/handlers/multi-agent.js +0 -165
- package/dist/cli/handlers/node.js +0 -41
- package/dist/cli/handlers/operational.js +0 -155
- package/dist/cli/handlers/operator.js +0 -146
- package/dist/cli/handlers/registry.js +0 -68
- package/dist/cli/handlers/run.js +0 -153
- package/dist/cli/handlers/scheduling.js +0 -132
- package/dist/cli/handlers/workbench.js +0 -41
- package/dist/cli/handlers/worker.js +0 -45
- package/dist/cli/run-summary.js +0 -45
- package/dist/clones.js +0 -162
- package/dist/collaboration.js +0 -726
- package/dist/commit.js +0 -592
- package/dist/compare.js +0 -18
- package/dist/coordinator/classify.js +0 -45
- package/dist/coordinator/paths.js +0 -42
- package/dist/coordinator/util.js +0 -126
- package/dist/coordinator.js +0 -990
- package/dist/daemon.js +0 -44
- package/dist/dispatch.js +0 -250
- package/dist/drive.js +0 -827
- package/dist/error-feedback.js +0 -419
- package/dist/evidence-grounding.js +0 -184
- package/dist/execution-backend/agent.js +0 -294
- package/dist/execution-backend/probes.js +0 -112
- package/dist/execution-backend/util.js +0 -47
- package/dist/execution-backend.js +0 -961
- package/dist/gates.js +0 -48
- package/dist/harness.js +0 -61
- package/dist/loop-expansion.js +0 -60
- package/dist/mcp/tool-call.js +0 -434
- package/dist/mcp/tool-definitions.js +0 -1040
- package/dist/mcp-surface.js +0 -30
- package/dist/multi-agent/graph.js +0 -84
- package/dist/multi-agent/helpers.js +0 -141
- package/dist/multi-agent/ids.js +0 -20
- package/dist/multi-agent/paths.js +0 -22
- package/dist/multi-agent-eval/normalize.js +0 -51
- package/dist/multi-agent-eval.js +0 -678
- package/dist/multi-agent-host.js +0 -777
- package/dist/multi-agent.js +0 -984
- package/dist/node-projection.js +0 -59
- package/dist/node-snapshot.js +0 -260
- package/dist/operator-ux.js +0 -631
- package/dist/orchestrator/app-operations.js +0 -211
- package/dist/orchestrator/audit-operations.js +0 -182
- package/dist/orchestrator/candidate-operations.js +0 -117
- package/dist/orchestrator/cli-options.js +0 -294
- package/dist/orchestrator/collaboration-operations.js +0 -86
- package/dist/orchestrator/feedback-operations.js +0 -81
- package/dist/orchestrator/host-operations.js +0 -78
- package/dist/orchestrator/lifecycle-operations.js +0 -626
- package/dist/orchestrator/migration-operations.js +0 -44
- package/dist/orchestrator/multi-agent-operations.js +0 -362
- package/dist/orchestrator/topology-operations.js +0 -84
- package/dist/orchestrator.js +0 -917
- package/dist/pipeline-runner.js +0 -285
- package/dist/reclamation/hash.js +0 -72
- package/dist/reclamation.js +0 -812
- package/dist/reporter.js +0 -67
- package/dist/run-export.js +0 -784
- package/dist/run-registry/derive.js +0 -175
- package/dist/run-registry/format.js +0 -124
- package/dist/run-registry/gc.js +0 -251
- package/dist/run-registry/policy.js +0 -16
- package/dist/run-registry/queue.js +0 -115
- package/dist/run-registry.js +0 -850
- package/dist/run-state-schema.js +0 -68
- package/dist/scheduling.js +0 -184
- package/dist/state-explosion.js +0 -1014
- package/dist/state.js +0 -367
- package/dist/telemetry-ledger.js +0 -196
- package/dist/topology.js +0 -565
- package/dist/triggers.js +0 -184
- package/dist/trust-audit.js +0 -644
- package/dist/types/blackboard.js +0 -2
- package/dist/types/candidate.js +0 -2
- package/dist/types/collaboration.js +0 -2
- package/dist/types/core.js +0 -2
- package/dist/types/drive.js +0 -10
- package/dist/types/error-feedback.js +0 -2
- package/dist/types/evidence-reasoning.js +0 -2
- package/dist/types/execution-backend.js +0 -2
- package/dist/types/multi-agent.js +0 -2
- package/dist/types/observability.js +0 -2
- package/dist/types/pipeline.js +0 -2
- package/dist/types/reclamation.js +0 -8
- package/dist/types/report-bundle.js +0 -6
- package/dist/types/result.js +0 -2
- package/dist/types/run-registry.js +0 -2
- package/dist/types/run.js +0 -2
- package/dist/types/sandbox.js +0 -2
- package/dist/types/schedule.js +0 -2
- package/dist/types/state-node.js +0 -2
- package/dist/types/topology.js +0 -2
- package/dist/types/trust.js +0 -2
- package/dist/types/workbench.js +0 -2
- package/dist/types/worker.js +0 -2
- package/dist/types/workflow-app.js +0 -2
- package/dist/types.js +0 -44
- package/dist/util/fingerprint.js +0 -19
- package/dist/util/fingerprint.test.js +0 -27
- package/dist/verifier.js +0 -78
- package/dist/version.js +0 -8
- package/dist/workbench-host.js +0 -182
- package/dist/workbench.js +0 -192
- package/dist/worker-accept/acceptance.js +0 -114
- package/dist/worker-accept/blackboard-fanout.js +0 -80
- package/dist/worker-accept/blackboard-linkage.js +0 -19
- package/dist/worker-accept/context.js +0 -2
- package/dist/worker-accept/telemetry-ledger.js +0 -126
- package/dist/worker-accept/validation.js +0 -77
- package/dist/worker-accept/verifier-completion.js +0 -73
- package/dist/worker-isolation/helpers.js +0 -51
- package/dist/worker-isolation/paths.js +0 -46
- package/dist/worker-isolation.js +0 -656
- package/dist/workflow-api.js +0 -131
- /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
|
+
}
|