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:
@@ -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", () => {
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user