fix(FN-2684): harden system-stats when project lookup fails

- Keep /api/system-stats process and host metrics available even if scoped project resolution throws
- Fall back task and agent aggregates to zero when scoped task or agent stores cannot be loaded
- Preserve vitest process reporting while guarding optional vitestLastAutoKillAt lookup behind scoped context success
- Add route tests for scoped project-not-found fallback behavior and include a patch changeset for @runfusion/fusion
This commit is contained in:
Fusion
2026-04-27 06:03:53 -07:00
committed by gsxdsm
parent 93274b8e18
commit 3856c3c054
3 changed files with 90 additions and 26 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix `/api/system-stats` so process/system metrics still return when project resolution fails, with task and agent aggregates gracefully falling back to zero counts.

View File

@@ -394,6 +394,52 @@ describe("GET /api/system-stats", () => {
expect(defaultStore.listTasks).not.toHaveBeenCalled();
expect(res.body.taskStats.byColumn.todo).toBe(1);
});
it("returns system stats with zeroed task stats when scoped project resolution fails", async () => {
const defaultStore = createMockStore({
listTasks: vi.fn().mockResolvedValue([{ id: "FN-default", column: "triage" }]),
getFusionDir: vi.fn().mockReturnValue("/fake/default"),
});
vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockRejectedValue(
new Error(`Project "${projectId}" not found`),
);
const initSpy = vi.spyOn(AgentStore.prototype, "init").mockResolvedValue(undefined);
const listAgentsSpy = vi.spyOn(AgentStore.prototype, "listAgents").mockResolvedValue([]);
const res = await GET(buildApp(defaultStore), `/api/system-stats?projectId=${projectId}`);
expect(res.status).toBe(200);
expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith(projectId);
expect(defaultStore.listTasks).not.toHaveBeenCalled();
expect(initSpy).not.toHaveBeenCalled();
expect(listAgentsSpy).not.toHaveBeenCalled();
expect(res.body.systemStats).toEqual(
expect.objectContaining({
rss: expect.any(Number),
heapUsed: expect.any(Number),
}),
);
expect(res.body.taskStats).toEqual({
total: 0,
byColumn: {
triage: 0,
todo: 0,
"in-progress": 0,
"in-review": 0,
done: 0,
archived: 0,
},
active: 0,
agents: {
idle: 0,
active: 0,
running: 0,
error: 0,
},
});
expect(res.body.vitestLastAutoKillAt).toBeNull();
});
});
describe("POST /api/kill-vitest", () => {

View File

@@ -1224,23 +1224,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
*/
router.get("/system-stats", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const mem = process.memoryUsage();
const heapStats = v8.getHeapStatistics();
const load = os.loadavg();
const vitestProcessIds = await getVitestProcessIds();
let vitestLastAutoKillAt: string | null = null;
const globalSettingsStore = scopedStore.getGlobalSettingsStore?.();
if (globalSettingsStore?.getSettings) {
const globalSettings = await globalSettingsStore.getSettings();
const candidate = (globalSettings as Record<string, unknown>).vitestLastAutoKillAt;
if (typeof candidate === "string" && candidate.length > 0) {
vitestLastAutoKillAt = candidate;
}
}
const tasks = await scopedStore.listTasks({ slim: true, includeArchived: false });
let totalTasks = 0;
let activeTasks = 0;
const byColumn: Record<string, number> = {
triage: 0,
todo: 0,
@@ -1249,20 +1239,43 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
done: 0,
archived: 0,
};
for (const task of tasks) {
byColumn[task.column] = (byColumn[task.column] ?? 0) + 1;
}
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
const agents = await agentStore.listAgents();
const agentCounts = { idle: 0, active: 0, running: 0, error: 0 };
for (const agent of agents) {
const state = agent.state as keyof typeof agentCounts;
if (state in agentCounts) {
agentCounts[state] += 1;
let vitestLastAutoKillAt: string | null = null;
try {
const { store: scopedStore } = await getProjectContext(req);
const globalSettingsStore = scopedStore.getGlobalSettingsStore?.();
if (globalSettingsStore?.getSettings) {
const globalSettings = await globalSettingsStore.getSettings();
const candidate = (globalSettings as Record<string, unknown>).vitestLastAutoKillAt;
if (typeof candidate === "string" && candidate.length > 0) {
vitestLastAutoKillAt = candidate;
}
}
const tasks = await scopedStore.listTasks({ slim: true, includeArchived: false });
totalTasks = tasks.length;
for (const task of tasks) {
byColumn[task.column] = (byColumn[task.column] ?? 0) + 1;
if (task.column === "in-progress" || task.column === "in-review") {
activeTasks += 1;
}
}
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
const agents = await agentStore.listAgents();
for (const agent of agents) {
const state = agent.state as keyof typeof agentCounts;
if (state in agentCounts) {
agentCounts[state] += 1;
}
}
} catch {
// System stats should still be available even when project resolution/scoped store fails.
}
res.json({
@@ -1283,9 +1296,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
platform: `${process.platform}/${process.arch}`,
},
taskStats: {
total: tasks.length,
total: totalTasks,
byColumn,
active: tasks.filter((task) => task.column === "in-progress" || task.column === "in-review").length,
active: activeTasks,
agents: agentCounts,
},
vitestProcessCount: vitestProcessIds.length,