cool-workflow 0.2.2 → 0.2.4
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 +13 -6
- package/dist/cli/entry.js +41 -1
- package/dist/cli/io.js +12 -22
- package/dist/cli/parseargv.js +1 -0
- package/dist/core/capability-data.js +3 -2
- package/dist/core/format/completion.js +68 -0
- package/dist/core/format/help.js +31 -6
- 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 +3 -3
- package/dist/core/multi-agent/coordinator.js +5 -5
- package/dist/core/multi-agent/runtime.js +4 -4
- package/dist/core/multi-agent/topology.js +3 -3
- 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 +13 -13
- package/dist/core/state/state-explosion/graph.js +17 -11
- package/dist/core/state/state-explosion/report.js +6 -6
- package/dist/core/util/cli-args.js +33 -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 +57 -25
- 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 +251 -49
- package/dist/shell/eval-text.js +2 -2
- package/dist/shell/evidence-reasoning.js +4 -4
- package/dist/shell/execution-backend/agent.js +30 -2
- package/dist/shell/execution-backend/container.js +4 -1
- package/dist/shell/execution-backend/local.js +19 -11
- package/dist/shell/feedback-cli.js +6 -6
- package/dist/shell/fs-atomic.js +218 -29
- 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 +80 -38
- package/dist/shell/node-store.js +10 -4
- package/dist/shell/observability.js +1 -1
- package/dist/shell/operator-ux-text.js +22 -22
- package/dist/shell/operator-ux.js +15 -14
- package/dist/shell/orchestrator.js +49 -38
- package/dist/shell/pipeline-cli.js +107 -42
- package/dist/shell/reclamation-io.js +88 -10
- package/dist/shell/registry-cli.js +23 -17
- package/dist/shell/remote-source.js +13 -8
- package/dist/shell/report-cli.js +45 -0
- package/dist/shell/report.js +2 -1
- package/dist/shell/run-registry-io.js +78 -19
- package/dist/shell/run-store.js +74 -2
- package/dist/shell/scheduling-io.js +12 -13
- package/dist/shell/state-cli.js +2 -7
- package/dist/shell/state-explosion-cli.js +17 -9
- package/dist/shell/topology-io.js +36 -5
- package/dist/shell/trust-audit.js +224 -22
- package/dist/shell/trust-policy-io.js +1 -1
- package/dist/shell/worker-cli.js +35 -31
- package/dist/shell/workflow-app-loader.js +67 -1
- package/dist/wiring/capability-table/basics.js +33 -8
- package/dist/wiring/capability-table/exec-backend.js +28 -19
- package/dist/wiring/capability-table/multi-agent.js +187 -180
- package/dist/wiring/capability-table/parity.js +1 -0
- package/dist/wiring/capability-table/pipeline.js +64 -52
- package/dist/wiring/capability-table/registry-core.js +30 -11
- package/dist/wiring/capability-table/reporting.js +147 -106
- package/dist/wiring/capability-table/scheduling-registry.js +201 -167
- package/dist/wiring/capability-table/state.js +59 -51
- package/dist/wiring/capability-table/trust-ledger.js +52 -28
- package/dist/wiring/capability-table/workflow-apps.js +85 -70
- 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 +26 -16
- 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 +4 -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/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 +31 -4
- package/docs/real-execution-backends.7.md +4 -0
- package/docs/release-and-migration.7.md +5 -1
- package/docs/release-tooling.7.md +22 -0
- package/docs/routines.md +4 -4
- package/docs/run-registry-control-plane.7.md +23 -19
- package/docs/run-retention-reclamation.7.md +24 -3
- package/docs/scheduled-tasks.md +14 -14
- package/docs/security-trust-hardening.7.md +15 -15
- package/docs/state-explosion-management.7.md +14 -10
- package/docs/team-collaboration.7.md +4 -0
- package/docs/trust-audit-anchor.7.md +4 -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 +3 -2
- package/scripts/agents/claude-p-agent.js +2 -2
- package/scripts/block-unapproved-tag.sh +42 -6
- package/scripts/bump-version.js +27 -10
- 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 +0 -27
- package/scripts/release-flow.js +194 -13
- package/scripts/release-oneclick.js +407 -0
- 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 +39 -22
- 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/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
|
@@ -338,7 +338,8 @@ function writeReport(run) {
|
|
|
338
338
|
"| Phase | Status | Completed | Total |",
|
|
339
339
|
"| --- | --- | ---: | ---: |",
|
|
340
340
|
...run.phases.map((phase) => {
|
|
341
|
-
const
|
|
341
|
+
const taskIds = new Set(phase.taskIds);
|
|
342
|
+
const phaseTasks = run.tasks.filter((t) => taskIds.has(t.id));
|
|
342
343
|
const completed = phaseTasks.filter((t) => t.status === "completed").length;
|
|
343
344
|
return `| ${phase.name} | ${phase.status} | ${completed} | ${phaseTasks.length} |`;
|
|
344
345
|
}),
|
|
@@ -87,6 +87,7 @@ exports.DEFAULT_RUN_REGISTRY_POLICY = {
|
|
|
87
87
|
reclaimStates: ["completed", "failed"],
|
|
88
88
|
keepSnapshots: false,
|
|
89
89
|
keepScratch: false,
|
|
90
|
+
keepCommits: false,
|
|
90
91
|
maxReclaimRuns: 0,
|
|
91
92
|
maxReclaimBytes: 0,
|
|
92
93
|
};
|
|
@@ -207,10 +208,25 @@ function clampInt(value, fallback, min) {
|
|
|
207
208
|
return Math.max(min, Math.floor(n));
|
|
208
209
|
}
|
|
209
210
|
let queueCounter = 0;
|
|
210
|
-
|
|
211
|
-
|
|
211
|
+
/** Byte format is `q-${stamp14}-${NNN}` (SPEC/scheduling-registry.md "Id
|
|
212
|
+
* formats"), unchanged. `queueCounter` alone is not enough to keep ids
|
|
213
|
+
* unique: it resets to 0 for every fresh `cw` process, so two SEPARATE
|
|
214
|
+
* processes calling `queue add` within the same second both compute
|
|
215
|
+
* `queueCounter === 1` and mint the IDENTICAL id — `sched lease` then
|
|
216
|
+
* keys grants by id, so both queue entries get treated as one, breaking
|
|
217
|
+
* through `maxConcurrent`. `existingIds` is the current queue file's ids
|
|
218
|
+
* (read inside queueAdd's own file lock, so this sees every entry any
|
|
219
|
+
* earlier-completed `queue add` — same process or not — has already
|
|
220
|
+
* saved); bumping past any collision against that real, shared state
|
|
221
|
+
* keeps ids unique across processes without changing the format. */
|
|
222
|
+
function queueId(existingIds) {
|
|
212
223
|
const stamp = new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
|
|
213
|
-
|
|
224
|
+
let candidate;
|
|
225
|
+
do {
|
|
226
|
+
queueCounter += 1;
|
|
227
|
+
candidate = `q-${stamp}-${String(queueCounter).padStart(3, "0")}`;
|
|
228
|
+
} while (existingIds && existingIds.has(candidate));
|
|
229
|
+
return candidate;
|
|
214
230
|
}
|
|
215
231
|
function isRunLifecycleState(value) {
|
|
216
232
|
return (typeof value === "string" &&
|
|
@@ -397,7 +413,7 @@ class RunRegistry {
|
|
|
397
413
|
const entries = this.loadQueueEntries();
|
|
398
414
|
const entry = {
|
|
399
415
|
schemaVersion: 1,
|
|
400
|
-
id: options.id || queueId(),
|
|
416
|
+
id: options.id || queueId(new Set(entries.map((e) => e.id))),
|
|
401
417
|
runId: options.runId,
|
|
402
418
|
appId: options.appId,
|
|
403
419
|
workflowId: options.workflowId,
|
|
@@ -462,6 +478,13 @@ class RunRegistry {
|
|
|
462
478
|
const result = (0, run_store_1.loadRunStateFile)(statePath, { dryRun: true });
|
|
463
479
|
if (result.report.status === "unsupported")
|
|
464
480
|
return null;
|
|
481
|
+
// Bulk scans (refreshRegistry, below) call deriveRecord directly and
|
|
482
|
+
// tolerate a null the same way as missing/unsupported — one bad run
|
|
483
|
+
// must not stop a listing of everything else. Single-run lookups
|
|
484
|
+
// (deriveRecordForRun, below) re-check this same condition WITHOUT
|
|
485
|
+
// swallowing it, so `cw run show`/`archive`/`rerun` on THIS run
|
|
486
|
+
// specifically get a clear reason instead of a misleading "not found."
|
|
487
|
+
(0, run_store_1.assertNotSuspectedDataLoss)(path.basename(runDir), result);
|
|
465
488
|
run = result.run;
|
|
466
489
|
}
|
|
467
490
|
catch {
|
|
@@ -609,7 +632,7 @@ class RunRegistry {
|
|
|
609
632
|
if (staleRuns.length || missingRuns.length)
|
|
610
633
|
status = "stale";
|
|
611
634
|
}
|
|
612
|
-
const refreshCmd = scope === "home" ? "
|
|
635
|
+
const refreshCmd = scope === "home" ? "cw registry refresh --scope home" : "cw registry refresh";
|
|
613
636
|
return {
|
|
614
637
|
schemaVersion: 1,
|
|
615
638
|
scope,
|
|
@@ -624,7 +647,7 @@ class RunRegistry {
|
|
|
624
647
|
},
|
|
625
648
|
index: current,
|
|
626
649
|
counts: current.counts,
|
|
627
|
-
nextAction: status === "valid" ? "
|
|
650
|
+
nextAction: status === "valid" ? "cw run search" : refreshCmd,
|
|
628
651
|
};
|
|
629
652
|
}
|
|
630
653
|
search(raw = {}) {
|
|
@@ -657,7 +680,7 @@ class RunRegistry {
|
|
|
657
680
|
offset: query.offset,
|
|
658
681
|
limit: query.limit,
|
|
659
682
|
records: page,
|
|
660
|
-
nextAction: report.freshness.status === "valid" ? "
|
|
683
|
+
nextAction: report.freshness.status === "valid" ? "cw run show <run-id>" : "cw registry refresh",
|
|
661
684
|
};
|
|
662
685
|
}
|
|
663
686
|
list(options = {}) {
|
|
@@ -680,7 +703,7 @@ class RunRegistry {
|
|
|
680
703
|
resolvedFrom: located.from,
|
|
681
704
|
repo: located.record.repo,
|
|
682
705
|
record: located.record,
|
|
683
|
-
nextAction: located.record.archived ? "
|
|
706
|
+
nextAction: located.record.archived ? "cw run resume " + runId : "cw run show " + runId,
|
|
684
707
|
};
|
|
685
708
|
}
|
|
686
709
|
const persisted = this.findPersisted(runId, scope);
|
|
@@ -691,10 +714,11 @@ class RunRegistry {
|
|
|
691
714
|
freshness: "missing",
|
|
692
715
|
repo: persisted?.repo,
|
|
693
716
|
persisted,
|
|
694
|
-
nextAction: "
|
|
717
|
+
nextAction: "cw registry refresh" + (scope === "home" ? " --scope home" : ""),
|
|
695
718
|
};
|
|
696
719
|
}
|
|
697
720
|
locate(runId, scope) {
|
|
721
|
+
(0, fs_atomic_1.assertSafeRunId)(runId);
|
|
698
722
|
const here = this.deriveRecordForRun(this.repoRoot, runId);
|
|
699
723
|
if (here)
|
|
700
724
|
return { record: here, from: "repo" };
|
|
@@ -711,9 +735,35 @@ class RunRegistry {
|
|
|
711
735
|
}
|
|
712
736
|
deriveRecordForRun(repo, runId) {
|
|
713
737
|
const runDir = path.join(this.repoRunsDir(repo), runId);
|
|
714
|
-
|
|
738
|
+
const statePath = path.join(runDir, "state.json");
|
|
739
|
+
if (!fs.existsSync(statePath))
|
|
715
740
|
return null;
|
|
716
|
-
|
|
741
|
+
const record = this.deriveRecord(repo, runDir);
|
|
742
|
+
if (record)
|
|
743
|
+
return record;
|
|
744
|
+
// deriveRecord returned null even though state.json existed a moment
|
|
745
|
+
// ago: it failed to parse, was flagged unsupported, tripped
|
|
746
|
+
// suspected-data-loss corroboration, or (a narrow race) a concurrent
|
|
747
|
+
// process deleted it in between. Re-derive without swallowing so a
|
|
748
|
+
// genuine corruption reaches THIS single-run lookup (backing `cw run
|
|
749
|
+
// show`/`archive`/`rerun`) instead of the misleading "not found" a bulk
|
|
750
|
+
// scan is content with — but if the file is ACTUALLY gone by the time
|
|
751
|
+
// we check again, that is genuinely missing, not corrupt, and must
|
|
752
|
+
// resolve to null like everywhere else, never a raw "File not found"
|
|
753
|
+
// exception surfacing from this fallback re-read.
|
|
754
|
+
try {
|
|
755
|
+
const result = (0, run_store_1.loadRunStateFile)(statePath, { dryRun: true });
|
|
756
|
+
if (result.report.status === "unsupported") {
|
|
757
|
+
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.`);
|
|
758
|
+
}
|
|
759
|
+
(0, run_store_1.assertNotSuspectedDataLoss)(runId, result);
|
|
760
|
+
return record;
|
|
761
|
+
}
|
|
762
|
+
catch (error) {
|
|
763
|
+
if (!fs.existsSync(statePath))
|
|
764
|
+
return null;
|
|
765
|
+
throw error;
|
|
766
|
+
}
|
|
717
767
|
}
|
|
718
768
|
findPersisted(runId, scope) {
|
|
719
769
|
for (const s of scope === "home" ? ["home", "repo"] : ["repo"]) {
|
|
@@ -725,6 +775,7 @@ class RunRegistry {
|
|
|
725
775
|
return undefined;
|
|
726
776
|
}
|
|
727
777
|
loadRun(repo, runId) {
|
|
778
|
+
(0, fs_atomic_1.assertSafeRunId)(runId);
|
|
728
779
|
const statePath = path.join(this.repoRunsDir(repo), runId, "state.json");
|
|
729
780
|
if (!fs.existsSync(statePath))
|
|
730
781
|
throw new Error(`Run not found: ${runId}`);
|
|
@@ -732,6 +783,7 @@ class RunRegistry {
|
|
|
732
783
|
if (result.report.status === "unsupported") {
|
|
733
784
|
throw new Error(`Unsupported run state for ${runId}: ${result.report.errors.join("; ")}`);
|
|
734
785
|
}
|
|
786
|
+
(0, run_store_1.assertNotSuspectedDataLoss)(runId, result);
|
|
735
787
|
return result.run;
|
|
736
788
|
}
|
|
737
789
|
resume(runId, options = {}) {
|
|
@@ -752,23 +804,23 @@ class RunRegistry {
|
|
|
752
804
|
const nextActions = [];
|
|
753
805
|
if (nextTasks.length) {
|
|
754
806
|
nextActions.push({
|
|
755
|
-
command: `
|
|
807
|
+
command: `cw dispatch ${runId} --cwd ${record.repo}`,
|
|
756
808
|
reason: `Continue ${nextTasks.length} pending/running task(s) from durable state.`,
|
|
757
809
|
});
|
|
758
810
|
nextActions.push({
|
|
759
|
-
command: `
|
|
811
|
+
command: `cw multi-agent step ${runId} --cwd ${record.repo}`,
|
|
760
812
|
reason: "Take one deterministic host step without spawning agents.",
|
|
761
813
|
});
|
|
762
814
|
}
|
|
763
815
|
else if (record.derivedLifecycle === "failed") {
|
|
764
816
|
nextActions.push({
|
|
765
|
-
command: `
|
|
817
|
+
command: `cw run rerun ${runId}`,
|
|
766
818
|
reason: "Run terminated as failed with no runnable tasks; rerun as a new linked run.",
|
|
767
819
|
});
|
|
768
820
|
}
|
|
769
821
|
else {
|
|
770
822
|
nextActions.push({
|
|
771
|
-
command: `
|
|
823
|
+
command: `cw status ${runId} --cwd ${record.repo} --json`,
|
|
772
824
|
reason: "No runnable tasks remain; inspect status.",
|
|
773
825
|
});
|
|
774
826
|
}
|
|
@@ -807,7 +859,14 @@ class RunRegistry {
|
|
|
807
859
|
}
|
|
808
860
|
(0, fs_atomic_1.writeJson)(file, overlay, { durable: true });
|
|
809
861
|
});
|
|
810
|
-
|
|
862
|
+
// deriveRecordForRun (not a bare deriveRecord(...)!) so a run that
|
|
863
|
+
// became corrupt/unsupported/suspected-data-loss in the narrow window
|
|
864
|
+
// between locate() above and this re-fetch gets the same clear,
|
|
865
|
+
// specific error every other single-run lookup gets, instead of a raw
|
|
866
|
+
// TypeError from a non-null assertion on a surprise null.
|
|
867
|
+
const record = this.deriveRecordForRun(repo, runId);
|
|
868
|
+
if (!record)
|
|
869
|
+
throw new Error(`Cannot archive: run ${runId} state became unavailable while archiving (fail closed).`);
|
|
811
870
|
return {
|
|
812
871
|
runId,
|
|
813
872
|
repo,
|
|
@@ -877,8 +936,8 @@ class RunRegistry {
|
|
|
877
936
|
pendingTasks: newRun.tasks.filter((t) => t.status === "pending").length,
|
|
878
937
|
provenance,
|
|
879
938
|
nextActions: [
|
|
880
|
-
{ command: `
|
|
881
|
-
{ command: `
|
|
939
|
+
{ command: `cw run resume ${newRun.id}`, reason: "Continue the new linked run." },
|
|
940
|
+
{ command: `cw run show ${runId}`, reason: "The original failed run is preserved for audit." },
|
|
882
941
|
],
|
|
883
942
|
};
|
|
884
943
|
}
|
|
@@ -918,7 +977,7 @@ class RunRegistry {
|
|
|
918
977
|
limit,
|
|
919
978
|
repos: index.repos,
|
|
920
979
|
entries,
|
|
921
|
-
nextAction: report.freshness.status === "valid" ? "
|
|
980
|
+
nextAction: report.freshness.status === "valid" ? "cw run show <run-id>" : "cw registry refresh --scope home",
|
|
922
981
|
};
|
|
923
982
|
}
|
|
924
983
|
}
|
package/dist/shell/run-store.js
CHANGED
|
@@ -49,6 +49,7 @@ 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;
|
|
53
54
|
exports.withRunStateLock = withRunStateLock;
|
|
54
55
|
exports.saveCheckpoint = saveCheckpoint;
|
|
@@ -98,19 +99,90 @@ function migrateRunStateFile(statePath, options = {}) {
|
|
|
98
99
|
}
|
|
99
100
|
return result;
|
|
100
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
|
+
}
|
|
101
161
|
/** Refuses an empty id with `Missing run id`; loads
|
|
102
162
|
* `<cwd>/.cw/runs/<runId>/state.json` (dry-run — never writes); throws
|
|
103
|
-
* `
|
|
104
|
-
*
|
|
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. */
|
|
105
174
|
function loadRunFromCwd(runId, cwd = process.cwd()) {
|
|
106
175
|
if (!runId)
|
|
107
176
|
throw new Error("Missing run id");
|
|
108
177
|
(0, fs_atomic_1.assertSafeRunId)(runId);
|
|
109
178
|
const statePath = path.join(cwd, ".cw", "runs", runId, "state.json");
|
|
179
|
+
if (!fs.existsSync(statePath))
|
|
180
|
+
throw new Error(`Run not found: ${runId}`);
|
|
110
181
|
const result = loadRunStateFile(statePath, { dryRun: true });
|
|
111
182
|
if (result.report.status === "unsupported") {
|
|
112
183
|
throw new Error(`Unsupported CW run state: ${result.report.errors.join("; ")}`);
|
|
113
184
|
}
|
|
185
|
+
assertNotSuspectedDataLoss(runId, result);
|
|
114
186
|
return result.run;
|
|
115
187
|
}
|
|
116
188
|
/** Hold the state.json lock over a WHOLE load -> change -> save cycle.
|
|
@@ -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";
|
package/dist/shell/state-cli.js
CHANGED
|
@@ -65,6 +65,7 @@ const node_store_1 = require("./node-store");
|
|
|
65
65
|
const contract_migration_1 = require("../core/state/contract-migration");
|
|
66
66
|
const node_snapshot_1 = require("../core/state/node-snapshot");
|
|
67
67
|
const dispatch_1 = require("../core/pipeline/dispatch");
|
|
68
|
+
const numeric_flag_1 = require("../core/util/numeric-flag");
|
|
68
69
|
function optionalStringArg(value) {
|
|
69
70
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
70
71
|
}
|
|
@@ -128,12 +129,6 @@ function loadRun(runId, options = {}) {
|
|
|
128
129
|
const cwd = options.cwd ? path.resolve(String(options.cwd)) : process.cwd();
|
|
129
130
|
return (0, run_store_1.loadRunFromCwd)(runId, cwd);
|
|
130
131
|
}
|
|
131
|
-
function numberOption(value) {
|
|
132
|
-
if (value === undefined || value === null || value === true)
|
|
133
|
-
return undefined;
|
|
134
|
-
const n = Number(value);
|
|
135
|
-
return Number.isFinite(n) ? n : undefined;
|
|
136
|
-
}
|
|
137
132
|
/** `cw next <run-id> [--limit N]` — the read-only "what would dispatch next"
|
|
138
133
|
* preview: the runnable tasks the next `cw dispatch` would pick, in order,
|
|
139
134
|
* without mutating state. A byte-behavior port of the old build's
|
|
@@ -141,7 +136,7 @@ function numberOption(value) {
|
|
|
141
136
|
* the cw_next MCP tool render this same DispatchTask[] value, so their
|
|
142
137
|
* payloads stay identical. */
|
|
143
138
|
function nextCli(runId, options = {}) {
|
|
144
|
-
return (0, dispatch_1.nextDispatchTasks)(loadRun(runId, options),
|
|
139
|
+
return (0, dispatch_1.nextDispatchTasks)(loadRun(runId, options), (0, numeric_flag_1.requiredNumberFlag)(options.limit, "--limit"));
|
|
145
140
|
}
|
|
146
141
|
function listNodes(runId, options = {}) {
|
|
147
142
|
return loadRun(runId, options).nodes || [];
|
|
@@ -134,7 +134,7 @@ function refreshStateExplosionSummaries(run, options = {}) {
|
|
|
134
134
|
generatedAt: now,
|
|
135
135
|
status: "valid",
|
|
136
136
|
deterministic: true,
|
|
137
|
-
nextAction: `
|
|
137
|
+
nextAction: `cw summary show ${run.id}`,
|
|
138
138
|
entries: entries.sort((a, b) => (0, collate_1.stableCompare)(a.id, b.id)),
|
|
139
139
|
views,
|
|
140
140
|
paths: { summariesDir: dir, indexPath: path.join(dir, "index.json"), reportPath },
|
|
@@ -184,15 +184,23 @@ function loadRun(runId, options = {}) {
|
|
|
184
184
|
* only (the run-state checkpoint write is real and load-bearing today;
|
|
185
185
|
* report.md rendering is not). */
|
|
186
186
|
function summaryRefreshCli(runId, options = {}) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
(
|
|
190
|
-
return
|
|
187
|
+
// Hold the state.json lock across the whole load -> refresh -> save so a
|
|
188
|
+
// concurrent run mutation cannot drop this summary refresh (lost-update class).
|
|
189
|
+
const cwd = options.cwd ? path.resolve(String(options.cwd)) : process.cwd();
|
|
190
|
+
return (0, run_store_1.withRunStateLock)(runId, cwd, (run) => {
|
|
191
|
+
const index = refreshStateExplosionSummaries(run);
|
|
192
|
+
(0, run_store_1.saveCheckpoint)(run);
|
|
193
|
+
return index;
|
|
194
|
+
});
|
|
191
195
|
}
|
|
192
|
-
/** `cw summary show <run-id> [--json]` —
|
|
196
|
+
/** `cw summary show <run-id> [--json]` — a plain read: `showStateExplosionSummary`
|
|
197
|
+
* only loads the persisted summary index and builds a report from it, it
|
|
198
|
+
* never mutates `run`, so there is nothing here for a checkpoint write to
|
|
199
|
+
* durably persist. No `saveCheckpoint` call (dropped one, see ITERATION_LOG
|
|
200
|
+
* — it used to durably rewrite the whole unchanged state.json on every
|
|
201
|
+
* `summary show`, only bumping `run.updatedAt` as a side effect of a
|
|
202
|
+
* command whose whole job is to look, not touch). */
|
|
193
203
|
function summaryShowCli(runId, options = {}) {
|
|
194
204
|
const run = loadRun(runId, options);
|
|
195
|
-
|
|
196
|
-
(0, run_store_1.saveCheckpoint)(run);
|
|
197
|
-
return report;
|
|
205
|
+
return showStateExplosionSummary(run);
|
|
198
206
|
}
|
|
@@ -71,6 +71,23 @@ function topologyRunPath(run, id) {
|
|
|
71
71
|
const safe = id.replace(/[^a-zA-Z0-9_.:-]+/g, "_");
|
|
72
72
|
return path.join(topologyRoot(run), "runs", `${safe}.json`);
|
|
73
73
|
}
|
|
74
|
+
// Dirty-id tracking for persistTopologyState, mirroring coordinator-io.ts's
|
|
75
|
+
// blackboard dirty tracking: without it, every persist call rewrote every
|
|
76
|
+
// topology run's OWN file (topologyRunPath), even ones untouched this call —
|
|
77
|
+
// O(N) writes per call, O(N^2) across N applyTopology calls. Kept off the
|
|
78
|
+
// serialized TopologyState itself (a WeakMap keyed by the state object) so it
|
|
79
|
+
// can never leak into state.json's bytes. applyTopology is the only place
|
|
80
|
+
// state.runs is pushed to or an existing record mutated, so it is the only
|
|
81
|
+
// call site that needs to mark a run id dirty.
|
|
82
|
+
const topologyDirtyIds = new WeakMap();
|
|
83
|
+
function dirtyTopologyIds(state) {
|
|
84
|
+
let ids = topologyDirtyIds.get(state);
|
|
85
|
+
if (!ids) {
|
|
86
|
+
ids = new Set();
|
|
87
|
+
topologyDirtyIds.set(state, ids);
|
|
88
|
+
}
|
|
89
|
+
return ids;
|
|
90
|
+
}
|
|
74
91
|
function ensureTopologyState(run) {
|
|
75
92
|
run.paths.topologiesDir = topologyRoot(run);
|
|
76
93
|
fs.mkdirSync(run.paths.topologiesDir, { recursive: true });
|
|
@@ -90,8 +107,13 @@ function persistTopologyState(run) {
|
|
|
90
107
|
counts: { runs: state.runs.length },
|
|
91
108
|
runs: state.runs.map((record) => ({ id: record.id, topologyId: record.topologyId, status: record.status, updatedAt: record.updatedAt })),
|
|
92
109
|
});
|
|
93
|
-
|
|
94
|
-
|
|
110
|
+
const dirty = dirtyTopologyIds(state);
|
|
111
|
+
for (const id of dirty) {
|
|
112
|
+
const record = state.runs.find((entry) => entry.id === id);
|
|
113
|
+
if (record)
|
|
114
|
+
(0, fs_atomic_1.writeJson)(topologyRunPath(run, id), record);
|
|
115
|
+
}
|
|
116
|
+
dirty.clear();
|
|
95
117
|
}
|
|
96
118
|
function now() {
|
|
97
119
|
return new Date().toISOString();
|
|
@@ -238,6 +260,7 @@ function applyTopology(run, topologyId, input = {}) {
|
|
|
238
260
|
metadata: compact({ ...(input.metadata || {}), topology: definition }),
|
|
239
261
|
};
|
|
240
262
|
state.runs.push(record);
|
|
263
|
+
dirtyTopologyIds(state).add(record.id);
|
|
241
264
|
appendTopologyNode(run, record, topo.statusToNodeStatus(record.status));
|
|
242
265
|
(0, trust_audit_1.recordTrustAuditEvent)(run, {
|
|
243
266
|
kind: "topology.verdict",
|
|
@@ -272,7 +295,15 @@ function summarizeTopologies(run) {
|
|
|
272
295
|
const multi = run.multiAgent || {};
|
|
273
296
|
const fanins = multi.fanins || [];
|
|
274
297
|
const active = state.runs.map((record) => {
|
|
275
|
-
|
|
298
|
+
// `fanins` (multi.fanins) grows with total fan-in activity across the
|
|
299
|
+
// whole run, re-scanned here once per topology record -- Sets built
|
|
300
|
+
// from each record's own small groupIds/fanoutIds replace an
|
|
301
|
+
// O(fanins) `.includes()` scan per fanin with an O(1) lookup (the
|
|
302
|
+
// same array-scan-per-item shape 024b007 fixed for phase/task
|
|
303
|
+
// selection).
|
|
304
|
+
const groupIdSet = new Set(record.groupIds);
|
|
305
|
+
const fanoutIdSet = new Set(record.fanoutIds);
|
|
306
|
+
const inferredFanins = fanins.filter((fanin) => groupIdSet.has(fanin.groupId) || fanoutIdSet.has(fanin.fanoutId || ""));
|
|
276
307
|
const allFaninIds = topo.unique([...record.faninIds, ...inferredFanins.map((fanin) => fanin.id)]);
|
|
277
308
|
const blocked = inferredFanins.filter((fanin) => fanin.status === "blocked" || !fanin.verifierReady);
|
|
278
309
|
const ready = inferredFanins.some((fanin) => fanin.verifierReady);
|
|
@@ -291,7 +322,7 @@ function summarizeTopologies(run) {
|
|
|
291
322
|
missingEvidence,
|
|
292
323
|
conflicts: record.conflicts,
|
|
293
324
|
readiness: ready ? "fanin ready" : missingEvidence.length ? "missing evidence" : "awaiting worker output",
|
|
294
|
-
nextActions: ready ? [`
|
|
325
|
+
nextActions: ready ? [`cw candidate register ${run.id} --result-node <reducer-or-panel-result>`] : record.nextActions,
|
|
295
326
|
};
|
|
296
327
|
});
|
|
297
328
|
return {
|
|
@@ -300,7 +331,7 @@ function summarizeTopologies(run) {
|
|
|
300
331
|
runsByStatus: countBy(active, (record) => record.status),
|
|
301
332
|
officialTopologies: topo.listTopologyDefinitions().map((definition) => definition.id),
|
|
302
333
|
active,
|
|
303
|
-
nextAction: active.find((record) => record.nextActions.length)?.nextActions[0] || `
|
|
334
|
+
nextAction: active.find((record) => record.nextActions.length)?.nextActions[0] || `cw topology apply ${run.id} map-reduce --task <task-id>`,
|
|
304
335
|
};
|
|
305
336
|
}
|
|
306
337
|
function buildTopologyGraph(run) {
|