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:
5
.changeset/fix-fn-3034-legacy-routines-agentid.md
Normal file
5
.changeset/fix-fn-3034-legacy-routines-agentid.md
Normal 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.
|
||||||
@@ -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). |
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user