feat(agent-team): live visual pipeline map for the status dashboard

Turn the read-only status page into an auto-updating visual map of the
agent-team DAG (INTAKE -> CLARIFY <-> gate -> PLAN <-> REVIEW ->
[BUILD -> VERIFY -> DISPATCH] -> DONE), rendered as hand-rolled inline
SVG (no CDN/D3 — the R720 is offline/LAN-only).

- Stage model (STAGES) with per-node model/agent role labels (Claude/
  GPT-4.1/Gemini/DeepSeek/Slack owner) and gated/role-node flags.
- Per-stage live state (idle/active/awaiting-human/parked) + count badge,
  grouped by current_phase; awaiting-human = OPEN pending_question.
- GET /api/state JSON sidecar (snapshot_to_dict); inline vanilla-JS poller
  fetches it every 4s and repaints node states/counts/cards/clock/tooltip
  in place (no reload, hover/scroll survive). <noscript> meta-refresh
  fallback retained.
- Hover/focus tooltip per node: short_id, description, status, waiting age.
- Existing table view kept as a detail section below the map.
- Read-only (mode=ro), fail-safe, no secrets; descriptions escaped for both
  HTML and the JSON-in-script seed (< / > -> \uXXXX), DOM via textContent.
This commit is contained in:
Adam Moussa 2026-06-23 14:37:43 -04:00
parent bd5bec2f9a
commit 72098ba26e

View file

