Add workspace setup streaming and setup tab

This commit is contained in:
Mohamed Boudra
2026-03-31 09:09:38 +07:00
parent 266d042528
commit 1f6ffa9e0e
28 changed files with 1645 additions and 165 deletions
@@ -213,6 +213,80 @@ describe("DaemonClient", () => {
expect(client.getConnectionState().status).toBe("disposed");
});
test("normalizes workspace_setup_progress into a workspace-scoped daemon event", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const events: Array<Parameters<Parameters<typeof client.subscribe>[0]>[0]> = [];
client.subscribe((event) => {
events.push(event);
});
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
mock.triggerMessage(
wrapSessionMessage({
type: "workspace_setup_progress",
payload: {
workspaceId: "/tmp/project/.paseo/worktrees/feature-a",
status: "running",
detail: {
type: "worktree_setup",
worktreePath: "/tmp/project/.paseo/worktrees/feature-a",
branchName: "feature-a",
log: "phase-one\n",
commands: [
{
index: 1,
command: "npm install",
cwd: "/tmp/project/.paseo/worktrees/feature-a",
status: "running",
exitCode: null,
},
],
},
error: null,
},
}),
);
expect(events).toContainEqual({
type: "workspace_setup_progress",
workspaceId: "/tmp/project/.paseo/worktrees/feature-a",
payload: {
workspaceId: "/tmp/project/.paseo/worktrees/feature-a",
status: "running",
detail: {
type: "worktree_setup",
worktreePath: "/tmp/project/.paseo/worktrees/feature-a",
branchName: "feature-a",
log: "phase-one\n",
commands: [
{
index: 1,
command: "npm install",
cwd: "/tmp/project/.paseo/worktrees/feature-a",
status: "running",
exitCode: null,
},
],
},
error: null,
},
});
});
test("sends explicit shutdown_server_request via shutdownServer", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -123,6 +123,11 @@ export type DaemonEvent =
workspaceId: string;
payload: Extract<SessionOutboundMessage, { type: "workspace_update" }>["payload"];
}
| {
type: "workspace_setup_progress";
workspaceId: string;
payload: Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }>["payload"];
}
| {
type: "agent_stream";
agentId: string;
@@ -3523,6 +3528,12 @@ export class DaemonClient {
workspaceId: msg.payload.kind === "upsert" ? msg.payload.workspace.id : msg.payload.id,
payload: msg.payload,
};
case "workspace_setup_progress":
return {
type: "workspace_setup_progress",
workspaceId: msg.payload.workspaceId,
payload: msg.payload,
};
case "agent_stream":
return {
type: "agent_stream",
@@ -299,21 +299,25 @@ export async function createAgentManagementMcpServer(
};
let resolvedCwd = expandUserPath(cwd);
let worktreeConfig: WorktreeConfig | undefined;
let worktreeBootstrap:
| {
worktree: WorktreeConfig;
shouldBootstrap: boolean;
}
| undefined;
if (worktreeName) {
if (!baseBranch) {
throw new Error("baseBranch is required when creating a worktree");
}
const worktree = await createAgentWorktree({
worktreeBootstrap = await createAgentWorktree({
branchName: worktreeName,
cwd: resolvedCwd,
baseBranch,
worktreeSlug: worktreeName,
paseoHome: options.paseoHome,
});
resolvedCwd = worktree.worktreePath;
worktreeConfig = worktree;
resolvedCwd = worktreeBootstrap.worktree.worktreePath;
}
const provider: AgentProvider = agentType ?? "claude";
@@ -325,10 +329,11 @@ export async function createAgentManagementMcpServer(
title: normalizedTitle ?? undefined,
});
if (worktreeConfig) {
if (worktreeBootstrap) {
void runAsyncWorktreeBootstrap({
agentId: snapshot.id,
worktree: worktreeConfig,
worktree: worktreeBootstrap.worktree,
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
terminalManager: options.terminalManager ?? null,
appendTimelineItem: (item) =>
appendTimelineItemIfAgentKnown({
@@ -431,6 +431,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
let resolvedCwd: string;
let resolvedMode: string | undefined;
let worktreeConfig: WorktreeConfig | undefined;
let shouldBootstrapWorktree: boolean | undefined;
if (callerAgentId) {
const callerArgs = agentToAgentCreateAgentArgsSchema.parse(args);
@@ -467,15 +468,16 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
if (!baseBranch) {
throw new Error("baseBranch is required when creating a worktree");
}
const worktree = await createAgentWorktree({
const worktreeBootstrap = await createAgentWorktree({
branchName: worktreeName,
cwd: resolvedCwd,
baseBranch,
worktreeSlug: worktreeName,
paseoHome: options.paseoHome,
});
resolvedCwd = worktree.worktreePath;
worktreeConfig = worktree;
resolvedCwd = worktreeBootstrap.worktree.worktreePath;
worktreeConfig = worktreeBootstrap.worktree;
shouldBootstrapWorktree = worktreeBootstrap.shouldBootstrap;
}
resolvedMode = initialMode;
@@ -500,6 +502,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
void runAsyncWorktreeBootstrap({
agentId: snapshot.id,
worktree: worktreeConfig,
shouldBootstrap: shouldBootstrapWorktree,
terminalManager: terminalManager ?? null,
appendTimelineItem: (item) =>
appendTimelineItemIfAgentKnown({
+12 -4
View File
@@ -2664,7 +2664,7 @@ export class Session {
...(provisionalTitle ? { title: provisionalTitle } : {}),
};
const { sessionConfig, worktreeConfig } = await this.buildAgentSessionConfig(
const { sessionConfig, worktreeBootstrap } = await this.buildAgentSessionConfig(
resolvedConfig,
git,
worktreeName,
@@ -2724,10 +2724,11 @@ export class Session {
});
}
if (worktreeConfig) {
if (worktreeBootstrap) {
void runAsyncWorktreeBootstrap({
agentId: snapshot.id,
worktree: worktreeConfig,
worktree: worktreeBootstrap.worktree,
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
terminalManager: this.terminalManager,
appendTimelineItem: (item) =>
appendTimelineItemIfAgentKnown({
@@ -2909,7 +2910,10 @@ export class Session {
gitOptions?: GitSetupOptions,
legacyWorktreeName?: string,
_labels?: Record<string, string>,
): Promise<{ sessionConfig: AgentSessionConfig; worktreeConfig?: WorktreeConfig }> {
): Promise<{
sessionConfig: AgentSessionConfig;
worktreeBootstrap?: { worktree: WorktreeConfig; shouldBootstrap: boolean };
}> {
return buildWorktreeAgentSessionConfig(
{
paseoHome: this.paseoHome,
@@ -5562,8 +5566,12 @@ export class Session {
paseoHome: this.paseoHome,
emitWorkspaceUpdateForCwd: (cwd, emitOptions) =>
this.emitWorkspaceUpdateForCwd(cwd, emitOptions),
emit: (message) => this.emit(message),
sessionLogger: this.sessionLogger,
terminalManager: this.terminalManager,
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
serviceRouteStore: this.serviceRouteStore,
daemonPort: this.getDaemonTcpPort?.() ?? null,
},
options,
);
@@ -56,7 +56,7 @@ describe("runAsyncWorktreeBootstrap", () => {
stdio: "pipe",
});
const worktree = await createAgentWorktree({
const worktreeBootstrap = await createAgentWorktree({
cwd: repoDir,
branchName: "feature-streaming-setup",
baseBranch: "main",
@@ -69,7 +69,8 @@ describe("runAsyncWorktreeBootstrap", () => {
await runAsyncWorktreeBootstrap({
agentId: "agent-test",
worktree,
worktree: worktreeBootstrap.worktree,
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
terminalManager: null,
appendTimelineItem: async (item) => {
persisted.push(item);
@@ -160,7 +161,7 @@ describe("runAsyncWorktreeBootstrap", () => {
stdio: "pipe",
});
const worktree = await createAgentWorktree({
const worktreeBootstrap = await createAgentWorktree({
cwd: repoDir,
branchName: "feature-live-failure",
baseBranch: "main",
@@ -172,7 +173,8 @@ describe("runAsyncWorktreeBootstrap", () => {
await expect(
runAsyncWorktreeBootstrap({
agentId: "agent-live-failure",
worktree,
worktree: worktreeBootstrap.worktree,
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
terminalManager: null,
appendTimelineItem: async (item) => {
persisted.push(item);
@@ -210,7 +212,7 @@ describe("runAsyncWorktreeBootstrap", () => {
stdio: "pipe",
});
const worktree = await createAgentWorktree({
const worktreeBootstrap = await createAgentWorktree({
cwd: repoDir,
branchName: "feature-large-output",
baseBranch: "main",
@@ -221,7 +223,8 @@ describe("runAsyncWorktreeBootstrap", () => {
const persisted: AgentTimelineItem[] = [];
await runAsyncWorktreeBootstrap({
agentId: "agent-large-output",
worktree,
worktree: worktreeBootstrap.worktree,
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
terminalManager: null,
appendTimelineItem: async (item) => {
persisted.push(item);
@@ -266,7 +269,7 @@ describe("runAsyncWorktreeBootstrap", () => {
stdio: "pipe",
});
const worktree = await createAgentWorktree({
const worktreeBootstrap = await createAgentWorktree({
cwd: repoDir,
branchName: "feature-terminal-readiness",
baseBranch: "main",
@@ -280,7 +283,8 @@ describe("runAsyncWorktreeBootstrap", () => {
await runAsyncWorktreeBootstrap({
agentId: "agent-terminal-readiness",
worktree,
worktree: worktreeBootstrap.worktree,
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
terminalManager: {
async getTerminals() {
return [];
@@ -357,7 +361,7 @@ describe("runAsyncWorktreeBootstrap", () => {
stdio: "pipe",
});
const worktree = await createAgentWorktree({
const worktreeBootstrap = await createAgentWorktree({
cwd: repoDir,
branchName: "feature-shared-runtime-port",
baseBranch: "main",
@@ -370,7 +374,8 @@ describe("runAsyncWorktreeBootstrap", () => {
const persisted: AgentTimelineItem[] = [];
await runAsyncWorktreeBootstrap({
agentId: "agent-shared-runtime-port",
worktree,
worktree: worktreeBootstrap.worktree,
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
terminalManager: {
async getTerminals() {
return [];
@@ -416,13 +421,13 @@ describe("runAsyncWorktreeBootstrap", () => {
emitLiveTimelineItem: async () => true,
});
const setupPortPath = join(worktree.worktreePath, "setup-port.txt");
const setupPortPath = join(worktreeBootstrap.worktree.worktreePath, "setup-port.txt");
await waitForPathExists(setupPortPath);
const setupPort = readFileSync(setupPortPath, "utf8").trim();
expect(setupPort.length).toBeGreaterThan(0);
expect(registeredEnvs).toHaveLength(1);
expect(registeredEnvs[0]?.cwd).toBe(worktree.worktreePath);
expect(registeredEnvs[0]?.cwd).toBe(worktreeBootstrap.worktree.worktreePath);
expect(registeredEnvs[0]?.env.PASEO_WORKTREE_PORT).toBe(setupPort);
expect(createTerminalEnvs.length).toBeGreaterThan(0);
expect(createTerminalEnvs[0]?.PASEO_WORKTREE_PORT).toBe(setupPort);
+113 -68
View File
@@ -19,7 +19,7 @@ import {
type WorktreeRuntimeEnv,
} from "../utils/worktree.js";
import { findFreePort, type ServiceRouteStore } from "./service-proxy.js";
import type { AgentTimelineItem } from "./agent/agent-sdk-types.js";
import type { AgentTimelineItem, ToolCallDetail } from "./agent/agent-sdk-types.js";
export interface WorktreeBootstrapTerminalResult {
name: string | null;
@@ -32,6 +32,7 @@ export interface WorktreeBootstrapTerminalResult {
export interface RunAsyncWorktreeBootstrapOptions {
agentId: string;
worktree: WorktreeConfig;
shouldBootstrap?: boolean;
terminalManager: TerminalManager | null;
serviceRouteStore?: ServiceRouteStore;
daemonPort?: number | null;
@@ -48,6 +49,11 @@ export interface CreateAgentWorktreeOptions {
paseoHome?: string;
}
export interface CreateAgentWorktreeResult {
worktree: WorktreeConfig;
shouldBootstrap: boolean;
}
const MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES = 64 * 1024;
const WORKTREE_SETUP_TRUNCATION_MARKER = "\n...<output truncated in the middle>...\n";
const WORKTREE_BOOTSTRAP_TERMINAL_READY_TIMEOUT_MS = 1_500;
@@ -56,8 +62,6 @@ const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
GIT_OPTIONAL_LOCKS: "0",
};
const execAsync = promisify(exec);
const worktreeSetupEligibility = new WeakMap<WorktreeConfig, boolean>();
type MiddleTruncationAccumulator = {
totalBytes: number;
head: string;
@@ -65,6 +69,12 @@ type MiddleTruncationAccumulator = {
truncated: boolean;
};
export type WorktreeSetupOutputAccumulator = MiddleTruncationAccumulator;
export type WorktreeSetupProgressAccumulator = {
resultsByIndex: Map<number, WorktreeSetupCommandResult>;
outputAccumulatorsByIndex: Map<number, WorktreeSetupOutputAccumulator>;
};
function byteLength(text: string): number {
return Buffer.byteLength(text, "utf8");
}
@@ -91,7 +101,7 @@ function sliceLastBytes(text: string, maxBytes: number): string {
return bytes.subarray(bytes.length - maxBytes).toString("utf8");
}
function createMiddleTruncationAccumulator(): MiddleTruncationAccumulator {
export function createWorktreeSetupOutputAccumulator(): WorktreeSetupOutputAccumulator {
return {
totalBytes: 0,
head: "",
@@ -108,8 +118,8 @@ function getHeadTailBudgets(maxBytes: number): { headBytes: number; tailBytes: n
return { headBytes, tailBytes };
}
function appendToMiddleTruncationAccumulator(
accumulator: MiddleTruncationAccumulator,
export function appendWorktreeSetupOutputAccumulator(
accumulator: WorktreeSetupOutputAccumulator,
chunk: string,
): void {
if (!chunk) {
@@ -166,16 +176,17 @@ function renderMiddleTruncationAccumulator(accumulator: MiddleTruncationAccumula
export async function createAgentWorktree(
options: CreateAgentWorktreeOptions,
): Promise<WorktreeConfig> {
): Promise<CreateAgentWorktreeResult> {
const existingWorktree = await findExistingPaseoWorktreeBySlug(options);
if (existingWorktree) {
const branchName = await resolveBranchNameForWorktreePath(existingWorktree.path);
const reusedWorktree = {
branchName,
worktreePath: existingWorktree.path,
return {
worktree: {
branchName,
worktreePath: existingWorktree.path,
},
shouldBootstrap: false,
};
worktreeSetupEligibility.set(reusedWorktree, false);
return reusedWorktree;
}
const createdWorktree = await createWorktree({
@@ -186,8 +197,10 @@ export async function createAgentWorktree(
runSetup: false,
paseoHome: options.paseoHome,
});
worktreeSetupEligibility.set(createdWorktree, true);
return createdWorktree;
return {
worktree: createdWorktree,
shouldBootstrap: true,
};
}
async function findExistingPaseoWorktreeBySlug(options: CreateAgentWorktreeOptions) {
@@ -226,7 +239,7 @@ function commandStatusFromResult(
function buildWorktreeSetupLog(input: {
results: WorktreeSetupCommandResult[];
outputAccumulatorsByIndex?: Map<number, MiddleTruncationAccumulator>;
outputAccumulatorsByIndex?: Map<number, WorktreeSetupOutputAccumulator>;
}): { log: string; truncated: boolean } {
const { results, outputAccumulatorsByIndex } = input;
if (results.length === 0) {
@@ -266,14 +279,68 @@ function buildWorktreeSetupLog(input: {
};
}
function buildSetupTimelineItem(input: {
callId: string;
status: "running" | "completed" | "failed";
export function createWorktreeSetupProgressAccumulator(): WorktreeSetupProgressAccumulator {
return {
resultsByIndex: new Map(),
outputAccumulatorsByIndex: new Map(),
};
}
export function applyWorktreeSetupProgressEvent(
accumulator: WorktreeSetupProgressAccumulator,
event: Parameters<NonNullable<Parameters<typeof runWorktreeSetupCommands>[0]["onEvent"]>>[0],
): void {
const existing = accumulator.resultsByIndex.get(event.index);
const baseResult: WorktreeSetupCommandResult = existing ?? {
command: event.command,
cwd: event.cwd,
stdout: "",
stderr: "",
exitCode: null,
durationMs: 0,
};
if (event.type === "output") {
const outputAccumulator =
accumulator.outputAccumulatorsByIndex.get(event.index) ??
createWorktreeSetupOutputAccumulator();
appendWorktreeSetupOutputAccumulator(outputAccumulator, event.chunk);
accumulator.outputAccumulatorsByIndex.set(event.index, outputAccumulator);
accumulator.resultsByIndex.set(event.index, {
...baseResult,
stdout: baseResult.stdout,
stderr: baseResult.stderr,
});
return;
}
if (event.type === "command_completed") {
accumulator.resultsByIndex.set(event.index, {
...baseResult,
stdout: event.stdout,
stderr: event.stderr,
exitCode: event.exitCode,
durationMs: event.durationMs,
});
return;
}
accumulator.resultsByIndex.set(event.index, baseResult);
}
export function getWorktreeSetupProgressResults(
accumulator: WorktreeSetupProgressAccumulator,
): WorktreeSetupCommandResult[] {
return Array.from(accumulator.resultsByIndex.entries())
.sort((a, b) => a[0] - b[0])
.map(([, result]) => result);
}
export function buildWorktreeSetupDetail(input: {
worktree: WorktreeConfig;
results: WorktreeSetupCommandResult[];
outputAccumulatorsByIndex?: Map<number, MiddleTruncationAccumulator>;
errorMessage: string | null;
}): AgentTimelineItem {
outputAccumulatorsByIndex?: Map<number, WorktreeSetupOutputAccumulator>;
}): Extract<ToolCallDetail, { type: "worktree_setup" }> {
const commands = input.results.map((result, index) => ({
index: index + 1,
command: result.command,
@@ -286,14 +353,30 @@ function buildSetupTimelineItem(input: {
results: input.results,
outputAccumulatorsByIndex: input.outputAccumulatorsByIndex,
});
const detail = {
type: "worktree_setup" as const,
return {
type: "worktree_setup",
worktreePath: input.worktree.worktreePath,
branchName: input.worktree.branchName,
log: renderedLog.log,
commands,
...(renderedLog.truncated ? { truncated: true } : {}),
};
}
function buildSetupTimelineItem(input: {
callId: string;
status: "running" | "completed" | "failed";
worktree: WorktreeConfig;
results: WorktreeSetupCommandResult[];
outputAccumulatorsByIndex?: Map<number, WorktreeSetupOutputAccumulator>;
errorMessage: string | null;
}): AgentTimelineItem {
const detail = buildWorktreeSetupDetail({
worktree: input.worktree,
results: input.results,
outputAccumulatorsByIndex: input.outputAccumulatorsByIndex,
});
if (input.status === "running") {
return {
@@ -528,7 +611,7 @@ async function runWorktreeTerminalBootstrap(
export async function runAsyncWorktreeBootstrap(
options: RunAsyncWorktreeBootstrapOptions,
): Promise<void> {
if (worktreeSetupEligibility.get(options.worktree) === false) {
if (options.shouldBootstrap === false) {
return;
}
@@ -536,17 +619,14 @@ export async function runAsyncWorktreeBootstrap(
let setupResults: WorktreeSetupCommandResult[] = [];
let runtimeEnv: WorktreeRuntimeEnv | null = null;
const emitLiveTimelineItem = options.emitLiveTimelineItem;
const runningResultsByIndex = new Map<number, WorktreeSetupCommandResult>();
const outputAccumulatorsByIndex = new Map<number, MiddleTruncationAccumulator>();
const progressAccumulator = createWorktreeSetupProgressAccumulator();
let liveEmitQueue = Promise.resolve();
const queueLiveRunningEmit = () => {
if (!emitLiveTimelineItem) {
return;
}
const runningResults = Array.from(runningResultsByIndex.entries())
.sort((a, b) => a[0] - b[0])
.map(([, result]) => result);
const runningResults = getWorktreeSetupProgressResults(progressAccumulator);
liveEmitQueue = liveEmitQueue.then(async () => {
try {
await emitLiveTimelineItem(
@@ -555,7 +635,7 @@ export async function runAsyncWorktreeBootstrap(
status: "running",
worktree: options.worktree,
results: runningResults,
outputAccumulatorsByIndex,
outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex,
errorMessage: null,
}),
);
@@ -584,42 +664,7 @@ export async function runAsyncWorktreeBootstrap(
cleanupOnFailure: false,
runtimeEnv,
onEvent: (event) => {
const existing = runningResultsByIndex.get(event.index);
const baseResult: WorktreeSetupCommandResult = existing ?? {
command: event.command,
cwd: event.cwd,
stdout: "",
stderr: "",
exitCode: null,
durationMs: 0,
};
if (event.type === "output") {
const outputAccumulator =
outputAccumulatorsByIndex.get(event.index) ?? createMiddleTruncationAccumulator();
appendToMiddleTruncationAccumulator(outputAccumulator, event.chunk);
outputAccumulatorsByIndex.set(event.index, outputAccumulator);
runningResultsByIndex.set(event.index, {
...baseResult,
// Keep the timeline command model lightweight; output is carried in
// outputAccumulatorsByIndex.
stdout: baseResult.stdout,
stderr: baseResult.stderr,
});
queueLiveRunningEmit();
return;
}
if (event.type === "command_completed") {
runningResultsByIndex.set(event.index, {
...baseResult,
stdout: event.stdout,
stderr: event.stderr,
exitCode: event.exitCode,
durationMs: event.durationMs,
});
queueLiveRunningEmit();
return;
}
runningResultsByIndex.set(event.index, baseResult);
applyWorktreeSetupProgressEvent(progressAccumulator, event);
queueLiveRunningEmit();
},
});
@@ -631,7 +676,7 @@ export async function runAsyncWorktreeBootstrap(
status: "completed",
worktree: options.worktree,
results: setupResults,
outputAccumulatorsByIndex,
outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex,
errorMessage: null,
}),
);
@@ -650,7 +695,7 @@ export async function runAsyncWorktreeBootstrap(
status: "failed",
worktree: options.worktree,
results: setupResults,
outputAccumulatorsByIndex,
outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex,
errorMessage: message,
}),
);
@@ -0,0 +1,415 @@
import { execSync } from "node:child_process";
import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { SessionOutboundMessage } from "./messages.js";
import { ServiceRouteStore } from "./service-proxy.js";
import { createPaseoWorktreeInBackground } from "./worktree-session.js";
import { computeWorktreePath, createWorktree } from "../utils/worktree.js";
function createLogger() {
return {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as any;
}
function createTerminalManagerStub(options?: {
createTerminal?: (input: {
cwd: string;
name?: string;
env?: Record<string, string>;
}) => Promise<any>;
}) {
const terminals: Array<{
id: string;
cwd: string;
name: string | undefined;
env: Record<string, string> | undefined;
sent: string[];
}> = [];
return {
terminals,
manager: {
registerCwdEnv: vi.fn(),
createTerminal: vi.fn(async (input: {
cwd: string;
name?: string;
env?: Record<string, string>;
}) => {
if (options?.createTerminal) {
return options.createTerminal(input);
}
const sent: string[] = [];
const terminal = {
id: `terminal-${terminals.length + 1}`,
getState: () => ({
scrollback: [[{ char: "$" }]],
grid: [],
}),
subscribe: () => () => {},
send: (message: { type: string; data: string }) => {
if (message.type === "input") {
sent.push(message.data);
}
},
};
terminals.push({
id: terminal.id,
cwd: input.cwd,
name: input.name,
env: input.env,
sent,
});
return terminal;
}),
} as any,
};
}
function createGitRepo(options?: { paseoConfig?: Record<string, unknown> }) {
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "worktree-session-test-")));
const repoDir = path.join(tempDir, "repo");
execSync(`mkdir -p ${JSON.stringify(repoDir)}`);
execSync("git init -b main", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
if (options?.paseoConfig) {
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
}
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
return { tempDir, repoDir };
}
describe("createPaseoWorktreeInBackground", () => {
const cleanupPaths: string[] = [];
afterEach(() => {
for (const target of cleanupPaths.splice(0)) {
rmSync(target, { recursive: true, force: true });
}
});
test("emits a single completed snapshot for no-setup workspaces and then launches services", async () => {
const { tempDir, repoDir } = createGitRepo({
paseoConfig: {
services: {
web: {
command: "npm run dev",
},
},
},
});
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const worktreePath = await computeWorktreePath(repoDir, "feature-no-setup", paseoHome);
const emitted: SessionOutboundMessage[] = [];
const routeStore = new ServiceRouteStore();
const logger = createLogger();
const terminalManager = createTerminalManagerStub();
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
const archiveWorkspaceRecord = vi.fn(async () => {});
await createPaseoWorktreeInBackground(
{
paseoHome,
emitWorkspaceUpdateForCwd,
emit: (message) => emitted.push(message),
sessionLogger: logger,
terminalManager: terminalManager.manager,
archiveWorkspaceRecord,
serviceRouteStore: routeStore,
daemonPort: 6767,
},
{
requestCwd: repoDir,
repoRoot: repoDir,
baseBranch: "main",
slug: "feature-no-setup",
worktreePath,
},
);
const progressMessages = emitted.filter(
(message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
message.type === "workspace_setup_progress",
);
expect(progressMessages).toHaveLength(1);
expect(progressMessages[0]?.payload).toMatchObject({
workspaceId: worktreePath,
status: "completed",
error: null,
detail: {
type: "worktree_setup",
worktreePath,
branchName: "feature-no-setup",
log: "",
commands: [],
},
});
expect(routeStore.listRoutes()).toEqual([
{ hostname: "feature-no-setup.web.localhost", port: expect.any(Number) },
]);
expect(terminalManager.terminals).toHaveLength(1);
expect(terminalManager.terminals[0]?.cwd).toBe(worktreePath);
expect(terminalManager.terminals[0]?.sent).toEqual(["npm run dev\r"]);
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
});
test("archives the pending workspace and emits a failed snapshot when setup cannot start", async () => {
const { tempDir, repoDir } = createGitRepo();
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const worktreePath = await computeWorktreePath(repoDir, "broken-feature", paseoHome);
const emitted: SessionOutboundMessage[] = [];
const logger = createLogger();
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
const archiveWorkspaceRecord = vi.fn(async () => {});
await createPaseoWorktreeInBackground(
{
paseoHome,
emitWorkspaceUpdateForCwd,
emit: (message) => emitted.push(message),
sessionLogger: logger,
terminalManager: null,
archiveWorkspaceRecord,
serviceRouteStore: null,
daemonPort: null,
},
{
requestCwd: repoDir,
repoRoot: repoDir,
baseBranch: "does-not-exist",
slug: "broken-feature",
worktreePath,
},
);
const progressMessages = emitted.filter(
(message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
message.type === "workspace_setup_progress",
);
expect(progressMessages).toHaveLength(1);
expect(progressMessages[0]?.payload.status).toBe("failed");
expect(progressMessages[0]?.payload.error).toContain("does-not-exist");
expect(progressMessages[0]?.payload.detail.commands).toEqual([]);
expect(archiveWorkspaceRecord).toHaveBeenCalledWith(worktreePath);
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
});
test("emits running setup snapshots before completed for real setup commands", async () => {
const { tempDir, repoDir } = createGitRepo({
paseoConfig: {
worktree: {
setup: ['sh -c "printf \'phase-one\\\\n\'; sleep 0.1; printf \'phase-two\\\\n\'"'],
},
},
});
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const worktreePath = await computeWorktreePath(repoDir, "feature-running-setup", paseoHome);
const emitted: SessionOutboundMessage[] = [];
const logger = createLogger();
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
const archiveWorkspaceRecord = vi.fn(async () => {});
await createPaseoWorktreeInBackground(
{
paseoHome,
emitWorkspaceUpdateForCwd,
emit: (message) => emitted.push(message),
sessionLogger: logger,
terminalManager: null,
archiveWorkspaceRecord,
serviceRouteStore: null,
daemonPort: null,
},
{
requestCwd: repoDir,
repoRoot: repoDir,
baseBranch: "main",
slug: "feature-running-setup",
worktreePath,
},
);
const progressMessages = emitted.filter(
(message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
message.type === "workspace_setup_progress",
);
expect(progressMessages.length).toBeGreaterThan(1);
expect(progressMessages.at(-1)?.payload.status).toBe("completed");
const runningMessages = progressMessages.filter((message) => message.payload.status === "running");
expect(runningMessages.length).toBeGreaterThan(0);
expect(progressMessages.findIndex((message) => message.payload.status === "running")).toBeLessThan(
progressMessages.findIndex((message) => message.payload.status === "completed"),
);
expect(runningMessages[0]?.payload.detail.log).toContain("phase-one");
expect(runningMessages[0]?.payload.detail.commands[0]).toMatchObject({
index: 1,
command: 'sh -c "printf \'phase-one\\\\n\'; sleep 0.1; printf \'phase-two\\\\n\'"',
status: "running",
});
expect(progressMessages.at(-1)?.payload).toMatchObject({
workspaceId: worktreePath,
status: "completed",
error: null,
detail: {
type: "worktree_setup",
worktreePath,
branchName: "feature-running-setup",
},
});
expect(progressMessages.at(-1)?.payload.detail.log).toContain("phase-two");
expect(progressMessages.at(-1)?.payload.detail.commands[0]).toMatchObject({
index: 1,
command: 'sh -c "printf \'phase-one\\\\n\'; sleep 0.1; printf \'phase-two\\\\n\'"',
status: "completed",
exitCode: 0,
});
});
test("keeps setup completed when service launch fails afterward", async () => {
const { tempDir, repoDir } = createGitRepo({
paseoConfig: {
services: {
web: {
command: "npm run dev",
},
},
},
});
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const worktreePath = await computeWorktreePath(repoDir, "feature-service-failure", paseoHome);
const emitted: SessionOutboundMessage[] = [];
const routeStore = new ServiceRouteStore();
const logger = createLogger();
const terminalManager = createTerminalManagerStub({
createTerminal: async () => {
throw new Error("terminal spawn failed");
},
});
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
const archiveWorkspaceRecord = vi.fn(async () => {});
await createPaseoWorktreeInBackground(
{
paseoHome,
emitWorkspaceUpdateForCwd,
emit: (message) => emitted.push(message),
sessionLogger: logger,
terminalManager: terminalManager.manager,
archiveWorkspaceRecord,
serviceRouteStore: routeStore,
daemonPort: 6767,
},
{
requestCwd: repoDir,
repoRoot: repoDir,
baseBranch: "main",
slug: "feature-service-failure",
worktreePath,
},
);
const progressMessages = emitted.filter(
(message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
message.type === "workspace_setup_progress",
);
expect(progressMessages).toHaveLength(1);
expect(progressMessages[0]?.payload.status).toBe("completed");
expect(progressMessages[0]?.payload.error).toBeNull();
expect(emitted.some((message) => message.type === "workspace_setup_progress" && message.payload.status === "failed")).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(Error),
worktreePath,
}),
"Failed to spawn worktree services after workspace setup completed",
);
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
});
test("reused existing worktrees do not rerun setup or spawn services", async () => {
const { tempDir, repoDir } = createGitRepo({
paseoConfig: {
worktree: {
setup: ["printf 'ran' > setup-ran.txt"],
},
services: {
web: {
command: "npm run dev",
},
},
},
});
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const existingWorktree = await createWorktree({
branchName: "reused-worktree",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "reused-worktree",
runSetup: false,
paseoHome,
});
const emitted: SessionOutboundMessage[] = [];
const routeStore = new ServiceRouteStore();
const logger = createLogger();
const terminalManager = createTerminalManagerStub();
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
const archiveWorkspaceRecord = vi.fn(async () => {});
await createPaseoWorktreeInBackground(
{
paseoHome,
emitWorkspaceUpdateForCwd,
emit: (message) => emitted.push(message),
sessionLogger: logger,
terminalManager: terminalManager.manager,
archiveWorkspaceRecord,
serviceRouteStore: routeStore,
daemonPort: 6767,
},
{
requestCwd: repoDir,
repoRoot: repoDir,
baseBranch: "main",
slug: "reused-worktree",
worktreePath: existingWorktree.worktreePath,
},
);
expect(
emitted.some((message) => message.type === "workspace_setup_progress"),
).toBe(false);
expect(routeStore.listRoutes()).toEqual([]);
expect(terminalManager.terminals).toHaveLength(0);
expect(
readFileSync(path.join(existingWorktree.worktreePath, "README.md"), "utf8"),
).toContain("hello");
expect(() => readFileSync(path.join(existingWorktree.worktreePath, "setup-ran.txt"), "utf8")).toThrow();
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(existingWorktree.worktreePath);
});
});
+141 -50
View File
@@ -20,8 +20,16 @@ import type {
WorkspaceRegistry,
} from "./workspace-registry.js";
import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js";
import { createAgentWorktree } from "./worktree-bootstrap.js";
import {
applyWorktreeSetupProgressEvent,
buildWorktreeSetupDetail,
createAgentWorktree,
createWorktreeSetupProgressAccumulator,
getWorktreeSetupProgressResults,
spawnWorktreeServices,
} from "./worktree-bootstrap.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { ServiceRouteStore } from "./service-proxy.js";
import {
getCheckoutStatusLite,
resolveRepositoryDefaultBranch,
@@ -35,9 +43,12 @@ import {
listPaseoWorktrees,
resolvePaseoWorktreeRootForCwd,
resolveWorktreeRuntimeEnv,
runWorktreeSetupCommands,
slugify,
validateBranchSlug,
type WorktreeConfig,
type WorktreeSetupCommandResult,
WorktreeSetupError,
} from "../utils/worktree.js";
import { READ_ONLY_GIT_ENV, toCheckoutError } from "./checkout-git-utils.js";
@@ -105,8 +116,12 @@ type CreatePaseoWorktreeInBackgroundDependencies = {
cwd: string,
options?: { dedupeGitState?: boolean },
) => Promise<void>;
emit: EmitSessionMessage;
sessionLogger: Logger;
terminalManager: TerminalManager | null;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
serviceRouteStore: ServiceRouteStore | null;
daemonPort: number | null;
};
type HandleCreatePaseoWorktreeRequestDependencies = {
@@ -143,10 +158,13 @@ export async function buildAgentSessionConfig(
gitOptions?: GitSetupOptions,
legacyWorktreeName?: string,
_labels?: Record<string, string>,
): Promise<{ sessionConfig: AgentSessionConfig; worktreeConfig?: WorktreeConfig }> {
): Promise<{
sessionConfig: AgentSessionConfig;
worktreeBootstrap?: { worktree: WorktreeConfig; shouldBootstrap: boolean };
}> {
let cwd = expandTilde(config.cwd);
const normalized = normalizeGitOptions(gitOptions, legacyWorktreeName);
let worktreeConfig: WorktreeConfig | undefined;
let worktreeBootstrap: { worktree: WorktreeConfig; shouldBootstrap: boolean } | undefined;
if (!normalized) {
return {
@@ -188,8 +206,8 @@ export async function buildAgentSessionConfig(
worktreeSlug: normalized.worktreeSlug ?? targetBranch,
paseoHome: dependencies.paseoHome,
});
cwd = createdWorktree.worktreePath;
worktreeConfig = createdWorktree;
cwd = createdWorktree.worktree.worktreePath;
worktreeBootstrap = createdWorktree;
} else if (normalized.createNewBranch) {
const baseBranch =
normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome));
@@ -207,7 +225,7 @@ export async function buildAgentSessionConfig(
...config,
cwd,
},
worktreeConfig,
worktreeBootstrap,
};
}
@@ -631,54 +649,127 @@ export async function createPaseoWorktreeInBackground(
worktreePath: string;
},
): Promise<void> {
let setupTerminalId: string | null = null;
let worktree: WorktreeConfig = {
branchName: options.slug,
worktreePath: options.worktreePath,
};
let setupResults: WorktreeSetupCommandResult[] = [];
let setupStarted = false;
const progressAccumulator = createWorktreeSetupProgressAccumulator();
const emitSetupProgress = (status: "running" | "completed" | "failed", error: string | null) => {
dependencies.emit({
type: "workspace_setup_progress",
payload: {
workspaceId: normalizePersistedWorkspaceId(worktree.worktreePath),
status,
detail: buildWorktreeSetupDetail({
worktree,
results:
status === "running" ? getWorktreeSetupProgressResults(progressAccumulator) : setupResults,
outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex,
}),
error,
},
});
};
try {
await createAgentWorktree({
cwd: options.repoRoot,
branchName: options.slug,
baseBranch: options.baseBranch,
worktreeSlug: options.slug,
paseoHome: dependencies.paseoHome,
});
const setupCommands = getWorktreeSetupCommands(options.worktreePath);
if (setupCommands.length > 0 && dependencies.terminalManager) {
const runtimeEnv = await resolveWorktreeRuntimeEnv({
worktreePath: options.worktreePath,
try {
const createdWorktree = await createAgentWorktree({
cwd: options.repoRoot,
branchName: options.slug,
repoRootPath: options.repoRoot,
});
dependencies.terminalManager.registerCwdEnv({
cwd: options.worktreePath,
env: runtimeEnv,
});
const terminal = await dependencies.terminalManager.createTerminal({
cwd: options.worktreePath,
name: `setup-${options.slug}`,
env: runtimeEnv,
});
setupTerminalId = terminal.id;
for (const command of setupCommands) {
terminal.send({
type: "input",
data: `${command}\r`,
});
}
}
} catch (error) {
dependencies.sessionLogger.error(
{
err: error,
cwd: options.requestCwd,
repoRoot: options.repoRoot,
baseBranch: options.baseBranch,
worktreeSlug: options.slug,
worktreePath: options.worktreePath,
setupTerminalId,
},
"Background worktree creation failed",
);
paseoHome: dependencies.paseoHome,
});
worktree = createdWorktree.worktree;
if (!createdWorktree.shouldBootstrap) {
return;
}
const setupCommands = getWorktreeSetupCommands(worktree.worktreePath);
if (setupCommands.length === 0) {
setupStarted = true;
emitSetupProgress("completed", null);
} else {
const runtimeEnv = await resolveWorktreeRuntimeEnv({
worktreePath: worktree.worktreePath,
branchName: worktree.branchName,
repoRootPath: options.repoRoot,
});
dependencies.terminalManager?.registerCwdEnv({
cwd: worktree.worktreePath,
env: runtimeEnv,
});
setupStarted = true;
setupResults = await runWorktreeSetupCommands({
worktreePath: worktree.worktreePath,
branchName: worktree.branchName,
cleanupOnFailure: false,
repoRootPath: options.repoRoot,
runtimeEnv,
onEvent: (event) => {
applyWorktreeSetupProgressEvent(progressAccumulator, event);
emitSetupProgress("running", null);
},
});
emitSetupProgress("completed", null);
}
} catch (error) {
if (error instanceof WorktreeSetupError) {
setupResults = error.results;
}
const message = error instanceof Error ? error.message : String(error);
emitSetupProgress("failed", message);
if (!setupStarted) {
await dependencies.archiveWorkspaceRecord(normalizePersistedWorkspaceId(options.worktreePath));
worktree = {
...worktree,
worktreePath: options.worktreePath,
};
}
dependencies.sessionLogger.error(
{
err: error,
cwd: options.requestCwd,
repoRoot: options.repoRoot,
worktreeSlug: options.slug,
worktreePath: options.worktreePath,
setupStarted,
},
"Background worktree creation failed",
);
return;
}
if (
!dependencies.terminalManager ||
!dependencies.serviceRouteStore ||
dependencies.daemonPort === null ||
dependencies.daemonPort === undefined
) {
return;
}
try {
await spawnWorktreeServices({
repoRoot: worktree.worktreePath,
branchName: worktree.branchName,
daemonPort: dependencies.daemonPort,
routeStore: dependencies.serviceRouteStore,
terminalManager: dependencies.terminalManager,
logger: dependencies.sessionLogger,
});
} catch (error) {
dependencies.sessionLogger.warn(
{ err: error, worktreePath: worktree.worktreePath },
"Failed to spawn worktree services after workspace setup completed",
);
}
} finally {
await dependencies.emitWorkspaceUpdateForCwd(options.worktreePath);
}
+31 -17
View File
@@ -195,7 +195,26 @@ const NonNullUnknownSchema = z.union([
z.object({}).passthrough(),
]);
const WorktreeSetupCommandSnapshotSchema = z.object({
index: z.number().int().positive(),
command: z.string(),
cwd: z.string(),
status: z.enum(["running", "completed", "failed"]),
exitCode: z.number().nullable(),
durationMs: z.number().nonnegative().optional(),
});
const WorktreeSetupDetailPayloadSchema = z.object({
type: z.literal("worktree_setup"),
worktreePath: z.string(),
branchName: z.string(),
log: z.string(),
commands: z.array(WorktreeSetupCommandSnapshotSchema),
truncated: z.boolean().optional(),
});
const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail> = z.discriminatedUnion("type", [
WorktreeSetupDetailPayloadSchema,
z.object({
type: z.literal("shell"),
command: z.string(),
@@ -254,23 +273,6 @@ const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail> = z.discriminatedUn
bytes: z.number().optional(),
durationMs: z.number().optional(),
}),
z.object({
type: z.literal("worktree_setup"),
worktreePath: z.string(),
branchName: z.string(),
log: z.string(),
commands: z.array(
z.object({
index: z.number().int().positive(),
command: z.string(),
cwd: z.string(),
status: z.enum(["running", "completed", "failed"]),
exitCode: z.number().nullable(),
durationMs: z.number().nonnegative().optional(),
}),
),
truncated: z.boolean().optional(),
}),
z.object({
type: z.literal("sub_agent"),
subAgentType: z.string().optional(),
@@ -1680,6 +1682,16 @@ export const WorkspaceUpdateMessageSchema = z.object({
]),
});
export const WorkspaceSetupProgressMessageSchema = z.object({
type: z.literal("workspace_setup_progress"),
payload: z.object({
workspaceId: z.string(),
status: z.enum(["running", "completed", "failed"]),
detail: WorktreeSetupDetailPayloadSchema,
error: z.string().nullable(),
}),
});
export const OpenProjectResponseMessageSchema = z.object({
type: z.literal("open_project_response"),
payload: z.object({
@@ -2251,6 +2263,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ArtifactMessageSchema,
AgentUpdateMessageSchema,
WorkspaceUpdateMessageSchema,
WorkspaceSetupProgressMessageSchema,
AgentStreamMessageSchema,
AgentStatusMessageSchema,
FetchAgentsResponseMessageSchema,
@@ -2334,6 +2347,7 @@ export type ServerInfoStatusPayload = z.infer<typeof ServerInfoStatusPayloadSche
export type RpcErrorMessage = z.infer<typeof RpcErrorMessageSchema>;
export type ArtifactMessage = z.infer<typeof ArtifactMessageSchema>;
export type AgentUpdateMessage = z.infer<typeof AgentUpdateMessageSchema>;
export type WorkspaceSetupProgressMessage = z.infer<typeof WorkspaceSetupProgressMessageSchema>;
export type AgentStreamMessage = z.infer<typeof AgentStreamMessageSchema>;
export type AgentStatusMessage = z.infer<typeof AgentStatusMessageSchema>;
export type ProjectCheckoutLitePayload = z.infer<typeof ProjectCheckoutLitePayloadSchema>;
@@ -50,4 +50,33 @@ describe("workspace message schemas", () => {
expect(result.success).toBe(false);
});
test("parses workspace_setup_progress payload", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "workspace_setup_progress",
payload: {
workspaceId: "/repo/.paseo/worktrees/feature-a",
status: "completed",
detail: {
type: "worktree_setup",
worktreePath: "/repo/.paseo/worktrees/feature-a",
branchName: "feature-a",
log: "done",
commands: [
{
index: 1,
command: "npm install",
cwd: "/repo/.paseo/worktrees/feature-a",
status: "completed",
exitCode: 0,
durationMs: 100,
},
],
},
error: null,
},
});
expect(parsed.type).toBe("workspace_setup_progress");
});
});