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:
7
.changeset/system-panel-restart-agents-pg.md
Normal file
7
.changeset/system-panel-restart-agents-pg.md
Normal 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`.
|
||||
@@ -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<string, unknown>;
|
||||
} = { agents: [] };
|
||||
|
||||
vi.mock("@fusion/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@fusion/core")>();
|
||||
return {
|
||||
...actual,
|
||||
AgentStore: class {
|
||||
constructor(_opts: unknown) {}
|
||||
constructor(opts: unknown) {
|
||||
agentStoreState.lastOptions = opts as Record<string, unknown>;
|
||||
}
|
||||
async init(): Promise<void> {}
|
||||
async listAgents(): Promise<Array<{ id: string; state: string }>> {
|
||||
return agentStoreState.agents;
|
||||
@@ -80,8 +85,13 @@ interface HarnessOptions {
|
||||
options?: Record<string, unknown>;
|
||||
deps?: Partial<Parameters<typeof registerSystemRoutes>[1]>;
|
||||
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 = {}) {
|
||||
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();
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user