cctally 1.59.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.
- package/CHANGELOG.md +31 -0
- package/bin/_cctally_cache.py +323 -48
- package/bin/_cctally_core.py +8 -0
- package/bin/_cctally_dashboard.py +913 -143
- package/bin/_cctally_doctor.py +2 -0
- package/bin/_cctally_forecast.py +24 -2
- package/bin/_cctally_parser.py +28 -2
- package/bin/_cctally_tui.py +676 -16
- package/bin/_cctally_update.py +49 -9
- package/bin/_cctally_weekrefs.py +92 -2
- package/bin/_lib_doctor.py +10 -0
- package/bin/_lib_snapshot_cache.py +988 -0
- package/bin/_lib_view_models.py +117 -12
- package/bin/cctally +57 -0
- package/dashboard/static/assets/index-0jzYm75p.css +1 -0
- package/dashboard/static/assets/index-D_Ylyqsf.js +80 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-BatfACUX.css +0 -1
- package/dashboard/static/assets/index-VUPDfWul.js +0 -80
package/bin/_cctally_update.py
CHANGED
|
@@ -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
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
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.
|
|
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.
|
|
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)."""
|
package/bin/_cctally_weekrefs.py
CHANGED
|
@@ -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(
|
|
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]:
|
package/bin/_lib_doctor.py
CHANGED
|
@@ -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
|
|