cctally 1.98.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 +23 -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 +375 -19
- package/bin/_cctally_dashboard_share.py +50 -1
- package/bin/_cctally_dashboard_sources.py +1501 -118
- 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 +508 -47
- package/bin/_lib_aggregators.py +117 -11
- package/bin/_lib_budget.py +60 -0
- package/bin/_lib_codex_window_attribution.py +259 -0
- package/bin/_lib_dashboard_sources.py +26 -1
- 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 +236 -7
- package/bin/_lib_source_analytics.py +20 -2
- package/bin/cctally +12 -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-CC8TTZUC.css +0 -1
- package/dashboard/static/assets/index-CChXFhs_.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,
|
|
@@ -2604,6 +2606,54 @@ def _tui_claude_domain_freshness(
|
|
|
2604
2606
|
return {"hero": accounting, "quota": quota, "sessions": "fresh"}
|
|
2605
2607
|
|
|
2606
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
|
+
|
|
2607
2657
|
def _refresh_claude_source_clock(
|
|
2608
2658
|
state: SourceDashboardState,
|
|
2609
2659
|
*,
|
|
@@ -2655,6 +2705,10 @@ def _refresh_claude_source_clock(
|
|
|
2655
2705
|
state,
|
|
2656
2706
|
domain_freshness=domain_freshness,
|
|
2657
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)
|
|
2658
2712
|
return state if refreshed == state else refreshed
|
|
2659
2713
|
|
|
2660
2714
|
|
|
@@ -2767,6 +2821,9 @@ def _tui_build_claude_aggregates(
|
|
|
2767
2821
|
# parameter shadowing it inside a function that also calls it reads as a
|
|
2768
2822
|
# recursive reference.
|
|
2769
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,
|
|
2770
2827
|
):
|
|
2771
2828
|
"""Both All-only Claude legs, from ONE candidate read (spec §3.3, §3.4).
|
|
2772
2829
|
|
|
@@ -2793,6 +2850,32 @@ def _tui_build_claude_aggregates(
|
|
|
2793
2850
|
outcomes: dict[str, object] = {
|
|
2794
2851
|
"projects": {"state": "ok"}, "daily": {"state": "ok"},
|
|
2795
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
|
+
)
|
|
2796
2879
|
try:
|
|
2797
2880
|
rows = tuple(c.iter_shared_range_entries(
|
|
2798
2881
|
cache_conn, start=shared_start, end_exclusive=shared_end_exclusive,
|
|
@@ -2805,6 +2888,7 @@ def _tui_build_claude_aggregates(
|
|
|
2805
2888
|
"projects": dict(_AGGREGATE_FOLD_FAILED),
|
|
2806
2889
|
"daily": dict(_AGGREGATE_FOLD_FAILED),
|
|
2807
2890
|
}
|
|
2891
|
+
prepared_daily_entries = None
|
|
2808
2892
|
if legacy_labels is None:
|
|
2809
2893
|
# No projects envelope was built this tick, so the routable population
|
|
2810
2894
|
# is unknown. Publishing anyway would relabel every row from the
|
|
@@ -2819,10 +2903,14 @@ def _tui_build_claude_aggregates(
|
|
|
2819
2903
|
)
|
|
2820
2904
|
outcomes["projects"] = dict(_AGGREGATE_FOLD_FAILED)
|
|
2821
2905
|
else:
|
|
2906
|
+
candidate_daily_entries = []
|
|
2822
2907
|
try:
|
|
2823
2908
|
payload["projects"] = c.build_project_aggregate_rows(
|
|
2824
|
-
rows,
|
|
2909
|
+
rows,
|
|
2910
|
+
legacy_labels=legacy_labels,
|
|
2911
|
+
prepared_daily_entries=candidate_daily_entries,
|
|
2825
2912
|
)
|
|
2913
|
+
prepared_daily_entries = candidate_daily_entries
|
|
2826
2914
|
except Exception:
|
|
2827
2915
|
_lib_log.get_logger("dashboard").error(
|
|
2828
2916
|
"claude range projects fold failed", exc_info=True,
|
|
@@ -2832,7 +2920,10 @@ def _tui_build_claude_aggregates(
|
|
|
2832
2920
|
payload["daily"] = [
|
|
2833
2921
|
c.daily_panel_row_to_wire(row)
|
|
2834
2922
|
for row in c.build_daily_aggregate_rows(
|
|
2835
|
-
rows,
|
|
2923
|
+
rows,
|
|
2924
|
+
now_utc=now_utc,
|
|
2925
|
+
display_tz=display_tz,
|
|
2926
|
+
prepared_entries=prepared_daily_entries,
|
|
2836
2927
|
)
|
|
2837
2928
|
]
|
|
2838
2929
|
except Exception:
|
|
@@ -2867,6 +2958,307 @@ def _tui_claude_data_with_aggregates(
|
|
|
2867
2958
|
return base
|
|
2868
2959
|
|
|
2869
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
|
+
|
|
2870
3262
|
def _tui_build_source_bundle(
|
|
2871
3263
|
*,
|
|
2872
3264
|
stats_conn,
|
|
@@ -3016,16 +3408,34 @@ def _tui_build_source_bundle(
|
|
|
3016
3408
|
f"{semantics.codex_identity}{_acct_suffix}{_backlog_suffix}"
|
|
3017
3409
|
f"{_aggregate_suffix}"
|
|
3018
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)
|
|
3019
3418
|
# #556 S1 §3.6: normalized period identity, so a nominal week rollover
|
|
3020
3419
|
# invalidates the generation even when no database signature moved.
|
|
3021
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 ""
|
|
3022
3432
|
claude_version = (
|
|
3023
3433
|
f"claude:{signature.max_entry_id}:{signature.entry_mutation_seq}:"
|
|
3024
3434
|
f"{signature.max_wus_id}:{signature.max_wcs_id}:"
|
|
3025
3435
|
f"{signature.reset_sig[0]}:{signature.reset_sig[1]}:"
|
|
3026
3436
|
f"{signature.generation}:{semantics.claude_identity}"
|
|
3027
|
-
f":p{_period_identity}{
|
|
3028
|
-
f":x{claude_digest}"
|
|
3437
|
+
f":p{_period_identity}{_budget_suffix}{_acct_suffix}"
|
|
3438
|
+
f"{_aggregate_suffix}:x{claude_digest}"
|
|
3029
3439
|
)
|
|
3030
3440
|
prior_claude = (
|
|
3031
3441
|
prior_bundle.sources.get("claude")
|
|
@@ -3114,6 +3524,23 @@ def _tui_build_source_bundle(
|
|
|
3114
3524
|
c.legacy_project_labels(projects_envelope)
|
|
3115
3525
|
if projects_envelope is not None else None
|
|
3116
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,
|
|
3117
3544
|
)
|
|
3118
3545
|
claude_aggregate_scope = build_aggregate_scope(
|
|
3119
3546
|
published_range, aggregate_outcomes,
|
|
@@ -3143,32 +3570,50 @@ def _tui_build_source_bundle(
|
|
|
3143
3570
|
"sessions": CapabilityRecord("supported", "legacy-session-rollup"),
|
|
3144
3571
|
"forensics": CapabilityRecord("supported", "legacy-projection"),
|
|
3145
3572
|
"quota": CapabilityRecord("supported", "subscription-week"),
|
|
3146
|
-
|
|
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
|
+
),
|
|
3147
3585
|
"projects": CapabilityRecord("supported", "legacy-projection"),
|
|
3148
3586
|
"alerts": CapabilityRecord("supported", "provider-native"),
|
|
3149
3587
|
},
|
|
3150
3588
|
data={
|
|
3151
|
-
**
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
"
|
|
3157
|
-
|
|
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": ()},
|
|
3158
3604
|
},
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
"projects": {"rows": ()},
|
|
3162
|
-
"quota": {"blocks": (), "milestones": ()},
|
|
3163
|
-
"budget": {"label": "Claude subscription budget"},
|
|
3164
|
-
"alerts": {"rows": ()},
|
|
3165
|
-
},
|
|
3605
|
+
),
|
|
3606
|
+
claude_budget_overlay,
|
|
3166
3607
|
),
|
|
3167
3608
|
**({"accounts": claude_accounts} if claude_accounts else {}),
|
|
3168
3609
|
},
|
|
3169
3610
|
domain_freshness=_tui_claude_domain_freshness(
|
|
3170
3611
|
claude_data, now_utc=now_utc,
|
|
3171
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},
|
|
3172
3617
|
aggregate_scope=claude_aggregate_scope,
|
|
3173
3618
|
)
|
|
3174
3619
|
if codex_ingest_failed:
|
|
@@ -3204,7 +3649,8 @@ def _tui_build_source_bundle(
|
|
|
3204
3649
|
# construction: one rebuild per crossing, not one per tick.
|
|
3205
3650
|
codex = (
|
|
3206
3651
|
None if prior_codex is not None and (
|
|
3207
|
-
|
|
3652
|
+
_codex_accounting_pending
|
|
3653
|
+
or any(
|
|
3208
3654
|
warning.code == "codex_projection_incoherent"
|
|
3209
3655
|
for warning in prior_codex.warnings
|
|
3210
3656
|
)
|
|
@@ -3264,9 +3710,18 @@ def _tui_build_source_bundle(
|
|
|
3264
3710
|
# cycle's expiry invariant holds on EVERY path, including the reuse
|
|
3265
3711
|
# path that returns the exact prior object (§2.5). Same-instant identity
|
|
3266
3712
|
# is preserved by ``refresh_codex_source_clock``'s own data-equality
|
|
3267
|
-
# guard, so a freshly built state is handed back unchanged.
|
|
3268
|
-
# deliberately untouched.
|
|
3713
|
+
# guard, so a freshly built state is handed back unchanged.
|
|
3269
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)
|
|
3270
3725
|
# #556 S1 §3.8: the decoration fact reaches composition as authoritative
|
|
3271
3726
|
# server-only metadata. It is attached HERE, after every build / reuse /
|
|
3272
3727
|
# degrade / clock branch, so no branch can publish a state without it.
|
|
@@ -4338,31 +4793,37 @@ def _tui_build_snapshot_once(
|
|
|
4338
4793
|
"fingerprint": "source-projection",
|
|
4339
4794
|
},
|
|
4340
4795
|
)
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
|
|
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
|
+
)
|
|
4366
4827
|
if source_bundle is None:
|
|
4367
4828
|
raise RuntimeError("source bundle builder returned no bundle")
|
|
4368
4829
|
except QuotaProjectionIncomplete as exc:
|