cctally 1.89.2 → 1.90.1

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 CHANGED
@@ -5,6 +5,31 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.90.1] - 2026-08-02
9
+
10
+ ### Fixed
11
+ - Internal (maintainer-only): the public release snapshot no longer ships the issue-triage pytest modules without the private workflow implementation they import. Those tests now stay behind the same mirror boundary as their `.agent-workflows` dependencies, preventing collection failures across the public Python matrix.
12
+
13
+ ## [1.90.0] - 2026-08-02
14
+
15
+ ### Added
16
+ - Internal (maintainer-only): the complete open GitHub issue backlog can now be normalized by a registered Luna worker, adjudicated by the Sol primary agent, and reconciled into a marker-owned Project and tracker through a preview-first, checksum-resumable workflow. The reconciler preserves user-owned metadata, resumes partial applies without improvising a new plan, records dependencies and evidence, requires current-main proof before closure, and verifies completion by recollecting GitHub plus a zero-mutation second preview (#475).
17
+
18
+ ### Fixed
19
+ - The first interactive command after a `stats.db` index-epoch upgrade no longer waits for the entire journal replay. A readable post-cutover index with the wrong epoch now starts one dedicated background rebuild: statusline output remains available, the dashboard binds with an explicit hydrating/degraded snapshot, and other stats-backed commands return retry guidance instead of blocking. The worker retains the existing maintenance locks and journal-truth rebuild path, retries failures safely, and lets the next invocation read the rebuilt index (#453).
20
+ - Sharing a Codex panel while focused on one account now keeps that account scope through the live preview, every export action, recent-share history, the report basket, section refreshes, and composed reports. Switching the dashboard's account selector while the share dialog is open no longer changes the captured scope, and account-agnostic shares keep their existing request and storage shape. Claude panels remain honestly account-agnostic because their dashboard rows are not yet account-filtered (#346).
21
+ - Internal (maintainer-only): managed issue-triage comments now lead with the human action summary and keep their lossless normalized record in a collapsed disclosure instead of filling the issue discussion with raw JSON. Existing marker-first v1 comments are read unchanged and migrate through exact-marker reconciliation; unmarked user comments remain untouched (#476).
22
+ - `cache-report --json` now identifies anomaly predicates that were not evaluated, so consumers can distinguish an evaluated-clean row from a check skipped for a thin baseline or because anomaly detection was disabled. This is an additive field and keeps `schemaVersion: 1` (#474).
23
+ - Cache Report charts now share one day-slot axis, so the sparkline's Today marker lines up with the Today net bar in both the card and modal, including one-day and no-activity windows. The card's net bars keep a compact 60px height, every card state carries the standard header icon, and an empty report is visually and accessibly distinct from a failed build. The expanded report keeps its labeled table through the 641–719px transition band, switches to labeled cards at 640px, and preserves its title and subtitle without horizontal overflow down to 320px (#452).
24
+ - The Cache Report's anomaly threshold now means the same thing everywhere. If `cache_report.anomaly_threshold_pp` in your config file held something other than a whole number between 1 and 100 — a quoted `"20"` after a hand edit, say — the Claude panel quietly coerced it while the Codex panel fell back to the default, so one setting produced two different verdicts side by side. A value that isn't a whole number in range now falls back to the documented default of 15 on every surface, matching what the settings dialog has always refused to save (#443).
25
+ - Cache Report no longer drops a day from the baseline when the clocks change. The baseline window was measured in elapsed hours while each day is anchored to its own local midnight, so across a daylight-saving transition a day exactly 14 days old fell an hour outside the 14-day window and was silently excluded — which could leave the baseline one sample short and suppress the comparison entirely. The window is now counted in calendar days, and the "Building baseline · N/5 days" count is taken from the very rows the comparison used, so the number you see and the number the check needs can no longer disagree (#443).
26
+ - Cache Report cache-dollar totals no longer depend on the order entries happen to be read in. Sums are now computed so the result is the correctly-rounded total regardless of ordering. On a day whose savings and cache-write costs cancel almost exactly this could previously flip the "net spend went negative" warning on or off between runs of the same data; it is now stable. One rendered cent can shift at a rounding boundary, always toward the more accurate value (#443).
27
+ - On a machine whose timezone isn't UTC, with no `display.tz` set, Cache Report no longer mixes up which day is today. The days were grouped in your local timezone while "today" was worked out in UTC, so for part of each day the report highlighted the wrong row, and the Codex per-project and per-model breakdowns could be built from a different set of days than the ones listed above them. Both now use the same timezone as the rows themselves (#443).
28
+ - The Codex Cache Report no longer labels an older day "Today" and no longer publishes a fabricated 0% for a day with no Codex activity yet — an idle day is now marked unmeasured, as it already was for Claude (#443).
29
+ - The Codex Cache Report no longer reports "net spend went negative" as a permanently unevaluated check. OpenAI charges no cache-write premium, so that check does not apply to Codex at all, and an ordinary Codex day now reads as clean rather than partially checked (#443).
30
+ - The Codex Cache Report now publishes its figures under Codex's own vocabulary — "cached input" rather than Claude's "cache hit" — and states which figures do not apply to Codex, and why (#443).
31
+ - The dashboard no longer invents a Codex Cache Report when the server published none. The card and the modal each built their own stand-in from raw daily rows, disagreeing with each other about whether the window was empty, presenting a row count as a 14-day window, and showing a confident verdict against a median taken from as few as two days — below the five-day floor the real report enforces. Worse, the modal's stand-in seeded the settings gear with a 5pp anomaly threshold the server never set, so saving from that screen silently rewrote the global threshold from 15 to 5 and changed Claude's verdicts too. Both surfaces now render what the server actually sent, and say plainly when there is nothing to render — distinguishing "this source has no cache activity yet" (including a focused account with no Codex activity) from "the snapshot could not be built" (#443).
32
+
8
33
  ## [1.89.2] - 2026-07-31
9
34
 
10
35
  ### Fixed
@@ -464,7 +464,6 @@ def _render_cache_day_rows(
464
464
  raw_rows.append((bd_cells, ROW_BREAKDOWN))
465
465
 
466
466
  tot_inp = sum(row.input_tokens for row in rows)
467
- tot_out = sum(row.output_tokens for row in rows)
468
467
  tot_cc = sum(row.cache_creation_tokens for row in rows)
469
468
  tot_cr = sum(row.cache_read_tokens for row in rows)
470
469
  tot_tokens = sum(row.total_tokens for row in rows)
@@ -613,7 +612,6 @@ def _render_cache_session_rows(
613
612
  raw_rows.append((bd_cells, ROW_BREAKDOWN))
614
613
 
615
614
  tot_inp = sum(r.input_tokens for r in rows)
616
- tot_out = sum(r.output_tokens for r in rows)
617
615
  tot_cc = sum(r.cache_creation_tokens for r in rows)
618
616
  tot_cr = sum(r.cache_read_tokens for r in rows)
619
617
  tot_tokens = sum(r.total_tokens for r in rows)
@@ -934,8 +932,11 @@ def _cache_report_json_payload(
934
932
  Daily mode keeps the top-level `days` key and per-row `totalCost` /
935
933
  totals.totalCost aliases for backward compat. Session mode uses
936
934
  `sessions` and adds sessionId / projectPath / lastActivity /
937
- sourcePaths per row. Anomaly object is always emitted with
938
- triggered=false / reasons=[] until Task 5 populates it.
935
+ sourcePaths per row. The anomaly object is always emitted: it
936
+ carries `triggered` / `reasons` from `_classify_anomalies` plus
937
+ `unevaluated` (the predicates the classifier declined to run for that
938
+ row), so a consumer can tell "evaluated, clean" from "never
939
+ evaluated".
939
940
  """
940
941
  top_key = "sessions" if mode == "session" else "days"
941
942
 
@@ -954,6 +955,7 @@ def _cache_report_json_payload(
954
955
  "anomaly": {
955
956
  "triggered": r.anomaly_triggered,
956
957
  "reasons": list(r.anomaly_reasons),
958
+ "unevaluated": list(r.anomaly_unevaluated),
957
959
  },
958
960
  "modelBreakdowns": [
959
961
  {
@@ -1065,7 +1067,10 @@ def _sort_cache_rows(
1065
1067
  if anchor is None and r.date:
1066
1068
  # Use .astimezone() (OS tzdb) rather than .replace(tzinfo=...) so
1067
1069
  # DST-straddling dates resolve to the correct offset — same idiom
1068
- # as _annotate_anomalies._row_anchor.
1070
+ # as `_row_anchor`, which is a module-level function in
1071
+ # bin/_lib_cache_report.py — NOT an attribute of this file's
1072
+ # `_annotate_anomalies` shim, which is what the old citation
1073
+ # implied.
1069
1074
  # internal fallback: host-local intentional
1070
1075
  anchor = dt.datetime.strptime(r.date, "%Y-%m-%d").astimezone()
1071
1076
  # Use tz-aware sentinel to avoid naive-vs-aware comparison errors.
@@ -1489,6 +1489,21 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
1489
1489
  _cctally_store = importlib.import_module("_cctally_store")
1490
1490
 
1491
1491
  ensure_dirs()
1492
+ # #453: probe the live index before the maintenance-shared opener. During
1493
+ # a large detached rebuild that opener can wait for its bounded 5-second
1494
+ # guard; a statusline arriving mid-replay would otherwise pay that delay
1495
+ # (and can reach more than one stats opener in a single render). The raw
1496
+ # probe is read-only and deliberately recognizes only a readable,
1497
+ # post-legacy wrong epoch. Missing/unreadable/legacy indexes retain their
1498
+ # established guarded-open paths. The post-open epoch gate below remains a
1499
+ # race-closing defense if the version changes after this probe.
1500
+ if (
1501
+ _target_path is None
1502
+ and _cctally_store.stats_epoch_enabled()
1503
+ and _cctally_store.stats_epoch_rebuild_pending(db_path)
1504
+ ):
1505
+ outcome = _cctally_store.defer_stats_epoch_rebuild()
1506
+ raise c.StatsEpochRebuildDeferred(outcome)
1492
1507
  # #386: the opener half of the physical-replacement protocol. This replaces
1493
1508
  # three bare `repair_marker.exists()` checks around an unguarded connect —
1494
1509
  # which observed no quarantine-pending record and held no maintenance lock,
@@ -1589,11 +1604,14 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
1589
1604
  if _target_path is None:
1590
1605
  if _uv > LEGACY_STATS_HEAD:
1591
1606
  # Neither legacy (<=13) nor the current epoch — a future epoch or
1592
- # a stray value. Resolve by journal REBUILD (spec §7.1), never by
1593
- # the corruption heal path (disjoint); a mismatch with no journal
1594
- # is a hard error, never a silent rebuild-to-empty.
1607
+ # a stray value. Ordinary live callers must not pay whole-journal
1608
+ # replay inline or read the schema-incompatible old index. Hand
1609
+ # the existing synchronous resolver to its dedicated worker and
1610
+ # fail promptly; explicit maintenance and the worker itself call
1611
+ # that resolver directly. Corruption/legacy paths stay disjoint.
1595
1612
  conn.close()
1596
- return _cctally_store.resolve_stats_epoch_mismatch()
1613
+ outcome = _cctally_store.defer_stats_epoch_rebuild()
1614
+ raise c.StatsEpochRebuildDeferred(outcome)
1597
1615
  # _uv <= LEGACY_STATS_HEAD → a pre-journal install: cut over below. A
1598
1616
  # dev/worktree binary must REFUSE to cut over a DB in the real prod
1599
1617
  # dir (mirrors #146 — the epoch stamp would brick the installed
@@ -5644,7 +5644,13 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5644
5644
  # documented default when neither the request nor the
5645
5645
  # persisted config carries an explicit value.
5646
5646
  persisted_cr = merged.get("cache_report") or {}
5647
- stored_threshold = persisted_cr.get("anomaly_threshold_pp", 15)
5647
+ # #443 S3 F17: the echo is a fifth reader of this setting. It used
5648
+ # to carry its own hard-coded 15, so a hand-edited non-integer in
5649
+ # config.json would be echoed verbatim to the settings composer
5650
+ # while both read paths classified at the default.
5651
+ stored_threshold = _cache_report_load_kernel().resolve_cache_report_threshold(
5652
+ persisted_cr.get("anomaly_threshold_pp")
5653
+ )
5648
5654
  out["cache_report"] = {
5649
5655
  "anomaly_threshold_pp": stored_threshold,
5650
5656
  }
@@ -6912,6 +6918,12 @@ def _dashboard_initial_snapshot(args, *, pinned_now, display_tz_pref_override):
6912
6918
  display_tz_pref_override=display_tz_pref_override,
6913
6919
  stats_heal_attempted=False,
6914
6920
  )
6921
+ except c.StatsEpochRebuildDeferred as exc:
6922
+ return _dashboard_stats_epoch_deferred_snapshot(
6923
+ args,
6924
+ pinned_now=pinned_now,
6925
+ exc=exc,
6926
+ )
6915
6927
  except tui._StatsSnapshotCorruption as fault:
6916
6928
  # The once-builder's finally has closed the cheap-seed stats handle.
6917
6929
  tui._tui_heal_post_query_stats(fault.cause)
@@ -6923,6 +6935,53 @@ def _dashboard_initial_snapshot(args, *, pinned_now, display_tz_pref_override):
6923
6935
  )
6924
6936
 
6925
6937
 
6938
+ def _dashboard_stats_epoch_deferred_snapshot(args, *, pinned_now, exc):
6939
+ """Bind promptly with a typed degraded frame while replay runs detached."""
6940
+
6941
+ import time as _time
6942
+
6943
+ c = _cctally()
6944
+ tui = c._cctally_tui
6945
+ now_utc = pinned_now or dt.datetime.now(dt.timezone.utc)
6946
+ hydrating = not getattr(args, "no_sync", False)
6947
+ errors = [f"stats-open: {exc}"]
6948
+ envelope_precompute = None
6949
+ try:
6950
+ envelope_precompute = tui._tui_precompute_envelope_config(load_config())
6951
+ except Exception as precompute_exc: # noqa: BLE001 — bind must continue
6952
+ errors.append(f"envelope-precompute: {precompute_exc}")
6953
+ # The real doctor shallow gather opens stats repeatedly. Once the detached
6954
+ # worker owns maintenance-exclusive, doing that before HTTP bind reintroduces
6955
+ # the exact multi-second startup stall this path exists to remove. Supply a
6956
+ # schema-valid, privacy-safe warning block; the first successful background
6957
+ # snapshot replaces it with a real doctor payload.
6958
+ doctor_payload = {
6959
+ "severity": "warn",
6960
+ "counts": {"ok": 0, "warn": 1, "fail": 0},
6961
+ "generated_at": now_utc.astimezone(dt.timezone.utc).strftime(
6962
+ "%Y-%m-%dT%H:%M:%SZ"
6963
+ ),
6964
+ "fingerprint": "sha1:" + ("0" * 40),
6965
+ }
6966
+ replacements = {
6967
+ "last_sync_at": _time.monotonic(),
6968
+ "last_sync_error": "; ".join(errors),
6969
+ "sync_failures": (
6970
+ tui.SyncFailureAttribution(
6971
+ leg="stats-open",
6972
+ database="stats",
6973
+ corruption=False,
6974
+ ),
6975
+ ),
6976
+ "doctor_payload": doctor_payload,
6977
+ "envelope_precompute": envelope_precompute,
6978
+ "hydrating": hydrating,
6979
+ }
6980
+ if hydrating:
6981
+ replacements["source_bundle"] = tui._tui_hydrating_source_bundle()
6982
+ return dataclasses.replace(tui._tui_empty_snapshot(now_utc), **replacements)
6983
+
6984
+
6926
6985
  def _dashboard_initial_snapshot_once(
6927
6986
  args,
6928
6987
  *,
@@ -7058,6 +7117,103 @@ def _dashboard_initial_snapshot_once(
7058
7117
  )
7059
7118
 
7060
7119
 
7120
+ _DASHBOARD_QA_STATES = frozenset({
7121
+ "hydrating", "failed", "forensics-degraded",
7122
+ })
7123
+
7124
+
7125
+ def _dashboard_qa_state_from_env() -> str | None:
7126
+ """Resolve the explicit, test-only Cache Report server state.
7127
+
7128
+ The master switch is intentionally separate from the requested state so a
7129
+ stray environment variable cannot alter a normal dashboard. These states
7130
+ transform a real server snapshot after its production builders run; they
7131
+ never replace endpoint payloads in the browser and never affect CLI data.
7132
+ """
7133
+ requested = os.environ.get("CCTALLY_DASHBOARD_QA_STATE", "").strip()
7134
+ if (
7135
+ not requested
7136
+ or os.environ.get("CCTALLY_DASHBOARD_QA") != "1"
7137
+ or not _cctally()._is_dev_checkout()
7138
+ ):
7139
+ return None
7140
+ if requested not in _DASHBOARD_QA_STATES:
7141
+ raise ValueError(
7142
+ "CCTALLY_DASHBOARD_QA_STATE must be one of: "
7143
+ + ", ".join(sorted(_DASHBOARD_QA_STATES))
7144
+ )
7145
+ return requested
7146
+
7147
+
7148
+ def _dashboard_apply_qa_state(snapshot, state: str | None):
7149
+ """Apply one guarded QA state to a real dashboard snapshot."""
7150
+ if state is None:
7151
+ return snapshot
7152
+ if state not in _DASHBOARD_QA_STATES:
7153
+ raise ValueError("unsupported dashboard QA state")
7154
+ tui = _cctally()._cctally_tui
7155
+ if state == "hydrating":
7156
+ return dataclasses.replace(
7157
+ snapshot,
7158
+ hydrating=True,
7159
+ cache_report=None,
7160
+ source_bundle=tui._tui_hydrating_source_bundle(),
7161
+ )
7162
+ bundle = getattr(snapshot, "source_bundle", None)
7163
+ if bundle is None:
7164
+ raise ValueError("dashboard QA state requires a built source bundle")
7165
+
7166
+ physical = {}
7167
+ for source in ("claude", "codex"):
7168
+ current = bundle.sources[source]
7169
+ capabilities = dict(current.capabilities)
7170
+ capabilities["forensics"] = tui.CapabilityRecord(
7171
+ "unavailable", f"qa-{state}",
7172
+ )
7173
+ warning = tui.SourceDashboardWarning(
7174
+ f"qa_{state.replace('-', '_')}",
7175
+ (
7176
+ "Cache forensics are degraded for deterministic QA."
7177
+ if state == "forensics-degraded"
7178
+ else "Cache forensics failed for deterministic QA."
7179
+ ),
7180
+ "forensics",
7181
+ )
7182
+ if state == "forensics-degraded":
7183
+ physical[source] = dataclasses.replace(
7184
+ current,
7185
+ availability="partial",
7186
+ warnings=tuple(current.warnings) + (warning,),
7187
+ capabilities=capabilities,
7188
+ data_version=f"{current.data_version}:qa-degraded",
7189
+ )
7190
+ else:
7191
+ physical[source] = dataclasses.replace(
7192
+ current,
7193
+ availability="unavailable",
7194
+ freshness="stale",
7195
+ warnings=(warning,),
7196
+ capabilities=capabilities,
7197
+ data=None,
7198
+ last_success_at=None,
7199
+ domain_freshness={
7200
+ "hero": "stale", "quota": "stale", "sessions": "stale",
7201
+ },
7202
+ data_version=f"{current.data_version}:qa-failed",
7203
+ )
7204
+ physical["all"] = tui.compose_all_state(
7205
+ physical["claude"], physical["codex"],
7206
+ )
7207
+ return dataclasses.replace(
7208
+ snapshot,
7209
+ hydrating=False,
7210
+ cache_report=(
7211
+ None if state == "failed" else getattr(snapshot, "cache_report", None)
7212
+ ),
7213
+ source_bundle=dataclasses.replace(bundle, sources=physical),
7214
+ )
7215
+
7216
+
7061
7217
  def cmd_dashboard(args: argparse.Namespace) -> int:
7062
7218
  """Launch the live web dashboard."""
7063
7219
  import signal as _signal
@@ -7165,6 +7321,7 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
7165
7321
  # stamp so the envelope emits sync_age_s=None and the JS client
7166
7322
  # renders the documented `sync paused` state.
7167
7323
  initial = dataclasses.replace(initial, last_sync_at=None)
7324
+ initial = _dashboard_apply_qa_state(initial, _dashboard_qa_state_from_env())
7168
7325
 
7169
7326
  ref = _SnapshotRef(initial)
7170
7327
  hub = SSEHub()
@@ -90,16 +90,24 @@ def _validate_cache_report_settings(block: dict) -> dict:
90
90
  validated: dict = {}
91
91
  if "anomaly_threshold_pp" in block:
92
92
  threshold = block["anomaly_threshold_pp"]
93
- # bool is an int subclass — reject it explicitly (mirrors the
93
+ # #443 S3 F17: the type-and-range rule is the kernel's
94
+ # ``cache_report_threshold_is_valid`` — the same predicate the two
95
+ # read paths resolve through, so the gate and the readers cannot
96
+ # disagree about what a valid setting is. The two-branch split
97
+ # survives only to keep the distinct messages: bool is an int
98
+ # subclass and is rejected explicitly (mirrors the
94
99
  # update.check.ttl_hours precedent).
100
+ crk = _cache_report_load_kernel()
95
101
  if isinstance(threshold, bool) or not isinstance(threshold, int):
96
102
  raise _CacheReportConfigError(
97
103
  "anomaly_threshold_pp must be an integer",
98
104
  field="anomaly_threshold_pp",
99
105
  )
100
- if threshold < 1 or threshold > 100:
106
+ if not crk.cache_report_threshold_is_valid(threshold):
101
107
  raise _CacheReportConfigError(
102
- "anomaly_threshold_pp must be in [1, 100]",
108
+ "anomaly_threshold_pp must be in "
109
+ f"[{crk.CACHE_REPORT_THRESHOLD_MIN_PP}, "
110
+ f"{crk.CACHE_REPORT_THRESHOLD_MAX_PP}]",
103
111
  field="anomaly_threshold_pp",
104
112
  )
105
113
  validated["anomaly_threshold_pp"] = threshold
@@ -183,11 +191,13 @@ def _cache_report_snapshot_to_dict(cr: "CacheReportSnapshot | None") -> "dict |
183
191
  """
184
192
  if cr is None:
185
193
  return None
186
- return {
187
- "window_days": cr.window_days,
188
- "anomaly_threshold_pp": cr.anomaly_threshold_pp,
189
- "anomaly_window_days": cr.anomaly_window_days,
190
- "today": {
194
+ wire = _cache_report_load_wire()
195
+ return wire.build_cache_report_wire(
196
+ provider="claude",
197
+ window_days=cr.window_days,
198
+ anomaly_threshold_pp=cr.anomaly_threshold_pp,
199
+ anomaly_window_days=cr.anomaly_window_days,
200
+ today={
191
201
  "date": cr.today.date,
192
202
  "cache_hit_percent": cr.today.cache_hit_percent,
193
203
  "baseline_median_percent": cr.today.baseline_median_percent,
@@ -196,51 +206,40 @@ def _cache_report_snapshot_to_dict(cr: "CacheReportSnapshot | None") -> "dict |
196
206
  "saved_usd": cr.today.saved_usd,
197
207
  "wasted_usd": cr.today.wasted_usd,
198
208
  "anomaly_triggered": cr.today.anomaly_triggered,
199
- "anomaly_reasons": list(cr.today.anomaly_reasons),
209
+ "anomaly_reasons": cr.today.anomaly_reasons,
200
210
  "baseline_daily_row_count": cr.today.baseline_daily_row_count,
201
- "anomaly_unevaluated": list(cr.today.anomaly_unevaluated),
211
+ "anomaly_unevaluated": cr.today.anomaly_unevaluated,
202
212
  "observed": cr.today.observed,
203
213
  },
204
- "days": [
214
+ days=[
205
215
  {
206
- "date": d.date,
207
- "cache_hit_percent": d.cache_hit_percent,
208
- "input_tokens": d.input_tokens,
209
- "output_tokens": d.output_tokens,
216
+ "date": d.date, "cache_hit_percent": d.cache_hit_percent,
217
+ "input_tokens": d.input_tokens, "output_tokens": d.output_tokens,
210
218
  "cache_creation_tokens": d.cache_creation_tokens,
211
219
  "cache_read_tokens": d.cache_read_tokens,
212
- "saved_usd": d.saved_usd,
213
- "wasted_usd": d.wasted_usd,
220
+ "saved_usd": d.saved_usd, "wasted_usd": d.wasted_usd,
214
221
  "net_usd": d.net_usd,
215
222
  "anomaly_triggered": d.anomaly_triggered,
216
- "anomaly_reasons": list(d.anomaly_reasons),
217
- "anomaly_unevaluated": list(d.anomaly_unevaluated),
223
+ "anomaly_reasons": d.anomaly_reasons,
224
+ "anomaly_unevaluated": d.anomaly_unevaluated,
218
225
  "observed": d.observed,
219
226
  }
220
227
  for d in cr.days
221
228
  ],
222
- "by_project": [
223
- {
224
- "key": b.key,
225
- "cache_hit_percent": b.cache_hit_percent,
226
- "net_usd": b.net_usd,
227
- }
229
+ by_project=[
230
+ {"key": b.key, "cache_hit_percent": b.cache_hit_percent, "net_usd": b.net_usd}
228
231
  for b in cr.by_project
229
232
  ],
230
- "by_model": [
231
- {
232
- "key": b.key,
233
- "cache_hit_percent": b.cache_hit_percent,
234
- "net_usd": b.net_usd,
235
- }
233
+ by_model=[
234
+ {"key": b.key, "cache_hit_percent": b.cache_hit_percent, "net_usd": b.net_usd}
236
235
  for b in cr.by_model
237
236
  ],
238
- "seven_day_net_usd": cr.seven_day_net_usd,
239
- "seven_day_anomaly_count": cr.seven_day_anomaly_count,
240
- "fourteen_day_counterfactual_usd": cr.fourteen_day_counterfactual_usd,
241
- "fourteen_day_efficiency_ratio": cr.fourteen_day_efficiency_ratio,
242
- "is_empty": cr.is_empty,
243
- }
237
+ seven_day_net_usd=cr.seven_day_net_usd,
238
+ seven_day_anomaly_count=cr.seven_day_anomaly_count,
239
+ fourteen_day_counterfactual_usd=cr.fourteen_day_counterfactual_usd,
240
+ fourteen_day_efficiency_ratio=cr.fourteen_day_efficiency_ratio,
241
+ is_empty=cr.is_empty,
242
+ )
244
243
 
245
244
 
246
245
  @dataclass(frozen=True)
@@ -285,6 +284,14 @@ def _cache_report_load_kernel():
285
284
  return sys.modules["cctally"]._load_sibling("_lib_cache_report")
286
285
 
287
286
 
287
+ def _cache_report_load_wire():
288
+ """Lazy-load ``_lib_cache_report_wire`` through the same bridge.
289
+
290
+ The wire builder is the single serializer shared with the Codex
291
+ producer in ``_cctally_dashboard_sources.py`` (#443 S2 F18)."""
292
+ return sys.modules["cctally"]._load_sibling("_lib_cache_report_wire")
293
+
294
+
288
295
  def _cache_report_needed_closed_dates(since, now_utc, bucket_tz):
289
296
  """The CLOSED display-tz dates a full-window cache-report build needs (#272 §6).
290
297
 
@@ -406,16 +413,16 @@ def build_cache_report_snapshot(
406
413
  since = now_utc - dt.timedelta(days=window_days)
407
414
  pricing = cctally_ns.CLAUDE_MODEL_PRICING
408
415
 
409
- # Cache mechanics key on the BUCKETING tz (``_resolve_bucket_tz`` — the same
416
+ # One current day per invocation (#443 S3 F23). Cache mechanics and the
417
+ # spotlight both key on the BUCKETING tz (``_resolve_bucket_tz`` — the same
410
418
  # tz ``_aggregate_cache_by_day`` buckets by), so ``today_key`` / the closed-
411
- # day keys line up with ``CacheRow.date``. The spotlight's ``today_iso``
412
- # keeps its own UTC-fallback derivation unchanged (byte-identity); the two
413
- # agree whenever ``display_tz`` is set — always, on the dashboard.
419
+ # day keys / ``today_iso`` line up with ``CacheRow.date`` and cannot
420
+ # disagree. ``today_iso`` previously kept a separate UTC fallback,
421
+ # justified by the caller-side assumption that ``display_tz`` is always
422
+ # set on the dashboard rather than by an invariant.
414
423
  bucket_tz = crk._resolve_bucket_tz(display_tz)
415
424
  today_key = now_utc.astimezone(bucket_tz).strftime("%Y-%m-%d")
416
- today_iso = now_utc.astimezone(
417
- display_tz if display_tz is not None else dt.timezone.utc
418
- ).strftime("%Y-%m-%d")
425
+ today_iso = today_key
419
426
 
420
427
  def _wrap_day_entries(raw):
421
428
  # Day-mode kernel expects entries with a ``usage`` dict (matches
@@ -550,10 +557,13 @@ def build_cache_report_snapshot(
550
557
  # alongside the anomaly classifier so we don't re-walk the same
551
558
  # row set here).
552
559
  today_row = next((r for r in result.rows if r.date == today_iso), None)
553
- other_rows = [r for r in result.rows if r.date != today_iso]
554
560
  baseline_median = result.today_baseline_median
555
561
 
556
- baseline_daily_row_count = len(other_rows)
562
+ # #443 S3 F22: the rows the median was actually taken over, not every
563
+ # non-today row. The old count was unbounded by the baseline window,
564
+ # so the panel could read "baseline sufficient" from it while
565
+ # `baseline_median_percent` was None.
566
+ baseline_daily_row_count = result.today_baseline_sample_count
557
567
 
558
568
  # ``delta_pp`` sign convention (spec §4.2): "signed; negative = today
559
569
  # below median" → ``delta = today − baseline``. The empty-day branch
@@ -846,6 +846,14 @@ def _sync_failure_envelope(
846
846
  "action": "cctally db repair --db stats --yes",
847
847
  }
848
848
 
849
+ if attributed("stats"):
850
+ return {
851
+ "kind": "maintenance_active",
852
+ "label": "stats index rebuilding",
853
+ "detail": "The dashboard statistics index is rebuilding in the background.",
854
+ "action": None,
855
+ }
856
+
849
857
  # conversations.db is intentionally outside core dashboard generations.
850
858
  # Typed transcript ownership therefore degrades through the generic server
851
859
  # contract: Doctor owns integrity diagnosis, and raw SQLite wording must
@@ -2590,6 +2590,7 @@ def _handle_share_history_post_impl(handler) -> None:
2590
2590
  destination = req.get("destination")
2591
2591
  try:
2592
2592
  source, _ = _share_source_selection(req)
2593
+ account = _share_account_selection(req)
2593
2594
  except ValueError:
2594
2595
  handler._respond_json(400, {
2595
2596
  "code": "source_capability_unavailable",
@@ -2661,6 +2662,7 @@ def _handle_share_history_post_impl(handler) -> None:
2661
2662
  "template_id": template_id,
2662
2663
  "options": options,
2663
2664
  "source": source,
2665
+ **({"account": account} if account is not None else {}),
2664
2666
  "format": fmt,
2665
2667
  "destination": destination,
2666
2668
  "exported_at": _share_now_utc_iso(),