Skip to content

Commit f7f18dd

Browse files
committed
🤖 Integrate init hooks with Runtime.createWorkspace()
Connects the init hooks system (PR #228) with the Runtime abstraction so workspace creation progress and init hook output stream to the frontend. **Init Hook Utilities (src/runtime/initHook.ts):** - checkInitHookExists(): Check if .cmux/init is executable - getInitHookPath(): Get init hook path for project - LineBuffer class: Line-buffered streaming (handles incomplete lines) - createLineBufferedLoggers(): Creates stdout/stderr line buffers **Runtime Integration:** - InitLogger interface: logStep(), logStdout(), logStderr(), logComplete() - WorkspaceCreationParams extended with initLogger - LocalRuntime: Runs init hook locally via bash, streams output - SSHRuntime: Runs init hook on remote host, streams via Web Streams **IPC Bridge:** - IpcMain creates InitLogger that bridges to InitStateManager - Runtime owns workspace creation entirely (no IPC branching) - Creation steps logged: "Creating worktree...", "Running init hook..." - Real-time streaming to frontend via existing init channels **Testing:** - 7 unit tests for LineBuffer and createLineBufferedLoggers - Integration tests updated with mockInitLogger - All 770 tests passing Generated with `cmux`
1 parent c693d25 commit f7f18dd

File tree

7 files changed

+361
-20
lines changed

7 files changed

+361
-20
lines changed

src/runtime/initHook.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, it, expect } from "bun:test";
2+
import { LineBuffer, createLineBufferedLoggers } from "./initHook";
3+
import type { InitLogger } from "./Runtime";
4+
5+
describe("LineBuffer", () => {
6+
it("should buffer incomplete lines", () => {
7+
const lines: string[] = [];
8+
const buffer = new LineBuffer((line) => lines.push(line));
9+
10+
buffer.append("hello ");
11+
expect(lines).toEqual([]);
12+
13+
buffer.append("world\n");
14+
expect(lines).toEqual(["hello world"]);
15+
});
16+
17+
it("should handle multiple lines in one chunk", () => {
18+
const lines: string[] = [];
19+
const buffer = new LineBuffer((line) => lines.push(line));
20+
21+
buffer.append("line1\nline2\nline3\n");
22+
expect(lines).toEqual(["line1", "line2", "line3"]);
23+
});
24+
25+
it("should handle incomplete line at end", () => {
26+
const lines: string[] = [];
27+
const buffer = new LineBuffer((line) => lines.push(line));
28+
29+
buffer.append("line1\nline2\nincomplete");
30+
expect(lines).toEqual(["line1", "line2"]);
31+
32+
buffer.flush();
33+
expect(lines).toEqual(["line1", "line2", "incomplete"]);
34+
});
35+
36+
it("should skip empty lines", () => {
37+
const lines: string[] = [];
38+
const buffer = new LineBuffer((line) => lines.push(line));
39+
40+
buffer.append("\nline1\n\nline2\n\n");
41+
expect(lines).toEqual(["line1", "line2"]);
42+
});
43+
44+
it("should handle flush with no buffered data", () => {
45+
const lines: string[] = [];
46+
const buffer = new LineBuffer((line) => lines.push(line));
47+
48+
buffer.append("line1\n");
49+
expect(lines).toEqual(["line1"]);
50+
51+
buffer.flush();
52+
expect(lines).toEqual(["line1"]); // No change
53+
});
54+
});
55+
56+
describe("createLineBufferedLoggers", () => {
57+
it("should create separate buffers for stdout and stderr", () => {
58+
const stdoutLines: string[] = [];
59+
const stderrLines: string[] = [];
60+
61+
const mockLogger: InitLogger = {
62+
logStep: () => {},
63+
logStdout: (line) => stdoutLines.push(line),
64+
logStderr: (line) => stderrLines.push(line),
65+
logComplete: () => {},
66+
};
67+
68+
const loggers = createLineBufferedLoggers(mockLogger);
69+
70+
loggers.stdout.append("out1\nout2\n");
71+
loggers.stderr.append("err1\nerr2\n");
72+
73+
expect(stdoutLines).toEqual(["out1", "out2"]);
74+
expect(stderrLines).toEqual(["err1", "err2"]);
75+
});
76+
77+
it("should handle incomplete lines and flush separately", () => {
78+
const stdoutLines: string[] = [];
79+
const stderrLines: string[] = [];
80+
81+
const mockLogger: InitLogger = {
82+
logStep: () => {},
83+
logStdout: (line) => stdoutLines.push(line),
84+
logStderr: (line) => stderrLines.push(line),
85+
logComplete: () => {},
86+
};
87+
88+
const loggers = createLineBufferedLoggers(mockLogger);
89+
90+
loggers.stdout.append("incomplete");
91+
loggers.stderr.append("also incomplete");
92+
93+
expect(stdoutLines).toEqual([]);
94+
expect(stderrLines).toEqual([]);
95+
96+
loggers.stdout.flush();
97+
expect(stdoutLines).toEqual(["incomplete"]);
98+
expect(stderrLines).toEqual([]); // stderr not flushed yet
99+
100+
loggers.stderr.flush();
101+
expect(stderrLines).toEqual(["also incomplete"]);
102+
});
103+
});

