cctally 1.89.2 → 1.90.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/bin/_cctally_cache_report.py +10 -5
- package/bin/_cctally_core.py +22 -4
- package/bin/_cctally_dashboard.py +158 -1
- package/bin/_cctally_dashboard_cache_report.py +56 -46
- package/bin/_cctally_dashboard_envelope.py +8 -0
- package/bin/_cctally_dashboard_share.py +2 -0
- package/bin/_cctally_dashboard_sources.py +82 -37
- package/bin/_cctally_db.py +27 -0
- package/bin/_cctally_parser.py +20 -0
- package/bin/_cctally_record.py +8 -38
- package/bin/_cctally_statusline.py +21 -2
- package/bin/_cctally_store.py +200 -0
- package/bin/_cctally_tui.py +33 -9
- package/bin/_lib_cache_report.py +199 -70
- package/bin/_lib_cache_report_wire.py +184 -0
- package/bin/cctally +14 -2
- package/dashboard/static/assets/{index-Ub8vwz1M.css → index-Bar8-S1i.css} +1 -1
- package/dashboard/static/assets/index-CRogVlEC.js +92 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-BgoYXdus.js +0 -92
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,26 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.90.0] - 2026-08-02
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- 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).
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- 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).
|
|
15
|
+
- 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).
|
|
16
|
+
- 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).
|
|
17
|
+
- `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).
|
|
18
|
+
- 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).
|
|
19
|
+
- 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).
|
|
20
|
+
- 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).
|
|
21
|
+
- 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).
|
|
22
|
+
- 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).
|
|
23
|
+
- 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).
|
|
24
|
+
- 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).
|
|
25
|
+
- 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).
|
|
26
|
+
- 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).
|
|
27
|
+
|
|
8
28
|
## [1.89.2] - 2026-07-31
|
|
9
29
|
|
|
10
30
|
### 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.
|
|
938
|
-
triggered
|
|
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
|
|
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.
|
package/bin/_cctally_core.py
CHANGED
|
@@ -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.
|
|
1593
|
-
#
|
|
1594
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
106
|
+
if not crk.cache_report_threshold_is_valid(threshold):
|
|
101
107
|
raise _CacheReportConfigError(
|
|
102
|
-
"anomaly_threshold_pp must be in
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
"
|
|
189
|
-
|
|
190
|
-
|
|
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":
|
|
209
|
+
"anomaly_reasons": cr.today.anomaly_reasons,
|
|
200
210
|
"baseline_daily_row_count": cr.today.baseline_daily_row_count,
|
|
201
|
-
"anomaly_unevaluated":
|
|
211
|
+
"anomaly_unevaluated": cr.today.anomaly_unevaluated,
|
|
202
212
|
"observed": cr.today.observed,
|
|
203
213
|
},
|
|
204
|
-
|
|
214
|
+
days=[
|
|
205
215
|
{
|
|
206
|
-
"date": d.date,
|
|
207
|
-
"
|
|
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":
|
|
217
|
-
"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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
#
|
|
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
|
|
412
|
-
#
|
|
413
|
-
#
|
|
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 =
|
|
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
|
-
|
|
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(),
|