cctally 1.60.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.
- package/CHANGELOG.md +14 -0
- package/bin/_cctally_cache.py +323 -48
- package/bin/_cctally_core.py +8 -0
- package/bin/_cctally_dashboard.py +866 -141
- package/bin/_cctally_doctor.py +2 -0
- package/bin/_cctally_forecast.py +24 -2
- package/bin/_cctally_parser.py +28 -2
- package/bin/_cctally_tui.py +643 -16
- package/bin/_cctally_update.py +49 -9
- package/bin/_cctally_weekrefs.py +92 -2
- package/bin/_lib_doctor.py +10 -0
- package/bin/_lib_snapshot_cache.py +988 -0
- package/bin/_lib_view_models.py +107 -12
- package/bin/cctally +57 -0
- package/dashboard/static/assets/index-0jzYm75p.css +1 -0
- package/dashboard/static/assets/{index-DQH6UPc_.js → index-D_Ylyqsf.js} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-uGwZSssU.css +0 -1
package/bin/_cctally_tui.py
CHANGED
|
@@ -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(
|
|
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,
|
|
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,
|
|
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
|
-
|
|
1610
|
-
|
|
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,144 @@ 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_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"
|
|
1842
|
+
)
|
|
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
|
+
]
|
|
1862
|
+
|
|
1863
|
+
|
|
1648
1864
|
@dataclass
|
|
1649
1865
|
class TuiSessionDetail:
|
|
1650
1866
|
"""Detailed view for the Session modal (spec §4.6.4).
|
|
@@ -1843,6 +2059,8 @@ def _tui_build_snapshot(
|
|
|
1843
2059
|
now_utc: dt.datetime | None = None,
|
|
1844
2060
|
skip_sync: bool = False,
|
|
1845
2061
|
display_tz_pref_override: "str | None" = None,
|
|
2062
|
+
precompute_envelope: bool = False,
|
|
2063
|
+
runtime_bind: "str | None" = None,
|
|
1846
2064
|
) -> DataSnapshot:
|
|
1847
2065
|
"""Single-shot build of a DataSnapshot from the DB + cache.
|
|
1848
2066
|
|
|
@@ -1854,9 +2072,24 @@ def _tui_build_snapshot(
|
|
|
1854
2072
|
the lifetime of this build. Used by ``cmd_dashboard`` when ``--tz``
|
|
1855
2073
|
is supplied so the in-memory zone wins over the persisted config
|
|
1856
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.
|
|
1857
2086
|
"""
|
|
1858
2087
|
import time
|
|
1859
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()
|
|
1860
2093
|
# Resolve the display tz once per snapshot so labels rendered into
|
|
1861
2094
|
# BlocksPanelRow / future panel rows share a single zone with the
|
|
1862
2095
|
# envelope's `display` block. Routed through the shared
|
|
@@ -1866,7 +2099,7 @@ def _tui_build_snapshot(
|
|
|
1866
2099
|
# into label-FOR-LOOKUP paths like `_aggregate_monthly` keys (out of
|
|
1867
2100
|
# scope for Task 11).
|
|
1868
2101
|
_build_display_tz = _resolve_display_tz_obj(
|
|
1869
|
-
_apply_display_tz_override(
|
|
2102
|
+
_apply_display_tz_override(raw_config, display_tz_pref_override)
|
|
1870
2103
|
)
|
|
1871
2104
|
conn = open_db()
|
|
1872
2105
|
try:
|
|
@@ -1882,6 +2115,132 @@ def _tui_build_snapshot(
|
|
|
1882
2115
|
blocks_panel: list[BlocksPanelRow] = []
|
|
1883
2116
|
daily_panel: list[DailyPanelRow] = []
|
|
1884
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}")
|
|
1885
2244
|
try:
|
|
1886
2245
|
cw = _tui_build_current_week(conn, now_utc, skip_sync=skip_sync)
|
|
1887
2246
|
except Exception as exc:
|
|
@@ -1892,7 +2251,10 @@ def _tui_build_snapshot(
|
|
|
1892
2251
|
# the legacy ``ForecastOutput`` (for ``snap.forecast``, which
|
|
1893
2252
|
# the many TUI panel consumers still read) and the surface
|
|
1894
2253
|
# fields the envelope adapter used to re-derive inline.
|
|
1895
|
-
fc_view = _tui_build_forecast_view(
|
|
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
|
+
)
|
|
1896
2258
|
fc = fc_view.output if fc_view is not None else None
|
|
1897
2259
|
except Exception as exc:
|
|
1898
2260
|
errors.append(f"forecast: {exc}")
|
|
@@ -1906,6 +2268,8 @@ def _tui_build_snapshot(
|
|
|
1906
2268
|
c = _cctally()
|
|
1907
2269
|
_trend_view = c.build_trend_view(
|
|
1908
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,
|
|
1909
2273
|
)
|
|
1910
2274
|
trend = list(_trend_view.rows)
|
|
1911
2275
|
trend_avg_dpp = _trend_view.avg_dollars_per_pct
|
|
@@ -1917,7 +2281,9 @@ def _tui_build_snapshot(
|
|
|
1917
2281
|
# `skip_sync=True` is threaded through. Honor the caller's
|
|
1918
2282
|
# intent so `--no-sync` and the initial cache-only paint
|
|
1919
2283
|
# both avoid ingest latency/lock contention.
|
|
1920
|
-
sessions = _tui_build_sessions(
|
|
2284
|
+
sessions = _tui_build_sessions(
|
|
2285
|
+
now_utc, skip_sync=skip_sync, use_session_cache=True,
|
|
2286
|
+
)
|
|
1921
2287
|
except Exception as exc:
|
|
1922
2288
|
errors.append(f"sessions: {exc}")
|
|
1923
2289
|
# ---- v2 additions ----
|
|
@@ -1936,6 +2302,7 @@ def _tui_build_snapshot(
|
|
|
1936
2302
|
# TrendModal.tsx stops re-deriving it client-side.
|
|
1937
2303
|
history_view = _tui_build_weekly_history_view(
|
|
1938
2304
|
conn, now_utc, skip_sync=skip_sync, display_tz=_build_display_tz,
|
|
2305
|
+
use_weekref_cost_cache=use_weekref_cost_cache,
|
|
1939
2306
|
)
|
|
1940
2307
|
history = list(history_view.rows)
|
|
1941
2308
|
history_median_dpp = history_view.median_dpp_non_current_4w
|
|
@@ -1958,7 +2325,8 @@ def _tui_build_snapshot(
|
|
|
1958
2325
|
weekly_total_tokens = 0
|
|
1959
2326
|
try:
|
|
1960
2327
|
weekly_periods = _dashboard_build_weekly_periods(
|
|
1961
|
-
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,
|
|
1962
2330
|
)
|
|
1963
2331
|
# ``stable_sum`` (math.fsum) returns float ``0.0`` on empty rows,
|
|
1964
2332
|
# so the envelope stays byte-stable with the pre-fix ``0.0`` shape
|
|
@@ -1981,6 +2349,7 @@ def _tui_build_snapshot(
|
|
|
1981
2349
|
try:
|
|
1982
2350
|
monthly_periods = _dashboard_build_monthly_periods(
|
|
1983
2351
|
conn, now_utc, n=12, skip_sync=skip_sync,
|
|
2352
|
+
use_group_a_cache=True,
|
|
1984
2353
|
display_tz=_build_display_tz,
|
|
1985
2354
|
)
|
|
1986
2355
|
monthly_total_cost_usd = stable_sum(
|
|
@@ -2024,6 +2393,7 @@ def _tui_build_snapshot(
|
|
|
2024
2393
|
try:
|
|
2025
2394
|
daily_panel = _dashboard_build_daily_panel(
|
|
2026
2395
|
conn, now_utc, n=30, skip_sync=skip_sync,
|
|
2396
|
+
use_group_a_cache=True,
|
|
2027
2397
|
display_tz=_build_display_tz,
|
|
2028
2398
|
)
|
|
2029
2399
|
daily_total_cost_usd = stable_sum(
|
|
@@ -2097,6 +2467,7 @@ def _tui_build_snapshot(
|
|
|
2097
2467
|
now_utc=now_utc,
|
|
2098
2468
|
current_week=cw,
|
|
2099
2469
|
weeks_back=12,
|
|
2470
|
+
use_projects_env_cache=use_projects_env_cache,
|
|
2100
2471
|
)
|
|
2101
2472
|
except Exception as exc:
|
|
2102
2473
|
errors.append(f"projects-envelope: {exc}")
|
|
@@ -2193,7 +2564,30 @@ def _tui_build_snapshot(
|
|
|
2193
2564
|
except Exception as exc:
|
|
2194
2565
|
errors.append(f"cache-report: {exc}")
|
|
2195
2566
|
|
|
2196
|
-
|
|
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(
|
|
2197
2591
|
current_week=cw,
|
|
2198
2592
|
forecast=fc,
|
|
2199
2593
|
trend=trend,
|
|
@@ -2222,11 +2616,222 @@ def _tui_build_snapshot(
|
|
|
2222
2616
|
forecast_view=fc_view,
|
|
2223
2617
|
projects_envelope=projects_envelope_block,
|
|
2224
2618
|
cache_report=cache_report_block,
|
|
2619
|
+
doctor_payload=doctor_payload_block,
|
|
2620
|
+
envelope_precompute=envelope_precompute_block,
|
|
2225
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
|
|
2226
2631
|
finally:
|
|
2227
2632
|
conn.close()
|
|
2228
2633
|
|
|
2229
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
|
+
|
|
2230
2835
|
def _tui_empty_snapshot(now_utc: dt.datetime) -> DataSnapshot:
|
|
2231
2836
|
"""First-paint placeholder with no data loaded yet."""
|
|
2232
2837
|
return DataSnapshot(
|
|
@@ -2657,6 +3262,13 @@ class _TuiSyncThread:
|
|
|
2657
3262
|
trend_avg_dollars_per_pct=prev.trend_avg_dollars_per_pct,
|
|
2658
3263
|
trend_history_median_dpp=prev.trend_history_median_dpp,
|
|
2659
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,
|
|
2660
3272
|
))
|
|
2661
3273
|
# Wait up to interval, or until forced.
|
|
2662
3274
|
for _ in range(int(max(1, self._interval * 10))):
|
|
@@ -3295,8 +3907,16 @@ def _tui_header_strip_a(
|
|
|
3295
3907
|
sync_age = int(time.monotonic() - snap.last_sync_at)
|
|
3296
3908
|
sync_txt = f"synced {sync_age}s ago" if snap.last_sync_at is not None else "synced —"
|
|
3297
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}│{/} "
|
|
3298
3918
|
if cw is None:
|
|
3299
|
-
hdr = (
|
|
3919
|
+
hdr = preview_prefix + (
|
|
3300
3920
|
f"{{bright.b}}Week — {{/}} {{faint}}│{{/}} "
|
|
3301
3921
|
f"{{dim}}no data yet — run record-usage first{{/}}"
|
|
3302
3922
|
)
|
|
@@ -3313,7 +3933,7 @@ def _tui_header_strip_a(
|
|
|
3313
3933
|
# "Fcst 74% WARN" where the WARN comes from a >=90% high
|
|
3314
3934
|
# projection, understating risk in the most glanceable line.
|
|
3315
3935
|
fcst_pct = f"{int(round(fc.final_percent_high))}%"
|
|
3316
|
-
hdr = (
|
|
3936
|
+
hdr = preview_prefix + (
|
|
3317
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)}{{/}} "
|
|
3318
3938
|
f"{{faint}}│{{/}} Used {{{used_cls}.b}}{cw.used_pct:.1f}%{{/}} "
|
|
3319
3939
|
f"{{dim}}(5h {int(cw.five_hour_pct or 0)}%){{/}} {{faint}}│{{/}} "
|
|
@@ -4405,7 +5025,8 @@ def _tui_refresh_interval_type(s: str) -> float:
|
|
|
4405
5025
|
return v
|
|
4406
5026
|
|
|
4407
5027
|
|
|
4408
|
-
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):
|
|
4409
5030
|
"""Return a closure that does the snapshot-rebuild + SSE-publish work.
|
|
4410
5031
|
|
|
4411
5032
|
Caller MUST hold sync_lock around the call. The naming convention
|
|
@@ -4416,6 +5037,11 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override)
|
|
|
4416
5037
|
callers that already hold ``sync_lock`` (e.g. so they can refresh OAuth
|
|
4417
5038
|
+ rebuild snapshot atomically without releasing between steps) reuse
|
|
4418
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.
|
|
4419
5045
|
"""
|
|
4420
5046
|
def _locked(skip_sync: bool) -> None:
|
|
4421
5047
|
try:
|
|
@@ -4427,6 +5053,7 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override)
|
|
|
4427
5053
|
snap = sys.modules["cctally"]._tui_build_snapshot(
|
|
4428
5054
|
now_utc=pinned_now, skip_sync=skip_sync,
|
|
4429
5055
|
display_tz_pref_override=display_tz_pref_override,
|
|
5056
|
+
precompute_envelope=True, runtime_bind=runtime_bind,
|
|
4430
5057
|
)
|
|
4431
5058
|
if skip_sync:
|
|
4432
5059
|
# Mirror the startup override: suppress the monotonic sync
|