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(started.body).toEqual({ session: { id: "ce-1", status: "launching" } });
|
||||||
expect(startHandler).toHaveBeenCalled();
|
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", () => {
|
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 = {
|
const pluginRunner = {
|
||||||
getPluginRoutes: vi.fn().mockReturnValue([
|
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" } }),
|
getPlugin: vi.fn().mockReturnValue({ manifest: { id: "fusion-plugin-roadmap" } }),
|
||||||
} as any), pluginRunner as any, createMockTaskStore());
|
} as any), pluginRunner as any, createMockTaskStore());
|
||||||
|
|
||||||
const stack = (router as any).stack as Array<{ route?: { path: string; methods: Record<string, boolean> } }>;
|
const app = express();
|
||||||
const patchRoute = stack.find((layer) => layer.route?.path === "/fusion-plugin-roadmap/roadmaps/x");
|
app.use(express.json());
|
||||||
expect(patchRoute?.route?.methods.patch).toBe(true);
|
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 () => {
|
it("passes scoped taskStore and createAiSession through pluginLoader.createRouteContext", async () => {
|
||||||
|
|||||||
@@ -322,12 +322,17 @@ async function readAndValidateManifest(
|
|||||||
* @param pluginStore - Plugin store for persistence
|
* @param pluginStore - Plugin store for persistence
|
||||||
* @param pluginLoader - Plugin loader for lifecycle management
|
* @param pluginLoader - Plugin loader for lifecycle management
|
||||||
* @param pluginRunner - Optional plugin runner for plugin-defined routes
|
* @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(
|
export function createPluginRouter(
|
||||||
pluginStore: PluginStore,
|
pluginStore: PluginStore,
|
||||||
pluginLoader: PluginLoader,
|
pluginLoader: PluginLoader,
|
||||||
pluginRunner?: PluginRunner,
|
pluginRunner?: PluginRunner,
|
||||||
defaultTaskStore?: import("@fusion/core").TaskStore,
|
defaultTaskStore?: import("@fusion/core").TaskStore,
|
||||||
|
resolveProjectPluginLoader?: (req: Request) => Promise<PluginLoader | undefined>,
|
||||||
): Router {
|
): Router {
|
||||||
const router = 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
|
"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
|
call while the stage cards painted normally. Prefer loader entries on key
|
||||||
collisions so handlers resolve against the same pluginLoader instance.
|
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 };
|
type PluginRouteEntry = { pluginId: string; route: import("@fusion/core").PluginRouteDefinition };
|
||||||
const pluginRoutesByKey = new Map<string, PluginRouteEntry>();
|
type DispatchEntry = PluginRouteEntry & { execLoader: PluginLoader };
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 fullPath = `/${pluginId}${route.path.startsWith("/") ? route.path : `/${route.path}`}`;
|
||||||
|
|
||||||
const handler = catchHandler(async (req: Request, res: Response) => {
|
const handler = catchHandler(async (req: Request, res: Response) => {
|
||||||
// Get the plugin context
|
// Get the plugin context
|
||||||
const plugin = pluginLoader.getPlugin(pluginId);
|
const plugin = execLoader.getPlugin(pluginId);
|
||||||
if (!plugin) {
|
if (!plugin) {
|
||||||
throw notFound(`Plugin "${pluginId}" not loaded`);
|
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,
|
taskStore,
|
||||||
settings,
|
settings,
|
||||||
resolveProjectTaskStore: getOrCreateProjectStore,
|
resolveProjectTaskStore: getOrCreateProjectStore,
|
||||||
@@ -808,22 +851,51 @@ export function createPluginRouter(
|
|||||||
|
|
||||||
switch (route.method) {
|
switch (route.method) {
|
||||||
case "GET":
|
case "GET":
|
||||||
router.get(fullPath, handler);
|
targetRouter.get(fullPath, handler);
|
||||||
break;
|
break;
|
||||||
case "POST":
|
case "POST":
|
||||||
router.post(fullPath, handler);
|
targetRouter.post(fullPath, handler);
|
||||||
break;
|
break;
|
||||||
case "PUT":
|
case "PUT":
|
||||||
router.put(fullPath, handler);
|
targetRouter.put(fullPath, handler);
|
||||||
break;
|
break;
|
||||||
case "PATCH":
|
case "PATCH":
|
||||||
router.patch(fullPath, handler);
|
targetRouter.patch(fullPath, handler);
|
||||||
break;
|
break;
|
||||||
case "DELETE":
|
case "DELETE":
|
||||||
router.delete(fullPath, handler);
|
targetRouter.delete(fullPath, handler);
|
||||||
break;
|
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;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -885,6 +885,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
getProjectIdFromRequest,
|
getProjectIdFromRequest,
|
||||||
getScopedStore,
|
getScopedStore,
|
||||||
getProjectContext,
|
getProjectContext,
|
||||||
|
getProjectPluginLoader,
|
||||||
emitRemoteRouteDiagnostic,
|
emitRemoteRouteDiagnostic,
|
||||||
emitAuthSyncAuditLog,
|
emitAuthSyncAuditLog,
|
||||||
parseScopeParam,
|
parseScopeParam,
|
||||||
@@ -909,6 +910,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
getProjectIdFromRequest,
|
getProjectIdFromRequest,
|
||||||
getScopedStore,
|
getScopedStore,
|
||||||
getProjectContext,
|
getProjectContext,
|
||||||
|
getProjectPluginLoader,
|
||||||
emitRemoteRouteDiagnostic,
|
emitRemoteRouteDiagnostic,
|
||||||
emitAuthSyncAuditLog,
|
emitAuthSyncAuditLog,
|
||||||
parseScopeParam,
|
parseScopeParam,
|
||||||
@@ -2002,6 +2004,17 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
options.pluginLoader,
|
options.pluginLoader,
|
||||||
pluginRunner,
|
pluginRunner,
|
||||||
store,
|
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);
|
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 fallbackMcpLoaders = new WeakMap<TaskStore, { loader: PluginLoader; initialized: Promise<void> }>();
|
||||||
const projectMcpProviders = new WeakMap<PluginLoader, ReturnType<typeof createProjectScopedPluginMcpProvider>>();
|
const projectMcpProviders = new WeakMap<PluginLoader, ReturnType<typeof createProjectScopedPluginMcpProvider>>();
|
||||||
|
|
||||||
@@ -486,6 +543,7 @@ export function createApiRoutesContext(store: TaskStore, options?: ServerOptions
|
|||||||
getProjectIdFromRequest,
|
getProjectIdFromRequest,
|
||||||
getScopedStore: resolveScopedStore,
|
getScopedStore: resolveScopedStore,
|
||||||
getProjectContext: resolveProjectContext,
|
getProjectContext: resolveProjectContext,
|
||||||
|
getProjectPluginLoader,
|
||||||
emitRemoteRouteDiagnostic: (input) => emitRemoteRouteDiagnostic(runtimeLogger, input),
|
emitRemoteRouteDiagnostic: (input) => emitRemoteRouteDiagnostic(runtimeLogger, input),
|
||||||
emitAuthSyncAuditLog,
|
emitAuthSyncAuditLog,
|
||||||
parseScopeParam,
|
parseScopeParam,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { NextFunction, Request, Response } from "express";
|
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 { ApiError, badRequest, conflict, internalError, notFound } from "../api-error.js";
|
||||||
import { verifyWebhookSignature } from "../github-webhooks.js";
|
import { verifyWebhookSignature } from "../github-webhooks.js";
|
||||||
import { resolvePluginManifest } from "../plugin-routes.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.
|
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 {
|
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 });
|
const makeRunStreamHandler = createAutomationRunStreamHandlerFactory({ parseScopeParam, rethrowAsApiError, ...deps });
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:PluginEnablementScope 2026-07-21-12:00:
|
FNXC:PluginEnablementScope 2026-07-22-20:30:
|
||||||
Plugin installation metadata is global, but every enabled/state decision belongs to the
|
getProjectPluginLoader moved to routes/context.ts so plugin-defined HTTP route dispatch
|
||||||
TaskStore selected for the request's project. A dashboard launched from project A must not
|
(plugin-routes.ts) shares the same project-scoped loader cache as the management and
|
||||||
use A's host loader after mutating project B: that loader reads A's project_plugin_states row
|
introspection routes below. Do not re-introduce a registrar-local loader cache: split
|
||||||
and can immediately report B's newly enabled plugin as disabled. Prefer B's running engine
|
caches are how dashboard-views could show a plugin whose API routes 404'd.
|
||||||
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.
|
|
||||||
*/
|
|
||||||
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 ────────────────────────────
|
// ── Automation / Scheduled Task Routes ────────────────────────────
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Request, RequestHandler, Router } from "express";
|
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 { ServerOptions } from "../server.js";
|
||||||
import type { RuntimeLogger } from "../runtime-logger.js";
|
import type { RuntimeLogger } from "../runtime-logger.js";
|
||||||
|
|
||||||
@@ -51,6 +51,16 @@ export interface ApiRoutesContext {
|
|||||||
getProjectIdFromRequest(req: Request): string | undefined;
|
getProjectIdFromRequest(req: Request): string | undefined;
|
||||||
getScopedStore(req: Request): Promise<TaskStore>;
|
getScopedStore(req: Request): Promise<TaskStore>;
|
||||||
getProjectContext(req: Request): Promise<ProjectContext>;
|
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[];
|
prioritizeProjectsForCurrentDirectory<T extends { path: string }>(projects: T[]): T[];
|
||||||
emitRemoteRouteDiagnostic(input: RemoteRouteDiagnosticInput): void;
|
emitRemoteRouteDiagnostic(input: RemoteRouteDiagnosticInput): void;
|
||||||
emitAuthSyncAuditLog(input: AuthSyncAuditLogInput): void;
|
emitAuthSyncAuditLog(input: AuthSyncAuditLogInput): void;
|
||||||
|
|||||||
Reference in New Issue
Block a user