feat(FN-1468): wire plugin backend into dashboard and serve startup

- Add plugin backend wiring to fn dashboard startup
- Add plugin backend wiring to fn serve (headless node) startup
- Document plugin wiring pattern in project memory
- Add comprehensive tests for plugin initialization in dashboard and serve commands
- Include changeset for @gsxdsm/fusion package
This commit is contained in:
gsxdsm
2026-04-09 21:22:46 -07:00
parent a8f4c3d447
commit 375f431f69
7 changed files with 333 additions and 3 deletions

View File

@@ -65,6 +65,25 @@ vi.mock("@fusion/core", () => ({
getAgent: vi.fn().mockResolvedValue(null),
deleteAgent: vi.fn(),
})),
PluginStore: vi.fn().mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
listPlugins: vi.fn().mockResolvedValue([]),
getPlugin: vi.fn(),
registerPlugin: vi.fn(),
enablePlugin: vi.fn(),
disablePlugin: vi.fn(),
updatePluginSettings: vi.fn(),
unregisterPlugin: vi.fn(),
updatePluginState: vi.fn(),
})),
PluginLoader: vi.fn().mockImplementation(() => ({
loadPlugin: vi.fn().mockResolvedValue(undefined),
stopPlugin: vi.fn().mockResolvedValue(undefined),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
getPluginRoutes: vi.fn().mockReturnValue([]),
getPlugin: vi.fn(),
getLoadedPlugins: vi.fn().mockReturnValue([]),
})),
syncInsightExtractionAutomation: mockSyncInsightExtraction,
INSIGHT_EXTRACTION_SCHEDULE_NAME: "Memory Insight Extraction",
processAndAuditInsightExtraction: mockProcessAndAudit,
@@ -395,6 +414,63 @@ describe("runDashboard — MissionAutopilot wiring", () => {
});
});
describe("runDashboard — Plugin wiring", () => {
beforeEach(async () => {
vi.clearAllMocks();
mockDiscoverAndLoadExtensions.mockResolvedValue({
runtime: { pendingProviderRegistrations: [] },
errors: [],
});
const { TaskStore } = await import("@fusion/core");
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
});
it("creates PluginStore and PluginLoader instances", async () => {
const { PluginStore, PluginLoader } = await import("@fusion/core");
await runDashboard(0, {});
expect(PluginStore).toHaveBeenCalledTimes(1);
expect(PluginLoader).toHaveBeenCalledTimes(1);
});
it("passes pluginStore, pluginLoader, and pluginRunner to createServer", async () => {
const { createServer } = await import("@fusion/dashboard");
const { PluginStore, PluginLoader } = await import("@fusion/core");
await runDashboard(0, {});
expect(createServer).toHaveBeenCalledTimes(1);
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];
expect(serverOpts.pluginStore).toBeDefined();
expect(serverOpts.pluginLoader).toBeDefined();
expect(serverOpts.pluginRunner).toBeDefined();
// pluginRunner should be the same instance as pluginLoader
expect(serverOpts.pluginRunner).toBe(serverOpts.pluginLoader);
});
it("initializes PluginStore with the task store's fusion directory", async () => {
const { PluginStore } = await import("@fusion/core");
await runDashboard(0, {});
expect(PluginStore).toHaveBeenCalledWith("/tmp/test/.fusion");
});
it("initializes PluginLoader with pluginStore and taskStore", async () => {
const { PluginLoader } = await import("@fusion/core");
await runDashboard(0, {});
expect(PluginLoader).toHaveBeenCalledTimes(1);
const loaderOptions = (PluginLoader as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(loaderOptions).toHaveProperty("pluginStore");
expect(loaderOptions).toHaveProperty("taskStore");
});
});
describe("runDashboard — Memory Insight Automation wiring", () => {
beforeEach(async () => {
vi.clearAllMocks();

View File

@@ -26,6 +26,8 @@ const mocks = vi.hoisted(() => {
const cronRunnerInstances: any[] = [];
const missionAutopilotInstances: any[] = [];
const notifierInstances: any[] = [];
const pluginStoreInstances: any[] = [];
const pluginLoaderInstances: any[] = [];
const listenCalls: ListenCall[] = [];
function createTaskStoreMock() {
@@ -201,6 +203,35 @@ const mocks = vi.hoisted(() => {
return notifier;
});
const pluginStoreCtor = vi.fn().mockImplementation(() => {
const pluginStore = {
init: vi.fn().mockResolvedValue(undefined),
listPlugins: vi.fn().mockResolvedValue([]),
getPlugin: vi.fn(),
registerPlugin: vi.fn(),
enablePlugin: vi.fn(),
disablePlugin: vi.fn(),
updatePluginSettings: vi.fn(),
unregisterPlugin: vi.fn(),
updatePluginState: vi.fn(),
};
pluginStoreInstances.push(pluginStore);
return pluginStore;
});
const pluginLoaderCtor = vi.fn().mockImplementation(() => {
const pluginLoader = {
loadPlugin: vi.fn().mockResolvedValue(undefined),
stopPlugin: vi.fn().mockResolvedValue(undefined),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
getPluginRoutes: vi.fn().mockReturnValue([]),
getPlugin: vi.fn(),
getLoadedPlugins: vi.fn().mockReturnValue([]),
};
pluginLoaderInstances.push(pluginLoader);
return pluginLoader;
});
const authStorage = {
getApiKey: vi.fn().mockResolvedValue(undefined),
};
@@ -237,6 +268,8 @@ const mocks = vi.hoisted(() => {
cronRunnerCtor,
missionAutopilotCtor,
notifierCtor,
pluginStoreCtor,
pluginLoaderCtor,
authStorage,
modelRegistry,
reset() {
@@ -252,6 +285,8 @@ const mocks = vi.hoisted(() => {
cronRunnerInstances.length = 0;
missionAutopilotInstances.length = 0;
notifierInstances.length = 0;
pluginStoreInstances.length = 0;
pluginLoaderInstances.length = 0;
listenCalls.length = 0;
},
};
@@ -262,6 +297,8 @@ vi.mock("@fusion/core", () => ({
AutomationStore: mocks.automationStoreCtor,
AgentStore: mocks.agentStoreCtor,
CentralCore: mocks.centralCoreCtor,
PluginStore: mocks.pluginStoreCtor,
PluginLoader: mocks.pluginLoaderCtor,
getTaskMergeBlocker: vi.fn().mockReturnValue(null),
syncInsightExtractionAutomation: vi.fn().mockResolvedValue(undefined),
INSIGHT_EXTRACTION_SCHEDULE_NAME: "Memory Insight Extraction",
@@ -479,6 +516,113 @@ describe("runServe", () => {
});
});
describe("runServe — Plugin wiring", () => {
const originalCwd = process.cwd;
const originalOn = process.on;
const originalExit = process.exit;
let signalHandlers: Record<"SIGINT" | "SIGTERM", Array<() => void>>;
let logSpy: ReturnType<typeof vi.spyOn>;
let cwdSpy: ReturnType<typeof vi.spyOn>;
let processOnSpy: ReturnType<typeof vi.spyOn>;
async function triggerSignal(signal: "SIGINT" | "SIGTERM") {
const handlers = signalHandlers[signal];
expect(handlers.length).toBeGreaterThan(0);
handlers[handlers.length - 1]();
await new Promise((resolve) => setTimeout(resolve, 0));
}
beforeEach(() => {
vi.clearAllMocks();
mocks.reset();
signalHandlers = { SIGINT: [], SIGTERM: [] };
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo");
processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => {
if (event === "SIGINT" || event === "SIGTERM") {
signalHandlers[event].push(listener);
}
return process;
}) as typeof process.on);
process.exit = vi.fn() as never;
});
afterEach(() => {
logSpy.mockRestore();
cwdSpy.mockRestore();
processOnSpy.mockRestore();
process.cwd = originalCwd;
process.on = originalOn;
process.exit = originalExit;
});
it("creates PluginStore and PluginLoader instances", async () => {
const { PluginStore, PluginLoader } = await import("@fusion/core");
await runServe(4040, {});
expect(PluginStore).toHaveBeenCalledTimes(1);
expect(PluginLoader).toHaveBeenCalledTimes(1);
await triggerSignal("SIGINT");
});
it("passes pluginStore, pluginLoader, and pluginRunner to createServer", async () => {
const { createServer } = await import("@fusion/dashboard");
await runServe(4040, {});
expect(createServer).toHaveBeenCalledTimes(1);
const serverOpts = createServer.mock.calls[0][1];
expect(serverOpts).toHaveProperty("pluginStore");
expect(serverOpts).toHaveProperty("pluginLoader");
expect(serverOpts).toHaveProperty("pluginRunner");
expect(serverOpts.pluginRunner).toBe(serverOpts.pluginLoader);
await triggerSignal("SIGINT");
});
it("initializes PluginStore with the task store's fusion directory", async () => {
const { PluginStore } = await import("@fusion/core");
await runServe(4040, {});
expect(PluginStore).toHaveBeenCalledWith("/repo/.fusion");
await triggerSignal("SIGINT");
});
it("initializes PluginLoader with pluginStore and taskStore", async () => {
const { PluginLoader } = await import("@fusion/core");
await runServe(4040, {});
expect(PluginLoader).toHaveBeenCalledTimes(1);
const loaderOptions = PluginLoader.mock.calls[0][0];
expect(loaderOptions).toHaveProperty("pluginStore");
expect(loaderOptions).toHaveProperty("taskStore");
await triggerSignal("SIGINT");
});
it("includes plugin wiring in headless server", async () => {
const { createServer } = await import("@fusion/dashboard");
await runServe(4040, {});
expect(createServer).toHaveBeenCalledTimes(1);
const serverOpts = createServer.mock.calls[0][1];
expect(serverOpts.headless).toBe(true);
expect(serverOpts.pluginStore).toBeDefined();
expect(serverOpts.pluginLoader).toBeDefined();
await triggerSignal("SIGINT");
});
});
describe("runServe — Memory Insight Automation wiring", () => {
const originalCwd = process.cwd;
const originalOn = process.on;

View File

@@ -100,6 +100,45 @@ vi.mock("@fusion/core", () => ({
getBudgetStatus: vi.fn().mockResolvedValue({ isOverBudget: false, isOverThreshold: false, usagePercent: 0 }),
getRecentRuns: vi.fn().mockResolvedValue([]),
})),
PluginStore: vi.fn().mockImplementation(() => {
const emitter = new EventEmitter();
return {
init: vi.fn().mockResolvedValue(undefined),
listPlugins: vi.fn().mockResolvedValue([]),
getPlugin: vi.fn(),
registerPlugin: vi.fn(),
enablePlugin: vi.fn(),
disablePlugin: vi.fn(),
updatePluginSettings: vi.fn(),
unregisterPlugin: vi.fn(),
updatePluginState: vi.fn(),
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
emitter.on(event, handler);
}),
off: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
emitter.off(event, handler);
}),
emit: emitter.emit.bind(emitter),
};
}),
PluginLoader: vi.fn().mockImplementation(() => {
const emitter = new EventEmitter();
return {
loadPlugin: vi.fn().mockResolvedValue(undefined),
stopPlugin: vi.fn().mockResolvedValue(undefined),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
getPluginRoutes: vi.fn().mockReturnValue([]),
getPlugin: vi.fn(),
getLoadedPlugins: vi.fn().mockReturnValue([]),
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
emitter.on(event, handler);
}),
off: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
emitter.off(event, handler);
}),
emit: emitter.emit.bind(emitter),
};
}),
getTaskMergeBlocker: vi.fn((task: any) => {
if (task.column !== "in-review") return `task is in '${task.column}', must be in 'in-review'`;
if (task.paused) return "task is paused";
@@ -250,6 +289,14 @@ vi.mock("@fusion/engine", async (importOriginal) => {
stop: vi.fn(),
setScheduler: vi.fn(),
})),
PluginLoader: vi.fn().mockImplementation(() => ({
loadPlugin: vi.fn().mockResolvedValue(undefined),
stopPlugin: vi.fn().mockResolvedValue(undefined),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
getPluginRoutes: vi.fn().mockReturnValue([]),
getPlugin: vi.fn(),
getLoadedPlugins: vi.fn().mockReturnValue([]),
})),
scanIdleWorktrees: vi.fn().mockResolvedValue([]),
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
};
@@ -1778,7 +1825,7 @@ describe("runDashboard — lifecycle listener cleanup", () => {
dispose();
const offCalls = mockStore.off.mock.calls.slice(offCallsBefore);
expect(offCalls.filter(([event]) => event === "settings:updated")).toHaveLength(5);
expect(offCalls.filter(([event]) => event === "settings:updated")).toHaveLength(6);
expect(offCalls.filter(([event]) => event === "task:moved")).toHaveLength(1);
});

View File

@@ -1,7 +1,7 @@
import { execSync } from "node:child_process";
import type { AddressInfo } from "node:net";
import { createInterface } from "node:readline";
import { TaskStore, AutomationStore, CentralCore, AgentStore, getTaskMergeBlocker, syncInsightExtractionAutomation, INSIGHT_EXTRACTION_SCHEDULE_NAME, processAndAuditInsightExtraction } from "@fusion/core";
import { TaskStore, AutomationStore, CentralCore, AgentStore, PluginStore, PluginLoader, getTaskMergeBlocker, syncInsightExtractionAutomation, INSIGHT_EXTRACTION_SCHEDULE_NAME, processAndAuditInsightExtraction } from "@fusion/core";
import type { Settings, TaskDetail, PrInfo, ScheduledTask, AutomationRunResult } from "@fusion/core";
import { createServer, GitHubClient } from "@fusion/dashboard";
import { TriageProcessor, TaskExecutor, Scheduler, AgentSemaphore, WorktreePool, aiMergeTask, UsageLimitPauser, PRIORITY_MERGE, scanIdleWorktrees, cleanupOrphanedWorktrees, NtfyNotifier, PrMonitor, PrCommentHandler, CronRunner, StuckTaskDetector, SelfHealingManager, MissionAutopilot, createAiPromptExecutor, HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext } from "@fusion/engine";
@@ -309,6 +309,27 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
const agentStore = new AgentStore({ rootDir: store.getFusionDir() });
await agentStore.init();
// ── PluginStore: plugin installation management ─────────────────────
//
// SQLite-backed plugin persistence for the Settings → Plugins experience.
// Enables the PluginManager UI to list, install, enable, disable, and
// configure plugins via the /api/plugins REST endpoints.
//
const pluginStore = new PluginStore(store.getFusionDir());
await pluginStore.init();
// ── PluginLoader: plugin lifecycle management ───────────────────────
//
// Manages dynamic plugin loading, hot-reload, hook invocation, and
// dependency resolution. The PluginLoader instance also serves as the
// PluginRunner for the REST routes (provides getPluginRoutes and
// reloadPlugin methods).
//
const pluginLoader = new PluginLoader({
pluginStore,
taskStore: store,
});
// ── HeartbeatMonitor: runtime monitoring and execution for agents ───
//
// Provides the Paperclip-style heartbeat execution engine:
@@ -755,7 +776,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry);
// Start the web server with AI merge, auth, and model registry wired in
// Start the web server with AI merge, auth, model registry, and plugin wiring
const app = createServer(store, {
onMerge,
authStorage: dashboardAuthStorage,
@@ -763,6 +784,9 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
automationStore,
missionAutopilot,
heartbeatMonitor,
pluginStore,
pluginLoader,
pluginRunner: pluginLoader,
});
function dispose(): void {

View File

@@ -4,6 +4,8 @@ import {
AutomationStore,
CentralCore,
AgentStore,
PluginStore,
PluginLoader,
getTaskMergeBlocker,
syncInsightExtractionAutomation,
INSIGHT_EXTRACTION_SCHEDULE_NAME,
@@ -79,6 +81,27 @@ export async function runServe(
const agentStore = new AgentStore({ rootDir: store.getFusionDir() });
await agentStore.init();
// ── PluginStore: plugin installation management ─────────────────────
//
// SQLite-backed plugin persistence for the Settings → Plugins experience.
// Enables the PluginManager UI to list, install, enable, disable, and
// configure plugins via the /api/plugins REST endpoints.
//
const pluginStore = new PluginStore(store.getFusionDir());
await pluginStore.init();
// ── PluginLoader: plugin lifecycle management ───────────────────────
//
// Manages dynamic plugin loading, hot-reload, hook invocation, and
// dependency resolution. The PluginLoader instance also serves as the
// PluginRunner for the REST routes (provides getPluginRoutes and
// reloadPlugin methods).
//
const pluginLoader = new PluginLoader({
pluginStore,
taskStore: store,
});
// ── HeartbeatMonitor: runtime monitoring and execution for agents ───
//
// Provides the Paperclip-style heartbeat execution engine:
@@ -493,6 +516,9 @@ export async function runServe(
automationStore,
missionAutopilot,
heartbeatMonitor,
pluginStore,
pluginLoader,
pluginRunner: pluginLoader,
headless: true,
});