feat(FN-3156): add database schema init hook runner for plugin settings lif

The merge adds plugin schema initialization lifecycle support (FN-3156), introduces a ResearchTaskActionModal with model fallback notifications (FN-3012, FN-3008), adds async planning draft sync to prevent UI blocking (FN-3229), and implements viewport-conditional mission split layout (FN-3130). Inf

Fusion-Task-Id: FN-3156
This commit is contained in:
Fusion
2026-05-02 20:25:46 -07:00
committed by gsxdsm
parent c6119f78ef
commit d1cb401a0d
11 changed files with 237 additions and 8 deletions

View File

@@ -415,6 +415,91 @@ describe("Database", () => {
});
});
describe("runPluginSchemaInits", () => {
it("returns without error when no hooks are provided", async () => {
await expect(db.runPluginSchemaInits([])).resolves.toBeUndefined();
});
it("executes a single schema hook and creates its table", async () => {
await db.runPluginSchemaInits([
{
pluginId: "plugin-single",
hook: (database) => {
database.exec("CREATE TABLE IF NOT EXISTS plugin_single_table (id TEXT PRIMARY KEY)");
},
},
]);
const row = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='plugin_single_table'")
.get() as { name: string } | undefined;
expect(row?.name).toBe("plugin_single_table");
});
it("executes multiple schema hooks in order", async () => {
const order: string[] = [];
await db.runPluginSchemaInits([
{
pluginId: "plugin-a",
hook: (database) => {
order.push("a");
database.exec("CREATE TABLE IF NOT EXISTS plugin_table_a (id TEXT PRIMARY KEY)");
},
},
{
pluginId: "plugin-b",
hook: (database) => {
order.push("b");
database.exec("CREATE TABLE IF NOT EXISTS plugin_table_b (id TEXT PRIMARY KEY)");
},
},
]);
expect(order).toEqual(["a", "b"]);
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('plugin_table_a','plugin_table_b') ORDER BY name")
.all() as Array<{ name: string }>;
expect(tables.map((table) => table.name)).toEqual(["plugin_table_a", "plugin_table_b"]);
});
it("continues executing hooks after a hook throws", async () => {
await db.runPluginSchemaInits([
{
pluginId: "plugin-fail",
hook: () => {
throw new Error("boom");
},
},
{
pluginId: "plugin-after",
hook: (database) => {
database.exec("CREATE TABLE IF NOT EXISTS plugin_after_table (id TEXT PRIMARY KEY)");
},
},
]);
const row = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='plugin_after_table'")
.get() as { name: string } | undefined;
expect(row?.name).toBe("plugin_after_table");
});
it("is idempotent when called repeatedly with the same hooks", async () => {
const hooks = [
{
pluginId: "plugin-idempotent",
hook: (database: Database) => {
database.exec("CREATE TABLE IF NOT EXISTS plugin_idempotent_table (id TEXT PRIMARY KEY)");
database.exec("CREATE INDEX IF NOT EXISTS idx_plugin_idempotent_id ON plugin_idempotent_table(id)");
},
},
];
await expect(db.runPluginSchemaInits(hooks)).resolves.toBeUndefined();
await expect(db.runPluginSchemaInits(hooks)).resolves.toBeUndefined();
});
});
describe("foreign key cascade", () => {
it("deleting an agent cascades to heartbeats", () => {
const now = new Date().toISOString();

View File

@@ -13,6 +13,7 @@ import { isAbsolute, join } from "node:path";
import { mkdirSync, existsSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { DEFAULT_PROJECT_SETTINGS } from "./types.js";
import type { PluginOnSchemaInit } from "./plugin-types.js";
import type { SteeringComment, TaskComment } from "./types.js";
// ── Types ────────────────────────────────────────────────────────────
@@ -2394,6 +2395,34 @@ export class Database {
}
}
/**
* Execute plugin-provided schema initialization hooks.
*
* Hooks run sequentially to preserve deterministic ordering based on plugin
* dependency resolution. Failures are isolated and logged so one plugin's
* schema initialization does not prevent later hooks from running.
*/
async runPluginSchemaInits(
hooks: Array<{ pluginId: string; hook: PluginOnSchemaInit }>,
): Promise<void> {
let errorCount = 0;
for (const { pluginId, hook } of hooks) {
try {
await hook(this);
console.log(`[fusion:db] Plugin schema init completed for ${pluginId}`);
} catch (error) {
errorCount += 1;
const message = error instanceof Error ? error.message : String(error);
console.error(`[fusion:db] Plugin schema init failed for ${pluginId}: ${message}`);
}
}
console.log(
`[fusion:db] Plugin schema initialization complete (${hooks.length} hooks executed, ${errorCount} errors)`,
);
}
/**
* Prepare a SQL statement. Returns a Statement object.
*/