diff --git a/.changeset/system-panel-restart-agents-pg.md b/.changeset/system-panel-restart-agents-pg.md new file mode 100644 index 0000000000..1c63a03412 --- /dev/null +++ b/.changeset/system-panel-restart-agents-pg.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix "Restart all agents" in the System panel failing with a SQLite removal error. +category: fix +dev: `POST /system/agents/restart-all` built its `AgentStore` from `rootDir` alone, falling through to the deleted sync SQLite path (VAL-REMOVAL-005). It now passes the scoped project's `AsyncDataLayer` via `requireAsyncLayer` and the scoped `taskStore`. diff --git a/packages/dashboard/src/routes/__tests__/register-system-routes.test.ts b/packages/dashboard/src/routes/__tests__/register-system-routes.test.ts index a16dd9eff2..b74f5b5eaf 100644 --- a/packages/dashboard/src/routes/__tests__/register-system-routes.test.ts +++ b/packages/dashboard/src/routes/__tests__/register-system-routes.test.ts @@ -49,14 +49,19 @@ pause+resume each running project engine, and agent restart-all must bounce only ACTIVE agents (operator-paused agents stay paused). */ -const agentStoreState: { agents: Array<{ id: string; state: string }> } = { agents: [] }; +const agentStoreState: { + agents: Array<{ id: string; state: string }>; + lastOptions?: Record; +} = { agents: [] }; vi.mock("@fusion/core", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, AgentStore: class { - constructor(_opts: unknown) {} + constructor(opts: unknown) { + agentStoreState.lastOptions = opts as Record; + } async init(): Promise {} async listAgents(): Promise> { return agentStoreState.agents; @@ -80,8 +85,13 @@ interface HarnessOptions { options?: Record; deps?: Partial[1]>; store?: Record; + /** Scoped project store returned by getProjectContext; omit for the default PG-backed stub. */ + scopedStore?: Record; } +/* FNXC:SystemPanel 2026-07-25-11:40: Scoped project stores are PostgreSQL-backed after VAL-REMOVAL-005; the fixture must expose an AsyncDataLayer so routes never construct a store on the deleted sync SQLite path. */ +const PROJECT_ASYNC_LAYER = { __layer: "pg" } as never; + function createApp(harness: HarnessOptions = {}) { const router = express.Router(); const rethrowAsApiError = vi.fn((error: unknown) => { @@ -101,7 +111,10 @@ function createApp(harness: HarnessOptions = {}) { getProjectContext: vi.fn(async () => ({ projectId: "proj-1", engine: undefined, - store: { getFusionDir: () => "/tmp/fusion-test" } as never, + store: (harness.scopedStore ?? { + getFusionDir: () => "/tmp/fusion-test", + getAsyncLayer: () => PROJECT_ASYNC_LAYER, + }) as never, })), prioritizeProjectsForCurrentDirectory: (projects) => projects, emitRemoteRouteDiagnostic: vi.fn(), @@ -574,6 +587,49 @@ describe("POST /system/agents/restart-all", () => { expect(resumeAgent).toHaveBeenCalledWith("agent-active", expect.objectContaining({ clearPauseReason: true })); }); + /* + FNXC:SystemPanel 2026-07-25-11:40: + Original symptom: "Restart agents" returned "SQLite Database class body has been removed + (VAL-REMOVAL-005)" because the route built its AgentStore from `rootDir` alone, with no + AsyncDataLayer, and the store fell back to the deleted sync SQLite path. The invariant — + not just the repro — is that the route always constructs its AgentStore against the scoped + project's AsyncDataLayer, and fails loudly when the project has none. + */ + it("constructs the AgentStore against the scoped project's PostgreSQL async layer", async () => { + agentStoreState.agents = [{ id: "agent-active", state: "active" }]; + const monitor = { pauseAgent: vi.fn(async () => ({})), resumeAgent: vi.fn(async () => ({})) }; + const { app } = createApp({ + deps: { + hasHeartbeatExecutor: true, + heartbeatMonitor: monitor as never, + isHeartbeatMonitorForProject: vi.fn(() => true), + resolveHeartbeatMonitor: vi.fn(() => monitor as never), + }, + }); + + const res = await postJson(app, "/api/system/agents/restart-all"); + expect(res.status).toBe(200); + expect(agentStoreState.lastOptions?.asyncLayer).toBe(PROJECT_ASYNC_LAYER); + }); + + it("fails loudly when the scoped project store has no async layer", async () => { + agentStoreState.agents = [{ id: "agent-active", state: "active" }]; + const monitor = { pauseAgent: vi.fn(async () => ({})), resumeAgent: vi.fn(async () => ({})) }; + const { app } = createApp({ + scopedStore: { getFusionDir: () => "/tmp/fusion-test", getAsyncLayer: () => null }, + deps: { + hasHeartbeatExecutor: true, + heartbeatMonitor: monitor as never, + isHeartbeatMonitorForProject: vi.fn(() => true), + resolveHeartbeatMonitor: vi.fn(() => monitor as never), + }, + }); + + const res = await postJson(app, "/api/system/agents/restart-all"); + expect(res.status).toBe(500); + expect(monitor.pauseAgent).not.toHaveBeenCalled(); + }); + it("409s when no lifecycle monitor is available", async () => { agentStoreState.agents = [{ id: "agent-active", state: "active" }]; const { app } = createApp(); diff --git a/packages/dashboard/src/routes/register-system-routes.ts b/packages/dashboard/src/routes/register-system-routes.ts index 0f1ac32f8a..5c71242ade 100644 --- a/packages/dashboard/src/routes/register-system-routes.ts +++ b/packages/dashboard/src/routes/register-system-routes.ts @@ -8,6 +8,7 @@ import { runLinkLocalFnBinary, runUseGlobalFnBinary, } from "../fn-binary-local-install.js"; +import { requireAsyncLayer } from "../require-async-layer.js"; import { writeSSEEvent } from "../sse-buffer.js"; import type { ApiRoutesContext } from "./types.js"; import type { SystemLogEntry } from "../server.js"; @@ -625,7 +626,19 @@ export function registerSystemRoutes(ctx: ApiRoutesContext, deps: SystemRouteDep if (rejectCrossOrigin(req, res)) return; try { const { store: scopedStore } = await getProjectContext(req); - const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); + /* + FNXC:SystemPanel 2026-07-25-11:40: + Restart-all reads agents from the same authoritative project PostgreSQL layer as every + other agent surface. Constructing AgentStore with only `rootDir` fell through to the + deleted sync SQLite Database path (VAL-REMOVAL-005), so the control threw + "SQLite Database class body has been removed" instead of bouncing agents. Fail loudly via + requireAsyncLayer when project wiring is incomplete rather than reading a SQLite shadow. + */ + const agentStore = new AgentStore({ + rootDir: scopedStore.getFusionDir(), + taskStore: scopedStore, + asyncLayer: requireAsyncLayer(scopedStore, "System panel agent restart-all"), + }); await agentStore.init(); const monitor =