feat(FN-3887): generalize schema compatibility reconciliation across schema

Generalizes schema compatibility reconciliation in the core database layer with hardened architecture lint table discovery, backed by new and expanded tests and aligned documentation; includes a changeset for the `@runfusion/fusion` package.

Fusion-Task-Id: FN-3887
This commit is contained in:
Fusion
2026-05-09 17:43:30 -07:00
committed by gsxdsm
parent abd2e71609
commit d74197ebf3
5 changed files with 487 additions and 42 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
Generalize the SQLite schema self-heal pass to reconcile missing columns for every critical table on `Database.init()`, not just `tasks`.
This prevents legacy or drifted databases from hitting `no such column: <X>` regressions after new column additions, and adds architecture lint coverage to ensure new `CREATE TABLE` definitions are always included in schema-compatibility coverage.

View File

@@ -218,6 +218,12 @@ Additional backend notes:
| `eval_task_results` | Per-task eval outcomes linked to runs (`runId` FK cascade), including durable task snapshots and structured score payloads. `categoryScores[]` stores canonical per-category fields (`category`, `deterministicScore`, `aiScore`, `finalScore`, `weight`, `band`, `rationale`, `evidence[]`), plus `overallScore` derived from category finals. Also stores deterministic/AI signal payloads, summary rationale, structured follow-up suggestions (`suggestionId`, `dedupeKey`, recommendation, lifecycle state, suppression fields, optional `createdTaskId` linkage), and a bounded `TaskEvaluationEvidenceBundle` (fixed source-order groups, capped entry counts, max 500-char excerpts with truncation marker) embedded in result metadata for backward-compatible persistence. | | `eval_task_results` | Per-task eval outcomes linked to runs (`runId` FK cascade), including durable task snapshots and structured score payloads. `categoryScores[]` stores canonical per-category fields (`category`, `deterministicScore`, `aiScore`, `finalScore`, `weight`, `band`, `rationale`, `evidence[]`), plus `overallScore` derived from category finals. Also stores deterministic/AI signal payloads, summary rationale, structured follow-up suggestions (`suggestionId`, `dedupeKey`, recommendation, lifecycle state, suppression fields, optional `createdTaskId` linkage), and a bounded `TaskEvaluationEvidenceBundle` (fixed source-order groups, capped entry counts, max 500-char excerpts with truncation marker) embedded in result metadata for backward-compatible persistence. |
| `eval_run_events` | Append-only eval run event trail (`runId` FK cascade, ordered by `seq`) for orchestration/debug auditing and downstream API/UI drill-down. | | `eval_run_events` | Append-only eval run event trail (`runId` FK cascade, ordered by `seq`) for orchestration/debug auditing and downstream API/UI drill-down. |
### Schema self-heal on init
`Database.init()` now runs an unconditional schema-compatibility reconciliation pass after versioned migrations. The pass unions table definitions from `SCHEMA_SQL` plus `MIGRATION_ONLY_TABLE_SCHEMAS`, then backfills missing columns with `addColumnIfMissing()` for tables that already exist.
Invariant: after init, every declared column for covered tables exists regardless of `__meta.schemaVersion`, preventing legacy drift from causing `no such column` regressions on newly added fields.
--- ---
### Chat rooms (migration 70) ### Chat rooms (migration 70)

View File

