diff --git a/.changeset/dynamic-plugin-route-dispatch.md b/.changeset/dynamic-plugin-route-dispatch.md new file mode 100644 index 0000000000..38ac53189c --- /dev/null +++ b/.changeset/dynamic-plugin-route-dispatch.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Plugin API routes now work for plugins enabled after startup or enabled only in a non-launch project. +category: fix +dev: Plugin-defined HTTP routes are dispatched per request through the shared project-scoped PluginLoader resolution (routes/context.ts getProjectPluginLoader) instead of a boot-time snapshot of the launch project's loader. Fixes Compound Engineering "Failed to load sessions/artifacts: Not found" persisting on v0.73.0-beta.3. diff --git a/packages/dashboard/src/__tests__/plugin-routes-wiring.test.ts b/packages/dashboard/src/__tests__/plugin-routes-wiring.test.ts index 31fa044c61..5ef339a6df 100644 --- a/packages/dashboard/src/__tests__/plugin-routes-wiring.test.ts +++ b/packages/dashboard/src/__tests__/plugin-routes-wiring.test.ts @@ -185,4 +185,117 @@ describe("createPluginRouter wiring under /api/plugins", () => { expect(started.body).toEqual({ session: { id: "ce-1", status: "launching" } }); expect(startHandler).toHaveBeenCalled(); }); + + /* + FNXC:PluginRoutes 2026-07-22-20:30: + Plugin routes were a boot-time snapshot of the launch loader. Two failure modes survived + the loader-mount fix above: a plugin enabled AFTER boot rendered its dashboard view + (served live) while its API routes 404'd until restart, and a plugin enabled only in a + NON-LAUNCH project never got routes at all. Dispatch is per-request now — these tests + pin both invariants. + */ + it("serves routes for a plugin enabled after the router was created (no restart)", async () => { + const helloHandler = vi.fn(async () => ({ ok: true })); + const routeTable: Array<{ pluginId: string; route: { method: string; path: string; handler: unknown } }> = []; + + const pluginStore = { + listPlugins: vi.fn(async () => []), + getPlugin: vi.fn(async (id: string) => ({ id, settings: {}, enabled: true, manifest: { id, name: id, version: "1.0.0", description: "" } })), + enablePlugin: vi.fn(), + disablePlugin: vi.fn(), + registerPlugin: vi.fn(), + unregisterPlugin: vi.fn(), + updatePluginSettings: vi.fn(), + updatePluginState: vi.fn(), + } as any; + + const pluginLoader = { + getPlugin: vi.fn((id: string) => (id === "late-plugin" && routeTable.length > 0 ? { manifest: { id } } : undefined)), + getPluginRoutes: vi.fn(() => [...routeTable]), + createRouteContext: vi.fn(async () => ({ + pluginId: "late-plugin", + taskStore: {}, + settings: {}, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + emitEvent: vi.fn(), + })), + loadPlugin: vi.fn(), + stopPlugin: vi.fn(), + } as any; + + const app = express(); + app.use(express.json()); + app.use("/api/plugins", createPluginRouter(pluginStore, pluginLoader, undefined, {} as any)); + app.use((_req, res) => res.status(404).json({ error: "Not found" })); + + // Before enable: no routes exist for the plugin. + const before = await performGet(app, "/api/plugins/late-plugin/hello"); + expect(before.status).toBe(404); + + // Enable-after-boot: the loader's route table grows; no router rebuild or restart. + routeTable.push({ pluginId: "late-plugin", route: { method: "GET", path: "/hello", handler: helloHandler } }); + + const after = await performGet(app, "/api/plugins/late-plugin/hello"); + expect(after.status).toBe(200); + expect(after.body).toEqual({ ok: true }); + expect(helloHandler).toHaveBeenCalled(); + }); + + it("serves routes from the request's project-scoped loader and executes against it", async () => { + const projectHandler = vi.fn(async () => ({ project: true })); + + const pluginStore = { + listPlugins: vi.fn(async () => []), + getPlugin: vi.fn(async (id: string) => ({ id, settings: {}, enabled: true, manifest: { id, name: id, version: "1.0.0", description: "" } })), + enablePlugin: vi.fn(), + disablePlugin: vi.fn(), + registerPlugin: vi.fn(), + unregisterPlugin: vi.fn(), + updatePluginSettings: vi.fn(), + updatePluginState: vi.fn(), + } as any; + + // Launch/host loader has NO plugins — mirrors a daemon launched from a project + // where the plugin is not enabled. + const hostLoader = { + getPlugin: vi.fn(() => undefined), + getPluginRoutes: vi.fn(() => []), + createRouteContext: vi.fn(), + loadPlugin: vi.fn(), + stopPlugin: vi.fn(), + } as any; + + const projectRouteContext = { + pluginId: "project-only-plugin", + taskStore: {}, + settings: {}, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + emitEvent: vi.fn(), + }; + const projectLoader = { + getPlugin: vi.fn((id: string) => (id === "project-only-plugin" ? { manifest: { id } } : undefined)), + getPluginRoutes: vi.fn(() => [ + { pluginId: "project-only-plugin", route: { method: "GET", path: "/data", handler: projectHandler } }, + ]), + createRouteContext: vi.fn(async () => projectRouteContext), + loadPlugin: vi.fn(), + stopPlugin: vi.fn(), + } as any; + + const resolveProjectPluginLoader = vi.fn(async () => projectLoader); + + const app = express(); + app.use(express.json()); + app.use("/api/plugins", createPluginRouter(pluginStore, hostLoader, undefined, {} as any, resolveProjectPluginLoader)); + app.use((_req, res) => res.status(404).json({ error: "Not found" })); + + const res = await performGet(app, "/api/plugins/project-only-plugin/data"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ project: true }); + expect(projectHandler).toHaveBeenCalled(); + expect(resolveProjectPluginLoader).toHaveBeenCalled(); + // Execution resolved through the project loader, never the host loader. + expect(projectLoader.createRouteContext).toHaveBeenCalledWith("project-only-plugin", expect.anything()); + expect(hostLoader.createRouteContext).not.toHaveBeenCalled(); + }); }); diff --git a/packages/dashboard/src/__tests__/plugin-routes.routes.test.ts b/packages/dashboard/src/__tests__/plugin-routes.routes.test.ts index 17dda29251..e04b20f603 100644 --- a/packages/dashboard/src/__tests__/plugin-routes.routes.test.ts +++ b/packages/dashboard/src/__tests__/plugin-routes.routes.test.ts @@ -1326,10 +1326,14 @@ describe("DELETE /plugins/:id", () => { }); describe("plugin-defined route dispatch", () => { - it("registers PATCH routes from plugins", () => { + it("dispatches PATCH routes from plugins", async () => { + // FNXC:PluginRoutes 2026-07-22-20:30: plugin routes are dispatched dynamically per + // request now, so assert observable dispatch behavior instead of inspecting the + // boot-time Express router stack (which no longer contains plugin routes). + const patchHandler = vi.fn().mockResolvedValue({ patched: true }); const pluginRunner = { getPluginRoutes: vi.fn().mockReturnValue([ - { pluginId: "fusion-plugin-roadmap", route: { method: "PATCH", path: "/roadmaps/x", handler: vi.fn() } }, + { pluginId: "fusion-plugin-roadmap", route: { method: "PATCH", path: "/roadmaps/x", handler: patchHandler } }, ]), }; @@ -1345,9 +1349,14 @@ describe("plugin-defined route dispatch", () => { 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 } }>; - const patchRoute = stack.find((layer) => layer.route?.path === "/fusion-plugin-roadmap/roadmaps/x"); - expect(patchRoute?.route?.methods.patch).toBe(true); + const app = express(); + app.use(express.json()); + app.use("/api/plugins", router); + + const res = await REQUEST(app, "PATCH", "/api/plugins/fusion-plugin-roadmap/roadmaps/x", {}); + expect(res.status).toBe(200); + expect(res.body).toEqual({ patched: true }); + expect(patchHandler).toHaveBeenCalled(); }); it("passes scoped taskStore and createAiSession through pluginLoader.createRouteContext", async () => { diff --git a/packages/dashboard/src/plugin-routes.ts b/packages/dashboard/src/plugin-routes.ts index 369d75a18e..07fc6ba871 100644 --- a/packages/dashboard/src/plugin-routes.ts +++ b/packages/dashboard/src/plugin-routes.ts @@ -322,12 +322,17 @@ async function readAndValidateManifest( * @param pluginStore - Plugin store for persistence * @param pluginLoader - Plugin loader for lifecycle management * @param pluginRunner - Optional plugin runner for plugin-defined routes + * @param defaultTaskStore - Task store used when a request carries no projectId + * @param resolveProjectPluginLoader - Per-request project-scoped loader resolution + * (routes/context.ts getProjectPluginLoader); when absent, dispatch falls back to + * the host pluginLoader + pluginRunner route tables only. */ export function createPluginRouter( pluginStore: PluginStore, pluginLoader: PluginLoader, pluginRunner?: PluginRunner, defaultTaskStore?: import("@fusion/core").TaskStore, + resolveProjectPluginLoader?: (req: Request) => Promise, ): Router { const router = Router(); @@ -703,29 +708,67 @@ export function createPluginRouter( "Failed to load sessions/artifacts: Not found" (catch-all 404) on every CE API call while the stage cards painted normally. Prefer loader entries on key collisions so handlers resolve against the same pluginLoader instance. + + FNXC:PluginRoutes 2026-07-22-20:30: + Plugin routes are now dispatched DYNAMICALLY per request, not registered once at + boot. The boot-time snapshot had two live failure modes on v0.73.0-beta.3 even + after the fix above: (1) a plugin enabled after boot rendered its dashboard view + (served live via the project-scoped loader) while its routes stayed unmounted + until restart; (2) a plugin enabled only in a non-launch project NEVER got routes + mounted because the snapshot came from the launch project's loader. Dispatch + resolves the request's project-scoped loader (same routes/context.ts + getProjectPluginLoader cache the dashboard-views/enable endpoints use), unions in + the host loader + PluginRunner tables (project entries win, loader beats runner), + and executes each entry against the loader that owns its plugin instance. The + matching sub-router is cached per resolved loader and rebuilt only when the route + signature changes, so views and routes agree by construction. */ type PluginRouteEntry = { pluginId: string; route: import("@fusion/core").PluginRouteDefinition }; - const pluginRoutesByKey = new Map(); - const addPluginRoutes = (entries: PluginRouteEntry[]) => { - for (const entry of entries) { - const key = `${entry.pluginId}\0${entry.route.method}\0${entry.route.path}`; - pluginRoutesByKey.set(key, entry); - } - }; - if (pluginRunner && typeof pluginRunner.getPluginRoutes === "function") { - addPluginRoutes(pluginRunner.getPluginRoutes()); - } - const loaderRoutes = (pluginLoader as { getPluginRoutes?: () => PluginRouteEntry[] }).getPluginRoutes?.(); - if (loaderRoutes) { - addPluginRoutes(loaderRoutes); - } + type DispatchEntry = PluginRouteEntry & { execLoader: PluginLoader }; - for (const { pluginId, route } of pluginRoutesByKey.values()) { + const collectDispatchEntries = (resolvedLoader?: PluginLoader): Map => { + const byKey = new Map(); + // First writer wins: project-scoped loader, then host loader, then runner. + const addPluginRoutes = (entries: PluginRouteEntry[] | undefined, execLoader: PluginLoader) => { + if (!entries) return; + for (const entry of entries) { + const key = `${entry.pluginId}\0${entry.route.method}\0${entry.route.path}`; + if (!byKey.has(key)) { + byKey.set(key, { ...entry, execLoader }); + } + } + }; + if (resolvedLoader && resolvedLoader !== pluginLoader) { + addPluginRoutes((resolvedLoader as { getPluginRoutes?: () => PluginRouteEntry[] }).getPluginRoutes?.(), resolvedLoader); + } + addPluginRoutes((pluginLoader as { getPluginRoutes?: () => PluginRouteEntry[] }).getPluginRoutes?.(), pluginLoader); + if (pluginRunner && typeof pluginRunner.getPluginRoutes === "function") { + // Runner entries execute against the host loader, matching the pre-dynamic + // behavior where handlers always resolved through pluginLoader. + addPluginRoutes(pluginRunner.getPluginRoutes(), pluginLoader); + } + return byKey; + }; + + const buildDispatchRouter = (entries: Map): Router => { + const dispatchRouter = Router(); + for (const { pluginId, route, execLoader } of entries.values()) { + registerPluginRoute(dispatchRouter, pluginId, route, execLoader); + } + return dispatchRouter; + }; + + const registerPluginRoute = ( + targetRouter: Router, + pluginId: string, + route: import("@fusion/core").PluginRouteDefinition, + execLoader: PluginLoader, + ): void => { const fullPath = `/${pluginId}${route.path.startsWith("/") ? route.path : `/${route.path}`}`; const handler = catchHandler(async (req: Request, res: Response) => { // Get the plugin context - const plugin = pluginLoader.getPlugin(pluginId); + const plugin = execLoader.getPlugin(pluginId); if (!plugin) { throw notFound(`Plugin "${pluginId}" not loaded`); } @@ -759,7 +802,7 @@ export function createPluginRouter( } } - const ctx: PluginContext = await pluginLoader.createRouteContext(pluginId, { + const ctx: PluginContext = await execLoader.createRouteContext(pluginId, { taskStore, settings, resolveProjectTaskStore: getOrCreateProjectStore, @@ -808,22 +851,51 @@ export function createPluginRouter( switch (route.method) { case "GET": - router.get(fullPath, handler); + targetRouter.get(fullPath, handler); break; case "POST": - router.post(fullPath, handler); + targetRouter.post(fullPath, handler); break; case "PUT": - router.put(fullPath, handler); + targetRouter.put(fullPath, handler); break; case "PATCH": - router.patch(fullPath, handler); + targetRouter.patch(fullPath, handler); break; case "DELETE": - router.delete(fullPath, handler); + targetRouter.delete(fullPath, handler); break; } - } + }; + + // Cache the compiled dispatch router per resolved loader; the signature encodes + // route identity AND owning loader so enabling a plugin later (route set grows) + // or a plugin migrating from host to project loader both trigger a rebuild. + const dispatchRouterCache = new WeakMap(); + const dispatchSignature = (entries: Map, resolvedLoader?: PluginLoader): string => + [...entries.entries()] + .map(([key, entry]) => `${key}\0${entry.execLoader === resolvedLoader ? "p" : "h"}`) + .sort() + .join("\n"); + + router.use((req: Request, res: Response, next: import("express").NextFunction) => { + void (async () => { + const resolvedLoader = resolveProjectPluginLoader ? await resolveProjectPluginLoader(req) : undefined; + const entries = collectDispatchEntries(resolvedLoader); + if (entries.size === 0) { + next(); + return; + } + const signature = dispatchSignature(entries, resolvedLoader); + const cacheKey = resolvedLoader ?? pluginLoader; + let cached = dispatchRouterCache.get(cacheKey); + if (!cached || cached.signature !== signature) { + cached = { signature, router: buildDispatchRouter(entries) }; + dispatchRouterCache.set(cacheKey, cached); + } + cached.router(req, res, next); + })().catch(next); + }); return router; } diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 26476f6831..d8b720a689 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -885,6 +885,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout getProjectIdFromRequest, getScopedStore, getProjectContext, + getProjectPluginLoader, emitRemoteRouteDiagnostic, emitAuthSyncAuditLog, parseScopeParam, @@ -909,6 +910,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout getProjectIdFromRequest, getScopedStore, getProjectContext, + getProjectPluginLoader, emitRemoteRouteDiagnostic, emitAuthSyncAuditLog, parseScopeParam, @@ -2002,6 +2004,17 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout options.pluginLoader, pluginRunner, store, + /* + FNXC:PluginRoutes 2026-07-22-20:30: + Per-request project-scoped loader resolution for plugin-defined routes — the + same getProjectPluginLoader cache the plugin management registrar uses — so a + plugin enabled after boot, or enabled only in a non-launch project, serves its + API routes the moment its dashboard view appears. + */ + async (req) => { + const { store: scopedStore, engine } = await routeContext.getProjectContext(req); + return routeContext.getProjectPluginLoader(scopedStore, engine); + }, ), ); } diff --git a/packages/dashboard/src/routes/context.ts b/packages/dashboard/src/routes/context.ts index b2f8a59a48..e396cddecd 100644 --- a/packages/dashboard/src/routes/context.ts +++ b/packages/dashboard/src/routes/context.ts @@ -284,6 +284,63 @@ export function createApiRoutesContext(store: TaskStore, options?: ServerOptions } const resolveScopedStore = (req: Request): Promise => getScopedStore(req, store, options); + + /* + FNXC:PluginEnablementScope 2026-07-21-12:00: + Plugin installation metadata is global, but every enabled/state decision belongs to the + TaskStore selected for the request's project. A dashboard launched from project A must not + use A's host loader after mutating project B: that loader reads A's project_plugin_states row + and can immediately report B's newly enabled plugin as disabled. Prefer B's running engine + loader; only reuse the host loader when it owns the same PluginStore, otherwise bind a loader + directly to B's TaskStore. + + FNXC:PluginEnablementScope 2026-07-21-15:30: + A project without a live engine still needs one loader for its dashboard lifetime. + Creating one per request starts a plugin in enable(), then loses that instance before + disable(), UI-slot, contribution, and runtime reads. Cache by the resolved TaskStore so + all fallback readers share the same project_plugin_states key and loaded plugin instance. + + FNXC:PluginEnablementScope 2026-07-22-20:30: + Moved here from register-plugins-automation.ts so plugin-defined HTTP route dispatch + (plugin-routes.ts) resolves through the SAME loader cache as dashboard-views/ui-slots/ + enable/disable. When these used separate loader instances, a plugin enabled after boot or + enabled only in a non-launch project rendered its dashboard view while every one of its + API routes 404'd (Compound Engineering "Failed to load sessions: Not found"). + */ + const fallbackProjectLoaders = new WeakMap; + }>(); + + const getProjectPluginLoader = async ( + scopedStore: TaskStore, + engine?: { getPluginRunner?: () => { getLoader?: () => PluginLoader } | undefined }, + ): Promise => { + const engineLoader = engine?.getPluginRunner?.()?.getLoader?.(); + if (engineLoader) return engineLoader; + + const scopedPluginStore = scopedStore.getPluginStore(); + if (scopedPluginStore === options?.pluginStore) return options?.pluginLoader; + + let fallback = fallbackProjectLoaders.get(scopedStore); + if (!fallback) { + const loader = new PluginLoader({ pluginStore: scopedPluginStore as PluginStore, taskStore: scopedStore }); + /* + FNXC:PluginEnablementScope 2026-07-21-20:15: + Dashboard-only projects lack an engine startup pass, so initialize their persistent scoped + loader once before introspection. Reusing this promise prevents ui-slots, contributions, + runtimes, and views from observing an empty loader after a dashboard restart. + */ + fallback = { + loader, + initialized: loader.loadAllPlugins().then(() => undefined), + }; + fallbackProjectLoaders.set(scopedStore, fallback); + } + await fallback.initialized; + return fallback.loader; + }; + const fallbackMcpLoaders = new WeakMap }>(); const projectMcpProviders = new WeakMap>(); @@ -486,6 +543,7 @@ export function createApiRoutesContext(store: TaskStore, options?: ServerOptions getProjectIdFromRequest, getScopedStore: resolveScopedStore, getProjectContext: resolveProjectContext, + getProjectPluginLoader, emitRemoteRouteDiagnostic: (input) => emitRemoteRouteDiagnostic(runtimeLogger, input), emitAuthSyncAuditLog, parseScopeParam, diff --git a/packages/dashboard/src/routes/register-plugins-automation.ts b/packages/dashboard/src/routes/register-plugins-automation.ts index f58f74a8fb..905e3c5152 100644 --- a/packages/dashboard/src/routes/register-plugins-automation.ts +++ b/packages/dashboard/src/routes/register-plugins-automation.ts @@ -1,5 +1,5 @@ import type { NextFunction, Request, Response } from "express"; -import { AutomationStore, PluginLoader, RoutineStore, isWebhookTrigger, resolvePluginEntryPath, type RoutineTriggerType, type ScheduleType } from "@fusion/core"; +import { AutomationStore, RoutineStore, isWebhookTrigger, resolvePluginEntryPath, type RoutineTriggerType, type ScheduleType } from "@fusion/core"; import { ApiError, badRequest, conflict, internalError, notFound } from "../api-error.js"; import { verifyWebhookSignature } from "../github-webhooks.js"; import { resolvePluginManifest } from "../plugin-routes.js"; @@ -21,58 +21,16 @@ FNXC:PluginsAutomationRoutes 2026-07-19-12:00: Automation, routine, and plugin-management endpoints live in this registrar so routes.ts remains an orchestrator. Preserve registration order: Express parameter matching makes operation paths and the registry pass-through precedence-sensitive. */ export function registerPluginsAutomationRoutes(ctx: ApiRoutesContext, deps: PluginsAutomationRouteDependencies): void { - const { router, options, parseScopeParam, resolveAutomationStore, resolveRoutineStore, resolveRoutineRunner, getScopedStore, getProjectContext, rethrowAsApiError, runtimeLogger } = ctx; + const { router, options, parseScopeParam, resolveAutomationStore, resolveRoutineStore, resolveRoutineRunner, getScopedStore, getProjectContext, getProjectPluginLoader, rethrowAsApiError, runtimeLogger } = ctx; const makeRunStreamHandler = createAutomationRunStreamHandlerFactory({ parseScopeParam, rethrowAsApiError, ...deps }); /* - FNXC:PluginEnablementScope 2026-07-21-12:00: - Plugin installation metadata is global, but every enabled/state decision belongs to the - TaskStore selected for the request's project. A dashboard launched from project A must not - use A's host loader after mutating project B: that loader reads A's project_plugin_states row - and can immediately report B's newly enabled plugin as disabled. Prefer B's running engine - loader; only reuse the host loader when it owns the same PluginStore, otherwise bind a loader - directly to B's TaskStore. + FNXC:PluginEnablementScope 2026-07-22-20:30: + getProjectPluginLoader moved to routes/context.ts so plugin-defined HTTP route dispatch + (plugin-routes.ts) shares the same project-scoped loader cache as the management and + introspection routes below. Do not re-introduce a registrar-local loader cache: split + caches are how dashboard-views could show a plugin whose API routes 404'd. */ - /* - FNXC:PluginEnablementScope 2026-07-21-15:30: - A project without a live engine still needs one loader for its dashboard lifetime. - Creating one per request starts a plugin in enable(), then loses that instance before - disable(), UI-slot, contribution, and runtime reads. Cache by the resolved TaskStore so - all fallback readers share the same project_plugin_states key and loaded plugin instance. - */ - const fallbackProjectLoaders = new WeakMap; - }>(); - - const getProjectPluginLoader = async ( - scopedStore: import("@fusion/core").TaskStore, - engine?: { getPluginRunner?: () => { getLoader?: () => PluginLoader } | undefined }, - ): Promise => { - const engineLoader = engine?.getPluginRunner?.()?.getLoader?.(); - if (engineLoader) return engineLoader; - - const scopedPluginStore = scopedStore.getPluginStore(); - if (scopedPluginStore === options?.pluginStore) return options?.pluginLoader; - - let fallback = fallbackProjectLoaders.get(scopedStore); - if (!fallback) { - const loader = new PluginLoader({ pluginStore: scopedPluginStore, taskStore: scopedStore }); - /* - FNXC:PluginEnablementScope 2026-07-21-20:15: - Dashboard-only projects lack an engine startup pass, so initialize their persistent scoped - loader once before introspection. Reusing this promise prevents ui-slots, contributions, - runtimes, and views from observing an empty loader after a dashboard restart. - */ - fallback = { - loader, - initialized: loader.loadAllPlugins().then(() => undefined), - }; - fallbackProjectLoaders.set(scopedStore, fallback); - } - await fallback.initialized; - return fallback.loader; - }; // ── Automation / Scheduled Task Routes ──────────────────────────── // diff --git a/packages/dashboard/src/routes/types.ts b/packages/dashboard/src/routes/types.ts index 12c6f9125e..4253ebfa82 100644 --- a/packages/dashboard/src/routes/types.ts +++ b/packages/dashboard/src/routes/types.ts @@ -1,5 +1,5 @@ import type { Request, RequestHandler, Router } from "express"; -import type { AutomationStore, RoutineStore, TaskStore } from "@fusion/core"; +import type { AutomationStore, PluginLoader, RoutineStore, TaskStore } from "@fusion/core"; import type { ServerOptions } from "../server.js"; import type { RuntimeLogger } from "../runtime-logger.js"; @@ -51,6 +51,16 @@ export interface ApiRoutesContext { getProjectIdFromRequest(req: Request): string | undefined; getScopedStore(req: Request): Promise; getProjectContext(req: Request): Promise; + /* + FNXC:PluginEnablementScope 2026-07-22-20:30: + Single per-request plugin-loader resolution shared by plugin management/introspection + routes AND plugin-defined HTTP route dispatch, so navigation (dashboard-views) and the + routes it calls can never disagree about which plugins exist for a project. + */ + getProjectPluginLoader( + scopedStore: TaskStore, + engine?: { getPluginRunner?: () => { getLoader?: () => PluginLoader } | undefined }, + ): Promise; prioritizeProjectsForCurrentDirectory(projects: T[]): T[]; emitRemoteRouteDiagnostic(input: RemoteRouteDiagnosticInput): void; emitAuthSyncAuditLog(input: AuthSyncAuditLogInput): void;