cctally 1.60.0 → 1.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1083,6 +1083,27 @@ class DataSnapshot:
1083
1083
  # ``CacheReportEnvelope | null`` and the client renders the
1084
1084
  # panel-empty state until the next tick replaces it.
1085
1085
  cache_report: Any | None = None
1086
+ # ---- #268 M4: doctor / config / update-state precompute (spec §6) ----
1087
+ # ``snapshot_to_envelope`` used to fork the `security` keychain subprocess
1088
+ # (via ``doctor_gather_state``) + read ``config.json`` + the update-state
1089
+ # files once PER SSE CLIENT PER TICK. These fields carry those reads,
1090
+ # precomputed ONCE per rebuild on the sync thread (doctor behind a
1091
+ # short-TTL memo), so the envelope stays a pure renderer — mirroring how
1092
+ # ``alerts`` / ``five_hour_milestones`` are already precomputed.
1093
+ # * ``doctor_payload`` — the small severity/counts/fingerprint envelope
1094
+ # block (``{severity, counts, generated_at, fingerprint}`` or a
1095
+ # ``_error`` FAIL block). ``None`` on the first/empty snapshot and on
1096
+ # fixtures constructed positionally → the envelope falls back to
1097
+ # computing inline (existing behavior).
1098
+ # * ``envelope_precompute`` — ``{config, update_state, update_suppress}``
1099
+ # the envelope derives its ``display`` / ``alerts_settings`` / ``budget``
1100
+ # / ``dashboard`` / ``update`` blocks from purely. ``None`` → the
1101
+ # envelope falls back to reading ``config.json`` / the update-state
1102
+ # files inline.
1103
+ # Both default at the END with ``None`` so positional fixture constructors
1104
+ # keep working, and both are carried forward on a sync crash (Codex F6).
1105
+ doctor_payload: dict | None = None
1106
+ envelope_precompute: dict | None = None
1086
1107
 
1087
1108
  @classmethod
1088
1109
  def synthesize_for_marketing(cls, *, as_of_iso: str) -> "DataSnapshot":
@@ -1465,6 +1486,7 @@ def _tui_build_forecast(
1465
1486
  now_utc: dt.datetime,
1466
1487
  *,
1467
1488
  skip_sync: bool = False,
1489
+ use_weekref_cost_cache: bool = False,
1468
1490
  ):
1469
1491
  """Build the TUI/dashboard sync-thread forecast.
1470
1492
 
@@ -1474,7 +1496,10 @@ def _tui_build_forecast(
1474
1496
  adapter, share builder). Use ``_tui_build_forecast_view`` when the
1475
1497
  full view is needed (e.g. ``snap.forecast_view`` population).
1476
1498
  """
1477
- view = _tui_build_forecast_view(conn, now_utc, skip_sync=skip_sync)
1499
+ view = _tui_build_forecast_view(
1500
+ conn, now_utc, skip_sync=skip_sync,
1501
+ use_weekref_cost_cache=use_weekref_cost_cache,
1502
+ )
1478
1503
  return view.output if view is not None else None
1479
1504
 
1480
1505
 
@@ -1483,14 +1508,20 @@ def _tui_build_forecast_view(
1483
1508
  now_utc: dt.datetime,
1484
1509
  *,
1485
1510
  skip_sync: bool = False,
1511
+ use_weekref_cost_cache: bool = False,
1486
1512
  ):
1487
1513
  """Build the ``ForecastView`` (issue #57). Returns ``None`` only on
1488
1514
  error in callers — the empty-state View is constructed by the
1489
1515
  builder itself with ``output=None`` + ``verdict="LOW CONF"``.
1516
+
1517
+ ``use_weekref_cost_cache`` (#269 §4) threads into the trailing-4-week
1518
+ fallback so the dashboard sync thread serves closed prior weeks from the
1519
+ shared per-weekref cost cache; default off keeps CLI byte-identical.
1490
1520
  """
1491
1521
  c = _cctally()
1492
1522
  return c.build_forecast_view(
1493
1523
  conn, now_utc=now_utc, targets=(100, 90), skip_sync=skip_sync,
1524
+ use_weekref_cost_cache=use_weekref_cost_cache,
1494
1525
  )
1495
1526
 
1496
1527
 
@@ -1498,7 +1529,7 @@ def _tui_build_trend(
1498
1529
  conn: sqlite3.Connection,
1499
1530
  now_utc: dt.datetime,
1500
1531
  *,
1501
- skip_sync: bool = False, # noqa: ARG001 — unused today, kept for API symmetry
1532
+ skip_sync: bool = False,
1502
1533
  count: int = 8,
1503
1534
  display_tz: "ZoneInfo | None" = None,
1504
1535
  ) -> list[TuiTrendRow]:
