fix(dashboard): restore System panel "Restart all agents" under PostgreSQL

POST /system/agents/restart-all constructed its AgentStore from rootDir alone,
with no AsyncDataLayer, so it fell through to the sync SQLite Database path
deleted under VAL-REMOVAL-005 and threw instead of bouncing agents. It now
builds the store against the scoped project's PostgreSQL layer via
requireAsyncLayer, failing loudly when project wiring is incomplete rather
than reading a SQLite shadow. This was the last unmigrated AgentStore call
site; the route test harness lacked getAsyncLayer, which is why nothing
caught it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-25 09:05:41 -07:00
parent 2ea5ac206d
commit 13ff8850f4
3 changed files with 80 additions and 4 deletions

View File

@@ -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`.

View File

@@ -49,14 +49,19 @@ pause+resume each running project engine, and agent restart-all must bounce
only ACTIVE agents (operator-paused agents stay paused). 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<string, unknown>;
} = { agents: [] };
vi.mock("@fusion/core", async (importOriginal) => { vi.mock("@fusion/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@fusion/core")>(); const actual = await importOriginal<typeof import("@fusion/core")>();
return { return {
...actual, ...actual,
AgentStore: class { AgentStore: class {
constructor(_opts: unknown) {} constructor(opts: unknown) {
agentStoreState.lastOptions = opts as Record<string, unknown>;
}
async init(): Promise<void> {} async init(): Promise<void> {}
async listAgents(): Promise<Array<{ id: string; state: string }>> { async listAgents(): Promise<Array<{ id: string; state: string }>> {
return agentStoreState.agents; return agentStoreState.agents;
@@ -80,8 +85,13 @@ interface HarnessOptions {
options?: Record<string, unknown>; options?: Record<string, unknown>;
deps?: Partial<Parameters<typeof registerSystemRoutes>[1]>; deps?: Partial<Parameters<typeof registerSystemRoutes>[1]>;
store?: Record<string, unknown>; store?: Record<string, unknown>;
/** Scoped project store returned by getProjectContext; omit for the default PG-backed stub. */
scopedStore?: Record<string, unknown>;
} }
/* 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 = {}) { function createApp(harness: HarnessOptions = {}) {
const router = express.Router(); const router = express.Router();
const rethrowAsApiError = vi.fn((error: unknown) => { const rethrowAsApiError = vi.fn((error: unknown) => {
@@ -101,7 +111,10 @@ function createApp(harness: HarnessOptions = {}) {
getProjectContext: vi.fn(async () => ({ getProjectContext: vi.fn(async () => ({
projectId: "proj-1", projectId: "proj-1",
engine: undefined, 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, prioritizeProjectsForCurrentDirectory: (projects) => projects,
emitRemoteRouteDiagnostic: vi.fn(), emitRemoteRouteDiagnostic: vi.fn(),
@@ -574,6 +587,49 @@ describe("POST /system/agents/restart-all", () => {
expect(resumeAgent).toHaveBeenCalledWith("agent-active", expect.objectContaining({ clearPauseReason: true })); 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 () => { it("409s when no lifecycle monitor is available", async () => {
agentStoreState.agents = [{ id: "agent-active", state: "active" }]; agentStoreState.agents = [{ id: "agent-active", state: "active" }];
const { app } = createApp(); const { app } = createApp();

View File

@@ -8,6 +8,7 @@ import {
runLinkLocalFnBinary, runLinkLocalFnBinary,
runUseGlobalFnBinary, runUseGlobalFnBinary,
} from "../fn-binary-local-install.js"; } from "../fn-binary-local-install.js";
import { requireAsyncLayer } from "../require-async-layer.js";
import { writeSSEEvent } from "../sse-buffer.js"; import { writeSSEEvent } from "../sse-buffer.js";
import type { ApiRoutesContext } from "./types.js"; import type { ApiRoutesContext } from "./types.js";
import type { SystemLogEntry } from "../server.js"; import type { SystemLogEntry } from "../server.js";
@@ -625,7 +626,19 @@ export function registerSystemRoutes(ctx: ApiRoutesContext, deps: SystemRouteDep
if (rejectCrossOrigin(req, res)) return; if (rejectCrossOrigin(req, res)) return;
try { try {
const { store: scopedStore } = await getProjectContext(req); 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(); await agentStore.init();
const monitor = const monitor =