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