feat(FN-1733): add dedicated pause/resume APIs for projects

- Add pauseProject() and resumeProject() methods to ProjectEngineManager
- Wire pause and resume routes to engineManager with proper error handling
- Update frontend useProjectActions hook to use dedicated pause/resume APIs
- Add comprehensive tests for pause/resume in ProjectEngineManager
- Add route tests for project pause/resume with engineManager mocks
- Fix mock stubs for getProject/updateProject/updateProjectHealth
This commit is contained in:
Fusion
2026-04-15 19:53:22 -07:00
committed by gsxdsm
parent 01a221fbe8
commit 9bd437e166
7 changed files with 445 additions and 20 deletions

View File

@@ -38,6 +38,8 @@ function createMockCentralCore(projects: RegisteredProject[]): CentralCore {
getProjectByPath: vi.fn().mockImplementation((path: string) =>
Promise.resolve(projects.find((p) => p.path === path) ?? null),
),
updateProject: vi.fn().mockResolvedValue(undefined),
updateProjectHealth: vi.fn().mockResolvedValue(undefined),
} as unknown as CentralCore;
}
@@ -291,6 +293,100 @@ describe("ProjectEngineManager", () => {
});
});
describe("pauseProject", () => {
it("calls updateProject and updateProjectHealth with 'paused'", async () => {
const manager = new ProjectEngineManager(centralCore);
await manager.pauseProject("proj_aaa");
expect(centralCore.updateProject).toHaveBeenCalledWith("proj_aaa", { status: "paused" });
expect(centralCore.updateProjectHealth).toHaveBeenCalledWith("proj_aaa", { status: "paused" });
});
it("stops engine if running and removes it from getEngine", async () => {
const manager = new ProjectEngineManager(centralCore);
const engine = await manager.ensureEngine("proj_aaa");
await manager.pauseProject("proj_aaa");
expect(engine.stop).toHaveBeenCalledOnce();
expect(manager.getEngine("proj_aaa")).toBeUndefined();
});
it("removes from starting set to prevent stalled starts from completing", async () => {
const manager = new ProjectEngineManager(centralCore);
// Start an engine (await it to ensure it's in the starting map)
const enginePromise = manager.ensureEngine("proj_aaa");
// Wait for the engine to be added to starting set
await vi.waitFor(() => {
expect(manager.has("proj_aaa")).toBe(true);
});
// Now pause - this should remove from starting set
await manager.pauseProject("proj_aaa");
// The engine should not be running
expect(manager.getEngine("proj_aaa")).toBeUndefined();
expect(manager.has("proj_aaa")).toBe(false);
// The pending promise should resolve but engine should not exist
await enginePromise.catch(() => {});
});
it("throws if manager is stopped", async () => {
const manager = new ProjectEngineManager(centralCore);
await manager.stopAll();
await expect(manager.pauseProject("proj_aaa")).rejects.toThrow(
"ProjectEngineManager is stopped",
);
});
});
describe("resumeProject", () => {
it("calls updateProject and updateProjectHealth with 'active'", async () => {
const manager = new ProjectEngineManager(centralCore);
await manager.resumeProject("proj_aaa");
expect(centralCore.updateProject).toHaveBeenCalledWith("proj_aaa", { status: "active" });
expect(centralCore.updateProjectHealth).toHaveBeenCalledWith("proj_aaa", { status: "active" });
});
it("starts engine via ensureEngine", async () => {
const manager = new ProjectEngineManager(centralCore);
await manager.resumeProject("proj_aaa");
expect(manager.getEngine("proj_aaa")).toBeDefined();
expect(ProjectEngine).toHaveBeenCalledTimes(1);
});
it("throws if manager is stopped", async () => {
const manager = new ProjectEngineManager(centralCore);
await manager.stopAll();
await expect(manager.resumeProject("proj_aaa")).rejects.toThrow(
"ProjectEngineManager is stopped",
);
});
});
describe("ensureEngine with paused projects", () => {
it("rejects when project status is 'paused'", async () => {
const manager = new ProjectEngineManager(centralCore);
// Mock getProject to return a paused project
(centralCore.getProject as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...projectA,
status: "paused",
});
await expect(manager.ensureEngine("proj_aaa")).rejects.toThrow(
"Project proj_aaa is paused",
);
});
});
describe("onProjectAccessed", () => {
it("starts engine in background for unknown project", async () => {
const manager = new ProjectEngineManager(centralCore);
@@ -392,6 +488,41 @@ describe("ProjectEngineManager", () => {
manager.stopReconciliation();
});
it("skips paused projects during reconciliation", async () => {
const manager = new ProjectEngineManager(centralCore);
// Make projectA paused
const pausedProjectA = { ...projectA, status: "paused" as const };
// Mock getProject to return paused status for projectA
(centralCore.getProject as ReturnType<typeof vi.fn>).mockImplementation((id: string) => {
if (id === "proj_aaa") {
return Promise.resolve(pausedProjectA);
}
return Promise.resolve([projectA, projectB, projectC].find((p) => p.id === id) ?? null);
});
// Mock listProjects to return all projects including the paused one
(centralCore.listProjects as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
pausedProjectA,
projectB,
projectC,
]);
manager.startReconciliation(1000);
// Advance time to trigger reconciliation
await vi.advanceTimersByTimeAsync(1000);
// projectA should NOT have an engine (it's paused)
expect(manager.getEngine("proj_aaa")).toBeUndefined();
// projectB and projectC should have engines (they're active)
expect(manager.getEngine("proj_bbb")).toBeDefined();
expect(manager.getEngine("proj_ccc")).toBeDefined();
manager.stopReconciliation();
});
it("retries failed project starts on subsequent reconciliation ticks", async () => {
// Track how many times start() is called to fail only the FIRST set
let startCallCount = 0;

View File

@@ -110,6 +110,49 @@ export class ProjectEngineManager {
return this.engines.has(projectId) || this.starting.has(projectId);
}
// ── Pause / Resume ────────────────────────────────────────────────────
/**
* Pause a project: update its status in CentralCore and stop its engine.
* This prevents the reconciliation loop from restarting the engine.
*/
async pauseProject(projectId: string): Promise<void> {
if (this.stopped) throw new Error("ProjectEngineManager is stopped");
runtimeLog.log(`Pausing project ${projectId}`);
// Update CentralCore status
await this.centralCore.updateProject(projectId, { status: "paused" });
await this.centralCore.updateProjectHealth(projectId, { status: "paused" });
// Stop the engine if running
const engine = this.engines.get(projectId);
if (engine) {
await engine.stop();
this.engines.delete(projectId);
runtimeLog.log(`Stopped engine for paused project ${projectId}`);
}
// Remove from starting set to prevent a stalled start from completing
this.starting.delete(projectId);
}
/**
* Resume a paused project: update its status in CentralCore and start its engine.
*/
async resumeProject(projectId: string): Promise<void> {
if (this.stopped) throw new Error("ProjectEngineManager is stopped");
runtimeLog.log(`Resuming project ${projectId}`);
// Update CentralCore status
await this.centralCore.updateProject(projectId, { status: "active" });
await this.centralCore.updateProjectHealth(projectId, { status: "active" });
// Start the engine
await this.ensureEngine(projectId);
}
// ── Lifecycle ──
/**
@@ -123,6 +166,12 @@ export class ProjectEngineManager {
): Promise<ProjectEngine> {
if (this.stopped) throw new Error("ProjectEngineManager is stopped");
// Check if the project is paused before starting
const project = await this.centralCore.getProject(projectId);
if (project && (project.status as string) === "paused") {
throw new Error(`Project ${projectId} is paused`);
}
const existing = this.engines.get(projectId);
if (existing) return existing;
@@ -283,8 +332,11 @@ export class ProjectEngineManager {
const projects = await this.centralCore.listProjects();
if (projects.length === 0) return;
// Filter out paused projects — they should not have engines started
const activeProjects = projects.filter((p) => (p.status as string) !== "paused");
// Find projects that don't have running or pending engines
const missing = projects.filter((p) => !this.has(p.id));
const missing = activeProjects.filter((p) => !this.has(p.id));
if (missing.length === 0) return;
runtimeLog.log(
@@ -318,6 +370,11 @@ export class ProjectEngineManager {
throw new Error(`Project ${projectId} not found in CentralCore`);
}
// Prevent starting engines for paused projects
if ((project.status as string) === "paused") {
throw new Error(`Project ${projectId} is paused`);
}
const runtimeConfig = this.buildRuntimeConfig(project);
const engineOptions = this.buildEngineOptions(project, overrides);