From aaf88f7824fb85b3bfda0e359936ab5ac3b6f42b Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:14:46 +0200 Subject: [PATCH] feat(cockpit): serve mode + SSE auto-update + dark theme + clickability (#69) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cockpit Phase 3a. Adds cockpit-serve.sh, a zero-dependency node http server (127.0.0.1 only) that reuses cockpit.sh's renderer unchanged: GET / regenerates the dashboard on a short TTL cache and injects an SSE/refresh client script before ; GET /events streams the live-progress event log (replay + fs.watch/poll, 15s heartbeat, auto-reconnect via EventSource); GET /api/refresh forces an immediate re-render for the client's timer/button. This lets workers' progress update live instead of requiring a manual re-run of cockpit.sh. cockpit.sh itself is restyled dark-by-default (CSS custom properties, with a localStorage-persisted light-theme toggle) and gains two small client-side hooks: a stable `data-theme="dark"` marker, and a module-header click filter over the issue list (data-module on each
  • ). Live-progress rows also grow a hidden data-role/data-task cell for a future worker inspector (issue 3b) — appended after every column the existing tests exact-match, so none of them needed to change. cockpit.test.sh gains a serve-mode smoke case (random free port, readiness poll, SSE delivery of a freshly appended events.jsonl line) plus assertions for the new dark-theme/filter markup, still fully offline against fixtures. Wired cockpit.test.sh + log-event.test.sh into .claude/self/checks.sh's `test` case (previously neither ran as part of the self-host test gate) and fixed a latent GATES_FILE leakage bug that surfaced once cockpit.test.sh actually ran under the self-adapter's environment. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NTZEsfFT4mrZpv8CY67Frd --- .claude/scripts/cockpit-serve.sh | 369 +++++++++++++++++++++++++++++++ .claude/scripts/cockpit.sh | 143 ++++++++++-- .claude/scripts/cockpit.test.sh | 100 ++++++++- .claude/self/checks.sh | 18 +- 4 files changed, 598 insertions(+), 32 deletions(-) create mode 100755 .claude/scripts/cockpit-serve.sh diff --git a/.claude/scripts/cockpit-serve.sh b/.claude/scripts/cockpit-serve.sh new file mode 100755 index 0000000..89b22af --- /dev/null +++ b/.claude/scripts/cockpit-serve.sh @@ -0,0 +1,369 @@ +#!/usr/bin/env bash +# cockpit-serve.sh — Phase 3a (issue #69) live HTTP serve mode for cockpit.sh. +# +# Wraps the EXISTING one-shot renderer (cockpit.sh) behind a tiny node +# `http` server: no new dependencies (node built-ins only), bound to +# 127.0.0.1 only. Rendering itself is never reimplemented here — every +# request shells back into `cockpit.sh` so there is exactly ONE HTML +# generator to keep in sync (mirrors cockpit.sh's own --parse-blocking seam). +# +# Usage: +# cockpit-serve.sh [port] [--fixtures ] +# port optional positional arg, default 8090 +# --fixtures same offline seam as cockpit.sh: read /issues.json, +# /prs.json, /events.jsonl instead of gh/network +# and the real event log. Used by cockpit.test.sh's serve +# smoke case — no network/gh in tests. +# +# Routes: +# GET / the dashboard (cockpit.sh's HTML with the SSE/theme +# client script injected before ). Served from a +# short-lived cache (COCKPIT_GH_REFRESH seconds, default +# 60) so gh-backed sections aren't re-rendered on every +# request; the client's own timer/button call +# /api/refresh to force an immediate re-render. +# GET /events SSE stream of the live-progress event log (JSONL). +# Replays whatever is already in the file on connect +# (so a client that connects right after an append still +# sees it), then streams every subsequent appended line +# as its own `data:` event, plus a `: ping` heartbeat +# every 15s. Watches via fs.watch AND a polling fallback +# (fs.watch is unreliable on some filesystems/OSes). +# GET /api/refresh forces an immediate re-render (bypassing the cache) +# and returns 200 {"ok":true} (or {"ok":false,"error"} +# on failure). The injected client calls this from its +# refresh timer and its manual "Refresh now" button. +# +# Foreground process — SIGTERM/SIGINT close the server cleanly (via `exec`, +# below, node receives signals directly; no bash wrapper indirection). +set -uo pipefail + +# Two-root derivation (issue #63): script_dir = sibling scripts, root = consumer project. +# shellcheck source=resolve-roots.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/resolve-roots.sh" +cockpit="$script_dir/cockpit.sh" + +# --------------------------------------------------------------------------- +# Args: [port] [--fixtures ] +# --------------------------------------------------------------------------- +port="" +fixtures="" +while [ $# -gt 0 ]; do + case "$1" in + --fixtures) fixtures="$2"; shift 2 ;; + --fixtures=*) fixtures="${1#--fixtures=}"; shift ;; + *) port="$1"; shift ;; + esac +done +port="${port:-8090}" + +# Same offline seam as cockpit.sh: in --fixtures mode, watch the fixture's +# events.jsonl, never the real (gitignored) event log. +if [ -n "$fixtures" ]; then + events_file="$fixtures/events.jsonl" +else + events_file="${CLAUDE_EVENTS_FILE:-$root/.claude/state/events.jsonl}" +fi + +gh_refresh="${COCKPIT_GH_REFRESH:-60}" + +tmp_out="$(mktemp "${TMPDIR:-/tmp}/cockpit-serve.XXXXXX.html")" +trap 'rm -f "$tmp_out"' EXIT + +# `exec` replaces this shell with node (same PID) so SIGTERM/SIGINT go +# straight to node's own handlers below — no bash signal-forwarding needed. +COCKPIT_SERVE_SELF="$cockpit" \ +COCKPIT_SERVE_FIXTURES="$fixtures" \ +COCKPIT_SERVE_PORT="$port" \ +COCKPIT_SERVE_EVENTS_FILE="$events_file" \ +COCKPIT_SERVE_GH_REFRESH="$gh_refresh" \ +COCKPIT_SERVE_TMP_OUT="$tmp_out" \ +exec node - <<'NODE_SERVE' +const http = require("http"); +const fs = require("fs"); +const { execFileSync } = require("child_process"); + +const SELF = process.env.COCKPIT_SERVE_SELF; +const FIXTURES = process.env.COCKPIT_SERVE_FIXTURES || ""; +const PORT = parseInt(process.env.COCKPIT_SERVE_PORT, 10) || 8090; +const EVENTS_FILE = process.env.COCKPIT_SERVE_EVENTS_FILE; +const GH_REFRESH_SECONDS = parseInt(process.env.COCKPIT_SERVE_GH_REFRESH, 10) || 60; +const GH_REFRESH_MS = GH_REFRESH_SECONDS * 1000; +const TMP_OUT = process.env.COCKPIT_SERVE_TMP_OUT; + +// --------------------------------------------------------------------------- +// Render cache: re-run cockpit.sh (the ONE renderer) at most once per +// GH_REFRESH_MS, unless a caller forces it (GET /api/refresh, or the very +// first request). This is what keeps serve mode from shelling out to gh on +// every single page load while still staying live. +// --------------------------------------------------------------------------- +let cache = { html: null, ts: 0, err: null }; + +function render() { + const args = [SELF]; + if (FIXTURES) args.push("--fixtures", FIXTURES); + args.push(TMP_OUT); + execFileSync("bash", args, { stdio: ["ignore", "ignore", "inherit"] }); + const html = fs.readFileSync(TMP_OUT, "utf8"); + cache = { html, ts: Date.now(), err: null }; + return html; +} + +function getHtml(force) { + const stale = !cache.html || Date.now() - cache.ts > GH_REFRESH_MS; + if (force || stale) { + try { + return render(); + } catch (e) { + cache.err = e; + if (cache.html) return cache.html; // degrade to last-good render + throw e; + } + } + return cache.html; +} + +// --------------------------------------------------------------------------- +// Client script injected into GET / only (never shipped in cockpit.sh's own +// static output) — SSE live-updates + a "stale since ..." badge + a manual +// refresh button that also drives the timed gh-backed refresh. Vanilla JS, +// no frameworks/external assets; every dynamic value from the server lands +// via textContent, never innerHTML. +// --------------------------------------------------------------------------- +function clientScript() { + return ` +`; +} + +function injectClientScript(html) { + const script = clientScript(); + if (html.includes("")) return html.replace("", script + "\n"); + return html + script; +} + +// --------------------------------------------------------------------------- +// /events — SSE. Replays whatever is already in EVENTS_FILE on connect (so a +// line appended just before the client connects is still delivered), then +// streams each newly appended line as its own event. fs.watch is +// unreliable on some platforms/filesystems, so a size/mtime poll runs +// alongside it as a fallback -- both funnel through the same "read from +// offset" step, so neither path can double-deliver a line. +// --------------------------------------------------------------------------- +function handleEvents(req, res) { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + + let offset = 0; + let closed = false; + + function pump() { + if (closed) return; + fs.stat(EVENTS_FILE, (err, stat) => { + if (closed) return; + if (err) return; // file missing yet -- nothing to send + if (stat.size <= offset) return; // no new bytes + fs.open(EVENTS_FILE, "r", (openErr, fd) => { + if (closed) return; + if (openErr) return; + const len = stat.size - offset; + const buf = Buffer.alloc(len); + fs.read(fd, buf, 0, len, offset, (readErr, bytesRead) => { + fs.close(fd, () => {}); + if (closed || readErr) return; + offset += bytesRead; + const text = buf.toString("utf8", 0, bytesRead); + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + // One JSONL line -> one SSE `data:` line; the payload itself is + // JSON (never multi-line here), so no need to fold newlines. + res.write(`data: ${trimmed}\n\n`); + } + }); + }); + }); + } + + const heartbeat = setInterval(() => { + if (!closed) res.write(": ping\n\n"); + }, 15000); + + const poll = setInterval(pump, 1000); + + let watcher = null; + try { + watcher = fs.watch(EVENTS_FILE, { persistent: false }, () => pump()); + } catch (e) { + // ENOENT (file doesn't exist yet) or platform without fs.watch support + // on this path -- the poll interval above still covers it. + watcher = null; + } + + // Initial replay of whatever's already on disk. + pump(); + + req.on("close", () => { + closed = true; + clearInterval(heartbeat); + clearInterval(poll); + if (watcher) watcher.close(); + }); +} + +function handleRefresh(req, res) { + try { + getHtml(true); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + } catch (e) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: String((e && e.message) || e) })); + } +} + +function handleIndex(req, res) { + try { + const html = injectClientScript(getHtml(false)); + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(html); + } catch (e) { + res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("cockpit-serve: render failed — " + String((e && e.message) || e)); + } +} + +const server = http.createServer((req, res) => { + const url = (req.url || "/").split("?")[0]; + if (req.method !== "GET") { + res.writeHead(405, { "Content-Type": "text/plain" }); + res.end("method not allowed"); + return; + } + if (url === "/" || url === "/index.html") return handleIndex(req, res); + if (url === "/events") return handleEvents(req, res); + if (url === "/api/refresh") return handleRefresh(req, res); + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("not found"); +}); + +// Best-effort warm cache so the first real request doesn't pay the initial +// render latency; failure here is not fatal -- getHtml() will retry lazily. +try { render(); } catch (e) { /* surfaces again on first request */ } + +server.listen(PORT, "127.0.0.1", () => { + console.log(`cockpit serving on http://127.0.0.1:${PORT}`); +}); + +function shutdown() { + server.close(() => process.exit(0)); + // Force-exit if close() hangs on a slow keep-alive connection. + setTimeout(() => process.exit(0), 2000).unref(); +} +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); +NODE_SERVE diff --git a/.claude/scripts/cockpit.sh b/.claude/scripts/cockpit.sh index 128cd24..0719316 100755 --- a/.claude/scripts/cockpit.sh +++ b/.claude/scripts/cockpit.sh @@ -33,6 +33,14 @@ # Output: self-contained HTML (inline CSS, no external CDN/JS/fonts), default # .claude/state/cockpit.html (that dir is gitignored — never commit the # generated artifact). Pass a second positional arg to write elsewhere. +# +# Phase 3a (issue #69): dark theme by default (with a light-theme toggle, +# persisted client-side in localStorage — inert/harmless if run in a +# file:// context with no localStorage), plus a client-side module-header +# click filter over the issue list. Both are small inline -

    Cockpit

    -

    Generated ${esc(generatedAt)} · read-only Phase 1 snapshot (issue #51) + Phase 2 live progress (issue #52) · re-run cockpit.sh to refresh

    +

    Cockpit

    +

    Generated ${esc(generatedAt)} · read-only Phase 1 snapshot (issue #51) + Phase 2 live progress (issue #52) + Phase 3a serve/theme/filter (issue #69) · re-run cockpit.sh to refresh (or run cockpit-serve.sh for live auto-update)

    ${renderLiveProgress()} ${renderIssues()} ${renderPRs()} ${renderRouting()} ${renderWorktrees()} + `; diff --git a/.claude/scripts/cockpit.test.sh b/.claude/scripts/cockpit.test.sh index ae8c1e3..3839beb 100755 --- a/.claude/scripts/cockpit.test.sh +++ b/.claude/scripts/cockpit.test.sh @@ -1,27 +1,52 @@ #!/usr/bin/env bash # cockpit.test.sh — offline smoke test for cockpit.sh (issue #51, extended for -# Phase 2 live progress in issue #52). +# Phase 2 live progress in issue #52, and Phase 3a serve/theme/filter in +# issue #69). # # Runs the generator against controlled FIXTURE issue/PR/events JSON (never # live gh/network, and never the real event log — see cockpit.sh's # --fixtures mode), then asserts the produced HTML contains every required # section (issues-by-module with blocking relationships, PRs with review/CI # badges, a routing table with a real `model:` value, a worktrees section, -# a live-progress panel deduped to each worker's latest phase) and that the -# blocking-relationship parser (`cockpit.sh --parse-blocking`) produces the -# expected edges for a known fixture body. Also exercises the "gh/network -# unavailable" degrade path via COCKPIT_GH_BIN, entirely offline (no real gh -# call, no .env). +# a live-progress panel deduped to each worker's latest phase, the default +# dark-theme marker) and that the blocking-relationship parser +# (`cockpit.sh --parse-blocking`) produces the expected edges for a known +# fixture body. Also exercises the "gh/network unavailable" degrade path via +# COCKPIT_GH_BIN, entirely offline (no real gh call, no .env), and a serve-mode +# smoke case (cockpit-serve.sh) against the SAME fixtures, over 127.0.0.1 only +# — no real network/gh either way. # # Exit 0 on success, non-zero if any assertion fails. Runnable bare: # bash .claude/scripts/cockpit.test.sh set -uo pipefail +# Isolate from the CALLER's environment: this test is now wired into +# .claude/self/checks.sh's `test` case, which itself typically runs under +# `GATES_FILE=.claude/self/gates.json` (the self-host loop). Since env vars +# set before a command propagate to every child process it spawns, an +# ambient GATES_FILE would silently redirect the DEFAULT-adapter assertions +# below (section 2) onto the self-adapter. Section 3 sets GATES_FILE +# explicitly where it actually wants the override; everywhere else must see +# the default (.claude/gates.json). +unset GATES_FILE + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cockpit="$script_dir/cockpit.sh" +cockpit_serve="$script_dir/cockpit-serve.sh" work="$(mktemp -d "${TMPDIR:-/tmp}/cockpit-test.XXXXXX")" -trap 'rm -rf "$work"' EXIT +# server_pid is set once the serve-mode smoke case (section 5, below) starts +# cockpit-serve.sh in the background -- declared here so the SAME EXIT trap +# cleans it up no matter where in the script a later assertion fails. +server_pid="" +cleanup() { + if [ -n "$server_pid" ]; then + kill "$server_pid" >/dev/null 2>&1 || true + wait "$server_pid" 2>/dev/null || true + fi + rm -rf "$work" +} +trap cleanup EXIT fail=0 ok=0 @@ -177,6 +202,12 @@ check "live-section field escaping: raw