Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 185 additions & 10 deletions .claude/scripts/cockpit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ function moduleLabelsOf(issue) {
return (issue.labels || []).map((l) => l.name).filter((n) => typeof n === "string" && n.startsWith("module:"));
}

// ---- Live worker progress section (issue #52) -----------------------------
// ---- Live worker progress section (issue #52, grouped by task in #92) -----
// Derive the CURRENT state per worker keyed by (role, task): keep the LATEST
// event (by file order, i.e. append order) per key. No event log, or an
// empty one, renders a muted "no active workers" placeholder — never a
Expand All @@ -366,6 +366,63 @@ function phaseBadge(phase) {
default: return { cls: "muted" };
}
}

// Normalize a task id into a group key (issue #92): "88", "issue-88", and
// "issue-70-worker-inspector" must all collapse to the SAME group, so pull
// out the first run of digits found anywhere in the id (covers a bare
// number, an "issue-N" prefix, or an "N<suffix>" variant like "52b" from a
// second lens/attempt on the same task). Task ids with no digits at all
// group under their own raw id, per the instructions' degrade path.
function taskGroupKey(taskRaw) {
const s = taskRaw == null ? "" : String(taskRaw);
const m = s.match(/\d+/);
if (m) {
const num = parseInt(m[0], 10);
return { key: String(num), num };
}
return { key: s || "(unknown)", num: null };
}

function issueByNumber(n) {
return issues.find((i) => i.number === n) || null;
}

// Link a task group's header to the real GitHub issue when we have it (the
// issues array is the same open-issues fetch renderIssues() uses); degrade
// to plain "#N" text (no href) when the issue isn't in that list, mirroring
// refLink()'s own degrade contract above.
function taskIssueLink(n) {
const issue = issueByNumber(n);
return issue ? `<a href="${esc(issue.url || "#")}">#${n}</a>` : `#${n}`;
}

// Associate a PR with an issue number by the repo's branch-name convention
// (feat/issue-<N>-..., fix/issue-<N>-..., or a bare issue-<N>-... branch),
// falling back to a "#N" mention in the PR title. Reuses the SAME `prs`
// array renderPRs() already fetched (issue #92 asks us not to add a second
// gh call) — that fetch is `--state open` only, so every match found here IS
// currently open; `pr.state` is checked defensively in prBadge() below in
// case a future schema change ever adds it (e.g. a merged-PR fetch), without
// requiring one now.
function findPRForIssue(n) {
if (n == null || !Number.isFinite(n)) return null;
for (const pr of prs) {
const branch = String(pr.headRefName || "");
const m = branch.match(/issue-(\d+)(?:[-_]|$)/i);
if (m && parseInt(m[1], 10) === n) return pr;
}
const titleRe = new RegExp("#" + n + "\\b");
for (const pr of prs) {
if (titleRe.test(String(pr.title || ""))) return pr;
}
return null;
}
function prBadge(pr) {
const state = pr.state === "MERGED" ? "merged" : "open";
const cls = state === "merged" ? "good" : "warn";
return `<a href="${esc(pr.url || "#")}">#${pr.number}</a> <span class="badge ${cls}">${esc(state)}</span>`;
}

