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:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user