cctally 1.97.0 → 1.98.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 +24 -0
- package/bin/_cctally_dashboard.py +595 -46
- package/bin/_cctally_dashboard_envelope.py +43 -23
- package/bin/_cctally_dashboard_sources.py +79 -13
- package/bin/_cctally_tui.py +313 -30
- package/bin/_lib_alert_axes.py +8 -3
- package/bin/_lib_dashboard_sources.py +463 -19
- package/bin/_lib_snapshot_cache.py +12 -0
- package/bin/cctally +13 -0
- package/dashboard/static/assets/index-CC8TTZUC.css +1 -0
- package/dashboard/static/assets/index-CChXFhs_.js +97 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -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
|
@@ -281,6 +281,11 @@ from _lib_dashboard_sources import (
|
|
|
281
281
|
SourceDashboardBundle,
|
|
282
282
|
SourceDashboardState,
|
|
283
283
|
SourceDashboardWarning,
|
|
284
|
+
aggregate_range,
|
|
285
|
+
aggregate_scope_failed,
|
|
286
|
+
aggregate_scope_identity,
|
|
287
|
+
build_aggregate_scope,
|
|
288
|
+
claude_stats_digest,
|
|
284
289
|
codex_stats_digest,
|
|
285
290
|
compose_all_state,
|
|
286
291
|
dashboard_resource_key,
|
|
@@ -2311,7 +2316,12 @@ def _snapshot_data_version(sig) -> str:
|
|
|
2311
2316
|
# in is what leaves the idle short-circuit so the source bundle is rebuilt
|
|
2312
2317
|
# at all. Empty once the backlog has drained, so it is byte-neutral there.
|
|
2313
2318
|
backlog = getattr(sig, "codex_ingest_backlog_sig", "")
|
|
2314
|
-
|
|
2319
|
+
out = out if not backlog else f"{out}.b{backlog}"
|
|
2320
|
+
# #556 S3 §2.9: the Claude alert relations. A fired or armed Claude alert
|
|
2321
|
+
# moves no numeric leg above, so without this the detail endpoints' change
|
|
2322
|
+
# signal stays flat across a tick that added an alert row.
|
|
2323
|
+
claude_digest = getattr(sig, "claude_stats_digest", "")
|
|
2324
|
+
return out if not claude_digest else f"{out}.x{claude_digest}"
|
|
2315
2325
|
|
|
2316
2326
|
|
|
2317
2327
|
def _tui_source_copy(value: object) -> object:
|
|
@@ -2342,6 +2352,38 @@ def _tui_claude_resource_row(
|
|
|
2342
2352
|
return wire
|
|
2343
2353
|
|
|
2344
2354
|
|
|
2355
|
+
def alert_row_owner(
|
|
2356
|
+
axis: object, vendor: object, metric: object,
|
|
2357
|
+
) -> str:
|
|
2358
|
+
"""Total ownership classifier for a legacy alert row (#556 S3 §3.4).
|
|
2359
|
+
|
|
2360
|
+
Raises on an unregistered axis, so adding a seventh axis without deciding
|
|
2361
|
+
its owner fails a test instead of shipping a row invisible everywhere. The
|
|
2362
|
+
predicate this replaced answered `False` for an unknown axis, which reads
|
|
2363
|
+
as "Codex owns it" and is indistinguishable from a real Codex row.
|
|
2364
|
+
"""
|
|
2365
|
+
if axis in {"weekly", "five_hour", "budget", "project_budget"}:
|
|
2366
|
+
# An absent vendor is the established Claude meaning: the legacy rows
|
|
2367
|
+
# predate the additive vendor field. An explicit non-Claude vendor is
|
|
2368
|
+
# never relabelled — `project_budget` gained that check here, having
|
|
2369
|
+
# previously claimed every row whatever its vendor said.
|
|
2370
|
+
return "codex" if vendor == "codex" else "claude"
|
|
2371
|
+
if axis == "projected":
|
|
2372
|
+
# The metric is the owner here, and it is enumerated rather than
|
|
2373
|
+
# defaulted. Defaulting an unrecognized metric to Claude would let a
|
|
2374
|
+
# future Codex-side projected metric render in the Claude tab, and
|
|
2375
|
+
# defaulting it to Codex would drop it from every surface without a
|
|
2376
|
+
# word — the two failure modes this classifier exists to prevent.
|
|
2377
|
+
if metric in {"weekly_pct", "budget_usd"}:
|
|
2378
|
+
return "claude"
|
|
2379
|
+
if metric == "codex_budget_usd":
|
|
2380
|
+
return "codex"
|
|
2381
|
+
raise ValueError(f"no ownership rule for projected metric {metric!r}")
|
|
2382
|
+
if axis == "codex_budget":
|
|
2383
|
+
return "codex"
|
|
2384
|
+
raise ValueError(f"no ownership rule for alert axis {axis!r}")
|
|
2385
|
+
|
|
2386
|
+
|
|
2345
2387
|
def _tui_project_claude_source_data(legacy_envelope: object) -> dict[str, object]:
|
|
2346
2388
|
"""Project one completed Claude legacy envelope without further DB reads.
|
|
2347
2389
|
|
|
@@ -2445,17 +2487,7 @@ def _tui_project_claude_source_data(legacy_envelope: object) -> dict[str, object
|
|
|
2445
2487
|
axis = raw.get("axis")
|
|
2446
2488
|
vendor = raw.get("vendor")
|
|
2447
2489
|
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:
|
|
2490
|
+
if alert_row_owner(axis, vendor, metric) != "claude":
|
|
2459
2491
|
continue
|
|
2460
2492
|
alert_rows.append(_tui_claude_resource_row(
|
|
2461
2493
|
raw,
|
|
@@ -2720,6 +2752,121 @@ def _tui_with_account_scope(
|
|
|
2720
2752
|
return dataclasses.replace(state, account_scope=scope)
|
|
2721
2753
|
|
|
2722
2754
|
|
|
2755
|
+
_AGGREGATE_FOLD_FAILED = {"state": "failed", "code": "claude_fold_failed"}
|
|
2756
|
+
|
|
2757
|
+
|
|
2758
|
+
def _tui_build_claude_aggregates(
|
|
2759
|
+
cache_conn,
|
|
2760
|
+
*,
|
|
2761
|
+
shared_start: dt.datetime,
|
|
2762
|
+
shared_end_exclusive: dt.datetime,
|
|
2763
|
+
now_utc: dt.datetime,
|
|
2764
|
+
display_tz_name: str | None,
|
|
2765
|
+
# NOT `legacy_project_labels`: that is the name of the public kernel
|
|
2766
|
+
# function this receives the RESULT of (`c.legacy_project_labels`), and a
|
|
2767
|
+
# parameter shadowing it inside a function that also calls it reads as a
|
|
2768
|
+
# recursive reference.
|
|
2769
|
+
legacy_labels: "dict[str, str] | None" = None,
|
|
2770
|
+
):
|
|
2771
|
+
"""Both All-only Claude legs, from ONE candidate read (spec §3.3, §3.4).
|
|
2772
|
+
|
|
2773
|
+
Returns ``(payload, outcomes)`` where ``payload`` holds the published rows
|
|
2774
|
+
for whichever legs succeeded and ``outcomes`` names each leg's state.
|
|
2775
|
+
|
|
2776
|
+
Runs on the caller's PINNED cache connection, beside the Codex read and on
|
|
2777
|
+
the same snapshot. Both legacy paths stay untouched: the attached-cache
|
|
2778
|
+
block continues to serve ``env.projects`` and the Group-A read continues to
|
|
2779
|
+
serve ``env.daily``, for the Claude tab.
|
|
2780
|
+
|
|
2781
|
+
Each fold has its OWN error boundary, so one failure cannot take the bundle
|
|
2782
|
+
or the other leg down. A failure of the shared read itself fails both, since
|
|
2783
|
+
neither leg has rows. A failure is a typed withheld outcome rather than an
|
|
2784
|
+
escaped exception: today an exception inside this helper is caught by the
|
|
2785
|
+
outer handler, which publishes the prior bundle or none at all, so a
|
|
2786
|
+
cold-start fold failure could never become the outcome §3.7 promises.
|
|
2787
|
+
"""
|
|
2788
|
+
from zoneinfo import ZoneInfo
|
|
2789
|
+
|
|
2790
|
+
c = _cctally()
|
|
2791
|
+
display_tz = ZoneInfo(display_tz_name) if display_tz_name else None
|
|
2792
|
+
payload: dict[str, object] = {}
|
|
2793
|
+
outcomes: dict[str, object] = {
|
|
2794
|
+
"projects": {"state": "ok"}, "daily": {"state": "ok"},
|
|
2795
|
+
}
|
|
2796
|
+
try:
|
|
2797
|
+
rows = tuple(c.iter_shared_range_entries(
|
|
2798
|
+
cache_conn, start=shared_start, end_exclusive=shared_end_exclusive,
|
|
2799
|
+
))
|
|
2800
|
+
except Exception:
|
|
2801
|
+
_lib_log.get_logger("dashboard").error(
|
|
2802
|
+
"claude shared-range candidate read failed", exc_info=True,
|
|
2803
|
+
)
|
|
2804
|
+
return {}, {
|
|
2805
|
+
"projects": dict(_AGGREGATE_FOLD_FAILED),
|
|
2806
|
+
"daily": dict(_AGGREGATE_FOLD_FAILED),
|
|
2807
|
+
}
|
|
2808
|
+
if legacy_labels is None:
|
|
2809
|
+
# No projects envelope was built this tick, so the routable population
|
|
2810
|
+
# is unknown. Publishing anyway would relabel every row from the
|
|
2811
|
+
# bounded population, mint different opaque keys, and hand them to a
|
|
2812
|
+
# drill-down that resolves against an envelope it rebuilds for itself
|
|
2813
|
+
# — the rows on screen and the rows the route can serve would be two
|
|
2814
|
+
# different populations, and nothing would say so. Withholding states
|
|
2815
|
+
# the failure instead, and `claude_fold_failed` also disqualifies the
|
|
2816
|
+
# bundle from idle reuse, so the next tick's envelope gets a chance.
|
|
2817
|
+
_lib_log.get_logger("dashboard").error(
|
|
2818
|
+
"claude range projects fold has no projects envelope",
|
|
2819
|
+
)
|
|
2820
|
+
outcomes["projects"] = dict(_AGGREGATE_FOLD_FAILED)
|
|
2821
|
+
else:
|
|
2822
|
+
try:
|
|
2823
|
+
payload["projects"] = c.build_project_aggregate_rows(
|
|
2824
|
+
rows, legacy_labels=legacy_labels,
|
|
2825
|
+
)
|
|
2826
|
+
except Exception:
|
|
2827
|
+
_lib_log.get_logger("dashboard").error(
|
|
2828
|
+
"claude range projects fold failed", exc_info=True,
|
|
2829
|
+
)
|
|
2830
|
+
outcomes["projects"] = dict(_AGGREGATE_FOLD_FAILED)
|
|
2831
|
+
try:
|
|
2832
|
+
payload["daily"] = [
|
|
2833
|
+
c.daily_panel_row_to_wire(row)
|
|
2834
|
+
for row in c.build_daily_aggregate_rows(
|
|
2835
|
+
rows, now_utc=now_utc, display_tz=display_tz,
|
|
2836
|
+
)
|
|
2837
|
+
]
|
|
2838
|
+
except Exception:
|
|
2839
|
+
_lib_log.get_logger("dashboard").error(
|
|
2840
|
+
"claude range daily fold failed", exc_info=True,
|
|
2841
|
+
)
|
|
2842
|
+
outcomes["daily"] = dict(_AGGREGATE_FOLD_FAILED)
|
|
2843
|
+
return payload, outcomes
|
|
2844
|
+
|
|
2845
|
+
|
|
2846
|
+
def _tui_claude_data_with_aggregates(
|
|
2847
|
+
claude_data: dict[str, object] | None,
|
|
2848
|
+
payload: dict[str, object],
|
|
2849
|
+
*,
|
|
2850
|
+
fallback: dict[str, object],
|
|
2851
|
+
) -> dict[str, object]:
|
|
2852
|
+
"""Attach the rows-only siblings without mutating the caller's dict.
|
|
2853
|
+
|
|
2854
|
+
``providers.claude.projects.aggregate`` and
|
|
2855
|
+
``providers.claude.periods.daily_aggregate`` are rows and nothing else — no
|
|
2856
|
+
range, no outcome. Those live once, on the All source.
|
|
2857
|
+
"""
|
|
2858
|
+
base = dict(claude_data) if claude_data is not None else dict(fallback)
|
|
2859
|
+
if "projects" in payload:
|
|
2860
|
+
projects = dict(base.get("projects") or {})
|
|
2861
|
+
projects["aggregate"] = {"rows": payload["projects"]}
|
|
2862
|
+
base["projects"] = projects
|
|
2863
|
+
if "daily" in payload:
|
|
2864
|
+
periods = dict(base.get("periods") or {})
|
|
2865
|
+
periods["daily_aggregate"] = {"rows": payload["daily"]}
|
|
2866
|
+
base["periods"] = periods
|
|
2867
|
+
return base
|
|
2868
|
+
|
|
2869
|
+
|
|
2723
2870
|
def _tui_build_source_bundle(
|
|
2724
2871
|
*,
|
|
2725
2872
|
stats_conn,
|
|
@@ -2733,6 +2880,7 @@ def _tui_build_source_bundle(
|
|
|
2733
2880
|
claude_total_tokens: int,
|
|
2734
2881
|
claude_data: dict[str, object] | None = None,
|
|
2735
2882
|
common_range_start: dt.datetime | None = None,
|
|
2883
|
+
projects_envelope: dict | None = None,
|
|
2736
2884
|
prior_bundle: SourceDashboardBundle | None = None,
|
|
2737
2885
|
raw_config: dict[str, object] | None = None,
|
|
2738
2886
|
) -> SourceDashboardBundle:
|
|
@@ -2758,10 +2906,48 @@ def _tui_build_source_bundle(
|
|
|
2758
2906
|
cache_conn.execute("BEGIN")
|
|
2759
2907
|
cache_read_tx = True
|
|
2760
2908
|
if common_range_start is None:
|
|
2761
|
-
|
|
2909
|
+
# Resolved through the SAME helper the callers use, with no daily
|
|
2910
|
+
# panel. A bare `now_utc - 30 days` here is a microsecond-precise
|
|
2911
|
+
# instant that advances on every tick, and the resolved start is
|
|
2912
|
+
# folded into both providers' version material at exactly the
|
|
2913
|
+
# granularity `compose_all_aggregates` compares it — so a start
|
|
2914
|
+
# that moves within a display day makes an unchanged provider's
|
|
2915
|
+
# retained carrier disagree with a rebuilt one's, and both
|
|
2916
|
+
# aggregates are then withheld as `retained_range_mismatch`
|
|
2917
|
+
# permanently. Both production callers pass a resolved start, so
|
|
2918
|
+
# this is the last producer that could reintroduce that shape.
|
|
2919
|
+
#
|
|
2920
|
+
# The zone lookup is guarded because this branch exists to be a
|
|
2921
|
+
# SAFE fallback. An unresolvable `display_tz_name` raising out of
|
|
2922
|
+
# it would take down the whole source build over the one path whose
|
|
2923
|
+
# purpose is to keep going, so an unusable name degrades to UTC —
|
|
2924
|
+
# which is what `resolve_shared_range` already does for `None`.
|
|
2925
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
2926
|
+
_fallback_tz = None
|
|
2927
|
+
if display_tz_name:
|
|
2928
|
+
try:
|
|
2929
|
+
_fallback_tz = ZoneInfo(display_tz_name)
|
|
2930
|
+
except (ZoneInfoNotFoundError, ValueError, OSError):
|
|
2931
|
+
_fallback_tz = None
|
|
2932
|
+
common_range_start, _fallback_end = c.resolve_shared_range(
|
|
2933
|
+
None,
|
|
2934
|
+
now_utc=now_utc,
|
|
2935
|
+
display_tz=_fallback_tz,
|
|
2936
|
+
)
|
|
2762
2937
|
if common_range_start.tzinfo is None or common_range_start.utcoffset() is None:
|
|
2763
2938
|
raise ValueError("common_range_start must be timezone-aware")
|
|
2764
2939
|
common_range_start = common_range_start.astimezone(dt.timezone.utc)
|
|
2940
|
+
# #556 S2 §3.2: ONE interval, resolved once and passed immutably to
|
|
2941
|
+
# both Claude folds and to the Codex read. The exclusive upper bound is
|
|
2942
|
+
# what the Codex projects read already applies, so the Codex tab stays
|
|
2943
|
+
# byte-stable. The PUBLISHED `end_at` is `now_utc` itself.
|
|
2944
|
+
shared_end_exclusive = now_utc.astimezone(
|
|
2945
|
+
dt.timezone.utc,
|
|
2946
|
+
) + dt.timedelta(microseconds=1)
|
|
2947
|
+
published_range = aggregate_range(
|
|
2948
|
+
common_range_start.isoformat(),
|
|
2949
|
+
now_utc.astimezone(dt.timezone.utc).isoformat(),
|
|
2950
|
+
)
|
|
2765
2951
|
semantics = resolve_dashboard_source_semantics(
|
|
2766
2952
|
raw_config if raw_config is not None else c.load_config(),
|
|
2767
2953
|
display_tz_name=display_tz_name,
|
|
@@ -2784,6 +2970,11 @@ def _tui_build_source_bundle(
|
|
|
2784
2970
|
from _cctally_quota import assert_projection_readable
|
|
2785
2971
|
assert_projection_readable(stats_conn)
|
|
2786
2972
|
stats_digest = codex_stats_digest(stats_conn)
|
|
2973
|
+
# #556 S3 §2.9: the Claude alert relations. Nothing else in the
|
|
2974
|
+
# signature moves when a Claude alert fires or is armed, so without
|
|
2975
|
+
# this the idle path can keep serving a prior bundle that predates the
|
|
2976
|
+
# alert.
|
|
2977
|
+
claude_digest = claude_stats_digest(stats_conn)
|
|
2787
2978
|
# #341 finding 9: the account registry/active-identity digest. Empty for
|
|
2788
2979
|
# every <=1-account install (byte-neutral — appended only when non-empty),
|
|
2789
2980
|
# so single-account source versions stay byte-identical to today; a
|
|
@@ -2798,6 +2989,7 @@ def _tui_build_source_bundle(
|
|
|
2798
2989
|
generation=c.current_generation(),
|
|
2799
2990
|
codex_stats_digest=stats_digest,
|
|
2800
2991
|
accounts_digest=accounts_digest,
|
|
2992
|
+
claude_stats_digest=claude_digest,
|
|
2801
2993
|
)
|
|
2802
2994
|
_acct_suffix = f":a{accounts_digest}" if accounts_digest else ""
|
|
2803
2995
|
# public #5: the hook's budgeted ingest can change what the Codex
|
|
@@ -2808,10 +3000,21 @@ def _tui_build_source_bundle(
|
|
|
2808
3000
|
# wire. Empty (and so byte-neutral) once the backlog has drained.
|
|
2809
3001
|
_backlog = getattr(signature, "codex_ingest_backlog_sig", "")
|
|
2810
3002
|
_backlog_suffix = f":b{_backlog}" if _backlog else ""
|
|
3003
|
+
# #556 S2 §3.6: the resolved range and the per-aggregate outcome enter
|
|
3004
|
+
# BOTH providers' version material. Both, so a shared-start change (a
|
|
3005
|
+
# display-day rollover) rebuilds them in lockstep and a coherent pair
|
|
3006
|
+
# can never disagree about the interval their rows cover. The fragment
|
|
3007
|
+
# below assumes SUCCESS, which is what makes it also the reuse gate: a
|
|
3008
|
+
# prior generation whose fold failed carries a different fragment and
|
|
3009
|
+
# therefore cannot be reused.
|
|
3010
|
+
_aggregate_suffix = ":g" + aggregate_scope_identity(
|
|
3011
|
+
build_aggregate_scope(published_range),
|
|
3012
|
+
)
|
|
2811
3013
|
codex_version = (
|
|
2812
3014
|
f"codex:{signature.max_codex_id}:"
|
|
2813
3015
|
f"{signature.codex_physical_mutation_seq}:{stats_digest}:"
|
|
2814
3016
|
f"{semantics.codex_identity}{_acct_suffix}{_backlog_suffix}"
|
|
3017
|
+
f"{_aggregate_suffix}"
|
|
2815
3018
|
)
|
|
2816
3019
|
# #556 S1 §3.6: normalized period identity, so a nominal week rollover
|
|
2817
3020
|
# invalidates the generation even when no database signature moved.
|
|
@@ -2821,7 +3024,8 @@ def _tui_build_source_bundle(
|
|
|
2821
3024
|
f"{signature.max_wus_id}:{signature.max_wcs_id}:"
|
|
2822
3025
|
f"{signature.reset_sig[0]}:{signature.reset_sig[1]}:"
|
|
2823
3026
|
f"{signature.generation}:{semantics.claude_identity}"
|
|
2824
|
-
f":p{_period_identity}{_acct_suffix}"
|
|
3027
|
+
f":p{_period_identity}{_acct_suffix}{_aggregate_suffix}"
|
|
3028
|
+
f":x{claude_digest}"
|
|
2825
3029
|
)
|
|
2826
3030
|
prior_claude = (
|
|
2827
3031
|
prior_bundle.sources.get("claude")
|
|
@@ -2853,6 +3057,14 @@ def _tui_build_source_bundle(
|
|
|
2853
3057
|
claude = reuse_coherent_source_state(
|
|
2854
3058
|
prior_claude, data_version=claude_version,
|
|
2855
3059
|
)
|
|
3060
|
+
# #556 S2 §3.6, gate 2 of 2. The version fragment above already
|
|
3061
|
+
# rejects a failed generation, but this gate is stated explicitly
|
|
3062
|
+
# rather than left implicit in string arithmetic: exact-version
|
|
3063
|
+
# provider reuse returns the PRIOR OBJECT unchanged, so a caught
|
|
3064
|
+
# fold failure that survived reuse would withhold the aggregate for
|
|
3065
|
+
# the life of the process. Gate 1 is the bundle-level idle guard.
|
|
3066
|
+
if claude is not None and aggregate_scope_failed(claude):
|
|
3067
|
+
claude = None
|
|
2856
3068
|
if claude is None:
|
|
2857
3069
|
claude_available = "ok" if (claude_cost_usd or claude_total_tokens) else "empty"
|
|
2858
3070
|
# #341 Task 4 (Ruling C): the conditional per-account Claude wire,
|
|
@@ -2875,6 +3087,47 @@ def _tui_build_source_bundle(
|
|
|
2875
3087
|
# wire must never fail the whole dashboard tick — it just falls
|
|
2876
3088
|
# back to the byte-stable undecorated shape.
|
|
2877
3089
|
claude_accounts = []
|
|
3090
|
+
# #556 S2 §3.3: both All-only Claude legs fold HERE, on the pinned
|
|
3091
|
+
# cache connection, after BEGIN and beside the Codex read, so the
|
|
3092
|
+
# two providers describe one snapshot. Folding them earlier — in
|
|
3093
|
+
# the attached-cache block or through the Group-A daily read — runs
|
|
3094
|
+
# against a different connection, and a cache commit in between
|
|
3095
|
+
# would publish Claude generation A beside Codex generation B while
|
|
3096
|
+
# the bundle's version names B.
|
|
3097
|
+
aggregate_payload, aggregate_outcomes = _tui_build_claude_aggregates(
|
|
3098
|
+
cache_conn,
|
|
3099
|
+
shared_start=common_range_start,
|
|
3100
|
+
shared_end_exclusive=shared_end_exclusive,
|
|
3101
|
+
now_utc=now_utc,
|
|
3102
|
+
display_tz_name=semantics.display_tz_name,
|
|
3103
|
+
# The legacy display keys the drill-down route resolves
|
|
3104
|
+
# against. Published rows adopt them wherever they exist, so
|
|
3105
|
+
# the aggregate identity and the legacy one agree and the
|
|
3106
|
+
# bounded rows stay routable. The raw envelope is required —
|
|
3107
|
+
# `claude_data` has already replaced every legacy display key
|
|
3108
|
+
# with an opaque key and dropped `bucket_path`, so the map
|
|
3109
|
+
# cannot be recovered from it.
|
|
3110
|
+
# `None` — not an empty map — when no envelope was built, so
|
|
3111
|
+
# the fold can tell "the legacy population is empty" from "the
|
|
3112
|
+
# legacy population is unknown" and withhold on the second.
|
|
3113
|
+
legacy_labels=(
|
|
3114
|
+
c.legacy_project_labels(projects_envelope)
|
|
3115
|
+
if projects_envelope is not None else None
|
|
3116
|
+
),
|
|
3117
|
+
)
|
|
3118
|
+
claude_aggregate_scope = build_aggregate_scope(
|
|
3119
|
+
published_range, aggregate_outcomes,
|
|
3120
|
+
)
|
|
3121
|
+
if aggregate_scope_failed(claude_aggregate_scope):
|
|
3122
|
+
# The published version must distinguish a failed fold from a
|
|
3123
|
+
# successful one over the same signature and the same bounds;
|
|
3124
|
+
# otherwise both would publish different rows under one
|
|
3125
|
+
# `data_version`. It also makes the next tick's success-shaped
|
|
3126
|
+
# candidate version mismatch, forcing the rebuild §3.6 requires.
|
|
3127
|
+
claude_version = (
|
|
3128
|
+
f"{claude_version}:x"
|
|
3129
|
+
f"{aggregate_scope_identity(claude_aggregate_scope)}"
|
|
3130
|
+
)
|
|
2878
3131
|
claude = SourceDashboardState(
|
|
2879
3132
|
source="claude",
|
|
2880
3133
|
availability=claude_available,
|
|
@@ -2895,9 +3148,10 @@ def _tui_build_source_bundle(
|
|
|
2895
3148
|
"alerts": CapabilityRecord("supported", "provider-native"),
|
|
2896
3149
|
},
|
|
2897
3150
|
data={
|
|
2898
|
-
**(
|
|
2899
|
-
claude_data
|
|
2900
|
-
|
|
3151
|
+
**_tui_claude_data_with_aggregates(
|
|
3152
|
+
claude_data,
|
|
3153
|
+
aggregate_payload,
|
|
3154
|
+
fallback={
|
|
2901
3155
|
"hero": {
|
|
2902
3156
|
"cost_usd": claude_cost_usd,
|
|
2903
3157
|
"total_tokens": claude_total_tokens,
|
|
@@ -2908,13 +3162,14 @@ def _tui_build_source_bundle(
|
|
|
2908
3162
|
"quota": {"blocks": (), "milestones": ()},
|
|
2909
3163
|
"budget": {"label": "Claude subscription budget"},
|
|
2910
3164
|
"alerts": {"rows": ()},
|
|
2911
|
-
}
|
|
3165
|
+
},
|
|
2912
3166
|
),
|
|
2913
3167
|
**({"accounts": claude_accounts} if claude_accounts else {}),
|
|
2914
3168
|
},
|
|
2915
3169
|
domain_freshness=_tui_claude_domain_freshness(
|
|
2916
3170
|
claude_data, now_utc=now_utc,
|
|
2917
3171
|
),
|
|
3172
|
+
aggregate_scope=claude_aggregate_scope,
|
|
2918
3173
|
)
|
|
2919
3174
|
if codex_ingest_failed:
|
|
2920
3175
|
warning = SourceDashboardWarning(
|
|
@@ -2958,6 +3213,13 @@ def _tui_build_source_bundle(
|
|
|
2958
3213
|
prior_codex, data_version=codex_version,
|
|
2959
3214
|
)
|
|
2960
3215
|
)
|
|
3216
|
+
# #556 S2 §3.6: symmetric with Claude. Codex's rows are already
|
|
3217
|
+
# bounded by this same range, so its carrier records no fold of its
|
|
3218
|
+
# own — but a retained failure state must never be reused, and the
|
|
3219
|
+
# gate is stated on both providers so a future Codex-side fold
|
|
3220
|
+
# inherits it.
|
|
3221
|
+
if codex is not None and aggregate_scope_failed(codex):
|
|
3222
|
+
codex = None
|
|
2961
3223
|
if codex is None:
|
|
2962
3224
|
try:
|
|
2963
3225
|
codex = build_codex_source_state(
|
|
@@ -2977,6 +3239,13 @@ def _tui_build_source_bundle(
|
|
|
2977
3239
|
),
|
|
2978
3240
|
data_version=codex_version,
|
|
2979
3241
|
)
|
|
3242
|
+
# Attached ONLY on a fresh build, never on the reuse or degrade
|
|
3243
|
+
# paths: those carry rows this tick did not produce, and their
|
|
3244
|
+
# own carrier already describes the range that bounds them.
|
|
3245
|
+
codex = dataclasses.replace(
|
|
3246
|
+
codex,
|
|
3247
|
+
aggregate_scope=build_aggregate_scope(published_range),
|
|
3248
|
+
)
|
|
2980
3249
|
except Exception:
|
|
2981
3250
|
_lib_log.get_logger("dashboard").error(
|
|
2982
3251
|
"codex_read_model source build failed",
|
|
@@ -3026,12 +3295,14 @@ def _tui_build_source_bundle(
|
|
|
3026
3295
|
cache_read_tx = False
|
|
3027
3296
|
post_stats_digest = codex_stats_digest(stats_conn)
|
|
3028
3297
|
post_accounts_digest = accounts_identity_digest(stats_conn)
|
|
3298
|
+
post_claude_digest = claude_stats_digest(stats_conn)
|
|
3029
3299
|
post_signature = c.compute_signature(
|
|
3030
3300
|
cache_conn,
|
|
3031
3301
|
stats_conn,
|
|
3032
3302
|
generation=c.current_generation(),
|
|
3033
3303
|
codex_stats_digest=post_stats_digest,
|
|
3034
3304
|
accounts_digest=post_accounts_digest,
|
|
3305
|
+
claude_stats_digest=post_claude_digest,
|
|
3035
3306
|
)
|
|
3036
3307
|
stats_generation_moved = (
|
|
3037
3308
|
post_signature.max_wus_id != signature.max_wus_id
|
|
@@ -3039,6 +3310,7 @@ def _tui_build_source_bundle(
|
|
|
3039
3310
|
or post_signature.reset_sig != signature.reset_sig
|
|
3040
3311
|
or post_stats_digest != stats_digest
|
|
3041
3312
|
or post_accounts_digest != accounts_digest
|
|
3313
|
+
or post_claude_digest != claude_digest
|
|
3042
3314
|
)
|
|
3043
3315
|
if stats_generation_moved:
|
|
3044
3316
|
if prior_bundle is not None:
|
|
@@ -3111,6 +3383,14 @@ def _tui_source_bundle_can_idle(bundle: SourceDashboardBundle | None) -> bool:
|
|
|
3111
3383
|
or state.freshness != "fresh"
|
|
3112
3384
|
or state.data is None):
|
|
3113
3385
|
return False
|
|
3386
|
+
# #556 S2 §3.6, gate 1 of 2. A locally caught fold failure leaves an
|
|
3387
|
+
# otherwise `ok` and `fresh` provider, so without this leg the bundle
|
|
3388
|
+
# would qualify for idle reuse and one transient failure would withhold
|
|
3389
|
+
# the aggregate for the life of the process. Falling through here routes
|
|
3390
|
+
# to the bounded source-adapter rebuild, which re-folds — at most one
|
|
3391
|
+
# rebuild per tick, so it creates no retry loop.
|
|
3392
|
+
if aggregate_scope_failed(state):
|
|
3393
|
+
return False
|
|
3114
3394
|
return True
|
|
3115
3395
|
|
|
3116
3396
|
|
|
@@ -3120,18 +3400,18 @@ def _tui_common_source_range_start(
|
|
|
3120
3400
|
now_utc: dt.datetime,
|
|
3121
3401
|
display_tz: dt.tzinfo | None,
|
|
3122
3402
|
) -> 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
|
|
3403
|
+
"""Return the shared provider interval from the already-built daily rows.
|
|
3404
|
+
|
|
3405
|
+
#556 S2 §3.2: the start bound is now resolved by
|
|
3406
|
+
``_cctally_dashboard.resolve_shared_range``, which also owns the exclusive
|
|
3407
|
+
upper bound the Claude folds enforce. This wrapper stays because every
|
|
3408
|
+
existing caller wants only the start, and because it is the monkeypatch
|
|
3409
|
+
surface the source-invalidation tests already use.
|
|
3410
|
+
"""
|
|
3411
|
+
start, _end_exclusive = _cctally().resolve_shared_range(
|
|
3412
|
+
daily_panel, now_utc=now_utc, display_tz=display_tz,
|
|
3413
|
+
)
|
|
3414
|
+
return start
|
|
3135
3415
|
|
|
3136
3416
|
|
|
3137
3417
|
def _tui_build_snapshot(
|
|
@@ -4079,6 +4359,7 @@ def _tui_build_snapshot_once(
|
|
|
4079
4359
|
claude_total_tokens=daily_total_tokens,
|
|
4080
4360
|
claude_data=_tui_project_claude_source_data(legacy_envelope),
|
|
4081
4361
|
common_range_start=common_range_start,
|
|
4362
|
+
projects_envelope=projects_envelope_block,
|
|
4082
4363
|
prior_bundle=prior_source_bundle,
|
|
4083
4364
|
raw_config=raw_config,
|
|
4084
4365
|
)
|
|
@@ -4232,6 +4513,7 @@ def _tui_compute_dispatch_signature(stats_conn):
|
|
|
4232
4513
|
generation=sc.current_generation(),
|
|
4233
4514
|
codex_stats_digest=codex_stats_digest(stats_conn),
|
|
4234
4515
|
accounts_digest=accounts_identity_digest(stats_conn),
|
|
4516
|
+
claude_stats_digest=claude_stats_digest(stats_conn),
|
|
4235
4517
|
)
|
|
4236
4518
|
finally:
|
|
4237
4519
|
cache_conn.close()
|
|
@@ -4409,6 +4691,7 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
|
|
|
4409
4691
|
now_utc=now_utc,
|
|
4410
4692
|
display_tz=source_display_tz,
|
|
4411
4693
|
),
|
|
4694
|
+
projects_envelope=prior.projects_envelope,
|
|
4412
4695
|
prior_bundle=source_bundle,
|
|
4413
4696
|
raw_config=raw_config,
|
|
4414
4697
|
)
|
package/bin/_lib_alert_axes.py
CHANGED
|
@@ -55,13 +55,18 @@ AXIS_REGISTRY: "tuple[AlertAxisDescriptor, ...]" = (
|
|
|
55
55
|
"project_budget", "PROJECT", "Project budget", "project_budget_milestones"
|
|
56
56
|
),
|
|
57
57
|
# Per-vendor Codex budget alerts (calendar-period; calendar-period-codex-budgets
|
|
58
|
-
# feature).
|
|
59
|
-
#
|
|
58
|
+
# feature). #556 S3 §4.3: the chip reads "BUDGET", the same as the Claude
|
|
59
|
+
# budget axis, because it names the METRIC. Attribution is the row's source
|
|
60
|
+
# chip, which under All reads "Codex" — a chip reading "CODEX" beside a
|
|
61
|
+
# source chip reading "Codex" said the same thing twice and named neither
|
|
62
|
+
# the metric nor anything the source chip did not already say. The two
|
|
63
|
+
# BUDGET chips stay visually distinct through `chip--codex_budget`, which
|
|
64
|
+
# keeps its own colour. As of #143 it shares the unified vendor-tagged
|
|
60
65
|
# `budget_milestones` table with the Claude `budget` axis; the envelope
|
|
61
66
|
# mapper's `WHERE vendor=?` filter does the row-level split (keyed on the
|
|
62
67
|
# resolved period-window start instant period_start_at, threshold).
|
|
63
68
|
AlertAxisDescriptor(
|
|
64
|
-
"codex_budget", "
|
|
69
|
+
"codex_budget", "BUDGET", "Codex budget", "budget_milestones", vendor="codex"
|
|
65
70
|
),
|
|
66
71
|
)
|
|
67
72
|
|