cool-workflow 0.2.5 → 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/plugin.json +1 -1
- package/.opencode-plugin/plugin.json +1 -1
- package/README.md +5 -3
- 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/core/state/run-paths.js +3 -30
- package/dist/core/version.js +1 -1
- package/dist/mcp/server.js +95 -27
- package/dist/mcp/tool-process.js +181 -0
- package/dist/mcp-server.js +8 -1
- package/dist/shell/agent-config.js +11 -2
- package/dist/shell/audit-cli.js +1 -1
- package/dist/shell/drive.js +99 -89
- package/dist/shell/execution-backend/agent.js +18 -15
- package/dist/shell/execution-backend/local.js +1 -0
- package/dist/shell/fs-atomic.js +3 -0
- package/dist/shell/metrics-cli.js +1 -1
- package/dist/shell/multi-agent-cli.js +4 -1
- package/dist/shell/multi-agent-operator-ux.js +1 -1
- package/dist/shell/observability.js +7 -4
- package/dist/shell/operator-ux.js +1 -1
- package/dist/shell/perf-trace.js +136 -0
- package/dist/shell/pipeline.js +1 -1
- package/dist/shell/report-view-cli.js +2 -0
- package/dist/shell/run-export-cli.js +5 -2
- package/dist/shell/run-export.js +241 -27
- package/dist/shell/run-registry-io.js +8 -0
- package/dist/shell/run-store.js +27 -3
- package/dist/shell/state-explosion-cli.js +4 -1
- package/dist/shell/telemetry-demo.js +1 -1
- package/dist/shell/trust-audit.js +91 -43
- package/dist/shell/workbench.js +4 -1
- package/dist/wiring/capability-table/parity.js +10 -5
- package/docs/agent-delegation-drive.7.md +2 -0
- package/docs/cli-mcp-parity.7.md +21 -5
- package/docs/contract-migration-tooling.7.md +2 -0
- package/docs/control-plane-scheduling.7.md +2 -0
- package/docs/durable-state-and-locking.7.md +2 -0
- package/docs/evidence-adoption-reasoning-chain.7.md +2 -0
- package/docs/execution-backends.7.md +2 -0
- package/docs/mcp-app-surface.7.md +21 -0
- package/docs/multi-agent-cli-mcp-surface.7.md +2 -0
- package/docs/multi-agent-eval-replay-harness.7.md +2 -0
- package/docs/multi-agent-operator-ux.7.md +2 -0
- package/docs/node-snapshot-diff-replay.7.md +2 -0
- package/docs/observability-cost-accounting.7.md +2 -0
- package/docs/project-index.md +18 -4
- package/docs/real-execution-backends.7.md +2 -0
- package/docs/release-and-migration.7.md +2 -0
- package/docs/release-tooling.7.md +10 -0
- package/docs/run-registry-control-plane.7.md +35 -9
- package/docs/run-retention-reclamation.7.md +2 -0
- package/docs/state-explosion-management.7.md +2 -0
- package/docs/team-collaboration.7.md +2 -0
- package/docs/trust-audit-anchor.7.md +2 -0
- package/docs/web-desktop-workbench.7.md +48 -0
- package/manifest/plugin.manifest.json +1 -1
- package/package.json +3 -2
- package/scripts/canonical-apps.js +4 -4
- package/scripts/dogfood-release.js +1 -1
- package/scripts/gen-manifests.js +32 -61
- package/scripts/golden-path.js +4 -4
- package/scripts/parity-check.js +42 -23
- package/scripts/purity-baseline.json +0 -3
- package/scripts/release-flow.js +43 -13
- package/scripts/release-gate.js +30 -6
- package/scripts/schema-version-inventory.json +31 -0
- package/scripts/validate-run-state-schema.js +95 -4
- package/scripts/verify-release-verdict.js +139 -0
- package/ui/workbench/app.css +15 -7
- package/ui/workbench/app.js +118 -16
- package/ui/workbench/index.html +2 -0
- package/ui/workbench/inspection.js +51 -0
- package/ui/workbench/navigation.js +44 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
// verify-release-verdict.js — the one true "find a signed, committed release
|
|
4
|
+
// verdict for this commit" check. Both CI workflows call it:
|
|
5
|
+
// .github/workflows/release-gate.yml (cwd = repo root)
|
|
6
|
+
// .github/workflows/npm-publish.yml (cwd = plugins/cool-workflow)
|
|
7
|
+
// Before this file, each workflow kept its own ~35-line bash copy of this
|
|
8
|
+
// same loop, and the two copies could drift apart. Now the logic lives here
|
|
9
|
+
// only, and each workflow step is one line.
|
|
10
|
+
//
|
|
11
|
+
// One more win: a GitHub Actions `run:` step prints its FULL script text to
|
|
12
|
+
// the log before it runs. With the old inline bash, the "::error::" lines of
|
|
13
|
+
// the fail branch were printed on EVERY run — even a fully green one — and
|
|
14
|
+
// GitHub's log UI marked them red, which made a good release look bad. With
|
|
15
|
+
// a one-line `run:`, the "::error::" text only ever gets printed when this
|
|
16
|
+
// script truly fails.
|
|
17
|
+
//
|
|
18
|
+
// WHY the check is shaped this way (was: near-same comments in both YAMLs):
|
|
19
|
+
// The tag commit itself bumps version/changelog; the reviewed sha is its
|
|
20
|
+
// parent. So a verdict for HEAD or HEAD~1 is accepted. But a validly-signed
|
|
21
|
+
// verdict for the PARENT alone does not prove HEAD is the same mechanical
|
|
22
|
+
// bump release-flow.js's cut() would have made on top of the approved
|
|
23
|
+
// parent — it could be an attacker's own commit smuggling in changes on top
|
|
24
|
+
// of an old, truly-approved parent, replaying that parent's already-public
|
|
25
|
+
// verdict + signature (public git objects; no secret is needed to copy
|
|
26
|
+
// them). So when the match is at HEAD~1, HEAD must ALSO reproduce exactly
|
|
27
|
+
// as the deterministic bump (verify-bump-reproduction.js).
|
|
28
|
+
//
|
|
29
|
+
// The pubkey is read from origin/main, NOT from the checked-out tree. The
|
|
30
|
+
// checked-out tree IS the commit under test — an attacker's tag could just
|
|
31
|
+
// delete .cw-release/verdict-signing.pub and fall back to a no-signature
|
|
32
|
+
// check. origin/main is a ref the tag being judged cannot rewrite. Until a
|
|
33
|
+
// pubkey is committed there, this stays a first-line-only text check
|
|
34
|
+
// (opt-in, backward compatible — see scripts/verdict-keygen.js).
|
|
35
|
+
//
|
|
36
|
+
// cwd does not matter: everything is anchored on
|
|
37
|
+
// `git rev-parse --show-toplevel`, and the two helper scripts are loaded
|
|
38
|
+
// from this script's own directory (never from the repo under test).
|
|
39
|
+
//
|
|
40
|
+
// Usage: verify-release-verdict.js [--context "<one sentence for the fail message>"]
|
|
41
|
+
// Exit 0: a valid verdict was found. Exit 1: none found — the two
|
|
42
|
+
// "::error::" lines are printed here and nowhere else.
|
|
43
|
+
|
|
44
|
+
const fs = require("node:fs");
|
|
45
|
+
const os = require("node:os");
|
|
46
|
+
const path = require("node:path");
|
|
47
|
+
const { spawnSync } = require("node:child_process");
|
|
48
|
+
|
|
49
|
+
// Where THIS SCRIPT lives — the helper scripts are siblings of the installed
|
|
50
|
+
// script, never files of the (possibly throwaway) repo being checked. Same
|
|
51
|
+
// rule as in verify-bump-reproduction.js.
|
|
52
|
+
const VERIFY_SIG = path.join(__dirname, "verify-verdict-signature.js");
|
|
53
|
+
const VERIFY_BUMP = path.join(__dirname, "verify-bump-reproduction.js");
|
|
54
|
+
|
|
55
|
+
function git(args) {
|
|
56
|
+
const r = spawnSync("git", args, { encoding: "utf8" });
|
|
57
|
+
return r.status === 0 ? r.stdout.trim() : "";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function main() {
|
|
61
|
+
let context = "The release flow was bypassed.";
|
|
62
|
+
const argv = process.argv.slice(2);
|
|
63
|
+
for (let i = 0; i < argv.length; i++) {
|
|
64
|
+
if (argv[i] === "--context" && argv[i + 1]) context = argv[++i];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const REPO_ROOT = git(["rev-parse", "--show-toplevel"]);
|
|
68
|
+
if (!REPO_ROOT) {
|
|
69
|
+
process.stderr.write("verify-release-verdict: not inside a git repo\n");
|
|
70
|
+
return 1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const SHA = git(["-C", REPO_ROOT, "rev-parse", "HEAD^{commit}"]);
|
|
74
|
+
if (!SHA) {
|
|
75
|
+
process.stderr.write("verify-release-verdict: could not resolve HEAD\n");
|
|
76
|
+
return 1;
|
|
77
|
+
}
|
|
78
|
+
const PARENT = git(["-C", REPO_ROOT, "rev-parse", "HEAD~1"]) || "none";
|
|
79
|
+
|
|
80
|
+
let PUBKEY = "";
|
|
81
|
+
const pk = spawnSync("git", ["-C", REPO_ROOT, "show", "origin/main:.cw-release/verdict-signing.pub"], {
|
|
82
|
+
encoding: "utf8",
|
|
83
|
+
});
|
|
84
|
+
if (pk.status === 0 && pk.stdout.trim() !== "") {
|
|
85
|
+
PUBKEY = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "cw-verdict-pub-")), "verdict-signing.pub");
|
|
86
|
+
fs.writeFileSync(PUBKEY, pk.stdout);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const C of [SHA, PARENT]) {
|
|
90
|
+
if (C === "none") continue;
|
|
91
|
+
const VERDICT_REL = `.cw-release/review-${C}.verdict`;
|
|
92
|
+
const VERDICT = path.join(REPO_ROOT, VERDICT_REL);
|
|
93
|
+
if (!fs.existsSync(VERDICT)) continue;
|
|
94
|
+
// The first line must say EXACTLY "APPROVED <this candidate's sha>". A
|
|
95
|
+
// starts-with check is not enough: the signature binds the BYTES, never
|
|
96
|
+
// the file NAME, so a real signed verdict for some other sha could be
|
|
97
|
+
// parked under this candidate's filename and still verify.
|
|
98
|
+
const FIRST_LINE = fs.readFileSync(VERDICT, "utf8").split("\n", 1)[0];
|
|
99
|
+
if (FIRST_LINE !== `APPROVED ${C}`) continue;
|
|
100
|
+
|
|
101
|
+
if (PUBKEY) {
|
|
102
|
+
const SIG_REL = `${VERDICT_REL}.sig`;
|
|
103
|
+
const SIG = path.join(REPO_ROOT, SIG_REL);
|
|
104
|
+
const sigOk =
|
|
105
|
+
fs.existsSync(SIG) &&
|
|
106
|
+
spawnSync(process.execPath, [VERIFY_SIG, VERDICT, SIG, PUBKEY], { stdio: "inherit" }).status === 0;
|
|
107
|
+
if (sigOk) {
|
|
108
|
+
if (C !== SHA) {
|
|
109
|
+
const bump = spawnSync(process.execPath, [VERIFY_BUMP, C, SHA, VERDICT_REL, SIG_REL], {
|
|
110
|
+
cwd: REPO_ROOT,
|
|
111
|
+
stdio: "inherit",
|
|
112
|
+
});
|
|
113
|
+
if (bump.status !== 0) {
|
|
114
|
+
console.log(
|
|
115
|
+
`verdict for ${C} has a valid signature but HEAD does not reproduce as its deterministic bump — trying the other candidate`
|
|
116
|
+
);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
console.log(`verdict found + signature verified for ${C}`);
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
console.log(`verdict for ${C} has no valid signature — trying the other candidate`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
console.log(`verdict found for ${C} (no verdict-signing.pub committed yet — signature not required)`);
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
console.log(
|
|
131
|
+
`::error::No committed APPROVED verdict for this tag (with a valid signature, if .cw-release/verdict-signing.pub is committed). ${context}`
|
|
132
|
+
);
|
|
133
|
+
console.log(
|
|
134
|
+
"::error::Expected: .cw-release/review-<sha>.verdict with first line 'APPROVED <sha>' — auto-created by release-flow.js --cut"
|
|
135
|
+
);
|
|
136
|
+
return 1;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
process.exit(main());
|
package/ui/workbench/app.css
CHANGED
|
@@ -63,24 +63,26 @@ input[type="search"] {
|
|
|
63
63
|
}
|
|
64
64
|
.freshness { font-size: 12px; color: var(--muted); margin-bottom: 8px; }
|
|
65
65
|
.run-list { list-style: none; margin: 0; padding: 0; }
|
|
66
|
-
.run-list li
|
|
67
|
-
.run-list
|
|
66
|
+
.run-list > li { margin-bottom: 6px; }
|
|
67
|
+
.run-list > li:not(.run-entry),
|
|
68
|
+
.run-list .run-entry > button {
|
|
68
69
|
padding: 8px; border: 1px solid var(--line); border-radius: 6px;
|
|
69
|
-
|
|
70
|
+
cursor: pointer; background: var(--panel);
|
|
70
71
|
}
|
|
71
72
|
/* Only the run-row <button> needs a plain-element reset: a <button> has
|
|
72
73
|
its own default display/width/font/color, a plain <li> (used only for
|
|
73
74
|
the error and empty-state rows) does not and must keep its own color
|
|
74
75
|
(.err red, .muted gray) — see app.js. */
|
|
75
|
-
.run-list button {
|
|
76
|
+
.run-list .run-entry > button {
|
|
76
77
|
display: block; width: 100%; text-align: left; font: inherit; color: inherit;
|
|
77
78
|
}
|
|
78
|
-
.run-list li:hover, .run-list button:hover { border-color: var(--accent); }
|
|
79
|
-
.run-list
|
|
79
|
+
.run-list > li:not(.run-entry):hover, .run-list .run-entry > button:hover { border-color: var(--accent); }
|
|
80
|
+
.run-list .run-entry > button.active { border-color: var(--accent); background: var(--panel-2); }
|
|
80
81
|
/* Run rows are now real <button> elements (for Tab/Enter/Space); make the
|
|
81
82
|
keyboard focus ring visible — there is no :focus-visible rule for this
|
|
82
83
|
element otherwise. */
|
|
83
|
-
.run-list button:focus-visible
|
|
84
|
+
.run-list button:focus-visible,
|
|
85
|
+
.tabs button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
84
86
|
.run-list .rid { font-family: ui-monospace, monospace; font-size: 12px; display: flex; align-items: center; gap: 6px; }
|
|
85
87
|
.run-list .meta { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
|
86
88
|
.status-dot {
|
|
@@ -126,6 +128,12 @@ pre.json {
|
|
|
126
128
|
color: var(--ink); white-space: pre; max-height: 460px;
|
|
127
129
|
}
|
|
128
130
|
.panel-card .absent-note { padding: 10px 12px; color: var(--absent); font-size: 12px; }
|
|
131
|
+
.what-matters { padding: 10px 12px; border-top: 1px solid var(--line); background: var(--panel-2); }
|
|
132
|
+
.what-matters-title { margin: 0 0 8px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
|
|
133
|
+
.action-fact { display: grid; grid-template-columns: minmax(100px, 140px) 1fr; gap: 10px; margin-top: 6px; }
|
|
134
|
+
.action-fact:first-of-type { margin-top: 0; }
|
|
135
|
+
.action-label { color: var(--muted); font-size: 12px; }
|
|
136
|
+
.action-items { margin: 0; padding-left: 18px; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
129
137
|
.kv { display: flex; gap: 16px; flex-wrap: wrap; padding: 8px 12px; font-size: 12px; color: var(--muted); }
|
|
130
138
|
.kv b { color: var(--ink); font-weight: 600; }
|
|
131
139
|
.err { color: var(--bad); }
|
package/ui/workbench/app.js
CHANGED
|
@@ -15,12 +15,24 @@ const PANEL_GROUPS = [
|
|
|
15
15
|
{ key: "collaboration", label: "Review & collaboration", panels: ["review", "comments"] }
|
|
16
16
|
];
|
|
17
17
|
|
|
18
|
+
const NAV = globalThis.CWWorkbenchNavigation;
|
|
19
|
+
if (!NAV) throw new Error("Workbench navigation helper is not available");
|
|
20
|
+
const INSPECTION = globalThis.CWWorkbenchInspection;
|
|
21
|
+
if (!INSPECTION) throw new Error("Workbench inspection helper is not available");
|
|
22
|
+
|
|
18
23
|
// `indexSeq` is a request sequence number: the debounced filter input can
|
|
19
24
|
// start a second /api/index fetch while an older one is still in flight, and
|
|
20
25
|
// only the NEWEST request may render (an old slow response must not
|
|
21
26
|
// overwrite a new fast one). `viewFetchedAt` is when the active run's view
|
|
22
27
|
// was fetched, shown as "as of HH:MM:SS" in the detail header.
|
|
23
|
-
const state = {
|
|
28
|
+
const state = {
|
|
29
|
+
activeRunId: null,
|
|
30
|
+
activeTab: NAV.DEFAULT_TAB,
|
|
31
|
+
currentView: null,
|
|
32
|
+
detailSeq: 0,
|
|
33
|
+
indexSeq: 0,
|
|
34
|
+
viewFetchedAt: null
|
|
35
|
+
};
|
|
24
36
|
|
|
25
37
|
// The host's auth token, read ONCE at startup from the page URL. When
|
|
26
38
|
// CW_WORKBENCH_TOKEN is set on the host, every /api/* request must carry it;
|
|
@@ -28,6 +40,16 @@ const state = { activeRunId: null, activeTab: "graph", indexSeq: 0, viewFetchedA
|
|
|
28
40
|
// and can explain what to do instead of rendering broken.
|
|
29
41
|
const TOKEN = new URLSearchParams(location.search).get("token") || "";
|
|
30
42
|
|
|
43
|
+
function routeUrl(runId, tab) {
|
|
44
|
+
return `${location.pathname}${location.search}${NAV.formatFragment(runId, tab)}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function writeRoute(runId, tab, mode = "push") {
|
|
48
|
+
const fragment = NAV.formatFragment(runId, tab);
|
|
49
|
+
if (location.hash === fragment && mode === "push") return;
|
|
50
|
+
history[mode === "replace" ? "replaceState" : "pushState"](null, "", routeUrl(runId, tab));
|
|
51
|
+
}
|
|
52
|
+
|
|
31
53
|
// Build a request URL with URLSearchParams so the token composes with any
|
|
32
54
|
// other query params (e.g. the index filter's ?text=).
|
|
33
55
|
function apiUrl(pathname, params = {}) {
|
|
@@ -83,14 +105,14 @@ async function loadIndex() {
|
|
|
83
105
|
// A loading placeholder, not a bare wipe: the same pattern the detail
|
|
84
106
|
// pane uses, so the sidebar never flashes empty while the fetch runs.
|
|
85
107
|
list.innerHTML = "";
|
|
86
|
-
list.appendChild(el("li", { class: "muted", text: "loading runs…" }));
|
|
108
|
+
list.appendChild(el("li", { class: "muted", text: "loading runs…", role: "status" }));
|
|
87
109
|
let view;
|
|
88
110
|
try {
|
|
89
111
|
view = await getJson(apiUrl("/api/index", { text: filter }));
|
|
90
112
|
} catch (error) {
|
|
91
113
|
if (seq !== state.indexSeq) return;
|
|
92
114
|
list.innerHTML = "";
|
|
93
|
-
list.appendChild(el("li", { class: "err", text: `failed to load index: ${error.message}
|
|
115
|
+
list.appendChild(el("li", { class: "err", text: `failed to load index: ${error.message}`, role: "alert" }));
|
|
94
116
|
// Clear the stale "registry valid · scope …" line — leaving the last
|
|
95
117
|
// successful freshness next to a load error is itself a stale badge on
|
|
96
118
|
// the one panel whose whole point is freshness.
|
|
@@ -144,7 +166,7 @@ async function loadIndex() {
|
|
|
144
166
|
})
|
|
145
167
|
]);
|
|
146
168
|
btn.addEventListener("click", () => selectRun(record.runId));
|
|
147
|
-
list.appendChild(btn);
|
|
169
|
+
list.appendChild(el("li", { class: "run-entry" }, [btn]));
|
|
148
170
|
}
|
|
149
171
|
}
|
|
150
172
|
|
|
@@ -155,33 +177,40 @@ async function loadIndex() {
|
|
|
155
177
|
function markActiveRow(runId) {
|
|
156
178
|
const list = document.getElementById("run-list");
|
|
157
179
|
for (const btn of list.querySelectorAll("button[data-runid]")) {
|
|
158
|
-
|
|
180
|
+
const active = btn.getAttribute("data-runid") === runId;
|
|
181
|
+
btn.classList.toggle("active", active);
|
|
182
|
+
if (active) btn.setAttribute("aria-current", "true");
|
|
183
|
+
else btn.removeAttribute("aria-current");
|
|
159
184
|
}
|
|
160
185
|
}
|
|
161
186
|
|
|
162
|
-
function selectRun(runId) {
|
|
187
|
+
function selectRun(runId, options = {}) {
|
|
163
188
|
state.activeRunId = runId;
|
|
189
|
+
if (!state.currentView || state.currentView.runId !== runId) state.currentView = null;
|
|
164
190
|
markActiveRow(runId);
|
|
191
|
+
if (options.history !== false) writeRoute(runId, state.activeTab);
|
|
165
192
|
return loadRunDetail(runId);
|
|
166
193
|
}
|
|
167
194
|
|
|
168
195
|
async function loadRunDetail(runId) {
|
|
196
|
+
const seq = ++state.detailSeq;
|
|
169
197
|
const detail = document.getElementById("run-panel");
|
|
170
198
|
detail.innerHTML = "";
|
|
171
|
-
detail.appendChild(el("p", { class: "muted", text: `loading ${runId}
|
|
199
|
+
detail.appendChild(el("p", { class: "muted", text: `loading ${runId}…`, role: "status" }));
|
|
172
200
|
let view;
|
|
173
201
|
try {
|
|
174
202
|
view = await getJson(apiUrl(`/api/run/${encodeURIComponent(runId)}`));
|
|
175
203
|
} catch (error) {
|
|
176
|
-
if (state.activeRunId !== runId) return;
|
|
204
|
+
if (state.activeRunId !== runId || seq !== state.detailSeq) return;
|
|
177
205
|
detail.innerHTML = "";
|
|
178
|
-
detail.appendChild(el("p", { class: "err", text: `failed to load run: ${error.message}
|
|
206
|
+
detail.appendChild(el("p", { class: "err", text: `failed to load run: ${error.message}`, role: "alert" }));
|
|
179
207
|
return;
|
|
180
208
|
}
|
|
181
209
|
// The user may have clicked another run while this fetch was in flight;
|
|
182
210
|
// a stale response must not overwrite the newer selection's render.
|
|
183
|
-
if (state.activeRunId !== runId) return;
|
|
211
|
+
if (state.activeRunId !== runId || seq !== state.detailSeq) return;
|
|
184
212
|
state.viewFetchedAt = new Date();
|
|
213
|
+
state.currentView = view;
|
|
185
214
|
renderRun(view);
|
|
186
215
|
}
|
|
187
216
|
|
|
@@ -198,7 +227,13 @@ function formatClock(date) {
|
|
|
198
227
|
return date ? date.toTimeString().slice(0, 8) : "";
|
|
199
228
|
}
|
|
200
229
|
|
|
201
|
-
function
|
|
230
|
+
function selectTab(tab, options = {}) {
|
|
231
|
+
state.activeTab = NAV.TAB_KEYS.includes(tab) ? tab : NAV.DEFAULT_TAB;
|
|
232
|
+
if (options.history !== false && state.activeRunId) writeRoute(state.activeRunId, state.activeTab);
|
|
233
|
+
if (state.currentView) renderRun(state.currentView, { focusTab: options.focus === true });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function renderRun(view, options = {}) {
|
|
202
237
|
const detail = document.getElementById("run-panel");
|
|
203
238
|
detail.innerHTML = "";
|
|
204
239
|
const header = el("div", { class: "kv" }, [
|
|
@@ -223,14 +258,22 @@ function renderRun(view) {
|
|
|
223
258
|
for (const group of PANEL_GROUPS) {
|
|
224
259
|
const active = state.activeTab === group.key;
|
|
225
260
|
const btn = el("button", {
|
|
261
|
+
id: `workbench-tab-${group.key}`,
|
|
226
262
|
class: `tab ${active ? "active" : ""}`,
|
|
227
263
|
text: group.label,
|
|
228
264
|
role: "tab",
|
|
229
|
-
"aria-
|
|
265
|
+
"aria-controls": `workbench-panel-${group.key}`,
|
|
266
|
+
"aria-selected": active ? "true" : "false",
|
|
267
|
+
tabindex: active ? "0" : "-1"
|
|
268
|
+
});
|
|
269
|
+
btn.addEventListener("click", (event) => {
|
|
270
|
+
selectTab(group.key, { focus: event.detail === 0 });
|
|
230
271
|
});
|
|
231
|
-
btn.addEventListener("
|
|
232
|
-
state.activeTab
|
|
233
|
-
|
|
272
|
+
btn.addEventListener("keydown", (event) => {
|
|
273
|
+
const next = NAV.moveTab(state.activeTab, event.key);
|
|
274
|
+
if (next === state.activeTab && !["Home", "End", "ArrowLeft", "ArrowRight"].includes(event.key)) return;
|
|
275
|
+
event.preventDefault();
|
|
276
|
+
selectTab(next, { focus: true });
|
|
234
277
|
});
|
|
235
278
|
tabs.appendChild(btn);
|
|
236
279
|
}
|
|
@@ -238,6 +281,11 @@ function renderRun(view) {
|
|
|
238
281
|
|
|
239
282
|
const group = PANEL_GROUPS.find((g) => g.key === state.activeTab) || PANEL_GROUPS[0];
|
|
240
283
|
const panels = (view.panels && view.panels[group.key]) || {};
|
|
284
|
+
const tabPanel = el("section", {
|
|
285
|
+
id: `workbench-panel-${group.key}`,
|
|
286
|
+
role: "tabpanel",
|
|
287
|
+
"aria-labelledby": `workbench-tab-${group.key}`
|
|
288
|
+
});
|
|
241
289
|
// Some groups declare two panel names that map to the same capability on
|
|
242
290
|
// the server (e.g. graph's compact/criticalPath both come from
|
|
243
291
|
// summary.show), so they carry byte-identical payloads. Render such a pair
|
|
@@ -252,8 +300,10 @@ function renderRun(view) {
|
|
|
252
300
|
else rendered.push({ names: [name], panel });
|
|
253
301
|
}
|
|
254
302
|
for (const entry of rendered) {
|
|
255
|
-
|
|
303
|
+
tabPanel.appendChild(renderPanel(entry.names.join(" / "), entry.panel));
|
|
256
304
|
}
|
|
305
|
+
detail.appendChild(tabPanel);
|
|
306
|
+
if (options.focusTab) document.getElementById(`workbench-tab-${group.key}`).focus();
|
|
257
307
|
}
|
|
258
308
|
|
|
259
309
|
function renderPanel(name, panel) {
|
|
@@ -265,6 +315,8 @@ function renderPanel(name, panel) {
|
|
|
265
315
|
card.appendChild(head);
|
|
266
316
|
card.appendChild(el("div", { class: "kv" }, [el("span", { class: "src", text: panel.cli }), el("span", { class: "src", text: panel.mcp })]));
|
|
267
317
|
if (panel.status === "present") {
|
|
318
|
+
const actionSummary = renderActionSummary(panel.data);
|
|
319
|
+
if (actionSummary) card.appendChild(actionSummary);
|
|
268
320
|
card.appendChild(renderStructured(panel.data) || el("pre", { class: "json", text: JSON.stringify(panel.data, null, 2) }));
|
|
269
321
|
} else {
|
|
270
322
|
card.appendChild(el("div", { class: "absent-note", text: `absent — ${panel.error || "source unreadable"}` }));
|
|
@@ -272,6 +324,23 @@ function renderPanel(name, panel) {
|
|
|
272
324
|
return card;
|
|
273
325
|
}
|
|
274
326
|
|
|
327
|
+
function renderActionSummary(data) {
|
|
328
|
+
const facts = INSPECTION.actionFacts(data);
|
|
329
|
+
if (facts.length === 0) return null;
|
|
330
|
+
const summary = el("section", { class: "what-matters", "aria-label": "What matters" }, [
|
|
331
|
+
el("h3", { class: "what-matters-title", text: "What matters" })
|
|
332
|
+
]);
|
|
333
|
+
for (const fact of facts) {
|
|
334
|
+
summary.appendChild(
|
|
335
|
+
el("div", { class: `action-fact ${fact.key}` }, [
|
|
336
|
+
el("div", { class: "action-label", text: fact.label }),
|
|
337
|
+
el("ul", { class: "action-items" }, fact.items.map((item) => el("li", { text: item })))
|
|
338
|
+
])
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
return summary;
|
|
342
|
+
}
|
|
343
|
+
|
|
275
344
|
// Purely presentational shape-detection: recognizes the two payload shapes
|
|
276
345
|
// that recur across several capabilities (a nodes/edges graph, and one or
|
|
277
346
|
// more TrustAuditEvent[] arrays) and tables them instead of dumping raw
|
|
@@ -396,8 +465,40 @@ function renderEventGroups(data, confirmedEventKeys) {
|
|
|
396
465
|
return wrap;
|
|
397
466
|
}
|
|
398
467
|
|
|
468
|
+
function showIndexOnly() {
|
|
469
|
+
++state.detailSeq;
|
|
470
|
+
state.currentView = null;
|
|
471
|
+
const detail = document.getElementById("run-panel");
|
|
472
|
+
detail.innerHTML = "";
|
|
473
|
+
detail.appendChild(
|
|
474
|
+
el("p", {
|
|
475
|
+
class: "empty",
|
|
476
|
+
text: "Select a run to inspect its graph, blackboard, worker logs, candidate compare, and audit timeline."
|
|
477
|
+
})
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function applyLocationRoute() {
|
|
482
|
+
const route = NAV.parseFragment(location.hash);
|
|
483
|
+
state.activeRunId = route.runId;
|
|
484
|
+
state.activeTab = route.tab;
|
|
485
|
+
if (route.replace) writeRoute(route.runId, route.tab, "replace");
|
|
486
|
+
markActiveRow(route.runId);
|
|
487
|
+
if (!route.runId) {
|
|
488
|
+
showIndexOnly();
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (state.currentView && state.currentView.runId === route.runId) {
|
|
492
|
+
renderRun(state.currentView);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
state.currentView = null;
|
|
496
|
+
loadRunDetail(route.runId);
|
|
497
|
+
}
|
|
498
|
+
|
|
399
499
|
document.getElementById("refresh").addEventListener("click", refreshAll);
|
|
400
500
|
document.getElementById("filter").addEventListener("input", debounce(loadIndex, 200));
|
|
501
|
+
window.addEventListener("popstate", applyLocationRoute);
|
|
401
502
|
|
|
402
503
|
function debounce(fn, ms) {
|
|
403
504
|
let timer;
|
|
@@ -408,3 +509,4 @@ function debounce(fn, ms) {
|
|
|
408
509
|
}
|
|
409
510
|
|
|
410
511
|
loadIndex();
|
|
512
|
+
applyLocationRoute();
|
package/ui/workbench/index.html
CHANGED
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
<p class="empty">Select a run to inspect its graph, blackboard, worker logs, candidate compare, and audit timeline.</p>
|
|
28
28
|
</section>
|
|
29
29
|
</main>
|
|
30
|
+
<script src="/ui/navigation.js"></script>
|
|
31
|
+
<script src="/ui/inspection.js"></script>
|
|
30
32
|
<script src="/ui/app.js"></script>
|
|
31
33
|
</body>
|
|
32
34
|
</html>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Pure action-first projection. It copies a small set of facts from one
|
|
3
|
+
// capability payload for display; it makes no new state, rank, or action.
|
|
4
|
+
|
|
5
|
+
(function expose(root, factory) {
|
|
6
|
+
const inspection = factory();
|
|
7
|
+
if (typeof module === "object" && module.exports) module.exports = inspection;
|
|
8
|
+
if (root) root.CWWorkbenchInspection = inspection;
|
|
9
|
+
})(typeof globalThis === "object" ? globalThis : this, function buildInspection() {
|
|
10
|
+
function compactValue(value) {
|
|
11
|
+
if (typeof value === "string") return value;
|
|
12
|
+
const encoded = JSON.stringify(value);
|
|
13
|
+
return typeof encoded === "string" ? encoded : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function arrayFact(data, key, label, emptyMeansNone) {
|
|
17
|
+
if (!Array.isArray(data[key])) return null;
|
|
18
|
+
const items = data[key].map(compactValue).filter((item) => item !== null);
|
|
19
|
+
if (items.length === 0 && emptyMeansNone) items.push("none");
|
|
20
|
+
return items.length > 0 ? { key, label, items } : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function actionFacts(data) {
|
|
24
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) return [];
|
|
25
|
+
const facts = [];
|
|
26
|
+
const integrity = data.integrity;
|
|
27
|
+
if (integrity && typeof integrity === "object" && !Array.isArray(integrity)) {
|
|
28
|
+
const items = [];
|
|
29
|
+
if (typeof integrity.verified === "boolean") items.push(`verified: ${integrity.verified}`);
|
|
30
|
+
if (typeof integrity.eventCount === "number" && Number.isFinite(integrity.eventCount)) items.push(`event count: ${integrity.eventCount}`);
|
|
31
|
+
if (typeof integrity.corruptLines === "number" && Number.isFinite(integrity.corruptLines)) items.push(`corrupt lines: ${integrity.corruptLines}`);
|
|
32
|
+
if (items.length > 0) facts.push({ key: "integrity", label: "integrity", items });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const problems = arrayFact(data, "problems", "problems", true);
|
|
36
|
+
if (problems) facts.push(problems);
|
|
37
|
+
const missing = arrayFact(data, "missingEvidence", "missing evidence", true);
|
|
38
|
+
if (missing) facts.push(missing);
|
|
39
|
+
|
|
40
|
+
if (typeof data.nextAction === "string" && data.nextAction.length > 0) {
|
|
41
|
+
facts.push({ key: "nextAction", label: "next action", items: [data.nextAction] });
|
|
42
|
+
}
|
|
43
|
+
if (Array.isArray(data.nextActions)) {
|
|
44
|
+
const items = data.nextActions.filter((item) => typeof item === "string" && item.length > 0);
|
|
45
|
+
if (items.length > 0) facts.push({ key: "nextActions", label: "next actions", items });
|
|
46
|
+
}
|
|
47
|
+
return facts;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { actionFacts };
|
|
51
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Pure Workbench navigation policy. The browser page and Node unit test both
|
|
3
|
+
// use this file; it holds no run data and does no file or network work.
|
|
4
|
+
|
|
5
|
+
(function expose(root, factory) {
|
|
6
|
+
const navigation = factory();
|
|
7
|
+
if (typeof module === "object" && module.exports) module.exports = navigation;
|
|
8
|
+
if (root) root.CWWorkbenchNavigation = navigation;
|
|
9
|
+
})(typeof globalThis === "object" ? globalThis : this, function buildNavigation() {
|
|
10
|
+
const TAB_KEYS = ["graph", "blackboard", "worker", "candidate", "audit", "metrics", "collaboration"];
|
|
11
|
+
const DEFAULT_TAB = TAB_KEYS[0];
|
|
12
|
+
|
|
13
|
+
function knownTab(value) {
|
|
14
|
+
return TAB_KEYS.includes(value) ? value : DEFAULT_TAB;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseFragment(fragment) {
|
|
18
|
+
const source = typeof fragment === "string" && fragment.startsWith("#") ? fragment.slice(1) : fragment || "";
|
|
19
|
+
const params = new URLSearchParams(source);
|
|
20
|
+
const run = params.get("run");
|
|
21
|
+
const requestedTab = params.get("tab");
|
|
22
|
+
return {
|
|
23
|
+
runId: run ? run : null,
|
|
24
|
+
tab: knownTab(requestedTab),
|
|
25
|
+
replace: requestedTab !== null && !TAB_KEYS.includes(requestedTab),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function formatFragment(runId, tab) {
|
|
30
|
+
if (!runId) return "";
|
|
31
|
+
return `#run=${encodeURIComponent(runId)}&tab=${encodeURIComponent(knownTab(tab))}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function moveTab(current, key) {
|
|
35
|
+
const index = Math.max(0, TAB_KEYS.indexOf(knownTab(current)));
|
|
36
|
+
if (key === "Home") return TAB_KEYS[0];
|
|
37
|
+
if (key === "End") return TAB_KEYS[TAB_KEYS.length - 1];
|
|
38
|
+
if (key === "ArrowLeft") return TAB_KEYS[(index - 1 + TAB_KEYS.length) % TAB_KEYS.length];
|
|
39
|
+
if (key === "ArrowRight") return TAB_KEYS[(index + 1) % TAB_KEYS.length];
|
|
40
|
+
return TAB_KEYS[index];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { DEFAULT_TAB, TAB_KEYS, formatFragment, moveTab, parseFragment };
|
|
44
|
+
});
|