feat(FN-1626): merge fusion/fn-1626
- docs(FN-1626): document PWA home bar gap token and updated bottom-layout contract - fix(pre-existing): add missing invalidateAllGlobalSettingsCaches export to unblock build - feat(FN-1626): update regression tests for standalone spacing and PWA home bar gap - feat(FN-1626): add PWA home bar gap token and update bottom-layout CSS contract
This commit is contained in:
313
packages/engine/src/__tests__/project-engine-manager.test.ts
Normal file
313
packages/engine/src/__tests__/project-engine-manager.test.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Mock ProjectEngine before importing the manager
|
||||
vi.mock("../project-engine.js", () => {
|
||||
return {
|
||||
ProjectEngine: vi.fn().mockImplementation((config, _centralCore, _options) => ({
|
||||
start: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
getTaskStore: vi.fn().mockReturnValue({ projectId: config.projectId }),
|
||||
getHeartbeatMonitor: vi.fn().mockReturnValue(undefined),
|
||||
getHeartbeatTriggerScheduler: vi.fn().mockReturnValue(undefined),
|
||||
getAutomationStore: vi.fn().mockReturnValue(undefined),
|
||||
getRuntime: vi.fn().mockReturnValue({
|
||||
getMissionAutopilot: vi.fn().mockReturnValue(undefined),
|
||||
getMissionExecutionLoop: vi.fn().mockReturnValue(undefined),
|
||||
}),
|
||||
getWorkingDirectory: vi.fn().mockReturnValue(config.workingDirectory),
|
||||
onMerge: vi.fn().mockResolvedValue(undefined),
|
||||
_config: config,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
import { ProjectEngineManager } from "../project-engine-manager.js";
|
||||
import { ProjectEngine } from "../project-engine.js";
|
||||
import type { RegisteredProject, CentralCore } from "@fusion/core";
|
||||
|
||||
function createMockCentralCore(projects: RegisteredProject[]): CentralCore {
|
||||
const projectMap = new Map(projects.map((p) => [p.id, p]));
|
||||
return {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
listProjects: vi.fn().mockResolvedValue(projects),
|
||||
getProject: vi.fn().mockImplementation((id: string) =>
|
||||
Promise.resolve(projectMap.get(id) ?? null),
|
||||
),
|
||||
getProjectByPath: vi.fn().mockImplementation((path: string) =>
|
||||
Promise.resolve(projects.find((p) => p.path === path) ?? null),
|
||||
),
|
||||
} as unknown as CentralCore;
|
||||
}
|
||||
|
||||
function makeProject(id: string, name: string, path: string): RegisteredProject {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
path,
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as RegisteredProject;
|
||||
}
|
||||
|
||||
describe("ProjectEngineManager", () => {
|
||||
let centralCore: CentralCore;
|
||||
const projectA = makeProject("proj_aaa", "Project A", "/tmp/a");
|
||||
const projectB = makeProject("proj_bbb", "Project B", "/tmp/b");
|
||||
const projectC = makeProject("proj_ccc", "Project C", "/tmp/c");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
centralCore = createMockCentralCore([projectA, projectB, projectC]);
|
||||
});
|
||||
|
||||
describe("ensureEngine", () => {
|
||||
it("creates and starts an engine for a registered project", async () => {
|
||||
const manager = new ProjectEngineManager(centralCore);
|
||||
const engine = await manager.ensureEngine("proj_aaa");
|
||||
|
||||
expect(engine).toBeDefined();
|
||||
expect(engine.start).toHaveBeenCalledOnce();
|
||||
expect(ProjectEngine).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectId: "proj_aaa",
|
||||
workingDirectory: "/tmp/a",
|
||||
isolationMode: "in-process",
|
||||
}),
|
||||
centralCore,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns existing engine on repeated calls", async () => {
|
||||
const manager = new ProjectEngineManager(centralCore);
|
||||
const engine1 = await manager.ensureEngine("proj_aaa");
|
||||
const engine2 = await manager.ensureEngine("proj_aaa");
|
||||
|
||||
expect(engine1).toBe(engine2);
|
||||
expect(engine1.start).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("deduplicates concurrent start requests", async () => {
|
||||
const manager = new ProjectEngineManager(centralCore);
|
||||
|
||||
const [e1, e2, e3] = await Promise.all([
|
||||
manager.ensureEngine("proj_aaa"),
|
||||
manager.ensureEngine("proj_aaa"),
|
||||
manager.ensureEngine("proj_aaa"),
|
||||
]);
|
||||
|
||||
expect(e1).toBe(e2);
|
||||
expect(e2).toBe(e3);
|
||||
expect(ProjectEngine).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("throws for unknown project", async () => {
|
||||
const manager = new ProjectEngineManager(centralCore);
|
||||
|
||||
await expect(manager.ensureEngine("proj_unknown")).rejects.toThrow(
|
||||
"Project proj_unknown not found",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws if manager is stopped", async () => {
|
||||
const manager = new ProjectEngineManager(centralCore);
|
||||
await manager.stopAll();
|
||||
|
||||
await expect(manager.ensureEngine("proj_aaa")).rejects.toThrow(
|
||||
"ProjectEngineManager is stopped",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows retry after a failed start", async () => {
|
||||
const manager = new ProjectEngineManager(centralCore);
|
||||
|
||||
// Make the first start fail
|
||||
let callCount = 0;
|
||||
(ProjectEngine as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
(config: any) => ({
|
||||
start: vi.fn().mockImplementation(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) throw new Error("transient failure");
|
||||
}),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
getTaskStore: vi.fn().mockReturnValue({ projectId: config.projectId }),
|
||||
_config: config,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(manager.ensureEngine("proj_aaa")).rejects.toThrow("transient failure");
|
||||
expect(manager.getEngine("proj_aaa")).toBeUndefined();
|
||||
|
||||
// Retry should work
|
||||
const engine = await manager.ensureEngine("proj_aaa");
|
||||
expect(engine).toBeDefined();
|
||||
});
|
||||
|
||||
it("passes engine options from manager config", async () => {
|
||||
const getMergeStrategy = vi.fn();
|
||||
const processPullRequestMerge = vi.fn();
|
||||
const getTaskMergeBlocker = vi.fn();
|
||||
|
||||
const manager = new ProjectEngineManager(centralCore, {
|
||||
getMergeStrategy,
|
||||
processPullRequestMerge,
|
||||
getTaskMergeBlocker,
|
||||
});
|
||||
|
||||
await manager.ensureEngine("proj_aaa");
|
||||
|
||||
expect(ProjectEngine).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
centralCore,
|
||||
expect.objectContaining({
|
||||
projectId: "proj_aaa",
|
||||
getMergeStrategy,
|
||||
processPullRequestMerge,
|
||||
getTaskMergeBlocker,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("merges per-engine overrides", async () => {
|
||||
const manager = new ProjectEngineManager(centralCore);
|
||||
|
||||
await manager.ensureEngine("proj_aaa", { skipNotifier: true });
|
||||
|
||||
expect(ProjectEngine).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
centralCore,
|
||||
expect.objectContaining({
|
||||
skipNotifier: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("startAll", () => {
|
||||
it("starts engines for all registered projects", async () => {
|
||||
const manager = new ProjectEngineManager(centralCore);
|
||||
await manager.startAll();
|
||||
|
||||
expect(manager.getEngine("proj_aaa")).toBeDefined();
|
||||
expect(manager.getEngine("proj_bbb")).toBeDefined();
|
||||
expect(manager.getEngine("proj_ccc")).toBeDefined();
|
||||
expect(ProjectEngine).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("continues starting other projects when one fails", async () => {
|
||||
// Make proj_bbb fail
|
||||
(centralCore.getProject as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
async (id: string) => {
|
||||
if (id === "proj_bbb") throw new Error("DB corruption");
|
||||
return [projectA, projectC].find((p) => p.id === id) ?? null;
|
||||
},
|
||||
);
|
||||
|
||||
const manager = new ProjectEngineManager(centralCore);
|
||||
await manager.startAll();
|
||||
|
||||
expect(manager.getEngine("proj_aaa")).toBeDefined();
|
||||
expect(manager.getEngine("proj_bbb")).toBeUndefined();
|
||||
expect(manager.getEngine("proj_ccc")).toBeDefined();
|
||||
});
|
||||
|
||||
it("is a no-op when no projects are registered", async () => {
|
||||
centralCore = createMockCentralCore([]);
|
||||
const manager = new ProjectEngineManager(centralCore);
|
||||
await manager.startAll();
|
||||
|
||||
expect(manager.getAllEngines().size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopAll", () => {
|
||||
it("stops all engines and clears state", async () => {
|
||||
const manager = new ProjectEngineManager(centralCore);
|
||||
await manager.startAll();
|
||||
|
||||
const engineA = manager.getEngine("proj_aaa")!;
|
||||
const engineB = manager.getEngine("proj_bbb")!;
|
||||
|
||||
await manager.stopAll();
|
||||
|
||||
expect(engineA.stop).toHaveBeenCalledOnce();
|
||||
expect(engineB.stop).toHaveBeenCalledOnce();
|
||||
expect(manager.getAllEngines().size).toBe(0);
|
||||
});
|
||||
|
||||
it("handles stop errors gracefully", async () => {
|
||||
const manager = new ProjectEngineManager(centralCore);
|
||||
await manager.startAll();
|
||||
|
||||
const engineA = manager.getEngine("proj_aaa")!;
|
||||
(engineA.stop as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||
new Error("stop failed"),
|
||||
);
|
||||
|
||||
// Should not throw
|
||||
await manager.stopAll();
|
||||
expect(manager.getAllEngines().size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("accessors", () => {
|
||||
it("getStore returns the engine's TaskStore", async () => {
|
||||
const manager = new ProjectEngineManager(centralCore);
|
||||
await manager.ensureEngine("proj_aaa");
|
||||
|
||||
const store = manager.getStore("proj_aaa");
|
||||
expect(store).toBeDefined();
|
||||
expect((store as any).projectId).toBe("proj_aaa");
|
||||
});
|
||||
|
||||
it("getStore returns undefined for unstarted project", () => {
|
||||
const manager = new ProjectEngineManager(centralCore);
|
||||
expect(manager.getStore("proj_aaa")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("has returns true for started and starting engines", async () => {
|
||||
const manager = new ProjectEngineManager(centralCore);
|
||||
expect(manager.has("proj_aaa")).toBe(false);
|
||||
|
||||
await manager.ensureEngine("proj_aaa");
|
||||
expect(manager.has("proj_aaa")).toBe(true);
|
||||
});
|
||||
|
||||
it("getAllEngines returns a snapshot", async () => {
|
||||
const manager = new ProjectEngineManager(centralCore);
|
||||
await manager.startAll();
|
||||
|
||||
const engines = manager.getAllEngines();
|
||||
expect(engines.size).toBe(3);
|
||||
expect(engines.get("proj_aaa")).toBeDefined();
|
||||
expect(engines.get("proj_bbb")).toBeDefined();
|
||||
expect(engines.get("proj_ccc")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("onProjectAccessed", () => {
|
||||
it("starts engine in background for unknown project", async () => {
|
||||
const manager = new ProjectEngineManager(centralCore);
|
||||
manager.onProjectAccessed("proj_aaa");
|
||||
|
||||
// Wait for background start
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.getEngine("proj_aaa")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("is a no-op for already-running engines", async () => {
|
||||
const manager = new ProjectEngineManager(centralCore);
|
||||
await manager.ensureEngine("proj_aaa");
|
||||
|
||||
vi.clearAllMocks();
|
||||
manager.onProjectAccessed("proj_aaa");
|
||||
|
||||
// No new engine created
|
||||
expect(ProjectEngine).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
222
packages/engine/src/project-engine-manager.ts
Normal file
222
packages/engine/src/project-engine-manager.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* ProjectEngineManager — uniform lifecycle management for all project engines.
|
||||
*
|
||||
* Every registered project gets an identical ProjectEngine. There is no
|
||||
* "primary" or "default" engine — each is created from CentralCore metadata
|
||||
* and started through the same code path.
|
||||
*
|
||||
* The manager is the single owner of all engines. It handles:
|
||||
* - Eager startup of all registered projects via `startAll()`
|
||||
* - Lazy startup of newly-accessed projects via `ensureEngine()`
|
||||
* - Deduplication of concurrent start requests for the same project
|
||||
* - Graceful shutdown of all engines via `stopAll()`
|
||||
*/
|
||||
|
||||
import type {
|
||||
CentralCore,
|
||||
TaskStore,
|
||||
RegisteredProject,
|
||||
} from "@fusion/core";
|
||||
import { ProjectEngine } from "./project-engine.js";
|
||||
import type { ProjectEngineOptions } from "./project-engine.js";
|
||||
import type { ProjectRuntimeConfig } from "./project-runtime.js";
|
||||
import { runtimeLog } from "./logger.js";
|
||||
|
||||
/**
|
||||
* Options shared across all engines created by the manager.
|
||||
* These are injected by the CLI layer (dashboard.ts / serve.ts).
|
||||
*/
|
||||
export interface EngineManagerOptions {
|
||||
getMergeStrategy?: ProjectEngineOptions["getMergeStrategy"];
|
||||
processPullRequestMerge?: ProjectEngineOptions["processPullRequestMerge"];
|
||||
getTaskMergeBlocker?: ProjectEngineOptions["getTaskMergeBlocker"];
|
||||
onInsightRunProcessed?: ProjectEngineOptions["onInsightRunProcessed"];
|
||||
}
|
||||
|
||||
export class ProjectEngineManager {
|
||||
private engines = new Map<string, ProjectEngine>();
|
||||
private starting = new Map<string, Promise<ProjectEngine>>();
|
||||
private stopped = false;
|
||||
|
||||
constructor(
|
||||
private centralCore: CentralCore,
|
||||
private options: EngineManagerOptions = {},
|
||||
) {}
|
||||
|
||||
// ── Public accessors ──
|
||||
|
||||
/** Get a running engine by projectId. Returns undefined if not started. */
|
||||
getEngine(projectId: string): ProjectEngine | undefined {
|
||||
return this.engines.get(projectId);
|
||||
}
|
||||
|
||||
/** Get all running engines. */
|
||||
getAllEngines(): ReadonlyMap<string, ProjectEngine> {
|
||||
return this.engines;
|
||||
}
|
||||
|
||||
/** Get the TaskStore for a project from its engine. */
|
||||
getStore(projectId: string): TaskStore | undefined {
|
||||
return this.engines.get(projectId)?.getTaskStore();
|
||||
}
|
||||
|
||||
/** Check if an engine is running or starting for this project. */
|
||||
has(projectId: string): boolean {
|
||||
return this.engines.has(projectId) || this.starting.has(projectId);
|
||||
}
|
||||
|
||||
// ── Lifecycle ──
|
||||
|
||||
/**
|
||||
* Ensure an engine is running for the given project.
|
||||
* If already started, returns immediately. If starting, deduplicates.
|
||||
* If not started, creates and starts a new engine from CentralCore metadata.
|
||||
*/
|
||||
async ensureEngine(
|
||||
projectId: string,
|
||||
overrides?: Partial<ProjectEngineOptions>,
|
||||
): Promise<ProjectEngine> {
|
||||
if (this.stopped) throw new Error("ProjectEngineManager is stopped");
|
||||
|
||||
const existing = this.engines.get(projectId);
|
||||
if (existing) return existing;
|
||||
|
||||
// Deduplicate concurrent start requests
|
||||
const pending = this.starting.get(projectId);
|
||||
if (pending) return pending;
|
||||
|
||||
const promise = this.createAndStart(projectId, overrides);
|
||||
this.starting.set(projectId, promise);
|
||||
|
||||
try {
|
||||
const engine = await promise;
|
||||
return engine;
|
||||
} catch (err) {
|
||||
// Clean up on failure so a retry can attempt again
|
||||
this.starting.delete(projectId);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start engines for all registered projects.
|
||||
* Failures for individual projects are logged but don't stop others.
|
||||
*/
|
||||
async startAll(): Promise<void> {
|
||||
const projects = await this.centralCore.listProjects();
|
||||
if (projects.length === 0) return;
|
||||
|
||||
runtimeLog.log(`Starting engines for ${projects.length} registered project(s)`);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
projects.map((p) => this.ensureEngine(p.id)),
|
||||
);
|
||||
|
||||
let started = 0;
|
||||
let failed = 0;
|
||||
for (const result of results) {
|
||||
if (result.status === "fulfilled") {
|
||||
started++;
|
||||
} else {
|
||||
failed++;
|
||||
runtimeLog.warn(`Engine start failed: ${result.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
runtimeLog.log(`Engine startup complete: ${started} started, ${failed} failed`);
|
||||
}
|
||||
|
||||
/** Gracefully stop all engines. */
|
||||
async stopAll(): Promise<void> {
|
||||
this.stopped = true;
|
||||
|
||||
const stops = Array.from(this.engines.entries()).map(
|
||||
async ([id, engine]) => {
|
||||
try {
|
||||
await engine.stop();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
runtimeLog.warn(`Engine ${id} stop error: ${message}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
await Promise.all(stops);
|
||||
this.engines.clear();
|
||||
this.starting.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget engine start — suitable as a callback for
|
||||
* onProjectFirstAccessed in the server layer.
|
||||
*/
|
||||
onProjectAccessed(projectId: string): void {
|
||||
if (this.has(projectId)) return;
|
||||
this.ensureEngine(projectId).catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
runtimeLog.warn(
|
||||
`Failed to start engine for project ${projectId}: ${message}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Internal ──
|
||||
|
||||
private async createAndStart(
|
||||
projectId: string,
|
||||
overrides?: Partial<ProjectEngineOptions>,
|
||||
): Promise<ProjectEngine> {
|
||||
const project = await this.centralCore.getProject(projectId);
|
||||
if (!project) {
|
||||
throw new Error(`Project ${projectId} not found in CentralCore`);
|
||||
}
|
||||
|
||||
const runtimeConfig = this.buildRuntimeConfig(project);
|
||||
const engineOptions = this.buildEngineOptions(project, overrides);
|
||||
|
||||
const engine = new ProjectEngine(
|
||||
runtimeConfig,
|
||||
this.centralCore,
|
||||
engineOptions,
|
||||
);
|
||||
|
||||
await engine.start();
|
||||
|
||||
this.engines.set(projectId, engine);
|
||||
this.starting.delete(projectId);
|
||||
runtimeLog.log(
|
||||
`Started engine for ${project.name ?? projectId} (${projectId})`,
|
||||
);
|
||||
|
||||
return engine;
|
||||
}
|
||||
|
||||
private buildRuntimeConfig(project: RegisteredProject): ProjectRuntimeConfig {
|
||||
const settings = project.settings as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
return {
|
||||
projectId: project.id,
|
||||
workingDirectory: project.path,
|
||||
isolationMode:
|
||||
(project.isolationMode as "in-process" | "child-process") ??
|
||||
"in-process",
|
||||
maxConcurrent: (settings?.maxConcurrent as number) ?? 4,
|
||||
maxWorktrees: (settings?.maxWorktrees as number) ?? 10,
|
||||
};
|
||||
}
|
||||
|
||||
private buildEngineOptions(
|
||||
project: RegisteredProject,
|
||||
overrides?: Partial<ProjectEngineOptions>,
|
||||
): ProjectEngineOptions {
|
||||
return {
|
||||
projectId: project.id,
|
||||
getMergeStrategy: this.options.getMergeStrategy,
|
||||
processPullRequestMerge: this.options.processPullRequestMerge,
|
||||
getTaskMergeBlocker: this.options.getTaskMergeBlocker,
|
||||
onInsightRunProcessed: this.options.onInsightRunProcessed,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user