From d54ab8039520e02ded7f966ebc7aecc6e8af34ba Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 7 Jul 2026 08:40:20 -0700 Subject: [PATCH] FN-7623: wire pluginStore/pluginLoader into desktop embedded dashboard server Fixes the desktop app's plugin subsystem, which was never wired into createServer(), breaking Settings > Plugins Browse registry and plugin install. - local-runtime.ts: construct a PluginStore + PluginLoader (mirroring the CLI dashboard command), load enabled plugins, run plugin schema-init hooks, and pass pluginStore/pluginLoader/pluginRunner into createServer() - local-server.ts: apply the same wiring to the legacy desktop local server path for consistency - Both paths fail soft: a broken plugin subsystem (e.g. corrupt manifest) is logged/traced but no longer blocks embedded dashboard startup - Extend local-runtime.test.ts and local-server.test.ts to cover plugin wiring and the fail-soft path - Add changeset (patch) documenting the fix Files changed: .changeset/fn-7623-desktop-plugin-wiring.md | 7 ++ .../desktop/src/__tests__/local-runtime.test.ts | 123 ++++++++++++++++++++- .../desktop/src/__tests__/local-server.test.ts | 69 +++++++++++- packages/desktop/src/local-runtime.ts | 50 ++++++++- packages/desktop/src/local-server.ts | 41 ++++++- 5 files changed, 286 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-7623 Fusion-Task-Lineage: c6f291fb-e6aa-4ac1-a3f3-4189fc831c60 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7623-desktop-plugin-wiring.md | 7 + .../src/__tests__/local-runtime.test.ts | 123 +++++++++++++++++- .../src/__tests__/local-server.test.ts | 69 +++++++++- packages/desktop/src/local-runtime.ts | 50 ++++++- packages/desktop/src/local-server.ts | 41 +++++- 5 files changed, 286 insertions(+), 4 deletions(-) create mode 100644 .changeset/fn-7623-desktop-plugin-wiring.md diff --git a/.changeset/fn-7623-desktop-plugin-wiring.md b/.changeset/fn-7623-desktop-plugin-wiring.md new file mode 100644 index 0000000000..5fecdc2e29 --- /dev/null +++ b/.changeset/fn-7623-desktop-plugin-wiring.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix desktop app plugin install and Browse registry (plugin subsystem now wired into the embedded server). +category: fix +dev: local-runtime.ts / local-server.ts now build a PluginStore + PluginLoader and pass pluginStore/pluginLoader/pluginRunner into createServer, mirroring the CLI dashboard command (FN-7623, issue #1937). Plugin subsystem init is fail-soft — a broken plugin (e.g. corrupt manifest) logs/traces via strace(...) but no longer blocks embedded dashboard startup. diff --git a/packages/desktop/src/__tests__/local-runtime.test.ts b/packages/desktop/src/__tests__/local-runtime.test.ts index af1541a28d..f7b06e862a 100644 --- a/packages/desktop/src/__tests__/local-runtime.test.ts +++ b/packages/desktop/src/__tests__/local-runtime.test.ts @@ -47,6 +47,19 @@ class FakeServer { * `createDashboardServer` in LocalRuntimeManagerOptions). Mirrors local-server.test.ts's pattern. */ const engineMocks = vi.hoisted(() => { + // FN-7623: pluginStore/pluginLoader mocks proving createDashboardServerDefault wires the plugin + // subsystem into createServer (fixes desktop's "Plugin install mode is not supported" and Browse + // registry "Plugin \"registry\" not found" symptoms). + const pluginStoreInstance = { init: vi.fn(async () => undefined) }; + const pluginLoaderInstance = { + loadAllPlugins: vi.fn(async () => ({ loaded: 2, errors: 0 })), + getPluginSchemaInitHooks: vi.fn(() => []), + }; + const runPluginSchemaInits = vi.fn(async () => undefined); + const PluginLoader = vi.fn(function () { + return pluginLoaderInstance; + }); + const centralCore = { init: vi.fn(async () => undefined), close: vi.fn(async () => undefined), @@ -76,14 +89,18 @@ const engineMocks = vi.hoisted(() => { centralCore, engineManager, CentralCore, + PluginLoader, ProjectEngineManager, seedDashboardProviders, seedDashboardProvidersDispose, createServer, + pluginStoreInstance, + pluginLoaderInstance, + runPluginSchemaInits, }; }); -vi.mock("@fusion/core", () => ({ CentralCore: engineMocks.CentralCore })); +vi.mock("@fusion/core", () => ({ CentralCore: engineMocks.CentralCore, PluginLoader: engineMocks.PluginLoader })); vi.mock("@fusion/dashboard", () => ({ createServer: engineMocks.createServer })); vi.mock("@fusion/engine", () => ({ ProjectEngineManager: engineMocks.ProjectEngineManager, @@ -97,6 +114,8 @@ describe("LocalRuntimeManager", () => { init: vi.fn(async () => undefined), watch: vi.fn(async () => undefined), close: vi.fn(), + getPluginStore: vi.fn(() => engineMocks.pluginStoreInstance), + getDatabase: vi.fn(() => ({ runPluginSchemaInits: engineMocks.runPluginSchemaInits })), }; beforeEach(() => { @@ -442,4 +461,106 @@ describe("LocalRuntimeManager", () => { await manager.stopLocal(); expect(engineMocks.seedDashboardProvidersDispose).toHaveBeenCalledTimes(1); }); + + /* + * FN-7623 symptom verification: before this fix, createDashboardServerDefault called createServer + * WITHOUT pluginStore/pluginLoader, so desktop's Browse-registry sub-router never mounted ("Plugin + * \"registry\" not found") and plugin install threw "Plugin install mode is not supported: plugin + * loader not available". Assert the fix in the engine-less (zero-projects) startup state — the + * plugin subsystem must wire in regardless of whether a primary engine resolved. + */ + it("wires PluginStore + PluginLoader into createServer when engine-less (zero projects) (FN-7623)", async () => { + const { LocalRuntimeManager } = await import("../local-runtime.ts"); + const server = new FakeServer(4545); + engineMocks.createServer.mockReturnValueOnce({ + listen: vi.fn(() => { + setTimeout(() => server.emit("listening"), 0); + return server as unknown as Server; + }), + }); + + const manager = new LocalRuntimeManager({ + rootDir: "/repo", + createStore: async () => store, + }); + + await manager.startLocal(); + + expect(store.getPluginStore).toHaveBeenCalledTimes(1); + expect(engineMocks.pluginStoreInstance.init).toHaveBeenCalledTimes(1); + expect(engineMocks.PluginLoader).toHaveBeenCalledWith( + expect.objectContaining({ pluginStore: engineMocks.pluginStoreInstance, taskStore: expect.anything() }), + ); + expect(engineMocks.pluginLoaderInstance.loadAllPlugins).toHaveBeenCalledTimes(1); + expect(engineMocks.createServer).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + pluginStore: engineMocks.pluginStoreInstance, + pluginLoader: engineMocks.pluginLoaderInstance, + pluginRunner: engineMocks.pluginLoaderInstance, + }), + ); + + await manager.stopLocal(); + }); + + it("wires PluginStore + PluginLoader into createServer when a project engine resolved (projects-present) (FN-7623)", async () => { + const { LocalRuntimeManager } = await import("../local-runtime.ts"); + engineMocks.centralCore.listProjects.mockResolvedValueOnce([ + { id: "project-1", name: "Repo", path: "/repo", status: "active" }, + ]); + const server = new FakeServer(4545); + engineMocks.createServer.mockReturnValueOnce({ + listen: vi.fn(() => { + setTimeout(() => server.emit("listening"), 0); + return server as unknown as Server; + }), + }); + + const manager = new LocalRuntimeManager({ + rootDir: "/repo", + createStore: async () => store, + }); + + await manager.startLocal(); + + expect(engineMocks.createServer).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + engine: expect.anything(), + pluginStore: engineMocks.pluginStoreInstance, + pluginLoader: engineMocks.pluginLoaderInstance, + pluginRunner: engineMocks.pluginLoaderInstance, + }), + ); + + await manager.stopLocal(); + }); + + it("boots the dashboard without plugin wiring when the plugin subsystem fails to init (fail-soft) (FN-7623)", async () => { + const { LocalRuntimeManager } = await import("../local-runtime.ts"); + engineMocks.pluginStoreInstance.init.mockRejectedValueOnce(new Error("plugin db locked")); + const server = new FakeServer(4545); + engineMocks.createServer.mockReturnValueOnce({ + listen: vi.fn(() => { + setTimeout(() => server.emit("listening"), 0); + return server as unknown as Server; + }), + }); + + const manager = new LocalRuntimeManager({ + rootDir: "/repo", + createStore: async () => store, + }); + + const status = await manager.startLocal(); + + expect(status).toMatchObject({ source: "embedded-local", state: "running", port: 4545 }); + expect(engineMocks.createServer).toHaveBeenCalledWith( + expect.anything(), + expect.not.objectContaining({ pluginStore: expect.anything() }), + ); + + await manager.stopLocal(); + }); }); diff --git a/packages/desktop/src/__tests__/local-server.test.ts b/packages/desktop/src/__tests__/local-server.test.ts index 6e3434ee63..d1556cb065 100644 --- a/packages/desktop/src/__tests__/local-server.test.ts +++ b/packages/desktop/src/__tests__/local-server.test.ts @@ -30,10 +30,28 @@ const mocks = vi.hoisted(() => { } } + // FN-7623: pluginStore/pluginLoader mocks proving local-server.ts wires the plugin subsystem + // into createServer (the fix for "Plugin install mode is not supported" and the Browse-registry + // "Plugin \"registry\" not found" symptoms). + const pluginStoreInstance = { + init: vi.fn(async () => undefined), + }; + const pluginLoaderInstance = { + loadAllPlugins: vi.fn(async () => ({ loaded: 2, errors: 0 })), + getPluginSchemaInitHooks: vi.fn(() => []), + }; + const runPluginSchemaInits = vi.fn(async () => undefined); + const database = { runPluginSchemaInits }; + const PluginLoader = vi.fn(function () { + return pluginLoaderInstance; + }); + const store = { init: vi.fn(async () => undefined), watch: vi.fn(async () => undefined), close: vi.fn(), + getPluginStore: vi.fn(() => pluginStoreInstance), + getDatabase: vi.fn(() => database), }; const centralCore = { init: vi.fn(async () => undefined), @@ -61,6 +79,8 @@ const mocks = vi.hoisted(() => { init = store.init; watch = store.watch; close = store.close; + getPluginStore = store.getPluginStore; + getDatabase = store.getDatabase; } const server = Object.assign(new SimpleEmitter(), { @@ -94,6 +114,7 @@ const mocks = vi.hoisted(() => { return { TaskStore, CentralCore, + PluginLoader, ProjectEngineManager, createServer, store, @@ -101,12 +122,15 @@ const mocks = vi.hoisted(() => { centralCore, engineManager, engine, + pluginStoreInstance, + pluginLoaderInstance, + runPluginSchemaInits, seedDashboardProviders, seedDashboardProvidersDispose, }; }); -vi.mock("@fusion/core", () => ({ TaskStore: mocks.TaskStore, CentralCore: mocks.CentralCore })); +vi.mock("@fusion/core", () => ({ TaskStore: mocks.TaskStore, CentralCore: mocks.CentralCore, PluginLoader: mocks.PluginLoader })); vi.mock("@fusion/dashboard", () => ({ createServer: mocks.createServer })); vi.mock("@fusion/engine", () => ({ ProjectEngineManager: mocks.ProjectEngineManager, @@ -245,4 +269,47 @@ describe("DesktopLocalServerManager", () => { await manager.stop(); expect(mocks.seedDashboardProvidersDispose).toHaveBeenCalledTimes(1); }); + + /* + * FN-7623 symptom verification: before this fix, DesktopLocalServerManager.start() called + * createServer WITHOUT pluginStore/pluginLoader, so the registry sub-router never mounted + * ("Plugin \"registry\" not found" on Browse registry) and install threw "Plugin install mode + * is not supported: plugin loader not available". Assert the fix: createServer now receives + * pluginStore, pluginLoader, and pluginRunner (aliased to the same PluginLoader instance). + */ + it("wires PluginStore + PluginLoader into createServer (FN-7623)", async () => { + const { DesktopLocalServerManager } = await import("../local-server.ts"); + const manager = new DesktopLocalServerManager("/repo"); + + await manager.start(); + + expect(mocks.store.getPluginStore).toHaveBeenCalledTimes(1); + expect(mocks.pluginStoreInstance.init).toHaveBeenCalledTimes(1); + expect(mocks.PluginLoader).toHaveBeenCalledWith( + expect.objectContaining({ pluginStore: mocks.pluginStoreInstance, taskStore: expect.anything() }), + ); + expect(mocks.pluginLoaderInstance.loadAllPlugins).toHaveBeenCalledTimes(1); + expect(mocks.createServer).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + pluginStore: mocks.pluginStoreInstance, + pluginLoader: mocks.pluginLoaderInstance, + pluginRunner: mocks.pluginLoaderInstance, + }), + ); + }); + + it("boots the dashboard without plugin wiring when the plugin subsystem fails to init (fail-soft)", async () => { + mocks.pluginStoreInstance.init.mockRejectedValueOnce(new Error("plugin db locked")); + const { DesktopLocalServerManager } = await import("../local-server.ts"); + const manager = new DesktopLocalServerManager("/repo"); + + const runtime = await manager.start(); + + expect(runtime.port).toBe(4545); + expect(mocks.createServer).toHaveBeenCalledWith( + expect.anything(), + expect.not.objectContaining({ pluginStore: expect.anything() }), + ); + }); }); diff --git a/packages/desktop/src/local-runtime.ts b/packages/desktop/src/local-runtime.ts index a12165d8df..c2bc54409e 100644 --- a/packages/desktop/src/local-runtime.ts +++ b/packages/desktop/src/local-runtime.ts @@ -36,10 +36,23 @@ export interface DesktopRuntimeStatus { error?: string; } +/* + * FNXC:DesktopRuntime 2026-07-07-12:00: + * FN-7623: the embedded desktop server must wire a PluginStore + PluginLoader into createServer + * (as the CLI dashboard command does) or the Settings -> Plugins Browse-registry sub-router never + * mounts ("Plugin \"registry\" not found") and plugin install throws "Plugin install mode is not + * supported: plugin loader not available". getPluginStore()/getDatabase() are the two TaskStore + * members this wiring needs beyond the pre-existing init/watch/close surface. + */ +type PluginStoreLike = { init(): Promise }; +type PluginDatabaseLike = { runPluginSchemaInits(hooks: Array<{ pluginId: string; hook: unknown }>): Promise }; + type TaskStoreLike = { init(): Promise; watch(): Promise; close(): void; + getPluginStore(): PluginStoreLike; + getDatabase(): PluginDatabaseLike; }; type RuntimeCleanup = () => Promise | void; @@ -87,7 +100,7 @@ async function createStoreDefault(rootDir: string): Promise { } async function createDashboardServerDefault(store: TaskStoreLike, rootDir: string): Promise<{ server: Server; cleanup: RuntimeCleanup }> { - const { CentralCore } = await import("@fusion/core"); + const { CentralCore, PluginLoader } = await import("@fusion/core"); const { createServer } = await import("@fusion/dashboard"); const { ProjectEngineManager, createFusionAuthStorage, createFusionModelRegistry, seedDashboardProviders } = await import("@fusion/engine"); @@ -149,6 +162,40 @@ async function createDashboardServerDefault(store: TaskStoreLike, rootDir: strin log: (scope, message) => strace(`[${scope}] ${message}`), }); providerSeeding.dispose = dispose; + + /* + * FNXC:DesktopRuntime 2026-07-07-12:00: + * FN-7623: mirror the CLI dashboard command's plugin wiring (packages/cli/src/commands/dashboard.ts) + * — construct the store's PluginStore, build a PluginLoader, load enabled plugins, and run schema-init + * hooks — so the desktop embedded server's registry sub-router mounts (GET /api/plugins/registry) and + * POST /api/plugins install mode works. Bundled-plugin auto-install (Hermes/OpenClaw/Paperclip/Dependency + * Graph) depends on packages/cli/src/plugins/bundled-plugin-install.ts, which is CLI-only and out of + * scope for desktop (desktop must not depend on the CLI package) — see FN-7623 scope note. Failures here + * must not crash embedded startup: the dashboard still needs to boot even if the plugin subsystem can't + * come up (e.g. a corrupt plugin manifest), so this is wrapped and traced rather than left to throw. + */ + let pluginStore: PluginStoreLike | undefined; + let pluginLoader: InstanceType | undefined; + try { + strace("createDashboardServer: pluginStore.init"); + pluginStore = store.getPluginStore(); + await pluginStore.init(); + pluginLoader = new PluginLoader({ pluginStore: pluginStore as never, taskStore: store as never }); + strace("createDashboardServer: pluginLoader.loadAllPlugins"); + const { loaded, errors } = await pluginLoader.loadAllPlugins(); + strace(`createDashboardServer: plugins loaded=${loaded} errors=${errors}`); + const schemaHooks = pluginLoader.getPluginSchemaInitHooks(); + if (schemaHooks.length > 0) { + await store.getDatabase().runPluginSchemaInits(schemaHooks); + } + } catch (error) { + strace( + `createDashboardServer: plugin subsystem init FAILED (non-fatal, dashboard still boots) — ${error instanceof Error ? error.stack : String(error)}`, + ); + pluginStore = undefined; + pluginLoader = undefined; + } + strace("createDashboardServer: createServer"); const app = createServer(store as never, { ...(primaryEngine ? { engine: primaryEngine } : {}), @@ -156,6 +203,7 @@ async function createDashboardServerDefault(store: TaskStoreLike, rootDir: strin centralCore, authStorage: wrappedAuthStorage, modelRegistry, + ...(pluginStore && pluginLoader ? { pluginStore: pluginStore as never, pluginLoader, pluginRunner: pluginLoader } : {}), onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId), }); diff --git a/packages/desktop/src/local-server.ts b/packages/desktop/src/local-server.ts index e4c978705a..f3f9c7f556 100644 --- a/packages/desktop/src/local-server.ts +++ b/packages/desktop/src/local-server.ts @@ -4,10 +4,23 @@ import type { Server } from "node:http"; import { resolveDesktopRuntimePrimaryProject } from "./engine-runtime.js"; +/* + * FNXC:DesktopRuntime 2026-07-07-12:00: + * FN-7623: this legacy desktop local server path had the same missing plugin-subsystem wiring as + * local-runtime.ts — createServer() never received pluginStore/pluginLoader, so Settings -> Plugins + * Browse registry ("Plugin \"registry\" not found") and plugin install ("Plugin install mode is not + * supported: plugin loader not available") were both dead in this path too. Keep both desktop server + * paths consistent (see local-runtime.ts's matching comment). + */ +type PluginStoreLike = { init(): Promise }; +type PluginDatabaseLike = { runPluginSchemaInits(hooks: Array<{ pluginId: string; hook: unknown }>): Promise }; + type TaskStoreLike = { init(): Promise; watch(): Promise; close(): void; + getPluginStore(): PluginStoreLike; + getDatabase(): PluginDatabaseLike; }; type RuntimeCleanup = () => Promise | void; @@ -53,7 +66,7 @@ export class DesktopLocalServerManager { try { const { TaskStore } = await import("@fusion/core"); - const { CentralCore } = await import("@fusion/core"); + const { CentralCore, PluginLoader } = await import("@fusion/core"); const { createServer } = await import("@fusion/dashboard"); const { ProjectEngineManager, createFusionAuthStorage, createFusionModelRegistry, seedDashboardProviders } = await import("@fusion/engine"); store = new TaskStore(this.rootDir) as TaskStoreLike; @@ -93,12 +106,38 @@ export class DesktopLocalServerManager { modelRegistry, }); providerSeeding.dispose = dispose; + + /* + * FNXC:DesktopRuntime 2026-07-07-12:00: + * FN-7623: mirror the CLI dashboard command's plugin wiring — construct the store's PluginStore, + * build a PluginLoader, load enabled plugins, and run schema-init hooks — so this legacy path's + * registry sub-router mounts and install works too. Fail soft: a broken plugin subsystem must not + * prevent the embedded dashboard from booting. + */ + let pluginStore: PluginStoreLike | undefined; + let pluginLoader: InstanceType | undefined; + try { + pluginStore = store.getPluginStore(); + await pluginStore.init(); + pluginLoader = new PluginLoader({ pluginStore: pluginStore as never, taskStore: store as never }); + await pluginLoader.loadAllPlugins(); + const schemaHooks = pluginLoader.getPluginSchemaInitHooks(); + if (schemaHooks.length > 0) { + await store.getDatabase().runPluginSchemaInits(schemaHooks); + } + } catch { + // Plugin subsystem failures must not block embedded dashboard startup (FN-7623). + pluginStore = undefined; + pluginLoader = undefined; + } + const app = createServer(store as never, { ...(primaryEngine ? { engine: primaryEngine } : {}), engineManager, centralCore, authStorage: wrappedAuthStorage, modelRegistry, + ...(pluginStore && pluginLoader ? { pluginStore: pluginStore as never, pluginLoader, pluginRunner: pluginLoader } : {}), onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId), }); server = app.listen(0);