264 lines
12 KiB
TypeScript
264 lines
12 KiB
TypeScript
import log from "electron-log/main.js";
|
|
import type { Migration, MigrationDeps } from "./types";
|
|
import { columnExists, nowIso, tableExists } from "./types";
|
|
|
|
/**
|
|
* B2 Phase 1 step 1/5 — dual-write substrate.
|
|
*
|
|
* This migration installs the new `tasks.phase` column and the new
|
|
* `task_leases` table **without** switching the runtime over. Existing
|
|
* reads and writes continue against `tasks.status` /
|
|
* `tasks.checkout_run_id` / `tasks.execution_locked_at`; the new shapes
|
|
* ship as a shadow surface that later phases (2/5 onward) populate in
|
|
* parallel so we can verify the state machine before flipping reads.
|
|
*
|
|
* The migration is transactional by virtue of `runMigrations` wrapping
|
|
* every `migration.fn` in `BEGIN IMMEDIATE`. Backfill and invariant
|
|
* assertion therefore commit atomically with the schema change: a
|
|
* failure at any step rolls the entire migration back, and the
|
|
* pre-migration automatic snapshot (taken by `runMigrations` before
|
|
* applying pending migrations) stays available as the last-resort
|
|
* recovery point.
|
|
*
|
|
* Phase enum follows the user-supplied canonical set:
|
|
* 'todo' | 'backlog' | 'in_progress' | 'blocked' | 'completed' | 'cancelled'
|
|
*
|
|
* The backfill maps the six legacy `tasks.status` values 1:1 — with
|
|
* `done → completed` and `in_review → in_progress` (a reviewable task
|
|
* is still mid-flight, and the review stage is orthogonal to phase).
|
|
* Any post-backfill row with `phase IS NULL` represents data the map
|
|
* did not anticipate; the assertion at the end of the migration throws
|
|
* rather than silently leaving the shadow column inconsistent.
|
|
*
|
|
* `task_leases` mirrors the lease component of the current lock
|
|
* metadata (`checkout_run_id` + `execution_locked_at`). Only rows whose
|
|
* task currently owns an `active_run_id` OR has a live `checkout_run_id`
|
|
* get a seeded lease; `expires_at` uses the existing lease timestamp
|
|
* plus the runtime stale-lock timeout when present, otherwise a 15-minute
|
|
* default from `now()` so the stale-lock sweep still applies to seeded
|
|
* rows. The seed is best-effort: missing runs (referenced by a stale
|
|
* `checkout_run_id` but deleted from `runs`) are skipped rather than
|
|
* throwing, because the FK would reject them and the existing lock was
|
|
* already orphaned pre-migration.
|
|
*/
|
|
|
|
const STALE_LOCK_TIMEOUT_MS = 15 * 60 * 1000;
|
|
|
|
export function createMigrationsV50V50(deps: MigrationDeps): Migration[] {
|
|
const { db } = deps;
|
|
return [
|
|
{
|
|
version: 50,
|
|
name: "tasks_phase_and_leases_dual_write",
|
|
fn: () => {
|
|
if (!tableExists(db, "tasks")) {
|
|
log.info("[migration v50] tasks table missing; nothing to do");
|
|
return;
|
|
}
|
|
|
|
// ── 1. Add `tasks.phase` (nullable at add time so the ALTER
|
|
// succeeds on an existing non-empty table). The CHECK
|
|
// constraint enforces the enum for new writes; legacy rows
|
|
// populate via the backfill two steps down.
|
|
if (!columnExists(db, "tasks", "phase")) {
|
|
db.exec(
|
|
`ALTER TABLE tasks ADD COLUMN phase TEXT CHECK (
|
|
phase IS NULL OR phase IN (
|
|
'todo', 'backlog', 'in_progress', 'blocked', 'completed', 'cancelled'
|
|
)
|
|
)`,
|
|
);
|
|
log.info("[migration v50] tasks.phase column added");
|
|
} else {
|
|
log.info("[migration v50] tasks.phase already present; reusing");
|
|
}
|
|
|
|
// ── 2. Create `task_leases`. The UNIQUE(task_id, run_id) pair
|
|
// matches the M7 design — a task can hold at most one active
|
|
// lease (PRIMARY KEY task_id), and that lease cannot belong
|
|
// to two different runs simultaneously.
|
|
db.exec(
|
|
`CREATE TABLE IF NOT EXISTS task_leases (
|
|
task_id TEXT PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE,
|
|
run_id TEXT NOT NULL REFERENCES runs(id),
|
|
acquired_at TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL,
|
|
checkout_run_id TEXT,
|
|
UNIQUE(task_id, run_id)
|
|
)`,
|
|
);
|
|
db.exec(
|
|
`CREATE INDEX IF NOT EXISTS idx_task_leases_expires_at ON task_leases(expires_at)`,
|
|
);
|
|
log.info("[migration v50] task_leases table ensured");
|
|
|
|
// ── 3. Backfill `phase` from the legacy `status` enum.
|
|
// Mapping:
|
|
// backlog → backlog
|
|
// todo → todo
|
|
// in_progress → in_progress
|
|
// blocked → blocked
|
|
// done → completed
|
|
// cancelled → cancelled
|
|
// in_review → in_progress (reviewable task is still mid-flight)
|
|
db.prepare(
|
|
`UPDATE tasks
|
|
SET phase = CASE status
|
|
WHEN 'backlog' THEN 'backlog'
|
|
WHEN 'todo' THEN 'todo'
|
|
WHEN 'in_progress' THEN 'in_progress'
|
|
WHEN 'blocked' THEN 'blocked'
|
|
WHEN 'done' THEN 'completed'
|
|
WHEN 'cancelled' THEN 'cancelled'
|
|
WHEN 'in_review' THEN 'in_progress'
|
|
END
|
|
WHERE phase IS NULL`,
|
|
).run();
|
|
log.info("[migration v50] phase backfilled from status");
|
|
|
|
// ── 3b. Triggers that maintain the phase/status invariant for
|
|
// writes that still go through the legacy columns. Step 2/5
|
|
// introduces an explicit `advanceTaskPhase` helper and step
|
|
// 3/5 wires it into the production call sites, but until
|
|
// then any `INSERT INTO tasks` or `UPDATE tasks SET status
|
|
// = …` that bypasses the helper must still yield a row with
|
|
// a consistent `phase` column — otherwise dual-read
|
|
// observability in step 4/5 would surface false drift the
|
|
// moment any task row is created.
|
|
//
|
|
// The triggers are deliberately one-directional (status →
|
|
// phase). `advanceTaskPhase` writes phase directly; the
|
|
// legacy-column writers that coexist during dual-write
|
|
// continue to set `status` and now have their shadow phase
|
|
// kept in sync automatically. Once step 3/5 lands and every
|
|
// call site dual-writes both columns explicitly, these
|
|
// triggers become a belt-and-braces backstop rather than
|
|
// the primary invariant.
|
|
db.exec(
|
|
`DROP TRIGGER IF EXISTS trg_tasks_phase_from_status_insert;
|
|
DROP TRIGGER IF EXISTS trg_tasks_phase_from_status_update;`,
|
|
);
|
|
db.exec(
|
|
`CREATE TRIGGER trg_tasks_phase_from_status_insert
|
|
AFTER INSERT ON tasks
|
|
WHEN NEW.phase IS NULL AND NEW.status IS NOT NULL
|
|
BEGIN
|
|
UPDATE tasks SET phase = CASE NEW.status
|
|
WHEN 'backlog' THEN 'backlog'
|
|
WHEN 'todo' THEN 'todo'
|
|
WHEN 'in_progress' THEN 'in_progress'
|
|
WHEN 'blocked' THEN 'blocked'
|
|
WHEN 'done' THEN 'completed'
|
|
WHEN 'cancelled' THEN 'cancelled'
|
|
WHEN 'in_review' THEN 'in_progress'
|
|
END
|
|
WHERE id = NEW.id;
|
|
END;`,
|
|
);
|
|
db.exec(
|
|
`CREATE TRIGGER trg_tasks_phase_from_status_update
|
|
AFTER UPDATE OF status ON tasks
|
|
WHEN NEW.status IS NOT OLD.status
|
|
BEGIN
|
|
UPDATE tasks SET phase = CASE NEW.status
|
|
WHEN 'backlog' THEN 'backlog'
|
|
WHEN 'todo' THEN 'todo'
|
|
WHEN 'in_progress' THEN 'in_progress'
|
|
WHEN 'blocked' THEN 'blocked'
|
|
WHEN 'done' THEN 'completed'
|
|
WHEN 'cancelled' THEN 'cancelled'
|
|
WHEN 'in_review' THEN 'in_progress'
|
|
END
|
|
WHERE id = NEW.id;
|
|
END;`,
|
|
);
|
|
log.info("[migration v50] status → phase mirroring triggers installed");
|
|
|
|
// ── 4. Seed `task_leases` from the current lock columns.
|
|
// A task currently holds a runtime lease when `active_run_id`
|
|
// is set (run is live) or when `checkout_run_id` is set
|
|
// (run is acquiring). Both map to the same lease row.
|
|
//
|
|
// `expires_at` uses `execution_locked_at + STALE_LOCK_TIMEOUT_MS`
|
|
// when present; otherwise it falls back to `now() + 15 minutes`
|
|
// so the stale-lock sweep keeps working on seeded rows.
|
|
//
|
|
// The seed cannot attribute to a run that no longer exists
|
|
// (FK violation on `run_id`). Such rows are skipped — they
|
|
// are already orphaned state in the legacy columns, and the
|
|
// migration is not the right place to clean them up. A
|
|
// follow-up observability pass can surface them.
|
|
const nowMs = Date.now();
|
|
const ts = nowIso();
|
|
const seedRows = db.prepare(
|
|
`SELECT t.id AS taskId,
|
|
COALESCE(t.active_run_id, t.checkout_run_id) AS runId,
|
|
t.active_run_id AS activeRunId,
|
|
t.checkout_run_id AS checkoutRunId,
|
|
t.execution_locked_at AS executionLockedAt
|
|
FROM tasks t
|
|
WHERE (t.active_run_id IS NOT NULL OR t.checkout_run_id IS NOT NULL)`,
|
|
).all() as Array<{
|
|
taskId: string;
|
|
runId: string | null;
|
|
activeRunId: string | null;
|
|
checkoutRunId: string | null;
|
|
executionLockedAt: string | null;
|
|
}>;
|
|
|
|
const runExistsStmt = db.prepare(`SELECT 1 AS present FROM runs WHERE id = ?`);
|
|
const insertLeaseStmt = db.prepare(
|
|
`INSERT OR IGNORE INTO task_leases
|
|
(task_id, run_id, acquired_at, expires_at, checkout_run_id)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
);
|
|
|
|
let seeded = 0;
|
|
let skipped = 0;
|
|
for (const row of seedRows) {
|
|
const runId = row.runId;
|
|
if (!runId) continue;
|
|
const runExists = runExistsStmt.get(runId) as { present: number } | undefined;
|
|
if (!runExists) {
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
const acquiredAt = row.executionLockedAt ?? ts;
|
|
const lockedAtMs = row.executionLockedAt
|
|
? Date.parse(row.executionLockedAt)
|
|
: NaN;
|
|
const expiresBaseMs = Number.isFinite(lockedAtMs) ? lockedAtMs : nowMs;
|
|
const expiresAt = new Date(expiresBaseMs + STALE_LOCK_TIMEOUT_MS).toISOString();
|
|
insertLeaseStmt.run(row.taskId, runId, acquiredAt, expiresAt, row.checkoutRunId ?? null);
|
|
seeded += 1;
|
|
}
|
|
log.info(
|
|
`[migration v50] task_leases seed complete: ${seeded} seeded, ${skipped} skipped (run missing)`,
|
|
);
|
|
|
|
// ── 5. Invariant assertion: every task row must now carry a
|
|
// phase value. If the backfill missed a status we do not
|
|
// know about, fail the migration so the problem is visible
|
|
// at upgrade time rather than days later when a checkoutTask
|
|
// query silently excludes the row.
|
|
const missingRow = db.prepare(
|
|
`SELECT COUNT(*) AS count FROM tasks WHERE phase IS NULL`,
|
|
).get() as { count: number } | undefined;
|
|
const missingCount = missingRow?.count ?? 0;
|
|
if (missingCount > 0) {
|
|
const sample = db.prepare(
|
|
`SELECT id, status FROM tasks WHERE phase IS NULL LIMIT 5`,
|
|
).all() as Array<{ id: string; status: string }>;
|
|
const sampleText = sample
|
|
.map((row) => `${row.id}=${row.status}`)
|
|
.join(", ");
|
|
throw new Error(
|
|
`[migration v50] phase backfill left ${missingCount} rows NULL — unknown status values: ${sampleText}`,
|
|
);
|
|
}
|
|
log.info("[migration v50] invariant check passed: every task has a phase");
|
|
},
|
|
},
|
|
];
|
|
}
|