From 1894d7602c3e63c8348c40d84aaf152a4915b681 Mon Sep 17 00:00:00 2001 From: adam Date: Sat, 21 Mar 2026 17:21:19 +1100 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20Phase=201b=20wiring=20=E2=80=94=20s?= =?UTF-8?q?tep=20timeline,=20run=20reconstruction,=20SSE=20recovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the remaining Phase 1b dashboard items: - Step timeline in detail drawer: click a run dot to see per-step progress with status badges, durations, and failure messages - Historical run reconstruction: on page load, fetch /v1/events and replay workflow events to rebuild appState.runs - SSE reconnect recovery: on reconnect, refetch health + events to recover missed state changes - Run-stage HUD: shows current stage and step progress (e.g. "1 run → implementer 10/23") instead of just a count - formatDuration helper for human-readable step durations - 20+ new JS test assertions for step timeline, formatDuration, and terminal-state filtering in runsAtStage Co-Authored-By: Claude Opus 4.6 (1M context) --- dashboard/app.js | 176 +++++++++++++++++++++++++++++++++++++++++++- dashboard/style.css | 40 ++++++++++ dashboard/test.html | 124 ++++++++++++++++++++++++++++++- 3 files changed, 334 insertions(+), 6 deletions(-) diff --git a/dashboard/app.js b/dashboard/app.js index 40a5361..07d13cf 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -20,6 +20,7 @@ var appState = { events: [], // recent events (newest first), capped at 50 eventCount: 0, selectedAgent: null, // composite key of currently selected agent + selectedRun: null, // correlation_id of currently selected run runs: {}, // correlation_id -> run state { stage, status, steps, workflow_name } }; @@ -32,6 +33,17 @@ function runStageFromEvent(evt) { return null; } +// Format milliseconds as human-readable duration +function formatDuration(ms) { + if (!ms && ms !== 0) return "—"; + if (ms < 1000) return ms + "ms"; + var s = Math.floor(ms / 1000); + if (s < 60) return s + "s"; + var m = Math.floor(s / 60); + s = s % 60; + return m + "m " + s + "s"; +} + // Process a workflow event and update run tracking state function processWorkflowEvent(evt) { var id = evt.correlation_id; @@ -45,7 +57,8 @@ function processWorkflowEvent(evt) { stepIndex: 0, totalSteps: (evt.data && evt.data.total_steps) || 0, workflowName: (evt.data && evt.data.workflow_name) || "", - startedAt: evt.timestamp + startedAt: evt.timestamp, + steps: [] // step timeline entries }; return; } @@ -58,13 +71,39 @@ function processWorkflowEvent(evt) { run.stepIndex = (evt.data && evt.data.step_index) || run.stepIndex; run.totalSteps = (evt.data && evt.data.total_steps) || run.totalSteps; run.status = "running"; + // Record step start in timeline + var idx = evt.data && evt.data.step_index; + if (idx) { + run.steps[idx] = { + index: idx, + type: (evt.data && evt.data.step_type) || "unknown", + agent: evt.agent || null, + stage: run.stage, + status: "running", + startedAt: evt.timestamp, + durationMs: null, + message: null + }; + } } else if (evt.event === "workflow.step.completed") { - // step done — stage will update when next step starts + var cidx = evt.data && evt.data.step_index; + if (cidx && run.steps[cidx]) { + run.steps[cidx].status = "completed"; + run.steps[cidx].durationMs = (evt.data && evt.data.duration_ms) || null; + run.steps[cidx].message = (evt.data && evt.data.message) || null; + } } else if (evt.event === "workflow.step.failed") { run.stage = runStageFromEvent(evt); run.status = "failed"; + var fidx = evt.data && evt.data.step_index; + if (fidx && run.steps[fidx]) { + run.steps[fidx].status = "failed"; + run.steps[fidx].durationMs = (evt.data && evt.data.duration_ms) || null; + run.steps[fidx].message = (evt.data && evt.data.message) || null; + } } else if (evt.event === "workflow.completed") { run.status = "completed"; + run.finishedAt = evt.timestamp; // Auto-remove completed runs after 8 seconds so the dot exits setTimeout(function() { delete appState.runs[id]; @@ -72,6 +111,7 @@ function processWorkflowEvent(evt) { }, 8000); } else if (evt.event === "workflow.failed") { run.status = "failed"; + run.finishedAt = evt.timestamp; } } @@ -236,7 +276,11 @@ function renderPipeline() { var dot = el("span", "run-dot"); if (stageRuns[r].status === "failed") dot.classList.add("run-dot-failed"); else dot.classList.add("run-dot-active"); + if (appState.selectedRun === stageRuns[r].id) dot.classList.add("run-dot-selected"); dot.title = stageRuns[r].workflowName + " (step " + stageRuns[r].stepIndex + "/" + stageRuns[r].totalSteps + ")"; + dot.addEventListener("click", (function(rid) { + return function(e) { e.stopPropagation(); selectRun(rid); }; + })(stageRuns[r].id)); dotsRow.appendChild(dot); } card.appendChild(dotsRow); @@ -265,8 +309,22 @@ function renderPipeline() { } var $hudRuns = document.getElementById("hud-runs"); if ($hudRuns) { - $hudRuns.textContent = runningCount > 0 ? runningCount + " run" + (runningCount > 1 ? "s" : "") : ""; - $hudRuns.className = "hud-item" + (runningCount > 0 ? " active-run" : ""); + if (runningCount > 0) { + // Show run count + current stage of first running run + var runStageLabel = ""; + for (i = 0; i < runIds.length; i++) { + var r = appState.runs[runIds[i]]; + if (r.status === "running" && r.stage) { + runStageLabel = " \u2192 " + r.stage + " " + r.stepIndex + "/" + r.totalSteps; + break; + } + } + $hudRuns.textContent = runningCount + " run" + (runningCount > 1 ? "s" : "") + runStageLabel; + $hudRuns.className = "hud-item active-run"; + } else { + $hudRuns.textContent = ""; + $hudRuns.className = "hud-item"; + } } // Bottleneck indicator @@ -336,12 +394,89 @@ function selectAgent(key) { function closeDrawer() { appState.selectedAgent = null; + appState.selectedRun = null; $drawer.classList.remove("open"); renderPipeline(); } $detailClose.addEventListener("click", closeDrawer); +// ── Run detail drawer (step timeline) ── +function selectRun(runId) { + if (appState.selectedRun === runId) { + closeDrawer(); + return; + } + appState.selectedRun = runId; + appState.selectedAgent = null; + var run = appState.runs[runId]; + if (!run) { closeDrawer(); return; } + + $detailName.textContent = run.workflowName + " — " + run.status; + + // Meta info + $detailMeta.innerHTML = ""; + var meta = [ + ["run", runId.substring(0, 12)], + ["status", run.status], + ["step", run.stepIndex + "/" + run.totalSteps], + ["started", timeAgo(run.startedAt)], + ]; + if (run.finishedAt) meta.push(["finished", timeAgo(run.finishedAt)]); + if (run.stage) meta.push(["stage", run.stage]); + for (var i = 0; i < meta.length; i++) { + var s = el("span", null, null); + var label = el("span", null, meta[i][0] + ":"); + s.appendChild(label); + s.appendChild(document.createTextNode(" " + meta[i][1])); + $detailMeta.appendChild(s); + } + + // Step timeline + while ($detailEvts.firstChild) $detailEvts.removeChild($detailEvts.firstChild); + var hasSteps = false; + for (var j = 1; j <= run.totalSteps; j++) { + var step = run.steps[j]; + if (!step) continue; + hasSteps = true; + var li = document.createElement("li"); + li.className = "step-row"; + + // Step index badge + var badge = el("span", "step-badge", String(step.index)); + if (step.status === "completed") badge.classList.add("step-ok"); + else if (step.status === "failed") badge.classList.add("step-fail"); + else badge.classList.add("step-running"); + li.appendChild(badge); + + // Step type + agent + var desc = step.type; + if (step.agent) desc += " \u2192 " + step.agent; + li.appendChild(el("span", "step-desc", desc)); + + // Duration + if (step.durationMs !== null) { + li.appendChild(el("span", "step-dur", formatDuration(step.durationMs))); + } else if (step.status === "running") { + li.appendChild(el("span", "step-dur running-text", "running\u2026")); + } + + // Failure message + if (step.status === "failed" && step.message) { + var msg = el("div", "step-msg", step.message); + li.appendChild(msg); + } + + $detailEvts.appendChild(li); + } + if (!hasSteps) { + $detailEvts.appendChild(el("li", null, "no steps recorded yet")); + } + + $drawer.classList.add("open"); + renderPipeline(); +} + // ── Render the event timeline ── function renderTimeline() { while ($eventList.firstChild) $eventList.removeChild($eventList.firstChild); @@ -403,8 +538,16 @@ function fetchHealth() { function connectSSE() { var es = new EventSource("/v1/events/stream"); + var wasConnected = false; + es.onopen = function() { $connDot.className = "conn-dot connected"; + // On reconnect, refetch health + events to recover missed state + if (wasConnected) { + fetchHealth(); + reconstructRuns(); + } + wasConnected = true; }; es.onerror = function() { @@ -527,8 +670,33 @@ if ($dispatchGo) { }); } +// ── Historical run reconstruction ── +// Fetch past events from /v1/events and replay workflow events to rebuild run state +function reconstructRuns() { + return fetch("/v1/events").then(function(res) { + return res.json(); + }).then(function(json) { + var events = json.data || []; + // Events come oldest-first from the API; replay in order + for (var i = 0; i < events.length; i++) { + var evt = events[i]; + if (evt.event && evt.event.indexOf("workflow.") === 0) { + processWorkflowEvent(evt); + } + // Also populate the event timeline (newest first) + appState.events.unshift(evt); + if (appState.events.length > 50) appState.events.length = 50; + } + scheduleRender(); + }).catch(function(e) { + console.warn("event reconstruction failed:", e); + }); +} + // ── Boot ── fetchHealth().then(function() { + return reconstructRuns(); +}).then(function() { connectSSE(); // Re-fetch health periodically to stay in sync setInterval(fetchHealth, 15000); diff --git a/dashboard/style.css b/dashboard/style.css index a508ca4..429de16 100644 --- a/dashboard/style.css +++ b/dashboard/style.css @@ -326,6 +326,46 @@ main#factory { #detail-drawer .detail-events .evt-type { color: var(--text-secondary); font-weight: 500; } #detail-drawer .detail-events .evt-time { float: right; font-size: 0.625rem; } +/* step timeline rows */ +.step-row { + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; +} +.step-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + font-size: 0.5625rem; + font-weight: 600; + flex-shrink: 0; + border: 1px solid var(--border); + color: var(--dim); +} +.step-badge.step-ok { border-color: var(--working); color: var(--working); } +.step-badge.step-fail { border-color: var(--blocked); color: var(--blocked); background: rgba(239,68,68,0.1); } +.step-badge.step-running { border-color: var(--working); color: var(--working); animation: dot-pulse 1.2s ease-in-out infinite; } +.step-desc { color: var(--text-secondary); flex: 1; min-width: 0; } +.step-dur { color: var(--dim); font-size: 0.625rem; flex-shrink: 0; } +.step-dur.running-text { color: var(--working); } +.step-msg { + width: 100%; + color: var(--blocked); + font-size: 0.625rem; + padding: 2px 0 2px 28px; + word-break: break-word; +} + +/* selected run dot */ +.run-dot-selected { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + /* ── dispatch panel ── */ #dispatch-panel { border-top: 1px solid var(--border); diff --git a/dashboard/test.html b/dashboard/test.html index 00e323a..8dc3dfc 100644 --- a/dashboard/test.html +++ b/dashboard/test.html @@ -215,6 +215,16 @@

tutti dashboard tests

return null; } +function formatDuration(ms) { + if (!ms && ms !== 0) return "\u2014"; + if (ms < 1000) return ms + "ms"; + var s = Math.floor(ms / 1000); + if (s < 60) return s + "s"; + var m = Math.floor(s / 60); + s = s % 60; + return m + "m " + s + "s"; +} + function processWorkflowEvent(evt) { var id = evt.correlation_id; if (!id) return; @@ -223,7 +233,8 @@

tutti dashboard tests

id: id, status: "running", stage: null, stepIndex: 0, totalSteps: (evt.data && evt.data.total_steps) || 0, workflowName: (evt.data && evt.data.workflow_name) || "", - startedAt: evt.timestamp + startedAt: evt.timestamp, + steps: [] }; return; } @@ -234,13 +245,36 @@

