feat(FN-1111): merge fusion/fn-1111 (auto-resolved)

- docs(FN-1111): complete Step 8 - plugin system documentation
- feat(FN-1111): complete Step 7 - testing and build fixes
- feat(FN-1111): complete Step 6 - export plugin types from core
- feat(FN-1111): complete Step 5 - plugin SDK package
- feat(FN-1111): complete Step 4 - plugin loader with lifecycle management
- feat(FN-1111): complete Step 3 - plugin store with CRUD
- feat(FN-1111): complete Step 2 - add plugins table schema migration
- feat(FN-1111): complete Step 1 - plugin type definitions
This commit is contained in:
gsxdsm
2026-04-09 14:45:32 -07:00
parent cdc430598d
commit 8c5327a660
16 changed files with 3577 additions and 11 deletions

View File

@@ -0,0 +1,865 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { mkdtempSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { PluginLoader } from "./plugin-loader.js";
import { PluginStore } from "./plugin-store.js";
import type { FusionPlugin, PluginManifest } from "./plugin-types.js";
// Test plugin manifest
function makeManifest(overrides: Partial<PluginManifest> = {}): PluginManifest {
return {
id: "test-plugin",
name: "Test Plugin",
version: "1.0.0",
description: "A test plugin",
...overrides,
};
}
// Create a minimal FusionPlugin for testing
function makePlugin(manifest: PluginManifest): FusionPlugin {
return {
manifest,
state: "installed",
hooks: {},
tools: [],
routes: [],
};
}
// Write a plugin module to disk - creates a simple module without hooks
async function writePluginModule(
dir: string,
filename: string,
plugin: FusionPlugin,
): Promise<string> {
const filepath = join(dir, filename);
await mkdir(dir, { recursive: true });
const manifest = JSON.stringify(plugin.manifest, null, 2);
// Create a module that exports the plugin
const moduleCode = `
const manifest = ${manifest};
const plugin = {
manifest,
state: "${plugin.state}",
hooks: {},
tools: ${JSON.stringify(plugin.tools || [])},
routes: ${JSON.stringify(plugin.routes || [])},
};
export default plugin;
export { plugin };
`;
await writeFile(filepath, moduleCode);
return filepath;
}
// Create a plugin module with hooks
async function writePluginWithHooks(
dir: string,
filename: string,
hooks: {
onLoad?: string;
onUnload?: string;
onTaskCreated?: string;
onError?: string;
},
manifest: PluginManifest,
): Promise<string> {
const filepath = join(dir, filename);
await mkdir(dir, { recursive: true });
const manifestStr = JSON.stringify(manifest, null, 2);
const hooksCode = Object.entries(hooks)
.map(([name, body]) => `${name}: ${body}`)
.join(",\n ");
const moduleCode = `
const manifest = ${manifestStr};
const plugin = {
manifest,
state: "installed",
hooks: {
${hooksCode}
},
tools: [],
routes: [],
};
export default plugin;
export { plugin };
`;
await writeFile(filepath, moduleCode);
return filepath;
}
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-plugin-loader-test-"));
}
// Mock TaskStore for testing
const mockTaskStore = {
logActivity: vi.fn(),
} as any;
describe("PluginLoader", () => {
let rootDir: string;
let pluginStore: PluginStore;
let loader: PluginLoader;
beforeEach(() => {
rootDir = makeTmpDir();
pluginStore = new PluginStore(rootDir);
});
afterEach(async () => {
const { rm } = await import("node:fs/promises");
await rm(rootDir, { recursive: true, force: true });
vi.clearAllMocks();
});
// ── Constructor & init ─────────────────────────────────────────────
describe("constructor", () => {
it("creates loader with options", () => {
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
expect(loader).toBeTruthy();
});
it("accepts custom plugin directories", () => {
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
pluginDirs: ["/custom/plugins"],
});
expect(loader).toBeTruthy();
});
});
// ── resolveLoadOrder ──────────────────────────────────────────────
describe("resolveLoadOrder", () => {
it("returns plugins in dependency order", async () => {
await pluginStore.init();
await pluginStore.registerPlugin({
manifest: makeManifest({ id: "plugin-a", dependencies: [] }),
path: "/a",
});
await pluginStore.registerPlugin({
manifest: makeManifest({ id: "plugin-b", dependencies: ["plugin-a"] }),
path: "/b",
});
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
const plugins = await pluginStore.listPlugins();
const sorted = loader.resolveLoadOrder(plugins);
expect(sorted[0].id).toBe("plugin-a");
expect(sorted[1].id).toBe("plugin-b");
});
it("handles complex dependency chains", async () => {
await pluginStore.init();
await pluginStore.registerPlugin({
manifest: makeManifest({ id: "base", dependencies: [] }),
path: "/base",
});
await pluginStore.registerPlugin({
manifest: makeManifest({ id: "middle", dependencies: ["base"] }),
path: "/middle",
});
await pluginStore.registerPlugin({
manifest: makeManifest({ id: "top", dependencies: ["middle", "base"] }),
path: "/top",
});
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
const plugins = await pluginStore.listPlugins();
const sorted = loader.resolveLoadOrder(plugins);
// base must come before middle and top
expect(sorted.findIndex((p) => p.id === "base")).toBeLessThan(
sorted.findIndex((p) => p.id === "middle"),
);
expect(sorted.findIndex((p) => p.id === "base")).toBeLessThan(
sorted.findIndex((p) => p.id === "top"),
);
// middle must come before top
expect(sorted.findIndex((p) => p.id === "middle")).toBeLessThan(
sorted.findIndex((p) => p.id === "top"),
);
});
it("throws on circular dependencies", async () => {
await pluginStore.init();
await pluginStore.registerPlugin({
manifest: makeManifest({ id: "a", dependencies: ["b"] }),
path: "/a",
});
await pluginStore.registerPlugin({
manifest: makeManifest({ id: "b", dependencies: ["a"] }),
path: "/b",
});
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
const plugins = await pluginStore.listPlugins();
expect(() => loader.resolveLoadOrder(plugins)).toThrow(
"Circular dependency detected",
);
});
it("handles plugins with no dependencies", async () => {
await pluginStore.init();
await pluginStore.registerPlugin({
manifest: makeManifest({ id: "solo" }),
path: "/solo",
});
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
const plugins = await pluginStore.listPlugins();
const sorted = loader.resolveLoadOrder(plugins);
expect(sorted).toHaveLength(1);
expect(sorted[0].id).toBe("solo");
});
});
// ── loadPlugin ─────────────────────────────────────────────────────
describe("loadPlugin", () => {
it("loads a valid plugin from file path", async () => {
await pluginStore.init();
const pluginDir = join(rootDir, "plugins");
const plugin = makePlugin(makeManifest({ id: "load-test" }));
const pluginPath = await writePluginModule(pluginDir, "index.js", plugin);
await pluginStore.registerPlugin({
manifest: plugin.manifest,
path: pluginPath,
});
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
const loaded = await loader.loadPlugin("load-test");
expect(loaded.manifest.id).toBe("load-test");
expect(loaded.state).toBe("started");
expect(loader.isPluginLoaded("load-test")).toBe(true);
});
it("updates plugin state to started", async () => {
await pluginStore.init();
const plugin = makePlugin(makeManifest({ id: "state-test" }));
const pluginDir = join(rootDir, "plugins");
const pluginPath = await writePluginModule(pluginDir, "index.js", plugin);
await pluginStore.registerPlugin({
manifest: plugin.manifest,
path: pluginPath,
});
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
await loader.loadPlugin("state-test");
const updated = await pluginStore.getPlugin("state-test");
expect(updated.state).toBe("started");
});
it("skips disabled plugins", async () => {
await pluginStore.init();
const plugin = makePlugin(makeManifest({ id: "disabled-test" }));
const pluginDir = join(rootDir, "plugins");
const pluginPath = await writePluginModule(pluginDir, "index.js", plugin);
await pluginStore.registerPlugin({
manifest: plugin.manifest,
path: pluginPath,
});
await pluginStore.disablePlugin("disabled-test");
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
await expect(loader.loadPlugin("disabled-test")).rejects.toThrow(
"disabled",
);
});
it("loads dependencies before loading dependent", async () => {
await pluginStore.init();
const depPlugin = makePlugin(makeManifest({ id: "dep-plugin" }));
const mainPlugin = makePlugin(
makeManifest({ id: "main-plugin", dependencies: ["dep-plugin"] }),
);
const pluginDir = join(rootDir, "plugins");
const depPath = await writePluginModule(pluginDir, "dep.js", depPlugin);
const mainPath = await writePluginModule(pluginDir, "main.js", mainPlugin);
await pluginStore.registerPlugin({
manifest: depPlugin.manifest,
path: depPath,
});
await pluginStore.registerPlugin({
manifest: mainPlugin.manifest,
path: mainPath,
});
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
// Use loadAllPlugins to test dependency ordering
const result = await loader.loadAllPlugins();
expect(result.loaded).toBe(2);
expect(loader.isPluginLoaded("dep-plugin")).toBe(true);
expect(loader.isPluginLoaded("main-plugin")).toBe(true);
});
it("fails when dependency is missing", async () => {
await pluginStore.init();
const plugin = makePlugin(
makeManifest({ id: "orphan-plugin", dependencies: ["nonexistent"] }),
);
const pluginDir = join(rootDir, "plugins");
const pluginPath = await writePluginModule(pluginDir, "index.js", plugin);
await pluginStore.registerPlugin({
manifest: plugin.manifest,
path: pluginPath,
});
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
await expect(loader.loadPlugin("orphan-plugin")).rejects.toThrow(
"depends on nonexistent",
);
});
it("error isolation - plugin crash during load doesn't crash loader", async () => {
await pluginStore.init();
const pluginDir = join(rootDir, "plugins");
const pluginPath = await writePluginWithHooks(
pluginDir,
"bad.js",
{
onLoad: "(async () => { throw new Error('Plugin crashed!'); })",
},
makeManifest({ id: "bad-plugin" }),
);
await pluginStore.registerPlugin({
manifest: makeManifest({ id: "bad-plugin" }),
path: pluginPath,
});
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
// Should throw but not crash the process
await expect(loader.loadPlugin("bad-plugin")).rejects.toThrow(
"Plugin crashed!",
);
// Plugin should be in error state
const updated = await pluginStore.getPlugin("bad-plugin");
expect(updated.state).toBe("error");
expect(updated.error).toContain("Plugin crashed!");
});
});
// ── loadAllPlugins ─────────────────────────────────────────────────
describe("loadAllPlugins", () => {
it("loads all enabled plugins", async () => {
await pluginStore.init();
const plugins: FusionPlugin[] = [
makePlugin(makeManifest({ id: "all-a" })),
makePlugin(makeManifest({ id: "all-b", dependencies: ["all-a"] })),
];
const pluginDir = join(rootDir, "plugins");
for (const plugin of plugins) {
const path = await writePluginModule(
pluginDir,
`${plugin.manifest.id}.js`,
plugin,
);
await pluginStore.registerPlugin({
manifest: plugin.manifest,
path,
});
}
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
const result = await loader.loadAllPlugins();
expect(result.loaded).toBe(2);
expect(result.errors).toBe(0);
expect(loader.isPluginLoaded("all-a")).toBe(true);
expect(loader.isPluginLoaded("all-b")).toBe(true);
});
it("returns error count for failed plugins", async () => {
await pluginStore.init();
const goodPlugin = makePlugin(makeManifest({ id: "good-plugin" }));
const pluginDir = join(rootDir, "plugins");
const goodPath = await writePluginModule(
pluginDir,
"good.js",
goodPlugin,
);
const badPath = await writePluginWithHooks(
pluginDir,
"bad.js",
{
onLoad: "(async () => { throw new Error('Load failed'); })",
},
makeManifest({ id: "bad-plugin" }),
);
await pluginStore.registerPlugin({
manifest: goodPlugin.manifest,
path: goodPath,
});
await pluginStore.registerPlugin({
manifest: makeManifest({ id: "bad-plugin" }),
path: badPath,
});
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
const result = await loader.loadAllPlugins();
expect(result.loaded).toBe(1);
expect(result.errors).toBe(1);
});
});
// ── stopPlugin ────────────────────────────────────────────────────
describe("stopPlugin", () => {
it("updates plugin state to stopped", async () => {
await pluginStore.init();
const plugin = makePlugin(makeManifest({ id: "stop-state-test" }));
const pluginDir = join(rootDir, "plugins");
const pluginPath = await writePluginModule(pluginDir, "index.js", plugin);
await pluginStore.registerPlugin({
manifest: plugin.manifest,
path: pluginPath,
});
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
await loader.loadPlugin("stop-state-test");
await loader.stopPlugin("stop-state-test");
const updated = await pluginStore.getPlugin("stop-state-test");
expect(updated.state).toBe("stopped");
});
it("removes plugin from loaded map", async () => {
await pluginStore.init();
const plugin = makePlugin(makeManifest({ id: "remove-test" }));
const pluginDir = join(rootDir, "plugins");
const pluginPath = await writePluginModule(pluginDir, "index.js", plugin);
await pluginStore.registerPlugin({
manifest: plugin.manifest,
path: pluginPath,
});
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
await loader.loadPlugin("remove-test");
expect(loader.isPluginLoaded("remove-test")).toBe(true);
await loader.stopPlugin("remove-test");
expect(loader.isPluginLoaded("remove-test")).toBe(false);
});
it("no-ops for non-loaded plugin", async () => {
await pluginStore.init();
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
// Should not throw
await loader.stopPlugin("nonexistent");
});
});
// ── stopAllPlugins ─────────────────────────────────────────────────
describe("stopAllPlugins", () => {
it("stops all loaded plugins", async () => {
await pluginStore.init();
const plugins: FusionPlugin[] = [
makePlugin(makeManifest({ id: "stop-all-a" })),
makePlugin(makeManifest({ id: "stop-all-b" })),
];
const pluginDir = join(rootDir, "plugins");
for (const plugin of plugins) {
const path = await writePluginModule(
pluginDir,
`${plugin.manifest.id}.js`,
plugin,
);
await pluginStore.registerPlugin({
manifest: plugin.manifest,
path,
});
}
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
await loader.loadAllPlugins();
await loader.stopAllPlugins();
expect(loader.isPluginLoaded("stop-all-a")).toBe(false);
expect(loader.isPluginLoaded("stop-all-b")).toBe(false);
});
});
// ── invokeHook ───────────────────────────────────────────────────
describe("invokeHook", () => {
it("calls hook on all plugins with the hook", async () => {
await pluginStore.init();
const hookA = vi.fn();
const hookB = vi.fn();
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
// Manually add plugins with hooks to the loader's internal state
(loader as any).plugins.set("hook-a", {
manifest: makeManifest({ id: "hook-a" }),
state: "started",
hooks: { onTaskCreated: hookA },
tools: [],
routes: [],
} as FusionPlugin);
(loader as any).plugins.set("hook-b", {
manifest: makeManifest({ id: "hook-b" }),
state: "started",
hooks: { onTaskCreated: hookB },
tools: [],
routes: [],
} as FusionPlugin);
await loader.invokeHook("onTaskCreated", { id: "FN-001" } as any);
expect(hookA).toHaveBeenCalledTimes(1);
expect(hookB).toHaveBeenCalledTimes(1);
});
it("continues when one plugin's hook fails", async () => {
await pluginStore.init();
const hookGood = vi.fn();
const hookBad = vi.fn().mockImplementation(() => {
throw new Error("Hook failed!");
});
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
// Manually add plugins with hooks
(loader as any).plugins.set("good-hook", {
manifest: makeManifest({ id: "good-hook" }),
state: "started",
hooks: { onTaskCreated: hookGood },
tools: [],
routes: [],
} as FusionPlugin);
(loader as any).plugins.set("bad-hook", {
manifest: makeManifest({ id: "bad-hook" }),
state: "started",
hooks: { onTaskCreated: hookBad },
tools: [],
routes: [],
} as FusionPlugin);
// Should not throw
await loader.invokeHook("onTaskCreated", { id: "FN-001" } as any);
// Both hooks were attempted
expect(hookGood).toHaveBeenCalledTimes(1);
expect(hookBad).toHaveBeenCalledTimes(1);
});
it("no error when plugin doesn't have the hook", async () => {
await pluginStore.init();
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
// Manually add plugin without hooks
(loader as any).plugins.set("no-hook", {
manifest: makeManifest({ id: "no-hook" }),
state: "started",
hooks: {},
tools: [],
routes: [],
} as FusionPlugin);
// Should not throw even though plugin has no hooks
await loader.invokeHook("onTaskCreated", { id: "FN-001" } as any);
});
});
// ── getPluginTools ─────────────────────────────────────────────────
describe("getPluginTools", () => {
it("aggregates tools from all loaded plugins", async () => {
await pluginStore.init();
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
// Manually add plugins with tools
(loader as any).plugins.set("tools-a", {
manifest: makeManifest({ id: "tools-a" }),
state: "started",
hooks: {},
tools: [
{
name: "tool_a1",
description: "Tool A1",
parameters: {},
execute: async () => ({ content: [] }),
},
],
routes: [],
} as FusionPlugin);
(loader as any).plugins.set("tools-b", {
manifest: makeManifest({ id: "tools-b" }),
state: "started",
hooks: {},
tools: [
{
name: "tool_b1",
description: "Tool B1",
parameters: {},
execute: async () => ({ content: [] }),
},
],
routes: [],
} as FusionPlugin);
const tools = loader.getPluginTools();
expect(tools).toHaveLength(2);
expect(tools.map((t) => t.name)).toContain("tool_a1");
expect(tools.map((t) => t.name)).toContain("tool_b1");
});
it("returns empty array when no plugins have tools", async () => {
await pluginStore.init();
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
// Manually add plugin without tools
(loader as any).plugins.set("no-tools", {
manifest: makeManifest({ id: "no-tools" }),
state: "started",
hooks: {},
tools: [],
routes: [],
} as FusionPlugin);
const tools = loader.getPluginTools();
expect(tools).toEqual([]);
});
});
// ── getPluginRoutes ───────────────────────────────────────────────
describe("getPluginRoutes", () => {
it("aggregates routes from all loaded plugins", async () => {
await pluginStore.init();
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
// Manually add plugins with routes
(loader as any).plugins.set("routes-a", {
manifest: makeManifest({ id: "routes-a" }),
state: "started",
hooks: {},
tools: [],
routes: [
{
method: "GET",
path: "/status",
handler: async () => ({}),
},
],
} as FusionPlugin);
(loader as any).plugins.set("routes-b", {
manifest: makeManifest({ id: "routes-b" }),
state: "started",
hooks: {},
tools: [],
routes: [
{
method: "POST",
path: "/action",
handler: async () => ({}),
},
],
} as FusionPlugin);
const routes = loader.getPluginRoutes();
expect(routes).toHaveLength(2);
expect(routes.find((r) => r.pluginId === "routes-a")?.route.path).toBe(
"/status",
);
expect(routes.find((r) => r.pluginId === "routes-b")?.route.path).toBe(
"/action",
);
});
});
// ── getLoadedPlugins ───────────────────────────────────────────────
describe("getLoadedPlugins", () => {
it("returns all loaded plugin instances", async () => {
await pluginStore.init();
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
// Manually add plugins
(loader as any).plugins.set("loaded-a", {
manifest: makeManifest({ id: "loaded-a" }),
state: "started",
hooks: {},
tools: [],
routes: [],
} as FusionPlugin);
(loader as any).plugins.set("loaded-b", {
manifest: makeManifest({ id: "loaded-b" }),
state: "started",
hooks: {},
tools: [],
routes: [],
} as FusionPlugin);
const loaded = loader.getLoadedPlugins();
expect(loaded).toHaveLength(2);
expect(loaded.map((p) => p.manifest.id).sort()).toEqual([
"loaded-a",
"loaded-b",
]);
});
it("returns empty array when no plugins loaded", async () => {
await pluginStore.init();
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
const loaded = loader.getLoadedPlugins();
expect(loaded).toEqual([]);
});
});
});