cctally 1.90.1 → 1.92.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 +74 -0
- package/README.md +2 -2
- package/bin/_cctally_cache.py +863 -74
- package/bin/_cctally_config.py +57 -0
- package/bin/_cctally_core.py +53 -8
- package/bin/_cctally_dashboard.py +146 -5
- package/bin/_cctally_dashboard_conversation.py +164 -18
- package/bin/_cctally_dashboard_envelope.py +69 -12
- package/bin/_cctally_dashboard_sources.py +27 -1
- package/bin/_cctally_db.py +372 -10
- package/bin/_cctally_doctor.py +18 -1
- package/bin/_cctally_journal.py +535 -13
- package/bin/_cctally_journal_repair.py +6 -0
- package/bin/_cctally_parser.py +6 -0
- package/bin/_cctally_quota.py +171 -55
- package/bin/_cctally_record.py +13 -1
- package/bin/_cctally_rederive.py +4 -0
- package/bin/_cctally_store.py +311 -6
- package/bin/_cctally_transcript.py +32 -2
- package/bin/_lib_cache_report.py +8 -3
- package/bin/_lib_cache_report_wire.py +8 -20
- package/bin/_lib_codex_conversation.py +959 -81
- package/bin/_lib_codex_conversation_query.py +2792 -167
- package/bin/_lib_codex_find_projection.py +370 -0
- package/bin/_lib_codex_harness_preamble.py +176 -0
- package/bin/_lib_codex_hooks.py +5 -3
- package/bin/_lib_codex_js_scan.py +254 -0
- package/bin/_lib_codex_landmarks.py +309 -0
- package/bin/_lib_codex_reasoning_headings.py +73 -0
- package/bin/_lib_codex_segments.py +259 -0
- package/bin/_lib_codex_title_clean.py +116 -0
- package/bin/_lib_conversation_dispatch.py +153 -21
- package/bin/_lib_conversation_watch.py +4 -2
- package/bin/_lib_dashboard_sources.py +33 -32
- package/bin/_lib_doctor.py +64 -0
- package/bin/_lib_quota_alert_axes.py +31 -34
- package/bin/_lib_stats_damage.py +523 -0
- package/bin/cctally +5 -0
- package/dashboard/static/assets/index-BEzzJtUd.js +97 -0
- package/dashboard/static/assets/index-DnWdv8um.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +9 -1
- package/dashboard/static/assets/index-Bar8-S1i.css +0 -1
- package/dashboard/static/assets/index-CRogVlEC.js +0 -92
|
@@ -45,6 +45,7 @@ import sys
|
|
|
45
45
|
|
|
46
46
|
from _cctally_cache import (
|
|
47
47
|
open_cache_db,
|
|
48
|
+
scope_conversations_db_to_account,
|
|
48
49
|
sync_codex_cache,
|
|
49
50
|
sync_claude_conversations,
|
|
50
51
|
sync_codex_conversations,
|
|
@@ -222,13 +223,15 @@ def _conversation_query_impl():
|
|
|
222
223
|
# are deliberately absent — they are meaningless on a collection route, so they
|
|
223
224
|
# fall to "genuinely unknown → ignored".
|
|
224
225
|
_RECOGNIZED_CONVERSATION_PARAMS = (
|
|
225
|
-
"source", "project_key", "model", "limit", "cursor", "q", "kind",
|
|
226
|
+
"source", "account", "project_key", "model", "limit", "cursor", "q", "kind",
|
|
226
227
|
"sort", "offset", "date_from", "date_to", "projects",
|
|
227
228
|
"cost_min", "cost_max", "rebuild_min", "models",
|
|
228
229
|
)
|
|
229
|
-
_QUALIFIED_BROWSE_ACCEPTED = (
|
|
230
|
-
|
|
231
|
-
|
|
230
|
+
_QUALIFIED_BROWSE_ACCEPTED = (
|
|
231
|
+
"source", "account", "project_key", "model", "limit", "cursor")
|
|
232
|
+
_QUALIFIED_SEARCH_ACCEPTED = (
|
|
233
|
+
"source", "account", "q", "kind", "limit", "cursor")
|
|
234
|
+
_QUALIFIED_FACETS_ACCEPTED = ("source", "account")
|
|
232
235
|
# A raw browse cursor is a conversation key — printable + URL-safe by construction
|
|
233
236
|
# (§2.2). Syntactic-only validation: reject whitespace/control/empty; echo raw.
|
|
234
237
|
_BROWSE_CURSOR_RE = re.compile(r"\A[!-~]+\Z")
|
|
@@ -321,8 +324,12 @@ def _respond_qualified_json(handler, env):
|
|
|
321
324
|
``ok`` / ``normalization_pending`` → 200; ``gone`` → 410; ``validation_error``
|
|
322
325
|
→ 400; anything else (``not_found`` + unknown) → 404. Body is the envelope."""
|
|
323
326
|
status = (env or {}).get("status")
|
|
324
|
-
if status in ("ok", "normalization_pending"):
|
|
327
|
+
if status in ("ok", "normalization_pending", "ready", "indexing"):
|
|
325
328
|
handler._respond_json(200, env)
|
|
329
|
+
elif status == "invalid_find_cursor":
|
|
330
|
+
handler._respond_json(400, {"error": "invalid find cursor"})
|
|
331
|
+
elif status == "stale_find_cursor":
|
|
332
|
+
handler._respond_json(409, {"error": "stale find cursor"})
|
|
326
333
|
elif status == "gone":
|
|
327
334
|
handler._respond_json(410, env)
|
|
328
335
|
elif status == "validation_error":
|
|
@@ -378,7 +385,7 @@ def _handle_qualified_facets(handler, qs_raw, source):
|
|
|
378
385
|
speed = _resolve_effective_speed()
|
|
379
386
|
disp = _conversation_dispatch()
|
|
380
387
|
ok, body = handler._run_conversation_query(
|
|
381
|
-
lambda conn: disp.
|
|
388
|
+
lambda conn: disp.neutral_facets(
|
|
382
389
|
conn, source=source, effective_speed=speed),
|
|
383
390
|
"/api/conversations/facets")
|
|
384
391
|
if not ok:
|
|
@@ -469,6 +476,15 @@ def _run_conversation_query_impl(handler, kernel_call, log_label):
|
|
|
469
476
|
)
|
|
470
477
|
return False, None
|
|
471
478
|
try:
|
|
479
|
+
import urllib.parse as _u
|
|
480
|
+
account_vals = _u.parse_qs(
|
|
481
|
+
handler.path.partition("?")[2], keep_blank_values=True
|
|
482
|
+
).get("account")
|
|
483
|
+
if account_vals is not None:
|
|
484
|
+
if len(account_vals) != 1 or not account_vals[0]:
|
|
485
|
+
handler._respond_json(400, {"error": "invalid account"})
|
|
486
|
+
return False, None
|
|
487
|
+
scope_conversations_db_to_account(conn, account_vals[0])
|
|
472
488
|
body = kernel_call(conn)
|
|
473
489
|
except Exception as exc: # noqa: BLE001
|
|
474
490
|
handler.log_error("%s failed: %r", log_label, exc)
|
|
@@ -794,25 +810,59 @@ def _run_conversation_events_stream(
|
|
|
794
810
|
idle = 0.0
|
|
795
811
|
|
|
796
812
|
|
|
797
|
-
def _bare_conversation_events(
|
|
813
|
+
def _bare_conversation_events(
|
|
814
|
+
handler, session_id: str, account_key: str | None = None,
|
|
815
|
+
) -> None:
|
|
798
816
|
"""Bare legacy Claude live-tail — today's no-preflight, ``sessionId``-framed
|
|
799
817
|
behavior, byte-identical (spec §5.2 reserves this for bare streams)."""
|
|
800
818
|
cq = handler._conversation_query()
|
|
801
|
-
_send_sse_headers(handler)
|
|
802
819
|
passive = bool(type(handler).no_sync)
|
|
803
820
|
try:
|
|
804
821
|
conn = sys.modules["_cctally_dashboard"].open_conversations_db()
|
|
805
822
|
except (sqlite3.DatabaseError, OSError):
|
|
806
|
-
|
|
807
|
-
|
|
823
|
+
if account_key is not None:
|
|
824
|
+
# A qualified account boundary must be established before any SSE
|
|
825
|
+
# bytes. If it cannot be proven, fail closed as JSON.
|
|
826
|
+
handler._respond_json(500, {"error": "internal error"})
|
|
827
|
+
return
|
|
828
|
+
# Unqualified legacy behavior: degrade to keep-alive only; the client
|
|
829
|
+
# backstop tick still surfaces turns.
|
|
808
830
|
passive = True
|
|
809
831
|
conn = None
|
|
832
|
+
if conn is not None and account_key is not None:
|
|
833
|
+
scope_conversations_db_to_account(conn, account_key)
|
|
834
|
+
if conn.execute(
|
|
835
|
+
"SELECT 1 FROM conversation_messages WHERE session_id=? LIMIT 1",
|
|
836
|
+
(session_id,),
|
|
837
|
+
).fetchone() is None:
|
|
838
|
+
conn.close()
|
|
839
|
+
handler._respond_json(404, {"error": "conversation not found"})
|
|
840
|
+
return
|
|
841
|
+
_send_sse_headers(handler)
|
|
810
842
|
|
|
811
843
|
def _resolve():
|
|
812
844
|
return cq.session_source_paths(conn, session_id) if conn else []
|
|
813
845
|
|
|
814
846
|
def _ingest(changed):
|
|
815
|
-
|
|
847
|
+
if account_key is None:
|
|
848
|
+
return sync_claude_conversations(conn, only_paths=set(changed))
|
|
849
|
+
before = conn.execute(
|
|
850
|
+
"SELECT COUNT(*),MAX(id) FROM conversation_messages "
|
|
851
|
+
"WHERE session_id=?",
|
|
852
|
+
(session_id,),
|
|
853
|
+
).fetchone()
|
|
854
|
+
writer = sys.modules["_cctally_dashboard"].open_conversations_db()
|
|
855
|
+
try:
|
|
856
|
+
stats = sync_claude_conversations(writer, only_paths=set(changed))
|
|
857
|
+
finally:
|
|
858
|
+
writer.close()
|
|
859
|
+
after = conn.execute(
|
|
860
|
+
"SELECT COUNT(*),MAX(id) FROM conversation_messages "
|
|
861
|
+
"WHERE session_id=?",
|
|
862
|
+
(session_id,),
|
|
863
|
+
).fetchone()
|
|
864
|
+
stats.targeted_visible = after != before
|
|
865
|
+
return stats
|
|
816
866
|
|
|
817
867
|
try:
|
|
818
868
|
_run_conversation_events_stream(
|
|
@@ -895,7 +945,9 @@ def _make_codex_discovery_step(handler, conn, conversation_key, cq_codex):
|
|
|
895
945
|
return _discovery
|
|
896
946
|
|
|
897
947
|
|
|
898
|
-
def _qualified_conversation_events(
|
|
948
|
+
def _qualified_conversation_events(
|
|
949
|
+
handler, key: str, account_key: str | None = None,
|
|
950
|
+
) -> None:
|
|
899
951
|
"""Qualified (``v1.``) live-tail (spec §5.2): a neutral preflight — resolve →
|
|
900
952
|
normalization authority (Codex) → existence — answered as plain JSON per
|
|
901
953
|
§2.3 BEFORE any SSE bytes; only on ``ok`` are SSE headers committed and the
|
|
@@ -924,6 +976,8 @@ def _qualified_conversation_events(handler, key: str) -> None:
|
|
|
924
976
|
except Exception as exc: # noqa: BLE001
|
|
925
977
|
handler.log_error("api/conversation/events stream failed: %r", exc)
|
|
926
978
|
return
|
|
979
|
+
if account_key is not None:
|
|
980
|
+
scope_conversations_db_to_account(conn, account_key)
|
|
927
981
|
|
|
928
982
|
try:
|
|
929
983
|
preflight = disp.neutral_events_preflight(conn, key)
|
|
@@ -959,10 +1013,43 @@ def _qualified_conversation_events(handler, key: str) -> None:
|
|
|
959
1013
|
return cq_codex.codex_conversation_source_paths(conn, key)
|
|
960
1014
|
|
|
961
1015
|
def _ingest(changed):
|
|
962
|
-
|
|
1016
|
+
if account_key is None:
|
|
1017
|
+
return sync_codex_conversations(conn, only_paths=set(changed))
|
|
1018
|
+
before = conn.execute(
|
|
1019
|
+
"SELECT COUNT(*),MAX(id) FROM codex_conversation_messages "
|
|
1020
|
+
"WHERE conversation_key=?",
|
|
1021
|
+
(key,),
|
|
1022
|
+
).fetchone()
|
|
1023
|
+
# The accounting cache owns the durable physical account-range
|
|
1024
|
+
# decision. Advance it first so transcript ingest never treats the
|
|
1025
|
+
# previous range as open-ended across an account switch.
|
|
1026
|
+
accounting = open_cache_db()
|
|
1027
|
+
try:
|
|
1028
|
+
accounting_stats = sync_codex_cache(
|
|
1029
|
+
accounting, only_paths=set(changed)
|
|
1030
|
+
)
|
|
1031
|
+
finally:
|
|
1032
|
+
accounting.close()
|
|
1033
|
+
if not accounting_stats.targeted_clean:
|
|
1034
|
+
return accounting_stats
|
|
1035
|
+
writer = sys.modules["_cctally_dashboard"].open_conversations_db()
|
|
1036
|
+
try:
|
|
1037
|
+
stats = sync_codex_conversations(writer, only_paths=set(changed))
|
|
1038
|
+
finally:
|
|
1039
|
+
writer.close()
|
|
1040
|
+
after = conn.execute(
|
|
1041
|
+
"SELECT COUNT(*),MAX(id) FROM codex_conversation_messages "
|
|
1042
|
+
"WHERE conversation_key=?",
|
|
1043
|
+
(key,),
|
|
1044
|
+
).fetchone()
|
|
1045
|
+
stats.targeted_visible = after != before
|
|
1046
|
+
return stats
|
|
963
1047
|
|
|
964
1048
|
cached = lambda paths: _codex_cached_file_sigs(conn, paths)
|
|
965
|
-
discovery =
|
|
1049
|
+
discovery = (
|
|
1050
|
+
None if account_key is not None
|
|
1051
|
+
else _make_codex_discovery_step(handler, conn, key, cq_codex)
|
|
1052
|
+
)
|
|
966
1053
|
else: # claude — reuse the Claude mechanics, speak qualified frames.
|
|
967
1054
|
cq = handler._conversation_query()
|
|
968
1055
|
|
|
@@ -970,7 +1057,25 @@ def _qualified_conversation_events(handler, key: str) -> None:
|
|
|
970
1057
|
return cq.session_source_paths(conn, native)
|
|
971
1058
|
|
|
972
1059
|
def _ingest(changed):
|
|
973
|
-
|
|
1060
|
+
if account_key is None:
|
|
1061
|
+
return sync_claude_conversations(conn, only_paths=set(changed))
|
|
1062
|
+
before = conn.execute(
|
|
1063
|
+
"SELECT COUNT(*),MAX(id) FROM conversation_messages "
|
|
1064
|
+
"WHERE session_id=?",
|
|
1065
|
+
(native,),
|
|
1066
|
+
).fetchone()
|
|
1067
|
+
writer = sys.modules["_cctally_dashboard"].open_conversations_db()
|
|
1068
|
+
try:
|
|
1069
|
+
stats = sync_claude_conversations(writer, only_paths=set(changed))
|
|
1070
|
+
finally:
|
|
1071
|
+
writer.close()
|
|
1072
|
+
after = conn.execute(
|
|
1073
|
+
"SELECT COUNT(*),MAX(id) FROM conversation_messages "
|
|
1074
|
+
"WHERE session_id=?",
|
|
1075
|
+
(native,),
|
|
1076
|
+
).fetchone()
|
|
1077
|
+
stats.targeted_visible = after != before
|
|
1078
|
+
return stats
|
|
974
1079
|
|
|
975
1080
|
cached = lambda paths: _cached_file_sigs(conn, paths)
|
|
976
1081
|
discovery = None
|
|
@@ -999,14 +1104,23 @@ def _handle_get_conversation_events_impl(handler, path: str) -> None:
|
|
|
999
1104
|
if not handler._require_transcripts_allowed():
|
|
1000
1105
|
return
|
|
1001
1106
|
import urllib.parse as _u
|
|
1107
|
+
account_vals = _u.parse_qs(
|
|
1108
|
+
handler.path.partition("?")[2], keep_blank_values=True
|
|
1109
|
+
).get("account")
|
|
1110
|
+
if account_vals is not None and (
|
|
1111
|
+
len(account_vals) != 1 or not account_vals[0]
|
|
1112
|
+
):
|
|
1113
|
+
handler._respond_json(400, {"error": "invalid account"})
|
|
1114
|
+
return
|
|
1115
|
+
account_key = account_vals[0] if account_vals else None
|
|
1002
1116
|
key = _u.unquote(path[len("/api/conversation/"):-len("/events")])
|
|
1003
1117
|
if not key:
|
|
1004
1118
|
handler.send_error(404, "conversation not found")
|
|
1005
1119
|
return
|
|
1006
1120
|
if key.startswith("v1."):
|
|
1007
|
-
_qualified_conversation_events(handler, key)
|
|
1121
|
+
_qualified_conversation_events(handler, key, account_key)
|
|
1008
1122
|
else:
|
|
1009
|
-
_bare_conversation_events(handler, key)
|
|
1123
|
+
_bare_conversation_events(handler, key, account_key)
|
|
1010
1124
|
|
|
1011
1125
|
|
|
1012
1126
|
def _handle_get_conversation_search_impl(handler) -> None:
|
|
@@ -1407,10 +1521,42 @@ def _handle_get_conversation_find_impl(handler, path: str) -> None:
|
|
|
1407
1521
|
return
|
|
1408
1522
|
if session_id.startswith("v1."):
|
|
1409
1523
|
disp = _conversation_dispatch()
|
|
1524
|
+
cref = disp.resolve_conversation_ref(session_id)
|
|
1525
|
+
paging = {}
|
|
1526
|
+
if cref is not None and cref.source == "codex":
|
|
1527
|
+
raw_limit = _qs_str(q, "limit", None)
|
|
1528
|
+
if raw_limit is None:
|
|
1529
|
+
limit = 100
|
|
1530
|
+
else:
|
|
1531
|
+
try:
|
|
1532
|
+
limit = int(raw_limit)
|
|
1533
|
+
except (TypeError, ValueError):
|
|
1534
|
+
handler._respond_json(400, {"error": "invalid find limit"})
|
|
1535
|
+
return
|
|
1536
|
+
if not 1 <= limit <= 200:
|
|
1537
|
+
handler._respond_json(400, {"error": "invalid find limit"})
|
|
1538
|
+
return
|
|
1539
|
+
cursor = _qs_str(q, "cursor", None)
|
|
1540
|
+
around = _qs_str(q, "around", None)
|
|
1541
|
+
direction = _qs_str(q, "direction", "next")
|
|
1542
|
+
if direction not in ("next", "previous"):
|
|
1543
|
+
handler._respond_json(400, {"error": "invalid find direction"})
|
|
1544
|
+
return
|
|
1545
|
+
if cursor is not None and around is not None:
|
|
1546
|
+
handler._respond_json(
|
|
1547
|
+
400, {"error": "find cursor and around are mutually exclusive"})
|
|
1548
|
+
return
|
|
1549
|
+
paging = {
|
|
1550
|
+
"limit": limit,
|
|
1551
|
+
"cursor": cursor,
|
|
1552
|
+
"direction": direction,
|
|
1553
|
+
"around": around,
|
|
1554
|
+
}
|
|
1410
1555
|
_serve_qualified_entity(
|
|
1411
1556
|
handler,
|
|
1412
1557
|
lambda conn: disp.neutral_find(
|
|
1413
|
-
conn, session_id, query, kind=kind, regex=regex, case=case
|
|
1558
|
+
conn, session_id, query, kind=kind, regex=regex, case=case,
|
|
1559
|
+
**paging),
|
|
1414
1560
|
"/api/conversation/find")
|
|
1415
1561
|
return
|
|
1416
1562
|
ok, body = handler._run_conversation_query(
|
|
@@ -41,6 +41,7 @@ import bisect
|
|
|
41
41
|
import datetime as dt
|
|
42
42
|
import importlib.util as _ilu
|
|
43
43
|
import os
|
|
44
|
+
import sqlite3
|
|
44
45
|
import sys
|
|
45
46
|
from collections.abc import Mapping
|
|
46
47
|
from zoneinfo import ZoneInfo
|
|
@@ -329,7 +330,40 @@ def _select_current_block_for_envelope(
|
|
|
329
330
|
# ``FROM`` clause so the table name lives in the registry, not inlined here.
|
|
330
331
|
|
|
331
332
|
|
|
332
|
-
def
|
|
333
|
+
def _alert_account_resolver(conn: sqlite3.Connection):
|
|
334
|
+
"""Build one snapshot-scoped #345 resolver without per-row DB reads."""
|
|
335
|
+
import _cctally_account
|
|
336
|
+
|
|
337
|
+
decorated = {
|
|
338
|
+
provider: _cctally_account.provider_is_decorated(conn, provider)
|
|
339
|
+
for provider in ("claude", "codex")
|
|
340
|
+
}
|
|
341
|
+
labels = {
|
|
342
|
+
provider: _cctally_account.display_label_map(conn, provider)
|
|
343
|
+
for provider, enabled in decorated.items()
|
|
344
|
+
if enabled
|
|
345
|
+
}
|
|
346
|
+
for provider_labels in labels.values():
|
|
347
|
+
provider_labels.update({"*": "All accounts", "unattributed": "Unattributed"})
|
|
348
|
+
|
|
349
|
+
def fields(provider: str, account_key: object) -> dict[str, str]:
|
|
350
|
+
if not decorated.get(provider, False):
|
|
351
|
+
return {}
|
|
352
|
+
key = str(account_key or "*")
|
|
353
|
+
label = labels[provider].get(key)
|
|
354
|
+
if label is None:
|
|
355
|
+
label = _cctally_account.account_label(conn, key)
|
|
356
|
+
return {
|
|
357
|
+
"accountKey": key,
|
|
358
|
+
"accountLabel": label,
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return fields
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _envelope_rows_weekly(
|
|
365
|
+
conn, descriptor, limit, severity_for, account_fields,
|
|
366
|
+
) -> list[dict]:
|
|
333
367
|
# ``reset_event_id`` (v1.7.2) segments the same (week, threshold)
|
|
334
368
|
# across pre-credit (0) and post-credit (event.id) cohorts, both
|
|
335
369
|
# of which can be alerted. The envelope id must include the
|
|
@@ -340,7 +374,7 @@ def _envelope_rows_weekly(conn, descriptor, limit, severity_for) -> list[dict]:
|
|
|
340
374
|
rows = conn.execute(
|
|
341
375
|
f"""
|
|
342
376
|
SELECT week_start_date, percent_threshold, captured_at_utc,
|
|
343
|
-
alerted_at, cumulative_cost_usd, reset_event_id
|
|
377
|
+
alerted_at, cumulative_cost_usd, reset_event_id, account_key
|
|
344
378
|
FROM {descriptor.milestone_table}
|
|
345
379
|
WHERE alerted_at IS NOT NULL
|
|
346
380
|
ORDER BY alerted_at DESC
|
|
@@ -360,6 +394,7 @@ def _envelope_rows_weekly(conn, descriptor, limit, severity_for) -> list[dict]:
|
|
|
360
394
|
"severity": severity_for(threshold),
|
|
361
395
|
"crossed_at": r["captured_at_utc"],
|
|
362
396
|
"alerted_at": r["alerted_at"],
|
|
397
|
+
**account_fields("claude", r["account_key"]),
|
|
363
398
|
"context": {
|
|
364
399
|
"week_start_date": r["week_start_date"],
|
|
365
400
|
"cumulative_cost_usd": cumulative,
|
|
@@ -377,7 +412,9 @@ def _envelope_rows_weekly(conn, descriptor, limit, severity_for) -> list[dict]:
|
|
|
377
412
|
return out
|
|
378
413
|
|
|
379
414
|
|
|
380
|
-
def _envelope_rows_five_hour(
|
|
415
|
+
def _envelope_rows_five_hour(
|
|
416
|
+
conn, descriptor, limit, severity_for, account_fields,
|
|
417
|
+
) -> list[dict]:
|
|
381
418
|
# Site F (spec §3.2 bucket C / §3.3): widen the row identity to
|
|
382
419
|
# include ``reset_event_id`` so post-credit (seg=event.id) crossings
|
|
383
420
|
# of the same (window_key, threshold) don't collide with pre-credit
|
|
@@ -387,10 +424,12 @@ def _envelope_rows_five_hour(conn, descriptor, limit, severity_for) -> list[dict
|
|
|
387
424
|
rows = conn.execute(
|
|
388
425
|
f"""
|
|
389
426
|
SELECT m.five_hour_window_key, m.percent_threshold, m.captured_at_utc,
|
|
390
|
-
m.alerted_at, m.block_cost_usd, m.reset_event_id,
|
|
427
|
+
m.alerted_at, m.block_cost_usd, m.reset_event_id, m.account_key,
|
|
391
428
|
b.block_start_at
|
|
392
429
|
FROM {descriptor.milestone_table} m
|
|
393
|
-
LEFT JOIN five_hour_blocks b
|
|
430
|
+
LEFT JOIN five_hour_blocks b
|
|
431
|
+
ON b.five_hour_window_key = m.five_hour_window_key
|
|
432
|
+
AND b.account_key = m.account_key
|
|
394
433
|
WHERE m.alerted_at IS NOT NULL
|
|
395
434
|
ORDER BY m.alerted_at DESC
|
|
396
435
|
LIMIT ?
|
|
@@ -410,6 +449,7 @@ def _envelope_rows_five_hour(conn, descriptor, limit, severity_for) -> list[dict
|
|
|
410
449
|
"severity": severity_for(threshold),
|
|
411
450
|
"crossed_at": r["captured_at_utc"],
|
|
412
451
|
"alerted_at": r["alerted_at"],
|
|
452
|
+
**account_fields("claude", r["account_key"]),
|
|
413
453
|
"context": {
|
|
414
454
|
"five_hour_window_key": int(r["five_hour_window_key"]),
|
|
415
455
|
"block_start_at": r["block_start_at"] or "",
|
|
@@ -420,7 +460,9 @@ def _envelope_rows_five_hour(conn, descriptor, limit, severity_for) -> list[dict
|
|
|
420
460
|
return out
|
|
421
461
|
|
|
422
462
|
|
|
423
|
-
def _envelope_rows_budget_family(
|
|
463
|
+
def _envelope_rows_budget_family(
|
|
464
|
+
conn, descriptor, limit, severity_for, account_fields,
|
|
465
|
+
) -> list[dict]:
|
|
424
466
|
# Unified vendor-tagged budget axis (#143). ONE mapper backs BOTH the
|
|
425
467
|
# ``budget`` (``vendor='claude'``, issue #19) and ``codex_budget``
|
|
426
468
|
# (``vendor='codex'``, calendar-period-codex-budgets spec §6) axes —
|
|
@@ -461,7 +503,7 @@ def _envelope_rows_budget_family(conn, descriptor, limit, severity_for) -> list[
|
|
|
461
503
|
SELECT period_start_at,
|
|
462
504
|
COALESCE(period, ?) AS period,
|
|
463
505
|
threshold, crossed_at_utc, alerted_at,
|
|
464
|
-
budget_usd, spent_usd, consumption_pct
|
|
506
|
+
budget_usd, spent_usd, consumption_pct, account_key
|
|
465
507
|
FROM {descriptor.milestone_table}
|
|
466
508
|
WHERE vendor = ? AND alerted_at IS NOT NULL
|
|
467
509
|
ORDER BY alerted_at DESC
|
|
@@ -498,12 +540,15 @@ def _envelope_rows_budget_family(conn, descriptor, limit, severity_for) -> list[
|
|
|
498
540
|
"severity": severity_for(threshold),
|
|
499
541
|
"crossed_at": r["crossed_at_utc"],
|
|
500
542
|
"alerted_at": r["alerted_at"],
|
|
543
|
+
**account_fields(str(vendor), r["account_key"]),
|
|
501
544
|
"context": ctx,
|
|
502
545
|
})
|
|
503
546
|
return out
|
|
504
547
|
|
|
505
548
|
|
|
506
|
-
def _envelope_rows_projected(
|
|
549
|
+
def _envelope_rows_projected(
|
|
550
|
+
conn, descriptor, limit, severity_for, account_fields,
|
|
551
|
+
) -> list[dict]:
|
|
507
552
|
# Fourth axis (issue #121): projected-pace threshold crossings. Like
|
|
508
553
|
# budget, projected alerts re-anchor ``week_start_at`` on a mid-week
|
|
509
554
|
# reset, so there is NO ``reset_event_id`` segment — the new window gets
|
|
@@ -524,7 +569,7 @@ def _envelope_rows_projected(conn, descriptor, limit, severity_for) -> list[dict
|
|
|
524
569
|
SELECT week_start_at,
|
|
525
570
|
COALESCE(period, 'subscription-week') AS period,
|
|
526
571
|
metric, threshold, projected_value,
|
|
527
|
-
denominator, crossed_at_utc, alerted_at
|
|
572
|
+
denominator, crossed_at_utc, alerted_at, account_key
|
|
528
573
|
FROM {descriptor.milestone_table}
|
|
529
574
|
WHERE alerted_at IS NOT NULL
|
|
530
575
|
ORDER BY alerted_at DESC
|
|
@@ -547,6 +592,10 @@ def _envelope_rows_projected(conn, descriptor, limit, severity_for) -> list[dict
|
|
|
547
592
|
"severity": severity_for(threshold),
|
|
548
593
|
"crossed_at": r["crossed_at_utc"],
|
|
549
594
|
"alerted_at": r["alerted_at"],
|
|
595
|
+
**account_fields(
|
|
596
|
+
"codex" if metric == "codex_budget_usd" else "claude",
|
|
597
|
+
r["account_key"],
|
|
598
|
+
),
|
|
550
599
|
"context": {
|
|
551
600
|
"week_start_at": r["week_start_at"],
|
|
552
601
|
"metric": metric,
|
|
@@ -557,7 +606,9 @@ def _envelope_rows_projected(conn, descriptor, limit, severity_for) -> list[dict
|
|
|
557
606
|
return out
|
|
558
607
|
|
|
559
608
|
|
|
560
|
-
def _envelope_rows_project_budget(
|
|
609
|
+
def _envelope_rows_project_budget(
|
|
610
|
+
conn, descriptor, limit, severity_for, account_fields,
|
|
611
|
+
) -> list[dict]:
|
|
561
612
|
# Fifth axis (issue #19 / #121): PER-PROJECT equiv-$ budget threshold
|
|
562
613
|
# crossings. Like the global budget axis, project-budget alerts re-anchor
|
|
563
614
|
# ``week_start_at`` on a mid-week reset, so there is NO ``reset_event_id``
|
|
@@ -575,7 +626,7 @@ def _envelope_rows_project_budget(conn, descriptor, limit, severity_for) -> list
|
|
|
575
626
|
rows = conn.execute(
|
|
576
627
|
f"""
|
|
577
628
|
SELECT week_start_at, project_key, threshold, budget_usd, spent_usd,
|
|
578
|
-
consumption_pct, crossed_at_utc, alerted_at
|
|
629
|
+
consumption_pct, crossed_at_utc, alerted_at, account_key
|
|
579
630
|
FROM {descriptor.milestone_table}
|
|
580
631
|
WHERE alerted_at IS NOT NULL
|
|
581
632
|
ORDER BY alerted_at DESC
|
|
@@ -605,6 +656,7 @@ def _envelope_rows_project_budget(conn, descriptor, limit, severity_for) -> list
|
|
|
605
656
|
"severity": severity_for(threshold),
|
|
606
657
|
"crossed_at": r["crossed_at_utc"],
|
|
607
658
|
"alerted_at": r["alerted_at"],
|
|
659
|
+
**account_fields("claude", r["account_key"]),
|
|
608
660
|
"context": {
|
|
609
661
|
"week_start_at": r["week_start_at"],
|
|
610
662
|
"project": label_by_key.get(project_key, project_key),
|
|
@@ -795,12 +847,15 @@ def _build_alerts_envelope_array(
|
|
|
795
847
|
c = sys.modules["cctally"]
|
|
796
848
|
registry = c.AXIS_REGISTRY
|
|
797
849
|
severity_for = c.severity_for
|
|
850
|
+
account_fields = _alert_account_resolver(conn)
|
|
798
851
|
out: list[dict] = []
|
|
799
852
|
for descriptor in registry:
|
|
800
853
|
mapper = _ENVELOPE_AXIS_MAPPERS.get(descriptor.id)
|
|
801
854
|
if mapper is None: # pragma: no cover - registry/mapper drift guard
|
|
802
855
|
continue
|
|
803
|
-
out.extend(mapper(
|
|
856
|
+
out.extend(mapper(
|
|
857
|
+
conn, descriptor, limit, severity_for, account_fields,
|
|
858
|
+
))
|
|
804
859
|
|
|
805
860
|
# Python's list.sort is stable. When two alerts share the same
|
|
806
861
|
# `alerted_at` ISO string (rare; multiple axes firing within the same
|
|
@@ -1363,9 +1418,11 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
1363
1418
|
_cfg_for_alerts.get("dashboard"), dict) else {}
|
|
1364
1419
|
_cfm = _dash_cfg.get("cache_failure_markers", True)
|
|
1365
1420
|
_lt = _dash_cfg.get("live_tail", True)
|
|
1421
|
+
_lan_auth = _dash_cfg.get("lan_auth", True)
|
|
1366
1422
|
dashboard_prefs = {
|
|
1367
1423
|
"cache_failure_markers": _cfm if isinstance(_cfm, bool) else True,
|
|
1368
1424
|
"live_tail": _lt if isinstance(_lt, bool) else True,
|
|
1425
|
+
"lan_auth": _lan_auth if isinstance(_lan_auth, bool) else True,
|
|
1369
1426
|
}
|
|
1370
1427
|
|
|
1371
1428
|
# Mirror update-state.json + update-suppress.json into the envelope
|
|
@@ -2422,9 +2422,29 @@ def _alerts_wire(
|
|
|
2422
2422
|
so it stays visible under focus and the client labels it as vendor-wide.
|
|
2423
2423
|
"""
|
|
2424
2424
|
rows: list[dict[str, object]] = []
|
|
2425
|
+
if decorated:
|
|
2426
|
+
import _cctally_account
|
|
2427
|
+
|
|
2428
|
+
account_labels = _cctally_account.display_label_map(stats_conn, "codex")
|
|
2429
|
+
account_labels.update({"*": "All accounts", "unattributed": "Unattributed"})
|
|
2430
|
+
else:
|
|
2431
|
+
account_labels = {}
|
|
2425
2432
|
|
|
2426
2433
|
def _account(value: object) -> dict[str, object]:
|
|
2427
|
-
|
|
2434
|
+
if not decorated:
|
|
2435
|
+
return {}
|
|
2436
|
+
|
|
2437
|
+
key = str(value or _CODEX_VENDOR_WIDE_ACCOUNT)
|
|
2438
|
+
label = account_labels.get(key)
|
|
2439
|
+
if label is None:
|
|
2440
|
+
label = _cctally_account.account_label(stats_conn, key)
|
|
2441
|
+
return {
|
|
2442
|
+
# Retained as the internal account-scope selector used by the
|
|
2443
|
+
# source-state builder; the camel fields are the public #345 wire.
|
|
2444
|
+
"account_key": key,
|
|
2445
|
+
"accountKey": key,
|
|
2446
|
+
"accountLabel": label,
|
|
2447
|
+
}
|
|
2428
2448
|
|
|
2429
2449
|
try:
|
|
2430
2450
|
for period, threshold, consumption_pct, crossed_at, account_key in stats_conn.execute(
|
|
@@ -2880,6 +2900,12 @@ def _codex_accounts_wire(
|
|
|
2880
2900
|
}
|
|
2881
2901
|
if is_unattributed:
|
|
2882
2902
|
card["unattributed"] = True
|
|
2903
|
+
elif cyc is not None and cyc.evidence_stale:
|
|
2904
|
+
# #360 / #416 closeout: freshness belongs to the account whose
|
|
2905
|
+
# resolved cycle supplied the card. The aggregate hero marker
|
|
2906
|
+
# cannot speak for a fresh sibling, and staleness is disclosure
|
|
2907
|
+
# only — the retained percentage, reset and spend remain useful.
|
|
2908
|
+
card["cycleFreshness"] = "stale"
|
|
2883
2909
|
accounts_wire.append(card)
|
|
2884
2910
|
if cyc is not None and not is_unattributed:
|
|
2885
2911
|
hero_cycles_wire.append({
|