feat(FN-1133): add plugin hot-reload support

- Add PluginLoader hot-load/unload with watch mode, auto-recovery, and staged loading
- Add PluginRunner reactive integration with executor dynamic tools registration
- Add dashboard reload endpoint (POST /api/plugins/reload) and PluginManager UI
- Add comprehensive tests for plugin-hot-reload (core) and plugin-runner (engine)
- Update plugin authoring docs and add memory notes
- Add changeset for @gsxdsm/fusion minor release
This commit is contained in:
gsxdsm
2026-04-09 19:15:53 -07:00
parent a3163f9935
commit db0d5c8dc9
10 changed files with 1176 additions and 30 deletions

View File

@@ -30,6 +30,11 @@ describe("PluginRunner", () => {
getPluginRoutes: ReturnType<typeof vi.fn>;
getLoadedPlugins: ReturnType<typeof vi.fn>;
getPlugin: ReturnType<typeof vi.fn>;
loadPlugin: ReturnType<typeof vi.fn>;
stopPlugin: ReturnType<typeof vi.fn>;
reloadPlugin: ReturnType<typeof vi.fn>;
on: ReturnType<typeof vi.fn>;
off: ReturnType<typeof vi.fn>;
};
let mockPluginStore: {
on: ReturnType<typeof vi.fn>;
@@ -64,6 +69,11 @@ describe("PluginRunner", () => {
getPluginRoutes: vi.fn().mockReturnValue([]),
getLoadedPlugins: vi.fn().mockReturnValue([]),
getPlugin: vi.fn(),
loadPlugin: vi.fn().mockResolvedValue({}),
stopPlugin: vi.fn().mockResolvedValue(undefined),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
on: vi.fn(),
off: vi.fn(),
};
const mockOn = vi.fn();
@@ -394,4 +404,213 @@ describe("PluginRunner", () => {
await expect(runner.invokeHook("onTaskCreated", {})).resolves.not.toThrow();
});
});
describe("Hot-load via store events", () => {
it("should auto-load plugin when plugin:enabled event fires", async () => {
await pluginRunner.init();
// Find the plugin:enabled handler
const enabledHandler = mockPluginStore.on.mock.calls.find(
(call) => call[0] === "plugin:enabled",
)?.[1] as (plugin: any) => void;
// Simulate plugin:enabled event
await enabledHandler?.({ id: "test-plugin", name: "Test Plugin", version: "1.0.0" });
// Should have called loadPlugin
expect(mockPluginLoader.loadPlugin).toHaveBeenCalledWith("test-plugin");
});
it("should auto-stop plugin when plugin:disabled event fires", async () => {
await pluginRunner.init();
// Find the plugin:disabled handler
const disabledHandler = mockPluginStore.on.mock.calls.find(
(call) => call[0] === "plugin:disabled",
)?.[1] as (plugin: any) => void;
// Simulate plugin:disabled event
await disabledHandler?.({ id: "test-plugin", name: "Test Plugin", version: "1.0.0" });
// Should have called stopPlugin
expect(mockPluginLoader.stopPlugin).toHaveBeenCalledWith("test-plugin");
});
it("should stop plugin when plugin:unregistered event fires", async () => {
await pluginRunner.init();
// Find the plugin:unregistered handler
const unregisteredHandler = mockPluginStore.on.mock.calls.find(
(call) => call[0] === "plugin:unregistered",
)?.[1] as (plugin: any) => void;
// Simulate plugin:unregistered event
await unregisteredHandler?.({ id: "test-plugin", name: "Test Plugin", version: "1.0.0" });
// Should have called stopPlugin
expect(mockPluginLoader.stopPlugin).toHaveBeenCalledWith("test-plugin");
});
it("should isolate errors in auto-load", async () => {
await pluginRunner.init();
// Make loadPlugin throw
mockPluginLoader.loadPlugin.mockRejectedValue(new Error("Load failed"));
// Find the plugin:enabled handler
const enabledHandler = mockPluginStore.on.mock.calls.find(
(call) => call[0] === "plugin:enabled",
)?.[1] as (plugin: any) => void;
// Should not throw
await expect(
enabledHandler?.({ id: "test-plugin", name: "Test Plugin", version: "1.0.0" }),
).resolves.not.toThrow();
});
it("should isolate errors in auto-stop", async () => {
await pluginRunner.init();
// Make stopPlugin throw
mockPluginLoader.stopPlugin.mockRejectedValue(new Error("Stop failed"));
// Find the plugin:disabled handler
const disabledHandler = mockPluginStore.on.mock.calls.find(
(call) => call[0] === "plugin:disabled",
)?.[1] as (plugin: any) => void;
// Should not throw
await expect(
disabledHandler?.({ id: "test-plugin", name: "Test Plugin", version: "1.0.0" }),
).resolves.not.toThrow();
});
});
describe("reloadPlugin()", () => {
it("should call pluginLoader.reloadPlugin", async () => {
await pluginRunner.init();
await pluginRunner.reloadPlugin("test-plugin");
expect(mockPluginLoader.reloadPlugin).toHaveBeenCalledWith("test-plugin");
});
it("should invalidate caches after reload", async () => {
const pluginTool: PluginToolDefinition = {
name: "testTool",
description: "A test tool",
parameters: { type: "object", properties: {} },
execute: vi.fn(),
};
const plugin = createMockPlugin({
manifest: { id: "test-plugin", name: "Test Plugin", version: "1.0.0" },
tools: [pluginTool],
});
mockPluginLoader.getLoadedPlugins.mockReturnValue([plugin]);
mockPluginLoader.getPluginTools.mockReturnValue([pluginTool]);
mockPluginLoader.getPlugin.mockReturnValue(plugin);
await pluginRunner.init();
// Get tools to build cache
const tools1 = pluginRunner.getPluginTools();
expect(tools1.length).toBe(1);
// Reload
await pluginRunner.reloadPlugin("test-plugin");
// Cache should be invalidated, getPluginTools called again
expect(mockPluginLoader.getPluginTools).toHaveBeenCalled();
});
});
describe("Plugin loader events", () => {
it("should subscribe to plugin:loaded event", async () => {
await pluginRunner.init();
expect(mockPluginLoader.on).toHaveBeenCalledWith("plugin:loaded", expect.any(Function));
});
it("should subscribe to plugin:unloaded event", async () => {
await pluginRunner.init();
expect(mockPluginLoader.on).toHaveBeenCalledWith("plugin:unloaded", expect.any(Function));
});
it("should subscribe to plugin:reloaded event", async () => {
await pluginRunner.init();
expect(mockPluginLoader.on).toHaveBeenCalledWith("plugin:reloaded", expect.any(Function));
});
});
describe("Event cleanup on shutdown", () => {
it("should unsubscribe from plugin store events", async () => {
await pluginRunner.init();
await pluginRunner.shutdown();
expect(mockPluginStore.off).toHaveBeenCalledWith("plugin:enabled", expect.any(Function));
expect(mockPluginStore.off).toHaveBeenCalledWith("plugin:disabled", expect.any(Function));
expect(mockPluginStore.off).toHaveBeenCalledWith("plugin:unregistered", expect.any(Function));
expect(mockPluginStore.off).toHaveBeenCalledWith("plugin:stateChanged", expect.any(Function));
expect(mockPluginStore.off).toHaveBeenCalledWith("plugin:updated", expect.any(Function));
});
it("should unsubscribe from plugin loader events", async () => {
await pluginRunner.init();
await pluginRunner.shutdown();
expect(mockPluginLoader.off).toHaveBeenCalledWith("plugin:loaded", expect.any(Function));
expect(mockPluginLoader.off).toHaveBeenCalledWith("plugin:unloaded", expect.any(Function));
expect(mockPluginLoader.off).toHaveBeenCalledWith("plugin:reloaded", expect.any(Function));
});
});
describe("Cache invalidation lifecycle", () => {
it("should invalidate caches on plugin:loaded event", async () => {
await pluginRunner.init();
// Build cache
mockPluginLoader.getPluginTools.mockReturnValue([]);
mockPluginLoader.getPluginRoutes.mockReturnValue([]);
pluginRunner.getPluginTools();
pluginRunner.getPluginRoutes();
const initialToolsCalls = mockPluginLoader.getPluginTools.mock.calls.length;
// Find and trigger plugin:loaded handler
const loadedHandler = mockPluginLoader.on.mock.calls.find(
(call) => call[0] === "plugin:loaded",
)?.[1] as (event: any) => void;
loadedHandler?.({ pluginId: "test-plugin" });
// Get tools again - should rebuild cache
mockPluginLoader.getPluginTools.mockReturnValue([]);
pluginRunner.getPluginTools();
// Should have called getPluginTools again (cache invalidated and rebuilt)
expect(mockPluginLoader.getPluginTools.mock.calls.length).toBeGreaterThan(initialToolsCalls);
});
it("should invalidate caches on plugin:unloaded event", async () => {
await pluginRunner.init();
// Build cache
mockPluginLoader.getPluginTools.mockReturnValue([]);
mockPluginLoader.getPluginRoutes.mockReturnValue([]);
pluginRunner.getPluginTools();
pluginRunner.getPluginRoutes();
const initialToolsCalls = mockPluginLoader.getPluginTools.mock.calls.length;
// Find and trigger plugin:unloaded handler
const unloadedHandler = mockPluginLoader.on.mock.calls.find(
(call) => call[0] === "plugin:unloaded",
)?.[1] as (event: any) => void;
unloadedHandler?.({ pluginId: "test-plugin" });
// Get tools again - should rebuild cache
mockPluginLoader.getPluginTools.mockReturnValue([]);
pluginRunner.getPluginTools();
expect(mockPluginLoader.getPluginTools.mock.calls.length).toBeGreaterThan(initialToolsCalls);
});
});
});

