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 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
@@ -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(conn)
724
- backfill_conversation_messages(conn)
725
- conn.execute(
726
- "DELETE FROM cache_meta WHERE key IN "
727
- "('conversation_reingest_pending',"
728
- " 'conversation_source_tool_use_reingest_pending',"
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
- # Build initial snapshot blocking, serves immediately.
7910
- initial = sys.modules["cctally"]._tui_build_snapshot(
7911
- now_utc=pinned_now, skip_sync=args.no_sync,
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.1",
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": {