What changed, and why

SPEC §13 requires one declared household timezone for the budget month, the run-rate, and day boundaries; §11 left which timezone open. No timezone existed anywhere in the code, so the decision was silently resolved to UTC. For a US Central household, UTC is 5–6 hours ahead: near midnight — and especially at month boundaries — the W6 monitor bucketed an alert into the wrong month's thread and dedupe key, the run-rate divided by the wrong day-of-month, and the alert's "days left" and the dashboard's "as of" stamp read a day off.

This change declares America/Chicago once and derives every W6 day/month boundary in it. The interesting part is where the conversion runs — a hang forced the right design.

§11's open decision, resolved explicitly: one declared zone, used for bucketing and display — never as a wall clock.

src/ynab_agent/domain/config.py · 37 lines
src/ynab_agent/domain/config.py37 lines · Python
⋯ 28 lines hidden (lines 1–28)
1"""Household config the pure core reads: timer windows + the timezone (§11).
2 
3The state machine is pure but must set absolute deadlines (the SPEC mandates
4absolute timestamps, not durations). It computes them as ``now + window`` from
5this config, so the windows are tunable in one place. Heavier policy (the
6autonomy gate, the hard floor) lives elsewhere; this holds the timers and the
7one declared household timezone.
8"""
9 
10from __future__ import annotations
11 
12from datetime import timedelta
13from zoneinfo import ZoneInfo
14 
15from pydantic import Field
16 
17from ynab_agent.domain.base import Frozen
18 
19 
20class LifecyclePolicy(Frozen):
21 """Tunable timer windows (defaults from SPEC §11)."""
22 
23 patience_window: timedelta = Field(default=timedelta(days=7))
24 amazon_hold: timedelta = Field(default=timedelta(hours=36))
25 archive_window: timedelta = Field(default=timedelta(days=30))
26 
27 
28DEFAULT_POLICY = LifecyclePolicy()
29 
30# The one declared household timezone (SPEC §11, §13). Every day/month
31# boundary the agent reasons about — the W6 budget month and run-rate, the
32# dashboard's "as of" stamp (and, as they land, the Amazon 02:00 expectation and
33# receipt date-proximity) — is derived in this zone, not UTC, so a charge near
34# midnight or a month boundary lands in the right day/month. ``workflow.now()``
35# stays the deterministic clock; it is only *converted* to this zone for
36# bucketing and display, never used as a non-deterministic local clock.
37HOUSEHOLD_TZ = ZoneInfo("America/Chicago")

The pure conversion

period_and_clock takes the deterministic UTC instant and converts it to household time before reading the day and month. It returns the YYYY-MM period and the MonthClock the run-rate divides by — the two values that were silently wrong near boundaries.

Convert first, then derive. Pure and unit-testable.

src/ynab_agent/budget/overspend.py · 183 lines
src/ynab_agent/budget/overspend.py183 lines · Python
⋯ 101 lines hidden (lines 1–101)
1"""W6 · the overspend monitor — pure projection and alerting (SPEC §7).
2 
3Run daily per category: from ``budgeted``/``activity`` and where we are in the
4month, project month-end spend by run-rate and decide whether to raise an alert.
5v1 is notify-only; the spine here is pure — :func:`assess` ranks a category and
6:func:`should_alert` enforces the dedupe (at most one alert per period unless it
7materially worsens). The workflow does the I/O (fetch, send, remember).
8 
9All money is YNAB-native: ``activity`` is signed (outflows negative), so the
10spend magnitude is ``-activity`` when it is an outflow.
11"""
12 
13from __future__ import annotations
14 
15import calendar
16from enum import StrEnum
17from typing import TYPE_CHECKING
18 
19from pydantic import Field, model_validator
20 
21from ynab_agent.domain.base import Frozen
22from ynab_agent.domain.config import HOUSEHOLD_TZ
23from ynab_agent.domain.ids import CategoryId
24from ynab_agent.domain.money import Money
25 
26if TYPE_CHECKING:
27 import datetime
28 
29# Days in the shortest/longest months — the bounds a month length must fall in.
30_MIN_MONTH_DAYS = 28
31_MAX_MONTH_DAYS = 31
32 
33 
34class CategorySpend(Frozen):
35 """A category's month-to-date budget figures (YNAB-native signs)."""
36 
37 category: CategoryId
38 name: str
39 budgeted: Money
40 activity: Money
41 balance: Money
42 
43 
44class MonthClock(Frozen):
45 """Where we are in the budget month, for the run-rate projection."""
46 
47 day_of_month: int = Field(ge=1)
48 days_in_month: int = Field(ge=_MIN_MONTH_DAYS, le=_MAX_MONTH_DAYS)
49 
50 @model_validator(mode="after")
51 def _check_within_month(self) -> MonthClock:
52 if self.day_of_month > self.days_in_month:
53 msg = "day_of_month cannot exceed days_in_month"
54 raise ValueError(msg)
55 return self
56 
57 
58class OverspendVerdict(StrEnum):
59 """How a category is tracking against its budget this month."""
60 
61 OK = "ok"
62 TRENDING_OVER = "trending_over"
63 ALREADY_OVER = "already_over"
64 
65 
66class OverspendPolicy(Frozen):
67 """The single tunable: how far over the projection must trend to alert."""
68 
69 trend_threshold: Money = Field(
70 default_factory=lambda: Money.from_currency(25)
71 )
72 
73 
74DEFAULT_OVERSPEND_POLICY = OverspendPolicy()
75 
76 
77class OverspendAssessment(Frozen):
78 """The verdict for one category and the numbers behind it."""
79 
80 category: CategoryId
81 name: str
82 verdict: OverspendVerdict
83 budgeted: Money
84 spent: Money
85 projected: Money
86 
87 
88class PriorAlert(Frozen):
89 """The last alert raised for a category this period (for dedupe)."""
90 
91 verdict: OverspendVerdict
92 projected: Money
93 
94 
95def spent_magnitude(category: CategorySpend) -> Money:
96 """The positive amount spent this month (``-activity`` if an outflow)."""
97 if category.activity.is_outflow:
98 return -category.activity
99 return Money.zero()
100 
101 
102def period_and_clock(now: datetime.datetime) -> tuple[str, MonthClock]:
103 """The budget period (``YYYY-MM``) and month position, in household time.
104 
105 ``now`` is the deterministic UTC instant (``workflow.now()``); it is
106 converted to the declared household timezone (SPEC §11, §13) before the
107 day and month are read, so a charge near midnight or a month boundary is
108 bucketed into the right month and the run-rate's ``day_of_month`` is the
109 household's, not UTC's (which is hours ahead).
110 """
111 local = now.astimezone(HOUSEHOLD_TZ)
112 clock = MonthClock(
113 day_of_month=local.day,
114 days_in_month=calendar.monthrange(local.year, local.month)[1],
115 )
116 return local.strftime("%Y-%m"), clock
117 
⋯ 66 lines hidden (lines 118–183)
118 
119def project_spend(
120 category: CategorySpend, clock: MonthClock, scheduled: Money
121) -> Money:
122 """Project month-end spend by run-rate plus known scheduled outflows (§7).
123 
124 ``spent / days_elapsed * days_in_month + scheduled``, in exact milliunits.
125 """
126 spent = spent_magnitude(category)
127 run_rate = Money.from_milliunits(
128 spent.milliunits * clock.days_in_month // clock.day_of_month
129 )
130 return run_rate + scheduled
131 
132 
133def assess(
134 category: CategorySpend,
135 clock: MonthClock,
136 *,
137 scheduled: Money | None = None,
138 policy: OverspendPolicy = DEFAULT_OVERSPEND_POLICY,
139) -> OverspendAssessment:
140 """Rank a category against its budget for the month (SPEC §7). Pure."""
141 scheduled = scheduled or Money.zero()
142 spent = spent_magnitude(category)
143 projected = project_spend(category, clock, scheduled)
144 
145 if spent > category.budgeted:
146 verdict = OverspendVerdict.ALREADY_OVER
147 elif projected - category.budgeted > policy.trend_threshold:
148 verdict = OverspendVerdict.TRENDING_OVER
149 else:
150 verdict = OverspendVerdict.OK
151 
152 return OverspendAssessment(
153 category=category.category,
154 name=category.name,
155 verdict=verdict,
156 budgeted=category.budgeted,
157 spent=spent,
158 projected=projected,
159 )
160 
161 
162def should_alert(
163 assessment: OverspendAssessment,
164 prior: PriorAlert | None,
165 *,
166 policy: OverspendPolicy = DEFAULT_OVERSPEND_POLICY,
167) -> bool:
168 """Whether to raise an alert now, deduped against the last one (SPEC §7).
169 
170 Never alert when OK; always alert the first flag of a period; otherwise
171 re-alert only on a material worsening — an escalation to already-over, or a
172 projection that climbed by more than the trend threshold.
173 """
174 if assessment.verdict is OverspendVerdict.OK:
175 return False
176 if prior is None:
177 return True
178 escalated = (
179 assessment.verdict is OverspendVerdict.ALREADY_OVER
180 and prior.verdict is not OverspendVerdict.ALREADY_OVER
181 )
182 worsened = assessment.projected - prior.projected > policy.trend_threshold
183 return escalated or worsened

