cctally 1.60.0 → 1.62.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.
@@ -196,7 +196,7 @@ import threading
196
196
  import time
197
197
  import urllib.error
198
198
  import urllib.request
199
- from dataclasses import dataclass
199
+ from dataclasses import dataclass, replace
200
200
  from typing import Any, Callable
201
201
 
202
202
 
@@ -1872,12 +1872,22 @@ class _DashboardUpdateCheckThread(threading.Thread):
1872
1872
  via ``update.check.enabled = false`` is honoured inside the gate
1873
1873
  so the thread becomes a no-op without needing teardown.
1874
1874
 
1875
- After a successful check, republishes the current snapshot via the
1876
- SSE hub so long-open dashboard tabs in ``--no-sync`` mode pick up
1877
- the fresh ``latest_version`` written to ``update-state.json``. The
1878
- snapshot itself is unchanged — ``snapshot_to_envelope`` re-reads
1879
- the state file per envelope build, so a bare publish is enough to
1880
- refresh the badge for every live subscriber.
1875
+ After a successful check (or an out-of-band ``current_version``
1876
+ self-heal), republishes the current snapshot via the SSE hub so
1877
+ long-open dashboard tabs in ``--no-sync`` mode pick up the fresh
1878
+ ``latest_version`` / ``current_version`` written to
1879
+ ``update-state.json``. Since M4 (#268) the SSE envelope reads
1880
+ update-state / update-suppress off the snapshot's
1881
+ ``envelope_precompute`` rather than re-reading the state file per
1882
+ envelope build, so a BARE publish of the held snapshot would surface
1883
+ the stale precompute. The republish therefore rebuilds
1884
+ ``envelope_precompute`` (small config/state JSON I/O, no DB, no
1885
+ module-cache mutation — see :meth:`_republish_with_fresh_envelope`)
1886
+ on a fresh ``dataclasses.replace`` snapshot before publishing, and
1887
+ persists it to the snapshot ref so the held snapshot stays current.
1888
+ Under ``--no-sync`` this thread is the ONLY refresher of that
1889
+ precompute (the data-sync thread that otherwise rebuilds it is
1890
+ disabled).
1881
1891
  """
1882
1892
 
1883
1893
  daemon = True
@@ -1917,14 +1927,14 @@ class _DashboardUpdateCheckThread(threading.Thread):
1917
1927
  ):
1918
1928
  snap = self._ref.get()
1919
1929
  if snap is not None:
1920
- self._hub.publish(snap)
1930
+ self._republish_with_fresh_envelope(snap)
1921
1931
  config = load_config()
1922
1932
  if c._is_update_check_due(config):
1923
1933
  c._do_update_check()
1924
1934
  if self._hub is not None and self._ref is not None:
1925
1935
  snap = self._ref.get()
1926
1936
  if snap is not None:
1927
- self._hub.publish(snap)
1937
+ self._republish_with_fresh_envelope(snap)
1928
1938
  except Exception as e:
1929
1939
  # Log but never propagate — this thread must keep
1930
1940
  # ticking so a transient registry hiccup doesn't
@@ -1940,6 +1950,36 @@ class _DashboardUpdateCheckThread(threading.Thread):
1940
1950
  pass
1941
1951
  self._stop_event.wait(c.UPDATE_DASHBOARD_CHECK_POLL_S)
1942
1952
 
1953
+ def _republish_with_fresh_envelope(self, snap) -> None:
1954
+ """Republish ``snap`` with a freshly-rebuilt ``envelope_precompute``.
1955
+
1956
+ Since M4 (#268) the SSE envelope reads update-state / update-suppress
1957
+ off ``snap.envelope_precompute`` rather than re-reading the JSON files
1958
+ per client. ``_do_update_check`` / ``_self_heal_current_version`` write
1959
+ ``update-state.json`` OUT OF BAND (advancing no DB signature and no
1960
+ config render key), so a bare re-publish of the HELD snapshot would
1961
+ surface the stale precompute — freezing the version banner on a
1962
+ ``--no-sync`` dashboard whose data-sync thread (the only other
1963
+ precompute refresher) is disabled.
1964
+
1965
+ ``_tui_precompute_envelope_config`` reads only the config / update-state
1966
+ / update-suppress files and mutates NO module cache, so calling it from
1967
+ this thread is safe (no shared-cache-mutation / Codex F7 hazard).
1968
+ ``dataclasses.replace`` yields a NEW snapshot sharing ``prior``'s
1969
+ immutable rows, so an SSE client serializing the previously-published
1970
+ snapshot can't observe a torn value. The fresh snapshot is persisted to
1971
+ the ref so the held snapshot stays current for the next reader.
1972
+ """
1973
+ c = _cctally()
1974
+ fresh = replace(
1975
+ snap,
1976
+ envelope_precompute=(
1977
+ c._cctally_tui._tui_precompute_envelope_config(load_config())
1978
+ ),
1979
+ )
1980
+ self._ref.set(fresh)
1981
+ self._hub.publish(fresh)
1982
+
1943
1983
 
1944
1984
  def cmd_update(args) -> int:
1945
1985
  """`cctally update` entry point — routes by mode flag (spec §4.1)."""
@@ -31,6 +31,7 @@ from __future__ import annotations
31
31
  import datetime as dt
32
32
  import sqlite3
33
33
  import sys
34
+ import threading
34
35
  from dataclasses import replace
35
36
 
36
37
  from _cctally_core import (
@@ -220,6 +221,68 @@ def _apply_reset_events_to_weekrefs(
220
221
  return out
221
222
 
222
223
 
224
+ # === #269 M4.4 — backfill-rescan process guard ==============================
225
+ #
226
+ # `_backfill_week_reset_events` rescans ALL `weekly_usage_snapshots` on every
227
+ # `open_db()`. Its output is NOT a pure function of the snapshots alone: the
228
+ # in-place-credit branch also reads existing `week_reset_events`, and the
229
+ # reset-events-regenerate contract deletes `week_reset_events` and re-runs the
230
+ # backfill WITHOUT a snapshot insert. So a `MAX(weekly_usage_snapshots.id)`-only
231
+ # guard would skip a needed regeneration. Guard instead on a process-level memo
232
+ # of BOTH `MAX(weekly_usage_snapshots.id)` AND a `week_reset_events` signature
233
+ # `(COUNT(*), MAX(rowid))`, scoped per DB FILE identity: skip the rescan only
234
+ # when both are unchanged since the last successful backfill. Byte-identical (a
235
+ # snapshot insert advances the WUS id; a reset-event delete/clear moves the
236
+ # reset-event sig; a backfill's own inserts move the sig so the next open is a
237
+ # clean skip). Production snapshots are append-only, so the WUS id moves on
238
+ # every new capture. In-memory / temp DBs (no file identity) are never memoized
239
+ # (always rescanned) — a `:memory:` identity could otherwise collide across
240
+ # distinct databases.
241
+
242
+ _BACKFILL_MEMO_LOCK = threading.Lock()
243
+ _BACKFILL_RESET_EVENTS_MEMO: dict = {} # db_path -> (max_wus_id, (count, max_rowid))
244
+
245
+
246
+ def _reset_backfill_reset_events_memo() -> None:
247
+ """Drop the backfill-guard memo (test hook + isolation)."""
248
+ with _BACKFILL_MEMO_LOCK:
249
+ _BACKFILL_RESET_EVENTS_MEMO.clear()
250
+
251
+
252
+ def _backfill_db_identity(conn: sqlite3.Connection) -> "str | None":
253
+ """The `main` schema's on-disk file path, or None for in-memory/temp DBs
254
+ (empty file string) — the memo key. None ⇒ never memoize (always rescan)."""
255
+ try:
256
+ for _seq, name, file in conn.execute("PRAGMA database_list"):
257
+ if name == "main":
258
+ return file or None
259
+ except sqlite3.Error:
260
+ pass
261
+ return None
262
+
263
+
264
+ def _backfill_reset_events_signature(
265
+ conn: sqlite3.Connection,
266
+ ) -> "tuple[int, tuple[int, int]]":
267
+ """`(MAX(weekly_usage_snapshots.id), (COUNT, MAX(rowid)) over
268
+ week_reset_events)` — two O(1) MAX/COUNT descents vs the full-table rescan.
269
+ Degrades to zero legs on a missing table so it never raises."""
270
+ try:
271
+ max_wus = conn.execute(
272
+ "SELECT COALESCE(MAX(id), 0) FROM weekly_usage_snapshots"
273
+ ).fetchone()[0]
274
+ except sqlite3.DatabaseError:
275
+ max_wus = 0
276
+ try:
277
+ row = conn.execute(
278
+ "SELECT COUNT(*), COALESCE(MAX(rowid), 0) FROM week_reset_events"
279
+ ).fetchone()
280
+ wre = (int(row[0]), int(row[1]))
281
+ except sqlite3.DatabaseError:
282
+ wre = (0, 0)
283
+ return (int(max_wus), wre)
284
+
285
+
223
286
  def _backfill_week_reset_events(conn: sqlite3.Connection) -> None:
224
287
  """One-shot scan over historical snapshots to synthesize reset events
225
288
  for past mid-week resets the tool lived through before this feature
@@ -244,6 +307,26 @@ def _backfill_week_reset_events(conn: sqlite3.Connection) -> None:
244
307
  window. See ``_is_reset_drop`` for the full rationale.
245
308
  """
246
309
  c = _cctally()
310
+ # #269 M4.4: skip the full rescan when neither the snapshots nor the
311
+ # reset events moved since the last successful backfill on this DB file.
312
+ #
313
+ # #271 §9d rider (from the #269 final review): the skip signature
314
+ # (`_backfill_reset_events_signature`, a MAX(id)/COUNT descent) is blind to a
315
+ # NON-max `weekly_usage_snapshots` DELETE — dropping a middle row leaves
316
+ # MAX(id) and could leave COUNT unchanged vs a prior state, so a rescan may
317
+ # be skipped. That is byte-safe because this backfill is ADD-ONLY
318
+ # (`INSERT OR IGNORE INTO week_reset_events`): a skipped rescan can only fail
319
+ # to *add* an event, never *remove* a needed one, matching the pre-#269
320
+ # idempotent behavior exactly.
321
+ db_id = _backfill_db_identity(conn)
322
+ sig = _backfill_reset_events_signature(conn)
323
+ if db_id is not None:
324
+ with _BACKFILL_MEMO_LOCK:
325
+ if _BACKFILL_RESET_EVENTS_MEMO.get(db_id) == sig:
326
+ # Preserve the original's transaction-flush behavior on the
327
+ # skip path (harmless when there is nothing pending).
328
+ conn.commit()
329
+ return
247
330
  try:
248
331
  rows = conn.execute(
249
332
  "SELECT captured_at_utc, week_end_at, weekly_percent "
@@ -372,6 +455,13 @@ def _backfill_week_reset_events(conn: sqlite3.Connection) -> None:
372
455
  # (e.g. _backfill_five_hour_blocks) don't trip "cannot start a
373
456
  # transaction within a transaction".
374
457
  conn.commit()
458
+ # #269 M4.4: store the POST-backfill signature (the scan's own inserts move
459
+ # the reset-event sig, so the next open with no snapshot change is a clean
460
+ # skip). Only after a completed scan — the early `except` return above
461
+ # deliberately does not memoize a partial state.
462
+ if db_id is not None:
463
+ with _BACKFILL_MEMO_LOCK:
464
+ _BACKFILL_RESET_EVENTS_MEMO[db_id] = _backfill_reset_events_signature(conn)
375
465
 
376
466
 
377
467
  # Minimum weekly_percent drop that counts as a goodwill reset (percentage
@@ -458,11 +548,20 @@ def _week_ref_has_reset_event(
458
548
  return row is not None
459
549
 
460
550
 
461
- def _compute_cost_for_weekref(ref: WeekRef) -> float | None:
551
+ def _compute_cost_for_weekref(
552
+ ref: WeekRef, *, skip_sync: bool = False
553
+ ) -> float | None:
462
554
  """Live-compute USD cost over `ref`'s (possibly reset-adjusted) range
463
555
  straight from session_entries. Mirrors what cmd_sync_week writes into
464
556
  weekly_cost_snapshots, minus the cache write — used for reset-affected
465
557
  weeks where the cached range disagrees with the effective range.
558
+
559
+ ``skip_sync`` (default ``False``) is threaded to ``_sum_cost_for_range``
560
+ so the caller can read the cache without triggering a JSONL ingest.
561
+ The #268 dashboard/TUI sync-thread rebuild passes ``True`` (it ingests
562
+ once at the top of the rebuild); ``build_trend_view`` calls this once per
563
+ reset-event week, so without the flag each reset week re-globbed the whole
564
+ ``~/.claude/projects`` tree — the CPU peg the sync-once refactor removes.
466
565
  """
467
566
  c = _cctally()
468
567
  if not ref.week_start_at or not ref.week_end_at:
@@ -474,7 +573,7 @@ def _compute_cost_for_weekref(ref: WeekRef) -> float | None:
474
573
  return None
475
574
  if end <= start:
476
575
  return 0.0
477
- return c._sum_cost_for_range(start, end, mode="auto")
576
+ return c._sum_cost_for_range(start, end, mode="auto", skip_sync=skip_sync)
478
577
 
479
578
 
480
579
  def _apply_overlap_clamp_to_weekrefs(refs: list[WeekRef]) -> list[WeekRef]:
@@ -106,6 +106,94 @@ class BucketUsage:
106
106
  model_breakdowns: list[dict[str, Any]] # Sorted by cost desc
107
107
 
108
108
 
109
+ def _new_bucket_acc() -> dict[str, Any]:
110
+ """A fresh running accumulator for one bucket (the pre-``BucketUsage`` shape).
111
+
112
+ The single per-bucket accumulation state shared by the full-pass
113
+ ``_aggregate_buckets`` and #271's incremental current-bucket accumulator, so
114
+ the byte-identity proof reduces to "same primitive, entries appended in the
115
+ same order" (spec §6).
116
+ """
117
+ return {
118
+ "input": 0, "output": 0, "cache_create": 0, "cache_read": 0,
119
+ "cost": 0.0, "models": {}, "models_order": [],
120
+ }
121
+
122
+
123
+ def _fold_entry(acc: dict[str, Any], entry: UsageEntry, mode: str = "auto") -> None:
124
+ """Fold one entry into ``acc`` (the exact per-entry logic of ``_aggregate_buckets``).
125
+
126
+ Carries over verbatim: the ``<synthetic>`` skip (never touches ``acc`` for
127
+ them, so it is safe over an unfiltered delta stream), the ``-fast``
128
+ ``display_model`` suffix, the ``_calculate_entry_cost`` call, the four
129
+ integer token ``+=``, the running float ``cost +=``, the per-model
130
+ sub-bucket sums, and the first-seen ``models_order.append``. Order-sensitive:
131
+ the running ``cost`` is a left-fold, so callers MUST fold in the same order
132
+ the full pass would (the pinned ``(timestamp_utc, id)`` order, #271 §5).
133
+ """
134
+ if entry.model == "<synthetic>":
135
+ return
136
+ usage = entry.usage
137
+ display_model = f"{entry.model}-fast" if usage.get("speed") == "fast" else entry.model
138
+ inp = int(usage.get("input_tokens", 0) or 0)
139
+ out = int(usage.get("output_tokens", 0) or 0)
140
+ cc = int(usage.get("cache_creation_input_tokens", 0) or 0)
141
+ cr = int(usage.get("cache_read_input_tokens", 0) or 0)
142
+ cost = _calculate_entry_cost(
143
+ entry.model, usage, mode=mode, cost_usd=entry.cost_usd,
144
+ )
145
+ acc["input"] += inp
146
+ acc["output"] += out
147
+ acc["cache_create"] += cc
148
+ acc["cache_read"] += cr
149
+ acc["cost"] += cost
150
+ model_bucket = acc["models"].setdefault(display_model, {
151
+ "input": 0, "output": 0, "cache_create": 0, "cache_read": 0, "cost": 0.0,
152
+ })
153
+ model_bucket["input"] += inp
154
+ model_bucket["output"] += out
155
+ model_bucket["cache_create"] += cc
156
+ model_bucket["cache_read"] += cr
157
+ model_bucket["cost"] += cost
158
+ if display_model not in acc["models_order"]:
159
+ acc["models_order"].append(display_model)
160
+
161
+
162
+ def _finalize_bucket(bucket_key: str, acc: dict[str, Any]) -> BucketUsage:
163
+ """Build the immutable ``BucketUsage`` from a running accumulator.
164
+
165
+ Copies ``models`` / ``model_breakdowns`` so the returned row shares no
166
+ mutable state with ``acc`` (F7 — a live accumulator may keep being folded,
167
+ e.g. #271's persisted current-bucket state).
168
+ """
169
+ model_breakdowns = [
170
+ {
171
+ "modelName": model,
172
+ "inputTokens": mb["input"],
173
+ "outputTokens": mb["output"],
174
+ "cacheCreationTokens": mb["cache_create"],
175
+ "cacheReadTokens": mb["cache_read"],
176
+ "cost": mb["cost"],
177
+ }
178
+ for model, mb in acc["models"].items()
179
+ ]
180
+ model_breakdowns.sort(key=lambda m: m["cost"], reverse=True)
181
+ total_tokens = (
182
+ acc["input"] + acc["output"] + acc["cache_create"] + acc["cache_read"]
183
+ )
184
+ return BucketUsage(
185
+ bucket=bucket_key,
186
+ input_tokens=acc["input"],
187
+ output_tokens=acc["output"],
188
+ cache_creation_tokens=acc["cache_create"],
189
+ cache_read_tokens=acc["cache_read"],
190
+ total_tokens=total_tokens,
191
+ cost_usd=acc["cost"],
192
+ models=list(acc["models_order"]),
193
+ model_breakdowns=model_breakdowns,
194
+ )
195
+
196
+
109
197
  def _aggregate_buckets(
110
198
  entries: list[UsageEntry],
111
199
  key_fn: Callable[[UsageEntry], str],
@@ -117,84 +205,23 @@ def _aggregate_buckets(
117
205
  The returned list is sorted by bucket key ascending — callers reverse
118
206
  for --order desc. Model breakdowns within each bucket are sorted by
119
207
  descending cost, matching upstream ccusage.
208
+
209
+ The per-entry accumulation is the shared ``_fold_entry`` primitive (spec
210
+ §6): a ``<synthetic>`` entry is skipped BEFORE keying (so a synthetic-only
211
+ key never materializes an empty bucket), and each surviving entry is folded
212
+ into its bucket's running accumulator, finalized per key in ascending order.
120
213
  """
121
214
  by_bucket: dict[str, dict[str, Any]] = {}
122
- models_order: dict[str, list[str]] = {}
123
-
124
215
  for entry in entries:
125
216
  if entry.model == "<synthetic>":
126
217
  continue
127
- usage = entry.usage
128
- display_model = f"{entry.model}-fast" if usage.get("speed") == "fast" else entry.model
129
218
  key = key_fn(entry)
130
- bucket = by_bucket.setdefault(key, {
131
- "input": 0,
132
- "output": 0,
133
- "cache_create": 0,
134
- "cache_read": 0,
135
- "cost": 0.0,
136
- "models": {},
137
- })
138
- order = models_order.setdefault(key, [])
219
+ acc = by_bucket.get(key)
220
+ if acc is None:
221
+ by_bucket[key] = acc = _new_bucket_acc()
222
+ _fold_entry(acc, entry, mode)
139
223
 
140
- inp = int(usage.get("input_tokens", 0) or 0)
141
- out = int(usage.get("output_tokens", 0) or 0)
142
- cc = int(usage.get("cache_creation_input_tokens", 0) or 0)
143
- cr = int(usage.get("cache_read_input_tokens", 0) or 0)
144
- cost = _calculate_entry_cost(
145
- entry.model, usage, mode=mode, cost_usd=entry.cost_usd,
146
- )
147
-
148
- bucket["input"] += inp
149
- bucket["output"] += out
150
- bucket["cache_create"] += cc
151
- bucket["cache_read"] += cr
152
- bucket["cost"] += cost
153
-
154
- model_bucket = bucket["models"].setdefault(display_model, {
155
- "input": 0,
156
- "output": 0,
157
- "cache_create": 0,
158
- "cache_read": 0,
159
- "cost": 0.0,
160
- })
161
- model_bucket["input"] += inp
162
- model_bucket["output"] += out
163
- model_bucket["cache_create"] += cc
164
- model_bucket["cache_read"] += cr
165
- model_bucket["cost"] += cost
166
-
167
- if display_model not in order:
168
- order.append(display_model)
169
-
170
- result: list[BucketUsage] = []
171
- for key in sorted(by_bucket.keys()):
172
- b = by_bucket[key]
173
- model_breakdowns = [
174
- {
175
- "modelName": model,
176
- "inputTokens": mb["input"],
177
- "outputTokens": mb["output"],
178
- "cacheCreationTokens": mb["cache_create"],
179
- "cacheReadTokens": mb["cache_read"],
180
- "cost": mb["cost"],
181
- }
182
- for model, mb in b["models"].items()
183
- ]
184
- model_breakdowns.sort(key=lambda m: m["cost"], reverse=True)
185
- total_tokens = b["input"] + b["output"] + b["cache_create"] + b["cache_read"]
186
- result.append(BucketUsage(
187
- bucket=key,
188
- input_tokens=b["input"],
189
- output_tokens=b["output"],
190
- cache_creation_tokens=b["cache_create"],
191
- cache_read_tokens=b["cache_read"],
192
- total_tokens=total_tokens,
193
- cost_usd=b["cost"],
194
- models=models_order[key],
195
- model_breakdowns=model_breakdowns,
196
- ))
197
- return result
224
+ return [_finalize_bucket(key, by_bucket[key]) for key in sorted(by_bucket.keys())]
198
225
 
199
226
 
200
227
  def _aggregate_daily(