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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user