feat(FN-3508): resolve node-specific working dirs and align runtime surfaces

- Add canonical working-directory resolution with node mapping support in core and engine paths
- Update runtime/multi-project documentation and include published-package changesets for the merged work
- Migrate roadmap dashboard surface to the bundled plugin registry flow and adjust lazy-view integration/tests
- Default non-ephemeral agents to active state and extend related agent/route/executor test coverage

Fusion-Task-Id: FN-3508
This commit is contained in:
Fusion
2026-05-08 17:04:14 -07:00
committed by gsxdsm
parent 5d226a28ab
commit ea34afaff6
10 changed files with 128 additions and 12 deletions

View File

@@ -138,6 +138,9 @@ describe("HybridExecutor", () => {
logActivity: vi.fn().mockResolvedValue(undefined),
acquireGlobalSlot: vi.fn().mockResolvedValue(true),
releaseGlobalSlot: vi.fn().mockResolvedValue(undefined),
resolveLocalProjectWorkingDirectory: vi
.fn()
.mockResolvedValue("/mapped/local/project-root"),
removeAllListeners: vi.fn(),
on: vi.fn().mockReturnThis(),
} as unknown as CentralCore;
@@ -188,11 +191,14 @@ describe("HybridExecutor", () => {
await executor.initialize();
const manager = mockProjectManagerInstances[0];
expect(mockCentralCore.resolveLocalProjectWorkingDirectory).toHaveBeenCalledWith(
"proj_remote_1",
);
expect(manager?.addProject).toHaveBeenCalledWith(
expect.objectContaining({
projectId: "proj_remote_1",
isolationMode: "in-process",
workingDirectory: "/tmp/test-project",
workingDirectory: "/mapped/local/project-root",
})
);
});
@@ -434,19 +440,34 @@ describe("HybridExecutor", () => {
).rejects.toThrow("Runtime not found");
});
it("reuses the registered project path when isolation mode changes without an explicit working directory", async () => {
it("resolves working directory from local-node mapping when isolation mode changes without an explicit working directory", async () => {
const manager = mockProjectManagerInstances[0];
manager?.addProject.mockClear();
await executor.updateProject("proj_test123", { isolationMode: "child-process" });
expect(mockCentralCore.getProject).toHaveBeenCalledWith("proj_test123");
expect(mockCentralCore.resolveLocalProjectWorkingDirectory).toHaveBeenCalledWith(
"proj_test123",
);
expect(manager?.removeProject).toHaveBeenCalledWith("proj_test123");
expect(manager?.addProject).toHaveBeenCalledWith(expect.objectContaining({
projectId: "proj_test123",
workingDirectory: "/tmp/test-project",
workingDirectory: "/mapped/local/project-root",
isolationMode: "child-process",
}));
});
it("fails isolation-mode runtime recreation when no local-node mapping exists", async () => {
(mockCentralCore.resolveLocalProjectWorkingDirectory as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("Project/node path mapping not found for projectId=proj_test123 nodeId=node_local"),
);
await expect(
executor.updateProject("proj_test123", { isolationMode: "child-process" }),
).rejects.toThrow(
"Project/node path mapping not found for projectId=proj_test123 nodeId=node_local",
);
});
});
});

View File

@@ -40,6 +40,9 @@ function createMockCentralCore(projects: RegisteredProject[]): CentralCore {
),
updateProject: vi.fn().mockResolvedValue(undefined),
updateProjectHealth: vi.fn().mockResolvedValue(undefined),
resolveLocalProjectWorkingDirectory: vi
.fn()
.mockImplementation((projectId: string) => Promise.resolve(`/mapped/${projectId}`)),
} as unknown as CentralCore;
}
@@ -73,10 +76,13 @@ describe("ProjectEngineManager", () => {
expect(engine).toBeDefined();
expect(engine.start).toHaveBeenCalledOnce();
expect(
(centralCore.resolveLocalProjectWorkingDirectory as unknown as ReturnType<typeof vi.fn>),
).toHaveBeenCalledWith("proj_aaa");
expect(ProjectEngine).toHaveBeenCalledWith(
expect.objectContaining({
projectId: "proj_aaa",
workingDirectory: "/tmp/a",
workingDirectory: "/mapped/proj_aaa",
isolationMode: "in-process",
}),
centralCore,
@@ -464,6 +470,9 @@ describe("ProjectEngineManager", () => {
Promise.resolve(projectMap.get(id) ?? null),
),
getProjectByPath: vi.fn().mockResolvedValue(null),
resolveLocalProjectWorkingDirectory: vi
.fn()
.mockImplementation((projectId: string) => Promise.resolve(`/mapped/${projectId}`)),
} as unknown as CentralCore;
const manager = new ProjectEngineManager(emptyCentralCore);

View File

@@ -114,7 +114,7 @@ export interface HybridExecutorOptions {
*
* await executor.addProject({
* projectId: project.id,
* workingDirectory: project.path,
* workingDirectory: await central.resolveLocalProjectWorkingDirectory(project.id),
* isolationMode: "in-process",
* maxConcurrent: 2,
* maxWorktrees: 4,
@@ -178,9 +178,13 @@ export class HybridExecutor extends EventEmitter<HybridExecutorEvents> {
for (const project of projects) {
if (project.status === "active" || project.status === "initializing") {
try {
const workingDirectory = await this.centralCore.resolveLocalProjectWorkingDirectory(
project.id,
);
await this.addProject({
projectId: project.id,
workingDirectory: project.path,
workingDirectory,
isolationMode: project.isolationMode,
maxConcurrent: project.settings?.maxConcurrent ?? 2,
maxWorktrees: project.settings?.maxWorktrees ?? 4,
@@ -275,11 +279,14 @@ export class HybridExecutor extends EventEmitter<HybridExecutorEvents> {
);
const project = await this.centralCore.getProject(projectId);
const workingDirectory = config.workingDirectory ?? project?.path;
if (!workingDirectory) {
if (!project) {
throw new Error(`Project not found in CentralCore: ${projectId}`);
}
const workingDirectory =
config.workingDirectory ??
(await this.centralCore.resolveLocalProjectWorkingDirectory(projectId));
// Stop old runtime
await this.projectManager.removeProject(projectId);

View File

@@ -375,7 +375,7 @@ export class ProjectEngineManager {
throw new Error(`Project ${projectId} is paused`);
}
const runtimeConfig = this.buildRuntimeConfig(project);
const runtimeConfig = await this.buildRuntimeConfig(project);
const engineOptions = this.buildEngineOptions(project, overrides);
const engine = new ProjectEngine(
@@ -395,14 +395,14 @@ export class ProjectEngineManager {
return engine;
}
private buildRuntimeConfig(project: RegisteredProject): ProjectRuntimeConfig {
private async buildRuntimeConfig(project: RegisteredProject): Promise<ProjectRuntimeConfig> {
const settings = project.settings as
| Record<string, unknown>
| undefined;
return {
projectId: project.id,
workingDirectory: project.path,
workingDirectory: await this.centralCore.resolveLocalProjectWorkingDirectory(project.id),
isolationMode:
(project.isolationMode as "in-process" | "child-process") ??
"in-process",

View File

@@ -79,7 +79,7 @@ export interface ProjectManagerEvents {
* // Then add runtime
* const runtime = await manager.addProject({
* projectId: project.id,
* workingDirectory: project.path,
* workingDirectory: await central.resolveLocalProjectWorkingDirectory(project.id),
* isolationMode: "in-process",
* maxConcurrent: 2,
* maxWorktrees: 4,