The discriminating case: Jun 1 03:00 UTC is still May 31 in Central — period 2026-05, day 31.

tests/budget/test_overspend.py · 145 lines
tests/budget/test_overspend.py145 lines · Python
⋯ 34 lines hidden (lines 1–34)
1"""Tests for the W6 overspend monitor's pure projection/alerting (SPEC §7)."""
2 
3from __future__ import annotations
4 
5import datetime
6 
7import pytest
8 
9from ynab_agent.budget.overspend import (
10 CategorySpend,
11 MonthClock,
12 OverspendVerdict,
13 PriorAlert,
14 assess,
15 period_and_clock,
16 project_spend,
17 should_alert,
18 spent_magnitude,
20from ynab_agent.domain.ids import CategoryId
21from ynab_agent.domain.money import Money
22 
23 
24def _category(*, budgeted: str, activity: str) -> CategorySpend:
25 # activity is YNAB-native: negative for spending.
26 return CategorySpend(
27 category=CategoryId("dining"),
28 name="Dining Out",
29 budgeted=Money.from_currency(budgeted),
30 activity=Money.from_currency(activity),
31 balance=Money.from_currency(budgeted) + Money.from_currency(activity),
32 )
33 
34 
35def test_period_and_clock_uses_household_timezone_at_a_month_boundary() -> None:
36 # 03:00 UTC on Jun 1 is still 22:00 May 31 in US Central (CDT, UTC-5): the
37 # budget month is May and the run-rate day is the 31st — not UTC's Jun 1.
38 utc = datetime.datetime(2026, 6, 1, 3, 0, tzinfo=datetime.UTC)
39 period, clock = period_and_clock(utc)
40 assert period == "2026-05"
41 assert clock.day_of_month == 31
42 assert clock.days_in_month == 31
43 
⋯ 102 lines hidden (lines 44–145)
44 
45def test_period_and_clock_matches_the_local_day_midmonth() -> None:
46 utc = datetime.datetime(
47 2026, 6, 15, 18, 0, tzinfo=datetime.UTC
48 ) # 13:00 CDT
49 period, clock = period_and_clock(utc)
50 assert period == "2026-06"
51 assert clock.day_of_month == 15
52 assert clock.days_in_month == 30
53 
54 
55def test_spent_magnitude_flips_outflow_sign() -> None:
56 assert spent_magnitude(
57 _category(budgeted="400", activity="-210")
58 ) == Money.from_currency("210")
59 
60 
61def test_spent_magnitude_zero_when_net_inflow() -> None:
62 assert spent_magnitude(_category(budgeted="400", activity="50")).is_zero
63 
64 
65def test_run_rate_projection_doubles_at_mid_month() -> None:
66 # $210 spent over 15 of 30 days → projects to ~$420.
67 cat = _category(budgeted="400", activity="-210")
68 clock = MonthClock(day_of_month=15, days_in_month=30)
69 assert project_spend(cat, clock, Money.zero()) == Money.from_currency("420")
70 
71 
72def test_scheduled_outflows_add_to_projection() -> None:
73 cat = _category(budgeted="400", activity="-200")
74 clock = MonthClock(day_of_month=10, days_in_month=30)
75 # run-rate 200*30//10 = 600, plus a $50 scheduled charge = 650.
76 projected = project_spend(cat, clock, Money.from_currency("50"))
77 assert projected == Money.from_currency("650")
78 
79 
80def test_already_over_when_spent_exceeds_budget() -> None:
81 out = assess(
82 _category(budgeted="400", activity="-420"),
83 MonthClock(day_of_month=24, days_in_month=30),
84 )
85 assert out.verdict is OverspendVerdict.ALREADY_OVER
86 
87 
88def test_trending_over_when_projection_exceeds_threshold() -> None:
89 # Halfway through, $250 of $400 → projects ~$500, over by $100 > $25.
90 out = assess(
91 _category(budgeted="400", activity="-250"),
92 MonthClock(day_of_month=15, days_in_month=30),
93 )
94 assert out.verdict is OverspendVerdict.TRENDING_OVER
95 
96 
97def test_ok_when_on_track() -> None:
98 out = assess(
99 _category(budgeted="400", activity="-180"),
100 MonthClock(day_of_month=15, days_in_month=30),
101 )
102 assert out.verdict is OverspendVerdict.OK
103 
104 
105def test_no_alert_when_ok() -> None:
106 out = assess(
107 _category(budgeted="400", activity="-100"),
108 MonthClock(day_of_month=15, days_in_month=30),
109 )
110 assert should_alert(out, None) is False
111 
112 
113def test_first_flag_of_period_alerts() -> None:
114 out = assess(
115 _category(budgeted="400", activity="-420"),
116 MonthClock(day_of_month=24, days_in_month=30),
117 )
118 assert should_alert(out, None) is True
119 
120 
121def test_repeat_flag_is_deduped_unless_worse() -> None:
122 out = assess(
123 _category(budgeted="400", activity="-250"),
124 MonthClock(day_of_month=15, days_in_month=30),
125 )
126 prior = PriorAlert(verdict=out.verdict, projected=out.projected)
127 # Same projection as last alert → no re-alert.
128 assert should_alert(out, prior) is False
129 
130 
131def test_escalation_to_already_over_re_alerts() -> None:
132 out = assess(
133 _category(budgeted="400", activity="-450"),
134 MonthClock(day_of_month=28, days_in_month=30),
135 )
136 prior = PriorAlert(
137 verdict=OverspendVerdict.TRENDING_OVER,
138 projected=out.projected,
139 )
140 assert should_alert(out, prior) is True
141 
142 
143def test_month_clock_rejects_day_past_month_end() -> None:
144 with pytest.raises(ValueError, match="day_of_month"):
145 MonthClock(day_of_month=31, days_in_month=30)

Why the conversion lives in an activity

The first draft called period_and_clock(workflow.now()) inside the workflow. The W6 test then hung — not failed, hung. The mechanism: an in-sandbox astimezone(ZoneInfo) tripped Temporal's 2-second deadlock detector, which fails the workflow task; the server retries it; the task trips again — an infinite retry loop that looks exactly like a frozen test. The diagnosis came from an accident worth recording: each retry re-imports the workflow file from disk, so when the refactor landed mid-run, the stuck process picked it up and finally errored (current_period ... not registered) instead of spinning — proof of both the loop and the fix.

So the conversion moved to a tiny activity — the standard Temporal pattern for environment-dependent computation. It runs outside the sandbox, and its result is recorded in history, so replay stays deterministic and one period still reaches every activity in the pass (the original no-drift guarantee holds).

The activity: real clock in, household period out, result recorded.

src/ynab_agent/workflow/monitor_activities.py · 177 lines
src/ynab_agent/workflow/monitor_activities.py177 lines · Python
⋯ 39 lines hidden (lines 1–39)
1"""The I/O ports of the W6 overspend monitor, as Temporal activities.
2 
3Its own module so the monitor workflow's sandbox import graph stays minimal
4(see ``poll_activities`` / ``dispatch_activities``). Heavy clients (YNAB,
5AgentMail, the Temporal client) are imported lazily inside the bodies so they
6never enter the workflow sandbox.
7 
8The per-period dedupe store is the durable
9:class:`~ynab_agent.workflow.overspend_ledger_workflow.OverspendLedgerWorkflow`:
10``load_prior_alert`` queries it and ``save_alert`` signals it, exactly as the
11W2 activities talk to the rule registry and the failure-alert ledger. The
12``period`` (``"YYYY-MM"``) is computed once from the workflow's deterministic
13clock and passed *into* every activity in a pass, so they can never disagree on
14it across a month boundary (the thread, the dedupe key, and the W7 offer id all
15derive from it). "Days left" is cosmetic and still read at send time.
16"""
17 
18from __future__ import annotations
19 
20import calendar
21import datetime
22 
23from temporalio import activity
24 
25from ynab_agent.budget.overspend import (
26 CategorySpend,
27 OverspendAssessment,
28 PriorAlert,
30from ynab_agent.domain.config import HOUSEHOLD_TZ
31from ynab_agent.workflow.monitor_types import PeriodClock
32 
33 
34def _days_left_in_month(now: datetime.datetime) -> int:
35 """Calendar days remaining in ``now``'s month (0 on the last day)."""
36 days_in_month = calendar.monthrange(now.year, now.month)[1]
37 return days_in_month - now.day
38 
39 
40@activity.defn
41async def current_period() -> PeriodClock:
42 """The budget period + month position, in household time (SPEC §7, §13).
43 
44 The timezone conversion (``period_and_clock``) runs here, in an activity,
45 so the workflow sandbox never touches ``zoneinfo``; the recorded result
46 keeps the workflow replay-deterministic, and one period reaches every
47 activity in the pass.
48 """
49 from ynab_agent.budget.overspend import period_and_clock
50 
51 period, clock = period_and_clock(datetime.datetime.now(datetime.UTC))
52 return PeriodClock(period=period, clock=clock)
53 
⋯ 124 lines hidden (lines 54–177)
54 
55@activity.defn
56async def fetch_category_spends() -> list[CategorySpend]:
57 """Read each category's month-to-date budget figures from YNAB (§7).
58 
59 The YNAB client is imported lazily and its blocking call runs off the loop.
60 """
61 import asyncio
62 
63 from ynab_agent.ynab.client import YnabClient
64 
65 client = YnabClient.from_env()
66 return list(await asyncio.to_thread(client.category_spends))
67 
68 
69@activity.defn
70async def load_prior_alert(category_id: str, period: str) -> PriorAlert | None:
71 """Load the last alert raised for a category this period, for dedupe.
72 
73 Queries the durable ledger for ``period`` (supplied by the workflow from its
74 deterministic clock, so it matches the period every other activity in the
75 pass uses). Returns ``None`` when the ledger has not been started yet
76 (nothing has ever alerted) — so the first flag of a period always alerts
77 (SPEC §7).
78 
79 The client-side query decodes a pydantic model to a plain ``dict``, so the
80 result is hydrated back into a :class:`PriorAlert` here — ``should_alert``
81 reads attributes off it (the #4 dict-vs-object class of bug). We hydrate by
82 hand rather than via ``result_type`` because the result is optional and the
83 SDK types ``result_type`` as a plain ``type``, not a ``X | None`` union.
84 """
85 from temporalio.service import RPCError
86 
87 from ynab_agent.workflow.overspend_ledger_types import (
88 OVERSPEND_LEDGER_WORKFLOW_ID,
89 PriorRequest,
90 )
91 from ynab_agent.workflow.temporal_client import client
92 
93 temporal = await client()
94 handle = temporal.get_workflow_handle(OVERSPEND_LEDGER_WORKFLOW_ID)
95 try:
96 raw = await handle.query(
97 "prior", PriorRequest(category=category_id, period=period)
98 )
99 except RPCError:
100 return None
101 return None if raw is None else PriorAlert.model_validate(raw)
102 
103 
104@activity.defn
105async def send_overspend_alert(
106 assessment: OverspendAssessment, period: str
107) -> str:
108 """Email the overspend alert and return its thread id (SPEC §7, §8).
109 
110 The body is deterministic (no model): the figures and verdict are already
111 decided. ``period`` is supplied by the workflow (from its deterministic
112 clock), so the thread and dedupe labels match the rest of the pass.
113 ``alert_on_thread`` keeps one thread per overspend: the first alert opens
114 it, a *worsening* re-alert (the one ``should_alert`` admits mid-period)
115 replies an update on that same thread, and a retry of either re-sends
116 nothing (idempotent on the update label). The id it returns is stable for
117 the period: what W7 replies on to offer a balancing move, and where a
118 worsening re-alert lands too, so the conversation never forks (the W6→W7
119 tie).
120 """
121 import asyncio
122 
123 from ynab_agent.budget.message import (
124 overspend_body,
125 overspend_subject,
126 overspend_thread_label,
127 overspend_update_label,
128 )
129 from ynab_agent.mail.client import MailClient
130 from ynab_agent.settings import Settings
131 
132 # "Days left" is the household's, not UTC's (SPEC §13): near midnight the
133 # two differ by a day, and the owner reads this in their own evening.
134 now = datetime.datetime.now(datetime.UTC).astimezone(HOUSEHOLD_TZ)
135 settings = Settings()
136 mail = MailClient.from_env()
137 return await asyncio.to_thread(
138 mail.alert_on_thread,
139 inbox_id=settings.inbox,
140 to=list(settings.owners),
141 subject=overspend_subject(assessment),
142 body=overspend_body(assessment, _days_left_in_month(now)),
143 thread_label=overspend_thread_label(assessment, period),
144 update_label=overspend_update_label(assessment, period),
145 )
146 
147 
148@activity.defn
149async def save_alert(category_id: str, alert: PriorAlert, period: str) -> None:
150 """Record this period's alert so the next pass can dedupe against it.
151 
152 Signal-with-start on the singleton ledger: the first alert creates it, every
153 later one just delivers the signal (SPEC §7) — the same shape as
154 ``feed_rule_learning`` and the failure-alert ledger. ``period`` is supplied
155 by the workflow so the record matches what ``load_prior_alert`` queried.
156 """
157 from temporalio.common import WorkflowIDConflictPolicy
158 
159 from ynab_agent.workflow.overspend_ledger_types import (
160 OVERSPEND_LEDGER_WORKFLOW_ID,
161 LedgerParams,
162 RecordRequest,
163 )
164 from ynab_agent.workflow.temporal_client import client, task_queue
165 
166 temporal = await client()
167 await temporal.start_workflow(
168 "OverspendLedgerWorkflow",
169 LedgerParams(),
170 id=OVERSPEND_LEDGER_WORKFLOW_ID,
171 task_queue=task_queue(),
172 id_conflict_policy=WorkflowIDConflictPolicy.USE_EXISTING,
173 start_signal="record",
174 start_signal_args=[
175 RecordRequest(category=category_id, period=period, alert=alert)
176 ],
177 )

The workflow consumes the recorded result; zoneinfo never enters the sandbox.

src/ynab_agent/workflow/monitor_workflow.py · 104 lines
src/ynab_agent/workflow/monitor_workflow.py104 lines · Python
⋯ 38 lines hidden (lines 1–38)
1"""W6 · the overspend-monitor workflow (SPEC §7).
2 
3A short workflow per monitor pass — fired by a daily Temporal Schedule (the
4trigger is infrastructure). It reads each category's figures, runs the pure
5:func:`~ynab_agent.budget.overspend.assess` projection, and for anything off
6track that survives the dedupe (``should_alert`` against the last alert) emails
7an alert and records it. v1 is notify-only; no budget is moved (that is W7).
8 
9The month position comes from ``params.clock`` when set, else from the
10``current_period`` activity — the household-timezone conversion (SPEC §13) runs
11outside the workflow sandbox, and its recorded result keeps replay
12deterministic. So a pass near midnight or a month boundary buckets into the
13household's day/month, not UTC's.
14"""
15 
16from __future__ import annotations
17 
18from temporalio import workflow
19 
20with workflow.unsafe.imports_passed_through():
21 from ynab_agent.budget.overspend import (
22 OverspendVerdict,
23 PriorAlert,
24 assess,
25 should_alert,
26 )
27 from ynab_agent.workflow import balance_activities, monitor_activities
28 from ynab_agent.workflow.constants import ACTIVITY_RETRY, ACTIVITY_TIMEOUT
29 from ynab_agent.workflow.monitor_types import MonitorParams, MonitorResult
30 
31 
32@workflow.defn
33class OverspendMonitorWorkflow:
34 """One daily overspend pass across all categories."""
35 
36 @workflow.run
37 async def run(self, params: MonitorParams) -> MonitorResult:
38 """Assess every category and alert on what is off track (SPEC §7)."""
39 # Derive the period + month position ONCE, in household time, via an
40 # activity (the tz conversion stays out of the sandbox; the recorded
41 # result is replay-deterministic), then pass that period into every
42 # activity in the pass — so the thread, the dedupe key, and the W7
43 # offer id can't drift across a month boundary (SPEC §0.5, §13).
44 period_clock = await workflow.execute_activity(
45 monitor_activities.current_period,
46 start_to_close_timeout=ACTIVITY_TIMEOUT,
47 retry_policy=ACTIVITY_RETRY,
48 )
49 period = period_clock.period
50 clock = params.clock or period_clock.clock
51 spends = await workflow.execute_activity(
⋯ 53 lines hidden (lines 52–104)
52 monitor_activities.fetch_category_spends,
53 start_to_close_timeout=ACTIVITY_TIMEOUT,
54 retry_policy=ACTIVITY_RETRY,
55 )
56 
57 alerted: list[str] = []
58 for spend in spends:
59 assessment = assess(spend, clock)
60 if assessment.verdict is OverspendVerdict.OK:
61 continue
62 prior = await workflow.execute_activity(
63 monitor_activities.load_prior_alert,
64 args=[str(spend.category), period],
65 start_to_close_timeout=ACTIVITY_TIMEOUT,
66 retry_policy=ACTIVITY_RETRY,
67 )
68 if not should_alert(assessment, prior):
69 continue
70 thread_id = await workflow.execute_activity(
71 monitor_activities.send_overspend_alert,
72 args=[assessment, period],
73 start_to_close_timeout=ACTIVITY_TIMEOUT,
74 retry_policy=ACTIVITY_RETRY,
75 )
76 await workflow.execute_activity(
77 monitor_activities.save_alert,
78 args=[
79 str(spend.category),
80 PriorAlert(
81 verdict=assessment.verdict,
82 projected=assessment.projected,
83 ),
84 period,
85 ],
86 start_to_close_timeout=ACTIVITY_TIMEOUT,
87 retry_policy=ACTIVITY_RETRY,
88 )
89 # Offer a balancing move on the same alert thread (W6→W7, §8). A
90 # fire-and-forget start: REJECT_DUPLICATE keeps it one offer per
91 # category per period, and the monitor never waits on coverage.
92 await workflow.execute_activity(
93 balance_activities.start_balance_offer,
94 args=[assessment, thread_id, period],
95 start_to_close_timeout=ACTIVITY_TIMEOUT,
96 retry_policy=ACTIVITY_RETRY,
97 )
98 alerted.append(spend.name)
99 
100 return MonitorResult(
101 categories=len(spends),
102 alerts=len(alerted),
103 alerted=tuple(alerted),
104 )

Display fixes riding along

Two cosmetic-but-visible UTC leaks convert too: the overspend alert's "days left" (an owner reads that in their own evening; near midnight UTC is a day ahead) and the dashboard masthead's "as of" stamp, which now renders CDT/CST.

Days-left in household time — an activity, so the direct conversion is safe here.

src/ynab_agent/workflow/monitor_activities.py · 177 lines
src/ynab_agent/workflow/monitor_activities.py177 lines · Python
⋯ 130 lines hidden (lines 1–130)
1"""The I/O ports of the W6 overspend monitor, as Temporal activities.
2 
3Its own module so the monitor workflow's sandbox import graph stays minimal
4(see ``poll_activities`` / ``dispatch_activities``). Heavy clients (YNAB,
5AgentMail, the Temporal client) are imported lazily inside the bodies so they
6never enter the workflow sandbox.
7 
8The per-period dedupe store is the durable
9:class:`~ynab_agent.workflow.overspend_ledger_workflow.OverspendLedgerWorkflow`:
10``load_prior_alert`` queries it and ``save_alert`` signals it, exactly as the
11W2 activities talk to the rule registry and the failure-alert ledger. The
12``period`` (``"YYYY-MM"``) is computed once from the workflow's deterministic
13clock and passed *into* every activity in a pass, so they can never disagree on
14it across a month boundary (the thread, the dedupe key, and the W7 offer id all
15derive from it). "Days left" is cosmetic and still read at send time.
16"""
17 
18from __future__ import annotations
19 
20import calendar
21import datetime
22 
23from temporalio import activity
24 
25from ynab_agent.budget.overspend import (
26 CategorySpend,
27 OverspendAssessment,
28 PriorAlert,
30from ynab_agent.domain.config import HOUSEHOLD_TZ
31from ynab_agent.workflow.monitor_types import PeriodClock
32 
33 
34def _days_left_in_month(now: datetime.datetime) -> int:
35 """Calendar days remaining in ``now``'s month (0 on the last day)."""
36 days_in_month = calendar.monthrange(now.year, now.month)[1]
37 return days_in_month - now.day
38 
39 
40@activity.defn
41async def current_period() -> PeriodClock:
42 """The budget period + month position, in household time (SPEC §7, §13).
43 
44 The timezone conversion (``period_and_clock``) runs here, in an activity,
45 so the workflow sandbox never touches ``zoneinfo``; the recorded result
46 keeps the workflow replay-deterministic, and one period reaches every
47 activity in the pass.
48 """
49 from ynab_agent.budget.overspend import period_and_clock
50 
51 period, clock = period_and_clock(datetime.datetime.now(datetime.UTC))
52 return PeriodClock(period=period, clock=clock)
53 
54 
55@activity.defn
56async def fetch_category_spends() -> list[CategorySpend]:
57 """Read each category's month-to-date budget figures from YNAB (§7).
58 
59 The YNAB client is imported lazily and its blocking call runs off the loop.
60 """
61 import asyncio
62 
63 from ynab_agent.ynab.client import YnabClient
64 
65 client = YnabClient.from_env()
66 return list(await asyncio.to_thread(client.category_spends))
67 
68 
69@activity.defn
70async def load_prior_alert(category_id: str, period: str) -> PriorAlert | None:
71 """Load the last alert raised for a category this period, for dedupe.
72 
73 Queries the durable ledger for ``period`` (supplied by the workflow from its
74 deterministic clock, so it matches the period every other activity in the
75 pass uses). Returns ``None`` when the ledger has not been started yet
76 (nothing has ever alerted) — so the first flag of a period always alerts
77 (SPEC §7).
78 
79 The client-side query decodes a pydantic model to a plain ``dict``, so the
80 result is hydrated back into a :class:`PriorAlert` here — ``should_alert``
81 reads attributes off it (the #4 dict-vs-object class of bug). We hydrate by
82 hand rather than via ``result_type`` because the result is optional and the
83 SDK types ``result_type`` as a plain ``type``, not a ``X | None`` union.
84 """
85 from temporalio.service import RPCError
86 
87 from ynab_agent.workflow.overspend_ledger_types import (
88 OVERSPEND_LEDGER_WORKFLOW_ID,
89 PriorRequest,
90 )
91 from ynab_agent.workflow.temporal_client import client
92 
93 temporal = await client()
94 handle = temporal.get_workflow_handle(OVERSPEND_LEDGER_WORKFLOW_ID)
95 try:
96 raw = await handle.query(
97 "prior", PriorRequest(category=category_id, period=period)
98 )
99 except RPCError:
100 return None
101 return None if raw is None else PriorAlert.model_validate(raw)
102 
103 
104@activity.defn
105async def send_overspend_alert(
106 assessment: OverspendAssessment, period: str
107) -> str:
108 """Email the overspend alert and return its thread id (SPEC §7, §8).
109 
110 The body is deterministic (no model): the figures and verdict are already
111 decided. ``period`` is supplied by the workflow (from its deterministic
112 clock), so the thread and dedupe labels match the rest of the pass.
113 ``alert_on_thread`` keeps one thread per overspend: the first alert opens
114 it, a *worsening* re-alert (the one ``should_alert`` admits mid-period)
115 replies an update on that same thread, and a retry of either re-sends
116 nothing (idempotent on the update label). The id it returns is stable for
117 the period: what W7 replies on to offer a balancing move, and where a
118 worsening re-alert lands too, so the conversation never forks (the W6→W7
119 tie).
120 """
121 import asyncio
122 
123 from ynab_agent.budget.message import (
124 overspend_body,
125 overspend_subject,
126 overspend_thread_label,
127 overspend_update_label,
128 )
129 from ynab_agent.mail.client import MailClient
130 from ynab_agent.settings import Settings
131 
132 # "Days left" is the household's, not UTC's (SPEC §13): near midnight the
133 # two differ by a day, and the owner reads this in their own evening.
134 now = datetime.datetime.now(datetime.UTC).astimezone(HOUSEHOLD_TZ)
135 settings = Settings()
136 mail = MailClient.from_env()
137 return await asyncio.to_thread(
138 mail.alert_on_thread,
139 inbox_id=settings.inbox,
140 to=list(settings.owners),
141 subject=overspend_subject(assessment),
⋯ 36 lines hidden (lines 142–177)
142 body=overspend_body(assessment, _days_left_in_month(now)),
143 thread_label=overspend_thread_label(assessment, period),
144 update_label=overspend_update_label(assessment, period),
145 )
146 
147 
148@activity.defn
149async def save_alert(category_id: str, alert: PriorAlert, period: str) -> None:
150 """Record this period's alert so the next pass can dedupe against it.
151 
152 Signal-with-start on the singleton ledger: the first alert creates it, every
153 later one just delivers the signal (SPEC §7) — the same shape as
154 ``feed_rule_learning`` and the failure-alert ledger. ``period`` is supplied
155 by the workflow so the record matches what ``load_prior_alert`` queried.
156 """
157 from temporalio.common import WorkflowIDConflictPolicy
158 
159 from ynab_agent.workflow.overspend_ledger_types import (
160 OVERSPEND_LEDGER_WORKFLOW_ID,
161 LedgerParams,
162 RecordRequest,
163 )
164 from ynab_agent.workflow.temporal_client import client, task_queue
165 
166 temporal = await client()
167 await temporal.start_workflow(
168 "OverspendLedgerWorkflow",
169 LedgerParams(),
170 id=OVERSPEND_LEDGER_WORKFLOW_ID,
171 task_queue=task_queue(),
172 id_conflict_policy=WorkflowIDConflictPolicy.USE_EXISTING,
173 start_signal="record",
174 start_signal_args=[
175 RecordRequest(category=category_id, period=period, alert=alert)
176 ],
177 )

The operator reads the stamp in their own day.

src/ynab_agent/dashboard/render.py · 655 lines
src/ynab_agent/dashboard/render.py655 lines · Python
⋯ 215 lines hidden (lines 1–215)
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.
7 
8The page is organized around the operator's questions, not the subsystems the
9data came from: a masthead verdict, a plain-English **state of things**, then
10**needs you** (the only acted-upon queue), **is it working** (health + flow),
11**what it's done** (the conversation feed), the **budget**, and finally the
12operator detail folded into progressive-disclosure rails. Structure comes from
13hairlines, whitespace, type, and a single accent — not boxes; semantic colour
14lives in dots and pills, not fills.
15"""
16 
17from __future__ import annotations
18 
19from datetime import UTC, datetime
20from html import escape
21from typing import TYPE_CHECKING
22 
23from ynab_agent.domain.config import HOUSEHOLD_TZ
24 
25if TYPE_CHECKING:
26 from ynab_agent.dashboard.model import (
27 DashboardModel,
28 Lifecycle,
29 QueueItem,
30 RunTelemetry,
31 )
32 
33_CSS = """
34:root{--fg:#1a1a1a;--mut:#6b6b6b;--faint:#8a8a8a;--line:#e6e6e6;--bg:#fff;
35--ok:#1a7f37;--warn:#9a6700;--bad:#cf222e;--accent:#0969da;--card:#f6f8fa;
36--serif:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,ui-serif,serif}
37@media(prefers-color-scheme:dark){:root{--fg:#e6e6e6;--mut:#9a9a9a;
38--faint:#7a7a7a;--line:#262626;--bg:#0d0d0d;--ok:#3fb950;--warn:#d29922;
39--bad:#f85149;--accent:#58a6ff;--card:#161b22}}
40*{box-sizing:border-box}
41body{margin:0;background:var(--bg);color:var(--fg);
42font:15px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
43main{max-width:820px;margin:0 auto;padding:30px 20px 72px}
44a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
45.mut{color:var(--mut)}.faint{color:var(--faint)}
46.ok{color:var(--ok)}.warn{color:var(--warn)}.bad{color:var(--bad)}
47.accent{color:var(--accent)}
48.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
49.dot{display:inline-block;width:9px;height:9px;border-radius:50%;flex:none;
50vertical-align:baseline}
51.dot.ok{background:var(--ok)}.dot.warn{background:var(--warn)}
52.dot.bad{background:var(--bad)}.dot.mute{background:var(--faint)}
53.dot.accent{background:var(--accent)}
54 
55/* Masthead — wordmark + the one composite verdict + the few facts. */
56.mast{display:flex;align-items:flex-start;gap:14px;flex-wrap:wrap}
57.mast h1{font-size:19px;margin:0;letter-spacing:-.01em;font-weight:650}
58.mast .tag{color:var(--mut);font-size:12px;margin:2px 0 0}
59.verdict{display:inline-flex;align-items:center;gap:7px;font-weight:650;
60font-size:15px}
61.verdict .dot{width:11px;height:11px}
62.verdict.ok{color:var(--ok)}.verdict.warn{color:var(--warn)}
63.verdict.bad{color:var(--bad)}
64.facts{margin-left:auto;text-align:right;color:var(--mut);font-size:12px;
65line-height:1.7}
66.chips{display:flex;flex-wrap:wrap;gap:7px;margin:12px 0 0}
67.chip{display:inline-flex;align-items:center;gap:6px;padding:3px 10px;
68border-radius:999px;border:1px solid var(--line);font-size:12px;font-weight:550}
69.chip.ok{border-color:transparent;color:var(--ok);
70background:color-mix(in srgb,var(--ok) 14%,transparent)}
71.chip.warn{border-color:transparent;color:var(--warn);
72background:color-mix(in srgb,var(--warn) 16%,transparent)}
73.chip.bad{border-color:transparent;color:var(--bad);
74background:color-mix(in srgb,var(--bad) 14%,transparent)}
75.srcs{display:flex;flex-wrap:wrap;gap:14px;margin:14px 0 0;font-size:11px;
76color:var(--mut)}
77.srcs .dot{margin-right:5px}
78 
79/* Section = an 11px uppercase label with a hairline underline. */
80section{margin:30px 0 0}
81h2{font-size:11px;text-transform:uppercase;letter-spacing:.09em;
82color:var(--mut);margin:0 0 13px;font-weight:650;padding-bottom:6px;
83border-bottom:1px solid var(--line);display:flex;align-items:baseline;gap:8px}
84h2 .h2-aside{margin-left:auto;font-size:11px;font-weight:500;letter-spacing:0;
85text-transform:none;color:var(--faint)}
86 
87/* State of things — the one serif voice, a lead with an accent rule. */
88.narr{border-left:3px solid var(--line);padding:2px 0 2px 15px;margin:26px 0 0}
89.narr.ok{border-color:var(--ok)}.narr.warn{border-color:var(--warn)}
90.narr.bad{border-color:var(--bad)}
91.narr .head{font-weight:650;font-size:15px;margin:0 0 4px}
92.narr .body{font-family:var(--serif);font-size:16.5px;line-height:1.5;
93margin:0;color:var(--fg);max-width:62ch}
94 
95/* Needs you — the humanized queue. */
96.qrow{display:flex;align-items:baseline;gap:11px;padding:9px 0;
97border-bottom:1px solid var(--line)}
98.qrow:last-child{border-bottom:0}
99.qrow .dot{position:relative;top:1px}
100.q-main{flex:1;min-width:0}
101.q-title{display:block;font-weight:550;overflow-wrap:anywhere}
102.q-sub{display:block;color:var(--mut);font-size:12.5px;margin-top:1px}
103.q-age{color:var(--faint);font-size:12px;white-space:nowrap;
104font-variant-numeric:tabular-nums}
105.tag-pill{display:inline-block;padding:0 7px;border-radius:10px;
106font-size:10.5px;font-weight:650;text-transform:uppercase;letter-spacing:.04em;
107background:var(--card);color:var(--mut);vertical-align:1px}
108.tag-pill.offer{color:var(--accent);
109background:color-mix(in srgb,var(--accent) 14%,transparent)}
110.empty{display:flex;align-items:center;gap:10px;color:var(--mut);
111font-size:14px;padding:6px 0}
112.empty .big{font-size:20px;line-height:1}
113.note{color:var(--mut);font-size:12.5px;margin:11px 0 0}
114 
115/* Is it working — health pills + a single flow bar. */
116.kpis{display:flex;flex-wrap:wrap;gap:18px;margin:0 0 16px}
117.kpi{display:inline-flex;align-items:baseline;gap:7px;font-size:13px}
118.kpi b{font-weight:600}
119.flow{display:flex;height:22px;border-radius:5px;overflow:hidden;
120margin:4px 0 10px;background:var(--card)}
121.flow>span{display:block;min-width:2px}
122.legend{display:flex;flex-wrap:wrap;gap:7px 16px;font-size:12px;
123color:var(--mut)}
124.legend .it{display:inline-flex;align-items:baseline;gap:6px}
125.legend b{color:var(--fg);font-weight:600;font-variant-numeric:tabular-nums}
126 
127/* What it's done — the conversation feed. */
128.feed{display:flex;flex-direction:column}
129.frow{display:flex;align-items:baseline;gap:10px;padding:8px 0;
130border-bottom:1px solid var(--line)}
131.frow:last-child{border-bottom:0}
132.frow .g{width:1.2em;text-align:center;flex:none;color:var(--faint)}
133.f-main{flex:1;min-width:0}
134.f-title{display:block;overflow-wrap:anywhere}
135.f-prev{display:block;color:var(--faint);font-size:12px;margin-top:1px;
136white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
137.f-age{color:var(--faint);font-size:12px;white-space:nowrap}
138 
139/* Budget — one tight line + overspent inline. */
140.over{display:flex;flex-wrap:wrap;gap:6px 14px;margin:8px 0 0;font-size:13px}
141.over .it{display:inline-flex;gap:6px}
142 
143/* Rails — progressive disclosure, hairline-topped. */
144details.rail{border-top:1px solid var(--line);padding:9px 0 2px}
145details.rail>summary{cursor:pointer;list-style:none;display:flex;
146align-items:center;gap:8px;font-size:12px;color:var(--mut)}
147details.rail>summary::-webkit-details-marker{display:none}
148details.rail>summary::before{content:"\\25B8";color:var(--faint);font-size:10px}
149details.rail[open]>summary::before{content:"\\25BE"}
150details.rail>summary .rail-n{margin-left:auto;color:var(--faint)}
151.rail-body{padding:12px 0 6px}
152table{width:100%;border-collapse:collapse;font-size:12.5px}
153th,td{text-align:left;padding:5px 12px 5px 0;vertical-align:top;
154border-bottom:1px solid var(--line)}
155th{color:var(--mut);font-weight:600;font-size:10.5px;text-transform:uppercase;
156letter-spacing:.04em}
157.num{text-align:right;font-variant-numeric:tabular-nums}
158footer{margin:40px 0 0;padding-top:15px;border-top:1px solid var(--line);
159color:var(--mut);font-size:12px;line-height:1.7}
160footer b{color:var(--fg);font-weight:600}
161"""
162 
163_LIFECYCLE_ORDER = (
164 "discovered",
165 "enriching",
166 "hold_amazon",
167 "revising",
168 "awaiting_human",
169 "open",
170 "lapsed",
172# Each in-flight state's human label + flow colour (a cool ramp: accent =
173# the agent is working, warn = it's on you, ok = settled-and-resting).
174_STATE_META = {
175 "discovered": ("discovered", "var(--faint)"),
176 "enriching": ("enriching", "var(--accent)"),
177 "hold_amazon": ("holding for Amazon detail", "var(--faint)"),
178 "revising": ("revising", "var(--accent)"),
179 "awaiting_human": ("awaiting you", "var(--warn)"),
180 "open": ("resting (done)", "var(--ok)"),
181 "lapsed": ("lapsed", "var(--faint)"),
183_TRUST_CLASS = {"trusted": "ok", "confirmed": "warn", "suggested": "mute"}
184_CI_CLASS = {"passed": "ok", "failed": "bad", "running": "warn"}
185_STATE_CLASS = {"merged": "ok", "open": "warn", "closed": "mut"}
186_KIND_GLYPH = {"proposal": "", "offer": "", "thread": "·"}
187 
188 
189def _aware(when: datetime) -> datetime:
190 return when if when.tzinfo is not None else when.replace(tzinfo=UTC)
191 
192 
193def _ago(when: datetime | None, now: datetime) -> str:
194 if when is None:
195 return ""
196 secs = (now - _aware(when)).total_seconds()
197 if secs < 90:
198 return "just now"
199 if secs < 5400:
200 return f"{int(secs // 60)}m ago"
201 if secs < 129600:
202 return f"{int(secs // 3600)}h ago"
203 return f"{int(secs // 86400)}d ago"
204 
205 
206def _dot(kind: str) -> str:
207 return f'<span class="dot {kind}"></span>'
208 
209 
210def _meta(state: str) -> tuple[str, str]:
211 return _STATE_META.get(state, (state, "var(--faint)"))
212 
213 
214# ── Masthead ─────────────────────────────────────────────────────────────────
215def _masthead(model: DashboardModel) -> str:
216 h = model.health
217 now = model.generated_at
218 # Show the "as of" time in the household timezone (SPEC §13), so the
219 # operator reads it in their own day, with the zone (CDT/CST) made explicit.
220 stamp = now.astimezone(HOUSEHOLD_TZ).strftime("%Y-%m-%d %H:%M %Z")
221 
⋯ 434 lines hidden (lines 222–655)
222 chips: list[str] = []
223 if h.needs_you:
224 suffix = "s" if h.needs_you == 1 else ""
225 chips.append(
226 f'<span class="chip warn">{h.needs_you} need{suffix} you</span>'
227 )
228 else:
229 chips.append('<span class="chip ok">✓ all caught up</span>')
230 poll_cls = "ok" if h.poll_live else "bad"
231 poll_age = escape(_ago(h.poll_last_start, now))
232 chips.append(f'<span class="chip">{_dot(poll_cls)} poll {poll_age}</span>')
233 if model.budget.available:
234 chips.append(
235 f'<span class="chip">{model.budget.unapproved} unapproved</span>'
236 )
237 if h.real_failures:
238 suffix = "s" if h.real_failures != 1 else ""
239 chips.append(
240 f'<span class="chip bad">{h.real_failures} failure{suffix}</span>'
241 )
242 
243 src = "".join(
244 f"<span>{_dot('ok' if s.ok else 'bad')}{escape(s.name)} "
245 f'<span class="faint">{escape(s.detail)}</span></span>'
246 for s in model.sources
247 )
248 return (
249 "<header>"
250 '<div class="mast">'
251 "<div><h1>ynab-agent &middot; ops</h1>"
252 '<p class="tag">durable transaction triage &middot; '
253 "earned-autonomy read-model</p></div>"
254 f'<div class="facts"><span class="verdict {escape(h.tone)}">'
255 f"{_dot(h.tone)}{escape(h.label)}</span><br>{escape(model.repo)}<br>"
256 f"{escape(stamp)}</div></div>"
257 f'<div class="chips">{"".join(chips)}</div>'
258 f'<div class="srcs">{src}</div>'
259 "</header>"
260 )
261 
262 
263# ── State of things ──────────────────────────────────────────────────────────
264def _narrative(model: DashboardModel) -> str:
265 n = model.narrative
266 body = " ".join(n.paragraphs)
267 return (
268 f'<section class="narr {escape(n.tone)}">'
269 f'<p class="head">{escape(n.headline)}</p>'
270 f'<p class="body">{escape(body)}</p></section>'
271 )
272 
273 
274# ── Needs you ────────────────────────────────────────────────────────────────
275def _needs_you(model: DashboardModel) -> str:
276 now = model.generated_at
277 queue = model.needs_you
278 if not queue:
279 body = (
280 '<div class="empty"><span class="big">✓</span>'
281 "<span>You're all caught up — nothing is waiting on you."
282 "</span></div>"
283 )
284 else:
285 body = "".join(_qrow(q, now) for q in queue[:30])
286 if model.handled:
287 n = len(model.handled)
288 plural = "s" if n != 1 else ""
289 verb = "are" if n != 1 else "is"
290 body += (
291 f'<p class="note">{n} more proposal{plural} you already settled '
292 f"in YNAB {verb} winding down — they lapse on their own, no reply "
293 "needed.</p>"
294 )
295 aside = (
296 f'<span class="h2-aside">oldest '
297 f"{escape(_ago(_oldest(queue), now))}</span>"
298 if queue
299 else ""
300 )
301 return f"<section><h2>Needs you{aside}</h2>{body}</section>"
302 
303 
304def _oldest(queue: tuple[QueueItem, ...]) -> datetime | None:
305 # Normalize to aware before min() — mixed aware/naive comparison raises.
306 sinces = [_aware(q.since) for q in queue if q.since is not None]
307 return min(sinces) if sinces else None
308 
309 
310def _qrow(q: QueueItem, now: datetime) -> str:
311 dot = "accent" if q.kind == "offer" else "warn"
312 title = escape(q.question or q.label)
313 pill = (
314 '<span class="tag-pill offer">offer</span> '
315 if q.kind == "offer"
316 else ""
317 )
318 sub_bits: list[str] = []
319 if q.amount:
320 sub_bits.append(escape(q.amount))
321 if q.category:
322 sub_bits.append(escape(q.category))
323 if not sub_bits and q.payee:
324 sub_bits.append(escape(q.payee))
325 sub = (
326 f'<span class="q-sub">{" &middot; ".join(sub_bits)}</span>'
327 if sub_bits
328 else ""
329 )
330 age = escape(_ago(q.since, now))
331 return (
332 f'<div class="qrow">{_dot(dot)}'
333 f'<span class="q-main"><span class="q-title">{pill}{title}</span>'
334 f'{sub}</span><span class="q-age">{age}</span></div>'
335 )
336 
337 
338# ── Is it working ────────────────────────────────────────────────────────────
339def _working(model: DashboardModel) -> str:
340 h = model.health
341 now = model.generated_at
342 poll_word = escape(h.poll_status) if h.poll_live else "stopped"
343 poll_age = escape(_ago(h.poll_last_start, now))
344 kpis = [
345 f'<span class="kpi">{_dot("ok" if h.poll_live else "bad")}'
346 f'<b>poll</b> <span class="mut">{poll_word} '
347 f"&middot; {poll_age}</span></span>"
348 ]
349 if h.worker_last_span is not None:
350 wago = (now - _aware(h.worker_last_span)).total_seconds()
351 wdot = "ok" if wago < 3600 else "warn"
352 span_age = escape(_ago(h.worker_last_span, now))
353 kpis.append(
354 f'<span class="kpi">{_dot(wdot)}<b>worker</b> '
355 f'<span class="mut">span {span_age}</span></span>'
356 )
357 else:
358 kpis.append(
359 f'<span class="kpi">{_dot("mute")}<b>worker</b> '
360 '<span class="mut">no telemetry</span></span>'
361 )
362 if h.span_error_rate is not None:
363 rate = h.span_error_rate
364 rdot = "ok" if rate <= 0.05 else "warn"
365 kpis.append(
366 f'<span class="kpi">{_dot(rdot)}<b>{rate * 100:.1f}%</b> '
367 '<span class="mut">span errors</span></span>'
368 )
369 return (
370 "<section><h2>Is it working?</h2>"
371 f'<div class="kpis">{"".join(kpis)}</div>'
372 f"{_flow(model.lifecycle)}</section>"
373 )
374 
375 
376def _flow(lc: Lifecycle) -> str:
377 counts = {s.state: s.count for s in lc.states}
378 ordered = [s for s in _LIFECYCLE_ORDER if counts.get(s)]
379 ordered += [
380 s for s in sorted(counts) if counts.get(s) and s not in _LIFECYCLE_ORDER
381 ]
382 if not ordered:
383 return '<p class="empty"><span>No transactions in flight.</span></p>'
384 segs = "".join(
385 f'<span style="flex:{counts[s]};background:{_meta(s)[1]}" '
386 f'title="{escape(_meta(s)[0])}: {counts[s]}"></span>'
387 for s in ordered
388 )
389 legend = "".join(
390 '<span class="it"><span class="dot" '
391 f'style="background:{_meta(s)[1]}"></span>'
392 f"<b>{counts[s]}</b> {escape(_meta(s)[0])}</span>"
393 for s in ordered
394 )
395 note = (
396 " — the post-write window runs for weeks, so resting transactions "
397 "haven't closed out yet"
398 if lc.archived == 0 and counts.get("open")
399 else ""
400 )
401 gloss = (
402 ' &middot; <span class="faint">'
403 f"{lc.archived} archived{note} &middot; "
404 f"{lc.terminated} terminated (recent)</span>"
405 )
406 return (
407 f'<div class="flow">{segs}</div>'
408 f'<div class="legend">{legend}{gloss}</div>'
409 )
410 
411 
412# ── What it's done ───────────────────────────────────────────────────────────
413def _activity(model: DashboardModel) -> str:
414 now = model.generated_at
415 if not model.conversations:
416 return ""
417 rows = ""
418 for c in model.conversations[:10]:
419 glyph = _KIND_GLYPH.get(c.kind, "·")
420 prev = (
421 f'<span class="f-prev">{escape(c.preview)}</span>'
422 if c.preview
423 else ""
424 )
425 age = escape(_ago(c.updated_at, now))
426 rows += (
427 f'<div class="frow"><span class="g">{glyph}</span>'
428 f'<span class="f-main"><span class="f-title">'
429 f"{escape(c.subject)}</span>{prev}</span>"
430 f'<span class="f-age">{age}</span></div>'
431 )
432 return (
433 "<section><h2>What it's done"
434 '<span class="h2-aside">recent threads</span></h2>'
435 f'<div class="feed">{rows}</div></section>'
436 )
437 
438 
439# ── Budget ───────────────────────────────────────────────────────────────────
440def _budget(model: DashboardModel) -> str:
441 b = model.budget
442 if not b.available:
443 return ""
444 if b.overspent:
445 items = "".join(
446 f'<span class="it"><span class="bad mono">{escape(c.balance)}'
447 f"</span> {escape(c.name)}</span>"
448 for c in b.overspent
449 )
450 over = f'<div class="over">{items}</div>'
451 else:
452 over = '<p class="note">No overspent categories.</p>'
453 noun = "y" if len(b.overspent) == 1 else "ies"
454 head = (
455 f"{b.unapproved} unapproved · "
456 f"{len(b.overspent)} overspent categor{noun}"
457 )
458 return (
459 "<section><h2>Budget &middot; YNAB"
460 f'<span class="h2-aside">{escape(head)}</span></h2>{over}</section>'
461 )
462 
463 
464# ── Rails (progressive disclosure) ───────────────────────────────────────────
465def _rail(title: str, count: str, inner: str) -> str:
466 return (
467 f'<details class="rail"><summary>{escape(title)}'
468 f'<span class="rail-n">{escape(count)}</span></summary>'
469 f'<div class="rail-body">{inner}</div></details>'
470 )
471 
472 
473def _autonomy_rail(model: DashboardModel) -> str:
474 a = model.autonomy
475 note = (
476 '<p class="note">Earned then granted: a rule becomes <b>eligible</b> '
477 "after K consistent confirms, and <b>blessed</b> (auto-applies) only "
478 "once you accept the offer. A correction demotes it to observe.</p>"
479 )
480 if a.rules:
481 rows = ""
482 for r in a.rules[:30]:
483 trust_cls = _TRUST_CLASS.get(r.trust, "mut")
484 rows += (
485 f"<tr><td>{escape(r.payee)}</td>"
486 f'<td class="mut">{escape(r.category)}</td>'
487 f'<td class="{trust_cls}">{escape(r.trust)}</td>'
488 f'<td class="num">{r.hits}</td></tr>'
489 )
490 table = (
491 "<table><thead><tr><th>payee</th><th>category</th><th>trust</th>"
492 "<th class='num'>hits</th></tr></thead>"
493 f"<tbody>{rows}</tbody></table>"
494 )
495 else:
496 table = '<p class="note">No learned rules yet.</p>'
497 count = f"{a.observe} observe · {a.eligible} eligible · {a.blessed} blessed"
498 return _rail("Autonomy ladder", count, table + note)
499 
500 
501def _telemetry_rail(model: DashboardModel) -> str:
502 t: RunTelemetry = model.telemetry
503 now = model.generated_at
504 if not t.available:
505 return _rail(
506 "Run telemetry",
507 "off",
508 '<p class="note">ClickHouse not configured or unreachable; '
509 "Temporal carries the page regardless.</p>",
510 )
511 rows = "".join(
512 f'<tr><td class="mono">{escape(a.name)}</td>'
513 f'<td class="num">{a.count}</td>'
514 f'<td class="num mut">{a.avg_ms:.0f}</td>'
515 f'<td class="num mut">{a.max_ms:.0f}</td></tr>'
516 for a in t.activities
517 )
518 table = (
519 "<table><thead><tr><th>activity</th><th class='num'>runs</th>"
520 "<th class='num'>avg ms</th><th class='num'>max ms</th></tr></thead>"
521 f"<tbody>{rows}</tbody></table>"
522 if t.activities
523 else '<p class="note">No spans in the window.</p>'
524 )
525 last = escape(_ago(t.last_activity, now))
526 summary = (
527 f'<p class="note">{t.total_spans} spans &middot; {t.error_spans} '
528 f"errored &middot; last {last} &middot; {t.window_days}-day window.</p>"
529 )
530 errs = "".join(
531 f'<p class="note bad mono">{escape(e)}</p>' for e in t.recent_errors
532 )
533 return _rail(
534 "Run telemetry", f"{t.total_spans} spans", summary + table + errs
535 )
536 
537 
538def _failures_rail(model: DashboardModel) -> str:
539 if not model.failures:
540 return ""
541 now = model.generated_at
542 real = [f for f in model.failures if not f.intentional]
543 intentional = [f for f in model.failures if f.intentional]
544 
545 def _rows(items: list) -> str: # type: ignore[type-arg]
546 out = ""
547 for f in items:
548 cls = "bad" if not f.intentional else "mut"
549 out += (
550 f'<tr><td class="mono">{escape(f.workflow_id[:18])}</td>'
551 f'<td class="{cls}">{escape(f.kind)}</td>'
552 f'<td class="mut">{escape(f.reason or "")}</td>'
553 f'<td class="mut">{escape(_ago(f.when, now))}</td></tr>'
554 )
555 return out
556 
557 head = "<tr><th>workflow</th><th>kind</th><th>reason</th><th>when</th></tr>"
558 real_html = (
559 f"<table><thead>{head}</thead><tbody>{_rows(real)}</tbody></table>"
560 if real
561 else '<p class="note">No real failures — nothing broke.</p>'
562 )
563 intentional_html = ""
564 if intentional:
565 plural = "s" if len(intentional) != 1 else ""
566 intentional_html = (
567 f'<p class="note">{len(intentional)} intentional go-live / '
568 f"re-test reset{plural} (housekeeping):</p>"
569 f"<table><tbody>{_rows(intentional)}</tbody></table>"
570 )
571 extra = f" · {len(intentional)} resets" if intentional else ""
572 count = f"{len(real)} real{extra}"
573 return _rail("Failures & resets", count, real_html + intentional_html)
574 
575 
576def _dispatch_rail(model: DashboardModel) -> str:
577 d = model.dispatch
578 plural = "es" if d.total != 1 else ""
579 inner = (
580 f'<p class="note">{d.total} inbound dispatch{plural} currently '
581 "retained in Temporal "
582 f"(→ {d.transaction} transaction · {d.offer} offer · {d.receipt} "
583 f"receipt · {d.command} command · {d.quarantine} quarantined · "
584 f"{d.ignore} ignored). Replies are processed continuously; this panel "
585 "only counts what Temporal still holds, so it reads low after "
586 "retention ages dispatches out — the telemetry above is the fuller "
587 "record.</p>"
588 )
589 return _rail("Inbound · W3 dispatch", f"{d.total} retained", inner)
590 
591 
592def _deploy_rail(model: DashboardModel) -> str:
593 if not model.deploy.prs:
594 return ""
595 now = model.generated_at
596 rows = ""
597 for p in model.deploy.prs:
598 state_cls = _STATE_CLASS.get(p.state, "mut")
599 rows += (
600 f'<tr><td><a href="{escape(p.url, quote=True)}">#{p.number}</a>'
601 f"</td><td>{escape(p.title)}</td>"
602 f'<td class="{state_cls}">{escape(p.state)}</td>'
603 f"<td>{_ci_cell(p.ci)}</td>"
604 f'<td class="mut">{escape(_ago(p.when, now))}</td></tr>'
605 )
606 table = (
607 "<table><thead><tr><th>pr</th><th>title</th><th>state</th><th>ci</th>"
608 f"<th>opened</th></tr></thead><tbody>{rows}</tbody></table>"
609 )
610 return _rail("Deploy · GitHub", f"{len(model.deploy.prs)} PRs", table)
611 
612 
613def _ci_cell(ci: str | None) -> str:
614 if ci is None:
615 return '<span class="mut">—</span>'
616 return f'<span class="{_CI_CLASS.get(ci, "mut")}">{escape(ci)}</span>'
617 
618 
619def _footer() -> str:
620 return (
621 "<footer><b>Safety envelope.</b> Every auto-apply passes the hard "
622 "floor (amount ceiling, unreadable amount, per-run/day breaker) and "
623 "the earned-autonomy gate (exactly one blessed rule), then a "
624 "clean-context model <b>safety review</b> that can only hold it back. "
625 "Autonomy is revoked — the rule demoted — on an explicit correction or "
626 "a silent in-YNAB edit. Everything above is derived on this request "
627 "from Temporal, YNAB, ClickHouse, AgentMail, and GitHub; nothing is "
628 "stored. Reload to recompute.</footer>"
629 )
630 
631 
632def page(model: DashboardModel) -> str:
633 """Render the whole dashboard as one self-contained HTML document."""
634 parts = (
635 _masthead(model),
636 _narrative(model),
637 _needs_you(model),
638 _working(model),
639 _activity(model),
640 _budget(model),
641 _autonomy_rail(model),
642 _telemetry_rail(model),
643 _failures_rail(model),
644 _dispatch_rail(model),
645 _deploy_rail(model),
646 _footer(),
647 )
648 return (
649 '<!doctype html><html lang="en"><head><meta charset="utf-8">'
650 '<meta name="viewport" content="width=device-width,initial-scale=1">'
651 "<title>ynab-agent &middot; ops</title>"
652 f"<style>{_CSS}</style></head><body><main>"
653 + "".join(parts)
654 + "</main></body></html>"
655 )