Migrate group_run.status to allow 'dry_run'

schema.sql has carried 'dry_run' in the group_run.status CHECK for a while,
but CREATE TABLE IF NOT EXISTS never re-applies a constraint to a database
that already has the table. Any DB created before that value was added kept
the old four-value CHECK, so run_group's `final = "dry_run"` (runner.py:230)
hit an IntegrityError in finish_group_run on every group dry run.

The per-module work was unaffected -- run_log already permits 'dry_run', so
each member recorded its resolved source and merge SQL correctly. Only the
group-level bookkeeping failed, leaving the group_run row stuck at
'running'. Via the web UI the failure was invisible:
_run_group_in_background swallows the exception and writes status='error',
mislabeling a successful dry run.

SQLite has no ALTER for CHECK constraints, so this rebuilds the table, the
same treatment run_log received earlier. Idempotent on the constraint text,
so it runs once. Verified on the live DB: 10,209 rows preserved, no foreign
key violations, sqlite_sequence intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-08-19 17:32:34 -04:00
parent e4c17bfc98
commit ae959aedad

View File

@ -48,6 +48,31 @@ def _apply_migrations(conn: sqlite3.Connection) -> None:
if "last_fired_at" not in sc_cols:
conn.execute("ALTER TABLE schedule ADD COLUMN last_fired_at TEXT")
# group_run.status predates 'dry_run'. schema.sql carries the correct CHECK, but
# CREATE TABLE IF NOT EXISTS never re-applies it to an existing DB, so a group
# dry run raised IntegrityError in finish_group_run. CHECK constraints need a
# table rebuild (SQLite has no ALTER for them); run_log got the same treatment
# earlier. Idempotent: keyed on the constraint text itself.
gr_ddl = conn.execute(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='group_run'"
).fetchone()
if gr_ddl and "dry_run" not in gr_ddl[0]:
conn.executescript("""
CREATE TABLE group_run_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL REFERENCES grp(id),
started_at TEXT DEFAULT (datetime('now')),
finished_at TEXT,
status TEXT NOT NULL DEFAULT 'running'
CHECK (status IN ('running','success','error','cancelled','dry_run')),
triggered_by TEXT
);
INSERT INTO group_run_new (id, group_id, started_at, finished_at, status, triggered_by)
SELECT id, group_id, started_at, finished_at, status, triggered_by FROM group_run;
DROP TABLE group_run;
ALTER TABLE group_run_new RENAME TO group_run;
""")
@contextmanager
def connect(db_path: Path | None = None):