feat(FN-1468): wire plugin backend into dashboard and serve startup
- Add plugin backend wiring to fn dashboard startup - Add plugin backend wiring to fn serve (headless node) startup - Document plugin wiring pattern in project memory - Add comprehensive tests for plugin initialization in dashboard and serve commands - Include changeset for @gsxdsm/fusion package
This commit is contained in:
7
.changeset/fix-plugin-settings-load.md
Normal file
7
.changeset/fix-plugin-settings-load.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@gsxdsm/fusion": patch
|
||||
---
|
||||
|
||||
Fix plugin settings load failure in dashboard/serve runtime
|
||||
|
||||
The Settings → Plugins page in the dashboard was showing "Failed to load plugins" because the plugin REST API routes were not being wired into the server startup. This fix initializes PluginStore and PluginLoader in both `runDashboard` and `runServe`, and passes them to `createServer` so the `/api/plugins` endpoints become available in both full dashboard mode and headless node mode.
|
||||
@@ -198,6 +198,12 @@ Key implementation details from the plugin core foundation task:
|
||||
- Classes: `PluginStore`, `PluginLoader`
|
||||
- Interfaces: `PluginStoreEvents`, `PluginRegistrationInput`, `PluginUpdateInput`, `PluginLoaderOptions`
|
||||
|
||||
**Dashboard/Serve plugin wiring (FN-1468)**:
|
||||
- PluginStore initialized with `store.getFusionDir()` as rootDir
|
||||
- PluginLoader initialized with `{ pluginStore, taskStore: store }`
|
||||
- Both passed to `createServer()` via `pluginStore`, `pluginLoader`, and `pluginRunner` (pluginLoader instance)
|
||||
- Enables `/api/plugins` REST endpoints in both dashboard and headless node modes
|
||||
|
||||
### Plugin Hot-Load/Unload (FN-1133)
|
||||
|
||||
- Plugins can be loaded and unloaded at runtime without restarting the engine or dashboard.
|
||||
|
||||
@@ -65,6 +65,25 @@ vi.mock("@fusion/core", () => ({
|
||||
getAgent: vi.fn().mockResolvedValue(null),
|
||||
deleteAgent: vi.fn(),
|
||||
})),
|
||||
PluginStore: vi.fn().mockImplementation(() => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
listPlugins: vi.fn().mockResolvedValue([]),
|
||||
getPlugin: vi.fn(),
|
||||
registerPlugin: vi.fn(),
|
||||
enablePlugin: vi.fn(),
|
||||
disablePlugin: vi.fn(),
|
||||
updatePluginSettings: vi.fn(),
|
||||
unregisterPlugin: vi.fn(),
|
||||
updatePluginState: vi.fn(),
|
||||
})),
|
||||
PluginLoader: vi.fn().mockImplementation(() => ({
|
||||
loadPlugin: vi.fn().mockResolvedValue(undefined),
|
||||
stopPlugin: vi.fn().mockResolvedValue(undefined),
|
||||
reloadPlugin: vi.fn().mockResolvedValue(undefined),
|
||||
getPluginRoutes: vi.fn().mockReturnValue([]),
|
||||
getPlugin: vi.fn(),
|
||||
getLoadedPlugins: vi.fn().mockReturnValue([]),
|
||||
})),
|
||||
syncInsightExtractionAutomation: mockSyncInsightExtraction,
|
||||
INSIGHT_EXTRACTION_SCHEDULE_NAME: "Memory Insight Extraction",
|
||||
processAndAuditInsightExtraction: mockProcessAndAudit,
|
||||
@@ -395,6 +414,63 @@ describe("runDashboard — MissionAutopilot wiring", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("runDashboard — Plugin wiring", () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockDiscoverAndLoadExtensions.mockResolvedValue({
|
||||
runtime: { pendingProviderRegistrations: [] },
|
||||
errors: [],
|
||||
});
|
||||
const { TaskStore } = await import("@fusion/core");
|
||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
|
||||
});
|
||||
|
||||
it("creates PluginStore and PluginLoader instances", async () => {
|
||||
const { PluginStore, PluginLoader } = await import("@fusion/core");
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
expect(PluginStore).toHaveBeenCalledTimes(1);
|
||||
expect(PluginLoader).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes pluginStore, pluginLoader, and pluginRunner to createServer", async () => {
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
const { PluginStore, PluginLoader } = await import("@fusion/core");
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
expect(createServer).toHaveBeenCalledTimes(1);
|
||||
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];
|
||||
|
||||
expect(serverOpts.pluginStore).toBeDefined();
|
||||
expect(serverOpts.pluginLoader).toBeDefined();
|
||||
expect(serverOpts.pluginRunner).toBeDefined();
|
||||
|
||||
// pluginRunner should be the same instance as pluginLoader
|
||||
expect(serverOpts.pluginRunner).toBe(serverOpts.pluginLoader);
|
||||
});
|
||||
|
||||
it("initializes PluginStore with the task store's fusion directory", async () => {
|
||||
const { PluginStore } = await import("@fusion/core");
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
expect(PluginStore).toHaveBeenCalledWith("/tmp/test/.fusion");
|
||||
});
|
||||
|
||||
it("initializes PluginLoader with pluginStore and taskStore", async () => {
|
||||
const { PluginLoader } = await import("@fusion/core");
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
expect(PluginLoader).toHaveBeenCalledTimes(1);
|
||||
const loaderOptions = (PluginLoader as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(loaderOptions).toHaveProperty("pluginStore");
|
||||
expect(loaderOptions).toHaveProperty("taskStore");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runDashboard — Memory Insight Automation wiring", () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -26,6 +26,8 @@ const mocks = vi.hoisted(() => {
|
||||
const cronRunnerInstances: any[] = [];
|
||||
const missionAutopilotInstances: any[] = [];
|
||||
const notifierInstances: any[] = [];
|
||||
const pluginStoreInstances: any[] = [];
|
||||
const pluginLoaderInstances: any[] = [];
|
||||
const listenCalls: ListenCall[] = [];
|
||||
|
||||
function createTaskStoreMock() {
|
||||
@@ -201,6 +203,35 @@ const mocks = vi.hoisted(() => {
|
||||
return notifier;
|
||||
});
|
||||
|
||||
const pluginStoreCtor = vi.fn().mockImplementation(() => {
|
||||
const pluginStore = {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
listPlugins: vi.fn().mockResolvedValue([]),
|
||||
getPlugin: vi.fn(),
|
||||
registerPlugin: vi.fn(),
|
||||
enablePlugin: vi.fn(),
|
||||
disablePlugin: vi.fn(),
|
||||
updatePluginSettings: vi.fn(),
|
||||
unregisterPlugin: vi.fn(),
|
||||
updatePluginState: vi.fn(),
|
||||
};
|
||||
pluginStoreInstances.push(pluginStore);
|
||||
return pluginStore;
|
||||
});
|
||||
|
||||
const pluginLoaderCtor = vi.fn().mockImplementation(() => {
|
||||
const pluginLoader = {
|
||||
loadPlugin: vi.fn().mockResolvedValue(undefined),
|
||||
stopPlugin: vi.fn().mockResolvedValue(undefined),
|
||||
reloadPlugin: vi.fn().mockResolvedValue(undefined),
|
||||
getPluginRoutes: vi.fn().mockReturnValue([]),
|
||||
getPlugin: vi.fn(),
|
||||
getLoadedPlugins: vi.fn().mockReturnValue([]),
|
||||
};
|
||||
pluginLoaderInstances.push(pluginLoader);
|
||||
return pluginLoader;
|
||||
});
|
||||
|
||||
const authStorage = {
|
||||
getApiKey: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
@@ -237,6 +268,8 @@ const mocks = vi.hoisted(() => {
|
||||
cronRunnerCtor,
|
||||
missionAutopilotCtor,
|
||||
notifierCtor,
|
||||
pluginStoreCtor,
|
||||
pluginLoaderCtor,
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
reset() {
|
||||
@@ -252,6 +285,8 @@ const mocks = vi.hoisted(() => {
|
||||
cronRunnerInstances.length = 0;
|
||||
missionAutopilotInstances.length = 0;
|
||||
notifierInstances.length = 0;
|
||||
pluginStoreInstances.length = 0;
|
||||
pluginLoaderInstances.length = 0;
|
||||
listenCalls.length = 0;
|
||||
},
|
||||
};
|
||||
@@ -262,6 +297,8 @@ vi.mock("@fusion/core", () => ({
|
||||
AutomationStore: mocks.automationStoreCtor,
|
||||
AgentStore: mocks.agentStoreCtor,
|
||||
CentralCore: mocks.centralCoreCtor,
|
||||
PluginStore: mocks.pluginStoreCtor,
|
||||
PluginLoader: mocks.pluginLoaderCtor,
|
||||
getTaskMergeBlocker: vi.fn().mockReturnValue(null),
|
||||
syncInsightExtractionAutomation: vi.fn().mockResolvedValue(undefined),
|
||||
INSIGHT_EXTRACTION_SCHEDULE_NAME: "Memory Insight Extraction",
|
||||
@@ -479,6 +516,113 @@ describe("runServe", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("runServe — Plugin wiring", () => {
|
||||
const originalCwd = process.cwd;
|
||||
const originalOn = process.on;
|
||||
const originalExit = process.exit;
|
||||
|
||||
let signalHandlers: Record<"SIGINT" | "SIGTERM", Array<() => void>>;
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let cwdSpy: ReturnType<typeof vi.spyOn>;
|
||||
let processOnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
async function triggerSignal(signal: "SIGINT" | "SIGTERM") {
|
||||
const handlers = signalHandlers[signal];
|
||||
expect(handlers.length).toBeGreaterThan(0);
|
||||
handlers[handlers.length - 1]();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.reset();
|
||||
|
||||
signalHandlers = { SIGINT: [], SIGTERM: [] };
|
||||
|
||||
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo");
|
||||
processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => {
|
||||
if (event === "SIGINT" || event === "SIGTERM") {
|
||||
signalHandlers[event].push(listener);
|
||||
}
|
||||
return process;
|
||||
}) as typeof process.on);
|
||||
process.exit = vi.fn() as never;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore();
|
||||
cwdSpy.mockRestore();
|
||||
processOnSpy.mockRestore();
|
||||
process.cwd = originalCwd;
|
||||
process.on = originalOn;
|
||||
process.exit = originalExit;
|
||||
});
|
||||
|
||||
it("creates PluginStore and PluginLoader instances", async () => {
|
||||
const { PluginStore, PluginLoader } = await import("@fusion/core");
|
||||
|
||||
await runServe(4040, {});
|
||||
|
||||
expect(PluginStore).toHaveBeenCalledTimes(1);
|
||||
expect(PluginLoader).toHaveBeenCalledTimes(1);
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("passes pluginStore, pluginLoader, and pluginRunner to createServer", async () => {
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
|
||||
await runServe(4040, {});
|
||||
|
||||
expect(createServer).toHaveBeenCalledTimes(1);
|
||||
const serverOpts = createServer.mock.calls[0][1];
|
||||
expect(serverOpts).toHaveProperty("pluginStore");
|
||||
expect(serverOpts).toHaveProperty("pluginLoader");
|
||||
expect(serverOpts).toHaveProperty("pluginRunner");
|
||||
expect(serverOpts.pluginRunner).toBe(serverOpts.pluginLoader);
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("initializes PluginStore with the task store's fusion directory", async () => {
|
||||
const { PluginStore } = await import("@fusion/core");
|
||||
|
||||
await runServe(4040, {});
|
||||
|
||||
expect(PluginStore).toHaveBeenCalledWith("/repo/.fusion");
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("initializes PluginLoader with pluginStore and taskStore", async () => {
|
||||
const { PluginLoader } = await import("@fusion/core");
|
||||
|
||||
await runServe(4040, {});
|
||||
|
||||
expect(PluginLoader).toHaveBeenCalledTimes(1);
|
||||
const loaderOptions = PluginLoader.mock.calls[0][0];
|
||||
expect(loaderOptions).toHaveProperty("pluginStore");
|
||||
expect(loaderOptions).toHaveProperty("taskStore");
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("includes plugin wiring in headless server", async () => {
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
|
||||
await runServe(4040, {});
|
||||
|
||||
expect(createServer).toHaveBeenCalledTimes(1);
|
||||
const serverOpts = createServer.mock.calls[0][1];
|
||||
expect(serverOpts.headless).toBe(true);
|
||||
expect(serverOpts.pluginStore).toBeDefined();
|
||||
expect(serverOpts.pluginLoader).toBeDefined();
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runServe — Memory Insight Automation wiring", () => {
|
||||
const originalCwd = process.cwd;
|
||||
const originalOn = process.on;
|
||||
|
||||
@@ -100,6 +100,45 @@ vi.mock("@fusion/core", () => ({
|
||||
getBudgetStatus: vi.fn().mockResolvedValue({ isOverBudget: false, isOverThreshold: false, usagePercent: 0 }),
|
||||
getRecentRuns: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
PluginStore: vi.fn().mockImplementation(() => {
|
||||
const emitter = new EventEmitter();
|
||||
return {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
listPlugins: vi.fn().mockResolvedValue([]),
|
||||
getPlugin: vi.fn(),
|
||||
registerPlugin: vi.fn(),
|
||||
enablePlugin: vi.fn(),
|
||||
disablePlugin: vi.fn(),
|
||||
updatePluginSettings: vi.fn(),
|
||||
unregisterPlugin: vi.fn(),
|
||||
updatePluginState: vi.fn(),
|
||||
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),
|
||||
};
|
||||
}),
|
||||
PluginLoader: vi.fn().mockImplementation(() => {
|
||||
const emitter = new EventEmitter();
|
||||
return {
|
||||
loadPlugin: vi.fn().mockResolvedValue(undefined),
|
||||
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),
|
||||
};
|
||||
}),
|
||||
getTaskMergeBlocker: vi.fn((task: any) => {
|
||||
if (task.column !== "in-review") return `task is in '${task.column}', must be in 'in-review'`;
|
||||
if (task.paused) return "task is paused";
|
||||
@@ -250,6 +289,14 @@ vi.mock("@fusion/engine", async (importOriginal) => {
|
||||
stop: vi.fn(),
|
||||
setScheduler: vi.fn(),
|
||||
})),
|
||||
PluginLoader: vi.fn().mockImplementation(() => ({
|
||||
loadPlugin: vi.fn().mockResolvedValue(undefined),
|
||||
stopPlugin: vi.fn().mockResolvedValue(undefined),
|
||||
reloadPlugin: vi.fn().mockResolvedValue(undefined),
|
||||
getPluginRoutes: vi.fn().mockReturnValue([]),
|
||||
getPlugin: vi.fn(),
|
||||
getLoadedPlugins: vi.fn().mockReturnValue([]),
|
||||
})),
|
||||
scanIdleWorktrees: vi.fn().mockResolvedValue([]),
|
||||
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
|
||||
};
|
||||
@@ -1778,7 +1825,7 @@ describe("runDashboard — lifecycle listener cleanup", () => {
|
||||
dispose();
|
||||
|
||||
const offCalls = mockStore.off.mock.calls.slice(offCallsBefore);
|
||||
expect(offCalls.filter(([event]) => event === "settings:updated")).toHaveLength(5);
|
||||
expect(offCalls.filter(([event]) => event === "settings:updated")).toHaveLength(6);
|
||||
expect(offCalls.filter(([event]) => event === "task:moved")).toHaveLength(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { createInterface } from "node:readline";
|
||||
import { TaskStore, AutomationStore, CentralCore, AgentStore, getTaskMergeBlocker, syncInsightExtractionAutomation, INSIGHT_EXTRACTION_SCHEDULE_NAME, processAndAuditInsightExtraction } from "@fusion/core";
|
||||
import { TaskStore, AutomationStore, CentralCore, AgentStore, PluginStore, PluginLoader, getTaskMergeBlocker, syncInsightExtractionAutomation, INSIGHT_EXTRACTION_SCHEDULE_NAME, processAndAuditInsightExtraction } from "@fusion/core";
|
||||
import type { Settings, TaskDetail, PrInfo, ScheduledTask, AutomationRunResult } from "@fusion/core";
|
||||
import { createServer, GitHubClient } from "@fusion/dashboard";
|
||||
import { TriageProcessor, TaskExecutor, Scheduler, AgentSemaphore, WorktreePool, aiMergeTask, UsageLimitPauser, PRIORITY_MERGE, scanIdleWorktrees, cleanupOrphanedWorktrees, NtfyNotifier, PrMonitor, PrCommentHandler, CronRunner, StuckTaskDetector, SelfHealingManager, MissionAutopilot, createAiPromptExecutor, HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext } from "@fusion/engine";
|
||||
@@ -309,6 +309,27 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
const agentStore = new AgentStore({ rootDir: store.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
// ── PluginStore: plugin installation management ─────────────────────
|
||||
//
|
||||
// SQLite-backed plugin persistence for the Settings → Plugins experience.
|
||||
// Enables the PluginManager UI to list, install, enable, disable, and
|
||||
// configure plugins via the /api/plugins REST endpoints.
|
||||
//
|
||||
const pluginStore = new PluginStore(store.getFusionDir());
|
||||
await pluginStore.init();
|
||||
|
||||
// ── PluginLoader: plugin lifecycle management ───────────────────────
|
||||
//
|
||||
// Manages dynamic plugin loading, hot-reload, hook invocation, and
|
||||
// dependency resolution. The PluginLoader instance also serves as the
|
||||
// PluginRunner for the REST routes (provides getPluginRoutes and
|
||||
// reloadPlugin methods).
|
||||
//
|
||||
const pluginLoader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: store,
|
||||
});
|
||||
|
||||
// ── HeartbeatMonitor: runtime monitoring and execution for agents ───
|
||||
//
|
||||
// Provides the Paperclip-style heartbeat execution engine:
|
||||
@@ -755,7 +776,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
|
||||
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry);
|
||||
|
||||
// Start the web server with AI merge, auth, and model registry wired in
|
||||
// Start the web server with AI merge, auth, model registry, and plugin wiring
|
||||
const app = createServer(store, {
|
||||
onMerge,
|
||||
authStorage: dashboardAuthStorage,
|
||||
@@ -763,6 +784,9 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
automationStore,
|
||||
missionAutopilot,
|
||||
heartbeatMonitor,
|
||||
pluginStore,
|
||||
pluginLoader,
|
||||
pluginRunner: pluginLoader,
|
||||
});
|
||||
|
||||
function dispose(): void {
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
AutomationStore,
|
||||
CentralCore,
|
||||
AgentStore,
|
||||
PluginStore,
|
||||
PluginLoader,
|
||||
getTaskMergeBlocker,
|
||||
syncInsightExtractionAutomation,
|
||||
INSIGHT_EXTRACTION_SCHEDULE_NAME,
|
||||
@@ -79,6 +81,27 @@ export async function runServe(
|
||||
const agentStore = new AgentStore({ rootDir: store.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
// ── PluginStore: plugin installation management ─────────────────────
|
||||
//
|
||||
// SQLite-backed plugin persistence for the Settings → Plugins experience.
|
||||
// Enables the PluginManager UI to list, install, enable, disable, and
|
||||
// configure plugins via the /api/plugins REST endpoints.
|
||||
//
|
||||
const pluginStore = new PluginStore(store.getFusionDir());
|
||||
await pluginStore.init();
|
||||
|
||||
// ── PluginLoader: plugin lifecycle management ───────────────────────
|
||||
//
|
||||
// Manages dynamic plugin loading, hot-reload, hook invocation, and
|
||||
// dependency resolution. The PluginLoader instance also serves as the
|
||||
// PluginRunner for the REST routes (provides getPluginRoutes and
|
||||
// reloadPlugin methods).
|
||||
//
|
||||
const pluginLoader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: store,
|
||||
});
|
||||
|
||||
// ── HeartbeatMonitor: runtime monitoring and execution for agents ───
|
||||
//
|
||||
// Provides the Paperclip-style heartbeat execution engine:
|
||||
@@ -493,6 +516,9 @@ export async function runServe(
|
||||
automationStore,
|
||||
missionAutopilot,
|
||||
heartbeatMonitor,
|
||||
pluginStore,
|
||||
pluginLoader,
|
||||
pluginRunner: pluginLoader,
|
||||
headless: true,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user