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.
- package/CHANGELOG.md +68 -0
- package/bin/_cctally_alerts.py +54 -2
- package/bin/_cctally_cache.py +754 -109
- package/bin/_cctally_cache_report.py +41 -17
- package/bin/_cctally_codex.py +109 -4
- package/bin/_cctally_config.py +368 -2
- package/bin/_cctally_core.py +187 -15
- package/bin/_cctally_dashboard.py +679 -5
- package/bin/_cctally_dashboard_conversation.py +9 -4
- package/bin/_cctally_dashboard_envelope.py +110 -1
- package/bin/_cctally_dashboard_share.py +557 -79
- package/bin/_cctally_db.py +366 -17
- package/bin/_cctally_diff.py +49 -16
- package/bin/_cctally_doctor.py +131 -0
- package/bin/_cctally_forecast.py +12 -4
- package/bin/_cctally_parser.py +321 -92
- package/bin/_cctally_project.py +24 -8
- package/bin/_cctally_quota.py +1381 -0
- package/bin/_cctally_record.py +319 -14
- package/bin/_cctally_refresh.py +105 -3
- package/bin/_cctally_reporting.py +7 -1
- package/bin/_cctally_setup.py +343 -113
- package/bin/_cctally_statusline.py +228 -0
- package/bin/_cctally_tui.py +787 -7
- package/bin/_lib_alert_axes.py +11 -0
- package/bin/_lib_codex_hooks.py +367 -0
- package/bin/_lib_conversation_query.py +156 -67
- package/bin/_lib_doctor.py +238 -0
- package/bin/_lib_jsonl.py +529 -162
- package/bin/_lib_quota.py +566 -0
- package/bin/_lib_share.py +152 -34
- package/bin/_lib_snapshot_cache.py +26 -0
- package/bin/_lib_source_identity.py +74 -0
- package/bin/cctally +325 -10
- package/dashboard/static/assets/index-CBbErI-P.js +80 -0
- package/dashboard/static/assets/index-kDDVOLa_.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +5 -1
- package/dashboard/static/assets/index-BybNp_Di.js +0 -80
- package/dashboard/static/assets/index-DgAmLK65.css +0 -1
package/bin/_cctally_doctor.py
CHANGED
|
@@ -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
|
|
package/bin/_cctally_forecast.py
CHANGED
|
@@ -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
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
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
|
-
|
|
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:
|