cctally 1.76.0 → 1.78.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 +26 -0
- package/bin/_cctally_cache.py +918 -143
- package/bin/_cctally_core.py +19 -5
- package/bin/_cctally_dashboard.py +226 -51
- package/bin/_cctally_dashboard_conversation.py +43 -19
- package/bin/_cctally_dashboard_sources.py +28 -9
- package/bin/_cctally_db.py +207 -5
- package/bin/_cctally_doctor.py +56 -10
- package/bin/_cctally_parser.py +1 -1
- package/bin/_cctally_quota.py +23 -24
- package/bin/_cctally_statusline.py +128 -3
- 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_dashboard_json.py +43 -0
- package/bin/_lib_doctor.py +53 -4
- package/bin/_lib_source_analytics.py +20 -6
- package/bin/cctally +8 -0
- package/dashboard/static/assets/index-CkTe6VJt.js +87 -0
- package/dashboard/static/assets/index-DsEXuMpq.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-CsDAxBT5.js +0 -80
- package/dashboard/static/assets/index-yftBNnLR.css +0 -1
|
@@ -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
|
|
@@ -38,13 +38,19 @@ edits — the forwarders hit ``sys.modules["_cctally_dashboard"]`` at call time
|
|
|
38
38
|
"""
|
|
39
39
|
from __future__ import annotations
|
|
40
40
|
|
|
41
|
-
import json
|
|
42
41
|
import re
|
|
43
42
|
import socket
|
|
44
43
|
import sqlite3
|
|
45
44
|
import sys
|
|
46
45
|
|
|
47
|
-
from _cctally_cache import
|
|
46
|
+
from _cctally_cache import (
|
|
47
|
+
open_cache_db,
|
|
48
|
+
sync_codex_cache,
|
|
49
|
+
sync_claude_conversations,
|
|
50
|
+
sync_codex_conversations,
|
|
51
|
+
_codex_provider_roots,
|
|
52
|
+
)
|
|
53
|
+
from _lib_dashboard_json import encode_dashboard_json
|
|
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
|
|
@@ -703,7 +709,8 @@ def _run_conversation_events_stream(
|
|
|
703
709
|
import time as _time
|
|
704
710
|
watch = sys.modules["cctally"]._load_sibling("_lib_conversation_watch")
|
|
705
711
|
tail_frame = (
|
|
706
|
-
"event: tail\ndata: " +
|
|
712
|
+
"event: tail\ndata: " + encode_dashboard_json(tail_data) + "\n\n"
|
|
713
|
+
).encode("utf-8")
|
|
707
714
|
|
|
708
715
|
if passive:
|
|
709
716
|
# Frozen-data contract: no ingest, no emit. Keep-alive only.
|
|
@@ -785,7 +792,7 @@ def _bare_conversation_events(handler, session_id: str) -> None:
|
|
|
785
792
|
_send_sse_headers(handler)
|
|
786
793
|
passive = bool(type(handler).no_sync)
|
|
787
794
|
try:
|
|
788
|
-
conn = sys.modules["_cctally_dashboard"].
|
|
795
|
+
conn = sys.modules["_cctally_dashboard"].open_conversations_db()
|
|
789
796
|
except (sqlite3.DatabaseError, OSError):
|
|
790
797
|
# Cache unavailable — degrade to keep-alive only; client backstop
|
|
791
798
|
# tick still surfaces turns. (Headers already sent; can't 500.)
|
|
@@ -796,7 +803,7 @@ def _bare_conversation_events(handler, session_id: str) -> None:
|
|
|
796
803
|
return cq.session_source_paths(conn, session_id) if conn else []
|
|
797
804
|
|
|
798
805
|
def _ingest(changed):
|
|
799
|
-
return
|
|
806
|
+
return sync_claude_conversations(conn, only_paths=set(changed))
|
|
800
807
|
|
|
801
808
|
try:
|
|
802
809
|
_run_conversation_events_stream(
|
|
@@ -840,7 +847,24 @@ def _make_codex_discovery_step(handler, conn, conversation_key, cq_codex):
|
|
|
840
847
|
if not to_ingest:
|
|
841
848
|
return files, False
|
|
842
849
|
try:
|
|
843
|
-
|
|
850
|
+
# A newly discovered child has no compact thread row yet. Advance
|
|
851
|
+
# the independent core cursor first so transcript normalization can
|
|
852
|
+
# link the child through the read-only attached cache, then commit
|
|
853
|
+
# the transcript cursor. Neither lock is held across the other.
|
|
854
|
+
core = open_cache_db()
|
|
855
|
+
try:
|
|
856
|
+
core_stats = sync_codex_cache(
|
|
857
|
+
core, only_paths=set(to_ingest)
|
|
858
|
+
)
|
|
859
|
+
finally:
|
|
860
|
+
core.close()
|
|
861
|
+
if not core_stats.targeted_clean:
|
|
862
|
+
return files, False
|
|
863
|
+
transcript_stats = sync_codex_conversations(
|
|
864
|
+
conn, only_paths=set(to_ingest)
|
|
865
|
+
)
|
|
866
|
+
if not transcript_stats.targeted_clean:
|
|
867
|
+
return files, False
|
|
844
868
|
except sqlite3.DatabaseError:
|
|
845
869
|
return files, False
|
|
846
870
|
# Reap the now-classified candidates (child → already widened; non-child
|
|
@@ -866,7 +890,7 @@ def _qualified_conversation_events(handler, key: str) -> None:
|
|
|
866
890
|
targeted ingest + the directory-frontier child discovery."""
|
|
867
891
|
disp = _conversation_dispatch_impl()
|
|
868
892
|
try:
|
|
869
|
-
conn = sys.modules["_cctally_dashboard"].
|
|
893
|
+
conn = sys.modules["_cctally_dashboard"].open_conversations_db()
|
|
870
894
|
except (sqlite3.DatabaseError, OSError):
|
|
871
895
|
conn = None
|
|
872
896
|
|
|
@@ -921,7 +945,7 @@ def _qualified_conversation_events(handler, key: str) -> None:
|
|
|
921
945
|
return cq_codex.codex_conversation_source_paths(conn, key)
|
|
922
946
|
|
|
923
947
|
def _ingest(changed):
|
|
924
|
-
return
|
|
948
|
+
return sync_codex_conversations(conn, only_paths=set(changed))
|
|
925
949
|
|
|
926
950
|
cached = lambda paths: _codex_cached_file_sigs(conn, paths)
|
|
927
951
|
discovery = _make_codex_discovery_step(handler, conn, key, cq_codex)
|
|
@@ -932,7 +956,7 @@ def _qualified_conversation_events(handler, key: str) -> None:
|
|
|
932
956
|
return cq.session_source_paths(conn, native)
|
|
933
957
|
|
|
934
958
|
def _ingest(changed):
|
|
935
|
-
return
|
|
959
|
+
return sync_claude_conversations(conn, only_paths=set(changed))
|
|
936
960
|
|
|
937
961
|
cached = lambda paths: _cached_file_sigs(conn, paths)
|
|
938
962
|
discovery = None
|
|
@@ -49,7 +49,10 @@ from _lib_jsonl import CodexEntry, codex_model_scoped_quota_pool
|
|
|
49
49
|
from _lib_fmt import stable_sum
|
|
50
50
|
from _lib_aggregators import _aggregate_codex_buckets
|
|
51
51
|
from _lib_five_hour import _FIVE_HOUR_JITTER_FLOOR_SECONDS
|
|
52
|
-
from _lib_source_analytics import
|
|
52
|
+
from _lib_source_analytics import (
|
|
53
|
+
build_codex_project_result,
|
|
54
|
+
collision_safe_project_label_map,
|
|
55
|
+
)
|
|
53
56
|
from _lib_view_models import (
|
|
54
57
|
CodexWeeklyView,
|
|
55
58
|
build_codex_daily_view,
|
|
@@ -733,22 +736,33 @@ def _codex_conversation_metadata(
|
|
|
733
736
|
|
|
734
737
|
``state_5.sqlite.threads.title`` is Codex's persisted user-facing task name.
|
|
735
738
|
Conversation rollup titles are derived from prompt text and therefore must
|
|
736
|
-
never be substituted for that name on the dashboard.
|
|
737
|
-
|
|
739
|
+
never be substituted for that name on the dashboard. Project attribution
|
|
740
|
+
is derived from the compact thread ``cwd``/``git_json`` retained in
|
|
741
|
+
``cache.db`` so non-conversation panels never open ``conversations.db``.
|
|
738
742
|
"""
|
|
739
743
|
metadata: dict[tuple[str, str], dict[str, object]] = {}
|
|
740
744
|
try:
|
|
741
|
-
|
|
745
|
+
core_rows = tuple(cache_conn.execute(
|
|
742
746
|
"SELECT t.source_root_key, t.source_path, t.native_thread_id, "
|
|
743
747
|
"(SELECT e.session_id FROM codex_session_entries AS e "
|
|
744
748
|
" WHERE e.source_root_key=t.source_root_key AND e.source_path=t.source_path "
|
|
745
749
|
" ORDER BY e.id LIMIT 1) AS accounting_session_id, "
|
|
746
|
-
"
|
|
750
|
+
"t.cwd, t.git_json, t.first_seen_utc, t.last_seen_utc "
|
|
747
751
|
"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"
|
|
752
|
+
"ORDER BY t.last_seen_utc DESC, t.conversation_key DESC"
|
|
751
753
|
))
|
|
754
|
+
from _cctally_cache import _codex_conversation_project_attribution
|
|
755
|
+
rows = tuple(
|
|
756
|
+
(
|
|
757
|
+
root_key, source_path, native_thread_id, accounting_session_id,
|
|
758
|
+
*_codex_conversation_project_attribution(root_key, cwd, git_json),
|
|
759
|
+
first_seen_at,
|
|
760
|
+
)
|
|
761
|
+
for (
|
|
762
|
+
root_key, source_path, native_thread_id, accounting_session_id,
|
|
763
|
+
cwd, git_json, first_seen_at, _last_seen_at,
|
|
764
|
+
) in core_rows
|
|
765
|
+
)
|
|
752
766
|
file_aliases = tuple(cache_conn.execute(
|
|
753
767
|
"SELECT source_root_key, path, last_native_thread_id, last_session_id "
|
|
754
768
|
"FROM codex_session_files "
|
|
@@ -1772,12 +1786,17 @@ def _partial_projects_wire(
|
|
|
1772
1786
|
model_totals[field] += value
|
|
1773
1787
|
session_totals[field] += value
|
|
1774
1788
|
|
|
1789
|
+
label_map = collision_safe_project_label_map(
|
|
1790
|
+
(f"{root_key}\0{project_key}", str(group["label"]))
|
|
1791
|
+
for (root_key, project_key), group in groups.items()
|
|
1792
|
+
)
|
|
1775
1793
|
rows = []
|
|
1776
1794
|
for (root_key, _project_key), group in groups.items():
|
|
1795
|
+
internal_identity = f"{root_key}\0{group['project_key']}"
|
|
1777
1796
|
rows.append({
|
|
1778
1797
|
"key": dashboard_resource_key("project", "codex", root_key, group["project_key"]),
|
|
1779
1798
|
"source": "codex",
|
|
1780
|
-
"label":
|
|
1799
|
+
"label": label_map[internal_identity],
|
|
1781
1800
|
"session_count": len(group["sessions"]),
|
|
1782
1801
|
"first_seen": group["first_seen"].astimezone(UTC).isoformat(),
|
|
1783
1802
|
"last_seen": group["last_seen"].astimezone(UTC).isoformat(),
|
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
|
package/bin/_cctally_doctor.py
CHANGED
|
@@ -27,6 +27,7 @@ import argparse
|
|
|
27
27
|
import datetime as dt
|
|
28
28
|
import fcntl
|
|
29
29
|
import json
|
|
30
|
+
import math
|
|
30
31
|
import pathlib
|
|
31
32
|
import shutil
|
|
32
33
|
import sqlite3
|
|
@@ -35,6 +36,7 @@ import sys
|
|
|
35
36
|
import _cctally_core
|
|
36
37
|
import _lib_changelog
|
|
37
38
|
from _cctally_core import _now_utc, eprint, now_utc_iso, parse_iso_datetime
|
|
39
|
+
from _lib_dashboard_json import encode_dashboard_json
|
|
38
40
|
|
|
39
41
|
|
|
40
42
|
def _cctally():
|
|
@@ -53,11 +55,25 @@ def _gather_statusline_pipeline(c, *, now_utc: dt.datetime) -> dict:
|
|
|
53
55
|
"tombstones": {"fiveHour": "absent", "sevenDay": "absent"},
|
|
54
56
|
}
|
|
55
57
|
try:
|
|
56
|
-
|
|
58
|
+
age = c._statusline_transport_age_seconds()
|
|
59
|
+
result["transport_age_seconds"] = (
|
|
60
|
+
age
|
|
61
|
+
if isinstance(age, (int, float))
|
|
62
|
+
and not isinstance(age, bool)
|
|
63
|
+
and math.isfinite(float(age))
|
|
64
|
+
else None
|
|
65
|
+
)
|
|
57
66
|
except Exception:
|
|
58
67
|
pass
|
|
59
68
|
try:
|
|
60
|
-
|
|
69
|
+
age = c._statusline_observe_age_seconds()
|
|
70
|
+
result["selected_age_seconds"] = (
|
|
71
|
+
age
|
|
72
|
+
if isinstance(age, (int, float))
|
|
73
|
+
and not isinstance(age, bool)
|
|
74
|
+
and math.isfinite(float(age))
|
|
75
|
+
else None
|
|
76
|
+
)
|
|
61
77
|
except Exception:
|
|
62
78
|
pass
|
|
63
79
|
try:
|
|
@@ -429,16 +445,35 @@ def doctor_gather_state(
|
|
|
429
445
|
# Conversation-sessions rollup consistency (#217 S1 / U9). Two cheap COUNTs
|
|
430
446
|
# (graceful None on a missing table / unreadable DB) + an in-progress signal
|
|
431
447
|
# so a transient mid-sync mismatch never WARNs. The in-progress signal is a
|
|
432
|
-
# NON-BLOCKING
|
|
448
|
+
# NON-BLOCKING conversations flock probe (a writer mid-walk holds it) OR the
|
|
433
449
|
# presence of any pending reingest/split/backfill cache_meta flag — doctor
|
|
434
450
|
# stays read-only and never blocks on the lock.
|
|
435
451
|
conv_sessions_rollup_count = None
|
|
436
452
|
conv_messages_distinct_sessions = None
|
|
437
453
|
conv_rollup_sync_in_progress = False
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
454
|
+
conversations_db_page_count = None
|
|
455
|
+
conversations_db_freelist_count = None
|
|
456
|
+
try:
|
|
457
|
+
if _cctally_core.CONVERSATIONS_DB_PATH.exists():
|
|
458
|
+
# This gather also runs inside dashboard snapshot precompute. A
|
|
459
|
+
# transcript writer may hold an exclusive SQLite lock, so use a
|
|
460
|
+
# read-only zero-timeout probe: conversation health can degrade,
|
|
461
|
+
# but it must never delay core snapshot freshness (#320).
|
|
462
|
+
conv_uri = (
|
|
463
|
+
_cctally_core.CONVERSATIONS_DB_PATH.resolve().as_uri()
|
|
464
|
+
+ "?mode=ro"
|
|
465
|
+
)
|
|
466
|
+
conn = sqlite3.connect(conv_uri, uri=True, timeout=0.0)
|
|
441
467
|
try:
|
|
468
|
+
try:
|
|
469
|
+
row = conn.execute("PRAGMA page_count").fetchone()
|
|
470
|
+
if row and row[0] is not None:
|
|
471
|
+
conversations_db_page_count = int(row[0])
|
|
472
|
+
row = conn.execute("PRAGMA freelist_count").fetchone()
|
|
473
|
+
if row and row[0] is not None:
|
|
474
|
+
conversations_db_freelist_count = int(row[0])
|
|
475
|
+
except sqlite3.Error:
|
|
476
|
+
pass
|
|
442
477
|
try:
|
|
443
478
|
row = conn.execute(
|
|
444
479
|
"SELECT COUNT(*) FROM conversation_sessions"
|
|
@@ -472,12 +507,12 @@ def doctor_gather_state(
|
|
|
472
507
|
pass
|
|
473
508
|
finally:
|
|
474
509
|
conn.close()
|
|
475
|
-
# Non-blocking flock probe: if a writer
|
|
476
|
-
#
|
|
510
|
+
# Non-blocking flock probe: if a transcript writer/reingest holds the
|
|
511
|
+
# conversations.db lock, the rollup may be mid-recompute → in progress. We
|
|
477
512
|
# acquire LOCK_EX|LOCK_NB and immediately release; failure (held) is the
|
|
478
513
|
# signal. Never blocks (LOCK_NB), so doctor stays read-only + prompt.
|
|
479
514
|
if not conv_rollup_sync_in_progress:
|
|
480
|
-
lock_path = _cctally_core.
|
|
515
|
+
lock_path = _cctally_core.CONVERSATIONS_LOCK_PATH
|
|
481
516
|
if lock_path is not None and pathlib.Path(lock_path).exists():
|
|
482
517
|
import fcntl as _fcntl
|
|
483
518
|
lock_fh = open(str(lock_path), "w")
|
|
@@ -669,6 +704,15 @@ def doctor_gather_state(
|
|
|
669
704
|
for _name, _lp in (
|
|
670
705
|
("cache.db.lock", _cctally_core.CACHE_LOCK_PATH),
|
|
671
706
|
("cache.db.codex.lock", _cctally_core.CACHE_LOCK_CODEX_PATH),
|
|
707
|
+
("conversations.db.lock", _cctally_core.CONVERSATIONS_LOCK_PATH),
|
|
708
|
+
(
|
|
709
|
+
"conversations.db.codex.lock",
|
|
710
|
+
_cctally_core.CONVERSATIONS_LOCK_CODEX_PATH,
|
|
711
|
+
),
|
|
712
|
+
(
|
|
713
|
+
"conversations.db.maintenance.lock",
|
|
714
|
+
_cctally_core.CONVERSATIONS_LOCK_MAINTENANCE_PATH,
|
|
715
|
+
),
|
|
672
716
|
):
|
|
673
717
|
if not _lp.exists():
|
|
674
718
|
locks_held[_name] = False
|
|
@@ -877,6 +921,8 @@ def doctor_gather_state(
|
|
|
877
921
|
# #315: read-only cache free-page evidence for the reclaim hint.
|
|
878
922
|
cache_db_page_count=cache_db_page_count,
|
|
879
923
|
cache_db_freelist_count=cache_db_freelist_count,
|
|
924
|
+
conversations_db_page_count=conversations_db_page_count,
|
|
925
|
+
conversations_db_freelist_count=conversations_db_freelist_count,
|
|
880
926
|
codex_quota_windows=codex_quota_windows,
|
|
881
927
|
codex_hook_roots=codex_hook_roots,
|
|
882
928
|
codex_lifecycle_activity_24h=codex_lifecycle_activity_24h,
|
|
@@ -913,7 +959,7 @@ def cmd_doctor(args: argparse.Namespace) -> int:
|
|
|
913
959
|
state = c.doctor_gather_state(deep=True)
|
|
914
960
|
report = _lib_doctor.run_checks(state)
|
|
915
961
|
if getattr(args, "json", False):
|
|
916
|
-
print(
|
|
962
|
+
print(encode_dashboard_json(
|
|
917
963
|
_lib_doctor.serialize_json(report), indent=2, sort_keys=True,
|
|
918
964
|
))
|
|
919
965
|
else:
|
package/bin/_cctally_parser.py
CHANGED
|
@@ -2924,7 +2924,7 @@ def _build_db_parser(subparsers, name, *, help_text, xref=None):
|
|
|
2924
2924
|
)
|
|
2925
2925
|
db_vacuum.add_argument(
|
|
2926
2926
|
"--db",
|
|
2927
|
-
choices=("cache", "stats", "all"),
|
|
2927
|
+
choices=("cache", "conversations", "stats", "all"),
|
|
2928
2928
|
default="cache",
|
|
2929
2929
|
help="Which DB to VACUUM (default: cache)",
|
|
2930
2930
|
)
|