diff --git a/dashboard/app.js b/dashboard/app.js
index f129727..40a5361 100644
--- a/dashboard/app.js
+++ b/dashboard/app.js
@@ -20,8 +20,72 @@ var appState = {
events: [], // recent events (newest first), capped at 50
eventCount: 0,
selectedAgent: null, // composite key of currently selected agent
+ runs: {}, // correlation_id -> run state { stage, status, steps, workflow_name }
};
+// ── Run tracking ──
+// Maps a step's agent to a pipeline stage so we can position the dot
+function runStageFromEvent(evt) {
+ if (!evt || !evt.data) return null;
+ var agent = evt.agent || (evt.data && evt.data.agent);
+ if (agent) return stageFor(agent);
+ return null;
+}
+
+// Process a workflow event and update run tracking state
+function processWorkflowEvent(evt) {
+ var id = evt.correlation_id;
+ if (!id) return;
+
+ if (evt.event === "workflow.started") {
+ appState.runs[id] = {
+ 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
+ };
+ return;
+ }
+
+ var run = appState.runs[id];
+ if (!run) return;
+
+ if (evt.event === "workflow.step.started") {
+ run.stage = runStageFromEvent(evt);
+ run.stepIndex = (evt.data && evt.data.step_index) || run.stepIndex;
+ run.totalSteps = (evt.data && evt.data.total_steps) || run.totalSteps;
+ run.status = "running";
+ } else if (evt.event === "workflow.step.completed") {
+ // step done — stage will update when next step starts
+ } else if (evt.event === "workflow.step.failed") {
+ run.stage = runStageFromEvent(evt);
+ run.status = "failed";
+ } else if (evt.event === "workflow.completed") {
+ run.status = "completed";
+ // Auto-remove completed runs after 8 seconds so the dot exits
+ setTimeout(function() {
+ delete appState.runs[id];
+ scheduleRender();
+ }, 8000);
+ } else if (evt.event === "workflow.failed") {
+ run.status = "failed";
+ }
+}
+
+// Get active (non-terminal) runs at a given stage
+function runsAtStage(stage) {
+ var result = [];
+ var ids = Object.keys(appState.runs);
+ for (var i = 0; i < ids.length; i++) {
+ var run = appState.runs[ids[i]];
+ if (run.stage === stage && run.status !== "completed" && run.status !== "failed") result.push(run);
+ }
+ return result;
+}
+
// Build a composite key for workspace-scoped agent storage
function agentKey(workspace, agent) {
return (workspace || "_") + ":" + agent;
@@ -127,12 +191,17 @@ function renderPipeline() {
// Flow connector between stages
if (i > 0) {
var conn = el("div", "flow-connector");
- var prevAgents = stageAgents[STAGE_ORDER[i - 1]] || [];
+ var prevStage = STAGE_ORDER[i - 1];
+ var prevAgents = stageAgents[prevStage] || [];
var prevActive = false;
for (var p = 0; p < prevAgents.length; p++) {
if (stateClass(prevAgents[p]) === "working") { prevActive = true; break; }
}
if (prevActive) conn.classList.add("active");
+ // Add flowing animation if a run is transitioning through this connector
+ if (runsAtStage(prevStage).length > 0 || runsAtStage(stage).length > 0) {
+ conn.classList.add("flowing");
+ }
$pipeline.appendChild(conn);
}
@@ -159,6 +228,20 @@ function renderPipeline() {
})(key));
}
+ // Render work-item dots for active runs at this stage
+ var stageRuns = runsAtStage(stage);
+ if (stageRuns.length > 0) {
+ var dotsRow = el("div", "run-dots");
+ for (var r = 0; r < stageRuns.length; r++) {
+ var dot = el("span", "run-dot");
+ if (stageRuns[r].status === "failed") dot.classList.add("run-dot-failed");
+ else dot.classList.add("run-dot-active");
+ dot.title = stageRuns[r].workflowName + " (step " + stageRuns[r].stepIndex + "/" + stageRuns[r].totalSteps + ")";
+ dotsRow.appendChild(dot);
+ }
+ card.appendChild(dotsRow);
+ }
+
$pipeline.appendChild(card);
}
@@ -174,6 +257,18 @@ function renderPipeline() {
$hudBlock.textContent = blocked + " blocked";
$hudBlock.className = "hud-item" + (blocked > 0 ? " alert" : "");
+ // Run count in HUD
+ var runIds = Object.keys(appState.runs);
+ var runningCount = 0;
+ for (i = 0; i < runIds.length; i++) {
+ if (appState.runs[runIds[i]].status === "running") runningCount++;
+ }
+ 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" : "");
+ }
+
// Bottleneck indicator
var bottleneck = findBottleneck();
if (bottleneck) {
@@ -338,17 +433,23 @@ function connectSSE() {
appState.agents[key] = merged;
}
+ // Track workflow runs
+ if (data.event && data.event.indexOf("workflow.") === 0) {
+ processWorkflowEvent(data);
+ }
+
scheduleRender();
} catch (_) { /* ignore parse errors */ }
};
- // Event types actually emitted by the server (state/mod.rs transition_events)
+ // Event types actually emitted by the server
var eventTypes = [
"agent.started", "agent.stopped",
"agent.working", "agent.idle",
"agent.auth_failed", "agent.auth_recovered",
- "agent.rate_limited", "agent.provider_down", "agent.provider_recovered"
- // Phase 1b will add: workflow.step, workflow.stage_transition, run.completed
+ "agent.rate_limited", "agent.provider_down", "agent.provider_recovered",
+ "workflow.started", "workflow.completed", "workflow.failed",
+ "workflow.step.started", "workflow.step.completed", "workflow.step.failed"
];
for (var i = 0; i < eventTypes.length; i++) {
es.addEventListener(eventTypes[i], handler);
@@ -358,6 +459,74 @@ function connectSSE() {
es.onmessage = handler;
}
+// ── Dispatch panel ──
+var $dispatchToggle = document.getElementById("dispatch-toggle");
+var $dispatchForm = document.getElementById("dispatch-form");
+var $dispatchWf = document.getElementById("dispatch-workflow");
+var $dispatchIssue = document.getElementById("dispatch-issue");
+var $dispatchGo = document.getElementById("dispatch-go");
+var $dispatchStatus = document.getElementById("dispatch-status");
+
+if ($dispatchToggle) {
+ $dispatchToggle.addEventListener("click", function() {
+ $dispatchForm.classList.toggle("open");
+ if ($dispatchForm.classList.contains("open") && $dispatchWf.options.length <= 1) {
+ loadWorkflows();
+ }
+ });
+}
+
+function loadWorkflows() {
+ fetch("/v1/workflows").then(function(res) { return res.json(); }).then(function(json) {
+ var wfs = (json.data && json.data.workflows) || json.data || [];
+ $dispatchWf.innerHTML = "";
+ if (wfs.length === 0) {
+ $dispatchWf.appendChild(new Option("no workflows", ""));
+ return;
+ }
+ for (var i = 0; i < wfs.length; i++) {
+ var name = typeof wfs[i] === "string" ? wfs[i] : (wfs[i].name || "");
+ if (name) $dispatchWf.appendChild(new Option(name, name));
+ }
+ }).catch(function() {
+ $dispatchWf.innerHTML = "";
+ $dispatchWf.appendChild(new Option("error loading", ""));
+ });
+}
+
+if ($dispatchGo) {
+ $dispatchGo.addEventListener("click", function() {
+ var wf = $dispatchWf.value;
+ if (!wf) return;
+ $dispatchGo.disabled = true;
+ $dispatchStatus.textContent = "dispatching…";
+ $dispatchStatus.className = "dispatch-status";
+
+ var body = { workflow: wf };
+ var issue = ($dispatchIssue.value || "").trim();
+ if (issue) body.issue = issue;
+
+ fetch("/v1/actions/run", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body)
+ }).then(function(res) { return res.json(); }).then(function(json) {
+ $dispatchGo.disabled = false;
+ if (json.status === "ok") {
+ $dispatchStatus.textContent = "dispatched";
+ $dispatchStatus.className = "dispatch-status ok";
+ } else {
+ $dispatchStatus.textContent = (json.error && json.error.message) || "failed";
+ $dispatchStatus.className = "dispatch-status err";
+ }
+ }).catch(function(e) {
+ $dispatchGo.disabled = false;
+ $dispatchStatus.textContent = "error: " + e.message;
+ $dispatchStatus.className = "dispatch-status err";
+ });
+ });
+}
+
// ── Boot ──
fetchHealth().then(function() {
connectSSE();
diff --git a/dashboard/index.html b/dashboard/index.html
index cf9a875..b17606a 100644
--- a/dashboard/index.html
+++ b/dashboard/index.html
@@ -15,6 +15,7 @@
0 active
0 blocked
+
@@ -35,6 +36,16 @@
+
+
+
+
+
+
+
+
+
+