From ae959aedadd7b3c9776ce5c142363d34e2f98479 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Wed, 19 Aug 2026 17:32:34 -0400 Subject: [PATCH] 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 --- pipekit/db.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/pipekit/db.py b/pipekit/db.py index dfa48ff..e85f24f 100644 --- a/pipekit/db.py +++ b/pipekit/db.py @@ -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):