cctally 1.97.0 → 1.99.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/README.md +4 -4
- package/bin/_cctally_account.py +925 -0
- package/bin/_cctally_cache.py +829 -31
- package/bin/_cctally_core.py +52 -0
- package/bin/_cctally_dashboard.py +960 -55
- package/bin/_cctally_dashboard_envelope.py +43 -23
- package/bin/_cctally_dashboard_share.py +50 -1
- package/bin/_cctally_dashboard_sources.py +1580 -131
- package/bin/_cctally_db.py +810 -34
- package/bin/_cctally_doctor.py +184 -1
- package/bin/_cctally_journal.py +732 -76
- package/bin/_cctally_parser.py +65 -0
- package/bin/_cctally_quota.py +896 -17
- package/bin/_cctally_rederive.py +157 -5
- package/bin/_cctally_source_analytics.py +60 -6
- package/bin/_cctally_tui.py +811 -67
- package/bin/_lib_aggregators.py +117 -11
- package/bin/_lib_alert_axes.py +8 -3
- package/bin/_lib_budget.py +60 -0
- package/bin/_lib_codex_window_attribution.py +259 -0
- package/bin/_lib_dashboard_sources.py +488 -19
- package/bin/_lib_doctor.py +71 -0
- package/bin/_lib_journal.py +212 -0
- package/bin/_lib_jsonl.py +6 -0
- package/bin/_lib_rederive.py +8 -0
- package/bin/_lib_snapshot_cache.py +248 -7
- package/bin/_lib_source_analytics.py +20 -2
- package/bin/cctally +25 -0
- package/dashboard/static/assets/index-Bcbm-DNP.js +97 -0
- package/dashboard/static/assets/index-hJP4wlIO.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-BSESoPIK.css +0 -1
- package/dashboard/static/assets/index-DgsMz5hA.js +0 -97
|
@@ -45,7 +45,55 @@ CapabilityStatus = Literal[
|
|
|
45
45
|
# number; after an in-place `execvp` update a still-loaded old client renders the
|
|
46
46
|
# new figure under old copy until it reloads, and that one-reconnect transient is
|
|
47
47
|
# accepted, consistent with the 2 -> 3 precedent above.
|
|
48
|
-
|
|
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
|
+
# 7 -> 8 (#556 S5): the Claude provider's `capabilities.budget` detail changed
|
|
72
|
+
# MEANING. It said `subscription-week` unconditionally, as a constant, while
|
|
73
|
+
# Codex advertised `calendar-period`; it now names the CONFIGURED period, so on
|
|
74
|
+
# an install with `budget.period = calendar-month` the same field reads
|
|
75
|
+
# `calendar-month`. `docs/cli-contract.md:58` says an optional additive key
|
|
76
|
+
# alone does not bump but a changed value or meaning does, which is exactly how
|
|
77
|
+
# S1, S2 and S3 justified theirs. The Claude source also gained an optional
|
|
78
|
+
# `data.budget.status` — the same object Codex publishes — plus its
|
|
79
|
+
# `status_unavailable` / `not_configured` siblings, and the Codex budget domain
|
|
80
|
+
# gained the same optional `status_unavailable` sibling. Those are additive and
|
|
81
|
+
# omitted when inapplicable, so an install with no budget configured publishes
|
|
82
|
+
# byte-identical bytes and the bump rests on the capability detail alone.
|
|
83
|
+
# `sources.all.capabilities.budget` is a DIFFERENT field and is unchanged at
|
|
84
|
+
# `not_applicable` / `provider-native`: rendering two provider-native child
|
|
85
|
+
# budgets side by side does not create an All-level budget quantity.
|
|
86
|
+
# 8 -> 9 (#564): on a decorated Codex provider, a card whose totals do not come
|
|
87
|
+
# from a live weekly cycle — a real account whose boundary is not live, and the
|
|
88
|
+
# unattributed sentinel — now covers one native cycle width ending at `now`
|
|
89
|
+
# instead of the whole ~30-day accounting range. `accounts[].spendUsd`, its five
|
|
90
|
+
# token siblings, and the decorated `hero.cost_usd` / `hero.total_tokens` summed
|
|
91
|
+
# from them therefore changed VALUE without changing shape, which is the same
|
|
92
|
+
# class of change the 4 -> 5 entry records. Those cards additionally publish the
|
|
93
|
+
# optional `spendWindow` bounds, which a cycle-bounded card omits. No client
|
|
94
|
+
# branches on this number; after an in-place `execvp` update a still-loaded old
|
|
95
|
+
# client renders the new figures under old copy until it reloads.
|
|
96
|
+
SOURCE_SCHEMA_VERSION = 9
|
|
49
97
|
DEFAULT_SOURCE = "claude"
|
|
50
98
|
SOURCE_ORDER = ("claude", "codex", "all")
|
|
51
99
|
SOURCE_FRESHNESS_DOMAINS = ("hero", "quota", "sessions")
|
|
@@ -161,6 +209,24 @@ class SourceDashboardState:
|
|
|
161
209
|
# publishes only ``data``, then the HTTP/SSE envelope layer injects a label
|
|
162
210
|
# into its request-local copies when that request's transcript gate is open.
|
|
163
211
|
private_session_labels: Mapping[str, str] | None = None
|
|
212
|
+
# #556 S2 §3.6 — the per-aggregate carrier. Server-only, in the same class
|
|
213
|
+
# as ``clock_data`` and ``account_scope``: the resolved shared range never
|
|
214
|
+
# enters a provider ``data`` domain, because composition embeds each
|
|
215
|
+
# provider's ``data`` under the All source and a range inside it would be
|
|
216
|
+
# published three times over. Shape:
|
|
217
|
+
#
|
|
218
|
+
# {"range": {"kind", "label", "start_at", "end_at"},
|
|
219
|
+
# "projects": {"state": "ok"} | {"state": "failed", "code": ...},
|
|
220
|
+
# "daily": {"state": "ok"} | {"state": "failed", "code": ...}}
|
|
221
|
+
#
|
|
222
|
+
# ``account_scope`` is the right precedent for STORAGE CLASS and the wrong
|
|
223
|
+
# one for LIFECYCLE. Account scope is deliberately reattached from the
|
|
224
|
+
# current tick after every build, reuse and degrade branch; doing that here
|
|
225
|
+
# would overwrite the range that describes RETAINED rows with the range of a
|
|
226
|
+
# tick that produced none. This carrier therefore travels with the rows it
|
|
227
|
+
# describes and is never re-derived on a reuse or degrade path. Explicit
|
|
228
|
+
# constructors must copy it.
|
|
229
|
+
aggregate_scope: Mapping[str, object] | None = None
|
|
164
230
|
|
|
165
231
|
def __post_init__(self) -> None:
|
|
166
232
|
validate_dashboard_selection(self.source)
|
|
@@ -206,6 +272,10 @@ class SourceDashboardState:
|
|
|
206
272
|
object.__setattr__(self, "clock_data", _freeze(self.clock_data))
|
|
207
273
|
if self.account_scope is not None:
|
|
208
274
|
object.__setattr__(self, "account_scope", _freeze(self.account_scope))
|
|
275
|
+
if self.aggregate_scope is not None:
|
|
276
|
+
object.__setattr__(
|
|
277
|
+
self, "aggregate_scope", _freeze(self.aggregate_scope),
|
|
278
|
+
)
|
|
209
279
|
if self.private_session_labels is not None:
|
|
210
280
|
private_session_labels = {
|
|
211
281
|
_nonempty_string(key, "private session label key"):
|
|
@@ -325,6 +395,11 @@ def degrade_source_state(
|
|
|
325
395
|
# `account_scope_unresolved` on an install whose count read fine.
|
|
326
396
|
account_scope=prior.account_scope,
|
|
327
397
|
private_session_labels=prior.private_session_labels,
|
|
398
|
+
# #556 S2 §3.6: the carrier travels with the rows it describes. A
|
|
399
|
+
# degraded generation retains `prior.data`, so it must retain the range
|
|
400
|
+
# that bounded those rows — re-deriving it from the current tick would
|
|
401
|
+
# publish a range the retained rows do not cover.
|
|
402
|
+
aggregate_scope=prior.aggregate_scope,
|
|
328
403
|
)
|
|
329
404
|
|
|
330
405
|
|
|
@@ -521,6 +596,44 @@ def _period_instant(value: object) -> str | None:
|
|
|
521
596
|
return parsed.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z")
|
|
522
597
|
|
|
523
598
|
|
|
599
|
+
def canonical_alerted_at(value: object) -> str:
|
|
600
|
+
"""Normalize an aware ISO-8601 firing instant to one UTC ``Z`` spelling.
|
|
601
|
+
|
|
602
|
+
#556 S3 §2.2. The union sorted on a field one writer never wrote, so a
|
|
603
|
+
missing or malformed value must raise here rather than degrade to a
|
|
604
|
+
sentinel that sorts silently. Sub-second precision is truncated, so two
|
|
605
|
+
alerts firing in the same second compare equal and fall back to source
|
|
606
|
+
order; no writer emits it today.
|
|
607
|
+
"""
|
|
608
|
+
if not isinstance(value, str) or not value:
|
|
609
|
+
raise ValueError(f"alerted_at must be a non-empty ISO-8601 string, got {value!r}")
|
|
610
|
+
try:
|
|
611
|
+
parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
612
|
+
except ValueError as exc:
|
|
613
|
+
raise ValueError(f"unparseable alerted_at {value!r}") from exc
|
|
614
|
+
if parsed.tzinfo is None:
|
|
615
|
+
raise ValueError(f"naive alerted_at {value!r}; an aware instant is required")
|
|
616
|
+
return parsed.astimezone(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def canonical_alerted_at_sql(column: str = "alerted_at") -> str:
|
|
620
|
+
"""The SQL twin of :func:`canonical_alerted_at`, for ordering in SQLite.
|
|
621
|
+
|
|
622
|
+
#556 S3 §2.3. Every per-axis ``LIMIT`` decides MEMBERSHIP, not merely
|
|
623
|
+
order, so a row excluded by a textual comparison of two spellings of one
|
|
624
|
+
instant cannot be recovered by any later projection. SQLite parses the
|
|
625
|
+
timezone indicator, so for every aware spelling this returns the same
|
|
626
|
+
canonical UTC ``Z`` string the Python helper returns —
|
|
627
|
+
``tests/test_556_s3_alert_ordering.py`` pins that agreement over the
|
|
628
|
+
committed estate. The twins diverge on exactly one input: SQLite reads a
|
|
629
|
+
naive value as UTC where the Python helper raises, so such a row is ordered
|
|
630
|
+
here and rejected later. An unparseable value yields SQL ``NULL``, which
|
|
631
|
+
sorts last under ``DESC``, so the ``LIMIT`` usually drops that row before
|
|
632
|
+
composition can reject it — corruption is truncated away, not surfaced.
|
|
633
|
+
"""
|
|
634
|
+
return f"strftime('%Y-%m-%dT%H:%M:%SZ', {column})"
|
|
635
|
+
|
|
636
|
+
|
|
524
637
|
def _leg_period(
|
|
525
638
|
provider: PhysicalSource, hero: Mapping[str, object] | None,
|
|
526
639
|
) -> Mapping[str, object] | None:
|
|
@@ -722,6 +835,256 @@ def _combined_outcome(
|
|
|
722
835
|
}, None
|
|
723
836
|
|
|
724
837
|
|
|
838
|
+
# === #556 S2 — the shared cross-provider aggregates (spec §3.5.1, §3.7) =====
|
|
839
|
+
#
|
|
840
|
+
# Two range rules coexist. A combined TOTAL sums provider-native cycles (S1,
|
|
841
|
+
# above). A cross-provider RANKING uses one shared absolute calendar range, and
|
|
842
|
+
# that is what these aggregates publish. Both are deliberate.
|
|
843
|
+
#
|
|
844
|
+
# Withholding is a typed outcome rather than an empty list, because an empty
|
|
845
|
+
# list renders as honest emptiness and a range problem is not emptiness.
|
|
846
|
+
|
|
847
|
+
AGGREGATE_NAMES: tuple[str, ...] = ("projects", "daily")
|
|
848
|
+
AGGREGATE_RANGE_KIND = "absolute_range"
|
|
849
|
+
AGGREGATE_RANGE_LABEL = "Shared range"
|
|
850
|
+
|
|
851
|
+
# Precedence is TOTAL and follows the declared code order (§3.5.1). The first
|
|
852
|
+
# two are ordered so the predicates stay mutually exclusive: `_coherent_provider`
|
|
853
|
+
# already subsumes unavailability, so testing incoherence first would make
|
|
854
|
+
# `provider_unavailable` unreachable.
|
|
855
|
+
_AGGREGATE_CAUSE_RANK: Mapping[str, int] = MappingProxyType({
|
|
856
|
+
"range_unresolved": 1,
|
|
857
|
+
"provider_unavailable": 2,
|
|
858
|
+
"provider_incoherent": 3,
|
|
859
|
+
"claude_fold_failed": 4,
|
|
860
|
+
"retained_range_mismatch": 5,
|
|
861
|
+
})
|
|
862
|
+
|
|
863
|
+
# One server warning maps to one published qualification. The figure stays
|
|
864
|
+
# AVAILABLE: the server already publishes a qualified Codex projects subset in
|
|
865
|
+
# this state, and withholding the whole ranking over it would discard real data.
|
|
866
|
+
_AGGREGATE_QUALIFYING_WARNINGS: Mapping[str, tuple[str, str]] = MappingProxyType({
|
|
867
|
+
# warning code -> (published qualification code, aggregate it qualifies)
|
|
868
|
+
"codex_metadata_incomplete": ("codex_project_metadata_partial", "projects"),
|
|
869
|
+
})
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def aggregate_range(start_at: object, end_at: object) -> dict | None:
|
|
873
|
+
"""Canonicalise one resolved absolute range, or ``None`` if it does not."""
|
|
874
|
+
start = _period_instant(start_at)
|
|
875
|
+
end = _period_instant(end_at)
|
|
876
|
+
if start is None or end is None:
|
|
877
|
+
return None
|
|
878
|
+
return {
|
|
879
|
+
"kind": AGGREGATE_RANGE_KIND,
|
|
880
|
+
"label": AGGREGATE_RANGE_LABEL,
|
|
881
|
+
"start_at": start,
|
|
882
|
+
"end_at": end,
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def build_aggregate_scope(
|
|
887
|
+
published_range: Mapping[str, object] | None,
|
|
888
|
+
outcomes: Mapping[str, object] | None = None,
|
|
889
|
+
) -> dict:
|
|
890
|
+
"""The server-only carrier a freshly built provider generation gets."""
|
|
891
|
+
scope: dict = {"range": dict(published_range) if published_range else None}
|
|
892
|
+
for name in AGGREGATE_NAMES:
|
|
893
|
+
entry = (outcomes or {}).get(name)
|
|
894
|
+
scope[name] = dict(entry) if isinstance(entry, Mapping) else {"state": "ok"}
|
|
895
|
+
return scope
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def aggregate_scope_failed(value: object) -> bool:
|
|
899
|
+
"""Whether a provider generation records a failed aggregate fold.
|
|
900
|
+
|
|
901
|
+
Accepts either a ``SourceDashboardState`` or a raw carrier mapping, because
|
|
902
|
+
both gates need the predicate and one of them runs before the state exists.
|
|
903
|
+
|
|
904
|
+
A failure must not become permanent. A locally caught fold failure leaves an
|
|
905
|
+
otherwise `ok` and `fresh` provider, and that bundle would qualify for idle
|
|
906
|
+
reuse while exact-version provider reuse returns the prior object unchanged
|
|
907
|
+
— so one transient failure would withhold the aggregate for the life of the
|
|
908
|
+
process. This predicate is read at BOTH gates.
|
|
909
|
+
"""
|
|
910
|
+
scope = (
|
|
911
|
+
value if isinstance(value, Mapping)
|
|
912
|
+
else getattr(value, "aggregate_scope", None)
|
|
913
|
+
)
|
|
914
|
+
if not isinstance(scope, Mapping):
|
|
915
|
+
return False
|
|
916
|
+
for name in AGGREGATE_NAMES:
|
|
917
|
+
entry = scope.get(name)
|
|
918
|
+
if isinstance(entry, Mapping) and entry.get("state") != "ok":
|
|
919
|
+
return True
|
|
920
|
+
return False
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def aggregate_scope_identity(scope: object) -> str:
|
|
924
|
+
"""The version fragment a provider's aggregate carrier contributes.
|
|
925
|
+
|
|
926
|
+
Carries the resolved range START and the per-aggregate outcome, so a failed
|
|
927
|
+
and a successful fold over the same database signature can never publish
|
|
928
|
+
different rows under one ``data_version``.
|
|
929
|
+
|
|
930
|
+
``end_at`` is deliberately EXCLUDED. It is ``now_utc``, which advances on
|
|
931
|
+
every tick by construction, so folding it in would make every provider
|
|
932
|
+
version unique per tick and defeat `reuse_coherent_source_state` on every
|
|
933
|
+
path — including the reuse §3.6 itself reasons about. The START is the bound
|
|
934
|
+
that can actually move (a display-day rollover), and it is folded into BOTH
|
|
935
|
+
providers' versions so they rebuild in lockstep and a coherent pair can
|
|
936
|
+
never disagree about it.
|
|
937
|
+
|
|
938
|
+
The start participates as the EXACT canonical instant, at the same
|
|
939
|
+
granularity `compose_all_aggregates` compares it. That is the point: the
|
|
940
|
+
composition publishes a range only when every coherent provider's canonical
|
|
941
|
+
``start_at`` is the same string, and this identity is what forces the two
|
|
942
|
+
providers to rebuild in lockstep so they can be. A coarser identity would
|
|
943
|
+
make a difference the composition rejects invisible to the gate that is
|
|
944
|
+
supposed to resolve it — an unchanged provider would keep reusing the old
|
|
945
|
+
carrier while a rebuilt one recorded the new instant, and both aggregates
|
|
946
|
+
would be withheld as ``retained_range_mismatch`` on every subsequent tick.
|
|
947
|
+
That is the original defect, and one value read at two granularities is its
|
|
948
|
+
structural shape.
|
|
949
|
+
|
|
950
|
+
An earlier revision folded the start at DAY granularity to protect against
|
|
951
|
+
a ``now_utc - 30 days`` fallback that advanced on every tick. That fallback
|
|
952
|
+
is gone: every producer of this bound now floors to display-timezone
|
|
953
|
+
midnight — `resolve_shared_range` on both its branches, and
|
|
954
|
+
`_tui_build_source_bundle`'s own fallback, which resolves through the same
|
|
955
|
+
helper. So the exact instant changes at most once per display day, which is
|
|
956
|
+
a tick that must rebuild anyway because the daily panel rolled over.
|
|
957
|
+
|
|
958
|
+
``end_at`` is the only value still excluded, for the reason above.
|
|
959
|
+
"""
|
|
960
|
+
if not isinstance(scope, Mapping):
|
|
961
|
+
return "none"
|
|
962
|
+
published = scope.get("range")
|
|
963
|
+
start = (
|
|
964
|
+
published.get("start_at") if isinstance(published, Mapping) else None
|
|
965
|
+
)
|
|
966
|
+
parts = [str(start or "")]
|
|
967
|
+
for name in AGGREGATE_NAMES:
|
|
968
|
+
entry = scope.get(name)
|
|
969
|
+
state = entry.get("state") if isinstance(entry, Mapping) else None
|
|
970
|
+
code = entry.get("code") if isinstance(entry, Mapping) else None
|
|
971
|
+
parts.append(f"{name}:{state or 'unknown'}" + (f":{code}" if code else ""))
|
|
972
|
+
return "|".join(parts)
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _aggregate_scope_range(state: SourceDashboardState) -> dict | None:
|
|
976
|
+
scope = getattr(state, "aggregate_scope", None)
|
|
977
|
+
if not isinstance(scope, Mapping):
|
|
978
|
+
return None
|
|
979
|
+
published = scope.get("range")
|
|
980
|
+
if not isinstance(published, Mapping):
|
|
981
|
+
return None
|
|
982
|
+
return aggregate_range(published.get("start_at"), published.get("end_at"))
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def _aggregate_fold_failed(state: SourceDashboardState, name: str) -> bool:
|
|
986
|
+
scope = getattr(state, "aggregate_scope", None)
|
|
987
|
+
if not isinstance(scope, Mapping):
|
|
988
|
+
return False
|
|
989
|
+
entry = scope.get(name)
|
|
990
|
+
return isinstance(entry, Mapping) and entry.get("state") != "ok"
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def _aggregate_qualifications(
|
|
994
|
+
claude: SourceDashboardState, codex: SourceDashboardState, name: str,
|
|
995
|
+
) -> list[dict]:
|
|
996
|
+
"""Notes that qualify a PUBLISHED aggregate. Empty means omit the key."""
|
|
997
|
+
qualifications: list[dict] = []
|
|
998
|
+
for provider, state in (("claude", claude), ("codex", codex)):
|
|
999
|
+
for warning in state.warnings:
|
|
1000
|
+
mapped = _AGGREGATE_QUALIFYING_WARNINGS.get(warning.code)
|
|
1001
|
+
if mapped is not None and mapped[1] == name:
|
|
1002
|
+
qualifications.append(
|
|
1003
|
+
{"code": mapped[0], "provider": provider},
|
|
1004
|
+
)
|
|
1005
|
+
return qualifications
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def compose_all_aggregates(
|
|
1009
|
+
claude: SourceDashboardState, codex: SourceDashboardState,
|
|
1010
|
+
) -> dict:
|
|
1011
|
+
"""The single public ``sources.all.data.aggregates`` object (§3.5.1).
|
|
1012
|
+
|
|
1013
|
+
The outcome carries STATE AND REASON only; the rows live on the provider
|
|
1014
|
+
domains and the client composes them. That is what keeps exactly one public
|
|
1015
|
+
copy of the range and one public copy of the rows.
|
|
1016
|
+
|
|
1017
|
+
Every cause is evaluated per aggregate, so a Projects fold failure cannot
|
|
1018
|
+
withhold Daily.
|
|
1019
|
+
"""
|
|
1020
|
+
pairs: tuple[tuple[PhysicalSource, SourceDashboardState], ...] = (
|
|
1021
|
+
("claude", claude), ("codex", codex),
|
|
1022
|
+
)
|
|
1023
|
+
coherent = [
|
|
1024
|
+
(provider, state) for provider, state in pairs
|
|
1025
|
+
if _coherent_provider(state)
|
|
1026
|
+
]
|
|
1027
|
+
ranges = {
|
|
1028
|
+
provider: _aggregate_scope_range(state) for provider, state in coherent
|
|
1029
|
+
}
|
|
1030
|
+
resolved = [value for value in ranges.values() if value is not None]
|
|
1031
|
+
starts = {value["start_at"] for value in resolved}
|
|
1032
|
+
|
|
1033
|
+
shared: list[tuple[int, int, str, PhysicalSource | None]] = []
|
|
1034
|
+
if coherent and len(resolved) != len(coherent):
|
|
1035
|
+
# A coherent provider whose rows are not bounded by a known range.
|
|
1036
|
+
shared.append((_AGGREGATE_CAUSE_RANK["range_unresolved"], 0,
|
|
1037
|
+
"range_unresolved", None))
|
|
1038
|
+
for rank_provider, (provider, state) in enumerate(pairs):
|
|
1039
|
+
if state.availability == "unavailable":
|
|
1040
|
+
shared.append((_AGGREGATE_CAUSE_RANK["provider_unavailable"],
|
|
1041
|
+
rank_provider, "provider_unavailable", provider))
|
|
1042
|
+
for rank_provider, (provider, state) in enumerate(pairs):
|
|
1043
|
+
if state.availability != "unavailable" and not _coherent_provider(state):
|
|
1044
|
+
shared.append((_AGGREGATE_CAUSE_RANK["provider_incoherent"],
|
|
1045
|
+
rank_provider, "provider_incoherent", provider))
|
|
1046
|
+
if len(starts) > 1:
|
|
1047
|
+
shared.append((_AGGREGATE_CAUSE_RANK["retained_range_mismatch"], 0,
|
|
1048
|
+
"retained_range_mismatch", None))
|
|
1049
|
+
|
|
1050
|
+
published_range: dict | None = None
|
|
1051
|
+
if coherent and len(resolved) == len(coherent) and len(starts) == 1:
|
|
1052
|
+
# Published only when EVERY coherent provider supplied a range and they
|
|
1053
|
+
# agree. Publishing one leg's range while the other's is unresolved or
|
|
1054
|
+
# different would state a span the composed rows do not cover.
|
|
1055
|
+
#
|
|
1056
|
+
# A reused provider provably has no new accounting rows — its physical
|
|
1057
|
+
# signature is part of the version that made the reuse legal — so the
|
|
1058
|
+
# later of the two ends is the instant BOTH legs are complete to.
|
|
1059
|
+
published_range = {
|
|
1060
|
+
**resolved[0],
|
|
1061
|
+
"end_at": max(value["end_at"] for value in resolved),
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
aggregates: dict = {"range": published_range}
|
|
1065
|
+
for name in AGGREGATE_NAMES:
|
|
1066
|
+
causes = list(shared)
|
|
1067
|
+
if _aggregate_fold_failed(claude, name):
|
|
1068
|
+
causes.append((_AGGREGATE_CAUSE_RANK["claude_fold_failed"], 0,
|
|
1069
|
+
"claude_fold_failed", "claude"))
|
|
1070
|
+
if causes:
|
|
1071
|
+
_rank, _provider_rank, code, provider = min(
|
|
1072
|
+
causes, key=lambda cause: (cause[0], cause[1]),
|
|
1073
|
+
)
|
|
1074
|
+
aggregates[name] = {
|
|
1075
|
+
"state": "withheld",
|
|
1076
|
+
"code": code,
|
|
1077
|
+
**({"provider": provider} if provider is not None else {}),
|
|
1078
|
+
}
|
|
1079
|
+
continue
|
|
1080
|
+
qualifications = _aggregate_qualifications(claude, codex, name)
|
|
1081
|
+
aggregates[name] = {
|
|
1082
|
+
"state": "available",
|
|
1083
|
+
**({"qualifications": qualifications} if qualifications else {}),
|
|
1084
|
+
}
|
|
1085
|
+
return aggregates
|
|
1086
|
+
|
|
1087
|
+
|
|
725
1088
|
def _combined_alert_rows(
|
|
726
1089
|
claude: SourceDashboardState,
|
|
727
1090
|
codex: SourceDashboardState,
|
|
@@ -739,13 +1102,23 @@ def _combined_alert_rows(
|
|
|
739
1102
|
if not isinstance(row, Mapping) or row.get("source") != source:
|
|
740
1103
|
continue
|
|
741
1104
|
ordered.append(row)
|
|
1105
|
+
|
|
1106
|
+
def _instant(row: Mapping[str, object]) -> str:
|
|
1107
|
+
try:
|
|
1108
|
+
return canonical_alerted_at(row.get("alerted_at"))
|
|
1109
|
+
except ValueError as exc:
|
|
1110
|
+
identity = row.get("id") if row.get("id") is not None else row.get("key")
|
|
1111
|
+
raise ValueError(
|
|
1112
|
+
f"{row.get('source')!r} alert row {identity!r}: {exc}"
|
|
1113
|
+
) from exc
|
|
1114
|
+
|
|
1115
|
+
# #556 S3 §2.5: composition is the chokepoint. The previous sort keyed on
|
|
1116
|
+
# `created_at`, which the Claude projection never wrote, so every Claude
|
|
1117
|
+
# row collapsed to "" and sorted last. Validating here means a future leg,
|
|
1118
|
+
# axis or provider that omits the canonical instant fails visibly instead.
|
|
742
1119
|
# Python's stable sort preserves declared source order, then each source's
|
|
743
|
-
# native order, when
|
|
744
|
-
return tuple(sorted(
|
|
745
|
-
ordered,
|
|
746
|
-
key=lambda row: str(row.get("created_at") or ""),
|
|
747
|
-
reverse=True,
|
|
748
|
-
))
|
|
1120
|
+
# native order, when firing instants tie.
|
|
1121
|
+
return tuple(sorted(ordered, key=_instant, reverse=True))
|
|
749
1122
|
|
|
750
1123
|
|
|
751
1124
|
def compose_all_state(
|
|
@@ -756,6 +1129,11 @@ def compose_all_state(
|
|
|
756
1129
|
if claude.source != "claude" or codex.source != "codex":
|
|
757
1130
|
raise ValueError("all composition requires Claude and Codex provider states")
|
|
758
1131
|
combined, combined_unavailable = _combined_outcome(claude, codex)
|
|
1132
|
+
aggregates = compose_all_aggregates(claude, codex)
|
|
1133
|
+
# Computed ONCE: the same ordered union is hashed into the version below
|
|
1134
|
+
# and published in `data` further down, so the identity and the rows can
|
|
1135
|
+
# never describe different orderings.
|
|
1136
|
+
combined_alerts = _combined_alert_rows(claude, codex)
|
|
759
1137
|
providers_coherent = _coherent_provider(claude) and _coherent_provider(codex)
|
|
760
1138
|
if providers_coherent:
|
|
761
1139
|
availability: Availability = (
|
|
@@ -790,6 +1168,20 @@ def compose_all_state(
|
|
|
790
1168
|
# `combined is not None` would leave materially different All
|
|
791
1169
|
# states sharing one `data_version` (invariant 6).
|
|
792
1170
|
combined, combined_unavailable,
|
|
1171
|
+
# #556 S2 §3.6: the COMPLETE `AllAggregates` value, not merely the
|
|
1172
|
+
# range. A failed and a successful fold over the same database
|
|
1173
|
+
# signature and the same bounds publish different rows, so hashing
|
|
1174
|
+
# only the range would leave them sharing one `data_version`.
|
|
1175
|
+
aggregates,
|
|
1176
|
+
# #556 S3 §2.9: the ordered alert union's identity. Without it the
|
|
1177
|
+
# version material omitted alerts entirely, so two materially
|
|
1178
|
+
# different unions — a different order, a different membership, a
|
|
1179
|
+
# newly fired alert — collided on one `data_version`.
|
|
1180
|
+
[
|
|
1181
|
+
(str(row.get("source")), str(row.get("id") or row.get("key")),
|
|
1182
|
+
canonical_alerted_at(row.get("alerted_at")))
|
|
1183
|
+
for row in combined_alerts
|
|
1184
|
+
],
|
|
793
1185
|
],
|
|
794
1186
|
separators=(",", ":"),
|
|
795
1187
|
sort_keys=True,
|
|
@@ -823,7 +1215,11 @@ def compose_all_state(
|
|
|
823
1215
|
# Emitted iff the figure is withheld; omitted-when-inapplicable.
|
|
824
1216
|
**({"combined_unavailable": combined_unavailable}
|
|
825
1217
|
if combined is None else {}),
|
|
826
|
-
"alerts": {"rows":
|
|
1218
|
+
"alerts": {"rows": combined_alerts},
|
|
1219
|
+
# #556 S2 §3.5.1: the ONE public copy of the shared range and of
|
|
1220
|
+
# both aggregate outcomes. The rows stay on the provider domains
|
|
1221
|
+
# under `providers` below, so nothing is published twice.
|
|
1222
|
+
"aggregates": aggregates,
|
|
827
1223
|
"providers": {
|
|
828
1224
|
"claude": claude.data,
|
|
829
1225
|
"codex": codex.data,
|
|
@@ -907,25 +1303,78 @@ _CODEX_STATS_DIGEST_RELATIONS: tuple[tuple[str, str], ...] = (
|
|
|
907
1303
|
)
|
|
908
1304
|
|
|
909
1305
|
|
|
910
|
-
|
|
911
|
-
|
|
1306
|
+
# #556 S3 §2.9. The Claude twin of the relation table above, over the five
|
|
1307
|
+
# alert tables the Claude projection reads. `codex_stats_digest` already covers
|
|
1308
|
+
# Codex's alert rows; Claude's were covered by nothing, and the dispatch
|
|
1309
|
+
# signature's stats legs are `MAX(id)` over the two weekly snapshot tables plus
|
|
1310
|
+
# the reset-event change signal — none of which a milestone INSERT or an
|
|
1311
|
+
# `alerted_at` arming UPDATE touches. A fired Claude alert could therefore
|
|
1312
|
+
# leave the idle path short-circuiting on a retained prior bundle. Measured
|
|
1313
|
+
# before the leg was added: inserting a `budget_milestones` row with
|
|
1314
|
+
# `vendor='claude'` left every existing leg byte-identical.
|
|
1315
|
+
#
|
|
1316
|
+
# Only the alert-bearing columns are selected, for the same reason the Codex
|
|
1317
|
+
# table selects a fixed list: the digest is an identity over what the surface
|
|
1318
|
+
# publishes, not a checksum of the table file.
|
|
1319
|
+
_CLAUDE_STATS_DIGEST_RELATIONS: tuple[tuple[str, str], ...] = (
|
|
1320
|
+
(
|
|
1321
|
+
"percent_milestones",
|
|
1322
|
+
"SELECT week_start_date, percent_threshold, captured_at_utc, "
|
|
1323
|
+
"cumulative_cost_usd, reset_event_id, account_key, alerted_at "
|
|
1324
|
+
"FROM percent_milestones WHERE alerted_at IS NOT NULL "
|
|
1325
|
+
"ORDER BY week_start_date, percent_threshold, reset_event_id, account_key, "
|
|
1326
|
+
"captured_at_utc, cumulative_cost_usd, alerted_at",
|
|
1327
|
+
),
|
|
1328
|
+
(
|
|
1329
|
+
"five_hour_milestones",
|
|
1330
|
+
"SELECT five_hour_window_key, percent_threshold, captured_at_utc, "
|
|
1331
|
+
"block_cost_usd, reset_event_id, account_key, alerted_at "
|
|
1332
|
+
"FROM five_hour_milestones WHERE alerted_at IS NOT NULL "
|
|
1333
|
+
"ORDER BY five_hour_window_key, percent_threshold, reset_event_id, account_key, "
|
|
1334
|
+
"captured_at_utc, block_cost_usd, alerted_at",
|
|
1335
|
+
),
|
|
1336
|
+
(
|
|
1337
|
+
"budget_milestones",
|
|
1338
|
+
"SELECT vendor, period_start_at, period, threshold, budget_usd, spent_usd, "
|
|
1339
|
+
"consumption_pct, crossed_at_utc, account_key, alerted_at "
|
|
1340
|
+
"FROM budget_milestones WHERE vendor <> 'codex' AND alerted_at IS NOT NULL "
|
|
1341
|
+
"ORDER BY vendor, period_start_at, period, threshold, account_key, "
|
|
1342
|
+
"budget_usd, spent_usd, consumption_pct, crossed_at_utc, alerted_at",
|
|
1343
|
+
),
|
|
1344
|
+
(
|
|
1345
|
+
"projected_milestones",
|
|
1346
|
+
"SELECT week_start_at, period, metric, threshold, projected_value, denominator, "
|
|
1347
|
+
"crossed_at_utc, account_key, alerted_at FROM projected_milestones "
|
|
1348
|
+
"WHERE metric <> 'codex_budget_usd' AND alerted_at IS NOT NULL "
|
|
1349
|
+
"ORDER BY week_start_at, period, metric, threshold, account_key, "
|
|
1350
|
+
"projected_value, denominator, crossed_at_utc, alerted_at",
|
|
1351
|
+
),
|
|
1352
|
+
(
|
|
1353
|
+
"project_budget_milestones",
|
|
1354
|
+
"SELECT week_start_at, project_key, threshold, budget_usd, spent_usd, "
|
|
1355
|
+
"consumption_pct, crossed_at_utc, account_key, alerted_at "
|
|
1356
|
+
"FROM project_budget_milestones WHERE alerted_at IS NOT NULL "
|
|
1357
|
+
"ORDER BY week_start_at, project_key, threshold, account_key, "
|
|
1358
|
+
"budget_usd, spent_usd, consumption_pct, crossed_at_utc, alerted_at",
|
|
1359
|
+
),
|
|
1360
|
+
)
|
|
912
1361
|
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
for _name, query in
|
|
1362
|
+
|
|
1363
|
+
def _stats_relations_digest(
|
|
1364
|
+
stats_conn: sqlite3.Connection,
|
|
1365
|
+
relations: tuple[tuple[str, str], ...],
|
|
1366
|
+
) -> str:
|
|
1367
|
+
relation_rows: list[list[list[object]]] = []
|
|
1368
|
+
for _name, query in relations:
|
|
920
1369
|
try:
|
|
921
1370
|
rows = stats_conn.execute(query).fetchall()
|
|
922
1371
|
except sqlite3.OperationalError as exc:
|
|
923
1372
|
if "no such table" not in str(exc).lower():
|
|
924
1373
|
raise
|
|
925
1374
|
rows = ()
|
|
926
|
-
|
|
1375
|
+
relation_rows.append([list(row) for row in rows])
|
|
927
1376
|
canonical = json.dumps(
|
|
928
|
-
|
|
1377
|
+
relation_rows,
|
|
929
1378
|
allow_nan=False,
|
|
930
1379
|
ensure_ascii=False,
|
|
931
1380
|
separators=(",", ":"),
|
|
@@ -933,6 +1382,26 @@ def codex_stats_digest(stats_conn: sqlite3.Connection) -> str:
|
|
|
933
1382
|
return hashlib.sha256(canonical).hexdigest()
|
|
934
1383
|
|
|
935
1384
|
|
|
1385
|
+
def claude_stats_digest(stats_conn: sqlite3.Connection) -> str:
|
|
1386
|
+
"""Hash the Claude-owned alert relations, canonically ordered.
|
|
1387
|
+
|
|
1388
|
+
A missing table is an empty relation, so an older or fresh stats database
|
|
1389
|
+
still has a stable digest — the same posture ``codex_stats_digest`` takes.
|
|
1390
|
+
"""
|
|
1391
|
+
return _stats_relations_digest(stats_conn, _CLAUDE_STATS_DIGEST_RELATIONS)
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
def codex_stats_digest(stats_conn: sqlite3.Connection) -> str:
|
|
1395
|
+
"""Hash exact, canonically ordered Codex-derived stats relations.
|
|
1396
|
+
|
|
1397
|
+
A missing table is an empty relation so an older/fresh stats database has a
|
|
1398
|
+
stable digest. Other SQLite failures remain visible to the builder, which
|
|
1399
|
+
then follows the source all-or-prior failure matrix instead of publishing a
|
|
1400
|
+
guessed identity.
|
|
1401
|
+
"""
|
|
1402
|
+
return _stats_relations_digest(stats_conn, _CODEX_STATS_DIGEST_RELATIONS)
|
|
1403
|
+
|
|
1404
|
+
|
|
936
1405
|
def assess_codex_projection_coherence(
|
|
937
1406
|
*,
|
|
938
1407
|
active_root_keys: tuple[str, ...] | list[str] | set[str],
|