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:
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { PluginLoader } from "../plugin-loader.js";
|
||||
|
||||
describe("PluginLoader.createRouteContext", () => {
|
||||
it("applies overrides including resolveProjectTaskStore", async () => {
|
||||
const pluginStore = {
|
||||
getPlugin: vi.fn().mockResolvedValue({ settings: { x: 1 } }),
|
||||
} as any;
|
||||
const baseStore = { getRootDir: () => "/tmp" } as any;
|
||||
const loader = new PluginLoader({ pluginStore, taskStore: baseStore });
|
||||
const resolveProjectTaskStore = vi.fn();
|
||||
const ctx = await loader.createRouteContext("roadmap-planner", {
|
||||
taskStore: baseStore,
|
||||
settings: { ok: true },
|
||||
resolveProjectTaskStore,
|
||||
});
|
||||
|
||||
expect(ctx.pluginId).toBe("roadmap-planner");
|
||||
expect(ctx.settings).toEqual({ ok: true });
|
||||
expect(ctx.resolveProjectTaskStore).toBe(resolveProjectTaskStore);
|
||||
});
|
||||
});
|
||||
@@ -114,25 +114,32 @@ export class PluginLoader extends EventEmitter<{
|
||||
// ── Context Creation ───────────────────────────────────────────────
|
||||
|
||||
private async createContext(plugin: FusionPlugin): Promise<PluginContext> {
|
||||
return this.createRouteContext(plugin.manifest.id);
|
||||
}
|
||||
|
||||
async createRouteContext(
|
||||
pluginId: string,
|
||||
overrides?: Partial<Pick<PluginContext, "taskStore" | "settings" | "resolveProjectTaskStore">>,
|
||||
): Promise<PluginContext> {
|
||||
const createAiSession = await getCreateAiSessionFactory();
|
||||
if (process.env.DEBUG?.includes("plugins")) {
|
||||
this.log.log(
|
||||
createAiSession
|
||||
? `[plugin:${plugin.manifest.id}] createAiSession available`
|
||||
: `[plugin:${plugin.manifest.id}] createAiSession unavailable`,
|
||||
? `[plugin:${pluginId}] createAiSession available`
|
||||
: `[plugin:${pluginId}] createAiSession unavailable`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
pluginId: plugin.manifest.id,
|
||||
taskStore: this.options.taskStore,
|
||||
settings: await this.getPluginSettings(plugin.manifest.id),
|
||||
logger: this.createLogger(plugin.manifest.id),
|
||||
pluginId,
|
||||
taskStore: overrides?.taskStore ?? this.options.taskStore,
|
||||
settings: overrides?.settings ?? await this.getPluginSettings(pluginId),
|
||||
logger: this.createLogger(pluginId),
|
||||
createAiSession,
|
||||
resolveProjectTaskStore: overrides?.resolveProjectTaskStore,
|
||||
emitEvent: (event: string, data: unknown) => {
|
||||
this.emit("plugin:error", { pluginId: plugin.manifest.id, error: new Error(`Custom event: ${event}`) });
|
||||
// Custom events are logged but not surfaced as errors
|
||||
this.log.log(`[plugin:${plugin.manifest.id}] Custom event: ${event}`, data);
|
||||
this.emit("plugin:error", { pluginId, error: new Error(`Custom event: ${event}`) });
|
||||
this.log.log(`[plugin:${pluginId}] Custom event: ${event}`, data);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -137,6 +137,8 @@ export interface PluginContext {
|
||||
emitEvent: (event: string, data: unknown) => void;
|
||||
/** Engine-injected AI session factory (undefined when engine is not loaded) */
|
||||
createAiSession?: CreateAiSessionFactory;
|
||||
/** Optional host capability to resolve a project-scoped TaskStore by projectId. */
|
||||
resolveProjectTaskStore?: (projectId: string) => Promise<TaskStore>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { PluginInstallation } from "@fusion/core";
|
||||
import type { PluginStore } from "@fusion/core";
|
||||
import type { PluginLoader } from "@fusion/core";
|
||||
import { createApiRoutes } from "../routes.js";
|
||||
import { createPluginRouter } from "../plugin-routes.js";
|
||||
import { get as performGet, request as performRequest } from "../test-request.js";
|
||||
import * as projectStoreResolver from "../project-store-resolver.js";
|
||||
|
||||
@@ -1179,6 +1180,32 @@ describe("DELETE /plugins/:id", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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() } },
|
||||
]),
|
||||
};
|
||||
|
||||
const pluginStore = createMockPluginStore();
|
||||
const router = createPluginRouter(pluginStore, createMockPluginLoader({
|
||||
createRouteContext: vi.fn().mockResolvedValue({
|
||||
pluginId: "roadmap-planner",
|
||||
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" } }),
|
||||
} 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");
|
||||
expect(patchRoute?.route?.methods.patch).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Project scoping", () => {
|
||||
let defaultPluginStore: PluginStore;
|
||||
let scopedPluginStore: PluginStore;
|
||||
|
||||
@@ -23,7 +23,7 @@ import type {
|
||||
PluginStore,
|
||||
PluginContext,
|
||||
} from "@fusion/core";
|
||||
import { getCreateAiSessionFactory, validatePluginManifest } from "@fusion/core";
|
||||
import { validatePluginManifest } from "@fusion/core";
|
||||
import {
|
||||
ApiError,
|
||||
badRequest,
|
||||
@@ -33,6 +33,9 @@ import {
|
||||
} from "./api-error.js";
|
||||
import { getOrCreateProjectStore } from "./project-store-resolver.js";
|
||||
|
||||
const ROADMAP_PLUGIN_ID = "fusion-plugin-roadmap";
|
||||
const ROADMAP_PLUGIN_ROUTE_NAMESPACE = "roadmap-planner";
|
||||
|
||||
// PluginRunner interface for optional plugin runner
|
||||
function isPluginRouteResponse(result: unknown): result is import("@fusion/core").PluginRouteResponse {
|
||||
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
||||
@@ -532,7 +535,8 @@ export function createPluginRouter(
|
||||
const pluginRoutes = pluginRunner.getPluginRoutes();
|
||||
|
||||
for (const { pluginId, route } of pluginRoutes) {
|
||||
const fullPath = `/${pluginId}${route.path.startsWith("/") ? route.path : `/${route.path}`}`;
|
||||
const routePluginId = pluginId === ROADMAP_PLUGIN_ID ? ROADMAP_PLUGIN_ROUTE_NAMESPACE : pluginId;
|
||||
const fullPath = `/${routePluginId}${route.path.startsWith("/") ? route.path : `/${route.path}`}`;
|
||||
|
||||
const handler = catchHandler(async (req: Request, res: Response) => {
|
||||
// Get the plugin context
|
||||
@@ -568,26 +572,11 @@ export function createPluginRouter(
|
||||
}
|
||||
}
|
||||
|
||||
const createAiSession = await getCreateAiSessionFactory();
|
||||
|
||||
// Create a minimal context for the handler
|
||||
const ctx: PluginContext = {
|
||||
pluginId,
|
||||
const ctx: PluginContext = await pluginLoader.createRouteContext(pluginId, {
|
||||
taskStore,
|
||||
settings,
|
||||
logger: {
|
||||
info: (...args: unknown[]) => console.log(`[plugin:${pluginId}]`, ...args),
|
||||
warn: (...args: unknown[]) => console.warn(`[plugin:${pluginId}]`, ...args),
|
||||
error: (...args: unknown[]) => console.error(`[plugin:${pluginId}]`, ...args),
|
||||
debug: (...args: unknown[]) => {
|
||||
if (process.env.DEBUG?.includes("plugins")) {
|
||||
console.log(`[plugin:${pluginId}]`, ...args);
|
||||
}
|
||||
},
|
||||
},
|
||||
emitEvent: () => {},
|
||||
createAiSession,
|
||||
};
|
||||
resolveProjectTaskStore: getOrCreateProjectStore,
|
||||
});
|
||||
|
||||
// Call the route handler with Express Request cast to unknown
|
||||
const result = await route.handler(req as unknown, ctx);
|
||||
@@ -618,6 +607,9 @@ export function createPluginRouter(
|
||||
case "PUT":
|
||||
router.put(fullPath, handler);
|
||||
break;
|
||||
case "PATCH":
|
||||
router.patch(fullPath, handler);
|
||||
break;
|
||||
case "DELETE":
|
||||
router.delete(fullPath, handler);
|
||||
break;
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { getCreateAiSessionFactory, type PluginContext, type PluginRouteDefinition, type TaskStore } from "@fusion/core";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { createRoadmapPluginRoutes } = require("../../../plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.js") as {
|
||||
createRoadmapPluginRoutes: () => PluginRouteDefinition[];
|
||||
};
|
||||
import { type PluginContext, type PluginRouteDefinition, type TaskStore } from "@fusion/core";
|
||||
import { createRoadmapPluginRoutes } from "@fusion-plugin-examples/roadmap/server";
|
||||
|
||||
function isRouteResponse(value: unknown): value is { status: number; body?: unknown } {
|
||||
return (
|
||||
@@ -17,10 +12,8 @@ function isRouteResponse(value: unknown): value is { status: number; body?: unkn
|
||||
}
|
||||
|
||||
async function buildContext(store: TaskStore): Promise<PluginContext> {
|
||||
const createAiSession = await getCreateAiSessionFactory();
|
||||
|
||||
return {
|
||||
pluginId: "fusion-plugin-roadmap",
|
||||
pluginId: "roadmap-planner",
|
||||
taskStore: store,
|
||||
settings: {},
|
||||
logger: {
|
||||
@@ -30,7 +23,7 @@ async function buildContext(store: TaskStore): Promise<PluginContext> {
|
||||
debug: () => {},
|
||||
},
|
||||
emitEvent: () => {},
|
||||
createAiSession,
|
||||
createAiSession: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const roadmapSuggestions = require("../../../plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js") as Record<string, unknown>;
|
||||
|
||||
export const FEATURE_SUGGESTION_SYSTEM_PROMPT = roadmapSuggestions.FEATURE_SUGGESTION_SYSTEM_PROMPT as string;
|
||||
export const MILESTONE_SUGGESTION_SYSTEM_PROMPT = roadmapSuggestions.MILESTONE_SUGGESTION_SYSTEM_PROMPT as string;
|
||||
export const ParseError = roadmapSuggestions.ParseError as new (message: string) => Error;
|
||||
export const ServiceUnavailableError = roadmapSuggestions.ServiceUnavailableError as new (message: string) => Error;
|
||||
export const SUGGESTION_TIMEOUT_MS = roadmapSuggestions.SUGGESTION_TIMEOUT_MS as number;
|
||||
export const ValidationError = roadmapSuggestions.ValidationError as new (message: string) => Error;
|
||||
export const __resetSuggestionState = roadmapSuggestions.__resetSuggestionState as () => void;
|
||||
export const __setCreateAiSessionFactory = roadmapSuggestions.__setCreateAiSessionFactory as (factory: unknown) => void;
|
||||
export const __setCreateFnAgent = roadmapSuggestions.__setCreateFnAgent as (factory: unknown) => void;
|
||||
export const generateFeatureSuggestions = roadmapSuggestions.generateFeatureSuggestions as (...args: unknown[]) => Promise<unknown>;
|
||||
export const generateMilestoneSuggestions = roadmapSuggestions.generateMilestoneSuggestions as (...args: unknown[]) => Promise<unknown>;
|
||||
export const validateFeatureSuggestionInput = roadmapSuggestions.validateFeatureSuggestionInput as (input: unknown) => void;
|
||||
export const validateSuggestionInput = roadmapSuggestions.validateSuggestionInput as (input: unknown) => void;
|
||||
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";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "fusion-plugin-roadmap",
|
||||
"id": "roadmap-planner",
|
||||
"name": "Roadmaps",
|
||||
"version": "0.1.0",
|
||||
"description": "Standalone roadmap planning plugin",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user