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:
5
.changeset/fn-3156-plugin-schema-init.md
Normal file
5
.changeset/fn-3156-plugin-schema-init.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Execute plugin `onSchemaInit` hooks during startup after plugins are loaded, so plugins can register idempotent tables and indexes with the runtime database.
|
||||
@@ -274,13 +274,15 @@ const plugin: FusionPlugin = {
|
||||
| `onTaskMoved` | `(task: Task, fromColumn: string, toColumn: string, ctx: PluginContext) => Promise<void> \| void` | Task moved between columns |
|
||||
| `onTaskCompleted` | `(task: Task, ctx: PluginContext) => Promise<void> \| void` | Task reached "done" |
|
||||
| `onError` | `(error: Error, ctx: PluginContext) => Promise<void> \| void` | Error occurred in plugin execution |
|
||||
| `onSchemaInit` | `(db: Database) => Promise<void> \| void` | During DB schema initialization (before core migrations complete) |
|
||||
| `onSchemaInit` | `(db: Database) => Promise<void> \| void` | After enabled plugins are loaded at startup (engine/daemon/dashboard/serve) |
|
||||
|
||||
### Hook Behavior
|
||||
|
||||
- **Timeout**: 5 seconds per invocation (logged and skipped if exceeded)
|
||||
- **Error Isolation**: Hook failures never block the host system
|
||||
- **Error Isolation**: Hook failures never block other hooks or abort startup
|
||||
- **Optional**: Only define the hooks you need
|
||||
- **Schema hook execution**: `onSchemaInit` hooks run sequentially in plugin dependency order (from `resolveLoadOrder`) after `loadAllPlugins()`.
|
||||
- **Schema hook database API**: The hook receives the runtime `Database` instance, including `db.exec()` and `db.prepare()` for SQL DDL.
|
||||
- **Schema hook constraints**: `onSchemaInit` is intended for idempotent DDL only (`CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`). Avoid data backfills or long-running logic.
|
||||
|
||||
### Example: Schema initialization hook
|
||||
|
||||
@@ -379,6 +379,17 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
||||
try {
|
||||
const { loaded, errors } = await pluginLoader.loadAllPlugins();
|
||||
console.log(`[plugins] Loaded ${loaded} plugins (${errors} errors)`);
|
||||
|
||||
const schemaHooks = pluginLoader.getPluginSchemaInitHooks();
|
||||
if (schemaHooks.length > 0) {
|
||||
try {
|
||||
await store.getDatabase().runPluginSchemaInits(schemaHooks);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[plugins] Schema initialization failed: ${err instanceof Error ? err.message : err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[plugins] Failed to load plugins: ${err instanceof Error ? err.message : err}`
|
||||
|
||||
@@ -1082,6 +1082,18 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
try {
|
||||
const { loaded, errors } = await pluginLoader.loadAllPlugins();
|
||||
logSink.log(`Loaded ${loaded} plugins (${errors} errors)`, "plugins");
|
||||
|
||||
const schemaHooks = pluginLoader.getPluginSchemaInitHooks();
|
||||
if (schemaHooks.length > 0) {
|
||||
try {
|
||||
await store.getDatabase().runPluginSchemaInits(schemaHooks);
|
||||
} catch (err) {
|
||||
logSink.log(
|
||||
`Schema initialization failed: ${err instanceof Error ? err.message : err}`,
|
||||
"plugins",
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logSink.log(
|
||||
`Failed to load plugins: ${err instanceof Error ? err.message : err}`,
|
||||
|
||||
@@ -433,6 +433,17 @@ export async function runServe(
|
||||
try {
|
||||
const { loaded, errors } = await pluginLoader.loadAllPlugins();
|
||||
console.log(`[plugins] Loaded ${loaded} plugins (${errors} errors)`);
|
||||
|
||||
const schemaHooks = pluginLoader.getPluginSchemaInitHooks();
|
||||
if (schemaHooks.length > 0) {
|
||||
try {
|
||||
await store.getDatabase().runPluginSchemaInits(schemaHooks);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[plugins] Schema initialization failed: ${err instanceof Error ? err.message : err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[plugins] Failed to load plugins: ${err instanceof Error ? err.message : err}`
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -201,6 +201,15 @@ export function MobileNavBar({
|
||||
const primaryPluginViews = pluginDashboardViews
|
||||
.filter((entry) => entry.view.placement === "primary")
|
||||
.sort((a, b) => (a.view.order ?? Number.MAX_SAFE_INTEGER) - (b.view.order ?? Number.MAX_SAFE_INTEGER));
|
||||
// Keep plugin-provided top-level tabs constrained on mobile so fixed tabs retain
|
||||
// reasonable touch-target width. Additional primary plugin destinations overflow into More.
|
||||
const MAX_PRIMARY_PLUGIN_TOP_LEVEL_TABS = 1;
|
||||
const topLevelPrimaryPluginViews = primaryPluginViews.slice(0, MAX_PRIMARY_PLUGIN_TOP_LEVEL_TABS);
|
||||
const overflowPrimaryPluginViews = primaryPluginViews.slice(MAX_PRIMARY_PLUGIN_TOP_LEVEL_TABS);
|
||||
const overflowPluginViews = [
|
||||
...overflowPrimaryPluginViews,
|
||||
...pluginDashboardViews.filter((entry) => entry.view.placement !== "primary"),
|
||||
].sort((a, b) => (a.view.order ?? Number.MAX_SAFE_INTEGER) - (b.view.order ?? Number.MAX_SAFE_INTEGER));
|
||||
|
||||
const isMoreActive =
|
||||
view === "documents"
|
||||
@@ -212,7 +221,7 @@ export function MobileNavBar({
|
||||
|| (todosOpen && todoViewEnabled)
|
||||
|| (view === "roadmaps" && !showRoadmapsTopLevel)
|
||||
|| (view === "skills" && !showSkillsTopLevel)
|
||||
|| (view.startsWith("plugin:") && !primaryPluginViews.some((entry) => buildPluginTaskViewId(entry.pluginId, entry.view.viewId) === view));
|
||||
|| (view.startsWith("plugin:") && !topLevelPrimaryPluginViews.some((entry) => buildPluginTaskViewId(entry.pluginId, entry.view.viewId) === view));
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -320,7 +329,7 @@ export function MobileNavBar({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{primaryPluginViews.map((entry) => {
|
||||
{topLevelPrimaryPluginViews.map((entry) => {
|
||||
const pluginTaskView = buildPluginTaskViewId(entry.pluginId, entry.view.viewId);
|
||||
const PluginIcon = getPluginNavIcon(entry.view.icon);
|
||||
return (
|
||||
@@ -660,10 +669,7 @@ export function MobileNavBar({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{pluginDashboardViews
|
||||
.filter((entry) => entry.view.placement !== "primary")
|
||||
.sort((a, b) => (a.view.order ?? Number.MAX_SAFE_INTEGER) - (b.view.order ?? Number.MAX_SAFE_INTEGER))
|
||||
.map((entry) => {
|
||||
{overflowPluginViews.map((entry) => {
|
||||
const pluginTaskView = buildPluginTaskViewId(entry.pluginId, entry.view.viewId);
|
||||
const PluginIcon = getPluginNavIcon(entry.view.icon);
|
||||
return (
|
||||
|
||||
@@ -134,6 +134,30 @@ describe("MobileNavBar", () => {
|
||||
expect(props.onChangeView).toHaveBeenCalledWith("plugin:fusion-plugin-dependency-graph:queue");
|
||||
});
|
||||
|
||||
it("limits primary plugin tabs on mobile and overflows extra primary views into More", () => {
|
||||
render(
|
||||
<MobileNavBar
|
||||
{...createDefaultProps()}
|
||||
pluginDashboardViews={[
|
||||
{
|
||||
pluginId: "fusion-plugin-dependency-graph",
|
||||
view: { viewId: "graph", label: "Graph", componentPath: "./GraphView", icon: "Map", placement: "primary", order: 1 },
|
||||
},
|
||||
{
|
||||
pluginId: "fusion-plugin-dependency-graph",
|
||||
view: { viewId: "queue", label: "Queue", componentPath: "./QueueView", icon: "Workflow", placement: "primary", order: 2 },
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("mobile-nav-tab-plugin-fusion-plugin-dependency-graph-graph")).toBeDefined();
|
||||
expect(screen.queryByTestId("mobile-nav-tab-plugin-fusion-plugin-dependency-graph-queue")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
||||
expect(screen.getByTestId("mobile-more-item-plugin-fusion-plugin-dependency-graph-queue")).toBeDefined();
|
||||
});
|
||||
|
||||
it("active tab is highlighted for mailbox", () => {
|
||||
render(<MobileNavBar {...createDefaultProps()} view="mailbox" />);
|
||||
expect(screen.getByTestId("mobile-nav-tab-mailbox").className).toContain("mobile-nav-tab--active");
|
||||
|
||||
@@ -30,6 +30,7 @@ describe("PluginRunner", () => {
|
||||
loadAllPlugins: ReturnType<typeof vi.fn>;
|
||||
stopAllPlugins: ReturnType<typeof vi.fn>;
|
||||
invokeHook: ReturnType<typeof vi.fn>;
|
||||
getPluginSchemaInitHooks: ReturnType<typeof vi.fn>;
|
||||
getPluginTools: ReturnType<typeof vi.fn>;
|
||||
getPluginRoutes: ReturnType<typeof vi.fn>;
|
||||
getPluginUiSlots: ReturnType<typeof vi.fn>;
|
||||
@@ -55,6 +56,7 @@ describe("PluginRunner", () => {
|
||||
on: ReturnType<typeof vi.fn>;
|
||||
off: ReturnType<typeof vi.fn>;
|
||||
getTask: ReturnType<typeof vi.fn>;
|
||||
getDatabase: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let pluginRunner: PluginRunner;
|
||||
|
||||
@@ -87,6 +89,7 @@ describe("PluginRunner", () => {
|
||||
loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 2, errors: 0 }),
|
||||
stopAllPlugins: vi.fn().mockResolvedValue(undefined),
|
||||
invokeHook: vi.fn().mockResolvedValue(undefined),
|
||||
getPluginSchemaInitHooks: vi.fn().mockReturnValue([]),
|
||||
getPluginTools: vi.fn().mockReturnValue([]),
|
||||
getPluginRoutes: vi.fn().mockReturnValue([]),
|
||||
getPluginUiSlots: vi.fn().mockReturnValue([]),
|
||||
@@ -106,10 +109,12 @@ describe("PluginRunner", () => {
|
||||
|
||||
const mockOn = vi.fn();
|
||||
const mockOff = vi.fn();
|
||||
const mockRunPluginSchemaInits = vi.fn().mockResolvedValue(undefined);
|
||||
mockTaskStore = {
|
||||
on: mockOn,
|
||||
off: mockOff,
|
||||
getTask: vi.fn(),
|
||||
getDatabase: vi.fn().mockReturnValue({ runPluginSchemaInits: mockRunPluginSchemaInits }),
|
||||
};
|
||||
|
||||
mockPluginStore = {
|
||||
@@ -142,6 +147,32 @@ describe("PluginRunner", () => {
|
||||
expect(mockPluginLoader.loadAllPlugins).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should execute schema init hooks after plugin load", async () => {
|
||||
const schemaHook = vi.fn();
|
||||
mockPluginLoader.getPluginSchemaInitHooks.mockReturnValue([
|
||||
{ pluginId: "plugin-a", hook: schemaHook },
|
||||
]);
|
||||
|
||||
await pluginRunner.init();
|
||||
|
||||
expect(mockPluginLoader.getPluginSchemaInitHooks).toHaveBeenCalledTimes(1);
|
||||
expect(mockTaskStore.getDatabase).toHaveBeenCalledTimes(1);
|
||||
const db = mockTaskStore.getDatabase.mock.results[0]?.value as {
|
||||
runPluginSchemaInits: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
expect(db.runPluginSchemaInits).toHaveBeenCalledWith([
|
||||
{ pluginId: "plugin-a", hook: schemaHook },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should skip schema init execution when no hooks are registered", async () => {
|
||||
mockPluginLoader.getPluginSchemaInitHooks.mockReturnValue([]);
|
||||
|
||||
await pluginRunner.init();
|
||||
|
||||
expect(mockTaskStore.getDatabase).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should subscribe to plugin store events", async () => {
|
||||
await pluginRunner.init();
|
||||
// Should subscribe to plugin lifecycle events
|
||||
|
||||
@@ -157,6 +157,19 @@ export class PluginRunner {
|
||||
const result = await this.options.pluginLoader.loadAllPlugins();
|
||||
executorLog.log(`PluginRunner loaded ${result.loaded} plugins (${result.errors} errors)`);
|
||||
|
||||
// Execute onSchemaInit hooks from loaded plugins.
|
||||
const schemaInitHooks = this.options.pluginLoader.getPluginSchemaInitHooks();
|
||||
if (schemaInitHooks.length > 0) {
|
||||
executorLog.log(`Executing onSchemaInit hooks from ${schemaInitHooks.length} plugins`);
|
||||
try {
|
||||
const db = this.options.taskStore.getDatabase();
|
||||
await db.runPluginSchemaInits(schemaInitHooks);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
executorLog.log(`onSchemaInit execution failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to store events for task lifecycle hooks
|
||||
this.subscribeToStoreEvents();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user