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 6a0ea822d2
commit 31437c5a74
4 changed files with 259 additions and 1 deletions

View File

@@ -390,6 +390,150 @@ describe("CentralCore", () => {
});
});
describe("project status reconciliation", () => {
beforeEach(async () => {
await central.init();
});
it("should promote stale initializing projects to active", async () => {
const projectPath = join(tempDir, "stale-project");
mkdirSync(projectPath);
projectPaths.push(projectPath);
// Register a project (starts as "initializing")
const project = await central.registerProject({
name: "Stale Project",
path: projectPath,
});
expect(project.status).toBe("initializing");
// Reconcile — should promote to active
const reconciled = await central.reconcileProjectStatuses();
expect(reconciled).toHaveLength(1);
expect(reconciled[0].projectId).toBe(project.id);
expect(reconciled[0].previousStatus).toBe("initializing");
// Verify project is now active
const updated = await central.getProject(project.id);
expect(updated?.status).toBe("active");
});
it("should update both projects and projectHealth tables", async () => {
const projectPath = join(tempDir, "health-stale");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Health Stale",
path: projectPath,
});
// Health row should be "initializing" initially
const healthBefore = await central.getProjectHealth(project.id);
expect(healthBefore?.status).toBe("initializing");
// Reconcile
await central.reconcileProjectStatuses();
// Both project and health should be "active"
const updatedProject = await central.getProject(project.id);
expect(updatedProject?.status).toBe("active");
const updatedHealth = await central.getProjectHealth(project.id);
expect(updatedHealth?.status).toBe("active");
});
it("should not affect active projects", async () => {
const projectPath = join(tempDir, "active-project");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Active Project",
path: projectPath,
});
await central.updateProject(project.id, { status: "active" });
const reconciled = await central.reconcileProjectStatuses();
expect(reconciled).toHaveLength(0);
const unchanged = await central.getProject(project.id);
expect(unchanged?.status).toBe("active");
});
it("should not affect paused or errored projects", async () => {
const pausedPath = join(tempDir, "paused-project");
mkdirSync(pausedPath);
projectPaths.push(pausedPath);
const erroredPath = join(tempDir, "errored-project");
mkdirSync(erroredPath);
projectPaths.push(erroredPath);
const paused = await central.registerProject({
name: "Paused Project",
path: pausedPath,
});
await central.updateProject(paused.id, { status: "paused" });
const errored = await central.registerProject({
name: "Errored Project",
path: erroredPath,
});
await central.updateProject(errored.id, { status: "errored" });
const reconciled = await central.reconcileProjectStatuses();
expect(reconciled).toHaveLength(0);
expect((await central.getProject(paused.id))?.status).toBe("paused");
expect((await central.getProject(errored.id))?.status).toBe("errored");
});
it("should be idempotent — calling twice is a no-op after promotion", async () => {
const projectPath = join(tempDir, "idempotent-project");
mkdirSync(projectPath);
projectPaths.push(projectPath);
await central.registerProject({
name: "Idempotent Project",
path: projectPath,
});
// First call promotes
const first = await central.reconcileProjectStatuses();
expect(first).toHaveLength(1);
// Second call is a no-op
const second = await central.reconcileProjectStatuses();
expect(second).toHaveLength(0);
});
it("should reconcile multiple stale projects at once", async () => {
const paths: string[] = [];
for (let i = 0; i < 3; i++) {
const p = join(tempDir, `multi-stale-${i}`);
mkdirSync(p);
projectPaths.push(p);
paths.push(p);
}
await central.registerProject({ name: "Stale A", path: paths[0] });
await central.registerProject({ name: "Stale B", path: paths[1] });
await central.registerProject({ name: "Stale C", path: paths[2] });
const reconciled = await central.reconcileProjectStatuses();
expect(reconciled).toHaveLength(3);
const projects = await central.listProjects();
expect(projects.every((p) => p.status === "active")).toBe(true);
});
it("should return empty array when no projects exist", async () => {
const reconciled = await central.reconcileProjectStatuses();
expect(reconciled).toEqual([]);
});
});
describe("project health", () => {
beforeEach(async () => {
await central.init();

View File

@@ -351,6 +351,55 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
return updated;
}
/**
* Reconcile stale project statuses.
*
* Projects stuck in `status: "initializing"` are considered stale because
* all current registration paths (`autoRegisterProject`, CLI commands, and
* the dashboard POST endpoint) immediately promote to `"active"` after
* registration. Any project still in `"initializing"` was created before
* those fixes and should be promoted to `"active"`.
*
* Updates both the `projects` and `projectHealth` tables atomically.
* Non-initializing projects are not affected.
*
* @returns Array of reconciled projects with their previous status
*/
async reconcileProjectStatuses(): Promise<Array<{ projectId: string; previousStatus: string }>> {
this.ensureInitialized();
const staleProjects = this.db!.prepare(
"SELECT id, status FROM projects WHERE status = ?"
).all("initializing") as Array<{ id: string; status: string }>;
if (staleProjects.length === 0) return [];
const now = new Date().toISOString();
const reconciled: Array<{ projectId: string; previousStatus: string }> = [];
this.db!.transaction(() => {
for (const project of staleProjects) {
// Update projects table
this.db!.prepare(
`UPDATE projects SET status = ?, updatedAt = ? WHERE id = ?`
).run("active", now, project.id);
// Update projectHealth table (if row exists)
this.db!.prepare(
`UPDATE projectHealth SET status = ?, updatedAt = ? WHERE projectId = ?`
).run("active", now, project.id);
reconciled.push({ projectId: project.id, previousStatus: project.status });
}
});
if (reconciled.length > 0) {
this.db!.bumpLastModified();
}
return reconciled;
}
// ── Project Health API ──────────────────────────────────────────────────
/**

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();