fix(dashboard): dispatch plugin routes dynamically per request
Plugin-defined HTTP routes were a boot-time snapshot of the launch
project's PluginLoader, while dashboard views/UI slots resolve a
project-scoped loader live per request. Two failure modes survived the
961edf214 no-engine mount fix: a plugin enabled after boot rendered its
view while every API route 404'd until restart, and a plugin enabled
only in a non-launch project never got routes mounted at all (Compound
Engineering "Failed to load sessions: Not found" on v0.73.0-beta.3).
Routes are now dispatched per request through the same
getProjectPluginLoader cache (moved from the plugins registrar into
routes/context.ts) that serves dashboard-views and enable/disable, with
the host loader + PluginRunner tables unioned in (project entries win,
loader beats runner). The compiled dispatch sub-router is cached per
resolved loader and rebuilt only when the route signature changes, so
views and routes agree by construction.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
7
.changeset/dynamic-plugin-route-dispatch.md
Normal file
7
.changeset/dynamic-plugin-route-dispatch.md
Normal file
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, boolean> } }>;
|
||||
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 () => {
|
||||
|
||||
@@ -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<PluginLoader | undefined>,
|
||||
): 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<string, PluginRouteEntry>();
|
||||
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<string, DispatchEntry> => {
|
||||
const byKey = new Map<string, DispatchEntry>();
|
||||
// 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<string, DispatchEntry>): 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<PluginLoader, { signature: string; router: Router }>();
|
||||
const dispatchSignature = (entries: Map<string, DispatchEntry>, 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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -284,6 +284,63 @@ export function createApiRoutesContext(store: TaskStore, options?: ServerOptions
|
||||
}
|
||||
|
||||
const resolveScopedStore = (req: Request): Promise<TaskStore> => 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<TaskStore, {
|
||||
loader: PluginLoader;
|
||||
initialized: Promise<void>;
|
||||
}>();
|
||||
|
||||
const getProjectPluginLoader = async (
|
||||
scopedStore: TaskStore,
|
||||
engine?: { getPluginRunner?: () => { getLoader?: () => PluginLoader } | undefined },
|
||||
): Promise<PluginLoader | undefined> => {
|
||||
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<TaskStore, { loader: PluginLoader; initialized: Promise<void> }>();
|
||||
const projectMcpProviders = new WeakMap<PluginLoader, ReturnType<typeof createProjectScopedPluginMcpProvider>>();
|
||||
|
||||
@@ -486,6 +543,7 @@ export function createApiRoutesContext(store: TaskStore, options?: ServerOptions
|
||||
getProjectIdFromRequest,
|
||||
getScopedStore: resolveScopedStore,
|
||||
getProjectContext: resolveProjectContext,
|
||||
getProjectPluginLoader,
|
||||
emitRemoteRouteDiagnostic: (input) => emitRemoteRouteDiagnostic(runtimeLogger, input),
|
||||
emitAuthSyncAuditLog,
|
||||
parseScopeParam,
|
||||
|
||||
@@ -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<import("@fusion/core").TaskStore, {
|
||||
loader: PluginLoader;
|
||||
initialized: Promise<void>;
|
||||
}>();
|
||||
|
||||
const getProjectPluginLoader = async (
|
||||
scopedStore: import("@fusion/core").TaskStore,
|
||||
engine?: { getPluginRunner?: () => { getLoader?: () => PluginLoader } | undefined },
|
||||
): Promise<PluginLoader | undefined> => {
|
||||
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 ────────────────────────────
|
||||
//
|
||||
|
||||
@@ -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<TaskStore>;
|
||||
getProjectContext(req: Request): Promise<ProjectContext>;
|
||||
/*
|
||||
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<PluginLoader | undefined>;
|
||||
prioritizeProjectsForCurrentDirectory<T extends { path: string }>(projects: T[]): T[];
|
||||
emitRemoteRouteDiagnostic(input: RemoteRouteDiagnosticInput): void;
|
||||
emitAuthSyncAuditLog(input: AuthSyncAuditLogInput): void;
|
||||
|
||||
Reference in New Issue
Block a user