cctally 1.91.0 → 1.92.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 +39 -0
- package/bin/_cctally_cache.py +863 -74
- package/bin/_cctally_config.py +57 -0
- package/bin/_cctally_core.py +39 -8
- package/bin/_cctally_dashboard.py +146 -5
- package/bin/_cctally_dashboard_conversation.py +164 -18
- package/bin/_cctally_dashboard_envelope.py +2 -0
- package/bin/_cctally_db.py +372 -10
- package/bin/_cctally_doctor.py +18 -1
- package/bin/_cctally_journal.py +535 -13
- package/bin/_cctally_journal_repair.py +6 -0
- package/bin/_cctally_parser.py +6 -0
- package/bin/_cctally_quota.py +171 -55
- package/bin/_cctally_record.py +13 -1
- package/bin/_cctally_rederive.py +4 -0
- package/bin/_cctally_store.py +311 -6
- package/bin/_cctally_transcript.py +32 -2
- package/bin/_lib_cache_report.py +8 -3
- package/bin/_lib_codex_conversation.py +851 -81
- package/bin/_lib_codex_conversation_query.py +2005 -95
- package/bin/_lib_codex_find_projection.py +370 -0
- package/bin/_lib_codex_harness_preamble.py +176 -0
- package/bin/_lib_codex_hooks.py +5 -3
- package/bin/_lib_codex_js_scan.py +254 -0
- package/bin/_lib_codex_landmarks.py +309 -0
- package/bin/_lib_codex_title_clean.py +116 -0
- package/bin/_lib_conversation_dispatch.py +153 -21
- package/bin/_lib_conversation_watch.py +4 -2
- package/bin/_lib_doctor.py +64 -0
- package/bin/_lib_quota_alert_axes.py +31 -34
- package/bin/_lib_stats_damage.py +523 -0
- package/bin/cctally +5 -0
- package/dashboard/static/assets/index-BEzzJtUd.js +97 -0
- package/dashboard/static/assets/{index-Dwirao3Y.css → index-DnWdv8um.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +7 -1
- package/dashboard/static/assets/index-CILAoEja.js +0 -90
package/bin/_cctally_db.py
CHANGED
|
@@ -275,6 +275,16 @@ class StatsDbCorruptError(sqlite3.DatabaseError):
|
|
|
275
275
|
"""
|
|
276
276
|
|
|
277
277
|
|
|
278
|
+
class StatsPublicationFailedError(StatsDbCorruptError):
|
|
279
|
+
"""A replacement stats index was published and then FAILED validation.
|
|
280
|
+
|
|
281
|
+
Distinct from ``StatsDbCorruptError``'s ordinary case because replacement
|
|
282
|
+
already occurred, so the inherited "Not auto-recreated" wording would be
|
|
283
|
+
false. Subclasses it deliberately: every graceful-degrade site and the CLI
|
|
284
|
+
boundary's staged exit 3 keep applying unchanged (#496 S1 F1).
|
|
285
|
+
"""
|
|
286
|
+
|
|
287
|
+
|
|
278
288
|
class StatsDbMaintenanceError(sqlite3.OperationalError):
|
|
279
289
|
"""A guided repair owns stats.db; new cctally opens must stay out.
|
|
280
290
|
|
|
@@ -778,6 +788,114 @@ def _corruption_trigger_record(
|
|
|
778
788
|
}
|
|
779
789
|
|
|
780
790
|
|
|
791
|
+
def _capture_corruption_wal_evidence(
|
|
792
|
+
db_path: pathlib.Path, ts: str, *, db_label: str, trigger_exception,
|
|
793
|
+
) -> dict:
|
|
794
|
+
"""Copy the WAL/SHM bytes present at forensics time (#496 S1 F2).
|
|
795
|
+
|
|
796
|
+
Scoped to stats.db. Retention for `logs/stats.db-corruption-forensics-<ts>/`
|
|
797
|
+
is handed to S6 alongside the other new log families; nobody owns retention
|
|
798
|
+
for a cache.db or conversations.db evidence directory, and those WALs are
|
|
799
|
+
not bounded by stats.db's 16 MiB `journal_size_limit` — `cache.db-wal` has
|
|
800
|
+
been observed above 256 MiB under a multi-agent hook storm (#297).
|
|
801
|
+
Generalizing this stays available to a later session that brings its own
|
|
802
|
+
retention story.
|
|
803
|
+
|
|
804
|
+
This is a FORENSICS-TIME capture, not an exact detection-time one, and the
|
|
805
|
+
difference is real: the connection whose failure classified the corruption
|
|
806
|
+
is closed inside ``open_db`` before the heal hook runs, and the heal's
|
|
807
|
+
locked re-check probe runs before this, so a clean close of that read-write
|
|
808
|
+
connection could already have checkpointed the WAL if it was the last
|
|
809
|
+
handle. Closing that residual gap means capturing at the ``open_db``
|
|
810
|
+
corruption boundary, which is not this session's surface.
|
|
811
|
+
|
|
812
|
+
Field evidence bounds the cost: 41 of the 74 retained production bundles
|
|
813
|
+
recorded a non-empty WAL at exactly this point.
|
|
814
|
+
|
|
815
|
+
Both the heal's re-check and the bundle's own integrity probe open
|
|
816
|
+
read-only, and a read-only connection cannot checkpoint, so nothing between
|
|
817
|
+
this capture and those probes can empty the WAL.
|
|
818
|
+
"""
|
|
819
|
+
empty = {"disposition": None, "path": None, "bytes": {}, "reason": None}
|
|
820
|
+
if db_label != "stats":
|
|
821
|
+
# Recorded rather than omitted, so a cache bundle still says WHY it
|
|
822
|
+
# carries no evidence.
|
|
823
|
+
return {**empty, "disposition": "skipped_not_stats"}
|
|
824
|
+
if trigger_exception is None or not _is_sqlite_corruption_error(
|
|
825
|
+
trigger_exception
|
|
826
|
+
):
|
|
827
|
+
# A deliberate `db rebuild` on a healthy index must not accumulate
|
|
828
|
+
# evidence directories.
|
|
829
|
+
return {**empty, "disposition": "skipped_not_corruption"}
|
|
830
|
+
wal = pathlib.Path(str(db_path) + "-wal")
|
|
831
|
+
try:
|
|
832
|
+
wal_bytes = wal.stat().st_size
|
|
833
|
+
except OSError:
|
|
834
|
+
wal_bytes = 0
|
|
835
|
+
if wal_bytes <= 0:
|
|
836
|
+
return {**empty, "disposition": "skipped_empty"}
|
|
837
|
+
try:
|
|
838
|
+
evidence = (
|
|
839
|
+
_cctally_core.LOG_DIR
|
|
840
|
+
/ f"{db_path.name}-corruption-forensics-{ts}"
|
|
841
|
+
)
|
|
842
|
+
evidence.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
843
|
+
# The main file is deliberately NOT copied: it is tens of megabytes,
|
|
844
|
+
# and the cutover's preservation retains it anyway.
|
|
845
|
+
_copy_db_family(
|
|
846
|
+
db_path, evidence / db_path.name, suffixes=("-wal", "-shm"),
|
|
847
|
+
)
|
|
848
|
+
copied: dict = {}
|
|
849
|
+
for suffix in ("-wal", "-shm"):
|
|
850
|
+
member = evidence / f"{db_path.name}{suffix}"
|
|
851
|
+
try:
|
|
852
|
+
copied[member.name] = member.stat().st_size
|
|
853
|
+
except OSError:
|
|
854
|
+
copied[member.name] = None
|
|
855
|
+
return {
|
|
856
|
+
"disposition": "captured",
|
|
857
|
+
"path": str(evidence),
|
|
858
|
+
"bytes": copied,
|
|
859
|
+
"reason": None,
|
|
860
|
+
}
|
|
861
|
+
except Exception as exc: # noqa: BLE001 — enrichment never breaks a heal
|
|
862
|
+
# Deliberately broader than OSError. An escaping exception propagates
|
|
863
|
+
# out of `write_corruption_forensics`, and in the cache path that
|
|
864
|
+
# caller's own `except Exception` then DECLINES destructive recovery —
|
|
865
|
+
# so a diagnostic copy failure would change what the heal does.
|
|
866
|
+
return {
|
|
867
|
+
**empty,
|
|
868
|
+
"disposition": "failed",
|
|
869
|
+
"reason": _bounded_forensics_text(
|
|
870
|
+
exc, _FORENSICS_EXCEPTION_MESSAGE_MAX,
|
|
871
|
+
),
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def _describe_corruption_damage(integrity_rows, probe_db_path) -> dict:
|
|
876
|
+
"""Structured damage description, or a recorded reason it is unavailable.
|
|
877
|
+
|
|
878
|
+
A forensics bundle must never fail to write because characterization
|
|
879
|
+
failed, so every exception is captured rather than propagated (#496 S1 F8).
|
|
880
|
+
"""
|
|
881
|
+
try:
|
|
882
|
+
import _lib_stats_damage
|
|
883
|
+
|
|
884
|
+
return _lib_stats_damage.describe_damage(
|
|
885
|
+
integrity_rows=integrity_rows, path=probe_db_path,
|
|
886
|
+
)
|
|
887
|
+
except Exception as exc:
|
|
888
|
+
return {
|
|
889
|
+
"schemaVersion": 1,
|
|
890
|
+
"method": "unavailable",
|
|
891
|
+
"findings": [],
|
|
892
|
+
"shapeToken": "none",
|
|
893
|
+
"reason": _bounded_forensics_text(
|
|
894
|
+
exc, _FORENSICS_EXCEPTION_MESSAGE_MAX,
|
|
895
|
+
),
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
|
|
781
899
|
def write_corruption_forensics(
|
|
782
900
|
db_path,
|
|
783
901
|
*,
|
|
@@ -834,6 +952,12 @@ def write_corruption_forensics(
|
|
|
834
952
|
bundle["trigger"] = _corruption_trigger_record(
|
|
835
953
|
trigger_origin, trigger_exception,
|
|
836
954
|
)
|
|
955
|
+
# Capture the WAL before the integrity probe, and before anything else can
|
|
956
|
+
# disturb it (#496 S1 F2). Shares the bundle's timestamp stem so the pair
|
|
957
|
+
# is obvious.
|
|
958
|
+
bundle["walEvidence"] = _capture_corruption_wal_evidence(
|
|
959
|
+
db_path, ts, db_label=db_label, trigger_exception=trigger_exception,
|
|
960
|
+
)
|
|
837
961
|
for suffix in ("", "-wal", "-shm"):
|
|
838
962
|
p = pathlib.Path(str(db_path) + suffix)
|
|
839
963
|
try:
|
|
@@ -876,6 +1000,11 @@ def write_corruption_forensics(
|
|
|
876
1000
|
reason = "integrity_check_unavailable"
|
|
877
1001
|
bundle["probeDisposition"] = disposition.value
|
|
878
1002
|
bundle["probeReason"] = reason
|
|
1003
|
+
# Scanned against `probe_db_path`, the same artifact `integrityCheck`
|
|
1004
|
+
# describes, so a `both` verdict never mixes two files.
|
|
1005
|
+
bundle["damage"] = _describe_corruption_damage(
|
|
1006
|
+
bundle["integrityCheck"], probe_db_path,
|
|
1007
|
+
)
|
|
879
1008
|
try:
|
|
880
1009
|
cp = subprocess.run(
|
|
881
1010
|
["lsof", "--", str(db_path)],
|
|
@@ -1196,7 +1325,15 @@ def cmd_db_rebuild(args: argparse.Namespace) -> int:
|
|
|
1196
1325
|
# authorizer-armed `open_db(_target_path=...)` connection, and we
|
|
1197
1326
|
# hold maintenance exclusive, which is what spec §3.1 sanctions.
|
|
1198
1327
|
with _cctally_store.stats_write_scope("maintenance-rebuild"):
|
|
1199
|
-
result = _cctally_journal.rebuild_stats_index(
|
|
1328
|
+
result = _cctally_journal.rebuild_stats_index(
|
|
1329
|
+
context=_cctally_journal.RebuildContext(
|
|
1330
|
+
trigger="db-rebuild",
|
|
1331
|
+
trigger_error=None,
|
|
1332
|
+
forensics_path=(
|
|
1333
|
+
str(forensics) if forensics is not None else None
|
|
1334
|
+
),
|
|
1335
|
+
)
|
|
1336
|
+
)
|
|
1200
1337
|
incident = result.quarantine_dir
|
|
1201
1338
|
except Exception as exc:
|
|
1202
1339
|
eprint(f"cctally: stats.db rebuild failed: {exc}")
|
|
@@ -3781,7 +3918,8 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
|
|
|
3781
3918
|
ON codex_conversation_rollups(last_activity_utc DESC, conversation_key DESC);
|
|
3782
3919
|
|
|
3783
3920
|
-- codex_conversation_file_touches: write-class axis for the `files`
|
|
3784
|
-
-- search kind
|
|
3921
|
+
-- search kind (§3.3). The richer outline file stats stay derived from
|
|
3922
|
+
-- retained event payloads at read time. source_path gives explicit
|
|
3785
3923
|
-- lineage so the per-file delete/truncate/prune paths scope deletions
|
|
3786
3924
|
-- exactly as they do for the other Codex families.
|
|
3787
3925
|
CREATE TABLE IF NOT EXISTS codex_conversation_file_touches (
|
|
@@ -4145,7 +4283,8 @@ def _apply_conversations_schema(conn: sqlite3.Connection) -> None:
|
|
|
4145
4283
|
).fetchone()
|
|
4146
4284
|
except sqlite3.OperationalError:
|
|
4147
4285
|
current = None
|
|
4148
|
-
if current is not None and current[0] == "
|
|
4286
|
+
if current is not None and current[0] == "2":
|
|
4287
|
+
_apply_codex_find_projection_schema(conn)
|
|
4149
4288
|
return
|
|
4150
4289
|
|
|
4151
4290
|
_apply_cache_schema(conn)
|
|
@@ -4195,11 +4334,81 @@ def _apply_conversations_schema(conn: sqlite3.Connection) -> None:
|
|
|
4195
4334
|
);
|
|
4196
4335
|
"""
|
|
4197
4336
|
)
|
|
4337
|
+
# #347: transcript attribution belongs to the physical rows. These are
|
|
4338
|
+
# pure column additions; migration 005 owns only the one-time legacy
|
|
4339
|
+
# backfill policy. NULL is the stored spelling of the `unattributed`
|
|
4340
|
+
# sentinel, matching the accounting cache contract from #341.
|
|
4341
|
+
add_column_if_missing(conn, "conversation_messages", "account_key", "TEXT")
|
|
4342
|
+
add_column_if_missing(conn, "codex_conversation_events", "account_key", "TEXT")
|
|
4343
|
+
add_column_if_missing(conn, "codex_conversation_messages", "account_key", "TEXT")
|
|
4344
|
+
conn.execute(
|
|
4345
|
+
"CREATE INDEX IF NOT EXISTS idx_conv_messages_account_session "
|
|
4346
|
+
"ON conversation_messages(account_key, session_id, timestamp_utc, id)"
|
|
4347
|
+
)
|
|
4348
|
+
conn.execute(
|
|
4349
|
+
"CREATE INDEX IF NOT EXISTS idx_codex_conv_messages_account_conversation "
|
|
4350
|
+
"ON codex_conversation_messages(account_key, conversation_key, timestamp_utc, id)"
|
|
4351
|
+
)
|
|
4352
|
+
conn.execute(
|
|
4353
|
+
"CREATE INDEX IF NOT EXISTS idx_codex_events_account_conversation "
|
|
4354
|
+
"ON codex_conversation_events(account_key, conversation_key, line_offset)"
|
|
4355
|
+
)
|
|
4198
4356
|
conn.execute(
|
|
4199
4357
|
"INSERT INTO cache_meta(key,value) VALUES "
|
|
4200
|
-
"('conversation_schema_version','
|
|
4358
|
+
"('conversation_schema_version','2') "
|
|
4201
4359
|
"ON CONFLICT(key) DO UPDATE SET value=excluded.value"
|
|
4202
4360
|
)
|
|
4361
|
+
_apply_codex_find_projection_schema(conn)
|
|
4362
|
+
|
|
4363
|
+
|
|
4364
|
+
def _apply_codex_find_projection_schema(conn: sqlite3.Connection) -> None:
|
|
4365
|
+
"""Create #482's disposable visible-text projection tables.
|
|
4366
|
+
|
|
4367
|
+
This helper is called even when the conversations base-schema marker is
|
|
4368
|
+
current: migration 004 must be able to add the derivation without bumping
|
|
4369
|
+
or replaying the much larger historical transcript schema.
|
|
4370
|
+
"""
|
|
4371
|
+
conn.executescript(
|
|
4372
|
+
"""
|
|
4373
|
+
CREATE TABLE IF NOT EXISTS codex_find_projection (
|
|
4374
|
+
message_id INTEGER NOT NULL,
|
|
4375
|
+
conversation_key TEXT NOT NULL,
|
|
4376
|
+
item_key TEXT NOT NULL,
|
|
4377
|
+
block_key TEXT NOT NULL,
|
|
4378
|
+
container_block_key TEXT NOT NULL,
|
|
4379
|
+
surface TEXT NOT NULL
|
|
4380
|
+
CHECK(surface IN ('body','call','output','completion')),
|
|
4381
|
+
render_order INTEGER NOT NULL,
|
|
4382
|
+
projected_text TEXT NOT NULL,
|
|
4383
|
+
leaves_json TEXT NOT NULL,
|
|
4384
|
+
disclosure_json TEXT NOT NULL,
|
|
4385
|
+
projection_version INTEGER NOT NULL,
|
|
4386
|
+
PRIMARY KEY(message_id, surface),
|
|
4387
|
+
FOREIGN KEY(message_id) REFERENCES codex_conversation_messages(id)
|
|
4388
|
+
ON DELETE CASCADE
|
|
4389
|
+
);
|
|
4390
|
+
CREATE INDEX IF NOT EXISTS idx_codex_find_projection_conversation_order
|
|
4391
|
+
ON codex_find_projection(
|
|
4392
|
+
conversation_key, render_order, message_id, surface
|
|
4393
|
+
);
|
|
4394
|
+
CREATE TRIGGER IF NOT EXISTS codex_find_projection_message_ad
|
|
4395
|
+
AFTER DELETE ON codex_conversation_messages BEGIN
|
|
4396
|
+
DELETE FROM codex_find_projection WHERE message_id=old.id;
|
|
4397
|
+
END;
|
|
4398
|
+
"""
|
|
4399
|
+
)
|
|
4400
|
+
# Fresh stores are complete without a backfill. Populated upgrades are
|
|
4401
|
+
# armed by migration 004 under the provider flock.
|
|
4402
|
+
has_state = conn.execute(
|
|
4403
|
+
"SELECT 1 FROM cache_meta WHERE key IN "
|
|
4404
|
+
"('codex_find_projection_complete_version',"
|
|
4405
|
+
" 'codex_find_projection_backfill_pending') LIMIT 1"
|
|
4406
|
+
).fetchone()
|
|
4407
|
+
if has_state is None and conn.execute(
|
|
4408
|
+
"SELECT 1 FROM codex_conversation_messages LIMIT 1"
|
|
4409
|
+
).fetchone() is None:
|
|
4410
|
+
_set_cache_meta(conn, "codex_find_projection_complete_version", "1")
|
|
4411
|
+
_set_cache_meta(conn, "codex_find_projection_generation", "0")
|
|
4203
4412
|
|
|
4204
4413
|
|
|
4205
4414
|
def _fts5_available(conn: sqlite3.Connection) -> bool:
|
|
@@ -4336,6 +4545,139 @@ def _conv_003_background_mcp_result_replay(conn: sqlite3.Connection) -> None:
|
|
|
4336
4545
|
_release_cache_db_writer_flocks(held)
|
|
4337
4546
|
|
|
4338
4547
|
|
|
4548
|
+
@conversations_migration("004_codex_find_projection")
|
|
4549
|
+
def _conv_004_codex_find_projection(conn: sqlite3.Connection) -> None:
|
|
4550
|
+
"""Arm #482's bounded projection backfill for populated Codex stores.
|
|
4551
|
+
|
|
4552
|
+
The projection is fully derivable from retained normalized messages and
|
|
4553
|
+
event payloads, so the migration never replays JSONL. The normal Codex
|
|
4554
|
+
conversation synchronizer consumes the marker under the same provider
|
|
4555
|
+
flock, in bounded batches. Fresh empty stores were certified by the schema
|
|
4556
|
+
helper and this handler becomes an idempotent no-op.
|
|
4557
|
+
"""
|
|
4558
|
+
held = _acquire_conversations_db_codex_provider_flock(
|
|
4559
|
+
conn, migration="conversations 004 Codex find projection")
|
|
4560
|
+
try:
|
|
4561
|
+
_apply_codex_find_projection_schema(conn)
|
|
4562
|
+
complete = conn.execute(
|
|
4563
|
+
"SELECT 1 FROM cache_meta "
|
|
4564
|
+
"WHERE key='codex_find_projection_complete_version' AND value='1'"
|
|
4565
|
+
).fetchone()
|
|
4566
|
+
if complete is None:
|
|
4567
|
+
_set_cache_meta(conn, "codex_find_projection_backfill_pending", "1")
|
|
4568
|
+
conn.execute(
|
|
4569
|
+
"INSERT OR IGNORE INTO cache_meta(key,value) VALUES"
|
|
4570
|
+
"('codex_find_projection_backfill_cursor','0')"
|
|
4571
|
+
)
|
|
4572
|
+
_set_cache_meta(conn, "codex_find_projection_generation", "0")
|
|
4573
|
+
conn.commit()
|
|
4574
|
+
finally:
|
|
4575
|
+
_release_cache_db_writer_flocks(held)
|
|
4576
|
+
|
|
4577
|
+
|
|
4578
|
+
@conversations_migration("005_conversation_account_dimension")
|
|
4579
|
+
def _conv_005_conversation_account_dimension(conn: sqlite3.Connection) -> None:
|
|
4580
|
+
"""Backfill the #347 transcript account dimension.
|
|
4581
|
+
|
|
4582
|
+
Decision R4 from #341 is the compatibility rule: retained Claude history
|
|
4583
|
+
belongs to the journaled cutover account, while retained Codex history is
|
|
4584
|
+
genuinely unrecoverable and remains NULL/``unattributed``. The physical
|
|
4585
|
+
columns are added by the base-schema helper before dispatch; this handler
|
|
4586
|
+
owns only the data-shape transition and is idempotent.
|
|
4587
|
+
"""
|
|
4588
|
+
import _cctally_journal
|
|
4589
|
+
import _lib_accounts
|
|
4590
|
+
|
|
4591
|
+
held = _acquire_conversations_db_claude_provider_flock(
|
|
4592
|
+
conn, migration="conversations 005 account dimension"
|
|
4593
|
+
)
|
|
4594
|
+
try:
|
|
4595
|
+
held += _acquire_conversations_db_codex_provider_flock(
|
|
4596
|
+
conn, migration="conversations 005 account dimension"
|
|
4597
|
+
)
|
|
4598
|
+
claude_key = _cctally_journal.find_accounts_cutover_op()
|
|
4599
|
+
if claude_key is None:
|
|
4600
|
+
pending = conn.execute(
|
|
4601
|
+
"SELECT 1 FROM conversation_messages "
|
|
4602
|
+
"WHERE account_key IS NULL LIMIT 1"
|
|
4603
|
+
).fetchone()
|
|
4604
|
+
if pending is not None:
|
|
4605
|
+
raise MigrationGateNotMet(
|
|
4606
|
+
"accounts cutover op not yet appended; deferring Claude "
|
|
4607
|
+
"conversation backfill until the epoch transition records it"
|
|
4608
|
+
)
|
|
4609
|
+
claude_key = _lib_accounts.UNATTRIBUTED
|
|
4610
|
+
stored_key = (
|
|
4611
|
+
None if claude_key == _lib_accounts.UNATTRIBUTED else claude_key
|
|
4612
|
+
)
|
|
4613
|
+
conn.execute(
|
|
4614
|
+
"UPDATE conversation_messages SET account_key=? "
|
|
4615
|
+
"WHERE account_key IS NULL",
|
|
4616
|
+
(stored_key,),
|
|
4617
|
+
)
|
|
4618
|
+
# Explicitly preserve the pre-feature Codex decision. This UPDATE makes
|
|
4619
|
+
# the idempotent intent visible in the migration golden without guessing
|
|
4620
|
+
# from whichever auth.json is active during upgrade.
|
|
4621
|
+
conn.execute(
|
|
4622
|
+
"UPDATE codex_conversation_events SET account_key=NULL "
|
|
4623
|
+
"WHERE account_key IS NULL"
|
|
4624
|
+
)
|
|
4625
|
+
conn.execute(
|
|
4626
|
+
"UPDATE codex_conversation_messages SET account_key=NULL "
|
|
4627
|
+
"WHERE account_key IS NULL"
|
|
4628
|
+
)
|
|
4629
|
+
_set_cache_meta(conn, "conversation_account_dimension", "1")
|
|
4630
|
+
conn.commit()
|
|
4631
|
+
finally:
|
|
4632
|
+
_release_cache_db_writer_flocks(held)
|
|
4633
|
+
|
|
4634
|
+
|
|
4635
|
+
@conversations_migration("006_backfill_codex_file_touches")
|
|
4636
|
+
def _conv_006_backfill_codex_file_touches(conn: sqlite3.Connection) -> None:
|
|
4637
|
+
"""Repair the file-search projection for retained dict-shaped patches.
|
|
4638
|
+
|
|
4639
|
+
The normalized writer historically recognized only list-shaped ``changes``;
|
|
4640
|
+
real Codex patch completions use an object keyed by file path. The physical
|
|
4641
|
+
event payloads are retained unbounded, so rebuild the derived touch rows from
|
|
4642
|
+
those authoritative bytes and link only events that still have a normalized
|
|
4643
|
+
message. ``INSERT OR IGNORE`` preserves valid legacy-list rows and makes a
|
|
4644
|
+
markerless retry idempotent.
|
|
4645
|
+
"""
|
|
4646
|
+
import _lib_codex_conversation as conversation
|
|
4647
|
+
|
|
4648
|
+
held = _acquire_conversations_db_codex_provider_flock(
|
|
4649
|
+
conn, migration="conversations 006 Codex file touches")
|
|
4650
|
+
try:
|
|
4651
|
+
pending: list[tuple[int, str, str, str, str]] = []
|
|
4652
|
+
for message_id, conversation_key, source_path, payload_json in conn.execute(
|
|
4653
|
+
"SELECT m.id,m.conversation_key,m.source_path,e.payload_json "
|
|
4654
|
+
"FROM codex_conversation_events e "
|
|
4655
|
+
"JOIN codex_conversation_messages m "
|
|
4656
|
+
"ON m.source_path=e.source_path AND m.line_offset=e.line_offset "
|
|
4657
|
+
"WHERE e.event_type='patch_apply_end'"
|
|
4658
|
+
).fetchall():
|
|
4659
|
+
try:
|
|
4660
|
+
decoded = json.loads(payload_json or "{}")
|
|
4661
|
+
except (json.JSONDecodeError, TypeError):
|
|
4662
|
+
continue
|
|
4663
|
+
payload = decoded.get("payload") if isinstance(decoded, dict) else None
|
|
4664
|
+
for file_path in conversation.codex_patch_file_paths(payload):
|
|
4665
|
+
pending.append((
|
|
4666
|
+
message_id, conversation_key, source_path,
|
|
4667
|
+
file_path, "apply_patch",
|
|
4668
|
+
))
|
|
4669
|
+
if pending:
|
|
4670
|
+
conn.executemany(
|
|
4671
|
+
"INSERT OR IGNORE INTO codex_conversation_file_touches "
|
|
4672
|
+
"(message_id,conversation_key,source_path,file_path,tool) "
|
|
4673
|
+
"VALUES(?,?,?,?,?)",
|
|
4674
|
+
pending,
|
|
4675
|
+
)
|
|
4676
|
+
conn.commit()
|
|
4677
|
+
finally:
|
|
4678
|
+
_release_cache_db_writer_flocks(held)
|
|
4679
|
+
|
|
4680
|
+
|
|
4339
4681
|
# #177 S6: the consolidated multi-column external-content FTS5 table that
|
|
4340
4682
|
# replaces the old conversation_fts(text) + conversation_fts_aux(search_aux)
|
|
4341
4683
|
# pair. The three column names MUST match the conversation_messages columns BY
|
|
@@ -4760,6 +5102,7 @@ def _codex_conversation_fts_full_clear(conn: sqlite3.Connection) -> None:
|
|
|
4760
5102
|
"INSERT INTO codex_conversation_fts(codex_conversation_fts) VALUES('delete-all')")
|
|
4761
5103
|
_create_codex_conversation_fts_triggers(conn)
|
|
4762
5104
|
for stmt in (
|
|
5105
|
+
"DELETE FROM codex_find_projection",
|
|
4763
5106
|
"DELETE FROM codex_conversation_file_touches",
|
|
4764
5107
|
"DELETE FROM codex_conversation_rollups",
|
|
4765
5108
|
):
|
|
@@ -6198,8 +6541,7 @@ def _028_split_conversation_store(conn: sqlite3.Connection) -> None:
|
|
|
6198
6541
|
"SELECT 1 FROM sqlite_master "
|
|
6199
6542
|
"WHERE type='table' AND name='codex_conversation_events'"
|
|
6200
6543
|
).fetchone() is not None:
|
|
6201
|
-
|
|
6202
|
-
"UPDATE quota_window_snapshots AS q SET observed_model=("
|
|
6544
|
+
model_lookup = (
|
|
6203
6545
|
" SELECT json_extract(e.payload_json, '$.payload.model')"
|
|
6204
6546
|
" FROM codex_conversation_events AS e"
|
|
6205
6547
|
" WHERE e.source_path=q.source_path"
|
|
@@ -6207,9 +6549,21 @@ def _028_split_conversation_store(conn: sqlite3.Connection) -> None:
|
|
|
6207
6549
|
" AND e.record_type IN ('turn_context','session_meta')"
|
|
6208
6550
|
" AND json_valid(e.payload_json)"
|
|
6209
6551
|
" AND json_type(e.payload_json, '$.payload.model')='text'"
|
|
6210
|
-
" ORDER BY e.line_offset DESC LIMIT 1
|
|
6552
|
+
" ORDER BY e.line_offset DESC LIMIT 1"
|
|
6553
|
+
)
|
|
6554
|
+
changed = conn.execute(
|
|
6555
|
+
"UPDATE quota_window_snapshots AS q SET observed_model=("
|
|
6556
|
+
+ model_lookup + ")"
|
|
6211
6557
|
" WHERE q.source='codex' AND q.observed_model IS NULL"
|
|
6558
|
+
" AND (" + model_lookup + ") IS NOT NULL"
|
|
6212
6559
|
)
|
|
6560
|
+
if changed.rowcount:
|
|
6561
|
+
# #457: migration DML changes the same physical quota
|
|
6562
|
+
# inputs as ordinary ingest, so invalidate every consumer
|
|
6563
|
+
# in this transaction. The IS NOT NULL guard keeps a
|
|
6564
|
+
# markerless retry byte-idempotent, including this token.
|
|
6565
|
+
import _cctally_cache
|
|
6566
|
+
_cctally_cache._bump_codex_physical_mutation_seq(conn)
|
|
6213
6567
|
for drop in (
|
|
6214
6568
|
"DROP TRIGGER IF EXISTS conv_fts_ai",
|
|
6215
6569
|
"DROP TRIGGER IF EXISTS conv_fts_ad",
|
|
@@ -6854,8 +7208,10 @@ def _039_codex_quota_observed_model_backfill(conn: sqlite3.Connection) -> None:
|
|
|
6854
7208
|
AND whose lookup actually resolves are considered, so a re-run over its own
|
|
6855
7209
|
output writes nothing at all — not even a NULL-to-NULL update, which would
|
|
6856
7210
|
fire the ledger trigger and dirty a window that never changed. Its real DML
|
|
6857
|
-
IS ledgered, which is
|
|
6858
|
-
rewrites this column no longer has to remember
|
|
7211
|
+
IS ledgered, which is that mechanism working as designed: a migration that
|
|
7212
|
+
rewrites this column no longer has to remember a ledger-specific
|
|
7213
|
+
announcement. The independent physical mutation token still advances for
|
|
7214
|
+
the dashboard/certificate consumers that consult it before reconciliation.
|
|
6859
7215
|
|
|
6860
7216
|
The migration is the ONE-TIME leg. It is not the whole guarantee: `db skip`
|
|
6861
7217
|
and a fresh journal-repopulated cache both bypass it, so ``sync_codex_cache``
|
|
@@ -6863,7 +7219,13 @@ def _039_codex_quota_observed_model_backfill(conn: sqlite3.Connection) -> None:
|
|
|
6863
7219
|
|
|
6864
7220
|
NO self-stamp — the dispatcher central-stamps on a clean return (#140).
|
|
6865
7221
|
"""
|
|
6866
|
-
backfill_codex_quota_observed_model(conn)
|
|
7222
|
+
changed = backfill_codex_quota_observed_model(conn)
|
|
7223
|
+
if changed:
|
|
7224
|
+
# #457: the ledger invalidates the incremental projector, while this
|
|
7225
|
+
# shared token independently invalidates certificate/coherence and
|
|
7226
|
+
# dashboard/TUI snapshot consumers. Both move in this transaction.
|
|
7227
|
+
import _cctally_cache
|
|
7228
|
+
_cctally_cache._bump_codex_physical_mutation_seq(conn)
|
|
6867
7229
|
conn.commit()
|
|
6868
7230
|
|
|
6869
7231
|
|
package/bin/_cctally_doctor.py
CHANGED
|
@@ -971,6 +971,7 @@ def _doctor_gather_state_impl(
|
|
|
971
971
|
conv_rollup_sync_in_progress = False
|
|
972
972
|
conversations_db_page_count = None
|
|
973
973
|
conversations_db_freelist_count = None
|
|
974
|
+
codex_prune_refusals: list[dict] = []
|
|
974
975
|
try:
|
|
975
976
|
if _cctally_core.CONVERSATIONS_DB_PATH.exists():
|
|
976
977
|
# This gather also runs inside dashboard snapshot precompute. A
|
|
@@ -1009,6 +1010,18 @@ def _doctor_gather_state_impl(
|
|
|
1009
1010
|
conv_messages_distinct_sessions = int(row[0])
|
|
1010
1011
|
except sqlite3.OperationalError:
|
|
1011
1012
|
pass
|
|
1013
|
+
try:
|
|
1014
|
+
import _cctally_cache as _cc_sib
|
|
1015
|
+
row = conn.execute(
|
|
1016
|
+
"SELECT value FROM cache_meta WHERE key=?",
|
|
1017
|
+
(_cc_sib.CODEX_ORPHAN_PRUNE_REFUSED_KEY,),
|
|
1018
|
+
).fetchone()
|
|
1019
|
+
if row and row[0]:
|
|
1020
|
+
record = json.loads(row[0])
|
|
1021
|
+
if isinstance(record, dict):
|
|
1022
|
+
codex_prune_refusals.append(record)
|
|
1023
|
+
except (sqlite3.OperationalError, ValueError, TypeError):
|
|
1024
|
+
pass
|
|
1012
1025
|
# Pending reingest/split/backfill flags ⇒ a full sync hasn't yet
|
|
1013
1026
|
# reconciled the rollup. Read the canonical flag set from
|
|
1014
1027
|
# _cctally_cache so it stays in lockstep with the sync consumers.
|
|
@@ -1219,7 +1232,8 @@ def _doctor_gather_state_impl(
|
|
|
1219
1232
|
try:
|
|
1220
1233
|
for _key in ("parse_health_claude", "parse_health_codex",
|
|
1221
1234
|
"codex_torn_auth_deferred", _blocked_key,
|
|
1222
|
-
_deferred_key, "codex_ingest_backlog"
|
|
1235
|
+
_deferred_key, "codex_ingest_backlog",
|
|
1236
|
+
"codex_orphan_prune_refused"):
|
|
1223
1237
|
try:
|
|
1224
1238
|
row = conn.execute(
|
|
1225
1239
|
"SELECT value FROM cache_meta WHERE key = ?",
|
|
@@ -1238,6 +1252,8 @@ def _doctor_gather_state_impl(
|
|
|
1238
1252
|
codex_replay_deferred = _parsed
|
|
1239
1253
|
elif _key == "codex_ingest_backlog":
|
|
1240
1254
|
codex_ingest_backlog = _parsed
|
|
1255
|
+
elif _key == "codex_orphan_prune_refused":
|
|
1256
|
+
codex_prune_refusals.append(_parsed)
|
|
1241
1257
|
else:
|
|
1242
1258
|
codex_torn_deferred = _parsed
|
|
1243
1259
|
except (sqlite3.OperationalError, ValueError):
|
|
@@ -1828,6 +1844,7 @@ def _doctor_gather_state_impl(
|
|
|
1828
1844
|
codex_replay_pending=codex_replay_pending,
|
|
1829
1845
|
codex_replay_blocked=codex_replay_blocked,
|
|
1830
1846
|
codex_replay_deferred=codex_replay_deferred,
|
|
1847
|
+
codex_prune_refusals=codex_prune_refusals or None,
|
|
1831
1848
|
stats_db_quick_check=stats_db_quick_check,
|
|
1832
1849
|
cache_db_quick_check=cache_db_quick_check,
|
|
1833
1850
|
conversations_db_quick_check=conversations_db_quick_check,
|