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:
@@ -150,6 +150,8 @@ export type {
|
||||
PluginToolResult,
|
||||
PluginRouteDefinition,
|
||||
PluginRouteMethod,
|
||||
PluginRouteResponse,
|
||||
PluginRouteResult,
|
||||
PluginUiSurface,
|
||||
PluginUiSlotDefinition,
|
||||
PluginUiContributionSurface,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -49,6 +49,8 @@ export type {
|
||||
PluginToolResult,
|
||||
PluginRouteDefinition,
|
||||
PluginRouteMethod,
|
||||
PluginRouteResponse,
|
||||
PluginRouteResult,
|
||||
PluginUiSurface,
|
||||
PluginUiSlotDefinition,
|
||||
PluginUiContributionSurface,
|
||||
|
||||
Reference in New Issue
Block a user