cctally 1.96.2 → 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 +41 -0
- package/bin/_cctally_dashboard.py +595 -46
- package/bin/_cctally_dashboard_envelope.py +57 -23
- package/bin/_cctally_dashboard_share.py +25 -5
- package/bin/_cctally_dashboard_sources.py +123 -36
- package/bin/_cctally_db.py +10 -0
- package/bin/_cctally_doctor.py +15 -10
- package/bin/_cctally_journal_repair.py +7 -6
- package/bin/_cctally_parser.py +47 -22
- package/bin/_cctally_tui.py +515 -63
- package/bin/_lib_alert_axes.py +8 -3
- package/bin/_lib_dashboard_sources.py +837 -102
- package/bin/_lib_journal_router.py +14 -0
- package/bin/_lib_share.py +191 -36
- package/bin/_lib_snapshot_cache.py +12 -0
- package/bin/cctally +48 -1
- 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-BRFMIN18.js +0 -97
- package/dashboard/static/assets/index-Crj7bzyj.css +0 -1
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,
|
|
@@ -334,6 +339,10 @@ def _sum_cost_for_range(*args, **kwargs):
|
|
|
334
339
|
return sys.modules["cctally"]._sum_cost_for_range(*args, **kwargs)
|
|
335
340
|
|
|
336
341
|
|
|
342
|
+
def _sum_cost_and_tokens_for_range(*args, **kwargs):
|
|
343
|
+
return sys.modules["cctally"]._sum_cost_and_tokens_for_range(*args, **kwargs)
|
|
344
|
+
|
|
345
|
+
|
|
337
346
|
def _compute_cost_for_weekref(*args, **kwargs):
|
|
338
347
|
return sys.modules["cctally"]._compute_cost_for_weekref(*args, **kwargs)
|
|
339
348
|
|
|
@@ -869,6 +878,11 @@ class TuiCurrentWeek:
|
|
|
869
878
|
# default `None` keeps fixture modules that construct TuiCurrentWeek
|
|
870
879
|
# directly (without this field) backwards-compatible.
|
|
871
880
|
five_hour_block: dict | None = None
|
|
881
|
+
# #556 S1 §3.3 — the current cycle's #104 token total, accumulated in the
|
|
882
|
+
# SAME pass that produces `spent_usd` so the two halves describe one entry
|
|
883
|
+
# set. Appended last with a default so fixture modules that construct
|
|
884
|
+
# `TuiCurrentWeek` positionally stay valid.
|
|
885
|
+
total_tokens: int = 0
|
|
872
886
|
|
|
873
887
|
|
|
874
888
|
# ---- View-model row dataclasses moved to bin/_lib_view_models.py ----
|
|
@@ -1607,7 +1621,11 @@ def _tui_build_current_week(
|
|
|
1607
1621
|
latest = samples[-1]
|
|
1608
1622
|
used_pct = float(latest[1])
|
|
1609
1623
|
five_hr_pct = float(latest[2]) if latest[2] is not None else None
|
|
1610
|
-
|
|
1624
|
+
# #556 S1 §3.3: one walk yields both halves. The range is whatever
|
|
1625
|
+
# `spent_usd` already used — taken AFTER `_apply_midweek_reset_override`
|
|
1626
|
+
# above, so a mid-week reset shortens the accumulation and the published
|
|
1627
|
+
# period together.
|
|
1628
|
+
spent, total_tokens = _sum_cost_and_tokens_for_range(
|
|
1611
1629
|
week_start_at, now_utc, mode="auto", skip_sync=skip_sync
|
|
1612
1630
|
)
|
|
1613
1631
|
dpp = (spent / used_pct) if used_pct > 0 else None
|
|
@@ -1678,6 +1696,7 @@ def _tui_build_current_week(
|
|
|
1678
1696
|
five_hour_block=_select_current_block_for_envelope(
|
|
1679
1697
|
conn, current_used_pct=used_pct, now_utc=now_utc,
|
|
1680
1698
|
),
|
|
1699
|
+
total_tokens=total_tokens,
|
|
1681
1700
|
)
|
|
1682
1701
|
|
|
1683
1702
|
|
|
@@ -2297,7 +2316,12 @@ def _snapshot_data_version(sig) -> str:
|
|
|
2297
2316
|
# in is what leaves the idle short-circuit so the source bundle is rebuilt
|
|
2298
2317
|
# at all. Empty once the backlog has drained, so it is byte-neutral there.
|
|
2299
2318
|
backlog = getattr(sig, "codex_ingest_backlog_sig", "")
|
|
2300
|
-
|
|
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}"
|
|
2301
2325
|
|
|
2302
2326
|
|
|
2303
2327
|
def _tui_source_copy(value: object) -> object:
|
|
@@ -2328,6 +2352,38 @@ def _tui_claude_resource_row(
|
|
|
2328
2352
|
return wire
|
|
2329
2353
|
|
|
2330
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
|
+
|
|
2331
2387
|
def _tui_project_claude_source_data(legacy_envelope: object) -> dict[str, object]:
|
|
2332
2388
|
"""Project one completed Claude legacy envelope without further DB reads.
|
|
2333
2389
|
|
|
@@ -2431,17 +2487,7 @@ def _tui_project_claude_source_data(legacy_envelope: object) -> dict[str, object
|
|
|
2431
2487
|
axis = raw.get("axis")
|
|
2432
2488
|
vendor = raw.get("vendor")
|
|
2433
2489
|
metric = raw.get("metric")
|
|
2434
|
-
|
|
2435
|
-
(axis in {"weekly", "five_hour"} and vendor in {None, "claude"})
|
|
2436
|
-
# Legacy top-level Claude budget rows predate the additive vendor
|
|
2437
|
-
# field; the distinct Codex axis is ``codex_budget``. Treat an
|
|
2438
|
-
# absent vendor as that established Claude meaning, while an
|
|
2439
|
-
# explicit non-Claude vendor must never be relabeled.
|
|
2440
|
-
or (axis == "budget" and vendor in {None, "claude"})
|
|
2441
|
-
or axis == "project_budget"
|
|
2442
|
-
or (axis == "projected" and metric in {"weekly_pct", "budget_usd"})
|
|
2443
|
-
)
|
|
2444
|
-
if not owns_alert:
|
|
2490
|
+
if alert_row_owner(axis, vendor, metric) != "claude":
|
|
2445
2491
|
continue
|
|
2446
2492
|
alert_rows.append(_tui_claude_resource_row(
|
|
2447
2493
|
raw,
|
|
@@ -2449,8 +2495,6 @@ def _tui_project_claude_source_data(legacy_envelope: object) -> dict[str, object
|
|
|
2449
2495
|
identity=(ordinal, raw.get("axis"), raw.get("threshold"), raw.get("alerted_at")),
|
|
2450
2496
|
))
|
|
2451
2497
|
|
|
2452
|
-
daily_total = daily.get("total_cost_usd", 0.0)
|
|
2453
|
-
daily_tokens = daily.get("total_tokens", 0)
|
|
2454
2498
|
budget_settings = _tui_source_copy(legacy.get("alerts_settings"))
|
|
2455
2499
|
if isinstance(budget_settings, dict):
|
|
2456
2500
|
# The legacy top-level settings mirror contains Codex capability flags
|
|
@@ -2465,8 +2509,15 @@ def _tui_project_claude_source_data(legacy_envelope: object) -> dict[str, object
|
|
|
2465
2509
|
budget_settings.pop(key, None)
|
|
2466
2510
|
return {
|
|
2467
2511
|
"hero": {
|
|
2468
|
-
|
|
2469
|
-
|
|
2512
|
+
# #556 S1 §3.4: current-cycle accounting, NOT the thirty-day
|
|
2513
|
+
# rollup. Both providers' `hero.cost_usd` / `hero.total_tokens`
|
|
2514
|
+
# now mean the same thing; the thirty-day figures keep their own
|
|
2515
|
+
# home in `periods.daily`. `None` when no current week resolves —
|
|
2516
|
+
# composition distinguishes an empty provider from an unresolved
|
|
2517
|
+
# cycle by the provider's availability, and a zero here would make
|
|
2518
|
+
# both read as observed spend.
|
|
2519
|
+
"cost_usd": current_week.get("spent_usd"),
|
|
2520
|
+
"total_tokens": current_week.get("total_tokens"),
|
|
2470
2521
|
"header": _tui_source_copy(legacy.get("header")),
|
|
2471
2522
|
"current_week": _tui_source_copy(current_week),
|
|
2472
2523
|
"forecast": _tui_source_copy(legacy.get("forecast")),
|
|
@@ -2499,15 +2550,41 @@ def _tui_project_claude_source_data(legacy_envelope: object) -> dict[str, object
|
|
|
2499
2550
|
}
|
|
2500
2551
|
|
|
2501
2552
|
|
|
2553
|
+
def _tui_claude_cycle_is_resolved(
|
|
2554
|
+
current_week: object, now_utc: dt.datetime,
|
|
2555
|
+
) -> bool:
|
|
2556
|
+
"""Whether Claude's current week resolves AND has not expired (#556 §4.1).
|
|
2557
|
+
|
|
2558
|
+
A stale percent observation does not enter this: the boundary can be
|
|
2559
|
+
perfectly resolved while the number that reports progress against it is an
|
|
2560
|
+
hour old.
|
|
2561
|
+
"""
|
|
2562
|
+
if not isinstance(current_week, dict):
|
|
2563
|
+
return False
|
|
2564
|
+
end = _tui_normalized_period_instant(current_week.get("reset_at_utc"))
|
|
2565
|
+
if end is None:
|
|
2566
|
+
return False
|
|
2567
|
+
return now_utc.astimezone(dt.timezone.utc) < dt.datetime.fromisoformat(end)
|
|
2568
|
+
|
|
2569
|
+
|
|
2502
2570
|
def _tui_claude_domain_freshness(
|
|
2503
2571
|
source_data: dict[str, object] | None,
|
|
2572
|
+
*,
|
|
2573
|
+
now_utc: dt.datetime,
|
|
2504
2574
|
) -> dict[str, str]:
|
|
2505
|
-
"""Derive Claude axes from its selected weekly snapshot evidence.
|
|
2575
|
+
"""Derive Claude's axes from its selected weekly snapshot evidence.
|
|
2576
|
+
|
|
2577
|
+
#556 S1 §4.1 repoints the two axes it publishes. ``quota`` carries the
|
|
2578
|
+
percent-OBSERVATION age, which is what it always described. ``hero`` now
|
|
2579
|
+
carries current-cycle ACCOUNTING resolvability: fresh while the boundary
|
|
2580
|
+
resolves and has not expired, so the backward-looking counters inside it
|
|
2581
|
+
are publishable. Pointing ``hero`` at the percent age is what kept All's
|
|
2582
|
+
combined caveat permanently on, because that clock's 90-second bound is
|
|
2583
|
+
forty times tighter than the Codex weekly one it was joined with.
|
|
2506
2584
|
|
|
2507
2585
|
The legacy current-week label has a third presentation-only ``aging``
|
|
2508
2586
|
state. The source contract deliberately keeps the frozen fresh/stale
|
|
2509
|
-
vocabulary: only the exact stale label moves the
|
|
2510
|
-
Missing evidence remains a capability/availability concern.
|
|
2587
|
+
vocabulary: only the exact stale label moves the quota axis.
|
|
2511
2588
|
"""
|
|
2512
2589
|
data = source_data if isinstance(source_data, dict) else {}
|
|
2513
2590
|
hero = data.get("hero")
|
|
@@ -2516,12 +2593,15 @@ def _tui_claude_domain_freshness(
|
|
|
2516
2593
|
current_week.get("freshness")
|
|
2517
2594
|
if isinstance(current_week, dict) else None
|
|
2518
2595
|
)
|
|
2519
|
-
|
|
2596
|
+
quota = (
|
|
2520
2597
|
"stale"
|
|
2521
2598
|
if isinstance(freshness, dict) and freshness.get("label") == "stale"
|
|
2522
2599
|
else "fresh"
|
|
2523
2600
|
)
|
|
2524
|
-
|
|
2601
|
+
accounting = (
|
|
2602
|
+
"fresh" if _tui_claude_cycle_is_resolved(current_week, now_utc) else "stale"
|
|
2603
|
+
)
|
|
2604
|
+
return {"hero": accounting, "quota": quota, "sessions": "fresh"}
|
|
2525
2605
|
|
|
2526
2606
|
|
|
2527
2607
|
def _refresh_claude_source_clock(
|
|
@@ -2536,29 +2616,41 @@ def _refresh_claude_source_clock(
|
|
|
2536
2616
|
return state
|
|
2537
2617
|
if now_utc.tzinfo is None or now_utc.utcoffset() is None:
|
|
2538
2618
|
raise ValueError("now_utc must be timezone-aware")
|
|
2539
|
-
captured = getattr(current_week, "latest_snapshot_at", None)
|
|
2540
|
-
if not isinstance(captured, dt.datetime):
|
|
2541
|
-
return state
|
|
2542
|
-
if captured.tzinfo is None or captured.utcoffset() is None:
|
|
2543
|
-
captured = captured.replace(tzinfo=dt.timezone.utc)
|
|
2544
|
-
age_seconds = max(
|
|
2545
|
-
0.0,
|
|
2546
|
-
(
|
|
2547
|
-
now_utc.astimezone(dt.timezone.utc)
|
|
2548
|
-
- captured.astimezone(dt.timezone.utc)
|
|
2549
|
-
).total_seconds(),
|
|
2550
|
-
)
|
|
2551
|
-
try:
|
|
2552
|
-
freshness_config = _get_oauth_usage_config(raw_config)
|
|
2553
|
-
except Exception:
|
|
2554
|
-
freshness_config = _OAUTH_USAGE_DEFAULTS
|
|
2555
|
-
weekly = (
|
|
2556
|
-
"stale"
|
|
2557
|
-
if _freshness_label(age_seconds, freshness_config) == "stale"
|
|
2558
|
-
else "fresh"
|
|
2559
|
-
)
|
|
2560
2619
|
domain_freshness = dict(state.domain_freshness or {})
|
|
2561
|
-
|
|
2620
|
+
# #556 S1 §4.1: the two axes advance from DIFFERENT evidence. Both legs run
|
|
2621
|
+
# on every tick — this used to write one percent-derived label to both, and
|
|
2622
|
+
# it also returned early on a missing capture, which left the accounting
|
|
2623
|
+
# axis frozen at its build-time value for the whole idle stretch.
|
|
2624
|
+
week_end = getattr(current_week, "week_end_at", None)
|
|
2625
|
+
domain_freshness["hero"] = (
|
|
2626
|
+
"fresh"
|
|
2627
|
+
if isinstance(week_end, dt.datetime)
|
|
2628
|
+
and now_utc.astimezone(dt.timezone.utc) < (
|
|
2629
|
+
week_end if week_end.tzinfo is not None
|
|
2630
|
+
else week_end.replace(tzinfo=dt.timezone.utc)
|
|
2631
|
+
).astimezone(dt.timezone.utc)
|
|
2632
|
+
else "stale"
|
|
2633
|
+
)
|
|
2634
|
+
captured = getattr(current_week, "latest_snapshot_at", None)
|
|
2635
|
+
if isinstance(captured, dt.datetime):
|
|
2636
|
+
if captured.tzinfo is None or captured.utcoffset() is None:
|
|
2637
|
+
captured = captured.replace(tzinfo=dt.timezone.utc)
|
|
2638
|
+
age_seconds = max(
|
|
2639
|
+
0.0,
|
|
2640
|
+
(
|
|
2641
|
+
now_utc.astimezone(dt.timezone.utc)
|
|
2642
|
+
- captured.astimezone(dt.timezone.utc)
|
|
2643
|
+
).total_seconds(),
|
|
2644
|
+
)
|
|
2645
|
+
try:
|
|
2646
|
+
freshness_config = _get_oauth_usage_config(raw_config)
|
|
2647
|
+
except Exception:
|
|
2648
|
+
freshness_config = _OAUTH_USAGE_DEFAULTS
|
|
2649
|
+
domain_freshness["quota"] = (
|
|
2650
|
+
"stale"
|
|
2651
|
+
if _freshness_label(age_seconds, freshness_config) == "stale"
|
|
2652
|
+
else "fresh"
|
|
2653
|
+
)
|
|
2562
2654
|
refreshed = dataclasses.replace(
|
|
2563
2655
|
state,
|
|
2564
2656
|
domain_freshness=domain_freshness,
|
|
@@ -2566,6 +2658,215 @@ def _refresh_claude_source_clock(
|
|
|
2566
2658
|
return state if refreshed == state else refreshed
|
|
2567
2659
|
|
|
2568
2660
|
|
|
2661
|
+
def _tui_normalized_period_instant(value: object) -> str | None:
|
|
2662
|
+
"""One canonical UTC spelling for a published cycle bound (#556 S1 §3.6).
|
|
2663
|
+
|
|
2664
|
+
Legacy rows carry local-offset spellings and current rows carry `Z`, so the
|
|
2665
|
+
raw text is not an identity: hashing it would rebuild the Claude source on
|
|
2666
|
+
a spelling change that moved no boundary. Returns ``None`` for anything
|
|
2667
|
+
that does not parse as an instant.
|
|
2668
|
+
"""
|
|
2669
|
+
if not isinstance(value, str) or not value:
|
|
2670
|
+
return None
|
|
2671
|
+
try:
|
|
2672
|
+
return parse_iso_datetime(value, "period bound").astimezone(
|
|
2673
|
+
dt.timezone.utc).isoformat()
|
|
2674
|
+
except ValueError:
|
|
2675
|
+
return None
|
|
2676
|
+
|
|
2677
|
+
|
|
2678
|
+
def _tui_claude_period_identity(claude_data: object) -> str:
|
|
2679
|
+
"""The effective current-cycle bounds, as a version fragment (§3.6).
|
|
2680
|
+
|
|
2681
|
+
`claude_version` carried the database signatures, the semantics identity
|
|
2682
|
+
and the accounts digest but no period, and `reuse_coherent_source_state`
|
|
2683
|
+
returns the exact prior object on an unchanged version — so a week that
|
|
2684
|
+
rolled over with no database movement could be republished as current.
|
|
2685
|
+
The snapshot-level `_snapshot_period_rolled_over` gate already covers the
|
|
2686
|
+
undecorated case; this is the second layer, so provider reuse cannot
|
|
2687
|
+
outlive the cycle even if that gate is later changed.
|
|
2688
|
+
|
|
2689
|
+
The bounds are read from the SAME resolved object the aggregation used
|
|
2690
|
+
(`current_week`, stored after `_apply_midweek_reset_override`), so a stale
|
|
2691
|
+
identity can never accompany a shortened range.
|
|
2692
|
+
|
|
2693
|
+
The literal `"none"` degradation is deliberate and is NOT an oversight. A
|
|
2694
|
+
caller that holds no `claude_data` — the capture and QA paths — contributes
|
|
2695
|
+
a constant fragment, so layer two adds no rollover protection there. Those
|
|
2696
|
+
paths are still covered by the snapshot-level `_snapshot_period_rolled_over`
|
|
2697
|
+
gate, which is layer one and is what production reuse actually runs behind.
|
|
2698
|
+
"""
|
|
2699
|
+
data = claude_data if isinstance(claude_data, dict) else {}
|
|
2700
|
+
hero = data.get("hero")
|
|
2701
|
+
current_week = hero.get("current_week") if isinstance(hero, dict) else None
|
|
2702
|
+
if not isinstance(current_week, dict):
|
|
2703
|
+
return "none"
|
|
2704
|
+
start = _tui_normalized_period_instant(current_week.get("week_start_at"))
|
|
2705
|
+
end = _tui_normalized_period_instant(current_week.get("reset_at_utc"))
|
|
2706
|
+
if start is None and end is None:
|
|
2707
|
+
return "none"
|
|
2708
|
+
return f"{start or ''}~{end or ''}"
|
|
2709
|
+
|
|
2710
|
+
|
|
2711
|
+
def _tui_resolve_account_scope(stats_conn, provider: str) -> dict[str, object] | None:
|
|
2712
|
+
"""Read one provider's authoritative REAL account count (#556 S1 §3.8).
|
|
2713
|
+
|
|
2714
|
+
Returns ``None`` when the count cannot be read. That is the FAIL-CLOSED
|
|
2715
|
+
path: composition withholds the combined figure rather than assuming a
|
|
2716
|
+
single account. It is deliberately the opposite of the builders' existing
|
|
2717
|
+
swallow-and-degrade behaviour for the accounts WIRE, where the fallback
|
|
2718
|
+
loses decoration styling; here it would publish a wrong number.
|
|
2719
|
+
|
|
2720
|
+
The catch is narrow on purpose. ``real_account_count`` is one SELECT over
|
|
2721
|
+
``accounts`` — no file I/O, no label lookups — so ``sqlite3.Error`` covers
|
|
2722
|
+
every failure the CALL can produce, and anything else is a real defect that
|
|
2723
|
+
must not be swallowed into a silent withholding. ``ImportError`` is caught
|
|
2724
|
+
alongside it because the deferred import is a different failure class from
|
|
2725
|
+
the query: an unimportable module is exactly the "count cannot be read"
|
|
2726
|
+
state this function exists to report, and letting it propagate would fail
|
|
2727
|
+
the whole dashboard tick instead of withholding one figure.
|
|
2728
|
+
"""
|
|
2729
|
+
try:
|
|
2730
|
+
import _cctally_account
|
|
2731
|
+
return {
|
|
2732
|
+
"real_account_count": int(
|
|
2733
|
+
_cctally_account.real_account_count(stats_conn, provider)
|
|
2734
|
+
),
|
|
2735
|
+
}
|
|
2736
|
+
except (ImportError, sqlite3.Error):
|
|
2737
|
+
return None
|
|
2738
|
+
|
|
2739
|
+
|
|
2740
|
+
def _tui_with_account_scope(
|
|
2741
|
+
state: SourceDashboardState, scope: dict[str, object] | None,
|
|
2742
|
+
) -> SourceDashboardState:
|
|
2743
|
+
"""Attach ``scope`` without breaking reuse-by-identity.
|
|
2744
|
+
|
|
2745
|
+
``reuse_coherent_source_state`` returns the EXACT prior object, and callers
|
|
2746
|
+
assert on that identity, so an unconditional ``dataclasses.replace`` would
|
|
2747
|
+
defeat reuse on every tick. Frozen mappings compare equal to plain dicts,
|
|
2748
|
+
so an unchanged count returns the prior object untouched.
|
|
2749
|
+
"""
|
|
2750
|
+
if state.account_scope == scope:
|
|
2751
|
+
return state
|
|
2752
|
+
return dataclasses.replace(state, account_scope=scope)
|
|
2753
|
+
|
|
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
|
+
|
|
2569
2870
|
def _tui_build_source_bundle(
|
|
2570
2871
|
*,
|
|
2571
2872
|
stats_conn,
|
|
@@ -2579,6 +2880,7 @@ def _tui_build_source_bundle(
|
|
|
2579
2880
|
claude_total_tokens: int,
|
|
2580
2881
|
claude_data: dict[str, object] | None = None,
|
|
2581
2882
|
common_range_start: dt.datetime | None = None,
|
|
2883
|
+
projects_envelope: dict | None = None,
|
|
2582
2884
|
prior_bundle: SourceDashboardBundle | None = None,
|
|
2583
2885
|
raw_config: dict[str, object] | None = None,
|
|
2584
2886
|
) -> SourceDashboardBundle:
|
|
@@ -2604,10 +2906,48 @@ def _tui_build_source_bundle(
|
|
|
2604
2906
|
cache_conn.execute("BEGIN")
|
|
2605
2907
|
cache_read_tx = True
|
|
2606
2908
|
if common_range_start is None:
|
|
2607
|
-
|
|
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
|
+
)
|
|
2608
2937
|
if common_range_start.tzinfo is None or common_range_start.utcoffset() is None:
|
|
2609
2938
|
raise ValueError("common_range_start must be timezone-aware")
|
|
2610
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
|
+
)
|
|
2611
2951
|
semantics = resolve_dashboard_source_semantics(
|
|
2612
2952
|
raw_config if raw_config is not None else c.load_config(),
|
|
2613
2953
|
display_tz_name=display_tz_name,
|
|
@@ -2630,6 +2970,11 @@ def _tui_build_source_bundle(
|
|
|
2630
2970
|
from _cctally_quota import assert_projection_readable
|
|
2631
2971
|
assert_projection_readable(stats_conn)
|
|
2632
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)
|
|
2633
2978
|
# #341 finding 9: the account registry/active-identity digest. Empty for
|
|
2634
2979
|
# every <=1-account install (byte-neutral — appended only when non-empty),
|
|
2635
2980
|
# so single-account source versions stay byte-identical to today; a
|
|
@@ -2644,6 +2989,7 @@ def _tui_build_source_bundle(
|
|
|
2644
2989
|
generation=c.current_generation(),
|
|
2645
2990
|
codex_stats_digest=stats_digest,
|
|
2646
2991
|
accounts_digest=accounts_digest,
|
|
2992
|
+
claude_stats_digest=claude_digest,
|
|
2647
2993
|
)
|
|
2648
2994
|
_acct_suffix = f":a{accounts_digest}" if accounts_digest else ""
|
|
2649
2995
|
# public #5: the hook's budgeted ingest can change what the Codex
|
|
@@ -2654,16 +3000,32 @@ def _tui_build_source_bundle(
|
|
|
2654
3000
|
# wire. Empty (and so byte-neutral) once the backlog has drained.
|
|
2655
3001
|
_backlog = getattr(signature, "codex_ingest_backlog_sig", "")
|
|
2656
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
|
+
)
|
|
2657
3013
|
codex_version = (
|
|
2658
3014
|
f"codex:{signature.max_codex_id}:"
|
|
2659
3015
|
f"{signature.codex_physical_mutation_seq}:{stats_digest}:"
|
|
2660
3016
|
f"{semantics.codex_identity}{_acct_suffix}{_backlog_suffix}"
|
|
3017
|
+
f"{_aggregate_suffix}"
|
|
2661
3018
|
)
|
|
3019
|
+
# #556 S1 §3.6: normalized period identity, so a nominal week rollover
|
|
3020
|
+
# invalidates the generation even when no database signature moved.
|
|
3021
|
+
_period_identity = _tui_claude_period_identity(claude_data)
|
|
2662
3022
|
claude_version = (
|
|
2663
3023
|
f"claude:{signature.max_entry_id}:{signature.entry_mutation_seq}:"
|
|
2664
3024
|
f"{signature.max_wus_id}:{signature.max_wcs_id}:"
|
|
2665
3025
|
f"{signature.reset_sig[0]}:{signature.reset_sig[1]}:"
|
|
2666
|
-
f"{signature.generation}:{semantics.claude_identity}
|
|
3026
|
+
f"{signature.generation}:{semantics.claude_identity}"
|
|
3027
|
+
f":p{_period_identity}{_acct_suffix}{_aggregate_suffix}"
|
|
3028
|
+
f":x{claude_digest}"
|
|
2667
3029
|
)
|
|
2668
3030
|
prior_claude = (
|
|
2669
3031
|
prior_bundle.sources.get("claude")
|
|
@@ -2695,6 +3057,14 @@ def _tui_build_source_bundle(
|
|
|
2695
3057
|
claude = reuse_coherent_source_state(
|
|
2696
3058
|
prior_claude, data_version=claude_version,
|
|
2697
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
|
|
2698
3068
|
if claude is None:
|
|
2699
3069
|
claude_available = "ok" if (claude_cost_usd or claude_total_tokens) else "empty"
|
|
2700
3070
|
# #341 Task 4 (Ruling C): the conditional per-account Claude wire,
|
|
@@ -2717,6 +3087,47 @@ def _tui_build_source_bundle(
|
|
|
2717
3087
|
# wire must never fail the whole dashboard tick — it just falls
|
|
2718
3088
|
# back to the byte-stable undecorated shape.
|
|
2719
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
|
+
)
|
|
2720
3131
|
claude = SourceDashboardState(
|
|
2721
3132
|
source="claude",
|
|
2722
3133
|
availability=claude_available,
|
|
@@ -2737,9 +3148,10 @@ def _tui_build_source_bundle(
|
|
|
2737
3148
|
"alerts": CapabilityRecord("supported", "provider-native"),
|
|
2738
3149
|
},
|
|
2739
3150
|
data={
|
|
2740
|
-
**(
|
|
2741
|
-
claude_data
|
|
2742
|
-
|
|
3151
|
+
**_tui_claude_data_with_aggregates(
|
|
3152
|
+
claude_data,
|
|
3153
|
+
aggregate_payload,
|
|
3154
|
+
fallback={
|
|
2743
3155
|
"hero": {
|
|
2744
3156
|
"cost_usd": claude_cost_usd,
|
|
2745
3157
|
"total_tokens": claude_total_tokens,
|
|
@@ -2750,11 +3162,14 @@ def _tui_build_source_bundle(
|
|
|
2750
3162
|
"quota": {"blocks": (), "milestones": ()},
|
|
2751
3163
|
"budget": {"label": "Claude subscription budget"},
|
|
2752
3164
|
"alerts": {"rows": ()},
|
|
2753
|
-
}
|
|
3165
|
+
},
|
|
2754
3166
|
),
|
|
2755
3167
|
**({"accounts": claude_accounts} if claude_accounts else {}),
|
|
2756
3168
|
},
|
|
2757
|
-
domain_freshness=_tui_claude_domain_freshness(
|
|
3169
|
+
domain_freshness=_tui_claude_domain_freshness(
|
|
3170
|
+
claude_data, now_utc=now_utc,
|
|
3171
|
+
),
|
|
3172
|
+
aggregate_scope=claude_aggregate_scope,
|
|
2758
3173
|
)
|
|
2759
3174
|
if codex_ingest_failed:
|
|
2760
3175
|
warning = SourceDashboardWarning(
|
|
@@ -2798,6 +3213,13 @@ def _tui_build_source_bundle(
|
|
|
2798
3213
|
prior_codex, data_version=codex_version,
|
|
2799
3214
|
)
|
|
2800
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
|
|
2801
3223
|
if codex is None:
|
|
2802
3224
|
try:
|
|
2803
3225
|
codex = build_codex_source_state(
|
|
@@ -2817,6 +3239,13 @@ def _tui_build_source_bundle(
|
|
|
2817
3239
|
),
|
|
2818
3240
|
data_version=codex_version,
|
|
2819
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
|
+
)
|
|
2820
3249
|
except Exception:
|
|
2821
3250
|
_lib_log.get_logger("dashboard").error(
|
|
2822
3251
|
"codex_read_model source build failed",
|
|
@@ -2838,6 +3267,15 @@ def _tui_build_source_bundle(
|
|
|
2838
3267
|
# guard, so a freshly built state is handed back unchanged. Claude is
|
|
2839
3268
|
# deliberately untouched.
|
|
2840
3269
|
codex = refresh_codex_source_clock(codex, now_utc=now_utc)
|
|
3270
|
+
# #556 S1 §3.8: the decoration fact reaches composition as authoritative
|
|
3271
|
+
# server-only metadata. It is attached HERE, after every build / reuse /
|
|
3272
|
+
# degrade / clock branch, so no branch can publish a state without it.
|
|
3273
|
+
claude = _tui_with_account_scope(
|
|
3274
|
+
claude, _tui_resolve_account_scope(stats_conn, "claude"),
|
|
3275
|
+
)
|
|
3276
|
+
codex = _tui_with_account_scope(
|
|
3277
|
+
codex, _tui_resolve_account_scope(stats_conn, "codex"),
|
|
3278
|
+
)
|
|
2841
3279
|
combined = compose_all_state(claude, codex)
|
|
2842
3280
|
bundle = SourceDashboardBundle(
|
|
2843
3281
|
source_schema_version=SOURCE_SCHEMA_VERSION,
|
|
@@ -2857,12 +3295,14 @@ def _tui_build_source_bundle(
|
|
|
2857
3295
|
cache_read_tx = False
|
|
2858
3296
|
post_stats_digest = codex_stats_digest(stats_conn)
|
|
2859
3297
|
post_accounts_digest = accounts_identity_digest(stats_conn)
|
|
3298
|
+
post_claude_digest = claude_stats_digest(stats_conn)
|
|
2860
3299
|
post_signature = c.compute_signature(
|
|
2861
3300
|
cache_conn,
|
|
2862
3301
|
stats_conn,
|
|
2863
3302
|
generation=c.current_generation(),
|
|
2864
3303
|
codex_stats_digest=post_stats_digest,
|
|
2865
3304
|
accounts_digest=post_accounts_digest,
|
|
3305
|
+
claude_stats_digest=post_claude_digest,
|
|
2866
3306
|
)
|
|
2867
3307
|
stats_generation_moved = (
|
|
2868
3308
|
post_signature.max_wus_id != signature.max_wus_id
|
|
@@ -2870,6 +3310,7 @@ def _tui_build_source_bundle(
|
|
|
2870
3310
|
or post_signature.reset_sig != signature.reset_sig
|
|
2871
3311
|
or post_stats_digest != stats_digest
|
|
2872
3312
|
or post_accounts_digest != accounts_digest
|
|
3313
|
+
or post_claude_digest != claude_digest
|
|
2873
3314
|
)
|
|
2874
3315
|
if stats_generation_moved:
|
|
2875
3316
|
if prior_bundle is not None:
|
|
@@ -2942,6 +3383,14 @@ def _tui_source_bundle_can_idle(bundle: SourceDashboardBundle | None) -> bool:
|
|
|
2942
3383
|
or state.freshness != "fresh"
|
|
2943
3384
|
or state.data is None):
|
|
2944
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
|
|
2945
3394
|
return True
|
|
2946
3395
|
|
|
2947
3396
|
|
|
@@ -2951,18 +3400,18 @@ def _tui_common_source_range_start(
|
|
|
2951
3400
|
now_utc: dt.datetime,
|
|
2952
3401
|
display_tz: dt.tzinfo | None,
|
|
2953
3402
|
) -> dt.datetime:
|
|
2954
|
-
"""Return the shared provider interval from the already-built daily rows.
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
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
|
|
2966
3415
|
|
|
2967
3416
|
|
|
2968
3417
|
def _tui_build_snapshot(
|
|
@@ -3910,6 +4359,7 @@ def _tui_build_snapshot_once(
|
|
|
3910
4359
|
claude_total_tokens=daily_total_tokens,
|
|
3911
4360
|
claude_data=_tui_project_claude_source_data(legacy_envelope),
|
|
3912
4361
|
common_range_start=common_range_start,
|
|
4362
|
+
projects_envelope=projects_envelope_block,
|
|
3913
4363
|
prior_bundle=prior_source_bundle,
|
|
3914
4364
|
raw_config=raw_config,
|
|
3915
4365
|
)
|
|
@@ -4063,6 +4513,7 @@ def _tui_compute_dispatch_signature(stats_conn):
|
|
|
4063
4513
|
generation=sc.current_generation(),
|
|
4064
4514
|
codex_stats_digest=codex_stats_digest(stats_conn),
|
|
4065
4515
|
accounts_digest=accounts_identity_digest(stats_conn),
|
|
4516
|
+
claude_stats_digest=claude_stats_digest(stats_conn),
|
|
4066
4517
|
)
|
|
4067
4518
|
finally:
|
|
4068
4519
|
cache_conn.close()
|
|
@@ -4240,6 +4691,7 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
|
|
|
4240
4691
|
now_utc=now_utc,
|
|
4241
4692
|
display_tz=source_display_tz,
|
|
4242
4693
|
),
|
|
4694
|
+
projects_envelope=prior.projects_envelope,
|
|
4243
4695
|
prior_bundle=source_bundle,
|
|
4244
4696
|
raw_config=raw_config,
|
|
4245
4697
|
)
|