cool-workflow 0.2.4 → 0.2.6
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 +204 -48
- package/apps/architecture-review/app.json +1 -1
- package/apps/architecture-review-fast/app.json +1 -1
- package/apps/end-to-end-golden-path/app.json +1 -1
- package/apps/pr-review-fix-ci/app.json +1 -1
- package/apps/release-cut/app.json +1 -1
- package/apps/research-synthesis/app.json +1 -1
- package/dist/cli/dispatch.js +32 -4
- package/dist/cli/entry.js +11 -19
- package/dist/cli/global-flags.js +113 -0
- package/dist/cli/parseargv.js +7 -1
- package/dist/core/capability-data.js +337 -8
- package/dist/core/format/help.js +73 -3
- package/dist/core/format/recovery-hint.js +32 -0
- package/dist/core/multi-agent/collaboration.js +35 -6
- package/dist/core/multi-agent/runtime.js +7 -0
- package/dist/core/multi-agent/trust-policy.js +7 -1
- package/dist/core/pipeline/contract.js +7 -0
- package/dist/core/pipeline/error-feedback.js +2 -2
- package/dist/core/state/run-paths.js +3 -30
- package/dist/core/trust/evidence-grounding.js +13 -1
- package/dist/core/util/cli-args.js +22 -0
- package/dist/core/version.js +1 -1
- package/dist/mcp/dispatch.js +22 -2
- package/dist/mcp/server.js +215 -36
- package/dist/mcp/tool-process.js +181 -0
- package/dist/mcp-server.js +28 -1
- package/dist/shell/agent-config.js +11 -2
- package/dist/shell/audit-cli.js +1 -1
- package/dist/shell/commit.js +8 -2
- package/dist/shell/coordinator-io.js +73 -1
- package/dist/shell/drive.js +195 -128
- package/dist/shell/error-feedback-io.js +6 -0
- package/dist/shell/execution-backend/agent.js +201 -26
- package/dist/shell/execution-backend/container.js +44 -10
- package/dist/shell/execution-backend/local.js +33 -0
- package/dist/shell/fs-atomic.js +96 -12
- package/dist/shell/ledger-cli.js +9 -2
- package/dist/shell/metrics-cli.js +1 -1
- package/dist/shell/multi-agent-cli.js +9 -2
- package/dist/shell/multi-agent-operator-ux.js +1 -1
- package/dist/shell/observability.js +7 -4
- package/dist/shell/onramp.js +48 -5
- package/dist/shell/operator-ux.js +1 -1
- package/dist/shell/perf-trace.js +136 -0
- package/dist/shell/pipeline.js +3 -2
- package/dist/shell/reclamation-io.js +10 -9
- package/dist/shell/report-view-cli.js +2 -0
- package/dist/shell/run-export-cli.js +5 -2
- package/dist/shell/run-export.js +293 -31
- package/dist/shell/run-registry-io.js +8 -0
- package/dist/shell/run-store.js +183 -3
- package/dist/shell/scheduler-io.js +101 -10
- package/dist/shell/state-explosion-cli.js +4 -1
- package/dist/shell/telemetry-demo.js +1 -1
- package/dist/shell/telemetry-ledger-io.js +36 -24
- package/dist/shell/trust-audit.js +178 -36
- package/dist/shell/workbench-host.js +121 -10
- package/dist/shell/workbench.js +83 -6
- package/dist/shell/worker-isolation.js +1 -1
- package/dist/wiring/capability-table/basics.js +5 -0
- package/dist/wiring/capability-table/exec-backend.js +40 -22
- package/dist/wiring/capability-table/parity.js +10 -5
- package/dist/wiring/capability-table/pipeline.js +32 -0
- package/dist/wiring/capability-table/registry-core.js +26 -3
- package/dist/wiring/capability-table/reporting.js +7 -1
- package/dist/wiring/capability-table/scheduling-registry.js +8 -2
- package/dist/wiring/capability-table/trust-ledger.js +54 -12
- package/docs/agent-delegation-drive.7.md +17 -0
- package/docs/cli-mcp-parity.7.md +46 -5
- 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 +27 -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 +53 -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 +12 -0
- package/docs/routine.7.md +22 -0
- package/docs/run-registry-control-plane.7.md +37 -9
- package/docs/run-retention-reclamation.7.md +12 -4
- 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 +72 -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 +6 -3
- package/scripts/agents/agent-adapter-core.js +26 -0
- package/scripts/agents/claude-p-agent.js +4 -1
- package/scripts/agents/codex-agent.js +4 -0
- package/scripts/agents/cw-attest-wrap.js +1 -1
- package/scripts/agents/gemini-agent.js +4 -0
- package/scripts/agents/opencode-agent.js +5 -0
- package/scripts/block-unapproved-tag.js +160 -0
- package/scripts/canonical-apps.js +4 -4
- package/scripts/children/batch-delegate-child.js +72 -4
- package/scripts/children/http-batch-delegate-child.js +132 -0
- package/scripts/children/http-delegate-child.js +8 -0
- package/scripts/dogfood-release.js +2 -2
- package/scripts/fake-date-for-reproduction.js +1 -1
- package/scripts/gen-manifests.js +32 -61
- package/scripts/golden-path.js +4 -4
- package/scripts/lang-policy-check.js +82 -0
- package/scripts/parity-check.js +42 -23
- package/scripts/purity-baseline.json +0 -3
- package/scripts/purity-gate.js +16 -3
- package/scripts/release-check.js +2 -1
- package/scripts/release-flow.js +169 -41
- package/scripts/release-gate.js +208 -0
- package/scripts/release-oneclick.js +38 -7
- package/scripts/release-tags.js +47 -0
- package/scripts/schema-version-inventory.json +31 -0
- package/scripts/validate-run-state-schema.js +95 -4
- package/scripts/verdict-keygen.js +2 -2
- package/scripts/verify-bump-reproduction.js +193 -0
- package/scripts/verify-release-verdict.js +139 -0
- package/scripts/verify-verdict-signature.js +1 -1
- package/ui/workbench/app.css +50 -10
- package/ui/workbench/app.js +269 -34
- package/ui/workbench/index.html +4 -2
- package/ui/workbench/inspection.js +51 -0
- package/ui/workbench/navigation.js +44 -0
- package/docs/agent-framework.md +0 -177
- package/docs/designs/handoff-ledger.md +0 -145
- package/docs/dogfood/resume-drive-real-agent-2026-06-14.md +0 -40
- package/docs/launch/launch-kit.md +0 -195
- package/docs/launch/pre-launch-checklist.md +0 -53
- package/docs/readme-v0.1.87-full.md +0 -301
- package/docs/routines.md +0 -101
- package/docs/scheduled-tasks.md +0 -80
- package/scripts/agents/claude-p-agent.sh +0 -9
- package/scripts/block-unapproved-tag.sh +0 -75
- package/scripts/release-gate.sh +0 -94
- package/scripts/verify-bump-reproduction.sh +0 -148
- package/scripts/verify-container-selfref.js +0 -64
|
@@ -62,6 +62,7 @@ exports.trustAuditHead = trustAuditHead;
|
|
|
62
62
|
exports.verifyTrustAudit = verifyTrustAudit;
|
|
63
63
|
exports.repairTrustAuditTornTail = repairTrustAuditTornTail;
|
|
64
64
|
exports.recordTrustAuditEvent = recordTrustAuditEvent;
|
|
65
|
+
exports.withTrustAuditBatch = withTrustAuditBatch;
|
|
65
66
|
exports.recordSandboxPathDecision = recordSandboxPathDecision;
|
|
66
67
|
exports.normalizeEvidence = normalizeEvidence;
|
|
67
68
|
exports.writeTrustAuditIndexPlaceholder = writeTrustAuditIndexPlaceholder;
|
|
@@ -85,8 +86,15 @@ function ensureTrustAudit(run) {
|
|
|
85
86
|
run.paths.auditDir = dir;
|
|
86
87
|
const audit = { schemaVersion: 1, ...trustAuditPaths(run) };
|
|
87
88
|
run.audit = audit;
|
|
89
|
+
// Create-if-missing must NEVER truncate: this runs OUTSIDE the append
|
|
90
|
+
// lock, so between an existsSync(false) here and a plain create, another
|
|
91
|
+
// process may have already created the log AND appended real events —
|
|
92
|
+
// a truncating create then silently deletes them (one event lost, chain
|
|
93
|
+
// still verifies "clean" from genesis; seen once on CI under coverage
|
|
94
|
+
// I/O). Flag "a" is O_CREAT without O_TRUNC: it makes the file when
|
|
95
|
+
// missing and adds zero bytes when not, with no exists-check window.
|
|
88
96
|
if (!fs.existsSync(audit.eventLogPath))
|
|
89
|
-
fs.writeFileSync(audit.eventLogPath, "", "utf8");
|
|
97
|
+
fs.writeFileSync(audit.eventLogPath, "", { encoding: "utf8", flag: "a" });
|
|
90
98
|
return audit;
|
|
91
99
|
}
|
|
92
100
|
/** Genesis prevHash for a run's chain (no prior event). Exported so
|
|
@@ -107,6 +115,7 @@ function computeEventHash(event) {
|
|
|
107
115
|
void eventHash;
|
|
108
116
|
return (0, hash_1.sha256)((0, hash_1.eventHashInput)(rest));
|
|
109
117
|
}
|
|
118
|
+
const ACTIVE_AUDIT_BATCHES = new Map();
|
|
110
119
|
function tailCachePathFor(eventLogPath) {
|
|
111
120
|
return path.join(path.dirname(eventLogPath), "tail-cache.json");
|
|
112
121
|
}
|
|
@@ -467,34 +476,90 @@ function recordTrustAuditEvent(run, input) {
|
|
|
467
476
|
parentEventIds: unique(input.parentEventIds || []).sort(),
|
|
468
477
|
metadata: scrubMetadata(input.metadata || {}),
|
|
469
478
|
});
|
|
479
|
+
const batch = ACTIVE_AUDIT_BATCHES.get(path.resolve(audit.eventLogPath));
|
|
480
|
+
if (batch)
|
|
481
|
+
return appendTrustAuditEvent(run, input, event, batch);
|
|
470
482
|
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
483
|
const currentBytes = fs.existsSync(audit.eventLogPath) ? fs.statSync(audit.eventLogPath).size : 0;
|
|
479
|
-
const cached = readAuditTailCache(
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
484
|
+
const cached = readAuditTailCache(tailCachePathFor(audit.eventLogPath));
|
|
485
|
+
const prior = cached && cached.logBytes === currentBytes ? undefined : readEventsRaw(audit.eventLogPath);
|
|
486
|
+
const state = {
|
|
487
|
+
audit,
|
|
488
|
+
currentBytes,
|
|
489
|
+
count: cached && cached.logBytes === currentBytes ? cached.count : prior.length,
|
|
490
|
+
lastHash: cached && cached.logBytes === currentBytes ? cached.lastHash : (prior.length ? prior[prior.length - 1].eventHash || computeEventHash(prior[prior.length - 1]) : trustAuditGenesis(run.id)),
|
|
491
|
+
lines: [],
|
|
492
|
+
};
|
|
493
|
+
const result = appendTrustAuditEvent(run, input, event, state);
|
|
494
|
+
flushTrustAuditBatch(state);
|
|
495
|
+
return result;
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
function appendTrustAuditEvent(run, input, event, batch) {
|
|
499
|
+
// The prior event count and last-event hash are the ONLY two things
|
|
500
|
+
// this append needs from the existing log. A tail cache (keyed on the
|
|
501
|
+
// log's own byte size) serves both without a full parse when nothing
|
|
502
|
+
// else has touched the log since it was written; any size mismatch
|
|
503
|
+
// (a repair, a torn write, this being the very first append) falls
|
|
504
|
+
// back to the full parse, same as before this cache existed.
|
|
505
|
+
event.id = createEventId(input.kind, batch.count);
|
|
506
|
+
event.prevEventHash = batch.lastHash;
|
|
507
|
+
event.eventHash = computeEventHash(event);
|
|
508
|
+
// Newline-boundary safety (fail-closed). `durableAppendFileSync` only ever
|
|
509
|
+
// ADDS bytes at the end of file and never writes a separator of its own. A
|
|
510
|
+
// completed append always leaves the log ending in "\n"; if the last byte
|
|
511
|
+
// is NOT "\n", the previous append was torn by a crash (its bytes were
|
|
512
|
+
// never a confirmed event — the append never returned). Writing this new,
|
|
513
|
+
// already-cross-linked event straight onto that partial byte-run would
|
|
514
|
+
// MERGE the two into one line that no longer parses — losing THIS event and
|
|
515
|
+
// poisoning the forward chain (the next append's prevEventHash would point
|
|
516
|
+
// into an unparseable blob), with no repair for that shape. So put the new
|
|
517
|
+
// event on its own clean line: prepend a "\n" when the log does not already
|
|
518
|
+
// end in one, confining any crash artifact to its own now-orphaned line.
|
|
519
|
+
// (Reads only the last byte, so the O(1) tail-cache path is preserved.)
|
|
520
|
+
// An empty log (currentBytes === 0, e.g. the first append) has no tail to
|
|
521
|
+
// merge with, so it never needs a leading newline.
|
|
522
|
+
const leadingNewline = batch.currentBytes > 0 && batch.lines.length === 0 && !(0, fs_atomic_1.logEndsWithNewline)(batch.audit.eventLogPath, batch.currentBytes) ? "\n" : "";
|
|
523
|
+
const line = `${leadingNewline}${JSON.stringify(event)}\n`;
|
|
524
|
+
batch.lines.push(line);
|
|
525
|
+
batch.currentBytes += Buffer.byteLength(line, "utf8");
|
|
526
|
+
batch.count += 1;
|
|
527
|
+
batch.lastHash = event.eventHash;
|
|
528
|
+
return event;
|
|
529
|
+
}
|
|
530
|
+
function flushTrustAuditBatch(batch) {
|
|
531
|
+
if (!batch.lines.length)
|
|
532
|
+
return;
|
|
533
|
+
(0, fs_atomic_1.durableAppendFileSync)(batch.audit.eventLogPath, batch.lines.join(""));
|
|
534
|
+
writeAuditTailCache(tailCachePathFor(batch.audit.eventLogPath), { schemaVersion: 1, logBytes: batch.currentBytes, count: batch.count, lastHash: batch.lastHash });
|
|
535
|
+
}
|
|
536
|
+
/** Run a short, synchronous mutation group under one audit lock and append its
|
|
537
|
+
* exact NDJSON lines with one durable write before the caller checkpoints. */
|
|
538
|
+
function withTrustAuditBatch(run, fn) {
|
|
539
|
+
const audit = ensureTrustAudit(run);
|
|
540
|
+
const key = path.resolve(audit.eventLogPath);
|
|
541
|
+
if (ACTIVE_AUDIT_BATCHES.has(key))
|
|
542
|
+
return fn();
|
|
543
|
+
return (0, fs_atomic_1.withFileLock)(audit.eventLogPath, () => {
|
|
544
|
+
const currentBytes = fs.existsSync(audit.eventLogPath) ? fs.statSync(audit.eventLogPath).size : 0;
|
|
545
|
+
const cached = readAuditTailCache(tailCachePathFor(audit.eventLogPath));
|
|
546
|
+
const prior = cached && cached.logBytes === currentBytes ? undefined : readEventsRaw(audit.eventLogPath);
|
|
547
|
+
const batch = {
|
|
548
|
+
audit,
|
|
549
|
+
currentBytes,
|
|
550
|
+
count: cached && cached.logBytes === currentBytes ? cached.count : prior.length,
|
|
551
|
+
lastHash: cached && cached.logBytes === currentBytes ? cached.lastHash : (prior.length ? prior[prior.length - 1].eventHash || computeEventHash(prior[prior.length - 1]) : trustAuditGenesis(run.id)),
|
|
552
|
+
lines: [],
|
|
553
|
+
};
|
|
554
|
+
ACTIVE_AUDIT_BATCHES.set(key, batch);
|
|
555
|
+
try {
|
|
556
|
+
const result = fn();
|
|
557
|
+
flushTrustAuditBatch(batch);
|
|
558
|
+
return result;
|
|
485
559
|
}
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
count = prior.length;
|
|
489
|
-
prevHash = prior.length ? prior[prior.length - 1].eventHash || computeEventHash(prior[prior.length - 1]) : trustAuditGenesis(run.id);
|
|
560
|
+
finally {
|
|
561
|
+
ACTIVE_AUDIT_BATCHES.delete(key);
|
|
490
562
|
}
|
|
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
563
|
});
|
|
499
564
|
}
|
|
500
565
|
function recordSandboxPathDecision(run, input) {
|
|
@@ -634,9 +699,68 @@ function commitRows(events, run) {
|
|
|
634
699
|
};
|
|
635
700
|
});
|
|
636
701
|
}
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
702
|
+
// Perf cycle P1 (read side): every read command (`cw status`, each
|
|
703
|
+
// `writeReport` call, multi-agent status) called `summarizeTrustAudit`,
|
|
704
|
+
// which read+parsed the log via `readEventsRaw`, THEN called
|
|
705
|
+
// `verifyTrustAudit(run)`, which read+parsed the SAME log AGAIN and
|
|
706
|
+
// recomputed a sha256 for every event -- two full passes for one call.
|
|
707
|
+
// It then durably (fsync) rewrote summary.json AND index.json on EVERY
|
|
708
|
+
// call, even when nothing about the run or the log had changed since the
|
|
709
|
+
// last call (a live audit reaches ~50k events, so `writeReport` alone --
|
|
710
|
+
// which calls this once per pipeline step and per feedback op -- turned
|
|
711
|
+
// into a real, repeated cost).
|
|
712
|
+
//
|
|
713
|
+
// Fix, in two parts:
|
|
714
|
+
// 1. Read the log ONCE (`readEventsRawCounted`) and hand the SAME parsed
|
|
715
|
+
// array to `verifyEventsChain` directly -- the exact same check
|
|
716
|
+
// `verifyTrustAudit` runs with no anchor, so the integrity result is
|
|
717
|
+
// byte-identical to before. `verifyTrustAudit` itself is untouched and
|
|
718
|
+
// every OTHER caller (doctor.ts, `cw audit verify`, drive.ts,
|
|
719
|
+
// run-export.ts) still gets a real, independent, always-full check.
|
|
720
|
+
// 2. Skip the durable rewrite of summary.json/index.json when the freshly
|
|
721
|
+
// and FULLY recomputed content is unchanged from last time. This is
|
|
722
|
+
// NOT a shortcut on verification -- the full parse+rehash+chain-walk
|
|
723
|
+
// above always runs, on every call, no exceptions. A cache that
|
|
724
|
+
// reused a stale "verified" verdict would be unsound here: this
|
|
725
|
+
// codebase's chain check is a sequential field-comparison walk, not a
|
|
726
|
+
// content-addressed (Merkle) chain, so tampering with an EARLY event
|
|
727
|
+
// can leave the file's total size and its LAST event's bytes fully
|
|
728
|
+
// unchanged while still flipping `verified` to false. So the cache
|
|
729
|
+
// below only ever gates the DISK WRITE of an already fully-verified
|
|
730
|
+
// result, never the verification itself, and the function's RETURN
|
|
731
|
+
// VALUE is always the fresh `summary` object either way.
|
|
732
|
+
function summaryFingerprintPathFor(summaryPath) {
|
|
733
|
+
return path.join(path.dirname(summaryPath), "summary-fingerprint.json");
|
|
734
|
+
}
|
|
735
|
+
function readSummaryFingerprint(fingerprintPath) {
|
|
736
|
+
if (!fs.existsSync(fingerprintPath))
|
|
737
|
+
return undefined;
|
|
738
|
+
try {
|
|
739
|
+
const parsed = JSON.parse(fs.readFileSync(fingerprintPath, "utf8"));
|
|
740
|
+
if (parsed && parsed.schemaVersion === 1 && typeof parsed.hash === "string")
|
|
741
|
+
return parsed.hash;
|
|
742
|
+
}
|
|
743
|
+
catch {
|
|
744
|
+
// Corrupt/unreadable fingerprint -- fall back to a real rewrite below.
|
|
745
|
+
}
|
|
746
|
+
return undefined;
|
|
747
|
+
}
|
|
748
|
+
function writeSummaryFingerprint(fingerprintPath, fingerprint) {
|
|
749
|
+
try {
|
|
750
|
+
(0, fs_atomic_1.writeJson)(fingerprintPath, fingerprint);
|
|
751
|
+
}
|
|
752
|
+
catch {
|
|
753
|
+
// Best-effort: a failed fingerprint write must never break the real call
|
|
754
|
+
// -- it just means the next call falls back to a real rewrite.
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
function summarizeTrustAudit(run, options = {}) {
|
|
758
|
+
const persist = options.persist !== false;
|
|
759
|
+
// A read-only projection must not call ensureTrustAudit(): that helper is
|
|
760
|
+
// deliberately for mutation paths and creates the audit directory and an
|
|
761
|
+
// empty event log when they are absent.
|
|
762
|
+
const audit = persist ? ensureTrustAudit(run) : { schemaVersion: 1, ...trustAuditPaths(run) };
|
|
763
|
+
const { events, corruptLines } = readEventsRawCounted(audit.eventLogPath);
|
|
640
764
|
const ma = run.multiAgent;
|
|
641
765
|
const bb = run.blackboard;
|
|
642
766
|
const summary = {
|
|
@@ -644,7 +768,7 @@ function summarizeTrustAudit(run) {
|
|
|
644
768
|
runId: run.id,
|
|
645
769
|
generatedAt: new Date().toISOString(),
|
|
646
770
|
eventCount: events.length,
|
|
647
|
-
integrity:
|
|
771
|
+
integrity: verifyEventsChain(run.id, events, corruptLines),
|
|
648
772
|
eventLogPath: audit.eventLogPath,
|
|
649
773
|
indexPath: audit.indexPath,
|
|
650
774
|
summaryPath: audit.summaryPath,
|
|
@@ -688,11 +812,7 @@ function summarizeTrustAudit(run) {
|
|
|
688
812
|
policyViolations: events.filter((e) => e.kind === "policy.violation").length,
|
|
689
813
|
},
|
|
690
814
|
};
|
|
691
|
-
|
|
692
|
-
// them durably so a crash can't leave them pointing past the last durably-
|
|
693
|
-
// appended event. Byte-behavior port of the old build's summarizeTrustAudit.
|
|
694
|
-
(0, fs_atomic_1.writeJson)(audit.summaryPath, summary, { durable: true });
|
|
695
|
-
(0, fs_atomic_1.writeJson)(audit.indexPath, {
|
|
815
|
+
const index = {
|
|
696
816
|
schemaVersion: exports.TRUST_AUDIT_SCHEMA_VERSION,
|
|
697
817
|
runId: run.id,
|
|
698
818
|
events: events.map((event) => ({
|
|
@@ -707,7 +827,29 @@ function summarizeTrustAudit(run) {
|
|
|
707
827
|
sandboxProfileId: event.sandboxProfileId,
|
|
708
828
|
policyRef: event.policyRef,
|
|
709
829
|
})),
|
|
710
|
-
}
|
|
711
|
-
|
|
830
|
+
};
|
|
831
|
+
// Durable: the summary/index are the read-side view of the audit log; persist
|
|
832
|
+
// them durably so a crash can't leave them pointing past the last durably-
|
|
833
|
+
// appended event. Byte-behavior port of the old build's summarizeTrustAudit.
|
|
834
|
+
//
|
|
835
|
+
// Skip the rewrite only when the freshly, fully recomputed content is
|
|
836
|
+
// unchanged from last time (see the comment on `summaryFingerprintPathFor`
|
|
837
|
+
// above) -- `generatedAt` is dropped before fingerprinting since it is the
|
|
838
|
+
// one field that legitimately changes on every call. The RETURNED
|
|
839
|
+
// `summary` below always carries a real, fresh `generatedAt` either way;
|
|
840
|
+
// only the ON-DISK bytes may keep the previous call's `generatedAt` when
|
|
841
|
+
// nothing else changed.
|
|
842
|
+
const { generatedAt: _generatedAt, ...summaryForFingerprint } = summary;
|
|
843
|
+
void _generatedAt;
|
|
844
|
+
const fingerprintPath = summaryFingerprintPathFor(audit.summaryPath);
|
|
845
|
+
const freshFingerprint = (0, hash_1.stableHash)({ summary: summaryForFingerprint, index });
|
|
846
|
+
const priorFingerprint = readSummaryFingerprint(fingerprintPath);
|
|
847
|
+
if (persist && priorFingerprint !== freshFingerprint) {
|
|
848
|
+
(0, fs_atomic_1.writeJson)(audit.summaryPath, summary, { durable: true });
|
|
849
|
+
(0, fs_atomic_1.writeJson)(audit.indexPath, index, { durable: true });
|
|
850
|
+
writeSummaryFingerprint(fingerprintPath, { schemaVersion: 1, hash: freshFingerprint });
|
|
851
|
+
}
|
|
852
|
+
if (persist)
|
|
853
|
+
run.audit = { schemaVersion: exports.TRUST_AUDIT_SCHEMA_VERSION, ...audit };
|
|
712
854
|
return summary;
|
|
713
855
|
}
|
|
@@ -52,13 +52,55 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
52
52
|
})();
|
|
53
53
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
54
54
|
exports.WorkbenchHost = void 0;
|
|
55
|
+
exports.formatServeHint = formatServeHint;
|
|
56
|
+
exports.printServeHint = printServeHint;
|
|
55
57
|
const crypto = __importStar(require("node:crypto"));
|
|
56
58
|
const fs = __importStar(require("node:fs"));
|
|
57
59
|
const http = __importStar(require("node:http"));
|
|
58
60
|
const path = __importStar(require("node:path"));
|
|
59
61
|
const node_url_1 = require("node:url");
|
|
60
62
|
const workbench_1 = require("./workbench");
|
|
63
|
+
// `[::1]` is the form Node's URL parser returns for a bracketed IPv6
|
|
64
|
+
// literal Host header (verified); the bare `::1` covers a non-bracketed
|
|
65
|
+
// header that our fallback split still yields. `.split(":")[0]` alone could
|
|
66
|
+
// never produce either (it returns `[` or ``), so those entries used to be
|
|
67
|
+
// dead — parseHostname below fixes that.
|
|
61
68
|
const ALLOWED_HOSTNAMES = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
|
69
|
+
/** Extract the hostname from a Host header, correctly handling a bracketed
|
|
70
|
+
* IPv6 literal (`[::1]:7717`) that a naive `split(":")[0]` mangles into
|
|
71
|
+
* `"["`. Returns undefined when the header cannot be parsed at all. */
|
|
72
|
+
function parseHostname(hostHeader) {
|
|
73
|
+
try {
|
|
74
|
+
return new node_url_1.URL(`http://${hostHeader}`).hostname;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// A non-bracketed IPv6 literal (`::1`) is not a valid URL authority, so
|
|
78
|
+
// URL() throws — fall back to the raw header for that one case; a
|
|
79
|
+
// hostname:port still trims the port.
|
|
80
|
+
if (hostHeader.includes("]") || hostHeader.split(":").length > 2)
|
|
81
|
+
return hostHeader;
|
|
82
|
+
return hostHeader.split(":")[0];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function isTTY(stream) {
|
|
86
|
+
return Boolean(stream.isTTY);
|
|
87
|
+
}
|
|
88
|
+
/** Pure text for the human-friendly serve hint line. Kept separate from
|
|
89
|
+
* the TTY gate below so it is directly unit-testable with no fake stream
|
|
90
|
+
* at all: just a port number in, one line of text out. */
|
|
91
|
+
function formatServeHint(boundPort) {
|
|
92
|
+
return `workbench serving at http://127.0.0.1:${boundPort} (Ctrl-C to stop)`;
|
|
93
|
+
}
|
|
94
|
+
/** Prints the human hint line to STDERR only, and only on a real
|
|
95
|
+
* interactive terminal — the same TTY-gated-nicety pattern as
|
|
96
|
+
* term.ts's printSuccessSummary: silent when the stream is not a TTY, so
|
|
97
|
+
* a piped/non-interactive run (and the existing STDOUT descriptor line)
|
|
98
|
+
* never changes at all. */
|
|
99
|
+
function printServeHint(boundPort, stream = process.stderr) {
|
|
100
|
+
if (!isTTY(stream))
|
|
101
|
+
return;
|
|
102
|
+
stream.write(`${formatServeHint(boundPort)}\n`);
|
|
103
|
+
}
|
|
62
104
|
function timingSafeEqual(a, b) {
|
|
63
105
|
const bufA = Buffer.from(a, "utf8");
|
|
64
106
|
const bufB = Buffer.from(b, "utf8");
|
|
@@ -72,6 +114,13 @@ function timingSafeEqual(a, b) {
|
|
|
72
114
|
return crypto.timingSafeEqual(bufA, bufB);
|
|
73
115
|
}
|
|
74
116
|
function sendJson(res, status, body) {
|
|
117
|
+
// If a handler already began the response (e.g. an asset read threw AFTER
|
|
118
|
+
// writeHead), a second writeHead throws ERR_HTTP_HEADERS_SENT synchronously
|
|
119
|
+
// inside the request listener and kills the whole server. Guard against it.
|
|
120
|
+
if (res.headersSent) {
|
|
121
|
+
res.end();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
75
124
|
const text = `${JSON.stringify(body, null, 2)}\n`;
|
|
76
125
|
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
|
|
77
126
|
res.end(text);
|
|
@@ -124,8 +173,8 @@ class WorkbenchHost {
|
|
|
124
173
|
try {
|
|
125
174
|
const hostHeader = req.headers.host;
|
|
126
175
|
if (hostHeader) {
|
|
127
|
-
const hostname = hostHeader
|
|
128
|
-
if (!ALLOWED_HOSTNAMES.has(hostname)) {
|
|
176
|
+
const hostname = parseHostname(hostHeader);
|
|
177
|
+
if (hostname === undefined || !ALLOWED_HOSTNAMES.has(hostname)) {
|
|
129
178
|
sendJson(res, 403, { error: "forbidden: non-localhost Host header" });
|
|
130
179
|
return;
|
|
131
180
|
}
|
|
@@ -143,10 +192,6 @@ class WorkbenchHost {
|
|
|
143
192
|
sendJson(res, 400, { error: "bad request: invalid URL" });
|
|
144
193
|
return;
|
|
145
194
|
}
|
|
146
|
-
if (!this.checkAuth(req, url)) {
|
|
147
|
-
sendJson(res, 401, { error: "unauthorized: token mismatch" });
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
195
|
// Decode the route path ONCE (old build's decodeRoutePath). Node's
|
|
151
196
|
// `new URL()` does NOT throw on a malformed percent-escape like
|
|
152
197
|
// `/%E0%A4%A`; only decodeURIComponent does — so this is where a
|
|
@@ -162,13 +207,36 @@ class WorkbenchHost {
|
|
|
162
207
|
sendJson(res, 400, { error: "bad request: malformed URL path" });
|
|
163
208
|
return;
|
|
164
209
|
}
|
|
210
|
+
// Auth is checked AFTER the route is decoded, and only where run data
|
|
211
|
+
// (or environment data) can flow. Before this, a set token made the
|
|
212
|
+
// browser's own follow-up requests for /ui/app.css and /ui/app.js fail
|
|
213
|
+
// 401 — the page rendered as broken unstyled HTML with no explanation.
|
|
214
|
+
// The three shipped UI files are generic static code with no run data,
|
|
215
|
+
// so they are served without a token. Every /api/* route carries run
|
|
216
|
+
// data and stays behind the token. The "/" route is split: an
|
|
217
|
+
// INSTALLED index.html is the same generic static code (open), but the
|
|
218
|
+
// FALLBACK page embeds the serve descriptor — which carries the
|
|
219
|
+
// absolute repo root path — so a missing UI keeps "/" behind the token.
|
|
220
|
+
if (!this.checkAuth(req, url)) {
|
|
221
|
+
const uiIndexInstalled = fs.existsSync(path.resolve((0, workbench_1.workbenchUiRoot)(), "index.html"));
|
|
222
|
+
if (route.startsWith("/api/") || (route === "/" && !uiIndexInstalled)) {
|
|
223
|
+
sendJson(res, 401, { error: "unauthorized: token mismatch" });
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
165
227
|
if (route === "/api/serve") {
|
|
166
228
|
sendJson(res, 200, this.descriptor(false));
|
|
167
229
|
return;
|
|
168
230
|
}
|
|
169
231
|
if (route === "/api/index") {
|
|
170
232
|
const scope = this.args.scope === "home" ? "home" : "repo";
|
|
171
|
-
|
|
233
|
+
// Only `text` may be driven by the query string — it is the sidebar
|
|
234
|
+
// filter. Spreading ALL query params (as before) let a request
|
|
235
|
+
// override the server's computed `scope` or inject an arbitrary
|
|
236
|
+
// `cwd`, reading run data from any directory the serving user can
|
|
237
|
+
// read, past the repo the operator chose to serve.
|
|
238
|
+
const text = url.searchParams.get("text") || undefined;
|
|
239
|
+
sendJson(res, 200, (0, workbench_1.buildWorkbenchIndex)({ ...this.args, scope, ...(text ? { text } : {}) }));
|
|
172
240
|
return;
|
|
173
241
|
}
|
|
174
242
|
const runMatch = route.match(/^\/api\/run\/([^/]+)$/);
|
|
@@ -204,15 +272,30 @@ class WorkbenchHost {
|
|
|
204
272
|
sendJson(res, 404, { error: `UI asset not installed: ${relative}` });
|
|
205
273
|
return;
|
|
206
274
|
}
|
|
275
|
+
// Read the bytes BEFORE writeHead: if the asset vanished or became
|
|
276
|
+
// unreadable between the checks above and here (TOCTOU), the throw
|
|
277
|
+
// then lands on the sendJson(500) path with headers not yet sent,
|
|
278
|
+
// instead of a fatal double-writeHead.
|
|
279
|
+
const bytes = fs.readFileSync(resolved);
|
|
207
280
|
res.writeHead(200, { "Content-Type": contentTypeFor(resolved), "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
|
|
208
|
-
res.end(
|
|
281
|
+
res.end(bytes);
|
|
209
282
|
}
|
|
210
283
|
/** Binds the loopback server and resolves once listening. Returns the
|
|
211
284
|
* actually-bound port (useful for `--port 0`). */
|
|
212
285
|
listen() {
|
|
213
286
|
return new Promise((resolve, reject) => {
|
|
287
|
+
// Defense in depth: `run()` already validated the port before ever
|
|
288
|
+
// reaching here, but a direct caller of listen() still fails closed
|
|
289
|
+
// with the clear message rather than node's opaque ERR_SOCKET_BAD_PORT.
|
|
290
|
+
let requestedPort;
|
|
291
|
+
try {
|
|
292
|
+
requestedPort = (0, workbench_1.parseWorkbenchPort)(this.args.port);
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
reject(error);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
214
298
|
const server = http.createServer((req, res) => this.handleRequest(req, res));
|
|
215
|
-
const requestedPort = this.args.port !== undefined ? Number(this.args.port) : undefined;
|
|
216
299
|
server.on("error", reject);
|
|
217
300
|
server.listen(requestedPort ?? 7717, "127.0.0.1", () => {
|
|
218
301
|
this.server = server;
|
|
@@ -243,14 +326,42 @@ class WorkbenchHost {
|
|
|
243
326
|
process.exitCode = 1;
|
|
244
327
|
return;
|
|
245
328
|
}
|
|
329
|
+
// Validate --port HERE, before any dispatch. The CLI binding calls this
|
|
330
|
+
// as `void host.run()` (not awaited), so a rejection from listen() would
|
|
331
|
+
// otherwise surface as an unhandled promise rejection stack dump on the
|
|
332
|
+
// real CLI. Fail closed the same clean way as --require-token: one `cw:`
|
|
333
|
+
// stderr line + exit 1 + return, never a crash and never a bound server.
|
|
334
|
+
try {
|
|
335
|
+
(0, workbench_1.parseWorkbenchPort)(this.args.port);
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
process.stderr.write(`cw: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
339
|
+
process.exitCode = 1;
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
246
342
|
const once = Boolean(this.args.once || this.args.json);
|
|
247
343
|
if (once) {
|
|
248
344
|
process.stdout.write(`${JSON.stringify(this.descriptor(true))}\n`);
|
|
249
345
|
return;
|
|
250
346
|
}
|
|
251
|
-
|
|
347
|
+
// The bind itself can still fail at runtime (EADDRINUSE when the port
|
|
348
|
+
// is taken, EACCES on a privileged port) — listen() rejects for those.
|
|
349
|
+
// The port-VALUE check above only covers a malformed --port. Fail
|
|
350
|
+
// closed the same clean way rather than an unhandled-rejection crash.
|
|
351
|
+
let boundPort;
|
|
352
|
+
try {
|
|
353
|
+
boundPort = await this.listen();
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
357
|
+
process.stderr.write(`cw: ${message}\n`);
|
|
358
|
+
process.stderr.write(`Try: pick another port with --port <n>, or stop whatever is already serving.\n`);
|
|
359
|
+
process.exitCode = 1;
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
252
362
|
const descriptor = this.descriptor(false, boundPort);
|
|
253
363
|
process.stdout.write(`${JSON.stringify({ ...descriptor, boundPort })}\n`);
|
|
364
|
+
printServeHint(boundPort);
|
|
254
365
|
// Block forever (until the process is killed) — a real serve.
|
|
255
366
|
await new Promise(() => { });
|
|
256
367
|
}
|
package/dist/shell/workbench.js
CHANGED
|
@@ -47,6 +47,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
47
47
|
})();
|
|
48
48
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
49
49
|
exports.WORKBENCH_UI_RELATIVE = exports.WORKBENCH_DEFAULT_PORT = void 0;
|
|
50
|
+
exports.parseWorkbenchPort = parseWorkbenchPort;
|
|
50
51
|
exports.buildWorkbenchRunView = buildWorkbenchRunView;
|
|
51
52
|
exports.workbenchUiRoot = workbenchUiRoot;
|
|
52
53
|
exports.buildWorkbenchServeDescriptor = buildWorkbenchServeDescriptor;
|
|
@@ -56,6 +57,32 @@ const capability_table_1 = require("../core/capability-table");
|
|
|
56
57
|
const run_store_1 = require("./run-store");
|
|
57
58
|
exports.WORKBENCH_DEFAULT_PORT = 7717;
|
|
58
59
|
exports.WORKBENCH_UI_RELATIVE = "ui/workbench";
|
|
60
|
+
/** Parse and range-check a workbench `--port` value. Returns `undefined`
|
|
61
|
+
* when no port was given (the caller then uses WORKBENCH_DEFAULT_PORT),
|
|
62
|
+
* the validated integer otherwise. Throws a clear Error on a bad value —
|
|
63
|
+
* a non-number (`NaN`), a float, a negative, or a number over 65535 — so
|
|
64
|
+
* callers fail closed with an actionable line instead of node's opaque
|
|
65
|
+
* ERR_SOCKET_BAD_PORT or a `"port": null` descriptor. A valid port is an
|
|
66
|
+
* integer in [0, 65535]; 0 is the legitimately-supported ephemeral port. */
|
|
67
|
+
function parseWorkbenchPort(raw) {
|
|
68
|
+
if (raw === undefined)
|
|
69
|
+
return undefined;
|
|
70
|
+
const reject = () => {
|
|
71
|
+
throw new Error(`workbench serve --port must be an integer 0-65535 (got ${JSON.stringify(raw)})`);
|
|
72
|
+
};
|
|
73
|
+
// Only a string (the argv form) or a number is a real port. A valueless
|
|
74
|
+
// `--port` flag parses to boolean `true`; reject it rather than let
|
|
75
|
+
// Number(true) === 1 silently bind to port 1. A blank string is bad input
|
|
76
|
+
// too — Number("") === 0 would otherwise pass as the ephemeral port.
|
|
77
|
+
if (typeof raw !== "string" && typeof raw !== "number")
|
|
78
|
+
reject();
|
|
79
|
+
if (typeof raw === "string" && raw.trim() === "")
|
|
80
|
+
reject();
|
|
81
|
+
const port = Number(raw);
|
|
82
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
83
|
+
reject();
|
|
84
|
+
return port;
|
|
85
|
+
}
|
|
59
86
|
/** Panel groups/members, in the SPEC's declared order. Each entry names
|
|
60
87
|
* the capability id whose MCP handler is called to fill the panel. */
|
|
61
88
|
const PANEL_MAP = {
|
|
@@ -104,8 +131,26 @@ function buildWorkbenchRunView(runId, args = {}) {
|
|
|
104
131
|
resolved = false;
|
|
105
132
|
resolveError = error instanceof Error ? error.message : String(error);
|
|
106
133
|
}
|
|
134
|
+
// The run's lifecycle, from the SAME `run.show` capability handler the
|
|
135
|
+
// CLI/MCP use (the buildWorkbenchIndex composition style — never a
|
|
136
|
+
// duplicate implementation). This function's contract is never-throws,
|
|
137
|
+
// so any failure here just leaves the key out.
|
|
138
|
+
let lifecycle;
|
|
139
|
+
try {
|
|
140
|
+
const showRow = (0, capability_table_1.findCapability)("run.show");
|
|
141
|
+
const shown = showRow?.mcp ? showRow.mcp.handler({ ...args, runId, cwd }) : undefined;
|
|
142
|
+
if (shown && shown.found === true && shown.record && typeof shown.record.lifecycle === "string") {
|
|
143
|
+
lifecycle = shown.record.lifecycle;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
lifecycle = undefined;
|
|
148
|
+
}
|
|
107
149
|
const panels = {};
|
|
108
|
-
|
|
150
|
+
// This is an internal mechanism flag, not a CLI/MCP option. The Workbench
|
|
151
|
+
// must use the same capability bodies as the other front doors, but its
|
|
152
|
+
// GET-only projection must not make derived audit or metrics files.
|
|
153
|
+
const panelArgs = { ...args, runId, cwd, __cwWorkbenchReadOnlyProjection: true };
|
|
109
154
|
for (const [group, members] of Object.entries(PANEL_MAP)) {
|
|
110
155
|
panels[group] = {};
|
|
111
156
|
for (const [member, capability] of Object.entries(members)) {
|
|
@@ -114,7 +159,7 @@ function buildWorkbenchRunView(runId, args = {}) {
|
|
|
114
159
|
: { capability, cli: cliCommandFor(capability), mcp: mcpToolFor(capability), status: "absent", error: resolveError };
|
|
115
160
|
}
|
|
116
161
|
}
|
|
117
|
-
return { schemaVersion: 1, surface: "workbench", runId, resolved, ...(resolveError ? { error: resolveError } : {}), panels };
|
|
162
|
+
return { schemaVersion: 1, surface: "workbench", runId, resolved, ...(lifecycle ? { lifecycle } : {}), ...(resolveError ? { error: resolveError } : {}), panels };
|
|
118
163
|
}
|
|
119
164
|
/** Package-relative resolution only — never falls back to the invocation
|
|
120
165
|
* cwd. `ui/` ships as a sibling of `dist/` in the published package
|
|
@@ -143,7 +188,10 @@ function buildWorkbenchServeDescriptor(args = {}, boundPort) {
|
|
|
143
188
|
surface: "workbench",
|
|
144
189
|
command: "serve",
|
|
145
190
|
host: "127.0.0.1",
|
|
146
|
-
|
|
191
|
+
// `boundPort` (from a real listen()) is already a valid port. Otherwise
|
|
192
|
+
// validate the requested `--port` so the `--once`/`--json`/MCP descriptor
|
|
193
|
+
// path fails closed with a clear line instead of emitting `"port": null`.
|
|
194
|
+
port: boundPort ?? (parseWorkbenchPort(args.port) ?? exports.WORKBENCH_DEFAULT_PORT),
|
|
147
195
|
once: Boolean(args.once),
|
|
148
196
|
readOnly: true,
|
|
149
197
|
scope: args.scope === "home" ? "home" : "repo",
|
|
@@ -158,13 +206,42 @@ function buildWorkbenchServeDescriptor(args = {}, boundPort) {
|
|
|
158
206
|
* list`), each embedded VERBATIM from its own already-declared capability
|
|
159
207
|
* handler — the Workbench adds no new source of truth. Composed the same
|
|
160
208
|
* way the panels are (findCapability(...).mcp.handler), so `/api/index`
|
|
161
|
-
* can never drift from the standalone `cw` commands. Read-only.
|
|
209
|
+
* can never drift from the standalone `cw` commands. Read-only.
|
|
210
|
+
*
|
|
211
|
+
* When `args.text` names a non-blank filter (the Workbench UI's sidebar
|
|
212
|
+
* filter box, `ui/workbench/app.js`'s `loadIndex`), the run list is filled
|
|
213
|
+
* via the `run.search` capability instead of `run.list` — `run.list`'s own
|
|
214
|
+
* handler (`runListCli` -> `RunRegistry.list()`) never reads a `text`
|
|
215
|
+
* field, so calling it with a filter present would silently ignore it.
|
|
216
|
+
* With no `text` filter the call is unchanged: `run.list`, byte-identical
|
|
217
|
+
* to the payload before this branch existed. */
|
|
162
218
|
function buildWorkbenchIndex(args = {}) {
|
|
163
219
|
const scope = args.scope === "home" ? "home" : "repo";
|
|
164
220
|
const scoped = { ...args, scope };
|
|
165
221
|
const registryRow = (0, capability_table_1.findCapability)("registry.show");
|
|
166
|
-
const
|
|
222
|
+
const text = typeof args.text === "string" ? args.text.trim() : "";
|
|
223
|
+
const runListRow = (0, capability_table_1.findCapability)(text ? "run.search" : "run.list");
|
|
167
224
|
const registry = registryRow?.mcp ? registryRow.mcp.handler(scoped) : undefined;
|
|
168
|
-
const runs = runListRow?.mcp ? runListRow.mcp.handler
|
|
225
|
+
const runs = runListRow?.mcp ? newestRunPage(runListRow.mcp.handler, scoped) : [];
|
|
169
226
|
return { schemaVersion: 1, surface: "workbench", command: "index", scope, registry, runs };
|
|
170
227
|
}
|
|
228
|
+
/** The run list/search handler sorts oldest-first and returns only the
|
|
229
|
+
* first `limit` page — so the default page is the OLDEST runs, and a scope
|
|
230
|
+
* with more runs than the page size never shows the newest run at all. The
|
|
231
|
+
* Workbench wants the newest page: when the caller pinned no offset and the
|
|
232
|
+
* total exceeds one page, re-fetch with the offset that lands on the last
|
|
233
|
+
* page. The payload shape (total/offset/limit/records) is unchanged, so
|
|
234
|
+
* the UI can still show "showing latest N of M". */
|
|
235
|
+
function newestRunPage(handler, scoped) {
|
|
236
|
+
const first = handler(scoped);
|
|
237
|
+
if (scoped.offset !== undefined)
|
|
238
|
+
return first;
|
|
239
|
+
if (!first || typeof first !== "object")
|
|
240
|
+
return first;
|
|
241
|
+
const page = first;
|
|
242
|
+
const total = typeof page.total === "number" ? page.total : undefined;
|
|
243
|
+
const limit = typeof page.limit === "number" ? page.limit : undefined;
|
|
244
|
+
if (total === undefined || limit === undefined || total <= limit)
|
|
245
|
+
return first;
|
|
246
|
+
return handler({ ...scoped, offset: total - limit });
|
|
247
|
+
}
|
|
@@ -703,7 +703,7 @@ function recordWorkerOutput(run, workerId, resultPath, options = {}) {
|
|
|
703
703
|
artifacts: [{ id: "result", kind: "markdown", path: destination }],
|
|
704
704
|
evidence: resultNode.evidence.length ? resultNode.evidence : [{ id: "result:summary", source: "summary", summary: parsedResult.summary }],
|
|
705
705
|
metadata: { taskId: task.id, workerId, resultNodeId: resultNode.id, sandboxProfileId: scope.sandboxProfileId },
|
|
706
|
-
}, { persist: false, persistNode: node_store_1.appendRunNode });
|
|
706
|
+
}, { persist: false, persistNode: node_store_1.appendRunNode, pathExists: fs.existsSync });
|
|
707
707
|
task.verifierNodeId = verifierResult.outputNodeId;
|
|
708
708
|
// Step 5: completion — persist the worker scope with the verify-derived status.
|
|
709
709
|
const output = { workerId, taskId: task.id, resultPath: absoluteResultPath, recordedAt: new Date().toISOString(), stateNodeId: resultNode.id, verifierNodeId: task.verifierNodeId, auditEventIds: [pathAudit.id, acceptedAudit.id] };
|
|
@@ -78,6 +78,11 @@ function loadWorkflowAppLoader() {
|
|
|
78
78
|
path: ["list"],
|
|
79
79
|
jsonMode: "default",
|
|
80
80
|
handler: () => ({ json: (0, registry_core_1.listBundledWorkflows)() }),
|
|
81
|
+
// UI/UX fix: a person at a real terminal used to get the raw JSON array.
|
|
82
|
+
// On a TTY (and only there — piped bytes stay the exact same JSON, see
|
|
83
|
+
// cli/dispatch.ts's shouldRenderHuman) render one "<id> — <title>" line
|
|
84
|
+
// per workflow plus the next-step footer.
|
|
85
|
+
humanRender: help_1.formatWorkflowList,
|
|
81
86
|
});
|
|
82
87
|
(0, registry_core_1.attachCliBinding)("status", {
|
|
83
88
|
path: ["status"],
|