feat(FN-1133): add plugin hot-reload support

- Add PluginLoader hot-load/unload with watch mode, auto-recovery, and staged loading
- Add PluginRunner reactive integration with executor dynamic tools registration
- Add dashboard reload endpoint (POST /api/plugins/reload) and PluginManager UI
- Add comprehensive tests for plugin-hot-reload (core) and plugin-runner (engine)
- Update plugin authoring docs and add memory notes
- Add changeset for @gsxdsm/fusion minor release
This commit is contained in:
gsxdsm
2026-04-09 19:15:53 -07:00
parent a3163f9935
commit db0d5c8dc9
10 changed files with 1176 additions and 30 deletions

View File

@@ -3837,3 +3837,10 @@ export async function updatePluginSettings(
body: JSON.stringify({ settings }),
});
}
/** Reload a running plugin with updated code */
export async function reloadPlugin(id: string, projectId?: string): Promise<PluginInstallation> {
return api<PluginInstallation>(withProjectId(`/plugins/${encodeURIComponent(id)}/reload`, projectId), {
method: "POST",
});
}

View File

@@ -10,8 +10,8 @@
*/
import { useState, useEffect, useCallback } from "react";
import { Package, Settings, Trash2, Plus, X, RefreshCw } from "lucide-react";
import { fetchPlugins, installPlugin, enablePlugin, disablePlugin, uninstallPlugin, fetchPluginSettings, updatePluginSettings } from "../api";
import { Package, Settings, Trash2, Plus, X, RefreshCw, RotateCcw } from "lucide-react";
import { fetchPlugins, installPlugin, enablePlugin, disablePlugin, uninstallPlugin, fetchPluginSettings, updatePluginSettings, reloadPlugin } from "../api";
import type { PluginInstallation } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
@@ -34,6 +34,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
const [showInstall, setShowInstall] = useState(false);
const [installPath, setInstallPath] = useState("");
const [installing, setInstalling] = useState(false);
const [reloadingPluginId, setReloadingPluginId] = useState<string | null>(null);
const [selectedPlugin, setSelectedPlugin] = useState<PluginInstallation | null>(null);
const [pluginSettings, setPluginSettings] = useState<Record<string, unknown>>({});
const [settingsLoading, setSettingsLoading] = useState(false);
@@ -94,6 +95,19 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
}
};
const handleReload = async (plugin: PluginInstallation) => {
try {
setReloadingPluginId(plugin.id);
await reloadPlugin(plugin.id, projectId);
addToast(`${plugin.name} reloaded`, "success");
await loadPlugins();
} catch (err) {
addToast(`Failed to reload plugin: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally {
setReloadingPluginId(null);
}
};
const handleUninstall = async (plugin: PluginInstallation) => {
if (!confirm(`Are you sure you want to uninstall "${plugin.name}"?`)) {
return;
@@ -230,6 +244,16 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
</div>
<div className="plugin-detail-actions">
{selectedPlugin.state === "started" && (
<button
className="btn-secondary"
onClick={() => handleReload(selectedPlugin)}
disabled={reloadingPluginId === selectedPlugin.id}
>
<RotateCcw size={14} className={reloadingPluginId === selectedPlugin.id ? "spin" : ""} />
{reloadingPluginId === selectedPlugin.id ? "Reloading..." : "Reload"}
</button>
)}
{selectedPlugin.enabled ? (
<button className="btn-secondary" onClick={() => handleDisable(selectedPlugin)}>
Disable
@@ -303,6 +327,16 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
</span>
</div>
<div className="plugin-actions">
{plugin.state === "started" && (
<button
className="btn-icon"
onClick={() => handleReload(plugin)}
disabled={reloadingPluginId === plugin.id}
title="Reload"
>
<RotateCcw size={14} className={reloadingPluginId === plugin.id ? "spin" : ""} />
</button>
)}
<label className="toggle-switch">
<input
type="checkbox"