diff --git a/packages/app/e2e/helpers/workspace-setup.ts b/packages/app/e2e/helpers/workspace-setup.ts new file mode 100644 index 000000000..844e40e0b --- /dev/null +++ b/packages/app/e2e/helpers/workspace-setup.ts @@ -0,0 +1,156 @@ +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { pathToFileURL } from "node:url"; +import { expect, type Page } from "@playwright/test"; +import { gotoAppShell } from "./app"; +import type { SessionOutboundMessage } from "@server/shared/messages"; + +type WorkspaceSetupDaemonClient = { + connect(): Promise; + close(): Promise; + openProject( + cwd: string, + ): Promise<{ workspace: { id: string; name: string } | null; error: string | null }>; + createPaseoWorktree( + input: { cwd: string; worktreeSlug?: string }, + ): Promise<{ workspace: { id: string; name: string } | null; error: string | null }>; + subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void; +}; + +export type WorkspaceSetupProgressPayload = Extract< + SessionOutboundMessage, + { type: "workspace_setup_progress" } +>["payload"]; + +function getDaemonWsUrl(): string { + const daemonPort = process.env.E2E_DAEMON_PORT; + if (!daemonPort) { + throw new Error("E2E_DAEMON_PORT is not set."); + } + return `ws://127.0.0.1:${daemonPort}/ws`; +} + +async function loadDaemonClientConstructor(): Promise< + new (config: { url: string; clientId: string; clientType: "cli" }) => WorkspaceSetupDaemonClient +> { + const repoRoot = path.resolve(process.cwd(), "../.."); + const moduleUrl = pathToFileURL( + path.join(repoRoot, "packages/server/dist/server/server/exports.js"), + ).href; + const mod = (await import(moduleUrl)) as { + DaemonClient: new (config: { + url: string; + clientId: string; + clientType: "cli"; + }) => WorkspaceSetupDaemonClient; + }; + return mod.DaemonClient; +} + +export async function connectWorkspaceSetupClient(): Promise { + const DaemonClient = await loadDaemonClientConstructor(); + const client = new DaemonClient({ + url: getDaemonWsUrl(), + clientId: `workspace-setup-${randomUUID()}`, + clientType: "cli", + }); + await client.connect(); + return client; +} + +export async function seedProjectForWorkspaceSetup( + client: WorkspaceSetupDaemonClient, + repoPath: string, +): Promise { + const result = await client.openProject(repoPath); + if (!result.workspace || result.error) { + throw new Error(result.error ?? `Failed to open project ${repoPath}`); + } +} + +export function projectNameFromPath(repoPath: string): string { + return repoPath.replace(/\/+$/, "").split("/").filter(Boolean).pop() ?? repoPath; +} + +export async function openHomeWithProject(page: Page, repoPath: string): Promise { + await gotoAppShell(page); + await expect(createWorkspaceButton(page, repoPath)).toBeVisible({ timeout: 30_000 }); +} + +function createWorkspaceButton(page: Page, repoPath: string) { + return page.getByRole("button", { + name: `Create a new workspace for ${projectNameFromPath(repoPath)}`, + }); +} + +async function revealWorkspaceButton(page: Page, repoPath: string): Promise { + await page.getByTestId(`sidebar-project-row-${repoPath}`).hover(); +} + +export async function createWorkspaceFromSidebar(page: Page, repoPath: string): Promise { + await revealWorkspaceButton(page, repoPath); + await expect(createWorkspaceButton(page, repoPath)).toBeEnabled({ timeout: 30_000 }); + await createWorkspaceButton(page, repoPath).click(); + await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 }); +} + +export async function expectSetupPanel(page: Page): Promise { + await expect(page.getByText("Workspace setup", { exact: true })).toBeVisible({ timeout: 30_000 }); +} + +export async function expectSetupStatus( + page: Page, + status: "Running" | "Completed" | "Failed", +): Promise { + await expect(page.getByTestId("workspace-setup-status")).toContainText(status, { + timeout: 30_000, + }); +} + +export async function expectSetupLogContains(page: Page, text: string): Promise { + await expect(page.getByTestId("workspace-setup-log")).toContainText(text, { + timeout: 30_000, + }); +} + +export async function expectNoSetupMessage(page: Page): Promise { + await expect(page.getByText("No setup commands ran for this workspace.", { exact: true })).toBeVisible({ + timeout: 30_000, + }); +} + +export async function createWorkspaceThroughDaemon( + client: WorkspaceSetupDaemonClient, + input: { cwd: string; worktreeSlug: string }, +): Promise<{ id: string; name: string }> { + const result = await client.createPaseoWorktree(input); + if (!result.workspace || result.error) { + throw new Error(result.error ?? `Failed to create workspace for ${input.cwd}`); + } + return result.workspace; +} + +export async function waitForWorkspaceSetupProgress( + client: WorkspaceSetupDaemonClient, + predicate: (payload: WorkspaceSetupProgressPayload) => boolean, + timeoutMs = 30_000, +): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + unsubscribe(); + reject(new Error(`Timed out waiting for workspace_setup_progress after ${timeoutMs}ms`)); + }, timeoutMs); + + const unsubscribe = client.subscribeRawMessages((message) => { + if (message.type !== "workspace_setup_progress") { + return; + } + if (!predicate(message.payload)) { + return; + } + clearTimeout(timeout); + unsubscribe(); + resolve(message.payload); + }); + }); +} diff --git a/packages/app/e2e/helpers/workspace.ts b/packages/app/e2e/helpers/workspace.ts index d49d67a87..e5305c414 100644 --- a/packages/app/e2e/helpers/workspace.ts +++ b/packages/app/e2e/helpers/workspace.ts @@ -10,7 +10,11 @@ type TempRepo = { export const createTempGitRepo = async ( prefix = "paseo-e2e-", - options?: { withRemote?: boolean }, + options?: { + withRemote?: boolean; + paseoConfig?: Record; + files?: Array<{ path: string; content: string }>; + }, ): Promise => { // Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping. const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp"; @@ -22,7 +26,24 @@ export const createTempGitRepo = async ( execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: "ignore" }); execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" }); await writeFile(path.join(repoPath, "README.md"), "# Temp Repo\n"); + if (options?.paseoConfig) { + await writeFile( + path.join(repoPath, "paseo.json"), + JSON.stringify(options.paseoConfig, null, 2), + ); + } + for (const file of options?.files ?? []) { + const filePath = path.join(repoPath, file.path); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, file.content); + } execSync("git add README.md", { cwd: repoPath, stdio: "ignore" }); + if (options?.paseoConfig) { + execSync("git add paseo.json", { cwd: repoPath, stdio: "ignore" }); + } + for (const file of options?.files ?? []) { + execSync(`git add ${JSON.stringify(file.path)}`, { cwd: repoPath, stdio: "ignore" }); + } execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" }); if (withRemote) { diff --git a/packages/app/e2e/workspace-setup-streaming.spec.ts b/packages/app/e2e/workspace-setup-streaming.spec.ts new file mode 100644 index 000000000..edd6d3979 --- /dev/null +++ b/packages/app/e2e/workspace-setup-streaming.spec.ts @@ -0,0 +1,132 @@ +import { test, expect } from "./fixtures"; +import { createTempGitRepo } from "./helpers/workspace"; +import { + connectWorkspaceSetupClient, + createWorkspaceFromSidebar, + createWorkspaceThroughDaemon, + expectSetupPanel, + openHomeWithProject, + seedProjectForWorkspaceSetup, + waitForWorkspaceSetupProgress, +} from "./helpers/workspace-setup"; + +test.describe("Workspace setup streaming", () => { + test("opens the setup tab when a workspace is created from the sidebar", async ({ page }) => { + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("setup-open-", { + paseoConfig: { + worktree: { + setup: ["sh -c 'echo starting setup; sleep 2; echo setup complete'"], + }, + }, + }); + + try { + await seedProjectForWorkspaceSetup(client, repo.path); + await openHomeWithProject(page, repo.path); + await createWorkspaceFromSidebar(page, repo.path); + + await expectSetupPanel(page); + await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 }); + } finally { + 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-", { + paseoConfig: { + worktree: { + setup: ["sh -c 'echo starting setup; sleep 2; echo setup complete'"], + }, + }, + }); + + try { + await seedProjectForWorkspaceSetup(client, repo.path); + const running = waitForWorkspaceSetupProgress(client, (payload) => payload.status === "running"); + const completed = waitForWorkspaceSetupProgress( + client, + (payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"), + ); + + await createWorkspaceThroughDaemon(client, { + cwd: repo.path, + worktreeSlug: "workspace-setup-success", + }); + + const runningPayload = await running; + const completedPayload = await completed; + + expect(runningPayload.detail.log).toContain("starting setup"); + expect(completedPayload.detail.log).toContain("setup complete"); + expect(completedPayload.error).toBeNull(); + } finally { + await client.close(); + await repo.cleanup(); + } + }); + + test("streams a failed setup snapshot when setup fails", async () => { + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("setup-failure-", { + paseoConfig: { + worktree: { + setup: ["sh -c 'echo starting setup; sleep 2; echo setup failed 1>&2; exit 1'"], + }, + }, + }); + + try { + await seedProjectForWorkspaceSetup(client, repo.path); + const failed = waitForWorkspaceSetupProgress( + client, + (payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"), + ); + + await createWorkspaceThroughDaemon(client, { + cwd: repo.path, + worktreeSlug: "workspace-setup-failure", + }); + + const failedPayload = await failed; + expect(failedPayload.detail.log).toContain("starting setup"); + expect(failedPayload.detail.log).toContain("setup failed"); + expect(failedPayload.error).toMatch(/failed/i); + } finally { + await client.close(); + await repo.cleanup(); + } + }); + + test("emits a completed empty snapshot when no setup commands exist", async () => { + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("setup-none-"); + + try { + await seedProjectForWorkspaceSetup(client, repo.path); + const completed = waitForWorkspaceSetupProgress( + client, + (payload) => + payload.status === "completed" && + payload.detail.commands.length === 0 && + payload.detail.log === "", + ); + + await createWorkspaceThroughDaemon(client, { + cwd: repo.path, + worktreeSlug: "workspace-setup-none", + }); + + const completedPayload = await completed; + expect(completedPayload.error).toBeNull(); + expect(completedPayload.detail.commands).toEqual([]); + expect(completedPayload.detail.log).toBe(""); + } finally { + await client.close(); + await repo.cleanup(); + } + }); +}); diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx index 2afc57223..49cec89c3 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx @@ -31,6 +31,9 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge if (openIntent.kind === "file") { return { kind: "file", path: openIntent.path }; } + if (openIntent.kind === "setup") { + return { kind: "setup", workspaceId: openIntent.workspaceId }; + } return { kind: "draft", draftId: openIntent.draftId }; } diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index aced08534..c8995c8d8 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -695,7 +695,7 @@ function ProjectHeaderRow({ prepareWorkspaceTab({ serverId: serverId!, workspaceId: workspace.id, - target: { kind: "draft", draftId: "new" }, + target: { kind: "setup", workspaceId: workspace.id }, }) as any, ); }, diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 96f2993fc..b6b4e547b 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -35,6 +35,7 @@ import { normalizeWorkspaceDescriptor, } from "@/stores/session-store"; import { useDraftStore } from "@/stores/draft-store"; +import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import type { AgentDirectoryEntry } from "@/types/agent-directory"; import { sendOsNotification } from "@/utils/os-notifications"; import { getIsAppActivelyVisible } from "@/utils/app-visibility"; @@ -159,6 +160,10 @@ type WorkspaceUpdatePayload = Extract< SessionOutboundMessage, { type: "workspace_update" } >["payload"]; +type WorkspaceSetupProgressPayload = Extract< + SessionOutboundMessage, + { type: "workspace_setup_progress" } +>["payload"]; const getAgentIdFromUpdate = (update: AgentUpdatePayload): string => update.kind === "remove" ? update.agentId : update.agent.id; @@ -264,6 +269,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages); const updateSessionClient = useSessionStore((state) => state.updateSessionClient); const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo); + const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress); + const removeWorkspaceSetup = useWorkspaceSetupStore((state) => state.removeWorkspace); + const clearWorkspaceSetupServer = useWorkspaceSetupStore((state) => state.clearServer); // Track focused agent for heartbeat const focusedAgentId = useSessionStore( @@ -748,6 +756,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider ], ); + const applyWorkspaceSetupProgress = useCallback( + (payload: WorkspaceSetupProgressPayload) => { + upsertWorkspaceSetupProgress({ serverId, payload }); + }, + [serverId, upsertWorkspaceSetupProgress], + ); + const requestCanonicalCatchUp = useCallback( (agentId: string, cursor: { epoch: string; endSeq: number }) => { void client @@ -1090,12 +1105,18 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const unsubWorkspaceUpdate = client.on("workspace_update", (message) => { if (message.type !== "workspace_update") return; if (message.payload.kind === "remove") { + removeWorkspaceSetup({ serverId, workspaceId: message.payload.id }); removeWorkspace(serverId, message.payload.id); return; } mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(message.payload.workspace)]); }); + const unsubWorkspaceSetupProgress = client.on("workspace_setup_progress", (message) => { + if (message.type !== "workspace_setup_progress") return; + applyWorkspaceSetupProgress(message.payload); + }); + const unsubStatus = client.on("status", (message) => { if (message.type !== "status") return; const serverInfo = parseServerInfoStatusPayload(message.payload); @@ -1444,6 +1465,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider unsubAgentStream(); unsubAgentTimeline(); unsubWorkspaceUpdate(); + unsubWorkspaceSetupProgress(); unsubStatus(); unsubPermissionRequest(); unsubPermissionResolved(); @@ -1471,6 +1493,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider setAgents, mergeWorkspaces, removeWorkspace, + removeWorkspaceSetup, setAgentLastActivity, setPendingPermissions, setHasHydratedAgents, @@ -1478,6 +1501,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider notifyAgentAttention, requestCanonicalCatchUp, applyAgentUpdatePayload, + applyWorkspaceSetupProgress, applyTimelineResponse, voiceRuntime, voiceAudioEngine, @@ -1681,9 +1705,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider // Cleanup on unmount useEffect(() => { return () => { + clearWorkspaceSetupServer(serverId); clearSession(serverId); }; - }, [clearSession, serverId]); + }, [clearSession, clearWorkspaceSetupServer, serverId]); return children; } diff --git a/packages/app/src/panels/register-panels.ts b/packages/app/src/panels/register-panels.ts index 760671b65..dfc14f7fe 100644 --- a/packages/app/src/panels/register-panels.ts +++ b/packages/app/src/panels/register-panels.ts @@ -2,6 +2,7 @@ import { agentPanelRegistration } from "@/panels/agent-panel"; import { draftPanelRegistration } from "@/panels/draft-panel"; import { filePanelRegistration } from "@/panels/file-panel"; import { registerPanel } from "@/panels/panel-registry"; +import { setupPanelRegistration } from "@/panels/setup-panel"; import { terminalPanelRegistration } from "@/panels/terminal-panel"; let panelsRegistered = false; @@ -12,6 +13,7 @@ export function ensurePanelsRegistered(): void { } registerPanel(draftPanelRegistration); registerPanel(agentPanelRegistration); + registerPanel(setupPanelRegistration); registerPanel(terminalPanelRegistration); registerPanel(filePanelRegistration); panelsRegistered = true; diff --git a/packages/app/src/panels/setup-panel.tsx b/packages/app/src/panels/setup-panel.tsx new file mode 100644 index 000000000..3407e0727 --- /dev/null +++ b/packages/app/src/panels/setup-panel.tsx @@ -0,0 +1,309 @@ +import { CheckCircle2, CircleAlert, SquareTerminal } from "lucide-react-native"; +import { ScrollView, Text, View } from "react-native"; +import invariant from "tiny-invariant"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { Fonts } from "@/constants/theme"; +import { usePaneContext } from "@/panels/pane-context"; +import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; +import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; +import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; + +function useSetupPanelDescriptor( + target: { kind: "setup"; workspaceId: string }, + context: { serverId: string; workspaceId: string }, +): PanelDescriptor { + const key = buildWorkspaceTabPersistenceKey({ + serverId: context.serverId, + workspaceId: target.workspaceId, + }); + const snapshot = useWorkspaceSetupStore((state) => (key ? state.snapshots[key] ?? null : null)); + + if (snapshot?.status === "completed") { + return { + label: "Setup", + subtitle: "Setup completed", + titleState: "ready", + icon: CheckCircle2, + statusBucket: null, + }; + } + + if (snapshot?.status === "failed") { + return { + label: "Setup", + subtitle: "Setup failed", + titleState: "ready", + icon: CircleAlert, + statusBucket: null, + }; + } + + return { + label: "Setup", + subtitle: "Workspace setup", + titleState: "ready", + icon: SquareTerminal, + statusBucket: snapshot?.status === "running" ? "running" : null, + }; +} + +function formatCommandStatus(status: "running" | "completed" | "failed"): string { + if (status === "running") { + return "Running"; + } + if (status === "completed") { + return "Completed"; + } + return "Failed"; +} + +function formatSetupStatus(status: "running" | "completed" | "failed" | null): string { + if (status === "running") { + return "Running"; + } + if (status === "completed") { + return "Completed"; + } + if (status === "failed") { + return "Failed"; + } + return "Waiting for setup output"; +} + +function SetupPanel() { + const { theme } = useUnistyles(); + const { serverId, target } = usePaneContext(); + invariant(target.kind === "setup", "SetupPanel requires setup target"); + + const key = buildWorkspaceTabPersistenceKey({ + serverId, + workspaceId: target.workspaceId, + }); + const snapshot = useWorkspaceSetupStore((state) => (key ? state.snapshots[key] ?? null : null)); + + const commands = snapshot?.detail.commands ?? []; + const log = snapshot?.detail.log ?? ""; + const statusLabel = formatSetupStatus(snapshot?.status ?? null); + const hasNoSetupCommands = + snapshot?.status === "completed" && commands.length === 0 && log.trim().length === 0; + + return ( + + + Workspace setup + + + {statusLabel} + + + + + {snapshot?.error ? ( + + Setup error + + {snapshot.error} + + + ) : null} + + {commands.length > 0 ? ( + + Commands + + {commands.map((command) => ( + + {command.index}. + + + {command.command} + + + {formatCommandStatus(command.status)} + {typeof command.exitCode === "number" ? ` ยท exit ${command.exitCode}` : ""} + + + + ))} + + + ) : null} + + + Log + {hasNoSetupCommands ? ( + + + No setup commands ran for this workspace. + + + ) : ( + + + {log.trim().length > 0 ? log : "Waiting for setup output..."} + + + )} + + + ); +} + +export const setupPanelRegistration: PanelRegistration<"setup"> = { + kind: "setup", + component: SetupPanel, + useDescriptor: useSetupPanelDescriptor, +}; + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + minHeight: 0, + padding: theme.spacing[4], + gap: theme.spacing[4], + backgroundColor: theme.colors.surface0, + }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[3], + }, + title: { + fontSize: theme.fontSize.lg, + fontWeight: "600", + color: theme.colors.foreground, + }, + statusBadge: { + borderRadius: theme.borderRadius.full, + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[1], + backgroundColor: theme.colors.surface2, + }, + statusBadgeText: { + fontSize: theme.fontSize.sm, + fontWeight: "600", + color: theme.colors.foregroundMuted, + }, + errorCard: { + borderRadius: theme.borderRadius.lg, + borderWidth: 1, + borderColor: theme.colors.palette.red[200], + backgroundColor: theme.colors.palette.red[100], + padding: theme.spacing[3], + gap: theme.spacing[2], + }, + errorTitle: { + fontSize: theme.fontSize.sm, + fontWeight: "600", + color: theme.colors.palette.red[800], + }, + errorBody: { + fontSize: theme.fontSize.sm, + color: theme.colors.palette.red[800], + }, + section: { + gap: theme.spacing[2], + }, + sectionFill: { + flex: 1, + minHeight: 0, + gap: theme.spacing[2], + }, + sectionTitle: { + fontSize: theme.fontSize.sm, + fontWeight: "600", + color: theme.colors.foregroundMuted, + textTransform: "uppercase", + letterSpacing: 0.5, + }, + commandList: { + gap: theme.spacing[2], + }, + commandRow: { + flexDirection: "row", + alignItems: "flex-start", + gap: theme.spacing[2], + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface1, + padding: theme.spacing[3], + }, + commandIndex: { + width: 18, + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, + commandTextColumn: { + flex: 1, + gap: theme.spacing[1], + }, + commandText: { + fontFamily: Fonts.mono, + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + }, + commandMeta: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + logContainer: { + flex: 1, + minHeight: 0, + borderRadius: theme.borderRadius.lg, + backgroundColor: theme.colors.surface1, + }, + logContent: { + padding: theme.spacing[3], + }, + logText: { + fontFamily: Fonts.mono, + fontSize: theme.fontSize.sm, + lineHeight: 20, + color: theme.colors.foreground, + }, + emptyCard: { + borderRadius: theme.borderRadius.lg, + backgroundColor: theme.colors.surface1, + padding: theme.spacing[3], + }, + emptyText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, +})); diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 851aab132..4a6816c11 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -79,6 +79,9 @@ function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string { if (tab.target.kind === "draft") { return "New Agent"; } + if (tab.target.kind === "setup") { + return "Setup"; + } if (tab.target.kind === "terminal") { return "Terminal"; } diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 913aff9f1..523cedec1 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -139,6 +139,9 @@ function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string { if (tab.target.kind === "draft") { return "New Agent"; } + if (tab.target.kind === "setup") { + return "Setup"; + } if (tab.target.kind === "terminal") { return "Terminal"; } @@ -152,6 +155,9 @@ function getFallbackTabOptionDescription(tab: WorkspaceTabDescriptor): string { if (tab.target.kind === "draft") { return "New Agent"; } + if (tab.target.kind === "setup") { + return "Workspace setup"; + } if (tab.target.kind === "agent") { return "Agent"; } diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.ts b/packages/app/src/screens/workspace/workspace-tab-menu.ts index bb4039712..b6bcec9d6 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.ts @@ -76,6 +76,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string { if (tab.target.kind === "draft") { return `workspace-draft-close-${tab.target.draftId}`; } + if (tab.target.kind === "setup") { + return `workspace-setup-close-${encodeFilePathForPathSegment(tab.target.workspaceId)}`; + } return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`; } diff --git a/packages/app/src/stores/workspace-setup-store.ts b/packages/app/src/stores/workspace-setup-store.ts new file mode 100644 index 000000000..ab0bb6198 --- /dev/null +++ b/packages/app/src/stores/workspace-setup-store.ts @@ -0,0 +1,72 @@ +import type { SessionOutboundMessage } from "@server/shared/messages"; +import { create } from "zustand"; +import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; + +export type WorkspaceSetupProgressPayload = Extract< + SessionOutboundMessage, + { type: "workspace_setup_progress" } +>["payload"]; + +export interface WorkspaceSetupSnapshot extends WorkspaceSetupProgressPayload { + updatedAt: number; +} + +interface WorkspaceSetupStoreState { + snapshots: Record; + upsertProgress: (input: { serverId: string; payload: WorkspaceSetupProgressPayload }) => void; + removeWorkspace: (input: { serverId: string; workspaceId: string }) => void; + clearServer: (serverId: string) => void; +} + +function buildWorkspaceSetupKey(input: { + serverId: string; + workspaceId: string; +}): string | null { + return buildWorkspaceTabPersistenceKey(input); +} + +export const useWorkspaceSetupStore = create()((set) => ({ + snapshots: {}, + upsertProgress: ({ serverId, payload }) => { + const key = buildWorkspaceSetupKey({ serverId, workspaceId: payload.workspaceId }); + if (!key) { + return; + } + + set((state) => ({ + snapshots: { + ...state.snapshots, + [key]: { + ...payload, + updatedAt: Date.now(), + }, + }, + })); + }, + removeWorkspace: ({ serverId, workspaceId }) => { + const key = buildWorkspaceSetupKey({ serverId, workspaceId }); + if (!key) { + return; + } + + set((state) => { + if (!(key in state.snapshots)) { + return state; + } + const next = { ...state.snapshots }; + delete next[key]; + return { snapshots: next }; + }); + }, + clearServer: (serverId) => { + set((state) => { + const nextEntries = Object.entries(state.snapshots).filter( + ([key]) => !key.startsWith(`${serverId}:`), + ); + if (nextEntries.length === Object.keys(state.snapshots).length) { + return state; + } + return { snapshots: Object.fromEntries(nextEntries) }; + }); + }, +})); diff --git a/packages/app/src/stores/workspace-tabs-store.test.ts b/packages/app/src/stores/workspace-tabs-store.test.ts index 1b22b31d1..98a3ad4ad 100644 --- a/packages/app/src/stores/workspace-tabs-store.test.ts +++ b/packages/app/src/stores/workspace-tabs-store.test.ts @@ -200,4 +200,19 @@ describe("workspace-tabs-store retargetTab", () => { expect(reopenedFileTabId).toBe(fileTabId); expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe(fileTabId); }); + + it("builds a deterministic setup tab keyed by workspace id", () => { + const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); + expect(key).toBeTruthy(); + const workspaceKey = key as string; + + const tabId = useWorkspaceTabsStore.getState().openOrFocusTab({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "setup", workspaceId: WORKSPACE_ID }, + }); + + expect(tabId).toBe(`setup_${WORKSPACE_ID}`); + expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe(tabId); + }); }); diff --git a/packages/app/src/stores/workspace-tabs-store.ts b/packages/app/src/stores/workspace-tabs-store.ts index 23ed460e0..b403f7e8e 100644 --- a/packages/app/src/stores/workspace-tabs-store.ts +++ b/packages/app/src/stores/workspace-tabs-store.ts @@ -6,7 +6,8 @@ export type WorkspaceTabTarget = | { kind: "draft"; draftId: string } | { kind: "agent"; agentId: string } | { kind: "terminal"; terminalId: string } - | { kind: "file"; path: string }; + | { kind: "file"; path: string } + | { kind: "setup"; workspaceId: string }; export type WorkspaceTab = { tabId: string; @@ -60,6 +61,10 @@ function normalizeTabTarget( const path = trimNonEmpty(value.path); return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null; } + if (value.kind === "setup") { + const workspaceId = trimNonEmpty(value.workspaceId); + return workspaceId ? { kind: "setup", workspaceId: workspaceId.replace(/\\/g, "/") } : null; + } return null; } @@ -79,6 +84,9 @@ function tabTargetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): b if (left.kind === "file" && right.kind === "file") { return left.path === right.path; } + if (left.kind === "setup" && right.kind === "setup") { + return left.workspaceId === right.workspaceId; + } return false; } @@ -92,6 +100,9 @@ function buildDeterministicTabId(target: WorkspaceTabTarget): string { if (target.kind === "terminal") { return `terminal_${target.terminalId}`; } + if (target.kind === "setup") { + return `setup_${target.workspaceId}`; + } return `file_${target.path}`; } diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index 5b34e4e95..f7328f183 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -82,6 +82,10 @@ describe("workspace route parsing", () => { kind: "file", path: "src/index.ts", }); + expect(parseWorkspaceOpenIntent("setup:L3RtcC9yZXBv")).toEqual({ + kind: "setup", + workspaceId: "/tmp/repo", + }); }); it("uses the plain workspace route when workspace context is provided", () => { diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 5afe9f059..9ea63bba8 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -99,7 +99,8 @@ export type WorkspaceOpenIntent = | { kind: "agent"; agentId: string } | { kind: "terminal"; terminalId: string } | { kind: "file"; path: string } - | { kind: "draft"; draftId: string }; + | { kind: "draft"; draftId: string } + | { kind: "setup"; workspaceId: string }; export function parseWorkspaceOpenIntent( value: string | null | undefined, @@ -136,6 +137,13 @@ export function parseWorkspaceOpenIntent( } return { kind: "file", path: decodedPath }; } + if (kind === "setup") { + const workspaceId = decodeWorkspaceIdFromPathSegment(payload); + if (!workspaceId) { + return null; + } + return { kind: "setup", workspaceId }; + } return null; } diff --git a/packages/app/src/utils/workspace-tab-identity.ts b/packages/app/src/utils/workspace-tab-identity.ts index 9ea9ffd7f..5de7dcff2 100644 --- a/packages/app/src/utils/workspace-tab-identity.ts +++ b/packages/app/src/utils/workspace-tab-identity.ts @@ -22,6 +22,10 @@ export function normalizeWorkspaceTabTarget( const path = trimNonEmpty(value.path); return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null; } + if (value.kind === "setup") { + const workspaceId = trimNonEmpty(value.workspaceId); + return workspaceId ? { kind: "setup", workspaceId: workspaceId.replace(/\\/g, "/") } : null; + } return null; } @@ -44,6 +48,9 @@ export function workspaceTabTargetsEqual( if (left.kind === "file" && right.kind === "file") { return left.path === right.path; } + if (left.kind === "setup" && right.kind === "setup") { + return left.workspaceId === right.workspaceId; + } return false; } @@ -57,6 +64,9 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st if (target.kind === "terminal") { return `terminal_${target.terminalId}`; } + if (target.kind === "setup") { + return `setup_${target.workspaceId}`; + } return `file_${target.path}`; } diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index eed79a7e0..8a01c3d83 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -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[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(); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 13d35ab3e..b5bd5514f 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -123,6 +123,11 @@ export type DaemonEvent = workspaceId: string; payload: Extract["payload"]; } + | { + type: "workspace_setup_progress"; + workspaceId: string; + payload: Extract["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", diff --git a/packages/server/src/server/agent/agent-management-mcp.ts b/packages/server/src/server/agent/agent-management-mcp.ts index 36ccf9ab6..06f016692 100644 --- a/packages/server/src/server/agent/agent-management-mcp.ts +++ b/packages/server/src/server/agent/agent-management-mcp.ts @@ -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({ diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index b1fb60104..56f163088 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -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({ diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 05a22c5a6..1ff4574bc 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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, - ): 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, ); diff --git a/packages/server/src/server/worktree-bootstrap.test.ts b/packages/server/src/server/worktree-bootstrap.test.ts index e52fef018..bb72bd6a1 100644 --- a/packages/server/src/server/worktree-bootstrap.test.ts +++ b/packages/server/src/server/worktree-bootstrap.test.ts @@ -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); diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index 849007779..3cb8430fe 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -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......\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(); - type MiddleTruncationAccumulator = { totalBytes: number; head: string; @@ -65,6 +69,12 @@ type MiddleTruncationAccumulator = { truncated: boolean; }; +export type WorktreeSetupOutputAccumulator = MiddleTruncationAccumulator; +export type WorktreeSetupProgressAccumulator = { + resultsByIndex: Map; + outputAccumulatorsByIndex: Map; +}; + 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 { +): Promise { 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; + outputAccumulatorsByIndex?: Map; }): { 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[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; - errorMessage: string | null; -}): AgentTimelineItem { + outputAccumulatorsByIndex?: Map; +}): Extract { 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; + 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 { - 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(); - const outputAccumulatorsByIndex = new Map(); + 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, }), ); diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts new file mode 100644 index 000000000..e99fd36fa --- /dev/null +++ b/packages/server/src/server/worktree-session.test.ts @@ -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; + }) => Promise; +}) { + const terminals: Array<{ + id: string; + cwd: string; + name: string | undefined; + env: Record | undefined; + sent: string[]; + }> = []; + + return { + terminals, + manager: { + registerCwdEnv: vi.fn(), + createTerminal: vi.fn(async (input: { + cwd: string; + name?: string; + env?: Record; + }) => { + 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 }) { + 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 => + 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 => + 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 => + 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 => + 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); + }); +}); diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 2076c53e9..2204a42c4 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -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; + emit: EmitSessionMessage; sessionLogger: Logger; terminalManager: TerminalManager | null; + archiveWorkspaceRecord: (workspaceId: string) => Promise; + serviceRouteStore: ServiceRouteStore | null; + daemonPort: number | null; }; type HandleCreatePaseoWorktreeRequestDependencies = { @@ -143,10 +158,13 @@ export async function buildAgentSessionConfig( gitOptions?: GitSetupOptions, legacyWorktreeName?: string, _labels?: Record, -): 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 { - 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); } diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 84f771030..64c4b2b11 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -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 = z.discriminatedUnion("type", [ + WorktreeSetupDetailPayloadSchema, z.object({ type: z.literal("shell"), command: z.string(), @@ -254,23 +273,6 @@ const ToolCallDetailPayloadSchema: z.ZodType = 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; export type ArtifactMessage = z.infer; export type AgentUpdateMessage = z.infer; +export type WorkspaceSetupProgressMessage = z.infer; export type AgentStreamMessage = z.infer; export type AgentStatusMessage = z.infer; export type ProjectCheckoutLitePayload = z.infer; diff --git a/packages/server/src/shared/messages.workspaces.test.ts b/packages/server/src/shared/messages.workspaces.test.ts index 3f62d3a60..57741db55 100644 --- a/packages/server/src/shared/messages.workspaces.test.ts +++ b/packages/server/src/shared/messages.workspaces.test.ts @@ -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"); + }); });