cctally 1.83.1 → 1.84.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.
@@ -754,6 +754,7 @@ def _corruption_trigger_record(
754
754
  def write_corruption_forensics(
755
755
  db_path,
756
756
  *,
757
+ probe_db_path: "pathlib.Path | None" = None,
757
758
  db_label: str = "stats",
758
759
  trigger_origin: "str | None" = None,
759
760
  trigger_exception: "BaseException | None" = None,
@@ -769,13 +770,21 @@ def write_corruption_forensics(
769
770
  heal/rebuild callers. Cache recovery opts into the typed result: only a
770
771
  persisted bundle plus either a non-``ok`` integrity row or a positively
771
772
  classified corruption failure during the probe confirms destructive
772
- quarantine. Probe/write unavailability is always unconfirmed.
773
+ quarantine. ``probe_db_path`` lets a caller run integrity against an
774
+ isolated family snapshot while the bundle still identifies the live
775
+ ``db_path``. Probe/write unavailability is always unconfirmed. Confirmed
776
+ bundles are atomically replaced and fsynced before this function returns.
773
777
  """
774
778
  if (trigger_origin is None) != (trigger_exception is None):
775
779
  raise ValueError(
776
780
  "trigger_origin and trigger_exception must be supplied together"
777
781
  )
778
782
  db_path = pathlib.Path(db_path)
783
+ probe_db_path = (
784
+ pathlib.Path(probe_db_path)
785
+ if probe_db_path is not None
786
+ else db_path
787
+ )
779
788
  ts = _db_backup_timestamp()
780
789
  try:
781
790
  _cctally_core.LOG_DIR.mkdir(parents=True, exist_ok=True)
@@ -808,7 +817,7 @@ def write_corruption_forensics(
808
817
  except OSError:
809
818
  bundle["family"][p.name] = None
810
819
  try:
811
- c = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
820
+ c = sqlite3.connect(f"file:{probe_db_path}?mode=ro", uri=True)
812
821
  try:
813
822
  c.execute("PRAGMA busy_timeout=2000")
814
823
  bundle["integrityCheck"] = [
@@ -860,11 +869,7 @@ def write_corruption_forensics(
860
869
  except Exception as exc:
861
870
  bundle["holders"] = [f"error: {exc}"]
862
871
  try:
863
- out.write_text(json.dumps(bundle, indent=2, sort_keys=True))
864
- try:
865
- os.chmod(out, 0o600)
866
- except OSError:
867
- pass
872
+ _atomic_write_private_json(out, bundle)
868
873
  except OSError:
869
874
  result = CorruptionForensicsResult(
870
875
  path=None,
@@ -3328,6 +3333,43 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
3328
3333
  last_seen_utc TEXT NOT NULL
3329
3334
  );
3330
3335
 
3336
+ -- #416 spec §3.2/§3.3: the durable Codex attribution map. Two tables,
3337
+ -- one concept. `codex_file_incarnations` is the per-file incarnation
3338
+ -- counter (bumped when a truncation resets the file to offset zero, so
3339
+ -- reused offsets can never be covered by the previous incarnation's
3340
+ -- intervals); `codex_file_accounts` holds the decisions themselves, one
3341
+ -- row per (identity, incarnation, byte range).
3342
+ --
3343
+ -- `account_key` is NULLABLE on purpose: NULL is the stably-absent
3344
+ -- SENTINEL DECISION (no auth / api-key mode), matching the
3345
+ -- `NULL ≡ unattributed` cache-read rule used by every other family. The
3346
+ -- literal string "unattributed" is never stored. A torn auth read
3347
+ -- records NO ROW AT ALL — "undecided" and "decided: no account" are
3348
+ -- distinct states and readers must not collapse them.
3349
+ --
3350
+ -- Both live in the UNCONDITIONAL executescript, BEFORE the FTS5
3351
+ -- `legacy_present` early-return below, because they are plain tables
3352
+ -- with no FTS-shape dependency and the ingest path needs them on every
3353
+ -- open (the `_apply_cache_schema_legacy_early_return_before_new_table`
3354
+ -- class). Cache migration 031 exists only to bump the registry head so
3355
+ -- an existing install re-runs this schema apply.
3356
+ CREATE TABLE IF NOT EXISTS codex_file_incarnations (
3357
+ file_identity TEXT NOT NULL PRIMARY KEY,
3358
+ incarnation INTEGER NOT NULL,
3359
+ updated_at_utc TEXT
3360
+ );
3361
+ CREATE TABLE IF NOT EXISTS codex_file_accounts (
3362
+ file_identity TEXT NOT NULL,
3363
+ incarnation INTEGER NOT NULL,
3364
+ from_offset INTEGER NOT NULL,
3365
+ root_scope TEXT NOT NULL,
3366
+ account_key TEXT,
3367
+ decided_at_utc TEXT NOT NULL,
3368
+ PRIMARY KEY (file_identity, incarnation, from_offset)
3369
+ );
3370
+ CREATE INDEX IF NOT EXISTS idx_codex_file_accounts_root
3371
+ ON codex_file_accounts(root_scope);
3372
+
3331
3373
  CREATE TABLE IF NOT EXISTS codex_conversation_threads (
3332
3374
  conversation_key TEXT NOT NULL PRIMARY KEY,
3333
3375
  source_root_key TEXT NOT NULL,
@@ -3641,6 +3683,15 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
3641
3683
  # backfills it from the still-present legacy event corpus before dropping
3642
3684
  # that corpus.
3643
3685
  add_column_if_missing(conn, "quota_window_snapshots", "observed_model", "TEXT")
3686
+ # #416 spec §4.1/§4.2: the tolerance-anchored canonical reset, resolved at
3687
+ # INGEST over the complete population and stored beside the raw value (which
3688
+ # is retained unchanged as evidence). NULL means "not yet resolved" and every
3689
+ # reader falls back to the raw reset, i.e. exactly today's behaviour — so an
3690
+ # old binary reading a new cache, or a row written before migration 032's
3691
+ # backfill, degrades rather than breaking. Migration 032 arms the one-time
3692
+ # backfill over existing history.
3693
+ add_column_if_missing(
3694
+ conn, "quota_window_snapshots", "canonical_resets_at_utc", "TEXT")
3644
3695
  conn.execute(
3645
3696
  "CREATE INDEX IF NOT EXISTS idx_session_files_session_id "
3646
3697
  "ON session_files(session_id)"
@@ -3795,6 +3846,11 @@ def _apply_conversations_schema(conn: sqlite3.Connection) -> None:
3795
3846
  DROP TABLE IF EXISTS quota_window_snapshots;
3796
3847
  DROP TABLE IF EXISTS codex_conversation_threads;
3797
3848
  DROP TABLE IF EXISTS codex_source_roots;
3849
+ -- #416: the Codex attribution map is a cache.db accounting concern; it
3850
+ -- rides in via _apply_cache_schema above and is dropped here with the
3851
+ -- rest of the accounting families so conversations.db stays transcripts-only.
3852
+ DROP TABLE IF EXISTS codex_file_accounts;
3853
+ DROP TABLE IF EXISTS codex_file_incarnations;
3798
3854
 
3799
3855
  CREATE TABLE IF NOT EXISTS conversation_source_files (
3800
3856
  path TEXT PRIMARY KEY,
@@ -5853,6 +5909,116 @@ def _030_session_entries_cache_creation_split(conn: sqlite3.Connection) -> None:
5853
5909
  conn.commit()
5854
5910
 
5855
5911
 
5912
+ @cache_migration("031_codex_file_account_map")
5913
+ def _031_codex_file_account_map(conn: sqlite3.Connection) -> None:
5914
+ """#416 spec §3.2/§3.3: land the durable Codex attribution map on an
5915
+ existing install.
5916
+
5917
+ The two tables (``codex_file_incarnations``, ``codex_file_accounts``) are
5918
+ created by ``_apply_cache_schema`` in its UNCONDITIONAL executescript — the
5919
+ repo's table-addition rule, and specifically BEFORE the FTS5
5920
+ ``legacy_present`` early-return so a legacy-shape cache still receives them.
5921
+ This migration exists because that schema apply is VERSION-GATED: a
5922
+ steady-state open compares ``PRAGMA user_version`` against
5923
+ ``len(_CACHE_MIGRATIONS)`` and skips the whole DDL pass when they match
5924
+ (``_cctally_store.schema_current``). Registering here bumps the head, so an
5925
+ already-current install re-runs the schema apply and gains the tables. Same
5926
+ mechanism migration 029 relies on, stated there in the same words.
5927
+
5928
+ There is deliberately NO BACKFILL (spec D1: history that was never durably
5929
+ stamped becomes ``unattributed``; nothing is inferred — heuristic backfill
5930
+ cannot distinguish two accounts that share an email). The map starts empty
5931
+ and fills forward from ingest decisions plus journal replay.
5932
+
5933
+ The idempotent DDL below is a defensive re-assert, not the primary creation
5934
+ path: it keeps the handler self-contained if ``_apply_cache_schema``'s
5935
+ ordering ever drifts, and it is what the per-migration golden exercises
5936
+ (the golden's ``pre.sqlite`` is a genuine 030-head install that predates
5937
+ both tables). Re-running is a no-op. NO self-stamp — the dispatcher
5938
+ central-stamps on a clean return (#140).
5939
+ """
5940
+ conn.executescript(
5941
+ """
5942
+ CREATE TABLE IF NOT EXISTS codex_file_incarnations (
5943
+ file_identity TEXT NOT NULL PRIMARY KEY,
5944
+ incarnation INTEGER NOT NULL,
5945
+ updated_at_utc TEXT
5946
+ );
5947
+ CREATE TABLE IF NOT EXISTS codex_file_accounts (
5948
+ file_identity TEXT NOT NULL,
5949
+ incarnation INTEGER NOT NULL,
5950
+ from_offset INTEGER NOT NULL,
5951
+ root_scope TEXT NOT NULL,
5952
+ account_key TEXT,
5953
+ decided_at_utc TEXT NOT NULL,
5954
+ PRIMARY KEY (file_identity, incarnation, from_offset)
5955
+ );
5956
+ CREATE INDEX IF NOT EXISTS idx_codex_file_accounts_root
5957
+ ON codex_file_accounts(root_scope);
5958
+ """
5959
+ )
5960
+ conn.commit()
5961
+
5962
+
5963
+ @cache_migration("032_codex_canonical_reset_anchor")
5964
+ def _032_codex_canonical_reset_anchor(conn: sqlite3.Connection) -> None:
5965
+ """#416 spec §4.1/§4.2: backfill the tolerance-anchored canonical reset onto
5966
+ existing Codex quota history.
5967
+
5968
+ ``canonical_resets_at_utc`` itself is a plain column addition, so it lands
5969
+ through ``add_column_if_missing`` in ``_apply_cache_schema`` (the idempotent
5970
+ guard pattern — no marker, no version). This migration exists for the
5971
+ BACKFILL, which is a data-shape change and therefore does go through the
5972
+ framework, and it bumps the registry head so a steady-state install re-runs
5973
+ the version-gated schema apply and actually gains the column (the same
5974
+ mechanism 029 and 031 rely on).
5975
+
5976
+ Ordering is ``(source_path, line_offset)`` — the order the rollout walk
5977
+ itself visits bytes in — so the anchors this backfill establishes are the
5978
+ same ones a later ``cache-sync --rebuild`` re-derives, rather than a
5979
+ different-but-also-deterministic set.
5980
+
5981
+ Idempotent by construction: only rows whose anchor is still NULL are
5982
+ considered, and rows already carrying an anchor SEED the resolver, so a
5983
+ re-run cannot move an established anchor.
5984
+ """
5985
+ import _cctally_cache as cache_mod
5986
+
5987
+ cols = {
5988
+ str(row[1]) for row in conn.execute(
5989
+ "PRAGMA table_info(quota_window_snapshots)")
5990
+ }
5991
+ if "canonical_resets_at_utc" not in cols:
5992
+ # An older schema apply did not reach the column add (a legacy-shape
5993
+ # cache whose FTS early-return fires first). Nothing to backfill; the
5994
+ # readers' raw-reset fallback keeps behaviour identical.
5995
+ return
5996
+ resolver = cache_mod.CodexResetAnchorResolver(conn)
5997
+ updates: list[tuple[str, int]] = []
5998
+ for row in conn.execute(
5999
+ "SELECT id, source_root_key, observed_slot, logical_limit_key, "
6000
+ " window_minutes, resets_at_utc "
6001
+ " FROM quota_window_snapshots "
6002
+ " WHERE source = 'codex' AND canonical_resets_at_utc IS NULL "
6003
+ " AND source_root_key IS NOT NULL AND observed_slot IS NOT NULL "
6004
+ " ORDER BY source_path, line_offset, id"
6005
+ ).fetchall():
6006
+ anchor = resolver.resolve(
6007
+ source_root_key=row[1], observed_slot=row[2],
6008
+ logical_limit_key=row[3], window_minutes=row[4],
6009
+ resets_at_utc=row[5],
6010
+ )
6011
+ if anchor is not None:
6012
+ updates.append((anchor, int(row[0])))
6013
+ if updates:
6014
+ conn.executemany(
6015
+ "UPDATE quota_window_snapshots SET canonical_resets_at_utc = ? "
6016
+ "WHERE id = ?",
6017
+ updates,
6018
+ )
6019
+ conn.commit()
6020
+
6021
+
5856
6022
  # === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
5857
6023
 
5858
6024
  @stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
@@ -24,6 +24,7 @@ Spec: docs/superpowers/specs/2026-05-30-extract-diagnostics-cmd-design.md
24
24
  from __future__ import annotations
25
25
 
26
26
  import argparse
27
+ import contextlib
27
28
  import datetime as dt
28
29
  import fcntl
29
30
  import json
@@ -78,6 +79,53 @@ def _stats_ro_guarded():
78
79
  )
79
80
 
80
81
 
82
+ @contextlib.contextmanager
83
+ def _conversation_ro_guarded(*, timeout: float):
84
+ """Bounded read-only transcript handle participating in #415 recovery."""
85
+ path = pathlib.Path(_cctally_core.CONVERSATIONS_DB_PATH)
86
+ if not path.exists():
87
+ yield None
88
+ return
89
+ marker = path.with_name(f"{path.name}.repairing")
90
+ pending = path.with_name(f"{path.name}.quarantine-pending.json")
91
+ recovery = path.with_name(f"{path.name}.recovery.json")
92
+ maintenance = pathlib.Path(
93
+ _cctally_core.CONVERSATIONS_LOCK_MAINTENANCE_PATH
94
+ )
95
+ lock_fh = conn = None
96
+ try:
97
+ maintenance.parent.mkdir(parents=True, exist_ok=True)
98
+ lock_fh = open(maintenance, "a+")
99
+ try:
100
+ fcntl.flock(lock_fh, fcntl.LOCK_SH | fcntl.LOCK_NB)
101
+ except BlockingIOError:
102
+ yield None
103
+ return
104
+ if marker.exists() or pending.exists() or recovery.exists():
105
+ yield None
106
+ return
107
+ conn = sqlite3.connect(
108
+ path.resolve().as_uri() + "?mode=ro",
109
+ uri=True,
110
+ timeout=max(0.0, timeout),
111
+ )
112
+ if marker.exists() or pending.exists() or recovery.exists():
113
+ conn.close()
114
+ conn = None
115
+ yield None
116
+ return
117
+ yield conn
118
+ finally:
119
+ if conn is not None:
120
+ conn.close()
121
+ if lock_fh is not None:
122
+ try:
123
+ fcntl.flock(lock_fh, fcntl.LOCK_UN)
124
+ except OSError:
125
+ pass
126
+ lock_fh.close()
127
+
128
+
81
129
  def _journal_heal_incident(kind: str, name: str, now_utc: dt.datetime) -> dict:
82
130
  """One auto-heal artifact record for the doctor journal leg (§9). Parses the
83
131
  legacy ``%Y%m%dT%H%M%SZ`` or collision-safe rebuild
@@ -533,7 +581,7 @@ def _doctor_gather_state_impl(
533
581
  diagnostic command must never mutate user state.
534
582
 
535
583
  `deep=True` (CLI cmd_doctor only) additionally runs `PRAGMA
536
- quick_check(1)` on each DB (#279 S2 F5b); the dashboard/TUI callers
584
+ quick_check(1)` on each DB (#279 S2 F5b, #415); the dashboard/TUI callers
537
585
  stay `deep=False` — the rebuild loop calls the gather every rebuild
538
586
  and quick_check on a large cache.db costs seconds.
539
587
  """
@@ -867,15 +915,15 @@ def _doctor_gather_state_impl(
867
915
  try:
868
916
  if _cctally_core.CONVERSATIONS_DB_PATH.exists():
869
917
  # This gather also runs inside dashboard snapshot precompute. A
870
- # transcript writer may hold an exclusive SQLite lock, so use a
871
- # read-only zero-timeout probe: conversation health can degrade,
872
- # but it must never delay core snapshot freshness (#320).
873
- conv_uri = (
874
- _cctally_core.CONVERSATIONS_DB_PATH.resolve().as_uri()
875
- + "?mode=ro"
876
- )
877
- conn = sqlite3.connect(conv_uri, uri=True, timeout=0.0)
878
- try:
918
+ # transcript writer or recovery may hold an exclusive lock, so use
919
+ # the recovery-aware read-only zero-timeout probe: conversation
920
+ # health can degrade, but it must never delay core snapshot
921
+ # freshness (#320, #415).
922
+ with _conversation_ro_guarded(timeout=0.0) as conn:
923
+ if conn is None:
924
+ raise sqlite3.OperationalError(
925
+ "conversation store maintenance in progress"
926
+ )
879
927
  try:
880
928
  row = conn.execute("PRAGMA page_count").fetchone()
881
929
  if row and row[0] is not None:
@@ -916,8 +964,6 @@ def _doctor_gather_state_impl(
916
964
  conv_rollup_sync_in_progress = True
917
965
  except Exception:
918
966
  pass
919
- finally:
920
- conn.close()
921
967
  # Non-blocking flock probe: if a transcript writer/reingest holds the
922
968
  # conversations.db lock, the rollup may be mid-recompute → in progress. We
923
969
  # acquire LOCK_EX|LOCK_NB and immediately release; failure (held) is the
@@ -1063,11 +1109,15 @@ def _doctor_gather_state_impl(
1063
1109
 
1064
1110
  # ── Parse health (#279 S2 F5a) ───────────────────────────────────
1065
1111
  parse_health_claude = parse_health_codex = None
1112
+ # #416 review B4: the durable record that a torn Codex `auth.json` halted
1113
+ # ingest. Same cache_meta read, same degrade-to-None-on-anything contract.
1114
+ codex_torn_deferred = None
1066
1115
  try:
1067
1116
  if _cache_probe_allowed and _cctally_core.CACHE_DB_PATH.exists():
1068
1117
  conn = sqlite3.connect(str(_cctally_core.CACHE_DB_PATH))
1069
1118
  try:
1070
- for _key in ("parse_health_claude", "parse_health_codex"):
1119
+ for _key in ("parse_health_claude", "parse_health_codex",
1120
+ "codex_torn_auth_deferred"):
1071
1121
  try:
1072
1122
  row = conn.execute(
1073
1123
  "SELECT value FROM cache_meta WHERE key = ?",
@@ -1078,8 +1128,10 @@ def _doctor_gather_state_impl(
1078
1128
  if isinstance(_parsed, dict):
1079
1129
  if _key == "parse_health_claude":
1080
1130
  parse_health_claude = _parsed
1081
- else:
1131
+ elif _key == "parse_health_codex":
1082
1132
  parse_health_codex = _parsed
1133
+ else:
1134
+ codex_torn_deferred = _parsed
1083
1135
  except (sqlite3.OperationalError, ValueError):
1084
1136
  pass
1085
1137
  finally:
@@ -1089,13 +1141,24 @@ def _doctor_gather_state_impl(
1089
1141
 
1090
1142
  # ── Integrity (deep only — #279 S2 F5b) ──────────────────────────
1091
1143
  stats_db_quick_check = cache_db_quick_check = None
1144
+ conversations_db_quick_check = None
1092
1145
  if deep:
1093
1146
  for _label, _path in (("stats", _cctally_core.DB_PATH),
1094
- ("cache", _cctally_core.CACHE_DB_PATH)):
1147
+ ("cache", _cctally_core.CACHE_DB_PATH),
1148
+ ("conversations",
1149
+ _cctally_core.CONVERSATIONS_DB_PATH)):
1095
1150
  _result = None
1096
1151
  try:
1097
1152
  if (
1098
- _path.exists()
1153
+ (
1154
+ _path.exists()
1155
+ or (
1156
+ _label == "conversations"
1157
+ and _path.with_name(
1158
+ f"{_path.name}.recovery.json"
1159
+ ).exists()
1160
+ )
1161
+ )
1099
1162
  and (_label != "cache" or _cache_probe_allowed)
1100
1163
  ):
1101
1164
  # #386: the stats leg holds a read-write handle for the whole
@@ -1104,24 +1167,40 @@ def _doctor_gather_state_impl(
1104
1167
  # protocol. The cache leg keeps its own opener.
1105
1168
  if _label == "stats":
1106
1169
  import _cctally_store as _store_mod
1107
- _conn = _store_mod.stats_open_guarded(_path)
1170
+ _conn_ctx = contextlib.closing(
1171
+ _store_mod.stats_open_guarded(_path)
1172
+ )
1173
+ elif _label == "cache":
1174
+ _conn_ctx = contextlib.closing(
1175
+ sqlite3.connect(str(_path))
1176
+ )
1108
1177
  else:
1109
- _conn = sqlite3.connect(str(_path))
1110
- try:
1111
- _row = _conn.execute(
1112
- "PRAGMA quick_check(1)").fetchone()
1113
- _result = (str(_row[0])
1114
- if _row and _row[0] is not None else None)
1115
- finally:
1116
- _conn.close()
1178
+ _conn_ctx = _conversation_ro_guarded(timeout=2.0)
1179
+ with _conn_ctx as _conn:
1180
+ if _conn is not None:
1181
+ _row = _conn.execute(
1182
+ "PRAGMA quick_check(1)").fetchone()
1183
+ _result = (
1184
+ str(_row[0])
1185
+ if _row and _row[0] is not None else None
1186
+ )
1187
+ elif (
1188
+ _label == "conversations"
1189
+ and _path.with_name(
1190
+ f"{_path.name}.recovery.json"
1191
+ ).exists()
1192
+ ):
1193
+ _result = "recovery in progress"
1117
1194
  except sqlite3.DatabaseError as exc:
1118
1195
  _result = f"open failed: {exc}"
1119
1196
  except Exception:
1120
1197
  _result = None
1121
1198
  if _label == "stats":
1122
1199
  stats_db_quick_check = _result
1123
- else:
1200
+ elif _label == "cache":
1124
1201
  cache_db_quick_check = _result
1202
+ else:
1203
+ conversations_db_quick_check = _result
1125
1204
 
1126
1205
  # ── Lock state (#279 S2 F5c) — read-only: never create files ─────
1127
1206
  locks_held: "dict | None" = None
@@ -1627,8 +1706,10 @@ def _doctor_gather_state_impl(
1627
1706
  # non-blocking lock-file probes (appended after the defaulted tail).
1628
1707
  parse_health_claude=parse_health_claude,
1629
1708
  parse_health_codex=parse_health_codex,
1709
+ codex_torn_deferred=codex_torn_deferred,
1630
1710
  stats_db_quick_check=stats_db_quick_check,
1631
1711
  cache_db_quick_check=cache_db_quick_check,
1712
+ conversations_db_quick_check=conversations_db_quick_check,
1632
1713
  locks_held=locks_held,
1633
1714
  # #297: cache.db WAL size backstop (gathered outside the deep branch).
1634
1715
  cache_db_wal_bytes=cache_db_wal_bytes,