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:
Fusion
2026-04-30 20:33:23 -07:00
committed by gsxdsm
parent d7fdff4c76
commit 97bb80e75c
6 changed files with 199 additions and 6 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix backup routine sync failures on legacy SQLite databases by backfilling missing `routines` columns (including `agentId`) during database initialization. Auto-backup settings now create/update the `Database Backup` routine without logging `table routines has no column named agentId` on upgraded installs.

View File

@@ -186,7 +186,7 @@ Additional backend notes:
| `mission_features` | Features under slices with optional task linkage and execution-loop counters/state. | | `mission_features` | Features under slices with optional task linkage and execution-loop counters/state. |
| `mission_events` | Mission event log with ordered sequence numbers and metadata payloads. | | `mission_events` | Mission event log with ordered sequence numbers and metadata payloads. |
| `plugins` | Plugin registry, lifecycle state, dependency metadata, and settings blobs. | | `plugins` | Plugin registry, lifecycle state, dependency metadata, and settings blobs. |
| `routines` | Routine definitions (trigger config, steps/command, catch-up policy, run history). | | `routines` | Routine definitions (trigger config, steps/command, catch-up policy, run history, and persisted `agentId` ownership metadata). Legacy databases missing routine fields (including `agentId`) are backfilled during init-time compatibility migration. |
| `roadmaps` | Standalone roadmap metadata. | | `roadmaps` | Standalone roadmap metadata. |
| `roadmap_milestones` | Milestones within roadmaps (`roadmapId` FK). | | `roadmap_milestones` | Milestones within roadmaps (`roadmapId` FK). |
| `roadmap_features` | Features within roadmap milestones (`milestoneId` FK). | | `roadmap_features` | Features within roadmap milestones (`milestoneId` FK). |

View File

@@ -13,6 +13,7 @@ import {
runBackupCommand, runBackupCommand,
syncBackupRoutine, syncBackupRoutine,
} from "../backup.js"; } from "../backup.js";
import { Database } from "../db.js";
import { RoutineStore } from "../routine-store.js"; import { RoutineStore } from "../routine-store.js";
import type { ProjectSettings } from "../types.js"; import type { ProjectSettings } from "../types.js";
@@ -522,6 +523,52 @@ describe("syncBackupRoutine", () => {
expect(await routineStore.listRoutines()).toEqual([]); 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", () => { describe("runBackupCommand", () => {

View File

@@ -965,6 +965,50 @@ describe("schema migrations", () => {
db.close(); 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", () => { it("migrates v50 databases by adding chat message attachments column", () => {
tmpDir = makeTmpDir(); tmpDir = makeTmpDir();
const fusionDir = join(tmpDir, ".fusion"); const fusionDir = join(tmpDir, ".fusion");

View File

@@ -565,8 +565,6 @@ CREATE TABLE IF NOT EXISTS routines (
createdAt TEXT NOT NULL, createdAt TEXT NOT NULL,
updatedAt 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) -- Roadmap persistence tables (FN-1690)
-- Standalone roadmap: Roadmap → RoadmapMilestone → RoadmapFeature -- Standalone roadmap: Roadmap → RoadmapMilestone → RoadmapFeature
@@ -851,6 +849,9 @@ export class Database {
// Run schema migrations // Run schema migrations
this.migrate(); this.migrate();
// Compatibility backfills that must run even when schemaVersion is current.
this.ensureRoutinesSchemaCompatibility();
// Seed config row idempotently with default settings // Seed config row idempotently with default settings
const configNow = new Date().toISOString(); const configNow = new Date().toISOString();
this.db.exec( this.db.exec(
@@ -869,6 +870,41 @@ export class Database {
* Column additions use `hasColumn()` so they are idempotent — safe to * Column additions use `hasColumn()` so they are idempotent — safe to
* re-run even if a previous migration partially applied. * 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 { private migrate(): void {
const version = this.getSchemaVersion() || 1; const version = this.getSchemaVersion() || 1;

View File

@@ -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"; import { createFnAgent } from "@fusion/engine";
const mockIsGhAvailable = vi.mocked(isGhAvailable); const mockIsGhAvailable = vi.mocked(isGhAvailable);
@@ -14114,10 +14114,10 @@ describe("PUT /settings", () => {
store = createMockStore(); store = createMockStore();
}); });
function buildApp() { function buildApp(routeOptions: Parameters<typeof createApiRoutes>[1] = { githubToken: "ghp_test_token" }) {
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
app.use("/api", createApiRoutes(store, { githubToken: "ghp_test_token" })); app.use("/api", createApiRoutes(store, routeOptions));
return app; return app;
} }
@@ -14137,6 +14137,67 @@ describe("PUT /settings", () => {
expect(store.updateSettings).toHaveBeenCalledWith({ maxConcurrent: 8 }); 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 () => { it("updates defaultNodeId when provided", async () => {
const updatedSettings = { ...DEFAULT_SETTINGS, defaultNodeId: "node-abc" }; const updatedSettings = { ...DEFAULT_SETTINGS, defaultNodeId: "node-abc" };
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings); (store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);