feat(FN-1662): fix project overview stats for non-first projects
- Always compute live task counts from the project-scoped store via getOrCreateProjectStore() - Remove broken path-comparison logic that only worked for the default project - Endpoint /api/projects/:id/health now returns accurate stats for all projects - Add tests covering multi-project health endpoint scenarios
This commit is contained in:
@@ -13,53 +13,71 @@ vi.mock("node:fs", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock CentralCore before importing routes
|
||||
const mockListProjects = vi.fn().mockResolvedValue([]);
|
||||
const mockGetProject = vi.fn().mockResolvedValue(null);
|
||||
const mockRegisterProject = vi.fn().mockResolvedValue({
|
||||
id: "proj_test123",
|
||||
name: "Test Project",
|
||||
path: "/test/path",
|
||||
status: "initializing",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const mockUpdateProject = vi.fn().mockResolvedValue({
|
||||
id: "proj_test123",
|
||||
name: "Test Project",
|
||||
path: "/test/path",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const mockUnregisterProject = vi.fn().mockResolvedValue(undefined);
|
||||
const mockGetProjectHealth = vi.fn().mockResolvedValue({
|
||||
projectId: "proj_test123",
|
||||
status: "active",
|
||||
activeTaskCount: 5,
|
||||
inFlightAgentCount: 2,
|
||||
totalTasksCompleted: 10,
|
||||
totalTasksFailed: 1,
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const mockGetRecentActivity = vi.fn().mockResolvedValue([]);
|
||||
const mockGetGlobalConcurrencyState = vi.fn().mockResolvedValue({
|
||||
globalMaxConcurrent: 4,
|
||||
currentlyActive: 2,
|
||||
queuedCount: 0,
|
||||
projectsActive: { proj_test123: 2 },
|
||||
});
|
||||
const mockUpdateGlobalConcurrency = vi.fn().mockResolvedValue({
|
||||
globalMaxConcurrent: 10,
|
||||
currentlyActive: 2,
|
||||
queuedCount: 0,
|
||||
projectsActive: { proj_test123: 2 },
|
||||
});
|
||||
const mockInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockClose = vi.fn().mockResolvedValue(undefined);
|
||||
const mockReconcileProjectStatuses = vi.fn().mockResolvedValue([]);
|
||||
// Use vi.hoisted() for mock functions that need to be accessible in hoisted vi.mock calls
|
||||
const {
|
||||
mockListProjects,
|
||||
mockGetProject,
|
||||
mockRegisterProject,
|
||||
mockUpdateProject,
|
||||
mockUnregisterProject,
|
||||
mockGetProjectHealth,
|
||||
mockGetRecentActivity,
|
||||
mockGetGlobalConcurrencyState,
|
||||
mockUpdateGlobalConcurrency,
|
||||
mockInit,
|
||||
mockClose,
|
||||
mockReconcileProjectStatuses,
|
||||
mockGetOrCreateProjectStore,
|
||||
} = vi.hoisted(() => ({
|
||||
mockListProjects: vi.fn().mockResolvedValue([]),
|
||||
mockGetProject: vi.fn().mockResolvedValue(null),
|
||||
mockRegisterProject: vi.fn().mockResolvedValue({
|
||||
id: "proj_test123",
|
||||
name: "Test Project",
|
||||
path: "/test/path",
|
||||
status: "initializing",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
}),
|
||||
mockUpdateProject: vi.fn().mockResolvedValue({
|
||||
id: "proj_test123",
|
||||
name: "Test Project",
|
||||
path: "/test/path",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
}),
|
||||
mockUnregisterProject: vi.fn().mockResolvedValue(undefined),
|
||||
mockGetProjectHealth: vi.fn().mockResolvedValue({
|
||||
projectId: "proj_test123",
|
||||
status: "active",
|
||||
activeTaskCount: 5,
|
||||
inFlightAgentCount: 2,
|
||||
totalTasksCompleted: 10,
|
||||
totalTasksFailed: 1,
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
}),
|
||||
mockGetRecentActivity: vi.fn().mockResolvedValue([]),
|
||||
mockGetGlobalConcurrencyState: vi.fn().mockResolvedValue({
|
||||
globalMaxConcurrent: 4,
|
||||
currentlyActive: 2,
|
||||
queuedCount: 0,
|
||||
projectsActive: { proj_test123: 2 },
|
||||
}),
|
||||
mockUpdateGlobalConcurrency: vi.fn().mockResolvedValue({
|
||||
globalMaxConcurrent: 10,
|
||||
currentlyActive: 2,
|
||||
queuedCount: 0,
|
||||
projectsActive: { proj_test123: 2 },
|
||||
}),
|
||||
mockInit: vi.fn().mockResolvedValue(undefined),
|
||||
mockClose: vi.fn().mockResolvedValue(undefined),
|
||||
mockReconcileProjectStatuses: vi.fn().mockResolvedValue([]),
|
||||
// Mock store registry - can be configured per-test to return specific stores per project ID
|
||||
mockGetOrCreateProjectStore: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", async () => {
|
||||
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||
@@ -82,6 +100,12 @@ vi.mock("@fusion/core", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock project-store-resolver for multi-project health tests
|
||||
vi.mock("../project-store-resolver.js", () => ({
|
||||
getOrCreateProjectStore: mockGetOrCreateProjectStore,
|
||||
invalidateAllGlobalSettingsCaches: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import after mocking - just import the types and verify the routes exist
|
||||
import {
|
||||
fetchProjects,
|
||||
@@ -646,3 +670,248 @@ describe("PUT /api/global-concurrency route handler", () => {
|
||||
expect((res.body as any).globalMaxConcurrent).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /api/projects/:id/health Route Tests ─────────────────────────────────
|
||||
// Regression tests for multi-project health resolution (FN-1662)
|
||||
|
||||
describe("GET /api/projects/:id/health route handler", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset mock to default behavior - returns new MockStoreForRoutes by default
|
||||
mockGetOrCreateProjectStore.mockReset();
|
||||
mockGetOrCreateProjectStore.mockImplementation(async () => new MockStoreForRoutes());
|
||||
});
|
||||
|
||||
// Helper to create a mock store with specific tasks
|
||||
function createMockStoreWithTasks(tasks: Array<{ id: string; column: string }>): MockStoreForRoutes & { listTasks: ReturnType<typeof vi.fn> } {
|
||||
const mockStore = new MockStoreForRoutes() as MockStoreForRoutes & { listTasks: ReturnType<typeof vi.fn> };
|
||||
mockStore.listTasks = vi.fn().mockResolvedValue(tasks);
|
||||
return mockStore;
|
||||
}
|
||||
|
||||
it("returns project-specific task counts when using project-scoped store", async () => {
|
||||
// Create a store with specific tasks
|
||||
const projectATasks = [
|
||||
{ id: "FN-1", column: "triage" },
|
||||
{ id: "FN-2", column: "todo" },
|
||||
{ id: "FN-3", column: "in-progress" },
|
||||
{ id: "FN-4", column: "in-review" },
|
||||
{ id: "FN-5", column: "done" },
|
||||
{ id: "FN-6", column: "archived" },
|
||||
];
|
||||
|
||||
const storeA = createMockStoreWithTasks(projectATasks);
|
||||
mockGetOrCreateProjectStore.mockResolvedValue(storeA);
|
||||
|
||||
// Setup: Project A
|
||||
mockGetProject.mockResolvedValue({
|
||||
id: "proj_a",
|
||||
name: "Project A",
|
||||
path: "/projects/a",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
// Central health returns stale data (common scenario)
|
||||
mockGetProjectHealth.mockResolvedValue({
|
||||
projectId: "proj_a",
|
||||
status: "active",
|
||||
activeTaskCount: 999, // Stale value
|
||||
inFlightAgentCount: 999, // Stale value
|
||||
totalTasksCompleted: 999, // Stale value
|
||||
totalTasksFailed: 0,
|
||||
updatedAt: "2020-01-01T00:00:00.000Z", // Very old timestamp
|
||||
});
|
||||
|
||||
const defaultStore = new MockStoreForRoutes();
|
||||
const app = createServer(defaultStore as any);
|
||||
|
||||
const res = await request(app, "GET", "/api/projects/proj_a/health");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const health = res.body as Record<string, unknown>;
|
||||
|
||||
// Should have computed counts from project-scoped store, not stale central data
|
||||
expect(health.projectId).toBe("proj_a");
|
||||
expect(health.activeTaskCount).toBe(4); // triage + todo + in-progress + in-review
|
||||
expect(health.inFlightAgentCount).toBe(1); // only in-progress
|
||||
expect(health.totalTasksCompleted).toBe(2); // done + archived
|
||||
expect(health.status).toBe("active");
|
||||
});
|
||||
|
||||
it("does not bleed counts between different projects", async () => {
|
||||
// Project A has 3 tasks
|
||||
const projectATasks = [
|
||||
{ id: "FN-1", column: "triage" },
|
||||
{ id: "FN-2", column: "in-progress" },
|
||||
{ id: "FN-3", column: "done" },
|
||||
];
|
||||
// Project B has 5 tasks
|
||||
const projectBTasks = [
|
||||
{ id: "FN-10", column: "todo" },
|
||||
{ id: "FN-11", column: "todo" },
|
||||
{ id: "FN-12", column: "in-progress" },
|
||||
{ id: "FN-13", column: "in-review" },
|
||||
{ id: "FN-14", column: "archived" },
|
||||
];
|
||||
|
||||
const storeA = createMockStoreWithTasks(projectATasks);
|
||||
const storeB = createMockStoreWithTasks(projectBTasks);
|
||||
|
||||
// Configure mock to return different stores per project
|
||||
mockGetOrCreateProjectStore.mockImplementation(async (projectId: string) => {
|
||||
if (projectId === "proj_a") return storeA;
|
||||
if (projectId === "proj_b") return storeB;
|
||||
return new MockStoreForRoutes();
|
||||
});
|
||||
|
||||
const defaultStore = new MockStoreForRoutes();
|
||||
const app = createServer(defaultStore as any);
|
||||
|
||||
// Request health for project A
|
||||
mockGetProject.mockResolvedValue({
|
||||
id: "proj_a",
|
||||
name: "Project A",
|
||||
path: "/projects/a",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
mockGetProjectHealth.mockResolvedValue({
|
||||
projectId: "proj_a",
|
||||
status: "active",
|
||||
activeTaskCount: 100,
|
||||
inFlightAgentCount: 50,
|
||||
totalTasksCompleted: 25,
|
||||
totalTasksFailed: 0,
|
||||
updatedAt: "2020-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const resA = await request(app, "GET", "/api/projects/proj_a/health");
|
||||
|
||||
expect(resA.status).toBe(200);
|
||||
const healthA = resA.body as Record<string, unknown>;
|
||||
|
||||
// Project A: 1 triage + 1 in-progress + 1 done = 3 tasks total, 2 active, 1 in-flight
|
||||
expect(healthA.activeTaskCount).toBe(2); // triage + in-progress
|
||||
expect(healthA.inFlightAgentCount).toBe(1); // in-progress
|
||||
expect(healthA.totalTasksCompleted).toBe(1); // done
|
||||
|
||||
// Request health for project B
|
||||
mockGetProject.mockResolvedValue({
|
||||
id: "proj_b",
|
||||
name: "Project B",
|
||||
path: "/projects/b",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
mockGetProjectHealth.mockResolvedValue({
|
||||
projectId: "proj_b",
|
||||
status: "active",
|
||||
activeTaskCount: 200,
|
||||
inFlightAgentCount: 100,
|
||||
totalTasksCompleted: 50,
|
||||
totalTasksFailed: 0,
|
||||
updatedAt: "2020-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const resB = await request(app, "GET", "/api/projects/proj_b/health");
|
||||
|
||||
expect(resB.status).toBe(200);
|
||||
const healthB = resB.body as Record<string, unknown>;
|
||||
|
||||
// Project B: 2 todo + 1 in-progress + 1 in-review = 4 active, 1 in-flight
|
||||
expect(healthB.activeTaskCount).toBe(4); // 2 todo + 1 in-progress + 1 in-review
|
||||
expect(healthB.inFlightAgentCount).toBe(1); // in-progress
|
||||
expect(healthB.totalTasksCompleted).toBe(1); // archived
|
||||
|
||||
// Verify no bleed-through: project A counts should not equal project B counts
|
||||
expect(healthA.activeTaskCount).not.toBe(healthB.activeTaskCount);
|
||||
});
|
||||
|
||||
it("returns valid health response when central health row is missing but project exists", async () => {
|
||||
// No central health row for this project
|
||||
mockGetProjectHealth.mockResolvedValue(null);
|
||||
|
||||
const tasks = [
|
||||
{ id: "FN-1", column: "todo" },
|
||||
{ id: "FN-2", column: "in-progress" },
|
||||
{ id: "FN-3", column: "in-progress" },
|
||||
{ id: "FN-4", column: "done" },
|
||||
];
|
||||
|
||||
const mockStore = createMockStoreWithTasks(tasks);
|
||||
mockGetOrCreateProjectStore.mockResolvedValue(mockStore);
|
||||
|
||||
mockGetProject.mockResolvedValue({
|
||||
id: "proj_new",
|
||||
name: "New Project",
|
||||
path: "/projects/new",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const defaultStore = new MockStoreForRoutes();
|
||||
const app = createServer(defaultStore as any);
|
||||
|
||||
// Should NOT return 404 - should synthesize valid health from project store
|
||||
const res = await request(app, "GET", "/api/projects/proj_new/health");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const health = res.body as Record<string, unknown>;
|
||||
|
||||
// Should have computed counts from project store
|
||||
expect(health.projectId).toBe("proj_new");
|
||||
expect(health.activeTaskCount).toBe(3); // todo + 2 in-progress
|
||||
expect(health.inFlightAgentCount).toBe(2); // 2 in-progress
|
||||
expect(health.totalTasksCompleted).toBe(1); // done
|
||||
expect(health.status).toBe("active");
|
||||
});
|
||||
|
||||
it("uses slim tasks when computing counts", async () => {
|
||||
const mockStore = createMockStoreWithTasks([
|
||||
{ id: "FN-1", column: "todo" },
|
||||
{ id: "FN-2", column: "in-progress" },
|
||||
]);
|
||||
mockGetOrCreateProjectStore.mockResolvedValue(mockStore);
|
||||
|
||||
mockGetProject.mockResolvedValue({
|
||||
id: "proj_test",
|
||||
name: "Test",
|
||||
path: "/test",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
mockGetProjectHealth.mockResolvedValue({
|
||||
projectId: "proj_test",
|
||||
status: "active",
|
||||
activeTaskCount: 100,
|
||||
inFlightAgentCount: 50,
|
||||
totalTasksCompleted: 25,
|
||||
totalTasksFailed: 0,
|
||||
updatedAt: "2020-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const defaultStore = new MockStoreForRoutes();
|
||||
const app = createServer(defaultStore as any);
|
||||
|
||||
const res = await request(app, "GET", "/api/projects/proj_test/health");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Verify that listTasks was called with { slim: true }
|
||||
expect(mockStore.listTasks).toHaveBeenCalledWith({ slim: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13208,12 +13208,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
/**
|
||||
* GET /api/projects/:id/health
|
||||
* Get health metrics for a specific project.
|
||||
* Computes live task counts from the project-scoped task store to ensure
|
||||
* accurate stats for all projects, not just the default/first project.
|
||||
* Returns: ProjectHealth
|
||||
*/
|
||||
router.get("/projects/:id/health", async (req, res) => {
|
||||
try {
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const { realpath } = await import("node:fs/promises");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
@@ -13223,39 +13224,39 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
throw notFound("Project not found");
|
||||
}
|
||||
|
||||
const health = await central.getProjectHealth(req.params.id);
|
||||
// Use the project-scoped store resolver to get the correct store for
|
||||
// this project. This ensures we compute counts from the right project,
|
||||
// regardless of which project is the dashboard's default.
|
||||
const projectStore = await getOrCreateProjectStore(req.params.id);
|
||||
|
||||
// Compute live task counts from the project-specific store
|
||||
const tasks = await projectStore.listTasks({ slim: true });
|
||||
const activeCols = new Set(["triage", "todo", "in-progress", "in-review"]);
|
||||
const activeTaskCount = tasks.filter((t) => activeCols.has(t.column)).length;
|
||||
const inFlightAgentCount = tasks.filter((t) => t.column === "in-progress").length;
|
||||
const totalTasksCompleted = tasks.filter((t) => t.column === "done" || t.column === "archived").length;
|
||||
|
||||
// Get central health metadata (if available) to preserve non-count fields
|
||||
const centralHealth = await central.getProjectHealth(req.params.id);
|
||||
await central.close();
|
||||
|
||||
if (!health) {
|
||||
throw notFound("Project health not found");
|
||||
}
|
||||
|
||||
// If this dashboard serves the requested project, compute live counts
|
||||
// from the task store instead of relying on cached central DB values.
|
||||
try {
|
||||
const storePath = await realpath(store.getRootDir());
|
||||
const projectPath = await realpath(project.path);
|
||||
// Build response: use central health as base if available, otherwise synthesize
|
||||
const healthBase = centralHealth ?? {
|
||||
projectId: req.params.id,
|
||||
status: project.status ?? "active",
|
||||
activeTaskCount: 0,
|
||||
inFlightAgentCount: 0,
|
||||
totalTasksCompleted: 0,
|
||||
totalTasksFailed: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (storePath === projectPath) {
|
||||
const tasks = await store.listTasks({ slim: true });
|
||||
const activeCols = new Set(["triage", "todo", "in-progress", "in-review"]);
|
||||
const activeTaskCount = tasks.filter((t) => activeCols.has(t.column)).length;
|
||||
const inFlightAgentCount = tasks.filter((t) => t.column === "in-progress").length;
|
||||
const totalTasksCompleted = tasks.filter((t) => t.column === "done" || t.column === "archived").length;
|
||||
|
||||
res.json({
|
||||
...health,
|
||||
activeTaskCount,
|
||||
inFlightAgentCount,
|
||||
totalTasksCompleted,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// realpath may fail if a path doesn't exist; fall through to cached data
|
||||
}
|
||||
|
||||
res.json(health);
|
||||
res.json({
|
||||
...healthBase,
|
||||
activeTaskCount,
|
||||
inFlightAgentCount,
|
||||
totalTasksCompleted,
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
|
||||
Reference in New Issue
Block a user