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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-07 08:40:20 -07:00
parent 0f2bfa546c
commit d54ab80395
5 changed files with 286 additions and 4 deletions

View File

@@ -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.

View File

@@ -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();
});
});

View File

@@ -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() }),
);
});
});

View File

@@ -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<void> };
type PluginDatabaseLike = { runPluginSchemaInits(hooks: Array<{ pluginId: string; hook: unknown }>): Promise<void> };
type TaskStoreLike = {
init(): Promise<void>;
watch(): Promise<void>;
close(): void;
getPluginStore(): PluginStoreLike;
getDatabase(): PluginDatabaseLike;
};
type RuntimeCleanup = () => Promise<void> | void;
@@ -87,7 +100,7 @@ async function createStoreDefault(rootDir: string): Promise<TaskStoreLike> {
}
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<typeof PluginLoader> | 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),
});

View File

@@ -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<void> };
type PluginDatabaseLike = { runPluginSchemaInits(hooks: Array<{ pluginId: string; hook: unknown }>): Promise<void> };
type TaskStoreLike = {
init(): Promise<void>;
watch(): Promise<void>;
close(): void;
getPluginStore(): PluginStoreLike;
getDatabase(): PluginDatabaseLike;
};
type RuntimeCleanup = () => Promise<void> | 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<typeof PluginLoader> | 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);