function renderLiveProgress() {
const latest = new Map(); // "role\u0000task" -> event
for (const ev of events) {
Expand All @@ -379,16 +436,65 @@ function renderLiveProgress() {
if (workers.length === 0) {
html += `<p class="muted">no active workers</p>`;
} else {
html += `<table class="routing"><thead><tr><th>Role</th><th>Task</th><th>Model</th><th>Phase</th><th>Lens</th><th>Updated</th><th hidden></th></tr></thead><tbody>`;
// Group workers by normalized task key (issue #92) so every worker
// (orchestrator/implementer/reviewer x lens) working the same task
// renders together under one linked header, instead of one flat row per
// (role, task) pair.
const groups = new Map(); // groupKey -> { num, workers: [] }
for (const w of workers) {
const badge = phaseBadge(w.phase);
html += `<tr><td>${esc(w.role)}</td><td>${esc(w.task)}</td><td><code>${esc(w.model || "(none)")}</code></td>`;
html += `<td><span class="badge ${badge.cls}">${esc(w.phase || "(unknown)")}</span></td>`;
html += `<td>${esc(w.lens || "")}</td><td>${esc(w.ts)}</td>`;
// Hidden trailing cell: stable data-role/data-task hook for a FUTURE
// worker inspector (issue 3b). Appended AFTER every column the
// existing tests exact-match, so it never disturbs them.
html += `<td class="wrow-meta" data-role="${esc(w.role)}" data-task="${esc(w.task)}" hidden></td></tr>`;
const g = taskGroupKey(w.task);
if (!groups.has(g.key)) groups.set(g.key, { num: g.num, workers: [] });
groups.get(g.key).workers.push(w);
}
// "Newest task first": numeric groups sorted by issue number descending
// (higher issue number == more recently filed task); unparseable groups
// (no leading number) sort after all numeric ones, alphabetically.
const groupKeys = [...groups.keys()].sort((a, b) => {
const ga = groups.get(a), gb = groups.get(b);
if (ga.num != null && gb.num != null) return gb.num - ga.num;
if (ga.num != null) return -1;
if (gb.num != null) return 1;
return a.localeCompare(b);
});

// Column headers carry data-sort-key hooks for the client-side sort
// script appended near the end of <body>. The hidden trailing <th>
// mirrors the hidden per-row wrow-meta cell below and is left exactly as
// it was (no sort hook — it has no visible text to sort by).
html += `<table class="routing"><thead><tr>`;
html += `<th data-sort-key="role">Role</th><th data-sort-key="task">Task</th>`;
html += `<th data-sort-key="model">Model</th><th data-sort-key="phase">Phase</th>`;
html += `<th data-sort-key="lens">Lens</th><th data-sort-key="updated">Updated</th><th hidden></th>`;
html += `</tr></thead><tbody>`;
for (const key of groupKeys) {
const g = groups.get(key);
let header = g.num != null ? `Task ${taskIssueLink(g.num)}` : `Task ${esc(key)}`;
if (g.num != null) {
const issue = issueByNumber(g.num);
if (issue && issue.title) header += ` <span class="muted">${esc(issue.title)}</span>`;
const pr = findPRForIssue(g.num);
if (pr) header += ` &middot; PR ${prBadge(pr)}`;
}
// Group-header row: a full-width <td colspan> so it never collides
// with the "<tr><td>" pattern a plain worker row starts with (tests
// and the client sort script both rely on being able to tell the two
// apart) — it uses <tr class="task-group"> instead of a bare <tr>.
html += `<tr class="task-group"><td colspan="7"><strong>${header}</strong></td></tr>`;
const rows = g.workers.slice().sort((a, b) => {
const ar = String(a.role || ""), br = String(b.role || "");
if (ar !== br) return ar.localeCompare(br);
return String(a.task || "").localeCompare(String(b.task || ""));
});
for (const w of rows) {
const badge = phaseBadge(w.phase);
html += `<tr><td>${esc(w.role)}</td><td>${esc(w.task)}</td><td><code>${esc(w.model || "(none)")}</code></td>`;
html += `<td><span class="badge ${badge.cls}">${esc(w.phase || "(unknown)")}</span></td>`;
html += `<td>${esc(w.lens || "")}</td><td>${esc(w.ts)}</td>`;
// Hidden trailing cell: stable data-role/data-task hook for the
// worker inspector (issue #70). Appended AFTER every column the
// existing tests exact-match, so it never disturbs them.
html += `<td class="wrow-meta" data-role="${esc(w.role)}" data-task="${esc(w.task)}" hidden></td></tr>`;
}
}
html += `</tbody></table>`;
}
Expand Down Expand Up @@ -640,6 +746,11 @@ const html = `<!doctype html>
.unavailable { color: var(--bad-fg); font-style: italic; }
table.routing { border-collapse: collapse; width: 100%; margin: 0.5rem 0 1rem; }
table.routing th, table.routing td { border: 1px solid var(--border); padding: 0.3rem 0.6rem; text-align: left; font-size: 0.9rem; }
table.routing tr.task-group td { background: var(--muted-bg); }
table.routing thead th[data-sort-key] { cursor: pointer; user-select: none; }
table.routing thead th[data-sort-key]:hover { color: var(--link); }
table.routing thead th[data-sort-dir="asc"]::after { content: " \\25B2"; }
table.routing thead th[data-sort-dir="desc"]::after { content: " \\25BC"; }
code { background: var(--code-bg); padding: 0.05rem 0.3rem; border-radius: 3px; }
a { color: var(--link); }
</style>
Expand Down Expand Up @@ -699,6 +810,70 @@ ${renderWorktrees()}
});
} catch (e) { /* inert if the DOM is unavailable */ }
})();
// Sortable live-progress columns (issue #92): clicking a Role/Task/Model/
// Phase/Lens/Updated header toggles asc/desc, sorting rows WITHIN each
// task-group (the "<tr class=\"task-group\">" header rows themselves never
// move) so the grouping stays coherent no matter which column is sorted.
// Pure client-side (compares each row's own <td> textContent, never touches
// the server-rendered hidden wrow-meta cell), inert if #live's table is
// absent (e.g. the "no active workers" placeholder, or the gh-unavailable
// degrade path) -- same try/catch-guarded IIFE convention as the two
// scripts above, so it is harmless under file:// or any other odd
// environment, and works identically whether this HTML came from a static
// cockpit.sh run or cockpit-serve.sh (which injects its OWN separate SSE
// script after this one, never replacing it).
(function () {
try {
var table = document.querySelector("#live table.routing");
if (!table) return;
var thead = table.querySelector("thead");
var tbody = table.querySelector("tbody");
if (!thead || !tbody) return;
var headers = Array.prototype.slice.call(thead.querySelectorAll("th[data-sort-key]"));
if (!headers.length) return;

function groupRows() {
var groups = [];
var current = null;
Array.prototype.forEach.call(tbody.children, function (tr) {
if (tr.classList && tr.classList.contains("task-group")) {
current = { header: tr, members: [] };
groups.push(current);
} else if (current) {
current.members.push(tr);
} else {
groups.push({ header: null, members: [tr] });
}
});
return groups;
}

headers.forEach(function (th, colIndex) {
th.addEventListener("click", function () {
var dir = th.getAttribute("data-sort-dir") === "asc" ? "desc" : "asc";
headers.forEach(function (t) { t.removeAttribute("data-sort-dir"); });
th.setAttribute("data-sort-dir", dir);

var groups = groupRows();
groups.forEach(function (g) {
g.members.sort(function (a, b) {
var av = (a.children[colIndex] && a.children[colIndex].textContent || "").trim();
var bv = (b.children[colIndex] && b.children[colIndex].textContent || "").trim();
var cmp = av.localeCompare(bv, undefined, { numeric: true, sensitivity: "base" });
return dir === "asc" ? cmp : -cmp;
});
});

var frag = document.createDocumentFragment();
groups.forEach(function (g) {
if (g.header) frag.appendChild(g.header);
g.members.forEach(function (tr) { frag.appendChild(tr); });
});
tbody.appendChild(frag);
});
});
} catch (e) { /* inert if the DOM/table is unavailable */ }
})();
</script>
</body>
</html>
Expand Down
115 changes: 115 additions & 0 deletions .claude/scripts/cockpit.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,121 @@ server_pid2=""
IG worktree remove --force "$insp_root/.claude/worktrees/issue-70a" >/dev/null 2>&1 || true
IG worktree remove --force "$insp_wt_70b" >/dev/null 2>&1 || true

# ---------------------------------------------------------------------------
# 7. Live-progress task GROUPING (issue #92): workers grouped by normalized
# task id (taskGroupKey), newest-issue-first ordering, an issue-linked
# group header (taskIssueLink), and a PR badge on the group header
# (findPRForIssue + prBadge). Uses an ISOLATED fixtures dir (does not
# reuse/mutate the shared "$work/fixtures" from section 2, so none of that
# section's exact-row-count/order assertions are perturbed by these extra
# task ids).
#
# Fixture shape: four distinct raw task ids across four groups —
# "91" -> group #91 (issue #91 is a KNOWN fixture issue, so its
# header must render a real <a href> link; also has an
# OPEN PR via headRefName feat/issue-91-groups)
# "88" -> group #88 (unknown issue -> plain "#88", no link)
# "issue-88-x" -> SAME group #88 as above (taskGroupKey normalization:
# first \d+ run == 88) -- a second worker, different
# raw task id, must nest under the SAME single header
# "70" -> group #70 (unknown issue -> plain "#70"; has a MERGED
# PR via headRefName feat/issue-70-merged-thing, to
# cover the merged-state pill since prBadge's state
# check is driven entirely by the fixture's pr.state)
# "50" -> group #50 (lowest number, no issue/PR match)
# Expected group order (numeric groups, descending by issue number):
# 91, 88, 70, 50.
# ---------------------------------------------------------------------------
mkdir -p "$work/fixtures-groups"
cat > "$work/fixtures-groups/issues.json" <<'EOF'
[
{"number":91,"title":"Live progress task groups","url":"https://example.com/91","labels":[],"body":""}
]
EOF
cat > "$work/fixtures-groups/prs.json" <<'EOF'
[
{"number":300,"title":"PR for 91","url":"https://example.com/pr/300","headRefName":"feat/issue-91-groups","state":"OPEN"},
{"number":301,"title":"PR for 70 (merged)","url":"https://example.com/pr/301","headRefName":"feat/issue-70-merged-thing","state":"MERGED"}
]
EOF
cat > "$work/fixtures-groups/events.jsonl" <<'EOF'
{"ts":"2026-01-01T00:00:00Z","role":"implementer","model":"sonnet","task":"91","phase":"implementing","lens":"","detail":""}
{"ts":"2026-01-01T00:01:00Z","role":"implementer","model":"sonnet","task":"88","phase":"implementing","lens":"","detail":""}
{"ts":"2026-01-01T00:02:00Z","role":"reviewer","model":"opus","task":"issue-88-x","phase":"reviewing","lens":"tests","detail":""}
{"ts":"2026-01-01T00:03:00Z","role":"implementer","model":"sonnet","task":"50","phase":"implementing","lens":"","detail":""}
{"ts":"2026-01-01T00:04:00Z","role":"implementer","model":"sonnet","task":"70","phase":"implementing","lens":"","detail":""}
EOF

html_groups="$work/cockpit-groups.html"
bash "$cockpit" --fixtures "$work/fixtures-groups" "$html_groups" >/dev/null 2>"$work/stderr-groups.log"
rc_groups=$?
check "task-grouping fixture run exits 0" [ "$rc_groups" -eq 0 ]

# (1) Multi-group + ordering: exactly 4 numeric groups, newest issue first.
check "live progress renders one task-group header per distinct group, newest issue number first (91, 88, 70, 50)" node -e '
const fs = require("fs");
const html = fs.readFileSync(process.argv[1], "utf8");
const m = html.match(/<section id="live">[\s\S]*?<\/section>/);
if (!m) throw new Error("live section not found");
const section = m[0];
const headers = [...section.matchAll(/<tr class="task-group"><td colspan="7"><strong>Task ([\s\S]*?)<\/strong><\/td><\/tr>/g)].map((r) => r[1]);
if (headers.length !== 4) throw new Error("expected 4 task-group headers, got " + headers.length + ": " + JSON.stringify(headers));
const nums = headers.map((h) => {
const mm = h.match(/#(\d+)/);
if (!mm) throw new Error("could not find an issue number in header: " + h);
return parseInt(mm[1], 10);
});
const want = [91, 88, 70, 50];
if (JSON.stringify(nums) !== JSON.stringify(want)) {
throw new Error("expected group order " + JSON.stringify(want) + " (newest issue first), got " + JSON.stringify(nums));
}
' "$html_groups"

# (2) taskIssueLink: task #91 is a known fixture issue -> its group header
# must render a real <a href="..."> link, not plain "#91" text.
check "task-group header links to the known fixture issue via taskIssueLink" grep -qF '<a href="https://example.com/91">#91</a>' "$html_groups"

# (3) findPRForIssue + prBadge: task #91's OPEN PR (matched via headRefName
# feat/issue-91-groups) renders an open-state pill on the group header; task
# #70's MERGED PR (feat/issue-70-merged-thing) renders a merged-state pill --
# both driven purely through the fixture's pr.state, since findPRForIssue
# only fetches --state open PRs live but prBadge's state check is generic.
check "task-group header renders an OPEN PR badge (findPRForIssue + prBadge)" grep -qF '&middot; PR <a href="https://example.com/pr/300">#300</a> <span class="badge warn">open</span>' "$html_groups"
check "task-group header renders a MERGED PR badge (findPRForIssue + prBadge)" grep -qF '&middot; PR <a href="https://example.com/pr/301">#301</a> <span class="badge good">merged</span>' "$html_groups"

# (4) taskGroupKey normalization: raw task ids "88" and "issue-88-x" both
# normalize to group key "88" -> exactly ONE "Task #88" header (already
# proven by the 4-header count above), with BOTH workers nested under that
# single header (not scattered into their own groups).
check "taskGroupKey normalizes \"88\" and \"issue-88-x\" into the SAME single group, both workers nested under it" node -e '
const fs = require("fs");
const html = fs.readFileSync(process.argv[1], "utf8");
const m = html.match(/<section id="live">[\s\S]*?<\/section>/);
if (!m) throw new Error("live section not found");
const section = m[0];
const idx88 = section.indexOf("Task #88");
if (idx88 === -1) throw new Error("could not find the #88 group header");
const nextGroupIdx = section.indexOf(String.raw`<tr class="task-group">`, idx88 + 1);
const segment = nextGroupIdx === -1 ? section.slice(idx88) : section.slice(idx88, nextGroupIdx);
if (!segment.includes("<td>implementer</td><td>88</td>")) throw new Error("implementer/88 row not nested under the #88 group header");
if (!segment.includes("<td>reviewer</td><td>issue-88-x</td>")) throw new Error("reviewer/issue-88-x row not nested under the SAME #88 group header (normalization failed)");
' "$html_groups"

# (5) Self-contained + client-side sort hooks: the live-progress <th>s carry
# data-sort-key attributes, and the whole document stays self-contained HTML
# (no external <script src=...> or <link href=...> — see the file header's
# "self-contained HTML" contract).
check "live-progress table headers carry data-sort-key attributes for the client-side sort script" bash -c '
grep -qF "<th data-sort-key=\"role\">Role</th>" "$1" &&
grep -qF "<th data-sort-key=\"task\">Task</th>" "$1" &&
grep -qF "<th data-sort-key=\"model\">Model</th>" "$1" &&
grep -qF "<th data-sort-key=\"phase\">Phase</th>" "$1" &&
grep -qF "<th data-sort-key=\"lens\">Lens</th>" "$1" &&
grep -qF "<th data-sort-key=\"updated\">Updated</th>" "$1"
' _ "$html_groups"
check "output HTML has no external <script src=...> (self-contained-HTML constraint)" bash -c '! grep -q "<script src=" "$1"' _ "$html_groups"
check "output HTML has no external <link href=...> (self-contained-HTML constraint)" bash -c '! grep -q "<link href=" "$1"' _ "$html_groups"

echo ""
if [ "$fail" -eq 0 ]; then
echo "cockpit.test.sh: PASS ($ok checks)"
Expand Down
Loading