cctally 1.74.0 → 1.75.1

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.
@@ -1,4 +1,4 @@
1
- """#313 P3: 180-day conversation-transcript retention prune kernel.
1
+ """#313 P3: conversation-transcript retention prune kernel.
2
2
 
3
3
  Prunes ONLY the re-derivable transcript rows:
4
4
  * Claude: ``conversation_messages`` + their ``conversation_file_touches`` /
@@ -63,7 +63,7 @@ def _cutoff_iso(cutoff_utc: dt.datetime) -> str:
63
63
  ``...Z`` timestamps.
64
64
 
65
65
  Second-granular: the sub-second mixed-precision edge at the exact cutoff
66
- second is immaterial for a 180-day window — any message wrongly classified
66
+ second is immaterial for a multi-day retention window — any message wrongly classified
67
67
  there is re-derivable from JSONL and the boundary self-corrects on the next
68
68
  (daily) prune.
69
69
  """
@@ -197,6 +197,27 @@ def _stamp_retention_prune(conn: sqlite3.Connection, now_utc: dt.datetime) -> No
197
197
  )
198
198
 
199
199
 
200
+ def _reclaim_incremental_vacuum(conn: sqlite3.Connection) -> None:
201
+ """Drive zero-column incremental-vacuum rows to completion portably."""
202
+ remaining = int(conn.execute("PRAGMA freelist_count").fetchone()[0])
203
+ if remaining <= 0:
204
+ return
205
+ chunk_pages = 4096
206
+ max_passes = (remaining + chunk_pages - 1) // chunk_pages + 1
207
+ for _ in range(max_passes):
208
+ requested = min(remaining, chunk_pages)
209
+ # executescript() routes through sqlite3_exec(), which steps zero-column
210
+ # pragma rows through SQLITE_DONE on Python/SQLite combinations where a
211
+ # Cursor.fetchall() can stop after the first row (public Linux 3.11).
212
+ conn.executescript(f"PRAGMA incremental_vacuum({requested});")
213
+ after = int(conn.execute("PRAGMA freelist_count").fetchone()[0])
214
+ if after <= 0:
215
+ return
216
+ if after >= remaining:
217
+ return
218
+ remaining = after
219
+
220
+
200
221
  def _maybe_prune_conversation_retention(
201
222
  conn: sqlite3.Connection,
202
223
  *,
@@ -259,6 +280,25 @@ def _maybe_prune_conversation_retention(
259
280
  except Exception:
260
281
  conn.rollback()
261
282
  raise
283
+ # Return the freed pages to the OS. On an INCREMENTAL auto-vacuum
284
+ # cache.db (the default for freshly-created DBs, #313 P3) this
285
+ # shrinks the file on disk instead of leaving a growing freelist,
286
+ # so the transcript prune reclaims space automatically without a
287
+ # manual `cctally db vacuum`; on a legacy auto_vacuum=NONE cache it
288
+ # is a harmless no-op (those still reclaim via `db vacuum` or a
289
+ # `cache-sync --rebuild`). Runs OUTSIDE the committed transaction,
290
+ # still under the maintenance + provider flocks (no concurrent
291
+ # writer), and best-effort — a reclaim error must never fail the
292
+ # already-durable prune.
293
+ if stats.total_rows > 0:
294
+ try:
295
+ # Use the sqlite3_exec path and verify progress between
296
+ # bounded chunks. This clears the freelist and drops
297
+ # page_count; the physical file shrinks on the next
298
+ # `wal_checkpoint(TRUNCATE)` the sync loop forces (#297).
299
+ _reclaim_incremental_vacuum(conn)
300
+ except sqlite3.Error:
301
+ pass
262
302
  return stats
263
303
  finally:
264
304
  try:
@@ -1650,10 +1650,10 @@ def _check_db_integrity(s: DoctorState) -> CheckResult:
1650
1650
  summary=f"stats.db quick_check: {s.stats_db_quick_check}",
1651
1651
  remediation=(
1652
1652
  "stats.db (the non-re-derivable DB) reports corruption. "
1653
- "Back up the file FIRST, then try "
1654
- "`sqlite3 <path> \".recover\"` into a fresh file; "
1655
- "`cctally db recover` does not repair corruption. Please "
1656
- "file a bug. Do not delete the file."),
1653
+ "Stop the dashboard and other cctally processes, then run "
1654
+ "`cctally db repair --db stats --yes`. The command preserves "
1655
+ "a backup of the corrupt original before replacing anything. "
1656
+ "Do not copy, restore, move, or delete the live DB by hand."),
1657
1657
  details=details,
1658
1658
  )
1659
1659
  if s.cache_db_quick_check is not None and s.cache_db_quick_check != "ok":
package/bin/cctally CHANGED
@@ -933,6 +933,9 @@ Migration = _cctally_db.Migration
933
933
  DowngradeDetected = _cctally_db.DowngradeDetected
934
934
  ProdMigrationRefused = _cctally_db.ProdMigrationRefused
935
935
  StatsDbCorruptError = _cctally_db.StatsDbCorruptError
936
+ StatsDbMaintenanceError = _cctally_db.StatsDbMaintenanceError
937
+ _is_sqlite_corruption_error = _cctally_db._is_sqlite_corruption_error
938
+ _stats_corruption_guidance = _cctally_db._stats_corruption_guidance
936
939
  _STATS_MIGRATIONS = _cctally_db._STATS_MIGRATIONS
937
940
  _CACHE_MIGRATIONS = _cctally_db._CACHE_MIGRATIONS
938
941
  _make_migration_decorator = _cctally_db._make_migration_decorator
@@ -957,6 +960,8 @@ _db_path_for_label = _cctally_db._db_path_for_label
957
960
  cmd_db_skip = _cctally_db.cmd_db_skip
958
961
  cmd_db_unskip = _cctally_db.cmd_db_unskip
959
962
  cmd_db_recover = _cctally_db.cmd_db_recover
963
+ cmd_db_repair = _cctally_db.cmd_db_repair
964
+ cmd_db_backup = _cctally_db.cmd_db_backup
960
965
  cmd_db_checkpoint = _cctally_db.cmd_db_checkpoint
961
966
  cmd_db_vacuum = _cctally_db.cmd_db_vacuum
962
967
 
@@ -1384,6 +1389,7 @@ cmd_codex_quota_statusline = _cctally_quota.cmd_codex_quota_statusline
1384
1389
  cmd_codex_quota_forecast = _cctally_quota.cmd_codex_quota_forecast
1385
1390
  cmd_codex_quota_blocks = _cctally_quota.cmd_codex_quota_blocks
1386
1391
  cmd_codex_quota_breakdown = _cctally_quota.cmd_codex_quota_breakdown
1392
+ cmd_codex_percent_breakdown = _cctally_quota.cmd_codex_percent_breakdown
1387
1393
 
1388
1394
  # S3's qualified Codex accounting adapter is intentionally cache-only: callers
1389
1395
  # that need project identity must not downgrade to an unqualified rollout parse.
@@ -2926,6 +2932,7 @@ _is_reset_drop = _cctally_weekrefs._is_reset_drop
2926
2932
  # handler (C8, #125 Batch B). Parser dispatch reaches c.cmd_percent_breakdown.
2927
2933
  _cctally_percent_breakdown = _load_sibling("_cctally_percent_breakdown")
2928
2934
  cmd_percent_breakdown = _cctally_percent_breakdown.cmd_percent_breakdown
2935
+ _render_percent_breakdown_terminal = _cctally_percent_breakdown._render_percent_breakdown_terminal
2929
2936
 
2930
2937
 
2931
2938
  # _is_cctally_hook_command, SetupError, _load_claude_settings,
@@ -3207,7 +3214,26 @@ def main(argv: list[str] | None = None) -> int:
3207
3214
  # + recovery guidance, exit 2. Caught before the generic handler so it
3208
3215
  # never renders as a raw traceback.
3209
3216
  eprint(f"cctally: {exc}")
3210
- return 2
3217
+ return 3
3218
+ except StatsDbMaintenanceError as exc:
3219
+ eprint(f"cctally: {exc}")
3220
+ return 3
3221
+ except sqlite3.DatabaseError as exc:
3222
+ if _is_sqlite_corruption_error(exc):
3223
+ eprint(
3224
+ "cctally: a SQLite database is corrupt — run `cctally doctor` "
3225
+ "to identify it. For stats.db run `cctally db repair --db "
3226
+ "stats --yes`; cache.db is re-derivable via `cctally "
3227
+ "cache-sync --rebuild`."
3228
+ )
3229
+ return 3
3230
+ if _lib_log.debug_enabled():
3231
+ _lib_log.get_logger("cli").debug(
3232
+ "unhandled exception in %r",
3233
+ getattr(args, "command", None), exc_info=True,
3234
+ )
3235
+ eprint(f"Error: {exc}")
3236
+ rc = 1
3211
3237
  except Exception as exc: # pragma: no cover
3212
3238
  # #279 S2 F2: CCTALLY_DEBUG=1 surfaces the full traceback on stderr
3213
3239
  # via the _lib_log chokepoint; default mode stays byte-identical.