Skip to content

Commit da92dc4

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 7ecbfee commit da92dc4

File tree

3 files changed

+226
-12
lines changed

3 files changed

+226
-12
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: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,13 @@ import { secretsToRecord } from "@/types/secrets";
3131
import { DisposableTempDir } from "@/services/tempDir";
3232
import { BashExecutionService } from "@/services/bashExecutionService";
3333
import { InitStateManager } from "@/services/initStateManager";
34+
<<<<<<< HEAD
3435
import { LocalRuntime } from "@/runtime/LocalRuntime";
3536
import { createRuntime } from "@/runtime/runtimeFactory";
37+
||||||| parent of f7f18ddf (🤖 Integrate init hooks with Runtime.createWorkspace())
38+
=======
39+
import { createRuntime } from "@/runtime/runtimeFactory";
40+
>>>>>>> f7f18ddf (🤖 Integrate init hooks with Runtime.createWorkspace())
3641

3742
/**
3843
* IpcMain - Manages all IPC handlers and service coordination
@@ -274,13 +279,41 @@ export class IpcMain {
274279
// Generate stable workspace ID (stored in config, not used for directory name)
275280
const workspaceId = this.config.generateStableId();
276281

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

283-
if (result.success && result.path) {
316+
if (result.success && result.workspacePath) {
284317
const projectName =
285318
projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown";
286319

@@ -306,7 +339,7 @@ export class IpcMain {
306339
}
307340
// Add workspace to project config with full metadata
308341
projectConfig.workspaces.push({
309-
path: result.path!,
342+
path: result.workspacePath!,
310343
id: workspaceId,
311344
name: branchName,
312345
createdAt: metadata.createdAt,
@@ -327,13 +360,8 @@ export class IpcMain {
327360
const session = this.getOrCreateSession(workspaceId);
328361
session.emitMetadata(completeMetadata);
329362

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

338366
// Return complete metadata with paths for frontend
339367
return {

0 commit comments

Comments
 (0)