feat(FN-3160): move roadmap route context into fusion-plugin-roadmap plugin

The merge completes FN-3160 by making the roadmap route context plugin-owned, moving `roadmap-routes` and `roadmap-suggestions` logic from the dashboard into `fusion-plugin-roadmap` with updated plugin-loader integration. It also includes FN-3755's shared state snapshot support for mesh sync hardeni

Fusion-Task-Id: FN-3160
This commit is contained in:
Fusion
2026-05-08 15:39:18 -07:00
committed by gsxdsm
parent c5948681b5
commit a1f158f13b
13 changed files with 189 additions and 68 deletions

View File

@@ -14,7 +14,7 @@ import plugin, {
normalizeRoadmapMilestoneOrder,
} from "../index.js";
describe("fusion-plugin-roadmap package surface", () => {
describe("roadmap-planner package surface", () => {
it("keeps manifest and plugin entry metadata aligned", () => {
const manifest = JSON.parse(readFileSync(resolve(process.cwd(), "manifest.json"), "utf8")) as {
id: string;
@@ -38,7 +38,7 @@ describe("fusion-plugin-roadmap package surface", () => {
});
it("exports plugin manifest with roadmap id", () => {
expect(plugin.manifest.id).toBe("fusion-plugin-roadmap");
expect(plugin.manifest.id).toBe("roadmap-planner");
});
it("re-exports roadmap domain symbols", () => {

View File

@@ -0,0 +1,33 @@
import { describe, it, expect, vi } from "vitest";
import { createRoadmapPluginRoutes } from "../routes/roadmap-routes.js";
function createCtx() {
return {
pluginId: "roadmap-planner",
taskStore: {
getDatabase: () => ({}),
getRootDir: () => "/tmp/project",
getRoadmapStore: () => ({
getRoadmap: vi.fn(() => ({ id: "RM-1", title: "R" })),
getMilestone: vi.fn(() => ({ id: "MS-1", roadmapId: "RM-1", title: "M" })),
listFeatures: vi.fn(() => []),
}),
},
settings: {},
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
emitEvent: vi.fn(),
} as any;
}
describe("createRoadmapPluginRoutes", () => {
it("includes PATCH roadmap routes", () => {
const routes = createRoadmapPluginRoutes();
expect(routes.some((r) => r.method === "PATCH" && r.path === "/roadmaps/:roadmapId")).toBe(true);
});
it("returns 400 for invalid milestone suggestions body", async () => {
const route = createRoadmapPluginRoutes().find((r) => r.path === "/roadmaps/:roadmapId/suggestions/milestones");
const result = await route!.handler({ params: { roadmapId: "RM-1" }, body: {} }, createCtx());
expect(result).toMatchObject({ status: 400 });
});
});

View File

@@ -0,0 +1,31 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
__resetSuggestionState,
__setCreateAiSessionFactory,
generateMilestoneSuggestions,
ServiceUnavailableError,
} from "../routes/roadmap-suggestions.js";
describe("roadmap suggestion service", () => {
beforeEach(() => {
__resetSuggestionState();
});
it("throws when AI factory is unavailable", async () => {
await expect(generateMilestoneSuggestions("goal", 1, "/tmp/project")).rejects.toBeInstanceOf(ServiceUnavailableError);
});
it("uses PluginContext createAiSession-compatible factory", async () => {
const prompt = vi.fn().mockResolvedValue(undefined);
__setCreateAiSessionFactory(async () => ({
session: {
prompt,
state: { messages: [{ role: "assistant", content: '[{"title":"A"}]' }] },
},
}));
const result = await generateMilestoneSuggestions("goal", 1, "/tmp/project");
expect(prompt).toHaveBeenCalled();
expect(result[0]?.title).toBe("A");
});
});

View File

@@ -44,7 +44,7 @@ export function ensureRoadmapSchema(db: Database): void {
const plugin = definePlugin({
manifest: {
id: "fusion-plugin-roadmap",
id: "roadmap-planner",
name: "Roadmaps",
version: "0.1.0",
description: "Standalone roadmap planning plugin",

View File

@@ -2,6 +2,7 @@ import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "
interface RouteRequest {
params: Record<string, string>;
query?: Record<string, string | string[] | undefined>;
body?: unknown;
}
import { RoadmapStore } from "../store/roadmap-store.js";
@@ -18,8 +19,23 @@ import {
const roadmapStoreCache = new WeakMap<object, RoadmapStore>();
function getRoadmapStore(ctx: PluginContext): RoadmapStore {
const taskStoreWithRoadmaps = ctx.taskStore as PluginContext["taskStore"] & {
function resolveProjectId(req: RouteRequest): string | undefined {
const queryProjectId = paramValue(req.query?.projectId);
if (queryProjectId.trim()) return queryProjectId.trim();
const bodyProjectId = req.body && typeof req.body === "object"
? (req.body as { projectId?: unknown }).projectId
: undefined;
if (typeof bodyProjectId === "string" && bodyProjectId.trim()) return bodyProjectId.trim();
return undefined;
}
async function getRoadmapStore(req: RouteRequest, ctx: PluginContext): Promise<RoadmapStore> {
const projectId = resolveProjectId(req);
const scopedTaskStore = projectId && ctx.resolveProjectTaskStore
? await ctx.resolveProjectTaskStore(projectId)
: ctx.taskStore;
const taskStoreWithRoadmaps = scopedTaskStore as PluginContext["taskStore"] & {
getRoadmapStore?: () => RoadmapStore;
};
@@ -27,10 +43,10 @@ function getRoadmapStore(ctx: PluginContext): RoadmapStore {
return taskStoreWithRoadmaps.getRoadmapStore();
}
const key = ctx.taskStore as object;
const key = scopedTaskStore as object;
const cached = roadmapStoreCache.get(key);
if (cached) return cached;
const store = new RoadmapStore(ctx.taskStore.getDatabase());
const store = new RoadmapStore(scopedTaskStore.getDatabase());
roadmapStoreCache.set(key, store);
return store;
}
@@ -62,9 +78,10 @@ function noContent(): PluginRouteResponse {
function routeHandler<T>(handler: (req: RouteRequest, ctx: PluginContext, roadmapStore: RoadmapStore) => Promise<T | PluginRouteResponse> | T | PluginRouteResponse) {
return async (req: unknown, ctx: PluginContext): Promise<T | PluginRouteResponse> => {
const roadmapStore = getRoadmapStore(ctx);
const routeRequest = asRequest(req);
const roadmapStore = await getRoadmapStore(routeRequest, ctx);
try {
return await handler(asRequest(req), ctx, roadmapStore);
return await handler(routeRequest, ctx, roadmapStore);
} catch (error) {
if (error instanceof Error && error.message.toLowerCase().includes("not found")) {
return notFound(error.message);