tutti dashboard tests

run.stepIndex = (evt.data && evt.data.step_index) || run.stepIndex; run.totalSteps = (evt.data && evt.data.total_steps) || run.totalSteps; run.status = "running"; + var idx = evt.data && evt.data.step_index; + if (idx) { + run.steps[idx] = { + index: idx, type: (evt.data && evt.data.step_type) || "unknown", + agent: evt.agent || null, stage: run.stage, + status: "running", startedAt: evt.timestamp, + durationMs: null, message: null + }; + } + } else if (evt.event === "workflow.step.completed") { + var cidx = evt.data && evt.data.step_index; + if (cidx && run.steps[cidx]) { + run.steps[cidx].status = "completed"; + run.steps[cidx].durationMs = (evt.data && evt.data.duration_ms) || null; + } } else if (evt.event === "workflow.step.failed") { run.stage = runStageFromEvent(evt); run.status = "failed"; + var fidx = evt.data && evt.data.step_index; + if (fidx && run.steps[fidx]) { + run.steps[fidx].status = "failed"; + run.steps[fidx].durationMs = (evt.data && evt.data.duration_ms) || null; + run.steps[fidx].message = (evt.data && evt.data.message) || null; + } } else if (evt.event === "workflow.completed") { run.status = "completed"; + run.finishedAt = evt.timestamp; } else if (evt.event === "workflow.failed") { run.status = "failed"; + run.finishedAt = evt.timestamp; } } @@ -249,7 +283,7 @@

