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:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user