cctally 1.79.1 → 1.80.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.
@@ -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(conn: sqlite3.Connection) -> int:
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
- keys = [
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
- ).fetchall()
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")
@@ -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
- c.maybe_record_codex_budget_milestone({})
1911
- # #135: the opportunistic Codex PROJECTED-pace backstop, scoped to
1912
- # the codex_budget_usd metric so it never pops a weekly_pct / Claude
1913
- # budget_usd notification from a bare `cctally budget`. The projected
1914
- # leg self-syncs (skip_sync=False); since codex_inputs was just
1915
- # built above, that delta-sync is a no-op here — correct and cheap.
1916
- if codex_cfg.get("projected_enabled"):
1917
- c.maybe_record_projected_alert(
1918
- {}, only_metrics={"codex_budget_usd"}
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
- c._reconcile_project_budget_milestones_on_write(
2123
- validated, touched_projects={root}
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
- c._reconcile_project_budget_milestones_on_write(
2183
- validated, touched_projects={root}
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
- c._reconcile_budget_on_config_write(validated)
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
- c._reconcile_codex_budget_on_config_write(validated)
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"]