tutti dashboard tests

var ids = Object.keys(testRuns); for (var i = 0; i < ids.length; i++) { var run = testRuns[ids[i]]; - if (run.stage === stage) result.push(run); + if (run.stage === stage && run.status !== "completed" && run.status !== "failed") result.push(run); } return result; } @@ -361,6 +395,92 @@

tutti dashboard tests

processWorkflowEvent({ event: "workflow.started", data: {} }); assert("no run created without correlation_id", Object.keys(testRuns).length, prevKeys); +suite("formatDuration"); +assert("null returns dash", formatDuration(null), "\u2014"); +assert("undefined returns dash", formatDuration(undefined), "\u2014"); +assert("0ms", formatDuration(0), "0ms"); +assert("500ms", formatDuration(500), "500ms"); +assert("999ms", formatDuration(999), "999ms"); +assert("1000ms = 1s", formatDuration(1000), "1s"); +assert("5500ms = 5s", formatDuration(5500), "5s"); +assert("61000ms = 1m 1s", formatDuration(61000), "1m 1s"); +assert("120000ms = 2m 0s", formatDuration(120000), "2m 0s"); + +suite("step timeline tracking"); +testRuns = {}; +processWorkflowEvent({ + event: "workflow.started", correlation_id: "st-1", + data: { workflow_name: "sdlc", total_steps: 3 }, + timestamp: "2026-03-21T05:00:00Z" +}); +assertTruthy("run has steps array", Array.isArray(testRuns["st-1"].steps)); +assert("steps array starts empty", testRuns["st-1"].steps.length, 0); + +processWorkflowEvent({ + event: "workflow.step.started", correlation_id: "st-1", + agent: "planner", data: { step_index: 1, step_type: "prompt", total_steps: 3 }, + timestamp: "2026-03-21T05:00:01Z" +}); +assertTruthy("step 1 recorded", testRuns["st-1"].steps[1]); +assert("step 1 type is prompt", testRuns["st-1"].steps[1].type, "prompt"); +assert("step 1 agent is planner", testRuns["st-1"].steps[1].agent, "planner"); +assert("step 1 status is running", testRuns["st-1"].steps[1].status, "running"); +assertNull("step 1 duration is null while running", testRuns["st-1"].steps[1].durationMs); + +processWorkflowEvent({ + event: "workflow.step.completed", correlation_id: "st-1", + agent: "planner", data: { step_index: 1, duration_ms: 82000 } +}); +assert("step 1 status is completed", testRuns["st-1"].steps[1].status, "completed"); +assert("step 1 duration is 82000", testRuns["st-1"].steps[1].durationMs, 82000); + +processWorkflowEvent({ + event: "workflow.step.started", correlation_id: "st-1", + agent: "implementer", data: { step_index: 2, step_type: "prompt", total_steps: 3 }, + timestamp: "2026-03-21T05:01:23Z" +}); +processWorkflowEvent({ + event: "workflow.step.failed", correlation_id: "st-1", + agent: "implementer", data: { step_index: 2, duration_ms: 43000, message: "no commit produced" } +}); +assert("step 2 status is failed", testRuns["st-1"].steps[2].status, "failed"); +assert("step 2 duration recorded", testRuns["st-1"].steps[2].durationMs, 43000); +assert("step 2 failure message", testRuns["st-1"].steps[2].message, "no commit produced"); + +suite("runsAtStage — excludes terminal runs"); +testRuns = {}; +processWorkflowEvent({ + event: "workflow.started", correlation_id: "term-1", + data: { workflow_name: "a", total_steps: 2 }, + timestamp: new Date().toISOString() +}); +processWorkflowEvent({ + event: "workflow.step.started", correlation_id: "term-1", + agent: "planner", data: { step_index: 1 } +}); +assert("running run shows at planner", runsAtStage("planner").length, 1); +processWorkflowEvent({ + event: "workflow.completed", correlation_id: "term-1", + data: { success: true } +}); +assert("completed run excluded from stage", runsAtStage("planner").length, 0); + +testRuns = {}; +processWorkflowEvent({ + event: "workflow.started", correlation_id: "term-2", + data: { workflow_name: "b", total_steps: 2 }, + timestamp: new Date().toISOString() +}); +processWorkflowEvent({ + event: "workflow.step.started", correlation_id: "term-2", + agent: "tester", data: { step_index: 1 } +}); +processWorkflowEvent({ + event: "workflow.failed", correlation_id: "term-2", + data: { success: false } +}); +assert("failed run excluded from stage", runsAtStage("tester").length, 0); + // ── Summary ── var $summary = document.getElementById("summary"); $summary.className = failed > 0 ? "fail" : "pass"; From 12260d060f941ff621dbefe091ecd98be5972d06 Mon Sep 17 00:00:00 2001 From: adam Date: Sat, 21 Mar 2026 17:26:32 +1100 Subject: [PATCH 2/3] =?UTF-8?q?fix(qa):=20ISSUE-001=20=E2=80=94=20prune=20?= =?UTF-8?q?orphan=20runs=20from=20historical=20reconstruction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When /v1/events returns only recent events, workflow.started events for old runs get reconstructed without their matching terminal events, creating zombie "running" runs. Prune runs older than 30 minutes that are still marked running after reconstruction completes. Co-Authored-By: Claude Opus 4.6 (1M context) --- dashboard/app.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/dashboard/app.js b/dashboard/app.js index 07d13cf..93e5ae3 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -687,6 +687,21 @@ function reconstructRuns() { appState.events.unshift(evt); if (appState.events.length > 50) appState.events.length = 50; } + // Clean up orphan runs: if a run was "started" but no terminal event + // was found in the event window, mark it as stale. The /v1/events + // endpoint only returns recent events, so old runs without a matching + // completed/failed event are zombies. + var runIds = Object.keys(appState.runs); + var cutoff = Date.now() - 30 * 60 * 1000; // 30 min age threshold + for (var j = 0; j < runIds.length; j++) { + var run = appState.runs[runIds[j]]; + if (run.status === "running") { + var startedMs = new Date(run.startedAt).getTime(); + if (startedMs < cutoff) { + delete appState.runs[runIds[j]]; + } + } + } scheduleRender(); }).catch(function(e) { console.warn("event reconstruction failed:", e); From 0b0dca96bd1ba3701e8eb5f1e7e64789a93b57be Mon Sep 17 00:00:00 2001 From: adam Date: Sat, 21 Mar 2026 17:44:09 +1100 Subject: [PATCH 3/3] fix: address CodeRabbit review on PR #103 - Preserve zero-length step durations (use != null instead of ||) - Use button element for run-dot (keyboard accessibility) - Skip full event replay on SSE reconnect (health snapshot only) - Replace deprecated word-break: break-word with overflow-wrap: anywhere Co-Authored-By: Claude Opus 4.6 (1M context) --- dashboard/app.js | 10 ++++++---- dashboard/style.css | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/dashboard/app.js b/dashboard/app.js index 93e5ae3..30985cf 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -89,7 +89,7 @@ function processWorkflowEvent(evt) { var cidx = evt.data && evt.data.step_index; if (cidx && run.steps[cidx]) { run.steps[cidx].status = "completed"; - run.steps[cidx].durationMs = (evt.data && evt.data.duration_ms) || null; + run.steps[cidx].durationMs = (evt.data && evt.data.duration_ms != null) ? evt.data.duration_ms : null; run.steps[cidx].message = (evt.data && evt.data.message) || null; } } else if (evt.event === "workflow.step.failed") { @@ -273,7 +273,7 @@ function renderPipeline() { if (stageRuns.length > 0) { var dotsRow = el("div", "run-dots"); for (var r = 0; r < stageRuns.length; r++) { - var dot = el("span", "run-dot"); + var dot = el("button", "run-dot"); if (stageRuns[r].status === "failed") dot.classList.add("run-dot-failed"); else dot.classList.add("run-dot-active"); if (appState.selectedRun === stageRuns[r].id) dot.classList.add("run-dot-selected"); @@ -542,10 +542,12 @@ function connectSSE() { es.onopen = function() { $connDot.className = "conn-dot connected"; - // On reconnect, refetch health + events to recover missed state + // On reconnect, refetch health snapshot only — don't replay the full + // event log into live state as it can duplicate run entries and refire + // side effects. The SSE stream will deliver any events missed during + // the disconnection window. if (wasConnected) { fetchHealth(); - reconstructRuns(); } wasConnected = true; }; diff --git a/dashboard/style.css b/dashboard/style.css index 429de16..81a1d4b 100644 --- a/dashboard/style.css +++ b/dashboard/style.css @@ -357,7 +357,7 @@ main#factory { color: var(--blocked); font-size: 0.625rem; padding: 2px 0 2px 28px; - word-break: break-word; + overflow-wrap: anywhere; } /* selected run dot */