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
package/ui/workbench/app.js
CHANGED
|
@@ -15,7 +15,52 @@ const PANEL_GROUPS = [
|
|
|
15
15
|
{ key: "collaboration", label: "Review & collaboration", panels: ["review", "comments"] }
|
|
16
16
|
];
|
|
17
17
|
|
|
18
|
-
const
|
|
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
|
+
|
|
23
|
+
// `indexSeq` is a request sequence number: the debounced filter input can
|
|
24
|
+
// start a second /api/index fetch while an older one is still in flight, and
|
|
25
|
+
// only the NEWEST request may render (an old slow response must not
|
|
26
|
+
// overwrite a new fast one). `viewFetchedAt` is when the active run's view
|
|
27
|
+
// was fetched, shown as "as of HH:MM:SS" in the detail header.
|
|
28
|
+
const state = {
|
|
29
|
+
activeRunId: null,
|
|
30
|
+
activeTab: NAV.DEFAULT_TAB,
|
|
31
|
+
currentView: null,
|
|
32
|
+
detailSeq: 0,
|
|
33
|
+
indexSeq: 0,
|
|
34
|
+
viewFetchedAt: null
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// The host's auth token, read ONCE at startup from the page URL. When
|
|
38
|
+
// CW_WORKBENCH_TOKEN is set on the host, every /api/* request must carry it;
|
|
39
|
+
// the static UI files themselves are served without it, so the page loads
|
|
40
|
+
// and can explain what to do instead of rendering broken.
|
|
41
|
+
const TOKEN = new URLSearchParams(location.search).get("token") || "";
|
|
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
|
+
|
|
53
|
+
// Build a request URL with URLSearchParams so the token composes with any
|
|
54
|
+
// other query params (e.g. the index filter's ?text=).
|
|
55
|
+
function apiUrl(pathname, params = {}) {
|
|
56
|
+
const search = new URLSearchParams();
|
|
57
|
+
for (const [key, value] of Object.entries(params)) {
|
|
58
|
+
if (value) search.set(key, value);
|
|
59
|
+
}
|
|
60
|
+
if (TOKEN) search.set("token", TOKEN);
|
|
61
|
+
const query = search.toString();
|
|
62
|
+
return query ? `${pathname}?${query}` : pathname;
|
|
63
|
+
}
|
|
19
64
|
|
|
20
65
|
async function getJson(url) {
|
|
21
66
|
const res = await fetch(url, { headers: { Accept: "application/json" } });
|
|
@@ -24,7 +69,10 @@ async function getJson(url) {
|
|
|
24
69
|
try {
|
|
25
70
|
body = JSON.parse(text);
|
|
26
71
|
} catch {
|
|
27
|
-
throw new Error(`non-JSON response (${res.status})`);
|
|
72
|
+
throw new Error(`non-JSON response (${res.status}): ${text.slice(0, 120)}`);
|
|
73
|
+
}
|
|
74
|
+
if (res.status === 401) {
|
|
75
|
+
throw new Error("unauthorized — reopen as /?token=<your CW_WORKBENCH_TOKEN value>");
|
|
28
76
|
}
|
|
29
77
|
if (!res.ok) throw new Error(body && body.error ? body.error : `HTTP ${res.status}`);
|
|
30
78
|
return body;
|
|
@@ -43,34 +91,71 @@ function el(tag, attrs = {}, children = []) {
|
|
|
43
91
|
return node;
|
|
44
92
|
}
|
|
45
93
|
|
|
46
|
-
function freshnessBadge(value) {
|
|
94
|
+
function freshnessBadge(value, title) {
|
|
47
95
|
const v = String(value || "").toLowerCase();
|
|
48
|
-
|
|
96
|
+
const attrs = { class: `badge ${v || "absent"}`, text: value || "unknown" };
|
|
97
|
+
if (title) attrs.title = title;
|
|
98
|
+
return el("span", attrs);
|
|
49
99
|
}
|
|
50
100
|
|
|
51
101
|
async function loadIndex() {
|
|
102
|
+
const seq = ++state.indexSeq;
|
|
52
103
|
const filter = document.getElementById("filter").value.trim();
|
|
53
104
|
const list = document.getElementById("run-list");
|
|
105
|
+
// A loading placeholder, not a bare wipe: the same pattern the detail
|
|
106
|
+
// pane uses, so the sidebar never flashes empty while the fetch runs.
|
|
54
107
|
list.innerHTML = "";
|
|
108
|
+
list.appendChild(el("li", { class: "muted", text: "loading runs…", role: "status" }));
|
|
55
109
|
let view;
|
|
56
110
|
try {
|
|
57
|
-
view = await getJson(
|
|
111
|
+
view = await getJson(apiUrl("/api/index", { text: filter }));
|
|
58
112
|
} catch (error) {
|
|
59
|
-
|
|
113
|
+
if (seq !== state.indexSeq) return;
|
|
114
|
+
list.innerHTML = "";
|
|
115
|
+
list.appendChild(el("li", { class: "err", text: `failed to load index: ${error.message}`, role: "alert" }));
|
|
116
|
+
// Clear the stale "registry valid · scope …" line — leaving the last
|
|
117
|
+
// successful freshness next to a load error is itself a stale badge on
|
|
118
|
+
// the one panel whose whole point is freshness.
|
|
119
|
+
const fresh = document.getElementById("registry-freshness");
|
|
120
|
+
fresh.innerHTML = "";
|
|
121
|
+
fresh.append("registry ", freshnessBadge("unavailable"), " · index unreachable");
|
|
60
122
|
return;
|
|
61
123
|
}
|
|
124
|
+
// Only the newest request may render (see state.indexSeq).
|
|
125
|
+
if (seq !== state.indexSeq) return;
|
|
126
|
+
list.innerHTML = "";
|
|
62
127
|
const reg = view.registry || {};
|
|
63
128
|
const fresh = document.getElementById("registry-freshness");
|
|
64
129
|
fresh.innerHTML = "";
|
|
65
|
-
|
|
66
|
-
const
|
|
130
|
+
const regStatus = reg.freshness && reg.freshness.status;
|
|
131
|
+
const regTitle = String(regStatus || "").toLowerCase() === "absent"
|
|
132
|
+
? "no home registry data yet — runs made in this repo still show; `cw registry refresh` builds it"
|
|
133
|
+
: undefined;
|
|
134
|
+
fresh.append("registry ", freshnessBadge(regStatus, regTitle), ` · scope ${view.scope}`);
|
|
135
|
+
const runs = view.runs || {};
|
|
136
|
+
const records = runs.records || [];
|
|
67
137
|
if (!records.length) {
|
|
68
|
-
list.appendChild(el("li", { class: "muted",
|
|
138
|
+
list.appendChild(el("li", { class: "muted" }, [
|
|
139
|
+
el("div", { text: "no runs indexed in this scope" }),
|
|
140
|
+
el("div", { class: "hint", text: 'create one with: cw quickstart <app> --repo <path> --question "..."' })
|
|
141
|
+
]));
|
|
69
142
|
return;
|
|
70
143
|
}
|
|
71
|
-
|
|
144
|
+
// Tell the user when the page is only part of the run set (the server
|
|
145
|
+
// returns the newest page but caps it) — otherwise a scope with more runs
|
|
146
|
+
// than the page size silently hides the rest, and someone checking "did my
|
|
147
|
+
// run get created" is misled into thinking it doesn't exist.
|
|
148
|
+
if (typeof runs.total === "number" && runs.total > records.length) {
|
|
149
|
+
list.appendChild(el("li", { class: "muted hint", text: `showing latest ${records.length} of ${runs.total} runs` }));
|
|
150
|
+
}
|
|
151
|
+
// The server returns the page sorted oldest-first; show newest at the top.
|
|
152
|
+
for (const record of [...records].reverse()) {
|
|
72
153
|
const lifecycle = record.lifecycle || record.status || "";
|
|
73
|
-
|
|
154
|
+
// A real <button>, not a bare <li> with a click listener: Tab reaches
|
|
155
|
+
// it, Enter/Space activate it, and it gets a focus ring for free — no
|
|
156
|
+
// extra ARIA needed. Same CSS classes as before so the row/card look
|
|
157
|
+
// is unchanged (see app.css .run-list button rules).
|
|
158
|
+
const btn = el("button", { type: "button", "data-runid": record.runId, class: state.activeRunId === record.runId ? "active" : "" }, [
|
|
74
159
|
el("div", { class: "rid" }, [
|
|
75
160
|
el("span", { class: `status-dot ${lifecycle}`, title: lifecycle || "unknown" }),
|
|
76
161
|
document.createTextNode(record.runId)
|
|
@@ -80,44 +165,115 @@ async function loadIndex() {
|
|
|
80
165
|
text: [record.appId || record.workflowId, lifecycle, record.repo].filter(Boolean).join(" · ")
|
|
81
166
|
})
|
|
82
167
|
]);
|
|
83
|
-
|
|
84
|
-
list.appendChild(li);
|
|
168
|
+
btn.addEventListener("click", () => selectRun(record.runId));
|
|
169
|
+
list.appendChild(el("li", { class: "run-entry" }, [btn]));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Move the "active" highlight to the chosen run's button WITHOUT rebuilding
|
|
174
|
+
// the list. Rebuilding (the old `loadIndex()` call in selectRun) removed the
|
|
175
|
+
// very <button> the user had just clicked/Entered, dropping keyboard focus to
|
|
176
|
+
// <body> and flashing the whole sidebar on every selection.
|
|
177
|
+
function markActiveRow(runId) {
|
|
178
|
+
const list = document.getElementById("run-list");
|
|
179
|
+
for (const btn of list.querySelectorAll("button[data-runid]")) {
|
|
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");
|
|
85
184
|
}
|
|
86
185
|
}
|
|
87
186
|
|
|
88
|
-
|
|
187
|
+
function selectRun(runId, options = {}) {
|
|
89
188
|
state.activeRunId = runId;
|
|
90
|
-
|
|
189
|
+
if (!state.currentView || state.currentView.runId !== runId) state.currentView = null;
|
|
190
|
+
markActiveRow(runId);
|
|
191
|
+
if (options.history !== false) writeRoute(runId, state.activeTab);
|
|
192
|
+
return loadRunDetail(runId);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function loadRunDetail(runId) {
|
|
196
|
+
const seq = ++state.detailSeq;
|
|
91
197
|
const detail = document.getElementById("run-panel");
|
|
92
198
|
detail.innerHTML = "";
|
|
93
|
-
detail.appendChild(el("p", { class: "muted", text: `loading ${runId}
|
|
199
|
+
detail.appendChild(el("p", { class: "muted", text: `loading ${runId}…`, role: "status" }));
|
|
94
200
|
let view;
|
|
95
201
|
try {
|
|
96
|
-
view = await getJson(`/api/run/${encodeURIComponent(runId)}`);
|
|
202
|
+
view = await getJson(apiUrl(`/api/run/${encodeURIComponent(runId)}`));
|
|
97
203
|
} catch (error) {
|
|
204
|
+
if (state.activeRunId !== runId || seq !== state.detailSeq) return;
|
|
98
205
|
detail.innerHTML = "";
|
|
99
|
-
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" }));
|
|
100
207
|
return;
|
|
101
208
|
}
|
|
209
|
+
// The user may have clicked another run while this fetch was in flight;
|
|
210
|
+
// a stale response must not overwrite the newer selection's render.
|
|
211
|
+
if (state.activeRunId !== runId || seq !== state.detailSeq) return;
|
|
212
|
+
state.viewFetchedAt = new Date();
|
|
213
|
+
state.currentView = view;
|
|
102
214
|
renderRun(view);
|
|
103
215
|
}
|
|
104
216
|
|
|
105
|
-
|
|
217
|
+
// The refresh button re-derives BOTH panes from disk: the sidebar index AND
|
|
218
|
+
// the currently-open run's detail. Refreshing only the index (the old
|
|
219
|
+
// behavior) left the detail header's lifecycle badge showing stale state
|
|
220
|
+
// while the sidebar dot next to it had already changed.
|
|
221
|
+
function refreshAll() {
|
|
222
|
+
loadIndex();
|
|
223
|
+
if (state.activeRunId) loadRunDetail(state.activeRunId);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function formatClock(date) {
|
|
227
|
+
return date ? date.toTimeString().slice(0, 8) : "";
|
|
228
|
+
}
|
|
229
|
+
|
|
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 = {}) {
|
|
106
237
|
const detail = document.getElementById("run-panel");
|
|
107
238
|
detail.innerHTML = "";
|
|
108
239
|
const header = el("div", { class: "kv" }, [
|
|
109
240
|
el("span", {}, [el("b", { text: "run " }), document.createTextNode(view.runId)]),
|
|
110
241
|
el("span", {}, [document.createTextNode("resolved "), freshnessBadge(view.resolved ? "valid" : "missing")])
|
|
111
242
|
]);
|
|
243
|
+
if (view.lifecycle) {
|
|
244
|
+
header.appendChild(el("span", {}, [document.createTextNode("lifecycle "), freshnessBadge(view.lifecycle)]));
|
|
245
|
+
}
|
|
246
|
+
if (state.viewFetchedAt) {
|
|
247
|
+
header.appendChild(el("span", { class: "muted", text: `as of ${formatClock(state.viewFetchedAt)}` }));
|
|
248
|
+
}
|
|
112
249
|
if (view.error) header.appendChild(el("span", { class: "err", text: view.error }));
|
|
113
250
|
detail.appendChild(header);
|
|
251
|
+
if (view.lifecycle === "blocked" || view.lifecycle === "failed") {
|
|
252
|
+
detail.appendChild(
|
|
253
|
+
el("p", { class: "recovery-hint", text: `${view.lifecycle} — run 'cw run status ${view.runId}' or 'cw doctor' for next steps` })
|
|
254
|
+
);
|
|
255
|
+
}
|
|
114
256
|
|
|
115
|
-
const tabs = el("div", { class: "tabs" });
|
|
257
|
+
const tabs = el("div", { class: "tabs", role: "tablist" });
|
|
116
258
|
for (const group of PANEL_GROUPS) {
|
|
117
|
-
const
|
|
118
|
-
btn
|
|
119
|
-
|
|
120
|
-
|
|
259
|
+
const active = state.activeTab === group.key;
|
|
260
|
+
const btn = el("button", {
|
|
261
|
+
id: `workbench-tab-${group.key}`,
|
|
262
|
+
class: `tab ${active ? "active" : ""}`,
|
|
263
|
+
text: group.label,
|
|
264
|
+
role: "tab",
|
|
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 });
|
|
271
|
+
});
|
|
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 });
|
|
121
277
|
});
|
|
122
278
|
tabs.appendChild(btn);
|
|
123
279
|
}
|
|
@@ -125,10 +281,29 @@ function renderRun(view) {
|
|
|
125
281
|
|
|
126
282
|
const group = PANEL_GROUPS.find((g) => g.key === state.activeTab) || PANEL_GROUPS[0];
|
|
127
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
|
+
});
|
|
289
|
+
// Some groups declare two panel names that map to the same capability on
|
|
290
|
+
// the server (e.g. graph's compact/criticalPath both come from
|
|
291
|
+
// summary.show), so they carry byte-identical payloads. Render such a pair
|
|
292
|
+
// ONCE under a merged label instead of showing the same JSON twice under
|
|
293
|
+
// two names that promise distinct views.
|
|
294
|
+
const rendered = [];
|
|
128
295
|
for (const name of group.panels) {
|
|
129
296
|
const panel = panels[name];
|
|
130
|
-
if (panel)
|
|
297
|
+
if (!panel) continue;
|
|
298
|
+
const twin = rendered.find((entry) => JSON.stringify(entry.panel) === JSON.stringify(panel));
|
|
299
|
+
if (twin) twin.names.push(name);
|
|
300
|
+
else rendered.push({ names: [name], panel });
|
|
301
|
+
}
|
|
302
|
+
for (const entry of rendered) {
|
|
303
|
+
tabPanel.appendChild(renderPanel(entry.names.join(" / "), entry.panel));
|
|
131
304
|
}
|
|
305
|
+
detail.appendChild(tabPanel);
|
|
306
|
+
if (options.focusTab) document.getElementById(`workbench-tab-${group.key}`).focus();
|
|
132
307
|
}
|
|
133
308
|
|
|
134
309
|
function renderPanel(name, panel) {
|
|
@@ -140,6 +315,8 @@ function renderPanel(name, panel) {
|
|
|
140
315
|
card.appendChild(head);
|
|
141
316
|
card.appendChild(el("div", { class: "kv" }, [el("span", { class: "src", text: panel.cli }), el("span", { class: "src", text: panel.mcp })]));
|
|
142
317
|
if (panel.status === "present") {
|
|
318
|
+
const actionSummary = renderActionSummary(panel.data);
|
|
319
|
+
if (actionSummary) card.appendChild(actionSummary);
|
|
143
320
|
card.appendChild(renderStructured(panel.data) || el("pre", { class: "json", text: JSON.stringify(panel.data, null, 2) }));
|
|
144
321
|
} else {
|
|
145
322
|
card.appendChild(el("div", { class: "absent-note", text: `absent — ${panel.error || "source unreadable"}` }));
|
|
@@ -147,6 +324,23 @@ function renderPanel(name, panel) {
|
|
|
147
324
|
return card;
|
|
148
325
|
}
|
|
149
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
|
+
|
|
150
344
|
// Purely presentational shape-detection: recognizes the two payload shapes
|
|
151
345
|
// that recur across several capabilities (a nodes/edges graph, and one or
|
|
152
346
|
// more TrustAuditEvent[] arrays) and tables them instead of dumping raw
|
|
@@ -173,17 +367,27 @@ function renderStructured(data) {
|
|
|
173
367
|
return null;
|
|
174
368
|
}
|
|
175
369
|
|
|
370
|
+
// A semantically-correct data table: header cells in a <thead> with
|
|
371
|
+
// scope="col", body rows in a <tbody> — screen readers can then associate
|
|
372
|
+
// each cell with its column header.
|
|
373
|
+
function structTable(headers) {
|
|
374
|
+
const table = el("table", { class: "struct-table" }, [
|
|
375
|
+
el("thead", {}, [el("tr", {}, headers.map((h) => el("th", { scope: "col", text: h })))])
|
|
376
|
+
]);
|
|
377
|
+
const tbody = el("tbody");
|
|
378
|
+
table.appendChild(tbody);
|
|
379
|
+
return { table, tbody };
|
|
380
|
+
}
|
|
381
|
+
|
|
176
382
|
function renderGraph(data) {
|
|
177
383
|
const wrap = el("div");
|
|
178
384
|
const nodesBlock = el("div", { class: "struct-block" }, [el("div", { class: "struct-title", text: `nodes (${data.nodes.length})` })]);
|
|
179
385
|
if (data.nodes.length === 0) {
|
|
180
386
|
nodesBlock.appendChild(el("div", { class: "struct-empty", text: "none" }));
|
|
181
387
|
} else {
|
|
182
|
-
const table =
|
|
183
|
-
el("tr", {}, ["id", "kind", "status", "label"].map((h) => el("th", { text: h })))
|
|
184
|
-
]);
|
|
388
|
+
const { table, tbody } = structTable(["id", "kind", "status", "label"]);
|
|
185
389
|
for (const node of data.nodes) {
|
|
186
|
-
|
|
390
|
+
tbody.appendChild(
|
|
187
391
|
el("tr", {}, [
|
|
188
392
|
el("td", { text: node.id }),
|
|
189
393
|
el("td", { text: node.kind }),
|
|
@@ -235,11 +439,9 @@ function renderEventGroups(data, confirmedEventKeys) {
|
|
|
235
439
|
for (const key of eventKeys) {
|
|
236
440
|
const events = [...data[key]].sort((a, b) => String(a.createdAt || "").localeCompare(String(b.createdAt || "")));
|
|
237
441
|
const block = el("div", { class: "struct-block" }, [el("div", { class: "struct-title", text: `${humanizeKey(key)} (${events.length})` })]);
|
|
238
|
-
const table =
|
|
239
|
-
el("tr", {}, ["time", "kind", "decision", "source", "actor"].map((h) => el("th", { text: h })))
|
|
240
|
-
]);
|
|
442
|
+
const { table, tbody } = structTable(["time", "kind", "decision", "source", "actor"]);
|
|
241
443
|
for (const event of events) {
|
|
242
|
-
|
|
444
|
+
tbody.appendChild(
|
|
243
445
|
el("tr", {}, [
|
|
244
446
|
el("td", { text: event.createdAt || "" }),
|
|
245
447
|
el("td", { text: event.kind || "" }),
|
|
@@ -263,8 +465,40 @@ function renderEventGroups(data, confirmedEventKeys) {
|
|
|
263
465
|
return wrap;
|
|
264
466
|
}
|
|
265
467
|
|
|
266
|
-
|
|
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
|
+
|
|
499
|
+
document.getElementById("refresh").addEventListener("click", refreshAll);
|
|
267
500
|
document.getElementById("filter").addEventListener("input", debounce(loadIndex, 200));
|
|
501
|
+
window.addEventListener("popstate", applyLocationRoute);
|
|
268
502
|
|
|
269
503
|
function debounce(fn, ms) {
|
|
270
504
|
let timer;
|
|
@@ -275,3 +509,4 @@ function debounce(fn, ms) {
|
|
|
275
509
|
}
|
|
276
510
|
|
|
277
511
|
loadIndex();
|
|
512
|
+
applyLocationRoute();
|
package/ui/workbench/index.html
CHANGED
|
@@ -16,10 +16,10 @@
|
|
|
16
16
|
<main>
|
|
17
17
|
<section id="index-panel" class="sidebar">
|
|
18
18
|
<div class="bar">
|
|
19
|
-
<
|
|
19
|
+
<h2>Runs</h2>
|
|
20
20
|
<button id="refresh" title="Re-derive from disk">refresh</button>
|
|
21
21
|
</div>
|
|
22
|
-
<input id="filter" type="search" placeholder="filter runs (app/status/text)" />
|
|
22
|
+
<input id="filter" type="search" placeholder="filter runs (app/status/text)" aria-label="filter runs" />
|
|
23
23
|
<div id="registry-freshness" class="freshness"></div>
|
|
24
24
|
<ul id="run-list" class="run-list"></ul>
|
|
25
25
|
</section>
|
|
@@ -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
|
+
});
|