cool-workflow 0.2.4 → 0.2.5
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/.gemini-plugin/mcp.json +10 -0
- package/.gemini-plugin/plugin.json +40 -0
- package/.opencode-plugin/mcp.json +10 -0
- package/.opencode-plugin/plugin.json +40 -0
- package/README.md +202 -48
- 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 +32 -4
- package/dist/cli/entry.js +11 -19
- package/dist/cli/global-flags.js +113 -0
- package/dist/cli/parseargv.js +7 -1
- package/dist/core/capability-data.js +337 -8
- package/dist/core/format/help.js +73 -3
- package/dist/core/format/recovery-hint.js +32 -0
- package/dist/core/multi-agent/collaboration.js +35 -6
- package/dist/core/multi-agent/runtime.js +7 -0
- package/dist/core/multi-agent/trust-policy.js +7 -1
- package/dist/core/pipeline/contract.js +7 -0
- package/dist/core/pipeline/error-feedback.js +2 -2
- package/dist/core/trust/evidence-grounding.js +13 -1
- package/dist/core/util/cli-args.js +22 -0
- package/dist/core/version.js +1 -1
- package/dist/mcp/dispatch.js +22 -2
- package/dist/mcp/server.js +124 -13
- package/dist/mcp-server.js +20 -0
- package/dist/shell/commit.js +8 -2
- package/dist/shell/coordinator-io.js +73 -1
- package/dist/shell/drive.js +120 -63
- package/dist/shell/error-feedback-io.js +6 -0
- package/dist/shell/execution-backend/agent.js +195 -23
- package/dist/shell/execution-backend/container.js +44 -10
- package/dist/shell/execution-backend/local.js +32 -0
- package/dist/shell/fs-atomic.js +93 -12
- package/dist/shell/ledger-cli.js +9 -2
- package/dist/shell/multi-agent-cli.js +5 -1
- package/dist/shell/onramp.js +48 -5
- package/dist/shell/pipeline.js +2 -1
- package/dist/shell/reclamation-io.js +10 -9
- package/dist/shell/run-export.js +52 -4
- package/dist/shell/run-store.js +156 -0
- package/dist/shell/scheduler-io.js +101 -10
- package/dist/shell/telemetry-ledger-io.js +36 -24
- package/dist/shell/trust-audit.js +104 -10
- package/dist/shell/workbench-host.js +121 -10
- package/dist/shell/workbench.js +79 -5
- package/dist/shell/worker-isolation.js +1 -1
- package/dist/wiring/capability-table/basics.js +5 -0
- package/dist/wiring/capability-table/exec-backend.js +40 -22
- package/dist/wiring/capability-table/pipeline.js +32 -0
- package/dist/wiring/capability-table/registry-core.js +26 -3
- package/dist/wiring/capability-table/reporting.js +7 -1
- package/dist/wiring/capability-table/scheduling-registry.js +8 -2
- package/dist/wiring/capability-table/trust-ledger.js +54 -12
- package/docs/agent-delegation-drive.7.md +15 -0
- package/docs/cli-mcp-parity.7.md +25 -0
- package/docs/contract-migration-tooling.7.md +2 -0
- package/docs/control-plane-scheduling.7.md +36 -0
- package/docs/cross-agent-ledger.7.md +20 -8
- package/docs/durable-state-and-locking.7.md +2 -0
- package/docs/evidence-adoption-reasoning-chain.7.md +2 -0
- package/docs/execution-backends.7.md +2 -0
- package/docs/fix.7.md +4 -4
- package/docs/getting-started.md +40 -32
- package/docs/index.md +17 -0
- package/docs/launch/demo.tape +4 -3
- package/docs/mcp-app-surface.7.md +6 -0
- package/docs/multi-agent-cli-mcp-surface.7.md +2 -0
- package/docs/multi-agent-eval-replay-harness.7.md +2 -0
- package/docs/multi-agent-operator-ux.7.md +2 -0
- package/docs/node-snapshot-diff-replay.7.md +2 -0
- package/docs/observability-cost-accounting.7.md +2 -0
- package/docs/project-index.md +39 -9
- package/docs/real-execution-backends.7.md +2 -0
- package/docs/release-and-migration.7.md +2 -0
- package/docs/release-tooling.7.md +2 -0
- package/docs/routine.7.md +22 -0
- package/docs/run-registry-control-plane.7.md +2 -0
- package/docs/run-retention-reclamation.7.md +10 -4
- package/docs/state-explosion-management.7.md +2 -0
- package/docs/team-collaboration.7.md +2 -0
- package/docs/trust-audit-anchor.7.md +2 -0
- package/docs/web-desktop-workbench.7.md +24 -2
- package/docs/workflow-app-framework.7.md +31 -0
- package/manifest/plugin.manifest.json +1 -1
- package/manifest/source-context-profiles.json +4 -3
- package/package.json +4 -2
- package/scripts/agents/agent-adapter-core.js +26 -0
- package/scripts/agents/claude-p-agent.js +4 -1
- package/scripts/agents/codex-agent.js +4 -0
- package/scripts/agents/cw-attest-wrap.js +1 -1
- package/scripts/agents/gemini-agent.js +4 -0
- package/scripts/agents/opencode-agent.js +5 -0
- package/scripts/block-unapproved-tag.js +160 -0
- package/scripts/canonical-apps.js +4 -4
- package/scripts/children/batch-delegate-child.js +72 -4
- package/scripts/children/http-batch-delegate-child.js +132 -0
- package/scripts/children/http-delegate-child.js +8 -0
- package/scripts/dogfood-release.js +2 -2
- package/scripts/fake-date-for-reproduction.js +1 -1
- package/scripts/golden-path.js +4 -4
- package/scripts/lang-policy-check.js +82 -0
- package/scripts/purity-gate.js +16 -3
- package/scripts/release-check.js +2 -1
- package/scripts/release-flow.js +128 -30
- package/scripts/release-gate.js +184 -0
- package/scripts/release-oneclick.js +38 -7
- package/scripts/release-tags.js +47 -0
- package/scripts/verdict-keygen.js +2 -2
- package/scripts/verify-bump-reproduction.js +193 -0
- package/scripts/verify-verdict-signature.js +1 -1
- package/ui/workbench/app.css +41 -9
- package/ui/workbench/app.js +160 -27
- package/ui/workbench/index.html +2 -2
- package/docs/agent-framework.md +0 -177
- package/docs/designs/handoff-ledger.md +0 -145
- package/docs/dogfood/resume-drive-real-agent-2026-06-14.md +0 -40
- package/docs/launch/launch-kit.md +0 -195
- package/docs/launch/pre-launch-checklist.md +0 -53
- package/docs/readme-v0.1.87-full.md +0 -301
- package/docs/routines.md +0 -101
- package/docs/scheduled-tasks.md +0 -80
- package/scripts/agents/claude-p-agent.sh +0 -9
- package/scripts/block-unapproved-tag.sh +0 -75
- package/scripts/release-gate.sh +0 -94
- package/scripts/verify-bump-reproduction.sh +0 -148
- package/scripts/verify-container-selfref.js +0 -64
|
@@ -113,6 +113,7 @@ exports.evalScoreCli = evalScoreCli;
|
|
|
113
113
|
exports.evalGateCli = evalGateCli;
|
|
114
114
|
exports.evalReportCli = evalReportCli;
|
|
115
115
|
const path = __importStar(require("node:path"));
|
|
116
|
+
const cli_args_1 = require("../core/util/cli-args");
|
|
116
117
|
const numeric_flag_1 = require("../core/util/numeric-flag");
|
|
117
118
|
const run_store_1 = require("./run-store");
|
|
118
119
|
const report_1 = require("./report");
|
|
@@ -188,7 +189,10 @@ function numberArg(value) {
|
|
|
188
189
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
189
190
|
}
|
|
190
191
|
function boolArg(value) {
|
|
191
|
-
|
|
192
|
+
// parseBoolFlag reads "false"/"0"/"no"/"off" as false and throws on an
|
|
193
|
+
// unrecognized string — Boolean("false") is true, which silently
|
|
194
|
+
// ENABLED flags like `--allow-self-approval false` (fail-open).
|
|
195
|
+
return (0, cli_args_1.parseBoolFlag)(value, "flag") ?? false;
|
|
192
196
|
}
|
|
193
197
|
/** `--multi-agent-run <id>` — parseArgv keeps kebab-case option keys
|
|
194
198
|
* verbatim (no camelCase folding), so this must check the literal
|
package/dist/shell/onramp.js
CHANGED
|
@@ -278,9 +278,9 @@ function resolveChangedFiles(options = {}) {
|
|
|
278
278
|
const root = gitRoot(cwd);
|
|
279
279
|
const baseRef = resolveBaseRef(root, options.changedFrom, options.env || process.env);
|
|
280
280
|
const files = new Set();
|
|
281
|
-
for (const file of
|
|
281
|
+
for (const file of gitLinesOrThrow(root, ["diff", "--name-only", baseRef, "--"]))
|
|
282
282
|
files.add(normalizeChangedPath(file));
|
|
283
|
-
for (const file of
|
|
283
|
+
for (const file of gitLinesOrThrow(root, ["ls-files", "--others", "--exclude-standard"]))
|
|
284
284
|
files.add(normalizeChangedPath(file));
|
|
285
285
|
return { baseRef, files: [...files].filter(Boolean).sort() };
|
|
286
286
|
}
|
|
@@ -406,8 +406,26 @@ function resolveBaseRef(root, changedFrom, env) {
|
|
|
406
406
|
return verifyRef(root, changedFrom);
|
|
407
407
|
if (env.CW_ONRAMP_BASE)
|
|
408
408
|
return verifyRef(root, env.CW_ONRAMP_BASE);
|
|
409
|
-
|
|
410
|
-
|
|
409
|
+
// A pull_request CI context sets GITHUB_BASE_REF, so a base ref is EXPECTED:
|
|
410
|
+
// the diff must run against the PR's own base branch. If merge-base cannot
|
|
411
|
+
// resolve there (a shallow clone that never fetched the base, a missing
|
|
412
|
+
// origin ref), we must NOT quietly degrade to HEAD -- a HEAD..HEAD diff on a
|
|
413
|
+
// clean, already-committed tree is empty, and an empty change set has no
|
|
414
|
+
// issues, so onramp:check would report ok:true on a real, unseen change (the
|
|
415
|
+
// same vacuous-pass class PR #446 closed one layer below). Fail closed.
|
|
416
|
+
if (env.GITHUB_BASE_REF) {
|
|
417
|
+
const baseBranch = `origin/${env.GITHUB_BASE_REF}`;
|
|
418
|
+
const mergeBase = gitOne(root, ["merge-base", "HEAD", baseBranch]);
|
|
419
|
+
if (mergeBase)
|
|
420
|
+
return mergeBase;
|
|
421
|
+
throw new Error(`onramp: cannot resolve a base ref -- git merge-base HEAD ${baseBranch} found no common commit (fail closed, not treated as zero changes). Fetch the base branch with a full (non-shallow) clone, or pass --changed-from / CW_ONRAMP_BASE.`);
|
|
422
|
+
}
|
|
423
|
+
// No base ref was requested (--changed-from / CW_ONRAMP_BASE) or expected
|
|
424
|
+
// (GITHUB_BASE_REF): this is the local "show my own changes" use. merge-base
|
|
425
|
+
// against origin/main narrows the diff to this branch's commits when the
|
|
426
|
+
// remote is present; with no remote we fall back to HEAD so `git diff HEAD`
|
|
427
|
+
// still surfaces the working-tree (uncommitted) changes.
|
|
428
|
+
const mergeBase = gitOne(root, ["merge-base", "HEAD", "origin/main"]);
|
|
411
429
|
if (mergeBase)
|
|
412
430
|
return mergeBase;
|
|
413
431
|
return verifyRef(root, "HEAD");
|
|
@@ -423,12 +441,37 @@ function verifyRef(root, ref) {
|
|
|
423
441
|
function gitRoot(cwd) {
|
|
424
442
|
return gitOne(node_path_1.default.resolve(cwd), ["rev-parse", "--show-toplevel"]) || node_path_1.default.resolve(cwd);
|
|
425
443
|
}
|
|
444
|
+
// Every onramp git call is a quick metadata read (rev-parse, merge-base, diff
|
|
445
|
+
// --name-only, ls-files). A finite timeout keeps a HUNG git -- a cold fsmonitor
|
|
446
|
+
// daemon, a credential prompt on a misconfigured remote -- from blocking the
|
|
447
|
+
// gate forever; 5s matches the git-metadata timeout already used in
|
|
448
|
+
// shell/commit.ts and shell/doctor.ts. On a timeout spawnSync sets
|
|
449
|
+
// `result.error` (and a null status), so gitLinesOrThrow fails closed while
|
|
450
|
+
// gitLines/gitOne fall back the same way they do for any other git failure.
|
|
451
|
+
const GIT_TIMEOUT_MS = 5000;
|
|
426
452
|
function gitLines(cwd, args) {
|
|
427
|
-
const result = (0, node_child_process_1.spawnSync)("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
453
|
+
const result = (0, node_child_process_1.spawnSync)("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: GIT_TIMEOUT_MS });
|
|
428
454
|
if (result.status !== 0)
|
|
429
455
|
return [];
|
|
430
456
|
return String(result.stdout || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
431
457
|
}
|
|
458
|
+
/** Same as gitLines, but a failed git invocation THROWS instead of silently
|
|
459
|
+
* becoming []. resolveChangedFiles must never mistake "git could not run
|
|
460
|
+
* this diff" for "there is nothing to diff" -- that turns a transient git
|
|
461
|
+
* failure into a vacuous onramp-contract pass (a 2026-07-12 security audit
|
|
462
|
+
* finding: a broken base ref or a git error made the changed-file set
|
|
463
|
+
* empty, and an empty set has no issues, so the gate reported ok:true on a
|
|
464
|
+
* real, unseen change). Callers that WANT a soft fallback (merge-base
|
|
465
|
+
* probing, optional discovery) should keep using gitLines/gitOne. */
|
|
466
|
+
function gitLinesOrThrow(cwd, args) {
|
|
467
|
+
const result = (0, node_child_process_1.spawnSync)("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: GIT_TIMEOUT_MS });
|
|
468
|
+
if (result.error)
|
|
469
|
+
throw new Error(`onramp: git ${args.join(" ")} failed to run: ${result.error.message}`);
|
|
470
|
+
if (result.status !== 0) {
|
|
471
|
+
throw new Error(`onramp: git ${args.join(" ")} exited ${result.status} -- cannot resolve changed files (fail closed, not treated as zero changes): ${String(result.stderr || "").trim()}`);
|
|
472
|
+
}
|
|
473
|
+
return String(result.stdout || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
474
|
+
}
|
|
432
475
|
function gitOne(cwd, args) {
|
|
433
476
|
return gitLines(cwd, args)[0] || "";
|
|
434
477
|
}
|
package/dist/shell/pipeline.js
CHANGED
|
@@ -48,6 +48,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
48
48
|
exports.pureAppendRunNode = exports.writeJson = void 0;
|
|
49
49
|
exports.plan = plan;
|
|
50
50
|
const crypto = __importStar(require("node:crypto"));
|
|
51
|
+
const fs = __importStar(require("node:fs"));
|
|
51
52
|
const path = __importStar(require("node:path"));
|
|
52
53
|
const run_paths_1 = require("../core/state/run-paths");
|
|
53
54
|
const migrations_1 = require("../core/state/migrations");
|
|
@@ -207,7 +208,7 @@ function plan(app, options) {
|
|
|
207
208
|
loopStage: "interpret",
|
|
208
209
|
artifacts: [{ id: "task", kind: "markdown", path: task.taskPath }],
|
|
209
210
|
metadata: { workflowId: app.workflow.id, appId: app.id, appVersion: app.version, taskId: task.id, phase: task.phase, taskKind: task.kind, requiresEvidence: task.requiresEvidence, sandboxProfileId: task.sandboxProfileId },
|
|
210
|
-
}, { persist: false, persistNode: node_store_1.writeRunNode });
|
|
211
|
+
}, { persist: false, persistNode: node_store_1.writeRunNode, pathExists: fs.existsSync });
|
|
211
212
|
task.stateNodeId = taskResult.outputNodeId;
|
|
212
213
|
}
|
|
213
214
|
(0, report_1.writeReport)(run);
|
|
@@ -473,14 +473,15 @@ function planReclamation(run, policy = {}) {
|
|
|
473
473
|
}
|
|
474
474
|
}
|
|
475
475
|
// (3) Superseded, non-verifier-gated commit snapshots. Each commitState()
|
|
476
|
-
// call
|
|
477
|
-
//
|
|
478
|
-
// Only the run's LATEST commit and any
|
|
479
|
-
// audit-significant milestones) are
|
|
480
|
-
// "checkpoint" commit's only value is
|
|
481
|
-
// state.json (not commits/) is the
|
|
482
|
-
//
|
|
483
|
-
//
|
|
476
|
+
// call writes only the commit's own small record into commits/<id>.json
|
|
477
|
+
// (not the whole run), but these files still add up over a long run with
|
|
478
|
+
// no reclamation path today. Only the run's LATEST commit and any
|
|
479
|
+
// verifier-gated commit (the actual audit-significant milestones) are
|
|
480
|
+
// kept — an intermediate, non-gated "checkpoint" commit's only value is
|
|
481
|
+
// as a point-in-time snapshot, and state.json (not commits/) is the
|
|
482
|
+
// source of truth for resume. Treated as not reconstructable (no recipe)
|
|
483
|
+
// on purpose, kept conservative: a commit snapshot is not offered a
|
|
484
|
+
// projection path derivable from retained data.
|
|
484
485
|
let reclaimedCommitSnapshot = false;
|
|
485
486
|
if (!policy.keepCommits) {
|
|
486
487
|
const commits = run.commits || [];
|
|
@@ -663,7 +664,7 @@ function prepareFree(run, tombstone) {
|
|
|
663
664
|
for (const nodeId of repointed) {
|
|
664
665
|
try {
|
|
665
666
|
const fresh = (0, node_store_1.snapshotNode)(run, nodeId, { persist: false });
|
|
666
|
-
const { freshness } = (0, node_snapshot_1.loadNodeSnapshot)(run, fresh);
|
|
667
|
+
const { freshness } = (0, node_snapshot_1.loadNodeSnapshot)(run, fresh, fs.existsSync);
|
|
667
668
|
if (freshness === "absent") {
|
|
668
669
|
throw new ReclamationError("repoint-incomplete", `re-pointed node ${nodeId} snapshot is absent (dangling artifact)`, { nodeId });
|
|
669
670
|
}
|
package/dist/shell/run-export.js
CHANGED
|
@@ -113,6 +113,17 @@ function importRun(exportPath, targetDir) {
|
|
|
113
113
|
if (!(0, fs_atomic_1.isContainedPath)(runDir, runsRoot)) {
|
|
114
114
|
throw new Error(`Run id escapes the runs directory: ${JSON.stringify(raw.run.id)}`);
|
|
115
115
|
}
|
|
116
|
+
// Validate the run shape BEFORE the deref and before any dir/file is written.
|
|
117
|
+
// A truncated archive can be missing run.paths (or carry non-string
|
|
118
|
+
// runDir/cwd); without this guard the deref throws a raw TypeError, or a
|
|
119
|
+
// present-but-shapeless paths half-restores the run dir with a broken rebase.
|
|
120
|
+
// Fail closed as an invalid archive instead.
|
|
121
|
+
if (!raw.run.paths ||
|
|
122
|
+
typeof raw.run.paths !== "object" ||
|
|
123
|
+
typeof raw.run.paths.runDir !== "string" ||
|
|
124
|
+
typeof raw.run.cwd !== "string") {
|
|
125
|
+
throw new Error("Invalid run export: run.paths.runDir and run.cwd must be strings");
|
|
126
|
+
}
|
|
116
127
|
const oldRunDir = raw.run.paths.runDir;
|
|
117
128
|
const oldCwd = raw.run.cwd;
|
|
118
129
|
const paths = (0, run_paths_1.createRunPaths)(runDir);
|
|
@@ -293,7 +304,7 @@ function reportSectionEmbedsResult(reportMd, taskId, expected) {
|
|
|
293
304
|
* (with the bundle's embedded public key) the ed25519 signatures.
|
|
294
305
|
* Never throws — every failure is a structured check and a false `ok`.
|
|
295
306
|
*
|
|
296
|
-
* Key precedence is
|
|
307
|
+
* Key precedence is argument > bundle > environment. */
|
|
297
308
|
function verifyReportBundle(archivePath, options = {}) {
|
|
298
309
|
const inspect = inspectArchive(archivePath);
|
|
299
310
|
const failedChecks = inspect.checks.filter((check) => !check.pass).map((check) => ({ name: check.name, code: check.code }));
|
|
@@ -333,8 +344,39 @@ function verifyReportBundle(archivePath, options = {}) {
|
|
|
333
344
|
catch {
|
|
334
345
|
/* inspect already recorded the parse failure; treat key as absent */
|
|
335
346
|
}
|
|
336
|
-
|
|
337
|
-
|
|
347
|
+
// Key precedence: an explicit operator --pubkey/options.pubkey WINS over a
|
|
348
|
+
// bundle-embedded key. A bundle carries its own key so it verifies OFFLINE,
|
|
349
|
+
// but that key must never OVERRIDE a key the operator pinned by hand — else a
|
|
350
|
+
// bundle re-signed with an attacker's OWN key (and embedding that key) would
|
|
351
|
+
// verify green against itself. When the operator pins a key AND the bundle
|
|
352
|
+
// embeds a DIFFERENT one, fail closed with a clear trust-key-mismatch rather
|
|
353
|
+
// than silently trusting the bundle's own key.
|
|
354
|
+
const resolvedArg = (0, telemetry_attestation_1.resolveTrustPublicKey)(options.pubkey);
|
|
355
|
+
const resolvedBundle = (0, telemetry_attestation_1.resolveTrustPublicKey)(bundleKey);
|
|
356
|
+
const resolvedEnv = (0, telemetry_attestation_1.resolveTrustPublicKey)(process.env.CW_AGENT_ATTEST_PUBKEY);
|
|
357
|
+
let trustKey;
|
|
358
|
+
let trustKeySource;
|
|
359
|
+
let trustKeyConflict = false;
|
|
360
|
+
if (options.pubkey) {
|
|
361
|
+
trustKey = resolvedArg;
|
|
362
|
+
trustKeySource = "argument";
|
|
363
|
+
if (resolvedArg && resolvedBundle && normalizePem(resolvedArg) !== normalizePem(resolvedBundle)) {
|
|
364
|
+
trustKeyConflict = true;
|
|
365
|
+
failedChecks.push({ name: "trust-key", code: "trust-key-mismatch" });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
else if (bundleKey) {
|
|
369
|
+
trustKey = resolvedBundle;
|
|
370
|
+
trustKeySource = "bundle";
|
|
371
|
+
}
|
|
372
|
+
else if (process.env.CW_AGENT_ATTEST_PUBKEY) {
|
|
373
|
+
trustKey = resolvedEnv;
|
|
374
|
+
trustKeySource = "environment";
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
trustKey = undefined;
|
|
378
|
+
trustKeySource = "none";
|
|
379
|
+
}
|
|
338
380
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cw-verify-bundle-"));
|
|
339
381
|
let telemetryVerified = false;
|
|
340
382
|
let trustAuditVerified = false;
|
|
@@ -421,7 +463,7 @@ function verifyReportBundle(archivePath, options = {}) {
|
|
|
421
463
|
schemaVersion: 1,
|
|
422
464
|
archivePath,
|
|
423
465
|
runId: inspect.runId,
|
|
424
|
-
ok: inspect.ok && telemetryVerified && trustAuditVerified && signaturesFailed === 0 && reportFindingsOk && !strictShortfall && !extractShortfall && !unsignedShortfall,
|
|
466
|
+
ok: inspect.ok && telemetryVerified && trustAuditVerified && signaturesFailed === 0 && reportFindingsOk && !strictShortfall && !extractShortfall && !unsignedShortfall && !trustKeyConflict,
|
|
425
467
|
archiveOk: inspect.ok,
|
|
426
468
|
telemetryVerified,
|
|
427
469
|
trustAuditVerified,
|
|
@@ -675,6 +717,12 @@ function toArchivePath(value) {
|
|
|
675
717
|
function safeArchiveBasename(value) {
|
|
676
718
|
return value.replace(/[^A-Za-z0-9._-]/g, "_") || "artifact";
|
|
677
719
|
}
|
|
720
|
+
/** Compare two PEM public keys byte-for-byte after dropping whitespace, so a
|
|
721
|
+
* benign line-wrap or trailing-newline difference does not read as a key
|
|
722
|
+
* change, but a truly different key (different base64 body) does. */
|
|
723
|
+
function normalizePem(pem) {
|
|
724
|
+
return (pem || "").replace(/\s+/g, "");
|
|
725
|
+
}
|
|
678
726
|
function messageOf(error) {
|
|
679
727
|
return error instanceof Error ? error.message : String(error);
|
|
680
728
|
}
|
package/dist/shell/run-store.js
CHANGED
|
@@ -51,7 +51,10 @@ exports.checkRunStateFile = checkRunStateFile;
|
|
|
51
51
|
exports.migrateRunStateFile = migrateRunStateFile;
|
|
52
52
|
exports.assertNotSuspectedDataLoss = assertNotSuspectedDataLoss;
|
|
53
53
|
exports.loadRunFromCwd = loadRunFromCwd;
|
|
54
|
+
exports.resolveRunDir = resolveRunDir;
|
|
54
55
|
exports.withRunStateLock = withRunStateLock;
|
|
56
|
+
exports.withDriveLock = withDriveLock;
|
|
57
|
+
exports.withDriveLockAsync = withDriveLockAsync;
|
|
55
58
|
exports.saveCheckpoint = saveCheckpoint;
|
|
56
59
|
exports.compactCheckpoint = compactCheckpoint;
|
|
57
60
|
exports.createRun = createRun;
|
|
@@ -185,6 +188,22 @@ function loadRunFromCwd(runId, cwd = process.cwd()) {
|
|
|
185
188
|
assertNotSuspectedDataLoss(runId, result);
|
|
186
189
|
return result.run;
|
|
187
190
|
}
|
|
191
|
+
/** The canonical run directory for `runId` under `cwd` — the SAME
|
|
192
|
+
* deterministic location loadRunFromCwd resolves `state.json` in (it too
|
|
193
|
+
* ignores any stored `paths.runDir`). Guards an empty/unsafe id and throws
|
|
194
|
+
* the exact `Run not found: <runId>` loadRunFromCwd throws, but via a plain
|
|
195
|
+
* `existsSync` so it adds no `state.json` READ and never creates the run dir
|
|
196
|
+
* for a nonexistent run. Used by the drive mutex, which needs the directory
|
|
197
|
+
* before it can acquire, without perturbing read accounting. */
|
|
198
|
+
function resolveRunDir(runId, cwd = process.cwd()) {
|
|
199
|
+
if (!runId)
|
|
200
|
+
throw new Error("Missing run id");
|
|
201
|
+
(0, fs_atomic_1.assertSafeRunId)(runId);
|
|
202
|
+
const runDir = path.join(cwd, ".cw", "runs", runId);
|
|
203
|
+
if (!fs.existsSync(path.join(runDir, "state.json")))
|
|
204
|
+
throw new Error(`Run not found: ${runId}`);
|
|
205
|
+
return runDir;
|
|
206
|
+
}
|
|
188
207
|
/** Hold the state.json lock over a WHOLE load -> change -> save cycle.
|
|
189
208
|
* A bare loadRunFromCwd + saveCheckpoint pair leaves a window where two
|
|
190
209
|
* processes both load the same state and the later save silently drops
|
|
@@ -201,6 +220,143 @@ function withRunStateLock(runId, cwd, fn) {
|
|
|
201
220
|
const probe = loadRunFromCwd(runId, cwd);
|
|
202
221
|
return (0, fs_atomic_1.withFileLock)(probe.paths.state, () => fn(loadRunFromCwd(runId, cwd)));
|
|
203
222
|
}
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// withDriveLock — a run-scoped DRIVE mutex, held across a WHOLE drive() call.
|
|
225
|
+
//
|
|
226
|
+
// The state.json write lock (withFileLock) covers only the instant of a
|
|
227
|
+
// write; a concurrent round loads the run once, spawns agents for MINUTES,
|
|
228
|
+
// then flushes the object it loaded at round start. A second drive on the
|
|
229
|
+
// same run therefore clobbers this one's flush (lost update) and both drives
|
|
230
|
+
// mint the same worker id from an in-memory count (double dispatch). This
|
|
231
|
+
// mutex closes both: only one process advances a run at a time.
|
|
232
|
+
//
|
|
233
|
+
// It is held far too long for withFileLock's 30s stale-steal window, so it is
|
|
234
|
+
// NEVER time-stale-stolen. A lock whose recorded pid is a LIVE process refuses
|
|
235
|
+
// (fail closed — never a silent double-drive); a lock whose owner is GONE (a
|
|
236
|
+
// crashed prior drive) or this process's own leaked lock is stolen so a crash
|
|
237
|
+
// can never wedge the run. Lock body is `"<pid>@<ISO>\n"`, the same shape as
|
|
238
|
+
// the fs-atomic lock, acquired with the same single-winner `link(2)` idiom.
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
/** Drive-lock paths this process holds right now. A nested drive() on the
|
|
241
|
+
* SAME run id (sub-workflows use a DIFFERENT run id, so this should not
|
|
242
|
+
* happen — defensive) re-enters instead of self-refusing. */
|
|
243
|
+
const HELD_DRIVE_LOCKS = new Set();
|
|
244
|
+
function driveLockPath(runDir) {
|
|
245
|
+
return path.join(runDir, "drive.lock");
|
|
246
|
+
}
|
|
247
|
+
/** True when `pid` names a live process on this machine. `process.kill(pid,
|
|
248
|
+
* 0)` delivers no signal — it only probes existence/permission. `EPERM` (the
|
|
249
|
+
* pid exists but is not signalable by us) counts as ALIVE, the conservative
|
|
250
|
+
* never-wrongly-steal direction; a malformed/absent pid is "not alive". */
|
|
251
|
+
function driveLockOwnerAlive(pid) {
|
|
252
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
253
|
+
return false;
|
|
254
|
+
try {
|
|
255
|
+
process.kill(pid, 0);
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
return Boolean(error && typeof error === "object" && error.code === "EPERM");
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function readDriveLockPid(lock) {
|
|
263
|
+
try {
|
|
264
|
+
const match = /^(\d+)@/.exec(fs.readFileSync(lock, "utf8"));
|
|
265
|
+
return match ? Number(match[1]) : 0;
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return 0; // vanished or unreadable — treat as no owner
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/** Acquire the drive mutex for `runId`'s run directory. Returns a release
|
|
272
|
+
* function (a no-op when this call re-entered a lock already held in-process).
|
|
273
|
+
* Throws a fail-closed refusal — naming the resume command — when another
|
|
274
|
+
* LIVE process holds it. */
|
|
275
|
+
function acquireDriveLock(runDir, runId) {
|
|
276
|
+
const lock = driveLockPath(runDir);
|
|
277
|
+
const key = path.resolve(lock);
|
|
278
|
+
if (HELD_DRIVE_LOCKS.has(key))
|
|
279
|
+
return () => { };
|
|
280
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
281
|
+
const pid = process.pid;
|
|
282
|
+
const body = `${pid}@${new Date().toISOString()}\n`;
|
|
283
|
+
const refuse = (ownerPid) => new Error(`Run ${runId} is already being driven by another process (pid ${ownerPid || "unknown"}). ` +
|
|
284
|
+
`Wait for it to finish, or if it has crashed remove ${lock}, then resume: cw run resume ${runId} --drive`);
|
|
285
|
+
let acquired = false;
|
|
286
|
+
// A live owner refuses at once; only a stealable (dead-owner / own-leak) lock
|
|
287
|
+
// loops to re-acquire, so this never hot-spins for a genuinely busy run.
|
|
288
|
+
for (let attempt = 0; attempt < 3 && !acquired; attempt++) {
|
|
289
|
+
const tmp = `${lock}.${pid}.${attempt}.tmp`;
|
|
290
|
+
try {
|
|
291
|
+
fs.writeFileSync(tmp, body, { mode: 0o600 });
|
|
292
|
+
fs.linkSync(tmp, lock);
|
|
293
|
+
acquired = true;
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
if (!(error && typeof error === "object" && error.code === "EEXIST")) {
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
const ownerPid = readDriveLockPid(lock);
|
|
300
|
+
// Stealable: our own leaked lock, or an owner that is no longer alive.
|
|
301
|
+
if (ownerPid === pid || !driveLockOwnerAlive(ownerPid)) {
|
|
302
|
+
try {
|
|
303
|
+
fs.unlinkSync(lock);
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
/* another process took it first — the next attempt re-judges */
|
|
307
|
+
}
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
throw refuse(ownerPid);
|
|
311
|
+
}
|
|
312
|
+
finally {
|
|
313
|
+
try {
|
|
314
|
+
fs.unlinkSync(tmp);
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
/* the link consumed it, or it is already gone */
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (!acquired)
|
|
322
|
+
throw refuse(readDriveLockPid(lock));
|
|
323
|
+
HELD_DRIVE_LOCKS.add(key);
|
|
324
|
+
return () => {
|
|
325
|
+
HELD_DRIVE_LOCKS.delete(key);
|
|
326
|
+
try {
|
|
327
|
+
// Release only while we still own it (a force-stale steal by a later
|
|
328
|
+
// process must not have its lock removed by us).
|
|
329
|
+
if (fs.readFileSync(lock, "utf8").startsWith(`${pid}@`))
|
|
330
|
+
fs.rmSync(lock, { force: true });
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
/* already released/removed */
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
/** Run `fn` while holding the run-scoped drive mutex (see the block comment
|
|
338
|
+
* above); always released, even on throw. Synchronous callers (drive()). */
|
|
339
|
+
function withDriveLock(runDir, runId, fn) {
|
|
340
|
+
const release = acquireDriveLock(runDir, runId);
|
|
341
|
+
try {
|
|
342
|
+
return fn();
|
|
343
|
+
}
|
|
344
|
+
finally {
|
|
345
|
+
release();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
/** Async form of `withDriveLock` — releases only AFTER `fn`'s promise
|
|
349
|
+
* settles, so driveAsync()'s awaited multi-round loop stays inside the lock
|
|
350
|
+
* for its whole lifetime. */
|
|
351
|
+
async function withDriveLockAsync(runDir, runId, fn) {
|
|
352
|
+
const release = acquireDriveLock(runDir, runId);
|
|
353
|
+
try {
|
|
354
|
+
return await fn();
|
|
355
|
+
}
|
|
356
|
+
finally {
|
|
357
|
+
release();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
204
360
|
/** state.json is the single source of truth — set `updatedAt`, then write
|
|
205
361
|
* it DURABLY with a lock so concurrent processes never lose an update. */
|
|
206
362
|
function saveCheckpoint(run) {
|
|
@@ -74,12 +74,18 @@ class Scheduler {
|
|
|
74
74
|
create(options) {
|
|
75
75
|
const kind = normalizeKind(options.kind);
|
|
76
76
|
const now = new Date();
|
|
77
|
-
|
|
77
|
+
// Fail closed on a bad number rather than silently clamping a typo into a
|
|
78
|
+
// runaway: interval is a whole number of 0 or more (0 = due now, a real
|
|
79
|
+
// supported value), maxRuns a whole number more than 0, jitter/delay 0 or
|
|
80
|
+
// more, ttlDays more than 0. Use `??` (not `||`) when picking the option so
|
|
81
|
+
// an explicit 0 is kept -- for interval a valid "due now", not swallowed
|
|
82
|
+
// into the default 1; for the others kept and then rejected.
|
|
83
|
+
const intervalMinutes = requireNonNegativeInt(options.intervalMinutes ?? options.interval, "interval");
|
|
78
84
|
const cron = stringOption(options.cron);
|
|
79
|
-
const delayMinutes =
|
|
80
|
-
const jitterSeconds =
|
|
85
|
+
const delayMinutes = requireNonNegative(options.delayMinutes ?? options.delay, "delay");
|
|
86
|
+
const jitterSeconds = requireNonNegative(options.jitterSeconds, "jitterSeconds") ?? 0;
|
|
81
87
|
const nextRunAt = computeInitialNextRunAt({ kind, now, intervalMinutes, cron, delayMinutes, jitterSeconds });
|
|
82
|
-
const ttlDays =
|
|
88
|
+
const ttlDays = requirePositive(options.ttlDays, "ttlDays") ?? DEFAULT_TTL_DAYS;
|
|
83
89
|
const task = {
|
|
84
90
|
id: createScheduleId(kind),
|
|
85
91
|
kind,
|
|
@@ -95,7 +101,7 @@ class Scheduler {
|
|
|
95
101
|
intervalMinutes,
|
|
96
102
|
cron,
|
|
97
103
|
jitterSeconds,
|
|
98
|
-
maxRuns:
|
|
104
|
+
maxRuns: requirePositiveInt(options.maxRuns, "maxRuns"),
|
|
99
105
|
runCount: 0,
|
|
100
106
|
};
|
|
101
107
|
return this.locked(() => {
|
|
@@ -125,7 +131,7 @@ class Scheduler {
|
|
|
125
131
|
const store = this.load();
|
|
126
132
|
let changed = false;
|
|
127
133
|
for (const task of store.tasks) {
|
|
128
|
-
if (task.status === "active" &&
|
|
134
|
+
if (task.status === "active" && reachedBy(task.expiresAt, now)) {
|
|
129
135
|
task.status = "expired";
|
|
130
136
|
task.updatedAt = now.toISOString();
|
|
131
137
|
changed = true;
|
|
@@ -133,7 +139,7 @@ class Scheduler {
|
|
|
133
139
|
}
|
|
134
140
|
if (changed)
|
|
135
141
|
this.save(store);
|
|
136
|
-
const dueTasks = store.tasks.filter((task) => task.status === "active" &&
|
|
142
|
+
const dueTasks = store.tasks.filter((task) => task.status === "active" && reachedBy(task.nextRunAt, now));
|
|
137
143
|
if (dueTasks.length) {
|
|
138
144
|
for (const task of dueTasks) {
|
|
139
145
|
const alreadyRecorded = task.lastDueAt && new Date(task.lastDueAt).getTime() >= new Date(task.nextRunAt).getTime();
|
|
@@ -219,6 +225,7 @@ class Scheduler {
|
|
|
219
225
|
if (!fs.existsSync(this.storePath))
|
|
220
226
|
return { schemaVersion: 1, tasks: [], history: [] };
|
|
221
227
|
const value = (0, fs_atomic_1.readJson)(this.storePath);
|
|
228
|
+
ensureKnownSchemaVersion(value, "schedule");
|
|
222
229
|
return {
|
|
223
230
|
schemaVersion: 1,
|
|
224
231
|
tasks: Array.isArray(value.tasks) ? value.tasks : [],
|
|
@@ -353,6 +360,64 @@ function numberOption(value) {
|
|
|
353
360
|
const parsed = Number(value);
|
|
354
361
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
355
362
|
}
|
|
363
|
+
// Number guards for create(): an absent option (undefined/null/bare flag) is
|
|
364
|
+
// left undefined for the caller's default; a GIVEN option that is out of
|
|
365
|
+
// bounds throws a clear, named refusal (fail closed) instead of being clamped.
|
|
366
|
+
function requirePositiveInt(value, name) {
|
|
367
|
+
if (value === undefined || value === null || value === true)
|
|
368
|
+
return undefined;
|
|
369
|
+
const parsed = Number(value);
|
|
370
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
371
|
+
throw new Error(`${name} must be a whole number more than 0`);
|
|
372
|
+
}
|
|
373
|
+
return parsed;
|
|
374
|
+
}
|
|
375
|
+
// interval only: a whole number of 0 or more. 0 means "due now" (nextRunAt is
|
|
376
|
+
// this moment) and is a real, supported value, so it must pass; a negative or
|
|
377
|
+
// non-whole interval is the typo we fail closed on.
|
|
378
|
+
function requireNonNegativeInt(value, name) {
|
|
379
|
+
if (value === undefined || value === null || value === true)
|
|
380
|
+
return undefined;
|
|
381
|
+
const parsed = Number(value);
|
|
382
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
383
|
+
throw new Error(`${name} must be a whole number of 0 or more`);
|
|
384
|
+
}
|
|
385
|
+
return parsed;
|
|
386
|
+
}
|
|
387
|
+
function requirePositive(value, name) {
|
|
388
|
+
if (value === undefined || value === null || value === true)
|
|
389
|
+
return undefined;
|
|
390
|
+
const parsed = Number(value);
|
|
391
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
392
|
+
throw new Error(`${name} must be a number more than 0`);
|
|
393
|
+
}
|
|
394
|
+
return parsed;
|
|
395
|
+
}
|
|
396
|
+
function requireNonNegative(value, name) {
|
|
397
|
+
if (value === undefined || value === null || value === true)
|
|
398
|
+
return undefined;
|
|
399
|
+
const parsed = Number(value);
|
|
400
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
401
|
+
throw new Error(`${name} must be a number of 0 or more`);
|
|
402
|
+
}
|
|
403
|
+
return parsed;
|
|
404
|
+
}
|
|
405
|
+
// Fail closed on a store written by a newer/unknown runtime: forcing it to v1
|
|
406
|
+
// would silently drop fields it does not understand. A missing schemaVersion is
|
|
407
|
+
// treated as legacy v1 for backward tolerance.
|
|
408
|
+
function ensureKnownSchemaVersion(value, store) {
|
|
409
|
+
const version = value.schemaVersion;
|
|
410
|
+
if (version !== undefined && version !== 1) {
|
|
411
|
+
throw new Error(`Unsupported ${store} store schemaVersion: ${String(version)}`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
// A stored date that parses to NaN is corrupt. `NaN <= now` is false, which
|
|
415
|
+
// would make such a task silently never fire and never expire; fail closed by
|
|
416
|
+
// treating a corrupt date as already reached (due / expired), never inert.
|
|
417
|
+
function reachedBy(iso, now) {
|
|
418
|
+
const at = new Date(iso).getTime();
|
|
419
|
+
return Number.isNaN(at) || at <= now.getTime();
|
|
420
|
+
}
|
|
356
421
|
class DesktopSchedulerDaemon {
|
|
357
422
|
cwd;
|
|
358
423
|
intervalSeconds;
|
|
@@ -369,11 +434,36 @@ class DesktopSchedulerDaemon {
|
|
|
369
434
|
(0, fs_atomic_1.writeJson)(inboxPath, { schemaVersion: 1, checkedAt, due });
|
|
370
435
|
return { checkedAt, dueCount: due.length, dueIds: due.map((task) => task.id), inboxPath };
|
|
371
436
|
}
|
|
437
|
+
/** One tick, guarded. A long-running daemon must survive a transient
|
|
438
|
+
* failure — chiefly file-lock contention when a concurrent `cw schedule`
|
|
439
|
+
* command holds tasks.json (withFileLock gives up after ~6s). Returns
|
|
440
|
+
* true to keep going, false to stop the daemon cleanly.
|
|
441
|
+
* - lock contention → warn to STDERR (stdout stays pure NDJSON), keep going;
|
|
442
|
+
* - any other error (e.g. a corrupt tasks.json) → one `cw:` line + stop,
|
|
443
|
+
* a clean fail-closed shutdown instead of a raw stack dump. */
|
|
444
|
+
safeTick() {
|
|
445
|
+
try {
|
|
446
|
+
process.stdout.write(`${JSON.stringify(this.tick())}\n`);
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
catch (error) {
|
|
450
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
451
|
+
if (/could not acquire file lock/i.test(message)) {
|
|
452
|
+
process.stderr.write(`cw: schedule daemon: skipped a tick (${message})\n`);
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
process.stderr.write(`cw: ${message}\n`);
|
|
456
|
+
process.exitCode = 1;
|
|
457
|
+
return false;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
372
460
|
async run() {
|
|
373
461
|
fs.mkdirSync(path.join(this.cwd, ".cw", "schedules"), { recursive: true });
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
462
|
+
if (!this.safeTick())
|
|
463
|
+
return;
|
|
464
|
+
const timer = setInterval(() => {
|
|
465
|
+
if (!this.safeTick())
|
|
466
|
+
clearInterval(timer);
|
|
377
467
|
}, Math.max(1, this.intervalSeconds) * 1000);
|
|
378
468
|
}
|
|
379
469
|
}
|
|
@@ -465,6 +555,7 @@ class RoutineTriggerBridge {
|
|
|
465
555
|
if (!fs.existsSync(this.storePath))
|
|
466
556
|
return { schemaVersion: 1, triggers: [], events: [], nextTriggerSeq: 0 };
|
|
467
557
|
const value = (0, fs_atomic_1.readJson)(this.storePath);
|
|
558
|
+
ensureKnownSchemaVersion(value, "routine");
|
|
468
559
|
const triggers = Array.isArray(value.triggers) ? value.triggers : [];
|
|
469
560
|
const maxExisting = triggers.reduce((max, trigger) => {
|
|
470
561
|
const n = Number((String(trigger.id).match(/(\d+)$/) || [])[1] || 0);
|