cctally 1.80.4 → 1.82.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 +32 -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 +434 -48
- 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 +56 -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_pricing.py +27 -3
- 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-BzWujBS3.js +0 -92
- package/dashboard/static/assets/index-Cc2ykqF_.css +0 -1
package/bin/_cctally_tui.py
CHANGED
|
@@ -263,6 +263,8 @@ _ensure_sibling_loaded("_lib_dashboard_sources")
|
|
|
263
263
|
_ensure_sibling_loaded("_cctally_dashboard_sources")
|
|
264
264
|
from _cctally_dashboard_sources import (
|
|
265
265
|
DashboardReadContext,
|
|
266
|
+
_claude_accounts_wire,
|
|
267
|
+
accounts_identity_digest,
|
|
266
268
|
build_codex_source_state,
|
|
267
269
|
refresh_codex_source_clock,
|
|
268
270
|
resolve_dashboard_source_semantics,
|
|
@@ -2145,7 +2147,12 @@ def _snapshot_data_version(sig) -> str:
|
|
|
2145
2147
|
getattr(sig, "codex_physical_mutation_seq", 0),
|
|
2146
2148
|
))
|
|
2147
2149
|
digest = getattr(sig, "codex_stats_digest", "")
|
|
2148
|
-
|
|
2150
|
+
out = numeric_legs if not digest else f"{numeric_legs}.{digest}"
|
|
2151
|
+
# #341 finding 9: fold the account registry/active-identity digest so an
|
|
2152
|
+
# account switch with zero new rows still flips the SSE change-signal. Empty
|
|
2153
|
+
# for every <=1-account install (byte-neutral — never appended).
|
|
2154
|
+
acct = getattr(sig, "accounts_digest", "")
|
|
2155
|
+
return out if not acct else f"{out}.a{acct}"
|
|
2149
2156
|
|
|
2150
2157
|
|
|
2151
2158
|
def _tui_source_copy(value: object) -> object:
|
|
@@ -2394,22 +2401,32 @@ def _tui_build_source_bundle(
|
|
|
2394
2401
|
display_tz_name=display_tz_name,
|
|
2395
2402
|
)
|
|
2396
2403
|
stats_digest = codex_stats_digest(stats_conn)
|
|
2404
|
+
# #341 finding 9: the account registry/active-identity digest. Empty for
|
|
2405
|
+
# every <=1-account install (byte-neutral — appended only when non-empty),
|
|
2406
|
+
# so single-account source versions stay byte-identical to today; a
|
|
2407
|
+
# multi-account switch flips it -> the source rebuilds (re-resolving the
|
|
2408
|
+
# `active` marker). Folded into BOTH providers' versions: a switch is a
|
|
2409
|
+
# rare event and a rebuild is safe, so per-provider narrowing is not worth
|
|
2410
|
+
# the split.
|
|
2411
|
+
accounts_digest = accounts_identity_digest(stats_conn)
|
|
2397
2412
|
signature = c.compute_signature(
|
|
2398
2413
|
cache_conn,
|
|
2399
2414
|
stats_conn,
|
|
2400
2415
|
generation=c.current_generation(),
|
|
2401
2416
|
codex_stats_digest=stats_digest,
|
|
2417
|
+
accounts_digest=accounts_digest,
|
|
2402
2418
|
)
|
|
2419
|
+
_acct_suffix = f":a{accounts_digest}" if accounts_digest else ""
|
|
2403
2420
|
codex_version = (
|
|
2404
2421
|
f"codex:{signature.max_codex_id}:"
|
|
2405
2422
|
f"{signature.codex_physical_mutation_seq}:{stats_digest}:"
|
|
2406
|
-
f"{semantics.codex_identity}"
|
|
2423
|
+
f"{semantics.codex_identity}{_acct_suffix}"
|
|
2407
2424
|
)
|
|
2408
2425
|
claude_version = (
|
|
2409
2426
|
f"claude:{signature.max_entry_id}:{signature.entry_mutation_seq}:"
|
|
2410
2427
|
f"{signature.max_wus_id}:{signature.max_wcs_id}:"
|
|
2411
2428
|
f"{signature.reset_sig[0]}:{signature.reset_sig[1]}:"
|
|
2412
|
-
f"{signature.generation}:{semantics.claude_identity}"
|
|
2429
|
+
f"{signature.generation}:{semantics.claude_identity}{_acct_suffix}"
|
|
2413
2430
|
)
|
|
2414
2431
|
prior_claude = (
|
|
2415
2432
|
prior_bundle.sources.get("claude")
|
|
@@ -2443,6 +2460,26 @@ def _tui_build_source_bundle(
|
|
|
2443
2460
|
)
|
|
2444
2461
|
if claude is None:
|
|
2445
2462
|
claude_available = "ok" if (claude_cost_usd or claude_total_tokens) else "empty"
|
|
2463
|
+
# #341 Task 4 (Ruling C): the conditional per-account Claude wire,
|
|
2464
|
+
# symmetric with Codex. Built ONLY when the Claude provider has >1
|
|
2465
|
+
# REAL account (R8) — a <=1-real-account install (every envelope
|
|
2466
|
+
# golden) adds nothing, so its wire is byte-identical. The generic
|
|
2467
|
+
# chip row / hero cards light up automatically for a decorated Claude
|
|
2468
|
+
# source. A read failure degrades to the byte-stable undecorated shape.
|
|
2469
|
+
claude_accounts: list[dict[str, object]] = []
|
|
2470
|
+
try:
|
|
2471
|
+
import _cctally_account
|
|
2472
|
+
if _cctally_account.provider_is_decorated(stats_conn, "claude"):
|
|
2473
|
+
claude_accounts = _claude_accounts_wire(stats_conn, now_utc=now_utc)
|
|
2474
|
+
except Exception:
|
|
2475
|
+
# #341 Task 4 P3 — degrade to no-accounts-wire on ANY read
|
|
2476
|
+
# failure, symmetric with the Codex path. Beyond sqlite, the wire
|
|
2477
|
+
# reaches the identity read (file I/O on ~/.claude.json via
|
|
2478
|
+
# resolve_active_account_keys) and the account registry/label
|
|
2479
|
+
# lookups; a transient failure in this best-effort DECORATIVE
|
|
2480
|
+
# wire must never fail the whole dashboard tick — it just falls
|
|
2481
|
+
# back to the byte-stable undecorated shape.
|
|
2482
|
+
claude_accounts = []
|
|
2446
2483
|
claude = SourceDashboardState(
|
|
2447
2484
|
source="claude",
|
|
2448
2485
|
availability=claude_available,
|
|
@@ -2478,6 +2515,7 @@ def _tui_build_source_bundle(
|
|
|
2478
2515
|
"alerts": {"rows": ()},
|
|
2479
2516
|
}
|
|
2480
2517
|
),
|
|
2518
|
+
**({"accounts": claude_accounts} if claude_accounts else {}),
|
|
2481
2519
|
},
|
|
2482
2520
|
)
|
|
2483
2521
|
if codex_ingest_failed:
|
|
@@ -2566,6 +2604,7 @@ def _tui_build_source_bundle(
|
|
|
2566
2604
|
stats_conn,
|
|
2567
2605
|
generation=c.current_generation(),
|
|
2568
2606
|
codex_stats_digest=post_stats_digest,
|
|
2607
|
+
accounts_digest=accounts_identity_digest(stats_conn),
|
|
2569
2608
|
)
|
|
2570
2609
|
if post_signature != signature:
|
|
2571
2610
|
if prior_bundle is not None:
|
|
@@ -2749,24 +2788,58 @@ def _tui_build_snapshot(
|
|
|
2749
2788
|
if do_ingest:
|
|
2750
2789
|
try:
|
|
2751
2790
|
cache_conn = _cctally().open_cache_db()
|
|
2791
|
+
cache_mod = _cctally()._load_sibling("_cctally_cache")
|
|
2752
2792
|
try:
|
|
2753
|
-
|
|
2754
|
-
|
|
2793
|
+
def _claude_leg(active_conn):
|
|
2794
|
+
try:
|
|
2795
|
+
return sync_cache(active_conn), None
|
|
2796
|
+
except sqlite3.DatabaseError as exc:
|
|
2797
|
+
if cache_mod._cctally_db_sib._is_sqlite_corruption_error(
|
|
2798
|
+
exc
|
|
2799
|
+
):
|
|
2800
|
+
raise
|
|
2801
|
+
return None, exc
|
|
2802
|
+
except Exception as exc:
|
|
2803
|
+
return None, exc
|
|
2804
|
+
|
|
2805
|
+
operations = [_claude_leg]
|
|
2806
|
+
if precompute_envelope:
|
|
2807
|
+
def _codex_leg(active_conn):
|
|
2808
|
+
try:
|
|
2809
|
+
return sync_codex_cache(active_conn), None
|
|
2810
|
+
except sqlite3.DatabaseError as exc:
|
|
2811
|
+
if cache_mod._cctally_db_sib._is_sqlite_corruption_error(
|
|
2812
|
+
exc
|
|
2813
|
+
):
|
|
2814
|
+
raise
|
|
2815
|
+
return None, exc
|
|
2816
|
+
except Exception as exc:
|
|
2817
|
+
return None, exc
|
|
2818
|
+
|
|
2819
|
+
operations.append(_codex_leg)
|
|
2820
|
+
|
|
2821
|
+
results, cache_conn = (
|
|
2822
|
+
cache_mod._run_cache_plan_with_recovery(
|
|
2823
|
+
cache_conn, tuple(operations)
|
|
2824
|
+
)
|
|
2825
|
+
)
|
|
2826
|
+
claude_ingest, claude_error = results[0]
|
|
2827
|
+
if claude_error is None:
|
|
2755
2828
|
claude_ingest_contended = bool(
|
|
2756
2829
|
getattr(claude_ingest, "lock_contended", False)
|
|
2757
2830
|
)
|
|
2758
|
-
|
|
2831
|
+
else:
|
|
2759
2832
|
claude_ingest_failed = True
|
|
2760
|
-
errors.append(f"sync-cache: {
|
|
2833
|
+
errors.append(f"sync-cache: {claude_error}")
|
|
2761
2834
|
if precompute_envelope:
|
|
2762
|
-
|
|
2763
|
-
|
|
2835
|
+
codex_ingest, codex_error = results[1]
|
|
2836
|
+
if codex_error is None:
|
|
2764
2837
|
codex_ingest_contended = bool(
|
|
2765
2838
|
getattr(codex_ingest, "lock_contended", False)
|
|
2766
2839
|
)
|
|
2767
|
-
|
|
2840
|
+
else:
|
|
2768
2841
|
codex_ingest_failed = True
|
|
2769
|
-
errors.append(f"sync-codex-cache: {
|
|
2842
|
+
errors.append(f"sync-codex-cache: {codex_error}")
|
|
2770
2843
|
finally:
|
|
2771
2844
|
cache_conn.close()
|
|
2772
2845
|
except Exception as exc:
|
|
@@ -3589,6 +3662,7 @@ def _tui_compute_dispatch_signature(stats_conn):
|
|
|
3589
3662
|
stats_conn,
|
|
3590
3663
|
generation=sc.current_generation(),
|
|
3591
3664
|
codex_stats_digest=codex_stats_digest(stats_conn),
|
|
3665
|
+
accounts_digest=accounts_identity_digest(stats_conn),
|
|
3592
3666
|
)
|
|
3593
3667
|
finally:
|
|
3594
3668
|
cache_conn.close()
|
|
@@ -6092,6 +6166,7 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override,
|
|
|
6092
6166
|
throttle=throttle, monotonic=_time.monotonic,
|
|
6093
6167
|
)
|
|
6094
6168
|
cache_conn = _cctally().open_cache_db()
|
|
6169
|
+
cache_mod = _cctally()._load_sibling("_cctally_cache")
|
|
6095
6170
|
try:
|
|
6096
6171
|
# Under CCTALLY_PERF_TRACE the phase tree this standalone
|
|
6097
6172
|
# sync_cache builds is intentionally NOT surfaced in the live
|
|
@@ -6100,13 +6175,19 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override,
|
|
|
6100
6175
|
# these phases never reach an emitter. §0's trace-attribution
|
|
6101
6176
|
# target is `cctally-bench --trace`, which builds directly
|
|
6102
6177
|
# (no decoupled standalone ingest) and keeps its phase tree.
|
|
6103
|
-
|
|
6104
|
-
#
|
|
6105
|
-
# the
|
|
6106
|
-
#
|
|
6107
|
-
|
|
6108
|
-
|
|
6109
|
-
|
|
6178
|
+
# Dashboard S4's physical identity includes both providers.
|
|
6179
|
+
# They are one recovery plan because quarantine replaces
|
|
6180
|
+
# the shared physical family: corruption in the second leg
|
|
6181
|
+
# must restart the first leg too.
|
|
6182
|
+
_, cache_conn = cache_mod._run_cache_plan_with_recovery(
|
|
6183
|
+
cache_conn,
|
|
6184
|
+
(
|
|
6185
|
+
lambda active_conn: sync_cache(
|
|
6186
|
+
active_conn, progress=cb
|
|
6187
|
+
),
|
|
6188
|
+
lambda active_conn: sync_codex_cache(active_conn),
|
|
6189
|
+
),
|
|
6190
|
+
)
|
|
6110
6191
|
except Exception as exc: # noqa: BLE001 — surfaced on the snap
|
|
6111
6192
|
sync_error = f"sync-cache: {exc}"
|
|
6112
6193
|
finally:
|
package/bin/_cctally_weekrefs.py
CHANGED
|
@@ -72,24 +72,34 @@ def _get_canonical_boundary_for_date(
|
|
|
72
72
|
return None, None
|
|
73
73
|
|
|
74
74
|
|
|
75
|
-
def get_recent_weeks(
|
|
75
|
+
def get_recent_weeks(
|
|
76
|
+
conn: sqlite3.Connection, limit: "int | None", *,
|
|
77
|
+
account_key: "str | None" = None,
|
|
78
|
+
) -> list[WeekRef]:
|
|
76
79
|
# ``limit=None`` → SQL ``LIMIT -1`` (unbounded): the milestone-history
|
|
77
80
|
# index (#hero-milestone-history) enumerates EVERY navigable week with no
|
|
78
81
|
# depth cap. Existing callers pass a concrete int and are unaffected.
|
|
82
|
+
#
|
|
83
|
+
# ``account_key`` (#341, spec §3): ``None`` = the account-blind merged read
|
|
84
|
+
# (today's byte-identical behavior); a real key / ``unattributed`` scopes both
|
|
85
|
+
# snapshot legs to that account's weeks (the ``--account`` render consumers —
|
|
86
|
+
# ``report`` — pass it explicitly).
|
|
79
87
|
limit_sql = -1 if limit is None else int(limit)
|
|
88
|
+
acct_pred = "" if account_key is None else " WHERE account_key = ?"
|
|
89
|
+
acct_p: tuple = () if account_key is None else (account_key,)
|
|
80
90
|
rows = conn.execute(
|
|
81
|
-
"""
|
|
91
|
+
f"""
|
|
82
92
|
SELECT week_start_date, MAX(week_end_date) AS week_end_date
|
|
83
93
|
FROM (
|
|
84
|
-
SELECT week_start_date, week_end_date FROM weekly_usage_snapshots
|
|
94
|
+
SELECT week_start_date, week_end_date FROM weekly_usage_snapshots{acct_pred}
|
|
85
95
|
UNION ALL
|
|
86
|
-
SELECT week_start_date, week_end_date FROM weekly_cost_snapshots
|
|
96
|
+
SELECT week_start_date, week_end_date FROM weekly_cost_snapshots{acct_pred}
|
|
87
97
|
)
|
|
88
98
|
GROUP BY week_start_date
|
|
89
99
|
ORDER BY week_start_date DESC
|
|
90
100
|
LIMIT ?
|
|
91
101
|
""",
|
|
92
|
-
(limit_sql,),
|
|
102
|
+
acct_p + acct_p + (limit_sql,),
|
|
93
103
|
).fetchall()
|
|
94
104
|
|
|
95
105
|
refs: list[WeekRef] = []
|
|
@@ -333,21 +343,31 @@ def _backfill_week_reset_events(conn: sqlite3.Connection) -> None:
|
|
|
333
343
|
return
|
|
334
344
|
try:
|
|
335
345
|
rows = conn.execute(
|
|
336
|
-
"SELECT captured_at_utc, week_end_at, weekly_percent "
|
|
346
|
+
"SELECT captured_at_utc, week_end_at, weekly_percent, account_key "
|
|
337
347
|
"FROM weekly_usage_snapshots "
|
|
338
348
|
"WHERE week_end_at IS NOT NULL "
|
|
339
|
-
"ORDER BY captured_at_utc ASC, id ASC"
|
|
349
|
+
"ORDER BY account_key ASC, captured_at_utc ASC, id ASC"
|
|
340
350
|
).fetchall()
|
|
341
351
|
except sqlite3.DatabaseError:
|
|
342
352
|
return
|
|
343
353
|
# Canonicalized (hour-rounded) previous end; stored canonical form is
|
|
344
354
|
# what WeekRef.week_end_at carries after make_week_ref, so maps in
|
|
345
355
|
# _apply_reset_events_to_weekrefs stay joinable without extra parsing.
|
|
356
|
+
# #341: the scan is PARTITIONED by account_key (ORDER BY account_key first)
|
|
357
|
+
# so one account's boundary shift never derives a phantom reset off another
|
|
358
|
+
# account's consecutive snapshot; prior state resets at each account boundary.
|
|
346
359
|
prior_end = None
|
|
347
360
|
prior_pct: float | None = None
|
|
361
|
+
prior_account = None
|
|
348
362
|
for row in rows:
|
|
349
363
|
cur_end_raw = row["week_end_at"]
|
|
350
364
|
cur_pct = row["weekly_percent"]
|
|
365
|
+
cur_account = row["account_key"]
|
|
366
|
+
if cur_account != prior_account:
|
|
367
|
+
# New account partition — do not compare across the boundary.
|
|
368
|
+
prior_end = None
|
|
369
|
+
prior_pct = None
|
|
370
|
+
prior_account = cur_account
|
|
351
371
|
if not cur_end_raw:
|
|
352
372
|
continue
|
|
353
373
|
try:
|
|
@@ -385,8 +405,9 @@ def _backfill_week_reset_events(conn: sqlite3.Connection) -> None:
|
|
|
385
405
|
conn.execute(
|
|
386
406
|
"INSERT OR IGNORE INTO week_reset_events "
|
|
387
407
|
"(detected_at_utc, old_week_end_at, new_week_end_at, "
|
|
388
|
-
" effective_reset_at_utc) VALUES (?, ?, ?, ?)",
|
|
389
|
-
(row["captured_at_utc"], prior_end, cur_end, effective_iso
|
|
408
|
+
" effective_reset_at_utc, account_key) VALUES (?, ?, ?, ?, ?)",
|
|
409
|
+
(row["captured_at_utc"], prior_end, cur_end, effective_iso,
|
|
410
|
+
cur_account),
|
|
390
411
|
)
|
|
391
412
|
elif prior_end and cur_end == prior_end:
|
|
392
413
|
# In-place credit branch (v1.7.2). Mirrors the live detection
|
|
@@ -418,8 +439,8 @@ def _backfill_week_reset_events(conn: sqlite3.Connection) -> None:
|
|
|
418
439
|
# upgrade. (See round-2 review Bug 1.)
|
|
419
440
|
already = conn.execute(
|
|
420
441
|
"SELECT 1 FROM week_reset_events "
|
|
421
|
-
"WHERE new_week_end_at = ? LIMIT 1",
|
|
422
|
-
(cur_end,),
|
|
442
|
+
"WHERE new_week_end_at = ? AND account_key = ? LIMIT 1",
|
|
443
|
+
(cur_end, cur_account),
|
|
423
444
|
).fetchone()
|
|
424
445
|
if already is not None:
|
|
425
446
|
prior_end = cur_end
|
|
@@ -450,8 +471,9 @@ def _backfill_week_reset_events(conn: sqlite3.Connection) -> None:
|
|
|
450
471
|
conn.execute(
|
|
451
472
|
"INSERT OR IGNORE INTO week_reset_events "
|
|
452
473
|
"(detected_at_utc, old_week_end_at, new_week_end_at, "
|
|
453
|
-
" effective_reset_at_utc) VALUES (?, ?, ?, ?)",
|
|
454
|
-
(row["captured_at_utc"], effective_iso, cur_end, effective_iso
|
|
474
|
+
" effective_reset_at_utc, account_key) VALUES (?, ?, ?, ?, ?)",
|
|
475
|
+
(row["captured_at_utc"], effective_iso, cur_end, effective_iso,
|
|
476
|
+
cur_account),
|
|
455
477
|
)
|
|
456
478
|
prior_end = cur_end
|
|
457
479
|
prior_pct = cur_pct
|
|
@@ -553,7 +575,7 @@ def _week_ref_has_reset_event(
|
|
|
553
575
|
|
|
554
576
|
|
|
555
577
|
def _compute_cost_for_weekref(
|
|
556
|
-
ref: WeekRef, *, skip_sync: bool = False
|
|
578
|
+
ref: WeekRef, *, skip_sync: bool = False, account_key: "str | None" = None,
|
|
557
579
|
) -> float | None:
|
|
558
580
|
"""Live-compute USD cost over `ref`'s (possibly reset-adjusted) range
|
|
559
581
|
straight from session_entries. Mirrors what cmd_sync_week writes into
|
|
@@ -577,7 +599,8 @@ def _compute_cost_for_weekref(
|
|
|
577
599
|
return None
|
|
578
600
|
if end <= start:
|
|
579
601
|
return 0.0
|
|
580
|
-
return c._sum_cost_for_range(
|
|
602
|
+
return c._sum_cost_for_range(
|
|
603
|
+
start, end, mode="auto", skip_sync=skip_sync, account_key=account_key)
|
|
581
604
|
|
|
582
605
|
|
|
583
606
|
def _apply_overlap_clamp_to_weekrefs(refs: list[WeekRef]) -> list[WeekRef]:
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Pure account-identity kernel for the multi-account epic (#341).
|
|
2
|
+
|
|
3
|
+
Mirrors ``bin/_lib_source_identity.py``: an opaque, non-reversible per-account
|
|
4
|
+
key plus the small set of pure helpers that derive an account identity from the
|
|
5
|
+
provider's own on-disk credential state. I/O-free by construction beyond the
|
|
6
|
+
stat-read-stat protocol (stdlib ``os`` only) and read-only SQLite lookups for
|
|
7
|
+
ref resolution — no ``_cctally_*`` imports, no locks, no writes.
|
|
8
|
+
|
|
9
|
+
Identity model (design spec §1):
|
|
10
|
+
- ``account_key = sha256("cctally-account-v1\\0" + provider + "\\0" + natural_id)[:32]``
|
|
11
|
+
(opaque, domain-separated, mirroring ``source_root_key``).
|
|
12
|
+
- Natural ids: Claude -> ``oauthAccount.accountUuid``; Codex ->
|
|
13
|
+
``chatgpt_account_id + "\\0" + email`` (the pair, since neither is unique
|
|
14
|
+
alone), decoded from the ``~/.codex/auth.json`` id_token JWT payload (plain
|
|
15
|
+
base64 body decode; NO signature verification — we read our own disk state,
|
|
16
|
+
not authenticate).
|
|
17
|
+
- Reserved sentinel ``UNATTRIBUTED = "unattributed"`` means "account could not
|
|
18
|
+
be determined" (pre-feature history and any stably-unreadable ingest).
|
|
19
|
+
- ``VENDOR_WIDE = "*"`` is the vendor-wide budget sentinel (used by later
|
|
20
|
+
tasks; defined here as the single home for the constant).
|
|
21
|
+
|
|
22
|
+
Stable-read protocol (review finding 7): identity files are rewritten in place
|
|
23
|
+
by other programs, so every read is stat-read-stat on ``(st_ino, st_size,
|
|
24
|
+
st_mtime_ns)`` with bounded retries. Three-valued outcome: *identified* (stamp
|
|
25
|
+
the account), *stably_absent* (missing file / api-key mode -> stamp
|
|
26
|
+
``unattributed``), or *torn* (stats never stabilised, or content unparseable
|
|
27
|
+
mid-write -> defer, never stamp a guess).
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import base64
|
|
32
|
+
import binascii
|
|
33
|
+
import hashlib
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from typing import Callable, Optional, TypeVar
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
ACCOUNT_KEY_VERSION = 1
|
|
41
|
+
UNATTRIBUTED = "unattributed"
|
|
42
|
+
VENDOR_WIDE = "*"
|
|
43
|
+
|
|
44
|
+
# The namespaced OpenAI OIDC claim carrying the ChatGPT account identity.
|
|
45
|
+
_OPENAI_AUTH_CLAIM = "https://api.openai.com/auth"
|
|
46
|
+
|
|
47
|
+
T = TypeVar("T")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _required_string(value: object, name: str) -> str:
|
|
51
|
+
if not isinstance(value, str) or not value:
|
|
52
|
+
raise ValueError(f"{name} must be a non-empty string")
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# --------------------------------------------------------------------------
|
|
57
|
+
# key derivation
|
|
58
|
+
# --------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def account_key(provider: str, natural_id: str) -> str:
|
|
61
|
+
"""Opaque, non-reversible 32-hex key for one account.
|
|
62
|
+
|
|
63
|
+
``sha256("cctally-account-v1\\0" + provider + "\\0" + natural_id)[:32]``.
|
|
64
|
+
Stable across processes; distinct per provider and per natural id."""
|
|
65
|
+
prov = _required_string(provider, "provider")
|
|
66
|
+
nat = _required_string(natural_id, "natural_id")
|
|
67
|
+
digest = hashlib.sha256(
|
|
68
|
+
b"cctally-account-v1\0" + prov.encode("utf-8") + b"\0" + nat.encode("utf-8")
|
|
69
|
+
)
|
|
70
|
+
return digest.hexdigest()[:32]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# --------------------------------------------------------------------------
|
|
74
|
+
# natural-id extraction (pure; operate on already-decoded dicts)
|
|
75
|
+
# --------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def claude_natural_id(oauth_account: object) -> Optional[str]:
|
|
78
|
+
"""Claude natural id = ``oauthAccount.accountUuid`` (or None if absent)."""
|
|
79
|
+
if not isinstance(oauth_account, dict):
|
|
80
|
+
return None
|
|
81
|
+
uuid = oauth_account.get("accountUuid")
|
|
82
|
+
if isinstance(uuid, str) and uuid:
|
|
83
|
+
return uuid
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def claude_email(oauth_account: object) -> Optional[str]:
|
|
88
|
+
"""Best-effort Claude email (``oauthAccount.emailAddress``) for display."""
|
|
89
|
+
if not isinstance(oauth_account, dict):
|
|
90
|
+
return None
|
|
91
|
+
email = oauth_account.get("emailAddress")
|
|
92
|
+
if isinstance(email, str) and email:
|
|
93
|
+
return email
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _codex_auth_claim(payload: dict) -> dict:
|
|
98
|
+
"""Return the OpenAI auth claim object, tolerating the flat dotted form."""
|
|
99
|
+
claim = payload.get(_OPENAI_AUTH_CLAIM)
|
|
100
|
+
if isinstance(claim, dict):
|
|
101
|
+
return claim
|
|
102
|
+
return {}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def codex_natural_id(id_token_payload: object) -> Optional[str]:
|
|
106
|
+
"""Codex natural id = ``chatgpt_account_id + "\\0" + email`` (the pair).
|
|
107
|
+
|
|
108
|
+
Returns None when either component is missing — e.g. ``OPENAI_API_KEY``
|
|
109
|
+
mode, which has no ChatGPT identity at all."""
|
|
110
|
+
if not isinstance(id_token_payload, dict):
|
|
111
|
+
return None
|
|
112
|
+
email = id_token_payload.get("email")
|
|
113
|
+
claim = _codex_auth_claim(id_token_payload)
|
|
114
|
+
account_id = claim.get("chatgpt_account_id")
|
|
115
|
+
# Flat fallback: a token that hoisted the id to the top level.
|
|
116
|
+
if not isinstance(account_id, str):
|
|
117
|
+
account_id = id_token_payload.get("chatgpt_account_id")
|
|
118
|
+
if not isinstance(email, str) or not email:
|
|
119
|
+
return None
|
|
120
|
+
if not isinstance(account_id, str) or not account_id:
|
|
121
|
+
return None
|
|
122
|
+
return account_id + "\0" + email
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def codex_email(id_token_payload: object) -> Optional[str]:
|
|
126
|
+
if not isinstance(id_token_payload, dict):
|
|
127
|
+
return None
|
|
128
|
+
email = id_token_payload.get("email")
|
|
129
|
+
return email if isinstance(email, str) and email else None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def codex_plan_type(id_token_payload: object) -> Optional[str]:
|
|
133
|
+
"""Best-effort ChatGPT plan type for registry enrichment."""
|
|
134
|
+
if not isinstance(id_token_payload, dict):
|
|
135
|
+
return None
|
|
136
|
+
claim = _codex_auth_claim(id_token_payload)
|
|
137
|
+
plan = claim.get("chatgpt_plan_type")
|
|
138
|
+
if not isinstance(plan, str):
|
|
139
|
+
plan = id_token_payload.get("chatgpt_plan_type")
|
|
140
|
+
return plan if isinstance(plan, str) and plan else None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# --------------------------------------------------------------------------
|
|
144
|
+
# JWT id_token decode (base64 body, NO signature verification)
|
|
145
|
+
# --------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
def decode_id_token_payload(id_token: object) -> Optional[dict]:
|
|
148
|
+
"""Decode the payload segment of a JWT ``id_token``.
|
|
149
|
+
|
|
150
|
+
Returns the payload dict, or None on ANY failure. We are reading our own
|
|
151
|
+
disk state, not authenticating, so the signature is neither present-checked
|
|
152
|
+
nor verified — only the base64url body is decoded and JSON-parsed."""
|
|
153
|
+
if not isinstance(id_token, str) or not id_token:
|
|
154
|
+
return None
|
|
155
|
+
parts = id_token.split(".")
|
|
156
|
+
if len(parts) < 2:
|
|
157
|
+
return None
|
|
158
|
+
body = parts[1]
|
|
159
|
+
padded = body + "=" * (-len(body) % 4)
|
|
160
|
+
try:
|
|
161
|
+
raw = base64.urlsafe_b64decode(padded.encode("ascii"))
|
|
162
|
+
except (binascii.Error, ValueError):
|
|
163
|
+
return None
|
|
164
|
+
try:
|
|
165
|
+
obj = json.loads(raw)
|
|
166
|
+
except (ValueError, TypeError):
|
|
167
|
+
return None
|
|
168
|
+
return obj if isinstance(obj, dict) else None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# --------------------------------------------------------------------------
|
|
172
|
+
# stat-read-stat stable-read protocol
|
|
173
|
+
# --------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
class TornRead(Exception):
|
|
176
|
+
"""Raised by a stable-read reader when the bytes are unparseable mid-write.
|
|
177
|
+
|
|
178
|
+
The stable-read machinery treats it as a *torn* outcome (defer), distinct
|
|
179
|
+
from a reader that returns None (parsed cleanly, but carries no identity ->
|
|
180
|
+
stably_absent)."""
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass
|
|
184
|
+
class StableRead:
|
|
185
|
+
"""Outcome of :func:`stable_read_identity`.
|
|
186
|
+
|
|
187
|
+
``status`` is one of ``"identified"`` / ``"stably_absent"`` / ``"torn"``;
|
|
188
|
+
``value`` is the reader's result only when ``identified``."""
|
|
189
|
+
|
|
190
|
+
status: str
|
|
191
|
+
value: object = None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _stat_signature(st) -> tuple:
|
|
195
|
+
return (st.st_ino, st.st_size, st.st_mtime_ns)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def stable_read_identity(
|
|
199
|
+
path: str,
|
|
200
|
+
reader: Callable[[bytes], T],
|
|
201
|
+
*,
|
|
202
|
+
max_retries: int = 3,
|
|
203
|
+
_stat=None,
|
|
204
|
+
_open=None,
|
|
205
|
+
) -> StableRead:
|
|
206
|
+
"""stat -> read -> stat an identity file, retrying on a changed signature.
|
|
207
|
+
|
|
208
|
+
Outcomes:
|
|
209
|
+
* ``stably_absent`` — the file is missing (any attempt), or ``reader``
|
|
210
|
+
returns None over a stat-stable read (content present, no identity).
|
|
211
|
+
* ``torn`` — the ``(inode, size, mtime_ns)`` signature never stabilised
|
|
212
|
+
across ``max_retries`` attempts, or ``reader`` raised :class:`TornRead`.
|
|
213
|
+
* ``identified`` — a stat-stable read whose ``reader`` returned a value.
|
|
214
|
+
|
|
215
|
+
The private ``_stat``/``_open`` hooks exist only for deterministic torn-read
|
|
216
|
+
testing; production always uses ``os.stat`` / ``open``."""
|
|
217
|
+
stat = _stat or os.stat
|
|
218
|
+
opener = _open or open
|
|
219
|
+
for _ in range(max_retries + 1):
|
|
220
|
+
try:
|
|
221
|
+
st1 = stat(path)
|
|
222
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
223
|
+
return StableRead("stably_absent")
|
|
224
|
+
except OSError:
|
|
225
|
+
continue
|
|
226
|
+
sig1 = _stat_signature(st1)
|
|
227
|
+
try:
|
|
228
|
+
with opener(path, "rb") as fh:
|
|
229
|
+
data = fh.read()
|
|
230
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
231
|
+
return StableRead("stably_absent")
|
|
232
|
+
except OSError:
|
|
233
|
+
continue
|
|
234
|
+
try:
|
|
235
|
+
st2 = stat(path)
|
|
236
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
237
|
+
return StableRead("stably_absent")
|
|
238
|
+
except OSError:
|
|
239
|
+
continue
|
|
240
|
+
if sig1 != _stat_signature(st2):
|
|
241
|
+
continue # file changed during the read window -> retry
|
|
242
|
+
try:
|
|
243
|
+
value = reader(data)
|
|
244
|
+
except TornRead:
|
|
245
|
+
return StableRead("torn")
|
|
246
|
+
if value is None:
|
|
247
|
+
return StableRead("stably_absent")
|
|
248
|
+
return StableRead("identified", value)
|
|
249
|
+
return StableRead("torn")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# --------------------------------------------------------------------------
|
|
253
|
+
# account-ref resolution (read-only over the accounts registry)
|
|
254
|
+
# --------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
class AccountRefError(Exception):
|
|
257
|
+
"""Raised when an account ref is ambiguous or unknown.
|
|
258
|
+
|
|
259
|
+
``candidates`` is the list of ``account_key``s that the caller can print on
|
|
260
|
+
stderr (the tied matches for an ambiguous ref; every known account for an
|
|
261
|
+
unknown ref)."""
|
|
262
|
+
|
|
263
|
+
def __init__(self, ref: str, candidates):
|
|
264
|
+
self.ref = ref
|
|
265
|
+
self.candidates = list(candidates)
|
|
266
|
+
super().__init__(f"account ref {ref!r} is ambiguous or unknown")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@dataclass
|
|
270
|
+
class _AccountRow:
|
|
271
|
+
account_key: str
|
|
272
|
+
provider: str
|
|
273
|
+
label: Optional[str] = None
|
|
274
|
+
email: Optional[str] = None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _load_account_rows(conn, provider: Optional[str]):
|
|
278
|
+
sql = "SELECT account_key, provider, label, email FROM accounts"
|
|
279
|
+
params: tuple = ()
|
|
280
|
+
if provider is not None:
|
|
281
|
+
sql += " WHERE provider = ?"
|
|
282
|
+
params = (provider,)
|
|
283
|
+
rows = []
|
|
284
|
+
for account_key_, prov, label, email in conn.execute(sql, params).fetchall():
|
|
285
|
+
rows.append(_AccountRow(account_key_, prov, label, email))
|
|
286
|
+
return rows
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def resolve_account_ref(conn, ref: str, provider: Optional[str] = None) -> str:
|
|
290
|
+
"""Resolve a user-supplied account ref to an ``account_key``.
|
|
291
|
+
|
|
292
|
+
Precedence: case-insensitive label -> case-insensitive email -> unique
|
|
293
|
+
``account_key`` prefix. The literal ``"unattributed"`` sentinel is accepted
|
|
294
|
+
directly. Ambiguity within a tier, or no match in any tier, raises
|
|
295
|
+
:class:`AccountRefError` (candidates = the tied matches, or every known
|
|
296
|
+
account when unknown)."""
|
|
297
|
+
if not isinstance(ref, str) or not ref:
|
|
298
|
+
raise AccountRefError(ref, [])
|
|
299
|
+
if ref == UNATTRIBUTED:
|
|
300
|
+
return UNATTRIBUTED
|
|
301
|
+
rows = _load_account_rows(conn, provider)
|
|
302
|
+
needle = ref.strip().lower()
|
|
303
|
+
|
|
304
|
+
# Tier 1: exact case-insensitive label.
|
|
305
|
+
label_matches = [r for r in rows if (r.label or "").lower() == needle]
|
|
306
|
+
if len(label_matches) == 1:
|
|
307
|
+
return label_matches[0].account_key
|
|
308
|
+
if len(label_matches) > 1:
|
|
309
|
+
raise AccountRefError(ref, [r.account_key for r in label_matches])
|
|
310
|
+
|
|
311
|
+
# Tier 2: exact case-insensitive email.
|
|
312
|
+
email_matches = [r for r in rows if (r.email or "").lower() == needle]
|
|
313
|
+
if len(email_matches) == 1:
|
|
314
|
+
return email_matches[0].account_key
|
|
315
|
+
if len(email_matches) > 1:
|
|
316
|
+
raise AccountRefError(ref, [r.account_key for r in email_matches])
|
|
317
|
+
|
|
318
|
+
# Tier 3: account_key prefix (case-sensitive hex).
|
|
319
|
+
prefix_matches = [r for r in rows if r.account_key.startswith(ref)]
|
|
320
|
+
if len(prefix_matches) == 1:
|
|
321
|
+
return prefix_matches[0].account_key
|
|
322
|
+
if len(prefix_matches) > 1:
|
|
323
|
+
raise AccountRefError(ref, [r.account_key for r in prefix_matches])
|
|
324
|
+
|
|
325
|
+
raise AccountRefError(ref, [r.account_key for r in rows])
|