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.
@@ -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
- return out if not backlog else f"{out}.b{backlog}"
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
- owns_alert = (
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
- common_range_start = now_utc - dt.timedelta(days=30)
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
- "budget": CapabilityRecord("supported", "subscription-week"),
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
- claude_data
2900
- if claude_data is not None else {
2901
- "hero": {
2902
- "cost_usd": claude_cost_usd,
2903
- "total_tokens": claude_total_tokens,
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
- "periods": {"daily": {"total_cost_usd": claude_cost_usd, "total_tokens": claude_total_tokens}},
2906
- "sessions": {"rows": ()},
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
- any(
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. Claude is
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
- if daily_panel:
3125
- earliest_day = dt.date.fromisoformat(daily_panel[-1].date)
3126
- if display_tz is not None:
3127
- return dt.datetime.combine(
3128
- earliest_day, dt.time.min, tzinfo=display_tz,
3129
- ).astimezone(dt.timezone.utc)
3130
- # internal fallback: host-local intentional
3131
- return dt.datetime.combine(
3132
- earliest_day, dt.time.min,
3133
- ).astimezone(dt.timezone.utc)
3134
- return now_utc - dt.timedelta(days=30)
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
- legacy_envelope = _cctally().snapshot_to_envelope(
4062
- source_snapshot,
4063
- now_utc=now_utc,
4064
- display_tz_pref_override=display_tz_pref_override,
4065
- runtime_bind=runtime_bind,
4066
- )
4067
- source_bundle = _tui_build_source_bundle(
4068
- stats_conn=conn,
4069
- now_utc=now_utc,
4070
- display_tz_name=(
4071
- getattr(_build_display_tz, "key", None)
4072
- if _build_display_tz is not None else None
4073
- ),
4074
- codex_ingest_contended=codex_ingest_contended,
4075
- codex_ingest_failed=codex_ingest_failed,
4076
- claude_ingest_contended=claude_ingest_contended,
4077
- claude_ingest_failed=claude_ingest_failed,
4078
- claude_cost_usd=daily_total_cost_usd,
4079
- claude_total_tokens=daily_total_tokens,
4080
- claude_data=_tui_project_claude_source_data(legacy_envelope),
4081
- common_range_start=common_range_start,
4082
- prior_bundle=prior_source_bundle,
4083
- raw_config=raw_config,
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
  )