Merge pull request #48 from Sea-Haven-Industries/feat/agent-team-status-page
feat(agent-team): LAN-only read-only status dashboard for the coordinator
This commit is contained in:
commit
dd6aef0f16
5 changed files with 1841 additions and 0 deletions
|
|
@ -163,6 +163,63 @@ bearer auth) only if you want the `/delegate` Claude Code hook or the
|
||||||
The `/docs` + `/openapi` routes are disabled and it binds loopback by design (do
|
The `/docs` + `/openapi` routes are disabled and it binds loopback by design (do
|
||||||
not change to `0.0.0.0`). See the deploy script's step 6 for how to start it.
|
not change to `0.0.0.0`). See the deploy script's step 6 for how to start it.
|
||||||
|
|
||||||
|
### Status dashboard (optional, LAN/VPN-only, READ-ONLY)
|
||||||
|
|
||||||
|
`agent_team/status_page.py` serves a **live visual pipeline map** of the
|
||||||
|
coordinator. The top of the page is a hand-rolled inline-SVG diagram of the
|
||||||
|
agent-team DAG (`INTAKE → CLARIFY ⇄ human gate → PLAN ⇄ REVIEW →
|
||||||
|
[BUILD → VERIFY → DISPATCH] → DONE`); each stage node is labelled with its model
|
||||||
|
role (Claude on subscription for CLARIFY/PLAN/VERIFY, GPT-4.1 cross_reviewer for
|
||||||
|
REVIEW, Gemini for SCAN, DeepSeek fast_coder for BUILD, the Slack owner for the
|
||||||
|
HUMAN GATE) and is colour-coded by live state — idle / active / awaiting-human
|
||||||
|
(an **open** pending question) / parked — with a count badge of tasks in that
|
||||||
|
stage. Hovering (or keyboard-focusing) a node shows what it is working on: the
|
||||||
|
short `thread_id`, description, status, and waiting age of each task there.
|
||||||
|
Below the map, the original detail tables remain: all tasks, the human-gate
|
||||||
|
wait-list, and recent `budget_ledger` spend.
|
||||||
|
|
||||||
|
The page **auto-updates without a full reload**: a `GET /api/state` JSON sidecar
|
||||||
|
returns the same snapshot, and an inline vanilla-JS poller (`fetch()`, no
|
||||||
|
libraries, no CDN) re-paints node states, counts, the cards, the tooltip data,
|
||||||
|
and the "last updated" clock every ~4s in place, so hover/scroll/focus survive.
|
||||||
|
A `<noscript>` 10s 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 VM is offline/LAN-only. It opens the SQLite ledger
|
||||||
|
**READ-ONLY** (`mode=ro`), exposes only the two read GETs (`/` and `/api/state`)
|
||||||
|
and **no mutating endpoints and no auth**.
|
||||||
|
|
||||||
|
It is a **separate, optional process** — `agent-team-status.service` (mirrors the
|
||||||
|
coordinator unit's hardening; `User=adam`, `EnvironmentFile=-/home/adam/secrev.env`,
|
||||||
|
venv-python ExecStart, `Restart=on-failure`). Unlike the coordinator it needs **no**
|
||||||
|
`ReadWritePaths` carve-out (it only reads). It can run side-by-side with the
|
||||||
|
coordinator (RO SQLite opens coexist with the writer).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# install the unit
|
||||||
|
sudo cp ~/orchestrator/agent-team/systemd/agent-team-status.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now agent-team-status.service
|
||||||
|
systemctl status agent-team-status.service
|
||||||
|
journalctl -u agent-team-status.service -e -f
|
||||||
|
|
||||||
|
# or run it ad hoc from the venv
|
||||||
|
cd ~/orchestrator/agent-team && . .venv/bin/activate && \
|
||||||
|
python3 -c "from agent_team.status_page import serve; serve()"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then browse `http://10.10.60.120:8770/` from the LAN/VPN (the live map polls
|
||||||
|
`http://10.10.60.120:8770/api/state` itself).
|
||||||
|
|
||||||
|
**Config (env):** `AGENT_TEAM_DB` (default `state/agent_team.sqlite`),
|
||||||
|
`AGENT_TEAM_STATUS_HOST` (default `0.0.0.0`), `AGENT_TEAM_STATUS_PORT` (default
|
||||||
|
`8770`).
|
||||||
|
|
||||||
|
**Posture:** the sh-secrev VM (`10.10.60.120`, VLAN 60) has no public NIC and sits
|
||||||
|
behind the UniFi firewall, so `0.0.0.0` reaches the **LAN/VPN only**. Task
|
||||||
|
descriptions may be sensitive and the page is unauthenticated — **keep it
|
||||||
|
LAN/VPN-only, never expose it to the public internet.** A missing/locked DB renders
|
||||||
|
a friendly "no data" page rather than crashing.
|
||||||
|
|
||||||
## 5. P1 live exit-criteria demo (§3.3.1)
|
## 5. P1 live exit-criteria demo (§3.3.1)
|
||||||
|
|
||||||
Demonstrate all four once the service is live. Map each to the operator commands
|
Demonstrate all four once the service is live. Map each to the operator commands
|
||||||
|
|
|
||||||
1297
agent-team/agent_team/status_page.py
Normal file
1297
agent-team/agent_team/status_page.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -95,6 +95,24 @@ Then set AGENT_TEAM_API_TOKEN + AGENT_TEAM_API_URL in the Mac Claude Code env
|
||||||
to enable the /delegate hook (sea-haven-claude-plugin).
|
to enable the /delegate hook (sea-haven-claude-plugin).
|
||||||
NOTE
|
NOTE
|
||||||
|
|
||||||
|
say "6b. (OPTIONAL) READ-ONLY status dashboard — LAN/VPN-only"
|
||||||
|
cat <<'NOTE'
|
||||||
|
agent_team/status_page.py serves a self-refreshing HTML view of the queue
|
||||||
|
(tasks/phases, who is waiting on the human gate, active/parked counts, recent
|
||||||
|
budget spend). It opens the ledger READ-ONLY (mode=ro), has no mutating
|
||||||
|
endpoints and NO auth. Separate, optional process from the coordinator.
|
||||||
|
- install the unit (mirrors the coordinator hardening; reads only, no RW carve-out):
|
||||||
|
sudo cp ~/orchestrator/agent-team/systemd/agent-team-status.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload && sudo systemctl enable --now agent-team-status.service
|
||||||
|
- or run ad hoc:
|
||||||
|
cd ~/orchestrator/agent-team && . .venv/bin/activate && \
|
||||||
|
python3 -c "from agent_team.status_page import serve; serve()"
|
||||||
|
- then browse http://10.10.60.120:8770/ from the LAN/VPN.
|
||||||
|
POSTURE: binds AGENT_TEAM_STATUS_HOST (default 0.0.0.0) on AGENT_TEAM_STATUS_PORT
|
||||||
|
(default 8770). The sh-secrev VM has no public NIC + sits behind the UniFi
|
||||||
|
firewall -> LAN/VPN only. Task descriptions may be sensitive; never expose public.
|
||||||
|
NOTE
|
||||||
|
|
||||||
say "7. SMOKE TESTS (manual)"
|
say "7. SMOKE TESTS (manual)"
|
||||||
cat <<'SMOKE'
|
cat <<'SMOKE'
|
||||||
a) Coordinator up: systemctl is-active agent-team-coordinator.service -> active
|
a) Coordinator up: systemctl is-active agent-team-coordinator.service -> active
|
||||||
|
|
|
||||||
53
agent-team/systemd/agent-team-status.service
Normal file
53
agent-team/systemd/agent-team-status.service
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
# agent-team-status.service - R720 LAN-only READ-ONLY status dashboard (sh-secrev VM, user adam).
|
||||||
|
#
|
||||||
|
# A tiny stdlib http.server that renders the agent-team coordinator's queue
|
||||||
|
# (tasks/phases, who is waiting on the human gate, active/parked counts, recent
|
||||||
|
# budget spend) as a 10s-auto-refresh HTML page. It opens the SQLite ledger
|
||||||
|
# READ-ONLY (mode=ro) and never writes; it has no mutating endpoints and no auth.
|
||||||
|
#
|
||||||
|
# NETWORK POSTURE: binds AGENT_TEAM_STATUS_HOST (default 0.0.0.0) on port
|
||||||
|
# AGENT_TEAM_STATUS_PORT (default 8770). The sh-secrev VM (10.10.60.120, VLAN 60)
|
||||||
|
# has NO public NIC and sits behind the UniFi firewall, so 0.0.0.0 reaches the
|
||||||
|
# LAN/VPN only. Task descriptions may be sensitive -> keep this LAN/VPN-only,
|
||||||
|
# never expose to the public internet.
|
||||||
|
#
|
||||||
|
# Install (on the VM, as root):
|
||||||
|
# sudo cp agent-team-status.service /etc/systemd/system/
|
||||||
|
# sudo systemctl daemon-reload
|
||||||
|
# sudo systemctl enable --now agent-team-status.service
|
||||||
|
# systemctl status agent-team-status.service
|
||||||
|
# journalctl -u agent-team-status.service -e -f
|
||||||
|
#
|
||||||
|
# This is a SEPARATE, OPTIONAL process from agent-team-coordinator.service. The
|
||||||
|
# coordinator owns the ledger (read/write); this unit only reads it. They can run
|
||||||
|
# side by side: SQLite WAL/RO opens coexist with the coordinator's writer.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Sea Haven agent-team LAN-only read-only status dashboard
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=adam
|
||||||
|
WorkingDirectory=/home/adam/orchestrator/agent-team
|
||||||
|
# Optional ('-'): the dashboard reads no secrets, but loading the same env file
|
||||||
|
# as the coordinator lets AGENT_TEAM_DB / AGENT_TEAM_STATUS_* overrides live in
|
||||||
|
# one place if set there.
|
||||||
|
EnvironmentFile=-/home/adam/secrev.env
|
||||||
|
# Use the agent-team venv interpreter (where langgraph + the checkpoint dep are
|
||||||
|
# installed), NOT the bare system python3 that systemd's PATH would resolve.
|
||||||
|
ExecStart=/home/adam/orchestrator/agent-team/.venv/bin/python -c "from agent_team.status_page import serve; serve()"
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
# Hardening - mirrors agent-team-coordinator.service, but this unit only READS
|
||||||
|
# the ledger, so it needs NO ReadWritePaths carve-out at all (ProtectHome can be
|
||||||
|
# read-only and ProtectSystem full; the RO sqlite open lives under read-only
|
||||||
|
# home, which is sufficient for mode=ro).
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=read-only
|
||||||
|
Nice=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
416
agent-team/tests/test_status_page.py
Normal file
416
agent-team/tests/test_status_page.py
Normal file
|
|
@ -0,0 +1,416 @@
|
||||||
|
"""Tests for the LAN-only read-only status dashboard (``agent_team.status_page``).
|
||||||
|
|
||||||
|
No live socket is bound: :func:`render_html` is exercised against fabricated
|
||||||
|
:class:`Snapshot` objects, and the read-only reader (:func:`build_snapshot`) is
|
||||||
|
exercised against a temp SQLite ledger seeded with a couple of rows (a real
|
||||||
|
``SqliteSaver`` checkpoint plus ``pending_questions`` / ``budget_ledger`` rows).
|
||||||
|
``conftest.py`` already puts ``agent-team/`` on ``sys.path``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from contextlib import closing
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agent_team.status_page import (
|
||||||
|
STAGES,
|
||||||
|
Snapshot,
|
||||||
|
TaskView,
|
||||||
|
build_snapshot,
|
||||||
|
render_html,
|
||||||
|
snapshot_to_dict,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- pure render_html tests (no DB, no socket). ------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_snapshot() -> Snapshot:
|
||||||
|
return Snapshot(
|
||||||
|
generated_at="2026-06-23 12:00:00 UTC",
|
||||||
|
db_path="/home/adam/orchestrator/agent-team/state/agent_team.sqlite",
|
||||||
|
ok=True,
|
||||||
|
tasks=[
|
||||||
|
TaskView(
|
||||||
|
thread_id="abc123def456",
|
||||||
|
short_id="abc123de",
|
||||||
|
task="remediate CVE-2026-0001 in payments-svc",
|
||||||
|
current_phase="clarify",
|
||||||
|
status="waiting_human",
|
||||||
|
waiting=True,
|
||||||
|
waiting_since="2026-06-23T11:50:00+00:00",
|
||||||
|
),
|
||||||
|
TaskView(
|
||||||
|
thread_id="ffff0000aaaa",
|
||||||
|
short_id="ffff0000",
|
||||||
|
task="add a smoke-test file",
|
||||||
|
current_phase="plan",
|
||||||
|
status="active",
|
||||||
|
waiting=False,
|
||||||
|
),
|
||||||
|
TaskView(
|
||||||
|
thread_id="dead0000beef",
|
||||||
|
short_id="dead0000",
|
||||||
|
task="stalled task",
|
||||||
|
current_phase="parked",
|
||||||
|
status="parked",
|
||||||
|
waiting=False,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
question_counts={"open": 1, "answered": 4, "expired": 1},
|
||||||
|
recent_spend=[
|
||||||
|
{
|
||||||
|
"recorded_at": "2026-06-23T11:00:00+00:00",
|
||||||
|
"stage": "plan",
|
||||||
|
"model": "claude-opus-4",
|
||||||
|
"usd_cost": 0.1234,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
spend_total_usd=1.5,
|
||||||
|
budget_available=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_html_returns_complete_document() -> None:
|
||||||
|
html_out = render_html(_sample_snapshot())
|
||||||
|
assert html_out.startswith("<!doctype html>")
|
||||||
|
assert html_out.rstrip().endswith("</html>")
|
||||||
|
# Auto-refresh + offline (no external CDN / http references).
|
||||||
|
assert '<meta http-equiv="refresh" content="10">' in html_out
|
||||||
|
assert "http://" not in html_out and "https://" not in html_out
|
||||||
|
assert "<style>" in html_out # inline CSS only
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_html_shows_counts_and_waiting() -> None:
|
||||||
|
snap = _sample_snapshot()
|
||||||
|
html_out = render_html(snap)
|
||||||
|
assert snap.active_count == 2 # active + waiting_human
|
||||||
|
assert snap.parked_count == 1
|
||||||
|
assert len(snap.waiting_tasks) == 1
|
||||||
|
# The waiting task and its phase are surfaced.
|
||||||
|
assert "abc123de" in html_out
|
||||||
|
assert "remediate CVE-2026-0001 in payments-svc" in html_out
|
||||||
|
assert "Waiting on the human gate" in html_out
|
||||||
|
# Budget panel present with a total.
|
||||||
|
assert "Budget" in html_out
|
||||||
|
assert "claude-opus-4" in html_out
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_html_escapes_task_descriptions() -> None:
|
||||||
|
snap = Snapshot(
|
||||||
|
generated_at="now",
|
||||||
|
db_path="/x.sqlite",
|
||||||
|
ok=True,
|
||||||
|
tasks=[
|
||||||
|
TaskView(
|
||||||
|
thread_id="x",
|
||||||
|
short_id="x",
|
||||||
|
task="<script>alert('xss')</script>",
|
||||||
|
current_phase="intake",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
html_out = render_html(snap)
|
||||||
|
assert "<script>alert" not in html_out
|
||||||
|
assert "<script>" in html_out
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_html_no_data_page_on_not_ok() -> None:
|
||||||
|
snap = Snapshot(
|
||||||
|
generated_at="now",
|
||||||
|
db_path="/missing.sqlite",
|
||||||
|
ok=False,
|
||||||
|
error="ledger not found",
|
||||||
|
)
|
||||||
|
html_out = render_html(snap)
|
||||||
|
assert "No data" in html_out
|
||||||
|
assert "ledger not found" in html_out
|
||||||
|
assert html_out.rstrip().endswith("</html>")
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_html_omits_budget_when_unavailable() -> None:
|
||||||
|
snap = Snapshot(
|
||||||
|
generated_at="now",
|
||||||
|
db_path="/x.sqlite",
|
||||||
|
ok=True,
|
||||||
|
tasks=[],
|
||||||
|
budget_available=False,
|
||||||
|
)
|
||||||
|
html_out = render_html(snap)
|
||||||
|
assert "Budget" not in html_out
|
||||||
|
|
||||||
|
|
||||||
|
# --- read-only reader tests against a temp seeded SQLite ledger. -------------
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_db(db_path: Path) -> None:
|
||||||
|
"""Create the agent-team schema and seed a couple of rows.
|
||||||
|
|
||||||
|
Seeds one pending question (open) and one budget row via the foundation
|
||||||
|
schema, and one LangGraph checkpoint per thread via the real ``SqliteSaver``
|
||||||
|
so the reader's checkpoint path is exercised faithfully.
|
||||||
|
"""
|
||||||
|
from agent_team.db.schema import init_db
|
||||||
|
from langgraph.checkpoint.base import empty_checkpoint
|
||||||
|
from langgraph.checkpoint.sqlite import SqliteSaver
|
||||||
|
|
||||||
|
init_db(db_path)
|
||||||
|
|
||||||
|
with closing(sqlite3.connect(str(db_path))) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO pending_questions "
|
||||||
|
"(question_id, thread_id, turn, status, transport, posted_at) "
|
||||||
|
"VALUES (?, ?, ?, 'open', 'slack', ?)",
|
||||||
|
("q1", "thread-waiting", 0, "2026-06-23T11:50:00+00:00"),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO budget_ledger "
|
||||||
|
"(thread_id, stage, model, billing_mode, usd_cost, recorded_at, day_bucket) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
"thread-waiting",
|
||||||
|
"plan",
|
||||||
|
"claude-opus-4",
|
||||||
|
"subscription",
|
||||||
|
0.25,
|
||||||
|
"2026-06-23T11:00:00+00:00",
|
||||||
|
"2026-06-23",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Seed checkpoints for two threads via the real saver.
|
||||||
|
with closing(sqlite3.connect(str(db_path), check_same_thread=False)) as conn:
|
||||||
|
saver = SqliteSaver(conn)
|
||||||
|
for tid, values in (
|
||||||
|
(
|
||||||
|
"thread-waiting",
|
||||||
|
{
|
||||||
|
"task": "remediate CVE",
|
||||||
|
"current_phase": "clarify",
|
||||||
|
"status": "waiting_human",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"thread-active",
|
||||||
|
{
|
||||||
|
"task": "add a file",
|
||||||
|
"current_phase": "plan",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
ck = empty_checkpoint()
|
||||||
|
ck["channel_values"] = values
|
||||||
|
saver.put(
|
||||||
|
{"configurable": {"thread_id": tid, "checkpoint_ns": ""}},
|
||||||
|
ck,
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_snapshot_reads_seeded_ledger(tmp_path: Path) -> None:
|
||||||
|
db = tmp_path / "agent_team.sqlite"
|
||||||
|
_seed_db(db)
|
||||||
|
|
||||||
|
snap = build_snapshot(db)
|
||||||
|
|
||||||
|
assert snap.ok is True
|
||||||
|
assert snap.error is None
|
||||||
|
# Two checkpointed threads enumerated.
|
||||||
|
by_id = {t.thread_id: t for t in snap.tasks}
|
||||||
|
assert set(by_id) == {"thread-waiting", "thread-active"}
|
||||||
|
|
||||||
|
waiting = by_id["thread-waiting"]
|
||||||
|
assert waiting.task == "remediate CVE"
|
||||||
|
assert waiting.current_phase == "clarify"
|
||||||
|
assert waiting.status == "waiting_human"
|
||||||
|
assert waiting.waiting is True # has an open pending_question
|
||||||
|
assert waiting.waiting_since == "2026-06-23T11:50:00+00:00"
|
||||||
|
|
||||||
|
active = by_id["thread-active"]
|
||||||
|
assert active.waiting is False
|
||||||
|
assert active.status == "active"
|
||||||
|
|
||||||
|
# Queue summary + question counts.
|
||||||
|
assert snap.active_count == 2 # active + waiting_human
|
||||||
|
assert snap.parked_count == 0
|
||||||
|
assert snap.question_counts.get("open") == 1
|
||||||
|
|
||||||
|
# Budget read.
|
||||||
|
assert snap.budget_available is True
|
||||||
|
assert snap.spend_total_usd == pytest.approx(0.25)
|
||||||
|
assert snap.recent_spend[0]["model"] == "claude-opus-4"
|
||||||
|
|
||||||
|
# The whole thing renders without error.
|
||||||
|
html_out = render_html(snap)
|
||||||
|
assert "remediate CVE" in html_out
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_snapshot_missing_db_is_fail_safe(tmp_path: Path) -> None:
|
||||||
|
snap = build_snapshot(tmp_path / "does-not-exist.sqlite")
|
||||||
|
assert snap.ok is False
|
||||||
|
assert snap.error and "not found" in snap.error
|
||||||
|
# Still renders a friendly page rather than raising.
|
||||||
|
html_out = render_html(snap)
|
||||||
|
assert "No data" in html_out
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_snapshot_never_writes(tmp_path: Path) -> None:
|
||||||
|
"""The reader must open mode=ro; it can never create the DB file."""
|
||||||
|
missing = tmp_path / "nope.sqlite"
|
||||||
|
build_snapshot(missing)
|
||||||
|
assert not missing.exists() # mode=ro did not create it
|
||||||
|
|
||||||
|
|
||||||
|
# --- pipeline-map + /api/state JSON contract tests ---------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_html_includes_svg_map_and_poller() -> None:
|
||||||
|
"""The page must carry the inline SVG map and the inline JS poller."""
|
||||||
|
html_out = render_html(_sample_snapshot())
|
||||||
|
# Inline SVG map (hand-rolled, not fetched).
|
||||||
|
assert '<svg class="map"' in html_out
|
||||||
|
assert 'data-stage="clarify"' in html_out
|
||||||
|
# Inline poller that fetches the JSON sidecar — no external libraries.
|
||||||
|
assert "<script>" in html_out
|
||||||
|
assert "fetch('/api/state'" in html_out
|
||||||
|
assert "setInterval(poll" in html_out
|
||||||
|
# Tooltip mount + live clock the poller updates.
|
||||||
|
assert 'id="tip"' in html_out
|
||||||
|
assert 'id="clock"' in html_out
|
||||||
|
# Legend present.
|
||||||
|
assert "awaiting human" in html_out
|
||||||
|
# Still fully offline: no CDN / external references.
|
||||||
|
assert "http://" not in html_out and "https://" not in html_out
|
||||||
|
# The no-JS fallback is the meta-refresh, now inside <noscript>.
|
||||||
|
assert "<noscript>" in html_out
|
||||||
|
assert '<meta http-equiv="refresh" content="10">' in html_out
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_html_renders_map_even_with_no_data() -> None:
|
||||||
|
"""A not-ok snapshot still renders the map + poller so it goes live later."""
|
||||||
|
snap = Snapshot(generated_at="now", db_path="/x.sqlite", ok=False, error="boom")
|
||||||
|
html_out = render_html(snap)
|
||||||
|
assert '<svg class="map"' in html_out
|
||||||
|
assert "fetch('/api/state'" in html_out
|
||||||
|
assert "No data" in html_out
|
||||||
|
assert html_out.rstrip().endswith("</html>")
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_to_dict_shape_and_grouping() -> None:
|
||||||
|
"""The /api/state payload has the expected keys + correct stage grouping."""
|
||||||
|
payload = snapshot_to_dict(_sample_snapshot())
|
||||||
|
assert payload["ok"] is True
|
||||||
|
assert set(payload) >= {
|
||||||
|
"ok",
|
||||||
|
"generated_at",
|
||||||
|
"stages",
|
||||||
|
"tasks",
|
||||||
|
"waiting",
|
||||||
|
"question_counts",
|
||||||
|
"summary",
|
||||||
|
"budget",
|
||||||
|
}
|
||||||
|
# One stage entry per declared STAGE, in declared order.
|
||||||
|
assert [s["key"] for s in payload["stages"]] == [s.key for s in STAGES]
|
||||||
|
|
||||||
|
by_key = {s["key"]: s for s in payload["stages"]}
|
||||||
|
# The waiting clarify task lands on the clarify node and flags awaiting_human.
|
||||||
|
clarify = by_key["clarify"]
|
||||||
|
assert clarify["count"] == 1
|
||||||
|
assert clarify["state"] == "awaiting_human"
|
||||||
|
assert clarify["tasks"][0]["short_id"] == "abc123de"
|
||||||
|
# Each stage carries its model/agent role for the per-node label.
|
||||||
|
assert clarify["agent"] == "Claude (sub)"
|
||||||
|
assert by_key["review"]["agent"] == "GPT-4.1 (cross)"
|
||||||
|
assert by_key["build"]["agent"] == "DeepSeek (fast)"
|
||||||
|
# The active plan task lands on the plan node as 'active'.
|
||||||
|
assert by_key["plan"]["state"] == "active"
|
||||||
|
assert by_key["plan"]["count"] == 1
|
||||||
|
# The parked task maps onto the (terminal-ish) parked side — no stage owns
|
||||||
|
# the 'parked' phase, so no pipeline node claims it.
|
||||||
|
assert all(
|
||||||
|
t["thread_id"] != "dead0000beef" for s in payload["stages"] for t in s["tasks"]
|
||||||
|
)
|
||||||
|
# An idle stage with no tasks.
|
||||||
|
assert by_key["intake"]["state"] == "idle"
|
||||||
|
assert by_key["intake"]["count"] == 0
|
||||||
|
|
||||||
|
# Summary mirrors the snapshot counts.
|
||||||
|
assert payload["summary"]["active"] == 2
|
||||||
|
assert payload["summary"]["waiting"] == 1
|
||||||
|
assert payload["summary"]["open_questions"] == 1
|
||||||
|
assert payload["summary"]["total"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_to_dict_not_ok_is_serializable() -> None:
|
||||||
|
"""A not-ok snapshot still serializes to a valid, JSON-dumpable payload."""
|
||||||
|
snap = Snapshot(generated_at="now", db_path="/x.sqlite", ok=False, error="boom")
|
||||||
|
payload = snapshot_to_dict(snap)
|
||||||
|
assert payload["ok"] is False
|
||||||
|
assert payload["error"] == "boom"
|
||||||
|
# Stages still present (all idle) so the client can render the empty map.
|
||||||
|
assert [s["key"] for s in payload["stages"]] == [s.key for s in STAGES]
|
||||||
|
assert all(s["count"] == 0 for s in payload["stages"])
|
||||||
|
# Round-trips through json.
|
||||||
|
assert json.loads(json.dumps(payload))["ok"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_descriptions_escaped_in_html_and_json_seed() -> None:
|
||||||
|
"""Hostile descriptions must not break out of HTML *or* the inline JSON."""
|
||||||
|
snap = Snapshot(
|
||||||
|
generated_at="now",
|
||||||
|
db_path="/x.sqlite",
|
||||||
|
ok=True,
|
||||||
|
tasks=[
|
||||||
|
TaskView(
|
||||||
|
thread_id="x",
|
||||||
|
short_id="x",
|
||||||
|
task="</script><script>alert(1)</script>",
|
||||||
|
current_phase="plan",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
html_out = render_html(snap)
|
||||||
|
# No raw script tag survives anywhere in the document (table render escapes
|
||||||
|
# it; the inline JSON seed escapes < and > to \\uXXXX).
|
||||||
|
assert "<script>alert(1)" not in html_out
|
||||||
|
assert "</script><script>" not in html_out
|
||||||
|
assert "\\u003cscript\\u003e" in html_out # JSON seed escaped form
|
||||||
|
|
||||||
|
# The JSON sidecar itself is valid JSON carrying the raw description (the
|
||||||
|
# consumer renders it via textContent, never as markup).
|
||||||
|
payload = snapshot_to_dict(snap)
|
||||||
|
body = json.dumps(payload)
|
||||||
|
parsed = json.loads(body)
|
||||||
|
assert parsed["tasks"][0]["task"] == "</script><script>alert(1)</script>"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_snapshot_api_payload_against_seeded_ledger(tmp_path: Path) -> None:
|
||||||
|
"""End-to-end: read a seeded ledger, then assert the /api/state payload."""
|
||||||
|
db = tmp_path / "agent_team.sqlite"
|
||||||
|
_seed_db(db)
|
||||||
|
|
||||||
|
payload = snapshot_to_dict(build_snapshot(db))
|
||||||
|
|
||||||
|
assert payload["ok"] is True
|
||||||
|
by_key = {s["key"]: s for s in payload["stages"]}
|
||||||
|
# thread-waiting (clarify, open question) -> clarify node, awaiting_human.
|
||||||
|
assert by_key["clarify"]["state"] == "awaiting_human"
|
||||||
|
assert by_key["clarify"]["count"] == 1
|
||||||
|
assert by_key["clarify"]["tasks"][0]["thread_id"] == "thread-waiting"
|
||||||
|
# thread-active (plan, active) -> plan node, active.
|
||||||
|
assert by_key["plan"]["state"] == "active"
|
||||||
|
assert by_key["plan"]["count"] == 1
|
||||||
|
# Budget surfaced.
|
||||||
|
assert payload["budget"]["available"] is True
|
||||||
|
assert payload["budget"]["total_usd"] == pytest.approx(0.25)
|
||||||
|
# Whole payload is JSON-serializable.
|
||||||
|
assert json.loads(json.dumps(payload))["summary"]["total"] == 2
|
||||||
Reference in a new issue