View File

@@ -43,16 +43,49 @@ interface CachedTools {
version: number;
}
/**
* Cached routes - rebuilt when plugin state changes
*/
interface CachedRoutes {
routes: Array<{ pluginId: string; route: PluginRouteDefinition }>;
version: number;
}
const DEFAULT_HOOK_TIMEOUT_MS = 5000;
export class PluginRunner {
private readonly log = createLogger("plugin-runner");
private cachedTools: CachedTools | null = null;
private cachedRoutes: CachedRoutes | null = null;
private toolsCacheVersion = 0;
private routesCacheVersion = 0;
private hookTimeoutMs: number;
// Event handler references for cleanup
private handlePluginEnabled: (plugin: import("@fusion/core").PluginInstallation) => void;
private handlePluginDisabled: (plugin: import("@fusion/core").PluginInstallation) => void;
private handlePluginUnregistered: (plugin: import("@fusion/core").PluginInstallation) => void;
private handlePluginStateChanged!: () => void;
private handlePluginUpdated!: () => void;
private handlePluginLoaded: (event: { pluginId: string }) => void;
private handlePluginUnloaded: (event: { pluginId: string }) => void;
private handlePluginReloaded: (event: { pluginId: string }) => void;
constructor(private options: PluginRunnerOptions) {
this.hookTimeoutMs = options.hookTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS;
// Create bound event handlers for proper cleanup
this.handlePluginEnabled = this.onPluginEnabled.bind(this);
this.handlePluginDisabled = this.onPluginDisabled.bind(this);
this.handlePluginUnregistered = this.onPluginUnregistered.bind(this);
this.handlePluginStateChanged = this.onPluginStateChanged.bind(this);
this.handlePluginUpdated = this.onPluginUpdated.bind(this);
this.handlePluginLoaded = this.onPluginLoaded.bind(this);
this.handlePluginUnloaded = this.onPluginUnloaded.bind(this);
this.handlePluginReloaded = this.onPluginReloaded.bind(this);
this.handlePluginLoaded = this.onPluginLoaded.bind(this);
this.handlePluginUnloaded = this.onPluginUnloaded.bind(this);
this.handlePluginReloaded = this.onPluginReloaded.bind(this);
}
/**
@@ -69,12 +102,21 @@ export class PluginRunner {
// Subscribe to store events for task lifecycle hooks
this.subscribeToStoreEvents();
// Subscribe to plugin state changes to invalidate tools cache
// Subscribe to plugin store events for automatic hot-load/unload
this.options.pluginStore.on("plugin:enabled", this.handlePluginEnabled);
this.options.pluginStore.on("plugin:disabled", this.handlePluginDisabled);
this.options.pluginStore.on("plugin:unregistered", this.handlePluginUnregistered);
this.options.pluginStore.on("plugin:stateChanged", this.handlePluginStateChanged);
this.options.pluginStore.on("plugin:updated", this.handlePluginUpdated);
// Build initial tools cache
// Subscribe to plugin loader events for cache invalidation
this.options.pluginLoader.on("plugin:loaded", this.handlePluginLoaded);
this.options.pluginLoader.on("plugin:unloaded", this.handlePluginUnloaded);
this.options.pluginLoader.on("plugin:reloaded", this.handlePluginReloaded);
// Build initial caches
this.invalidateToolsCache();
this.invalidateRoutesCache();
}
/**
@@ -84,13 +126,21 @@ export class PluginRunner {
async shutdown(): Promise<void> {
executorLog.log("Shutting down PluginRunner...");
// Unsubscribe from store events
// Unsubscribe from task store events
this.unsubscribeFromStoreEvents();
// Unsubscribe from plugin store events
this.options.pluginStore.off("plugin:enabled", this.handlePluginEnabled);
this.options.pluginStore.off("plugin:disabled", this.handlePluginDisabled);
this.options.pluginStore.off("plugin:unregistered", this.handlePluginUnregistered);
this.options.pluginStore.off("plugin:stateChanged", this.handlePluginStateChanged);
this.options.pluginStore.off("plugin:updated", this.handlePluginUpdated);
// Unsubscribe from plugin loader events
this.options.pluginLoader.off("plugin:loaded", this.handlePluginLoaded);
this.options.pluginLoader.off("plugin:unloaded", this.handlePluginUnloaded);
this.options.pluginLoader.off("plugin:reloaded", this.handlePluginReloaded);
// Stop all plugins
await this.options.pluginLoader.stopAllPlugins();
@@ -123,9 +173,16 @@ export class PluginRunner {
/**
* Get all plugin routes with their plugin IDs.
* Routes are cached and only rebuilt when plugin state changes.
*/
getPluginRoutes(): Array<{ pluginId: string; route: PluginRouteDefinition }> {
return this.options.pluginLoader.getPluginRoutes();
if (!this.cachedRoutes || this.cachedRoutes.version !== this.routesCacheVersion) {
this.cachedRoutes = {
routes: this.options.pluginLoader.getPluginRoutes(),
version: this.routesCacheVersion,
};
}
return this.cachedRoutes.routes;
}
/**
@@ -142,6 +199,104 @@ export class PluginRunner {
return this.options.pluginStore;
}
/**
* Reload a plugin: stop the old instance, re-import, and start the new one.
* This invalidates the tools and routes caches.
*/
async reloadPlugin(pluginId: string): Promise<void> {
executorLog.log(`Reloading plugin: ${pluginId}`);
await this.options.pluginLoader.reloadPlugin(pluginId);
this.invalidateToolsCache();
this.invalidateRoutesCache();
executorLog.log(`Plugin ${pluginId} reloaded`);
}
// ── Event Handlers for Hot-Load/Unload ─────────────────────────
/**
* Handle plugin:enabled event - automatically load the plugin.
*/
private async onPluginEnabled(plugin: import("@fusion/core").PluginInstallation): Promise<void> {
try {
executorLog.log(`Auto-loading enabled plugin: ${plugin.id}`);
await this.options.pluginLoader.loadPlugin(plugin.id);
this.invalidateToolsCache();
this.invalidateRoutesCache();
} catch (err) {
this.log.error(`Failed to auto-load plugin ${plugin.id}:`, err);
// Don't rethrow - error isolation
}
}
/**
* Handle plugin:disabled event - automatically stop the plugin.
*/
private async onPluginDisabled(plugin: import("@fusion/core").PluginInstallation): Promise<void> {
try {
executorLog.log(`Auto-stopping disabled plugin: ${plugin.id}`);
await this.options.pluginLoader.stopPlugin(plugin.id);
this.invalidateToolsCache();
this.invalidateRoutesCache();
} catch (err) {
this.log.error(`Failed to auto-stop plugin ${plugin.id}:`, err);
// Don't rethrow - error isolation
}
}
/**
* Handle plugin:unregistered event - ensure plugin is stopped.
*/
private async onPluginUnregistered(plugin: import("@fusion/core").PluginInstallation): Promise<void> {
try {
executorLog.log(`Stopping unregistered plugin: ${plugin.id}`);
await this.options.pluginLoader.stopPlugin(plugin.id);
this.invalidateToolsCache();
this.invalidateRoutesCache();
} catch {
// Ignore - plugin might not be loaded
}
}
/**
* Handle plugin state changes - invalidate caches.
*/
private onPluginStateChanged(): void {
this.invalidateToolsCache();
this.invalidateRoutesCache();
}
/**
* Handle plugin updates - invalidate caches.
*/
private onPluginUpdated(): void {
this.invalidateToolsCache();
this.invalidateRoutesCache();
}
/**
* Handle plugin:loaded event from loader - invalidate caches.
*/
private onPluginLoaded(_event: { pluginId: string }): void {
this.invalidateToolsCache();
this.invalidateRoutesCache();
}
/**
* Handle plugin:unloaded event from loader - invalidate caches.
*/
private onPluginUnloaded(_event: { pluginId: string }): void {
this.invalidateToolsCache();
this.invalidateRoutesCache();
}
/**
* Handle plugin:reloaded event from loader - invalidate caches.
*/
private onPluginReloaded(_event: { pluginId: string }): void {
this.invalidateToolsCache();
this.invalidateRoutesCache();
}
// ── Tool Conversion ───────────────────────────────────────────────
/**
@@ -291,6 +446,14 @@ export class PluginRunner {
this.log.log(`Tools cache invalidated (version: ${this.toolsCacheVersion})`);
}
/**
* Invalidate the routes cache, forcing rebuild on next access.
*/
private invalidateRoutesCache(): void {
this.routesCacheVersion++;
this.log.log(`Routes cache invalidated (version: ${this.routesCacheVersion})`);
}
// ── Store Event Subscriptions ────────────────────────────────────
/**
@@ -349,19 +512,8 @@ export class PluginRunner {
// ── Event Handlers for Cache ────────────────────────────────────
/**
* Handler for plugin state changes.
*/
private handlePluginStateChanged = (): void => {
this.invalidateToolsCache();
};
/**
* Handler for plugin updates.
*/
private handlePluginUpdated = (): void => {
this.invalidateToolsCache();
};
// Note: handlePluginStateChanged and handlePluginUpdated are defined
// in the hot-load event handlers section above
// ── Utilities ────────────────────────────────────────────────────