fix(FN-2698): load plugins during CLI runtime startup
- Load configured plugins during dashboard startup before launching the UI flow - Load plugins during serve and daemon startup so runtime hooks are available immediately - Add targeted CLI command tests for dashboard, serve, and daemon auto-load behavior at startup - Add a changeset documenting the plugin runtime startup fix for @runfusion/fusion
This commit is contained in:
5
.changeset/fix-plugin-runtime-startup.md
Normal file
5
.changeset/fix-plugin-runtime-startup.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Load enabled plugins during dashboard, serve, and daemon startup so plugin runtimes are available to agent runtime selection immediately after boot.
|
||||||
@@ -255,6 +255,7 @@ const mocks = vi.hoisted(() => {
|
|||||||
const pluginLoaderCtor = vi.fn().mockImplementation(() => {
|
const pluginLoaderCtor = vi.fn().mockImplementation(() => {
|
||||||
const pluginLoader = {
|
const pluginLoader = {
|
||||||
loadPlugin: vi.fn().mockResolvedValue(undefined),
|
loadPlugin: vi.fn().mockResolvedValue(undefined),
|
||||||
|
loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }),
|
||||||
stopPlugin: vi.fn().mockResolvedValue(undefined),
|
stopPlugin: vi.fn().mockResolvedValue(undefined),
|
||||||
reloadPlugin: vi.fn().mockResolvedValue(undefined),
|
reloadPlugin: vi.fn().mockResolvedValue(undefined),
|
||||||
getPluginRoutes: vi.fn().mockReturnValue([]),
|
getPluginRoutes: vi.fn().mockReturnValue([]),
|
||||||
@@ -647,6 +648,39 @@ describe("runDaemon", () => {
|
|||||||
await triggerSignal("SIGINT");
|
await triggerSignal("SIGINT");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("auto-loads installed plugins during startup", async () => {
|
||||||
|
const { PluginLoader } = await import("@fusion/core");
|
||||||
|
|
||||||
|
await runDaemon({});
|
||||||
|
|
||||||
|
const loaderInstance = (PluginLoader as unknown as ReturnType<typeof vi.fn>).mock.results[0]?.value as
|
||||||
|
| { loadAllPlugins: ReturnType<typeof vi.fn> }
|
||||||
|
| undefined;
|
||||||
|
expect(loaderInstance?.loadAllPlugins).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await triggerSignal("SIGINT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues startup when plugin auto-load fails", async () => {
|
||||||
|
const { PluginLoader } = await import("@fusion/core");
|
||||||
|
(PluginLoader as unknown as ReturnType<typeof vi.fn>).mockImplementationOnce(() => ({
|
||||||
|
loadPlugin: vi.fn().mockResolvedValue(undefined),
|
||||||
|
loadAllPlugins: vi.fn().mockRejectedValue(new Error("plugin load failed")),
|
||||||
|
stopPlugin: vi.fn().mockResolvedValue(undefined),
|
||||||
|
reloadPlugin: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getPluginRoutes: vi.fn().mockReturnValue([]),
|
||||||
|
getPlugin: vi.fn(),
|
||||||
|
getLoadedPlugins: vi.fn().mockReturnValue([]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await expect(runDaemon({})).resolves.toBeUndefined();
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("[plugins] Failed to load plugins: plugin load failed")
|
||||||
|
);
|
||||||
|
|
||||||
|
await triggerSignal("SIGINT");
|
||||||
|
});
|
||||||
|
|
||||||
it("passes provided token to createServer daemon option", async () => {
|
it("passes provided token to createServer daemon option", async () => {
|
||||||
const providedToken = "fn_custom_token_1234567890123456";
|
const providedToken = "fn_custom_token_1234567890123456";
|
||||||
|
|
||||||
|
|||||||
@@ -1990,6 +1990,54 @@ describe("runDashboard — --dev mode", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("runDashboard — plugin auto-load", () => {
|
||||||
|
let mockStore: ReturnType<typeof makeMockStore>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
resetGitHubMocks();
|
||||||
|
mockStore = makeMockStore();
|
||||||
|
const { TaskStore } = await import("@fusion/core");
|
||||||
|
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-loads installed plugins during startup", async () => {
|
||||||
|
const { PluginLoader } = await import("@fusion/core");
|
||||||
|
|
||||||
|
await runDashboard(0, { open: false });
|
||||||
|
|
||||||
|
const loaderInstance = (PluginLoader as unknown as ReturnType<typeof vi.fn>).mock.results[0]?.value as
|
||||||
|
| { loadAllPlugins: ReturnType<typeof vi.fn> }
|
||||||
|
| undefined;
|
||||||
|
expect(loaderInstance?.loadAllPlugins).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues startup when plugin auto-load fails", async () => {
|
||||||
|
const { PluginLoader } = await import("@fusion/core");
|
||||||
|
(PluginLoader as unknown as ReturnType<typeof vi.fn>).mockImplementationOnce(() => {
|
||||||
|
const emitter = new EventEmitter();
|
||||||
|
return {
|
||||||
|
loadPlugin: vi.fn().mockResolvedValue(undefined),
|
||||||
|
loadAllPlugins: vi.fn().mockRejectedValue(new Error("plugin load failed")),
|
||||||
|
stopPlugin: vi.fn().mockResolvedValue(undefined),
|
||||||
|
reloadPlugin: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getPluginRoutes: vi.fn().mockReturnValue([]),
|
||||||
|
getPlugin: vi.fn(),
|
||||||
|
getLoadedPlugins: vi.fn().mockReturnValue([]),
|
||||||
|
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||||
|
emitter.on(event, handler);
|
||||||
|
}),
|
||||||
|
off: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||||
|
emitter.off(event, handler);
|
||||||
|
}),
|
||||||
|
emit: emitter.emit.bind(emitter),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(runDashboard(0, { open: false })).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("runDashboard — merge conflict retry logic", () => {
|
describe("runDashboard — merge conflict retry logic", () => {
|
||||||
let mockStore: ReturnType<typeof makeMockStore>;
|
let mockStore: ReturnType<typeof makeMockStore>;
|
||||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|||||||
@@ -281,6 +281,7 @@ const mocks = vi.hoisted(() => {
|
|||||||
const pluginLoaderCtor = vi.fn().mockImplementation(() => {
|
const pluginLoaderCtor = vi.fn().mockImplementation(() => {
|
||||||
const pluginLoader = {
|
const pluginLoader = {
|
||||||
loadPlugin: vi.fn().mockResolvedValue(undefined),
|
loadPlugin: vi.fn().mockResolvedValue(undefined),
|
||||||
|
loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }),
|
||||||
stopPlugin: vi.fn().mockResolvedValue(undefined),
|
stopPlugin: vi.fn().mockResolvedValue(undefined),
|
||||||
reloadPlugin: vi.fn().mockResolvedValue(undefined),
|
reloadPlugin: vi.fn().mockResolvedValue(undefined),
|
||||||
getPluginRoutes: vi.fn().mockReturnValue([]),
|
getPluginRoutes: vi.fn().mockReturnValue([]),
|
||||||
@@ -892,6 +893,41 @@ describe("runServe — Plugin wiring", () => {
|
|||||||
await triggerSignal("SIGINT");
|
await triggerSignal("SIGINT");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("auto-loads installed plugins during startup", async () => {
|
||||||
|
const { PluginLoader } = await import("@fusion/core");
|
||||||
|
|
||||||
|
await runServe(4040, {});
|
||||||
|
|
||||||
|
const loaderInstance = (PluginLoader as unknown as ReturnType<typeof vi.fn>).mock.results[0]?.value as
|
||||||
|
| { loadAllPlugins: ReturnType<typeof vi.fn> }
|
||||||
|
| undefined;
|
||||||
|
expect(loaderInstance?.loadAllPlugins).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await triggerSignal("SIGINT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues startup when plugin auto-load fails", async () => {
|
||||||
|
const { PluginLoader } = await import("@fusion/core");
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
(PluginLoader as unknown as ReturnType<typeof vi.fn>).mockImplementationOnce(() => ({
|
||||||
|
loadPlugin: vi.fn().mockResolvedValue(undefined),
|
||||||
|
loadAllPlugins: vi.fn().mockRejectedValue(new Error("plugin load failed")),
|
||||||
|
stopPlugin: vi.fn().mockResolvedValue(undefined),
|
||||||
|
reloadPlugin: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getPluginRoutes: vi.fn().mockReturnValue([]),
|
||||||
|
getPlugin: vi.fn(),
|
||||||
|
getLoadedPlugins: vi.fn().mockReturnValue([]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await expect(runServe(4040, {})).resolves.toBeUndefined();
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("[plugins] Failed to load plugins: plugin load failed")
|
||||||
|
);
|
||||||
|
|
||||||
|
await triggerSignal("SIGINT");
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
it("includes plugin wiring in headless server", async () => {
|
it("includes plugin wiring in headless server", async () => {
|
||||||
const { createServer } = await import("@fusion/dashboard");
|
const { createServer } = await import("@fusion/dashboard");
|
||||||
|
|
||||||
|
|||||||
@@ -369,6 +369,17 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
|||||||
taskStore: store,
|
taskStore: store,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-load all enabled plugins so runtime UI (NewAgentDialog, AgentDetailView)
|
||||||
|
// can discover installed runtimes like Hermes and OpenClaw.
|
||||||
|
try {
|
||||||
|
const { loaded, errors } = await pluginLoader.loadAllPlugins();
|
||||||
|
console.log(`[plugins] Loaded ${loaded} plugins (${errors} errors)`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`[plugins] Failed to load plugins: ${err instanceof Error ? err.message : err}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Get subsystems from the cwd engine for the HTTP layer
|
// Get subsystems from the cwd engine for the HTTP layer
|
||||||
const heartbeatMonitor = cwdEngine.getRuntime().getHeartbeatMonitor();
|
const heartbeatMonitor = cwdEngine.getRuntime().getHeartbeatMonitor();
|
||||||
const missionAutopilot = cwdEngine.getRuntime().getMissionAutopilot();
|
const missionAutopilot = cwdEngine.getRuntime().getMissionAutopilot();
|
||||||
|
|||||||
@@ -1023,6 +1023,18 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
taskStore: store,
|
taskStore: store,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-load all enabled plugins so runtime UI (NewAgentDialog, AgentDetailView)
|
||||||
|
// can discover installed runtimes like Hermes and OpenClaw.
|
||||||
|
try {
|
||||||
|
const { loaded, errors } = await pluginLoader.loadAllPlugins();
|
||||||
|
logSink.log(`Loaded ${loaded} plugins (${errors} errors)`, "plugins");
|
||||||
|
} catch (err) {
|
||||||
|
logSink.log(
|
||||||
|
`Failed to load plugins: ${err instanceof Error ? err.message : err}`,
|
||||||
|
"plugins"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── HeartbeatMonitor + HeartbeatTriggerScheduler ──────────────────────
|
// ── HeartbeatMonitor + HeartbeatTriggerScheduler ──────────────────────
|
||||||
//
|
//
|
||||||
// In non-dev mode: obtained from ProjectEngine after engine.start(), which
|
// In non-dev mode: obtained from ProjectEngine after engine.start(), which
|
||||||
|
|||||||
@@ -422,6 +422,17 @@ export async function runServe(
|
|||||||
taskStore: store,
|
taskStore: store,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-load all enabled plugins so runtime UI (NewAgentDialog, AgentDetailView)
|
||||||
|
// can discover installed runtimes like Hermes and OpenClaw.
|
||||||
|
try {
|
||||||
|
const { loaded, errors } = await pluginLoader.loadAllPlugins();
|
||||||
|
console.log(`[plugins] Loaded ${loaded} plugins (${errors} errors)`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`[plugins] Failed to load plugins: ${err instanceof Error ? err.message : err}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Get subsystems from the cwd engine for the HTTP layer
|
// Get subsystems from the cwd engine for the HTTP layer
|
||||||
const heartbeatMonitor = cwdEngine.getRuntime().getHeartbeatMonitor();
|
const heartbeatMonitor = cwdEngine.getRuntime().getHeartbeatMonitor();
|
||||||
const missionAutopilot = cwdEngine.getRuntime().getMissionAutopilot();
|
const missionAutopilot = cwdEngine.getRuntime().getMissionAutopilot();
|
||||||
|
|||||||
Reference in New Issue
Block a user