cctally 1.68.0 → 1.69.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 +56 -0
- package/bin/_cctally_alerts.py +54 -2
- package/bin/_cctally_cache.py +644 -107
- package/bin/_cctally_cache_report.py +41 -17
- package/bin/_cctally_codex.py +109 -4
- package/bin/_cctally_config.py +368 -2
- package/bin/_cctally_core.py +168 -5
- package/bin/_cctally_dashboard.py +679 -5
- package/bin/_cctally_dashboard_conversation.py +9 -4
- package/bin/_cctally_dashboard_envelope.py +110 -1
- package/bin/_cctally_dashboard_share.py +557 -79
- package/bin/_cctally_db.py +303 -17
- package/bin/_cctally_diff.py +49 -16
- package/bin/_cctally_doctor.py +118 -0
- package/bin/_cctally_forecast.py +12 -4
- package/bin/_cctally_parser.py +298 -92
- package/bin/_cctally_project.py +24 -8
- package/bin/_cctally_quota.py +1381 -0
- package/bin/_cctally_record.py +319 -14
- package/bin/_cctally_refresh.py +105 -3
- package/bin/_cctally_reporting.py +7 -1
- package/bin/_cctally_setup.py +343 -113
- package/bin/_cctally_statusline.py +228 -0
- package/bin/_cctally_tui.py +787 -7
- package/bin/_lib_alert_axes.py +11 -0
- package/bin/_lib_codex_hooks.py +367 -0
- package/bin/_lib_conversation_query.py +156 -67
- package/bin/_lib_doctor.py +202 -0
- package/bin/_lib_jsonl.py +529 -162
- package/bin/_lib_quota.py +566 -0
- package/bin/_lib_share.py +152 -34
- package/bin/_lib_snapshot_cache.py +26 -0
- package/bin/_lib_source_identity.py +74 -0
- package/bin/cctally +324 -10
- package/dashboard/static/assets/index-CBbErI-P.js +80 -0
- package/dashboard/static/assets/index-kDDVOLa_.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +5 -1
- package/dashboard/static/assets/index-BybNp_Di.js +0 -80
- package/dashboard/static/assets/index-DgAmLK65.css +0 -1
package/bin/_cctally_tui.py
CHANGED
|
@@ -194,7 +194,7 @@ import sys
|
|
|
194
194
|
import threading
|
|
195
195
|
import time
|
|
196
196
|
from dataclasses import dataclass, field
|
|
197
|
-
from typing import Any
|
|
197
|
+
from typing import Any, Sequence
|
|
198
198
|
|
|
199
199
|
|
|
200
200
|
def _cctally():
|
|
@@ -258,6 +258,27 @@ def _ensure_sibling_loaded(name: str) -> None:
|
|
|
258
258
|
|
|
259
259
|
_ensure_sibling_loaded("_lib_forecast")
|
|
260
260
|
from _lib_forecast import _compute_forecast, ForecastInputs, ForecastOutput, BudgetRow
|
|
261
|
+
_ensure_sibling_loaded("_lib_dashboard_sources")
|
|
262
|
+
_ensure_sibling_loaded("_cctally_dashboard_sources")
|
|
263
|
+
from _cctally_dashboard_sources import (
|
|
264
|
+
CodexProjectionIncoherent,
|
|
265
|
+
DashboardReadContext,
|
|
266
|
+
build_codex_source_state,
|
|
267
|
+
refresh_codex_source_clock,
|
|
268
|
+
resolve_dashboard_source_semantics,
|
|
269
|
+
)
|
|
270
|
+
from _lib_dashboard_sources import (
|
|
271
|
+
CapabilityRecord,
|
|
272
|
+
SourceDashboardBundle,
|
|
273
|
+
SourceDashboardState,
|
|
274
|
+
SourceDashboardWarning,
|
|
275
|
+
codex_stats_digest,
|
|
276
|
+
compose_all_state,
|
|
277
|
+
dashboard_resource_key,
|
|
278
|
+
degrade_source_state,
|
|
279
|
+
reuse_coherent_source_state,
|
|
280
|
+
unavailable_source_state,
|
|
281
|
+
)
|
|
261
282
|
|
|
262
283
|
|
|
263
284
|
# === Module-level back-ref shims for helpers that STAY in bin/cctally ======
|
|
@@ -340,6 +361,10 @@ def sync_cache(*args, **kwargs):
|
|
|
340
361
|
return sys.modules["cctally"].sync_cache(*args, **kwargs)
|
|
341
362
|
|
|
342
363
|
|
|
364
|
+
def sync_codex_cache(*args, **kwargs):
|
|
365
|
+
return sys.modules["cctally"].sync_codex_cache(*args, **kwargs)
|
|
366
|
+
|
|
367
|
+
|
|
343
368
|
# ``_compute_forecast`` + the forecast dataclasses (``ForecastInputs`` /
|
|
344
369
|
# ``ForecastOutput`` / ``BudgetRow``) now live in ``bin/_lib_forecast.py``
|
|
345
370
|
# (#279 S4 F2) and are honest-imported at module top — used as bare-name
|
|
@@ -1143,6 +1168,27 @@ class DataSnapshot:
|
|
|
1143
1168
|
# Placed LAST with a default so positional fixture constructors keep
|
|
1144
1169
|
# working.
|
|
1145
1170
|
hydrating: bool = False
|
|
1171
|
+
# ---- #300: change-signal for the dashboard's lazy detail fetchers ----
|
|
1172
|
+
# A compact, deterministic string derived from the whole DB dispatch
|
|
1173
|
+
# signature (``_snapshot_data_version(dispatch_sig)``): it changes iff ANY
|
|
1174
|
+
# DB input the detail endpoints read changed (session entries, weekly
|
|
1175
|
+
# usage/cost, reset events, codex entries, cache generation), and stays flat
|
|
1176
|
+
# on an idle tick (idle ⇔ signature unchanged). Empty string when no
|
|
1177
|
+
# dispatch signature was computed — the TUI / non-precompute path, which
|
|
1178
|
+
# never consumes it. Serialized into the dashboard envelope by
|
|
1179
|
+
# ``snapshot_to_envelope`` as ``"data_version"`` so the browser's
|
|
1180
|
+
# session-modal / projects-drill / conversation-outline fetchers revalidate
|
|
1181
|
+
# on an actual DATA-CHANGE signal instead of the 5s ``generated_at``
|
|
1182
|
+
# heartbeat (#300). The idle short-circuit carries it forward via
|
|
1183
|
+
# ``dataclasses.replace`` (idle ⇒ signature unchanged ⇒ same value).
|
|
1184
|
+
# Trailing default so positional fixture constructors keep working; appears
|
|
1185
|
+
# in NO ``--json``/CLI surface.
|
|
1186
|
+
data_version: str = ""
|
|
1187
|
+
# Dashboard-only #294 S4 provider bundle. The terminal TUI's normal data
|
|
1188
|
+
# path deliberately leaves this ``None`` and never touches Codex dashboard
|
|
1189
|
+
# ingest/read-model work. It remains trailing/defaulted for every legacy
|
|
1190
|
+
# positional fixture constructor.
|
|
1191
|
+
source_bundle: SourceDashboardBundle | None = None
|
|
1146
1192
|
|
|
1147
1193
|
@classmethod
|
|
1148
1194
|
def synthesize_for_marketing(cls, *, as_of_iso: str) -> "DataSnapshot":
|
|
@@ -2099,6 +2145,535 @@ def _tui_build_session_detail(
|
|
|
2099
2145
|
)
|
|
2100
2146
|
|
|
2101
2147
|
|
|
2148
|
+
def _snapshot_data_version(sig) -> str:
|
|
2149
|
+
"""#300 — compact, deterministic change-signal string from the DB dispatch
|
|
2150
|
+
signature (``_lib_snapshot_cache.SnapshotSignature``).
|
|
2151
|
+
|
|
2152
|
+
Changes iff any DB leg the dashboard's detail endpoints read changed
|
|
2153
|
+
(session entries + their id-stable mutation counter, weekly usage/cost,
|
|
2154
|
+
reset events, codex entries, cache generation); stays flat on an idle tick
|
|
2155
|
+
(idle ⇔ signature unchanged). Returns ``""`` when no signature was computed
|
|
2156
|
+
(the non-precompute / TUI path, which never consumes it) — the browser's
|
|
2157
|
+
``revalToken`` then falls back to ``generated_at``. Every leg is an int
|
|
2158
|
+
(``reset_sig`` is a 2-int tuple), so a ``"."``-join is process-stable (no
|
|
2159
|
+
hash-seed dependence, unlike ``hash()``)."""
|
|
2160
|
+
if sig is None:
|
|
2161
|
+
return ""
|
|
2162
|
+
rs = getattr(sig, "reset_sig", None) or (0, 0)
|
|
2163
|
+
numeric_legs = ".".join(str(int(x)) for x in (
|
|
2164
|
+
sig.max_entry_id, sig.entry_mutation_seq, sig.max_wus_id,
|
|
2165
|
+
sig.max_wcs_id, rs[0], rs[1], sig.max_codex_id, sig.generation,
|
|
2166
|
+
getattr(sig, "codex_physical_mutation_seq", 0),
|
|
2167
|
+
))
|
|
2168
|
+
digest = getattr(sig, "codex_stats_digest", "")
|
|
2169
|
+
return numeric_legs if not digest else f"{numeric_legs}.{digest}"
|
|
2170
|
+
|
|
2171
|
+
|
|
2172
|
+
def _tui_source_copy(value: object) -> object:
|
|
2173
|
+
"""Copy only JSON-shaped legacy envelope values into a source state."""
|
|
2174
|
+
if isinstance(value, dict):
|
|
2175
|
+
return {str(key): _tui_source_copy(item) for key, item in value.items()}
|
|
2176
|
+
if isinstance(value, (list, tuple)):
|
|
2177
|
+
return [_tui_source_copy(item) for item in value]
|
|
2178
|
+
return value
|
|
2179
|
+
|
|
2180
|
+
|
|
2181
|
+
def _tui_claude_resource_row(
|
|
2182
|
+
row: object,
|
|
2183
|
+
*,
|
|
2184
|
+
resource: str,
|
|
2185
|
+
identity: object,
|
|
2186
|
+
remove: tuple[str, ...] = (),
|
|
2187
|
+
) -> dict[str, object]:
|
|
2188
|
+
"""Attach S4's opaque owner key while dropping legacy raw identities."""
|
|
2189
|
+
raw = row if isinstance(row, dict) else {}
|
|
2190
|
+
wire = {
|
|
2191
|
+
str(name): _tui_source_copy(value)
|
|
2192
|
+
for name, value in raw.items()
|
|
2193
|
+
if name not in {"key", *remove}
|
|
2194
|
+
}
|
|
2195
|
+
wire["key"] = dashboard_resource_key(resource, "claude", identity)
|
|
2196
|
+
wire["source"] = "claude"
|
|
2197
|
+
return wire
|
|
2198
|
+
|
|
2199
|
+
|
|
2200
|
+
def _tui_project_claude_source_data(legacy_envelope: object) -> dict[str, object]:
|
|
2201
|
+
"""Project one completed Claude legacy envelope without further DB reads.
|
|
2202
|
+
|
|
2203
|
+
The legacy dashboard snapshot is still the authoritative Claude model.
|
|
2204
|
+
This adapter only places its already-rendered values under the S4 source
|
|
2205
|
+
contract, replacing native route identities with opaque provider-qualified
|
|
2206
|
+
resource keys. It deliberately never opens a connection or invokes a
|
|
2207
|
+
loader, so the source bundle remains part of the one coordinated read.
|
|
2208
|
+
"""
|
|
2209
|
+
legacy = legacy_envelope if isinstance(legacy_envelope, dict) else {}
|
|
2210
|
+
daily = legacy.get("daily") if isinstance(legacy.get("daily"), dict) else {}
|
|
2211
|
+
monthly = legacy.get("monthly") if isinstance(legacy.get("monthly"), dict) else {}
|
|
2212
|
+
weekly = legacy.get("weekly") if isinstance(legacy.get("weekly"), dict) else {}
|
|
2213
|
+
sessions = legacy.get("sessions") if isinstance(legacy.get("sessions"), dict) else {}
|
|
2214
|
+
projects = legacy.get("projects") if isinstance(legacy.get("projects"), dict) else {}
|
|
2215
|
+
blocks = legacy.get("blocks") if isinstance(legacy.get("blocks"), dict) else {}
|
|
2216
|
+
current_week = legacy.get("current_week") if isinstance(legacy.get("current_week"), dict) else {}
|
|
2217
|
+
|
|
2218
|
+
session_rows: list[dict[str, object]] = []
|
|
2219
|
+
for ordinal, row in enumerate(sessions.get("rows", ()) or ()):
|
|
2220
|
+
raw = row if isinstance(row, dict) else {}
|
|
2221
|
+
native_id = raw.get("session_id")
|
|
2222
|
+
identity = native_id if isinstance(native_id, str) and native_id else (
|
|
2223
|
+
ordinal, raw.get("started_utc"),
|
|
2224
|
+
)
|
|
2225
|
+
session_rows.append(_tui_claude_resource_row(
|
|
2226
|
+
raw,
|
|
2227
|
+
resource="session",
|
|
2228
|
+
identity=identity,
|
|
2229
|
+
remove=("session_id", "project_key"),
|
|
2230
|
+
))
|
|
2231
|
+
|
|
2232
|
+
current_project = projects.get("current_week")
|
|
2233
|
+
current_project = current_project if isinstance(current_project, dict) else {}
|
|
2234
|
+
trend_project = projects.get("trend")
|
|
2235
|
+
trend_project = trend_project if isinstance(trend_project, dict) else {}
|
|
2236
|
+
|
|
2237
|
+
def project_rows(rows: object) -> list[dict[str, object]]:
|
|
2238
|
+
result: list[dict[str, object]] = []
|
|
2239
|
+
for ordinal, row in enumerate(rows if isinstance(rows, (list, tuple)) else ()):
|
|
2240
|
+
raw = row if isinstance(row, dict) else {}
|
|
2241
|
+
legacy_key = raw.get("key")
|
|
2242
|
+
identity = legacy_key if isinstance(legacy_key, str) and legacy_key else ordinal
|
|
2243
|
+
result.append(_tui_claude_resource_row(
|
|
2244
|
+
raw,
|
|
2245
|
+
resource="project",
|
|
2246
|
+
identity=identity,
|
|
2247
|
+
remove=("bucket_path",),
|
|
2248
|
+
))
|
|
2249
|
+
return result
|
|
2250
|
+
|
|
2251
|
+
current_project_rows = project_rows(current_project.get("rows", ()))
|
|
2252
|
+
trend_project_rows = project_rows(trend_project.get("projects", ()))
|
|
2253
|
+
project_source = {
|
|
2254
|
+
"current_week": {
|
|
2255
|
+
str(name): _tui_source_copy(value)
|
|
2256
|
+
for name, value in current_project.items() if name != "rows"
|
|
2257
|
+
} | {"rows": current_project_rows},
|
|
2258
|
+
"trend": {
|
|
2259
|
+
str(name): _tui_source_copy(value)
|
|
2260
|
+
for name, value in trend_project.items() if name != "projects"
|
|
2261
|
+
} | {"projects": trend_project_rows},
|
|
2262
|
+
# Route lookup is a flat source resource collection; the existing
|
|
2263
|
+
# dashboard panel shapes remain above unchanged except for identity.
|
|
2264
|
+
"rows": current_project_rows or trend_project_rows,
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
block_rows: list[dict[str, object]] = []
|
|
2268
|
+
for ordinal, row in enumerate(blocks.get("rows", ()) or ()):
|
|
2269
|
+
raw = row if isinstance(row, dict) else {}
|
|
2270
|
+
start_at = raw.get("start_at")
|
|
2271
|
+
identity = start_at if isinstance(start_at, str) and start_at else ordinal
|
|
2272
|
+
block_rows.append(_tui_claude_resource_row(
|
|
2273
|
+
raw, resource="block", identity=identity,
|
|
2274
|
+
))
|
|
2275
|
+
|
|
2276
|
+
def milestone_rows(rows: object, kind: str) -> list[dict[str, object]]:
|
|
2277
|
+
result: list[dict[str, object]] = []
|
|
2278
|
+
for ordinal, row in enumerate(rows if isinstance(rows, (list, tuple)) else ()):
|
|
2279
|
+
raw = row if isinstance(row, dict) else {}
|
|
2280
|
+
result.append(_tui_claude_resource_row(
|
|
2281
|
+
raw,
|
|
2282
|
+
resource=kind,
|
|
2283
|
+
identity=(ordinal, raw.get("percent"), raw.get("crossed_at_utc")),
|
|
2284
|
+
))
|
|
2285
|
+
return result
|
|
2286
|
+
|
|
2287
|
+
weekly_milestones = milestone_rows(current_week.get("milestones", ()), "quota_milestone")
|
|
2288
|
+
five_hour_milestones = milestone_rows(
|
|
2289
|
+
current_week.get("five_hour_milestones", ()), "quota_milestone",
|
|
2290
|
+
)
|
|
2291
|
+
quota_current = {
|
|
2292
|
+
str(name): _tui_source_copy(value)
|
|
2293
|
+
for name, value in current_week.items()
|
|
2294
|
+
if name not in {"milestones", "five_hour_milestones"}
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
alert_rows: list[dict[str, object]] = []
|
|
2298
|
+
for ordinal, row in enumerate(legacy.get("alerts", ()) or ()):
|
|
2299
|
+
raw = row if isinstance(row, dict) else {}
|
|
2300
|
+
axis = raw.get("axis")
|
|
2301
|
+
vendor = raw.get("vendor")
|
|
2302
|
+
metric = raw.get("metric")
|
|
2303
|
+
owns_alert = (
|
|
2304
|
+
(axis in {"weekly", "five_hour"} and vendor in {None, "claude"})
|
|
2305
|
+
# Legacy top-level Claude budget rows predate the additive vendor
|
|
2306
|
+
# field; the distinct Codex axis is ``codex_budget``. Treat an
|
|
2307
|
+
# absent vendor as that established Claude meaning, while an
|
|
2308
|
+
# explicit non-Claude vendor must never be relabeled.
|
|
2309
|
+
or (axis == "budget" and vendor in {None, "claude"})
|
|
2310
|
+
or axis == "project_budget"
|
|
2311
|
+
or (axis == "projected" and metric in {"weekly_pct", "budget_usd"})
|
|
2312
|
+
)
|
|
2313
|
+
if not owns_alert:
|
|
2314
|
+
continue
|
|
2315
|
+
alert_rows.append(_tui_claude_resource_row(
|
|
2316
|
+
raw,
|
|
2317
|
+
resource="alert",
|
|
2318
|
+
identity=(ordinal, raw.get("axis"), raw.get("threshold"), raw.get("alerted_at")),
|
|
2319
|
+
))
|
|
2320
|
+
|
|
2321
|
+
daily_total = daily.get("total_cost_usd", 0.0)
|
|
2322
|
+
daily_tokens = daily.get("total_tokens", 0)
|
|
2323
|
+
budget_settings = _tui_source_copy(legacy.get("alerts_settings"))
|
|
2324
|
+
if isinstance(budget_settings, dict):
|
|
2325
|
+
# The legacy top-level settings mirror contains Codex capability flags
|
|
2326
|
+
# for its combined/dashboard compatibility surface. They are not
|
|
2327
|
+
# Claude-provider facts and would make a reused Claude state stale
|
|
2328
|
+
# after a Codex-only budget/config change.
|
|
2329
|
+
for key in (
|
|
2330
|
+
"codex_budget_configured",
|
|
2331
|
+
"codex_budget_alerts_enabled",
|
|
2332
|
+
"codex_projected_enabled",
|
|
2333
|
+
):
|
|
2334
|
+
budget_settings.pop(key, None)
|
|
2335
|
+
return {
|
|
2336
|
+
"hero": {
|
|
2337
|
+
"cost_usd": daily_total,
|
|
2338
|
+
"total_tokens": daily_tokens,
|
|
2339
|
+
"header": _tui_source_copy(legacy.get("header")),
|
|
2340
|
+
"current_week": _tui_source_copy(current_week),
|
|
2341
|
+
"forecast": _tui_source_copy(legacy.get("forecast")),
|
|
2342
|
+
"trend": _tui_source_copy(legacy.get("trend")),
|
|
2343
|
+
},
|
|
2344
|
+
"periods": {
|
|
2345
|
+
"daily": _tui_source_copy(daily),
|
|
2346
|
+
"monthly": _tui_source_copy(monthly),
|
|
2347
|
+
"weekly": _tui_source_copy(weekly),
|
|
2348
|
+
},
|
|
2349
|
+
"sessions": {
|
|
2350
|
+
**{
|
|
2351
|
+
str(name): _tui_source_copy(value)
|
|
2352
|
+
for name, value in sessions.items() if name != "rows"
|
|
2353
|
+
},
|
|
2354
|
+
"rows": session_rows,
|
|
2355
|
+
},
|
|
2356
|
+
"projects": project_source,
|
|
2357
|
+
"quota": {
|
|
2358
|
+
"current_week": quota_current,
|
|
2359
|
+
"blocks": block_rows,
|
|
2360
|
+
"milestones": weekly_milestones,
|
|
2361
|
+
"five_hour_milestones": five_hour_milestones,
|
|
2362
|
+
},
|
|
2363
|
+
"budget": {
|
|
2364
|
+
"forecast": _tui_source_copy(legacy.get("forecast")),
|
|
2365
|
+
"settings": budget_settings,
|
|
2366
|
+
},
|
|
2367
|
+
"alerts": {"rows": alert_rows},
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
|
|
2371
|
+
def _tui_build_source_bundle(
|
|
2372
|
+
*,
|
|
2373
|
+
stats_conn,
|
|
2374
|
+
now_utc: dt.datetime,
|
|
2375
|
+
display_tz_name: str | None,
|
|
2376
|
+
codex_ingest_contended: bool,
|
|
2377
|
+
codex_ingest_failed: bool = False,
|
|
2378
|
+
claude_ingest_contended: bool = False,
|
|
2379
|
+
claude_ingest_failed: bool = False,
|
|
2380
|
+
claude_cost_usd: float,
|
|
2381
|
+
claude_total_tokens: int,
|
|
2382
|
+
claude_data: dict[str, object] | None = None,
|
|
2383
|
+
common_range_start: dt.datetime | None = None,
|
|
2384
|
+
prior_bundle: SourceDashboardBundle | None = None,
|
|
2385
|
+
raw_config: dict[str, object] | None = None,
|
|
2386
|
+
) -> SourceDashboardBundle:
|
|
2387
|
+
"""Build one frozen source bundle after the dashboard's coordinated ingest.
|
|
2388
|
+
|
|
2389
|
+
This helper is reachable only from ``precompute_envelope=True``. It opens
|
|
2390
|
+
a fresh cache handle after the ingest handle has closed, then performs
|
|
2391
|
+
read-only provider adaptation with no implicit sync or rollout fallback.
|
|
2392
|
+
"""
|
|
2393
|
+
c = _cctally()
|
|
2394
|
+
cache_conn = c.open_cache_db()
|
|
2395
|
+
cache_read_tx = False
|
|
2396
|
+
stats_read_tx = False
|
|
2397
|
+
try:
|
|
2398
|
+
# Read both databases through stable snapshots. cache.db and stats.db
|
|
2399
|
+
# cannot share one SQLite transaction, so a post-build signature check
|
|
2400
|
+
# below rejects a generation that moves while the two snapshots are
|
|
2401
|
+
# being assembled.
|
|
2402
|
+
if not cache_conn.in_transaction:
|
|
2403
|
+
cache_conn.execute("BEGIN")
|
|
2404
|
+
cache_read_tx = True
|
|
2405
|
+
if not stats_conn.in_transaction:
|
|
2406
|
+
stats_conn.execute("BEGIN")
|
|
2407
|
+
stats_read_tx = True
|
|
2408
|
+
if common_range_start is None:
|
|
2409
|
+
common_range_start = now_utc - dt.timedelta(days=30)
|
|
2410
|
+
if common_range_start.tzinfo is None or common_range_start.utcoffset() is None:
|
|
2411
|
+
raise ValueError("common_range_start must be timezone-aware")
|
|
2412
|
+
common_range_start = common_range_start.astimezone(dt.timezone.utc)
|
|
2413
|
+
semantics = resolve_dashboard_source_semantics(
|
|
2414
|
+
raw_config if raw_config is not None else c.load_config(),
|
|
2415
|
+
display_tz_name=display_tz_name,
|
|
2416
|
+
)
|
|
2417
|
+
stats_digest = codex_stats_digest(stats_conn)
|
|
2418
|
+
signature = c.compute_signature(
|
|
2419
|
+
cache_conn,
|
|
2420
|
+
stats_conn,
|
|
2421
|
+
generation=c.current_generation(),
|
|
2422
|
+
codex_stats_digest=stats_digest,
|
|
2423
|
+
)
|
|
2424
|
+
codex_version = (
|
|
2425
|
+
f"codex:{signature.max_codex_id}:"
|
|
2426
|
+
f"{signature.codex_physical_mutation_seq}:{stats_digest}:"
|
|
2427
|
+
f"{semantics.codex_identity}"
|
|
2428
|
+
)
|
|
2429
|
+
claude_version = (
|
|
2430
|
+
f"claude:{signature.max_entry_id}:{signature.entry_mutation_seq}:"
|
|
2431
|
+
f"{signature.max_wus_id}:{signature.max_wcs_id}:"
|
|
2432
|
+
f"{signature.reset_sig[0]}:{signature.reset_sig[1]}:"
|
|
2433
|
+
f"{signature.generation}:{semantics.claude_identity}"
|
|
2434
|
+
)
|
|
2435
|
+
prior_claude = (
|
|
2436
|
+
prior_bundle.sources.get("claude")
|
|
2437
|
+
if prior_bundle is not None else None
|
|
2438
|
+
)
|
|
2439
|
+
prior_codex = (
|
|
2440
|
+
prior_bundle.sources.get("codex")
|
|
2441
|
+
if prior_bundle is not None else None
|
|
2442
|
+
)
|
|
2443
|
+
if claude_ingest_failed:
|
|
2444
|
+
warning = SourceDashboardWarning(
|
|
2445
|
+
"source_ingest_failed", "Source ingest failed.", "ingest",
|
|
2446
|
+
)
|
|
2447
|
+
claude = (
|
|
2448
|
+
degrade_source_state(prior_claude, warning)
|
|
2449
|
+
if prior_claude is not None
|
|
2450
|
+
else unavailable_source_state("claude", warning)
|
|
2451
|
+
)
|
|
2452
|
+
elif claude_ingest_contended:
|
|
2453
|
+
warning = SourceDashboardWarning(
|
|
2454
|
+
"source_ingest_contended", "Source ingest is in progress.", "ingest",
|
|
2455
|
+
)
|
|
2456
|
+
claude = (
|
|
2457
|
+
degrade_source_state(prior_claude, warning)
|
|
2458
|
+
if prior_claude is not None
|
|
2459
|
+
else unavailable_source_state("claude", warning)
|
|
2460
|
+
)
|
|
2461
|
+
else:
|
|
2462
|
+
claude = reuse_coherent_source_state(
|
|
2463
|
+
prior_claude, data_version=claude_version,
|
|
2464
|
+
)
|
|
2465
|
+
if claude is None:
|
|
2466
|
+
claude_available = "ok" if (claude_cost_usd or claude_total_tokens) else "empty"
|
|
2467
|
+
claude = SourceDashboardState(
|
|
2468
|
+
source="claude",
|
|
2469
|
+
availability=claude_available,
|
|
2470
|
+
freshness="fresh",
|
|
2471
|
+
warnings=(),
|
|
2472
|
+
data_version=claude_version,
|
|
2473
|
+
last_success_at=now_utc,
|
|
2474
|
+
capabilities={
|
|
2475
|
+
"hero": CapabilityRecord("supported", "subscription-week"),
|
|
2476
|
+
"daily": CapabilityRecord("supported", "calendar-day"),
|
|
2477
|
+
"monthly": CapabilityRecord("supported", "calendar-month"),
|
|
2478
|
+
"weekly": CapabilityRecord("supported", "subscription-week"),
|
|
2479
|
+
"sessions": CapabilityRecord("supported", "legacy-session-rollup"),
|
|
2480
|
+
"forensics": CapabilityRecord("supported", "legacy-projection"),
|
|
2481
|
+
"quota": CapabilityRecord("supported", "subscription-week"),
|
|
2482
|
+
"budget": CapabilityRecord("supported", "subscription-week"),
|
|
2483
|
+
"projects": CapabilityRecord("supported", "legacy-projection"),
|
|
2484
|
+
"alerts": CapabilityRecord("supported", "provider-native"),
|
|
2485
|
+
},
|
|
2486
|
+
data={
|
|
2487
|
+
**(
|
|
2488
|
+
claude_data
|
|
2489
|
+
if claude_data is not None else {
|
|
2490
|
+
"hero": {
|
|
2491
|
+
"cost_usd": claude_cost_usd,
|
|
2492
|
+
"total_tokens": claude_total_tokens,
|
|
2493
|
+
},
|
|
2494
|
+
"periods": {"daily": {"total_cost_usd": claude_cost_usd, "total_tokens": claude_total_tokens}},
|
|
2495
|
+
"sessions": {"rows": ()},
|
|
2496
|
+
"projects": {"rows": ()},
|
|
2497
|
+
"quota": {"blocks": (), "milestones": ()},
|
|
2498
|
+
"budget": {"label": "Claude subscription budget"},
|
|
2499
|
+
"alerts": {"rows": ()},
|
|
2500
|
+
}
|
|
2501
|
+
),
|
|
2502
|
+
},
|
|
2503
|
+
)
|
|
2504
|
+
if codex_ingest_failed:
|
|
2505
|
+
warning = SourceDashboardWarning(
|
|
2506
|
+
"source_ingest_failed", "Source ingest failed.", "ingest",
|
|
2507
|
+
)
|
|
2508
|
+
codex = (
|
|
2509
|
+
degrade_source_state(prior_codex, warning)
|
|
2510
|
+
if prior_codex is not None
|
|
2511
|
+
else unavailable_source_state("codex", warning)
|
|
2512
|
+
)
|
|
2513
|
+
elif codex_ingest_contended:
|
|
2514
|
+
warning = SourceDashboardWarning(
|
|
2515
|
+
"source_ingest_contended", "Source ingest is in progress.", "ingest",
|
|
2516
|
+
)
|
|
2517
|
+
codex = (
|
|
2518
|
+
degrade_source_state(prior_codex, warning)
|
|
2519
|
+
if prior_codex is not None
|
|
2520
|
+
else unavailable_source_state("codex", warning)
|
|
2521
|
+
)
|
|
2522
|
+
else:
|
|
2523
|
+
codex = reuse_coherent_source_state(
|
|
2524
|
+
prior_codex, data_version=codex_version,
|
|
2525
|
+
)
|
|
2526
|
+
if codex is None:
|
|
2527
|
+
try:
|
|
2528
|
+
codex = build_codex_source_state(
|
|
2529
|
+
DashboardReadContext(
|
|
2530
|
+
cache_conn=cache_conn,
|
|
2531
|
+
stats_conn=stats_conn,
|
|
2532
|
+
range_start=common_range_start,
|
|
2533
|
+
now_utc=now_utc,
|
|
2534
|
+
display_tz_name=semantics.display_tz_name,
|
|
2535
|
+
week_start_idx=semantics.week_start_idx,
|
|
2536
|
+
week_start_name=semantics.week_start_name,
|
|
2537
|
+
speed=semantics.speed,
|
|
2538
|
+
codex_budget=semantics.codex_budget,
|
|
2539
|
+
),
|
|
2540
|
+
data_version=codex_version,
|
|
2541
|
+
)
|
|
2542
|
+
except CodexProjectionIncoherent:
|
|
2543
|
+
warning = SourceDashboardWarning(
|
|
2544
|
+
"codex_projection_incoherent",
|
|
2545
|
+
"Codex quota projection is unavailable.",
|
|
2546
|
+
"quota",
|
|
2547
|
+
)
|
|
2548
|
+
codex = (
|
|
2549
|
+
degrade_source_state(prior_codex, warning)
|
|
2550
|
+
if prior_codex is not None
|
|
2551
|
+
else unavailable_source_state("codex", warning)
|
|
2552
|
+
)
|
|
2553
|
+
except Exception:
|
|
2554
|
+
warning = SourceDashboardWarning(
|
|
2555
|
+
"source_build_failed", "Source data could not be built.", "read_model",
|
|
2556
|
+
)
|
|
2557
|
+
codex = (
|
|
2558
|
+
degrade_source_state(prior_codex, warning)
|
|
2559
|
+
if prior_codex is not None
|
|
2560
|
+
else unavailable_source_state("codex", warning)
|
|
2561
|
+
)
|
|
2562
|
+
combined = compose_all_state(claude, codex)
|
|
2563
|
+
bundle = SourceDashboardBundle(
|
|
2564
|
+
source_schema_version=1,
|
|
2565
|
+
default_source="claude",
|
|
2566
|
+
source_order=("claude", "codex", "all"),
|
|
2567
|
+
sources={"claude": claude, "codex": codex, "all": combined},
|
|
2568
|
+
)
|
|
2569
|
+
# End our snapshots before re-reading the cheap signatures. Seeing a
|
|
2570
|
+
# different physical state means the build may have mixed independent
|
|
2571
|
+
# database generations; retain only the prior complete bundle.
|
|
2572
|
+
if cache_read_tx:
|
|
2573
|
+
cache_conn.rollback()
|
|
2574
|
+
cache_read_tx = False
|
|
2575
|
+
if stats_read_tx:
|
|
2576
|
+
stats_conn.rollback()
|
|
2577
|
+
stats_read_tx = False
|
|
2578
|
+
post_stats_digest = codex_stats_digest(stats_conn)
|
|
2579
|
+
post_signature = c.compute_signature(
|
|
2580
|
+
cache_conn,
|
|
2581
|
+
stats_conn,
|
|
2582
|
+
generation=c.current_generation(),
|
|
2583
|
+
codex_stats_digest=post_stats_digest,
|
|
2584
|
+
)
|
|
2585
|
+
if post_signature != signature:
|
|
2586
|
+
if prior_bundle is not None:
|
|
2587
|
+
return prior_bundle
|
|
2588
|
+
raise RuntimeError("source read generation moved during build")
|
|
2589
|
+
return bundle
|
|
2590
|
+
finally:
|
|
2591
|
+
if cache_read_tx:
|
|
2592
|
+
cache_conn.rollback()
|
|
2593
|
+
if stats_read_tx:
|
|
2594
|
+
stats_conn.rollback()
|
|
2595
|
+
cache_conn.close()
|
|
2596
|
+
|
|
2597
|
+
|
|
2598
|
+
def _tui_hydrating_source_bundle() -> SourceDashboardBundle:
|
|
2599
|
+
"""Return the honest no-ingest source state used by the cheap dashboard seed.
|
|
2600
|
+
|
|
2601
|
+
The seed has not coordinated either provider ingest or derived projection,
|
|
2602
|
+
so it must not present those partial headline fields as a coherent provider
|
|
2603
|
+
generation. The dashboard's existing ``hydrating`` flag identifies this
|
|
2604
|
+
short-lived state; the first background rebuild replaces the whole frozen
|
|
2605
|
+
bundle atomically.
|
|
2606
|
+
"""
|
|
2607
|
+
claude = SourceDashboardState(
|
|
2608
|
+
source="claude",
|
|
2609
|
+
availability="partial",
|
|
2610
|
+
freshness="stale",
|
|
2611
|
+
warnings=(),
|
|
2612
|
+
data_version="hydrating:claude",
|
|
2613
|
+
last_success_at=None,
|
|
2614
|
+
capabilities={},
|
|
2615
|
+
data=None,
|
|
2616
|
+
)
|
|
2617
|
+
codex = SourceDashboardState(
|
|
2618
|
+
source="codex",
|
|
2619
|
+
availability="partial",
|
|
2620
|
+
freshness="stale",
|
|
2621
|
+
warnings=(),
|
|
2622
|
+
data_version="hydrating:codex",
|
|
2623
|
+
last_success_at=None,
|
|
2624
|
+
capabilities={},
|
|
2625
|
+
data=None,
|
|
2626
|
+
)
|
|
2627
|
+
return SourceDashboardBundle(
|
|
2628
|
+
source_schema_version=1,
|
|
2629
|
+
default_source="claude",
|
|
2630
|
+
source_order=("claude", "codex", "all"),
|
|
2631
|
+
sources={"claude": claude, "codex": codex, "all": compose_all_state(claude, codex)},
|
|
2632
|
+
)
|
|
2633
|
+
|
|
2634
|
+
|
|
2635
|
+
def _tui_source_bundle_can_idle(bundle: SourceDashboardBundle | None) -> bool:
|
|
2636
|
+
"""Return whether both physical provider generations are safe to retain.
|
|
2637
|
+
|
|
2638
|
+
A stable dispatch key alone cannot prove that a previously unavailable or
|
|
2639
|
+
degraded provider remains unavailable: a projection certificate can be
|
|
2640
|
+
repaired without changing accounting facts. Such a source must take the
|
|
2641
|
+
full source-bundle path again; that path still independently reuses the
|
|
2642
|
+
healthy provider by its own data version.
|
|
2643
|
+
"""
|
|
2644
|
+
if not isinstance(bundle, SourceDashboardBundle):
|
|
2645
|
+
return False
|
|
2646
|
+
for source in ("claude", "codex"):
|
|
2647
|
+
state = bundle.sources.get(source)
|
|
2648
|
+
if not isinstance(state, SourceDashboardState):
|
|
2649
|
+
return False
|
|
2650
|
+
if (state.availability not in ("ok", "empty")
|
|
2651
|
+
or state.freshness != "fresh"
|
|
2652
|
+
or state.data is None):
|
|
2653
|
+
return False
|
|
2654
|
+
return True
|
|
2655
|
+
|
|
2656
|
+
|
|
2657
|
+
def _tui_common_source_range_start(
|
|
2658
|
+
daily_panel: Sequence[TuiDailyPanelRow],
|
|
2659
|
+
*,
|
|
2660
|
+
now_utc: dt.datetime,
|
|
2661
|
+
display_tz: dt.tzinfo | None,
|
|
2662
|
+
) -> dt.datetime:
|
|
2663
|
+
"""Return the shared provider interval from the already-built daily rows."""
|
|
2664
|
+
if daily_panel:
|
|
2665
|
+
earliest_day = dt.date.fromisoformat(daily_panel[-1].date)
|
|
2666
|
+
if display_tz is not None:
|
|
2667
|
+
return dt.datetime.combine(
|
|
2668
|
+
earliest_day, dt.time.min, tzinfo=display_tz,
|
|
2669
|
+
).astimezone(dt.timezone.utc)
|
|
2670
|
+
# internal fallback: host-local intentional
|
|
2671
|
+
return dt.datetime.combine(
|
|
2672
|
+
earliest_day, dt.time.min,
|
|
2673
|
+
).astimezone(dt.timezone.utc)
|
|
2674
|
+
return now_utc - dt.timedelta(days=30)
|
|
2675
|
+
|
|
2676
|
+
|
|
2102
2677
|
def _tui_build_snapshot(
|
|
2103
2678
|
*,
|
|
2104
2679
|
now_utc: dt.datetime | None = None,
|
|
@@ -2180,17 +2755,40 @@ def _tui_build_snapshot(
|
|
|
2180
2755
|
# builders always read pure regardless (skip_sync is reassigned to
|
|
2181
2756
|
# True below), so --no-sync means "no ingest, still read".
|
|
2182
2757
|
do_ingest = not skip_sync
|
|
2758
|
+
claude_ingest_contended = False
|
|
2759
|
+
claude_ingest_failed = False
|
|
2760
|
+
codex_ingest_contended = False
|
|
2761
|
+
codex_ingest_failed = False
|
|
2183
2762
|
with _perf.phase("sync") as _p_sync:
|
|
2184
2763
|
_p_sync.set_meta(ingest=do_ingest)
|
|
2185
2764
|
if do_ingest:
|
|
2186
2765
|
try:
|
|
2187
2766
|
cache_conn = _cctally().open_cache_db()
|
|
2188
2767
|
try:
|
|
2189
|
-
|
|
2768
|
+
try:
|
|
2769
|
+
claude_ingest = sync_cache(cache_conn)
|
|
2770
|
+
claude_ingest_contended = bool(
|
|
2771
|
+
getattr(claude_ingest, "lock_contended", False)
|
|
2772
|
+
)
|
|
2773
|
+
except Exception as exc:
|
|
2774
|
+
claude_ingest_failed = True
|
|
2775
|
+
errors.append(f"sync-cache: {exc}")
|
|
2776
|
+
if precompute_envelope:
|
|
2777
|
+
try:
|
|
2778
|
+
codex_ingest = sync_codex_cache(cache_conn)
|
|
2779
|
+
codex_ingest_contended = bool(
|
|
2780
|
+
getattr(codex_ingest, "lock_contended", False)
|
|
2781
|
+
)
|
|
2782
|
+
except Exception as exc:
|
|
2783
|
+
codex_ingest_failed = True
|
|
2784
|
+
errors.append(f"sync-codex-cache: {exc}")
|
|
2190
2785
|
finally:
|
|
2191
2786
|
cache_conn.close()
|
|
2192
2787
|
except Exception as exc:
|
|
2193
|
-
|
|
2788
|
+
claude_ingest_failed = True
|
|
2789
|
+
if precompute_envelope:
|
|
2790
|
+
codex_ingest_failed = True
|
|
2791
|
+
errors.append(f"sync-cache-open: {exc}")
|
|
2194
2792
|
# Force pure reads for every view builder below, independent of the
|
|
2195
2793
|
# caller's flag: the single ingest above is the only glob per tick.
|
|
2196
2794
|
skip_sync = True
|
|
@@ -2239,7 +2837,27 @@ def _tui_build_snapshot(
|
|
|
2239
2837
|
# The signature is computed AFTER the top-of-rebuild ingest so a fresh
|
|
2240
2838
|
# tail-ingested row is reflected before the idle decision is made.
|
|
2241
2839
|
dispatch_key = None
|
|
2840
|
+
# #300: the change signal surfaced on the dashboard envelope. Empty
|
|
2841
|
+
# unless a dispatch signature is computed below (the non-precompute /
|
|
2842
|
+
# TUI path never consumes it). The idle short-circuit carries the prior
|
|
2843
|
+
# value forward via ``dataclasses.replace`` (idle ⇒ signature unchanged).
|
|
2844
|
+
data_version = ""
|
|
2845
|
+
prior_source_bundle: SourceDashboardBundle | None = None
|
|
2846
|
+
_sc = None
|
|
2847
|
+
prior_key = None
|
|
2848
|
+
prior_snap = None
|
|
2242
2849
|
if precompute_envelope:
|
|
2850
|
+
# The last published provider generation is independently useful
|
|
2851
|
+
# when calculating today's signature fails. Read it before the
|
|
2852
|
+
# new digest so an identity/build failure can retain the complete
|
|
2853
|
+
# old bundle rather than publishing a missing replacement.
|
|
2854
|
+
try:
|
|
2855
|
+
_sc = _cctally()._load_sibling("_lib_snapshot_cache")
|
|
2856
|
+
prior_key, prior_snap = _sc.dispatch_state()
|
|
2857
|
+
if prior_snap is not None:
|
|
2858
|
+
prior_source_bundle = getattr(prior_snap, "source_bundle", None)
|
|
2859
|
+
except Exception as exc:
|
|
2860
|
+
errors.append(f"prior-source-bundle: {exc}")
|
|
2243
2861
|
dispatch_sig = None
|
|
2244
2862
|
with _perf.phase("signature"):
|
|
2245
2863
|
try:
|
|
@@ -2263,8 +2881,10 @@ def _tui_build_snapshot(
|
|
|
2263
2881
|
json.dumps(raw_config, sort_keys=True, default=str),
|
|
2264
2882
|
)
|
|
2265
2883
|
dispatch_key = (dispatch_sig, render_key)
|
|
2266
|
-
|
|
2267
|
-
|
|
2884
|
+
# #300: carry the change signal onto the non-idle snapshot built
|
|
2885
|
+
# below. (The idle path returns `idle_snap`, which inherits the
|
|
2886
|
+
# prior — equal, since idle ⇒ signature unchanged — value.)
|
|
2887
|
+
data_version = _snapshot_data_version(dispatch_sig)
|
|
2268
2888
|
if (prior_snap is not None and prior_key is not None
|
|
2269
2889
|
and dispatch_key == prior_key
|
|
2270
2890
|
and not _snapshot_period_rolled_over(
|
|
@@ -2274,8 +2894,20 @@ def _tui_build_snapshot(
|
|
|
2274
2894
|
prior_snap, now_utc=now_utc,
|
|
2275
2895
|
precompute_envelope=precompute_envelope,
|
|
2276
2896
|
runtime_bind=runtime_bind, raw_config=raw_config,
|
|
2897
|
+
display_tz_pref_override=display_tz_pref_override,
|
|
2898
|
+
source_stats_conn=conn,
|
|
2899
|
+
source_display_tz_name=(
|
|
2900
|
+
getattr(_build_display_tz, "key", None)
|
|
2901
|
+
if _build_display_tz is not None else None
|
|
2902
|
+
),
|
|
2903
|
+
source_display_tz=_build_display_tz,
|
|
2904
|
+
codex_ingest_contended=codex_ingest_contended,
|
|
2905
|
+
codex_ingest_failed=codex_ingest_failed,
|
|
2906
|
+
claude_ingest_contended=claude_ingest_contended,
|
|
2907
|
+
claude_ingest_failed=claude_ingest_failed,
|
|
2277
2908
|
errors=errors,
|
|
2278
2909
|
)
|
|
2910
|
+
assert _sc is not None
|
|
2279
2911
|
_sc.store_dispatch_state(dispatch_key, idle_snap)
|
|
2280
2912
|
_p_snapshot.__exit__(None, None, None)
|
|
2281
2913
|
if _perf.enabled():
|
|
@@ -2741,6 +3373,15 @@ def _tui_build_snapshot(
|
|
|
2741
3373
|
except Exception as exc:
|
|
2742
3374
|
errors.append(f"envelope-precompute: {exc}")
|
|
2743
3375
|
|
|
3376
|
+
# Determine the shared visible interval before publishing either source.
|
|
3377
|
+
# The actual source bundle is built after ``snap`` exists, so Claude's
|
|
3378
|
+
# entry can be projected from this exact completed legacy snapshot.
|
|
3379
|
+
common_range_start: dt.datetime | None = None
|
|
3380
|
+
if precompute_envelope:
|
|
3381
|
+
common_range_start = _tui_common_source_range_start(
|
|
3382
|
+
daily_panel, now_utc=now_utc, display_tz=_build_display_tz,
|
|
3383
|
+
)
|
|
3384
|
+
|
|
2744
3385
|
snap = DataSnapshot(
|
|
2745
3386
|
current_week=cw,
|
|
2746
3387
|
forecast=fc,
|
|
@@ -2772,7 +3413,65 @@ def _tui_build_snapshot(
|
|
|
2772
3413
|
cache_report=cache_report_block,
|
|
2773
3414
|
doctor_payload=doctor_payload_block,
|
|
2774
3415
|
envelope_precompute=envelope_precompute_block,
|
|
3416
|
+
data_version=data_version,
|
|
3417
|
+
source_bundle=None,
|
|
2775
3418
|
)
|
|
3419
|
+
if precompute_envelope:
|
|
3420
|
+
source_bundle: SourceDashboardBundle | None = None
|
|
3421
|
+
try:
|
|
3422
|
+
# ``snapshot_to_envelope`` is a pure snapshot projection when
|
|
3423
|
+
# its precomputed doctor/config blocks are present. If the
|
|
3424
|
+
# optional doctor precompute failed, use a local sentinel for
|
|
3425
|
+
# this source-only projection so it cannot recover by opening
|
|
3426
|
+
# another database connection; the Claude adapter ignores the
|
|
3427
|
+
# doctor field entirely.
|
|
3428
|
+
source_snapshot = snap
|
|
3429
|
+
if source_snapshot.doctor_payload is None:
|
|
3430
|
+
source_snapshot = dataclasses.replace(
|
|
3431
|
+
source_snapshot,
|
|
3432
|
+
doctor_payload={
|
|
3433
|
+
"severity": "fail",
|
|
3434
|
+
"counts": {"ok": 0, "warn": 0, "fail": 1},
|
|
3435
|
+
"generated_at": now_utc.isoformat(),
|
|
3436
|
+
"fingerprint": "source-projection",
|
|
3437
|
+
},
|
|
3438
|
+
)
|
|
3439
|
+
legacy_envelope = _cctally().snapshot_to_envelope(
|
|
3440
|
+
source_snapshot,
|
|
3441
|
+
now_utc=now_utc,
|
|
3442
|
+
display_tz_pref_override=display_tz_pref_override,
|
|
3443
|
+
runtime_bind=runtime_bind,
|
|
3444
|
+
)
|
|
3445
|
+
source_bundle = _tui_build_source_bundle(
|
|
3446
|
+
stats_conn=conn,
|
|
3447
|
+
now_utc=now_utc,
|
|
3448
|
+
display_tz_name=(
|
|
3449
|
+
getattr(_build_display_tz, "key", None)
|
|
3450
|
+
if _build_display_tz is not None else None
|
|
3451
|
+
),
|
|
3452
|
+
codex_ingest_contended=codex_ingest_contended,
|
|
3453
|
+
codex_ingest_failed=codex_ingest_failed,
|
|
3454
|
+
claude_ingest_contended=claude_ingest_contended,
|
|
3455
|
+
claude_ingest_failed=claude_ingest_failed,
|
|
3456
|
+
claude_cost_usd=daily_total_cost_usd,
|
|
3457
|
+
claude_total_tokens=daily_total_tokens,
|
|
3458
|
+
claude_data=_tui_project_claude_source_data(legacy_envelope),
|
|
3459
|
+
common_range_start=common_range_start,
|
|
3460
|
+
prior_bundle=prior_source_bundle,
|
|
3461
|
+
raw_config=raw_config,
|
|
3462
|
+
)
|
|
3463
|
+
if source_bundle is None:
|
|
3464
|
+
raise RuntimeError("source bundle builder returned no bundle")
|
|
3465
|
+
except Exception as exc:
|
|
3466
|
+
# Public source warnings are stable/sanitized; the detailed
|
|
3467
|
+
# exception remains only on the internal rebuild-error string.
|
|
3468
|
+
errors.append(f"source-bundle: {exc}")
|
|
3469
|
+
source_bundle = prior_source_bundle
|
|
3470
|
+
snap = dataclasses.replace(
|
|
3471
|
+
snap,
|
|
3472
|
+
last_sync_error=("; ".join(errors) if errors else None),
|
|
3473
|
+
source_bundle=source_bundle,
|
|
3474
|
+
)
|
|
2776
3475
|
# #268 M5.1: record the (signature+render key, snapshot) so the next
|
|
2777
3476
|
# dashboard tick can idle-short-circuit when nothing changed. Full-build
|
|
2778
3477
|
# path only sets it when the key was computed (precompute_envelope); the
|
|
@@ -2888,7 +3587,10 @@ def _tui_compute_dispatch_signature(stats_conn):
|
|
|
2888
3587
|
cache_conn = c.open_cache_db()
|
|
2889
3588
|
try:
|
|
2890
3589
|
return sc.compute_signature(
|
|
2891
|
-
cache_conn,
|
|
3590
|
+
cache_conn,
|
|
3591
|
+
stats_conn,
|
|
3592
|
+
generation=sc.current_generation(),
|
|
3593
|
+
codex_stats_digest=codex_stats_digest(stats_conn),
|
|
2892
3594
|
)
|
|
2893
3595
|
finally:
|
|
2894
3596
|
cache_conn.close()
|
|
@@ -2946,7 +3648,15 @@ def _snapshot_period_rolled_over(prior, now_utc, display_tz):
|
|
|
2946
3648
|
|
|
2947
3649
|
|
|
2948
3650
|
def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
|
|
2949
|
-
runtime_bind, raw_config, errors
|
|
3651
|
+
runtime_bind, raw_config, errors,
|
|
3652
|
+
display_tz_pref_override=None,
|
|
3653
|
+
source_stats_conn=None,
|
|
3654
|
+
source_display_tz_name=None,
|
|
3655
|
+
source_display_tz: dt.tzinfo | None = None,
|
|
3656
|
+
codex_ingest_contended=False,
|
|
3657
|
+
codex_ingest_failed=False,
|
|
3658
|
+
claude_ingest_contended=False,
|
|
3659
|
+
claude_ingest_failed=False):
|
|
2950
3660
|
"""Fresh snapshot reusing ``prior``'s heavy rows, re-patching only the
|
|
2951
3661
|
time-derived fields + the doctor payload / envelope precompute on each idle
|
|
2952
3662
|
tick (spec §3 idle path).
|
|
@@ -2986,6 +3696,68 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
|
|
|
2986
3696
|
envelope_precompute = _tui_precompute_envelope_config(raw_config)
|
|
2987
3697
|
except Exception as exc: # noqa: BLE001 — never crash the rebuild
|
|
2988
3698
|
errors.append(f"envelope-precompute: {exc}")
|
|
3699
|
+
source_bundle = prior.source_bundle
|
|
3700
|
+
if source_bundle is not None:
|
|
3701
|
+
try:
|
|
3702
|
+
prior_claude = source_bundle.sources["claude"]
|
|
3703
|
+
prior_codex = source_bundle.sources["codex"]
|
|
3704
|
+
if _tui_source_bundle_can_idle(source_bundle):
|
|
3705
|
+
codex = refresh_codex_source_clock(prior_codex, now_utc=now_utc)
|
|
3706
|
+
if codex is not prior_codex:
|
|
3707
|
+
source_bundle = SourceDashboardBundle(
|
|
3708
|
+
source_schema_version=source_bundle.source_schema_version,
|
|
3709
|
+
default_source=source_bundle.default_source,
|
|
3710
|
+
source_order=source_bundle.source_order,
|
|
3711
|
+
sources={
|
|
3712
|
+
"claude": prior_claude,
|
|
3713
|
+
"codex": codex,
|
|
3714
|
+
"all": compose_all_state(prior_claude, codex),
|
|
3715
|
+
},
|
|
3716
|
+
)
|
|
3717
|
+
elif source_stats_conn is not None:
|
|
3718
|
+
# A provider can become coherent without changing the global
|
|
3719
|
+
# data signature (notably after its post-projection certificate
|
|
3720
|
+
# is written). Re-run only the bounded source adapter while
|
|
3721
|
+
# preserving the already-idle legacy snapshot rows.
|
|
3722
|
+
source_snapshot = prior
|
|
3723
|
+
if source_snapshot.doctor_payload is None:
|
|
3724
|
+
source_snapshot = dataclasses.replace(
|
|
3725
|
+
source_snapshot,
|
|
3726
|
+
doctor_payload={
|
|
3727
|
+
"severity": "fail",
|
|
3728
|
+
"counts": {"ok": 0, "warn": 0, "fail": 1},
|
|
3729
|
+
"generated_at": now_utc.isoformat(),
|
|
3730
|
+
"fingerprint": "source-projection",
|
|
3731
|
+
},
|
|
3732
|
+
)
|
|
3733
|
+
legacy_envelope = _cctally().snapshot_to_envelope(
|
|
3734
|
+
source_snapshot,
|
|
3735
|
+
now_utc=now_utc,
|
|
3736
|
+
display_tz_pref_override=display_tz_pref_override,
|
|
3737
|
+
runtime_bind=runtime_bind,
|
|
3738
|
+
)
|
|
3739
|
+
source_bundle = _tui_build_source_bundle(
|
|
3740
|
+
stats_conn=source_stats_conn,
|
|
3741
|
+
now_utc=now_utc,
|
|
3742
|
+
display_tz_name=source_display_tz_name,
|
|
3743
|
+
codex_ingest_contended=codex_ingest_contended,
|
|
3744
|
+
codex_ingest_failed=codex_ingest_failed,
|
|
3745
|
+
claude_ingest_contended=claude_ingest_contended,
|
|
3746
|
+
claude_ingest_failed=claude_ingest_failed,
|
|
3747
|
+
claude_cost_usd=prior.daily_total_cost_usd,
|
|
3748
|
+
claude_total_tokens=prior.daily_total_tokens,
|
|
3749
|
+
claude_data=_tui_project_claude_source_data(legacy_envelope),
|
|
3750
|
+
common_range_start=_tui_common_source_range_start(
|
|
3751
|
+
prior.daily_panel,
|
|
3752
|
+
now_utc=now_utc,
|
|
3753
|
+
display_tz=source_display_tz,
|
|
3754
|
+
),
|
|
3755
|
+
prior_bundle=source_bundle,
|
|
3756
|
+
raw_config=raw_config,
|
|
3757
|
+
)
|
|
3758
|
+
except Exception as exc: # noqa: BLE001 — retain prior complete bundle
|
|
3759
|
+
errors.append(f"source-clock-refresh: {exc}")
|
|
3760
|
+
source_bundle = prior.source_bundle
|
|
2989
3761
|
return dataclasses.replace(
|
|
2990
3762
|
prior,
|
|
2991
3763
|
generated_at=now_utc,
|
|
@@ -2993,6 +3765,7 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
|
|
|
2993
3765
|
last_sync_error=("; ".join(errors) if errors else None),
|
|
2994
3766
|
doctor_payload=doctor_payload,
|
|
2995
3767
|
envelope_precompute=envelope_precompute,
|
|
3768
|
+
source_bundle=source_bundle,
|
|
2996
3769
|
# #278 §1.4.1: an idle snapshot means the data-version signature is
|
|
2997
3770
|
# unchanged (data stable) → force the hydration latch clear even if
|
|
2998
3771
|
# ``prior`` was a hydrating seed/partial.
|
|
@@ -3437,6 +4210,7 @@ class _TuiSyncThread:
|
|
|
3437
4210
|
# dataclass defaults and re-forking `security` per client.
|
|
3438
4211
|
doctor_payload=prev.doctor_payload,
|
|
3439
4212
|
envelope_precompute=prev.envelope_precompute,
|
|
4213
|
+
source_bundle=prev.source_bundle,
|
|
3440
4214
|
))
|
|
3441
4215
|
# Wait up to interval, or until forced.
|
|
3442
4216
|
for _ in range(int(max(1, self._interval * 10))):
|
|
@@ -5326,6 +6100,12 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override,
|
|
|
5326
6100
|
# target is `cctally-bench --trace`, which builds directly
|
|
5327
6101
|
# (no decoupled standalone ingest) and keeps its phase tree.
|
|
5328
6102
|
sync_cache(cache_conn, progress=cb)
|
|
6103
|
+
# Dashboard S4's physical identity includes Codex. Keep
|
|
6104
|
+
# the decoupled path's ingest set identical to the direct
|
|
6105
|
+
# dashboard snapshot path before its final pure read, or
|
|
6106
|
+
# it can publish a legacy data version from a different
|
|
6107
|
+
# provider generation.
|
|
6108
|
+
sync_codex_cache(cache_conn)
|
|
5329
6109
|
except Exception as exc: # noqa: BLE001 — surfaced on the snap
|
|
5330
6110
|
sync_error = f"sync-cache: {exc}"
|
|
5331
6111
|
finally:
|