feat(FN-3565): add plugin management CLI, loader, runner, and dashboard rou

This merge adds a complete plugin management system to Fusion: a new `fn plugin` CLI command for installing/removing plugins, a plugin loader in core, a plugin runner in engine, and dashboard routes for plugin management UI, along with a plugin management guide in docs. It also documents task evalua

Fusion-Task-Id: FN-3565
This commit is contained in:
Fusion
2026-05-06 04:15:13 -07:00
committed by gsxdsm
parent 27973a8345
commit 8f812e2f89
15 changed files with 994 additions and 7 deletions

View File

@@ -2096,6 +2096,224 @@ export default plugin;
});
});
describe("plugin setup lifecycle", () => {
it("checkPluginSetup returns installed for plugins without setup", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
(loader as any).plugins.set("plain-plugin", {
manifest: makeManifest({ id: "plain-plugin" }),
state: "started",
hooks: {},
} as FusionPlugin);
await expect(loader.checkPluginSetup("plain-plugin")).resolves.toEqual({ status: "installed" });
});
it("checkPluginSetup throws when plugin is not loaded", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
await expect(loader.checkPluginSetup("missing-plugin")).rejects.toThrow('Plugin "missing-plugin" is not loaded');
});
it("checkPluginSetup calls hook and returns result", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
const checkSetup = vi.fn().mockResolvedValue({ status: "installed", version: "1.2.3", binaryPath: "/bin/agent-browser" });
(loader as any).plugins.set("setup-plugin", {
manifest: makeManifest({ id: "setup-plugin" }),
state: "started",
hooks: {},
setup: {
manifest: { binaryName: "agent-browser", description: "Binary" },
hooks: { checkSetup },
},
} as FusionPlugin);
await expect(loader.checkPluginSetup("setup-plugin")).resolves.toEqual({
status: "installed",
version: "1.2.3",
binaryPath: "/bin/agent-browser",
});
expect(checkSetup).toHaveBeenCalledTimes(1);
});
it("checkPluginSetup returns error status when hook throws", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
const checkSetup = vi.fn().mockRejectedValue(new Error("probe failed"));
(loader as any).plugins.set("error-setup-plugin", {
manifest: makeManifest({ id: "error-setup-plugin" }),
state: "started",
hooks: {},
setup: { manifest: { binaryName: "agent-browser", description: "Binary" }, hooks: { checkSetup } },
} as FusionPlugin);
await expect(loader.checkPluginSetup("error-setup-plugin")).resolves.toEqual({ status: "error", error: "probe failed" });
});
it("checkPluginSetup returns error status when hook times out", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
vi.useFakeTimers();
const checkSetup = vi.fn().mockImplementation(() => new Promise(() => undefined));
(loader as any).plugins.set("timeout-setup-plugin", {
manifest: makeManifest({ id: "timeout-setup-plugin" }),
state: "started",
hooks: {},
setup: {
manifest: { binaryName: "agent-browser", description: "Binary", defaultTimeoutMs: 5 },
hooks: { checkSetup },
},
} as FusionPlugin);
const resultPromise = loader.checkPluginSetup("timeout-setup-plugin");
await vi.advanceTimersByTimeAsync(6);
await expect(resultPromise).resolves.toEqual({
status: "error",
error: 'Setup check for "timeout-setup-plugin" timed out after 5ms',
});
vi.useRealTimers();
});
it("checkPluginSetup respects manifest defaultTimeoutMs", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
vi.useFakeTimers();
const checkSetup = vi.fn().mockImplementation(() => new Promise(() => undefined));
(loader as any).plugins.set("custom-timeout-setup-plugin", {
manifest: makeManifest({ id: "custom-timeout-setup-plugin" }),
state: "started",
hooks: {},
setup: {
manifest: { binaryName: "agent-browser", description: "Binary", defaultTimeoutMs: 12 },
hooks: { checkSetup },
},
} as FusionPlugin);
const resultPromise = loader.checkPluginSetup("custom-timeout-setup-plugin");
await vi.advanceTimersByTimeAsync(11);
expect(checkSetup).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
await expect(resultPromise).resolves.toEqual({
status: "error",
error: 'Setup check for "custom-timeout-setup-plugin" timed out after 12ms',
});
vi.useRealTimers();
});
it("installPluginSetup calls install hook", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
const install = vi.fn().mockResolvedValue(undefined);
(loader as any).plugins.set("install-plugin", {
manifest: makeManifest({ id: "install-plugin" }),
state: "started",
hooks: {},
setup: {
manifest: { binaryName: "agent-browser", description: "Binary" },
hooks: { checkSetup: vi.fn().mockResolvedValue({ status: "installed" }), install },
},
} as FusionPlugin);
await expect(loader.installPluginSetup("install-plugin")).resolves.toBeUndefined();
expect(install).toHaveBeenCalledTimes(1);
});
it("installPluginSetup throws when plugin has no install hook", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
(loader as any).plugins.set("no-install-plugin", {
manifest: makeManifest({ id: "no-install-plugin" }),
state: "started",
hooks: {},
setup: { manifest: { binaryName: "agent-browser", description: "Binary" }, hooks: { checkSetup: vi.fn() } },
} as FusionPlugin);
await expect(loader.installPluginSetup("no-install-plugin")).rejects.toThrow('Plugin "no-install-plugin" has no install hook');
});
it("installPluginSetup throws when plugin is not loaded", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
await expect(loader.installPluginSetup("missing-install-plugin")).rejects.toThrow('Plugin "missing-install-plugin" is not loaded');
});
it("installPluginSetup throws on timeout", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
vi.useFakeTimers();
const install = vi.fn().mockImplementation(() => new Promise(() => undefined));
(loader as any).plugins.set("timeout-install-plugin", {
manifest: makeManifest({ id: "timeout-install-plugin" }),
state: "started",
hooks: {},
setup: {
manifest: { binaryName: "agent-browser", description: "Binary", defaultTimeoutMs: 5 },
hooks: { checkSetup: vi.fn(), install },
},
} as FusionPlugin);
const installPromise = loader.installPluginSetup("timeout-install-plugin");
const installAssertion = expect(installPromise).rejects.toThrow('Install command for "timeout-install-plugin" timed out after 5ms');
await vi.advanceTimersByTimeAsync(6);
await installAssertion;
vi.useRealTimers();
});
it("uninstallPluginSetup calls uninstall hook", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
const uninstall = vi.fn().mockResolvedValue(undefined);
(loader as any).plugins.set("uninstall-plugin", {
manifest: makeManifest({ id: "uninstall-plugin" }),
state: "started",
hooks: {},
setup: {
manifest: { binaryName: "agent-browser", description: "Binary" },
hooks: { checkSetup: vi.fn().mockResolvedValue({ status: "installed" }), uninstall },
},
} as FusionPlugin);
await expect(loader.uninstallPluginSetup("uninstall-plugin")).resolves.toBeUndefined();
expect(uninstall).toHaveBeenCalledTimes(1);
});
it("uninstallPluginSetup returns silently when no uninstall hook", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
(loader as any).plugins.set("no-uninstall-plugin", {
manifest: makeManifest({ id: "no-uninstall-plugin" }),
state: "started",
hooks: {},
setup: { manifest: { binaryName: "agent-browser", description: "Binary" }, hooks: { checkSetup: vi.fn() } },
} as FusionPlugin);
await expect(loader.uninstallPluginSetup("no-uninstall-plugin")).resolves.toBeUndefined();
});
it("uninstallPluginSetup respects timeout", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
vi.useFakeTimers();
const uninstall = vi.fn().mockImplementation(() => new Promise(() => undefined));
(loader as any).plugins.set("timeout-uninstall-plugin", {
manifest: makeManifest({ id: "timeout-uninstall-plugin" }),
state: "started",
hooks: {},
setup: {
manifest: { binaryName: "agent-browser", description: "Binary", defaultTimeoutMs: 5 },
hooks: { checkSetup: vi.fn(), uninstall },
},
} as FusionPlugin);
const uninstallPromise = loader.uninstallPluginSetup("timeout-uninstall-plugin");
const uninstallAssertion = expect(uninstallPromise).rejects.toThrow('Uninstall command for "timeout-uninstall-plugin" timed out after 5ms');
await vi.advanceTimersByTimeAsync(6);
await uninstallAssertion;
vi.useRealTimers();
});
});
// ── getLoadedPlugins ───────────────────────────────────────────────
describe("getLoadedPlugins", () => {

View File

@@ -34,6 +34,7 @@ import type {
PluginPromptContributions,
PluginSetupManifest,
PluginSetupHooks,
PluginSetupCheckResult,
} from "./plugin-types.js";
import { normalizePluginUiContributionDefinition, validatePluginManifest } from "./plugin-types.js";
import { createLogger } from "./logger.js";
@@ -745,6 +746,89 @@ export class PluginLoader extends EventEmitter<{
}
}
async checkPluginSetup(pluginId: string): Promise<PluginSetupCheckResult> {
const plugin = this.plugins.get(pluginId);
if (!plugin) {
throw new Error(`Plugin "${pluginId}" is not loaded`);
}
if (!plugin.setup) {
return { status: "installed" };
}
const timeout = plugin.setup.manifest.defaultTimeoutMs ?? 30_000;
try {
const ctx = await this.createContext(plugin);
return await this.withTimeout(
plugin.setup.hooks.checkSetup(ctx),
timeout,
`Setup check for "${pluginId}" timed out after ${timeout}ms`,
);
} catch (error) {
return {
status: "error",
error: error instanceof Error ? error.message : String(error),
};
}
}
async installPluginSetup(pluginId: string): Promise<void> {
const plugin = this.plugins.get(pluginId);
if (!plugin) {
throw new Error(`Plugin "${pluginId}" is not loaded`);
}
if (!plugin.setup?.hooks.install) {
throw new Error(`Plugin "${pluginId}" has no install hook`);
}
const timeout = plugin.setup.manifest.defaultTimeoutMs ?? 120_000;
const ctx = await this.createContext(plugin);
try {
await this.withTimeout(
plugin.setup.hooks.install(ctx),
timeout,
`Install command for "${pluginId}" timed out after ${timeout}ms`,
);
} catch (error) {
if (error instanceof Error && error.message.includes(`timed out after ${timeout}ms`)) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Install hook failed for "${pluginId}": ${message}`);
}
}
async uninstallPluginSetup(pluginId: string): Promise<void> {
const plugin = this.plugins.get(pluginId);
if (!plugin) {
throw new Error(`Plugin "${pluginId}" is not loaded`);
}
if (!plugin.setup?.hooks.uninstall) {
return;
}
const timeout = plugin.setup.manifest.defaultTimeoutMs ?? 60_000;
const ctx = await this.createContext(plugin);
try {
await this.withTimeout(
plugin.setup.hooks.uninstall(ctx),
timeout,
`Uninstall command for "${pluginId}" timed out after ${timeout}ms`,
);
} catch (error) {
if (error instanceof Error && error.message.includes(`timed out after ${timeout}ms`)) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Uninstall hook failed for "${pluginId}": ${message}`);
}
}
// ── Accessors ─────────────────────────────────────────────────────
/**