What changed, and why it's safe to merge
The in-worker operations dashboard was a wall of eleven panels organized by subsystem — Temporal, YNAB, ClickHouse, AgentMail, GitHub — so the operator had to assemble the two answers they actually want ("is it healthy?" and "do I need to do anything?") out of scattered tables. Several panels also misled: a benign backlog of resting workflows read as "40 in flight"; intentional go-live resets read as failures; the human queue listed bare UUIDs.
This PR keeps the same data flow — sources → read_model.assemble() (pure) → render.page() (pure) — but reshapes the view-model and the page around the operator's questions: a composite Health verdict, a deterministic plain-English Narrative, a humanized needs-you queue split by whether you already settled the transaction in YNAB, a health-and-flow zone, an activity feed, and the operator detail folded into progressive-disclosure rails.
Blast radius is small by construction. The dashboard runs in-process on the worker's event loop, gated and try/except'd, so a failure degrades the page, never the triage loop. Every new YNAB call is a read. The one new piece of I/O (resolve_queue) is bounded, runs off the event loop, has a wall-clock timeout, and falls back to bare ids on any error. make check is green (ruff, mypy --strict, 486 tests), and an adversarial multi-agent pre-ship review found zero ship-blockers.
The view-model: questions, not subsystems
The model now leads with the two headline projections. Narrative is the plain-English summary; Health is the single masthead verdict (one dot + word) plus the few figures shown beside it. Note the comment on Health.needs_you: an item waiting on the owner is normal operation, not a fault — so it never flips the verdict.
The two at-a-glance types that now lead the model.
src/ynab_agent/dashboard/model.py · 285 lines
⋯ 29 lines hidden (lines 1–29)
⋯ 219 lines hidden (lines 67–285)
The queue gains the fields that make a row readable, and a dedicated facts type the YNAB source fills in. The pivotal field is TxnFacts.approved: a proposal whose transaction is already approved in YNAB is one you settled in-app, so it belongs in handled (it lapses on its own), not in your action list.
TxnFacts (the humanizing join's payload) and the enriched QueueItem.
src/ynab_agent/dashboard/model.py · 285 lines
⋯ 114 lines hidden (lines 1–114)
⋯ 133 lines hidden (lines 153–285)
DashboardModel is reshaped to match: the old heartbeat/queue give way to narrative, health, and the two-way needs_you / handled split.
The whole-page model: narrative + health lead; the queue is split.
src/ynab_agent/dashboard/model.py · 285 lines
⋯ 261 lines hidden (lines 1–261)
The humanizing join — pure, and conservative
_humanize_queue turns bare awaiting ids into readable rows and performs the needs-you / handled split. It is a pure function: the I/O layer hands it a facts map and a conversations-by-ref map, and it joins by dict lookup. The label degrades in a clear order — the proposal's email subject, then payee · amount, then a short id — so a row is always readable even when YNAB or AgentMail is unreachable.
The pure join: humanize each awaiting id, then split on approved is True.
src/ynab_agent/dashboard/read_model.py · 397 lines
⋯ 86 lines hidden (lines 1–86)
⋯ 252 lines hidden (lines 146–397)
resolve_queue — bounded, read-only, timeout-guarded
This is the only new I/O, and the section a reviewer should scrutinize. It runs in the live worker, so it must be cheap, safe, and unable to hang or crash the page.
It resolves the still-unapproved ids (the genuine to-dos) from one unapproved index — no per-id list scan — and the rest, which are the proposals you already approved, with a cheap single GET each. Category names come from category_spends. Every call is a read; nothing here writes to YNAB.
The cap + timeout constants, the facts reducer, and the read-only resolve.
src/ynab_agent/dashboard/ynab_source.py · 130 lines
⋯ 23 lines hidden (lines 1–23)
⋯ 41 lines hidden (lines 35–75)
The most human label — the actual question the agent emailed — is recovered without any extra call. The agent stamps each thread with a yatxn-{id} / yaoffer-{id} label; _ref pulls that id back out so the read-model can borrow the thread's subject for the queue row.
The id the queue joins on rides in the agent's own idempotency label.
src/ynab_agent/dashboard/agentmail_source.py · 90 lines
⋯ 29 lines hidden (lines 1–29)
⋯ 49 lines hidden (lines 42–90)
The narrator and the health verdict — deterministic
narrate composes the summary paragraph from the numbers. It is deterministic and fully guarded: every clause is conditional, so the prose reads naturally whether or not a given source is on. The headline keys off the health tone first, then the count of things waiting on you. (An optional LLM polish may later rephrase it, but always falls back to exactly this text.)
Guarded clause assembly: headline by tone, then the YNAB caught-up line.
src/ynab_agent/dashboard/read_model.py · 397 lines
⋯ 212 lines hidden (lines 1–212)
⋯ 141 lines hidden (lines 257–397)
_rollup reduces the readouts to the one verdict. The precedence is the important part: the tone reflects current operational health — poll dead or Temporal down is bad; a stuck worker, an unreachable money source, or a high live error rate is warn. An isolated historical failure is surfaced as a chip and in the narrative, but it does not flip the verdict.
The verdict: bad → warn → ok, with failures deliberately excluded from tone.
src/ynab_agent/dashboard/read_model.py · 397 lines
⋯ 146 lines hidden (lines 1–146)
⋯ 192 lines hidden (lines 206–397)
Operator resets, kept out of the fault headline
At go-live the operator terminated a batch of test/reset workflows. Those are housekeeping, not breakage, but they were swelling the failure list. _intentional classifies them out — and does so carefully: only a terminated run carrying a reset-vocabulary reason counts as housekeeping. A failed run is always real breakage, so a coincidental "connection reset" in an exception message is never miscounted.
The reset vocabulary, the scoped classifier, and where each Failure is tagged.
src/ynab_agent/dashboard/temporal_source.py · 379 lines
⋯ 41 lines hidden (lines 1–41)
⋯ 234 lines hidden (lines 68–301)
⋯ 67 lines hidden (lines 313–379)
The render — zones, escaping, designed empty states
page composes the zones in reading order: masthead, the narrative, needs you, "is it working", the activity feed, budget, then the operator detail as rails. It's pure string assembly with all CSS inline and no JavaScript — a static projection of an already-computed model.
Reading order: the operator's questions first, the detail in rails last.
src/ynab_agent/dashboard/render.py · 651 lines
⋯ 627 lines hidden (lines 1–627)
_qrow is representative of the render's two safety habits. Every dynamic value — the question, amount, category, payee — is escape()'d at the boundary, and the row reads even with no facts (it falls back through q.label, which itself fell back to a short id upstream).
Escape-at-the-boundary on every interpolation; graceful fallback.
src/ynab_agent/dashboard/render.py · 651 lines
⋯ 305 lines hidden (lines 1–305)
⋯ 321 lines hidden (lines 331–651)
Wiring and verification
build_html keeps the parallel fan-out of the five sources, then adds one dependent hop: the awaiting ids are known only after Temporal answers, so it resolves them to YNAB facts and hands the map to the pure assemble. If YNAB is off the map is empty and the queue still renders.
Parallel fan-out, then the one dependent resolve; failure stays isolated.
src/ynab_agent/dashboard/server.py · 178 lines
⋯ 47 lines hidden (lines 1–47)
⋯ 106 lines hidden (lines 73–178)
The behavior that matters is tested directly: the needs-you / handled split keyed on approved, the health verdict across its branches, and the read-only resolve's flagging of an already-approved transaction.
The split (approved → handled; unknown → needs you) and the verdict branches.
tests/dashboard/test_read_model.py · 297 lines
⋯ 92 lines hidden (lines 1–92)
⋯ 65 lines hidden (lines 112–176)
⋯ 107 lines hidden (lines 191–297)
resolve_queue humanizes, flags approved, and omits the unresolvable.