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

View File

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

View File

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

View File

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

View File

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

View File

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