cctally 1.69.3 → 1.71.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 +24 -0
- package/bin/_cctally_cache.py +167 -2
- package/bin/_cctally_config.py +108 -0
- package/bin/_cctally_core.py +32 -3
- package/bin/_cctally_dashboard.py +106 -11
- package/bin/_cctally_db.py +159 -0
- package/bin/_cctally_doctor.py +10 -0
- package/bin/_cctally_parser.py +20 -0
- package/bin/_cctally_quota.py +58 -6
- package/bin/_cctally_setup.py +320 -0
- package/bin/_cctally_statusline.py +11 -0
- package/bin/_lib_doctor.py +48 -0
- package/bin/_lib_statusline.py +26 -0
- package/bin/cctally +10 -0
- package/dashboard/static/assets/index-BWadAHxC.js +80 -0
- package/dashboard/static/assets/index-D2nwo_ln.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-CBbErI-P.js +0 -80
- package/dashboard/static/assets/index-kDDVOLa_.css +0 -1
package/bin/_cctally_db.py
CHANGED
|
@@ -4278,6 +4278,13 @@ def _024_codex_fused_ingest_rebuild(conn: sqlite3.Connection) -> None:
|
|
|
4278
4278
|
conn.execute("DELETE FROM codex_conversation_threads")
|
|
4279
4279
|
conn.execute("DELETE FROM codex_conversation_events")
|
|
4280
4280
|
conn.execute("DELETE FROM codex_source_roots")
|
|
4281
|
+
# F3 (#313): clearing Codex quota state without invalidating the
|
|
4282
|
+
# quota-projection certificate would leave a stale-valid cert
|
|
4283
|
+
# (cache sequence unchanged) that lets the reconcile short-circuit
|
|
4284
|
+
# skip over now-deleted data. Delete it in the same transaction.
|
|
4285
|
+
conn.execute(
|
|
4286
|
+
"DELETE FROM cache_meta WHERE key='codex_quota_projection_certificate'"
|
|
4287
|
+
)
|
|
4281
4288
|
conn.commit()
|
|
4282
4289
|
except Exception:
|
|
4283
4290
|
conn.rollback()
|
|
@@ -5802,3 +5809,155 @@ def cmd_db_checkpoint(args: argparse.Namespace) -> int:
|
|
|
5802
5809
|
print(f"cctally: {result.db} WAL {mb_b:.1f} MB -> {mb_a:.1f} MB "
|
|
5803
5810
|
f"({result.frames_checkpointed} frames; {state}).")
|
|
5804
5811
|
return 0 if result.truncated else 3
|
|
5812
|
+
|
|
5813
|
+
|
|
5814
|
+
# VACUUM writes a full fresh copy of the database into a temporary file and then
|
|
5815
|
+
# swaps it in, so it transiently needs roughly the DB's own size on top of the
|
|
5816
|
+
# existing file, plus room for the drained WAL. A short busy_timeout keeps a
|
|
5817
|
+
# contended VACUUM from hanging (F13).
|
|
5818
|
+
_VACUUM_BUSY_TIMEOUT_MS = 250
|
|
5819
|
+
|
|
5820
|
+
|
|
5821
|
+
def _free_disk_bytes(directory) -> int:
|
|
5822
|
+
"""Free bytes on the filesystem holding ``directory`` (mockable in tests)."""
|
|
5823
|
+
import shutil
|
|
5824
|
+
return shutil.disk_usage(str(directory)).free
|
|
5825
|
+
|
|
5826
|
+
|
|
5827
|
+
def _vacuum_required_free_bytes(path) -> int:
|
|
5828
|
+
"""Conservative free-space floor to VACUUM ``path``: ~2x the DB file plus its
|
|
5829
|
+
current WAL sidecar (F13 — the VACUUM temp copy + a WAL/temp margin)."""
|
|
5830
|
+
try:
|
|
5831
|
+
db_bytes = path.stat().st_size
|
|
5832
|
+
except OSError:
|
|
5833
|
+
db_bytes = 0
|
|
5834
|
+
wal = path.parent / (path.name + "-wal")
|
|
5835
|
+
try:
|
|
5836
|
+
wal_bytes = wal.stat().st_size
|
|
5837
|
+
except OSError:
|
|
5838
|
+
wal_bytes = 0
|
|
5839
|
+
return 2 * db_bytes + wal_bytes
|
|
5840
|
+
|
|
5841
|
+
|
|
5842
|
+
def _run_vacuum_exclusive(path, label: str) -> int:
|
|
5843
|
+
"""Checkpoint + VACUUM ``path`` under a real SQLite EXCLUSIVE lock (F13).
|
|
5844
|
+
|
|
5845
|
+
``locking_mode=EXCLUSIVE`` + a short ``busy_timeout`` make a concurrent
|
|
5846
|
+
reader/writer FAIL PROMPTLY (no TOCTOU gap — the exclusion is the DB's own
|
|
5847
|
+
lock, which the advisory flocks do not provide against dashboard readers).
|
|
5848
|
+
Exit 0 on success, 3 when the DB is in use."""
|
|
5849
|
+
conn = sqlite3.connect(f"file:{path}?mode=rw", uri=True)
|
|
5850
|
+
try:
|
|
5851
|
+
conn.execute(f"PRAGMA busy_timeout={_VACUUM_BUSY_TIMEOUT_MS}")
|
|
5852
|
+
page_size = conn.execute("PRAGMA page_size").fetchone()[0]
|
|
5853
|
+
before = conn.execute("PRAGMA page_count").fetchone()[0]
|
|
5854
|
+
try:
|
|
5855
|
+
conn.execute("PRAGMA locking_mode=EXCLUSIVE")
|
|
5856
|
+
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
5857
|
+
conn.execute("VACUUM")
|
|
5858
|
+
except sqlite3.OperationalError as exc:
|
|
5859
|
+
if "lock" in str(exc).lower() or "busy" in str(exc).lower():
|
|
5860
|
+
eprint(
|
|
5861
|
+
f"cctally: {label} is in use — VACUUM needs exclusive access. "
|
|
5862
|
+
f"Stop the dashboard and any other cctally process holding "
|
|
5863
|
+
f"{label}, then retry."
|
|
5864
|
+
)
|
|
5865
|
+
return 3
|
|
5866
|
+
raise
|
|
5867
|
+
after = conn.execute("PRAGMA page_count").fetchone()[0]
|
|
5868
|
+
freed_mb = max(0, (before - after)) * page_size / (1024 * 1024)
|
|
5869
|
+
print(
|
|
5870
|
+
f"cctally: {label} reclaimed {freed_mb:.1f} MB "
|
|
5871
|
+
f"({before} -> {after} pages)."
|
|
5872
|
+
)
|
|
5873
|
+
return 0
|
|
5874
|
+
finally:
|
|
5875
|
+
conn.close()
|
|
5876
|
+
|
|
5877
|
+
|
|
5878
|
+
def _vacuum_one_db(path, label: str, provider_locked: bool) -> int:
|
|
5879
|
+
if not path.exists():
|
|
5880
|
+
print(f"cctally: no {label} database file present; nothing to reclaim.")
|
|
5881
|
+
return 0
|
|
5882
|
+
needed = _vacuum_required_free_bytes(path)
|
|
5883
|
+
free = _free_disk_bytes(path.parent)
|
|
5884
|
+
if free < needed:
|
|
5885
|
+
eprint(
|
|
5886
|
+
f"cctally: not enough free disk to VACUUM {label}: need ~"
|
|
5887
|
+
f"{needed // (1024 * 1024)} MB free, have {free // (1024 * 1024)} MB. "
|
|
5888
|
+
f"Free up space and retry."
|
|
5889
|
+
)
|
|
5890
|
+
return 3
|
|
5891
|
+
core = _cctally_core
|
|
5892
|
+
try:
|
|
5893
|
+
core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
5894
|
+
except OSError:
|
|
5895
|
+
pass
|
|
5896
|
+
# Serialize against the retention prune and concurrent vacuums via the
|
|
5897
|
+
# dedicated maintenance flock (F13/F7), then the provider flocks for
|
|
5898
|
+
# cache.db. All non-blocking: fail promptly rather than hang.
|
|
5899
|
+
maint_fh = open(core.CACHE_LOCK_MAINTENANCE_PATH, "w")
|
|
5900
|
+
try:
|
|
5901
|
+
try:
|
|
5902
|
+
fcntl.flock(maint_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
5903
|
+
except (BlockingIOError, OSError):
|
|
5904
|
+
eprint(
|
|
5905
|
+
f"cctally: {label} VACUUM skipped: another maintenance operation "
|
|
5906
|
+
f"is running. Retry shortly."
|
|
5907
|
+
)
|
|
5908
|
+
return 3
|
|
5909
|
+
held = []
|
|
5910
|
+
try:
|
|
5911
|
+
if provider_locked:
|
|
5912
|
+
for lock_path, lname in (
|
|
5913
|
+
(core.CACHE_LOCK_PATH, "claude"),
|
|
5914
|
+
(core.CACHE_LOCK_CODEX_PATH, "codex"),
|
|
5915
|
+
):
|
|
5916
|
+
fh = open(lock_path, "w")
|
|
5917
|
+
try:
|
|
5918
|
+
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
5919
|
+
except (BlockingIOError, OSError):
|
|
5920
|
+
fh.close()
|
|
5921
|
+
eprint(
|
|
5922
|
+
f"cctally: {label} VACUUM skipped: a {lname} sync is in "
|
|
5923
|
+
f"progress. Stop the dashboard / other cctally "
|
|
5924
|
+
f"processes and retry."
|
|
5925
|
+
)
|
|
5926
|
+
return 3
|
|
5927
|
+
held.append(fh)
|
|
5928
|
+
return _run_vacuum_exclusive(path, label)
|
|
5929
|
+
finally:
|
|
5930
|
+
for fh in held:
|
|
5931
|
+
try:
|
|
5932
|
+
fcntl.flock(fh, fcntl.LOCK_UN)
|
|
5933
|
+
except OSError:
|
|
5934
|
+
pass
|
|
5935
|
+
fh.close()
|
|
5936
|
+
finally:
|
|
5937
|
+
try:
|
|
5938
|
+
fcntl.flock(maint_fh, fcntl.LOCK_UN)
|
|
5939
|
+
except OSError:
|
|
5940
|
+
pass
|
|
5941
|
+
maint_fh.close()
|
|
5942
|
+
|
|
5943
|
+
|
|
5944
|
+
def cmd_db_vacuum(args: argparse.Namespace) -> int:
|
|
5945
|
+
"""Reclaim disk space via VACUUM after a transcript prune (#313 P3, F13).
|
|
5946
|
+
|
|
5947
|
+
NEVER automatic. Holds the maintenance flock + (for cache.db) the provider
|
|
5948
|
+
flocks, then runs a checkpoint + VACUUM under a real SQLite EXCLUSIVE lock so
|
|
5949
|
+
a concurrent dashboard reader fails promptly instead of racing. Refuses when
|
|
5950
|
+
free disk is below ~2x the file + WAL. Exit 0 on success, 3 when a target is
|
|
5951
|
+
in use or disk is short."""
|
|
5952
|
+
which = getattr(args, "db", "cache")
|
|
5953
|
+
targets = []
|
|
5954
|
+
if which in ("cache", "all"):
|
|
5955
|
+
targets.append((_cctally_core.CACHE_DB_PATH, "cache.db", True))
|
|
5956
|
+
if which in ("stats", "all"):
|
|
5957
|
+
targets.append((_cctally_core.DB_PATH, "stats.db", False))
|
|
5958
|
+
overall = 0
|
|
5959
|
+
for path, label, provider_locked in targets:
|
|
5960
|
+
rc = _vacuum_one_db(path, label, provider_locked)
|
|
5961
|
+
if rc != 0:
|
|
5962
|
+
overall = rc
|
|
5963
|
+
return overall
|
package/bin/_cctally_doctor.py
CHANGED
|
@@ -187,6 +187,14 @@ def doctor_gather_state(
|
|
|
187
187
|
settings = c._load_claude_settings()
|
|
188
188
|
except c.SetupError:
|
|
189
189
|
settings = None
|
|
190
|
+
# #311: precompute the statusLine.refreshInterval state via the setup
|
|
191
|
+
# I/O-layer classifier (wrapper recognition does file scans), so the pure
|
|
192
|
+
# doctor kernel stays I/O-free. `settings is None` (SetupError) → the
|
|
193
|
+
# classifier's `unavailable`, matching the check's always-OK posture.
|
|
194
|
+
try:
|
|
195
|
+
statusline_refresh_state = c._classify_statusline_refresh(settings)[0]
|
|
196
|
+
except Exception:
|
|
197
|
+
statusline_refresh_state = "unavailable"
|
|
190
198
|
# Below: fail-soft posture for the diagnostic — any unexpected error
|
|
191
199
|
# in a sub-probe degrades that field to None rather than aborting the
|
|
192
200
|
# whole report.
|
|
@@ -777,6 +785,8 @@ def doctor_gather_state(
|
|
|
777
785
|
codex_quota_windows=codex_quota_windows,
|
|
778
786
|
codex_hook_roots=codex_hook_roots,
|
|
779
787
|
codex_lifecycle_activity_24h=codex_lifecycle_activity_24h,
|
|
788
|
+
# #311: precomputed statusLine.refreshInterval classification.
|
|
789
|
+
statusline_refresh_state=statusline_refresh_state,
|
|
780
790
|
)
|
|
781
791
|
|
|
782
792
|
|
package/bin/_cctally_parser.py
CHANGED
|
@@ -2134,6 +2134,14 @@ def _build_cache_sync_parser(subparsers, name, *, help_text, xref=None):
|
|
|
2134
2134
|
help="Prune cache rows for source files removed from disk "
|
|
2135
2135
|
"(e.g. a deleted git worktree) without a full rebuild.",
|
|
2136
2136
|
)
|
|
2137
|
+
p_cache_sync.add_argument(
|
|
2138
|
+
"--prune-conversations",
|
|
2139
|
+
action="store_true",
|
|
2140
|
+
help="Prune conversation transcripts older than "
|
|
2141
|
+
"conversation.retention_days (default 180) now, without a full "
|
|
2142
|
+
"rebuild. Re-derivable from JSONL; run `cctally db vacuum` to "
|
|
2143
|
+
"reclaim the freed disk space.",
|
|
2144
|
+
)
|
|
2137
2145
|
p_cache_sync.set_defaults(func=c.cmd_cache_sync)
|
|
2138
2146
|
|
|
2139
2147
|
def _build_project_parser(subparsers, name, *, help_text, xref=None, fixed_source=None):
|
|
@@ -2802,6 +2810,18 @@ def _build_db_parser(subparsers, name, *, help_text, xref=None):
|
|
|
2802
2810
|
help=argparse.SUPPRESS,
|
|
2803
2811
|
)
|
|
2804
2812
|
db_checkpoint.set_defaults(func=c.cmd_db_checkpoint)
|
|
2813
|
+
db_vacuum = db_sub.add_parser(
|
|
2814
|
+
"vacuum",
|
|
2815
|
+
help="Reclaim disk space after a transcript prune (VACUUM) — "
|
|
2816
|
+
"exclusive, never automatic",
|
|
2817
|
+
)
|
|
2818
|
+
db_vacuum.add_argument(
|
|
2819
|
+
"--db",
|
|
2820
|
+
choices=("cache", "stats", "all"),
|
|
2821
|
+
default="cache",
|
|
2822
|
+
help="Which DB to VACUUM (default: cache)",
|
|
2823
|
+
)
|
|
2824
|
+
db_vacuum.set_defaults(func=c.cmd_db_vacuum)
|
|
2805
2825
|
|
|
2806
2826
|
def _build_doctor_parser(subparsers, name, *, help_text, xref=None):
|
|
2807
2827
|
"""Build the `doctor` parser (registered via _REGISTRATION; #279 S6 W3).
|
package/bin/_cctally_quota.py
CHANGED
|
@@ -183,6 +183,32 @@ def _store_codex_quota_projection_certificate(
|
|
|
183
183
|
return
|
|
184
184
|
|
|
185
185
|
|
|
186
|
+
def _stats_projection_signatures_match(
|
|
187
|
+
stats_conn: sqlite3.Connection,
|
|
188
|
+
active_roots: set[str],
|
|
189
|
+
cert_sigs: Mapping[str, str],
|
|
190
|
+
) -> bool:
|
|
191
|
+
"""True iff stats.db's projection signature matches the certificate for every root.
|
|
192
|
+
|
|
193
|
+
The cache certificate alone does not prove stats.db still holds the
|
|
194
|
+
projection: stats.db can be independently wiped/recovered while cache.db
|
|
195
|
+
persists (F1). Require an exact ``quota_projection_state.physical_signature``
|
|
196
|
+
match for every active root before the reconcile is allowed to short-circuit.
|
|
197
|
+
A missing row, a mismatch, or any ``sqlite3.Error`` degrades to False, which
|
|
198
|
+
forces the full reconcile (fail-safe).
|
|
199
|
+
"""
|
|
200
|
+
try:
|
|
201
|
+
rows = stats_conn.execute(
|
|
202
|
+
"SELECT source_root_key, physical_signature FROM quota_projection_state"
|
|
203
|
+
).fetchall()
|
|
204
|
+
except sqlite3.Error:
|
|
205
|
+
return False
|
|
206
|
+
projection = {str(row[0]): str(row[1]) for row in rows}
|
|
207
|
+
return all(
|
|
208
|
+
projection.get(root) == cert_sigs.get(root) for root in active_roots
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
186
212
|
def load_codex_quota_observations(
|
|
187
213
|
*,
|
|
188
214
|
source_root_keys: Iterable[str] | None = None,
|
|
@@ -754,22 +780,48 @@ def reconcile_codex_quota_projection(
|
|
|
754
780
|
raise ValueError("now must be timezone-aware")
|
|
755
781
|
now_iso = _utc_iso(now)
|
|
756
782
|
|
|
783
|
+
alert_eligible_roots = {str(key) for key in alert_eligible_root_keys}
|
|
784
|
+
|
|
757
785
|
try:
|
|
758
786
|
cache = _cache_connection()
|
|
759
787
|
except (FileNotFoundError, sqlite3.Error):
|
|
760
788
|
return QuotaProjectionResult(None, 0, 0, 0, 0, 0, 0)
|
|
761
789
|
try:
|
|
762
|
-
active_roots
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
)
|
|
766
|
-
|
|
790
|
+
# F2: read active_roots, the physical sequence, and the certificate
|
|
791
|
+
# inside ONE WAL read snapshot so a concurrent commit cannot interleave
|
|
792
|
+
# a stale sequence with a fresh certificate.
|
|
793
|
+
cache.execute("BEGIN")
|
|
794
|
+
try:
|
|
795
|
+
active_roots = (
|
|
796
|
+
_cache_root_keys(cache)
|
|
797
|
+
if source_root_keys is None else {str(key) for key in source_root_keys}
|
|
798
|
+
)
|
|
799
|
+
physical_sequence = codex_physical_mutation_seq(cache)
|
|
800
|
+
certificate = load_codex_quota_projection_certificate(cache)
|
|
801
|
+
finally:
|
|
802
|
+
cache.commit()
|
|
803
|
+
# Short-circuit: when nothing is alert-eligible and the certificate
|
|
804
|
+
# proves the cache physical state is current AND the stats-side
|
|
805
|
+
# projection still matches it (F1), the ~2.9 s observation load and the
|
|
806
|
+
# whole reconcile are provably a no-op. Any missed concurrent write
|
|
807
|
+
# leaves cur_seq != cert_seq (or a stats-signature mismatch) on the next
|
|
808
|
+
# call, so the scheme is self-healing.
|
|
809
|
+
if not alert_eligible_roots and certificate is not None:
|
|
810
|
+
cert_seq, cert_sigs = certificate
|
|
811
|
+
if physical_sequence == cert_seq and active_roots <= set(cert_sigs):
|
|
812
|
+
stats_conn = _cctally_core.open_db()
|
|
813
|
+
try:
|
|
814
|
+
if _stats_projection_signatures_match(
|
|
815
|
+
stats_conn, active_roots, cert_sigs
|
|
816
|
+
):
|
|
817
|
+
return QuotaProjectionResult(None, 0, 0, 0, 0, 0, 0)
|
|
818
|
+
finally:
|
|
819
|
+
stats_conn.close()
|
|
767
820
|
observations = load_codex_quota_observations(
|
|
768
821
|
source_root_keys=active_roots, cache_conn=cache,
|
|
769
822
|
)
|
|
770
823
|
finally:
|
|
771
824
|
cache.close()
|
|
772
|
-
alert_eligible_roots = {str(key) for key in alert_eligible_root_keys}
|
|
773
825
|
|
|
774
826
|
# No configured roots and no existing interpreted history means there is no
|
|
775
827
|
# stats work. This preserves the existing empty-Codex sync fast path.
|