cctally 1.37.1 → 1.37.2
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 +6 -0
- package/bin/_cctally_cache.py +101 -10
- package/bin/_cctally_dashboard.py +22 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,12 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.37.2] - 2026-06-12
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- Dashboard: the web dashboard no longer hangs on startup when you have a large conversation history. It now binds its HTTP port and serves the six cost/usage panels immediately, building the first heavy cache sync on a background thread and pushing the enriched conversation data over SSE once it completes — instead of blocking the bind behind a multi-minute (and, if interrupted, indefinite) first sync. `--no-sync` startup is byte-identical (#179).
|
|
12
|
+
- Dashboard conversation reader: the one-time conversation-enrichment reingest (the #177 re-derivation that runs once over existing transcripts) is now resumable. It walks transcript files under a sorted-path cursor, re-enriching one file per atomic transaction, so a Ctrl-C or crash mid-reingest resumes where it left off on the next sync instead of restarting the full clear-and-backfill from scratch — which previously could hold the cache writer lock and hang. `cache-sync --rebuild` remains the clean one-shot recovery path (#179).
|
|
13
|
+
|
|
8
14
|
## [1.37.1] - 2026-06-11
|
|
9
15
|
|
|
10
16
|
### Fixed
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -268,6 +268,9 @@ _CACHE_MIGRATIONS = _cctally_db_sib._CACHE_MIGRATIONS
|
|
|
268
268
|
# drop/recreate dance so the per-row delete trigger never fires O(rows) under
|
|
269
269
|
# the held lock on a rebuild / truncation escalation.
|
|
270
270
|
clear_conversation_messages = _cctally_db_sib.clear_conversation_messages
|
|
271
|
+
# cache_meta key/value upsert helper — reused by the resumable reingest cursor
|
|
272
|
+
# writes (#179) so the ON CONFLICT idiom lives in one place. Caller commits.
|
|
273
|
+
_set_cache_meta = _cctally_db_sib._set_cache_meta
|
|
271
274
|
|
|
272
275
|
|
|
273
276
|
# === BEGIN MOVED REGIONS ===
|
|
@@ -640,7 +643,9 @@ def sync_cache(
|
|
|
640
643
|
"DELETE FROM cache_meta WHERE key IN "
|
|
641
644
|
"('conversation_reingest_pending',"
|
|
642
645
|
" 'conversation_source_tool_use_reingest_pending',"
|
|
643
|
-
" 'conversation_reingest_enrichment_pending'
|
|
646
|
+
" 'conversation_reingest_enrichment_pending',"
|
|
647
|
+
" 'conversation_reingest_cursor',"
|
|
648
|
+
" 'conversation_reingest_cursor_gen')")
|
|
644
649
|
conn.commit()
|
|
645
650
|
eprint("[cache-sync] rebuild: cleared Claude cached entries")
|
|
646
651
|
|
|
@@ -720,15 +725,12 @@ def sync_cache(
|
|
|
720
725
|
except sqlite3.OperationalError:
|
|
721
726
|
_reingest = False
|
|
722
727
|
if _reingest:
|
|
723
|
-
clear_conversation_messages
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
" 'conversation_reingest_enrichment_pending')"
|
|
730
|
-
)
|
|
731
|
-
conn.commit()
|
|
728
|
+
# #179: resumable per-file reingest (was a global clear_conversation_messages
|
|
729
|
+
# + offset-0 backfill that re-armed the entire ~2.5min rebuild on any
|
|
730
|
+
# interrupt). The helper checkpoints a sorted-path cursor and clears the
|
|
731
|
+
# three flags + cursor + gen atomically on completion. Never on the rebuild
|
|
732
|
+
# path (which already wipes + repopulates id-aware via the normal walk).
|
|
733
|
+
_resumable_reingest_conversation_messages(conn)
|
|
732
734
|
|
|
733
735
|
paths: list[pathlib.Path] = list(_iter_claude_jsonl_files())
|
|
734
736
|
stats.files_total = len(paths)
|
|
@@ -1157,6 +1159,95 @@ def backfill_conversation_messages(conn: sqlite3.Connection) -> int:
|
|
|
1157
1159
|
return inserted
|
|
1158
1160
|
|
|
1159
1161
|
|
|
1162
|
+
_REINGEST_FLAG_KEYS = (
|
|
1163
|
+
"conversation_reingest_pending",
|
|
1164
|
+
"conversation_source_tool_use_reingest_pending",
|
|
1165
|
+
"conversation_reingest_enrichment_pending",
|
|
1166
|
+
)
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
def _reingest_parse_file(jp, path_str):
|
|
1170
|
+
"""Parse one Claude JSONL into enriched ``conversation_messages`` row tuples
|
|
1171
|
+
(``_CONV_INSERT_SQL`` column order). Mirrors ``backfill_conversation_messages``'s
|
|
1172
|
+
inner read+flatten, factored to a module-level seam so the resumable reingest
|
|
1173
|
+
builds rows BEFORE any write (a parse failure does no DML) and tests can inject.
|
|
1174
|
+
Raises ``OSError`` if the file can't be opened/read."""
|
|
1175
|
+
rows = []
|
|
1176
|
+
with open(jp, "r", encoding="utf-8", errors="replace") as fh:
|
|
1177
|
+
for m in _iter_message_rows(fh, path_str):
|
|
1178
|
+
rows.append(_conv_row_tuple(m, path_str))
|
|
1179
|
+
return rows
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
def _resumable_reingest_conversation_messages(conn):
|
|
1183
|
+
"""#179: resumable, lock-friendly replacement for the old global
|
|
1184
|
+
``clear_conversation_messages`` + offset-0 ``backfill_conversation_messages``
|
|
1185
|
+
reingest, which re-armed the whole ~2.5min rebuild on any interrupt. Walks
|
|
1186
|
+
every Claude JSONL in deterministic sorted-path order, re-enriching one file
|
|
1187
|
+
per atomic transaction and checkpointing ``conversation_reingest_cursor`` so an
|
|
1188
|
+
interrupt resumes instead of restarting. A ``conversation_reingest_cursor_gen``
|
|
1189
|
+
fingerprint (the set of pending reingest flags) resets the cursor whenever the
|
|
1190
|
+
pending-flag set changes, so a newly-armed flag forces a fresh pass. The caller
|
|
1191
|
+
(``sync_cache``) already holds the cache.db flock; per-file commits bound only
|
|
1192
|
+
the SQLite write transaction, not the flock. Clears the three flags + cursor +
|
|
1193
|
+
gen atomically on completion."""
|
|
1194
|
+
# 1. Generation guard: reset the cursor if the live pending-flag set differs.
|
|
1195
|
+
set_flags = [k for k in _REINGEST_FLAG_KEYS
|
|
1196
|
+
if conn.execute("SELECT 1 FROM cache_meta WHERE key=?", (k,)).fetchone()]
|
|
1197
|
+
gen = ",".join(sorted(set_flags))
|
|
1198
|
+
gen_row = conn.execute(
|
|
1199
|
+
"SELECT value FROM cache_meta WHERE key='conversation_reingest_cursor_gen'"
|
|
1200
|
+
).fetchone()
|
|
1201
|
+
if (gen_row[0] if gen_row else None) != gen:
|
|
1202
|
+
_set_cache_meta(conn, "conversation_reingest_cursor_gen", gen)
|
|
1203
|
+
conn.execute("DELETE FROM cache_meta WHERE key='conversation_reingest_cursor'")
|
|
1204
|
+
conn.commit()
|
|
1205
|
+
cursor = ""
|
|
1206
|
+
else:
|
|
1207
|
+
crow = conn.execute(
|
|
1208
|
+
"SELECT value FROM cache_meta WHERE key='conversation_reingest_cursor'"
|
|
1209
|
+
).fetchone()
|
|
1210
|
+
cursor = crow[0] if crow and crow[0] is not None else ""
|
|
1211
|
+
|
|
1212
|
+
# 2. Per-file resumable walk in deterministic sorted-path order.
|
|
1213
|
+
for jp in sorted(_iter_claude_jsonl_files(), key=str):
|
|
1214
|
+
path_str = str(jp)
|
|
1215
|
+
if path_str <= cursor:
|
|
1216
|
+
continue
|
|
1217
|
+
try:
|
|
1218
|
+
rows = _reingest_parse_file(jp, path_str) # parse FIRST — no DML on failure
|
|
1219
|
+
except OSError as exc:
|
|
1220
|
+
# Read/parse failed BEFORE any conversation_messages DML — the file's
|
|
1221
|
+
# existing rows are untouched (preserved, not dropped). Only advance the
|
|
1222
|
+
# cursor; this cursor-only write needs no rollback envelope (no message
|
|
1223
|
+
# DML to undo, and an interrupt mid-commit just re-runs this file).
|
|
1224
|
+
eprint(f"[conversation-reingest] could not read {jp}: {exc}; "
|
|
1225
|
+
"preserving existing rows")
|
|
1226
|
+
_set_cache_meta(conn, "conversation_reingest_cursor", path_str)
|
|
1227
|
+
conn.commit()
|
|
1228
|
+
continue
|
|
1229
|
+
try:
|
|
1230
|
+
conn.execute("DELETE FROM conversation_messages WHERE source_path=?",
|
|
1231
|
+
(path_str,))
|
|
1232
|
+
if rows:
|
|
1233
|
+
conn.executemany(_CONV_INSERT_SQL, rows)
|
|
1234
|
+
_set_cache_meta(conn, "conversation_reingest_cursor", path_str)
|
|
1235
|
+
conn.commit()
|
|
1236
|
+
except BaseException:
|
|
1237
|
+
conn.rollback()
|
|
1238
|
+
raise
|
|
1239
|
+
|
|
1240
|
+
# 3. Completion: clear flags + cursor + gen atomically.
|
|
1241
|
+
conn.execute(
|
|
1242
|
+
"DELETE FROM cache_meta WHERE key IN "
|
|
1243
|
+
"('conversation_reingest_pending',"
|
|
1244
|
+
" 'conversation_source_tool_use_reingest_pending',"
|
|
1245
|
+
" 'conversation_reingest_enrichment_pending',"
|
|
1246
|
+
" 'conversation_reingest_cursor',"
|
|
1247
|
+
" 'conversation_reingest_cursor_gen')")
|
|
1248
|
+
conn.commit()
|
|
1249
|
+
|
|
1250
|
+
|
|
1160
1251
|
def iter_entries(
|
|
1161
1252
|
conn: sqlite3.Connection,
|
|
1162
1253
|
range_start: dt.datetime,
|
|
@@ -7829,6 +7829,21 @@ def _dashboard_wait_for_signal(
|
|
|
7829
7829
|
os.close(write_fd)
|
|
7830
7830
|
|
|
7831
7831
|
|
|
7832
|
+
def _dashboard_initial_snapshot(args, *, pinned_now, display_tz_pref_override):
|
|
7833
|
+
"""#179: build the dashboard's first snapshot WITHOUT the heavy sync so the
|
|
7834
|
+
HTTP port binds promptly. The background ``_DashboardSyncThread`` (started in
|
|
7835
|
+
cmd_dashboard before the ThreadingHTTPServer bind) runs the first full
|
|
7836
|
+
``sync_cache`` — including any pending conversation reingest — and SSE-pushes
|
|
7837
|
+
the populated snapshot on completion. ``--no-sync`` already passed
|
|
7838
|
+
``skip_sync=True`` here, so that path is byte-identical; only the normal launch
|
|
7839
|
+
changes (its previously-redundant foreground sync is removed, not duplicated —
|
|
7840
|
+
the background thread's first tick was always going to sync anyway)."""
|
|
7841
|
+
return sys.modules["cctally"]._tui_build_snapshot(
|
|
7842
|
+
now_utc=pinned_now, skip_sync=True,
|
|
7843
|
+
display_tz_pref_override=display_tz_pref_override,
|
|
7844
|
+
)
|
|
7845
|
+
|
|
7846
|
+
|
|
7832
7847
|
def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
7833
7848
|
"""Launch the live web dashboard."""
|
|
7834
7849
|
import signal as _signal
|
|
@@ -7906,9 +7921,13 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
7906
7921
|
# weekly, project, forecast subcommands already respect.
|
|
7907
7922
|
pinned_now = _command_as_of() if os.environ.get("CCTALLY_AS_OF") else None
|
|
7908
7923
|
|
|
7909
|
-
#
|
|
7910
|
-
|
|
7911
|
-
|
|
7924
|
+
# #179: build the initial snapshot WITHOUT the heavy sync so the HTTP port
|
|
7925
|
+
# binds promptly even on a heavy-history instance. The background
|
|
7926
|
+
# _DashboardSyncThread (started below, before the bind) owns the first full
|
|
7927
|
+
# sync_cache — including any pending conversation-enrichment reingest — and
|
|
7928
|
+
# SSE-pushes the populated snapshot on completion.
|
|
7929
|
+
initial = _dashboard_initial_snapshot(
|
|
7930
|
+
args, pinned_now=pinned_now,
|
|
7912
7931
|
display_tz_pref_override=display_tz_pref_override,
|
|
7913
7932
|
)
|
|
7914
7933
|
if args.no_sync:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cctally",
|
|
3
|
-
"version": "1.37.
|
|
3
|
+
"version": "1.37.2",
|
|
4
4
|
"description": "Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.",
|
|
5
5
|
"homepage": "https://github.com/omrikais/cctally",
|
|
6
6
|
"repository": {
|