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:
7
.changeset/FN-3887-generalize-schema-compat.md
Normal file
7
.changeset/FN-3887-generalize-schema-compat.md
Normal 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.
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user