cctally 1.72.0 → 1.74.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 +22 -0
- package/bin/_cctally_cache.py +271 -55
- package/bin/_cctally_core.py +20 -40
- package/bin/_cctally_dashboard.py +35 -1
- package/bin/_cctally_dashboard_conversation.py +731 -94
- package/bin/_cctally_dashboard_share.py +38 -3
- package/bin/_cctally_dashboard_sources.py +812 -74
- package/bin/_cctally_db.py +104 -1
- package/bin/_cctally_doctor.py +86 -4
- package/bin/_cctally_parser.py +17 -4
- package/bin/_cctally_record.py +162 -33
- package/bin/_cctally_refresh.py +58 -36
- package/bin/_cctally_source_analytics.py +288 -3
- package/bin/_cctally_statusline.py +811 -82
- package/bin/_cctally_transcript.py +223 -7
- package/bin/_cctally_tui.py +17 -14
- package/bin/_lib_aggregators.py +48 -34
- package/bin/_lib_codex_conversation_export.py +124 -0
- package/bin/_lib_codex_conversation_query.py +500 -28
- package/bin/_lib_codex_conversation_watch.py +323 -0
- package/bin/_lib_conversation_dispatch.py +269 -7
- package/bin/_lib_conversation_query.py +88 -0
- package/bin/_lib_dashboard_sources.py +20 -7
- package/bin/_lib_doctor.py +106 -0
- package/bin/_lib_jsonl.py +11 -0
- package/bin/_lib_pricing.py +7 -4
- package/bin/_lib_pricing_check.py +7 -1
- package/bin/_lib_quota.py +12 -11
- package/bin/_lib_statusline_candidates.py +734 -0
- package/bin/_lib_view_models.py +50 -14
- package/bin/cctally +31 -2
- package/dashboard/static/assets/index-DeQjEMO6.js +80 -0
- package/dashboard/static/assets/index-ZiPO8veo.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-BWadAHxC.js +0 -80
- package/dashboard/static/assets/index-D2nwo_ln.css +0 -1
package/bin/_cctally_refresh.py
CHANGED
|
@@ -624,10 +624,6 @@ def _refresh_usage_inproc(timeout_seconds: float = 5.0) -> _RefreshUsageResult:
|
|
|
624
624
|
status="fetch_failed",
|
|
625
625
|
reason=f"invalid oauth_usage config: {exc}",
|
|
626
626
|
)
|
|
627
|
-
# Successful API response — clear any pending 429 backoff + counter, so a
|
|
628
|
-
# user-confirmed recovery lets automatic polling resume (spec §4).
|
|
629
|
-
c._oauth_backoff_reset()
|
|
630
|
-
|
|
631
627
|
seven = api.get("seven_day") or {}
|
|
632
628
|
try:
|
|
633
629
|
# Normalize at the OAuth ingress so the payload JSON published
|
|
@@ -681,12 +677,21 @@ def _refresh_usage_inproc(timeout_seconds: float = 5.0) -> _RefreshUsageResult:
|
|
|
681
677
|
# this, cmd_record_usage would fall back to its "statusline" default.
|
|
682
678
|
source="api",
|
|
683
679
|
)
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
680
|
+
authoritative = c._authoritative_record_usage(
|
|
681
|
+
record_args,
|
|
682
|
+
{
|
|
683
|
+
"sevenDay",
|
|
684
|
+
*({"fiveHour"} if five_pct is not None else set()),
|
|
685
|
+
},
|
|
686
|
+
)
|
|
687
|
+
if authoritative.status != "ok":
|
|
688
|
+
return _RefreshUsageResult(
|
|
689
|
+
status="record_failed", reason=authoritative.reason
|
|
690
|
+
)
|
|
691
|
+
# A fetched response becomes evidence of recovery only after its
|
|
692
|
+
# write-ahead tombstones, database reconciliation, selected control, and
|
|
693
|
+
# selected-freshness marker have all committed.
|
|
694
|
+
c._oauth_backoff_reset()
|
|
690
695
|
|
|
691
696
|
# §5.6 Option C: route through cctally's namespace so
|
|
692
697
|
# `monkeypatch.setitem(ns, "_bust_statusline_cache", …)` propagates
|
|
@@ -877,18 +882,38 @@ def _hook_tick_oauth_refresh(
|
|
|
877
882
|
throttle_seconds = float(_get_oauth_usage_config(c.load_config())["throttle_seconds"])
|
|
878
883
|
except OauthUsageConfigError:
|
|
879
884
|
throttle_seconds = float(c.HOOK_TICK_DEFAULT_THROTTLE_SECONDS)
|
|
880
|
-
#
|
|
881
|
-
#
|
|
882
|
-
#
|
|
883
|
-
#
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
885
|
+
# The automatic writer takes the selected-state lock *before* rechecking
|
|
886
|
+
# its suppression conditions. A concurrent publisher can otherwise make
|
|
887
|
+
# this tick issue an unnecessary OAuth request between the initial gate
|
|
888
|
+
# and the request itself.
|
|
889
|
+
try:
|
|
890
|
+
with c._selected_state_lock():
|
|
891
|
+
return _hook_tick_oauth_refresh_locked(
|
|
892
|
+
c,
|
|
893
|
+
token=token,
|
|
894
|
+
timeout_seconds=timeout_seconds,
|
|
895
|
+
throttle_seconds=throttle_seconds,
|
|
896
|
+
)
|
|
897
|
+
except OSError:
|
|
898
|
+
return "err(record-usage=exc)", None
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _hook_tick_oauth_refresh_locked(
|
|
902
|
+
c,
|
|
903
|
+
*,
|
|
904
|
+
token: str,
|
|
905
|
+
timeout_seconds: float,
|
|
906
|
+
throttle_seconds: float,
|
|
907
|
+
) -> tuple[str, dict | None]:
|
|
908
|
+
"""Automatic OAuth path with the selected-state lock already held."""
|
|
909
|
+
# Backfill gate: an inflight/invalid tombstone bypasses only selected-age
|
|
910
|
+
# suppression so a later authoritative result can repair it. The normal
|
|
911
|
+
# throttle and 429 deadline continue to bound OAuth traffic.
|
|
912
|
+
now_epoch = int(time.time())
|
|
913
|
+
needs_repair = c._authoritative_repair_required(now_epoch=now_epoch)
|
|
890
914
|
obs_age = c._statusline_observe_age_seconds()
|
|
891
|
-
if
|
|
915
|
+
if (not needs_repair
|
|
916
|
+
and obs_age < float(_cctally_core.OAUTH_BACKFILL_STALE_SECONDS)):
|
|
892
917
|
return f"skipped(statusline-fresh:{int(obs_age)}s)", None
|
|
893
918
|
backoff_remaining = c._oauth_backoff_remaining_seconds()
|
|
894
919
|
if backoff_remaining > 0:
|
|
@@ -899,9 +924,6 @@ def _hook_tick_oauth_refresh(
|
|
|
899
924
|
try:
|
|
900
925
|
api = c._fetch_oauth_usage(token=token, timeout_seconds=timeout_seconds)
|
|
901
926
|
except RefreshUsageRateLimitError as exc:
|
|
902
|
-
# Arm the shared backoff deadline so the next tick is suppressed
|
|
903
|
-
# rather than immediately re-polling (spec §4). Honors Retry-After
|
|
904
|
-
# when present, else conservative exponential backoff.
|
|
905
927
|
c._oauth_backoff_register_429(
|
|
906
928
|
retry_after_deadline=getattr(exc, "retry_after_deadline", None),
|
|
907
929
|
now=time.time(),
|
|
@@ -911,8 +933,6 @@ def _hook_tick_oauth_refresh(
|
|
|
911
933
|
return "err(network)", None
|
|
912
934
|
except RefreshUsageMalformedError:
|
|
913
935
|
return "err(parse)", None
|
|
914
|
-
# Successful API response — clear any pending 429 backoff + counter.
|
|
915
|
-
c._oauth_backoff_reset()
|
|
916
936
|
seven = api["seven_day"]
|
|
917
937
|
try:
|
|
918
938
|
seven_pct = c._normalize_percent(float(seven["utilization"]))
|
|
@@ -922,10 +942,6 @@ def _hook_tick_oauth_refresh(
|
|
|
922
942
|
five = api.get("five_hour") if isinstance(api.get("five_hour"), dict) else None
|
|
923
943
|
five_pct: float | None = None
|
|
924
944
|
five_resets_epoch: int | None = None
|
|
925
|
-
# An inactive 5h window arrives as `resets_at: null` (key present, value
|
|
926
|
-
# null). Require a string here — mirrors the seven_day guard in
|
|
927
|
-
# _fetch_oauth_usage — so _iso_to_epoch(None) can't raise AttributeError;
|
|
928
|
-
# a malformed (non-null) string still degrades via the except below.
|
|
929
945
|
if (five is not None and "utilization" in five
|
|
930
946
|
and isinstance(five.get("resets_at"), str)):
|
|
931
947
|
try:
|
|
@@ -939,15 +955,21 @@ def _hook_tick_oauth_refresh(
|
|
|
939
955
|
resets_at=str(seven_resets_epoch),
|
|
940
956
|
five_hour_percent=five_pct,
|
|
941
957
|
five_hour_resets_at=str(five_resets_epoch) if five_resets_epoch is not None else None,
|
|
942
|
-
# OAuth feeder — label the row "api" (spec §5).
|
|
943
958
|
source="api",
|
|
944
959
|
)
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
960
|
+
authoritative = c._authoritative_record_usage(
|
|
961
|
+
record_args,
|
|
962
|
+
{"sevenDay", *({"fiveHour"} if five_pct is not None else set())},
|
|
963
|
+
lock_held=True,
|
|
964
|
+
)
|
|
965
|
+
if authoritative.status != "ok":
|
|
966
|
+
reason = authoritative.reason or ""
|
|
967
|
+
if reason.startswith("exit "):
|
|
968
|
+
return f"err(record-usage={reason[5:]})", None
|
|
948
969
|
return "err(record-usage=exc)", None
|
|
949
|
-
|
|
950
|
-
|
|
970
|
+
# Success is acknowledged to the shared OAuth backoff only after the
|
|
971
|
+
# authoritative writer has published tombstones, control, and freshness.
|
|
972
|
+
c._oauth_backoff_reset()
|
|
951
973
|
parts = [f"7d={int(round(seven_pct))}"]
|
|
952
974
|
if five_pct is not None:
|
|
953
975
|
parts.append(f"5h={int(round(five_pct))}")
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import datetime as dt
|
|
5
|
+
import dataclasses
|
|
5
6
|
import hashlib
|
|
6
7
|
import json
|
|
7
8
|
import pathlib
|
|
@@ -20,6 +21,7 @@ from _cctally_core import (
|
|
|
20
21
|
)
|
|
21
22
|
from _cctally_cache import _codex_provider_roots
|
|
22
23
|
from _lib_quota import build_blocks
|
|
24
|
+
from _lib_pricing import _calculate_codex_entry_cost
|
|
23
25
|
from _cctally_quota import load_codex_quota_observations
|
|
24
26
|
from _lib_source_analytics import (
|
|
25
27
|
AnalyticsWindow,
|
|
@@ -50,6 +52,37 @@ class SourceUsageError(ValueError):
|
|
|
50
52
|
"""A provider-aware request is syntactically or semantically invalid."""
|
|
51
53
|
|
|
52
54
|
|
|
55
|
+
@dataclasses.dataclass(frozen=True)
|
|
56
|
+
class CodexProjectMetadataHealth:
|
|
57
|
+
"""A root-qualified partition of retained Codex accounting metadata."""
|
|
58
|
+
|
|
59
|
+
total_rows: int
|
|
60
|
+
qualified_rows: int
|
|
61
|
+
missing_conversation_key_rows: int
|
|
62
|
+
missing_thread_join_rows: int
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def incomplete_rows(self) -> int:
|
|
66
|
+
return self.missing_conversation_key_rows + self.missing_thread_join_rows
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclasses.dataclass(frozen=True)
|
|
70
|
+
class RootedCodexAccountingEntry:
|
|
71
|
+
"""One cache-only Codex accounting row with authoritative root identity."""
|
|
72
|
+
|
|
73
|
+
timestamp: dt.datetime
|
|
74
|
+
session_id: str
|
|
75
|
+
source_path: str
|
|
76
|
+
source_root_key: str
|
|
77
|
+
model: str
|
|
78
|
+
input_tokens: int
|
|
79
|
+
cached_input_tokens: int
|
|
80
|
+
output_tokens: int
|
|
81
|
+
reasoning_output_tokens: int
|
|
82
|
+
total_tokens: int
|
|
83
|
+
cost_usd: float
|
|
84
|
+
|
|
85
|
+
|
|
53
86
|
_OPAQUE_PROJECT_KEY_RE = re.compile(r"^project:[0-9a-f]{24}$")
|
|
54
87
|
|
|
55
88
|
|
|
@@ -74,6 +107,18 @@ _QUALIFIED_CODEX_ENTRIES_SQL = """
|
|
|
74
107
|
"""
|
|
75
108
|
|
|
76
109
|
|
|
110
|
+
_INHERITED_CODEX_PROJECT_METADATA_SQL = """
|
|
111
|
+
SELECT files.source_root_key, files.path, inherited.cwd, inherited.git_json
|
|
112
|
+
FROM codex_session_files AS files
|
|
113
|
+
JOIN codex_conversation_threads AS inherited
|
|
114
|
+
ON inherited.source_root_key = files.source_root_key
|
|
115
|
+
AND inherited.native_thread_id = files.last_native_thread_id
|
|
116
|
+
WHERE files.last_native_thread_id IS NOT NULL
|
|
117
|
+
AND files.last_native_thread_id != ''
|
|
118
|
+
ORDER BY inherited.last_seen_utc DESC, inherited.conversation_key DESC
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
|
|
77
122
|
_CODEX_ACCOUNTING_ENTRIES_SQL = """
|
|
78
123
|
SELECT timestamp_utc, source_root_key, conversation_key, model,
|
|
79
124
|
input_tokens, cached_input_tokens, output_tokens,
|
|
@@ -86,6 +131,75 @@ _CODEX_ACCOUNTING_ENTRIES_SQL = """
|
|
|
86
131
|
"""
|
|
87
132
|
|
|
88
133
|
|
|
134
|
+
_ROOTED_CODEX_ACCOUNTING_ENTRIES_SQL = """
|
|
135
|
+
SELECT timestamp_utc, session_id, source_path, source_root_key, model,
|
|
136
|
+
input_tokens, cached_input_tokens, output_tokens,
|
|
137
|
+
reasoning_output_tokens, total_tokens
|
|
138
|
+
FROM codex_session_entries INDEXED BY idx_codex_entries_ts_root_conversation
|
|
139
|
+
WHERE timestamp_utc >= ?
|
|
140
|
+
AND timestamp_utc < ?
|
|
141
|
+
{root_predicate}
|
|
142
|
+
ORDER BY timestamp_utc ASC, source_root_key ASC, conversation_key ASC, id ASC
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
_CODEX_PROJECT_METADATA_HEALTH_SQL = """
|
|
147
|
+
SELECT
|
|
148
|
+
COUNT(*) AS total_rows,
|
|
149
|
+
COALESCE(SUM(CASE
|
|
150
|
+
WHEN entries.conversation_key IS NULL OR entries.conversation_key = ''
|
|
151
|
+
THEN 1 ELSE 0 END), 0) AS missing_conversation_key_rows,
|
|
152
|
+
COALESCE(SUM(CASE
|
|
153
|
+
WHEN entries.conversation_key IS NOT NULL
|
|
154
|
+
AND entries.conversation_key != ''
|
|
155
|
+
AND threads.conversation_key IS NULL
|
|
156
|
+
AND NOT EXISTS (
|
|
157
|
+
SELECT 1 FROM codex_session_files AS files
|
|
158
|
+
JOIN codex_conversation_threads AS inherited
|
|
159
|
+
ON inherited.source_root_key = entries.source_root_key
|
|
160
|
+
AND inherited.native_thread_id = files.last_native_thread_id
|
|
161
|
+
WHERE files.path = entries.source_path
|
|
162
|
+
AND files.source_root_key = entries.source_root_key
|
|
163
|
+
)
|
|
164
|
+
THEN 1 ELSE 0 END), 0) AS missing_thread_join_rows
|
|
165
|
+
FROM codex_session_entries AS entries
|
|
166
|
+
LEFT JOIN codex_conversation_threads AS threads
|
|
167
|
+
ON threads.conversation_key = entries.conversation_key
|
|
168
|
+
AND threads.source_root_key = entries.source_root_key
|
|
169
|
+
WHERE (? IS NULL OR entries.timestamp_utc >= ?)
|
|
170
|
+
AND (? IS NULL OR entries.timestamp_utc < ?)
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
_CODEX_PROJECT_METADATA_HEALTH_LEGACY_SQL = """
|
|
175
|
+
SELECT
|
|
176
|
+
COUNT(*) AS total_rows,
|
|
177
|
+
COALESCE(SUM(CASE
|
|
178
|
+
WHEN entries.conversation_key IS NULL OR entries.conversation_key = ''
|
|
179
|
+
THEN 1 ELSE 0 END), 0) AS missing_conversation_key_rows,
|
|
180
|
+
COALESCE(SUM(CASE
|
|
181
|
+
WHEN entries.conversation_key IS NOT NULL
|
|
182
|
+
AND entries.conversation_key != ''
|
|
183
|
+
AND threads.conversation_key IS NULL
|
|
184
|
+
THEN 1 ELSE 0 END), 0) AS missing_thread_join_rows
|
|
185
|
+
FROM codex_session_entries AS entries
|
|
186
|
+
LEFT JOIN codex_conversation_threads AS threads
|
|
187
|
+
ON threads.conversation_key = entries.conversation_key
|
|
188
|
+
AND threads.source_root_key = entries.source_root_key
|
|
189
|
+
WHERE (? IS NULL OR entries.timestamp_utc >= ?)
|
|
190
|
+
AND (? IS NULL OR entries.timestamp_utc < ?)
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _supports_native_file_aliases(cache_conn: sqlite3.Connection) -> bool:
|
|
195
|
+
"""Return whether this cache generation can link child files to a root task."""
|
|
196
|
+
try:
|
|
197
|
+
columns = cache_conn.execute("PRAGMA table_info(codex_session_files)").fetchall()
|
|
198
|
+
except sqlite3.Error:
|
|
199
|
+
return False
|
|
200
|
+
return any(len(row) > 1 and row[1] == "last_native_thread_id" for row in columns)
|
|
201
|
+
|
|
202
|
+
|
|
89
203
|
def _cctally():
|
|
90
204
|
return sys.modules["cctally"]
|
|
91
205
|
|
|
@@ -100,6 +214,160 @@ def _parse_timestamp(value: object) -> dt.datetime:
|
|
|
100
214
|
return timestamp.astimezone(UTC)
|
|
101
215
|
|
|
102
216
|
|
|
217
|
+
def _metadata_health_bound(value: dt.datetime | None, *, name: str) -> str | None:
|
|
218
|
+
if value is None:
|
|
219
|
+
return None
|
|
220
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
221
|
+
raise ValueError(f"{name} must be timezone-aware")
|
|
222
|
+
# Retain the canonical ``+00:00`` representation written by Codex ingest
|
|
223
|
+
# and consumed by the accounting readers: SQLite TEXT bounds must sort in
|
|
224
|
+
# the same encoding for the [start, end) window to remain exact.
|
|
225
|
+
return value.astimezone(UTC).isoformat()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def load_codex_project_metadata_health(
|
|
229
|
+
*,
|
|
230
|
+
cache_conn: sqlite3.Connection,
|
|
231
|
+
start: dt.datetime | None = None,
|
|
232
|
+
end: dt.datetime | None = None,
|
|
233
|
+
) -> CodexProjectMetadataHealth:
|
|
234
|
+
"""Classify retained Codex accounting in one root-qualified SQL read.
|
|
235
|
+
|
|
236
|
+
Bounds are both timezone-aware or both omitted; the omitted form serves the
|
|
237
|
+
all-history doctor check. The output is an exhaustive, mutually-exclusive
|
|
238
|
+
count partition and contains no source or identity data.
|
|
239
|
+
"""
|
|
240
|
+
if (start is None) != (end is None):
|
|
241
|
+
raise ValueError("start and end must be supplied together or both omitted")
|
|
242
|
+
bound_start = _metadata_health_bound(start, name="start")
|
|
243
|
+
bound_end = _metadata_health_bound(end, name="end")
|
|
244
|
+
if start is not None and end is not None and start > end:
|
|
245
|
+
raise ValueError("start must not be after end")
|
|
246
|
+
|
|
247
|
+
row = cache_conn.execute(
|
|
248
|
+
(_CODEX_PROJECT_METADATA_HEALTH_SQL if _supports_native_file_aliases(cache_conn)
|
|
249
|
+
else _CODEX_PROJECT_METADATA_HEALTH_LEGACY_SQL),
|
|
250
|
+
(bound_start, bound_start, bound_end, bound_end),
|
|
251
|
+
).fetchone()
|
|
252
|
+
if row is None or len(row) != 3:
|
|
253
|
+
raise RuntimeError("Codex project metadata health query returned no partition")
|
|
254
|
+
try:
|
|
255
|
+
total_rows, missing_key_rows, missing_join_rows = (int(value) for value in row)
|
|
256
|
+
except (TypeError, ValueError) as exc:
|
|
257
|
+
raise RuntimeError("Codex project metadata health query returned invalid counts") from exc
|
|
258
|
+
qualified_rows = total_rows - missing_key_rows - missing_join_rows
|
|
259
|
+
values = (total_rows, qualified_rows, missing_key_rows, missing_join_rows)
|
|
260
|
+
if any(value < 0 for value in values) or total_rows != sum(values[1:]):
|
|
261
|
+
raise RuntimeError("Codex project metadata health partition is invalid")
|
|
262
|
+
return CodexProjectMetadataHealth(
|
|
263
|
+
total_rows=total_rows,
|
|
264
|
+
qualified_rows=qualified_rows,
|
|
265
|
+
missing_conversation_key_rows=missing_key_rows,
|
|
266
|
+
missing_thread_join_rows=missing_join_rows,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def load_cached_rooted_codex_accounting_entries(
|
|
271
|
+
start: dt.datetime,
|
|
272
|
+
end: dt.datetime,
|
|
273
|
+
*,
|
|
274
|
+
speed: str,
|
|
275
|
+
cache_conn: sqlite3.Connection,
|
|
276
|
+
source_root_keys: Iterable[str] | None = None,
|
|
277
|
+
) -> tuple[RootedCodexAccountingEntry, ...]:
|
|
278
|
+
"""Read bounded accounting from one caller-owned cache snapshot only.
|
|
279
|
+
|
|
280
|
+
This reader intentionally never syncs, opens or closes a database, reads
|
|
281
|
+
rollouts, resolves projects, or discovers configured Codex roots. The
|
|
282
|
+
cached root key plus source path are the dashboard's file identity. A
|
|
283
|
+
caller can additionally constrain the read to roots that established one
|
|
284
|
+
native cycle; root identities remain internal to this adapter.
|
|
285
|
+
"""
|
|
286
|
+
if start.tzinfo is None or start.utcoffset() is None:
|
|
287
|
+
raise ValueError("start must be timezone-aware")
|
|
288
|
+
if end.tzinfo is None or end.utcoffset() is None:
|
|
289
|
+
raise ValueError("end must be timezone-aware")
|
|
290
|
+
if end <= start:
|
|
291
|
+
raise ValueError("end must be after start")
|
|
292
|
+
root_keys = (
|
|
293
|
+
tuple(sorted({key for key in source_root_keys if isinstance(key, str) and key}))
|
|
294
|
+
if source_root_keys is not None else ()
|
|
295
|
+
)
|
|
296
|
+
if source_root_keys is not None and not root_keys:
|
|
297
|
+
return ()
|
|
298
|
+
root_predicate = (
|
|
299
|
+
"AND source_root_key IN (" + ", ".join("?" for _ in root_keys) + ")"
|
|
300
|
+
if source_root_keys is not None else ""
|
|
301
|
+
)
|
|
302
|
+
try:
|
|
303
|
+
rows = tuple(cache_conn.execute(
|
|
304
|
+
_ROOTED_CODEX_ACCOUNTING_ENTRIES_SQL.format(root_predicate=root_predicate),
|
|
305
|
+
(
|
|
306
|
+
start.astimezone(UTC).isoformat(), end.astimezone(UTC).isoformat(), *root_keys,
|
|
307
|
+
),
|
|
308
|
+
))
|
|
309
|
+
except sqlite3.Error as exc:
|
|
310
|
+
raise QualifiedMetadataUnavailable("Codex accounting metadata is unavailable") from exc
|
|
311
|
+
|
|
312
|
+
result: list[RootedCodexAccountingEntry] = []
|
|
313
|
+
for row in rows:
|
|
314
|
+
try:
|
|
315
|
+
(
|
|
316
|
+
timestamp_raw, session_id_raw, source_path_raw, source_root_key_raw,
|
|
317
|
+
model_raw, input_raw, cached_input_raw, output_raw, reasoning_raw,
|
|
318
|
+
total_raw,
|
|
319
|
+
) = row
|
|
320
|
+
source_path = source_path_raw if isinstance(source_path_raw, str) else ""
|
|
321
|
+
source_root_key = source_root_key_raw if isinstance(source_root_key_raw, str) else ""
|
|
322
|
+
if not source_path or not source_root_key:
|
|
323
|
+
raise ValueError("rooted accounting identity is absent")
|
|
324
|
+
model = str(model_raw)
|
|
325
|
+
input_tokens = int(input_raw)
|
|
326
|
+
cached_input_tokens = int(cached_input_raw)
|
|
327
|
+
output_tokens = int(output_raw)
|
|
328
|
+
reasoning_output_tokens = int(reasoning_raw)
|
|
329
|
+
total_tokens = int(total_raw)
|
|
330
|
+
cost_usd = _calculate_codex_entry_cost(
|
|
331
|
+
model,
|
|
332
|
+
input_tokens,
|
|
333
|
+
cached_input_tokens,
|
|
334
|
+
output_tokens,
|
|
335
|
+
reasoning_output_tokens,
|
|
336
|
+
speed=speed,
|
|
337
|
+
)
|
|
338
|
+
except (TypeError, ValueError, OverflowError) as exc:
|
|
339
|
+
raise QualifiedMetadataUnavailable("Codex accounting metadata is unavailable") from exc
|
|
340
|
+
result.append(RootedCodexAccountingEntry(
|
|
341
|
+
timestamp=_parse_timestamp(timestamp_raw),
|
|
342
|
+
session_id=str(session_id_raw or ""),
|
|
343
|
+
source_path=source_path,
|
|
344
|
+
source_root_key=source_root_key,
|
|
345
|
+
model=model,
|
|
346
|
+
input_tokens=input_tokens,
|
|
347
|
+
cached_input_tokens=cached_input_tokens,
|
|
348
|
+
output_tokens=output_tokens,
|
|
349
|
+
reasoning_output_tokens=reasoning_output_tokens,
|
|
350
|
+
total_tokens=total_tokens,
|
|
351
|
+
cost_usd=cost_usd,
|
|
352
|
+
))
|
|
353
|
+
return tuple(result)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def has_cached_codex_accounting_entries(*, cache_conn: sqlite3.Connection) -> bool:
|
|
357
|
+
"""Return whether any retained Codex accounting exists without range filtering.
|
|
358
|
+
|
|
359
|
+
This intentionally tiny caller-owned cache read lets a missing native cycle
|
|
360
|
+
fail closed even when the dashboard's visible or budget range is empty.
|
|
361
|
+
It neither resolves roots nor reads rollout files.
|
|
362
|
+
"""
|
|
363
|
+
try:
|
|
364
|
+
return cache_conn.execute(
|
|
365
|
+
"SELECT 1 FROM codex_session_entries LIMIT 1"
|
|
366
|
+
).fetchone() is not None
|
|
367
|
+
except sqlite3.Error as exc:
|
|
368
|
+
raise QualifiedMetadataUnavailable("Codex accounting metadata is unavailable") from exc
|
|
369
|
+
|
|
370
|
+
|
|
103
371
|
def _project_label(value: object) -> str:
|
|
104
372
|
"""Render only a basename-like label; absolute paths remain internal."""
|
|
105
373
|
raw = str(value).strip()
|
|
@@ -191,6 +459,12 @@ def load_qualified_codex_entries(
|
|
|
191
459
|
_QUALIFIED_CODEX_ENTRIES_SQL,
|
|
192
460
|
(start.astimezone(UTC).isoformat(), end.astimezone(UTC).isoformat()),
|
|
193
461
|
))
|
|
462
|
+
inherited_metadata: dict[tuple[str, str], sqlite3.Row] = {}
|
|
463
|
+
if _supports_native_file_aliases(conn):
|
|
464
|
+
for inherited in conn.execute(_INHERITED_CODEX_PROJECT_METADATA_SQL):
|
|
465
|
+
identity = (str(inherited["source_root_key"] or ""), str(inherited["path"] or ""))
|
|
466
|
+
if all(identity):
|
|
467
|
+
inherited_metadata.setdefault(identity, inherited)
|
|
194
468
|
except sqlite3.Error as exc:
|
|
195
469
|
raise QualifiedMetadataUnavailable("Codex qualified project metadata is unavailable") from exc
|
|
196
470
|
finally:
|
|
@@ -204,8 +478,19 @@ def load_qualified_codex_entries(
|
|
|
204
478
|
resolved_by_git_json: dict[str, str | None] = {}
|
|
205
479
|
result: list[QualifiedCodexEntry] = []
|
|
206
480
|
for row in rows:
|
|
207
|
-
root_key
|
|
208
|
-
|
|
481
|
+
root_key = row["source_root_key"]
|
|
482
|
+
conversation_key = row["conversation_key"]
|
|
483
|
+
inherited = inherited_metadata.get((str(root_key or ""), str(row["source_path"] or "")))
|
|
484
|
+
try:
|
|
485
|
+
root_key, conversation_key = _require_joined_metadata(row)
|
|
486
|
+
except QualifiedMetadataUnavailable:
|
|
487
|
+
if (
|
|
488
|
+
inherited is None
|
|
489
|
+
or not isinstance(root_key, str) or not root_key
|
|
490
|
+
or not isinstance(conversation_key, str) or not conversation_key
|
|
491
|
+
):
|
|
492
|
+
raise
|
|
493
|
+
cwd = row["cwd"] or (inherited["cwd"] if inherited is not None else None)
|
|
209
494
|
if isinstance(cwd, str) and cwd:
|
|
210
495
|
project = resolved_by_cwd.get(cwd)
|
|
211
496
|
if project is None:
|
|
@@ -218,7 +503,7 @@ def load_qualified_codex_entries(
|
|
|
218
503
|
else _project_label(project.display_key)
|
|
219
504
|
)
|
|
220
505
|
else:
|
|
221
|
-
git_json = row["git_json"]
|
|
506
|
+
git_json = row["git_json"] or (inherited["git_json"] if inherited is not None else None)
|
|
222
507
|
if isinstance(git_json, str) and git_json not in resolved_by_git_json:
|
|
223
508
|
resolved_by_git_json[git_json] = _git_resolved_key(git_json)
|
|
224
509
|
git_key = resolved_by_git_json.get(git_json) if isinstance(git_json, str) else None
|