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.
Files changed (40) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/bin/_cctally_alerts.py +54 -2
  3. package/bin/_cctally_cache.py +644 -107
  4. package/bin/_cctally_cache_report.py +41 -17
  5. package/bin/_cctally_codex.py +109 -4
  6. package/bin/_cctally_config.py +368 -2
  7. package/bin/_cctally_core.py +168 -5
  8. package/bin/_cctally_dashboard.py +679 -5
  9. package/bin/_cctally_dashboard_conversation.py +9 -4
  10. package/bin/_cctally_dashboard_envelope.py +110 -1
  11. package/bin/_cctally_dashboard_share.py +557 -79
  12. package/bin/_cctally_db.py +303 -17
  13. package/bin/_cctally_diff.py +49 -16
  14. package/bin/_cctally_doctor.py +118 -0
  15. package/bin/_cctally_forecast.py +12 -4
  16. package/bin/_cctally_parser.py +298 -92
  17. package/bin/_cctally_project.py +24 -8
  18. package/bin/_cctally_quota.py +1381 -0
  19. package/bin/_cctally_record.py +319 -14
  20. package/bin/_cctally_refresh.py +105 -3
  21. package/bin/_cctally_reporting.py +7 -1
  22. package/bin/_cctally_setup.py +343 -113
  23. package/bin/_cctally_statusline.py +228 -0
  24. package/bin/_cctally_tui.py +787 -7
  25. package/bin/_lib_alert_axes.py +11 -0
  26. package/bin/_lib_codex_hooks.py +367 -0
  27. package/bin/_lib_conversation_query.py +156 -67
  28. package/bin/_lib_doctor.py +202 -0
  29. package/bin/_lib_jsonl.py +529 -162
  30. package/bin/_lib_quota.py +566 -0
  31. package/bin/_lib_share.py +152 -34
  32. package/bin/_lib_snapshot_cache.py +26 -0
  33. package/bin/_lib_source_identity.py +74 -0
  34. package/bin/cctally +324 -10
  35. package/dashboard/static/assets/index-CBbErI-P.js +80 -0
  36. package/dashboard/static/assets/index-kDDVOLa_.css +1 -0
  37. package/dashboard/static/dashboard.html +2 -2
  38. package/package.json +5 -1
  39. package/dashboard/static/assets/index-BybNp_Di.js +0 -80
  40. package/dashboard/static/assets/index-DgAmLK65.css +0 -1
@@ -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
- sync_cache(cache_conn)
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
- errors.append(f"sync-cache: {exc}")
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
- _sc = _cctally()._load_sibling("_lib_snapshot_cache")
2267
- prior_key, prior_snap = _sc.dispatch_state()
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, stats_conn, generation=sc.current_generation(),
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: