feat(FN-3034): add legacy routines agentId compatibility and migration back
Merges FN-3009 (remote settings migrated to global scope with updated schema and docs), FN-3034 (legacy routines compatibility layer: agentId backfill on load and schema validation for old format), and FN-3039 (Nerd Font glyph asset bundled into the terminal with CSS prioritization). Core settings s Fusion-Task-Id: FN-3034
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
runBackupCommand,
|
||||
syncBackupRoutine,
|
||||
} from "../backup.js";
|
||||
import { Database } from "../db.js";
|
||||
import { RoutineStore } from "../routine-store.js";
|
||||
import type { ProjectSettings } from "../types.js";
|
||||
|
||||
@@ -522,6 +523,52 @@ describe("syncBackupRoutine", () => {
|
||||
|
||||
expect(await routineStore.listRoutines()).toEqual([]);
|
||||
});
|
||||
|
||||
it("creates backup routine after upgrading legacy routines schema missing agentId", async () => {
|
||||
const diskDir = mkdtempSync(join(tmpdir(), "kb-backup-routine-legacy-"));
|
||||
const db = new Database(join(diskDir, ".fusion"));
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT);
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
nextId INTEGER DEFAULT 1,
|
||||
nextWorkflowStepId INTEGER DEFAULT 1,
|
||||
settings TEXT DEFAULT '{}',
|
||||
workflowSteps TEXT DEFAULT '[]',
|
||||
updatedAt TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS routines (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
triggerType TEXT NOT NULL,
|
||||
triggerConfig TEXT NOT NULL,
|
||||
command TEXT,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '55')");
|
||||
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
|
||||
db.close();
|
||||
|
||||
const diskRoutineStore = new RoutineStore(diskDir);
|
||||
await diskRoutineStore.init();
|
||||
|
||||
await expect(syncBackupRoutine(diskRoutineStore, {
|
||||
...baseSettings,
|
||||
autoBackupEnabled: true,
|
||||
autoBackupSchedule: "0 1 * * *",
|
||||
})).resolves.toBeDefined();
|
||||
|
||||
const routines = await diskRoutineStore.listRoutines();
|
||||
expect(routines).toHaveLength(1);
|
||||
expect(routines[0]?.name).toBe("Database Backup");
|
||||
expect(routines[0]?.agentId).toBe("");
|
||||
|
||||
await rm(diskDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("runBackupCommand", () => {
|
||||
|
||||
@@ -965,6 +965,50 @@ describe("schema migrations", () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("backfills legacy routines table missing agentId with safe defaults", () => {
|
||||
tmpDir = makeTmpDir();
|
||||
const fusionDir = join(tmpDir, ".fusion");
|
||||
const db = new Database(fusionDir);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT);
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
nextId INTEGER DEFAULT 1,
|
||||
nextWorkflowStepId INTEGER DEFAULT 1,
|
||||
settings TEXT DEFAULT '{}',
|
||||
workflowSteps TEXT DEFAULT '[]',
|
||||
updatedAt TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS routines (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
triggerType TEXT NOT NULL,
|
||||
triggerConfig TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '55')");
|
||||
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
|
||||
db.exec(`
|
||||
INSERT INTO routines (id, name, description, triggerType, triggerConfig, enabled, createdAt, updatedAt)
|
||||
VALUES ('routine-1', 'Database Backup', 'legacy row', 'cron', '{}', 1, '2026-01-01', '2026-01-01')
|
||||
`);
|
||||
|
||||
db.init();
|
||||
|
||||
const columns = db.prepare("PRAGMA table_info(routines)").all() as Array<{ name: string }>;
|
||||
expect(columns.map((column) => column.name)).toContain("agentId");
|
||||
|
||||
const row = db.prepare("SELECT agentId FROM routines WHERE id = 'routine-1'").get() as { agentId: string | null };
|
||||
expect(row.agentId).toBe("");
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("migrates v50 databases by adding chat message attachments column", () => {
|
||||
tmpDir = makeTmpDir();
|
||||
const fusionDir = join(tmpDir, ".fusion");
|
||||
|
||||
@@ -565,8 +565,6 @@ CREATE TABLE IF NOT EXISTS routines (
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idxRoutinesNextRunAt ON routines(nextRunAt);
|
||||
CREATE INDEX IF NOT EXISTS idxRoutinesEnabled ON routines(enabled);
|
||||
|
||||
-- Roadmap persistence tables (FN-1690)
|
||||
-- Standalone roadmap: Roadmap → RoadmapMilestone → RoadmapFeature
|
||||
@@ -851,6 +849,9 @@ export class Database {
|
||||
// Run schema migrations
|
||||
this.migrate();
|
||||
|
||||
// Compatibility backfills that must run even when schemaVersion is current.
|
||||
this.ensureRoutinesSchemaCompatibility();
|
||||
|
||||
// Seed config row idempotently with default settings
|
||||
const configNow = new Date().toISOString();
|
||||
this.db.exec(
|
||||
@@ -869,6 +870,41 @@ export class Database {
|
||||
* Column additions use `hasColumn()` so they are idempotent — safe to
|
||||
* re-run even if a previous migration partially applied.
|
||||
*/
|
||||
/**
|
||||
* Applies idempotent compatibility fixes for legacy routines table shapes.
|
||||
*
|
||||
* Some older databases contain `routines` without `agentId`, or with NULL
|
||||
* agent IDs from earlier table definitions. `RoutineStore.rowToRoutine()` and
|
||||
* backup routine sync expect a safe string value, so normalize to ''.
|
||||
*/
|
||||
private ensureRoutinesSchemaCompatibility(): void {
|
||||
if (!this.hasTable("routines")) {
|
||||
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 scope = 'project' WHERE scope IS NULL OR TRIM(scope) = ''");
|
||||
|
||||
this.db.exec("CREATE INDEX IF NOT EXISTS idxRoutinesNextRunAt ON routines(nextRunAt)");
|
||||
this.db.exec("CREATE INDEX IF NOT EXISTS idxRoutinesEnabled ON routines(enabled)");
|
||||
this.db.exec("CREATE INDEX IF NOT EXISTS idxRoutinesScope ON routines(scope)");
|
||||
}
|
||||
|
||||
private migrate(): void {
|
||||
const version = this.getSchemaVersion() || 1;
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ vi.mock("@fusion/engine", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { AgentStore, isGhAvailable, isGhAuthenticated } from "@fusion/core";
|
||||
import { AgentStore, Database, RoutineStore, isGhAvailable, isGhAuthenticated } from "@fusion/core";
|
||||
import { createFnAgent } from "@fusion/engine";
|
||||
|
||||
const mockIsGhAvailable = vi.mocked(isGhAvailable);
|
||||
@@ -14114,10 +14114,10 @@ describe("PUT /settings", () => {
|
||||
store = createMockStore();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
function buildApp(routeOptions: Parameters<typeof createApiRoutes>[1] = { githubToken: "ghp_test_token" }) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, { githubToken: "ghp_test_token" }));
|
||||
app.use("/api", createApiRoutes(store, routeOptions));
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -14137,6 +14137,67 @@ describe("PUT /settings", () => {
|
||||
expect(store.updateSettings).toHaveBeenCalledWith({ maxConcurrent: 8 });
|
||||
});
|
||||
|
||||
it("updates settings with auto-backup enabled without logging routine sync failure", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "kb-routes-backup-routine-"));
|
||||
const db = new Database(join(tempDir, ".fusion"));
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT);
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
nextId INTEGER DEFAULT 1,
|
||||
nextWorkflowStepId INTEGER DEFAULT 1,
|
||||
settings TEXT DEFAULT '{}',
|
||||
workflowSteps TEXT DEFAULT '[]',
|
||||
updatedAt TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS routines (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
triggerType TEXT NOT NULL,
|
||||
triggerConfig TEXT NOT NULL,
|
||||
command TEXT,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '55')");
|
||||
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
|
||||
db.close();
|
||||
|
||||
const routineStore = new RoutineStore(tempDir);
|
||||
await routineStore.init();
|
||||
|
||||
const updatedSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
autoBackupEnabled: true,
|
||||
autoBackupSchedule: "0 2 * * *",
|
||||
};
|
||||
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
|
||||
|
||||
const runtimeEvents: Array<{ level: string; scope: string; message: string; context?: Record<string, unknown> }> = [];
|
||||
setRuntimeLogSink((level, scope, message, context) => {
|
||||
runtimeEvents.push({ level, scope, message, context });
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await REQUEST(
|
||||
buildApp({ githubToken: "ghp_test_token", routineStore }),
|
||||
"PUT",
|
||||
"/api/settings",
|
||||
JSON.stringify({ autoBackupEnabled: true, autoBackupSchedule: "0 2 * * *" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(runtimeEvents.some((event) => event.message === "Failed to sync backup routine")).toBe(false);
|
||||
} finally {
|
||||
resetRuntimeLogSink();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("updates defaultNodeId when provided", async () => {
|
||||
const updatedSettings = { ...DEFAULT_SETTINGS, defaultNodeId: "node-abc" };
|
||||
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
|
||||
|
||||
Reference in New Issue
Block a user