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).
|
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();
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
Reference in New Issue
Block a user