cctally 1.80.4 → 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 +24 -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 +434 -48
  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-BzWujBS3.js +0 -92
  52. package/dashboard/static/assets/index-Cc2ykqF_.css +0 -1
@@ -220,6 +220,13 @@ def cmd_diff(args: argparse.Namespace) -> int:
220
220
  if args.min_delta_pct is not None:
221
221
  threshold = dataclasses.replace(threshold, min_delta_pct=args.min_delta_pct)
222
222
 
223
+ # #341 --account: resolve the render filter (provider=claude; fail closed
224
+ # with exit 3 when the entry cache is unavailable). None = merged.
225
+ acct_key, acct_exit = c.resolve_account_filter(
226
+ args, "claude", needs_cache=True)
227
+ if acct_exit is not None:
228
+ return acct_exit
229
+
223
230
  try:
224
231
  result = dk._build_diff_result(
225
232
  window_a, window_b,
@@ -229,6 +236,7 @@ def cmd_diff(args: argparse.Namespace) -> int:
229
236
  allow_mismatch=bool(args.allow_mismatch),
230
237
  skip_sync=not bool(args.sync),
231
238
  top=args.top,
239
+ account_key=acct_key,
232
240
  )
233
241
  except dk.WindowMismatchError as exc:
234
242
  print(f"diff: {exc}", file=sys.stderr)
@@ -250,6 +258,7 @@ def cmd_diff(args: argparse.Namespace) -> int:
250
258
 
251
259
  if args.emit_json:
252
260
  payload = dk._diff_to_json_payload(result, options=options)
261
+ payload.update(c.account_json_fields(acct_key)) # #341 R8 decoration
253
262
  sink = getattr(args, "_source_result_sink", None)
254
263
  if sink is not None:
255
264
  sink(payload)
@@ -183,11 +183,159 @@ def _codex_lifecycle_activity_24h(
183
183
  return records
184
184
 
185
185
 
186
+ def _gather_accounts_state(now_utc: "dt.datetime") -> dict:
187
+ """Best-effort account-attribution state for the doctor `accounts.*` legs
188
+ (#341). Never raises: identity + registry reads are read-only and each guard
189
+ swallows OperationalError so a legacy / pre-account stats.db degrades to the
190
+ empty (all-OK) shape."""
191
+ state: dict = {
192
+ "claude_identity_status": None, "claude_email": None,
193
+ "real_account_count": 0, "by_provider": {}, "missing_provider": 0,
194
+ "freshest_last_seen_age_s": None,
195
+ "recent_attributed": 0, "recent_unattributed": 0,
196
+ }
197
+ try:
198
+ ident = _cctally_core._resolve_active_claude_identity()
199
+ state["claude_identity_status"] = ident.get("status")
200
+ state["claude_email"] = ident.get("email")
201
+ except Exception:
202
+ pass
203
+ if not _cctally_core.DB_PATH.exists():
204
+ return state
205
+ try:
206
+ conn = sqlite3.connect(f"file:{_cctally_core.DB_PATH}?mode=ro", uri=True)
207
+ except sqlite3.Error:
208
+ return state
209
+ try:
210
+ try:
211
+ rows = conn.execute(
212
+ "SELECT provider, account_key, last_seen_utc FROM accounts"
213
+ ).fetchall()
214
+ except sqlite3.DatabaseError:
215
+ # OperationalError (table missing) OR a corrupt / non-DB file
216
+ # ("file is not a database") -> degrade to the empty (all-OK) shape.
217
+ rows = []
218
+ by_provider: dict = {}
219
+ missing = 0
220
+ freshest = None
221
+ for provider, account_key, last_seen in rows:
222
+ if not provider:
223
+ missing += 1
224
+ if account_key and account_key != "unattributed":
225
+ p = provider or "?"
226
+ by_provider[p] = by_provider.get(p, 0) + 1
227
+ if last_seen:
228
+ try:
229
+ ls = parse_iso_datetime(
230
+ last_seen, "accounts.last_seen_utc"
231
+ ).astimezone(dt.timezone.utc)
232
+ if freshest is None or ls > freshest:
233
+ freshest = ls
234
+ except Exception:
235
+ pass
236
+ state["by_provider"] = by_provider
237
+ state["real_account_count"] = sum(by_provider.values())
238
+ state["missing_provider"] = missing
239
+ if freshest is not None:
240
+ state["freshest_last_seen_age_s"] = max(
241
+ 0, int((now_utc - freshest).total_seconds()))
242
+ cutoff = (now_utc - dt.timedelta(days=7)).astimezone(
243
+ dt.timezone.utc).isoformat()
244
+ try:
245
+ for account_key, cnt in conn.execute(
246
+ "SELECT account_key, COUNT(*) FROM weekly_usage_snapshots "
247
+ "WHERE captured_at_utc >= ? GROUP BY account_key", (cutoff,)
248
+ ).fetchall():
249
+ if account_key and account_key != "unattributed":
250
+ state["recent_attributed"] += int(cnt)
251
+ else:
252
+ state["recent_unattributed"] += int(cnt)
253
+ except sqlite3.DatabaseError:
254
+ pass
255
+ finally:
256
+ conn.close()
257
+ return state
258
+
259
+
186
260
  def doctor_gather_state(
187
261
  *,
188
262
  now_utc: "dt.datetime | None" = None,
189
263
  runtime_bind: "str | None" = None,
190
264
  deep: bool = False,
265
+ ):
266
+ """Gather doctor state while excluding cache-family replacement.
267
+
268
+ Doctor is read-only, so it never creates the maintenance lock. When the
269
+ lock already exists it holds maintenance-shared from the marker check
270
+ through every cache probe. A live/stale marker or a pending quarantine
271
+ suppresses all raw SQLite cache opens; when a cache exists but its lock is
272
+ absent, probes also degrade rather than racing a newly starting repair.
273
+ """
274
+ cache_lock = None
275
+ cache_probe_allowed = not _cctally_core.CACHE_DB_PATH.exists()
276
+ cache_repair_marker = {
277
+ "exists": False,
278
+ "live": None,
279
+ "reason": None,
280
+ }
281
+ try:
282
+ lock_path = _cctally_core.CACHE_LOCK_MAINTENANCE_PATH
283
+ if lock_path.exists():
284
+ cache_lock = open(lock_path, "r")
285
+ fcntl.flock(cache_lock, fcntl.LOCK_SH)
286
+ cache_probe_allowed = True
287
+
288
+ c = _cctally()
289
+ db_mod = c._load_sibling("_cctally_db")
290
+ repair_marker = db_mod._repair_marker_path(
291
+ _cctally_core.CACHE_DB_PATH
292
+ )
293
+ pending = db_mod._quarantine_pending_path(
294
+ _cctally_core.CACHE_DB_PATH
295
+ )
296
+ if repair_marker.exists():
297
+ live, reason = db_mod._repair_marker_is_live(repair_marker)
298
+ cache_repair_marker = {
299
+ "exists": True,
300
+ "live": live,
301
+ "reason": reason,
302
+ }
303
+ cache_probe_allowed = False
304
+ elif pending.exists():
305
+ cache_repair_marker = {
306
+ "exists": True,
307
+ "live": False,
308
+ "reason": "interrupted quarantine is pending",
309
+ }
310
+ cache_probe_allowed = False
311
+
312
+ if not cache_probe_allowed and cache_lock is not None:
313
+ fcntl.flock(cache_lock, fcntl.LOCK_UN)
314
+ cache_lock.close()
315
+ cache_lock = None
316
+
317
+ return _doctor_gather_state_impl(
318
+ now_utc=now_utc,
319
+ runtime_bind=runtime_bind,
320
+ deep=deep,
321
+ _cache_probe_allowed=cache_probe_allowed,
322
+ _cache_repair_marker=cache_repair_marker,
323
+ )
324
+ finally:
325
+ if cache_lock is not None:
326
+ try:
327
+ fcntl.flock(cache_lock, fcntl.LOCK_UN)
328
+ finally:
329
+ cache_lock.close()
330
+
331
+
332
+ def _doctor_gather_state_impl(
333
+ *,
334
+ now_utc: "dt.datetime | None" = None,
335
+ runtime_bind: "str | None" = None,
336
+ deep: bool = False,
337
+ _cache_probe_allowed: bool,
338
+ _cache_repair_marker: dict,
191
339
  ):
192
340
  """I/O chokepoint for `cctally doctor` (spec §7.2).
193
341
 
@@ -313,14 +461,32 @@ def doctor_gather_state(
313
461
  # uv==1000 as HEALTHY (not a #145 version-ahead FAIL). registry_size stays the
314
462
  # frozen legacy head (13) and serves as the legacy-range boundary.
315
463
  stats_db_status["epoch"] = _cctally_core.STATS_INDEX_EPOCH
316
- try:
317
- cache_db_status = c._db_status_for(_cctally_core.CACHE_DB_PATH, c._CACHE_MIGRATIONS, "cache.db")
318
- if not _cctally_core.CACHE_DB_PATH.exists():
319
- cache_db_status["_file_exists"] = False
320
- except sqlite3.Error as exc:
321
- cache_db_status = {"path": str(_cctally_core.CACHE_DB_PATH), "user_version": 0,
322
- "registry_size": len(c._CACHE_MIGRATIONS),
323
- "migrations": [], "_open_error": str(exc)}
464
+ if _cache_probe_allowed:
465
+ try:
466
+ cache_db_status = c._db_status_for(
467
+ _cctally_core.CACHE_DB_PATH,
468
+ c._CACHE_MIGRATIONS,
469
+ "cache.db",
470
+ )
471
+ if not _cctally_core.CACHE_DB_PATH.exists():
472
+ cache_db_status["_file_exists"] = False
473
+ except sqlite3.Error as exc:
474
+ cache_db_status = {
475
+ "path": str(_cctally_core.CACHE_DB_PATH),
476
+ "user_version": 0,
477
+ "registry_size": len(c._CACHE_MIGRATIONS),
478
+ "migrations": [],
479
+ "_open_error": str(exc),
480
+ }
481
+ else:
482
+ cache_db_status = {
483
+ "path": str(_cctally_core.CACHE_DB_PATH),
484
+ "user_version": 0,
485
+ "registry_size": len(c._CACHE_MIGRATIONS),
486
+ "migrations": [],
487
+ "_open_error": "cache maintenance excludes read probes",
488
+ }
489
+ cache_repair_marker = _cache_repair_marker
324
490
 
325
491
  # ── Data freshness ───────────────────────────────────────────────
326
492
  latest_snapshot_at = None
@@ -427,7 +593,7 @@ def doctor_gather_state(
427
593
  cache_db_page_count = None
428
594
  cache_db_freelist_count = None
429
595
  try:
430
- if _cctally_core.CACHE_DB_PATH.exists():
596
+ if _cache_probe_allowed and _cctally_core.CACHE_DB_PATH.exists():
431
597
  conn = sqlite3.connect(str(_cctally_core.CACHE_DB_PATH))
432
598
  try:
433
599
  try:
@@ -562,7 +728,7 @@ def doctor_gather_state(
562
728
  codex_project_metadata_health = None
563
729
  codex_project_metadata_error = None
564
730
  try:
565
- if _cctally_core.CACHE_DB_PATH.exists():
731
+ if _cache_probe_allowed and _cctally_core.CACHE_DB_PATH.exists():
566
732
  conn = sqlite3.connect(str(_cctally_core.CACHE_DB_PATH))
567
733
  try:
568
734
  row = conn.execute(
@@ -619,7 +785,11 @@ def doctor_gather_state(
619
785
  # inspector supplies the exact owned-hook state without exposing paths.
620
786
  codex_quota_windows: list[dict] = []
621
787
  try:
622
- observations = c._cctally_quota.load_codex_quota_observations()
788
+ observations = (
789
+ c._cctally_quota.load_codex_quota_observations()
790
+ if _cache_probe_allowed
791
+ else ()
792
+ )
623
793
  by_identity: dict[object, list] = {}
624
794
  for observation in observations:
625
795
  by_identity.setdefault(observation.identity, []).append(observation)
@@ -672,7 +842,7 @@ def doctor_gather_state(
672
842
  # ── Parse health (#279 S2 F5a) ───────────────────────────────────
673
843
  parse_health_claude = parse_health_codex = None
674
844
  try:
675
- if _cctally_core.CACHE_DB_PATH.exists():
845
+ if _cache_probe_allowed and _cctally_core.CACHE_DB_PATH.exists():
676
846
  conn = sqlite3.connect(str(_cctally_core.CACHE_DB_PATH))
677
847
  try:
678
848
  for _key in ("parse_health_claude", "parse_health_codex"):
@@ -702,7 +872,10 @@ def doctor_gather_state(
702
872
  ("cache", _cctally_core.CACHE_DB_PATH)):
703
873
  _result = None
704
874
  try:
705
- if _path.exists():
875
+ if (
876
+ _path.exists()
877
+ and (_label != "cache" or _cache_probe_allowed)
878
+ ):
706
879
  _conn = sqlite3.connect(str(_path))
707
880
  try:
708
881
  _row = _conn.execute(
@@ -871,19 +1044,18 @@ def doctor_gather_state(
871
1044
  # the rest of the report is unaffected — same posture as the cache reads
872
1045
  # above. `_pricing_observed_models` honors the no-mutation contract.
873
1046
  pricing_coverage = None
874
- try:
875
- observed = c._pricing_observed_models(now_utc)
876
- # Detection-only: pass warn=False so finding an unpriced model here does
877
- # NOT fire the cost-engine's `[cost] unknown model` stderr warning (this
878
- # is a read-only diagnostic, and the warning would also poison the
879
- # dedup set, suppressing a later genuine cost-path warning).
880
- pricing_coverage = c.classify_coverage(
881
- observed,
882
- lambda m: c._resolve_model_pricing(m, warn=False),
883
- c._is_codex_fallback,
884
- )
885
- except Exception:
886
- pricing_coverage = None
1047
+ if _cache_probe_allowed:
1048
+ try:
1049
+ observed = c._pricing_observed_models(now_utc)
1050
+ # Detection-only: pass warn=False so finding an unpriced model here
1051
+ # does NOT fire the cost-engine's unknown-model warning.
1052
+ pricing_coverage = c.classify_coverage(
1053
+ observed,
1054
+ lambda m: c._resolve_model_pricing(m, warn=False),
1055
+ c._is_codex_fallback,
1056
+ )
1057
+ except Exception:
1058
+ pricing_coverage = None
887
1059
 
888
1060
  # ── Meta ─────────────────────────────────────────────────────────
889
1061
  # ── Journal (DB journal redesign §9) ─────────────────────────────
@@ -1100,6 +1272,9 @@ def doctor_gather_state(
1100
1272
  journal_hw_segment=journal_hw_segment,
1101
1273
  journal_cursor_segment=journal_cursor_segment,
1102
1274
  journal_heal_incidents=journal_heal_incidents,
1275
+ # Multi-account attribution legs (#341).
1276
+ accounts_state=_gather_accounts_state(now_utc),
1277
+ cache_repair_marker=cache_repair_marker,
1103
1278
  )
1104
1279
 
1105
1280