cool-workflow 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/apps/architecture-review/app.json +1 -1
- package/apps/architecture-review-fast/app.json +1 -1
- package/apps/end-to-end-golden-path/app.json +1 -1
- package/apps/pr-review-fix-ci/app.json +1 -1
- package/apps/release-cut/app.json +1 -1
- package/apps/research-synthesis/app.json +1 -1
- package/dist/cli/dispatch.js +29 -91
- package/dist/cli/entry.js +41 -1
- package/dist/cli/io.js +6 -2
- package/dist/cli/parseargv.js +6 -2
- package/dist/core/capability-data.js +271 -0
- package/dist/core/capability-table.js +16 -3504
- package/dist/core/format/completion.js +68 -0
- package/dist/core/format/help.js +50 -8
- package/dist/core/format/safe-json.js +73 -0
- package/dist/core/format/state-explosion-text.js +1 -1
- package/dist/core/multi-agent/candidate-scoring.js +5 -1
- package/dist/core/multi-agent/collaboration.js +6 -5
- package/dist/core/multi-agent/coordinator.js +9 -8
- package/dist/core/multi-agent/runtime.js +4 -4
- package/dist/core/multi-agent/topology.js +3 -3
- package/dist/core/pipeline/commit-gate.js +2 -1
- package/dist/core/pipeline/dispatch.js +18 -4
- package/dist/core/pipeline/drive-decide.js +2 -1
- package/dist/core/state/migrations.js +16 -1
- package/dist/core/state/state-explosion/digest.js +16 -15
- package/dist/core/state/state-explosion/graph.js +26 -19
- package/dist/core/state/state-explosion/helpers.js +2 -1
- package/dist/core/state/state-explosion/report.js +6 -6
- package/dist/core/trust/ledger.js +2 -1
- package/dist/core/types/execution-backend.js +18 -0
- package/dist/core/types/observability.js +7 -0
- package/dist/core/util/collate.js +23 -0
- package/dist/core/util/numeric-flag.js +40 -0
- package/dist/core/version.js +1 -1
- package/dist/mcp/server.js +99 -11
- package/dist/shell/audit-cli.js +99 -23
- package/dist/shell/commit-summary.js +2 -1
- package/dist/shell/coordinator-io.js +73 -13
- package/dist/shell/dispatch.js +1 -1
- package/dist/shell/doctor.js +80 -1
- package/dist/shell/drive.js +269 -59
- package/dist/shell/eval-text.js +2 -2
- package/dist/shell/evidence-reasoning.js +8 -7
- package/dist/shell/execution-backend/agent.js +20 -1
- package/dist/shell/execution-backend/container.js +4 -1
- package/dist/shell/execution-backend/local.js +4 -1
- package/dist/shell/execution-backend/registry.js +2 -1
- package/dist/shell/execution-backend/types.js +0 -9
- package/dist/shell/feedback-cli.js +6 -6
- package/dist/shell/fs-atomic.js +232 -30
- package/dist/shell/man-cli.js +6 -0
- package/dist/shell/metrics-cli.js +2 -1
- package/dist/shell/multi-agent-cli.js +367 -323
- package/dist/shell/multi-agent-host.js +9 -9
- package/dist/shell/multi-agent-operator-ux.js +82 -39
- package/dist/shell/node-store.js +10 -4
- package/dist/shell/observability.js +13 -12
- package/dist/shell/onramp.js +17 -1
- package/dist/shell/operator-ux-text.js +24 -23
- package/dist/shell/operator-ux.js +22 -20
- package/dist/shell/orchestrator.js +49 -38
- package/dist/shell/pipeline-cli.js +168 -111
- package/dist/shell/reclamation-io.js +16 -5
- package/dist/shell/registry-cli.js +19 -17
- package/dist/shell/remote-source.js +13 -8
- package/dist/shell/report-cli.js +45 -0
- package/dist/shell/report.js +4 -2
- package/dist/shell/run-registry-io.js +77 -19
- package/dist/shell/run-store.js +91 -2
- package/dist/shell/scheduling-io.js +12 -13
- package/dist/shell/state-cli.js +2 -7
- package/dist/shell/state-explosion-cli.js +19 -10
- package/dist/shell/topology-io.js +38 -6
- package/dist/shell/trust-audit.js +264 -23
- package/dist/shell/trust-policy-io.js +1 -1
- package/dist/shell/worker-cli.js +41 -29
- package/dist/shell/worker-isolation.js +34 -9
- package/dist/shell/workflow-app-loader.js +3 -2
- package/dist/wiring/capability-table/basics.js +91 -0
- package/dist/wiring/capability-table/exec-backend.js +171 -0
- package/dist/wiring/capability-table/index.js +42 -0
- package/dist/wiring/capability-table/multi-agent.js +630 -0
- package/dist/wiring/capability-table/parity.js +467 -0
- package/dist/wiring/capability-table/pipeline.js +292 -0
- package/dist/wiring/capability-table/registry-core.js +208 -0
- package/dist/wiring/capability-table/reporting.js +435 -0
- package/dist/wiring/capability-table/scheduling-registry.js +592 -0
- package/dist/wiring/capability-table/state.js +181 -0
- package/dist/wiring/capability-table/trust-ledger.js +158 -0
- package/dist/wiring/capability-table/workflow-apps.js +381 -0
- package/docs/agent-delegation-drive.7.md +4 -0
- package/docs/candidate-scoring.7.md +1 -1
- package/docs/canonical-workflow-apps.7.md +7 -7
- package/docs/cli-mcp-parity.7.md +13 -3
- package/docs/contract-migration-tooling.7.md +4 -0
- package/docs/control-plane-scheduling.7.md +4 -0
- package/docs/coordinator-blackboard.7.md +17 -17
- package/docs/dogfood-one-real-repo.7.md +11 -11
- package/docs/durable-state-and-locking.7.md +24 -0
- package/docs/end-to-end-golden-path.7.md +14 -14
- package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
- package/docs/execution-backends.7.md +4 -0
- package/docs/getting-started.md +37 -37
- package/docs/index.md +1 -0
- package/docs/multi-agent-cli-mcp-surface.7.md +21 -17
- package/docs/multi-agent-eval-replay-harness.7.md +17 -13
- package/docs/multi-agent-operator-ux.7.md +23 -19
- package/docs/multi-agent-runtime-core.7.md +22 -22
- package/docs/multi-agent-topologies.7.md +6 -6
- package/docs/multi-agent-trust-policy-audit.7.md +11 -11
- package/docs/node-snapshot-diff-replay.7.md +4 -0
- package/docs/observability-cost-accounting.7.md +4 -0
- package/docs/operator-ux.7.md +34 -34
- package/docs/pipeline-runner.7.md +4 -4
- package/docs/project-index.md +48 -5
- package/docs/real-execution-backends.7.md +4 -0
- package/docs/release-and-migration.7.md +5 -1
- package/docs/release-tooling.7.md +4 -0
- package/docs/routines.md +4 -4
- package/docs/run-registry-control-plane.7.md +23 -19
- package/docs/run-retention-reclamation.7.md +4 -0
- package/docs/scheduled-tasks.md +14 -14
- package/docs/security-trust-hardening.7.md +43 -13
- package/docs/state-explosion-management.7.md +14 -10
- package/docs/team-collaboration.7.md +4 -0
- package/docs/trust-audit-anchor.7.md +71 -0
- package/docs/unix-principles.md +3 -1
- package/docs/verifier-gated-commit.7.md +1 -1
- package/docs/web-desktop-workbench.7.md +4 -0
- package/docs/workflow-app-framework.7.md +13 -13
- package/manifest/plugin.manifest.json +1 -1
- package/package.json +4 -2
- package/scripts/agents/claude-p-agent.js +2 -2
- package/scripts/block-unapproved-tag.sh +23 -2
- package/scripts/bump-version.js +24 -2
- package/scripts/canonical-apps.js +4 -4
- package/scripts/children/batch-delegate-child.js +52 -2
- package/scripts/dogfood-release.js +1 -1
- package/scripts/fake-date-for-reproduction.js +44 -0
- package/scripts/golden-path.js +4 -4
- package/scripts/purity-baseline.json +71 -0
- package/scripts/purity-gate.js +239 -0
- package/scripts/release-check.js +8 -1
- package/scripts/release-flow.js +57 -1
- package/scripts/verdict-keygen.js +83 -0
- package/scripts/verify-bump-reproduction.sh +148 -0
- package/scripts/verify-verdict-signature.js +61 -0
- package/scripts/version-sync-check.js +33 -12
- package/skills/cool-workflow/SKILL.md +9 -9
- package/skills/cool-workflow/references/commands.md +89 -88
- package/ui/workbench/app.css +37 -1
- package/ui/workbench/app.js +124 -6
- package/workflows/README.md +19 -0
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 || [];
|
|
@@ -66,6 +66,7 @@ const report_1 = require("../core/state/state-explosion/report");
|
|
|
66
66
|
Object.defineProperty(exports, "buildStateExplosionReport", { enumerable: true, get: function () { return report_1.buildStateExplosionReport; } });
|
|
67
67
|
const size_2 = require("../core/state/state-explosion/size");
|
|
68
68
|
const multi_agent_operator_ux_1 = require("./multi-agent-operator-ux");
|
|
69
|
+
const collate_1 = require("../core/util/collate");
|
|
69
70
|
function summariesDir(run) {
|
|
70
71
|
return path.join(run.paths.runDir, "summaries");
|
|
71
72
|
}
|
|
@@ -133,8 +134,8 @@ function refreshStateExplosionSummaries(run, options = {}) {
|
|
|
133
134
|
generatedAt: now,
|
|
134
135
|
status: "valid",
|
|
135
136
|
deterministic: true,
|
|
136
|
-
nextAction: `
|
|
137
|
-
entries: entries.sort((a, b) => a.id
|
|
137
|
+
nextAction: `cw summary show ${run.id}`,
|
|
138
|
+
entries: entries.sort((a, b) => (0, collate_1.stableCompare)(a.id, b.id)),
|
|
138
139
|
views,
|
|
139
140
|
paths: { summariesDir: dir, indexPath: path.join(dir, "index.json"), reportPath },
|
|
140
141
|
};
|
|
@@ -183,15 +184,23 @@ function loadRun(runId, options = {}) {
|
|
|
183
184
|
* only (the run-state checkpoint write is real and load-bearing today;
|
|
184
185
|
* report.md rendering is not). */
|
|
185
186
|
function summaryRefreshCli(runId, options = {}) {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
(
|
|
189
|
-
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
|
+
});
|
|
190
195
|
}
|
|
191
|
-
/** `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). */
|
|
192
203
|
function summaryShowCli(runId, options = {}) {
|
|
193
204
|
const run = loadRun(runId, options);
|
|
194
|
-
|
|
195
|
-
(0, run_store_1.saveCheckpoint)(run);
|
|
196
|
-
return report;
|
|
205
|
+
return showStateExplosionSummary(run);
|
|
197
206
|
}
|
|
@@ -63,6 +63,7 @@ const hash_2 = require("../core/hash");
|
|
|
63
63
|
const topo = __importStar(require("../core/multi-agent/topology"));
|
|
64
64
|
const multi_agent_io_1 = require("./multi-agent-io");
|
|
65
65
|
const coordinator_io_1 = require("./coordinator-io");
|
|
66
|
+
const collate_1 = require("../core/util/collate");
|
|
66
67
|
function topologyRoot(run) {
|
|
67
68
|
return run.paths.topologiesDir || path.join(run.paths.runDir, "topologies");
|
|
68
69
|
}
|
|
@@ -70,6 +71,23 @@ function topologyRunPath(run, id) {
|
|
|
70
71
|
const safe = id.replace(/[^a-zA-Z0-9_.:-]+/g, "_");
|
|
71
72
|
return path.join(topologyRoot(run), "runs", `${safe}.json`);
|
|
72
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
|
+
}
|
|
73
91
|
function ensureTopologyState(run) {
|
|
74
92
|
run.paths.topologiesDir = topologyRoot(run);
|
|
75
93
|
fs.mkdirSync(run.paths.topologiesDir, { recursive: true });
|
|
@@ -89,8 +107,13 @@ function persistTopologyState(run) {
|
|
|
89
107
|
counts: { runs: state.runs.length },
|
|
90
108
|
runs: state.runs.map((record) => ({ id: record.id, topologyId: record.topologyId, status: record.status, updatedAt: record.updatedAt })),
|
|
91
109
|
});
|
|
92
|
-
|
|
93
|
-
|
|
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();
|
|
94
117
|
}
|
|
95
118
|
function now() {
|
|
96
119
|
return new Date().toISOString();
|
|
@@ -237,6 +260,7 @@ function applyTopology(run, topologyId, input = {}) {
|
|
|
237
260
|
metadata: compact({ ...(input.metadata || {}), topology: definition }),
|
|
238
261
|
};
|
|
239
262
|
state.runs.push(record);
|
|
263
|
+
dirtyTopologyIds(state).add(record.id);
|
|
240
264
|
appendTopologyNode(run, record, topo.statusToNodeStatus(record.status));
|
|
241
265
|
(0, trust_audit_1.recordTrustAuditEvent)(run, {
|
|
242
266
|
kind: "topology.verdict",
|
|
@@ -271,7 +295,15 @@ function summarizeTopologies(run) {
|
|
|
271
295
|
const multi = run.multiAgent || {};
|
|
272
296
|
const fanins = multi.fanins || [];
|
|
273
297
|
const active = state.runs.map((record) => {
|
|
274
|
-
|
|
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 || ""));
|
|
275
307
|
const allFaninIds = topo.unique([...record.faninIds, ...inferredFanins.map((fanin) => fanin.id)]);
|
|
276
308
|
const blocked = inferredFanins.filter((fanin) => fanin.status === "blocked" || !fanin.verifierReady);
|
|
277
309
|
const ready = inferredFanins.some((fanin) => fanin.verifierReady);
|
|
@@ -290,7 +322,7 @@ function summarizeTopologies(run) {
|
|
|
290
322
|
missingEvidence,
|
|
291
323
|
conflicts: record.conflicts,
|
|
292
324
|
readiness: ready ? "fanin ready" : missingEvidence.length ? "missing evidence" : "awaiting worker output",
|
|
293
|
-
nextActions: ready ? [`
|
|
325
|
+
nextActions: ready ? [`cw candidate register ${run.id} --result-node <reducer-or-panel-result>`] : record.nextActions,
|
|
294
326
|
};
|
|
295
327
|
});
|
|
296
328
|
return {
|
|
@@ -299,7 +331,7 @@ function summarizeTopologies(run) {
|
|
|
299
331
|
runsByStatus: countBy(active, (record) => record.status),
|
|
300
332
|
officialTopologies: topo.listTopologyDefinitions().map((definition) => definition.id),
|
|
301
333
|
active,
|
|
302
|
-
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>`,
|
|
303
335
|
};
|
|
304
336
|
}
|
|
305
337
|
function buildTopologyGraph(run) {
|
|
@@ -307,7 +339,7 @@ function buildTopologyGraph(run) {
|
|
|
307
339
|
return topo.buildTopologyGraphFromRuns(run.id, state.runs, (id) => topologyRunPath(run, id));
|
|
308
340
|
}
|
|
309
341
|
function formatTopologyCounts(counts) {
|
|
310
|
-
const entries = Object.entries(counts).sort(([a], [b]) =>
|
|
342
|
+
const entries = Object.entries(counts).sort(([a], [b]) => (0, collate_1.stableCompare)(a, b));
|
|
311
343
|
if (!entries.length)
|
|
312
344
|
return "none";
|
|
313
345
|
return entries.map(([k, v]) => `${k}=${v}`).join(", ");
|
|
@@ -58,7 +58,9 @@ exports.TRUST_AUDIT_SCHEMA_VERSION = void 0;
|
|
|
58
58
|
exports.ensureTrustAudit = ensureTrustAudit;
|
|
59
59
|
exports.trustAuditGenesis = trustAuditGenesis;
|
|
60
60
|
exports.listTrustAuditEvents = listTrustAuditEvents;
|
|
61
|
+
exports.trustAuditHead = trustAuditHead;
|
|
61
62
|
exports.verifyTrustAudit = verifyTrustAudit;
|
|
63
|
+
exports.repairTrustAuditTornTail = repairTrustAuditTornTail;
|
|
62
64
|
exports.recordTrustAuditEvent = recordTrustAuditEvent;
|
|
63
65
|
exports.recordSandboxPathDecision = recordSandboxPathDecision;
|
|
64
66
|
exports.normalizeEvidence = normalizeEvidence;
|
|
@@ -105,6 +107,43 @@ function computeEventHash(event) {
|
|
|
105
107
|
void eventHash;
|
|
106
108
|
return (0, hash_1.sha256)((0, hash_1.eventHashInput)(rest));
|
|
107
109
|
}
|
|
110
|
+
function tailCachePathFor(eventLogPath) {
|
|
111
|
+
return path.join(path.dirname(eventLogPath), "tail-cache.json");
|
|
112
|
+
}
|
|
113
|
+
function readAuditTailCache(tailCachePath) {
|
|
114
|
+
if (!fs.existsSync(tailCachePath))
|
|
115
|
+
return undefined;
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(fs.readFileSync(tailCachePath, "utf8"));
|
|
118
|
+
if (parsed && parsed.schemaVersion === 1 && typeof parsed.logBytes === "number" && typeof parsed.count === "number" && typeof parsed.lastHash === "string") {
|
|
119
|
+
return parsed;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// Corrupt/unreadable cache -- fall back to the full parse below.
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
function writeAuditTailCache(tailCachePath, cache) {
|
|
128
|
+
try {
|
|
129
|
+
(0, fs_atomic_1.writeJson)(tailCachePath, cache);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// Best-effort: a failed cache write must never break the real append.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Deletes the tail cache so the NEXT append always re-derives ground
|
|
136
|
+
* truth from the real log, rather than trusting a byte-size coincidence.
|
|
137
|
+
* Called whenever something OTHER than a plain append changes the log's
|
|
138
|
+
* content (currently: repair). */
|
|
139
|
+
function invalidateAuditTailCache(eventLogPath) {
|
|
140
|
+
try {
|
|
141
|
+
fs.unlinkSync(tailCachePathFor(eventLogPath));
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// Nothing to invalidate (no cache existed yet) -- fine.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
108
147
|
/** Read events in FILE (append) order, tolerating corrupt lines — one
|
|
109
148
|
* bad line must not brick the whole audit read surface (it is counted,
|
|
110
149
|
* not thrown). The chain links append order, so this is the order
|
|
@@ -136,6 +175,21 @@ function readEventsRaw(eventLogPath) {
|
|
|
136
175
|
function listTrustAuditEvents(run) {
|
|
137
176
|
return readEventsRaw(ensureTrustAudit(run).eventLogPath);
|
|
138
177
|
}
|
|
178
|
+
/** The current head of a run's trust-audit chain: the hash the NEXT
|
|
179
|
+
* appended event will link from (genesis when the log is empty), plus
|
|
180
|
+
* the event count. Read-only projection over existing data. Capture it
|
|
181
|
+
* (e.g. right after a run, or at export time) and later hand it to
|
|
182
|
+
* `verifyTrustAudit`'s anchor / `cw audit verify --expect-head` to
|
|
183
|
+
* re-prove the log was not shortened since the capture. */
|
|
184
|
+
function trustAuditHead(run) {
|
|
185
|
+
const audit = ensureTrustAudit(run);
|
|
186
|
+
const events = readEventsRaw(audit.eventLogPath);
|
|
187
|
+
let head = trustAuditGenesis(run.id);
|
|
188
|
+
for (const event of events) {
|
|
189
|
+
head = event.eventHash !== undefined ? event.eventHash : computeEventHash(event);
|
|
190
|
+
}
|
|
191
|
+
return { eventCount: events.length, headHash: head };
|
|
192
|
+
}
|
|
139
193
|
/** Re-prove the run's trust-audit chain: prevEventHash linkage (append
|
|
140
194
|
* order) + per-event hash recompute. A corrupt line, an edited event,
|
|
141
195
|
* or a removed event flips verified=false. Legacy events without a
|
|
@@ -147,23 +201,32 @@ function listTrustAuditEvents(run) {
|
|
|
147
201
|
* era) or all-legacy (pre-chain). An unchained (eventHash-less) line
|
|
148
202
|
* mixed into an otherwise-chained log is a forgery attempt — dropping
|
|
149
203
|
* the hash to be waved through as "legacy" — so it fails with
|
|
150
|
-
* `trust-audit-unchained-event`, never silently accepted.
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
204
|
+
* `trust-audit-unchained-event`, never silently accepted.
|
|
205
|
+
*
|
|
206
|
+
* ANCHOR (optional): the walk alone cannot see tail truncation — see
|
|
207
|
+
* TrustAuditAnchor. With an anchor, the head-hash trail (genesis plus
|
|
208
|
+
* the hash after each event) must contain `expectHead`, and the log
|
|
209
|
+
* must reach `expectCount` events; a shortfall fails closed with
|
|
210
|
+
* `trust-audit-truncated`. Without an anchor, behavior is unchanged. */
|
|
211
|
+
/** Chain-walk core shared by `verifyTrustAudit` (reads from disk) and
|
|
212
|
+
* `repairTrustAuditTornTail` (re-checks an in-memory candidate result
|
|
213
|
+
* BEFORE ever writing it to disk). Pure — no fs. */
|
|
214
|
+
function verifyEventsChain(runId, events, corruptLines, anchor) {
|
|
154
215
|
const checks = [];
|
|
155
216
|
let verified = corruptLines === 0;
|
|
156
217
|
if (corruptLines > 0)
|
|
157
218
|
checks.push({ name: "parse", pass: false, code: "trust-audit-corrupt-line" });
|
|
158
219
|
let chained = 0;
|
|
159
220
|
let unchained = 0;
|
|
160
|
-
let expectedPrev = trustAuditGenesis(
|
|
221
|
+
let expectedPrev = trustAuditGenesis(runId);
|
|
222
|
+
const headTrail = new Set([expectedPrev]);
|
|
161
223
|
for (let i = 0; i < events.length; i++) {
|
|
162
224
|
const event = events[i];
|
|
163
225
|
const recomputed = computeEventHash(event);
|
|
164
226
|
if (event.eventHash === undefined) {
|
|
165
227
|
unchained += 1;
|
|
166
228
|
expectedPrev = recomputed; // advance the chain over legacy events
|
|
229
|
+
headTrail.add(expectedPrev);
|
|
167
230
|
continue;
|
|
168
231
|
}
|
|
169
232
|
chained += 1;
|
|
@@ -171,19 +234,126 @@ function verifyTrustAudit(run) {
|
|
|
171
234
|
verified = false;
|
|
172
235
|
checks.push({ name: `event-hash[${i}]`, pass: false, code: "trust-audit-digest-mismatch" });
|
|
173
236
|
}
|
|
174
|
-
|
|
237
|
+
// No `undefined` skip here: the writer ALWAYS sets prevEventHash (the
|
|
238
|
+
// first event gets the genesis hash), so a chained event without it is
|
|
239
|
+
// a forgery — dropping the field and re-making eventHash must NOT let a
|
|
240
|
+
// cut or re-ordered chain verify green. Fail closed on the mismatch.
|
|
241
|
+
if (event.prevEventHash !== expectedPrev) {
|
|
175
242
|
verified = false;
|
|
176
243
|
checks.push({ name: `chain-link[${i}]`, pass: false, code: "trust-audit-chain-broken" });
|
|
177
244
|
}
|
|
178
245
|
expectedPrev = event.eventHash;
|
|
246
|
+
headTrail.add(expectedPrev);
|
|
179
247
|
}
|
|
180
248
|
// Era rule: a log with ANY chained event must have EVERY event chained.
|
|
181
249
|
if (chained > 0 && unchained > 0) {
|
|
182
250
|
verified = false;
|
|
183
251
|
checks.push({ name: "unchained-events", pass: false, code: "trust-audit-unchained-event" });
|
|
184
252
|
}
|
|
253
|
+
// Anchor rule: the captured head must still be ON the chain, and the log
|
|
254
|
+
// must be at least as long as it was at capture time. A truncated-then-
|
|
255
|
+
// appended log fails the head check (new events link from an earlier
|
|
256
|
+
// point, so the old head is no longer in the trail).
|
|
257
|
+
if (anchor) {
|
|
258
|
+
if (anchor.expectCount !== undefined && events.length < anchor.expectCount) {
|
|
259
|
+
verified = false;
|
|
260
|
+
checks.push({ name: "anchor-count", pass: false, code: "trust-audit-truncated" });
|
|
261
|
+
}
|
|
262
|
+
if (anchor.expectHead !== undefined && !headTrail.has(anchor.expectHead)) {
|
|
263
|
+
verified = false;
|
|
264
|
+
checks.push({ name: "anchor-head", pass: false, code: "trust-audit-truncated" });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
185
267
|
return { present: events.length > 0, verified, eventCount: events.length, chained, unchained, corruptLines, checks };
|
|
186
268
|
}
|
|
269
|
+
function verifyTrustAudit(run, anchor) {
|
|
270
|
+
const audit = ensureTrustAudit(run);
|
|
271
|
+
const { events, corruptLines } = readEventsRawCounted(audit.eventLogPath);
|
|
272
|
+
return verifyEventsChain(run.id, events, corruptLines, anchor);
|
|
273
|
+
}
|
|
274
|
+
/** Repairs a torn TRAILING write in the audit event log — the ONE
|
|
275
|
+
* corruption shape a crash mid-append can produce (`durableAppendFileSync`
|
|
276
|
+
* only ever adds bytes at the current end of file, so an interruption can
|
|
277
|
+
* only ever leave the LAST append incomplete; it can never touch earlier,
|
|
278
|
+
* already-flushed lines). Every line is always actually parsed — the
|
|
279
|
+
* file's trailing-newline byte says only whether the LAST WRITE
|
|
280
|
+
* completed, nothing about whether any line parses: if a run is RESUMED
|
|
281
|
+
* after a torn write (another event appended right after the garbled
|
|
282
|
+
* remnant, with no separating newline of its own), the two merge into one
|
|
283
|
+
* unparseable line and the file ends in a newline again, even though
|
|
284
|
+
* content is still corrupt. Refuses (`outcome: "refused"`) rather than
|
|
285
|
+
* touching anything when:
|
|
286
|
+
* - more than one line is unparseable, or the sole bad line is NOT the
|
|
287
|
+
* last one (not a shape a crash can produce — treated as possible
|
|
288
|
+
* tampering, not auto-repaired);
|
|
289
|
+
* - removing the bad trailing line still leaves an unverifiable chain,
|
|
290
|
+
* OR (when `anchor` is given) the repaired chain doesn't reach
|
|
291
|
+
* `anchor.expectCount`/contain `anchor.expectHead` — an anchor
|
|
292
|
+
* captured before the corruption is the ONLY way to catch an attacker
|
|
293
|
+
* deleting real historical events and leaving a torn-looking fragment
|
|
294
|
+
* behind (an empty/short chain otherwise "verifies" trivially — the
|
|
295
|
+
* same documented blind spot `verifyTrustAudit` itself has without an
|
|
296
|
+
* anchor; this function must never launder that shape into a
|
|
297
|
+
* confidently-"repaired" empty log).
|
|
298
|
+
* `write: false` (default) reports what WOULD happen without touching
|
|
299
|
+
* disk, matching this codebase's `cw state check [--write]` convention.
|
|
300
|
+
* Held under the SAME `withFileLock` as `recordTrustAuditEvent` (below),
|
|
301
|
+
* so a repair can never interleave with a live append. */
|
|
302
|
+
function repairTrustAuditTornTail(run, options = {}) {
|
|
303
|
+
const audit = ensureTrustAudit(run);
|
|
304
|
+
return (0, fs_atomic_1.withFileLock)(audit.eventLogPath, () => {
|
|
305
|
+
const raw = fs.readFileSync(audit.eventLogPath, "utf8");
|
|
306
|
+
const lines = raw.split("\n").filter((line) => line.trim() !== "");
|
|
307
|
+
const badIndexes = [];
|
|
308
|
+
const events = [];
|
|
309
|
+
lines.forEach((line, i) => {
|
|
310
|
+
try {
|
|
311
|
+
events.push(JSON.parse(line));
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
badIndexes.push(i);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
if (badIndexes.length === 0) {
|
|
318
|
+
return { outcome: "clean", reason: "every line parses — no torn trailing write to repair" };
|
|
319
|
+
}
|
|
320
|
+
if (badIndexes.length > 1 || badIndexes[0] !== lines.length - 1) {
|
|
321
|
+
return {
|
|
322
|
+
outcome: "refused",
|
|
323
|
+
reason: "corruption is not confined to exactly the trailing line — this is not a shape a crash mid-append can produce and will not be auto-repaired (looks like tampering)",
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
// `events` already holds every line EXCEPT the one bad trailing line
|
|
327
|
+
// (JSON.parse threw for it, so nothing was pushed) — exactly the "good"
|
|
328
|
+
// set, in file order.
|
|
329
|
+
const recheck = verifyEventsChain(run.id, events, 0, options.anchor);
|
|
330
|
+
if (!recheck.verified) {
|
|
331
|
+
return {
|
|
332
|
+
outcome: "refused",
|
|
333
|
+
reason: options.anchor
|
|
334
|
+
? "removing the torn trailing write still doesn't reach the given --expect-head/--expect-count anchor — refusing to repair (this looks like deleted history, not a crash)"
|
|
335
|
+
: "removing the torn trailing write still leaves an unverifiable chain — refusing to repair (this looks like tampering, not a crash)",
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
const removedBytes = Buffer.byteLength(lines[lines.length - 1], "utf8");
|
|
339
|
+
const repairedContent = events.length > 0 ? `${lines.slice(0, -1).join("\n")}\n` : "";
|
|
340
|
+
if (options.write) {
|
|
341
|
+
(0, fs_atomic_1.writeTextDurable)(audit.eventLogPath, repairedContent, { durable: true });
|
|
342
|
+
// The log's bytes just changed out from under the append tail cache
|
|
343
|
+
// (perf cycle P1-2) -- invalidate rather than rely on the size check
|
|
344
|
+
// alone catching every case.
|
|
345
|
+
invalidateAuditTailCache(audit.eventLogPath);
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
outcome: "repaired",
|
|
349
|
+
reason: options.write
|
|
350
|
+
? `removed a torn trailing write (${removedBytes} bytes) and restored a verified chain of ${events.length} event(s)`
|
|
351
|
+
: `would remove a torn trailing write (${removedBytes} bytes) and restore a verified chain of ${events.length} event(s) — pass --write to apply`,
|
|
352
|
+
removedLines: 1,
|
|
353
|
+
removedBytes,
|
|
354
|
+
};
|
|
355
|
+
});
|
|
356
|
+
}
|
|
187
357
|
function unique(values) {
|
|
188
358
|
return Array.from(new Set(values.filter(Boolean)));
|
|
189
359
|
}
|
|
@@ -210,9 +380,14 @@ function scrubMetadata(value) {
|
|
|
210
380
|
function compact(value) {
|
|
211
381
|
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
212
382
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
383
|
+
/** `count` must be the number of PRIOR events, read under the SAME lock
|
|
384
|
+
* the append itself happens under (see `recordTrustAuditEvent`) — an
|
|
385
|
+
* earlier version read this count separately, outside any lock, so two
|
|
386
|
+
* concurrent writers could mint the SAME id for two different events
|
|
387
|
+
* (the hash chain still forked-proof, but the id — referenced elsewhere
|
|
388
|
+
* as `auditEventIds`/`parentEventIds` for provenance — was not unique). */
|
|
389
|
+
function createEventId(kind, count) {
|
|
390
|
+
return `audit-${(0, fs_atomic_1.safeFileName)(kind)}-${String(count + 1).padStart(4, "0")}`;
|
|
216
391
|
}
|
|
217
392
|
/** Correlation-id keys copied verbatim (and no others) — byte-exact list/
|
|
218
393
|
* order to the old build's CORRELATION_ID_FIELDS. */
|
|
@@ -255,11 +430,20 @@ function indexCorrelationIds(event) {
|
|
|
255
430
|
}
|
|
256
431
|
return picked;
|
|
257
432
|
}
|
|
433
|
+
/** Read-modify-append: computes `prevEventHash` from the CURRENT last event
|
|
434
|
+
* and appends. Held under `withFileLock` (like every other read-modify-
|
|
435
|
+
* write in this codebase) so two processes recording events for the same
|
|
436
|
+
* run at once can never both read the same tail and compute the same
|
|
437
|
+
* `prevEventHash` — that would fork the hash chain, and a forked chain
|
|
438
|
+
* fails `verifyTrustAudit` for good, with no repair for THAT shape (unlike
|
|
439
|
+
* a torn trailing write, a fork is not confined to the last line). */
|
|
258
440
|
function recordTrustAuditEvent(run, input) {
|
|
259
441
|
const audit = ensureTrustAudit(run);
|
|
442
|
+
// `id` is NOT set here — it depends on the prior event count, which (like
|
|
443
|
+
// prevEventHash) must be read under the lock below, or two concurrent
|
|
444
|
+
// writers could mint the same id for two different events.
|
|
260
445
|
const event = compact({
|
|
261
446
|
schemaVersion: exports.TRUST_AUDIT_SCHEMA_VERSION,
|
|
262
|
-
id: createEventId(run, input.kind),
|
|
263
447
|
createdAt: new Date().toISOString(),
|
|
264
448
|
runId: run.id,
|
|
265
449
|
kind: input.kind,
|
|
@@ -283,11 +467,35 @@ function recordTrustAuditEvent(run, input) {
|
|
|
283
467
|
parentEventIds: unique(input.parentEventIds || []).sort(),
|
|
284
468
|
metadata: scrubMetadata(input.metadata || {}),
|
|
285
469
|
});
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
470
|
+
return (0, fs_atomic_1.withFileLock)(audit.eventLogPath, () => {
|
|
471
|
+
// The prior event count and last-event hash are the ONLY two things
|
|
472
|
+
// this append needs from the existing log. A tail cache (keyed on the
|
|
473
|
+
// log's own byte size) serves both without a full parse when nothing
|
|
474
|
+
// else has touched the log since it was written; any size mismatch
|
|
475
|
+
// (a repair, a torn write, this being the very first append) falls
|
|
476
|
+
// back to the full parse, same as before this cache existed.
|
|
477
|
+
const tailCachePath = tailCachePathFor(audit.eventLogPath);
|
|
478
|
+
const currentBytes = fs.existsSync(audit.eventLogPath) ? fs.statSync(audit.eventLogPath).size : 0;
|
|
479
|
+
const cached = readAuditTailCache(tailCachePath);
|
|
480
|
+
let count;
|
|
481
|
+
let prevHash;
|
|
482
|
+
if (cached && cached.logBytes === currentBytes) {
|
|
483
|
+
count = cached.count;
|
|
484
|
+
prevHash = cached.lastHash;
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
const prior = readEventsRaw(audit.eventLogPath);
|
|
488
|
+
count = prior.length;
|
|
489
|
+
prevHash = prior.length ? prior[prior.length - 1].eventHash || computeEventHash(prior[prior.length - 1]) : trustAuditGenesis(run.id);
|
|
490
|
+
}
|
|
491
|
+
event.id = createEventId(input.kind, count);
|
|
492
|
+
event.prevEventHash = prevHash;
|
|
493
|
+
event.eventHash = computeEventHash(event);
|
|
494
|
+
const line = `${JSON.stringify(event)}\n`;
|
|
495
|
+
(0, fs_atomic_1.durableAppendFileSync)(audit.eventLogPath, line);
|
|
496
|
+
writeAuditTailCache(tailCachePath, { schemaVersion: 1, logBytes: currentBytes + Buffer.byteLength(line, "utf8"), count: count + 1, lastHash: event.eventHash });
|
|
497
|
+
return event;
|
|
498
|
+
});
|
|
291
499
|
}
|
|
292
500
|
function recordSandboxPathDecision(run, input) {
|
|
293
501
|
return recordTrustAuditEvent(run, {
|
|
@@ -348,11 +556,32 @@ function countBy(values, key) {
|
|
|
348
556
|
* here — the old build's extra workers/candidates/commits/multiAgent/
|
|
349
557
|
* blackboard rollups are milestone 9's own summarizeMultiAgent/
|
|
350
558
|
* candidate-scoring-io/coordinator-io surfaces, not duplicated here. */
|
|
559
|
+
/** Groups events by a key field once, so each row below is an O(1) Map
|
|
560
|
+
* lookup instead of an O(events) `.filter()` re-scan per id -- found
|
|
561
|
+
* alongside the id.find() version of this same shape while pinning perf
|
|
562
|
+
* cycle P1-1's review-fix regression test (O(ids x events) otherwise). */
|
|
563
|
+
function groupEventsBy(events, key) {
|
|
564
|
+
const groups = new Map();
|
|
565
|
+
for (const event of events) {
|
|
566
|
+
const k = key(event);
|
|
567
|
+
if (!k)
|
|
568
|
+
continue;
|
|
569
|
+
const list = groups.get(k);
|
|
570
|
+
if (list)
|
|
571
|
+
list.push(event);
|
|
572
|
+
else
|
|
573
|
+
groups.set(k, [event]);
|
|
574
|
+
}
|
|
575
|
+
return groups;
|
|
576
|
+
}
|
|
351
577
|
function workerRows(events, run) {
|
|
352
|
-
const
|
|
578
|
+
const workers = run.workers || [];
|
|
579
|
+
const workersById = new Map(workers.map((w) => [w.id, w]));
|
|
580
|
+
const eventsByWorkerId = groupEventsBy(events, (e) => e.workerId);
|
|
581
|
+
const workerIds = unique([...workers.map((w) => w.id), ...events.map((e) => e.workerId || "")]).sort();
|
|
353
582
|
return workerIds.filter(Boolean).map((workerId) => {
|
|
354
|
-
const worker =
|
|
355
|
-
const scoped =
|
|
583
|
+
const worker = workersById.get(workerId);
|
|
584
|
+
const scoped = eventsByWorkerId.get(workerId) || [];
|
|
356
585
|
return {
|
|
357
586
|
workerId,
|
|
358
587
|
taskId: worker?.taskId || scoped.find((e) => e.taskId)?.taskId,
|
|
@@ -366,11 +595,21 @@ function workerRows(events, run) {
|
|
|
366
595
|
function candidateRows(events, run) {
|
|
367
596
|
const cands = run.candidates || [];
|
|
368
597
|
const selectionsAll = run.candidateSelections || [];
|
|
598
|
+
const candsById = new Map(cands.map((c) => [c.id, c]));
|
|
599
|
+
const selectionsByCandidateId = new Map();
|
|
600
|
+
for (const selection of selectionsAll) {
|
|
601
|
+
const list = selectionsByCandidateId.get(selection.candidateId);
|
|
602
|
+
if (list)
|
|
603
|
+
list.push(selection);
|
|
604
|
+
else
|
|
605
|
+
selectionsByCandidateId.set(selection.candidateId, [selection]);
|
|
606
|
+
}
|
|
607
|
+
const eventsByCandidateId = groupEventsBy(events, (e) => e.candidateId);
|
|
369
608
|
const ids = unique([...cands.map((c) => c.id), ...events.map((e) => e.candidateId || "")]).sort();
|
|
370
609
|
return ids.filter(Boolean).map((candidateId) => {
|
|
371
|
-
const candidate =
|
|
372
|
-
const selections =
|
|
373
|
-
const scoped =
|
|
610
|
+
const candidate = candsById.get(candidateId);
|
|
611
|
+
const selections = selectionsByCandidateId.get(candidateId) || [];
|
|
612
|
+
const scoped = eventsByCandidateId.get(candidateId) || [];
|
|
374
613
|
return {
|
|
375
614
|
candidateId,
|
|
376
615
|
scoreIds: unique([...(candidate?.scores || []), ...scoped.map((e) => e.scoreId || "")]).filter(Boolean).sort(),
|
|
@@ -380,9 +619,11 @@ function candidateRows(events, run) {
|
|
|
380
619
|
});
|
|
381
620
|
}
|
|
382
621
|
function commitRows(events, run) {
|
|
383
|
-
const
|
|
622
|
+
const commits = run.commits || [];
|
|
623
|
+
const commitsById = new Map(commits.map((c) => [c.id, c]));
|
|
624
|
+
const ids = unique([...commits.map((c) => c.id), ...events.map((e) => e.commitId || "")]).sort();
|
|
384
625
|
return ids.filter(Boolean).map((commitId) => {
|
|
385
|
-
const commit =
|
|
626
|
+
const commit = commitsById.get(commitId);
|
|
386
627
|
return {
|
|
387
628
|
commitId,
|
|
388
629
|
verifierGated: Boolean(commit?.verifierGated),
|
|
@@ -209,7 +209,7 @@ function summarizeMultiAgentTrust(run) {
|
|
|
209
209
|
judgeRationales: byKind("judge.rationale"),
|
|
210
210
|
panelDecisions: byKind("judge.panel-decision"),
|
|
211
211
|
policyViolations,
|
|
212
|
-
nextAction: policyViolations.length ? `
|
|
212
|
+
nextAction: policyViolations.length ? `cw audit policy ${run.id}` : `cw audit multi-agent ${run.id} --json`,
|
|
213
213
|
};
|
|
214
214
|
}
|
|
215
215
|
function hasAcceptedJudgeRationale(run, input = {}) {
|