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:
7
.changeset/fn-8467-plugin-enable-state-consistency.md
Normal file
7
.changeset/fn-8467-plugin-enable-state-consistency.md
Normal 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).
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
@@ -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", {});
|
||||
|
||||
|
||||
@@ -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}`, {
|
||||
|
||||
Reference in New Issue
Block a user