cctally 1.27.1 → 1.28.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 +19 -0
- package/bin/_cctally_alerts.py +26 -1
- package/bin/_cctally_cache.py +278 -6
- package/bin/_cctally_config.py +153 -11
- package/bin/_cctally_core.py +230 -41
- package/bin/_cctally_dashboard.py +399 -37
- package/bin/_cctally_db.py +594 -163
- package/bin/_cctally_doctor.py +11 -0
- package/bin/_cctally_forecast.py +700 -57
- package/bin/_cctally_milestones.py +273 -28
- package/bin/_cctally_parser.py +44 -4
- package/bin/_cctally_record.py +328 -50
- package/bin/_cctally_weekrefs.py +30 -6
- package/bin/_lib_alert_axes.py +8 -1
- package/bin/_lib_alerts_payload.py +95 -3
- package/bin/_lib_budget.py +48 -0
- package/bin/_lib_conversation.py +162 -0
- package/bin/_lib_conversation_query.py +524 -0
- package/bin/_lib_doctor.py +60 -1
- package/bin/_lib_transcript_access.py +80 -0
- package/bin/cctally +27 -0
- package/dashboard/static/assets/{index-D34qf0LE.css → index-Bj5ckRUE.css} +1 -1
- package/dashboard/static/assets/index-Dw4G5FD9.js +18 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-C2F1_Mxt.js +0 -18
package/bin/_cctally_db.py
CHANGED
|
@@ -202,9 +202,38 @@ class DowngradeDetected(Exception):
|
|
|
202
202
|
self.db_label = db_label
|
|
203
203
|
self.db_version = db_version
|
|
204
204
|
self.max_known = max_known
|
|
205
|
+
db_key = "cache" if db_label.startswith("cache") else "stats"
|
|
205
206
|
super().__init__(
|
|
206
207
|
f"{db_label} is at version {db_version} but this cctally "
|
|
207
|
-
f"only knows up to {max_known}."
|
|
208
|
+
f"only knows up to {max_known}. A newer/unreleased cctally likely "
|
|
209
|
+
f"touched this data dir. Run `cctally db recover --db {db_key}` to "
|
|
210
|
+
f"revert it to the known schema head (cache.db is re-derivable and "
|
|
211
|
+
f"recovers without --yes; stats.db needs --yes and may require a "
|
|
212
|
+
f"re-record afterward)."
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class ProdMigrationRefused(Exception):
|
|
217
|
+
"""Raised by the dispatcher when a git-checkout binary would forward-migrate
|
|
218
|
+
the REAL prod data dir (~/.local/share/cctally), which would brick the
|
|
219
|
+
installed release with DowngradeDetected (issue #142).
|
|
220
|
+
|
|
221
|
+
Escape hatch: set CCTALLY_ALLOW_PROD_MIGRATION=1. The guard is
|
|
222
|
+
connection-scoped + password-DB-resolved (see _would_block_prod_migration
|
|
223
|
+
+ _cctally_core._real_prod_data_dir) so it never fires on :memory:/temp/
|
|
224
|
+
fake-HOME test connections. Spec:
|
|
225
|
+
docs/superpowers/specs/2026-06-05-prod-migration-guard-design.md."""
|
|
226
|
+
|
|
227
|
+
def __init__(self, db_label: str, next_migration: str):
|
|
228
|
+
self.db_label = db_label
|
|
229
|
+
self.next_migration = next_migration
|
|
230
|
+
super().__init__(
|
|
231
|
+
f"cctally: refusing to apply migration '{next_migration}' "
|
|
232
|
+
f"({db_label}) to the prod data dir (~/.local/share/cctally) from "
|
|
233
|
+
f"a dev checkout — a checkout may carry migrations your installed "
|
|
234
|
+
f"cctally can't read, which would brick it (DowngradeDetected). "
|
|
235
|
+
f"Point CCTALLY_DATA_DIR at a scratch/dev dir, or run the installed "
|
|
236
|
+
f"binary. Override with CCTALLY_ALLOW_PROD_MIGRATION=1."
|
|
208
237
|
)
|
|
209
238
|
|
|
210
239
|
|
|
@@ -430,11 +459,149 @@ def _bootstrap_rename_legacy_markers(conn: sqlite3.Connection, db_label: str) ->
|
|
|
430
459
|
_clear_migration_error_log_entries(old)
|
|
431
460
|
|
|
432
461
|
|
|
462
|
+
def _conn_db_dir(conn: sqlite3.Connection) -> "pathlib.Path | None":
|
|
463
|
+
"""Resolved directory of the connection's `main` database file, or None for
|
|
464
|
+
an in-memory / no-file connection (PRAGMA database_list returns '' there).
|
|
465
|
+
Tuple-indexed so it works on the cache.db connection (no row_factory)."""
|
|
466
|
+
for row in conn.execute("PRAGMA database_list").fetchall():
|
|
467
|
+
if row[1] == "main":
|
|
468
|
+
db_file = row[2]
|
|
469
|
+
if not db_file:
|
|
470
|
+
return None
|
|
471
|
+
return pathlib.Path(db_file).resolve().parent
|
|
472
|
+
return None
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _would_block_prod_migration(conn: sqlite3.Connection) -> bool:
|
|
476
|
+
"""True iff a git-checkout binary is about to migrate a DB that physically
|
|
477
|
+
lives in the REAL prod data dir (issue #142).
|
|
478
|
+
|
|
479
|
+
Connection-scoped (NOT global APP_DIR) so :memory:/temp/scratch connections
|
|
480
|
+
never trip it; HOME-faking-immune via _real_prod_data_dir (password DB, not
|
|
481
|
+
$HOME); suppressor-INDEPENDENT raw .git check so it still fires under the
|
|
482
|
+
test-suite's CCTALLY_DISABLE_DEV_AUTODETECT. Escape: CCTALLY_ALLOW_PROD_MIGRATION."""
|
|
483
|
+
if os.environ.get("CCTALLY_ALLOW_PROD_MIGRATION"):
|
|
484
|
+
return False
|
|
485
|
+
if not (_cctally_core._repo_root() / ".git").exists():
|
|
486
|
+
return False
|
|
487
|
+
db_dir = _conn_db_dir(conn)
|
|
488
|
+
if db_dir is None:
|
|
489
|
+
return False
|
|
490
|
+
try:
|
|
491
|
+
return db_dir == _cctally_core._real_prod_data_dir().resolve()
|
|
492
|
+
except OSError:
|
|
493
|
+
return False
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _first_pending_migration_name(
|
|
497
|
+
conn: sqlite3.Connection, registry: "list[Migration]", cur_version: int
|
|
498
|
+
) -> str:
|
|
499
|
+
"""Best-effort name of the first not-yet-applied migration, for the refusal
|
|
500
|
+
message. Marker-aware (handles skip-gaps + db-unskip's user_version=0) with
|
|
501
|
+
a raw-index fallback. Legacy unprefixed markers are an accepted imperfection
|
|
502
|
+
— the name is a human hint, not load-bearing."""
|
|
503
|
+
try:
|
|
504
|
+
applied = {r[0] for r in conn.execute(
|
|
505
|
+
"SELECT name FROM schema_migrations").fetchall()}
|
|
506
|
+
except sqlite3.OperationalError:
|
|
507
|
+
applied = set()
|
|
508
|
+
try:
|
|
509
|
+
skipped = {r[0] for r in conn.execute(
|
|
510
|
+
"SELECT name FROM schema_migrations_skipped").fetchall()}
|
|
511
|
+
except sqlite3.OperationalError:
|
|
512
|
+
skipped = set()
|
|
513
|
+
for m in registry:
|
|
514
|
+
if m.name not in applied and m.name not in skipped:
|
|
515
|
+
return m.name
|
|
516
|
+
return registry[cur_version].name
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _recover_version_ahead(
|
|
520
|
+
conn: sqlite3.Connection,
|
|
521
|
+
registry: list[Migration],
|
|
522
|
+
db_label: str,
|
|
523
|
+
) -> dict:
|
|
524
|
+
"""Reconcile a version-ahead DB down to this binary's known head (issue #145).
|
|
525
|
+
|
|
526
|
+
A DB whose ``PRAGMA user_version`` exceeds ``len(registry)`` was last
|
|
527
|
+
touched by a newer/unreleased cctally. cache.db is fully re-derivable, so
|
|
528
|
+
we heal in place instead of bricking: trim the unknown (ahead) markers from
|
|
529
|
+
BOTH ledger tables, then reconcile ``user_version``.
|
|
530
|
+
|
|
531
|
+
We DELIBERATELY do not blind-set ``user_version = len(registry)``: the
|
|
532
|
+
dispatcher treats ``schema_migrations_skipped`` as authoritative and only
|
|
533
|
+
advances ``user_version`` when every known migration is applied-or-skipped.
|
|
534
|
+
So we trim unknown rows from both tables (Codex review P1 #1), then set
|
|
535
|
+
``user_version = len(registry)`` only if every known migration is
|
|
536
|
+
applied-or-skipped; otherwise ``0`` so the dispatcher's normal walk re-runs
|
|
537
|
+
the still-pending known migrations idempotently (Codex review P1 #2) — never
|
|
538
|
+
cementing a fast-path past a genuinely-missing known migration.
|
|
539
|
+
|
|
540
|
+
Extra tables/columns the unknown migration created are left inert (SQLite
|
|
541
|
+
tolerates them; cache is re-derivable). Idempotent: no-op when not ahead.
|
|
542
|
+
Tolerates absent ledger tables (Codex review P2).
|
|
543
|
+
|
|
544
|
+
Returns ``{"reverted_from", "reverted_to", "trimmed"}`` for the caller's
|
|
545
|
+
breadcrumb / ``db recover`` report.
|
|
546
|
+
"""
|
|
547
|
+
cur_version = conn.execute("PRAGMA user_version").fetchone()[0]
|
|
548
|
+
if cur_version <= len(registry):
|
|
549
|
+
return {"reverted_from": cur_version, "reverted_to": cur_version, "trimmed": 0}
|
|
550
|
+
|
|
551
|
+
aliases = _LEGACY_MARKER_ALIASES_BY_DB.get(db_label, {})
|
|
552
|
+
known = {m.name for m in registry} | set(aliases.keys()) | set(aliases.values())
|
|
553
|
+
placeholders = ",".join("?" for _ in known) if known else "''"
|
|
554
|
+
params = tuple(known)
|
|
555
|
+
|
|
556
|
+
trimmed = 0
|
|
557
|
+
for table in ("schema_migrations", "schema_migrations_skipped"):
|
|
558
|
+
try:
|
|
559
|
+
cur = conn.execute(
|
|
560
|
+
f"DELETE FROM {table} WHERE name NOT IN ({placeholders})", params
|
|
561
|
+
)
|
|
562
|
+
trimmed += max(cur.rowcount, 0) # DELETE rowcount is always >= 0
|
|
563
|
+
except sqlite3.OperationalError:
|
|
564
|
+
pass # table absent → nothing to trim there
|
|
565
|
+
|
|
566
|
+
applied: set[str] = set()
|
|
567
|
+
skipped: set[str] = set()
|
|
568
|
+
for table, dest in (("schema_migrations", applied),
|
|
569
|
+
("schema_migrations_skipped", skipped)):
|
|
570
|
+
try:
|
|
571
|
+
for row in conn.execute(f"SELECT name FROM {table}").fetchall():
|
|
572
|
+
dest.add(row[0])
|
|
573
|
+
except sqlite3.OperationalError:
|
|
574
|
+
pass
|
|
575
|
+
|
|
576
|
+
all_known_done = all((m.name in applied or m.name in skipped) for m in registry)
|
|
577
|
+
new_version = len(registry) if all_known_done else 0
|
|
578
|
+
conn.execute(f"PRAGMA user_version = {new_version}")
|
|
579
|
+
conn.commit()
|
|
580
|
+
return {"reverted_from": cur_version, "reverted_to": new_version, "trimmed": trimmed}
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _stamp_applied(conn, name, applied_at_utc=None):
|
|
584
|
+
"""Persist the schema_migrations marker for ``name``, then commit.
|
|
585
|
+
|
|
586
|
+
Central stamp owned by the dispatcher (issue #140). Handlers no longer
|
|
587
|
+
self-stamp — EXCEPT cache 001, whose stamp must stay atomic with its
|
|
588
|
+
destructive wipe; for that one this call is an idempotent no-op.
|
|
589
|
+
``INSERT OR IGNORE`` so a pre-existing row (cache 001, or a concurrent
|
|
590
|
+
winner) never raises.
|
|
591
|
+
"""
|
|
592
|
+
conn.execute(
|
|
593
|
+
"INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) VALUES (?, ?)",
|
|
594
|
+
(name, applied_at_utc or now_utc_iso()),
|
|
595
|
+
)
|
|
596
|
+
conn.commit()
|
|
597
|
+
|
|
598
|
+
|
|
433
599
|
def _run_pending_migrations(
|
|
434
600
|
conn: sqlite3.Connection,
|
|
435
601
|
*,
|
|
436
602
|
registry: list[Migration],
|
|
437
603
|
db_label: str,
|
|
604
|
+
recover_version_ahead: bool = False,
|
|
438
605
|
) -> None:
|
|
439
606
|
"""Apply pending migrations from ``registry`` against ``conn``.
|
|
440
607
|
|
|
@@ -475,9 +642,31 @@ def _run_pending_migrations(
|
|
|
475
642
|
"""
|
|
476
643
|
cur_version = conn.execute("PRAGMA user_version").fetchone()[0]
|
|
477
644
|
if cur_version > len(registry):
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
645
|
+
if recover_version_ahead:
|
|
646
|
+
# cache.db is re-derivable — heal in place instead of bricking (#145).
|
|
647
|
+
info = _recover_version_ahead(conn, registry, db_label)
|
|
648
|
+
eprint(
|
|
649
|
+
f"cctally: {db_label} was ahead (v{info['reverted_from']} > "
|
|
650
|
+
f"known v{len(registry)}); trimmed unknown migration state and "
|
|
651
|
+
f"reconciled to the known head (cache is re-derivable). Run "
|
|
652
|
+
f"'cctally cache-sync --rebuild' for a full rebuild."
|
|
653
|
+
)
|
|
654
|
+
cur_version = conn.execute("PRAGMA user_version").fetchone()[0]
|
|
655
|
+
# common case: cur_version == len(registry) → fast-path below.
|
|
656
|
+
# adversarial (a known marker was missing): cur_version == 0 →
|
|
657
|
+
# falls through to the normal pending-loop, which reconciles.
|
|
658
|
+
# NOTE: on that adversarial fall-through against a prod cache.db,
|
|
659
|
+
# _recover_version_ahead has ALREADY committed user_version=0, so
|
|
660
|
+
# the prod-migration guard below ("user_version provably unchanged")
|
|
661
|
+
# is reached with user_version already lowered. That is acceptable
|
|
662
|
+
# ONLY because heal opts in for cache.db, which is re-derivable — a
|
|
663
|
+
# reset-to-0 then ProdMigrationRefused just makes the next legit
|
|
664
|
+
# open re-walk. The guard's "unchanged" invariant holds for stats.db
|
|
665
|
+
# (never heals) and for the non-heal path.
|
|
666
|
+
else:
|
|
667
|
+
raise DowngradeDetected(
|
|
668
|
+
db_label, db_version=cur_version, max_known=len(registry),
|
|
669
|
+
)
|
|
481
670
|
if cur_version == len(registry):
|
|
482
671
|
# When the registry is currently empty (today's cache.db case),
|
|
483
672
|
# still leave the schema_migrations table behind so a later
|
|
@@ -504,6 +693,19 @@ def _run_pending_migrations(
|
|
|
504
693
|
)
|
|
505
694
|
return # fast path
|
|
506
695
|
|
|
696
|
+
# Prod-migration guard (issue #142): a git-checkout binary must not
|
|
697
|
+
# forward-migrate the real prod data dir — that bumps user_version past
|
|
698
|
+
# what the installed release knows and bricks it with DowngradeDetected.
|
|
699
|
+
# We are past the two early returns, so cur_version < len(registry): there
|
|
700
|
+
# ARE pending migrations that would advance user_version. Refuse BEFORE
|
|
701
|
+
# bootstrap-rename / fresh-install detection / any marker write, so
|
|
702
|
+
# user_version is provably unchanged. Connection-scoped so it only fires
|
|
703
|
+
# on the real prod DB files, never on :memory:/temp/scratch test conns.
|
|
704
|
+
if _would_block_prod_migration(conn):
|
|
705
|
+
raise ProdMigrationRefused(
|
|
706
|
+
db_label, _first_pending_migration_name(conn, registry, cur_version)
|
|
707
|
+
)
|
|
708
|
+
|
|
507
709
|
# Track whether schema_migrations existed before this open so we can
|
|
508
710
|
# detect the fresh-install path. After bootstrap, even a "first time
|
|
509
711
|
# opened with framework code" DB might have rows from the legacy
|
|
@@ -606,6 +808,11 @@ def _run_pending_migrations(
|
|
|
606
808
|
"weekly_cost_snapshots",
|
|
607
809
|
"five_hour_blocks",
|
|
608
810
|
"percent_milestones",
|
|
811
|
+
# budget milestone tables tracked by 011 (#137); empty on fresh
|
|
812
|
+
# installs, so this only guards a hand-dropped schema_migrations DB.
|
|
813
|
+
"budget_milestones",
|
|
814
|
+
"projected_milestones",
|
|
815
|
+
"codex_budget_milestones",
|
|
609
816
|
),
|
|
610
817
|
"cache.db": ("session_entries",),
|
|
611
818
|
}.get(db_label, ())
|
|
@@ -636,6 +843,7 @@ def _run_pending_migrations(
|
|
|
636
843
|
qualified_name = f"{db_label}:{m.name}"
|
|
637
844
|
try:
|
|
638
845
|
m.handler(conn)
|
|
846
|
+
_stamp_applied(conn, m.name, now_iso) # central stamp (#140)
|
|
639
847
|
_clear_migration_error_log_entries(qualified_name)
|
|
640
848
|
applied.add(m.name)
|
|
641
849
|
except MigrationGateNotMet as gate_exc:
|
|
@@ -713,25 +921,18 @@ def _backfill_five_hour_block_models(conn: sqlite3.Connection) -> None:
|
|
|
713
921
|
`DELETE FROM five_hour_blocks` followed by re-backfill doesn't
|
|
714
922
|
leave duplicates.
|
|
715
923
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
924
|
+
The gate closes regardless of how many child rows were written —
|
|
925
|
+
empty `session_entries` for a block (real users with API/web-only
|
|
926
|
+
blocks) yields zero child rows but MUST still be marked applied
|
|
927
|
+
(regression scenario Q2). The dispatcher central-stamps the
|
|
928
|
+
schema_migrations marker on this handler's clean return (#140).
|
|
721
929
|
"""
|
|
722
930
|
# Empty-table fast path: with no parent five_hour_blocks rows, this
|
|
723
|
-
# backfill has nothing to do.
|
|
724
|
-
#
|
|
725
|
-
#
|
|
726
|
-
# pre-framework era).
|
|
931
|
+
# backfill has nothing to do. Return cleanly so the dispatcher
|
|
932
|
+
# central-stamps us as applied (#140) — replaces the prior
|
|
933
|
+
# `has_blocks` outer gate from the pre-framework era.
|
|
727
934
|
if not conn.execute("SELECT 1 FROM five_hour_blocks LIMIT 1").fetchone():
|
|
728
|
-
conn.execute(
|
|
729
|
-
"INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) VALUES (?, ?)",
|
|
730
|
-
("001_five_hour_block_models_backfill_v1", now_utc_iso()),
|
|
731
|
-
)
|
|
732
|
-
conn.commit()
|
|
733
935
|
return
|
|
734
|
-
now_iso = now_utc_iso()
|
|
735
936
|
conn.execute("BEGIN")
|
|
736
937
|
try:
|
|
737
938
|
# Defensive: clean up any orphans from a prior parent rebuild.
|
|
@@ -792,15 +993,6 @@ def _backfill_five_hour_block_models(conn: sqlite3.Connection) -> None:
|
|
|
792
993
|
],
|
|
793
994
|
)
|
|
794
995
|
|
|
795
|
-
# Mark migration done — closes the gate even when zero rows
|
|
796
|
-
# were written (empty session_entries / API-only blocks).
|
|
797
|
-
conn.execute(
|
|
798
|
-
"""
|
|
799
|
-
INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc)
|
|
800
|
-
VALUES (?, ?)
|
|
801
|
-
""",
|
|
802
|
-
("001_five_hour_block_models_backfill_v1", now_iso),
|
|
803
|
-
)
|
|
804
996
|
conn.commit()
|
|
805
997
|
except Exception:
|
|
806
998
|
conn.rollback()
|
|
@@ -814,24 +1006,17 @@ def _backfill_five_hour_block_projects(conn: sqlite3.Connection) -> None:
|
|
|
814
1006
|
"""Upgrade-user backfill of five_hour_block_projects.
|
|
815
1007
|
|
|
816
1008
|
Mirror of _backfill_five_hour_block_models but writes by_project
|
|
817
|
-
buckets
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
1009
|
+
buckets. Cleans up orphan child rows defensively before the main
|
|
1010
|
+
loop. The dispatcher central-stamps the projects-side
|
|
1011
|
+
schema_migrations marker on clean return (#140), so the gate closes
|
|
1012
|
+
for empty-row backfills too.
|
|
821
1013
|
"""
|
|
822
1014
|
# Empty-table fast path: with no parent five_hour_blocks rows, this
|
|
823
|
-
# backfill has nothing to do.
|
|
824
|
-
#
|
|
825
|
-
#
|
|
826
|
-
# pre-framework era).
|
|
1015
|
+
# backfill has nothing to do. Return cleanly so the dispatcher
|
|
1016
|
+
# central-stamps us as applied (#140) — replaces the prior
|
|
1017
|
+
# `has_blocks` outer gate from the pre-framework era.
|
|
827
1018
|
if not conn.execute("SELECT 1 FROM five_hour_blocks LIMIT 1").fetchone():
|
|
828
|
-
conn.execute(
|
|
829
|
-
"INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) VALUES (?, ?)",
|
|
830
|
-
("002_five_hour_block_projects_backfill_v1", now_utc_iso()),
|
|
831
|
-
)
|
|
832
|
-
conn.commit()
|
|
833
1019
|
return
|
|
834
|
-
now_iso = now_utc_iso()
|
|
835
1020
|
conn.execute("BEGIN")
|
|
836
1021
|
try:
|
|
837
1022
|
conn.execute(
|
|
@@ -888,13 +1073,6 @@ def _backfill_five_hour_block_projects(conn: sqlite3.Connection) -> None:
|
|
|
888
1073
|
],
|
|
889
1074
|
)
|
|
890
1075
|
|
|
891
|
-
conn.execute(
|
|
892
|
-
"""
|
|
893
|
-
INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc)
|
|
894
|
-
VALUES (?, ?)
|
|
895
|
-
""",
|
|
896
|
-
("002_five_hour_block_projects_backfill_v1", now_iso),
|
|
897
|
-
)
|
|
898
1076
|
conn.commit()
|
|
899
1077
|
except Exception:
|
|
900
1078
|
conn.rollback()
|
|
@@ -1428,13 +1606,6 @@ def _migration_merge_5h_block_duplicates_v1(conn: sqlite3.Connection) -> None:
|
|
|
1428
1606
|
dropped_ids,
|
|
1429
1607
|
)
|
|
1430
1608
|
|
|
1431
|
-
conn.execute(
|
|
1432
|
-
"""
|
|
1433
|
-
INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc)
|
|
1434
|
-
VALUES (?, ?)
|
|
1435
|
-
""",
|
|
1436
|
-
("003_merge_5h_block_duplicates_v1", now_utc_iso()),
|
|
1437
|
-
)
|
|
1438
1609
|
conn.commit()
|
|
1439
1610
|
except Exception:
|
|
1440
1611
|
conn.rollback()
|
|
@@ -1483,7 +1654,8 @@ def _migration_heal_forked_week_start_date_buckets(conn: sqlite3.Connection) ->
|
|
|
1483
1654
|
external state (no ``cache.db`` open, no JSONL walk).
|
|
1484
1655
|
|
|
1485
1656
|
Empty-table fast path: when none of the three tables has a forked
|
|
1486
|
-
row,
|
|
1657
|
+
row, return without opening a transaction (the dispatcher
|
|
1658
|
+
central-stamps the marker on clean return, #140).
|
|
1487
1659
|
|
|
1488
1660
|
Spec hook: paired regression test in
|
|
1489
1661
|
``tests/test_heal_forked_week_start_date_buckets.py``.
|
|
@@ -1491,7 +1663,7 @@ def _migration_heal_forked_week_start_date_buckets(conn: sqlite3.Connection) ->
|
|
|
1491
1663
|
# Empty-fork fast path. UNION ALL across the three tables; one
|
|
1492
1664
|
# SELECT 1 / LIMIT 1 short-circuits on the first violator. When
|
|
1493
1665
|
# zero rows are forked, skip the BEGIN/UPDATE block entirely and
|
|
1494
|
-
#
|
|
1666
|
+
# return (the dispatcher central-stamps the marker, #140).
|
|
1495
1667
|
has_fork_row = conn.execute(
|
|
1496
1668
|
"""
|
|
1497
1669
|
SELECT 1 FROM (
|
|
@@ -1510,12 +1682,6 @@ def _migration_heal_forked_week_start_date_buckets(conn: sqlite3.Connection) ->
|
|
|
1510
1682
|
"""
|
|
1511
1683
|
).fetchone()
|
|
1512
1684
|
if not has_fork_row:
|
|
1513
|
-
conn.execute(
|
|
1514
|
-
"INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
|
|
1515
|
-
"VALUES (?, ?)",
|
|
1516
|
-
("004_heal_forked_week_start_date_buckets", now_utc_iso()),
|
|
1517
|
-
)
|
|
1518
|
-
conn.commit()
|
|
1519
1685
|
return
|
|
1520
1686
|
|
|
1521
1687
|
conn.execute("BEGIN")
|
|
@@ -1571,13 +1737,6 @@ def _migration_heal_forked_week_start_date_buckets(conn: sqlite3.Connection) ->
|
|
|
1571
1737
|
"""
|
|
1572
1738
|
)
|
|
1573
1739
|
|
|
1574
|
-
conn.execute(
|
|
1575
|
-
"""
|
|
1576
|
-
INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc)
|
|
1577
|
-
VALUES (?, ?)
|
|
1578
|
-
""",
|
|
1579
|
-
("004_heal_forked_week_start_date_buckets", now_utc_iso()),
|
|
1580
|
-
)
|
|
1581
1740
|
conn.commit()
|
|
1582
1741
|
except Exception:
|
|
1583
1742
|
conn.rollback()
|
|
@@ -1608,23 +1767,18 @@ def _migration_percent_milestones_reset_event_id(conn: sqlite3.Connection) -> No
|
|
|
1608
1767
|
|
|
1609
1768
|
Idempotent: a second invocation finds the column already present
|
|
1610
1769
|
and returns. Empty-table fast path: when the column is already
|
|
1611
|
-
present
|
|
1770
|
+
present this handler is a no-op — no schema edit needed (the
|
|
1771
|
+
dispatcher central-stamps the marker on clean return, #140).
|
|
1612
1772
|
"""
|
|
1613
1773
|
# Fast-path probe: column already present means a prior run of this
|
|
1614
1774
|
# migration (or a fresh-install fast-stamp from the dispatcher that
|
|
1615
1775
|
# already picked up the new live-schema CREATE TABLE) has done the
|
|
1616
|
-
# work.
|
|
1776
|
+
# work. Return; the dispatcher central-stamps the marker (#140).
|
|
1617
1777
|
cols = {
|
|
1618
1778
|
str(r[1])
|
|
1619
1779
|
for r in conn.execute("PRAGMA table_info(percent_milestones)").fetchall()
|
|
1620
1780
|
}
|
|
1621
1781
|
if "reset_event_id" in cols:
|
|
1622
|
-
conn.execute(
|
|
1623
|
-
"INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
|
|
1624
|
-
"VALUES (?, ?)",
|
|
1625
|
-
("005_percent_milestones_reset_event_id", now_utc_iso()),
|
|
1626
|
-
)
|
|
1627
|
-
conn.commit()
|
|
1628
1782
|
return
|
|
1629
1783
|
|
|
1630
1784
|
conn.execute("BEGIN")
|
|
@@ -1681,11 +1835,6 @@ def _migration_percent_milestones_reset_event_id(conn: sqlite3.Connection) -> No
|
|
|
1681
1835
|
"""
|
|
1682
1836
|
)
|
|
1683
1837
|
conn.execute("DROP TABLE percent_milestones_old_005")
|
|
1684
|
-
conn.execute(
|
|
1685
|
-
"INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
|
|
1686
|
-
"VALUES (?, ?)",
|
|
1687
|
-
("005_percent_milestones_reset_event_id", now_utc_iso()),
|
|
1688
|
-
)
|
|
1689
1838
|
conn.commit()
|
|
1690
1839
|
except Exception:
|
|
1691
1840
|
conn.rollback()
|
|
@@ -1722,26 +1871,18 @@ def _migration_five_hour_milestones_reset_event_id(conn: sqlite3.Connection) ->
|
|
|
1722
1871
|
(fresh-install fast-stamp from the dispatcher because the live
|
|
1723
1872
|
``CREATE TABLE IF NOT EXISTS five_hour_milestones`` already carries
|
|
1724
1873
|
the new shape — REQUIRED for fresh-install correctness per spec §3.2),
|
|
1725
|
-
|
|
1874
|
+
this handler is a no-op — no schema edit needed (the dispatcher
|
|
1875
|
+
central-stamps the marker on clean return, #140).
|
|
1726
1876
|
"""
|
|
1727
1877
|
# Fast-path probe: column already present means a prior run of this
|
|
1728
1878
|
# migration (or a fresh-install fast-stamp from the dispatcher that
|
|
1729
1879
|
# already picked up the new live-schema CREATE TABLE) has done the
|
|
1730
|
-
# work.
|
|
1731
|
-
# SQLite's implicit transaction (auto-opened by the write, closed by
|
|
1732
|
-
# ``commit()`` — same shape as migration 005's fast path); no explicit
|
|
1733
|
-
# ``BEGIN`` is needed for a single-statement DML.
|
|
1880
|
+
# work. Return; the dispatcher central-stamps the marker (#140).
|
|
1734
1881
|
cols = {
|
|
1735
1882
|
str(r[1])
|
|
1736
1883
|
for r in conn.execute("PRAGMA table_info(five_hour_milestones)").fetchall()
|
|
1737
1884
|
}
|
|
1738
1885
|
if "reset_event_id" in cols:
|
|
1739
|
-
conn.execute(
|
|
1740
|
-
"INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
|
|
1741
|
-
"VALUES (?, ?)",
|
|
1742
|
-
("006_five_hour_milestones_reset_event_id", now_utc_iso()),
|
|
1743
|
-
)
|
|
1744
|
-
conn.commit()
|
|
1745
1886
|
return
|
|
1746
1887
|
|
|
1747
1888
|
conn.execute("BEGIN")
|
|
@@ -1813,11 +1954,6 @@ def _migration_five_hour_milestones_reset_event_id(conn: sqlite3.Connection) ->
|
|
|
1813
1954
|
"""
|
|
1814
1955
|
)
|
|
1815
1956
|
conn.execute("DROP TABLE five_hour_milestones_old_006")
|
|
1816
|
-
conn.execute(
|
|
1817
|
-
"INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
|
|
1818
|
-
"VALUES (?, ?)",
|
|
1819
|
-
("006_five_hour_milestones_reset_event_id", now_utc_iso()),
|
|
1820
|
-
)
|
|
1821
1957
|
conn.commit()
|
|
1822
1958
|
except Exception:
|
|
1823
1959
|
conn.rollback()
|
|
@@ -1861,22 +1997,16 @@ def _migration_observed_pre_credit_pct(conn: sqlite3.Connection) -> None:
|
|
|
1861
1997
|
|
|
1862
1998
|
Idempotent: a second invocation finds the column already present
|
|
1863
1999
|
and returns. Empty-column fast path: when the live CREATE TABLE
|
|
1864
|
-
already carries the column (fresh install),
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
005 / 006).
|
|
2000
|
+
already carries the column (fresh install), return without an ALTER
|
|
2001
|
+
(the dispatcher central-stamps the marker on clean return, #140).
|
|
2002
|
+
Simple ADD COLUMN — no UNIQUE constraint change, so no
|
|
2003
|
+
rename-recreate-copy needed (contrast migrations 005 / 006).
|
|
1868
2004
|
"""
|
|
1869
2005
|
cols = {
|
|
1870
2006
|
str(r[1])
|
|
1871
2007
|
for r in conn.execute("PRAGMA table_info(week_reset_events)").fetchall()
|
|
1872
2008
|
}
|
|
1873
2009
|
if "observed_pre_credit_pct" in cols:
|
|
1874
|
-
conn.execute(
|
|
1875
|
-
"INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
|
|
1876
|
-
"VALUES (?, ?)",
|
|
1877
|
-
("007_observed_pre_credit_pct", now_utc_iso()),
|
|
1878
|
-
)
|
|
1879
|
-
conn.commit()
|
|
1880
2010
|
return
|
|
1881
2011
|
|
|
1882
2012
|
conn.execute("BEGIN")
|
|
@@ -1885,11 +2015,6 @@ def _migration_observed_pre_credit_pct(conn: sqlite3.Connection) -> None:
|
|
|
1885
2015
|
"ALTER TABLE week_reset_events "
|
|
1886
2016
|
"ADD COLUMN observed_pre_credit_pct REAL"
|
|
1887
2017
|
)
|
|
1888
|
-
conn.execute(
|
|
1889
|
-
"INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
|
|
1890
|
-
"VALUES (?, ?)",
|
|
1891
|
-
("007_observed_pre_credit_pct", now_utc_iso()),
|
|
1892
|
-
)
|
|
1893
2018
|
conn.commit()
|
|
1894
2019
|
except Exception:
|
|
1895
2020
|
conn.rollback()
|
|
@@ -2182,6 +2307,34 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
|
|
|
2182
2307
|
ON session_entries(msg_id, req_id)
|
|
2183
2308
|
WHERE msg_id IS NOT NULL AND req_id IS NOT NULL;
|
|
2184
2309
|
|
|
2310
|
+
CREATE TABLE IF NOT EXISTS conversation_messages (
|
|
2311
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2312
|
+
session_id TEXT,
|
|
2313
|
+
uuid TEXT,
|
|
2314
|
+
parent_uuid TEXT,
|
|
2315
|
+
source_path TEXT NOT NULL,
|
|
2316
|
+
byte_offset INTEGER NOT NULL,
|
|
2317
|
+
timestamp_utc TEXT,
|
|
2318
|
+
entry_type TEXT NOT NULL,
|
|
2319
|
+
text TEXT NOT NULL DEFAULT '',
|
|
2320
|
+
blocks_json TEXT NOT NULL DEFAULT '[]',
|
|
2321
|
+
model TEXT,
|
|
2322
|
+
msg_id TEXT,
|
|
2323
|
+
req_id TEXT,
|
|
2324
|
+
cwd TEXT,
|
|
2325
|
+
git_branch TEXT,
|
|
2326
|
+
is_sidechain INTEGER NOT NULL DEFAULT 0,
|
|
2327
|
+
UNIQUE(source_path, byte_offset)
|
|
2328
|
+
);
|
|
2329
|
+
CREATE INDEX IF NOT EXISTS idx_conv_session_ts
|
|
2330
|
+
ON conversation_messages(session_id, timestamp_utc, id);
|
|
2331
|
+
CREATE INDEX IF NOT EXISTS idx_conv_session_uuid
|
|
2332
|
+
ON conversation_messages(session_id, uuid);
|
|
2333
|
+
CREATE INDEX IF NOT EXISTS idx_conv_source
|
|
2334
|
+
ON conversation_messages(source_path);
|
|
2335
|
+
CREATE INDEX IF NOT EXISTS idx_conv_turnkey
|
|
2336
|
+
ON conversation_messages(msg_id, req_id);
|
|
2337
|
+
|
|
2185
2338
|
CREATE TABLE IF NOT EXISTS codex_session_files (
|
|
2186
2339
|
path TEXT PRIMARY KEY,
|
|
2187
2340
|
size_bytes INTEGER NOT NULL,
|
|
@@ -2227,6 +2380,99 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
|
|
|
2227
2380
|
"CREATE INDEX IF NOT EXISTS idx_session_files_session_id "
|
|
2228
2381
|
"ON session_files(session_id)"
|
|
2229
2382
|
)
|
|
2383
|
+
# FTS5 is optional in the sqlite build. Create the external-content index +
|
|
2384
|
+
# sync triggers as separate executes wrapped in one try; on failure create
|
|
2385
|
+
# NEITHER the table NOR the triggers (a trigger referencing a missing table
|
|
2386
|
+
# would itself error), set a persisted flag, and let search fall back to
|
|
2387
|
+
# LIKE. Spec §1. Idempotent (IF NOT EXISTS).
|
|
2388
|
+
if _fts5_available(conn):
|
|
2389
|
+
try:
|
|
2390
|
+
# Recovery (spec §1/P2): if a PRIOR run marked FTS unavailable,
|
|
2391
|
+
# conversation_messages rows were ingested (by sync_cache / the
|
|
2392
|
+
# backfill) WITHOUT the AFTER INSERT trigger ever indexing them —
|
|
2393
|
+
# or a prior downgrade dropped the index while leaving the base
|
|
2394
|
+
# rows. Detect that BEFORE clearing the flag so we can rebuild the
|
|
2395
|
+
# external-content index from conversation_messages below. A fresh
|
|
2396
|
+
# install never sets the flag, so this stays False and no rebuild
|
|
2397
|
+
# runs (the triggers index rows incrementally as they arrive).
|
|
2398
|
+
recovering = conn.execute(
|
|
2399
|
+
"SELECT 1 FROM cache_meta WHERE key='fts5_unavailable'"
|
|
2400
|
+
).fetchone() is not None
|
|
2401
|
+
conn.execute(
|
|
2402
|
+
"CREATE VIRTUAL TABLE IF NOT EXISTS conversation_fts "
|
|
2403
|
+
"USING fts5(text, content='conversation_messages', content_rowid='id')")
|
|
2404
|
+
conn.execute(
|
|
2405
|
+
"CREATE TRIGGER IF NOT EXISTS conv_fts_ai AFTER INSERT ON conversation_messages "
|
|
2406
|
+
"BEGIN INSERT INTO conversation_fts(rowid, text) VALUES (new.id, new.text); END")
|
|
2407
|
+
conn.execute(
|
|
2408
|
+
"CREATE TRIGGER IF NOT EXISTS conv_fts_ad AFTER DELETE ON conversation_messages "
|
|
2409
|
+
"BEGIN INSERT INTO conversation_fts(conversation_fts, rowid, text) "
|
|
2410
|
+
"VALUES('delete', old.id, old.text); END")
|
|
2411
|
+
conn.execute(
|
|
2412
|
+
"CREATE TRIGGER IF NOT EXISTS conv_fts_au AFTER UPDATE OF text ON conversation_messages "
|
|
2413
|
+
"BEGIN INSERT INTO conversation_fts(conversation_fts, rowid, text) "
|
|
2414
|
+
"VALUES('delete', old.id, old.text); "
|
|
2415
|
+
"INSERT INTO conversation_fts(rowid, text) VALUES (new.id, new.text); END")
|
|
2416
|
+
if recovering:
|
|
2417
|
+
# Repopulate the freshly-(re)created index from the base table
|
|
2418
|
+
# so pre-recovery history is searchable. Cheap no-op when
|
|
2419
|
+
# conversation_messages is empty.
|
|
2420
|
+
conn.execute(
|
|
2421
|
+
"INSERT INTO conversation_fts(conversation_fts) VALUES('rebuild')")
|
|
2422
|
+
conn.execute("DELETE FROM cache_meta WHERE key='fts5_unavailable'")
|
|
2423
|
+
except sqlite3.OperationalError:
|
|
2424
|
+
# partial create cleanup, then mark unavailable
|
|
2425
|
+
for stmt in ("DROP TRIGGER IF EXISTS conv_fts_au",
|
|
2426
|
+
"DROP TRIGGER IF EXISTS conv_fts_ad",
|
|
2427
|
+
"DROP TRIGGER IF EXISTS conv_fts_ai",
|
|
2428
|
+
"DROP TABLE IF EXISTS conversation_fts"):
|
|
2429
|
+
try:
|
|
2430
|
+
conn.execute(stmt)
|
|
2431
|
+
except sqlite3.OperationalError:
|
|
2432
|
+
pass
|
|
2433
|
+
_set_cache_meta(conn, "fts5_unavailable", "1")
|
|
2434
|
+
else:
|
|
2435
|
+
# FTS5 is unavailable on THIS sqlite build. If a prior (FTS-capable)
|
|
2436
|
+
# run created the sync triggers, they now reference an unusable
|
|
2437
|
+
# conversation_fts and EVERY INSERT into conversation_messages would
|
|
2438
|
+
# raise "no such module: fts5". Because the conversation INSERT shares
|
|
2439
|
+
# sync_cache's per-file write transaction with session_entries, that
|
|
2440
|
+
# rollback would discard COST ingest too. Drop the orphan triggers so
|
|
2441
|
+
# writes succeed under the LIKE fallback. (The conversation_fts vtable
|
|
2442
|
+
# itself can't be DROPped without the fts5 module, but with no triggers
|
|
2443
|
+
# nothing writes to it.)
|
|
2444
|
+
for stmt in ("DROP TRIGGER IF EXISTS conv_fts_au",
|
|
2445
|
+
"DROP TRIGGER IF EXISTS conv_fts_ad",
|
|
2446
|
+
"DROP TRIGGER IF EXISTS conv_fts_ai"):
|
|
2447
|
+
try:
|
|
2448
|
+
conn.execute(stmt)
|
|
2449
|
+
except sqlite3.OperationalError:
|
|
2450
|
+
pass
|
|
2451
|
+
_set_cache_meta(conn, "fts5_unavailable", "1")
|
|
2452
|
+
# The FTS branch above issues DML (DELETE/INSERT on cache_meta) which opens
|
|
2453
|
+
# an implicit transaction under sqlite3's legacy autocommit mode. Close it
|
|
2454
|
+
# so the migration dispatcher's subsequent ``conn.execute("BEGIN")`` starts
|
|
2455
|
+
# cleanly (mirrors the bootstrap-rename commit envelope rationale).
|
|
2456
|
+
conn.commit()
|
|
2457
|
+
|
|
2458
|
+
|
|
2459
|
+
def _fts5_available(conn: sqlite3.Connection) -> bool:
|
|
2460
|
+
"""True if this sqlite build can create an FTS5 table. Cheap probe on a
|
|
2461
|
+
temp table that is created then dropped. Hidden test seam: tests monkeypatch
|
|
2462
|
+
this to False to exercise the LIKE fallback."""
|
|
2463
|
+
try:
|
|
2464
|
+
conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS _fts5_probe USING fts5(x)")
|
|
2465
|
+
conn.execute("DROP TABLE IF EXISTS _fts5_probe")
|
|
2466
|
+
return True
|
|
2467
|
+
except sqlite3.OperationalError:
|
|
2468
|
+
return False
|
|
2469
|
+
|
|
2470
|
+
|
|
2471
|
+
def _set_cache_meta(conn: sqlite3.Connection, key: str, value: str) -> None:
|
|
2472
|
+
conn.execute(
|
|
2473
|
+
"CREATE TABLE IF NOT EXISTS cache_meta (key TEXT PRIMARY KEY, value TEXT)")
|
|
2474
|
+
conn.execute("INSERT INTO cache_meta(key, value) VALUES(?, ?) "
|
|
2475
|
+
"ON CONFLICT(key) DO UPDATE SET value=excluded.value", (key, value))
|
|
2230
2476
|
|
|
2231
2477
|
|
|
2232
2478
|
def _eagerly_apply_cache_migrations() -> None:
|
|
@@ -2338,6 +2584,7 @@ def _eagerly_apply_cache_migrations() -> None:
|
|
|
2338
2584
|
# if 001 has already applied, this is a fast-path return.
|
|
2339
2585
|
_run_pending_migrations(
|
|
2340
2586
|
conn, registry=_CACHE_MIGRATIONS, db_label="cache.db",
|
|
2587
|
+
recover_version_ahead=True,
|
|
2341
2588
|
)
|
|
2342
2589
|
finally:
|
|
2343
2590
|
# Close immediately so the WAL writer lock (if any) is
|
|
@@ -2627,6 +2874,45 @@ def _001_dedup_highest_wins_locked(conn: sqlite3.Connection) -> None:
|
|
|
2627
2874
|
raise
|
|
2628
2875
|
|
|
2629
2876
|
|
|
2877
|
+
# === Region 7c: Cache migration 002_conversation_messages_backfill ===
|
|
2878
|
+
|
|
2879
|
+
@cache_migration("002_conversation_messages_backfill")
|
|
2880
|
+
def _002_conversation_messages_backfill(conn: sqlite3.Connection) -> None:
|
|
2881
|
+
"""Mark the ``conversation_messages`` backfill pending (Plan 1 Task 5; the
|
|
2882
|
+
deferral is issue #139).
|
|
2883
|
+
|
|
2884
|
+
The table + indexes + FTS already live in ``_apply_cache_schema`` (so fresh
|
|
2885
|
+
installs have them and the dispatcher stamps THIS migration without running
|
|
2886
|
+
it — there is no history to populate). This handler runs only on an
|
|
2887
|
+
EXISTING install (``session_entries`` non-empty), which needs the message
|
|
2888
|
+
index populated from the full JSONL history.
|
|
2889
|
+
|
|
2890
|
+
Rather than walk that history INLINE — which blocked the triggering command
|
|
2891
|
+
until the whole (potentially ~1M-line) backfill completed, including a
|
|
2892
|
+
stats-only ``cctally report`` that fires the cache dispatcher via
|
|
2893
|
+
``_eagerly_apply_cache_migrations`` but never opens cache.db for reads
|
|
2894
|
+
(issue #139) — this handler just sets the ``conversation_backfill_pending``
|
|
2895
|
+
cache_meta flag and returns in microseconds. The actual offset-0 backfill
|
|
2896
|
+
runs on the next ``sync_cache``, which already holds the ``cache.db.lock``
|
|
2897
|
+
flock and owns the walker (see ``_cctally_cache.sync_cache``); a
|
|
2898
|
+
cache-consuming command — or, most often, the background ``hook-tick`` —
|
|
2899
|
+
absorbs the one-time walk where the latency is expected/invisible. Because
|
|
2900
|
+
the handler no longer touches JSONL it needs no flock and cannot contend
|
|
2901
|
+
with a concurrent sync, so the old non-blocking-flock +
|
|
2902
|
+
``MigrationGateNotMet`` defer dance is gone.
|
|
2903
|
+
|
|
2904
|
+
Does NOT self-stamp its ``schema_migrations`` marker: the dispatcher owns
|
|
2905
|
+
the central stamp on the existing-install success path (issue #140), calling
|
|
2906
|
+
``_stamp_applied(conn, m.name)`` right after this handler returns cleanly —
|
|
2907
|
+
so the migration persists and is never re-walked (re-setting the flag) on a
|
|
2908
|
+
subsequent ``open_cache_db()``. This handler only commits the cache_meta
|
|
2909
|
+
flag. The flag itself is consumed + cleared by the first ``sync_cache`` that
|
|
2910
|
+
sees it (idempotent + crash-resumable there); a ``cache-sync --rebuild``
|
|
2911
|
+
clears it directly since its normal offset-0 walk repopulates the index."""
|
|
2912
|
+
_set_cache_meta(conn, "conversation_backfill_pending", "1")
|
|
2913
|
+
conn.commit()
|
|
2914
|
+
|
|
2915
|
+
|
|
2630
2916
|
# === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
|
|
2631
2917
|
|
|
2632
2918
|
@stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
|
|
@@ -2789,17 +3075,6 @@ def _008_recompute_weekly_cost_snapshots_dedup_fix(
|
|
|
2789
3075
|
"SET cost_usd = ? WHERE id = ?",
|
|
2790
3076
|
(total, snap_id),
|
|
2791
3077
|
)
|
|
2792
|
-
# D3 — INSERT OR IGNORE for race safety. Mirrors the
|
|
2793
|
-
# convention applied to every other production migration
|
|
2794
|
-
# and the matching change to cache migration 001.
|
|
2795
|
-
conn.execute(
|
|
2796
|
-
"INSERT OR IGNORE INTO schema_migrations "
|
|
2797
|
-
"(name, applied_at_utc) VALUES (?, ?)",
|
|
2798
|
-
(
|
|
2799
|
-
"008_recompute_weekly_cost_snapshots_dedup_fix",
|
|
2800
|
-
now_utc_iso(),
|
|
2801
|
-
),
|
|
2802
|
-
)
|
|
2803
3078
|
conn.execute("COMMIT")
|
|
2804
3079
|
except Exception:
|
|
2805
3080
|
conn.execute("ROLLBACK")
|
|
@@ -3217,14 +3492,6 @@ def _009_recompute_five_hour_blocks_dedup_fix(
|
|
|
3217
3492
|
],
|
|
3218
3493
|
)
|
|
3219
3494
|
|
|
3220
|
-
conn.execute(
|
|
3221
|
-
"INSERT OR IGNORE INTO schema_migrations "
|
|
3222
|
-
"(name, applied_at_utc) VALUES (?, ?)",
|
|
3223
|
-
(
|
|
3224
|
-
"009_recompute_five_hour_blocks_dedup_fix",
|
|
3225
|
-
now_utc_iso(),
|
|
3226
|
-
),
|
|
3227
|
-
)
|
|
3228
3495
|
conn.execute("COMMIT")
|
|
3229
3496
|
except Exception:
|
|
3230
3497
|
conn.execute("ROLLBACK")
|
|
@@ -3407,14 +3674,6 @@ def _010_recompute_percent_milestones_dedup_fix(
|
|
|
3407
3674
|
(cumulative, marginal, mid),
|
|
3408
3675
|
)
|
|
3409
3676
|
|
|
3410
|
-
conn.execute(
|
|
3411
|
-
"INSERT OR IGNORE INTO schema_migrations "
|
|
3412
|
-
"(name, applied_at_utc) VALUES (?, ?)",
|
|
3413
|
-
(
|
|
3414
|
-
"010_recompute_percent_milestones_dedup_fix",
|
|
3415
|
-
now_utc_iso(),
|
|
3416
|
-
),
|
|
3417
|
-
)
|
|
3418
3677
|
conn.execute("COMMIT")
|
|
3419
3678
|
except Exception:
|
|
3420
3679
|
conn.execute("ROLLBACK")
|
|
@@ -3423,6 +3682,124 @@ def _010_recompute_percent_milestones_dedup_fix(
|
|
|
3423
3682
|
cache_ro.close()
|
|
3424
3683
|
|
|
3425
3684
|
|
|
3685
|
+
@stats_migration("011_budget_milestone_period_keys")
|
|
3686
|
+
def _migration_budget_milestone_period_keys(conn: sqlite3.Connection) -> None:
|
|
3687
|
+
"""Add a write-once ``period`` column to the three budget milestone tables
|
|
3688
|
+
and include it in each UNIQUE key (issue #137).
|
|
3689
|
+
|
|
3690
|
+
``budget_milestones`` -> UNIQUE(week_start_at, period, threshold)
|
|
3691
|
+
``codex_budget_milestones`` -> UNIQUE(period_start_at, period, threshold)
|
|
3692
|
+
``projected_milestones`` -> UNIQUE(week_start_at, period, metric, threshold)
|
|
3693
|
+
|
|
3694
|
+
Fixes (1) stale dashboard period labels and (2) the calendar-week /
|
|
3695
|
+
calendar-month dedup collision when the 1st of the month lands on the
|
|
3696
|
+
configured week-start weekday.
|
|
3697
|
+
|
|
3698
|
+
Historical rows are backfilled to ``period = NULL`` (the "pre-011 unknown
|
|
3699
|
+
period" sentinel) rather than a fabricated value, honoring write-once
|
|
3700
|
+
milestones. The firing pre-probe matches ``period = ? OR period IS NULL``
|
|
3701
|
+
so unknown-period rows never re-fire (no spurious upgrade alert), and the
|
|
3702
|
+
dashboard COALESCEs NULL to the vendor-default noun.
|
|
3703
|
+
|
|
3704
|
+
SQLite cannot ALTER an inline UNIQUE in place -> rename-recreate-copy idiom
|
|
3705
|
+
(same as migration 005). Idempotent: a table that already has ``period``
|
|
3706
|
+
(fresh install where the live CREATE made the new shape, or a prior run) is
|
|
3707
|
+
skipped; when all three are present the handler returns and the dispatcher
|
|
3708
|
+
central-stamps the marker (#140).
|
|
3709
|
+
"""
|
|
3710
|
+
specs = [
|
|
3711
|
+
(
|
|
3712
|
+
"budget_milestones",
|
|
3713
|
+
"""
|
|
3714
|
+
CREATE TABLE budget_milestones (
|
|
3715
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3716
|
+
week_start_at TEXT NOT NULL,
|
|
3717
|
+
period TEXT,
|
|
3718
|
+
threshold INTEGER NOT NULL,
|
|
3719
|
+
budget_usd REAL NOT NULL,
|
|
3720
|
+
spent_usd REAL NOT NULL,
|
|
3721
|
+
consumption_pct REAL NOT NULL,
|
|
3722
|
+
crossed_at_utc TEXT NOT NULL,
|
|
3723
|
+
alerted_at TEXT,
|
|
3724
|
+
UNIQUE(week_start_at, period, threshold)
|
|
3725
|
+
)
|
|
3726
|
+
""",
|
|
3727
|
+
# (cols copied target<-source) — period omitted from source => NULL
|
|
3728
|
+
"id, week_start_at, threshold, budget_usd, spent_usd, "
|
|
3729
|
+
"consumption_pct, crossed_at_utc, alerted_at",
|
|
3730
|
+
),
|
|
3731
|
+
(
|
|
3732
|
+
"codex_budget_milestones",
|
|
3733
|
+
"""
|
|
3734
|
+
CREATE TABLE codex_budget_milestones (
|
|
3735
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3736
|
+
period_start_at TEXT NOT NULL,
|
|
3737
|
+
period TEXT,
|
|
3738
|
+
threshold INTEGER NOT NULL,
|
|
3739
|
+
budget_usd REAL NOT NULL,
|
|
3740
|
+
spent_usd REAL NOT NULL,
|
|
3741
|
+
consumption_pct REAL NOT NULL,
|
|
3742
|
+
crossed_at_utc TEXT NOT NULL,
|
|
3743
|
+
alerted_at TEXT,
|
|
3744
|
+
UNIQUE(period_start_at, period, threshold)
|
|
3745
|
+
)
|
|
3746
|
+
""",
|
|
3747
|
+
"id, period_start_at, threshold, budget_usd, spent_usd, "
|
|
3748
|
+
"consumption_pct, crossed_at_utc, alerted_at",
|
|
3749
|
+
),
|
|
3750
|
+
(
|
|
3751
|
+
"projected_milestones",
|
|
3752
|
+
"""
|
|
3753
|
+
CREATE TABLE projected_milestones (
|
|
3754
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3755
|
+
week_start_at TEXT NOT NULL,
|
|
3756
|
+
period TEXT,
|
|
3757
|
+
metric TEXT NOT NULL,
|
|
3758
|
+
threshold INTEGER NOT NULL,
|
|
3759
|
+
projected_value REAL NOT NULL,
|
|
3760
|
+
denominator REAL NOT NULL,
|
|
3761
|
+
crossed_at_utc TEXT NOT NULL,
|
|
3762
|
+
alerted_at TEXT,
|
|
3763
|
+
UNIQUE(week_start_at, period, metric, threshold)
|
|
3764
|
+
)
|
|
3765
|
+
""",
|
|
3766
|
+
"id, week_start_at, metric, threshold, projected_value, "
|
|
3767
|
+
"denominator, crossed_at_utc, alerted_at",
|
|
3768
|
+
),
|
|
3769
|
+
]
|
|
3770
|
+
|
|
3771
|
+
def _has_period(table: str) -> bool:
|
|
3772
|
+
cols = {
|
|
3773
|
+
str(r[1])
|
|
3774
|
+
for r in conn.execute(f"PRAGMA table_info({table})").fetchall()
|
|
3775
|
+
}
|
|
3776
|
+
return "period" in cols
|
|
3777
|
+
|
|
3778
|
+
# Compute needs-rebuild BEFORE any transaction (no deferred-BEGIN-then-read
|
|
3779
|
+
# on stats.db — SQLITE_BUSY_SNAPSHOT, migrations-gotchas.md).
|
|
3780
|
+
pending = [s for s in specs if not _has_period(s[0])]
|
|
3781
|
+
|
|
3782
|
+
if not pending:
|
|
3783
|
+
# Fresh install (live CREATE already made the new shape) or prior run.
|
|
3784
|
+
return
|
|
3785
|
+
|
|
3786
|
+
conn.execute("BEGIN IMMEDIATE") # write-lock up front; DDL is first DML
|
|
3787
|
+
try:
|
|
3788
|
+
for table, create_sql, cols in pending:
|
|
3789
|
+
old = f"{table}_old_011"
|
|
3790
|
+
conn.execute(f"ALTER TABLE {table} RENAME TO {old}")
|
|
3791
|
+
conn.execute(create_sql)
|
|
3792
|
+
# period omitted from the SELECT => NULL for every historical row
|
|
3793
|
+
conn.execute(
|
|
3794
|
+
f"INSERT INTO {table} ({cols}) SELECT {cols} FROM {old}"
|
|
3795
|
+
)
|
|
3796
|
+
conn.execute(f"DROP TABLE {old}")
|
|
3797
|
+
conn.commit()
|
|
3798
|
+
except Exception:
|
|
3799
|
+
conn.rollback()
|
|
3800
|
+
raise
|
|
3801
|
+
|
|
3802
|
+
|
|
3426
3803
|
# === Region 8: Test-only migration registration (was bin/cctally:12086-12140) ===
|
|
3427
3804
|
|
|
3428
3805
|
# ──────────────────────────────────────────────────────────────────────
|
|
@@ -3447,39 +3824,22 @@ if os.environ.get("CCTALLY_MIGRATION_TEST_MODE") == "1":
|
|
|
3447
3824
|
@stats_migration(_stats_test_name)
|
|
3448
3825
|
def _test_migration_failure_injection(conn):
|
|
3449
3826
|
"""Test-only migration: raises RuntimeError when test_failure_trigger
|
|
3450
|
-
table is non-empty; otherwise
|
|
3827
|
+
table is non-empty; otherwise it is a no-op (the dispatcher stamps)."""
|
|
3451
3828
|
if conn.execute(
|
|
3452
3829
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='test_failure_trigger'"
|
|
3453
3830
|
).fetchone() and conn.execute(
|
|
3454
3831
|
"SELECT 1 FROM test_failure_trigger LIMIT 1"
|
|
3455
3832
|
).fetchone():
|
|
3456
3833
|
raise RuntimeError("test failure injected")
|
|
3457
|
-
|
|
3458
|
-
try:
|
|
3459
|
-
conn.execute(
|
|
3460
|
-
"INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) VALUES (?, ?)",
|
|
3461
|
-
(_stats_test_name, now_utc_iso()),
|
|
3462
|
-
)
|
|
3463
|
-
conn.commit()
|
|
3464
|
-
except Exception:
|
|
3465
|
-
conn.rollback()
|
|
3466
|
-
raise
|
|
3834
|
+
return
|
|
3467
3835
|
|
|
3468
3836
|
_cache_test_seq = len(_CACHE_MIGRATIONS) + 1
|
|
3469
3837
|
_cache_test_name = f"{_cache_test_seq:03d}_test_cache_migration"
|
|
3470
3838
|
|
|
3471
3839
|
@cache_migration(_cache_test_name)
|
|
3472
3840
|
def _test_cache_migration(conn):
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
conn.execute(
|
|
3476
|
-
"INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) VALUES (?, ?)",
|
|
3477
|
-
(_cache_test_name, now_utc_iso()),
|
|
3478
|
-
)
|
|
3479
|
-
conn.commit()
|
|
3480
|
-
except Exception:
|
|
3481
|
-
conn.rollback()
|
|
3482
|
-
raise
|
|
3841
|
+
"""Test-only cache migration: no-op body; the dispatcher stamps."""
|
|
3842
|
+
return
|
|
3483
3843
|
|
|
3484
3844
|
|
|
3485
3845
|
# === Region 9: db CLI subcommands (was bin/cctally:19707-20043) ===
|
|
@@ -3821,3 +4181,74 @@ def cmd_db_unskip(args: argparse.Namespace) -> int:
|
|
|
3821
4181
|
conn.close()
|
|
3822
4182
|
print(f"Unskipped: {name} (will run on next open).")
|
|
3823
4183
|
return 0
|
|
4184
|
+
|
|
4185
|
+
|
|
4186
|
+
def cmd_db_recover(args: argparse.Namespace) -> int:
|
|
4187
|
+
"""Revert a version-ahead DB to this binary's known schema head (#145).
|
|
4188
|
+
|
|
4189
|
+
cache.db is fully re-derivable, so `--db cache` heals without --yes.
|
|
4190
|
+
stats.db holds non-re-derivable snapshots/milestones, so `--db stats`
|
|
4191
|
+
requires explicit --yes and may need a re-record afterward, AND honors the
|
|
4192
|
+
#146 prod guard (a dev/worktree binary refuses to trim+revert the real prod
|
|
4193
|
+
stats.db unless CCTALLY_ALLOW_PROD_MIGRATION=1). Bypasses
|
|
4194
|
+
open_db()/open_cache_db() (raw connect) so it never re-triggers the
|
|
4195
|
+
dispatcher. Idempotent: a no-op when the DB is not ahead.
|
|
4196
|
+
"""
|
|
4197
|
+
which = args.db # "cache" | "stats"
|
|
4198
|
+
if which == "cache":
|
|
4199
|
+
path, registry, label = _cctally_core.CACHE_DB_PATH, _CACHE_MIGRATIONS, "cache.db"
|
|
4200
|
+
else:
|
|
4201
|
+
path, registry, label = _cctally_core.DB_PATH, _STATS_MIGRATIONS, "stats.db"
|
|
4202
|
+
|
|
4203
|
+
# Absent file → nothing to recover; do NOT connect (sqlite3.connect would
|
|
4204
|
+
# create an empty DB file — mirrors cmd_db_unskip).
|
|
4205
|
+
if not path.exists():
|
|
4206
|
+
print(f"cctally: {label} not present; nothing to recover.")
|
|
4207
|
+
return 0
|
|
4208
|
+
|
|
4209
|
+
conn = sqlite3.connect(path)
|
|
4210
|
+
try:
|
|
4211
|
+
cur_version = conn.execute("PRAGMA user_version").fetchone()[0]
|
|
4212
|
+
head = len(registry)
|
|
4213
|
+
if cur_version <= head:
|
|
4214
|
+
print(
|
|
4215
|
+
f"cctally: {label} is at version {cur_version} "
|
|
4216
|
+
f"(≤ known {head}); nothing to recover."
|
|
4217
|
+
)
|
|
4218
|
+
return 0
|
|
4219
|
+
# Prod guard (issue #146): a dev/worktree binary must not trim the unknown
|
|
4220
|
+
# migration markers + revert user_version on the installed release's
|
|
4221
|
+
# NON-re-derivable prod stats.db — the destructive cousin of the #142
|
|
4222
|
+
# forward-migration guard (trimmed markers can't be re-derived). Reuses
|
|
4223
|
+
# the same connection-scoped predicate (git checkout AND the DB physically
|
|
4224
|
+
# in the real prod dir, password-DB-resolved, honoring
|
|
4225
|
+
# CCTALLY_ALLOW_PROD_MIGRATION). cache.db is re-derivable and intentionally
|
|
4226
|
+
# exempt — it mirrors the dispatcher's opt-in auto-heal.
|
|
4227
|
+
if which == "stats" and _would_block_prod_migration(conn):
|
|
4228
|
+
eprint(
|
|
4229
|
+
"cctally: refusing to recover stats.db in the prod data dir "
|
|
4230
|
+
"(~/.local/share/cctally) from a dev checkout — trimming the "
|
|
4231
|
+
"unknown migration markers and reverting user_version on the "
|
|
4232
|
+
"installed release's non-re-derivable stats.db could corrupt it. "
|
|
4233
|
+
"Run the installed binary, or override with "
|
|
4234
|
+
"CCTALLY_ALLOW_PROD_MIGRATION=1."
|
|
4235
|
+
)
|
|
4236
|
+
return 2
|
|
4237
|
+
if which == "stats" and not getattr(args, "yes", False):
|
|
4238
|
+
eprint(
|
|
4239
|
+
f"cctally: {label} is at version {cur_version} but this cctally "
|
|
4240
|
+
f"only knows up to {head}. Recovering stats.db trims the unknown "
|
|
4241
|
+
f"migration markers and reverts user_version, but any schema the "
|
|
4242
|
+
f"unknown migration created is left in place and a re-record/"
|
|
4243
|
+
f"re-sync may be needed. Re-run with --yes to proceed, or restore "
|
|
4244
|
+
f"{label} from a backup."
|
|
4245
|
+
)
|
|
4246
|
+
return 2
|
|
4247
|
+
info = _recover_version_ahead(conn, registry, label)
|
|
4248
|
+
print(
|
|
4249
|
+
f"cctally: reverted {label} v{info['reverted_from']} → "
|
|
4250
|
+
f"v{info['reverted_to']}, dropped {info['trimmed']} unknown marker(s)."
|
|
4251
|
+
)
|
|
4252
|
+
return 0
|
|
4253
|
+
finally:
|
|
4254
|
+
conn.close()
|