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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,31 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.80.0] - 2026-07-23
9
+
10
+ ### Added
11
+ - Your usage history is now backed by an append-only journal (`~/.local/share/cctally/journal/`) that is the durable source of truth; `stats.db` becomes a disposable, rebuildable index, so a corrupted or deleted stats database now self-heals on the very next command — forensics bundle, quarantine of the damaged file, then an automatic rebuild from the journal — with zero loss of usage history and no manual repair step. (#336)
12
+ - `cctally db rebuild --db stats` rebuilds the stats index from the journal on demand (the manual form of the automatic self-heal), reporting per-table row counts and timing. (#336)
13
+ - `cctally doctor` gains a Journal section reporting the journal's presence and writability, torn/malformed-line counts, how far the stats index has fallen behind the journal, and the most recent self-heal incident. (#336)
14
+
15
+ ### Changed
16
+ - Upgrading to this version migrates existing installs in place automatically on first run, with zero loss of irreplaceable usage history and no manual steps (a slow first run is expected while the one-time journal is written). (#336)
17
+ - Every stats write now flows through a single-flight ingester, ending the `database is locked` pile-ups and wedged handles that concurrent multi-agent hook storms used to cause. (#336)
18
+ - Opening any database is faster: schema work now runs only when the schema version actually changes, not on every open (the status line opens databases several times per render). (#336)
19
+ - Codex quota observations are now preserved durably in the journal, so that history is no longer silently lost when the source rollout files are pruned. (#336)
20
+ - Back up `~/.local/share/cctally/journal/` — hand-copying a `.db` file is now pointless rather than risky, because every database rebuilds from the journal plus surviving provider logs. (#336)
21
+
22
+ ### Fixed
23
+ - The mobile dashboard hero now keeps a clear gap between its 7-day and 5-hour usage values instead of letting a wide 7-day percentage overflow into the adjacent metric.
24
+
25
+ ### Removed
26
+ - `cctally db recover --db stats` is retired: a stats-index version mismatch now self-heals by rebuild, so use `cctally db rebuild --db stats` instead (`db recover --db cache` is unchanged). (#336)
27
+
28
+ ## [1.79.2] - 2026-07-23
29
+
30
+ ### Fixed
31
+ - The dashboard's current-period five-hour milestone section now shows the complete block position and both navigation controls immediately, matching historic periods while keeping older block details lazy-loaded. Claude receives the fix now, and Codex uses the same behavior whenever native 300-minute blocks are retained.
32
+
8
33
  ## [1.79.1] - 2026-07-22
9
34
 
10
35
  ### Fixed
@@ -331,9 +331,14 @@ def _iter_claude_jsonl_files():
331
331
  yield jp
332
332
 
333
333
  _cctally_db_sib = _load_lib("_cctally_db")
334
+ # Unified store opener (spec §6.1/§6.2). Owns the per-store PRAGMA policy and
335
+ # the version gate; open_cache_db / open_conversations_db route their
336
+ # connect+policy through it and skip the schema apply when schema_current().
337
+ _cctally_store = _load_lib("_cctally_store")
334
338
  add_column_if_missing = _cctally_db_sib.add_column_if_missing
335
339
  _run_pending_migrations = _cctally_db_sib._run_pending_migrations
336
340
  _CACHE_MIGRATIONS = _cctally_db_sib._CACHE_MIGRATIONS
341
+ _CONVERSATIONS_MIGRATIONS = _cctally_db_sib._CONVERSATIONS_MIGRATIONS
337
342
  # Storm-free conversation_messages + FTS full-clear (#138). Owns the trigger
338
343
  # drop/recreate dance so the per-row delete trigger never fires O(rows) under
339
344
  # the held lock on a rebuild / truncation escalation.
@@ -1191,6 +1196,61 @@ def _prune_inactive_codex_source_roots(
1191
1196
  )
1192
1197
 
1193
1198
 
1199
+ def _append_codex_quota_obs(quota_rows: list) -> None:
1200
+ """Journal one Codex quota `obs` per newly-read quota observation (Task 7
1201
+ Item 1, spec §4.2 / §5.3 / Appendix A).
1202
+
1203
+ The direct cache.db write in ``_write_codex_file_batch`` stays byte-identical;
1204
+ this obs is the DURABLE truth for the observation. Codex rollout JSONL
1205
+ evaporates over time (§1 latent data-loss hole), so ``quota_window_snapshots``
1206
+ are silently irreplaceable today — journaling the raw capture closes that
1207
+ hole, and the ingest cycle's ``QUOTA_APPLIER`` (spec §5.2 step 3)
1208
+ re-materializes cache.db from these obs.
1209
+
1210
+ Called UNDER the ``cache.db.codex.lock`` provider flock (the journal append
1211
+ lock is a LEAF — legal to take inside a provider flock, spec §4.3) and BEFORE
1212
+ the cache write / offset advance, so a crash re-reads the same bytes and
1213
+ re-appends (idempotent at the QUOTA_APPLIER's natural-key INSERT OR IGNORE)
1214
+ rather than losing the observation. ``at`` is the detection clock
1215
+ (``_command_as_of()``-aware, honoring the CCTALLY_AS_OF test hook); the raw
1216
+ row (incl. its own ``captured_at_utc``) rides the payload (spec §4.2 raw
1217
+ capture — no derivation baked into the stored line). $CODEX_HOME multi-root
1218
+ scoping is preserved: each row carries its own ``source_root_key`` and roots
1219
+ are never combined."""
1220
+ if not quota_rows:
1221
+ return
1222
+ import _cctally_journal as _jr
1223
+ import _lib_journal as _jl
1224
+ at = (
1225
+ _cctally_core._command_as_of()
1226
+ .isoformat(timespec="seconds")
1227
+ .replace("+00:00", "Z")
1228
+ )
1229
+ for row in quota_rows:
1230
+ (source, source_root_key, source_path, line_offset, captured_at_utc,
1231
+ observed_slot, logical_limit_key, limit_id, limit_name, window_minutes,
1232
+ used_percent, resets_at_utc, plan_type, individual_limit_json,
1233
+ reached_type, observed_model) = row
1234
+ try:
1235
+ _jr.append_record(_jl.make_obs(
1236
+ at=at, src="codex-quota", provider="codex",
1237
+ payload={
1238
+ "kind": "quota_window_snapshot",
1239
+ "source": source, "source_root_key": source_root_key,
1240
+ "source_path": source_path, "line_offset": line_offset,
1241
+ "captured_at_utc": captured_at_utc,
1242
+ "observed_slot": observed_slot,
1243
+ "logical_limit_key": logical_limit_key, "limit_id": limit_id,
1244
+ "limit_name": limit_name, "window_minutes": window_minutes,
1245
+ "used_percent": used_percent, "resets_at_utc": resets_at_utc,
1246
+ "plan_type": plan_type,
1247
+ "individual_limit_json": individual_limit_json,
1248
+ "reached_type": reached_type, "observed_model": observed_model,
1249
+ }))
1250
+ except Exception as exc: # best-effort; a journal append must not break sync
1251
+ eprint(f"[codex-cache] quota obs journal append failed: {exc}")
1252
+
1253
+
1194
1254
  def _write_codex_file_batch(
1195
1255
  conn: sqlite3.Connection,
1196
1256
  *,
@@ -4333,6 +4393,15 @@ def sync_codex_cache(
4333
4393
  # owns the authoritative transcript-local value.
4334
4394
  new_last_turn_id = prev_turn_id
4335
4395
 
4396
+ # Task 7 Item 1: journal the Codex quota observations BEFORE the cache
4397
+ # write (and before the offset advances), under the codex flock this
4398
+ # function already holds. Durable-first: a crash after the append but
4399
+ # before the commit re-reads the same bytes next sync and re-appends
4400
+ # (idempotent at the QUOTA_APPLIER natural key) rather than losing the
4401
+ # observation. Appended once here, not inside the retry loop, so a DB
4402
+ # retry never double-journals.
4403
+ _append_codex_quota_obs(quota_rows)
4404
+
4336
4405
  # Every derived row above was buffered before the first DML. A
4337
4406
  # late database failure therefore rolls the whole file back and
4338
4407
  # retries that same in-memory batch exactly once.
@@ -4738,15 +4807,22 @@ def open_cache_db() -> sqlite3.Connection:
4738
4807
  except OSError as exc:
4739
4808
  eprint(f"[cache] could not chmod data dir 0700 ({exc}); continuing")
4740
4809
  try:
4741
- conn = sqlite3.connect(_cctally_core.CACHE_DB_PATH)
4810
+ # Connect + install row factory / test trace hook via the unified
4811
+ # opener (spec §6.1). PRAGMAs are applied AFTER the corruption probe so
4812
+ # a mode-change never writes a WAL onto a DB we are about to unlink.
4813
+ conn = _cctally_store.open_index("cache")
4742
4814
  conn.execute("SELECT 1").fetchone()
4743
4815
  except sqlite3.DatabaseError as exc:
4744
4816
  eprint(f"[cache] corrupt cache DB ({exc}); recreating")
4817
+ try:
4818
+ conn.close()
4819
+ except Exception:
4820
+ pass
4745
4821
  try:
4746
4822
  _cctally_core.CACHE_DB_PATH.unlink()
4747
4823
  except FileNotFoundError:
4748
4824
  pass
4749
- conn = sqlite3.connect(_cctally_core.CACHE_DB_PATH)
4825
+ conn = _cctally_store.open_index("cache")
4750
4826
 
4751
4827
  # Best-effort 0600 on cache.db itself (the 0700 dir above backstops the
4752
4828
  # sidecars until the first write hardens them in sync_cache).
@@ -4755,56 +4831,46 @@ def open_cache_db() -> sqlite3.Connection:
4755
4831
  except OSError as exc:
4756
4832
  eprint(f"[cache] could not chmod cache.db 0600 ({exc}); continuing")
4757
4833
 
4758
- # #313 P3 reclaim: create a fresh cache.db in INCREMENTAL auto-vacuum mode so
4759
- # the transcript-retention prune can return freed pages to the OS via
4760
- # `PRAGMA incremental_vacuum` instead of only growing the freelist — the
4761
- # conversation-transcript store is what bloated cache.db to multiple GB and
4762
- # slowed every sync tick. This MUST run before the first page is written
4763
- # (before journal_mode=WAL / any CREATE TABLE) to take effect; it is a
4764
- # harmless no-op on an already-created DB, which keeps its existing mode
4765
- # until a full VACUUM (`cctally db vacuum`) or a `cache-sync --rebuild`.
4766
- conn.execute("PRAGMA auto_vacuum=INCREMENTAL")
4767
- conn.execute("PRAGMA journal_mode=WAL")
4768
- # #297: 15 s (was 5 s) lets a writer wait out a slow-but-normal sync (>5 s)
4769
- # instead of instantly erroring with "database is locked". Covers plain
4770
- # writer-vs-writer SQLITE_BUSY, not SQLITE_BUSY_SNAPSHOT (defended by
4771
- # write-lock-up-front at the write sites, #87).
4772
- conn.execute("PRAGMA busy_timeout=15000")
4773
- # #297: bound the persistent WAL file so a resetting checkpoint truncates it
4774
- # back to this cap instead of leaving it at its multi-GB high-water size.
4775
- conn.execute(f"PRAGMA journal_size_limit={CACHE_WAL_SIZE_LIMIT_BYTES}")
4776
- # Re-derivable DB under WAL: NORMAL risks at most the tail transaction on
4777
- # power loss, and cache.db can always be rebuilt (cache-sync --rebuild).
4778
- # Fewer ingest fsyncs than the default FULL. Matches stats.db
4779
- # (bin/_cctally_core.py open_db). #279 S1 F8.
4780
- conn.execute("PRAGMA synchronous=NORMAL")
4781
-
4782
- # Apply the shared cache.db schema (cctally-dev#93, D4): Claude tables +
4783
- # indexes, the session_id / project_path column adds on session_files
4784
- # (A2 `session` metadata, populated lazily in sync_cache() /
4785
- # _ensure_session_files_row()), the Codex base tables + indexes, and the
4786
- # cache_meta sentinel table. This is the single cache.db schema source
4787
- # the eager-apply path (_eagerly_apply_cache_migrations) uses the SAME
4788
- # helper, so the two can no longer drift. The Codex last_total_tokens
4789
- # ALTER + purge stays below (out of the shared helper — D4/P1#3).
4790
- _cctally_db_sib._apply_cache_schema(conn)
4791
-
4792
- # Migration: add last_total_tokens to codex_session_files. When the column
4793
- # is newly added (i.e. this is the first run after upgrade), purge the
4794
- # Codex cache so the duplicate-counted rows produced by the previous
4795
- # iterator are reingested cleanly by sync_codex_cache(). The cache is
4796
- # fully re-derivable from ~/.codex/sessions/*.jsonl so this is safe.
4797
- if add_column_if_missing(conn, "codex_session_files", "last_total_tokens", "INTEGER"):
4798
- conn.execute("DELETE FROM codex_session_entries")
4799
- conn.execute("DELETE FROM codex_session_files")
4800
- conn.commit()
4801
- eprint("[cache] migrated codex cache — re-ingesting")
4834
+ # §6.1 PRAGMA policy (auto_vacuum=INCREMENTAL emitted first #313 P3
4835
+ # reclaim needs it before the first page; journal_mode=WAL / busy_timeout /
4836
+ # journal_size_limit #297; synchronous=NORMAL #279 S1 F8). Byte-equivalent
4837
+ # to the former inline block, now owned by the shared policy table.
4838
+ _cctally_store.apply_policy(conn, "cache")
4839
+
4840
+ # §6.2 version gate: run the full schema executescript + add_column_if_missing
4841
+ # probes + the codex last_total_tokens ALTER/purge ONLY when the stamped
4842
+ # user_version differs from the registry head. On a steady-state open of an
4843
+ # up-to-date cache.db this whole block is skipped — the open becomes
4844
+ # connect PRAGMAs one user_version read the dispatcher fast-path.
4845
+ if not _cctally_store.schema_current(conn, "cache"):
4846
+ # Apply the shared cache.db schema (cctally-dev#93, D4): Claude tables +
4847
+ # indexes, the session_id / project_path column adds on session_files
4848
+ # (A2 `session` metadata, populated lazily in sync_cache() /
4849
+ # _ensure_session_files_row()), the Codex base tables + indexes, and the
4850
+ # cache_meta sentinel table. This is the single cache.db schema source
4851
+ # the eager-apply path (_eagerly_apply_cache_migrations) uses the SAME
4852
+ # helper, so the two can no longer drift. The Codex last_total_tokens
4853
+ # ALTER + purge stays below (out of the shared helper — D4/P1#3).
4854
+ _cctally_db_sib._apply_cache_schema(conn)
4855
+
4856
+ # Migration: add last_total_tokens to codex_session_files. When the
4857
+ # column is newly added (i.e. this is the first run after upgrade),
4858
+ # purge the Codex cache so the duplicate-counted rows produced by the
4859
+ # previous iterator are reingested cleanly by sync_codex_cache(). The
4860
+ # cache is fully re-derivable from ~/.codex/sessions/*.jsonl so this is
4861
+ # safe. Under the version gate now (spec §6.2): on an up-to-date DB the
4862
+ # column already exists so add_column_if_missing was a no-op / no purge,
4863
+ # making the gate byte-equivalent.
4864
+ if add_column_if_missing(conn, "codex_session_files", "last_total_tokens", "INTEGER"):
4865
+ conn.execute("DELETE FROM codex_session_entries")
4866
+ conn.execute("DELETE FROM codex_session_files")
4867
+ conn.commit()
4868
+ eprint("[cache] migrated codex cache re-ingesting")
4802
4869
 
4803
- # Migration framework dispatcher for cache.db. The registry is empty in
4804
- # v1 this is preparatory wiring that activates when the next cache.db
4805
- # migration ships. With an empty registry the dispatcher hits the
4806
- # fast-path or fresh-install branch and returns immediately. See spec
4807
- # §2.5, §3.3 + the @cache_migration decorator further down in this file.
4870
+ # Migration framework dispatcher for cache.db. Runs unconditionally (spec
4871
+ # §6.2: only the schema apply moves under the gate; the dispatcher keeps its
4872
+ # own fast-path that returns early when user_version == registry head). See
4873
+ # the @cache_migration decorator further down in this file.
4808
4874
  _run_pending_migrations(
4809
4875
  conn, registry=_CACHE_MIGRATIONS, db_label="cache.db",
4810
4876
  recover_version_ahead=True,
@@ -4854,10 +4920,13 @@ def open_conversations_db(*, attach_cache: bool = True) -> sqlite3.Connection:
4854
4920
  path = _cctally_core.CONVERSATIONS_DB_PATH
4855
4921
  conn: sqlite3.Connection | None = None
4856
4922
  try:
4857
- # URI mode belongs to the connection, not only the later ATTACH value.
4858
- # Without it, some supported system-Python SQLite builds interpret the
4859
- # read-only ``file:...cache.db?mode=ro`` attachment as a literal path.
4860
- conn = sqlite3.connect(path, uri=True)
4923
+ # Connect via the unified opener (spec §6.1). URI mode belongs to the
4924
+ # connection, not only the later ATTACH value — without it, some
4925
+ # supported system-Python SQLite builds interpret the read-only
4926
+ # ``file:...cache.db?mode=ro`` attachment as a literal path; the
4927
+ # conversations policy carries ``uri=True``. PRAGMAs are applied after
4928
+ # the corruption probe.
4929
+ conn = _cctally_store.open_index("conversations")
4861
4930
  conn.execute("SELECT 1").fetchone()
4862
4931
  except sqlite3.DatabaseError as exc:
4863
4932
  if conn is not None:
@@ -4878,13 +4947,31 @@ def open_conversations_db(*, attach_cache: bool = True) -> sqlite3.Connection:
4878
4947
  f"[conversations] could not chmod conversations.db 0600 ({exc}); continuing"
4879
4948
  )
4880
4949
 
4881
- conn.execute("PRAGMA auto_vacuum=INCREMENTAL")
4882
- conn.execute("PRAGMA journal_mode=WAL")
4883
- conn.execute("PRAGMA busy_timeout=15000")
4884
- conn.execute(f"PRAGMA journal_size_limit={CACHE_WAL_SIZE_LIMIT_BYTES}")
4885
- conn.execute("PRAGMA synchronous=NORMAL")
4886
- _cctally_db_sib._apply_conversations_schema(conn)
4950
+ # §6.1 PRAGMA policy (auto_vacuum=INCREMENTAL first / WAL / busy_timeout /
4951
+ # journal_size_limit / synchronous=NORMAL) via the shared table.
4952
+ _cctally_store.apply_policy(conn, "conversations")
4953
+ # §6.2 version gate. conversations.db is under the migration framework
4954
+ # (spec §7.2): the schema apply runs only when the stamped user_version is
4955
+ # behind the registry head; the dispatcher below then stamps user_version to
4956
+ # the head so the next open gates the schema apply out. On an existing
4957
+ # populated DB _apply_conversations_schema short-circuits on its own marker,
4958
+ # so no transcript row is touched even when the gate re-runs it.
4959
+ if not _cctally_store.schema_current(conn, "conversations"):
4960
+ _cctally_db_sib._apply_conversations_schema(conn)
4961
+ # Commit the schema apply BEFORE the dispatcher: _apply_conversations_schema
4962
+ # can leave an open implicit transaction (its trailing marker INSERT), and
4963
+ # the dispatcher's bootstrap-rename opens its own BEGIN — which would collide
4964
+ # with a still-open transaction. Committing here leaves the connection in
4965
+ # autocommit so the dispatcher starts clean.
4887
4966
  conn.commit()
4967
+ # Migration framework dispatcher for conversations.db (spec §7.2). Runs
4968
+ # unconditionally with its own fast-path (user_version == registry head).
4969
+ # recover_version_ahead=True because conversations.db is re-derivable from
4970
+ # provider JSONL, matching the cache.db posture.
4971
+ _run_pending_migrations(
4972
+ conn, registry=_CONVERSATIONS_MIGRATIONS, db_label="conversations.db",
4973
+ recover_version_ahead=True,
4974
+ )
4888
4975
 
4889
4976
  if attach_cache:
4890
4977
  # Ensure the compact schema exists before opening it read-only. This
@@ -1050,8 +1050,11 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
1050
1050
  except _BudgetConfigError as exc:
1051
1051
  eprint(f"cctally config: {exc}")
1052
1052
  return 2
1053
- # Deliberately outside the config lock: this helper opens and locks DBs.
1054
- c._reconcile_codex_budget_on_config_write({"codex": configured})
1053
+ # Deliberately outside the config lock. 6f writer reroute: routed THROUGH
1054
+ # the ingest cycle (opportunistic + exception-wrapped) so the latched
1055
+ # Codex crossings are journaled and rebuild-replayable.
1056
+ import _cctally_journal as _jr
1057
+ _jr.reconcile_budget_config({"codex": configured}, axes={"codex_budget"})
1055
1058
  leaf = key.removeprefix(_CODEX_BUDGET_LEAF_PREFIX)
1056
1059
  value = configured[leaf]
1057
1060
  if getattr(args, "emit_json", False):
@@ -1765,21 +1768,32 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
1765
1768
  # forward-only-from-set reconcile prevents (`budget set --period` already
1766
1769
  # reconciles via the same helper). Both run OUTSIDE config_writer_lock
1767
1770
  # (each helper has its own open_db lock).
1771
+ # 6f writer reroute: the conditional per-leaf axis mapping is UNCHANGED,
1772
+ # but the reconciles now run THROUGH the ingest cycle (opportunistic +
1773
+ # exception-wrapped) so their latched crossings are journaled and
1774
+ # rebuild-replayable. A leaf that feeds two axes (`alert_thresholds`
1775
+ # feeds both budget + project_budget) reconciles both in ONE cycle via
1776
+ # the axes union. `touched_projects=None` = reconcile every configured
1777
+ # project (a `config set` write is wholesale, not project-scoped).
1778
+ _reconcile_axes: set = set()
1768
1779
  if inner_key in (
1769
1780
  "weekly_usd", "alerts_enabled", "alert_thresholds", "period"
1770
1781
  ):
1771
- c._reconcile_budget_on_config_write(validated)
1782
+ _reconcile_axes.add("budget")
1772
1783
  if inner_key in (
1773
1784
  "projects", "project_alerts_enabled", "alert_thresholds"
1774
1785
  ):
1775
- c._reconcile_project_budget_milestones_on_write(validated)
1786
+ _reconcile_axes.add("project_budget")
1776
1787
  # Codex budget axis (spec §6): the nested budget.codex block is set
1777
1788
  # wholesale via `config set budget.codex '<json>'`, so the only key that
1778
1789
  # touches it is `codex` itself. Gated on the codex block carrying
1779
1790
  # alerts_enabled + thresholds (the helper re-checks); records nothing
1780
1791
  # otherwise.
1781
1792
  if inner_key == "codex":
1782
- c._reconcile_codex_budget_on_config_write(validated)
1793
+ _reconcile_axes.add("codex_budget")
1794
+ if _reconcile_axes:
1795
+ import _cctally_journal as _jr
1796
+ _jr.reconcile_budget_config(validated, axes=_reconcile_axes)
1783
1797
  out_val = validated[inner_key]
1784
1798
  if getattr(args, "emit_json", False):
1785
1799
  print(json.dumps({"budget": {inner_key: out_val}}, indent=2))
@@ -1843,8 +1857,11 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
1843
1857
  return 2
1844
1858
  # An optional leaf leaves a configured block, even when it already
1845
1859
  # held its default; the forward-only state must be reconciled once.
1860
+ # 6f writer reroute: routed THROUGH the ingest cycle (opportunistic +
1861
+ # exception-wrapped) so the latched Codex crossings are journaled.
1846
1862
  assert configured is not None
1847
- c._reconcile_codex_budget_on_config_write({"codex": configured})
1863
+ import _cctally_journal as _jr
1864
+ _jr.reconcile_budget_config({"codex": configured}, axes={"codex_budget"})
1848
1865
  return 0
1849
1866
  if key == "display.tz":
1850
1867
  with config_writer_lock():