cctally 1.97.0 → 1.99.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 +47 -0
- package/README.md +4 -4
- package/bin/_cctally_account.py +925 -0
- package/bin/_cctally_cache.py +829 -31
- package/bin/_cctally_core.py +52 -0
- package/bin/_cctally_dashboard.py +960 -55
- package/bin/_cctally_dashboard_envelope.py +43 -23
- package/bin/_cctally_dashboard_share.py +50 -1
- package/bin/_cctally_dashboard_sources.py +1580 -131
- package/bin/_cctally_db.py +810 -34
- package/bin/_cctally_doctor.py +184 -1
- package/bin/_cctally_journal.py +732 -76
- package/bin/_cctally_parser.py +65 -0
- package/bin/_cctally_quota.py +896 -17
- package/bin/_cctally_rederive.py +157 -5
- package/bin/_cctally_source_analytics.py +60 -6
- package/bin/_cctally_tui.py +811 -67
- package/bin/_lib_aggregators.py +117 -11
- package/bin/_lib_alert_axes.py +8 -3
- package/bin/_lib_budget.py +60 -0
- package/bin/_lib_codex_window_attribution.py +259 -0
- package/bin/_lib_dashboard_sources.py +488 -19
- package/bin/_lib_doctor.py +71 -0
- package/bin/_lib_journal.py +212 -0
- package/bin/_lib_jsonl.py +6 -0
- package/bin/_lib_rederive.py +8 -0
- package/bin/_lib_snapshot_cache.py +248 -7
- package/bin/_lib_source_analytics.py +20 -2
- package/bin/cctally +25 -0
- package/dashboard/static/assets/index-Bcbm-DNP.js +97 -0
- package/dashboard/static/assets/index-hJP4wlIO.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-BSESoPIK.css +0 -1
- package/dashboard/static/assets/index-DgsMz5hA.js +0 -97
package/bin/_cctally_tui.py
CHANGED
|
@@ -193,6 +193,7 @@ import sqlite3
|
|
|
193
193
|
import sys
|
|
194
194
|
import threading
|
|
195
195
|
import time
|
|
196
|
+
from collections.abc import Mapping
|
|
196
197
|
from dataclasses import dataclass, field
|
|
197
198
|
from typing import Any, Sequence
|
|
198
199
|
|
|
@@ -264,6 +265,7 @@ _ensure_sibling_loaded("_cctally_dashboard_sources")
|
|
|
264
265
|
from _cctally_dashboard_sources import (
|
|
265
266
|
DashboardReadContext,
|
|
266
267
|
_claude_accounts_wire,
|
|
268
|
+
_refresh_budget_status_clock,
|
|
267
269
|
accounts_identity_digest,
|
|
268
270
|
build_codex_source_state,
|
|
269
271
|
codex_decision_deadline_passed,
|
|
@@ -281,6 +283,11 @@ from _lib_dashboard_sources import (
|
|
|
281
283
|
SourceDashboardBundle,
|
|
282
284
|
SourceDashboardState,
|
|
283
285
|
SourceDashboardWarning,
|
|
286
|
+
aggregate_range,
|
|
287
|
+
aggregate_scope_failed,
|
|
288
|
+
aggregate_scope_identity,
|
|
289
|
+
build_aggregate_scope,
|
|
290
|
+
claude_stats_digest,
|
|
284
291
|
codex_stats_digest,
|
|
285
292
|
compose_all_state,
|
|
286
293
|
dashboard_resource_key,
|
|
@@ -2311,7 +2318,12 @@ def _snapshot_data_version(sig) -> str:
|
|
|
2311
2318
|
# in is what leaves the idle short-circuit so the source bundle is rebuilt
|
|
2312
2319
|
# at all. Empty once the backlog has drained, so it is byte-neutral there.
|
|
2313
2320
|
backlog = getattr(sig, "codex_ingest_backlog_sig", "")
|
|
2314
|
-
|
|
2321
|
+
out = out if not backlog else f"{out}.b{backlog}"
|
|
2322
|
+
# #556 S3 §2.9: the Claude alert relations. A fired or armed Claude alert
|
|
2323
|
+
# moves no numeric leg above, so without this the detail endpoints' change
|
|
2324
|
+
# signal stays flat across a tick that added an alert row.
|
|
2325
|
+
claude_digest = getattr(sig, "claude_stats_digest", "")
|
|
2326
|
+
return out if not claude_digest else f"{out}.x{claude_digest}"
|
|
2315
2327
|
|
|
2316
2328
|
|
|
2317
2329
|
def _tui_source_copy(value: object) -> object:
|
|
@@ -2342,6 +2354,38 @@ def _tui_claude_resource_row(
|
|
|
2342
2354
|
return wire
|
|
2343
2355
|
|
|
2344
2356
|
|
|
2357
|
+
def alert_row_owner(
|
|
2358
|
+
axis: object, vendor: object, metric: object,
|
|
2359
|
+
) -> str:
|
|
2360
|
+
"""Total ownership classifier for a legacy alert row (#556 S3 §3.4).
|
|
2361
|
+
|
|
2362
|
+
Raises on an unregistered axis, so adding a seventh axis without deciding
|
|
2363
|
+
its owner fails a test instead of shipping a row invisible everywhere. The
|
|
2364
|
+
predicate this replaced answered `False` for an unknown axis, which reads
|
|
2365
|
+
as "Codex owns it" and is indistinguishable from a real Codex row.
|
|
2366
|
+
"""
|
|
2367
|
+
if axis in {"weekly", "five_hour", "budget", "project_budget"}:
|
|
2368
|
+
# An absent vendor is the established Claude meaning: the legacy rows
|
|
2369
|
+
# predate the additive vendor field. An explicit non-Claude vendor is
|
|
2370
|
+
# never relabelled — `project_budget` gained that check here, having
|
|
2371
|
+
# previously claimed every row whatever its vendor said.
|
|
2372
|
+
return "codex" if vendor == "codex" else "claude"
|
|
2373
|
+
if axis == "projected":
|
|
2374
|
+
# The metric is the owner here, and it is enumerated rather than
|
|
2375
|
+
# defaulted. Defaulting an unrecognized metric to Claude would let a
|
|
2376
|
+
# future Codex-side projected metric render in the Claude tab, and
|
|
2377
|
+
# defaulting it to Codex would drop it from every surface without a
|
|
2378
|
+
# word — the two failure modes this classifier exists to prevent.
|
|
2379
|
+
if metric in {"weekly_pct", "budget_usd"}:
|
|
2380
|
+
return "claude"
|
|
2381
|
+
if metric == "codex_budget_usd":
|
|
2382
|
+
return "codex"
|
|
2383
|
+
raise ValueError(f"no ownership rule for projected metric {metric!r}")
|
|
2384
|
+
if axis == "codex_budget":
|
|
2385
|
+
return "codex"
|
|
2386
|
+
raise ValueError(f"no ownership rule for alert axis {axis!r}")
|
|
2387
|
+
|
|
2388
|
+
|
|
2345
2389
|
def _tui_project_claude_source_data(legacy_envelope: object) -> dict[str, object]:
|
|
2346
2390
|
"""Project one completed Claude legacy envelope without further DB reads.
|
|
2347
2391
|
|
|
@@ -2445,17 +2489,7 @@ def _tui_project_claude_source_data(legacy_envelope: object) -> dict[str, object
|
|
|
2445
2489
|
axis = raw.get("axis")
|
|
2446
2490
|
vendor = raw.get("vendor")
|
|
2447
2491
|
metric = raw.get("metric")
|
|
2448
|
-
|
|
2449
|
-
(axis in {"weekly", "five_hour"} and vendor in {None, "claude"})
|
|
2450
|
-
# Legacy top-level Claude budget rows predate the additive vendor
|
|
2451
|
-
# field; the distinct Codex axis is ``codex_budget``. Treat an
|
|
2452
|
-
# absent vendor as that established Claude meaning, while an
|
|
2453
|
-
# explicit non-Claude vendor must never be relabeled.
|
|
2454
|
-
or (axis == "budget" and vendor in {None, "claude"})
|
|
2455
|
-
or axis == "project_budget"
|
|
2456
|
-
or (axis == "projected" and metric in {"weekly_pct", "budget_usd"})
|
|
2457
|
-
)
|
|
2458
|
-
if not owns_alert:
|
|
2492
|
+
if alert_row_owner(axis, vendor, metric) != "claude":
|
|
2459
2493
|
continue
|
|
2460
2494
|
alert_rows.append(_tui_claude_resource_row(
|
|
2461
2495
|
raw,
|
|
@@ -2572,6 +2606,54 @@ def _tui_claude_domain_freshness(
|
|
|
2572
2606
|
return {"hero": accounting, "quota": quota, "sessions": "fresh"}
|
|
2573
2607
|
|
|
2574
2608
|
|
|
2609
|
+
def _refresh_claude_budget_clock(
|
|
2610
|
+
state: SourceDashboardState,
|
|
2611
|
+
*,
|
|
2612
|
+
now_utc: dt.datetime,
|
|
2613
|
+
) -> SourceDashboardState:
|
|
2614
|
+
"""Re-run the pure pace kernel over Claude's frozen budget facts.
|
|
2615
|
+
|
|
2616
|
+
#556 S5 §3.7. Separate from ``_refresh_claude_source_clock`` because the two
|
|
2617
|
+
are called from different places: that one runs ONLY on the pure-idle short
|
|
2618
|
+
circuit and needs the legacy ``current_week`` object and the raw config,
|
|
2619
|
+
neither of which the source-bundle builder holds. This one needs only the
|
|
2620
|
+
published status and the server-private cost events, so it can be called
|
|
2621
|
+
unconditionally after every build, reuse and degrade branch — which is what
|
|
2622
|
+
§3.7 requires, because exact-version Claude reuse returns the prior object
|
|
2623
|
+
unchanged and would otherwise republish a budget frozen at the instant it
|
|
2624
|
+
was built.
|
|
2625
|
+
|
|
2626
|
+
Same-instant identity is preserved: the underlying kernel is deterministic
|
|
2627
|
+
in ``now``, so a freshly built state reclocks to itself and the equality
|
|
2628
|
+
guards below hand the caller the exact object it passed in.
|
|
2629
|
+
"""
|
|
2630
|
+
if state.source != "claude" or not isinstance(state.data, Mapping):
|
|
2631
|
+
return state
|
|
2632
|
+
if now_utc.tzinfo is None or now_utc.utcoffset() is None:
|
|
2633
|
+
raise ValueError("now_utc must be timezone-aware")
|
|
2634
|
+
now_utc = now_utc.astimezone(dt.timezone.utc)
|
|
2635
|
+
budget_domain = state.data.get("budget")
|
|
2636
|
+
if not isinstance(budget_domain, Mapping):
|
|
2637
|
+
return state
|
|
2638
|
+
status = budget_domain.get("status")
|
|
2639
|
+
if not isinstance(status, Mapping):
|
|
2640
|
+
return state
|
|
2641
|
+
refreshed = _refresh_budget_status_clock(
|
|
2642
|
+
status,
|
|
2643
|
+
now_utc,
|
|
2644
|
+
cost_events=(
|
|
2645
|
+
state.clock_data.get("claude_budget_cost_events", ())
|
|
2646
|
+
if isinstance(state.clock_data, Mapping) else ()
|
|
2647
|
+
),
|
|
2648
|
+
)
|
|
2649
|
+
if refreshed is None or refreshed == status:
|
|
2650
|
+
return state
|
|
2651
|
+
data = dict(state.data)
|
|
2652
|
+
data["budget"] = {**dict(budget_domain), "status": refreshed}
|
|
2653
|
+
refreshed_state = dataclasses.replace(state, data=data)
|
|
2654
|
+
return state if refreshed_state == state else refreshed_state
|
|
2655
|
+
|
|
2656
|
+
|
|
2575
2657
|
def _refresh_claude_source_clock(
|
|
2576
2658
|
state: SourceDashboardState,
|
|
2577
2659
|
*,
|
|
@@ -2623,6 +2705,10 @@ def _refresh_claude_source_clock(
|
|
|
2623
2705
|
state,
|
|
2624
2706
|
domain_freshness=domain_freshness,
|
|
2625
2707
|
)
|
|
2708
|
+
# #556 S5 §3.7: the budget leg runs on the pure-idle path too. It is the
|
|
2709
|
+
# SAME helper the bundle builder calls after every other branch, so the two
|
|
2710
|
+
# paths cannot drift.
|
|
2711
|
+
refreshed = _refresh_claude_budget_clock(refreshed, now_utc=now_utc)
|
|
2626
2712
|
return state if refreshed == state else refreshed
|
|
2627
2713
|
|
|
2628
2714
|
|
|
@@ -2720,6 +2806,459 @@ def _tui_with_account_scope(
|
|
|
2720
2806
|
return dataclasses.replace(state, account_scope=scope)
|
|
2721
2807
|
|
|
2722
2808
|
|
|
2809
|
+
_AGGREGATE_FOLD_FAILED = {"state": "failed", "code": "claude_fold_failed"}
|
|
2810
|
+
|
|
2811
|
+
|
|
2812
|
+
def _tui_build_claude_aggregates(
|
|
2813
|
+
cache_conn,
|
|
2814
|
+
*,
|
|
2815
|
+
shared_start: dt.datetime,
|
|
2816
|
+
shared_end_exclusive: dt.datetime,
|
|
2817
|
+
now_utc: dt.datetime,
|
|
2818
|
+
display_tz_name: str | None,
|
|
2819
|
+
# NOT `legacy_project_labels`: that is the name of the public kernel
|
|
2820
|
+
# function this receives the RESULT of (`c.legacy_project_labels`), and a
|
|
2821
|
+
# parameter shadowing it inside a function that also calls it reads as a
|
|
2822
|
+
# recursive reference.
|
|
2823
|
+
legacy_labels: "dict[str, str] | None" = None,
|
|
2824
|
+
max_entry_id: "int | None" = None,
|
|
2825
|
+
entry_mutation_seq: "int | None" = None,
|
|
2826
|
+
generation: int = 0,
|
|
2827
|
+
):
|
|
2828
|
+
"""Both All-only Claude legs, from ONE candidate read (spec §3.3, §3.4).
|
|
2829
|
+
|
|
2830
|
+
Returns ``(payload, outcomes)`` where ``payload`` holds the published rows
|
|
2831
|
+
for whichever legs succeeded and ``outcomes`` names each leg's state.
|
|
2832
|
+
|
|
2833
|
+
Runs on the caller's PINNED cache connection, beside the Codex read and on
|
|
2834
|
+
the same snapshot. Both legacy paths stay untouched: the attached-cache
|
|
2835
|
+
block continues to serve ``env.projects`` and the Group-A read continues to
|
|
2836
|
+
serve ``env.daily``, for the Claude tab.
|
|
2837
|
+
|
|
2838
|
+
Each fold has its OWN error boundary, so one failure cannot take the bundle
|
|
2839
|
+
or the other leg down. A failure of the shared read itself fails both, since
|
|
2840
|
+
neither leg has rows. A failure is a typed withheld outcome rather than an
|
|
2841
|
+
escaped exception: today an exception inside this helper is caught by the
|
|
2842
|
+
outer handler, which publishes the prior bundle or none at all, so a
|
|
2843
|
+
cold-start fold failure could never become the outcome §3.7 promises.
|
|
2844
|
+
"""
|
|
2845
|
+
from zoneinfo import ZoneInfo
|
|
2846
|
+
|
|
2847
|
+
c = _cctally()
|
|
2848
|
+
display_tz = ZoneInfo(display_tz_name) if display_tz_name else None
|
|
2849
|
+
payload: dict[str, object] = {}
|
|
2850
|
+
outcomes: dict[str, object] = {
|
|
2851
|
+
"projects": {"state": "ok"}, "daily": {"state": "ok"},
|
|
2852
|
+
}
|
|
2853
|
+
dashboard_module = sys.modules["_cctally_dashboard"]
|
|
2854
|
+
cache_seams_unpatched = (
|
|
2855
|
+
c.build_project_aggregate_rows
|
|
2856
|
+
is dashboard_module.build_project_aggregate_rows
|
|
2857
|
+
and c.build_daily_aggregate_rows
|
|
2858
|
+
is dashboard_module.build_daily_aggregate_rows
|
|
2859
|
+
)
|
|
2860
|
+
if legacy_labels is not None and cache_seams_unpatched:
|
|
2861
|
+
try:
|
|
2862
|
+
return c.build_cached_claude_range_aggregates(
|
|
2863
|
+
cache_conn,
|
|
2864
|
+
shared_start=shared_start,
|
|
2865
|
+
shared_end_exclusive=shared_end_exclusive,
|
|
2866
|
+
now_utc=now_utc,
|
|
2867
|
+
display_tz=display_tz,
|
|
2868
|
+
legacy_labels=legacy_labels,
|
|
2869
|
+
max_entry_id=max_entry_id,
|
|
2870
|
+
entry_mutation_seq=entry_mutation_seq,
|
|
2871
|
+
generation=generation,
|
|
2872
|
+
), outcomes
|
|
2873
|
+
except Exception:
|
|
2874
|
+
# The accumulator is only an optimization. Its failure falls
|
|
2875
|
+
# through to the original two independent fold boundaries below.
|
|
2876
|
+
_lib_log.get_logger("dashboard").error(
|
|
2877
|
+
"claude range aggregate cache failed", exc_info=True,
|
|
2878
|
+
)
|
|
2879
|
+
try:
|
|
2880
|
+
rows = tuple(c.iter_shared_range_entries(
|
|
2881
|
+
cache_conn, start=shared_start, end_exclusive=shared_end_exclusive,
|
|
2882
|
+
))
|
|
2883
|
+
except Exception:
|
|
2884
|
+
_lib_log.get_logger("dashboard").error(
|
|
2885
|
+
"claude shared-range candidate read failed", exc_info=True,
|
|
2886
|
+
)
|
|
2887
|
+
return {}, {
|
|
2888
|
+
"projects": dict(_AGGREGATE_FOLD_FAILED),
|
|
2889
|
+
"daily": dict(_AGGREGATE_FOLD_FAILED),
|
|
2890
|
+
}
|
|
2891
|
+
prepared_daily_entries = None
|
|
2892
|
+
if legacy_labels is None:
|
|
2893
|
+
# No projects envelope was built this tick, so the routable population
|
|
2894
|
+
# is unknown. Publishing anyway would relabel every row from the
|
|
2895
|
+
# bounded population, mint different opaque keys, and hand them to a
|
|
2896
|
+
# drill-down that resolves against an envelope it rebuilds for itself
|
|
2897
|
+
# — the rows on screen and the rows the route can serve would be two
|
|
2898
|
+
# different populations, and nothing would say so. Withholding states
|
|
2899
|
+
# the failure instead, and `claude_fold_failed` also disqualifies the
|
|
2900
|
+
# bundle from idle reuse, so the next tick's envelope gets a chance.
|
|
2901
|
+
_lib_log.get_logger("dashboard").error(
|
|
2902
|
+
"claude range projects fold has no projects envelope",
|
|
2903
|
+
)
|
|
2904
|
+
outcomes["projects"] = dict(_AGGREGATE_FOLD_FAILED)
|
|
2905
|
+
else:
|
|
2906
|
+
candidate_daily_entries = []
|
|
2907
|
+
try:
|
|
2908
|
+
payload["projects"] = c.build_project_aggregate_rows(
|
|
2909
|
+
rows,
|
|
2910
|
+
legacy_labels=legacy_labels,
|
|
2911
|
+
prepared_daily_entries=candidate_daily_entries,
|
|
2912
|
+
)
|
|
2913
|
+
prepared_daily_entries = candidate_daily_entries
|
|
2914
|
+
except Exception:
|
|
2915
|
+
_lib_log.get_logger("dashboard").error(
|
|
2916
|
+
"claude range projects fold failed", exc_info=True,
|
|
2917
|
+
)
|
|
2918
|
+
outcomes["projects"] = dict(_AGGREGATE_FOLD_FAILED)
|
|
2919
|
+
try:
|
|
2920
|
+
payload["daily"] = [
|
|
2921
|
+
c.daily_panel_row_to_wire(row)
|
|
2922
|
+
for row in c.build_daily_aggregate_rows(
|
|
2923
|
+
rows,
|
|
2924
|
+
now_utc=now_utc,
|
|
2925
|
+
display_tz=display_tz,
|
|
2926
|
+
prepared_entries=prepared_daily_entries,
|
|
2927
|
+
)
|
|
2928
|
+
]
|
|
2929
|
+
except Exception:
|
|
2930
|
+
_lib_log.get_logger("dashboard").error(
|
|
2931
|
+
"claude range daily fold failed", exc_info=True,
|
|
2932
|
+
)
|
|
2933
|
+
outcomes["daily"] = dict(_AGGREGATE_FOLD_FAILED)
|
|
2934
|
+
return payload, outcomes
|
|
2935
|
+
|
|
2936
|
+
|
|
2937
|
+
def _tui_claude_data_with_aggregates(
|
|
2938
|
+
claude_data: dict[str, object] | None,
|
|
2939
|
+
payload: dict[str, object],
|
|
2940
|
+
*,
|
|
2941
|
+
fallback: dict[str, object],
|
|
2942
|
+
) -> dict[str, object]:
|
|
2943
|
+
"""Attach the rows-only siblings without mutating the caller's dict.
|
|
2944
|
+
|
|
2945
|
+
``providers.claude.projects.aggregate`` and
|
|
2946
|
+
``providers.claude.periods.daily_aggregate`` are rows and nothing else — no
|
|
2947
|
+
range, no outcome. Those live once, on the All source.
|
|
2948
|
+
"""
|
|
2949
|
+
base = dict(claude_data) if claude_data is not None else dict(fallback)
|
|
2950
|
+
if "projects" in payload:
|
|
2951
|
+
projects = dict(base.get("projects") or {})
|
|
2952
|
+
projects["aggregate"] = {"rows": payload["projects"]}
|
|
2953
|
+
base["projects"] = projects
|
|
2954
|
+
if "daily" in payload:
|
|
2955
|
+
periods = dict(base.get("periods") or {})
|
|
2956
|
+
periods["daily_aggregate"] = {"rows": payload["daily"]}
|
|
2957
|
+
base["periods"] = periods
|
|
2958
|
+
return base
|
|
2959
|
+
|
|
2960
|
+
|
|
2961
|
+
# #556 S5 §3.5 — the five dispositions, encoded on the wire. `status` published
|
|
2962
|
+
# means CONFIGURED AND COMPUTED. Everything else is an optional sibling that is
|
|
2963
|
+
# omitted when inapplicable, so the ordinary no-budget payload is byte-identical
|
|
2964
|
+
# to what shipped before this session:
|
|
2965
|
+
#
|
|
2966
|
+
# provider_budget_unset no key at all (the default the client assumes)
|
|
2967
|
+
# account_budgets_only `not_configured.disposition`
|
|
2968
|
+
# period_unresolved `status_unavailable.code`
|
|
2969
|
+
# budget_compute_failed `status_unavailable.code`
|
|
2970
|
+
#
|
|
2971
|
+
# The unavailable shape follows S1's `combined_unavailable` — {code, message,
|
|
2972
|
+
# provider} — so one client reader handles both.
|
|
2973
|
+
_CLAUDE_BUDGET_UNAVAILABLE_MESSAGES = {
|
|
2974
|
+
"period_unresolved": (
|
|
2975
|
+
"Claude's budget period could not be resolved, so no budget status "
|
|
2976
|
+
"is published."
|
|
2977
|
+
),
|
|
2978
|
+
"budget_compute_failed": (
|
|
2979
|
+
"Claude's budget status could not be computed."
|
|
2980
|
+
),
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
|
|
2984
|
+
def _tui_claude_budget_period(claude_budget: "Mapping[str, object] | None") -> str:
|
|
2985
|
+
"""The configured Claude budget period, defaulting to ``subscription-week``.
|
|
2986
|
+
|
|
2987
|
+
One reader for the capability record and the published status, so the two
|
|
2988
|
+
can never name different periods.
|
|
2989
|
+
"""
|
|
2990
|
+
config = claude_budget if isinstance(claude_budget, Mapping) else {}
|
|
2991
|
+
return str(config.get("period") or "subscription-week")
|
|
2992
|
+
|
|
2993
|
+
|
|
2994
|
+
def _tui_claude_budget_window_identity(
|
|
2995
|
+
claude_budget: "Mapping[str, object] | None",
|
|
2996
|
+
*,
|
|
2997
|
+
now_utc: dt.datetime,
|
|
2998
|
+
display_tz_name: str | None,
|
|
2999
|
+
week_start_name: str,
|
|
3000
|
+
) -> str:
|
|
3001
|
+
"""The configured CALENDAR budget window's end, as a version fragment.
|
|
3002
|
+
|
|
3003
|
+
#556 S5 Unit 1 review R5, kept as DEFENCE IN DEPTH — not a fix for a
|
|
3004
|
+
shipped user-visible defect. `claude_version` carries
|
|
3005
|
+
`_tui_claude_period_identity`, which reads the SUBSCRIPTION-WEEK bounds and
|
|
3006
|
+
therefore tracks that period's boundary and no other. On a `calendar-week`
|
|
3007
|
+
or `calendar-month` budget the fragment that already moved was S2's
|
|
3008
|
+
aggregate-range fragment, and that one moves at DISPLAY-TIMEZONE midnight:
|
|
3009
|
+
`resolve_shared_range` (`bin/_cctally_dashboard.py`) floors the earliest
|
|
3010
|
+
day to midnight in the resolved display zone, and production passes ONE
|
|
3011
|
+
resolved zone object to both legs — `_tui_build_snapshot` sets
|
|
3012
|
+
`source_display_tz_name` from `_build_display_tz` and hands the same object
|
|
3013
|
+
to `_tui_common_source_range_start`, while `_resolve_display_tz_obj` always
|
|
3014
|
+
returns a `ZoneInfo`. A `calendar-week` or `calendar-month` boundary falls
|
|
3015
|
+
at local midnight, so the aggregate fragment already moved with it.
|
|
3016
|
+
|
|
3017
|
+
The Unit 2 review corrected the Unit 1 claim that this reproduced a
|
|
3018
|
+
production defect: the reproduction went red only because the test helper
|
|
3019
|
+
paired `display_tz_name="America/New_York"` with a UTC range start, which
|
|
3020
|
+
is a configuration production cannot construct. The fragment stays because
|
|
3021
|
+
it makes the budget window's own boundary the thing that invalidates the
|
|
3022
|
+
budget's own generation, rather than leaving that to a neighbouring
|
|
3023
|
+
fragment that happens to move at the same instant.
|
|
3024
|
+
|
|
3025
|
+
Returns `""` for `subscription-week`, which `_tui_claude_period_identity`
|
|
3026
|
+
already covers from the same bounds, and for an unconfigured budget — so an
|
|
3027
|
+
install with no budget produces a byte-identical version string. Calendar
|
|
3028
|
+
resolution is PURE (no database), which is what lets it run before the reuse
|
|
3029
|
+
decision on every tick.
|
|
3030
|
+
|
|
3031
|
+
A resolution failure contributes `""` rather than raising: the same failure
|
|
3032
|
+
reaches `_tui_claude_budget_domain`, which names `budget_compute_failed`.
|
|
3033
|
+
"""
|
|
3034
|
+
config = claude_budget if isinstance(claude_budget, Mapping) else {}
|
|
3035
|
+
if config.get("weekly_usd") is None:
|
|
3036
|
+
return ""
|
|
3037
|
+
period = _tui_claude_budget_period(config)
|
|
3038
|
+
if period == "subscription-week":
|
|
3039
|
+
return ""
|
|
3040
|
+
try:
|
|
3041
|
+
window = _tui_claude_budget_window(
|
|
3042
|
+
None,
|
|
3043
|
+
period=period,
|
|
3044
|
+
now_utc=now_utc,
|
|
3045
|
+
display_tz_name=display_tz_name,
|
|
3046
|
+
week_start_name=week_start_name,
|
|
3047
|
+
)
|
|
3048
|
+
except Exception:
|
|
3049
|
+
return ""
|
|
3050
|
+
if window is None:
|
|
3051
|
+
return ""
|
|
3052
|
+
return window[1].isoformat()
|
|
3053
|
+
|
|
3054
|
+
|
|
3055
|
+
def _tui_claude_budget_unavailable(
|
|
3056
|
+
code: str,
|
|
3057
|
+
*,
|
|
3058
|
+
budget_usd: float | None = None,
|
|
3059
|
+
period: str | None = None,
|
|
3060
|
+
) -> dict[str, object]:
|
|
3061
|
+
"""The `{code, message, provider}` sibling, plus what the user configured.
|
|
3062
|
+
|
|
3063
|
+
#556 S5 Unit 2 review F5. §4.6 requires `period_unresolved` to render "the
|
|
3064
|
+
configured amount with the window named as unresolved", and the client had
|
|
3065
|
+
no amount to render: this payload carried the code, the message and the
|
|
3066
|
+
provider, so the block printed the bare code and nothing else. Both codes
|
|
3067
|
+
are reached ONLY from a configured budget, so both carry the configured
|
|
3068
|
+
amount and period; they are additive and omitted when the caller has
|
|
3069
|
+
nothing to state, which keeps every other consumer's shape unchanged.
|
|
3070
|
+
"""
|
|
3071
|
+
payload: dict[str, object] = {
|
|
3072
|
+
"code": code,
|
|
3073
|
+
"message": _CLAUDE_BUDGET_UNAVAILABLE_MESSAGES[code],
|
|
3074
|
+
"provider": "claude",
|
|
3075
|
+
}
|
|
3076
|
+
if budget_usd is not None:
|
|
3077
|
+
try:
|
|
3078
|
+
payload["budget_usd"] = float(budget_usd)
|
|
3079
|
+
except (TypeError, ValueError):
|
|
3080
|
+
# The `budget_compute_failed` caller runs INSIDE the only exception
|
|
3081
|
+
# boundary the Claude build has, so a second raise here would take
|
|
3082
|
+
# the whole bundle down. Omitting the amount degrades this one line.
|
|
3083
|
+
pass
|
|
3084
|
+
if period is not None:
|
|
3085
|
+
payload["period"] = period
|
|
3086
|
+
return payload
|
|
3087
|
+
|
|
3088
|
+
|
|
3089
|
+
def _tui_claude_budget_window(
|
|
3090
|
+
stats_conn,
|
|
3091
|
+
*,
|
|
3092
|
+
period: str,
|
|
3093
|
+
now_utc: dt.datetime,
|
|
3094
|
+
display_tz_name: str | None,
|
|
3095
|
+
week_start_name: str,
|
|
3096
|
+
):
|
|
3097
|
+
"""Resolve the Claude budget window, or ``None`` when it cannot be resolved.
|
|
3098
|
+
|
|
3099
|
+
#556 S5 §3.1: the IMPURE resolvers stay here and are injected into the pure
|
|
3100
|
+
kernel. Subscription-week resolution reads ``weekly_usage_snapshots`` (and
|
|
3101
|
+
returns ``None`` before the first snapshot lands, which the CLI reports as
|
|
3102
|
+
``status: "no_data"``); calendar resolution goes through the same DST-correct
|
|
3103
|
+
`_resolve_calendar_window` the Codex side already uses, including its
|
|
3104
|
+
per-instant `display.tz = local` path.
|
|
3105
|
+
"""
|
|
3106
|
+
from zoneinfo import ZoneInfo
|
|
3107
|
+
|
|
3108
|
+
c = _cctally()
|
|
3109
|
+
forecast = c._load_sibling("_cctally_forecast")
|
|
3110
|
+
if period == "subscription-week":
|
|
3111
|
+
window = forecast._resolve_current_budget_window(stats_conn, now_utc)
|
|
3112
|
+
if window is None:
|
|
3113
|
+
return None
|
|
3114
|
+
start_at, end_at = window
|
|
3115
|
+
else:
|
|
3116
|
+
tz = ZoneInfo(display_tz_name) if display_tz_name else None
|
|
3117
|
+
start_at, end_at = forecast._resolve_calendar_window(
|
|
3118
|
+
period, now_utc, {"collector": {"week_start": week_start_name}}, tz,
|
|
3119
|
+
)
|
|
3120
|
+
return (
|
|
3121
|
+
start_at.astimezone(dt.timezone.utc),
|
|
3122
|
+
end_at.astimezone(dt.timezone.utc),
|
|
3123
|
+
)
|
|
3124
|
+
|
|
3125
|
+
|
|
3126
|
+
def _tui_claude_budget_cost_events(
|
|
3127
|
+
cache_conn, *, start_at: dt.datetime, end_at: dt.datetime,
|
|
3128
|
+
) -> tuple[tuple[dt.datetime, float], ...]:
|
|
3129
|
+
"""Freeze every configured-window Claude cost event for idle pace updates.
|
|
3130
|
+
|
|
3131
|
+
#556 S5 §3.7: trailing-24h spend CANNOT be derived from the status
|
|
3132
|
+
aggregate — `_refresh_budget_status_clock` iterates individual events — so
|
|
3133
|
+
the same per-event carrier Codex keeps in server-private `clock_data` is
|
|
3134
|
+
built here for Claude. It runs on the caller's PINNED cache connection and
|
|
3135
|
+
reuses `iter_shared_range_entries` plus `_shared_range_row_to_usage_entry`,
|
|
3136
|
+
which route through the `claude_usage_dict` chokepoint and therefore price
|
|
3137
|
+
the 1-hour cache-write portion correctly (#195).
|
|
3138
|
+
"""
|
|
3139
|
+
c = _cctally()
|
|
3140
|
+
events: list[tuple[dt.datetime, float]] = []
|
|
3141
|
+
for row in c.iter_shared_range_entries(
|
|
3142
|
+
cache_conn, start=start_at, end_exclusive=end_at,
|
|
3143
|
+
):
|
|
3144
|
+
entry = c._shared_range_row_to_usage_entry(row)
|
|
3145
|
+
timestamp = entry.timestamp
|
|
3146
|
+
if timestamp.tzinfo is None or timestamp.utcoffset() is None:
|
|
3147
|
+
timestamp = timestamp.replace(tzinfo=dt.timezone.utc)
|
|
3148
|
+
events.append((
|
|
3149
|
+
timestamp.astimezone(dt.timezone.utc),
|
|
3150
|
+
c._calculate_entry_cost(
|
|
3151
|
+
entry.model, entry.usage, mode="auto", cost_usd=entry.cost_usd,
|
|
3152
|
+
),
|
|
3153
|
+
))
|
|
3154
|
+
return tuple(events)
|
|
3155
|
+
|
|
3156
|
+
|
|
3157
|
+
def _tui_claude_budget_domain(
|
|
3158
|
+
cache_conn,
|
|
3159
|
+
stats_conn,
|
|
3160
|
+
*,
|
|
3161
|
+
claude_budget: "Mapping[str, object] | None",
|
|
3162
|
+
now_utc: dt.datetime,
|
|
3163
|
+
display_tz_name: str | None,
|
|
3164
|
+
week_start_name: str,
|
|
3165
|
+
) -> tuple[dict[str, object], tuple[tuple[dt.datetime, float], ...]]:
|
|
3166
|
+
"""Return ``(budget_domain_overlay, cost_events)`` for the Claude provider.
|
|
3167
|
+
|
|
3168
|
+
The status is VENDOR-WIDE (spec §3.4). A populated `budget.accounts` is NOT
|
|
3169
|
+
consumed to simulate per-account scoping: Claude publishes no
|
|
3170
|
+
`account_scopes` for such a map to describe, so an account-only
|
|
3171
|
+
configuration is its own disposition rather than a fabricated status.
|
|
3172
|
+
|
|
3173
|
+
Every failure is caught HERE. The Claude build has no error boundary of its
|
|
3174
|
+
own — unlike the Codex build at `_tui_build_source_bundle`'s
|
|
3175
|
+
`source_build_failed` handler — so an escaping budget error would take the
|
|
3176
|
+
whole bundle down, not merely the provider.
|
|
3177
|
+
"""
|
|
3178
|
+
config = claude_budget if isinstance(claude_budget, Mapping) else {}
|
|
3179
|
+
target = config.get("weekly_usd")
|
|
3180
|
+
if target is None:
|
|
3181
|
+
if config.get("accounts"):
|
|
3182
|
+
return {"not_configured": {"disposition": "account_budgets_only"}}, ()
|
|
3183
|
+
return {}, ()
|
|
3184
|
+
# Resolved BEFORE the boundary so the `budget_compute_failed` payload can
|
|
3185
|
+
# still name the configured period (#556 S5 Unit 2 review F5); the reader
|
|
3186
|
+
# is a pure config lookup with no failure mode of its own.
|
|
3187
|
+
period = _tui_claude_budget_period(config)
|
|
3188
|
+
try:
|
|
3189
|
+
c = _cctally()
|
|
3190
|
+
window = _tui_claude_budget_window(
|
|
3191
|
+
stats_conn,
|
|
3192
|
+
period=period,
|
|
3193
|
+
now_utc=now_utc,
|
|
3194
|
+
display_tz_name=display_tz_name,
|
|
3195
|
+
week_start_name=week_start_name,
|
|
3196
|
+
)
|
|
3197
|
+
if window is None:
|
|
3198
|
+
return {
|
|
3199
|
+
"status_unavailable": _tui_claude_budget_unavailable(
|
|
3200
|
+
"period_unresolved", budget_usd=target, period=period),
|
|
3201
|
+
}, ()
|
|
3202
|
+
start_at, end_at = window
|
|
3203
|
+
events = _tui_claude_budget_cost_events(
|
|
3204
|
+
cache_conn, start_at=start_at, end_at=end_at,
|
|
3205
|
+
)
|
|
3206
|
+
recent_start = max(start_at, now_utc - dt.timedelta(hours=24))
|
|
3207
|
+
status = c.budget_status_payload(
|
|
3208
|
+
period=period,
|
|
3209
|
+
window_start_at=start_at,
|
|
3210
|
+
window_end_at=end_at,
|
|
3211
|
+
target_usd=target,
|
|
3212
|
+
spent_usd=sum(
|
|
3213
|
+
cost for timestamp, cost in events
|
|
3214
|
+
if start_at <= timestamp < now_utc
|
|
3215
|
+
),
|
|
3216
|
+
recent_24h_usd=sum(
|
|
3217
|
+
cost for timestamp, cost in events
|
|
3218
|
+
if recent_start <= timestamp < now_utc
|
|
3219
|
+
),
|
|
3220
|
+
now=now_utc,
|
|
3221
|
+
alert_thresholds=config["alert_thresholds"],
|
|
3222
|
+
)
|
|
3223
|
+
except Exception:
|
|
3224
|
+
_lib_log.get_logger("dashboard").error(
|
|
3225
|
+
"claude budget status could not be computed", exc_info=True,
|
|
3226
|
+
)
|
|
3227
|
+
return {
|
|
3228
|
+
"status_unavailable": _tui_claude_budget_unavailable(
|
|
3229
|
+
"budget_compute_failed", budget_usd=target, period=period),
|
|
3230
|
+
}, ()
|
|
3231
|
+
# #556 S5 Unit 1 review R4 — retain ONLY the events a later reclock can
|
|
3232
|
+
# read. `_refresh_budget_status_clock` consults the carrier for exactly one
|
|
3233
|
+
# quantity, `recent_24h_usd` over `[max(window_start, now - 24h), now)`, and
|
|
3234
|
+
# `now` advances monotonically, so an event older than `now_utc - 24h` is
|
|
3235
|
+
# unreadable for the life of this retained state. `spent_usd` above is
|
|
3236
|
+
# already summed from the FULL set, so nothing published is lost — while a
|
|
3237
|
+
# `calendar-month` budget would otherwise pin up to 31 days of Claude
|
|
3238
|
+
# `session_entries` and make every reclock tick walk all of them.
|
|
3239
|
+
reclock_floor = now_utc - dt.timedelta(hours=24)
|
|
3240
|
+
retained = tuple(
|
|
3241
|
+
(timestamp, cost) for timestamp, cost in events
|
|
3242
|
+
if timestamp >= reclock_floor
|
|
3243
|
+
)
|
|
3244
|
+
return {"status": status}, retained
|
|
3245
|
+
|
|
3246
|
+
|
|
3247
|
+
def _tui_claude_data_with_budget(
|
|
3248
|
+
base: dict[str, object], overlay: dict[str, object],
|
|
3249
|
+
) -> dict[str, object]:
|
|
3250
|
+
"""Merge the budget overlay without mutating the caller's dict.
|
|
3251
|
+
|
|
3252
|
+
An empty overlay returns the base unchanged, which is what keeps the
|
|
3253
|
+
`provider_budget_unset` payload byte-identical.
|
|
3254
|
+
"""
|
|
3255
|
+
if not overlay:
|
|
3256
|
+
return base
|
|
3257
|
+
merged = dict(base)
|
|
3258
|
+
merged["budget"] = {**(merged.get("budget") or {}), **overlay}
|
|
3259
|
+
return merged
|
|
3260
|
+
|
|
3261
|
+
|
|
2723
3262
|
def _tui_build_source_bundle(
|
|
2724
3263
|
*,
|
|
2725
3264
|
stats_conn,
|
|
@@ -2733,6 +3272,7 @@ def _tui_build_source_bundle(
|
|
|
2733
3272
|
claude_total_tokens: int,
|
|
2734
3273
|
claude_data: dict[str, object] | None = None,
|
|
2735
3274
|
common_range_start: dt.datetime | None = None,
|
|
3275
|
+
projects_envelope: dict | None = None,
|
|
2736
3276
|
prior_bundle: SourceDashboardBundle | None = None,
|
|
2737
3277
|
raw_config: dict[str, object] | None = None,
|
|
2738
3278
|
) -> SourceDashboardBundle:
|
|
@@ -2758,10 +3298,48 @@ def _tui_build_source_bundle(
|
|
|
2758
3298
|
cache_conn.execute("BEGIN")
|
|
2759
3299
|
cache_read_tx = True
|
|
2760
3300
|
if common_range_start is None:
|
|
2761
|
-
|
|
3301
|
+
# Resolved through the SAME helper the callers use, with no daily
|
|
3302
|
+
# panel. A bare `now_utc - 30 days` here is a microsecond-precise
|
|
3303
|
+
# instant that advances on every tick, and the resolved start is
|
|
3304
|
+
# folded into both providers' version material at exactly the
|
|
3305
|
+
# granularity `compose_all_aggregates` compares it — so a start
|
|
3306
|
+
# that moves within a display day makes an unchanged provider's
|
|
3307
|
+
# retained carrier disagree with a rebuilt one's, and both
|
|
3308
|
+
# aggregates are then withheld as `retained_range_mismatch`
|
|
3309
|
+
# permanently. Both production callers pass a resolved start, so
|
|
3310
|
+
# this is the last producer that could reintroduce that shape.
|
|
3311
|
+
#
|
|
3312
|
+
# The zone lookup is guarded because this branch exists to be a
|
|
3313
|
+
# SAFE fallback. An unresolvable `display_tz_name` raising out of
|
|
3314
|
+
# it would take down the whole source build over the one path whose
|
|
3315
|
+
# purpose is to keep going, so an unusable name degrades to UTC —
|
|
3316
|
+
# which is what `resolve_shared_range` already does for `None`.
|
|
3317
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
3318
|
+
_fallback_tz = None
|
|
3319
|
+
if display_tz_name:
|
|
3320
|
+
try:
|
|
3321
|
+
_fallback_tz = ZoneInfo(display_tz_name)
|
|
3322
|
+
except (ZoneInfoNotFoundError, ValueError, OSError):
|
|
3323
|
+
_fallback_tz = None
|
|
3324
|
+
common_range_start, _fallback_end = c.resolve_shared_range(
|
|
3325
|
+
None,
|
|
3326
|
+
now_utc=now_utc,
|
|
3327
|
+
display_tz=_fallback_tz,
|
|
3328
|
+
)
|
|
2762
3329
|
if common_range_start.tzinfo is None or common_range_start.utcoffset() is None:
|
|
2763
3330
|
raise ValueError("common_range_start must be timezone-aware")
|
|
2764
3331
|
common_range_start = common_range_start.astimezone(dt.timezone.utc)
|
|
3332
|
+
# #556 S2 §3.2: ONE interval, resolved once and passed immutably to
|
|
3333
|
+
# both Claude folds and to the Codex read. The exclusive upper bound is
|
|
3334
|
+
# what the Codex projects read already applies, so the Codex tab stays
|
|
3335
|
+
# byte-stable. The PUBLISHED `end_at` is `now_utc` itself.
|
|
3336
|
+
shared_end_exclusive = now_utc.astimezone(
|
|
3337
|
+
dt.timezone.utc,
|
|
3338
|
+
) + dt.timedelta(microseconds=1)
|
|
3339
|
+
published_range = aggregate_range(
|
|
3340
|
+
common_range_start.isoformat(),
|
|
3341
|
+
now_utc.astimezone(dt.timezone.utc).isoformat(),
|
|
3342
|
+
)
|
|
2765
3343
|
semantics = resolve_dashboard_source_semantics(
|
|
2766
3344
|
raw_config if raw_config is not None else c.load_config(),
|
|
2767
3345
|
display_tz_name=display_tz_name,
|
|
@@ -2784,6 +3362,11 @@ def _tui_build_source_bundle(
|
|
|
2784
3362
|
from _cctally_quota import assert_projection_readable
|
|
2785
3363
|
assert_projection_readable(stats_conn)
|
|
2786
3364
|
stats_digest = codex_stats_digest(stats_conn)
|
|
3365
|
+
# #556 S3 §2.9: the Claude alert relations. Nothing else in the
|
|
3366
|
+
# signature moves when a Claude alert fires or is armed, so without
|
|
3367
|
+
# this the idle path can keep serving a prior bundle that predates the
|
|
3368
|
+
# alert.
|
|
3369
|
+
claude_digest = claude_stats_digest(stats_conn)
|
|
2787
3370
|
# #341 finding 9: the account registry/active-identity digest. Empty for
|
|
2788
3371
|
# every <=1-account install (byte-neutral — appended only when non-empty),
|
|
2789
3372
|
# so single-account source versions stay byte-identical to today; a
|
|
@@ -2798,6 +3381,7 @@ def _tui_build_source_bundle(
|
|
|
2798
3381
|
generation=c.current_generation(),
|
|
2799
3382
|
codex_stats_digest=stats_digest,
|
|
2800
3383
|
accounts_digest=accounts_digest,
|
|
3384
|
+
claude_stats_digest=claude_digest,
|
|
2801
3385
|
)
|
|
2802
3386
|
_acct_suffix = f":a{accounts_digest}" if accounts_digest else ""
|
|
2803
3387
|
# public #5: the hook's budgeted ingest can change what the Codex
|
|
@@ -2808,20 +3392,50 @@ def _tui_build_source_bundle(
|
|
|
2808
3392
|
# wire. Empty (and so byte-neutral) once the backlog has drained.
|
|
2809
3393
|
_backlog = getattr(signature, "codex_ingest_backlog_sig", "")
|
|
2810
3394
|
_backlog_suffix = f":b{_backlog}" if _backlog else ""
|
|
3395
|
+
# #556 S2 §3.6: the resolved range and the per-aggregate outcome enter
|
|
3396
|
+
# BOTH providers' version material. Both, so a shared-start change (a
|
|
3397
|
+
# display-day rollover) rebuilds them in lockstep and a coherent pair
|
|
3398
|
+
# can never disagree about the interval their rows cover. The fragment
|
|
3399
|
+
# below assumes SUCCESS, which is what makes it also the reuse gate: a
|
|
3400
|
+
# prior generation whose fold failed carries a different fragment and
|
|
3401
|
+
# therefore cannot be reused.
|
|
3402
|
+
_aggregate_suffix = ":g" + aggregate_scope_identity(
|
|
3403
|
+
build_aggregate_scope(published_range),
|
|
3404
|
+
)
|
|
2811
3405
|
codex_version = (
|
|
2812
3406
|
f"codex:{signature.max_codex_id}:"
|
|
2813
3407
|
f"{signature.codex_physical_mutation_seq}:{stats_digest}:"
|
|
2814
3408
|
f"{semantics.codex_identity}{_acct_suffix}{_backlog_suffix}"
|
|
3409
|
+
f"{_aggregate_suffix}"
|
|
2815
3410
|
)
|
|
3411
|
+
# #582: the dedicated accounting ledger is deliberately absent from
|
|
3412
|
+
# the published version string (byte-stable API contract), but an
|
|
3413
|
+
# id-stable token/account/project mutation still has to bypass exact
|
|
3414
|
+
# provider reuse so the incremental path can consume it.
|
|
3415
|
+
_codex_accounting_pending = c._load_sibling(
|
|
3416
|
+
"_lib_snapshot_cache"
|
|
3417
|
+
).codex_accounting_cache_pending(cache_conn)
|
|
2816
3418
|
# #556 S1 §3.6: normalized period identity, so a nominal week rollover
|
|
2817
3419
|
# invalidates the generation even when no database signature moved.
|
|
2818
3420
|
_period_identity = _tui_claude_period_identity(claude_data)
|
|
3421
|
+
# #556 S5 (Unit 1 review R5) — the CONFIGURED budget period has its own
|
|
3422
|
+
# boundary, and `_period_identity` above tracks only the subscription
|
|
3423
|
+
# week. Empty for an unconfigured or subscription-week budget, so the
|
|
3424
|
+
# version string is byte-identical on every install that had one before.
|
|
3425
|
+
_budget_identity = _tui_claude_budget_window_identity(
|
|
3426
|
+
semantics.claude_budget,
|
|
3427
|
+
now_utc=now_utc,
|
|
3428
|
+
display_tz_name=display_tz_name,
|
|
3429
|
+
week_start_name=semantics.week_start_name,
|
|
3430
|
+
)
|
|
3431
|
+
_budget_suffix = f":b{_budget_identity}" if _budget_identity else ""
|
|
2819
3432
|
claude_version = (
|
|
2820
3433
|
f"claude:{signature.max_entry_id}:{signature.entry_mutation_seq}:"
|
|
2821
3434
|
f"{signature.max_wus_id}:{signature.max_wcs_id}:"
|
|
2822
3435
|
f"{signature.reset_sig[0]}:{signature.reset_sig[1]}:"
|
|
2823
3436
|
f"{signature.generation}:{semantics.claude_identity}"
|
|
2824
|
-
f":p{_period_identity}{_acct_suffix}"
|
|
3437
|
+
f":p{_period_identity}{_budget_suffix}{_acct_suffix}"
|
|
3438
|
+
f"{_aggregate_suffix}:x{claude_digest}"
|
|
2825
3439
|
)
|
|
2826
3440
|
prior_claude = (
|
|
2827
3441
|
prior_bundle.sources.get("claude")
|
|
@@ -2853,6 +3467,14 @@ def _tui_build_source_bundle(
|
|
|
2853
3467
|
claude = reuse_coherent_source_state(
|
|
2854
3468
|
prior_claude, data_version=claude_version,
|
|
2855
3469
|
)
|
|
3470
|
+
# #556 S2 §3.6, gate 2 of 2. The version fragment above already
|
|
3471
|
+
# rejects a failed generation, but this gate is stated explicitly
|
|
3472
|
+
# rather than left implicit in string arithmetic: exact-version
|
|
3473
|
+
# provider reuse returns the PRIOR OBJECT unchanged, so a caught
|
|
3474
|
+
# fold failure that survived reuse would withhold the aggregate for
|
|
3475
|
+
# the life of the process. Gate 1 is the bundle-level idle guard.
|
|
3476
|
+
if claude is not None and aggregate_scope_failed(claude):
|
|
3477
|
+
claude = None
|
|
2856
3478
|
if claude is None:
|
|
2857
3479
|
claude_available = "ok" if (claude_cost_usd or claude_total_tokens) else "empty"
|
|
2858
3480
|
# #341 Task 4 (Ruling C): the conditional per-account Claude wire,
|
|
@@ -2875,6 +3497,64 @@ def _tui_build_source_bundle(
|
|
|
2875
3497
|
# wire must never fail the whole dashboard tick — it just falls
|
|
2876
3498
|
# back to the byte-stable undecorated shape.
|
|
2877
3499
|
claude_accounts = []
|
|
3500
|
+
# #556 S2 §3.3: both All-only Claude legs fold HERE, on the pinned
|
|
3501
|
+
# cache connection, after BEGIN and beside the Codex read, so the
|
|
3502
|
+
# two providers describe one snapshot. Folding them earlier — in
|
|
3503
|
+
# the attached-cache block or through the Group-A daily read — runs
|
|
3504
|
+
# against a different connection, and a cache commit in between
|
|
3505
|
+
# would publish Claude generation A beside Codex generation B while
|
|
3506
|
+
# the bundle's version names B.
|
|
3507
|
+
aggregate_payload, aggregate_outcomes = _tui_build_claude_aggregates(
|
|
3508
|
+
cache_conn,
|
|
3509
|
+
shared_start=common_range_start,
|
|
3510
|
+
shared_end_exclusive=shared_end_exclusive,
|
|
3511
|
+
now_utc=now_utc,
|
|
3512
|
+
display_tz_name=semantics.display_tz_name,
|
|
3513
|
+
# The legacy display keys the drill-down route resolves
|
|
3514
|
+
# against. Published rows adopt them wherever they exist, so
|
|
3515
|
+
# the aggregate identity and the legacy one agree and the
|
|
3516
|
+
# bounded rows stay routable. The raw envelope is required —
|
|
3517
|
+
# `claude_data` has already replaced every legacy display key
|
|
3518
|
+
# with an opaque key and dropped `bucket_path`, so the map
|
|
3519
|
+
# cannot be recovered from it.
|
|
3520
|
+
# `None` — not an empty map — when no envelope was built, so
|
|
3521
|
+
# the fold can tell "the legacy population is empty" from "the
|
|
3522
|
+
# legacy population is unknown" and withhold on the second.
|
|
3523
|
+
legacy_labels=(
|
|
3524
|
+
c.legacy_project_labels(projects_envelope)
|
|
3525
|
+
if projects_envelope is not None else None
|
|
3526
|
+
),
|
|
3527
|
+
max_entry_id=signature.max_entry_id,
|
|
3528
|
+
entry_mutation_seq=signature.entry_mutation_seq,
|
|
3529
|
+
generation=signature.generation,
|
|
3530
|
+
)
|
|
3531
|
+
# #556 S5 §3.2: the Claude budget fold runs HERE, on this
|
|
3532
|
+
# function's own pinned cache snapshot — the same snapshot the S2
|
|
3533
|
+
# folds and the Codex read use. That is the narrow, true claim: the
|
|
3534
|
+
# legacy Claude envelope was constructed BEFORE this call, and
|
|
3535
|
+
# stats.db deliberately stays in statement-scoped autocommit, so
|
|
3536
|
+
# this is not coherence with every number in the envelope.
|
|
3537
|
+
claude_budget_overlay, claude_budget_events = _tui_claude_budget_domain(
|
|
3538
|
+
cache_conn,
|
|
3539
|
+
stats_conn,
|
|
3540
|
+
claude_budget=semantics.claude_budget,
|
|
3541
|
+
now_utc=now_utc,
|
|
3542
|
+
display_tz_name=semantics.display_tz_name,
|
|
3543
|
+
week_start_name=semantics.week_start_name,
|
|
3544
|
+
)
|
|
3545
|
+
claude_aggregate_scope = build_aggregate_scope(
|
|
3546
|
+
published_range, aggregate_outcomes,
|
|
3547
|
+
)
|
|
3548
|
+
if aggregate_scope_failed(claude_aggregate_scope):
|
|
3549
|
+
# The published version must distinguish a failed fold from a
|
|
3550
|
+
# successful one over the same signature and the same bounds;
|
|
3551
|
+
# otherwise both would publish different rows under one
|
|
3552
|
+
# `data_version`. It also makes the next tick's success-shaped
|
|
3553
|
+
# candidate version mismatch, forcing the rebuild §3.6 requires.
|
|
3554
|
+
claude_version = (
|
|
3555
|
+
f"{claude_version}:x"
|
|
3556
|
+
f"{aggregate_scope_identity(claude_aggregate_scope)}"
|
|
3557
|
+
)
|
|
2878
3558
|
claude = SourceDashboardState(
|
|
2879
3559
|
source="claude",
|
|
2880
3560
|
availability=claude_available,
|
|
@@ -2890,31 +3570,51 @@ def _tui_build_source_bundle(
|
|
|
2890
3570
|
"sessions": CapabilityRecord("supported", "legacy-session-rollup"),
|
|
2891
3571
|
"forensics": CapabilityRecord("supported", "legacy-projection"),
|
|
2892
3572
|
"quota": CapabilityRecord("supported", "subscription-week"),
|
|
2893
|
-
|
|
3573
|
+
# #556 S5 §3.6: the CONFIGURED period, not a constant. This
|
|
3574
|
+
# record used to say `subscription-week` unconditionally
|
|
3575
|
+
# while Codex advertised `calendar-period`, and once the
|
|
3576
|
+
# published status beside it can carry `calendar-week` or
|
|
3577
|
+
# `calendar-month` the constant contradicts the very object
|
|
3578
|
+
# it describes. The default period IS `subscription-week`,
|
|
3579
|
+
# so an install with no budget configured advertises exactly
|
|
3580
|
+
# what it advertised before.
|
|
3581
|
+
"budget": CapabilityRecord(
|
|
3582
|
+
"supported", _tui_claude_budget_period(
|
|
3583
|
+
semantics.claude_budget),
|
|
3584
|
+
),
|
|
2894
3585
|
"projects": CapabilityRecord("supported", "legacy-projection"),
|
|
2895
3586
|
"alerts": CapabilityRecord("supported", "provider-native"),
|
|
2896
3587
|
},
|
|
2897
3588
|
data={
|
|
2898
|
-
**(
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
"
|
|
3589
|
+
**_tui_claude_data_with_budget(
|
|
3590
|
+
_tui_claude_data_with_aggregates(
|
|
3591
|
+
claude_data,
|
|
3592
|
+
aggregate_payload,
|
|
3593
|
+
fallback={
|
|
3594
|
+
"hero": {
|
|
3595
|
+
"cost_usd": claude_cost_usd,
|
|
3596
|
+
"total_tokens": claude_total_tokens,
|
|
3597
|
+
},
|
|
3598
|
+
"periods": {"daily": {"total_cost_usd": claude_cost_usd, "total_tokens": claude_total_tokens}},
|
|
3599
|
+
"sessions": {"rows": ()},
|
|
3600
|
+
"projects": {"rows": ()},
|
|
3601
|
+
"quota": {"blocks": (), "milestones": ()},
|
|
3602
|
+
"budget": {"label": "Claude subscription budget"},
|
|
3603
|
+
"alerts": {"rows": ()},
|
|
2904
3604
|
},
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
"projects": {"rows": ()},
|
|
2908
|
-
"quota": {"blocks": (), "milestones": ()},
|
|
2909
|
-
"budget": {"label": "Claude subscription budget"},
|
|
2910
|
-
"alerts": {"rows": ()},
|
|
2911
|
-
}
|
|
3605
|
+
),
|
|
3606
|
+
claude_budget_overlay,
|
|
2912
3607
|
),
|
|
2913
3608
|
**({"accounts": claude_accounts} if claude_accounts else {}),
|
|
2914
3609
|
},
|
|
2915
3610
|
domain_freshness=_tui_claude_domain_freshness(
|
|
2916
3611
|
claude_data, now_utc=now_utc,
|
|
2917
3612
|
),
|
|
3613
|
+
# #556 S5 §3.7: server-private, NEVER published. The idle clock
|
|
3614
|
+
# recomputes trailing-24h spend by iterating individual events,
|
|
3615
|
+
# which the status aggregate cannot supply.
|
|
3616
|
+
clock_data={"claude_budget_cost_events": claude_budget_events},
|
|
3617
|
+
aggregate_scope=claude_aggregate_scope,
|
|
2918
3618
|
)
|
|
2919
3619
|
if codex_ingest_failed:
|
|
2920
3620
|
warning = SourceDashboardWarning(
|
|
@@ -2949,7 +3649,8 @@ def _tui_build_source_bundle(
|
|
|
2949
3649
|
# construction: one rebuild per crossing, not one per tick.
|
|
2950
3650
|
codex = (
|
|
2951
3651
|
None if prior_codex is not None and (
|
|
2952
|
-
|
|
3652
|
+
_codex_accounting_pending
|
|
3653
|
+
or any(
|
|
2953
3654
|
warning.code == "codex_projection_incoherent"
|
|
2954
3655
|
for warning in prior_codex.warnings
|
|
2955
3656
|
)
|
|
@@ -2958,6 +3659,13 @@ def _tui_build_source_bundle(
|
|
|
2958
3659
|
prior_codex, data_version=codex_version,
|
|
2959
3660
|
)
|
|
2960
3661
|
)
|
|
3662
|
+
# #556 S2 §3.6: symmetric with Claude. Codex's rows are already
|
|
3663
|
+
# bounded by this same range, so its carrier records no fold of its
|
|
3664
|
+
# own — but a retained failure state must never be reused, and the
|
|
3665
|
+
# gate is stated on both providers so a future Codex-side fold
|
|
3666
|
+
# inherits it.
|
|
3667
|
+
if codex is not None and aggregate_scope_failed(codex):
|
|
3668
|
+
codex = None
|
|
2961
3669
|
if codex is None:
|
|
2962
3670
|
try:
|
|
2963
3671
|
codex = build_codex_source_state(
|
|
@@ -2977,6 +3685,13 @@ def _tui_build_source_bundle(
|
|
|
2977
3685
|
),
|
|
2978
3686
|
data_version=codex_version,
|
|
2979
3687
|
)
|
|
3688
|
+
# Attached ONLY on a fresh build, never on the reuse or degrade
|
|
3689
|
+
# paths: those carry rows this tick did not produce, and their
|
|
3690
|
+
# own carrier already describes the range that bounds them.
|
|
3691
|
+
codex = dataclasses.replace(
|
|
3692
|
+
codex,
|
|
3693
|
+
aggregate_scope=build_aggregate_scope(published_range),
|
|
3694
|
+
)
|
|
2980
3695
|
except Exception:
|
|
2981
3696
|
_lib_log.get_logger("dashboard").error(
|
|
2982
3697
|
"codex_read_model source build failed",
|
|
@@ -2995,9 +3710,18 @@ def _tui_build_source_bundle(
|
|
|
2995
3710
|
# cycle's expiry invariant holds on EVERY path, including the reuse
|
|
2996
3711
|
# path that returns the exact prior object (§2.5). Same-instant identity
|
|
2997
3712
|
# is preserved by ``refresh_codex_source_clock``'s own data-equality
|
|
2998
|
-
# guard, so a freshly built state is handed back unchanged.
|
|
2999
|
-
# deliberately untouched.
|
|
3713
|
+
# guard, so a freshly built state is handed back unchanged.
|
|
3000
3714
|
codex = refresh_codex_source_clock(codex, now_utc=now_utc)
|
|
3715
|
+
# #556 S5 §3.7: Claude is clocked here too, on the same terms and for
|
|
3716
|
+
# the same reason. This comment used to say Claude was deliberately
|
|
3717
|
+
# untouched, and that was correct only while Claude published nothing
|
|
3718
|
+
# time-dependent. It now publishes a budget pace, and exact-version
|
|
3719
|
+
# reuse returns the PRIOR OBJECT unchanged — so any tick another source
|
|
3720
|
+
# forced would have republished a budget frozen at its build instant.
|
|
3721
|
+
# Only the budget leg runs here: the two freshness axes need the legacy
|
|
3722
|
+
# `current_week` object, which this builder does not hold, and they are
|
|
3723
|
+
# already advanced by the pure-idle clock that does.
|
|
3724
|
+
claude = _refresh_claude_budget_clock(claude, now_utc=now_utc)
|
|
3001
3725
|
# #556 S1 §3.8: the decoration fact reaches composition as authoritative
|
|
3002
3726
|
# server-only metadata. It is attached HERE, after every build / reuse /
|
|
3003
3727
|
# degrade / clock branch, so no branch can publish a state without it.
|
|
@@ -3026,12 +3750,14 @@ def _tui_build_source_bundle(
|
|
|
3026
3750
|
cache_read_tx = False
|
|
3027
3751
|
post_stats_digest = codex_stats_digest(stats_conn)
|
|
3028
3752
|
post_accounts_digest = accounts_identity_digest(stats_conn)
|
|
3753
|
+
post_claude_digest = claude_stats_digest(stats_conn)
|
|
3029
3754
|
post_signature = c.compute_signature(
|
|
3030
3755
|
cache_conn,
|
|
3031
3756
|
stats_conn,
|
|
3032
3757
|
generation=c.current_generation(),
|
|
3033
3758
|
codex_stats_digest=post_stats_digest,
|
|
3034
3759
|
accounts_digest=post_accounts_digest,
|
|
3760
|
+
claude_stats_digest=post_claude_digest,
|
|
3035
3761
|
)
|
|
3036
3762
|
stats_generation_moved = (
|
|
3037
3763
|
post_signature.max_wus_id != signature.max_wus_id
|
|
@@ -3039,6 +3765,7 @@ def _tui_build_source_bundle(
|
|
|
3039
3765
|
or post_signature.reset_sig != signature.reset_sig
|
|
3040
3766
|
or post_stats_digest != stats_digest
|
|
3041
3767
|
or post_accounts_digest != accounts_digest
|
|
3768
|
+
or post_claude_digest != claude_digest
|
|
3042
3769
|
)
|
|
3043
3770
|
if stats_generation_moved:
|
|
3044
3771
|
if prior_bundle is not None:
|
|
@@ -3111,6 +3838,14 @@ def _tui_source_bundle_can_idle(bundle: SourceDashboardBundle | None) -> bool:
|
|
|
3111
3838
|
or state.freshness != "fresh"
|
|
3112
3839
|
or state.data is None):
|
|
3113
3840
|
return False
|
|
3841
|
+
# #556 S2 §3.6, gate 1 of 2. A locally caught fold failure leaves an
|
|
3842
|
+
# otherwise `ok` and `fresh` provider, so without this leg the bundle
|
|
3843
|
+
# would qualify for idle reuse and one transient failure would withhold
|
|
3844
|
+
# the aggregate for the life of the process. Falling through here routes
|
|
3845
|
+
# to the bounded source-adapter rebuild, which re-folds — at most one
|
|
3846
|
+
# rebuild per tick, so it creates no retry loop.
|
|
3847
|
+
if aggregate_scope_failed(state):
|
|
3848
|
+
return False
|
|
3114
3849
|
return True
|
|
3115
3850
|
|
|
3116
3851
|
|
|
@@ -3120,18 +3855,18 @@ def _tui_common_source_range_start(
|
|
|
3120
3855
|
now_utc: dt.datetime,
|
|
3121
3856
|
display_tz: dt.tzinfo | None,
|
|
3122
3857
|
) -> dt.datetime:
|
|
3123
|
-
"""Return the shared provider interval from the already-built daily rows.
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
return
|
|
3858
|
+
"""Return the shared provider interval from the already-built daily rows.
|
|
3859
|
+
|
|
3860
|
+
#556 S2 §3.2: the start bound is now resolved by
|
|
3861
|
+
``_cctally_dashboard.resolve_shared_range``, which also owns the exclusive
|
|
3862
|
+
upper bound the Claude folds enforce. This wrapper stays because every
|
|
3863
|
+
existing caller wants only the start, and because it is the monkeypatch
|
|
3864
|
+
surface the source-invalidation tests already use.
|
|
3865
|
+
"""
|
|
3866
|
+
start, _end_exclusive = _cctally().resolve_shared_range(
|
|
3867
|
+
daily_panel, now_utc=now_utc, display_tz=display_tz,
|
|
3868
|
+
)
|
|
3869
|
+
return start
|
|
3135
3870
|
|
|
3136
3871
|
|
|
3137
3872
|
def _tui_build_snapshot(
|
|
@@ -4058,30 +4793,37 @@ def _tui_build_snapshot_once(
|
|
|
4058
4793
|
"fingerprint": "source-projection",
|
|
4059
4794
|
},
|
|
4060
4795
|
)
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4796
|
+
# #566 §5.1 item 6: both calls carry their own phase. The two
|
|
4797
|
+
# of them are the tail of the build and were the only region
|
|
4798
|
+
# the trace never entered, so a slow store attributed 79% of
|
|
4799
|
+
# its build to the root's unnamed remainder.
|
|
4800
|
+
with _perf.phase("envelope.legacy_projection"):
|
|
4801
|
+
legacy_envelope = _cctally().snapshot_to_envelope(
|
|
4802
|
+
source_snapshot,
|
|
4803
|
+
now_utc=now_utc,
|
|
4804
|
+
display_tz_pref_override=display_tz_pref_override,
|
|
4805
|
+
runtime_bind=runtime_bind,
|
|
4806
|
+
)
|
|
4807
|
+
with _perf.phase("build.source_bundle"):
|
|
4808
|
+
source_bundle = _tui_build_source_bundle(
|
|
4809
|
+
stats_conn=conn,
|
|
4810
|
+
now_utc=now_utc,
|
|
4811
|
+
display_tz_name=(
|
|
4812
|
+
getattr(_build_display_tz, "key", None)
|
|
4813
|
+
if _build_display_tz is not None else None
|
|
4814
|
+
),
|
|
4815
|
+
codex_ingest_contended=codex_ingest_contended,
|
|
4816
|
+
codex_ingest_failed=codex_ingest_failed,
|
|
4817
|
+
claude_ingest_contended=claude_ingest_contended,
|
|
4818
|
+
claude_ingest_failed=claude_ingest_failed,
|
|
4819
|
+
claude_cost_usd=daily_total_cost_usd,
|
|
4820
|
+
claude_total_tokens=daily_total_tokens,
|
|
4821
|
+
claude_data=_tui_project_claude_source_data(legacy_envelope),
|
|
4822
|
+
common_range_start=common_range_start,
|
|
4823
|
+
projects_envelope=projects_envelope_block,
|
|
4824
|
+
prior_bundle=prior_source_bundle,
|
|
4825
|
+
raw_config=raw_config,
|
|
4826
|
+
)
|
|
4085
4827
|
if source_bundle is None:
|
|
4086
4828
|
raise RuntimeError("source bundle builder returned no bundle")
|
|
4087
4829
|
except QuotaProjectionIncomplete as exc:
|
|
@@ -4232,6 +4974,7 @@ def _tui_compute_dispatch_signature(stats_conn):
|
|
|
4232
4974
|
generation=sc.current_generation(),
|
|
4233
4975
|
codex_stats_digest=codex_stats_digest(stats_conn),
|
|
4234
4976
|
accounts_digest=accounts_identity_digest(stats_conn),
|
|
4977
|
+
claude_stats_digest=claude_stats_digest(stats_conn),
|
|
4235
4978
|
)
|
|
4236
4979
|
finally:
|
|
4237
4980
|
cache_conn.close()
|
|
@@ -4409,6 +5152,7 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
|
|
|
4409
5152
|
now_utc=now_utc,
|
|
4410
5153
|
display_tz=source_display_tz,
|
|
4411
5154
|
),
|
|
5155
|
+
projects_envelope=prior.projects_envelope,
|
|
4412
5156
|
prior_bundle=source_bundle,
|
|
4413
5157
|
raw_config=raw_config,
|
|
4414
5158
|
)
|