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 c3ab5613e4
commit 17f5d4a058
11 changed files with 237 additions and 8 deletions

View 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.

View File

@@ -274,13 +274,15 @@ const plugin: FusionPlugin = {
| `onTaskMoved` | `(task: Task, fromColumn: string, toColumn: string, ctx: PluginContext) => Promise<void> \| void` | Task moved between columns | | `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" | | `onTaskCompleted` | `(task: Task, ctx: PluginContext) => Promise<void> \| void` | Task reached "done" |
| `onError` | `(error: Error, ctx: PluginContext) => Promise<void> \| void` | Error occurred in plugin execution | | `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 ### Hook Behavior
- **Timeout**: 5 seconds per invocation (logged and skipped if exceeded) - **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 - **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. - **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 ### Example: Schema initialization hook

View File

@@ -379,6 +379,17 @@ export async function runDaemon(opts: DaemonOptions = {}) {
try { try {
const { loaded, errors } = await pluginLoader.loadAllPlugins(); const { loaded, errors } = await pluginLoader.loadAllPlugins();
console.log(`[plugins] Loaded ${loaded} plugins (${errors} errors)`); 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) { } catch (err) {
console.error( console.error(
`[plugins] Failed to load plugins: ${err instanceof Error ? err.message : err}` `[plugins] Failed to load plugins: ${err instanceof Error ? err.message : err}`

View File

@@ -1082,6 +1082,18 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
try { try {
const { loaded, errors } = await pluginLoader.loadAllPlugins(); const { loaded, errors } = await pluginLoader.loadAllPlugins();
logSink.log(`Loaded ${loaded} plugins (${errors} errors)`, "plugins"); 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) { } catch (err) {
logSink.log( logSink.log(
`Failed to load plugins: ${err instanceof Error ? err.message : err}`, `Failed to load plugins: ${err instanceof Error ? err.message : err}`,

View File

@@ -433,6 +433,17 @@ export async function runServe(
try { try {
const { loaded, errors } = await pluginLoader.loadAllPlugins(); const { loaded, errors } = await pluginLoader.loadAllPlugins();
console.log(`[plugins] Loaded ${loaded} plugins (${errors} errors)`); 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) { } catch (err) {
console.error( console.error(
`[plugins] Failed to load plugins: ${err instanceof Error ? err.message : err}` `[plugins] Failed to load plugins: ${err instanceof Error ? err.message : err}`

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", () => { describe("foreign key cascade", () => {
it("deleting an agent cascades to heartbeats", () => { it("deleting an agent cascades to heartbeats", () => {
const now = new Date().toISOString(); const now = new Date().toISOString();

View File

@@ -13,6 +13,7 @@ import { isAbsolute, join } from "node:path";
import { mkdirSync, existsSync } from "node:fs"; import { mkdirSync, existsSync } from "node:fs";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { DEFAULT_PROJECT_SETTINGS } from "./types.js"; import { DEFAULT_PROJECT_SETTINGS } from "./types.js";
import type { PluginOnSchemaInit } from "./plugin-types.js";
import type { SteeringComment, TaskComment } from "./types.js"; import type { SteeringComment, TaskComment } from "./types.js";
// ── Types ──────────────────────────────────────────────────────────── // ── 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. * Prepare a SQL statement. Returns a Statement object.
*/ */

View File

@@ -201,6 +201,15 @@ export function MobileNavBar({
const primaryPluginViews = pluginDashboardViews const primaryPluginViews = pluginDashboardViews
.filter((entry) => entry.view.placement === "primary") .filter((entry) => entry.view.placement === "primary")
.sort((a, b) => (a.view.order ?? Number.MAX_SAFE_INTEGER) - (b.view.order ?? Number.MAX_SAFE_INTEGER)); .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 = const isMoreActive =
view === "documents" view === "documents"
@@ -212,7 +221,7 @@ export function MobileNavBar({
|| (todosOpen && todoViewEnabled) || (todosOpen && todoViewEnabled)
|| (view === "roadmaps" && !showRoadmapsTopLevel) || (view === "roadmaps" && !showRoadmapsTopLevel)
|| (view === "skills" && !showSkillsTopLevel) || (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 ( return (
<> <>
@@ -320,7 +329,7 @@ export function MobileNavBar({
</button> </button>
)} )}
{primaryPluginViews.map((entry) => { {topLevelPrimaryPluginViews.map((entry) => {
const pluginTaskView = buildPluginTaskViewId(entry.pluginId, entry.view.viewId); const pluginTaskView = buildPluginTaskViewId(entry.pluginId, entry.view.viewId);
const PluginIcon = getPluginNavIcon(entry.view.icon); const PluginIcon = getPluginNavIcon(entry.view.icon);
return ( return (
@@ -660,10 +669,7 @@ export function MobileNavBar({
</button> </button>
)} )}
{pluginDashboardViews {overflowPluginViews.map((entry) => {
.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) => {
const pluginTaskView = buildPluginTaskViewId(entry.pluginId, entry.view.viewId); const pluginTaskView = buildPluginTaskViewId(entry.pluginId, entry.view.viewId);
const PluginIcon = getPluginNavIcon(entry.view.icon); const PluginIcon = getPluginNavIcon(entry.view.icon);
return ( return (

View File

@@ -134,6 +134,30 @@ describe("MobileNavBar", () => {
expect(props.onChangeView).toHaveBeenCalledWith("plugin:fusion-plugin-dependency-graph:queue"); 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", () => { it("active tab is highlighted for mailbox", () => {
render(<MobileNavBar {...createDefaultProps()} view="mailbox" />); render(<MobileNavBar {...createDefaultProps()} view="mailbox" />);
expect(screen.getByTestId("mobile-nav-tab-mailbox").className).toContain("mobile-nav-tab--active"); expect(screen.getByTestId("mobile-nav-tab-mailbox").className).toContain("mobile-nav-tab--active");

View File

@@ -30,6 +30,7 @@ describe("PluginRunner", () => {
loadAllPlugins: ReturnType<typeof vi.fn>; loadAllPlugins: ReturnType<typeof vi.fn>;
stopAllPlugins: ReturnType<typeof vi.fn>; stopAllPlugins: ReturnType<typeof vi.fn>;
invokeHook: ReturnType<typeof vi.fn>; invokeHook: ReturnType<typeof vi.fn>;
getPluginSchemaInitHooks: ReturnType<typeof vi.fn>;
getPluginTools: ReturnType<typeof vi.fn>; getPluginTools: ReturnType<typeof vi.fn>;
getPluginRoutes: ReturnType<typeof vi.fn>; getPluginRoutes: ReturnType<typeof vi.fn>;
getPluginUiSlots: ReturnType<typeof vi.fn>; getPluginUiSlots: ReturnType<typeof vi.fn>;
@@ -55,6 +56,7 @@ describe("PluginRunner", () => {
on: ReturnType<typeof vi.fn>; on: ReturnType<typeof vi.fn>;
off: ReturnType<typeof vi.fn>; off: ReturnType<typeof vi.fn>;
getTask: ReturnType<typeof vi.fn>; getTask: ReturnType<typeof vi.fn>;
getDatabase: ReturnType<typeof vi.fn>;
}; };
let pluginRunner: PluginRunner; let pluginRunner: PluginRunner;
@@ -87,6 +89,7 @@ describe("PluginRunner", () => {
loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 2, errors: 0 }), loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 2, errors: 0 }),
stopAllPlugins: vi.fn().mockResolvedValue(undefined), stopAllPlugins: vi.fn().mockResolvedValue(undefined),
invokeHook: vi.fn().mockResolvedValue(undefined), invokeHook: vi.fn().mockResolvedValue(undefined),
getPluginSchemaInitHooks: vi.fn().mockReturnValue([]),
getPluginTools: vi.fn().mockReturnValue([]), getPluginTools: vi.fn().mockReturnValue([]),
getPluginRoutes: vi.fn().mockReturnValue([]), getPluginRoutes: vi.fn().mockReturnValue([]),
getPluginUiSlots: vi.fn().mockReturnValue([]), getPluginUiSlots: vi.fn().mockReturnValue([]),
@@ -106,10 +109,12 @@ describe("PluginRunner", () => {
const mockOn = vi.fn(); const mockOn = vi.fn();
const mockOff = vi.fn(); const mockOff = vi.fn();
const mockRunPluginSchemaInits = vi.fn().mockResolvedValue(undefined);
mockTaskStore = { mockTaskStore = {
on: mockOn, on: mockOn,
off: mockOff, off: mockOff,
getTask: vi.fn(), getTask: vi.fn(),
getDatabase: vi.fn().mockReturnValue({ runPluginSchemaInits: mockRunPluginSchemaInits }),
}; };
mockPluginStore = { mockPluginStore = {
@@ -142,6 +147,32 @@ describe("PluginRunner", () => {
expect(mockPluginLoader.loadAllPlugins).toHaveBeenCalled(); 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 () => { it("should subscribe to plugin store events", async () => {
await pluginRunner.init(); await pluginRunner.init();
// Should subscribe to plugin lifecycle events // Should subscribe to plugin lifecycle events

View File

@@ -157,6 +157,19 @@ export class PluginRunner {
const result = await this.options.pluginLoader.loadAllPlugins(); const result = await this.options.pluginLoader.loadAllPlugins();
executorLog.log(`PluginRunner loaded ${result.loaded} plugins (${result.errors} errors)`); 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 // Subscribe to store events for task lifecycle hooks
this.subscribeToStoreEvents(); this.subscribeToStoreEvents();