cctally 1.97.0 → 1.98.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/bin/_cctally_dashboard.py +595 -46
- package/bin/_cctally_dashboard_envelope.py +43 -23
- package/bin/_cctally_dashboard_sources.py +79 -13
- package/bin/_cctally_tui.py +313 -30
- package/bin/_lib_alert_axes.py +8 -3
- package/bin/_lib_dashboard_sources.py +463 -19
- package/bin/_lib_snapshot_cache.py +12 -0
- package/bin/cctally +13 -0
- package/dashboard/static/assets/index-CC8TTZUC.css +1 -0
- package/dashboard/static/assets/index-CChXFhs_.js +97 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-BSESoPIK.css +0 -1
- package/dashboard/static/assets/index-DgsMz5hA.js +0 -97
|
@@ -45,7 +45,30 @@ 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
|
+
SOURCE_SCHEMA_VERSION = 7
|
|
49
72
|
DEFAULT_SOURCE = "claude"
|
|
50
73
|
SOURCE_ORDER = ("claude", "codex", "all")
|
|
51
74
|
SOURCE_FRESHNESS_DOMAINS = ("hero", "quota", "sessions")
|
|
@@ -161,6 +184,24 @@ class SourceDashboardState:
|
|
|
161
184
|
# publishes only ``data``, then the HTTP/SSE envelope layer injects a label
|
|
162
185
|
# into its request-local copies when that request's transcript gate is open.
|
|
163
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
|
|
164
205
|
|
|
165
206
|
def __post_init__(self) -> None:
|
|
166
207
|
validate_dashboard_selection(self.source)
|
|
@@ -206,6 +247,10 @@ class SourceDashboardState:
|
|
|
206
247
|
object.__setattr__(self, "clock_data", _freeze(self.clock_data))
|
|
207
248
|
if self.account_scope is not None:
|
|
208
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
|
+
)
|
|
209
254
|
if self.private_session_labels is not None:
|
|
210
255
|
private_session_labels = {
|
|
211
256
|
_nonempty_string(key, "private session label key"):
|
|
@@ -325,6 +370,11 @@ def degrade_source_state(
|
|
|
325
370
|
# `account_scope_unresolved` on an install whose count read fine.
|
|
326
371
|
account_scope=prior.account_scope,
|
|
327
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,
|
|
328
378
|
)
|
|
329
379
|
|
|
330
380
|
|
|
@@ -521,6 +571,44 @@ def _period_instant(value: object) -> str | None:
|
|
|
521
571
|
return parsed.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z")
|
|
522
572
|
|
|
523
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
|
+
|
|
524
612
|
def _leg_period(
|
|
525
613
|
provider: PhysicalSource, hero: Mapping[str, object] | None,
|
|
526
614
|
) -> Mapping[str, object] | None:
|
|
@@ -722,6 +810,256 @@ def _combined_outcome(
|
|
|
722
810
|
}, None
|
|
723
811
|
|
|
724
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:
|
|
852
|
+
return None
|
|
853
|
+
return {
|
|
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
|
|
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
|
|
1061
|
+
|
|
1062
|
+
|
|
725
1063
|
def _combined_alert_rows(
|
|
726
1064
|
claude: SourceDashboardState,
|
|
727
1065
|
codex: SourceDashboardState,
|
|
@@ -739,13 +1077,23 @@ def _combined_alert_rows(
|
|
|
739
1077
|
if not isinstance(row, Mapping) or row.get("source") != source:
|
|
740
1078
|
continue
|
|
741
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.
|
|
742
1094
|
# 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
|
-
))
|
|
1095
|
+
# native order, when firing instants tie.
|
|
1096
|
+
return tuple(sorted(ordered, key=_instant, reverse=True))
|
|
749
1097
|
|
|
750
1098
|
|
|
751
1099
|
def compose_all_state(
|
|
@@ -756,6 +1104,11 @@ def compose_all_state(
|
|
|
756
1104
|
if claude.source != "claude" or codex.source != "codex":
|
|
757
1105
|
raise ValueError("all composition requires Claude and Codex provider states")
|
|
758
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)
|
|
759
1112
|
providers_coherent = _coherent_provider(claude) and _coherent_provider(codex)
|
|
760
1113
|
if providers_coherent:
|
|
761
1114
|
availability: Availability = (
|
|
@@ -790,6 +1143,20 @@ def compose_all_state(
|
|
|
790
1143
|
# `combined is not None` would leave materially different All
|
|
791
1144
|
# states sharing one `data_version` (invariant 6).
|
|
792
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
|
+
],
|
|
793
1160
|
],
|
|
794
1161
|
separators=(",", ":"),
|
|
795
1162
|
sort_keys=True,
|
|
@@ -823,7 +1190,11 @@ def compose_all_state(
|
|
|
823
1190
|
# Emitted iff the figure is withheld; omitted-when-inapplicable.
|
|
824
1191
|
**({"combined_unavailable": combined_unavailable}
|
|
825
1192
|
if combined is None else {}),
|
|
826
|
-
"alerts": {"rows":
|
|
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,
|
|
827
1198
|
"providers": {
|
|
828
1199
|
"claude": claude.data,
|
|
829
1200
|
"codex": codex.data,
|
|
@@ -907,25 +1278,78 @@ _CODEX_STATS_DIGEST_RELATIONS: tuple[tuple[str, str], ...] = (
|
|
|
907
1278
|
)
|
|
908
1279
|
|
|
909
1280
|
|
|
910
|
-
|
|
911
|
-
|
|
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
|
+
)
|
|
912
1336
|
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
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:
|
|
920
1344
|
try:
|
|
921
1345
|
rows = stats_conn.execute(query).fetchall()
|
|
922
1346
|
except sqlite3.OperationalError as exc:
|
|
923
1347
|
if "no such table" not in str(exc).lower():
|
|
924
1348
|
raise
|
|
925
1349
|
rows = ()
|
|
926
|
-
|
|
1350
|
+
relation_rows.append([list(row) for row in rows])
|
|
927
1351
|
canonical = json.dumps(
|
|
928
|
-
|
|
1352
|
+
relation_rows,
|
|
929
1353
|
allow_nan=False,
|
|
930
1354
|
ensure_ascii=False,
|
|
931
1355
|
separators=(",", ":"),
|
|
@@ -933,6 +1357,26 @@ def codex_stats_digest(stats_conn: sqlite3.Connection) -> str:
|
|
|
933
1357
|
return hashlib.sha256(canonical).hexdigest()
|
|
934
1358
|
|
|
935
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
|
+
|
|
936
1380
|
def assess_codex_projection_coherence(
|
|
937
1381
|
*,
|
|
938
1382
|
active_root_keys: tuple[str, ...] | list[str] | set[str],
|
|
@@ -106,6 +106,16 @@ class SnapshotSignature(NamedTuple):
|
|
|
106
106
|
# Codex mutation happens along. Empty when nothing is owed (the writer
|
|
107
107
|
# DELETEs the key at zero), so a fully-ingested store is byte-neutral.
|
|
108
108
|
codex_ingest_backlog_sig: str = ""
|
|
109
|
+
# #556 S3 §2.9: the Claude twin of `codex_stats_digest`. The stats legs
|
|
110
|
+
# above are `MAX(id)` over the two weekly snapshot tables plus the
|
|
111
|
+
# reset-event change signal, and a Claude milestone INSERT or an
|
|
112
|
+
# `alerted_at` arming UPDATE touches none of them — measured: inserting a
|
|
113
|
+
# `budget_milestones` row with `vendor='claude'` left every other leg
|
|
114
|
+
# byte-identical. Without this leg a fired Claude alert could leave the
|
|
115
|
+
# idle path short-circuiting on a retained prior bundle. Unlike the two
|
|
116
|
+
# legs above, this one is a digest and is never empty: a store with no
|
|
117
|
+
# armed Claude alert carries a constant hash, not the empty string.
|
|
118
|
+
claude_stats_digest: str = ""
|
|
109
119
|
|
|
110
120
|
|
|
111
121
|
def _max_id(conn: sqlite3.Connection, table: str) -> int:
|
|
@@ -221,6 +231,7 @@ def compute_signature(
|
|
|
221
231
|
generation: int,
|
|
222
232
|
codex_stats_digest: str = "",
|
|
223
233
|
accounts_digest: str = "",
|
|
234
|
+
claude_stats_digest: str = "",
|
|
224
235
|
) -> SnapshotSignature:
|
|
225
236
|
"""Composite data-version signature across cache.db + stats.db (spec §3).
|
|
226
237
|
|
|
@@ -243,6 +254,7 @@ def compute_signature(
|
|
|
243
254
|
codex_stats_digest=str(codex_stats_digest),
|
|
244
255
|
accounts_digest=str(accounts_digest),
|
|
245
256
|
codex_ingest_backlog_sig=_codex_ingest_backlog_sig(cache_conn),
|
|
257
|
+
claude_stats_digest=str(claude_stats_digest),
|
|
246
258
|
)
|
|
247
259
|
|
|
248
260
|
|
package/bin/cctally
CHANGED
|
@@ -1372,6 +1372,19 @@ _dashboard_build_blocks_panel = _cctally_dashboard._dashboard_build_blocks_panel
|
|
|
1372
1372
|
_dashboard_build_blocks_view = _cctally_dashboard._dashboard_build_blocks_view
|
|
1373
1373
|
_dashboard_build_daily_panel = _cctally_dashboard._dashboard_build_daily_panel
|
|
1374
1374
|
_empty_dashboard_snapshot = _cctally_dashboard._empty_dashboard_snapshot
|
|
1375
|
+
# #556 S2: the shared cross-provider aggregate range and the two range-native
|
|
1376
|
+
# folds it feeds. Re-exported so the source-bundle builder in
|
|
1377
|
+
# `_cctally_tui._tui_build_source_bundle` reaches them through `_cctally()`,
|
|
1378
|
+
# which is also the seam its fold-failure tests monkeypatch.
|
|
1379
|
+
resolve_shared_range = _cctally_dashboard.resolve_shared_range
|
|
1380
|
+
iter_shared_range_entries = _cctally_dashboard.iter_shared_range_entries
|
|
1381
|
+
fold_projects_over_range = _cctally_dashboard.fold_projects_over_range
|
|
1382
|
+
fold_daily_over_range = _cctally_dashboard.fold_daily_over_range
|
|
1383
|
+
materialise_daily_calendar = _cctally_dashboard.materialise_daily_calendar
|
|
1384
|
+
build_daily_aggregate_rows = _cctally_dashboard.build_daily_aggregate_rows
|
|
1385
|
+
daily_panel_row_to_wire = _cctally_dashboard.daily_panel_row_to_wire
|
|
1386
|
+
build_project_aggregate_rows = _cctally_dashboard.build_project_aggregate_rows
|
|
1387
|
+
legacy_project_labels = _cctally_dashboard.legacy_project_labels
|
|
1375
1388
|
# _iso_z is NOT bound here anymore — the former dashboard-then-forecast
|
|
1376
1389
|
# double-bind collapses to a single canonical bind below (#279 S6 W4).
|
|
1377
1390
|
# Projects panel + modal (spec 2026-05-19-projects-panel-design.md).
|