cool-workflow 0.1.78
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 +20 -0
- package/.codex-plugin/mcp.json +10 -0
- package/.codex-plugin/plugin.json +38 -0
- package/.mcp.json +10 -0
- package/LICENSE +24 -0
- package/README.md +638 -0
- package/apps/architecture-review/app.json +51 -0
- package/apps/architecture-review/workflow.js +116 -0
- package/apps/end-to-end-golden-path/app.json +30 -0
- package/apps/end-to-end-golden-path/workflow.js +33 -0
- package/apps/pr-review-fix-ci/app.json +59 -0
- package/apps/pr-review-fix-ci/workflow.js +90 -0
- package/apps/release-cut/app.json +54 -0
- package/apps/release-cut/workflow.js +82 -0
- package/apps/research-synthesis/app.json +50 -0
- package/apps/research-synthesis/workflow.js +76 -0
- package/apps/workflow-app-framework-demo/app.json +29 -0
- package/apps/workflow-app-framework-demo/workflow.js +44 -0
- package/dist/agent-config.js +223 -0
- package/dist/candidate-scoring.js +715 -0
- package/dist/capability-core.js +630 -0
- package/dist/capability-dispatcher.js +86 -0
- package/dist/capability-registry.js +523 -0
- package/dist/cli.js +1276 -0
- package/dist/collaboration.js +727 -0
- package/dist/commit.js +570 -0
- package/dist/contract-migration.js +234 -0
- package/dist/coordinator.js +1163 -0
- package/dist/daemon.js +44 -0
- package/dist/dispatch.js +201 -0
- package/dist/drive.js +503 -0
- package/dist/error-feedback.js +415 -0
- package/dist/evidence-grounding.js +179 -0
- package/dist/evidence-reasoning.js +733 -0
- package/dist/execution-backend.js +1279 -0
- package/dist/harness.js +61 -0
- package/dist/mcp-server.js +1615 -0
- package/dist/multi-agent-eval.js +857 -0
- package/dist/multi-agent-host.js +764 -0
- package/dist/multi-agent-operator-ux.js +537 -0
- package/dist/multi-agent-trust.js +366 -0
- package/dist/multi-agent.js +1173 -0
- package/dist/node-snapshot.js +270 -0
- package/dist/observability.js +922 -0
- package/dist/operator-ux.js +971 -0
- package/dist/orchestrator/audit-operations.js +182 -0
- package/dist/orchestrator/candidate-operations.js +117 -0
- package/dist/orchestrator/cli-options.js +288 -0
- package/dist/orchestrator/collaboration-operations.js +86 -0
- package/dist/orchestrator/feedback-operations.js +81 -0
- package/dist/orchestrator/host-operations.js +78 -0
- package/dist/orchestrator/lifecycle-operations.js +462 -0
- package/dist/orchestrator/migration-operations.js +44 -0
- package/dist/orchestrator/multi-agent-operations.js +362 -0
- package/dist/orchestrator/report.js +369 -0
- package/dist/orchestrator/topology-operations.js +84 -0
- package/dist/orchestrator.js +874 -0
- package/dist/pipeline-contract.js +92 -0
- package/dist/pipeline-runner.js +285 -0
- package/dist/reclamation.js +882 -0
- package/dist/result-normalize.js +194 -0
- package/dist/run-export.js +64 -0
- package/dist/run-registry.js +1347 -0
- package/dist/run-state-schema.js +67 -0
- package/dist/sandbox-profile.js +471 -0
- package/dist/scheduler.js +266 -0
- package/dist/scheduling.js +184 -0
- package/dist/schema-validate.js +98 -0
- package/dist/state-explosion.js +1213 -0
- package/dist/state-migrations.js +463 -0
- package/dist/state-node.js +301 -0
- package/dist/state.js +308 -0
- package/dist/telemetry-attestation.js +156 -0
- package/dist/telemetry-ledger.js +145 -0
- package/dist/topology.js +527 -0
- package/dist/triggers.js +159 -0
- package/dist/trust-audit.js +475 -0
- package/dist/types/blackboard.js +2 -0
- package/dist/types/boundary.js +29 -0
- package/dist/types/candidate.js +2 -0
- package/dist/types/collaboration.js +2 -0
- package/dist/types/core.js +2 -0
- package/dist/types/drive.js +10 -0
- package/dist/types/error-feedback.js +2 -0
- package/dist/types/evidence-reasoning.js +2 -0
- package/dist/types/execution-backend.js +2 -0
- package/dist/types/multi-agent.js +2 -0
- package/dist/types/observability.js +2 -0
- package/dist/types/pipeline.js +2 -0
- package/dist/types/reclamation.js +8 -0
- package/dist/types/result.js +2 -0
- package/dist/types/run-registry.js +2 -0
- package/dist/types/run.js +2 -0
- package/dist/types/sandbox.js +2 -0
- package/dist/types/schedule.js +2 -0
- package/dist/types/state-node.js +2 -0
- package/dist/types/topology.js +2 -0
- package/dist/types/trust.js +2 -0
- package/dist/types/workbench.js +2 -0
- package/dist/types/worker.js +2 -0
- package/dist/types/workflow-app.js +2 -0
- package/dist/types.js +43 -0
- package/dist/verifier-registry.js +46 -0
- package/dist/verifier.js +78 -0
- package/dist/version.js +8 -0
- package/dist/workbench-host.js +172 -0
- package/dist/workbench.js +190 -0
- package/dist/worker-isolation.js +1028 -0
- package/dist/workflow-api.js +98 -0
- package/dist/workflow-app-framework.js +626 -0
- package/docs/agent-delegation-drive.7.md +190 -0
- package/docs/agent-framework.md +176 -0
- package/docs/candidate-scoring.7.md +106 -0
- package/docs/canonical-workflow-apps.7.md +137 -0
- package/docs/capability-topology-registry.7.md +168 -0
- package/docs/cli-mcp-parity.7.md +373 -0
- package/docs/contract-migration-tooling.7.md +123 -0
- package/docs/control-plane-scheduling.7.md +110 -0
- package/docs/coordinator-blackboard.7.md +183 -0
- package/docs/dogfood/architecture-review-cool-workflow.md +16 -0
- package/docs/dogfood-one-real-repo.7.md +168 -0
- package/docs/durable-state-and-locking.7.md +107 -0
- package/docs/end-to-end-golden-path.7.md +117 -0
- package/docs/error-feedback.7.md +153 -0
- package/docs/evidence-adoption-reasoning-chain.7.md +270 -0
- package/docs/execution-backends.7.md +300 -0
- package/docs/getting-started.md +99 -0
- package/docs/index.md +41 -0
- package/docs/mcp-app-surface.7.md +235 -0
- package/docs/multi-agent-cli-mcp-surface.7.md +265 -0
- package/docs/multi-agent-eval-replay-harness.7.md +302 -0
- package/docs/multi-agent-operator-ux.7.md +314 -0
- package/docs/multi-agent-runtime-core.7.md +231 -0
- package/docs/multi-agent-topologies.7.md +103 -0
- package/docs/multi-agent-trust-policy-audit.7.md +154 -0
- package/docs/node-snapshot-diff-replay.7.md +135 -0
- package/docs/observability-cost-accounting.7.md +194 -0
- package/docs/operator-ux.7.md +180 -0
- package/docs/pipeline-runner.7.md +136 -0
- package/docs/project-index.md +261 -0
- package/docs/real-execution-backends.7.md +142 -0
- package/docs/release-and-migration.7.md +280 -0
- package/docs/release-tooling.7.md +159 -0
- package/docs/routines.md +48 -0
- package/docs/run-registry-control-plane.7.md +312 -0
- package/docs/run-retention-reclamation.7.md +191 -0
- package/docs/sandbox-profiles.7.md +137 -0
- package/docs/scheduled-tasks.md +80 -0
- package/docs/security-trust-hardening.7.md +117 -0
- package/docs/state-explosion-management.7.md +264 -0
- package/docs/state-node.7.md +96 -0
- package/docs/team-collaboration.7.md +207 -0
- package/docs/unix-principles.md +192 -0
- package/docs/verifier-gated-commit.7.md +140 -0
- package/docs/web-desktop-workbench.7.md +215 -0
- package/docs/worker-isolation.7.md +167 -0
- package/docs/workflow-app-framework.7.md +274 -0
- package/manifest/README.md +43 -0
- package/manifest/plugin.manifest.json +316 -0
- package/manifest/pricing.policy.json +14 -0
- package/package.json +79 -0
- package/scripts/agents/claude-p-agent.js +104 -0
- package/scripts/agents/claude-p-agent.sh +9 -0
- package/scripts/agents/cw-attest-keygen.js +55 -0
- package/scripts/agents/cw-attest-wrap.js +143 -0
- package/scripts/block-unapproved-tag.sh +39 -0
- package/scripts/bump-version.js +249 -0
- package/scripts/canonical-apps.js +171 -0
- package/scripts/cw.js +4 -0
- package/scripts/dist-drift-check.js +79 -0
- package/scripts/dogfood-architecture-review.js +237 -0
- package/scripts/dogfood-release.js +624 -0
- package/scripts/forward-ref-docs.js +73 -0
- package/scripts/gen-manifests.js +232 -0
- package/scripts/golden-path.js +300 -0
- package/scripts/mcp-server.js +4 -0
- package/scripts/new-feature.js +121 -0
- package/scripts/parity-check.js +213 -0
- package/scripts/release-check.js +118 -0
- package/scripts/release-flow.js +272 -0
- package/scripts/release-gate.sh +85 -0
- package/scripts/sync-project-index.js +387 -0
- package/scripts/validate-run-state-schema.js +126 -0
- package/scripts/verify-container-selfref.js +64 -0
- package/scripts/version-sync-check.js +237 -0
- package/skills/cool-workflow/SKILL.md +162 -0
- package/skills/cool-workflow/references/commands.md +282 -0
- package/tsconfig.json +16 -0
- package/ui/workbench/app.css +76 -0
- package/ui/workbench/app.js +159 -0
- package/ui/workbench/index.html +32 -0
- package/workflows/architecture-review.workflow.js +84 -0
- package/workflows/research-synthesis.workflow.js +47 -0
|
@@ -0,0 +1,1347 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Run Registry / Control Plane (v0.1.28) — a DERIVED, rebuildable index over the
|
|
3
|
+
// runs that live under each repo's `.cw/runs/<id>/`, plus a home-level cross-repo
|
|
4
|
+
// registry. It manages many runs across repos: search, resume, archive, queue,
|
|
5
|
+
// cross-repo history, and failed-run rerun.
|
|
6
|
+
//
|
|
7
|
+
// BSD / Unix discipline (each non-trivial choice cites its tenet):
|
|
8
|
+
//
|
|
9
|
+
// - SEPARATE MECHANISM FROM POLICY. The per-run `.cw/runs/<id>/state.json` is the
|
|
10
|
+
// SINGLE SOURCE OF TRUTH (loadRunFromCwd) and is never owned or mutated here.
|
|
11
|
+
// The registry is MECHANISM: a derived cache, rebuilt from source on demand.
|
|
12
|
+
// Retention windows, queue ordering, and archive thresholds are POLICY and live
|
|
13
|
+
// in RunRegistryPolicy / explicit flags, never baked into the index.
|
|
14
|
+
//
|
|
15
|
+
// - DERIVED, NOT AUTHORITATIVE (fail closed). Every record carries a
|
|
16
|
+
// `sourceFingerprint`; every read reports `valid|stale|absent` freshness, just
|
|
17
|
+
// like the v0.1.25 state-explosion summaries. We ALWAYS re-derive a record from
|
|
18
|
+
// source state when source is present, and surface `missing` (never a fabricated
|
|
19
|
+
// status) when it is gone. An unreadable run is never treated as success.
|
|
20
|
+
//
|
|
21
|
+
// - APPEND-ONLY HISTORY; NEVER MUTATE THE PAST. Resume continues an existing run
|
|
22
|
+
// from durable state (read-only over source). Rerun creates a NEW run that
|
|
23
|
+
// records a provenance link to the original; the failed run is preserved.
|
|
24
|
+
// Archive is an overlay mark, not a delete — source truth stays in place and
|
|
25
|
+
// stays searchable.
|
|
26
|
+
//
|
|
27
|
+
// - EXPLICIT, INSPECTABLE STATE. Cross-repo discovery and the queue are plain
|
|
28
|
+
// files under a home registry ($CW_HOME / XDG), readable and diffable. No hidden
|
|
29
|
+
// database, no daemon required to read state.
|
|
30
|
+
//
|
|
31
|
+
// - STABLE INTERFACES. Pre-v0.1.28 single-repo runs keep working with an empty /
|
|
32
|
+
// rebuildable registry; nothing about `.cw/runs/` layout changes.
|
|
33
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
34
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
35
|
+
};
|
|
36
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
+
exports.RunRegistry = exports.DEFAULT_RUN_REGISTRY_POLICY = exports.RUN_REGISTRY_SCHEMA_VERSION = void 0;
|
|
38
|
+
exports.resolveCwHome = resolveCwHome;
|
|
39
|
+
exports.deriveLifecycle = deriveLifecycle;
|
|
40
|
+
exports.compareQueue = compareQueue;
|
|
41
|
+
exports.isRunLifecycleState = isRunLifecycleState;
|
|
42
|
+
exports.formatRegistryReport = formatRegistryReport;
|
|
43
|
+
exports.formatRunSearch = formatRunSearch;
|
|
44
|
+
exports.formatRunShow = formatRunShow;
|
|
45
|
+
exports.formatGcPlan = formatGcPlan;
|
|
46
|
+
exports.formatGcRun = formatGcRun;
|
|
47
|
+
exports.formatGcVerify = formatGcVerify;
|
|
48
|
+
exports.formatResume = formatResume;
|
|
49
|
+
exports.formatHistory = formatHistory;
|
|
50
|
+
exports.formatQueueList = formatQueueList;
|
|
51
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
52
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
53
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
54
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
55
|
+
const state_1 = require("./state");
|
|
56
|
+
const reclamation_1 = require("./reclamation");
|
|
57
|
+
exports.RUN_REGISTRY_SCHEMA_VERSION = 1;
|
|
58
|
+
const LIFECYCLE_STATES = [
|
|
59
|
+
"queued",
|
|
60
|
+
"running",
|
|
61
|
+
"blocked",
|
|
62
|
+
"completed",
|
|
63
|
+
"failed",
|
|
64
|
+
"archived",
|
|
65
|
+
"reclaimed"
|
|
66
|
+
];
|
|
67
|
+
// POLICY defaults. Configurable; never baked into the index. archiveOlderThanDays
|
|
68
|
+
// = 0 disables retention archiving (explicit selection still works). The v0.1.39
|
|
69
|
+
// reclamation knobs all default to RECLAIM NOTHING (back-compatible, opt-in).
|
|
70
|
+
exports.DEFAULT_RUN_REGISTRY_POLICY = {
|
|
71
|
+
schemaVersion: 1,
|
|
72
|
+
archiveOlderThanDays: 0,
|
|
73
|
+
archiveStates: ["completed", "failed"],
|
|
74
|
+
defaultQueuePriority: 100,
|
|
75
|
+
reclaimAfterArchiveDays: 0,
|
|
76
|
+
reclaimStates: ["completed", "failed"],
|
|
77
|
+
keepSnapshots: false,
|
|
78
|
+
keepScratch: false,
|
|
79
|
+
maxReclaimRuns: 0,
|
|
80
|
+
maxReclaimBytes: 0
|
|
81
|
+
};
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Home registry location (EXPLICIT, INSPECTABLE STATE)
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
/** Resolve the home registry root: CW_HOME, then XDG_STATE_HOME/cool-workflow,
|
|
86
|
+
* then ~/.local/state/cool-workflow. Always a plain directory of plain files. */
|
|
87
|
+
function resolveCwHome(env = process.env) {
|
|
88
|
+
if (env.CW_HOME && String(env.CW_HOME).trim())
|
|
89
|
+
return node_path_1.default.resolve(String(env.CW_HOME));
|
|
90
|
+
if (env.XDG_STATE_HOME && String(env.XDG_STATE_HOME).trim()) {
|
|
91
|
+
return node_path_1.default.join(node_path_1.default.resolve(String(env.XDG_STATE_HOME)), "cool-workflow");
|
|
92
|
+
}
|
|
93
|
+
return node_path_1.default.join(node_os_1.default.homedir(), ".local", "state", "cool-workflow");
|
|
94
|
+
}
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Fingerprints (same shape/strength as state-explosion's)
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
function fingerprintStrings(values) {
|
|
99
|
+
const hash = node_crypto_1.default.createHash("sha256");
|
|
100
|
+
hash.update(JSON.stringify([...values].sort()));
|
|
101
|
+
return `sha256:${hash.digest("hex").slice(0, 32)}`;
|
|
102
|
+
}
|
|
103
|
+
/** Content fingerprint of a run's source state.json. Structural, not just mtime,
|
|
104
|
+
* so a tampered task status trips `stale` even if updatedAt is unchanged. */
|
|
105
|
+
function fingerprintRun(run) {
|
|
106
|
+
const parts = [
|
|
107
|
+
`id:${run.id}`,
|
|
108
|
+
`updatedAt:${run.updatedAt}`,
|
|
109
|
+
`loopStage:${run.loopStage}`,
|
|
110
|
+
`schema:${run.schemaVersion}`
|
|
111
|
+
];
|
|
112
|
+
for (const task of [...run.tasks].sort((a, b) => a.id.localeCompare(b.id))) {
|
|
113
|
+
parts.push(`task:${task.id}:${task.status}`);
|
|
114
|
+
}
|
|
115
|
+
for (const commit of [...run.commits].sort((a, b) => a.id.localeCompare(b.id))) {
|
|
116
|
+
parts.push(`commit:${commit.id}:${commit.verifierGated ? "gated" : "checkpoint"}`);
|
|
117
|
+
}
|
|
118
|
+
for (const phase of [...run.phases].sort((a, b) => a.id.localeCompare(b.id))) {
|
|
119
|
+
parts.push(`phase:${phase.id}:${phase.status}`);
|
|
120
|
+
}
|
|
121
|
+
for (const fb of [...(run.feedback || [])].sort((a, b) => a.id.localeCompare(b.id))) {
|
|
122
|
+
parts.push(`feedback:${fb.id}:${fb.status}`);
|
|
123
|
+
}
|
|
124
|
+
return fingerprintStrings(parts);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Classify a run's lifecycle purely from its source state. First match wins:
|
|
128
|
+
* 1. running > 0 -> running
|
|
129
|
+
* 2. openFeedback > 0 -> blocked (failures under correction)
|
|
130
|
+
* 3. failed > 0 -> failed
|
|
131
|
+
* 4. total > 0 && completed === total -> completed
|
|
132
|
+
* 5. verifierGatedCommits > 0 && pending === 0 -> completed (commit-only runs)
|
|
133
|
+
* 6. completed > 0 -> running (mid-flight)
|
|
134
|
+
* 7. otherwise -> queued
|
|
135
|
+
* The classifier never invents status; `archived` is applied as an overlay on
|
|
136
|
+
* top of this by deriveRecord, which keeps `derivedLifecycle` for search.
|
|
137
|
+
*/
|
|
138
|
+
function deriveLifecycle(input) {
|
|
139
|
+
if (input.running > 0)
|
|
140
|
+
return "running";
|
|
141
|
+
if (input.openFeedback > 0)
|
|
142
|
+
return "blocked";
|
|
143
|
+
if (input.failed > 0)
|
|
144
|
+
return "failed";
|
|
145
|
+
if (input.total > 0 && input.completed === input.total)
|
|
146
|
+
return "completed";
|
|
147
|
+
if (input.verifierGatedCommits > 0 && input.pending === 0)
|
|
148
|
+
return "completed";
|
|
149
|
+
if (input.completed > 0)
|
|
150
|
+
return "running";
|
|
151
|
+
return "queued";
|
|
152
|
+
}
|
|
153
|
+
function lifecycleInputs(run) {
|
|
154
|
+
const tasks = run.tasks || [];
|
|
155
|
+
return {
|
|
156
|
+
total: tasks.length,
|
|
157
|
+
pending: tasks.filter((t) => t.status === "pending").length,
|
|
158
|
+
running: tasks.filter((t) => t.status === "running").length,
|
|
159
|
+
failed: tasks.filter((t) => t.status === "failed").length,
|
|
160
|
+
completed: tasks.filter((t) => t.status === "completed").length,
|
|
161
|
+
verifierGatedCommits: (run.commits || []).filter((c) => c.verifierGated).length,
|
|
162
|
+
openFeedback: (run.feedback || []).filter((f) => f.status === "open" || f.status === "tasked").length,
|
|
163
|
+
loopStage: run.loopStage
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// The registry
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
class RunRegistry {
|
|
170
|
+
repoRoot;
|
|
171
|
+
homeRoot;
|
|
172
|
+
planner;
|
|
173
|
+
constructor(cwd = process.cwd(), planner, env = process.env) {
|
|
174
|
+
this.repoRoot = node_path_1.default.resolve(cwd);
|
|
175
|
+
this.homeRoot = resolveCwHome(env);
|
|
176
|
+
this.planner = planner;
|
|
177
|
+
}
|
|
178
|
+
// ---- path helpers -------------------------------------------------------
|
|
179
|
+
repoRunsDir(repo) {
|
|
180
|
+
return node_path_1.default.join(repo, ".cw", "runs");
|
|
181
|
+
}
|
|
182
|
+
repoRegistryDir(repo) {
|
|
183
|
+
return node_path_1.default.join(repo, ".cw", "registry");
|
|
184
|
+
}
|
|
185
|
+
homeRegistryDir() {
|
|
186
|
+
return node_path_1.default.join(this.homeRoot, "registry");
|
|
187
|
+
}
|
|
188
|
+
// ---- per-repo overlays (plain files) ------------------------------------
|
|
189
|
+
loadArchiveOverlay(repo) {
|
|
190
|
+
const file = node_path_1.default.join(this.repoRegistryDir(repo), "archive.json");
|
|
191
|
+
if (!node_fs_1.default.existsSync(file))
|
|
192
|
+
return { schemaVersion: 1, archived: {} };
|
|
193
|
+
try {
|
|
194
|
+
const parsed = (0, state_1.readJson)(file);
|
|
195
|
+
return { schemaVersion: 1, archived: parsed.archived || {} };
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return { schemaVersion: 1, archived: {} };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
loadProvenanceOverlay(repo) {
|
|
202
|
+
const file = node_path_1.default.join(this.repoRegistryDir(repo), "provenance.json");
|
|
203
|
+
if (!node_fs_1.default.existsSync(file))
|
|
204
|
+
return { schemaVersion: 1, links: {} };
|
|
205
|
+
try {
|
|
206
|
+
const parsed = (0, state_1.readJson)(file);
|
|
207
|
+
return { schemaVersion: 1, links: parsed.links || {} };
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return { schemaVersion: 1, links: {} };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// ---- home registry files ------------------------------------------------
|
|
214
|
+
reposFilePath() {
|
|
215
|
+
return node_path_1.default.join(this.homeRegistryDir(), "repos.json");
|
|
216
|
+
}
|
|
217
|
+
loadRepos() {
|
|
218
|
+
const file = this.reposFilePath();
|
|
219
|
+
if (!node_fs_1.default.existsSync(file))
|
|
220
|
+
return { schemaVersion: 1, repos: [] };
|
|
221
|
+
try {
|
|
222
|
+
const parsed = (0, state_1.readJson)(file);
|
|
223
|
+
return { schemaVersion: 1, repos: Array.isArray(parsed.repos) ? parsed.repos : [] };
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return { schemaVersion: 1, repos: [] };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/** Persisted union of registered repo roots and the current repo, deduped and
|
|
230
|
+
* sorted. Read-only: does NOT write repos.json (reads stay pure). */
|
|
231
|
+
knownRepos() {
|
|
232
|
+
const roots = new Set([this.repoRoot]);
|
|
233
|
+
for (const entry of this.loadRepos().repos)
|
|
234
|
+
roots.add(node_path_1.default.resolve(entry.root));
|
|
235
|
+
return [...roots].sort();
|
|
236
|
+
}
|
|
237
|
+
/** Register a repo root into the home repos.json (idempotent). Only mutating
|
|
238
|
+
* operations call this; reads never do. */
|
|
239
|
+
registerRepo(repo = this.repoRoot) {
|
|
240
|
+
const resolved = node_path_1.default.resolve(repo);
|
|
241
|
+
const file = this.reposFilePath();
|
|
242
|
+
// Cross-process read-modify-write: lock so a concurrent register can't drop a
|
|
243
|
+
// repo (v0.1.40, P1-D), and persist durably.
|
|
244
|
+
return (0, state_1.withFileLock)(file, () => {
|
|
245
|
+
const current = this.loadRepos();
|
|
246
|
+
const already = current.repos.some((entry) => node_path_1.default.resolve(entry.root) === resolved);
|
|
247
|
+
if (!already)
|
|
248
|
+
current.repos.push({ root: resolved, addedAt: new Date().toISOString() });
|
|
249
|
+
current.repos.sort((a, b) => a.root.localeCompare(b.root));
|
|
250
|
+
(0, state_1.writeJson)(file, current, { durable: true });
|
|
251
|
+
return { registered: !already, repos: current.repos.map((entry) => entry.root) };
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
queueFilePath() {
|
|
255
|
+
return node_path_1.default.join(this.homeRegistryDir(), "queue.json");
|
|
256
|
+
}
|
|
257
|
+
loadQueue() {
|
|
258
|
+
const file = this.queueFilePath();
|
|
259
|
+
if (!node_fs_1.default.existsSync(file))
|
|
260
|
+
return [];
|
|
261
|
+
try {
|
|
262
|
+
const parsed = (0, state_1.readJson)(file);
|
|
263
|
+
return Array.isArray(parsed.entries) ? parsed.entries : [];
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
saveQueue(entries) {
|
|
270
|
+
(0, state_1.writeJson)(this.queueFilePath(), { schemaVersion: 1, entries }, { durable: true });
|
|
271
|
+
}
|
|
272
|
+
// Public queue accessors for the v0.1.37 control-plane scheduler (it operates ON
|
|
273
|
+
// this queue store via pure functions in scheduling.ts; the queue file is never
|
|
274
|
+
// duplicated). The scheduling-policy file lives beside the queue in the home
|
|
275
|
+
// registry, plain and diffable.
|
|
276
|
+
loadQueueEntries() {
|
|
277
|
+
return this.loadQueue();
|
|
278
|
+
}
|
|
279
|
+
saveQueueEntries(entries) {
|
|
280
|
+
this.saveQueue(entries);
|
|
281
|
+
}
|
|
282
|
+
schedulingPolicyPath() {
|
|
283
|
+
return node_path_1.default.join(this.homeRegistryDir(), "scheduling-policy.json");
|
|
284
|
+
}
|
|
285
|
+
// ---- record derivation (always from source) -----------------------------
|
|
286
|
+
/** Derive a RunRecord from a run directory's source state.json. Returns the
|
|
287
|
+
* record, or null when source is unreadable/unsupported (caller decides how to
|
|
288
|
+
* surface `missing` — we never fabricate a status). */
|
|
289
|
+
deriveRecord(repo, runDir) {
|
|
290
|
+
const statePath = node_path_1.default.join(runDir, "state.json");
|
|
291
|
+
if (!node_fs_1.default.existsSync(statePath))
|
|
292
|
+
return null;
|
|
293
|
+
let run;
|
|
294
|
+
try {
|
|
295
|
+
const result = (0, state_1.loadRunStateFile)(statePath, { dryRun: true });
|
|
296
|
+
if (result.report.status === "unsupported")
|
|
297
|
+
return null;
|
|
298
|
+
run = result.run;
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
const li = lifecycleInputs(run);
|
|
304
|
+
const derived = deriveLifecycle(li);
|
|
305
|
+
const archive = this.loadArchiveOverlay(repo).archived[run.id];
|
|
306
|
+
const provenance = this.loadProvenanceOverlay(repo).links[run.id];
|
|
307
|
+
// Run Retention & Provable Reclamation (v0.1.39): the per-run reclaimed.json
|
|
308
|
+
// overlay (if any) raises the disk-tier above `archived` and downgrades the
|
|
309
|
+
// capability. Derived from source, never invented.
|
|
310
|
+
const reclaim = loadReclaimedFromDir(runDir);
|
|
311
|
+
const lastTombstone = reclaim.tombstones[reclaim.tombstones.length - 1];
|
|
312
|
+
const tier = lastTombstone ? "reclaimed" : archive ? "archived" : "live";
|
|
313
|
+
const capability = lastTombstone ? lastTombstone.capability : "re-runnable";
|
|
314
|
+
const capabilityReason = lastTombstone
|
|
315
|
+
? lastTombstone.capabilityReason
|
|
316
|
+
: archive
|
|
317
|
+
? "archived-full"
|
|
318
|
+
: "live-full";
|
|
319
|
+
return {
|
|
320
|
+
schemaVersion: 1,
|
|
321
|
+
runId: run.id,
|
|
322
|
+
appId: run.workflow.app?.id,
|
|
323
|
+
appVersion: run.workflow.app?.version,
|
|
324
|
+
workflowId: run.workflow.id,
|
|
325
|
+
title: run.workflow.title,
|
|
326
|
+
repo,
|
|
327
|
+
runDir,
|
|
328
|
+
statePath,
|
|
329
|
+
createdAt: run.createdAt,
|
|
330
|
+
updatedAt: run.updatedAt,
|
|
331
|
+
loopStage: run.loopStage,
|
|
332
|
+
lifecycle: lastTombstone ? "reclaimed" : archive ? "archived" : derived,
|
|
333
|
+
derivedLifecycle: derived,
|
|
334
|
+
archived: Boolean(archive),
|
|
335
|
+
archivedAt: archive?.archivedAt,
|
|
336
|
+
archiveReason: archive?.reason,
|
|
337
|
+
tier,
|
|
338
|
+
capability,
|
|
339
|
+
capabilityReason,
|
|
340
|
+
reclaimedAt: lastTombstone?.reclaimedAt,
|
|
341
|
+
reclaimedBytes: reclaim.tombstones.reduce((sum, t) => sum + (t.bytesFreed || 0), 0) || undefined,
|
|
342
|
+
tombstoneHash: lastTombstone?.tombstoneHash,
|
|
343
|
+
tasks: {
|
|
344
|
+
total: li.total,
|
|
345
|
+
pending: li.pending,
|
|
346
|
+
running: li.running,
|
|
347
|
+
failed: li.failed,
|
|
348
|
+
completed: li.completed
|
|
349
|
+
},
|
|
350
|
+
commitCount: (run.commits || []).length,
|
|
351
|
+
verifierGatedCommitCount: li.verifierGatedCommits,
|
|
352
|
+
openFeedbackCount: li.openFeedback,
|
|
353
|
+
backends: distinctBackends(run),
|
|
354
|
+
inputsDigest: digestInputs(run.inputs),
|
|
355
|
+
sourceFingerprint: fingerprintRun(run),
|
|
356
|
+
freshness: "valid",
|
|
357
|
+
provenance
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
/** Scan one repo's `.cw/runs/` and derive a record per run, deterministically
|
|
361
|
+
* ordered (createdAt asc, then runId). Unreadable runs are skipped here; the
|
|
362
|
+
* freshness layer is responsible for reporting persisted-but-missing runs. */
|
|
363
|
+
scanRepo(repo) {
|
|
364
|
+
const runsDir = this.repoRunsDir(repo);
|
|
365
|
+
if (!node_fs_1.default.existsSync(runsDir))
|
|
366
|
+
return [];
|
|
367
|
+
const records = [];
|
|
368
|
+
for (const entry of node_fs_1.default.readdirSync(runsDir, { withFileTypes: true })) {
|
|
369
|
+
if (!entry.isDirectory())
|
|
370
|
+
continue;
|
|
371
|
+
const record = this.deriveRecord(repo, node_path_1.default.join(runsDir, entry.name));
|
|
372
|
+
if (record)
|
|
373
|
+
records.push(record);
|
|
374
|
+
}
|
|
375
|
+
return records.sort(compareRecords);
|
|
376
|
+
}
|
|
377
|
+
// ---- index construction (current truth) ---------------------------------
|
|
378
|
+
/** Build the CURRENT index fresh from source for the requested scope. This is
|
|
379
|
+
* the authoritative-from-source view; persistence/freshness is layered on top. */
|
|
380
|
+
buildIndex(scope) {
|
|
381
|
+
const repos = scope === "home" ? this.knownRepos() : [this.repoRoot];
|
|
382
|
+
const records = [];
|
|
383
|
+
for (const repo of repos)
|
|
384
|
+
records.push(...this.scanRepo(repo));
|
|
385
|
+
records.sort(compareRecords);
|
|
386
|
+
const queue = scope === "home" ? this.loadQueue() : this.loadQueue().filter((q) => node_path_1.default.resolve(q.repo) === this.repoRoot);
|
|
387
|
+
const sourceFingerprint = fingerprintStrings([
|
|
388
|
+
...repos.map((r) => `repo:${r}`),
|
|
389
|
+
...records.map((r) => `${r.runId}:${r.sourceFingerprint}:${r.lifecycle}`)
|
|
390
|
+
]);
|
|
391
|
+
return {
|
|
392
|
+
schemaVersion: 1,
|
|
393
|
+
scope,
|
|
394
|
+
root: scope === "home" ? this.homeRoot : this.repoRoot,
|
|
395
|
+
generatedAt: new Date().toISOString(),
|
|
396
|
+
sourceFingerprint,
|
|
397
|
+
repos,
|
|
398
|
+
records,
|
|
399
|
+
queue,
|
|
400
|
+
counts: countRecords(records)
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
persistedIndexPath(scope) {
|
|
404
|
+
return scope === "home"
|
|
405
|
+
? node_path_1.default.join(this.homeRegistryDir(), "index.json")
|
|
406
|
+
: node_path_1.default.join(this.repoRegistryDir(this.repoRoot), "index.json");
|
|
407
|
+
}
|
|
408
|
+
loadPersistedIndex(scope) {
|
|
409
|
+
const file = this.persistedIndexPath(scope);
|
|
410
|
+
if (!node_fs_1.default.existsSync(file))
|
|
411
|
+
return undefined;
|
|
412
|
+
try {
|
|
413
|
+
const parsed = (0, state_1.readJson)(file);
|
|
414
|
+
if (!parsed || parsed.schemaVersion !== 1)
|
|
415
|
+
return undefined;
|
|
416
|
+
return parsed;
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
return undefined;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/** Refresh (recompute and persist) the index. registers the current repo into
|
|
423
|
+
* the home registry so cross-repo discovery finds it later. MECHANISM only:
|
|
424
|
+
* never touches source state.json. */
|
|
425
|
+
refresh(options = {}) {
|
|
426
|
+
const scope = options.scope || "repo";
|
|
427
|
+
// Registering the current repo is what makes a single-repo run discoverable
|
|
428
|
+
// cross-repo. Always safe (idempotent) and never mutates run source.
|
|
429
|
+
this.registerRepo(this.repoRoot);
|
|
430
|
+
const index = this.buildIndex(scope);
|
|
431
|
+
(0, state_1.writeJson)(this.persistedIndexPath(scope), index);
|
|
432
|
+
if (scope === "repo") {
|
|
433
|
+
// A repo refresh also keeps the home aggregate fresh enough to discover this
|
|
434
|
+
// repo's runs, without forcing a full cross-repo rebuild.
|
|
435
|
+
const homeIndex = this.buildIndex("home");
|
|
436
|
+
(0, state_1.writeJson)(this.persistedIndexPath("home"), homeIndex);
|
|
437
|
+
}
|
|
438
|
+
return this.report(scope, index);
|
|
439
|
+
}
|
|
440
|
+
/** Read the index with explicit freshness against current source. Re-derives
|
|
441
|
+
* every record from source (never fabricates); compares to the persisted cache
|
|
442
|
+
* to report valid|stale|absent + staleRuns/missingRuns. */
|
|
443
|
+
show(options = {}) {
|
|
444
|
+
const scope = options.scope || "repo";
|
|
445
|
+
return this.report(scope, this.buildIndex(scope));
|
|
446
|
+
}
|
|
447
|
+
report(scope, current) {
|
|
448
|
+
const persisted = this.loadPersistedIndex(scope);
|
|
449
|
+
const currentById = new Map(current.records.map((r) => [r.runId, r]));
|
|
450
|
+
let status = persisted ? "valid" : "absent";
|
|
451
|
+
const staleRuns = [];
|
|
452
|
+
const missingRuns = [];
|
|
453
|
+
if (persisted) {
|
|
454
|
+
if (persisted.sourceFingerprint !== current.sourceFingerprint)
|
|
455
|
+
status = "stale";
|
|
456
|
+
for (const prior of persisted.records) {
|
|
457
|
+
const now = currentById.get(prior.runId);
|
|
458
|
+
if (!now) {
|
|
459
|
+
missingRuns.push(prior.runId);
|
|
460
|
+
}
|
|
461
|
+
else if (now.sourceFingerprint !== prior.sourceFingerprint) {
|
|
462
|
+
staleRuns.push(prior.runId);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (staleRuns.length || missingRuns.length)
|
|
466
|
+
status = "stale";
|
|
467
|
+
}
|
|
468
|
+
const refreshCmd = scope === "home" ? "node scripts/cw.js registry refresh --scope home" : "node scripts/cw.js registry refresh";
|
|
469
|
+
return {
|
|
470
|
+
schemaVersion: 1,
|
|
471
|
+
scope,
|
|
472
|
+
root: current.root,
|
|
473
|
+
generatedAt: current.generatedAt,
|
|
474
|
+
freshness: {
|
|
475
|
+
status,
|
|
476
|
+
persistedFingerprint: persisted?.sourceFingerprint,
|
|
477
|
+
currentFingerprint: current.sourceFingerprint,
|
|
478
|
+
staleRuns: staleRuns.sort(),
|
|
479
|
+
missingRuns: missingRuns.sort()
|
|
480
|
+
},
|
|
481
|
+
index: current,
|
|
482
|
+
counts: current.counts,
|
|
483
|
+
nextAction: status === "valid" ? "node scripts/cw.js run search" : refreshCmd
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
// ---- search (deterministic, paginated) ----------------------------------
|
|
487
|
+
search(raw = {}) {
|
|
488
|
+
const scope = raw.scope || "home";
|
|
489
|
+
const index = this.buildIndex(scope);
|
|
490
|
+
const report = this.report(scope, index);
|
|
491
|
+
const query = {
|
|
492
|
+
text: optionalLower(raw.text),
|
|
493
|
+
app: optionalLower(raw.app),
|
|
494
|
+
status: raw.status,
|
|
495
|
+
repo: raw.repo ? node_path_1.default.resolve(raw.repo) : undefined,
|
|
496
|
+
since: raw.since,
|
|
497
|
+
until: raw.until,
|
|
498
|
+
includeArchived: raw.includeArchived ?? true,
|
|
499
|
+
offset: clampInt(raw.offset, 0, 0),
|
|
500
|
+
limit: clampInt(raw.limit, 50, 1)
|
|
501
|
+
};
|
|
502
|
+
let records = index.records.filter((record) => matchesQuery(record, query));
|
|
503
|
+
if (!query.includeArchived)
|
|
504
|
+
records = records.filter((record) => !record.archived);
|
|
505
|
+
records.sort(compareRecords);
|
|
506
|
+
const total = records.length;
|
|
507
|
+
const page = records.slice(query.offset, query.offset + query.limit);
|
|
508
|
+
return {
|
|
509
|
+
schemaVersion: 1,
|
|
510
|
+
scope,
|
|
511
|
+
query,
|
|
512
|
+
freshness: report.freshness.status,
|
|
513
|
+
total,
|
|
514
|
+
offset: query.offset,
|
|
515
|
+
limit: query.limit,
|
|
516
|
+
records: page,
|
|
517
|
+
nextAction: report.freshness.status === "valid"
|
|
518
|
+
? "node scripts/cw.js run show <run-id>"
|
|
519
|
+
: "node scripts/cw.js registry refresh"
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
list(options = {}) {
|
|
523
|
+
return this.search({
|
|
524
|
+
scope: options.scope || "home",
|
|
525
|
+
includeArchived: options.includeArchived ?? true,
|
|
526
|
+
limit: options.limit,
|
|
527
|
+
offset: options.offset
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
// ---- resolve one run by id (cross-repo, fail-closed) --------------------
|
|
531
|
+
/** Resolve a run by id, preferring the current repo, then any registered repo.
|
|
532
|
+
* Returns found=false with freshness `missing` (and the last-known persisted
|
|
533
|
+
* record, clearly flagged) when source is gone. */
|
|
534
|
+
showRun(runId, options = {}) {
|
|
535
|
+
const scope = options.scope || "home";
|
|
536
|
+
const located = this.locate(runId, scope);
|
|
537
|
+
if (located) {
|
|
538
|
+
return {
|
|
539
|
+
schemaVersion: 1,
|
|
540
|
+
runId,
|
|
541
|
+
found: true,
|
|
542
|
+
freshness: "valid",
|
|
543
|
+
resolvedFrom: located.from,
|
|
544
|
+
repo: located.record.repo,
|
|
545
|
+
record: located.record,
|
|
546
|
+
nextAction: located.record.archived
|
|
547
|
+
? "node scripts/cw.js run resume " + runId
|
|
548
|
+
: "node scripts/cw.js run show " + runId
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
// Not present in source. Surface the last-known persisted record (if any),
|
|
552
|
+
// flagged `missing` — never as a live status.
|
|
553
|
+
const persisted = this.findPersisted(runId, scope);
|
|
554
|
+
return {
|
|
555
|
+
schemaVersion: 1,
|
|
556
|
+
runId,
|
|
557
|
+
found: false,
|
|
558
|
+
freshness: "missing",
|
|
559
|
+
repo: persisted?.repo,
|
|
560
|
+
persisted,
|
|
561
|
+
nextAction: "node scripts/cw.js registry refresh" + (scope === "home" ? " --scope home" : "")
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
locate(runId, scope) {
|
|
565
|
+
// Current repo first (least astonishment: cwd wins).
|
|
566
|
+
const here = this.deriveRecordForRun(this.repoRoot, runId);
|
|
567
|
+
if (here)
|
|
568
|
+
return { record: here, from: "repo" };
|
|
569
|
+
if (scope === "repo")
|
|
570
|
+
return undefined;
|
|
571
|
+
for (const repo of this.knownRepos()) {
|
|
572
|
+
if (node_path_1.default.resolve(repo) === this.repoRoot)
|
|
573
|
+
continue;
|
|
574
|
+
const record = this.deriveRecordForRun(repo, runId);
|
|
575
|
+
if (record)
|
|
576
|
+
return { record, from: "home" };
|
|
577
|
+
}
|
|
578
|
+
return undefined;
|
|
579
|
+
}
|
|
580
|
+
deriveRecordForRun(repo, runId) {
|
|
581
|
+
const runDir = node_path_1.default.join(this.repoRunsDir(repo), runId);
|
|
582
|
+
if (!node_fs_1.default.existsSync(node_path_1.default.join(runDir, "state.json")))
|
|
583
|
+
return null;
|
|
584
|
+
return this.deriveRecord(repo, runDir);
|
|
585
|
+
}
|
|
586
|
+
findPersisted(runId, scope) {
|
|
587
|
+
for (const s of scope === "home" ? ["home", "repo"] : ["repo"]) {
|
|
588
|
+
const persisted = this.loadPersistedIndex(s);
|
|
589
|
+
const hit = persisted?.records.find((r) => r.runId === runId);
|
|
590
|
+
if (hit)
|
|
591
|
+
return hit;
|
|
592
|
+
}
|
|
593
|
+
return undefined;
|
|
594
|
+
}
|
|
595
|
+
loadRun(repo, runId) {
|
|
596
|
+
const statePath = node_path_1.default.join(this.repoRunsDir(repo), runId, "state.json");
|
|
597
|
+
if (!node_fs_1.default.existsSync(statePath))
|
|
598
|
+
throw new Error(`Run not found: ${runId}`);
|
|
599
|
+
const result = (0, state_1.loadRunStateFile)(statePath, { dryRun: true });
|
|
600
|
+
if (result.report.status === "unsupported") {
|
|
601
|
+
throw new Error(`Unsupported run state for ${runId}: ${result.report.errors.join("; ")}`);
|
|
602
|
+
}
|
|
603
|
+
return result.run;
|
|
604
|
+
}
|
|
605
|
+
// ---- resume (continue from durable state; read-only over source) --------
|
|
606
|
+
resume(runId, options = {}) {
|
|
607
|
+
const scope = options.scope || "home";
|
|
608
|
+
const located = this.locate(runId, scope);
|
|
609
|
+
if (!located) {
|
|
610
|
+
throw new Error(`Cannot resume: run ${runId} not found in source state (fail closed; try registry refresh).`);
|
|
611
|
+
}
|
|
612
|
+
const record = located.record;
|
|
613
|
+
const run = this.loadRun(record.repo, runId);
|
|
614
|
+
const limit = clampInt(options.limit, 5, 1);
|
|
615
|
+
const nextTasks = (run.tasks || [])
|
|
616
|
+
.filter((t) => t.status === "pending" || t.status === "running")
|
|
617
|
+
.slice(0, limit)
|
|
618
|
+
.map((t) => ({ id: t.id, phase: t.phase, status: t.status, taskPath: t.taskPath }));
|
|
619
|
+
const terminal = record.derivedLifecycle === "completed" || record.derivedLifecycle === "failed";
|
|
620
|
+
const resumable = nextTasks.length > 0 || (!terminal && record.derivedLifecycle !== "completed");
|
|
621
|
+
const nextActions = [];
|
|
622
|
+
if (nextTasks.length) {
|
|
623
|
+
nextActions.push({
|
|
624
|
+
command: `node scripts/cw.js dispatch ${runId} --cwd ${record.repo}`,
|
|
625
|
+
reason: `Continue ${nextTasks.length} pending/running task(s) from durable state.`
|
|
626
|
+
});
|
|
627
|
+
nextActions.push({
|
|
628
|
+
command: `node scripts/cw.js multi-agent step ${runId} --cwd ${record.repo}`,
|
|
629
|
+
reason: "Take one deterministic host step without spawning agents."
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
else if (record.derivedLifecycle === "failed") {
|
|
633
|
+
nextActions.push({
|
|
634
|
+
command: `node scripts/cw.js run rerun ${runId}`,
|
|
635
|
+
reason: "Run terminated as failed with no runnable tasks; rerun as a new linked run."
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
else {
|
|
639
|
+
nextActions.push({
|
|
640
|
+
command: `node scripts/cw.js status ${runId} --cwd ${record.repo} --json`,
|
|
641
|
+
reason: "No runnable tasks remain; inspect status.",
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
return {
|
|
645
|
+
schemaVersion: 1,
|
|
646
|
+
runId,
|
|
647
|
+
repo: record.repo,
|
|
648
|
+
runDir: record.runDir,
|
|
649
|
+
statePath: record.statePath,
|
|
650
|
+
resolvedFrom: located.from,
|
|
651
|
+
lifecycle: record.lifecycle,
|
|
652
|
+
derivedLifecycle: record.derivedLifecycle,
|
|
653
|
+
loopStage: record.loopStage,
|
|
654
|
+
freshness: "valid",
|
|
655
|
+
resumable,
|
|
656
|
+
reason: record.archived ? "Run is archived; resuming reads durable state without un-archiving." : undefined,
|
|
657
|
+
record,
|
|
658
|
+
nextTasks,
|
|
659
|
+
nextActions
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
// ---- archive (overlay mark; never deletes source) -----------------------
|
|
663
|
+
archive(runId, options = {}) {
|
|
664
|
+
const scope = options.scope || "home";
|
|
665
|
+
const located = this.locate(runId, scope);
|
|
666
|
+
if (!located)
|
|
667
|
+
throw new Error(`Cannot archive: run ${runId} not found in source state (fail closed).`);
|
|
668
|
+
const repo = located.record.repo;
|
|
669
|
+
const file = node_path_1.default.join(this.repoRegistryDir(repo), "archive.json");
|
|
670
|
+
// Lock the archive-overlay read-modify-write (v0.1.40, P1-D) + durable write.
|
|
671
|
+
(0, state_1.withFileLock)(file, () => {
|
|
672
|
+
const overlay = this.loadArchiveOverlay(repo);
|
|
673
|
+
if (options.unarchive) {
|
|
674
|
+
delete overlay.archived[runId];
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
overlay.archived[runId] = { archivedAt: new Date().toISOString(), reason: options.reason };
|
|
678
|
+
}
|
|
679
|
+
(0, state_1.writeJson)(file, overlay, { durable: true });
|
|
680
|
+
});
|
|
681
|
+
const record = this.deriveRecord(repo, located.record.runDir);
|
|
682
|
+
return {
|
|
683
|
+
runId,
|
|
684
|
+
repo,
|
|
685
|
+
archived: record.archived,
|
|
686
|
+
archivedAt: record.archivedAt,
|
|
687
|
+
reason: record.archiveReason,
|
|
688
|
+
record,
|
|
689
|
+
overlayPath: file
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
/** Apply a retention POLICY: archive eligible runs older than the window. The
|
|
693
|
+
* window/states are policy inputs, never baked into the index. Returns the set
|
|
694
|
+
* archived; archives are overlay marks, so nothing is destroyed. */
|
|
695
|
+
archiveByPolicy(policy = exports.DEFAULT_RUN_REGISTRY_POLICY, options = {}) {
|
|
696
|
+
const scope = options.scope || "home";
|
|
697
|
+
if (!policy.archiveOlderThanDays || policy.archiveOlderThanDays <= 0) {
|
|
698
|
+
return { policy, archived: [], eligible: 0 };
|
|
699
|
+
}
|
|
700
|
+
const nowMs = options.now ? Date.parse(options.now) : Date.now();
|
|
701
|
+
const cutoff = nowMs - policy.archiveOlderThanDays * 24 * 60 * 60 * 1000;
|
|
702
|
+
const index = this.buildIndex(scope);
|
|
703
|
+
const eligible = index.records.filter((r) => !r.archived && policy.archiveStates.includes(r.derivedLifecycle) && Date.parse(r.updatedAt) < cutoff);
|
|
704
|
+
const archived = [];
|
|
705
|
+
for (const record of eligible) {
|
|
706
|
+
this.archive(record.runId, { reason: `retention:${policy.archiveOlderThanDays}d`, scope });
|
|
707
|
+
archived.push(record.runId);
|
|
708
|
+
}
|
|
709
|
+
return { policy, archived: archived.sort(), eligible: eligible.length };
|
|
710
|
+
}
|
|
711
|
+
// ---- Run Retention & Provable Reclamation (v0.1.39) ----------------------
|
|
712
|
+
// A small, verifiable GC built on the archive overlay. `gc plan` is a pure
|
|
713
|
+
// dry-run (frees nothing); `gc run` executes the write-ahead reclamation
|
|
714
|
+
// transaction (skeleton → tombstone → fsync → free); `gc verify` re-proves a
|
|
715
|
+
// reclaimed run independently. Eligibility is explicit and fail-closed.
|
|
716
|
+
/** Resolve the effective reclamation policy (defaults reclaim NOTHING). */
|
|
717
|
+
reclamationPolicy(overrides = {}) {
|
|
718
|
+
return { ...exports.DEFAULT_RUN_REGISTRY_POLICY, ...overrides };
|
|
719
|
+
}
|
|
720
|
+
/** Fail-closed eligibility: terminal AND archived AND no open feedback AND past
|
|
721
|
+
* retention. Returns the matching refusal code, or null when eligible. Reads
|
|
722
|
+
* the live-source-derived record; order yields distinct, stable codes. */
|
|
723
|
+
reclaimEligibility(record, policy, nowMs) {
|
|
724
|
+
if (record.tier === "reclaimed")
|
|
725
|
+
return "already-reclaimed";
|
|
726
|
+
const terminalStates = policy.reclaimStates && policy.reclaimStates.length ? policy.reclaimStates : ["completed", "failed"];
|
|
727
|
+
if (record.derivedLifecycle !== "completed" && record.derivedLifecycle !== "failed")
|
|
728
|
+
return "non-terminal";
|
|
729
|
+
if (!terminalStates.includes(record.derivedLifecycle))
|
|
730
|
+
return "non-terminal";
|
|
731
|
+
if (record.openFeedbackCount > 0)
|
|
732
|
+
return "open-feedback";
|
|
733
|
+
if (!record.archived)
|
|
734
|
+
return "not-archived";
|
|
735
|
+
const days = policy.reclaimAfterArchiveDays ?? 0;
|
|
736
|
+
if (days > 0) {
|
|
737
|
+
const archivedAtMs = record.archivedAt ? Date.parse(record.archivedAt) : NaN;
|
|
738
|
+
if (!Number.isFinite(archivedAtMs))
|
|
739
|
+
return "within-retention";
|
|
740
|
+
if (archivedAtMs > nowMs - days * 24 * 60 * 60 * 1000)
|
|
741
|
+
return "within-retention";
|
|
742
|
+
}
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
/** Resolve a single run to a one-element record list via locate() (repo-first),
|
|
746
|
+
* avoiding a full-registry scan for single-run gc plan/run. */
|
|
747
|
+
recordsForRunId(runId, scope) {
|
|
748
|
+
const located = this.locate(runId, scope);
|
|
749
|
+
return located ? [located.record] : [];
|
|
750
|
+
}
|
|
751
|
+
/** Dry-run: compute eligible runs, per-kind bytes that WOULD be freed, and the
|
|
752
|
+
* capability downgrade. Frees NOTHING. */
|
|
753
|
+
gcPlan(options = {}) {
|
|
754
|
+
const scope = options.scope || "home";
|
|
755
|
+
const policy = this.reclamationPolicy(options.policy);
|
|
756
|
+
const nowIso = options.now || new Date().toISOString();
|
|
757
|
+
const nowMs = Date.parse(nowIso);
|
|
758
|
+
// Fast, deterministic single-run path: resolve just that run via locate()
|
|
759
|
+
// (repo-first) so a home-scope plan never re-scans the whole registry.
|
|
760
|
+
const records = options.runId ? this.recordsForRunId(options.runId, scope) : this.buildIndex(scope).records;
|
|
761
|
+
const entries = [];
|
|
762
|
+
let bytesToFree = 0;
|
|
763
|
+
let eligibleCount = 0;
|
|
764
|
+
for (const record of records) {
|
|
765
|
+
const refusal = this.reclaimEligibility(record, policy, nowMs);
|
|
766
|
+
let plan;
|
|
767
|
+
try {
|
|
768
|
+
const run = this.loadRun(record.repo, record.runId);
|
|
769
|
+
plan = (0, reclamation_1.planReclamation)(run, { keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots });
|
|
770
|
+
}
|
|
771
|
+
catch {
|
|
772
|
+
entries.push({
|
|
773
|
+
runId: record.runId,
|
|
774
|
+
repo: record.repo,
|
|
775
|
+
eligible: false,
|
|
776
|
+
reason: "unreadable",
|
|
777
|
+
tier: record.tier || "live",
|
|
778
|
+
capability: record.capability || "re-runnable",
|
|
779
|
+
capabilityReason: record.capabilityReason || "live-full",
|
|
780
|
+
bytesToFree: 0,
|
|
781
|
+
byKind: {},
|
|
782
|
+
freeable: []
|
|
783
|
+
});
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
const eligible = refusal === null;
|
|
787
|
+
const entry = {
|
|
788
|
+
runId: record.runId,
|
|
789
|
+
repo: record.repo,
|
|
790
|
+
eligible,
|
|
791
|
+
reason: eligible ? "eligible" : refusal,
|
|
792
|
+
tier: record.tier || "live",
|
|
793
|
+
capability: plan.capability,
|
|
794
|
+
capabilityReason: plan.capabilityReason,
|
|
795
|
+
bytesToFree: eligible ? plan.bytesToFree : 0,
|
|
796
|
+
byKind: eligible ? plan.byKind : {},
|
|
797
|
+
freeable: eligible ? plan.freeable.map((f) => ({ path: f.path, kind: f.kind, bytes: f.bytes })) : []
|
|
798
|
+
};
|
|
799
|
+
entries.push(entry);
|
|
800
|
+
if (eligible) {
|
|
801
|
+
eligibleCount += 1;
|
|
802
|
+
bytesToFree += plan.bytesToFree;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return {
|
|
806
|
+
schemaVersion: 1,
|
|
807
|
+
scope,
|
|
808
|
+
generatedAt: nowIso,
|
|
809
|
+
policy: {
|
|
810
|
+
reclaimAfterArchiveDays: policy.reclaimAfterArchiveDays ?? 0,
|
|
811
|
+
keepSnapshots: Boolean(policy.keepSnapshots),
|
|
812
|
+
keepScratch: Boolean(policy.keepScratch),
|
|
813
|
+
reclaimStates: policy.reclaimStates && policy.reclaimStates.length ? policy.reclaimStates : ["completed", "failed"]
|
|
814
|
+
},
|
|
815
|
+
total: entries.length,
|
|
816
|
+
eligibleCount,
|
|
817
|
+
bytesToFree,
|
|
818
|
+
entries,
|
|
819
|
+
nextAction: eligibleCount ? "node scripts/cw.js gc run" : "node scripts/cw.js run search"
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
/** Execute the write-ahead reclamation transaction for eligible runs. Bounded
|
|
823
|
+
* (`maxReclaimRuns` / `maxReclaimBytes`), fail-closed on any incomplete
|
|
824
|
+
* skeleton. Produces a tombstone and frees the bulk. */
|
|
825
|
+
gcRun(options = {}) {
|
|
826
|
+
const scope = options.scope || "home";
|
|
827
|
+
const policy = this.reclamationPolicy(options.policy);
|
|
828
|
+
const nowIso = options.now || new Date().toISOString();
|
|
829
|
+
const nowMs = Date.parse(nowIso);
|
|
830
|
+
const records = options.runId ? this.recordsForRunId(options.runId, scope) : this.buildIndex(scope).records;
|
|
831
|
+
const maxRuns = options.limit ?? (policy.maxReclaimRuns || 0);
|
|
832
|
+
const maxBytes = policy.maxReclaimBytes || 0;
|
|
833
|
+
const reclaimed = [];
|
|
834
|
+
const refused = [];
|
|
835
|
+
let totalBytesFreed = 0;
|
|
836
|
+
for (const record of records) {
|
|
837
|
+
const refusal = this.reclaimEligibility(record, policy, nowMs);
|
|
838
|
+
if (refusal) {
|
|
839
|
+
refused.push({ runId: record.runId, code: refusal });
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
if (maxRuns > 0 && reclaimed.length >= maxRuns)
|
|
843
|
+
break;
|
|
844
|
+
let run;
|
|
845
|
+
try {
|
|
846
|
+
run = this.loadRun(record.repo, record.runId);
|
|
847
|
+
}
|
|
848
|
+
catch {
|
|
849
|
+
refused.push({ runId: record.runId, code: "unreadable" });
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
try {
|
|
853
|
+
const result = (0, reclamation_1.runReclamation)(run, {
|
|
854
|
+
now: nowIso,
|
|
855
|
+
actor: options.actor,
|
|
856
|
+
policy: { reclaimAfterArchiveDays: policy.reclaimAfterArchiveDays, keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots },
|
|
857
|
+
reclaimPolicy: { keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots }
|
|
858
|
+
});
|
|
859
|
+
// No post-free saveCheckpoint: runReclamation now DURABLY persists the
|
|
860
|
+
// result-node re-point inside the transaction (before any byte is freed),
|
|
861
|
+
// so state.json can never reference a freed path even on a crash here.
|
|
862
|
+
reclaimed.push({
|
|
863
|
+
runId: record.runId,
|
|
864
|
+
bytesFreed: result.bytesFreed,
|
|
865
|
+
tombstoneHash: result.tombstone.tombstoneHash,
|
|
866
|
+
capability: result.tombstone.capability,
|
|
867
|
+
capabilityReason: result.tombstone.capabilityReason
|
|
868
|
+
});
|
|
869
|
+
totalBytesFreed += result.bytesFreed;
|
|
870
|
+
if (maxBytes > 0 && totalBytesFreed >= maxBytes)
|
|
871
|
+
break;
|
|
872
|
+
}
|
|
873
|
+
catch (error) {
|
|
874
|
+
if (error instanceof reclamation_1.ReclamationError)
|
|
875
|
+
refused.push({ runId: record.runId, code: error.code });
|
|
876
|
+
else
|
|
877
|
+
throw error;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
return {
|
|
881
|
+
schemaVersion: 1,
|
|
882
|
+
scope,
|
|
883
|
+
generatedAt: nowIso,
|
|
884
|
+
dryRun: false,
|
|
885
|
+
reclaimed,
|
|
886
|
+
refused,
|
|
887
|
+
totalBytesFreed,
|
|
888
|
+
nextAction: reclaimed.length ? "node scripts/cw.js gc verify <run-id>" : "node scripts/cw.js gc plan"
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
/** Re-prove a reclaimed run: skeleton schema-complete, tombstone chain
|
|
892
|
+
* recomputed-and-untampered, each reconstructable artifact re-derived from its
|
|
893
|
+
* RETAINED inputs to its expectDigest, and eligible-when-reclaimed. */
|
|
894
|
+
gcVerify(runId, options = {}) {
|
|
895
|
+
const scope = options.scope || "home";
|
|
896
|
+
const located = this.locate(runId, scope);
|
|
897
|
+
if (!located) {
|
|
898
|
+
return {
|
|
899
|
+
schemaVersion: 1,
|
|
900
|
+
runId,
|
|
901
|
+
reclaimed: false,
|
|
902
|
+
verified: false,
|
|
903
|
+
tier: "live",
|
|
904
|
+
capability: "re-runnable",
|
|
905
|
+
chainLength: 0,
|
|
906
|
+
checks: [{ name: "located", pass: false, code: "not-reclaimed", detail: "run source not found" }],
|
|
907
|
+
nextAction: "node scripts/cw.js registry refresh" + (scope === "home" ? " --scope home" : "")
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
const run = this.loadRun(located.record.repo, runId);
|
|
911
|
+
const result = (0, reclamation_1.verifyReclamation)(run);
|
|
912
|
+
const checks = result.checks.map((c) => ({ name: c.name, pass: c.pass, code: c.code, detail: c.detail }));
|
|
913
|
+
// Eligible-when-reclaimed: each tombstone must have sealed a terminal verdict.
|
|
914
|
+
let eligibleWhenReclaimed = result.reclaimed;
|
|
915
|
+
for (const tombstone of result.tombstones) {
|
|
916
|
+
const terminal = tombstone.skeleton.finalVerdict?.terminal === true;
|
|
917
|
+
if (!terminal) {
|
|
918
|
+
eligibleWhenReclaimed = false;
|
|
919
|
+
checks.push({ name: `eligible-when-reclaimed:${tombstone.tombstoneId}`, pass: false, code: "ineligible-when-reclaimed", detail: "non-terminal verdict sealed" });
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
const last = result.tombstones[result.tombstones.length - 1];
|
|
923
|
+
const verified = result.verified && eligibleWhenReclaimed;
|
|
924
|
+
return {
|
|
925
|
+
schemaVersion: 1,
|
|
926
|
+
runId,
|
|
927
|
+
reclaimed: result.reclaimed,
|
|
928
|
+
verified,
|
|
929
|
+
tier: located.record.tier || (result.reclaimed ? "reclaimed" : "live"),
|
|
930
|
+
capability: located.record.capability || "re-runnable",
|
|
931
|
+
capabilityReason: located.record.capabilityReason,
|
|
932
|
+
tombstoneHash: last?.tombstoneHash,
|
|
933
|
+
chainLength: result.tombstones.length,
|
|
934
|
+
checks,
|
|
935
|
+
nextAction: verified ? "node scripts/cw.js run show " + runId : "node scripts/cw.js gc plan"
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
// ---- rerun (NEW run linked to the original; original preserved) ---------
|
|
939
|
+
rerun(runId, options = {}) {
|
|
940
|
+
if (!this.planner)
|
|
941
|
+
throw new Error("rerun requires a run planner (CoolWorkflowRunner)");
|
|
942
|
+
const scope = options.scope || "home";
|
|
943
|
+
const located = this.locate(runId, scope);
|
|
944
|
+
if (!located)
|
|
945
|
+
throw new Error(`Cannot rerun: run ${runId} not found in source state (fail closed).`);
|
|
946
|
+
const original = located.record;
|
|
947
|
+
const originalRun = this.loadRun(original.repo, runId);
|
|
948
|
+
const appId = originalRun.workflow.app?.id || originalRun.workflow.id;
|
|
949
|
+
// Reuse the original inputs verbatim, pinned to the original repo so the new
|
|
950
|
+
// run lands beside it. We never fork run creation — this is runner.plan.
|
|
951
|
+
const inputs = { ...(originalRun.inputs || {}), cwd: original.repo, repo: original.repo };
|
|
952
|
+
const newRun = this.planner.plan(appId, inputs);
|
|
953
|
+
const priorProv = original.provenance;
|
|
954
|
+
const provenance = {
|
|
955
|
+
rerunOf: runId,
|
|
956
|
+
rerunOfRepo: original.repo,
|
|
957
|
+
originRunId: priorProv?.originRunId || runId,
|
|
958
|
+
generation: (priorProv?.generation || 0) + 1,
|
|
959
|
+
reason: options.reason || "rerun of failed run",
|
|
960
|
+
createdAt: new Date().toISOString()
|
|
961
|
+
};
|
|
962
|
+
// Record provenance in the per-repo overlay (derived metadata), NOT in the
|
|
963
|
+
// original run's source state — the past is never mutated.
|
|
964
|
+
const provFile = node_path_1.default.join(this.repoRegistryDir(original.repo), "provenance.json");
|
|
965
|
+
const provOverlay = this.loadProvenanceOverlay(original.repo);
|
|
966
|
+
provOverlay.links[newRun.id] = provenance;
|
|
967
|
+
(0, state_1.writeJson)(provFile, provOverlay, { durable: true });
|
|
968
|
+
return {
|
|
969
|
+
schemaVersion: 1,
|
|
970
|
+
originalRunId: runId,
|
|
971
|
+
originalRepo: original.repo,
|
|
972
|
+
originalLifecycle: original.lifecycle,
|
|
973
|
+
newRunId: newRun.id,
|
|
974
|
+
repo: original.repo,
|
|
975
|
+
appId: newRun.workflow.app?.id || appId,
|
|
976
|
+
workflowId: newRun.workflow.id,
|
|
977
|
+
statePath: newRun.paths.state,
|
|
978
|
+
reportPath: newRun.paths.report,
|
|
979
|
+
pendingTasks: newRun.tasks.filter((t) => t.status === "pending").length,
|
|
980
|
+
provenance,
|
|
981
|
+
nextActions: [
|
|
982
|
+
{ command: `node scripts/cw.js run resume ${newRun.id}`, reason: "Continue the new linked run." },
|
|
983
|
+
{ command: `node scripts/cw.js run show ${runId}`, reason: "The original failed run is preserved for audit." }
|
|
984
|
+
]
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
// ---- queue (durable, ordered; drained by the host) ----------------------
|
|
988
|
+
queueAdd(options = {}) {
|
|
989
|
+
const repo = options.repo ? node_path_1.default.resolve(options.repo) : this.repoRoot;
|
|
990
|
+
// Cross-process read-modify-write on the home queue: lock so a concurrently
|
|
991
|
+
// added task can never vanish (v0.1.40, P1-D).
|
|
992
|
+
return (0, state_1.withFileLock)(this.queueFilePath(), () => {
|
|
993
|
+
const entries = this.loadQueue();
|
|
994
|
+
const entry = {
|
|
995
|
+
schemaVersion: 1,
|
|
996
|
+
id: options.id || queueId(),
|
|
997
|
+
runId: options.runId,
|
|
998
|
+
appId: options.appId,
|
|
999
|
+
workflowId: options.workflowId,
|
|
1000
|
+
repo,
|
|
1001
|
+
priority: Number.isFinite(options.priority) ? Number(options.priority) : exports.DEFAULT_RUN_REGISTRY_POLICY.defaultQueuePriority,
|
|
1002
|
+
enqueuedAt: new Date().toISOString(),
|
|
1003
|
+
status: "pending",
|
|
1004
|
+
inputs: options.inputs,
|
|
1005
|
+
note: options.note
|
|
1006
|
+
};
|
|
1007
|
+
entries.push(entry);
|
|
1008
|
+
this.registerRepo(repo);
|
|
1009
|
+
this.saveQueue(entries);
|
|
1010
|
+
return entry;
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
queueList(options = {}) {
|
|
1014
|
+
let entries = this.loadQueue();
|
|
1015
|
+
if (options.status)
|
|
1016
|
+
entries = entries.filter((e) => e.status === options.status);
|
|
1017
|
+
if (options.repo) {
|
|
1018
|
+
const repo = node_path_1.default.resolve(options.repo);
|
|
1019
|
+
entries = entries.filter((e) => node_path_1.default.resolve(e.repo) === repo);
|
|
1020
|
+
}
|
|
1021
|
+
entries = [...entries].sort(compareQueue);
|
|
1022
|
+
return { schemaVersion: 1, total: entries.length, entries };
|
|
1023
|
+
}
|
|
1024
|
+
queueShow(id) {
|
|
1025
|
+
const entry = this.loadQueue().find((e) => e.id === id);
|
|
1026
|
+
if (!entry)
|
|
1027
|
+
throw new Error(`Queue entry not found: ${id}`);
|
|
1028
|
+
return entry;
|
|
1029
|
+
}
|
|
1030
|
+
/** Drain the next N ready/pending entries in policy order, marking them drained.
|
|
1031
|
+
* CW records readiness/order; the HOST still executes the workers. */
|
|
1032
|
+
queueDrain(options = {}) {
|
|
1033
|
+
const limit = clampInt(options.limit, 1, 1);
|
|
1034
|
+
const repoFilter = options.repo ? node_path_1.default.resolve(options.repo) : undefined;
|
|
1035
|
+
// Lock the drain RMW so two hosts can never double-drain the same entry
|
|
1036
|
+
// (v0.1.40, P1-D — the scheduling kernel's concurrency ceiling now holds
|
|
1037
|
+
// across processes, not just within one).
|
|
1038
|
+
return (0, state_1.withFileLock)(this.queueFilePath(), () => {
|
|
1039
|
+
const entries = this.loadQueue();
|
|
1040
|
+
const drainable = entries
|
|
1041
|
+
.filter((e) => e.status === "pending" || e.status === "ready")
|
|
1042
|
+
.filter((e) => !repoFilter || node_path_1.default.resolve(e.repo) === repoFilter)
|
|
1043
|
+
.sort(compareQueue);
|
|
1044
|
+
const drained = [];
|
|
1045
|
+
const drainedAt = new Date().toISOString();
|
|
1046
|
+
for (const entry of drainable.slice(0, limit)) {
|
|
1047
|
+
entry.status = "drained";
|
|
1048
|
+
entry.drainedAt = drainedAt;
|
|
1049
|
+
drained.push(entry);
|
|
1050
|
+
}
|
|
1051
|
+
this.saveQueue(entries);
|
|
1052
|
+
const remaining = entries.filter((e) => e.status === "pending" || e.status === "ready").length;
|
|
1053
|
+
return { schemaVersion: 1, drained, remaining };
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
// ---- cross-repo history (unified timeline) ------------------------------
|
|
1057
|
+
history(options = {}) {
|
|
1058
|
+
const scope = options.scope || "home";
|
|
1059
|
+
const index = this.buildIndex(scope);
|
|
1060
|
+
const report = this.report(scope, index);
|
|
1061
|
+
const app = optionalLower(options.app);
|
|
1062
|
+
const limit = clampInt(options.limit, 50, 1);
|
|
1063
|
+
const offset = clampInt(options.offset, 0, 0);
|
|
1064
|
+
let records = index.records;
|
|
1065
|
+
if (app)
|
|
1066
|
+
records = records.filter((r) => (r.appId || r.workflowId || "").toLowerCase().includes(app));
|
|
1067
|
+
if (options.status)
|
|
1068
|
+
records = records.filter((r) => r.lifecycle === options.status || r.derivedLifecycle === options.status);
|
|
1069
|
+
const ordered = [...records].sort(compareHistory);
|
|
1070
|
+
const total = ordered.length;
|
|
1071
|
+
const page = ordered.slice(offset, offset + limit);
|
|
1072
|
+
const entries = page.map((r) => ({
|
|
1073
|
+
runId: r.runId,
|
|
1074
|
+
repo: r.repo,
|
|
1075
|
+
appId: r.appId,
|
|
1076
|
+
workflowId: r.workflowId,
|
|
1077
|
+
lifecycle: r.lifecycle,
|
|
1078
|
+
loopStage: r.loopStage,
|
|
1079
|
+
createdAt: r.createdAt,
|
|
1080
|
+
updatedAt: r.updatedAt,
|
|
1081
|
+
freshness: r.freshness,
|
|
1082
|
+
provenance: r.provenance
|
|
1083
|
+
}));
|
|
1084
|
+
return {
|
|
1085
|
+
schemaVersion: 1,
|
|
1086
|
+
scope,
|
|
1087
|
+
freshness: report.freshness.status,
|
|
1088
|
+
total,
|
|
1089
|
+
offset,
|
|
1090
|
+
limit,
|
|
1091
|
+
repos: index.repos,
|
|
1092
|
+
entries,
|
|
1093
|
+
nextAction: report.freshness.status === "valid" ? "node scripts/cw.js run show <run-id>" : "node scripts/cw.js registry refresh --scope home"
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
exports.RunRegistry = RunRegistry;
|
|
1098
|
+
// ---------------------------------------------------------------------------
|
|
1099
|
+
// pure helpers
|
|
1100
|
+
// ---------------------------------------------------------------------------
|
|
1101
|
+
function compareRecords(a, b) {
|
|
1102
|
+
if (a.createdAt !== b.createdAt)
|
|
1103
|
+
return a.createdAt < b.createdAt ? -1 : 1;
|
|
1104
|
+
return a.runId.localeCompare(b.runId);
|
|
1105
|
+
}
|
|
1106
|
+
function compareHistory(a, b) {
|
|
1107
|
+
// Newest first.
|
|
1108
|
+
if (a.createdAt !== b.createdAt)
|
|
1109
|
+
return a.createdAt < b.createdAt ? 1 : -1;
|
|
1110
|
+
return a.runId.localeCompare(b.runId);
|
|
1111
|
+
}
|
|
1112
|
+
function compareQueue(a, b) {
|
|
1113
|
+
if (a.priority !== b.priority)
|
|
1114
|
+
return a.priority - b.priority;
|
|
1115
|
+
if (a.enqueuedAt !== b.enqueuedAt)
|
|
1116
|
+
return a.enqueuedAt < b.enqueuedAt ? -1 : 1;
|
|
1117
|
+
return a.id.localeCompare(b.id);
|
|
1118
|
+
}
|
|
1119
|
+
function matchesQuery(record, query) {
|
|
1120
|
+
if (query.app && !(record.appId || record.workflowId || "").toLowerCase().includes(query.app))
|
|
1121
|
+
return false;
|
|
1122
|
+
if (query.status && record.lifecycle !== query.status && record.derivedLifecycle !== query.status)
|
|
1123
|
+
return false;
|
|
1124
|
+
if (query.repo && node_path_1.default.resolve(record.repo) !== query.repo)
|
|
1125
|
+
return false;
|
|
1126
|
+
if (query.since && record.createdAt < query.since)
|
|
1127
|
+
return false;
|
|
1128
|
+
if (query.until && record.createdAt > query.until)
|
|
1129
|
+
return false;
|
|
1130
|
+
if (query.text) {
|
|
1131
|
+
const haystack = [
|
|
1132
|
+
record.runId,
|
|
1133
|
+
record.appId,
|
|
1134
|
+
record.workflowId,
|
|
1135
|
+
record.title,
|
|
1136
|
+
record.repo,
|
|
1137
|
+
record.lifecycle,
|
|
1138
|
+
record.loopStage,
|
|
1139
|
+
record.inputsDigest
|
|
1140
|
+
]
|
|
1141
|
+
.filter(Boolean)
|
|
1142
|
+
.join(" ")
|
|
1143
|
+
.toLowerCase();
|
|
1144
|
+
if (!haystack.includes(query.text))
|
|
1145
|
+
return false;
|
|
1146
|
+
}
|
|
1147
|
+
return true;
|
|
1148
|
+
}
|
|
1149
|
+
/** Bounded, deterministic stringification of run inputs for free-text search.
|
|
1150
|
+
* Descriptive intent keys (question, prompt, ...) come first so they survive
|
|
1151
|
+
* truncation; the rest follow alphabetically. Deterministic and compact. */
|
|
1152
|
+
const DIGEST_PRIORITY_KEYS = ["question", "prompt", "task", "summary", "title", "objective", "focus", "topic"];
|
|
1153
|
+
/** Distinct execution backends used by a run's dispatches/tasks, recomputed from
|
|
1154
|
+
* source state. Sorted; empty for pre-v0.1.29 / default-only runs that never
|
|
1155
|
+
* recorded a backend. The registry stays backend-agnostic — this is metadata. */
|
|
1156
|
+
function distinctBackends(run) {
|
|
1157
|
+
const backends = new Set();
|
|
1158
|
+
for (const dispatch of run.dispatches || []) {
|
|
1159
|
+
if (dispatch.backendId)
|
|
1160
|
+
backends.add(dispatch.backendId);
|
|
1161
|
+
}
|
|
1162
|
+
for (const task of run.tasks || []) {
|
|
1163
|
+
if (task.backendId)
|
|
1164
|
+
backends.add(task.backendId);
|
|
1165
|
+
}
|
|
1166
|
+
return [...backends].sort();
|
|
1167
|
+
}
|
|
1168
|
+
function digestInputs(inputs) {
|
|
1169
|
+
if (!inputs || typeof inputs !== "object")
|
|
1170
|
+
return undefined;
|
|
1171
|
+
const keys = Object.keys(inputs);
|
|
1172
|
+
const ordered = [
|
|
1173
|
+
...DIGEST_PRIORITY_KEYS.filter((k) => keys.includes(k)),
|
|
1174
|
+
...keys.filter((k) => !DIGEST_PRIORITY_KEYS.includes(k)).sort()
|
|
1175
|
+
];
|
|
1176
|
+
const parts = [];
|
|
1177
|
+
for (const key of ordered) {
|
|
1178
|
+
const value = inputs[key];
|
|
1179
|
+
if (value === undefined || value === null)
|
|
1180
|
+
continue;
|
|
1181
|
+
const rendered = Array.isArray(value) ? value.join(",") : typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
1182
|
+
parts.push(`${key}=${rendered}`);
|
|
1183
|
+
}
|
|
1184
|
+
const joined = parts.join(" ").replace(/\s+/g, " ").trim();
|
|
1185
|
+
return joined.length > 360 ? `${joined.slice(0, 357)}...` : joined;
|
|
1186
|
+
}
|
|
1187
|
+
function countRecords(records) {
|
|
1188
|
+
const counts = {
|
|
1189
|
+
total: records.length,
|
|
1190
|
+
queued: 0,
|
|
1191
|
+
running: 0,
|
|
1192
|
+
blocked: 0,
|
|
1193
|
+
completed: 0,
|
|
1194
|
+
failed: 0,
|
|
1195
|
+
archived: 0,
|
|
1196
|
+
reclaimed: 0
|
|
1197
|
+
};
|
|
1198
|
+
for (const record of records) {
|
|
1199
|
+
counts[record.lifecycle] = (counts[record.lifecycle] || 0) + 1;
|
|
1200
|
+
}
|
|
1201
|
+
return counts;
|
|
1202
|
+
}
|
|
1203
|
+
function optionalLower(value) {
|
|
1204
|
+
if (value === undefined || value === null || value === "")
|
|
1205
|
+
return undefined;
|
|
1206
|
+
return String(value).toLowerCase();
|
|
1207
|
+
}
|
|
1208
|
+
function clampInt(value, fallback, min) {
|
|
1209
|
+
const n = Number(value);
|
|
1210
|
+
if (!Number.isFinite(n))
|
|
1211
|
+
return fallback;
|
|
1212
|
+
return Math.max(min, Math.floor(n));
|
|
1213
|
+
}
|
|
1214
|
+
let queueCounter = 0;
|
|
1215
|
+
function queueId() {
|
|
1216
|
+
queueCounter += 1;
|
|
1217
|
+
const stamp = new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
|
|
1218
|
+
return `q-${stamp}-${String(queueCounter).padStart(3, "0")}`;
|
|
1219
|
+
}
|
|
1220
|
+
function isRunLifecycleState(value) {
|
|
1221
|
+
return typeof value === "string" && LIFECYCLE_STATES.includes(value);
|
|
1222
|
+
}
|
|
1223
|
+
/** Read a run dir's `reclaimed.json` overlay (v0.1.39). Fail-closed to an empty
|
|
1224
|
+
* chain on absence/corruption — a malformed overlay must never brick the run. */
|
|
1225
|
+
function loadReclaimedFromDir(runDir) {
|
|
1226
|
+
const file = node_path_1.default.join(runDir, "reclaimed.json");
|
|
1227
|
+
if (!node_fs_1.default.existsSync(file))
|
|
1228
|
+
return { schemaVersion: 1, runId: "", tombstones: [] };
|
|
1229
|
+
try {
|
|
1230
|
+
const parsed = JSON.parse(node_fs_1.default.readFileSync(file, "utf8"));
|
|
1231
|
+
return { schemaVersion: 1, runId: parsed.runId || "", tombstones: Array.isArray(parsed.tombstones) ? parsed.tombstones : [] };
|
|
1232
|
+
}
|
|
1233
|
+
catch {
|
|
1234
|
+
return { schemaVersion: 1, runId: "", tombstones: [] };
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
// ---------------------------------------------------------------------------
|
|
1238
|
+
// Human formatting (CLI-only; never affects --json / MCP payloads)
|
|
1239
|
+
// ---------------------------------------------------------------------------
|
|
1240
|
+
function countsLine(counts) {
|
|
1241
|
+
return `total=${counts.total} queued=${counts.queued} running=${counts.running} blocked=${counts.blocked} completed=${counts.completed} failed=${counts.failed} archived=${counts.archived} reclaimed=${counts.reclaimed}`;
|
|
1242
|
+
}
|
|
1243
|
+
function recordLine(record) {
|
|
1244
|
+
const flags = [record.archived ? "archived" : "", record.provenance?.rerunOf ? `rerunOf=${record.provenance.rerunOf}` : ""].filter(Boolean).join(" ");
|
|
1245
|
+
return ` [${record.lifecycle}] ${record.runId} (${record.appId || record.workflowId}) ${record.loopStage}${flags ? ` {${flags}}` : ""}`;
|
|
1246
|
+
}
|
|
1247
|
+
function formatRegistryReport(report) {
|
|
1248
|
+
const lines = [];
|
|
1249
|
+
lines.push(`Run Registry (${report.scope}): ${report.root}`);
|
|
1250
|
+
lines.push(`Freshness: ${report.freshness.status}${report.freshness.staleRuns.length ? ` (stale: ${report.freshness.staleRuns.join(", ")})` : ""}${report.freshness.missingRuns.length ? ` (missing: ${report.freshness.missingRuns.join(", ")})` : ""}`);
|
|
1251
|
+
lines.push(`Repos: ${report.index.repos.length}`);
|
|
1252
|
+
lines.push(countsLine(report.counts));
|
|
1253
|
+
if (report.freshness.status !== "valid")
|
|
1254
|
+
lines.push(`Next Action: ${report.nextAction}`);
|
|
1255
|
+
return lines.join("\n");
|
|
1256
|
+
}
|
|
1257
|
+
function formatRunSearch(result) {
|
|
1258
|
+
const lines = [];
|
|
1259
|
+
lines.push(`Run Search (${result.scope}): ${result.total} match(es), showing ${result.records.length} [offset ${result.offset}] freshness=${result.freshness}`);
|
|
1260
|
+
for (const record of result.records)
|
|
1261
|
+
lines.push(recordLine(record));
|
|
1262
|
+
if (!result.records.length)
|
|
1263
|
+
lines.push(" (no matching runs)");
|
|
1264
|
+
return lines.join("\n");
|
|
1265
|
+
}
|
|
1266
|
+
function formatRunShow(result) {
|
|
1267
|
+
if (!result.found) {
|
|
1268
|
+
return `Run ${result.runId}: MISSING (source state.json absent — fail closed). Next: ${result.nextAction}`;
|
|
1269
|
+
}
|
|
1270
|
+
const r = result.record;
|
|
1271
|
+
const lines = [
|
|
1272
|
+
`Run ${r.runId} [${r.lifecycle}] (derived: ${r.derivedLifecycle})`,
|
|
1273
|
+
` app=${r.appId || r.workflowId} loopStage=${r.loopStage} repo=${r.repo}`,
|
|
1274
|
+
` tasks: total=${r.tasks.total} pending=${r.tasks.pending} running=${r.tasks.running} failed=${r.tasks.failed} completed=${r.tasks.completed}`,
|
|
1275
|
+
` commits=${r.commitCount} (verifier-gated=${r.verifierGatedCommitCount}) openFeedback=${r.openFeedbackCount}`
|
|
1276
|
+
];
|
|
1277
|
+
if (r.provenance?.rerunOf)
|
|
1278
|
+
lines.push(` provenance: rerunOf=${r.provenance.rerunOf} gen=${r.provenance.generation} origin=${r.provenance.originRunId}`);
|
|
1279
|
+
if (r.tier && r.tier !== "live") {
|
|
1280
|
+
lines.push(` tier=${r.tier} capability=${r.capability} reason=${r.capabilityReason}${r.reclaimedBytes ? ` bytesFreed=${r.reclaimedBytes}` : ""}${r.tombstoneHash ? ` tombstone=${r.tombstoneHash.slice(0, 19)}` : ""}`);
|
|
1281
|
+
}
|
|
1282
|
+
return lines.join("\n");
|
|
1283
|
+
}
|
|
1284
|
+
function formatGcPlan(result) {
|
|
1285
|
+
const lines = [
|
|
1286
|
+
`GC Plan (${result.scope}): ${result.eligibleCount}/${result.total} eligible, ${result.bytesToFree} byte(s) would be freed [DRY-RUN, frees nothing]`,
|
|
1287
|
+
` policy: reclaimAfterArchiveDays=${result.policy.reclaimAfterArchiveDays} keepScratch=${result.policy.keepScratch} keepSnapshots=${result.policy.keepSnapshots}`
|
|
1288
|
+
];
|
|
1289
|
+
for (const entry of result.entries) {
|
|
1290
|
+
if (entry.eligible) {
|
|
1291
|
+
const kinds = Object.entries(entry.byKind).map(([k, v]) => `${k}=${v}`).join(" ");
|
|
1292
|
+
lines.push(` [eligible] ${entry.runId} -> ${entry.capability} (${entry.capabilityReason}) ${entry.bytesToFree}B {${kinds}}`);
|
|
1293
|
+
}
|
|
1294
|
+
else {
|
|
1295
|
+
lines.push(` [skip:${entry.reason}] ${entry.runId} (tier=${entry.tier})`);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
if (!result.entries.length)
|
|
1299
|
+
lines.push(" (no runs in scope)");
|
|
1300
|
+
return lines.join("\n");
|
|
1301
|
+
}
|
|
1302
|
+
function formatGcRun(result) {
|
|
1303
|
+
const lines = [`GC Run (${result.scope}): reclaimed ${result.reclaimed.length} run(s), freed ${result.totalBytesFreed} byte(s)`];
|
|
1304
|
+
for (const r of result.reclaimed)
|
|
1305
|
+
lines.push(` [reclaimed] ${r.runId} -> ${r.capability} (${r.capabilityReason}) ${r.bytesFreed}B tombstone=${r.tombstoneHash.slice(0, 19)}`);
|
|
1306
|
+
for (const r of result.refused)
|
|
1307
|
+
lines.push(` [refused:${r.code}] ${r.runId}`);
|
|
1308
|
+
if (!result.reclaimed.length && !result.refused.length)
|
|
1309
|
+
lines.push(" (nothing eligible)");
|
|
1310
|
+
return lines.join("\n");
|
|
1311
|
+
}
|
|
1312
|
+
function formatGcVerify(result) {
|
|
1313
|
+
const lines = [
|
|
1314
|
+
`GC Verify ${result.runId}: reclaimed=${result.reclaimed} verified=${result.verified} tier=${result.tier} capability=${result.capability}${result.tombstoneHash ? ` tombstone=${result.tombstoneHash.slice(0, 19)}` : ""}`
|
|
1315
|
+
];
|
|
1316
|
+
for (const check of result.checks)
|
|
1317
|
+
lines.push(` ${check.pass ? "PASS" : "FAIL"} ${check.name}${check.code ? ` [${check.code}]` : ""}${check.detail ? ` (${check.detail})` : ""}`);
|
|
1318
|
+
return lines.join("\n");
|
|
1319
|
+
}
|
|
1320
|
+
function formatResume(result) {
|
|
1321
|
+
const lines = [
|
|
1322
|
+
`Resume ${result.runId} [${result.lifecycle}] loopStage=${result.loopStage} (resolved from ${result.resolvedFrom}, ${result.freshness})`,
|
|
1323
|
+
` resumable=${result.resumable} nextTasks=${result.nextTasks.length}`
|
|
1324
|
+
];
|
|
1325
|
+
for (const action of result.nextActions)
|
|
1326
|
+
lines.push(` -> ${action.command}\n ${action.reason}`);
|
|
1327
|
+
return lines.join("\n");
|
|
1328
|
+
}
|
|
1329
|
+
function formatHistory(result) {
|
|
1330
|
+
const lines = [];
|
|
1331
|
+
lines.push(`Run History (${result.scope}): ${result.total} run(s) across ${result.repos.length} repo(s), freshness=${result.freshness}`);
|
|
1332
|
+
for (const entry of result.entries) {
|
|
1333
|
+
lines.push(` ${entry.createdAt} [${entry.lifecycle}] ${entry.runId} (${entry.appId || entry.workflowId})${entry.provenance?.rerunOf ? ` rerunOf=${entry.provenance.rerunOf}` : ""}`);
|
|
1334
|
+
}
|
|
1335
|
+
if (!result.entries.length)
|
|
1336
|
+
lines.push(" (no runs)");
|
|
1337
|
+
return lines.join("\n");
|
|
1338
|
+
}
|
|
1339
|
+
function formatQueueList(result) {
|
|
1340
|
+
const lines = [`Run Queue: ${result.total} entry(ies) [priority asc]`];
|
|
1341
|
+
for (const entry of result.entries) {
|
|
1342
|
+
lines.push(` #${entry.priority} ${entry.id} [${entry.status}] ${entry.appId || entry.workflowId || entry.runId || "?"} repo=${entry.repo}${entry.note ? ` note=${entry.note}` : ""}`);
|
|
1343
|
+
}
|
|
1344
|
+
if (!result.entries.length)
|
|
1345
|
+
lines.push(" (queue empty)");
|
|
1346
|
+
return lines.join("\n");
|
|
1347
|
+
}
|