cctally 1.60.0 → 1.61.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,17 @@ 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
+ db_id = _backfill_db_identity(conn)
313
+ sig = _backfill_reset_events_signature(conn)
314
+ if db_id is not None:
315
+ with _BACKFILL_MEMO_LOCK:
316
+ if _BACKFILL_RESET_EVENTS_MEMO.get(db_id) == sig:
317
+ # Preserve the original's transaction-flush behavior on the
318
+ # skip path (harmless when there is nothing pending).
319
+ conn.commit()
320
+ return
247
321
  try:
248
322
  rows = conn.execute(
249
323
  "SELECT captured_at_utc, week_end_at, weekly_percent "
@@ -372,6 +446,13 @@ def _backfill_week_reset_events(conn: sqlite3.Connection) -> None:
372
446
  # (e.g. _backfill_five_hour_blocks) don't trip "cannot start a
373
447
  # transaction within a transaction".
374
448
  conn.commit()
449
+ # #269 M4.4: store the POST-backfill signature (the scan's own inserts move
450
+ # the reset-event sig, so the next open with no snapshot change is a clean
451
+ # skip). Only after a completed scan — the early `except` return above
452
+ # deliberately does not memoize a partial state.
453
+ if db_id is not None:
454
+ with _BACKFILL_MEMO_LOCK:
455
+ _BACKFILL_RESET_EVENTS_MEMO[db_id] = _backfill_reset_events_signature(conn)
375
456
 
376
457
 
377
458
  # Minimum weekly_percent drop that counts as a goodwill reset (percentage
@@ -458,11 +539,20 @@ def _week_ref_has_reset_event(
458
539
  return row is not None
459
540
 
460
541
 
461
- def _compute_cost_for_weekref(ref: WeekRef) -> float | None:
542
+ def _compute_cost_for_weekref(
543
+ ref: WeekRef, *, skip_sync: bool = False
544
+ ) -> float | None:
462
545
  """Live-compute USD cost over `ref`'s (possibly reset-adjusted) range
463
546
  straight from session_entries. Mirrors what cmd_sync_week writes into
464
547
  weekly_cost_snapshots, minus the cache write — used for reset-affected
465
548
  weeks where the cached range disagrees with the effective range.
549
+
550
+ ``skip_sync`` (default ``False``) is threaded to ``_sum_cost_for_range``
551
+ so the caller can read the cache without triggering a JSONL ingest.
552
+ The #268 dashboard/TUI sync-thread rebuild passes ``True`` (it ingests
553
+ once at the top of the rebuild); ``build_trend_view`` calls this once per
554
+ reset-event week, so without the flag each reset week re-globbed the whole
555
+ ``~/.claude/projects`` tree — the CPU peg the sync-once refactor removes.
466
556
  """
467
557
  c = _cctally()
468
558
  if not ref.week_start_at or not ref.week_end_at:
@@ -474,7 +564,7 @@ def _compute_cost_for_weekref(ref: WeekRef) -> float | None:
474
564
  return None
475
565
  if end <= start:
476
566
  return 0.0
477
- return c._sum_cost_for_range(start, end, mode="auto")
567
+ return c._sum_cost_for_range(start, end, mode="auto", skip_sync=skip_sync)
478
568
 
479
569
 
480
570
  def _apply_overlap_clamp_to_weekrefs(refs: list[WeekRef]) -> list[WeekRef]:
@@ -164,6 +164,11 @@ class DoctorState:
164
164
  conv_sessions_rollup_count: Optional[int] = None
165
165
  conv_messages_distinct_sessions: Optional[int] = None
166
166
  conv_rollup_sync_in_progress: bool = False
167
+ # Preview channel (CCTALLY_CHANNEL=preview): "preview" when the binary runs
168
+ # under the preview channel, else "prod". Populated by `doctor_gather_state`
169
+ # from the env; surfaced in the install.mode check. Defaulted (placed last)
170
+ # so existing constructors stay valid and the prod path is unchanged.
171
+ channel: str = "prod"
167
172
 
168
173
 
169
174
  @dataclasses.dataclass(frozen=True)
@@ -359,6 +364,10 @@ def _check_install_dev_mode(s: DoctorState) -> CheckResult:
359
364
  summary = "DEV (git checkout, custom data dir via CCTALLY_DATA_DIR)"
360
365
  else:
361
366
  summary = "installed"
367
+ # Preview channel (CCTALLY_CHANNEL=preview): note the channel in the
368
+ # summary. Gated → prod (channel="prod") summary is unchanged.
369
+ if s.channel == "preview":
370
+ summary += " · channel: preview"
362
371
  return CheckResult(
363
372
  id="install.mode", title="Mode",
364
373
  severity="ok", summary=summary, remediation=None,
@@ -366,6 +375,7 @@ def _check_install_dev_mode(s: DoctorState) -> CheckResult:
366
375
  "dev_mode": s.dev_mode,
367
376
  "is_dev_checkout": s.is_dev_checkout,
368
377
  "app_dir": s.app_dir,
378
+ "channel": s.channel,
369
379
  },
370
380
  )
371
381