cctally 1.80.3 → 1.81.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 +29 -0
- package/bin/_cctally_account.py +397 -0
- package/bin/_cctally_alerts.py +58 -2
- package/bin/_cctally_cache.py +696 -166
- package/bin/_cctally_cache_report.py +21 -3
- package/bin/_cctally_config.py +119 -8
- package/bin/_cctally_core.py +343 -48
- package/bin/_cctally_dashboard.py +32 -7
- package/bin/_cctally_dashboard_conversation.py +6 -2
- package/bin/_cctally_dashboard_envelope.py +49 -0
- package/bin/_cctally_dashboard_share.py +78 -1
- package/bin/_cctally_dashboard_sources.py +459 -59
- package/bin/_cctally_db.py +659 -152
- package/bin/_cctally_diff.py +9 -0
- package/bin/_cctally_doctor.py +201 -26
- package/bin/_cctally_five_hour.py +110 -69
- package/bin/_cctally_forecast.py +112 -43
- package/bin/_cctally_journal.py +591 -80
- package/bin/_cctally_milestones.py +180 -67
- package/bin/_cctally_parser.py +87 -0
- package/bin/_cctally_percent_breakdown.py +24 -15
- package/bin/_cctally_project.py +12 -1
- package/bin/_cctally_quota.py +150 -39
- package/bin/_cctally_record.py +491 -141
- package/bin/_cctally_reporting.py +66 -14
- package/bin/_cctally_setup.py +9 -1
- package/bin/_cctally_source_analytics.py +56 -7
- package/bin/_cctally_statusline.py +55 -8
- package/bin/_cctally_store.py +20 -8
- package/bin/_cctally_sync_week.py +30 -10
- package/bin/_cctally_tui.py +99 -18
- package/bin/_cctally_weekrefs.py +38 -15
- package/bin/_lib_accounts.py +325 -0
- package/bin/_lib_alerts_payload.py +25 -4
- package/bin/_lib_cache_writer_lock.py +77 -0
- package/bin/_lib_codex_hooks.py +40 -1
- package/bin/_lib_diff_kernel.py +28 -14
- package/bin/_lib_doctor.py +160 -0
- package/bin/_lib_journal.py +65 -2
- package/bin/_lib_quota.py +81 -4
- package/bin/_lib_render.py +19 -5
- package/bin/_lib_share.py +25 -0
- package/bin/_lib_snapshot_cache.py +8 -0
- package/bin/_lib_subscription_weeks.py +16 -2
- package/bin/_lib_view_models.py +18 -9
- package/bin/cctally +43 -4
- package/dashboard/static/assets/index-DJP4gEB7.js +92 -0
- package/dashboard/static/assets/index-Dk1nplOz.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-B3y14l1o.js +0 -92
- package/dashboard/static/assets/index-Cc2ykqF_.css +0 -1
|
@@ -29,6 +29,7 @@ from _cctally_source_analytics import (
|
|
|
29
29
|
load_qualified_codex_entries,
|
|
30
30
|
)
|
|
31
31
|
import _lib_log
|
|
32
|
+
import _lib_accounts
|
|
32
33
|
from _lib_dashboard_sources import (
|
|
33
34
|
CapabilityRecord,
|
|
34
35
|
ProjectionCoherence,
|
|
@@ -69,6 +70,47 @@ DASHBOARD_QUOTA_OBSERVATION_LIMIT = 1000
|
|
|
69
70
|
DASHBOARD_QUOTA_RECENT_DAYS = 35
|
|
70
71
|
|
|
71
72
|
|
|
73
|
+
def accounts_identity_digest(stats_conn: sqlite3.Connection) -> str:
|
|
74
|
+
"""Digest of the account registry + the providers' live active-account state
|
|
75
|
+
(#341 spec §4 finding 9 — the identity-only invalidation signal).
|
|
76
|
+
|
|
77
|
+
Empty when NO account has ever been observed (the ``accounts`` registry is
|
|
78
|
+
empty): every <=1-account install and every pre-accounts fixture is
|
|
79
|
+
byte-neutral (the caller never appends an empty digest to a version/idle
|
|
80
|
+
signature), and no identity-file read happens on the idle path. Once accounts
|
|
81
|
+
exist it folds together (a) each registry row's ``account_key`` / provider /
|
|
82
|
+
label / label_source — catching a new account observed or a label edit — and
|
|
83
|
+
(b) the CURRENTLY-active account keys (``resolve_active_account_keys`` reads
|
|
84
|
+
``~/.claude.json`` + each Codex root's ``auth.json``, stable-read + best
|
|
85
|
+
effort) — catching an in-place account SWITCH with zero new ingested rows.
|
|
86
|
+
Folded into both the outer dispatch/idle signature and each physical source's
|
|
87
|
+
``data_version``, so a switch rebuilds the source state (flipping the
|
|
88
|
+
``active`` marker) on the very next tick without any new rows.
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
row = stats_conn.execute("SELECT COUNT(*) FROM accounts").fetchone()
|
|
92
|
+
except sqlite3.Error:
|
|
93
|
+
return ""
|
|
94
|
+
if not row or not row[0]:
|
|
95
|
+
return "" # no account ever observed -> byte-stable, no identity I/O
|
|
96
|
+
parts: list[str] = []
|
|
97
|
+
try:
|
|
98
|
+
for r in stats_conn.execute(
|
|
99
|
+
"SELECT account_key, provider, label, label_source FROM accounts "
|
|
100
|
+
"ORDER BY provider, account_key"
|
|
101
|
+
):
|
|
102
|
+
parts.append(f"{r[0]}|{r[1]}|{r[2] or ''}|{r[3] or ''}")
|
|
103
|
+
except sqlite3.Error:
|
|
104
|
+
pass
|
|
105
|
+
try:
|
|
106
|
+
import _cctally_account
|
|
107
|
+
active = sorted(_cctally_account.resolve_active_account_keys())
|
|
108
|
+
except Exception:
|
|
109
|
+
active = []
|
|
110
|
+
parts.append("active\x1f" + ",".join(active))
|
|
111
|
+
return hashlib.sha256("\x00".join(parts).encode("utf-8")).hexdigest()[:16]
|
|
112
|
+
|
|
113
|
+
|
|
72
114
|
class CodexCycleUnavailable(RuntimeError):
|
|
73
115
|
"""No single active native seven-day boundary can bound hero accounting."""
|
|
74
116
|
|
|
@@ -128,50 +170,78 @@ def _is_model_scoped_codex_quota(logical_limit_key: object) -> bool:
|
|
|
128
170
|
def _resolve_codex_weekly_cycle(
|
|
129
171
|
observations: Iterable[object],
|
|
130
172
|
now_utc: dt.datetime,
|
|
131
|
-
) -> CodexCycleBoundary:
|
|
132
|
-
"""
|
|
133
|
-
|
|
134
|
-
|
|
173
|
+
) -> list[CodexCycleBoundary]:
|
|
174
|
+
"""Resolve one active 10,080-minute native cycle PER ACCOUNT (#341 spec §4).
|
|
175
|
+
|
|
176
|
+
Returns a list with one :class:`CodexCycleBoundary` per account that has
|
|
177
|
+
exactly one active weekly boundary — N simultaneously-active account cycles
|
|
178
|
+
are each valid instead of collapsing to ``CodexCycleUnavailable("conflicting")``.
|
|
179
|
+
``conflicting`` now fires only for a genuine conflict WITHIN one account.
|
|
180
|
+
Raises ``CodexCycleUnavailable`` only when NO account yields a live cycle. A
|
|
181
|
+
single-account install returns a 1-element list = today's boundary
|
|
182
|
+
byte-for-byte (the hero path is unchanged, spec R8).
|
|
183
|
+
"""
|
|
184
|
+
per_account: dict[str, dict[tuple[int, dt.datetime], list[tuple[object, object]]]] = {}
|
|
185
|
+
accounts_seen: set[str] = set()
|
|
186
|
+
stale_by_account: dict[str, bool] = {}
|
|
135
187
|
for history in build_history(tuple(observations)):
|
|
136
188
|
if history.identity.window_minutes != 10_080:
|
|
137
189
|
continue
|
|
138
190
|
if _is_model_scoped_codex_quota(history.identity.logical_limit_key):
|
|
139
191
|
continue
|
|
192
|
+
account = history.identity.account_key
|
|
193
|
+
accounts_seen.add(account)
|
|
140
194
|
baseline = select_baseline(history.observations, now_utc)
|
|
141
195
|
if baseline is None or baseline.resets_at <= now_utc:
|
|
142
196
|
continue
|
|
143
197
|
if quota_freshness(history.physical_observations, now_utc).state != "fresh":
|
|
144
|
-
|
|
198
|
+
stale_by_account[account] = True
|
|
145
199
|
continue
|
|
146
200
|
boundary = (history.identity.window_minutes, baseline.resets_at)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
201
|
+
per_account.setdefault(account, {}).setdefault(boundary, []).append((history, baseline))
|
|
202
|
+
cycles: list[CodexCycleBoundary] = []
|
|
203
|
+
reasons: list[str] = []
|
|
204
|
+
for account in sorted(accounts_seen | set(per_account)):
|
|
205
|
+
boundaries = per_account.get(account, {})
|
|
206
|
+
if len(boundaries) != 1:
|
|
207
|
+
# Within one account: 0 boundaries -> stale/missing; >=2 -> conflicting.
|
|
208
|
+
reasons.append(
|
|
209
|
+
"conflicting" if boundaries
|
|
210
|
+
else ("stale" if stale_by_account.get(account) else "missing")
|
|
211
|
+
)
|
|
212
|
+
continue
|
|
213
|
+
(window_minutes, resets_at), candidates = next(iter(boundaries.items()))
|
|
214
|
+
# Preserve the existing hero's max-used-percent choice, then pin every
|
|
215
|
+
# remaining tie deterministically. The selected full identity—not the
|
|
216
|
+
# union of sibling roots/slots/limits—owns the account's hero cycle.
|
|
217
|
+
history, baseline = max(
|
|
218
|
+
candidates,
|
|
219
|
+
key=lambda item: (
|
|
220
|
+
float(item[1].used_percent),
|
|
221
|
+
item[1].captured_at.astimezone(UTC),
|
|
222
|
+
item[0].identity.source_root_key,
|
|
223
|
+
item[0].identity.logical_limit_key,
|
|
224
|
+
item[0].identity.observed_slot,
|
|
225
|
+
),
|
|
226
|
+
)
|
|
227
|
+
selected_identity = history.identity
|
|
228
|
+
cycles.append(CodexCycleBoundary(
|
|
229
|
+
window_minutes=window_minutes,
|
|
230
|
+
start_at=resets_at - dt.timedelta(minutes=window_minutes),
|
|
231
|
+
resets_at=resets_at,
|
|
232
|
+
source_root_keys=(selected_identity.source_root_key,),
|
|
233
|
+
used_percent=float(baseline.used_percent),
|
|
234
|
+
quota_identity=selected_identity,
|
|
235
|
+
))
|
|
236
|
+
if not cycles:
|
|
237
|
+
# Aggregate reason: for a single account this is exactly the old reason
|
|
238
|
+
# (byte-stable); across accounts, conflicting > stale > missing.
|
|
239
|
+
if "conflicting" in reasons:
|
|
240
|
+
raise CodexCycleUnavailable("conflicting")
|
|
241
|
+
if "stale" in reasons:
|
|
242
|
+
raise CodexCycleUnavailable("stale")
|
|
243
|
+
raise CodexCycleUnavailable("missing")
|
|
244
|
+
return cycles
|
|
175
245
|
|
|
176
246
|
|
|
177
247
|
def _codex_weekly_periods(
|
|
@@ -432,13 +502,27 @@ def _public_copy(value: object) -> object:
|
|
|
432
502
|
return value
|
|
433
503
|
|
|
434
504
|
|
|
435
|
-
def source_detail_lookup(
|
|
505
|
+
def source_detail_lookup(
|
|
506
|
+
bundle: object, source: str, resource: str, key: str,
|
|
507
|
+
account: "str | None" = None,
|
|
508
|
+
) -> dict[str, object]:
|
|
436
509
|
"""Find one provider-owned opaque row without I/O, ingest, or fallback.
|
|
437
510
|
|
|
438
511
|
The handler has already parsed the fixed route grammar. This adapter only
|
|
439
512
|
reads the frozen bundle published by the dashboard owner thread, so a
|
|
440
513
|
request cannot accidentally trigger cache sync, rollout parsing, or a
|
|
441
514
|
Claude fallback for a Codex key.
|
|
515
|
+
|
|
516
|
+
Server-side account row-ownership (#341 Task 4, spec §4 finding 10).
|
|
517
|
+
``account`` is the qualifier the modal captured with ``(source, account)`` at
|
|
518
|
+
open. When it is provided AND any row matching ``key`` carries an
|
|
519
|
+
``account_key`` (the decorated wire shape — two accounts can share one opaque
|
|
520
|
+
resource key), the server VERIFIES ownership: it returns the row owned by
|
|
521
|
+
``account`` and NEVER a different account's row — a key resolving only to
|
|
522
|
+
another account raises ``SourceResourceNotFound`` rather than leaking it.
|
|
523
|
+
Account-agnostic rows (no ``account_key``: sessions/projects per Decision R4,
|
|
524
|
+
or any undecorated source) ignore the qualifier and match by key alone, so
|
|
525
|
+
the undecorated path stays byte-identical.
|
|
442
526
|
"""
|
|
443
527
|
if source not in ("claude", "codex") or resource not in _RESOURCE_ROWS:
|
|
444
528
|
raise SourceCapabilityUnavailable()
|
|
@@ -454,10 +538,23 @@ def source_detail_lookup(bundle: object, source: str, resource: str, key: str) -
|
|
|
454
538
|
rows = data[domain][rows_key]
|
|
455
539
|
except (KeyError, TypeError) as exc:
|
|
456
540
|
raise SourceCapabilityUnavailable() from exc
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
541
|
+
key_matches = [
|
|
542
|
+
row for row in rows
|
|
543
|
+
if isinstance(row, Mapping) and row.get("key") == key
|
|
544
|
+
]
|
|
545
|
+
if not key_matches:
|
|
546
|
+
raise SourceResourceNotFound()
|
|
547
|
+
if account is not None:
|
|
548
|
+
# Any key-match that carries an account_key is account-scoped: enforce
|
|
549
|
+
# ownership so a fetch qualified to account X can never fall through to
|
|
550
|
+
# account Y's row sharing the same opaque key.
|
|
551
|
+
scoped = [row for row in key_matches if "account_key" in row]
|
|
552
|
+
if scoped:
|
|
553
|
+
owned = [row for row in scoped if row.get("account_key") == account]
|
|
554
|
+
if not owned:
|
|
555
|
+
raise SourceResourceNotFound()
|
|
556
|
+
return _public_copy(owned[0]) # type: ignore[return-value]
|
|
557
|
+
return _public_copy(key_matches[0]) # type: ignore[return-value]
|
|
461
558
|
|
|
462
559
|
|
|
463
560
|
def codex_projection_coherence(
|
|
@@ -757,12 +854,19 @@ def _codex_conversation_metadata(
|
|
|
757
854
|
metadata: dict[tuple[str, str], dict[str, object]] = {}
|
|
758
855
|
try:
|
|
759
856
|
core_rows = tuple(cache_conn.execute(
|
|
857
|
+
"WITH accounting AS ("
|
|
858
|
+
" SELECT source_root_key, source_path, MIN(id) AS first_id,"
|
|
859
|
+
" MIN(timestamp_utc) AS started_at"
|
|
860
|
+
" FROM codex_session_entries"
|
|
861
|
+
" GROUP BY source_root_key, source_path"
|
|
862
|
+
") "
|
|
760
863
|
"SELECT t.source_root_key, t.source_path, t.native_thread_id, "
|
|
761
|
-
"
|
|
762
|
-
"
|
|
763
|
-
" ORDER BY e.id LIMIT 1) AS accounting_session_id, "
|
|
764
|
-
"t.cwd, t.git_json, t.first_seen_utc, t.last_seen_utc "
|
|
864
|
+
"e.session_id AS accounting_session_id, "
|
|
865
|
+
"t.cwd, t.git_json, a.started_at, t.last_seen_utc "
|
|
765
866
|
"FROM codex_conversation_threads AS t "
|
|
867
|
+
"LEFT JOIN accounting AS a "
|
|
868
|
+
"ON a.source_root_key=t.source_root_key AND a.source_path=t.source_path "
|
|
869
|
+
"LEFT JOIN codex_session_entries AS e ON e.id=a.first_id "
|
|
766
870
|
"ORDER BY t.last_seen_utc DESC, t.conversation_key DESC"
|
|
767
871
|
))
|
|
768
872
|
from _cctally_cache import _codex_conversation_project_attribution
|
|
@@ -778,16 +882,20 @@ def _codex_conversation_metadata(
|
|
|
778
882
|
) in core_rows
|
|
779
883
|
)
|
|
780
884
|
file_aliases = tuple(cache_conn.execute(
|
|
781
|
-
"SELECT source_root_key, path, last_native_thread_id,
|
|
782
|
-
"
|
|
783
|
-
"
|
|
784
|
-
"
|
|
885
|
+
"SELECT f.source_root_key, f.path, f.last_native_thread_id, "
|
|
886
|
+
"f.last_session_id, MIN(e.timestamp_utc) "
|
|
887
|
+
"FROM codex_session_files AS f "
|
|
888
|
+
"LEFT JOIN codex_session_entries AS e "
|
|
889
|
+
"ON e.source_root_key=f.source_root_key AND e.source_path=f.path "
|
|
890
|
+
"WHERE f.last_native_thread_id IS NOT NULL AND f.last_native_thread_id != '' "
|
|
891
|
+
"GROUP BY f.source_root_key, f.path, f.last_native_thread_id, f.last_session_id "
|
|
892
|
+
"ORDER BY f.last_ingested_at DESC, f.path DESC"
|
|
785
893
|
))
|
|
786
894
|
native_ids = tuple(sorted({
|
|
787
895
|
str(native_thread_id) for _, _, native_thread_id, *_ in rows
|
|
788
896
|
if isinstance(native_thread_id, str) and native_thread_id
|
|
789
897
|
} | {
|
|
790
|
-
str(native_thread_id) for _, _, native_thread_id, _ in file_aliases
|
|
898
|
+
str(native_thread_id) for _, _, native_thread_id, *_ in file_aliases
|
|
791
899
|
if isinstance(native_thread_id, str) and native_thread_id
|
|
792
900
|
}))
|
|
793
901
|
provider_roots = {
|
|
@@ -858,7 +966,10 @@ def _codex_conversation_metadata(
|
|
|
858
966
|
# persists the rooted native thread id. Inherit only presentation
|
|
859
967
|
# metadata from that rooted task; the child's accounting path and
|
|
860
968
|
# session id remain its own identity and totals are never merged.
|
|
861
|
-
for
|
|
969
|
+
for (
|
|
970
|
+
root_key, source_path, native_thread_id, accounting_session_id,
|
|
971
|
+
started_at,
|
|
972
|
+
) in file_aliases:
|
|
862
973
|
identity = (str(root_key or ""), str(source_path or ""))
|
|
863
974
|
if not all(identity) or identity in metadata:
|
|
864
975
|
continue
|
|
@@ -871,7 +982,7 @@ def _codex_conversation_metadata(
|
|
|
871
982
|
"root_path": str(provider_roots.get(identity[0]) or ""),
|
|
872
983
|
"project_key": (inherited or {}).get("project_key"),
|
|
873
984
|
"project_label": (inherited or {}).get("project_label"),
|
|
874
|
-
"started_at": (inherited or {}).get("started_at"),
|
|
985
|
+
"started_at": started_at or (inherited or {}).get("started_at"),
|
|
875
986
|
}
|
|
876
987
|
except sqlite3.Error:
|
|
877
988
|
return {}
|
|
@@ -1186,6 +1297,18 @@ def _quota_read_model(
|
|
|
1186
1297
|
history_rows: list[dict[str, object]] = []
|
|
1187
1298
|
milestone_rows: list[dict[str, object]] = []
|
|
1188
1299
|
active_rows: list[dict[str, object]] = []
|
|
1300
|
+
# R8 (#341 Task 4): the per-account `account_key` is serialized onto each
|
|
1301
|
+
# history row ONLY when the Codex provider has >1 REAL account, so the idle
|
|
1302
|
+
# clock (`_clock_cycle_validity`) can scope weekly-cycle validity per account
|
|
1303
|
+
# instead of degrading a genuine multi-account state to `conflicting`. A
|
|
1304
|
+
# <=1-real-account install (all fixtures) stays byte-identical (no key added).
|
|
1305
|
+
_codex_decorated = False
|
|
1306
|
+
try:
|
|
1307
|
+
import _cctally_account
|
|
1308
|
+
_codex_decorated = _cctally_account.provider_is_decorated(
|
|
1309
|
+
context.stats_conn, "codex")
|
|
1310
|
+
except Exception:
|
|
1311
|
+
_codex_decorated = False
|
|
1189
1312
|
for history in histories:
|
|
1190
1313
|
identity = history.identity
|
|
1191
1314
|
key_parts = (
|
|
@@ -1200,6 +1323,7 @@ def _quota_read_model(
|
|
|
1200
1323
|
history_rows.append({
|
|
1201
1324
|
"key": dashboard_resource_key("quota", "codex", *key_parts),
|
|
1202
1325
|
"source": "codex",
|
|
1326
|
+
**({"account_key": identity.account_key} if _codex_decorated else {}),
|
|
1203
1327
|
"label": _native_limit_label(identity.limit_name, identity.window_minutes),
|
|
1204
1328
|
"observed_slot": identity.observed_slot,
|
|
1205
1329
|
"window_minutes": identity.window_minutes,
|
|
@@ -1381,9 +1505,23 @@ def _clock_cycle_validity(
|
|
|
1381
1505
|
histories: Iterable[object],
|
|
1382
1506
|
now_utc: dt.datetime,
|
|
1383
1507
|
) -> tuple[bool, str]:
|
|
1384
|
-
"""Re-evaluate frozen weekly evidence without touching cache or rollouts.
|
|
1385
|
-
|
|
1386
|
-
|
|
1508
|
+
"""Re-evaluate frozen weekly evidence without touching cache or rollouts.
|
|
1509
|
+
|
|
1510
|
+
Per-account (#341 Task 4): boundaries are grouped by the history row's
|
|
1511
|
+
``account_key`` (serialized only when the Codex provider is DECORATED, i.e.
|
|
1512
|
+
>1 real account — R8). The idle clock mirrors the build-time
|
|
1513
|
+
``_resolve_codex_weekly_cycle`` resolution: an account yields a live cycle iff
|
|
1514
|
+
it has EXACTLY ONE fresh future boundary, and the hero is valid iff AT LEAST
|
|
1515
|
+
ONE account does (raising the aggregate ``conflicting > stale > missing``
|
|
1516
|
+
reason only when NO account yields one). When ``account_key`` is absent
|
|
1517
|
+
(<=1-real-account install — no decoration) every row falls into one global
|
|
1518
|
+
bucket, so this reduces EXACTLY to the prior single-boundary logic
|
|
1519
|
+
(byte-stable). This removes the documented placeholder degrade where two real
|
|
1520
|
+
accounts with distinct weekly cycles collapsed the whole hero to
|
|
1521
|
+
``conflicting`` on the idle clock.
|
|
1522
|
+
"""
|
|
1523
|
+
per_account: dict[str, set[dt.datetime]] = {}
|
|
1524
|
+
stale_by_account: dict[str, bool] = {}
|
|
1387
1525
|
for raw_history in histories:
|
|
1388
1526
|
if not isinstance(raw_history, Mapping):
|
|
1389
1527
|
continue
|
|
@@ -1401,15 +1539,29 @@ def _clock_cycle_validity(
|
|
|
1401
1539
|
continue
|
|
1402
1540
|
if resets_at <= now_utc:
|
|
1403
1541
|
continue
|
|
1542
|
+
# None account_key (undecorated / <=1 real account) -> one global bucket
|
|
1543
|
+
# == today's behavior. A real key buckets per account.
|
|
1544
|
+
acct = raw_history.get("account_key")
|
|
1545
|
+
bucket = acct if acct is not None else "__all__"
|
|
1404
1546
|
if raw_history.get("freshness") != "fresh":
|
|
1405
|
-
|
|
1547
|
+
stale_by_account[bucket] = True
|
|
1406
1548
|
continue
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1549
|
+
per_account.setdefault(bucket, set()).add(resets_at)
|
|
1550
|
+
accounts = set(per_account) | set(stale_by_account)
|
|
1551
|
+
reasons: list[str] = []
|
|
1552
|
+
for acct in accounts:
|
|
1553
|
+
boundaries = per_account.get(acct, set())
|
|
1554
|
+
if len(boundaries) == 1:
|
|
1555
|
+
return True, "ok" # at least one account yields a live cycle
|
|
1556
|
+
reasons.append(
|
|
1557
|
+
"conflicting" if boundaries
|
|
1558
|
+
else ("stale" if stale_by_account.get(acct) else "missing")
|
|
1559
|
+
)
|
|
1560
|
+
if "conflicting" in reasons:
|
|
1561
|
+
return False, "conflicting"
|
|
1562
|
+
if "stale" in reasons:
|
|
1411
1563
|
return False, "stale"
|
|
1412
|
-
return False, "missing"
|
|
1564
|
+
return False, "missing"
|
|
1413
1565
|
|
|
1414
1566
|
|
|
1415
1567
|
def _refresh_budget_status_clock(
|
|
@@ -1939,6 +2091,218 @@ def _build_codex_native_weekly_view(
|
|
|
1939
2091
|
)
|
|
1940
2092
|
|
|
1941
2093
|
|
|
2094
|
+
def _codex_account_five_hour_percent(
|
|
2095
|
+
observations: Iterable[object],
|
|
2096
|
+
now_utc: dt.datetime,
|
|
2097
|
+
) -> dict[str, float]:
|
|
2098
|
+
"""Per-account current five-hour (300-minute) used-percent (#341 Task 4).
|
|
2099
|
+
|
|
2100
|
+
Account key -> the highest active-window used-percent among that account's
|
|
2101
|
+
fresh sibling 300-minute windows. Used to render the per-account hero card's
|
|
2102
|
+
5h bar; account-blind physical breakdown readers are untouched.
|
|
2103
|
+
"""
|
|
2104
|
+
result: dict[str, float] = {}
|
|
2105
|
+
for history in build_history(tuple(observations)):
|
|
2106
|
+
if history.identity.window_minutes != 300:
|
|
2107
|
+
continue
|
|
2108
|
+
baseline = select_baseline(history.observations, now_utc)
|
|
2109
|
+
if baseline is None or baseline.resets_at <= now_utc:
|
|
2110
|
+
continue
|
|
2111
|
+
acct = history.identity.account_key
|
|
2112
|
+
pct = float(baseline.used_percent)
|
|
2113
|
+
if acct not in result or pct > result[acct]:
|
|
2114
|
+
result[acct] = pct
|
|
2115
|
+
return result
|
|
2116
|
+
|
|
2117
|
+
|
|
2118
|
+
def _codex_accounts_wire(
|
|
2119
|
+
context: DashboardReadContext,
|
|
2120
|
+
*,
|
|
2121
|
+
quota_observations: Iterable[object],
|
|
2122
|
+
cycles: list["CodexCycleBoundary"],
|
|
2123
|
+
accounting_start: dt.datetime,
|
|
2124
|
+
accounting_end: dt.datetime,
|
|
2125
|
+
) -> tuple[list[dict[str, object]], list[dict[str, object]]]:
|
|
2126
|
+
"""Return ``(accounts_wire, hero_cycles_wire)`` for a decorated Codex source.
|
|
2127
|
+
|
|
2128
|
+
Caller must gate on ``provider_is_decorated(stats_conn, "codex")`` — this
|
|
2129
|
+
builds nothing for a <=1-real-account install (the whole surface is absent,
|
|
2130
|
+
so the envelope stays byte-identical, spec R8). Each account carries
|
|
2131
|
+
``{accountKey, label, plan, active, weeklyPercent, fiveHourPercent, resetsAt,
|
|
2132
|
+
spendUsd, inputTokens, cachedInputTokens, outputTokens,
|
|
2133
|
+
reasoningOutputTokens, totalTokens, unattributed?}``; ``hero_cycles_wire`` is
|
|
2134
|
+
the thin per-account cycle-boundary list the hero renders (``cycles[]``).
|
|
2135
|
+
"""
|
|
2136
|
+
import _cctally_account
|
|
2137
|
+
active_keys = _cctally_account.resolve_active_account_keys()
|
|
2138
|
+
five_hour = _codex_account_five_hour_percent(quota_observations, context.now_utc)
|
|
2139
|
+
cycle_by_account: dict[str, "CodexCycleBoundary"] = {}
|
|
2140
|
+
for cyc in cycles:
|
|
2141
|
+
acct = (
|
|
2142
|
+
cyc.quota_identity.account_key if cyc.quota_identity is not None
|
|
2143
|
+
else _lib_accounts.UNATTRIBUTED
|
|
2144
|
+
)
|
|
2145
|
+
cycle_by_account.setdefault(acct, cyc)
|
|
2146
|
+
# Registry accounts (real, deterministically ordered) + the unattributed
|
|
2147
|
+
# sentinel when it has any retained accounting (it renders dimmed, totals
|
|
2148
|
+
# only). Registry rows never include the sentinel.
|
|
2149
|
+
reg = _cctally_account.load_accounts(context.stats_conn, "codex")
|
|
2150
|
+
plan_by_key = {r["account_key"]: r.get("plan_type") for r in reg}
|
|
2151
|
+
ordered_keys = [r["account_key"] for r in reg]
|
|
2152
|
+
# Include unattributed last iff it has cycle/5h/spend evidence.
|
|
2153
|
+
unattributed_rows = load_cached_rooted_codex_accounting_entries(
|
|
2154
|
+
accounting_start, accounting_end, speed=context.speed,
|
|
2155
|
+
cache_conn=context.cache_conn, account_key=_lib_accounts.UNATTRIBUTED,
|
|
2156
|
+
)
|
|
2157
|
+
if (
|
|
2158
|
+
unattributed_rows
|
|
2159
|
+
or _lib_accounts.UNATTRIBUTED in cycle_by_account
|
|
2160
|
+
or _lib_accounts.UNATTRIBUTED in five_hour
|
|
2161
|
+
):
|
|
2162
|
+
ordered_keys.append(_lib_accounts.UNATTRIBUTED)
|
|
2163
|
+
|
|
2164
|
+
def _totals(rows: tuple[object, ...]) -> dict[str, object]:
|
|
2165
|
+
entries = _codex_entries_from_accounting(rows)
|
|
2166
|
+
cost = build_codex_daily_view(
|
|
2167
|
+
entries, now_utc=context.now_utc, tz_name=context.display_tz_name,
|
|
2168
|
+
speed=context.speed,
|
|
2169
|
+
).total_cost_usd if entries else 0.0
|
|
2170
|
+
return {
|
|
2171
|
+
"spendUsd": cost,
|
|
2172
|
+
"inputTokens": sum(e.input_tokens for e in entries),
|
|
2173
|
+
"cachedInputTokens": sum(e.cached_input_tokens for e in entries),
|
|
2174
|
+
"outputTokens": sum(e.output_tokens for e in entries),
|
|
2175
|
+
"reasoningOutputTokens": sum(e.reasoning_output_tokens for e in entries),
|
|
2176
|
+
"totalTokens": sum(e.total_tokens for e in entries),
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
accounts_wire: list[dict[str, object]] = []
|
|
2180
|
+
hero_cycles_wire: list[dict[str, object]] = []
|
|
2181
|
+
for key in ordered_keys:
|
|
2182
|
+
cyc = cycle_by_account.get(key)
|
|
2183
|
+
is_unattributed = key == _lib_accounts.UNATTRIBUTED
|
|
2184
|
+
if cyc is not None and not is_unattributed:
|
|
2185
|
+
cycle_end = min(accounting_end, cyc.resets_at)
|
|
2186
|
+
rows = load_cached_rooted_codex_accounting_entries(
|
|
2187
|
+
cyc.start_at, cycle_end, speed=context.speed,
|
|
2188
|
+
cache_conn=context.cache_conn,
|
|
2189
|
+
source_root_keys=cyc.source_root_keys, account_key=key,
|
|
2190
|
+
)
|
|
2191
|
+
totals = _totals(rows)
|
|
2192
|
+
elif is_unattributed:
|
|
2193
|
+
totals = _totals(unattributed_rows)
|
|
2194
|
+
else:
|
|
2195
|
+
# A real account without a live weekly cycle: totals over the
|
|
2196
|
+
# accounting range so the card still shows spend (no bars/reset).
|
|
2197
|
+
rows = load_cached_rooted_codex_accounting_entries(
|
|
2198
|
+
accounting_start, accounting_end, speed=context.speed,
|
|
2199
|
+
cache_conn=context.cache_conn, account_key=key,
|
|
2200
|
+
)
|
|
2201
|
+
totals = _totals(rows)
|
|
2202
|
+
card: dict[str, object] = {
|
|
2203
|
+
"accountKey": key,
|
|
2204
|
+
"label": _cctally_account.account_label(context.stats_conn, key),
|
|
2205
|
+
"plan": plan_by_key.get(key),
|
|
2206
|
+
"active": key in active_keys,
|
|
2207
|
+
"weeklyPercent": (
|
|
2208
|
+
None if is_unattributed or cyc is None else cyc.used_percent
|
|
2209
|
+
),
|
|
2210
|
+
"fiveHourPercent": (None if is_unattributed else five_hour.get(key)),
|
|
2211
|
+
"resetsAt": (
|
|
2212
|
+
None if is_unattributed or cyc is None
|
|
2213
|
+
else cyc.resets_at.astimezone(UTC).isoformat()
|
|
2214
|
+
),
|
|
2215
|
+
**totals,
|
|
2216
|
+
}
|
|
2217
|
+
if is_unattributed:
|
|
2218
|
+
card["unattributed"] = True
|
|
2219
|
+
accounts_wire.append(card)
|
|
2220
|
+
if cyc is not None and not is_unattributed:
|
|
2221
|
+
hero_cycles_wire.append({
|
|
2222
|
+
"accountKey": key,
|
|
2223
|
+
"window_minutes": cyc.window_minutes,
|
|
2224
|
+
"start_at": cyc.start_at.astimezone(UTC).isoformat(),
|
|
2225
|
+
"resets_at": cyc.resets_at.astimezone(UTC).isoformat(),
|
|
2226
|
+
"used_percent": cyc.used_percent,
|
|
2227
|
+
"cost_usd": totals["spendUsd"],
|
|
2228
|
+
"total_tokens": totals["totalTokens"],
|
|
2229
|
+
})
|
|
2230
|
+
return accounts_wire, hero_cycles_wire
|
|
2231
|
+
|
|
2232
|
+
|
|
2233
|
+
def _claude_accounts_wire(
|
|
2234
|
+
stats_conn: sqlite3.Connection,
|
|
2235
|
+
*,
|
|
2236
|
+
now_utc: dt.datetime,
|
|
2237
|
+
) -> list[dict[str, object]]:
|
|
2238
|
+
"""Per-account Claude hero cards (#341 Task 4, Ruling C).
|
|
2239
|
+
|
|
2240
|
+
Symmetric with ``_codex_accounts_wire``: the caller gates on
|
|
2241
|
+
``provider_is_decorated(stats_conn, "claude")`` (>1 REAL account, R8), so a
|
|
2242
|
+
<=1-real-account install builds nothing and its envelope stays byte-identical
|
|
2243
|
+
on BOTH goldens. Each card carries
|
|
2244
|
+
``{accountKey, label, plan, active, weeklyPercent, fiveHourPercent, resetsAt,
|
|
2245
|
+
spendUsd, unattributed?}`` drawn from the ALREADY-account-scoped stats
|
|
2246
|
+
snapshots (``weekly_usage_snapshots``/``weekly_cost_snapshots`` both hold
|
|
2247
|
+
``account_key`` — Section 6 scope matrix), taking each account's latest
|
|
2248
|
+
captured row as its current-cycle state. spendUsd is the snapshotted weekly
|
|
2249
|
+
cost (the ``report`` semantics), account-scoped. The unattributed bucket
|
|
2250
|
+
renders last, dimmed/totals-only, iff it has any retained snapshot.
|
|
2251
|
+
"""
|
|
2252
|
+
import _cctally_account
|
|
2253
|
+
active_keys = _cctally_account.resolve_active_account_keys()
|
|
2254
|
+
reg = _cctally_account.load_accounts(stats_conn, "claude")
|
|
2255
|
+
plan_by_key = {r["account_key"]: r.get("plan_type") for r in reg}
|
|
2256
|
+
ordered_keys = [r["account_key"] for r in reg]
|
|
2257
|
+
|
|
2258
|
+
def _latest_usage(key: str):
|
|
2259
|
+
return stats_conn.execute(
|
|
2260
|
+
"SELECT weekly_percent, five_hour_percent, week_end_at "
|
|
2261
|
+
"FROM weekly_usage_snapshots WHERE account_key=? "
|
|
2262
|
+
"ORDER BY captured_at_utc DESC LIMIT 1",
|
|
2263
|
+
(key,),
|
|
2264
|
+
).fetchone()
|
|
2265
|
+
|
|
2266
|
+
def _latest_cost(key: str) -> float:
|
|
2267
|
+
row = stats_conn.execute(
|
|
2268
|
+
"SELECT cost_usd FROM weekly_cost_snapshots WHERE account_key=? "
|
|
2269
|
+
"ORDER BY captured_at_utc DESC LIMIT 1",
|
|
2270
|
+
(key,),
|
|
2271
|
+
).fetchone()
|
|
2272
|
+
return float(row[0]) if row is not None and row[0] is not None else 0.0
|
|
2273
|
+
|
|
2274
|
+
# Include the unattributed bucket last iff it retained any snapshot.
|
|
2275
|
+
unattr_usage = _latest_usage(_lib_accounts.UNATTRIBUTED)
|
|
2276
|
+
unattr_cost = stats_conn.execute(
|
|
2277
|
+
"SELECT 1 FROM weekly_cost_snapshots WHERE account_key=? LIMIT 1",
|
|
2278
|
+
(_lib_accounts.UNATTRIBUTED,),
|
|
2279
|
+
).fetchone()
|
|
2280
|
+
if unattr_usage is not None or unattr_cost is not None:
|
|
2281
|
+
ordered_keys.append(_lib_accounts.UNATTRIBUTED)
|
|
2282
|
+
|
|
2283
|
+
cards: list[dict[str, object]] = []
|
|
2284
|
+
for key in ordered_keys:
|
|
2285
|
+
is_unattributed = key == _lib_accounts.UNATTRIBUTED
|
|
2286
|
+
usage = _latest_usage(key)
|
|
2287
|
+
weekly_pct = usage[0] if usage is not None else None
|
|
2288
|
+
five_hour_pct = usage[1] if usage is not None else None
|
|
2289
|
+
resets_at = usage[2] if usage is not None else None
|
|
2290
|
+
card: dict[str, object] = {
|
|
2291
|
+
"accountKey": key,
|
|
2292
|
+
"label": _cctally_account.account_label(stats_conn, key),
|
|
2293
|
+
"plan": plan_by_key.get(key),
|
|
2294
|
+
"active": key in active_keys,
|
|
2295
|
+
"weeklyPercent": None if is_unattributed else weekly_pct,
|
|
2296
|
+
"fiveHourPercent": None if is_unattributed else five_hour_pct,
|
|
2297
|
+
"resetsAt": None if is_unattributed else resets_at,
|
|
2298
|
+
"spendUsd": _latest_cost(key),
|
|
2299
|
+
}
|
|
2300
|
+
if is_unattributed:
|
|
2301
|
+
card["unattributed"] = True
|
|
2302
|
+
cards.append(card)
|
|
2303
|
+
return cards
|
|
2304
|
+
|
|
2305
|
+
|
|
1942
2306
|
def build_codex_source_state(
|
|
1943
2307
|
context: DashboardReadContext,
|
|
1944
2308
|
*,
|
|
@@ -2023,8 +2387,16 @@ def build_codex_source_state(
|
|
|
2023
2387
|
)
|
|
2024
2388
|
budget_entries = _codex_entries_from_accounting(accounting_entries)
|
|
2025
2389
|
cycle_reason: str | None = None
|
|
2390
|
+
cycles_all: list[CodexCycleBoundary] = []
|
|
2026
2391
|
try:
|
|
2027
|
-
|
|
2392
|
+
# Per-account list (#341 Task 2). ``cycles_all`` drives the per-account
|
|
2393
|
+
# hero cards (Task 4, gated on decoration); ``cycle`` stays the first
|
|
2394
|
+
# account's boundary (sorted by account_key) as the interim single hero —
|
|
2395
|
+
# for a single-account install this IS today's single boundary
|
|
2396
|
+
# (byte-stable), and a multi-account install no longer degrades to
|
|
2397
|
+
# `conflicting`.
|
|
2398
|
+
cycles_all = _resolve_codex_weekly_cycle(quota_observations, context.now_utc)
|
|
2399
|
+
cycle = cycles_all[0] if cycles_all else None
|
|
2028
2400
|
except CodexCycleUnavailable as exc:
|
|
2029
2401
|
cycle = None
|
|
2030
2402
|
cycle_reason = exc.reason
|
|
@@ -2162,6 +2534,32 @@ def build_codex_source_state(
|
|
|
2162
2534
|
"Codex native reset cycle is unavailable.",
|
|
2163
2535
|
"hero",
|
|
2164
2536
|
))
|
|
2537
|
+
# #341 Task 4: the conditional per-account wire. Built ONLY when the Codex
|
|
2538
|
+
# provider has >1 REAL account (R8) — a <=1-real-account install adds nothing
|
|
2539
|
+
# so its envelope is byte-identical to today. The array ships EVERY account's
|
|
2540
|
+
# projection (client-side chip filter); the hero renders per-account cards.
|
|
2541
|
+
accounts_wire: list[dict[str, object]] = []
|
|
2542
|
+
hero_cycles_wire: list[dict[str, object]] = []
|
|
2543
|
+
try:
|
|
2544
|
+
import _cctally_account
|
|
2545
|
+
_codex_decorated = _cctally_account.provider_is_decorated(
|
|
2546
|
+
context.stats_conn, "codex")
|
|
2547
|
+
except Exception:
|
|
2548
|
+
_codex_decorated = False
|
|
2549
|
+
if _codex_decorated:
|
|
2550
|
+
try:
|
|
2551
|
+
accounts_wire, hero_cycles_wire = _codex_accounts_wire(
|
|
2552
|
+
context,
|
|
2553
|
+
quota_observations=quota_observations,
|
|
2554
|
+
cycles=cycles_all,
|
|
2555
|
+
accounting_start=accounting_start,
|
|
2556
|
+
accounting_end=accounting_end,
|
|
2557
|
+
)
|
|
2558
|
+
except (sqlite3.Error, QualifiedMetadataUnavailable):
|
|
2559
|
+
# A per-account wire failure must never fail the whole source build;
|
|
2560
|
+
# degrade to the byte-stable undecorated shape.
|
|
2561
|
+
accounts_wire = []
|
|
2562
|
+
hero_cycles_wire = []
|
|
2165
2563
|
return SourceDashboardState(
|
|
2166
2564
|
source="codex",
|
|
2167
2565
|
availability=availability,
|
|
@@ -2214,7 +2612,9 @@ def build_codex_source_state(
|
|
|
2214
2612
|
"quota": quota["summary"],
|
|
2215
2613
|
"budget": configured_budget,
|
|
2216
2614
|
"alerts": {"count": len(alerts)},
|
|
2615
|
+
**({"cycles": hero_cycles_wire} if _codex_decorated else {}),
|
|
2217
2616
|
},
|
|
2617
|
+
**({"accounts": accounts_wire} if _codex_decorated else {}),
|
|
2218
2618
|
"periods": {
|
|
2219
2619
|
"daily": _period_wire(daily),
|
|
2220
2620
|
"monthly": _period_wire(monthly),
|