cctally 1.70.0 → 1.72.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 +21 -0
- package/bin/_cctally_cache.py +430 -8
- package/bin/_cctally_config.py +108 -0
- package/bin/_cctally_core.py +6 -1
- package/bin/_cctally_dashboard.py +106 -11
- package/bin/_cctally_db.py +445 -1
- package/bin/_cctally_parser.py +20 -0
- package/bin/_cctally_quota.py +58 -6
- package/bin/_lib_codex_conversation.py +509 -0
- package/bin/_lib_codex_conversation_query.py +950 -0
- package/bin/_lib_conversation_dispatch.py +547 -0
- package/bin/_lib_conversation_retention.py +344 -0
- package/bin/cctally +1 -0
- package/dashboard/static/assets/index-BWadAHxC.js +80 -0
- package/dashboard/static/assets/index-D2nwo_ln.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +5 -1
- package/dashboard/static/assets/index-CBbErI-P.js +0 -80
- package/dashboard/static/assets/index-kDDVOLa_.css +0 -1
package/bin/_cctally_db.py
CHANGED
|
@@ -876,7 +876,16 @@ def _run_pending_migrations(
|
|
|
876
876
|
# absent must still be treated as non-fresh so a flag-only
|
|
877
877
|
# conversation migration's consumer actually runs (e.g. 011's
|
|
878
878
|
# command-args promotion) instead of being stamped without it.
|
|
879
|
-
|
|
879
|
+
# codex_conversation_events joins them (#294 S6): a Codex-bearing
|
|
880
|
+
# cache written by S1's fused ingest but missing schema_migrations
|
|
881
|
+
# would otherwise be classified fresh and stamp 025 WITHOUT replaying
|
|
882
|
+
# normalization, leaving the read kernels authoritative over an empty
|
|
883
|
+
# normalized corpus. Probing it forces 025's handler to run and derive
|
|
884
|
+
# the normalized rows from the retained events.
|
|
885
|
+
"cache.db": (
|
|
886
|
+
"session_entries", "conversation_messages",
|
|
887
|
+
"codex_conversation_events",
|
|
888
|
+
),
|
|
880
889
|
}.get(db_label, ())
|
|
881
890
|
for probe_table in probe_tables:
|
|
882
891
|
# _probe_table_nonempty centralizes the "is there data here?"
|
|
@@ -2621,6 +2630,81 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
|
|
|
2621
2630
|
CREATE INDEX IF NOT EXISTS idx_codex_events_timestamp
|
|
2622
2631
|
ON codex_conversation_events(timestamp_utc);
|
|
2623
2632
|
|
|
2633
|
+
-- #294 S6: normalized Codex conversation storage. Like the S1 tables
|
|
2634
|
+
-- above, these live in the UNCONDITIONAL base-schema script — before the
|
|
2635
|
+
-- legacy-FTS topology checks below — so an existing cache gains them on
|
|
2636
|
+
-- every open. The independent Codex FTS layer
|
|
2637
|
+
-- (_apply_codex_conversation_fts) stands up codex_conversation_fts + its
|
|
2638
|
+
-- own triggers separately, BEFORE the Claude legacy-FTS early-return, so
|
|
2639
|
+
-- a legacy-shape Claude cache still gets the Codex search index.
|
|
2640
|
+
--
|
|
2641
|
+
-- codex_conversation_messages: one normalized row per NORMALIZED physical
|
|
2642
|
+
-- event (§3.1). ``id`` is a PLAIN rowid alias (deliberately NOT
|
|
2643
|
+
-- AUTOINCREMENT): migration 025 replays inserts in deterministic
|
|
2644
|
+
-- (source_path ASC, line_offset ASC) order, so a repeated run is
|
|
2645
|
+
-- byte-idempotent (no sqlite_sequence drift), while fresh ingest inserts
|
|
2646
|
+
-- in discovery order — fresh-vs-migrated equality is asserted
|
|
2647
|
+
-- SEMANTICALLY (row content modulo id).
|
|
2648
|
+
CREATE TABLE IF NOT EXISTS codex_conversation_messages (
|
|
2649
|
+
id INTEGER PRIMARY KEY,
|
|
2650
|
+
conversation_key TEXT NOT NULL,
|
|
2651
|
+
source_root_key TEXT NOT NULL,
|
|
2652
|
+
source_path TEXT NOT NULL,
|
|
2653
|
+
line_offset INTEGER NOT NULL,
|
|
2654
|
+
timestamp_utc TEXT,
|
|
2655
|
+
turn_id TEXT,
|
|
2656
|
+
call_id TEXT,
|
|
2657
|
+
kind TEXT NOT NULL,
|
|
2658
|
+
event_type TEXT,
|
|
2659
|
+
record_family TEXT NOT NULL,
|
|
2660
|
+
model TEXT,
|
|
2661
|
+
text TEXT,
|
|
2662
|
+
content_digest TEXT NOT NULL,
|
|
2663
|
+
content_len INTEGER NOT NULL CHECK(content_len >= 0),
|
|
2664
|
+
detail_json TEXT,
|
|
2665
|
+
search_tool TEXT,
|
|
2666
|
+
search_thinking TEXT,
|
|
2667
|
+
UNIQUE(source_path, line_offset)
|
|
2668
|
+
);
|
|
2669
|
+
CREATE INDEX IF NOT EXISTS idx_codex_conv_msgs_conversation
|
|
2670
|
+
ON codex_conversation_messages(conversation_key, timestamp_utc, id);
|
|
2671
|
+
CREATE INDEX IF NOT EXISTS idx_codex_conv_msgs_source
|
|
2672
|
+
ON codex_conversation_messages(source_path);
|
|
2673
|
+
|
|
2674
|
+
-- codex_conversation_rollups: browse-rail materialization (§3.2). A pure
|
|
2675
|
+
-- function of surviving codex_conversation_messages (+ thread metadata),
|
|
2676
|
+
-- recomputed-affected-or-deleted after every write/delete path. item_count
|
|
2677
|
+
-- counts rendered LOGICAL items (mirror-paired), not physical rows.
|
|
2678
|
+
CREATE TABLE IF NOT EXISTS codex_conversation_rollups (
|
|
2679
|
+
conversation_key TEXT NOT NULL PRIMARY KEY,
|
|
2680
|
+
source_root_key TEXT NOT NULL,
|
|
2681
|
+
parent_thread_id TEXT,
|
|
2682
|
+
item_count INTEGER NOT NULL DEFAULT 0,
|
|
2683
|
+
started_utc TEXT,
|
|
2684
|
+
last_activity_utc TEXT,
|
|
2685
|
+
project_key TEXT,
|
|
2686
|
+
project_label TEXT,
|
|
2687
|
+
models_json TEXT,
|
|
2688
|
+
title TEXT
|
|
2689
|
+
);
|
|
2690
|
+
CREATE INDEX IF NOT EXISTS idx_codex_conv_rollups_recent
|
|
2691
|
+
ON codex_conversation_rollups(last_activity_utc DESC, conversation_key DESC);
|
|
2692
|
+
|
|
2693
|
+
-- codex_conversation_file_touches: write-class axis for the `files`
|
|
2694
|
+
-- search kind + outline file stats (§3.3). source_path gives explicit
|
|
2695
|
+
-- lineage so the per-file delete/truncate/prune paths scope deletions
|
|
2696
|
+
-- exactly as they do for the other Codex families.
|
|
2697
|
+
CREATE TABLE IF NOT EXISTS codex_conversation_file_touches (
|
|
2698
|
+
message_id INTEGER NOT NULL,
|
|
2699
|
+
conversation_key TEXT NOT NULL,
|
|
2700
|
+
source_path TEXT NOT NULL,
|
|
2701
|
+
file_path TEXT NOT NULL,
|
|
2702
|
+
tool TEXT NOT NULL,
|
|
2703
|
+
UNIQUE(message_id, file_path, tool)
|
|
2704
|
+
);
|
|
2705
|
+
CREATE INDEX IF NOT EXISTS idx_codex_conv_touches_source
|
|
2706
|
+
ON codex_conversation_file_touches(source_path);
|
|
2707
|
+
|
|
2624
2708
|
CREATE TABLE IF NOT EXISTS cache_meta (
|
|
2625
2709
|
key TEXT PRIMARY KEY,
|
|
2626
2710
|
value TEXT
|
|
@@ -2662,6 +2746,10 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
|
|
|
2662
2746
|
add_column_if_missing(conn, "codex_session_files", "last_root_thread_id", "TEXT")
|
|
2663
2747
|
add_column_if_missing(conn, "codex_session_files", "last_parent_thread_id", "TEXT")
|
|
2664
2748
|
add_column_if_missing(conn, "codex_session_files", "last_conversation_key", "TEXT")
|
|
2749
|
+
# #294 S6: the terminal sticky-turn seed for delta resumes, alongside the
|
|
2750
|
+
# existing last_model/thread facts. A batch that ends after a turn_context
|
|
2751
|
+
# and resumes with a response_item stamps the correct effective turn.
|
|
2752
|
+
add_column_if_missing(conn, "codex_session_files", "last_turn_id", "TEXT")
|
|
2665
2753
|
conn.execute(
|
|
2666
2754
|
"CREATE INDEX IF NOT EXISTS idx_codex_files_source_root "
|
|
2667
2755
|
"ON codex_session_files(source_root_key)"
|
|
@@ -2761,6 +2849,12 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
|
|
|
2761
2849
|
"CREATE INDEX IF NOT EXISTS idx_session_files_session_id "
|
|
2762
2850
|
"ON session_files(session_id)"
|
|
2763
2851
|
)
|
|
2852
|
+
# #294 S6: the independent Codex conversation FTS lifecycle. Run BEFORE (and
|
|
2853
|
+
# independent of) the Claude FTS branch below — the Claude branch early-returns
|
|
2854
|
+
# on a legacy conversation_fts(text) shape, so the Codex index must be stood up
|
|
2855
|
+
# here or a legacy-shape cache would never gain Codex search. Never touches any
|
|
2856
|
+
# Claude FTS object (conv_fts_* / conv_title_fts_*).
|
|
2857
|
+
_apply_codex_conversation_fts(conn)
|
|
2764
2858
|
# FTS5 is optional in the sqlite build. Create the external-content index +
|
|
2765
2859
|
# sync triggers as separate executes wrapped in one try; on failure create
|
|
2766
2860
|
# NEITHER the table NOR the triggers (a trigger referencing a missing table
|
|
@@ -3188,6 +3282,142 @@ def clear_conversation_messages(conn: sqlite3.Connection) -> None:
|
|
|
3188
3282
|
_create_conversation_fts_triggers(conn)
|
|
3189
3283
|
|
|
3190
3284
|
|
|
3285
|
+
# ── #294 S6: independent Codex conversation FTS lifecycle (§3.4) ──────────────
|
|
3286
|
+
# The Codex normalized search index owns its OWN external-content FTS5 table +
|
|
3287
|
+
# AI/AD/AU trigger trio, entirely separate from the Claude conversation_fts /
|
|
3288
|
+
# conversation_title_fts objects. _apply_codex_conversation_fts runs from
|
|
3289
|
+
# _apply_cache_schema BEFORE the Claude legacy-FTS early-return, so a legacy-shape
|
|
3290
|
+
# Claude cache still gains it. The Codex-scoped `codex_fts_unavailable` cache_meta
|
|
3291
|
+
# marker is INDEPENDENT of the Claude `fts5_unavailable` marker. Column names
|
|
3292
|
+
# match codex_conversation_messages BY NAME (external-content FTS5 rule).
|
|
3293
|
+
_CODEX_CONV_FTS_DDL = (
|
|
3294
|
+
"CREATE VIRTUAL TABLE IF NOT EXISTS codex_conversation_fts USING fts5("
|
|
3295
|
+
"text, search_tool, search_thinking, "
|
|
3296
|
+
"content='codex_conversation_messages', content_rowid='id')"
|
|
3297
|
+
)
|
|
3298
|
+
# Trigger DDL lives in ONE place so the initial create and the storm-free
|
|
3299
|
+
# full-clear (which drops + recreates the trigger set) can never drift. AD/AU use
|
|
3300
|
+
# the external-content `'delete'` idiom; AU fires AFTER UPDATE OF the three
|
|
3301
|
+
# indexed columns.
|
|
3302
|
+
_CODEX_CONV_FTS_TRIGGER_DDL = (
|
|
3303
|
+
"CREATE TRIGGER IF NOT EXISTS codex_conv_fts_ai AFTER INSERT ON codex_conversation_messages "
|
|
3304
|
+
"BEGIN INSERT INTO codex_conversation_fts(rowid, text, search_tool, search_thinking) "
|
|
3305
|
+
"VALUES (new.id, new.text, new.search_tool, new.search_thinking); END",
|
|
3306
|
+
"CREATE TRIGGER IF NOT EXISTS codex_conv_fts_ad AFTER DELETE ON codex_conversation_messages "
|
|
3307
|
+
"BEGIN INSERT INTO codex_conversation_fts(codex_conversation_fts, rowid, text, search_tool, search_thinking) "
|
|
3308
|
+
"VALUES('delete', old.id, old.text, old.search_tool, old.search_thinking); END",
|
|
3309
|
+
"CREATE TRIGGER IF NOT EXISTS codex_conv_fts_au "
|
|
3310
|
+
"AFTER UPDATE OF text, search_tool, search_thinking ON codex_conversation_messages "
|
|
3311
|
+
"BEGIN INSERT INTO codex_conversation_fts(codex_conversation_fts, rowid, text, search_tool, search_thinking) "
|
|
3312
|
+
"VALUES('delete', old.id, old.text, old.search_tool, old.search_thinking); "
|
|
3313
|
+
"INSERT INTO codex_conversation_fts(rowid, text, search_tool, search_thinking) "
|
|
3314
|
+
"VALUES (new.id, new.text, new.search_tool, new.search_thinking); END",
|
|
3315
|
+
)
|
|
3316
|
+
_CODEX_CONV_FTS_TRIGGER_NAMES = ("codex_conv_fts_au", "codex_conv_fts_ad", "codex_conv_fts_ai")
|
|
3317
|
+
|
|
3318
|
+
|
|
3319
|
+
def _create_codex_conversation_fts_triggers(conn: sqlite3.Connection) -> None:
|
|
3320
|
+
"""Create the Codex FTS sync trigger set — idempotent (each ``IF NOT EXISTS``).
|
|
3321
|
+
Single source of truth, shared by ``_apply_codex_conversation_fts`` and the
|
|
3322
|
+
storm-free full-clear. The caller must have already created the vtable."""
|
|
3323
|
+
for stmt in _CODEX_CONV_FTS_TRIGGER_DDL:
|
|
3324
|
+
conn.execute(stmt)
|
|
3325
|
+
|
|
3326
|
+
|
|
3327
|
+
def _drop_codex_conversation_fts_triggers(conn: sqlite3.Connection) -> None:
|
|
3328
|
+
"""Drop the Codex FTS sync trigger set — idempotent (``IF EXISTS``). Swallows
|
|
3329
|
+
``OperationalError`` per statement so an absent set (FTS5-unavailable build) is
|
|
3330
|
+
tolerated. Dropping these on a no-FTS5 build is what keeps a normalized INSERT
|
|
3331
|
+
from firing a trigger against the missing vtable and rolling back the shared
|
|
3332
|
+
per-file ingest transaction. NEVER touches any Claude FTS trigger."""
|
|
3333
|
+
for name in _CODEX_CONV_FTS_TRIGGER_NAMES:
|
|
3334
|
+
try:
|
|
3335
|
+
conn.execute(f"DROP TRIGGER IF EXISTS {name}")
|
|
3336
|
+
except sqlite3.OperationalError:
|
|
3337
|
+
pass
|
|
3338
|
+
|
|
3339
|
+
|
|
3340
|
+
def _apply_codex_conversation_fts(conn: sqlite3.Connection) -> None:
|
|
3341
|
+
"""Stand up / recover / degrade the independent Codex conversation FTS (§3.4).
|
|
3342
|
+
|
|
3343
|
+
Four states: create (FTS5 available, no marker) → codex_conversation_fts +
|
|
3344
|
+
triggers; unavailable-at-creation → set ``codex_fts_unavailable`` + skip DDL;
|
|
3345
|
+
capable→unavailable reopen → drop ONLY the Codex triggers + set the marker;
|
|
3346
|
+
recovery (FTS5 available again, marker set) → drop/recreate the vtable + its
|
|
3347
|
+
triggers, ``rebuild`` from the base rows, clear the marker. Never touches any
|
|
3348
|
+
Claude FTS object."""
|
|
3349
|
+
if _fts5_available(conn):
|
|
3350
|
+
recovering = conn.execute(
|
|
3351
|
+
"SELECT 1 FROM cache_meta WHERE key='codex_fts_unavailable'"
|
|
3352
|
+
).fetchone() is not None
|
|
3353
|
+
try:
|
|
3354
|
+
if recovering:
|
|
3355
|
+
# A prior FTS5-unavailable run ingested normalized rows without a
|
|
3356
|
+
# live trigger (or a capable→unavailable→capable cycle left a stale
|
|
3357
|
+
# vtable). Drop + recreate the index and its triggers, then rebuild
|
|
3358
|
+
# from the base rows.
|
|
3359
|
+
_drop_codex_conversation_fts_triggers(conn)
|
|
3360
|
+
conn.execute("DROP TABLE IF EXISTS codex_conversation_fts")
|
|
3361
|
+
conn.execute(_CODEX_CONV_FTS_DDL)
|
|
3362
|
+
_create_codex_conversation_fts_triggers(conn)
|
|
3363
|
+
if recovering:
|
|
3364
|
+
conn.execute(
|
|
3365
|
+
"INSERT INTO codex_conversation_fts(codex_conversation_fts) VALUES('rebuild')")
|
|
3366
|
+
conn.execute("DELETE FROM cache_meta WHERE key='codex_fts_unavailable'")
|
|
3367
|
+
except sqlite3.OperationalError:
|
|
3368
|
+
# Partial create cleanup, then mark unavailable so search uses LIKE.
|
|
3369
|
+
_drop_codex_conversation_fts_triggers(conn)
|
|
3370
|
+
try:
|
|
3371
|
+
conn.execute("DROP TABLE IF EXISTS codex_conversation_fts")
|
|
3372
|
+
except sqlite3.OperationalError:
|
|
3373
|
+
pass
|
|
3374
|
+
_set_cache_meta(conn, "codex_fts_unavailable", "1")
|
|
3375
|
+
else:
|
|
3376
|
+
# FTS5 unavailable on THIS build. If a prior capable run left the Codex
|
|
3377
|
+
# triggers, they now reference an unusable vtable and every normalized
|
|
3378
|
+
# INSERT would fail and roll back the shared per-file transaction — so
|
|
3379
|
+
# drop ONLY the Codex triggers (the vtable can't be dropped without fts5,
|
|
3380
|
+
# but with no triggers nothing writes to it) and mark unavailable.
|
|
3381
|
+
_drop_codex_conversation_fts_triggers(conn)
|
|
3382
|
+
_set_cache_meta(conn, "codex_fts_unavailable", "1")
|
|
3383
|
+
|
|
3384
|
+
|
|
3385
|
+
def _codex_conversation_fts_full_clear(conn: sqlite3.Connection) -> None:
|
|
3386
|
+
"""Storm-free FULL clear of every Codex normalized derived table (§3.4).
|
|
3387
|
+
|
|
3388
|
+
Drops the Codex FTS triggers, truncates codex_conversation_messages (the
|
|
3389
|
+
no-trigger fast path), resets the external-content index via ``'delete-all'``,
|
|
3390
|
+
recreates the triggers, then clears file-touches + rollups. ``'delete-all'`` is
|
|
3391
|
+
valid here precisely because the WHOLE normalized corpus empties; the sequence
|
|
3392
|
+
makes repeated runs byte-idempotent at the FTS shadow-table level (migration
|
|
3393
|
+
025 re-run, cache-rebuild). Partial deletes (per-file truncation, root-set
|
|
3394
|
+
orphan prune) MUST NOT use this — they ride the per-row delete triggers so
|
|
3395
|
+
surviving conversations keep their postings. Falls back to a plain base DELETE
|
|
3396
|
+
when FTS5 is unavailable (no triggers, no usable vtable)."""
|
|
3397
|
+
try:
|
|
3398
|
+
fts_unavailable = conn.execute(
|
|
3399
|
+
"SELECT 1 FROM cache_meta WHERE key='codex_fts_unavailable'"
|
|
3400
|
+
).fetchone() is not None
|
|
3401
|
+
except sqlite3.OperationalError:
|
|
3402
|
+
fts_unavailable = True
|
|
3403
|
+
if fts_unavailable:
|
|
3404
|
+
conn.execute("DELETE FROM codex_conversation_messages")
|
|
3405
|
+
else:
|
|
3406
|
+
_drop_codex_conversation_fts_triggers(conn)
|
|
3407
|
+
conn.execute("DELETE FROM codex_conversation_messages")
|
|
3408
|
+
conn.execute(
|
|
3409
|
+
"INSERT INTO codex_conversation_fts(codex_conversation_fts) VALUES('delete-all')")
|
|
3410
|
+
_create_codex_conversation_fts_triggers(conn)
|
|
3411
|
+
for stmt in (
|
|
3412
|
+
"DELETE FROM codex_conversation_file_touches",
|
|
3413
|
+
"DELETE FROM codex_conversation_rollups",
|
|
3414
|
+
):
|
|
3415
|
+
try:
|
|
3416
|
+
conn.execute(stmt)
|
|
3417
|
+
except sqlite3.OperationalError:
|
|
3418
|
+
pass # table not yet created (pre-schema conn); nothing to clear
|
|
3419
|
+
|
|
3420
|
+
|
|
3191
3421
|
def _eagerly_apply_cache_migrations() -> None:
|
|
3192
3422
|
"""Open cache.db so its pending migrations (notably
|
|
3193
3423
|
``001_dedup_highest_wins``) apply BEFORE stats migration 008's gate
|
|
@@ -4278,6 +4508,68 @@ def _024_codex_fused_ingest_rebuild(conn: sqlite3.Connection) -> None:
|
|
|
4278
4508
|
conn.execute("DELETE FROM codex_conversation_threads")
|
|
4279
4509
|
conn.execute("DELETE FROM codex_conversation_events")
|
|
4280
4510
|
conn.execute("DELETE FROM codex_source_roots")
|
|
4511
|
+
# F3 (#313): clearing Codex quota state without invalidating the
|
|
4512
|
+
# quota-projection certificate would leave a stale-valid cert
|
|
4513
|
+
# (cache sequence unchanged) that lets the reconcile short-circuit
|
|
4514
|
+
# skip over now-deleted data. Delete it in the same transaction.
|
|
4515
|
+
conn.execute(
|
|
4516
|
+
"DELETE FROM cache_meta WHERE key='codex_quota_projection_certificate'"
|
|
4517
|
+
)
|
|
4518
|
+
conn.commit()
|
|
4519
|
+
except Exception:
|
|
4520
|
+
conn.rollback()
|
|
4521
|
+
raise
|
|
4522
|
+
finally:
|
|
4523
|
+
if lock_fh is not None:
|
|
4524
|
+
try:
|
|
4525
|
+
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
4526
|
+
except OSError:
|
|
4527
|
+
pass
|
|
4528
|
+
lock_fh.close()
|
|
4529
|
+
|
|
4530
|
+
|
|
4531
|
+
@cache_migration("025_codex_conversation_normalization")
|
|
4532
|
+
def _025_codex_conversation_normalization(conn: sqlite3.Connection) -> None:
|
|
4533
|
+
"""Derive S6 normalized conversation state for caches whose Codex events were
|
|
4534
|
+
ingested before S6 (#294 S6, §3.5).
|
|
4535
|
+
|
|
4536
|
+
Clears the three derived tables (codex_conversation_messages,
|
|
4537
|
+
codex_conversation_file_touches, codex_conversation_rollups) via the §3.4
|
|
4538
|
+
storm-free full-clear helper, then replays the pure normalization kernel over
|
|
4539
|
+
the stored ``codex_conversation_events`` per file in
|
|
4540
|
+
``(source_path ASC, line_offset ASC)`` order — rebuilding session_meta /
|
|
4541
|
+
turn_context sticky state as it goes. The physical event log and Codex
|
|
4542
|
+
accounting/thread/quota rows are the replay SOURCE and are never touched.
|
|
4543
|
+
|
|
4544
|
+
Takes ``cache.db.codex.lock`` with the same fcntl-then-``BEGIN IMMEDIATE``
|
|
4545
|
+
order as ``sync_codex_cache``, raising ``MigrationGateNotMet`` on contention
|
|
4546
|
+
(defer, never partially normalize) from both the ordinary and eager cache
|
|
4547
|
+
dispatch paths. Deterministic replay order + the non-AUTOINCREMENT ``id``
|
|
4548
|
+
rowid alias + the storm-free full-clear make a re-run (handler success, crash
|
|
4549
|
+
before the central stamp) byte-idempotent INCLUDING the FTS shadow tables.
|
|
4550
|
+
Never self-stamps; the dispatcher owns ``schema_migrations`` and
|
|
4551
|
+
``user_version``.
|
|
4552
|
+
"""
|
|
4553
|
+
lock_path = _cache_db_codex_lock_path_for_conn(conn)
|
|
4554
|
+
lock_fh = None
|
|
4555
|
+
if lock_path is not None:
|
|
4556
|
+
lock_fh = open(lock_path, "w")
|
|
4557
|
+
try:
|
|
4558
|
+
fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
4559
|
+
except BlockingIOError:
|
|
4560
|
+
lock_fh.close()
|
|
4561
|
+
raise MigrationGateNotMet(
|
|
4562
|
+
"cache.db.codex.lock held by a concurrent sync_codex_cache; "
|
|
4563
|
+
"deferring cache 025 conversation normalization (#294 S6)"
|
|
4564
|
+
)
|
|
4565
|
+
try:
|
|
4566
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
4567
|
+
try:
|
|
4568
|
+
# Full clear of only the DERIVED normalized tables (the event log
|
|
4569
|
+
# remains as the replay source), then re-derive from it.
|
|
4570
|
+
_codex_conversation_fts_full_clear(conn)
|
|
4571
|
+
import _cctally_cache
|
|
4572
|
+
_cctally_cache._replay_codex_normalization(conn)
|
|
4281
4573
|
conn.commit()
|
|
4282
4574
|
except Exception:
|
|
4283
4575
|
conn.rollback()
|
|
@@ -5802,3 +6094,155 @@ def cmd_db_checkpoint(args: argparse.Namespace) -> int:
|
|
|
5802
6094
|
print(f"cctally: {result.db} WAL {mb_b:.1f} MB -> {mb_a:.1f} MB "
|
|
5803
6095
|
f"({result.frames_checkpointed} frames; {state}).")
|
|
5804
6096
|
return 0 if result.truncated else 3
|
|
6097
|
+
|
|
6098
|
+
|
|
6099
|
+
# VACUUM writes a full fresh copy of the database into a temporary file and then
|
|
6100
|
+
# swaps it in, so it transiently needs roughly the DB's own size on top of the
|
|
6101
|
+
# existing file, plus room for the drained WAL. A short busy_timeout keeps a
|
|
6102
|
+
# contended VACUUM from hanging (F13).
|
|
6103
|
+
_VACUUM_BUSY_TIMEOUT_MS = 250
|
|
6104
|
+
|
|
6105
|
+
|
|
6106
|
+
def _free_disk_bytes(directory) -> int:
|
|
6107
|
+
"""Free bytes on the filesystem holding ``directory`` (mockable in tests)."""
|
|
6108
|
+
import shutil
|
|
6109
|
+
return shutil.disk_usage(str(directory)).free
|
|
6110
|
+
|
|
6111
|
+
|
|
6112
|
+
def _vacuum_required_free_bytes(path) -> int:
|
|
6113
|
+
"""Conservative free-space floor to VACUUM ``path``: ~2x the DB file plus its
|
|
6114
|
+
current WAL sidecar (F13 — the VACUUM temp copy + a WAL/temp margin)."""
|
|
6115
|
+
try:
|
|
6116
|
+
db_bytes = path.stat().st_size
|
|
6117
|
+
except OSError:
|
|
6118
|
+
db_bytes = 0
|
|
6119
|
+
wal = path.parent / (path.name + "-wal")
|
|
6120
|
+
try:
|
|
6121
|
+
wal_bytes = wal.stat().st_size
|
|
6122
|
+
except OSError:
|
|
6123
|
+
wal_bytes = 0
|
|
6124
|
+
return 2 * db_bytes + wal_bytes
|
|
6125
|
+
|
|
6126
|
+
|
|
6127
|
+
def _run_vacuum_exclusive(path, label: str) -> int:
|
|
6128
|
+
"""Checkpoint + VACUUM ``path`` under a real SQLite EXCLUSIVE lock (F13).
|
|
6129
|
+
|
|
6130
|
+
``locking_mode=EXCLUSIVE`` + a short ``busy_timeout`` make a concurrent
|
|
6131
|
+
reader/writer FAIL PROMPTLY (no TOCTOU gap — the exclusion is the DB's own
|
|
6132
|
+
lock, which the advisory flocks do not provide against dashboard readers).
|
|
6133
|
+
Exit 0 on success, 3 when the DB is in use."""
|
|
6134
|
+
conn = sqlite3.connect(f"file:{path}?mode=rw", uri=True)
|
|
6135
|
+
try:
|
|
6136
|
+
conn.execute(f"PRAGMA busy_timeout={_VACUUM_BUSY_TIMEOUT_MS}")
|
|
6137
|
+
page_size = conn.execute("PRAGMA page_size").fetchone()[0]
|
|
6138
|
+
before = conn.execute("PRAGMA page_count").fetchone()[0]
|
|
6139
|
+
try:
|
|
6140
|
+
conn.execute("PRAGMA locking_mode=EXCLUSIVE")
|
|
6141
|
+
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
6142
|
+
conn.execute("VACUUM")
|
|
6143
|
+
except sqlite3.OperationalError as exc:
|
|
6144
|
+
if "lock" in str(exc).lower() or "busy" in str(exc).lower():
|
|
6145
|
+
eprint(
|
|
6146
|
+
f"cctally: {label} is in use — VACUUM needs exclusive access. "
|
|
6147
|
+
f"Stop the dashboard and any other cctally process holding "
|
|
6148
|
+
f"{label}, then retry."
|
|
6149
|
+
)
|
|
6150
|
+
return 3
|
|
6151
|
+
raise
|
|
6152
|
+
after = conn.execute("PRAGMA page_count").fetchone()[0]
|
|
6153
|
+
freed_mb = max(0, (before - after)) * page_size / (1024 * 1024)
|
|
6154
|
+
print(
|
|
6155
|
+
f"cctally: {label} reclaimed {freed_mb:.1f} MB "
|
|
6156
|
+
f"({before} -> {after} pages)."
|
|
6157
|
+
)
|
|
6158
|
+
return 0
|
|
6159
|
+
finally:
|
|
6160
|
+
conn.close()
|
|
6161
|
+
|
|
6162
|
+
|
|
6163
|
+
def _vacuum_one_db(path, label: str, provider_locked: bool) -> int:
|
|
6164
|
+
if not path.exists():
|
|
6165
|
+
print(f"cctally: no {label} database file present; nothing to reclaim.")
|
|
6166
|
+
return 0
|
|
6167
|
+
needed = _vacuum_required_free_bytes(path)
|
|
6168
|
+
free = _free_disk_bytes(path.parent)
|
|
6169
|
+
if free < needed:
|
|
6170
|
+
eprint(
|
|
6171
|
+
f"cctally: not enough free disk to VACUUM {label}: need ~"
|
|
6172
|
+
f"{needed // (1024 * 1024)} MB free, have {free // (1024 * 1024)} MB. "
|
|
6173
|
+
f"Free up space and retry."
|
|
6174
|
+
)
|
|
6175
|
+
return 3
|
|
6176
|
+
core = _cctally_core
|
|
6177
|
+
try:
|
|
6178
|
+
core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
6179
|
+
except OSError:
|
|
6180
|
+
pass
|
|
6181
|
+
# Serialize against the retention prune and concurrent vacuums via the
|
|
6182
|
+
# dedicated maintenance flock (F13/F7), then the provider flocks for
|
|
6183
|
+
# cache.db. All non-blocking: fail promptly rather than hang.
|
|
6184
|
+
maint_fh = open(core.CACHE_LOCK_MAINTENANCE_PATH, "w")
|
|
6185
|
+
try:
|
|
6186
|
+
try:
|
|
6187
|
+
fcntl.flock(maint_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
6188
|
+
except (BlockingIOError, OSError):
|
|
6189
|
+
eprint(
|
|
6190
|
+
f"cctally: {label} VACUUM skipped: another maintenance operation "
|
|
6191
|
+
f"is running. Retry shortly."
|
|
6192
|
+
)
|
|
6193
|
+
return 3
|
|
6194
|
+
held = []
|
|
6195
|
+
try:
|
|
6196
|
+
if provider_locked:
|
|
6197
|
+
for lock_path, lname in (
|
|
6198
|
+
(core.CACHE_LOCK_PATH, "claude"),
|
|
6199
|
+
(core.CACHE_LOCK_CODEX_PATH, "codex"),
|
|
6200
|
+
):
|
|
6201
|
+
fh = open(lock_path, "w")
|
|
6202
|
+
try:
|
|
6203
|
+
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
6204
|
+
except (BlockingIOError, OSError):
|
|
6205
|
+
fh.close()
|
|
6206
|
+
eprint(
|
|
6207
|
+
f"cctally: {label} VACUUM skipped: a {lname} sync is in "
|
|
6208
|
+
f"progress. Stop the dashboard / other cctally "
|
|
6209
|
+
f"processes and retry."
|
|
6210
|
+
)
|
|
6211
|
+
return 3
|
|
6212
|
+
held.append(fh)
|
|
6213
|
+
return _run_vacuum_exclusive(path, label)
|
|
6214
|
+
finally:
|
|
6215
|
+
for fh in held:
|
|
6216
|
+
try:
|
|
6217
|
+
fcntl.flock(fh, fcntl.LOCK_UN)
|
|
6218
|
+
except OSError:
|
|
6219
|
+
pass
|
|
6220
|
+
fh.close()
|
|
6221
|
+
finally:
|
|
6222
|
+
try:
|
|
6223
|
+
fcntl.flock(maint_fh, fcntl.LOCK_UN)
|
|
6224
|
+
except OSError:
|
|
6225
|
+
pass
|
|
6226
|
+
maint_fh.close()
|
|
6227
|
+
|
|
6228
|
+
|
|
6229
|
+
def cmd_db_vacuum(args: argparse.Namespace) -> int:
|
|
6230
|
+
"""Reclaim disk space via VACUUM after a transcript prune (#313 P3, F13).
|
|
6231
|
+
|
|
6232
|
+
NEVER automatic. Holds the maintenance flock + (for cache.db) the provider
|
|
6233
|
+
flocks, then runs a checkpoint + VACUUM under a real SQLite EXCLUSIVE lock so
|
|
6234
|
+
a concurrent dashboard reader fails promptly instead of racing. Refuses when
|
|
6235
|
+
free disk is below ~2x the file + WAL. Exit 0 on success, 3 when a target is
|
|
6236
|
+
in use or disk is short."""
|
|
6237
|
+
which = getattr(args, "db", "cache")
|
|
6238
|
+
targets = []
|
|
6239
|
+
if which in ("cache", "all"):
|
|
6240
|
+
targets.append((_cctally_core.CACHE_DB_PATH, "cache.db", True))
|
|
6241
|
+
if which in ("stats", "all"):
|
|
6242
|
+
targets.append((_cctally_core.DB_PATH, "stats.db", False))
|
|
6243
|
+
overall = 0
|
|
6244
|
+
for path, label, provider_locked in targets:
|
|
6245
|
+
rc = _vacuum_one_db(path, label, provider_locked)
|
|
6246
|
+
if rc != 0:
|
|
6247
|
+
overall = rc
|
|
6248
|
+
return overall
|
package/bin/_cctally_parser.py
CHANGED
|
@@ -2134,6 +2134,14 @@ def _build_cache_sync_parser(subparsers, name, *, help_text, xref=None):
|
|
|
2134
2134
|
help="Prune cache rows for source files removed from disk "
|
|
2135
2135
|
"(e.g. a deleted git worktree) without a full rebuild.",
|
|
2136
2136
|
)
|
|
2137
|
+
p_cache_sync.add_argument(
|
|
2138
|
+
"--prune-conversations",
|
|
2139
|
+
action="store_true",
|
|
2140
|
+
help="Prune conversation transcripts older than "
|
|
2141
|
+
"conversation.retention_days (default 180) now, without a full "
|
|
2142
|
+
"rebuild. Re-derivable from JSONL; run `cctally db vacuum` to "
|
|
2143
|
+
"reclaim the freed disk space.",
|
|
2144
|
+
)
|
|
2137
2145
|
p_cache_sync.set_defaults(func=c.cmd_cache_sync)
|
|
2138
2146
|
|
|
2139
2147
|
def _build_project_parser(subparsers, name, *, help_text, xref=None, fixed_source=None):
|
|
@@ -2802,6 +2810,18 @@ def _build_db_parser(subparsers, name, *, help_text, xref=None):
|
|
|
2802
2810
|
help=argparse.SUPPRESS,
|
|
2803
2811
|
)
|
|
2804
2812
|
db_checkpoint.set_defaults(func=c.cmd_db_checkpoint)
|
|
2813
|
+
db_vacuum = db_sub.add_parser(
|
|
2814
|
+
"vacuum",
|
|
2815
|
+
help="Reclaim disk space after a transcript prune (VACUUM) — "
|
|
2816
|
+
"exclusive, never automatic",
|
|
2817
|
+
)
|
|
2818
|
+
db_vacuum.add_argument(
|
|
2819
|
+
"--db",
|
|
2820
|
+
choices=("cache", "stats", "all"),
|
|
2821
|
+
default="cache",
|
|
2822
|
+
help="Which DB to VACUUM (default: cache)",
|
|
2823
|
+
)
|
|
2824
|
+
db_vacuum.set_defaults(func=c.cmd_db_vacuum)
|
|
2805
2825
|
|
|
2806
2826
|
def _build_doctor_parser(subparsers, name, *, help_text, xref=None):
|
|
2807
2827
|
"""Build the `doctor` parser (registered via _REGISTRATION; #279 S6 W3).
|
package/bin/_cctally_quota.py
CHANGED
|
@@ -183,6 +183,32 @@ def _store_codex_quota_projection_certificate(
|
|
|
183
183
|
return
|
|
184
184
|
|
|
185
185
|
|
|
186
|
+
def _stats_projection_signatures_match(
|
|
187
|
+
stats_conn: sqlite3.Connection,
|
|
188
|
+
active_roots: set[str],
|
|
189
|
+
cert_sigs: Mapping[str, str],
|
|
190
|
+
) -> bool:
|
|
191
|
+
"""True iff stats.db's projection signature matches the certificate for every root.
|
|
192
|
+
|
|
193
|
+
The cache certificate alone does not prove stats.db still holds the
|
|
194
|
+
projection: stats.db can be independently wiped/recovered while cache.db
|
|
195
|
+
persists (F1). Require an exact ``quota_projection_state.physical_signature``
|
|
196
|
+
match for every active root before the reconcile is allowed to short-circuit.
|
|
197
|
+
A missing row, a mismatch, or any ``sqlite3.Error`` degrades to False, which
|
|
198
|
+
forces the full reconcile (fail-safe).
|
|
199
|
+
"""
|
|
200
|
+
try:
|
|
201
|
+
rows = stats_conn.execute(
|
|
202
|
+
"SELECT source_root_key, physical_signature FROM quota_projection_state"
|
|
203
|
+
).fetchall()
|
|
204
|
+
except sqlite3.Error:
|
|
205
|
+
return False
|
|
206
|
+
projection = {str(row[0]): str(row[1]) for row in rows}
|
|
207
|
+
return all(
|
|
208
|
+
projection.get(root) == cert_sigs.get(root) for root in active_roots
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
186
212
|
def load_codex_quota_observations(
|
|
187
213
|
*,
|
|
188
214
|
source_root_keys: Iterable[str] | None = None,
|
|
@@ -754,22 +780,48 @@ def reconcile_codex_quota_projection(
|
|
|
754
780
|
raise ValueError("now must be timezone-aware")
|
|
755
781
|
now_iso = _utc_iso(now)
|
|
756
782
|
|
|
783
|
+
alert_eligible_roots = {str(key) for key in alert_eligible_root_keys}
|
|
784
|
+
|
|
757
785
|
try:
|
|
758
786
|
cache = _cache_connection()
|
|
759
787
|
except (FileNotFoundError, sqlite3.Error):
|
|
760
788
|
return QuotaProjectionResult(None, 0, 0, 0, 0, 0, 0)
|
|
761
789
|
try:
|
|
762
|
-
active_roots
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
)
|
|
766
|
-
|
|
790
|
+
# F2: read active_roots, the physical sequence, and the certificate
|
|
791
|
+
# inside ONE WAL read snapshot so a concurrent commit cannot interleave
|
|
792
|
+
# a stale sequence with a fresh certificate.
|
|
793
|
+
cache.execute("BEGIN")
|
|
794
|
+
try:
|
|
795
|
+
active_roots = (
|
|
796
|
+
_cache_root_keys(cache)
|
|
797
|
+
if source_root_keys is None else {str(key) for key in source_root_keys}
|
|
798
|
+
)
|
|
799
|
+
physical_sequence = codex_physical_mutation_seq(cache)
|
|
800
|
+
certificate = load_codex_quota_projection_certificate(cache)
|
|
801
|
+
finally:
|
|
802
|
+
cache.commit()
|
|
803
|
+
# Short-circuit: when nothing is alert-eligible and the certificate
|
|
804
|
+
# proves the cache physical state is current AND the stats-side
|
|
805
|
+
# projection still matches it (F1), the ~2.9 s observation load and the
|
|
806
|
+
# whole reconcile are provably a no-op. Any missed concurrent write
|
|
807
|
+
# leaves cur_seq != cert_seq (or a stats-signature mismatch) on the next
|
|
808
|
+
# call, so the scheme is self-healing.
|
|
809
|
+
if not alert_eligible_roots and certificate is not None:
|
|
810
|
+
cert_seq, cert_sigs = certificate
|
|
811
|
+
if physical_sequence == cert_seq and active_roots <= set(cert_sigs):
|
|
812
|
+
stats_conn = _cctally_core.open_db()
|
|
813
|
+
try:
|
|
814
|
+
if _stats_projection_signatures_match(
|
|
815
|
+
stats_conn, active_roots, cert_sigs
|
|
816
|
+
):
|
|
817
|
+
return QuotaProjectionResult(None, 0, 0, 0, 0, 0, 0)
|
|
818
|
+
finally:
|
|
819
|
+
stats_conn.close()
|
|
767
820
|
observations = load_codex_quota_observations(
|
|
768
821
|
source_root_keys=active_roots, cache_conn=cache,
|
|
769
822
|
)
|
|
770
823
|
finally:
|
|
771
824
|
cache.close()
|
|
772
|
-
alert_eligible_roots = {str(key) for key in alert_eligible_root_keys}
|
|
773
825
|
|
|
774
826
|
# No configured roots and no existing interpreted history means there is no
|
|
775
827
|
# stats work. This preserves the existing empty-Codex sync fast path.
|