What changed, and why
The agent has enough moving parts now — the W2 lifecycle, the rule registry and its autonomy ladder, proactive offers, YNAB writes, per-transaction email threads — that there was no single place to see the system as a whole. This adds one: a private, read-only dashboard the worker hosts in-process, reached over kubectl port-forward.
The shape (borrowed from froot's dashboard): a tiny dependency-free asyncio HTTP server reuses the worker's own Temporal client, fans five readers out concurrently on each GET, assembles a pure read-model, and renders one self-contained HTML page. It is derived live and stores nothing, and every reader degrades to an error string → a red dot, so a source being down yields a warning rather than a crash — and never touches the worker's real work. New code only; the worker's existing behaviour is untouched.
The server: derive on request, store nothing
build_html is the whole per-request job: asyncio.gather the five readers (each returns (data, error)), assemble, render. The HTTP layer is deliberately minimal — GET-only, Cache-Control: no-store, Connection: close, a 15s read timeout and a 64 KB header cap — because the only client is one human behind a port-forward.
One gather, one assemble, one render — nothing held between requests.
src/ynab_agent/dashboard/server.py · 170 lines
⋯ 47 lines hidden (lines 1–47)
⋯ 100 lines hidden (lines 71–170)
Reusing the worker's client (and not stopping it)
The server is started from run_worker after the Temporal client connects and before the worker runs, then closed in the same finally that flushes telemetry. It reuses that connected client (so the Temporal reader needs no second connection), and — the load-bearing detail — a start failure is logged, never raised: the dashboard must never be the reason the worker doesn't run.
Gated on YNAB_AGENT_DASHBOARD_ENABLED; try/except so a failure is non-fatal.
src/ynab_agent/worker.py · 137 lines
⋯ 113 lines hidden (lines 1–113)
The Temporal reader: the live run ledger
This is the load-bearing source — it reads the agent's durable state directly. fetch is best-effort end to end: it gathers the poll heartbeat, the in-flight W2 funnel, the autonomy ladder, offers, the dispatch tally, and failures, and on any error returns whatever it gathered plus an error string.
Each sub-read contributes what it can; the registry's absence is normal, not an error.
src/ynab_agent/dashboard/temporal_source.py · 338 lines
⋯ 273 lines hidden (lines 1–273)
⋯ 38 lines hidden (lines 301–338)
The funnel can't come from a search attribute (lifecycle state isn't indexed), so it queries each running TransactionWorkflow's state query and tallies — bounded by _MAX_STATE_QUERIES, and a stuck workflow degrades to unknown rather than dropping the panel.
Per-workflow state queries → the funnel + the awaiting-human queue.
src/ynab_agent/dashboard/temporal_source.py · 338 lines
⋯ 94 lines hidden (lines 1–94)
⋯ 209 lines hidden (lines 130–338)
src/ynab_agent/dashboard/temporal_source.py · 338 lines
⋯ 132 lines hidden (lines 1–132)
⋯ 170 lines hidden (lines 169–338)
Assemble: errors become dots, pieces slot in
assemble is pure: it turns each reader's error into a source-health dot (None → green with a summary, anything else — including "off" — → red) and slots the Temporal-derived pieces into the model. The human queue is the one join: awaiting-human transactions plus live offers.
No I/O — unit-tested straight from in-memory readouts.
src/ynab_agent/dashboard/read_model.py · 110 lines
⋯ 40 lines hidden (lines 1–40)
⋯ 15 lines hidden (lines 96–110)
Render: the visuals
One self-contained page, inline CSS, no JS, every value html.escaped. Two panels carry the 'strong visuals' ask. The lifecycle funnel is proportional bars sized to each state's count; the autonomy ladder is a coloured observe→eligible→blessed strip above the rule table (with trust pills).
The lifecycle bar-chart: width ∝ count / peak.
src/ynab_agent/dashboard/render.py · 477 lines
⋯ 171 lines hidden (lines 1–171)
⋯ 279 lines hidden (lines 199–477)
The autonomy-ladder strip: flex segments sized to the three counts.
src/ynab_agent/dashboard/render.py · 477 lines
⋯ 199 lines hidden (lines 1–199)
⋯ 253 lines hidden (lines 225–477)
Verification
make check is green — ruff, mypy, 468 tests (19 new). The pure core (render, read_model) renders a full page from any model and degrades cleanly; each source's helpers and its "off" path are unit-tested; and a fake-Temporal-client test drives fetch end to end (funnel counts, the ladder fold, the dispatch tally, failures) plus its degrade-to-error path. Live smoke: kubectl port-forward deploy/ynab-agent-worker 8080:8080 → open http://localhost:8080/.
The ClickHouse and GitHub panels are best-effort: they show "off" until YNAB_AGENT_CLICKHOUSE_PASSWORD / YNAB_AGENT_GITHUB_TOKEN are set (the infra change in the zo repo adds the container port + those optional secrets).