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.
Files changed (52) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/bin/_cctally_account.py +397 -0
  3. package/bin/_cctally_alerts.py +58 -2
  4. package/bin/_cctally_cache.py +696 -166
  5. package/bin/_cctally_cache_report.py +21 -3
  6. package/bin/_cctally_config.py +119 -8
  7. package/bin/_cctally_core.py +343 -48
  8. package/bin/_cctally_dashboard.py +32 -7
  9. package/bin/_cctally_dashboard_conversation.py +6 -2
  10. package/bin/_cctally_dashboard_envelope.py +49 -0
  11. package/bin/_cctally_dashboard_share.py +78 -1
  12. package/bin/_cctally_dashboard_sources.py +459 -59
  13. package/bin/_cctally_db.py +659 -152
  14. package/bin/_cctally_diff.py +9 -0
  15. package/bin/_cctally_doctor.py +201 -26
  16. package/bin/_cctally_five_hour.py +110 -69
  17. package/bin/_cctally_forecast.py +112 -43
  18. package/bin/_cctally_journal.py +591 -80
  19. package/bin/_cctally_milestones.py +180 -67
  20. package/bin/_cctally_parser.py +87 -0
  21. package/bin/_cctally_percent_breakdown.py +24 -15
  22. package/bin/_cctally_project.py +12 -1
  23. package/bin/_cctally_quota.py +150 -39
  24. package/bin/_cctally_record.py +491 -141
  25. package/bin/_cctally_reporting.py +66 -14
  26. package/bin/_cctally_setup.py +9 -1
  27. package/bin/_cctally_source_analytics.py +56 -7
  28. package/bin/_cctally_statusline.py +55 -8
  29. package/bin/_cctally_store.py +20 -8
  30. package/bin/_cctally_sync_week.py +30 -10
  31. package/bin/_cctally_tui.py +99 -18
  32. package/bin/_cctally_weekrefs.py +38 -15
  33. package/bin/_lib_accounts.py +325 -0
  34. package/bin/_lib_alerts_payload.py +25 -4
  35. package/bin/_lib_cache_writer_lock.py +77 -0
  36. package/bin/_lib_codex_hooks.py +40 -1
  37. package/bin/_lib_diff_kernel.py +28 -14
  38. package/bin/_lib_doctor.py +160 -0
  39. package/bin/_lib_journal.py +65 -2
  40. package/bin/_lib_quota.py +81 -4
  41. package/bin/_lib_render.py +19 -5
  42. package/bin/_lib_share.py +25 -0
  43. package/bin/_lib_snapshot_cache.py +8 -0
  44. package/bin/_lib_subscription_weeks.py +16 -2
  45. package/bin/_lib_view_models.py +18 -9
  46. package/bin/cctally +43 -4
  47. package/dashboard/static/assets/index-DJP4gEB7.js +92 -0
  48. package/dashboard/static/assets/index-Dk1nplOz.css +1 -0
  49. package/dashboard/static/dashboard.html +2 -2
  50. package/package.json +4 -1
  51. package/dashboard/static/assets/index-B3y14l1o.js +0 -92
  52. package/dashboard/static/assets/index-Cc2ykqF_.css +0 -1
@@ -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
- return numeric_legs if not digest else f"{numeric_legs}.{digest}"
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
- try:
2754
- claude_ingest = sync_cache(cache_conn)
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
- except Exception as exc:
2831
+ else:
2759
2832
  claude_ingest_failed = True
2760
- errors.append(f"sync-cache: {exc}")
2833
+ errors.append(f"sync-cache: {claude_error}")
2761
2834
  if precompute_envelope:
2762
- try:
2763
- codex_ingest = sync_codex_cache(cache_conn)
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
- except Exception as exc:
2840
+ else:
2768
2841
  codex_ingest_failed = True
2769
- errors.append(f"sync-codex-cache: {exc}")
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
- sync_cache(cache_conn, progress=cb)
6104
- # Dashboard S4's physical identity includes Codex. Keep
6105
- # the decoupled path's ingest set identical to the direct
6106
- # dashboard snapshot path before its final pure read, or
6107
- # it can publish a legacy data version from a different
6108
- # provider generation.
6109
- sync_codex_cache(cache_conn)
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:
@@ -72,24 +72,34 @@ def _get_canonical_boundary_for_date(
72
72
  return None, None
73
73
 
74
74
 
75
- def get_recent_weeks(conn: sqlite3.Connection, limit: "int | None") -> list[WeekRef]:
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(start, end, mode="auto", skip_sync=skip_sync)
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])