feat(FN-3637): establish canonical roadmap plugin ID and compatibility rout
Merged commits stabilize the Fusion roadmap plugin's identity and routing surface, establishing a canonical plugin ID and compatibility routes so the roadmap plugin integrates cleanly with the dashboard's plugin system. Added new roadmap-routes and roadmap-suggestions modules in the dashboard packag Fusion-Task-Id: FN-3637
This commit is contained in:
@@ -834,7 +834,7 @@ describe("plugin dashboard view API wrappers", () => {
|
||||
it("fetchPluginDashboardViews calls /api/plugins/dashboard-views", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, [
|
||||
{
|
||||
pluginId: "roadmap-planner",
|
||||
pluginId: "fusion-plugin-roadmap",
|
||||
view: { viewId: "roadmaps", label: "Roadmaps", componentPath: "./dashboard-view" },
|
||||
},
|
||||
]));
|
||||
@@ -850,7 +850,7 @@ describe("plugin dashboard view API wrappers", () => {
|
||||
it("fetchPluginUiSlots calls /api/plugins/ui-slots and keeps slot shape", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, [
|
||||
{
|
||||
pluginId: "roadmap-planner",
|
||||
pluginId: "fusion-plugin-roadmap",
|
||||
slot: {
|
||||
slotId: "task-detail-tab",
|
||||
label: "Roadmap Details",
|
||||
|
||||
@@ -411,7 +411,7 @@ describe("MobileNavBar", () => {
|
||||
experimentalFeatures={{}}
|
||||
pluginDashboardViews={[
|
||||
{
|
||||
pluginId: "roadmap-planner",
|
||||
pluginId: "fusion-plugin-roadmap",
|
||||
view: { viewId: "roadmaps", label: "Roadmaps", componentPath: "./RoadmapsView", icon: "Map", placement: "primary" },
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -73,7 +73,7 @@ describe("useViewState", () => {
|
||||
const { result } = renderHook(() => useViewState(createOptions()));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.taskView).toBe("plugin:roadmap-planner:roadmaps");
|
||||
expect(result.current.taskView).toBe("plugin:fusion-plugin-roadmap:roadmaps");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ function isTaskView(value: string | null): value is TaskView {
|
||||
return value !== null && (isBuiltInTaskView(value) || isPluginViewId(value));
|
||||
}
|
||||
|
||||
const LEGACY_ROADMAPS_PLUGIN_VIEW = getPluginViewId("roadmap-planner", "roadmaps");
|
||||
const LEGACY_ROADMAPS_PLUGIN_VIEW = getPluginViewId("fusion-plugin-roadmap", "roadmaps");
|
||||
|
||||
function normalizeTaskView(value: TaskView): TaskView {
|
||||
return value === "devserver" ? "dev-server" : value;
|
||||
@@ -47,7 +47,7 @@ function migrateLegacyRoadmapsView(value: string): TaskView {
|
||||
if (value !== "roadmaps") {
|
||||
return "board";
|
||||
}
|
||||
return isPluginViewRegistered("roadmap-planner", "roadmaps") ? LEGACY_ROADMAPS_PLUGIN_VIEW : "board";
|
||||
return isPluginViewRegistered("fusion-plugin-roadmap", "roadmaps") ? LEGACY_ROADMAPS_PLUGIN_VIEW : "board";
|
||||
}
|
||||
|
||||
interface UseViewStateOptions {
|
||||
|
||||
@@ -18,7 +18,7 @@ describe("pluginViewRegistry", () => {
|
||||
|
||||
it("builds plugin IDs", () => {
|
||||
expect(getPluginViewId("plugin-a", "main")).toBe("plugin:plugin-a:main");
|
||||
expect(getPluginViewId("roadmap-planner", "roadmaps")).toBe("plugin:roadmap-planner:roadmaps");
|
||||
expect(getPluginViewId("fusion-plugin-roadmap", "roadmaps")).toBe("plugin:fusion-plugin-roadmap:roadmaps");
|
||||
});
|
||||
|
||||
it("parses and validates plugin IDs", () => {
|
||||
@@ -78,11 +78,11 @@ describe("pluginViewRegistry", () => {
|
||||
expect(await screen.findByText("proj-1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("resolves roadmap-planner registry entry and avoids unavailable fallback", async () => {
|
||||
it("resolves fusion-plugin-roadmap registry entry and avoids unavailable fallback", async () => {
|
||||
const RoadmapsView = lazy(async () => ({ default: () => <div>Roadmaps Plugin View</div> }));
|
||||
registerPluginView("roadmap-planner", "roadmaps", RoadmapsView);
|
||||
registerPluginView("fusion-plugin-roadmap", "roadmaps", RoadmapsView);
|
||||
|
||||
render(<>{PluginDashboardViewHost({ viewId: "plugin:roadmap-planner:roadmaps" })}</>);
|
||||
render(<>{PluginDashboardViewHost({ viewId: "plugin:fusion-plugin-roadmap:roadmaps" })}</>);
|
||||
|
||||
expect(await screen.findByText("Roadmaps Plugin View")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("plugin-view-unavailable")).toBeNull();
|
||||
|
||||
@@ -15,7 +15,7 @@ vi.mock("@fusion-plugin-examples/dependency-graph/dashboard-view", () => ({
|
||||
DependencyGraphDashboardView: (...args: unknown[]) => MockDependencyGraphDashboardView(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion-plugin-examples/roadmap/dashboard-view", () => ({
|
||||
vi.mock("@fusion-plugin-examples/fusion-plugin-roadmap/dashboard-view", () => ({
|
||||
RoadmapDashboardView: (...args: unknown[]) => MockRoadmapDashboardView(...args),
|
||||
}));
|
||||
|
||||
@@ -37,7 +37,7 @@ describe("registerBundledPluginViews", () => {
|
||||
registerBundledPluginViews();
|
||||
|
||||
expect(getPluginViewComponent("fusion-plugin-dependency-graph", "graph")).toBeTruthy();
|
||||
expect(getPluginViewComponent("roadmap-planner", "roadmaps")).toBeTruthy();
|
||||
expect(getPluginViewComponent("fusion-plugin-roadmap", "roadmaps")).toBeTruthy();
|
||||
expect(getPluginViewComponent("fusion-plugin-cli-printing-press", "wizard")).toBeTruthy();
|
||||
expect(getPluginViewComponent("fusion-plugin-cli-printing-press", "manage")).toBeTruthy();
|
||||
});
|
||||
@@ -56,7 +56,7 @@ describe("registerBundledPluginViews", () => {
|
||||
registerBundledPluginViews();
|
||||
|
||||
expect(isPluginViewRegistered("fusion-plugin-dependency-graph", "graph")).toBe(true);
|
||||
expect(isPluginViewRegistered("roadmap-planner", "roadmaps")).toBe(true);
|
||||
expect(isPluginViewRegistered("fusion-plugin-roadmap", "roadmaps")).toBe(true);
|
||||
expect(isPluginViewRegistered("fusion-plugin-cli-printing-press", "wizard")).toBe(true);
|
||||
expect(isPluginViewRegistered("fusion-plugin-cli-printing-press", "manage")).toBe(true);
|
||||
// Unknown plugin/view should not be registered
|
||||
|
||||
@@ -80,7 +80,7 @@ export function registerBundledPluginViews(): void {
|
||||
);
|
||||
|
||||
registerPluginView(
|
||||
"roadmap-planner",
|
||||
"fusion-plugin-roadmap",
|
||||
"roadmaps",
|
||||
lazy(loadRoadmapView),
|
||||
);
|
||||
|
||||
@@ -1193,24 +1193,24 @@ describe("plugin-defined route dispatch", () => {
|
||||
it("registers PATCH routes from plugins", () => {
|
||||
const pluginRunner = {
|
||||
getPluginRoutes: vi.fn().mockReturnValue([
|
||||
{ pluginId: "roadmap-planner", route: { method: "PATCH", path: "/roadmaps/x", handler: vi.fn() } },
|
||||
{ pluginId: "fusion-plugin-roadmap", route: { method: "PATCH", path: "/roadmaps/x", handler: vi.fn() } },
|
||||
]),
|
||||
};
|
||||
|
||||
const pluginStore = createMockPluginStore();
|
||||
const router = createPluginRouter(pluginStore, createMockPluginLoader({
|
||||
createRouteContext: vi.fn().mockResolvedValue({
|
||||
pluginId: "roadmap-planner",
|
||||
pluginId: "fusion-plugin-roadmap",
|
||||
taskStore: createMockTaskStore(),
|
||||
settings: {},
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
emitEvent: vi.fn(),
|
||||
}),
|
||||
getPlugin: vi.fn().mockReturnValue({ manifest: { id: "roadmap-planner" } }),
|
||||
getPlugin: vi.fn().mockReturnValue({ manifest: { id: "fusion-plugin-roadmap" } }),
|
||||
} as any), pluginRunner as any, createMockTaskStore());
|
||||
|
||||
const stack = (router as any).stack as Array<{ route?: { path: string; methods: Record<string, boolean> } }>;
|
||||
const patchRoute = stack.find((layer) => layer.route?.path === "/roadmap-planner/roadmaps/x");
|
||||
const patchRoute = stack.find((layer) => layer.route?.path === "/fusion-plugin-roadmap/roadmaps/x");
|
||||
expect(patchRoute?.route?.methods.patch).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1218,14 +1218,14 @@ describe("plugin-defined route dispatch", () => {
|
||||
const routeHandler = vi.fn().mockResolvedValue({ ok: true });
|
||||
const pluginRunner = {
|
||||
getPluginRoutes: vi.fn().mockReturnValue([
|
||||
{ pluginId: "roadmap-planner", route: { method: "POST", path: "/ctx-check", handler: routeHandler } },
|
||||
{ pluginId: "fusion-plugin-roadmap", route: { method: "POST", path: "/ctx-check", handler: routeHandler } },
|
||||
]),
|
||||
};
|
||||
const scopedPluginStore = createMockPluginStore();
|
||||
const scopedTaskStore = createMockTaskStore({ getPluginStore: vi.fn().mockReturnValue(scopedPluginStore) });
|
||||
mockGetOrCreateProjectStore.mockResolvedValue(scopedTaskStore);
|
||||
const createRouteContext = vi.fn().mockImplementation(async (_pluginId: string, overrides: any) => ({
|
||||
pluginId: "roadmap-planner",
|
||||
pluginId: "fusion-plugin-roadmap",
|
||||
taskStore: overrides.taskStore,
|
||||
settings: overrides.settings,
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
@@ -1235,7 +1235,7 @@ describe("plugin-defined route dispatch", () => {
|
||||
}));
|
||||
const pluginLoader = createMockPluginLoader({
|
||||
createRouteContext,
|
||||
getPlugin: vi.fn().mockReturnValue({ manifest: { id: "roadmap-planner" } }),
|
||||
getPlugin: vi.fn().mockReturnValue({ manifest: { id: "fusion-plugin-roadmap" } }),
|
||||
} as any);
|
||||
const pluginStore = createMockPluginStore();
|
||||
|
||||
@@ -1243,9 +1243,9 @@ describe("plugin-defined route dispatch", () => {
|
||||
app.use(express.json());
|
||||
app.use("/api/plugins", createPluginRouter(pluginStore, pluginLoader, pluginRunner as any, createMockTaskStore()));
|
||||
|
||||
const res = await REQUEST(app, "POST", "/api/plugins/roadmap-planner/ctx-check", { projectId: "proj_123" });
|
||||
const res = await REQUEST(app, "POST", "/api/plugins/fusion-plugin-roadmap/ctx-check", { projectId: "proj_123" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(createRouteContext).toHaveBeenCalledWith("roadmap-planner", expect.objectContaining({
|
||||
expect(createRouteContext).toHaveBeenCalledWith("fusion-plugin-roadmap", expect.objectContaining({
|
||||
taskStore: scopedTaskStore,
|
||||
resolveProjectTaskStore: projectStoreResolver.getOrCreateProjectStore,
|
||||
}));
|
||||
|
||||
@@ -1028,7 +1028,7 @@ describe("GET /api/plugins/dashboard-views", () => {
|
||||
it("keeps dashboard-views payload separate from ui-slots payload", async () => {
|
||||
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
{
|
||||
pluginId: "roadmap-planner",
|
||||
pluginId: "fusion-plugin-roadmap",
|
||||
view: {
|
||||
viewId: "roadmaps",
|
||||
label: "Roadmaps",
|
||||
@@ -1038,7 +1038,7 @@ describe("GET /api/plugins/dashboard-views", () => {
|
||||
]);
|
||||
(pluginLoader.getPluginUiSlots as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
{
|
||||
pluginId: "roadmap-planner",
|
||||
pluginId: "fusion-plugin-roadmap",
|
||||
slot: {
|
||||
slotId: "task-detail-tab",
|
||||
label: "Roadmap Details",
|
||||
@@ -1696,7 +1696,8 @@ describe("createPluginRouter plugin-defined route responses", () => {
|
||||
const res = await performGet(app, "/plugins/demo/html");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers["content-type"]).toContain("text/html");
|
||||
expect(res.text).toContain("<html><body>Hello</body></html>");
|
||||
const html = typeof res.text === "string" ? res.text : String(res.body ?? "");
|
||||
expect(html).toContain("<html><body>Hello</body></html>");
|
||||
});
|
||||
|
||||
it("propagates custom response headers", async () => {
|
||||
|
||||
@@ -4,18 +4,21 @@ import { describe, it, expect } from "vitest";
|
||||
import express from "express";
|
||||
import { registerIntegratedRouters } from "../routes/register-integrated-routers.js";
|
||||
|
||||
describe("integrated roadmap routes removed", () => {
|
||||
it("does not register a legacy /roadmaps mount", () => {
|
||||
describe("integrated roadmap routes compatibility", () => {
|
||||
it("registers a legacy /roadmaps mount that delegates to plugin routes", () => {
|
||||
const router = express.Router();
|
||||
registerIntegratedRouters({
|
||||
router,
|
||||
store: {} as never,
|
||||
});
|
||||
|
||||
const mountedPaths = (router as unknown as { stack?: Array<{ regexp?: { source?: string } }> }).stack
|
||||
?.map((layer) => layer.regexp?.source ?? "")
|
||||
?? [];
|
||||
const stack = (router as unknown as { stack?: Array<{ regexp?: { source?: string }; handle?: { stack?: Array<{ route?: { path?: string } }> } }> }).stack ?? [];
|
||||
|
||||
expect(mountedPaths.some((path) => path.includes("roadmaps"))).toBe(false);
|
||||
const hasRoadmapMount = stack.some((layer) => {
|
||||
if (layer.regexp?.source?.includes("roadmaps")) return true;
|
||||
return layer.handle?.stack?.some((nested) => typeof nested.route?.path === "string" && nested.route.path.startsWith("/roadmaps")) ?? false;
|
||||
});
|
||||
|
||||
expect(hasRoadmapMount).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { SUGGESTION_TIMEOUT_MS } from "../roadmap-suggestions.js";
|
||||
|
||||
describe("roadmap suggestions compatibility exports", () => {
|
||||
it("re-exports suggestion timeout from roadmap plugin", () => {
|
||||
expect(typeof SUGGESTION_TIMEOUT_MS).toBe("number");
|
||||
expect(SUGGESTION_TIMEOUT_MS).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
88
packages/dashboard/src/roadmap-routes.ts
Normal file
88
packages/dashboard/src/roadmap-routes.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { getCreateAiSessionFactory, type PluginContext, type TaskStore } from "@fusion/core";
|
||||
import { createRoadmapPluginRoutes } from "@fusion-plugin-examples/roadmap";
|
||||
import { getOrCreateProjectStore } from "./project-store-resolver.js";
|
||||
|
||||
function asQueryRecord(query: Request["query"]): Record<string, string | string[] | undefined> {
|
||||
return query as Record<string, string | string[] | undefined>;
|
||||
}
|
||||
|
||||
function resolveProjectId(req: Request): string | undefined {
|
||||
const queryProjectId = req.query.projectId;
|
||||
if (typeof queryProjectId === "string" && queryProjectId.trim()) return queryProjectId;
|
||||
if (req.body && typeof req.body === "object") {
|
||||
const bodyProjectId = (req.body as { projectId?: unknown }).projectId;
|
||||
if (typeof bodyProjectId === "string" && bodyProjectId.trim()) return bodyProjectId;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function createRoadmapCompatibilityRouter(defaultTaskStore: TaskStore): Router {
|
||||
const router = Router();
|
||||
const routes = createRoadmapPluginRoutes();
|
||||
|
||||
for (const route of routes) {
|
||||
if (!route.path.startsWith("/roadmaps")) continue;
|
||||
|
||||
const handler = async (req: Request, res: Response): Promise<void> => {
|
||||
const projectId = resolveProjectId(req);
|
||||
const scopedStore = projectId ? await getOrCreateProjectStore(projectId) : null;
|
||||
const taskStore = scopedStore ?? defaultTaskStore;
|
||||
const createAiSession = await getCreateAiSessionFactory();
|
||||
|
||||
const ctx: PluginContext = {
|
||||
pluginId: "fusion-plugin-roadmap",
|
||||
taskStore,
|
||||
settings: {},
|
||||
logger: console,
|
||||
emitEvent: () => {},
|
||||
createAiSession,
|
||||
resolveProjectTaskStore: getOrCreateProjectStore,
|
||||
};
|
||||
|
||||
const result = await route.handler({ params: req.params as Record<string, string>, query: asQueryRecord(req.query), body: req.body }, ctx);
|
||||
|
||||
if (result && typeof result === "object" && "status" in (result as Record<string, unknown>) && typeof (result as { status?: unknown }).status === "number") {
|
||||
const response = result as { status: number; body?: unknown; headers?: Record<string, string>; contentType?: string };
|
||||
if (response.headers) {
|
||||
for (const [name, value] of Object.entries(response.headers)) {
|
||||
res.setHeader(name, value);
|
||||
}
|
||||
}
|
||||
if (response.contentType) res.setHeader("Content-Type", response.contentType);
|
||||
if (response.status === 204) {
|
||||
res.status(204).send();
|
||||
return;
|
||||
}
|
||||
if (response.body === undefined) {
|
||||
res.status(response.status).send();
|
||||
return;
|
||||
}
|
||||
res.status(response.status).json(response.body);
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json(result);
|
||||
};
|
||||
|
||||
switch (route.method) {
|
||||
case "GET":
|
||||
router.get(route.path, handler);
|
||||
break;
|
||||
case "POST":
|
||||
router.post(route.path, handler);
|
||||
break;
|
||||
case "PUT":
|
||||
router.put(route.path, handler);
|
||||
break;
|
||||
case "PATCH":
|
||||
router.patch(route.path, handler);
|
||||
break;
|
||||
case "DELETE":
|
||||
router.delete(route.path, handler);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return router;
|
||||
}
|
||||
15
packages/dashboard/src/roadmap-suggestions.ts
Normal file
15
packages/dashboard/src/roadmap-suggestions.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export {
|
||||
FEATURE_SUGGESTION_SYSTEM_PROMPT,
|
||||
MILESTONE_SUGGESTION_SYSTEM_PROMPT,
|
||||
ParseError,
|
||||
ServiceUnavailableError,
|
||||
SUGGESTION_TIMEOUT_MS,
|
||||
ValidationError,
|
||||
__resetSuggestionState,
|
||||
__setCreateAiSessionFactory,
|
||||
__setCreateFnAgent,
|
||||
generateFeatureSuggestions,
|
||||
generateMilestoneSuggestions,
|
||||
validateFeatureSuggestionInput,
|
||||
validateSuggestionInput,
|
||||
} from "@fusion-plugin-examples/roadmap/roadmap-suggestions";
|
||||
@@ -139,7 +139,7 @@ Integrated routers are mounted through `register-integrated-routers.ts` and inte
|
||||
- `createMissionRouter` → `/api/missions`
|
||||
- `createInsightsRouter` → `/api/insights`
|
||||
- `createTodoRouter` → `/api/todos`
|
||||
- Roadmap endpoints are plugin-owned and exposed under `/api/plugins/roadmap-planner/...`.
|
||||
- Roadmap endpoints are plugin-owned and exposed under `/api/plugins/fusion-plugin-roadmap/...`.
|
||||
- `registerIntegratedDevServerRouter(...)` mounts:
|
||||
- `createDevServerRouter` → `/api/dev-server`
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createInsightsRouter } from "../insights-routes.js";
|
||||
import { createEvalsRouter } from "../evals-routes.js";
|
||||
import { createResearchRouter } from "../research-routes.js";
|
||||
import { createTodoRouter } from "../todo-routes.js";
|
||||
import { createRoadmapCompatibilityRouter } from "../roadmap-routes.js";
|
||||
import { createDevServerRouter } from "../dev-server-routes.js";
|
||||
import type { AiSessionStore } from "../ai-session-store.js";
|
||||
import { createStashRecoveryRouter } from "./register-stash-recovery-routes.js";
|
||||
@@ -37,6 +38,7 @@ export function registerIntegratedRouters({
|
||||
router.use("/evals", createEvalsRouter(store));
|
||||
router.use("/research", createResearchRouter(store));
|
||||
router.use("/todos", createTodoRouter(store));
|
||||
router.use("/roadmaps", createRoadmapCompatibilityRouter(store));
|
||||
router.use("/stash-recovery", createStashRecoveryRouter(store));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user