diff --git a/src/node/runtime/DockerRuntime.ts b/src/node/runtime/DockerRuntime.ts index 989c10816d..7b914e766b 100644 --- a/src/node/runtime/DockerRuntime.ts +++ b/src/node/runtime/DockerRuntime.ts @@ -353,7 +353,10 @@ export class DockerRuntime extends RemoteRuntime { return `cd ${shescape.quote(cwd)}`; } - protected spawnRemoteProcess(fullCommand: string, _options: ExecOptions): Promise { + protected spawnRemoteProcess( + fullCommand: string, + _options: ExecOptions & { deadlineMs?: number } + ): Promise { // Verify container name is available if (!this.containerName) { throw new RuntimeError( diff --git a/src/node/runtime/RemoteRuntime.ts b/src/node/runtime/RemoteRuntime.ts index a60aef5c51..b531bb68ce 100644 --- a/src/node/runtime/RemoteRuntime.ts +++ b/src/node/runtime/RemoteRuntime.ts @@ -11,7 +11,6 @@ * - spawnRemoteProcess() - how to spawn the external process (ssh/docker) * - getBasePath() - base directory for workspace operations * - quoteForRemote() - path quoting strategy - * - onExitCode() - optional exit code handling (SSH connection pool) */ import type { ChildProcess } from "child_process"; @@ -45,8 +44,12 @@ import { getAtomicWriteTempPath } from "./atomicWriteTempPath"; export interface SpawnResult { /** The spawned child process */ process: ChildProcess; - /** Optional async work to do before exec (e.g., acquire connection) */ - preExec?: Promise; + /** Optional transport-scoped exit handling (e.g., master-pool health accounting). */ + onExit?: (exitCode: number, stderr: string) => void; + /** Optional close handling that must run even for synthetic abort/timeout exits. */ + onClose?: () => void; + /** Optional transport-scoped spawn error handling. */ + onError?: (error: Error) => void; } /** @@ -59,11 +62,11 @@ export abstract class RemoteRuntime implements Runtime { * * @param fullCommand The full shell command to execute (already wrapped in bash -c) * @param options Original exec options - * @returns The spawned process and optional pre-exec work + * @returns The spawned process and optional transport lifecycle hooks */ protected abstract spawnRemoteProcess( fullCommand: string, - options: ExecOptions + options: ExecOptions & { deadlineMs?: number } ): Promise; /** @@ -84,15 +87,6 @@ export abstract class RemoteRuntime implements Runtime { */ protected abstract cdCommand(cwd: string): string; - /** - * Called when exec completes with an exit code. - * Subclasses can use this for connection pool health tracking. - * @param stderr - Captured stderr for error reporting (e.g., SSH connection failures) - */ - protected onExitCode(_exitCode: number, _options: ExecOptions, _stderr: string): void { - // Default: no-op. SSH overrides to report to connection pool. - } - /** * Command prefix (e.g., "SSH" or "Docker") for logging. */ @@ -138,8 +132,13 @@ export abstract class RemoteRuntime implements Runtime { } // Spawn the remote process (SSH or Docker) - // For SSH, this awaits connection pool backoff before spawning - const { process: childProcess } = await this.spawnRemoteProcess(fullCommand, options); + const timeoutMs = options.timeout !== undefined ? options.timeout * 1000 : undefined; + const deadlineMs = timeoutMs !== undefined ? Date.now() + timeoutMs : undefined; + const spawnResult = await this.spawnRemoteProcess(fullCommand, { + ...options, + deadlineMs, + }); + const { process: childProcess } = spawnResult; // Short-lived commands can close stdin before writes/close complete. if (childProcess.stdin) { @@ -163,23 +162,23 @@ export abstract class RemoteRuntime implements Runtime { // Create promises for exit code and duration immediately. const exitCode = new Promise((resolve, reject) => { childProcess.on("close", (code, signal) => { - if (aborted || options.abortSignal?.aborted) { - resolve(EXIT_CODE_ABORTED); - return; - } - if (timedOut) { - resolve(EXIT_CODE_TIMEOUT); - return; + const finalExitCode = + aborted || options.abortSignal?.aborted + ? EXIT_CODE_ABORTED + : timedOut + ? EXIT_CODE_TIMEOUT + : (code ?? (signal ? -1 : 0)); + + if (finalExitCode !== EXIT_CODE_ABORTED && finalExitCode !== EXIT_CODE_TIMEOUT) { + spawnResult.onExit?.(finalExitCode, stderrForErrorReporting); } - const finalExitCode = code ?? (signal ? -1 : 0); - - // Let subclass handle exit code (e.g., SSH connection pool) - this.onExitCode(finalExitCode, options, stderrForErrorReporting); + spawnResult.onClose?.(); resolve(finalExitCode); }); childProcess.on("error", (err) => { + spawnResult.onError?.(err); reject( new RuntimeError( `Failed to execute ${this.commandPrefix} command: ${err.message}`, @@ -226,8 +225,10 @@ export abstract class RemoteRuntime implements Runtime { void exitCode.finally(() => abortSignal.removeEventListener("abort", onAbort)); } - // Handle timeout - if (options.timeout !== undefined) { + // Handle timeout. Include connection acquisition time in the local deadline so + // user-configured timeouts do not silently stretch while the runtime waits for SSH capacity. + if (timeoutMs !== undefined) { + const remainingTimeoutMs = Math.max(0, (deadlineMs ?? Date.now()) - Date.now()); const timeoutHandle = setTimeout(() => { timedOut = true; @@ -245,7 +246,7 @@ export abstract class RemoteRuntime implements Runtime { disposable[Symbol.dispose](); }, 1000); hardKillHandle.unref(); - }, options.timeout * 1000); + }, remainingTimeoutMs); void exitCode.finally(() => clearTimeout(timeoutHandle)); } diff --git a/src/node/runtime/SSHRuntime.test.ts b/src/node/runtime/SSHRuntime.test.ts deleted file mode 100644 index 60abe4d2d0..0000000000 --- a/src/node/runtime/SSHRuntime.test.ts +++ /dev/null @@ -1,836 +0,0 @@ -import { describe, expect, it, beforeEach, afterEach, spyOn } from "bun:test"; -import * as runtimeHelpers from "@/node/utils/runtime/helpers"; -import * as disposableExec from "@/node/utils/disposableExec"; -import * as submoduleSync from "./submoduleSync"; -import { SSHRuntime, computeBaseRepoPath } from "./SSHRuntime"; -import { createSSHTransport } from "./transports"; - -/** - * SSHRuntime unit tests (run with bun test) - * - * Integration tests for workspace operations (renameWorkspace, deleteWorkspace, forkWorkspace, - * worktree-based operations) require Docker and are in tests/runtime/runtime.test.ts. - * Run with: TEST_INTEGRATION=1 bun x jest tests/runtime/runtime.test.ts - */ -function createMockExecResult( - result: Promise<{ stdout: string; stderr: string }> -): ReturnType { - void result.catch(() => undefined); - return { - result, - get promise() { - return result; - }, - child: {}, - [Symbol.dispose]: () => undefined, - } as unknown as ReturnType; -} - -describe("SSHRuntime constructor", () => { - it("should accept tilde in srcBaseDir", () => { - // Tildes are now allowed - they will be resolved via resolvePath() - expect(() => { - const config = { host: "example.com", srcBaseDir: "~/mux" }; - new SSHRuntime(config, createSSHTransport(config, false)); - }).not.toThrow(); - }); - - it("should accept bare tilde in srcBaseDir", () => { - // Tildes are now allowed - they will be resolved via resolvePath() - expect(() => { - const config = { host: "example.com", srcBaseDir: "~" }; - new SSHRuntime(config, createSSHTransport(config, false)); - }).not.toThrow(); - }); - - it("should accept absolute paths in srcBaseDir", () => { - expect(() => { - const config = { host: "example.com", srcBaseDir: "/home/user/mux" }; - new SSHRuntime(config, createSSHTransport(config, false)); - }).not.toThrow(); - }); -}); - -describe("SSHRuntime base repo config normalization", () => { - type NormalizeBaseRepoSharedConfig = ( - baseRepoPathArg: string, - abortSignal?: AbortSignal - ) => Promise; - - let execBufferedSpy: ReturnType> | null = - null; - let runtime: SSHRuntime; - - beforeEach(() => { - const config = { host: "example.com", srcBaseDir: "/home/user/src" }; - runtime = new SSHRuntime(config, createSSHTransport(config, false)); - }); - - afterEach(() => { - execBufferedSpy?.mockRestore(); - execBufferedSpy = null; - }); - - function getNormalizeBaseRepoSharedConfig(): NormalizeBaseRepoSharedConfig { - const normalizeUnknown: unknown = Reflect.get(runtime, "normalizeBaseRepoSharedConfig"); - if (typeof normalizeUnknown !== "function") { - throw new Error("normalizeBaseRepoSharedConfig is unavailable"); - } - - return normalizeUnknown as NormalizeBaseRepoSharedConfig; - } - - function normalizeBaseRepoSharedConfig(): Promise { - return getNormalizeBaseRepoSharedConfig().call( - runtime, - '"/home/user/src/project/.mux-base.git"' - ); - } - - it("removes a local core.bare entry from the shared base repo config", async () => { - execBufferedSpy = spyOn(runtimeHelpers, "execBuffered").mockResolvedValue({ - stdout: "", - stderr: "", - exitCode: 0, - duration: 0, - }); - - expect(await normalizeBaseRepoSharedConfig()).toBe(true); - expect(execBufferedSpy).toHaveBeenCalledWith( - runtime, - expect.stringContaining("config --local --unset-all core.bare"), - expect.objectContaining({ cwd: "/tmp", timeout: 10 }) - ); - }); - - it("treats missing local core.bare config as already normalized", async () => { - execBufferedSpy = spyOn(runtimeHelpers, "execBuffered").mockResolvedValue({ - stdout: "", - stderr: "", - exitCode: 5, - duration: 0, - }); - - expect(await normalizeBaseRepoSharedConfig()).toBe(false); - }); - - it("treats lock conflicts as a no-op when another writer already removed core.bare", async () => { - execBufferedSpy = spyOn(runtimeHelpers, "execBuffered") - .mockResolvedValueOnce({ - stdout: "", - stderr: "error: could not lock config file config: File exists", - exitCode: 255, - duration: 0, - }) - .mockResolvedValueOnce({ - stdout: "", - stderr: "", - exitCode: 1, - duration: 0, - }); - - expect(await normalizeBaseRepoSharedConfig()).toBe(false); - expect(execBufferedSpy).toHaveBeenNthCalledWith( - 2, - runtime, - expect.stringContaining("config --local --get core.bare"), - expect.objectContaining({ cwd: "/tmp", timeout: 10 }) - ); - }); - - it("retries lock conflicts while the shared core.bare entry still exists", async () => { - execBufferedSpy = spyOn(runtimeHelpers, "execBuffered") - .mockResolvedValueOnce({ - stdout: "", - stderr: "error: could not lock config file config: File exists", - exitCode: 255, - duration: 0, - }) - .mockResolvedValueOnce({ - stdout: "true\n", - stderr: "", - exitCode: 0, - duration: 0, - }) - .mockResolvedValueOnce({ - stdout: "", - stderr: "", - exitCode: 0, - duration: 0, - }); - - expect(await normalizeBaseRepoSharedConfig()).toBe(true); - expect(execBufferedSpy).toHaveBeenCalledTimes(3); - }); -}); - -describe("SSHRuntime bundle sync reuse", () => { - type ShouldReuseCurrentBundleTrunk = ( - projectPath: string, - trunkBranch: string, - initLogger: { - logStep: (message: string) => void; - logStdout: (line: string) => void; - logStderr: (line: string) => void; - logComplete: (exitCode: number) => void; - }, - abortSignal?: AbortSignal - ) => Promise; - - interface RuntimeWithResolveLocalSyncRefManifest { - resolveLocalSyncRefManifest: (projectPath: string) => Promise; - } - - interface RuntimeWithResolveRemoteSyncRefManifest { - resolveRemoteSyncRefManifest: ( - baseRepoPathArg: string, - abortSignal?: AbortSignal - ) => Promise; - } - - let runtime: SSHRuntime; - let execBufferedSpy: ReturnType> | null = - null; - let execFileAsyncSpy: ReturnType> | null = - null; - const initMessages: string[] = []; - const initLogger = { - logStep: (message: string) => { - initMessages.push(message); - }, - logStdout: () => undefined, - logStderr: () => undefined, - logComplete: () => undefined, - }; - - beforeEach(() => { - const config = { host: "example.com", srcBaseDir: "/home/user/src" }; - runtime = new SSHRuntime(config, createSSHTransport(config, false)); - initMessages.length = 0; - }); - - afterEach(() => { - execBufferedSpy?.mockRestore(); - execBufferedSpy = null; - execFileAsyncSpy?.mockRestore(); - execFileAsyncSpy = null; - }); - - function getShouldReuseCurrentBundleTrunk(): ShouldReuseCurrentBundleTrunk { - const reuseUnknown: unknown = Reflect.get(runtime, "shouldReuseCurrentBundleTrunk"); - if (typeof reuseUnknown !== "function") { - throw new Error("shouldReuseCurrentBundleTrunk is unavailable"); - } - - return reuseUnknown as ShouldReuseCurrentBundleTrunk; - } - - it("reuses the shared bundle when the remote refs already match local refs", async () => { - execFileAsyncSpy = spyOn(disposableExec, "execFileAsync").mockReturnValue( - createMockExecResult(Promise.resolve({ stdout: "abc123\n", stderr: "" })) - ); - execBufferedSpy = spyOn(runtimeHelpers, "execBuffered").mockResolvedValue({ - stdout: "abc123\n", - stderr: "", - exitCode: 0, - duration: 0, - }); - const localManifestSpy = spyOn( - runtime as unknown as RuntimeWithResolveLocalSyncRefManifest, - "resolveLocalSyncRefManifest" - ).mockResolvedValue("refs/heads/main abc123\nrefs/tags/v1 def456"); - const remoteManifestSpy = spyOn( - runtime as unknown as RuntimeWithResolveRemoteSyncRefManifest, - "resolveRemoteSyncRefManifest" - ).mockResolvedValue("refs/heads/main abc123\nrefs/tags/v1 def456"); - - try { - expect( - await getShouldReuseCurrentBundleTrunk().call(runtime, "/projects/demo", "main", initLogger) - ).toBe(true); - expect(execFileAsyncSpy).toHaveBeenCalledWith("git", [ - "-C", - "/projects/demo", - "rev-parse", - "--verify", - "main", - ]); - expect(execBufferedSpy).toHaveBeenCalledWith( - runtime, - expect.stringContaining("refs/mux-bundle/main"), - expect.objectContaining({ cwd: "/tmp", timeout: 10 }) - ); - expect(localManifestSpy).toHaveBeenCalledWith("/projects/demo"); - expect(remoteManifestSpy).toHaveBeenCalledWith( - '"/home/user/src/demo/.mux-base.git"', - undefined - ); - expect(initMessages.some((message) => message.includes("skipping sync"))).toBe(true); - } finally { - localManifestSpy.mockRestore(); - remoteManifestSpy.mockRestore(); - } - }); - - it("does not reuse the shared bundle when the remote trunk ref is stale", async () => { - execFileAsyncSpy = spyOn(disposableExec, "execFileAsync").mockReturnValue( - createMockExecResult(Promise.resolve({ stdout: "abc123\n", stderr: "" })) - ); - execBufferedSpy = spyOn(runtimeHelpers, "execBuffered").mockResolvedValue({ - stdout: "def456\n", - stderr: "", - exitCode: 0, - duration: 0, - }); - - expect( - await getShouldReuseCurrentBundleTrunk().call(runtime, "/projects/demo", "main", initLogger) - ).toBe(false); - expect(initMessages).toHaveLength(0); - }); - - it("does not reuse the shared bundle when non-trunk refs drift", async () => { - execFileAsyncSpy = spyOn(disposableExec, "execFileAsync").mockReturnValue( - createMockExecResult(Promise.resolve({ stdout: "abc123\n", stderr: "" })) - ); - execBufferedSpy = spyOn(runtimeHelpers, "execBuffered").mockResolvedValue({ - stdout: "abc123\n", - stderr: "", - exitCode: 0, - duration: 0, - }); - const localManifestSpy = spyOn( - runtime as unknown as RuntimeWithResolveLocalSyncRefManifest, - "resolveLocalSyncRefManifest" - ).mockResolvedValue("refs/heads/main abc123\nrefs/tags/v2 fedcba"); - const remoteManifestSpy = spyOn( - runtime as unknown as RuntimeWithResolveRemoteSyncRefManifest, - "resolveRemoteSyncRefManifest" - ).mockResolvedValue("refs/heads/main abc123\nrefs/tags/v1 def456"); - - try { - expect( - await getShouldReuseCurrentBundleTrunk().call(runtime, "/projects/demo", "main", initLogger) - ).toBe(false); - expect(initMessages).toHaveLength(0); - } finally { - localManifestSpy.mockRestore(); - remoteManifestSpy.mockRestore(); - } - }); - - it("falls back to sync when the remote bundle probe throws", async () => { - execFileAsyncSpy = spyOn(disposableExec, "execFileAsync").mockReturnValue( - createMockExecResult(Promise.resolve({ stdout: "abc123\n", stderr: "" })) - ); - execBufferedSpy = spyOn(runtimeHelpers, "execBuffered").mockImplementation(() => { - throw new Error("ssh unavailable"); - }); - - expect( - await getShouldReuseCurrentBundleTrunk().call(runtime, "/projects/demo", "main", initLogger) - ).toBe(false); - expect(initMessages).toHaveLength(0); - }); - - it("does not reuse the shared bundle when the local trunk ref is missing", async () => { - execFileAsyncSpy = spyOn(disposableExec, "execFileAsync").mockReturnValue( - createMockExecResult(Promise.reject(new Error("unknown revision"))) - ); - execBufferedSpy = spyOn(runtimeHelpers, "execBuffered").mockResolvedValue({ - stdout: "abc123\n", - stderr: "", - exitCode: 0, - duration: 0, - }); - - expect( - await getShouldReuseCurrentBundleTrunk().call(runtime, "/projects/demo", "main", initLogger) - ).toBe(false); - expect(execBufferedSpy).not.toHaveBeenCalled(); - }); -}); - -describe("SSHRuntime.prepareWorkspaceCheckout", () => { - interface RuntimeWithPrepareWorkspaceCheckout { - prepareWorkspaceCheckout: ( - params: { - projectPath: string; - branchName: string; - trunkBranch: string; - workspacePath: string; - initLogger: { - logStep: (message: string) => void; - logStdout: (line: string) => void; - logStderr: (line: string) => void; - logComplete: (exitCode: number) => void; - }; - abortSignal?: AbortSignal; - env?: Record; - trusted?: boolean; - }, - nhp: string - ) => Promise; - } - - interface RuntimeWithShouldReuseCurrentBundleTrunk { - shouldReuseCurrentBundleTrunk: ( - projectPath: string, - trunkBranch: string, - initLogger: { - logStep: (message: string) => void; - logStdout: (line: string) => void; - logStderr: (line: string) => void; - logComplete: (exitCode: number) => void; - }, - abortSignal?: AbortSignal - ) => Promise; - } - - interface RuntimeWithFetchOriginTrunk { - fetchOriginTrunk: ( - workspacePath: string, - trunkBranch: string, - initLogger: { - logStep: (message: string) => void; - logStdout: (line: string) => void; - logStderr: (line: string) => void; - logComplete: (exitCode: number) => void; - }, - abortSignal?: AbortSignal, - nhp?: string - ) => Promise; - } - - interface RuntimeWithResolveBundleTrunkRef { - resolveBundleTrunkRef: ( - baseRepoPathArg: string, - trunkBranch: string, - abortSignal?: AbortSignal - ) => Promise; - } - - interface RuntimeWithEnsureBaseRepo { - ensureBaseRepo: ( - projectPath: string, - initLogger: { - logStep: (message: string) => void; - logStdout: (line: string) => void; - logStderr: (line: string) => void; - logComplete: (exitCode: number) => void; - }, - abortSignal?: AbortSignal - ) => Promise; - } - - interface RuntimeWithGetOriginUrlForSync { - getOriginUrlForSync: ( - projectPath: string, - initLogger: { - logStep: (message: string) => void; - logStdout: (line: string) => void; - logStderr: (line: string) => void; - logComplete: (exitCode: number) => void; - } - ) => Promise<{ originUrl: string | null }>; - } - - interface RuntimeWithCanFastForwardToOrigin { - canFastForwardToOrigin: ( - workspacePath: string, - localRef: string, - originBranch: string, - initLogger: { - logStep: (message: string) => void; - logStdout: (line: string) => void; - logStderr: (line: string) => void; - logComplete: (exitCode: number) => void; - }, - abortSignal?: AbortSignal - ) => Promise; - } - - interface RuntimeWithSyncProjectToRemote { - syncProjectToRemote: ( - projectPath: string, - workspacePath: string, - initLogger: { - logStep: (message: string) => void; - logStdout: (line: string) => void; - logStderr: (line: string) => void; - logComplete: (exitCode: number) => void; - }, - abortSignal?: AbortSignal - ) => Promise; - } - - it("still creates a worktree when bundle sync is skipped for a new workspace", async () => { - const config = { host: "example.com", srcBaseDir: "/home/user/src" }; - const runtime = new SSHRuntime(config, createSSHTransport(config, false)); - const initMessages: string[] = []; - const initLogger = { - logStep: (message: string) => { - initMessages.push(message); - }, - logStdout: () => undefined, - logStderr: () => undefined, - logComplete: () => undefined, - }; - - const execBufferedSpy = spyOn(runtimeHelpers, "execBuffered").mockImplementation( - (_runtime, command) => { - if (command === "test -d /home/user/src/demo/review-slot") { - return Promise.resolve({ stdout: "", stderr: "", exitCode: 1, duration: 0 }); - } - if ( - command.includes("remote set-url origin") || - command.includes("remote add origin") || - command.includes('worktree add "/home/user/src/demo/review-slot"') - ) { - return Promise.resolve({ stdout: "", stderr: "", exitCode: 0, duration: 0 }); - } - throw new Error(`Unexpected execBuffered command: ${command}`); - } - ); - const reuseSpy = spyOn( - runtime as unknown as RuntimeWithShouldReuseCurrentBundleTrunk, - "shouldReuseCurrentBundleTrunk" - ).mockResolvedValue(true); - const fetchOriginSpy = spyOn( - runtime as unknown as RuntimeWithFetchOriginTrunk, - "fetchOriginTrunk" - ).mockResolvedValue(false); - const resolveBundleSpy = spyOn( - runtime as unknown as RuntimeWithResolveBundleTrunkRef, - "resolveBundleTrunkRef" - ).mockResolvedValue("refs/mux-bundle/main"); - const ensureBaseRepoSpy = spyOn( - runtime as unknown as RuntimeWithEnsureBaseRepo, - "ensureBaseRepo" - ).mockResolvedValue('"/home/user/src/demo/.mux-base.git"'); - const getOriginUrlSpy = spyOn( - runtime as unknown as RuntimeWithGetOriginUrlForSync, - "getOriginUrlForSync" - ).mockResolvedValue({ originUrl: "git@github.com:coder/mux.git" }); - const canFastForwardSpy = spyOn( - runtime as unknown as RuntimeWithCanFastForwardToOrigin, - "canFastForwardToOrigin" - ).mockResolvedValue(false); - const syncProjectSpy = spyOn( - runtime as unknown as RuntimeWithSyncProjectToRemote, - "syncProjectToRemote" - ).mockResolvedValue(undefined); - const syncSubmodulesSpy = spyOn(submoduleSync, "syncRuntimeGitSubmodules").mockResolvedValue( - undefined - ); - - try { - await (runtime as unknown as RuntimeWithPrepareWorkspaceCheckout).prepareWorkspaceCheckout( - { - projectPath: "/projects/demo", - branchName: "review-slot", - trunkBranch: "main", - workspacePath: "/home/user/src/demo/review-slot", - initLogger, - env: {}, - trusted: true, - }, - "" - ); - - expect(reuseSpy).toHaveBeenCalled(); - expect(syncProjectSpy).not.toHaveBeenCalled(); - expect(ensureBaseRepoSpy).toHaveBeenCalledWith("/projects/demo", initLogger, undefined); - expect(fetchOriginSpy).toHaveBeenCalled(); - expect(resolveBundleSpy).toHaveBeenCalled(); - expect(getOriginUrlSpy).toHaveBeenCalledWith("/projects/demo", initLogger); - expect(canFastForwardSpy).not.toHaveBeenCalled(); - expect(execBufferedSpy).toHaveBeenCalledWith( - runtime, - expect.stringContaining("remote set-url origin 'git@github.com:coder/mux.git'"), - expect.objectContaining({ cwd: "/tmp", timeout: 10 }) - ); - expect(execBufferedSpy).toHaveBeenCalledWith( - runtime, - expect.stringContaining( - "worktree add \"/home/user/src/demo/review-slot\" -B 'review-slot' 'refs/mux-bundle/main'" - ), - expect.objectContaining({ cwd: "/tmp", timeout: 300 }) - ); - expect(syncSubmodulesSpy).toHaveBeenCalledWith( - expect.objectContaining({ workspacePath: "/home/user/src/demo/review-slot" }) - ); - expect(initMessages).toContain("Worktree created successfully"); - } finally { - execBufferedSpy.mockRestore(); - reuseSpy.mockRestore(); - fetchOriginSpy.mockRestore(); - resolveBundleSpy.mockRestore(); - ensureBaseRepoSpy.mockRestore(); - getOriginUrlSpy.mockRestore(); - canFastForwardSpy.mockRestore(); - syncProjectSpy.mockRestore(); - syncSubmodulesSpy.mockRestore(); - } - }); -}); - -describe("SSHRuntime.createWorkspace", () => { - it("uses directoryName for the workspace path while preparing the remote parent directory", async () => { - const config = { host: "example.com", srcBaseDir: "/home/user/src" }; - const runtime = new SSHRuntime(config, createSSHTransport(config, false)); - const execSpy = spyOn(runtime, "exec").mockResolvedValue({ - stdout: new ReadableStream({ - start(controller) { - controller.close(); - }, - }), - stderr: new ReadableStream({ - start(controller) { - controller.close(); - }, - }), - stdin: new WritableStream(), - exitCode: Promise.resolve(0), - duration: Promise.resolve(0), - }); - const readFileSpy = spyOn(runtime, "readFile").mockReturnValue( - new ReadableStream({ - start(controller) { - controller.error(new Error("missing branch map")); - }, - }) - ); - const writeFileSpy = spyOn(runtime, "writeFile").mockReturnValue( - new WritableStream() - ); - - try { - const result = await runtime.createWorkspace({ - projectPath: "/projects/demo", - branchName: "feature-branch", - directoryName: "review-slot", - trunkBranch: "main", - initLogger: { - logStep: () => undefined, - logStdout: () => undefined, - logStderr: () => undefined, - logComplete: () => undefined, - }, - }); - - expect(result).toEqual({ - success: true, - workspacePath: "/home/user/src/demo/review-slot", - }); - expect(execSpy).toHaveBeenCalledWith('mkdir -p "/home/user/src/demo"', { - cwd: "/tmp", - timeout: 10, - abortSignal: undefined, - }); - } finally { - execSpy.mockRestore(); - readFileSpy.mockRestore(); - writeFileSpy.mockRestore(); - } - }); -}); - -describe("SSHRuntime.deleteWorkspace", () => { - function createExecStream(exitCode: number) { - return { - stdout: new ReadableStream(), - stderr: new ReadableStream(), - stdin: new WritableStream(), - exitCode: Promise.resolve(exitCode), - duration: Promise.resolve(0), - }; - } - - it("deletes the mapped workspace branch instead of the current remote checkout", async () => { - const config = { host: "example.com", srcBaseDir: "/home/user/src" }; - const runtime = new SSHRuntime(config, createSSHTransport(config, false)); - const execSpy = spyOn(runtime, "exec").mockImplementation((command) => { - if (command.includes("git diff --quiet") || command.includes("test -d")) { - return Promise.resolve(createExecStream(0)); - } - if (command.includes("worktree remove")) { - return Promise.resolve(createExecStream(0)); - } - throw new Error(`Unexpected exec command: ${command}`); - }); - const readFileSpy = spyOn(runtime, "readFile").mockReturnValue( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode('{"review-slot":"feature-branch"}\n')); - controller.close(); - }, - }) - ); - const execBufferedSpy = spyOn(runtimeHelpers, "execBuffered").mockImplementation( - (_runtime, command) => { - if (command.startsWith("test -f ")) { - return Promise.resolve({ stdout: "", stderr: "", exitCode: 0, duration: 0 }); - } - if (command.startsWith("rm -f ")) { - return Promise.resolve({ stdout: "", stderr: "", exitCode: 0, duration: 0 }); - } - if (command.includes(" branch -D ")) { - expect(command).toContain("feature-branch"); - expect(command).not.toContain("review-slot"); - return Promise.resolve({ stdout: "", stderr: "", exitCode: 0, duration: 0 }); - } - throw new Error(`Unexpected execBuffered command: ${command}`); - } - ); - - try { - const result = await runtime.deleteWorkspace("/projects/demo", "review-slot", true); - expect(result).toEqual({ - success: true, - deletedPath: "/home/user/src/demo/review-slot", - }); - } finally { - execSpy.mockRestore(); - readFileSpy.mockRestore(); - execBufferedSpy.mockRestore(); - } - }); -}); -describe("SSHRuntime.ensureReady repository checks", () => { - let execBufferedSpy: ReturnType> | null = - null; - let runtime: SSHRuntime; - - beforeEach(() => { - const config = { host: "example.com", srcBaseDir: "/home/user/src" }; - runtime = new SSHRuntime(config, createSSHTransport(config, false), { - projectPath: "/project", - workspaceName: "ws", - }); - }); - - afterEach(() => { - execBufferedSpy?.mockRestore(); - execBufferedSpy = null; - }); - - it("accepts worktrees where .git is a file", async () => { - execBufferedSpy = spyOn(runtimeHelpers, "execBuffered") - .mockResolvedValueOnce({ stdout: "", stderr: "", exitCode: 0, duration: 0 }) - .mockResolvedValueOnce({ stdout: ".git", stderr: "", exitCode: 0, duration: 0 }) - .mockResolvedValueOnce({ stdout: "true\n", stderr: "", exitCode: 0, duration: 0 }); - - const result = await runtime.ensureReady(); - - expect(execBufferedSpy).toHaveBeenCalledTimes(3); - const firstCommand = execBufferedSpy?.mock.calls[0]?.[1]; - expect(firstCommand).toContain("test -d"); - expect(firstCommand).toContain("test -f"); - const thirdCommand = execBufferedSpy?.mock.calls[2]?.[1]; - expect(thirdCommand).toContain("rev-parse --is-inside-work-tree"); - expect(result).toEqual({ ready: true }); - }); - - it("returns runtime_not_ready when git reports the workspace is not inside a work tree", async () => { - execBufferedSpy = spyOn(runtimeHelpers, "execBuffered") - .mockResolvedValueOnce({ stdout: "", stderr: "", exitCode: 0, duration: 0 }) - .mockResolvedValueOnce({ stdout: ".git", stderr: "", exitCode: 0, duration: 0 }) - .mockResolvedValueOnce({ stdout: "false\n", stderr: "", exitCode: 0, duration: 0 }); - - const result = await runtime.ensureReady(); - - expect(result.ready).toBe(false); - if (!result.ready) { - expect(result.errorType).toBe("runtime_not_ready"); - } - }); - - it("returns runtime_not_ready when the repo is missing", async () => { - execBufferedSpy = spyOn(runtimeHelpers, "execBuffered").mockResolvedValue({ - stdout: "", - stderr: "", - exitCode: 1, - duration: 0, - }); - - const result = await runtime.ensureReady(); - - expect(result.ready).toBe(false); - if (!result.ready) { - expect(result.errorType).toBe("runtime_not_ready"); - } - }); - - it("returns runtime_start_failed when git is unavailable", async () => { - execBufferedSpy = spyOn(runtimeHelpers, "execBuffered") - .mockResolvedValueOnce({ stdout: "", stderr: "", exitCode: 0, duration: 0 }) - .mockResolvedValueOnce({ - stdout: "", - stderr: "command not found", - exitCode: 127, - duration: 0, - }); - - const result = await runtime.ensureReady(); - - expect(result.ready).toBe(false); - if (!result.ready) { - expect(result.errorType).toBe("runtime_start_failed"); - } - }); -}); - -describe("SSHRuntime.resolvePath", () => { - let runtime: SSHRuntime; - let transport: ReturnType; - let acquireConnectionSpy: ReturnType> | null = - null; - let execBufferedSpy: ReturnType> | null = - null; - - beforeEach(() => { - const config = { host: "example.com", srcBaseDir: "/home/user/src" }; - transport = createSSHTransport(config, false); - runtime = new SSHRuntime(config, transport, { - projectPath: "/project", - workspaceName: "ws", - }); - }); - - afterEach(() => { - acquireConnectionSpy?.mockRestore(); - acquireConnectionSpy = null; - execBufferedSpy?.mockRestore(); - execBufferedSpy = null; - }); - - it("passes a 10s timeout and max wait to preflight acquireConnection", async () => { - acquireConnectionSpy = spyOn(transport, "acquireConnection").mockResolvedValue(undefined); - execBufferedSpy = spyOn(runtimeHelpers, "execBuffered").mockResolvedValue({ - stdout: "/home/user/foo\n", - stderr: "", - exitCode: 0, - duration: 0, - }); - - expect(await runtime.resolvePath("~/foo")).toBe("/home/user/foo"); - expect(acquireConnectionSpy).toHaveBeenCalledWith({ - timeoutMs: 10_000, - maxWaitMs: 10_000, - }); - }); -}); -describe("computeBaseRepoPath", () => { - it("computes the correct bare repo path", () => { - // computeBaseRepoPath uses getProjectName (basename) to compute: - // //.mux-base.git - const result = computeBaseRepoPath("~/mux", "/Users/me/code/my-project"); - expect(result).toBe("~/mux/my-project/.mux-base.git"); - }); - - it("handles absolute srcBaseDir", () => { - const result = computeBaseRepoPath("/home/user/src", "/code/repo"); - expect(result).toBe("/home/user/src/repo/.mux-base.git"); - }); -}); diff --git a/src/node/runtime/SSHRuntime.ts b/src/node/runtime/SSHRuntime.ts index 76e23d96b1..6849f2da13 100644 --- a/src/node/runtime/SSHRuntime.ts +++ b/src/node/runtime/SSHRuntime.ts @@ -16,6 +16,7 @@ */ import { spawn, type ChildProcess } from "child_process"; +import * as crypto from "crypto"; import * as path from "path"; import type { EnsureReadyOptions, @@ -35,7 +36,7 @@ import { log } from "@/node/services/log"; import { runInitHookOnRuntime, runWorkspaceInitHook } from "./initHook"; import { expandTildeForSSH as expandHookPath } from "./tildeExpansion"; import { expandTildeForSSH, cdCommandForSSH } from "./tildeExpansion"; -import { getProjectName, execBuffered } from "@/node/utils/runtime/helpers"; +import { execBuffered } from "@/node/utils/runtime/helpers"; import { getErrorMessage } from "@/common/utils/errors"; import { type SSHRuntimeConfig } from "./sshConnectionPool"; import { getOriginUrlForBundle } from "./gitBundleSync"; @@ -43,11 +44,13 @@ import { gitNoHooksPrefix } from "@/node/utils/gitNoHooksEnv"; import { execFileAsync } from "@/node/utils/disposableExec"; import { syncRuntimeGitSubmodules } from "./submoduleSync"; import type { PtyHandle, PtySessionParams, SSHTransport } from "./transports"; +import { + buildRemoteProjectLayout, + getRemoteWorkspacePath, + type RemoteProjectLayout, +} from "./remoteProjectLayout"; import { streamToString, shescape } from "./streamUtils"; -/** Name of the shared bare repo directory under each project on the remote. */ -const BASE_REPO_DIR = ".mux-base.git"; - /** Staging namespace for bundle-imported branch refs. Branches land here instead * of refs/heads/* so they don't collide with branches checked out in worktrees. */ const BUNDLE_REF_PREFIX = "refs/mux-bundle/"; @@ -55,6 +58,59 @@ const BUNDLE_REF_PREFIX = "refs/mux-bundle/"; /** Small backoff for concurrent writers healing the same shared base repo config. */ const BASE_REPO_CONFIG_LOCK_RETRY_DELAYS_MS = [50, 100, 200]; +const sharedProjectSyncTails = new Map>(); + +async function enqueueProjectSync( + projectKey: string, + abortSignal: AbortSignal | undefined, + fn: () => Promise +): Promise { + const previous = sharedProjectSyncTails.get(projectKey) ?? Promise.resolve(); + let releaseCurrent: (() => void) | undefined; + const current = new Promise((resolve) => { + releaseCurrent = resolve; + }); + const tail = previous.then( + () => current, + () => current + ); + sharedProjectSyncTails.set(projectKey, tail); + void tail.finally(() => { + if (sharedProjectSyncTails.get(projectKey) === tail) { + sharedProjectSyncTails.delete(projectKey); + } + }); + + let onAbort: (() => void) | undefined; + const waitForPrevious = previous.catch(() => undefined); + const waitForTurn = abortSignal + ? Promise.race([ + waitForPrevious, + new Promise((_, reject) => { + onAbort = () => reject(new Error("Operation aborted")); + if (abortSignal.aborted) { + onAbort(); + return; + } + abortSignal.addEventListener("abort", onAbort, { once: true }); + }), + ]) + : waitForPrevious; + + try { + await waitForTurn; + if (abortSignal?.aborted) { + throw new Error("Operation aborted"); + } + await fn(); + } finally { + if (onAbort) { + abortSignal?.removeEventListener("abort", onAbort); + } + releaseCurrent?.(); + } +} + function isGitConfigLockConflict(message: string): boolean { return /could not lock config file/i.test(message); } @@ -139,29 +195,18 @@ async function waitForProcessExit(proc: ChildProcess): Promise { proc.on("error", (err) => reject(err)); }); } -/** Truncate SSH stderr for error logging (keep first line, max 200 chars) */ -function truncateSSHError(stderr: string): string { - const trimmed = stderr.trim(); - if (!trimmed) return "exit code 255"; - // Take first line only (SSH errors are usually single-line) - const firstLine = trimmed.split("\n")[0]; - if (firstLine.length <= 200) return firstLine; - return firstLine.slice(0, 197) + "..."; -} - // Re-export SSHRuntimeConfig from connection pool (defined there to avoid circular deps) export type { SSHRuntimeConfig } from "./sshConnectionPool"; /** * Compute the path to the shared bare base repo for a project on the remote. - * Convention: //.mux-base.git + * Convention: //.mux-base.git * * Exported for unit testing; runtime code should use the private * `SSHRuntime.getBaseRepoPath()` method instead. */ export function computeBaseRepoPath(srcBaseDir: string, projectPath: string): string { - const projectName = getProjectName(projectPath); - return path.posix.join(srcBaseDir, projectName, BASE_REPO_DIR); + return buildRemoteProjectLayout(srcBaseDir, projectPath).baseRepoPath; } /** @@ -175,6 +220,7 @@ export class SSHRuntime extends RemoteRuntime { private readonly transport: SSHTransport; private readonly ensureReadyProjectPath?: string; private readonly ensureReadyWorkspaceName?: string; + private readonly currentWorkspacePath?: string; /** Cached resolved bgOutputDir (tilde expanded to absolute path) */ private resolvedBgOutputDir: string | null = null; @@ -184,6 +230,7 @@ export class SSHRuntime extends RemoteRuntime { options?: { projectPath?: string; workspaceName?: string; + workspacePath?: string; } ) { super(); @@ -193,6 +240,7 @@ export class SSHRuntime extends RemoteRuntime { this.transport = transport; this.ensureReadyProjectPath = options?.projectPath; this.ensureReadyWorkspaceName = options?.workspaceName; + this.currentWorkspacePath = options?.workspacePath; } /** @@ -235,6 +283,50 @@ export class SSHRuntime extends RemoteRuntime { return this.config; } + private getProjectLayout(projectPath: string): RemoteProjectLayout { + return buildRemoteProjectLayout(this.config.srcBaseDir, projectPath); + } + + private getProjectSyncKey(projectId: string): string { + return [ + this.config.host, + this.config.port?.toString() ?? "22", + this.config.identityFile ?? "default", + this.config.srcBaseDir, + projectId, + ].join(":"); + } + + private async computeSnapshotDigest(projectPath: string): Promise { + const refsOutput = await new Promise((resolve, reject) => { + const proc = spawn("git", ["-C", projectPath, "show-ref", "--heads", "--tags"], { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + proc.stdout?.on("data", (chunk: Buffer) => stdoutChunks.push(Buffer.from(chunk))); + proc.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(Buffer.from(chunk))); + proc.once("close", (code) => { + if (code === 0) { + resolve(Buffer.concat(stdoutChunks).toString()); + return; + } + const stderrText = Buffer.concat(stderrChunks).toString().trim(); + reject( + new Error( + stderrText.length > 0 + ? stderrText + : `git show-ref failed with code ${code ?? "unknown"}` + ) + ); + }); + proc.once("error", reject); + }); + + return crypto.createHash("sha256").update(refsOutput).digest("hex"); + } + // ===== RemoteRuntime abstract method implementations ===== protected readonly commandPrefix: string = "SSH"; @@ -251,27 +343,15 @@ export class SSHRuntime extends RemoteRuntime { return cdCommandForSSH(cwd); } - /** - * Handle exit codes for SSH connection pool health tracking. - */ - protected override onExitCode(exitCode: number, _options: ExecOptions, stderr: string): void { - // Connection-level failures should inform transport backoff. The meaning of - // specific exit codes (like 255) is transport-dependent. - if (this.transport.isConnectionFailure(exitCode, stderr)) { - this.transport.reportFailure(truncateSSHError(stderr)); - } else { - this.transport.markHealthy(); - } - } - protected async spawnRemoteProcess( fullCommand: string, - options: ExecOptions + options: ExecOptions & { deadlineMs?: number } ): Promise { return this.transport.spawnRemoteProcess(fullCommand, { forcePTY: options.forcePTY, timeout: options.timeout, abortSignal: options.abortSignal, + deadlineMs: options.deadlineMs, }); } @@ -340,8 +420,15 @@ export class SSHRuntime extends RemoteRuntime { } getWorkspacePath(projectPath: string, workspaceName: string): string { - const projectName = getProjectName(projectPath); - return path.posix.join(this.config.srcBaseDir, projectName, workspaceName); + if ( + this.currentWorkspacePath && + this.ensureReadyProjectPath === projectPath && + this.ensureReadyWorkspaceName === workspaceName + ) { + return this.currentWorkspacePath; + } + + return getRemoteWorkspacePath(this.getProjectLayout(projectPath), workspaceName); } /** @@ -349,7 +436,7 @@ export class SSHRuntime extends RemoteRuntime { * All worktree-based workspaces share this object store. */ private getBaseRepoPath(projectPath: string): string { - return computeBaseRepoPath(this.config.srcBaseDir, projectPath); + return this.getProjectLayout(projectPath).baseRepoPath; } /** @@ -362,7 +449,8 @@ export class SSHRuntime extends RemoteRuntime { initLogger: InitLogger, abortSignal?: AbortSignal ): Promise { - const baseRepoPath = this.getBaseRepoPath(projectPath); + const layout = this.getProjectLayout(projectPath); + const baseRepoPath = layout.baseRepoPath; const baseRepoPathArg = expandTildeForSSH(baseRepoPath); const check = await execBuffered(this, `test -d ${baseRepoPathArg}`, { @@ -466,6 +554,35 @@ export class SSHRuntime extends RemoteRuntime { return false; } + private async resolveWorktreeBaseRepoPath( + projectPath: string, + workspacePath: string, + abortSignal?: AbortSignal + ): Promise { + const fallbackBaseRepoPath = this.getBaseRepoPath(projectPath); + + try { + const result = await execBuffered( + this, + `git -C ${this.quoteForRemote(workspacePath)} rev-parse --path-format=absolute --git-common-dir`, + { + cwd: "/tmp", + timeout: 10, + abortSignal, + } + ); + const resolvedBaseRepoPath = result.stdout.trim(); + if (result.exitCode === 0 && resolvedBaseRepoPath.length > 0) { + return resolvedBaseRepoPath; + } + } catch { + // Fall back to the canonical hashed layout when the existing workspace cannot report its + // common git dir (for example, if the directory is already partially missing/corrupted). + } + + return fallbackBaseRepoPath; + } + /** * Detect whether a remote workspace is a git worktree (`.git` is a file) * vs a legacy full clone (`.git` is a directory). @@ -505,115 +622,6 @@ export class SSHRuntime extends RemoteRuntime { } } - private async persistWorkspaceBranchMapping( - projectPath: string, - workspaceName: string, - branchName: string - ): Promise { - const branchMap = await this.readWorkspaceBranchMap(projectPath); - branchMap[workspaceName] = branchName; - await this.writeWorkspaceBranchMap(projectPath, branchMap); - } - - private async updateWorkspaceBranchMapping( - projectPath: string, - oldWorkspaceName: string, - newWorkspaceName: string - ): Promise { - const branchMap = await this.readWorkspaceBranchMap(projectPath); - const branchName = branchMap[oldWorkspaceName]?.trim() || oldWorkspaceName; - delete branchMap[oldWorkspaceName]; - branchMap[newWorkspaceName] = branchName; - await this.writeWorkspaceBranchMap(projectPath, branchMap); - } - - private async getPersistedWorkspaceBranchName( - projectPath: string, - workspaceName: string - ): Promise { - const branchName = (await this.readWorkspaceBranchMap(projectPath))[workspaceName]?.trim(); - return branchName || null; - } - - private async deletePersistedWorkspaceBranchMapping( - projectPath: string, - workspaceName: string - ): Promise { - try { - const branchMap = await this.readWorkspaceBranchMap(projectPath); - if (!(workspaceName in branchMap)) { - return; - } - delete branchMap[workspaceName]; - await this.writeWorkspaceBranchMap(projectPath, branchMap); - } catch { - // Best-effort cleanup after delete; future creates overwrite any stale entry. - } - } - - private async readWorkspaceBranchMap(projectPath: string): Promise> { - try { - const contents = await streamToString( - this.readFile(this.getWorkspaceBranchMapPath(projectPath)) - ); - const parsed: unknown = JSON.parse(contents); - if (typeof parsed !== "object" || parsed === null) { - return {}; - } - return Object.fromEntries( - Object.entries(parsed).filter(([workspaceName, branchName]) => { - return ( - workspaceName.trim().length > 0 && - typeof branchName === "string" && - branchName.trim().length > 0 - ); - }) - ); - } catch { - return {}; - } - } - - private async writeWorkspaceBranchMap( - projectPath: string, - branchMap: Record - ): Promise { - const branchMapPath = this.getWorkspaceBranchMapPath(projectPath); - if (Object.keys(branchMap).length === 0) { - await execBuffered(this, `rm -f ${this.quoteForRemote(branchMapPath)}`, { - cwd: "/tmp", - timeout: 10, - }).catch(() => undefined); - return; - } - - const parentDir = path.posix.dirname(branchMapPath); - const mkdirResult = await execBuffered(this, `mkdir -p ${this.quoteForRemote(parentDir)}`, { - cwd: "/tmp", - timeout: 10, - }); - if (mkdirResult.exitCode !== 0) { - throw new Error( - `Failed to prepare remote workspace branch map: ${mkdirResult.stderr || mkdirResult.stdout}` - ); - } - - const writer = this.writeFile(branchMapPath).getWriter(); - try { - await writer.write(new TextEncoder().encode(`${JSON.stringify(branchMap, null, 2)}\n`)); - } finally { - await writer.close(); - } - } - - private getWorkspaceBranchMapPath(projectPath: string): string { - return path.posix.join( - this.config.srcBaseDir, - getProjectName(projectPath), - ".mux-workspace-branches.json" - ); - } - /** * Resolve the bundle staging ref for the trunk branch. * Returns refs/mux-bundle/ if it exists, otherwise falls back @@ -651,27 +659,9 @@ export class SSHRuntime extends RemoteRuntime { return null; } - private async resolveLocalRefOid(projectPath: string, ref: string): Promise { - try { - using proc = execFileAsync("git", ["-C", projectPath, "rev-parse", "--verify", ref]); - const { stdout } = await proc.result; - const oid = stdout.trim(); - return oid.length > 0 ? oid : null; - } catch { - return null; - } - } - private async resolveLocalSyncRefManifest(projectPath: string): Promise { try { - using proc = execFileAsync("git", [ - "-C", - projectPath, - "for-each-ref", - "--format=%(refname) %(objectname)", - "refs/heads", - "refs/tags", - ]); + using proc = execFileAsync("git", ["-C", projectPath, "show-ref", "--heads", "--tags"]); const { stdout } = await proc.result; return stdout .split("\n") @@ -690,7 +680,7 @@ export class SSHRuntime extends RemoteRuntime { ): Promise { const result = await execBuffered( this, - `git -C ${baseRepoPathArg} for-each-ref --format='%(refname) %(objectname)' ${BUNDLE_REF_PREFIX} refs/tags`, + `git -C ${baseRepoPathArg} for-each-ref --format='%(objectname) %(refname)' ${BUNDLE_REF_PREFIX} refs/tags`, { cwd: "/tmp", timeout: 20, abortSignal } ); if (result.exitCode !== 0) { @@ -701,70 +691,23 @@ export class SSHRuntime extends RemoteRuntime { .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0) - .map((line) => - line.startsWith(BUNDLE_REF_PREFIX) ? line.replace(BUNDLE_REF_PREFIX, "refs/heads/") : line - ) + .map((line) => { + const separator = line.indexOf(" "); + if (separator === -1) { + return line; + } + + const oid = line.slice(0, separator); + const refName = line.slice(separator + 1); + const normalizedRefName = refName.startsWith(BUNDLE_REF_PREFIX) + ? refName.replace(BUNDLE_REF_PREFIX, "refs/heads/") + : refName; + return `${oid} ${normalizedRefName}`; + }) .sort() .join("\n"); } - private async shouldReuseCurrentBundleTrunk( - projectPath: string, - trunkBranch: string, - initLogger: InitLogger, - abortSignal?: AbortSignal - ): Promise { - const localTrunkOid = await this.resolveLocalRefOid(projectPath, trunkBranch); - if (localTrunkOid == null) { - return false; - } - - try { - const remoteTrunkRef = `${BUNDLE_REF_PREFIX}${trunkBranch}`; - const baseRepoPathArg = expandTildeForSSH(this.getBaseRepoPath(projectPath)); - const remoteTrunkResult = await execBuffered( - this, - `git -C ${baseRepoPathArg} rev-parse --verify ${shescape.quote(remoteTrunkRef)}`, - { cwd: "/tmp", timeout: 10, abortSignal } - ); - if (remoteTrunkResult.exitCode !== 0) { - return false; - } - - const remoteTrunkOid = remoteTrunkResult.stdout.trim(); - if (remoteTrunkOid.length === 0 || remoteTrunkOid !== localTrunkOid) { - return false; - } - - const localRefManifest = await this.resolveLocalSyncRefManifest(projectPath); - if (localRefManifest == null) { - return false; - } - - const remoteRefManifest = await this.resolveRemoteSyncRefManifest( - baseRepoPathArg, - abortSignal - ); - if (remoteRefManifest == null || remoteRefManifest !== localRefManifest) { - return false; - } - } catch (error) { - log.debug("Failed to probe shared bundle trunk; falling back to sync", { - projectPath, - trunkBranch, - error: getErrorMessage(error), - }); - return false; - } - - // Re-uploading the full bundle adds startup latency even when the shared base already - // has the exact trunk snapshot this workspace will branch from. - initLogger.logStep( - `Remote bundle already matches ${trunkBranch} (${localTrunkOid.slice(0, 12)}); skipping sync` - ); - return true; - } - private async refreshBaseRepoOrigin( projectPath: string, baseRepoPathArg: string, @@ -981,12 +924,10 @@ export class SSHRuntime extends RemoteRuntime { */ private async transferBundleToRemote( projectPath: string, + remoteBundlePath: string, initLogger: InitLogger, abortSignal?: AbortSignal ): Promise { - const timestamp = Date.now(); - const remoteBundlePath = `~/.mux-bundle-${timestamp}.bundle`; - await this.transport.acquireConnection({ abortSignal, onWait: (waitMs) => logSSHBackoffWait(initLogger, waitMs), @@ -1080,49 +1021,126 @@ export class SSHRuntime extends RemoteRuntime { initLogger: InitLogger, abortSignal?: AbortSignal ): Promise { - const baseRepoPathArg = await this.ensureBaseRepo(projectPath, initLogger, abortSignal); + if (abortSignal?.aborted) { + throw new Error("Operation aborted"); + } - const remoteBundlePath = await this.transferBundleToRemote( - projectPath, - initLogger, - abortSignal - ); - const remoteBundlePathArg = this.quoteForRemote(remoteBundlePath); + const layout = this.getProjectLayout(projectPath); + const currentSnapshotPath = layout.currentSnapshotPath; + const projectKey = this.getProjectSyncKey(layout.projectId); - try { - // Import branches and tags from the bundle into the shared bare repo. - // Branches land in refs/mux-bundle/* (staging namespace) instead of - // refs/heads/* to avoid colliding with branches checked out in existing - // worktrees — git refuses to update any ref checked out in a worktree. - // Tags go directly to refs/tags/* (they're never checked out). - initLogger.logStep("Importing bundle into shared base repository..."); - const fetchResult = await execBuffered( + await enqueueProjectSync(projectKey, abortSignal, async () => { + if (abortSignal?.aborted) { + throw new Error("Operation aborted"); + } + + const snapshotDigest = await this.computeSnapshotDigest(projectPath); + const baseRepoPathArg = await this.ensureBaseRepo(projectPath, initLogger, abortSignal); + + const snapshotStatusCheck = await execBuffered( + this, + [ + 'current_snapshot=""', + `if test -f ${this.quoteForRemote(currentSnapshotPath)}; then`, + ` current_snapshot=$(tr -d '\\n' < ${this.quoteForRemote(currentSnapshotPath)})`, + "fi", + `if test "$current_snapshot" = ${shescape.quote(snapshotDigest)}; then`, + ` bundle_ref=$(git -C ${baseRepoPathArg} for-each-ref --count=1 --format='%(refname)' ${shescape.quote(BUNDLE_REF_PREFIX)})`, + ' if test -n "$bundle_ref"; then', + " echo reusable", + " else", + " echo stale-current", + " fi", + "else", + " echo missing", + "fi", + ].join("\n"), + { cwd: "/tmp", timeout: 10, abortSignal } + ); + const snapshotStatus = snapshotStatusCheck.stdout.trim(); + if (snapshotStatus === "reusable") { + const localRefManifest = await this.resolveLocalSyncRefManifest(projectPath); + const remoteRefManifest = + localRefManifest == null + ? null + : await this.resolveRemoteSyncRefManifest(baseRepoPathArg, abortSignal); + if (localRefManifest != null && remoteRefManifest === localRefManifest) { + await this.refreshBaseRepoOrigin(projectPath, baseRepoPathArg, initLogger, abortSignal); + initLogger.logStep("Reusing existing remote project snapshot"); + return; + } + initLogger.logStep( + "Remote snapshot marker drifted from imported refs; reimporting bundle..." + ); + } + if (snapshotStatus === "stale-current") { + initLogger.logStep( + "Remote snapshot marker found without matching imported refs; reimporting bundle..." + ); + } + + // Snapshot markers stay deterministic, but the uploaded bundle itself must use + // a per-attempt temp path so concurrent Mux processes do not stream into the same file. + const remoteBundlePath = path.posix.join( + "~/.mux-bundles", + layout.projectId, + `${snapshotDigest}.${crypto.randomUUID()}.bundle` + ); + const remoteBundlePathArg = this.quoteForRemote(remoteBundlePath); + const remoteBundleParentDir = path.posix.dirname(remoteBundlePath); + const prepareRemoteDirs = await execBuffered( this, - `git -C ${baseRepoPathArg} fetch ${remoteBundlePathArg} '+refs/heads/*:${BUNDLE_REF_PREFIX}*' '+refs/tags/*:refs/tags/*'`, - { cwd: "/tmp", timeout: 300, abortSignal } + `mkdir -p ${this.quoteForRemote(remoteBundleParentDir)} ${this.quoteForRemote(path.posix.dirname(currentSnapshotPath))}`, + { cwd: "/tmp", timeout: 10, abortSignal } ); - if (fetchResult.exitCode !== 0) { + if (prepareRemoteDirs.exitCode !== 0) { throw new Error( - `Failed to import bundle into base repo: ${fetchResult.stderr || fetchResult.stdout}` + `Failed to prepare remote snapshot directories: ${prepareRemoteDirs.stderr || prepareRemoteDirs.stdout}` ); } - // Keep the bare base repo's origin aligned with the local project so later - // fetchOriginTrunk() calls base new worktrees on the intended remote. - await this.refreshBaseRepoOrigin(projectPath, baseRepoPathArg, initLogger, abortSignal); + await this.transferBundleToRemote(projectPath, remoteBundlePath, initLogger, abortSignal); - initLogger.logStep("Repository synced to base successfully"); - } finally { - // Best-effort cleanup of the remote bundle file. try { - await execBuffered(this, `rm -f ${remoteBundlePathArg}`, { - cwd: "/tmp", - timeout: 10, - }); - } catch { - // Ignore cleanup errors. + // Import branches and tags from the bundle into the shared bare repo. + // Branches land in refs/mux-bundle/* (staging namespace) instead of + // refs/heads/* to avoid colliding with branches checked out in existing + // worktrees — git refuses to update any ref checked out in a worktree. + // Tags go directly to refs/tags/* (they're never checked out). + initLogger.logStep("Importing bundle into shared base repository..."); + const fetchResult = await execBuffered( + this, + `git -C ${baseRepoPathArg} fetch --prune --prune-tags ${remoteBundlePathArg} '+refs/heads/*:${BUNDLE_REF_PREFIX}*' '+refs/tags/*:refs/tags/*'`, + { cwd: "/tmp", timeout: 300, abortSignal } + ); + if (fetchResult.exitCode !== 0) { + throw new Error( + `Failed to import bundle into base repo: ${fetchResult.stderr || fetchResult.stdout}` + ); + } + + await this.refreshBaseRepoOrigin(projectPath, baseRepoPathArg, initLogger, abortSignal); + + const currentSnapshotWriter = this.writeFile(currentSnapshotPath).getWriter(); + try { + await currentSnapshotWriter.write(new TextEncoder().encode(`${snapshotDigest}\n`)); + } finally { + await currentSnapshotWriter.close(); + } + + initLogger.logStep("Repository synced to base successfully"); + } finally { + // Best-effort cleanup of the remote bundle file. + try { + await execBuffered(this, `rm -f ${remoteBundlePathArg}`, { + cwd: "/tmp", + timeout: 10, + }); + } catch { + // Ignore cleanup errors. + } } - } + }); } /** Get origin URL from local project for setting on the remote base repo. */ @@ -1136,8 +1154,9 @@ export class SSHRuntime extends RemoteRuntime { async createWorkspace(params: WorkspaceCreationParams): Promise { try { const { projectPath, directoryName, initLogger, abortSignal } = params; + const layout = this.getProjectLayout(projectPath); // Workspace directories follow the persisted workspace name; branch checkout happens later. - const workspacePath = this.getWorkspacePath(projectPath, directoryName); + const workspacePath = getRemoteWorkspacePath(layout, directoryName); // Prepare parent directory for git clone (fast - returns immediately) // Note: git clone will create the workspace directory itself during initWorkspace, @@ -1173,7 +1192,6 @@ export class SSHRuntime extends RemoteRuntime { }; } - await this.persistWorkspaceBranchMapping(projectPath, directoryName, params.branchName); initLogger.logStep("Remote workspace prepared"); return { @@ -1246,65 +1264,44 @@ export class SSHRuntime extends RemoteRuntime { needsWorktreeCheckout = true; } - let shouldSyncBaseRepo = needsWorktreeCheckout; - if (shouldSyncBaseRepo) { - shouldSyncBaseRepo = !(await this.shouldReuseCurrentBundleTrunk( - projectPath, - trunkBranch, - initLogger, - abortSignal - )); - } - if (needsWorktreeCheckout) { - if (shouldSyncBaseRepo) { - // SSH workspace initialization owns repo materialization: it syncs the project into - // the shared base repo, checks out the worktree, and then materializes submodules - // before repo-controlled init hooks run. - initLogger.logStep("Syncing project files to remote..."); - const maxSyncAttempts = 3; - for (let attempt = 1; attempt <= maxSyncAttempts; attempt++) { - try { - await this.syncProjectToRemote(projectPath, workspacePath, initLogger, abortSignal); - break; - } catch (error) { - const errorMsg = getErrorMessage(error); - const isRetryable = - errorMsg.includes("pack-objects died") || - errorMsg.includes("Connection reset") || - errorMsg.includes("Connection closed") || - errorMsg.includes("Broken pipe") || - errorMsg.includes("EPIPE"); - - if (!isRetryable || attempt === maxSyncAttempts) { - throw new Error(`Failed to sync project: ${errorMsg}`); - } - - log.info( - `Sync failed (attempt ${attempt}/${maxSyncAttempts}), will retry: ${errorMsg}` - ); - initLogger.logStep( - `Sync failed, retrying (attempt ${attempt + 1}/${maxSyncAttempts})...` - ); - await new Promise((r) => setTimeout(r, attempt * 1000)); + // SSH workspace initialization owns repo materialization: it syncs the project into + // the shared base repo, checks out the worktree, and then materializes submodules + // before repo-controlled init hooks run. + initLogger.logStep("Syncing project files to remote..."); + const maxSyncAttempts = 3; + for (let attempt = 1; attempt <= maxSyncAttempts; attempt++) { + try { + await this.syncProjectToRemote(projectPath, workspacePath, initLogger, abortSignal); + break; + } catch (error) { + const errorMsg = getErrorMessage(error); + const isRetryable = + errorMsg.includes("pack-objects died") || + errorMsg.includes("Connection reset") || + errorMsg.includes("Connection closed") || + errorMsg.includes("Broken pipe") || + errorMsg.includes("EPIPE"); + + if (!isRetryable || attempt === maxSyncAttempts) { + throw new Error(`Failed to sync project: ${errorMsg}`); } + + log.info(`Sync failed (attempt ${attempt}/${maxSyncAttempts}), will retry: ${errorMsg}`); + initLogger.logStep( + `Sync failed, retrying (attempt ${attempt + 1}/${maxSyncAttempts})...` + ); + await new Promise((r) => setTimeout(r, attempt * 1000)); } - initLogger.logStep("Files synced successfully"); } + initLogger.logStep("Files synced successfully"); - // Even when the shared base repo is already current, a brand-new workspace still needs - // git worktree add so the checkout exists before init hooks or submodule sync run. - // Re-enter ensureBaseRepo() here so older shared repos still get their local core.bare - // config normalized before we reuse them for a fresh worktree checkout. + // A brand-new workspace still needs git worktree add so the checkout exists before init hooks + // or submodule sync run. Re-enter ensureBaseRepo() here so older shared repos still get their + // local core.bare config normalized before we reuse them for a fresh worktree checkout. const baseRepoPath = this.getBaseRepoPath(projectPath); const baseRepoPathArg = await this.ensureBaseRepo(projectPath, initLogger, abortSignal); - if (!shouldSyncBaseRepo) { - // Skipping the bundle upload must still refresh origin in case the user repointed - // the local remote without changing trunk commits. - await this.refreshBaseRepoOrigin(projectPath, baseRepoPathArg, initLogger, abortSignal); - } - // Fetch latest from origin in the base repo (best-effort) so new branches // can start from the latest upstream state. const fetchedOrigin = await this.fetchOriginTrunk( @@ -1534,9 +1531,8 @@ export class SSHRuntime extends RemoteRuntime { if (abortSignal?.aborted) { return { success: false, error: "Rename operation aborted" }; } - // Compute workspace paths using canonical method const oldPath = this.getWorkspacePath(projectPath, oldName); - const newPath = this.getWorkspacePath(projectPath, newName); + const newPath = path.posix.join(path.posix.dirname(oldPath), newName); try { const expandedOldPath = expandTildeForSSH(oldPath); @@ -1547,8 +1543,11 @@ export class SSHRuntime extends RemoteRuntime { let moveCommand: string; if (isWorktree) { - // Worktree: use `git worktree move` to keep base repo metadata consistent. - const baseRepoPathArg = expandTildeForSSH(this.getBaseRepoPath(projectPath)); + // Worktree: use `git worktree move` to keep the workspace registered in whichever + // shared base repo originally created it, including upgraded legacy SSH layouts. + const baseRepoPathArg = expandTildeForSSH( + await this.resolveWorktreeBaseRepoPath(projectPath, oldPath, abortSignal) + ); moveCommand = `git -C ${baseRepoPathArg} worktree move ${expandedOldPath} ${expandedNewPath}`; } else { // Legacy full clone: plain mv. @@ -1584,7 +1583,6 @@ export class SSHRuntime extends RemoteRuntime { }; } - await this.updateWorkspaceBranchMapping(projectPath, oldName, newName); return { success: true, oldPath, newPath }; } catch (error) { return { @@ -1609,7 +1607,6 @@ export class SSHRuntime extends RemoteRuntime { // Disable git hooks for untrusted projects const nhp = gitNoHooksPrefix(trusted); - // Compute workspace path using canonical method const deletedPath = this.getWorkspacePath(projectPath, workspaceName); try { @@ -1710,7 +1707,6 @@ export class SSHRuntime extends RemoteRuntime { // Handle check results if (checkExitCode === 3) { // Directory doesn't exist - deletion is idempotent (success). - await this.deletePersistedWorkspaceBranchMapping(projectPath, workspaceName); return { success: true, deletedPath }; } @@ -1744,15 +1740,17 @@ export class SSHRuntime extends RemoteRuntime { }; } - const branchToDelete = - (await this.getPersistedWorkspaceBranchName(projectPath, workspaceName)) ?? workspaceName; + const branchToDelete = await this.resolveCheckedOutBranch(deletedPath, abortSignal, 10); // Detect if workspace is a worktree (.git is a file) vs a legacy full clone (.git is a directory). const isWorktree = await this.isWorktreeWorkspace(deletedPath, abortSignal); if (isWorktree) { - // Worktree: use `git worktree remove` to clean up the base repo's worktree metadata. - const baseRepoPathArg = expandTildeForSSH(this.getBaseRepoPath(projectPath)); + // Worktree: use `git worktree remove` against the actual common git dir for this + // workspace so upgraded legacy SSH worktrees keep their original base repo metadata. + const baseRepoPathArg = expandTildeForSSH( + await this.resolveWorktreeBaseRepoPath(projectPath, deletedPath, abortSignal) + ); const removeCmd = force ? `${nhp}git -C ${baseRepoPathArg} worktree remove --force ${this.quoteForRemote(deletedPath)}` : `${nhp}git -C ${baseRepoPathArg} worktree remove ${this.quoteForRemote(deletedPath)}`; @@ -1818,7 +1816,6 @@ export class SSHRuntime extends RemoteRuntime { } } - await this.deletePersistedWorkspaceBranchMapping(projectPath, workspaceName); return { success: true, deletedPath }; } catch (error) { return { success: false, error: `Failed to delete directory: ${getErrorMessage(error)}` }; @@ -1828,9 +1825,11 @@ export class SSHRuntime extends RemoteRuntime { async forkWorkspace(params: WorkspaceForkParams): Promise { const { projectPath, sourceWorkspaceName, newWorkspaceName, initLogger, abortSignal } = params; - // Compute workspace paths using canonical method const sourceWorkspacePath = this.getWorkspacePath(projectPath, sourceWorkspaceName); - const newWorkspacePath = this.getWorkspacePath(projectPath, newWorkspaceName); + const newWorkspacePath = path.posix.join( + path.posix.dirname(sourceWorkspacePath), + newWorkspaceName + ); // For SSH commands, tilde must be expanded using $HOME - plain quoting won't expand it. const sourceWorkspacePathArg = expandTildeForSSH(sourceWorkspacePath); diff --git a/src/node/runtime/remoteProjectLayout.ts b/src/node/runtime/remoteProjectLayout.ts new file mode 100644 index 0000000000..555072d75e --- /dev/null +++ b/src/node/runtime/remoteProjectLayout.ts @@ -0,0 +1,68 @@ +import * as crypto from "crypto"; +import * as path from "path"; +import { getProjectName } from "@/node/utils/runtime/helpers"; + +export const REMOTE_BASE_REPO_DIR = ".mux-base.git"; +const REMOTE_METADATA_DIR = ".mux-meta"; +const REMOTE_CURRENT_SNAPSHOT_FILE = "current-snapshot"; + +export interface RemoteProjectLayout { + projectId: string; + projectRoot: string; + baseRepoPath: string; + currentSnapshotPath: string; +} + +function sanitizeProjectSegment(segment: string): string { + const sanitized = segment + .trim() + .replace(/[^A-Za-z0-9._-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + return sanitized.length > 0 ? sanitized : "project"; +} + +function hashText(input: string): string { + return crypto.createHash("sha256").update(input).digest("hex").slice(0, 12); +} + +export function createRemoteProjectId(projectPath: string): string { + const normalizedPath = projectPath.replace(/\\/g, "/"); + const projectSlug = sanitizeProjectSegment(getProjectName(projectPath)); + return `${projectSlug}-${hashText(normalizedPath)}`; +} + +export function buildRemoteProjectLayout( + srcBaseDir: string, + projectPath: string, + projectRootOverride?: string +): RemoteProjectLayout { + const projectId = createRemoteProjectId(projectPath); + const projectRoot = projectRootOverride ?? path.posix.join(srcBaseDir, projectId); + + return { + projectId, + projectRoot, + baseRepoPath: path.posix.join(projectRoot, REMOTE_BASE_REPO_DIR), + currentSnapshotPath: path.posix.join( + projectRoot, + REMOTE_METADATA_DIR, + REMOTE_CURRENT_SNAPSHOT_FILE + ), + }; +} + +export function buildLegacyRemoteProjectLayout( + srcBaseDir: string, + projectPath: string +): RemoteProjectLayout { + return buildRemoteProjectLayout( + srcBaseDir, + projectPath, + path.posix.join(srcBaseDir, getProjectName(projectPath)) + ); +} + +export function getRemoteWorkspacePath(layout: RemoteProjectLayout, workspaceName: string): string { + return path.posix.join(layout.projectRoot, workspaceName); +} diff --git a/src/node/runtime/runtimeFactory.ts b/src/node/runtime/runtimeFactory.ts index 296a6d8ab0..c8dacfc70f 100644 --- a/src/node/runtime/runtimeFactory.ts +++ b/src/node/runtime/runtimeFactory.ts @@ -135,6 +135,7 @@ export function createRuntime(config: RuntimeConfig, options?: CreateRuntimeOpti const runtimeIdentity = { projectPath: options?.projectPath, workspaceName: options?.workspaceName, + workspacePath: options?.workspacePath, }; switch (config.type) { diff --git a/src/node/runtime/runtimeHelpers.test.ts b/src/node/runtime/runtimeHelpers.test.ts index 02fefdc9d7..1d4024b1b1 100644 --- a/src/node/runtime/runtimeHelpers.test.ts +++ b/src/node/runtime/runtimeHelpers.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "bun:test"; import type { RuntimeConfig } from "@/common/types/runtime"; import { DevcontainerRuntime } from "./DevcontainerRuntime"; -import { createRuntimeForWorkspace, resolveWorkspaceExecutionPath } from "./runtimeHelpers"; +import { + createRuntimeContextForWorkspace, + createRuntimeForWorkspace, + resolveWorkspaceExecutionPath, +} from "./runtimeHelpers"; describe("createRuntimeForWorkspace", () => { it("forwards the persisted workspace path to devcontainer runtimes", () => { @@ -19,6 +23,24 @@ describe("createRuntimeForWorkspace", () => { const internal = runtime as unknown as { currentWorkspacePath?: string }; expect(internal.currentWorkspacePath).toBe("/tmp/non-canonical/workspaces/review-1"); }); + + it("seeds ssh runtimes from the persisted workspace root", () => { + const metadata = { + runtimeConfig: { + type: "ssh", + host: "example.com", + srcBaseDir: "/remote/src", + } satisfies RuntimeConfig, + projectPath: "/projects/demo", + name: "review-1", + namedWorkspacePath: "/remote/src/demo/review-1", + }; + + const runtime = createRuntimeForWorkspace(metadata); + expect(runtime.getWorkspacePath(metadata.projectPath, "review-2")).toMatch( + /^\/remote\/src\/demo-[a-f0-9]{12}\/review-2$/ + ); + }); }); describe("resolveWorkspaceExecutionPath", () => { @@ -37,6 +59,37 @@ describe("resolveWorkspaceExecutionPath", () => { expect(resolveWorkspaceExecutionPath(metadata, runtime)).toBe("/persisted/review-1"); }); + it("requires the persisted path for SSH workspaces", () => { + const metadata = { + runtimeConfig: { + type: "ssh", + host: "example.com", + srcBaseDir: "/remote/src", + } satisfies RuntimeConfig, + projectPath: "/projects/demo", + name: "review-1", + }; + + const runtime = createRuntimeForWorkspace(metadata); + expect(() => resolveWorkspaceExecutionPath(metadata, runtime)).toThrow( + /missing a persisted workspace path/ + ); + }); + + it("falls back to the runtime path for non-SSH workspaces when persisted metadata is unavailable", () => { + const metadata = { + runtimeConfig: { + type: "worktree", + srcBaseDir: "/tmp/src", + } satisfies RuntimeConfig, + projectPath: "/projects/demo", + name: "review-1", + }; + + const runtime = createRuntimeForWorkspace(metadata); + const runtimeWorkspacePath = runtime.getWorkspacePath(metadata.projectPath, metadata.name); + expect(resolveWorkspaceExecutionPath(metadata, runtime)).toBe(runtimeWorkspacePath); + }); it("uses the runtime path for docker workspaces", () => { const metadata = { runtimeConfig: { @@ -51,4 +104,40 @@ describe("resolveWorkspaceExecutionPath", () => { const runtime = createRuntimeForWorkspace(metadata); expect(resolveWorkspaceExecutionPath(metadata, runtime)).toBe("/src"); }); + + it("returns the project root for in-place workspaces", () => { + const metadata = { + runtimeConfig: { + type: "worktree", + srcBaseDir: "/tmp/src", + } satisfies RuntimeConfig, + projectPath: "/projects/cli", + name: "/projects/cli", + }; + + const runtime = createRuntimeForWorkspace(metadata); + expect(resolveWorkspaceExecutionPath(metadata, runtime)).toBe("/projects/cli"); + }); +}); + +describe("createRuntimeContextForWorkspace", () => { + it("returns a runtime together with the resolved execution path", () => { + const metadata = { + runtimeConfig: { + type: "ssh", + host: "example.com", + srcBaseDir: "/remote/src", + } satisfies RuntimeConfig, + projectPath: "/projects/demo", + name: "review-1", + namedWorkspacePath: "/remote/src/demo/review-1", + }; + + const context = createRuntimeContextForWorkspace(metadata); + + expect(context.workspacePath).toBe("/remote/src/demo/review-1"); + expect(context.runtime.getWorkspacePath(metadata.projectPath, "review-2")).toMatch( + /^\/remote\/src\/demo-[a-f0-9]{12}\/review-2$/ + ); + }); }); diff --git a/src/node/runtime/runtimeHelpers.ts b/src/node/runtime/runtimeHelpers.ts index 737c688093..bd1cc54ea8 100644 --- a/src/node/runtime/runtimeHelpers.ts +++ b/src/node/runtime/runtimeHelpers.ts @@ -1,5 +1,10 @@ import assert from "@/common/utils/assert"; -import { isDockerRuntime, isLocalProjectRuntime, type RuntimeConfig } from "@/common/types/runtime"; +import { + isDockerRuntime, + isLocalProjectRuntime, + isSSHRuntime, + type RuntimeConfig, +} from "@/common/types/runtime"; import type { Runtime } from "./Runtime"; import { createRuntime } from "./runtimeFactory"; @@ -28,6 +33,12 @@ export function resolveWorkspaceExecutionPath( metadata: WorkspaceMetadataForRuntime, runtime: Runtime ): string { + if (metadata.projectPath === metadata.name) { + // In-place workspaces (CLI/benchmarks) execute directly in their project root instead of a + // named sibling checkout, so deriving a worktree path would be reconstructing the wrong shape. + return metadata.projectPath; + } + const runtimeWorkspacePath = runtime.getWorkspacePath(metadata.projectPath, metadata.name); assert(runtimeWorkspacePath, `Workspace ${metadata.name} resolved to an empty runtime path`); @@ -36,10 +47,18 @@ export function resolveWorkspaceExecutionPath( } const persistedWorkspacePath = metadata.namedWorkspacePath?.trim(); - assert( - persistedWorkspacePath, - `Workspace ${metadata.name} is missing its persisted workspace path for runtime ${metadata.runtimeConfig.type}` - ); + if (!persistedWorkspacePath) { + // SSH workspaces must keep using the persisted checkout root from config so upgraded legacy + // workspaces do not silently fall back to the reconstructed hashed path and miss their real cwd. + assert( + !isSSHRuntime(metadata.runtimeConfig), + `SSH workspace ${metadata.name} is missing a persisted workspace path` + ); + + // Other runtimes can still fall back to their canonical derived path when only identity metadata + // is available (for example in narrow unit tests). + return runtimeWorkspacePath; + } if (isLocalProjectRuntime(metadata.runtimeConfig)) { // Project-dir local runtimes always execute directly in the project root. @@ -52,6 +71,25 @@ export function resolveWorkspaceExecutionPath( return persistedWorkspacePath; } +export interface WorkspaceRuntimeContext { + runtime: Runtime; + workspacePath: string; +} + +/** + * Recreate an existing workspace runtime together with the execution path that should be used for + * terminals, tool calls, and agent discovery. + */ +export function createRuntimeContextForWorkspace( + metadata: WorkspaceMetadataForRuntime +): WorkspaceRuntimeContext { + const runtime = createRuntimeForWorkspace(metadata); + return { + runtime, + workspacePath: resolveWorkspaceExecutionPath(metadata, runtime), + }; +} + /** * Create a runtime from workspace metadata, ensuring workspace identity is always passed. * diff --git a/src/node/runtime/sshConnectionPool.test.ts b/src/node/runtime/sshConnectionPool.test.ts index 3ae59fdcbd..cb4ee5ebca 100644 --- a/src/node/runtime/sshConnectionPool.test.ts +++ b/src/node/runtime/sshConnectionPool.test.ts @@ -457,6 +457,139 @@ describe("SSHConnectionPool", () => { expect(pool.getConnectionHealth(config)?.consecutiveFailures).toBe(1); }); + test("re-checks requested ControlPath after waiting for another probe", async () => { + const pool = new SSHConnectionPool(); + const config: SSHRuntimeConfig = { + host: "test.example.com", + srcBaseDir: "/work", + }; + const privatePool = pool as unknown as { + probeConnection: ( + config: SSHRuntimeConfig, + timeoutMs: number, + key: string, + controlPath: string + ) => Promise; + markHealthyByKey: (key: string) => void; + markControlPathReady: (key: string, controlPath: string) => void; + }; + + const probeCalls: string[] = []; + let releaseFirstProbe!: () => void; + let signalFirstProbeStarted!: () => void; + const firstProbeStarted = new Promise((resolve) => { + signalFirstProbeStarted = resolve; + }); + + const probeSpy = spyOn(privatePool, "probeConnection").mockImplementation( + async (_config, _timeoutMs, key, controlPath) => { + probeCalls.push(controlPath); + if (probeCalls.length === 1) { + signalFirstProbeStarted(); + await new Promise((resolve) => { + releaseFirstProbe = () => { + privatePool.markHealthyByKey(key); + privatePool.markControlPathReady(key, controlPath); + resolve(); + }; + }); + return; + } + + privatePool.markHealthyByKey(key); + privatePool.markControlPathReady(key, controlPath); + } + ); + + const firstAcquire = pool.acquireConnection(config, { + controlPath: "/tmp/mux-control-a", + maxWaitMs: 0, + }); + await firstProbeStarted; + + const secondAcquire = pool.acquireConnection(config, { + controlPath: "/tmp/mux-control-b", + maxWaitMs: 0, + }); + releaseFirstProbe(); + + await Promise.all([firstAcquire, secondAcquire]); + + expect(probeCalls).toEqual(["/tmp/mux-control-a", "/tmp/mux-control-b"]); + probeSpy.mockRestore(); + }); + + test("caps the follow-up probe timeout to the remaining wait budget", async () => { + const pool = new SSHConnectionPool(); + const config: SSHRuntimeConfig = { + host: "test.example.com", + srcBaseDir: "/work", + }; + const privatePool = pool as unknown as { + probeConnection: ( + config: SSHRuntimeConfig, + timeoutMs: number, + key: string, + controlPath: string + ) => Promise; + markHealthyByKey: (key: string) => void; + markControlPathReady: (key: string, controlPath: string) => void; + }; + + const probeCalls: Array<{ controlPath: string; timeoutMs: number }> = []; + let releaseFirstProbe!: () => void; + let signalFirstProbeStarted!: () => void; + const firstProbeStarted = new Promise((resolve) => { + signalFirstProbeStarted = resolve; + }); + let now = 1_000; + const nowSpy = spyOn(Date, "now").mockImplementation(() => now); + + const probeSpy = spyOn(privatePool, "probeConnection").mockImplementation( + async (_config, timeoutMs, key, controlPath) => { + probeCalls.push({ controlPath, timeoutMs }); + if (probeCalls.length === 1) { + signalFirstProbeStarted(); + await new Promise((resolve) => { + releaseFirstProbe = () => { + now += 25; + privatePool.markHealthyByKey(key); + privatePool.markControlPathReady(key, controlPath); + resolve(); + }; + }); + return; + } + + privatePool.markHealthyByKey(key); + privatePool.markControlPathReady(key, controlPath); + } + ); + + const firstAcquire = pool.acquireConnection(config, { + controlPath: "/tmp/mux-control-a", + timeoutMs: 30, + maxWaitMs: 30, + }); + await firstProbeStarted; + + const secondAcquire = pool.acquireConnection(config, { + controlPath: "/tmp/mux-control-b", + timeoutMs: 30, + maxWaitMs: 30, + }); + releaseFirstProbe(); + + await Promise.all([firstAcquire, secondAcquire]); + + expect(probeCalls).toEqual([ + { controlPath: "/tmp/mux-control-a", timeoutMs: 30 }, + { controlPath: "/tmp/mux-control-b", timeoutMs: 5 }, + ]); + probeSpy.mockRestore(); + nowSpy.mockRestore(); + }); + test("callers waking from backoff share single probe (herd only released on success)", async () => { const pool = new SSHConnectionPool(); const config: SSHRuntimeConfig = { diff --git a/src/node/runtime/sshConnectionPool.ts b/src/node/runtime/sshConnectionPool.ts index cb36d59513..fb9157b1d1 100644 --- a/src/node/runtime/sshConnectionPool.ts +++ b/src/node/runtime/sshConnectionPool.ts @@ -33,6 +33,10 @@ export function setSshPromptService(svc: SshPromptService | undefined): void { sshPromptService = svc; } +export function getSshPromptService(): SshPromptService | undefined { + return sshPromptService; +} + export function setOpenSSHHostKeyPolicyMode(mode: OpenSSHHostKeyPolicyMode): void { hostKeyPolicyMode = mode; } @@ -134,6 +138,12 @@ export interface AcquireConnectionOptions { */ onWait?: (waitMs: number) => void; + /** + * Optional explicit ControlPath to probe/bootstrap before returning. When omitted, + * the default host-scoped ControlPath is used. + */ + controlPath?: string; + /** * Test seam. * @@ -168,6 +178,53 @@ async function sleepWithAbort(ms: number, abortSignal?: AbortSignal): Promise( + promise: Promise, + timeoutMs: number, + abortSignal?: AbortSignal, + timeoutError?: Error +): Promise { + if (abortSignal?.aborted) { + throw new Error("Operation aborted"); + } + + return await new Promise((resolve, reject) => { + let settled = false; + + const cleanup = () => { + clearTimeout(timer); + abortSignal?.removeEventListener("abort", onAbort); + }; + + const finish = (handler: () => void) => { + if (settled) { + return; + } + settled = true; + cleanup(); + handler(); + }; + + const onAbort = () => { + finish(() => reject(new Error("Operation aborted"))); + }; + + const timer = setTimeout(() => { + finish(() => reject(timeoutError ?? new Error("Operation timed out"))); + }, timeoutMs); + + abortSignal?.addEventListener("abort", onAbort); + promise.then( + (value) => { + finish(() => resolve(value)); + }, + (error) => { + finish(() => reject(error instanceof Error ? error : new Error(String(error)))); + } + ); + }); +} + /** * SSH Connection Pool * @@ -179,6 +236,7 @@ async function sleepWithAbort(ms: number, abortSignal?: AbortSignal): Promise(); + private readyControlPaths = new Map>(); private inflight = new Map>(); /** @@ -210,7 +268,15 @@ export class SSHConnectionPool { const shouldWait = maxWaitMs > 0; const key = makeConnectionKey(config); + const requestedControlPath = options.controlPath ?? getControlPath(config); const startTime = Date.now(); + const getRemainingWaitBudgetMs = (): number => + Math.max(0, maxWaitMs - (Date.now() - startTime)); + const createWaitBudgetExceededError = (lastError?: string): Error => + new Error( + `SSH connection to ${config.host} did not become healthy within ${maxWaitMs}ms. ` + + `Last error: ${lastError ?? "unknown"}` + ); while (true) { if (options.abortSignal?.aborted) { @@ -231,13 +297,9 @@ export class SSHConnectionPool { ); } - const elapsedMs = Date.now() - startTime; - const budgetMs = Math.max(0, maxWaitMs - elapsedMs); + const budgetMs = getRemainingWaitBudgetMs(); if (budgetMs <= 0) { - throw new Error( - `SSH connection to ${config.host} did not become healthy within ${maxWaitMs}ms. ` + - `Last error: ${health.lastError ?? "unknown"}` - ); + throw createWaitBudgetExceededError(health.lastError); } const waitMs = Math.min(remainingMs, budgetMs); @@ -249,13 +311,21 @@ export class SSHConnectionPool { // Return immediately if known healthy and not stale. if (health?.status === "healthy") { const age = Date.now() - (health.lastSuccess?.getTime() ?? 0); - if (age < HEALTHY_TTL_MS) { + const specificMasterReady = + options.controlPath == null ? true : this.isControlPathReady(key, requestedControlPath); + if (age < HEALTHY_TTL_MS && specificMasterReady) { log.debug(`SSH connection to ${config.host} is known healthy, skipping probe`); return; } - log.debug( - `SSH connection to ${config.host} health is stale (${Math.round(age / 1000)}s), re-probing` - ); + if (!specificMasterReady) { + log.debug( + `SSH connection to ${config.host} is healthy, but ControlPath ${requestedControlPath} is not ready; bootstrapping it now` + ); + } else { + log.debug( + `SSH connection to ${config.host} health is stale (${Math.round(age / 1000)}s), re-probing` + ); + } } // Check for inflight probe - singleflighting. @@ -263,11 +333,28 @@ export class SSHConnectionPool { if (existing) { log.debug(`SSH connection to ${config.host} has inflight probe, waiting...`); try { - await existing; - return; + if (shouldWait) { + const budgetMs = getRemainingWaitBudgetMs(); + if (budgetMs <= 0) { + throw createWaitBudgetExceededError(health?.lastError); + } + await waitForPromiseWithTimeout( + existing, + budgetMs, + options.abortSignal, + createWaitBudgetExceededError(health?.lastError) + ); + } else { + await existing; + } + continue; } catch (error) { // Probe failed; if we're in wait mode we'll loop and sleep through the backoff. - if (!shouldWait) { + if ( + !shouldWait || + (error instanceof Error && + error.message.includes(`did not become healthy within ${maxWaitMs}ms`)) + ) { throw error; } continue; @@ -275,8 +362,14 @@ export class SSHConnectionPool { } // Start new probe. + const probeTimeoutMs = shouldWait + ? Math.min(timeoutMs, getRemainingWaitBudgetMs()) + : timeoutMs; + if (probeTimeoutMs <= 0) { + throw createWaitBudgetExceededError(health?.lastError); + } log.debug(`SSH connection to ${config.host} needs probe, starting health check`); - const probe = this.probeConnection(config, timeoutMs, key); + const probe = this.probeConnection(config, probeTimeoutMs, key, requestedControlPath); this.inflight.set(key, probe); try { @@ -300,6 +393,20 @@ export class SSHConnectionPool { } } + private isControlPathReady(key: string, controlPath: string): boolean { + return this.readyControlPaths.get(key)?.has(controlPath) === true; + } + + private markControlPathReady(key: string, controlPath: string): void { + const readyPaths = this.readyControlPaths.get(key) ?? new Set(); + readyPaths.add(controlPath); + this.readyControlPaths.set(key, readyPaths); + } + + private clearReadyControlPaths(key: string): void { + this.readyControlPaths.delete(key); + } + /** * Get current health status for a connection */ @@ -325,6 +432,7 @@ export class SSHConnectionPool { health.backoffUntil = undefined; health.consecutiveFailures = 0; health.status = "unknown"; + this.clearReadyControlPaths(key); log.info(`Reset backoff for SSH connection to ${config.host}`); } } @@ -368,6 +476,7 @@ export class SSHConnectionPool { const backoffIndex = Math.min(failures - 1, BACKOFF_SCHEDULE.length - 1); const backoffSecs = withJitter(BACKOFF_SCHEDULE[backoffIndex]); + this.clearReadyControlPaths(key); this.health.set(key, { status: "unhealthy", lastFailure: new Date(), @@ -387,6 +496,7 @@ export class SSHConnectionPool { */ clearAllHealth(): void { this.health.clear(); + this.readyControlPaths.clear(); this.inflight.clear(); } @@ -396,9 +506,9 @@ export class SSHConnectionPool { private async probeConnection( config: SSHConnectionConfig, timeoutMs: number, - key: string + key: string, + controlPath = getControlPath(config) ): Promise { - const controlPath = getControlPath(config); const promptService = sshPromptService; const canPromptInteractively = isInteractiveHostKeyApprovalAvailable(); @@ -504,6 +614,7 @@ export class SSHConnectionPool { if (code === 0) { this.markHealthyByKey(key); + this.markControlPathReady(key, controlPath); log.debug(`SSH probe to ${config.host} succeeded`); resolve(); } else { diff --git a/src/node/runtime/transports/OpenSSHTransport.test.ts b/src/node/runtime/transports/OpenSSHTransport.test.ts deleted file mode 100644 index 8d92da011b..0000000000 --- a/src/node/runtime/transports/OpenSSHTransport.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; -import * as childProcess from "child_process"; - -import { SshPromptService } from "@/node/services/sshPromptService"; -import { - setSshPromptService, - setOpenSSHHostKeyPolicyMode, - sshConnectionPool, -} from "../sshConnectionPool"; -import { OpenSSHTransport } from "./OpenSSHTransport"; - -function createMockChildProcess(): ReturnType { - return { - on: mock(() => undefined), - pid: 12345, - } as unknown as ReturnType; -} - -describe("OpenSSHTransport.spawnRemoteProcess", () => { - let spawnSpy: ReturnType>; - let acquireConnectionSpy: ReturnType>; - let releaseInteractiveResponder: (() => void) | undefined; - - beforeEach(() => { - spawnSpy = spyOn(childProcess, "spawn").mockImplementation((() => - createMockChildProcess()) as unknown as typeof childProcess.spawn); - acquireConnectionSpy = spyOn(sshConnectionPool, "acquireConnection").mockResolvedValue( - undefined - ); - }); - - afterEach(() => { - releaseInteractiveResponder?.(); - releaseInteractiveResponder = undefined; - // Reset to a configured service without responders so state does not leak across tests. - setSshPromptService(new SshPromptService()); - setOpenSSHHostKeyPolicyMode("headless-fallback"); - - spawnSpy.mockRestore(); - acquireConnectionSpy.mockRestore(); - }); - - function setSshPromptCapability(configured: boolean): SshPromptService | undefined { - releaseInteractiveResponder?.(); - releaseInteractiveResponder = undefined; - - if (!configured) { - setSshPromptService(undefined); - return undefined; - } - - const service = new SshPromptService(); - setSshPromptService(service); - return service; - } - - async function runSpawnRemoteProcess(): Promise { - const transport = new OpenSSHTransport({ host: "remote.example.com" }); - await transport.spawnRemoteProcess("echo ok", {}); - - expect(spawnSpy).toHaveBeenCalledTimes(1); - const [command, args] = spawnSpy.mock.calls[0] as [string, string[], childProcess.SpawnOptions]; - expect(command).toBe("ssh"); - return args; - } - - test("explicit headless (no service) includes host-key fallback options and BatchMode=yes", async () => { - setSshPromptCapability(false); - setOpenSSHHostKeyPolicyMode("headless-fallback"); - - const args = await runSpawnRemoteProcess(); - - expect(args).toContain("BatchMode=yes"); - expect(args).toContain("StrictHostKeyChecking=no"); - expect(args).toContain("UserKnownHostsFile=/dev/null"); - expect(args.indexOf("StrictHostKeyChecking=no")).toBeGreaterThan(args.indexOf("BatchMode=yes")); - expect(args.indexOf("UserKnownHostsFile=/dev/null")).toBeGreaterThan( - args.indexOf("StrictHostKeyChecking=no") - ); - }); - - test("service configured keeps BatchMode=yes but excludes host-key fallback options", async () => { - const service = setSshPromptCapability(true); - releaseInteractiveResponder = service?.registerInteractiveResponder(); - setOpenSSHHostKeyPolicyMode("strict"); - - const args = await runSpawnRemoteProcess(); - - expect(args).toContain("BatchMode=yes"); - expect(args).not.toContain("StrictHostKeyChecking=no"); - expect(args).not.toContain("UserKnownHostsFile=/dev/null"); - }); - - test("service configured without active responder still excludes host-key fallback options", async () => { - setSshPromptCapability(true); - setOpenSSHHostKeyPolicyMode("strict"); - - const args = await runSpawnRemoteProcess(); - - expect(args).toContain("BatchMode=yes"); - expect(args).not.toContain("StrictHostKeyChecking=no"); - expect(args).not.toContain("UserKnownHostsFile=/dev/null"); - }); -}); diff --git a/src/node/runtime/transports/OpenSSHTransport.ts b/src/node/runtime/transports/OpenSSHTransport.ts index b20f7523a6..43990d52b6 100644 --- a/src/node/runtime/transports/OpenSSHTransport.ts +++ b/src/node/runtime/transports/OpenSSHTransport.ts @@ -1,11 +1,9 @@ import { spawn } from "child_process"; -import { log } from "@/node/services/log"; import { spawnPtyProcess } from "../ptySpawn"; import { expandTildeForSSH } from "../tildeExpansion"; import { appendOpenSSHHostKeyPolicyArgs, - getControlPath, sshConnectionPool, type SSHConnectionConfig, } from "../sshConnectionPool"; @@ -18,14 +16,30 @@ import type { PtySessionParams, } from "./SSHTransport"; -export class OpenSSHTransport implements SSHTransport { - private readonly config: SSHConnectionConfig; - private readonly controlPath: string; +const MAX_REPORTED_FAILURE_STDERR_CHARS = 1000; +const OPENSSH_EXEC_SHARD_COUNT = 4; +const nextShardByConnection = new Map(); - constructor(config: SSHConnectionConfig) { - this.config = config; - this.controlPath = getControlPath(config); +function summarizeFailureStderr(stderr: string, exitCode: number): string { + const trimmed = stderr.trim(); + if (trimmed.length === 0) { + return `SSH exited with code ${exitCode}`; + } + if (trimmed.length <= MAX_REPORTED_FAILURE_STDERR_CHARS) { + return trimmed; } + return `${trimmed.slice(0, MAX_REPORTED_FAILURE_STDERR_CHARS)}…`; +} + +function getShardedControlPath(config: SSHConnectionConfig): string { + const baseControlPath = sshConnectionPool.getControlPath(config); + const nextShard = nextShardByConnection.get(baseControlPath) ?? 0; + nextShardByConnection.set(baseControlPath, (nextShard + 1) % OPENSSH_EXEC_SHARD_COUNT); + return `${baseControlPath}-${nextShard}`; +} + +export class OpenSSHTransport implements SSHTransport { + constructor(private readonly config: SSHConnectionConfig) {} isConnectionFailure(exitCode: number, _stderr: string): boolean { return exitCode === 255; @@ -35,14 +49,6 @@ export class OpenSSHTransport implements SSHTransport { return this.config; } - markHealthy(): void { - sshConnectionPool.markHealthy(this.config); - } - - reportFailure(error: string): void { - sshConnectionPool.reportFailure(this.config, error); - } - async acquireConnection(options?: { abortSignal?: AbortSignal; timeoutMs?: number; @@ -58,39 +64,63 @@ export class OpenSSHTransport implements SSHTransport { } async spawnRemoteProcess(fullCommand: string, options: SpawnOptions): Promise { + const remainingWaitMs = + options.deadlineMs != null ? Math.max(0, options.deadlineMs - Date.now()) : undefined; + const controlPath = getShardedControlPath(this.config); await sshConnectionPool.acquireConnection(this.config, { abortSignal: options.abortSignal, + timeoutMs: remainingWaitMs, + maxWaitMs: remainingWaitMs, + controlPath, }); - // Note: use -tt (not -t) so PTY allocation works even when stdin is a pipe. - const sshArgs: string[] = [options.forcePTY ? "-tt" : "-T", ...this.buildSSHArgs()]; + // Shard short-lived SSH execs across a few deterministic ControlPaths so the host no longer + // funnels all multiplexed sessions through one implicit master socket. + const sshArgs: string[] = [ + options.forcePTY ? "-tt" : "-T", + ...this.buildBaseSSHArgs(), + "-o", + "ControlMaster=auto", + "-o", + `ControlPath=${controlPath}`, + "-o", + "ControlPersist=60", + ]; const connectTimeout = options.timeout !== undefined ? Math.min(Math.ceil(options.timeout), 15) : 15; sshArgs.push("-o", `ConnectTimeout=${connectTimeout}`); sshArgs.push("-o", "ServerAliveInterval=5"); sshArgs.push("-o", "ServerAliveCountMax=2"); - // Non-interactive execs must never hang on host-key or password prompts. - // Host-key trust policy is capability-scoped (verification service wired), - // while responder liveness only affects whether prompts can be shown. sshArgs.push("-o", "BatchMode=yes"); appendOpenSSHHostKeyPolicyArgs(sshArgs); - sshArgs.push(this.config.host, fullCommand); - log.debug(`SSH exec on ${this.config.host}`); const process = spawn("ssh", sshArgs, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true, }); - return { process }; + return { + process, + onExit: (exitCode, stderr) => { + if (this.isConnectionFailure(exitCode, stderr)) { + sshConnectionPool.reportFailure(this.config, summarizeFailureStderr(stderr, exitCode)); + return; + } + sshConnectionPool.markHealthy(this.config); + }, + onError: (error) => { + sshConnectionPool.reportFailure(this.config, error.message); + }, + }; } async createPtySession(params: PtySessionParams): Promise { - await sshConnectionPool.acquireConnection(this.config, { maxWaitMs: 0 }); + await this.acquireConnection({ maxWaitMs: 0 }); - const args: string[] = [...this.buildSSHArgs()]; + const args: string[] = [...this.buildBaseSSHArgs()]; + args.push("-o", "ControlMaster=no"); args.push("-o", "ConnectTimeout=15"); args.push("-o", "ServerAliveInterval=5"); args.push("-o", "ServerAliveCountMax=2"); @@ -113,7 +143,7 @@ export class OpenSSHTransport implements SSHTransport { }); } - private buildSSHArgs(): string[] { + private buildBaseSSHArgs(): string[] { const args: string[] = []; if (this.config.port) { @@ -125,10 +155,6 @@ export class OpenSSHTransport implements SSHTransport { } args.push("-o", "LogLevel=FATAL"); - args.push("-o", "ControlMaster=auto"); - args.push("-o", `ControlPath=${this.controlPath}`); - args.push("-o", "ControlPersist=60"); - return args; } } diff --git a/src/node/runtime/transports/SSH2Transport.ts b/src/node/runtime/transports/SSH2Transport.ts index 3ff1df2db5..7da6350acb 100644 --- a/src/node/runtime/transports/SSH2Transport.ts +++ b/src/node/runtime/transports/SSH2Transport.ts @@ -235,14 +235,6 @@ export class SSH2Transport implements SSHTransport { return this.config; } - markHealthy(): void { - ssh2ConnectionPool.markHealthy(this.config); - } - - reportFailure(error: string): void { - ssh2ConnectionPool.reportFailure(this.config, error); - } - async acquireConnection(options?: { abortSignal?: AbortSignal; timeoutMs?: number; @@ -297,7 +289,15 @@ export class SSH2Transport implements SSHTransport { }); const process = new SSH2ChildProcess(channel) as unknown as ChildProcess; - return { process }; + return { + process, + onExit: () => { + ssh2ConnectionPool.markHealthy(this.config); + }, + onError: (error) => { + ssh2ConnectionPool.reportFailure(this.config, getErrorMessage(error)); + }, + }; } catch (error) { ssh2ConnectionPool.reportFailure(this.config, getErrorMessage(error)); throw new RuntimeErrorClass( diff --git a/src/node/runtime/transports/SSHTransport.ts b/src/node/runtime/transports/SSHTransport.ts index 43f7aedef4..9ff4c7a546 100644 --- a/src/node/runtime/transports/SSHTransport.ts +++ b/src/node/runtime/transports/SSHTransport.ts @@ -15,6 +15,8 @@ export interface SpawnOptions { forcePTY?: boolean; timeout?: number; abortSignal?: AbortSignal; + /** Absolute client-side deadline (Date.now milliseconds) for queueing + execution. */ + deadlineMs?: number; } export interface PtySessionParams { @@ -30,12 +32,6 @@ export interface SSHTransport { /** Determine if an exit code represents a connection-level failure for this transport. */ isConnectionFailure(exitCode: number, stderr: string): boolean; - /** Mark connection as healthy (after successful command). */ - markHealthy(): void; - - /** Report connection failure (triggers backoff). */ - reportFailure(error: string): void; - /** Pre-flight connection check with backoff enforcement. */ acquireConnection(options?: { abortSignal?: AbortSignal; diff --git a/src/node/services/agentSession.postCompactionRetry.test.ts b/src/node/services/agentSession.postCompactionRetry.test.ts index 68d10d6c63..341ce38424 100644 --- a/src/node/services/agentSession.postCompactionRetry.test.ts +++ b/src/node/services/agentSession.postCompactionRetry.test.ts @@ -272,7 +272,9 @@ describe("AgentSession execSubagentHardRestart", () => { name: "child", projectName: "proj", projectPath: "/tmp/proj", - namedWorkspacePath: "/tmp/proj/child", + // Project-dir local runtimes execute in the project root, so persisted workspace paths + // must match projectPath instead of pointing at a synthetic sibling checkout. + namedWorkspacePath: "/tmp/proj", runtimeConfig: { type: "local" }, parentWorkspaceId, agentId: "exec", diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 754c5afd7f..0f5bbcacc4 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -63,8 +63,10 @@ import { type ReviewNoteDataForDisplay, type StartupRetrySendOptions, } from "@/common/types/message"; -import { createRuntime } from "@/node/runtime/runtimeFactory"; -import { createRuntimeForWorkspace } from "@/node/runtime/runtimeHelpers"; +import { + createRuntimeContextForWorkspace, + createRuntimeForWorkspace, +} from "@/node/runtime/runtimeHelpers"; import { hasNonEmptyPlanFile } from "@/node/utils/runtime/helpers"; import { isExecLikeEditingCapableInResolvedChain } from "@/common/utils/agentTools"; import { @@ -1952,20 +1954,12 @@ export class AgentSession { const existing = await this.aiService.getWorkspaceMetadata(this.workspaceId); if (existing.success) { - // Metadata already exists, verify workspace path matches - const metadata = existing.data; - // For in-place workspaces (projectPath === name), use path directly - // Otherwise reconstruct using runtime's worktree pattern - const isInPlace = metadata.projectPath === metadata.name; - const expectedPath = isInPlace - ? metadata.projectPath - : (() => { - const runtime = createRuntime(metadata.runtimeConfig, { - projectPath: metadata.projectPath, - workspaceName: metadata.name, - }); - return runtime.getWorkspacePath(metadata.projectPath, metadata.name); - })(); + // Metadata already exists; use the persisted config entry as the source of truth instead of + // reconstructing a canonical path, because upgraded SSH workspaces may still live under a + // legacy remote layout until an operation explicitly seeds that layout back into the runtime. + const workspace = this.config.findWorkspace(this.workspaceId); + assert(workspace, `Workspace ${this.workspaceId} is missing its persisted config entry`); + const expectedPath = path.resolve(workspace.workspacePath); assert( expectedPath === normalizedWorkspacePath, `Existing metadata workspace path mismatch for ${this.workspaceId}: expected ${expectedPath}, got ${normalizedWorkspacePath}` @@ -3527,14 +3521,7 @@ export class AgentSession { let chain: Awaited> | undefined; for (const agentMetadata of metadataCandidates) { try { - const runtime = createRuntimeForWorkspace(agentMetadata); - - // In-place workspaces (CLI/benchmarks) have projectPath === name. - // Use path directly instead of reconstructing via getWorkspacePath. - const isInPlace = agentMetadata.projectPath === agentMetadata.name; - const workspacePath = isInPlace - ? agentMetadata.projectPath - : runtime.getWorkspacePath(agentMetadata.projectPath, agentMetadata.name); + const { runtime, workspacePath } = createRuntimeContextForWorkspace(agentMetadata); const agentDiscoveryPath = context.options?.disableWorkspaceAgents === true @@ -4456,14 +4443,7 @@ export class AgentSession { } const metadata = metadataResult.data; - const runtime = createRuntimeForWorkspace(metadata); - - // In-place workspaces (CLI/benchmarks) have projectPath === name. - // Use the path directly instead of reconstructing via getWorkspacePath. - const isInPlace = metadata.projectPath === metadata.name; - const workspacePath = isInPlace - ? metadata.projectPath - : runtime.getWorkspacePath(metadata.projectPath, metadata.name); + const { runtime, workspacePath } = createRuntimeContextForWorkspace(metadata); // When disableWorkspaceAgents is active, use project path for discovery // (only built-in/global agents). Mirrors resolveAgentForStream behavior. @@ -5125,8 +5105,7 @@ export class AgentSession { } const metadata = metadataResult.data; - const runtime = createRuntimeForWorkspace(metadata); - const workspacePath = runtime.getWorkspacePath(metadata.projectPath, metadata.name); + const { runtime, workspacePath } = createRuntimeContextForWorkspace(metadata); const materialized = await materializeFileAtMentions(messageText, { runtime, @@ -5189,17 +5168,7 @@ export class AgentSession { } const metadata = metadataResult.data; - const runtime = createRuntime(metadata.runtimeConfig, { - projectPath: metadata.projectPath, - workspaceName: metadata.name, - }); - - // In-place workspaces (CLI/benchmarks) have projectPath === name. - // Use the path directly instead of reconstructing via getWorkspacePath. - const isInPlace = metadata.projectPath === metadata.name; - const workspacePath = isInPlace - ? metadata.projectPath - : runtime.getWorkspacePath(metadata.projectPath, metadata.name); + const { runtime, workspacePath } = createRuntimeContextForWorkspace(metadata); // When workspace agents are disabled, resolve skills from the project path instead of // the worktree so skill invocation uses the same precedence/discovery root as the UI. diff --git a/src/node/services/aiService.test.ts b/src/node/services/aiService.test.ts index dd7220e137..024b35d8ef 100644 --- a/src/node/services/aiService.test.ts +++ b/src/node/services/aiService.test.ts @@ -19,6 +19,7 @@ import { InitStateManager } from "./initStateManager"; import { ProviderService } from "./providerService"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import { Config } from "@/node/config"; +import * as runtimeFactory from "@/node/runtime/runtimeFactory"; import { LocalRuntime } from "@/node/runtime/LocalRuntime"; import { DisposableTempDir } from "@/node/services/tempDir"; @@ -1815,7 +1816,11 @@ describe("AIService.streamMessage multi-project trust gating", () => { getToolsForModelSpy: ReturnType>; } - function createTrustMetadata(workspaceId: string, projectPaths: string[]): WorkspaceMetadata { + function createTrustMetadata( + workspaceId: string, + projectPaths: string[], + runtimeConfig: WorkspaceMetadata["runtimeConfig"] = { type: "local" } + ): WorkspaceMetadata { const [primaryProjectPath, secondaryProjectPath] = projectPaths; if (!primaryProjectPath) { throw new Error("Expected at least one project path"); @@ -1832,14 +1837,15 @@ describe("AIService.streamMessage multi-project trust gating", () => { { projectPath: secondaryProjectPath, projectName: "project-b" }, ] : undefined, - runtimeConfig: { type: "local" }, + runtimeConfig, }; } function createHarness( muxHomePath: string, metadata: WorkspaceMetadata, - multiProjectExperimentEnabled = true + multiProjectExperimentEnabled = true, + workspacePathOverride?: string ): TrustGatingHarness { const config = new Config(muxHomePath); const historyService = new HistoryService(config); @@ -1949,7 +1955,7 @@ describe("AIService.streamMessage multi-project trust gating", () => { spyOn(initStateManager, "waitForInit").mockResolvedValue(undefined); spyOn(config, "findWorkspace").mockReturnValue({ - workspacePath: metadata.projectPath, + workspacePath: workspacePathOverride ?? metadata.projectPath, projectPath: metadata.projectPath, }); @@ -2032,6 +2038,42 @@ describe("AIService.streamMessage multi-project trust gating", () => { expect(trustedFromFirstGetToolsCall(harness.getToolsForModelSpy)).toBe(false); }); + it("uses the persisted workspace root as cwd for multi-project ssh startup", async () => { + using muxHome = new DisposableTempDir("ai-service-multi-project-persisted-cwd"); + const projectAPath = path.join(muxHome.path, "project-a"); + const projectBPath = path.join(muxHome.path, "project-b"); + await fs.mkdir(projectAPath, { recursive: true }); + await fs.mkdir(projectBPath, { recursive: true }); + + const workspaceId = "workspace-multi-project-persisted-cwd"; + const persistedWorkspacePath = path.join(muxHome.path, "persisted-legacy-workspace-root"); + const metadata = createTrustMetadata(workspaceId, [projectAPath, projectBPath], { + type: "ssh", + host: "example.com", + srcBaseDir: "/remote/src", + }); + const harness = createHarness(muxHome.path, metadata, true, persistedWorkspacePath); + const createRuntimeSpy = spyOn(runtimeFactory, "createRuntime").mockImplementation( + (_runtimeConfig, options) => new LocalRuntime(options?.projectPath ?? projectAPath) + ); + + try { + await harness.config.editConfig((cfg) => { + cfg.projects.set(projectAPath, { workspaces: [], trusted: true }); + cfg.projects.set(projectBPath, { workspaces: [], trusted: true }); + return cfg; + }); + + await streamOnce(harness, workspaceId); + + const toolConfig = harness.getToolsForModelSpy.mock.calls[0]?.[1] as + | { cwd?: unknown } + | undefined; + expect(toolConfig?.cwd).toBe(persistedWorkspacePath); + } finally { + createRuntimeSpy.mockRestore(); + } + }); it("fails closed before tool setup when the multi-project experiment is disabled", async () => { using muxHome = new DisposableTempDir("ai-service-multi-project-experiment-disabled"); const projectAPath = path.join(muxHome.path, "project-a"); diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 2c0e681cb1..1ecbfc95ef 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -23,9 +23,14 @@ 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 { MultiProjectRuntime } from "@/node/runtime/multiProjectRuntime"; import { getMuxEnv, getRuntimeType } from "@/node/runtime/initHook"; -import { getSrcBaseDir } from "@/common/types/runtime"; +import { getSrcBaseDir, isSSHRuntime } from "@/common/types/runtime"; import { ContainerManager } from "@/node/multiProject/containerManager"; import { secretsToRecord, type ExternalSecretResolver } from "@/common/types/secrets"; import { mergeMultiProjectSecrets } from "@/node/services/utils/multiProjectSecrets"; @@ -924,10 +929,19 @@ export class AIService extends EventEmitter { }); }; - if (!this.config.findWorkspace(workspaceId)) { + const workspace = this.config.findWorkspace(workspaceId); + if (!workspace) { return Err({ type: "unknown", raw: `Workspace ${workspaceId} not found in config` }); } + const metadataWithPath = { + ...metadata, + // Existing SSH workspaces may still live at a persisted root that differs from the canonical + // hashed project layout, so stream startup seeds the runtime from config for the current + // workspace instead of always reconstructing the path from project metadata. + namedWorkspacePath: workspace.workspacePath, + }; + const multiProjectExecutionGate = this.ensureMultiProjectRuntimeExecutionEnabled( workspaceId, metadata @@ -936,8 +950,12 @@ export class AIService extends EventEmitter { return multiProjectExecutionGate; } - const runtime = isMultiProject(metadata) - ? new MultiProjectRuntime( + const singleProjectContext = isMultiProject(metadata) + ? undefined + : createRuntimeContextForWorkspace(metadataWithPath); + const runtime = singleProjectContext + ? singleProjectContext.runtime + : new MultiProjectRuntime( new ContainerManager(getSrcBaseDir(metadata.runtimeConfig) ?? this.config.srcDir), getProjects(metadata).map((project) => ({ projectPath: project.projectPath, @@ -945,21 +963,34 @@ export class AIService extends EventEmitter { 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, }), })), metadata.name - ) - : createRuntime(metadata.runtimeConfig, { - projectPath: metadata.projectPath, - workspaceName: metadata.name, - }); + ); - // In-place workspaces (CLI/benchmarks) have projectPath === name - // Use path directly instead of reconstructing via getWorkspacePath - const isInPlace = metadata.projectPath === metadata.name; - const workspacePath = isInPlace - ? metadata.projectPath - : runtime.getWorkspacePath(metadata.projectPath, metadata.name); + const workspacePath = + singleProjectContext?.workspacePath ?? + (isSSHRuntime(metadata.runtimeConfig) + ? resolveWorkspaceExecutionPath(metadataWithPath, runtime) + : // Non-SSH multi-project runtimes intentionally start from their shared container root so + // sibling repos stay addressable during agent/tool setup. SSH workspaces are the exception: + // upgraded legacy layouts must reuse the persisted root from config until remote layout + // detection seeds the new hashed paths. + runtime.getWorkspacePath(metadata.projectPath, metadata.name)); // Wait for init to complete before any runtime I/O operations // (SSH/devcontainer may not be ready until init finishes pulling the container) diff --git a/src/node/services/utils/forkOrchestrator.test.ts b/src/node/services/utils/forkOrchestrator.test.ts index 63e95d56db..e4c45d02d0 100644 --- a/src/node/services/utils/forkOrchestrator.test.ts +++ b/src/node/services/utils/forkOrchestrator.test.ts @@ -153,6 +153,7 @@ describe("orchestrateFork", () => { expect(createRuntimeMock).toHaveBeenCalledWith(DEFAULT_FORKED_RUNTIME_CONFIG, { projectPath: PROJECT_PATH, workspaceName: NEW_WORKSPACE_NAME, + workspacePath: "/workspaces/forked", }); }); @@ -201,6 +202,7 @@ describe("orchestrateFork", () => { expect(createRuntimeMock).toHaveBeenCalledWith(DEFAULT_FORKED_RUNTIME_CONFIG, { projectPath: PROJECT_PATH, workspaceName: NEW_WORKSPACE_NAME, + workspacePath: "/workspaces/created", }); }); @@ -409,6 +411,7 @@ describe("orchestrateFork", () => { expect(createRuntimeMock).toHaveBeenCalledWith(customForkedRuntimeConfig, { projectPath: PROJECT_PATH, workspaceName: NEW_WORKSPACE_NAME, + workspacePath: "/workspaces/created-with-custom-runtime", }); }); @@ -451,10 +454,14 @@ describe("orchestrateFork", () => { expect.objectContaining({ containerName: "mux-demo-source-aaaaaa" }) ); - // createRuntime should also receive the normalized config + // createRuntime should also receive the normalized config and the created workspace path. expect(createRuntimeMock).toHaveBeenCalledWith( expect.objectContaining({ containerName: expectedContainerName }), - { projectPath: PROJECT_PATH, workspaceName: NEW_WORKSPACE_NAME } + { + projectPath: PROJECT_PATH, + workspaceName: NEW_WORKSPACE_NAME, + workspacePath: "/workspaces/new", + } ); }); it("returns Err when create fallback also fails", async () => { diff --git a/src/node/services/utils/forkOrchestrator.ts b/src/node/services/utils/forkOrchestrator.ts index 67c85bd5f3..560373f228 100644 --- a/src/node/services/utils/forkOrchestrator.ts +++ b/src/node/services/utils/forkOrchestrator.ts @@ -577,6 +577,7 @@ export async function orchestrateFork( const targetRuntime = createRuntime(normalizedForkedRuntimeConfig, { projectPath, workspaceName: newWorkspaceName, + workspacePath, }); return Ok({ diff --git a/src/node/services/workspaceProjectRepos.test.ts b/src/node/services/workspaceProjectRepos.test.ts index 1acf0c01dc..31d8755472 100644 --- a/src/node/services/workspaceProjectRepos.test.ts +++ b/src/node/services/workspaceProjectRepos.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from "bun:test"; -import { getWorkspaceProjectRepos } from "@/node/services/workspaceProjectRepos"; +import { + buildLegacyRemoteProjectLayout, + buildRemoteProjectLayout, + getRemoteWorkspacePath, +} from "@/node/runtime/remoteProjectLayout"; +import { + getWorkspacePathHintForProject, + getWorkspaceProjectRepos, +} from "@/node/services/workspaceProjectRepos"; describe("getWorkspaceProjectRepos", () => { it("treats an empty project list as a single-project fallback", () => { @@ -38,6 +46,125 @@ describe("getWorkspaceProjectRepos", () => { expect(repos[0]?.storageKey).toBe("..-..-secrets"); }); + it("reuses the persisted workspace path for the current SSH project when it matches a known project layout", () => { + const runtimeConfig = { + type: "ssh", + host: "example.com", + srcBaseDir: "/tmp/src", + } as const; + const primaryProjectPath = "/tmp/projects/main"; + const workspaceName = "main"; + const workspacePath = getRemoteWorkspacePath( + buildLegacyRemoteProjectLayout(runtimeConfig.srcBaseDir, primaryProjectPath), + workspaceName + ); + const repos = getWorkspaceProjectRepos({ + workspaceId: "workspace-1", + workspaceName, + workspacePath, + runtimeConfig, + projectPath: primaryProjectPath, + projectName: "main", + projects: [ + { projectPath: primaryProjectPath, projectName: "main" }, + { projectPath: "/tmp/projects/other", projectName: "other" }, + ], + }); + + expect(repos[0]?.repoCwd).toBe(workspacePath); + }); + + it("derives hashed SSH paths for secondary multi-project repos", () => { + 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"; + const repos = getWorkspaceProjectRepos({ + workspaceId: "workspace-1", + workspaceName, + workspacePath: "/tmp/legacy/main", + runtimeConfig, + projectPath: primaryProjectPath, + projectName: "main", + projects: [ + { projectPath: primaryProjectPath, projectName: "main" }, + { projectPath: secondaryProjectPath, projectName: "other" }, + ], + }); + + expect(repos[1]?.repoCwd).toBe( + getRemoteWorkspacePath( + buildRemoteProjectLayout(runtimeConfig.srcBaseDir, secondaryProjectPath), + workspaceName + ) + ); + }); + + it("derives legacy SSH path hints for sibling multi-project repos when the persisted root is legacy-shaped", () => { + const runtimeConfig = { + type: "ssh", + host: "example.com", + srcBaseDir: "/tmp/src", + } as const; + const primaryProjectPath = "/tmp/projects/main"; + const secondaryProjectPath = "/tmp/projects/other"; + const workspaceName = "main"; + + const hint = getWorkspacePathHintForProject( + { + workspaceId: "workspace-1", + workspaceName, + workspacePath: getRemoteWorkspacePath( + buildLegacyRemoteProjectLayout(runtimeConfig.srcBaseDir, primaryProjectPath), + workspaceName + ), + runtimeConfig, + projectPath: primaryProjectPath, + projectName: "main", + projects: [ + { projectPath: primaryProjectPath, projectName: "main" }, + { projectPath: secondaryProjectPath, projectName: "other" }, + ], + }, + secondaryProjectPath + ); + + expect(hint).toBe( + getRemoteWorkspacePath( + buildLegacyRemoteProjectLayout(runtimeConfig.srcBaseDir, secondaryProjectPath), + workspaceName + ) + ); + }); + + it("returns no SSH path hint when the persisted root is not a project checkout", () => { + const runtimeConfig = { + type: "ssh", + host: "example.com", + srcBaseDir: "/tmp/src", + } as const; + const primaryProjectPath = "/tmp/projects/main"; + + const hint = getWorkspacePathHintForProject( + { + workspaceId: "workspace-1", + workspaceName: "main", + workspacePath: "/tmp/src/containers/main", + runtimeConfig, + projectPath: primaryProjectPath, + projectName: "main", + projects: [{ projectPath: primaryProjectPath, projectName: "main" }], + }, + primaryProjectPath + ); + + expect(hint).toBeUndefined(); + }); + it("disambiguates storage keys when sanitized project names collide", () => { const repos = getWorkspaceProjectRepos({ workspaceId: "workspace-1", diff --git a/src/node/services/workspaceProjectRepos.ts b/src/node/services/workspaceProjectRepos.ts index 52e7129f8f..8a5677b42d 100644 --- a/src/node/services/workspaceProjectRepos.ts +++ b/src/node/services/workspaceProjectRepos.ts @@ -2,8 +2,13 @@ import assert from "node:assert/strict"; import * as path from "node:path"; import type { ProjectRef } from "@/common/types/workspace"; -import type { RuntimeConfig } from "@/common/types/runtime"; +import { isSSHRuntime, type RuntimeConfig } from "@/common/types/runtime"; import { PlatformPaths } from "@/common/utils/paths"; +import { + buildLegacyRemoteProjectLayout, + buildRemoteProjectLayout, + getRemoteWorkspacePath, +} from "@/node/runtime/remoteProjectLayout"; import { createRuntime } from "@/node/runtime/runtimeFactory"; export interface WorkspaceProjectRepo { @@ -133,6 +138,40 @@ export function getWorkspaceProjectStorageKeys( return storageKeys; } +export function getWorkspacePathHintForProject( + params: WorkspaceProjectRepoParams, + targetProjectPath: string +): string | undefined { + if (!isSSHRuntime(params.runtimeConfig)) { + return undefined; + } + + const currentProjectRoot = path.posix.dirname(path.posix.normalize(params.workspacePath)); + const primaryLegacyLayout = buildLegacyRemoteProjectLayout( + params.runtimeConfig.srcBaseDir, + params.projectPath + ); + if (currentProjectRoot === primaryLegacyLayout.projectRoot) { + return getRemoteWorkspacePath( + buildLegacyRemoteProjectLayout(params.runtimeConfig.srcBaseDir, targetProjectPath), + params.workspaceName + ); + } + + const primaryPreferredLayout = buildRemoteProjectLayout( + params.runtimeConfig.srcBaseDir, + params.projectPath + ); + if (currentProjectRoot === primaryPreferredLayout.projectRoot) { + return getRemoteWorkspacePath( + buildRemoteProjectLayout(params.runtimeConfig.srcBaseDir, targetProjectPath), + params.workspaceName + ); + } + + return undefined; +} + export function getWorkspaceProjectRepos( params: WorkspaceProjectRepoParams ): WorkspaceProjectRepo[] { @@ -161,12 +200,17 @@ export function getWorkspaceProjectRepos( const isMultiProject = projectStorageKeys.length > 1; const repos = projectStorageKeys.map((project) => { - const repoCwd = isMultiProject - ? createRuntime(params.runtimeConfig, { + 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) - : params.workspacePath; + }).getWorkspacePath(project.projectPath, params.workspaceName)); assert( repoCwd.trim().length > 0, diff --git a/src/node/services/workspaceService.multiProject.test.ts b/src/node/services/workspaceService.multiProject.test.ts index b83c904e28..665e5805cc 100644 --- a/src/node/services/workspaceService.multiProject.test.ts +++ b/src/node/services/workspaceService.multiProject.test.ts @@ -181,10 +181,12 @@ describe("WorkspaceService executeBash runtime selection", () => { expect(createRuntimeSpy).toHaveBeenNthCalledWith(1, metadata.runtimeConfig, { projectPath: projectAPath, workspaceName, + workspacePath: undefined, }); expect(createRuntimeSpy).toHaveBeenNthCalledWith(2, metadata.runtimeConfig, { projectPath: projectBPath, workspaceName, + workspacePath: undefined, }); assert(capturedToolConfig); expect(capturedToolConfig.runtime).toBeInstanceOf(MultiProjectRuntime); @@ -201,6 +203,115 @@ describe("WorkspaceService executeBash runtime selection", () => { } }); + test("preserves the current SSH repo root and derives sibling legacy repo roots for multi-project repo-root bash mode when the persisted root matches that layout", async () => { + const workspaceId = "ws-multi-bash-ssh"; + const workspaceName = "feature-multi-bash-ssh"; + const srcDir = "/tmp/src"; + const projectAPath = "/tmp/project-a"; + const projectBPath = "/tmp/project-b"; + const primaryWorkspacePath = `/tmp/src/project-a/${workspaceName}`; + const metadata: WorkspaceMetadata = { + id: workspaceId, + name: workspaceName, + projectPath: projectAPath, + projectName: "project-a", + projects: [ + { projectPath: projectAPath, projectName: "project-a" }, + { projectPath: projectBPath, projectName: "project-b" }, + ], + runtimeConfig: { type: "ssh", host: "example.com", srcBaseDir: "/tmp/src" }, + }; + const waitForInitMock = mock(() => Promise.resolve()); + const ensureReadyAMock = mock(() => Promise.resolve({ ready: true as const })); + const ensureReadyBMock = mock(() => Promise.resolve({ ready: true as const })); + const createRuntimeSpy = spyOn(runtimeFactory, "createRuntime").mockImplementation( + (_runtimeConfig, options) => { + if (options?.projectPath === projectAPath) { + expect(options.workspacePath).toBe(primaryWorkspacePath); + return { + ensureReady: ensureReadyAMock, + getWorkspacePath: mock(() => primaryWorkspacePath), + } as unknown as ReturnType; + } + if (options?.projectPath === projectBPath) { + expect(options.workspacePath).toBe(`/tmp/src/project-b/${workspaceName}`); + return { + ensureReady: ensureReadyBMock, + getWorkspacePath: mock(() => `/tmp/src/project-b/${workspaceName}`), + } as unknown as ReturnType; + } + throw new Error(`Unexpected projectPath: ${options?.projectPath ?? "missing"}`); + } + ); + const bashExecuteMock = mock(() => + Promise.resolve({ success: true as const, output: "ok", exitCode: 0, wall_duration_ms: 1 }) + ); + let capturedToolConfig: Parameters[0] | undefined; + const createBashToolSpy = spyOn(bashToolModule, "createBashTool").mockImplementation( + (config) => { + capturedToolConfig = config; + return { + execute: bashExecuteMock, + } as unknown as ReturnType; + } + ); + + const aiService: AIService = { + isStreaming: mock(() => false), + getWorkspaceMetadata: mock(() => Promise.resolve(Ok(metadata))), + on: mock(() => undefined), + off: mock(() => undefined), + } as unknown as AIService; + const workspaceService = new WorkspaceService( + { + srcDir, + getSessionDir: mock(() => "/tmp/test/sessions"), + findWorkspace: mock(() => ({ + projectPath: projectAPath, + workspacePath: primaryWorkspacePath, + })), + loadConfigOrDefault: mock(() => ({ + projects: new Map([ + [projectAPath, { workspaces: [], trusted: true }], + [projectBPath, { workspaces: [], trusted: true }], + ]), + })), + getEffectiveSecrets: mock(() => []), + } as unknown as Config, + historyService, + aiService, + { + on: mock(() => undefined as unknown as InitStateManager), + getInitState: mock(() => undefined), + waitForInit: waitForInitMock, + } as unknown as InitStateManager, + mockExtensionMetadataService as ExtensionMetadataService, + mockBackgroundProcessManager as BackgroundProcessManager, + undefined, + undefined, + undefined, + createMockExperimentsService(true) + ); + + try { + const result = await workspaceService.executeBash(workspaceId, "git status --short", { + cwdMode: "repo-root", + repoRootProjectPath: projectBPath, + }); + + expect(result.success).toBe(true); + expect(waitForInitMock).toHaveBeenCalledWith(workspaceId); + assert(capturedToolConfig); + expect(capturedToolConfig.cwd).toBe(`/tmp/src/project-b/${workspaceName}`); + expect(ensureReadyAMock).toHaveBeenCalledTimes(1); + expect(ensureReadyBMock).toHaveBeenCalledTimes(1); + expect(bashExecuteMock).toHaveBeenCalledTimes(1); + } finally { + createBashToolSpy.mockRestore(); + createRuntimeSpy.mockRestore(); + } + }); + test("lets multi-project script mode target a secondary repo checkout explicitly", async () => { const workspaceId = "ws-multi-bash-repo-root"; const workspaceName = "feature-multi-bash-repo-root"; diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 08bd038646..4871dd08ca 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -2240,6 +2240,78 @@ describe("WorkspaceService getFileCompletions", () => { expect(execBufferedSpy.mock.calls[0]?.[2].cwd).toBe("/persisted/project-a/ws"); }); + test("preserves the current SSH workspace path and derives sibling legacy paths for multi-project completions when the persisted root matches that layout", async () => { + interface WorkspaceServiceTestAccess { + getInfo: (workspaceId: string) => Promise; + } + + const svc = workspaceService as unknown as WorkspaceServiceTestAccess; + svc.getInfo = mock(() => + Promise.resolve({ + id: "ws-multi-ssh", + name: "ws", + projectName: "project-a", + projectPath: "/tmp/project-a", + namedWorkspacePath: "/tmp/src/project-a/ws", + runtimeConfig: { type: "ssh", host: "example.com", srcBaseDir: "/tmp/src" }, + projects: [ + { projectPath: "/tmp/project-a", projectName: "project-a" }, + { projectPath: "/tmp/project-b", projectName: "project-b" }, + ], + } satisfies FrontendWorkspaceMetadata) + ); + const config = (workspaceService as unknown as { config: Config }).config; + spyOn(config, "findWorkspace").mockReturnValue({ + projectPath: "/tmp/project-a", + workspacePath: "/tmp/src/project-a/ws", + }); + createRuntimeSpy.mockImplementation((_runtimeConfig, options) => { + const runtimeProjectPath = options?.projectPath; + if (!runtimeProjectPath) { + throw new Error("Expected createRuntime projectPath in SSH completion test"); + } + return { + getWorkspacePath: () => + options.workspacePath ?? `/runtime/${path.basename(runtimeProjectPath)}/ws`, + } as unknown as ReturnType; + }); + + execBufferedSpy.mockImplementation((_runtime, _command, options) => { + if (options.cwd === "/tmp/src/project-a/ws") { + return Promise.resolve({ + stdout: "README.md\n", + stderr: "", + exitCode: 0, + duration: 1, + }); + } + if (options.cwd === "/tmp/src/project-b/ws") { + return Promise.resolve({ + stdout: "src/b.ts\n", + stderr: "", + exitCode: 0, + duration: 1, + }); + } + return Promise.reject(new Error(`Unexpected cwd ${options.cwd}`)); + }); + + const result = await workspaceService.getFileCompletions("ws-multi-ssh", "", 10); + + expect(result.paths).toContain("project-a/README.md"); + expect(result.paths).toContain("project-b/src/b.ts"); + expect(createRuntimeSpy).toHaveBeenNthCalledWith(1, expect.anything(), { + projectPath: "/tmp/project-a", + workspaceName: "ws", + workspacePath: "/tmp/src/project-a/ws", + }); + expect(createRuntimeSpy).toHaveBeenNthCalledWith(2, expect.anything(), { + projectPath: "/tmp/project-b", + workspaceName: "ws", + workspacePath: "/tmp/src/project-b/ws", + }); + }); + test("aggregates multi-project completions using project-prefixed paths", async () => { interface WorkspaceServiceTestAccess { getInfo: (workspaceId: string) => Promise; diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index a5e965834b..c06c5a395e 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -36,9 +36,11 @@ import { } from "@/node/runtime/runtimeFactory"; import { MultiProjectRuntime } from "@/node/runtime/multiProjectRuntime"; import { + createRuntimeContextForWorkspace, createRuntimeForWorkspace, resolveWorkspaceExecutionPath, } from "@/node/runtime/runtimeHelpers"; +import { getWorkspacePathHintForProject } from "@/node/services/workspaceProjectRepos"; import { validateWorkspaceName } from "@/common/utils/validation/workspaceValidation"; import { ensurePrivateDir } from "@/node/utils/fs"; import { stripTrailingSlashes } from "@/node/utils/pathUtils"; @@ -2830,6 +2832,8 @@ export class WorkspaceService extends EventEmitter { const metadata = metadataResult.data; const configSnapshot = this.config.loadConfigOrDefault(); + const persistedWorkspacePath = this.config.findWorkspace(workspaceId)?.workspacePath; + if (isMultiProject(metadata)) { const projects = getProjects(metadata); const deleteErrors: string[] = []; @@ -2844,6 +2848,20 @@ export class WorkspaceService extends EventEmitter { 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 trusted = configSnapshot.projects.get(stripTrailingSlashes(project.projectPath))?.trusted ?? @@ -2964,6 +2982,7 @@ export class WorkspaceService extends EventEmitter { const runtime = createRuntime(metadata.runtimeConfig, { projectPath, workspaceName: metadata.name, + workspacePath: persistedWorkspacePath, }); // Delete workspace from runtime first - if this fails with force=false, we abort @@ -3459,6 +3478,7 @@ export class WorkspaceService extends EventEmitter { const rollbackRuntime = createRuntime(oldMetadata.runtimeConfig, { projectPath: renamedProject.projectPath, workspaceName: newName, + workspacePath: renamedProject.newWorkspacePath, }); const rollbackTrusted = configSnapshot.projects.get(stripTrailingSlashes(renamedProject.projectPath)) @@ -3492,6 +3512,18 @@ export class WorkspaceService extends EventEmitter { 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 trusted = @@ -3614,6 +3646,7 @@ export class WorkspaceService extends EventEmitter { const runtime = createRuntime(oldMetadata.runtimeConfig, { projectPath: configProjectPath, workspaceName: oldName, + workspacePath: workspace.workspacePath, }); const trusted = @@ -5009,9 +5042,11 @@ export class WorkspaceService extends EventEmitter { return Err(resolvedNameValidation.error ?? "Invalid workspace name"); } + const sourceWorkspace = this.config.findWorkspace(sourceWorkspaceId); const sourceRuntime = createRuntime(sourceRuntimeConfig, { projectPath: foundProjectPath, workspaceName: sourceMetadata.name, + workspacePath: sourceWorkspace?.workspacePath, }); const newWorkspaceId = this.config.generateStableId(); @@ -5170,6 +5205,7 @@ export class WorkspaceService extends EventEmitter { const freshSourceRuntime = createRuntime(sourceRuntimeConfig, { projectPath: foundProjectPath, workspaceName: sourceMetadata.name, + workspacePath: sourceWorkspace?.workspacePath, }); await copyPlanFileAcrossRuntimes( freshSourceRuntime, @@ -6446,11 +6482,11 @@ export class WorkspaceService extends EventEmitter { } private async listWorkspacePathsForFileCompletions( - metadata: FrontendWorkspaceMetadata + metadata: FrontendWorkspaceMetadata, + workspacePath?: string ): Promise { if (!isMultiProject(metadata)) { - const runtime = createRuntimeForWorkspace(metadata); - const workspacePath = resolveWorkspaceExecutionPath(metadata, runtime); + const { runtime, workspacePath } = createRuntimeContextForWorkspace(metadata); return this.listGitPathsForFileCompletions(runtime, workspacePath); } @@ -6464,6 +6500,21 @@ export class WorkspaceService extends EventEmitter { 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, @@ -6526,7 +6577,11 @@ export class WorkspaceService extends EventEmitter { const previousIndex = cacheEntry.index; try { - const files = await this.listWorkspacePathsForFileCompletions(metadata); + const workspace = this.config.findWorkspace(workspaceId); + const files = await this.listWorkspacePathsForFileCompletions( + metadata, + workspace?.workspacePath + ); cacheEntry.index = files === null ? previousIndex : buildFileCompletionsIndex(files); cacheEntry.fetchedAt = Date.now(); } catch (error) { @@ -6616,6 +6671,20 @@ export class WorkspaceService extends EventEmitter { 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, }), })) : undefined; diff --git a/tests/e2e/utils/ui.ts b/tests/e2e/utils/ui.ts index aed0a1e389..d04d347e23 100644 --- a/tests/e2e/utils/ui.ts +++ b/tests/e2e/utils/ui.ts @@ -211,7 +211,7 @@ export function createWorkspaceUI(page: Page, context: DemoProjectConfig): Works } for (let attempt = 0; attempt < 3; attempt++) { - await control.click(); + await control.dispatchEvent("click"); try { await expect .poll( @@ -282,8 +282,9 @@ export function createWorkspaceUI(page: Page, context: DemoProjectConfig): Works return labelIndex === -1 ? 0 : labelIndex; }; - // Click paddles until we reach the target level. If the control is still settling after - // a mode switch, clickUntilLabelChanges() retries the interaction before giving up. + // Click paddles until we reach the target level. clickUntilLabelChanges() retries + // the interaction and dispatches DOM clicks directly so transient Linux Electron + // overlays do not interfere with toolbar hit-testing. for (let i = 0; i < allowedLabels.length * 2; i++) { const currentLevel = await getCurrentLevel(); if (currentLevel === clampedTargetLevel) { diff --git a/tests/runtime/runtime.test.ts b/tests/runtime/runtime.test.ts index bc35d99f2d..1bad6f3b37 100644 --- a/tests/runtime/runtime.test.ts +++ b/tests/runtime/runtime.test.ts @@ -33,7 +33,12 @@ import { import { execBuffered, readFileString, writeFileString } from "@/node/utils/runtime/helpers"; import type { Runtime } from "@/node/runtime/Runtime"; import { RuntimeError } from "@/node/runtime/Runtime"; -import { computeBaseRepoPath, type SSHRuntime } from "@/node/runtime/SSHRuntime"; +import { computeBaseRepoPath, SSHRuntime } from "@/node/runtime/SSHRuntime"; +import { + buildLegacyRemoteProjectLayout, + buildRemoteProjectLayout, + getRemoteWorkspacePath, +} from "@/node/runtime/remoteProjectLayout"; import { createSSHTransport } from "@/node/runtime/transports"; import { runFullInit } from "@/node/runtime/runtimeFactory"; import { sshConnectionPool } from "@/node/runtime/sshConnectionPool"; @@ -955,14 +960,15 @@ describeIntegration("Runtime integration tests", () => { * SSHRuntime-specific workspace operation tests * WorktreeRuntime workspace tests are covered by the matrix above * - * Note: SSHRuntime.getWorkspacePath uses srcBaseDir + projectName + workspaceName. - * The projectPath argument is only used to extract the project name (basename). - * So the actual workspace path is: /home/testuser/workspace/{projectName}/{workspaceName} + * Note: SSHRuntime derives workspace paths from the hashed remote project layout + * when a persisted workspacePath is not available. + * These tests build the same layout helpers as production code before asserting paths. */ describe("SSHRuntime workspace operations", () => { const testForRuntime = test; const srcBaseDir = "/home/testuser/workspace"; const createSSHRuntime = (): Runtime => createTestRuntime("ssh", srcBaseDir, sshConfig); + const getLayout = (projectPath: string) => buildRemoteProjectLayout(srcBaseDir, projectPath); describe("renameWorkspace", () => { testForRuntime("successfully renames directory", async () => { @@ -972,9 +978,9 @@ describeIntegration("Runtime integration tests", () => { // projectPath is used to extract project name - can be any path ending with projectName const projectPath = `/some/path/${projectName}`; - // The runtime will construct paths as: srcBaseDir/projectName/workspaceName - const oldWorkspacePath = `${srcBaseDir}/${projectName}/worktree-1`; - const newWorkspacePath = `${srcBaseDir}/${projectName}/worktree-renamed`; + const layout = getLayout(projectPath); + const oldWorkspacePath = getRemoteWorkspacePath(layout, "worktree-1"); + const newWorkspacePath = getRemoteWorkspacePath(layout, "worktree-renamed"); // Create the workspace directory structure where the runtime expects it await execBuffered( @@ -1009,7 +1015,7 @@ describeIntegration("Runtime integration tests", () => { } // Cleanup - await execBuffered(runtime, `rm -rf "${srcBaseDir}/${projectName}"`, { + await execBuffered(runtime, `rm -rf "${layout.projectRoot}"`, { cwd: "/home/testuser", timeout: 30, }); @@ -1039,8 +1045,9 @@ describeIntegration("Runtime integration tests", () => { const sourceWorkspaceName = "source"; const newWorkspaceName = "forked"; - const sourceWorkspacePath = `${srcBaseDir}/${projectName}/${sourceWorkspaceName}`; - const newWorkspacePath = `${srcBaseDir}/${projectName}/${newWorkspaceName}`; + const layout = getLayout(projectPath); + const sourceWorkspacePath = getRemoteWorkspacePath(layout, sourceWorkspaceName); + const newWorkspacePath = getRemoteWorkspacePath(layout, newWorkspaceName); // Create a source workspace repo with a non-trunk branch checked out. await execBuffered( @@ -1136,7 +1143,7 @@ describeIntegration("Runtime integration tests", () => { expect(initResult.success).toBe(true); // Cleanup - await execBuffered(runtime, `rm -rf "${srcBaseDir}/${projectName}"`, { + await execBuffered(runtime, `rm -rf "${layout.projectRoot}"`, { cwd: "/home/testuser", timeout: 30, }); @@ -1148,7 +1155,8 @@ describeIntegration("Runtime integration tests", () => { const runtime = createSSHRuntime(); const projectName = `delete-test-${Date.now()}-${Math.random().toString(36).substring(7)}`; const projectPath = `/some/path/${projectName}`; - const workspacePath = `${srcBaseDir}/${projectName}/worktree-delete-test`; + const layout = getLayout(projectPath); + const workspacePath = getRemoteWorkspacePath(layout, "worktree-delete-test"); // Create the workspace directory structure where the runtime expects it await execBuffered( @@ -1182,7 +1190,7 @@ describeIntegration("Runtime integration tests", () => { } // Cleanup - await execBuffered(runtime, `rm -rf "${srcBaseDir}/${projectName}"`, { + await execBuffered(runtime, `rm -rf "${layout.projectRoot}"`, { cwd: "/home/testuser", timeout: 30, }); @@ -1213,27 +1221,30 @@ describeIntegration("Runtime integration tests", () => { const srcBaseDir = "/home/testuser/workspace"; const createSSHRuntime = (): SSHRuntime => createTestRuntime("ssh", srcBaseDir, sshConfig) as SSHRuntime; + const getLayout = (projectPath: string) => buildRemoteProjectLayout(srcBaseDir, projectPath); test("computeBaseRepoPath returns correct path", async () => { + const layout = getLayout("/some/path/my-project"); const result = computeBaseRepoPath(srcBaseDir, "/some/path/my-project"); - expect(result).toBe(`${srcBaseDir}/my-project/.mux-base.git`); + expect(result).toBe(layout.baseRepoPath); }, 10000); test("forkWorkspace uses worktree when base repo exists", async () => { const runtime = createSSHRuntime(); const projectName = `wt-fork-${Date.now()}-${Math.random().toString(36).substring(7)}`; const projectPath = `/some/path/${projectName}`; - const baseRepoPath = `${srcBaseDir}/${projectName}/.mux-base.git`; - const sourceWorkspacePath = `${srcBaseDir}/${projectName}/source`; + const layout = getLayout(projectPath); + const baseRepoPath = layout.baseRepoPath; + const sourceWorkspacePath = getRemoteWorkspacePath(layout, "source"); const newWorkspaceName = "forked-wt"; - const newWorkspacePath = `${srcBaseDir}/${projectName}/${newWorkspaceName}`; + const newWorkspacePath = getRemoteWorkspacePath(layout, newWorkspaceName); try { // 1. Create a bare base repo and populate it with a commit. await execBuffered( runtime, [ - `mkdir -p "${srcBaseDir}/${projectName}"`, + `mkdir -p "${layout.projectRoot}"`, `git init --bare "${baseRepoPath}"`, // Create a temp repo, commit, and push to the bare repo. `TMPCLONE=$(mktemp -d)`, @@ -1308,7 +1319,7 @@ describeIntegration("Runtime integration tests", () => { expect(worktreeList.stdout).toContain(newWorkspaceName); } finally { // Cleanup: remove all worktrees and the project directory. - await execBuffered(runtime, `rm -rf "${srcBaseDir}/${projectName}"`, { + await execBuffered(runtime, `rm -rf "${layout.projectRoot}"`, { cwd: "/home/testuser", timeout: 30, }); @@ -1319,9 +1330,10 @@ describeIntegration("Runtime integration tests", () => { const runtime = createSSHRuntime(); const projectName = `wt-legacy-fork-${Date.now()}-${Math.random().toString(36).substring(7)}`; const projectPath = `/some/path/${projectName}`; - const sourceWorkspacePath = `${srcBaseDir}/${projectName}/legacy-source`; + const layout = getLayout(projectPath); + const sourceWorkspacePath = getRemoteWorkspacePath(layout, "legacy-source"); const newWorkspaceName = "legacy-forked"; - const newWorkspacePath = `${srcBaseDir}/${projectName}/${newWorkspaceName}`; + const newWorkspacePath = getRemoteWorkspacePath(layout, newWorkspaceName); try { // Create a legacy workspace (standalone git clone, no base repo). @@ -1344,7 +1356,7 @@ describeIntegration("Runtime integration tests", () => { // Verify no base repo exists. const baseCheck = await execBuffered( runtime, - `test -d "${srcBaseDir}/${projectName}/.mux-base.git" && echo "exists" || echo "missing"`, + `test -d "${layout.baseRepoPath}" && echo "exists" || echo "missing"`, { cwd: "/home/testuser", timeout: 30 } ); expect(baseCheck.stdout.trim()).toBe("missing"); @@ -1376,7 +1388,7 @@ describeIntegration("Runtime integration tests", () => { }); expect(fileCheck.stdout.trim()).toBe("legacy content"); } finally { - await execBuffered(runtime, `rm -rf "${srcBaseDir}/${projectName}"`, { + await execBuffered(runtime, `rm -rf "${layout.projectRoot}"`, { cwd: "/home/testuser", timeout: 30, }); @@ -1387,17 +1399,18 @@ describeIntegration("Runtime integration tests", () => { const runtime = createSSHRuntime(); const projectName = `wt-mixed-fork-${Date.now()}-${Math.random().toString(36).substring(7)}`; const projectPath = `/some/path/${projectName}`; - const baseRepoPath = `${srcBaseDir}/${projectName}/.mux-base.git`; - const sourceWorkspacePath = `${srcBaseDir}/${projectName}/legacy-ws`; + const layout = getLayout(projectPath); + const baseRepoPath = layout.baseRepoPath; + const sourceWorkspacePath = getRemoteWorkspacePath(layout, "legacy-ws"); const newWorkspaceName = "forked-mixed"; - const newWorkspacePath = `${srcBaseDir}/${projectName}/${newWorkspaceName}`; + const newWorkspacePath = getRemoteWorkspacePath(layout, newWorkspaceName); try { // 1. Create a bare base repo with a commit on 'main' (simulates a previous initWorkspace). await execBuffered( runtime, [ - `mkdir -p "${srcBaseDir}/${projectName}"`, + `mkdir -p "${layout.projectRoot}"`, `git init --bare "${baseRepoPath}"`, `TMPCLONE=$(mktemp -d)`, `git clone "${baseRepoPath}" "$TMPCLONE/work"`, @@ -1466,7 +1479,7 @@ describeIntegration("Runtime integration tests", () => { }); expect(fileCheck.stdout.trim()).toBe("legacy content"); } finally { - await execBuffered(runtime, `rm -rf "${srcBaseDir}/${projectName}"`, { + await execBuffered(runtime, `rm -rf "${layout.projectRoot}"`, { cwd: "/home/testuser", timeout: 30, }); @@ -1477,16 +1490,17 @@ describeIntegration("Runtime integration tests", () => { const runtime = createSSHRuntime(); const projectName = `wt-delete-${Date.now()}-${Math.random().toString(36).substring(7)}`; const projectPath = `/some/path/${projectName}`; - const baseRepoPath = `${srcBaseDir}/${projectName}/.mux-base.git`; + const layout = getLayout(projectPath); + const baseRepoPath = layout.baseRepoPath; const workspaceName = "to-delete"; - const workspacePath = `${srcBaseDir}/${projectName}/${workspaceName}`; + const workspacePath = getRemoteWorkspacePath(layout, workspaceName); try { // Create bare base repo with a commit. await execBuffered( runtime, [ - `mkdir -p "${srcBaseDir}/${projectName}"`, + `mkdir -p "${layout.projectRoot}"`, `git init --bare "${baseRepoPath}"`, `TMPCLONE=$(mktemp -d)`, `git clone "${baseRepoPath}" "$TMPCLONE/work"`, @@ -1539,7 +1553,7 @@ describeIntegration("Runtime integration tests", () => { }); expect(worktreeList.stdout).not.toContain(workspaceName); } finally { - await execBuffered(runtime, `rm -rf "${srcBaseDir}/${projectName}"`, { + await execBuffered(runtime, `rm -rf "${layout.projectRoot}"`, { cwd: "/home/testuser", timeout: 30, }); @@ -1550,7 +1564,8 @@ describeIntegration("Runtime integration tests", () => { const runtime = createSSHRuntime(); const projectName = `wt-del-legacy-${Date.now()}-${Math.random().toString(36).substring(7)}`; const projectPath = `/some/path/${projectName}`; - const workspacePath = `${srcBaseDir}/${projectName}/legacy-ws`; + const layout = getLayout(projectPath); + const workspacePath = getRemoteWorkspacePath(layout, "legacy-ws"); try { // Create a legacy workspace (standalone git clone, .git is a directory). @@ -1577,27 +1592,28 @@ describeIntegration("Runtime integration tests", () => { ); expect(afterCheck.stdout.trim()).toBe("missing"); } finally { - await execBuffered(runtime, `rm -rf "${srcBaseDir}/${projectName}"`, { + await execBuffered(runtime, `rm -rf "${layout.projectRoot}"`, { cwd: "/home/testuser", timeout: 30, }); } }, 60000); - test("renameWorkspace uses git worktree move for worktree-based workspaces", async () => { + test("renameWorkspace uses git worktree move and deleteWorkspace still cleans up the renamed branch", async () => { const runtime = createSSHRuntime(); const projectName = `wt-rename-${Date.now()}-${Math.random().toString(36).substring(7)}`; const projectPath = `/some/path/${projectName}`; - const baseRepoPath = `${srcBaseDir}/${projectName}/.mux-base.git`; - const oldWorkspacePath = `${srcBaseDir}/${projectName}/old-name`; - const newWorkspacePath = `${srcBaseDir}/${projectName}/new-name`; + const layout = getLayout(projectPath); + const baseRepoPath = layout.baseRepoPath; + const oldWorkspacePath = getRemoteWorkspacePath(layout, "old-name"); + const newWorkspacePath = getRemoteWorkspacePath(layout, "new-name"); try { // Set up bare base repo with a commit. await execBuffered( runtime, [ - `mkdir -p "${srcBaseDir}/${projectName}"`, + `mkdir -p "${layout.projectRoot}"`, `git init --bare "${baseRepoPath}"`, `TMPCLONE=$(mktemp -d)`, `git clone "${baseRepoPath}" "$TMPCLONE/work"`, @@ -1648,13 +1664,187 @@ describeIntegration("Runtime integration tests", () => { }); expect(worktreeList.stdout).toContain("/new-name"); expect(worktreeList.stdout).not.toContain("/old-name"); + + const deleteResult = await runtime.deleteWorkspace(projectPath, "new-name", true); + expect(deleteResult.success).toBe(true); + + const deletedPathCheck = await execBuffered( + runtime, + `test -d "${newWorkspacePath}" && echo "exists" || echo "missing"`, + { cwd: "/home/testuser", timeout: 30 } + ); + expect(deletedPathCheck.stdout.trim()).toBe("missing"); + + const branchCheck = await execBuffered( + runtime, + `git -C "${baseRepoPath}" branch --list old-name`, + { cwd: "/home/testuser", timeout: 30 } + ); + expect(branchCheck.stdout.trim()).toBe(""); } finally { - await execBuffered(runtime, `rm -rf "${srcBaseDir}/${projectName}"`, { + await execBuffered(runtime, `rm -rf "${layout.projectRoot}"`, { cwd: "/home/testuser", timeout: 30, }); } }, 60000); + + test("renameWorkspace and deleteWorkspace keep using the legacy base repo for upgraded SSH worktrees", async () => { + if (!sshConfig) { + throw new Error("SSH config unavailable"); + } + + const config = { + host: "testuser@localhost", + srcBaseDir, + identityFile: sshConfig.privateKeyPath, + port: sshConfig.port, + }; + const runtime = new SSHRuntime(config, createSSHTransport(config, false), { + projectPath: "/unused", + workspaceName: "unused", + }); + const projectName = `wt-legacy-rename-${Date.now()}-${Math.random().toString(36).substring(7)}`; + const projectPath = `/some/path/${projectName}`; + const legacyLayout = buildLegacyRemoteProjectLayout(srcBaseDir, projectPath); + const baseRepoPath = legacyLayout.baseRepoPath; + const oldWorkspacePath = getRemoteWorkspacePath(legacyLayout, "old-name"); + const newWorkspacePath = getRemoteWorkspacePath(legacyLayout, "new-name"); + const legacyRuntime = new SSHRuntime(config, createSSHTransport(config, false), { + projectPath, + workspaceName: "old-name", + workspacePath: oldWorkspacePath, + }); + + try { + await execBuffered( + runtime, + [ + `mkdir -p "${legacyLayout.projectRoot}"`, + `git init --bare "${baseRepoPath}"`, + `TMPCLONE=$(mktemp -d)`, + `git clone "${baseRepoPath}" "$TMPCLONE/work"`, + `cd "$TMPCLONE/work"`, + `git config user.email "test@test.com"`, + `git config user.name "Test"`, + `echo "x" > x.txt && git add x.txt && git commit -m "init"`, + `git push origin HEAD:main`, + `rm -rf "$TMPCLONE"`, + ].join(" && "), + { cwd: "/home/testuser", timeout: 30 } + ); + + await execBuffered( + runtime, + `git -C "${baseRepoPath}" worktree add "${oldWorkspacePath}" -b old-name main`, + { cwd: "/home/testuser", timeout: 30 } + ); + + const renameResult = await legacyRuntime.renameWorkspace( + projectPath, + "old-name", + "new-name" + ); + expect(renameResult.success).toBe(true); + if (!renameResult.success) return; + + const legacyWorktreeList = await execBuffered( + runtime, + `git -C "${baseRepoPath}" worktree list`, + { cwd: "/home/testuser", timeout: 30 } + ); + expect(legacyWorktreeList.stdout).toContain("/new-name"); + expect(legacyWorktreeList.stdout).not.toContain("/old-name"); + + const renamedLegacyRuntime = new SSHRuntime(config, createSSHTransport(config, false), { + projectPath, + workspaceName: "new-name", + workspacePath: newWorkspacePath, + }); + const deleteResult = await renamedLegacyRuntime.deleteWorkspace( + projectPath, + "new-name", + true + ); + expect(deleteResult.success).toBe(true); + + const deletedPathCheck = await execBuffered( + runtime, + `test -d "${newWorkspacePath}" && echo "exists" || echo "missing"`, + { cwd: "/home/testuser", timeout: 30 } + ); + expect(deletedPathCheck.stdout.trim()).toBe("missing"); + + const branchCheck = await execBuffered( + runtime, + `git -C "${baseRepoPath}" branch --list old-name`, + { cwd: "/home/testuser", timeout: 30 } + ); + expect(branchCheck.stdout.trim()).toBe(""); + } finally { + await execBuffered(runtime, `rm -rf "${legacyLayout.projectRoot}"`, { + cwd: "/home/testuser", + timeout: 30, + }); + } + }, 60000); + + test("exec handles a concurrent burst on one SSH host", async () => { + const runtime = createSSHRuntime(); + const projectName = `ssh-burst-${Date.now()}-${Math.random().toString(36).substring(7)}`; + const tmpDir = await import("os").then((os) => os.tmpdir()); + const localProjectPath = `${tmpDir}/${projectName}`; + const layout = getLayout(localProjectPath); + const workspaceName = "burst-ws"; + const workspacePath = getRemoteWorkspacePath(layout, workspaceName); + const { execSync } = await import("child_process"); + + try { + execSync( + [ + `mkdir -p "${localProjectPath}"`, + `cd "${localProjectPath}"`, + `git init -b main`, + `git config user.email "test@test.com"`, + `git config user.name "Test"`, + `echo "content" > file.txt`, + `git add file.txt`, + `git commit -m "initial"`, + ].join(" && "), + { stdio: "pipe" } + ); + + const initResult = await runtime.initWorkspace({ + projectPath: localProjectPath, + branchName: workspaceName, + trunkBranch: "main", + workspacePath, + initLogger: noopInitLogger, + }); + if (!initResult.success) { + throw new Error(`initWorkspace failed: ${initResult.error}`); + } + + const results = await Promise.all( + Array.from({ length: 12 }, async (_value, index) => { + const result = await execBuffered( + runtime, + `printf '%s' ${JSON.stringify(String(index))} > burst-${index}.txt && cat burst-${index}.txt`, + { cwd: workspacePath, timeout: 30 } + ); + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toBe(String(index)); + }) + ); + expect(results).toHaveLength(12); + } finally { + execSync(`rm -rf "${localProjectPath}"`); + await execBuffered(runtime, `rm -rf "${layout.projectRoot}"`, { + cwd: "/home/testuser", + timeout: 30, + }); + } + }, 120000); }); /** @@ -1673,14 +1863,13 @@ describeIntegration("Runtime integration tests", () => { test("initWorkspace does not populate refs/remotes/origin in the base repo from the bundle", async () => { const runtime = createSSHRuntime(); - // projectName must match the basename of the local project path so that - // getBaseRepoPath(localProjectPath) resolves to the expected baseRepoPath. const projectName = `sync-no-remotes-${Date.now()}-${Math.random().toString(36).substring(7)}`; const tmpDir = await import("os").then((os) => os.tmpdir()); const localProjectPath = `${tmpDir}/${projectName}`; + const layout = buildRemoteProjectLayout(srcBaseDir, localProjectPath); const branchName = "test-ws"; - const workspacePath = `${srcBaseDir}/${projectName}/${branchName}`; - const baseRepoPath = `${srcBaseDir}/${projectName}/.mux-base.git`; + const workspacePath = getRemoteWorkspacePath(layout, branchName); + const baseRepoPath = layout.baseRepoPath; const { execSync } = await import("child_process"); try { @@ -1742,7 +1931,7 @@ describeIntegration("Runtime integration tests", () => { expect(baseRefs.stdout).not.toContain("refs/remotes/origin/main"); expect(baseRefs.stdout).not.toContain("refs/remotes/origin/stale-branch"); } finally { - await execBuffered(runtime, `rm -rf "${srcBaseDir}/${projectName}"`, { + await execBuffered(runtime, `rm -rf "${layout.projectRoot}"`, { cwd: "/home/testuser", timeout: 30, }); @@ -1758,9 +1947,10 @@ describeIntegration("Runtime integration tests", () => { const projectName = `sync-heal-bare-${Date.now()}-${Math.random().toString(36).substring(7)}`; const tmpDir = await import("os").then((os) => os.tmpdir()); const localProjectPath = `${tmpDir}/${projectName}`; + const layout = buildRemoteProjectLayout(srcBaseDir, localProjectPath); const branchName = "worktree-heal"; - const workspacePath = `${srcBaseDir}/${projectName}/${branchName}`; - const baseRepoPath = `${srcBaseDir}/${projectName}/.mux-base.git`; + const workspacePath = getRemoteWorkspacePath(layout, branchName); + const baseRepoPath = layout.baseRepoPath; const { execSync } = await import("child_process"); try { @@ -1780,7 +1970,7 @@ describeIntegration("Runtime integration tests", () => { await execBuffered( runtime, - `mkdir -p "${srcBaseDir}/${projectName}" && git init --bare "${baseRepoPath}"`, + `mkdir -p "${layout.projectRoot}" && git init --bare "${baseRepoPath}"`, { cwd: "/home/testuser", timeout: 30 } ); @@ -1824,7 +2014,192 @@ describeIntegration("Runtime integration tests", () => { expect(workspaceCoreBareCheck.exitCode).toBe(1); } finally { execSync(`rm -rf "${localProjectPath}"`); - await execBuffered(runtime, `rm -rf "${srcBaseDir}/${projectName}"`, { + await execBuffered(runtime, `rm -rf "${layout.projectRoot}"`, { + cwd: "/home/testuser", + timeout: 30, + }); + } + }, 120000); + + test("initWorkspace reimports when the snapshot marker outlives the base repo", async () => { + const runtime = createSSHRuntime(); + + const projectName = `sync-heal-marker-${Date.now()}-${Math.random().toString(36).substring(7)}`; + const tmpDir = await import("os").then((os) => os.tmpdir()); + const localProjectPath = `${tmpDir}/${projectName}`; + const layout = buildRemoteProjectLayout(srcBaseDir, localProjectPath); + const firstWorkspaceName = "marker-a"; + const secondWorkspaceName = "marker-b"; + const firstWorkspacePath = getRemoteWorkspacePath(layout, firstWorkspaceName); + const secondWorkspacePath = getRemoteWorkspacePath(layout, secondWorkspaceName); + const baseRepoPath = layout.baseRepoPath; + + const { execSync } = await import("child_process"); + try { + execSync( + [ + `mkdir -p "${localProjectPath}"`, + `cd "${localProjectPath}"`, + `git init -b main`, + `git config user.email "test@test.com"`, + `git config user.name "Test"`, + `echo "content" > file.txt`, + `git add file.txt`, + `git commit -m "initial"`, + ].join(" && "), + { stdio: "pipe" } + ); + + const firstInit = await runtime.initWorkspace({ + projectPath: localProjectPath, + branchName: firstWorkspaceName, + trunkBranch: "main", + workspacePath: firstWorkspacePath, + initLogger: noopInitLogger, + }); + if (!firstInit.success) { + throw new Error(`first initWorkspace failed: ${firstInit.error}`); + } + + const snapshotMarkerCheck = await execBuffered( + runtime, + `test -f "${layout.currentSnapshotPath}" && cat "${layout.currentSnapshotPath}"`, + { cwd: "/home/testuser", timeout: 30 } + ); + expect(snapshotMarkerCheck.stdout.trim()).not.toBe(""); + + await execBuffered( + runtime, + `rm -rf "${baseRepoPath}" && git init --bare "${baseRepoPath}"`, + { cwd: "/home/testuser", timeout: 30 } + ); + + const secondInit = await runtime.initWorkspace({ + projectPath: localProjectPath, + branchName: secondWorkspaceName, + trunkBranch: "main", + workspacePath: secondWorkspacePath, + initLogger: noopInitLogger, + }); + if (!secondInit.success) { + throw new Error(`second initWorkspace failed: ${secondInit.error}`); + } + + const baseRefs = await execBuffered( + runtime, + `git -C "${baseRepoPath}" for-each-ref --format='%(refname)' refs/mux-bundle/`, + { cwd: "/home/testuser", timeout: 30 } + ); + expect(baseRefs.stdout).toContain("refs/mux-bundle/main"); + + const insideWorkTreeCheck = await execBuffered( + runtime, + `git -C "${secondWorkspacePath}" rev-parse --is-inside-work-tree`, + { cwd: "/home/testuser", timeout: 30 } + ); + expect(insideWorkTreeCheck.stdout.trim()).toBe("true"); + } finally { + execSync(`rm -rf "${localProjectPath}"`); + await execBuffered(runtime, `rm -rf "${layout.projectRoot}"`, { + cwd: "/home/testuser", + timeout: 30, + }); + } + }, 120000); + + test("initWorkspace reimports when an older snapshot marker exists but bundle refs were advanced", async () => { + const runtime = createSSHRuntime(); + + const projectName = `sync-heal-history-${Date.now()}-${Math.random().toString(36).substring(7)}`; + const tmpDir = await import("os").then((os) => os.tmpdir()); + const localProjectPath = `${tmpDir}/${projectName}`; + const layout = buildRemoteProjectLayout(srcBaseDir, localProjectPath); + const firstWorkspacePath = getRemoteWorkspacePath(layout, "history-a"); + const secondWorkspacePath = getRemoteWorkspacePath(layout, "history-b"); + const thirdWorkspacePath = getRemoteWorkspacePath(layout, "history-c"); + const baseRepoPath = layout.baseRepoPath; + + const { execSync } = await import("child_process"); + try { + execSync( + [ + `mkdir -p "${localProjectPath}"`, + `cd "${localProjectPath}"`, + `git init -b main`, + `git config user.email "test@test.com"`, + `git config user.name "Test"`, + `echo "version-a" > file.txt`, + `git add file.txt`, + `git commit -m "initial"`, + ].join(" && "), + { stdio: "pipe" } + ); + const firstCommit = execSync(`git -C "${localProjectPath}" rev-parse HEAD`, { + encoding: "utf8", + }).trim(); + + const firstInit = await runtime.initWorkspace({ + projectPath: localProjectPath, + branchName: "history-a", + trunkBranch: "main", + workspacePath: firstWorkspacePath, + initLogger: noopInitLogger, + }); + if (!firstInit.success) { + throw new Error(`first initWorkspace failed: ${firstInit.error}`); + } + + execSync( + [ + `cd "${localProjectPath}"`, + `echo "version-b" > file.txt`, + `git add file.txt`, + `git commit -m "second"`, + ].join(" && "), + { stdio: "pipe" } + ); + + const secondInit = await runtime.initWorkspace({ + projectPath: localProjectPath, + branchName: "history-b", + trunkBranch: "main", + workspacePath: secondWorkspacePath, + initLogger: noopInitLogger, + }); + if (!secondInit.success) { + throw new Error(`second initWorkspace failed: ${secondInit.error}`); + } + + execSync(`git -C "${localProjectPath}" reset --hard ${firstCommit}`, { + stdio: "pipe", + }); + + const thirdInit = await runtime.initWorkspace({ + projectPath: localProjectPath, + branchName: "history-c", + trunkBranch: "main", + workspacePath: thirdWorkspacePath, + initLogger: noopInitLogger, + }); + if (!thirdInit.success) { + throw new Error(`third initWorkspace failed: ${thirdInit.error}`); + } + + const fileCheck = await execBuffered(runtime, `cat "${thirdWorkspacePath}/file.txt"`, { + cwd: "/home/testuser", + timeout: 30, + }); + expect(fileCheck.stdout.trim()).toBe("version-a"); + + const baseHead = await execBuffered( + runtime, + `git -C "${baseRepoPath}" rev-parse refs/mux-bundle/main`, + { cwd: "/home/testuser", timeout: 30 } + ); + expect(baseHead.stdout.trim()).toBe(firstCommit); + } finally { + execSync(`rm -rf "${localProjectPath}"`); + await execBuffered(runtime, `rm -rf "${layout.projectRoot}"`, { cwd: "/home/testuser", timeout: 30, }); @@ -1852,9 +2227,10 @@ describeIntegration("Runtime integration tests", () => { const localProjectPath = `${tmpDir}/${projectName}`; const wsAName = "ws-a"; const wsBName = "ws-b"; - const wsAPath = `${srcBaseDir}/${projectName}/${wsAName}`; - const wsBPath = `${srcBaseDir}/${projectName}/${wsBName}`; - const baseRepoPath = `${srcBaseDir}/${projectName}/.mux-base.git`; + const layout = buildRemoteProjectLayout(srcBaseDir, localProjectPath); + const wsAPath = getRemoteWorkspacePath(layout, wsAName); + const wsBPath = getRemoteWorkspacePath(layout, wsBName); + const baseRepoPath = layout.baseRepoPath; const { execSync } = await import("child_process"); @@ -1958,7 +2334,7 @@ describeIntegration("Runtime integration tests", () => { expect(worktreeList.stdout).toContain(wsBName); } finally { execSync(`rm -rf "${localProjectPath}"`); - await execBuffered(runtime, `rm -rf "${srcBaseDir}/${projectName}"`, { + await execBuffered(runtime, `rm -rf "${layout.projectRoot}"`, { cwd: "/home/testuser", timeout: 30, }); @@ -2291,6 +2667,7 @@ describeIntegration("Runtime integration tests", () => { */ describe("CoderSSHRuntime workspace operations", () => { const srcBaseDir = "/home/testuser/src"; + const getLayout = (projectPath: string) => buildRemoteProjectLayout(srcBaseDir, projectPath); // Create a CoderSSHRuntime with mock CoderService const createCoderSSHRuntime = async () => { @@ -2326,7 +2703,8 @@ describeIntegration("Runtime integration tests", () => { const sourceWorkspaceName = "source"; const newWorkspaceName = "forked"; - const sourceWorkspacePath = `${srcBaseDir}/${projectName}/${sourceWorkspaceName}`; + const layout = getLayout(projectPath); + const sourceWorkspacePath = getRemoteWorkspacePath(layout, sourceWorkspaceName); // Create a source workspace repo await execBuffered( @@ -2415,8 +2793,9 @@ describeIntegration("Runtime integration tests", () => { const projectPath = `/some/path/${projectName}`; const sourceWorkspaceName = "source"; const newWorkspaceName = "forked"; - const sourceWorkspacePath = `${srcBaseDir}/${projectName}/${sourceWorkspaceName}`; - const forkedWorkspacePath = `${srcBaseDir}/${projectName}/${newWorkspaceName}`; + const layout = getLayout(projectPath); + const sourceWorkspacePath = getRemoteWorkspacePath(layout, sourceWorkspaceName); + const forkedWorkspacePath = getRemoteWorkspacePath(layout, newWorkspaceName); // Create a source workspace repo await execBuffered(