FN-8521: separate runtime installation from toggles
Keep built-in runtime installation distinct from project-scoped enablement. - Show enable/disable toggles only for installed runtime plugins. - Prevent toggles from registering or reinstalling uninstalled runtimes. - Update plugin lifecycle documentation and regression coverage. Files changed: .../fn-8521-separate-plugin-install-toggle.md | 7 ++ docs/dashboard-guide.md | 2 +- docs/plugin-management.md | 2 +- .../dashboard/app/components/PluginManager.tsx | 74 ++++------------------ .../components/__tests__/PluginManager.test.tsx | 30 ++++++--- .../__tests__/PluginManager.toggle.test.tsx | 51 +++++++-------- 6 files changed, 67 insertions(+), 99 deletions(-) Fusion-Task-Id: FN-8521 Fusion-Task-Lineage: 0de225f4-1d9c-411e-b5cc-851d3579a2ec Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8521-separate-plugin-install-toggle.md
Normal file
7
.changeset/fn-8521-separate-plugin-install-toggle.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Prevent plugin toggles from reinstalling uninstalled runtimes.
|
||||
category: fix
|
||||
dev: FN-8521 / Runfusion/Fusion#2409 separates install from project-scoped enablement.
|
||||
@@ -1385,7 +1385,7 @@ Features:
|
||||
- Enable/disable plugins, reload active plugins, and uninstall plugins
|
||||
- Inspect plugin runtime state and transition feedback
|
||||
- Edit and save plugin-defined settings schemas from the same panel
|
||||
- Built-in runtime plugins (Hermes, Paperclip, OpenClaw, Droid) always expose an interactive enable/disable toggle in the Built-in Plugins list, even before install. Disabling one registers it and disables it in the same action, and the decision survives restarts — a disabled runtime is not re-activated on the next startup. The Runtimes settings cards mirror this state instead of showing a stale detected/connected status.
|
||||
- A built-in runtime without an installed plugin record offers **Install** only. Once installed, it exposes project-scoped Enable/Disable, management, and uninstall controls; toggling never installs or reinstalls a runtime. The Runtimes settings cards mirror the installed runtime state instead of showing a stale detected/connected status.
|
||||
|
||||
For full plugin lifecycle workflows (discovery, install, enable/disable, configure, update, uninstall, troubleshooting), see [Plugin Management](./plugin-management.md). For plugin-related settings and experimental toggles, see [Settings reference](./settings-reference.md).
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ Expected outcome: Plugin is enabled or disabled by ID.
|
||||
|
||||
### Built-in runtime plugins (Hermes, Paperclip, OpenClaw, Droid)
|
||||
|
||||
Built-in runtime plugins always expose an interactive enable/disable toggle in **Built-in Plugins**, even before the plugin has been explicitly installed (no `plugin_installs` record yet). Disabling a not-yet-installed runtime registers it (mirroring the same lazy-install path used elsewhere) and then disables it in one step, so the decision persists. A user-disabled built-in runtime is never silently re-enabled or re-activated by Fusion on the next restart — startup auto-activation only loads plugins whose project state is enabled. The **Runtimes** settings cards (Hermes/OpenClaw/Paperclip) reflect this state and show "Disabled in Plugin Manager" instead of a stale detected/connected status when the runtime has been turned off.
|
||||
Built-in runtime rows without a `plugin_installs` record expose **Install** only. After installation, their project-scoped **Enable/Disable** toggle and management/uninstall controls become available. Installing and enabling are separate actions: changing enabled state never registers or reinstalls a runtime. The **Runtimes** settings cards (Hermes/OpenClaw/Paperclip) reflect the installed plugin state and show "Disabled in Plugin Manager" when an installed runtime has been turned off.
|
||||
|
||||
## 5) Configure plugin settings
|
||||
|
||||
|
||||
@@ -289,14 +289,6 @@ export function PluginManager({ addToast, projectId, onPluginsChanged }: PluginM
|
||||
const [builtinSetupStatusById, setBuiltinSetupStatusById] = useState<Record<string, PluginSetupStatusResponse>>({});
|
||||
const [loadingBuiltinSetupId, setLoadingBuiltinSetupId] = useState<string | null>(null);
|
||||
const [installingBuiltinSetupId, setInstallingBuiltinSetupId] = useState<string | null>(null);
|
||||
/*
|
||||
* FNXC:PluginManager 2026-07-07-00:00:
|
||||
* FN-7629 — built-in runtime plugins (Hermes/Paperclip/OpenClaw/Droid) must expose a durable
|
||||
* enable/disable control even when no plugin_installs row exists yet ("activated-without-record").
|
||||
* Track in-flight toggles separately from install/setup so the toggle-switch can show a busy
|
||||
* state without blocking the Install/Manage button.
|
||||
*/
|
||||
const [togglingBuiltinRuntimeId, setTogglingBuiltinRuntimeId] = useState<string | null>(null);
|
||||
const { confirm } = useConfirm();
|
||||
|
||||
const loadPlugins = useCallback(async (background = false, mutationResponse?: PluginInstallation) => {
|
||||
@@ -614,44 +606,15 @@ export function PluginManager({ addToast, projectId, onPluginsChanged }: PluginM
|
||||
};
|
||||
|
||||
/*
|
||||
* FNXC:PluginManager 2026-07-07-00:00:
|
||||
* FN-7629 — durable built-in runtime disable path. A built-in runtime
|
||||
* (Hermes/Paperclip/OpenClaw/Droid) that has never been explicitly installed
|
||||
* has no plugin_installs row, so enablePlugin/disablePlugin (which both call
|
||||
* getPlugin -> ENOENT) cannot persist a decision for it. When the user wants
|
||||
* to disable such a runtime, first register it via the existing install path
|
||||
* (mirrors the CLI's ensureBundledPluginInstalled lazy-install) so a
|
||||
* plugin_installs row + project state exists, then disable it immediately so
|
||||
* the decision is durable: loadAllPlugins() only loads plugins where
|
||||
* enabled=true, so a disabled runtime is never re-activated on restart.
|
||||
* Already-installed runtimes just toggle through the normal enable/disable
|
||||
* handlers, same as the installed-plugin list row.
|
||||
* FNXC:PluginManager 2026-07-22-20:41:
|
||||
* FN-8521 keeps installation and project-scoped enablement as separate actions: only a
|
||||
* PluginInstallation can reach this toggle, so toggling can never register a missing package.
|
||||
*/
|
||||
const handleToggleBuiltinRuntime = async (builtinPlugin: BuiltinPlugin, installedPlugin?: PluginInstallation) => {
|
||||
if (installedPlugin) {
|
||||
if (installedPlugin.enabled) {
|
||||
await handleDisable(installedPlugin);
|
||||
} else {
|
||||
await handleEnable(installedPlugin);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!builtinPlugin.path) {
|
||||
addToast(t("plugins.builtinNoPackage", "{{name}} is built in and does not have an installable package yet", { name: builtinPlugin.name }), "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setTogglingBuiltinRuntimeId(builtinPlugin.id);
|
||||
const registered = await installPlugin({ path: builtinPlugin.path }, projectId);
|
||||
await disablePlugin(registered.id, projectId);
|
||||
addToast(t("plugins.disabledForProject", "{{name}} disabled for this project", { name: builtinPlugin.name }), "success");
|
||||
await loadPlugins();
|
||||
} catch (err) {
|
||||
addToast(t("plugins.disablePluginFailed", "Failed to disable plugin: {{error}}", { error: err instanceof Error ? err.message : String(err) }), "error");
|
||||
} finally {
|
||||
setTogglingBuiltinRuntimeId(null);
|
||||
const handleToggleBuiltinRuntime = async (installedPlugin: PluginInstallation) => {
|
||||
if (installedPlugin.enabled) {
|
||||
await handleDisable(installedPlugin);
|
||||
} else {
|
||||
await handleEnable(installedPlugin);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1167,16 +1130,8 @@ export function PluginManager({ addToast, projectId, onPluginsChanged }: PluginM
|
||||
const setupReady = isInstalled && setupStatus?.hasSetup && pluginSetupState === "installed";
|
||||
const setupCheckInFlight = loadingBuiltinSetupId === builtinPlugin.id;
|
||||
const metadataOnly = !builtinPlugin.path;
|
||||
/*
|
||||
* FNXC:PluginManager 2026-07-07-00:00:
|
||||
* FN-7629 — runtime built-ins (Hermes/Paperclip/OpenClaw/Droid) must always expose an
|
||||
* interactive enable/disable control, independent of install status, and must never
|
||||
* dead-end at the static "Built-in metadata only" label. Non-runtime built-ins (e.g.
|
||||
* Agent Browser) keep the existing metadata-only/install/manage affordances unchanged.
|
||||
*/
|
||||
const isRuntimeBuiltin = builtinPlugin.category === "runtime";
|
||||
const runtimeEnabled = installedPlugin ? installedPlugin.enabled : true;
|
||||
const isTogglingRuntime = togglingBuiltinRuntimeId === builtinPlugin.id;
|
||||
const runtimeEnabled = installedPlugin?.enabled;
|
||||
|
||||
return (
|
||||
<div key={builtinPlugin.id} className="plugin-builtins-item">
|
||||
@@ -1187,7 +1142,7 @@ export function PluginManager({ addToast, projectId, onPluginsChanged }: PluginM
|
||||
<span className={`plugin-builtins-status ${isInstalled ? "plugin-builtins-status--installed" : "plugin-builtins-status--available"}`}>
|
||||
{isInstalled ? t("plugins.statusInstalled", "Installed") : metadataOnly ? t("plugins.statusBuiltIn", "Built in") : t("plugins.statusNotInstalled", "Not installed")}
|
||||
</span>
|
||||
{isRuntimeBuiltin && !runtimeEnabled && (
|
||||
{isRuntimeBuiltin && runtimeEnabled === false && (
|
||||
<span className="plugin-builtins-setup-status plugin-builtins-setup-status--warning">{t("plugins.builtinDisabled", "Disabled")}</span>
|
||||
)}
|
||||
{requiresSetupAction && (
|
||||
@@ -1205,14 +1160,13 @@ export function PluginManager({ addToast, projectId, onPluginsChanged }: PluginM
|
||||
<span className="plugin-builtins-description-text">{builtinPlugin.description}</span>
|
||||
</div>
|
||||
<div className="plugin-builtins-actions">
|
||||
{isRuntimeBuiltin && (
|
||||
{isRuntimeBuiltin && installedPlugin && (
|
||||
<label className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={runtimeEnabled}
|
||||
onChange={() => void handleToggleBuiltinRuntime(builtinPlugin, installedPlugin)}
|
||||
disabled={isTogglingRuntime}
|
||||
aria-label={runtimeEnabled
|
||||
checked={installedPlugin.enabled}
|
||||
onChange={() => void handleToggleBuiltinRuntime(installedPlugin)}
|
||||
aria-label={installedPlugin.enabled
|
||||
? t("plugins.disablePlugin", "Disable {{name}}", { name: builtinPlugin.name })
|
||||
: t("plugins.enablePlugin", "Enable {{name}}", { name: builtinPlugin.name })}
|
||||
/>
|
||||
|
||||
@@ -1209,22 +1209,33 @@ describe("PluginManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("handles plugin uninstalled SSE event", async () => {
|
||||
vi.mocked(fetchPlugins).mockResolvedValueOnce(mockPlugins);
|
||||
it("removes a runtime toggle after its uninstalled lifecycle event", async () => {
|
||||
const hermesRuntime: PluginInstallation = {
|
||||
id: "fusion-plugin-hermes-runtime",
|
||||
name: "Hermes Runtime",
|
||||
version: "1.0.0",
|
||||
state: "started",
|
||||
enabled: true,
|
||||
path: "./plugins/fusion-plugin-hermes-runtime",
|
||||
settings: {},
|
||||
settingsSchema: {},
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
vi.mocked(fetchPlugins).mockResolvedValueOnce([hermesRuntime]);
|
||||
|
||||
render(<PluginManager addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Plugin A")).toBeTruthy();
|
||||
});
|
||||
const installedCard = await waitFor(() => getBuiltInPluginCard("Hermes Runtime"));
|
||||
expect(within(installedCard).getByRole("checkbox", { name: "Disable Hermes Runtime" })).toBeChecked();
|
||||
|
||||
const eventSourceInstance = (globalThis as any).__testEventSourceInstance;
|
||||
const eventHandler = eventSourceInstance?.handlers?.["plugin:lifecycle"];
|
||||
|
||||
|
||||
act(() => {
|
||||
eventHandler({
|
||||
data: JSON.stringify({
|
||||
pluginId: "plugin-a",
|
||||
pluginId: hermesRuntime.id,
|
||||
transition: "uninstalled",
|
||||
sourceEvent: "plugin:unregistered",
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -1237,7 +1248,10 @@ describe("PluginManager", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Test Plugin A")).toBeNull();
|
||||
const card = getBuiltInPluginCard("Hermes Runtime");
|
||||
expect(within(card).getByRole("button", { name: "Install Hermes Runtime" })).toBeVisible();
|
||||
expect(within(card).queryByRole("checkbox")).not.toBeInTheDocument();
|
||||
expect(card.querySelector("label.toggle-switch")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -51,7 +51,10 @@ beforeEach(() => {
|
||||
const esInstance = {
|
||||
readyState: 1,
|
||||
close: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
addEventListener: vi.fn((event: string, handler: (event: MessageEvent) => void) => {
|
||||
(esInstance as { handlers?: Record<string, (event: MessageEvent) => void> }).handlers ??= {};
|
||||
(esInstance as { handlers: Record<string, (event: MessageEvent) => void> }).handlers[event] = handler;
|
||||
}),
|
||||
removeEventListener: vi.fn(),
|
||||
onerror: null,
|
||||
onopen: null,
|
||||
@@ -64,12 +67,14 @@ beforeEach(() => {
|
||||
(MockES as unknown as { CONNECTING: number; OPEN: number; CLOSED: number }).OPEN = 1;
|
||||
(MockES as unknown as { CONNECTING: number; OPEN: number; CLOSED: number }).CLOSED = 2;
|
||||
vi.stubGlobal("EventSource", MockES);
|
||||
(globalThis as { __testEventSourceInstance?: typeof esInstance }).__testEventSourceInstance = esInstance;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
document.querySelector('[data-test-id="all-app-css"]')?.remove();
|
||||
vi.restoreAllMocks();
|
||||
delete (globalThis as { __testEventSourceInstance?: unknown }).__testEventSourceInstance;
|
||||
});
|
||||
|
||||
function builtinPlugin(id: string, enabled: boolean) {
|
||||
@@ -174,57 +179,44 @@ describe("PluginManager toggle switch", () => {
|
||||
});
|
||||
|
||||
/*
|
||||
* FNXC:PluginManager 2026-07-07-00:00:
|
||||
* FN-7629 — regression coverage for the durable built-in runtime enable/disable control.
|
||||
* Symptom: with no plugin_installs row for Hermes/Paperclip/OpenClaw/Droid (fetchPlugins -> []),
|
||||
* the built-in runtime rows dead-ended at a static "Built-in metadata only"/install-only CTA and
|
||||
* offered no way to disable the runtime. Assert every runtime built-in exposes an interactive
|
||||
* toggle in every data state, and clicking disable on a not-installed runtime persists via
|
||||
* installPlugin + disablePlugin (the durable register-then-disable path).
|
||||
* FNXC:PluginManager 2026-07-22-20:41:
|
||||
* FN-8521 prevents the post-uninstall toggle from re-registering a runtime. A missing
|
||||
* PluginInstallation has exactly one action (Install); only installed records expose toggles.
|
||||
*/
|
||||
describe("PluginManager built-in runtime enable/disable toggle (FN-7629)", () => {
|
||||
describe("PluginManager built-in runtime install and enable controls (FN-8521)", () => {
|
||||
const RUNTIME_BUILTINS = BUILTIN_PLUGINS.filter((p) => p.category === "runtime");
|
||||
|
||||
async function builtinSection() {
|
||||
return within(await screen.findByLabelText("Built-in plugin recommendations"));
|
||||
}
|
||||
|
||||
it("renders an interactive, checked toggle for every runtime built-in when not installed", async () => {
|
||||
it("shows every uninstalled runtime's Install action without a toggle or orphaned toggle shell", async () => {
|
||||
vi.mocked(fetchPlugins).mockResolvedValue([]);
|
||||
|
||||
render(<PluginManager addToast={addToast} />);
|
||||
const section = await builtinSection();
|
||||
|
||||
for (const runtime of RUNTIME_BUILTINS) {
|
||||
const toggle = await section.findByRole("checkbox", { name: `Disable ${runtime.name}` });
|
||||
expect(toggle).toBeChecked();
|
||||
expect(toggle.closest("label.toggle-switch")).not.toBeNull();
|
||||
}
|
||||
|
||||
// No dead-end "Built-in metadata only" label for any runtime built-in row
|
||||
// (the metadata-only dead-end may still legitimately appear for non-runtime
|
||||
// built-ins like Agent Browser, which this task does not change).
|
||||
for (const runtime of RUNTIME_BUILTINS) {
|
||||
const row = section.getByText(runtime.name).closest(".plugin-builtins-item") as HTMLElement;
|
||||
expect(within(row).queryByText("Built-in metadata only")).not.toBeInTheDocument();
|
||||
expect(within(row).getByRole("button", { name: `Install ${runtime.name}` })).toBeVisible();
|
||||
expect(within(row).queryByRole("checkbox")).not.toBeInTheDocument();
|
||||
expect(row.querySelector("label.toggle-switch")).toBeNull();
|
||||
expect(row.querySelector(".toggle-slider")).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("disabling a not-installed runtime built-in registers it then disables it (durable path)", async () => {
|
||||
it("uses Install as the only transition from an uninstalled runtime", async () => {
|
||||
vi.mocked(fetchPlugins).mockResolvedValue([]);
|
||||
vi.mocked(installPlugin).mockResolvedValue(builtinPlugin("fusion-plugin-hermes-runtime", true));
|
||||
|
||||
render(<PluginManager addToast={addToast} />);
|
||||
|
||||
const toggle = await (await builtinSection()).findByRole("checkbox", { name: "Disable Hermes Runtime" });
|
||||
await userEvent.click(toggle.closest("label.toggle-switch") as HTMLLabelElement);
|
||||
const hermes = (await builtinSection()).getByText("Hermes Runtime").closest(".plugin-builtins-item") as HTMLElement;
|
||||
await userEvent.click(within(hermes).getByRole("button", { name: "Install Hermes Runtime" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(installPlugin).toHaveBeenCalledWith({ path: "./plugins/fusion-plugin-hermes-runtime" }, undefined);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(disablePlugin).toHaveBeenCalledWith("fusion-plugin-hermes-runtime", undefined);
|
||||
});
|
||||
expect(disablePlugin).not.toHaveBeenCalled();
|
||||
expect(enablePlugin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("toggles an installed, enabled runtime built-in via the standard disable path (no re-install)", async () => {
|
||||
@@ -241,7 +233,7 @@ describe("PluginManager built-in runtime enable/disable toggle (FN-7629)", () =>
|
||||
expect(installPlugin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("toggles an installed, disabled runtime built-in back on via the standard enable path", async () => {
|
||||
it("toggles an installed, disabled runtime built-in back on via the standard enable path without installing", async () => {
|
||||
vi.mocked(fetchPlugins).mockResolvedValue([builtinPlugin("fusion-plugin-openclaw-runtime", false)]);
|
||||
|
||||
render(<PluginManager addToast={addToast} />);
|
||||
@@ -253,5 +245,6 @@ describe("PluginManager built-in runtime enable/disable toggle (FN-7629)", () =>
|
||||
await waitFor(() => {
|
||||
expect(enablePlugin).toHaveBeenCalledWith("fusion-plugin-openclaw-runtime", undefined);
|
||||
});
|
||||
expect(installPlugin).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user