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:
5
.changeset/fn-3508-node-specific-wd-resolution.md
Normal file
5
.changeset/fn-3508-node-specific-wd-resolution.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Resolve project runtime working directories from per-node project path mappings for the routed/current node instead of falling back to `RegisteredProject.path`, and fail with clear errors when the exact mapping is missing.
|
||||
@@ -1014,6 +1014,7 @@ Multi-project orchestration spans core + engine.
|
||||
### Engine orchestration
|
||||
- `HybridExecutor` (`packages/engine/src/hybrid-executor.ts`) is the top-level orchestrator
|
||||
- `ProjectManager` instantiates per-project runtimes and forwards events with project attribution
|
||||
- Runtime startup/update resolves `ProjectRuntimeConfig.workingDirectory` through `CentralCore.resolveLocalProjectWorkingDirectory()` / `resolveProjectWorkingDirectory(projectId,nodeId)` using exact `projectNodePathMappings` rows for the active node; missing mappings are hard failures (no fallback to `RegisteredProject.path`).
|
||||
|
||||
### Runtime abstraction
|
||||
Defined in `project-runtime.ts`:
|
||||
|
||||
@@ -149,6 +149,8 @@ So node onboarding records where a given node can access a project on disk, with
|
||||
- `isolationMode: "in-process"` + remote `projects.nodeId` → `RemoteNodeRuntime`
|
||||
- `isolationMode: "in-process"` + local/unset/missing node assignment → `InProcessRuntime`
|
||||
|
||||
Runtime startup now resolves `ProjectRuntimeConfig.workingDirectory` from the exact routed/current node mapping (`projectNodePathMappings` for `{projectId,nodeId}`) via `CentralCore` resolver APIs. It does **not** fall back to `projects.path` when that node mapping is missing; startup/update fails with a clear mapping error.
|
||||
|
||||
So `projects.nodeId` is a **project host-node assignment**, not a per-task override, and not the node-specific working-directory source of truth (that lives in `projectNodePathMappings`).
|
||||
|
||||
### Task routing defaults (`defaultNodeId` + `Task.nodeId`)
|
||||
|
||||
@@ -862,6 +862,43 @@ describe("CentralCore", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves working directories strictly from exact project/node mappings", async () => {
|
||||
const projectPath = join(tempDir, "mapping-resolver-project");
|
||||
mkdirSync(projectPath);
|
||||
projectPaths.push(projectPath);
|
||||
|
||||
const project = await central.registerProject({
|
||||
name: "Mapping Resolver",
|
||||
path: projectPath,
|
||||
});
|
||||
const remoteNode = await central.registerNode({ name: "mapping-resolver-remote", type: "remote", url: "http://remote.example" });
|
||||
const otherNode = await central.registerNode({ name: "mapping-resolver-other", type: "remote", url: "http://other.example" });
|
||||
|
||||
const localNode = (await central.listNodes()).find((node) => node.type === "local");
|
||||
expect(localNode).toBeDefined();
|
||||
|
||||
await expect(central.resolveLocalProjectWorkingDirectory(project.id)).resolves.toBe(projectPath);
|
||||
|
||||
await central.upsertProjectNodePathMapping({
|
||||
projectId: project.id,
|
||||
nodeId: remoteNode.id,
|
||||
path: "/remote/project/root",
|
||||
});
|
||||
|
||||
await expect(central.resolveProjectWorkingDirectory(project.id, remoteNode.id)).resolves.toBe(
|
||||
"/remote/project/root",
|
||||
);
|
||||
await expect(central.resolveProjectWorkingDirectory("proj_missing", remoteNode.id)).rejects.toThrow(
|
||||
"Project not found: proj_missing",
|
||||
);
|
||||
await expect(central.resolveProjectWorkingDirectory(project.id, "node_missing")).rejects.toThrow(
|
||||
"Node not found: node_missing",
|
||||
);
|
||||
await expect(central.resolveProjectWorkingDirectory(project.id, otherNode.id)).rejects.toThrow(
|
||||
`Project/node path mapping not found for projectId=${project.id} nodeId=${otherNode.id}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should check local node health and emit node:health:changed", async () => {
|
||||
const node = await central.registerNode({ name: "local-health", type: "local" });
|
||||
|
||||
|
||||
@@ -1779,6 +1779,40 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
return row?.path;
|
||||
}
|
||||
|
||||
async resolveProjectWorkingDirectory(projectId: string, nodeId: string): Promise<string> {
|
||||
this.ensureInitialized();
|
||||
|
||||
const project = await this.getProject(projectId);
|
||||
if (!project) {
|
||||
throw new Error(`Project not found: ${projectId}`);
|
||||
}
|
||||
|
||||
const node = await this.getNode(nodeId);
|
||||
if (!node) {
|
||||
throw new Error(`Node not found: ${nodeId}`);
|
||||
}
|
||||
|
||||
const mappedPath = await this.getProjectNodePath(projectId, nodeId);
|
||||
if (!mappedPath) {
|
||||
throw new Error(
|
||||
`Project/node path mapping not found for projectId=${projectId} nodeId=${nodeId}`,
|
||||
);
|
||||
}
|
||||
|
||||
return mappedPath;
|
||||
}
|
||||
|
||||
async resolveLocalProjectWorkingDirectory(projectId: string): Promise<string> {
|
||||
this.ensureInitialized();
|
||||
|
||||
const localNode = await this.getLocalNode();
|
||||
if (!localNode) {
|
||||
throw new Error("Local node not found");
|
||||
}
|
||||
|
||||
return this.resolveProjectWorkingDirectory(projectId, localNode.id);
|
||||
}
|
||||
|
||||
async listProjectNodePathMappings(filters?: {
|
||||
projectId?: string;
|
||||
nodeId?: string;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user