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
|
@@ -54,6 +54,15 @@ def _load_lib(name: str):
|
|
|
54
54
|
_lib_display_tz = _load_lib("_lib_display_tz")
|
|
55
55
|
format_display_dt = _lib_display_tz.format_display_dt
|
|
56
56
|
|
|
57
|
+
# Account dimension (#341): every alert payload carries the account_key it wrote
|
|
58
|
+
# under as a TOP-LEVEL field (never in `context`, to avoid touching any
|
|
59
|
+
# context-serialized golden). Real-account families default to the sentinel;
|
|
60
|
+
# vendor-wide families default to `*`. `_dispatch_alert_notification` reads it for
|
|
61
|
+
# the unconditional 8th `alerts.log` field + the R8 `[label]` title prefix.
|
|
62
|
+
_lib_accounts = _load_lib("_lib_accounts")
|
|
63
|
+
_UNATTRIBUTED = _lib_accounts.UNATTRIBUTED
|
|
64
|
+
_VENDOR_WIDE = _lib_accounts.VENDOR_WIDE
|
|
65
|
+
|
|
57
66
|
|
|
58
67
|
def _alert_text_weekly(payload: dict, tz: "ZoneInfo | None") -> tuple[str, str, str]:
|
|
59
68
|
"""Build (title, subtitle, body) for a weekly threshold alert.
|
|
@@ -137,6 +146,7 @@ def _build_alert_payload_weekly(
|
|
|
137
146
|
week_start_date: str,
|
|
138
147
|
cumulative_cost_usd: float,
|
|
139
148
|
dollars_per_percent: "float | None",
|
|
149
|
+
account_key: str = _UNATTRIBUTED,
|
|
140
150
|
) -> dict:
|
|
141
151
|
"""Build the alert payload for a weekly threshold crossing.
|
|
142
152
|
|
|
@@ -144,14 +154,15 @@ def _build_alert_payload_weekly(
|
|
|
144
154
|
sets the DB ``alerted_at`` BEFORE invoking ``_dispatch_alert_notification``
|
|
145
155
|
(set-then-dispatch invariant, spec §3.2). Consumers (envelope builders,
|
|
146
156
|
test inspectors) read the ``alerted_at`` field as the authoritative
|
|
147
|
-
"alert was attempted" timestamp.
|
|
148
|
-
"""
|
|
157
|
+
"alert was attempted" timestamp. ``account_key`` (#341) is the real account
|
|
158
|
+
the crossing wrote under (per-account family)."""
|
|
149
159
|
return {
|
|
150
160
|
"id": f"weekly:{week_start_date}:{threshold}",
|
|
151
161
|
"axis": "weekly",
|
|
152
162
|
"threshold": int(threshold),
|
|
153
163
|
"crossed_at": crossed_at_utc,
|
|
154
164
|
"alerted_at": crossed_at_utc, # set-then-dispatch
|
|
165
|
+
"account_key": str(account_key),
|
|
155
166
|
"context": {
|
|
156
167
|
"week_start_date": week_start_date,
|
|
157
168
|
"cumulative_cost_usd": float(cumulative_cost_usd),
|
|
@@ -170,6 +181,7 @@ def _build_alert_payload_five_hour(
|
|
|
170
181
|
block_start_at: str,
|
|
171
182
|
block_cost_usd: float,
|
|
172
183
|
primary_model: "str | None",
|
|
184
|
+
account_key: str = _UNATTRIBUTED,
|
|
173
185
|
) -> dict:
|
|
174
186
|
"""Build the alert payload for a 5h-block threshold crossing.
|
|
175
187
|
|
|
@@ -177,14 +189,15 @@ def _build_alert_payload_five_hour(
|
|
|
177
189
|
rationale. ``primary_model`` is the highest-cost model active in the
|
|
178
190
|
block (resolved via ``_resolve_primary_model_for_block``); ``None`` when
|
|
179
191
|
the rollup-children child table is empty (e.g., direct-JSONL-fallback
|
|
180
|
-
path before lazy backfill).
|
|
181
|
-
"""
|
|
192
|
+
path before lazy backfill). ``account_key`` (#341) is the block's account
|
|
193
|
+
(per-account family)."""
|
|
182
194
|
return {
|
|
183
195
|
"id": f"five_hour:{five_hour_window_key}:{threshold}",
|
|
184
196
|
"axis": "five_hour",
|
|
185
197
|
"threshold": int(threshold),
|
|
186
198
|
"crossed_at": crossed_at_utc,
|
|
187
199
|
"alerted_at": crossed_at_utc, # set-then-dispatch
|
|
200
|
+
"account_key": str(account_key),
|
|
188
201
|
"context": {
|
|
189
202
|
"five_hour_window_key": int(five_hour_window_key),
|
|
190
203
|
"block_start_at": block_start_at,
|
|
@@ -223,6 +236,7 @@ def _build_alert_payload_budget(
|
|
|
223
236
|
spent_usd: float,
|
|
224
237
|
consumption_pct: float,
|
|
225
238
|
period: str = "subscription-week",
|
|
239
|
+
account_key: str = _VENDOR_WIDE,
|
|
226
240
|
) -> dict:
|
|
227
241
|
"""Build the alert payload for an equiv-$ budget threshold crossing.
|
|
228
242
|
|
|
@@ -245,6 +259,7 @@ def _build_alert_payload_budget(
|
|
|
245
259
|
"threshold": int(threshold),
|
|
246
260
|
"crossed_at": crossed_at_utc,
|
|
247
261
|
"alerted_at": crossed_at_utc, # set-then-dispatch
|
|
262
|
+
"account_key": str(account_key),
|
|
248
263
|
"context": {
|
|
249
264
|
"week_start_at": week_start_at,
|
|
250
265
|
"period": str(period),
|
|
@@ -296,6 +311,7 @@ def _build_alert_payload_project_budget(
|
|
|
296
311
|
budget_usd: float,
|
|
297
312
|
spent_usd: float,
|
|
298
313
|
consumption_pct: float,
|
|
314
|
+
account_key: str = _VENDOR_WIDE,
|
|
299
315
|
) -> dict:
|
|
300
316
|
"""Build the alert payload for a PER-PROJECT equiv-$ budget threshold
|
|
301
317
|
crossing (axis ``project_budget``, the fifth alert axis; spec §5.3).
|
|
@@ -315,6 +331,7 @@ def _build_alert_payload_project_budget(
|
|
|
315
331
|
"threshold": int(threshold),
|
|
316
332
|
"crossed_at": crossed_at_utc,
|
|
317
333
|
"alerted_at": crossed_at_utc, # set-then-dispatch
|
|
334
|
+
"account_key": str(account_key),
|
|
318
335
|
"context": {
|
|
319
336
|
"week_start_at": week_start_at,
|
|
320
337
|
"project": project,
|
|
@@ -369,6 +386,7 @@ def _build_alert_payload_codex_budget(
|
|
|
369
386
|
budget_usd: float,
|
|
370
387
|
spent_usd: float,
|
|
371
388
|
consumption_pct: float,
|
|
389
|
+
account_key: str = _VENDOR_WIDE,
|
|
372
390
|
) -> dict:
|
|
373
391
|
"""Build the alert payload for a Codex budget threshold crossing (axis
|
|
374
392
|
``codex_budget``, the sixth alert axis; spec §6).
|
|
@@ -388,6 +406,7 @@ def _build_alert_payload_codex_budget(
|
|
|
388
406
|
"threshold": int(threshold),
|
|
389
407
|
"crossed_at": crossed_at_utc,
|
|
390
408
|
"alerted_at": crossed_at_utc, # set-then-dispatch
|
|
409
|
+
"account_key": str(account_key),
|
|
391
410
|
"context": {
|
|
392
411
|
"period": str(period),
|
|
393
412
|
"period_start_at": period_start_at,
|
|
@@ -442,6 +461,7 @@ def _build_alert_payload_projected(
|
|
|
442
461
|
projected_value: float,
|
|
443
462
|
denominator: float,
|
|
444
463
|
week_start_at: str,
|
|
464
|
+
account_key: str = _VENDOR_WIDE,
|
|
445
465
|
) -> dict:
|
|
446
466
|
"""Build the alert payload for a projected-pace threshold crossing (#121).
|
|
447
467
|
|
|
@@ -464,6 +484,7 @@ def _build_alert_payload_projected(
|
|
|
464
484
|
"threshold": int(threshold),
|
|
465
485
|
"projected_value": float(projected_value),
|
|
466
486
|
"denominator": float(denominator),
|
|
487
|
+
"account_key": str(account_key),
|
|
467
488
|
"context": {
|
|
468
489
|
"week_start_at": week_start_at,
|
|
469
490
|
"metric": str(metric),
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Ordered cross-process writer locks for the shared cache.db family.
|
|
2
|
+
|
|
3
|
+
Every cache.db mutator takes the global writer lock first. Codex mutators then
|
|
4
|
+
take the provider lock before opening a SQLite write transaction. A single
|
|
5
|
+
deadline covers the complete lock set so adding the provider lock cannot double
|
|
6
|
+
the caller's bounded-wait contract.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import fcntl
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def acquire_ordered_flocks(
|
|
18
|
+
locks: list[tuple[Path, int]],
|
|
19
|
+
*,
|
|
20
|
+
timeout: float | None = None,
|
|
21
|
+
) -> list[int] | None:
|
|
22
|
+
"""Acquire ``(path, LOCK_SH|LOCK_EX)`` entries in caller-supplied order."""
|
|
23
|
+
deadline = None if timeout is None else time.monotonic() + max(timeout, 0.0)
|
|
24
|
+
held: list[int] = []
|
|
25
|
+
try:
|
|
26
|
+
for path, mode in locks:
|
|
27
|
+
fd = os.open(str(path), os.O_RDWR | os.O_CREAT, 0o600)
|
|
28
|
+
os.fchmod(fd, 0o600)
|
|
29
|
+
held.append(fd)
|
|
30
|
+
while True:
|
|
31
|
+
try:
|
|
32
|
+
fcntl.flock(fd, mode | fcntl.LOCK_NB)
|
|
33
|
+
break
|
|
34
|
+
except BlockingIOError:
|
|
35
|
+
if deadline is None or time.monotonic() >= deadline:
|
|
36
|
+
release_cache_writer_flocks(held)
|
|
37
|
+
return None
|
|
38
|
+
time.sleep(min(0.2, max(0.0, deadline - time.monotonic())))
|
|
39
|
+
return held
|
|
40
|
+
except BaseException:
|
|
41
|
+
release_cache_writer_flocks(held)
|
|
42
|
+
raise
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def acquire_cache_writer_flocks(
|
|
46
|
+
writer_path: Path,
|
|
47
|
+
provider_path: Path | None = None,
|
|
48
|
+
*,
|
|
49
|
+
timeout: float | None = None,
|
|
50
|
+
) -> list[int] | None:
|
|
51
|
+
"""Acquire global then optional provider flock, or return ``None``.
|
|
52
|
+
|
|
53
|
+
``timeout=None`` performs one non-blocking attempt for the entire set.
|
|
54
|
+
A non-negative timeout bounds the complete acquisition, not each lock.
|
|
55
|
+
Returned descriptors must be released with
|
|
56
|
+
:func:`release_cache_writer_flocks`.
|
|
57
|
+
"""
|
|
58
|
+
paths = [writer_path]
|
|
59
|
+
if provider_path is not None and provider_path != writer_path:
|
|
60
|
+
paths.append(provider_path)
|
|
61
|
+
return acquire_ordered_flocks(
|
|
62
|
+
[(path, fcntl.LOCK_EX) for path in paths],
|
|
63
|
+
timeout=timeout,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def release_cache_writer_flocks(held: list[int]) -> None:
|
|
68
|
+
"""Release an acquired lock set in reverse order."""
|
|
69
|
+
for fd in reversed(held):
|
|
70
|
+
try:
|
|
71
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
72
|
+
except OSError:
|
|
73
|
+
pass
|
|
74
|
+
try:
|
|
75
|
+
os.close(fd)
|
|
76
|
+
except OSError:
|
|
77
|
+
pass
|
package/bin/_lib_codex_hooks.py
CHANGED
|
@@ -301,12 +301,43 @@ def codex_hook_roots(paths: list[pathlib.Path]) -> list[CodexHookRoot]:
|
|
|
301
301
|
return [roots[key] for key in sorted(roots)]
|
|
302
302
|
|
|
303
303
|
|
|
304
|
+
def _resolve_codex_hook_account(root: CodexHookRoot) -> str:
|
|
305
|
+
"""Resolve one hook root's active account_key from ``<home>/auth.json`` via
|
|
306
|
+
the stable-read protocol (#341, spec §1). identified -> the real key;
|
|
307
|
+
stably-absent (no auth / api-key mode) OR torn -> the ``unattributed``
|
|
308
|
+
sentinel (so the throttle marker keeps its byte-stable name). Read-only."""
|
|
309
|
+
import _lib_accounts
|
|
310
|
+
|
|
311
|
+
def _reader(data: bytes):
|
|
312
|
+
try:
|
|
313
|
+
obj = json.loads(data)
|
|
314
|
+
except (ValueError, TypeError):
|
|
315
|
+
raise _lib_accounts.TornRead()
|
|
316
|
+
if not isinstance(obj, dict):
|
|
317
|
+
raise _lib_accounts.TornRead()
|
|
318
|
+
tokens = obj.get("tokens")
|
|
319
|
+
id_token = (tokens.get("id_token") if isinstance(tokens, dict)
|
|
320
|
+
else obj.get("id_token"))
|
|
321
|
+
payload = _lib_accounts.decode_id_token_payload(id_token)
|
|
322
|
+
nat = _lib_accounts.codex_natural_id(payload)
|
|
323
|
+
if nat is None:
|
|
324
|
+
return None
|
|
325
|
+
return _lib_accounts.account_key("codex", nat)
|
|
326
|
+
|
|
327
|
+
result = _lib_accounts.stable_read_identity(
|
|
328
|
+
str(root.codex_home / "auth.json"), _reader)
|
|
329
|
+
if result.status == "identified":
|
|
330
|
+
return str(result.value)
|
|
331
|
+
return _lib_accounts.UNATTRIBUTED
|
|
332
|
+
|
|
333
|
+
|
|
304
334
|
def acquire_due_lifecycle_locks(
|
|
305
335
|
app_dir: pathlib.Path,
|
|
306
336
|
roots: list[CodexHookRoot],
|
|
307
337
|
*,
|
|
308
338
|
now: float,
|
|
309
339
|
throttle_seconds: float = CODEX_HOOK_THROTTLE_SECONDS,
|
|
340
|
+
account_resolver: "Callable[[CodexHookRoot], str] | None" = None,
|
|
310
341
|
) -> list[CodexLifecycleLock]:
|
|
311
342
|
base = app_dir / "codex-hook-tick"
|
|
312
343
|
base.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
@@ -314,10 +345,18 @@ def acquire_due_lifecycle_locks(
|
|
|
314
345
|
os.chmod(base, 0o700)
|
|
315
346
|
except OSError:
|
|
316
347
|
pass
|
|
348
|
+
resolve_account = account_resolver or _resolve_codex_hook_account
|
|
317
349
|
held: list[CodexLifecycleLock] = []
|
|
318
350
|
for root in sorted(roots, key=lambda item: item.source_root_key):
|
|
319
351
|
lock_path = base / f"{root.source_root_key}.lock"
|
|
320
|
-
|
|
352
|
+
# Throttle marker keys by (source_root_key, account_key) so a mid-interval
|
|
353
|
+
# account switch bypasses the PRIOR account's throttle (spec §1). The
|
|
354
|
+
# ``unattributed`` sentinel keeps the byte-stable legacy marker name (no
|
|
355
|
+
# account suffix) — single-account / no-auth installs are unchanged.
|
|
356
|
+
import _lib_accounts
|
|
357
|
+
account = resolve_account(root)
|
|
358
|
+
suffix = "" if account == _lib_accounts.UNATTRIBUTED else f".{account}"
|
|
359
|
+
marker_path = base / f"{root.source_root_key}{suffix}.last-success"
|
|
321
360
|
fd = -1
|
|
322
361
|
try:
|
|
323
362
|
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
|
package/bin/_lib_diff_kernel.py
CHANGED
|
@@ -470,7 +470,8 @@ def _humanize_tokens(n: "int | None") -> str:
|
|
|
470
470
|
return f"{sign}{a / 1_000_000_000:.1f}B"
|
|
471
471
|
|
|
472
472
|
|
|
473
|
-
def _diff_iter_claude_entries(window: ParsedWindow, *, skip_sync: bool
|
|
473
|
+
def _diff_iter_claude_entries(window: ParsedWindow, *, skip_sync: bool,
|
|
474
|
+
account_key=None):
|
|
474
475
|
"""Honor ParsedWindow's half-open semantics by trimming end_utc by 1 µs
|
|
475
476
|
before passing into the inclusive-end shared cache helper.
|
|
476
477
|
|
|
@@ -479,10 +480,14 @@ def _diff_iter_claude_entries(window: ParsedWindow, *, skip_sync: bool):
|
|
|
479
480
|
end-of-day semantics for date-only inputs — so we cannot tighten the
|
|
480
481
|
helper's SQL. `ParsedWindow.end_utc` is documented exclusive, so trim
|
|
481
482
|
by one microsecond locally to bridge the convention gap.
|
|
483
|
+
|
|
484
|
+
``account_key`` (#341 --account) scopes the read to one account's stamped
|
|
485
|
+
entries; ``None`` = merged (byte-identical).
|
|
482
486
|
"""
|
|
483
487
|
end_exclusive = window.end_utc - dt.timedelta(microseconds=1)
|
|
484
488
|
return get_claude_session_entries(
|
|
485
|
-
window.start_utc, end_exclusive, skip_sync=skip_sync
|
|
489
|
+
window.start_utc, end_exclusive, skip_sync=skip_sync,
|
|
490
|
+
account_key=account_key,
|
|
486
491
|
)
|
|
487
492
|
|
|
488
493
|
|
|
@@ -490,6 +495,7 @@ def _diff_aggregate_overall(
|
|
|
490
495
|
window: ParsedWindow,
|
|
491
496
|
*,
|
|
492
497
|
skip_sync: bool = False,
|
|
498
|
+
account_key=None,
|
|
493
499
|
) -> MetricBundle:
|
|
494
500
|
"""Sum cost, tokens, and cache stats across all entries in `window`.
|
|
495
501
|
|
|
@@ -500,7 +506,8 @@ def _diff_aggregate_overall(
|
|
|
500
506
|
"""
|
|
501
507
|
cost = 0.0
|
|
502
508
|
ti = to = tcr = tcw = 0
|
|
503
|
-
for e in _diff_iter_claude_entries(window, skip_sync=skip_sync
|
|
509
|
+
for e in _diff_iter_claude_entries(window, skip_sync=skip_sync,
|
|
510
|
+
account_key=account_key):
|
|
504
511
|
if e.model == "<synthetic>":
|
|
505
512
|
continue
|
|
506
513
|
cost += _calculate_entry_cost(
|
|
@@ -532,10 +539,12 @@ def _diff_aggregate_models(
|
|
|
532
539
|
window: ParsedWindow,
|
|
533
540
|
*,
|
|
534
541
|
skip_sync: bool = False,
|
|
542
|
+
account_key=None,
|
|
535
543
|
) -> dict:
|
|
536
544
|
"""Group entries by model id, aggregate to per-model MetricBundle."""
|
|
537
545
|
buckets: dict = {}
|
|
538
|
-
for e in _diff_iter_claude_entries(window, skip_sync=skip_sync
|
|
546
|
+
for e in _diff_iter_claude_entries(window, skip_sync=skip_sync,
|
|
547
|
+
account_key=account_key):
|
|
539
548
|
if e.model == "<synthetic>":
|
|
540
549
|
continue
|
|
541
550
|
b = buckets.setdefault(e.model, {
|
|
@@ -568,12 +577,14 @@ def _diff_aggregate_projects(
|
|
|
568
577
|
window: ParsedWindow,
|
|
569
578
|
*,
|
|
570
579
|
skip_sync: bool = False,
|
|
580
|
+
account_key=None,
|
|
571
581
|
group_mode: str = "git-root",
|
|
572
582
|
) -> dict:
|
|
573
583
|
"""Group entries by ProjectKey.display_key (git-root resolved)."""
|
|
574
584
|
resolver_cache: dict = {}
|
|
575
585
|
buckets: dict = {}
|
|
576
|
-
for e in _diff_iter_claude_entries(window, skip_sync=skip_sync
|
|
586
|
+
for e in _diff_iter_claude_entries(window, skip_sync=skip_sync,
|
|
587
|
+
account_key=account_key):
|
|
577
588
|
if e.model == "<synthetic>":
|
|
578
589
|
continue
|
|
579
590
|
key = _resolve_project_key(e.project_path, group_mode, resolver_cache)
|
|
@@ -607,6 +618,7 @@ def _diff_aggregate_cache(
|
|
|
607
618
|
window: ParsedWindow,
|
|
608
619
|
*,
|
|
609
620
|
skip_sync: bool = False,
|
|
621
|
+
account_key=None,
|
|
610
622
|
) -> dict:
|
|
611
623
|
"""Cache-active-entries scope: only entries that touched the cache.
|
|
612
624
|
|
|
@@ -619,7 +631,8 @@ def _diff_aggregate_cache(
|
|
|
619
631
|
"""
|
|
620
632
|
cost = 0.0
|
|
621
633
|
tcr = tcw = ti = 0
|
|
622
|
-
for e in _diff_iter_claude_entries(window, skip_sync=skip_sync
|
|
634
|
+
for e in _diff_iter_claude_entries(window, skip_sync=skip_sync,
|
|
635
|
+
account_key=account_key):
|
|
623
636
|
if e.model == "<synthetic>":
|
|
624
637
|
continue
|
|
625
638
|
if e.cache_creation_tokens == 0 and e.cache_read_tokens == 0:
|
|
@@ -891,6 +904,7 @@ def _build_diff_result(
|
|
|
891
904
|
sort: str,
|
|
892
905
|
allow_mismatch: bool = False,
|
|
893
906
|
skip_sync: bool = False,
|
|
907
|
+
account_key=None,
|
|
894
908
|
top: "int | None" = None,
|
|
895
909
|
) -> DiffResult:
|
|
896
910
|
"""Top-level diff builder: wire window_a vs window_b through every
|
|
@@ -936,8 +950,8 @@ def _build_diff_result(
|
|
|
936
950
|
raw_totals: "dict[str, tuple[MetricBundle | None, MetricBundle | None]]" = {}
|
|
937
951
|
|
|
938
952
|
if "overall" in sections_requested:
|
|
939
|
-
a_overall_raw = _norm_a(_diff_aggregate_overall(window_a, skip_sync=skip_sync))
|
|
940
|
-
b_overall_raw = _norm_b(_diff_aggregate_overall(window_b, skip_sync=skip_sync))
|
|
953
|
+
a_overall_raw = _norm_a(_diff_aggregate_overall(window_a, skip_sync=skip_sync, account_key=account_key))
|
|
954
|
+
b_overall_raw = _norm_b(_diff_aggregate_overall(window_b, skip_sync=skip_sync, account_key=account_key))
|
|
941
955
|
# Splice in the resolved Used% AFTER normalization — Used % is
|
|
942
956
|
# never per-day-normalized (it's a weekly ceiling ratio).
|
|
943
957
|
a_overall = dataclasses.replace(a_overall_raw, used_pct=used_a)
|
|
@@ -954,9 +968,9 @@ def _build_diff_result(
|
|
|
954
968
|
|
|
955
969
|
if "models" in sections_requested:
|
|
956
970
|
a_map = {k: _norm_a(v) for k, v in
|
|
957
|
-
_diff_aggregate_models(window_a, skip_sync=skip_sync).items()}
|
|
971
|
+
_diff_aggregate_models(window_a, skip_sync=skip_sync, account_key=account_key).items()}
|
|
958
972
|
b_map = {k: _norm_b(v) for k, v in
|
|
959
|
-
_diff_aggregate_models(window_b, skip_sync=skip_sync).items()}
|
|
973
|
+
_diff_aggregate_models(window_b, skip_sync=skip_sync, account_key=account_key).items()}
|
|
960
974
|
sections.append(_diff_build_section(
|
|
961
975
|
"models", "all", a_map, b_map,
|
|
962
976
|
_DIFF_DEFAULT_COLUMNS_MODELS, threshold, sort,
|
|
@@ -969,9 +983,9 @@ def _build_diff_result(
|
|
|
969
983
|
|
|
970
984
|
if "projects" in sections_requested:
|
|
971
985
|
a_map = {k: _norm_a(v) for k, v in
|
|
972
|
-
_diff_aggregate_projects(window_a, skip_sync=skip_sync).items()}
|
|
986
|
+
_diff_aggregate_projects(window_a, skip_sync=skip_sync, account_key=account_key).items()}
|
|
973
987
|
b_map = {k: _norm_b(v) for k, v in
|
|
974
|
-
_diff_aggregate_projects(window_b, skip_sync=skip_sync).items()}
|
|
988
|
+
_diff_aggregate_projects(window_b, skip_sync=skip_sync, account_key=account_key).items()}
|
|
975
989
|
sections.append(_diff_build_section(
|
|
976
990
|
"projects", "all", a_map, b_map,
|
|
977
991
|
_DIFF_DEFAULT_COLUMNS_PROJECTS, threshold, sort,
|
|
@@ -984,9 +998,9 @@ def _build_diff_result(
|
|
|
984
998
|
|
|
985
999
|
if "cache" in sections_requested:
|
|
986
1000
|
a_map = {k: _norm_a(v) for k, v in
|
|
987
|
-
_diff_aggregate_cache(window_a, skip_sync=skip_sync).items()}
|
|
1001
|
+
_diff_aggregate_cache(window_a, skip_sync=skip_sync, account_key=account_key).items()}
|
|
988
1002
|
b_map = {k: _norm_b(v) for k, v in
|
|
989
|
-
_diff_aggregate_cache(window_b, skip_sync=skip_sync).items()}
|
|
1003
|
+
_diff_aggregate_cache(window_b, skip_sync=skip_sync, account_key=account_key).items()}
|
|
990
1004
|
sections.append(_diff_build_section(
|
|
991
1005
|
"cache", "cache-active-entries", a_map, b_map,
|
|
992
1006
|
_DIFF_DEFAULT_COLUMNS_CACHE,
|
package/bin/_lib_doctor.py
CHANGED
|
@@ -272,6 +272,20 @@ class DoctorState:
|
|
|
272
272
|
journal_hw_segment: Optional[str] = None
|
|
273
273
|
journal_cursor_segment: Optional[str] = None
|
|
274
274
|
journal_heal_incidents: Optional[list] = None
|
|
275
|
+
# Multi-account attribution (#341). A dict (None when unavailable) with:
|
|
276
|
+
# * claude_identity_status — "identified" | "stably_absent" | "torn"
|
|
277
|
+
# * claude_email — the active Claude email (or None)
|
|
278
|
+
# * real_account_count — # real accounts across providers (excludes the
|
|
279
|
+
# unattributed sentinel); by_provider — {provider: count}
|
|
280
|
+
# * missing_provider — # registry rows lacking a provider (corrupt)
|
|
281
|
+
# * freshest_last_seen_age_s — age of the newest last_seen_utc (or None)
|
|
282
|
+
# * recent_attributed / recent_unattributed — last-7-day Claude usage
|
|
283
|
+
# snapshot counts stamped to a real account vs the sentinel.
|
|
284
|
+
accounts_state: Optional[dict] = None
|
|
285
|
+
# Issue #344 Task B: read-only repair-owner classification for
|
|
286
|
+
# cache.db.repairing. {exists, live, reason}; None only when gather itself
|
|
287
|
+
# could not inspect the marker. Doctor never reclaims or deletes it.
|
|
288
|
+
cache_repair_marker: Optional[dict] = None
|
|
275
289
|
|
|
276
290
|
|
|
277
291
|
@dataclasses.dataclass(frozen=True)
|
|
@@ -692,6 +706,25 @@ def _check_db_stats_file(s: DoctorState) -> CheckResult:
|
|
|
692
706
|
|
|
693
707
|
|
|
694
708
|
def _check_db_cache_file(s: DoctorState) -> CheckResult:
|
|
709
|
+
marker = s.cache_repair_marker
|
|
710
|
+
if marker and marker.get("exists"):
|
|
711
|
+
if marker.get("live") is True:
|
|
712
|
+
return CheckResult(
|
|
713
|
+
id="db.cache.file",
|
|
714
|
+
title="cache.db",
|
|
715
|
+
severity="warn",
|
|
716
|
+
summary="repair in progress",
|
|
717
|
+
remediation="Wait for the active cache repair to finish.",
|
|
718
|
+
details={"repair_marker": marker},
|
|
719
|
+
)
|
|
720
|
+
return CheckResult(
|
|
721
|
+
id="db.cache.file",
|
|
722
|
+
title="cache.db",
|
|
723
|
+
severity="warn",
|
|
724
|
+
summary="stale repair owner",
|
|
725
|
+
remediation="Run `cctally cache-sync --rebuild`",
|
|
726
|
+
details={"repair_marker": marker},
|
|
727
|
+
)
|
|
695
728
|
return _db_file_check("db.cache.file", "cache.db", s.cache_db_status,
|
|
696
729
|
"Run `cctally cache-sync --rebuild`")
|
|
697
730
|
|
|
@@ -2055,6 +2088,127 @@ def _check_journal_auto_heal(s: DoctorState) -> CheckResult:
|
|
|
2055
2088
|
# `_evaluate_one` uses this id — not the function name — so the synthesized
|
|
2056
2089
|
# FAIL CheckResult retains the contract id and fingerprint stays stable across
|
|
2057
2090
|
# success-vs-raise transitions.
|
|
2091
|
+
def _check_accounts_identity(s: DoctorState) -> CheckResult:
|
|
2092
|
+
"""Is the active-account identity source (`~/.claude.json`) readable? A torn
|
|
2093
|
+
mid-write read WARNs (transient); identified / stably-absent are OK (a legacy
|
|
2094
|
+
/ non-Claude-Code install has no file — that is a resolved state, not a
|
|
2095
|
+
fault)."""
|
|
2096
|
+
st = s.accounts_state or {}
|
|
2097
|
+
status = st.get("claude_identity_status")
|
|
2098
|
+
if status == "torn":
|
|
2099
|
+
return CheckResult(
|
|
2100
|
+
id="accounts.identity", title="Account identity source",
|
|
2101
|
+
severity="warn",
|
|
2102
|
+
summary="~/.claude.json is being rewritten (torn read); attribution deferred",
|
|
2103
|
+
remediation="Transient — re-run once Claude Code finishes writing",
|
|
2104
|
+
details={"claude_identity_status": status},
|
|
2105
|
+
)
|
|
2106
|
+
if status == "identified":
|
|
2107
|
+
summary = f"active Claude account readable ({st.get('claude_email') or 'no email'})"
|
|
2108
|
+
else:
|
|
2109
|
+
summary = "no active Claude identity on disk (single-account / api-key mode)"
|
|
2110
|
+
return CheckResult(
|
|
2111
|
+
id="accounts.identity", title="Account identity source",
|
|
2112
|
+
severity="ok", summary=summary, remediation=None,
|
|
2113
|
+
details={"claude_identity_status": status},
|
|
2114
|
+
)
|
|
2115
|
+
|
|
2116
|
+
|
|
2117
|
+
def _check_accounts_registry(s: DoctorState) -> CheckResult:
|
|
2118
|
+
"""Is the accounts registry consistent? A row lacking a provider is corrupt
|
|
2119
|
+
(WARN → rebuild)."""
|
|
2120
|
+
st = s.accounts_state
|
|
2121
|
+
if st is None:
|
|
2122
|
+
return CheckResult(
|
|
2123
|
+
id="accounts.registry", title="Account registry",
|
|
2124
|
+
severity="ok", summary="no registry (fresh install)",
|
|
2125
|
+
remediation=None, details={},
|
|
2126
|
+
)
|
|
2127
|
+
missing = int(st.get("missing_provider") or 0)
|
|
2128
|
+
count = int(st.get("real_account_count") or 0)
|
|
2129
|
+
by_provider = st.get("by_provider") or {}
|
|
2130
|
+
details = {"real_account_count": count, "by_provider": by_provider,
|
|
2131
|
+
"missing_provider": missing}
|
|
2132
|
+
if missing:
|
|
2133
|
+
return CheckResult(
|
|
2134
|
+
id="accounts.registry", title="Account registry",
|
|
2135
|
+
severity="warn",
|
|
2136
|
+
summary=f"{missing} account row(s) missing a provider",
|
|
2137
|
+
remediation="Run `cctally db rebuild --db stats` to re-derive the registry",
|
|
2138
|
+
details=details,
|
|
2139
|
+
)
|
|
2140
|
+
return CheckResult(
|
|
2141
|
+
id="accounts.registry", title="Account registry",
|
|
2142
|
+
severity="ok",
|
|
2143
|
+
summary=(f"{count} account(s) known" if count
|
|
2144
|
+
else "no accounts observed yet"),
|
|
2145
|
+
remediation=None, details=details,
|
|
2146
|
+
)
|
|
2147
|
+
|
|
2148
|
+
|
|
2149
|
+
def _check_accounts_freshness(s: DoctorState) -> CheckResult:
|
|
2150
|
+
"""Attribution freshness — how recently any account was last seen. Purely
|
|
2151
|
+
informational (always OK); a stale value just reports the age."""
|
|
2152
|
+
st = s.accounts_state or {}
|
|
2153
|
+
age = st.get("freshest_last_seen_age_s")
|
|
2154
|
+
if age is None:
|
|
2155
|
+
summary = "no attribution activity recorded"
|
|
2156
|
+
else:
|
|
2157
|
+
a = int(age)
|
|
2158
|
+
if a >= 86400:
|
|
2159
|
+
human = f"{a // 86400}d"
|
|
2160
|
+
elif a >= 3600:
|
|
2161
|
+
human = f"{a // 3600}h"
|
|
2162
|
+
elif a >= 60:
|
|
2163
|
+
human = f"{a // 60}m"
|
|
2164
|
+
else:
|
|
2165
|
+
human = f"{a}s"
|
|
2166
|
+
summary = f"most recent account activity {human} ago"
|
|
2167
|
+
return CheckResult(
|
|
2168
|
+
id="accounts.freshness", title="Account attribution freshness",
|
|
2169
|
+
severity="ok", summary=summary, remediation=None,
|
|
2170
|
+
details={"freshest_last_seen_age_s": age},
|
|
2171
|
+
)
|
|
2172
|
+
|
|
2173
|
+
|
|
2174
|
+
def _check_accounts_attribution(s: DoctorState) -> CheckResult:
|
|
2175
|
+
"""WARN when Claude usage is flowing but landing in `unattributed` despite a
|
|
2176
|
+
resolved active account (the stamping pipeline is broken), or while the
|
|
2177
|
+
identity is torn. This is the runtime complement to the structural
|
|
2178
|
+
writer-audit backstop for the account_key DEFAULT."""
|
|
2179
|
+
st = s.accounts_state or {}
|
|
2180
|
+
recent_unattr = int(st.get("recent_unattributed") or 0)
|
|
2181
|
+
recent_attr = int(st.get("recent_attributed") or 0)
|
|
2182
|
+
status = st.get("claude_identity_status")
|
|
2183
|
+
details = {"recent_attributed": recent_attr,
|
|
2184
|
+
"recent_unattributed": recent_unattr,
|
|
2185
|
+
"claude_identity_status": status}
|
|
2186
|
+
# A real active account, recent usage, yet all of it unattributed -> the
|
|
2187
|
+
# write-path stamp is not landing.
|
|
2188
|
+
if status == "identified" and recent_unattr > 0 and recent_attr == 0:
|
|
2189
|
+
return CheckResult(
|
|
2190
|
+
id="accounts.attribution", title="Account attribution",
|
|
2191
|
+
severity="warn",
|
|
2192
|
+
summary="recent Claude usage is landing in 'unattributed' despite an active account",
|
|
2193
|
+
remediation="Run `cctally db rebuild --db stats`; if it persists, file an issue",
|
|
2194
|
+
details=details,
|
|
2195
|
+
)
|
|
2196
|
+
if status == "torn" and recent_unattr > 0:
|
|
2197
|
+
return CheckResult(
|
|
2198
|
+
id="accounts.attribution", title="Account attribution",
|
|
2199
|
+
severity="warn",
|
|
2200
|
+
summary="recent usage attributed to 'unattributed' while identity is torn",
|
|
2201
|
+
remediation="Transient — the next clean read re-attributes new usage",
|
|
2202
|
+
details=details,
|
|
2203
|
+
)
|
|
2204
|
+
return CheckResult(
|
|
2205
|
+
id="accounts.attribution", title="Account attribution",
|
|
2206
|
+
severity="ok",
|
|
2207
|
+
summary="usage attribution healthy",
|
|
2208
|
+
remediation=None, details=details,
|
|
2209
|
+
)
|
|
2210
|
+
|
|
2211
|
+
|
|
2058
2212
|
_CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...] = (
|
|
2059
2213
|
("install", "Install", (
|
|
2060
2214
|
("install.mode", "_check_install_dev_mode"),
|
|
@@ -2106,6 +2260,12 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
|
|
|
2106
2260
|
("data.conversation_sessions_rollup",
|
|
2107
2261
|
"_check_data_conversation_sessions_rollup"),
|
|
2108
2262
|
)),
|
|
2263
|
+
("accounts", "Accounts", (
|
|
2264
|
+
("accounts.identity", "_check_accounts_identity"),
|
|
2265
|
+
("accounts.registry", "_check_accounts_registry"),
|
|
2266
|
+
("accounts.freshness", "_check_accounts_freshness"),
|
|
2267
|
+
("accounts.attribution", "_check_accounts_attribution"),
|
|
2268
|
+
)),
|
|
2109
2269
|
("pricing", "Pricing", (
|
|
2110
2270
|
("pricing.coverage", "_check_pricing_coverage"),
|
|
2111
2271
|
)),
|