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.
- package/CHANGELOG.md +22 -0
- package/bin/_cctally_alerts.py +231 -0
- package/bin/_cctally_cache.py +1432 -0
- package/bin/_cctally_config.py +560 -0
- package/bin/_cctally_dashboard.py +5218 -0
- package/bin/_cctally_db.py +1729 -0
- package/bin/_cctally_record.py +2120 -0
- package/bin/_cctally_refresh.py +812 -0
- package/bin/_cctally_release.py +751 -0
- package/bin/_cctally_setup.py +1571 -0
- package/bin/_cctally_sync_week.py +110 -0
- package/bin/_cctally_tui.py +4381 -0
- package/bin/_cctally_update.py +2132 -0
- package/bin/_lib_aggregators.py +712 -0
- package/bin/_lib_alerts_payload.py +194 -0
- package/bin/_lib_blocks.py +414 -0
- package/bin/_lib_diff_kernel.py +1618 -0
- package/bin/_lib_display_tz.py +361 -0
- package/bin/_lib_doctor.py +961 -0
- package/bin/_lib_five_hour.py +82 -0
- package/bin/_lib_jsonl.py +403 -0
- package/bin/_lib_pricing.py +520 -0
- package/bin/_lib_render.py +2785 -0
- package/bin/_lib_semver.py +105 -0
- package/bin/_lib_share.py +350 -32
- package/bin/_lib_share_templates.py +233 -44
- package/bin/_lib_subscription_weeks.py +492 -0
- package/bin/cctally +11061 -34659
- package/dashboard/static/assets/index-BgpoazlS.js +18 -0
- package/dashboard/static/assets/index-nJdUaGys.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +25 -1
- package/dashboard/static/assets/index-Z6V0XgqK.js +0 -18
- package/dashboard/static/assets/index-ZPC0pk-h.css +0 -1
|
@@ -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
|