diff --git a/packages/app/e2e/workspace-setup-streaming.spec.ts b/packages/app/e2e/workspace-setup-streaming.spec.ts index edd6d3979..6e789634c 100644 --- a/packages/app/e2e/workspace-setup-streaming.spec.ts +++ b/packages/app/e2e/workspace-setup-streaming.spec.ts @@ -1,10 +1,14 @@ +import { rm, writeFile } from "node:fs/promises"; import { test, expect } from "./fixtures"; import { createTempGitRepo } from "./helpers/workspace"; +import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; import { connectWorkspaceSetupClient, createWorkspaceFromSidebar, createWorkspaceThroughDaemon, + expectSetupLogContains, expectSetupPanel, + expectSetupStatus, openHomeWithProject, seedProjectForWorkspaceSetup, waitForWorkspaceSetupProgress, @@ -34,6 +38,58 @@ test.describe("Workspace setup streaming", () => { } }); + test("runs setup through the sidebar and leaves the workspace usable", async ({ page }) => { + const setupTriggerPath = `/tmp/setup-trigger-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("setup-ui-flow-", { + paseoConfig: { + worktree: { + setup: [ + `sh -c 'while [ ! -f "${setupTriggerPath}" ]; do sleep 0.2; done; echo starting setup; sleep 1; echo loading dependencies; sleep 1; echo setup complete'`, + ], + }, + }, + files: [{ path: "src/index.ts", content: "export const ready = true;\n" }], + }); + + try { + await seedProjectForWorkspaceSetup(client, repo.path); + await openHomeWithProject(page, repo.path); + await createWorkspaceFromSidebar(page, repo.path); + + await expectSetupPanel(page); + await expectSetupStatus(page, "Running"); + await writeFile(setupTriggerPath, "start\n"); + await expectSetupLogContains(page, "starting setup"); + await expectSetupLogContains(page, "loading dependencies"); + await expectSetupStatus(page, "Completed"); + await expectSetupLogContains(page, "setup complete"); + + await waitForWorkspaceTabsVisible(page); + await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({ + timeout: 30_000, + }); + + const explorerToggle = page.getByTestId("workspace-explorer-toggle").first(); + if ((await explorerToggle.getAttribute("aria-expanded")) !== "true") { + await explorerToggle.click(); + } + await expect(explorerToggle).toHaveAttribute("aria-expanded", "true", { timeout: 30_000 }); + await page.getByTestId("explorer-tab-files").click(); + await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText("README.md", { exact: true }).first()).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByText("src", { exact: true }).first()).toBeVisible({ + timeout: 30_000, + }); + } finally { + await rm(setupTriggerPath, { force: true }); + await client.close(); + await repo.cleanup(); + } + }); + test("streams running and completed setup snapshots for a successful setup", async () => { const client = await connectWorkspaceSetupClient(); const repo = await createTempGitRepo("setup-success-", { @@ -46,7 +102,14 @@ test.describe("Workspace setup streaming", () => { try { await seedProjectForWorkspaceSetup(client, repo.path); - const running = waitForWorkspaceSetupProgress(client, (payload) => payload.status === "running"); + const initialRunning = waitForWorkspaceSetupProgress( + client, + (payload) => payload.status === "running" && payload.detail.log === "", + ); + const runningWithOutput = waitForWorkspaceSetupProgress( + client, + (payload) => payload.status === "running" && payload.detail.log.includes("starting setup"), + ); const completed = waitForWorkspaceSetupProgress( client, (payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"), @@ -57,9 +120,11 @@ test.describe("Workspace setup streaming", () => { worktreeSlug: "workspace-setup-success", }); - const runningPayload = await running; + const initialPayload = await initialRunning; + const runningPayload = await runningWithOutput; const completedPayload = await completed; + expect(initialPayload.detail.log).toBe(""); expect(runningPayload.detail.log).toContain("starting setup"); expect(completedPayload.detail.log).toContain("setup complete"); expect(completedPayload.error).toBeNull(); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 1ff4574bc..091ecfc62 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -5557,9 +5557,8 @@ export class Session { private async createPaseoWorktreeInBackground(options: { requestCwd: string; repoRoot: string; - baseBranch: string; - slug: string; - worktreePath: string; + worktree: { branchName: string; worktreePath: string }; + shouldBootstrap: boolean; }): Promise { return createWorktreeInBackgroundSession( { diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index e99fd36fa..e3064f93d 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -96,7 +96,7 @@ describe("createPaseoWorktreeInBackground", () => { } }); - test("emits a single completed snapshot for no-setup workspaces and then launches services", async () => { + test("emits running then completed snapshots for no-setup workspaces and then launches services", async () => { const { tempDir, repoDir } = createGitRepo({ paseoConfig: { services: { @@ -141,8 +141,20 @@ describe("createPaseoWorktreeInBackground", () => { (message): message is Extract => message.type === "workspace_setup_progress", ); - expect(progressMessages).toHaveLength(1); + expect(progressMessages).toHaveLength(2); expect(progressMessages[0]?.payload).toMatchObject({ + workspaceId: worktreePath, + status: "running", + error: null, + detail: { + type: "worktree_setup", + worktreePath, + branchName: "feature-no-setup", + log: "", + commands: [], + }, + }); + expect(progressMessages[1]?.payload).toMatchObject({ workspaceId: worktreePath, status: "completed", error: null, @@ -200,10 +212,12 @@ describe("createPaseoWorktreeInBackground", () => { (message): message is Extract => 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(progressMessages).toHaveLength(2); + expect(progressMessages[0]?.payload.status).toBe("running"); + expect(progressMessages[0]?.payload.error).toBeNull(); + expect(progressMessages[1]?.payload.status).toBe("failed"); + expect(progressMessages[1]?.payload.error).toContain("does-not-exist"); + expect(progressMessages[1]?.payload.detail.commands).toEqual([]); expect(archiveWorkspaceRecord).toHaveBeenCalledWith(worktreePath); expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath); }); @@ -250,6 +264,18 @@ describe("createPaseoWorktreeInBackground", () => { message.type === "workspace_setup_progress", ); expect(progressMessages.length).toBeGreaterThan(1); + expect(progressMessages[0]?.payload).toMatchObject({ + workspaceId: worktreePath, + status: "running", + error: null, + detail: { + type: "worktree_setup", + worktreePath, + branchName: "feature-running-setup", + log: "", + commands: [], + }, + }); expect(progressMessages.at(-1)?.payload.status).toBe("completed"); const runningMessages = progressMessages.filter((message) => message.payload.status === "running"); @@ -258,8 +284,11 @@ describe("createPaseoWorktreeInBackground", () => { progressMessages.findIndex((message) => message.payload.status === "completed"), ); - expect(runningMessages[0]?.payload.detail.log).toContain("phase-one"); - expect(runningMessages[0]?.payload.detail.commands[0]).toMatchObject({ + const setupOutputMessage = runningMessages.find((message) => + message.payload.detail.log.includes("phase-one"), + ); + expect(setupOutputMessage?.payload.detail.log).toContain("phase-one"); + expect(setupOutputMessage?.payload.detail.commands[0]).toMatchObject({ index: 1, command: 'sh -c "printf \'phase-one\\\\n\'; sleep 0.1; printf \'phase-two\\\\n\'"', status: "running", @@ -284,71 +313,7 @@ describe("createPaseoWorktreeInBackground", () => { }); }); - 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 => - 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 () => { + test("emits completed when reusing an existing worktree without bootstrapping", async () => { const { tempDir, repoDir } = createGitRepo({ paseoConfig: { worktree: { @@ -400,9 +365,28 @@ describe("createPaseoWorktreeInBackground", () => { }, ); - expect( - emitted.some((message) => message.type === "workspace_setup_progress"), - ).toBe(false); + const progressMessages = emitted.filter( + (message): message is Extract => + message.type === "workspace_setup_progress", + ); + expect(progressMessages).toHaveLength(2); + expect(progressMessages[0]?.payload).toMatchObject({ + workspaceId: existingWorktree.worktreePath, + status: "running", + error: null, + }); + expect(progressMessages[1]?.payload).toMatchObject({ + workspaceId: existingWorktree.worktreePath, + status: "completed", + error: null, + detail: { + type: "worktree_setup", + worktreePath: existingWorktree.worktreePath, + branchName: "reused-worktree", + log: "", + commands: [], + }, + }); expect(routeStore.listRoutes()).toEqual([]); expect(terminalManager.terminals).toHaveLength(0); expect( @@ -412,4 +396,71 @@ describe("createPaseoWorktreeInBackground", () => { expect(archiveWorkspaceRecord).not.toHaveBeenCalled(); expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(existingWorktree.worktreePath); }); + + 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 => + message.type === "workspace_setup_progress", + ); + expect(progressMessages).toHaveLength(2); + expect(progressMessages[0]?.payload.status).toBe("running"); + expect(progressMessages[0]?.payload.error).toBeNull(); + expect(progressMessages[1]?.payload.status).toBe("completed"); + expect(progressMessages[1]?.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); + }); + }); diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 2204a42c4..221a42091 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -27,6 +27,7 @@ import { createWorktreeSetupProgressAccumulator, getWorktreeSetupProgressResults, spawnWorktreeServices, + type CreateAgentWorktreeResult, } from "./worktree-bootstrap.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { ServiceRouteStore } from "./service-proxy.js"; @@ -139,9 +140,8 @@ type HandleCreatePaseoWorktreeRequestDependencies = { createPaseoWorktreeInBackground: (options: { requestCwd: string; repoRoot: string; - baseBranch: string; - slug: string; - worktreePath: string; + worktree: WorktreeConfig; + shouldBootstrap: boolean; }) => Promise; }; @@ -598,10 +598,17 @@ export async function handleCreatePaseoWorktreeRequest( } const worktreePath = await computeWorktreePath(repoRoot, normalizedSlug, dependencies.paseoHome); + const createdWorktree = await createAgentWorktree({ + cwd: repoRoot, + branchName: normalizedSlug, + baseBranch, + worktreeSlug: normalizedSlug, + paseoHome: dependencies.paseoHome, + }); const workspace = await dependencies.registerPendingWorktreeWorkspace({ repoRoot, - worktreePath, - branchName: normalizedSlug, + worktreePath: createdWorktree.worktree.worktreePath, + branchName: createdWorktree.worktree.branchName, }); const descriptor = await dependencies.describeWorkspaceRecord(workspace); dependencies.emit({ @@ -617,9 +624,8 @@ export async function handleCreatePaseoWorktreeRequest( void dependencies.createPaseoWorktreeInBackground({ requestCwd: request.cwd, repoRoot, - baseBranch, - slug: normalizedSlug, - worktreePath, + worktree: createdWorktree.worktree, + shouldBootstrap: createdWorktree.shouldBootstrap, }); } catch (error) { const message = error instanceof Error ? error.message : "Failed to create worktree"; @@ -644,15 +650,11 @@ export async function createPaseoWorktreeInBackground( options: { requestCwd: string; repoRoot: string; - baseBranch: string; - slug: string; - worktreePath: string; + worktree: WorktreeConfig; + shouldBootstrap: boolean; }, ): Promise { - let worktree: WorktreeConfig = { - branchName: options.slug, - worktreePath: options.worktreePath, - }; + let worktree: WorktreeConfig = options.worktree; let setupResults: WorktreeSetupCommandResult[] = []; let setupStarted = false; const progressAccumulator = createWorktreeSetupProgressAccumulator(); @@ -676,16 +678,10 @@ export async function createPaseoWorktreeInBackground( try { try { - const createdWorktree = await createAgentWorktree({ - cwd: options.repoRoot, - branchName: options.slug, - baseBranch: options.baseBranch, - worktreeSlug: options.slug, - paseoHome: dependencies.paseoHome, - }); - worktree = createdWorktree.worktree; + emitSetupProgress("running", null); - if (!createdWorktree.shouldBootstrap) { + if (!options.shouldBootstrap) { + emitSetupProgress("completed", null); return; } @@ -725,11 +721,7 @@ export async function createPaseoWorktreeInBackground( emitSetupProgress("failed", message); if (!setupStarted) { - await dependencies.archiveWorkspaceRecord(normalizePersistedWorkspaceId(options.worktreePath)); - worktree = { - ...worktree, - worktreePath: options.worktreePath, - }; + await dependencies.archiveWorkspaceRecord(normalizePersistedWorkspaceId(worktree.worktreePath)); } dependencies.sessionLogger.error( @@ -737,8 +729,8 @@ export async function createPaseoWorktreeInBackground( err: error, cwd: options.requestCwd, repoRoot: options.repoRoot, - worktreeSlug: options.slug, - worktreePath: options.worktreePath, + worktreeSlug: worktree.branchName, + worktreePath: worktree.worktreePath, setupStarted, }, "Background worktree creation failed", @@ -771,7 +763,7 @@ export async function createPaseoWorktreeInBackground( ); } } finally { - await dependencies.emitWorkspaceUpdateForCwd(options.worktreePath); + await dependencies.emitWorkspaceUpdateForCwd(worktree.worktreePath); } }