Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 13 additions & 20 deletions src/node/services/aiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -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
);
Expand Down
92 changes: 90 additions & 2 deletions src/node/services/workspaceProjectRepos.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -116,7 +206,6 @@ describe("getWorkspaceProjectRepos", () => {

const hint = getWorkspacePathHintForProject(
{
workspaceId: "workspace-1",
workspaceName,
workspacePath: getRemoteWorkspacePath(
buildLegacyRemoteProjectLayout(runtimeConfig.srcBaseDir, primaryProjectPath),
Expand Down Expand Up @@ -151,7 +240,6 @@ describe("getWorkspaceProjectRepos", () => {

const hint = getWorkspacePathHintForProject(
{
workspaceId: "workspace-1",
workspaceName: "main",
workspacePath: "/tmp/src/containers/main",
runtimeConfig,
Expand Down
78 changes: 58 additions & 20 deletions src/node/services/workspaceProjectRepos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -18,8 +19,7 @@ export interface WorkspaceProjectRepo {
repoCwd: string;
}

interface WorkspaceProjectRepoParams {
workspaceId: string;
export interface WorkspaceProjectRuntimeParams {
workspaceName: string;
workspacePath: string;
runtimeConfig: RuntimeConfig;
Expand All @@ -28,6 +28,10 @@ interface WorkspaceProjectRepoParams {
projects?: ProjectRef[];
}

interface WorkspaceProjectRepoParams extends WorkspaceProjectRuntimeParams {
workspaceId: string;
}

interface WorkspaceProjectStorageKeyParams {
projectPath: string;
projectName?: string;
Expand Down Expand Up @@ -138,8 +142,14 @@ export function getWorkspaceProjectStorageKeys(
return storageKeys;
}

function hasMultipleWorkspaceProjects(
params: Pick<WorkspaceProjectRuntimeParams, "projects">
): boolean {
return (params.projects?.length ?? 0) > 1;
}

export function getWorkspacePathHintForProject(
params: WorkspaceProjectRepoParams,
params: WorkspaceProjectRuntimeParams,
targetProjectPath: string
): string | undefined {
if (!isSSHRuntime(params.runtimeConfig)) {
Expand Down Expand Up @@ -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[] {
Expand All @@ -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,
Expand Down
Loading
Loading