@ -27,13 +27,24 @@ Posture (read this before changing anything):
* **Fail-safe.** A missing, locked, or unreadable DB renders a friendly
"no data" page; the server never crashes on a bad read.
The page is a live visual pipeline map: server-rendered inline SVG of the
agent-team DAG (``INTAKE -> CLARIFY (human gate) -> PLAN <-> REVIEW ->
[BUILD -> VERIFY -> DISPATCH] -> DONE``), with each stage coloured by live
state and badged with a task count. Vanilla inline JS polls a JSON sidecar
endpoint (``GET /api/state``) every few seconds with ``fetch()`` and updates
the node states, counts, tooltips, and "last updated" clock *in place* — no
full-page reload, so hover and scroll survive the refresh. A ``<noscript>``
meta-refresh is the JS-disabled fallback. Everything (SVG + CSS + JS) is inline
in the served document; nothing is fetched from a CDN, because the R720 VM is
offline/LAN-only.
Why stdlib ``http.server`` and not FastAPI (which is already a dep): the page is
a single read-only GET that returns server-rendered HTML on a 10s meta-refresh.
FastAPI/uvicorn buys nothing here (no JSON contract, no auth, no async I/O) and
would couple this LAN dashboard to the optional WS1 HTTP-API dependency set.
stdlib ``http.server`` keeps it zero-new-deps and importable anywhere the
``agent_team`` package is, which also keeps :func:`render_html` trivially
unit-testable without binding a socket.
two read-only GETs (the HTML map and its JSON sidecar). FastAPI/uvicorn buys
nothing here (no auth, no async I/O) and would couple this LAN dashboard to the
optional WS1 HTTP-API dependency set. stdlib ``http.server`` keeps it
zero-new-deps and importable anywhere the ``agent_team`` package is, which also
keeps :func:`render_html` and :func:`snapshot_to_dict` trivially unit-testable
without binding a socket.
Config (all env, with defaults):
@ -47,10 +58,11 @@ Config (all env, with defaults):
from __future__ import annotations
import html
import json
import os
import sqlite3
from collections import Counter
from dataclasses import dataclass, field
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
@ -64,15 +76,73 @@ _DEFAULT_DB = _PACKAGE_DIR.parent / "state" / "agent_team.sqlite"
_DEFAULT_HOST = "0.0.0.0"
_DEFAULT_PORT = 8770
# How long the browser waits before re-requesting (seconds).
# <noscript> fallback / hard cap: how long the browser waits before a full
# reload if JS is disabled. The live JS poller (below) refreshes far more often
# without reloading, so this only matters for the no-JS path.
_REFRESH_SECONDS = 10
# How often the inline JS poller re-fetches /api/state (milliseconds). ~4s keeps
# the map feeling live without hammering a read-only SQLite reader.
_POLL_INTERVAL_MS = 4000
# Phases we treat as "still progressing" for the queue summary. PARKED/DONE/
# FAILED are terminal-ish; everything else is in flight.
_ACTIVE_STATUSES = {"active", "waiting_human"}
_PARKED_STATUSES = {"parked"}
# --- Pipeline map model -------------------------------------------------------
#
# The agent-team is one durable LangGraph DAG. These STAGES are the canonical
# flow we draw, in order, each carrying the model "agent"/role that owns it (per
# the design: CLARIFY + PLAN = Claude on subscription; REVIEW = GPT-4.1
# cross_reviewer; SCAN = Gemini scanner; BUILD = DeepSeek fast_coder; the HUMAN
# GATE = the Slack owner). ``phases`` lists the Phase enum value(s) that map a
# live task onto that stage; tasks are grouped onto stages by ``current_phase``.
#
# VERIFY/DISPATCH are gated/inert in the current deploy (P3 not live) but are
# drawn so the operator sees the full intended pipeline. SCAN is a role node
# (Gemini), not a distinct LangGraph phase, so no task ever lands on it — it
# renders as a labelled-but-idle capability the BUILD/VERIFY stages can call.
@dataclass(frozen=True)
class Stage:
"""One node in the rendered pipeline map.
``key`` is a stable id used by the SVG + JS. ``phases`` are the Phase enum
*values* whose tasks belong to this stage (empty for pure role nodes like
the human gate / scan that no ``current_phase`` ever names).
"""
key: str
label: str
agent: str # model / role that owns the stage
phases: tuple[str, ...] = ()
gated: bool = False # drawn but inert in the current deploy
role_node: bool = False # capability, not a Phase tasks land on
# Left-to-right pipeline order. Keep in sync with task_model.Phase.
STAGES: tuple[Stage, ...] = (
Stage("intake", "Intake", "coordinator", phases=("intake",)),
Stage("clarify", "Clarify", "Claude (sub)", phases=("clarify",)),
Stage("gate", "Human Gate", "Slack owner", role_node=True),
Stage("plan", "Plan", "Claude (sub)", phases=("plan",)),
Stage("review", "Review", "GPT-4.1 (cross)", phases=("review",)),
Stage("scan", "Scan", "Gemini", role_node=True),
Stage("build", "Build", "DeepSeek (fast)", phases=("build",), gated=True),
Stage("verify", "Verify", "Claude (sub)", phases=("verify",), gated=True),
Stage("dispatch", "Dispatch", "GitHub PR", gated=True, role_node=True),
Stage("done", "Done", "—", phases=("done",)),
)
# Phase value -> stage key, derived once from STAGES.
_PHASE_TO_STAGE: dict[str, str] = {
phase: stage.key for stage in STAGES for phase in stage.phases
}
@dataclass(frozen=True)
class TaskView:
"""A read-only, render-ready view of one pipeline task.
@ -125,6 +195,35 @@ class Snapshot:
def progressing_tasks(self) -> list[TaskView]:
return [t for t in self.tasks if not t.waiting]
def tasks_for_stage(self, stage_key: str) -> list[TaskView]:
"""Tasks whose ``current_phase`` maps onto ``stage_key``.
Role nodes (human gate / scan / dispatch) own no phase, so they get no
tasks here even when work is "logically" at the gate — a CLARIFY task
awaiting an answer stays on the CLARIFY node and is flagged
awaiting-human there, which is where the operator looks.
"""
return [
t for t in self.tasks if _PHASE_TO_STAGE.get(t.current_phase) == stage_key
]
def stage_state(self, stage: Stage) -> str:
"""Live state for a stage node: awaiting_human > active > parked > idle.
``awaiting_human`` wins so a blocked stage reads as blocked at a glance.
Role/gated nodes with no tasks fall through to ``idle``.
"""
members = self.tasks_for_stage(stage.key)
if not members:
return "idle"
if any(t.waiting for t in members):
return "awaiting_human"
if any(t.status in _PARKED_STATUSES for t in members):
return "parked"
if any(t.status in _ACTIVE_STATUSES for t in members):
return "active"
return "idle"
def _utc_now_iso() -> str:
"""Current UTC time as a stable, human-readable string."""
@ -244,6 +343,99 @@ def build_snapshot(db_path: Path | str | None = None) -> Snapshot:
)
def snapshot_to_dict(snapshot: Snapshot) -> dict[str, Any]:
"""Serialize a :class:`Snapshot` into the ``/api/state`` JSON payload.
Pure (no I/O) and self-contained so it is unit-testable without a socket.
The shape is the contract the inline JS poller depends on:
* ``ok`` / ``error`` / ``generated_at`` — header + clock + no-data handling.
* ``stages`` — one entry per pipeline node in draw order, each with its live
``state``, a task ``count``, and the member ``tasks`` (for the tooltip).
* ``tasks`` / ``waiting`` / ``question_counts`` / ``summary`` / ``budget`` —
the detail table + count cards the page also renders.
Task descriptions are operator input; they are emitted as raw strings here
and the *consumer* (the JS, via ``textContent``/JSON parsing) is responsible
for not interpreting them as markup. ``json.dumps`` itself escapes the
JSON-string context, and the renderer additionally hardens the inlined seed
against ``</script>`` / HTML-comment breakouts (see ``_json_for_script``).
"""
if not snapshot.ok:
return {
"ok": False,
"error": snapshot.error or "ledger unavailable",
"generated_at": snapshot.generated_at,
"db_path": snapshot.db_path,
"stages": [_stage_payload(s, []) for s in STAGES],
"tasks": [],
"waiting": [],
"question_counts": {},
"summary": {
"active": 0,
"parked": 0,
"waiting": 0,
"open_questions": 0,
"total": 0,
},
"budget": {"available": False},
}
stages = [
_stage_payload(s, snapshot.tasks_for_stage(s.key), snapshot.stage_state(s))
for s in STAGES
]
qc = snapshot.question_counts
return {
"ok": True,
"error": None,
"generated_at": snapshot.generated_at,
"db_path": snapshot.db_path,
"stages": stages,
"tasks": [_task_payload(t) for t in snapshot.tasks],
"waiting": [_task_payload(t) for t in snapshot.waiting_tasks],
"question_counts": qc,
"summary": {
"active": snapshot.active_count,
"parked": snapshot.parked_count,
"waiting": len(snapshot.waiting_tasks),
"open_questions": int(qc.get("open", 0)),
"total": len(snapshot.tasks),
},
"budget": {
"available": snapshot.budget_available,
"total_usd": snapshot.spend_total_usd,
"recent": snapshot.recent_spend,
},
}
def _task_payload(t: TaskView) -> dict[str, Any]:
"""JSON-safe view of one task (the tooltip + detail-table source)."""
return {
"thread_id": t.thread_id,
"short_id": t.short_id,
"task": t.task,
"current_phase": t.current_phase,
"status": t.status,
"waiting": t.waiting,
"waiting_since": t.waiting_since,
}
def _stage_payload(
stage: Stage,
members: list[TaskView],
state: str | None = None,
) -> dict[str, Any]:
"""JSON-safe view of one pipeline stage node."""
payload = asdict(stage)
payload["state"] = state if state is not None else "idle"
payload["count"] = len(members)
payload["tasks"] = [_task_payload(t) for t in members]
return payload
def _resolve_db_path() -> Path:
"""Resolve the ledger path from ``AGENT_TEAM_DB`` or the package default."""
env = os.environ.get("AGENT_TEAM_DB")
@ -436,6 +628,73 @@ td.mono, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.warn { background: #3a1f16; border: 1px solid #5a3322; color: #f3c79e;
padding: 1rem; border-radius: 8px; }
footer { color: #6b7385; font-size: .78rem; margin-top: 2rem; }
/* --- Pipeline map ------------------------------------------------------- */
.map-wrap {
background: #12151c; border: 1px solid #232a36; border-radius: 10px;
padding: 1rem; overflow-x: auto; position: relative;
}
svg.map { display: block; max-width: 100%; height: auto; }
/* Stage node states. Fill = the box; stroke = its border accent. */
.node rect.box { fill: #1a1e26; stroke: #2f3645; stroke-width: 1.5;
transition: fill .25s, stroke .25s; rx: 9; }
.node text { fill: #e6e6e6; font-size: 13px; font-weight: 600;
text-anchor: middle; pointer-events: none; }
.node text.agent { fill: #8a93a6; font-size: 10.5px; font-weight: 500; }
.node text.gated-tag { fill: #6b7385; font-size: 9px; font-weight: 600;
letter-spacing: .06em; }
.node .count { font-size: 11px; font-weight: 700; fill: #0f1115; }
.node circle.badge { display: none; }
.node.has-tasks circle.badge { display: inline; }
/* state -> accent. idle is the default rect above. */
.node.active rect.box { fill: #163a2b; stroke: #2f8f5e; }
.node.active circle.badge { fill: #6ee7a8; }
.node.awaiting_human rect.box { fill: #3a3216; stroke: #b89a2e; }
.node.awaiting_human circle.badge { fill: #f5d76e; }
.node.parked rect.box { fill: #3a1f16; stroke: #a85f33; }
.node.parked circle.badge { fill: #f3a36e; }
.node.gated rect.box { stroke-dasharray: 5 4; opacity: .82; }
.node.role rect.box { fill: #181c28; stroke-dasharray: 2 3; }
.node:focus { outline: none; }
.node:focus rect.box, .node:hover rect.box { stroke-width: 2.5; }
.edge { stroke: #3b4456; stroke-width: 1.6; fill: none;
marker-end: url(#arrow); }
.edge.loop { stroke: #4a5266; stroke-dasharray: 4 4; }
.edge-label { fill: #6b7385; font-size: 9.5px; text-anchor: middle; }
/* Legend */
.legend { display: flex; flex-wrap: wrap; gap: 1rem; margin: .75rem 0 0;
font-size: .8rem; color: #9aa3b4; align-items: center; }
.legend .key { display: inline-flex; align-items: center; gap: .35rem; }
.legend .swatch { width: 14px; height: 14px; border-radius: 4px;
border: 1px solid #2f3645; display: inline-block; }
.legend .swatch.active { background: #163a2b; border-color: #2f8f5e; }
.legend .swatch.awaiting_human { background: #3a3216; border-color: #b89a2e; }
.legend .swatch.parked { background: #3a1f16; border-color: #a85f33; }
.legend .swatch.idle { background: #1a1e26; }
/* Tooltip (one node, follows hover/focus). */
#tip {
position: fixed; z-index: 10; max-width: 24rem; pointer-events: none;
background: #0b0d12; border: 1px solid #36405a; border-radius: 8px;
padding: .55rem .7rem; font-size: .8rem; color: #d6dcea;
box-shadow: 0 8px 24px rgba(0,0,0,.45); display: none; line-height: 1.35;
}
#tip.show { display: block; }
#tip h4 { margin: 0 0 .35rem; font-size: .82rem; color: #9fb4d6; }
#tip .agent-line { color: #8a93a6; font-size: .74rem; margin-bottom: .35rem; }
#tip ul { margin: .2rem 0 0; padding-left: 0; list-style: none; }
#tip li { margin: .3rem 0; border-top: 1px solid #20283a; padding-top: .3rem; }
#tip li:first-child { border-top: 0; padding-top: 0; }
#tip .tid { font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
color: #7fb3f5; }
#tip .empty-tip { color: #6b7385; font-style: italic; }
#tip .age { color: #8a93a6; }
.live-dot { width: .55rem; height: .55rem; border-radius: 50%;
background: #6ee7a8; display: inline-block; margin-right: .3rem;
vertical-align: middle; }
.live-dot.stale { background: #f3a36e; }
@media (prefers-reduced-motion: reduce) {
.node rect.box, .edge { transition: none; }
}
"""
@ -518,49 +777,362 @@ def _render_budget(snapshot: Snapshot) -> str:
)
# --- Inline SVG pipeline map (hand-rolled, no CDN, no D3). -------------------
#
# Layout: a serpentine of two rows of five stage nodes, flowing row 1 L->R then
# row 2 L->R (with a connector down the right side), so the whole DAG reads in
# one screen. Loop-backs (CLARIFY <-> human gate, PLAN <-> REVIEW) are drawn as
# dashed curved edges so the operator can see the gating cycles.
_NODE_W = 132
_NODE_H = 66
_COL_GAP = 56
_ROW_GAP = 86
_MARGIN = 24
_COLS = 5
def _node_xy(index: int) -> tuple[int, int]:
"""Top-left (x, y) of the stage box at ``index`` in the serpentine grid."""
row, col = divmod(index, _COLS)
x = _MARGIN + col * (_NODE_W + _COL_GAP)
y = _MARGIN + row * (_NODE_H + _ROW_GAP)
return x, y
def _render_node(index: int, stage: Stage, state: str, count: int) -> str:
"""One stage box: state class, label, agent role, gated tag, count badge."""
x, y = _node_xy(index)
classes = ["node", _esc(state)]
if stage.gated:
classes.append("gated")
if stage.role_node:
classes.append("role")
if count > 0:
classes.append("has-tasks")
cls = " ".join(classes)
cx, cy = x + _NODE_W, y # badge anchor: top-right corner
label_x = x + _NODE_W // 2
gated_tag = (
f'<text class="gated-tag" x="{label_x}" y="{y + _NODE_H - 8}">GATED</text>'
if stage.gated
else ""
)
badge = (
f'<circle class="badge" cx="{cx}" cy="{cy}" r="11"></circle>'
f'<text class="count" x="{cx}" y="{cy + 4}">{count}</text>'
)
return (
f'<g class="{cls}" tabindex="0" role="button" '
f'data-stage="{_esc(stage.key)}" '
f'aria-label="{_esc(stage.label)} stage, {_esc(state)}, '
f'{count} task(s)">'
f'<rect class="box" x="{x}" y="{y}" width="{_NODE_W}" '
f'height="{_NODE_H}" rx="9"></rect>'
f'<text x="{label_x}" y="{y + 27}">{_esc(stage.label)}</text>'
f'<text class="agent" x="{label_x}" y="{y + 45}">'
f"{_esc(stage.agent)}</text>"
f"{gated_tag}{badge}</g>"
)
def _straight_edge(a: int, b: int) -> str:
"""Forward edge from node ``a`` to node ``b`` (adjacent in the spine)."""
ax, ay = _node_xy(a)
bx, by = _node_xy(b)
a_row = a // _COLS
b_row = b // _COLS
if a_row == b_row:
# Same row: right edge of a -> left edge of b.
x1, y1 = ax + _NODE_W, ay + _NODE_H // 2
x2, y2 = bx, by + _NODE_H // 2
mx = (x1 + x2) / 2
return (
f'<path class="edge" d="M{x1},{y1} C{mx},{y1} {mx},{y2} {x2},{y2}"></path>'
)
# Row wrap: drop from bottom of a, curve down to top of b.
x1, y1 = ax + _NODE_W // 2, ay + _NODE_H
x2, y2 = bx + _NODE_W // 2, by
my = (y1 + y2) / 2
return f'<path class="edge" d="M{x1},{y1} C{x1},{my} {x2},{my} {x2},{y2}"></path>'
def _loop_edge(a: int, b: int, label: str) -> str:
"""Dashed loop-back edge a<->b drawn as an arc above the two same-row nodes."""
ax, ay = _node_xy(a)
bx, _ = _node_xy(b)
# Arc over the top of both boxes (a is to the right of b on the spine).
x1, y1 = ax + _NODE_W // 2, ay
x2, y2 = bx + _NODE_W // 2, ay
lift = 30
mid_x = (x1 + x2) / 2
path = (
f'<path class="edge loop" d="M{x1},{y1} '
f'C{x1},{y1 - lift} {x2},{y2 - lift} {x2},{y2}"></path>'
)
lbl = (
f'<text class="edge-label" x="{mid_x}" y="{y1 - lift + 2}">{_esc(label)}</text>'
)
return path + lbl
def _render_map_svg(snapshot: Snapshot) -> str:
"""Hand-rolled inline SVG of the agent-team DAG, with live per-stage state.
Deterministic geometry (no measurement), all inline — no external assets.
The JS poller re-paints node classes/counts in place; this server render is
the first paint and the no-JS view.
"""
rows = (len(STAGES) + _COLS - 1) // _COLS
width = _MARGIN * 2 + _COLS * _NODE_W + (_COLS - 1) * _COL_GAP
height = _MARGIN * 2 + rows * _NODE_H + (rows - 1) * _ROW_GAP
parts: list[str] = []
# Forward spine: 0->1->...->n following serpentine order.
for i in range(len(STAGES) - 1):
parts.append(_straight_edge(i, i + 1))
# Loop-backs by stage key (robust to index shifts).
idx = {s.key: i for i, s in enumerate(STAGES)}
if "clarify" in idx and "gate" in idx:
parts.append(_loop_edge(idx["gate"], idx["clarify"], "answer ↺"))
if "plan" in idx and "review" in idx:
parts.append(_loop_edge(idx["review"], idx["plan"], "revise ↺"))
for i, stage in enumerate(STAGES):
members = snapshot.tasks_for_stage(stage.key)
state = snapshot.stage_state(stage)
parts.append(_render_node(i, stage, state, len(members)))
arrow = (
'<defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" '
'markerWidth="7" markerHeight="7" orient="auto-start-reverse">'
'<path d="M0,0 L10,5 L0,10 z" fill="#3b4456"></path></marker></defs>'
)
return (
f'<svg class="map" viewBox="0 0 {width} {height}" '
f'width="{width}" height="{height}" role="img" '
'aria-label="agent-team pipeline map">'
f"{arrow}{''.join(parts)}</svg>"
)
def _render_legend() -> str:
return (
'<div class="legend">'
'<span class="key"><span class="swatch idle"></span>idle</span>'
'<span class="key"><span class="swatch active"></span>active</span>'
'<span class="key"><span class="swatch awaiting_human"></span>'
"awaiting human</span>"
'<span class="key"><span class="swatch parked"></span>parked</span>'
'<span class="key" style="color:#6b7385">'
"dashed border = gated / not-yet-live stage</span>"
"</div>"
)
def _json_for_script(payload: dict[str, Any]) -> str:
"""JSON safe to inline inside a ``<script>`` element.
``json.dumps`` handles the JSON-string escaping. We then escape ``<`` and
``>`` to their ``\\uXXXX`` JSON escapes, which is the standard hardening for
JSON embedded in HTML: it makes it impossible for any value (e.g. a task
description containing ``</script>`` or ``<script>``) to introduce a tag,
comment, or CDATA breakout, while remaining byte-for-byte parseable by
``JSON.parse``. ``ensure_ascii=True`` additionally escapes the unicode line
separators that would otherwise terminate a JS string. Descriptions still
only ever reach the DOM via ``textContent``, never ``innerHTML``.
"""
raw = json.dumps(payload, ensure_ascii=True)
return raw.replace("<", "\\u003c").replace(">", "\\u003e")
def _poller_script(payload: dict[str, Any]) -> str:
"""Inline vanilla-JS poller: fetch /api/state, repaint the map in place.
No libraries, no CDN. Updates node state classes + count badges, the count
cards, the "last updated" clock, and the tooltip dataset — without reloading
the page, so hover/scroll/focus survive. Descriptions reach the DOM only via
``textContent`` (never innerHTML), so they cannot inject markup.
"""
seed = _json_for_script(payload)
return (
"<script>(function(){"
"var POLL=" + str(_POLL_INTERVAL_MS) + ";"
"var state=" + seed + ";"
"var tip=document.getElementById('tip');"
"var clock=document.getElementById('clock');"
"var dot=document.getElementById('livedot');"
# ---- helpers ----
"function age(iso){if(!iso)return '';"
"var t=Date.parse(iso);if(isNaN(t))return '';"
"var s=Math.max(0,(Date.now()-t)/1000);"
"if(s<90)return Math.round(s)+'s ago';"
"if(s<5400)return Math.round(s/60)+'m ago';"
"if(s<129600)return Math.round(s/3600)+'h ago';"
"return Math.round(s/86400)+'d ago';}"
"function byKey(k){var s=state.stages||[];"
"for(var i=0;i<s.length;i++){if(s[i].key===k)return s[i];}return null;}"
"function setNum(id,v){var e=document.getElementById(id);"
"if(e)e.textContent=String(v==null?0:v);}"
# ---- repaint the SVG nodes + cards from `state` ----
"function paint(){"
"var nodes=document.querySelectorAll('g.node[data-stage]');"
"for(var i=0;i<nodes.length;i++){var g=nodes[i];"
"var st=byKey(g.getAttribute('data-stage'));if(!st)continue;"
"['idle','active','awaiting_human','parked','has-tasks']"
".forEach(function(c){g.classList.remove(c);});"
"g.classList.add(st.state||'idle');"
"if((st.count||0)>0)g.classList.add('has-tasks');"
"var cnt=g.querySelector('text.count');"
"if(cnt)cnt.textContent=String(st.count||0);"
"g.setAttribute('aria-label',(st.label||g.getAttribute('data-stage'))"
"+' stage, '+(st.state||'idle')+', '+(st.count||0)+' task(s)');}"
"var sm=state.summary||{};"
"setNum('card-active',sm.active);setNum('card-parked',sm.parked);"
"setNum('card-waiting',sm.waiting);setNum('card-open',sm.open_questions);"
"setNum('card-total',sm.total);}"
# ---- tooltip ----
"function fillTip(st){"
"var h=document.createElement('h4');h.textContent=(st.label||'')+' — '"
"+(st.state||'idle').replace('_',' ');"
"var a=document.createElement('div');a.className='agent-line';"
"a.textContent='agent: '+(st.agent||'—');"
"tip.innerHTML='';tip.appendChild(h);tip.appendChild(a);"
"var tasks=st.tasks||[];"
"if(!tasks.length){var e=document.createElement('div');"
"e.className='empty-tip';"
"e.textContent=(st.gated?'gated / not yet live — ':'')+'no tasks here';"
"tip.appendChild(e);return;}"
"var ul=document.createElement('ul');"
"for(var i=0;i<tasks.length;i++){var t=tasks[i];"
"var li=document.createElement('li');"
"var idline=document.createElement('div');"
"var sp=document.createElement('span');sp.className='tid';"
"sp.textContent=t.short_id||t.thread_id;"
"idline.appendChild(sp);"
"idline.appendChild(document.createTextNode(' · '+(t.status||'')));"
"li.appendChild(idline);"
"var d=document.createElement('div');d.textContent=t.task||'(no description)';"
"li.appendChild(d);"
"var when=t.waiting?t.waiting_since:null;"
"if(when){var ag=document.createElement('div');ag.className='age';"
"ag.textContent='waiting since '+when+(age(when)?' ('+age(when)+')':'');"
"li.appendChild(ag);}"
"ul.appendChild(li);}"
"tip.appendChild(ul);}"
"function showTip(key,ev){var st=byKey(key);if(!st)return;"
"fillTip(st);tip.classList.add('show');moveTip(ev);}"
"function moveTip(ev){if(!ev)return;var pad=14;"
"var x=ev.clientX+pad,y=ev.clientY+pad;"
"var r=tip.getBoundingClientRect();"
"if(x+r.width>window.innerWidth)x=ev.clientX-r.width-pad;"
"if(y+r.height>window.innerHeight)y=ev.clientY-r.height-pad;"
"tip.style.left=x+'px';tip.style.top=y+'px';}"
"function hideTip(){tip.classList.remove('show');}"
# ---- wire node hover/focus ----
"function wire(){var nodes=document.querySelectorAll('g.node[data-stage]');"
"for(var i=0;i<nodes.length;i++){(function(g){"
"var k=g.getAttribute('data-stage');"
"g.addEventListener('mouseenter',function(e){showTip(k,e);});"
"g.addEventListener('mousemove',function(e){moveTip(e);});"
"g.addEventListener('mouseleave',hideTip);"
"g.addEventListener('focus',function(){var st=byKey(k);if(st){"
"fillTip(st);var b=g.getBoundingClientRect();tip.classList.add('show');"
"tip.style.left=(b.left)+'px';tip.style.top=(b.bottom+8)+'px';}});"
"g.addEventListener('blur',hideTip);"
"})(nodes[i]);}}"
# ---- clock ----
"function setClock(){if(clock)clock.textContent=state.generated_at||'';"
"if(dot){dot.classList.remove('stale');}}"
# ---- poll ----
"function apply(s){state=s;paint();setClock();}"
"function poll(){fetch('/api/state',{cache:'no-store'})"
".then(function(r){return r.json();})"
".then(function(s){apply(s);})"
".catch(function(){if(dot)dot.classList.add('stale');});}"
# ---- init ----
"paint();wire();setClock();"
"setInterval(poll,POLL);"
"})();</script>"
)
def render_html(snapshot: Snapshot) -> str:
"""Render a :class:`Snapshot` to a complete, self-contained HTML document.
Pure function: no I/O, no DB, no socket — unit-testable against a fake
snapshot. Inline CSS only and a ``<meta http-equiv="refresh">`` so the page
works fully offline (no external CDN) and auto-refreshes.
snapshot. Inline CSS + inline SVG map + inline JS poller only, plus a
``<noscript>`` meta-refresh fallback, so the page works fully offline (no
external CDN) and updates live without a full reload.
"""
payload = snapshot_to_dict(snapshot)
head = (
'<!doctype html><html lang="en"><head><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width, initial-scale=1">'
# No-JS fallback: full reload. The inline poller refreshes live without
# reloading, so this only fires when JS is disabled.
"<noscript>"
f'<meta http-equiv="refresh" content="{_REFRESH_SECONDS}">'
"</noscript>"
"<title>agent-team status</title>"
f"<style>{_CSS}</style></head><body>"
)
header = (
"<h1>Sea Haven agent-team &mdash; coordinator status</h1>"
f'<div class="meta">Last refresh {_esc(snapshot.generated_at)} '
f"&middot; auto-refresh {_REFRESH_SECONDS}s &middot; "
'<div class="meta"><span class="live-dot" id="livedot"></span>'
'last updated <span class="mono" id="clock">'
f"{_esc(snapshot.generated_at)}</span> &middot; live "
f"(polls /api/state every {_POLL_INTERVAL_MS // 1000}s) &middot; "
f'<span class="mono">{_esc(snapshot.db_path)}</span> &middot; '
"read-only, LAN/VPN-only</div>"
)
tooltip = '<div id="tip" role="tooltip"></div>'
# The map + tooltip + poller render on every path (including no-data) so the
# page goes live the moment the coordinator produces state, without a reload.
map_section = (
"<h2>Pipeline map</h2>"
'<div class="map-wrap">'
+ _render_map_svg(snapshot)
+ "</div>"
+ _render_legend()
)
if not snapshot.ok:
body = (
warn = (
'<div class="warn"><strong>No data.</strong><br>'
f"{_esc(snapshot.error or 'ledger unavailable')}<br>"
"The dashboard is read-only and waits for the coordinator to "
"produce state.</div>"
"produce state. The map will populate live once it does.</div>"
)
return (
head
+ header
+ tooltip
+ map_section
+ warn
+ _footer()
+ _poller_script(payload)
+ "</body></html>"
)
return head + header + body + _footer() + "</body></html>"
qc = snapshot.question_counts
cards = (
'<div class="cards">'
f'<div class="card"><div class="num">{snapshot.active_count}</div>'
f'<div class="card"><div class="num" id="card-active">'
f"{snapshot.active_count}</div>"
'<div class="lbl">Active</div></div>'
f'<div class="card"><div class="num">{snapshot.parked_count}</div>'
f'<div class="card"><div class="num" id="card-parked">'
f"{snapshot.parked_count}</div>"
'<div class="lbl">Parked</div></div>'
f'<div class="card"><div class="num">{len(snapshot.waiting_tasks)}</div>'
f'<div class="card"><div class="num" id="card-waiting">'
f"{len(snapshot.waiting_tasks)}</div>"
'<div class="lbl">Waiting (gate)</div></div>'
f'<div class="card"><div class="num">{qc.get("open", 0)}</div>'
f'<div class="card"><div class="num" id="card-open">'
f"{qc.get('open', 0)}</div>"
'<div class="lbl">Open questions</div></div>'
f'<div class="card"><div class="num">{len(snapshot.tasks)}</div>'
f'<div class="card"><div class="num" id="card-total">'
f"{len(snapshot.tasks)}</div>"
'<div class="lbl">Total tasks</div></div>'
"</div>"
)
@ -582,11 +1154,14 @@ def render_html(snapshot: Snapshot) -> str:
return (
head
+ header
+ tooltip
+ cards
+ map_section
+ waiting_section
+ tasks_section
+ budget_section
+ _footer()
+ _poller_script(payload)
+ "</body></html>"
)
@ -608,13 +1183,51 @@ def _make_handler(db_path: Path) -> type[BaseHTTPRequestHandler]:
class _StatusHandler(BaseHTTPRequestHandler):
server_version = "AgentTeamStatus/1.0"
# Strip any query string so "/api/state?ts=123" (a cache-buster the JS
# may append) still routes. Read-only: only GET, only these two routes.
def _route(self) -> str:
return self.path.split("?", 1)[0]
def do_GET(self) -> None: # noqa: N802 (BaseHTTPRequestHandler API)
# Read-only: every path serves the dashboard. Favicon gets a 204 so
# browsers stop logging 404s; there are no other routes.
if self.path == "/favicon.ico":
route = self._route()
# Favicon gets a 204 so browsers stop logging 404s.
if route == "/favicon.ico":
self.send_response(204)
self.end_headers()
return
if route == "/api/state":
self._serve_json()
return
# Every other path serves the dashboard HTML.
self._serve_html()
def _serve_json(self) -> None:
try:
payload = snapshot_to_dict(build_snapshot(db_path))
except Exception as exc: # last-ditch: never 500 the sidecar
payload = {
"ok": False,
"error": f"snapshot error: {exc}",
"generated_at": _utc_now_iso(),
"db_path": str(db_path),
"stages": [],
"tasks": [],
"waiting": [],
"question_counts": {},
"summary": {},
"budget": {"available": False},
}
body = json.dumps(payload).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def _serve_html(self) -> None:
try:
snapshot = build_snapshot(db_path)
page = render_html(snapshot)