Skip to content

Commit 2f41d25

Browse files
committed
🤖 refactor: broaden workspace runtime cleanup
Continue the workspace runtime cleanup by centralizing the shared init-hook flow, threading persisted workspace identity through runtime recreation, and using directoryName consistently for workspace/container paths. --- _Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `$153.79`_ <!-- mux-attribution: model=openai:gpt-5.4 thinking=xhigh costs=153.79 -->
1 parent caf02ae commit 2f41d25

14 files changed

Lines changed: 408 additions & 207 deletions

src/node/runtime/DevcontainerRuntime.ts

Lines changed: 18 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,7 @@ import {
3030
type BindMount,
3131
} from "./credentialForwarding";
3232
import { devcontainerUp, devcontainerDown } from "./devcontainerCli";
33-
import {
34-
checkInitHookExists,
35-
getMuxEnv,
36-
runInitHookOnRuntime,
37-
shouldSkipInitHook,
38-
} from "./initHook";
33+
import { runInitHookOnRuntime, runWorkspaceInitHook } from "./initHook";
3934
import { DisposableProcess, forceCloseStdio, killProcessTree } from "@/node/utils/disposableExec";
4035
import { EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT } from "@/common/constants/exitCodes";
4136
import { NON_INTERACTIVE_ENV_VARS } from "@/common/constants/env";
@@ -481,6 +476,7 @@ export class DevcontainerRuntime extends LocalBaseRuntime {
481476
return this.worktreeManager.createWorkspace({
482477
projectPath: params.projectPath,
483478
branchName: params.branchName,
479+
directoryName: params.directoryName,
484480
trunkBranch: params.trunkBranch,
485481
initLogger: params.initLogger,
486482
abortSignal: params.abortSignal,
@@ -530,36 +526,23 @@ export class DevcontainerRuntime extends LocalBaseRuntime {
530526
* Run .mux/init hook inside the devcontainer.
531527
*/
532528
async initWorkspace(params: WorkspaceInitParams): Promise<WorkspaceInitResult> {
533-
const { projectPath, branchName, workspacePath, initLogger, env } = params;
534-
535-
try {
536-
if (shouldSkipInitHook(params, initLogger)) {
537-
initLogger.logComplete(0);
538-
return { success: true };
539-
}
540-
541-
// Check if init hook exists (on host - worktree is bind-mounted)
542-
const hookExists = await checkInitHookExists(workspacePath);
543-
if (hookExists) {
544-
initLogger.enterHookPhase?.();
545-
const muxEnv = { ...env, ...getMuxEnv(projectPath, "devcontainer", branchName) };
546-
const containerWorkspacePath = this.remoteWorkspaceFolder ?? workspacePath;
529+
return runWorkspaceInitHook({
530+
params,
531+
runtimeType: "devcontainer",
532+
hookCheckPath: params.workspacePath,
533+
runHook: async ({ muxEnv, initLogger, abortSignal }) => {
534+
const containerWorkspacePath = this.remoteWorkspaceFolder ?? params.workspacePath;
547535
const hookPath = `${containerWorkspacePath}/.mux/init`;
548-
await runInitHookOnRuntime(this, hookPath, containerWorkspacePath, muxEnv, initLogger);
549-
} else {
550-
// No hook - signal completion immediately
551-
initLogger.logComplete(0);
552-
}
553-
return { success: true };
554-
} catch (error) {
555-
const errorMsg = getErrorMessage(error);
556-
initLogger.logStderr(`Initialization failed: ${errorMsg}`);
557-
initLogger.logComplete(-1);
558-
return {
559-
success: false,
560-
error: errorMsg,
561-
};
562-
}
536+
await runInitHookOnRuntime(
537+
this,
538+
hookPath,
539+
containerWorkspacePath,
540+
muxEnv,
541+
initLogger,
542+
abortSignal
543+
);
544+
},
545+
});
563546
}
564547

565548
/**

src/node/runtime/DockerRuntime.ts

Lines changed: 25 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,7 @@ import type {
2929
} from "./Runtime";
3030
import { RuntimeError } from "./Runtime";
3131
import { RemoteRuntime, type SpawnResult } from "./RemoteRuntime";
32-
import {
33-
checkInitHookExists,
34-
getMuxEnv,
35-
runInitHookOnRuntime,
36-
shouldSkipInitHook,
37-
} from "./initHook";
32+
import { runInitHookOnRuntime, runWorkspaceInitHook } from "./initHook";
3833
import { getProjectName } from "@/node/utils/runtime/helpers";
3934
import { getErrorMessage } from "@/common/utils/errors";
4035
import { syncProjectViaGitBundle } from "./gitBundleSync";
@@ -423,10 +418,11 @@ export class DockerRuntime extends RemoteRuntime {
423418
}
424419

425420
async createWorkspace(params: WorkspaceCreationParams): Promise<WorkspaceCreationResult> {
426-
const { projectPath, branchName } = params;
421+
const { projectPath, directoryName } = params;
427422

428-
// Generate container name and check for collisions before persisting metadata
429-
const containerName = getContainerName(projectPath, branchName);
423+
// Container identity should follow the workspace entry name rather than the git branch.
424+
// The two are usually equal today, but the runtime contract keeps them distinct.
425+
const containerName = getContainerName(projectPath, directoryName);
430426

431427
// Check if container already exists (collision detection)
432428
const checkResult = await runDockerCommand(`docker inspect ${containerName}`, 10000);
@@ -528,43 +524,30 @@ export class DockerRuntime extends RemoteRuntime {
528524
* is handled by postCreateSetup().
529525
*/
530526
async initWorkspace(params: WorkspaceInitParams): Promise<WorkspaceInitResult> {
531-
const { projectPath, branchName, workspacePath, initLogger, abortSignal, env } = params;
532-
533-
try {
534-
if (!this.containerName) {
535-
return {
536-
success: false,
537-
error: "Container not initialized. Call createWorkspace first.",
538-
};
539-
}
540-
541-
if (shouldSkipInitHook(params, initLogger)) {
542-
initLogger.logComplete(0);
543-
return { success: true };
544-
}
545-
546-
// Run .mux/init hook if it exists
547-
const hookExists = await checkInitHookExists(projectPath);
548-
if (hookExists) {
549-
initLogger.enterHookPhase?.();
550-
const muxEnv = { ...env, ...getMuxEnv(projectPath, "docker", branchName) };
551-
const hookPath = `${workspacePath}/.mux/init`;
552-
await runInitHookOnRuntime(this, hookPath, workspacePath, muxEnv, initLogger, abortSignal);
553-
} else {
554-
initLogger.logComplete(0);
555-
}
556-
557-
return { success: true };
558-
} catch (error) {
559-
const errorMsg = getErrorMessage(error);
560-
initLogger.logStderr(`Initialization failed: ${errorMsg}`);
561-
initLogger.logComplete(-1);
562-
// Do NOT delete container on hook failure - user can debug
527+
if (!this.containerName) {
563528
return {
564529
success: false,
565-
error: errorMsg,
530+
error: "Container not initialized. Call createWorkspace first.",
566531
};
567532
}
533+
534+
// Do NOT delete container on hook failure - user can debug.
535+
return runWorkspaceInitHook({
536+
params,
537+
runtimeType: "docker",
538+
hookCheckPath: params.projectPath,
539+
runHook: async ({ muxEnv, initLogger, abortSignal }) => {
540+
const hookPath = `${params.workspacePath}/.mux/init`;
541+
await runInitHookOnRuntime(
542+
this,
543+
hookPath,
544+
params.workspacePath,
545+
muxEnv,
546+
initLogger,
547+
abortSignal
548+
);
549+
},
550+
});
568551
}
569552

570553
/**

src/node/runtime/LocalRuntime.ts

Lines changed: 9 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type {
88
WorkspaceForkParams,
99
WorkspaceForkResult,
1010
} from "./Runtime";
11-
import { checkInitHookExists, getMuxEnv, shouldSkipInitHook } from "./initHook";
11+
import { runWorkspaceInitHook } from "./initHook";
1212
import { getErrorMessage } from "@/common/utils/errors";
1313
import { LocalBaseRuntime } from "./LocalBaseRuntime";
1414

@@ -86,34 +86,14 @@ export class LocalRuntime extends LocalBaseRuntime {
8686
}
8787

8888
async initWorkspace(params: WorkspaceInitParams): Promise<WorkspaceInitResult> {
89-
const { projectPath, branchName, workspacePath, initLogger, abortSignal, env } = params;
90-
91-
try {
92-
if (shouldSkipInitHook(params, initLogger)) {
93-
initLogger.logComplete(0);
94-
return { success: true };
95-
}
96-
97-
// Run .mux/init hook if it exists
98-
const hookExists = await checkInitHookExists(projectPath);
99-
if (hookExists) {
100-
initLogger.enterHookPhase?.();
101-
const muxEnv = { ...env, ...getMuxEnv(projectPath, "local", branchName) };
102-
await this.runInitHook(workspacePath, muxEnv, initLogger, abortSignal);
103-
} else {
104-
// No hook - signal completion immediately
105-
initLogger.logComplete(0);
106-
}
107-
return { success: true };
108-
} catch (error) {
109-
const errorMsg = getErrorMessage(error);
110-
initLogger.logStderr(`Initialization failed: ${errorMsg}`);
111-
initLogger.logComplete(-1);
112-
return {
113-
success: false,
114-
error: errorMsg,
115-
};
116-
}
89+
return runWorkspaceInitHook({
90+
params,
91+
runtimeType: "local",
92+
hookCheckPath: params.projectPath,
93+
runHook: async ({ muxEnv, initLogger, abortSignal }) => {
94+
await this.runInitHook(params.workspacePath, muxEnv, initLogger, abortSignal);
95+
},
96+
});
11797
}
11898

11999
/**

src/node/runtime/SSHRuntime.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,47 @@ describe("SSHRuntime constructor", () => {
3535
});
3636
});
3737

38+
describe("SSHRuntime.createWorkspace", () => {
39+
it("uses directoryName for the workspace path while preparing the remote parent directory", async () => {
40+
const config = { host: "example.com", srcBaseDir: "/home/user/src" };
41+
const runtime = new SSHRuntime(config, createSSHTransport(config, false));
42+
const execSpy = spyOn(runtime, "exec").mockResolvedValue({
43+
stdout: new ReadableStream<Uint8Array>(),
44+
stderr: new ReadableStream<Uint8Array>(),
45+
stdin: new WritableStream<Uint8Array>(),
46+
exitCode: Promise.resolve(0),
47+
duration: Promise.resolve(0),
48+
});
49+
50+
try {
51+
const result = await runtime.createWorkspace({
52+
projectPath: "/projects/demo",
53+
branchName: "feature-branch",
54+
directoryName: "review-slot",
55+
trunkBranch: "main",
56+
initLogger: {
57+
logStep: () => undefined,
58+
logStdout: () => undefined,
59+
logStderr: () => undefined,
60+
logComplete: () => undefined,
61+
},
62+
});
63+
64+
expect(result).toEqual({
65+
success: true,
66+
workspacePath: "/home/user/src/demo/review-slot",
67+
});
68+
expect(execSpy).toHaveBeenCalledWith('mkdir -p "/home/user/src/demo"', {
69+
cwd: "/tmp",
70+
timeout: 10,
71+
abortSignal: undefined,
72+
});
73+
} finally {
74+
execSpy.mockRestore();
75+
}
76+
});
77+
});
78+
3879
describe("SSHRuntime.ensureReady repository checks", () => {
3980
let execBufferedSpy: ReturnType<typeof spyOn<typeof runtimeHelpers, "execBuffered">> | null =
4081
null;

src/node/runtime/SSHRuntime.ts

Lines changed: 24 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,7 @@ import type {
3232
import { WORKSPACE_REPO_MISSING_ERROR } from "./Runtime";
3333
import { RemoteRuntime, type SpawnResult } from "./RemoteRuntime";
3434
import { log } from "@/node/services/log";
35-
import {
36-
checkInitHookExists,
37-
getMuxEnv,
38-
runInitHookOnRuntime,
39-
shouldSkipInitHook,
40-
} from "./initHook";
35+
import { runInitHookOnRuntime, runWorkspaceInitHook } from "./initHook";
4136
import { expandTildeForSSH as expandHookPath } from "./tildeExpansion";
4237

4338
import { expandTildeForSSH, cdCommandForSSH } from "./tildeExpansion";
@@ -752,9 +747,9 @@ export class SSHRuntime extends RemoteRuntime {
752747

753748
async createWorkspace(params: WorkspaceCreationParams): Promise<WorkspaceCreationResult> {
754749
try {
755-
const { projectPath, branchName, initLogger, abortSignal } = params;
756-
// Compute workspace path using canonical method
757-
const workspacePath = this.getWorkspacePath(projectPath, branchName);
750+
const { projectPath, directoryName, initLogger, abortSignal } = params;
751+
// Workspace directories follow the persisted workspace name; branch checkout happens later.
752+
const workspacePath = this.getWorkspacePath(projectPath, directoryName);
758753

759754
// Prepare parent directory for git clone (fast - returns immediately)
760755
// Note: git clone will create the workspace directory itself during initWorkspace,
@@ -805,49 +800,29 @@ export class SSHRuntime extends RemoteRuntime {
805800
}
806801

807802
async initWorkspace(params: WorkspaceInitParams): Promise<WorkspaceInitResult> {
808-
const { projectPath, branchName, workspacePath, initLogger, abortSignal, env } = params;
809-
810803
// Disable git hooks for untrusted projects (prevents post-checkout execution)
811804
const nhp = gitNoHooksPrefix(params.trusted);
812805

813-
try {
814-
await this.prepareWorkspaceCheckout(params, nhp);
815-
816-
// 3. Run .mux/init hook if it exists
817-
// Note: runInitHookOnRuntime calls logComplete() internally
818-
if (shouldSkipInitHook(params, initLogger)) {
819-
initLogger.logComplete(0);
820-
} else {
821-
const hookExists = await checkInitHookExists(projectPath);
822-
if (hookExists) {
823-
initLogger.enterHookPhase?.();
824-
const muxEnv = { ...env, ...getMuxEnv(projectPath, "ssh", branchName) };
825-
// Expand tilde in hook path (quoted paths don't auto-expand on remote)
826-
const hookPath = expandHookPath(`${workspacePath}/.mux/init`);
827-
await runInitHookOnRuntime(
828-
this,
829-
hookPath,
830-
workspacePath,
831-
muxEnv,
832-
initLogger,
833-
abortSignal
834-
);
835-
} else {
836-
// No hook - signal completion immediately
837-
initLogger.logComplete(0);
838-
}
839-
}
840-
841-
return { success: true };
842-
} catch (error) {
843-
const errorMsg = getErrorMessage(error);
844-
initLogger.logStderr(`Initialization failed: ${errorMsg}`);
845-
initLogger.logComplete(-1);
846-
return {
847-
success: false,
848-
error: errorMsg,
849-
};
850-
}
806+
return runWorkspaceInitHook({
807+
params,
808+
runtimeType: "ssh",
809+
hookCheckPath: params.projectPath,
810+
beforeHook: async () => {
811+
await this.prepareWorkspaceCheckout(params, nhp);
812+
},
813+
runHook: async ({ muxEnv, initLogger, abortSignal }) => {
814+
// Expand tilde in hook path (quoted paths don't auto-expand on remote).
815+
const hookPath = expandHookPath(`${params.workspacePath}/.mux/init`);
816+
await runInitHookOnRuntime(
817+
this,
818+
hookPath,
819+
params.workspacePath,
820+
muxEnv,
821+
initLogger,
822+
abortSignal
823+
);
824+
},
825+
});
851826
}
852827

853828
private async prepareWorkspaceCheckout(params: WorkspaceInitParams, nhp: string): Promise<void> {

0 commit comments

Comments
 (0)