diff --git a/src/node/runtime/CoderSSHRuntime.ts b/src/node/runtime/CoderSSHRuntime.ts index b7ee785957..14ffe08a51 100644 --- a/src/node/runtime/CoderSSHRuntime.ts +++ b/src/node/runtime/CoderSSHRuntime.ts @@ -91,7 +91,15 @@ export class CoderSSHRuntime extends SSHRuntime { configLevelCollisionDetection: true, }; - constructor(config: CoderSSHRuntimeConfig, transport: SSHTransport, coderService: CoderService) { + constructor( + config: CoderSSHRuntimeConfig, + transport: SSHTransport, + coderService: CoderService, + options?: { + projectPath?: string; + workspaceName?: string; + } + ) { if (!config || !coderService || !transport) { throw new Error("CoderSSHRuntime requires config, transport, and coderService"); } @@ -104,7 +112,7 @@ export class CoderSSHRuntime extends SSHRuntime { port: config.port, }; - super(baseConfig, transport); + super(baseConfig, transport, options); this.coderConfig = config.coder; this.coderService = coderService; } @@ -198,6 +206,12 @@ export class CoderSSHRuntime extends SSHRuntime { // Short-circuit: already running if (statusResult.kind === "ok" && statusResult.status === "running") { + const repoCheck = await this.checkWorkspaceRepo(options); + if (repoCheck && !repoCheck.ready) { + emitStatus("error", repoCheck.error); + return repoCheck; + } + this.lastActivityAtMs = Date.now(); emitStatus("ready"); return { ready: true }; @@ -251,6 +265,13 @@ export class CoderSSHRuntime extends SSHRuntime { // Check for state changes during polling if (statusResult.kind === "ok" && statusResult.status === "running") { + // Ensure setup failures (missing repo) surface before marking ready. + const repoCheck = await this.checkWorkspaceRepo(options); + if (repoCheck && !repoCheck.ready) { + emitStatus("error", repoCheck.error); + return repoCheck; + } + this.lastActivityAtMs = Date.now(); emitStatus("ready"); return { ready: true }; @@ -301,6 +322,13 @@ export class CoderSSHRuntime extends SSHRuntime { )) { // Consume output for timeout/abort handling } + + const repoCheck = await this.checkWorkspaceRepo(options); + if (repoCheck && !repoCheck.ready) { + emitStatus("error", repoCheck.error); + return repoCheck; + } + this.lastActivityAtMs = Date.now(); emitStatus("ready"); return { ready: true }; diff --git a/src/node/runtime/DevcontainerRuntime.ts b/src/node/runtime/DevcontainerRuntime.ts index 927ef9121e..35cd5348cf 100644 --- a/src/node/runtime/DevcontainerRuntime.ts +++ b/src/node/runtime/DevcontainerRuntime.ts @@ -15,7 +15,7 @@ import type { EnsureReadyOptions, FileStat, } from "./Runtime"; -import { RuntimeError } from "./Runtime"; +import { RuntimeError, WORKSPACE_REPO_MISSING_ERROR } from "./Runtime"; import { LocalBaseRuntime } from "./LocalBaseRuntime"; import { WorktreeManager } from "@/node/worktree/WorktreeManager"; import { expandTildeForSSH } from "./tildeExpansion"; @@ -33,7 +33,7 @@ import { EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT } from "@/common/constants/exitCod import { NON_INTERACTIVE_ENV_VARS } from "@/common/constants/env"; import { getErrorMessage } from "@/common/utils/errors"; import { log } from "@/node/services/log"; -import { stripTrailingSlashes } from "@/node/utils/pathUtils"; +import { isGitRepository, stripTrailingSlashes } from "@/node/utils/pathUtils"; export interface DevcontainerRuntimeOptions { srcBaseDir: string; @@ -694,7 +694,25 @@ export class DevcontainerRuntime extends LocalBaseRuntime { } const statusSink = options?.statusSink; - statusSink?.({ phase: "checking", runtimeType: "devcontainer" }); + statusSink?.({ + phase: "checking", + runtimeType: "devcontainer", + detail: "Checking repository...", + }); + + const hasRepo = await isGitRepository(this.currentWorkspacePath); + if (!hasRepo) { + statusSink?.({ + phase: "error", + runtimeType: "devcontainer", + detail: WORKSPACE_REPO_MISSING_ERROR, + }); + return { + ready: false, + error: WORKSPACE_REPO_MISSING_ERROR, + errorType: "runtime_not_ready", + }; + } try { statusSink?.({ diff --git a/src/node/runtime/LocalRuntime.test.ts b/src/node/runtime/LocalRuntime.test.ts index bfb74209bf..5abe3d0f51 100644 --- a/src/node/runtime/LocalRuntime.test.ts +++ b/src/node/runtime/LocalRuntime.test.ts @@ -3,7 +3,7 @@ import * as os from "os"; import * as path from "path"; import * as fs from "fs/promises"; import { LocalRuntime } from "./LocalRuntime"; -import type { InitLogger } from "./Runtime"; +import type { InitLogger, RuntimeStatusEvent } from "./Runtime"; // Minimal mock logger - matches pattern in initHook.test.ts function createMockLogger(): InitLogger & { steps: string[] } { @@ -51,6 +51,21 @@ describe("LocalRuntime", () => { }); }); + describe("ensureReady", () => { + it("allows non-git project directories to be ready", async () => { + const runtime = new LocalRuntime(testDir); + const events: RuntimeStatusEvent[] = []; + + const result = await runtime.ensureReady({ + statusSink: (event) => events.push(event), + }); + + expect(result).toEqual({ ready: true }); + expect(events[0]).toMatchObject({ phase: "checking", runtimeType: "local" }); + expect(events[events.length - 1]).toMatchObject({ phase: "ready", runtimeType: "local" }); + }); + }); + describe("createWorkspace", () => { it("succeeds when directory exists", async () => { const runtime = new LocalRuntime(testDir); diff --git a/src/node/runtime/LocalRuntime.ts b/src/node/runtime/LocalRuntime.ts index 0d58538a90..5a6ba2c5c0 100644 --- a/src/node/runtime/LocalRuntime.ts +++ b/src/node/runtime/LocalRuntime.ts @@ -1,4 +1,6 @@ import type { + EnsureReadyOptions, + EnsureReadyResult, WorkspaceCreationParams, WorkspaceCreationResult, WorkspaceInitParams, @@ -38,6 +40,20 @@ export class LocalRuntime extends LocalBaseRuntime { return this.projectPath; } + override ensureReady(options?: EnsureReadyOptions): Promise { + const statusSink = options?.statusSink; + statusSink?.({ + phase: "checking", + runtimeType: "local", + detail: "Checking repository...", + }); + + // Non-git projects are explicitly supported for LocalRuntime; avoid blocking readiness + // on missing .git so local-only workflows continue to work. + statusSink?.({ phase: "ready", runtimeType: "local" }); + return Promise.resolve({ ready: true }); + } + /** * Creating a workspace is a no-op for LocalRuntime since we use the project directory directly. * We just verify the directory exists. diff --git a/src/node/runtime/Runtime.ts b/src/node/runtime/Runtime.ts index 6793104fe8..1679f2d354 100644 --- a/src/node/runtime/Runtime.ts +++ b/src/node/runtime/Runtime.ts @@ -326,6 +326,11 @@ export type EnsureReadyResult = errorType: "runtime_not_ready" | "runtime_start_failed"; }; +/** + * Shared error message for missing repositories during runtime readiness checks. + */ +export const WORKSPACE_REPO_MISSING_ERROR = "Workspace setup incomplete: repository not found."; + /** * Runtime interface - minimal, low-level abstraction for tool execution environments. * diff --git a/src/node/runtime/SSHRuntime.test.ts b/src/node/runtime/SSHRuntime.test.ts index 290951e3af..9e5d297cfa 100644 --- a/src/node/runtime/SSHRuntime.test.ts +++ b/src/node/runtime/SSHRuntime.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, beforeEach, afterEach, spyOn } from "bun:test"; +import * as runtimeHelpers from "@/node/utils/runtime/helpers"; import { SSHRuntime } from "./SSHRuntime"; import { createSSHTransport } from "./transports"; @@ -32,3 +33,70 @@ describe("SSHRuntime constructor", () => { }).not.toThrow(); }); }); + +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 }); + + const result = await runtime.ensureReady(); + + expect(execBufferedSpy).toHaveBeenCalledTimes(2); + const firstCommand = execBufferedSpy?.mock.calls[0]?.[1]; + expect(firstCommand).toContain("test -d"); + expect(firstCommand).toContain("test -f"); + expect(result).toEqual({ ready: true }); + }); + + 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"); + } + }); +}); diff --git a/src/node/runtime/SSHRuntime.ts b/src/node/runtime/SSHRuntime.ts index 246e766230..60eed5a373 100644 --- a/src/node/runtime/SSHRuntime.ts +++ b/src/node/runtime/SSHRuntime.ts @@ -18,6 +18,8 @@ import { spawn, type ChildProcess } from "child_process"; import * as path from "path"; import type { + EnsureReadyOptions, + EnsureReadyResult, ExecOptions, WorkspaceCreationParams, WorkspaceCreationResult, @@ -27,6 +29,7 @@ import type { WorkspaceForkResult, InitLogger, } from "./Runtime"; +import { WORKSPACE_REPO_MISSING_ERROR } from "./Runtime"; import { RemoteRuntime, type SpawnResult } from "./RemoteRuntime"; import { log } from "@/node/services/log"; import { checkInitHookExists, getMuxEnv, runInitHookOnRuntime } from "./initHook"; @@ -142,15 +145,26 @@ export type { SSHRuntimeConfig } from "./sshConnectionPool"; export class SSHRuntime extends RemoteRuntime { private readonly config: SSHRuntimeConfig; private readonly transport: SSHTransport; + private readonly ensureReadyProjectPath?: string; + private readonly ensureReadyWorkspaceName?: string; /** Cached resolved bgOutputDir (tilde expanded to absolute path) */ private resolvedBgOutputDir: string | null = null; - constructor(config: SSHRuntimeConfig, transport: SSHTransport) { + constructor( + config: SSHRuntimeConfig, + transport: SSHTransport, + options?: { + projectPath?: string; + workspaceName?: string; + } + ) { super(); // Note: srcBaseDir may contain tildes - they will be resolved via resolvePath() before use // The WORKSPACE_CREATE IPC handler resolves paths before storing in config this.config = config; this.transport = transport; + this.ensureReadyProjectPath = options?.projectPath; + this.ensureReadyWorkspaceName = options?.workspaceName; } /** @@ -292,6 +306,129 @@ export class SSHRuntime extends RemoteRuntime { return path.posix.join(this.config.srcBaseDir, projectName, workspaceName); } + override async ensureReady(options?: EnsureReadyOptions): Promise { + const repoCheck = await this.checkWorkspaceRepo(options); + if (repoCheck) { + if (!repoCheck.ready) { + options?.statusSink?.({ + phase: "error", + runtimeType: "ssh", + detail: repoCheck.error, + }); + return repoCheck; + } + + options?.statusSink?.({ phase: "ready", runtimeType: "ssh" }); + return { ready: true }; + } + + return { ready: true }; + } + + protected async checkWorkspaceRepo( + options?: EnsureReadyOptions + ): Promise { + if (!this.ensureReadyProjectPath || !this.ensureReadyWorkspaceName) { + return null; + } + + const statusSink = options?.statusSink; + statusSink?.({ + phase: "checking", + runtimeType: "ssh", + detail: "Checking repository...", + }); + + if (options?.signal?.aborted) { + return { ready: false, error: "Aborted", errorType: "runtime_start_failed" }; + } + + const workspacePath = this.getWorkspacePath( + this.ensureReadyProjectPath, + this.ensureReadyWorkspaceName + ); + const gitDir = path.posix.join(workspacePath, ".git"); + const gitDirProbe = this.quoteForRemote(gitDir); + + let testResult: { exitCode: number; stderr: string }; + try { + // .git is a file for worktrees; accept either file or directory so existing SSH/Coder + // worktree checkouts don't get flagged as setup failures. + testResult = await execBuffered(this, `test -d ${gitDirProbe} || test -f ${gitDirProbe}`, { + cwd: "~", + timeout: 10, + abortSignal: options?.signal, + }); + } catch (error) { + return { + ready: false, + error: `Failed to reach SSH host: ${getErrorMessage(error)}`, + errorType: "runtime_start_failed", + }; + } + + if (testResult.exitCode !== 0) { + if (this.transport.isConnectionFailure(testResult.exitCode, testResult.stderr)) { + return { + ready: false, + error: `Failed to reach SSH host: ${testResult.stderr || "connection failure"}`, + errorType: "runtime_start_failed", + }; + } + + return { + ready: false, + error: WORKSPACE_REPO_MISSING_ERROR, + errorType: "runtime_not_ready", + }; + } + + let revResult: { exitCode: number; stderr: string; stdout: string }; + try { + revResult = await execBuffered( + this, + `git -C ${this.quoteForRemote(workspacePath)} rev-parse --git-dir`, + { + cwd: "~", + timeout: 10, + abortSignal: options?.signal, + } + ); + } catch (error) { + return { + ready: false, + error: `Failed to verify repository: ${getErrorMessage(error)}`, + errorType: "runtime_start_failed", + }; + } + + if (revResult.exitCode !== 0) { + const stderr = revResult.stderr.trim(); + const stdout = revResult.stdout.trim(); + const errorDetail = stderr || stdout || "git unavailable"; + const isCommandMissing = + revResult.exitCode === 127 || /command not found/i.test(stderr || stdout); + if ( + isCommandMissing || + this.transport.isConnectionFailure(revResult.exitCode, revResult.stderr) + ) { + return { + ready: false, + error: `Failed to verify repository: ${errorDetail}`, + errorType: "runtime_start_failed", + }; + } + + return { + ready: false, + error: WORKSPACE_REPO_MISSING_ERROR, + errorType: "runtime_not_ready", + }; + } + + return { ready: true }; + } + /** * Sync project to remote using git bundle * diff --git a/src/node/runtime/WorktreeRuntime.ts b/src/node/runtime/WorktreeRuntime.ts index aff9f667c5..66d9d39b66 100644 --- a/src/node/runtime/WorktreeRuntime.ts +++ b/src/node/runtime/WorktreeRuntime.ts @@ -1,4 +1,6 @@ import type { + EnsureReadyOptions, + EnsureReadyResult, WorkspaceCreationParams, WorkspaceCreationResult, WorkspaceInitParams, @@ -6,9 +8,11 @@ import type { WorkspaceForkParams, WorkspaceForkResult, } from "./Runtime"; +import { WORKSPACE_REPO_MISSING_ERROR } from "./Runtime"; import { checkInitHookExists, getMuxEnv } from "./initHook"; import { LocalBaseRuntime } from "./LocalBaseRuntime"; import { getErrorMessage } from "@/common/utils/errors"; +import { isGitRepository } from "@/node/utils/pathUtils"; import { WorktreeManager } from "@/node/worktree/WorktreeManager"; /** @@ -21,16 +25,57 @@ import { WorktreeManager } from "@/node/worktree/WorktreeManager"; */ export class WorktreeRuntime extends LocalBaseRuntime { private readonly worktreeManager: WorktreeManager; + private readonly currentProjectPath?: string; + private readonly currentWorkspaceName?: string; - constructor(srcBaseDir: string) { + constructor( + srcBaseDir: string, + options?: { + projectPath?: string; + workspaceName?: string; + } + ) { super(); this.worktreeManager = new WorktreeManager(srcBaseDir); + this.currentProjectPath = options?.projectPath; + this.currentWorkspaceName = options?.workspaceName; } getWorkspacePath(projectPath: string, workspaceName: string): string { return this.worktreeManager.getWorkspacePath(projectPath, workspaceName); } + override async ensureReady(options?: EnsureReadyOptions): Promise { + if (!this.currentProjectPath || !this.currentWorkspaceName) { + return { ready: true }; + } + + const statusSink = options?.statusSink; + statusSink?.({ + phase: "checking", + runtimeType: "worktree", + detail: "Checking repository...", + }); + + const workspacePath = this.getWorkspacePath(this.currentProjectPath, this.currentWorkspaceName); + const hasRepo = await isGitRepository(workspacePath); + if (!hasRepo) { + statusSink?.({ + phase: "error", + runtimeType: "worktree", + detail: WORKSPACE_REPO_MISSING_ERROR, + }); + return { + ready: false, + error: WORKSPACE_REPO_MISSING_ERROR, + errorType: "runtime_not_ready", + }; + } + + statusSink?.({ phase: "ready", runtimeType: "worktree" }); + return { ready: true }; + } + async createWorkspace(params: WorkspaceCreationParams): Promise { return this.worktreeManager.createWorkspace({ projectPath: params.projectPath, diff --git a/src/node/runtime/runtimeFactory.ts b/src/node/runtime/runtimeFactory.ts index d8518374f1..02e7ed5e2f 100644 --- a/src/node/runtime/runtimeFactory.ts +++ b/src/node/runtime/runtimeFactory.ts @@ -138,7 +138,10 @@ export function createRuntime(config: RuntimeConfig, options?: CreateRuntimeOpti // or new "local" without srcBaseDir (= project-dir semantics) if (hasSrcBaseDir(config)) { // Legacy: "local" with srcBaseDir is treated as worktree - return new WorktreeRuntime(config.srcBaseDir); + return new WorktreeRuntime(config.srcBaseDir, { + projectPath: options?.projectPath, + workspaceName: options?.workspaceName, + }); } // Project-dir: uses project path directly, no isolation if (!options?.projectPath) { @@ -149,7 +152,10 @@ export function createRuntime(config: RuntimeConfig, options?: CreateRuntimeOpti return new LocalRuntime(options.projectPath); case "worktree": - return new WorktreeRuntime(config.srcBaseDir); + return new WorktreeRuntime(config.srcBaseDir, { + projectPath: options?.projectPath, + workspaceName: options?.workspaceName, + }); case "ssh": { const sshConfig = { @@ -170,10 +176,16 @@ export function createRuntime(config: RuntimeConfig, options?: CreateRuntimeOpti if (!coderService) { throw new Error("Coder runtime requested but CoderService is not initialized"); } - return new CoderSSHRuntime({ ...sshConfig, coder: config.coder }, transport, coderService); + return new CoderSSHRuntime({ ...sshConfig, coder: config.coder }, transport, coderService, { + projectPath: options?.projectPath, + workspaceName: options?.workspaceName, + }); } - return new SSHRuntime(sshConfig, transport); + return new SSHRuntime(sshConfig, transport, { + projectPath: options?.projectPath, + workspaceName: options?.workspaceName, + }); } case "docker": {