cctally 1.82.0 → 1.83.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 (52) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +52 -74
  3. package/bin/_cctally_alerts.py +8 -1
  4. package/bin/_cctally_cache.py +963 -149
  5. package/bin/_cctally_config.py +43 -4
  6. package/bin/_cctally_core.py +933 -759
  7. package/bin/_cctally_dashboard.py +157 -47
  8. package/bin/_cctally_dashboard_cache_report.py +13 -6
  9. package/bin/_cctally_dashboard_conversation.py +1 -0
  10. package/bin/_cctally_dashboard_envelope.py +186 -8
  11. package/bin/_cctally_dashboard_share.py +60 -20
  12. package/bin/_cctally_dashboard_sources.py +427 -128
  13. package/bin/_cctally_db.py +605 -128
  14. package/bin/_cctally_doctor.py +413 -28
  15. package/bin/_cctally_five_hour.py +12 -5
  16. package/bin/_cctally_journal.py +2050 -156
  17. package/bin/_cctally_journal_repair.py +519 -0
  18. package/bin/_cctally_milestone_history.py +142 -56
  19. package/bin/_cctally_milestones.py +179 -111
  20. package/bin/_cctally_parser.py +42 -0
  21. package/bin/_cctally_project.py +24 -18
  22. package/bin/_cctally_quota.py +139 -25
  23. package/bin/_cctally_record.py +279 -108
  24. package/bin/_cctally_rederive.py +1052 -0
  25. package/bin/_cctally_reporting.py +58 -53
  26. package/bin/_cctally_setup.py +1 -0
  27. package/bin/_cctally_source_analytics.py +4 -1
  28. package/bin/_cctally_statusline.py +11 -11
  29. package/bin/_cctally_store.py +1039 -31
  30. package/bin/_cctally_sync_week.py +17 -8
  31. package/bin/_cctally_tui.py +421 -54
  32. package/bin/_cctally_update.py +133 -8
  33. package/bin/_cctally_weekrefs.py +14 -0
  34. package/bin/_lib_aggregators.py +10 -6
  35. package/bin/_lib_cache_report.py +101 -9
  36. package/bin/_lib_codex_pools.py +82 -0
  37. package/bin/_lib_conversation_query.py +126 -33
  38. package/bin/_lib_dashboard_sources.py +126 -1
  39. package/bin/_lib_diff_kernel.py +28 -15
  40. package/bin/_lib_doctor.py +342 -4
  41. package/bin/_lib_journal.py +924 -2
  42. package/bin/_lib_jsonl.py +43 -14
  43. package/bin/_lib_pricing.py +140 -21
  44. package/bin/_lib_readme_refresh.py +401 -0
  45. package/bin/_lib_rederive.py +395 -0
  46. package/bin/_lib_share.py +58 -2
  47. package/bin/cctally +56 -8
  48. package/dashboard/static/assets/{index-DJP4gEB7.js → index-3bgCMVHb.js} +52 -52
  49. package/dashboard/static/assets/index-D27EIHEI.css +1 -0
  50. package/dashboard/static/dashboard.html +2 -2
  51. package/package.json +6 -1
  52. package/dashboard/static/assets/index-Dk1nplOz.css +0 -1
@@ -349,7 +349,8 @@ from _lib_display_tz import (
349
349
  )
350
350
  from _lib_aggregators import _aggregate_daily, _aggregate_monthly, _aggregate_weekly
351
351
  from _lib_fmt import stable_sum
352
- from _lib_pricing import _calculate_entry_cost, _chip_for_model, _short_model_name
352
+ from _lib_pricing import (_calculate_entry_cost, _chip_for_model,
353
+ _short_model_name, claude_usage_dict)
353
354
  from _lib_five_hour import _canonical_5h_window_key, _round_to_ten_minutes
354
355
  from _lib_subscription_weeks import _compute_subscription_weeks
355
356
  from _lib_blocks import _group_entries_into_blocks
