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_core.py
CHANGED
|
@@ -60,8 +60,10 @@ def _init_paths_from_env() -> None:
|
|
|
60
60
|
`import _cctally_core`).
|
|
61
61
|
"""
|
|
62
62
|
global APP_DIR, LEGACY_APP_DIR, LOG_DIR, DEV_MODE
|
|
63
|
-
global DB_PATH, CACHE_DB_PATH
|
|
63
|
+
global DB_PATH, CACHE_DB_PATH, CONVERSATIONS_DB_PATH
|
|
64
64
|
global CACHE_LOCK_PATH, CACHE_LOCK_CODEX_PATH, CACHE_LOCK_MAINTENANCE_PATH
|
|
65
|
+
global CONVERSATIONS_LOCK_PATH, CONVERSATIONS_LOCK_CODEX_PATH
|
|
66
|
+
global CONVERSATIONS_LOCK_MAINTENANCE_PATH
|
|
65
67
|
global CONFIG_LOCK_PATH
|
|
66
68
|
global CONFIG_PATH, MIGRATION_ERROR_LOG_PATH, CHANGELOG_PATH
|
|
67
69
|
global HOOK_TICK_LOG_DIR, HOOK_TICK_LOG_PATH, HOOK_TICK_LOG_ROTATED_PATH
|
|
@@ -101,6 +103,7 @@ def _init_paths_from_env() -> None:
|
|
|
101
103
|
|
|
102
104
|
DB_PATH = APP_DIR / "stats.db"
|
|
103
105
|
CACHE_DB_PATH = APP_DIR / "cache.db"
|
|
106
|
+
CONVERSATIONS_DB_PATH = APP_DIR / "conversations.db"
|
|
104
107
|
|
|
105
108
|
CACHE_LOCK_PATH = APP_DIR / "cache.db.lock"
|
|
106
109
|
CACHE_LOCK_CODEX_PATH = APP_DIR / "cache.db.codex.lock"
|
|
@@ -108,6 +111,14 @@ def _init_paths_from_env() -> None:
|
|
|
108
111
|
# retention prune across processes, held ABOVE the two provider flocks so a
|
|
109
112
|
# rebuild/reingest cannot land between candidate selection and deletion.
|
|
110
113
|
CACHE_LOCK_MAINTENANCE_PATH = APP_DIR / "cache.db.maintenance.lock"
|
|
114
|
+
# #320: transcript ingest and maintenance are physically independent from
|
|
115
|
+
# the latency-sensitive token/quota cache. Never reuse the cache.db flock
|
|
116
|
+
# namespace for conversations.db writes.
|
|
117
|
+
CONVERSATIONS_LOCK_PATH = APP_DIR / "conversations.db.lock"
|
|
118
|
+
CONVERSATIONS_LOCK_CODEX_PATH = APP_DIR / "conversations.db.codex.lock"
|
|
119
|
+
CONVERSATIONS_LOCK_MAINTENANCE_PATH = (
|
|
120
|
+
APP_DIR / "conversations.db.maintenance.lock"
|
|
121
|
+
)
|
|
111
122
|
CONFIG_LOCK_PATH = APP_DIR / "config.json.lock"
|
|
112
123
|
|
|
113
124
|
CONFIG_PATH = APP_DIR / "config.json"
|
|
@@ -359,7 +359,9 @@ import _lib_log
|
|
|
359
359
|
from _cctally_config import save_config, _load_config_unlocked
|
|
360
360
|
from _cctally_db import _render_migration_error_banner
|
|
361
361
|
from _cctally_cache import (
|
|
362
|
-
get_entries, iter_entries, iter_entries_with_id, open_cache_db,
|
|
362
|
+
get_entries, iter_entries, iter_entries_with_id, open_cache_db,
|
|
363
|
+
open_conversations_db, sync_cache, sync_claude_conversations,
|
|
364
|
+
sync_codex_conversations,
|
|
363
365
|
_prune_orphaned_cache_entries,
|
|
364
366
|
)
|
|
365
367
|
from _lib_snapshot_cache import (
|
|
@@ -1175,8 +1177,7 @@ def _dashboard_sync_loop(
|
|
|
1175
1177
|
|
|
1176
1178
|
def _dashboard_maybe_prune_retention() -> None:
|
|
1177
1179
|
"""#313 P3 (F7): throttled transcript retention prune driven from the
|
|
1178
|
-
|
|
1179
|
-
work (Task 6), so its cost paces the deadline. Opens a dedicated cache
|
|
1180
|
+
independent conversation sync thread. Opens a dedicated transcript
|
|
1180
1181
|
connection (no provider flock, no open transaction) for the orchestrator.
|
|
1181
1182
|
No-op when retention is disabled; never raises (a prune failure must not
|
|
1182
1183
|
crash the sync thread)."""
|
|
@@ -1187,7 +1188,7 @@ def _dashboard_maybe_prune_retention() -> None:
|
|
|
1187
1188
|
retention_days = resolve_retention_days(c.load_config())
|
|
1188
1189
|
if retention_days <= 0:
|
|
1189
1190
|
return
|
|
1190
|
-
conn = c.
|
|
1191
|
+
conn = c.open_conversations_db(attach_cache=False)
|
|
1191
1192
|
try:
|
|
1192
1193
|
retention._maybe_prune_conversation_retention(
|
|
1193
1194
|
conn,
|
|
@@ -4100,10 +4101,6 @@ def _qs_str(q: dict, key: str, default: str | None) -> str | None:
|
|
|
4100
4101
|
_DEBUG_CACHE_TABLES = (
|
|
4101
4102
|
"session_entries",
|
|
4102
4103
|
"session_files",
|
|
4103
|
-
"conversation_messages",
|
|
4104
|
-
"conversation_sessions",
|
|
4105
|
-
"conversation_ai_titles",
|
|
4106
|
-
"conversation_file_touches",
|
|
4107
4104
|
"codex_session_entries",
|
|
4108
4105
|
"codex_session_files",
|
|
4109
4106
|
)
|
|
@@ -6769,11 +6766,6 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
6769
6766
|
and _time.monotonic() - last_heal[0] >= 60.0):
|
|
6770
6767
|
last_heal[0] = _time.monotonic()
|
|
6771
6768
|
_dashboard_self_heal_orphans(skip_sync=self._skip_sync)
|
|
6772
|
-
# #313 P3 (F7): throttled transcript retention prune, inside the
|
|
6773
|
-
# measured iteration so its cost is in the cooldown work. Gated
|
|
6774
|
-
# off under --no-sync (frozen mode makes no cache writes).
|
|
6775
|
-
if not self._skip_sync:
|
|
6776
|
-
_dashboard_maybe_prune_retention()
|
|
6777
6769
|
|
|
6778
6770
|
# Work-proportional cooldown (F10): sleep to t0 + max(interval, work)
|
|
6779
6771
|
# so a slow rebuild cannot peg a full core. The manual POST /api/sync
|
|
@@ -6797,6 +6789,47 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
6797
6789
|
if sync_thread is not None:
|
|
6798
6790
|
sync_thread.start()
|
|
6799
6791
|
|
|
6792
|
+
# #320: transcript/search ingestion runs on its own thread and SQLite file.
|
|
6793
|
+
# A multi-GB first rebuild or a contended conversations.db therefore cannot
|
|
6794
|
+
# delay `_run_sync_now`, its `last_sync_at` stamp, or core SSE publication.
|
|
6795
|
+
conversation_sync_stop = threading.Event()
|
|
6796
|
+
|
|
6797
|
+
def _conversation_sync_loop() -> None:
|
|
6798
|
+
interval = max(5.0, float(args.sync_interval))
|
|
6799
|
+
while not conversation_sync_stop.is_set():
|
|
6800
|
+
conn = None
|
|
6801
|
+
try:
|
|
6802
|
+
conn = open_conversations_db()
|
|
6803
|
+
sync_claude_conversations(conn)
|
|
6804
|
+
sync_codex_conversations(conn)
|
|
6805
|
+
_dashboard_maybe_prune_retention()
|
|
6806
|
+
except (OSError, sqlite3.DatabaseError) as exc:
|
|
6807
|
+
eprint(f"[conversations] background sync unavailable: {exc}")
|
|
6808
|
+
except Exception as exc: # noqa: BLE001
|
|
6809
|
+
# Transcript parsing/normalization is deliberately outside the
|
|
6810
|
+
# core freshness loop. Keep this worker alive so a later clean
|
|
6811
|
+
# tick can self-heal instead of permanently stopping after one
|
|
6812
|
+
# malformed provider record.
|
|
6813
|
+
eprint(
|
|
6814
|
+
"[conversations] background sync failed: "
|
|
6815
|
+
f"{type(exc).__name__}: {exc}"
|
|
6816
|
+
)
|
|
6817
|
+
finally:
|
|
6818
|
+
if conn is not None:
|
|
6819
|
+
conn.close()
|
|
6820
|
+
conversation_sync_stop.wait(interval)
|
|
6821
|
+
|
|
6822
|
+
conversation_sync_thread = (
|
|
6823
|
+
None if args.no_sync
|
|
6824
|
+
else threading.Thread(
|
|
6825
|
+
target=_conversation_sync_loop,
|
|
6826
|
+
daemon=True,
|
|
6827
|
+
name="dashboard-conversations-sync",
|
|
6828
|
+
)
|
|
6829
|
+
)
|
|
6830
|
+
if conversation_sync_thread is not None:
|
|
6831
|
+
conversation_sync_thread.start()
|
|
6832
|
+
|
|
6800
6833
|
# Spec §3.5 (codex review fix #5): update-check thread runs even
|
|
6801
6834
|
# under --no-sync (frozen-data sessions still surface new versions).
|
|
6802
6835
|
# Dedicated stop event so we can join cleanly on shutdown without
|
|
@@ -6879,6 +6912,9 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
6879
6912
|
finally:
|
|
6880
6913
|
if sync_thread is not None:
|
|
6881
6914
|
sync_thread.stop()
|
|
6915
|
+
conversation_sync_stop.set()
|
|
6916
|
+
if conversation_sync_thread is not None:
|
|
6917
|
+
conversation_sync_thread.join(timeout=2)
|
|
6882
6918
|
update_check_stop.set()
|
|
6883
6919
|
srv.shutdown()
|
|
6884
6920
|
http_thread.join(timeout=2)
|
|
@@ -20,9 +20,9 @@ What lives here (spec §6):
|
|
|
20
20
|
(``_require_transcripts_allowed`` / ``_transcript_gate`` / …) STAY on the
|
|
21
21
|
class and are reached via the ``handler`` parameter (spec §6).
|
|
22
22
|
|
|
23
|
-
Late-binding (spec §6): the
|
|
23
|
+
Late-binding (spec §6): the conversation DB opener calls (in
|
|
24
24
|
``_run_conversation_query_impl`` + the events handler) reach
|
|
25
|
-
``sys.modules["_cctally_dashboard"].
|
|
25
|
+
``sys.modules["_cctally_dashboard"].open_conversations_db(...)`` — patched on the
|
|
26
26
|
dashboard module object at ``tests/test_conversation_endpoints.py:674``.
|
|
27
27
|
|
|
28
28
|
Cross-module reaches (spec §2.1 "fully-qualify cross-module refs"): the
|
|
@@ -44,7 +44,13 @@ import socket
|
|
|
44
44
|
import sqlite3
|
|
45
45
|
import sys
|
|
46
46
|
|
|
47
|
-
from _cctally_cache import
|
|
47
|
+
from _cctally_cache import (
|
|
48
|
+
open_cache_db,
|
|
49
|
+
sync_codex_cache,
|
|
50
|
+
sync_claude_conversations,
|
|
51
|
+
sync_codex_conversations,
|
|
52
|
+
_codex_provider_roots,
|
|
53
|
+
)
|
|
48
54
|
|
|
49
55
|
# Live-tail watch-loop tuning — used ONLY by _handle_get_conversation_events_impl
|
|
50
56
|
# below, so moved here with the events handler (spec §4.1 / §6).
|
|
@@ -91,7 +97,7 @@ class _BadConversationFilter(Exception):
|
|
|
91
97
|
|
|
92
98
|
|
|
93
99
|
def _cached_file_sigs(conn, paths):
|
|
94
|
-
"""{path: size_bytes} from
|
|
100
|
+
"""{path: size_bytes} from conversation_source_files for the given paths — the transcript cache's
|
|
95
101
|
own view of how far each file is ingested. Size-only by design, matching the
|
|
96
102
|
watch kernel's size-only signature (`file_sig`) and sync_cache's size-only
|
|
97
103
|
delta signal: mtime is NOT consulted, because a size-unchanged ingest does
|
|
@@ -105,7 +111,7 @@ def _cached_file_sigs(conn, paths):
|
|
|
105
111
|
placeholders = ",".join("?" for _ in paths)
|
|
106
112
|
try:
|
|
107
113
|
rows = conn.execute(
|
|
108
|
-
f"SELECT path, size_bytes FROM
|
|
114
|
+
f"SELECT path, size_bytes FROM conversation_source_files "
|
|
109
115
|
f"WHERE path IN ({placeholders})", list(paths)).fetchall()
|
|
110
116
|
except sqlite3.OperationalError:
|
|
111
117
|
return out
|
|
@@ -115,7 +121,7 @@ def _cached_file_sigs(conn, paths):
|
|
|
115
121
|
|
|
116
122
|
|
|
117
123
|
def _codex_cached_file_sigs(conn, paths):
|
|
118
|
-
"""{path: size_bytes} from ``
|
|
124
|
+
"""{path: size_bytes} from ``codex_conversation_source_files`` for the given paths — the
|
|
119
125
|
Codex analogue of ``_cached_file_sigs`` (spec §5.2). Size-only, matching the
|
|
120
126
|
watch kernel's size-only signature and ``sync_codex_cache``'s size-only
|
|
121
127
|
delta. Paths with no row are absent → treated as changed. Baselines the
|
|
@@ -129,7 +135,7 @@ def _codex_cached_file_sigs(conn, paths):
|
|
|
129
135
|
placeholders = ",".join("?" for _ in paths)
|
|
130
136
|
try:
|
|
131
137
|
rows = conn.execute(
|
|
132
|
-
f"SELECT path, size_bytes FROM
|
|
138
|
+
f"SELECT path, size_bytes FROM codex_conversation_source_files "
|
|
133
139
|
f"WHERE path IN ({placeholders})", list(paths)).fetchall()
|
|
134
140
|
except sqlite3.OperationalError:
|
|
135
141
|
return out
|
|
@@ -144,7 +150,7 @@ def _codex_all_committed_sizes(conn):
|
|
|
144
150
|
baseline. A plain SELECT (no lock), so it never widens the SSE lock scope."""
|
|
145
151
|
try:
|
|
146
152
|
rows = conn.execute(
|
|
147
|
-
"SELECT path, size_bytes FROM
|
|
153
|
+
"SELECT path, size_bytes FROM codex_conversation_source_files").fetchall()
|
|
148
154
|
except sqlite3.OperationalError:
|
|
149
155
|
return {}
|
|
150
156
|
return {p: size for (p, size) in rows}
|
|
@@ -162,7 +168,7 @@ def _codex_classified_paths(conn, paths):
|
|
|
162
168
|
placeholders = ",".join("?" for _ in paths)
|
|
163
169
|
try:
|
|
164
170
|
rows = conn.execute(
|
|
165
|
-
f"SELECT path FROM
|
|
171
|
+
f"SELECT path FROM codex_conversation_source_files "
|
|
166
172
|
f"WHERE path IN ({placeholders}) AND last_conversation_key IS NOT NULL",
|
|
167
173
|
list(paths)).fetchall()
|
|
168
174
|
except sqlite3.OperationalError:
|
|
@@ -444,12 +450,12 @@ def _run_conversation_query_impl(handler, kernel_call, log_label):
|
|
|
444
450
|
has ALREADY been sent and the caller must just ``return``; ``ok=True``
|
|
445
451
|
carries the kernel result (which may itself be ``None`` — the reader's
|
|
446
452
|
404 sentinel — so the explicit flag, not ``body is None``, signals
|
|
447
|
-
failure). An ``
|
|
453
|
+
failure). An ``open_conversations_db`` failure is a ``cache unavailable:`` 500;
|
|
448
454
|
a kernel exception is logged as ``<log_label> failed: %r`` and returned
|
|
449
455
|
as a ``{type}: {msg}`` 500 — byte-identical to the inlined handlers.
|
|
450
456
|
"""
|
|
451
457
|
try:
|
|
452
|
-
conn = sys.modules["_cctally_dashboard"].
|
|
458
|
+
conn = sys.modules["_cctally_dashboard"].open_conversations_db()
|
|
453
459
|
except (sqlite3.DatabaseError, OSError) as exc:
|
|
454
460
|
handler._respond_json(500, {"error": f"cache unavailable: {exc}"})
|
|
455
461
|
return False, None
|
|
@@ -785,7 +791,7 @@ def _bare_conversation_events(handler, session_id: str) -> None:
|
|
|
785
791
|
_send_sse_headers(handler)
|
|
786
792
|
passive = bool(type(handler).no_sync)
|
|
787
793
|
try:
|
|
788
|
-
conn = sys.modules["_cctally_dashboard"].
|
|
794
|
+
conn = sys.modules["_cctally_dashboard"].open_conversations_db()
|
|
789
795
|
except (sqlite3.DatabaseError, OSError):
|
|
790
796
|
# Cache unavailable — degrade to keep-alive only; client backstop
|
|
791
797
|
# tick still surfaces turns. (Headers already sent; can't 500.)
|
|
@@ -796,7 +802,7 @@ def _bare_conversation_events(handler, session_id: str) -> None:
|
|
|
796
802
|
return cq.session_source_paths(conn, session_id) if conn else []
|
|
797
803
|
|
|
798
804
|
def _ingest(changed):
|
|
799
|
-
return
|
|
805
|
+
return sync_claude_conversations(conn, only_paths=set(changed))
|
|
800
806
|
|
|
801
807
|
try:
|
|
802
808
|
_run_conversation_events_stream(
|
|
@@ -840,7 +846,24 @@ def _make_codex_discovery_step(handler, conn, conversation_key, cq_codex):
|
|
|
840
846
|
if not to_ingest:
|
|
841
847
|
return files, False
|
|
842
848
|
try:
|
|
843
|
-
|
|
849
|
+
# A newly discovered child has no compact thread row yet. Advance
|
|
850
|
+
# the independent core cursor first so transcript normalization can
|
|
851
|
+
# link the child through the read-only attached cache, then commit
|
|
852
|
+
# the transcript cursor. Neither lock is held across the other.
|
|
853
|
+
core = open_cache_db()
|
|
854
|
+
try:
|
|
855
|
+
core_stats = sync_codex_cache(
|
|
856
|
+
core, only_paths=set(to_ingest)
|
|
857
|
+
)
|
|
858
|
+
finally:
|
|
859
|
+
core.close()
|
|
860
|
+
if not core_stats.targeted_clean:
|
|
861
|
+
return files, False
|
|
862
|
+
transcript_stats = sync_codex_conversations(
|
|
863
|
+
conn, only_paths=set(to_ingest)
|
|
864
|
+
)
|
|
865
|
+
if not transcript_stats.targeted_clean:
|
|
866
|
+
return files, False
|
|
844
867
|
except sqlite3.DatabaseError:
|
|
845
868
|
return files, False
|
|
846
869
|
# Reap the now-classified candidates (child → already widened; non-child
|
|
@@ -866,7 +889,7 @@ def _qualified_conversation_events(handler, key: str) -> None:
|
|
|
866
889
|
targeted ingest + the directory-frontier child discovery."""
|
|
867
890
|
disp = _conversation_dispatch_impl()
|
|
868
891
|
try:
|
|
869
|
-
conn = sys.modules["_cctally_dashboard"].
|
|
892
|
+
conn = sys.modules["_cctally_dashboard"].open_conversations_db()
|
|
870
893
|
except (sqlite3.DatabaseError, OSError):
|
|
871
894
|
conn = None
|
|
872
895
|
|
|
@@ -921,7 +944,7 @@ def _qualified_conversation_events(handler, key: str) -> None:
|
|
|
921
944
|
return cq_codex.codex_conversation_source_paths(conn, key)
|
|
922
945
|
|
|
923
946
|
def _ingest(changed):
|
|
924
|
-
return
|
|
947
|
+
return sync_codex_conversations(conn, only_paths=set(changed))
|
|
925
948
|
|
|
926
949
|
cached = lambda paths: _codex_cached_file_sigs(conn, paths)
|
|
927
950
|
discovery = _make_codex_discovery_step(handler, conn, key, cq_codex)
|
|
@@ -932,7 +955,7 @@ def _qualified_conversation_events(handler, key: str) -> None:
|
|
|
932
955
|
return cq.session_source_paths(conn, native)
|
|
933
956
|
|
|
934
957
|
def _ingest(changed):
|
|
935
|
-
return
|
|
958
|
+
return sync_claude_conversations(conn, only_paths=set(changed))
|
|
936
959
|
|
|
937
960
|
cached = lambda paths: _cached_file_sigs(conn, paths)
|
|
938
961
|
discovery = None
|
|
@@ -733,22 +733,33 @@ def _codex_conversation_metadata(
|
|
|
733
733
|
|
|
734
734
|
``state_5.sqlite.threads.title`` is Codex's persisted user-facing task name.
|
|
735
735
|
Conversation rollup titles are derived from prompt text and therefore must
|
|
736
|
-
never be substituted for that name on the dashboard.
|
|
737
|
-
|
|
736
|
+
never be substituted for that name on the dashboard. Project attribution
|
|
737
|
+
is derived from the compact thread ``cwd``/``git_json`` retained in
|
|
738
|
+
``cache.db`` so non-conversation panels never open ``conversations.db``.
|
|
738
739
|
"""
|
|
739
740
|
metadata: dict[tuple[str, str], dict[str, object]] = {}
|
|
740
741
|
try:
|
|
741
|
-
|
|
742
|
+
core_rows = tuple(cache_conn.execute(
|
|
742
743
|
"SELECT t.source_root_key, t.source_path, t.native_thread_id, "
|
|
743
744
|
"(SELECT e.session_id FROM codex_session_entries AS e "
|
|
744
745
|
" WHERE e.source_root_key=t.source_root_key AND e.source_path=t.source_path "
|
|
745
746
|
" ORDER BY e.id LIMIT 1) AS accounting_session_id, "
|
|
746
|
-
"
|
|
747
|
+
"t.cwd, t.git_json, t.first_seen_utc, t.last_seen_utc "
|
|
747
748
|
"FROM codex_conversation_threads AS t "
|
|
748
|
-
"
|
|
749
|
-
"ON r.conversation_key=t.conversation_key "
|
|
750
|
-
"ORDER BY r.last_activity_utc DESC, t.conversation_key DESC"
|
|
749
|
+
"ORDER BY t.last_seen_utc DESC, t.conversation_key DESC"
|
|
751
750
|
))
|
|
751
|
+
from _cctally_cache import _codex_conversation_project_attribution
|
|
752
|
+
rows = tuple(
|
|
753
|
+
(
|
|
754
|
+
root_key, source_path, native_thread_id, accounting_session_id,
|
|
755
|
+
*_codex_conversation_project_attribution(root_key, cwd, git_json),
|
|
756
|
+
first_seen_at,
|
|
757
|
+
)
|
|
758
|
+
for (
|
|
759
|
+
root_key, source_path, native_thread_id, accounting_session_id,
|
|
760
|
+
cwd, git_json, first_seen_at, _last_seen_at,
|
|
761
|
+
) in core_rows
|
|
762
|
+
)
|
|
752
763
|
file_aliases = tuple(cache_conn.execute(
|
|
753
764
|
"SELECT source_root_key, path, last_native_thread_id, last_session_id "
|
|
754
765
|
"FROM codex_session_files "
|
package/bin/_cctally_db.py
CHANGED
|
@@ -2648,6 +2648,7 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
|
|
|
2648
2648
|
plan_type TEXT,
|
|
2649
2649
|
individual_limit_json TEXT,
|
|
2650
2650
|
reached_type TEXT,
|
|
2651
|
+
observed_model TEXT,
|
|
2651
2652
|
UNIQUE(source, source_path, line_offset, logical_limit_key),
|
|
2652
2653
|
CHECK(source != 'codex' OR source_root_key IS NOT NULL)
|
|
2653
2654
|
);
|
|
@@ -2893,6 +2894,12 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
|
|
|
2893
2894
|
add_column_if_missing(conn, "conversation_sessions", "git_branch", "TEXT")
|
|
2894
2895
|
add_column_if_missing(conn, "conversation_sessions", "models_json", "TEXT")
|
|
2895
2896
|
add_column_if_missing(conn, "conversation_sessions", "title", "TEXT")
|
|
2897
|
+
# #320: quota pool identity cannot depend on transcript events after the
|
|
2898
|
+
# store split. Stamp the active model directly on each compact physical
|
|
2899
|
+
# quota observation. Existing caches receive the nullable column here; 028
|
|
2900
|
+
# backfills it from the still-present legacy event corpus before dropping
|
|
2901
|
+
# that corpus.
|
|
2902
|
+
add_column_if_missing(conn, "quota_window_snapshots", "observed_model", "TEXT")
|
|
2896
2903
|
conn.execute(
|
|
2897
2904
|
"CREATE INDEX IF NOT EXISTS idx_session_files_session_id "
|
|
2898
2905
|
"ON session_files(session_id)"
|
|
@@ -3014,6 +3021,72 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
|
|
|
3014
3021
|
conn.commit()
|
|
3015
3022
|
|
|
3016
3023
|
|
|
3024
|
+
def _apply_conversations_schema(conn: sqlite3.Connection) -> None:
|
|
3025
|
+
"""Create the #320 transcript/search database schema.
|
|
3026
|
+
|
|
3027
|
+
The historical cache schema remains the migration-fixture source of truth.
|
|
3028
|
+
Reuse it here, then remove the compact accounting families so conversation
|
|
3029
|
+
queries can resolve those names through the read-only ``cache_db``
|
|
3030
|
+
attachment. Empty compatibility tables may still exist in cache.db for old
|
|
3031
|
+
migration handlers, but all live transcript rows belong here.
|
|
3032
|
+
"""
|
|
3033
|
+
# The first open projects the historical monolithic schema into the new
|
|
3034
|
+
# store. Avoid repeating that create-then-drop work on every conversation
|
|
3035
|
+
# endpoint: it writes sqlite_schema and WAL pages even when no transcript
|
|
3036
|
+
# data changed. Future conversation schema revisions must bump this marker.
|
|
3037
|
+
try:
|
|
3038
|
+
current = conn.execute(
|
|
3039
|
+
"SELECT value FROM cache_meta "
|
|
3040
|
+
"WHERE key='conversation_schema_version'"
|
|
3041
|
+
).fetchone()
|
|
3042
|
+
except sqlite3.OperationalError:
|
|
3043
|
+
current = None
|
|
3044
|
+
if current is not None and current[0] == "1":
|
|
3045
|
+
return
|
|
3046
|
+
|
|
3047
|
+
_apply_cache_schema(conn)
|
|
3048
|
+
conn.executescript(
|
|
3049
|
+
"""
|
|
3050
|
+
DROP TABLE IF EXISTS session_entries;
|
|
3051
|
+
DROP TABLE IF EXISTS session_files;
|
|
3052
|
+
DROP TABLE IF EXISTS codex_session_entries;
|
|
3053
|
+
DROP TABLE IF EXISTS codex_session_files;
|
|
3054
|
+
DROP TABLE IF EXISTS quota_window_snapshots;
|
|
3055
|
+
DROP TABLE IF EXISTS codex_conversation_threads;
|
|
3056
|
+
DROP TABLE IF EXISTS codex_source_roots;
|
|
3057
|
+
|
|
3058
|
+
CREATE TABLE IF NOT EXISTS conversation_source_files (
|
|
3059
|
+
path TEXT PRIMARY KEY,
|
|
3060
|
+
size_bytes INTEGER NOT NULL,
|
|
3061
|
+
mtime_ns INTEGER NOT NULL,
|
|
3062
|
+
last_byte_offset INTEGER NOT NULL,
|
|
3063
|
+
last_ingested_at TEXT NOT NULL
|
|
3064
|
+
);
|
|
3065
|
+
CREATE TABLE IF NOT EXISTS codex_conversation_source_files (
|
|
3066
|
+
path TEXT PRIMARY KEY,
|
|
3067
|
+
size_bytes INTEGER NOT NULL,
|
|
3068
|
+
mtime_ns INTEGER NOT NULL,
|
|
3069
|
+
last_byte_offset INTEGER NOT NULL,
|
|
3070
|
+
last_ingested_at TEXT NOT NULL,
|
|
3071
|
+
source_root_key TEXT,
|
|
3072
|
+
last_session_id TEXT,
|
|
3073
|
+
last_model TEXT,
|
|
3074
|
+
last_total_tokens INTEGER,
|
|
3075
|
+
last_native_thread_id TEXT,
|
|
3076
|
+
last_root_thread_id TEXT,
|
|
3077
|
+
last_parent_thread_id TEXT,
|
|
3078
|
+
last_conversation_key TEXT,
|
|
3079
|
+
last_turn_id TEXT
|
|
3080
|
+
);
|
|
3081
|
+
"""
|
|
3082
|
+
)
|
|
3083
|
+
conn.execute(
|
|
3084
|
+
"INSERT INTO cache_meta(key,value) VALUES "
|
|
3085
|
+
"('conversation_schema_version','1') "
|
|
3086
|
+
"ON CONFLICT(key) DO UPDATE SET value=excluded.value"
|
|
3087
|
+
)
|
|
3088
|
+
|
|
3089
|
+
|
|
3017
3090
|
def _fts5_available(conn: sqlite3.Connection) -> bool:
|
|
3018
3091
|
"""True if this sqlite build can create an FTS5 table. Cheap probe on a
|
|
3019
3092
|
temp table that is created then dropped. Hidden test seam: tests monkeypatch
|
|
@@ -4730,6 +4803,118 @@ def _027_codex_fork_preamble_rebuild(conn: sqlite3.Connection) -> None:
|
|
|
4730
4803
|
lock_fh.close()
|
|
4731
4804
|
|
|
4732
4805
|
|
|
4806
|
+
@cache_migration("028_split_conversation_store")
|
|
4807
|
+
def _028_split_conversation_store(conn: sqlite3.Connection) -> None:
|
|
4808
|
+
"""Move the re-derivable transcript corpus out of cache.db (#320).
|
|
4809
|
+
|
|
4810
|
+
The upgrade deliberately does not copy gigabytes of prose/events. It creates
|
|
4811
|
+
the independent current-schema store, arms provider-local byte-zero replay,
|
|
4812
|
+
then drops the legacy transcript tables from the hot cache. Compact Codex
|
|
4813
|
+
thread metadata stays in cache.db because accounting/project attribution
|
|
4814
|
+
joins it directly.
|
|
4815
|
+
"""
|
|
4816
|
+
core = _cctally_core
|
|
4817
|
+
lock_paths = (
|
|
4818
|
+
core.CACHE_LOCK_MAINTENANCE_PATH,
|
|
4819
|
+
core.CACHE_LOCK_PATH,
|
|
4820
|
+
core.CACHE_LOCK_CODEX_PATH,
|
|
4821
|
+
core.CONVERSATIONS_LOCK_MAINTENANCE_PATH,
|
|
4822
|
+
core.CONVERSATIONS_LOCK_PATH,
|
|
4823
|
+
core.CONVERSATIONS_LOCK_CODEX_PATH,
|
|
4824
|
+
)
|
|
4825
|
+
held = []
|
|
4826
|
+
try:
|
|
4827
|
+
core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
4828
|
+
for path in lock_paths:
|
|
4829
|
+
fh = open(path, "w")
|
|
4830
|
+
try:
|
|
4831
|
+
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
4832
|
+
except BlockingIOError:
|
|
4833
|
+
fh.close()
|
|
4834
|
+
raise MigrationGateNotMet(
|
|
4835
|
+
"cache/conversation sync lock held; deferring cache 028 split"
|
|
4836
|
+
)
|
|
4837
|
+
held.append(fh)
|
|
4838
|
+
|
|
4839
|
+
conv = sqlite3.connect(core.CONVERSATIONS_DB_PATH)
|
|
4840
|
+
try:
|
|
4841
|
+
conv.execute("PRAGMA auto_vacuum=INCREMENTAL")
|
|
4842
|
+
conv.execute("PRAGMA journal_mode=WAL")
|
|
4843
|
+
_apply_conversations_schema(conv)
|
|
4844
|
+
_set_cache_meta(conv, "conversation_rebuild_claude_pending", "1")
|
|
4845
|
+
_set_cache_meta(conv, "conversation_rebuild_codex_pending", "1")
|
|
4846
|
+
conv.commit()
|
|
4847
|
+
finally:
|
|
4848
|
+
conv.close()
|
|
4849
|
+
|
|
4850
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
4851
|
+
try:
|
|
4852
|
+
# Preserve exact model-scoped quota-pool identity before discarding
|
|
4853
|
+
# the legacy physical event corpus. The correlated lookup is the
|
|
4854
|
+
# same nearest-prior context rule the quota reader used pre-split.
|
|
4855
|
+
if conn.execute(
|
|
4856
|
+
"SELECT 1 FROM sqlite_master "
|
|
4857
|
+
"WHERE type='table' AND name='codex_conversation_events'"
|
|
4858
|
+
).fetchone() is not None:
|
|
4859
|
+
conn.execute(
|
|
4860
|
+
"UPDATE quota_window_snapshots AS q SET observed_model=("
|
|
4861
|
+
" SELECT json_extract(e.payload_json, '$.payload.model')"
|
|
4862
|
+
" FROM codex_conversation_events AS e"
|
|
4863
|
+
" WHERE e.source_path=q.source_path"
|
|
4864
|
+
" AND e.line_offset<=q.line_offset"
|
|
4865
|
+
" AND e.record_type IN ('turn_context','session_meta')"
|
|
4866
|
+
" AND json_valid(e.payload_json)"
|
|
4867
|
+
" AND json_type(e.payload_json, '$.payload.model')='text'"
|
|
4868
|
+
" ORDER BY e.line_offset DESC LIMIT 1)"
|
|
4869
|
+
" WHERE q.source='codex' AND q.observed_model IS NULL"
|
|
4870
|
+
)
|
|
4871
|
+
for drop in (
|
|
4872
|
+
"DROP TRIGGER IF EXISTS conv_fts_ai",
|
|
4873
|
+
"DROP TRIGGER IF EXISTS conv_fts_ad",
|
|
4874
|
+
"DROP TRIGGER IF EXISTS conv_fts_au",
|
|
4875
|
+
"DROP TRIGGER IF EXISTS conv_fts_aux_ai",
|
|
4876
|
+
"DROP TRIGGER IF EXISTS conv_fts_aux_ad",
|
|
4877
|
+
"DROP TRIGGER IF EXISTS conv_fts_aux_au",
|
|
4878
|
+
"DROP TRIGGER IF EXISTS conv_title_fts_ai",
|
|
4879
|
+
"DROP TRIGGER IF EXISTS conv_title_fts_ad",
|
|
4880
|
+
"DROP TRIGGER IF EXISTS conv_title_fts_au",
|
|
4881
|
+
"DROP TRIGGER IF EXISTS codex_conv_fts_ai",
|
|
4882
|
+
"DROP TRIGGER IF EXISTS codex_conv_fts_ad",
|
|
4883
|
+
"DROP TRIGGER IF EXISTS codex_conv_fts_au",
|
|
4884
|
+
"DROP TABLE IF EXISTS conversation_fts_aux",
|
|
4885
|
+
"DROP TABLE IF EXISTS conversation_fts",
|
|
4886
|
+
"DROP TABLE IF EXISTS conversation_title_fts",
|
|
4887
|
+
"DROP TABLE IF EXISTS codex_conversation_fts",
|
|
4888
|
+
"DROP TABLE IF EXISTS conversation_file_touches",
|
|
4889
|
+
"DROP TABLE IF EXISTS conversation_sessions",
|
|
4890
|
+
"DROP TABLE IF EXISTS conversation_ai_titles",
|
|
4891
|
+
"DROP TABLE IF EXISTS conversation_messages",
|
|
4892
|
+
"DROP TABLE IF EXISTS codex_conversation_file_touches",
|
|
4893
|
+
"DROP TABLE IF EXISTS codex_conversation_rollups",
|
|
4894
|
+
"DROP TABLE IF EXISTS codex_conversation_messages",
|
|
4895
|
+
"DROP TABLE IF EXISTS codex_conversation_events",
|
|
4896
|
+
):
|
|
4897
|
+
conn.execute(drop)
|
|
4898
|
+
conn.commit()
|
|
4899
|
+
except Exception:
|
|
4900
|
+
conn.rollback()
|
|
4901
|
+
raise
|
|
4902
|
+
try:
|
|
4903
|
+
if conn.execute("PRAGMA auto_vacuum").fetchone()[0] == 2:
|
|
4904
|
+
# Python 3.11/Linux may defer this zero-column PRAGMA until the
|
|
4905
|
+
# cursor is exhausted; fetchall drives it to completion.
|
|
4906
|
+
conn.execute("PRAGMA incremental_vacuum").fetchall()
|
|
4907
|
+
except sqlite3.DatabaseError:
|
|
4908
|
+
pass
|
|
4909
|
+
finally:
|
|
4910
|
+
for fh in reversed(held):
|
|
4911
|
+
try:
|
|
4912
|
+
fcntl.flock(fh, fcntl.LOCK_UN)
|
|
4913
|
+
except OSError:
|
|
4914
|
+
pass
|
|
4915
|
+
fh.close()
|
|
4916
|
+
|
|
4917
|
+
|
|
4733
4918
|
# === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
|
|
4734
4919
|
|
|
4735
4920
|
@stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
|
|
@@ -7006,7 +7191,12 @@ def _vacuum_one_db(path, label: str, provider_locked: bool) -> int:
|
|
|
7006
7191
|
# Serialize against the retention prune and concurrent vacuums via the
|
|
7007
7192
|
# dedicated maintenance flock (F13/F7), then the provider flocks for
|
|
7008
7193
|
# cache.db. All non-blocking: fail promptly rather than hang.
|
|
7009
|
-
|
|
7194
|
+
conversation_store = path == core.CONVERSATIONS_DB_PATH
|
|
7195
|
+
maintenance_path = (
|
|
7196
|
+
core.CONVERSATIONS_LOCK_MAINTENANCE_PATH
|
|
7197
|
+
if conversation_store else core.CACHE_LOCK_MAINTENANCE_PATH
|
|
7198
|
+
)
|
|
7199
|
+
maint_fh = open(maintenance_path, "w")
|
|
7010
7200
|
try:
|
|
7011
7201
|
try:
|
|
7012
7202
|
fcntl.flock(maint_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
@@ -7019,10 +7209,18 @@ def _vacuum_one_db(path, label: str, provider_locked: bool) -> int:
|
|
|
7019
7209
|
held = []
|
|
7020
7210
|
try:
|
|
7021
7211
|
if provider_locked:
|
|
7022
|
-
|
|
7023
|
-
(
|
|
7024
|
-
|
|
7025
|
-
|
|
7212
|
+
provider_locks = (
|
|
7213
|
+
(
|
|
7214
|
+
(core.CONVERSATIONS_LOCK_PATH, "claude conversations"),
|
|
7215
|
+
(core.CONVERSATIONS_LOCK_CODEX_PATH, "codex conversations"),
|
|
7216
|
+
)
|
|
7217
|
+
if conversation_store else
|
|
7218
|
+
(
|
|
7219
|
+
(core.CACHE_LOCK_PATH, "claude"),
|
|
7220
|
+
(core.CACHE_LOCK_CODEX_PATH, "codex"),
|
|
7221
|
+
)
|
|
7222
|
+
)
|
|
7223
|
+
for lock_path, lname in provider_locks:
|
|
7026
7224
|
fh = open(lock_path, "w")
|
|
7027
7225
|
try:
|
|
7028
7226
|
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
@@ -7063,6 +7261,10 @@ def cmd_db_vacuum(args: argparse.Namespace) -> int:
|
|
|
7063
7261
|
targets = []
|
|
7064
7262
|
if which in ("cache", "all"):
|
|
7065
7263
|
targets.append((_cctally_core.CACHE_DB_PATH, "cache.db", True))
|
|
7264
|
+
if which in ("conversations", "all"):
|
|
7265
|
+
targets.append((
|
|
7266
|
+
_cctally_core.CONVERSATIONS_DB_PATH, "conversations.db", True,
|
|
7267
|
+
))
|
|
7066
7268
|
if which in ("stats", "all"):
|
|
7067
7269
|
targets.append((_cctally_core.DB_PATH, "stats.db", False))
|
|
7068
7270
|
overall = 0
|