feat(FN-3638): extract roadmap domain into plugin and define plugin route s

This merge restructures the roadmap plugin by removing its duplicated domain logic (roadmap store, ordering, handoff, and types — ~3,000 lines deleted) and completing the plugin SDK scaffold with proper route status/body contracts and type exports. It also introduces eval settings infrastructure wit

Fusion-Task-Id: FN-3638
This commit is contained in:
Fusion
2026-05-07 08:12:26 -07:00
committed by gsxdsm
parent 1f5e102312
commit 213ed79d5f
19 changed files with 190 additions and 3858 deletions

View File

@@ -150,6 +150,8 @@ export type {
PluginToolResult,
PluginRouteDefinition,
PluginRouteMethod,
PluginRouteResponse,
PluginRouteResult,
PluginUiSurface,
PluginUiSlotDefinition,
PluginUiContributionSurface,

View File

@@ -196,11 +196,18 @@ export type PluginRouteMethod = "GET" | "POST" | "PUT" | "DELETE";
/**
* Custom dashboard API route definition.
*/
export interface PluginRouteResponse {
status: number;
body?: unknown;
}
export type PluginRouteResult = unknown | PluginRouteResponse;
export interface PluginRouteDefinition {
method: PluginRouteMethod;
/** Relative path under /api/plugins/:pluginId/ */
path: string;
handler: (req: unknown, ctx: PluginContext) => Promise<unknown>;
handler: (req: unknown, ctx: PluginContext) => Promise<PluginRouteResult>;
description?: string;
}

View File

@@ -1171,6 +1171,124 @@ describe("createPluginRouter plugin setup routes", () => {
});
});
describe("createPluginRouter plugin-defined route responses", () => {
it("injects request-scoped taskStore and supports explicit status/body responses", async () => {
const defaultTaskStore = createMockTaskStore();
const scopedTaskStore = createMockTaskStore({ getRootDir: vi.fn().mockReturnValue("/scoped") });
mockGetOrCreateProjectStore.mockResolvedValue(scopedTaskStore);
const pluginStore = createMockPluginStore();
const pluginLoader = createMockPluginLoader({
getPlugin: vi.fn().mockReturnValue({ manifest: { id: "demo" } }),
});
const pluginRunner = {
getPluginRoutes: vi.fn().mockReturnValue([
{
pluginId: "demo",
route: {
method: "POST",
path: "/status",
handler: vi.fn(async (_req: unknown, ctx: import("@fusion/core").PluginContext) => ({
status: 201,
body: { scoped: ctx.taskStore.getRootDir() },
})),
},
},
]),
};
const app = express();
app.use(express.json());
app.use("/plugins", createPluginRouter(pluginStore, pluginLoader, pluginRunner, defaultTaskStore));
const res = await REQUEST(app, "POST", "/plugins/demo/status?projectId=p1", { projectId: "p1" });
expect(res.status).toBe(201);
expect(res.body).toEqual({ scoped: "/scoped" });
});
it("maps plugin-defined non-2xx status responses", async () => {
const pluginStore = createMockPluginStore();
const pluginLoader = createMockPluginLoader({
getPlugin: vi.fn().mockReturnValue({ manifest: { id: "demo" } }),
});
const pluginRunner = {
getPluginRoutes: vi.fn().mockReturnValue([
{
pluginId: "demo",
route: {
method: "GET",
path: "/error",
handler: vi.fn(async () => ({ status: 422, body: { error: "invalid" } })),
},
},
]),
};
const app = express();
app.use(express.json());
app.use("/plugins", createPluginRouter(pluginStore, pluginLoader, pluginRunner));
const res = await REQUEST(app, "GET", "/plugins/demo/error");
expect(res.status).toBe(422);
expect(res.body).toEqual({ error: "invalid" });
});
it("propagates thrown handler errors via catchHandler", async () => {
const pluginStore = createMockPluginStore();
const pluginLoader = createMockPluginLoader({
getPlugin: vi.fn().mockReturnValue({ manifest: { id: "demo" } }),
});
const pluginRunner = {
getPluginRoutes: vi.fn().mockReturnValue([
{
pluginId: "demo",
route: {
method: "GET",
path: "/throws",
handler: vi.fn(async () => {
throw new Error("boom");
}),
},
},
]),
};
const app = express();
app.use(express.json());
app.use("/plugins", createPluginRouter(pluginStore, pluginLoader, pluginRunner));
const res = await REQUEST(app, "GET", "/plugins/demo/throws");
expect(res.status).toBe(500);
expect(res.body.error).toContain("boom");
});
it("supports 204 empty responses for plugin routes", async () => {
const pluginStore = createMockPluginStore();
const pluginLoader = createMockPluginLoader({
getPlugin: vi.fn().mockReturnValue({ manifest: { id: "demo" } }),
});
const pluginRunner = {
getPluginRoutes: vi.fn().mockReturnValue([
{
pluginId: "demo",
route: {
method: "DELETE",
path: "/resource",
handler: vi.fn(async () => ({ status: 204 })),
},
},
]),
};
const app = express();
app.use(express.json());
app.use("/plugins", createPluginRouter(pluginStore, pluginLoader, pluginRunner));
const res = await REQUEST(app, "DELETE", "/plugins/demo/resource");
expect(res.status).toBe(204);
});
});
describe("GET /api/plugins/runtimes", () => {
let pluginStore: PluginStore;
let pluginLoader: PluginLoader;

View File

@@ -31,8 +31,17 @@ import {
internalError,
notFound,
} from "./api-error.js";
import { getOrCreateProjectStore } from "./project-store-resolver.js";
// PluginRunner interface for optional plugin runner
function isPluginRouteResponse(result: unknown): result is import("@fusion/core").PluginRouteResponse {
if (!result || typeof result !== "object" || Array.isArray(result)) {
return false;
}
const candidate = result as { status?: unknown };
return typeof candidate.status === "number";
}
interface PluginRunner {
reloadPlugin?(pluginId: string): Promise<void>;
checkPluginSetup?(pluginId: string): Promise<import("@fusion/core").PluginSetupCheckResult>;
@@ -193,6 +202,7 @@ export function createPluginRouter(
pluginStore: PluginStore,
pluginLoader: PluginLoader,
pluginRunner?: PluginRunner,
defaultTaskStore?: import("@fusion/core").TaskStore,
): Router {
const router = Router();
@@ -529,10 +539,17 @@ export function createPluginRouter(
throw notFound(`Plugin "${pluginId}" not loaded`);
}
const projectId = typeof req.query.projectId === "string" && req.query.projectId.trim()
? req.query.projectId
: (req.body && typeof req.body === "object" && typeof (req.body as { projectId?: unknown }).projectId === "string" && (req.body as { projectId: string }).projectId.trim()
? (req.body as { projectId: string }).projectId
: undefined);
const scopedStore = projectId ? await getOrCreateProjectStore(projectId) : null;
// Create a minimal context for the handler
const ctx: PluginContext = {
pluginId,
taskStore: {} as import("@fusion/core").TaskStore, // TaskStore is provided by the plugin loader
taskStore: scopedStore ?? defaultTaskStore ?? ({} as import("@fusion/core").TaskStore),
settings: {},
logger: {
info: (...args: unknown[]) => console.log(`[plugin:${pluginId}]`, ...args),
@@ -549,7 +566,21 @@ export function createPluginRouter(
// Call the route handler with Express Request cast to unknown
const result = await route.handler(req as unknown, ctx);
res.json(result);
if (isPluginRouteResponse(result)) {
if (result.status === 204) {
res.status(204).send();
return;
}
if (result.body === undefined) {
res.status(result.status).send();
return;
}
res.status(result.status).json(result.body);
return;
}
res.status(200).json(result);
});
switch (route.method) {

View File

@@ -49,6 +49,8 @@ export type {
PluginToolResult,
PluginRouteDefinition,
PluginRouteMethod,
PluginRouteResponse,
PluginRouteResult,
PluginUiSurface,
PluginUiSlotDefinition,
PluginUiContributionSurface,