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.
- package/CHANGELOG.md +70 -0
- package/README.md +52 -74
- package/bin/_cctally_alerts.py +8 -1
- package/bin/_cctally_cache.py +963 -149
- package/bin/_cctally_config.py +43 -4
- package/bin/_cctally_core.py +933 -759
- package/bin/_cctally_dashboard.py +157 -47
- package/bin/_cctally_dashboard_cache_report.py +13 -6
- package/bin/_cctally_dashboard_conversation.py +1 -0
- package/bin/_cctally_dashboard_envelope.py +186 -8
- package/bin/_cctally_dashboard_share.py +60 -20
- package/bin/_cctally_dashboard_sources.py +427 -128
- package/bin/_cctally_db.py +605 -128
- package/bin/_cctally_doctor.py +413 -28
- package/bin/_cctally_five_hour.py +12 -5
- package/bin/_cctally_journal.py +2050 -156
- package/bin/_cctally_journal_repair.py +519 -0
- package/bin/_cctally_milestone_history.py +142 -56
- package/bin/_cctally_milestones.py +179 -111
- package/bin/_cctally_parser.py +42 -0
- package/bin/_cctally_project.py +24 -18
- package/bin/_cctally_quota.py +139 -25
- package/bin/_cctally_record.py +279 -108
- package/bin/_cctally_rederive.py +1052 -0
- package/bin/_cctally_reporting.py +58 -53
- package/bin/_cctally_setup.py +1 -0
- package/bin/_cctally_source_analytics.py +4 -1
- package/bin/_cctally_statusline.py +11 -11
- package/bin/_cctally_store.py +1039 -31
- package/bin/_cctally_sync_week.py +17 -8
- package/bin/_cctally_tui.py +421 -54
- package/bin/_cctally_update.py +133 -8
- package/bin/_cctally_weekrefs.py +14 -0
- package/bin/_lib_aggregators.py +10 -6
- package/bin/_lib_cache_report.py +101 -9
- package/bin/_lib_codex_pools.py +82 -0
- package/bin/_lib_conversation_query.py +126 -33
- package/bin/_lib_dashboard_sources.py +126 -1
- package/bin/_lib_diff_kernel.py +28 -15
- package/bin/_lib_doctor.py +342 -4
- package/bin/_lib_journal.py +924 -2
- package/bin/_lib_jsonl.py +43 -14
- package/bin/_lib_pricing.py +140 -21
- package/bin/_lib_readme_refresh.py +401 -0
- package/bin/_lib_rederive.py +395 -0
- package/bin/_lib_share.py +58 -2
- package/bin/cctally +56 -8
- package/dashboard/static/assets/{index-DJP4gEB7.js → index-3bgCMVHb.js} +52 -52
- package/dashboard/static/assets/index-D27EIHEI.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +6 -1
- 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,
|
|
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
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
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
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
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
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
6872
|
-
|
|
6873
|
-
|
|
6874
|
-
|
|
6875
|
-
|
|
6876
|
-
|
|
6877
|
-
|
|
6878
|
-
|
|
6879
|
-
|
|
6880
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
]
|
|
@@ -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":
|
|
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(
|
|
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
|
-
|
|
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
|
|
847
|
-
|
|
848
|
-
a title, so False (the default) fails closed
|
|
849
|
-
to pass it — no
|
|
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(
|
|
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
|
|