cctally 1.65.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/bin/_cctally_alerts.py +1 -1
  3. package/bin/_cctally_cache.py +203 -22
  4. package/bin/_cctally_cache_report.py +7 -5
  5. package/bin/_cctally_core.py +49 -14
  6. package/bin/_cctally_dashboard.py +481 -4891
  7. package/bin/_cctally_dashboard_cache_report.py +714 -0
  8. package/bin/_cctally_dashboard_conversation.py +771 -0
  9. package/bin/_cctally_dashboard_envelope.py +1520 -0
  10. package/bin/_cctally_dashboard_share.py +2012 -0
  11. package/bin/_cctally_db.py +127 -5
  12. package/bin/_cctally_doctor.py +104 -3
  13. package/bin/_cctally_five_hour.py +2 -1
  14. package/bin/_cctally_forecast.py +78 -135
  15. package/bin/_cctally_parser.py +919 -784
  16. package/bin/_cctally_percent_breakdown.py +1 -1
  17. package/bin/_cctally_pricing_check.py +39 -0
  18. package/bin/_cctally_project.py +8 -5
  19. package/bin/_cctally_record.py +279 -274
  20. package/bin/_cctally_reporting.py +1 -1
  21. package/bin/_cctally_sync_week.py +1 -1
  22. package/bin/_cctally_telemetry.py +3 -5
  23. package/bin/_cctally_tui.py +42 -18
  24. package/bin/_lib_alert_dispatch.py +1 -1
  25. package/bin/_lib_blocks.py +6 -1
  26. package/bin/_lib_credit.py +133 -0
  27. package/bin/_lib_doctor.py +150 -1
  28. package/bin/_lib_five_hour.py +34 -0
  29. package/bin/_lib_forecast.py +144 -0
  30. package/bin/_lib_json_envelope.py +38 -0
  31. package/bin/_lib_jsonl.py +115 -73
  32. package/bin/_lib_log.py +96 -0
  33. package/bin/_lib_pricing.py +47 -1
  34. package/bin/_lib_pricing_check.py +25 -3
  35. package/bin/_lib_record.py +179 -0
  36. package/bin/_lib_render.py +18 -10
  37. package/bin/_lib_snapshot_cache.py +61 -0
  38. package/bin/cctally +51 -7
  39. package/bin/cctally-dashboard +2 -2
  40. package/bin/cctally-statusline +1 -0
  41. package/bin/cctally-tui +2 -2
  42. package/package.json +10 -1
@@ -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 os.environ.get("CCTALLY_ALLOW_PROD_MIGRATION"):
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 os.environ.get("CCTALLY_DEBUG"):
879
- eprint(
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")
@@ -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
- repo_root = c._setup_resolve_repo_root()
63
- dst_dir = c._setup_local_bin_dir()
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
- state = c.doctor_gather_state()
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('{\n "blocks": [],\n "message": "No active block"\n}')
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