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