@@ -1509,10 +1540,15 @@ def _tui_build_trend(
1509
1540
  ``bin/_lib_view_models.build_trend_view``. The TUI snapshot module
1510
1541
  consumes the first 7 ``TuiTrendRow`` fields and ignores the 10
1511
1542
  extended fields (which exist for cmd_report's JSON contract).
1543
+
1544
+ ``skip_sync`` threads into ``build_trend_view`` so the reset-event
1545
+ live-cost path reads the cache without a JSONL ingest. The share
1546
+ period-override handler (``_share_apply_period_override``) passes
1547
+ ``skip_sync=True`` on an HTTP thread that must not glob (#268).
1512
1548
  """
1513
1549
  c = _cctally()
1514
1550
  view = c.build_trend_view(conn, now_utc=now_utc, n=max(1, count),
1515
- display_tz=display_tz)
1551
+ display_tz=display_tz, skip_sync=skip_sync)
1516
1552
  return list(view.rows)
1517
1553
 
1518
1554
 
@@ -1523,6 +1559,7 @@ def _tui_build_weekly_history(
1523
1559
  skip_sync: bool = False,
1524
1560
  count: int = 12,
1525
1561
  display_tz: "ZoneInfo | None" = None,
1562
+ use_weekref_cost_cache: bool = False,
1526
1563
  ) -> list[TuiTrendRow]:
1527
1564
  """Return the last `count` weeks for the Trend modal (spec §4.6.3).
1528
1565
 
@@ -1543,6 +1580,7 @@ def _tui_build_weekly_history(
1543
1580
  _tui_build_weekly_history_view(
1544
1581
  conn, now_utc, skip_sync=skip_sync, count=count,
1545
1582
  display_tz=display_tz,
1583
+ use_weekref_cost_cache=use_weekref_cost_cache,
1546
1584
  ).rows
1547
1585
  )
1548
1586
 
@@ -1551,9 +1589,10 @@ def _tui_build_weekly_history_view(
1551
1589
  conn: sqlite3.Connection,
1552
1590
  now_utc: dt.datetime,
1553
1591
  *,
1554
- skip_sync: bool = False, # noqa: ARG001 — unused today, kept for API symmetry
1592
+ skip_sync: bool = False,
1555
1593
  count: int = 12,
1556
1594
  display_tz: "ZoneInfo | None" = None,
1595
+ use_weekref_cost_cache: bool = False,
1557
1596
  ):
1558
1597
  """Build the full ``TrendView`` for the dashboard Trend modal
1559
1598
  (issue #59).
@@ -1571,14 +1610,26 @@ def _tui_build_weekly_history_view(
1571
1610
  c = _cctally()
1572
1611
  return c.build_trend_view(
1573
1612
  conn, now_utc=now_utc, n=max(1, count), display_tz=display_tz,
1613
+ skip_sync=skip_sync, use_weekref_cost_cache=use_weekref_cost_cache,
1574
1614
  )
1575
1615
 
1576
1616
 
1617
+ # #268 Group B session-cache master switch. Normally True: the sync-thread
1618
+ # rebuild serves the sessions pane from the module-level ``SessionCache``,
1619
+ # re-aggregating only the sessions touched since the last tick. Flip to False
1620
+ # to force the from-scratch 365-day fetch (the pre-#268 behavior) — the parity
1621
+ # tests toggle it to prove the cached and from-scratch rebuilds are
1622
+ # byte-identical, and the builder falls back to it automatically if the cache
1623
+ # DB can't be opened.
1624
+ _SESSION_CACHE_ENABLED = True
1625
+
1626
+
1577
1627
  def _tui_build_sessions(
1578
1628
  now_utc: dt.datetime,
1579
1629
  *,
1580
1630
  limit: int = 100,
1581
1631
  skip_sync: bool = False,
1632
+ use_session_cache: bool = False,
1582
1633
  ) -> list[TuiSessionRow]:
1583
1634
  """Load the last `limit` Claude sessions (merged across resumes).
1584
1635
 
@@ -1597,6 +1648,17 @@ def _tui_build_sessions(
1597
1648
  consumes ``view.rows`` (the typed ``TuiSessionRow`` tuple). The
1598
1649
  view's parallel ``view.aggregated`` is reserved for the CLI / share
1599
1650
  surfaces; the TUI doesn't need ``ClaudeSessionUsage`` fields.
1651
+
1652
+ ``use_session_cache`` (#268 Group B): when True AND
1653
+ ``_SESSION_CACHE_ENABLED``, serve the sessions from the module-level
1654
+ ``SessionCache`` — re-aggregate ONLY the sessions changed since the last
1655
+ tick, then sort+truncate the FULL cached set (so a session below the top
1656
+ 100 can still promote once it gets new activity, Codex F5). Set True ONLY
1657
+ by the sync-thread rebuild (``_tui_build_snapshot``), which runs on a
1658
+ process-consistent ``now``. Every OTHER caller keeps the default
1659
+ ``False`` → the from-scratch 365-day fetch, so a non-sync-thread caller
1660
+ with a shifted ``now`` can NEVER pollute the shared cache (the Bundle 2
1661
+ Group A lesson). The visible rows are byte-identical either way.
1600
1662
  """
1601
1663
  # Bounded scan window — the sessions pane promises "last `limit`". A
1602
1664
  # 365-day scan covers virtually all users (even one-session-every-few-days
@@ -1604,11 +1666,27 @@ def _tui_build_sessions(
1604
1666
  # sync-tick cost stays predictable on heavy DBs: the aggregator runs
1605
1667
  # on every entry in the window before slicing.
1606
1668
  range_start = now_utc - dt.timedelta(days=365)
1607
- entries = get_claude_session_entries(range_start, now_utc, skip_sync=skip_sync)
1608
1669
  c = _cctally()
1609
- view = c.build_sessions_view(
1610
- entries, now_utc=now_utc, limit=limit, display_tz=None,
1611
- )
1670
+ aggregated_override = None
1671
+ if use_session_cache and _SESSION_CACHE_ENABLED:
1672
+ try:
1673
+ aggregated_override = _tui_sessions_cached(
1674
+ now_utc, range_start, limit, skip_sync,
1675
+ )
1676
+ except (sqlite3.DatabaseError, OSError):
1677
+ # Cache DB unavailable / read failure — fall back to the
1678
+ # from-scratch fetch so the pane still renders.
1679
+ aggregated_override = None
1680
+ if aggregated_override is None:
1681
+ entries = get_claude_session_entries(range_start, now_utc, skip_sync=skip_sync)
1682
+ view = c.build_sessions_view(
1683
+ entries, now_utc=now_utc, limit=limit, display_tz=None,
1684
+ )
1685
+ else:
1686
+ view = c.build_sessions_view(
1687
+ (), now_utc=now_utc, limit=limit, display_tz=None,
1688
+ aggregated_override=aggregated_override,
1689
+ )
1612
1690
  rows = list(view.rows)
1613
1691
  # Attach human-readable titles for the dashboard/TUI Sessions surface.
1614
1692
  # Reuses the conversation viewer's title derivation (AI title wins, else
@@ -1645,6 +1723,150 @@ def _tui_build_sessions(
1645
1723
  return rows
1646
1724
 
1647
1725
 
1726
+ def _tui_sessions_cached(
1727
+ now_utc: dt.datetime,
1728
+ range_start: dt.datetime,
1729
+ limit: int,
1730
+ skip_sync: bool,
1731
+ ) -> "list":
1732
+ """Assemble the full session set from the module-level ``SessionCache`` (#268).
1733
+
1734
+ Cold tick: aggregate every session in ``[range_start, now]`` and populate
1735
+ the cache. Warm tick: re-aggregate ONLY the sessions touched since the
1736
+ last tick — each from its OWN full in-window entry set (a straddling /
1737
+ resumed session re-aggregates whole, no split-row). Returns the FULL
1738
+ cached set, window-filtered to ``[range_start, now]`` and sorted by
1739
+ ``last_activity`` desc; ``build_sessions_view`` then truncates to the view
1740
+ limit, which is what preserves correct eviction/**promotion** at the
1741
+ 100-row boundary (Codex F5).
1742
+
1743
+ Raises ``sqlite3.DatabaseError`` / ``OSError`` on a cache-open failure so
1744
+ ``_tui_build_sessions`` can fall back to the from-scratch path.
1745
+ """
1746
+ c = _cctally()
1747
+ sc = c._load_sibling("_lib_snapshot_cache")
1748
+ cache_conn = c.open_cache_db()
1749
+ try:
1750
+ def _aggregate_all():
1751
+ entries = get_claude_session_entries(
1752
+ range_start, now_utc, skip_sync=skip_sync,
1753
+ )
1754
+ return _aggregate_claude_sessions(entries)
1755
+
1756
+ def _reaggregate(last_seen, _affected):
1757
+ entries = _fetch_affected_session_entries(
1758
+ cache_conn, last_seen, range_start, now_utc,
1759
+ )
1760
+ return _aggregate_claude_sessions(entries)
1761
+
1762
+ # Identity resolution here is the resolved ``session_id`` (via the
1763
+ # session_files join, filename-stem fallback when null). A late
1764
+ # null→session_id backfill on an existing path (the schema migration
1765
+ # that added the column shipped long ago) would re-key an already-cached
1766
+ # session; that is DELIBERATELY NOT handled per-tick (a session_files
1767
+ # rescan every tick would tax the exact hot path #268 optimizes, and
1768
+ # reachability is near-zero because files are named ``<sessionId>.jsonl``
1769
+ # so the stem == the sessionId in the overwhelming majority). Escape
1770
+ # hatches: a dashboard restart, the M5.2 orphan-prune generation bump, or
1771
+ # ``reset_session_cache_state()`` all re-cold-start the cache.
1772
+ full = sc.build_cached_sessions(
1773
+ cache_conn=cache_conn,
1774
+ aggregate_all=_aggregate_all,
1775
+ reaggregate=_reaggregate,
1776
+ )
1777
+ finally:
1778
+ try:
1779
+ cache_conn.close()
1780
+ except sqlite3.Error:
1781
+ pass
1782
+ # #268 M5-additional (a): DROP aged-out sessions from the STORE, not just
1783
+ # from the returned view. Under a sliding `now`, a session whose
1784
+ # last_activity has fallen before range_start is out of [range_start, now]
1785
+ # — the from-scratch fetch wouldn't return it either — so evict it from the
1786
+ # module cache. Without this the store retains every session ever
1787
+ # cold-populated and grows unboundedly over long dashboard uptime. No-op on
1788
+ # a pinned `now` (every cold-populated session's last_activity is >=
1789
+ # range_start by construction), and cheap (a scalar partition over a few
1790
+ # thousand aggregates, not a 365-day entry re-scan).
1791
+ aged_out = {s.session_id for s in full if s.last_activity < range_start}
1792
+ if aged_out:
1793
+ sc.session_cache().drop(aged_out)
1794
+ in_window = [s for s in full if s.last_activity >= range_start]
1795
+ in_window.sort(key=lambda s: s.last_activity, reverse=True)
1796
+ return in_window
1797
+
1798
+
1799
+ def _fetch_affected_session_entries(
1800
+ cache_conn: "sqlite3.Connection",
1801
+ last_seen_seq: int,
1802
+ range_start: dt.datetime,
1803
+ range_end: dt.datetime,
1804
+ ) -> "list":
1805
+ """Fetch (timestamp-ASC) every entry of every session touched by a row
1806
+ CHANGED since ``last_seen_seq`` — expanded across ``session_id`` siblings so
1807
+ a resumed session re-aggregates WHOLE — within ``[range_start, range_end]``.
1808
+
1809
+ The column list + ``LEFT JOIN`` mirror ``get_claude_session_entries``
1810
+ EXACTLY (including the materialized ``speed`` column → ``usage_extra``), so
1811
+ the per-session aggregate is byte-identical to the from-scratch pass. The
1812
+ affected-source-paths set is inlined as a SQL subquery (parameterized only
1813
+ by ``last_seen_seq``) so the fetch stays a single timestamp-ordered result —
1814
+ no Python ``IN`` list to chunk, and stable first-seen model order.
1815
+
1816
+ #270 (§7d, Codex-2c): the two affected-path subqueries key on
1817
+ ``mutation_seq > ?``, NOT ``id > ?`` — so an id-stable in-place finalization
1818
+ of an EXISTING session's row (which leaves ``MAX(id)`` flat) still selects
1819
+ that session's sibling paths and it re-aggregates WHOLE. On a pure-insert
1820
+ interval ``{mutation_seq > last}`` == ``{id > last}``, so byte-identical.
1821
+ """
1822
+ c = _cctally()
1823
+ _JoinedClaudeEntry = c._JoinedClaudeEntry
1824
+ start_iso = range_start.astimezone(dt.timezone.utc).isoformat()
1825
+ end_iso = range_end.astimezone(dt.timezone.utc).isoformat()
1826
+ sql = (
1827
+ "SELECT se.timestamp_utc, se.model, se.input_tokens, se.output_tokens, "
1828
+ " se.cache_create_tokens, se.cache_read_tokens, se.source_path, "
1829
+ " sf.session_id, sf.project_path, se.cost_usd_raw, se.speed "
1830
+ "FROM session_entries se "
1831
+ "LEFT JOIN session_files sf ON sf.path = se.source_path "
1832
+ "WHERE se.timestamp_utc >= ? AND se.timestamp_utc <= ? "
1833
+ " AND se.source_path IN ("
1834
+ # Sibling paths of every session_id touched by a changed row ...
1835
+ " SELECT sf2.path FROM session_files sf2 WHERE sf2.session_id IN ("
1836
+ " SELECT sf1.session_id FROM session_files sf1 "
1837
+ " JOIN (SELECT DISTINCT source_path FROM session_entries "
1838
+ " WHERE mutation_seq > ?) af "
1839
+ " ON af.source_path = sf1.path "
1840
+ " WHERE sf1.session_id IS NOT NULL"
1841
+ " )"
1842
+ # ... UNION the touched files themselves (covers fallback / not-yet-
1843
+ # backfilled session_files rows keyed by filename stem).
1844
+ " UNION "
1845
+ " SELECT DISTINCT source_path FROM session_entries WHERE mutation_seq > ?"
1846
+ " ) "
1847
+ "ORDER BY se.timestamp_utc ASC"
1848
+ )
1849
+ rows = cache_conn.execute(
1850
+ sql, (start_iso, end_iso, last_seen_seq, last_seen_seq),
1851
+ ).fetchall()
1852
+ return [
1853
+ _JoinedClaudeEntry(
1854
+ timestamp=dt.datetime.fromisoformat(row[0]),
1855
+ model=row[1],
1856
+ input_tokens=row[2],
1857
+ output_tokens=row[3],
1858
+ cache_creation_tokens=row[4],
1859
+ cache_read_tokens=row[5],
1860
+ source_path=row[6],
1861
+ session_id=row[7],
1862
+ project_path=row[8],
1863
+ cost_usd=row[9],
1864
+ usage_extra=({"speed": row[10]} if row[10] is not None else None),
1865
+ )
1866
+ for row in rows
1867
+ ]
1868
+
1869
+
1648
1870
  @dataclass
1649
1871
  class TuiSessionDetail:
1650
1872
  """Detailed view for the Session modal (spec §4.6.4).
@@ -1843,6 +2065,8 @@ def _tui_build_snapshot(
1843
2065
  now_utc: dt.datetime | None = None,
1844
2066
  skip_sync: bool = False,
1845
2067
  display_tz_pref_override: "str | None" = None,
2068
+ precompute_envelope: bool = False,
2069
+ runtime_bind: "str | None" = None,
1846
2070
  ) -> DataSnapshot:
1847
2071
  """Single-shot build of a DataSnapshot from the DB + cache.
1848
2072
 
@@ -1854,9 +2078,24 @@ def _tui_build_snapshot(
1854
2078
  the lifetime of this build. Used by ``cmd_dashboard`` when ``--tz``
1855
2079
  is supplied so the in-memory zone wins over the persisted config
1856
2080
  without modifying it. ``None`` means "respect config".
2081
+
2082
+ ``precompute_envelope`` (#268 M4, spec §6): when True, precompute the
2083
+ dashboard envelope's doctor / config / update-state reads once here (on
2084
+ the sync thread, doctor behind a short-TTL memo) and attach them to
2085
+ ``DataSnapshot.doctor_payload`` / ``.envelope_precompute`` so
2086
+ ``snapshot_to_envelope`` stays a pure renderer. Set True ONLY by the
2087
+ DASHBOARD callers (the sync-thread rebuild + the initial snapshot); the
2088
+ terminal TUI leaves it False so it never forks the `security` keychain
2089
+ subprocess it doesn't render. ``runtime_bind`` is the actual host the
2090
+ dashboard bound to, threaded into the doctor gather so
2091
+ ``safety.dashboard_bind`` reflects the running state.
1857
2092
  """
1858
2093
  import time
1859
2094
  now_utc = now_utc or dt.datetime.now(dt.timezone.utc)
2095
+ # Read config ONCE per rebuild and reuse it for the display-tz resolution
2096
+ # here AND the envelope precompute below (#268 M4) — the envelope used to
2097
+ # call ``load_config()`` twice per SSE client per tick.
2098
+ raw_config = load_config()
1860
2099
  # Resolve the display tz once per snapshot so labels rendered into
1861
2100
  # BlocksPanelRow / future panel rows share a single zone with the
1862
2101
  # envelope's `display` block. Routed through the shared
@@ -1866,7 +2105,7 @@ def _tui_build_snapshot(
1866
2105
  # into label-FOR-LOOKUP paths like `_aggregate_monthly` keys (out of
1867
2106
  # scope for Task 11).
1868
2107
  _build_display_tz = _resolve_display_tz_obj(
1869
- _apply_display_tz_override(load_config(), display_tz_pref_override)
2108
+ _apply_display_tz_override(raw_config, display_tz_pref_override)
1870
2109
  )
1871
2110
  conn = open_db()
1872
2111
  try:
@@ -1882,6 +2121,189 @@ def _tui_build_snapshot(
1882
2121
  blocks_panel: list[BlocksPanelRow] = []
1883
2122
  daily_panel: list[DailyPanelRow] = []
1884
2123
  alerts: list[dict] = []
2124
+ # ── Sync-once (spec §4, #268) ──────────────────────────────────
2125
+ # Ingest new JSONL bytes into the cache EXACTLY ONCE at the top of
2126
+ # the rebuild, then read every builder with skip_sync=True (pure
2127
+ # SQLite reads). Before this, each of the ~8 wide builders called
2128
+ # get_entries(..., skip_sync=False) which ran its own sync_cache —
2129
+ # ~8-10 redundant whole-tree globs + per-file stats per rebuild,
2130
+ # the CPU-side cost that pegs a core on a large instance.
2131
+ #
2132
+ # The caller's skip_sync flag expresses the --no-sync intent:
2133
+ # honor it by gating ONLY this top-of-rebuild ingest. Downstream
2134
+ # builders always read pure regardless (skip_sync is reassigned to
2135
+ # True below), so --no-sync means "no ingest, still read".
2136
+ do_ingest = not skip_sync
2137
+ if do_ingest:
2138
+ try:
2139
+ cache_conn = _cctally().open_cache_db()
2140
+ try:
2141
+ sync_cache(cache_conn)
2142
+ finally:
2143
+ cache_conn.close()
2144
+ except Exception as exc:
2145
+ errors.append(f"sync-cache: {exc}")
2146
+ # Force pure reads for every view builder below, independent of the
2147
+ # caller's flag: the single ingest above is the only glob per tick.
2148
+ skip_sync = True
2149
+ # ── #269 M3.1: shared per-weekref immutable-cost cache (spec §4/§6) ──
2150
+ # Gated OFF by default; flipped ON only on the dashboard sync-thread
2151
+ # NON-IDLE path below, AFTER the once-per-rebuild `reconcile_weekref_cache`
2152
+ # has run. Off ⇒ the trend / weekly-history / forecast builders compute
2153
+ # per-closed-week cost directly (CLI / TUI byte-identical). B2 (the
2154
+ # cache-report per-day cache) was DROPPED at the M2.0 byte-identity gate
2155
+ # (by_project net_usd is not associative across the day partition — see
2156
+ # the spec §5 finding note), so only the weekref cache is wired here.
2157
+ use_weekref_cost_cache = False
2158
+ # ── #269 M4.5: projects-envelope per-(project, week) cache (spec §14) ──
2159
+ # Same gating as the weekref cache: OFF by default, flipped ON only on
2160
+ # the dashboard sync-thread NON-IDLE path after the once-per-rebuild
2161
+ # `reconcile_projects_env_cache` succeeds. Off ⇒ `_build_projects_envelope`
2162
+ # does the full-window walk (CLI / drill / test byte-identical).
2163
+ use_projects_env_cache = False
2164
+ # ── #271 M3: Bug-K pre-credit segment cache (spec §18) ──────────────
2165
+ # A DEDICATED flag (Codex-BK-2 — NOT piggybacked on `use_group_a_cache`):
2166
+ # OFF by default, flipped ON only on the dashboard sync-thread NON-IDLE
2167
+ # path after the once-per-rebuild `reconcile_bugk_cache` succeeds. Off ⇒
2168
+ # the Weekly builder's Bug-K synthesis re-folds the closed pre-credit
2169
+ # window directly each tick (CLI / share / test byte-identical). A
2170
+ # failed/absent reconcile must fall back to direct compute, never reuse
2171
+ # stale segment state.
2172
+ use_bugk_segment_cache = False
2173
+ # ── #272: cache-report per-day cache (spec §5/§6) ───────────────────
2174
+ # Same gating: OFF by default, flipped ON only on the dashboard
2175
+ # sync-thread NON-IDLE path after the once-per-rebuild
2176
+ # `reconcile_cache_report_cache` succeeds. Off ⇒ `build_cache_report_snapshot`
2177
+ # fetches + folds the full 14d window every tick (byte-identical to the
2178
+ # cached path). A failed/absent reconcile falls back to full recompute.
2179
+ use_cache_report_cache = False
2180
+ # ── Three-path dispatch — idle short-circuit (spec §3, #268 M5.1) ──
2181
+ # Compute the composite data-version signature (cheap MAX(id) descents
2182
+ # over cache.db + stats.db + the reset-event change-signal + the
2183
+ # generation counter). When it is UNCHANGED versus the last published
2184
+ # rebuild AND no wall-clock day/week/month boundary has rolled over,
2185
+ # take the IDLE path: reuse the prior snapshot's heavy period/session
2186
+ # rows and re-patch ONLY the time-derived fields + doctor-on-TTL, then
2187
+ # return — NO re-aggregation, so an idle dashboard sits near 0% CPU.
2188
+ # Dashboard-only (``precompute_envelope``) + sync-thread-only, mirroring
2189
+ # the Group A / session cache gating; the shared ``(signature,
2190
+ # snapshot)`` memo lives in ``_lib_snapshot_cache`` (single-writer here).
2191
+ # The signature is computed AFTER the top-of-rebuild ingest so a fresh
2192
+ # tail-ingested row is reflected before the idle decision is made.
2193
+ dispatch_key = None
2194
+ if precompute_envelope:
2195
+ dispatch_sig = None
2196
+ try:
2197
+ dispatch_sig = _tui_compute_dispatch_signature(conn)
2198
+ except Exception as exc:
2199
+ errors.append(f"dispatch-signature: {exc}")
2200
+ dispatch_sig = None
2201
+ if dispatch_sig is not None:
2202
+ # The idle decision keys on the DB signature AND a render key
2203
+ # that captures the config-derived inputs the composite
2204
+ # signature does NOT cover (spec §3 is DB-only): the resolved
2205
+ # display-tz override + the full raw config (display.tz,
2206
+ # alerts_settings, budget, dashboard prefs). A `POST /api/settings`
2207
+ # edit advances neither `MAX(id)` nor the reset legs, so without
2208
+ # this a settings change would idle-serve the stale envelope; a
2209
+ # render-key change forces a full rebuild that re-buckets the
2210
+ # calendar builders (their Group A cache already keys tz) and
2211
+ # refreshes every config-derived envelope block.
2212
+ render_key = (
2213
+ display_tz_pref_override,
2214
+ json.dumps(raw_config, sort_keys=True, default=str),
2215
+ )
2216
+ dispatch_key = (dispatch_sig, render_key)
2217
+ _sc = _cctally()._load_sibling("_lib_snapshot_cache")
2218
+ prior_key, prior_snap = _sc.dispatch_state()
2219
+ if (prior_snap is not None and prior_key is not None
2220
+ and dispatch_key == prior_key
2221
+ and not _snapshot_period_rolled_over(
2222
+ prior_snap, now_utc, _build_display_tz)):
2223
+ idle_snap = _tui_build_idle_snapshot(
2224
+ prior_snap, now_utc=now_utc,
2225
+ precompute_envelope=precompute_envelope,
2226
+ runtime_bind=runtime_bind, raw_config=raw_config,
2227
+ errors=errors,
2228
+ )
2229
+ _sc.store_dispatch_state(dispatch_key, idle_snap)
2230
+ return idle_snap
2231
+ # ── #269 M3.1: non-idle rebuild — reconcile the shared
2232
+ # per-weekref immutable-cost cache ONCE here (spec §4/§6),
2233
+ # before the builders run, using the dispatch-signature legs
2234
+ # already computed for the idle decision (no extra MAX(id) /
2235
+ # reset query). A SHORT-LIVED cache.db conn is opened only for
2236
+ # reconcile's new-entry watermark query (Codex-4), mirroring
2237
+ # `_tui_compute_dispatch_signature`. On success the trend /
2238
+ # weekly-history / forecast builders opt into the cache via
2239
+ # `use_weekref_cost_cache=True`; any failure leaves the flag OFF
2240
+ # (safe direct-compute fallback, byte-identical output).
2241
+ try:
2242
+ _rc_cache_conn = _cctally().open_cache_db()
2243
+ try:
2244
+ _sc.reconcile_weekref_cache(
2245
+ _rc_cache_conn,
2246
+ max_entry_id=dispatch_sig.max_entry_id,
2247
+ max_mutation_seq=dispatch_sig.entry_mutation_seq,
2248
+ reset_sig=dispatch_sig.reset_sig,
2249
+ )
2250
+ use_weekref_cost_cache = True
2251
+ # #269 M4.5: reconcile the projects-envelope cache with
2252
+ # the SAME short-lived cache conn (session_files_sig +
2253
+ # the new-entry watermark both live in cache.db). Only
2254
+ # after this succeeds does `_build_projects_envelope`
2255
+ # opt into the cache below.
2256
+ _sc.reconcile_projects_env_cache(
2257
+ _rc_cache_conn,
2258
+ max_entry_id=dispatch_sig.max_entry_id,
2259
+ max_mutation_seq=dispatch_sig.entry_mutation_seq,
2260
+ max_wus_id=dispatch_sig.max_wus_id,
2261
+ sf_sig=_sc.session_files_sig(_rc_cache_conn),
2262
+ )
2263
+ use_projects_env_cache = True
2264
+ # #271 M3: reconcile the Bug-K pre-credit segment cache
2265
+ # with the SAME short-lived cache conn (its watermark
2266
+ # query lives in cache.db), using the dispatch-signature
2267
+ # legs already computed. Only after this succeeds does
2268
+ # `_dashboard_build_weekly_periods` opt into the cache
2269
+ # below (`use_bugk_segment_cache=True`).
2270
+ _sc.reconcile_bugk_cache(
2271
+ _rc_cache_conn,
2272
+ max_entry_id=dispatch_sig.max_entry_id,
2273
+ max_mutation_seq=dispatch_sig.entry_mutation_seq,
2274
+ reset_sig=dispatch_sig.reset_sig,
2275
+ )
2276
+ use_bugk_segment_cache = True
2277
+ # #272: reconcile the cache-report per-day cache with the
2278
+ # SAME short-lived cache conn (its watermark query +
2279
+ # session_files_sig both live in cache.db), using the
2280
+ # dispatch-signature legs already computed. `sf_sig`
2281
+ # closes the lazy-`project_path`-backfill hole (Codex-1);
2282
+ # `tz_key` full-clears on a display-tz change (every
2283
+ # calendar-day key shifts). Only after this succeeds does
2284
+ # `build_cache_report_snapshot` opt into the cache below.
2285
+ _sc.reconcile_cache_report_cache(
2286
+ _rc_cache_conn,
2287
+ max_entry_id=dispatch_sig.max_entry_id,
2288
+ max_mutation_seq=dispatch_sig.entry_mutation_seq,
2289
+ reset_sig=dispatch_sig.reset_sig,
2290
+ sf_sig=_sc.session_files_sig(_rc_cache_conn),
2291
+ # `_load_sibling` is the canonical idempotent
2292
+ # sibling-access idiom (it returns the already-loaded
2293
+ # cctally-bound kernel instance). `_lib_cache_report`
2294
+ # is loaded eagerly at import (bin/cctally), so this
2295
+ # is a plain re-fetch of a populated `sys.modules`
2296
+ # entry — NOT a first-tick KeyError guard.
2297
+ bucket_tz=_cctally()._load_sibling(
2298
+ "_lib_cache_report"
2299
+ )._resolve_bucket_tz(_build_display_tz),
2300
+ tz_key=str(_build_display_tz),
2301
+ )
2302
+ use_cache_report_cache = True
2303
+ finally:
2304
+ _rc_cache_conn.close()
2305
+ except Exception as exc:
2306
+ errors.append(f"snapshot-cache-reconcile: {exc}")
1885
2307
  try:
1886
2308
  cw = _tui_build_current_week(conn, now_utc, skip_sync=skip_sync)
1887
2309
  except Exception as exc:
@@ -1892,7 +2314,10 @@ def _tui_build_snapshot(
1892
2314
  # the legacy ``ForecastOutput`` (for ``snap.forecast``, which
1893
2315
  # the many TUI panel consumers still read) and the surface
1894
2316
  # fields the envelope adapter used to re-derive inline.
1895
- fc_view = _tui_build_forecast_view(conn, now_utc, skip_sync=skip_sync)
2317
+ fc_view = _tui_build_forecast_view(
2318
+ conn, now_utc, skip_sync=skip_sync,
2319
+ use_weekref_cost_cache=use_weekref_cost_cache,
2320
+ )
1896
2321
  fc = fc_view.output if fc_view is not None else None
1897
2322
  except Exception as exc:
1898
2323
  errors.append(f"forecast: {exc}")
@@ -1906,6 +2331,8 @@ def _tui_build_snapshot(
1906
2331
  c = _cctally()
1907
2332
  _trend_view = c.build_trend_view(
1908
2333
  conn, now_utc=now_utc, n=8, display_tz=_build_display_tz,
2334
+ skip_sync=skip_sync,
2335
+ use_weekref_cost_cache=use_weekref_cost_cache,
1909
2336
  )
1910
2337
  trend = list(_trend_view.rows)
1911
2338
  trend_avg_dpp = _trend_view.avg_dollars_per_pct
@@ -1917,7 +2344,9 @@ def _tui_build_snapshot(
1917
2344
  # `skip_sync=True` is threaded through. Honor the caller's
1918
2345
  # intent so `--no-sync` and the initial cache-only paint
1919
2346
  # both avoid ingest latency/lock contention.
1920
- sessions = _tui_build_sessions(now_utc, skip_sync=skip_sync)
2347
+ sessions = _tui_build_sessions(
2348
+ now_utc, skip_sync=skip_sync, use_session_cache=True,
2349
+ )
1921
2350
  except Exception as exc:
1922
2351
  errors.append(f"sessions: {exc}")
1923
2352
  # ---- v2 additions ----
@@ -1936,6 +2365,7 @@ def _tui_build_snapshot(
1936
2365
  # TrendModal.tsx stops re-deriving it client-side.
1937
2366
  history_view = _tui_build_weekly_history_view(
1938
2367
  conn, now_utc, skip_sync=skip_sync, display_tz=_build_display_tz,
2368
+ use_weekref_cost_cache=use_weekref_cost_cache,
1939
2369
  )
1940
2370
  history = list(history_view.rows)
1941
2371
  history_median_dpp = history_view.median_dpp_non_current_4w
@@ -1958,7 +2388,9 @@ def _tui_build_snapshot(
1958
2388
  weekly_total_tokens = 0
1959
2389
  try:
1960
2390
  weekly_periods = _dashboard_build_weekly_periods(
1961
- conn, now_utc, n=12, skip_sync=skip_sync
2391
+ conn, now_utc, n=12, skip_sync=skip_sync,
2392
+ use_group_a_cache=True,
2393
+ use_bugk_segment_cache=use_bugk_segment_cache,
1962
2394
  )
1963
2395
  # ``stable_sum`` (math.fsum) returns float ``0.0`` on empty rows,
1964
2396
  # so the envelope stays byte-stable with the pre-fix ``0.0`` shape
@@ -1981,6 +2413,7 @@ def _tui_build_snapshot(
1981
2413
  try:
1982
2414
  monthly_periods = _dashboard_build_monthly_periods(
1983
2415
  conn, now_utc, n=12, skip_sync=skip_sync,
2416
+ use_group_a_cache=True,
1984
2417
  display_tz=_build_display_tz,
1985
2418
  )
1986
2419
  monthly_total_cost_usd = stable_sum(
@@ -2024,6 +2457,7 @@ def _tui_build_snapshot(
2024
2457
  try:
2025
2458
  daily_panel = _dashboard_build_daily_panel(
2026
2459
  conn, now_utc, n=30, skip_sync=skip_sync,
2460
+ use_group_a_cache=True,
2027
2461
  display_tz=_build_display_tz,
2028
2462
  )
2029
2463
  daily_total_cost_usd = stable_sum(
@@ -2097,6 +2531,7 @@ def _tui_build_snapshot(
2097
2531
  now_utc=now_utc,
2098
2532
  current_week=cw,
2099
2533
  weeks_back=12,
2534
+ use_projects_env_cache=use_projects_env_cache,
2100
2535
  )
2101
2536
  except Exception as exc:
2102
2537
  errors.append(f"projects-envelope: {exc}")
@@ -2189,11 +2624,35 @@ def _tui_build_snapshot(
2189
2624
  anomaly_window_days=_dash_mod.CACHE_REPORT_ANOMALY_WINDOW_DAYS,
2190
2625
  display_tz=_build_display_tz,
2191
2626
  skip_sync=skip_sync,
2627
+ use_cache_report_cache=use_cache_report_cache,
2192
2628
  )
2193
2629
  except Exception as exc:
2194
2630
  errors.append(f"cache-report: {exc}")
2195
2631
 
2196
- return DataSnapshot(
2632
+ # ---- #268 M4: doctor / config / update-state precompute (spec §6) ----
2633
+ # Precompute the envelope's doctor / config / update-state reads ONCE
2634
+ # here (dashboard callers only), so `snapshot_to_envelope` stays a pure
2635
+ # renderer that never forks `security` / reads config.json per SSE
2636
+ # client per tick. Errors are folded into `errors` (recorded on
2637
+ # `last_sync_error`); the fields stay None on failure → the envelope
2638
+ # falls back to its inline computation, preserving behavior.
2639
+ doctor_payload_block: "dict | None" = None
2640
+ envelope_precompute_block: "dict | None" = None
2641
+ if precompute_envelope:
2642
+ try:
2643
+ doctor_payload_block = _tui_precompute_doctor_payload(
2644
+ now_utc, runtime_bind,
2645
+ )
2646
+ except Exception as exc:
2647
+ errors.append(f"doctor-precompute: {exc}")
2648
+ try:
2649
+ envelope_precompute_block = _tui_precompute_envelope_config(
2650
+ raw_config,
2651
+ )
2652
+ except Exception as exc:
2653
+ errors.append(f"envelope-precompute: {exc}")
2654
+
2655
+ snap = DataSnapshot(
2197
2656
  current_week=cw,
2198
2657
  forecast=fc,
2199
2658
  trend=trend,
@@ -2222,11 +2681,222 @@ def _tui_build_snapshot(
2222
2681
  forecast_view=fc_view,
2223
2682
  projects_envelope=projects_envelope_block,
2224
2683
  cache_report=cache_report_block,
2684
+ doctor_payload=doctor_payload_block,
2685
+ envelope_precompute=envelope_precompute_block,
2225
2686
  )
2687
+ # #268 M5.1: record the (signature+render key, snapshot) so the next
2688
+ # dashboard tick can idle-short-circuit when nothing changed. Full-build
2689
+ # path only sets it when the key was computed (precompute_envelope); the
2690
+ # TUI path never touches the dispatch memo.
2691
+ if precompute_envelope and dispatch_key is not None:
2692
+ _cctally()._load_sibling("_lib_snapshot_cache").store_dispatch_state(
2693
+ dispatch_key, snap,
2694
+ )
2695
+ return snap
2226
2696
  finally:
2227
2697
  conn.close()
2228
2698
 
2229
2699
 
2700
+ def _tui_precompute_doctor_payload(
2701
+ now_utc: dt.datetime,
2702
+ runtime_bind: "str | None",
2703
+ ) -> dict:
2704
+ """Precompute the dashboard envelope's small doctor block, via the
2705
+ short-TTL memo (#268 M4, spec §6).
2706
+
2707
+ Returns the SAME ``{severity, counts, generated_at, fingerprint}`` dict
2708
+ ``snapshot_to_envelope`` used to build inline (or a synthetic FAIL block
2709
+ with ``_error`` on a doctor gather/checks failure), so moving the
2710
+ computation onto the snapshot is byte-identical for a given
2711
+ ``now_utc``/``runtime_bind``. Guarded by ``doctor_payload_memo`` so
2712
+ back-to-back warm rebuilds don't re-fork the `security` keychain
2713
+ subprocess every tick.
2714
+ """
2715
+ c = _cctally()
2716
+ sc = c._load_sibling("_lib_snapshot_cache")
2717
+
2718
+ def _compute(now: dt.datetime, bind: "str | None") -> dict:
2719
+ _ld = c._load_sibling("_lib_doctor")
2720
+ try:
2721
+ _doc_state = c.doctor_gather_state(now_utc=now, runtime_bind=bind)
2722
+ _doc_report = _ld.run_checks(_doc_state)
2723
+ return {
2724
+ "severity": _doc_report.overall_severity,
2725
+ "counts": dict(_doc_report.counts),
2726
+ "generated_at": _ld._iso_z(_doc_report.generated_at),
2727
+ "fingerprint": _ld.fingerprint(_doc_report),
2728
+ }
2729
+ except Exception as exc: # noqa: BLE001 — never crash the rebuild
2730
+ # Mirror `snapshot_to_envelope`'s inline FAIL fallback (dashboard
2731
+ # `_iso_z`: strftime, no microseconds) so a doctor failure serves
2732
+ # the same shape whether computed here or in the envelope.
2733
+ return {
2734
+ "severity": "fail",
2735
+ "counts": {"ok": 0, "warn": 0, "fail": 1},
2736
+ "generated_at": now.astimezone(dt.timezone.utc).strftime(
2737
+ "%Y-%m-%dT%H:%M:%SZ"
2738
+ ),
2739
+ "fingerprint": "sha1:" + ("0" * 40),
2740
+ "_error": f"{type(exc).__name__}: {exc}",
2741
+ }
2742
+
2743
+ return sc.doctor_payload_memo(
2744
+ now_utc, runtime_bind, ttl_s=sc.DOCTOR_MEMO_TTL_S, compute=_compute,
2745
+ )
2746
+
2747
+
2748
+ def _tui_precompute_envelope_config(raw_config: dict) -> dict:
2749
+ """Precompute the config / update-state reads `snapshot_to_envelope`
2750
+ needs (#268 M4, spec §6).
2751
+
2752
+ Returns ``{config, update_state, update_suppress}``: the raw
2753
+ ``load_config()`` dict (the envelope layers the display-tz override on it
2754
+ purely and reads the alerts/budget/dashboard blocks off it) plus the
2755
+ update-state / update-suppress payloads with the SAME error sentinels the
2756
+ envelope used inline, so the derived ``update`` block is unchanged.
2757
+ """
2758
+ c = _cctally()
2759
+ try:
2760
+ update_state = c._load_update_state()
2761
+ except c.UpdateError:
2762
+ update_state = {"_error": "update-state.json invalid"}
2763
+ except Exception:
2764
+ update_state = {"_error": "update-state.json read failed"}
2765
+ try:
2766
+ update_suppress = c._load_update_suppress()
2767
+ except Exception:
2768
+ update_suppress = {"skipped_versions": [], "remind_after": None}
2769
+ return {
2770
+ "config": raw_config,
2771
+ "update_state": update_state,
2772
+ "update_suppress": update_suppress,
2773
+ }
2774
+
2775
+
2776
+ def _tui_compute_dispatch_signature(stats_conn):
2777
+ """Composite data-version signature for the three-path dispatch (#268 M5.1).
2778
+
2779
+ Cheap ``MAX(id)`` b-tree descents over cache.db + stats.db, the reset-event
2780
+ change-signal, and the module generation counter (spec §3). ``stats_conn``
2781
+ is the already-open stats.db connection the rebuild holds; a throwaway
2782
+ cache.db connection is opened for the ``session_entries`` / ``codex`` legs.
2783
+ ``compute_signature`` never raises (each leg degrades to 0 on a missing
2784
+ table); a cache-open failure propagates so the caller skips the idle path
2785
+ and does a full rebuild.
2786
+ """
2787
+ c = _cctally()
2788
+ sc = c._load_sibling("_lib_snapshot_cache")
2789
+ cache_conn = c.open_cache_db()
2790
+ try:
2791
+ return sc.compute_signature(
2792
+ cache_conn, stats_conn, generation=sc.current_generation(),
2793
+ )
2794
+ finally:
2795
+ cache_conn.close()
2796
+
2797
+
2798
+ def _snapshot_period_rolled_over(prior, now_utc, display_tz):
2799
+ """True if a day/week/month boundary was crossed since ``prior`` was built.
2800
+
2801
+ The idle short-circuit reuses ``prior``'s period rows, which is only valid
2802
+ while the current day/week/month is unchanged (spec §3). A day change in the
2803
+ display tz subsumes a month change (a new month starts on a new day), so
2804
+ daily+monthly reduce to one day-label compare. Weekly rollover is checked
2805
+ against the subscription week's absolute boundaries carried on
2806
+ ``prior.current_week`` (reset-anchored UTC datetimes) — a week can roll at a
2807
+ mid-day reset hour without the calendar day changing.
2808
+
2809
+ The 5-hour block is also boundary-checked here (#268 M5 Finding 4): a 5h
2810
+ reset with zero new usage across a full window on a truly-idle dashboard
2811
+ advances no DB signature and crosses no calendar day, so without this leg the
2812
+ idle path would keep serving the prior "current block" past its reset. The
2813
+ reset-anchored UTC instant carried on ``prior.current_week.five_hour_resets_at``
2814
+ forces a full rebuild once ``now`` reaches it, re-anchoring the block.
2815
+ """
2816
+ prev = getattr(prior, "generated_at", None)
2817
+ if prev is None:
2818
+ return True
2819
+
2820
+ def _day(dtm):
2821
+ # Day label in the resolved display tz — matches how the daily builder
2822
+ # buckets (display tz, host-local when None). Not a user-facing render,
2823
+ # only a same-day equality check.
2824
+ localized = dtm.astimezone(display_tz) if display_tz is not None \
2825
+ else dtm.astimezone() # internal fallback: host-local intentional
2826
+ return localized.strftime("%Y-%m-%d")
2827
+
2828
+ if _day(prev) != _day(now_utc):
2829
+ return True
2830
+ cw = getattr(prior, "current_week", None)
2831
+ if cw is not None:
2832
+ week_end = getattr(cw, "week_end_at", None)
2833
+ week_start = getattr(cw, "week_start_at", None)
2834
+ if week_end is not None and now_utc >= week_end:
2835
+ return True
2836
+ if week_start is not None and now_utc < week_start:
2837
+ return True
2838
+ # 5h block rollover (#268 M5 Finding 4): the reset instant is the
2839
+ # reset-anchored UTC end of the active block (already suppressed to None
2840
+ # by `_tui_build_current_week` once it has elapsed, so a non-None value is
2841
+ # a future boundary at build time). Once now crosses it, force a full
2842
+ # rebuild so the blocks panel re-anchors even with zero new usage.
2843
+ five_hour_reset = getattr(cw, "five_hour_resets_at", None)
2844
+ if five_hour_reset is not None and now_utc >= five_hour_reset:
2845
+ return True
2846
+ return False
2847
+
2848
+
2849
+ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
2850
+ runtime_bind, raw_config, errors):
2851
+ """Fresh snapshot reusing ``prior``'s heavy rows, re-patching only the
2852
+ time-derived fields + the doctor payload / envelope precompute on each idle
2853
+ tick (spec §3 idle path).
2854
+
2855
+ ``dataclasses.replace`` builds a NEW ``DataSnapshot`` sharing ``prior``'s
2856
+ immutable row objects (never mutated in place — Codex F7), so an SSE client
2857
+ thread serializing the previously-published snapshot can't observe a torn
2858
+ value. ``generated_at`` moves to ``now_utc`` (drives the envelope's display
2859
+ block + the emitted timestamp); ``last_sync_at`` refreshes (the ingest ran,
2860
+ so "synced Xs ago" resets); the doctor payload is refreshed through the TTL
2861
+ memo so the doctor chip stays live on a long-idle dashboard (Codex F6). All
2862
+ the wall-clock countdowns / freshness ages are computed from a LIVE now at
2863
+ envelope-render time (the SSE loop passes its own ``now_utc`` /
2864
+ ``time.monotonic()``), so nothing else needs patching here.
2865
+
2866
+ ``envelope_precompute`` is ALSO refreshed each idle tick (#268 M5 Finding 1):
2867
+ since M4 the envelope reads update-state / update-suppress off the snapshot's
2868
+ ``envelope_precompute`` rather than re-reading the JSON files per client.
2869
+ ``_DashboardUpdateCheckThread`` writes ``update-state.json`` OUT OF BAND
2870
+ (advancing no DB signature and no config render key), so carrying
2871
+ ``prior.envelope_precompute`` forward would freeze the version banner on a
2872
+ long-idle / --no-sync dashboard until new usage or a config edit lands. This
2873
+ mirrors the doctor-on-idle treatment (spec §6): config is render-key-stable on
2874
+ the idle path (the short-circuit only runs when the render key — which bundles
2875
+ the full raw config — is unchanged), so recomputing is cheap small-JSON I/O,
2876
+ not CPU, and byte-matches a full rebuild's envelope for the same state.
2877
+ """
2878
+ import time
2879
+ doctor_payload = prior.doctor_payload
2880
+ envelope_precompute = prior.envelope_precompute
2881
+ if precompute_envelope:
2882
+ try:
2883
+ doctor_payload = _tui_precompute_doctor_payload(now_utc, runtime_bind)
2884
+ except Exception as exc: # noqa: BLE001 — never crash the rebuild
2885
+ errors.append(f"doctor-precompute: {exc}")
2886
+ try:
2887
+ envelope_precompute = _tui_precompute_envelope_config(raw_config)
2888
+ except Exception as exc: # noqa: BLE001 — never crash the rebuild
2889
+ errors.append(f"envelope-precompute: {exc}")
2890
+ return dataclasses.replace(
2891
+ prior,
2892
+ generated_at=now_utc,
2893
+ last_sync_at=time.monotonic(),
2894
+ last_sync_error=("; ".join(errors) if errors else None),
2895
+ doctor_payload=doctor_payload,
2896
+ envelope_precompute=envelope_precompute,
2897
+ )
2898
+
2899
+
2230
2900
  def _tui_empty_snapshot(now_utc: dt.datetime) -> DataSnapshot:
2231
2901
  """First-paint placeholder with no data loaded yet."""
2232
2902
  return DataSnapshot(
@@ -2657,6 +3327,13 @@ class _TuiSyncThread:
2657
3327
  trend_avg_dollars_per_pct=prev.trend_avg_dollars_per_pct,
2658
3328
  trend_history_median_dpp=prev.trend_history_median_dpp,
2659
3329
  forecast_view=prev.forecast_view,
3330
+ # #268 M4 (Codex F6): carry the precomputed doctor payload
3331
+ # + config/update-state forward so the envelope stays a
3332
+ # pure renderer (and the doctor chip stays populated)
3333
+ # across a transient sync crash, instead of dropping to the
3334
+ # dataclass defaults and re-forking `security` per client.
3335
+ doctor_payload=prev.doctor_payload,
3336
+ envelope_precompute=prev.envelope_precompute,
2660
3337
  ))
2661
3338
  # Wait up to interval, or until forced.
2662
3339
  for _ in range(int(max(1, self._interval * 10))):
@@ -3295,8 +3972,16 @@ def _tui_header_strip_a(
3295
3972
  sync_age = int(time.monotonic() - snap.last_sync_at)
3296
3973
  sync_txt = f"synced {sync_age}s ago" if snap.last_sync_at is not None else "synced —"
3297
3974
  err = snap.last_sync_error
3975
+ # Preview channel (CCTALLY_CHANNEL=preview): a marker-gated PREVIEW segment
3976
+ # so a preview-channel TUI (running against a real-data snapshot) is
3977
+ # unmistakable next to the live prod one. Reuses the existing `{warn}` style
3978
+ # token, so no `_TUI_VALID_STYLE_NAMES` change is needed. Empty when the
3979
+ # marker is unset → the TUI goldens stay byte-identical.
3980
+ preview_prefix = ""
3981
+ if _cctally_core.is_preview_channel():
3982
+ preview_prefix = "{warn}PREVIEW{/} {faint}│{/} "
3298
3983
  if cw is None:
3299
- hdr = (
3984
+ hdr = preview_prefix + (
3300
3985
  f"{{bright.b}}Week — {{/}} {{faint}}│{{/}} "
3301
3986
  f"{{dim}}no data yet — run record-usage first{{/}}"
3302
3987
  )
@@ -3313,7 +3998,7 @@ def _tui_header_strip_a(
3313
3998
  # "Fcst 74% WARN" where the WARN comes from a >=90% high
3314
3999
  # projection, understating risk in the most glanceable line.
3315
4000
  fcst_pct = f"{int(round(fc.final_percent_high))}%"
3316
- hdr = (
4001
+ hdr = preview_prefix + (
3317
4002
  f"{{bright.b}}Week {format_display_dt(cw.week_start_at, runtime.display_tz, fmt='%b %d', suffix=False)}–{format_display_dt(cw.week_end_at, runtime.display_tz, fmt='%b %d', suffix=False)}{{/}} "
3318
4003
  f"{{faint}}│{{/}} Used {{{used_cls}.b}}{cw.used_pct:.1f}%{{/}} "
3319
4004
  f"{{dim}}(5h {int(cw.five_hour_pct or 0)}%){{/}} {{faint}}│{{/}} "
@@ -4405,7 +5090,8 @@ def _tui_refresh_interval_type(s: str) -> float:
4405
5090
  return v
4406
5091
 
4407
5092
 
4408
- def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override):
5093
+ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override,
5094
+ runtime_bind=None):
4409
5095
  """Return a closure that does the snapshot-rebuild + SSE-publish work.
4410
5096
 
4411
5097
  Caller MUST hold sync_lock around the call. The naming convention
@@ -4416,6 +5102,11 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override)
4416
5102
  callers that already hold ``sync_lock`` (e.g. so they can refresh OAuth
4417
5103
  + rebuild snapshot atomically without releasing between steps) reuse
4418
5104
  this body without recursive-acquire / self-deadlock.
5105
+
5106
+ ``runtime_bind`` (#268 M4) is the dashboard's actual bound host; this is a
5107
+ DASHBOARD-only factory, so every rebuild here precomputes the envelope's
5108
+ doctor / config / update-state onto the snapshot (``precompute_envelope=True``)
5109
+ — keeping ``snapshot_to_envelope`` a pure renderer across all SSE clients.
4419
5110
  """
4420
5111
  def _locked(skip_sync: bool) -> None:
4421
5112
  try:
@@ -4427,6 +5118,7 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override)
4427
5118
  snap = sys.modules["cctally"]._tui_build_snapshot(
4428
5119
  now_utc=pinned_now, skip_sync=skip_sync,
4429
5120
  display_tz_pref_override=display_tz_pref_override,
5121
+ precompute_envelope=True, runtime_bind=runtime_bind,
4430
5122
  )
4431
5123
  if skip_sync:
4432
5124
  # Mirror the startup override: suppress the monotonic sync