refactor: replace primary/secondary engine pattern with uniform ProjectEngineManager

Remove the anti-pattern where the cwd project was treated as "primary" with a
special engine, and other projects got "secondary" engines through a separate
code path. Every project now gets an identical ProjectEngine created through
ProjectEngineManager.

Key changes:
- Add ProjectEngineManager class to @fusion/engine for uniform engine lifecycle
- Replace manual engine maps in dashboard.ts and serve.ts with engineManager
- Add engineManager to ServerOptions for per-project engine resolution
- Add getProjectContext() helper in routes.ts (replaces 199 getScopedStore calls)
- Merge and automation routes now resolve engine subsystems per-request
- SSE endpoint uses engine's store when available (same EventEmitter)
- Fix tsx not found in dev-with-memory.mjs startup script
- Add invalidateAllGlobalSettingsCaches for cross-project settings sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-13 13:32:38 -07:00
parent 6d414f7b59
commit 1383b8d7d6
12 changed files with 520 additions and 500 deletions

View File

@@ -60,6 +60,12 @@ vi.mock("@fusion/core", () => ({
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }),
getProject: vi.fn().mockImplementation((id: string) =>
Promise.resolve({ id, name: `Project ${id}`, path: process.cwd(), status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }),
),
listProjects: vi.fn().mockResolvedValue([
{ id: "project-1", name: "Test Project", path: process.cwd(), status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
]),
})),
AutomationStore: vi.fn().mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
@@ -156,6 +162,45 @@ vi.mock("@fusion/engine", async (importOriginal) => {
getWorkingDirectory: vi.fn().mockReturnValue("/tmp/test"),
onMerge: vi.fn().mockResolvedValue({ merged: true }),
})),
ProjectEngineManager: vi.fn().mockImplementation((centralCore: any, _options: any) => {
const engines = new Map<string, any>();
// Create mock engines that match the ProjectEngine mock shape above.
const createMockEngine = () => ({
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
getTaskStore: vi.fn().mockImplementation(() => makeMockStore()),
getRuntime: vi.fn().mockReturnValue({
getHeartbeatMonitor: vi.fn().mockReturnValue(undefined),
getMissionAutopilot: vi.fn().mockReturnValue(undefined),
getMissionExecutionLoop: vi.fn().mockReturnValue(undefined),
}),
getAutomationStore: vi.fn().mockReturnValue(undefined),
getHeartbeatMonitor: vi.fn().mockReturnValue(undefined),
getHeartbeatTriggerScheduler: vi.fn().mockReturnValue(undefined),
getWorkingDirectory: vi.fn().mockReturnValue("/tmp/test"),
onMerge: vi.fn().mockResolvedValue({ merged: true }),
});
return {
startAll: vi.fn(async () => {
const projects = await centralCore.listProjects();
for (const project of projects) {
const engine = createMockEngine();
await engine.start();
engines.set(project.id, engine);
}
}),
getEngine: vi.fn((id: string) => engines.get(id)),
getAllEngines: vi.fn(() => engines),
getStore: vi.fn((id: string) => engines.get(id)?.getTaskStore()),
has: vi.fn((id: string) => engines.has(id)),
ensureEngine: vi.fn(async (id: string) => engines.get(id)),
stopAll: vi.fn(async () => {
for (const engine of engines.values()) await engine.stop();
engines.clear();
}),
onProjectAccessed: vi.fn(),
};
}),
MissionAutopilot: vi.fn().mockImplementation(() => ({
start: vi.fn(),
stop: vi.fn(),
@@ -422,17 +467,17 @@ describe("runDashboard — non-dev mode engine wiring", () => {
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
});
it("passes engine to createServer (non-dev mode)", async () => {
it("passes engineManager to createServer (non-dev mode)", async () => {
const { createServer } = await import("@fusion/dashboard");
const { ProjectEngine } = await import("@fusion/engine");
const { ProjectEngineManager } = await import("@fusion/engine");
await runDashboard(0, {});
expect(ProjectEngine).toHaveBeenCalledTimes(1);
expect(ProjectEngineManager).toHaveBeenCalledTimes(1);
expect(createServer).toHaveBeenCalledTimes(1);
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];
expect(serverOpts).toHaveProperty("engine");
expect(serverOpts.engine).toBeDefined();
expect(serverOpts).toHaveProperty("engineManager");
expect(serverOpts.engineManager).toBeDefined();
});
});
@@ -503,12 +548,14 @@ describe("runDashboard — per-project engine manager (multi-project)", () => {
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
});
it("creates a ProjectEngine in non-dev mode", async () => {
const { ProjectEngine } = await import("@fusion/engine");
it("creates a ProjectEngineManager and calls startAll in non-dev mode", async () => {
const { ProjectEngineManager } = await import("@fusion/engine");
await runDashboard(0, {});
expect(ProjectEngine).toHaveBeenCalledTimes(1);
expect(ProjectEngineManager).toHaveBeenCalledTimes(1);
const managerInstance = (ProjectEngineManager as unknown as ReturnType<typeof vi.fn>).mock.results[0]?.value;
expect(managerInstance.startAll).toHaveBeenCalledTimes(1);
});
it("passes onProjectFirstAccessed callback to createServer", async () => {
@@ -521,105 +568,30 @@ describe("runDashboard — per-project engine manager (multi-project)", () => {
expect(serverOpts.onProjectFirstAccessed).toBeTypeOf("function");
});
it("onProjectFirstAccessed starts a secondary ProjectEngine for a new project", async () => {
it("onProjectFirstAccessed delegates to engineManager.onProjectAccessed", async () => {
const { createServer } = await import("@fusion/dashboard");
const { ProjectEngine } = await import("@fusion/engine");
const { CentralCore } = await import("@fusion/core");
const mockProject = {
id: "proj_other",
path: "/other/project",
name: "Other Project",
isolationMode: "in-process",
settings: { maxConcurrent: 2, maxWorktrees: 4 },
};
(CentralCore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }),
getProject: vi.fn().mockImplementation((id: string) =>
id === "proj_other" ? Promise.resolve(mockProject) : Promise.resolve(null),
),
}));
const { ProjectEngineManager } = await import("@fusion/engine");
await runDashboard(0, {});
const managerInstance = (ProjectEngineManager as unknown as ReturnType<typeof vi.fn>).mock.results[0]?.value;
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];
const cb: (id: string) => void = serverOpts.onProjectFirstAccessed;
cb("proj_other");
await new Promise((r) => setTimeout(r, 0));
cb("proj_new");
expect(ProjectEngine).toHaveBeenCalledTimes(2);
const secondaryConfig = (ProjectEngine as ReturnType<typeof vi.fn>).mock.calls[1][0];
expect(secondaryConfig).toEqual(
expect.objectContaining({
projectId: "proj_other",
workingDirectory: "/other/project",
isolationMode: "in-process",
maxConcurrent: 2,
maxWorktrees: 4,
}),
);
const secondaryInstance = (ProjectEngine as ReturnType<typeof vi.fn>).mock.results[1]?.value;
expect(secondaryInstance.start).toHaveBeenCalledTimes(1);
expect(managerInstance.onProjectAccessed).toHaveBeenCalledWith("proj_new");
});
it("onProjectFirstAccessed skips unknown projects", async () => {
it("passes engineManager to createServer", async () => {
const { createServer } = await import("@fusion/dashboard");
const { ProjectEngine } = await import("@fusion/engine");
const { CentralCore } = await import("@fusion/core");
(CentralCore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }),
getProject: vi.fn().mockResolvedValue(null),
}));
const { ProjectEngineManager } = await import("@fusion/engine");
await runDashboard(0, {});
const managerInstance = (ProjectEngineManager as unknown as ReturnType<typeof vi.fn>).mock.results[0]?.value;
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];
const cb: (id: string) => void = serverOpts.onProjectFirstAccessed;
cb("proj_unknown");
await new Promise((r) => setTimeout(r, 0));
expect(ProjectEngine).toHaveBeenCalledTimes(1);
});
it("onProjectFirstAccessed skips if a secondary engine already exists for that project", async () => {
const { createServer } = await import("@fusion/dashboard");
const { ProjectEngine } = await import("@fusion/engine");
const { CentralCore } = await import("@fusion/core");
const mockProject = {
id: "proj_dupe",
path: "/dupe",
name: "Dupe",
isolationMode: "in-process",
settings: {},
};
(CentralCore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }),
getProject: vi.fn().mockResolvedValue(mockProject),
}));
await runDashboard(0, {});
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];
const cb: (id: string) => void = serverOpts.onProjectFirstAccessed;
cb("proj_dupe");
await new Promise((r) => setTimeout(r, 0));
cb("proj_dupe");
await new Promise((r) => setTimeout(r, 0));
expect(ProjectEngine).toHaveBeenCalledTimes(2);
expect(serverOpts.engineManager).toBe(managerInstance);
});
it("does not create ProjectEngine in dev mode", async () => {

View File

@@ -105,6 +105,12 @@ const mocks = vi.hoisted(() => {
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }),
getProject: vi.fn().mockImplementation((id: string) =>
Promise.resolve({ id, name: `Project ${id}`, path: `/repo/${id}`, status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }),
),
listProjects: vi.fn().mockResolvedValue([
{ id: "project-1", name: "Test Project", path: "/repo", status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
]),
listNodes: vi.fn().mockResolvedValue([
{ id: "node-local", name: "local", type: "local", status: "offline" },
]),
@@ -459,6 +465,33 @@ vi.mock("@fusion/dashboard", () => ({
vi.mock("@fusion/engine", () => ({
ProjectEngine: mocks.projectEngineCtor,
ProjectEngineManager: vi.fn().mockImplementation((centralCore: any, options: any) => {
const engines = new Map<string, any>();
return {
startAll: vi.fn(async () => {
const projects = await centralCore.listProjects();
for (const project of projects) {
const engine = mocks.projectEngineCtor(
{ projectId: project.id, workingDirectory: project.path, isolationMode: "in-process", maxConcurrent: 4, maxWorktrees: 10 },
centralCore,
{ ...options, projectId: project.id },
);
await engine.start();
engines.set(project.id, engine);
}
}),
getEngine: vi.fn((id: string) => engines.get(id)),
getAllEngines: vi.fn(() => engines),
getStore: vi.fn((id: string) => engines.get(id)?.getTaskStore()),
has: vi.fn((id: string) => engines.has(id)),
ensureEngine: vi.fn(async (id: string) => engines.get(id)),
stopAll: vi.fn(async () => {
for (const engine of engines.values()) await engine.stop();
engines.clear();
}),
onProjectAccessed: vi.fn(),
};
}),
TriageProcessor: mocks.triageCtor,
TaskExecutor: mocks.executorCtor,
Scheduler: mocks.schedulerCtor,