@@ -2983,7 +2984,8 @@ def _projects_iter_session_entries(conn: "sqlite3.Connection",
2983
2984
  "SELECT e.id, e.timestamp_utc, e.model, e.input_tokens, "
2984
2985
  " e.output_tokens, e.cache_create_tokens, e.cache_read_tokens, "
2985
2986
  " e.cost_usd_raw, e.source_path, "
2986
- " sf.session_id, sf.project_path "
2987
+ " sf.session_id, sf.project_path, "
2988
+ " e.cache_create_1h_tokens, e.speed "
2987
2989
  "FROM session_entries e "
2988
2990
  "LEFT JOIN session_files sf ON sf.path = e.source_path "
2989
2991
  "WHERE e.mutation_seq > ? AND +e.timestamp_utc >= ? AND +e.timestamp_utc <= ? "
@@ -2995,7 +2997,8 @@ def _projects_iter_session_entries(conn: "sqlite3.Connection",
2995
2997
  "SELECT e.id, e.timestamp_utc, e.model, e.input_tokens, "
2996
2998
  " e.output_tokens, e.cache_create_tokens, e.cache_read_tokens, "
2997
2999
  " e.cost_usd_raw, e.source_path, "
2998
- " sf.session_id, sf.project_path "
3000
+ " sf.session_id, sf.project_path, "
3001
+ " e.cache_create_1h_tokens, e.speed "
2999
3002
  "FROM session_entries e "
3000
3003
  "LEFT JOIN session_files sf ON sf.path = e.source_path "
3001
3004
  "WHERE e.timestamp_utc >= ? AND e.timestamp_utc <= ? "
@@ -3060,7 +3063,7 @@ def _fold_projects_entry(
3060
3063
  c = _cctally()
3061
3064
  (entry_id, ts_iso, model, input_tok, output_tok,
3062
3065
  cache_create, cache_read, cost_raw, source_path,
3063
- session_id, project_path) = row
3066
+ session_id, project_path, cache_1h, speed) = row
3064
3067
  if model == "<synthetic>":
3065
3068
  return None
3066
3069
  ts = parse_iso_datetime(ts_iso, "session_entries.timestamp_utc")
@@ -3068,12 +3071,14 @@ def _fold_projects_entry(
3068
3071
  return None
3069
3072
  entry_cost = _calculate_entry_cost(
3070
3073
  model,
3071
- {
3072
- "input_tokens": input_tok or 0,
3073
- "output_tokens": output_tok or 0,
3074
- "cache_creation_input_tokens": cache_create or 0,
3075
- "cache_read_input_tokens": cache_read or 0,
3076
- },
3074
+ claude_usage_dict( # #195 chokepoint
3075
+ input_tokens=input_tok,
3076
+ output_tokens=output_tok,
3077
+ cache_creation_tokens=cache_create,
3078
+ cache_read_tokens=cache_read,
3079
+ cache_1h_tokens=cache_1h,
3080
+ speed=speed,
3081
+ ),
3077
3082
  mode="auto",
3078
3083
  cost_usd=cost_raw,
3079
3084
  )
@@ -3457,7 +3462,7 @@ def _build_projects_envelope(
3457
3462
  ):
3458
3463
  (entry_id, ts_iso, model, input_tok, output_tok,
3459
3464
  cache_create, cache_read, cost_raw, source_path,
3460
- session_id, project_path) = row
3465
+ session_id, project_path, cache_1h, speed) = row
3461
3466
  if model == "<synthetic>":
3462
3467
  continue
3463
3468
  # Parse timestamp; assume Z / +00:00 — production iterators do
@@ -3470,12 +3475,14 @@ def _build_projects_envelope(
3470
3475
  # Entry cost via the shared pricing chokepoint.
3471
3476
  entry_cost = _calculate_entry_cost(
3472
3477
  model,
3473
- {
3474
- "input_tokens": input_tok or 0,
3475
- "output_tokens": output_tok or 0,
3476
- "cache_creation_input_tokens": cache_create or 0,
3477
- "cache_read_input_tokens": cache_read or 0,
3478
- },
3478
+ claude_usage_dict( # #195 chokepoint
3479
+ input_tokens=input_tok,
3480
+ output_tokens=output_tok,
3481
+ cache_creation_tokens=cache_create,
3482
+ cache_read_tokens=cache_read,
3483
+ cache_1h_tokens=cache_1h,
3484
+ speed=speed,
3485
+ ),
3479
3486
  mode="auto",
3480
3487
  cost_usd=cost_raw,
3481
3488
  )
@@ -3874,7 +3881,8 @@ def _project_detail_for_window(
3874
3881
  "SELECT e.id, e.timestamp_utc, e.model, e.input_tokens, "
3875
3882
  " e.output_tokens, e.cache_create_tokens, "
3876
3883
  " e.cache_read_tokens, e.cost_usd_raw, e.source_path, "
3877
- " sf.session_id, sf.project_path "
3884
+ " sf.session_id, sf.project_path, e.cache_create_1h_tokens, "
3885
+ " e.speed "
3878
3886
  "FROM session_entries e "
3879
3887
  "INNER JOIN _drill_paths dp ON dp.path = e.source_path "
3880
3888
  "LEFT JOIN session_files sf ON sf.path = e.source_path "
@@ -3896,7 +3904,7 @@ def _project_detail_for_window(
3896
3904
  for row in entries_cur:
3897
3905
  (entry_id, ts_iso, model, input_tok, output_tok,
3898
3906
  cache_create, cache_read, cost_raw, source_path,
3899
- session_id, project_path) = row
3907
+ session_id, project_path, cache_1h, speed) = row
3900
3908
  if model == "<synthetic>":
3901
3909
  continue
3902
3910
  # No need to call _resolve_project_key here — the INNER JOIN
@@ -3905,12 +3913,14 @@ def _project_detail_for_window(
3905
3913
  ts = parse_iso_datetime(ts_iso, "session_entries.timestamp_utc")
3906
3914
  entry_cost = _calculate_entry_cost(
3907
3915
  model,
3908
- {
3909
- "input_tokens": input_tok or 0,
3910
- "output_tokens": output_tok or 0,
3911
- "cache_creation_input_tokens": cache_create or 0,
3912
- "cache_read_input_tokens": cache_read or 0,
3913
- },
3916
+ claude_usage_dict( # #195 chokepoint
3917
+ input_tokens=input_tok,
3918
+ output_tokens=output_tok,
3919
+ cache_creation_tokens=cache_create,
3920
+ cache_read_tokens=cache_read,
3921
+ cache_1h_tokens=cache_1h,
3922
+ speed=speed,
3923
+ ),
3914
3924
  mode="auto",
3915
3925
  cost_usd=cost_raw,
3916
3926
  )
@@ -4324,9 +4334,7 @@ def _debug_source_counts(cache_conn, bundle) -> dict:
4324
4334
  pass
4325
4335
  stats_conn = None
4326
4336
  try:
4327
- stats_conn = sqlite3.connect(
4328
- f"{_cctally_core.DB_PATH.as_uri()}?mode=ro", uri=True
4329
- )
4337
+ stats_conn = _stats_ro_guarded()
4330
4338
  for source, tables in _DEBUG_SOURCE_STATS_TABLES.items():
4331
4339
  for table, where in tables:
4332
4340
  try:
@@ -4344,6 +4352,29 @@ def _debug_source_counts(cache_conn, bundle) -> dict:
4344
4352
  return result
4345
4353
 
4346
4354
 
4355
+ def _stats_ro_guarded():
4356
+ """A `mode=ro` stats connection that participates in the #386 opener protocol.
4357
+
4358
+ A read-only opener is NOT exempt. Measured on this platform: a `mode=ro`
4359
+ connection to a WAL database whose sidecars are absent CREATES both
4360
+ `stats.db-shm` and `stats.db-wal`. That is exactly the cross-generation
4361
+ sidecar pairing spec §1.2 identifies as where SQLite's crash guarantees stop
4362
+ applying once another process renames the main file underneath — so these
4363
+ diagnostics must observe the repair marker and the quarantine-pending record
4364
+ under maintenance-shared like every other opener.
4365
+
4366
+ Raises `StatsDbMaintenanceError` (an `sqlite3.OperationalError`) during a
4367
+ replacement; both callers already degrade on `sqlite3.Error`.
4368
+ """
4369
+ import _cctally_store
4370
+
4371
+ return _cctally_store.stats_open_guarded(
4372
+ _cctally_core.DB_PATH,
4373
+ connect=lambda p: sqlite3.connect(
4374
+ f"{pathlib.Path(p).as_uri()}?mode=ro", uri=True),
4375
+ )
4376
+
4377
+
4347
4378
  def _debug_cache_state(cache_conn) -> dict:
4348
4379
  """On-demand signature legs + pending-reingest flags + generation.
4349
4380
 
@@ -4357,9 +4388,7 @@ def _debug_cache_state(cache_conn) -> dict:
4357
4388
  state: dict = {"generation": sc.current_generation()}
4358
4389
  stats_conn = None
4359
4390
  try:
4360
- stats_conn = sqlite3.connect(
4361
- f"{_cctally_core.DB_PATH.as_uri()}?mode=ro", uri=True
4362
- )
4391
+ stats_conn = _stats_ro_guarded()
4363
4392
  except sqlite3.Error:
4364
4393
  stats_conn = None
4365
4394
  try:
@@ -6359,7 +6388,6 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6359
6388
  404 ``{code: "unknown_key", reason}`` for keys that don't resolve.
6360
6389
  """
6361
6390
  import re as _re
6362
- import types as _types
6363
6391
  import urllib.parse as _urlparse
6364
6392
  from _cctally_cache import open_cache_db
6365
6393
 
@@ -6403,7 +6431,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6403
6431
  # matches its exact cycle without needing the hero-cycle identity —
6404
6432
  # this also keeps a just-closed former-current cycle fetchable when
6405
6433
  # the live hero cycle is momentarily unavailable, spec §2).
6406
- from _cctally_dashboard_sources import resolve_dashboard_source_semantics
6434
+ from _cctally_dashboard_sources import (
6435
+ resolve_codex_cycle_detail_identity,
6436
+ resolve_dashboard_source_semantics,
6437
+ )
6407
6438
  speed = resolve_dashboard_source_semantics(
6408
6439
  load_config(), display_tz_name="UTC",
6409
6440
  ).speed
@@ -6422,7 +6453,14 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6422
6453
  "WHERE source='codex'"
6423
6454
  )
6424
6455
  }))
6425
- identity = _types.SimpleNamespace(source_root_keys=roots, resets_at=None)
6456
+ # #373: resolve the SAME live boundary the cycle index was built
6457
+ # with. A stub with no `resets_at` makes `_codex_is_current`
6458
+ # fall through to `cyc.reset > now_utc`, which marks every
6459
+ # future-ending cycle current — so one cycle key described two
6460
+ # different cycles depending on which route answered.
6461
+ identity = resolve_codex_cycle_detail_identity(
6462
+ cache_conn, source_root_keys=roots, now_utc=now_utc,
6463
+ )
6426
6464
  result = c.build_codex_cycle_detail(
6427
6465
  stats_conn, cache_conn, identity=identity, key=key,
6428
6466
  speed=speed, now_utc=now_utc,
@@ -6563,7 +6601,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6563
6601
  def _handle_post_update(self) -> None:
6564
6602
  """POST /api/update — kick off an in-process update.
6565
6603
 
6566
- Body: ``{"version"?: "X.Y.Z"}``. CSRF-gated. Returns
6604
+ Body: ``{}``. A legacy ``{"version": "X.Y.Z"}`` from an already-open
6605
+ beta dashboard is accepted but treated as an auto-target hint, never
6606
+ as an explicit user pin; the worker resolves the selected channel
6607
+ afresh. CSRF-gated. Returns
6567
6608
  202 + ``{"run_id": ...}`` on accept; 409 + ``{"run_id_in_progress": ...}``
6568
6609
  when another run is already in progress.
6569
6610
  """
@@ -6584,7 +6625,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6584
6625
  400, {"error": "version must be a string"}
6585
6626
  )
6586
6627
  return
6587
- accepted, run_id = worker.start(version)
6628
+ # The dashboard has no explicit-version input. Older clients sent the
6629
+ # cached beta target here; trusting it as a pin recreates #342 whenever
6630
+ # the registry advances while the modal is open.
6631
+ accepted, run_id = worker.start(None)
6588
6632
  if accepted:
6589
6633
  self._respond_json(202, {"run_id": run_id})
6590
6634
  else:
@@ -6813,6 +6857,35 @@ def _dashboard_wait_for_signal(
6813
6857
 
6814
6858
 
6815
6859
  def _dashboard_initial_snapshot(args, *, pinned_now, display_tz_pref_override):
6860
+ """Build the first paint with one post-query stats heal/reopen at most."""
6861
+
6862
+ c = _cctally()
6863
+ tui = c._cctally_tui
6864
+ try:
6865
+ return _dashboard_initial_snapshot_once(
6866
+ args,
6867
+ pinned_now=pinned_now,
6868
+ display_tz_pref_override=display_tz_pref_override,
6869
+ stats_heal_attempted=False,
6870
+ )
6871
+ except tui._StatsSnapshotCorruption as fault:
6872
+ # The once-builder's finally has closed the cheap-seed stats handle.
6873
+ tui._tui_heal_post_query_stats(fault.cause)
6874
+ return _dashboard_initial_snapshot_once(
6875
+ args,
6876
+ pinned_now=pinned_now,
6877
+ display_tz_pref_override=display_tz_pref_override,
6878
+ stats_heal_attempted=True,
6879
+ )
6880
+
6881
+
6882
+ def _dashboard_initial_snapshot_once(
6883
+ args,
6884
+ *,
6885
+ pinned_now,
6886
+ display_tz_pref_override,
6887
+ stats_heal_attempted,
6888
+ ):
6816
6889
  """#278 Theme A (A1): build the dashboard's first snapshot as a CHEAP
6817
6890
  partial on a normal launch so the HTTP port binds in ~110ms instead of
6818
6891
  waiting on the ~2.2s full aggregation. #179 already deferred the *ingest*
@@ -6862,22 +6935,58 @@ def _dashboard_initial_snapshot(args, *, pinned_now, display_tz_pref_override):
6862
6935
  runtime_bind = getattr(args, "host", None)
6863
6936
  base = tui._tui_empty_snapshot(now_utc)
6864
6937
  errors: list[str] = []
6938
+ sync_failures: list[tui.SyncFailureAttribution] = []
6865
6939
  cw = None
6866
6940
  fc = None
6867
6941
  fc_view = None
6868
- conn = open_db()
6942
+ conn = None
6869
6943
  try:
6944
+ conn = open_db()
6945
+ except Exception as exc: # noqa: BLE001 — retry-open must not block bind
6946
+ if (
6947
+ not stats_heal_attempted
6948
+ or not c._is_sqlite_corruption_error(exc)
6949
+ ):
6950
+ raise
6951
+ errors.append(f"stats-open: {exc}")
6952
+ sync_failures.append(
6953
+ tui.SyncFailureAttribution(
6954
+ leg="stats-open",
6955
+ database="stats",
6956
+ corruption=True,
6957
+ )
6958
+ )
6959
+ if conn is not None:
6870
6960
  try:
6871
- cw = tui._tui_build_current_week(conn, now_utc, skip_sync=True)
6872
- except Exception as exc: # noqa: BLE001 — never block the bind
6873
- errors.append(f"current-week: {exc}")
6874
- try:
6875
- fc_view = tui._tui_build_forecast_view(conn, now_utc, skip_sync=True)
6876
- fc = fc_view.output if fc_view is not None else None
6877
- except Exception as exc: # noqa: BLE001
6878
- errors.append(f"forecast: {exc}")
6879
- finally:
6880
- conn.close()
6961
+ try:
6962
+ cw = tui._tui_build_current_week(conn, now_utc, skip_sync=True)
6963
+ except Exception as exc: # noqa: BLE001 — never block the bind
6964
+ tui._tui_capture_sync_failure(
6965
+ conn,
6966
+ errors,
6967
+ sync_failures,
6968
+ leg="current-week",
6969
+ database="stats_or_cache",
6970
+ exc=exc,
6971
+ stats_heal_attempted=stats_heal_attempted,
6972
+ )
6973
+ try:
6974
+ fc_view = tui._tui_build_forecast_view(
6975
+ conn, now_utc, skip_sync=True
6976
+ )
6977
+ fc = fc_view.output if fc_view is not None else None
6978
+ except Exception as exc: # noqa: BLE001
6979
+ tui._tui_capture_sync_failure(
6980
+ conn,
6981
+ errors,
6982
+ sync_failures,
6983
+ leg="forecast",
6984
+ database="stats_or_cache",
6985
+ exc=exc,
6986
+ stats_heal_attempted=stats_heal_attempted,
6987
+ )
6988
+ finally:
6989
+ conn.close()
6881
6990
  # §1.3: run BOTH precomputes for real so the envelope serializes cleanly
6882
6991
  # without the per-connection inline-doctor fork or the config/update KeyErrors.
6883
6992
  doctor_payload = None
@@ -6897,6 +7006,7 @@ def _dashboard_initial_snapshot(args, *, pinned_now, display_tz_pref_override):
6897
7006
  forecast_view=fc_view,
6898
7007
  last_sync_at=_time.monotonic(),
6899
7008
  last_sync_error=("; ".join(errors) if errors else None),
7009
+ sync_failures=tuple(sync_failures),
6900
7010
  doctor_payload=doctor_payload,
6901
7011
  envelope_precompute=envelope_precompute,
6902
7012
  hydrating=True,
@@ -28,6 +28,7 @@ from dataclasses import dataclass
28
28
 
29
29
  from _lib_fmt import stable_sum
30
30
  from _lib_pricing import _calculate_entry_cost
31
+ from _lib_pricing import claude_usage_dict as _claude_usage_dict
31
32
 
32
33
 
33
34
  # === Cache-report settings validator (spec 2026-05-21 §6) ================
@@ -406,12 +407,18 @@ def build_cache_report_snapshot(
406
407
  timestamp=e.timestamp,
407
408
  model=e.model,
408
409
  cost_usd=e.cost_usd,
409
- usage={
410
- "input_tokens": e.input_tokens,
411
- "output_tokens": e.output_tokens,
412
- "cache_creation_input_tokens": e.cache_creation_tokens,
413
- "cache_read_input_tokens": e.cache_read_tokens,
414
- },
410
+ usage=_claude_usage_dict( # #195 chokepoint
411
+ input_tokens=e.input_tokens,
412
+ output_tokens=e.output_tokens,
413
+ cache_creation_tokens=e.cache_creation_tokens,
414
+ cache_read_tokens=e.cache_read_tokens,
415
+ # #195 / acceptance 7b: this bridge feeds BOTH the day-mode
416
+ # cost_calculator AND _compute_entry_cache_dollars, so
417
+ # dropping the split here would leave the dashboard's
418
+ # Wasted $/Net $ 5m-priced while its total cost was correct.
419
+ cache_1h_tokens=getattr(e, "cache_1h_tokens", None),
420
+ speed=getattr(e, "speed", None),
421
+ ),
415
422
  )
416
423
  for e in raw
417
424
  ]
@@ -859,6 +859,7 @@ def _make_codex_discovery_step(handler, conn, conversation_key, cq_codex):
859
859
  lambda active_conn: sync_codex_cache(
860
860
  active_conn, only_paths=set(to_ingest)
861
861
  ),
862
+ origin="dashboard.conversation.codex_sync",
862
863
  )
863
864
  finally:
864
865
  core.close()
@@ -52,6 +52,9 @@ from _cctally_core import (
52
52
  _get_alerts_config,
53
53
  _get_budget_config,
54
54
  )
55
+ from _lib_dashboard_sources import (
56
+ dashboard_resource_key as _dashboard_resource_key,
57
+ )
55
58
  from _lib_display_tz import _compute_display_block, format_display_dt
56
59
  from _lib_pricing import _chip_for_model, _short_model_name
57
60
 
@@ -649,6 +652,11 @@ def _unavailable_source_wire() -> dict:
649
652
  return {
650
653
  "availability": "unavailable",
651
654
  "freshness": "stale",
655
+ "domain_freshness": {
656
+ "hero": "stale",
657
+ "quota": "stale",
658
+ "sessions": "stale",
659
+ },
652
660
  "warnings": [{
653
661
  "code": "source_build_failed",
654
662
  "message": "Source data could not be built.",
@@ -666,9 +674,23 @@ def _source_state_to_wire(state: object) -> dict:
666
674
  warnings = getattr(state, "warnings", ())
667
675
  capabilities = getattr(state, "capabilities", {})
668
676
  last_success_at = getattr(state, "last_success_at", None)
677
+ provider_freshness = getattr(state, "freshness", "stale")
678
+ raw_domain_freshness = getattr(state, "domain_freshness", None)
679
+ # Additive-transition fallback: an older in-memory state has no map, so
680
+ # every domain inherits its provider-generation value deterministically.
681
+ domain_freshness = {
682
+ domain: (
683
+ raw_domain_freshness.get(domain)
684
+ if isinstance(raw_domain_freshness, Mapping)
685
+ and raw_domain_freshness.get(domain) in ("fresh", "stale")
686
+ else provider_freshness
687
+ )
688
+ for domain in ("hero", "quota", "sessions")
689
+ }
669
690
  return {
670
691
  "availability": getattr(state, "availability"),
671
- "freshness": getattr(state, "freshness"),
692
+ "freshness": provider_freshness,
693
+ "domain_freshness": domain_freshness,
672
694
  "warnings": [
673
695
  {
674
696
  "code": warning.code,
@@ -788,11 +810,41 @@ def _build_alerts_envelope_array(
788
810
  return out[:limit]
789
811
 
790
812
 
791
- def _sync_failure_envelope(error: str | None) -> dict | None:
813
+ def _sync_failure_envelope(
814
+ error: str | None,
815
+ attributions=(),
816
+ ) -> dict | None:
792
817
  """Classify a raw server sync failure into a privacy-safe UI contract."""
793
818
  if not error:
794
819
  return None
795
820
 
821
+ def attributed(database: str, *, corruption: bool = False) -> bool:
822
+ for item in attributions or ():
823
+ item_database = (
824
+ item.get("database") if isinstance(item, dict)
825
+ else getattr(item, "database", None)
826
+ )
827
+ item_corruption = (
828
+ item.get("corruption") if isinstance(item, dict)
829
+ else getattr(item, "corruption", False)
830
+ )
831
+ if item_database == database and (
832
+ not corruption or bool(item_corruption)
833
+ ):
834
+ return True
835
+ return False
836
+
837
+ # Typed stats ownership wins mixed failures. Raw error text is deliberately
838
+ # not consulted for database identity: it may contain both cache and stats
839
+ # messages and can carry private local paths.
840
+ if attributed("stats", corruption=True):
841
+ return {
842
+ "kind": "stats_corruption",
843
+ "label": "⚠ stats recovery needed",
844
+ "detail": "The dashboard statistics database could not be read safely.",
845
+ "action": "cctally db repair --db stats --yes",
846
+ }
847
+
796
848
  text = error.casefold()
797
849
  if (
798
850
  "stale maintenance marker" in text
@@ -815,7 +867,8 @@ def _sync_failure_envelope(error: str | None) -> dict | None:
815
867
 
816
868
  c = sys.modules["cctally"]
817
869
  if (
818
- c._is_sqlite_corruption_error(error)
870
+ attributed("cache", corruption=True)
871
+ or c._is_sqlite_corruption_error(error)
819
872
  or "cache.db recovery" in text
820
873
  or "cache.db is still open" in text
821
874
  ):
@@ -843,10 +896,10 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
843
896
  """Serialize a DataSnapshot into the JSON envelope consumed by the
844
897
  browser (design spec §2.2).
845
898
 
846
- ``transcripts_visible`` gates the transcript-derived session ``title``
847
- (#264 S3): the key is emitted ONLY when the flag is True AND the row has
848
- a title, so False (the default) fails closed for any caller that forgets
849
- to pass it — no ``title`` key, no leaked prompt content. The two
899
+ ``transcripts_visible`` gates transcript-derived Claude session ``title``
900
+ and Codex session ``label`` values: each key is emitted ONLY when the flag
901
+ is True AND the row has a private title, so False (the default) fails closed
902
+ for any caller that forgets to pass it — no leaked prompt content. The two
850
903
  browser-serving emit sites (``GET /api/data`` + the SSE loop) pass the
851
904
  per-request ``_transcripts_visible_to_request()`` — the SAME predicate
852
905
  that drives ``transcriptsEnabled`` and the per-row "open conversation"
@@ -1417,7 +1470,10 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1417
1470
  # Privacy-safe classification for user-facing dashboard copy. Keep the
1418
1471
  # raw legacy field for API compatibility and diagnostics, but never
1419
1472
  # place it in visible text/title/aria surfaces.
1420
- "sync_failure": _sync_failure_envelope(snap.last_sync_error),
1473
+ "sync_failure": _sync_failure_envelope(
1474
+ snap.last_sync_error,
1475
+ getattr(snap, "sync_failures", ()),
1476
+ ),
1421
1477
 
1422
1478
  # F1 (server-resolves "local" → IANA): the browser never has to
1423
1479
  # guess. {tz, resolved_tz, offset_label, offset_seconds} computed
@@ -1647,9 +1703,131 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1647
1703
  **sys.modules["_cctally_dashboard"]._channel_env_fragment(),
1648
1704
  }
1649
1705
  envelope.update(_source_bundle_to_envelope(getattr(snap, "source_bundle", None)))
1706
+ _overlay_claude_source_session_titles(envelope, snap, transcripts_visible)
1707
+ _overlay_codex_source_session_labels(envelope, snap, transcripts_visible)
1650
1708
  return envelope
1651
1709
 
1652
1710
 
1711
+ def _claude_source_session_rows(envelope: dict) -> list:
1712
+ """The SOURCE-scoped Claude session row lists, both of the places the All
1713
+ tab may read them from (``sourceRows.ts::collectSourceSessionRows`` prefers
1714
+ the nested provider payload and falls back to the sibling source entry)."""
1715
+ sources = envelope.get("sources")
1716
+ if not isinstance(sources, Mapping):
1717
+ return []
1718
+ out = []
1719
+ candidates = [(sources.get("claude") or {}).get("data")]
1720
+ all_data = (sources.get("all") or {}).get("data")
1721
+ if isinstance(all_data, Mapping):
1722
+ providers = all_data.get("providers")
1723
+ if isinstance(providers, Mapping):
1724
+ candidates.append(providers.get("claude"))
1725
+ for data in candidates:
1726
+ if not isinstance(data, Mapping):
1727
+ continue
1728
+ sessions = data.get("sessions")
1729
+ if not isinstance(sessions, Mapping):
1730
+ continue
1731
+ rows = sessions.get("rows")
1732
+ if isinstance(rows, list):
1733
+ out.append(rows)
1734
+ return out
1735
+
1736
+
1737
+ def _overlay_claude_source_session_titles(
1738
+ envelope: dict, snap, transcripts_visible: bool,
1739
+ ) -> None:
1740
+ """Inject the gated session title into the source-scoped Claude rows (#363).
1741
+
1742
+ The Sessions card reads a DIFFERENT payload per tab: the legacy
1743
+ ``sessions.rows`` block on the Claude tab, but the source-scoped rows on the
1744
+ All tab. Those source rows are projected into the published source bundle on
1745
+ the sync thread — one frozen bundle shared by every SSE client — so the
1746
+ bundle deliberately carries NO transcript content: a build-time gate
1747
+ decision cannot be right for every later connection, and the projection is
1748
+ fed a fail-closed envelope precisely so nothing leaks into it.
1749
+
1750
+ The title therefore rides the SAME per-request gate as the legacy block, by
1751
+ injection here rather than publication upstream: gate closed → the key stays
1752
+ absent, exactly as before. Injection (rather than publish-then-strip) is what
1753
+ makes it fail closed — a missed site omits a title, it never leaks one.
1754
+
1755
+ Safe to mutate: ``_source_wire_value`` rebuilds plain dicts/lists per call,
1756
+ so these rows are this request's own copies.
1757
+ """
1758
+ if not transcripts_visible:
1759
+ return
1760
+ titles = {}
1761
+ for session in getattr(snap, "sessions", ()) or ():
1762
+ session_id = getattr(session, "session_id", None)
1763
+ title = getattr(session, "title", None)
1764
+ if isinstance(session_id, str) and session_id and title is not None:
1765
+ titles[_dashboard_resource_key("session", "claude", session_id)] = title
1766
+ if not titles:
1767
+ return
1768
+ for rows in _claude_source_session_rows(envelope):
1769
+ for row in rows:
1770
+ if not isinstance(row, dict):
1771
+ continue
1772
+ title = titles.get(row.get("key"))
1773
+ if title is not None:
1774
+ row["title"] = title
1775
+
1776
+
1777
+ def _codex_source_session_rows(envelope: dict) -> list:
1778
+ """Return direct-Codex and All-tab Codex session row lists."""
1779
+ sources = envelope.get("sources")
1780
+ if not isinstance(sources, Mapping):
1781
+ return []
1782
+ out = []
1783
+ candidates = [(sources.get("codex") or {}).get("data")]
1784
+ all_data = (sources.get("all") or {}).get("data")
1785
+ if isinstance(all_data, Mapping):
1786
+ providers = all_data.get("providers")
1787
+ if isinstance(providers, Mapping):
1788
+ candidates.append(providers.get("codex"))
1789
+ for data in candidates:
1790
+ if not isinstance(data, Mapping):
1791
+ continue
1792
+ sessions = data.get("sessions")
1793
+ if not isinstance(sessions, Mapping):
1794
+ continue
1795
+ rows = sessions.get("rows")
1796
+ if isinstance(rows, list):
1797
+ out.append(rows)
1798
+ return out
1799
+
1800
+
1801
+ def _overlay_codex_source_session_labels(
1802
+ envelope: dict, snap, transcripts_visible: bool,
1803
+ ) -> None:
1804
+ """Inject Codex task labels only into this request's source-row copies.
1805
+
1806
+ ``state_5.sqlite.threads.title`` is derived from transcript prompt content.
1807
+ The frozen source state therefore retains it only in a server-private key
1808
+ map, outside the published ``data`` tree. Closed requests return before
1809
+ consulting that map; open requests match labels to direct and All rows by
1810
+ their opaque resource keys. A missed call fails closed.
1811
+ """
1812
+ if not transcripts_visible:
1813
+ return
1814
+ bundle = getattr(snap, "source_bundle", None)
1815
+ sources = getattr(bundle, "sources", None)
1816
+ if not isinstance(sources, Mapping):
1817
+ return
1818
+ codex = sources.get("codex")
1819
+ labels = getattr(codex, "private_session_labels", None)
1820
+ if not isinstance(labels, Mapping) or not labels:
1821
+ return
1822
+ for rows in _codex_source_session_rows(envelope):
1823
+ for row in rows:
1824
+ if not isinstance(row, dict):
1825
+ continue
1826
+ label = labels.get(row.get("key"))
1827
+ if isinstance(label, str) and label:
1828
+ row["label"] = label
1829
+
1830
+
1653
1831
  def _session_detail_to_envelope(detail: "TuiSessionDetail") -> dict:
1654
1832
  """Serialize TuiSessionDetail for GET /api/session/:id (spec §3.2, §4.6.4).
1655
1833