From 34a00a7d32966eba959a7979703cf69a700e0800 Mon Sep 17 00:00:00 2001 From: Paperclip Bot Date: Thu, 4 Jun 2026 04:32:48 +0000 Subject: [PATCH] Fix plugin reload lifecycle wiring --- .../plugin-lifecycle-restart.test.ts | 31 ++++++++++++++ server/src/app.ts | 8 +++- server/src/services/plugin-lifecycle.ts | 42 ++++++++++++------- 3 files changed, 63 insertions(+), 18 deletions(-) diff --git a/server/src/__tests__/plugin-lifecycle-restart.test.ts b/server/src/__tests__/plugin-lifecycle-restart.test.ts index 04026e6e..03da11ae 100644 --- a/server/src/__tests__/plugin-lifecycle-restart.test.ts +++ b/server/src/__tests__/plugin-lifecycle-restart.test.ts @@ -126,4 +126,35 @@ describe("pluginLifecycleManager.restartWorker", () => { expect(started).toHaveBeenCalledTimes(1); expect(started).toHaveBeenCalledWith({ pluginId: "plugin-1", pluginKey: "example.plugin" }); }); + + it("uses a late-bound runtime loader after bootstrap wiring completes", async () => { + mockRegistry.getById.mockResolvedValue(pluginRecord); + mockRegistry.updateStatus.mockResolvedValue(pluginRecord); + + const { handle, workerManager } = makeWorkerManagerStub(); + let runtimeLoader: Partial | undefined; + const lifecycle = pluginLifecycleManager( + {} as never, + { + resolveLoader: () => runtimeLoader as PluginLoader | undefined, + workerManager, + }, + ); + + runtimeLoader = { + hasRuntimeServices: vi.fn().mockReturnValue(true) as PluginLoader["hasRuntimeServices"], + loadSingle: vi.fn().mockResolvedValue({ + success: true, + plugin: pluginRecord, + registered: { worker: true, eventSubscriptions: 0, jobs: 0, webhooks: 0, tools: 0 }, + }) as PluginLoader["loadSingle"], + unloadSingle: vi.fn().mockResolvedValue(undefined) as PluginLoader["unloadSingle"], + }; + + await lifecycle.restartWorker("plugin-1"); + + expect(runtimeLoader.unloadSingle).toHaveBeenCalledWith("plugin-1", "example.plugin"); + expect(runtimeLoader.loadSingle).toHaveBeenCalledWith("plugin-1"); + expect(handle.restart).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/app.ts b/server/src/app.ts index 57f5fae5..74ceabfe 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -242,7 +242,11 @@ export async function createApp( const eventBus = createPluginEventBus(); setPluginEventBus(eventBus); const jobStore = pluginJobStore(db); - const lifecycle = pluginLifecycleManager(db, { workerManager }); + let loader: ReturnType; + const lifecycle = pluginLifecycleManager(db, { + workerManager, + resolveLoader: () => loader, + }); const scheduler = createPluginJobScheduler({ db, jobStore, @@ -261,7 +265,7 @@ export async function createApp( }); const hostServiceCleanup = createPluginHostServiceCleanup(lifecycle, hostServicesDisposers); let viteHtmlRenderer: ReturnType | null = null; - const loader = pluginLoader( + loader = pluginLoader( db, { localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR, diff --git a/server/src/services/plugin-lifecycle.ts b/server/src/services/plugin-lifecycle.ts index 24fa13e7..ae9de136 100644 --- a/server/src/services/plugin-lifecycle.ts +++ b/server/src/services/plugin-lifecycle.ts @@ -267,6 +267,8 @@ export interface PluginLifecycleManager { export interface PluginLifecycleManagerOptions { /** Plugin loader instance. Falls back to the default if omitted. */ loader?: PluginLoader; + /** Resolve the loader lazily for bootstrap paths with circular wiring. */ + resolveLoader?: () => PluginLoader | undefined; /** * Worker process manager. When provided, lifecycle transitions that bring @@ -308,6 +310,7 @@ export function pluginLifecycleManager( // Support the legacy signature: pluginLifecycleManager(db, loader) // as well as the new options object form. let loaderArg: PluginLoader | undefined; + let resolveLoader: (() => PluginLoader | undefined) | undefined; let workerManager: PluginWorkerManager | undefined; if (options && typeof options === "object" && "discoverAll" in options) { @@ -316,11 +319,12 @@ export function pluginLifecycleManager( } else if (options && typeof options === "object") { const opts = options as PluginLifecycleManagerOptions; loaderArg = opts.loader; + resolveLoader = opts.resolveLoader; workerManager = opts.workerManager; } const registry = pluginRegistryService(db); - const pluginLoaderInstance = loaderArg ?? pluginLoader(db); + const fallbackLoader = loaderArg ?? pluginLoader(db); const emitter = new EventEmitter(); emitter.setMaxListeners(100); // plugins may have many listeners; 100 is a safe upper bound @@ -386,6 +390,10 @@ export function pluginLifecycleManager( emitter.emit(event, payload); } + function currentLoader(): PluginLoader { + return resolveLoader?.() ?? loaderArg ?? fallbackLoader; + } + // ----------------------------------------------------------------------- // Worker management helpers // ----------------------------------------------------------------------- @@ -415,14 +423,15 @@ export function pluginLifecycleManager( } async function activateReadyPlugin(pluginId: string): Promise { + const loader = currentLoader(); const supportsRuntimeActivation = - typeof pluginLoaderInstance.hasRuntimeServices === "function" - && typeof pluginLoaderInstance.loadSingle === "function"; - if (!supportsRuntimeActivation || !pluginLoaderInstance.hasRuntimeServices()) { + typeof loader.hasRuntimeServices === "function" + && typeof loader.loadSingle === "function"; + if (!supportsRuntimeActivation || !loader.hasRuntimeServices()) { return; } - const loadResult = await pluginLoaderInstance.loadSingle(pluginId); + const loadResult = await loader.loadSingle(pluginId); if (!loadResult.success) { throw new Error( loadResult.error @@ -435,12 +444,13 @@ export function pluginLifecycleManager( pluginId: string, pluginKey: string, ): Promise { + const loader = currentLoader(); const supportsRuntimeDeactivation = - typeof pluginLoaderInstance.hasRuntimeServices === "function" - && typeof pluginLoaderInstance.unloadSingle === "function"; + typeof loader.hasRuntimeServices === "function" + && typeof loader.unloadSingle === "function"; - if (supportsRuntimeDeactivation && pluginLoaderInstance.hasRuntimeServices()) { - await pluginLoaderInstance.unloadSingle(pluginId, pluginKey); + if (supportsRuntimeDeactivation && loader.hasRuntimeServices()) { + await loader.unloadSingle(pluginId, pluginKey); return; } @@ -541,7 +551,7 @@ export function pluginLifecycleManager( // If already uninstalled and removeData, hard-delete if (plugin.status === "uninstalled") { if (removeData) { - await pluginLoaderInstance.cleanupInstallArtifacts(plugin); + await currentLoader().cleanupInstallArtifacts(plugin); const deleted = await registry.uninstall(pluginId, true); log.info( { pluginId, pluginKey: plugin.pluginKey }, @@ -561,7 +571,7 @@ export function pluginLifecycleManager( } await deactivatePluginRuntime(pluginId, plugin.pluginKey); - await pluginLoaderInstance.cleanupInstallArtifacts(plugin); + await currentLoader().cleanupInstallArtifacts(plugin); // Perform the uninstall via registry (handles soft/hard delete) const result = await registry.uninstall(pluginId, removeData); @@ -656,7 +666,7 @@ export function pluginLifecycleManager( // 1. Download and validate new package via loader const { oldManifest, newManifest, discovered } = - await pluginLoaderInstance.upgradePlugin(pluginId, { version }); + await currentLoader().upgradePlugin(pluginId, { version }); log.info( { @@ -777,10 +787,10 @@ export function pluginLifecycleManager( } const supportsRuntimeActivation = - typeof pluginLoaderInstance.hasRuntimeServices === "function" - && typeof pluginLoaderInstance.loadSingle === "function" - && typeof pluginLoaderInstance.unloadSingle === "function" - && pluginLoaderInstance.hasRuntimeServices(); + typeof currentLoader().hasRuntimeServices === "function" + && typeof currentLoader().loadSingle === "function" + && typeof currentLoader().unloadSingle === "function" + && currentLoader().hasRuntimeServices(); if (supportsRuntimeActivation) { log.info(