feat(FN-1023): add project status reconciliation and apply to GET /api/projects route

- Add reconcileProjectStatuses() method to CentralCore that scans all registered projects and updates stale health records
- Integrate reconciliation into the GET /api/projects dashboard route so statuses are refreshed on read
- Add comprehensive tests for CentralCore reconciliation logic (144 lines)
- Add route-level tests verifying reconciliation runs before response (61 lines)
This commit is contained in:
gsxdsm
2026-04-05 23:09:22 -07:00
parent 6501535a86
commit 7224e383c0
4 changed files with 259 additions and 1 deletions

View File

@@ -53,6 +53,7 @@ const mockGetGlobalConcurrencyState = vi.fn().mockResolvedValue({
});
const mockInit = vi.fn().mockResolvedValue(undefined);
const mockClose = vi.fn().mockResolvedValue(undefined);
const mockReconcileProjectStatuses = vi.fn().mockResolvedValue([]);
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
@@ -69,6 +70,7 @@ vi.mock("@fusion/core", async () => {
getProjectHealth: mockGetProjectHealth,
getRecentActivity: mockGetRecentActivity,
getGlobalConcurrencyState: mockGetGlobalConcurrencyState,
reconcileProjectStatuses: mockReconcileProjectStatuses,
})),
};
});
@@ -530,3 +532,62 @@ describe("POST /api/projects route handler", () => {
expect((res.body as any).status).toBe("active");
});
});
describe("GET /api/projects route handler", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("calls reconcileProjectStatuses before listing projects", async () => {
const store = new MockStoreForRoutes();
const app = createServer(store as any);
mockReconcileProjectStatuses.mockResolvedValue([]);
mockListProjects.mockResolvedValue([
{
id: "proj_abc",
name: "Healed Project",
path: "/test/path",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
]);
const res = await request(app, "GET", "/api/projects");
expect(res.status).toBe(200);
expect(mockReconcileProjectStatuses).toHaveBeenCalledBefore(mockListProjects);
expect(mockListProjects).toHaveBeenCalled();
expect((res.body as any[])).toHaveLength(1);
expect((res.body as any[])[0].status).toBe("active");
});
it("returns healed status after reconciliation promotes stale projects", async () => {
const store = new MockStoreForRoutes();
const app = createServer(store as any);
// Simulate reconciliation promoting one stale project
mockReconcileProjectStatuses.mockResolvedValue([
{ projectId: "proj_stale", previousStatus: "initializing" },
]);
mockListProjects.mockResolvedValue([
{
id: "proj_stale",
name: "Formerly Stale",
path: "/test/stale",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
]);
const res = await request(app, "GET", "/api/projects");
expect(res.status).toBe(200);
expect(mockReconcileProjectStatuses).toHaveBeenCalledTimes(1);
expect((res.body as any[])[0].status).toBe("active");
});
});

View File

@@ -7256,7 +7256,11 @@ Output ONLY the prompt text (no markdown, no explanations).`;
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
// Reconcile stale "initializing" projects before listing so the
// dashboard never shows permanent loading spinners for legacy records.
await central.reconcileProjectStatuses();
const projects = prioritizeProjectsForCurrentDirectory(await central.listProjects());
await central.close();