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.
- package/CHANGELOG.md +34 -0
- package/README.md +2 -4
- package/bin/_cctally_account.py +144 -11
- package/bin/_cctally_alerts.py +3 -1
- package/bin/_cctally_cache.py +1396 -33
- package/bin/_cctally_dashboard.py +46 -2
- package/bin/_cctally_dashboard_conversation.py +16 -7
- package/bin/_cctally_dashboard_envelope.py +12 -0
- package/bin/_cctally_dashboard_share.py +58 -5
- package/bin/_cctally_dashboard_sources.py +641 -38
- package/bin/_cctally_db.py +173 -7
- package/bin/_cctally_doctor.py +112 -27
- package/bin/_cctally_journal.py +761 -74
- package/bin/_cctally_milestone_history.py +73 -16
- package/bin/_cctally_quota.py +313 -15
- package/bin/_cctally_source_analytics.py +4 -1
- package/bin/_cctally_tui.py +7 -3
- package/bin/_lib_doctor.py +67 -2
- package/bin/_lib_journal.py +48 -0
- package/bin/_lib_jsonl.py +119 -0
- package/bin/_lib_quota.py +222 -13
- package/bin/_lib_rederive.py +12 -0
- package/bin/_lib_source_analytics.py +13 -0
- package/bin/_lib_source_identity.py +24 -0
- package/dashboard/static/assets/index-DlVVJeS4.js +92 -0
- package/dashboard/static/assets/index-OYBkyglj.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-3bgCMVHb.js +0 -92
- package/dashboard/static/assets/index-D27EIHEI.css +0 -1
package/bin/_cctally_db.py
CHANGED
|
@@ -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.
|
|
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:{
|
|
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
|
|
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")
|
package/bin/_cctally_doctor.py
CHANGED
|
@@ -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 =
|
|
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
|
|
867
|
-
# read-only zero-timeout probe: conversation
|
|
868
|
-
# but it must never delay core snapshot
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
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
|
-
|
|
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,
|