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 ──────────────────────────────────────────────────
/**