cctally 1.59.0 → 1.61.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,12 +1666,199 @@ 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,
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
+ )
1690
+ rows = list(view.rows)
1691
+ # Attach human-readable titles for the dashboard/TUI Sessions surface.
1692
+ # Reuses the conversation viewer's title derivation (AI title wins, else
1693
+ # first non-marker human line), which reads the CACHE db
1694
+ # (conversation_messages / conversation_ai_titles). Fail-soft: any cache
1695
+ # failure leaves titles ``None`` -> the client renders an em-dash. One
1696
+ # bounded indexed query (<= `limit` session ids, rides idx_conv_session_ts)
1697
+ # per snapshot build. Titles are stashed unconditionally on this
1698
+ # server-internal row; the privacy gate is applied later, at envelope
1699
+ # serialization (``snapshot_to_envelope(transcripts_visible=...)``).
1700
+ if rows:
1701
+ session_ids = [r.session_id for r in rows if r.session_id]
1702
+ try:
1703
+ conn = c.open_cache_db()
1704
+ except (sqlite3.DatabaseError, OSError):
1705
+ conn = None
1706
+ if conn is not None:
1707
+ try:
1708
+ titles = c._load_sibling(
1709
+ "_lib_conversation_query"
1710
+ )._session_titles_map(conn, session_ids)
1711
+ rows = [
1712
+ dataclasses.replace(r, title=titles.get(r.session_id))
1713
+ if r.session_id in titles else r
1714
+ for r in rows
1715
+ ]
1716
+ except (sqlite3.DatabaseError, OSError):
1717
+ pass # fail-soft: leave titles None
1718
+ finally:
1719
+ try:
1720
+ conn.close()
1721
+ except sqlite3.Error:
1722
+ pass
1723
+ return rows
1724
+
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_max_id: 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 with
1806
+ ``id > last_seen`` — expanded across ``session_id`` siblings so a resumed
1807
+ 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``) so the fetch stays a single timestamp-ordered result —
1814
+ no Python ``IN`` list to chunk, and stable first-seen model order.
1815
+ """
1816
+ c = _cctally()
1817
+ _JoinedClaudeEntry = c._JoinedClaudeEntry
1818
+ start_iso = range_start.astimezone(dt.timezone.utc).isoformat()
1819
+ end_iso = range_end.astimezone(dt.timezone.utc).isoformat()
1820
+ sql = (
1821
+ "SELECT se.timestamp_utc, se.model, se.input_tokens, se.output_tokens, "
1822
+ " se.cache_create_tokens, se.cache_read_tokens, se.source_path, "
1823
+ " sf.session_id, sf.project_path, se.cost_usd_raw, se.speed "
1824
+ "FROM session_entries se "
1825
+ "LEFT JOIN session_files sf ON sf.path = se.source_path "
1826
+ "WHERE se.timestamp_utc >= ? AND se.timestamp_utc <= ? "
1827
+ " AND se.source_path IN ("
1828
+ # Sibling paths of every session_id touched by a new row ...
1829
+ " SELECT sf2.path FROM session_files sf2 WHERE sf2.session_id IN ("
1830
+ " SELECT sf1.session_id FROM session_files sf1 "
1831
+ " JOIN (SELECT DISTINCT source_path FROM session_entries "
1832
+ " WHERE id > ?) af "
1833
+ " ON af.source_path = sf1.path "
1834
+ " WHERE sf1.session_id IS NOT NULL"
1835
+ " )"
1836
+ # ... UNION the touched files themselves (covers fallback / not-yet-
1837
+ # backfilled session_files rows keyed by filename stem).
1838
+ " UNION "
1839
+ " SELECT DISTINCT source_path FROM session_entries WHERE id > ?"
1840
+ " ) "
1841
+ "ORDER BY se.timestamp_utc ASC"
1611
1842
  )
1612
- return list(view.rows)
1843
+ rows = cache_conn.execute(
1844
+ sql, (start_iso, end_iso, last_seen_max_id, last_seen_max_id),
1845
+ ).fetchall()
1846
+ return [
1847
+ _JoinedClaudeEntry(
1848
+ timestamp=dt.datetime.fromisoformat(row[0]),
1849
+ model=row[1],
1850
+ input_tokens=row[2],
1851
+ output_tokens=row[3],
1852
+ cache_creation_tokens=row[4],
1853
+ cache_read_tokens=row[5],
1854
+ source_path=row[6],
1855
+ session_id=row[7],
1856
+ project_path=row[8],
1857
+ cost_usd=row[9],
1858
+ usage_extra=({"speed": row[10]} if row[10] is not None else None),
1859
+ )
1860
+ for row in rows
1861
+ ]
1613
1862
 
1614
1863
 
1615
1864
  @dataclass
@@ -1810,6 +2059,8 @@ def _tui_build_snapshot(
1810
2059
  now_utc: dt.datetime | None = None,
1811
2060
  skip_sync: bool = False,
1812
2061
  display_tz_pref_override: "str | None" = None,
2062
+ precompute_envelope: bool = False,
2063
+ runtime_bind: "str | None" = None,
1813
2064
  ) -> DataSnapshot:
1814
2065
  """Single-shot build of a DataSnapshot from the DB + cache.
