cctally 1.67.1 → 1.69.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 (40) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/bin/_cctally_alerts.py +54 -2
  3. package/bin/_cctally_cache.py +754 -109
  4. package/bin/_cctally_cache_report.py +41 -17
  5. package/bin/_cctally_codex.py +109 -4
  6. package/bin/_cctally_config.py +368 -2
  7. package/bin/_cctally_core.py +187 -15
  8. package/bin/_cctally_dashboard.py +679 -5
  9. package/bin/_cctally_dashboard_conversation.py +9 -4
  10. package/bin/_cctally_dashboard_envelope.py +110 -1
  11. package/bin/_cctally_dashboard_share.py +557 -79
  12. package/bin/_cctally_db.py +366 -17
  13. package/bin/_cctally_diff.py +49 -16
  14. package/bin/_cctally_doctor.py +131 -0
  15. package/bin/_cctally_forecast.py +12 -4
  16. package/bin/_cctally_parser.py +321 -92
  17. package/bin/_cctally_project.py +24 -8
  18. package/bin/_cctally_quota.py +1381 -0
  19. package/bin/_cctally_record.py +319 -14
  20. package/bin/_cctally_refresh.py +105 -3
  21. package/bin/_cctally_reporting.py +7 -1
  22. package/bin/_cctally_setup.py +343 -113
  23. package/bin/_cctally_statusline.py +228 -0
  24. package/bin/_cctally_tui.py +787 -7
  25. package/bin/_lib_alert_axes.py +11 -0
  26. package/bin/_lib_codex_hooks.py +367 -0
  27. package/bin/_lib_conversation_query.py +156 -67
  28. package/bin/_lib_doctor.py +238 -0
  29. package/bin/_lib_jsonl.py +529 -162
  30. package/bin/_lib_quota.py +566 -0
  31. package/bin/_lib_share.py +152 -34
  32. package/bin/_lib_snapshot_cache.py +26 -0
  33. package/bin/_lib_source_identity.py +74 -0
  34. package/bin/cctally +325 -10
  35. package/dashboard/static/assets/index-CBbErI-P.js +80 -0
  36. package/dashboard/static/assets/index-kDDVOLa_.css +1 -0
  37. package/dashboard/static/dashboard.html +2 -2
  38. package/package.json +5 -1
  39. package/dashboard/static/assets/index-BybNp_Di.js +0 -80
  40. package/dashboard/static/assets/index-DgAmLK65.css +0 -1
@@ -42,6 +42,65 @@ def _cctally():
42
42
  return sys.modules["cctally"]
43
43
 
44
44
 
