cctally 1.65.0 → 1.67.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 +63 -0
- package/README.md +6 -4
- package/bin/_cctally_alerts.py +1 -1
- package/bin/_cctally_cache.py +203 -22
- package/bin/_cctally_cache_report.py +7 -5
- package/bin/_cctally_core.py +125 -14
- package/bin/_cctally_dashboard.py +495 -4893
- package/bin/_cctally_dashboard_cache_report.py +714 -0
- package/bin/_cctally_dashboard_conversation.py +830 -0
- package/bin/_cctally_dashboard_envelope.py +1520 -0
- package/bin/_cctally_dashboard_share.py +2012 -0
- package/bin/_cctally_db.py +158 -5
- package/bin/_cctally_doctor.py +104 -3
- package/bin/_cctally_five_hour.py +19 -3
- package/bin/_cctally_forecast.py +118 -161
- package/bin/_cctally_parser.py +1003 -777
- package/bin/_cctally_percent_breakdown.py +1 -1
- package/bin/_cctally_pricing_check.py +39 -0
- package/bin/_cctally_project.py +18 -42
- package/bin/_cctally_record.py +279 -274
- package/bin/_cctally_reporting.py +1 -1
- package/bin/_cctally_setup.py +150 -43
- package/bin/_cctally_sync_week.py +1 -1
- package/bin/_cctally_telemetry.py +3 -5
- package/bin/_cctally_transcript.py +191 -0
- package/bin/_cctally_tui.py +42 -18
- package/bin/_lib_alert_dispatch.py +1 -1
- package/bin/_lib_blocks.py +6 -1
- package/bin/_lib_conversation_anon.py +254 -0
- package/bin/_lib_conversation_query.py +66 -0
- package/bin/_lib_credit.py +133 -0
- package/bin/_lib_diff_kernel.py +19 -7
- package/bin/_lib_doctor.py +150 -1
- package/bin/_lib_five_hour.py +61 -0
- package/bin/_lib_forecast.py +144 -0
- package/bin/_lib_json_envelope.py +38 -0
- package/bin/_lib_jsonl.py +115 -73
- package/bin/_lib_log.py +96 -0
- package/bin/_lib_pricing.py +47 -1
- package/bin/_lib_pricing_check.py +25 -3
- package/bin/_lib_record.py +179 -0
- package/bin/_lib_render.py +26 -11
- package/bin/_lib_snapshot_cache.py +61 -0
- package/bin/_lib_view_models.py +7 -1
- package/bin/cctally +59 -7
- package/bin/cctally-dashboard +2 -2
- package/bin/cctally-statusline +1 -0
- package/bin/cctally-transcript +5 -0
- package/bin/cctally-tui +2 -2
- package/dashboard/static/assets/index-BybNp_Di.js +80 -0
- package/dashboard/static/assets/{index-DQWNrIqu.css → index-DgAmLK65.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +13 -1
- package/dashboard/static/assets/index-CJTUCpKt.js +0 -80
|
@@ -0,0 +1,1520 @@
|
|
|
1
|
+
"""Dashboard envelope serialization (#279 S5 F2, wide scope).
|
|
2
|
+
|
|
3
|
+
Consumer-only sibling of ``bin/_cctally_dashboard.py`` — it re-imports every
|
|
4
|
+
name below, so ``bin/cctally``'s re-exports and the direct
|
|
5
|
+
``sys.modules["_cctally_dashboard"].X`` reaches (TUI accessor shims
|
|
6
|
+
``bin/_cctally_tui.py:373-378``, the pytest sites) keep resolving unchanged
|
|
7
|
+
(spec §2 re-export continuity).
|
|
8
|
+
|
|
9
|
+
What lives here (spec §4): the ``DataSnapshot`` → JSON-envelope serializer
|
|
10
|
+
call-tree — ``snapshot_to_envelope`` + ``_session_detail_to_envelope`` +
|
|
11
|
+
``_iso_z`` + ``_compute_intensity_buckets`` — plus the sync-thread-called
|
|
12
|
+
"envelope builders" satellites the dashboard docstring groups with it:
|
|
13
|
+
``_select_current_block_for_envelope``, ``_model_breakdowns_to_models``,
|
|
14
|
+
the five ``_envelope_rows_*`` + ``_ENVELOPE_AXIS_MAPPERS`` +
|
|
15
|
+
``_build_alerts_envelope_array``. The positional-``DataSnapshot``
|
|
16
|
+
``getattr(snap, …, None)`` / legacy-fallback branches inside
|
|
17
|
+
``snapshot_to_envelope`` are load-bearing (spec §1) — moved verbatim.
|
|
18
|
+
|
|
19
|
+
``DataSnapshot`` / ``TuiSessionDetail`` / ``DailyPanelRow`` stay in
|
|
20
|
+
``bin/cctally`` (the TUI vertical); this module only CONSUMES instances
|
|
21
|
+
(all references are in string annotations under ``from __future__ import
|
|
22
|
+
annotations``).
|
|
23
|
+
|
|
24
|
+
Cross-module reaches (spec §2 "fully-qualify cross-module refs"): the
|
|
25
|
+
cctally-forwarding accessor shims the moved code called by bare name
|
|
26
|
+
(``load_config``, ``_apply_display_tz_override``, ``_freshness_label``,
|
|
27
|
+
``_build_forecast_json_payload``, ``_warn_alerts_bad_config_once``,
|
|
28
|
+
``doctor_gather_state``, ``_load_update_state``, ``_load_update_suppress``,
|
|
29
|
+
and ``c = _cctally()``) are inlined to their ``sys.modules["cctally"].X``
|
|
30
|
+
call-time reach — identical behavior, and the ``ns["X"]`` cctally-namespace
|
|
31
|
+
patch surface is preserved (none is patched on the dashboard module object;
|
|
32
|
+
audited). ``_channel_env_fragment`` STAYS in the dashboard (grouped with the
|
|
33
|
+
port/startup wiring), so it is reached at call time via
|
|
34
|
+
``sys.modules["_cctally_dashboard"]``. ``_cache_report_snapshot_to_dict`` is
|
|
35
|
+
an honest import from ``_cctally_dashboard_cache_report`` (single source,
|
|
36
|
+
spec §4 / gate P2-2 — no duplicated function, just the bootstrap helper).
|
|
37
|
+
"""
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import bisect
|
|
41
|
+
import datetime as dt
|
|
42
|
+
import importlib.util as _ilu
|
|
43
|
+
import os
|
|
44
|
+
import sys
|
|
45
|
+
from zoneinfo import ZoneInfo
|
|
46
|
+
|
|
47
|
+
from _cctally_core import (
|
|
48
|
+
_AlertsConfigError,
|
|
49
|
+
_BudgetConfigError,
|
|
50
|
+
_budget_alerts_active,
|
|
51
|
+
_get_alerts_config,
|
|
52
|
+
_get_budget_config,
|
|
53
|
+
)
|
|
54
|
+
from _lib_display_tz import _compute_display_block, format_display_dt
|
|
55
|
+
from _lib_pricing import _chip_for_model, _short_model_name
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _ensure_sibling_loaded(name: str) -> None:
|
|
59
|
+
"""Register a NON-eager-loaded ``_cctally_dashboard_*`` sibling in ``sys.modules``.
|
|
60
|
+
|
|
61
|
+
Bootstrap-only copy (spec §4 / gate P2-2 — the FUNCTION
|
|
62
|
+
``_cache_report_snapshot_to_dict`` stays single-source in the
|
|
63
|
+
cache-report sibling; only this helper is duplicated). Mirrors
|
|
64
|
+
``bin/_cctally_record.py:_ensure_sibling_loaded`` so the honest import
|
|
65
|
+
below resolves under the ``SourceFileLoader`` harness path too.
|
|
66
|
+
"""
|
|
67
|
+
if name in sys.modules:
|
|
68
|
+
return
|
|
69
|
+
try:
|
|
70
|
+
__import__(name) # bin/ on sys.path: prod script / conftest / pytest
|
|
71
|
+
return
|
|
72
|
+
except ModuleNotFoundError:
|
|
73
|
+
pass
|
|
74
|
+
_p = os.path.join(os.path.dirname(__file__), f"{name}.py")
|
|
75
|
+
_spec = _ilu.spec_from_file_location(name, _p)
|
|
76
|
+
_mod = _ilu.module_from_spec(_spec)
|
|
77
|
+
sys.modules[name] = _mod
|
|
78
|
+
_spec.loader.exec_module(_mod)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Deliberate envelope → cache-report load edge (single source; no cycle,
|
|
82
|
+
# cache-report never references envelope). Bootstrapped, then honest-imported.
|
|
83
|
+
_ensure_sibling_loaded("_cctally_dashboard_cache_report")
|
|
84
|
+
from _cctally_dashboard_cache_report import _cache_report_snapshot_to_dict
|
|
85
|
+
|
|
86
|
+
# #279 S6 W4: the canonical None-safe UTC-Z serializer (its own former local
|
|
87
|
+
# copy was already exactly this — collapse to the single definition).
|
|
88
|
+
_ensure_sibling_loaded("_lib_json_envelope")
|
|
89
|
+
from _lib_json_envelope import _iso_z
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _model_breakdowns_to_models(model_breakdowns: list[dict[str, Any]],
|
|
93
|
+
period_cost: float) -> list[dict[str, Any]]:
|
|
94
|
+
"""Reshape a BucketUsage.model_breakdowns list (sorted desc by cost)
|
|
95
|
+
into the dashboard envelope's `models` shape: short display name,
|
|
96
|
+
chip key, and cost percentage of the period."""
|
|
97
|
+
out: list[dict[str, Any]] = []
|
|
98
|
+
for mb in model_breakdowns:
|
|
99
|
+
canonical = mb["modelName"]
|
|
100
|
+
cost = float(mb["cost"])
|
|
101
|
+
pct = (cost / period_cost * 100.0) if period_cost > 0 else 0.0
|
|
102
|
+
# Display: reuse the canonical short-name helper (strips "claude-"
|
|
103
|
+
# prefix and any trailing "-YYYYMMDD" date suffix via regex).
|
|
104
|
+
display = _short_model_name(canonical)
|
|
105
|
+
out.append({
|
|
106
|
+
"model": canonical,
|
|
107
|
+
"display": display,
|
|
108
|
+
"chip": _chip_for_model(canonical),
|
|
109
|
+
"cost_usd": cost,
|
|
110
|
+
"cost_pct": pct,
|
|
111
|
+
})
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _compute_intensity_buckets(rows: "list[DailyPanelRow]") -> list[float]:
|
|
116
|
+
"""Mutates rows in place to set `intensity_bucket`. Returns the
|
|
117
|
+
threshold cut points (length 5 when any non-zero days exist, [] otherwise).
|
|
118
|
+
|
|
119
|
+
Differs from spec §5.1 pseudocode: the spec saturates the largest of
|
|
120
|
+
5 distinct values at bucket 4; this variant correctly reaches bucket
|
|
121
|
+
5. See `test_compute_intensity_buckets_quintile_distribution`.
|
|
122
|
+
|
|
123
|
+
Bucket 0 → cost == 0 (always).
|
|
124
|
+
Buckets 1..5 → quintile over non-zero days only, so a string of
|
|
125
|
+
zero-cost days doesn't flatten the scale.
|
|
126
|
+
|
|
127
|
+
The five raw cut points are taken from the sorted non-zero costs at
|
|
128
|
+
relative positions 0/5, 1/5, 2/5, 3/5, 4/5 (treated as "lower bound
|
|
129
|
+
of bucket 1..5"). When fewer than five distinct non-zero values
|
|
130
|
+
exist (e.g. one non-zero day), duplicate cut points are kept in the
|
|
131
|
+
returned threshold list for telemetry, but bucket assignment uses a
|
|
132
|
+
deduplicated copy so a single distinct value collapses to bucket 1
|
|
133
|
+
rather than saturating at bucket 5.
|
|
134
|
+
"""
|
|
135
|
+
nonzero = sorted(r.cost_usd for r in rows if r.cost_usd > 0)
|
|
136
|
+
if not nonzero:
|
|
137
|
+
for r in rows:
|
|
138
|
+
r.intensity_bucket = 0
|
|
139
|
+
return []
|
|
140
|
+
# Five cut points at relative quintiles of the sorted non-zero days.
|
|
141
|
+
# Using i in 0..4 (rather than 1..5) makes each threshold the LOWER
|
|
142
|
+
# bound of its bucket — so a value v lands in bucket `count of
|
|
143
|
+
# thresholds <= v`, which is exactly `bisect_right(thresholds, v)`.
|
|
144
|
+
thresholds = [
|
|
145
|
+
nonzero[min(int(i / 5 * len(nonzero)), len(nonzero) - 1)]
|
|
146
|
+
for i in (0, 1, 2, 3, 4)
|
|
147
|
+
]
|
|
148
|
+
# Dedup for the bucket-assignment lookup: with one distinct non-zero
|
|
149
|
+
# value the raw list is [v]*5, and bisect_right would return 5 →
|
|
150
|
+
# bucket 5. Dedup → [v], bisect_right → 1 → bucket 1, matching the
|
|
151
|
+
# "degenerate data lands at the lowest active bucket" intent.
|
|
152
|
+
distinct = sorted(set(thresholds))
|
|
153
|
+
for r in rows:
|
|
154
|
+
if r.cost_usd == 0:
|
|
155
|
+
r.intensity_bucket = 0
|
|
156
|
+
else:
|
|
157
|
+
r.intensity_bucket = min(bisect.bisect_right(distinct, r.cost_usd), 5)
|
|
158
|
+
return thresholds
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _select_current_block_for_envelope(
|
|
162
|
+
conn: sqlite3.Connection,
|
|
163
|
+
*,
|
|
164
|
+
current_used_pct: "float | None",
|
|
165
|
+
now_utc: "dt.datetime",
|
|
166
|
+
) -> "dict | None":
|
|
167
|
+
"""Select the current 5h block for the dashboard's current_week envelope.
|
|
168
|
+
|
|
169
|
+
Selection rule (spec §4.1): pick the row from ``five_hour_blocks`` whose
|
|
170
|
+
``five_hour_window_key`` matches the latest ``weekly_usage_snapshots``
|
|
171
|
+
row's ``five_hour_window_key``. Returns None when no row matches —
|
|
172
|
+
binding to the latest snapshot's key (rather than highest
|
|
173
|
+
``block_start_at``) prevents the panel's "current 5h session" copy from
|
|
174
|
+
surfacing stale closed blocks.
|
|
175
|
+
|
|
176
|
+
The latest-snapshot lookup is filtered to ``captured_at_utc <=
|
|
177
|
+
now_utc`` so callers that pin the clock (CCTALLY_AS_OF / --as-of) get
|
|
178
|
+
a block consistent with the same pinned moment used for ``used_pct``.
|
|
179
|
+
Without this filter, a past ``now_utc`` would still pick the
|
|
180
|
+
absolute-newest snapshot's window and surface a future block's deltas
|
|
181
|
+
against past usage — mirrors ``_handle_get_session_detail``'s
|
|
182
|
+
``snap.generated_at`` plumbing for the same reason.
|
|
183
|
+
``captured_at_utc`` is stored canonical UTC-Z (see ``now_utc_iso``),
|
|
184
|
+
so a lexicographic ``<=`` compare is chronological.
|
|
185
|
+
|
|
186
|
+
Stale-block suppression: the block lookup additionally filters on
|
|
187
|
+
``is_closed = 0`` AND ``five_hour_resets_at > now_utc``. The two
|
|
188
|
+
clauses are belt-and-suspenders — ``maybe_update_five_hour_block``'s
|
|
189
|
+
natural-expiration sweep (``UPDATE … SET is_closed = 1``) only fires
|
|
190
|
+
on a ``record-usage`` tick, but the dashboard read can race ahead of
|
|
191
|
+
the next tick when the user is idle past the reset. A block that
|
|
192
|
+
matches the latest snapshot's key but whose ``five_hour_resets_at``
|
|
193
|
+
has already passed is no longer current — surfacing it would render
|
|
194
|
+
a stale "current 5h session" delta against post-reset weekly usage.
|
|
195
|
+
Returns ``None`` in that case; the React panel falls back to the
|
|
196
|
+
legacy single-big-number layout (CLAUDE.md gotcha:
|
|
197
|
+
``Dashboard current_week.five_hour_block …``).
|
|
198
|
+
|
|
199
|
+
Returned dict has snake_case keys to match the existing dashboard
|
|
200
|
+
envelope convention (``current_week`` is snake_case; CLI ``--json`` is
|
|
201
|
+
camelCase — separate conventions, see CLAUDE.md).
|
|
202
|
+
|
|
203
|
+
Delta semantics:
|
|
204
|
+
- Non-crossed block: ``current_used_pct - seven_day_pct_at_block_start``
|
|
205
|
+
(the natural "how much 7d% has changed during this 5h block" read).
|
|
206
|
+
- Crossed block (``crossed_seven_day_reset == 1``): the block straddles
|
|
207
|
+
a weekly reset, so the natural delta would be dominated by the
|
|
208
|
+
reset itself (e.g. −94pp) rather than the user's actual burn-rate.
|
|
209
|
+
Compute the POST-RESET delta instead — ``current_used_pct -
|
|
210
|
+
weekly_percent_at_first_post_reset_snapshot_in_block``. The React
|
|
211
|
+
panel prefixes this delta with ``⚡`` to show the reset crossing
|
|
212
|
+
without hiding the informative number.
|
|
213
|
+
- ``None`` only when ``current_used_pct`` is unknown OR the
|
|
214
|
+
block-start anchor is missing AND no post-reset anchor was found.
|
|
215
|
+
"""
|
|
216
|
+
snap = conn.execute(
|
|
217
|
+
"""
|
|
218
|
+
SELECT five_hour_window_key, week_start_at
|
|
219
|
+
FROM weekly_usage_snapshots
|
|
220
|
+
WHERE captured_at_utc <= ?
|
|
221
|
+
ORDER BY captured_at_utc DESC, id DESC
|
|
222
|
+
LIMIT 1
|
|
223
|
+
""",
|
|
224
|
+
(_iso_z(now_utc),),
|
|
225
|
+
).fetchone()
|
|
226
|
+
if snap is None or snap["five_hour_window_key"] is None:
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
block = conn.execute(
|
|
230
|
+
"""
|
|
231
|
+
SELECT five_hour_window_key, block_start_at, last_observed_at_utc,
|
|
232
|
+
seven_day_pct_at_block_start,
|
|
233
|
+
crossed_seven_day_reset
|
|
234
|
+
FROM five_hour_blocks
|
|
235
|
+
WHERE five_hour_window_key = ?
|
|
236
|
+
AND is_closed = 0
|
|
237
|
+
AND five_hour_resets_at > ?
|
|
238
|
+
""",
|
|
239
|
+
(snap["five_hour_window_key"], _iso_z(now_utc)),
|
|
240
|
+
).fetchone()
|
|
241
|
+
if block is None:
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
crossed = bool(block["crossed_seven_day_reset"])
|
|
245
|
+
p_start = block["seven_day_pct_at_block_start"]
|
|
246
|
+
|
|
247
|
+
# When the block crossed a weekly reset, recompute the delta against
|
|
248
|
+
# the first post-reset snapshot inside the block instead of the
|
|
249
|
+
# pre-reset block-start anchor. Use ``unixepoch()`` because
|
|
250
|
+
# ``block_start_at`` is host-local-tz (``+03:00``) while
|
|
251
|
+
# ``captured_at_utc`` is canonical UTC-Z; lex compares mis-order
|
|
252
|
+
# mixed-offset moments. ``snap.week_start_at`` is the latest
|
|
253
|
+
# (post-reset) week's anchor, so equality on that column scopes
|
|
254
|
+
# the lookup to the current weekly window.
|
|
255
|
+
p_anchor = p_start
|
|
256
|
+
if crossed and snap["week_start_at"] is not None:
|
|
257
|
+
post = conn.execute(
|
|
258
|
+
"""
|
|
259
|
+
SELECT weekly_percent
|
|
260
|
+
FROM weekly_usage_snapshots
|
|
261
|
+
WHERE week_start_at = ?
|
|
262
|
+
AND unixepoch(captured_at_utc) >= unixepoch(?)
|
|
263
|
+
AND unixepoch(captured_at_utc) <= unixepoch(?)
|
|
264
|
+
ORDER BY captured_at_utc ASC, id ASC
|
|
265
|
+
LIMIT 1
|
|
266
|
+
""",
|
|
267
|
+
(
|
|
268
|
+
snap["week_start_at"],
|
|
269
|
+
block["block_start_at"],
|
|
270
|
+
_iso_z(now_utc),
|
|
271
|
+
),
|
|
272
|
+
).fetchone()
|
|
273
|
+
if post is not None and post["weekly_percent"] is not None:
|
|
274
|
+
p_anchor = float(post["weekly_percent"])
|
|
275
|
+
|
|
276
|
+
delta = (
|
|
277
|
+
None if (p_anchor is None or current_used_pct is None)
|
|
278
|
+
else round(current_used_pct - p_anchor, 9)
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# Spec §5.3 — in-place credit events for this 5h block's window,
|
|
282
|
+
# ascending by ``effective_reset_at_utc``. Drives the
|
|
283
|
+
# ``CurrentWeekPanel.tsx`` ``⚡ credited -Xpp`` chip and the
|
|
284
|
+
# ``CurrentWeekModal.tsx`` merged-stream 5h milestones section.
|
|
285
|
+
# Snake_case keys to match the envelope convention (see CLAUDE.md;
|
|
286
|
+
# CLI ``--json`` uses camelCase, dashboard envelope is snake_case).
|
|
287
|
+
cred_rows = conn.execute(
|
|
288
|
+
"""
|
|
289
|
+
SELECT effective_reset_at_utc, prior_percent, post_percent
|
|
290
|
+
FROM five_hour_reset_events
|
|
291
|
+
WHERE five_hour_window_key = ?
|
|
292
|
+
ORDER BY effective_reset_at_utc ASC
|
|
293
|
+
""",
|
|
294
|
+
(int(block["five_hour_window_key"]),),
|
|
295
|
+
).fetchall()
|
|
296
|
+
credits = [
|
|
297
|
+
{
|
|
298
|
+
"effective_reset_at_utc": c["effective_reset_at_utc"],
|
|
299
|
+
"prior_percent": float(c["prior_percent"]),
|
|
300
|
+
"post_percent": float(c["post_percent"]),
|
|
301
|
+
"delta_pp": round(
|
|
302
|
+
float(c["post_percent"]) - float(c["prior_percent"]), 1
|
|
303
|
+
),
|
|
304
|
+
}
|
|
305
|
+
for c in cred_rows
|
|
306
|
+
]
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
"block_start_at": block["block_start_at"],
|
|
310
|
+
"five_hour_window_key": int(block["five_hour_window_key"]),
|
|
311
|
+
"seven_day_pct_at_block_start": p_start,
|
|
312
|
+
"seven_day_pct_delta_pp": delta,
|
|
313
|
+
"crossed_seven_day_reset": crossed,
|
|
314
|
+
"credits": credits,
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# === Alerts-envelope per-axis row-mappers (Task F) =========================
|
|
319
|
+
# Each mapper turns one axis's ``alerted_at IS NOT NULL`` milestone rows into
|
|
320
|
+
# the shared envelope-item dicts. The SQL is genuinely heterogeneous per axis
|
|
321
|
+
# (Codex P0-3: distinct columns, JOINs, id shapes), so the registry unifies the
|
|
322
|
+
# *set* of axes + their ``milestone_table`` + the shared ``severity_for``
|
|
323
|
+
# authority, not the query itself. ``descriptor.milestone_table`` drives each
|
|
324
|
+
# ``FROM`` clause so the table name lives in the registry, not inlined here.
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _envelope_rows_weekly(conn, descriptor, limit, severity_for) -> list[dict]:
|
|
328
|
+
# ``reset_event_id`` (v1.7.2) segments the same (week, threshold)
|
|
329
|
+
# across pre-credit (0) and post-credit (event.id) cohorts, both
|
|
330
|
+
# of which can be alerted. The envelope id must include the
|
|
331
|
+
# segment so React's <li key={a.id}> / <tr key={a.id}> doesn't
|
|
332
|
+
# collide on the duplicate (week, threshold) pair. Older clients
|
|
333
|
+
# tolerate longer ids — the id is opaque to them; only the React
|
|
334
|
+
# key uniqueness invariant matters.
|
|
335
|
+
rows = conn.execute(
|
|
336
|
+
f"""
|
|
337
|
+
SELECT week_start_date, percent_threshold, captured_at_utc,
|
|
338
|
+
alerted_at, cumulative_cost_usd, reset_event_id
|
|
339
|
+
FROM {descriptor.milestone_table}
|
|
340
|
+
WHERE alerted_at IS NOT NULL
|
|
341
|
+
ORDER BY alerted_at DESC
|
|
342
|
+
LIMIT ?
|
|
343
|
+
""",
|
|
344
|
+
(limit,),
|
|
345
|
+
).fetchall()
|
|
346
|
+
out: list[dict] = []
|
|
347
|
+
for r in rows:
|
|
348
|
+
threshold = int(r["percent_threshold"])
|
|
349
|
+
cumulative = float(r["cumulative_cost_usd"])
|
|
350
|
+
dpp = (cumulative / threshold) if threshold else None
|
|
351
|
+
out.append({
|
|
352
|
+
"id": f"weekly:{r['week_start_date']}:{threshold}:{r['reset_event_id']}",
|
|
353
|
+
"axis": descriptor.id,
|
|
354
|
+
"threshold": threshold,
|
|
355
|
+
"severity": severity_for(threshold),
|
|
356
|
+
"crossed_at": r["captured_at_utc"],
|
|
357
|
+
"alerted_at": r["alerted_at"],
|
|
358
|
+
"context": {
|
|
359
|
+
"week_start_date": r["week_start_date"],
|
|
360
|
+
"cumulative_cost_usd": cumulative,
|
|
361
|
+
"dollars_per_percent": dpp,
|
|
362
|
+
# Round-3: parallel to the 5h context block below — both
|
|
363
|
+
# axes now expose ``reset_event_id`` so downstream
|
|
364
|
+
# clients (panel, modal, third-party consumers) can
|
|
365
|
+
# discriminate pre- vs post-credit crossings of the
|
|
366
|
+
# same (week, threshold) without scraping the
|
|
367
|
+
# envelope ``id`` string. 0 = pre-credit / no-event;
|
|
368
|
+
# event.id = post-credit segment.
|
|
369
|
+
"reset_event_id": int(r["reset_event_id"]),
|
|
370
|
+
},
|
|
371
|
+
})
|
|
372
|
+
return out
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _envelope_rows_five_hour(conn, descriptor, limit, severity_for) -> list[dict]:
|
|
376
|
+
# Site F (spec §3.2 bucket C / §3.3): widen the row identity to
|
|
377
|
+
# include ``reset_event_id`` so post-credit (seg=event.id) crossings
|
|
378
|
+
# of the same (window_key, threshold) don't collide with pre-credit
|
|
379
|
+
# (seg=0) crossings on the React row key. Older clients tolerate
|
|
380
|
+
# longer ids — the id is opaque to them; only the React key
|
|
381
|
+
# uniqueness invariant matters. Mirrors the weekly precedent.
|
|
382
|
+
rows = conn.execute(
|
|
383
|
+
f"""
|
|
384
|
+
SELECT m.five_hour_window_key, m.percent_threshold, m.captured_at_utc,
|
|
385
|
+
m.alerted_at, m.block_cost_usd, m.reset_event_id,
|
|
386
|
+
b.block_start_at
|
|
387
|
+
FROM {descriptor.milestone_table} m
|
|
388
|
+
LEFT JOIN five_hour_blocks b ON b.five_hour_window_key = m.five_hour_window_key
|
|
389
|
+
WHERE m.alerted_at IS NOT NULL
|
|
390
|
+
ORDER BY m.alerted_at DESC
|
|
391
|
+
LIMIT ?
|
|
392
|
+
""",
|
|
393
|
+
(limit,),
|
|
394
|
+
).fetchall()
|
|
395
|
+
out: list[dict] = []
|
|
396
|
+
for r in rows:
|
|
397
|
+
threshold = int(r["percent_threshold"])
|
|
398
|
+
out.append({
|
|
399
|
+
"id": (
|
|
400
|
+
f"five_hour:{int(r['five_hour_window_key'])}:"
|
|
401
|
+
f"{threshold}:{int(r['reset_event_id'])}"
|
|
402
|
+
),
|
|
403
|
+
"axis": descriptor.id,
|
|
404
|
+
"threshold": threshold,
|
|
405
|
+
"severity": severity_for(threshold),
|
|
406
|
+
"crossed_at": r["captured_at_utc"],
|
|
407
|
+
"alerted_at": r["alerted_at"],
|
|
408
|
+
"context": {
|
|
409
|
+
"five_hour_window_key": int(r["five_hour_window_key"]),
|
|
410
|
+
"block_start_at": r["block_start_at"] or "",
|
|
411
|
+
"block_cost_usd": float(r["block_cost_usd"] or 0.0),
|
|
412
|
+
"reset_event_id": int(r["reset_event_id"]),
|
|
413
|
+
},
|
|
414
|
+
})
|
|
415
|
+
return out
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _envelope_rows_budget_family(conn, descriptor, limit, severity_for) -> list[dict]:
|
|
419
|
+
# Unified vendor-tagged budget axis (#143). ONE mapper backs BOTH the
|
|
420
|
+
# ``budget`` (``vendor='claude'``, issue #19) and ``codex_budget``
|
|
421
|
+
# (``vendor='codex'``, calendar-period-codex-budgets spec §6) axes —
|
|
422
|
+
# ``descriptor.vendor`` drives the ``WHERE vendor=?`` row filter + the
|
|
423
|
+
# ``COALESCE(period, <vendor-default-noun>)`` default, ``descriptor.id`` the
|
|
424
|
+
# envelope id prefix, ``descriptor.milestone_table`` (now ``budget_milestones``
|
|
425
|
+
# for both) the source table.
|
|
426
|
+
#
|
|
427
|
+
# Budget alerts are keyed by ``period_start_at`` (the resolved period-window
|
|
428
|
+
# start instant — a subscription-week start for claude OR a calendar
|
|
429
|
+
# period-start for codex) + the write-once ``period`` discriminator (#137) +
|
|
430
|
+
# the integer threshold. No ``reset_event_id`` segment: a mid-week reset
|
|
431
|
+
# (claude) or a period rollover (codex) re-anchors ``period_start_at`` so the
|
|
432
|
+
# new window naturally gets fresh rows under
|
|
433
|
+
# ``UNIQUE(vendor, period_start_at, period, threshold)``.
|
|
434
|
+
#
|
|
435
|
+
# All numbers + the ``period`` noun are read FROM THE ROW (snapshotted at
|
|
436
|
+
# crossing), never live config that may have changed since (the Codex P0-4
|
|
437
|
+
# lesson; Symptom 1 fix) — a user who fires alerts then switches
|
|
438
|
+
# ``budget.period`` keeps the historical row's noun. ``COALESCE(period, …)``
|
|
439
|
+
# renders a pre-011 NULL-sentinel row with the vendor-default noun and keeps
|
|
440
|
+
# the ``id`` non-``None``; the id's ``period`` segment gives a calendar-week ↔
|
|
441
|
+
# calendar-month coinciding-instant collision (now distinct coexisting rows)
|
|
442
|
+
# distinct React keys.
|
|
443
|
+
#
|
|
444
|
+
# Byte-stable identity: the envelope id ==
|
|
445
|
+
# ``f"{descriptor.id}:{period_start_at}:{period}:{threshold}"`` matches the
|
|
446
|
+
# pre-#143 ``budget:…`` / ``codex_budget:…`` strings exactly (same instant
|
|
447
|
+
# value, renamed key column). The per-vendor context dict KEY ORDER matches
|
|
448
|
+
# the pre-#143 envelopes verbatim: the claude/``budget`` axis still emits BOTH
|
|
449
|
+
# the legacy ``week_start_at`` AND ``period_start_at`` (same instant value) so
|
|
450
|
+
# no existing TS consumer of ``context.week_start_at`` breaks; the
|
|
451
|
+
# codex/``codex_budget`` axis emits ``period_start_at`` only.
|
|
452
|
+
vendor = descriptor.vendor
|
|
453
|
+
default_noun = "subscription-week" if vendor == "claude" else "calendar-month"
|
|
454
|
+
rows = conn.execute(
|
|
455
|
+
f"""
|
|
456
|
+
SELECT period_start_at,
|
|
457
|
+
COALESCE(period, ?) AS period,
|
|
458
|
+
threshold, crossed_at_utc, alerted_at,
|
|
459
|
+
budget_usd, spent_usd, consumption_pct
|
|
460
|
+
FROM {descriptor.milestone_table}
|
|
461
|
+
WHERE vendor = ? AND alerted_at IS NOT NULL
|
|
462
|
+
ORDER BY alerted_at DESC
|
|
463
|
+
LIMIT ?
|
|
464
|
+
""",
|
|
465
|
+
(default_noun, vendor, limit),
|
|
466
|
+
).fetchall()
|
|
467
|
+
out: list[dict] = []
|
|
468
|
+
for r in rows:
|
|
469
|
+
threshold = int(r["threshold"])
|
|
470
|
+
if vendor == "claude":
|
|
471
|
+
ctx = { # key order byte-stable with the pre-#143 budget envelope
|
|
472
|
+
"week_start_at": r["period_start_at"],
|
|
473
|
+
"period": r["period"],
|
|
474
|
+
"period_start_at": r["period_start_at"],
|
|
475
|
+
"budget_usd": float(r["budget_usd"]),
|
|
476
|
+
"spent_usd": float(r["spent_usd"]),
|
|
477
|
+
"consumption_pct": float(r["consumption_pct"]),
|
|
478
|
+
}
|
|
479
|
+
else:
|
|
480
|
+
ctx = { # key order byte-stable with the pre-#143 codex_budget envelope
|
|
481
|
+
"period": r["period"],
|
|
482
|
+
"period_start_at": r["period_start_at"],
|
|
483
|
+
"budget_usd": float(r["budget_usd"]),
|
|
484
|
+
"spent_usd": float(r["spent_usd"]),
|
|
485
|
+
"consumption_pct": float(r["consumption_pct"]),
|
|
486
|
+
}
|
|
487
|
+
out.append({
|
|
488
|
+
"id": (
|
|
489
|
+
f"{descriptor.id}:{r['period_start_at']}:{r['period']}:{threshold}"
|
|
490
|
+
),
|
|
491
|
+
"axis": descriptor.id,
|
|
492
|
+
"threshold": threshold,
|
|
493
|
+
"severity": severity_for(threshold),
|
|
494
|
+
"crossed_at": r["crossed_at_utc"],
|
|
495
|
+
"alerted_at": r["alerted_at"],
|
|
496
|
+
"context": ctx,
|
|
497
|
+
})
|
|
498
|
+
return out
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _envelope_rows_projected(conn, descriptor, limit, severity_for) -> list[dict]:
|
|
502
|
+
# Fourth axis (issue #121): projected-pace threshold crossings. Like
|
|
503
|
+
# budget, projected alerts re-anchor ``week_start_at`` on a mid-week
|
|
504
|
+
# reset, so there is NO ``reset_event_id`` segment — the new window gets
|
|
505
|
+
# fresh rows under ``UNIQUE(week_start_at, period, metric, threshold)``. The
|
|
506
|
+
# ``metric`` discriminator (``weekly_pct`` | ``budget_usd`` |
|
|
507
|
+
# ``codex_budget_usd``) drives the frontend's metric-aware context renderer;
|
|
508
|
+
# ``denominator`` + ``projected_value`` are rendered FROM THE ROW (the values
|
|
509
|
+
# snapshotted at crossing), never live config that may have changed since
|
|
510
|
+
# (Codex P0-4). The envelope id mirrors the dispatch payload's
|
|
511
|
+
# ``projected:<week_start_at>:<period>:<metric>:<threshold>`` shape. The
|
|
512
|
+
# write-once ``period`` discriminator (#137) carries no symptom-1 label here
|
|
513
|
+
# — projected's ``context`` is metric-driven, never a live-config period
|
|
514
|
+
# noun — so ``COALESCE(period, 'subscription-week')`` is purely for a stable
|
|
515
|
+
# non-``None`` id segment on a pre-011 NULL-sentinel row (the calendar-week ↔
|
|
516
|
+
# calendar-month within-metric collision otherwise shares a React key).
|
|
517
|
+
rows = conn.execute(
|
|
518
|
+
f"""
|
|
519
|
+
SELECT week_start_at,
|
|
520
|
+
COALESCE(period, 'subscription-week') AS period,
|
|
521
|
+
metric, threshold, projected_value,
|
|
522
|
+
denominator, crossed_at_utc, alerted_at
|
|
523
|
+
FROM {descriptor.milestone_table}
|
|
524
|
+
WHERE alerted_at IS NOT NULL
|
|
525
|
+
ORDER BY alerted_at DESC
|
|
526
|
+
LIMIT ?
|
|
527
|
+
""",
|
|
528
|
+
(limit,),
|
|
529
|
+
).fetchall()
|
|
530
|
+
out: list[dict] = []
|
|
531
|
+
for r in rows:
|
|
532
|
+
threshold = int(r["threshold"])
|
|
533
|
+
metric = str(r["metric"])
|
|
534
|
+
out.append({
|
|
535
|
+
"id": (
|
|
536
|
+
f"projected:{r['week_start_at']}:{r['period']}"
|
|
537
|
+
f":{metric}:{threshold}"
|
|
538
|
+
),
|
|
539
|
+
"axis": descriptor.id,
|
|
540
|
+
"metric": metric,
|
|
541
|
+
"threshold": threshold,
|
|
542
|
+
"severity": severity_for(threshold),
|
|
543
|
+
"crossed_at": r["crossed_at_utc"],
|
|
544
|
+
"alerted_at": r["alerted_at"],
|
|
545
|
+
"context": {
|
|
546
|
+
"week_start_at": r["week_start_at"],
|
|
547
|
+
"metric": metric,
|
|
548
|
+
"projected_value": float(r["projected_value"]),
|
|
549
|
+
"denominator": float(r["denominator"]),
|
|
550
|
+
},
|
|
551
|
+
})
|
|
552
|
+
return out
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _envelope_rows_project_budget(conn, descriptor, limit, severity_for) -> list[dict]:
|
|
556
|
+
# Fifth axis (issue #19 / #121): PER-PROJECT equiv-$ budget threshold
|
|
557
|
+
# crossings. Like the global budget axis, project-budget alerts re-anchor
|
|
558
|
+
# ``week_start_at`` on a mid-week reset, so there is NO ``reset_event_id``
|
|
559
|
+
# segment — the new window gets fresh rows under
|
|
560
|
+
# ``UNIQUE(week_start_at, project_key, threshold)``. ``project_key`` is the
|
|
561
|
+
# canonical git-root (``ProjectKey.bucket_path``); the human-readable chip
|
|
562
|
+
# context carries the project BASENAME, resolved through the production
|
|
563
|
+
# ``_resolve_project_key`` (git-root mode) so a moved/deleted repo still
|
|
564
|
+
# renders its basename from the snapshotted path (no FS dependency on a
|
|
565
|
+
# live ``.git``). ``budget_usd`` / ``spent_usd`` / ``consumption_pct`` are
|
|
566
|
+
# rendered FROM THE ROW (snapshotted at crossing), never live config that
|
|
567
|
+
# may have changed since (Codex P0-4). The envelope id mirrors the dispatch
|
|
568
|
+
# payload's ``project_budget:<week_start_at>:<project_key>:<threshold>``
|
|
569
|
+
# shape (``_build_alert_payload_project_budget``).
|
|
570
|
+
rows = conn.execute(
|
|
571
|
+
f"""
|
|
572
|
+
SELECT week_start_at, project_key, threshold, budget_usd, spent_usd,
|
|
573
|
+
consumption_pct, crossed_at_utc, alerted_at
|
|
574
|
+
FROM {descriptor.milestone_table}
|
|
575
|
+
WHERE alerted_at IS NOT NULL
|
|
576
|
+
ORDER BY alerted_at DESC
|
|
577
|
+
LIMIT ?
|
|
578
|
+
""",
|
|
579
|
+
(limit,),
|
|
580
|
+
).fetchall()
|
|
581
|
+
c = sys.modules["cctally"]
|
|
582
|
+
# Collision-aware labels via the shared primitive (#130), disambiguated
|
|
583
|
+
# across the alerted ROWS (NOT live config) to preserve the
|
|
584
|
+
# render-from-the-snapshotted-row invariant above — so a deleted/renamed
|
|
585
|
+
# config key still renders. This is intentionally a different feed than the
|
|
586
|
+
# table/notification (full config); see spec §1 Goals (Codex F1).
|
|
587
|
+
label_by_key = c._project_budget_labels(
|
|
588
|
+
sorted({r["project_key"] for r in rows})
|
|
589
|
+
)
|
|
590
|
+
out: list[dict] = []
|
|
591
|
+
for r in rows:
|
|
592
|
+
threshold = int(r["threshold"])
|
|
593
|
+
project_key = r["project_key"]
|
|
594
|
+
out.append({
|
|
595
|
+
"id": (
|
|
596
|
+
f"project_budget:{r['week_start_at']}:{project_key}:{threshold}"
|
|
597
|
+
),
|
|
598
|
+
"axis": descriptor.id,
|
|
599
|
+
"threshold": threshold,
|
|
600
|
+
"severity": severity_for(threshold),
|
|
601
|
+
"crossed_at": r["crossed_at_utc"],
|
|
602
|
+
"alerted_at": r["alerted_at"],
|
|
603
|
+
"context": {
|
|
604
|
+
"week_start_at": r["week_start_at"],
|
|
605
|
+
"project": label_by_key.get(project_key, project_key),
|
|
606
|
+
"project_key": project_key,
|
|
607
|
+
"budget_usd": float(r["budget_usd"]),
|
|
608
|
+
"spent_usd": float(r["spent_usd"]),
|
|
609
|
+
"consumption_pct": float(r["consumption_pct"]),
|
|
610
|
+
},
|
|
611
|
+
})
|
|
612
|
+
return out
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
# Keyed by ``AlertAxisDescriptor.id`` — the registry decides which axes run,
|
|
616
|
+
# in what order; this table supplies the bespoke heterogeneous row-mapper.
|
|
617
|
+
# ``budget`` (vendor='claude') and ``codex_budget`` (vendor='codex') share the
|
|
618
|
+
# one ``_envelope_rows_budget_family`` mapper (#143) — it reads
|
|
619
|
+
# ``descriptor.vendor`` for the row filter + default noun and ``descriptor.id``
|
|
620
|
+
# for the envelope id prefix, so the two axes stay distinct on the wire.
|
|
621
|
+
_ENVELOPE_AXIS_MAPPERS = {
|
|
622
|
+
"weekly": _envelope_rows_weekly,
|
|
623
|
+
"five_hour": _envelope_rows_five_hour,
|
|
624
|
+
"budget": _envelope_rows_budget_family,
|
|
625
|
+
"projected": _envelope_rows_projected,
|
|
626
|
+
"project_budget": _envelope_rows_project_budget,
|
|
627
|
+
"codex_budget": _envelope_rows_budget_family,
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _build_alerts_envelope_array(
|
|
632
|
+
conn: sqlite3.Connection,
|
|
633
|
+
limit: int = 100,
|
|
634
|
+
) -> list[dict]:
|
|
635
|
+
"""Return the ``alerts`` array for the SSE snapshot envelope.
|
|
636
|
+
|
|
637
|
+
Union of ``percent_milestones``, ``five_hour_milestones``,
|
|
638
|
+
``budget_milestones`` (vendor-tagged — backs BOTH the ``budget`` and
|
|
639
|
+
``codex_budget`` axes since #143), ``projected_milestones``, and
|
|
640
|
+
``project_budget_milestones`` rows with
|
|
641
|
+
``alerted_at IS NOT NULL``, ordered newest-first by ``alerted_at``, capped at
|
|
642
|
+
``limit`` (default 100). Single source of truth for both the dashboard panel
|
|
643
|
+
(slices to 10 client-side) and the modal (renders all 100). Forward-only
|
|
644
|
+
semantics: only rows the alert-dispatch path stamped get included; pre-deploy
|
|
645
|
+
crossings stay NULL and are intentionally invisible (spec §4.3).
|
|
646
|
+
|
|
647
|
+
All six axes share the same envelope schema; the ``axis`` field
|
|
648
|
+
(``weekly`` / ``five_hour`` / ``budget`` / ``projected`` /
|
|
649
|
+
``project_budget`` / ``codex_budget``) discriminates. The ``projected`` axis
|
|
650
|
+
additionally carries a top-level ``metric`` (``weekly_pct`` | ``budget_usd``
|
|
651
|
+
| ``codex_budget_usd``) so the frontend can pick its metric-aware context
|
|
652
|
+
renderer; ``project_budget``
|
|
653
|
+
carries the project basename + ``$spent of $budget`` in its context;
|
|
654
|
+
``budget`` + ``codex_budget`` carry a ``period`` discriminator
|
|
655
|
+
(subscription-week / calendar-week / calendar-month) so the frontend renders
|
|
656
|
+
a period-aware "Month" / "Calendar week" / "Week" label.
|
|
657
|
+
|
|
658
|
+
Per-axis ``LIMIT`` is applied at the SQL level (each query may yield
|
|
659
|
+
up to ``limit``) and the union is re-sorted + sliced — important for
|
|
660
|
+
the boundary case where one axis has ``limit`` rows and the other
|
|
661
|
+
has more recent ones that would otherwise be dropped before the
|
|
662
|
+
final sort.
|
|
663
|
+
|
|
664
|
+
**Registry-driven (Task F).** The *set* of axes, their union *order*,
|
|
665
|
+
and each axis's ``milestone_table`` come from
|
|
666
|
+
``_lib_alert_axes.AXIS_REGISTRY`` — adding a future axis is "register a
|
|
667
|
+
descriptor + add a row-mapper", not "hand-roll a parallel branch". The
|
|
668
|
+
SQL stays genuinely heterogeneous per axis (Codex P0-3: different
|
|
669
|
+
columns, JOINs, id shapes), so each descriptor pairs with a bespoke
|
|
670
|
+
row-mapper keyed by ``descriptor.id`` in ``_ENVELOPE_AXIS_MAPPERS``. The
|
|
671
|
+
shared ``severity_for`` kernel stamps the additive ``severity`` field on
|
|
672
|
+
every item (single severity authority, consumed by the frontend too).
|
|
673
|
+
"""
|
|
674
|
+
c = sys.modules["cctally"]
|
|
675
|
+
registry = c.AXIS_REGISTRY
|
|
676
|
+
severity_for = c.severity_for
|
|
677
|
+
out: list[dict] = []
|
|
678
|
+
for descriptor in registry:
|
|
679
|
+
mapper = _ENVELOPE_AXIS_MAPPERS.get(descriptor.id)
|
|
680
|
+
if mapper is None: # pragma: no cover - registry/mapper drift guard
|
|
681
|
+
continue
|
|
682
|
+
out.extend(mapper(conn, descriptor, limit, severity_for))
|
|
683
|
+
|
|
684
|
+
# Python's list.sort is stable. When two alerts share the same
|
|
685
|
+
# `alerted_at` ISO string (rare; multiple axes firing within the same
|
|
686
|
+
# millisecond), the union order (weekly, then 5h, then budget, then
|
|
687
|
+
# projected) determines the tiebreaker — no extra deterministic key is
|
|
688
|
+
# added because the spec doesn't require one.
|
|
689
|
+
out.sort(key=lambda a: a["alerted_at"], reverse=True)
|
|
690
|
+
return out[:limit]
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
694
|
+
now_utc: "dt.datetime",
|
|
695
|
+
monotonic_now: "float | None" = None,
|
|
696
|
+
oauth_usage_cfg: "dict | None" = None,
|
|
697
|
+
display_tz_pref_override: "str | None" = None,
|
|
698
|
+
runtime_bind: "str | None" = None,
|
|
699
|
+
transcripts_visible: bool = False) -> dict:
|
|
700
|
+
"""Serialize a DataSnapshot into the JSON envelope consumed by the
|
|
701
|
+
browser (design spec §2.2).
|
|
702
|
+
|
|
703
|
+
``transcripts_visible`` gates the transcript-derived session ``title``
|
|
704
|
+
(#264 S3): the key is emitted ONLY when the flag is True AND the row has
|
|
705
|
+
a title, so False (the default) fails closed for any caller that forgets
|
|
706
|
+
to pass it — no ``title`` key, no leaked prompt content. The two
|
|
707
|
+
browser-serving emit sites (``GET /api/data`` + the SSE loop) pass the
|
|
708
|
+
per-request ``_transcripts_visible_to_request()`` — the SAME predicate
|
|
709
|
+
that drives ``transcriptsEnabled`` and the per-row "open conversation"
|
|
710
|
+
button; every other caller (share builders, fixtures, tests) keeps the
|
|
711
|
+
default. ``cache_hit_pct`` (sessions) and ``cost_usd`` (trend) are plain
|
|
712
|
+
numbers and are never gated.
|
|
713
|
+
|
|
714
|
+
Pure function — no I/O on the snapshot data path. Reads
|
|
715
|
+
``config.json`` once for ``display.tz`` and once for the
|
|
716
|
+
``alerts_settings`` mirror (cheap atomic-rename reads); the
|
|
717
|
+
actual ``alerts`` array comes from the precomputed
|
|
718
|
+
``snap.alerts`` already populated by the sync thread, so
|
|
719
|
+
rendering never touches the DB.
|
|
720
|
+
|
|
721
|
+
``now_utc`` is used for wall-clock age computations (``reset_in_sec``,
|
|
722
|
+
``last_snapshot_age_sec``, etc.). ``monotonic_now`` is the caller's
|
|
723
|
+
``time.monotonic()`` snapshot, used only to compute ``sync_age_s``
|
|
724
|
+
against ``snap.last_sync_at`` (a monotonic-clock reading on the real
|
|
725
|
+
DataSnapshot). Pass ``None`` when no sync has happened yet.
|
|
726
|
+
|
|
727
|
+
``oauth_usage_cfg`` is the resolved oauth_usage block (from
|
|
728
|
+
``_get_oauth_usage_config(load_config())``) used for freshness-label
|
|
729
|
+
thresholds. Callers MUST pass a pre-resolved cfg to keep this
|
|
730
|
+
function pure (no per-tick FS reads on the dashboard hot path);
|
|
731
|
+
when ``None``, falls back to ``_OAUTH_USAGE_DEFAULTS`` directly so
|
|
732
|
+
pure-construction tests don't need to plumb config.
|
|
733
|
+
|
|
734
|
+
Panels that have no data serialize as None so the JS client can
|
|
735
|
+
render placeholders without special-casing.
|
|
736
|
+
|
|
737
|
+
Field mapping (envelope key → DataSnapshot attribute):
|
|
738
|
+
* ``current_week.*`` ← ``TuiCurrentWeek`` (dollars_per_percent,
|
|
739
|
+
five_hour_resets_at, latest_snapshot_at, spent_usd, used_pct,
|
|
740
|
+
five_hour_pct). ``week_label`` and ``reset_at_utc`` are
|
|
741
|
+
synthesized from ``week_start_at`` / ``week_end_at``.
|
|
742
|
+
* ``forecast.*`` ← ``ForecastOutput`` (inputs.confidence,
|
|
743
|
+
inputs.dollars_per_percent, r_avg / r_recent, inputs.p_now /
|
|
744
|
+
remaining_hours, budgets[]). ``week_avg_projection_pct`` and
|
|
745
|
+
``recent_24h_projection_pct`` are computed per-method from their
|
|
746
|
+
source rates so labels stay correct on decelerating weeks
|
|
747
|
+
(r_recent < r_avg); ``recent_24h`` is omitted when ``r_recent``
|
|
748
|
+
is None or the two projections match. ``budget_100_per_day_usd``
|
|
749
|
+
/ ``budget_90_per_day_usd`` pick the matching
|
|
750
|
+
``BudgetRow.dollars_per_day``.
|
|
751
|
+
* ``trend.weeks[]`` ← ``snap.trend`` (the 8-week panel dataset,
|
|
752
|
+
``TuiTrendRow``: week_label, used_pct, dollars_per_percent,
|
|
753
|
+
delta_dpp, is_current, spark_height). Preserves the envelope key
|
|
754
|
+
names (``label``, ``dollar_per_pct``, ``delta``) that the JS
|
|
755
|
+
consumes. Do NOT swap this for ``snap.weekly_history`` — that
|
|
756
|
+
field holds up to 12 rows for the modal (``trend.history[]``
|
|
757
|
+
below) and would overflow the panel's hard-coded "(8 weeks)"
|
|
758
|
+
header if used for ``weeks[]``.
|
|
759
|
+
* ``trend.history[]`` ← ``snap.weekly_history`` (up to 12 rows,
|
|
760
|
+
same TuiTrendRow shape as ``weeks[]``). v2-only: consumed by
|
|
761
|
+
the Trend detail modal. Sibling to ``weeks[]``, not a
|
|
762
|
+
replacement.
|
|
763
|
+
* ``sessions.rows[]`` ← ``TuiSessionRow`` (session_id, started_at,
|
|
764
|
+
duration_minutes, model_primary, project_label, cost_usd).
|
|
765
|
+
"""
|
|
766
|
+
cw = snap.current_week
|
|
767
|
+
fc = snap.forecast
|
|
768
|
+
# Issue #57 — prefer ``snap.forecast_view`` (precomputed by the
|
|
769
|
+
# sync thread via ``build_forecast_view``) over re-deriving the
|
|
770
|
+
# projection / verdict / header-routing / budget fields inline.
|
|
771
|
+
# Falls back to the legacy inline routing below when
|
|
772
|
+
# ``forecast_view`` is missing — fixture modules that construct
|
|
773
|
+
# ``DataSnapshot`` positionally without the post-Bundle-1 fields
|
|
774
|
+
# leave it at ``None``, and their goldens predate the View so
|
|
775
|
+
# keeping the legacy path under that fallback preserves byte
|
|
776
|
+
# stability.
|
|
777
|
+
fc_view = getattr(snap, "forecast_view", None)
|
|
778
|
+
|
|
779
|
+
# F1 fix: server-resolve the display tz to a CONCRETE IANA name and
|
|
780
|
+
# surface it on the envelope so the browser never has to guess "local".
|
|
781
|
+
# Reused below for week_lbl / blocks / monthly label rendering so the
|
|
782
|
+
# whole envelope speaks one zone consistently.
|
|
783
|
+
# F3 fix: when the dashboard was started with `--tz <X>`, the override
|
|
784
|
+
# supersedes the persisted config.display.tz for the lifetime of the
|
|
785
|
+
# process. The override flows in as a canonical tz token; we layer it
|
|
786
|
+
# onto the config dict via _apply_display_tz_override before resolving
|
|
787
|
+
# so every reader downstream sees one zone.
|
|
788
|
+
# #268 M4: read the config + update-state + doctor from the sync-thread
|
|
789
|
+
# precompute (spec §6) so this stays a PURE renderer — no config.json read,
|
|
790
|
+
# no `security` fork, no update-state file read per SSE client per tick.
|
|
791
|
+
# When the fields are absent (fixtures / the initial empty snapshot /
|
|
792
|
+
# positionally-constructed DataSnapshots) fall back to the inline reads so
|
|
793
|
+
# behavior is byte-identical for those callers.
|
|
794
|
+
_precomp = getattr(snap, "envelope_precompute", None)
|
|
795
|
+
_raw_config = _precomp["config"] if _precomp is not None else sys.modules["cctally"].load_config()
|
|
796
|
+
config = sys.modules["cctally"]._apply_display_tz_override(
|
|
797
|
+
_raw_config, display_tz_pref_override
|
|
798
|
+
)
|
|
799
|
+
display_block = _compute_display_block(config, snap.generated_at)
|
|
800
|
+
resolved_tz_obj = ZoneInfo(display_block["resolved_tz"])
|
|
801
|
+
|
|
802
|
+
if snap.last_sync_at is not None and monotonic_now is not None:
|
|
803
|
+
sync_age_s = max(0, int(monotonic_now - snap.last_sync_at))
|
|
804
|
+
else:
|
|
805
|
+
sync_age_s = None
|
|
806
|
+
|
|
807
|
+
# Header is a thin projection of the other panels — the JS can read
|
|
808
|
+
# from current_week / forecast directly, but pre-composing it lets
|
|
809
|
+
# tools like `curl` read a self-contained envelope.
|
|
810
|
+
used_pct = getattr(cw, "used_pct", None) if cw is not None else None
|
|
811
|
+
five_hr = getattr(cw, "five_hour_pct", None) if cw is not None else None
|
|
812
|
+
dollar_pp = getattr(cw, "dollars_per_percent", None) if cw is not None else None
|
|
813
|
+
|
|
814
|
+
# Forecast field routing (issue #57). ``snap.forecast_view`` is the
|
|
815
|
+
# single source of truth: ``build_forecast_view`` runs the per-
|
|
816
|
+
# method projection / verdict / budget routing once and stashes
|
|
817
|
+
# the surface fields on the View. The legacy inline derivation
|
|
818
|
+
# remains as a fallback for fixture modules that construct
|
|
819
|
+
# ``DataSnapshot`` positionally without populating ``forecast_view``
|
|
820
|
+
# — their goldens predate the View, and the legacy block emits
|
|
821
|
+
# the same numbers so byte stability is preserved.
|
|
822
|
+
fcast_pct: "float | None" = None
|
|
823
|
+
recent_24h_pct: "float | None" = None
|
|
824
|
+
verdict: "str | None" = None
|
|
825
|
+
confidence: "str | None" = None
|
|
826
|
+
budget_100: "float | None" = None
|
|
827
|
+
budget_90: "float | None" = None
|
|
828
|
+
if fc_view is not None:
|
|
829
|
+
fcast_pct = fc_view.week_avg_projection_pct
|
|
830
|
+
recent_24h_pct = fc_view.recent_24h_projection_pct
|
|
831
|
+
# ForecastView.dashboard_verdict / .confidence default to
|
|
832
|
+
# ``"ok"`` / ``"unknown"`` even when ``output is None``; only
|
|
833
|
+
# surface non-None envelope values when there's an actual
|
|
834
|
+
# ForecastOutput backing them so the existing
|
|
835
|
+
# ``verdict / confidence is None when fc is None`` shape stays.
|
|
836
|
+
verdict = fc_view.dashboard_verdict if fc is not None else None
|
|
837
|
+
confidence = fc_view.confidence if fc is not None else None
|
|
838
|
+
budget_100 = fc_view.budget_100_per_day_usd
|
|
839
|
+
budget_90 = fc_view.budget_90_per_day_usd
|
|
840
|
+
elif fc is not None:
|
|
841
|
+
# Legacy inline routing — kept verbatim for positionally-
|
|
842
|
+
# constructed fixture snapshots that don't carry ``forecast_view``.
|
|
843
|
+
inputs = getattr(fc, "inputs", None)
|
|
844
|
+
if inputs is not None:
|
|
845
|
+
confidence = getattr(inputs, "confidence", None)
|
|
846
|
+
r_avg = getattr(fc, "r_avg", None)
|
|
847
|
+
r_recent = getattr(fc, "r_recent", None)
|
|
848
|
+
p_now = getattr(inputs, "p_now", None) if inputs is not None else None
|
|
849
|
+
rem_hrs = getattr(inputs, "remaining_hours", None) if inputs is not None else None
|
|
850
|
+
if p_now is not None and rem_hrs is not None and r_avg is not None:
|
|
851
|
+
fcast_pct = p_now + r_avg * rem_hrs
|
|
852
|
+
if (p_now is not None and rem_hrs is not None
|
|
853
|
+
and r_recent is not None):
|
|
854
|
+
p_final_recent = p_now + r_recent * rem_hrs
|
|
855
|
+
if fcast_pct is None or p_final_recent != fcast_pct:
|
|
856
|
+
recent_24h_pct = p_final_recent
|
|
857
|
+
if getattr(fc, "already_capped", False):
|
|
858
|
+
verdict = "capped"
|
|
859
|
+
elif getattr(fc, "projected_cap", False):
|
|
860
|
+
verdict = "cap"
|
|
861
|
+
else:
|
|
862
|
+
verdict = "ok"
|
|
863
|
+
for b in getattr(fc, "budgets", []) or []:
|
|
864
|
+
tp = getattr(b, "target_percent", None)
|
|
865
|
+
dpd = getattr(b, "dollars_per_day", None)
|
|
866
|
+
if tp == 100:
|
|
867
|
+
budget_100 = dpd
|
|
868
|
+
elif tp == 90:
|
|
869
|
+
budget_90 = dpd
|
|
870
|
+
|
|
871
|
+
# Freshness envelope for current_week — derived from
|
|
872
|
+
# cw.latest_snapshot_at (a datetime, not an ISO string). None when
|
|
873
|
+
# cw is absent or has no snapshot timestamp; the React client renders
|
|
874
|
+
# a placeholder in that case. Refs spec §3.4.
|
|
875
|
+
cw_freshness: "dict | None" = None
|
|
876
|
+
if cw is not None and cw.latest_snapshot_at is not None:
|
|
877
|
+
captured = cw.latest_snapshot_at
|
|
878
|
+
if captured.tzinfo is None:
|
|
879
|
+
captured = captured.replace(tzinfo=dt.timezone.utc)
|
|
880
|
+
age_s = max(0.0, (now_utc - captured).total_seconds())
|
|
881
|
+
_fresh_cfg = (
|
|
882
|
+
oauth_usage_cfg if oauth_usage_cfg is not None
|
|
883
|
+
else sys.modules["cctally"]._OAUTH_USAGE_DEFAULTS
|
|
884
|
+
)
|
|
885
|
+
cw_freshness = {
|
|
886
|
+
"label": sys.modules["cctally"]._freshness_label(age_s, _fresh_cfg),
|
|
887
|
+
"captured_at": _iso_z(captured),
|
|
888
|
+
"age_seconds": int(age_s),
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
week_lbl: "str | None" = None
|
|
892
|
+
reset_at_utc: "dt.datetime | None" = None
|
|
893
|
+
if cw is not None:
|
|
894
|
+
ws = getattr(cw, "week_start_at", None)
|
|
895
|
+
we = getattr(cw, "week_end_at", None)
|
|
896
|
+
# Full window "Apr 13–20" so the dashboard matches the TUI/report
|
|
897
|
+
# headers and doesn't hide month crossings (e.g. "Apr 27–May 03").
|
|
898
|
+
# En-dash U+2013 matches the TUI format at cmd_tui.
|
|
899
|
+
if ws is not None and we is not None:
|
|
900
|
+
week_lbl = (
|
|
901
|
+
f"{format_display_dt(ws, resolved_tz_obj, fmt='%b %d', suffix=False)}"
|
|
902
|
+
f"–"
|
|
903
|
+
f"{format_display_dt(we, resolved_tz_obj, fmt='%b %d', suffix=False)}"
|
|
904
|
+
)
|
|
905
|
+
elif ws is not None:
|
|
906
|
+
week_lbl = format_display_dt(ws, resolved_tz_obj, fmt='%b %d', suffix=False)
|
|
907
|
+
reset_at_utc = we
|
|
908
|
+
|
|
909
|
+
# Header forecast_pct should match the projection that drove the
|
|
910
|
+
# verdict pill next to it. The View (issue #57) carries the
|
|
911
|
+
# already-routed ``header_projection_pct``; the fallback path
|
|
912
|
+
# replays the legacy routing inline for fixture snapshots that
|
|
913
|
+
# don't populate ``forecast_view``. The Forecast panel still
|
|
914
|
+
# exposes both ``week_avg_projection_pct`` and
|
|
915
|
+
# ``recent_24h_projection_pct`` unchanged.
|
|
916
|
+
if fc_view is not None and fc is not None:
|
|
917
|
+
header_fcast_pct = fc_view.header_projection_pct
|
|
918
|
+
else:
|
|
919
|
+
header_fcast_pct = fcast_pct
|
|
920
|
+
if (
|
|
921
|
+
verdict in ("cap", "capped")
|
|
922
|
+
and recent_24h_pct is not None
|
|
923
|
+
and fcast_pct is not None
|
|
924
|
+
and recent_24h_pct > fcast_pct
|
|
925
|
+
):
|
|
926
|
+
header_fcast_pct = recent_24h_pct
|
|
927
|
+
|
|
928
|
+
# ---- weekly / monthly periods ---------------------------------
|
|
929
|
+
def _weekly_row_to_dict(r: "WeeklyPeriodRow") -> dict:
|
|
930
|
+
return {
|
|
931
|
+
"label": r.label,
|
|
932
|
+
"cost_usd": r.cost_usd,
|
|
933
|
+
"total_tokens": r.total_tokens,
|
|
934
|
+
"input_tokens": r.input_tokens,
|
|
935
|
+
"output_tokens": r.output_tokens,
|
|
936
|
+
"cache_creation_tokens": r.cache_creation_tokens,
|
|
937
|
+
"cache_read_tokens": r.cache_read_tokens,
|
|
938
|
+
"used_pct": r.used_pct,
|
|
939
|
+
"dollar_per_pct": r.dollar_per_pct,
|
|
940
|
+
"delta_cost_pct": r.delta_cost_pct,
|
|
941
|
+
"is_current": r.is_current,
|
|
942
|
+
"models": list(r.models),
|
|
943
|
+
"week_start_at": r.week_start_at,
|
|
944
|
+
"week_end_at": r.week_end_at,
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
def _monthly_row_to_dict(r: "MonthlyPeriodRow") -> dict:
|
|
948
|
+
return {
|
|
949
|
+
"label": r.label,
|
|
950
|
+
"cost_usd": r.cost_usd,
|
|
951
|
+
"total_tokens": r.total_tokens,
|
|
952
|
+
"input_tokens": r.input_tokens,
|
|
953
|
+
"output_tokens": r.output_tokens,
|
|
954
|
+
"cache_creation_tokens": r.cache_creation_tokens,
|
|
955
|
+
"cache_read_tokens": r.cache_read_tokens,
|
|
956
|
+
"delta_cost_pct": r.delta_cost_pct,
|
|
957
|
+
"is_current": r.is_current,
|
|
958
|
+
"models": list(r.models),
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
def _blocks_row_to_dict(r: "BlocksPanelRow") -> dict:
|
|
962
|
+
return {
|
|
963
|
+
"start_at": r.start_at,
|
|
964
|
+
"end_at": r.end_at,
|
|
965
|
+
"anchor": r.anchor,
|
|
966
|
+
"is_active": r.is_active,
|
|
967
|
+
"cost_usd": r.cost_usd,
|
|
968
|
+
"models": list(r.models),
|
|
969
|
+
"label": r.label,
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
def _daily_row_to_dict(r: "DailyPanelRow") -> dict:
|
|
973
|
+
return {
|
|
974
|
+
"date": r.date,
|
|
975
|
+
"label": r.label,
|
|
976
|
+
"cost_usd": r.cost_usd,
|
|
977
|
+
"is_today": r.is_today,
|
|
978
|
+
"intensity_bucket": r.intensity_bucket,
|
|
979
|
+
"models": list(r.models),
|
|
980
|
+
# ---- v2.3 additions ----
|
|
981
|
+
"input_tokens": r.input_tokens,
|
|
982
|
+
"output_tokens": r.output_tokens,
|
|
983
|
+
"cache_creation_tokens": r.cache_creation_tokens,
|
|
984
|
+
"cache_read_tokens": r.cache_read_tokens,
|
|
985
|
+
"total_tokens": r.total_tokens,
|
|
986
|
+
"cache_hit_pct": r.cache_hit_pct,
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
# Spec §2.7: empty state is `weekly.rows === []`, not `weekly === null`.
|
|
990
|
+
# Always emit a `{rows: [...]}` envelope (possibly empty) so the panel
|
|
991
|
+
# can distinguish "loading / not synced yet" (null parent) from
|
|
992
|
+
# "synced + no data" (empty rows). For Weekly/Monthly, sync always
|
|
993
|
+
# builds the rows list (even if empty), so we always emit the object.
|
|
994
|
+
weekly_env = {
|
|
995
|
+
"rows": [_weekly_row_to_dict(r) for r in snap.weekly_periods],
|
|
996
|
+
# View-model unification (Bundle 1; spec §6.6): pre-computed
|
|
997
|
+
# totals. Sourced from sum-over-visible-rows in the sync thread
|
|
998
|
+
# (``_tui_build_snapshot``) so the footer total is structurally
|
|
999
|
+
# equal to the React panel's ``rows.reduce(...)``. The earlier
|
|
1000
|
+
# ``build_weekly_view``-sourced totals undercounted Bug-K
|
|
1001
|
+
# pre-credit synthesized rows on credit weeks; the regression is
|
|
1002
|
+
# captured by
|
|
1003
|
+
# ``test_weekly_envelope_total_matches_sum_of_visible_rows``.
|
|
1004
|
+
"total_cost_usd": snap.weekly_total_cost_usd,
|
|
1005
|
+
"total_tokens": snap.weekly_total_tokens,
|
|
1006
|
+
}
|
|
1007
|
+
monthly_env = {
|
|
1008
|
+
"rows": [_monthly_row_to_dict(r) for r in snap.monthly_periods],
|
|
1009
|
+
# View-model unification (Bundle 1; spec §6.6): pre-computed
|
|
1010
|
+
# totals via sum-over-visible-rows so the React MonthlyPanel's
|
|
1011
|
+
# smoking-gun ``rows.reduce(...)`` collapses to a single envelope
|
|
1012
|
+
# read with the structural-equality invariant preserved.
|
|
1013
|
+
"total_cost_usd": snap.monthly_total_cost_usd,
|
|
1014
|
+
"total_tokens": snap.monthly_total_tokens,
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
blocks_env = {
|
|
1018
|
+
"rows": [_blocks_row_to_dict(r) for r in snap.blocks_panel],
|
|
1019
|
+
# View-model unification follow-up (issue #56): additive scalars
|
|
1020
|
+
# so the React BlocksPanel can stop running `rows.reduce(...)`
|
|
1021
|
+
# in JS. Cost is summed-over-visible-rows in
|
|
1022
|
+
# `_dashboard_build_blocks_view` (same structural invariant as
|
|
1023
|
+
# daily/weekly/monthly footers); ``total_tokens`` is sourced
|
|
1024
|
+
# from the same view since ``BlocksPanelRow`` doesn't carry
|
|
1025
|
+
# token columns and we don't want to widen that shape.
|
|
1026
|
+
"total_cost_usd": snap.blocks_total_cost_usd,
|
|
1027
|
+
"total_tokens": snap.blocks_total_tokens,
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
# Re-run helper to derive thresholds; mutates rows[*].intensity_bucket
|
|
1031
|
+
# (no-op for builder-constructed rows since values match cost_usd).
|
|
1032
|
+
# Single-source-of-truth with bucket assignment — do NOT re-derive
|
|
1033
|
+
# thresholds with an independent formula.
|
|
1034
|
+
daily_rows = list(snap.daily_panel)
|
|
1035
|
+
daily_thresholds = _compute_intensity_buckets(daily_rows)
|
|
1036
|
+
daily_peak = None
|
|
1037
|
+
if daily_rows:
|
|
1038
|
+
nonzero_rows = [r for r in daily_rows if r.cost_usd > 0]
|
|
1039
|
+
if nonzero_rows:
|
|
1040
|
+
peak_row = max(nonzero_rows, key=lambda r: r.cost_usd)
|
|
1041
|
+
daily_peak = {"date": peak_row.date, "cost_usd": peak_row.cost_usd}
|
|
1042
|
+
daily_env = {
|
|
1043
|
+
"rows": [_daily_row_to_dict(r) for r in daily_rows],
|
|
1044
|
+
"quantile_thresholds": daily_thresholds,
|
|
1045
|
+
"peak": daily_peak,
|
|
1046
|
+
# View-model unification (Bundle 1; spec §6.6): pre-computed
|
|
1047
|
+
# totals via sum-over-visible-rows in the sync thread. Gap days
|
|
1048
|
+
# carry ``cost_usd=0.0`` and ``total_tokens=0`` so summing the
|
|
1049
|
+
# materialized panel rows preserves the gap-free semantics. The
|
|
1050
|
+
# React panel's `rows.reduce(...)` collapses to a single
|
|
1051
|
+
# envelope read with the structural-equality invariant
|
|
1052
|
+
# preserved.
|
|
1053
|
+
"total_cost_usd": snap.daily_total_cost_usd,
|
|
1054
|
+
"total_tokens": snap.daily_total_tokens,
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
# ---- threshold-actions T5: alerts envelope + settings mirror ----
|
|
1058
|
+
# `alerts` is precomputed at sync-thread snapshot-build time (see
|
|
1059
|
+
# `_tui_build_snapshot`'s `_build_alerts_envelope_array(conn)` call)
|
|
1060
|
+
# so this remains a pure render path (no DB I/O on dashboard hot
|
|
1061
|
+
# path). Single source of truth for both the dashboard panel
|
|
1062
|
+
# (slices to 10 client-side) and the modal (renders all 100).
|
|
1063
|
+
#
|
|
1064
|
+
# `alerts_settings` mirrors the validated alerts config block into
|
|
1065
|
+
# the envelope so the dashboard's SettingsOverlay can seed its
|
|
1066
|
+
# fieldset without a separate GET /api/settings round-trip
|
|
1067
|
+
# (parallels how `display.tz` lives at envelope["display"]["tz"]).
|
|
1068
|
+
# Defensive: a corrupt alerts block in `config.json` must not 500
|
|
1069
|
+
# the entire snapshot — fall back to safe defaults and rely on
|
|
1070
|
+
# `_warn_alerts_bad_config_once` for the user-visible signal.
|
|
1071
|
+
alerts_array = list(getattr(snap, "alerts", []) or [])
|
|
1072
|
+
# #268 M4: reuse the precompute's config (or the fallback load_config()
|
|
1073
|
+
# resolved above) — the envelope used to call load_config() a SECOND time
|
|
1074
|
+
# here. Within one tick both reads returned the same file, so this is
|
|
1075
|
+
# byte-identical.
|
|
1076
|
+
_cfg_for_alerts = _raw_config
|
|
1077
|
+
try:
|
|
1078
|
+
_alerts_cfg = _get_alerts_config(_cfg_for_alerts)
|
|
1079
|
+
except _AlertsConfigError as exc:
|
|
1080
|
+
sys.modules["cctally"]._warn_alerts_bad_config_once(exc)
|
|
1081
|
+
_alerts_cfg = {
|
|
1082
|
+
"enabled": False,
|
|
1083
|
+
"weekly_thresholds": [],
|
|
1084
|
+
"five_hour_thresholds": [],
|
|
1085
|
+
"projected_enabled": False,
|
|
1086
|
+
# Mirror the dispatch keys so the new alerts_settings lines
|
|
1087
|
+
# (`notifier` / `command_configured`) don't KeyError on a
|
|
1088
|
+
# corrupt config. Safe defaults: no notifier override, no
|
|
1089
|
+
# configured command.
|
|
1090
|
+
"notifier": "auto",
|
|
1091
|
+
"command_template": None,
|
|
1092
|
+
}
|
|
1093
|
+
# Budget is its OWN config block (issue #19) — source budget fields
|
|
1094
|
+
# from ``_get_budget_config`` (the validated ``budget`` block), NOT
|
|
1095
|
+
# the ``alerts`` block. Defensive: a corrupt budget block must not
|
|
1096
|
+
# 500 the whole snapshot — fall back to "no budget / disabled".
|
|
1097
|
+
try:
|
|
1098
|
+
_budget_cfg = _get_budget_config(_cfg_for_alerts)
|
|
1099
|
+
except _BudgetConfigError:
|
|
1100
|
+
_budget_cfg = {"weekly_usd": None, "alerts_enabled": True,
|
|
1101
|
+
"alert_thresholds": [], "projected_enabled": False,
|
|
1102
|
+
"projects": {}, "project_alerts_enabled": False}
|
|
1103
|
+
alerts_settings = {
|
|
1104
|
+
"enabled": _alerts_cfg["enabled"],
|
|
1105
|
+
"weekly_thresholds": list(_alerts_cfg["weekly_thresholds"]),
|
|
1106
|
+
"five_hour_thresholds": list(_alerts_cfg["five_hour_thresholds"]),
|
|
1107
|
+
"budget_thresholds": list(_budget_cfg["alert_thresholds"]),
|
|
1108
|
+
"budget_enabled": _budget_alerts_active(_budget_cfg),
|
|
1109
|
+
# Projected-pace opt-in mirrors (#121). Two flags, one per parent
|
|
1110
|
+
# axis — the frontend SettingsOverlay seeds two toggles. Sourced
|
|
1111
|
+
# from the validated getters' ``projected_enabled`` (default False).
|
|
1112
|
+
"projected_weekly_enabled": bool(_alerts_cfg.get("projected_enabled")),
|
|
1113
|
+
"projected_budget_enabled": bool(_budget_cfg.get("projected_enabled")),
|
|
1114
|
+
# Per-project budget alerts opt-in mirror (issue #19 / #121). Gates the
|
|
1115
|
+
# ``project_budget`` axis dispatch only (the display section always
|
|
1116
|
+
# renders configured projects). Sourced from the validated budget
|
|
1117
|
+
# getter's ``project_alerts_enabled`` (default False) — the frontend
|
|
1118
|
+
# SettingsOverlay seeds a single on/off toggle from it.
|
|
1119
|
+
"project_alerts_enabled": bool(_budget_cfg.get("project_alerts_enabled")),
|
|
1120
|
+
# Codex budget toggle mirrors (#134). The frontend SettingsOverlay
|
|
1121
|
+
# seeds two toggles (alerts + projected) + a disabled-with-hint empty
|
|
1122
|
+
# state from these three flags. ``_budget_cfg["codex"]`` is ``None`` by
|
|
1123
|
+
# default (no Codex budget) → all three default false/absent safely;
|
|
1124
|
+
# the ``_BudgetConfigError`` fallback dict above lacks a ``codex`` key,
|
|
1125
|
+
# so ``.get("codex")`` → ``None`` is likewise safe.
|
|
1126
|
+
"codex_budget_configured": _budget_cfg.get("codex") is not None,
|
|
1127
|
+
"codex_budget_alerts_enabled": bool((_budget_cfg.get("codex") or {}).get("alerts_enabled")),
|
|
1128
|
+
"codex_projected_enabled": bool((_budget_cfg.get("codex") or {}).get("projected_enabled")),
|
|
1129
|
+
# Alert-dispatch notifier mirror (Phase B). `notifier` is the
|
|
1130
|
+
# validated backend selector ("auto"/"command"/etc.). The raw
|
|
1131
|
+
# `command_template` is NEVER mirrored — it routinely holds secrets
|
|
1132
|
+
# (webhook URLs, bearer tokens) and the SSE snapshot is broadcast to
|
|
1133
|
+
# every connected client. We expose only a boolean: is a custom
|
|
1134
|
+
# command configured? (the CLI/config remains the sole writer of the
|
|
1135
|
+
# template itself).
|
|
1136
|
+
"notifier": _alerts_cfg.get("notifier", "auto"),
|
|
1137
|
+
"command_configured": _alerts_cfg.get("command_template") is not None,
|
|
1138
|
+
}
|
|
1139
|
+
# Dashboard render-prefs mirror (cache-failure-markers opt-out, spec §5).
|
|
1140
|
+
# Reuses the single `_cfg_for_alerts = load_config()` read above (no extra
|
|
1141
|
+
# FS hit on the hot path). Default true — absence is treated as ON (opt-out,
|
|
1142
|
+
# not opt-in); a hand-edited non-bool surfaces the default. The on/off toggle
|
|
1143
|
+
# is honored entirely at client render time; the kernel + API always emit the
|
|
1144
|
+
# marker data.
|
|
1145
|
+
_dash_cfg = _cfg_for_alerts.get("dashboard") if isinstance(
|
|
1146
|
+
_cfg_for_alerts.get("dashboard"), dict) else {}
|
|
1147
|
+
_cfm = _dash_cfg.get("cache_failure_markers", True)
|
|
1148
|
+
_lt = _dash_cfg.get("live_tail", True)
|
|
1149
|
+
dashboard_prefs = {
|
|
1150
|
+
"cache_failure_markers": _cfm if isinstance(_cfm, bool) else True,
|
|
1151
|
+
"live_tail": _lt if isinstance(_lt, bool) else True,
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
# Mirror update-state.json + update-suppress.json into the envelope
|
|
1155
|
+
# so the dashboard's amber "Update available" badge repaints from
|
|
1156
|
+
# the SSE channel rather than requiring a /api/update/status fetch
|
|
1157
|
+
# per check. Without this mirror, a long-open dashboard tab never
|
|
1158
|
+
# learns that the dashboard's `_DashboardUpdateCheckThread` wrote
|
|
1159
|
+
# a fresher latest_version (24h-TTL by default) — the badge stayed
|
|
1160
|
+
# hidden until manual reload. Same precedent as `alerts_settings`:
|
|
1161
|
+
# cheap atomic-rename reads on the snapshot hot path. Failures
|
|
1162
|
+
# produce a `null` block so a missing/corrupt state file doesn't
|
|
1163
|
+
# 500 the entire envelope; the client falls back to the defensive
|
|
1164
|
+
# null-state shape `coerceUpdateState` already understands.
|
|
1165
|
+
#
|
|
1166
|
+
# #268 M4: read from the sync-thread precompute (spec §6) when present so
|
|
1167
|
+
# this stays pure — no update-state file reads per SSE client. The inline
|
|
1168
|
+
# try/except is the fallback for fixtures / the initial snapshot, and keeps
|
|
1169
|
+
# the SAME error sentinels so the derived block is byte-identical.
|
|
1170
|
+
if _precomp is not None:
|
|
1171
|
+
_update_state_envelope = _precomp["update_state"]
|
|
1172
|
+
_update_suppress_envelope = _precomp["update_suppress"]
|
|
1173
|
+
else:
|
|
1174
|
+
try:
|
|
1175
|
+
_update_state_envelope = sys.modules["cctally"]._load_update_state()
|
|
1176
|
+
except sys.modules["cctally"].UpdateError:
|
|
1177
|
+
# _load_update_state() raises on truly malformed JSON. Surface
|
|
1178
|
+
# an _error sentinel so the client renders "no update info" the
|
|
1179
|
+
# same way it does for unreachable /api/update/status.
|
|
1180
|
+
_update_state_envelope = {"_error": "update-state.json invalid"}
|
|
1181
|
+
except Exception:
|
|
1182
|
+
_update_state_envelope = {"_error": "update-state.json read failed"}
|
|
1183
|
+
try:
|
|
1184
|
+
_update_suppress_envelope = sys.modules["cctally"]._load_update_suppress()
|
|
1185
|
+
except Exception:
|
|
1186
|
+
_update_suppress_envelope = {
|
|
1187
|
+
"skipped_versions": [], "remind_after": None,
|
|
1188
|
+
}
|
|
1189
|
+
update_envelope = {
|
|
1190
|
+
"state": _update_state_envelope,
|
|
1191
|
+
"suppress": _update_suppress_envelope,
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
# Doctor aggregate block (spec §5.5). Only the small severity tree
|
|
1195
|
+
# rides on every SSE tick (~120 bytes); the full per-check payload
|
|
1196
|
+
# is fetched lazily via `GET /api/doctor`. `runtime_bind` is the
|
|
1197
|
+
# actual host the dashboard process is bound to (Codex H4) so
|
|
1198
|
+
# `safety.dashboard_bind` reflects the running state, not just
|
|
1199
|
+
# `config.json`. Defensive: never crash the snapshot pipeline on
|
|
1200
|
+
# a doctor failure — surface a synthetic FAIL block with `_error`.
|
|
1201
|
+
#
|
|
1202
|
+
# #268 M4: read the precomputed doctor block from the snapshot (spec §6)
|
|
1203
|
+
# so this render path never forks the `security` keychain subprocess. The
|
|
1204
|
+
# sync thread computes it ONCE per rebuild via `doctor_payload_memo`
|
|
1205
|
+
# (`_tui_precompute_doctor_payload`). The inline try/except below is the
|
|
1206
|
+
# fallback for fixtures / the initial snapshot / positionally-constructed
|
|
1207
|
+
# DataSnapshots (`doctor_payload=None`) and produces the SAME block, so
|
|
1208
|
+
# moving the computation is byte-identical. The lazy `GET /api/doctor`
|
|
1209
|
+
# endpoint keeps computing LIVE (an explicit user refresh must be fresh).
|
|
1210
|
+
_doctor_payload = getattr(snap, "doctor_payload", None)
|
|
1211
|
+
if _doctor_payload is not None:
|
|
1212
|
+
doctor_envelope: "dict" = _doctor_payload
|
|
1213
|
+
else:
|
|
1214
|
+
try:
|
|
1215
|
+
_ld = sys.modules["cctally"]._load_sibling("_lib_doctor")
|
|
1216
|
+
_doc_state = sys.modules["cctally"].doctor_gather_state(now_utc=now_utc, runtime_bind=runtime_bind)
|
|
1217
|
+
_doc_report = _ld.run_checks(_doc_state)
|
|
1218
|
+
doctor_envelope = {
|
|
1219
|
+
"severity": _doc_report.overall_severity,
|
|
1220
|
+
"counts": dict(_doc_report.counts),
|
|
1221
|
+
"generated_at": _ld._iso_z(_doc_report.generated_at),
|
|
1222
|
+
"fingerprint": _ld.fingerprint(_doc_report),
|
|
1223
|
+
}
|
|
1224
|
+
except Exception as exc: # noqa: BLE001 — never crash SSE on doctor failure
|
|
1225
|
+
doctor_envelope = {
|
|
1226
|
+
"severity": "fail",
|
|
1227
|
+
"counts": {"ok": 0, "warn": 0, "fail": 1},
|
|
1228
|
+
"generated_at": _iso_z(now_utc),
|
|
1229
|
+
"fingerprint": "sha1:" + ("0" * 40),
|
|
1230
|
+
"_error": f"{type(exc).__name__}: {exc}",
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
# B1 (#207): the "vs last week" header delta reuses the is_current trend
|
|
1234
|
+
# row's delta_dpp ($/1% vs the previous trend row — normally the prior
|
|
1235
|
+
# subscription week). Select by the is_current FLAG, not snap.trend[-1]:
|
|
1236
|
+
# snap.trend is oldest-first by week_start_date and reset/credit handling
|
|
1237
|
+
# can synthesize an intervening row, so position -1 is not guaranteed to
|
|
1238
|
+
# be the current week (Codex P1). reversed() picks the latest if ever
|
|
1239
|
+
# multiple are flagged. Null-safe: None when no row is current, or when
|
|
1240
|
+
# the current row's delta_dpp is itself None — both hide the stat.
|
|
1241
|
+
_current_trend = next(
|
|
1242
|
+
(r for r in reversed(snap.trend) if r.is_current), None
|
|
1243
|
+
) if snap.trend else None
|
|
1244
|
+
|
|
1245
|
+
return {
|
|
1246
|
+
"envelope_version": 2,
|
|
1247
|
+
# #278 Theme A: single additive first-paint hydration latch. True only
|
|
1248
|
+
# on the cheap seed + A2's partial republishes (data still being
|
|
1249
|
+
# assembled); False on every complete/stable snapshot. ``getattr``
|
|
1250
|
+
# default keeps positionally-constructed fixture snapshots serializing.
|
|
1251
|
+
"hydrating": bool(getattr(snap, "hydrating", False)),
|
|
1252
|
+
"generated_at": _iso_z(snap.generated_at),
|
|
1253
|
+
# last_sync_at in DataSnapshot is a monotonic float, not a wall
|
|
1254
|
+
# clock — the envelope's wall-clock moment is unknowable from
|
|
1255
|
+
# here, so expose it as None and rely on sync_age_s for the UI.
|
|
1256
|
+
"last_sync_at": None,
|
|
1257
|
+
"sync_age_s": sync_age_s,
|
|
1258
|
+
"last_sync_error": snap.last_sync_error,
|
|
1259
|
+
|
|
1260
|
+
# F1 (server-resolves "local" → IANA): the browser never has to
|
|
1261
|
+
# guess. {tz, resolved_tz, offset_label, offset_seconds} computed
|
|
1262
|
+
# at the snapshot's `generated_at`. Always present, even when
|
|
1263
|
+
# cw / fc are None.
|
|
1264
|
+
"display": display_block,
|
|
1265
|
+
|
|
1266
|
+
"header": {
|
|
1267
|
+
"week_label": week_lbl,
|
|
1268
|
+
"used_pct": used_pct,
|
|
1269
|
+
"five_hour_pct": five_hr,
|
|
1270
|
+
"dollar_per_pct": dollar_pp,
|
|
1271
|
+
"forecast_pct": header_fcast_pct,
|
|
1272
|
+
"forecast_verdict": verdict,
|
|
1273
|
+
"vs_last_week_delta": (
|
|
1274
|
+
_current_trend.delta_dpp if _current_trend is not None else None
|
|
1275
|
+
),
|
|
1276
|
+
},
|
|
1277
|
+
|
|
1278
|
+
"current_week":
|
|
1279
|
+
None if cw is None else {
|
|
1280
|
+
"used_pct": cw.used_pct,
|
|
1281
|
+
"five_hour_pct": cw.five_hour_pct,
|
|
1282
|
+
"five_hour_resets_in_sec":
|
|
1283
|
+
None if cw.five_hour_resets_at is None
|
|
1284
|
+
else max(0, int((cw.five_hour_resets_at - now_utc).total_seconds())),
|
|
1285
|
+
"spent_usd": cw.spent_usd,
|
|
1286
|
+
"dollar_per_pct": cw.dollars_per_percent,
|
|
1287
|
+
"reset_at_utc": _iso_z(reset_at_utc),
|
|
1288
|
+
"reset_in_sec":
|
|
1289
|
+
None if reset_at_utc is None
|
|
1290
|
+
else max(0, int((reset_at_utc - now_utc).total_seconds())),
|
|
1291
|
+
"last_snapshot_age_sec":
|
|
1292
|
+
None if cw.latest_snapshot_at is None
|
|
1293
|
+
else max(0, int((now_utc - cw.latest_snapshot_at).total_seconds())),
|
|
1294
|
+
"freshness": cw_freshness,
|
|
1295
|
+
# Current 5h block (spec §4.1) — populated upstream by
|
|
1296
|
+
# `_tui_build_current_week`. `getattr` with default keeps
|
|
1297
|
+
# legacy fixture modules that construct TuiCurrentWeek
|
|
1298
|
+
# directly (without the new field) compatible.
|
|
1299
|
+
"five_hour_block": getattr(cw, "five_hour_block", None),
|
|
1300
|
+
"milestones": [
|
|
1301
|
+
{
|
|
1302
|
+
"percent": m.percent,
|
|
1303
|
+
"crossed_at_utc": _iso_z(m.crossed_at),
|
|
1304
|
+
"cumulative_usd": round(m.cumulative_cost_usd, 4),
|
|
1305
|
+
"marginal_usd":
|
|
1306
|
+
None if m.marginal_cost_usd is None
|
|
1307
|
+
else round(m.marginal_cost_usd, 4),
|
|
1308
|
+
"five_hour_pct_at_cross": m.five_hour_pct_at_crossing,
|
|
1309
|
+
}
|
|
1310
|
+
for m in (snap.percent_milestones or [])
|
|
1311
|
+
],
|
|
1312
|
+
# Spec §5.3 (Codex r1 finding 3) — NEW envelope key
|
|
1313
|
+
# parallel to ``milestones`` (which carries the WEEKLY
|
|
1314
|
+
# timeline). 5h-block milestones for the active block,
|
|
1315
|
+
# in capture-time order, both pre- and post-credit
|
|
1316
|
+
# segments included (bucket B per §3.2 — no
|
|
1317
|
+
# ``reset_event_id`` filter; the React layer renders
|
|
1318
|
+
# repeated thresholds as distinct rows keyed on
|
|
1319
|
+
# ``reset_event_id``). Empty list when no 5h block is
|
|
1320
|
+
# bound or the data source crashed during sync
|
|
1321
|
+
# (recorded on ``last_sync_error``).
|
|
1322
|
+
"five_hour_milestones": getattr(snap, "five_hour_milestones", []) or [],
|
|
1323
|
+
},
|
|
1324
|
+
|
|
1325
|
+
"forecast":
|
|
1326
|
+
None if fc is None else {
|
|
1327
|
+
"verdict": verdict,
|
|
1328
|
+
"week_avg_projection_pct": fcast_pct,
|
|
1329
|
+
"recent_24h_projection_pct": recent_24h_pct,
|
|
1330
|
+
"budget_100_per_day_usd": budget_100,
|
|
1331
|
+
"budget_90_per_day_usd": budget_90,
|
|
1332
|
+
"confidence": confidence or "unknown",
|
|
1333
|
+
# Map the binary ForecastOutput.confidence ("high" / "low") to
|
|
1334
|
+
# a 7-dot fill scale for the dashboard footer. ForecastOutput
|
|
1335
|
+
# has no numeric grade, so we pick a visually meaningful floor
|
|
1336
|
+
# for "low" (≈30% filled) and a full bar for "high".
|
|
1337
|
+
"confidence_score": (7 if confidence == "high"
|
|
1338
|
+
else 2 if confidence == "low"
|
|
1339
|
+
else 0),
|
|
1340
|
+
"explain": sys.modules["cctally"]._build_forecast_json_payload(fc),
|
|
1341
|
+
},
|
|
1342
|
+
|
|
1343
|
+
"trend":
|
|
1344
|
+
None if not snap.trend else {
|
|
1345
|
+
"weeks": [
|
|
1346
|
+
{
|
|
1347
|
+
"label": w.week_label,
|
|
1348
|
+
"used_pct": w.used_pct,
|
|
1349
|
+
"dollar_per_pct": w.dollars_per_percent,
|
|
1350
|
+
"delta": w.delta_dpp,
|
|
1351
|
+
"is_current": bool(w.is_current),
|
|
1352
|
+
# #264 S3: additive weekly cost (already on the row via
|
|
1353
|
+
# build_trend_view); rendered by the Trend modal's Cost
|
|
1354
|
+
# column. ``getattr`` (like ``project_key`` /
|
|
1355
|
+
# ``trend_history_median_dpp``) tolerates minimal trend
|
|
1356
|
+
# shapes — SimpleNamespace test rows / older fixtures —
|
|
1357
|
+
# that predate this nullable field. ``None`` when the
|
|
1358
|
+
# week has no cost snapshot.
|
|
1359
|
+
"cost_usd": round(_wc, 4) if (_wc := getattr(w, "weekly_cost_usd", None)) is not None else None,
|
|
1360
|
+
}
|
|
1361
|
+
for w in snap.trend
|
|
1362
|
+
],
|
|
1363
|
+
"spark_heights": [w.spark_height for w in snap.trend],
|
|
1364
|
+
"history": [
|
|
1365
|
+
{
|
|
1366
|
+
"label": w.week_label,
|
|
1367
|
+
"used_pct": w.used_pct,
|
|
1368
|
+
"dollar_per_pct": w.dollars_per_percent,
|
|
1369
|
+
"delta": w.delta_dpp,
|
|
1370
|
+
"is_current": bool(w.is_current),
|
|
1371
|
+
# #264 S3: additive weekly cost (see weeks[] above; same
|
|
1372
|
+
# getattr tolerance for minimal/older trend shapes).
|
|
1373
|
+
"cost_usd": round(_wc, 4) if (_wc := getattr(w, "weekly_cost_usd", None)) is not None else None,
|
|
1374
|
+
}
|
|
1375
|
+
for w in (snap.weekly_history or [])
|
|
1376
|
+
],
|
|
1377
|
+
# View-model unification (Bundle 1; spec §6.6): the
|
|
1378
|
+
# pre-computed 3-sample $/% mean. TrendPanel reads this
|
|
1379
|
+
# instead of re-deriving the panel-average.
|
|
1380
|
+
"avg_dollars_per_pct": snap.trend_avg_dollars_per_pct,
|
|
1381
|
+
# Issue #59 follow-up: pre-computed 4-week-median of
|
|
1382
|
+
# non-current ``dollars_per_percent`` over the 12-row
|
|
1383
|
+
# history. TrendModal.tsx's ``median4NonCurrent`` helper
|
|
1384
|
+
# used to compute this client-side; pre-computing on
|
|
1385
|
+
# ``build_trend_view`` keeps the rule
|
|
1386
|
+
# (``sort(last 4 non-current dpps)``, midpoint
|
|
1387
|
+
# ``(s[1]+s[2])/2``) in one place. ``None`` when fewer
|
|
1388
|
+
# than 4 valid non-current samples — modal's client-side
|
|
1389
|
+
# fallback handles the ``null`` case.
|
|
1390
|
+
"history_median_dpp":
|
|
1391
|
+
getattr(snap, "trend_history_median_dpp", None),
|
|
1392
|
+
},
|
|
1393
|
+
|
|
1394
|
+
"weekly": weekly_env,
|
|
1395
|
+
"monthly": monthly_env,
|
|
1396
|
+
"blocks": blocks_env,
|
|
1397
|
+
"daily": daily_env,
|
|
1398
|
+
|
|
1399
|
+
"sessions": {
|
|
1400
|
+
"total": len(snap.sessions),
|
|
1401
|
+
"sort_key": "started_desc",
|
|
1402
|
+
"rows": [
|
|
1403
|
+
{
|
|
1404
|
+
"session_id": s.session_id,
|
|
1405
|
+
"started_utc": _iso_z(s.started_at),
|
|
1406
|
+
"duration_min": int(round(s.duration_minutes)) if s.duration_minutes else 0,
|
|
1407
|
+
"model": s.model_primary,
|
|
1408
|
+
"project": s.project_label,
|
|
1409
|
+
# Projects-panel cross-nav identity (spec §4.1).
|
|
1410
|
+
# Disambiguated display_key matching the projects
|
|
1411
|
+
# envelope's `current_week.rows[].key`; ``None`` when
|
|
1412
|
+
# the projects envelope sub-build failed, the row's
|
|
1413
|
+
# project_path is missing, or the lookup didn't
|
|
1414
|
+
# resolve (the React cell falls back to plain text).
|
|
1415
|
+
"project_key": getattr(s, "project_key", None),
|
|
1416
|
+
"cost_usd": round(s.cost_usd, 4) if s.cost_usd is not None else None,
|
|
1417
|
+
# #264 S3: cache-hit % is a plain metric — always serialized
|
|
1418
|
+
# (never gated). ``None`` when the row has no denominator.
|
|
1419
|
+
"cache_hit_pct": round(s.cache_hit_pct, 1) if s.cache_hit_pct is not None else None,
|
|
1420
|
+
# The session title is transcript-derived content: emit the
|
|
1421
|
+
# `title` KEY only when the per-request transcript gate is
|
|
1422
|
+
# open AND a title exists. Omitted (not null) otherwise, so
|
|
1423
|
+
# a gated-off or untitled session renders the client's
|
|
1424
|
+
# em-dash fallback and committed goldens (empty conversation
|
|
1425
|
+
# fixtures -> no title) never carry a `title` key. Default
|
|
1426
|
+
# transcripts_visible=False therefore fails fully closed.
|
|
1427
|
+
**({"title": getattr(s, "title", None)}
|
|
1428
|
+
if (transcripts_visible
|
|
1429
|
+
and getattr(s, "title", None) is not None)
|
|
1430
|
+
else {}),
|
|
1431
|
+
}
|
|
1432
|
+
for s in snap.sessions
|
|
1433
|
+
],
|
|
1434
|
+
},
|
|
1435
|
+
|
|
1436
|
+
# Projects panel + modal envelope block (spec §5.2).
|
|
1437
|
+
# Populated on the sync thread; the serializer reads it back
|
|
1438
|
+
# unchanged so it stays a pure renderer (no DB I/O). ``None``
|
|
1439
|
+
# on first tick before sync completes; the client renders the
|
|
1440
|
+
# panel-empty state in that case.
|
|
1441
|
+
"projects": getattr(snap, "projects_envelope", None),
|
|
1442
|
+
|
|
1443
|
+
# Cache-report panel + modal envelope block (spec
|
|
1444
|
+
# 2026-05-21-cache-report-panel-design.md §4.2). Snake_case
|
|
1445
|
+
# keys throughout — the envelope is intentionally snake_case
|
|
1446
|
+
# end-to-end (envelope.ts:189). ``None`` on first tick before
|
|
1447
|
+
# sync completes; the client renders the panel-empty state in
|
|
1448
|
+
# that case. envelope_version stays at 2 (additive optional
|
|
1449
|
+
# field, matches the update? / doctor? precedent).
|
|
1450
|
+
"cache_report": _cache_report_snapshot_to_dict(
|
|
1451
|
+
getattr(snap, "cache_report", None)
|
|
1452
|
+
),
|
|
1453
|
+
|
|
1454
|
+
# threshold-actions T5: see prelude above for rationale.
|
|
1455
|
+
"alerts": alerts_array,
|
|
1456
|
+
"alerts_settings": alerts_settings,
|
|
1457
|
+
|
|
1458
|
+
# Dashboard render-prefs mirror (cache-failure-markers opt-out, spec
|
|
1459
|
+
# §5). Additive optional, like alerts_settings — envelope_version
|
|
1460
|
+
# stays at 2. The client derives `markersEnabled` from this, defaulting
|
|
1461
|
+
# to true when the field is undefined (older server / first tick).
|
|
1462
|
+
"dashboard_prefs": dashboard_prefs,
|
|
1463
|
+
|
|
1464
|
+
# update-subcommand SSE mirror (see comment above the
|
|
1465
|
+
# `_load_update_state()` block). Shape matches GET
|
|
1466
|
+
# /api/update/status's payload (`{state, suppress}`) so the
|
|
1467
|
+
# dashboard client's existing coerceUpdateState/Suppress logic
|
|
1468
|
+
# consumes both surfaces uniformly.
|
|
1469
|
+
"update": update_envelope,
|
|
1470
|
+
|
|
1471
|
+
# Doctor aggregate-only block (spec §5.5). Full per-check
|
|
1472
|
+
# report fetched lazily via GET /api/doctor.
|
|
1473
|
+
"doctor": doctor_envelope,
|
|
1474
|
+
|
|
1475
|
+
# Preview channel (CCTALLY_CHANNEL=preview): additive-optional
|
|
1476
|
+
# `channel` key. Omitted when the marker is unset so prod payloads
|
|
1477
|
+
# (and the dashboard goldens) stay byte-identical; feeds the header
|
|
1478
|
+
# PREVIEW badge when set.
|
|
1479
|
+
**sys.modules["_cctally_dashboard"]._channel_env_fragment(),
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
def _session_detail_to_envelope(detail: "TuiSessionDetail") -> dict:
|
|
1484
|
+
"""Serialize TuiSessionDetail for GET /api/session/:id (spec §3.2, §4.6.4).
|
|
1485
|
+
|
|
1486
|
+
Field names stay parallel to the live ``sessions.rows[]`` envelope
|
|
1487
|
+
(``session_id``, ``started_utc``, ``cost_usd``) so the JS client can
|
|
1488
|
+
treat this as an enrichment of a session row. ``models`` is flattened
|
|
1489
|
+
from ``list[tuple[str, str]]`` to ``list[{name, role}]`` for JSON
|
|
1490
|
+
palatability. ``cost_per_model`` is similarly flattened.
|
|
1491
|
+
|
|
1492
|
+
Pure function — no I/O, no clocks. Dataclass fields mirror exactly
|
|
1493
|
+
(``started_at`` → ``started_utc``, ``last_activity_at`` →
|
|
1494
|
+
``last_activity_utc``, ``duration_minutes`` → ``duration_min`` as an
|
|
1495
|
+
int for parity with sessions.rows[]). There is no per-entry array —
|
|
1496
|
+
the underlying aggregator is session-level (spec §4.6.4).
|
|
1497
|
+
"""
|
|
1498
|
+
return {
|
|
1499
|
+
"session_id": detail.session_id,
|
|
1500
|
+
"started_utc": _iso_z(detail.started_at),
|
|
1501
|
+
"last_activity_utc": _iso_z(detail.last_activity_at),
|
|
1502
|
+
"duration_min": int(round(detail.duration_minutes)) if detail.duration_minutes else 0,
|
|
1503
|
+
"project_label": detail.project_label,
|
|
1504
|
+
"project_path": detail.project_path,
|
|
1505
|
+
"source_paths": list(detail.source_paths),
|
|
1506
|
+
"models": [
|
|
1507
|
+
{"name": name, "role": role}
|
|
1508
|
+
for (name, role) in detail.models
|
|
1509
|
+
],
|
|
1510
|
+
"input_tokens": detail.input_tokens,
|
|
1511
|
+
"cache_creation_tokens": detail.cache_creation_tokens,
|
|
1512
|
+
"cache_read_tokens": detail.cache_read_tokens,
|
|
1513
|
+
"output_tokens": detail.output_tokens,
|
|
1514
|
+
"cache_hit_pct": detail.cache_hit_pct,
|
|
1515
|
+
"cost_per_model": [
|
|
1516
|
+
{"model": name, "cost_usd": round(cost, 4)}
|
|
1517
|
+
for (name, cost) in detail.cost_per_model
|
|
1518
|
+
],
|
|
1519
|
+
"cost_total_usd": round(detail.cost_total_usd, 4),
|
|
1520
|
+
}
|