cctally 1.83.0 → 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
@@ -130,7 +178,11 @@ def _gather_backup_sync_state(
130
178
  run=subprocess.run,
131
179
  ) -> dict:
132
180
  """Classify known macOS file-level backup/sync coverage without mutation."""
133
- platform_name = platform_name or sys.platform
181
+ platform_name = (
182
+ platform_name
183
+ or os.environ.get("CCTALLY_DOCTOR_FIXTURE_PLATFORM")
184
+ or sys.platform
185
+ )
134
186
  if platform_name != "darwin":
135
187
  return {"status": "unsupported", "provider": None}
136
188
 
@@ -529,7 +581,7 @@ def _doctor_gather_state_impl(
529
581
  diagnostic command must never mutate user state.
530
582
 
531
583
  `deep=True` (CLI cmd_doctor only) additionally runs `PRAGMA
532
- 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
533
585
  stay `deep=False` — the rebuild loop calls the gather every rebuild
534
586
  and quick_check on a large cache.db costs seconds.
535
587
  """
@@ -863,15 +915,15 @@ def _doctor_gather_state_impl(
863
915
  try:
864
916
  if _cctally_core.CONVERSATIONS_DB_PATH.exists():
865
917
  # This gather also runs inside dashboard snapshot precompute. A
866
- # transcript writer may hold an exclusive SQLite lock, so use a
867
- # read-only zero-timeout probe: conversation health can degrade,
868
- # but it must never delay core snapshot freshness (#320).
869
- conv_uri = (
870
- _cctally_core.CONVERSATIONS_DB_PATH.resolve().as_uri()
871
- + "?mode=ro"
872
- )
873
- conn = sqlite3.connect(conv_uri, uri=True, timeout=0.0)
874
- 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
+ )
875
927
  try:
876
928
  row = conn.execute("PRAGMA page_count").fetchone()
877
929
  if row and row[0] is not None:
@@ -912,8 +964,6 @@ def _doctor_gather_state_impl(
912
964
  conv_rollup_sync_in_progress = True
913
965
  except Exception:
914
966
  pass
915
- finally:
916
- conn.close()
917
967
  # Non-blocking flock probe: if a transcript writer/reingest holds the
918
968
  # conversations.db lock, the rollup may be mid-recompute → in progress. We
919
969
  # acquire LOCK_EX|LOCK_NB and immediately release; failure (held) is the
@@ -1059,11 +1109,15 @@ def _doctor_gather_state_impl(
1059
1109
 
1060
1110
  # ── Parse health (#279 S2 F5a) ───────────────────────────────────
1061
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
1062
1115
  try:
1063
1116
  if _cache_probe_allowed and _cctally_core.CACHE_DB_PATH.exists():
1064
1117
  conn = sqlite3.connect(str(_cctally_core.CACHE_DB_PATH))
1065
1118
  try:
1066
- for _key in ("parse_health_claude", "parse_health_codex"):
1119
+ for _key in ("parse_health_claude", "parse_health_codex",
1120
+ "codex_torn_auth_deferred"):
1067
1121
  try:
1068
1122
  row = conn.execute(
1069
1123
  "SELECT value FROM cache_meta WHERE key = ?",
@@ -1074,8 +1128,10 @@ def _doctor_gather_state_impl(
1074
1128
  if isinstance(_parsed, dict):
1075
1129
  if _key == "parse_health_claude":
1076
1130
  parse_health_claude = _parsed
1077
- else:
1131
+ elif _key == "parse_health_codex":
1078
1132
  parse_health_codex = _parsed
1133
+ else:
1134
+ codex_torn_deferred = _parsed
1079
1135
  except (sqlite3.OperationalError, ValueError):
1080
1136
  pass
1081
1137
  finally:
@@ -1085,13 +1141,24 @@ def _doctor_gather_state_impl(
1085
1141
 
1086
1142
  # ── Integrity (deep only — #279 S2 F5b) ──────────────────────────
1087
1143
  stats_db_quick_check = cache_db_quick_check = None
1144
+ conversations_db_quick_check = None
1088
1145
  if deep:
1089
1146
  for _label, _path in (("stats", _cctally_core.DB_PATH),
1090
- ("cache", _cctally_core.CACHE_DB_PATH)):
1147
+ ("cache", _cctally_core.CACHE_DB_PATH),
1148
+ ("conversations",
1149
+ _cctally_core.CONVERSATIONS_DB_PATH)):
1091
1150
  _result = None
1092
1151
  try:
1093
1152
  if (
1094
- _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
+ )
1095
1162
  and (_label != "cache" or _cache_probe_allowed)
1096
1163
  ):
1097
1164
  # #386: the stats leg holds a read-write handle for the whole
@@ -1100,24 +1167,40 @@ def _doctor_gather_state_impl(
1100
1167
  # protocol. The cache leg keeps its own opener.
1101
1168
  if _label == "stats":
1102
1169
  import _cctally_store as _store_mod
1103
- _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
+ )
1104
1177
  else:
1105
- _conn = sqlite3.connect(str(_path))
1106
- try:
1107
- _row = _conn.execute(
1108
- "PRAGMA quick_check(1)").fetchone()
1109
- _result = (str(_row[0])
1110
- if _row and _row[0] is not None else None)
1111
- finally:
1112
- _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"
1113
1194
  except sqlite3.DatabaseError as exc:
1114
1195
  _result = f"open failed: {exc}"
1115
1196
  except Exception:
1116
1197
  _result = None
1117
1198
  if _label == "stats":
1118
1199
  stats_db_quick_check = _result
1119
- else:
1200
+ elif _label == "cache":
1120
1201
  cache_db_quick_check = _result
1202
+ else:
1203
+ conversations_db_quick_check = _result
1121
1204
 
1122
1205
  # ── Lock state (#279 S2 F5c) — read-only: never create files ─────
1123
1206
  locks_held: "dict | None" = None
@@ -1623,8 +1706,10 @@ def _doctor_gather_state_impl(
1623
1706
  # non-blocking lock-file probes (appended after the defaulted tail).
1624
1707
  parse_health_claude=parse_health_claude,
1625
1708
  parse_health_codex=parse_health_codex,
1709
+ codex_torn_deferred=codex_torn_deferred,
1626
1710
  stats_db_quick_check=stats_db_quick_check,
1627
1711
  cache_db_quick_check=cache_db_quick_check,
1712
+ conversations_db_quick_check=conversations_db_quick_check,
1628
1713
  locks_held=locks_held,
1629
1714
  # #297: cache.db WAL size backstop (gathered outside the deep branch).
1630
1715
  cache_db_wal_bytes=cache_db_wal_bytes,