45
+ def _codex_lifecycle_activity_24h(
46
+ *, root_keys: set[str], now_utc: dt.datetime,
47
+ ) -> dict[str, dict]:
48
+ """Read root-qualified Codex lifecycle outcomes from bounded local logs.
49
+
50
+ The parser intentionally accepts only timestamped token records and retains
51
+ aggregate lifecycle counters. It never loads session, prompt, or response
52
+ content into doctor state.
53
+ """
54
+ cutoff = now_utc - dt.timedelta(hours=24)
55
+ records: dict[str, dict] = {}
56
+ for path in (
57
+ _cctally_core.HOOK_TICK_LOG_ROTATED_PATH,
58
+ _cctally_core.HOOK_TICK_LOG_PATH,
59
+ ):
60
+ try:
61
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
62
+ except OSError:
63
+ continue
64
+ for line in lines:
65
+ tokens = line.split()
66
+ if not tokens:
67
+ continue
68
+ try:
69
+ captured_at = parse_iso_datetime(tokens[0], "codex lifecycle log timestamp")
70
+ captured_at = captured_at.astimezone(dt.timezone.utc)
71
+ except (IndexError, ValueError, TypeError):
72
+ continue
73
+ if captured_at > now_utc:
74
+ continue
75
+ fields = {
76
+ token.split("=", 1)[0]: token.split("=", 1)[1]
77
+ for token in tokens[1:] if "=" in token
78
+ }
79
+ if fields.get("provider") != "codex":
80
+ continue
81
+ key = fields.get("source_root_key")
82
+ if key not in root_keys:
83
+ continue
84
+ outcome = fields.get("result")
85
+ if outcome not in {"success", "error"}:
86
+ continue
87
+ row = records.setdefault(key, {
88
+ "last_tick_at": None,
89
+ "success_count_24h": 0,
90
+ "error_count_24h": 0,
91
+ })
92
+ if outcome == "success":
93
+ prior = row["last_tick_at"]
94
+ if prior is None or captured_at > prior:
95
+ row["last_tick_at"] = captured_at
96
+ if captured_at >= cutoff:
97
+ if outcome == "success":
98
+ row["success_count_24h"] += 1
99
+ else:
100
+ row["error_count_24h"] += 1
101
+ return records
102
+
103
+
45
104
  def doctor_gather_state(
46
105
  *,
47
106
  now_utc: "dt.datetime | None" = None,
@@ -398,6 +457,62 @@ def doctor_gather_state(
398
457
  except Exception:
399
458
  pass
400
459
 
460
+ # ── Codex quota lifecycle (#294 S2) ──────────────────────────────
461
+ # All three probes are read-only and root-qualified. The physical cache
462
+ # adapter preserves S1's per-window degradation, while setup's existing
463
+ # inspector supplies the exact owned-hook state without exposing paths.
464
+ codex_quota_windows: list[dict] = []
465
+ try:
466
+ observations = c._cctally_quota.load_codex_quota_observations()
467
+ by_identity: dict[object, list] = {}
468
+ for observation in observations:
469
+ by_identity.setdefault(observation.identity, []).append(observation)
470
+ for identity in sorted(
471
+ by_identity,
472
+ key=lambda item: (
473
+ item.source, item.source_root_key, item.logical_limit_key,
474
+ item.observed_slot, item.window_minutes,
475
+ ),
476
+ ):
477
+ freshness = c.quota_freshness(by_identity[identity], now_utc)
478
+ codex_quota_windows.append({
479
+ "identity": {
480
+ "source": identity.source,
481
+ "source_root_key": identity.source_root_key,
482
+ "logical_limit_key": identity.logical_limit_key,
483
+ "observed_slot": identity.observed_slot,
484
+ "window_minutes": identity.window_minutes,
485
+ },
486
+ "latest_capture_at": freshness.captured_at,
487
+ "freshness_state": freshness.state,
488
+ "age_seconds": freshness.age_seconds,
489
+ "stale_after_seconds": freshness.stale_after_seconds,
490
+ })
491
+ except Exception:
492
+ codex_quota_windows = []
493
+
494
+ codex_hook_roots: list[dict] = []
495
+ try:
496
+ codex_binary = str(c._setup_resolve_hook_target(repo_root))
497
+ hook_rows = [
498
+ c._cctally_setup._codex_hook_row(root, codex_binary)
499
+ for root in c._setup_codex_hook_roots()
500
+ ]
501
+ codex_hook_roots = [
502
+ {"source_root_key": row["source_root_key"], "state": row["state"]}
503
+ for row in sorted(hook_rows, key=lambda row: row["source_root_key"])
504
+ ]
505
+ except Exception:
506
+ codex_hook_roots = []
507
+
508
+ try:
509
+ codex_lifecycle_activity_24h = _codex_lifecycle_activity_24h(
510
+ root_keys={row["source_root_key"] for row in codex_hook_roots},
511
+ now_utc=now_utc,
512
+ )
513
+ except Exception:
514
+ codex_lifecycle_activity_24h = {}
515
+
401
516
  # ── Parse health (#279 S2 F5a) ───────────────────────────────────
402
517
  parse_health_claude = parse_health_codex = None
403
518
  try:
@@ -473,6 +588,17 @@ def doctor_gather_state(
473
588
  except Exception:
474
589
  locks_held = None
475
590
 
591
+ # ── cache.db WAL size (#297) — read-only backstop ────────────────
592
+ # Gathered OUTSIDE the deep/quick_check branch (above) so the WAL-size
593
+ # check runs in both shallow and deep gather modes. Best-effort getsize;
594
+ # None on OSError/race (doctor never blocks or raises), 0 when absent.
595
+ cache_db_wal_bytes: "int | None"
596
+ try:
597
+ _wal = pathlib.Path(f"{_cctally_core.CACHE_DB_PATH}-wal")
598
+ cache_db_wal_bytes = _wal.stat().st_size if _wal.exists() else 0
599
+ except OSError:
600
+ cache_db_wal_bytes = None
601
+
476
602
  # ── Safety ───────────────────────────────────────────────────────
477
603
  # `dashboard.bind` is read via the same chokepoint that powers
478
604
  # `cctally config get dashboard.bind` — `_config_known_value`
@@ -646,6 +772,11 @@ def doctor_gather_state(
646
772
  stats_db_quick_check=stats_db_quick_check,
647
773
  cache_db_quick_check=cache_db_quick_check,
648
774
  locks_held=locks_held,
775
+ # #297: cache.db WAL size backstop (gathered outside the deep branch).
776
+ cache_db_wal_bytes=cache_db_wal_bytes,
777
+ codex_quota_windows=codex_quota_windows,
778
+ codex_hook_roots=codex_hook_roots,
779
+ codex_lifecycle_activity_24h=codex_lifecycle_activity_24h,
649
780
  )
650
781
 
651
782
 
@@ -1011,9 +1011,12 @@ def cmd_report(args: argparse.Namespace) -> int:
1011
1011
  c._share_render_and_emit(snap, args)
1012
1012
  return 0
1013
1013
  if args.json:
1014
- print(json.dumps(
1015
- c.stamp_schema_version({"current": None, "trend": []}),
1016
- indent=2))
1014
+ payload = c.stamp_schema_version({"current": None, "trend": []})
1015
+ sink = getattr(args, "_source_result_sink", None)
1016
+ if sink is not None:
1017
+ sink(payload)
1018
+ else:
1019
+ print(json.dumps(payload, indent=2))
1017
1020
  else:
1018
1021
  print("No data yet. Add record-usage to your status line script (see record-usage --help).")
1019
1022
  return 0
@@ -1206,7 +1209,12 @@ def cmd_report(args: argparse.Namespace) -> int:
1206
1209
  return 0
1207
1210
 
1208
1211
  if args.json:
1209
- print(json.dumps(c.stamp_schema_version(output), indent=2))
1212
+ payload = c.stamp_schema_version(output)
1213
+ sink = getattr(args, "_source_result_sink", None)
1214
+ if sink is not None:
1215
+ sink(payload)
1216
+ else:
1217
+ print(json.dumps(payload, indent=2))
1210
1218
  return 0
1211
1219
 
1212
1220
  if current_row is not None: