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
|
@@ -55,6 +55,8 @@ from _cctally_core import (
|
|
|
55
55
|
)
|
|
56
56
|
from _lib_dashboard_sources import (
|
|
57
57
|
SOURCE_SCHEMA_VERSION,
|
|
58
|
+
canonical_alerted_at as _canonical_alerted_at,
|
|
59
|
+
canonical_alerted_at_sql as _canonical_alerted_at_sql,
|
|
58
60
|
dashboard_resource_key as _dashboard_resource_key,
|
|
59
61
|
)
|
|
60
62
|
from _lib_display_tz import _compute_display_block, format_display_dt
|
|
@@ -361,6 +363,16 @@ def _alert_account_resolver(conn: sqlite3.Connection):
|
|
|
361
363
|
return fields
|
|
362
364
|
|
|
363
365
|
|
|
366
|
+
# #556 S3 §2.3. Every per-axis mapper orders by the CANONICAL firing instant
|
|
367
|
+
# before its ``LIMIT``, because that limit decides which rows exist downstream
|
|
368
|
+
# and a row excluded there is unrecoverable. Two spellings of one instant
|
|
369
|
+
# compare equal here; a nonzero offset — which the ``all-combined`` fixture
|
|
370
|
+
# stores deliberately — orders by its true instant rather than by its
|
|
371
|
+
# local-time text.
|
|
372
|
+
_CANON_ALERTED_AT = _canonical_alerted_at_sql()
|
|
373
|
+
_CANON_ALERTED_AT_M = _canonical_alerted_at_sql("m.alerted_at")
|
|
374
|
+
|
|
375
|
+
|
|
364
376
|
def _envelope_rows_weekly(
|
|
365
377
|
conn, descriptor, limit, severity_for, account_fields,
|
|
366
378
|
) -> list[dict]:
|
|
@@ -377,7 +389,7 @@ def _envelope_rows_weekly(
|
|
|
377
389
|
alerted_at, cumulative_cost_usd, reset_event_id, account_key
|
|
378
390
|
FROM {descriptor.milestone_table}
|
|
379
391
|
WHERE alerted_at IS NOT NULL
|
|
380
|
-
ORDER BY
|
|
392
|
+
ORDER BY {_CANON_ALERTED_AT} DESC
|
|
381
393
|
LIMIT ?
|
|
382
394
|
""",
|
|
383
395
|
(limit,),
|
|
@@ -431,7 +443,7 @@ def _envelope_rows_five_hour(
|
|
|
431
443
|
ON b.five_hour_window_key = m.five_hour_window_key
|
|
432
444
|
AND b.account_key = m.account_key
|
|
433
445
|
WHERE m.alerted_at IS NOT NULL
|
|
434
|
-
ORDER BY
|
|
446
|
+
ORDER BY {_CANON_ALERTED_AT_M} DESC
|
|
435
447
|
LIMIT ?
|
|
436
448
|
""",
|
|
437
449
|
(limit,),
|
|
@@ -506,7 +518,7 @@ def _envelope_rows_budget_family(
|
|
|
506
518
|
budget_usd, spent_usd, consumption_pct, account_key
|
|
507
519
|
FROM {descriptor.milestone_table}
|
|
508
520
|
WHERE vendor = ? AND alerted_at IS NOT NULL
|
|
509
|
-
ORDER BY
|
|
521
|
+
ORDER BY {_CANON_ALERTED_AT} DESC
|
|
510
522
|
LIMIT ?
|
|
511
523
|
""",
|
|
512
524
|
(default_noun, vendor, limit),
|
|
@@ -572,7 +584,7 @@ def _envelope_rows_projected(
|
|
|
572
584
|
denominator, crossed_at_utc, alerted_at, account_key
|
|
573
585
|
FROM {descriptor.milestone_table}
|
|
574
586
|
WHERE alerted_at IS NOT NULL
|
|
575
|
-
ORDER BY
|
|
587
|
+
ORDER BY {_CANON_ALERTED_AT} DESC
|
|
576
588
|
LIMIT ?
|
|
577
589
|
""",
|
|
578
590
|
(limit,),
|
|
@@ -629,7 +641,7 @@ def _envelope_rows_project_budget(
|
|
|
629
641
|
consumption_pct, crossed_at_utc, alerted_at, account_key
|
|
630
642
|
FROM {descriptor.milestone_table}
|
|
631
643
|
WHERE alerted_at IS NOT NULL
|
|
632
|
-
ORDER BY
|
|
644
|
+
ORDER BY {_CANON_ALERTED_AT} DESC
|
|
633
645
|
LIMIT ?
|
|
634
646
|
""",
|
|
635
647
|
(limit,),
|
|
@@ -858,11 +870,29 @@ def _build_alerts_envelope_array(
|
|
|
858
870
|
))
|
|
859
871
|
|
|
860
872
|
# Python's list.sort is stable. When two alerts share the same
|
|
861
|
-
# `alerted_at`
|
|
862
|
-
#
|
|
873
|
+
# `alerted_at` instant (rare; multiple axes firing within the same
|
|
874
|
+
# second), the union order (weekly, then 5h, then budget, then
|
|
863
875
|
# projected) determines the tiebreaker — no extra deterministic key is
|
|
864
876
|
# added because the spec doesn't require one.
|
|
865
|
-
|
|
877
|
+
#
|
|
878
|
+
# #556 S3 §2.3: the slice below is a truncation, so this re-sort compares
|
|
879
|
+
# instants rather than spellings. Each axis reaches here already ordered by
|
|
880
|
+
# its own canonical SQL expression, but the axes are merged raw, so two
|
|
881
|
+
# differently-spelled instants meet for the first time right here.
|
|
882
|
+
def _order_key(alert):
|
|
883
|
+
# A raise here empties the whole legacy array AND the Claude projection
|
|
884
|
+
# derived from it, so the diagnostic must name the offending row the way
|
|
885
|
+
# `_combined_alert_rows` does — otherwise the operator sees an empty
|
|
886
|
+
# panel and a message that identifies nothing.
|
|
887
|
+
try:
|
|
888
|
+
return _canonical_alerted_at(alert["alerted_at"])
|
|
889
|
+
except (KeyError, ValueError) as exc:
|
|
890
|
+
identity = alert.get("key") or alert.get("id")
|
|
891
|
+
raise ValueError(
|
|
892
|
+
f"alert row {identity!r} (axis {alert.get('axis')!r}): {exc}"
|
|
893
|
+
) from exc
|
|
894
|
+
|
|
895
|
+
out.sort(key=_order_key, reverse=True)
|
|
866
896
|
return out[:limit]
|
|
867
897
|
|
|
868
898
|
|
|
@@ -1188,6 +1218,7 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
1188
1218
|
|
|
1189
1219
|
week_lbl: "str | None" = None
|
|
1190
1220
|
reset_at_utc: "dt.datetime | None" = None
|
|
1221
|
+
week_start_at_utc: "dt.datetime | None" = None
|
|
1191
1222
|
if cw is not None:
|
|
1192
1223
|
ws = getattr(cw, "week_start_at", None)
|
|
1193
1224
|
we = getattr(cw, "week_end_at", None)
|
|
@@ -1203,6 +1234,7 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
1203
1234
|
elif ws is not None:
|
|
1204
1235
|
week_lbl = format_display_dt(ws, resolved_tz_obj, fmt='%b %d', suffix=False)
|
|
1205
1236
|
reset_at_utc = we
|
|
1237
|
+
week_start_at_utc = ws
|
|
1206
1238
|
|
|
1207
1239
|
# Header forecast_pct should match the projection that drove the
|
|
1208
1240
|
# verdict pill next to it. The View (issue #57) carries the
|
|
@@ -1268,21 +1300,11 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
1268
1300
|
}
|
|
1269
1301
|
|
|
1270
1302
|
def _daily_row_to_dict(r: "DailyPanelRow") -> dict:
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
"intensity_bucket": r.intensity_bucket,
|
|
1277
|
-
"models": list(r.models),
|
|
1278
|
-
# ---- v2.3 additions ----
|
|
1279
|
-
"input_tokens": r.input_tokens,
|
|
1280
|
-
"output_tokens": r.output_tokens,
|
|
1281
|
-
"cache_creation_tokens": r.cache_creation_tokens,
|
|
1282
|
-
"cache_read_tokens": r.cache_read_tokens,
|
|
1283
|
-
"total_tokens": r.total_tokens,
|
|
1284
|
-
"cache_hit_pct": r.cache_hit_pct,
|
|
1285
|
-
}
|
|
1303
|
+
# #556 S2 §6.3a: one owner for the daily row wire shape. The All-only
|
|
1304
|
+
# `periods.daily_aggregate.rows` sibling publishes the same shape, and
|
|
1305
|
+
# the client reads one shape whichever sibling produced it, so the
|
|
1306
|
+
# dict is built in `_cctally_dashboard` and this renderer delegates.
|
|
1307
|
+
return sys.modules["_cctally_dashboard"].daily_panel_row_to_wire(r)
|
|
1286
1308
|
|
|
1287
1309
|
# Spec §2.7: empty state is `weekly.rows === []`, not `weekly === null`.
|
|
1288
1310
|
# Always emit a `{rows: [...]}` envelope (possibly empty) so the panel
|
|
@@ -1616,7 +1638,19 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
1616
1638
|
None if cw.five_hour_resets_at is None
|
|
1617
1639
|
else max(0, int((cw.five_hour_resets_at - now_utc).total_seconds())),
|
|
1618
1640
|
"spent_usd": cw.spent_usd,
|
|
1641
|
+
# #556 S1 §3.3 — the token half of the same accumulation pass
|
|
1642
|
+
# that produced `spent_usd`. `getattr` keeps legacy fixture
|
|
1643
|
+
# modules that construct `TuiCurrentWeek` without the field
|
|
1644
|
+
# serializing, the same way `five_hour_block` does.
|
|
1645
|
+
"total_tokens": getattr(cw, "total_tokens", 0),
|
|
1619
1646
|
"dollar_per_pct": cw.dollars_per_percent,
|
|
1647
|
+
# #556 S1 §3.5 — the effective cycle start, the companion of
|
|
1648
|
+
# the already-published `reset_at_utc` end. Composition needs
|
|
1649
|
+
# BOTH bounds to label the Claude leg's period, and the source
|
|
1650
|
+
# version needs them to detect a nominal rollover (§3.6).
|
|
1651
|
+
# Effective, not nominal: `_tui_build_current_week` stores this
|
|
1652
|
+
# AFTER `_apply_midweek_reset_override`.
|
|
1653
|
+
"week_start_at": _iso_z(week_start_at_utc),
|
|
1620
1654
|
"reset_at_utc": _iso_z(reset_at_utc),
|
|
1621
1655
|
"reset_in_sec":
|
|
1622
1656
|
None if reset_at_utc is None
|
|
@@ -50,7 +50,6 @@ from _cctally_config import save_config, _load_config_unlocked
|
|
|
50
50
|
from _lib_fmt import stable_sum
|
|
51
51
|
from _lib_pricing import _calculate_entry_cost, claude_usage_dict
|
|
52
52
|
from _lib_five_hour import _canonical_5h_window_key
|
|
53
|
-
from _lib_dashboard_sources import source_domain_freshness
|
|
54
53
|
from _lib_display_tz import _resolve_tz, resolve_display_tz_name
|
|
55
54
|
|
|
56
55
|
|
|
@@ -1783,12 +1782,33 @@ def _build_codex_source_share_snapshot(ls, *, state, panel: str,
|
|
|
1783
1782
|
)
|
|
1784
1783
|
|
|
1785
1784
|
|
|
1785
|
+
def _share_current_week_evidence_is_stale(state) -> bool:
|
|
1786
|
+
"""Whether ONE provider's own current-cycle evidence is stale (#556 §4.7).
|
|
1787
|
+
|
|
1788
|
+
This used to read the shared ``domain_freshness.hero`` axis, which #556 S1
|
|
1789
|
+
repointed to accounting resolvability. The aggregate ``quota`` axis is not
|
|
1790
|
+
the substitute: a stale five-hour row stales it independently of the weekly
|
|
1791
|
+
cycle this note describes. So each provider is read on its own field —
|
|
1792
|
+
Claude's percent-observation label under ``hero.current_week.freshness``,
|
|
1793
|
+
Codex's additive ``hero.cycle_freshness``, which is omitted while fresh.
|
|
1794
|
+
"""
|
|
1795
|
+
data = getattr(state, "data", None)
|
|
1796
|
+
hero = data.get("hero") if isinstance(data, Mapping) else None
|
|
1797
|
+
if not isinstance(hero, Mapping):
|
|
1798
|
+
return False
|
|
1799
|
+
if getattr(state, "source", None) == "claude":
|
|
1800
|
+
current_week = hero.get("current_week")
|
|
1801
|
+
freshness = (
|
|
1802
|
+
current_week.get("freshness")
|
|
1803
|
+
if isinstance(current_week, Mapping) else None
|
|
1804
|
+
)
|
|
1805
|
+
return isinstance(freshness, Mapping) and freshness.get("label") == "stale"
|
|
1806
|
+
return hero.get("cycle_freshness") == "stale"
|
|
1807
|
+
|
|
1808
|
+
|
|
1786
1809
|
def _share_apply_current_week_freshness(snapshot, state, panel: str):
|
|
1787
1810
|
"""Qualify retained current-week actuals with provider-local evidence age."""
|
|
1788
|
-
if (
|
|
1789
|
-
panel != "current-week"
|
|
1790
|
-
or source_domain_freshness(state, "hero") != "stale"
|
|
1791
|
-
):
|
|
1811
|
+
if panel != "current-week" or not _share_current_week_evidence_is_stale(state):
|
|
1792
1812
|
return snapshot
|
|
1793
1813
|
provider = "Claude" if state.source == "claude" else "Codex"
|
|
1794
1814
|
note = (
|
|
@@ -37,6 +37,8 @@ from _lib_dashboard_sources import (
|
|
|
37
37
|
SourceDashboardState,
|
|
38
38
|
SourceDashboardWarning,
|
|
39
39
|
assess_codex_projection_coherence,
|
|
40
|
+
canonical_alerted_at,
|
|
41
|
+
canonical_alerted_at_sql,
|
|
40
42
|
dashboard_resource_key,
|
|
41
43
|
)
|
|
42
44
|
from _lib_quota import (
|
|
@@ -860,6 +862,33 @@ _RESOURCE_ROWS = {
|
|
|
860
862
|
"block": ("quota", "blocks"),
|
|
861
863
|
}
|
|
862
864
|
|
|
865
|
+
# #556 S2: additional collections a resource may ALSO be routed through.
|
|
866
|
+
#
|
|
867
|
+
# The primary above stays the capability gate — its absence is still
|
|
868
|
+
# `SourceCapabilityUnavailable`. These are searched only after the primary
|
|
869
|
+
# misses, and their own absence is an ordinary not-found rather than a
|
|
870
|
+
# capability failure, because a provider legitimately need not publish them (a
|
|
871
|
+
# Codex source has no aggregate sibling, and a Claude source whose bounded fold
|
|
872
|
+
# failed publishes none either).
|
|
873
|
+
#
|
|
874
|
+
# Projects needs one because `projects.rows` is the current SUBSCRIPTION WEEK
|
|
875
|
+
# while `projects.aggregate.rows` is folded over the thirty-day shared range.
|
|
876
|
+
# Without this the aggregate ranking would publish rows the drill-down route
|
|
877
|
+
# answers 404 for — in the committed `all-combined` fixture, four of six.
|
|
878
|
+
_RESOURCE_EXTRA_ROWS: Mapping[str, tuple[tuple[str, ...], ...]] = MappingProxyType({
|
|
879
|
+
"project": (("projects", "aggregate", "rows"),),
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def _rows_at(data: Mapping, path: "tuple[str, ...]") -> "list | tuple":
|
|
884
|
+
"""Read one optional nested rows collection, or an empty tuple."""
|
|
885
|
+
node: object = data
|
|
886
|
+
for step in path:
|
|
887
|
+
if not isinstance(node, Mapping):
|
|
888
|
+
return ()
|
|
889
|
+
node = node.get(step)
|
|
890
|
+
return node if isinstance(node, (list, tuple)) else ()
|
|
891
|
+
|
|
863
892
|
|
|
864
893
|
def _public_copy(value: object) -> object:
|
|
865
894
|
"""Detach a bounded source row from its immutable published state."""
|
|
@@ -910,6 +939,21 @@ def source_detail_lookup(
|
|
|
910
939
|
row for row in rows
|
|
911
940
|
if isinstance(row, Mapping) and row.get("key") == key
|
|
912
941
|
]
|
|
942
|
+
if not key_matches:
|
|
943
|
+
# Only after the primary collection misses, so every key that resolves
|
|
944
|
+
# today keeps resolving to the same row. The account-ownership branch
|
|
945
|
+
# below does NOT see an unchanged candidate set — a key that resolves
|
|
946
|
+
# only here reaches it as a row the primary collection never carried.
|
|
947
|
+
# That leaks nothing, because an aggregate row carries no
|
|
948
|
+
# `account_key` and the ownership check refuses a row it cannot
|
|
949
|
+
# attribute, but the set is genuinely wider than it was.
|
|
950
|
+
for path in _RESOURCE_EXTRA_ROWS.get(resource, ()):
|
|
951
|
+
key_matches = [
|
|
952
|
+
row for row in _rows_at(data, path)
|
|
953
|
+
if isinstance(row, Mapping) and row.get("key") == key
|
|
954
|
+
]
|
|
955
|
+
if key_matches:
|
|
956
|
+
break
|
|
913
957
|
if not key_matches:
|
|
914
958
|
raise SourceResourceNotFound()
|
|
915
959
|
if account is not None:
|
|
@@ -1261,6 +1305,24 @@ def _codex_cache_report_wire(
|
|
|
1261
1305
|
)
|
|
1262
1306
|
|
|
1263
1307
|
|
|
1308
|
+
#: Per-file terminal thread aliases, joined to their first accounting entry.
|
|
1309
|
+
#: The ``(source_root_key, source_path)`` join predicate needs the composite
|
|
1310
|
+
#: ``idx_codex_entries_root_path`` to stay linear: a single-column root index
|
|
1311
|
+
#: cannot discriminate when every rollout resolves to one provider root, so the
|
|
1312
|
+
#: join degenerates to files x entries. Module-level so the query-plan
|
|
1313
|
+
#: regression asserts THIS text rather than a copy that can drift from it.
|
|
1314
|
+
_CODEX_FILE_ALIAS_SQL = (
|
|
1315
|
+
"SELECT f.source_root_key, f.path, f.last_native_thread_id, "
|
|
1316
|
+
"f.last_session_id, MIN(e.timestamp_utc) "
|
|
1317
|
+
"FROM codex_session_files AS f "
|
|
1318
|
+
"LEFT JOIN codex_session_entries AS e "
|
|
1319
|
+
"ON e.source_root_key=f.source_root_key AND e.source_path=f.path "
|
|
1320
|
+
"WHERE f.last_native_thread_id IS NOT NULL AND f.last_native_thread_id != '' "
|
|
1321
|
+
"GROUP BY f.source_root_key, f.path, f.last_native_thread_id, f.last_session_id "
|
|
1322
|
+
"ORDER BY f.last_ingested_at DESC, f.path DESC"
|
|
1323
|
+
)
|
|
1324
|
+
|
|
1325
|
+
|
|
1264
1326
|
def _codex_conversation_metadata(
|
|
1265
1327
|
cache_conn: sqlite3.Connection,
|
|
1266
1328
|
) -> dict[tuple[str, str], dict[str, object]]:
|
|
@@ -1302,16 +1364,7 @@ def _codex_conversation_metadata(
|
|
|
1302
1364
|
cwd, git_json, first_seen_at, _last_seen_at,
|
|
1303
1365
|
) in core_rows
|
|
1304
1366
|
)
|
|
1305
|
-
file_aliases = tuple(cache_conn.execute(
|
|
1306
|
-
"SELECT f.source_root_key, f.path, f.last_native_thread_id, "
|
|
1307
|
-
"f.last_session_id, MIN(e.timestamp_utc) "
|
|
1308
|
-
"FROM codex_session_files AS f "
|
|
1309
|
-
"LEFT JOIN codex_session_entries AS e "
|
|
1310
|
-
"ON e.source_root_key=f.source_root_key AND e.source_path=f.path "
|
|
1311
|
-
"WHERE f.last_native_thread_id IS NOT NULL AND f.last_native_thread_id != '' "
|
|
1312
|
-
"GROUP BY f.source_root_key, f.path, f.last_native_thread_id, f.last_session_id "
|
|
1313
|
-
"ORDER BY f.last_ingested_at DESC, f.path DESC"
|
|
1314
|
-
))
|
|
1367
|
+
file_aliases = tuple(cache_conn.execute(_CODEX_FILE_ALIAS_SQL))
|
|
1315
1368
|
native_ids = tuple(sorted({
|
|
1316
1369
|
str(native_thread_id) for _, _, native_thread_id, *_ in rows
|
|
1317
1370
|
if isinstance(native_thread_id, str) and native_thread_id
|
|
@@ -2393,6 +2446,11 @@ def refresh_codex_source_clock(
|
|
|
2393
2446
|
"hero",
|
|
2394
2447
|
),)
|
|
2395
2448
|
availability = "partial"
|
|
2449
|
+
# #556 S1 §4.1: an EXPIRED boundary is exactly the state the
|
|
2450
|
+
# accounting axis reports as stale. Build time can never see it
|
|
2451
|
+
# (`_resolve_codex_weekly_cycle` retains only `resets_at > now`),
|
|
2452
|
+
# so this clock is the only writer of that value.
|
|
2453
|
+
domain_freshness["hero"] = "stale"
|
|
2396
2454
|
cycle_changed = True
|
|
2397
2455
|
# 3. budget last
|
|
2398
2456
|
if refreshed_budget is not None:
|
|
@@ -2411,7 +2469,17 @@ def refresh_codex_source_clock(
|
|
|
2411
2469
|
data=data,
|
|
2412
2470
|
domain_freshness=domain_freshness,
|
|
2413
2471
|
clock_data=state.clock_data,
|
|
2472
|
+
# #556 S1 §3.8: this constructor lists every field explicitly, so an
|
|
2473
|
+
# omission silently drops the authoritative account count and makes the
|
|
2474
|
+
# combined figure fail closed on an idle tick that changed nothing else.
|
|
2475
|
+
account_scope=state.account_scope,
|
|
2414
2476
|
private_session_labels=state.private_session_labels,
|
|
2477
|
+
# #556 S2 §3.6: the aggregate carrier travels with the rows it
|
|
2478
|
+
# describes. This clock refreshes presentation axes only and publishes
|
|
2479
|
+
# the SAME rows, so it must carry the range that bounded them —
|
|
2480
|
+
# dropping it here would withhold the aggregate on any idle tick whose
|
|
2481
|
+
# clock moved.
|
|
2482
|
+
aggregate_scope=state.aggregate_scope,
|
|
2415
2483
|
)
|
|
2416
2484
|
return state if refreshed_state == state else refreshed_state
|
|
2417
2485
|
|
|
@@ -2457,39 +2525,55 @@ def _alerts_wire(
|
|
|
2457
2525
|
"accountLabel": label,
|
|
2458
2526
|
}
|
|
2459
2527
|
|
|
2528
|
+
# #556 S3 §2.1/§2.4: the firing instant is what this wire filters on, what
|
|
2529
|
+
# the panel orders by and what it prints, so it is what every leg selects,
|
|
2530
|
+
# orders by and publishes. Two legs previously ordered, truncated and
|
|
2531
|
+
# published the CROSSING instant instead, which is a different moment: a
|
|
2532
|
+
# row that fired most recently but crossed longest ago was dropped at the
|
|
2533
|
+
# LIMIT and never reached the panel at all. `created_at` stays as an
|
|
2534
|
+
# equal-valued compatibility alias for a client reading a pre-v7 envelope.
|
|
2535
|
+
canon = canonical_alerted_at_sql()
|
|
2536
|
+
|
|
2537
|
+
def _instants(raw: object) -> dict[str, str]:
|
|
2538
|
+
value = canonical_alerted_at(raw)
|
|
2539
|
+
return {"alerted_at": value, "created_at": value}
|
|
2540
|
+
|
|
2460
2541
|
try:
|
|
2461
|
-
for period, threshold, consumption_pct, crossed_at, account_key in stats_conn.execute(
|
|
2462
|
-
"SELECT period, threshold, consumption_pct, crossed_at_utc, account_key "
|
|
2542
|
+
for period, threshold, consumption_pct, crossed_at, alerted_at, account_key in stats_conn.execute(
|
|
2543
|
+
"SELECT period, threshold, consumption_pct, crossed_at_utc, alerted_at, account_key "
|
|
2463
2544
|
"FROM budget_milestones WHERE vendor='codex' AND alerted_at IS NOT NULL "
|
|
2464
|
-
"ORDER BY
|
|
2545
|
+
f"ORDER BY {canon} DESC, threshold DESC LIMIT ?",
|
|
2465
2546
|
(SOURCE_HISTORY_LIMIT,),
|
|
2466
2547
|
):
|
|
2467
2548
|
rows.append({
|
|
2549
|
+
# The resource key keeps the crossing instant it has always
|
|
2550
|
+
# carried: it is an opaque identity, and re-keying every
|
|
2551
|
+
# historical Codex alert row is not this session's change.
|
|
2468
2552
|
"key": dashboard_resource_key("alert", "codex", "codex_budget", period, threshold, crossed_at),
|
|
2469
2553
|
"source": "codex",
|
|
2470
2554
|
"axis": "codex_budget", "period": period, "threshold": threshold,
|
|
2471
|
-
"value": consumption_pct,
|
|
2555
|
+
"value": consumption_pct, **_instants(alerted_at),
|
|
2472
2556
|
**_account(account_key),
|
|
2473
2557
|
})
|
|
2474
|
-
for period, threshold, projected_value, crossed_at, account_key in stats_conn.execute(
|
|
2475
|
-
"SELECT period, threshold, projected_value, crossed_at_utc, account_key "
|
|
2558
|
+
for period, threshold, projected_value, crossed_at, alerted_at, account_key in stats_conn.execute(
|
|
2559
|
+
"SELECT period, threshold, projected_value, crossed_at_utc, alerted_at, account_key "
|
|
2476
2560
|
"FROM projected_milestones WHERE metric='codex_budget_usd' AND alerted_at IS NOT NULL "
|
|
2477
|
-
"ORDER BY
|
|
2561
|
+
f"ORDER BY {canon} DESC, threshold DESC LIMIT ?",
|
|
2478
2562
|
(SOURCE_HISTORY_LIMIT,),
|
|
2479
2563
|
):
|
|
2480
2564
|
rows.append({
|
|
2481
2565
|
"key": dashboard_resource_key("alert", "codex", "projected", period, threshold, crossed_at),
|
|
2482
2566
|
"source": "codex",
|
|
2483
2567
|
"axis": "projected", "period": period, "threshold": threshold,
|
|
2484
|
-
"value": projected_value,
|
|
2568
|
+
"value": projected_value, **_instants(alerted_at),
|
|
2485
2569
|
**_account(account_key),
|
|
2486
2570
|
})
|
|
2487
2571
|
for (root_key, logical_key, observed_slot, window_minutes, resets_at,
|
|
2488
|
-
threshold, severity, created_at, account_key) in stats_conn.execute(
|
|
2572
|
+
threshold, severity, created_at, alerted_at, account_key) in stats_conn.execute(
|
|
2489
2573
|
"SELECT source_root_key, logical_limit_key, observed_slot, window_minutes, resets_at_utc, "
|
|
2490
|
-
"threshold, severity, created_at_utc, account_key FROM quota_threshold_events "
|
|
2574
|
+
"threshold, severity, created_at_utc, alerted_at, account_key FROM quota_threshold_events "
|
|
2491
2575
|
"WHERE source='codex' AND disposition='alerted' AND orphaned_at IS NULL "
|
|
2492
|
-
"ORDER BY
|
|
2576
|
+
f"ORDER BY {canon} DESC, source_root_key, logical_limit_key, observed_slot, threshold "
|
|
2493
2577
|
"LIMIT ?",
|
|
2494
2578
|
(SOURCE_HISTORY_LIMIT,),
|
|
2495
2579
|
):
|
|
@@ -2500,14 +2584,14 @@ def _alerts_wire(
|
|
|
2500
2584
|
),
|
|
2501
2585
|
"source": "codex",
|
|
2502
2586
|
"axis": "quota", "threshold": threshold, "severity": severity,
|
|
2503
|
-
|
|
2587
|
+
**_instants(alerted_at),
|
|
2504
2588
|
**_account(account_key),
|
|
2505
2589
|
})
|
|
2506
2590
|
except sqlite3.Error:
|
|
2507
2591
|
return ()
|
|
2508
2592
|
return tuple(sorted(
|
|
2509
2593
|
rows,
|
|
2510
|
-
key=lambda item:
|
|
2594
|
+
key=lambda item: canonical_alerted_at(item["alerted_at"]),
|
|
2511
2595
|
reverse=True,
|
|
2512
2596
|
)[:SOURCE_HISTORY_LIMIT])
|
|
2513
2597
|
|
|
@@ -3408,7 +3492,6 @@ def build_codex_source_state(
|
|
|
3408
3492
|
cache_conn=context.cache_conn,
|
|
3409
3493
|
)
|
|
3410
3494
|
budget_entries = _codex_entries_from_accounting(accounting_entries)
|
|
3411
|
-
cycle_reason: str | None = None
|
|
3412
3495
|
cycles_all: list[CodexCycleBoundary] = []
|
|
3413
3496
|
try:
|
|
3414
3497
|
# Per-account list (#341 Task 2). ``cycles_all`` drives the per-account
|
|
@@ -3419,9 +3502,12 @@ def build_codex_source_state(
|
|
|
3419
3502
|
# `conflicting`.
|
|
3420
3503
|
cycles_all = _resolve_codex_weekly_cycle(quota_observations, context.now_utc)
|
|
3421
3504
|
cycle = cycles_all[0] if cycles_all else None
|
|
3422
|
-
except CodexCycleUnavailable
|
|
3505
|
+
except CodexCycleUnavailable:
|
|
3506
|
+
# #556 S1 §4.1: the reason no longer moves a freshness axis. A
|
|
3507
|
+
# `stale` reason is observation AGE, which `quota` owns; every reason
|
|
3508
|
+
# here leaves `cycle` unresolved, which `cycle_failure` below turns
|
|
3509
|
+
# into the hero failure the accounting axis actually reports.
|
|
3423
3510
|
cycle = None
|
|
3424
|
-
cycle_reason = exc.reason
|
|
3425
3511
|
cycle_failure = cycle is None and has_cached_codex_accounting_entries(
|
|
3426
3512
|
cache_conn=context.cache_conn,
|
|
3427
3513
|
)
|
|
@@ -3826,16 +3912,17 @@ def build_codex_source_state(
|
|
|
3826
3912
|
if ingest_backlog is not None else {}),
|
|
3827
3913
|
},
|
|
3828
3914
|
domain_freshness={
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3915
|
+
# #556 S1 §4.1: `hero` means current-cycle ACCOUNTING
|
|
3916
|
+
# resolvability, not observation age. A stale-but-still-future
|
|
3917
|
+
# boundary stays RESOLVED — the spend it bounds is correct, and
|
|
3918
|
+
# Codex has no background quota poll, so `stale_after_seconds`
|
|
3919
|
+
# (3600) makes an idle weekly observation stale within the hour
|
|
3920
|
+
# while nothing about the accounting changed. The percent age is
|
|
3921
|
+
# already carried by `quota` below and by the additive hero-local
|
|
3922
|
+
# `cycle_freshness` field. `_resolve_codex_weekly_cycle` retains
|
|
3923
|
+
# only boundaries with `resets_at > now`, so a resolved cycle is
|
|
3924
|
+
# never expired at build time; the idle clock owns expiry.
|
|
3925
|
+
"hero": "stale" if hero_failure else "fresh",
|
|
3839
3926
|
"quota": (
|
|
3840
3927
|
"stale"
|
|
3841
3928
|
if quota["summary"]["freshness"] == "stale"
|
package/bin/_cctally_db.py
CHANGED
|
@@ -4310,6 +4310,16 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
|
|
|
4310
4310
|
"CREATE INDEX IF NOT EXISTS idx_codex_entries_ts_root_conversation "
|
|
4311
4311
|
"ON codex_session_entries(timestamp_utc, source_root_key, conversation_key)"
|
|
4312
4312
|
)
|
|
4313
|
+
# The per-file alias join in `_codex_conversation_metadata` matches on
|
|
4314
|
+
# (source_root_key, source_path). `idx_codex_entries_source_root` cannot
|
|
4315
|
+
# serve it: a machine normally has ONE provider root, so a root-only search
|
|
4316
|
+
# visits every entry row for every file and the join costs files x entries
|
|
4317
|
+
# on every dashboard snapshot build. Re-derivable, so it belongs on the
|
|
4318
|
+
# unconditional path with the S3 index above rather than in a migration.
|
|
4319
|
+
conn.execute(
|
|
4320
|
+
"CREATE INDEX IF NOT EXISTS idx_codex_entries_root_path "
|
|
4321
|
+
"ON codex_session_entries(source_root_key, source_path)"
|
|
4322
|
+
)
|
|
4313
4323
|
# The per-file terminal thread facts seed a later append without rereading
|
|
4314
4324
|
# the prefix. They are nullable for old cache rows; migration 024 never
|
|
4315
4325
|
# fabricates these source facts and instead clears/rederives them.
|
package/bin/_cctally_doctor.py
CHANGED
|
@@ -38,6 +38,7 @@ import sys
|
|
|
38
38
|
|
|
39
39
|
import _cctally_core
|
|
40
40
|
import _lib_changelog
|
|
41
|
+
import _lib_journal_router
|
|
41
42
|
from _cctally_core import _now_utc, eprint, now_utc_iso, parse_iso_datetime
|
|
42
43
|
from _lib_dashboard_json import encode_dashboard_json
|
|
43
44
|
|
|
@@ -46,10 +47,10 @@ from _lib_dashboard_json import encode_dashboard_json
|
|
|
46
47
|
#: (`_lib_journal.resolve_effective_events`) reads only `evt`, `correction` and
|
|
47
48
|
#: `correction_batch`; `op` is kept because the rebuild-equivalent account
|
|
48
49
|
#: normalization is defined over evt/op records. Everything else — above all the
|
|
49
|
-
#: `obs` lines, ~97% of a real journal —
|
|
50
|
-
#:
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
#: `obs` lines, ~97% of a real journal — becomes a `None` positional slot as it
|
|
51
|
+
#: is decoded, so peak RSS tracks decision dictionaries plus one pointer per
|
|
52
|
+
#: decoded line rather than the whole decoded journal.
|
|
53
|
+
_CONFLICT_SCAN_RECORD_TYPES = _lib_journal_router.RETAINED_RECORD_TYPES
|
|
53
54
|
|
|
54
55
|
#: Doctor needs only recent evidence for this diagnostic. Bound both line count
|
|
55
56
|
#: and bytes so a corrupt single-line file cannot defeat the tail limit.
|
|
@@ -1798,10 +1799,13 @@ def _doctor_gather_state_impl(
|
|
|
1798
1799
|
# RETAIN ONLY what the selector consumes. `obs` lines are
|
|
1799
1800
|
# ~97% of a real journal (984k of 1.02M) and
|
|
1800
1801
|
# `resolve_effective_events` ignores them entirely —
|
|
1801
|
-
# keeping
|
|
1802
|
-
# result (#374 review).
|
|
1803
|
-
|
|
1804
|
-
|
|
1802
|
+
# keeping their dictionaries cost 4.3 GB of peak RSS for
|
|
1803
|
+
# an identical result (#374 review). They still consume a
|
|
1804
|
+
# lightweight slot because their physical sequence is
|
|
1805
|
+
# part of three durable violation fingerprints (#508).
|
|
1806
|
+
decoded_records.append(
|
|
1807
|
+
_lib_journal_router.selector_slot(record)
|
|
1808
|
+
)
|
|
1805
1809
|
prior_high_water = (
|
|
1806
1810
|
seg,
|
|
1807
1811
|
offset + len(raw) + 1,
|
|
@@ -1821,8 +1825,9 @@ def _doctor_gather_state_impl(
|
|
|
1821
1825
|
else _jr.resolve_cutover_claude_account()
|
|
1822
1826
|
)
|
|
1823
1827
|
for record in decoded_records:
|
|
1824
|
-
|
|
1825
|
-
|
|
1828
|
+
if record is not None:
|
|
1829
|
+
_jr._normalize_legacy_account_stamp(
|
|
1830
|
+
record, cutover_claude)
|
|
1826
1831
|
selection = _jl.resolve_effective_events(
|
|
1827
1832
|
decoded_records,
|
|
1828
1833
|
protocol_prefix_evidence=protocol_evidence,
|
|
@@ -54,10 +54,10 @@ def _read_prefix(high_water):
|
|
|
54
54
|
inline exactly as `rebuild_stats_index` captures it. Before this, those were
|
|
55
55
|
four separate whole-prefix traversals on top of this one (#496 S5 §4).
|
|
56
56
|
|
|
57
|
-
Every
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
Every decoded line contributes one selector slot. Decision records stay
|
|
58
|
+
decoded; observations and other irrelevant records become ``None``
|
|
59
|
+
placeholders, preserving the `enumerate` numbering that durable violation
|
|
60
|
+
fingerprints hash without retaining whole-prefix decoded history.
|
|
61
61
|
"""
|
|
62
62
|
if high_water is None:
|
|
63
63
|
return [], (), None, {}
|
|
@@ -106,7 +106,7 @@ def _read_prefix(high_water):
|
|
|
106
106
|
and record.get("id") == _journal.CUTOVER_OP_ID
|
|
107
107
|
):
|
|
108
108
|
cutover_captured = _journal._cutover_value_of(record)
|
|
109
|
-
records.append(record)
|
|
109
|
+
records.append(_lib_journal_router.selector_slot(record))
|
|
110
110
|
prior_high_water = record_end
|
|
111
111
|
prefix_hash = hasher.digest_at(high_water)
|
|
112
112
|
# The accumulator buffers the segment it is reading — 410 MB on the
|
|
@@ -136,7 +136,8 @@ def _read_prefix(high_water):
|
|
|
136
136
|
else:
|
|
137
137
|
cutover_claude = cutover_captured
|
|
138
138
|
for record in records:
|
|
139
|
-
|
|
139
|
+
if record is not None:
|
|
140
|
+
_journal._normalize_legacy_account_stamp(record, cutover_claude)
|
|
140
141
|
return records, tuple(evidence), prefix_hash, audit_ends
|
|
141
142
|
|
|
142
143
|
|