1815
2066
 
@@ -1821,9 +2072,24 @@ def _tui_build_snapshot(
1821
2072
  the lifetime of this build. Used by ``cmd_dashboard`` when ``--tz``
1822
2073
  is supplied so the in-memory zone wins over the persisted config
1823
2074
  without modifying it. ``None`` means "respect config".
2075
+
2076
+ ``precompute_envelope`` (#268 M4, spec §6): when True, precompute the
2077
+ dashboard envelope's doctor / config / update-state reads once here (on
2078
+ the sync thread, doctor behind a short-TTL memo) and attach them to
2079
+ ``DataSnapshot.doctor_payload`` / ``.envelope_precompute`` so
2080
+ ``snapshot_to_envelope`` stays a pure renderer. Set True ONLY by the
2081
+ DASHBOARD callers (the sync-thread rebuild + the initial snapshot); the
2082
+ terminal TUI leaves it False so it never forks the `security` keychain
2083
+ subprocess it doesn't render. ``runtime_bind`` is the actual host the
2084
+ dashboard bound to, threaded into the doctor gather so
2085
+ ``safety.dashboard_bind`` reflects the running state.
1824
2086
  """
1825
2087
  import time
1826
2088
  now_utc = now_utc or dt.datetime.now(dt.timezone.utc)
2089
+ # Read config ONCE per rebuild and reuse it for the display-tz resolution
2090
+ # here AND the envelope precompute below (#268 M4) — the envelope used to
2091
+ # call ``load_config()`` twice per SSE client per tick.
2092
+ raw_config = load_config()
1827
2093
  # Resolve the display tz once per snapshot so labels rendered into
1828
2094
  # BlocksPanelRow / future panel rows share a single zone with the
1829
2095
  # envelope's `display` block. Routed through the shared
@@ -1833,7 +2099,7 @@ def _tui_build_snapshot(
1833
2099
  # into label-FOR-LOOKUP paths like `_aggregate_monthly` keys (out of
1834
2100
  # scope for Task 11).
1835
2101
  _build_display_tz = _resolve_display_tz_obj(
1836
- _apply_display_tz_override(load_config(), display_tz_pref_override)
2102
+ _apply_display_tz_override(raw_config, display_tz_pref_override)
1837
2103
  )
1838
2104
  conn = open_db()
1839
2105
  try:
@@ -1849,6 +2115,132 @@ def _tui_build_snapshot(
1849
2115
  blocks_panel: list[BlocksPanelRow] = []
1850
2116
  daily_panel: list[DailyPanelRow] = []
1851
2117
  alerts: list[dict] = []
2118
+ # ── Sync-once (spec §4, #268) ──────────────────────────────────
2119
+ # Ingest new JSONL bytes into the cache EXACTLY ONCE at the top of
2120
+ # the rebuild, then read every builder with skip_sync=True (pure
2121
+ # SQLite reads). Before this, each of the ~8 wide builders called
2122
+ # get_entries(..., skip_sync=False) which ran its own sync_cache —
2123
+ # ~8-10 redundant whole-tree globs + per-file stats per rebuild,
2124
+ # the CPU-side cost that pegs a core on a large instance.
2125
+ #
2126
+ # The caller's skip_sync flag expresses the --no-sync intent:
2127
+ # honor it by gating ONLY this top-of-rebuild ingest. Downstream
2128
+ # builders always read pure regardless (skip_sync is reassigned to
2129
+ # True below), so --no-sync means "no ingest, still read".
2130
+ do_ingest = not skip_sync
2131
+ if do_ingest:
2132
+ try:
2133
+ cache_conn = _cctally().open_cache_db()
2134
+ try:
2135
+ sync_cache(cache_conn)
2136
+ finally:
2137
+ cache_conn.close()
2138
+ except Exception as exc:
2139
+ errors.append(f"sync-cache: {exc}")
2140
+ # Force pure reads for every view builder below, independent of the
2141
+ # caller's flag: the single ingest above is the only glob per tick.
2142
+ skip_sync = True
2143
+ # ── #269 M3.1: shared per-weekref immutable-cost cache (spec §4/§6) ──
2144
+ # Gated OFF by default; flipped ON only on the dashboard sync-thread
2145
+ # NON-IDLE path below, AFTER the once-per-rebuild `reconcile_weekref_cache`
2146
+ # has run. Off ⇒ the trend / weekly-history / forecast builders compute
2147
+ # per-closed-week cost directly (CLI / TUI byte-identical). B2 (the
2148
+ # cache-report per-day cache) was DROPPED at the M2.0 byte-identity gate
2149
+ # (by_project net_usd is not associative across the day partition — see
2150
+ # the spec §5 finding note), so only the weekref cache is wired here.
2151
+ use_weekref_cost_cache = False
2152
+ # ── #269 M4.5: projects-envelope per-(project, week) cache (spec §14) ──
2153
+ # Same gating as the weekref cache: OFF by default, flipped ON only on
2154
+ # the dashboard sync-thread NON-IDLE path after the once-per-rebuild
2155
+ # `reconcile_projects_env_cache` succeeds. Off ⇒ `_build_projects_envelope`
2156
+ # does the full-window walk (CLI / drill / test byte-identical).
2157
+ use_projects_env_cache = False
2158
+ # ── Three-path dispatch — idle short-circuit (spec §3, #268 M5.1) ──
2159
+ # Compute the composite data-version signature (cheap MAX(id) descents
2160
+ # over cache.db + stats.db + the reset-event change-signal + the
2161
+ # generation counter). When it is UNCHANGED versus the last published
2162
+ # rebuild AND no wall-clock day/week/month boundary has rolled over,
2163
+ # take the IDLE path: reuse the prior snapshot's heavy period/session
2164
+ # rows and re-patch ONLY the time-derived fields + doctor-on-TTL, then
2165
+ # return — NO re-aggregation, so an idle dashboard sits near 0% CPU.
2166
+ # Dashboard-only (``precompute_envelope``) + sync-thread-only, mirroring
2167
+ # the Group A / session cache gating; the shared ``(signature,
2168
+ # snapshot)`` memo lives in ``_lib_snapshot_cache`` (single-writer here).
2169
+ # The signature is computed AFTER the top-of-rebuild ingest so a fresh
2170
+ # tail-ingested row is reflected before the idle decision is made.
2171
+ dispatch_key = None
2172
+ if precompute_envelope:
2173
+ dispatch_sig = None
2174
+ try:
2175
+ dispatch_sig = _tui_compute_dispatch_signature(conn)
2176
+ except Exception as exc:
2177
+ errors.append(f"dispatch-signature: {exc}")
2178
+ dispatch_sig = None
2179
+ if dispatch_sig is not None:
2180
+ # The idle decision keys on the DB signature AND a render key
2181
+ # that captures the config-derived inputs the composite
2182
+ # signature does NOT cover (spec §3 is DB-only): the resolved
2183
+ # display-tz override + the full raw config (display.tz,
2184
+ # alerts_settings, budget, dashboard prefs). A `POST /api/settings`
2185
+ # edit advances neither `MAX(id)` nor the reset legs, so without
2186
+ # this a settings change would idle-serve the stale envelope; a
2187
+ # render-key change forces a full rebuild that re-buckets the
2188
+ # calendar builders (their Group A cache already keys tz) and
2189
+ # refreshes every config-derived envelope block.
2190
+ render_key = (
2191
+ display_tz_pref_override,
2192
+ json.dumps(raw_config, sort_keys=True, default=str),
2193
+ )
2194
+ dispatch_key = (dispatch_sig, render_key)
2195
+ _sc = _cctally()._load_sibling("_lib_snapshot_cache")
2196
+ prior_key, prior_snap = _sc.dispatch_state()
2197
+ if (prior_snap is not None and prior_key is not None
2198
+ and dispatch_key == prior_key
2199
+ and not _snapshot_period_rolled_over(
2200
+ prior_snap, now_utc, _build_display_tz)):
2201
+ idle_snap = _tui_build_idle_snapshot(
2202
+ prior_snap, now_utc=now_utc,
2203
+ precompute_envelope=precompute_envelope,
2204
+ runtime_bind=runtime_bind, raw_config=raw_config,
2205
+ errors=errors,
2206
+ )
2207
+ _sc.store_dispatch_state(dispatch_key, idle_snap)
2208
+ return idle_snap
2209
+ # ── #269 M3.1: non-idle rebuild — reconcile the shared
2210
+ # per-weekref immutable-cost cache ONCE here (spec §4/§6),
2211
+ # before the builders run, using the dispatch-signature legs
2212
+ # already computed for the idle decision (no extra MAX(id) /
2213
+ # reset query). A SHORT-LIVED cache.db conn is opened only for
2214
+ # reconcile's new-entry watermark query (Codex-4), mirroring
2215
+ # `_tui_compute_dispatch_signature`. On success the trend /
2216
+ # weekly-history / forecast builders opt into the cache via
2217
+ # `use_weekref_cost_cache=True`; any failure leaves the flag OFF
2218
+ # (safe direct-compute fallback, byte-identical output).
2219
+ try:
2220
+ _rc_cache_conn = _cctally().open_cache_db()
2221
+ try:
2222
+ _sc.reconcile_weekref_cache(
2223
+ _rc_cache_conn,
2224
+ max_entry_id=dispatch_sig.max_entry_id,
2225
+ reset_sig=dispatch_sig.reset_sig,
2226
+ )
2227
+ use_weekref_cost_cache = True
2228
+ # #269 M4.5: reconcile the projects-envelope cache with
2229
+ # the SAME short-lived cache conn (session_files_sig +
2230
+ # the new-entry watermark both live in cache.db). Only
2231
+ # after this succeeds does `_build_projects_envelope`
2232
+ # opt into the cache below.
2233
+ _sc.reconcile_projects_env_cache(
2234
+ _rc_cache_conn,
2235
+ max_entry_id=dispatch_sig.max_entry_id,
2236
+ max_wus_id=dispatch_sig.max_wus_id,
2237
+ sf_sig=_sc.session_files_sig(_rc_cache_conn),
2238
+ )
2239
+ use_projects_env_cache = True
2240
+ finally:
2241
+ _rc_cache_conn.close()
2242
+ except Exception as exc:
2243
+ errors.append(f"snapshot-cache-reconcile: {exc}")
1852
2244
  try:
1853
2245
  cw = _tui_build_current_week(conn, now_utc, skip_sync=skip_sync)
1854
2246
  except Exception as exc:
@@ -1859,7 +2251,10 @@ def _tui_build_snapshot(
1859
2251
  # the legacy ``ForecastOutput`` (for ``snap.forecast``, which
1860
2252
  # the many TUI panel consumers still read) and the surface
1861
2253
  # fields the envelope adapter used to re-derive inline.
1862
- fc_view = _tui_build_forecast_view(conn, now_utc, skip_sync=skip_sync)
2254
+ fc_view = _tui_build_forecast_view(
2255
+ conn, now_utc, skip_sync=skip_sync,
2256
+ use_weekref_cost_cache=use_weekref_cost_cache,
2257
+ )
1863
2258
  fc = fc_view.output if fc_view is not None else None
1864
2259
  except Exception as exc:
1865
2260
  errors.append(f"forecast: {exc}")
@@ -1873,6 +2268,8 @@ def _tui_build_snapshot(
1873
2268
  c = _cctally()
1874
2269
  _trend_view = c.build_trend_view(
1875
2270
  conn, now_utc=now_utc, n=8, display_tz=_build_display_tz,
2271
+ skip_sync=skip_sync,
2272
+ use_weekref_cost_cache=use_weekref_cost_cache,
1876
2273
  )
1877
2274
  trend = list(_trend_view.rows)
1878
2275
  trend_avg_dpp = _trend_view.avg_dollars_per_pct
@@ -1884,7 +2281,9 @@ def _tui_build_snapshot(
1884
2281
  # `skip_sync=True` is threaded through. Honor the caller's
1885
2282
  # intent so `--no-sync` and the initial cache-only paint
1886
2283
  # both avoid ingest latency/lock contention.
1887
- sessions = _tui_build_sessions(now_utc, skip_sync=skip_sync)
2284
+ sessions = _tui_build_sessions(
2285
+ now_utc, skip_sync=skip_sync, use_session_cache=True,
2286
+ )
1888
2287
  except Exception as exc:
1889
2288
  errors.append(f"sessions: {exc}")
1890
2289
  # ---- v2 additions ----
@@ -1903,6 +2302,7 @@ def _tui_build_snapshot(
1903
2302
  # TrendModal.tsx stops re-deriving it client-side.
1904
2303
  history_view = _tui_build_weekly_history_view(
1905
2304
  conn, now_utc, skip_sync=skip_sync, display_tz=_build_display_tz,
2305
+ use_weekref_cost_cache=use_weekref_cost_cache,
1906
2306
  )
1907
2307
  history = list(history_view.rows)
1908
2308
  history_median_dpp = history_view.median_dpp_non_current_4w
@@ -1925,7 +2325,8 @@ def _tui_build_snapshot(
1925
2325
  weekly_total_tokens = 0
1926
2326
  try:
1927
2327
  weekly_periods = _dashboard_build_weekly_periods(
1928
- conn, now_utc, n=12, skip_sync=skip_sync
2328
+ conn, now_utc, n=12, skip_sync=skip_sync,
2329
+ use_group_a_cache=True,
1929
2330
  )
1930
2331
  # ``stable_sum`` (math.fsum) returns float ``0.0`` on empty rows,
1931
2332
  # so the envelope stays byte-stable with the pre-fix ``0.0`` shape
@@ -1948,6 +2349,7 @@ def _tui_build_snapshot(
1948
2349
  try:
1949
2350
  monthly_periods = _dashboard_build_monthly_periods(
1950
2351
  conn, now_utc, n=12, skip_sync=skip_sync,
2352
+ use_group_a_cache=True,
1951
2353
  display_tz=_build_display_tz,
1952
2354
  )
1953
2355
  monthly_total_cost_usd = stable_sum(
@@ -1991,6 +2393,7 @@ def _tui_build_snapshot(
1991
2393
  try:
1992
2394
  daily_panel = _dashboard_build_daily_panel(
1993
2395
  conn, now_utc, n=30, skip_sync=skip_sync,
2396
+ use_group_a_cache=True,
1994
2397
  display_tz=_build_display_tz,
1995
2398
  )
1996
2399
  daily_total_cost_usd = stable_sum(
@@ -2064,6 +2467,7 @@ def _tui_build_snapshot(
2064
2467
  now_utc=now_utc,
2065
2468
  current_week=cw,
2066
2469
  weeks_back=12,
2470
+ use_projects_env_cache=use_projects_env_cache,
2067
2471
  )
2068
2472
  except Exception as exc:
2069
2473
  errors.append(f"projects-envelope: {exc}")
@@ -2160,7 +2564,30 @@ def _tui_build_snapshot(
2160
2564
  except Exception as exc:
2161
2565
  errors.append(f"cache-report: {exc}")
2162
2566
 
2163
- return DataSnapshot(
2567
+ # ---- #268 M4: doctor / config / update-state precompute (spec §6) ----
2568
+ # Precompute the envelope's doctor / config / update-state reads ONCE
2569
+ # here (dashboard callers only), so `snapshot_to_envelope` stays a pure
2570
+ # renderer that never forks `security` / reads config.json per SSE
2571
+ # client per tick. Errors are folded into `errors` (recorded on
2572
+ # `last_sync_error`); the fields stay None on failure → the envelope
2573
+ # falls back to its inline computation, preserving behavior.
2574
+ doctor_payload_block: "dict | None" = None
2575
+ envelope_precompute_block: "dict | None" = None
2576
+ if precompute_envelope:
2577
+ try:
2578
+ doctor_payload_block = _tui_precompute_doctor_payload(
2579
+ now_utc, runtime_bind,
2580
+ )
2581
+ except Exception as exc:
2582
+ errors.append(f"doctor-precompute: {exc}")
2583
+ try:
2584
+ envelope_precompute_block = _tui_precompute_envelope_config(
2585
+ raw_config,
2586
+ )
2587
+ except Exception as exc:
2588
+ errors.append(f"envelope-precompute: {exc}")
2589
+
2590
+ snap = DataSnapshot(
2164
2591
  current_week=cw,
2165
2592
  forecast=fc,
2166
2593
  trend=trend,
@@ -2189,11 +2616,222 @@ def _tui_build_snapshot(
2189
2616
  forecast_view=fc_view,
2190
2617
  projects_envelope=projects_envelope_block,
2191
2618
  cache_report=cache_report_block,
2619
+ doctor_payload=doctor_payload_block,
2620
+ envelope_precompute=envelope_precompute_block,
2192
2621
  )
2622
+ # #268 M5.1: record the (signature+render key, snapshot) so the next
2623
+ # dashboard tick can idle-short-circuit when nothing changed. Full-build
2624
+ # path only sets it when the key was computed (precompute_envelope); the
2625
+ # TUI path never touches the dispatch memo.
2626
+ if precompute_envelope and dispatch_key is not None:
2627
+ _cctally()._load_sibling("_lib_snapshot_cache").store_dispatch_state(
2628
+ dispatch_key, snap,
2629
+ )
2630
+ return snap
2193
2631
  finally:
2194
2632
  conn.close()
2195
2633
 
2196
2634
 
2635
+ def _tui_precompute_doctor_payload(
2636
+ now_utc: dt.datetime,
2637
+ runtime_bind: "str | None",
2638
+ ) -> dict:
2639
+ """Precompute the dashboard envelope's small doctor block, via the
2640
+ short-TTL memo (#268 M4, spec §6).
2641
+
2642
+ Returns the SAME ``{severity, counts, generated_at, fingerprint}`` dict
2643
+ ``snapshot_to_envelope`` used to build inline (or a synthetic FAIL block
2644
+ with ``_error`` on a doctor gather/checks failure), so moving the
2645
+ computation onto the snapshot is byte-identical for a given
2646
+ ``now_utc``/``runtime_bind``. Guarded by ``doctor_payload_memo`` so
2647
+ back-to-back warm rebuilds don't re-fork the `security` keychain
2648
+ subprocess every tick.
2649
+ """
2650
+ c = _cctally()
2651
+ sc = c._load_sibling("_lib_snapshot_cache")
2652
+
2653
+ def _compute(now: dt.datetime, bind: "str | None") -> dict:
2654
+ _ld = c._load_sibling("_lib_doctor")
2655
+ try:
2656
+ _doc_state = c.doctor_gather_state(now_utc=now, runtime_bind=bind)
2657
+ _doc_report = _ld.run_checks(_doc_state)
2658
+ return {
2659
+ "severity": _doc_report.overall_severity,
2660
+ "counts": dict(_doc_report.counts),
2661
+ "generated_at": _ld._iso_z(_doc_report.generated_at),
2662
+ "fingerprint": _ld.fingerprint(_doc_report),
2663
+ }
2664
+ except Exception as exc: # noqa: BLE001 — never crash the rebuild
2665
+ # Mirror `snapshot_to_envelope`'s inline FAIL fallback (dashboard
2666
+ # `_iso_z`: strftime, no microseconds) so a doctor failure serves
2667
+ # the same shape whether computed here or in the envelope.
2668
+ return {
2669
+ "severity": "fail",
2670
+ "counts": {"ok": 0, "warn": 0, "fail": 1},
2671
+ "generated_at": now.astimezone(dt.timezone.utc).strftime(
2672
+ "%Y-%m-%dT%H:%M:%SZ"
2673
+ ),
2674
+ "fingerprint": "sha1:" + ("0" * 40),
2675
+ "_error": f"{type(exc).__name__}: {exc}",
2676
+ }
2677
+
2678
+ return sc.doctor_payload_memo(
2679
+ now_utc, runtime_bind, ttl_s=sc.DOCTOR_MEMO_TTL_S, compute=_compute,
2680
+ )
2681
+
2682
+
2683
+ def _tui_precompute_envelope_config(raw_config: dict) -> dict:
2684
+ """Precompute the config / update-state reads `snapshot_to_envelope`
2685
+ needs (#268 M4, spec §6).
2686
+
2687
+ Returns ``{config, update_state, update_suppress}``: the raw
2688
+ ``load_config()`` dict (the envelope layers the display-tz override on it
2689
+ purely and reads the alerts/budget/dashboard blocks off it) plus the
2690
+ update-state / update-suppress payloads with the SAME error sentinels the
2691
+ envelope used inline, so the derived ``update`` block is unchanged.
2692
+ """
2693
+ c = _cctally()
2694
+ try:
2695
+ update_state = c._load_update_state()
2696
+ except c.UpdateError:
2697
+ update_state = {"_error": "update-state.json invalid"}
2698
+ except Exception:
2699
+ update_state = {"_error": "update-state.json read failed"}
2700
+ try:
2701
+ update_suppress = c._load_update_suppress()
2702
+ except Exception:
2703
+ update_suppress = {"skipped_versions": [], "remind_after": None}
2704
+ return {
2705
+ "config": raw_config,
2706
+ "update_state": update_state,
2707
+ "update_suppress": update_suppress,
2708
+ }
2709
+
2710
+
2711
+ def _tui_compute_dispatch_signature(stats_conn):
2712
+ """Composite data-version signature for the three-path dispatch (#268 M5.1).
2713
+
2714
+ Cheap ``MAX(id)`` b-tree descents over cache.db + stats.db, the reset-event
2715
+ change-signal, and the module generation counter (spec §3). ``stats_conn``
2716
+ is the already-open stats.db connection the rebuild holds; a throwaway
2717
+ cache.db connection is opened for the ``session_entries`` / ``codex`` legs.
2718
+ ``compute_signature`` never raises (each leg degrades to 0 on a missing
2719
+ table); a cache-open failure propagates so the caller skips the idle path
2720
+ and does a full rebuild.
2721
+ """
2722
+ c = _cctally()
2723
+ sc = c._load_sibling("_lib_snapshot_cache")
2724
+ cache_conn = c.open_cache_db()
2725
+ try:
2726
+ return sc.compute_signature(
2727
+ cache_conn, stats_conn, generation=sc.current_generation(),
2728
+ )
2729
+ finally:
2730
+ cache_conn.close()
2731
+
2732
+
2733
+ def _snapshot_period_rolled_over(prior, now_utc, display_tz):
2734
+ """True if a day/week/month boundary was crossed since ``prior`` was built.
2735
+
2736
+ The idle short-circuit reuses ``prior``'s period rows, which is only valid
2737
+ while the current day/week/month is unchanged (spec §3). A day change in the
2738
+ display tz subsumes a month change (a new month starts on a new day), so
2739
+ daily+monthly reduce to one day-label compare. Weekly rollover is checked
2740
+ against the subscription week's absolute boundaries carried on
2741
+ ``prior.current_week`` (reset-anchored UTC datetimes) — a week can roll at a
2742
+ mid-day reset hour without the calendar day changing.
2743
+
2744
+ The 5-hour block is also boundary-checked here (#268 M5 Finding 4): a 5h
2745
+ reset with zero new usage across a full window on a truly-idle dashboard
2746
+ advances no DB signature and crosses no calendar day, so without this leg the
2747
+ idle path would keep serving the prior "current block" past its reset. The
2748
+ reset-anchored UTC instant carried on ``prior.current_week.five_hour_resets_at``
2749
+ forces a full rebuild once ``now`` reaches it, re-anchoring the block.
2750
+ """
2751
+ prev = getattr(prior, "generated_at", None)
2752
+ if prev is None:
2753
+ return True
2754
+
2755
+ def _day(dtm):
2756
+ # Day label in the resolved display tz — matches how the daily builder
2757
+ # buckets (display tz, host-local when None). Not a user-facing render,
2758
+ # only a same-day equality check.
2759
+ localized = dtm.astimezone(display_tz) if display_tz is not None \
2760
+ else dtm.astimezone() # internal fallback: host-local intentional
2761
+ return localized.strftime("%Y-%m-%d")
2762
+
2763
+ if _day(prev) != _day(now_utc):
2764
+ return True
2765
+ cw = getattr(prior, "current_week", None)
2766
+ if cw is not None:
2767
+ week_end = getattr(cw, "week_end_at", None)
2768
+ week_start = getattr(cw, "week_start_at", None)
2769
+ if week_end is not None and now_utc >= week_end:
2770
+ return True
2771
+ if week_start is not None and now_utc < week_start:
2772
+ return True
2773
+ # 5h block rollover (#268 M5 Finding 4): the reset instant is the
2774
+ # reset-anchored UTC end of the active block (already suppressed to None
2775
+ # by `_tui_build_current_week` once it has elapsed, so a non-None value is
2776
+ # a future boundary at build time). Once now crosses it, force a full
2777
+ # rebuild so the blocks panel re-anchors even with zero new usage.
2778
+ five_hour_reset = getattr(cw, "five_hour_resets_at", None)
2779
+ if five_hour_reset is not None and now_utc >= five_hour_reset:
2780
+ return True
2781
+ return False
2782
+
2783
+
2784
+ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
2785
+ runtime_bind, raw_config, errors):
2786
+ """Fresh snapshot reusing ``prior``'s heavy rows, re-patching only the
2787
+ time-derived fields + the doctor payload / envelope precompute on each idle
2788
+ tick (spec §3 idle path).
2789
+
2790
+ ``dataclasses.replace`` builds a NEW ``DataSnapshot`` sharing ``prior``'s
2791
+ immutable row objects (never mutated in place — Codex F7), so an SSE client
2792
+ thread serializing the previously-published snapshot can't observe a torn
2793
+ value. ``generated_at`` moves to ``now_utc`` (drives the envelope's display
2794
+ block + the emitted timestamp); ``last_sync_at`` refreshes (the ingest ran,
2795
+ so "synced Xs ago" resets); the doctor payload is refreshed through the TTL
2796
+ memo so the doctor chip stays live on a long-idle dashboard (Codex F6). All
2797
+ the wall-clock countdowns / freshness ages are computed from a LIVE now at
2798
+ envelope-render time (the SSE loop passes its own ``now_utc`` /
2799
+ ``time.monotonic()``), so nothing else needs patching here.
2800
+
2801
+ ``envelope_precompute`` is ALSO refreshed each idle tick (#268 M5 Finding 1):
2802
+ since M4 the envelope reads update-state / update-suppress off the snapshot's
2803
+ ``envelope_precompute`` rather than re-reading the JSON files per client.
2804
+ ``_DashboardUpdateCheckThread`` writes ``update-state.json`` OUT OF BAND
2805
+ (advancing no DB signature and no config render key), so carrying
2806
+ ``prior.envelope_precompute`` forward would freeze the version banner on a
2807
+ long-idle / --no-sync dashboard until new usage or a config edit lands. This
2808
+ mirrors the doctor-on-idle treatment (spec §6): config is render-key-stable on
2809
+ the idle path (the short-circuit only runs when the render key — which bundles
2810
+ the full raw config — is unchanged), so recomputing is cheap small-JSON I/O,
2811
+ not CPU, and byte-matches a full rebuild's envelope for the same state.
2812
+ """
2813
+ import time
2814
+ doctor_payload = prior.doctor_payload
2815
+ envelope_precompute = prior.envelope_precompute
2816
+ if precompute_envelope:
2817
+ try:
2818
+ doctor_payload = _tui_precompute_doctor_payload(now_utc, runtime_bind)
2819
+ except Exception as exc: # noqa: BLE001 — never crash the rebuild
2820
+ errors.append(f"doctor-precompute: {exc}")
2821
+ try:
2822
+ envelope_precompute = _tui_precompute_envelope_config(raw_config)
2823
+ except Exception as exc: # noqa: BLE001 — never crash the rebuild
2824
+ errors.append(f"envelope-precompute: {exc}")
2825
+ return dataclasses.replace(
2826
+ prior,
2827
+ generated_at=now_utc,
2828
+ last_sync_at=time.monotonic(),
2829
+ last_sync_error=("; ".join(errors) if errors else None),
2830
+ doctor_payload=doctor_payload,
2831
+ envelope_precompute=envelope_precompute,
2832
+ )
2833
+
2834
+
2197
2835
  def _tui_empty_snapshot(now_utc: dt.datetime) -> DataSnapshot:
2198
2836
  """First-paint placeholder with no data loaded yet."""
2199
2837
  return DataSnapshot(
@@ -2624,6 +3262,13 @@ class _TuiSyncThread:
2624
3262
  trend_avg_dollars_per_pct=prev.trend_avg_dollars_per_pct,
2625
3263
  trend_history_median_dpp=prev.trend_history_median_dpp,
2626
3264
  forecast_view=prev.forecast_view,
3265
+ # #268 M4 (Codex F6): carry the precomputed doctor payload
3266
+ # + config/update-state forward so the envelope stays a
3267
+ # pure renderer (and the doctor chip stays populated)
3268
+ # across a transient sync crash, instead of dropping to the
3269
+ # dataclass defaults and re-forking `security` per client.
3270
+ doctor_payload=prev.doctor_payload,
3271
+ envelope_precompute=prev.envelope_precompute,
2627
3272
  ))
2628
3273
  # Wait up to interval, or until forced.
2629
3274
  for _ in range(int(max(1, self._interval * 10))):
@@ -3262,8 +3907,16 @@ def _tui_header_strip_a(
3262
3907
  sync_age = int(time.monotonic() - snap.last_sync_at)
3263
3908
  sync_txt = f"synced {sync_age}s ago" if snap.last_sync_at is not None else "synced —"
3264
3909
  err = snap.last_sync_error
3910
+ # Preview channel (CCTALLY_CHANNEL=preview): a marker-gated PREVIEW segment
3911
+ # so a preview-channel TUI (running against a real-data snapshot) is
3912
+ # unmistakable next to the live prod one. Reuses the existing `{warn}` style
3913
+ # token, so no `_TUI_VALID_STYLE_NAMES` change is needed. Empty when the
3914
+ # marker is unset → the TUI goldens stay byte-identical.
3915
+ preview_prefix = ""
3916
+ if _cctally_core.is_preview_channel():
3917
+ preview_prefix = "{warn}PREVIEW{/} {faint}│{/} "
3265
3918
  if cw is None:
3266
- hdr = (
3919
+ hdr = preview_prefix + (
3267
3920
  f"{{bright.b}}Week — {{/}} {{faint}}│{{/}} "
3268
3921
  f"{{dim}}no data yet — run record-usage first{{/}}"
3269
3922
  )
@@ -3280,7 +3933,7 @@ def _tui_header_strip_a(
3280
3933
  # "Fcst 74% WARN" where the WARN comes from a >=90% high
3281
3934
  # projection, understating risk in the most glanceable line.
3282
3935
  fcst_pct = f"{int(round(fc.final_percent_high))}%"
3283
- hdr = (
3936
+ hdr = preview_prefix + (
3284
3937
  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)}{{/}} "
3285
3938
  f"{{faint}}│{{/}} Used {{{used_cls}.b}}{cw.used_pct:.1f}%{{/}} "
3286
3939
  f"{{dim}}(5h {int(cw.five_hour_pct or 0)}%){{/}} {{faint}}│{{/}} "
@@ -4372,7 +5025,8 @@ def _tui_refresh_interval_type(s: str) -> float:
4372
5025
  return v
4373
5026
 
4374
5027
 
4375
- def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override):
5028
+ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override,
5029
+ runtime_bind=None):
4376
5030
  """Return a closure that does the snapshot-rebuild + SSE-publish work.
4377
5031
 
4378
5032
  Caller MUST hold sync_lock around the call. The naming convention
@@ -4383,6 +5037,11 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override)
4383
5037
  callers that already hold ``sync_lock`` (e.g. so they can refresh OAuth
4384
5038
  + rebuild snapshot atomically without releasing between steps) reuse
4385
5039
  this body without recursive-acquire / self-deadlock.
5040
+
5041
+ ``runtime_bind`` (#268 M4) is the dashboard's actual bound host; this is a
5042
+ DASHBOARD-only factory, so every rebuild here precomputes the envelope's
5043
+ doctor / config / update-state onto the snapshot (``precompute_envelope=True``)
5044
+ — keeping ``snapshot_to_envelope`` a pure renderer across all SSE clients.
4386
5045
  """
4387
5046
  def _locked(skip_sync: bool) -> None:
4388
5047
  try:
@@ -4394,6 +5053,7 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override)
4394
5053
  snap = sys.modules["cctally"]._tui_build_snapshot(
4395
5054
  now_utc=pinned_now, skip_sync=skip_sync,
4396
5055
  display_tz_pref_override=display_tz_pref_override,
5056
+ precompute_envelope=True, runtime_bind=runtime_bind,
4397
5057
  )
4398
5058
  if skip_sync:
4399
5059
  # Mirror the startup override: suppress the monotonic sync