@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import { readFileSync, mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { Database, getSchemaSqlTableSchemas, MIGRATION_ONLY_TABLE_SCHEMAS } from "../db.js";
function readDbSource(): string {
return readFileSync(new URL("../db.ts", import.meta.url), "utf8");
}
describe("architecture schema compatibility", () => {
it("invokes ensureSchemaCompatibility() from init()", () => {
const source = readDbSource();
expect(source).toMatch(/private ensureSchemaCompatibility\(\): void/);
expect(source).toMatch(/this\.migrate\(\);\s*[\s\S]*?this\.ensureSchemaCompatibility\(\);/);
});
it("restores missing declared columns for SCHEMA_SQL tables", () => {
const source = readDbSource();
const versionMatch = source.match(/^const SCHEMA_VERSION = (\d+);/m);
expect(versionMatch).not.toBeNull();
const schemaVersion = Number(versionMatch?.[1]);
const indexedColumnsByTable = new Map<string, Set<string>>();
for (const match of source.matchAll(/CREATE INDEX IF NOT EXISTS\s+\w+\s+ON\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]+)\)/g)) {
const table = match[1];
const cols = match[2]
.split(",")
.map((column) => column.trim().replace(/\s+(ASC|DESC)$/i, ""));
const set = indexedColumnsByTable.get(table) ?? new Set<string>();
cols.forEach((column) => set.add(column));
indexedColumnsByTable.set(table, set);
}
const isSafeToDrop = (definition: string): boolean => {
const upper = definition.toUpperCase();
if (upper.includes("PRIMARY KEY")) return false;
if (upper.includes("NOT NULL") && !upper.includes("DEFAULT")) return false;
return true;
};
for (const [tableName, columns] of getSchemaSqlTableSchemas()) {
const entries = [...columns.entries()];
const indexedColumns = indexedColumnsByTable.get(tableName) ?? new Set<string>();
const removable = entries.find(([name, definition]) => isSafeToDrop(definition) && !indexedColumns.has(name));
if (!removable) continue;
const [removedColumnName] = removable;
const keptColumns = entries.filter(([name]) => name !== removedColumnName);
const legacyTableSql = keptColumns
.map(([name, def]) => ` "${name}" ${def}`)
.join(",\n");
const fusionDir = mkdtempSync(join(tmpdir(), "kb-schema-compat-"));
const db = new Database(fusionDir, { inMemory: true });
db.exec(`CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)`);
db.exec(`CREATE TABLE IF NOT EXISTS ${tableName} (\n${legacyTableSql}\n)`);
db.exec(`INSERT INTO __meta (key, value) VALUES ('schemaVersion', '${schemaVersion}')`);
db.exec(`INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')`);
db.init();
const actualColumns = new Set(
(db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string }>).map((column) => column.name),
);
expect(
actualColumns.has(removedColumnName),
`expected column ${tableName}.${removedColumnName} after init() but it is missing`,
).toBe(true);
db.close();
}
});
it("covers every CREATE TABLE in db.ts via SCHEMA_SQL or MIGRATION_ONLY_TABLE_SCHEMAS", () => {
const source = readDbSource();
const discoveredTables = new Set<string>();
const createTableRegex = /CREATE TABLE\s+(?:IF NOT EXISTS\s+)?([A-Za-z_][A-Za-z0-9_]*)/g;
for (const match of source.matchAll(createTableRegex)) {
discoveredTables.add(match[1]);
}
const coveredTables = new Set<string>([
...[...getSchemaSqlTableSchemas().keys()],
...Object.keys(MIGRATION_ONLY_TABLE_SCHEMAS),
]);
for (const tableName of discoveredTables) {
expect(
coveredTables.has(tableName),
`Table ${tableName} is created in db.ts but not covered by ensureSchemaCompatibility(). Add it to SCHEMA_SQL or MIGRATION_ONLY_TABLE_SCHEMAS in db.ts.`,
).toBe(true);
}
});
});

View File

