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 pluginLoader = {
|
||||
loadPlugin: vi.fn().mockResolvedValue(undefined),
|
||||
loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }),
|
||||
stopPlugin: vi.fn().mockResolvedValue(undefined),
|
||||
reloadPlugin: vi.fn().mockResolvedValue(undefined),
|
||||
getPluginRoutes: vi.fn().mockReturnValue([]),
|
||||
@@ -647,6 +648,39 @@ describe("runDaemon", () => {
|
||||
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 () => {
|
||||
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", () => {
|
||||
let mockStore: ReturnType<typeof makeMockStore>;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
@@ -281,6 +281,7 @@ const mocks = vi.hoisted(() => {
|
||||
const pluginLoaderCtor = vi.fn().mockImplementation(() => {
|
||||
const pluginLoader = {
|
||||
loadPlugin: vi.fn().mockResolvedValue(undefined),
|
||||
loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }),
|
||||
stopPlugin: vi.fn().mockResolvedValue(undefined),
|
||||
reloadPlugin: vi.fn().mockResolvedValue(undefined),
|
||||
getPluginRoutes: vi.fn().mockReturnValue([]),
|
||||
@@ -892,6 +893,41 @@ describe("runServe — Plugin wiring", () => {
|
||||
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 () => {
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
|
||||
|
||||
@@ -369,6 +369,17 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
||||
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
|
||||
const heartbeatMonitor = cwdEngine.getRuntime().getHeartbeatMonitor();
|
||||
const missionAutopilot = cwdEngine.getRuntime().getMissionAutopilot();
|
||||
|
||||
@@ -1023,6 +1023,18 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
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 ──────────────────────
|
||||
//
|
||||
// In non-dev mode: obtained from ProjectEngine after engine.start(), which
|
||||
|
||||
@@ -422,6 +422,17 @@ export async function runServe(
|
||||
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
|
||||
const heartbeatMonitor = cwdEngine.getRuntime().getHeartbeatMonitor();
|
||||
const missionAutopilot = cwdEngine.getRuntime().getMissionAutopilot();
|
||||
|
||||
Reference in New Issue
Block a user