cool-workflow 0.2.1 → 0.2.3
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/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 +29 -91
- package/dist/cli/entry.js +41 -1
- package/dist/cli/io.js +6 -2
- package/dist/cli/parseargv.js +6 -2
- package/dist/core/capability-data.js +271 -0
- package/dist/core/capability-table.js +16 -3504
- package/dist/core/format/completion.js +68 -0
- package/dist/core/format/help.js +50 -8
- package/dist/core/format/safe-json.js +73 -0
- package/dist/core/format/state-explosion-text.js +1 -1
- package/dist/core/multi-agent/candidate-scoring.js +5 -1
- package/dist/core/multi-agent/collaboration.js +6 -5
- package/dist/core/multi-agent/coordinator.js +9 -8
- package/dist/core/multi-agent/runtime.js +4 -4
- package/dist/core/multi-agent/topology.js +3 -3
- package/dist/core/pipeline/commit-gate.js +2 -1
- package/dist/core/pipeline/dispatch.js +18 -4
- package/dist/core/pipeline/drive-decide.js +2 -1
- package/dist/core/state/migrations.js +16 -1
- package/dist/core/state/state-explosion/digest.js +16 -15
- package/dist/core/state/state-explosion/graph.js +26 -19
- package/dist/core/state/state-explosion/helpers.js +2 -1
- package/dist/core/state/state-explosion/report.js +6 -6
- package/dist/core/trust/ledger.js +2 -1
- package/dist/core/types/execution-backend.js +18 -0
- package/dist/core/types/observability.js +7 -0
- package/dist/core/util/collate.js +23 -0
- package/dist/core/util/numeric-flag.js +40 -0
- package/dist/core/version.js +1 -1
- package/dist/mcp/server.js +99 -11
- package/dist/shell/audit-cli.js +99 -23
- package/dist/shell/commit-summary.js +2 -1
- package/dist/shell/coordinator-io.js +73 -13
- package/dist/shell/dispatch.js +1 -1
- package/dist/shell/doctor.js +80 -1
- package/dist/shell/drive.js +269 -59
- package/dist/shell/eval-text.js +2 -2
- package/dist/shell/evidence-reasoning.js +8 -7
- package/dist/shell/execution-backend/agent.js +20 -1
- package/dist/shell/execution-backend/container.js +4 -1
- package/dist/shell/execution-backend/local.js +4 -1
- package/dist/shell/execution-backend/registry.js +2 -1
- package/dist/shell/execution-backend/types.js +0 -9
- package/dist/shell/feedback-cli.js +6 -6
- package/dist/shell/fs-atomic.js +232 -30
- package/dist/shell/man-cli.js +6 -0
- package/dist/shell/metrics-cli.js +2 -1
- package/dist/shell/multi-agent-cli.js +367 -323
- package/dist/shell/multi-agent-host.js +9 -9
- package/dist/shell/multi-agent-operator-ux.js +82 -39
- package/dist/shell/node-store.js +10 -4
- package/dist/shell/observability.js +13 -12
- package/dist/shell/onramp.js +17 -1
- package/dist/shell/operator-ux-text.js +24 -23
- package/dist/shell/operator-ux.js +22 -20
- package/dist/shell/orchestrator.js +49 -38
- package/dist/shell/pipeline-cli.js +168 -111
- package/dist/shell/reclamation-io.js +16 -5
- package/dist/shell/registry-cli.js +19 -17
- package/dist/shell/remote-source.js +13 -8
- package/dist/shell/report-cli.js +45 -0
- package/dist/shell/report.js +4 -2
- package/dist/shell/run-registry-io.js +77 -19
- package/dist/shell/run-store.js +91 -2
- package/dist/shell/scheduling-io.js +12 -13
- package/dist/shell/state-cli.js +2 -7
- package/dist/shell/state-explosion-cli.js +19 -10
- package/dist/shell/topology-io.js +38 -6
- package/dist/shell/trust-audit.js +264 -23
- package/dist/shell/trust-policy-io.js +1 -1
- package/dist/shell/worker-cli.js +41 -29
- package/dist/shell/worker-isolation.js +34 -9
- package/dist/shell/workflow-app-loader.js +3 -2
- package/dist/wiring/capability-table/basics.js +91 -0
- package/dist/wiring/capability-table/exec-backend.js +171 -0
- package/dist/wiring/capability-table/index.js +42 -0
- package/dist/wiring/capability-table/multi-agent.js +630 -0
- package/dist/wiring/capability-table/parity.js +467 -0
- package/dist/wiring/capability-table/pipeline.js +292 -0
- package/dist/wiring/capability-table/registry-core.js +208 -0
- package/dist/wiring/capability-table/reporting.js +435 -0
- package/dist/wiring/capability-table/scheduling-registry.js +592 -0
- package/dist/wiring/capability-table/state.js +181 -0
- package/dist/wiring/capability-table/trust-ledger.js +158 -0
- package/dist/wiring/capability-table/workflow-apps.js +381 -0
- package/docs/agent-delegation-drive.7.md +4 -0
- package/docs/candidate-scoring.7.md +1 -1
- package/docs/canonical-workflow-apps.7.md +7 -7
- package/docs/cli-mcp-parity.7.md +13 -3
- package/docs/contract-migration-tooling.7.md +4 -0
- package/docs/control-plane-scheduling.7.md +4 -0
- package/docs/coordinator-blackboard.7.md +17 -17
- package/docs/dogfood-one-real-repo.7.md +11 -11
- package/docs/durable-state-and-locking.7.md +24 -0
- package/docs/end-to-end-golden-path.7.md +14 -14
- package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
- package/docs/execution-backends.7.md +4 -0
- package/docs/getting-started.md +37 -37
- package/docs/index.md +1 -0
- package/docs/multi-agent-cli-mcp-surface.7.md +21 -17
- package/docs/multi-agent-eval-replay-harness.7.md +17 -13
- package/docs/multi-agent-operator-ux.7.md +23 -19
- package/docs/multi-agent-runtime-core.7.md +22 -22
- package/docs/multi-agent-topologies.7.md +6 -6
- package/docs/multi-agent-trust-policy-audit.7.md +11 -11
- package/docs/node-snapshot-diff-replay.7.md +4 -0
- package/docs/observability-cost-accounting.7.md +4 -0
- package/docs/operator-ux.7.md +34 -34
- package/docs/pipeline-runner.7.md +4 -4
- package/docs/project-index.md +48 -5
- package/docs/real-execution-backends.7.md +4 -0
- package/docs/release-and-migration.7.md +5 -1
- package/docs/release-tooling.7.md +4 -0
- package/docs/routines.md +4 -4
- package/docs/run-registry-control-plane.7.md +23 -19
- package/docs/run-retention-reclamation.7.md +4 -0
- package/docs/scheduled-tasks.md +14 -14
- package/docs/security-trust-hardening.7.md +43 -13
- package/docs/state-explosion-management.7.md +14 -10
- package/docs/team-collaboration.7.md +4 -0
- package/docs/trust-audit-anchor.7.md +71 -0
- package/docs/unix-principles.md +3 -1
- package/docs/verifier-gated-commit.7.md +1 -1
- package/docs/web-desktop-workbench.7.md +4 -0
- package/docs/workflow-app-framework.7.md +13 -13
- package/manifest/plugin.manifest.json +1 -1
- package/package.json +4 -2
- package/scripts/agents/claude-p-agent.js +2 -2
- package/scripts/block-unapproved-tag.sh +23 -2
- package/scripts/bump-version.js +24 -2
- package/scripts/canonical-apps.js +4 -4
- package/scripts/children/batch-delegate-child.js +52 -2
- package/scripts/dogfood-release.js +1 -1
- package/scripts/fake-date-for-reproduction.js +44 -0
- package/scripts/golden-path.js +4 -4
- package/scripts/purity-baseline.json +71 -0
- package/scripts/purity-gate.js +239 -0
- package/scripts/release-check.js +8 -1
- package/scripts/release-flow.js +57 -1
- package/scripts/verdict-keygen.js +83 -0
- package/scripts/verify-bump-reproduction.sh +148 -0
- package/scripts/verify-verdict-signature.js +61 -0
- package/scripts/version-sync-check.js +33 -12
- package/skills/cool-workflow/SKILL.md +9 -9
- package/skills/cool-workflow/references/commands.md +89 -88
- package/ui/workbench/app.css +37 -1
- package/ui/workbench/app.js +124 -6
- package/workflows/README.md +19 -0
|
@@ -82,6 +82,7 @@ exports.clonesListCli = clonesListCli;
|
|
|
82
82
|
exports.clonesGcCli = clonesGcCli;
|
|
83
83
|
const fs = __importStar(require("node:fs"));
|
|
84
84
|
const path = __importStar(require("node:path"));
|
|
85
|
+
const numeric_flag_1 = require("../core/util/numeric-flag");
|
|
85
86
|
const run_registry_io_1 = require("./run-registry-io");
|
|
86
87
|
const pipeline_1 = require("./pipeline");
|
|
87
88
|
const workflow_app_loader_1 = require("./workflow-app-loader");
|
|
@@ -186,16 +187,16 @@ function runSearchCli(options = {}) {
|
|
|
186
187
|
since: optionalString(options.since),
|
|
187
188
|
until: optionalString(options.until),
|
|
188
189
|
includeArchived: options.includeArchived === undefined ? undefined : Boolean(options.includeArchived),
|
|
189
|
-
limit:
|
|
190
|
-
offset:
|
|
190
|
+
limit: (0, numeric_flag_1.requiredNumberFlag)(options.limit, "--limit"),
|
|
191
|
+
offset: (0, numeric_flag_1.requiredNumberFlag)(options.offset, "--offset"),
|
|
191
192
|
});
|
|
192
193
|
}
|
|
193
194
|
function runListCli(options = {}) {
|
|
194
195
|
return new run_registry_io_1.RunRegistry(resolveCwd(options)).list({
|
|
195
196
|
scope: scopeOf(options, "home"),
|
|
196
197
|
includeArchived: options.includeArchived === undefined ? undefined : Boolean(options.includeArchived),
|
|
197
|
-
limit:
|
|
198
|
-
offset:
|
|
198
|
+
limit: (0, numeric_flag_1.requiredNumberFlag)(options.limit, "--limit"),
|
|
199
|
+
offset: (0, numeric_flag_1.requiredNumberFlag)(options.offset, "--offset"),
|
|
199
200
|
});
|
|
200
201
|
}
|
|
201
202
|
function runShowCli(runId, options = {}) {
|
|
@@ -205,21 +206,21 @@ function runShowCli(runId, options = {}) {
|
|
|
205
206
|
* is read-only and byte-identical to the registry resume payload; with
|
|
206
207
|
* `--drive`/`--once` the SAME run (nothing re-planned) is handed to the
|
|
207
208
|
* real drive loop and the payload gains a `drive: DriveResult` field. */
|
|
208
|
-
function runResumeCli(runId, options = {}) {
|
|
209
|
+
async function runResumeCli(runId, options = {}) {
|
|
209
210
|
const base = new run_registry_io_1.RunRegistry(resolveCwd(options)).resume(runId, {
|
|
210
211
|
scope: scopeOf(options, "home"),
|
|
211
|
-
limit:
|
|
212
|
+
limit: (0, numeric_flag_1.requiredNumberFlag)(options.limit, "--limit"),
|
|
212
213
|
});
|
|
213
214
|
if (!options.drive && !options.once)
|
|
214
215
|
return base;
|
|
215
|
-
const drive = (0, pipeline_cli_1.runDriveStep)({ ...options, runId: base.runId, repo: base.repo, once: Boolean(options.once) });
|
|
216
|
+
const drive = await (0, pipeline_cli_1.runDriveStep)({ ...options, runId: base.runId, repo: base.repo, once: Boolean(options.once) });
|
|
216
217
|
return { ...base, drive };
|
|
217
218
|
}
|
|
218
219
|
function runArchiveCli(runId, options = {}) {
|
|
219
220
|
const registry = new run_registry_io_1.RunRegistry(resolveCwd(options));
|
|
220
221
|
if (!runId) {
|
|
221
|
-
const olderThanDays =
|
|
222
|
-
if (
|
|
222
|
+
const olderThanDays = (0, numeric_flag_1.requiredNumberFlag)(options.olderThanDays ?? options["older-than-days"], "--older-than-days");
|
|
223
|
+
if (olderThanDays === undefined)
|
|
223
224
|
throw new Error("Missing run id (or --older-than-days N for the retention policy path).");
|
|
224
225
|
const states = Array.isArray(options.state) ? options.state : options.state ? [options.state] : undefined;
|
|
225
226
|
return registry.archiveByPolicy({
|
|
@@ -253,8 +254,8 @@ function historyCli(options = {}) {
|
|
|
253
254
|
scope: scopeOf(options, "home"),
|
|
254
255
|
app: optionalString(options.app),
|
|
255
256
|
status: optionalString(options.status),
|
|
256
|
-
limit:
|
|
257
|
-
offset:
|
|
257
|
+
limit: (0, numeric_flag_1.requiredNumberFlag)(options.limit, "--limit"),
|
|
258
|
+
offset: (0, numeric_flag_1.requiredNumberFlag)(options.offset, "--offset"),
|
|
258
259
|
});
|
|
259
260
|
}
|
|
260
261
|
function queueAddCli(options = {}) {
|
|
@@ -263,7 +264,7 @@ function queueAddCli(options = {}) {
|
|
|
263
264
|
appId: optionalString(options.app || options.appId),
|
|
264
265
|
workflowId: optionalString(options.workflow || options.workflowId),
|
|
265
266
|
repo: optionalString(options.repo),
|
|
266
|
-
priority:
|
|
267
|
+
priority: (0, numeric_flag_1.requiredNumberFlag)(options.priority, "--priority"),
|
|
267
268
|
note: optionalString(options.note),
|
|
268
269
|
id: optionalString(options.id),
|
|
269
270
|
});
|
|
@@ -276,7 +277,7 @@ function queueShowCli(id, options = {}) {
|
|
|
276
277
|
}
|
|
277
278
|
function queueDrainCli(options = {}) {
|
|
278
279
|
return new run_registry_io_1.RunRegistry(resolveCwd(options)).queueDrain({
|
|
279
|
-
limit:
|
|
280
|
+
limit: (0, numeric_flag_1.requiredNumberFlag)(options.limit, "--limit"),
|
|
280
281
|
repo: optionalString(options.repo),
|
|
281
282
|
});
|
|
282
283
|
}
|
|
@@ -285,8 +286,9 @@ function queueDrainCli(options = {}) {
|
|
|
285
286
|
// ---------------------------------------------------------------------
|
|
286
287
|
function gcPolicyOverridesFrom(options) {
|
|
287
288
|
const overrides = {};
|
|
288
|
-
|
|
289
|
-
|
|
289
|
+
const reclaimAfterArchiveDays = (0, numeric_flag_1.requiredNumberFlag)(options.reclaimAfterArchiveDays, "--reclaimAfterArchiveDays");
|
|
290
|
+
if (reclaimAfterArchiveDays !== undefined)
|
|
291
|
+
overrides.reclaimAfterArchiveDays = reclaimAfterArchiveDays;
|
|
290
292
|
if (options.keepScratch !== undefined)
|
|
291
293
|
overrides.keepScratch = Boolean(options.keepScratch);
|
|
292
294
|
if (options["keep-scratch"] !== undefined)
|
|
@@ -316,7 +318,7 @@ function gcRunCli(runId, options = {}) {
|
|
|
316
318
|
policy: gcPolicyOverridesFrom(options),
|
|
317
319
|
now: optionalString(options.now),
|
|
318
320
|
actor: optionalString(options.actor),
|
|
319
|
-
limit:
|
|
321
|
+
limit: (0, numeric_flag_1.requiredNumberFlag)(options.limit, "--limit"),
|
|
320
322
|
});
|
|
321
323
|
}
|
|
322
324
|
function gcVerifyCli(runId, options = {}) {
|
|
@@ -331,7 +333,7 @@ function orphansGcCli(options = {}) {
|
|
|
331
333
|
const registry = new run_registry_io_1.RunRegistry(resolveCwd(options));
|
|
332
334
|
return (0, reclamation_io_1.gcOrphanRuns)(registry, {
|
|
333
335
|
scope: scopeOf(options, "home"),
|
|
334
|
-
minAgeMinutes:
|
|
336
|
+
minAgeMinutes: (0, numeric_flag_1.requiredNumberFlag)(options.minAgeMinutes ?? options["min-age-minutes"], "--min-age-minutes"),
|
|
335
337
|
all: Boolean(options.all),
|
|
336
338
|
now: optionalString(options.now),
|
|
337
339
|
});
|
|
@@ -310,9 +310,12 @@ function fetchArchiveBytes(rawUrl, sanitizedUrl, dest, opts) {
|
|
|
310
310
|
}
|
|
311
311
|
/** List an archive's entry NAMES WITHOUT extracting (the zip-slip/tar-slip name guard runs on
|
|
312
312
|
* this). Symlinks/specials and decompression bombs are caught separately (below). */
|
|
313
|
-
function listArchive(file, isZip) {
|
|
313
|
+
function listArchive(file, isZip, timeoutMs) {
|
|
314
314
|
const cmd = isZip ? ["unzip", "-Z1", "--", file] : ["tar", "-tf", file];
|
|
315
|
-
|
|
315
|
+
// Same unset-timeout hazard as a hung local/container task (post-v0.2.2
|
|
316
|
+
// robustness hardening): a pathological archive must not hang the CLI
|
|
317
|
+
// forever with no kill path. Matches git()'s own fallback below.
|
|
318
|
+
const result = (0, node_child_process_1.spawnSync)(cmd[0], cmd.slice(1), { encoding: "utf8", timeout: timeoutMs || 120000 });
|
|
316
319
|
if (result.status !== 0) {
|
|
317
320
|
// Distinguish "unzip not installed" (ENOENT) from "archive is corrupt" — never conflate the
|
|
318
321
|
// two (a corrupt .tar mislabeled .zip used to surface a bogus "unzip not found").
|
|
@@ -338,10 +341,10 @@ function assertNoTraversal(entries, sanitizedUrl) {
|
|
|
338
341
|
/** Declared uncompressed size, read WITHOUT extracting — gzip's ISIZE trailer for `.tar.gz`/
|
|
339
342
|
* `.tgz`, or `unzip -l`'s total for `.zip`. Best-effort (undefined when unknown); the
|
|
340
343
|
* post-extraction walk is authoritative. Lets us reject a bomb BEFORE it fills the disk. */
|
|
341
|
-
function declaredUncompressedSize(file, isZip) {
|
|
344
|
+
function declaredUncompressedSize(file, isZip, timeoutMs) {
|
|
342
345
|
try {
|
|
343
346
|
if (isZip) {
|
|
344
|
-
const r = (0, node_child_process_1.spawnSync)("unzip", ["-l", "--", file], { encoding: "utf8" });
|
|
347
|
+
const r = (0, node_child_process_1.spawnSync)("unzip", ["-l", "--", file], { encoding: "utf8", timeout: timeoutMs || 120000 });
|
|
345
348
|
if (r.status !== 0)
|
|
346
349
|
return undefined;
|
|
347
350
|
const lines = String(r.stdout || "").trim().split(/\r?\n/);
|
|
@@ -421,15 +424,17 @@ function downloadArchive(rawUrl, sanitizedUrl, opts) {
|
|
|
421
424
|
const commit = (0, node_crypto_1.createHash)("sha256").update(node_fs_1.default.readFileSync(tmpFile)).digest("hex"); // content address
|
|
422
425
|
// Bomb defense (BEFORE extracting, to avoid filling the disk): reject a declared
|
|
423
426
|
// uncompressed size over the cap. assertSafeTree re-checks the ACTUAL size afterward.
|
|
424
|
-
const declared = declaredUncompressedSize(tmpFile, isZip);
|
|
427
|
+
const declared = declaredUncompressedSize(tmpFile, isZip, opts.timeoutMs);
|
|
425
428
|
if (declared !== undefined && declared > MAX_EXTRACTED_BYTES) {
|
|
426
429
|
throw new Error(`refusing archive ${sanitizedUrl}: declared uncompressed size ${declared} exceeds ${MAX_EXTRACTED_BYTES} bytes (possible decompression bomb)`);
|
|
427
430
|
}
|
|
428
|
-
assertNoTraversal(listArchive(tmpFile, isZip), sanitizedUrl);
|
|
431
|
+
assertNoTraversal(listArchive(tmpFile, isZip, opts.timeoutMs), sanitizedUrl);
|
|
429
432
|
const extractDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(staging, "x-"));
|
|
433
|
+
// Same unset-timeout hazard as above: extraction runs on downloaded,
|
|
434
|
+
// attacker-influenced bytes and must not hang forever.
|
|
430
435
|
const ex = isZip
|
|
431
|
-
? (0, node_child_process_1.spawnSync)("unzip", ["-q", "-o", "-d", extractDir, "--", tmpFile], { encoding: "utf8" })
|
|
432
|
-
: (0, node_child_process_1.spawnSync)("tar", ["-xf", tmpFile, "-C", extractDir], { encoding: "utf8" });
|
|
436
|
+
? (0, node_child_process_1.spawnSync)("unzip", ["-q", "-o", "-d", extractDir, "--", tmpFile], { encoding: "utf8", timeout: opts.timeoutMs || 120000 })
|
|
437
|
+
: (0, node_child_process_1.spawnSync)("tar", ["-xf", tmpFile, "-C", extractDir], { encoding: "utf8", timeout: opts.timeoutMs || 120000 });
|
|
433
438
|
if (ex.status !== 0)
|
|
434
439
|
throw new Error(`could not extract archive ${sanitizedUrl}: ${String(ex.stderr || "").trim() || `exit ${ex.status}`}`);
|
|
435
440
|
// Fail closed on symlinks/specials and on an over-cap actual extracted size.
|
package/dist/shell/report-cli.js
CHANGED
|
@@ -43,9 +43,11 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
43
43
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
44
44
|
exports.reportBundleCli = reportBundleCli;
|
|
45
45
|
exports.reportVerifyBundleCli = reportVerifyBundleCli;
|
|
46
|
+
exports.formatReportVerifyBundle = formatReportVerifyBundle;
|
|
46
47
|
const path = __importStar(require("node:path"));
|
|
47
48
|
const run_export_1 = require("./run-export");
|
|
48
49
|
const run_store_1 = require("./run-store");
|
|
50
|
+
const term_1 = require("./term");
|
|
49
51
|
function optionalString(value) {
|
|
50
52
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
51
53
|
}
|
|
@@ -99,3 +101,46 @@ function reportVerifyBundleCli(args) {
|
|
|
99
101
|
requireSignatures: Boolean(args["require-signatures"] || args.requireSignatures || args.requireSigs),
|
|
100
102
|
});
|
|
101
103
|
}
|
|
104
|
+
/** `cw report verify-bundle`'s human render (default; `--json` prints
|
|
105
|
+
* `ReportBundleVerification` verbatim instead). Mirrors `doctor`'s
|
|
106
|
+
* checks-list-then-verdict shape (`shell/doctor.ts`'s
|
|
107
|
+
* `formatDoctorReport`) rather than `demo bundle`'s narrative one
|
|
108
|
+
* (`shell/telemetry-demo.ts`'s `formatBundleDemo`) — verify-bundle is a
|
|
109
|
+
* flat set of pass/fail checks on one already-sealed archive, not a
|
|
110
|
+
* multi-step forge-and-catch demonstration. */
|
|
111
|
+
function formatReportVerifyBundle(r) {
|
|
112
|
+
const lines = [(0, term_1.bold)(`cw report verify-bundle ${r.archivePath}`), ""];
|
|
113
|
+
const checks = [
|
|
114
|
+
[r.archiveOk, "archive intact — file digests match, no tamper"],
|
|
115
|
+
[r.telemetryVerified, "telemetry hash chain verifies"],
|
|
116
|
+
[r.trustAuditVerified, "trust-audit chain verifies"],
|
|
117
|
+
[r.reportFindingsVerified, "report.md matches every signed result"],
|
|
118
|
+
];
|
|
119
|
+
for (const [ok, label] of checks)
|
|
120
|
+
lines.push(` ${(0, term_1.doctorGlyph)(ok ? "ok" : "fail")} ${label}`);
|
|
121
|
+
const sigDetail = r.signatureKeyProvided
|
|
122
|
+
? `${r.signaturesReverified}/${r.signaturesChecked} signature(s) reverified (key source: ${r.trustKeySource})`
|
|
123
|
+
: "no public key available — signatures not checked";
|
|
124
|
+
lines.push(` trust: ${r.trustLevel} — ${sigDetail}`);
|
|
125
|
+
if (r.reportExtractedTo)
|
|
126
|
+
lines.push(` report.md extracted to: ${r.reportExtractedTo}`);
|
|
127
|
+
if (r.failedChecks.length > 0) {
|
|
128
|
+
lines.push("");
|
|
129
|
+
lines.push(" Failed checks");
|
|
130
|
+
for (const c of r.failedChecks) {
|
|
131
|
+
// Most check sites in run-export.ts's verifyReportBundle set `code`
|
|
132
|
+
// to a short slug (e.g. "digest-mismatch"); the top-level restore
|
|
133
|
+
// catch-all sets it to the caught error's full message instead
|
|
134
|
+
// (run-export.ts's "restore" failedChecks.push) — too long/sentence-
|
|
135
|
+
// shaped to cram into "name [code]" without reading as a run-on.
|
|
136
|
+
// Give it its own indented line instead.
|
|
137
|
+
const isSlug = c.code !== undefined && c.code.length <= 40 && !c.code.includes(" ");
|
|
138
|
+
lines.push(` - ${c.name}${isSlug ? ` [${c.code}]` : ""}`);
|
|
139
|
+
if (c.code !== undefined && !isSlug)
|
|
140
|
+
lines.push(` ${c.code}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
lines.push("");
|
|
144
|
+
lines.push(`${(0, term_1.doctorGlyph)(r.ok ? "ok" : "fail")} ${r.ok ? "bundle verifies" : "bundle verification FAILED"}`);
|
|
145
|
+
return lines.join("\n");
|
|
146
|
+
}
|
package/dist/shell/report.js
CHANGED
|
@@ -59,13 +59,14 @@ const telemetry_ledger_io_1 = require("./telemetry-ledger-io");
|
|
|
59
59
|
const multi_agent_io_1 = require("./multi-agent-io");
|
|
60
60
|
const coordinator_io_1 = require("./coordinator-io");
|
|
61
61
|
const multi_agent_operator_ux_1 = require("./multi-agent-operator-ux");
|
|
62
|
+
const collate_1 = require("../core/util/collate");
|
|
62
63
|
function formatInputList(value) {
|
|
63
64
|
if (Array.isArray(value))
|
|
64
65
|
return value.join("; ");
|
|
65
66
|
return value ? String(value) : "";
|
|
66
67
|
}
|
|
67
68
|
function formatCounts(counts) {
|
|
68
|
-
const entries = Object.entries(counts).sort(([a], [b]) =>
|
|
69
|
+
const entries = Object.entries(counts).sort(([a], [b]) => (0, collate_1.stableCompare)(a, b));
|
|
69
70
|
if (!entries.length)
|
|
70
71
|
return "none";
|
|
71
72
|
return entries.map(([k, v]) => `${k}=${v}`).join(", ");
|
|
@@ -337,7 +338,8 @@ function writeReport(run) {
|
|
|
337
338
|
"| Phase | Status | Completed | Total |",
|
|
338
339
|
"| --- | --- | ---: | ---: |",
|
|
339
340
|
...run.phases.map((phase) => {
|
|
340
|
-
const
|
|
341
|
+
const taskIds = new Set(phase.taskIds);
|
|
342
|
+
const phaseTasks = run.tasks.filter((t) => taskIds.has(t.id));
|
|
341
343
|
const completed = phaseTasks.filter((t) => t.status === "completed").length;
|
|
342
344
|
return `| ${phase.name} | ${phase.status} | ${completed} | ${phaseTasks.length} |`;
|
|
343
345
|
}),
|
|
@@ -207,10 +207,25 @@ function clampInt(value, fallback, min) {
|
|
|
207
207
|
return Math.max(min, Math.floor(n));
|
|
208
208
|
}
|
|
209
209
|
let queueCounter = 0;
|
|
210
|
-
|
|
211
|
-
|
|
210
|
+
/** Byte format is `q-${stamp14}-${NNN}` (SPEC/scheduling-registry.md "Id
|
|
211
|
+
* formats"), unchanged. `queueCounter` alone is not enough to keep ids
|
|
212
|
+
* unique: it resets to 0 for every fresh `cw` process, so two SEPARATE
|
|
213
|
+
* processes calling `queue add` within the same second both compute
|
|
214
|
+
* `queueCounter === 1` and mint the IDENTICAL id — `sched lease` then
|
|
215
|
+
* keys grants by id, so both queue entries get treated as one, breaking
|
|
216
|
+
* through `maxConcurrent`. `existingIds` is the current queue file's ids
|
|
217
|
+
* (read inside queueAdd's own file lock, so this sees every entry any
|
|
218
|
+
* earlier-completed `queue add` — same process or not — has already
|
|
219
|
+
* saved); bumping past any collision against that real, shared state
|
|
220
|
+
* keeps ids unique across processes without changing the format. */
|
|
221
|
+
function queueId(existingIds) {
|
|
212
222
|
const stamp = new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
|
|
213
|
-
|
|
223
|
+
let candidate;
|
|
224
|
+
do {
|
|
225
|
+
queueCounter += 1;
|
|
226
|
+
candidate = `q-${stamp}-${String(queueCounter).padStart(3, "0")}`;
|
|
227
|
+
} while (existingIds && existingIds.has(candidate));
|
|
228
|
+
return candidate;
|
|
214
229
|
}
|
|
215
230
|
function isRunLifecycleState(value) {
|
|
216
231
|
return (typeof value === "string" &&
|
|
@@ -397,7 +412,7 @@ class RunRegistry {
|
|
|
397
412
|
const entries = this.loadQueueEntries();
|
|
398
413
|
const entry = {
|
|
399
414
|
schemaVersion: 1,
|
|
400
|
-
id: options.id || queueId(),
|
|
415
|
+
id: options.id || queueId(new Set(entries.map((e) => e.id))),
|
|
401
416
|
runId: options.runId,
|
|
402
417
|
appId: options.appId,
|
|
403
418
|
workflowId: options.workflowId,
|
|
@@ -462,6 +477,13 @@ class RunRegistry {
|
|
|
462
477
|
const result = (0, run_store_1.loadRunStateFile)(statePath, { dryRun: true });
|
|
463
478
|
if (result.report.status === "unsupported")
|
|
464
479
|
return null;
|
|
480
|
+
// Bulk scans (refreshRegistry, below) call deriveRecord directly and
|
|
481
|
+
// tolerate a null the same way as missing/unsupported — one bad run
|
|
482
|
+
// must not stop a listing of everything else. Single-run lookups
|
|
483
|
+
// (deriveRecordForRun, below) re-check this same condition WITHOUT
|
|
484
|
+
// swallowing it, so `cw run show`/`archive`/`rerun` on THIS run
|
|
485
|
+
// specifically get a clear reason instead of a misleading "not found."
|
|
486
|
+
(0, run_store_1.assertNotSuspectedDataLoss)(path.basename(runDir), result);
|
|
465
487
|
run = result.run;
|
|
466
488
|
}
|
|
467
489
|
catch {
|
|
@@ -609,7 +631,7 @@ class RunRegistry {
|
|
|
609
631
|
if (staleRuns.length || missingRuns.length)
|
|
610
632
|
status = "stale";
|
|
611
633
|
}
|
|
612
|
-
const refreshCmd = scope === "home" ? "
|
|
634
|
+
const refreshCmd = scope === "home" ? "cw registry refresh --scope home" : "cw registry refresh";
|
|
613
635
|
return {
|
|
614
636
|
schemaVersion: 1,
|
|
615
637
|
scope,
|
|
@@ -624,7 +646,7 @@ class RunRegistry {
|
|
|
624
646
|
},
|
|
625
647
|
index: current,
|
|
626
648
|
counts: current.counts,
|
|
627
|
-
nextAction: status === "valid" ? "
|
|
649
|
+
nextAction: status === "valid" ? "cw run search" : refreshCmd,
|
|
628
650
|
};
|
|
629
651
|
}
|
|
630
652
|
search(raw = {}) {
|
|
@@ -657,7 +679,7 @@ class RunRegistry {
|
|
|
657
679
|
offset: query.offset,
|
|
658
680
|
limit: query.limit,
|
|
659
681
|
records: page,
|
|
660
|
-
nextAction: report.freshness.status === "valid" ? "
|
|
682
|
+
nextAction: report.freshness.status === "valid" ? "cw run show <run-id>" : "cw registry refresh",
|
|
661
683
|
};
|
|
662
684
|
}
|
|
663
685
|
list(options = {}) {
|
|
@@ -680,7 +702,7 @@ class RunRegistry {
|
|
|
680
702
|
resolvedFrom: located.from,
|
|
681
703
|
repo: located.record.repo,
|
|
682
704
|
record: located.record,
|
|
683
|
-
nextAction: located.record.archived ? "
|
|
705
|
+
nextAction: located.record.archived ? "cw run resume " + runId : "cw run show " + runId,
|
|
684
706
|
};
|
|
685
707
|
}
|
|
686
708
|
const persisted = this.findPersisted(runId, scope);
|
|
@@ -691,10 +713,11 @@ class RunRegistry {
|
|
|
691
713
|
freshness: "missing",
|
|
692
714
|
repo: persisted?.repo,
|
|
693
715
|
persisted,
|
|
694
|
-
nextAction: "
|
|
716
|
+
nextAction: "cw registry refresh" + (scope === "home" ? " --scope home" : ""),
|
|
695
717
|
};
|
|
696
718
|
}
|
|
697
719
|
locate(runId, scope) {
|
|
720
|
+
(0, fs_atomic_1.assertSafeRunId)(runId);
|
|
698
721
|
const here = this.deriveRecordForRun(this.repoRoot, runId);
|
|
699
722
|
if (here)
|
|
700
723
|
return { record: here, from: "repo" };
|
|
@@ -711,9 +734,35 @@ class RunRegistry {
|
|
|
711
734
|
}
|
|
712
735
|
deriveRecordForRun(repo, runId) {
|
|
713
736
|
const runDir = path.join(this.repoRunsDir(repo), runId);
|
|
714
|
-
|
|
737
|
+
const statePath = path.join(runDir, "state.json");
|
|
738
|
+
if (!fs.existsSync(statePath))
|
|
715
739
|
return null;
|
|
716
|
-
|
|
740
|
+
const record = this.deriveRecord(repo, runDir);
|
|
741
|
+
if (record)
|
|
742
|
+
return record;
|
|
743
|
+
// deriveRecord returned null even though state.json existed a moment
|
|
744
|
+
// ago: it failed to parse, was flagged unsupported, tripped
|
|
745
|
+
// suspected-data-loss corroboration, or (a narrow race) a concurrent
|
|
746
|
+
// process deleted it in between. Re-derive without swallowing so a
|
|
747
|
+
// genuine corruption reaches THIS single-run lookup (backing `cw run
|
|
748
|
+
// show`/`archive`/`rerun`) instead of the misleading "not found" a bulk
|
|
749
|
+
// scan is content with — but if the file is ACTUALLY gone by the time
|
|
750
|
+
// we check again, that is genuinely missing, not corrupt, and must
|
|
751
|
+
// resolve to null like everywhere else, never a raw "File not found"
|
|
752
|
+
// exception surfacing from this fallback re-read.
|
|
753
|
+
try {
|
|
754
|
+
const result = (0, run_store_1.loadRunStateFile)(statePath, { dryRun: true });
|
|
755
|
+
if (result.report.status === "unsupported") {
|
|
756
|
+
throw new Error(`Run state for ${runId} is corrupt (fail closed): ${result.report.errors.join("; ") || "unsupported run state"}. Restore ${statePath} from a backup, or remove the run directory to start over.`);
|
|
757
|
+
}
|
|
758
|
+
(0, run_store_1.assertNotSuspectedDataLoss)(runId, result);
|
|
759
|
+
return record;
|
|
760
|
+
}
|
|
761
|
+
catch (error) {
|
|
762
|
+
if (!fs.existsSync(statePath))
|
|
763
|
+
return null;
|
|
764
|
+
throw error;
|
|
765
|
+
}
|
|
717
766
|
}
|
|
718
767
|
findPersisted(runId, scope) {
|
|
719
768
|
for (const s of scope === "home" ? ["home", "repo"] : ["repo"]) {
|
|
@@ -725,6 +774,7 @@ class RunRegistry {
|
|
|
725
774
|
return undefined;
|
|
726
775
|
}
|
|
727
776
|
loadRun(repo, runId) {
|
|
777
|
+
(0, fs_atomic_1.assertSafeRunId)(runId);
|
|
728
778
|
const statePath = path.join(this.repoRunsDir(repo), runId, "state.json");
|
|
729
779
|
if (!fs.existsSync(statePath))
|
|
730
780
|
throw new Error(`Run not found: ${runId}`);
|
|
@@ -732,6 +782,7 @@ class RunRegistry {
|
|
|
732
782
|
if (result.report.status === "unsupported") {
|
|
733
783
|
throw new Error(`Unsupported run state for ${runId}: ${result.report.errors.join("; ")}`);
|
|
734
784
|
}
|
|
785
|
+
(0, run_store_1.assertNotSuspectedDataLoss)(runId, result);
|
|
735
786
|
return result.run;
|
|
736
787
|
}
|
|
737
788
|
resume(runId, options = {}) {
|
|
@@ -752,23 +803,23 @@ class RunRegistry {
|
|
|
752
803
|
const nextActions = [];
|
|
753
804
|
if (nextTasks.length) {
|
|
754
805
|
nextActions.push({
|
|
755
|
-
command: `
|
|
806
|
+
command: `cw dispatch ${runId} --cwd ${record.repo}`,
|
|
756
807
|
reason: `Continue ${nextTasks.length} pending/running task(s) from durable state.`,
|
|
757
808
|
});
|
|
758
809
|
nextActions.push({
|
|
759
|
-
command: `
|
|
810
|
+
command: `cw multi-agent step ${runId} --cwd ${record.repo}`,
|
|
760
811
|
reason: "Take one deterministic host step without spawning agents.",
|
|
761
812
|
});
|
|
762
813
|
}
|
|
763
814
|
else if (record.derivedLifecycle === "failed") {
|
|
764
815
|
nextActions.push({
|
|
765
|
-
command: `
|
|
816
|
+
command: `cw run rerun ${runId}`,
|
|
766
817
|
reason: "Run terminated as failed with no runnable tasks; rerun as a new linked run.",
|
|
767
818
|
});
|
|
768
819
|
}
|
|
769
820
|
else {
|
|
770
821
|
nextActions.push({
|
|
771
|
-
command: `
|
|
822
|
+
command: `cw status ${runId} --cwd ${record.repo} --json`,
|
|
772
823
|
reason: "No runnable tasks remain; inspect status.",
|
|
773
824
|
});
|
|
774
825
|
}
|
|
@@ -807,7 +858,14 @@ class RunRegistry {
|
|
|
807
858
|
}
|
|
808
859
|
(0, fs_atomic_1.writeJson)(file, overlay, { durable: true });
|
|
809
860
|
});
|
|
810
|
-
|
|
861
|
+
// deriveRecordForRun (not a bare deriveRecord(...)!) so a run that
|
|
862
|
+
// became corrupt/unsupported/suspected-data-loss in the narrow window
|
|
863
|
+
// between locate() above and this re-fetch gets the same clear,
|
|
864
|
+
// specific error every other single-run lookup gets, instead of a raw
|
|
865
|
+
// TypeError from a non-null assertion on a surprise null.
|
|
866
|
+
const record = this.deriveRecordForRun(repo, runId);
|
|
867
|
+
if (!record)
|
|
868
|
+
throw new Error(`Cannot archive: run ${runId} state became unavailable while archiving (fail closed).`);
|
|
811
869
|
return {
|
|
812
870
|
runId,
|
|
813
871
|
repo,
|
|
@@ -877,8 +935,8 @@ class RunRegistry {
|
|
|
877
935
|
pendingTasks: newRun.tasks.filter((t) => t.status === "pending").length,
|
|
878
936
|
provenance,
|
|
879
937
|
nextActions: [
|
|
880
|
-
{ command: `
|
|
881
|
-
{ command: `
|
|
938
|
+
{ command: `cw run resume ${newRun.id}`, reason: "Continue the new linked run." },
|
|
939
|
+
{ command: `cw run show ${runId}`, reason: "The original failed run is preserved for audit." },
|
|
882
940
|
],
|
|
883
941
|
};
|
|
884
942
|
}
|
|
@@ -918,7 +976,7 @@ class RunRegistry {
|
|
|
918
976
|
limit,
|
|
919
977
|
repos: index.repos,
|
|
920
978
|
entries,
|
|
921
|
-
nextAction: report.freshness.status === "valid" ? "
|
|
979
|
+
nextAction: report.freshness.status === "valid" ? "cw run show <run-id>" : "cw registry refresh --scope home",
|
|
922
980
|
};
|
|
923
981
|
}
|
|
924
982
|
}
|
package/dist/shell/run-store.js
CHANGED
|
@@ -49,7 +49,9 @@ exports.hashArtifactFile = hashArtifactFile;
|
|
|
49
49
|
exports.loadRunStateFile = loadRunStateFile;
|
|
50
50
|
exports.checkRunStateFile = checkRunStateFile;
|
|
51
51
|
exports.migrateRunStateFile = migrateRunStateFile;
|
|
52
|
+
exports.assertNotSuspectedDataLoss = assertNotSuspectedDataLoss;
|
|
52
53
|
exports.loadRunFromCwd = loadRunFromCwd;
|
|
54
|
+
exports.withRunStateLock = withRunStateLock;
|
|
53
55
|
exports.saveCheckpoint = saveCheckpoint;
|
|
54
56
|
exports.compactCheckpoint = compactCheckpoint;
|
|
55
57
|
exports.createRun = createRun;
|
|
@@ -97,21 +99,108 @@ function migrateRunStateFile(statePath, options = {}) {
|
|
|
97
99
|
}
|
|
98
100
|
return result;
|
|
99
101
|
}
|
|
102
|
+
/** ONLY tasksDir/commitsDir (plus the audit event log, checked separately
|
|
103
|
+
* below) — deliberately NOT every directory a run's `paths` can carry.
|
|
104
|
+
* Verified empirically (via test/run-fixture-compat-smoke.js's real
|
|
105
|
+
* fixtures) that several other candidate directories are unsafe signals:
|
|
106
|
+
* a plain `cw status`/`cw graph` READ already writes cache/derived files
|
|
107
|
+
* as a side effect — `audit/summary.json` + `audit/index.json`
|
|
108
|
+
* (summarizeTrustAudit, called from the status/report path) are non-empty
|
|
109
|
+
* the moment ANY read touches the run, and `blackboard/`/`candidates/`/
|
|
110
|
+
* `topologies/` subdirectories get created (though left empty) the same
|
|
111
|
+
* way. Using those as "real content" signals would refuse to load a
|
|
112
|
+
* perfectly healthy, merely-already-viewed-once run — report.md is the
|
|
113
|
+
* same story (a derived rendering of state, not independent evidence).
|
|
114
|
+
* tasksDir/commitsDir are the two directories confirmed to hold content
|
|
115
|
+
* ONLY from genuine task-dispatch/commit actions, never from an
|
|
116
|
+
* otherwise-read-only command. */
|
|
117
|
+
function contentDirs(paths) {
|
|
118
|
+
return [paths.tasksDir, paths.commitsDir];
|
|
119
|
+
}
|
|
120
|
+
/** True when the run directory already holds real task/commit files, or a
|
|
121
|
+
* non-empty audit event log — i.e. this was NOT a brand-new run dir. Used
|
|
122
|
+
* only to corroborate `report.suspectedDataLoss`: a bare
|
|
123
|
+
* `{workflow, paths}`-less state.json is unremarkable for a run dir that
|
|
124
|
+
* has nothing else in it either (e.g. a run whose creation crashed before
|
|
125
|
+
* anything else was written), but is a strong corruption signal when real
|
|
126
|
+
* content already sits next to it. A directory entry must not START WITH
|
|
127
|
+
* "." to count — cw never writes dot-prefixed names into these
|
|
128
|
+
* directories, so incidental filesystem debris (a stray `.DS_Store`) never
|
|
129
|
+
* by itself makes a genuinely fresh run look corrupted. */
|
|
130
|
+
function hasPreexistingRunContent(run) {
|
|
131
|
+
for (const dir of contentDirs(run.paths)) {
|
|
132
|
+
try {
|
|
133
|
+
if (fs.readdirSync(dir).some((name) => !name.startsWith(".")))
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
/* missing dir — not a signal either way */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
const eventLogPath = run.audit?.eventLogPath;
|
|
142
|
+
if (eventLogPath && fs.statSync(eventLogPath).size > 0)
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
/* missing/empty audit log — not a signal */
|
|
147
|
+
}
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
/** Throws when `result.report.suspectedDataLoss` is true AND the run
|
|
151
|
+
* directory already has real content on disk — see
|
|
152
|
+
* `hasPreexistingRunContent`. Shared by every state.json reader
|
|
153
|
+
* (`loadRunFromCwd` here, `RunRegistry.loadRun` in run-registry-io.ts) so
|
|
154
|
+
* a corrupted/wiped state.json is refused the same way regardless of
|
|
155
|
+
* which entry point reached it. */
|
|
156
|
+
function assertNotSuspectedDataLoss(runId, result) {
|
|
157
|
+
if (result.report.suspectedDataLoss && hasPreexistingRunContent(result.run)) {
|
|
158
|
+
throw new Error(`Refusing to load run ${runId}: state.json is missing its core fields (workflow, paths), but the run directory already has task, commit, or other content on disk. This looks like state.json was corrupted, truncated, or replaced by something outside cw, not a new run. Restore state.json from a backup, or remove the run directory to start over.`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
100
161
|
/** Refuses an empty id with `Missing run id`; loads
|
|
101
162
|
* `<cwd>/.cw/runs/<runId>/state.json` (dry-run — never writes); throws
|
|
102
|
-
* `
|
|
103
|
-
*
|
|
163
|
+
* `Run not found: <runId>` when no such run directory has a state.json
|
|
164
|
+
* (every caller — report, status, audit, eval snapshot, and more — used
|
|
165
|
+
* to leak the raw `readJson` message, "File not found: <absolute
|
|
166
|
+
* path>/state.json", which named neither the run nor a next step, and
|
|
167
|
+
* didn't contain any of the words `cli/entry.ts`'s `recoveryHint` scans
|
|
168
|
+
* for, so no `Try: cw run list` line ever appeared); throws `Unsupported
|
|
169
|
+
* CW run state: <errors joined by "; ">` on an unsupported verdict;
|
|
170
|
+
* refuses a state.json that lost its core fields (workflow, paths) while
|
|
171
|
+
* the run dir still has real content next to it, rather than silently
|
|
172
|
+
* returning it as a fresh empty run; else returns the migrated
|
|
173
|
+
* WorkflowRun in memory. */
|
|
104
174
|
function loadRunFromCwd(runId, cwd = process.cwd()) {
|
|
105
175
|
if (!runId)
|
|
106
176
|
throw new Error("Missing run id");
|
|
107
177
|
(0, fs_atomic_1.assertSafeRunId)(runId);
|
|
108
178
|
const statePath = path.join(cwd, ".cw", "runs", runId, "state.json");
|
|
179
|
+
if (!fs.existsSync(statePath))
|
|
180
|
+
throw new Error(`Run not found: ${runId}`);
|
|
109
181
|
const result = loadRunStateFile(statePath, { dryRun: true });
|
|
110
182
|
if (result.report.status === "unsupported") {
|
|
111
183
|
throw new Error(`Unsupported CW run state: ${result.report.errors.join("; ")}`);
|
|
112
184
|
}
|
|
185
|
+
assertNotSuspectedDataLoss(runId, result);
|
|
113
186
|
return result.run;
|
|
114
187
|
}
|
|
188
|
+
/** Hold the state.json lock over a WHOLE load -> change -> save cycle.
|
|
189
|
+
* A bare loadRunFromCwd + saveCheckpoint pair leaves a window where two
|
|
190
|
+
* processes both load the same state and the later save silently drops
|
|
191
|
+
* the earlier change (the same lost-update class PR #339 fixed for
|
|
192
|
+
* queue.json / triggers.json). `fn` gets the run loaded UNDER the lock;
|
|
193
|
+
* saveCheckpoint calls inside `fn` re-enter the same lock (withFileLock
|
|
194
|
+
* is re-entrant in-process) and write exactly as before. The probe load
|
|
195
|
+
* runs BEFORE the lock so an unknown run id throws the exact
|
|
196
|
+
* loadRunFromCwd error without first creating the run directory as a
|
|
197
|
+
* lock-file side effect — and it supplies `paths.state`, the same lock
|
|
198
|
+
* target saveCheckpoint uses. Keep `fn` short: a critical section past
|
|
199
|
+
* 30s can be stolen as a stale lock. */
|
|
200
|
+
function withRunStateLock(runId, cwd, fn) {
|
|
201
|
+
const probe = loadRunFromCwd(runId, cwd);
|
|
202
|
+
return (0, fs_atomic_1.withFileLock)(probe.paths.state, () => fn(loadRunFromCwd(runId, cwd)));
|
|
203
|
+
}
|
|
115
204
|
/** state.json is the single source of truth — set `updatedAt`, then write
|
|
116
205
|
* it DURABLY with a lock so concurrent processes never lose an update. */
|
|
117
206
|
function saveCheckpoint(run) {
|
|
@@ -67,6 +67,7 @@ const fs = __importStar(require("node:fs"));
|
|
|
67
67
|
const path = __importStar(require("node:path"));
|
|
68
68
|
const fs_atomic_1 = require("./fs-atomic");
|
|
69
69
|
const run_registry_io_1 = require("./run-registry-io");
|
|
70
|
+
const numeric_flag_1 = require("../core/util/numeric-flag");
|
|
70
71
|
exports.SCHEDULING_SCHEMA_VERSION = 1;
|
|
71
72
|
/** Conservative fail-closed defaults: serial, bounded retries, exponential backoff. */
|
|
72
73
|
exports.DEFAULT_SCHEDULING_POLICY = {
|
|
@@ -232,14 +233,7 @@ function loadSchedulingPolicy(registry) {
|
|
|
232
233
|
return { schemaVersion: 1, policy: normalizeSchedulingPolicy(parsed), source: "file" };
|
|
233
234
|
}
|
|
234
235
|
function numericFlag(options, key) {
|
|
235
|
-
|
|
236
|
-
return undefined;
|
|
237
|
-
const raw = options[key];
|
|
238
|
-
const n = Number(raw);
|
|
239
|
-
if (!Number.isFinite(n)) {
|
|
240
|
-
throw new Error(`Invalid --${key} "${raw}": expected a number (e.g. --${key} 4)`);
|
|
241
|
-
}
|
|
242
|
-
return n;
|
|
236
|
+
return (0, numeric_flag_1.requiredNumberFlag)(options[key], `--${key}`);
|
|
243
237
|
}
|
|
244
238
|
function schedPlanCli(options = {}) {
|
|
245
239
|
const registry = new run_registry_io_1.RunRegistry(resolveCwd(options));
|
|
@@ -250,7 +244,7 @@ function schedLeaseCli(options = {}) {
|
|
|
250
244
|
const registry = new run_registry_io_1.RunRegistry(resolveCwd(options));
|
|
251
245
|
const now = nowIso(options);
|
|
252
246
|
const policy = loadSchedulingPolicy(registry).policy;
|
|
253
|
-
const limit =
|
|
247
|
+
const limit = (0, numeric_flag_1.requiredNumberFlag)(options.limit, "--limit");
|
|
254
248
|
return (0, fs_atomic_1.withFileLock)(registry.queueFilePath(), () => {
|
|
255
249
|
const { entries, leases } = applyLease(registry.loadQueueEntries(), policy, now, limit);
|
|
256
250
|
registry.saveQueueEntries(entries);
|
|
@@ -305,16 +299,21 @@ function schedPolicyShowCli(options = {}) {
|
|
|
305
299
|
}
|
|
306
300
|
function schedPolicySetCli(options = {}) {
|
|
307
301
|
const registry = new run_registry_io_1.RunRegistry(resolveCwd(options));
|
|
308
|
-
const current = loadSchedulingPolicy(registry).policy;
|
|
309
302
|
const patch = {};
|
|
310
303
|
for (const key of ["maxConcurrent", "maxAttempts", "leaseTtlMs", "backoffBaseMs", "backoffFactor", "backoffCapMs"]) {
|
|
311
304
|
const value = numericFlag(options, key);
|
|
312
305
|
if (value !== undefined)
|
|
313
306
|
patch[key] = value;
|
|
314
307
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
308
|
+
// Read-modify-write under the policy file's own lock: two concurrent
|
|
309
|
+
// `sched policy set` calls patching different fields must not drop each
|
|
310
|
+
// other's write.
|
|
311
|
+
return (0, fs_atomic_1.withFileLock)(registry.schedulingPolicyPath(), () => {
|
|
312
|
+
const current = loadSchedulingPolicy(registry).policy;
|
|
313
|
+
const policy = normalizeSchedulingPolicy({ ...current, ...patch });
|
|
314
|
+
(0, fs_atomic_1.writeJson)(registry.schedulingPolicyPath(), policy);
|
|
315
|
+
return { schemaVersion: 1, policy, source: "file" };
|
|
316
|
+
});
|
|
318
317
|
}
|
|
319
318
|
function isTrue(value) {
|
|
320
319
|
return value === true || value === "true" || value === "1";
|