cctally 1.79.2 → 1.80.1
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 +25 -0
- package/bin/_cctally_cache.py +148 -61
- package/bin/_cctally_config.py +23 -6
- package/bin/_cctally_core.py +342 -88
- package/bin/_cctally_dashboard.py +13 -7
- package/bin/_cctally_db.py +361 -36
- package/bin/_cctally_doctor.py +156 -0
- package/bin/_cctally_five_hour.py +19 -7
- package/bin/_cctally_forecast.py +43 -18
- package/bin/_cctally_journal.py +2148 -0
- package/bin/_cctally_milestones.py +116 -26
- package/bin/_cctally_parser.py +21 -3
- package/bin/_cctally_quota.py +195 -76
- package/bin/_cctally_record.py +1596 -1133
- package/bin/_cctally_statusline.py +13 -1
- package/bin/_cctally_store.py +508 -0
- package/bin/_cctally_sync_week.py +139 -2
- package/bin/_lib_doctor.py +209 -12
- package/bin/_lib_journal.py +188 -0
- package/bin/cctally +11 -1
- package/dashboard/static/assets/index-Cc2ykqF_.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-CrIlpAlQ.css +0 -1
- /package/dashboard/static/assets/{index-BDkr0bdD.js → index-B3y14l1o.js} +0 -0
package/bin/_cctally_db.py
CHANGED
|
@@ -196,6 +196,10 @@ class Migration:
|
|
|
196
196
|
|
|
197
197
|
_STATS_MIGRATIONS: list[Migration] = []
|
|
198
198
|
_CACHE_MIGRATIONS: list[Migration] = []
|
|
199
|
+
# conversations.db joins the framework (DB journal redesign spec §7.2). Shared
|
|
200
|
+
# list object — re-exported by ``bin/cctally`` + ``bin/_cctally_cache`` the same
|
|
201
|
+
# way as ``_CACHE_MIGRATIONS`` (mutate in place, never rebind).
|
|
202
|
+
_CONVERSATIONS_MIGRATIONS: list[Migration] = []
|
|
199
203
|
|
|
200
204
|
|
|
201
205
|
class DowngradeDetected(Exception):
|
|
@@ -267,6 +271,15 @@ class StatsDbMaintenanceError(sqlite3.OperationalError):
|
|
|
267
271
|
)
|
|
268
272
|
|
|
269
273
|
|
|
274
|
+
class StatsEpochMismatchError(sqlite3.DatabaseError):
|
|
275
|
+
"""A stats.db at a version-mismatched index epoch that CANNOT be rebuilt
|
|
276
|
+
(DB journal redesign §7.1). Raised when ``user_version`` is neither legacy
|
|
277
|
+
(<= 13) nor the current ``STATS_INDEX_EPOCH`` AND no journal is present to
|
|
278
|
+
rebuild from — a hard error with guidance, never a silent rebuild-to-empty.
|
|
279
|
+
Subclasses ``sqlite3.DatabaseError`` so graceful-degrade sites treat it as a
|
|
280
|
+
DB failure; ``main()`` maps it to a staged exit 3."""
|
|
281
|
+
|
|
282
|
+
|
|
270
283
|
_SQLITE_CORRUPTION_MESSAGES = (
|
|
271
284
|
"database disk image is malformed",
|
|
272
285
|
"file is not a database",
|
|
@@ -486,6 +499,22 @@ def cache_migration(name: str):
|
|
|
486
499
|
return _make_migration_decorator(_CACHE_MIGRATIONS, "cache.db", name)
|
|
487
500
|
|
|
488
501
|
|
|
502
|
+
def conversations_migration(name: str):
|
|
503
|
+
"""Register a conversations.db migration (DB journal redesign spec §7.2).
|
|
504
|
+
Use as @conversations_migration("NNN_descriptive_name").
|
|
505
|
+
|
|
506
|
+
HANDLER CONTRACT — idempotency is MANDATORY. See ``stats_migration`` above
|
|
507
|
+
for the full statement: the dispatcher's handler-commit → marker-stamp
|
|
508
|
+
two-transaction window means a crash (or a ``MigrationGateNotMet`` deferral)
|
|
509
|
+
re-invokes the handler on its own output, so every handler must be safe to
|
|
510
|
+
re-run (probe-then-return / ``INSERT OR IGNORE`` / not-yet-migrated guards).
|
|
511
|
+
conversations.db is re-derivable from provider JSONL, so ``open_conversations_db``
|
|
512
|
+
runs the dispatcher with ``recover_version_ahead=True`` (like cache.db).
|
|
513
|
+
"""
|
|
514
|
+
return _make_migration_decorator(
|
|
515
|
+
_CONVERSATIONS_MIGRATIONS, "conversations.db", name)
|
|
516
|
+
|
|
517
|
+
|
|
489
518
|
# Pre-framework migration markers were stored under unprefixed names;
|
|
490
519
|
# the dispatcher's bootstrap rename rewrites them to NNN_ form on the
|
|
491
520
|
# first open_db() that runs the framework. Raw-sqlite3 db commands
|
|
@@ -587,6 +616,253 @@ def _would_block_prod_migration(conn: sqlite3.Connection) -> bool:
|
|
|
587
616
|
return False
|
|
588
617
|
|
|
589
618
|
|
|
619
|
+
def _would_block_prod_stats(path: pathlib.Path) -> bool:
|
|
620
|
+
"""Path-based sibling of ``_would_block_prod_migration`` for the stats.db
|
|
621
|
+
classifier-gated auto-heal + ``db rebuild --db stats`` (spec §6.3, issue
|
|
622
|
+
#146): True iff a git-checkout binary would quarantine+rebuild a stats.db that
|
|
623
|
+
physically lives in the REAL prod data dir. Password-DB-resolved (HOME-faking
|
|
624
|
+
immune), suppressor-independent raw ``.git`` check. Escape:
|
|
625
|
+
``CCTALLY_ALLOW_PROD_MIGRATION``."""
|
|
626
|
+
if _cctally_core._truthy_env("CCTALLY_ALLOW_PROD_MIGRATION"):
|
|
627
|
+
return False
|
|
628
|
+
if not (_cctally_core._repo_root() / ".git").exists():
|
|
629
|
+
return False
|
|
630
|
+
try:
|
|
631
|
+
return (
|
|
632
|
+
pathlib.Path(path).resolve().parent
|
|
633
|
+
== _cctally_core._real_prod_data_dir().resolve()
|
|
634
|
+
)
|
|
635
|
+
except OSError:
|
|
636
|
+
return False
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
# Known external cloud-sync / backup daemons that hold a DB file open and are the
|
|
640
|
+
# leading suspects for the region-shifting prod corruption (#336 defense 2). The
|
|
641
|
+
# forensics scan flags any running process whose name matches, so the auto-heal
|
|
642
|
+
# incident finally captures the culprit rather than only the damage.
|
|
643
|
+
_CORRUPTION_HOLDER_HINTS = (
|
|
644
|
+
"backupd", "bird", "cloudd", "Dropbox", "GoogleDrive", "OneDrive",
|
|
645
|
+
"FileProvider", "CloudDocs", "Box", "pCloud",
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _forensics_iso(epoch_or_now) -> str:
|
|
650
|
+
if isinstance(epoch_or_now, (int, float)):
|
|
651
|
+
d = dt.datetime.fromtimestamp(epoch_or_now, dt.timezone.utc)
|
|
652
|
+
else:
|
|
653
|
+
d = epoch_or_now
|
|
654
|
+
return d.isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def write_corruption_forensics(
|
|
658
|
+
db_path, *, db_label: str = "stats",
|
|
659
|
+
) -> "pathlib.Path | None":
|
|
660
|
+
"""Write the #336-defense-2 corruption forensics bundle FIRST — before any
|
|
661
|
+
quarantine/rebuild disturbs the evidence (spec §6.3). Captures the file family
|
|
662
|
+
sizes+mtimes, a best-effort ``PRAGMA integrity_check``, an ``lsof`` of the
|
|
663
|
+
family, and a scan of running processes for known cloud-sync/backup holders.
|
|
664
|
+
Every leg degrades to a captured error string rather than raising, so a heal
|
|
665
|
+
never fails because forensics could not fully run. Returns the bundle path (or
|
|
666
|
+
None if it could not be written). Shared by the auto-heal HEAL_HOOK and
|
|
667
|
+
``db rebuild`` (and, later, doctor's incident leg)."""
|
|
668
|
+
db_path = pathlib.Path(db_path)
|
|
669
|
+
ts = _db_backup_timestamp()
|
|
670
|
+
try:
|
|
671
|
+
_cctally_core.LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
672
|
+
except OSError:
|
|
673
|
+
pass
|
|
674
|
+
out = _cctally_core.LOG_DIR / f"{db_path.name}-corruption-forensics-{ts}.json"
|
|
675
|
+
bundle: dict = {
|
|
676
|
+
"schemaVersion": 1,
|
|
677
|
+
"capturedAtUtc": _forensics_iso(dt.datetime.now(dt.timezone.utc)),
|
|
678
|
+
"db": db_label,
|
|
679
|
+
"path": str(db_path),
|
|
680
|
+
"family": {},
|
|
681
|
+
"integrityCheck": None,
|
|
682
|
+
"lsof": None,
|
|
683
|
+
"holders": [],
|
|
684
|
+
}
|
|
685
|
+
for suffix in ("", "-wal", "-shm"):
|
|
686
|
+
p = pathlib.Path(str(db_path) + suffix)
|
|
687
|
+
try:
|
|
688
|
+
st = p.stat()
|
|
689
|
+
bundle["family"][p.name] = {
|
|
690
|
+
"bytes": st.st_size, "mtimeUtc": _forensics_iso(st.st_mtime),
|
|
691
|
+
}
|
|
692
|
+
except OSError:
|
|
693
|
+
bundle["family"][p.name] = None
|
|
694
|
+
try:
|
|
695
|
+
c = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
|
696
|
+
try:
|
|
697
|
+
c.execute("PRAGMA busy_timeout=2000")
|
|
698
|
+
bundle["integrityCheck"] = [
|
|
699
|
+
r[0] for r in c.execute("PRAGMA integrity_check").fetchall()
|
|
700
|
+
]
|
|
701
|
+
finally:
|
|
702
|
+
c.close()
|
|
703
|
+
except Exception as exc:
|
|
704
|
+
bundle["integrityCheck"] = f"error: {exc}"
|
|
705
|
+
try:
|
|
706
|
+
cp = subprocess.run(
|
|
707
|
+
["lsof", "--", str(db_path)],
|
|
708
|
+
capture_output=True, text=True, timeout=5,
|
|
709
|
+
)
|
|
710
|
+
bundle["lsof"] = cp.stdout or cp.stderr or ""
|
|
711
|
+
except Exception as exc:
|
|
712
|
+
bundle["lsof"] = f"error: {exc}"
|
|
713
|
+
try:
|
|
714
|
+
cp = subprocess.run(
|
|
715
|
+
["ps", "-Axo", "pid=,comm="],
|
|
716
|
+
capture_output=True, text=True, timeout=5,
|
|
717
|
+
)
|
|
718
|
+
for line in (cp.stdout or "").splitlines():
|
|
719
|
+
low = line.lower()
|
|
720
|
+
if any(h.lower() in low for h in _CORRUPTION_HOLDER_HINTS):
|
|
721
|
+
bundle["holders"].append(line.strip())
|
|
722
|
+
except Exception as exc:
|
|
723
|
+
bundle["holders"] = [f"error: {exc}"]
|
|
724
|
+
try:
|
|
725
|
+
out.write_text(json.dumps(bundle, indent=2, sort_keys=True))
|
|
726
|
+
try:
|
|
727
|
+
os.chmod(out, 0o600)
|
|
728
|
+
except OSError:
|
|
729
|
+
pass
|
|
730
|
+
except OSError:
|
|
731
|
+
return None
|
|
732
|
+
return out
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def quarantine_db_family(db_path, *, ts: "str | None" = None) -> pathlib.Path:
|
|
736
|
+
"""Move a damaged DB + its ``-wal``/``-shm`` sidecars into a single
|
|
737
|
+
timestamped incident directory under ``quarantine/`` with a manifest (spec
|
|
738
|
+
§6.3). NEVER deletes evidence — three renames under the caller's exclusion
|
|
739
|
+
locks, not pretending to be one atomic op. ``0o700`` dir / ``0o600`` files.
|
|
740
|
+
Returns the incident directory (which may be empty if nothing was present)."""
|
|
741
|
+
db_path = pathlib.Path(db_path)
|
|
742
|
+
ts = ts or _db_backup_timestamp()
|
|
743
|
+
root = _cctally_core.APP_DIR / "quarantine"
|
|
744
|
+
incident = root / f"{db_path.name}-{ts}"
|
|
745
|
+
incident.mkdir(parents=True, exist_ok=True)
|
|
746
|
+
for d in (root, incident):
|
|
747
|
+
try:
|
|
748
|
+
os.chmod(d, 0o700)
|
|
749
|
+
except OSError:
|
|
750
|
+
pass
|
|
751
|
+
moved: list = []
|
|
752
|
+
for suffix in ("", "-wal", "-shm"):
|
|
753
|
+
src = pathlib.Path(str(db_path) + suffix)
|
|
754
|
+
if not src.exists():
|
|
755
|
+
continue
|
|
756
|
+
dst = incident / src.name
|
|
757
|
+
try:
|
|
758
|
+
os.replace(str(src), str(dst))
|
|
759
|
+
try:
|
|
760
|
+
os.chmod(dst, 0o600)
|
|
761
|
+
except OSError:
|
|
762
|
+
pass
|
|
763
|
+
moved.append(src.name)
|
|
764
|
+
except OSError as exc:
|
|
765
|
+
eprint(f"[quarantine] could not move {src}: {exc}")
|
|
766
|
+
manifest = {
|
|
767
|
+
"schemaVersion": 1,
|
|
768
|
+
"quarantinedAtUtc": _forensics_iso(dt.datetime.now(dt.timezone.utc)),
|
|
769
|
+
"originalPath": str(db_path),
|
|
770
|
+
"movedFiles": moved,
|
|
771
|
+
}
|
|
772
|
+
try:
|
|
773
|
+
(incident / "manifest.json").write_text(
|
|
774
|
+
json.dumps(manifest, indent=2, sort_keys=True))
|
|
775
|
+
except OSError:
|
|
776
|
+
pass
|
|
777
|
+
return incident
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def cmd_db_rebuild(args: argparse.Namespace) -> int:
|
|
781
|
+
"""``db rebuild --db stats`` — explicit journal replay into a fresh index
|
|
782
|
+
(spec §9). Forensics-quarantines the current stats.db the SAME forensics-first
|
|
783
|
+
way auto-heal does (even when the DB is healthy — this is an operator's
|
|
784
|
+
deliberate reset), then rebuilds from the journal and reports counts +
|
|
785
|
+
duration. Held under the stats maintenance lock so it serializes with a
|
|
786
|
+
concurrent auto-heal. #146 prod guard: a dev/worktree binary refuses to
|
|
787
|
+
rebuild the real prod stats.db unless ``CCTALLY_ALLOW_PROD_MIGRATION=1``.
|
|
788
|
+
Exit 0 on success, 2 for the prod guard, 3 for a rebuild failure."""
|
|
789
|
+
from _lib_json_envelope import stamp_schema_version
|
|
790
|
+
import _cctally_journal
|
|
791
|
+
|
|
792
|
+
as_json = bool(getattr(args, "json", False))
|
|
793
|
+
path = _cctally_core.DB_PATH
|
|
794
|
+
if _would_block_prod_stats(path):
|
|
795
|
+
eprint(
|
|
796
|
+
"cctally: refusing to rebuild the prod stats.db "
|
|
797
|
+
"(~/.local/share/cctally) from a dev checkout. Run the installed "
|
|
798
|
+
"binary, or override with CCTALLY_ALLOW_PROD_MIGRATION=1."
|
|
799
|
+
)
|
|
800
|
+
return 2
|
|
801
|
+
|
|
802
|
+
_cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
803
|
+
maint_fd = os.open(
|
|
804
|
+
str(_cctally_core.STATS_LOCK_MAINTENANCE_PATH),
|
|
805
|
+
os.O_RDWR | os.O_CREAT, 0o600,
|
|
806
|
+
)
|
|
807
|
+
forensics = None
|
|
808
|
+
incident = None
|
|
809
|
+
try:
|
|
810
|
+
fcntl.flock(maint_fd, fcntl.LOCK_EX)
|
|
811
|
+
import _cctally_store
|
|
812
|
+
# Symmetry with the auto-heal path (Task-8 P3-3): take the bounded ingest
|
|
813
|
+
# lock so a concurrent ingest cycle can't write into the stats.db we are
|
|
814
|
+
# about to quarantine + rebuild. Journal-is-truth makes this
|
|
815
|
+
# correctness-neutral, but keeping the two rebuild paths identical avoids
|
|
816
|
+
# a latent write-into-quarantined-inode surprise.
|
|
817
|
+
ingest_fd = _cctally_store._heal_flock_bounded(
|
|
818
|
+
_cctally_core.JOURNAL_INGEST_LOCK_PATH, 5.0)
|
|
819
|
+
try:
|
|
820
|
+
if path.exists():
|
|
821
|
+
forensics = write_corruption_forensics(path, db_label="stats")
|
|
822
|
+
incident = quarantine_db_family(path)
|
|
823
|
+
result = _cctally_journal.rebuild_stats_index()
|
|
824
|
+
except Exception as exc:
|
|
825
|
+
eprint(f"cctally: stats.db rebuild failed: {exc}")
|
|
826
|
+
return 3
|
|
827
|
+
finally:
|
|
828
|
+
_cctally_store._heal_release_flock(ingest_fd)
|
|
829
|
+
finally:
|
|
830
|
+
try:
|
|
831
|
+
fcntl.flock(maint_fd, fcntl.LOCK_UN)
|
|
832
|
+
finally:
|
|
833
|
+
os.close(maint_fd)
|
|
834
|
+
|
|
835
|
+
total_rows = sum(result.rows_by_table.values())
|
|
836
|
+
if as_json:
|
|
837
|
+
payload = {
|
|
838
|
+
"db": "stats",
|
|
839
|
+
"segmentsRead": result.segments_read,
|
|
840
|
+
"linesFolded": result.lines_folded,
|
|
841
|
+
"malformed": result.malformed,
|
|
842
|
+
"durationSeconds": round(result.duration_s, 3),
|
|
843
|
+
"rowsByTable": result.rows_by_table,
|
|
844
|
+
"totalRows": total_rows,
|
|
845
|
+
"quarantineDir": str(incident) if incident else None,
|
|
846
|
+
"forensicsPath": str(forensics) if forensics else None,
|
|
847
|
+
}
|
|
848
|
+
print(json.dumps(stamp_schema_version(payload, version=1)))
|
|
849
|
+
else:
|
|
850
|
+
print(
|
|
851
|
+
f"cctally: rebuilt stats.db from {result.segments_read} journal "
|
|
852
|
+
f"segment(s) — {result.lines_folded} lines folded, "
|
|
853
|
+
f"{result.malformed} malformed, {total_rows} rows, "
|
|
854
|
+
f"{result.duration_s:.2f}s."
|
|
855
|
+
)
|
|
856
|
+
for tbl, n in result.rows_by_table.items():
|
|
857
|
+
if n:
|
|
858
|
+
print(f" {tbl}: {n}")
|
|
859
|
+
if incident is not None:
|
|
860
|
+
print(f" previous stats.db quarantined -> {incident}")
|
|
861
|
+
if forensics is not None:
|
|
862
|
+
print(f" forensics -> {forensics}")
|
|
863
|
+
return 0
|
|
864
|
+
|
|
865
|
+
|
|
590
866
|
def _first_pending_migration_name(
|
|
591
867
|
conn: sqlite3.Connection, registry: "list[Migration]", cur_version: int
|
|
592
868
|
) -> str:
|
|
@@ -934,6 +1210,15 @@ def _run_pending_migrations(
|
|
|
934
1210
|
"session_entries", "conversation_messages", "codex_session_entries",
|
|
935
1211
|
"codex_conversation_events",
|
|
936
1212
|
),
|
|
1213
|
+
# conversations.db (spec §7.2): a transcript-bearing store written
|
|
1214
|
+
# before the framework existed carries the marker but no
|
|
1215
|
+
# schema_migrations. Probing its data tables classifies it NON-fresh
|
|
1216
|
+
# so migration 001's handler runs (adopts the marker) instead of
|
|
1217
|
+
# being stamped without it — the difference between adopt and skip.
|
|
1218
|
+
"conversations.db": (
|
|
1219
|
+
"conversation_messages", "conversation_source_files",
|
|
1220
|
+
"codex_conversation_source_files",
|
|
1221
|
+
),
|
|
937
1222
|
}.get(db_label, ())
|
|
938
1223
|
for probe_table in probe_tables:
|
|
939
1224
|
# _probe_table_nonempty centralizes the "is there data here?"
|
|
@@ -3106,6 +3391,28 @@ def _set_cache_meta(conn: sqlite3.Connection, key: str, value: str) -> None:
|
|
|
3106
3391
|
"ON CONFLICT(key) DO UPDATE SET value=excluded.value", (key, value))
|
|
3107
3392
|
|
|
3108
3393
|
|
|
3394
|
+
@conversations_migration("001_adopt_schema_version_marker")
|
|
3395
|
+
def _conv_001_adopt_schema_version_marker(conn: sqlite3.Connection) -> None:
|
|
3396
|
+
"""Bring conversations.db under the migration framework (spec §7.2).
|
|
3397
|
+
|
|
3398
|
+
Adoption, not migration: ``_apply_conversations_schema`` (run before the
|
|
3399
|
+
dispatcher in ``open_conversations_db``) already owns the physical schema and
|
|
3400
|
+
the ``cache_meta['conversation_schema_version']`` marker. On an EXISTING
|
|
3401
|
+
populated DB the store's version gate keeps ``_apply_conversations_schema``
|
|
3402
|
+
from re-running the schema (its own marker short-circuit), and the
|
|
3403
|
+
dispatcher's data-emptiness probe (``conversation_messages`` /
|
|
3404
|
+
``conversation_source_files``) classifies the DB as NON-fresh so this handler
|
|
3405
|
+
runs and the dispatcher stamps the marker + advances ``user_version`` to 1
|
|
3406
|
+
WITHOUT touching a single transcript row. On a FRESH DB the dispatcher's
|
|
3407
|
+
fresh-install path stamps the marker without invoking this handler at all.
|
|
3408
|
+
|
|
3409
|
+
The handler is a self-contained, idempotent no-op-equivalent: it re-asserts
|
|
3410
|
+
the ``conversation_schema_version`` marker (an ``INSERT … ON CONFLICT`` upsert)
|
|
3411
|
+
so it is safe to re-run on its own output (handler-idempotency contract)."""
|
|
3412
|
+
_set_cache_meta(conn, "conversation_schema_version", "1")
|
|
3413
|
+
conn.commit()
|
|
3414
|
+
|
|
3415
|
+
|
|
3109
3416
|
# #177 S6: the consolidated multi-column external-content FTS5 table that
|
|
3110
3417
|
# replaces the old conversation_fts(text) + conversation_fts_aux(search_aux)
|
|
3111
3418
|
# pair. The three column names MUST match the conversation_messages columns BY
|
|
@@ -5911,6 +6218,22 @@ def _migration_codex_quota_projection_state(conn: sqlite3.Connection) -> None:
|
|
|
5911
6218
|
_cctally_core._apply_quota_projection_schema(conn)
|
|
5912
6219
|
|
|
5913
6220
|
|
|
6221
|
+
# ── Stats registry FROZEN at the legacy head (DB journal redesign §7.1) ──
|
|
6222
|
+
# stats.db moved to epoch-rebuild versioning (STATS_INDEX_EPOCH); NO stats
|
|
6223
|
+
# migration is ever written again — a schema change bumps the epoch instead. This
|
|
6224
|
+
# module-load assertion fires if a 14th @stats_migration lands, mirroring the
|
|
6225
|
+
# import-time contiguity check in `_make_migration_decorator`. It runs BEFORE the
|
|
6226
|
+
# test-injection block below (which registers a 14th ONLY under
|
|
6227
|
+
# CCTALLY_MIGRATION_TEST_MODE), so the production shape is asserted at exactly 13.
|
|
6228
|
+
STATS_REGISTRY_FROZEN_HEAD = 13
|
|
6229
|
+
assert len(_STATS_MIGRATIONS) == STATS_REGISTRY_FROZEN_HEAD, (
|
|
6230
|
+
f"stats registry is frozen at {STATS_REGISTRY_FROZEN_HEAD} (DB journal "
|
|
6231
|
+
f"redesign §7.1) but has {len(_STATS_MIGRATIONS)} migrations — a new "
|
|
6232
|
+
f"@stats_migration was added. Schema changes now bump STATS_INDEX_EPOCH; the "
|
|
6233
|
+
f"stats registry is frozen and must not grow."
|
|
6234
|
+
)
|
|
6235
|
+
|
|
6236
|
+
|
|
5914
6237
|
# === Region 8: Test-only migration registration (was bin/cctally:12086-12140) ===
|
|
5915
6238
|
|
|
5916
6239
|
# ──────────────────────────────────────────────────────────────────────
|
|
@@ -5966,12 +6289,16 @@ def cmd_db_status(args: argparse.Namespace) -> int:
|
|
|
5966
6289
|
"databases": {
|
|
5967
6290
|
"stats.db": _db_status_for(_cctally_core.DB_PATH, _STATS_MIGRATIONS, "stats.db"),
|
|
5968
6291
|
"cache.db": _db_status_for(_cctally_core.CACHE_DB_PATH, _CACHE_MIGRATIONS, "cache.db"),
|
|
6292
|
+
"conversations.db": _db_status_for(
|
|
6293
|
+
_cctally_core.CONVERSATIONS_DB_PATH, _CONVERSATIONS_MIGRATIONS,
|
|
6294
|
+
"conversations.db",
|
|
6295
|
+
),
|
|
5969
6296
|
},
|
|
5970
6297
|
}
|
|
5971
6298
|
if getattr(args, "json", False):
|
|
5972
6299
|
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
5973
6300
|
return 0
|
|
5974
|
-
for db_label in ("stats.db", "cache.db"):
|
|
6301
|
+
for db_label in ("stats.db", "cache.db", "conversations.db"):
|
|
5975
6302
|
info = payload["databases"][db_label]
|
|
5976
6303
|
skipped_count = sum(1 for m in info["migrations"] if m["status"] == "skipped")
|
|
5977
6304
|
suffix = f" ({skipped_count} skipped)" if skipped_count else ""
|
|
@@ -6143,14 +6470,22 @@ def _db_resolve_migration_name(name_arg: str) -> tuple[str, str, list[Migration]
|
|
|
6143
6470
|
if any(m.name == unq for m in _CACHE_MIGRATIONS):
|
|
6144
6471
|
return "cache.db", unq, _CACHE_MIGRATIONS
|
|
6145
6472
|
raise LookupError(name_arg)
|
|
6473
|
+
if name_arg.startswith("conversations.db:"):
|
|
6474
|
+
unq = name_arg[len("conversations.db:"):]
|
|
6475
|
+
if any(m.name == unq for m in _CONVERSATIONS_MIGRATIONS):
|
|
6476
|
+
return "conversations.db", unq, _CONVERSATIONS_MIGRATIONS
|
|
6477
|
+
raise LookupError(name_arg)
|
|
6146
6478
|
in_stats = any(m.name == name_arg for m in _STATS_MIGRATIONS)
|
|
6147
6479
|
in_cache = any(m.name == name_arg for m in _CACHE_MIGRATIONS)
|
|
6148
|
-
|
|
6480
|
+
in_conv = any(m.name == name_arg for m in _CONVERSATIONS_MIGRATIONS)
|
|
6481
|
+
if (in_stats + in_cache + in_conv) > 1:
|
|
6149
6482
|
raise RuntimeError(name_arg)
|
|
6150
6483
|
if in_stats:
|
|
6151
6484
|
return "stats.db", name_arg, _STATS_MIGRATIONS
|
|
6152
6485
|
if in_cache:
|
|
6153
6486
|
return "cache.db", name_arg, _CACHE_MIGRATIONS
|
|
6487
|
+
if in_conv:
|
|
6488
|
+
return "conversations.db", name_arg, _CONVERSATIONS_MIGRATIONS
|
|
6154
6489
|
raise LookupError(name_arg)
|
|
6155
6490
|
|
|
6156
6491
|
|
|
@@ -6159,6 +6494,8 @@ def _db_path_for_label(db_label: str) -> pathlib.Path:
|
|
|
6159
6494
|
return _cctally_core.DB_PATH
|
|
6160
6495
|
if db_label == "cache.db":
|
|
6161
6496
|
return _cctally_core.CACHE_DB_PATH
|
|
6497
|
+
if db_label == "conversations.db":
|
|
6498
|
+
return _cctally_core.CONVERSATIONS_DB_PATH
|
|
6162
6499
|
raise ValueError(f"unknown db_label: {db_label}")
|
|
6163
6500
|
|
|
6164
6501
|
|
|
@@ -6175,7 +6512,7 @@ def cmd_db_skip(args: argparse.Namespace) -> int:
|
|
|
6175
6512
|
except RuntimeError:
|
|
6176
6513
|
eprint(
|
|
6177
6514
|
f"cctally: ambiguous migration name '{name_arg}'; "
|
|
6178
|
-
f"qualify as 'stats.db:{name_arg}' or '
|
|
6515
|
+
f"qualify as 'stats.db:{name_arg}', 'cache.db:{name_arg}', or 'conversations.db:{name_arg}'"
|
|
6179
6516
|
)
|
|
6180
6517
|
return 2
|
|
6181
6518
|
except LookupError:
|
|
@@ -6256,7 +6593,7 @@ def cmd_db_unskip(args: argparse.Namespace) -> int:
|
|
|
6256
6593
|
except RuntimeError:
|
|
6257
6594
|
eprint(
|
|
6258
6595
|
f"cctally: ambiguous migration name '{name_arg}'; "
|
|
6259
|
-
f"qualify as 'stats.db:{name_arg}' or '
|
|
6596
|
+
f"qualify as 'stats.db:{name_arg}', 'cache.db:{name_arg}', or 'conversations.db:{name_arg}'"
|
|
6260
6597
|
)
|
|
6261
6598
|
return 2
|
|
6262
6599
|
except LookupError:
|
|
@@ -6306,10 +6643,21 @@ def cmd_db_recover(args: argparse.Namespace) -> int:
|
|
|
6306
6643
|
dispatcher. Idempotent: a no-op when the DB is not ahead.
|
|
6307
6644
|
"""
|
|
6308
6645
|
which = args.db # "cache" | "stats"
|
|
6309
|
-
if which == "
|
|
6310
|
-
|
|
6311
|
-
|
|
6312
|
-
path
|
|
6646
|
+
if which == "stats":
|
|
6647
|
+
# RETIRED (DB journal redesign §7.1). stats.db is now a disposable
|
|
6648
|
+
# journal index — a version mismatch resolves by rebuild, not by
|
|
6649
|
+
# trim-and-revert. The old trim path would mangle an epoch-stamped DB
|
|
6650
|
+
# (user_version 1000 > 13 looks "version-ahead" to the legacy registry).
|
|
6651
|
+
# Point the operator at the rebuild surface instead.
|
|
6652
|
+
eprint(
|
|
6653
|
+
"cctally: `db recover --db stats` is retired — stats.db is now a "
|
|
6654
|
+
"disposable index materialized from the append-only journal, so a "
|
|
6655
|
+
"version mismatch self-heals by rebuild rather than trim-and-revert. "
|
|
6656
|
+
"Run `cctally db rebuild --db stats` to rebuild it from the journal."
|
|
6657
|
+
)
|
|
6658
|
+
return 2
|
|
6659
|
+
path, registry, label = (
|
|
6660
|
+
_cctally_core.CACHE_DB_PATH, _CACHE_MIGRATIONS, "cache.db")
|
|
6313
6661
|
|
|
6314
6662
|
# Absent file → nothing to recover; do NOT connect (sqlite3.connect would
|
|
6315
6663
|
# create an empty DB file — mirrors cmd_db_unskip).
|
|
@@ -6327,34 +6675,11 @@ def cmd_db_recover(args: argparse.Namespace) -> int:
|
|
|
6327
6675
|
f"(≤ known {head}); nothing to recover."
|
|
6328
6676
|
)
|
|
6329
6677
|
return 0
|
|
6330
|
-
#
|
|
6331
|
-
#
|
|
6332
|
-
#
|
|
6333
|
-
#
|
|
6334
|
-
# the
|
|
6335
|
-
# in the real prod dir, password-DB-resolved, honoring
|
|
6336
|
-
# CCTALLY_ALLOW_PROD_MIGRATION). cache.db is re-derivable and intentionally
|
|
6337
|
-
# exempt — it mirrors the dispatcher's opt-in auto-heal.
|
|
6338
|
-
if which == "stats" and _would_block_prod_migration(conn):
|
|
6339
|
-
eprint(
|
|
6340
|
-
"cctally: refusing to recover stats.db in the prod data dir "
|
|
6341
|
-
"(~/.local/share/cctally) from a dev checkout — trimming the "
|
|
6342
|
-
"unknown migration markers and reverting user_version on the "
|
|
6343
|
-
"installed release's non-re-derivable stats.db could corrupt it. "
|
|
6344
|
-
"Run the installed binary, or override with "
|
|
6345
|
-
"CCTALLY_ALLOW_PROD_MIGRATION=1."
|
|
6346
|
-
)
|
|
6347
|
-
return 2
|
|
6348
|
-
if which == "stats" and not getattr(args, "yes", False):
|
|
6349
|
-
eprint(
|
|
6350
|
-
f"cctally: {label} is at version {cur_version} but this cctally "
|
|
6351
|
-
f"only knows up to {head}. Recovering stats.db trims the unknown "
|
|
6352
|
-
f"migration markers and reverts user_version, but any schema the "
|
|
6353
|
-
f"unknown migration created is left in place and a re-record/"
|
|
6354
|
-
f"re-sync may be needed. Re-run with --yes to proceed, or restore "
|
|
6355
|
-
f"{label} from a backup."
|
|
6356
|
-
)
|
|
6357
|
-
return 2
|
|
6678
|
+
# `--db stats` returned early above (retired, §7.1), so control only
|
|
6679
|
+
# reaches here for cache.db: re-derivable, healed in place with no --yes
|
|
6680
|
+
# and no #146 prod guard (the dispatcher's opt-in auto-heal already covers
|
|
6681
|
+
# it). The former `which == "stats"` prod-guard + --yes gates were dead
|
|
6682
|
+
# after the retirement and have been removed.
|
|
6358
6683
|
info = _recover_version_ahead(conn, registry, label)
|
|
6359
6684
|
print(
|
|
6360
6685
|
f"cctally: reverted {label} v{info['reverted_from']} → "
|