From 961edf2145b379b444e25c263a3e7e1bfa9101f2 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 22 Jul 2026 11:04:19 -0700 Subject: [PATCH] fix(dashboard): mount plugin routes without engine Keep plugin-defined APIs reachable in UI-only dashboards by sourcing routes from the plugin loader when no engine runner exists. --- packages/cli/src/commands/dashboard.ts | 3 + .../__tests__/plugin-routes-wiring.test.ts | 69 ++++++ packages/dashboard/src/plugin-routes.ts | 220 ++++++++++-------- 3 files changed, 193 insertions(+), 99 deletions(-) diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 78d2371994..ea7cf32e89 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -2535,6 +2535,9 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: /* FNXC:GrokCliRouting 2026-07-15-10:17: UI-only mode has no ProjectEngine PluginRunner. Pass pluginRunner undefined (not pluginLoader) so Grok auto-derive surfaces dual-remediation instead of getRuntimeById TypeError. Plugin management routes that need reloadPlugin degrade via optional chaining on options.pluginRunner. + + FNXC:PluginRoutes 2026-07-22-09:55: + createPluginRouter still mounts plugin-defined HTTP routes from pluginLoader when pluginRunner is undefined. Do not pass pluginLoader as pluginRunner here — that reintroduces the Grok getRuntimeById TypeError — and do not skip pluginLoadingPromise; CE /sessions and /artifacts depend on the loaded plugin route table. */ app = createServer(store, { onMerge: uiOnlyOnMerge, diff --git a/packages/dashboard/src/__tests__/plugin-routes-wiring.test.ts b/packages/dashboard/src/__tests__/plugin-routes-wiring.test.ts index 2d8042a234..31fa044c61 100644 --- a/packages/dashboard/src/__tests__/plugin-routes-wiring.test.ts +++ b/packages/dashboard/src/__tests__/plugin-routes-wiring.test.ts @@ -116,4 +116,73 @@ describe("createPluginRouter wiring under /api/plugins", () => { expect(taskStore.listTasks).toHaveBeenCalled(); expect(handlers.taskStoreHandler).toHaveBeenCalled(); }); + + /* + FNXC:PluginRoutes 2026-07-22-09:55: + UI-only / --no-engine dashboards pass pluginRunner=undefined (Grok dual-remediation) + while still loading plugins on pluginLoader. Compound Engineering's bundled view then + painted while /sessions and /artifacts hit the catch-all 404 "Not found". Mount routes + from the loader when the runner is absent so CE and other plugin APIs stay reachable. + */ + it("mounts plugin-defined routes from pluginLoader when pluginRunner is undefined", async () => { + const sessionsHandler = vi.fn(async () => ({ sessions: [] })); + const artifactsHandler = vi.fn(async () => ({ groups: [], totalArtifacts: 0, totalErrors: 0 })); + const startHandler = vi.fn(async () => ({ session: { id: "ce-1", status: "launching" } })); + + const pluginStore = { + listPlugins: vi.fn(async () => [{ id: "fusion-plugin-compound-engineering", name: "Compound Engineering", enabled: true }]), + getPlugin: vi.fn(async (id: string) => ({ id, settings: {}, enabled: true, manifest: { id, name: id, version: "0.1.0", description: "" } })), + enablePlugin: vi.fn(), + disablePlugin: vi.fn(), + registerPlugin: vi.fn(), + unregisterPlugin: vi.fn(), + updatePluginSettings: vi.fn(), + updatePluginState: vi.fn(), + } as any; + + const pluginLoader = { + getPlugin: vi.fn((id: string) => (id === "fusion-plugin-compound-engineering" ? { manifest: { id } } : undefined)), + getPluginRoutes: vi.fn(() => [ + { pluginId: "fusion-plugin-compound-engineering", route: { method: "GET", path: "/sessions", handler: sessionsHandler } }, + { pluginId: "fusion-plugin-compound-engineering", route: { method: "GET", path: "/artifacts", handler: artifactsHandler } }, + { pluginId: "fusion-plugin-compound-engineering", route: { method: "POST", path: "/sessions", handler: startHandler } }, + ]), + createRouteContext: vi.fn(async () => ({ + pluginId: "fusion-plugin-compound-engineering", + taskStore: {}, + settings: {}, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + emitEvent: vi.fn(), + })), + loadPlugin: vi.fn(), + stopPlugin: vi.fn(), + } as any; + + const app = express(); + app.use(express.json()); + // No pluginRunner — mirrors UI-only dashboard wiring. + app.use("/api/plugins", createPluginRouter(pluginStore, pluginLoader, undefined, {} as any)); + app.use((_req, res) => res.status(404).json({ error: "Not found" })); + + const sessions = await performGet(app, "/api/plugins/fusion-plugin-compound-engineering/sessions"); + expect(sessions.status).toBe(200); + expect(sessions.body).toEqual({ sessions: [] }); + expect(sessionsHandler).toHaveBeenCalled(); + + const artifacts = await performGet(app, "/api/plugins/fusion-plugin-compound-engineering/artifacts"); + expect(artifacts.status).toBe(200); + expect(artifacts.body).toEqual({ groups: [], totalArtifacts: 0, totalErrors: 0 }); + expect(artifactsHandler).toHaveBeenCalled(); + + const started = await performRequest( + app, + "POST", + "/api/plugins/fusion-plugin-compound-engineering/sessions", + JSON.stringify({ stage: "strategy" }), + { "content-type": "application/json" }, + ); + expect(started.status).toBe(200); + expect(started.body).toEqual({ session: { id: "ce-1", status: "launching" } }); + expect(startHandler).toHaveBeenCalled(); + }); }); diff --git a/packages/dashboard/src/plugin-routes.ts b/packages/dashboard/src/plugin-routes.ts index a66d4ed35d..369d75a18e 100644 --- a/packages/dashboard/src/plugin-routes.ts +++ b/packages/dashboard/src/plugin-routes.ts @@ -693,113 +693,135 @@ export function createPluginRouter( // ── Plugin-Defined Routes ────────────────────────────────────── - // Mount plugin-defined routes - if (pluginRunner) { - const pluginRoutes = pluginRunner.getPluginRoutes(); + /* + FNXC:PluginRoutes 2026-07-22-09:55: + Mount plugin-defined HTTP routes from the dashboard PluginLoader always, and + union in PluginRunner routes when present. Do not gate mounting on pluginRunner: + UI-only / --no-engine (and engine-warmup failure) pass pluginRunner=undefined for + Grok dual-remediation, which previously skipped ALL plugin routes. Compound + Engineering still rendered its bundled dashboard view, so operators saw + "Failed to load sessions/artifacts: Not found" (catch-all 404) on every CE API + call while the stage cards painted normally. Prefer loader entries on key + collisions so handlers resolve against the same pluginLoader instance. + */ + type PluginRouteEntry = { pluginId: string; route: import("@fusion/core").PluginRouteDefinition }; + const pluginRoutesByKey = new Map(); + const addPluginRoutes = (entries: PluginRouteEntry[]) => { + for (const entry of entries) { + const key = `${entry.pluginId}\0${entry.route.method}\0${entry.route.path}`; + pluginRoutesByKey.set(key, entry); + } + }; + if (pluginRunner && typeof pluginRunner.getPluginRoutes === "function") { + addPluginRoutes(pluginRunner.getPluginRoutes()); + } + const loaderRoutes = (pluginLoader as { getPluginRoutes?: () => PluginRouteEntry[] }).getPluginRoutes?.(); + if (loaderRoutes) { + addPluginRoutes(loaderRoutes); + } - for (const { pluginId, route } of pluginRoutes) { - const fullPath = `/${pluginId}${route.path.startsWith("/") ? route.path : `/${route.path}`}`; + for (const { pluginId, route } of pluginRoutesByKey.values()) { + const fullPath = `/${pluginId}${route.path.startsWith("/") ? route.path : `/${route.path}`}`; - const handler = catchHandler(async (req: Request, res: Response) => { - // Get the plugin context - const plugin = pluginLoader.getPlugin(pluginId); - if (!plugin) { - throw notFound(`Plugin "${pluginId}" not loaded`); + const handler = catchHandler(async (req: Request, res: Response) => { + // Get the plugin context + const plugin = pluginLoader.getPlugin(pluginId); + if (!plugin) { + throw notFound(`Plugin "${pluginId}" not loaded`); + } + + // FNXC:BranchGroupProjectScoping 2026-07-14-06:15: return the trimmed id, not the raw padded string. + const queryProjectId = typeof req.query.projectId === "string" ? req.query.projectId.trim() : ""; + const bodyProjectId = + req.body && typeof req.body === "object" && typeof (req.body as { projectId?: unknown }).projectId === "string" + ? (req.body as { projectId: string }).projectId.trim() + : ""; + const projectId = queryProjectId || bodyProjectId || undefined; + const scopedStore = projectId ? await getOrCreateProjectStore(projectId) : null; + const taskStore = scopedStore ?? defaultTaskStore ?? ({} as import("@fusion/core").TaskStore); + + let settings: Record = {}; + const scopedPluginStore = scopedStore?.getPluginStore?.(); + if (scopedPluginStore) { + try { + const scopedPlugin = await scopedPluginStore.getPlugin(pluginId); + settings = scopedPlugin.settings; + } catch { + // Fall back to default store plugin settings when project-scoped plugin record is unavailable. } - - // FNXC:BranchGroupProjectScoping 2026-07-14-06:15: return the trimmed id, not the raw padded string. - const queryProjectId = typeof req.query.projectId === "string" ? req.query.projectId.trim() : ""; - const bodyProjectId = - req.body && typeof req.body === "object" && typeof (req.body as { projectId?: unknown }).projectId === "string" - ? (req.body as { projectId: string }).projectId.trim() - : ""; - const projectId = queryProjectId || bodyProjectId || undefined; - const scopedStore = projectId ? await getOrCreateProjectStore(projectId) : null; - const taskStore = scopedStore ?? defaultTaskStore ?? ({} as import("@fusion/core").TaskStore); - - let settings: Record = {}; - const scopedPluginStore = scopedStore?.getPluginStore?.(); - if (scopedPluginStore) { - try { - const scopedPlugin = await scopedPluginStore.getPlugin(pluginId); - settings = scopedPlugin.settings; - } catch { - // Fall back to default store plugin settings when project-scoped plugin record is unavailable. - } - } - if (!scopedPluginStore || Object.keys(settings).length === 0) { - try { - const pluginRecord = await pluginStore.getPlugin(pluginId); - settings = pluginRecord.settings; - } catch { - // Keep empty settings when plugin store record isn't available. - } + } + if (!scopedPluginStore || Object.keys(settings).length === 0) { + try { + const pluginRecord = await pluginStore.getPlugin(pluginId); + settings = pluginRecord.settings; + } catch { + // Keep empty settings when plugin store record isn't available. } + } - const ctx: PluginContext = await pluginLoader.createRouteContext(pluginId, { - taskStore, - settings, - resolveProjectTaskStore: getOrCreateProjectStore, - // Real publish-to-/api/events seam: forward custom plugin events to - // connected SSE clients, scoped to the request's project so a - // project stream only sees its own events. - emitEvent: (event: string, data: unknown) => { - emitPluginCustomSseEvent(pluginId, event, data, projectId); - }, - }); - - // Call the route handler with Express Request cast to unknown - const result = await route.handler(req as unknown, ctx); - - if (isPluginRouteResponse(result)) { - if (result.headers) { - for (const [name, value] of Object.entries(result.headers)) { - res.setHeader(name, value); - } - } - if (result.contentType) { - res.setHeader("Content-Type", result.contentType); - } - if (result.status === 204) { - res.status(204).send(); - return; - } - if (result.body === undefined) { - res.status(result.status).send(); - return; - } - if ( - result.contentType - || typeof result.body === "string" - || Buffer.isBuffer(result.body) - ) { - res.status(result.status).send(result.body); - return; - } - res.status(result.status).json(result.body); - return; - } - - res.status(200).json(result); + const ctx: PluginContext = await pluginLoader.createRouteContext(pluginId, { + taskStore, + settings, + resolveProjectTaskStore: getOrCreateProjectStore, + // Real publish-to-/api/events seam: forward custom plugin events to + // connected SSE clients, scoped to the request's project so a + // project stream only sees its own events. + emitEvent: (event: string, data: unknown) => { + emitPluginCustomSseEvent(pluginId, event, data, projectId); + }, }); - switch (route.method) { - case "GET": - router.get(fullPath, handler); - break; - case "POST": - router.post(fullPath, handler); - break; - case "PUT": - router.put(fullPath, handler); - break; - case "PATCH": - router.patch(fullPath, handler); - break; - case "DELETE": - router.delete(fullPath, handler); - break; + // Call the route handler with Express Request cast to unknown + const result = await route.handler(req as unknown, ctx); + + if (isPluginRouteResponse(result)) { + if (result.headers) { + for (const [name, value] of Object.entries(result.headers)) { + res.setHeader(name, value); + } + } + if (result.contentType) { + res.setHeader("Content-Type", result.contentType); + } + if (result.status === 204) { + res.status(204).send(); + return; + } + if (result.body === undefined) { + res.status(result.status).send(); + return; + } + if ( + result.contentType + || typeof result.body === "string" + || Buffer.isBuffer(result.body) + ) { + res.status(result.status).send(result.body); + return; + } + res.status(result.status).json(result.body); + return; } + + res.status(200).json(result); + }); + + switch (route.method) { + case "GET": + router.get(fullPath, handler); + break; + case "POST": + router.post(fullPath, handler); + break; + case "PUT": + router.put(fullPath, handler); + break; + case "PATCH": + router.patch(fullPath, handler); + break; + case "DELETE": + router.delete(fullPath, handler); + break; } }