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
src/ynab_agent/dashboard/server.py170 lines · Python
⋯ 47 lines hidden (lines 1–47)
1"""The dashboard HTTP server — a tiny dependency-free asyncio responder.
2 
3Runs on the worker's own event loop (same process, same pod) and reuses the
4worker's connected Temporal client. On each GET it fans the source readers out
5concurrently, assembles the view, and renders one self-contained page; it stores
6nothing between requests. GET-only, ``Connection: close``, ``Cache-Control:
7no-store`` — built for a single viewer behind ``kubectl port-forward``, not the
8public internet. Every reader degrades to an error string, so a source being
9down yields a page with a red dot, never a crash.
10"""
11 
12from __future__ import annotations
13 
14import asyncio
15import contextlib
16import logging
17from datetime import UTC, datetime
18from typing import TYPE_CHECKING, Final
19 
20from ynab_agent.dashboard import (
21 agentmail_source,
22 clickhouse_source,
23 github_source,
24 read_model,
25 render,
26 temporal_source,
27 ynab_source,
29from ynab_agent.settings import DashboardSettings, GitHubSettings
30 
31if TYPE_CHECKING:
32 from temporalio.client import Client
33 
34_log = logging.getLogger("ynab_agent.dashboard")
35 
36_READ_TIMEOUT: Final = 15.0
37_MAX_HEAD_BYTES: Final = 64 * 1024
38 
39 
40def _repo() -> str:
41 """The watched repo, degrading to a default if config is unreadable."""
42 try:
43 return GitHubSettings().repo
44 except Exception: # never let a config read fail the page
45 return "mseeks/ynab-agent"
46 
47 
48async def build_html(client: Client) -> str:
49 """Derive the whole view live and render it (the per-request work)."""
50 now = datetime.now(UTC)
51 temporal, clickhouse, ynab, agentmail, github = await asyncio.gather(
52 temporal_source.fetch(client),
53 clickhouse_source.fetch(),
54 ynab_source.fetch(),
55 agentmail_source.fetch(),
56 github_source.fetch(),
57 )
58 model = read_model.assemble(
59 now=now,
60 repo=_repo(),
61 temporal=temporal,
62 clickhouse=clickhouse,
63 ynab=ynab,
64 agentmail=agentmail,
65 github=github,
66 )
67 return render.page(model)
68 
69 
70def _parse_path(request_line: bytes) -> tuple[str, str]:
⋯ 100 lines hidden (lines 71–170)
71 """Return ``(method, path)`` from a raw HTTP request line."""
72 parts = request_line.decode("latin-1", "replace").split()
73 if len(parts) < 2:
74 return "", "/"
75 return parts[0].upper(), parts[1].split("?", 1)[0]
76 
77 
78async def _drain_headers(reader: asyncio.StreamReader) -> None:
79 """Read and discard the request headers, bounded in size and time."""
80 total = 0
81 while True:
82 line = await asyncio.wait_for(reader.readline(), _READ_TIMEOUT)
83 total += len(line)
84 if line in (b"\r\n", b"\n", b"") or total > _MAX_HEAD_BYTES:
85 return
86 
87 
88async def _respond(
89 writer: asyncio.StreamWriter,
90 status: str,
91 content_type: str,
92 body: bytes,
93) -> None:
94 """Write a complete HTTP/1.1 response and close the connection."""
95 head = (
96 f"HTTP/1.1 {status}\r\n"
97 f"Content-Type: {content_type}\r\n"
98 f"Content-Length: {len(body)}\r\n"
99 "Cache-Control: no-store\r\n"
100 "Connection: close\r\n\r\n"
101 ).encode("latin-1")
102 writer.write(head + body)
103 await writer.drain()
104 
105 
106async def _handle(
107 reader: asyncio.StreamReader,
108 writer: asyncio.StreamWriter,
109 client: Client,
110) -> None:
111 """Serve one request: route a GET to the dashboard, else 404/405/500."""
112 try:
113 request_line = await asyncio.wait_for(reader.readline(), _READ_TIMEOUT)
114 await _drain_headers(reader)
115 method, path = _parse_path(request_line)
116 if method != "GET":
117 await _respond(writer, "405 Method Not Allowed", "text/plain", b"")
118 elif path in ("/", "/index.html", "/dashboard"):
119 html = await build_html(client)
120 await _respond(
121 writer, "200 OK", "text/html; charset=utf-8", html.encode()
122 )
123 elif path == "/healthz":
124 await _respond(writer, "200 OK", "text/plain", b"ok")
125 else:
126 await _respond(writer, "404 Not Found", "text/plain", b"")
127 except (TimeoutError, ConnectionError):
128 pass
129 except Exception as exc: # never let a request take the worker down
130 _log.exception("dashboard request failed")
131 with contextlib.suppress(Exception):
132 await _respond(
133 writer,
134 "500 Internal Server Error",
135 "text/html; charset=utf-8",
136 _error_html(exc).encode(),
137 )
138 finally:
139 with contextlib.suppress(Exception):
140 writer.close()
141 await writer.wait_closed()
142 
143 
144def _error_html(exc: Exception) -> str:
145 """A minimal error page (the detail is for a human at a port-forward)."""
146 from html import escape
147 
148 return (
149 "<!doctype html><meta charset=utf-8>"
150 "<title>ynab-agent &middot; error</title>"
151 '<body style="font:15px system-ui;max-width:640px;margin:40px auto">'
152 "<h1>dashboard error</h1><p>Could not build the read-model.</p>"
153 f"<pre>{escape(type(exc).__name__)}: {escape(str(exc))}</pre></body>"
154 )
155 
156 
157async def start(client: Client) -> asyncio.Server:
158 """Start the dashboard server on the configured host/port (returns it)."""
159 settings = DashboardSettings()
160 
161 async def on_connect(
162 reader: asyncio.StreamReader, writer: asyncio.StreamWriter
163 ) -> None:
164 await _handle(reader, writer, client)
165 
166 server = await asyncio.start_server(
167 on_connect, host=settings.host, port=settings.port
168 )
169 _log.info("dashboard listening on %s:%d", settings.host, settings.port)
170 return server

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
src/ynab_agent/worker.py137 lines · Python
⋯ 113 lines hidden (lines 1–113)
1"""The Temporal worker entrypoint — the runnable assembly (SPEC §0.5).
2 
3Connects to Temporal with the Pydantic data converter and registers the whole
4runtime: every workflow and every activity port. The activities are
5progressively wired to the real clients (YNAB reads are live; the model and mail
6sends land as each is connected); registering them all here is the single place
7a deployment turns on.
8 
9Run it once a Temporal server is reachable::
10 
11 python -m ynab_agent.worker
12 
13The connection is env-configured so the same image runs anywhere:
14``TEMPORAL_HOST`` (default ``localhost:7233``), ``TEMPORAL_NAMESPACE`` (default
15``default``), and ``TEMPORAL_TASK_QUEUE`` (default ``ynab-agent``). The clients
16read their own keys (``YNAB_API_KEY``, ``AGENTMAIL_API_KEY``), the model
17endpoint (``YNAB_AGENT_OLLAMA_URL`` / ``YNAB_AGENT_MODEL``), and ``Settings``
18reads the recipient config — all from the environment, never the repo.
19"""
20 
21from __future__ import annotations
22 
23import asyncio
24import contextlib
25import logging
26import os
27import signal
28 
29from temporalio.client import Client
30from temporalio.worker import Worker
31 
32from ynab_agent.telemetry import (
33 metrics_runtime,
34 setup_tracing,
35 shutdown_tracing,
36 tracing_interceptors,
38from ynab_agent.workflow.runtime import (
39 ALL_ACTIVITIES,
40 DATA_CONVERTER,
41 WORKFLOWS,
43 
44DEFAULT_HOST = "localhost:7233"
45DEFAULT_NAMESPACE = "default"
46DEFAULT_TASK_QUEUE = "ynab-agent"
47# A small activity pool. Model calls effectively serialize at the single local
48# Gemma regardless (Ollama runs one generation at a time), but the fast I/O
49# activities (open_thread, the commit, the mail sends) have no reason to queue
50# behind a 20-min generation. At concurrency 1 a sub-second send could wait
51# minutes for the one slot — head-of-line blocking that, once activities had a
52# wall-clock budget, surfaced as a spurious SCHEDULE_TO_START page (#6 fix).
53# A few slots let the fast work start immediately while model work still piles
54# against the Ollama lock; keep it small so a burst can't fan many concurrent
55# generations at the model.
56_MAX_CONCURRENT_ACTIVITIES = 4
57 
58 
59async def run_worker(
60 *,
61 target_host: str | None = None,
62 namespace: str | None = None,
63 task_queue: str | None = None,
64) -> None:
65 """Connect to Temporal and run the worker until cancelled (SPEC §0.5).
66 
67 Each parameter falls back to its ``TEMPORAL_*`` environment variable, then
68 to the in-cluster default, so a deployment configures the worker purely
69 through the environment.
70 """
71 host = target_host or os.environ.get("TEMPORAL_HOST", DEFAULT_HOST)
72 ns = namespace or os.environ.get("TEMPORAL_NAMESPACE", DEFAULT_NAMESPACE)
73 queue = task_queue or os.environ.get(
74 "TEMPORAL_TASK_QUEUE", DEFAULT_TASK_QUEUE
75 )
76 setup_tracing("ynab-agent-worker")
77 client = await Client.connect(
78 host,
79 namespace=ns,
80 data_converter=DATA_CONVERTER,
81 interceptors=tracing_interceptors(),
82 runtime=metrics_runtime(),
83 )
84 worker = Worker(
85 client,
86 task_queue=queue,
87 workflows=WORKFLOWS,
88 activities=ALL_ACTIVITIES,
89 max_concurrent_activities=_MAX_CONCURRENT_ACTIVITIES,
90 )
91 # Run until SIGTERM/SIGINT, then shut down gracefully and flush telemetry.
92 # `uv run` forwards SIGTERM to us, and Python's atexit does NOT run on an
93 # unhandled signal — so without this the worker's last span batch is dropped
94 # on every (Recreate) rollout.
95 stop = asyncio.Event()
96 loop = asyncio.get_running_loop()
97 for sig in (signal.SIGTERM, signal.SIGINT):
98 with contextlib.suppress(NotImplementedError):
99 loop.add_signal_handler(sig, stop.set)
100 # The in-process ops dashboard (SPEC §15): reuses this client, reached over
101 # kubectl port-forward. A start failure must never stop the worker.
102 dashboard = await _start_dashboard(client)
103 try:
104 async with worker:
105 await stop.wait()
106 finally:
107 if dashboard is not None:
108 dashboard.close()
109 with contextlib.suppress(Exception):
110 await dashboard.wait_closed()
111 shutdown_tracing()
112 
113 
114async def _start_dashboard(client: Client) -> asyncio.Server | None:
115 """Start the dashboard if enabled; a failure is logged, never fatal."""
116 from ynab_agent.settings import DashboardSettings
117 
118 if not DashboardSettings().enabled:
119 return None
120 try:
121 from ynab_agent.dashboard.server import start as start_dashboard
122 
123 return await start_dashboard(client)
124 except Exception:
125 logging.getLogger("ynab_agent.worker").exception(
126 "dashboard failed to start; running worker without it"
127 )
128 return None
129 
130 
131def main() -> None:
132 """Console entrypoint: run the worker, configured from the environment."""
133 asyncio.run(run_worker())
134 
⋯ 3 lines hidden (lines 135–137)
135 
136if __name__ == "__main__":
137 main()

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
src/ynab_agent/dashboard/temporal_source.py338 lines · Python
⋯ 273 lines hidden (lines 1–273)
1"""Temporal reader: the live run ledger (reuses the worker's client).
2 
3Reads the agent's durable state straight from Temporal: the poll heartbeat, the
4in-flight W2 transactions counted by lifecycle state (each running workflow's
5``state`` query), the rule registry's autonomy ladder (its ``view`` query), the
6live autonomy offers, the recent inbound-dispatch tally, and any
7terminated/failed workflows with their recovered reason. Everything is bounded
8and best-effort: a failure returns whatever was gathered plus an error string,
9never raising into the page.
10"""
11 
12from __future__ import annotations
13 
14import contextlib
15from datetime import datetime
16from typing import TYPE_CHECKING, Final
17 
18from temporalio.client import WorkflowExecutionStatus
19 
20from ynab_agent.dashboard.model import (
21 DispatchTally,
22 Failure,
23 OfferRow,
24 QueueItem,
25 RuleRow,
26 StateCount,
28from ynab_agent.domain.base import Frozen
29 
30if TYPE_CHECKING:
31 from collections.abc import AsyncIterator
32 
33 from temporalio.client import Client, WorkflowExecution
34 
35_MAX_PER_TYPE: Final = 500
36_MAX_STATE_QUERIES: Final = 200
37_REGISTRY_ID: Final = "ynab-rule-registry"
38 
39 
40class TemporalReadout(Frozen):
41 """The Temporal-derived pieces of the dashboard, ready to slot in."""
42 
43 poll_status: str = "none"
44 poll_live: bool = False
45 poll_last_start: datetime | None = None
46 lifecycle_states: tuple[StateCount, ...] = ()
47 in_flight: int = 0
48 archived: int = 0
49 terminated: int = 0
50 rules: tuple[RuleRow, ...] = ()
51 observe: int = 0
52 eligible: int = 0
53 blessed: int = 0
54 offers: tuple[OfferRow, ...] = ()
55 awaiting: tuple[QueueItem, ...] = ()
56 dispatch: DispatchTally = DispatchTally()
57 failures: tuple[Failure, ...] = ()
58 
59 
60def _status(execution: WorkflowExecution) -> str:
61 status = execution.status
62 return status.name.lower() if status is not None else "unknown"
63 
64 
65async def _take(
66 iterator: AsyncIterator[WorkflowExecution], cap: int = _MAX_PER_TYPE
67) -> AsyncIterator[WorkflowExecution]:
68 """Yield at most ``cap`` executions (a runaway-visibility backstop)."""
69 seen = 0
70 async for execution in iterator:
71 yield execution
72 seen += 1
73 if seen >= cap:
74 return
75 
76 
77async def _poll(client: Client) -> tuple[str, bool, datetime | None]:
78 """The most recent poll tick: (status, live, last_start)."""
79 async for execution in _take(
80 client.list_workflows(
81 "WorkflowType = 'PollWorkflow' ORDER BY StartTime DESC"
82 ),
83 cap=1,
84 ):
85 status = _status(execution)
86 live = execution.status in (
87 WorkflowExecutionStatus.RUNNING,
88 WorkflowExecutionStatus.COMPLETED,
89 WorkflowExecutionStatus.CONTINUED_AS_NEW,
90 )
91 return status, live, execution.start_time
92 return "none", False, None
93 
94 
95async def _lifecycle(
96 client: Client,
97) -> tuple[tuple[StateCount, ...], int, tuple[QueueItem, ...]]:
98 """Count running W2s by state; collect the awaiting-human queue."""
99 counts: dict[str, int] = {}
100 awaiting: list[QueueItem] = []
101 seen = 0
102 async for execution in _take(
103 client.list_workflows(
104 "WorkflowType = 'TransactionWorkflow' "
105 "AND ExecutionStatus = 'Running'"
106 ),
107 cap=_MAX_STATE_QUERIES,
108 ):
109 seen += 1
110 try:
111 handle = client.get_workflow_handle(
112 execution.id, run_id=execution.run_id
113 )
114 state = await handle.query("state", result_type=str)
115 except Exception: # a busy/odd workflow shouldn't drop the whole panel
116 state = "unknown"
117 counts[state] = counts.get(state, 0) + 1
118 if state == "awaiting_human":
119 awaiting.append(
120 QueueItem(
121 kind="proposal",
122 label=execution.id,
123 ident=execution.id,
124 since=execution.start_time,
125 )
126 )
127 states = tuple(
128 StateCount(state=name, count=counts[name]) for name in sorted(counts)
129 )
130 return states, seen, tuple(awaiting)
131 
132 
133async def _registry(
134 client: Client,
135) -> tuple[tuple[RuleRow, ...], int, int, int]:
136 """The rule table + (observe, eligible, blessed) counts (view query)."""
137 from ynab_agent.domain.allocations import ProposedCategory
138 from ynab_agent.domain.enums import RuleSource, TrustState
139 from ynab_agent.workflow.registry_types import RegistryView
140 
141 handle = client.get_workflow_handle(_REGISTRY_ID)
142 view = await handle.query("view", result_type=RegistryView)
143 rows: list[RuleRow] = []
144 observe = eligible = blessed = 0
145 for rule in view.rules:
146 allocation = rule.action.allocation
147 category = (
148 str(allocation.category)
149 if isinstance(allocation, ProposedCategory)
150 else "split"
151 )
152 rows.append(
153 RuleRow(
154 payee=rule.match.payee_pattern,
155 category=category,
156 trust=rule.trust.value,
157 source=rule.source.value,
158 hits=rule.hits,
159 offered=rule.offered_at is not None,
160 last_confirmed_at=rule.last_confirmed_at,
161 )
162 )
163 if rule.source is RuleSource.HUMAN_EXPLICIT:
164 blessed += 1
165 elif rule.trust is TrustState.TRUSTED:
166 eligible += 1
167 else:
168 observe += 1
169 return tuple(rows), observe, eligible, blessed
170 
171 
172async def _offers(client: Client) -> tuple[OfferRow, ...]:
173 """The live autonomy-offer workflows (awaiting the owner's yes/no)."""
174 offers: list[OfferRow] = []
175 async for execution in _take(
176 client.list_workflows(
177 "WorkflowType = 'AutonomyOfferWorkflow' "
178 "AND ExecutionStatus = 'Running'"
179 )
180 ):
181 offers.append(
182 OfferRow(
183 rule_id=execution.id.removeprefix("autonomy-offer-"),
184 payee="",
185 status=_status(execution),
186 started_at=execution.start_time,
187 )
188 )
189 return tuple(offers)
190 
191 
192async def _dispatch(client: Client) -> DispatchTally:
193 """Tally recent inbound dispatch results by routing action."""
194 counts: dict[str, int] = {}
195 total = 0
196 async for execution in _take(
197 client.list_workflows(
198 "WorkflowType = 'DispatchWorkflow' "
199 "AND ExecutionStatus = 'Completed'"
200 )
201 ):
202 total += 1
203 action = "?"
204 try:
205 handle = client.get_workflow_handle(
206 execution.id, run_id=execution.run_id
207 )
208 result = await handle.result()
209 got = getattr(result, "action", None)
210 if isinstance(got, str):
211 action = got
212 except Exception: # a bad decode shouldn't drop the tally
213 action = "?"
214 counts[action] = counts.get(action, 0) + 1
215 return DispatchTally(
216 transaction=counts.get("transaction", 0),
217 offer=counts.get("offer", 0),
218 receipt=counts.get("receipt", 0),
219 command=counts.get("command", 0),
220 quarantine=counts.get("quarantine", 0),
221 ignore=counts.get("ignore", 0),
222 total=total,
223 )
224 
225 
226async def _reason(client: Client, execution: WorkflowExecution) -> str | None:
227 """Recover a terminated/failed workflow's human reason from history."""
228 try:
229 handle = client.get_workflow_handle(
230 execution.id, run_id=execution.run_id
231 )
232 found: str | None = None
233 async for event in handle.fetch_history_events():
234 terminated = event.workflow_execution_terminated_event_attributes
235 if terminated.reason:
236 found = terminated.reason
237 failed = event.workflow_execution_failed_event_attributes
238 if failed.failure.message:
239 found = failed.failure.message
240 return found
241 except Exception: # the reason is a nicety — never fail the panel for it
242 return None
243 
244 
245async def _terminal(client: Client) -> tuple[int, int, tuple[Failure, ...]]:
246 """Recent archived (completed W2) + terminated/failed counts + failures."""
247 archived = terminated = 0
248 failures: list[Failure] = []
249 async for _completed in _take(
250 client.list_workflows(
251 "WorkflowType = 'TransactionWorkflow' "
252 "AND ExecutionStatus = 'Completed'"
253 )
254 ):
255 archived += 1
256 async for execution in _take(
257 client.list_workflows(
258 "ExecutionStatus = 'Terminated' OR ExecutionStatus = 'Failed'"
259 )
260 ):
261 terminated += 1
262 if len(failures) < 25:
263 failures.append(
264 Failure(
265 workflow_id=execution.id,
266 kind=_status(execution),
267 reason=await _reason(client, execution),
268 when=execution.close_time,
269 )
270 )
271 return archived, terminated, tuple(failures)
272 
273 
274async def fetch(client: Client) -> tuple[TemporalReadout, str | None]:
275 """Read the agent's Temporal state; degrades to an error string."""
276 poll_status = "none"
277 poll_live = False
278 poll_last: datetime | None = None
279 states: tuple[StateCount, ...] = ()
280 in_flight = 0
281 awaiting: tuple[QueueItem, ...] = ()
282 rules: tuple[RuleRow, ...] = ()
283 observe = eligible = blessed = 0
284 offers: tuple[OfferRow, ...] = ()
285 dispatch = DispatchTally()
286 archived = terminated = 0
287 failures: tuple[Failure, ...] = ()
288 
289 try:
290 poll_status, poll_live, poll_last = await _poll(client)
291 states, in_flight, awaiting = await _lifecycle(client)
292 # The registry may not exist until the first learning signal — a normal
293 # state, not an error, so its absence is simply suppressed.
294 with contextlib.suppress(Exception):
295 rules, observe, eligible, blessed = await _registry(client)
296 offers = await _offers(client)
297 dispatch = await _dispatch(client)
298 archived, terminated, failures = await _terminal(client)
299 except Exception as exc: # degrade to an error, never crash the page
300 readout = TemporalReadout(
⋯ 38 lines hidden (lines 301–338)
301 poll_status=poll_status,
302 poll_live=poll_live,
303 poll_last_start=poll_last,
304 lifecycle_states=states,
305 in_flight=in_flight,
306 rules=rules,
307 observe=observe,
308 eligible=eligible,
309 blessed=blessed,
310 offers=offers,
311 awaiting=awaiting,
312 dispatch=dispatch,
313 archived=archived,
314 terminated=terminated,
315 failures=failures,
316 )
317 return readout, f"{type(exc).__name__}: {exc}"
318 
319 return (
320 TemporalReadout(
321 poll_status=poll_status,
322 poll_live=poll_live,
323 poll_last_start=poll_last,
324 lifecycle_states=states,
325 in_flight=in_flight,
326 archived=archived,
327 terminated=terminated,
328 rules=rules,
329 observe=observe,
330 eligible=eligible,
331 blessed=blessed,
332 offers=offers,
333 awaiting=awaiting,
334 dispatch=dispatch,
335 failures=failures,
336 ),
337 None,
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
src/ynab_agent/dashboard/temporal_source.py338 lines · Python
⋯ 94 lines hidden (lines 1–94)
1"""Temporal reader: the live run ledger (reuses the worker's client).
2 
3Reads the agent's durable state straight from Temporal: the poll heartbeat, the
4in-flight W2 transactions counted by lifecycle state (each running workflow's
5``state`` query), the rule registry's autonomy ladder (its ``view`` query), the
6live autonomy offers, the recent inbound-dispatch tally, and any
7terminated/failed workflows with their recovered reason. Everything is bounded
8and best-effort: a failure returns whatever was gathered plus an error string,
9never raising into the page.
10"""
11 
12from __future__ import annotations
13 
14import contextlib
15from datetime import datetime
16from typing import TYPE_CHECKING, Final
17 
18from temporalio.client import WorkflowExecutionStatus
19 
20from ynab_agent.dashboard.model import (
21 DispatchTally,
22 Failure,
23 OfferRow,
24 QueueItem,
25 RuleRow,
26 StateCount,
28from ynab_agent.domain.base import Frozen
29 
30if TYPE_CHECKING:
31 from collections.abc import AsyncIterator
32 
33 from temporalio.client import Client, WorkflowExecution
34 
35_MAX_PER_TYPE: Final = 500
36_MAX_STATE_QUERIES: Final = 200
37_REGISTRY_ID: Final = "ynab-rule-registry"
38 
39 
40class TemporalReadout(Frozen):
41 """The Temporal-derived pieces of the dashboard, ready to slot in."""
42 
43 poll_status: str = "none"
44 poll_live: bool = False
45 poll_last_start: datetime | None = None
46 lifecycle_states: tuple[StateCount, ...] = ()
47 in_flight: int = 0
48 archived: int = 0
49 terminated: int = 0
50 rules: tuple[RuleRow, ...] = ()
51 observe: int = 0
52 eligible: int = 0
53 blessed: int = 0
54 offers: tuple[OfferRow, ...] = ()
55 awaiting: tuple[QueueItem, ...] = ()
56 dispatch: DispatchTally = DispatchTally()
57 failures: tuple[Failure, ...] = ()
58 
59 
60def _status(execution: WorkflowExecution) -> str:
61 status = execution.status
62 return status.name.lower() if status is not None else "unknown"
63 
64 
65async def _take(
66 iterator: AsyncIterator[WorkflowExecution], cap: int = _MAX_PER_TYPE
67) -> AsyncIterator[WorkflowExecution]:
68 """Yield at most ``cap`` executions (a runaway-visibility backstop)."""
69 seen = 0
70 async for execution in iterator:
71 yield execution
72 seen += 1
73 if seen >= cap:
74 return
75 
76 
77async def _poll(client: Client) -> tuple[str, bool, datetime | None]:
78 """The most recent poll tick: (status, live, last_start)."""
79 async for execution in _take(
80 client.list_workflows(
81 "WorkflowType = 'PollWorkflow' ORDER BY StartTime DESC"
82 ),
83 cap=1,
84 ):
85 status = _status(execution)
86 live = execution.status in (
87 WorkflowExecutionStatus.RUNNING,
88 WorkflowExecutionStatus.COMPLETED,
89 WorkflowExecutionStatus.CONTINUED_AS_NEW,
90 )
91 return status, live, execution.start_time
92 return "none", False, None
93 
94 
95async def _lifecycle(
96 client: Client,
97) -> tuple[tuple[StateCount, ...], int, tuple[QueueItem, ...]]:
98 """Count running W2s by state; collect the awaiting-human queue."""
99 counts: dict[str, int] = {}
100 awaiting: list[QueueItem] = []
101 seen = 0
102 async for execution in _take(
103 client.list_workflows(
104 "WorkflowType = 'TransactionWorkflow' "
105 "AND ExecutionStatus = 'Running'"
106 ),
107 cap=_MAX_STATE_QUERIES,
108 ):
109 seen += 1
110 try:
111 handle = client.get_workflow_handle(
112 execution.id, run_id=execution.run_id
113 )
114 state = await handle.query("state", result_type=str)
115 except Exception: # a busy/odd workflow shouldn't drop the whole panel
116 state = "unknown"
117 counts[state] = counts.get(state, 0) + 1
118 if state == "awaiting_human":
119 awaiting.append(
120 QueueItem(
121 kind="proposal",
122 label=execution.id,
123 ident=execution.id,
124 since=execution.start_time,
125 )
126 )
127 states = tuple(
128 StateCount(state=name, count=counts[name]) for name in sorted(counts)
129 )
⋯ 209 lines hidden (lines 130–338)
130 return states, seen, tuple(awaiting)
131 
132 
133async def _registry(
134 client: Client,
135) -> tuple[tuple[RuleRow, ...], int, int, int]:
136 """The rule table + (observe, eligible, blessed) counts (view query)."""
137 from ynab_agent.domain.allocations import ProposedCategory
138 from ynab_agent.domain.enums import RuleSource, TrustState
139 from ynab_agent.workflow.registry_types import RegistryView
140 
141 handle = client.get_workflow_handle(_REGISTRY_ID)
142 view = await handle.query("view", result_type=RegistryView)
143 rows: list[RuleRow] = []
144 observe = eligible = blessed = 0
145 for rule in view.rules:
146 allocation = rule.action.allocation
147 category = (
148 str(allocation.category)
149 if isinstance(allocation, ProposedCategory)
150 else "split"
151 )
152 rows.append(
153 RuleRow(
154 payee=rule.match.payee_pattern,
155 category=category,
156 trust=rule.trust.value,
157 source=rule.source.value,
158 hits=rule.hits,
159 offered=rule.offered_at is not None,
160 last_confirmed_at=rule.last_confirmed_at,
161 )
162 )
163 if rule.source is RuleSource.HUMAN_EXPLICIT:
164 blessed += 1
165 elif rule.trust is TrustState.TRUSTED:
166 eligible += 1
167 else:
168 observe += 1
169 return tuple(rows), observe, eligible, blessed
170 
171 
172async def _offers(client: Client) -> tuple[OfferRow, ...]:
173 """The live autonomy-offer workflows (awaiting the owner's yes/no)."""
174 offers: list[OfferRow] = []
175 async for execution in _take(
176 client.list_workflows(
177 "WorkflowType = 'AutonomyOfferWorkflow' "
178 "AND ExecutionStatus = 'Running'"
179 )
180 ):
181 offers.append(
182 OfferRow(
183 rule_id=execution.id.removeprefix("autonomy-offer-"),
184 payee="",
185 status=_status(execution),
186 started_at=execution.start_time,
187 )
188 )
189 return tuple(offers)
190 
191 
192async def _dispatch(client: Client) -> DispatchTally:
193 """Tally recent inbound dispatch results by routing action."""
194 counts: dict[str, int] = {}
195 total = 0
196 async for execution in _take(
197 client.list_workflows(
198 "WorkflowType = 'DispatchWorkflow' "
199 "AND ExecutionStatus = 'Completed'"
200 )
201 ):
202 total += 1
203 action = "?"
204 try:
205 handle = client.get_workflow_handle(
206 execution.id, run_id=execution.run_id
207 )
208 result = await handle.result()
209 got = getattr(result, "action", None)
210 if isinstance(got, str):
211 action = got
212 except Exception: # a bad decode shouldn't drop the tally
213 action = "?"
214 counts[action] = counts.get(action, 0) + 1
215 return DispatchTally(
216 transaction=counts.get("transaction", 0),
217 offer=counts.get("offer", 0),
218 receipt=counts.get("receipt", 0),
219 command=counts.get("command", 0),
220 quarantine=counts.get("quarantine", 0),
221 ignore=counts.get("ignore", 0),
222 total=total,
223 )
224 
225 
226async def _reason(client: Client, execution: WorkflowExecution) -> str | None:
227 """Recover a terminated/failed workflow's human reason from history."""
228 try:
229 handle = client.get_workflow_handle(
230 execution.id, run_id=execution.run_id
231 )
232 found: str | None = None
233 async for event in handle.fetch_history_events():
234 terminated = event.workflow_execution_terminated_event_attributes
235 if terminated.reason:
236 found = terminated.reason
237 failed = event.workflow_execution_failed_event_attributes
238 if failed.failure.message:
239 found = failed.failure.message
240 return found
241 except Exception: # the reason is a nicety — never fail the panel for it
242 return None
243 
244 
245async def _terminal(client: Client) -> tuple[int, int, tuple[Failure, ...]]:
246 """Recent archived (completed W2) + terminated/failed counts + failures."""
247 archived = terminated = 0
248 failures: list[Failure] = []
249 async for _completed in _take(
250 client.list_workflows(
251 "WorkflowType = 'TransactionWorkflow' "
252 "AND ExecutionStatus = 'Completed'"
253 )
254 ):
255 archived += 1
256 async for execution in _take(
257 client.list_workflows(
258 "ExecutionStatus = 'Terminated' OR ExecutionStatus = 'Failed'"
259 )
260 ):
261 terminated += 1
262 if len(failures) < 25:
263 failures.append(
264 Failure(
265 workflow_id=execution.id,
266 kind=_status(execution),
267 reason=await _reason(client, execution),
268 when=execution.close_time,
269 )
270 )
271 return archived, terminated, tuple(failures)
272 
273 
274async def fetch(client: Client) -> tuple[TemporalReadout, str | None]:
275 """Read the agent's Temporal state; degrades to an error string."""
276 poll_status = "none"
277 poll_live = False
278 poll_last: datetime | None = None
279 states: tuple[StateCount, ...] = ()
280 in_flight = 0
281 awaiting: tuple[QueueItem, ...] = ()
282 rules: tuple[RuleRow, ...] = ()
283 observe = eligible = blessed = 0
284 offers: tuple[OfferRow, ...] = ()
285 dispatch = DispatchTally()
286 archived = terminated = 0
287 failures: tuple[Failure, ...] = ()
288 
289 try:
290 poll_status, poll_live, poll_last = await _poll(client)
291 states, in_flight, awaiting = await _lifecycle(client)
292 # The registry may not exist until the first learning signal — a normal
293 # state, not an error, so its absence is simply suppressed.
294 with contextlib.suppress(Exception):
295 rules, observe, eligible, blessed = await _registry(client)
296 offers = await _offers(client)
297 dispatch = await _dispatch(client)
298 archived, terminated, failures = await _terminal(client)
299 except Exception as exc: # degrade to an error, never crash the page
300 readout = TemporalReadout(
301 poll_status=poll_status,
302 poll_live=poll_live,
303 poll_last_start=poll_last,
304 lifecycle_states=states,
305 in_flight=in_flight,
306 rules=rules,
307 observe=observe,
308 eligible=eligible,
309 blessed=blessed,
310 offers=offers,
311 awaiting=awaiting,
312 dispatch=dispatch,
313 archived=archived,
314 terminated=terminated,
315 failures=failures,
316 )
317 return readout, f"{type(exc).__name__}: {exc}"
318 
319 return (
320 TemporalReadout(
321 poll_status=poll_status,
322 poll_live=poll_live,
323 poll_last_start=poll_last,
324 lifecycle_states=states,
325 in_flight=in_flight,
326 archived=archived,
327 terminated=terminated,
328 rules=rules,
329 observe=observe,
330 eligible=eligible,
331 blessed=blessed,
332 offers=offers,
333 awaiting=awaiting,
334 dispatch=dispatch,
335 failures=failures,
336 ),
337 None,
338 )
src/ynab_agent/dashboard/temporal_source.py · 338 lines
src/ynab_agent/dashboard/temporal_source.py338 lines · Python
⋯ 132 lines hidden (lines 1–132)
1"""Temporal reader: the live run ledger (reuses the worker's client).
2 
3Reads the agent's durable state straight from Temporal: the poll heartbeat, the
4in-flight W2 transactions counted by lifecycle state (each running workflow's
5``state`` query), the rule registry's autonomy ladder (its ``view`` query), the
6live autonomy offers, the recent inbound-dispatch tally, and any
7terminated/failed workflows with their recovered reason. Everything is bounded
8and best-effort: a failure returns whatever was gathered plus an error string,
9never raising into the page.
10"""
11 
12from __future__ import annotations
13 
14import contextlib
15from datetime import datetime
16from typing import TYPE_CHECKING, Final
17 
18from temporalio.client import WorkflowExecutionStatus
19 
20from ynab_agent.dashboard.model import (
21 DispatchTally,
22 Failure,
23 OfferRow,
24 QueueItem,
25 RuleRow,
26 StateCount,
28from ynab_agent.domain.base import Frozen
29 
30if TYPE_CHECKING:
31 from collections.abc import AsyncIterator
32 
33 from temporalio.client import Client, WorkflowExecution
34 
35_MAX_PER_TYPE: Final = 500
36_MAX_STATE_QUERIES: Final = 200
37_REGISTRY_ID: Final = "ynab-rule-registry"
38 
39 
40class TemporalReadout(Frozen):
41 """The Temporal-derived pieces of the dashboard, ready to slot in."""
42 
43 poll_status: str = "none"
44 poll_live: bool = False
45 poll_last_start: datetime | None = None
46 lifecycle_states: tuple[StateCount, ...] = ()
47 in_flight: int = 0
48 archived: int = 0
49 terminated: int = 0
50 rules: tuple[RuleRow, ...] = ()
51 observe: int = 0
52 eligible: int = 0
53 blessed: int = 0
54 offers: tuple[OfferRow, ...] = ()
55 awaiting: tuple[QueueItem, ...] = ()
56 dispatch: DispatchTally = DispatchTally()
57 failures: tuple[Failure, ...] = ()
58 
59 
60def _status(execution: WorkflowExecution) -> str:
61 status = execution.status
62 return status.name.lower() if status is not None else "unknown"
63 
64 
65async def _take(
66 iterator: AsyncIterator[WorkflowExecution], cap: int = _MAX_PER_TYPE
67) -> AsyncIterator[WorkflowExecution]:
68 """Yield at most ``cap`` executions (a runaway-visibility backstop)."""
69 seen = 0
70 async for execution in iterator:
71 yield execution
72 seen += 1
73 if seen >= cap:
74 return
75 
76 
77async def _poll(client: Client) -> tuple[str, bool, datetime | None]:
78 """The most recent poll tick: (status, live, last_start)."""
79 async for execution in _take(
80 client.list_workflows(
81 "WorkflowType = 'PollWorkflow' ORDER BY StartTime DESC"
82 ),
83 cap=1,
84 ):
85 status = _status(execution)
86 live = execution.status in (
87 WorkflowExecutionStatus.RUNNING,
88 WorkflowExecutionStatus.COMPLETED,
89 WorkflowExecutionStatus.CONTINUED_AS_NEW,
90 )
91 return status, live, execution.start_time
92 return "none", False, None
93 
94 
95async def _lifecycle(
96 client: Client,
97) -> tuple[tuple[StateCount, ...], int, tuple[QueueItem, ...]]:
98 """Count running W2s by state; collect the awaiting-human queue."""
99 counts: dict[str, int] = {}
100 awaiting: list[QueueItem] = []
101 seen = 0
102 async for execution in _take(
103 client.list_workflows(
104 "WorkflowType = 'TransactionWorkflow' "
105 "AND ExecutionStatus = 'Running'"
106 ),
107 cap=_MAX_STATE_QUERIES,
108 ):
109 seen += 1
110 try:
111 handle = client.get_workflow_handle(
112 execution.id, run_id=execution.run_id
113 )
114 state = await handle.query("state", result_type=str)
115 except Exception: # a busy/odd workflow shouldn't drop the whole panel
116 state = "unknown"
117 counts[state] = counts.get(state, 0) + 1
118 if state == "awaiting_human":
119 awaiting.append(
120 QueueItem(
121 kind="proposal",
122 label=execution.id,
123 ident=execution.id,
124 since=execution.start_time,
125 )
126 )
127 states = tuple(
128 StateCount(state=name, count=counts[name]) for name in sorted(counts)
129 )
130 return states, seen, tuple(awaiting)
131 
132 
133async def _registry(
134 client: Client,
135) -> tuple[tuple[RuleRow, ...], int, int, int]:
136 """The rule table + (observe, eligible, blessed) counts (view query)."""
137 from ynab_agent.domain.allocations import ProposedCategory
138 from ynab_agent.domain.enums import RuleSource, TrustState
139 from ynab_agent.workflow.registry_types import RegistryView
140 
141 handle = client.get_workflow_handle(_REGISTRY_ID)
142 view = await handle.query("view", result_type=RegistryView)
143 rows: list[RuleRow] = []
144 observe = eligible = blessed = 0
145 for rule in view.rules:
146 allocation = rule.action.allocation
147 category = (
148 str(allocation.category)
149 if isinstance(allocation, ProposedCategory)
150 else "split"
151 )
152 rows.append(
153 RuleRow(
154 payee=rule.match.payee_pattern,
155 category=category,
156 trust=rule.trust.value,
157 source=rule.source.value,
158 hits=rule.hits,
159 offered=rule.offered_at is not None,
160 last_confirmed_at=rule.last_confirmed_at,
161 )
162 )
163 if rule.source is RuleSource.HUMAN_EXPLICIT:
164 blessed += 1
165 elif rule.trust is TrustState.TRUSTED:
166 eligible += 1
167 else:
168 observe += 1
⋯ 170 lines hidden (lines 169–338)
169 return tuple(rows), observe, eligible, blessed
170 
171 
172async def _offers(client: Client) -> tuple[OfferRow, ...]:
173 """The live autonomy-offer workflows (awaiting the owner's yes/no)."""
174 offers: list[OfferRow] = []
175 async for execution in _take(
176 client.list_workflows(
177 "WorkflowType = 'AutonomyOfferWorkflow' "
178 "AND ExecutionStatus = 'Running'"
179 )
180 ):
181 offers.append(
182 OfferRow(
183 rule_id=execution.id.removeprefix("autonomy-offer-"),
184 payee="",
185 status=_status(execution),
186 started_at=execution.start_time,
187 )
188 )
189 return tuple(offers)
190 
191 
192async def _dispatch(client: Client) -> DispatchTally:
193 """Tally recent inbound dispatch results by routing action."""
194 counts: dict[str, int] = {}
195 total = 0
196 async for execution in _take(
197 client.list_workflows(
198 "WorkflowType = 'DispatchWorkflow' "
199 "AND ExecutionStatus = 'Completed'"
200 )
201 ):
202 total += 1
203 action = "?"
204 try:
205 handle = client.get_workflow_handle(
206 execution.id, run_id=execution.run_id
207 )
208 result = await handle.result()
209 got = getattr(result, "action", None)
210 if isinstance(got, str):
211 action = got
212 except Exception: # a bad decode shouldn't drop the tally
213 action = "?"
214 counts[action] = counts.get(action, 0) + 1
215 return DispatchTally(
216 transaction=counts.get("transaction", 0),
217 offer=counts.get("offer", 0),
218 receipt=counts.get("receipt", 0),
219 command=counts.get("command", 0),
220 quarantine=counts.get("quarantine", 0),
221 ignore=counts.get("ignore", 0),
222 total=total,
223 )
224 
225 
226async def _reason(client: Client, execution: WorkflowExecution) -> str | None:
227 """Recover a terminated/failed workflow's human reason from history."""
228 try:
229 handle = client.get_workflow_handle(
230 execution.id, run_id=execution.run_id
231 )
232 found: str | None = None
233 async for event in handle.fetch_history_events():
234 terminated = event.workflow_execution_terminated_event_attributes
235 if terminated.reason:
236 found = terminated.reason
237 failed = event.workflow_execution_failed_event_attributes
238 if failed.failure.message:
239 found = failed.failure.message
240 return found
241 except Exception: # the reason is a nicety — never fail the panel for it
242 return None
243 
244 
245async def _terminal(client: Client) -> tuple[int, int, tuple[Failure, ...]]:
246 """Recent archived (completed W2) + terminated/failed counts + failures."""
247 archived = terminated = 0
248 failures: list[Failure] = []
249 async for _completed in _take(
250 client.list_workflows(
251 "WorkflowType = 'TransactionWorkflow' "
252 "AND ExecutionStatus = 'Completed'"
253 )
254 ):
255 archived += 1
256 async for execution in _take(
257 client.list_workflows(
258 "ExecutionStatus = 'Terminated' OR ExecutionStatus = 'Failed'"
259 )
260 ):
261 terminated += 1
262 if len(failures) < 25:
263 failures.append(
264 Failure(
265 workflow_id=execution.id,
266 kind=_status(execution),
267 reason=await _reason(client, execution),
268 when=execution.close_time,
269 )
270 )
271 return archived, terminated, tuple(failures)
272 
273 
274async def fetch(client: Client) -> tuple[TemporalReadout, str | None]:
275 """Read the agent's Temporal state; degrades to an error string."""
276 poll_status = "none"
277 poll_live = False
278 poll_last: datetime | None = None
279 states: tuple[StateCount, ...] = ()
280 in_flight = 0
281 awaiting: tuple[QueueItem, ...] = ()
282 rules: tuple[RuleRow, ...] = ()
283 observe = eligible = blessed = 0
284 offers: tuple[OfferRow, ...] = ()
285 dispatch = DispatchTally()
286 archived = terminated = 0
287 failures: tuple[Failure, ...] = ()
288 
289 try:
290 poll_status, poll_live, poll_last = await _poll(client)
291 states, in_flight, awaiting = await _lifecycle(client)
292 # The registry may not exist until the first learning signal — a normal
293 # state, not an error, so its absence is simply suppressed.
294 with contextlib.suppress(Exception):
295 rules, observe, eligible, blessed = await _registry(client)
296 offers = await _offers(client)
297 dispatch = await _dispatch(client)
298 archived, terminated, failures = await _terminal(client)
299 except Exception as exc: # degrade to an error, never crash the page
300 readout = TemporalReadout(
301 poll_status=poll_status,
302 poll_live=poll_live,
303 poll_last_start=poll_last,
304 lifecycle_states=states,
305 in_flight=in_flight,
306 rules=rules,
307 observe=observe,
308 eligible=eligible,
309 blessed=blessed,
310 offers=offers,
311 awaiting=awaiting,
312 dispatch=dispatch,
313 archived=archived,
314 terminated=terminated,
315 failures=failures,
316 )
317 return readout, f"{type(exc).__name__}: {exc}"
318 
319 return (
320 TemporalReadout(
321 poll_status=poll_status,
322 poll_live=poll_live,
323 poll_last_start=poll_last,
324 lifecycle_states=states,
325 in_flight=in_flight,
326 archived=archived,
327 terminated=terminated,
328 rules=rules,
329 observe=observe,
330 eligible=eligible,
331 blessed=blessed,
332 offers=offers,
333 awaiting=awaiting,
334 dispatch=dispatch,
335 failures=failures,
336 ),
337 None,
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
src/ynab_agent/dashboard/read_model.py110 lines · Python
⋯ 40 lines hidden (lines 1–40)
1"""Assemble the source readouts into one view-model (pure).
2 
3Each reader hands in ``(data, error)``; this composes the
4:class:`~ynab_agent.dashboard.model.DashboardModel`, turns each error into a
5source-health dot, and slots the Temporal-derived pieces into place. No I/O —
6fully unit-testable from in-memory readouts.
7"""
8 
9from __future__ import annotations
10 
11from typing import TYPE_CHECKING
12 
13from ynab_agent.dashboard.model import (
14 Autonomy,
15 DashboardModel,
16 Heartbeat,
17 Lifecycle,
18 QueueItem,
19 SourceHealth,
21 
22if TYPE_CHECKING:
23 from datetime import datetime
24 
25 from ynab_agent.dashboard.model import (
26 Budget,
27 Conversation,
28 Deploy,
29 RunTelemetry,
30 )
31 from ynab_agent.dashboard.temporal_source import TemporalReadout
32 
33 
34def _health(name: str, error: str | None, ok_detail: str) -> SourceHealth:
35 """A source dot: green with a summary, or red with the error/'off'."""
36 if error is None:
37 return SourceHealth(name=name, ok=True, detail=ok_detail)
38 return SourceHealth(name=name, ok=False, detail=error)
39 
40 
41def assemble(
42 *,
43 now: datetime,
44 repo: str,
45 temporal: tuple[TemporalReadout, str | None],
46 ynab: tuple[Budget, str | None],
47 clickhouse: tuple[RunTelemetry, str | None],
48 agentmail: tuple[tuple[Conversation, ...], str | None],
49 github: tuple[Deploy, str | None],
50) -> DashboardModel:
51 """Compose the whole dashboard view from the source readouts."""
52 t, t_err = temporal
53 budget, ynab_err = ynab
54 telemetry, ch_err = clickhouse
55 conversations, mail_err = agentmail
56 deploy, gh_err = github
57 
58 sources = (
59 _health("temporal", t_err, f"{t.in_flight} in-flight"),
60 _health("ynab", ynab_err, f"{budget.unapproved} unapproved"),
61 _health("clickhouse", ch_err, f"{telemetry.total_spans} spans"),
62 _health("agentmail", mail_err, f"{len(conversations)} threads"),
63 _health("github", gh_err, f"{len(deploy.prs)} PRs"),
64 )
65 
66 heartbeat = Heartbeat(
67 poll_status=t.poll_status,
68 poll_live=t.poll_live,
69 poll_last_start=t.poll_last_start,
70 worker_last_span=telemetry.last_activity,
71 )
72 lifecycle = Lifecycle(
73 states=t.lifecycle_states,
74 in_flight=t.in_flight,
75 archived=t.archived,
76 terminated=t.terminated,
77 )
78 autonomy = Autonomy(
79 observe=t.observe,
80 eligible=t.eligible,
81 blessed=t.blessed,
82 rules=t.rules,
83 offers=t.offers,
84 )
85 offer_queue = tuple(
86 QueueItem(
87 kind="offer",
88 label=offer.payee or offer.rule_id,
89 ident=offer.rule_id,
90 since=offer.started_at,
91 )
92 for offer in t.offers
93 )
94 queue = (*t.awaiting, *offer_queue)
95 
⋯ 15 lines hidden (lines 96–110)
96 return DashboardModel(
97 generated_at=now,
98 repo=repo,
99 sources=sources,
100 heartbeat=heartbeat,
101 lifecycle=lifecycle,
102 autonomy=autonomy,
103 queue=queue,
104 budget=budget,
105 conversations=conversations,
106 dispatch=t.dispatch,
107 telemetry=telemetry,
108 failures=t.failures,
109 deploy=deploy,
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
src/ynab_agent/dashboard/render.py477 lines · Python
⋯ 171 lines hidden (lines 1–171)
1"""Render the view-model to one self-contained HTML page (pure).
2 
3All CSS is inline, there is no JavaScript, and the page makes no network request
4of its own — it is a static projection of an already-computed
5:class:`~ynab_agent.dashboard.model.DashboardModel`. Every dynamic value is
6HTML-escaped at the boundary. Ordering is ops-first (is it alive → what's
7flowing → earned autonomy → the human's queue → money → conversations → inbound
8→ telemetry → failures → deploy), so it reads top to bottom.
9"""
10 
11from __future__ import annotations
12 
13from datetime import UTC, datetime
14from html import escape
15from typing import TYPE_CHECKING
16 
17if TYPE_CHECKING:
18 from ynab_agent.dashboard.model import (
19 DashboardModel,
20 Lifecycle,
21 RunTelemetry,
22 )
23 
24_CSS = """
25:root{--fg:#1a1a1a;--mut:#6b6b6b;--line:#e4e4e4;--bg:#fff;--ok:#1a7f37;
26--warn:#9a6700;--bad:#cf222e;--accent:#0969da;--card:#f2f2f2}
27@media(prefers-color-scheme:dark){:root{--fg:#e6e6e6;--mut:#9a9a9a;
28--line:#262626;--bg:#0d0d0d;--ok:#3fb950;--warn:#d29922;--bad:#f85149;
29--accent:#58a6ff;--card:#1b1b1b}}
30*{box-sizing:border-box}
31body{margin:0;background:var(--bg);color:var(--fg);
32font:15px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
33main{max-width:860px;margin:0 auto;padding:34px 20px 72px}
34h1{font-size:22px;margin:0;letter-spacing:-.01em}
35.tag{color:var(--mut);margin:3px 0 0;font-size:13px}
36.meta{color:var(--mut);font-size:12px;margin:12px 0 0}
37.sources{display:flex;flex-wrap:wrap;gap:16px;margin:10px 0 0;font-size:12px}
38section{margin:30px 0 0}
39h2{font-size:12px;text-transform:uppercase;letter-spacing:.08em;
40color:var(--mut);margin:0 0 12px;font-weight:600;
41border-bottom:1px solid var(--line);padding-bottom:6px}
42.stats{display:flex;flex-wrap:wrap;gap:26px}
43.stat .n{font-size:26px;font-weight:600;line-height:1.1}
44.stat .l{color:var(--mut);font-size:12px;margin-top:2px}
45.dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:7px}
46.dot.ok{background:var(--ok)}.dot.warn{background:var(--warn)}
47.dot.bad{background:var(--bad)}.dot.mute{background:var(--mut)}
48table{width:100%;border-collapse:collapse;font-size:13px}
49th,td{text-align:left;padding:6px 12px 6px 0;vertical-align:top;
50border-bottom:1px solid var(--line)}
51th{color:var(--mut);font-weight:600;font-size:11px;text-transform:uppercase;
52letter-spacing:.04em}
53.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
54a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
55.row{display:flex;align-items:baseline;gap:8px;padding:4px 0}
56.mut{color:var(--mut)}.ok{color:var(--ok)}.warn{color:var(--warn)}.bad{color:var(--bad)}
57.note{color:var(--mut);font-size:12px;margin:10px 0 0}
58.barrow{display:grid;grid-template-columns:140px 1fr 34px;gap:10px;
59align-items:center;margin:5px 0}
60.barrow .lab{font-size:12px;color:var(--mut)}
61.barrow .num{font-size:12px;text-align:right;font-variant-numeric:tabular-nums}
62.bar{background:var(--card);border-radius:4px;height:16px;overflow:hidden}
63.bar>span{display:block;height:100%;background:var(--accent);min-width:2px}
64.ladder{display:flex;height:24px;border-radius:5px;overflow:hidden;
65margin:2px 0 10px;font-size:11px;font-weight:600}
66.ladder>span{display:flex;align-items:center;justify-content:center;color:#fff;
67white-space:nowrap;min-width:0}
68.ladder .obs{background:var(--mut)}.ladder .elig{background:var(--warn)}
69.ladder .bless{background:var(--ok)}
70.pill{display:inline-block;padding:1px 7px;border-radius:10px;font-size:11px;
71font-weight:600}
72.pill.ok{background:rgba(63,185,80,.16);color:var(--ok)}
73.pill.warn{background:rgba(210,153,34,.16);color:var(--warn)}
74.pill.mute{background:var(--card);color:var(--mut)}
75footer{margin:44px 0 0;padding-top:16px;border-top:1px solid var(--line);
76color:var(--mut);font-size:12px;line-height:1.7}
77footer b{color:var(--fg);font-weight:600}
78"""
79 
80# The canonical W2 lifecycle order for the funnel (any other state is appended).
81_LIFECYCLE_ORDER = (
82 "discovered",
83 "enriching",
84 "hold_amazon",
85 "awaiting_human",
86 "open",
87 "lapsed",
88 "revising",
90_TRUST_CLASS = {"trusted": "ok", "confirmed": "warn", "suggested": "mute"}
91_CI_CLASS = {"passed": "ok", "failed": "bad", "running": "warn"}
92_STATE_CLASS = {"merged": "ok", "open": "warn", "closed": "mut"}
93 
94 
95def _aware(when: datetime) -> datetime:
96 return when if when.tzinfo is not None else when.replace(tzinfo=UTC)
97 
98 
99def _ago(when: datetime | None, now: datetime) -> str:
100 if when is None:
101 return ""
102 secs = (now - _aware(when)).total_seconds()
103 if secs < 0:
104 return "just now"
105 if secs < 90:
106 return "just now"
107 if secs < 5400:
108 return f"{int(secs // 60)}m ago"
109 if secs < 129600:
110 return f"{int(secs // 3600)}h ago"
111 return f"{int(secs // 86400)}d ago"
112 
113 
114def _dot(kind: str) -> str:
115 return f'<span class="dot {kind}"></span>'
116 
117 
118def _stat(n: object, label: str) -> str:
119 return (
120 f'<div class="stat"><div class="n">{escape(str(n))}</div>'
121 f'<div class="l">{escape(label)}</div></div>'
122 )
123 
124 
125def _pill(text: str, cls: str) -> str:
126 return f'<span class="pill {cls}">{escape(text)}</span>'
127 
128 
129def _header(model: DashboardModel) -> str:
130 stamp = model.generated_at.strftime("%Y-%m-%d %H:%M UTC")
131 dots = "".join(
132 f"<span>{_dot('ok' if s.ok else 'bad')}{escape(s.name)} "
133 f'<span class="mut">{escape(s.detail)}</span></span>'
134 for s in model.sources
135 )
136 return (
137 "<header><h1>ynab-agent</h1>"
138 '<p class="tag">durable transaction triage &middot; '
139 "earned-autonomy read-model</p>"
140 f'<p class="meta">{escape(model.repo)} &middot; generated '
141 f"{escape(stamp)} &middot; derived live, stored nowhere</p>"
142 f'<div class="sources">{dots}</div></header>'
143 )
144 
145 
146def _heartbeat(model: DashboardModel) -> str:
147 h = model.heartbeat
148 now = model.generated_at
149 poll_dot = "ok" if h.poll_live else "bad"
150 poll = (
151 f'<div class="row">{_dot(poll_dot)}<span class="mono">W1 poll</span> '
152 f'<span class="mut">&middot; {escape(h.poll_status)} &middot; last '
153 f"{escape(_ago(h.poll_last_start, now))}</span></div>"
154 )
155 if h.worker_last_span is not None:
156 span_ago = (now - _aware(h.worker_last_span)).total_seconds()
157 wdot = "ok" if span_ago < 3600 else "warn"
158 worker = (
159 f'<div class="row">{_dot(wdot)}<span class="mono">worker</span> '
160 f'<span class="mut">&middot; last span '
161 f"{escape(_ago(h.worker_last_span, now))}</span></div>"
162 )
163 else:
164 worker = (
165 f'<div class="row">{_dot("mute")}'
166 '<span class="mono">worker</span> '
167 '<span class="mut">&middot; no telemetry</span></div>'
168 )
169 return f"<section><h2>Is it alive?</h2>{poll}{worker}</section>"
170 
171 
172def _lifecycle(model: DashboardModel) -> str:
173 lc: Lifecycle = model.lifecycle
174 counts = {s.state: s.count for s in lc.states}
175 ordered = [s for s in _LIFECYCLE_ORDER if s in counts]
176 ordered += [s for s in sorted(counts) if s not in _LIFECYCLE_ORDER]
177 peak = max((counts[s] for s in ordered), default=0) or 1
178 rows = "".join(
179 f'<div class="barrow"><div class="lab">{escape(s)}</div>'
180 f'<div class="bar"><span style="width:{counts[s] * 100 // peak}%">'
181 f'</span></div><div class="num">{counts[s]}</div></div>'
182 for s in ordered
183 )
184 if not ordered:
185 rows = '<p class="note">No transactions in flight.</p>'
186 stats = "".join(
187 (
188 _stat(lc.in_flight, "in flight"),
189 _stat(lc.archived, "archived (recent)"),
190 _stat(lc.terminated, "terminated (recent)"),
191 )
192 )
193 return (
194 "<section><h2>Transaction lifecycle</h2>"
195 f'<div class="stats">{stats}</div>'
196 f'<div style="margin-top:14px">{rows}</div></section>'
197 )
198 
⋯ 279 lines hidden (lines 199–477)
199 
200def _ladder(model: DashboardModel) -> str:
201 a = model.autonomy
202 total = a.observe + a.eligible + a.blessed
203 if total:
204 segs = "".join(
205 f'<span class="{cls}" style="flex:{n}" title="{label}: {n}">'
206 f"{n if n * 100 // total >= 8 else ''}</span>"
207 for n, cls, label in (
208 (a.observe, "obs", "observe"),
209 (a.eligible, "elig", "eligible"),
210 (a.blessed, "bless", "blessed"),
211 )
212 if n
213 )
214 bar = f'<div class="ladder">{segs}</div>'
215 else:
216 bar = ""
217 stats = "".join(
218 (
219 _stat(a.observe, "observe"),
220 _stat(a.eligible, "eligible"),
221 _stat(a.blessed, "blessed (auto)"),
222 _stat(len(a.offers), "live offers"),
223 )
224 )
225 if a.rules:
226 rows = "".join(
227 "<tr>"
228 f'<td class="mono">{escape(r.payee)}</td>'
229 f'<td class="mono mut">{escape(r.category)}</td>'
230 f"<td>{_pill(r.trust, _TRUST_CLASS.get(r.trust, 'mute'))}</td>"
231 f"<td>{escape(r.source)}</td>"
232 f'<td class="num">{r.hits}</td>'
233 f"<td>{'yes' if r.offered else ''}</td>"
234 "</tr>"
235 for r in a.rules[:30]
236 )
237 table = (
238 "<table><thead><tr><th>payee</th><th>category</th><th>trust</th>"
239 "<th>source</th><th>hits</th><th>offered</th></tr></thead>"
240 f"<tbody>{rows}</tbody></table>"
241 )
242 else:
243 table = '<p class="note">No learned rules yet.</p>'
244 note = (
245 '<p class="note">Autonomy is earned then granted: a rule becomes '
246 "<b>eligible</b> after K consistent confirms, and <b>blessed</b> "
247 "(auto-applies) only once the owner accepts the offer. A correction "
248 "demotes it back to observe.</p>"
249 )
250 return (
251 "<section><h2>Autonomy ladder</h2>"
252 f'<div class="stats">{stats}</div>{bar}{table}{note}</section>'
253 )
254 
255 
256def _queue(model: DashboardModel) -> str:
257 now = model.generated_at
258 if not model.queue:
259 body = '<p class="note">Nothing awaiting the owner.</p>'
260 else:
261 rows = "".join(
262 "<tr>"
263 f"<td>{_pill(q.kind, 'warn')}</td>"
264 f'<td class="mono">{escape(q.label)}</td>'
265 f'<td class="mut">{escape(_ago(q.since, now))}</td>'
266 "</tr>"
267 for q in model.queue[:30]
268 )
269 body = (
270 "<table><thead><tr><th>kind</th><th>what</th><th>waiting</th>"
271 f"</tr></thead><tbody>{rows}</tbody></table>"
272 )
273 return f"<section><h2>Awaiting a human</h2>{body}</section>"
274 
275 
276def _budget(model: DashboardModel) -> str:
277 b = model.budget
278 if not b.available:
279 return (
280 "<section><h2>Budget &middot; YNAB</h2>"
281 '<p class="note">Unavailable (YNAB not configured or '
282 "unreachable).</p></section>"
283 )
284 sample = ", ".join(
285 f"{escape(t.payee)} {escape(t.amount)}" for t in b.unapproved_sample
286 )
287 sample_note = f'<p class="note">{escape(sample)}</p>' if sample else ""
288 if b.overspent:
289 rows = "".join(
290 "<tr>"
291 f'<td class="mono">{escape(c.name)}</td>'
292 f'<td class="bad mono">{escape(c.balance)}</td>'
293 "</tr>"
294 for c in b.overspent
295 )
296 over = (
297 "<table><thead><tr><th>overspent category</th><th>balance</th>"
298 f"</tr></thead><tbody>{rows}</tbody></table>"
299 )
300 else:
301 over = '<p class="note">No overspent categories.</p>'
302 stats = "".join((_stat(b.unapproved, "unapproved"),))
303 return (
304 "<section><h2>Budget &middot; YNAB</h2>"
305 f'<div class="stats">{stats}</div>{sample_note}'
306 f'<div style="margin-top:12px">{over}</div></section>'
307 )
308 
309 
310def _conversations(model: DashboardModel) -> str:
311 now = model.generated_at
312 if not model.conversations:
313 return ""
314 rows = "".join(
315 "<tr>"
316 f"<td>{_pill(c.kind, 'mute')}</td>"
317 f"<td>{escape(c.subject)}</td>"
318 f'<td class="mut">{escape(c.preview)}</td>'
319 f'<td class="mut">{escape(_ago(c.updated_at, now))}</td>'
320 "</tr>"
321 for c in model.conversations[:12]
322 )
323 return (
324 "<section><h2>Conversations &middot; AgentMail</h2>"
325 "<table><thead><tr><th>kind</th><th>subject</th><th>preview</th>"
326 f"<th>updated</th></tr></thead><tbody>{rows}</tbody></table></section>"
327 )
328 
329 
330def _dispatch(model: DashboardModel) -> str:
331 d = model.dispatch
332 stats = "".join(
333 (
334 _stat(d.transaction, "→ transaction"),
335 _stat(d.offer, "→ offer"),
336 _stat(d.receipt, "→ receipt"),
337 _stat(d.command, "→ command"),
338 _stat(d.quarantine, "quarantined"),
339 _stat(d.ignore, "ignored"),
340 )
341 )
342 return (
343 "<section><h2>Inbound &middot; W3 dispatch "
344 f'({d.total} recent)</h2><div class="stats">{stats}</div></section>'
345 )
346 
347 
348def _telemetry(model: DashboardModel) -> str:
349 t: RunTelemetry = model.telemetry
350 now = model.generated_at
351 if not t.available:
352 return (
353 "<section><h2>Run telemetry &middot; ClickHouse</h2>"
354 '<p class="note">Unavailable (not configured or unreachable); '
355 "Temporal carries the dashboard regardless.</p></section>"
356 )
357 if t.activities:
358 rows = "".join(
359 "<tr>"
360 f'<td class="mono">{escape(a.name)}</td>'
361 f'<td class="num">{a.count}</td>'
362 f'<td class="mut">{a.avg_ms:.0f} ms</td>'
363 f'<td class="mut">{a.max_ms:.0f} ms</td>'
364 "</tr>"
365 for a in t.activities
366 )
367 table = (
368 "<table><thead><tr><th>activity</th><th>runs</th><th>avg</th>"
369 f"<th>max</th></tr></thead><tbody>{rows}</tbody></table>"
370 )
371 else:
372 table = '<p class="note">No spans in the window.</p>'
373 errors = (
374 "".join(
375 f'<div class="row mono bad">{escape(e)}</div>'
376 for e in t.recent_errors
377 )
378 if t.recent_errors
379 else ""
380 )
381 summary = (
382 f'<p class="note">{t.total_spans} spans &middot; '
383 f"{t.error_spans} errored &middot; last "
384 f"{escape(_ago(t.last_activity, now))} &middot; "
385 f"{t.window_days}-day window.</p>"
386 )
387 return (
388 "<section><h2>Run telemetry &middot; ClickHouse</h2>"
389 f"{summary}{table}{errors}</section>"
390 )
391 
392 
393def _failures(model: DashboardModel) -> str:
394 if not model.failures:
395 return ""
396 now = model.generated_at
397 rows = "".join(
398 "<tr>"
399 f'<td class="mono">{escape(f.workflow_id)}</td>'
400 f'<td class="bad">{escape(f.kind)}</td>'
401 f'<td class="mut">{escape(f.reason or "")}</td>'
402 f'<td class="mut">{escape(_ago(f.when, now))}</td>'
403 "</tr>"
404 for f in model.failures
405 )
406 return (
407 "<section><h2>Failures &middot; where a workflow did not close</h2>"
408 "<table><thead><tr><th>workflow</th><th>kind</th><th>reason</th>"
409 f"<th>when</th></tr></thead><tbody>{rows}</tbody></table></section>"
410 )
411 
412 
413def _deploy(model: DashboardModel) -> str:
414 now = model.generated_at
415 if not model.deploy.prs:
416 return ""
417 rows = "".join(
418 "<tr>"
419 f'<td><a href="{escape(p.url, quote=True)}">#{p.number}</a></td>'
420 f"<td>{escape(p.title)}</td>"
421 f'<td class="{_STATE_CLASS.get(p.state, "mut")}">{escape(p.state)}</td>'
422 f"<td>{_ci_cell(p.ci)}</td>"
423 f'<td class="mut">{escape(_ago(p.when, now))}</td>'
424 "</tr>"
425 for p in model.deploy.prs
426 )
427 return (
428 "<section><h2>Deploy &middot; GitHub</h2>"
429 "<table><thead><tr><th>pr</th><th>title</th><th>state</th><th>ci</th>"
430 f"<th>opened</th></tr></thead><tbody>{rows}</tbody></table></section>"
431 )
432 
433 
434def _ci_cell(ci: str | None) -> str:
435 if ci is None:
436 return '<span class="mut">—</span>'
437 return f'<span class="{_CI_CLASS.get(ci, "mut")}">{escape(ci)}</span>'
438 
439 
440def _footer() -> str:
441 return (
442 "<footer><b>Safety envelope.</b> Every auto-apply passes the hard "
443 "floor (amount ceiling, unreadable amount, per-run/day breaker) and "
444 "the earned-autonomy gate (exactly one blessed rule), then a "
445 "clean-context "
446 "model <b>safety review</b> that can only hold it back. Autonomy is "
447 "revoked — the rule demoted — on an explicit correction or a silent "
448 "in-YNAB edit. Everything above is derived on this request from "
449 "Temporal, YNAB, ClickHouse, AgentMail, and GitHub; nothing is stored. "
450 "Reload to recompute.</footer>"
451 )
452 
453 
454def page(model: DashboardModel) -> str:
455 """Render the whole dashboard as one self-contained HTML document."""
456 parts = (
457 _header(model),
458 _heartbeat(model),
459 _lifecycle(model),
460 _ladder(model),
461 _queue(model),
462 _budget(model),
463 _conversations(model),
464 _dispatch(model),
465 _telemetry(model),
466 _failures(model),
467 _deploy(model),
468 _footer(),
469 )
470 return (
471 '<!doctype html><html lang="en"><head><meta charset="utf-8">'
472 '<meta name="viewport" content="width=device-width,initial-scale=1">'
473 "<title>ynab-agent &middot; ops</title>"
474 f"<style>{_CSS}</style></head><body><main>"
475 + "".join(parts)
476 + "</main></body></html>"
477 )

The autonomy-ladder strip: flex segments sized to the three counts.

src/ynab_agent/dashboard/render.py · 477 lines
src/ynab_agent/dashboard/render.py477 lines · Python
⋯ 199 lines hidden (lines 1–199)
1"""Render the view-model to one self-contained HTML page (pure).
2 
3All CSS is inline, there is no JavaScript, and the page makes no network request
4of its own — it is a static projection of an already-computed
5:class:`~ynab_agent.dashboard.model.DashboardModel`. Every dynamic value is
6HTML-escaped at the boundary. Ordering is ops-first (is it alive → what's
7flowing → earned autonomy → the human's queue → money → conversations → inbound
8→ telemetry → failures → deploy), so it reads top to bottom.
9"""
10 
11from __future__ import annotations
12 
13from datetime import UTC, datetime
14from html import escape
15from typing import TYPE_CHECKING
16 
17if TYPE_CHECKING:
18 from ynab_agent.dashboard.model import (
19 DashboardModel,
20 Lifecycle,
21 RunTelemetry,
22 )
23 
24_CSS = """
25:root{--fg:#1a1a1a;--mut:#6b6b6b;--line:#e4e4e4;--bg:#fff;--ok:#1a7f37;
26--warn:#9a6700;--bad:#cf222e;--accent:#0969da;--card:#f2f2f2}
27@media(prefers-color-scheme:dark){:root{--fg:#e6e6e6;--mut:#9a9a9a;
28--line:#262626;--bg:#0d0d0d;--ok:#3fb950;--warn:#d29922;--bad:#f85149;
29--accent:#58a6ff;--card:#1b1b1b}}
30*{box-sizing:border-box}
31body{margin:0;background:var(--bg);color:var(--fg);
32font:15px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
33main{max-width:860px;margin:0 auto;padding:34px 20px 72px}
34h1{font-size:22px;margin:0;letter-spacing:-.01em}
35.tag{color:var(--mut);margin:3px 0 0;font-size:13px}
36.meta{color:var(--mut);font-size:12px;margin:12px 0 0}
37.sources{display:flex;flex-wrap:wrap;gap:16px;margin:10px 0 0;font-size:12px}
38section{margin:30px 0 0}
39h2{font-size:12px;text-transform:uppercase;letter-spacing:.08em;
40color:var(--mut);margin:0 0 12px;font-weight:600;
41border-bottom:1px solid var(--line);padding-bottom:6px}
42.stats{display:flex;flex-wrap:wrap;gap:26px}
43.stat .n{font-size:26px;font-weight:600;line-height:1.1}
44.stat .l{color:var(--mut);font-size:12px;margin-top:2px}
45.dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:7px}
46.dot.ok{background:var(--ok)}.dot.warn{background:var(--warn)}
47.dot.bad{background:var(--bad)}.dot.mute{background:var(--mut)}
48table{width:100%;border-collapse:collapse;font-size:13px}
49th,td{text-align:left;padding:6px 12px 6px 0;vertical-align:top;
50border-bottom:1px solid var(--line)}
51th{color:var(--mut);font-weight:600;font-size:11px;text-transform:uppercase;
52letter-spacing:.04em}
53.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
54a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
55.row{display:flex;align-items:baseline;gap:8px;padding:4px 0}
56.mut{color:var(--mut)}.ok{color:var(--ok)}.warn{color:var(--warn)}.bad{color:var(--bad)}
57.note{color:var(--mut);font-size:12px;margin:10px 0 0}
58.barrow{display:grid;grid-template-columns:140px 1fr 34px;gap:10px;
59align-items:center;margin:5px 0}
60.barrow .lab{font-size:12px;color:var(--mut)}
61.barrow .num{font-size:12px;text-align:right;font-variant-numeric:tabular-nums}
62.bar{background:var(--card);border-radius:4px;height:16px;overflow:hidden}
63.bar>span{display:block;height:100%;background:var(--accent);min-width:2px}
64.ladder{display:flex;height:24px;border-radius:5px;overflow:hidden;
65margin:2px 0 10px;font-size:11px;font-weight:600}
66.ladder>span{display:flex;align-items:center;justify-content:center;color:#fff;
67white-space:nowrap;min-width:0}
68.ladder .obs{background:var(--mut)}.ladder .elig{background:var(--warn)}
69.ladder .bless{background:var(--ok)}
70.pill{display:inline-block;padding:1px 7px;border-radius:10px;font-size:11px;
71font-weight:600}
72.pill.ok{background:rgba(63,185,80,.16);color:var(--ok)}
73.pill.warn{background:rgba(210,153,34,.16);color:var(--warn)}
74.pill.mute{background:var(--card);color:var(--mut)}
75footer{margin:44px 0 0;padding-top:16px;border-top:1px solid var(--line);
76color:var(--mut);font-size:12px;line-height:1.7}
77footer b{color:var(--fg);font-weight:600}
78"""
79 
80# The canonical W2 lifecycle order for the funnel (any other state is appended).
81_LIFECYCLE_ORDER = (
82 "discovered",
83 "enriching",
84 "hold_amazon",
85 "awaiting_human",
86 "open",
87 "lapsed",
88 "revising",
90_TRUST_CLASS = {"trusted": "ok", "confirmed": "warn", "suggested": "mute"}
91_CI_CLASS = {"passed": "ok", "failed": "bad", "running": "warn"}
92_STATE_CLASS = {"merged": "ok", "open": "warn", "closed": "mut"}
93 
94 
95def _aware(when: datetime) -> datetime:
96 return when if when.tzinfo is not None else when.replace(tzinfo=UTC)
97 
98 
99def _ago(when: datetime | None, now: datetime) -> str:
100 if when is None:
101 return ""
102 secs = (now - _aware(when)).total_seconds()
103 if secs < 0:
104 return "just now"
105 if secs < 90:
106 return "just now"
107 if secs < 5400:
108 return f"{int(secs // 60)}m ago"
109 if secs < 129600:
110 return f"{int(secs // 3600)}h ago"
111 return f"{int(secs // 86400)}d ago"
112 
113 
114def _dot(kind: str) -> str:
115 return f'<span class="dot {kind}"></span>'
116 
117 
118def _stat(n: object, label: str) -> str:
119 return (
120 f'<div class="stat"><div class="n">{escape(str(n))}</div>'
121 f'<div class="l">{escape(label)}</div></div>'
122 )
123 
124 
125def _pill(text: str, cls: str) -> str:
126 return f'<span class="pill {cls}">{escape(text)}</span>'
127 
128 
129def _header(model: DashboardModel) -> str:
130 stamp = model.generated_at.strftime("%Y-%m-%d %H:%M UTC")
131 dots = "".join(
132 f"<span>{_dot('ok' if s.ok else 'bad')}{escape(s.name)} "
133 f'<span class="mut">{escape(s.detail)}</span></span>'
134 for s in model.sources
135 )
136 return (
137 "<header><h1>ynab-agent</h1>"
138 '<p class="tag">durable transaction triage &middot; '
139 "earned-autonomy read-model</p>"
140 f'<p class="meta">{escape(model.repo)} &middot; generated '
141 f"{escape(stamp)} &middot; derived live, stored nowhere</p>"
142 f'<div class="sources">{dots}</div></header>'
143 )
144 
145 
146def _heartbeat(model: DashboardModel) -> str:
147 h = model.heartbeat
148 now = model.generated_at
149 poll_dot = "ok" if h.poll_live else "bad"
150 poll = (
151 f'<div class="row">{_dot(poll_dot)}<span class="mono">W1 poll</span> '
152 f'<span class="mut">&middot; {escape(h.poll_status)} &middot; last '
153 f"{escape(_ago(h.poll_last_start, now))}</span></div>"
154 )
155 if h.worker_last_span is not None:
156 span_ago = (now - _aware(h.worker_last_span)).total_seconds()
157 wdot = "ok" if span_ago < 3600 else "warn"
158 worker = (
159 f'<div class="row">{_dot(wdot)}<span class="mono">worker</span> '
160 f'<span class="mut">&middot; last span '
161 f"{escape(_ago(h.worker_last_span, now))}</span></div>"
162 )
163 else:
164 worker = (
165 f'<div class="row">{_dot("mute")}'
166 '<span class="mono">worker</span> '
167 '<span class="mut">&middot; no telemetry</span></div>'
168 )
169 return f"<section><h2>Is it alive?</h2>{poll}{worker}</section>"
170 
171 
172def _lifecycle(model: DashboardModel) -> str:
173 lc: Lifecycle = model.lifecycle
174 counts = {s.state: s.count for s in lc.states}
175 ordered = [s for s in _LIFECYCLE_ORDER if s in counts]
176 ordered += [s for s in sorted(counts) if s not in _LIFECYCLE_ORDER]
177 peak = max((counts[s] for s in ordered), default=0) or 1
178 rows = "".join(
179 f'<div class="barrow"><div class="lab">{escape(s)}</div>'
180 f'<div class="bar"><span style="width:{counts[s] * 100 // peak}%">'
181 f'</span></div><div class="num">{counts[s]}</div></div>'
182 for s in ordered
183 )
184 if not ordered:
185 rows = '<p class="note">No transactions in flight.</p>'
186 stats = "".join(
187 (
188 _stat(lc.in_flight, "in flight"),
189 _stat(lc.archived, "archived (recent)"),
190 _stat(lc.terminated, "terminated (recent)"),
191 )
192 )
193 return (
194 "<section><h2>Transaction lifecycle</h2>"
195 f'<div class="stats">{stats}</div>'
196 f'<div style="margin-top:14px">{rows}</div></section>'
197 )
198 
199 
200def _ladder(model: DashboardModel) -> str:
201 a = model.autonomy
202 total = a.observe + a.eligible + a.blessed
203 if total:
204 segs = "".join(
205 f'<span class="{cls}" style="flex:{n}" title="{label}: {n}">'
206 f"{n if n * 100 // total >= 8 else ''}</span>"
207 for n, cls, label in (
208 (a.observe, "obs", "observe"),
209 (a.eligible, "elig", "eligible"),
210 (a.blessed, "bless", "blessed"),
211 )
212 if n
213 )
214 bar = f'<div class="ladder">{segs}</div>'
215 else:
216 bar = ""
217 stats = "".join(
218 (
219 _stat(a.observe, "observe"),
220 _stat(a.eligible, "eligible"),
221 _stat(a.blessed, "blessed (auto)"),
222 _stat(len(a.offers), "live offers"),
223 )
224 )
⋯ 253 lines hidden (lines 225–477)
225 if a.rules:
226 rows = "".join(
227 "<tr>"
228 f'<td class="mono">{escape(r.payee)}</td>'
229 f'<td class="mono mut">{escape(r.category)}</td>'
230 f"<td>{_pill(r.trust, _TRUST_CLASS.get(r.trust, 'mute'))}</td>"
231 f"<td>{escape(r.source)}</td>"
232 f'<td class="num">{r.hits}</td>'
233 f"<td>{'yes' if r.offered else ''}</td>"
234 "</tr>"
235 for r in a.rules[:30]
236 )
237 table = (
238 "<table><thead><tr><th>payee</th><th>category</th><th>trust</th>"
239 "<th>source</th><th>hits</th><th>offered</th></tr></thead>"
240 f"<tbody>{rows}</tbody></table>"
241 )
242 else:
243 table = '<p class="note">No learned rules yet.</p>'
244 note = (
245 '<p class="note">Autonomy is earned then granted: a rule becomes '
246 "<b>eligible</b> after K consistent confirms, and <b>blessed</b> "
247 "(auto-applies) only once the owner accepts the offer. A correction "
248 "demotes it back to observe.</p>"
249 )
250 return (
251 "<section><h2>Autonomy ladder</h2>"
252 f'<div class="stats">{stats}</div>{bar}{table}{note}</section>'
253 )
254 
255 
256def _queue(model: DashboardModel) -> str:
257 now = model.generated_at
258 if not model.queue:
259 body = '<p class="note">Nothing awaiting the owner.</p>'
260 else:
261 rows = "".join(
262 "<tr>"
263 f"<td>{_pill(q.kind, 'warn')}</td>"
264 f'<td class="mono">{escape(q.label)}</td>'
265 f'<td class="mut">{escape(_ago(q.since, now))}</td>'
266 "</tr>"
267 for q in model.queue[:30]
268 )
269 body = (
270 "<table><thead><tr><th>kind</th><th>what</th><th>waiting</th>"
271 f"</tr></thead><tbody>{rows}</tbody></table>"
272 )
273 return f"<section><h2>Awaiting a human</h2>{body}</section>"
274 
275 
276def _budget(model: DashboardModel) -> str:
277 b = model.budget
278 if not b.available:
279 return (
280 "<section><h2>Budget &middot; YNAB</h2>"
281 '<p class="note">Unavailable (YNAB not configured or '
282 "unreachable).</p></section>"
283 )
284 sample = ", ".join(
285 f"{escape(t.payee)} {escape(t.amount)}" for t in b.unapproved_sample
286 )
287 sample_note = f'<p class="note">{escape(sample)}</p>' if sample else ""
288 if b.overspent:
289 rows = "".join(
290 "<tr>"
291 f'<td class="mono">{escape(c.name)}</td>'
292 f'<td class="bad mono">{escape(c.balance)}</td>'
293 "</tr>"
294 for c in b.overspent
295 )
296 over = (
297 "<table><thead><tr><th>overspent category</th><th>balance</th>"
298 f"</tr></thead><tbody>{rows}</tbody></table>"
299 )
300 else:
301 over = '<p class="note">No overspent categories.</p>'
302 stats = "".join((_stat(b.unapproved, "unapproved"),))
303 return (
304 "<section><h2>Budget &middot; YNAB</h2>"
305 f'<div class="stats">{stats}</div>{sample_note}'
306 f'<div style="margin-top:12px">{over}</div></section>'
307 )
308 
309 
310def _conversations(model: DashboardModel) -> str:
311 now = model.generated_at
312 if not model.conversations:
313 return ""
314 rows = "".join(
315 "<tr>"
316 f"<td>{_pill(c.kind, 'mute')}</td>"
317 f"<td>{escape(c.subject)}</td>"
318 f'<td class="mut">{escape(c.preview)}</td>'
319 f'<td class="mut">{escape(_ago(c.updated_at, now))}</td>'
320 "</tr>"
321 for c in model.conversations[:12]
322 )
323 return (
324 "<section><h2>Conversations &middot; AgentMail</h2>"
325 "<table><thead><tr><th>kind</th><th>subject</th><th>preview</th>"
326 f"<th>updated</th></tr></thead><tbody>{rows}</tbody></table></section>"
327 )
328 
329 
330def _dispatch(model: DashboardModel) -> str:
331 d = model.dispatch
332 stats = "".join(
333 (
334 _stat(d.transaction, "→ transaction"),
335 _stat(d.offer, "→ offer"),
336 _stat(d.receipt, "→ receipt"),
337 _stat(d.command, "→ command"),
338 _stat(d.quarantine, "quarantined"),
339 _stat(d.ignore, "ignored"),
340 )
341 )
342 return (
343 "<section><h2>Inbound &middot; W3 dispatch "
344 f'({d.total} recent)</h2><div class="stats">{stats}</div></section>'
345 )
346 
347 
348def _telemetry(model: DashboardModel) -> str:
349 t: RunTelemetry = model.telemetry
350 now = model.generated_at
351 if not t.available:
352 return (
353 "<section><h2>Run telemetry &middot; ClickHouse</h2>"
354 '<p class="note">Unavailable (not configured or unreachable); '
355 "Temporal carries the dashboard regardless.</p></section>"
356 )
357 if t.activities:
358 rows = "".join(
359 "<tr>"
360 f'<td class="mono">{escape(a.name)}</td>'
361 f'<td class="num">{a.count}</td>'
362 f'<td class="mut">{a.avg_ms:.0f} ms</td>'
363 f'<td class="mut">{a.max_ms:.0f} ms</td>'
364 "</tr>"
365 for a in t.activities
366 )
367 table = (
368 "<table><thead><tr><th>activity</th><th>runs</th><th>avg</th>"
369 f"<th>max</th></tr></thead><tbody>{rows}</tbody></table>"
370 )
371 else:
372 table = '<p class="note">No spans in the window.</p>'
373 errors = (
374 "".join(
375 f'<div class="row mono bad">{escape(e)}</div>'
376 for e in t.recent_errors
377 )
378 if t.recent_errors
379 else ""
380 )
381 summary = (
382 f'<p class="note">{t.total_spans} spans &middot; '
383 f"{t.error_spans} errored &middot; last "
384 f"{escape(_ago(t.last_activity, now))} &middot; "
385 f"{t.window_days}-day window.</p>"
386 )
387 return (
388 "<section><h2>Run telemetry &middot; ClickHouse</h2>"
389 f"{summary}{table}{errors}</section>"
390 )
391 
392 
393def _failures(model: DashboardModel) -> str:
394 if not model.failures:
395 return ""
396 now = model.generated_at
397 rows = "".join(
398 "<tr>"
399 f'<td class="mono">{escape(f.workflow_id)}</td>'
400 f'<td class="bad">{escape(f.kind)}</td>'
401 f'<td class="mut">{escape(f.reason or "")}</td>'
402 f'<td class="mut">{escape(_ago(f.when, now))}</td>'
403 "</tr>"
404 for f in model.failures
405 )
406 return (
407 "<section><h2>Failures &middot; where a workflow did not close</h2>"
408 "<table><thead><tr><th>workflow</th><th>kind</th><th>reason</th>"
409 f"<th>when</th></tr></thead><tbody>{rows}</tbody></table></section>"
410 )
411 
412 
413def _deploy(model: DashboardModel) -> str:
414 now = model.generated_at
415 if not model.deploy.prs:
416 return ""
417 rows = "".join(
418 "<tr>"
419 f'<td><a href="{escape(p.url, quote=True)}">#{p.number}</a></td>'
420 f"<td>{escape(p.title)}</td>"
421 f'<td class="{_STATE_CLASS.get(p.state, "mut")}">{escape(p.state)}</td>'
422 f"<td>{_ci_cell(p.ci)}</td>"
423 f'<td class="mut">{escape(_ago(p.when, now))}</td>'
424 "</tr>"
425 for p in model.deploy.prs
426 )
427 return (
428 "<section><h2>Deploy &middot; GitHub</h2>"
429 "<table><thead><tr><th>pr</th><th>title</th><th>state</th><th>ci</th>"
430 f"<th>opened</th></tr></thead><tbody>{rows}</tbody></table></section>"
431 )
432 
433 
434def _ci_cell(ci: str | None) -> str:
435 if ci is None:
436 return '<span class="mut">—</span>'
437 return f'<span class="{_CI_CLASS.get(ci, "mut")}">{escape(ci)}</span>'
438 
439 
440def _footer() -> str:
441 return (
442 "<footer><b>Safety envelope.</b> Every auto-apply passes the hard "
443 "floor (amount ceiling, unreadable amount, per-run/day breaker) and "
444 "the earned-autonomy gate (exactly one blessed rule), then a "
445 "clean-context "
446 "model <b>safety review</b> that can only hold it back. Autonomy is "
447 "revoked — the rule demoted — on an explicit correction or a silent "
448 "in-YNAB edit. Everything above is derived on this request from "
449 "Temporal, YNAB, ClickHouse, AgentMail, and GitHub; nothing is stored. "
450 "Reload to recompute.</footer>"
451 )
452 
453 
454def page(model: DashboardModel) -> str:
455 """Render the whole dashboard as one self-contained HTML document."""
456 parts = (
457 _header(model),
458 _heartbeat(model),
459 _lifecycle(model),
460 _ladder(model),
461 _queue(model),
462 _budget(model),
463 _conversations(model),
464 _dispatch(model),
465 _telemetry(model),
466 _failures(model),
467 _deploy(model),
468 _footer(),
469 )
470 return (
471 '<!doctype html><html lang="en"><head><meta charset="utf-8">'
472 '<meta name="viewport" content="width=device-width,initial-scale=1">'
473 "<title>ynab-agent &middot; ops</title>"
474 f"<style>{_CSS}</style></head><body><main>"
475 + "".join(parts)
476 + "</main></body></html>"
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).