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"

View File

@@ -35,6 +35,7 @@ import {
// PluginRunner interface for optional plugin runner
interface PluginRunner {
reloadPlugin?(pluginId: string): Promise<void>;
getPluginRoutes(): Array<{ pluginId: string; route: import("@fusion/core").PluginRouteDefinition }>;
}
@@ -229,6 +230,46 @@ export function createPluginRouter(
res.json(plugin);
}));
/**
* POST /plugins/:id/reload
* Reload a running plugin with updated code.
*/
router.post("/:id/reload", catchHandler(async (req: Request, res: Response) => {
const id = req.params.id as string;
// Validate plugin exists
let plugin;
try {
plugin = await pluginStore.getPlugin(id);
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
throw notFound(`Plugin "${id}" not found`);
}
throw internalError(err instanceof Error ? err.message : "Unknown error");
}
// Validate plugin is started (must be loaded to reload)
if (plugin.state !== "started") {
throw badRequest("Plugin is not currently loaded. Use enable instead.");
}
// Check if pluginRunner is available and has reloadPlugin method
if (!pluginRunner || !pluginRunner.reloadPlugin) {
throw internalError("Plugin runner not available");
}
// Reload the plugin
try {
await pluginRunner.reloadPlugin(id);
} catch (reloadErr) {
throw internalError(`Reload failed: ${reloadErr instanceof Error ? reloadErr.message : String(reloadErr)}`);
}
// Return updated plugin
const updatedPlugin = await pluginStore.getPlugin(id);
res.json(updatedPlugin);
}));
/**
* DELETE /plugins/:id
* Uninstall a plugin.