cctally 1.76.0 → 1.77.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 +11 -0
- package/bin/_cctally_cache.py +918 -143
- package/bin/_cctally_core.py +12 -1
- package/bin/_cctally_dashboard.py +49 -13
- package/bin/_cctally_dashboard_conversation.py +40 -17
- package/bin/_cctally_dashboard_sources.py +18 -7
- package/bin/_cctally_db.py +207 -5
- package/bin/_cctally_doctor.py +37 -7
- package/bin/_cctally_parser.py +1 -1
- package/bin/_cctally_quota.py +23 -24
- package/bin/_cctally_transcript.py +4 -4
- package/bin/_cctally_tui.py +6 -34
- package/bin/_lib_codex_conversation_query.py +19 -3
- package/bin/_lib_conversation_query.py +8 -3
- package/bin/_lib_conversation_retention.py +3 -3
- package/bin/_lib_doctor.py +53 -4
- package/bin/cctally +3 -0
- package/dashboard/static/assets/index-CvcCeA4L.css +1 -0
- package/dashboard/static/assets/index-DLNXAevv.js +87 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-CsDAxBT5.js +0 -80
- package/dashboard/static/assets/index-yftBNnLR.css +0 -1
package/bin/_cctally_cache.py
CHANGED
|
@@ -243,7 +243,14 @@ def _conv_row_tuple(m, path_str):
|
|
|
243
243
|
)
|
|
244
244
|
|
|
245
245
|
|
|
246
|
-
def _iter_sync_entries(
|
|
246
|
+
def _iter_sync_entries(
|
|
247
|
+
fh,
|
|
248
|
+
path_str,
|
|
249
|
+
stats: "IngestStats | None" = None,
|
|
250
|
+
*,
|
|
251
|
+
include_cost: bool = True,
|
|
252
|
+
include_conversations: bool = True,
|
|
253
|
+
):
|
|
247
254
|
"""Fused single-pass sync walker (#138). Yields
|
|
248
255
|
``(byte_offset, cost_or_None, msgrow_or_None, aititle_or_None)`` for each
|
|
249
256
|
JSONL line from ``fh``'s current position that produces a cost entry, a
|
|
@@ -293,7 +300,7 @@ def _iter_sync_entries(fh, path_str, stats: "IngestStats | None" = None):
|
|
|
293
300
|
if stats is not None:
|
|
294
301
|
stats.lines_malformed += 1
|
|
295
302
|
continue
|
|
296
|
-
cost = _lib_jsonl.parse_cost_entry(obj, path_str)
|
|
303
|
+
cost = _lib_jsonl.parse_cost_entry(obj, path_str) if include_cost else None
|
|
297
304
|
if cost is None and stats is not None:
|
|
298
305
|
# Assistant-typed line rejected for a NON-deliberate reason
|
|
299
306
|
# (schema-drift tripwire; <synthetic>/non-assistant are normal).
|
|
@@ -302,8 +309,14 @@ def _iter_sync_entries(fh, path_str, stats: "IngestStats | None" = None):
|
|
|
302
309
|
stats.assistant_lines_skipped += 1
|
|
303
310
|
stats.skip_reasons[reason] = \
|
|
304
311
|
stats.skip_reasons.get(reason, 0) + 1
|
|
305
|
-
mrow =
|
|
306
|
-
|
|
312
|
+
mrow = (
|
|
313
|
+
_lib_conversation.parse_message_row(obj, offset)
|
|
314
|
+
if include_conversations else None
|
|
315
|
+
)
|
|
316
|
+
ai = (
|
|
317
|
+
_lib_conversation.parse_ai_title(obj, offset)
|
|
318
|
+
if include_conversations else None
|
|
319
|
+
)
|
|
307
320
|
if cost is not None or mrow is not None or ai is not None:
|
|
308
321
|
yield offset, cost, mrow, ai
|
|
309
322
|
|
|
@@ -792,13 +805,6 @@ def _delete_codex_file_derived_rows(
|
|
|
792
805
|
"DELETE FROM codex_conversation_threads WHERE source_path = ?" + root_clause,
|
|
793
806
|
params,
|
|
794
807
|
)
|
|
795
|
-
# #294 S6: normalized rows + file touches for this file. This is a PARTIAL
|
|
796
|
-
# delete (one file), so it rides the per-row FTS delete trigger — surviving
|
|
797
|
-
# conversations keep their postings (§3.4 two-path policy). File touches have
|
|
798
|
-
# no source_root_key column; a source_path maps to exactly one file/root, so
|
|
799
|
-
# scoping them by source_path alone is exact. Rollups are repaired by the
|
|
800
|
-
# caller's _recompute_codex_rollups (§3.2), which needs the affected keys
|
|
801
|
-
# captured BEFORE this delete.
|
|
802
808
|
conn.execute(
|
|
803
809
|
"DELETE FROM codex_conversation_file_touches WHERE source_path = ?",
|
|
804
810
|
(path_str,),
|
|
@@ -843,9 +849,6 @@ def _clear_codex_derived_rows(conn: sqlite3.Connection) -> bool:
|
|
|
843
849
|
conn.execute("DELETE FROM codex_conversation_events")
|
|
844
850
|
conn.execute("DELETE FROM codex_session_files")
|
|
845
851
|
conn.execute("DELETE FROM codex_source_roots")
|
|
846
|
-
# #294 S6: FULL clear of the normalized derived tables (messages + touches +
|
|
847
|
-
# rollups) via the storm-free helper — this is a whole-corpus rebuild, so
|
|
848
|
-
# 'delete-all' resets the FTS shadow tables cleanly (§3.4 full-clear path).
|
|
849
852
|
_codex_conversation_fts_full_clear(conn)
|
|
850
853
|
# F3: this clears the Codex physical quota state, so any stored
|
|
851
854
|
# quota-projection certificate would become stale-valid (its cache
|
|
@@ -1068,7 +1071,6 @@ def _collect_inactive_codex_paths_and_roots(
|
|
|
1068
1071
|
"SELECT source_path, source_root_key FROM quota_window_snapshots "
|
|
1069
1072
|
"WHERE source = 'codex'",
|
|
1070
1073
|
"SELECT source_path, source_root_key FROM codex_conversation_threads",
|
|
1071
|
-
"SELECT source_path, source_root_key FROM codex_conversation_events",
|
|
1072
1074
|
)
|
|
1073
1075
|
for query in family_queries:
|
|
1074
1076
|
for source_path, root_key in conn.execute(query):
|
|
@@ -1138,10 +1140,7 @@ def _prune_inactive_codex_source_roots(
|
|
|
1138
1140
|
SELECT 1 FROM codex_conversation_threads AS threads
|
|
1139
1141
|
WHERE threads.source_root_key = roots.source_root_key
|
|
1140
1142
|
)
|
|
1141
|
-
|
|
1142
|
-
SELECT 1 FROM codex_conversation_events AS events
|
|
1143
|
-
WHERE events.source_root_key = roots.source_root_key
|
|
1144
|
-
)""",
|
|
1143
|
+
""",
|
|
1145
1144
|
tuple(params),
|
|
1146
1145
|
)
|
|
1147
1146
|
|
|
@@ -1166,9 +1165,6 @@ def _write_codex_file_batch(
|
|
|
1166
1165
|
accounting_rows: list[tuple[Any, ...]],
|
|
1167
1166
|
quota_rows: list[tuple[Any, ...]],
|
|
1168
1167
|
thread_rows: list[tuple[Any, ...]],
|
|
1169
|
-
event_rows: list[tuple[Any, ...]],
|
|
1170
|
-
normalized_rows: list,
|
|
1171
|
-
normalized_touches: list,
|
|
1172
1168
|
active_root_keys: set[str],
|
|
1173
1169
|
prune_roots: bool = True,
|
|
1174
1170
|
) -> int:
|
|
@@ -1179,15 +1175,7 @@ def _write_codex_file_batch(
|
|
|
1179
1175
|
``codex_source_roots`` row for a root it wasn't asked about (spec §5.1
|
|
1180
1176
|
whole-tree bypass — ``active_root_keys`` then covers only the targets)."""
|
|
1181
1177
|
now_iso = dt.datetime.now(dt.timezone.utc).isoformat()
|
|
1182
|
-
# #294 S6: rollups are recomputed-affected-or-deleted (§3.2), so capture the
|
|
1183
|
-
# conversation keys this file's normalized rows touch BEFORE a reset delete
|
|
1184
|
-
# removes them.
|
|
1185
|
-
affected_keys: set = set()
|
|
1186
1178
|
if reset_file:
|
|
1187
|
-
affected_keys.update(
|
|
1188
|
-
row[0] for row in conn.execute(
|
|
1189
|
-
"SELECT DISTINCT conversation_key FROM codex_conversation_messages "
|
|
1190
|
-
"WHERE source_path = ?", (path_str,)) if row[0])
|
|
1191
1179
|
_delete_codex_file_derived_rows(conn, path_str)
|
|
1192
1180
|
conn.execute(
|
|
1193
1181
|
"""INSERT INTO codex_source_roots
|
|
@@ -1217,8 +1205,8 @@ def _write_codex_file_batch(
|
|
|
1217
1205
|
(source, source_root_key, source_path, line_offset,
|
|
1218
1206
|
captured_at_utc, observed_slot, logical_limit_key, limit_id,
|
|
1219
1207
|
limit_name, window_minutes, used_percent, resets_at_utc,
|
|
1220
|
-
plan_type, individual_limit_json, reached_type)
|
|
1221
|
-
VALUES (
|
|
1208
|
+
plan_type, individual_limit_json, reached_type, observed_model)
|
|
1209
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
1222
1210
|
quota_rows,
|
|
1223
1211
|
)
|
|
1224
1212
|
if thread_rows:
|
|
@@ -1242,25 +1230,6 @@ def _write_codex_file_batch(
|
|
|
1242
1230
|
last_seen_utc=excluded.last_seen_utc""",
|
|
1243
1231
|
[(*row, now_iso, now_iso) for row in thread_rows],
|
|
1244
1232
|
)
|
|
1245
|
-
if event_rows:
|
|
1246
|
-
conn.executemany(
|
|
1247
|
-
"""INSERT OR IGNORE INTO codex_conversation_events
|
|
1248
|
-
(source_path, line_offset, source_root_key, conversation_key,
|
|
1249
|
-
native_thread_id, root_thread_id, parent_thread_id,
|
|
1250
|
-
timestamp_utc, record_type, event_type, turn_id, call_id,
|
|
1251
|
-
payload_json)
|
|
1252
|
-
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
1253
|
-
event_rows,
|
|
1254
|
-
)
|
|
1255
|
-
# #294 S6: normalized rows + file touches ride the same per-file transaction
|
|
1256
|
-
# as the events themselves. INSERT OR IGNORE on the physical key keeps a delta
|
|
1257
|
-
# re-read idempotent; the per-row FTS triggers index them. Then recompute the
|
|
1258
|
-
# rollup for every affected conversation (aggregating across all its files),
|
|
1259
|
-
# deleting emptied rollups. Threads were inserted above, so project
|
|
1260
|
-
# attribution can read them.
|
|
1261
|
-
_insert_codex_normalized_rows(conn, normalized_rows, normalized_touches)
|
|
1262
|
-
affected_keys.update(r.conversation_key for r in normalized_rows)
|
|
1263
|
-
_recompute_codex_rollups(conn, affected_keys)
|
|
1264
1233
|
conn.execute(
|
|
1265
1234
|
"""INSERT OR REPLACE INTO codex_session_files
|
|
1266
1235
|
(path, size_bytes, mtime_ns, last_byte_offset, last_ingested_at,
|
|
@@ -1517,13 +1486,14 @@ def _reset_orphan_warning_throttle():
|
|
|
1517
1486
|
_LAST_WARNED_ORPHAN_SET = frozenset()
|
|
1518
1487
|
|
|
1519
1488
|
|
|
1520
|
-
# Flags whose presence means the
|
|
1489
|
+
# Flags whose presence means the conversation store is mid-migration /
|
|
1490
|
+
# mid-reingest. A
|
|
1521
1491
|
# targeted (only_paths) ingest DECLINES when any is set and defers to the next
|
|
1522
1492
|
# full background sync — inserting through a half-migrated FTS shape or skipping
|
|
1523
1493
|
# a pending backfill would diverge from what a full sync produces (spec §
|
|
1524
1494
|
# "Targeted ingest contract"). Enumerated against the flag-consumption blocks
|
|
1525
|
-
# guarded by the
|
|
1526
|
-
#
|
|
1495
|
+
# guarded by the full-sync-only maintenance path in
|
|
1496
|
+
# sync_claude_conversations; keep this tuple in sync with those consumers.
|
|
1527
1497
|
_TARGETED_DECLINE_FLAGS = (
|
|
1528
1498
|
"conversation_backfill_pending",
|
|
1529
1499
|
"ai_titles_backfill_pending",
|
|
@@ -1595,23 +1565,32 @@ def _prune_orphaned_cache_entries(conn, *, lock_timeout=None):
|
|
|
1595
1565
|
the same turn -> deleting P's deduped cost row would lose it).
|
|
1596
1566
|
Anything failing A/B/C is left as residual (reported; for `--rebuild`).
|
|
1597
1567
|
|
|
1598
|
-
Deletes the
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
on a clean walk. Acquires
|
|
1605
|
-
|
|
1568
|
+
Deletes the derived conversation rows first, then the core accounting rows
|
|
1569
|
+
in a second transaction. The ordering is deliberately failure-safe: an
|
|
1570
|
+
interruption may leave re-derivable transcript rows absent, but cannot
|
|
1571
|
+
delete accounting evidence while its conversation coverage is still the
|
|
1572
|
+
only proof that the orphan is safe. Recomputes conversation_sessions for
|
|
1573
|
+
exactly the pruned session_ids. Does NOT write the walk-complete marker:
|
|
1574
|
+
the next full sync_cache re-establishes it on a clean walk. Acquires both
|
|
1575
|
+
provider flocks itself; contention returns a `contended` result without
|
|
1576
|
+
mutating.
|
|
1606
1577
|
"""
|
|
1607
1578
|
result = PruneResult()
|
|
1608
1579
|
_cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
1609
1580
|
_cctally_core.CACHE_LOCK_PATH.touch()
|
|
1610
1581
|
lock_fh = open(_cctally_core.CACHE_LOCK_PATH, "w")
|
|
1582
|
+
conv_lock_fh = None
|
|
1583
|
+
conv = None
|
|
1611
1584
|
try:
|
|
1612
1585
|
if not _acquire_cache_flock(lock_fh, timeout=lock_timeout):
|
|
1613
1586
|
result.contended = True
|
|
1614
1587
|
return result
|
|
1588
|
+
_cctally_core.CONVERSATIONS_LOCK_PATH.touch()
|
|
1589
|
+
conv_lock_fh = open(_cctally_core.CONVERSATIONS_LOCK_PATH, "w")
|
|
1590
|
+
if not _acquire_cache_flock(conv_lock_fh, timeout=lock_timeout):
|
|
1591
|
+
result.contended = True
|
|
1592
|
+
return result
|
|
1593
|
+
conv = open_conversations_db(attach_cache=False)
|
|
1615
1594
|
|
|
1616
1595
|
tracked = conn.execute(
|
|
1617
1596
|
"SELECT path, size_bytes, session_id FROM session_files").fetchall()
|
|
@@ -1635,14 +1614,14 @@ def _prune_orphaned_cache_entries(conn, *, lock_timeout=None):
|
|
|
1635
1614
|
(path,)).fetchall()
|
|
1636
1615
|
ok = True
|
|
1637
1616
|
for mid, rid in keys:
|
|
1638
|
-
covered =
|
|
1617
|
+
covered = conv.execute( # Gate B
|
|
1639
1618
|
"SELECT 1 FROM conversation_messages "
|
|
1640
1619
|
"WHERE msg_id=? AND req_id=? AND source_path=? LIMIT 1",
|
|
1641
1620
|
(mid, rid, path)).fetchone() is not None
|
|
1642
1621
|
if not covered:
|
|
1643
1622
|
ok = False
|
|
1644
1623
|
break
|
|
1645
|
-
shared =
|
|
1624
|
+
shared = conv.execute( # Gate C
|
|
1646
1625
|
"SELECT source_path FROM conversation_messages "
|
|
1647
1626
|
"WHERE msg_id=? AND req_id=?", (mid, rid)).fetchall()
|
|
1648
1627
|
if any(sp in on_disk for (sp,) in shared):
|
|
@@ -1661,30 +1640,44 @@ def _prune_orphaned_cache_entries(conn, *, lock_timeout=None):
|
|
|
1661
1640
|
# handful of removed files), well under SQLite's variable limit;
|
|
1662
1641
|
# _recompute_conversation_sessions chunks its own session-id list.
|
|
1663
1642
|
ph = ",".join("?" * len(safe_paths))
|
|
1664
|
-
result.pruned_messages =
|
|
1643
|
+
result.pruned_messages = conv.execute(
|
|
1665
1644
|
f"SELECT count(*) FROM conversation_messages WHERE source_path IN ({ph})",
|
|
1666
1645
|
safe_paths).fetchone()[0]
|
|
1667
|
-
|
|
1646
|
+
conv.execute("BEGIN")
|
|
1668
1647
|
try:
|
|
1669
|
-
|
|
1648
|
+
conv.execute(
|
|
1670
1649
|
f"DELETE FROM conversation_file_touches WHERE message_id IN "
|
|
1671
1650
|
f"(SELECT id FROM conversation_messages WHERE source_path IN ({ph}))",
|
|
1672
1651
|
safe_paths)
|
|
1673
|
-
|
|
1652
|
+
conv.execute(
|
|
1674
1653
|
f"DELETE FROM conversation_messages WHERE source_path IN ({ph})", safe_paths)
|
|
1675
|
-
|
|
1654
|
+
conv.execute(
|
|
1676
1655
|
f"DELETE FROM conversation_ai_titles WHERE source_path IN ({ph})", safe_paths)
|
|
1656
|
+
_recompute_conversation_sessions(conv, list(pruned_sids))
|
|
1657
|
+
conv.commit()
|
|
1658
|
+
except BaseException:
|
|
1659
|
+
conv.rollback()
|
|
1660
|
+
raise
|
|
1661
|
+
conn.execute("BEGIN")
|
|
1662
|
+
try:
|
|
1677
1663
|
result.pruned_entries = conn.execute(
|
|
1678
1664
|
f"DELETE FROM session_entries WHERE source_path IN ({ph})", safe_paths).rowcount
|
|
1679
1665
|
result.pruned_files = conn.execute(
|
|
1680
1666
|
f"DELETE FROM session_files WHERE path IN ({ph})", safe_paths).rowcount
|
|
1681
|
-
_recompute_conversation_sessions(conn, list(pruned_sids))
|
|
1682
1667
|
conn.commit()
|
|
1683
1668
|
except BaseException:
|
|
1684
1669
|
conn.rollback()
|
|
1685
1670
|
raise
|
|
1686
1671
|
return result
|
|
1687
1672
|
finally:
|
|
1673
|
+
if conv is not None:
|
|
1674
|
+
conv.close()
|
|
1675
|
+
if conv_lock_fh is not None:
|
|
1676
|
+
try:
|
|
1677
|
+
fcntl.flock(conv_lock_fh, fcntl.LOCK_UN)
|
|
1678
|
+
except OSError:
|
|
1679
|
+
pass
|
|
1680
|
+
conv_lock_fh.close()
|
|
1688
1681
|
try:
|
|
1689
1682
|
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
1690
1683
|
except OSError:
|
|
@@ -1720,7 +1713,9 @@ def _bump_mutation_seq(conn: sqlite3.Connection) -> int:
|
|
|
1720
1713
|
return int(row[0])
|
|
1721
1714
|
|
|
1722
1715
|
|
|
1723
|
-
def _force_retention_prune_after_replay(
|
|
1716
|
+
def _force_retention_prune_after_replay(
|
|
1717
|
+
conn: "sqlite3.Connection | None" = None,
|
|
1718
|
+
) -> None:
|
|
1724
1719
|
"""#313 P3 (F9): run an UNTHROTTLED transcript retention prune after a
|
|
1725
1720
|
from-zero replay (a ``--rebuild`` or a truncation/requalification re-ingest,
|
|
1726
1721
|
both of which replay from offset 0 and restore >retention-day rows the
|
|
@@ -1734,12 +1729,18 @@ def _force_retention_prune_after_replay(conn: sqlite3.Connection) -> None:
|
|
|
1734
1729
|
retention_days = resolve_retention_days(_cctally().load_config())
|
|
1735
1730
|
if retention_days <= 0:
|
|
1736
1731
|
return
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1732
|
+
owned = conn is None
|
|
1733
|
+
conv_conn = open_conversations_db(attach_cache=False) if owned else conn
|
|
1734
|
+
try:
|
|
1735
|
+
retention._maybe_prune_conversation_retention(
|
|
1736
|
+
conv_conn,
|
|
1737
|
+
now_utc=dt.datetime.now(dt.timezone.utc),
|
|
1738
|
+
retention_days=retention_days,
|
|
1739
|
+
force=True,
|
|
1740
|
+
)
|
|
1741
|
+
finally:
|
|
1742
|
+
if owned:
|
|
1743
|
+
conv_conn.close()
|
|
1743
1744
|
except Exception:
|
|
1744
1745
|
pass
|
|
1745
1746
|
|
|
@@ -2396,7 +2397,12 @@ def sync_cache(
|
|
|
2396
2397
|
# walk over the identical span — the "identical span"
|
|
2397
2398
|
# invariant is now structural (a single stop point) rather
|
|
2398
2399
|
# than a prose-enforced ``>= final_offset`` runtime break.
|
|
2399
|
-
for offset, cost, mrow, ai in _iter_sync_entries(
|
|
2400
|
+
for offset, cost, mrow, ai in _iter_sync_entries(
|
|
2401
|
+
fh,
|
|
2402
|
+
path_str,
|
|
2403
|
+
stats=stats,
|
|
2404
|
+
include_conversations=False,
|
|
2405
|
+
):
|
|
2400
2406
|
if cost is not None:
|
|
2401
2407
|
entry, msg_id, req_id = cost
|
|
2402
2408
|
usage = entry.usage
|
|
@@ -2696,19 +2702,12 @@ def sync_cache(
|
|
|
2696
2702
|
# and the flock is still held, so the short busy_timeout keeps it from
|
|
2697
2703
|
# stalling the lock under heavy-reader contention.
|
|
2698
2704
|
_maybe_truncate_wal(conn, _cctally_core.CACHE_DB_PATH)
|
|
2699
|
-
# #313 P3 (F9): a rebuild or a truncation escalation replays from offset
|
|
2700
|
-
# 0 and restores >retention-day transcript rows. Force an unthrottled
|
|
2701
|
-
# prune AFTER the flock releases below (early lock-contended / deferred
|
|
2702
|
-
# returns above never reach here, so a no-op sync does not prune).
|
|
2703
|
-
did_from_zero_replay = rebuild or stats.files_reset_truncated > 0
|
|
2704
2705
|
finally:
|
|
2705
2706
|
try:
|
|
2706
2707
|
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
2707
2708
|
except OSError:
|
|
2708
2709
|
pass
|
|
2709
2710
|
lock_fh.close()
|
|
2710
|
-
if did_from_zero_replay:
|
|
2711
|
-
_force_retention_prune_after_replay(conn)
|
|
2712
2711
|
return stats
|
|
2713
2712
|
|
|
2714
2713
|
|
|
@@ -3875,7 +3874,6 @@ def sync_codex_cache(
|
|
|
3875
3874
|
"""
|
|
3876
3875
|
stats = CodexIngestStats()
|
|
3877
3876
|
project_after_unlock = False
|
|
3878
|
-
did_from_zero_replay = False
|
|
3879
3877
|
# #313 P1 review (F4/F1): when the CACHE certificate is current we cannot
|
|
3880
3878
|
# yet decide whether to skip the reconcile — reconcile's own short-circuit
|
|
3881
3879
|
# ALSO requires the stats-side quota_projection_state signatures to match
|
|
@@ -4083,12 +4081,12 @@ def sync_codex_cache(
|
|
|
4083
4081
|
initial_model: str | None = None
|
|
4084
4082
|
initial_total_tokens = 0
|
|
4085
4083
|
# #294 S6: the sticky-turn resume seed (parallel to initial_model).
|
|
4086
|
-
initial_turn_id: str | None = None
|
|
4087
4084
|
prev_total_tokens: int | None = None
|
|
4088
4085
|
prev_native_thread_id: str | None = None
|
|
4089
4086
|
prev_root_thread_id: str | None = None
|
|
4090
4087
|
prev_parent_thread_id: str | None = None
|
|
4091
4088
|
prev_conversation_key: str | None = None
|
|
4089
|
+
prev_turn_id: str | None = None
|
|
4092
4090
|
requalified = False
|
|
4093
4091
|
if prev is not None:
|
|
4094
4092
|
(
|
|
@@ -4119,23 +4117,17 @@ def sync_codex_cache(
|
|
|
4119
4117
|
initial_session_id = prev_sid
|
|
4120
4118
|
initial_model = prev_model
|
|
4121
4119
|
initial_total_tokens = prev_total_tokens or 0
|
|
4122
|
-
initial_turn_id = prev_turn_id
|
|
4123
4120
|
else:
|
|
4124
4121
|
truncated = True
|
|
4125
4122
|
start_offset = 0
|
|
4126
4123
|
initial_session_id = None
|
|
4127
4124
|
initial_model = None
|
|
4128
4125
|
initial_total_tokens = 0
|
|
4129
|
-
initial_turn_id = None
|
|
4130
4126
|
prev_total_tokens = None
|
|
4131
4127
|
|
|
4132
4128
|
accounting_rows: list[tuple[Any, ...]] = []
|
|
4133
4129
|
quota_rows: list[tuple[Any, ...]] = []
|
|
4134
4130
|
thread_rows: list[tuple[Any, ...]] = []
|
|
4135
|
-
event_rows: list[tuple[Any, ...]] = []
|
|
4136
|
-
# #294 S6: the physical event objects for this file window, fed to the
|
|
4137
|
-
# normalization kernel after the drain (in offset order).
|
|
4138
|
-
events_list: list = []
|
|
4139
4131
|
final_offset = start_offset
|
|
4140
4132
|
# Mutable tracker that the iterator updates on every
|
|
4141
4133
|
# session_meta / turn_context record, regardless of whether a
|
|
@@ -4189,15 +4181,6 @@ def sync_codex_cache(
|
|
|
4189
4181
|
state=iter_state,
|
|
4190
4182
|
):
|
|
4191
4183
|
event = emission.event
|
|
4192
|
-
events_list.append(event)
|
|
4193
|
-
event_rows.append((
|
|
4194
|
-
event.source_path, event.line_offset,
|
|
4195
|
-
event.source_root_key, event.conversation_key,
|
|
4196
|
-
event.native_thread_id, event.root_thread_id,
|
|
4197
|
-
event.parent_thread_id, event.timestamp_utc,
|
|
4198
|
-
event.record_type, event.event_type, event.turn_id,
|
|
4199
|
-
event.call_id, event.payload_json,
|
|
4200
|
-
))
|
|
4201
4184
|
for quota in emission.quotas:
|
|
4202
4185
|
quota_rows.append((
|
|
4203
4186
|
quota.source, quota.source_root_key,
|
|
@@ -4207,7 +4190,7 @@ def sync_codex_cache(
|
|
|
4207
4190
|
quota.limit_name, quota.window_minutes,
|
|
4208
4191
|
quota.used_percent, quota.resets_at_utc,
|
|
4209
4192
|
quota.plan_type, quota.individual_limit_json,
|
|
4210
|
-
quota.reached_type,
|
|
4193
|
+
quota.reached_type, iter_state.model,
|
|
4211
4194
|
))
|
|
4212
4195
|
if (thread := emission.thread) is not None and (
|
|
4213
4196
|
thread.conversation_key is not None
|
|
@@ -4298,28 +4281,11 @@ def sync_codex_cache(
|
|
|
4298
4281
|
if terminal_thread is not None else prev_conversation_key
|
|
4299
4282
|
)
|
|
4300
4283
|
|
|
4301
|
-
#
|
|
4302
|
-
#
|
|
4303
|
-
#
|
|
4304
|
-
#
|
|
4305
|
-
|
|
4306
|
-
try:
|
|
4307
|
-
norm_result = _lib_codex_conversation.normalize_codex_events(
|
|
4308
|
-
events_list,
|
|
4309
|
-
initial=_lib_codex_conversation.CodexStickyState(
|
|
4310
|
-
turn_id=initial_turn_id, model=initial_model),
|
|
4311
|
-
)
|
|
4312
|
-
except Exception as exc: # noqa: BLE001
|
|
4313
|
-
# §5.1: a normalization exception during a targeted ingest is a
|
|
4314
|
-
# per-file decline → the call is dirty (files_failed) but earlier
|
|
4315
|
-
# commits stand. A full sync keeps its historical crash-loud
|
|
4316
|
-
# behavior (there the whole walk is authoritative).
|
|
4317
|
-
if not targeted:
|
|
4318
|
-
raise
|
|
4319
|
-
eprint(f"[codex-cache] normalization failed for {jp}: {exc}")
|
|
4320
|
-
stats.files_failed += 1
|
|
4321
|
-
continue
|
|
4322
|
-
new_last_turn_id = norm_result.terminal.turn_id
|
|
4284
|
+
# Transcript normalization and its sticky turn cursor belong to the
|
|
4285
|
+
# independent conversations.db pass. Preserve the legacy compact
|
|
4286
|
+
# cursor column without advancing it here; sync_codex_conversations
|
|
4287
|
+
# owns the authoritative transcript-local value.
|
|
4288
|
+
new_last_turn_id = prev_turn_id
|
|
4323
4289
|
|
|
4324
4290
|
# Every derived row above was buffered before the first DML. A
|
|
4325
4291
|
# late database failure therefore rolls the whole file back and
|
|
@@ -4346,9 +4312,6 @@ def sync_codex_cache(
|
|
|
4346
4312
|
accounting_rows=accounting_rows,
|
|
4347
4313
|
quota_rows=quota_rows,
|
|
4348
4314
|
thread_rows=thread_rows,
|
|
4349
|
-
event_rows=event_rows,
|
|
4350
|
-
normalized_rows=norm_result.rows,
|
|
4351
|
-
normalized_touches=norm_result.touches,
|
|
4352
4315
|
active_root_keys={item.source_root_key for item in files},
|
|
4353
4316
|
# §5.1 whole-tree bypass: targeted mode never prunes
|
|
4354
4317
|
# codex_source_roots for roots outside its target set.
|
|
@@ -4441,7 +4404,7 @@ def sync_codex_cache(
|
|
|
4441
4404
|
# reconciler (its observation load reconciles all roots at seconds-scale
|
|
4442
4405
|
# cost). Quota projection is deferred to the next full sync, which the
|
|
4443
4406
|
# ordinary hook cadence supplies. Skip the whole decision block so
|
|
4444
|
-
# project_after_unlock / deferred_cert_*
|
|
4407
|
+
# project_after_unlock / deferred_cert_* keep
|
|
4445
4408
|
# their no-op defaults — the post-flock reconcile paths below then all
|
|
4446
4409
|
# short-circuit for a targeted call.
|
|
4447
4410
|
if not targeted:
|
|
@@ -4471,11 +4434,6 @@ def sync_codex_cache(
|
|
|
4471
4434
|
deferred_cert_sigs = dict(certificate[1])
|
|
4472
4435
|
else:
|
|
4473
4436
|
project_after_unlock = True
|
|
4474
|
-
# #313 P3 (F9): a Codex rebuild or a truncation/requalification
|
|
4475
|
-
# re-ingest replays from offset 0 and restores >retention-day
|
|
4476
|
-
# codex_conversation_events. Force an unthrottled prune after the
|
|
4477
|
-
# flock releases (below), so restored old rows don't persist for 24h.
|
|
4478
|
-
did_from_zero_replay = rebuild or stats.files_reset_truncated > 0
|
|
4479
4437
|
finally:
|
|
4480
4438
|
try:
|
|
4481
4439
|
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
@@ -4504,8 +4462,6 @@ def sync_codex_cache(
|
|
|
4504
4462
|
if project_after_unlock:
|
|
4505
4463
|
from _cctally_quota import reconcile_codex_quota_projection
|
|
4506
4464
|
reconcile_codex_quota_projection()
|
|
4507
|
-
if did_from_zero_replay:
|
|
4508
|
-
_force_retention_prune_after_replay(conn)
|
|
4509
4465
|
return stats
|
|
4510
4466
|
|
|
4511
4467
|
|
|
@@ -4807,9 +4763,773 @@ def open_cache_db() -> sqlite3.Connection:
|
|
|
4807
4763
|
conn, registry=_CACHE_MIGRATIONS, db_label="cache.db",
|
|
4808
4764
|
recover_version_ahead=True,
|
|
4809
4765
|
)
|
|
4766
|
+
# Migration 028 removes the legacy transcript objects after arming the
|
|
4767
|
+
# independent rebuild. Recreate EMPTY compatibility objects only so older
|
|
4768
|
+
# migration/fixture probes remain valid; live core sync never populates
|
|
4769
|
+
# them. Keeping this conditional avoids a second schema pass on normal
|
|
4770
|
+
# opens while preserving the physical data split.
|
|
4771
|
+
if conn.execute(
|
|
4772
|
+
"SELECT 1 FROM sqlite_master WHERE name='conversation_messages'"
|
|
4773
|
+
).fetchone() is None:
|
|
4774
|
+
_cctally_db_sib._apply_cache_schema(conn)
|
|
4810
4775
|
return conn
|
|
4811
4776
|
|
|
4812
4777
|
|
|
4778
|
+
def _harden_conversation_sidecars() -> None:
|
|
4779
|
+
"""Best-effort 0600 on conversations.db and its WAL sidecars."""
|
|
4780
|
+
base = str(_cctally_core.CONVERSATIONS_DB_PATH)
|
|
4781
|
+
for path in (base, base + "-wal", base + "-shm"):
|
|
4782
|
+
try:
|
|
4783
|
+
if os.path.exists(path):
|
|
4784
|
+
os.chmod(path, 0o600)
|
|
4785
|
+
except OSError as exc:
|
|
4786
|
+
eprint(
|
|
4787
|
+
f"[conversations] could not chmod {path} 0600 ({exc}); continuing"
|
|
4788
|
+
)
|
|
4789
|
+
|
|
4790
|
+
|
|
4791
|
+
def open_conversations_db(*, attach_cache: bool = True) -> sqlite3.Connection:
|
|
4792
|
+
"""Open the independent transcript/search store (#320).
|
|
4793
|
+
|
|
4794
|
+
``conversations.db`` is the main schema. Conversation readers optionally
|
|
4795
|
+
attach ``cache.db`` read-only as ``cache_db`` for cost/token and compact
|
|
4796
|
+
Codex-thread metadata. Core cache callers never take the inverse
|
|
4797
|
+
dependency, so a missing or locked transcript store cannot block quota or
|
|
4798
|
+
accounting refreshes.
|
|
4799
|
+
"""
|
|
4800
|
+
_cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
4801
|
+
try:
|
|
4802
|
+
os.chmod(_cctally_core.APP_DIR, 0o700)
|
|
4803
|
+
except OSError as exc:
|
|
4804
|
+
eprint(
|
|
4805
|
+
f"[conversations] could not chmod data dir 0700 ({exc}); continuing"
|
|
4806
|
+
)
|
|
4807
|
+
|
|
4808
|
+
path = _cctally_core.CONVERSATIONS_DB_PATH
|
|
4809
|
+
conn: sqlite3.Connection | None = None
|
|
4810
|
+
try:
|
|
4811
|
+
# URI mode belongs to the connection, not only the later ATTACH value.
|
|
4812
|
+
# Without it, some supported system-Python SQLite builds interpret the
|
|
4813
|
+
# read-only ``file:...cache.db?mode=ro`` attachment as a literal path.
|
|
4814
|
+
conn = sqlite3.connect(path, uri=True)
|
|
4815
|
+
conn.execute("SELECT 1").fetchone()
|
|
4816
|
+
except sqlite3.DatabaseError as exc:
|
|
4817
|
+
if conn is not None:
|
|
4818
|
+
conn.close()
|
|
4819
|
+
# Do not unlink a live SQLite family from a reader path. The store is
|
|
4820
|
+
# re-derivable, but safe replacement still requires excluding its
|
|
4821
|
+
# independent writers; callers degrade this surface and leave core
|
|
4822
|
+
# accounting/quota available. An explicit rebuild/delete can recover it.
|
|
4823
|
+
eprint(f"[conversations] corrupt transcript DB ({exc}); unavailable")
|
|
4824
|
+
raise
|
|
4825
|
+
|
|
4826
|
+
assert conn is not None
|
|
4827
|
+
|
|
4828
|
+
try:
|
|
4829
|
+
os.chmod(path, 0o600)
|
|
4830
|
+
except OSError as exc:
|
|
4831
|
+
eprint(
|
|
4832
|
+
f"[conversations] could not chmod conversations.db 0600 ({exc}); continuing"
|
|
4833
|
+
)
|
|
4834
|
+
|
|
4835
|
+
conn.execute("PRAGMA auto_vacuum=INCREMENTAL")
|
|
4836
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
4837
|
+
conn.execute("PRAGMA busy_timeout=15000")
|
|
4838
|
+
conn.execute(f"PRAGMA journal_size_limit={CACHE_WAL_SIZE_LIMIT_BYTES}")
|
|
4839
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
4840
|
+
_cctally_db_sib._apply_conversations_schema(conn)
|
|
4841
|
+
conn.commit()
|
|
4842
|
+
|
|
4843
|
+
if attach_cache:
|
|
4844
|
+
# Ensure the compact schema exists before opening it read-only. This
|
|
4845
|
+
# call has no dependency on conversations.db (pinned by the split RED
|
|
4846
|
+
# test), so the direction remains one-way.
|
|
4847
|
+
cache = open_cache_db()
|
|
4848
|
+
cache.close()
|
|
4849
|
+
cache_uri = _cctally_core.CACHE_DB_PATH.resolve().as_uri() + "?mode=ro"
|
|
4850
|
+
conn.execute("ATTACH DATABASE ? AS cache_db", (cache_uri,))
|
|
4851
|
+
_import_legacy_conversation_rows(conn)
|
|
4852
|
+
_harden_conversation_sidecars()
|
|
4853
|
+
return conn
|
|
4854
|
+
|
|
4855
|
+
|
|
4856
|
+
def _import_legacy_conversation_rows(conn: sqlite3.Connection) -> None:
|
|
4857
|
+
"""Bridge pre-028/compatibility rows into an empty conversation store.
|
|
4858
|
+
|
|
4859
|
+
Migration 028 normally arms a JSONL rebuild and clears the old rows. This
|
|
4860
|
+
defensive bridge covers an interrupted upgrade and keeps historical test
|
|
4861
|
+
fixtures readable without making core sync depend on the transcript DB.
|
|
4862
|
+
It writes only the main conversation store; ``cache_db`` is attached RO.
|
|
4863
|
+
"""
|
|
4864
|
+
tables = (
|
|
4865
|
+
"conversation_messages",
|
|
4866
|
+
"conversation_ai_titles",
|
|
4867
|
+
"conversation_sessions",
|
|
4868
|
+
"conversation_file_touches",
|
|
4869
|
+
"codex_conversation_events",
|
|
4870
|
+
"codex_conversation_messages",
|
|
4871
|
+
"codex_conversation_file_touches",
|
|
4872
|
+
"codex_conversation_rollups",
|
|
4873
|
+
)
|
|
4874
|
+
changed = False
|
|
4875
|
+
for table in tables:
|
|
4876
|
+
try:
|
|
4877
|
+
if conn.execute(f"SELECT 1 FROM main.{table} LIMIT 1").fetchone():
|
|
4878
|
+
continue
|
|
4879
|
+
if not conn.execute(
|
|
4880
|
+
f"SELECT 1 FROM cache_db.{table} LIMIT 1"
|
|
4881
|
+
).fetchone():
|
|
4882
|
+
continue
|
|
4883
|
+
main_cols = [
|
|
4884
|
+
str(row[1]) for row in conn.execute(f"PRAGMA main.table_info({table})")
|
|
4885
|
+
]
|
|
4886
|
+
source_cols = {
|
|
4887
|
+
str(row[1])
|
|
4888
|
+
for row in conn.execute(f"PRAGMA cache_db.table_info({table})")
|
|
4889
|
+
}
|
|
4890
|
+
cols = [col for col in main_cols if col in source_cols]
|
|
4891
|
+
quoted = ",".join(f'"{col}"' for col in cols)
|
|
4892
|
+
conn.execute(
|
|
4893
|
+
f"INSERT OR IGNORE INTO main.{table} ({quoted}) "
|
|
4894
|
+
f"SELECT {quoted} FROM cache_db.{table}"
|
|
4895
|
+
)
|
|
4896
|
+
changed = True
|
|
4897
|
+
except sqlite3.Error:
|
|
4898
|
+
continue
|
|
4899
|
+
if changed:
|
|
4900
|
+
conn.commit()
|
|
4901
|
+
|
|
4902
|
+
|
|
4903
|
+
def _prepare_claude_conversation_maintenance(
|
|
4904
|
+
conn: sqlite3.Connection,
|
|
4905
|
+
*,
|
|
4906
|
+
rebuild: bool,
|
|
4907
|
+
targeted: bool,
|
|
4908
|
+
) -> None:
|
|
4909
|
+
"""Consume transcript-only upgrade work under the conversation flock.
|
|
4910
|
+
|
|
4911
|
+
These consumers historically ran inside ``sync_cache`` because prose and
|
|
4912
|
+
accounting shared one database. Keeping them here is the load-bearing
|
|
4913
|
+
half of the #320 split: schema upgrades may re-derive transcript state, but
|
|
4914
|
+
they never extend the core-cache critical section.
|
|
4915
|
+
"""
|
|
4916
|
+
if rebuild:
|
|
4917
|
+
# The offset-zero walk below re-derives every transcript projection.
|
|
4918
|
+
conn.execute(
|
|
4919
|
+
"DELETE FROM cache_meta WHERE key IN ("
|
|
4920
|
+
"'conversation_backfill_pending',"
|
|
4921
|
+
"'conversation_reingest_pending',"
|
|
4922
|
+
"'conversation_source_tool_use_reingest_pending',"
|
|
4923
|
+
"'conversation_reingest_enrichment_pending',"
|
|
4924
|
+
"'conversation_media_reingest_pending',"
|
|
4925
|
+
"'conversation_queued_prompt_reingest_pending',"
|
|
4926
|
+
"'conversation_reingest_nested_agent_pending',"
|
|
4927
|
+
"'conversation_reingest_file_touches_pending',"
|
|
4928
|
+
"'conversation_file_touches_cursor',"
|
|
4929
|
+
"'conversation_reingest_cursor',"
|
|
4930
|
+
"'conversation_reingest_cursor_gen',"
|
|
4931
|
+
"'conversation_promote_command_args_pending',"
|
|
4932
|
+
"'conversation_promote_command_args_cursor',"
|
|
4933
|
+
"'conversation_title_fts_backfill_pending',"
|
|
4934
|
+
"'ai_titles_backfill_pending')"
|
|
4935
|
+
)
|
|
4936
|
+
split_pending = conn.execute(
|
|
4937
|
+
"SELECT 1 FROM cache_meta "
|
|
4938
|
+
"WHERE key='conversation_search_split_pending'"
|
|
4939
|
+
).fetchone() is not None
|
|
4940
|
+
if split_pending:
|
|
4941
|
+
fts_off = conn.execute(
|
|
4942
|
+
"SELECT 1 FROM cache_meta WHERE key='fts5_unavailable'"
|
|
4943
|
+
).fetchone() is not None
|
|
4944
|
+
if not fts_off and not _cctally_db_sib._conversation_fts_is_split(conn):
|
|
4945
|
+
_cctally_db_sib._swap_conversation_fts_to_split(conn)
|
|
4946
|
+
conn.execute(
|
|
4947
|
+
"DELETE FROM cache_meta WHERE key IN "
|
|
4948
|
+
"('conversation_search_split_pending',"
|
|
4949
|
+
" 'conversation_search_split_cursor')"
|
|
4950
|
+
)
|
|
4951
|
+
_set_cache_meta(conn, "conversation_sessions_backfill_pending", "1")
|
|
4952
|
+
conn.commit()
|
|
4953
|
+
return
|
|
4954
|
+
|
|
4955
|
+
if targeted:
|
|
4956
|
+
return
|
|
4957
|
+
|
|
4958
|
+
if conn.execute(
|
|
4959
|
+
"SELECT 1 FROM cache_meta WHERE key='conversation_backfill_pending'"
|
|
4960
|
+
).fetchone() is not None:
|
|
4961
|
+
backfill_conversation_messages(conn)
|
|
4962
|
+
conn.execute(
|
|
4963
|
+
"DELETE FROM cache_meta WHERE key='conversation_backfill_pending'"
|
|
4964
|
+
)
|
|
4965
|
+
_set_cache_meta(conn, "conversation_sessions_backfill_pending", "1")
|
|
4966
|
+
conn.commit()
|
|
4967
|
+
|
|
4968
|
+
if conn.execute(
|
|
4969
|
+
"SELECT 1 FROM cache_meta WHERE key='ai_titles_backfill_pending'"
|
|
4970
|
+
).fetchone() is not None:
|
|
4971
|
+
backfill_ai_titles(conn)
|
|
4972
|
+
conn.execute(
|
|
4973
|
+
"DELETE FROM cache_meta WHERE key='ai_titles_backfill_pending'"
|
|
4974
|
+
)
|
|
4975
|
+
conn.commit()
|
|
4976
|
+
|
|
4977
|
+
reingest = conn.execute(
|
|
4978
|
+
"SELECT 1 FROM cache_meta WHERE key IN ("
|
|
4979
|
+
"'conversation_reingest_pending',"
|
|
4980
|
+
"'conversation_source_tool_use_reingest_pending',"
|
|
4981
|
+
"'conversation_reingest_enrichment_pending',"
|
|
4982
|
+
"'conversation_media_reingest_pending',"
|
|
4983
|
+
"'conversation_queued_prompt_reingest_pending',"
|
|
4984
|
+
"'conversation_reingest_nested_agent_pending')"
|
|
4985
|
+
).fetchone() is not None
|
|
4986
|
+
if reingest:
|
|
4987
|
+
_resumable_reingest_conversation_messages(conn)
|
|
4988
|
+
_set_cache_meta(conn, "conversation_sessions_backfill_pending", "1")
|
|
4989
|
+
conn.commit()
|
|
4990
|
+
|
|
4991
|
+
_consume_search_split(conn)
|
|
4992
|
+
_consume_promote_command_args(conn)
|
|
4993
|
+
_consume_title_fts(conn)
|
|
4994
|
+
_consume_file_touches(conn)
|
|
4995
|
+
|
|
4996
|
+
|
|
4997
|
+
def sync_claude_conversations(
|
|
4998
|
+
conn: sqlite3.Connection,
|
|
4999
|
+
*,
|
|
5000
|
+
rebuild: bool = False,
|
|
5001
|
+
lock_timeout: "float | None" = None,
|
|
5002
|
+
only_paths: "set[str] | None" = None,
|
|
5003
|
+
) -> IngestStats:
|
|
5004
|
+
"""Delta-sync Claude transcript/search rows into conversations.db (#320).
|
|
5005
|
+
|
|
5006
|
+
The transcript cursor is committed in the same conversations.db
|
|
5007
|
+
transaction as its message/title rows. No cache.db table is written, and
|
|
5008
|
+
the core accounting cursor is neither read nor advanced.
|
|
5009
|
+
"""
|
|
5010
|
+
stats = IngestStats()
|
|
5011
|
+
did_from_zero_replay = False
|
|
5012
|
+
_cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
5013
|
+
_cctally_core.CONVERSATIONS_LOCK_PATH.touch()
|
|
5014
|
+
lock_fh = open(_cctally_core.CONVERSATIONS_LOCK_PATH, "w")
|
|
5015
|
+
try:
|
|
5016
|
+
if not _acquire_cache_flock(lock_fh, timeout=lock_timeout):
|
|
5017
|
+
stats.lock_contended = True
|
|
5018
|
+
return stats
|
|
5019
|
+
|
|
5020
|
+
targeted = only_paths is not None
|
|
5021
|
+
pending_rebuild = conn.execute(
|
|
5022
|
+
"SELECT 1 FROM cache_meta "
|
|
5023
|
+
"WHERE key='conversation_rebuild_claude_pending'"
|
|
5024
|
+
).fetchone() is not None
|
|
5025
|
+
if pending_rebuild and targeted:
|
|
5026
|
+
stats.deferred_reason = "rebuild_pending"
|
|
5027
|
+
return stats
|
|
5028
|
+
if targeted:
|
|
5029
|
+
placeholders = ",".join("?" for _ in _TARGETED_DECLINE_FLAGS)
|
|
5030
|
+
if conn.execute(
|
|
5031
|
+
f"SELECT 1 FROM cache_meta WHERE key IN ({placeholders}) LIMIT 1",
|
|
5032
|
+
_TARGETED_DECLINE_FLAGS,
|
|
5033
|
+
).fetchone() is not None:
|
|
5034
|
+
stats.deferred_reason = "pending_global_flags"
|
|
5035
|
+
return stats
|
|
5036
|
+
rebuild = rebuild or pending_rebuild
|
|
5037
|
+
|
|
5038
|
+
_prepare_claude_conversation_maintenance(
|
|
5039
|
+
conn, rebuild=rebuild, targeted=targeted
|
|
5040
|
+
)
|
|
5041
|
+
|
|
5042
|
+
if rebuild:
|
|
5043
|
+
clear_conversation_messages(conn)
|
|
5044
|
+
conn.execute("DELETE FROM conversation_ai_titles")
|
|
5045
|
+
conn.execute("DELETE FROM conversation_sessions")
|
|
5046
|
+
conn.execute("DELETE FROM conversation_source_files")
|
|
5047
|
+
conn.commit()
|
|
5048
|
+
|
|
5049
|
+
if only_paths is not None and rebuild:
|
|
5050
|
+
raise ValueError(
|
|
5051
|
+
"sync_claude_conversations: only_paths is incompatible with rebuild"
|
|
5052
|
+
)
|
|
5053
|
+
paths = (
|
|
5054
|
+
[pathlib.Path(path) for path in sorted(only_paths)
|
|
5055
|
+
if pathlib.Path(path).is_file()]
|
|
5056
|
+
if only_paths is not None
|
|
5057
|
+
else list(_iter_claude_jsonl_files())
|
|
5058
|
+
)
|
|
5059
|
+
stats.files_total = len(paths)
|
|
5060
|
+
existing = {
|
|
5061
|
+
row[0]: (row[1], row[2], row[3])
|
|
5062
|
+
for row in conn.execute(
|
|
5063
|
+
"SELECT path,size_bytes,mtime_ns,last_byte_offset "
|
|
5064
|
+
"FROM conversation_source_files"
|
|
5065
|
+
)
|
|
5066
|
+
}
|
|
5067
|
+
if targeted:
|
|
5068
|
+
for jp in paths:
|
|
5069
|
+
prev = existing.get(str(jp))
|
|
5070
|
+
if prev is None:
|
|
5071
|
+
continue
|
|
5072
|
+
try:
|
|
5073
|
+
current_size = jp.stat().st_size
|
|
5074
|
+
except OSError:
|
|
5075
|
+
continue
|
|
5076
|
+
if current_size < prev[0]:
|
|
5077
|
+
stats.deferred_reason = "truncation"
|
|
5078
|
+
return stats
|
|
5079
|
+
# Missing Claude paths are deliberately retained here. Their message
|
|
5080
|
+
# rows are the evidence used by _prune_orphaned_cache_entries's
|
|
5081
|
+
# coverage/disjointness gates before it deletes core accounting rows.
|
|
5082
|
+
# Eager transcript cleanup would destroy that proof and turn every
|
|
5083
|
+
# dashboard orphan heal into a residual. Explicit --rebuild may clear
|
|
5084
|
+
# the whole re-derivable store; ordinary sync remains detect/retain.
|
|
5085
|
+
touched_sessions: set[str] = set()
|
|
5086
|
+
|
|
5087
|
+
for jp in paths:
|
|
5088
|
+
path_str = str(jp)
|
|
5089
|
+
try:
|
|
5090
|
+
st = jp.stat()
|
|
5091
|
+
except OSError:
|
|
5092
|
+
stats.files_failed += 1
|
|
5093
|
+
continue
|
|
5094
|
+
size, mtime_ns = st.st_size, st.st_mtime_ns
|
|
5095
|
+
prev = existing.get(path_str)
|
|
5096
|
+
if prev is not None and size == prev[0]:
|
|
5097
|
+
stats.files_skipped_unchanged += 1
|
|
5098
|
+
continue
|
|
5099
|
+
truncated = prev is not None and size < prev[0]
|
|
5100
|
+
if targeted and truncated:
|
|
5101
|
+
stats.deferred_reason = "truncation"
|
|
5102
|
+
return stats
|
|
5103
|
+
start_offset = 0 if prev is None or truncated else prev[2]
|
|
5104
|
+
conv_rows: list[tuple[Any, ...]] = []
|
|
5105
|
+
ai_rows: list[tuple[Any, ...]] = []
|
|
5106
|
+
final_offset = start_offset
|
|
5107
|
+
try:
|
|
5108
|
+
with open(jp, "r", encoding="utf-8", errors="replace") as fh:
|
|
5109
|
+
fh.seek(start_offset)
|
|
5110
|
+
for _offset, _cost, mrow, ai in _iter_sync_entries(
|
|
5111
|
+
fh,
|
|
5112
|
+
path_str,
|
|
5113
|
+
include_cost=False,
|
|
5114
|
+
):
|
|
5115
|
+
if mrow is not None:
|
|
5116
|
+
conv_rows.append(_conv_row_tuple(mrow, path_str))
|
|
5117
|
+
if ai is not None:
|
|
5118
|
+
ai_rows.append(
|
|
5119
|
+
(ai.session_id, ai.ai_title, path_str, ai.byte_offset)
|
|
5120
|
+
)
|
|
5121
|
+
final_offset = fh.tell()
|
|
5122
|
+
except OSError as exc:
|
|
5123
|
+
eprint(f"[conversations] could not read {jp}: {exc}")
|
|
5124
|
+
stats.files_failed += 1
|
|
5125
|
+
continue
|
|
5126
|
+
|
|
5127
|
+
try:
|
|
5128
|
+
if truncated:
|
|
5129
|
+
touched_sessions.update(
|
|
5130
|
+
row[0]
|
|
5131
|
+
for row in conn.execute(
|
|
5132
|
+
"SELECT DISTINCT session_id FROM conversation_messages "
|
|
5133
|
+
"WHERE source_path=? AND session_id IS NOT NULL",
|
|
5134
|
+
(path_str,),
|
|
5135
|
+
)
|
|
5136
|
+
)
|
|
5137
|
+
conn.execute(
|
|
5138
|
+
"DELETE FROM conversation_file_touches WHERE message_id IN "
|
|
5139
|
+
"(SELECT id FROM conversation_messages WHERE source_path=?)",
|
|
5140
|
+
(path_str,),
|
|
5141
|
+
)
|
|
5142
|
+
conn.execute(
|
|
5143
|
+
"DELETE FROM conversation_messages WHERE source_path=?",
|
|
5144
|
+
(path_str,),
|
|
5145
|
+
)
|
|
5146
|
+
conn.execute(
|
|
5147
|
+
"DELETE FROM conversation_ai_titles WHERE source_path=?",
|
|
5148
|
+
(path_str,),
|
|
5149
|
+
)
|
|
5150
|
+
stats.files_reset_truncated += 1
|
|
5151
|
+
if conv_rows:
|
|
5152
|
+
conn.executemany(_CONV_INSERT_SQL, conv_rows)
|
|
5153
|
+
_fill_file_touches(
|
|
5154
|
+
conn, scope=[(row[3], row[4]) for row in conv_rows]
|
|
5155
|
+
)
|
|
5156
|
+
if ai_rows:
|
|
5157
|
+
conn.executemany(_AI_TITLE_UPSERT_SQL, ai_rows)
|
|
5158
|
+
conn.execute(
|
|
5159
|
+
"INSERT INTO conversation_source_files "
|
|
5160
|
+
"(path,size_bytes,mtime_ns,last_byte_offset,last_ingested_at) "
|
|
5161
|
+
"VALUES(?,?,?,?,?) ON CONFLICT(path) DO UPDATE SET "
|
|
5162
|
+
"size_bytes=excluded.size_bytes,mtime_ns=excluded.mtime_ns,"
|
|
5163
|
+
"last_byte_offset=excluded.last_byte_offset,"
|
|
5164
|
+
"last_ingested_at=excluded.last_ingested_at",
|
|
5165
|
+
(
|
|
5166
|
+
path_str,
|
|
5167
|
+
size,
|
|
5168
|
+
mtime_ns,
|
|
5169
|
+
final_offset,
|
|
5170
|
+
dt.datetime.now(dt.timezone.utc).isoformat(),
|
|
5171
|
+
),
|
|
5172
|
+
)
|
|
5173
|
+
conn.commit()
|
|
5174
|
+
stats.files_processed += 1
|
|
5175
|
+
touched_sessions.update(
|
|
5176
|
+
row[0] for row in conv_rows if row[0] is not None
|
|
5177
|
+
)
|
|
5178
|
+
except sqlite3.DatabaseError as exc:
|
|
5179
|
+
conn.rollback()
|
|
5180
|
+
eprint(f"[conversations] db error on {jp}: {exc}")
|
|
5181
|
+
stats.files_failed += 1
|
|
5182
|
+
|
|
5183
|
+
_arm_rollup_backfill_on_pricing_change(conn)
|
|
5184
|
+
if _conversation_sessions_backfill_pending(conn):
|
|
5185
|
+
_recompute_conversation_sessions(conn)
|
|
5186
|
+
conn.execute(
|
|
5187
|
+
"DELETE FROM cache_meta "
|
|
5188
|
+
"WHERE key='conversation_sessions_backfill_pending'"
|
|
5189
|
+
)
|
|
5190
|
+
conn.commit()
|
|
5191
|
+
elif touched_sessions:
|
|
5192
|
+
_recompute_conversation_sessions(conn, touched_sessions)
|
|
5193
|
+
conn.commit()
|
|
5194
|
+
if only_paths is None and stats.files_failed == 0:
|
|
5195
|
+
conn.execute(
|
|
5196
|
+
"DELETE FROM cache_meta "
|
|
5197
|
+
"WHERE key='conversation_rebuild_claude_pending'"
|
|
5198
|
+
)
|
|
5199
|
+
conn.commit()
|
|
5200
|
+
_harden_conversation_sidecars()
|
|
5201
|
+
_maybe_truncate_wal(conn, _cctally_core.CONVERSATIONS_DB_PATH)
|
|
5202
|
+
did_from_zero_replay = rebuild or stats.files_reset_truncated > 0
|
|
5203
|
+
finally:
|
|
5204
|
+
try:
|
|
5205
|
+
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
5206
|
+
except OSError:
|
|
5207
|
+
pass
|
|
5208
|
+
lock_fh.close()
|
|
5209
|
+
if did_from_zero_replay:
|
|
5210
|
+
_force_retention_prune_after_replay()
|
|
5211
|
+
return stats
|
|
5212
|
+
|
|
5213
|
+
|
|
5214
|
+
def _clear_codex_conversation_store(conn: sqlite3.Connection) -> None:
|
|
5215
|
+
"""Clear only the re-derivable Codex transcript families."""
|
|
5216
|
+
conn.execute("DELETE FROM codex_conversation_events")
|
|
5217
|
+
_codex_conversation_fts_full_clear(conn)
|
|
5218
|
+
conn.execute("DELETE FROM codex_conversation_source_files")
|
|
5219
|
+
|
|
5220
|
+
|
|
5221
|
+
def sync_codex_conversations(
|
|
5222
|
+
conn: sqlite3.Connection,
|
|
5223
|
+
*,
|
|
5224
|
+
rebuild: bool = False,
|
|
5225
|
+
lock_timeout: "float | None" = None,
|
|
5226
|
+
only_paths: "set[str] | None" = None,
|
|
5227
|
+
) -> CodexIngestStats:
|
|
5228
|
+
"""Delta-sync Codex events/search rows into conversations.db (#320)."""
|
|
5229
|
+
stats = CodexIngestStats()
|
|
5230
|
+
did_from_zero_replay = False
|
|
5231
|
+
_cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
5232
|
+
_cctally_core.CONVERSATIONS_LOCK_CODEX_PATH.touch()
|
|
5233
|
+
lock_fh = open(_cctally_core.CONVERSATIONS_LOCK_CODEX_PATH, "w")
|
|
5234
|
+
try:
|
|
5235
|
+
if not _acquire_cache_flock(lock_fh, timeout=lock_timeout):
|
|
5236
|
+
stats.lock_contended = True
|
|
5237
|
+
return stats
|
|
5238
|
+
targeted = only_paths is not None
|
|
5239
|
+
pending_rebuild = conn.execute(
|
|
5240
|
+
"SELECT 1 FROM cache_meta "
|
|
5241
|
+
"WHERE key='conversation_rebuild_codex_pending'"
|
|
5242
|
+
).fetchone() is not None
|
|
5243
|
+
if pending_rebuild and targeted:
|
|
5244
|
+
stats.deferred_reason = "rebuild_pending"
|
|
5245
|
+
return stats
|
|
5246
|
+
rebuild = rebuild or pending_rebuild
|
|
5247
|
+
if rebuild:
|
|
5248
|
+
_clear_codex_conversation_store(conn)
|
|
5249
|
+
conn.commit()
|
|
5250
|
+
|
|
5251
|
+
if only_paths is not None and rebuild:
|
|
5252
|
+
raise ValueError(
|
|
5253
|
+
"sync_codex_conversations: only_paths is incompatible with rebuild"
|
|
5254
|
+
)
|
|
5255
|
+
files = (
|
|
5256
|
+
_qualify_codex_targets(only_paths)
|
|
5257
|
+
if only_paths is not None
|
|
5258
|
+
else _discover_codex_files_with_roots()
|
|
5259
|
+
)
|
|
5260
|
+
stats.files_total = len(files)
|
|
5261
|
+
existing = {
|
|
5262
|
+
row[0]: tuple(row[1:])
|
|
5263
|
+
for row in conn.execute(
|
|
5264
|
+
"SELECT path,size_bytes,mtime_ns,last_byte_offset,source_root_key,"
|
|
5265
|
+
"last_session_id,last_model,last_total_tokens,"
|
|
5266
|
+
"last_native_thread_id,last_root_thread_id,last_parent_thread_id,"
|
|
5267
|
+
"last_conversation_key,last_turn_id "
|
|
5268
|
+
"FROM codex_conversation_source_files"
|
|
5269
|
+
)
|
|
5270
|
+
}
|
|
5271
|
+
if targeted:
|
|
5272
|
+
for discovered in files:
|
|
5273
|
+
prev = existing.get(str(discovered.source_path))
|
|
5274
|
+
if prev is None:
|
|
5275
|
+
continue
|
|
5276
|
+
if prev[3] != discovered.source_root_key:
|
|
5277
|
+
stats.deferred_reason = "requalification"
|
|
5278
|
+
return stats
|
|
5279
|
+
try:
|
|
5280
|
+
current_size = discovered.source_path.stat().st_size
|
|
5281
|
+
except OSError:
|
|
5282
|
+
continue
|
|
5283
|
+
if current_size < prev[0]:
|
|
5284
|
+
stats.deferred_reason = "truncation"
|
|
5285
|
+
return stats
|
|
5286
|
+
if only_paths is None:
|
|
5287
|
+
active_paths = {str(item.source_path) for item in files}
|
|
5288
|
+
for stale_path in sorted(set(existing) - active_paths):
|
|
5289
|
+
affected = {
|
|
5290
|
+
row[0] for row in conn.execute(
|
|
5291
|
+
"SELECT DISTINCT conversation_key "
|
|
5292
|
+
"FROM codex_conversation_messages WHERE source_path=?",
|
|
5293
|
+
(stale_path,),
|
|
5294
|
+
) if row[0]
|
|
5295
|
+
}
|
|
5296
|
+
conn.execute(
|
|
5297
|
+
"DELETE FROM codex_conversation_file_touches WHERE source_path=?",
|
|
5298
|
+
(stale_path,),
|
|
5299
|
+
)
|
|
5300
|
+
conn.execute(
|
|
5301
|
+
"DELETE FROM codex_conversation_messages WHERE source_path=?",
|
|
5302
|
+
(stale_path,),
|
|
5303
|
+
)
|
|
5304
|
+
conn.execute(
|
|
5305
|
+
"DELETE FROM codex_conversation_events WHERE source_path=?",
|
|
5306
|
+
(stale_path,),
|
|
5307
|
+
)
|
|
5308
|
+
conn.execute(
|
|
5309
|
+
"DELETE FROM codex_conversation_source_files WHERE path=?",
|
|
5310
|
+
(stale_path,),
|
|
5311
|
+
)
|
|
5312
|
+
_recompute_codex_rollups(conn, affected)
|
|
5313
|
+
conn.commit()
|
|
5314
|
+
|
|
5315
|
+
for discovered in files:
|
|
5316
|
+
jp = discovered.source_path
|
|
5317
|
+
path_str = str(jp)
|
|
5318
|
+
try:
|
|
5319
|
+
st = jp.stat()
|
|
5320
|
+
except OSError:
|
|
5321
|
+
stats.files_failed += 1
|
|
5322
|
+
continue
|
|
5323
|
+
size, mtime_ns = st.st_size, st.st_mtime_ns
|
|
5324
|
+
prev = existing.get(path_str)
|
|
5325
|
+
if prev is not None and size == prev[0] and prev[3] == discovered.source_root_key:
|
|
5326
|
+
stats.files_skipped_unchanged += 1
|
|
5327
|
+
continue
|
|
5328
|
+
reset_file = (
|
|
5329
|
+
prev is not None
|
|
5330
|
+
and (size < prev[0] or prev[3] != discovered.source_root_key)
|
|
5331
|
+
)
|
|
5332
|
+
if targeted and reset_file:
|
|
5333
|
+
stats.deferred_reason = (
|
|
5334
|
+
"requalification"
|
|
5335
|
+
if prev is not None and prev[3] != discovered.source_root_key
|
|
5336
|
+
else "truncation"
|
|
5337
|
+
)
|
|
5338
|
+
return stats
|
|
5339
|
+
start_offset = 0 if prev is None or reset_file else int(prev[2])
|
|
5340
|
+
initial_session_id = prev[4] if prev else None
|
|
5341
|
+
initial_model = prev[5] if prev else None
|
|
5342
|
+
initial_total_tokens = (
|
|
5343
|
+
int(prev[6]) if prev and prev[6] is not None else 0
|
|
5344
|
+
)
|
|
5345
|
+
initial_native = prev[7] if prev else None
|
|
5346
|
+
initial_root = prev[8] if prev else None
|
|
5347
|
+
initial_parent = prev[9] if prev else None
|
|
5348
|
+
initial_conversation = prev[10] if prev else None
|
|
5349
|
+
initial_turn = prev[11] if prev else None
|
|
5350
|
+
|
|
5351
|
+
state = _CodexIterState(
|
|
5352
|
+
session_id=initial_session_id,
|
|
5353
|
+
model=initial_model,
|
|
5354
|
+
total_tokens=initial_total_tokens,
|
|
5355
|
+
)
|
|
5356
|
+
if initial_native and initial_root:
|
|
5357
|
+
state.thread = _lib_jsonl.CodexThreadMetadata(
|
|
5358
|
+
source_root_key=discovered.source_root_key,
|
|
5359
|
+
source_path=path_str,
|
|
5360
|
+
native_thread_id=initial_native,
|
|
5361
|
+
root_thread_id=initial_root,
|
|
5362
|
+
parent_thread_id=initial_parent,
|
|
5363
|
+
conversation_key=initial_conversation,
|
|
5364
|
+
cwd=None,
|
|
5365
|
+
git_json=None,
|
|
5366
|
+
source_kind=None,
|
|
5367
|
+
thread_source_json=None,
|
|
5368
|
+
model_provider=None,
|
|
5369
|
+
context_window=None,
|
|
5370
|
+
)
|
|
5371
|
+
events = []
|
|
5372
|
+
event_rows = []
|
|
5373
|
+
yielded = 0
|
|
5374
|
+
try:
|
|
5375
|
+
with open(jp, "rb") as fh:
|
|
5376
|
+
fh.seek(start_offset)
|
|
5377
|
+
for emission in _iter_codex_fused_records_with_offsets(
|
|
5378
|
+
fh,
|
|
5379
|
+
path_str,
|
|
5380
|
+
initial_session_id=initial_session_id,
|
|
5381
|
+
initial_model=initial_model,
|
|
5382
|
+
initial_total_tokens=initial_total_tokens,
|
|
5383
|
+
source_root_key=discovered.source_root_key,
|
|
5384
|
+
state=state,
|
|
5385
|
+
):
|
|
5386
|
+
event = emission.event
|
|
5387
|
+
events.append(event)
|
|
5388
|
+
event_rows.append((
|
|
5389
|
+
event.source_path,
|
|
5390
|
+
event.line_offset,
|
|
5391
|
+
event.source_root_key,
|
|
5392
|
+
event.conversation_key,
|
|
5393
|
+
event.native_thread_id,
|
|
5394
|
+
event.root_thread_id,
|
|
5395
|
+
event.parent_thread_id,
|
|
5396
|
+
event.timestamp_utc,
|
|
5397
|
+
event.record_type,
|
|
5398
|
+
event.event_type,
|
|
5399
|
+
event.turn_id,
|
|
5400
|
+
event.call_id,
|
|
5401
|
+
event.payload_json,
|
|
5402
|
+
))
|
|
5403
|
+
if emission.accounting is not None:
|
|
5404
|
+
yielded += 1
|
|
5405
|
+
final_offset = fh.tell()
|
|
5406
|
+
except OSError as exc:
|
|
5407
|
+
eprint(f"[codex-conversations] could not read {jp}: {exc}")
|
|
5408
|
+
stats.files_failed += 1
|
|
5409
|
+
continue
|
|
5410
|
+
|
|
5411
|
+
try:
|
|
5412
|
+
normalized = _lib_codex_conversation.normalize_codex_events(
|
|
5413
|
+
events,
|
|
5414
|
+
initial=_lib_codex_conversation.CodexStickyState(
|
|
5415
|
+
turn_id=initial_turn,
|
|
5416
|
+
model=initial_model,
|
|
5417
|
+
),
|
|
5418
|
+
)
|
|
5419
|
+
except Exception as exc: # noqa: BLE001
|
|
5420
|
+
if not targeted:
|
|
5421
|
+
raise
|
|
5422
|
+
eprint(
|
|
5423
|
+
f"[codex-conversations] normalization failed for {jp}: {exc}"
|
|
5424
|
+
)
|
|
5425
|
+
stats.files_failed += 1
|
|
5426
|
+
continue
|
|
5427
|
+
affected_keys = {
|
|
5428
|
+
row[0]
|
|
5429
|
+
for row in conn.execute(
|
|
5430
|
+
"SELECT DISTINCT conversation_key "
|
|
5431
|
+
"FROM codex_conversation_messages WHERE source_path=?",
|
|
5432
|
+
(path_str,),
|
|
5433
|
+
)
|
|
5434
|
+
if row[0]
|
|
5435
|
+
} if reset_file else set()
|
|
5436
|
+
try:
|
|
5437
|
+
if reset_file:
|
|
5438
|
+
conn.execute(
|
|
5439
|
+
"DELETE FROM codex_conversation_file_touches "
|
|
5440
|
+
"WHERE source_path=?",
|
|
5441
|
+
(path_str,),
|
|
5442
|
+
)
|
|
5443
|
+
conn.execute(
|
|
5444
|
+
"DELETE FROM codex_conversation_messages WHERE source_path=?",
|
|
5445
|
+
(path_str,),
|
|
5446
|
+
)
|
|
5447
|
+
conn.execute(
|
|
5448
|
+
"DELETE FROM codex_conversation_events WHERE source_path=?",
|
|
5449
|
+
(path_str,),
|
|
5450
|
+
)
|
|
5451
|
+
stats.files_reset_truncated += 1
|
|
5452
|
+
if event_rows:
|
|
5453
|
+
conn.executemany(
|
|
5454
|
+
"INSERT OR IGNORE INTO codex_conversation_events "
|
|
5455
|
+
"(source_path,line_offset,source_root_key,conversation_key,"
|
|
5456
|
+
"native_thread_id,root_thread_id,parent_thread_id,"
|
|
5457
|
+
"timestamp_utc,record_type,event_type,turn_id,call_id,payload_json) "
|
|
5458
|
+
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
5459
|
+
event_rows,
|
|
5460
|
+
)
|
|
5461
|
+
_insert_codex_normalized_rows(
|
|
5462
|
+
conn, normalized.rows, normalized.touches
|
|
5463
|
+
)
|
|
5464
|
+
affected_keys.update(
|
|
5465
|
+
row.conversation_key for row in normalized.rows
|
|
5466
|
+
)
|
|
5467
|
+
_recompute_codex_rollups(conn, affected_keys)
|
|
5468
|
+
terminal = state.thread
|
|
5469
|
+
conn.execute(
|
|
5470
|
+
"INSERT INTO codex_conversation_source_files "
|
|
5471
|
+
"(path,size_bytes,mtime_ns,last_byte_offset,last_ingested_at,"
|
|
5472
|
+
"source_root_key,last_session_id,last_model,last_total_tokens,"
|
|
5473
|
+
"last_native_thread_id,last_root_thread_id,last_parent_thread_id,"
|
|
5474
|
+
"last_conversation_key,last_turn_id) "
|
|
5475
|
+
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) "
|
|
5476
|
+
"ON CONFLICT(path) DO UPDATE SET "
|
|
5477
|
+
"size_bytes=excluded.size_bytes,mtime_ns=excluded.mtime_ns,"
|
|
5478
|
+
"last_byte_offset=excluded.last_byte_offset,"
|
|
5479
|
+
"last_ingested_at=excluded.last_ingested_at,"
|
|
5480
|
+
"source_root_key=excluded.source_root_key,"
|
|
5481
|
+
"last_session_id=excluded.last_session_id,"
|
|
5482
|
+
"last_model=excluded.last_model,"
|
|
5483
|
+
"last_total_tokens=excluded.last_total_tokens,"
|
|
5484
|
+
"last_native_thread_id=excluded.last_native_thread_id,"
|
|
5485
|
+
"last_root_thread_id=excluded.last_root_thread_id,"
|
|
5486
|
+
"last_parent_thread_id=excluded.last_parent_thread_id,"
|
|
5487
|
+
"last_conversation_key=excluded.last_conversation_key,"
|
|
5488
|
+
"last_turn_id=excluded.last_turn_id",
|
|
5489
|
+
(
|
|
5490
|
+
path_str,
|
|
5491
|
+
size,
|
|
5492
|
+
mtime_ns,
|
|
5493
|
+
final_offset,
|
|
5494
|
+
dt.datetime.now(dt.timezone.utc).isoformat(),
|
|
5495
|
+
discovered.source_root_key,
|
|
5496
|
+
state.session_id or initial_session_id,
|
|
5497
|
+
state.model or initial_model,
|
|
5498
|
+
state.total_tokens if yielded else initial_total_tokens,
|
|
5499
|
+
terminal.native_thread_id if terminal else initial_native,
|
|
5500
|
+
terminal.root_thread_id if terminal else initial_root,
|
|
5501
|
+
terminal.parent_thread_id if terminal else initial_parent,
|
|
5502
|
+
terminal.conversation_key if terminal else initial_conversation,
|
|
5503
|
+
normalized.terminal.turn_id,
|
|
5504
|
+
),
|
|
5505
|
+
)
|
|
5506
|
+
conn.commit()
|
|
5507
|
+
stats.files_processed += 1
|
|
5508
|
+
except sqlite3.DatabaseError as exc:
|
|
5509
|
+
conn.rollback()
|
|
5510
|
+
eprint(f"[codex-conversations] db error on {jp}: {exc}")
|
|
5511
|
+
stats.files_failed += 1
|
|
5512
|
+
|
|
5513
|
+
if only_paths is None and stats.files_failed == 0:
|
|
5514
|
+
conn.execute(
|
|
5515
|
+
"DELETE FROM cache_meta "
|
|
5516
|
+
"WHERE key='conversation_rebuild_codex_pending'"
|
|
5517
|
+
)
|
|
5518
|
+
conn.commit()
|
|
5519
|
+
_harden_conversation_sidecars()
|
|
5520
|
+
_maybe_truncate_wal(conn, _cctally_core.CONVERSATIONS_DB_PATH)
|
|
5521
|
+
did_from_zero_replay = rebuild or stats.files_reset_truncated > 0
|
|
5522
|
+
finally:
|
|
5523
|
+
try:
|
|
5524
|
+
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
5525
|
+
except OSError:
|
|
5526
|
+
pass
|
|
5527
|
+
lock_fh.close()
|
|
5528
|
+
if did_from_zero_replay:
|
|
5529
|
+
_force_retention_prune_after_replay()
|
|
5530
|
+
return stats
|
|
5531
|
+
|
|
5532
|
+
|
|
4813
5533
|
# === Region 7: cmd_cache_sync (was bin/cctally:11563-11616) ===
|
|
4814
5534
|
|
|
4815
5535
|
|
|
@@ -4878,12 +5598,16 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
|
|
|
4878
5598
|
"(conversation.retention_days=0); nothing pruned."
|
|
4879
5599
|
)
|
|
4880
5600
|
return 0
|
|
4881
|
-
|
|
4882
|
-
|
|
4883
|
-
|
|
4884
|
-
|
|
4885
|
-
|
|
4886
|
-
|
|
5601
|
+
conv_conn = open_conversations_db(attach_cache=False)
|
|
5602
|
+
try:
|
|
5603
|
+
result = retention._maybe_prune_conversation_retention(
|
|
5604
|
+
conv_conn,
|
|
5605
|
+
now_utc=dt.datetime.now(dt.timezone.utc),
|
|
5606
|
+
retention_days=retention_days,
|
|
5607
|
+
force=True,
|
|
5608
|
+
)
|
|
5609
|
+
finally:
|
|
5610
|
+
conv_conn.close()
|
|
4887
5611
|
if result is None:
|
|
4888
5612
|
eprint(
|
|
4889
5613
|
"[cache-sync] prune-conversations skipped: another process "
|
|
@@ -4896,7 +5620,7 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
|
|
|
4896
5620
|
f"{result.claude_messages} message(s), "
|
|
4897
5621
|
f"codex {result.codex_conversations} conversation(s) / "
|
|
4898
5622
|
f"{result.codex_events} event(s). "
|
|
4899
|
-
f"Run `cctally db vacuum` to reclaim the freed space."
|
|
5623
|
+
f"Run `cctally db vacuum --db conversations` to reclaim the freed space."
|
|
4900
5624
|
)
|
|
4901
5625
|
return 0
|
|
4902
5626
|
|
|
@@ -4963,6 +5687,57 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
|
|
|
4963
5687
|
f"{stats.token_events_skipped} drift-skipped"
|
|
4964
5688
|
)
|
|
4965
5689
|
|
|
5690
|
+
# #320: transcript/search ingestion is a second physical database with its
|
|
5691
|
+
# own cursors and flocks. Run it only after the core providers have
|
|
5692
|
+
# committed so a slow/failed transcript pass can never roll back accounting
|
|
5693
|
+
# or quota state.
|
|
5694
|
+
try:
|
|
5695
|
+
conversation_conn = open_conversations_db()
|
|
5696
|
+
except (OSError, sqlite3.DatabaseError) as exc:
|
|
5697
|
+
eprint(
|
|
5698
|
+
f"[cache-sync] transcript store unavailable ({exc}); "
|
|
5699
|
+
"core accounting/quota sync is complete"
|
|
5700
|
+
)
|
|
5701
|
+
_p_root.__exit__(None, None, None)
|
|
5702
|
+
if _perf.enabled():
|
|
5703
|
+
_perf.flush_stderr(_perf.current_root())
|
|
5704
|
+
return 1 if args.rebuild else (1 if contended else 0)
|
|
5705
|
+
try:
|
|
5706
|
+
if source in ("claude", "all"):
|
|
5707
|
+
conv_stats = sync_claude_conversations(
|
|
5708
|
+
conversation_conn, rebuild=args.rebuild, lock_timeout=lt
|
|
5709
|
+
)
|
|
5710
|
+
if conv_stats.lock_contended:
|
|
5711
|
+
eprint(
|
|
5712
|
+
"[cache-sync] transcript sync skipped (claude): "
|
|
5713
|
+
"another process holds the conversations lock"
|
|
5714
|
+
)
|
|
5715
|
+
contended = contended or bool(args.rebuild)
|
|
5716
|
+
else:
|
|
5717
|
+
eprint(
|
|
5718
|
+
f"[cache-sync] claude transcripts done: "
|
|
5719
|
+
f"{conv_stats.files_processed} processed, "
|
|
5720
|
+
f"{conv_stats.files_skipped_unchanged} skipped"
|
|
5721
|
+
)
|
|
5722
|
+
if source in ("codex", "all"):
|
|
5723
|
+
conv_stats = sync_codex_conversations(
|
|
5724
|
+
conversation_conn, rebuild=args.rebuild, lock_timeout=lt
|
|
5725
|
+
)
|
|
5726
|
+
if conv_stats.lock_contended:
|
|
5727
|
+
eprint(
|
|
5728
|
+
"[cache-sync] transcript sync skipped (codex): "
|
|
5729
|
+
"another process holds the conversations lock"
|
|
5730
|
+
)
|
|
5731
|
+
contended = contended or bool(args.rebuild)
|
|
5732
|
+
else:
|
|
5733
|
+
eprint(
|
|
5734
|
+
f"[cache-sync] codex transcripts done: "
|
|
5735
|
+
f"{conv_stats.files_processed} processed, "
|
|
5736
|
+
f"{conv_stats.files_skipped_unchanged} skipped"
|
|
5737
|
+
)
|
|
5738
|
+
finally:
|
|
5739
|
+
conversation_conn.close()
|
|
5740
|
+
|
|
4966
5741
|
_p_root.__exit__(None, None, None)
|
|
4967
5742
|
# #276 perf: when tracing is enabled, flush the completed "cache-sync"
|
|
4968
5743
|
# phase tree to stderr (stdout stays byte-identical). No-op when off.
|