FN-8467: align project plugin enablement state

Keep project-scoped plugin enablement consistent across dashboard and loaders.

- Resolve plugin loaders from the requested project's store and reuse scoped fallback loaders.
- Preserve authoritative enable and disable responses during dashboard refresh races.
- Add route and manager regression coverage, documentation, and a patch changeset.

Files changed:
 .../fn-8467-plugin-enable-state-consistency.md     |   7 ++
 docs/multi-project.md                              |   1 +
 docs/plugin-management.md                          |   4 +-
 .../dashboard/app/components/PluginManager.tsx     |  36 ++++--
 .../__tests__/PluginManager.toggle.test.tsx        |  38 ++++++
 .../src/__tests__/plugin-routes.routes.test.ts     | 134 ++++++++++++++++++---
 .../src/routes/register-plugins-automation.ts      | 118 +++++++++++++-----
 7 files changed, 281 insertions(+), 57 deletions(-)

Fusion-Task-Id: FN-8467
Fusion-Task-Lineage: 697a0783-9771-4140-bc82-53491a2d0b70
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-21 19:35:03 -07:00
parent 746d33e7e6
commit 859475d0bd
7 changed files with 281 additions and 57 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep plugin enable state consistent across UI and loaders after toggle.
category: fix
dev: Unify project_plugin_states reads so host/engine/UI use the same per-project enablement key (issue #2383 / FN-8467).

View File

@@ -160,6 +160,7 @@ Operationally:
- `install` / `uninstall` are global actions
- `enable` / `disable` and runtime state/error are project-scoped
- A single global plugin install can be enabled in one project and disabled in another
- The Plugin Manager list/toggle, lifecycle SSE stream, and every loader for a project resolve the same normalized project root key. An enable or disable response is reflected immediately; a daemon launch directory never substitutes its state when an explicit project is selected.
## Isolation Modes

View File

@@ -118,9 +118,9 @@ Expected outcome: Plugin is installed from the specified path and visible in plu
2. Toggle plugin enable/disable controls.
3. Use reload controls when available.
> Enable/disable is project-scoped and only affects the current project.
> Enable/disable is project-scoped and only affects the current project. Fusion resolves the toggle response, plugin list, lifecycle updates, and loaders from the same normalized project root, so the manager reflects the new state immediately without a restart.
Expected outcome: Plugin transitions between runtime states (`started` / `stopped`) and reflects transitions in the manager.
Expected outcome: Plugin transitions between runtime states (`started` / `stopped`) and reflects transitions in the manager immediately.
### CLI

View File

@@ -297,15 +297,25 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
const [togglingBuiltinRuntimeId, setTogglingBuiltinRuntimeId] = useState<string | null>(null);
const { confirm } = useConfirm();
const loadPlugins = useCallback(async () => {
const loadPlugins = useCallback(async (background = false, mutationResponse?: PluginInstallation) => {
try {
setLoading(true);
if (!background) setLoading(true);
const data = await fetchPlugins(projectId);
setPlugins(data);
/*
FNXC:PluginEnablementScope 2026-07-21-16:30:
A successful project-scoped toggle response is authoritative for its immediate confirmation
refresh. Preserve it when that refresh returns a stale or host-scoped list so a completed
enable/disable request cannot flip the switch back until a later explicit refresh or SSE event.
*/
setPlugins(mutationResponse
? data.some((entry) => entry.id === mutationResponse.id)
? data.map((entry) => entry.id === mutationResponse.id ? mutationResponse : entry)
: [...data, mutationResponse]
: data);
} catch (err) {
addToast(t("plugins.loadFailed", "Failed to load plugins: {{error}}", { error: err instanceof Error ? err.message : String(err) }), "error");
} finally {
setLoading(false);
if (!background) setLoading(false);
}
}, [projectId, addToast]);
@@ -563,6 +573,13 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
const handleEnable = async (plugin: PluginInstallation) => {
try {
const enabledPlugin = await enablePlugin(plugin.id, projectId);
/*
FNXC:PluginEnablementScope 2026-07-21-12:00:
Apply the project-scoped enable response before the background list refresh. This keeps the
switch truthful even when lifecycle SSE races the refresh; SSE filtering below rejects
events attributed to another project instead of overwriting this project’s enabled state.
*/
setPlugins((previous) => previous.map((entry) => entry.id === enabledPlugin.id ? enabledPlugin : entry));
if (enabledPlugin.state === "error") {
addToast(t("plugins.enableFailed", "Failed to enable {{name}}: {{error}}", { name: plugin.name, error: enabledPlugin.error ?? t("plugins.unknownError", "unknown error") }), "error");
await loadPlugins();
@@ -570,7 +587,9 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
}
addToast(t("plugins.enabledForProject", "{{name}} enabled for this project", { name: plugin.name }), "success");
await loadPlugins();
// FNXC:PluginEnablementScope 2026-07-21-16:30: Preserve this authoritative
// response if the single confirmation refresh races a stale scoped-list read.
await loadPlugins(true, enabledPlugin);
} catch (err) {
addToast(t("plugins.enablePluginFailed", "Failed to enable plugin: {{error}}", { error: err instanceof Error ? err.message : String(err) }), "error");
}
@@ -578,9 +597,10 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
const handleDisable = async (plugin: PluginInstallation) => {
try {
await disablePlugin(plugin.id, projectId);
const disabledPlugin = await disablePlugin(plugin.id, projectId);
setPlugins((previous) => previous.map((entry) => entry.id === disabledPlugin.id ? disabledPlugin : entry));
addToast(t("plugins.disabledForProject", "{{name}} disabled for this project", { name: plugin.name }), "success");
await loadPlugins();
await loadPlugins(true, disabledPlugin);
} catch (err) {
addToast(t("plugins.disablePluginFailed", "Failed to disable plugin: {{error}}", { error: err instanceof Error ? err.message : String(err) }), "error");
}
@@ -1252,7 +1272,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
<div className="plugin-manager-header">
<span className="plugin-manager-header-title">{t("plugins.installedPlugins", "Installed Plugins")}</span>
<div className="plugin-manager-actions">
<button className="btn btn-sm" onClick={loadPlugins} title={t("plugins.refresh", "Refresh")} aria-label={t("plugins.refreshPluginList", "Refresh plugin list")}>
<button className="btn btn-sm" onClick={() => void loadPlugins()} title={t("plugins.refresh", "Refresh")} aria-label={t("plugins.refreshPluginList", "Refresh plugin list")}>
<RefreshCw size={14} className={loading ? "spin" : ""} />
{t("plugins.refresh", "Refresh")}
</button>

View File

@@ -116,6 +116,44 @@ describe("PluginManager toggle switch", () => {
});
});
it("preserves the scoped enable response after a stale background refresh resolves", async () => {
vi.mocked(fetchPlugins)
.mockResolvedValueOnce([plugin(false)])
// The confirmation request can return the original host-scoped false value.
.mockResolvedValueOnce([plugin(false)]);
vi.mocked(enablePlugin).mockResolvedValueOnce(plugin(true));
render(<PluginManager addToast={addToast} projectId="project-p" />);
const checkbox = await screen.findByRole("checkbox", { name: "Enable Test Plugin A" });
await userEvent.click(checkbox.closest("label.toggle-switch") as HTMLLabelElement);
await waitFor(() => {
expect(enablePlugin).toHaveBeenCalledWith("plugin-a", "project-p");
expect(fetchPlugins).toHaveBeenCalledTimes(2);
expect(screen.getByRole("checkbox", { name: "Disable Test Plugin A" })).toBeChecked();
});
});
it("preserves the scoped disable response after a stale background refresh resolves", async () => {
vi.mocked(fetchPlugins)
.mockResolvedValueOnce([plugin(true)])
// The reciprocal stale response must not re-enable a plugin just disabled for P.
.mockResolvedValueOnce([plugin(true)]);
vi.mocked(disablePlugin).mockResolvedValueOnce(plugin(false));
render(<PluginManager addToast={addToast} projectId="project-p" />);
const checkbox = await screen.findByRole("checkbox", { name: "Disable Test Plugin A" });
await userEvent.click(checkbox.closest("label.toggle-switch") as HTMLLabelElement);
await waitFor(() => {
expect(disablePlugin).toHaveBeenCalledWith("plugin-a", "project-p");
expect(fetchPlugins).toHaveBeenCalledTimes(2);
expect(screen.getByRole("checkbox", { name: "Enable Test Plugin A" })).not.toBeChecked();
});
});
it("renders slider next to input and reflects enabled state", async () => {
vi.mocked(fetchPlugins).mockResolvedValue([plugin(true)]);
const first = render(<PluginManager addToast={addToast} />);

View File

@@ -2,10 +2,12 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import express from "express";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { TaskStore } from "@fusion/core";
import type { PluginInstallation } from "@fusion/core";
import type { PluginStore } from "@fusion/core";
import type { PluginLoader } from "@fusion/core";
import { PluginLoader, type PluginInstallation, type PluginStore } from "@fusion/core";
import { PluginRunner } from "@fusion/engine";
import { createApiRoutes } from "../routes.js";
import { createPluginRouter } from "../plugin-routes.js";
import { get as performGet, request as performRequest } from "../test-request.js";
@@ -53,10 +55,21 @@ function createMockPluginLoader(overrides: Partial<PluginLoader> = {}): PluginLo
getLoadedPlugins: vi.fn().mockReturnValue([]),
getPluginTools: vi.fn().mockReturnValue([]),
getPluginRoutes: vi.fn().mockReturnValue([]),
getPluginUiSlots: vi.fn().mockReturnValue([]),
getPluginUiContributions: vi.fn().mockReturnValue([]),
getPluginRuntimes: vi.fn().mockReturnValue([]),
getCliProviderContributions: vi.fn().mockReturnValue([]),
getPluginSkills: vi.fn().mockReturnValue([]),
getPluginWorkflowSteps: vi.fn().mockReturnValue([]),
getPluginWorkflowExtensions: vi.fn().mockReturnValue([]),
getPluginWorkflowStepTemplates: vi.fn().mockReturnValue([]),
getPluginPromptContributions: vi.fn().mockReturnValue([]),
loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }),
stopAllPlugins: vi.fn().mockResolvedValue(undefined),
invokeHook: vi.fn().mockResolvedValue(undefined),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
on: vi.fn(),
off: vi.fn(),
createRouteContext: vi.fn().mockImplementation(async (pluginId: string, ctx: Record<string, unknown>) => ({
pluginId,
taskStore: ctx.taskStore,
@@ -654,21 +667,106 @@ describe("POST /plugins/:id/enable", () => {
expect(pluginLoader.loadPlugin).toHaveBeenCalledWith("test-plugin");
});
it("supports body-based projectId scoping", async () => {
// Set up mock for scoped store with projectId
const scopedPluginStore = createMockPluginStore();
(scopedPluginStore.enablePlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
it("initializes one fallback loader before project-scoped introspection", async () => {
const scopedPluginStore = createMockPluginStore({ listPlugins: vi.fn().mockResolvedValue([]) });
const scopedStore = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(scopedPluginStore),
on: vi.fn(),
off: vi.fn(),
});
mockGetOrCreateProjectStore.mockResolvedValue(scopedStore);
const loadAll = vi.spyOn(PluginLoader.prototype, "loadAllPlugins");
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { pluginStore, pluginLoader }));
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/enable", {
projectId: "proj_123",
try {
expect((await GET(app, "/api/plugins/ui-slots?projectId=project-p")).status).toBe(200);
expect((await GET(app, "/api/plugins/ui-contributions?projectId=project-p")).status).toBe(200);
expect(loadAll).toHaveBeenCalledTimes(1);
} finally {
loadAll.mockRestore();
}
});
it("keeps the enable route and real engine startup reader scoped to the managed project", async () => {
const hostLoader = pluginLoader;
const pluginDir = await mkdtemp(join(tmpdir(), "fusion-plugin-scope-"));
const entryPath = join(pluginDir, "plugin.mjs");
await writeFile(entryPath, `
const plugin = {
manifest: { id: "test-plugin", name: "Test Plugin", version: "1.0.0" },
hooks: {}, tools: [], routes: [],
};
export default plugin;
`);
const projectPlugin: PluginInstallation = {
...FAKE_PLUGIN,
enabled: false,
path: entryPath,
state: "installed",
};
const projectPluginStore = createMockPluginStore({
enablePlugin: vi.fn(async () => {
projectPlugin.enabled = true;
return { ...projectPlugin };
}),
getPlugin: vi.fn(async () => ({ ...projectPlugin })),
listPlugins: vi.fn(async (filter?: { enabled?: boolean }) =>
filter?.enabled === true && !projectPlugin.enabled ? [] : [{ ...projectPlugin }]),
updatePluginState: vi.fn(async (_id, state) => {
projectPlugin.state = state as PluginInstallation["state"];
return { ...projectPlugin };
}),
on: vi.fn(),
off: vi.fn(),
});
const projectStore = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(projectPluginStore),
getRootDir: vi.fn().mockReturnValue("/managed/project-p"),
preflightPluginSchema: vi.fn().mockReturnValue(null),
runPluginSchemaInits: vi.fn().mockResolvedValue(undefined),
on: vi.fn(),
off: vi.fn(),
});
const projectLoader = new PluginLoader({
pluginStore: projectPluginStore,
taskStore: projectStore,
});
const warn = vi.spyOn((projectLoader as any).log, "warn");
const projectRunner = new PluginRunner({
pluginLoader: projectLoader,
pluginStore: projectPluginStore,
taskStore: projectStore,
rootDir: "/managed/project-p",
});
const projectEngine = {
getTaskStore: vi.fn().mockReturnValue(projectStore),
getPluginRunner: vi.fn().mockReturnValue(projectRunner),
};
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore,
pluginLoader: hostLoader,
engineManager: { getEngine: vi.fn().mockReturnValue(projectEngine) } as any,
}));
expect(res.status).toBe(200);
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("proj_123");
try {
const enabled = await REQUEST(app, "POST", "/api/plugins/test-plugin/enable?projectId=project-p", {});
const listed = await GET(app, "/api/plugins?projectId=project-p");
await projectRunner.init();
expect(enabled.status).toBe(200);
expect(enabled.body).toMatchObject({ id: "test-plugin", enabled: true });
expect(listed.body).toEqual([expect.objectContaining({ id: "test-plugin", enabled: true })]);
expect(projectLoader.isPluginLoaded("test-plugin")).toBe(true);
expect(warn).not.toHaveBeenCalledWith("Skipped disabled plugin during loadAllPlugins: test-plugin");
expect(hostLoader.loadPlugin).not.toHaveBeenCalled();
expect(hostLoader.loadAllPlugins).not.toHaveBeenCalled();
} finally {
await rm(pluginDir, { recursive: true, force: true });
}
});
});
@@ -732,6 +830,7 @@ describe("POST /plugins/:id/disable", () => {
describe("POST /plugins/:id/reload", () => {
let store: TaskStore;
let pluginStore: PluginStore;
let pluginLoader: PluginLoader;
let pluginRunner: {
getPluginRoutes: ReturnType<typeof vi.fn>;
reloadPlugin: ReturnType<typeof vi.fn>;
@@ -743,6 +842,7 @@ describe("POST /plugins/:id/reload", () => {
beforeEach(() => {
pluginStore = createMockPluginStore();
pluginLoader = createMockPluginLoader();
pluginRunner = {
getPluginRoutes: vi.fn().mockReturnValue([]),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
@@ -761,7 +861,7 @@ describe("POST /plugins/:id/reload", () => {
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore,
pluginLoader: createMockPluginLoader(),
pluginLoader,
pluginRunner: includeRunner ? pluginRunner : undefined,
}));
return app;
@@ -775,7 +875,7 @@ describe("POST /plugins/:id/reload", () => {
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/reload", {});
expect(res.status).toBe(200);
expect(pluginRunner.reloadPlugin).toHaveBeenCalledWith("test-plugin");
expect(pluginLoader.reloadPlugin).toHaveBeenCalledWith("test-plugin");
expect(res.body.id).toBe("test-plugin");
});
@@ -801,7 +901,7 @@ describe("POST /plugins/:id/reload", () => {
expect(res.body.error).toContain("Use enable instead");
});
it("returns 500 when plugin runner is unavailable", async () => {
it("uses the scoped loader when the host runner is unavailable", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...FAKE_PLUGIN,
state: "started",
@@ -809,8 +909,8 @@ describe("POST /plugins/:id/reload", () => {
const res = await REQUEST(buildApp(false), "POST", "/api/plugins/test-plugin/reload", {});
expect(res.status).toBe(500);
expect(res.body.error).toContain("Plugin runner not available");
expect(res.status).toBe(200);
expect(pluginLoader.reloadPlugin).toHaveBeenCalledWith("test-plugin");
});
it("returns 500 when reload operation fails", async () => {
@@ -818,7 +918,7 @@ describe("POST /plugins/:id/reload", () => {
...FAKE_PLUGIN,
state: "started",
});
pluginRunner.reloadPlugin.mockRejectedValueOnce(new Error("boom"));
(pluginLoader.reloadPlugin as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("boom"));
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/reload", {});

View File

@@ -1,5 +1,5 @@
import type { NextFunction, Request, Response } from "express";
import { AutomationStore, RoutineStore, isWebhookTrigger, resolvePluginEntryPath, type RoutineTriggerType, type ScheduleType } from "@fusion/core";
import { AutomationStore, PluginLoader, 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";
@@ -23,6 +23,57 @@ Automation, routine, and plugin-management endpoints live in this registrar so r
export function registerPluginsAutomationRoutes(ctx: ApiRoutesContext, deps: PluginsAutomationRouteDependencies): void {
const { router, options, parseScopeParam, resolveAutomationStore, resolveRoutineStore, resolveRoutineRunner, getScopedStore, getProjectContext, 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-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 ────────────────────────────
//
// Scope-aware endpoints: Accept `scope=global|project` query param or body field.
@@ -857,8 +908,9 @@ export function registerPluginsAutomationRoutes(ctx: ApiRoutesContext, deps: Plu
* Get all UI slot definitions from active plugins.
* Returns aggregated array of { pluginId, slot } objects.
*/
router.get("/plugins/ui-slots", async (_req: Request, res: Response) => {
const slots = options?.pluginLoader?.getPluginUiSlots() ?? [];
router.get("/plugins/ui-slots", async (req: Request, res: Response) => {
const { store: scopedStore, engine } = await getProjectContext(req);
const slots = (await getProjectPluginLoader(scopedStore, engine))?.getPluginUiSlots() ?? [];
const normalizedSlots = slots
.map((entry) => ({
pluginId: entry.pluginId,
@@ -882,8 +934,9 @@ export function registerPluginsAutomationRoutes(ctx: ApiRoutesContext, deps: Plu
* GET /api/plugins/ui-contributions
* Get all structured UI contributions from active plugins.
*/
router.get("/plugins/ui-contributions", async (_req: Request, res: Response) => {
const contributions = options?.pluginLoader?.getPluginUiContributions() ?? [];
router.get("/plugins/ui-contributions", async (req: Request, res: Response) => {
const { store: scopedStore, engine } = await getProjectContext(req);
const contributions = (await getProjectPluginLoader(scopedStore, engine))?.getPluginUiContributions() ?? [];
const normalizedContributions = contributions
.map((entry) => ({
pluginId: entry.pluginId,
@@ -915,9 +968,8 @@ export function registerPluginsAutomationRoutes(ctx: ApiRoutesContext, deps: Plu
Engineering navigation from project A into project B while B's loader is still absent or empty.
*/
router.get("/plugins/dashboard-views", async (req: Request, res: Response) => {
const { engine, projectId } = await getProjectContext(req);
const pluginLoader = engine?.getPluginRunner?.()?.getLoader()
?? (projectId === undefined ? options?.pluginLoader : undefined);
const { store: scopedStore, engine } = await getProjectContext(req);
const pluginLoader = await getProjectPluginLoader(scopedStore, engine);
const views = await pluginLoader?.getPluginDashboardViews() ?? [];
res.json(views);
});
@@ -927,8 +979,9 @@ export function registerPluginsAutomationRoutes(ctx: ApiRoutesContext, deps: Plu
* Get all plugin runtime metadata from active plugins.
* Returns aggregated array of { pluginId, runtimeId, name, description, version }.
*/
router.get("/plugins/runtimes", async (_req: Request, res: Response) => {
const runtimes = options?.pluginLoader?.getPluginRuntimes() ?? [];
router.get("/plugins/runtimes", async (req: Request, res: Response) => {
const { store: scopedStore, engine } = await getProjectContext(req);
const runtimes = (await getProjectPluginLoader(scopedStore, engine))?.getPluginRuntimes() ?? [];
const installed = runtimes.map(({ pluginId, runtime }) => ({
pluginId,
runtimeId: runtime.metadata.runtimeId,
@@ -1012,8 +1065,9 @@ export function registerPluginsAutomationRoutes(ctx: ApiRoutesContext, deps: Plu
* Returns 201 on success, 400 for validation errors, 409 for conflicts.
*/
router.post("/plugins", async (req: Request, res: Response) => {
const { store: scopedStore } = await getProjectContext(req);
const { store: scopedStore, engine } = await getProjectContext(req);
const pluginStore = scopedStore.getPluginStore();
const pluginLoader = await getProjectPluginLoader(scopedStore, engine);
if (!req.body || typeof req.body !== "object") {
throw badRequest("Request body is required");
@@ -1069,9 +1123,9 @@ export function registerPluginsAutomationRoutes(ctx: ApiRoutesContext, deps: Plu
settings,
});
if (plugin.enabled && options?.pluginLoader) {
if (plugin.enabled && pluginLoader) {
try {
await options.pluginLoader.loadPlugin(plugin.id);
await pluginLoader.loadPlugin(plugin.id);
} catch (loadErr) {
// Log but don't fail - plugin is registered, just not loaded
runtimeLogger.child("plugin-routes").error(`Failed to load plugin ${plugin.id}`, {
@@ -1095,7 +1149,7 @@ export function registerPluginsAutomationRoutes(ctx: ApiRoutesContext, deps: Plu
}
// Check if runtime install interface is available
if (!options?.pluginLoader) {
if (!pluginLoader) {
throw badRequest("Plugin install mode is not supported: plugin loader not available");
}
@@ -1163,7 +1217,7 @@ export function registerPluginsAutomationRoutes(ctx: ApiRoutesContext, deps: Plu
// remove the new registration so install does not leave a broken record.
if (plugin.enabled) {
try {
await options.pluginLoader.loadPlugin(plugin.id);
await pluginLoader.loadPlugin(plugin.id);
} catch (loadErr) {
if (plugin.aiScanOnLoad) {
await pluginStore.unregisterPlugin(plugin.id);
@@ -1196,8 +1250,9 @@ export function registerPluginsAutomationRoutes(ctx: ApiRoutesContext, deps: Plu
* Body: { projectId?: string }
*/
router.post("/plugins/:id/enable", async (req: Request, res: Response) => {
const { store: scopedStore } = await getProjectContext(req);
const { store: scopedStore, engine } = await getProjectContext(req);
const pluginStore = scopedStore.getPluginStore();
const pluginLoader = await getProjectPluginLoader(scopedStore, engine);
const id = req.params.id as string;
let plugin = await pluginStore.enablePlugin(id);
@@ -1217,9 +1272,9 @@ export function registerPluginsAutomationRoutes(ctx: ApiRoutesContext, deps: Plu
}
// Start the plugin if loader is available
if (options?.pluginLoader) {
if (pluginLoader) {
try {
await options.pluginLoader.loadPlugin(id);
await pluginLoader.loadPlugin(id);
} catch (loadErr) {
// Update state to error
await pluginStore.updatePluginState(
@@ -1240,14 +1295,15 @@ export function registerPluginsAutomationRoutes(ctx: ApiRoutesContext, deps: Plu
* Body: { projectId?: string }
*/
router.post("/plugins/:id/disable", async (req: Request, res: Response) => {
const { store: scopedStore } = await getProjectContext(req);
const { store: scopedStore, engine } = await getProjectContext(req);
const pluginStore = scopedStore.getPluginStore();
const pluginLoader = await getProjectPluginLoader(scopedStore, engine);
const id = req.params.id as string;
// Stop the plugin if loader is available
if (options?.pluginLoader) {
if (pluginLoader) {
try {
await options.pluginLoader.stopPlugin(id);
await pluginLoader.stopPlugin(id);
} catch {
// Ignore errors from stopping - plugin might not be loaded
}
@@ -1263,8 +1319,9 @@ export function registerPluginsAutomationRoutes(ctx: ApiRoutesContext, deps: Plu
* Body: { projectId?: string }
*/
router.post("/plugins/:id/reload", async (req: Request, res: Response) => {
const { store: scopedStore } = await getProjectContext(req);
const { store: scopedStore, engine } = await getProjectContext(req);
const pluginStore = scopedStore.getPluginStore();
const pluginLoader = await getProjectPluginLoader(scopedStore, engine);
const id = req.params.id as string;
let plugin: import("@fusion/core").PluginInstallation;
@@ -1281,12 +1338,12 @@ export function registerPluginsAutomationRoutes(ctx: ApiRoutesContext, deps: Plu
throw badRequest("Plugin is not currently loaded. Use enable instead.");
}
if (!options?.pluginRunner?.reloadPlugin) {
throw internalError("Plugin runner not available");
if (!pluginLoader) {
throw internalError("Plugin loader not available");
}
try {
await options.pluginRunner.reloadPlugin(id);
await pluginLoader.reloadPlugin(id);
} catch (reloadErr: unknown) {
throw internalError(`Reload failed: ${reloadErr instanceof Error ? reloadErr.message : String(reloadErr)}`);
}
@@ -1327,8 +1384,9 @@ export function registerPluginsAutomationRoutes(ctx: ApiRoutesContext, deps: Plu
* Trigger a fresh plugin scan/load gate via reload or load flow.
*/
router.post("/plugins/:id/rescan", async (req: Request, res: Response) => {
const { store: scopedStore } = await getProjectContext(req);
const { store: scopedStore, engine } = await getProjectContext(req);
const pluginStore = scopedStore.getPluginStore();
const pluginLoader = await getProjectPluginLoader(scopedStore, engine);
const id = req.params.id as string;
let plugin: import("@fusion/core").PluginInstallation;
@@ -1338,15 +1396,15 @@ export function registerPluginsAutomationRoutes(ctx: ApiRoutesContext, deps: Plu
throw notFound(`Plugin "${id}" not found`);
}
if (!options?.pluginLoader) {
if (!pluginLoader) {
throw internalError("Plugin loader not available");
}
try {
if (plugin.state === "started" && options.pluginRunner?.reloadPlugin) {
await options.pluginRunner.reloadPlugin(id);
if (plugin.state === "started") {
await pluginLoader.reloadPlugin(id);
} else if (plugin.enabled) {
await options.pluginLoader.loadPlugin(id);
await pluginLoader.loadPlugin(id);
}
} catch (reloadErr) {
runtimeLogger.child("plugin-routes").error(`Failed to rescan plugin ${id}`, {