Files
AgentCompany/src/main/database/migrations/v50-v50.ts
T

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");
},
},
];
}