src/runtime/initHook.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import * as fs from "fs";
2+
import * as fsPromises from "fs/promises";
3+
import * as path from "path";
4+
import type { InitLogger } from "./Runtime";
5+
6+
/**
7+
* Check if .cmux/init hook exists and is executable
8+
* @param projectPath - Path to the project root
9+
* @returns true if hook exists and is executable, false otherwise
10+
*/
11+
export async function checkInitHookExists(projectPath: string): Promise<boolean> {
12+
const hookPath = path.join(projectPath, ".cmux", "init");
13+
14+
try {
15+
await fsPromises.access(hookPath, fs.constants.X_OK);
16+
return true;
17+
} catch {
18+
return false;
19+
}
20+
}
21+
22+
/**
23+
* Get the init hook path for a project
24+
*/
25+
export function getInitHookPath(projectPath: string): string {
26+
return path.join(projectPath, ".cmux", "init");
27+
}
28+
29+
/**
30+
* Line-buffered logger that splits stream output into lines and logs them
31+
* Handles incomplete lines by buffering until a newline is received
32+
*/
33+
export class LineBuffer {
34+
private buffer = "";
35+
private readonly logLine: (line: string) => void;
36+
37+
constructor(logLine: (line: string) => void) {
38+
this.logLine = logLine;
39+
}
40+
41+
/**
42+
* Process a chunk of data, splitting on newlines and logging complete lines
43+
*/
44+
append(data: string): void {
45+
this.buffer += data;
46+
const lines = this.buffer.split("\n");
47+
this.buffer = lines.pop() ?? ""; // Keep last incomplete line
48+
for (const line of lines) {
49+
if (line) this.logLine(line);
50+
}
51+
}
52+
53+
/**
54+
* Flush any remaining buffered data (called when stream closes)
55+
*/
56+
flush(): void {
57+
if (this.buffer) {
58+
this.logLine(this.buffer);
59+
this.buffer = "";
60+
}
61+
}
62+
}
63+
64+
/**
65+
* Create line-buffered loggers for stdout and stderr
66+
* Returns an object with append and flush methods for each stream
67+
*/
68+
export function createLineBufferedLoggers(initLogger: InitLogger) {
69+
const stdoutBuffer = new LineBuffer((line) => initLogger.logStdout(line));
70+
const stderrBuffer = new LineBuffer((line) => initLogger.logStderr(line));
71+
72+
return {
73+
stdout: {
74+
append: (data: string) => stdoutBuffer.append(data),
75+
flush: () => stdoutBuffer.flush(),
76+
},
77+
stderr: {
78+
append: (data: string) => stderrBuffer.append(data),
79+
flush: () => stderrBuffer.flush(),
80+
},
81+
};
82+
}
83+

src/services/ipcMain.ts

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { secretsToRecord } from "@/types/secrets";
3131
import { DisposableTempDir } from "@/services/tempDir";
3232
import { BashExecutionService } from "@/services/bashExecutionService";
3333
import { InitStateManager } from "@/services/initStateManager";
34+
import { createRuntime } from "@/runtime/runtimeFactory";
3435

