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.
This commit is contained in:
gsxdsm
2026-07-22 11:04:19 -07:00
parent 8e6985aed3
commit 961edf2145
3 changed files with 193 additions and 99 deletions

View File

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

View File

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

View File

@@ -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<string, PluginRouteEntry>();
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<string, unknown> = {};
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<string, unknown> = {};
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;
}
}