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:
gsxdsm
2026-07-22 22:11:31 -07:00
parent 28e8c0abc9
commit adf51e21d7
8 changed files with 318 additions and 78 deletions

View 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.

View File

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

View File

@@ -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 () => {

View File

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

View File

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

View File

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

View File

@@ -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 ────────────────────────────
// //

View File

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