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:
Fusion
2026-05-11 06:04:17 -07:00
committed by gsxdsm
parent 119ba88a3f
commit d910c0c0f3
33 changed files with 219 additions and 81 deletions

View File

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

View File

@@ -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" },
},
]}

View File

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

View File

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

View File

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

View File

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

View File

@@ -80,7 +80,7 @@ export function registerBundledPluginViews(): void {
);
registerPluginView(
"roadmap-planner",
"fusion-plugin-roadmap",
"roadmaps",
lazy(loadRoadmapView),
);

View File

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

View File

@@ -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 () => {

View File

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

View File

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

View 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;
}

View 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";

View File

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

View File

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