Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
33f8d57
πŸ€– perf: make SSH fork instant by moving copy to init phase
ammar-agent Nov 10, 2025
ddcc250
Make runInitHook private - not needed in public interface
ammar-agent Nov 10, 2025
f9d10eb
Refactor fork tests to minimize nesting
ammar-agent Nov 10, 2025
5b40d3d
fix: expand tilde paths in SSH fork operations
ammar-agent Nov 10, 2025
177618e
πŸ€– test: fix fork init blocking test + prevent fork during init
ammar-agent Nov 10, 2025
d317baf
πŸ€– fix: lint errors - remove unused imports and use optional chaining
ammar-agent Nov 10, 2025
1e5e623
πŸ€– fix: add provider setup to sendMessageAndWait helper
ammar-agent Nov 10, 2025
ae57a51
πŸ€– fix: add skipProviderSetup option for testing error cases
ammar-agent Nov 10, 2025
c6952da
πŸ€– fix: add missing sendMessage import in initWorkspace test
ammar-agent Nov 10, 2025
cb42c20
πŸ€– refactor: simplify changes to reduce complexity
ammar-agent Nov 10, 2025
01d954c
πŸ€– fix: clear event array in sendMessageAndWait to prevent test pollution
ammar-agent Nov 11, 2025
5c9275c
πŸ€– refactor: centralize duplicated test constants
ammar-agent Nov 12, 2025
8649a7f
πŸ€– refactor: remove unnecessary branch detection from SSH fork
ammar-agent Nov 12, 2025
ca1f470
πŸ€– fix: remove unused expandedSourcePath variable
ammar-agent Nov 12, 2025
1da01cb
πŸ€– fix: preserve source runtime config when forking workspaces
ammar-agent Nov 13, 2025
3223650
πŸ€– refactor: simplify fork tests to 5 orthogonal cases
ammar-agent Nov 13, 2025
9cd6b0b
πŸ€– fix: remove unused checkInitHookExists import
ammar-agent Nov 13, 2025
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
1 change: 1 addition & 0 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ This project uses **Make** as the primary build orchestrator. See `Makefile` for
- ❌ **Bad**: `expect(isValid("foo")).toBe(true)` for every valid value - Duplicates implementation
- βœ… **Good**: `expect(isValid("invalid")).toBe(false)` - Tests boundary/error cases
- **Rule of thumb**: If changing the implementation requires changing the test in the same way, the test is probably useless
- **Avoid requiring manual setup in tests** - If every test needs the same initialization (provider setup, config, etc.), move that logic into the test helper functions. Tests should call one function and get a working environment, not repeat boilerplate setup steps.
- Strive to decompose complex logic away from the components and into `.src/utils/`
- utils should be either pure functions or easily isolated (e.g. if they operate on the FS they accept
a path). Testing them should not require complex mocks or setup.
Expand Down
5 changes: 5 additions & 0 deletions src/constants/ipc-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,8 @@ export const IPC_CHANNELS = {
// Helper functions for dynamic channels
export const getChatChannel = (workspaceId: string): string =>
`${IPC_CHANNELS.WORKSPACE_CHAT_PREFIX}${workspaceId}`;

// Event type constants for workspace init events
export const EVENT_TYPE_PREFIX_INIT = "init-";
export const EVENT_TYPE_INIT_OUTPUT = "init-output" as const;
export const EVENT_TYPE_INIT_END = "init-end" as const;
9 changes: 9 additions & 0 deletions src/runtime/LocalRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,9 @@ export class LocalRuntime implements Runtime {
const { projectPath, workspacePath, initLogger } = params;

try {
// Note: sourceWorkspacePath is only used by SSH runtime (to copy workspace)
// Local runtime creates git worktrees which are instant, so we don't need it here

// Run .cmux/init hook if it exists
// Note: runInitHook calls logComplete() internally if hook exists
const hookExists = await checkInitHookExists(projectPath);
Expand Down Expand Up @@ -588,7 +591,10 @@ export class LocalRuntime implements Runtime {
};
}

initLogger.logStep(`Detected source branch: ${sourceBranch}`);

// Use createWorkspace with sourceBranch as trunk to fork from source branch
// For local workspaces (worktrees), this is instant - no init needed
const createResult = await this.createWorkspace({
projectPath,
branchName: newWorkspaceName,
Expand All @@ -604,9 +610,12 @@ export class LocalRuntime implements Runtime {
};
}

initLogger.logStep("Workspace forked successfully");

return {
success: true,
workspacePath: createResult.workspacePath,
sourceWorkspacePath,
sourceBranch,
};
} catch (error) {
Expand Down
6 changes: 5 additions & 1 deletion src/runtime/Runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ export interface WorkspaceInitParams {
initLogger: InitLogger;
/** Optional abort signal for cancellation */
abortSignal?: AbortSignal;
/** If provided, copy from this workspace instead of syncing from local (fork scenario) */
sourceWorkspacePath?: string;
}

/**
Expand Down Expand Up @@ -187,6 +189,8 @@ export interface WorkspaceForkResult {
success: boolean;
/** Path to the new workspace (if successful) */
workspacePath?: string;
/** Path to the source workspace (needed for init phase) */
sourceWorkspacePath?: string;
/** Branch that was forked from */
sourceBranch?: string;
/** Error message (if failed) */
Expand Down Expand Up @@ -302,7 +306,7 @@ export interface Runtime {
/**
* Initialize workspace asynchronously (may be slow, streams progress)
* - LocalRuntime: Runs init hook if present
* - SSHRuntime: Syncs files, checks out branch, runs init hook
* - SSHRuntime: Syncs files (or copies from source), checks out branch, runs init hook
* Streams progress via initLogger.
* @param params Workspace initialization parameters
* @returns Result indicating success or error
Expand Down
128 changes: 99 additions & 29 deletions src/runtime/SSHRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -838,32 +838,72 @@ export class SSHRuntime implements Runtime {
}

async initWorkspace(params: WorkspaceInitParams): Promise<WorkspaceInitResult> {
const { projectPath, branchName, trunkBranch, workspacePath, initLogger, abortSignal } = params;
const {
projectPath,
branchName,
trunkBranch,
workspacePath,
initLogger,
abortSignal,
sourceWorkspacePath,
} = params;

try {
// 1. Sync project to remote (opportunistic rsync with scp fallback)
initLogger.logStep("Syncing project files to remote...");
try {
await this.syncProjectToRemote(projectPath, workspacePath, initLogger, abortSignal);
} catch (error) {
const errorMsg = getErrorMessage(error);
initLogger.logStderr(`Failed to sync project: ${errorMsg}`);
initLogger.logComplete(-1);
return {
success: false,
error: `Failed to sync project: ${errorMsg}`,
};
// Fork scenario: Copy from source workspace instead of syncing from local
if (sourceWorkspacePath) {
// 1. Copy workspace directory on remote host
// cp -a preserves all attributes (permissions, timestamps, symlinks, uncommitted changes)
initLogger.logStep("Copying workspace from source...");
// Expand tilde paths before using in remote command
const expandedSourcePath = expandTildeForSSH(sourceWorkspacePath);
const expandedWorkspacePath = expandTildeForSSH(workspacePath);
const copyStream = await this.exec(
`cp -a ${expandedSourcePath}/. ${expandedWorkspacePath}/`,
{ cwd: "~", timeout: 300, abortSignal } // 5 minute timeout for large workspaces
);

const [stdout, stderr, exitCode] = await Promise.all([
streamToString(copyStream.stdout),
streamToString(copyStream.stderr),
copyStream.exitCode,
]);

if (exitCode !== 0) {
const errorMsg = `Failed to copy workspace: ${stderr || stdout}`;
initLogger.logStderr(errorMsg);
initLogger.logComplete(-1);
return {
success: false,
error: errorMsg,
};
}
initLogger.logStep("Workspace copied successfully");
} else {
// Normal scenario: Sync from local project
// 1. Sync project to remote (opportunistic rsync with scp fallback)
initLogger.logStep("Syncing project files to remote...");
try {
await this.syncProjectToRemote(projectPath, workspacePath, initLogger, abortSignal);
} catch (error) {
const errorMsg = getErrorMessage(error);
initLogger.logStderr(`Failed to sync project: ${errorMsg}`);
initLogger.logComplete(-1);
return {
success: false,
error: `Failed to sync project: ${errorMsg}`,
};
}
initLogger.logStep("Files synced successfully");
}
initLogger.logStep("Files synced successfully");

// 2. Checkout branch remotely
// If branch exists locally, check it out; otherwise create it from the specified trunk branch
// Note: We've already created local branches for all remote refs in syncProjectToRemote
initLogger.logStep(`Checking out branch: ${branchName}`);

// Try to checkout existing branch, or create new branch from trunk
// Since we've created local branches for all remote refs, we can use branch names directly
const checkoutCmd = `git checkout ${shescape.quote(branchName)} 2>/dev/null || git checkout -b ${shescape.quote(branchName)} ${shescape.quote(trunkBranch)}`;
// For forked workspaces (copied with cp -a), HEAD is already on the source branch
// For synced workspaces, we need to specify the trunk branch to create from
const checkoutCmd = sourceWorkspacePath
? `git checkout ${shescape.quote(branchName)} 2>/dev/null || git checkout -b ${shescape.quote(branchName)}`
: `git checkout ${shescape.quote(branchName)} 2>/dev/null || git checkout -b ${shescape.quote(branchName)} ${shescape.quote(trunkBranch)}`;

const checkoutStream = await this.exec(checkoutCmd, {
cwd: workspacePath, // Use the full workspace path for git operations
Expand Down Expand Up @@ -1141,16 +1181,46 @@ export class SSHRuntime implements Runtime {
}
}

forkWorkspace(_params: WorkspaceForkParams): Promise<WorkspaceForkResult> {
// SSH forking is not yet implemented due to unresolved complexities:
// - Users expect the new workspace's filesystem state to match the remote workspace,
// not the local project (which may be out of sync or on a different commit)
// - This requires: detecting the branch, copying remote state, handling uncommitted changes
// - For now, users should create a new workspace from the desired branch instead
return Promise.resolve({
success: false,
error: "Forking SSH workspaces is not yet implemented. Create a new workspace instead.",
});
async forkWorkspace(params: WorkspaceForkParams): Promise<WorkspaceForkResult> {
const { projectPath, sourceWorkspaceName, newWorkspaceName, initLogger } = params;

// Get source and destination workspace paths
const sourceWorkspacePath = this.getWorkspacePath(projectPath, sourceWorkspaceName);
const newWorkspacePath = this.getWorkspacePath(projectPath, newWorkspaceName);

// Expand tilde path for the new workspace directory
const expandedNewPath = expandTildeForSSH(newWorkspacePath);

try {
// Step 1: Create empty directory for new workspace (instant)
// The actual copy happens in initWorkspace (fire-and-forget)
initLogger.logStep("Creating workspace directory...");
const mkdirStream = await this.exec(`mkdir -p ${expandedNewPath}`, { cwd: "~", timeout: 10 });

await mkdirStream.stdin.abort();
const mkdirExitCode = await mkdirStream.exitCode;
if (mkdirExitCode !== 0) {
const stderr = await streamToString(mkdirStream.stderr);
return {
success: false,
error: `Failed to create workspace directory: ${stderr}`,
};
}

initLogger.logStep("Workspace directory created");

// Return immediately - copy and init happen in initWorkspace (fire-and-forget)
return {
success: true,
workspacePath: newWorkspacePath,
sourceWorkspacePath,
};
} catch (error) {
return {
success: false,
error: getErrorMessage(error),
};
}
}
}

Expand Down
10 changes: 8 additions & 2 deletions src/runtime/initHook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,19 @@ import * as fsPromises from "fs/promises";
import * as path from "path";
import type { InitLogger } from "./Runtime";

/**
* Constants for init hook functionality
*/
export const CMUX_DIR = ".cmux";
export const INIT_HOOK_FILENAME = "init";

/**
* Check if .cmux/init hook exists and is executable
* @param projectPath - Path to the project root
* @returns true if hook exists and is executable, false otherwise
*/
export async function checkInitHookExists(projectPath: string): Promise<boolean> {
const hookPath = path.join(projectPath, ".cmux", "init");
const hookPath = path.join(projectPath, CMUX_DIR, INIT_HOOK_FILENAME);

try {
await fsPromises.access(hookPath, fs.constants.X_OK);
Expand All @@ -23,7 +29,7 @@ export async function checkInitHookExists(projectPath: string): Promise<boolean>
* Get the init hook path for a project
*/
export function getInitHookPath(projectPath: string): string {
return path.join(projectPath, ".cmux", "init");
return path.join(projectPath, CMUX_DIR, INIT_HOOK_FILENAME);
}

/**
Expand Down
62 changes: 51 additions & 11 deletions src/services/ipcMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import { countTokens, countTokensBatch } from "@/utils/main/tokenizer";
import { calculateTokenStats } from "@/utils/tokens/tokenStatsCalculator";
import { IPC_CHANNELS, getChatChannel } from "@/constants/ipc-constants";
import { SUPPORTED_PROVIDERS } from "@/constants/providers";
import { DEFAULT_RUNTIME_CONFIG } from "@/constants/workspace";
import type { SendMessageError } from "@/types/errors";
import type { SendMessageOptions, DeleteMessage } from "@/types/ipc";
import { Ok, Err } from "@/types/result";
Expand Down Expand Up @@ -688,6 +687,16 @@ export class IpcMain {
return { success: false, error: validation.error };
}

// Check if source workspace is currently initializing
const initState = this.initStateManager.getInitState(sourceWorkspaceId);
if (initState?.status === "running") {
return {
success: false,
error:
"Cannot fork workspace while it is initializing. Please wait for initialization to complete.",
};
}

// If streaming, commit the partial response to history first
// This preserves the streamed content in both workspaces
if (this.aiService.isStreaming(sourceWorkspaceId)) {
Expand All @@ -706,12 +715,16 @@ export class IpcMain {
const foundProjectPath = sourceMetadata.projectPath;
const projectName = sourceMetadata.projectName;

// Create runtime for source workspace
const sourceRuntimeConfig = sourceMetadata.runtimeConfig ?? {
type: "local",
srcBaseDir: this.config.srcDir,
};
const runtime = createRuntime(sourceRuntimeConfig);
// Preserve source runtime config (undefined for local default, or explicit SSH config)
const sourceRuntimeConfig = sourceMetadata.runtimeConfig;

// Create runtime for fork operation
const runtimeForFork = createRuntime(
sourceRuntimeConfig ?? {
type: "local",
srcBaseDir: this.config.srcDir,
}
);

// Generate stable workspace ID for the new workspace
const newWorkspaceId = this.config.generateStableId();
Expand All @@ -724,8 +737,8 @@ export class IpcMain {

const initLogger = this.createInitLogger(newWorkspaceId);

// Delegate fork operation to runtime
const forkResult = await runtime.forkWorkspace({
// Delegate fork operation to runtime (fast path - returns before init hook)
const forkResult = await runtimeForFork.forkWorkspace({
projectPath: foundProjectPath,
sourceWorkspaceName: sourceMetadata.name,
newWorkspaceName: newName,
Expand All @@ -736,6 +749,13 @@ export class IpcMain {
return { success: false, error: forkResult.error };
}

if (!forkResult.workspacePath) {
return { success: false, error: "Fork succeeded but no workspace path returned" };
}

// Store validated workspace path for type safety
const workspacePath = forkResult.workspacePath;

// Copy session files (chat.jsonl, partial.json) - local backend operation
const sourceSessionDir = this.config.getSessionDir(sourceWorkspaceId);
const newSessionDir = this.config.getSessionDir(newWorkspaceId);
Expand Down Expand Up @@ -770,7 +790,7 @@ export class IpcMain {
}
} catch (copyError) {
// If copy fails, clean up everything we created
await runtime.deleteWorkspace(foundProjectPath, newName, true);
await runtimeForFork.deleteWorkspace(foundProjectPath, newName, true);
try {
await fsPromises.rm(newSessionDir, { recursive: true, force: true });
} catch (cleanupError) {
Expand All @@ -787,7 +807,7 @@ export class IpcMain {
projectName,
projectPath: foundProjectPath,
createdAt: new Date().toISOString(),
runtimeConfig: DEFAULT_RUNTIME_CONFIG,
runtimeConfig: sourceRuntimeConfig,
};

// Write metadata to config.json
Expand All @@ -796,6 +816,26 @@ export class IpcMain {
// Emit metadata event
session.emitMetadata(metadata);

// Run initialization in background (fire-and-forget like createWorkspace)
// For SSH: copies workspace + creates branch + runs init hook
// For Local: just runs init hook (worktree already created)
// This allows fork to return immediately while init streams progress
void runtimeForFork
.initWorkspace({
projectPath: foundProjectPath,
branchName: newName,
trunkBranch: "main", // Only used for non-fork (sync) case in SSH runtime
workspacePath,
initLogger,
sourceWorkspacePath: forkResult.sourceWorkspacePath, // Fork from source, not sync from local
})
.catch((error: unknown) => {
const errorMsg = error instanceof Error ? error.message : String(error);
log.error(`initWorkspace failed for forked workspace ${newWorkspaceId}:`, error);
initLogger.logStderr(`Initialization failed: ${errorMsg}`);
initLogger.logComplete(-1);
});

return {
success: true,
metadata,
Expand Down
Loading