@@ -1,5 +1,14 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { Database, createDatabase, toJson, toJsonNullable, fromJson, normalizeTaskComments } from "../db.js"; import {
Database,
createDatabase,
toJson,
toJsonNullable,
fromJson,
normalizeTaskComments,
getSchemaSqlTableSchemas,
MIGRATION_ONLY_TABLE_SCHEMAS,
} from "../db.js";
import { DEFAULT_PROJECT_SETTINGS } from "../types.js"; import { DEFAULT_PROJECT_SETTINGS } from "../types.js";
import { TaskStore } from "../store.js"; import { TaskStore } from "../store.js";
import { mkdtempSync, existsSync, readFileSync, rmSync } from "node:fs"; import { mkdtempSync, existsSync, readFileSync, rmSync } from "node:fs";
@@ -1191,6 +1200,95 @@ describe("schema migrations", () => {
db.close(); db.close();
}); });
it("reconciles missing columns across all SCHEMA_SQL tables even when schemaVersion is current", () => {
tmpDir = makeTmpDir();
const fusionDir = join(tmpDir, ".fusion");
const dbSourcePath = fileURLToPath(new URL("../db.ts", import.meta.url));
const source = readFileSync(dbSourcePath, "utf8");
const versionMatch = source.match(/^const SCHEMA_VERSION = (\d+);/m);
expect(versionMatch).not.toBeNull();
const schemaVersion = Number(versionMatch?.[1]);
const legacyDb = new Database(fusionDir);
legacyDb.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)");
const schemaTables = getSchemaSqlTableSchemas();
const indexedColumnsByTable = new Map<string, Set<string>>();
for (const match of source.matchAll(/CREATE INDEX IF NOT EXISTS\s+\w+\s+ON\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]+)\)/g)) {
const table = match[1];
const cols = match[2]
.split(",")
.map((column) => column.trim().replace(/\s+(ASC|DESC)$/i, ""));
const set = indexedColumnsByTable.get(table) ?? new Set<string>();
cols.forEach((column) => set.add(column));
indexedColumnsByTable.set(table, set);
}
const requiredDrops = new Map<string, string>([
["tasks", "checkoutNodeId"],
["agents", "currentTaskId"],
["missions", "autoAdvance"],
["routines", "agentId"],
]);
const isSafeToDrop = (definition: string): boolean => {
const upper = definition.toUpperCase();
if (upper.includes("PRIMARY KEY")) return false;
if (upper.includes("NOT NULL") && !upper.includes("DEFAULT")) return false;
return true;
};
for (const [tableName, columns] of schemaTables) {
const entries = [...columns.entries()];
const dropped = new Set<string>();
const indexedColumns = indexedColumnsByTable.get(tableName) ?? new Set<string>();
entries.forEach(([name, definition], index) => {
if (index % 4 === 0 && entries.length > 1 && isSafeToDrop(definition) && !indexedColumns.has(name)) {
dropped.add(name);
}
});
const forcedDrop = requiredDrops.get(tableName);
if (forcedDrop) dropped.add(forcedDrop);
const kept = entries.filter(([name]) => !dropped.has(name));
const chosen = kept.length > 0 ? kept : entries.slice(0, 1);
const columnSql = chosen.map(([name, def]) => ` ${name} ${def}`).join(",\n");
legacyDb.exec(`CREATE TABLE IF NOT EXISTS ${tableName} (\n${columnSql}\n)`);
}
const validatorColumns = Object.entries(MIGRATION_ONLY_TABLE_SCHEMAS.mission_validator_runs)
.filter(([name, definition], index) => name === "id" || (name !== "taskId" && (index % 4 !== 0 || !isSafeToDrop(definition))))
.map(([name, def]) => ` ${name} ${def}`)
.join(",\n");
legacyDb.exec(`CREATE TABLE IF NOT EXISTS mission_validator_runs (\n${validatorColumns}\n)`);
legacyDb.exec(`INSERT INTO __meta (key, value) VALUES ('schemaVersion', '${schemaVersion}')`);
legacyDb.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
legacyDb.close();
const opened = new Database(fusionDir);
opened.init();
for (const [tableName, columns] of schemaTables) {
const actualColumns = new Set(
(opened.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string }>).map((column) => column.name),
);
for (const [columnName] of columns) {
expect(actualColumns.has(columnName), `expected column ${tableName}.${columnName} after init() but it is missing`).toBe(true);
}
}
const missionValidatorColumns = new Set(
(opened.prepare("PRAGMA table_info(mission_validator_runs)").all() as Array<{ name: string }>).map((column) => column.name),
);
expect(
missionValidatorColumns.has("taskId"),
"expected column mission_validator_runs.taskId after init() but it is missing",
).toBe(true);
opened.close();
});
it("backfills missing checkout lease columns when schemaVersion is already current", () => { it("backfills missing checkout lease columns when schemaVersion is already current", () => {
tmpDir = makeTmpDir(); tmpDir = makeTmpDir();
const fusionDir = join(tmpDir, ".fusion"); const fusionDir = join(tmpDir, ".fusion");
@@ -1222,6 +1320,8 @@ describe("schema migrations", () => {
const db = new Database(fusionDir); const db = new Database(fusionDir);
db.init(); db.init();
expect(() => db.prepare("SELECT checkoutNodeId FROM tasks WHERE id = 'FN-lease'").get()).not.toThrow();
const columns = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const columns = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const columnNames = columns.map((column) => column.name); const columnNames = columns.map((column) => column.name);
expect(columnNames).toContain("checkedOutBy"); expect(columnNames).toContain("checkedOutBy");

View File

@@ -688,6 +688,7 @@ CREATE TABLE IF NOT EXISTS routines (
nextRunAt TEXT, nextRunAt TEXT,
runCount INTEGER DEFAULT 0, runCount INTEGER DEFAULT 0,
runHistory TEXT DEFAULT '[]', runHistory TEXT DEFAULT '[]',
scope TEXT DEFAULT 'project',
createdAt TEXT NOT NULL, createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL updatedAt TEXT NOT NULL
); );
@@ -790,6 +791,266 @@ CREATE INDEX IF NOT EXISTS idxTodoItemsListId ON todo_items(listId);
CREATE INDEX IF NOT EXISTS idxTodoItemsSortOrder ON todo_items(listId, sortOrder); CREATE INDEX IF NOT EXISTS idxTodoItemsSortOrder ON todo_items(listId, sortOrder);
`; `;
const TABLE_LEVEL_CONSTRAINT_PREFIXES = new Set([
"PRIMARY",
"FOREIGN",
"UNIQUE",
"CHECK",
"CONSTRAINT",
]);
function normalizeSqlIdentifier(identifier: string): string {
const trimmed = identifier.trim();
if (!trimmed) return trimmed;
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("`") && trimmed.endsWith("`")) ||
(trimmed.startsWith("[") && trimmed.endsWith("]"))) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function parseCreateTableSchemasFromSql(sql: string): Map<string, Map<string, string>> {
const schema = new Map<string, Map<string, string>>();
const createTableRegex = /CREATE TABLE\s+(?:IF NOT EXISTS\s+)?((?:["`]|\[)?[A-Za-z_][A-Za-z0-9_]*(?:["`]|\])?)\s*\(([\s\S]*?)\)\s*;/g;
for (const match of sql.matchAll(createTableRegex)) {
const tableName = normalizeSqlIdentifier(match[1]);
const body = match[2] ?? "";
const columns = new Map<string, string>();
for (const rawLine of body.split("\n")) {
const noComment = rawLine.replace(/--.*$/, "").trim();
if (!noComment) continue;
const line = noComment.endsWith(",") ? noComment.slice(0, -1).trim() : noComment;
if (!line) continue;
const firstWord = line.split(/\s+/, 1)[0]?.toUpperCase() ?? "";
if (TABLE_LEVEL_CONSTRAINT_PREFIXES.has(firstWord)) continue;
const columnMatch = line.match(/^((?:["`]|\[)?[A-Za-z_][A-Za-z0-9_]*(?:["`]|\])?)\s+(.+)$/);
if (!columnMatch) continue;
const columnName = normalizeSqlIdentifier(columnMatch[1]);
const columnDefinition = columnMatch[2].trim();
if (!columnDefinition) continue;
columns.set(columnName, columnDefinition);
}
schema.set(tableName, columns);
}
return schema;
}
const SCHEMA_TABLE_SCHEMAS = parseCreateTableSchemasFromSql(SCHEMA_SQL);
export function getSchemaSqlTableSchemas(): Map<string, Map<string, string>> {
return new Map([...SCHEMA_TABLE_SCHEMAS].map(([table, columns]) => [table, new Map(columns)]));
}
export function getSchemaCompatibilityTableSchemas(): Map<string, Map<string, string>> {
const tables = getSchemaSqlTableSchemas();
for (const [table, columns] of Object.entries(MIGRATION_ONLY_TABLE_SCHEMAS)) {
tables.set(table, new Map(Object.entries(columns)));
}
return tables;
}
export const MIGRATION_ONLY_TABLE_SCHEMAS: Record<string, Record<string, string>> = {
ai_sessions: {
id: "TEXT PRIMARY KEY",
type: "TEXT NOT NULL",
status: "TEXT NOT NULL",
title: "TEXT NOT NULL",
inputPayload: "TEXT NOT NULL",
conversationHistory: "TEXT DEFAULT '[]'",
currentQuestion: "TEXT",
result: "TEXT",
thinkingOutput: "TEXT DEFAULT ''",
error: "TEXT",
projectId: "TEXT",
createdAt: "TEXT NOT NULL",
updatedAt: "TEXT NOT NULL",
lockedByTab: "TEXT",
lockedAt: "TEXT",
archived: "INTEGER DEFAULT 0",
},
messages: {
id: "TEXT PRIMARY KEY",
fromId: "TEXT NOT NULL",
fromType: "TEXT NOT NULL",
toId: "TEXT NOT NULL",
toType: "TEXT NOT NULL",
content: "TEXT NOT NULL",
type: "TEXT NOT NULL",
read: "INTEGER DEFAULT 0",
metadata: "TEXT",
createdAt: "TEXT NOT NULL",
updatedAt: "TEXT NOT NULL",
},
agentRatings: {
id: "TEXT PRIMARY KEY",
agentId: "TEXT NOT NULL",
raterType: "TEXT NOT NULL",
raterId: "TEXT",
score: "INTEGER NOT NULL CHECK(score BETWEEN 1 AND 5)",
category: "TEXT",
comment: "TEXT",
runId: "TEXT",
taskId: "TEXT",
createdAt: "TEXT NOT NULL",
},
chat_sessions: {
id: "TEXT PRIMARY KEY",
agentId: "TEXT NOT NULL",
title: "TEXT",
status: "TEXT NOT NULL DEFAULT 'active'",
projectId: "TEXT",
modelProvider: "TEXT",
modelId: "TEXT",
createdAt: "TEXT NOT NULL",
updatedAt: "TEXT NOT NULL",
cliSessionFile: "TEXT",
},
chat_messages: {
id: "TEXT PRIMARY KEY",
sessionId: "TEXT NOT NULL",
role: "TEXT NOT NULL",
content: "TEXT NOT NULL",
thinkingOutput: "TEXT",
metadata: "TEXT",
createdAt: "TEXT NOT NULL",
attachments: "TEXT",
},
runAuditEvents: {
id: "TEXT PRIMARY KEY",
timestamp: "TEXT NOT NULL",
taskId: "TEXT",
agentId: "TEXT NOT NULL",
runId: "TEXT NOT NULL",
domain: "TEXT NOT NULL",
mutationType: "TEXT NOT NULL",
target: "TEXT NOT NULL",
metadata: "TEXT",
},
mission_contract_assertions: {
id: "TEXT PRIMARY KEY",
milestoneId: "TEXT NOT NULL",
title: "TEXT NOT NULL",
assertion: "TEXT NOT NULL",
status: "TEXT NOT NULL DEFAULT 'pending'",
orderIndex: "INTEGER NOT NULL DEFAULT 0",
createdAt: "TEXT NOT NULL",
updatedAt: "TEXT NOT NULL",
},
mission_feature_assertions: {
featureId: "TEXT NOT NULL",
assertionId: "TEXT NOT NULL",
createdAt: "TEXT NOT NULL",
},
mission_validator_runs: {
id: "TEXT PRIMARY KEY",
featureId: "TEXT NOT NULL",
milestoneId: "TEXT NOT NULL",
sliceId: "TEXT NOT NULL",
status: "TEXT NOT NULL DEFAULT 'running'",
triggerType: "TEXT NOT NULL DEFAULT 'auto'",
implementationAttempt: "INTEGER NOT NULL DEFAULT 0",
validatorAttempt: "INTEGER NOT NULL DEFAULT 0",
summary: "TEXT",
blockedReason: "TEXT",
startedAt: "TEXT NOT NULL",
completedAt: "TEXT",
createdAt: "TEXT NOT NULL",
updatedAt: "TEXT NOT NULL",
taskId: "TEXT",
},
mission_validator_failures: {
id: "TEXT PRIMARY KEY",
runId: "TEXT NOT NULL",
featureId: "TEXT NOT NULL",
assertionId: "TEXT NOT NULL",
message: "TEXT",
expected: "TEXT",
actual: "TEXT",
createdAt: "TEXT NOT NULL",
},
mission_fix_feature_lineage: {
id: "TEXT PRIMARY KEY",
sourceFeatureId: "TEXT NOT NULL",
fixFeatureId: "TEXT NOT NULL",
runId: "TEXT NOT NULL",
failedAssertionIds: "TEXT NOT NULL DEFAULT '[]'",
createdAt: "TEXT NOT NULL",
},
verification_cache: {
treeSha: "TEXT NOT NULL",
testCommand: "TEXT NOT NULL DEFAULT ''",
buildCommand: "TEXT NOT NULL DEFAULT ''",
recordedAt: "TEXT NOT NULL",
taskId: "TEXT",
},
approval_requests: {
id: "TEXT PRIMARY KEY",
status: "TEXT NOT NULL",
requesterActorId: "TEXT NOT NULL",
requesterActorType: "TEXT NOT NULL",
requesterActorName: "TEXT NOT NULL",
targetActionCategory: "TEXT NOT NULL",
targetActionOperation: "TEXT NOT NULL",
targetActionSummary: "TEXT NOT NULL",
targetResourceType: "TEXT NOT NULL",
targetResourceId: "TEXT NOT NULL",
targetContext: "TEXT",
taskId: "TEXT",
runId: "TEXT",
requestedAt: "TEXT NOT NULL",
decidedAt: "TEXT",
completedAt: "TEXT",
createdAt: "TEXT NOT NULL",
updatedAt: "TEXT NOT NULL",
},
approval_request_audit_events: {
id: "TEXT PRIMARY KEY",
requestId: "TEXT NOT NULL",
eventType: "TEXT NOT NULL",
actorId: "TEXT NOT NULL",
actorType: "TEXT NOT NULL",
actorName: "TEXT NOT NULL",
note: "TEXT",
createdAt: "TEXT NOT NULL",
},
chat_rooms: {
id: "TEXT PRIMARY KEY",
name: "TEXT NOT NULL",
slug: "TEXT NOT NULL",
description: "TEXT",
projectId: "TEXT",
createdBy: "TEXT",
status: "TEXT NOT NULL DEFAULT 'active'",
createdAt: "TEXT NOT NULL",
updatedAt: "TEXT NOT NULL",
},
chat_room_members: {
roomId: "TEXT NOT NULL",
agentId: "TEXT NOT NULL",
role: "TEXT NOT NULL DEFAULT 'member'",
addedAt: "TEXT NOT NULL",
},
chat_room_messages: {
id: "TEXT PRIMARY KEY",
roomId: "TEXT NOT NULL",
role: "TEXT NOT NULL",
content: "TEXT NOT NULL",
thinkingOutput: "TEXT",
metadata: "TEXT",
attachments: "TEXT",
senderAgentId: "TEXT",
mentions: "TEXT",
createdAt: "TEXT NOT NULL",
},
};
// ── Database Class ─────────────────────────────────────────────────── // ── Database Class ───────────────────────────────────────────────────
export class Database { export class Database {
@@ -1064,7 +1325,7 @@ export class Database {
this.migrate(); this.migrate();
// Compatibility backfills that must run even when schemaVersion is current. // Compatibility backfills that must run even when schemaVersion is current.
this.ensureTasksSchemaCompatibility(); this.ensureSchemaCompatibility();
this.ensureRoutinesSchemaCompatibility(); this.ensureRoutinesSchemaCompatibility();
this.ensureInsightRunsSchemaCompatibility(); this.ensureInsightRunsSchemaCompatibility();
this.ensureEvalTaskResultsSchemaCompatibility(); this.ensureEvalTaskResultsSchemaCompatibility();
@@ -1088,26 +1349,23 @@ export class Database {
* re-run even if a previous migration partially applied. * re-run even if a previous migration partially applied.
*/ */
/** /**
* Applies idempotent compatibility fixes for legacy tasks checkout lease columns. * Applies unconditional column reconciliation for all known project DB tables.
* *
* FN-3879 documented a self-heal for missing checkout lease columns, but the * FN-3879 introduced a tasks checkout-column self-heal, FN-3898 formalized it,
* original column adds lived only in the `version < 20` migration block. Some * and FN-3887 generalized the guardrail so migration-version drift no longer
* legacy/mesh-synced databases report `schemaVersion >= 20` despite never * determines whether additive columns exist. Invariant: every column declared
* receiving those columns, so task listing queries can fail with `no such * in SCHEMA_SQL or MIGRATION_ONLY_TABLE_SCHEMAS exists on any live table after
* column: checkoutNodeId`. Running this unconditionally on init guarantees the * this method returns, regardless of the persisted schemaVersion.
* canonical lease columns exist.
*/ */
private ensureTasksSchemaCompatibility(): void { private ensureSchemaCompatibility(): void {
if (!this.hasTable("tasks")) { const knownTableSchemas = getSchemaCompatibilityTableSchemas();
return;
}
this.addColumnIfMissing("tasks", "checkedOutBy", "TEXT"); for (const [tableName, columns] of knownTableSchemas) {
this.addColumnIfMissing("tasks", "checkedOutAt", "TEXT"); if (!this.hasTable(tableName)) continue;
this.addColumnIfMissing("tasks", "checkoutNodeId", "TEXT"); for (const [columnName, columnDefinition] of columns) {
this.addColumnIfMissing("tasks", "checkoutRunId", "TEXT"); this.addColumnIfMissing(tableName, columnName, columnDefinition);
this.addColumnIfMissing("tasks", "checkoutLeaseRenewedAt", "TEXT"); }
this.addColumnIfMissing("tasks", "checkoutLeaseEpoch", "INTEGER DEFAULT 0"); }
} }
/** /**
@@ -1122,21 +1380,6 @@ export class Database {
return; return;
} }
this.addColumnIfMissing("routines", "agentId", "TEXT NOT NULL DEFAULT ''");
this.addColumnIfMissing("routines", "command", "TEXT");
this.addColumnIfMissing("routines", "steps", "TEXT");
this.addColumnIfMissing("routines", "timeoutMs", "INTEGER");
this.addColumnIfMissing("routines", "catchUpPolicy", "TEXT NOT NULL DEFAULT 'run_one'");
this.addColumnIfMissing("routines", "executionPolicy", "TEXT NOT NULL DEFAULT 'queue'");
this.addColumnIfMissing("routines", "catchUpLimit", "INTEGER DEFAULT 5");
this.addColumnIfMissing("routines", "lastRunAt", "TEXT");
this.addColumnIfMissing("routines", "lastRunResult", "TEXT");
this.addColumnIfMissing("routines", "nextRunAt", "TEXT");
this.addColumnIfMissing("routines", "runCount", "INTEGER DEFAULT 0");
this.addColumnIfMissing("routines", "runHistory", "TEXT DEFAULT '[]'");
this.addColumnIfMissing("routines", "scope", "TEXT DEFAULT 'project'");
this.addColumnIfMissing("routines", "enabled", "INTEGER DEFAULT 1");
this.db.exec("UPDATE routines SET agentId = '' WHERE agentId IS NULL"); this.db.exec("UPDATE routines SET agentId = '' WHERE agentId IS NULL");
this.db.exec("UPDATE routines SET scope = 'project' WHERE scope IS NULL OR TRIM(scope) = ''"); this.db.exec("UPDATE routines SET scope = 'project' WHERE scope IS NULL OR TRIM(scope) = ''");
@@ -1146,21 +1389,17 @@ export class Database {
} }
/** /**
* Applies idempotent compatibility fixes for the project_insight_runs table. * Applies idempotent post-schema compatibility fixes for project_insight_runs.
* *
* The `lifecycle` and `cancelledAt` columns were added to SCHEMA_SQL and * Column reconciliation is handled by ensureSchemaCompatibility(); this method
* retroactively inserted into migration v33's CREATE TABLE, with a safety-net * remains focused on index creation that should run after the generic column
* in migration v59. However, databases that were already at v59+ when the * backfill pass.
* commit landed never re-run v59, leaving the columns missing. Running this
* unconditionally on every init guarantees the columns exist.
*/ */
private ensureInsightRunsSchemaCompatibility(): void { private ensureInsightRunsSchemaCompatibility(): void {
if (!this.hasTable("project_insight_runs")) { if (!this.hasTable("project_insight_runs")) {
return; return;
} }
this.addColumnIfMissing("project_insight_runs", "lifecycle", "TEXT");
this.addColumnIfMissing("project_insight_runs", "cancelledAt", "TEXT");
this.db.exec(`CREATE INDEX IF NOT EXISTS idxInsightRunsProjectTriggerStatus ON project_insight_runs(projectId, trigger, status)`); this.db.exec(`CREATE INDEX IF NOT EXISTS idxInsightRunsProjectTriggerStatus ON project_insight_runs(projectId, trigger, status)`);
} }