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
|
@@ -36,7 +36,39 @@ CapabilityStatus = Literal[
|
|
|
36
36
|
# 3 -> 4 (#465): the Codex cache report retired its transitional
|
|
37
37
|
# `cache_hit_percent` alias and changed structurally inapplicable figures from
|
|
38
38
|
# numeric placeholders to null.
|
|
39
|
-
|
|
39
|
+
# 4 -> 5 (#556 S1): Claude's `hero.cost_usd` / `hero.total_tokens` changed
|
|
40
|
+
# MEANING — they were a thirty-day accounting rollup and are now current-cycle
|
|
41
|
+
# actuals, matching what the same-named Codex fields have always meant. The All
|
|
42
|
+
# source's `data.combined` also gained a required `legs` object and the optional
|
|
43
|
+
# `qualifications` / `combined_unavailable` companions, which supersedes normal-
|
|
44
|
+
# payload byte identity for this version (spec §3.5). No client branches on this
|
|
45
|
+
# number; after an in-place `execvp` update a still-loaded old client renders the
|
|
46
|
+
# new figure under old copy until it reloads, and that one-reconnect transient is
|
|
47
|
+
# accepted, consistent with the 2 -> 3 precedent above.
|
|
48
|
+
# 5 -> 6 (#556 S2): the All source gained a required `aggregates` object —
|
|
49
|
+
# one `range` describing the shared absolute interval every cross-provider
|
|
50
|
+
# ranking covers, plus a typed `available`/`withheld` outcome for Projects and
|
|
51
|
+
# for Daily. Two rows-only siblings appeared beside it on the Claude provider
|
|
52
|
+
# domain, `projects.aggregate` and `periods.daily_aggregate`, whose rows are
|
|
53
|
+
# folded over that same interval. Because those fields are REQUIRED on a v6
|
|
54
|
+
# payload, this supersedes normal-payload byte identity for this version,
|
|
55
|
+
# exactly as S1's `legs` object did for v5; the additive-omission discipline
|
|
56
|
+
# continues to govern every genuinely optional field. Nothing existing changed
|
|
57
|
+
# shape: `projects.current_week`, `projects.trend`, the flat route-lookup
|
|
58
|
+
# `projects.rows` and `periods.daily` are untouched. No client branches on this
|
|
59
|
+
# number — the bump ships as the signal it has always been, which is exactly
|
|
60
|
+
# why the wire change is additive: after an in-place `execvp` update a
|
|
61
|
+
# still-loaded old client renders precisely what it renders today until it
|
|
62
|
+
# reloads, and no forced page reload is required.
|
|
63
|
+
# 6 -> 7 (#556 S3): every Codex alert row gained `alerted_at`, the canonical
|
|
64
|
+
# firing instant, and `created_at` became an equal-valued compatibility alias
|
|
65
|
+
# for it rather than the crossing instant it used to carry. The All source's
|
|
66
|
+
# alert union is ordered by that instant across both providers instead of by a
|
|
67
|
+
# field only one of them wrote. `alerted_at` is additive and `created_at`
|
|
68
|
+
# remains present, so a pre-v7 client reading `created_at` keeps working — but
|
|
69
|
+
# the VALUE it reads changed on two of the three Codex legs, which is why this
|
|
70
|
+
# is a version bump and not a silent addition.
|
|
71
|
+
SOURCE_SCHEMA_VERSION = 7
|
|
40
72
|
DEFAULT_SOURCE = "claude"
|
|
41
73
|
SOURCE_ORDER = ("claude", "codex", "all")
|
|
42
74
|
SOURCE_FRESHNESS_DOMAINS = ("hero", "quota", "sessions")
|
|
@@ -133,11 +165,43 @@ class SourceDashboardState:
|
|
|
133
165
|
# They are deliberately separate from ``data`` so no internal accounting
|
|
134
166
|
# evidence becomes part of the public source-envelope contract.
|
|
135
167
|
clock_data: Mapping[str, object] | None = None
|
|
168
|
+
# #556 S1 §3.8 — the provider's authoritative REAL account count, resolved
|
|
169
|
+
# by the builder and carried here so `compose_all_state` can apply the
|
|
170
|
+
# single-account gate. Server-only, in the same class as `clock_data`:
|
|
171
|
+
# deliberately outside `data`, so no account cardinality enters the public
|
|
172
|
+
# source envelope. Shape: ``{"real_account_count": int}``.
|
|
173
|
+
#
|
|
174
|
+
# ``None`` means UNRESOLVED and must fail closed (withhold the combined
|
|
175
|
+
# figure), never "undecorated". Inferring decoration from the published
|
|
176
|
+
# `data.accounts` is forbidden for exactly this reason: both physical
|
|
177
|
+
# builders swallow a decoration-read failure and fall back to the
|
|
178
|
+
# undecorated shape, so a two-account install whose account read failed
|
|
179
|
+
# would present as single-account and publish the one number §3.2 forbids,
|
|
180
|
+
# on precisely the install where it is wrong.
|
|
181
|
+
account_scope: Mapping[str, object] | None = None
|
|
136
182
|
# Request-gated transcript content. This mapping is frozen with the source
|
|
137
183
|
# generation but is deliberately outside ``data``: source serialization
|
|
138
184
|
# publishes only ``data``, then the HTTP/SSE envelope layer injects a label
|
|
139
185
|
# into its request-local copies when that request's transcript gate is open.
|
|
140
186
|
private_session_labels: Mapping[str, str] | None = None
|
|
187
|
+
# #556 S2 §3.6 — the per-aggregate carrier. Server-only, in the same class
|
|
188
|
+
# as ``clock_data`` and ``account_scope``: the resolved shared range never
|
|
189
|
+
# enters a provider ``data`` domain, because composition embeds each
|
|
190
|
+
# provider's ``data`` under the All source and a range inside it would be
|
|
191
|
+
# published three times over. Shape:
|
|
192
|
+
#
|
|
193
|
+
# {"range": {"kind", "label", "start_at", "end_at"},
|
|
194
|
+
# "projects": {"state": "ok"} | {"state": "failed", "code": ...},
|
|
195
|
+
# "daily": {"state": "ok"} | {"state": "failed", "code": ...}}
|
|
196
|
+
#
|
|
197
|
+
# ``account_scope`` is the right precedent for STORAGE CLASS and the wrong
|
|
198
|
+
# one for LIFECYCLE. Account scope is deliberately reattached from the
|
|
199
|
+
# current tick after every build, reuse and degrade branch; doing that here
|
|
200
|
+
# would overwrite the range that describes RETAINED rows with the range of a
|
|
201
|
+
# tick that produced none. This carrier therefore travels with the rows it
|
|
202
|
+
# describes and is never re-derived on a reuse or degrade path. Explicit
|
|
203
|
+
# constructors must copy it.
|
|
204
|
+
aggregate_scope: Mapping[str, object] | None = None
|
|
141
205
|
|
|
142
206
|
def __post_init__(self) -> None:
|
|
143
207
|
validate_dashboard_selection(self.source)
|
|
@@ -181,6 +245,12 @@ class SourceDashboardState:
|
|
|
181
245
|
object.__setattr__(self, "data", _freeze(self.data))
|
|
182
246
|
if self.clock_data is not None:
|
|
183
247
|
object.__setattr__(self, "clock_data", _freeze(self.clock_data))
|
|
248
|
+
if self.account_scope is not None:
|
|
249
|
+
object.__setattr__(self, "account_scope", _freeze(self.account_scope))
|
|
250
|
+
if self.aggregate_scope is not None:
|
|
251
|
+
object.__setattr__(
|
|
252
|
+
self, "aggregate_scope", _freeze(self.aggregate_scope),
|
|
253
|
+
)
|
|
184
254
|
if self.private_session_labels is not None:
|
|
185
255
|
private_session_labels = {
|
|
186
256
|
_nonempty_string(key, "private session label key"):
|
|
@@ -295,7 +365,16 @@ def degrade_source_state(
|
|
|
295
365
|
domain: "stale" for domain in SOURCE_FRESHNESS_DOMAINS
|
|
296
366
|
},
|
|
297
367
|
clock_data=prior.clock_data,
|
|
368
|
+
# #556 S1 §3.8: a degraded generation must not LOSE the count. Dropping
|
|
369
|
+
# it here would turn a transient provider failure into
|
|
370
|
+
# `account_scope_unresolved` on an install whose count read fine.
|
|
371
|
+
account_scope=prior.account_scope,
|
|
298
372
|
private_session_labels=prior.private_session_labels,
|
|
373
|
+
# #556 S2 §3.6: the carrier travels with the rows it describes. A
|
|
374
|
+
# degraded generation retains `prior.data`, so it must retain the range
|
|
375
|
+
# that bounded those rows — re-deriving it from the current tick would
|
|
376
|
+
# publish a range the retained rows do not cover.
|
|
377
|
+
aggregate_scope=prior.aggregate_scope,
|
|
299
378
|
)
|
|
300
379
|
|
|
301
380
|
|
|
@@ -365,67 +444,620 @@ def reuse_coherent_source_state(
|
|
|
365
444
|
return prior if _coherent_provider(prior) and prior.data_version == data_version else None
|
|
366
445
|
|
|
367
446
|
|
|
368
|
-
|
|
369
|
-
|
|
447
|
+
# === #556 S1 — the typed combined outcome (spec §3.5, §3.7) =================
|
|
448
|
+
#
|
|
449
|
+
# `combined` is the sum, over both providers, of that provider's accounting
|
|
450
|
+
# actuals within its OWN current cycle: Claude's subscription week and Codex's
|
|
451
|
+
# native 7-day cycle. The two legs are deliberately not one shared range — the
|
|
452
|
+
# property bought is that each leg reconciles with its provider tab, and each
|
|
453
|
+
# leg therefore names the cycle it covers.
|
|
454
|
+
|
|
455
|
+
_PROVIDER_LABELS: Mapping[str, str] = MappingProxyType(
|
|
456
|
+
{"claude": "Claude", "codex": "Codex"},
|
|
457
|
+
)
|
|
458
|
+
_LEG_PERIOD_KINDS: Mapping[str, tuple[str, str, str, str]] = MappingProxyType({
|
|
459
|
+
# provider -> (kind, label, hero container key, (start key, end key))
|
|
460
|
+
"claude": ("subscription_week", "Claude subscription week",
|
|
461
|
+
"current_week", "week_start_at|reset_at_utc"),
|
|
462
|
+
"codex": ("native_7_day_cycle", "Codex native 7-day cycle",
|
|
463
|
+
"cycle", "start_at|resets_at"),
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
@dataclass(frozen=True)
|
|
468
|
+
class _CombinedCause:
|
|
469
|
+
"""One reason the combined figure is withheld, with its precedence rank."""
|
|
470
|
+
|
|
471
|
+
precedence: int
|
|
472
|
+
provider: PhysicalSource
|
|
473
|
+
code: str
|
|
474
|
+
detail: Mapping[str, object] | None = None
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _cause_message(cause: _CombinedCause) -> str:
|
|
478
|
+
"""Public-safe prose for one cause. Never echoes a rejected value."""
|
|
479
|
+
provider = _PROVIDER_LABELS.get(cause.provider, cause.provider)
|
|
480
|
+
detail = cause.detail or {}
|
|
481
|
+
if cause.code == "provider_incoherent":
|
|
482
|
+
return (
|
|
483
|
+
f"{provider} data is not current, so a combined total is withheld."
|
|
484
|
+
)
|
|
485
|
+
if cause.code == "account_scope_unresolved":
|
|
486
|
+
return (
|
|
487
|
+
f"{provider}'s account count could not be read, so a combined "
|
|
488
|
+
"total is withheld."
|
|
489
|
+
)
|
|
490
|
+
if cause.code == "multi_account_unsupported":
|
|
491
|
+
count = detail.get("account_count")
|
|
492
|
+
return (
|
|
493
|
+
f"{provider} has {count} accounts on separate cycles, so a "
|
|
494
|
+
"combined total is not published; see the per-account cards."
|
|
495
|
+
)
|
|
496
|
+
if cause.code == "claude_cycle_unresolved":
|
|
497
|
+
return "Claude's current subscription week could not be resolved."
|
|
498
|
+
if cause.code == "codex_projection_incoherent":
|
|
499
|
+
return "Codex quota projection is unavailable."
|
|
500
|
+
if cause.code == "codex_cycle_unavailable":
|
|
501
|
+
return "Codex native reset cycle is unavailable."
|
|
502
|
+
if cause.code == "invalid_counter":
|
|
503
|
+
return (
|
|
504
|
+
f"{provider} reported an unusable {detail.get('field')} counter "
|
|
505
|
+
f"({detail.get('reason')})."
|
|
506
|
+
)
|
|
507
|
+
return f"{provider} data cannot contribute to a combined total."
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _combined_hero(state: SourceDashboardState) -> Mapping[str, object] | None:
|
|
511
|
+
data = state.data
|
|
512
|
+
if not isinstance(data, Mapping):
|
|
513
|
+
return None
|
|
514
|
+
hero = data.get("hero")
|
|
515
|
+
return hero if isinstance(hero, Mapping) else None
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _real_account_count(state: SourceDashboardState) -> int | None:
|
|
519
|
+
"""The provider's authoritative REAL account count, or ``None``.
|
|
370
520
|
|
|
371
|
-
``
|
|
372
|
-
|
|
521
|
+
``None`` is UNRESOLVED and fails closed (§3.8). Decoration is never
|
|
522
|
+
inferred from published data, because both physical builders swallow a
|
|
523
|
+
decoration-read failure and fall back to the undecorated shape.
|
|
373
524
|
"""
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
525
|
+
scope = getattr(state, "account_scope", None)
|
|
526
|
+
if not isinstance(scope, Mapping):
|
|
527
|
+
return None
|
|
528
|
+
count = scope.get("real_account_count")
|
|
529
|
+
if isinstance(count, bool) or not isinstance(count, int) or count < 0:
|
|
530
|
+
return None
|
|
531
|
+
return count
|
|
380
532
|
|
|
381
533
|
|
|
382
|
-
def
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
534
|
+
def _counter_reason(value: object, *, integral: bool) -> str | None:
|
|
535
|
+
"""Return why ``value`` is unusable as a counter, or ``None`` if it is fine.
|
|
536
|
+
|
|
537
|
+
Booleans are rejected: ``True`` is an ``int`` in Python, and a flag that
|
|
538
|
+
leaked into a counter slot would otherwise be summed as 1.
|
|
539
|
+
"""
|
|
540
|
+
if value is None:
|
|
541
|
+
return "missing"
|
|
542
|
+
if isinstance(value, bool):
|
|
543
|
+
return "non_integer"
|
|
544
|
+
if integral:
|
|
545
|
+
if not isinstance(value, int):
|
|
546
|
+
return "non_integer"
|
|
547
|
+
else:
|
|
548
|
+
if not isinstance(value, (int, float)):
|
|
549
|
+
return "non_integer"
|
|
550
|
+
if not math.isfinite(value):
|
|
551
|
+
return "non_finite"
|
|
552
|
+
return "negative" if value < 0 else None
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _period_instant(value: object) -> str | None:
|
|
556
|
+
"""One canonical UTC spelling for a published period bound.
|
|
557
|
+
|
|
558
|
+
The two providers reach this with different spellings of the same
|
|
559
|
+
convention — Claude's bounds arrive from the legacy envelope's `_iso_z`
|
|
560
|
+
(`...Z`) and Codex's from `datetime.isoformat()` (`...+00:00`). Publishing
|
|
561
|
+
both would make every client parse two forms of one field for no reason.
|
|
562
|
+
"""
|
|
563
|
+
if not isinstance(value, str) or not value:
|
|
564
|
+
return None
|
|
565
|
+
try:
|
|
566
|
+
parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
567
|
+
except ValueError:
|
|
568
|
+
return None
|
|
569
|
+
if parsed.tzinfo is None:
|
|
570
|
+
parsed = parsed.replace(tzinfo=dt.timezone.utc)
|
|
571
|
+
return parsed.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z")
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def canonical_alerted_at(value: object) -> str:
|
|
575
|
+
"""Normalize an aware ISO-8601 firing instant to one UTC ``Z`` spelling.
|
|
576
|
+
|
|
577
|
+
#556 S3 §2.2. The union sorted on a field one writer never wrote, so a
|
|
578
|
+
missing or malformed value must raise here rather than degrade to a
|
|
579
|
+
sentinel that sorts silently. Sub-second precision is truncated, so two
|
|
580
|
+
alerts firing in the same second compare equal and fall back to source
|
|
581
|
+
order; no writer emits it today.
|
|
582
|
+
"""
|
|
583
|
+
if not isinstance(value, str) or not value:
|
|
584
|
+
raise ValueError(f"alerted_at must be a non-empty ISO-8601 string, got {value!r}")
|
|
585
|
+
try:
|
|
586
|
+
parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
587
|
+
except ValueError as exc:
|
|
588
|
+
raise ValueError(f"unparseable alerted_at {value!r}") from exc
|
|
589
|
+
if parsed.tzinfo is None:
|
|
590
|
+
raise ValueError(f"naive alerted_at {value!r}; an aware instant is required")
|
|
591
|
+
return parsed.astimezone(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def canonical_alerted_at_sql(column: str = "alerted_at") -> str:
|
|
595
|
+
"""The SQL twin of :func:`canonical_alerted_at`, for ordering in SQLite.
|
|
596
|
+
|
|
597
|
+
#556 S3 §2.3. Every per-axis ``LIMIT`` decides MEMBERSHIP, not merely
|
|
598
|
+
order, so a row excluded by a textual comparison of two spellings of one
|
|
599
|
+
instant cannot be recovered by any later projection. SQLite parses the
|
|
600
|
+
timezone indicator, so for every aware spelling this returns the same
|
|
601
|
+
canonical UTC ``Z`` string the Python helper returns —
|
|
602
|
+
``tests/test_556_s3_alert_ordering.py`` pins that agreement over the
|
|
603
|
+
committed estate. The twins diverge on exactly one input: SQLite reads a
|
|
604
|
+
naive value as UTC where the Python helper raises, so such a row is ordered
|
|
605
|
+
here and rejected later. An unparseable value yields SQL ``NULL``, which
|
|
606
|
+
sorts last under ``DESC``, so the ``LIMIT`` usually drops that row before
|
|
607
|
+
composition can reject it — corruption is truncated away, not surfaced.
|
|
608
|
+
"""
|
|
609
|
+
return f"strftime('%Y-%m-%dT%H:%M:%SZ', {column})"
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _leg_period(
|
|
613
|
+
provider: PhysicalSource, hero: Mapping[str, object] | None,
|
|
614
|
+
) -> Mapping[str, object] | None:
|
|
615
|
+
"""The named cycle a `current` leg covers, or ``None`` when unresolvable."""
|
|
616
|
+
kind, label, container_key, bound_keys = _LEG_PERIOD_KINDS[provider]
|
|
617
|
+
container = hero.get(container_key) if isinstance(hero, Mapping) else None
|
|
618
|
+
if not isinstance(container, Mapping):
|
|
619
|
+
return None
|
|
620
|
+
start_key, end_key = bound_keys.split("|")
|
|
621
|
+
start_at = _period_instant(container.get(start_key))
|
|
622
|
+
end_at = _period_instant(container.get(end_key))
|
|
623
|
+
if start_at is None or end_at is None:
|
|
624
|
+
return None
|
|
625
|
+
return {
|
|
626
|
+
"kind": kind, "label": label, "start_at": start_at, "end_at": end_at,
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
_CODEX_HERO_FAILURE_ORDER: tuple[str, ...] = (
|
|
631
|
+
"codex_projection_incoherent", "codex_cycle_unavailable",
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _codex_hero_failure_codes(state: SourceDashboardState) -> tuple[str, ...]:
|
|
636
|
+
"""EVERY Codex hero failure present, in the order §3.7 fixes.
|
|
637
|
+
|
|
638
|
+
Codex computes projection coherence and cycle resolution independently and
|
|
639
|
+
emits BOTH warnings, so §3.5 lists both as causes and §3.7 fixes their
|
|
640
|
+
order: an incoherent projection certificate invalidates the evidence the
|
|
641
|
+
cycle resolution rests on, and is therefore the earlier cause. The first
|
|
642
|
+
element is the winner, so the top-level `code` is unchanged by listing the
|
|
643
|
+
rest.
|
|
644
|
+
"""
|
|
645
|
+
codes = {warning.code for warning in state.warnings}
|
|
646
|
+
found = tuple(code for code in _CODEX_HERO_FAILURE_ORDER if code in codes)
|
|
647
|
+
if found:
|
|
648
|
+
return found
|
|
649
|
+
# No warning names the failure — fall back to the hero capability's own
|
|
650
|
+
# semantics, which can only describe one of the two.
|
|
651
|
+
capability = state.capabilities.get("hero")
|
|
652
|
+
semantics = capability.semantics if capability is not None else None
|
|
653
|
+
if semantics == "projection-incoherent":
|
|
654
|
+
return ("codex_projection_incoherent",)
|
|
655
|
+
return ("codex_cycle_unavailable",)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _combined_leg(
|
|
659
|
+
state: SourceDashboardState, provider: PhysicalSource,
|
|
660
|
+
) -> tuple[Mapping[str, object] | None, tuple[_CombinedCause, ...]]:
|
|
661
|
+
"""Build one leg, or the causes that stop it contributing.
|
|
662
|
+
|
|
663
|
+
A leg is `empty` when the provider reports no accounting AND no cycle:
|
|
664
|
+
`availability == "empty"` and no period resolves. Both halves are needed.
|
|
665
|
+
`availability` alone is not the fact, because it is computed over the
|
|
666
|
+
dashboard's VISIBLE range while Codex's hero is a separate cycle-bounded
|
|
667
|
+
read — a Codex provider can be `empty` in the visible range while its
|
|
668
|
+
current cycle holds real spend. An unresolved period alone is not the fact
|
|
669
|
+
either, because for Claude that is a FAILURE when accounting exists
|
|
670
|
+
(§3.7, "empty versus unresolved").
|
|
671
|
+
"""
|
|
672
|
+
hero = _combined_hero(state)
|
|
673
|
+
capability = state.capabilities.get("hero")
|
|
674
|
+
if capability is None or capability.status not in {"supported", "derived"}:
|
|
675
|
+
codes = (
|
|
676
|
+
_codex_hero_failure_codes(state) if provider == "codex"
|
|
677
|
+
else ("claude_cycle_unresolved",)
|
|
678
|
+
)
|
|
679
|
+
return None, tuple(
|
|
680
|
+
_CombinedCause(4, provider, code) for code in codes
|
|
681
|
+
)
|
|
682
|
+
cost = hero.get("cost_usd") if hero is not None else None
|
|
683
|
+
tokens = hero.get("total_tokens") if hero is not None else None
|
|
684
|
+
period = _leg_period(provider, hero)
|
|
685
|
+
if period is None and state.availability == "empty":
|
|
686
|
+
# Numeric zeros and no period, so nothing presents `$0` as observed
|
|
687
|
+
# spend inside a named cycle.
|
|
688
|
+
return {"state": "empty", "cost_usd": 0.0, "total_tokens": 0}, ()
|
|
689
|
+
if provider == "claude" and cost is None and tokens is None:
|
|
690
|
+
# Accounting exists but no subscription week resolved. Claude's hero
|
|
691
|
+
# capability stays `supported` in that state, so this is its own
|
|
692
|
+
# detection rather than the capability branch above.
|
|
693
|
+
return None, (_CombinedCause(4, provider, "claude_cycle_unresolved"),)
|
|
694
|
+
causes = tuple(
|
|
695
|
+
_CombinedCause(
|
|
696
|
+
5, provider, "invalid_counter", {"field": field, "reason": reason},
|
|
697
|
+
)
|
|
698
|
+
for field, reason in (
|
|
699
|
+
("cost_usd", _counter_reason(cost, integral=False)),
|
|
700
|
+
("total_tokens", _counter_reason(tokens, integral=True)),
|
|
701
|
+
)
|
|
702
|
+
if reason is not None
|
|
389
703
|
)
|
|
704
|
+
if causes:
|
|
705
|
+
return None, causes
|
|
706
|
+
return {
|
|
707
|
+
"state": "current",
|
|
708
|
+
"cost_usd": float(cost),
|
|
709
|
+
"total_tokens": int(tokens),
|
|
710
|
+
**({"period": period} if period is not None else {}),
|
|
711
|
+
}, ()
|
|
390
712
|
|
|
391
713
|
|
|
392
|
-
def
|
|
714
|
+
def _combined_qualifications(
|
|
715
|
+
legs: Mapping[str, Mapping[str, object]],
|
|
393
716
|
claude: SourceDashboardState,
|
|
394
717
|
codex: SourceDashboardState,
|
|
395
|
-
) -> Mapping[str, object]
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
718
|
+
) -> tuple[Mapping[str, object], ...]:
|
|
719
|
+
"""Notes that qualify a PUBLISHED figure (§4.3). Empty means omit the key."""
|
|
720
|
+
qualifications: list[Mapping[str, object]] = []
|
|
721
|
+
for provider in ("claude", "codex"):
|
|
722
|
+
if legs[provider].get("state") == "empty":
|
|
723
|
+
label = _PROVIDER_LABELS[provider]
|
|
724
|
+
qualifications.append({
|
|
725
|
+
"code": "provider_empty",
|
|
726
|
+
"message": f"{label} has no accounting in its current cycle.",
|
|
727
|
+
"provider": provider,
|
|
728
|
+
})
|
|
729
|
+
# public #5: the Codex ingest backlog is LIFTED here rather than read from
|
|
730
|
+
# the provider field by the All surfaces, so the figure and its disclosure
|
|
731
|
+
# cannot disagree. The provider field stays published for the Codex tab.
|
|
732
|
+
data = codex.data
|
|
733
|
+
backlog = data.get("ingest_backlog") if isinstance(data, Mapping) else None
|
|
734
|
+
if isinstance(backlog, Mapping) and backlog:
|
|
735
|
+
qualifications.append({
|
|
736
|
+
"code": "codex_ingest_backlog",
|
|
737
|
+
"message": (
|
|
738
|
+
"Codex has pending accounting to ingest, so its cycle total "
|
|
739
|
+
"may be incomplete."
|
|
740
|
+
),
|
|
741
|
+
"provider": "codex",
|
|
742
|
+
})
|
|
743
|
+
return tuple(qualifications)
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _combined_outcome(
|
|
747
|
+
claude: SourceDashboardState,
|
|
748
|
+
codex: SourceDashboardState,
|
|
749
|
+
) -> tuple[Mapping[str, object] | None, Mapping[str, object] | None]:
|
|
750
|
+
"""Return ``(combined, combined_unavailable)`` — exactly one is not None.
|
|
751
|
+
|
|
752
|
+
Cause precedence (§3.7), first match wins, Claude before Codex at equal
|
|
753
|
+
precedence: provider incoherence, unresolved account scope, decoration, a
|
|
754
|
+
hero capability outside {supported, derived} reported as the provider's own
|
|
755
|
+
reason, then an invalid counter. Every cause found is listed, ordered so
|
|
756
|
+
that `causes[0]` is always the winner.
|
|
757
|
+
"""
|
|
758
|
+
pairs: tuple[tuple[PhysicalSource, SourceDashboardState], ...] = (
|
|
759
|
+
("claude", claude), ("codex", codex),
|
|
760
|
+
)
|
|
761
|
+
causes: list[_CombinedCause] = []
|
|
762
|
+
for provider, state in pairs:
|
|
763
|
+
if not _coherent_provider(state):
|
|
764
|
+
causes.append(_CombinedCause(1, provider, "provider_incoherent"))
|
|
765
|
+
for provider, state in pairs:
|
|
766
|
+
count = _real_account_count(state)
|
|
767
|
+
if count is None:
|
|
768
|
+
causes.append(
|
|
769
|
+
_CombinedCause(2, provider, "account_scope_unresolved"))
|
|
770
|
+
elif count > 1:
|
|
771
|
+
causes.append(_CombinedCause(
|
|
772
|
+
3, provider, "multi_account_unsupported",
|
|
773
|
+
{"account_count": count},
|
|
774
|
+
))
|
|
775
|
+
legs: dict[str, Mapping[str, object]] = {}
|
|
776
|
+
for provider, state in pairs:
|
|
777
|
+
if not _coherent_provider(state):
|
|
778
|
+
# An incoherent generation's data cannot be trusted to yield a leg
|
|
779
|
+
# OR a leg-level cause; precedence 1 already withholds the figure.
|
|
780
|
+
continue
|
|
781
|
+
leg, leg_causes = _combined_leg(state, provider)
|
|
782
|
+
causes.extend(leg_causes)
|
|
783
|
+
if leg is not None:
|
|
784
|
+
legs[provider] = leg
|
|
785
|
+
if causes:
|
|
786
|
+
ordered = sorted(
|
|
787
|
+
causes,
|
|
788
|
+
key=lambda cause: (
|
|
789
|
+
cause.precedence, 0 if cause.provider == "claude" else 1,
|
|
790
|
+
),
|
|
791
|
+
)
|
|
792
|
+
return None, {
|
|
793
|
+
"code": ordered[0].code,
|
|
794
|
+
"message": _cause_message(ordered[0]),
|
|
795
|
+
"causes": tuple(
|
|
796
|
+
{
|
|
797
|
+
"provider": cause.provider,
|
|
798
|
+
"code": cause.code,
|
|
799
|
+
**({"detail": cause.detail} if cause.detail else {}),
|
|
800
|
+
}
|
|
801
|
+
for cause in ordered
|
|
802
|
+
),
|
|
803
|
+
}
|
|
804
|
+
qualifications = _combined_qualifications(legs, claude, codex)
|
|
805
|
+
return {
|
|
806
|
+
"cost_usd": float(legs["claude"]["cost_usd"]) + float(legs["codex"]["cost_usd"]),
|
|
807
|
+
"total_tokens": int(legs["claude"]["total_tokens"]) + int(legs["codex"]["total_tokens"]),
|
|
808
|
+
"legs": legs,
|
|
809
|
+
**({"qualifications": qualifications} if qualifications else {}),
|
|
810
|
+
}, None
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
# === #556 S2 — the shared cross-provider aggregates (spec §3.5.1, §3.7) =====
|
|
814
|
+
#
|
|
815
|
+
# Two range rules coexist. A combined TOTAL sums provider-native cycles (S1,
|
|
816
|
+
# above). A cross-provider RANKING uses one shared absolute calendar range, and
|
|
817
|
+
# that is what these aggregates publish. Both are deliberate.
|
|
818
|
+
#
|
|
819
|
+
# Withholding is a typed outcome rather than an empty list, because an empty
|
|
820
|
+
# list renders as honest emptiness and a range problem is not emptiness.
|
|
821
|
+
|
|
822
|
+
AGGREGATE_NAMES: tuple[str, ...] = ("projects", "daily")
|
|
823
|
+
AGGREGATE_RANGE_KIND = "absolute_range"
|
|
824
|
+
AGGREGATE_RANGE_LABEL = "Shared range"
|
|
825
|
+
|
|
826
|
+
# Precedence is TOTAL and follows the declared code order (§3.5.1). The first
|
|
827
|
+
# two are ordered so the predicates stay mutually exclusive: `_coherent_provider`
|
|
828
|
+
# already subsumes unavailability, so testing incoherence first would make
|
|
829
|
+
# `provider_unavailable` unreachable.
|
|
830
|
+
_AGGREGATE_CAUSE_RANK: Mapping[str, int] = MappingProxyType({
|
|
831
|
+
"range_unresolved": 1,
|
|
832
|
+
"provider_unavailable": 2,
|
|
833
|
+
"provider_incoherent": 3,
|
|
834
|
+
"claude_fold_failed": 4,
|
|
835
|
+
"retained_range_mismatch": 5,
|
|
836
|
+
})
|
|
837
|
+
|
|
838
|
+
# One server warning maps to one published qualification. The figure stays
|
|
839
|
+
# AVAILABLE: the server already publishes a qualified Codex projects subset in
|
|
840
|
+
# this state, and withholding the whole ranking over it would discard real data.
|
|
841
|
+
_AGGREGATE_QUALIFYING_WARNINGS: Mapping[str, tuple[str, str]] = MappingProxyType({
|
|
842
|
+
# warning code -> (published qualification code, aggregate it qualifies)
|
|
843
|
+
"codex_metadata_incomplete": ("codex_project_metadata_partial", "projects"),
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def aggregate_range(start_at: object, end_at: object) -> dict | None:
|
|
848
|
+
"""Canonicalise one resolved absolute range, or ``None`` if it does not."""
|
|
849
|
+
start = _period_instant(start_at)
|
|
850
|
+
end = _period_instant(end_at)
|
|
851
|
+
if start is None or end is None:
|
|
424
852
|
return None
|
|
425
853
|
return {
|
|
426
|
-
"
|
|
427
|
-
"
|
|
854
|
+
"kind": AGGREGATE_RANGE_KIND,
|
|
855
|
+
"label": AGGREGATE_RANGE_LABEL,
|
|
856
|
+
"start_at": start,
|
|
857
|
+
"end_at": end,
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def build_aggregate_scope(
|
|
862
|
+
published_range: Mapping[str, object] | None,
|
|
863
|
+
outcomes: Mapping[str, object] | None = None,
|
|
864
|
+
) -> dict:
|
|
865
|
+
"""The server-only carrier a freshly built provider generation gets."""
|
|
866
|
+
scope: dict = {"range": dict(published_range) if published_range else None}
|
|
867
|
+
for name in AGGREGATE_NAMES:
|
|
868
|
+
entry = (outcomes or {}).get(name)
|
|
869
|
+
scope[name] = dict(entry) if isinstance(entry, Mapping) else {"state": "ok"}
|
|
870
|
+
return scope
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def aggregate_scope_failed(value: object) -> bool:
|
|
874
|
+
"""Whether a provider generation records a failed aggregate fold.
|
|
875
|
+
|
|
876
|
+
Accepts either a ``SourceDashboardState`` or a raw carrier mapping, because
|
|
877
|
+
both gates need the predicate and one of them runs before the state exists.
|
|
878
|
+
|
|
879
|
+
A failure must not become permanent. A locally caught fold failure leaves an
|
|
880
|
+
otherwise `ok` and `fresh` provider, and that bundle would qualify for idle
|
|
881
|
+
reuse while exact-version provider reuse returns the prior object unchanged
|
|
882
|
+
— so one transient failure would withhold the aggregate for the life of the
|
|
883
|
+
process. This predicate is read at BOTH gates.
|
|
884
|
+
"""
|
|
885
|
+
scope = (
|
|
886
|
+
value if isinstance(value, Mapping)
|
|
887
|
+
else getattr(value, "aggregate_scope", None)
|
|
888
|
+
)
|
|
889
|
+
if not isinstance(scope, Mapping):
|
|
890
|
+
return False
|
|
891
|
+
for name in AGGREGATE_NAMES:
|
|
892
|
+
entry = scope.get(name)
|
|
893
|
+
if isinstance(entry, Mapping) and entry.get("state") != "ok":
|
|
894
|
+
return True
|
|
895
|
+
return False
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def aggregate_scope_identity(scope: object) -> str:
|
|
899
|
+
"""The version fragment a provider's aggregate carrier contributes.
|
|
900
|
+
|
|
901
|
+
Carries the resolved range START and the per-aggregate outcome, so a failed
|
|
902
|
+
and a successful fold over the same database signature can never publish
|
|
903
|
+
different rows under one ``data_version``.
|
|
904
|
+
|
|
905
|
+
``end_at`` is deliberately EXCLUDED. It is ``now_utc``, which advances on
|
|
906
|
+
every tick by construction, so folding it in would make every provider
|
|
907
|
+
version unique per tick and defeat `reuse_coherent_source_state` on every
|
|
908
|
+
path — including the reuse §3.6 itself reasons about. The START is the bound
|
|
909
|
+
that can actually move (a display-day rollover), and it is folded into BOTH
|
|
910
|
+
providers' versions so they rebuild in lockstep and a coherent pair can
|
|
911
|
+
never disagree about it.
|
|
912
|
+
|
|
913
|
+
The start participates as the EXACT canonical instant, at the same
|
|
914
|
+
granularity `compose_all_aggregates` compares it. That is the point: the
|
|
915
|
+
composition publishes a range only when every coherent provider's canonical
|
|
916
|
+
``start_at`` is the same string, and this identity is what forces the two
|
|
917
|
+
providers to rebuild in lockstep so they can be. A coarser identity would
|
|
918
|
+
make a difference the composition rejects invisible to the gate that is
|
|
919
|
+
supposed to resolve it — an unchanged provider would keep reusing the old
|
|
920
|
+
carrier while a rebuilt one recorded the new instant, and both aggregates
|
|
921
|
+
would be withheld as ``retained_range_mismatch`` on every subsequent tick.
|
|
922
|
+
That is the original defect, and one value read at two granularities is its
|
|
923
|
+
structural shape.
|
|
924
|
+
|
|
925
|
+
An earlier revision folded the start at DAY granularity to protect against
|
|
926
|
+
a ``now_utc - 30 days`` fallback that advanced on every tick. That fallback
|
|
927
|
+
is gone: every producer of this bound now floors to display-timezone
|
|
928
|
+
midnight — `resolve_shared_range` on both its branches, and
|
|
929
|
+
`_tui_build_source_bundle`'s own fallback, which resolves through the same
|
|
930
|
+
helper. So the exact instant changes at most once per display day, which is
|
|
931
|
+
a tick that must rebuild anyway because the daily panel rolled over.
|
|
932
|
+
|
|
933
|
+
``end_at`` is the only value still excluded, for the reason above.
|
|
934
|
+
"""
|
|
935
|
+
if not isinstance(scope, Mapping):
|
|
936
|
+
return "none"
|
|
937
|
+
published = scope.get("range")
|
|
938
|
+
start = (
|
|
939
|
+
published.get("start_at") if isinstance(published, Mapping) else None
|
|
940
|
+
)
|
|
941
|
+
parts = [str(start or "")]
|
|
942
|
+
for name in AGGREGATE_NAMES:
|
|
943
|
+
entry = scope.get(name)
|
|
944
|
+
state = entry.get("state") if isinstance(entry, Mapping) else None
|
|
945
|
+
code = entry.get("code") if isinstance(entry, Mapping) else None
|
|
946
|
+
parts.append(f"{name}:{state or 'unknown'}" + (f":{code}" if code else ""))
|
|
947
|
+
return "|".join(parts)
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def _aggregate_scope_range(state: SourceDashboardState) -> dict | None:
|
|
951
|
+
scope = getattr(state, "aggregate_scope", None)
|
|
952
|
+
if not isinstance(scope, Mapping):
|
|
953
|
+
return None
|
|
954
|
+
published = scope.get("range")
|
|
955
|
+
if not isinstance(published, Mapping):
|
|
956
|
+
return None
|
|
957
|
+
return aggregate_range(published.get("start_at"), published.get("end_at"))
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def _aggregate_fold_failed(state: SourceDashboardState, name: str) -> bool:
|
|
961
|
+
scope = getattr(state, "aggregate_scope", None)
|
|
962
|
+
if not isinstance(scope, Mapping):
|
|
963
|
+
return False
|
|
964
|
+
entry = scope.get(name)
|
|
965
|
+
return isinstance(entry, Mapping) and entry.get("state") != "ok"
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def _aggregate_qualifications(
|
|
969
|
+
claude: SourceDashboardState, codex: SourceDashboardState, name: str,
|
|
970
|
+
) -> list[dict]:
|
|
971
|
+
"""Notes that qualify a PUBLISHED aggregate. Empty means omit the key."""
|
|
972
|
+
qualifications: list[dict] = []
|
|
973
|
+
for provider, state in (("claude", claude), ("codex", codex)):
|
|
974
|
+
for warning in state.warnings:
|
|
975
|
+
mapped = _AGGREGATE_QUALIFYING_WARNINGS.get(warning.code)
|
|
976
|
+
if mapped is not None and mapped[1] == name:
|
|
977
|
+
qualifications.append(
|
|
978
|
+
{"code": mapped[0], "provider": provider},
|
|
979
|
+
)
|
|
980
|
+
return qualifications
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def compose_all_aggregates(
|
|
984
|
+
claude: SourceDashboardState, codex: SourceDashboardState,
|
|
985
|
+
) -> dict:
|
|
986
|
+
"""The single public ``sources.all.data.aggregates`` object (§3.5.1).
|
|
987
|
+
|
|
988
|
+
The outcome carries STATE AND REASON only; the rows live on the provider
|
|
989
|
+
domains and the client composes them. That is what keeps exactly one public
|
|
990
|
+
copy of the range and one public copy of the rows.
|
|
991
|
+
|
|
992
|
+
Every cause is evaluated per aggregate, so a Projects fold failure cannot
|
|
993
|
+
withhold Daily.
|
|
994
|
+
"""
|
|
995
|
+
pairs: tuple[tuple[PhysicalSource, SourceDashboardState], ...] = (
|
|
996
|
+
("claude", claude), ("codex", codex),
|
|
997
|
+
)
|
|
998
|
+
coherent = [
|
|
999
|
+
(provider, state) for provider, state in pairs
|
|
1000
|
+
if _coherent_provider(state)
|
|
1001
|
+
]
|
|
1002
|
+
ranges = {
|
|
1003
|
+
provider: _aggregate_scope_range(state) for provider, state in coherent
|
|
428
1004
|
}
|
|
1005
|
+
resolved = [value for value in ranges.values() if value is not None]
|
|
1006
|
+
starts = {value["start_at"] for value in resolved}
|
|
1007
|
+
|
|
1008
|
+
shared: list[tuple[int, int, str, PhysicalSource | None]] = []
|
|
1009
|
+
if coherent and len(resolved) != len(coherent):
|
|
1010
|
+
# A coherent provider whose rows are not bounded by a known range.
|
|
1011
|
+
shared.append((_AGGREGATE_CAUSE_RANK["range_unresolved"], 0,
|
|
1012
|
+
"range_unresolved", None))
|
|
1013
|
+
for rank_provider, (provider, state) in enumerate(pairs):
|
|
1014
|
+
if state.availability == "unavailable":
|
|
1015
|
+
shared.append((_AGGREGATE_CAUSE_RANK["provider_unavailable"],
|
|
1016
|
+
rank_provider, "provider_unavailable", provider))
|
|
1017
|
+
for rank_provider, (provider, state) in enumerate(pairs):
|
|
1018
|
+
if state.availability != "unavailable" and not _coherent_provider(state):
|
|
1019
|
+
shared.append((_AGGREGATE_CAUSE_RANK["provider_incoherent"],
|
|
1020
|
+
rank_provider, "provider_incoherent", provider))
|
|
1021
|
+
if len(starts) > 1:
|
|
1022
|
+
shared.append((_AGGREGATE_CAUSE_RANK["retained_range_mismatch"], 0,
|
|
1023
|
+
"retained_range_mismatch", None))
|
|
1024
|
+
|
|
1025
|
+
published_range: dict | None = None
|
|
1026
|
+
if coherent and len(resolved) == len(coherent) and len(starts) == 1:
|
|
1027
|
+
# Published only when EVERY coherent provider supplied a range and they
|
|
1028
|
+
# agree. Publishing one leg's range while the other's is unresolved or
|
|
1029
|
+
# different would state a span the composed rows do not cover.
|
|
1030
|
+
#
|
|
1031
|
+
# A reused provider provably has no new accounting rows — its physical
|
|
1032
|
+
# signature is part of the version that made the reuse legal — so the
|
|
1033
|
+
# later of the two ends is the instant BOTH legs are complete to.
|
|
1034
|
+
published_range = {
|
|
1035
|
+
**resolved[0],
|
|
1036
|
+
"end_at": max(value["end_at"] for value in resolved),
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
aggregates: dict = {"range": published_range}
|
|
1040
|
+
for name in AGGREGATE_NAMES:
|
|
1041
|
+
causes = list(shared)
|
|
1042
|
+
if _aggregate_fold_failed(claude, name):
|
|
1043
|
+
causes.append((_AGGREGATE_CAUSE_RANK["claude_fold_failed"], 0,
|
|
1044
|
+
"claude_fold_failed", "claude"))
|
|
1045
|
+
if causes:
|
|
1046
|
+
_rank, _provider_rank, code, provider = min(
|
|
1047
|
+
causes, key=lambda cause: (cause[0], cause[1]),
|
|
1048
|
+
)
|
|
1049
|
+
aggregates[name] = {
|
|
1050
|
+
"state": "withheld",
|
|
1051
|
+
"code": code,
|
|
1052
|
+
**({"provider": provider} if provider is not None else {}),
|
|
1053
|
+
}
|
|
1054
|
+
continue
|
|
1055
|
+
qualifications = _aggregate_qualifications(claude, codex, name)
|
|
1056
|
+
aggregates[name] = {
|
|
1057
|
+
"state": "available",
|
|
1058
|
+
**({"qualifications": qualifications} if qualifications else {}),
|
|
1059
|
+
}
|
|
1060
|
+
return aggregates
|
|
429
1061
|
|
|
430
1062
|
|
|
431
1063
|
def _combined_alert_rows(
|
|
@@ -445,13 +1077,23 @@ def _combined_alert_rows(
|
|
|
445
1077
|
if not isinstance(row, Mapping) or row.get("source") != source:
|
|
446
1078
|
continue
|
|
447
1079
|
ordered.append(row)
|
|
1080
|
+
|
|
1081
|
+
def _instant(row: Mapping[str, object]) -> str:
|
|
1082
|
+
try:
|
|
1083
|
+
return canonical_alerted_at(row.get("alerted_at"))
|
|
1084
|
+
except ValueError as exc:
|
|
1085
|
+
identity = row.get("id") if row.get("id") is not None else row.get("key")
|
|
1086
|
+
raise ValueError(
|
|
1087
|
+
f"{row.get('source')!r} alert row {identity!r}: {exc}"
|
|
1088
|
+
) from exc
|
|
1089
|
+
|
|
1090
|
+
# #556 S3 §2.5: composition is the chokepoint. The previous sort keyed on
|
|
1091
|
+
# `created_at`, which the Claude projection never wrote, so every Claude
|
|
1092
|
+
# row collapsed to "" and sorted last. Validating here means a future leg,
|
|
1093
|
+
# axis or provider that omits the canonical instant fails visibly instead.
|
|
448
1094
|
# Python's stable sort preserves declared source order, then each source's
|
|
449
|
-
# native order, when
|
|
450
|
-
return tuple(sorted(
|
|
451
|
-
ordered,
|
|
452
|
-
key=lambda row: str(row.get("created_at") or ""),
|
|
453
|
-
reverse=True,
|
|
454
|
-
))
|
|
1095
|
+
# native order, when firing instants tie.
|
|
1096
|
+
return tuple(sorted(ordered, key=_instant, reverse=True))
|
|
455
1097
|
|
|
456
1098
|
|
|
457
1099
|
def compose_all_state(
|
|
@@ -461,35 +1103,23 @@ def compose_all_state(
|
|
|
461
1103
|
"""Compose provider-labeled sections without inventing blended semantics."""
|
|
462
1104
|
if claude.source != "claude" or codex.source != "codex":
|
|
463
1105
|
raise ValueError("all composition requires Claude and Codex provider states")
|
|
464
|
-
combined =
|
|
1106
|
+
combined, combined_unavailable = _combined_outcome(claude, codex)
|
|
1107
|
+
aggregates = compose_all_aggregates(claude, codex)
|
|
1108
|
+
# Computed ONCE: the same ordered union is hashed into the version below
|
|
1109
|
+
# and published in `data` further down, so the identity and the rows can
|
|
1110
|
+
# never describe different orderings.
|
|
1111
|
+
combined_alerts = _combined_alert_rows(claude, codex)
|
|
465
1112
|
providers_coherent = _coherent_provider(claude) and _coherent_provider(codex)
|
|
466
|
-
stale_cycle_providers = (
|
|
467
|
-
_stale_cycle_providers(claude, codex) if providers_coherent else ()
|
|
468
|
-
)
|
|
469
|
-
# #359: the warning qualifies a retained combined actual. It stays
|
|
470
|
-
# All-local and keeps the composed source partial so the header status also
|
|
471
|
-
# names the caveat; provider envelopes remain independently coherent.
|
|
472
|
-
all_local_warnings: tuple[SourceDashboardWarning, ...] = ()
|
|
473
|
-
if stale_cycle_providers:
|
|
474
|
-
all_local_warnings = (SourceDashboardWarning(
|
|
475
|
-
"combined_totals_stale",
|
|
476
|
-
f"{' and '.join(stale_cycle_providers)} quota evidence is stale; "
|
|
477
|
-
"combined totals use retained actuals.",
|
|
478
|
-
"hero",
|
|
479
|
-
),)
|
|
480
1113
|
if providers_coherent:
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
"
|
|
486
|
-
if
|
|
487
|
-
else
|
|
488
|
-
"empty"
|
|
489
|
-
if claude.availability == "empty" and codex.availability == "empty"
|
|
490
|
-
else "ok"
|
|
491
|
-
)
|
|
1114
|
+
availability: Availability = (
|
|
1115
|
+
"partial"
|
|
1116
|
+
if combined is None or "partial" in (claude.availability, codex.availability)
|
|
1117
|
+
else (
|
|
1118
|
+
"empty"
|
|
1119
|
+
if claude.availability == "empty" and codex.availability == "empty"
|
|
1120
|
+
else "ok"
|
|
492
1121
|
)
|
|
1122
|
+
)
|
|
493
1123
|
freshness: Freshness = "fresh"
|
|
494
1124
|
else:
|
|
495
1125
|
availability = "partial"
|
|
@@ -506,22 +1136,47 @@ def compose_all_state(
|
|
|
506
1136
|
source_domain_freshness(codex, domain)
|
|
507
1137
|
for domain in SOURCE_FRESHNESS_DOMAINS
|
|
508
1138
|
],
|
|
509
|
-
|
|
1139
|
+
# #556 S1: the WHOLE outcome, not merely whether one exists. The
|
|
1140
|
+
# legs, their periods, the qualifications and the withheld cause
|
|
1141
|
+
# are all published, and the account scope and hero counters that
|
|
1142
|
+
# produce them are not otherwise in this material — so hashing only
|
|
1143
|
+
# `combined is not None` would leave materially different All
|
|
1144
|
+
# states sharing one `data_version` (invariant 6).
|
|
1145
|
+
combined, combined_unavailable,
|
|
1146
|
+
# #556 S2 §3.6: the COMPLETE `AllAggregates` value, not merely the
|
|
1147
|
+
# range. A failed and a successful fold over the same database
|
|
1148
|
+
# signature and the same bounds publish different rows, so hashing
|
|
1149
|
+
# only the range would leave them sharing one `data_version`.
|
|
1150
|
+
aggregates,
|
|
1151
|
+
# #556 S3 §2.9: the ordered alert union's identity. Without it the
|
|
1152
|
+
# version material omitted alerts entirely, so two materially
|
|
1153
|
+
# different unions — a different order, a different membership, a
|
|
1154
|
+
# newly fired alert — collided on one `data_version`.
|
|
1155
|
+
[
|
|
1156
|
+
(str(row.get("source")), str(row.get("id") or row.get("key")),
|
|
1157
|
+
canonical_alerted_at(row.get("alerted_at")))
|
|
1158
|
+
for row in combined_alerts
|
|
1159
|
+
],
|
|
510
1160
|
],
|
|
511
1161
|
separators=(",", ":"),
|
|
1162
|
+
sort_keys=True,
|
|
512
1163
|
).encode("utf-8")
|
|
513
1164
|
data_version = "all:" + hashlib.sha256(version_material).hexdigest()[:24]
|
|
514
|
-
successes = (
|
|
515
|
-
|
|
1165
|
+
successes = (claude.last_success_at, codex.last_success_at)
|
|
1166
|
+
# §4.6: `None` unless BOTH providers have one, otherwise the older.
|
|
1167
|
+
# Filtering `None` before `min` let one provider's success masquerade as
|
|
1168
|
+
# All's while the client keys "no successful snapshot yet" on null.
|
|
1169
|
+
last_success_at = None if None in successes else min(successes)
|
|
516
1170
|
return SourceDashboardState(
|
|
517
1171
|
source="all",
|
|
518
1172
|
availability=availability,
|
|
519
1173
|
freshness=freshness,
|
|
520
|
-
# All-
|
|
521
|
-
#
|
|
522
|
-
# `
|
|
523
|
-
#
|
|
524
|
-
|
|
1174
|
+
# §4.2: the All-local `combined_totals_stale` warning is retired. The
|
|
1175
|
+
# combined figure's own disclosure now travels in `data.combined`
|
|
1176
|
+
# (`qualifications`) and `data.combined_unavailable`, which is typed and
|
|
1177
|
+
# has provenance — All flattens both providers' warnings into one tuple
|
|
1178
|
+
# with no provenance field, so warning order could never carry it.
|
|
1179
|
+
warnings=tuple((*claude.warnings, *codex.warnings)),
|
|
525
1180
|
data_version=data_version,
|
|
526
1181
|
last_success_at=last_success_at,
|
|
527
1182
|
capabilities={
|
|
@@ -532,7 +1187,14 @@ def compose_all_state(
|
|
|
532
1187
|
},
|
|
533
1188
|
data={
|
|
534
1189
|
"combined": combined,
|
|
535
|
-
|
|
1190
|
+
# Emitted iff the figure is withheld; omitted-when-inapplicable.
|
|
1191
|
+
**({"combined_unavailable": combined_unavailable}
|
|
1192
|
+
if combined is None else {}),
|
|
1193
|
+
"alerts": {"rows": combined_alerts},
|
|
1194
|
+
# #556 S2 §3.5.1: the ONE public copy of the shared range and of
|
|
1195
|
+
# both aggregate outcomes. The rows stay on the provider domains
|
|
1196
|
+
# under `providers` below, so nothing is published twice.
|
|
1197
|
+
"aggregates": aggregates,
|
|
536
1198
|
"providers": {
|
|
537
1199
|
"claude": claude.data,
|
|
538
1200
|
"codex": codex.data,
|
|
@@ -616,25 +1278,78 @@ _CODEX_STATS_DIGEST_RELATIONS: tuple[tuple[str, str], ...] = (
|
|
|
616
1278
|
)
|
|
617
1279
|
|
|
618
1280
|
|
|
619
|
-
|
|
620
|
-
|
|
1281
|
+
# #556 S3 §2.9. The Claude twin of the relation table above, over the five
|
|
1282
|
+
# alert tables the Claude projection reads. `codex_stats_digest` already covers
|
|
1283
|
+
# Codex's alert rows; Claude's were covered by nothing, and the dispatch
|
|
1284
|
+
# signature's stats legs are `MAX(id)` over the two weekly snapshot tables plus
|
|
1285
|
+
# the reset-event change signal — none of which a milestone INSERT or an
|
|
1286
|
+
# `alerted_at` arming UPDATE touches. A fired Claude alert could therefore
|
|
1287
|
+
# leave the idle path short-circuiting on a retained prior bundle. Measured
|
|
1288
|
+
# before the leg was added: inserting a `budget_milestones` row with
|
|
1289
|
+
# `vendor='claude'` left every existing leg byte-identical.
|
|
1290
|
+
#
|
|
1291
|
+
# Only the alert-bearing columns are selected, for the same reason the Codex
|
|
1292
|
+
# table selects a fixed list: the digest is an identity over what the surface
|
|
1293
|
+
# publishes, not a checksum of the table file.
|
|
1294
|
+
_CLAUDE_STATS_DIGEST_RELATIONS: tuple[tuple[str, str], ...] = (
|
|
1295
|
+
(
|
|
1296
|
+
"percent_milestones",
|
|
1297
|
+
"SELECT week_start_date, percent_threshold, captured_at_utc, "
|
|
1298
|
+
"cumulative_cost_usd, reset_event_id, account_key, alerted_at "
|
|
1299
|
+
"FROM percent_milestones WHERE alerted_at IS NOT NULL "
|
|
1300
|
+
"ORDER BY week_start_date, percent_threshold, reset_event_id, account_key, "
|
|
1301
|
+
"captured_at_utc, cumulative_cost_usd, alerted_at",
|
|
1302
|
+
),
|
|
1303
|
+
(
|
|
1304
|
+
"five_hour_milestones",
|
|
1305
|
+
"SELECT five_hour_window_key, percent_threshold, captured_at_utc, "
|
|
1306
|
+
"block_cost_usd, reset_event_id, account_key, alerted_at "
|
|
1307
|
+
"FROM five_hour_milestones WHERE alerted_at IS NOT NULL "
|
|
1308
|
+
"ORDER BY five_hour_window_key, percent_threshold, reset_event_id, account_key, "
|
|
1309
|
+
"captured_at_utc, block_cost_usd, alerted_at",
|
|
1310
|
+
),
|
|
1311
|
+
(
|
|
1312
|
+
"budget_milestones",
|
|
1313
|
+
"SELECT vendor, period_start_at, period, threshold, budget_usd, spent_usd, "
|
|
1314
|
+
"consumption_pct, crossed_at_utc, account_key, alerted_at "
|
|
1315
|
+
"FROM budget_milestones WHERE vendor <> 'codex' AND alerted_at IS NOT NULL "
|
|
1316
|
+
"ORDER BY vendor, period_start_at, period, threshold, account_key, "
|
|
1317
|
+
"budget_usd, spent_usd, consumption_pct, crossed_at_utc, alerted_at",
|
|
1318
|
+
),
|
|
1319
|
+
(
|
|
1320
|
+
"projected_milestones",
|
|
1321
|
+
"SELECT week_start_at, period, metric, threshold, projected_value, denominator, "
|
|
1322
|
+
"crossed_at_utc, account_key, alerted_at FROM projected_milestones "
|
|
1323
|
+
"WHERE metric <> 'codex_budget_usd' AND alerted_at IS NOT NULL "
|
|
1324
|
+
"ORDER BY week_start_at, period, metric, threshold, account_key, "
|
|
1325
|
+
"projected_value, denominator, crossed_at_utc, alerted_at",
|
|
1326
|
+
),
|
|
1327
|
+
(
|
|
1328
|
+
"project_budget_milestones",
|
|
1329
|
+
"SELECT week_start_at, project_key, threshold, budget_usd, spent_usd, "
|
|
1330
|
+
"consumption_pct, crossed_at_utc, account_key, alerted_at "
|
|
1331
|
+
"FROM project_budget_milestones WHERE alerted_at IS NOT NULL "
|
|
1332
|
+
"ORDER BY week_start_at, project_key, threshold, account_key, "
|
|
1333
|
+
"budget_usd, spent_usd, consumption_pct, crossed_at_utc, alerted_at",
|
|
1334
|
+
),
|
|
1335
|
+
)
|
|
621
1336
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
for _name, query in
|
|
1337
|
+
|
|
1338
|
+
def _stats_relations_digest(
|
|
1339
|
+
stats_conn: sqlite3.Connection,
|
|
1340
|
+
relations: tuple[tuple[str, str], ...],
|
|
1341
|
+
) -> str:
|
|
1342
|
+
relation_rows: list[list[list[object]]] = []
|
|
1343
|
+
for _name, query in relations:
|
|
629
1344
|
try:
|
|
630
1345
|
rows = stats_conn.execute(query).fetchall()
|
|
631
1346
|
except sqlite3.OperationalError as exc:
|
|
632
1347
|
if "no such table" not in str(exc).lower():
|
|
633
1348
|
raise
|
|
634
1349
|
rows = ()
|
|
635
|
-
|
|
1350
|
+
relation_rows.append([list(row) for row in rows])
|
|
636
1351
|
canonical = json.dumps(
|
|
637
|
-
|
|
1352
|
+
relation_rows,
|
|
638
1353
|
allow_nan=False,
|
|
639
1354
|
ensure_ascii=False,
|
|
640
1355
|
separators=(",", ":"),
|
|
@@ -642,6 +1357,26 @@ def codex_stats_digest(stats_conn: sqlite3.Connection) -> str:
|
|
|
642
1357
|
return hashlib.sha256(canonical).hexdigest()
|
|
643
1358
|
|
|
644
1359
|
|
|
1360
|
+
def claude_stats_digest(stats_conn: sqlite3.Connection) -> str:
|
|
1361
|
+
"""Hash the Claude-owned alert relations, canonically ordered.
|
|
1362
|
+
|
|
1363
|
+
A missing table is an empty relation, so an older or fresh stats database
|
|
1364
|
+
still has a stable digest — the same posture ``codex_stats_digest`` takes.
|
|
1365
|
+
"""
|
|
1366
|
+
return _stats_relations_digest(stats_conn, _CLAUDE_STATS_DIGEST_RELATIONS)
|
|
1367
|
+
|
|
1368
|
+
|
|
1369
|
+
def codex_stats_digest(stats_conn: sqlite3.Connection) -> str:
|
|
1370
|
+
"""Hash exact, canonically ordered Codex-derived stats relations.
|
|
1371
|
+
|
|
1372
|
+
A missing table is an empty relation so an older/fresh stats database has a
|
|
1373
|
+
stable digest. Other SQLite failures remain visible to the builder, which
|
|
1374
|
+
then follows the source all-or-prior failure matrix instead of publishing a
|
|
1375
|
+
guessed identity.
|
|
1376
|
+
"""
|
|
1377
|
+
return _stats_relations_digest(stats_conn, _CODEX_STATS_DIGEST_RELATIONS)
|
|
1378
|
+
|
|
1379
|
+
|
|
645
1380
|
def assess_codex_projection_coherence(
|
|
646
1381
|
*,
|
|
647
1382
|
active_root_keys: tuple[str, ...] | list[str] | set[str],
|