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

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

View File

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

View File

@@ -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>;
}
/**

View File

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

View File

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

View File

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

View File

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