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:
parent
bd5bec2f9a
commit
72098ba26e
1 changed files with 636 additions and 23 deletions
|
|
@ -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 — coordinator status</h1>"
|
||||
f'<div class="meta">Last refresh {_esc(snapshot.generated_at)} '
|
||||
f"· auto-refresh {_REFRESH_SECONDS}s · "
|
||||
'<div class="meta"><span class="live-dot" id="livedot"></span>'
|
||||
'last updated <span class="mono" id="clock">'
|
||||
f"{_esc(snapshot.generated_at)}</span> · live "
|
||||
f"(polls /api/state every {_POLL_INTERVAL_MS // 1000}s) · "
|
||||
f'<span class="mono">{_esc(snapshot.db_path)}</span> · '
|
||||
"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)
|
||||
|
|
|
|||
Reference in a new issue