3536
/**
3637
* IpcMain - Manages all IPC handlers and service coordination
@@ -272,13 +273,41 @@ export class IpcMain {
272273
// Generate stable workspace ID (stored in config, not used for directory name)
273274
const workspaceId = this.config.generateStableId();
274275

275-
// Create the git worktree with the workspace name as directory name
276-
const result = await createWorktree(this.config, projectPath, branchName, {
276+
// Create runtime for workspace creation (defaults to local)
277+
const workspacePath = this.config.getWorkspacePath(projectPath, branchName);
278+
const runtimeConfig = { type: "local" as const, workdir: workspacePath };
279+
const runtime = createRuntime(runtimeConfig);
280+
281+
// Start init tracking (creates in-memory state + emits init-start event)
282+
// This MUST complete before workspace creation returns so replayInit() finds state
283+
this.initStateManager.startInit(workspaceId, projectPath);
284+
285+
// Create InitLogger that bridges to InitStateManager
286+
const initLogger = {
287+
logStep: (message: string) => {
288+
this.initStateManager.appendOutput(workspaceId, message, false);
289+
},
290+
logStdout: (line: string) => {
291+
this.initStateManager.appendOutput(workspaceId, line, false);
292+
},
293+
logStderr: (line: string) => {
294+
this.initStateManager.appendOutput(workspaceId, line, true);
295+
},
296+
logComplete: (exitCode: number) => {
297+
void this.initStateManager.endInit(workspaceId, exitCode);
298+
},
299+
};
300+
301+
// Create workspace through runtime abstraction
302+
const result = await runtime.createWorkspace({
303+
projectPath,
304+
branchName,
277305
trunkBranch: normalizedTrunkBranch,
278-
workspaceId: branchName, // Use name for directory (workspaceId param is misnamed, it's directoryName)
306+
workspaceId: branchName, // Use name for directory
307+
initLogger,
279308
});
280309

281-
if (result.success && result.path) {
310+
if (result.success && result.workspacePath) {
282311
const projectName =
283312
projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown";
284313

@@ -304,7 +333,7 @@ export class IpcMain {
304333
}
305334
// Add workspace to project config with full metadata
306335
projectConfig.workspaces.push({
307-
path: result.path!,
336+
path: result.workspacePath!,
308337
id: workspaceId,
309338
name: branchName,
310339
createdAt: metadata.createdAt,
@@ -325,13 +354,8 @@ export class IpcMain {
325354
const session = this.getOrCreateSession(workspaceId);
326355
session.emitMetadata(completeMetadata);
327356

328-
// Start optional .cmux/init hook (waits for state creation, then returns)
329-
// This ensures replayInit() will find state when frontend subscribes
330-
await this.startWorkspaceInitHook({
331-
projectPath,
332-
worktreePath: result.path,
333-
workspaceId,
334-
});
357+
// Init hook has already been run by the runtime
358+
// No need to call startWorkspaceInitHook here anymore
335359

336360
// Return complete metadata with paths for frontend
337361
return {
@@ -839,6 +863,7 @@ export class IpcMain {
839863
// All IPC bash calls are from UI (background operations) - use truncate to avoid temp file spam
840864
const bashTool = createBashTool({
841865
cwd: namedPath,
866+
runtime: createRuntime({ type: "local", workdir: namedPath }),
842867
secrets: secretsToRecord(projectSecrets),
843868
niceness: options?.niceness,
844869
tempDir: tempDir.path,

src/services/tools/file_edit_operation.test.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,7 @@
11
import { describe, it, expect } from "bun:test";
22
import { executeFileEditOperation } from "./file_edit_operation";
3-
<<<<<<< HEAD
43
import { WRITE_DENIED_PREFIX } from "@/types/tools";
5-
import { LocalRuntime } from "@/runtime/LocalRuntime";
6-
||||||| parent of a522bfce (🤖 Integrate runtime config with workspace metadata and AIService)
7-
import { WRITE_DENIED_PREFIX } from "./fileCommon";
8-
import { LocalRuntime } from "@/runtime/LocalRuntime";
9-
=======
10-
import { WRITE_DENIED_PREFIX } from "./fileCommon";
114
import { createRuntime } from "@/runtime/runtimeFactory";
12-
>>>>>>> a522bfce (🤖 Integrate runtime config with workspace metadata and AIService)
135

146
const TEST_CWD = "/tmp";
157

src/types/runtime.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* Runtime configuration types for workspace execution environments
3+
*/
4+
5+
export type RuntimeConfig =
6+
| {
7+
type: "local";
8+
/** Working directory on local host */
9+
workdir: string;
10+
}
11+
| {
12+
type: "ssh";
13+
/** SSH host (can be hostname, user@host, or SSH config alias) */
14+
host: string;
15+
/** Working directory on remote host */
16+
workdir: string;
17+
};

src/utils/runtime/fileExists.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { Runtime } from "@/runtime/Runtime";
2+
3+
/**
4+
* Check if a path exists using runtime.stat()
5+
* @param runtime Runtime instance to use
6+
* @param path Path to check
7+
* @returns True if path exists, false otherwise
8+
*/
9+
export async function fileExists(runtime: Runtime, path: string): Promise<boolean> {
10+
try {
11+
await runtime.stat(path);
12+
return true;
13+
} catch {
14+
return false;
15+
}
16+
}

0 commit comments

Comments
 (0)