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.
- package/CHANGELOG.md +31 -0
- package/bin/_cctally_cache.py +323 -48
- package/bin/_cctally_core.py +8 -0
- package/bin/_cctally_dashboard.py +913 -143
- 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 +676 -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 +117 -12
- package/bin/cctally +57 -0
- package/dashboard/static/assets/index-0jzYm75p.css +1 -0
- package/dashboard/static/assets/index-D_Ylyqsf.js +80 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-BatfACUX.css +0 -1
- package/dashboard/static/assets/index-VUPDfWul.js +0 -80
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,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
|
-
|
|
1610
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|