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:
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user