cool-workflow 0.2.3 → 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 +33 -4
- package/dist/cli/entry.js +11 -19
- package/dist/cli/global-flags.js +113 -0
- package/dist/cli/io.js +6 -20
- package/dist/cli/parseargv.js +7 -1
- package/dist/core/capability-data.js +339 -10
- package/dist/core/format/help.js +80 -4
- 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 +55 -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 +129 -64
- package/dist/shell/error-feedback-io.js +6 -0
- package/dist/shell/execution-backend/agent.js +205 -24
- package/dist/shell/execution-backend/container.js +44 -10
- package/dist/shell/execution-backend/local.js +47 -10
- 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-cli.js +20 -2
- package/dist/shell/pipeline.js +2 -1
- package/dist/shell/reclamation-io.js +76 -7
- package/dist/shell/registry-cli.js +4 -0
- package/dist/shell/run-export.js +52 -4
- package/dist/shell/run-registry-io.js +1 -0
- 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/shell/workflow-app-loader.js +67 -1
- package/dist/wiring/capability-table/basics.js +7 -2
- package/dist/wiring/capability-table/exec-backend.js +42 -24
- package/dist/wiring/capability-table/multi-agent.js +69 -69
- package/dist/wiring/capability-table/pipeline.js +60 -28
- package/dist/wiring/capability-table/registry-core.js +29 -6
- package/dist/wiring/capability-table/reporting.js +45 -39
- package/dist/wiring/capability-table/scheduling-registry.js +66 -60
- package/dist/wiring/capability-table/state.js +34 -34
- package/dist/wiring/capability-table/trust-ledger.js +66 -24
- package/dist/wiring/capability-table/workflow-apps.js +15 -15
- package/docs/agent-delegation-drive.7.md +17 -0
- package/docs/cli-mcp-parity.7.md +43 -13
- package/docs/contract-migration-tooling.7.md +4 -0
- package/docs/control-plane-scheduling.7.md +38 -0
- package/docs/cross-agent-ledger.7.md +20 -8
- package/docs/durable-state-and-locking.7.md +4 -0
- package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
- package/docs/execution-backends.7.md +4 -0
- package/docs/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 +4 -0
- package/docs/multi-agent-eval-replay-harness.7.md +4 -0
- package/docs/multi-agent-operator-ux.7.md +4 -0
- package/docs/node-snapshot-diff-replay.7.md +4 -0
- package/docs/observability-cost-accounting.7.md +4 -0
- package/docs/project-index.md +43 -9
- package/docs/real-execution-backends.7.md +4 -0
- package/docs/release-and-migration.7.md +4 -0
- package/docs/release-tooling.7.md +22 -0
- package/docs/routine.7.md +22 -0
- package/docs/run-registry-control-plane.7.md +4 -0
- package/docs/run-retention-reclamation.7.md +28 -3
- package/docs/state-explosion-management.7.md +4 -0
- package/docs/team-collaboration.7.md +4 -0
- package/docs/trust-audit-anchor.7.md +4 -0
- package/docs/web-desktop-workbench.7.md +26 -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 +5 -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/bump-version.js +27 -10
- 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-baseline.json +0 -30
- package/scripts/purity-gate.js +16 -3
- package/scripts/release-check.js +2 -1
- package/scripts/release-flow.js +264 -41
- package/scripts/release-gate.js +184 -0
- package/scripts/release-oneclick.js +438 -0
- 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/scripts/version-sync-check.js +39 -22
- 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 -60
- package/scripts/release-gate.sh +0 -94
- package/scripts/verify-bump-reproduction.sh +0 -148
- package/scripts/verify-container-selfref.js +0 -64
|
@@ -472,6 +472,32 @@ function planReclamation(run, policy = {}) {
|
|
|
472
472
|
}
|
|
473
473
|
}
|
|
474
474
|
}
|
|
475
|
+
// (3) Superseded, non-verifier-gated commit snapshots. Each commitState()
|
|
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.
|
|
485
|
+
let reclaimedCommitSnapshot = false;
|
|
486
|
+
if (!policy.keepCommits) {
|
|
487
|
+
const commits = run.commits || [];
|
|
488
|
+
for (let i = 0; i < commits.length - 1; i++) {
|
|
489
|
+
const commit = commits[i];
|
|
490
|
+
if (commit.verifierGated)
|
|
491
|
+
continue;
|
|
492
|
+
if (!commit.snapshotPath || !fs.existsSync(commit.snapshotPath))
|
|
493
|
+
continue;
|
|
494
|
+
const bytes = dirBytes(commit.snapshotPath);
|
|
495
|
+
if (bytes <= 0)
|
|
496
|
+
continue;
|
|
497
|
+
freeable.push({ path: rel(commit.snapshotPath), absPath: commit.snapshotPath, kind: "commit-snapshot", bytes });
|
|
498
|
+
reclaimedCommitSnapshot = true;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
475
501
|
// Determinism (HARD constraint): sort by path BEFORE anything hashes it,
|
|
476
502
|
// so tombstoneHash is reproducible across hosts regardless of
|
|
477
503
|
// fs.readdirSync's filesystem-dependent order.
|
|
@@ -484,7 +510,15 @@ function planReclamation(run, policy = {}) {
|
|
|
484
510
|
}
|
|
485
511
|
let capability = "re-runnable";
|
|
486
512
|
let capabilityReason = "scratch-only-reclaimed";
|
|
487
|
-
|
|
513
|
+
// A reclaimed commit snapshot is never reconstructable (a genuine
|
|
514
|
+
// point-in-time capture, no recipe) — it caps capability at "verify-only"
|
|
515
|
+
// regardless of what node-snapshot reclamation achieved, same as an
|
|
516
|
+
// unreconstructable node snapshot would.
|
|
517
|
+
if (reclaimedCommitSnapshot) {
|
|
518
|
+
capability = "verify-only";
|
|
519
|
+
capabilityReason = "snapshot-reclaimed-no-reconstruction";
|
|
520
|
+
}
|
|
521
|
+
else if (reclaimedSnapshot && reconstructableSnapshot) {
|
|
488
522
|
capability = "re-runnable-by-reconstruction";
|
|
489
523
|
capabilityReason = "inputs-and-expectdigest-retained";
|
|
490
524
|
}
|
|
@@ -595,13 +629,22 @@ function commitTombstone(run, tombstone) {
|
|
|
595
629
|
function prepareFree(run, tombstone) {
|
|
596
630
|
const runDir = run.paths.runDir;
|
|
597
631
|
const scratchDirs = tombstone.freed.filter((f) => f.kind === "scratch").map((f) => (0, fs_atomic_1.realResolve)(path.join(runDir, f.path)));
|
|
598
|
-
|
|
632
|
+
const commitSnapshotPaths = tombstone.freed.filter((f) => f.kind === "commit-snapshot").map((f) => (0, fs_atomic_1.realResolve)(path.join(runDir, f.path)));
|
|
633
|
+
if (!scratchDirs.length && !commitSnapshotPaths.length)
|
|
599
634
|
return;
|
|
600
635
|
const repointed = new Set();
|
|
601
636
|
for (const scratchDir of scratchDirs) {
|
|
602
637
|
for (const id of repointResultNodeArtifacts(run, scratchDir))
|
|
603
638
|
repointed.add(id);
|
|
604
639
|
}
|
|
640
|
+
// Unlike scratch (which has a retained "result" artifact to repoint to),
|
|
641
|
+
// a reclaimed commit snapshot has no surviving alternative — its OWN
|
|
642
|
+
// StateNode's "snapshot" artifact (recordCommitNode, shell/commit.ts) is
|
|
643
|
+
// the only reference to it, so it is stripped outright rather than
|
|
644
|
+
// repointed. node.outputs.snapshotPath (a plain metadata string, not an
|
|
645
|
+
// artifact the check below inspects) is left as a historical record,
|
|
646
|
+
// same as commit.snapshotPath itself staying in state.json.
|
|
647
|
+
stripCommitSnapshotArtifacts(run, commitSnapshotPaths);
|
|
605
648
|
persistRunDurable(run);
|
|
606
649
|
for (const node of run.nodes || []) {
|
|
607
650
|
for (const artifact of node.artifacts || []) {
|
|
@@ -613,12 +656,15 @@ function prepareFree(run, tombstone) {
|
|
|
613
656
|
throw new ReclamationError("repoint-incomplete", `node ${node.id} artifact ${artifact.id} still references freed scratch path ${artifact.path}`, { nodeId: node.id, artifactId: artifact.id, path: artifact.path });
|
|
614
657
|
}
|
|
615
658
|
}
|
|
659
|
+
if (commitSnapshotPaths.includes(resolved)) {
|
|
660
|
+
throw new ReclamationError("repoint-incomplete", `node ${node.id} artifact ${artifact.id} still references freed commit snapshot ${artifact.path}`, { nodeId: node.id, artifactId: artifact.id, path: artifact.path });
|
|
661
|
+
}
|
|
616
662
|
}
|
|
617
663
|
}
|
|
618
664
|
for (const nodeId of repointed) {
|
|
619
665
|
try {
|
|
620
666
|
const fresh = (0, node_store_1.snapshotNode)(run, nodeId, { persist: false });
|
|
621
|
-
const { freshness } = (0, node_snapshot_1.loadNodeSnapshot)(run, fresh);
|
|
667
|
+
const { freshness } = (0, node_snapshot_1.loadNodeSnapshot)(run, fresh, fs.existsSync);
|
|
622
668
|
if (freshness === "absent") {
|
|
623
669
|
throw new ReclamationError("repoint-incomplete", `re-pointed node ${nodeId} snapshot is absent (dangling artifact)`, { nodeId });
|
|
624
670
|
}
|
|
@@ -682,6 +728,28 @@ function repointResultNodeArtifacts(run, freedScratchDir) {
|
|
|
682
728
|
}
|
|
683
729
|
return changedIds;
|
|
684
730
|
}
|
|
731
|
+
/** Removes the "snapshot" artifact entry from any node that references one
|
|
732
|
+
* of `freedCommitSnapshotPaths` — there is no retained alternative to
|
|
733
|
+
* repoint to (unlike a scratch dir's "result" copy), so the reference is
|
|
734
|
+
* dropped outright. StateArtifact.path is a required string, so the
|
|
735
|
+
* artifact entry is filtered out rather than nulled. */
|
|
736
|
+
function stripCommitSnapshotArtifacts(run, freedCommitSnapshotPaths) {
|
|
737
|
+
if (!freedCommitSnapshotPaths.length)
|
|
738
|
+
return [];
|
|
739
|
+
const freedSet = new Set(freedCommitSnapshotPaths);
|
|
740
|
+
const changedIds = [];
|
|
741
|
+
for (const node of run.nodes || []) {
|
|
742
|
+
if (!node.artifacts || !node.artifacts.length)
|
|
743
|
+
continue;
|
|
744
|
+
const before = node.artifacts.length;
|
|
745
|
+
node.artifacts = node.artifacts.filter((artifact) => !artifact.path || !freedSet.has((0, fs_atomic_1.realResolve)(artifact.path)));
|
|
746
|
+
if (node.artifacts.length !== before) {
|
|
747
|
+
node.updatedAt = new Date().toISOString();
|
|
748
|
+
changedIds.push(node.id);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
return changedIds;
|
|
752
|
+
}
|
|
685
753
|
/** Execute the write-ahead, fail-closed reclamation transaction. */
|
|
686
754
|
function runReclamation(run, options = {}) {
|
|
687
755
|
const skeleton = extractSkeleton(run);
|
|
@@ -827,7 +895,7 @@ function gcPlan(host, options = {}) {
|
|
|
827
895
|
let plan;
|
|
828
896
|
try {
|
|
829
897
|
const run = host.loadRun(record.repo, record.runId);
|
|
830
|
-
plan = planReclamation(run, { keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots });
|
|
898
|
+
plan = planReclamation(run, { keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots, keepCommits: policy.keepCommits });
|
|
831
899
|
}
|
|
832
900
|
catch {
|
|
833
901
|
entries.push({
|
|
@@ -871,6 +939,7 @@ function gcPlan(host, options = {}) {
|
|
|
871
939
|
reclaimAfterArchiveDays: policy.reclaimAfterArchiveDays ?? 0,
|
|
872
940
|
keepSnapshots: Boolean(policy.keepSnapshots),
|
|
873
941
|
keepScratch: Boolean(policy.keepScratch),
|
|
942
|
+
keepCommits: Boolean(policy.keepCommits),
|
|
874
943
|
reclaimStates: policy.reclaimStates && policy.reclaimStates.length ? policy.reclaimStates : ["completed", "failed"],
|
|
875
944
|
},
|
|
876
945
|
total: entries.length,
|
|
@@ -911,8 +980,8 @@ function gcRun(host, options = {}) {
|
|
|
911
980
|
const result = runReclamation(run, {
|
|
912
981
|
now: nowIso,
|
|
913
982
|
actor: options.actor,
|
|
914
|
-
policy: { reclaimAfterArchiveDays: policy.reclaimAfterArchiveDays, keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots },
|
|
915
|
-
reclaimPolicy: { keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots },
|
|
983
|
+
policy: { reclaimAfterArchiveDays: policy.reclaimAfterArchiveDays, keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots, keepCommits: policy.keepCommits },
|
|
984
|
+
reclaimPolicy: { keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots, keepCommits: policy.keepCommits },
|
|
916
985
|
});
|
|
917
986
|
reclaimed.push({
|
|
918
987
|
runId: record.runId,
|
|
@@ -1004,7 +1073,7 @@ function gcVerify(host, runId, options = {}) {
|
|
|
1004
1073
|
function formatGcPlan(result) {
|
|
1005
1074
|
const lines = [
|
|
1006
1075
|
`GC Plan (${result.scope}): ${result.eligibleCount}/${result.total} eligible, ${result.bytesToFree} byte(s) would be freed [DRY-RUN, frees nothing]`,
|
|
1007
|
-
` policy: reclaimAfterArchiveDays=${result.policy.reclaimAfterArchiveDays} keepScratch=${result.policy.keepScratch} keepSnapshots=${result.policy.keepSnapshots}`,
|
|
1076
|
+
` policy: reclaimAfterArchiveDays=${result.policy.reclaimAfterArchiveDays} keepScratch=${result.policy.keepScratch} keepSnapshots=${result.policy.keepSnapshots} keepCommits=${result.policy.keepCommits}`,
|
|
1008
1077
|
];
|
|
1009
1078
|
for (const entry of result.entries) {
|
|
1010
1079
|
if (entry.eligible) {
|
|
@@ -297,6 +297,10 @@ function gcPolicyOverridesFrom(options) {
|
|
|
297
297
|
overrides.keepSnapshots = Boolean(options.keepSnapshots);
|
|
298
298
|
if (options["keep-snapshots"] !== undefined)
|
|
299
299
|
overrides.keepSnapshots = Boolean(options["keep-snapshots"]);
|
|
300
|
+
if (options.keepCommits !== undefined)
|
|
301
|
+
overrides.keepCommits = Boolean(options.keepCommits);
|
|
302
|
+
if (options["keep-commits"] !== undefined)
|
|
303
|
+
overrides.keepCommits = Boolean(options["keep-commits"]);
|
|
300
304
|
if (options.state !== undefined)
|
|
301
305
|
overrides.reclaimStates = Array.isArray(options.state) ? options.state : [options.state];
|
|
302
306
|
return overrides;
|
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);
|