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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions src/node/runtime/CoderSSHRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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 };
Expand Down
24 changes: 21 additions & 3 deletions src/node/runtime/DevcontainerRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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?.({
Expand Down
17 changes: 16 additions & 1 deletion src/node/runtime/LocalRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] } {
Expand Down Expand Up @@ -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);
Expand Down
16 changes: 16 additions & 0 deletions src/node/runtime/LocalRuntime.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type {
EnsureReadyOptions,
EnsureReadyResult,
WorkspaceCreationParams,
WorkspaceCreationResult,
WorkspaceInitParams,
Expand Down Expand Up @@ -38,6 +40,20 @@ export class LocalRuntime extends LocalBaseRuntime {
return this.projectPath;
}

override ensureReady(options?: EnsureReadyOptions): Promise<EnsureReadyResult> {
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.
Expand Down
5 changes: 5 additions & 0 deletions src/node/runtime/Runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
70 changes: 69 additions & 1 deletion src/node/runtime/SSHRuntime.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -32,3 +33,70 @@ describe("SSHRuntime constructor", () => {
}).not.toThrow();
});
});

describe("SSHRuntime.ensureReady repository checks", () => {
let execBufferedSpy: ReturnType<typeof spyOn<typeof runtimeHelpers, "execBuffered">> | 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");
}
});
});
Loading
Loading