diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 1ecbfc95ef..9ed832f8c3 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -22,12 +22,11 @@ import type { InitStateManager } from "./initStateManager"; import type { SendMessageError } from "@/common/types/errors"; import { getToolsForModel } from "@/common/utils/tools/tools"; import { cloneToolPreservingDescriptors } from "@/common/utils/tools/cloneToolPreservingDescriptors"; -import { createRuntime } from "@/node/runtime/runtimeFactory"; import { createRuntimeContextForWorkspace, resolveWorkspaceExecutionPath, } from "@/node/runtime/runtimeHelpers"; -import { getWorkspacePathHintForProject } from "@/node/services/workspaceProjectRepos"; +import { createRuntimeForWorkspaceProject } from "@/node/services/workspaceProjectRepos"; import { MultiProjectRuntime } from "@/node/runtime/multiProjectRuntime"; import { getMuxEnv, getRuntimeType } from "@/node/runtime/initHook"; import { getSrcBaseDir, isSSHRuntime } from "@/common/types/runtime"; @@ -950,6 +949,14 @@ export class AIService extends EventEmitter { return multiProjectExecutionGate; } + const workspaceProjectRuntimeParams = { + workspaceName: metadata.name, + workspacePath: workspace.workspacePath, + runtimeConfig: metadata.runtimeConfig, + projectPath: metadata.projectPath, + projectName: metadata.projectName, + projects: metadata.projects, + }; const singleProjectContext = isMultiProject(metadata) ? undefined : createRuntimeContextForWorkspace(metadataWithPath); @@ -960,24 +967,10 @@ export class AIService extends EventEmitter { getProjects(metadata).map((project) => ({ projectPath: project.projectPath, projectName: project.projectName, - runtime: createRuntime(metadata.runtimeConfig, { - projectPath: project.projectPath, - workspaceName: metadata.name, - workspacePath: isSSHRuntime(metadata.runtimeConfig) - ? getWorkspacePathHintForProject( - { - workspaceId, - workspaceName: metadata.name, - workspacePath: workspace.workspacePath, - runtimeConfig: metadata.runtimeConfig, - projectPath: metadata.projectPath, - projectName: metadata.projectName, - projects: metadata.projects, - }, - project.projectPath - ) - : undefined, - }), + runtime: createRuntimeForWorkspaceProject( + workspaceProjectRuntimeParams, + project.projectPath + ), })), metadata.name ); diff --git a/src/node/services/workspaceProjectRepos.test.ts b/src/node/services/workspaceProjectRepos.test.ts index 31d8755472..32441c7271 100644 --- a/src/node/services/workspaceProjectRepos.test.ts +++ b/src/node/services/workspaceProjectRepos.test.ts @@ -1,13 +1,16 @@ import { describe, expect, it } from "bun:test"; +import type { RuntimeConfig } from "@/common/types/runtime"; import { buildLegacyRemoteProjectLayout, buildRemoteProjectLayout, getRemoteWorkspacePath, } from "@/node/runtime/remoteProjectLayout"; import { + createRuntimeForWorkspaceProject, getWorkspacePathHintForProject, getWorkspaceProjectRepos, + resolveWorkspacePathForProject, } from "@/node/services/workspaceProjectRepos"; describe("getWorkspaceProjectRepos", () => { @@ -74,6 +77,93 @@ describe("getWorkspaceProjectRepos", () => { expect(repos[0]?.repoCwd).toBe(workspacePath); }); + it("recreates single-project SSH runtimes from the persisted workspace path", () => { + const runtimeConfig = { + type: "ssh", + host: "example.com", + srcBaseDir: "/tmp/src", + } as const; + const projectPath = "/tmp/projects/main"; + const workspaceName = "main"; + const workspacePath = getRemoteWorkspacePath( + buildLegacyRemoteProjectLayout(runtimeConfig.srcBaseDir, projectPath), + workspaceName + ); + const runtime = createRuntimeForWorkspaceProject( + { + workspaceName, + workspacePath, + runtimeConfig, + projectPath, + projectName: "main", + }, + projectPath + ); + + expect(runtime.getWorkspacePath(projectPath, workspaceName)).toBe(workspacePath); + expect( + resolveWorkspacePathForProject( + { + workspaceName, + workspacePath, + runtimeConfig, + projectPath, + projectName: "main", + }, + projectPath, + runtime + ) + ).toBe(workspacePath); + }); + + it("resolves single-project paths without constructing a runtime", () => { + expect( + resolveWorkspacePathForProject( + { + workspaceName: "main", + workspacePath: "/tmp/workspaces/main", + runtimeConfig: { type: "made-up-runtime" } as unknown as RuntimeConfig, + projectPath: "/tmp/projects/main", + projectName: "main", + }, + "/tmp/projects/main" + ) + ).toBe("/tmp/workspaces/main"); + }); + + it("resolves canonical sibling paths when a multi-project SSH workspace has no recognized layout hint", () => { + const runtimeConfig = { + type: "ssh", + host: "example.com", + srcBaseDir: "/tmp/src", + } as const; + const workspaceName = "main"; + const primaryProjectPath = "/tmp/projects/main"; + const secondaryProjectPath = "/tmp/projects/other"; + + expect( + resolveWorkspacePathForProject( + { + workspaceName, + workspacePath: "/tmp/src/containers/main", + runtimeConfig, + projectPath: primaryProjectPath, + projectName: "main", + projects: [ + { projectPath: primaryProjectPath, projectName: "main" }, + { projectPath: secondaryProjectPath, projectName: "other" }, + ], + }, + secondaryProjectPath + ) + ).toBe( + getRemoteWorkspacePath( + buildRemoteProjectLayout(runtimeConfig.srcBaseDir, secondaryProjectPath), + workspaceName + ) + ); + }); + it("derives hashed SSH paths for secondary multi-project repos", () => { const runtimeConfig = { type: "ssh", @@ -116,7 +206,6 @@ describe("getWorkspaceProjectRepos", () => { const hint = getWorkspacePathHintForProject( { - workspaceId: "workspace-1", workspaceName, workspacePath: getRemoteWorkspacePath( buildLegacyRemoteProjectLayout(runtimeConfig.srcBaseDir, primaryProjectPath), @@ -151,7 +240,6 @@ describe("getWorkspaceProjectRepos", () => { const hint = getWorkspacePathHintForProject( { - workspaceId: "workspace-1", workspaceName: "main", workspacePath: "/tmp/src/containers/main", runtimeConfig, diff --git a/src/node/services/workspaceProjectRepos.ts b/src/node/services/workspaceProjectRepos.ts index 8a5677b42d..0c45f6cb14 100644 --- a/src/node/services/workspaceProjectRepos.ts +++ b/src/node/services/workspaceProjectRepos.ts @@ -4,6 +4,7 @@ import * as path from "node:path"; import type { ProjectRef } from "@/common/types/workspace"; import { isSSHRuntime, type RuntimeConfig } from "@/common/types/runtime"; import { PlatformPaths } from "@/common/utils/paths"; +import type { Runtime } from "@/node/runtime/Runtime"; import { buildLegacyRemoteProjectLayout, buildRemoteProjectLayout, @@ -18,8 +19,7 @@ export interface WorkspaceProjectRepo { repoCwd: string; } -interface WorkspaceProjectRepoParams { - workspaceId: string; +export interface WorkspaceProjectRuntimeParams { workspaceName: string; workspacePath: string; runtimeConfig: RuntimeConfig; @@ -28,6 +28,10 @@ interface WorkspaceProjectRepoParams { projects?: ProjectRef[]; } +interface WorkspaceProjectRepoParams extends WorkspaceProjectRuntimeParams { + workspaceId: string; +} + interface WorkspaceProjectStorageKeyParams { projectPath: string; projectName?: string; @@ -138,8 +142,14 @@ export function getWorkspaceProjectStorageKeys( return storageKeys; } +function hasMultipleWorkspaceProjects( + params: Pick +): boolean { + return (params.projects?.length ?? 0) > 1; +} + export function getWorkspacePathHintForProject( - params: WorkspaceProjectRepoParams, + params: WorkspaceProjectRuntimeParams, targetProjectPath: string ): string | undefined { if (!isSSHRuntime(params.runtimeConfig)) { @@ -172,6 +182,50 @@ export function getWorkspacePathHintForProject( return undefined; } +/** + * Recreate the runtime for one project inside an existing workspace. + * + * Why: multi-project SSH workspaces sometimes need a sibling checkout hint derived from the + * persisted workspace root, while single-project workspaces should always keep using their exact + * checkout path from config. + */ +export function createRuntimeForWorkspaceProject( + params: WorkspaceProjectRuntimeParams, + targetProjectPath: string +): Runtime { + const workspacePath = hasMultipleWorkspaceProjects(params) + ? getWorkspacePathHintForProject(params, targetProjectPath) + : params.workspacePath; + + return createRuntime(params.runtimeConfig, { + projectPath: targetProjectPath, + workspaceName: params.workspaceName, + workspacePath, + }); +} + +export function resolveWorkspacePathForProject( + params: WorkspaceProjectRuntimeParams, + targetProjectPath: string, + runtime?: Runtime +): string { + if (!hasMultipleWorkspaceProjects(params)) { + assert( + params.workspacePath.trim().length > 0, + "resolveWorkspacePathForProject: workspacePath must be non-empty" + ); + return params.workspacePath; + } + + const projectRuntime = runtime ?? createRuntimeForWorkspaceProject(params, targetProjectPath); + const workspacePath = projectRuntime.getWorkspacePath(targetProjectPath, params.workspaceName); + assert( + workspacePath.trim().length > 0, + `resolveWorkspacePathForProject: workspacePath missing for ${targetProjectPath}` + ); + return workspacePath; +} + export function getWorkspaceProjectRepos( params: WorkspaceProjectRepoParams ): WorkspaceProjectRepo[] { @@ -197,25 +251,9 @@ export function getWorkspaceProjectRepos( projectName: params.projectName, projects: params.projects, }); - const isMultiProject = projectStorageKeys.length > 1; const repos = projectStorageKeys.map((project) => { - const sshWorkspacePathHint = isMultiProject - ? getWorkspacePathHintForProject(params, project.projectPath) - : undefined; - - const repoCwd = !isMultiProject - ? params.workspacePath - : (sshWorkspacePathHint ?? - createRuntime(params.runtimeConfig, { - projectPath: project.projectPath, - workspaceName: params.workspaceName, - }).getWorkspacePath(project.projectPath, params.workspaceName)); - - assert( - repoCwd.trim().length > 0, - `getWorkspaceProjectRepos: repoCwd missing for ${project.projectName}` - ); + const repoCwd = resolveWorkspacePathForProject(params, project.projectPath); return { projectPath: project.projectPath, diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index c06c5a395e..bfc32316f2 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -40,7 +40,10 @@ import { createRuntimeForWorkspace, resolveWorkspaceExecutionPath, } from "@/node/runtime/runtimeHelpers"; -import { getWorkspacePathHintForProject } from "@/node/services/workspaceProjectRepos"; +import { + createRuntimeForWorkspaceProject, + resolveWorkspacePathForProject, +} from "@/node/services/workspaceProjectRepos"; import { validateWorkspaceName } from "@/common/utils/validation/workspaceValidation"; import { ensurePrivateDir } from "@/node/utils/fs"; import { stripTrailingSlashes } from "@/node/utils/pathUtils"; @@ -2833,6 +2836,17 @@ export class WorkspaceService extends EventEmitter { const configSnapshot = this.config.loadConfigOrDefault(); const persistedWorkspacePath = this.config.findWorkspace(workspaceId)?.workspacePath; + const workspaceProjectRuntimeParams = + persistedWorkspacePath == null + ? undefined + : { + workspaceName: metadata.name, + workspacePath: persistedWorkspacePath, + runtimeConfig: metadata.runtimeConfig, + projectPath: metadata.projectPath, + projectName: metadata.projectName, + projects: metadata.projects, + }; if (isMultiProject(metadata)) { const projects = getProjects(metadata); @@ -2845,24 +2859,15 @@ export class WorkspaceService extends EventEmitter { for (const project of projects) { try { - const runtime = createRuntime(metadata.runtimeConfig, { - projectPath: project.projectPath, - workspaceName: metadata.name, - workspacePath: persistedWorkspacePath - ? getWorkspacePathHintForProject( - { - workspaceId, - workspaceName: metadata.name, - workspacePath: persistedWorkspacePath, - runtimeConfig: metadata.runtimeConfig, - projectPath: metadata.projectPath, - projectName: metadata.projectName, - projects: metadata.projects, - }, - project.projectPath - ) - : undefined, - }); + const runtime = workspaceProjectRuntimeParams + ? createRuntimeForWorkspaceProject( + workspaceProjectRuntimeParams, + project.projectPath + ) + : createRuntime(metadata.runtimeConfig, { + projectPath: project.projectPath, + workspaceName: metadata.name, + }); const trusted = configSnapshot.projects.get(stripTrailingSlashes(project.projectPath))?.trusted ?? false; @@ -2979,10 +2984,9 @@ export class WorkspaceService extends EventEmitter { } } else { const projectPath = metadata.projectPath; - const runtime = createRuntime(metadata.runtimeConfig, { - projectPath, - workspaceName: metadata.name, - workspacePath: persistedWorkspacePath, + const runtime = createRuntimeForWorkspace({ + ...metadata, + namedWorkspacePath: persistedWorkspacePath, }); // Delete workspace from runtime first - if this fails with force=false, we abort @@ -3230,11 +3234,9 @@ export class WorkspaceService extends EventEmitter { return null; } - const runtimeConfig = metadata.runtimeConfig; - const runtime = createRuntime(runtimeConfig, { - projectPath: metadata.projectPath, - workspaceName: metadata.name, - workspacePath: workspace.workspacePath, + const runtime = createRuntimeForWorkspace({ + ...metadata, + namedWorkspacePath: workspace.workspacePath, }); const hostWorkspacePath = workspace.workspacePath; @@ -3508,23 +3510,20 @@ export class WorkspaceService extends EventEmitter { } }; + const workspaceProjectRuntimeParams = { + workspaceName: oldName, + workspacePath: workspace.workspacePath, + runtimeConfig: oldMetadata.runtimeConfig, + projectPath: oldMetadata.projectPath, + projectName: oldMetadata.projectName, + projects: oldMetadata.projects, + }; + for (const project of projects) { - const runtime = createRuntime(oldMetadata.runtimeConfig, { - projectPath: project.projectPath, - workspaceName: oldName, - workspacePath: getWorkspacePathHintForProject( - { - workspaceId, - workspaceName: oldName, - workspacePath: workspace.workspacePath, - runtimeConfig: oldMetadata.runtimeConfig, - projectPath: oldMetadata.projectPath, - projectName: oldMetadata.projectName, - projects: oldMetadata.projects, - }, - project.projectPath - ), - }); + const runtime = createRuntimeForWorkspaceProject( + workspaceProjectRuntimeParams, + project.projectPath + ); const trusted = configSnapshot.projects.get(stripTrailingSlashes(project.projectPath))?.trusted ?? @@ -3643,10 +3642,9 @@ export class WorkspaceService extends EventEmitter { workspaceName: newName, }); } else { - const runtime = createRuntime(oldMetadata.runtimeConfig, { - projectPath: configProjectPath, - workspaceName: oldName, - workspacePath: workspace.workspacePath, + const runtime = createRuntimeForWorkspace({ + ...oldMetadata, + namedWorkspacePath: workspace.workspacePath, }); const trusted = @@ -6490,6 +6488,17 @@ export class WorkspaceService extends EventEmitter { return this.listGitPathsForFileCompletions(runtime, workspacePath); } + const workspaceProjectRuntimeParams = + workspacePath == null + ? undefined + : { + workspaceName: metadata.name, + workspacePath, + runtimeConfig: metadata.runtimeConfig, + projectPath: metadata.projectPath, + projectName: metadata.projectName, + projects: metadata.projects, + }; const projectFiles = await Promise.all( getProjects(metadata).map(async (project) => { assert( @@ -6497,29 +6506,19 @@ export class WorkspaceService extends EventEmitter { `Workspace ${metadata.id} has a project without a projectName` ); - const projectRuntime = createRuntime(metadata.runtimeConfig, { - projectPath: project.projectPath, - workspaceName: metadata.name, - workspacePath: - isSSHRuntime(metadata.runtimeConfig) && workspacePath != null - ? getWorkspacePathHintForProject( - { - workspaceId: metadata.id, - workspaceName: metadata.name, - workspacePath, - runtimeConfig: metadata.runtimeConfig, - projectPath: metadata.projectPath, - projectName: metadata.projectName, - projects: metadata.projects, - }, - project.projectPath - ) - : undefined, - }); - const projectWorkspacePath = projectRuntime.getWorkspacePath( - project.projectPath, - metadata.name - ); + const projectRuntime = workspaceProjectRuntimeParams + ? createRuntimeForWorkspaceProject(workspaceProjectRuntimeParams, project.projectPath) + : createRuntime(metadata.runtimeConfig, { + projectPath: project.projectPath, + workspaceName: metadata.name, + }); + const projectWorkspacePath = workspaceProjectRuntimeParams + ? resolveWorkspacePathForProject( + workspaceProjectRuntimeParams, + project.projectPath, + projectRuntime + ) + : projectRuntime.getWorkspacePath(project.projectPath, metadata.name); assert( projectWorkspacePath.trim().length > 0, `Workspace ${metadata.id} project ${project.projectName} resolved to an empty workspace path` @@ -6664,28 +6663,22 @@ export class WorkspaceService extends EventEmitter { return Err(`Workspace ${workspaceId} not found in config`); } + const workspaceProjectRuntimeParams = { + workspaceName: metadata.name, + workspacePath: workspace.workspacePath, + runtimeConfig: metadata.runtimeConfig, + projectPath: metadata.projectPath, + projectName: metadata.projectName, + projects: metadata.projects, + }; const multiProjectRuntimes = isMultiProject(metadata) ? getProjects(metadata).map((project) => ({ projectPath: project.projectPath, projectName: project.projectName, - runtime: createRuntime(metadata.runtimeConfig, { - projectPath: project.projectPath, - workspaceName: metadata.name, - workspacePath: isSSHRuntime(metadata.runtimeConfig) - ? getWorkspacePathHintForProject( - { - workspaceId, - workspaceName: metadata.name, - workspacePath: workspace.workspacePath, - runtimeConfig: metadata.runtimeConfig, - projectPath: metadata.projectPath, - projectName: metadata.projectName, - projects: metadata.projects, - }, - project.projectPath - ) - : undefined, - }), + runtime: createRuntimeForWorkspaceProject( + workspaceProjectRuntimeParams, + project.projectPath + ), })) : undefined; @@ -6698,10 +6691,9 @@ export class WorkspaceService extends EventEmitter { multiProjectRuntimes, metadata.name ) - : createRuntime(metadata.runtimeConfig, { - projectPath: metadata.projectPath, - workspaceName: metadata.name, - workspacePath: workspace.workspacePath, + : createRuntimeForWorkspace({ + ...metadata, + namedWorkspacePath: workspace.workspacePath, }); // Ensure runtime is ready (e.g., start Docker container if stopped)