cctally 1.64.0 → 1.66.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 +50 -0
- package/bin/_cctally_alerts.py +1 -1
- package/bin/_cctally_cache.py +262 -40
- package/bin/_cctally_cache_report.py +7 -5
- package/bin/_cctally_core.py +49 -14
- package/bin/_cctally_dashboard.py +827 -4911
- package/bin/_cctally_dashboard_cache_report.py +714 -0
- package/bin/_cctally_dashboard_conversation.py +771 -0
- package/bin/_cctally_dashboard_envelope.py +1520 -0
- package/bin/_cctally_dashboard_share.py +2012 -0
- package/bin/_cctally_db.py +127 -5
- package/bin/_cctally_doctor.py +104 -3
- package/bin/_cctally_five_hour.py +2 -1
- package/bin/_cctally_forecast.py +78 -135
- package/bin/_cctally_parser.py +919 -784
- package/bin/_cctally_percent_breakdown.py +1 -1
- package/bin/_cctally_pricing_check.py +39 -0
- package/bin/_cctally_project.py +8 -5
- package/bin/_cctally_record.py +279 -274
- package/bin/_cctally_reporting.py +1 -1
- package/bin/_cctally_sync_week.py +1 -1
- package/bin/_cctally_telemetry.py +3 -5
- package/bin/_cctally_tui.py +487 -254
- package/bin/_cctally_update.py +5 -0
- package/bin/_lib_alert_dispatch.py +1 -1
- package/bin/_lib_blocks.py +6 -1
- package/bin/_lib_conversation_query.py +349 -36
- package/bin/_lib_credit.py +133 -0
- package/bin/_lib_doctor.py +150 -1
- package/bin/_lib_five_hour.py +34 -0
- package/bin/_lib_forecast.py +144 -0
- package/bin/_lib_json_envelope.py +38 -0
- package/bin/_lib_jsonl.py +115 -73
- package/bin/_lib_log.py +96 -0
- package/bin/_lib_perf.py +180 -0
- package/bin/_lib_pricing.py +47 -1
- package/bin/_lib_pricing_check.py +25 -3
- package/bin/_lib_record.py +179 -0
- package/bin/_lib_render.py +18 -10
- package/bin/_lib_snapshot_cache.py +61 -0
- package/bin/_lib_transcript_access.py +23 -0
- package/bin/cctally +51 -7
- package/bin/cctally-dashboard +2 -2
- package/bin/cctally-statusline +1 -0
- package/bin/cctally-tui +2 -2
- package/dashboard/static/assets/index-CJTUCpKt.js +80 -0
- package/dashboard/static/assets/index-DQWNrIqu.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +11 -1
- package/dashboard/static/assets/index-0jzYm75p.css +0 -1
- package/dashboard/static/assets/index-D_Ylyqsf.js +0 -80
package/bin/_cctally_db.py
CHANGED
|
@@ -91,6 +91,11 @@ from _cctally_core import (
|
|
|
91
91
|
parse_iso_datetime,
|
|
92
92
|
)
|
|
93
93
|
|
|
94
|
+
# #279 S2 F2: demonstration adopter of the stdlib-logging chokepoint —
|
|
95
|
+
# the migration gate-defer diagnostic routes through _lib_log so
|
|
96
|
+
# CCTALLY_DEBUG verbosity is decided in one place.
|
|
97
|
+
import _lib_log
|
|
98
|
+
|
|
94
99
|
# Stats migration 008 needs the same per-entry cost computation used by
|
|
95
100
|
# the live cost-report path. Direct import keeps the kernel single-sourced
|
|
96
101
|
# (no shim drift); _lib_pricing is a stdlib-only leaf module so no cycle
|
|
@@ -237,6 +242,19 @@ class ProdMigrationRefused(Exception):
|
|
|
237
242
|
)
|
|
238
243
|
|
|
239
244
|
|
|
245
|
+
class StatsDbCorruptError(sqlite3.DatabaseError):
|
|
246
|
+
"""stats.db failed the open-time probe (#279 S1 F4).
|
|
247
|
+
|
|
248
|
+
Subclasses ``sqlite3.DatabaseError`` so graceful-degrade sites (doctor,
|
|
249
|
+
dashboard/TUI background threads, the 5h-anchor fallback) keep treating it
|
|
250
|
+
as a DB failure exactly as before — but command-level handlers that map DB
|
|
251
|
+
errors to OTHER exit codes must re-raise it so the global exit-2 diagnosis
|
|
252
|
+
wins (``cmd_record_credit`` does; its documented DB-error exit is 3). NOT
|
|
253
|
+
auto-recreated: stats.db is the non-re-derivable DB (recorded usage
|
|
254
|
+
history), unlike cache.db.
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
|
|
240
258
|
class MigrationGateNotMet(Exception):
|
|
241
259
|
"""Migration cannot run yet because a cross-DB prerequisite is unsatisfied.
|
|
242
260
|
|
|
@@ -383,12 +401,44 @@ def _make_migration_decorator(registry: list[Migration], db_label: str, name: st
|
|
|
383
401
|
|
|
384
402
|
|
|
385
403
|
def stats_migration(name: str):
|
|
386
|
-
"""Register a stats.db migration. Use as @stats_migration("NNN_descriptive_name").
|
|
404
|
+
"""Register a stats.db migration. Use as @stats_migration("NNN_descriptive_name").
|
|
405
|
+
|
|
406
|
+
HANDLER CONTRACT — idempotency is MANDATORY, not best-effort. The
|
|
407
|
+
dispatcher applies a handler and stamps its ``schema_migrations`` marker in
|
|
408
|
+
a TWO-TRANSACTION window (``m.handler(conn)`` commits the data change, THEN
|
|
409
|
+
``_stamp_applied`` writes the marker in a separate transaction — see
|
|
410
|
+
``_run_pending_migrations``). A crash BETWEEN those two commits leaves the
|
|
411
|
+
data change applied but unmarked, so the NEXT ``open_db()`` re-invokes the
|
|
412
|
+
handler. A handler that is not idempotent (re-derives from already-migrated
|
|
413
|
+
state, double-applies, or raises on a second run) corrupts or bricks the DB
|
|
414
|
+
on that retry. Every handler MUST therefore be safe to run again on its own
|
|
415
|
+
output: probe-then-return on the already-applied shape (a fast-path guard
|
|
416
|
+
like ``if 'col' in cols(...): return``), or use ``INSERT OR IGNORE`` /
|
|
417
|
+
``UPDATE … WHERE not-yet-migrated`` / upsert idioms. The same contract
|
|
418
|
+
covers a ``MigrationGateNotMet`` deferral: the dispatcher leaves the
|
|
419
|
+
migration pending and re-runs it on a later open, which is just another
|
|
420
|
+
handler retry. Carve-out: cache ``001_dedup_highest_wins`` stamps its marker
|
|
421
|
+
ATOMICALLY inside its own ``BEGIN IMMEDIATE`` (the destructive wipe and the
|
|
422
|
+
stamp must commit together), so it has no two-transaction window — but it is
|
|
423
|
+
still idempotent (its in-txn ``already_applied`` re-check). Per-migration
|
|
424
|
+
goldens (``tests/test_*_per_migration_goldens.py``) pin the second-invocation
|
|
425
|
+
no-op; the registry-completeness guard enforces one per migration.
|
|
426
|
+
"""
|
|
387
427
|
return _make_migration_decorator(_STATS_MIGRATIONS, "stats.db", name)
|
|
388
428
|
|
|
389
429
|
|
|
390
430
|
def cache_migration(name: str):
|
|
391
|
-
"""Register a cache.db migration. Use as @cache_migration("NNN_descriptive_name").
|
|
431
|
+
"""Register a cache.db migration. Use as @cache_migration("NNN_descriptive_name").
|
|
432
|
+
|
|
433
|
+
HANDLER CONTRACT — idempotency is MANDATORY. See ``stats_migration`` above
|
|
434
|
+
for the full statement: the dispatcher's handler-commit → marker-stamp
|
|
435
|
+
two-transaction window means a crash (or a ``MigrationGateNotMet`` deferral)
|
|
436
|
+
re-invokes the handler on its own output, so every handler must be safe to
|
|
437
|
+
re-run (probe-then-return / ``INSERT OR IGNORE`` / not-yet-migrated guards).
|
|
438
|
+
The flag-only re-ingest cache migrations (003-007, 009, 014, 017) satisfy it
|
|
439
|
+
by ``_set_cache_meta`` upsert; ``001_dedup_highest_wins`` is the lone
|
|
440
|
+
atomic-stamp carve-out.
|
|
441
|
+
"""
|
|
392
442
|
return _make_migration_decorator(_CACHE_MIGRATIONS, "cache.db", name)
|
|
393
443
|
|
|
394
444
|
|
|
@@ -480,7 +530,7 @@ def _would_block_prod_migration(conn: sqlite3.Connection) -> bool:
|
|
|
480
530
|
never trip it; HOME-faking-immune via _real_prod_data_dir (password DB, not
|
|
481
531
|
$HOME); suppressor-INDEPENDENT raw .git check so it still fires under the
|
|
482
532
|
test-suite's CCTALLY_DISABLE_DEV_AUTODETECT. Escape: CCTALLY_ALLOW_PROD_MIGRATION."""
|
|
483
|
-
if
|
|
533
|
+
if _cctally_core._truthy_env("CCTALLY_ALLOW_PROD_MIGRATION"):
|
|
484
534
|
return False
|
|
485
535
|
if not (_cctally_core._repo_root() / ".git").exists():
|
|
486
536
|
return False
|
|
@@ -875,8 +925,8 @@ def _run_pending_migrations(
|
|
|
875
925
|
# outcome (apply OR gate-defer) clears any prior failure log
|
|
876
926
|
# for this migration's qualified name.
|
|
877
927
|
_clear_migration_error_log_entries(qualified_name)
|
|
878
|
-
if
|
|
879
|
-
|
|
928
|
+
if _lib_log.debug_enabled():
|
|
929
|
+
_lib_log.get_logger("db").debug(
|
|
880
930
|
f"[migration {qualified_name}] deferred: {gate_exc}"
|
|
881
931
|
)
|
|
882
932
|
# D2 — ``continue``, NOT ``break``. A gate-defer leaves the DB
|
|
@@ -2500,6 +2550,24 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
|
|
|
2500
2550
|
conn.execute(
|
|
2501
2551
|
"CREATE INDEX IF NOT EXISTS idx_entries_mutation_seq "
|
|
2502
2552
|
"ON session_entries(mutation_seq, mutation_min_ts)")
|
|
2553
|
+
# #279 S3 F3: DB-level idempotency backstop mirroring
|
|
2554
|
+
# codex_session_entries' UNIQUE(source_path, line_offset). Guarded and
|
|
2555
|
+
# OUTSIDE the top executescript: this function runs on EVERY open BEFORE the
|
|
2556
|
+
# migration dispatcher, so a legacy cache.db holding historical
|
|
2557
|
+
# physical-key duplicates must tolerate the index being ABSENT until cache
|
|
2558
|
+
# migration 020 dedups it — an unguarded CREATE UNIQUE INDEX here would
|
|
2559
|
+
# brick every open of such a DB before 020 could ever run. Fresh and clean
|
|
2560
|
+
# DBs get the index immediately; a dirty legacy one gets it from 020 (or the
|
|
2561
|
+
# first open after 020 dedups the duplicates). Kept after the
|
|
2562
|
+
# add_column_if_missing calls (like idx_entries_mutation_seq) so the columns
|
|
2563
|
+
# exist, and BEFORE the legacy-FTS early-return so an old-shape DB still
|
|
2564
|
+
# receives it.
|
|
2565
|
+
try:
|
|
2566
|
+
conn.execute(
|
|
2567
|
+
"CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_physical "
|
|
2568
|
+
"ON session_entries(source_path, line_offset)")
|
|
2569
|
+
except sqlite3.IntegrityError:
|
|
2570
|
+
pass
|
|
2503
2571
|
# Existing-DB guard for the skill-content fold link (cctally-dev
|
|
2504
2572
|
# skill-content-nesting): the message-level sourceToolUseID. Idempotent
|
|
2505
2573
|
# column-add (no marker, no version); cache migration 006 then re-ingests
|
|
@@ -3865,6 +3933,60 @@ def _019_create_conversation_file_touches(conn: sqlite3.Connection) -> None:
|
|
|
3865
3933
|
conn.commit()
|
|
3866
3934
|
|
|
3867
3935
|
|
|
3936
|
+
@cache_migration("020_session_entries_physical_unique")
|
|
3937
|
+
def _020_session_entries_physical_unique(conn: sqlite3.Connection) -> None:
|
|
3938
|
+
"""#279 S3 F3: dedup historical (source_path, line_offset) duplicates
|
|
3939
|
+
(keep-first-id) and ensure the physical-key UNIQUE backstop index exists.
|
|
3940
|
+
|
|
3941
|
+
Duplicated physical keys are strictly ingest-bug artifacts (spec §4: correct
|
|
3942
|
+
offset bookkeeping cannot produce them) with no principled winner; MIN(id)
|
|
3943
|
+
keeps the first-ingested row, matching the pre-bug state. cache.db is
|
|
3944
|
+
re-derivable — `cache-sync --rebuild` remains the escape hatch. Mirrors
|
|
3945
|
+
cache 001's flock-then-BEGIN-IMMEDIATE pattern (#105): mutual exclusion with
|
|
3946
|
+
a mid-walk sync_cache, deferring via MigrationGateNotMet on contention.
|
|
3947
|
+
|
|
3948
|
+
Fresh installs never run this handler: the dispatcher stamps it WITHOUT
|
|
3949
|
+
running (the fresh-install branch), and `_apply_cache_schema` already created
|
|
3950
|
+
the index on the fresh DB. NO self-stamp — the dispatcher central-stamps on a
|
|
3951
|
+
clean return (#140). Run-twice safe: the DELETE is idempotent (no dupes left
|
|
3952
|
+
on a second run) and the index create is `IF NOT EXISTS`; safe on an empty
|
|
3953
|
+
table.
|
|
3954
|
+
"""
|
|
3955
|
+
lock_path = _cache_db_lock_path_for_conn(conn)
|
|
3956
|
+
lock_fh = None
|
|
3957
|
+
if lock_path is not None:
|
|
3958
|
+
lock_fh = open(lock_path, "w")
|
|
3959
|
+
try:
|
|
3960
|
+
fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
3961
|
+
except BlockingIOError:
|
|
3962
|
+
lock_fh.close()
|
|
3963
|
+
raise MigrationGateNotMet(
|
|
3964
|
+
"cache.db.lock held by a concurrent sync_cache; deferring "
|
|
3965
|
+
"cache 020 physical-unique dedup (#279 S3)"
|
|
3966
|
+
)
|
|
3967
|
+
try:
|
|
3968
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
3969
|
+
try:
|
|
3970
|
+
conn.execute(
|
|
3971
|
+
"DELETE FROM session_entries WHERE id NOT IN ("
|
|
3972
|
+
"SELECT MIN(id) FROM session_entries "
|
|
3973
|
+
"GROUP BY source_path, line_offset)")
|
|
3974
|
+
conn.execute(
|
|
3975
|
+
"CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_physical "
|
|
3976
|
+
"ON session_entries(source_path, line_offset)")
|
|
3977
|
+
conn.commit()
|
|
3978
|
+
except Exception:
|
|
3979
|
+
conn.rollback()
|
|
3980
|
+
raise
|
|
3981
|
+
finally:
|
|
3982
|
+
if lock_fh is not None:
|
|
3983
|
+
try:
|
|
3984
|
+
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
3985
|
+
except OSError:
|
|
3986
|
+
pass
|
|
3987
|
+
lock_fh.close()
|
|
3988
|
+
|
|
3989
|
+
|
|
3868
3990
|
# === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
|
|
3869
3991
|
|
|
3870
3992
|
@stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
|
package/bin/_cctally_doctor.py
CHANGED
|
@@ -25,6 +25,7 @@ from __future__ import annotations
|
|
|
25
25
|
|
|
26
26
|
import argparse
|
|
27
27
|
import datetime as dt
|
|
28
|
+
import fcntl
|
|
28
29
|
import json
|
|
29
30
|
import pathlib
|
|
30
31
|
import shutil
|
|
@@ -45,12 +46,18 @@ def doctor_gather_state(
|
|
|
45
46
|
*,
|
|
46
47
|
now_utc: "dt.datetime | None" = None,
|
|
47
48
|
runtime_bind: "str | None" = None,
|
|
49
|
+
deep: bool = False,
|
|
48
50
|
):
|
|
49
51
|
"""I/O chokepoint for `cctally doctor` (spec §7.2).
|
|
50
52
|
|
|
51
53
|
H1 invariant: config.json is read RAW (NOT via load_config), since
|
|
52
54
|
load_config auto-creates the file on first run — a read-only
|
|
53
55
|
diagnostic command must never mutate user state.
|
|
56
|
+
|
|
57
|
+
`deep=True` (CLI cmd_doctor only) additionally runs `PRAGMA
|
|
58
|
+
quick_check(1)` on each DB (#279 S2 F5b); the dashboard/TUI callers
|
|
59
|
+
stay `deep=False` — the rebuild loop calls the gather every rebuild
|
|
60
|
+
and quick_check on a large cache.db costs seconds.
|
|
54
61
|
"""
|
|
55
62
|
import _lib_doctor
|
|
56
63
|
|
|
@@ -59,8 +66,17 @@ def doctor_gather_state(
|
|
|
59
66
|
now_utc = _now_utc()
|
|
60
67
|
|
|
61
68
|
# ── Install ──────────────────────────────────────────────────────
|
|
62
|
-
|
|
63
|
-
|
|
69
|
+
# #279 S2 F5d: guard the only two unguarded statements in the
|
|
70
|
+
# otherwise fail-soft gather — an exception here would kill the whole
|
|
71
|
+
# report. Downstream consumers already degrade on None.
|
|
72
|
+
try:
|
|
73
|
+
repo_root = c._setup_resolve_repo_root()
|
|
74
|
+
except Exception:
|
|
75
|
+
repo_root = None
|
|
76
|
+
try:
|
|
77
|
+
dst_dir = c._setup_local_bin_dir()
|
|
78
|
+
except Exception:
|
|
79
|
+
dst_dir = None
|
|
64
80
|
try:
|
|
65
81
|
symlink_state = c._setup_compute_symlink_state(repo_root, dst_dir)
|
|
66
82
|
except Exception:
|
|
@@ -382,6 +398,81 @@ def doctor_gather_state(
|
|
|
382
398
|
except Exception:
|
|
383
399
|
pass
|
|
384
400
|
|
|
401
|
+
# ── Parse health (#279 S2 F5a) ───────────────────────────────────
|
|
402
|
+
parse_health_claude = parse_health_codex = None
|
|
403
|
+
try:
|
|
404
|
+
if _cctally_core.CACHE_DB_PATH.exists():
|
|
405
|
+
conn = sqlite3.connect(str(_cctally_core.CACHE_DB_PATH))
|
|
406
|
+
try:
|
|
407
|
+
for _key in ("parse_health_claude", "parse_health_codex"):
|
|
408
|
+
try:
|
|
409
|
+
row = conn.execute(
|
|
410
|
+
"SELECT value FROM cache_meta WHERE key = ?",
|
|
411
|
+
(_key,),
|
|
412
|
+
).fetchone()
|
|
413
|
+
if row and row[0]:
|
|
414
|
+
_parsed = json.loads(row[0])
|
|
415
|
+
if isinstance(_parsed, dict):
|
|
416
|
+
if _key == "parse_health_claude":
|
|
417
|
+
parse_health_claude = _parsed
|
|
418
|
+
else:
|
|
419
|
+
parse_health_codex = _parsed
|
|
420
|
+
except (sqlite3.OperationalError, ValueError):
|
|
421
|
+
pass
|
|
422
|
+
finally:
|
|
423
|
+
conn.close()
|
|
424
|
+
except Exception:
|
|
425
|
+
pass
|
|
426
|
+
|
|
427
|
+
# ── Integrity (deep only — #279 S2 F5b) ──────────────────────────
|
|
428
|
+
stats_db_quick_check = cache_db_quick_check = None
|
|
429
|
+
if deep:
|
|
430
|
+
for _label, _path in (("stats", _cctally_core.DB_PATH),
|
|
431
|
+
("cache", _cctally_core.CACHE_DB_PATH)):
|
|
432
|
+
_result = None
|
|
433
|
+
try:
|
|
434
|
+
if _path.exists():
|
|
435
|
+
_conn = sqlite3.connect(str(_path))
|
|
436
|
+
try:
|
|
437
|
+
_row = _conn.execute(
|
|
438
|
+
"PRAGMA quick_check(1)").fetchone()
|
|
439
|
+
_result = (str(_row[0])
|
|
440
|
+
if _row and _row[0] is not None else None)
|
|
441
|
+
finally:
|
|
442
|
+
_conn.close()
|
|
443
|
+
except sqlite3.DatabaseError as exc:
|
|
444
|
+
_result = f"open failed: {exc}"
|
|
445
|
+
except Exception:
|
|
446
|
+
_result = None
|
|
447
|
+
if _label == "stats":
|
|
448
|
+
stats_db_quick_check = _result
|
|
449
|
+
else:
|
|
450
|
+
cache_db_quick_check = _result
|
|
451
|
+
|
|
452
|
+
# ── Lock state (#279 S2 F5c) — read-only: never create files ─────
|
|
453
|
+
locks_held: "dict | None" = None
|
|
454
|
+
try:
|
|
455
|
+
locks_held = {}
|
|
456
|
+
for _name, _lp in (
|
|
457
|
+
("cache.db.lock", _cctally_core.CACHE_LOCK_PATH),
|
|
458
|
+
("cache.db.codex.lock", _cctally_core.CACHE_LOCK_CODEX_PATH),
|
|
459
|
+
):
|
|
460
|
+
if not _lp.exists():
|
|
461
|
+
locks_held[_name] = False
|
|
462
|
+
continue
|
|
463
|
+
try:
|
|
464
|
+
with open(_lp, "r") as _lf:
|
|
465
|
+
try:
|
|
466
|
+
fcntl.flock(_lf, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
467
|
+
fcntl.flock(_lf, fcntl.LOCK_UN)
|
|
468
|
+
locks_held[_name] = False
|
|
469
|
+
except OSError:
|
|
470
|
+
locks_held[_name] = True
|
|
471
|
+
except OSError:
|
|
472
|
+
locks_held[_name] = None
|
|
473
|
+
except Exception:
|
|
474
|
+
locks_held = None
|
|
475
|
+
|
|
385
476
|
# ── Safety ───────────────────────────────────────────────────────
|
|
386
477
|
# `dashboard.bind` is read via the same chokepoint that powers
|
|
387
478
|
# `cctally config get dashboard.bind` — `_config_known_value`
|
|
@@ -548,6 +639,13 @@ def doctor_gather_state(
|
|
|
548
639
|
conv_sessions_rollup_count=conv_sessions_rollup_count,
|
|
549
640
|
conv_messages_distinct_sessions=conv_messages_distinct_sessions,
|
|
550
641
|
conv_rollup_sync_in_progress=conv_rollup_sync_in_progress,
|
|
642
|
+
# #279 S2 F5: parse-health records, deep quick_check results, and
|
|
643
|
+
# non-blocking lock-file probes (appended after the defaulted tail).
|
|
644
|
+
parse_health_claude=parse_health_claude,
|
|
645
|
+
parse_health_codex=parse_health_codex,
|
|
646
|
+
stats_db_quick_check=stats_db_quick_check,
|
|
647
|
+
cache_db_quick_check=cache_db_quick_check,
|
|
648
|
+
locks_held=locks_held,
|
|
551
649
|
)
|
|
552
650
|
|
|
553
651
|
|
|
@@ -572,7 +670,10 @@ def cmd_doctor(args: argparse.Namespace) -> int:
|
|
|
572
670
|
if quiet and verbose:
|
|
573
671
|
eprint("doctor: --quiet and --verbose are mutually exclusive")
|
|
574
672
|
return 2
|
|
575
|
-
|
|
673
|
+
# #279 S2 F5b: deep=True runs PRAGMA quick_check(1) — CLI-only (the
|
|
674
|
+
# dashboard/TUI gather callers stay deep=False so their per-rebuild
|
|
675
|
+
# gather never pays the multi-second cost on a large cache.db).
|
|
676
|
+
state = c.doctor_gather_state(deep=True)
|
|
576
677
|
report = _lib_doctor.run_checks(state)
|
|
577
678
|
if getattr(args, "json", False):
|
|
578
679
|
print(json.dumps(
|
|
@@ -708,7 +708,8 @@ def cmd_blocks(args: argparse.Namespace) -> int:
|
|
|
708
708
|
blocks = [b for b in blocks if b.is_active and not b.is_gap]
|
|
709
709
|
if not blocks:
|
|
710
710
|
if args.json:
|
|
711
|
-
print(
|
|
711
|
+
print(json.dumps(_c.stamp_schema_version(
|
|
712
|
+
{"blocks": [], "message": "No active block"}), indent=2))
|
|
712
713
|
else:
|
|
713
714
|
print("No active session block found.")
|
|
714
715
|
return 0
|