cctally 1.79.2 → 1.80.1
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 +25 -0
- package/bin/_cctally_cache.py +148 -61
- package/bin/_cctally_config.py +23 -6
- package/bin/_cctally_core.py +342 -88
- package/bin/_cctally_dashboard.py +13 -7
- package/bin/_cctally_db.py +361 -36
- package/bin/_cctally_doctor.py +156 -0
- package/bin/_cctally_five_hour.py +19 -7
- package/bin/_cctally_forecast.py +43 -18
- package/bin/_cctally_journal.py +2148 -0
- package/bin/_cctally_milestones.py +116 -26
- package/bin/_cctally_parser.py +21 -3
- package/bin/_cctally_quota.py +195 -76
- package/bin/_cctally_record.py +1596 -1133
- package/bin/_cctally_statusline.py +13 -1
- package/bin/_cctally_store.py +508 -0
- package/bin/_cctally_sync_week.py +139 -2
- package/bin/_lib_doctor.py +209 -12
- package/bin/_lib_journal.py +188 -0
- package/bin/cctally +11 -1
- package/dashboard/static/assets/index-Cc2ykqF_.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-CrIlpAlQ.css +0 -1
- /package/dashboard/static/assets/{index-BDkr0bdD.js → index-B3y14l1o.js} +0 -0
package/bin/_cctally_doctor.py
CHANGED
|
@@ -28,6 +28,7 @@ import datetime as dt
|
|
|
28
28
|
import fcntl
|
|
29
29
|
import json
|
|
30
30
|
import math
|
|
31
|
+
import os
|
|
31
32
|
import pathlib
|
|
32
33
|
import shutil
|
|
33
34
|
import sqlite3
|
|
@@ -44,6 +45,23 @@ def _cctally():
|
|
|
44
45
|
return sys.modules["cctally"]
|
|
45
46
|
|
|
46
47
|
|
|
48
|
+
def _journal_heal_incident(kind: str, name: str, now_utc: dt.datetime) -> dict:
|
|
49
|
+
"""One auto-heal artifact record for the doctor journal leg (§9). Parses the
|
|
50
|
+
``%Y%m%dT%H%M%SZ`` timestamp trailing the name (quarantine dir
|
|
51
|
+
``<db>.db-<ts>`` or forensics file ``<db>.db-corruption-forensics-<ts>.json``)
|
|
52
|
+
into an age; a name that doesn't parse degrades to ``age_s=None``."""
|
|
53
|
+
base = name[:-5] if name.endswith(".json") else name
|
|
54
|
+
ts = base.rsplit("-", 1)[-1]
|
|
55
|
+
age_s = None
|
|
56
|
+
try:
|
|
57
|
+
parsed = dt.datetime.strptime(ts, "%Y%m%dT%H%M%SZ").replace(
|
|
58
|
+
tzinfo=dt.timezone.utc)
|
|
59
|
+
age_s = int((now_utc - parsed).total_seconds())
|
|
60
|
+
except ValueError:
|
|
61
|
+
pass
|
|
62
|
+
return {"kind": kind, "name": name, "age_s": age_s}
|
|
63
|
+
|
|
64
|
+
|
|
47
65
|
def _gather_statusline_pipeline(c, *, now_utc: dt.datetime) -> dict:
|
|
48
66
|
"""Read the #318 statusline pipeline without creating or pruning files."""
|
|
49
67
|
now_epoch = int(now_utc.timestamp())
|
|
@@ -290,6 +308,11 @@ def doctor_gather_state(
|
|
|
290
308
|
stats_db_status = {"path": str(_cctally_core.DB_PATH), "user_version": 0,
|
|
291
309
|
"registry_size": len(c._STATS_MIGRATIONS),
|
|
292
310
|
"migrations": [], "_open_error": str(exc)}
|
|
311
|
+
# stats.db is the epoch-versioned journal index (DB journal redesign §7.1):
|
|
312
|
+
# feed the epoch constant to the pure kernel so db.version_ahead classifies
|
|
313
|
+
# uv==1000 as HEALTHY (not a #145 version-ahead FAIL). registry_size stays the
|
|
314
|
+
# frozen legacy head (13) and serves as the legacy-range boundary.
|
|
315
|
+
stats_db_status["epoch"] = _cctally_core.STATS_INDEX_EPOCH
|
|
293
316
|
try:
|
|
294
317
|
cache_db_status = c._db_status_for(_cctally_core.CACHE_DB_PATH, c._CACHE_MIGRATIONS, "cache.db")
|
|
295
318
|
if not _cctally_core.CACHE_DB_PATH.exists():
|
|
@@ -863,6 +886,129 @@ def doctor_gather_state(
|
|
|
863
886
|
pricing_coverage = None
|
|
864
887
|
|
|
865
888
|
# ── Meta ─────────────────────────────────────────────────────────
|
|
889
|
+
# ── Journal (DB journal redesign §9) ─────────────────────────────
|
|
890
|
+
# Read-only legs over the append-only journal: presence + appendability,
|
|
891
|
+
# torn-tail/malformed counts (deep-gated — reads whole segments), the ingest
|
|
892
|
+
# cursor lag vs. the high-water, and the auto-heal incident history. Every
|
|
893
|
+
# probe degrades to its always-OK posture on any error (the pure kernel then
|
|
894
|
+
# reports "no journal" / "not scanned").
|
|
895
|
+
import _lib_journal as _jl
|
|
896
|
+
journal_present = False
|
|
897
|
+
journal_appendable = None
|
|
898
|
+
journal_segment_count = 0
|
|
899
|
+
journal_malformed_count = None
|
|
900
|
+
journal_torn_tail_count = None
|
|
901
|
+
journal_cursor_lag_bytes = None
|
|
902
|
+
journal_hw_segment = None
|
|
903
|
+
journal_cursor_segment = None
|
|
904
|
+
try:
|
|
905
|
+
jdir = _cctally_core.JOURNAL_DIR
|
|
906
|
+
journal_present = jdir.exists()
|
|
907
|
+
if journal_present:
|
|
908
|
+
try:
|
|
909
|
+
journal_appendable = os.access(str(jdir), os.W_OK)
|
|
910
|
+
except OSError:
|
|
911
|
+
journal_appendable = None
|
|
912
|
+
import _cctally_journal as _jr
|
|
913
|
+
try:
|
|
914
|
+
segs = _jr.list_segments() # canonical (segment) order
|
|
915
|
+
except Exception:
|
|
916
|
+
segs = []
|
|
917
|
+
journal_segment_count = len(segs)
|
|
918
|
+
sizes: dict = {}
|
|
919
|
+
for seg in segs:
|
|
920
|
+
try:
|
|
921
|
+
sizes[seg] = (jdir / seg).stat().st_size
|
|
922
|
+
except OSError:
|
|
923
|
+
sizes[seg] = 0
|
|
924
|
+
if segs:
|
|
925
|
+
journal_hw_segment = segs[-1]
|
|
926
|
+
# deep-gated malformed / torn-tail scan (reads the whole journal;
|
|
927
|
+
# the dashboard's per-rebuild gather stays deep=False so it never
|
|
928
|
+
# pays this at the 10× envelope — mirrors the quick_check legs).
|
|
929
|
+
if deep and segs:
|
|
930
|
+
malformed = 0
|
|
931
|
+
torn = 0
|
|
932
|
+
for seg in segs:
|
|
933
|
+
try:
|
|
934
|
+
data = (jdir / seg).read_bytes()
|
|
935
|
+
except OSError:
|
|
936
|
+
continue
|
|
937
|
+
if not data:
|
|
938
|
+
continue
|
|
939
|
+
if not data.endswith(b"\n"):
|
|
940
|
+
torn += 1
|
|
941
|
+
# every element except the last is a complete line; the last
|
|
942
|
+
# is either "" (ended in \n) or the torn partial — not a
|
|
943
|
+
# mid-file line, so it is never counted as malformed.
|
|
944
|
+
for raw in data.split(b"\n")[:-1]:
|
|
945
|
+
if raw and _jl.decode_line(raw) is None:
|
|
946
|
+
malformed += 1
|
|
947
|
+
journal_malformed_count = malformed
|
|
948
|
+
journal_torn_tail_count = torn
|
|
949
|
+
# ingest cursor lag: unconsumed bytes between the stats index cursor
|
|
950
|
+
# and the journal high-water, in canonical (segment, offset) order.
|
|
951
|
+
cursor = None
|
|
952
|
+
try:
|
|
953
|
+
if _cctally_core.DB_PATH.exists():
|
|
954
|
+
jc = sqlite3.connect(
|
|
955
|
+
f"file:{_cctally_core.DB_PATH}?mode=ro", uri=True)
|
|
956
|
+
try:
|
|
957
|
+
crow = jc.execute(
|
|
958
|
+
"SELECT segment, offset FROM journal_cursor "
|
|
959
|
+
"WHERE id = 1").fetchone()
|
|
960
|
+
if crow:
|
|
961
|
+
cursor = (crow[0], int(crow[1]))
|
|
962
|
+
except sqlite3.OperationalError:
|
|
963
|
+
pass # pre-cutover DB has no journal_cursor table
|
|
964
|
+
finally:
|
|
965
|
+
jc.close()
|
|
966
|
+
except sqlite3.Error:
|
|
967
|
+
cursor = None
|
|
968
|
+
if cursor is not None and segs:
|
|
969
|
+
cseg, coff = cursor
|
|
970
|
+
journal_cursor_segment = cseg
|
|
971
|
+
order = {s: i for i, s in enumerate(segs)}
|
|
972
|
+
if cseg in order:
|
|
973
|
+
ci = order[cseg]
|
|
974
|
+
lag = max(0, sizes.get(segs[ci], 0) - coff)
|
|
975
|
+
for s in segs[ci + 1:]:
|
|
976
|
+
lag += sizes.get(s, 0)
|
|
977
|
+
journal_cursor_lag_bytes = lag
|
|
978
|
+
except Exception:
|
|
979
|
+
pass
|
|
980
|
+
# Auto-heal incident history — independent of journal presence (a corruption
|
|
981
|
+
# incident can predate cutover). None only if BOTH dirs were unreadable.
|
|
982
|
+
journal_heal_incidents = None
|
|
983
|
+
_incidents: list = []
|
|
984
|
+
_incident_read_ok = False
|
|
985
|
+
try:
|
|
986
|
+
qroot = _cctally_core.APP_DIR / "quarantine"
|
|
987
|
+
if qroot.exists():
|
|
988
|
+
_incident_read_ok = True
|
|
989
|
+
for entry in qroot.iterdir():
|
|
990
|
+
if entry.is_dir():
|
|
991
|
+
_incidents.append(
|
|
992
|
+
_journal_heal_incident("quarantine", entry.name, now_utc))
|
|
993
|
+
except OSError:
|
|
994
|
+
pass
|
|
995
|
+
try:
|
|
996
|
+
logdir = _cctally_core.LOG_DIR
|
|
997
|
+
if logdir.exists():
|
|
998
|
+
_incident_read_ok = True
|
|
999
|
+
for entry in logdir.iterdir():
|
|
1000
|
+
n = entry.name
|
|
1001
|
+
if "-corruption-forensics-" in n and n.endswith(".json"):
|
|
1002
|
+
_incidents.append(
|
|
1003
|
+
_journal_heal_incident("forensics", n, now_utc))
|
|
1004
|
+
except OSError:
|
|
1005
|
+
pass
|
|
1006
|
+
if _incident_read_ok:
|
|
1007
|
+
# most-recent first; unparseable ages (None) sort last.
|
|
1008
|
+
_incidents.sort(key=lambda d: (d["age_s"] is None,
|
|
1009
|
+
d["age_s"] if d["age_s"] is not None else 0))
|
|
1010
|
+
journal_heal_incidents = _incidents
|
|
1011
|
+
|
|
866
1012
|
cctally_version_tuple = _lib_changelog._read_latest_changelog_version()
|
|
867
1013
|
cctally_version = (
|
|
868
1014
|
cctally_version_tuple[0] if cctally_version_tuple else "unknown"
|
|
@@ -944,6 +1090,16 @@ def doctor_gather_state(
|
|
|
944
1090
|
# #311: precomputed statusLine.refreshInterval classification.
|
|
945
1091
|
statusline_refresh_state=statusline_refresh_state,
|
|
946
1092
|
statusline_pipeline=statusline_pipeline,
|
|
1093
|
+
# DB journal redesign §9: append-only journal legs.
|
|
1094
|
+
journal_present=journal_present,
|
|
1095
|
+
journal_appendable=journal_appendable,
|
|
1096
|
+
journal_segment_count=journal_segment_count,
|
|
1097
|
+
journal_malformed_count=journal_malformed_count,
|
|
1098
|
+
journal_torn_tail_count=journal_torn_tail_count,
|
|
1099
|
+
journal_cursor_lag_bytes=journal_cursor_lag_bytes,
|
|
1100
|
+
journal_hw_segment=journal_hw_segment,
|
|
1101
|
+
journal_cursor_segment=journal_cursor_segment,
|
|
1102
|
+
journal_heal_incidents=journal_heal_incidents,
|
|
947
1103
|
)
|
|
948
1104
|
|
|
949
1105
|
|
|
@@ -1461,7 +1461,9 @@ def cmd_five_hour_breakdown(args: argparse.Namespace) -> int:
|
|
|
1461
1461
|
conn.close()
|
|
1462
1462
|
|
|
1463
1463
|
|
|
1464
|
-
def _backfill_five_hour_blocks(
|
|
1464
|
+
def _backfill_five_hour_blocks(
|
|
1465
|
+
conn: sqlite3.Connection, *, only_missing: bool = False,
|
|
1466
|
+
) -> int:
|
|
1465
1467
|
"""One-shot historical backfill of five_hour_blocks from existing
|
|
1466
1468
|
weekly_usage_snapshots data. Idempotent via UNIQUE(five_hour_window_key)
|
|
1467
1469
|
+ INSERT OR IGNORE. Per spec §4.3, five_hour_milestones is NEVER
|
|
@@ -1475,6 +1477,14 @@ def _backfill_five_hour_blocks(conn: sqlite3.Connection) -> int:
|
|
|
1475
1477
|
snapshot keys we're about to insert are jitter-forked. On failure,
|
|
1476
1478
|
rolls back and returns 0; gate fires again on next open_db() so
|
|
1477
1479
|
partial state is recoverable.
|
|
1480
|
+
|
|
1481
|
+
``only_missing`` (Task 8, spec §5.4 rebuild): scope the window scan to
|
|
1482
|
+
windows that have snapshots but NO five_hour_blocks row yet — the OPEN
|
|
1483
|
+
(never-closed) window's projection. On a journal rebuild the CLOSED blocks
|
|
1484
|
+
are already materialized from ``five_hour_block_close`` evts, so this
|
|
1485
|
+
re-materializes only the open block (block-only, no milestones — the
|
|
1486
|
+
journaled 5h-milestone evts carry their own values and resolve block_id
|
|
1487
|
+
against this row), keeping the pass O(open windows) instead of O(all blocks).
|
|
1478
1488
|
"""
|
|
1479
1489
|
_c = _cctally()
|
|
1480
1490
|
inserted = 0
|
|
@@ -1483,16 +1493,18 @@ def _backfill_five_hour_blocks(conn: sqlite3.Connection) -> int:
|
|
|
1483
1493
|
# 5h percent. The percent guard is critical: MAX(five_hour_percent)
|
|
1484
1494
|
# over a NULL-only window is NULL, which would trip the
|
|
1485
1495
|
# final_five_hour_percent NOT NULL constraint at insert time.
|
|
1486
|
-
|
|
1487
|
-
int(r[0]) for r in conn.execute(
|
|
1488
|
-
"""
|
|
1496
|
+
keys_sql = """
|
|
1489
1497
|
SELECT DISTINCT five_hour_window_key
|
|
1490
1498
|
FROM weekly_usage_snapshots
|
|
1491
1499
|
WHERE five_hour_window_key IS NOT NULL
|
|
1492
1500
|
AND five_hour_percent IS NOT NULL
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1501
|
+
"""
|
|
1502
|
+
if only_missing:
|
|
1503
|
+
keys_sql += (
|
|
1504
|
+
" AND five_hour_window_key NOT IN "
|
|
1505
|
+
"(SELECT five_hour_window_key FROM five_hour_blocks)"
|
|
1506
|
+
)
|
|
1507
|
+
keys = [int(r[0]) for r in conn.execute(keys_sql).fetchall()]
|
|
1496
1508
|
|
|
1497
1509
|
now_iso = now_utc_iso()
|
|
1498
1510
|
now_dt = parse_iso_datetime(now_iso, "now")
|
package/bin/_cctally_forecast.py
CHANGED
|
@@ -1907,16 +1907,30 @@ def cmd_budget(args: argparse.Namespace) -> int:
|
|
|
1907
1907
|
or getattr(args, "format", None)
|
|
1908
1908
|
)
|
|
1909
1909
|
if interactive_status and codex_cfg.get("alerts_enabled"):
|
|
1910
|
-
|
|
1911
|
-
#
|
|
1912
|
-
# the
|
|
1913
|
-
#
|
|
1914
|
-
#
|
|
1915
|
-
#
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1910
|
+
# Task 7 Item 4: the interactive on-demand Codex budget (+ #135
|
|
1911
|
+
# projected) backstop routes through the single-flight ingest cycle
|
|
1912
|
+
# (the `codex_apply` seam) instead of opening its own stats
|
|
1913
|
+
# connection — so its `budget_milestones`/`projected_milestones`
|
|
1914
|
+
# (vendor=codex) crossings are journaled by the harvest and their
|
|
1915
|
+
# alerts dispatch post-commit (set-then-dispatch). OPPORTUNISTIC +
|
|
1916
|
+
# cycle-swallowed, preserving "never raises into the status render".
|
|
1917
|
+
# The passed-conn helpers re-raise on error (invariant ii), which the
|
|
1918
|
+
# opportunistic cycle catches. The #135 projected leg stays scoped to
|
|
1919
|
+
# the codex_budget_usd metric so a bare `cctally budget` never pops a
|
|
1920
|
+
# weekly_pct / Claude budget_usd notification.
|
|
1921
|
+
import _cctally_journal as _jr_codex
|
|
1922
|
+
projected_on = codex_cfg.get("projected_enabled")
|
|
1923
|
+
|
|
1924
|
+
def _codex_backstop_leg(ictx):
|
|
1925
|
+
c.maybe_record_codex_budget_milestone(
|
|
1926
|
+
{}, conn=ictx.conn, alert_sink=ictx.pending_alerts)
|
|
1927
|
+
if projected_on:
|
|
1928
|
+
c.maybe_record_projected_alert(
|
|
1929
|
+
{}, only_metrics={"codex_budget_usd"},
|
|
1930
|
+
conn=ictx.conn, alert_sink=ictx.pending_alerts)
|
|
1931
|
+
|
|
1932
|
+
_jr_codex.run_stats_ingest(
|
|
1933
|
+
mode="opportunistic", codex_apply=_codex_backstop_leg)
|
|
1920
1934
|
|
|
1921
1935
|
if target is None:
|
|
1922
1936
|
# Global Claude budget unset → friendly message, then (if configured)
|
|
@@ -2119,9 +2133,12 @@ def _cmd_budget_set_project(args: argparse.Namespace) -> int:
|
|
|
2119
2133
|
# budget mid-week (already over) doesn't storm. Scoped to the TOUCHED
|
|
2120
2134
|
# project so it never latches a sibling's already-crossed-but-not-yet-
|
|
2121
2135
|
# dispatched threshold (which would permanently suppress that alert).
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
)
|
|
2136
|
+
# 6f writer reroute: routed THROUGH the ingest cycle (opportunistic +
|
|
2137
|
+
# exception-wrapped) so the latched crossings are journaled (rebuild-
|
|
2138
|
+
# replayable) instead of written on a bespoke stats.db connection.
|
|
2139
|
+
import _cctally_journal as _jr
|
|
2140
|
+
_jr.reconcile_budget_config(
|
|
2141
|
+
validated, axes={"project_budget"}, touched_projects={root})
|
|
2125
2142
|
|
|
2126
2143
|
basename = os.path.basename(root) or root
|
|
2127
2144
|
if getattr(args, "json", False):
|
|
@@ -2179,9 +2196,11 @@ def _cmd_budget_unset_project(args: argparse.Namespace) -> int:
|
|
|
2179
2196
|
# the map, so this is a no-op for `root` — and it must NOT scan the
|
|
2180
2197
|
# remaining projects: scanning them would latch a sibling's already-crossed-
|
|
2181
2198
|
# but-not-yet-dispatched threshold, permanently suppressing its real alert.
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2199
|
+
# 6f writer reroute: routed THROUGH the ingest cycle (opportunistic +
|
|
2200
|
+
# exception-wrapped) so the latched crossings are journaled.
|
|
2201
|
+
import _cctally_journal as _jr
|
|
2202
|
+
_jr.reconcile_budget_config(
|
|
2203
|
+
validated, axes={"project_budget"}, touched_projects={root})
|
|
2185
2204
|
|
|
2186
2205
|
basename = os.path.basename(root) or root
|
|
2187
2206
|
if getattr(args, "json", False):
|
|
@@ -2258,7 +2277,10 @@ def _cmd_budget_set(args: argparse.Namespace, period=None) -> int:
|
|
|
2258
2277
|
# Shared with `config set budget.*` + dashboard POST /api/settings via
|
|
2259
2278
|
# _reconcile_budget_on_config_write (gated on _budget_alerts_active — a
|
|
2260
2279
|
# budget with alerts off or no thresholds records nothing).
|
|
2261
|
-
|
|
2280
|
+
# 6f writer reroute: routed THROUGH the ingest cycle (opportunistic +
|
|
2281
|
+
# exception-wrapped) so the latched crossings are journaled.
|
|
2282
|
+
import _cctally_journal as _jr
|
|
2283
|
+
_jr.reconcile_budget_config(validated, axes={"budget"})
|
|
2262
2284
|
if getattr(args, "json", False):
|
|
2263
2285
|
print(json.dumps({
|
|
2264
2286
|
"schemaVersion": _BUDGET_JSON_SCHEMA_VERSION,
|
|
@@ -2363,7 +2385,10 @@ def _cmd_budget_set_codex(args: argparse.Namespace, period=None) -> int:
|
|
|
2363
2385
|
# Runs OUTSIDE the config_writer_lock (open_db has its own locking). Gated on
|
|
2364
2386
|
# codex alerts_enabled + thresholds — a Codex budget with alerts off records
|
|
2365
2387
|
# nothing.
|
|
2366
|
-
|
|
2388
|
+
# 6f writer reroute: routed THROUGH the ingest cycle (opportunistic +
|
|
2389
|
+
# exception-wrapped) so the latched crossings are journaled.
|
|
2390
|
+
import _cctally_journal as _jr
|
|
2391
|
+
_jr.reconcile_budget_config(validated, axes={"codex_budget"})
|
|
2367
2392
|
|
|
2368
2393
|
codex = validated["codex"]
|
|
2369
2394
|
amount_usd = codex["amount_usd"]
|