cctally 1.6.3 → 1.7.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.
@@ -0,0 +1,1729 @@
1
+ """Stats.db / cache.db migration framework, dispatcher, error-banner render, `cctally db` subcommands.
2
+
3
+ Eager I/O sibling: bin/cctally loads this at startup. The framework
4
+ registers its three production stats.db migration handlers
5
+ (`001_five_hour_block_models_backfill_v1`,
6
+ `002_five_hour_block_projects_backfill_v1`,
7
+ `003_merge_5h_block_duplicates_v1`) and the conditional test-only
8
+ migrations (gated on `CCTALLY_MIGRATION_TEST_MODE=1` +
9
+ `HARNESS_FAKE_HOME_BASE`) at module-load time. Subsequent imports
10
+ through `_load_sibling` hit `sys.modules` cache and reuse the
11
+ populated registry — re-imports under SourceFileLoader DO NOT
12
+ re-execute the decorator chain, which preserves the
13
+ "registry length == NNN" invariant per-DB.
14
+
15
+ Holds:
16
+ - ``_MIGRATION_IDENT_RE``, ``add_column_if_missing`` — idempotent
17
+ column-shape guard. Used by ``open_db`` / ``open_cache_db`` from
18
+ bin/cctally via the eager re-export.
19
+ - ``_MIGRATION_NAME_RE``, ``Migration`` (frozen dataclass),
20
+ ``DowngradeDetected`` exception, ``_STATS_MIGRATIONS`` /
21
+ ``_CACHE_MIGRATIONS`` registries, ``_make_migration_decorator``,
22
+ ``stats_migration`` / ``cache_migration`` decorator factories,
23
+ ``_LEGACY_MARKER_ALIASES_BY_DB`` + ``_bootstrap_rename_legacy_markers``,
24
+ ``_run_pending_migrations`` (the dispatcher).
25
+ - The three production migration handlers + the test-only
26
+ migration-registration block.
27
+ - ``_log_migration_error``, ``_clear_migration_error_log_entries``,
28
+ ``_render_migration_error_banner``,
29
+ ``_print_migration_error_banner_if_needed`` — the migration-error
30
+ sentinel surface (single source of truth per CLAUDE.md gotcha).
31
+ - ``cmd_db_status`` / ``cmd_db_skip`` / ``cmd_db_unskip`` plus
32
+ helpers (``_db_status_for``, ``_db_status_failed_names_from_log``,
33
+ ``_db_status_format_row``, ``_db_resolve_migration_name``,
34
+ ``_db_path_for_label``).
35
+
36
+ What stays in bin/cctally (reached via the ``_cctally()`` accessor):
37
+ - Path constants ``STATS_DB_PATH``, ``CACHE_DB_PATH``,
38
+ ``MIGRATION_ERROR_LOG_PATH``, ``LOG_DIR`` (spec §86–92 — every
39
+ path constant stays so monkeypatched HOME redirects propagate).
40
+ - ``open_db`` / ``open_cache_db`` — DB-open primitives that CALL
41
+ the dispatcher; they're the boundary owners, not internal to the
42
+ migration system.
43
+ - ``now_utc_iso``, ``parse_iso_datetime``, ``_compute_block_totals``,
44
+ ``eprint``, ``format_local_iso`` — tiny helpers / hot-path entry
45
+ points consumed by migration handlers + cmd_db_status renderers.
46
+
47
+ §5.6 audit: zero monkeypatch sites on any moved symbol — the
48
+ extraction is pure-mechanical. No Option C call-site rewrites
49
+ required for test propagation.
50
+
51
+ Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
52
+ """
53
+ from __future__ import annotations
54
+
55
+ import argparse
56
+ import datetime as dt
57
+ import json
58
+ import os
59
+ import pathlib
60
+ import re
61
+ import sqlite3
62
+ import sys
63
+ import time
64
+ import traceback
65
+ from dataclasses import dataclass
66
+ from typing import Any, Callable
67
+
68
+
69
+ def _cctally():
70
+ """Resolve the current `cctally` module at call-time (spec §5.5)."""
71
+ return sys.modules["cctally"]
72
+
73
+
74
+ # Module-level back-ref shims for the four callables most heavily used
75
+ # across migration handlers + cmd_db_* renderers. Each shim resolves
76
+ # `sys.modules['cctally'].X` at CALL TIME (not bind time), so
77
+ # monkeypatches on cctally's namespace propagate into the moved code
78
+ # unchanged. This lets the moved function bodies stay byte-identical
79
+ # at every bare-name call site (`now_utc_iso(...)`,
80
+ # `parse_iso_datetime(...)`, etc.) without requiring per-function
81
+ # `c = _cctally()` boilerplate or `c.X` rewrites at every call site.
82
+ #
83
+ # Path constants and rarer helpers (`MIGRATION_ERROR_LOG_PATH`,
84
+ # `LOG_DIR`, `DB_PATH`, `CACHE_DB_PATH`, `format_local_iso`) are
85
+ # accessed via the standard `c = _cctally()` + `c.X` pattern instead
86
+ # (call-time lookup so fixture-HOME redirects propagate).
87
+ def now_utc_iso(*args, **kwargs):
88
+ return sys.modules["cctally"].now_utc_iso(*args, **kwargs)
89
+
90
+
91
+ def parse_iso_datetime(*args, **kwargs):
92
+ return sys.modules["cctally"].parse_iso_datetime(*args, **kwargs)
93
+
94
+
95
+ def _compute_block_totals(*args, **kwargs):
96
+ return sys.modules["cctally"]._compute_block_totals(*args, **kwargs)
97
+
98
+
99
+ def eprint(*args, **kwargs):
100
+ return sys.modules["cctally"].eprint(*args, **kwargs)
101
+
102
+
103
+ # === BEGIN MOVED REGIONS ===
104
+ # Regions below are inserted verbatim from bin/cctally. Bare-name
105
+ # references to `now_utc_iso(...)`, `parse_iso_datetime(...)`,
106
+ # `_compute_block_totals(...)`, and `eprint(...)` resolve to the shims
107
+ # above. Path-constant references (`MIGRATION_ERROR_LOG_PATH`,
108
+ # `LOG_DIR`, `DB_PATH`, `CACHE_DB_PATH`) get rewritten to `c.X` form
109
+ # with a top-of-function `c = _cctally()` binding inserted.
110
+
111
+ # === Region 1: add_column_if_missing (was bin/cctally:8584-8621) ===
112
+
113
+ _MIGRATION_IDENT_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
114
+
115
+
116
+ def add_column_if_missing(
117
+ conn: sqlite3.Connection,
118
+ table: str,
119
+ column: str,
120
+ decl: str,
121
+ ) -> bool:
122
+ """Add a column iff it doesn't already exist. Returns True if added.
123
+
124
+ Idempotent guard for column-shape evolution; NOT a migration. Use this
125
+ when the new column is nullable / has a sensible default and there is
126
+ no data backfill required (or the backfill is a separate
127
+ @stats_migration / @cache_migration). For data-shape changes (backfill,
128
+ dedup, rename), write a real registered migration instead — see
129
+ _STATS_MIGRATIONS / _CACHE_MIGRATIONS in this file.
130
+
131
+ f-string SQL is safe because `table` and `column` come from in-script
132
+ literals only; the regex check rejects names that don't match
133
+ ^[a-zA-Z_][a-zA-Z0-9_]*$ as belt-and-suspenders against future misuse.
134
+ """
135
+ if not _MIGRATION_IDENT_RE.match(table):
136
+ raise ValueError(f"invalid identifier (table): {table!r}")
137
+ if not _MIGRATION_IDENT_RE.match(column):
138
+ raise ValueError(f"invalid identifier (column): {column!r}")
139
+ # PRAGMA table_info column 1 is `name`. Index by position (r[1]) so
140
+ # the helper works whether the caller's connection set
141
+ # row_factory=sqlite3.Row (open_db) or left it as the default tuple
142
+ # factory (open_cache_db).
143
+ cols = {
144
+ str(r[1])
145
+ for r in conn.execute(f"PRAGMA table_info({table})").fetchall()
146
+ }
147
+ if column in cols:
148
+ return False
149
+ conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {decl}")
150
+ return True
151
+
152
+
153
+ # === Region 2: Migration framework + dispatcher (was bin/cctally:10952-11229) ===
154
+
155
+ _MIGRATION_NAME_RE = re.compile(r"^\d{3}_[a-z0-9_]+$")
156
+
157
+
158
+ @dataclass(frozen=True)
159
+ class Migration:
160
+ """A single registered migration.
161
+
162
+ seq: 1-based; equals position in the registry at registration time.
163
+ name: "NNN_descriptive_name", written into schema_migrations.
164
+ handler: callable(conn) that owns its own BEGIN/COMMIT/ROLLBACK.
165
+ """
166
+ seq: int
167
+ name: str
168
+ handler: Callable[[sqlite3.Connection], None]
169
+
170
+
171
+ _STATS_MIGRATIONS: list[Migration] = []
172
+ _CACHE_MIGRATIONS: list[Migration] = []
173
+
174
+
175
+ class DowngradeDetected(Exception):
176
+ """Raised by the dispatcher when PRAGMA user_version > len(registry).
177
+
178
+ Means the DB was last touched by a newer cctally that has since been
179
+ downgraded. The framework refuses to open because newer migrations
180
+ may have written shapes the older code can't read.
181
+ """
182
+
183
+ def __init__(self, db_label: str, db_version: int, max_known: int):
184
+ self.db_label = db_label
185
+ self.db_version = db_version
186
+ self.max_known = max_known
187
+ super().__init__(
188
+ f"{db_label} is at version {db_version} but this cctally "
189
+ f"only knows up to {max_known}."
190
+ )
191
+
192
+
193
+ def _make_migration_decorator(registry: list[Migration], db_label: str, name: str):
194
+ """Internal helper — builds the @stats_migration / @cache_migration decorators.
195
+
196
+ Enforces three invariants at registration time. Checks run in this
197
+ order so the developer sees the most actionable message first:
198
+ 1. Name matches ^\\d{3}_[a-z0-9_]+$ (well-formed) — typos beat
199
+ contiguity errors.
200
+ 2. Name is unique within this registry — re-registration of an
201
+ existing migration is a copy-paste bug, not a numbering bug.
202
+ 3. Numeric prefix matches len(registry) + 1 exactly (contiguity)
203
+ — final defense against gaps / out-of-order edits.
204
+ Failure of any → RuntimeError at script load (not silently mis-applied).
205
+ """
206
+ def deco(fn):
207
+ if not _MIGRATION_NAME_RE.match(name):
208
+ raise RuntimeError(
209
+ f"{db_label} migration name '{name}' is invalid; "
210
+ f"must match {_MIGRATION_NAME_RE.pattern}"
211
+ )
212
+ if any(m.name == name for m in registry):
213
+ raise RuntimeError(
214
+ f"{db_label} migration '{name}' duplicated"
215
+ )
216
+ seq = len(registry) + 1
217
+ prefix = f"{seq:03d}_"
218
+ if not name.startswith(prefix):
219
+ raise RuntimeError(
220
+ f"{db_label} migration #{seq} must be named '{prefix}…' "
221
+ f"but got '{name}'"
222
+ )
223
+ registry.append(Migration(seq=seq, name=name, handler=fn))
224
+ return fn
225
+ return deco
226
+
227
+
228
+ def stats_migration(name: str):
229
+ """Register a stats.db migration. Use as @stats_migration("NNN_descriptive_name")."""
230
+ return _make_migration_decorator(_STATS_MIGRATIONS, "stats.db", name)
231
+
232
+
233
+ def cache_migration(name: str):
234
+ """Register a cache.db migration. Use as @cache_migration("NNN_descriptive_name")."""
235
+ return _make_migration_decorator(_CACHE_MIGRATIONS, "cache.db", name)
236
+
237
+
238
+ # Pre-framework migration markers were stored under unprefixed names;
239
+ # the dispatcher's bootstrap rename rewrites them to NNN_ form on the
240
+ # first open_db() that runs the framework. Raw-sqlite3 db commands
241
+ # (cmd_db_status, cmd_db_skip) bypass open_db() by design and so don't
242
+ # benefit from that rename — they consult this map to recognize legacy
243
+ # rows as already-applied without mutating the DB.
244
+ _LEGACY_MARKER_ALIASES_BY_DB: dict[str, dict[str, str]] = {
245
+ "stats.db": {
246
+ "five_hour_block_models_backfill_v1": "001_five_hour_block_models_backfill_v1",
247
+ "five_hour_block_projects_backfill_v1": "002_five_hour_block_projects_backfill_v1",
248
+ "merge_5h_block_duplicates_v1": "003_merge_5h_block_duplicates_v1",
249
+ },
250
+ # cache.db has no pre-framework markers.
251
+ "cache.db": {},
252
+ }
253
+
254
+
255
+ def _bootstrap_rename_legacy_markers(conn: sqlite3.Connection, db_label: str) -> None:
256
+ """One-shot, idempotent: rename pre-framework marker rows to NNN_ form.
257
+
258
+ Caller (the dispatcher, added in a later task) owns the BEGIN/COMMIT
259
+ envelope; this fn just executes the DML inside the active transaction.
260
+ Hardcoded against the three known stats.db markers — no-op everywhere
261
+ else, including cache.db (which has no pre-framework markers).
262
+
263
+ Also clears any pre-framework failure-log entries referencing the
264
+ legacy unprefixed name, so a residual banner stops rendering once the
265
+ rename succeeds. Without this clear, the dispatcher's success-side
266
+ _clear_migration_error_log_entries(qualified_name) would match
267
+ nothing and the legacy banner would persist forever (Codex P2 #5).
268
+
269
+ Idempotent on subsequent opens: the UPDATEs find nothing to rename
270
+ and the log clears find nothing to drop.
271
+
272
+ Idempotent against the duplicate-marker case too: if BOTH the
273
+ legacy (``old``) and the prefixed (``new``) rows already exist
274
+ (e.g., a user briefly ran a dev build that prefixed the markers,
275
+ then reverted to a pre-framework binary that re-applied the legacy
276
+ unprefixed markers), the UPDATE would collide on the schema_migrations
277
+ PRIMARY KEY (``name``) — observed in the wild as a recurring
278
+ ``UNIQUE constraint failed: schema_migrations.name`` failure that
279
+ permanently blocked the dispatcher from running ANY downstream
280
+ migration. Resolution: DELETE the legacy row first when its
281
+ prefixed counterpart already exists, then UPDATE the rest. The
282
+ prefixed row wins because it carries the dispatcher-managed
283
+ applied_at_utc that newer code reads for sequencing decisions.
284
+ """
285
+ aliases = _LEGACY_MARKER_ALIASES_BY_DB.get(db_label, {})
286
+ if not aliases:
287
+ return
288
+ for old, new in aliases.items():
289
+ # If the prefixed marker is already present, drop the legacy
290
+ # duplicate (UPDATE would collide on PRIMARY KEY); keep the
291
+ # prefixed row's applied_at_utc as authoritative.
292
+ conn.execute(
293
+ "DELETE FROM schema_migrations "
294
+ " WHERE name = ? "
295
+ " AND EXISTS (SELECT 1 FROM schema_migrations WHERE name = ?)",
296
+ (old, new),
297
+ )
298
+ conn.execute(
299
+ "UPDATE schema_migrations SET name = ? WHERE name = ?",
300
+ (new, old),
301
+ )
302
+ _clear_migration_error_log_entries(old)
303
+
304
+
305
+ def _run_pending_migrations(
306
+ conn: sqlite3.Connection,
307
+ *,
308
+ registry: list[Migration],
309
+ db_label: str,
310
+ ) -> None:
311
+ """Apply pending migrations from ``registry`` against ``conn``.
312
+
313
+ Spec: docs/superpowers/specs/2026-05-06-migration-framework-design.md
314
+ §2.3 (full pseudocode), §3.1 (failure semantics).
315
+
316
+ Behavior:
317
+ - PRAGMA user_version > len(registry) → raise DowngradeDetected.
318
+ - PRAGMA user_version == len(registry) → fast-path return.
319
+ - Bootstrap rename runs in its own BEGIN/COMMIT (Codex P1 #2 fix):
320
+ closes the implicit transaction Python's sqlite3 module would
321
+ auto-open on the UPDATE statements, so subsequent handler
322
+ ``conn.execute("BEGIN")`` calls start cleanly.
323
+ - Fresh install (schema_migrations just CREATE'd, zero rows
324
+ post-bootstrap) → stamp every migration applied without invoking
325
+ handlers.
326
+ - Per migration: handler raises ``Exception`` → log + BREAK
327
+ (Codex P1 #3 — the FIRST failure halts the registry walk so
328
+ later migrations never see partial-prior state). ``BaseException``
329
+ propagates uncaught (Codex P1 #4 — KeyboardInterrupt / SystemExit
330
+ must not be swallowed).
331
+ - Tuple-safe SELECTs (Codex P2 #7) — works against connections
332
+ with or without ``row_factory = Row``; cache.db deliberately
333
+ leaves the default tuple row factory.
334
+ - PRAGMA user_version advances ONLY when every migration is
335
+ applied OR skipped post-loop. A failure in the middle of the
336
+ registry leaves user_version unchanged so the next open re-tries
337
+ from the failed entry.
338
+ """
339
+ cur_version = conn.execute("PRAGMA user_version").fetchone()[0]
340
+ if cur_version > len(registry):
341
+ raise DowngradeDetected(
342
+ db_label, db_version=cur_version, max_known=len(registry),
343
+ )
344
+ if cur_version == len(registry):
345
+ # When the registry is currently empty (today's cache.db case),
346
+ # still leave the schema_migrations table behind so a later
347
+ # transition to len(registry) >= 1 can distinguish populated
348
+ # DBs from fresh installs. Without this, the fast-path returns
349
+ # before any DDL, so the future first-cache-migration walk
350
+ # finds no schema_migrations table, treats the populated DB as
351
+ # fresh, and stamps the new migration applied without invoking
352
+ # its handler.
353
+ if len(registry) == 0:
354
+ conn.execute(
355
+ "CREATE TABLE IF NOT EXISTS schema_migrations "
356
+ "(name TEXT PRIMARY KEY, applied_at_utc TEXT NOT NULL)"
357
+ )
358
+ # Clear stale bootstrap-rename failure entries. If user_version
359
+ # reached len(registry), every migration is applied OR skipped
360
+ # — by definition no pending failure remains. Any persisted
361
+ # bootstrap-rename entry in the error log is from a PRIOR
362
+ # buggy bootstrap (now repaired) and is stale; clear it so the
363
+ # banner stops rendering. Cheap no-op when the log file
364
+ # doesn't exist or doesn't contain a matching entry.
365
+ _clear_migration_error_log_entries(
366
+ f"{db_label}:_bootstrap_rename_legacy_markers"
367
+ )
368
+ return # fast path
369
+
370
+ # Track whether schema_migrations existed before this open so we can
371
+ # detect the fresh-install path. After bootstrap, even a "first time
372
+ # opened with framework code" DB might have rows from the legacy
373
+ # rename — those count as already-applied, NOT as a fresh install.
374
+ schema_migrations_existed = conn.execute(
375
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='schema_migrations'"
376
+ ).fetchone() is not None
377
+
378
+ conn.execute(
379
+ """
380
+ CREATE TABLE IF NOT EXISTS schema_migrations (
381
+ name TEXT PRIMARY KEY,
382
+ applied_at_utc TEXT NOT NULL
383
+ )
384
+ """
385
+ )
386
+ conn.execute(
387
+ """
388
+ CREATE TABLE IF NOT EXISTS schema_migrations_skipped (
389
+ name TEXT PRIMARY KEY,
390
+ skipped_at_utc TEXT NOT NULL,
391
+ reason TEXT
392
+ )
393
+ """
394
+ )
395
+
396
+ # Bootstrap rename in its own commit envelope (Codex P1 #2 fix).
397
+ # Closes the implicit transaction Python's sqlite3 module would
398
+ # auto-open on the UPDATE statements, so subsequent handler
399
+ # conn.execute("BEGIN") starts cleanly.
400
+ try:
401
+ conn.execute("BEGIN")
402
+ _bootstrap_rename_legacy_markers(conn, db_label)
403
+ conn.commit()
404
+ # On success, clear any persisted error from a PRIOR bootstrap-rename
405
+ # failure (e.g., the duplicate-marker UNIQUE collision that was
406
+ # observed in the wild and is now repaired by the DELETE-before-UPDATE
407
+ # in _bootstrap_rename_legacy_markers). Without this clear, the
408
+ # banner from the prior failed run would persist forever after the
409
+ # repair, even though the dispatcher now completes cleanly.
410
+ _clear_migration_error_log_entries(
411
+ f"{db_label}:_bootstrap_rename_legacy_markers"
412
+ )
413
+ except Exception as exc:
414
+ try:
415
+ conn.rollback()
416
+ except Exception:
417
+ pass
418
+ _log_migration_error(
419
+ name=f"{db_label}:_bootstrap_rename_legacy_markers",
420
+ exc=exc,
421
+ tb=traceback.format_exc(),
422
+ )
423
+ eprint(
424
+ f"[migration {db_label}:_bootstrap_rename_legacy_markers] "
425
+ f"failed: {exc}"
426
+ )
427
+ return # do not walk the registry this open
428
+
429
+ # Tuple-safe SELECTs — cache.db connection does not set row_factory.
430
+ applied = {
431
+ row[0] for row in conn.execute("SELECT name FROM schema_migrations").fetchall()
432
+ }
433
+ skipped = {
434
+ row[0] for row in conn.execute("SELECT name FROM schema_migrations_skipped").fetchall()
435
+ }
436
+
437
+ # Fresh install: schema_migrations was just CREATE'd this open AND
438
+ # has zero rows post-bootstrap. (If bootstrap renamed pre-existing
439
+ # rows, those rows now appear in `applied`; not a fresh install.)
440
+ fresh_install = (not schema_migrations_existed) and len(applied) == 0
441
+
442
+ now_iso = now_utc_iso()
443
+ for m in registry:
444
+ if m.name in applied or m.name in skipped:
445
+ continue
446
+ if fresh_install:
447
+ conn.execute(
448
+ "INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) VALUES (?, ?)",
449
+ (m.name, now_iso),
450
+ )
451
+ applied.add(m.name)
452
+ continue
453
+ qualified_name = f"{db_label}:{m.name}"
454
+ try:
455
+ m.handler(conn)
456
+ _clear_migration_error_log_entries(qualified_name)
457
+ applied.add(m.name)
458
+ except Exception as exc:
459
+ _log_migration_error(
460
+ name=qualified_name,
461
+ exc=exc,
462
+ tb=traceback.format_exc(),
463
+ )
464
+ eprint(f"[migration {qualified_name}] failed: {exc}")
465
+ break # stop on first failure (Codex P1 #3)
466
+
467
+ if fresh_install:
468
+ conn.commit() # commit fresh-install stamps so they're durable
469
+
470
+ # Advance user_version only when every migration is applied OR skipped.
471
+ if all((m.name in applied or m.name in skipped) for m in registry):
472
+ conn.execute(f"PRAGMA user_version = {len(registry)}")
473
+ conn.commit()
474
+
475
+
476
+ # === Region 3: 001 handler (was bin/cctally:11232-11344) ===
477
+
478
+ @stats_migration("001_five_hour_block_models_backfill_v1")
479
+ def _backfill_five_hour_block_models(conn: sqlite3.Connection) -> None:
480
+ """Upgrade-user backfill of five_hour_block_models.
481
+
482
+ Fires when schema_migrations has no row for
483
+ '001_five_hour_block_models_backfill_v1' AND five_hour_blocks is
484
+ non-empty.
485
+
486
+ Iterates parent rows, re-walks session_entries per block via
487
+ _compute_block_totals(..., skip_sync=False), and INSERT OR IGNORE's
488
+ child rows. skip_sync=False is intentional: cache.db can be empty
489
+ or stale at open_db() time (deleted, imported, restored from
490
+ backup), and querying it as-is would close the gate forever with
491
+ zero children even though JSONL exists on disk. sync_cache only
492
+ touches cache.db; no open_db() recursion.
493
+
494
+ Defensively cleans up orphan child rows (block_id referencing a
495
+ parent that no longer exists) before re-backfilling, so manual
496
+ `DELETE FROM five_hour_blocks` followed by re-backfill doesn't
497
+ leave duplicates.
498
+
499
+ Always inserts the schema_migrations marker at the end (inside the
500
+ same transaction) so the gate closes regardless of how many child
501
+ rows were written — empty `session_entries` for a block (real
502
+ users with API/web-only blocks) yields zero child rows but MUST
503
+ still close the gate (regression scenario Q2).
504
+ """
505
+ # Empty-table fast path: with no parent five_hour_blocks rows, this
506
+ # backfill has nothing to do. We still must close the gate so the
507
+ # dispatcher sees us as applied. INSERT OR IGNORE the marker and
508
+ # return (replaces the prior `has_blocks` outer gate from the
509
+ # pre-framework era).
510
+ if not conn.execute("SELECT 1 FROM five_hour_blocks LIMIT 1").fetchone():
511
+ conn.execute(
512
+ "INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) VALUES (?, ?)",
513
+ ("001_five_hour_block_models_backfill_v1", now_utc_iso()),
514
+ )
515
+ conn.commit()
516
+ return
517
+ now_iso = now_utc_iso()
518
+ conn.execute("BEGIN")
519
+ try:
520
+ # Defensive: clean up any orphans from a prior parent rebuild.
521
+ conn.execute(
522
+ "DELETE FROM five_hour_block_models "
523
+ "WHERE block_id NOT IN (SELECT id FROM five_hour_blocks)"
524
+ )
525
+
526
+ rows = conn.execute(
527
+ "SELECT id, five_hour_window_key, block_start_at, "
528
+ " last_observed_at_utc "
529
+ " FROM five_hour_blocks"
530
+ ).fetchall()
531
+ for row in rows:
532
+ block_start_dt = parse_iso_datetime(
533
+ row["block_start_at"],
534
+ "five_hour_blocks.block_start_at",
535
+ )
536
+ last_obs_dt = parse_iso_datetime(
537
+ row["last_observed_at_utc"],
538
+ "five_hour_blocks.last_observed_at_utc",
539
+ )
540
+ # skip_sync=False: ingest JSONL deltas before walking
541
+ # entries. If the user's cache.db is empty/stale at the
542
+ # moment open_db() fires this gate (e.g., cache.db deleted,
543
+ # stats.db imported from another machine), querying the
544
+ # cache as-is would return zero entries and we'd close the
545
+ # gate forever with empty children. sync_cache(conn)
546
+ # operates on cache.db only — it does NOT call open_db(),
547
+ # so there is no recursion risk.
548
+ totals = _compute_block_totals(
549
+ block_start_dt, last_obs_dt, skip_sync=False,
550
+ )
551
+ if totals.get("by_model"):
552
+ conn.executemany(
553
+ """
554
+ INSERT OR IGNORE INTO five_hour_block_models (
555
+ block_id, five_hour_window_key, model,
556
+ input_tokens, output_tokens,
557
+ cache_create_tokens, cache_read_tokens,
558
+ cost_usd, entry_count
559
+ )
560
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
561
+ """,
562
+ [
563
+ (
564
+ int(row["id"]),
565
+ int(row["five_hour_window_key"]),
566
+ model,
567
+ b["input_tokens"],
568
+ b["output_tokens"],
569
+ b["cache_create_tokens"],
570
+ b["cache_read_tokens"],
571
+ b["cost_usd"],
572
+ b["entry_count"],
573
+ )
574
+ for model, b in totals["by_model"].items()
575
+ ],
576
+ )
577
+
578
+ # Mark migration done — closes the gate even when zero rows
579
+ # were written (empty session_entries / API-only blocks).
580
+ conn.execute(
581
+ """
582
+ INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc)
583
+ VALUES (?, ?)
584
+ """,
585
+ ("001_five_hour_block_models_backfill_v1", now_iso),
586
+ )
587
+ conn.commit()
588
+ except Exception:
589
+ conn.rollback()
590
+ raise
591
+
592
+
593
+ # === Region 4: 002 handler (was bin/cctally:11347-11437) ===
594
+
595
+ @stats_migration("002_five_hour_block_projects_backfill_v1")
596
+ def _backfill_five_hour_block_projects(conn: sqlite3.Connection) -> None:
597
+ """Upgrade-user backfill of five_hour_block_projects.
598
+
599
+ Mirror of _backfill_five_hour_block_models but writes by_project
600
+ buckets and inserts the projects-side schema_migrations marker.
601
+ Cleans up orphan child rows defensively before the main loop.
602
+ Marker insert fires regardless of child-row count so the gate
603
+ closes for empty-row backfills too.
604
+ """
605
+ # Empty-table fast path: with no parent five_hour_blocks rows, this
606
+ # backfill has nothing to do. We still must close the gate so the
607
+ # dispatcher sees us as applied. INSERT OR IGNORE the marker and
608
+ # return (replaces the prior `has_blocks` outer gate from the
609
+ # pre-framework era).
610
+ if not conn.execute("SELECT 1 FROM five_hour_blocks LIMIT 1").fetchone():
611
+ conn.execute(
612
+ "INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) VALUES (?, ?)",
613
+ ("002_five_hour_block_projects_backfill_v1", now_utc_iso()),
614
+ )
615
+ conn.commit()
616
+ return
617
+ now_iso = now_utc_iso()
618
+ conn.execute("BEGIN")
619
+ try:
620
+ conn.execute(
621
+ "DELETE FROM five_hour_block_projects "
622
+ "WHERE block_id NOT IN (SELECT id FROM five_hour_blocks)"
623
+ )
624
+
625
+ rows = conn.execute(
626
+ "SELECT id, five_hour_window_key, block_start_at, "
627
+ " last_observed_at_utc "
628
+ " FROM five_hour_blocks"
629
+ ).fetchall()
630
+ for row in rows:
631
+ block_start_dt = parse_iso_datetime(
632
+ row["block_start_at"],
633
+ "five_hour_blocks.block_start_at",
634
+ )
635
+ last_obs_dt = parse_iso_datetime(
636
+ row["last_observed_at_utc"],
637
+ "five_hour_blocks.last_observed_at_utc",
638
+ )
639
+ # See _backfill_five_hour_block_models for the same
640
+ # skip_sync=False rationale: ingest JSONL deltas first so
641
+ # an empty/stale cache.db doesn't permanently close the
642
+ # gate with zero rows. sync_cache only touches cache.db,
643
+ # so there is no open_db() recursion risk.
644
+ totals = _compute_block_totals(
645
+ block_start_dt, last_obs_dt, skip_sync=False,
646
+ )
647
+ if totals.get("by_project"):
648
+ conn.executemany(
649
+ """
650
+ INSERT OR IGNORE INTO five_hour_block_projects (
651
+ block_id, five_hour_window_key, project_path,
652
+ input_tokens, output_tokens,
653
+ cache_create_tokens, cache_read_tokens,
654
+ cost_usd, entry_count
655
+ )
656
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
657
+ """,
658
+ [
659
+ (
660
+ int(row["id"]),
661
+ int(row["five_hour_window_key"]),
662
+ project_path,
663
+ b["input_tokens"],
664
+ b["output_tokens"],
665
+ b["cache_create_tokens"],
666
+ b["cache_read_tokens"],
667
+ b["cost_usd"],
668
+ b["entry_count"],
669
+ )
670
+ for project_path, b in totals["by_project"].items()
671
+ ],
672
+ )
673
+
674
+ conn.execute(
675
+ """
676
+ INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc)
677
+ VALUES (?, ?)
678
+ """,
679
+ ("002_five_hour_block_projects_backfill_v1", now_iso),
680
+ )
681
+ conn.commit()
682
+ except Exception:
683
+ conn.rollback()
684
+ raise
685
+
686
+
687
+
688
+ # === Region 5: Error sentinel (was bin/cctally:11439-11717) ===
689
+
690
+ def _log_migration_error(*, name: str, exc: BaseException, tb: str) -> None:
691
+ """Append a migration failure record to MIGRATION_ERROR_LOG_PATH.
692
+
693
+ Failure-tolerant: any IO error here is logged via eprint and swallowed
694
+ so a logging-side failure doesn't shadow the original migration error.
695
+ """
696
+ # POSIX append() is atomic per write() syscall up to PIPE_BUF (~4 KiB).
697
+ # A multi-line traceback exceeding that can interleave with a concurrent
698
+ # appender, producing a corrupt block. _render_migration_error_banner's
699
+ # parser handles malformed entries via the generic-copy fallback (no
700
+ # crash). Acceptable per "best effort" design — concurrent migration
701
+ # failures are vanishingly rare since open_db() serializes via WAL.
702
+ try:
703
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
704
+ ts = now_utc_iso()
705
+ one_line_err = str(exc).replace("\n", " ").strip() or exc.__class__.__name__
706
+ indented_tb = "\n".join(" " + line for line in tb.rstrip().splitlines())
707
+ block = f"[{ts}] {name}\n {one_line_err}\n{indented_tb}\n\n"
708
+ with open(MIGRATION_ERROR_LOG_PATH, "a") as fh:
709
+ fh.write(block)
710
+ except Exception as log_exc:
711
+ eprint(f"[migration-error-log] failed to write: {log_exc}")
712
+
713
+
714
+ def _clear_migration_error_log_entries(name: str) -> None:
715
+ """Remove all entries tagged with ``name`` from the migration error log.
716
+
717
+ If the resulting file is empty (or doesn't exist to begin with), unlink
718
+ it. Failure-tolerant: any IO error is swallowed; the log file is
719
+ best-effort.
720
+ """
721
+ # Race: read → filter → write is non-atomic. Concurrent writers (rare —
722
+ # usually only happens if a manual cctally cmd races a status-line tick)
723
+ # can lose one log entry or briefly resurrect a stale banner. Acceptable
724
+ # per "best effort" design (see Q1=A in design discussion); the next
725
+ # successful migration auto-clears, and worst case the user sees one
726
+ # extra banner cycle. Not worth fcntl.flock complexity for failure-rare
727
+ # code path.
728
+ try:
729
+ if not MIGRATION_ERROR_LOG_PATH.exists():
730
+ return
731
+ content = MIGRATION_ERROR_LOG_PATH.read_text()
732
+ # Entries are separated by "\n\n". Each entry's first line is
733
+ # "[ts] <name>".
734
+ blocks = [b for b in content.split("\n\n") if b.strip()]
735
+ kept = []
736
+ for block in blocks:
737
+ first_line = block.splitlines()[0] if block.splitlines() else ""
738
+ # Match: line ends with " <name>" (after the timestamp prefix).
739
+ if first_line.endswith(f" {name}"):
740
+ continue
741
+ kept.append(block)
742
+ if not kept:
743
+ MIGRATION_ERROR_LOG_PATH.unlink()
744
+ return
745
+ MIGRATION_ERROR_LOG_PATH.write_text("\n\n".join(kept) + "\n\n")
746
+ except Exception as exc:
747
+ eprint(
748
+ f"[migration-error-log] failed to clear entries for {name}: {exc}"
749
+ )
750
+
751
+
752
+ def _render_migration_error_banner() -> str | None:
753
+ """Return a one-line banner string from the migration error log, or
754
+ ``None`` if there is nothing to surface.
755
+
756
+ Parses the most recent entry's first line for the migration name and
757
+ timestamp. Falls back to a generic message on parse failure.
758
+ """
759
+ if not MIGRATION_ERROR_LOG_PATH.exists():
760
+ return None
761
+ try:
762
+ content = MIGRATION_ERROR_LOG_PATH.read_text()
763
+ except Exception:
764
+ return None
765
+ if not content.strip():
766
+ return None
767
+ blocks = [b for b in content.split("\n\n") if b.strip()]
768
+ if not blocks:
769
+ return None
770
+ most_recent = blocks[-1].splitlines()[0]
771
+ # most_recent format: "[2026-05-01T12:34:56Z] merge_5h_block_duplicates_v1"
772
+ if most_recent.startswith("[") and "] " in most_recent:
773
+ try:
774
+ ts_part, _, name_part = most_recent[1:].partition("] ")
775
+ ts = ts_part.strip()
776
+ name = name_part.strip()
777
+ if ts and name:
778
+ return (
779
+ f"⚠ cctally: migration `{name}` failed at {ts}. "
780
+ f"See {MIGRATION_ERROR_LOG_PATH}"
781
+ )
782
+ except Exception:
783
+ pass
784
+ return (
785
+ f"⚠ cctally: migration error logged. "
786
+ f"See {MIGRATION_ERROR_LOG_PATH}"
787
+ )
788
+
789
+
790
+ # Suppression list — silent / background / internal commands. The banner
791
+ # would either pollute machine-readable output (record-usage / hook-tick /
792
+ # refresh-usage when consumed by status-line shells, sync-week / cache-sync
793
+ # when scripted) or have nowhere to land (tui's full-screen rich render).
794
+ # `setup` is special-cased (banner shown only with --status); `dashboard`
795
+ # is also special-cased so cmd_dashboard can print at server startup
796
+ # instead of swallowing into early stdout.
797
+ _BANNER_SUPPRESSED_COMMANDS = frozenset({
798
+ "record-usage", # invoked every status-line tick + hook fire; banner would spam
799
+ "hook-tick", # background; CC hook fire, log-only output
800
+ "sync-week", # background; called from refresh-usage path
801
+ "cache-sync", # background; bulk operation, no banner needed
802
+ "refresh-usage", # background; OAuth fetch + record-usage chain
803
+ "tui", # rich Live mode takes over the screen; banner would be clobbered
804
+ "db", # `db status` shows failure state in its own output;
805
+ # `db skip` / `db unskip` are mid-fix — banner would be redundant.
806
+ "doctor", # consolidates migration + update banner state into its
807
+ # own report; double-printing the banner would duplicate
808
+ # findings doctor already surfaces structurally.
809
+ # Note: `setup` carve-out handled separately (only suppressed w/o --status).
810
+ # Note: `dashboard` carve-out handled separately (banner printed in cmd_dashboard).
811
+ })
812
+
813
+
814
+ # === Region 6: _print_migration_error_banner_if_needed (was bin/cctally:11719-11760) ===
815
+
816
+ def _print_migration_error_banner_if_needed(args) -> None:
817
+ """Print a one-line warning banner if the migration error log has
818
+ entries.
819
+
820
+ Suppression rules:
821
+ - Sentinel file doesn't exist or is empty -> no banner.
822
+ - Command in ``_BANNER_SUPPRESSED_COMMANDS`` -> no banner.
823
+ - ``setup`` without --status -> no banner. ``setup --status`` -> banner.
824
+ - ``dashboard`` -> handled inside cmd_dashboard, skipped here.
825
+ - Machine-stdout modes (--status-line and similar single-line shell-
826
+ substituted integrations) -> no banner anywhere; both stdout AND
827
+ stderr are unsafe surfaces (status-line integration is
828
+ `$(cmd 2>/dev/null)`). User sees banner on next interactive cmd.
829
+ - --json mode (any command exposing it, including diff's
830
+ dest="emit_json") -> banner goes to STDERR instead of stdout to
831
+ keep stdout JSON parsable.
832
+ """
833
+ c = _cctally()
834
+ cmd = getattr(args, "command", None)
835
+ if cmd is None or cmd in _BANNER_SUPPRESSED_COMMANDS:
836
+ return
837
+ # args.status is meaningful only for cmd == "setup" (--status flag);
838
+ # any future subcommand adding --status with default dest="status"
839
+ # would inherit show-banner behavior unintentionally. Audit at that time.
840
+ if cmd == "setup" and not getattr(args, "status", False):
841
+ return
842
+ if cmd == "dashboard":
843
+ return # cmd_dashboard handles its own banner at server startup
844
+
845
+ # Machine-stdout suppression: status-line and similar single-line scripted
846
+ # integrations swallow both stdout and stderr — banner has no safe surface,
847
+ # so skip entirely. User will see the banner on their next interactive cmd.
848
+ # _args_emit_machine_stdout / _args_emit_json stay in bin/cctally
849
+ # (shared with the update-banner gate); reach via the call-time accessor.
850
+ if c._args_emit_machine_stdout(args):
851
+ return
852
+
853
+ banner_msg = _render_migration_error_banner()
854
+ if banner_msg is None:
855
+ return
856
+
857
+ # JSON mode: banner goes to STDERR to keep stdout JSON parseable.
858
+ json_mode = c._args_emit_json(args)
859
+ print(banner_msg, file=sys.stderr if json_mode else sys.stdout)
860
+
861
+
862
+
863
+ # === Region 7: 003 handler (was bin/cctally:11762-12084) ===
864
+
865
+ @stats_migration("003_merge_5h_block_duplicates_v1")
866
+ def _migration_merge_5h_block_duplicates_v1(conn: sqlite3.Connection) -> None:
867
+ """One-shot migration: merge ``five_hour_blocks`` rows that represent
868
+ the same physical 5h window but have different ``five_hour_window_key``
869
+ values (boundary-jitter forks; F4-incident class).
870
+
871
+ Algorithm
872
+ ─────────
873
+ 1. Load every parent row ordered by ``five_hour_resets_at``.
874
+ 2. Greedy-group: a new row joins the current group iff
875
+ ``epoch - group_anchor_epoch <= 1800`` (3 × the 600 s floor); else
876
+ flush and start a new group at this row's epoch.
877
+ 3. For each group of size ≥ 2:
878
+ a. Canonical = the row with the earliest ``first_observed_at_utc``
879
+ (write-once anchor — same precedence the 5h-block live-write
880
+ path treats as immutable).
881
+ b. ``weekly_usage_snapshots.five_hour_window_key`` IN (dropped) →
882
+ rewritten to canonical so the latest-snapshot lookup returns
883
+ one canonical key.
884
+ c. ``five_hour_milestones`` are write-once per
885
+ (canonical_block, percent_threshold). For each threshold seen
886
+ across the group, KEEP the row with the earliest
887
+ ``captured_at_utc`` and re-FK it onto the canonical block;
888
+ DELETE the rest. (Earliest-captured guards the spec invariant
889
+ [Write-once milestones]: never overwrite a historical milestone
890
+ with a later — and therefore higher-cost — observation.)
891
+ d. ``five_hour_block_models`` / ``five_hour_block_projects`` for
892
+ dropped windows → DELETE outright. They're recompute-every-tick
893
+ rollup-children (CLAUDE.md spec); the next ``record-usage`` will
894
+ repopulate the canonical block's rows from
895
+ ``session_entries`` via ``_compute_block_totals``.
896
+ e. MERGE group-wide aggregates into canonical: ``last_observed_at_utc``,
897
+ ``final_five_hour_percent``, ``seven_day_pct_at_block_end``,
898
+ ``crossed_seven_day_reset``, ``is_closed``, and the five
899
+ ``total_*`` columns. Rationale: each duplicate row received
900
+ ticks only while ITS specific (jittered) ``five_hour_window_key``
901
+ was current, so the rows hold complementary slices of the same
902
+ physical 5h window. Without this merge, canonical (= earliest
903
+ ``first_observed_at_utc``) would freeze at the earliest slice,
904
+ and CLOSED blocks (no future tick) would permanently
905
+ under-report. Read-only access to the rows already in memory —
906
+ no ``cache.db`` open, honoring the migration's external-state
907
+ constraint.
908
+ f. DELETE the dropped parent ``five_hour_blocks`` rows.
909
+
910
+ Single ``BEGIN`` / ``COMMIT`` envelope. On any exception the whole
911
+ migration ROLLBACKs and re-raises; the missing ``schema_migrations``
912
+ row makes the next ``open_db`` call retry idempotently.
913
+
914
+ FK on ``five_hour_milestones.block_id`` is documentation-only (no
915
+ SQLite cascade — see CLAUDE.md), so all FK rewrites are explicit
916
+ ``UPDATE``s here.
917
+ """
918
+ conn.execute("BEGIN")
919
+ try:
920
+ blocks = conn.execute(
921
+ """
922
+ SELECT id, five_hour_window_key, five_hour_resets_at,
923
+ first_observed_at_utc, last_observed_at_utc,
924
+ final_five_hour_percent,
925
+ seven_day_pct_at_block_end,
926
+ crossed_seven_day_reset, is_closed,
927
+ total_input_tokens, total_output_tokens,
928
+ total_cache_create_tokens, total_cache_read_tokens,
929
+ total_cost_usd
930
+ FROM five_hour_blocks
931
+ ORDER BY five_hour_resets_at ASC
932
+ """
933
+ ).fetchall()
934
+
935
+ # Convert resets_at to epoch for distance math. A row whose
936
+ # five_hour_resets_at fails to parse is left alone in its own
937
+ # singleton group (defensive — should not happen on data
938
+ # written by record-usage, but better to skip than raise).
939
+ rows: list[tuple[int, dict]] = []
940
+ for b in blocks:
941
+ try:
942
+ ep = int(parse_iso_datetime(
943
+ b["five_hour_resets_at"],
944
+ "five_hour_blocks.five_hour_resets_at",
945
+ ).timestamp())
946
+ except (ValueError, TypeError):
947
+ continue
948
+ rows.append((ep, dict(b)))
949
+
950
+ # Defensive: SQL ORDER BY is lex-ordered. For the columns we
951
+ # read today (consistently +00:00 form), lex == chronological.
952
+ # Re-sort by parsed epoch in Python so a future code path
953
+ # accidentally writing `Z` form into five_hour_resets_at can't
954
+ # mis-group across format boundaries.
955
+ rows.sort(key=lambda r: r[0])
956
+
957
+ # Greedy-group by proximity to the group's anchor epoch.
958
+ groups: list[list[tuple[int, dict]]] = []
959
+ cur_group: list[tuple[int, dict]] = []
960
+ cur_anchor: int | None = None
961
+ for ep, row in rows:
962
+ if (
963
+ cur_anchor is None
964
+ or (ep - cur_anchor) <= _cctally()._FIVE_HOUR_JITTER_FLOOR_SECONDS * 3
965
+ ):
966
+ cur_group.append((ep, row))
967
+ if cur_anchor is None:
968
+ cur_anchor = ep
969
+ else:
970
+ groups.append(cur_group)
971
+ cur_group = [(ep, row)]
972
+ cur_anchor = ep
973
+ if cur_group:
974
+ groups.append(cur_group)
975
+
976
+ for group in groups:
977
+ if len(group) < 2:
978
+ continue
979
+
980
+ # Canonical wins by earliest first_observed_at_utc — same
981
+ # write-once precedence as the live upsert path.
982
+ # NULL first_observed_at_utc shouldn't happen post-schema-
983
+ # NOT-NULL, but defensive against legacy rows; NULL rows
984
+ # lose canonical-pick tiebreak (sort LAST via True>False).
985
+ # Empty-string fallback in the second tuple element keeps
986
+ # SQLite NULLs comparable; a NULL row only becomes
987
+ # canonical if EVERY row in the group is NULL.
988
+ group_sorted = sorted(
989
+ group,
990
+ key=lambda g: (
991
+ g[1]["first_observed_at_utc"] is None,
992
+ g[1]["first_observed_at_utc"] or "",
993
+ ),
994
+ )
995
+ canonical = group_sorted[0][1]
996
+ dropped = [g[1] for g in group_sorted[1:]]
997
+ dropped_keys = [d["five_hour_window_key"] for d in dropped]
998
+ dropped_ids = [d["id"] for d in dropped]
999
+
1000
+ # (b) Re-key snapshots so latest-snapshot lookup returns
1001
+ # the canonical key.
1002
+ placeholders_keys = ",".join("?" * len(dropped_keys))
1003
+ conn.execute(
1004
+ f"UPDATE weekly_usage_snapshots "
1005
+ f" SET five_hour_window_key = ? "
1006
+ f" WHERE five_hour_window_key IN ({placeholders_keys})",
1007
+ [canonical["five_hour_window_key"], *dropped_keys],
1008
+ )
1009
+
1010
+ # (c) Milestones: per-threshold dedup, keep earliest
1011
+ # captured_at_utc, re-FK keepers to canonical.
1012
+ ms_id_placeholders = ",".join(
1013
+ "?" * (len(dropped_ids) + 1)
1014
+ )
1015
+ all_milestones = conn.execute(
1016
+ f"SELECT id, percent_threshold, captured_at_utc "
1017
+ f" FROM five_hour_milestones "
1018
+ f" WHERE block_id IN ({ms_id_placeholders})",
1019
+ [canonical["id"], *dropped_ids],
1020
+ ).fetchall()
1021
+ by_threshold: dict[int, dict] = {}
1022
+ for m in all_milestones:
1023
+ t = m["percent_threshold"]
1024
+ md = dict(m)
1025
+ if (
1026
+ t not in by_threshold
1027
+ or md["captured_at_utc"]
1028
+ < by_threshold[t]["captured_at_utc"]
1029
+ ):
1030
+ by_threshold[t] = md
1031
+ keep_ids = {m["id"] for m in by_threshold.values()}
1032
+ # DELETE non-keepers BEFORE rekeying keepers. Otherwise, when
1033
+ # both canonical and a dropped block hold a milestone for the
1034
+ # same percent_threshold and the dropped row's milestone is
1035
+ # the earlier keeper, UPDATEing it to the canonical key
1036
+ # collides with canonical's still-present non-keeper on
1037
+ # UNIQUE(five_hour_window_key, percent_threshold), rolling
1038
+ # back the migration. After this DELETE the only milestones
1039
+ # referencing dropped_keys are the keepers themselves
1040
+ # (one per threshold), so the UPDATE loop below is collision-
1041
+ # free.
1042
+ non_keep_ids = [
1043
+ m["id"] for m in all_milestones if m["id"] not in keep_ids
1044
+ ]
1045
+ if non_keep_ids:
1046
+ nk_placeholders = ",".join("?" * len(non_keep_ids))
1047
+ conn.execute(
1048
+ f"DELETE FROM five_hour_milestones "
1049
+ f" WHERE id IN ({nk_placeholders})",
1050
+ non_keep_ids,
1051
+ )
1052
+ for m in by_threshold.values():
1053
+ conn.execute(
1054
+ "UPDATE five_hour_milestones "
1055
+ " SET block_id = ?, "
1056
+ " five_hour_window_key = ? "
1057
+ " WHERE id = ?",
1058
+ (
1059
+ canonical["id"],
1060
+ canonical["five_hour_window_key"],
1061
+ m["id"],
1062
+ ),
1063
+ )
1064
+
1065
+ # (d) Children rollup tables — delete dropped rows'
1066
+ # children. Recompute on next record-usage tick repopulates
1067
+ # canonical's rows.
1068
+ for tbl in (
1069
+ "five_hour_block_models",
1070
+ "five_hour_block_projects",
1071
+ ):
1072
+ conn.execute(
1073
+ f"DELETE FROM {tbl} "
1074
+ f" WHERE five_hour_window_key IN ({placeholders_keys})",
1075
+ dropped_keys,
1076
+ )
1077
+
1078
+ # (e) Merge group-wide aggregates into canonical BEFORE
1079
+ # deleting the dropped rows. Each duplicate row received
1080
+ # record-usage ticks for the slice of the 5h window during
1081
+ # which its specific (jittered) five_hour_window_key was
1082
+ # current — so their last_observed_at_utc / final_pct /
1083
+ # totals are complementary, not redundant. For closed /
1084
+ # historical blocks no future tick will fire, so without
1085
+ # this merge the canonical row would be permanently
1086
+ # frozen at the earliest-observation slice. Reads no
1087
+ # external state (still no cache.db open) — all values
1088
+ # come from rows we already SELECT'd above.
1089
+ #
1090
+ # Rules:
1091
+ # - last_observed_at_utc → group MAX (lexicographic on
1092
+ # canonical UTC-Z form == chronological).
1093
+ # - final_five_hour_percent / seven_day_pct_at_block_end
1094
+ # → values from the group row whose
1095
+ # last_observed_at_utc is MAX (preserves the
1096
+ # latest-observation snapshot rather than blindly
1097
+ # taking MAX(percent), which could pick a glitched
1098
+ # spike from a non-latest row).
1099
+ # - crossed_seven_day_reset / is_closed → group MAX
1100
+ # (any row flagged ⇒ canonical flagged).
1101
+ # - total_*_tokens / total_cost_usd → group MAX.
1102
+ # _compute_block_totals always recomputes over
1103
+ # [block_start_at, captured_at_utc], so the row with
1104
+ # the latest captured_at has the strict-superset
1105
+ # totals; MAX picks that row's values without needing
1106
+ # to track which row "wins".
1107
+ group_rows = [g[1] for g in group_sorted]
1108
+ latest = max(
1109
+ group_rows,
1110
+ key=lambda r: r["last_observed_at_utc"] or "",
1111
+ )
1112
+ merged_crossed = max(
1113
+ int(r["crossed_seven_day_reset"] or 0)
1114
+ for r in group_rows
1115
+ )
1116
+ merged_is_closed = max(
1117
+ int(r["is_closed"] or 0) for r in group_rows
1118
+ )
1119
+ merged_in = max(
1120
+ int(r["total_input_tokens"] or 0) for r in group_rows
1121
+ )
1122
+ merged_out = max(
1123
+ int(r["total_output_tokens"] or 0) for r in group_rows
1124
+ )
1125
+ merged_cc = max(
1126
+ int(r["total_cache_create_tokens"] or 0)
1127
+ for r in group_rows
1128
+ )
1129
+ merged_cr = max(
1130
+ int(r["total_cache_read_tokens"] or 0)
1131
+ for r in group_rows
1132
+ )
1133
+ merged_cost = max(
1134
+ float(r["total_cost_usd"] or 0.0) for r in group_rows
1135
+ )
1136
+ conn.execute(
1137
+ """
1138
+ UPDATE five_hour_blocks
1139
+ SET last_observed_at_utc = ?,
1140
+ final_five_hour_percent = ?,
1141
+ seven_day_pct_at_block_end = ?,
1142
+ crossed_seven_day_reset = ?,
1143
+ is_closed = ?,
1144
+ total_input_tokens = ?,
1145
+ total_output_tokens = ?,
1146
+ total_cache_create_tokens = ?,
1147
+ total_cache_read_tokens = ?,
1148
+ total_cost_usd = ?,
1149
+ last_updated_at_utc = ?
1150
+ WHERE id = ?
1151
+ """,
1152
+ (
1153
+ latest["last_observed_at_utc"],
1154
+ latest["final_five_hour_percent"],
1155
+ latest["seven_day_pct_at_block_end"],
1156
+ merged_crossed,
1157
+ merged_is_closed,
1158
+ merged_in,
1159
+ merged_out,
1160
+ merged_cc,
1161
+ merged_cr,
1162
+ merged_cost,
1163
+ now_utc_iso(),
1164
+ canonical["id"],
1165
+ ),
1166
+ )
1167
+
1168
+ # (f) Delete dropped parent block rows.
1169
+ id_placeholders = ",".join("?" * len(dropped_ids))
1170
+ conn.execute(
1171
+ f"DELETE FROM five_hour_blocks "
1172
+ f" WHERE id IN ({id_placeholders})",
1173
+ dropped_ids,
1174
+ )
1175
+
1176
+ conn.execute(
1177
+ """
1178
+ INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc)
1179
+ VALUES (?, ?)
1180
+ """,
1181
+ ("003_merge_5h_block_duplicates_v1", now_utc_iso()),
1182
+ )
1183
+ conn.commit()
1184
+ except Exception:
1185
+ conn.rollback()
1186
+ raise
1187
+
1188
+
1189
+ # === Region 7b: 004 handler — self-heal forked week_start_date buckets ===
1190
+
1191
+ @stats_migration("004_heal_forked_week_start_date_buckets")
1192
+ def _migration_heal_forked_week_start_date_buckets(conn: sqlite3.Connection) -> None:
1193
+ """One-shot self-heal: merge rows whose ``week_start_date`` was forked
1194
+ by a host-TZ contamination at insert time (pre-fix
1195
+ ``_derive_week_from_payload`` / ``pick_week_selection`` took ``.date()``
1196
+ of a host-local-TZ datetime instead of the canonical UTC ISO).
1197
+
1198
+ Defense-in-depth pairing with commit ``6def75f8`` (UTC-anchor the
1199
+ bucket-key date at the writer). The writer fix prevents NEW ghost
1200
+ rows on the FIXED binary, but a still-deployed older binary (e.g.,
1201
+ npm v1.7.0 on the user's machine) can keep writing ghosts every
1202
+ time the host process inherits a non-UTC ``TZ``. This migration
1203
+ auto-merges any such ghost rows on the next ``open_db()``, so the
1204
+ in-place corruption gets cleaned up regardless of which binary
1205
+ happened to write it.
1206
+
1207
+ Invariant: for every row with ``week_start_at IS NOT NULL``,
1208
+ ``week_start_date`` MUST equal ``substr(week_start_at, 1, 10)`` (the
1209
+ canonical UTC calendar day of the subscription-week boundary).
1210
+
1211
+ Per-table action when the invariant is violated:
1212
+
1213
+ * ``weekly_usage_snapshots`` / ``weekly_cost_snapshots`` — no
1214
+ UNIQUE constraint on ``(week_start_date, ...)``, so simply
1215
+ UPDATE both date columns to ``substr(week_start_at, 1, 10)`` /
1216
+ ``substr(week_end_at, 1, 10)``. The ghost rows merge into the
1217
+ canonical bucket as additional samples on the same physical
1218
+ week.
1219
+
1220
+ * ``percent_milestones`` — UNIQUE(week_start_date,
1221
+ percent_threshold). For each ghost row: if a canonical-keyed
1222
+ row at the same threshold already exists, DELETE the ghost
1223
+ (canonical preserves the original alerted_at and the genuine
1224
+ crossing's cumulative cost). Otherwise UPDATE.
1225
+
1226
+ Idempotent: a second invocation finds zero forked rows and is a
1227
+ no-op. Forward-only — never regresses canonical rows. Reads no
1228
+ external state (no ``cache.db`` open, no JSONL walk).
1229
+
1230
+ Empty-table fast path: when none of the three tables has a forked
1231
+ row, INSERT the marker and return without opening a transaction.
1232
+
1233
+ Spec hook: paired regression test in
1234
+ ``tests/test_heal_forked_week_start_date_buckets.py``.
1235
+ """
1236
+ # Empty-fork fast path. UNION ALL across the three tables; one
1237
+ # SELECT 1 / LIMIT 1 short-circuits on the first violator. When
1238
+ # zero rows are forked, skip the BEGIN/UPDATE block entirely and
1239
+ # just stamp the marker.
1240
+ has_fork_row = conn.execute(
1241
+ """
1242
+ SELECT 1 FROM (
1243
+ SELECT 1 FROM weekly_usage_snapshots
1244
+ WHERE week_start_at IS NOT NULL
1245
+ AND week_start_date != substr(week_start_at, 1, 10)
1246
+ UNION ALL
1247
+ SELECT 1 FROM weekly_cost_snapshots
1248
+ WHERE week_start_at IS NOT NULL
1249
+ AND week_start_date != substr(week_start_at, 1, 10)
1250
+ UNION ALL
1251
+ SELECT 1 FROM percent_milestones
1252
+ WHERE week_start_at IS NOT NULL
1253
+ AND week_start_date != substr(week_start_at, 1, 10)
1254
+ ) LIMIT 1
1255
+ """
1256
+ ).fetchone()
1257
+ if not has_fork_row:
1258
+ conn.execute(
1259
+ "INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
1260
+ "VALUES (?, ?)",
1261
+ ("004_heal_forked_week_start_date_buckets", now_utc_iso()),
1262
+ )
1263
+ conn.commit()
1264
+ return
1265
+
1266
+ conn.execute("BEGIN")
1267
+ try:
1268
+ # (a) weekly_usage_snapshots — no UNIQUE; straight UPDATE.
1269
+ conn.execute(
1270
+ """
1271
+ UPDATE weekly_usage_snapshots
1272
+ SET week_start_date = substr(week_start_at, 1, 10),
1273
+ week_end_date = substr(week_end_at, 1, 10)
1274
+ WHERE week_start_at IS NOT NULL
1275
+ AND week_start_date != substr(week_start_at, 1, 10)
1276
+ """
1277
+ )
1278
+
1279
+ # (b) weekly_cost_snapshots — same.
1280
+ conn.execute(
1281
+ """
1282
+ UPDATE weekly_cost_snapshots
1283
+ SET week_start_date = substr(week_start_at, 1, 10),
1284
+ week_end_date = substr(week_end_at, 1, 10)
1285
+ WHERE week_start_at IS NOT NULL
1286
+ AND week_start_date != substr(week_start_at, 1, 10)
1287
+ """
1288
+ )
1289
+
1290
+ # (c) percent_milestones — UNIQUE(week_start_date,
1291
+ # percent_threshold). DELETE ghosts whose canonical-keyed
1292
+ # counterpart already exists at the same threshold BEFORE
1293
+ # UPDATEing the rest, otherwise the UPDATE collides on UNIQUE
1294
+ # and rolls back the migration.
1295
+ conn.execute(
1296
+ """
1297
+ DELETE FROM percent_milestones
1298
+ WHERE week_start_at IS NOT NULL
1299
+ AND week_start_date != substr(week_start_at, 1, 10)
1300
+ AND EXISTS (
1301
+ SELECT 1 FROM percent_milestones canon
1302
+ WHERE canon.week_start_date
1303
+ = substr(percent_milestones.week_start_at, 1, 10)
1304
+ AND canon.percent_threshold
1305
+ = percent_milestones.percent_threshold
1306
+ )
1307
+ """
1308
+ )
1309
+ conn.execute(
1310
+ """
1311
+ UPDATE percent_milestones
1312
+ SET week_start_date = substr(week_start_at, 1, 10),
1313
+ week_end_date = substr(week_end_at, 1, 10)
1314
+ WHERE week_start_at IS NOT NULL
1315
+ AND week_start_date != substr(week_start_at, 1, 10)
1316
+ """
1317
+ )
1318
+
1319
+ conn.execute(
1320
+ """
1321
+ INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc)
1322
+ VALUES (?, ?)
1323
+ """,
1324
+ ("004_heal_forked_week_start_date_buckets", now_utc_iso()),
1325
+ )
1326
+ conn.commit()
1327
+ except Exception:
1328
+ conn.rollback()
1329
+ raise
1330
+
1331
+
1332
+ # === Region 8: Test-only migration registration (was bin/cctally:12086-12140) ===
1333
+
1334
+ # ──────────────────────────────────────────────────────────────────────
1335
+ # Test-only migrations — registered ONLY when CCTALLY_MIGRATION_TEST_MODE=1
1336
+ # AND HARNESS_FAKE_HOME_BASE points at a fixture home. Production runs
1337
+ # never register these. Numbering is dynamic so the test migrations
1338
+ # always slot at len(registry)+1 — future-proof against the next real
1339
+ # migration colliding on the prefix (Codex P2 #8).
1340
+ # ──────────────────────────────────────────────────────────────────────
1341
+ if os.environ.get("CCTALLY_MIGRATION_TEST_MODE") == "1":
1342
+ if not os.environ.get("HARNESS_FAKE_HOME_BASE"):
1343
+ eprint(
1344
+ "cctally: CCTALLY_MIGRATION_TEST_MODE=1 set but "
1345
+ "HARNESS_FAKE_HOME_BASE is empty; refusing to register "
1346
+ "test-only migrations against a non-fixture home."
1347
+ )
1348
+ sys.exit(2)
1349
+
1350
+ _stats_test_seq = len(_STATS_MIGRATIONS) + 1
1351
+ _stats_test_name = f"{_stats_test_seq:03d}_test_failure_injection"
1352
+
1353
+ @stats_migration(_stats_test_name)
1354
+ def _test_migration_failure_injection(conn):
1355
+ """Test-only migration: raises RuntimeError when test_failure_trigger
1356
+ table is non-empty; otherwise inserts the marker and succeeds."""
1357
+ if conn.execute(
1358
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='test_failure_trigger'"
1359
+ ).fetchone() and conn.execute(
1360
+ "SELECT 1 FROM test_failure_trigger LIMIT 1"
1361
+ ).fetchone():
1362
+ raise RuntimeError("test failure injected")
1363
+ conn.execute("BEGIN")
1364
+ try:
1365
+ conn.execute(
1366
+ "INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) VALUES (?, ?)",
1367
+ (_stats_test_name, now_utc_iso()),
1368
+ )
1369
+ conn.commit()
1370
+ except Exception:
1371
+ conn.rollback()
1372
+ raise
1373
+
1374
+ _cache_test_seq = len(_CACHE_MIGRATIONS) + 1
1375
+ _cache_test_name = f"{_cache_test_seq:03d}_test_cache_migration"
1376
+
1377
+ @cache_migration(_cache_test_name)
1378
+ def _test_cache_migration(conn):
1379
+ conn.execute("BEGIN")
1380
+ try:
1381
+ conn.execute(
1382
+ "INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) VALUES (?, ?)",
1383
+ (_cache_test_name, now_utc_iso()),
1384
+ )
1385
+ conn.commit()
1386
+ except Exception:
1387
+ conn.rollback()
1388
+ raise
1389
+
1390
+
1391
+ # === Region 9: db CLI subcommands (was bin/cctally:19707-20043) ===
1392
+
1393
+ def cmd_db_status(args: argparse.Namespace) -> int:
1394
+ """Render migration status across both DBs.
1395
+
1396
+ Spec: docs/superpowers/specs/2026-05-06-migration-framework-design.md §4.2.
1397
+ Glyphs: ✓ applied, ✗ failed, · pending, ~ skipped.
1398
+ """
1399
+ payload = {
1400
+ "schema_version": 1,
1401
+ "databases": {
1402
+ "stats.db": _db_status_for(DB_PATH, _STATS_MIGRATIONS, "stats.db"),
1403
+ "cache.db": _db_status_for(CACHE_DB_PATH, _CACHE_MIGRATIONS, "cache.db"),
1404
+ },
1405
+ }
1406
+ if getattr(args, "json", False):
1407
+ print(json.dumps(payload, indent=2, sort_keys=True))
1408
+ return 0
1409
+ for db_label in ("stats.db", "cache.db"):
1410
+ info = payload["databases"][db_label]
1411
+ skipped_count = sum(1 for m in info["migrations"] if m["status"] == "skipped")
1412
+ suffix = f" ({skipped_count} skipped)" if skipped_count else ""
1413
+ print(
1414
+ f"{db_label} ({info['path']}) "
1415
+ f"version {info['user_version']} / {info['registry_size']} known{suffix}"
1416
+ )
1417
+ for m in info["migrations"]:
1418
+ line = _db_status_format_row(m)
1419
+ print(line)
1420
+ print() # blank line between DBs
1421
+ return 0
1422
+
1423
+
1424
+ def _db_status_for(
1425
+ db_path: pathlib.Path, registry: list[Migration], db_label: str,
1426
+ ) -> dict:
1427
+ """Build per-DB status dict.
1428
+
1429
+ Tolerates missing tables: cache.db never opened by the framework
1430
+ (registry empty in v1) won't have schema_migrations /
1431
+ schema_migrations_skipped, so each lookup is wrapped in a try /
1432
+ except sqlite3.OperationalError.
1433
+ """
1434
+ if not db_path.exists():
1435
+ return {
1436
+ "path": str(db_path),
1437
+ "user_version": 0,
1438
+ "registry_size": len(registry),
1439
+ "migrations": [
1440
+ {"seq": m.seq, "name": m.name, "status": "pending"}
1441
+ for m in registry
1442
+ ],
1443
+ }
1444
+ conn = sqlite3.connect(db_path)
1445
+ try:
1446
+ user_version = conn.execute("PRAGMA user_version").fetchone()[0]
1447
+ # Tolerate missing tables (e.g., cache.db never opened by framework).
1448
+ # Alias legacy unprefixed names to their NNN_ equivalents so a
1449
+ # `db status` that runs before any open_db()-via-bootstrap rename
1450
+ # still reports legacy-marker DBs as applied, not pending.
1451
+ aliases = _LEGACY_MARKER_ALIASES_BY_DB.get(db_label, {})
1452
+ applied_rows: dict[str, str] = {}
1453
+ try:
1454
+ for row in conn.execute(
1455
+ "SELECT name, applied_at_utc FROM schema_migrations"
1456
+ ).fetchall():
1457
+ applied_rows[aliases.get(row[0], row[0])] = row[1]
1458
+ except sqlite3.OperationalError:
1459
+ pass
1460
+ skipped_rows: dict[str, tuple[str, str | None]] = {}
1461
+ try:
1462
+ for row in conn.execute(
1463
+ "SELECT name, skipped_at_utc, reason FROM schema_migrations_skipped"
1464
+ ).fetchall():
1465
+ skipped_rows[row[0]] = (row[1], row[2])
1466
+ except sqlite3.OperationalError:
1467
+ pass
1468
+ finally:
1469
+ conn.close()
1470
+
1471
+ failed_names = _db_status_failed_names_from_log(db_label)
1472
+ migrations_out: list[dict] = []
1473
+ for m in registry:
1474
+ if m.name in skipped_rows:
1475
+ ts, reason = skipped_rows[m.name]
1476
+ migrations_out.append({
1477
+ "seq": m.seq, "name": m.name,
1478
+ "status": "skipped",
1479
+ "skipped_at": ts,
1480
+ "reason": reason,
1481
+ })
1482
+ elif m.name in applied_rows:
1483
+ migrations_out.append({
1484
+ "seq": m.seq, "name": m.name,
1485
+ "status": "applied",
1486
+ "applied_at": applied_rows[m.name],
1487
+ })
1488
+ elif m.name in failed_names:
1489
+ migrations_out.append({
1490
+ "seq": m.seq, "name": m.name,
1491
+ "status": "failed",
1492
+ "last_failure_at": failed_names[m.name],
1493
+ "log_path": str(MIGRATION_ERROR_LOG_PATH),
1494
+ })
1495
+ else:
1496
+ migrations_out.append({
1497
+ "seq": m.seq, "name": m.name,
1498
+ "status": "pending",
1499
+ })
1500
+ return {
1501
+ "path": str(db_path),
1502
+ "user_version": user_version,
1503
+ "registry_size": len(registry),
1504
+ "migrations": migrations_out,
1505
+ }
1506
+
1507
+
1508
+ def _db_status_failed_names_from_log(db_label: str) -> dict[str, str]:
1509
+ """Parse migration-errors.log for the most-recent failure per qualified name.
1510
+
1511
+ Returns {migration_name (unqualified): last_failure_iso}. Names whose
1512
+ log entries lack the db_label prefix (pre-framework legacy entries)
1513
+ are NOT included for cache.db; for stats.db, legacy names like
1514
+ `merge_5h_block_duplicates_v1` are bootstrap-renamed at next open
1515
+ (via Task 4) so they don't accumulate post-PR.
1516
+ """
1517
+ if not MIGRATION_ERROR_LOG_PATH.exists():
1518
+ return {}
1519
+ out: dict[str, str] = {}
1520
+ try:
1521
+ content = MIGRATION_ERROR_LOG_PATH.read_text()
1522
+ except Exception:
1523
+ return {}
1524
+ blocks = [b for b in content.split("\n\n") if b.strip()]
1525
+ for block in blocks:
1526
+ first_line = block.splitlines()[0] if block.splitlines() else ""
1527
+ if not first_line.startswith("[") or "] " not in first_line:
1528
+ continue
1529
+ ts_part, _, name_part = first_line[1:].partition("] ")
1530
+ ts = ts_part.strip()
1531
+ qualified = name_part.strip()
1532
+ if ":" not in qualified:
1533
+ continue
1534
+ prefix, _, name = qualified.partition(":")
1535
+ if prefix != db_label:
1536
+ continue
1537
+ out[name] = ts # later block overrides earlier — most-recent wins
1538
+ return out
1539
+
1540
+
1541
+ def _db_status_format_row(m: dict) -> str:
1542
+ name = m["name"]
1543
+ status = m["status"]
1544
+ if status == "applied":
1545
+ return f" ✓ {name:<46} applied {m['applied_at']}"
1546
+ if status == "skipped":
1547
+ line = f" ~ {name:<46} skipped {m['skipped_at']}"
1548
+ if m.get("reason"):
1549
+ line += f"\n Reason: {m['reason']}"
1550
+ return line
1551
+ if status == "failed":
1552
+ return (
1553
+ f" ✗ {name:<46} FAILED last at {m['last_failure_at']}\n"
1554
+ f" See {m['log_path']}"
1555
+ )
1556
+ return f" · {name:<46} pending"
1557
+
1558
+
1559
+ def _db_resolve_migration_name(name_arg: str) -> tuple[str, str, list[Migration]]:
1560
+ """Resolve a name argument to (db_label, unqualified_name, registry).
1561
+
1562
+ Spec §4.1 routing:
1563
+ - "stats.db:NNN_…" → stats registry only.
1564
+ - "cache.db:NNN_…" → cache registry only.
1565
+ - "NNN_…" (bare) → both; ambiguous if matches both, exit 2.
1566
+
1567
+ Raises:
1568
+ LookupError: name not found in any registry (caller exits 1).
1569
+ RuntimeError: ambiguous bare name (caller exits 2).
1570
+ """
1571
+ if name_arg.startswith("stats.db:"):
1572
+ unq = name_arg[len("stats.db:"):]
1573
+ if any(m.name == unq for m in _STATS_MIGRATIONS):
1574
+ return "stats.db", unq, _STATS_MIGRATIONS
1575
+ raise LookupError(name_arg)
1576
+ if name_arg.startswith("cache.db:"):
1577
+ unq = name_arg[len("cache.db:"):]
1578
+ if any(m.name == unq for m in _CACHE_MIGRATIONS):
1579
+ return "cache.db", unq, _CACHE_MIGRATIONS
1580
+ raise LookupError(name_arg)
1581
+ in_stats = any(m.name == name_arg for m in _STATS_MIGRATIONS)
1582
+ in_cache = any(m.name == name_arg for m in _CACHE_MIGRATIONS)
1583
+ if in_stats and in_cache:
1584
+ raise RuntimeError(name_arg)
1585
+ if in_stats:
1586
+ return "stats.db", name_arg, _STATS_MIGRATIONS
1587
+ if in_cache:
1588
+ return "cache.db", name_arg, _CACHE_MIGRATIONS
1589
+ raise LookupError(name_arg)
1590
+
1591
+
1592
+ def _db_path_for_label(db_label: str) -> pathlib.Path:
1593
+ if db_label == "stats.db":
1594
+ return DB_PATH
1595
+ if db_label == "cache.db":
1596
+ return CACHE_DB_PATH
1597
+ raise ValueError(f"unknown db_label: {db_label}")
1598
+
1599
+
1600
+ def cmd_db_skip(args: argparse.Namespace) -> int:
1601
+ """Mark a migration as skipped.
1602
+
1603
+ Spec §4.3. Bypasses ``open_db()`` (raw ``sqlite3.connect``) so the
1604
+ pending migration cannot be triggered en route to recording the
1605
+ skip — that defeats the entire point.
1606
+ """
1607
+ name_arg = args.name
1608
+ try:
1609
+ db_label, name, _ = _db_resolve_migration_name(name_arg)
1610
+ except RuntimeError:
1611
+ eprint(
1612
+ f"cctally: ambiguous migration name '{name_arg}'; "
1613
+ f"qualify as 'stats.db:{name_arg}' or 'cache.db:{name_arg}'"
1614
+ )
1615
+ return 2
1616
+ except LookupError:
1617
+ eprint(f"cctally: unknown migration '{name_arg}'.")
1618
+ return 1
1619
+
1620
+ path = _db_path_for_label(db_label)
1621
+ # Ensure the parent dir exists — fresh-install HOME may not have
1622
+ # ~/.local/share/cctally/ yet, and sqlite3.connect() does NOT
1623
+ # create parent directories (only the DB file itself).
1624
+ path.parent.mkdir(parents=True, exist_ok=True)
1625
+ conn = sqlite3.connect(path)
1626
+ try:
1627
+ conn.execute(
1628
+ """
1629
+ CREATE TABLE IF NOT EXISTS schema_migrations_skipped (
1630
+ name TEXT PRIMARY KEY,
1631
+ skipped_at_utc TEXT NOT NULL,
1632
+ reason TEXT
1633
+ )
1634
+ """
1635
+ )
1636
+ conn.execute(
1637
+ "CREATE TABLE IF NOT EXISTS schema_migrations "
1638
+ "(name TEXT PRIMARY KEY, applied_at_utc TEXT NOT NULL)"
1639
+ )
1640
+ # Reject if already applied. Also check the legacy unprefixed
1641
+ # alias: pre-framework DBs may store the marker under e.g.
1642
+ # `merge_5h_block_duplicates_v1` until the next open_db()
1643
+ # bootstrap rename. Without this, `db skip 003_…` succeeds
1644
+ # against an already-applied legacy-marker DB and leaves the
1645
+ # row in BOTH schema_migrations (post-rename) AND
1646
+ # schema_migrations_skipped — inconsistent state.
1647
+ applied_check_names = [name]
1648
+ for legacy, new in _LEGACY_MARKER_ALIASES_BY_DB.get(db_label, {}).items():
1649
+ if new == name:
1650
+ applied_check_names.append(legacy)
1651
+ placeholders = ",".join("?" * len(applied_check_names))
1652
+ if conn.execute(
1653
+ f"SELECT 1 FROM schema_migrations WHERE name IN ({placeholders})",
1654
+ applied_check_names,
1655
+ ).fetchone():
1656
+ eprint(f"cctally: {name} is already applied; nothing to skip.")
1657
+ return 1
1658
+ # Reject if already skipped.
1659
+ if conn.execute(
1660
+ "SELECT 1 FROM schema_migrations_skipped WHERE name = ?", (name,)
1661
+ ).fetchone():
1662
+ eprint(f"cctally: {name} is already skipped.")
1663
+ return 1
1664
+ conn.execute(
1665
+ "INSERT INTO schema_migrations_skipped "
1666
+ "(name, skipped_at_utc, reason) VALUES (?, ?, ?)",
1667
+ (name, now_utc_iso(), args.reason),
1668
+ )
1669
+ conn.commit()
1670
+ finally:
1671
+ conn.close()
1672
+ print(f"Skipped: {name}")
1673
+ return 0
1674
+
1675
+
1676
+ def cmd_db_unskip(args: argparse.Namespace) -> int:
1677
+ """Remove a skip mark.
1678
+
1679
+ Spec §4.4. After deleting the row, writes ``PRAGMA user_version = 0``
1680
+ to invalidate the dispatcher's fast path (Codex P1 #1) — without
1681
+ this, the unskipped migration would silently never run because
1682
+ ``cur_version == len(registry)`` short-circuits on the next open.
1683
+
1684
+ Bypasses ``open_db()`` so the migration about to be unskipped can
1685
+ actually run on the next legitimate open, not from inside this
1686
+ handler.
1687
+ """
1688
+ name_arg = args.name
1689
+ try:
1690
+ db_label, name, _ = _db_resolve_migration_name(name_arg)
1691
+ except RuntimeError:
1692
+ eprint(
1693
+ f"cctally: ambiguous migration name '{name_arg}'; "
1694
+ f"qualify as 'stats.db:{name_arg}' or 'cache.db:{name_arg}'"
1695
+ )
1696
+ return 2
1697
+ except LookupError:
1698
+ eprint(f"cctally: unknown migration '{name_arg}'.")
1699
+ return 1
1700
+
1701
+ path = _db_path_for_label(db_label)
1702
+ # If the DB file doesn't even exist, the migration cannot have been
1703
+ # skipped. Avoid creating an empty stats.db / cache.db just to print
1704
+ # the no-op message (sqlite3.connect would create the file
1705
+ # otherwise — leaving stale empty DBs around for fresh-install
1706
+ # users who poke at unskip).
1707
+ if not path.exists():
1708
+ print(f"cctally: {name} is not skipped; nothing to do.")
1709
+ return 0
1710
+ conn = sqlite3.connect(path)
1711
+ try:
1712
+ try:
1713
+ cur = conn.execute(
1714
+ "DELETE FROM schema_migrations_skipped WHERE name = ?", (name,)
1715
+ )
1716
+ except sqlite3.OperationalError:
1717
+ # Table doesn't exist → nothing was ever skipped.
1718
+ print(f"cctally: {name} is not skipped; nothing to do.")
1719
+ return 0
1720
+ if cur.rowcount == 0:
1721
+ print(f"cctally: {name} is not skipped; nothing to do.")
1722
+ return 0
1723
+ # Invalidate the fast path so the dispatcher re-walks (Codex P1 #1).
1724
+ conn.execute("PRAGMA user_version = 0")
1725
+ conn.commit()
1726
+ finally:
1727
+ conn.close()
1728
+ print(f"Unskipped: {name} (will run on next open).")
1729
+ return 0