diff --git a/packages/app/e2e/helpers/workspace-setup.ts b/packages/app/e2e/helpers/workspace-setup.ts index 0123ab634..1dc09024a 100644 --- a/packages/app/e2e/helpers/workspace-setup.ts +++ b/packages/app/e2e/helpers/workspace-setup.ts @@ -4,17 +4,7 @@ import { pathToFileURL } from "node:url"; import { expect, type Page } from "@playwright/test"; import { parseHostWorkspaceRouteFromPathname } from "../../src/utils/host-routes"; import { gotoAppShell } from "./app"; - -type WorkspaceSetupProgressPayload = { - status: "running" | "completed" | "failed"; - detail: { commands: string[]; log: string }; - error: string | null; -}; - -type WorkspaceSetupRawMessage = { - type: string; - payload?: WorkspaceSetupProgressPayload; -}; +import type { SessionOutboundMessage } from "@server/shared/messages"; type WorkspaceSetupDaemonClient = { connect(): Promise; @@ -67,9 +57,15 @@ type WorkspaceSetupDaemonClient = { terminals: Array<{ id: string; cwd: string; name: string }>; error?: string | null; }>; - subscribeRawMessages(handler: (message: WorkspaceSetupRawMessage) => void): () => void; + subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void; }; -export type { WorkspaceSetupDaemonClient, WorkspaceSetupProgressPayload }; + +export type WorkspaceSetupProgressPayload = Extract< + SessionOutboundMessage, + { type: "workspace_setup_progress" } +>["payload"]; + +export type { WorkspaceSetupDaemonClient }; function getDaemonWsUrl(): string { const daemonPort = process.env.E2E_DAEMON_PORT; @@ -334,9 +330,6 @@ export async function waitForWorkspaceSetupProgress( if (message.type !== "workspace_setup_progress") { return; } - if (!message.payload) { - return; - } if (!predicate(message.payload)) { return; } diff --git a/packages/app/e2e/workspace-hover-card.spec.ts b/packages/app/e2e/workspace-hover-card.spec.ts new file mode 100644 index 000000000..553b4efc8 --- /dev/null +++ b/packages/app/e2e/workspace-hover-card.spec.ts @@ -0,0 +1,134 @@ +import { test, expect } from "./fixtures"; +import { createTempGitRepo } from "./helpers/workspace"; +import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; +import { + connectWorkspaceSetupClient, + createWorkspaceFromSidebar, + expectSetupPanel, + expectSetupStatus, + openHomeWithProject, + seedProjectForWorkspaceSetup, +} from "./helpers/workspace-setup"; +import type { Page } from "@playwright/test"; + +// --------------------------------------------------------------------------- +// Composable helpers +// --------------------------------------------------------------------------- + +/** Waits for the globe icon to appear on a workspace row (proves services are running). */ +async function expectGlobeIcon(page: Page): Promise { + await expect(page.getByTestId("workspace-globe-icon")).toBeVisible({ timeout: 30_000 }); +} + +/** Hovers the workspace row (by visible name) and waits for the hover card to appear. */ +async function expectHoverCard(page: Page, workspaceName: string): Promise { + const row = page.getByRole("button", { name: workspaceName }).first(); + await row.hover(); + await expect(page.getByTestId("workspace-hover-card")).toBeVisible({ timeout: 10_000 }); +} + +/** Asserts that a service row with the given name exists in the hover card. */ +async function expectServiceInCard(page: Page, serviceName: string): Promise { + const card = page.getByTestId("workspace-hover-card"); + await expect(card.getByTestId(`hover-card-service-${serviceName}`)).toBeVisible({ + timeout: 10_000, + }); +} + +/** Asserts the service status dot indicates "running". */ +async function expectServiceRunning(page: Page, serviceName: string): Promise { + const card = page.getByTestId("workspace-hover-card"); + await expect( + card.getByTestId(`hover-card-service-status-${serviceName}`), + ).toHaveAttribute("aria-label", "Running", { timeout: 10_000 }); +} + +/** Asserts the hover card contains the workspace name. */ +async function expectWorkspaceNameInCard(page: Page, name: string): Promise { + const card = page.getByTestId("workspace-hover-card"); + await expect(card.getByTestId("hover-card-workspace-name")).toContainText(name, { + timeout: 10_000, + }); +} + +/** Moves the mouse away from the sidebar and asserts the hover card disappears. */ +async function expectHoverCardDismissed(page: Page): Promise { + // Move mouse to the center of the viewport (away from sidebar) + const viewport = page.viewportSize(); + await page.mouse.move((viewport?.width ?? 1280) / 2, (viewport?.height ?? 720) / 2); + await expect(page.getByTestId("workspace-hover-card")).not.toBeVisible({ timeout: 10_000 }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test.describe("Workspace hover card", () => { + test("shows hover card with services when hovering a workspace with running services", async ({ + page, + }) => { + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("hovercard-svc-", { + paseoConfig: { + worktree: { + setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"], + }, + services: { + web: { + command: + "node -e \"const http = require('http'); const s = http.createServer((q,r) => r.end('ok')); s.listen(process.env.PORT || 3000, () => console.log('listening on ' + s.address().port))\"", + }, + }, + }, + }); + + try { + await seedProjectForWorkspaceSetup(client, repo.path); + await openHomeWithProject(page, repo.path); + await createWorkspaceFromSidebar(page, repo.path); + + // Wait for setup to complete and workspace to be usable + await expectSetupPanel(page); + await expectSetupStatus(page, "Completed"); + await waitForWorkspaceTabsVisible(page); + + // Wait for the globe icon — proves services are running and client has the data + await expectGlobeIcon(page); + + // Read the workspace name from the page header (the mnemonic name, e.g. "upbeat-crab") + const workspaceHeader = page.getByTestId("workspace-tabs-row"); + await expect(workspaceHeader).toBeVisible({ timeout: 10_000 }); + // The workspace name is the second workspace row button in the sidebar under the worktree project + // We can find it by looking for the workspace row that has the globe icon next to it + const globeIcon = page.getByTestId("workspace-globe-icon"); + const workspaceRow = page.locator('[data-testid^="sidebar-workspace-row-"]', { + has: globeIcon, + }); + const workspaceName = + (await workspaceRow.locator("button").first().innerText()).trim() || "workspace"; + + // Hover the workspace row — hover card should appear + await expectHoverCard(page, workspaceName); + + // Assert the card shows the workspace name + await expectWorkspaceNameInCard(page, workspaceName); + + // Assert the "web" service entry exists in the card + await expectServiceInCard(page, "web"); + + // Assert the status dot shows "running" + await expectServiceRunning(page, "web"); + + // Assert the service row is a link (has role="link") + const card = page.getByTestId("workspace-hover-card"); + const serviceLink = card.getByRole("link", { name: "web service" }); + await expect(serviceLink).toBeVisible({ timeout: 10_000 }); + + // Move mouse away — card should dismiss + await expectHoverCardDismissed(page); + } finally { + await client.close(); + await repo.cleanup(); + } + }); +}); 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..9ea4522bd --- /dev/null +++ b/packages/app/e2e/workspace-setup-streaming.spec.ts @@ -0,0 +1,292 @@ +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, +} 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("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 page.getByTestId("workspace-new-agent-tab").first().click(); + 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-label")) === "Open explorer") { + await explorerToggle.click(); + } + await expect(explorerToggle).toHaveAttribute("aria-label", "Close explorer", { + 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-", { + paseoConfig: { + worktree: { + setup: ["sh -c 'echo starting setup; sleep 2; echo setup complete'"], + }, + }, + }); + + try { + await seedProjectForWorkspaceSetup(client, repo.path); + 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"), + ); + + await createWorkspaceThroughDaemon(client, { + cwd: repo.path, + worktreeSlug: "workspace-setup-success", + }); + + 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(); + } 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(); + } + }); + + test("launches service terminals after setup completes", async ({ page }) => { + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("setup-svc-ui-", { + paseoConfig: { + worktree: { + setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"], + }, + services: { + web: { + command: + "node -e \"const http = require('http'); const s = http.createServer((q,r) => r.end('ok')); s.listen(process.env.PORT || 3000, () => console.log('listening on ' + s.address().port))\"", + }, + }, + }, + }); + + try { + await seedProjectForWorkspaceSetup(client, repo.path); + await openHomeWithProject(page, repo.path); + await createWorkspaceFromSidebar(page, repo.path); + + await expectSetupPanel(page); + await expectSetupStatus(page, "Completed"); + + await waitForWorkspaceTabsVisible(page); + + // Wait for the service terminal tab to appear in the tabs bar + const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]', { + hasText: "web", + }); + await expect(terminalTab).toBeVisible({ timeout: 30_000 }); + + // Click the service terminal tab + await terminalTab.click(); + + // Verify the terminal surface rendered + await expect(page.getByTestId("terminal-surface").first()).toBeVisible({ timeout: 10_000 }); + + // Verify the terminal output contains "listening on" (xterm renders text in .xterm-rows) + await expect(page.locator(".xterm-rows").first()).toContainText("listening on", { + timeout: 30_000, + }); + } finally { + await client.close(); + await repo.cleanup(); + } + }); + + test("launches workspace services after setup completes", async () => { + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("setup-services-", { + paseoConfig: { + worktree: { + setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"], + }, + services: { + editor: { + command: "npm run dev", + }, + }, + }, + }); + + try { + await seedProjectForWorkspaceSetup(client, repo.path); + const completed = waitForWorkspaceSetupProgress( + client, + (payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"), + ); + + const workspace = await createWorkspaceThroughDaemon(client, { + cwd: repo.path, + worktreeSlug: "workspace-setup-services", + }); + + await completed; + + await expect + .poll(async () => { + const terminals = await client.listTerminals(workspace.id); + return terminals.terminals.find((terminal) => terminal.name === "editor") ?? null; + }) + .toMatchObject({ + id: expect.any(String), + name: "editor", + }); + } 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 399830a1c..aa8c89726 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 8e1dc43c6..aaf82eab3 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -34,6 +34,7 @@ import { FolderPlus, FolderGit2, GitPullRequest, + Globe, Monitor, MoreVertical, Plus, @@ -90,6 +91,7 @@ import { requireWorkspaceExecutionDirectory, resolveWorkspaceExecutionDirectory, } from "@/utils/workspace-execution"; +import { WorkspaceHoverCard } from "@/components/workspace-hover-card"; function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null { if (!icon) { @@ -839,122 +841,132 @@ function WorkspaceRowInner({ onPress(); }, [interaction.didLongPressRef, onPress]); + const isDesktop = !isTouchPlatform; + const showGlobe = isDesktop && workspace.hasRunningServices; + return ( - setIsHovered(true)} - onPointerLeave={() => setIsHovered(false)} - > - [ - styles.workspaceRow, - isDragging && styles.workspaceRowDragging, - selected && styles.sidebarRowSelected, - isHovered && styles.workspaceRowHovered, - pressed && styles.workspaceRowPressed, - ]} - onPressIn={interaction.handlePressIn} - onTouchMove={interaction.handleTouchMove} - onPressOut={interaction.handlePressOut} - onPress={handlePress} - testID={`sidebar-workspace-row-${workspace.workspaceKey}`} + + setIsHovered(true)} + onPointerLeave={() => setIsHovered(false)} > - - - - [ + styles.workspaceRow, + isDragging && styles.workspaceRowDragging, + selected && styles.sidebarRowSelected, + isHovered && styles.workspaceRowHovered, + pressed && styles.workspaceRowPressed, + ]} + onPressIn={interaction.handlePressIn} + onTouchMove={interaction.handleTouchMove} + onPressOut={interaction.handlePressOut} + onPress={handlePress} + testID={`sidebar-workspace-row-${workspace.workspaceKey}`} + > + + - {workspace.name} - - - - {isCreating ? Creating... : null} - {onArchive && (isHovered || isTouchPlatform) ? ( - - [ - styles.kebabButton, - hovered && styles.kebabButtonHovered, - ]} - accessibilityRole="button" - accessibilityLabel="Workspace actions" - testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`} - > - {({ hovered }) => ( - - )} - - - {onCopyPath ? ( - } - onSelect={onCopyPath} - > - Copy path - - ) : null} - {onCopyBranchName ? ( - } - onSelect={onCopyBranchName} - > - Copy branch name - - ) : null} - } - trailing={archiveShortcutKeys ? : null} - status={archiveStatus} - pendingLabel={archivePendingLabel} - onSelect={onArchive} + + + {workspace.name} + + + + {showGlobe ? ( + + + + ) : null} + {isCreating ? Creating... : null} + {onArchive && (isHovered || isTouchPlatform) ? ( + + [ + styles.kebabButton, + hovered && styles.kebabButtonHovered, + ]} + accessibilityRole="button" + accessibilityLabel="Workspace actions" + testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`} > - {archiveLabel ?? "Archive"} - - - - ) : workspace.diffStat ? ( - - +{workspace.diffStat.additions} - -{workspace.diffStat.deletions} - - ) : null} - {showShortcutBadge && shortcutNumber !== null ? ( - - {shortcutNumber} - - ) : null} + {({ hovered }) => ( + + )} + + + {onCopyPath ? ( + } + onSelect={onCopyPath} + > + Copy path + + ) : null} + {onCopyBranchName ? ( + } + onSelect={onCopyBranchName} + > + Copy branch name + + ) : null} + } + trailing={archiveShortcutKeys ? : null} + status={archiveStatus} + pendingLabel={archivePendingLabel} + onSelect={onArchive} + > + {archiveLabel ?? "Archive"} + + + + ) : workspace.diffStat ? ( + + +{workspace.diffStat.additions} + -{workspace.diffStat.deletions} + + ) : null} + {showShortcutBadge && shortcutNumber !== null ? ( + + {shortcutNumber} + + ) : null} + - - {prHint ? ( - - - - ) : null} - - + {prHint ? ( + + + + ) : null} + + + ); } diff --git a/packages/app/src/components/workspace-hover-card.tsx b/packages/app/src/components/workspace-hover-card.tsx new file mode 100644 index 000000000..f66bc75c0 --- /dev/null +++ b/packages/app/src/components/workspace-hover-card.tsx @@ -0,0 +1,490 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type PropsWithChildren, + type ReactElement, +} from "react"; +import { Dimensions, Platform, Text, View } from "react-native"; +import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { ExternalLink, FolderGit2, GitPullRequest, Monitor } from "lucide-react-native"; +import { Pressable } from "react-native"; +import { Portal } from "@gorhom/portal"; +import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet"; +import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; +import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query"; +import { openExternalUrl } from "@/utils/open-external-url"; +import { getStatusDotColor } from "@/utils/status-dot-color"; +import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; +import { SyncedLoader } from "@/components/synced-loader"; + +interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +function measureElement(element: View): Promise { + return new Promise((resolve) => { + element.measureInWindow((x, y, width, height) => { + resolve({ x, y, width, height }); + }); + }); +} + +function computeHoverCardPosition({ + triggerRect, + contentSize, + displayArea, + offset, +}: { + triggerRect: Rect; + contentSize: { width: number; height: number }; + displayArea: Rect; + offset: number; +}): { x: number; y: number } { + let x = triggerRect.x + triggerRect.width + offset; + let y = triggerRect.y; + + // If it overflows right, try left + if (x + contentSize.width > displayArea.width - 8) { + x = triggerRect.x - contentSize.width - offset; + } + + // Constrain to screen + const padding = 8; + x = Math.max(padding, Math.min(displayArea.width - contentSize.width - padding, x)); + y = Math.max( + displayArea.y + padding, + Math.min(displayArea.y + displayArea.height - contentSize.height - padding, y), + ); + + return { x, y }; +} + +const HOVER_GRACE_MS = 100; +const HOVER_CARD_WIDTH = 260; + +interface WorkspaceHoverCardProps { + workspace: SidebarWorkspaceEntry; + isDragging: boolean; +} + +export function WorkspaceHoverCard({ + workspace, + isDragging, + children, +}: PropsWithChildren): ReactElement { + // Desktop-only: skip on non-web platforms + if (Platform.OS !== "web") { + return <>{children}; + } + + return ( + + {children} + + ); +} + +function WorkspaceHoverCardDesktop({ + workspace, + isDragging, + children, +}: PropsWithChildren): ReactElement { + const triggerRef = useRef(null); + const [open, setOpen] = useState(false); + const graceTimerRef = useRef | null>(null); + const triggerHoveredRef = useRef(false); + const contentHoveredRef = useRef(false); + + const hasServices = workspace.services.length > 0; + + const clearGraceTimer = useCallback(() => { + if (graceTimerRef.current) { + clearTimeout(graceTimerRef.current); + graceTimerRef.current = null; + } + }, []); + + const scheduleClose = useCallback(() => { + clearGraceTimer(); + graceTimerRef.current = setTimeout(() => { + if (!triggerHoveredRef.current && !contentHoveredRef.current) { + setOpen(false); + } + graceTimerRef.current = null; + }, HOVER_GRACE_MS); + }, [clearGraceTimer]); + + const handleTriggerEnter = useCallback(() => { + triggerHoveredRef.current = true; + clearGraceTimer(); + if (!isDragging && hasServices) { + setOpen(true); + } + }, [clearGraceTimer, isDragging, hasServices]); + + const handleTriggerLeave = useCallback(() => { + triggerHoveredRef.current = false; + scheduleClose(); + }, [scheduleClose]); + + const handleContentEnter = useCallback(() => { + contentHoveredRef.current = true; + clearGraceTimer(); + }, [clearGraceTimer]); + + const handleContentLeave = useCallback(() => { + contentHoveredRef.current = false; + scheduleClose(); + }, [scheduleClose]); + + // Close when drag starts + useEffect(() => { + if (isDragging) { + clearGraceTimer(); + setOpen(false); + } + }, [isDragging, clearGraceTimer]); + + // When hasServices becomes true while trigger is already hovered, open the card. + useEffect(() => { + if (!hasServices || isDragging) return; + if (triggerHoveredRef.current) { + setOpen(true); + } + }, [hasServices, isDragging]); + + // Cleanup on unmount + useEffect(() => { + return () => { + clearGraceTimer(); + }; + }, [clearGraceTimer]); + + return ( + + {children} + {open && hasServices ? ( + + ) : null} + + ); +} + +const GITHUB_PR_STATE_LABELS: Record = { + open: "Open", + merged: "Merged", + closed: "Closed", +}; + +function HoverCardStatusIndicator({ + workspace, +}: { + workspace: SidebarWorkspaceEntry; +}): ReactElement | null { + const { theme } = useUnistyles(); + const showSyncedLoader = shouldRenderSyncedStatusLoader({ bucket: workspace.statusBucket }); + + if (showSyncedLoader) { + return ; + } + + const KindIcon = + workspace.workspaceKind === "local_checkout" + ? Monitor + : workspace.workspaceKind === "worktree" + ? FolderGit2 + : null; + if (!KindIcon) return null; + + const dotColor = getStatusDotColor({ theme, bucket: workspace.statusBucket, showDoneAsInactive: false }); + + return ( + + + {dotColor ? ( + + ) : null} + + ); +} + +function WorkspaceHoverCardContent({ + workspace, + triggerRef, + onContentEnter, + onContentLeave, +}: { + workspace: SidebarWorkspaceEntry; + triggerRef: React.RefObject; + onContentEnter: () => void; + onContentLeave: () => void; +}): ReactElement | null { + const { theme } = useUnistyles(); + const bottomSheetInternal = useBottomSheetModalInternal(true); + const [triggerRect, setTriggerRect] = useState(null); + const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); + const [position, setPosition] = useState<{ x: number; y: number } | null>(null); + const prHint = useWorkspacePrHint({ + serverId: workspace.serverId, + cwd: workspace.workspaceId, + enabled: workspace.workspaceKind !== "directory", + }); + + // Measure trigger — same pattern as tooltip.tsx + useEffect(() => { + if (!triggerRef.current) return; + + let cancelled = false; + measureElement(triggerRef.current).then((rect) => { + if (cancelled) return; + setTriggerRect(rect); + }); + + return () => { + cancelled = true; + }; + }, [triggerRef]); + + // Compute position when both measurements are available + useEffect(() => { + if (!triggerRect || !contentSize) return; + const { width: screenWidth, height: screenHeight } = Dimensions.get("window"); + const displayArea = { x: 0, y: 0, width: screenWidth, height: screenHeight }; + const result = computeHoverCardPosition({ + triggerRect, + contentSize, + displayArea, + offset: 4, + }); + setPosition(result); + }, [triggerRect, contentSize]); + + const handleLayout = useCallback( + (event: { nativeEvent: { layout: { width: number; height: number } } }) => { + const { width, height } = event.nativeEvent.layout; + setContentSize({ width, height }); + }, + [], + ); + + return ( + + + + + + + {workspace.name} + + + {workspace.diffStat ? ( + + +{workspace.diffStat.additions} + -{workspace.diffStat.deletions} + + ) : null} + {prHint ? ( + void openExternalUrl(prHint.url)} + > + + + #{prHint.number} · {GITHUB_PR_STATE_LABELS[prHint.state]} + + + ) : null} + + + {workspace.services.map((service) => ( + [ + styles.serviceRow, + hovered && styles.serviceRowHovered, + ]} + onPress={() => { + if (service.url) { + void openExternalUrl(service.url); + } + }} + disabled={!service.url} + > + + + {service.serviceName} + + {service.url ? ( + + ) : null} + + ))} + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + portalOverlay: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + zIndex: 1000, + }, + card: { + backgroundColor: theme.colors.surface1, + borderWidth: 1, + borderColor: theme.colors.borderAccent, + borderRadius: theme.borderRadius.lg, + paddingVertical: theme.spacing[2], + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 8, + elevation: 8, + zIndex: 1000, + }, + cardHeader: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingBottom: theme.spacing[2], + }, + cardTitle: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + flex: 1, + minWidth: 0, + }, + cardMetaRow: { + flexDirection: "row", + alignItems: "center", + gap: 6, + paddingHorizontal: theme.spacing[3], + paddingBottom: theme.spacing[2], + }, + diffStatAdditions: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + color: theme.colors.palette.green[400], + }, + diffStatDeletions: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + color: theme.colors.palette.red[500], + }, + prBadgeText: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + hoverStatusIcon: { + width: 14, + height: 14, + alignItems: "center", + justifyContent: "center", + position: "relative", + }, + hoverStatusDotOverlay: { + position: "absolute", + bottom: -1, + right: -1, + width: 6, + height: 6, + borderRadius: 3, + borderWidth: 1, + }, + separator: { + height: 1, + backgroundColor: theme.colors.border, + }, + serviceList: { + paddingTop: theme.spacing[1], + }, + serviceRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + minHeight: 32, + }, + serviceRowHovered: { + backgroundColor: theme.colors.surface2, + }, + statusDot: { + width: 8, + height: 8, + borderRadius: 4, + flexShrink: 0, + }, + serviceName: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + flex: 1, + minWidth: 0, + }, +})); diff --git a/packages/app/src/contexts/session-context.service-status.test.ts b/packages/app/src/contexts/session-context.service-status.test.ts new file mode 100644 index 000000000..617a0f024 --- /dev/null +++ b/packages/app/src/contexts/session-context.service-status.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import type { WorkspaceServicePayload } from "@server/shared/messages"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { patchWorkspaceServices } from "./session-workspace-services"; + +function workspace(input: { + id: string; + services?: WorkspaceDescriptor["services"]; +}): WorkspaceDescriptor { + return { + id: input.id, + projectId: "project-1", + projectDisplayName: "Project 1", + projectRootPath: "/repo", + projectKind: "git", + workspaceKind: "local_checkout", + name: "main", + status: "running", + activityAt: null, + diffStat: null, + services: input.services ?? [], + }; +} + +const runningService: WorkspaceServicePayload = { + serviceName: "web", + hostname: "main.web.localhost", + port: 3000, + url: "http://main.web.localhost:6767", + status: "running", +}; + +describe("patchWorkspaceServices", () => { + it("patches only the matching workspace services", () => { + const other = workspace({ id: "/repo/other", services: [] }); + const current = new Map([ + ["/repo/main", workspace({ id: "/repo/main", services: [] })], + [other.id, other], + ]); + + const next = patchWorkspaceServices(current, { + workspaceId: "/repo/main", + services: [runningService], + }); + + expect(next).not.toBe(current); + expect(next.get("/repo/main")?.services).toEqual([runningService]); + expect(next.get("/repo/other")).toBe(other); + }); + + it("ignores updates for unknown workspaces", () => { + const current = new Map([ + ["/repo/main", workspace({ id: "/repo/main", services: [] })], + ]); + + const next = patchWorkspaceServices(current, { + workspaceId: "/repo/missing", + services: [runningService], + }); + + expect(next).toBe(current); + expect(next.get("/repo/main")?.services).toEqual([]); + }); +}); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 887d597b2..42b095786 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"; @@ -50,6 +51,7 @@ import { resolveProjectPlacement } from "@/utils/project-placement"; import { buildDraftStoreKey } from "@/stores/draft-keys"; import type { AttachmentMetadata } from "@/attachments/types"; import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking"; +import { patchWorkspaceServices } from "@/contexts/session-workspace-services"; // Re-export types from session-store and draft-store for backward compatibility export type { DraftInput } from "@/stores/draft-store"; @@ -159,6 +161,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 +270,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( @@ -747,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: { endSeq: number }) => { void client @@ -1083,12 +1099,34 @@ 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: String(message.payload.id) }); removeWorkspace(serverId, String(message.payload.id)); return; } mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(message.payload.workspace)]); }); + const unsubServiceStatusUpdate = client.on("service_status_update", (message) => { + if (message.type !== "service_status_update") return; + setWorkspaces(serverId, (prev) => patchWorkspaceServices(prev, message.payload)); + }); + + const unsubWorkspaceSetupProgress = client.on("workspace_setup_progress", (message) => { + if (message.type !== "workspace_setup_progress") return; + applyWorkspaceSetupProgress(message.payload); + }); + + const unsubWorkspaceSetupStatusResponse = client.on( + "workspace_setup_status_response", + (message) => { + if (message.type !== "workspace_setup_status_response") return; + const { workspaceId, snapshot } = message.payload; + if (snapshot) { + applyWorkspaceSetupProgress({ workspaceId, ...snapshot }); + } + }, + ); + const unsubStatus = client.on("status", (message) => { if (message.type !== "status") return; const serverInfo = parseServerInfoStatusPayload(message.payload); @@ -1437,6 +1475,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider unsubAgentStream(); unsubAgentTimeline(); unsubWorkspaceUpdate(); + unsubServiceStatusUpdate(); + unsubWorkspaceSetupProgress(); + unsubWorkspaceSetupStatusResponse(); unsubStatus(); unsubPermissionRequest(); unsubPermissionResolved(); @@ -1462,8 +1503,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider setAgentTimelineCursor, setInitializingAgents, setAgents, + setWorkspaces, mergeWorkspaces, removeWorkspace, + removeWorkspaceSetup, setAgentLastActivity, setPendingPermissions, setHasHydratedAgents, @@ -1471,6 +1514,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider notifyAgentAttention, requestCanonicalCatchUp, applyAgentUpdatePayload, + applyWorkspaceSetupProgress, applyTimelineResponse, voiceRuntime, voiceAudioEngine, @@ -1674,9 +1718,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/contexts/session-workspace-services.ts b/packages/app/src/contexts/session-workspace-services.ts new file mode 100644 index 000000000..3ba1885f5 --- /dev/null +++ b/packages/app/src/contexts/session-workspace-services.ts @@ -0,0 +1,19 @@ +import type { ServiceStatusUpdateMessage } from "@server/shared/messages"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; + +export function patchWorkspaceServices( + workspaces: Map, + update: ServiceStatusUpdateMessage["payload"], +): Map { + const existing = workspaces.get(update.workspaceId); + if (!existing) { + return workspaces; + } + + const next = new Map(workspaces); + next.set(update.workspaceId, { + ...existing, + services: update.services.map((s) => ({ ...s })), + }); + return next; +} diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts index e87f2443d..2678ce54c 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import type { WorkspaceServicePayload } from "@server/shared/messages"; import { appendMissingOrderKeys, applyStoredOrdering, @@ -24,6 +25,7 @@ function workspace( | "workspaceDirectory" | "projectKind" | "workspaceKind" + | "services" > >, ): WorkspaceDescriptor { @@ -39,9 +41,26 @@ function workspace( status: input.status, activityAt: input.activityAt, diffStat: null, + services: input.services ?? [], }; } +const runningService: WorkspaceServicePayload = { + serviceName: "web", + hostname: "main.web.localhost", + port: 3000, + url: "http://main.web.localhost:6767", + status: "running", +}; + +const stoppedService: WorkspaceServicePayload = { + serviceName: "api", + hostname: "main.api.localhost", + port: 3001, + url: "http://main.api.localhost:6767", + status: "stopped", +}; + describe("applyStoredOrdering", () => { it("keeps unknown items on the baseline while applying stored order", () => { const result = applyStoredOrdering({ @@ -122,6 +141,27 @@ describe("buildSidebarProjectsFromWorkspaces", () => { expect(projects[0]?.workspaces[0]?.statusBucket).toBe("failed"); }); + it("threads services into workspace rows and derives hasRunningServices", () => { + const projects = buildSidebarProjectsFromWorkspaces({ + serverId: "srv", + workspaces: [ + workspace({ + id: "/repo/main", + projectId: "project-1", + name: "main", + status: "running", + activityAt: new Date("2026-01-01T00:00:00.000Z"), + services: [runningService, stoppedService], + }), + ], + projectOrder: [], + workspaceOrderByScope: {}, + }); + + expect(projects[0]?.workspaces[0]?.services).toEqual([runningService, stoppedService]); + expect(projects[0]?.workspaces[0]?.hasRunningServices).toBe(true); + }); + it("preserves stored project order even when activity changes", () => { const initialWorkspaces: WorkspaceDescriptor[] = [ workspace({ diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index 0678b78ee..ee7f162a5 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; +import type { WorkspaceDescriptorPayload } from "@server/shared/messages"; import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; @@ -23,6 +24,8 @@ export interface SidebarWorkspaceEntry { activityAt: Date | null; statusBucket: SidebarStateBucket; diffStat: { additions: number; deletions: number } | null; + services: WorkspaceDescriptor["services"]; + hasRunningServices: boolean; } export interface SidebarProjectEntry { @@ -141,6 +144,8 @@ export function buildSidebarProjectsFromWorkspaces(input: { activityAt: workspace.activityAt, statusBucket: workspace.status, diffStat: workspace.diffStat, + services: workspace.services, + hasRunningServices: workspace.services.some((service) => service.status === "running"), }; project.workspaces.push(row); @@ -264,8 +269,12 @@ function toWorkspaceDescriptor(payload: { name: string; status: WorkspaceDescriptor["status"]; activityAt: string | null; + services?: WorkspaceDescriptorPayload["services"]; }): WorkspaceDescriptor { - return normalizeWorkspaceDescriptor(payload); + return normalizeWorkspaceDescriptor({ + ...payload, + services: payload.services ?? [], + }); } export function useSidebarWorkspacesList(options?: { diff --git a/packages/app/src/panels/register-panels.ts b/packages/app/src/panels/register-panels.ts index c22fd66a2..3afb57025 100644 --- a/packages/app/src/panels/register-panels.ts +++ b/packages/app/src/panels/register-panels.ts @@ -3,6 +3,7 @@ import { draftPanelRegistration } from "@/panels/draft-panel"; import { filePanelRegistration } from "@/panels/file-panel"; import { launcherPanelRegistration } from "@/panels/launcher-panel"; import { registerPanel } from "@/panels/panel-registry"; +import { setupPanelRegistration } from "@/panels/setup-panel"; import { terminalPanelRegistration } from "@/panels/terminal-panel"; let panelsRegistered = false; @@ -13,6 +14,7 @@ export function ensurePanelsRegistered(): void { } registerPanel(draftPanelRegistration); registerPanel(agentPanelRegistration); + registerPanel(setupPanelRegistration); registerPanel(terminalPanelRegistration); registerPanel(filePanelRegistration); registerPanel(launcherPanelRegistration); diff --git a/packages/app/src/panels/setup-panel.tsx b/packages/app/src/panels/setup-panel.tsx new file mode 100644 index 000000000..41ed6b143 --- /dev/null +++ b/packages/app/src/panels/setup-panel.tsx @@ -0,0 +1,444 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { CheckCircle2, ChevronRight, CircleAlert, SquareTerminal } from "lucide-react-native"; +import { ActivityIndicator, Pressable, 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"; +import { useHostRuntimeClient } from "@/runtime/host-runtime"; + +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, + }; +} + +type CommandStatus = "running" | "completed" | "failed"; + +function CommandStatusIcon({ status }: { status: CommandStatus }) { + const { theme } = useUnistyles(); + + if (status === "running") { + return ; + } + if (status === "completed") { + return ; + } + return ; +} + +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds}s`; +} + +/** + * Process carriage returns in log text so progress-bar output renders cleanly. + * Splits on \r, keeps only the last segment per CR-delimited group (unless followed by \n). + */ +function processCarriageReturns(text: string): string { + if (!text.includes("\r")) return text; + return text + .split("\n") + .map((line) => { + if (!line.includes("\r")) return line; + const segments = line.split("\r"); + return segments[segments.length - 1]; + }) + .join("\n"); +} + +function SetupPanel() { + const { theme } = useUnistyles(); + const { serverId, target } = usePaneContext(); + invariant(target.kind === "setup", "SetupPanel requires setup target"); + + const client = useHostRuntimeClient(serverId); + const key = buildWorkspaceTabPersistenceKey({ + serverId, + workspaceId: target.workspaceId, + }); + const snapshot = useWorkspaceSetupStore((state) => (key ? state.snapshots[key] ?? null : null)); + const upsertProgress = useWorkspaceSetupStore((state) => state.upsertProgress); + + // On mount, if no snapshot in the store, request cached status from server + const requestedRef = useRef(false); + useEffect(() => { + if (snapshot || requestedRef.current || !client) return; + requestedRef.current = true; + client + .fetchWorkspaceSetupStatus(target.workspaceId) + .then((response) => { + if (response.snapshot) { + upsertProgress({ + serverId, + payload: { workspaceId: response.workspaceId, ...response.snapshot }, + }); + } + }) + .catch(() => { + // Server may not support this yet — ignore + }); + }, [client, snapshot, serverId, target.workspaceId, upsertProgress]); + + const commands = snapshot?.detail.commands ?? []; + const log = snapshot?.detail.log ?? ""; + const hasNoSetupCommands = + snapshot?.status === "completed" && commands.length === 0 && log.trim().length === 0; + const isWaiting = !snapshot || (snapshot.status === "running" && commands.length === 0); + + const [expandedIndices, setExpandedIndices] = useState>(new Set()); + const [manuallyCollapsed, setManuallyCollapsed] = useState>(new Set()); + + const toggleExpanded = useCallback((index: number, isAutoExpanded: boolean) => { + setExpandedIndices((prev) => { + const next = new Set(prev); + if (next.has(index) || isAutoExpanded) { + next.delete(index); + // If this was auto-expanded, record that the user manually collapsed it + if (isAutoExpanded) { + setManuallyCollapsed((mc) => new Set(mc).add(index)); + } + } else { + next.add(index); + // If the user re-expands, remove from manually collapsed + setManuallyCollapsed((mc) => { + const next = new Set(mc); + next.delete(index); + return next; + }); + } + return next; + }); + }, []); + + // Determine which command should auto-expand (running or last completed). + const autoExpandIndex = (() => { + const running = commands.find((c) => c.status === "running"); + if (running) return running.index; + if (commands.length > 0) return commands[commands.length - 1].index; + return null; + })(); + + const statusLabel = snapshot?.status === "running" + ? "Running" + : snapshot?.status === "completed" + ? "Completed" + : snapshot?.status === "failed" + ? "Failed" + : "Waiting for setup output"; + + return ( + + {/* Hidden element for status — preserves testID for E2E */} + {statusLabel} + + {isWaiting ? ( + + + Setting up workspace... + + ) : hasNoSetupCommands ? ( + + + No setup commands ran for this workspace. + + + ) : ( + + {commands.map((command) => { + const isExpanded = expandedIndices.has(command.index); + const hasError = command.status === "failed" && snapshot?.error; + + // Per-command log: use command.log if available, fall back to detail.log for the auto-expand target + const commandLog = (() => { + if ("log" in command && typeof command.log === "string") { + return command.log; + } + // Fallback: show detail.log on the auto-expand target command + if (command.index === autoExpandIndex) return log; + return ""; + })(); + const hasLog = commandLog.trim().length > 0; + + // All non-running commands are expandable (completed/failed) + const isExpandable = command.status !== "running" || hasLog || !!hasError; + + // Auto-expand the active command unless the user manually collapsed it + const isAutoExpanded = + command.index === autoExpandIndex && !manuallyCollapsed.has(command.index); + const showDetail = isExpanded || isAutoExpanded; + + const processedLog = hasLog ? processCarriageReturns(commandLog) : ""; + + return ( + + toggleExpanded(command.index, isAutoExpanded)} + style={({ pressed }) => [ + styles.commandRow, + showDetail && styles.commandRowExpanded, + pressed && styles.commandRowPressed, + ]} + accessibilityRole="button" + accessibilityState={{ expanded: showDetail }} + > + + + + + {command.command} + + {command.durationMs != null ? ( + + {formatDuration(command.durationMs)} + + ) : null} + + + {showDetail ? ( + + {hasLog ? ( + + + {processedLog} + + + ) : ( + + No output + + )} + {hasError ? ( + + + {snapshot.error} + + + ) : null} + + ) : null} + + ); + })} + + {/* If there's log but no commands yet, or log without a target command, show standalone */} + {commands.length === 0 && log.trim().length > 0 ? ( + + + {log} + + + ) : null} + + {/* Show error at top level if no commands failed but there's a setup error */} + {snapshot?.error && !commands.some((c) => c.status === "failed") ? ( + + + {snapshot.error} + + + ) : null} + + )} + + ); +} + +export const setupPanelRegistration: PanelRegistration<"setup"> = { + kind: "setup", + component: SetupPanel, + useDescriptor: useSetupPanelDescriptor, +}; + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + minHeight: 0, + backgroundColor: theme.colors.surface0, + }, + contentContainer: { + padding: theme.spacing[4], + flexGrow: 1, + }, + hiddenStatus: { + position: "absolute", + width: 1, + height: 1, + overflow: "hidden", + opacity: 0, + }, + waitingContainer: { + flex: 1, + alignItems: "center", + justifyContent: "center", + gap: theme.spacing[3], + }, + waitingText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, + emptyContainer: { + flex: 1, + alignItems: "center", + justifyContent: "center", + }, + emptyText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, + commandList: { + gap: theme.spacing[2], + }, + commandItem: { + borderRadius: theme.borderRadius.lg, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + overflow: "hidden", + }, + commandRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + backgroundColor: theme.colors.surface1, + }, + commandRowExpanded: { + borderBottomWidth: theme.borderWidth[1], + borderBottomColor: theme.colors.border, + }, + commandRowPressed: { + opacity: 0.8, + }, + commandStatusIcon: { + width: 18, + height: 18, + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }, + commandText: { + flex: 1, + fontFamily: Fonts.mono, + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + }, + commandDuration: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + flexShrink: 0, + }, + chevron: { + flexShrink: 0, + }, + chevronExpanded: { + transform: [{ rotate: "90deg" }], + }, + commandDetail: { + backgroundColor: theme.colors.surface0, + }, + logScroll: { + maxHeight: 400, + }, + logScrollContent: { + padding: theme.spacing[3], + }, + logText: { + fontFamily: Fonts.mono, + fontSize: theme.fontSize.sm, + lineHeight: 20, + color: theme.colors.foreground, + }, + emptyLogText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + fontStyle: "italic", + }, + errorCard: { + padding: theme.spacing[3], + backgroundColor: theme.colors.palette.red[100], + }, + errorText: { + fontSize: theme.fontSize.sm, + color: theme.colors.palette.red[800], + }, +})); 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 b6bac5ce4..1cdb2f304 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -87,6 +87,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 c0667d051..7aeb092d9 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -149,6 +149,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"; } @@ -165,6 +168,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-source-of-truth.test.ts b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts index 1ca625126..e17e9a940 100644 --- a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts +++ b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts @@ -20,6 +20,7 @@ describe("workspace source of truth consumption", () => { status: "running", activityAt: new Date("2026-03-01T00:00:00.000Z"), diffStat: null, + services: [], }; const header = resolveWorkspaceHeader({ workspace }); diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.ts b/packages/app/src/screens/workspace/workspace-tab-menu.ts index bfdb89713..e1f3c5589 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.ts @@ -81,6 +81,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string { if (tab.target.kind === "launcher") { return `workspace-launcher-close-${tab.target.launcherId}`; } + 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/session-store.test.ts b/packages/app/src/stores/session-store.test.ts new file mode 100644 index 000000000..3a6ae4955 --- /dev/null +++ b/packages/app/src/stores/session-store.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { DaemonClient } from "@server/client/daemon-client"; +import type { WorkspaceDescriptorPayload } from "@server/shared/messages"; +import { + normalizeWorkspaceDescriptor, + useSessionStore, + type WorkspaceDescriptor, +} from "./session-store"; + +function workspace( + input: Partial & Pick, +): WorkspaceDescriptor { + return { + id: input.id, + projectId: input.projectId ?? "project-1", + projectDisplayName: input.projectDisplayName ?? "Project 1", + projectRootPath: input.projectRootPath ?? "/repo", + projectKind: input.projectKind ?? "git", + workspaceKind: input.workspaceKind ?? "local_checkout", + name: input.name ?? "main", + status: input.status ?? "done", + activityAt: input.activityAt ?? null, + diffStat: input.diffStat ?? null, + services: input.services ?? [], + }; +} + +afterEach(() => { + useSessionStore.getState().clearSession("test-server"); +}); + +describe("normalizeWorkspaceDescriptor", () => { + it("normalizes workspace services and invalid activity timestamps", () => { + const services = [ + { + serviceName: "web", + hostname: "main.web.localhost", + port: 3000, + url: "http://main.web.localhost:6767", + status: "running" as const, + }, + ]; + const workspace = normalizeWorkspaceDescriptor({ + id: "/repo/main", + projectId: "project-1", + projectDisplayName: "Project 1", + projectRootPath: "/repo", + projectKind: "git", + workspaceKind: "local_checkout", + name: "main", + status: "running", + activityAt: "not-a-date", + diffStat: null, + services, + }); + + expect(workspace.activityAt).toBeNull(); + expect(workspace.services).toEqual([ + { + serviceName: "web", + hostname: "main.web.localhost", + port: 3000, + url: "http://main.web.localhost:6767", + status: "running", + }, + ]); + expect(workspace.services).not.toBe(services); + }); + + it("defaults missing services to an empty array", () => { + const payload = { + id: "/repo/main", + projectId: "project-1", + projectDisplayName: "Project 1", + projectRootPath: "/repo", + projectKind: "git", + workspaceKind: "local_checkout", + name: "main", + status: "done", + activityAt: null, + diffStat: null, + } as WorkspaceDescriptorPayload; + + const workspace = normalizeWorkspaceDescriptor(payload); + + expect(workspace.services).toEqual([]); + }); +}); + +describe("mergeWorkspaces", () => { + it("preserves services on merged workspace entries", () => { + const store = useSessionStore.getState(); + store.initializeSession("test-server", null as unknown as DaemonClient); + store.setWorkspaces( + "test-server", + new Map([["/repo/main", workspace({ id: "/repo/main", services: [] })]]), + ); + + store.mergeWorkspaces("test-server", [ + workspace({ + id: "/repo/main", + services: [ + { + serviceName: "web", + hostname: "main.web.localhost", + port: 3000, + url: "http://main.web.localhost:6767", + status: "running", + }, + ], + }), + ]); + + expect(store.getSession("test-server")?.workspaces.get("/repo/main")?.services).toEqual([ + { + serviceName: "web", + hostname: "main.web.localhost", + port: 3000, + url: "http://main.web.localhost:6767", + status: "running", + }, + ]); + }); +}); diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index ecc252603..d029864f6 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -125,6 +125,7 @@ export interface WorkspaceDescriptor { status: WorkspaceDescriptorPayload["status"]; activityAt: Date | null; diffStat: { additions: number; deletions: number } | null; + services: WorkspaceDescriptorPayload["services"]; } export function normalizeWorkspaceDescriptor( @@ -143,6 +144,7 @@ export function normalizeWorkspaceDescriptor( status: payload.status, activityAt: activityAt && !Number.isNaN(activityAt.getTime()) ? activityAt : null, diffStat: payload.diffStat ?? null, + services: (payload.services ?? []).map((s) => ({ ...s })), }; } diff --git a/packages/app/src/stores/workspace-setup-store.ts b/packages/app/src/stores/workspace-setup-store.ts index ba24dde8d..b8e5cf7fd 100644 --- a/packages/app/src/stores/workspace-setup-store.ts +++ b/packages/app/src/stores/workspace-setup-store.ts @@ -1,4 +1,6 @@ +import type { SessionOutboundMessage } from "@server/shared/messages"; import { create } from "zustand"; +import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; export type WorkspaceSetupNavigationMethod = "navigate" | "replace"; export type WorkspaceCreationMethod = "open_project" | "create_worktree"; @@ -12,18 +14,81 @@ export interface PendingWorkspaceSetup { navigationMethod: WorkspaceSetupNavigationMethod; } +export type WorkspaceSetupProgressPayload = Extract< + SessionOutboundMessage, + { type: "workspace_setup_progress" } +>["payload"]; + +export interface WorkspaceSetupSnapshot extends WorkspaceSetupProgressPayload { + updatedAt: number; +} + interface WorkspaceSetupStoreState { pendingWorkspaceSetup: PendingWorkspaceSetup | null; + snapshots: Record; beginWorkspaceSetup: (value: PendingWorkspaceSetup) => void; clearWorkspaceSetup: () => void; + 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) => ({ pendingWorkspaceSetup: null, + snapshots: {}, beginWorkspaceSetup: (value) => { set({ pendingWorkspaceSetup: value }); }, clearWorkspaceSetup: () => { set({ pendingWorkspaceSetup: null }); }, + 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 9459d3665..079a523da 100644 --- a/packages/app/src/stores/workspace-tabs-store.test.ts +++ b/packages/app/src/stores/workspace-tabs-store.test.ts @@ -235,4 +235,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 679e7e018..47b28d2e2 100644 --- a/packages/app/src/stores/workspace-tabs-store.ts +++ b/packages/app/src/stores/workspace-tabs-store.ts @@ -13,7 +13,8 @@ export type WorkspaceTabTarget = | { kind: "agent"; agentId: string } | { kind: "terminal"; terminalId: string } | { kind: "file"; path: string } - | { kind: "launcher"; launcherId: string }; + | { kind: "launcher"; launcherId: string } + | { kind: "setup"; workspaceId: string }; export type WorkspaceTab = { tabId: string; diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index 85ede68bf..4a49076e1 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -83,6 +83,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 fab334d7c..35e6da757 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/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts index a554cde01..e52f78e84 100644 --- a/packages/app/src/utils/sidebar-project-row-model.test.ts +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -19,6 +19,8 @@ function workspace(overrides: Partial = {}): SidebarWorks activityAt: null, statusBucket: "done", diffStat: null, + services: [], + hasRunningServices: false, ...overrides, }; } diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index dc8625481..928c898a3 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -17,6 +17,8 @@ function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry { activityAt: null, statusBucket: "done", diffStat: null, + services: [], + hasRunningServices: false, }; } diff --git a/packages/app/src/utils/tool-call-display.test.ts b/packages/app/src/utils/tool-call-display.test.ts index 3854da586..572cc75f9 100644 --- a/packages/app/src/utils/tool-call-display.test.ts +++ b/packages/app/src/utils/tool-call-display.test.ts @@ -96,6 +96,7 @@ describe("tool-call-display", () => { index: 1, command: "npm install", cwd: "/tmp/repo/.paseo/worktrees/repo/branch", + log: "", status: "running", exitCode: null, }, diff --git a/packages/app/src/utils/workspace-archive-navigation.test.ts b/packages/app/src/utils/workspace-archive-navigation.test.ts index 266a9fd14..a307c5376 100644 --- a/packages/app/src/utils/workspace-archive-navigation.test.ts +++ b/packages/app/src/utils/workspace-archive-navigation.test.ts @@ -20,6 +20,7 @@ function workspace( status: input.status ?? "running", activityAt: input.activityAt ?? null, diffStat: input.diffStat ?? null, + services: input.services ?? [], }; } diff --git a/packages/app/src/utils/workspace-tab-identity.ts b/packages/app/src/utils/workspace-tab-identity.ts index 771c3d42c..2341af17a 100644 --- a/packages/app/src/utils/workspace-tab-identity.ts +++ b/packages/app/src/utils/workspace-tab-identity.ts @@ -26,6 +26,10 @@ export function normalizeWorkspaceTabTarget( const launcherId = trimNonEmpty(value.launcherId); return launcherId ? { kind: "launcher", launcherId } : null; } + if (value.kind === "setup") { + const workspaceId = trimNonEmpty(value.workspaceId); + return workspaceId ? { kind: "setup", workspaceId: workspaceId.replace(/\\/g, "/") } : null; + } return null; } @@ -52,6 +56,9 @@ export function workspaceTabTargetsEqual( // Launcher tabs are intentionally always unique, even when reopened repeatedly. return false; } + if (left.kind === "setup" && right.kind === "setup") { + return left.workspaceId === right.workspaceId; + } return false; } @@ -68,6 +75,9 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st if (target.kind === "launcher") { return `launcher_${target.launcherId}`; } + 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 c28c4e3f7..f25ff65c7 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -213,6 +213,82 @@ 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", + log: "phase-one\n", + 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", + log: "phase-one\n", + 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 49762baed..74cee2f36 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -36,6 +36,7 @@ import type { ProjectIconResponse, OpenProjectResponseMessage, ArchiveWorkspaceResponseMessage, + WorkspaceSetupStatusResponseMessage, ListCommandsResponse, ListProviderModelsResponseMessage, ListAvailableProvidersResponse, @@ -124,6 +125,11 @@ export type DaemonEvent = workspaceId: number; payload: Extract["payload"]; } + | { + type: "workspace_setup_progress"; + workspaceId: string; + payload: Extract["payload"]; + } | { type: "agent_stream"; agentId: string; @@ -459,6 +465,7 @@ export type InspectScheduleOptions = { }; type OpenProjectPayload = OpenProjectResponseMessage["payload"]; type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"]; +type WorkspaceSetupStatusPayload = WorkspaceSetupStatusResponseMessage["payload"]; export type FetchAgentResult = { agent: AgentSnapshotPayload; @@ -1315,6 +1322,21 @@ export class DaemonClient { }); } + async fetchWorkspaceSetupStatus( + workspaceId: string, + requestId?: string, + ): Promise { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "workspace_setup_status_request", + workspaceId, + }, + responseType: "workspace_setup_status_response", + timeout: 10000, + }); + } + async fetchAgent(agentId: string, requestId?: string): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ @@ -3549,6 +3571,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 375f0e1bc..e5b3070ea 100644 --- a/packages/server/src/server/agent/agent-management-mcp.ts +++ b/packages/server/src/server/agent/agent-management-mcp.ts @@ -48,11 +48,14 @@ import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js"; import { expandUserPath } from "../path-utils.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js"; +import type { ServiceRouteStore } from "../service-proxy.js"; export interface AgentManagementMcpOptions { agentManager: AgentManager; agentStorage: AgentSnapshotStore; terminalManager?: TerminalManager | null; + serviceRouteStore?: ServiceRouteStore; + getDaemonTcpPort?: () => number | null; paseoHome?: string; logger: Logger; } @@ -296,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"; @@ -322,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({ @@ -339,6 +347,8 @@ export async function createAgentManagementMcpServer( agentId: snapshot.id, item, }), + serviceRouteStore: options.serviceRouteStore, + daemonPort: options.getDaemonTcpPort?.() ?? null, logger: childLogger, }); } diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 7e179a0a7..5fc10b088 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -179,6 +179,7 @@ export type ToolCallDetail = index: number; command: string; cwd: string; + log: string; status: "running" | "completed" | "failed"; exitCode: number | null; durationMs?: number; diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 388878419..7a2238c73 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -28,11 +28,14 @@ import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js"; import { expandUserPath, resolvePathFromBase } from "../path-utils.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js"; +import type { ServiceRouteStore } from "../service-proxy.js"; export interface AgentMcpServerOptions { agentManager: AgentManager; agentStorage: AgentSnapshotStore; terminalManager?: TerminalManager | null; + serviceRouteStore?: ServiceRouteStore; + getDaemonTcpPort?: () => number | null; paseoHome?: string; /** * ID of the agent that is connecting to this MCP server. @@ -428,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); @@ -464,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; @@ -497,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({ @@ -510,6 +516,8 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom agentId: snapshot.id, item, }), + serviceRouteStore: options.serviceRouteStore, + daemonPort: options.getDaemonTcpPort?.() ?? null, logger: childLogger, }); } diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 2ef594562..41956b22f 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -118,6 +118,13 @@ import { resolveDaemonVersion } from "./daemon-version.js"; import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js"; +import { + ServiceRouteStore, + createServiceProxyMiddleware, + createServiceProxyUpgradeHandler, +} from "./service-proxy.js"; +import { ServiceHealthMonitor } from "./service-health-monitor.js"; +import { createServiceStatusEmitter } from "./service-status-projection.js"; import { createVoiceMcpSocketBridgeManager, type VoiceMcpSocketBridgeManager, @@ -194,6 +201,7 @@ export interface PaseoDaemon { agentManager: AgentManager; agentStorage: AgentSnapshotStore; terminalManager: TerminalManager; + serviceRouteStore: ServiceRouteStore; start(): Promise; stop(): Promise; getListenTarget(): ListenTarget | null; @@ -224,6 +232,20 @@ export async function createPaseoDaemon( const app = express(); let boundListenTarget: ListenTarget | null = null; + const serviceRouteStore = new ServiceRouteStore(); + let wsServer: VoiceAssistantWebSocketServer | null = null; + const serviceHealthMonitor = new ServiceHealthMonitor({ + routeStore: serviceRouteStore, + onChange: createServiceStatusEmitter({ + sessions: () => + wsServer?.listActiveSessions().map((session) => ({ + emit: (message) => session.emitServerMessage(message), + })) ?? [], + routeStore: serviceRouteStore, + daemonPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), + }), + }); + // Host allowlist / DNS rebinding protection (vite-like semantics). // For non-TCP (unix sockets), skip host validation. if (listenTarget.type === "tcp") { @@ -237,6 +259,14 @@ export async function createPaseoDaemon( }); } + // Service proxy — intercepts requests for registered *.localhost hostnames + // and forwards them to the corresponding local service port. Placed after + // the host allowlist (*.localhost is already allowed) but before CORS and + // the rest of the routes so proxied requests skip unnecessary middleware. + app.use( + createServiceProxyMiddleware({ routeStore: serviceRouteStore, logger }), + ); + // CORS - allow same-origin + configured origins const allowedOrigins = new Set([ ...config.corsAllowedOrigins, @@ -361,6 +391,16 @@ export async function createPaseoDaemon( database = await openPaseoDatabase(path.join(config.paseoHome, "db")); logger.info({ elapsed: elapsed() }, "Paseo database opened"); + // Service proxy WebSocket upgrade handler — must be registered before the + // VoiceAssistantWebSocketServer attaches its own "upgrade" listener so that + // service-bound upgrades are forwarded first. The handler is a no-op for + // requests that don't match a registered service route. + const serviceProxyUpgradeHandler = createServiceProxyUpgradeHandler({ + routeStore: serviceRouteStore, + logger, + }); + httpServer.on("upgrade", serviceProxyUpgradeHandler); + const agentStorage = new DbAgentSnapshotStore(database.db); const chatService = new FileBackedChatService({ paseoHome: config.paseoHome, @@ -451,7 +491,6 @@ export async function createPaseoDaemon( "Voice mode configured for agent-scoped resume flow (no dedicated voice assistant provider)", ); logger.info({ elapsed: elapsed() }, "Preparing voice and MCP runtime"); - let wsServer: VoiceAssistantWebSocketServer | null = null; let voiceMcpBridgeManager: VoiceMcpSocketBridgeManager | null = null; // Create in-memory transport for Session's Agent MCP client (voice assistant tools) @@ -460,6 +499,9 @@ export async function createPaseoDaemon( agentManager, agentStorage, terminalManager, + serviceRouteStore, + getDaemonTcpPort: () => + boundListenTarget?.type === "tcp" ? boundListenTarget.port : null, paseoHome: config.paseoHome, enableVoiceTools: false, resolveSpeakHandler: (callerAgentId) => @@ -486,6 +528,9 @@ export async function createPaseoDaemon( agentManager, agentStorage, terminalManager, + serviceRouteStore, + getDaemonTcpPort: () => + boundListenTarget?.type === "tcp" ? boundListenTarget.port : null, paseoHome: config.paseoHome, callerAgentId, enableVoiceTools: false, @@ -606,6 +651,9 @@ export async function createPaseoDaemon( agentManager, agentStorage, terminalManager, + serviceRouteStore, + getDaemonTcpPort: () => + boundListenTarget?.type === "tcp" ? boundListenTarget.port : null, paseoHome: config.paseoHome, callerAgentId, voiceOnly: true, @@ -666,6 +714,9 @@ export async function createPaseoDaemon( loopService, scheduleService, checkoutDiffManager, + serviceRouteStore, + () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), + (hostname) => serviceHealthMonitor.getStatusForHostname(hostname), ); logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening"); @@ -756,10 +807,12 @@ export async function createPaseoDaemon( // Start speech service after listening so synchronous Sherpa native // model loading doesn't block the server from accepting connections. speechService.start(); + serviceHealthMonitor.start(); }; const stop = async () => { reconciliationService.stop(); + serviceHealthMonitor.stop(); await closeAllAgents(logger, agentManager); await agentManager.flush().catch(() => undefined); await shutdownProviders(logger, { @@ -790,6 +843,7 @@ export async function createPaseoDaemon( agentManager, agentStorage, terminalManager, + serviceRouteStore, start, stop, getListenTarget: () => boundListenTarget, diff --git a/packages/server/src/server/service-health-monitor.test.ts b/packages/server/src/server/service-health-monitor.test.ts new file mode 100644 index 000000000..5c2579b43 --- /dev/null +++ b/packages/server/src/server/service-health-monitor.test.ts @@ -0,0 +1,455 @@ +import net from "node:net"; +import { scheduler } from "node:timers/promises"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { findFreePort, ServiceRouteStore } from "./service-proxy.js"; +import { + ServiceHealthMonitor, + type ServiceStatusEntry, +} from "./service-health-monitor.js"; + +type TcpServerHandle = { + port: number; + server: net.Server; +}; + +async function startTcpServer(): Promise { + const server = net.createServer((socket) => { + socket.end(); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Failed to resolve TCP server address"); + } + + return { port: address.port, server }; +} + +async function closeServer(server: net.Server): Promise { + if (!server.listening) { + return; + } + + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +async function advancePoll(ms: number): Promise { + await vi.advanceTimersByTimeAsync(ms); + for (let i = 0; i < 5; i += 1) { + await scheduler.yield(); + } +} + +describe("ServiceHealthMonitor", () => { + const servers = new Set(); + + afterEach(async () => { + vi.useRealTimers(); + + for (const server of servers) { + await closeServer(server); + } + servers.clear(); + }); + + it("marks a healthy port as running after successful TCP connect", async () => { + vi.useFakeTimers(); + + const healthy = await startTcpServer(); + servers.add(healthy.server); + + const routeStore = new ServiceRouteStore(); + routeStore.registerRoute({ + hostname: "api.localhost", + port: healthy.port, + workspaceId: "workspace-a", + serviceName: "api", + }); + + const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>(); + const monitor = new ServiceHealthMonitor({ + routeStore, + onChange, + pollIntervalMs: 1_000, + probeTimeoutMs: 100, + graceMs: 0, + }); + + monitor.start(); + await advancePoll(1_000); + monitor.stop(); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith("workspace-a", [ + { + serviceName: "api", + hostname: "api.localhost", + port: healthy.port, + status: "running", + }, + ]); + }); + + it("marks an unreachable port as stopped after consecutive failures", async () => { + vi.useFakeTimers(); + + const deadPort = await findFreePort(); + const routeStore = new ServiceRouteStore(); + routeStore.registerRoute({ + hostname: "api.localhost", + port: deadPort, + workspaceId: "workspace-a", + serviceName: "api", + }); + + const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>(); + const monitor = new ServiceHealthMonitor({ + routeStore, + onChange, + pollIntervalMs: 1_000, + probeTimeoutMs: 100, + graceMs: 0, + failuresBeforeStopped: 2, + }); + + monitor.start(); + await advancePoll(1_000); + expect(onChange).not.toHaveBeenCalled(); + + await advancePoll(1_000); + monitor.stop(); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith("workspace-a", [ + { + serviceName: "api", + hostname: "api.localhost", + port: deadPort, + status: "stopped", + }, + ]); + }); + + it("does not emit when status has not changed", async () => { + vi.useFakeTimers(); + + const healthy = await startTcpServer(); + servers.add(healthy.server); + + const routeStore = new ServiceRouteStore(); + routeStore.registerRoute({ + hostname: "api.localhost", + port: healthy.port, + workspaceId: "workspace-a", + serviceName: "api", + }); + + const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>(); + const monitor = new ServiceHealthMonitor({ + routeStore, + onChange, + pollIntervalMs: 1_000, + probeTimeoutMs: 100, + graceMs: 0, + }); + + monitor.start(); + await advancePoll(3_000); + monitor.stop(); + + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it("respects startup grace period — does not probe newly registered routes for 5 seconds", async () => { + vi.useFakeTimers(); + + const healthy = await startTcpServer(); + servers.add(healthy.server); + + const routeStore = new ServiceRouteStore(); + routeStore.registerRoute({ + hostname: "api.localhost", + port: healthy.port, + workspaceId: "workspace-a", + serviceName: "api", + }); + + const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>(); + const monitor = new ServiceHealthMonitor({ + routeStore, + onChange, + pollIntervalMs: 1_000, + probeTimeoutMs: 100, + graceMs: 5_000, + }); + + monitor.start(); + await advancePoll(4_000); + expect(onChange).not.toHaveBeenCalled(); + + await advancePoll(1_000); + monitor.stop(); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith("workspace-a", [ + { + serviceName: "api", + hostname: "api.localhost", + port: healthy.port, + status: "running", + }, + ]); + }); + + it("requires 2 consecutive failures before marking stopped (debounce)", async () => { + vi.useFakeTimers(); + + const healthy = await startTcpServer(); + servers.add(healthy.server); + + const routeStore = new ServiceRouteStore(); + routeStore.registerRoute({ + hostname: "api.localhost", + port: healthy.port, + workspaceId: "workspace-a", + serviceName: "api", + }); + + const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>(); + const monitor = new ServiceHealthMonitor({ + routeStore, + onChange, + pollIntervalMs: 1_000, + probeTimeoutMs: 100, + graceMs: 0, + failuresBeforeStopped: 2, + }); + + monitor.start(); + await advancePoll(1_000); + expect(onChange).toHaveBeenCalledTimes(1); + + await closeServer(healthy.server); + servers.delete(healthy.server); + + await advancePoll(1_000); + expect(onChange).toHaveBeenCalledTimes(1); + + await advancePoll(1_000); + monitor.stop(); + + expect(onChange).toHaveBeenCalledTimes(2); + expect(onChange).toHaveBeenLastCalledWith("workspace-a", [ + { + serviceName: "api", + hostname: "api.localhost", + port: healthy.port, + status: "stopped", + }, + ]); + }); + + it("stops probing routes that are removed from the store", async () => { + vi.useFakeTimers(); + + const healthy = await startTcpServer(); + servers.add(healthy.server); + + const routeStore = new ServiceRouteStore(); + routeStore.registerRoute({ + hostname: "api.localhost", + port: healthy.port, + workspaceId: "workspace-a", + serviceName: "api", + }); + + const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>(); + const monitor = new ServiceHealthMonitor({ + routeStore, + onChange, + pollIntervalMs: 1_000, + probeTimeoutMs: 100, + graceMs: 0, + failuresBeforeStopped: 2, + }); + + monitor.start(); + await advancePoll(1_000); + expect(onChange).toHaveBeenCalledTimes(1); + + routeStore.removeRoute("api.localhost"); + await closeServer(healthy.server); + servers.delete(healthy.server); + + await advancePoll(3_000); + monitor.stop(); + + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it("calls onChange with workspaceId and full service list when status transitions", async () => { + vi.useFakeTimers(); + + const api = await startTcpServer(); + const web = await startTcpServer(); + servers.add(api.server); + servers.add(web.server); + + const routeStore = new ServiceRouteStore(); + routeStore.registerRoute({ + hostname: "api.localhost", + port: api.port, + workspaceId: "workspace-a", + serviceName: "api", + }); + routeStore.registerRoute({ + hostname: "web.localhost", + port: web.port, + workspaceId: "workspace-a", + serviceName: "web", + }); + + const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>(); + const monitor = new ServiceHealthMonitor({ + routeStore, + onChange, + pollIntervalMs: 1_000, + probeTimeoutMs: 100, + graceMs: 0, + }); + + monitor.start(); + await advancePoll(1_000); + monitor.stop(); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith("workspace-a", [ + { + serviceName: "api", + hostname: "api.localhost", + port: api.port, + status: "running", + }, + { + serviceName: "web", + hostname: "web.localhost", + port: web.port, + status: "running", + }, + ]); + }); + + it("getStatusForHostname returns current status after probe", async () => { + vi.useFakeTimers(); + + const healthy = await startTcpServer(); + servers.add(healthy.server); + + const routeStore = new ServiceRouteStore(); + routeStore.registerRoute({ + hostname: "api.localhost", + port: healthy.port, + workspaceId: "workspace-a", + serviceName: "api", + }); + + const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>(); + const monitor = new ServiceHealthMonitor({ + routeStore, + onChange, + pollIntervalMs: 1_000, + probeTimeoutMs: 100, + graceMs: 0, + }); + + expect(monitor.getStatusForHostname("api.localhost")).toBeNull(); + + monitor.start(); + await advancePoll(1_000); + monitor.stop(); + + expect(monitor.getStatusForHostname("api.localhost")).toBe("running"); + expect(monitor.getStatusForHostname("unknown.localhost")).toBeNull(); + }); + + it("coalesces multiple service changes in same workspace into one onChange call per poll cycle", async () => { + vi.useFakeTimers(); + + const api = await startTcpServer(); + const web = await startTcpServer(); + servers.add(api.server); + servers.add(web.server); + + const routeStore = new ServiceRouteStore(); + routeStore.registerRoute({ + hostname: "api.localhost", + port: api.port, + workspaceId: "workspace-a", + serviceName: "api", + }); + routeStore.registerRoute({ + hostname: "web.localhost", + port: web.port, + workspaceId: "workspace-a", + serviceName: "web", + }); + + const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>(); + const monitor = new ServiceHealthMonitor({ + routeStore, + onChange, + pollIntervalMs: 1_000, + probeTimeoutMs: 100, + graceMs: 0, + failuresBeforeStopped: 2, + }); + + monitor.start(); + await advancePoll(1_000); + expect(onChange).toHaveBeenCalledTimes(1); + + onChange.mockClear(); + await closeServer(api.server); + await closeServer(web.server); + servers.delete(api.server); + servers.delete(web.server); + + await advancePoll(1_000); + expect(onChange).not.toHaveBeenCalled(); + + await advancePoll(1_000); + monitor.stop(); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith("workspace-a", [ + { + serviceName: "api", + hostname: "api.localhost", + port: api.port, + status: "stopped", + }, + { + serviceName: "web", + hostname: "web.localhost", + port: web.port, + status: "stopped", + }, + ]); + }); +}); diff --git a/packages/server/src/server/service-health-monitor.ts b/packages/server/src/server/service-health-monitor.ts new file mode 100644 index 000000000..376d014ca --- /dev/null +++ b/packages/server/src/server/service-health-monitor.ts @@ -0,0 +1,203 @@ +import net from "node:net"; +import type { ServiceRouteEntry, ServiceRouteStore } from "./service-proxy.js"; + +export interface ServiceStatusEntry { + serviceName: string; + hostname: string; + port: number; + status: "running" | "stopped"; +} + +type RouteHealthState = { + status: ServiceStatusEntry["status"] | null; + consecutiveFailures: number; + registeredAt: number; +}; + +export class ServiceHealthMonitor { + private readonly routeStore: ServiceRouteStore; + private readonly onChange: ( + workspaceId: string, + services: ServiceStatusEntry[], + ) => void; + private readonly pollIntervalMs: number; + private readonly probeTimeoutMs: number; + private readonly graceMs: number; + private readonly failuresBeforeStopped: number; + private readonly routeStates = new Map(); + private readonly lastEmittedSnapshots = new Map(); + + private intervalHandle: NodeJS.Timeout | null = null; + private pollInFlight = false; + + constructor({ + routeStore, + onChange, + pollIntervalMs = 3_000, + probeTimeoutMs = 500, + graceMs = 5_000, + failuresBeforeStopped = 2, + }: { + routeStore: ServiceRouteStore; + onChange: (workspaceId: string, services: ServiceStatusEntry[]) => void; + pollIntervalMs?: number; + probeTimeoutMs?: number; + graceMs?: number; + failuresBeforeStopped?: number; + }) { + this.routeStore = routeStore; + this.onChange = onChange; + this.pollIntervalMs = pollIntervalMs; + this.probeTimeoutMs = probeTimeoutMs; + this.graceMs = graceMs; + this.failuresBeforeStopped = failuresBeforeStopped; + } + + start(): void { + if (this.intervalHandle) { + return; + } + + const now = Date.now(); + for (const route of this.routeStore.listRoutes()) { + this.getOrCreateState(route.hostname, now); + } + + this.intervalHandle = setInterval(() => { + void this.poll(); + }, this.pollIntervalMs); + } + + stop(): void { + if (this.intervalHandle) { + clearInterval(this.intervalHandle); + this.intervalHandle = null; + } + } + + private async poll(): Promise { + if (this.pollInFlight) { + return; + } + + this.pollInFlight = true; + try { + const routes = this.routeStore.listRoutes(); + const activeHostnames = new Set(routes.map((route) => route.hostname)); + const changedWorkspaceIds = new Set(); + const now = Date.now(); + + for (const route of routes) { + const state = this.getOrCreateState(route.hostname, now); + if (now - state.registeredAt < this.graceMs) { + continue; + } + + const isHealthy = await this.probeRoute(route.port); + const previousStatus = state.status; + + if (isHealthy) { + state.consecutiveFailures = 0; + state.status = "running"; + } else { + state.consecutiveFailures += 1; + if (state.consecutiveFailures >= this.failuresBeforeStopped) { + state.status = "stopped"; + } + } + + if (state.status !== null && state.status !== previousStatus) { + changedWorkspaceIds.add(route.workspaceId); + } + } + + this.pruneRemovedRoutes(activeHostnames); + + for (const workspaceId of changedWorkspaceIds) { + const services = this.buildWorkspaceServiceList(workspaceId); + const snapshot = JSON.stringify(services); + if (snapshot === this.lastEmittedSnapshots.get(workspaceId)) { + continue; + } + + this.lastEmittedSnapshots.set(workspaceId, snapshot); + this.onChange(workspaceId, services); + } + } finally { + this.pollInFlight = false; + } + } + + private getOrCreateState(hostname: string, registeredAt: number): RouteHealthState { + const existing = this.routeStates.get(hostname); + if (existing) { + return existing; + } + + const state: RouteHealthState = { + status: null, + consecutiveFailures: 0, + registeredAt, + }; + this.routeStates.set(hostname, state); + return state; + } + + private pruneRemovedRoutes(activeHostnames: Set): void { + for (const hostname of this.routeStates.keys()) { + if (activeHostnames.has(hostname)) { + continue; + } + this.routeStates.delete(hostname); + } + } + + private buildWorkspaceServiceList(workspaceId: string): ServiceStatusEntry[] { + return this.routeStore + .listRoutesForWorkspace(workspaceId) + .flatMap((route) => { + const state = this.routeStates.get(route.hostname); + if (!state?.status) { + return []; + } + return [this.toServiceStatusEntry(route, state.status)]; + }); + } + + getStatusForHostname(hostname: string): ServiceStatusEntry["status"] | null { + return this.routeStates.get(hostname)?.status ?? null; + } + + private toServiceStatusEntry( + route: ServiceRouteEntry, + status: ServiceStatusEntry["status"], + ): ServiceStatusEntry { + return { + serviceName: route.serviceName, + hostname: route.hostname, + port: route.port, + status, + }; + } + + private probeRoute(port: number): Promise { + return new Promise((resolve) => { + const socket = net.connect({ host: "127.0.0.1", port }); + let settled = false; + + const finish = (healthy: boolean) => { + if (settled) { + return; + } + settled = true; + socket.destroy(); + resolve(healthy); + }; + + socket.setTimeout(this.probeTimeoutMs); + socket.once("connect", () => finish(true)); + socket.once("timeout", () => finish(false)); + socket.once("error", () => finish(false)); + }); + } +} diff --git a/packages/server/src/server/service-proxy.test.ts b/packages/server/src/server/service-proxy.test.ts new file mode 100644 index 000000000..3a10ca745 --- /dev/null +++ b/packages/server/src/server/service-proxy.test.ts @@ -0,0 +1,478 @@ +import { describe, it, expect, afterEach } from "vitest"; +import http from "node:http"; +import net from "node:net"; +import express from "express"; +import WebSocket, { WebSocketServer } from "ws"; +import pino from "pino"; +import { + ServiceRouteStore, + createServiceProxyMiddleware, + createServiceProxyUpgradeHandler, + findFreePort, +} from "./service-proxy.js"; + +const logger = pino({ level: "silent" }); + +// --------------------------------------------------------------------------- +// Helpers for cleanup +// --------------------------------------------------------------------------- + +function closeServer(server: http.Server): Promise { + return new Promise((resolve) => { + server.close(() => resolve()); + }); +} + +// --------------------------------------------------------------------------- +// ServiceRouteStore +// --------------------------------------------------------------------------- + +describe("ServiceRouteStore", () => { + it("registerRoute and findRoute with exact match", () => { + const store = new ServiceRouteStore(); + store.registerRoute({ + hostname: "editor.localhost", + port: 3000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + serviceName: "editor", + }); + + const route = store.findRoute("editor.localhost"); + expect(route).toEqual({ hostname: "editor.localhost", port: 3000 }); + }); + + it("findRoute strips port from host header", () => { + const store = new ServiceRouteStore(); + store.registerRoute({ + hostname: "editor.localhost", + port: 3000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + serviceName: "editor", + }); + + const route = store.findRoute("editor.localhost:6767"); + expect(route).toEqual({ hostname: "editor.localhost", port: 3000 }); + }); + + it("findRoute subdomain match", () => { + const store = new ServiceRouteStore(); + store.registerRoute({ + hostname: "editor.localhost", + port: 3000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + serviceName: "editor", + }); + + const route = store.findRoute("fix-auth.editor.localhost"); + expect(route).toEqual({ hostname: "editor.localhost", port: 3000 }); + }); + + it("listRoutes returns enriched entries", () => { + const store = new ServiceRouteStore(); + store.registerRoute({ + hostname: "a.localhost", + port: 3000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + serviceName: "web", + }); + store.registerRoute({ + hostname: "b.localhost", + port: 4000, + workspaceId: "/repo/.paseo/worktrees/feature-b", + serviceName: "docs", + }); + + const routes = store.listRoutes(); + expect(routes).toHaveLength(2); + expect(routes).toContainEqual({ + hostname: "a.localhost", + port: 3000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + serviceName: "web", + }); + expect(routes).toContainEqual({ + hostname: "b.localhost", + port: 4000, + workspaceId: "/repo/.paseo/worktrees/feature-b", + serviceName: "docs", + }); + }); + + it("listRoutesForWorkspace returns only routes for that workspace", () => { + const store = new ServiceRouteStore(); + store.registerRoute({ + hostname: "a.localhost", + port: 3000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + serviceName: "web", + }); + store.registerRoute({ + hostname: "b.localhost", + port: 4000, + workspaceId: "/repo/.paseo/worktrees/feature-b", + serviceName: "docs", + }); + store.registerRoute({ + hostname: "c.localhost", + port: 5000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + serviceName: "api", + }); + + expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([ + { + hostname: "a.localhost", + port: 3000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + serviceName: "web", + }, + { + hostname: "c.localhost", + port: 5000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + serviceName: "api", + }, + ]); + }); + + it("removeRoute works", () => { + const store = new ServiceRouteStore(); + store.registerRoute({ + hostname: "editor.localhost", + port: 3000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + serviceName: "editor", + }); + store.removeRoute("editor.localhost"); + + expect(store.findRoute("editor.localhost")).toBeNull(); + }); + + it("removeRoute cleans up workspace index", () => { + const store = new ServiceRouteStore(); + store.registerRoute({ + hostname: "editor.localhost", + port: 3000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + serviceName: "editor", + }); + + store.removeRoute("editor.localhost"); + + expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([]); + }); + + it("removeRoutesForPort works", () => { + const store = new ServiceRouteStore(); + store.registerRoute({ + hostname: "a.localhost", + port: 3000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + serviceName: "web", + }); + store.registerRoute({ + hostname: "b.localhost", + port: 3000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + serviceName: "api", + }); + store.registerRoute({ + hostname: "c.localhost", + port: 4000, + workspaceId: "/repo/.paseo/worktrees/feature-b", + serviceName: "docs", + }); + + store.removeRoutesForPort(3000); + + expect(store.findRoute("a.localhost")).toBeNull(); + expect(store.findRoute("b.localhost")).toBeNull(); + expect(store.findRoute("c.localhost")).toEqual({ + hostname: "c.localhost", + port: 4000, + }); + }); + + it("removeRoutesForPort cleans up workspace index", () => { + const store = new ServiceRouteStore(); + store.registerRoute({ + hostname: "a.localhost", + port: 3000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + serviceName: "web", + }); + store.registerRoute({ + hostname: "b.localhost", + port: 3000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + serviceName: "api", + }); + + store.removeRoutesForPort(3000); + + expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([]); + }); + + it("findRoute returns null for unknown hosts", () => { + const store = new ServiceRouteStore(); + store.registerRoute({ + hostname: "editor.localhost", + port: 3000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + serviceName: "editor", + }); + + expect(store.findRoute("unknown.example.com")).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// HTTP proxy +// --------------------------------------------------------------------------- + +describe("HTTP proxy", () => { + const servers: http.Server[] = []; + + afterEach(async () => { + await Promise.all(servers.map(closeServer)); + servers.length = 0; + }); + + /** Start a real HTTP server that echoes back a known body and records received headers. */ + async function startUpstream(): Promise<{ + port: number; + server: http.Server; + receivedHeaders: () => http.IncomingHttpHeaders; + }> { + const port = await findFreePort(); + let lastHeaders: http.IncomingHttpHeaders = {}; + + const server = http.createServer((req, res) => { + lastHeaders = req.headers; + res.writeHead(200, { "content-type": "text/plain" }); + res.end("upstream-ok"); + }); + + await new Promise((resolve) => + server.listen(port, "127.0.0.1", resolve), + ); + servers.push(server); + + return { + port, + server, + receivedHeaders: () => lastHeaders, + }; + } + + /** Start an Express app with the service proxy middleware and an optional fallback. */ + async function startProxy( + routeStore: ServiceRouteStore, + opts?: { fallback?: boolean }, + ): Promise<{ port: number; server: http.Server }> { + const port = await findFreePort(); + const app = express(); + app.use(createServiceProxyMiddleware({ routeStore, logger })); + + if (opts?.fallback) { + app.use((_req, res) => { + res.status(404).send("no route"); + }); + } + + const server = http.createServer(app); + await new Promise((resolve) => + server.listen(port, "127.0.0.1", resolve), + ); + servers.push(server); + + return { port, server }; + } + + /** Simple HTTP GET helper that returns status code and body. */ + function httpGet( + port: number, + host: string, + path = "/", + ): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const req = http.get( + { hostname: "127.0.0.1", port, path, headers: { host } }, + (res) => { + let body = ""; + res.on("data", (chunk: Buffer) => (body += chunk.toString())); + res.on("end", () => + resolve({ status: res.statusCode ?? 0, body }), + ); + }, + ); + req.on("error", reject); + }); + } + + it("proxies requests to the correct upstream based on Host header", async () => { + const upstream = await startUpstream(); + const routeStore = new ServiceRouteStore(); + routeStore.addRoute("test-service.localhost", upstream.port); + + const proxy = await startProxy(routeStore); + const res = await httpGet( + proxy.port, + `test-service.localhost:${proxy.port}`, + ); + + expect(res.status).toBe(200); + expect(res.body).toBe("upstream-ok"); + + const headers = upstream.receivedHeaders(); + expect(headers["x-forwarded-for"]).toBeDefined(); + expect(headers["x-forwarded-host"]).toBe("test-service.localhost"); + }); + + it("falls through when no route matches", async () => { + const routeStore = new ServiceRouteStore(); + const proxy = await startProxy(routeStore, { fallback: true }); + + const res = await httpGet( + proxy.port, + `unknown.localhost:${proxy.port}`, + ); + + expect(res.status).toBe(404); + expect(res.body).toBe("no route"); + }); + + it("returns 502 when upstream is down", async () => { + // Get a port that nothing is listening on + const deadPort = await findFreePort(); + + const routeStore = new ServiceRouteStore(); + routeStore.addRoute("dead-service.localhost", deadPort); + + const proxy = await startProxy(routeStore); + const res = await httpGet( + proxy.port, + `dead-service.localhost:${proxy.port}`, + ); + + expect(res.status).toBe(502); + expect(res.body).toBe("502 Bad Gateway"); + }); +}); + +// --------------------------------------------------------------------------- +// WebSocket proxy +// --------------------------------------------------------------------------- + +describe("WebSocket proxy", () => { + const httpServers: http.Server[] = []; + const wsServers: WebSocketServer[] = []; + const wsClients: WebSocket[] = []; + + afterEach(async () => { + for (const ws of wsClients) { + if (ws.readyState === WebSocket.OPEN) ws.close(); + } + wsClients.length = 0; + + for (const wss of wsServers) { + wss.close(); + } + wsServers.length = 0; + + await Promise.all(httpServers.map(closeServer)); + httpServers.length = 0; + }); + + it("proxies WebSocket connections to the correct upstream", async () => { + // 1. Start a real WebSocket echo server + const upstreamPort = await findFreePort(); + const upstreamServer = http.createServer(); + const wss = new WebSocketServer({ server: upstreamServer }); + wsServers.push(wss); + + wss.on("connection", (ws) => { + ws.on("message", (data) => { + ws.send(`echo: ${data.toString()}`); + }); + }); + + await new Promise((resolve) => + upstreamServer.listen(upstreamPort, "127.0.0.1", resolve), + ); + httpServers.push(upstreamServer); + + // 2. Create the proxy server with the upgrade handler + const routeStore = new ServiceRouteStore(); + routeStore.addRoute("ws-service.localhost", upstreamPort); + + const proxyPort = await findFreePort(); + const proxyServer = http.createServer((_req, res) => { + res.writeHead(404); + res.end(); + }); + + const upgradeHandler = createServiceProxyUpgradeHandler({ + routeStore, + logger, + }); + proxyServer.on("upgrade", upgradeHandler); + + await new Promise((resolve) => + proxyServer.listen(proxyPort, "127.0.0.1", resolve), + ); + httpServers.push(proxyServer); + + // 3. Connect a WebSocket client through the proxy + const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}`, { + headers: { host: `ws-service.localhost:${proxyPort}` }, + }); + wsClients.push(ws); + + await new Promise((resolve, reject) => { + ws.on("open", resolve); + ws.on("error", reject); + }); + + // 4. Send a message and verify echo + const reply = await new Promise((resolve, reject) => { + ws.on("message", (data) => resolve(data.toString())); + ws.on("error", reject); + ws.send("hello proxy"); + }); + + expect(reply).toBe("echo: hello proxy"); + }); +}); + +// --------------------------------------------------------------------------- +// findFreePort +// --------------------------------------------------------------------------- + +describe("findFreePort", () => { + it("returns a number", async () => { + const port = await findFreePort(); + expect(typeof port).toBe("number"); + expect(port).toBeGreaterThan(0); + expect(port).toBeLessThan(65536); + }); + + it("returns a port that is actually available", async () => { + const port = await findFreePort(); + + // Verify we can bind a server to it + const server = net.createServer(); + await new Promise((resolve, reject) => { + server.listen(port, "127.0.0.1", () => resolve()); + server.on("error", reject); + }); + + const addr = server.address(); + expect(addr).not.toBeNull(); + expect(typeof addr === "object" && addr !== null ? addr.port : -1).toBe( + port, + ); + + await new Promise((resolve) => server.close(() => resolve())); + }); +}); diff --git a/packages/server/src/server/service-proxy.ts b/packages/server/src/server/service-proxy.ts new file mode 100644 index 000000000..fc7a23d0c --- /dev/null +++ b/packages/server/src/server/service-proxy.ts @@ -0,0 +1,321 @@ +import http from "node:http"; +import net from "node:net"; +import type { IncomingMessage } from "node:http"; +import type { Logger } from "pino"; +import type { RequestHandler } from "express"; + +// --------------------------------------------------------------------------- +// Hop-by-hop headers that must not be forwarded +// --------------------------------------------------------------------------- + +const HOP_BY_HOP_HEADERS = new Set([ + "connection", + "transfer-encoding", + "keep-alive", + "upgrade", + "proxy-connection", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", +]); + +// --------------------------------------------------------------------------- +// ServiceRouteStore +// --------------------------------------------------------------------------- + +export interface ServiceRoute { + hostname: string; + port: number; +} + +export interface ServiceRouteEntry extends ServiceRoute { + workspaceId: string; + serviceName: string; +} + +export class ServiceRouteStore { + private routes = new Map(); + private workspaceHostnames = new Map>(); + + addRoute(hostname: string, port: number): void { + this.registerRoute({ + hostname, + port, + workspaceId: "", + serviceName: hostname, + }); + } + + registerRoute(entry: ServiceRouteEntry): void { + const previous = this.routes.get(entry.hostname); + if (previous) { + this.removeHostnameFromWorkspaceIndex(previous.workspaceId, previous.hostname); + } + + const storedEntry = { ...entry }; + this.routes.set(storedEntry.hostname, storedEntry); + this.addHostnameToWorkspaceIndex(storedEntry.workspaceId, storedEntry.hostname); + } + + removeRoute(hostname: string): void { + const entry = this.routes.get(hostname); + if (!entry) { + return; + } + this.routes.delete(hostname); + this.removeHostnameFromWorkspaceIndex(entry.workspaceId, hostname); + } + + removeRoutesForPort(port: number): void { + for (const [hostname, entry] of this.routes) { + if (entry.port === port) { + this.routes.delete(hostname); + this.removeHostnameFromWorkspaceIndex(entry.workspaceId, hostname); + } + } + } + + findRoute(host: string): ServiceRoute | null { + // Strip port suffix from the Host header value + const hostname = host.replace(/:\d+$/, ""); + + // 1. Exact match + const exactRoute = this.routes.get(hostname); + if (exactRoute !== undefined) { + return { hostname: exactRoute.hostname, port: exactRoute.port }; + } + + // 2. Subdomain match — walk up the labels looking for a registered parent + const parts = hostname.split("."); + for (let i = 1; i < parts.length; i++) { + const candidate = parts.slice(i).join("."); + const candidateRoute = this.routes.get(candidate); + if (candidateRoute !== undefined) { + return { hostname: candidateRoute.hostname, port: candidateRoute.port }; + } + } + + return null; + } + + listRoutes(): ServiceRouteEntry[] { + return Array.from(this.routes.values()).map((entry) => ({ ...entry })); + } + + listRoutesForWorkspace(workspaceId: string): ServiceRouteEntry[] { + const hostnames = this.workspaceHostnames.get(workspaceId); + if (!hostnames) { + return []; + } + + const routes: ServiceRouteEntry[] = []; + for (const hostname of hostnames) { + const entry = this.routes.get(hostname); + if (entry) { + routes.push({ ...entry }); + } + } + return routes; + } + + private addHostnameToWorkspaceIndex(workspaceId: string, hostname: string): void { + const hostnames = this.workspaceHostnames.get(workspaceId) ?? new Set(); + hostnames.add(hostname); + this.workspaceHostnames.set(workspaceId, hostnames); + } + + private removeHostnameFromWorkspaceIndex(workspaceId: string, hostname: string): void { + const hostnames = this.workspaceHostnames.get(workspaceId); + if (!hostnames) { + return; + } + + hostnames.delete(hostname); + if (hostnames.size === 0) { + this.workspaceHostnames.delete(workspaceId); + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function stripHopByHopHeaders( + rawHeaders: http.IncomingHttpHeaders, +): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(rawHeaders)) { + if (value === undefined) continue; + if (HOP_BY_HOP_HEADERS.has(key.toLowerCase())) continue; + out[key] = value; + } + return out; +} + +// --------------------------------------------------------------------------- +// createServiceProxyMiddleware +// --------------------------------------------------------------------------- + +export function createServiceProxyMiddleware({ + routeStore, + logger, +}: { + routeStore: ServiceRouteStore; + logger: Logger; +}): RequestHandler { + return (req, res, next) => { + const hostHeader = req.headers.host; + if (!hostHeader) { + next(); + return; + } + + const route = routeStore.findRoute(hostHeader); + if (!route) { + next(); + return; + } + + const forwardedHeaders = stripHopByHopHeaders(req.headers); + forwardedHeaders["x-forwarded-for"] = + req.socket.remoteAddress ?? "127.0.0.1"; + forwardedHeaders["x-forwarded-host"] = hostHeader.replace(/:\d+$/, ""); + forwardedHeaders["x-forwarded-proto"] = req.protocol; + + const proxyReq = http.request( + { + hostname: "127.0.0.1", + port: route.port, + path: req.originalUrl, + method: req.method, + headers: forwardedHeaders, + }, + (proxyRes) => { + const responseHeaders = stripHopByHopHeaders(proxyRes.headers); + res.writeHead(proxyRes.statusCode ?? 502, responseHeaders); + proxyRes.pipe(res, { end: true }); + }, + ); + + proxyReq.on("error", (err) => { + logger.warn( + { err, hostname: route.hostname, port: route.port }, + "Service proxy: upstream unreachable", + ); + if (!res.headersSent) { + res.writeHead(502, { "content-type": "text/plain" }); + res.end("502 Bad Gateway"); + } + }); + + req.pipe(proxyReq, { end: true }); + }; +} + +// --------------------------------------------------------------------------- +// createServiceProxyUpgradeHandler +// --------------------------------------------------------------------------- + +export function createServiceProxyUpgradeHandler({ + routeStore, + logger, +}: { + routeStore: ServiceRouteStore; + logger: Logger; +}): (req: IncomingMessage, socket: net.Socket, head: Buffer) => void { + return (req, socket, head) => { + const hostHeader = req.headers.host; + if (!hostHeader) { + return; + } + + const route = routeStore.findRoute(hostHeader); + if (!route) { + return; + } + + const targetSocket = net.connect( + { host: "127.0.0.1", port: route.port }, + () => { + // Reconstruct the raw HTTP upgrade request to send to the target + const forwardedHeaders = stripHopByHopHeaders(req.headers); + forwardedHeaders["x-forwarded-for"] = + req.socket.remoteAddress ?? "127.0.0.1"; + forwardedHeaders["x-forwarded-host"] = hostHeader.replace(/:\d+$/, ""); + forwardedHeaders["x-forwarded-proto"] = "http"; + + // Re-include upgrade and connection headers — they are required for + // WebSocket handshake even though they are hop-by-hop. + forwardedHeaders["connection"] = "Upgrade"; + forwardedHeaders["upgrade"] = req.headers.upgrade ?? "websocket"; + + const headerLines: string[] = []; + headerLines.push( + `${req.method ?? "GET"} ${req.url ?? "/"} HTTP/${req.httpVersion}`, + ); + for (const [key, value] of Object.entries(forwardedHeaders)) { + if (Array.isArray(value)) { + for (const v of value) { + headerLines.push(`${key}: ${v}`); + } + } else { + headerLines.push(`${key}: ${value}`); + } + } + headerLines.push("\r\n"); + + targetSocket.write(headerLines.join("\r\n")); + + if (head.length > 0) { + targetSocket.write(head); + } + + // Pipe in both directions + targetSocket.pipe(socket); + socket.pipe(targetSocket); + }, + ); + + targetSocket.on("error", (err) => { + logger.warn( + { err, hostname: route.hostname, port: route.port }, + "Service proxy: WebSocket upstream unreachable", + ); + socket.end(); + }); + + socket.on("error", () => { + targetSocket.destroy(); + }); + }; +} + +// --------------------------------------------------------------------------- +// findFreePort +// --------------------------------------------------------------------------- + +export function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Failed to get assigned port")); + return; + } + const { port } = address; + server.close((err) => { + if (err) { + reject(err); + } else { + resolve(port); + } + }); + }); + server.on("error", reject); + }); +} diff --git a/packages/server/src/server/service-status-projection.test.ts b/packages/server/src/server/service-status-projection.test.ts new file mode 100644 index 000000000..2074d0e13 --- /dev/null +++ b/packages/server/src/server/service-status-projection.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, it, vi } from "vitest"; +import { ServiceRouteStore } from "./service-proxy.js"; +import { + buildWorkspaceServicePayloads, + createServiceStatusEmitter, +} from "./service-status-projection.js"; + +describe("service-status-projection", () => { + it("buildWorkspaceServicePayloads returns service payloads from workspace routes", () => { + const routeStore = new ServiceRouteStore(); + routeStore.registerRoute({ + hostname: "api.localhost", + port: 3001, + workspaceId: "workspace-a", + serviceName: "api", + }); + routeStore.registerRoute({ + hostname: "docs.localhost", + port: 3002, + workspaceId: "workspace-b", + serviceName: "docs", + }); + routeStore.registerRoute({ + hostname: "web.localhost", + port: 3003, + workspaceId: "workspace-a", + serviceName: "web", + }); + + expect(buildWorkspaceServicePayloads(routeStore, "workspace-a", 6767)).toEqual([ + { + serviceName: "api", + hostname: "api.localhost", + port: 3001, + url: "http://api.localhost:6767", + status: "stopped", + }, + { + serviceName: "web", + hostname: "web.localhost", + port: 3003, + url: "http://web.localhost:6767", + status: "stopped", + }, + ]); + }); + + it("computes URLs with and without a daemon port", () => { + const routeStore = new ServiceRouteStore(); + routeStore.registerRoute({ + hostname: "api.localhost", + port: 3001, + workspaceId: "workspace-a", + serviceName: "api", + }); + + expect(buildWorkspaceServicePayloads(routeStore, "workspace-a", 6767)).toEqual([ + { + serviceName: "api", + hostname: "api.localhost", + port: 3001, + url: "http://api.localhost:6767", + status: "stopped", + }, + ]); + + expect(buildWorkspaceServicePayloads(routeStore, "workspace-a", null)).toEqual([ + { + serviceName: "api", + hostname: "api.localhost", + port: 3001, + url: null, + status: "stopped", + }, + ]); + }); + + it("createServiceStatusEmitter emits updates to all active sessions", () => { + const routeStore = new ServiceRouteStore(); + routeStore.registerRoute({ + hostname: "api.localhost", + port: 3001, + workspaceId: "workspace-a", + serviceName: "api", + }); + + const sessionA = { emit: vi.fn() }; + const sessionB = { emit: vi.fn() }; + + const emitUpdate = createServiceStatusEmitter({ + sessions: () => [sessionA, sessionB], + routeStore, + daemonPort: 6767, + }); + + emitUpdate("workspace-a", [ + { + serviceName: "api", + hostname: "api.localhost", + port: 3001, + status: "running", + }, + ]); + + expect(sessionA.emit).toHaveBeenCalledWith({ + type: "service_status_update", + payload: { + workspaceId: "workspace-a", + services: [ + { + serviceName: "api", + hostname: "api.localhost", + port: 3001, + url: "http://api.localhost:6767", + status: "running", + }, + ], + }, + }); + expect(sessionB.emit).toHaveBeenCalledWith({ + type: "service_status_update", + payload: { + workspaceId: "workspace-a", + services: [ + { + serviceName: "api", + hostname: "api.localhost", + port: 3001, + url: "http://api.localhost:6767", + status: "running", + }, + ], + }, + }); + }); + + it("uses resolveStatus to set initial service status when provided", () => { + const routeStore = new ServiceRouteStore(); + routeStore.registerRoute({ + hostname: "api.localhost", + port: 3001, + workspaceId: "workspace-a", + serviceName: "api", + }); + routeStore.registerRoute({ + hostname: "web.localhost", + port: 3003, + workspaceId: "workspace-a", + serviceName: "web", + }); + + const statuses = new Map([ + ["api.localhost", "running"], + ]); + + expect( + buildWorkspaceServicePayloads(routeStore, "workspace-a", 6767, (hostname) => + statuses.get(hostname) ?? null, + ), + ).toEqual([ + { + serviceName: "api", + hostname: "api.localhost", + port: 3001, + url: "http://api.localhost:6767", + status: "running", + }, + { + serviceName: "web", + hostname: "web.localhost", + port: 3003, + url: "http://web.localhost:6767", + status: "stopped", + }, + ]); + }); + + it("emits workspace-specific batches", () => { + const routeStore = new ServiceRouteStore(); + routeStore.registerRoute({ + hostname: "api.localhost", + port: 3001, + workspaceId: "workspace-a", + serviceName: "api", + }); + routeStore.registerRoute({ + hostname: "web.localhost", + port: 3002, + workspaceId: "workspace-a", + serviceName: "web", + }); + routeStore.registerRoute({ + hostname: "docs.localhost", + port: 3003, + workspaceId: "workspace-b", + serviceName: "docs", + }); + + const session = { emit: vi.fn() }; + const emitUpdate = createServiceStatusEmitter({ + sessions: () => [session], + routeStore, + daemonPort: null, + }); + + emitUpdate("workspace-a", [ + { + serviceName: "api", + hostname: "api.localhost", + port: 3001, + status: "running", + }, + { + serviceName: "web", + hostname: "web.localhost", + port: 3002, + status: "stopped", + }, + ]); + + emitUpdate("workspace-b", [ + { + serviceName: "docs", + hostname: "docs.localhost", + port: 3003, + status: "running", + }, + ]); + + expect(session.emit).toHaveBeenNthCalledWith(1, { + type: "service_status_update", + payload: { + workspaceId: "workspace-a", + services: [ + { + serviceName: "api", + hostname: "api.localhost", + port: 3001, + url: null, + status: "running", + }, + { + serviceName: "web", + hostname: "web.localhost", + port: 3002, + url: null, + status: "stopped", + }, + ], + }, + }); + expect(session.emit).toHaveBeenNthCalledWith(2, { + type: "service_status_update", + payload: { + workspaceId: "workspace-b", + services: [ + { + serviceName: "docs", + hostname: "docs.localhost", + port: 3003, + url: null, + status: "running", + }, + ], + }, + }); + }); + +}); diff --git a/packages/server/src/server/service-status-projection.ts b/packages/server/src/server/service-status-projection.ts new file mode 100644 index 000000000..892ffa5ff --- /dev/null +++ b/packages/server/src/server/service-status-projection.ts @@ -0,0 +1,86 @@ +import type { + ServiceStatusUpdateMessage, + SessionOutboundMessage, + WorkspaceServicePayload, +} from "../shared/messages.js"; +import type { ServiceStatusEntry } from "./service-health-monitor.js"; +import type { ServiceRouteStore } from "./service-proxy.js"; + +type SessionEmitter = { + emit(message: SessionOutboundMessage): void; +}; + +function resolveDaemonPort(daemonPort: number | null | (() => number | null)): number | null { + if (typeof daemonPort === "function") { + return daemonPort(); + } + return daemonPort; +} + +function toServiceUrl(hostname: string, daemonPort: number | null): string | null { + if (daemonPort === null) { + return null; + } + return `http://${hostname}:${daemonPort}`; +} + +export function buildWorkspaceServicePayloads( + routeStore: ServiceRouteStore, + workspaceId: string, + daemonPort: number | null, + resolveStatus?: (hostname: string) => "running" | "stopped" | null, +): WorkspaceServicePayload[] { + return routeStore.listRoutesForWorkspace(workspaceId).map((route) => ({ + serviceName: route.serviceName, + hostname: route.hostname, + port: route.port, + url: toServiceUrl(route.hostname, daemonPort), + status: resolveStatus?.(route.hostname) ?? "stopped", + })); +} + +function buildServiceStatusUpdateMessage(params: { + workspaceId: string; + services: WorkspaceServicePayload[]; +}): ServiceStatusUpdateMessage { + return { + type: "service_status_update", + payload: { + workspaceId: params.workspaceId, + services: params.services, + }, + }; +} + +export function createServiceStatusEmitter({ + sessions, + routeStore, + daemonPort, +}: { + sessions: () => SessionEmitter[]; + routeStore: ServiceRouteStore; + daemonPort: number | null | (() => number | null); +}): (workspaceId: string, services: ServiceStatusEntry[]) => void { + return (workspaceId, services) => { + const resolvedDaemonPort = resolveDaemonPort(daemonPort); + const serviceStatusByHostname = new Map( + services.map((service) => [service.hostname, service.status] as const), + ); + + const projected = buildWorkspaceServicePayloads(routeStore, workspaceId, resolvedDaemonPort).map( + (service) => ({ + ...service, + status: serviceStatusByHostname.get(service.hostname) ?? service.status, + }), + ); + + const message = buildServiceStatusUpdateMessage({ + workspaceId, + services: projected, + }); + + for (const session of sessions()) { + session.emit(message); + } + }; +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 021864498..3d628732e 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -29,6 +29,7 @@ import { type UnsubscribeCheckoutDiffRequest, type DirectorySuggestionsRequest, type ProjectPlacementPayload, + type WorkspaceSetupSnapshot, type WorkspaceDescriptorPayload, type WorkspaceStateBucket, } from "./messages.js"; @@ -63,6 +64,7 @@ import { import { experimental_createMCPClient } from "ai"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js"; +import { buildWorkspaceServicePayloads } from "./service-status-projection.js"; export type AgentMcpTransportFactory = () => Promise; import { buildProviderRegistry } from "./agent/provider-registry.js"; @@ -125,6 +127,7 @@ import { type WorktreeConfig, } from "../utils/worktree.js"; import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js"; +import type { ServiceRouteStore } from "./service-proxy.js"; import { getCheckoutDiff, getCheckoutShortstat, @@ -166,6 +169,7 @@ import { handleCreatePaseoWorktreeRequest as handleCreateWorktreeRequest, handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest, handlePaseoWorktreeListRequest as handleWorktreeListRequest, + handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage, killTerminalsUnderPath as killWorktreeTerminalsUnderPath, } from "./worktree-session.js"; @@ -371,6 +375,9 @@ export type SessionOptions = { stt: Resolvable; tts: Resolvable; terminalManager: TerminalManager | null; + serviceRouteStore?: ServiceRouteStore; + getDaemonTcpPort?: () => number | null; + resolveServiceStatus?: (hostname: string) => "running" | "stopped" | null; voice?: { voiceAgentMcpStdio?: VoiceMcpStdioConfig | null; turnDetection?: Resolvable; @@ -543,6 +550,9 @@ export class Session { } | null = null; private readonly MOBILE_BACKGROUND_STREAM_GRACE_MS = 60_000; private readonly terminalManager: TerminalManager | null; + private readonly serviceRouteStore: ServiceRouteStore | null; + private readonly getDaemonTcpPort: (() => number | null) | null; + private readonly resolveServiceStatus: ((hostname: string) => "running" | "stopped" | null) | null; private readonly subscribedTerminalDirectories = new Set(); private unsubscribeTerminalsChanged: (() => void) | null = null; private terminalExitSubscriptions: Map void> = new Map(); @@ -553,6 +563,7 @@ export class Session { private peakInflightRequests = 0; private readonly checkoutDiffSubscriptions = new Map void>(); private readonly workspaceGitWatchTargets = new Map(); + private readonly workspaceSetupSnapshots = new Map(); private readonly voiceAgentMcpStdio: VoiceMcpStdioConfig | null; private readonly registerVoiceSpeakHandler?: ( agentId: string, @@ -595,6 +606,9 @@ export class Session { stt, tts, terminalManager, + serviceRouteStore, + getDaemonTcpPort, + resolveServiceStatus, voice, voiceBridge, dictation, @@ -631,6 +645,9 @@ export class Session { }); this.createAgentMcpTransport = createAgentMcpTransport; this.terminalManager = terminalManager; + this.serviceRouteStore = serviceRouteStore ?? null; + this.getDaemonTcpPort = getDaemonTcpPort ?? null; + this.resolveServiceStatus = resolveServiceStatus ?? null; if (this.terminalManager) { this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) => this.handleTerminalsChanged(event), @@ -691,6 +708,10 @@ export class Session { }; } + public emitServerMessage(message: SessionOutboundMessage): void { + this.emit(message); + } + /** * Send initial state to client after connection */ @@ -1542,6 +1563,10 @@ export class Session { await this.handleCreatePaseoWorktreeRequest(msg); break; + case "workspace_setup_status_request": + await this.handleWorkspaceSetupStatusRequest(msg); + break; + case "open_project_request": await this.handleOpenProjectRequest(msg); break; @@ -2566,7 +2591,7 @@ export class Session { ...(provisionalTitle ? { title: provisionalTitle } : {}), }; - const { sessionConfig, worktreeConfig } = await this.buildAgentSessionConfig( + const { sessionConfig, worktreeBootstrap } = await this.buildAgentSessionConfig( resolvedConfig, git, worktreeName, @@ -2649,10 +2674,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({ @@ -2666,6 +2692,8 @@ export class Session { agentId: snapshot.id, item, }), + serviceRouteStore: this.serviceRouteStore ?? undefined, + daemonPort: this.getDaemonTcpPort?.() ?? null, logger: this.sessionLogger, }); } @@ -2812,7 +2840,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, @@ -4823,6 +4854,14 @@ export class Session { status: "done", activityAt: null, diffStat, + services: this.serviceRouteStore + ? buildWorkspaceServicePayloads( + this.serviceRouteStore, + workspace.workspaceId, + this.getDaemonTcpPort?.() ?? null, + this.resolveServiceStatus ?? undefined, + ) + : [], }; } @@ -5535,22 +5574,40 @@ 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( { paseoHome: this.paseoHome, emitWorkspaceUpdateForCwd: (cwd, emitOptions) => this.emitWorkspaceUpdateForCwd(cwd, emitOptions), + cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => { + this.workspaceSetupSnapshots.set(workspaceId, snapshot); + }, + emit: (message) => this.emit(message), sessionLogger: this.sessionLogger, terminalManager: this.terminalManager, + archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), + serviceRouteStore: this.serviceRouteStore, + daemonPort: this.getDaemonTcpPort?.() ?? null, }, options, ); } + private async handleWorkspaceSetupStatusRequest( + request: Extract, + ): Promise { + return handleWorkspaceSetupStatusRequestMessage( + { + emit: (message) => this.emit(message), + workspaceSetupSnapshots: this.workspaceSetupSnapshots, + }, + request, + ); + } + private async handleArchiveWorkspaceRequest( request: Extract, ): Promise { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index eb65bfea8..327cff466 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -33,6 +33,7 @@ import type { AgentProvider } from "./agent/agent-sdk-types.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; import { PushTokenStore } from "./push/token-store.js"; import { PushService } from "./push/push-service.js"; +import type { ServiceRouteStore } from "./service-proxy.js"; import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js"; import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js"; import { @@ -242,6 +243,9 @@ export class VoiceAssistantWebSocketServer { private readonly createAgentMcpTransport: AgentMcpTransportFactory; private readonly speech: SpeechService | null; private readonly terminalManager: TerminalManager | null; + private readonly serviceRouteStore: ServiceRouteStore | null; + private readonly getDaemonTcpPort: (() => number | null) | null; + private readonly resolveServiceStatus: ((hostname: string) => "running" | "stopped" | null) | null; private readonly dictation: { finalTimeoutMs?: number; } | null; @@ -308,6 +312,9 @@ export class VoiceAssistantWebSocketServer { loopService?: LoopService, scheduleService?: ScheduleService, checkoutDiffManager?: CheckoutDiffManager, + serviceRouteStore?: ServiceRouteStore | null, + getDaemonTcpPort?: () => number | null, + resolveServiceStatus?: (hostname: string) => "running" | "stopped" | null, ) { this.logger = logger.child({ module: "websocket-server" }); this.serverId = serverId; @@ -344,6 +351,9 @@ export class VoiceAssistantWebSocketServer { this.dictation = dictation ?? null; this.agentProviderRuntimeSettings = agentProviderRuntimeSettings; this.onLifecycleIntent = onLifecycleIntent ?? null; + this.serviceRouteStore = serviceRouteStore ?? null; + this.getDaemonTcpPort = getDaemonTcpPort ?? null; + this.resolveServiceStatus = resolveServiceStatus ?? null; this.serverCapabilities = buildServerCapabilities({ readiness: this.speech?.getReadiness() ?? null, }); @@ -416,6 +426,16 @@ export class VoiceAssistantWebSocketServer { } } + public listActiveSessions(): Session[] { + return Array.from( + new Set( + [...this.sessions.values(), ...this.externalSessionsByKey.values()].map( + (connection) => connection.session, + ), + ), + ); + } + public publishSpeechReadiness(readiness: SpeechReadinessSnapshot | null): void { this.updateServerCapabilities(buildServerCapabilities({ readiness })); } @@ -646,6 +666,9 @@ export class VoiceAssistantWebSocketServer { stt: () => this.speech?.resolveStt() ?? null, tts: () => this.speech?.resolveTts() ?? null, terminalManager: this.terminalManager, + serviceRouteStore: this.serviceRouteStore ?? undefined, + getDaemonTcpPort: this.getDaemonTcpPort ?? undefined, + resolveServiceStatus: this.resolveServiceStatus ?? undefined, voice: { ...(this.voice ?? {}), turnDetection: () => this.speech?.resolveTurnDetection() ?? null, diff --git a/packages/server/src/server/worktree-bootstrap.test.ts b/packages/server/src/server/worktree-bootstrap.test.ts index e52fef018..e6b20a5f9 100644 --- a/packages/server/src/server/worktree-bootstrap.test.ts +++ b/packages/server/src/server/worktree-bootstrap.test.ts @@ -5,7 +5,12 @@ import { join } from "path"; import { tmpdir } from "os"; import type { AgentTimelineItem } from "./agent/agent-sdk-types.js"; -import { createAgentWorktree, runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js"; +import { + createAgentWorktree, + runAsyncWorktreeBootstrap, + spawnWorktreeServices, +} from "./worktree-bootstrap.js"; +import { ServiceRouteStore } from "./service-proxy.js"; describe("runAsyncWorktreeBootstrap", () => { let tempDir: string; @@ -56,7 +61,7 @@ describe("runAsyncWorktreeBootstrap", () => { stdio: "pipe", }); - const worktree = await createAgentWorktree({ + const worktreeBootstrap = await createAgentWorktree({ cwd: repoDir, branchName: "feature-streaming-setup", baseBranch: "main", @@ -69,7 +74,8 @@ describe("runAsyncWorktreeBootstrap", () => { await runAsyncWorktreeBootstrap({ agentId: "agent-test", - worktree, + worktree: worktreeBootstrap.worktree, + shouldBootstrap: worktreeBootstrap.shouldBootstrap, terminalManager: null, appendTimelineItem: async (item) => { persisted.push(item); @@ -113,12 +119,15 @@ describe("runAsyncWorktreeBootstrap", () => { expect(persistedSetupItems[0].detail.commands[0]).toMatchObject({ index: 1, command: 'echo "line-one"; echo "line-two" 1>&2', + log: expect.stringContaining("line-one"), status: "completed", exitCode: 0, }); + expect(persistedSetupItems[0].detail.commands[0]?.log).toContain("line-two"); expect(persistedSetupItems[0].detail.commands[1]).toMatchObject({ index: 2, command: 'echo "line-three"', + log: "line-three\n", status: "completed", exitCode: 0, }); @@ -160,7 +169,7 @@ describe("runAsyncWorktreeBootstrap", () => { stdio: "pipe", }); - const worktree = await createAgentWorktree({ + const worktreeBootstrap = await createAgentWorktree({ cwd: repoDir, branchName: "feature-live-failure", baseBranch: "main", @@ -172,7 +181,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 +220,7 @@ describe("runAsyncWorktreeBootstrap", () => { stdio: "pipe", }); - const worktree = await createAgentWorktree({ + const worktreeBootstrap = await createAgentWorktree({ cwd: repoDir, branchName: "feature-large-output", baseBranch: "main", @@ -221,7 +231,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); @@ -244,6 +255,64 @@ describe("runAsyncWorktreeBootstrap", () => { expect(persistedSetupItem.detail.log).toContain("prefix-"); expect(persistedSetupItem.detail.log).toContain("-suffix"); expect(persistedSetupItem.detail.log).toContain("......"); + expect(persistedSetupItem.detail.commands[0]?.log).toContain("prefix-"); + expect(persistedSetupItem.detail.commands[0]?.log).toContain("-suffix"); + expect(persistedSetupItem.detail.commands[0]?.log).toContain( + "......", + ); + }); + + it("keeps only the final carriage-return-updated content in command logs", async () => { + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + worktree: { + setup: [ + `node -e "process.stdout.write('fetch 1/3\\\\rfetch 2/3\\\\rfetch 3/3\\\\nready\\\\n')"`, + ], + }, + }), + ); + execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'add carriage return setup'", { + cwd: repoDir, + stdio: "pipe", + }); + + const worktreeBootstrap = await createAgentWorktree({ + cwd: repoDir, + branchName: "feature-carriage-return", + baseBranch: "main", + worktreeSlug: "feature-carriage-return", + paseoHome, + }); + + const persisted: AgentTimelineItem[] = []; + await runAsyncWorktreeBootstrap({ + agentId: "agent-carriage-return", + worktree: worktreeBootstrap.worktree, + shouldBootstrap: worktreeBootstrap.shouldBootstrap, + terminalManager: null, + appendTimelineItem: async (item) => { + persisted.push(item); + return true; + }, + emitLiveTimelineItem: async () => true, + }); + + const persistedSetupItem = persisted.find( + (item): item is Extract => + item.type === "tool_call" && item.name === "paseo_worktree_setup", + ); + expect(persistedSetupItem?.detail.type).toBe("worktree_setup"); + if (!persistedSetupItem || persistedSetupItem.detail.type !== "worktree_setup") { + throw new Error("Expected worktree_setup tool detail"); + } + + expect(persistedSetupItem.detail.log).toContain("\nfetch 3/3\nready\n"); + expect(persistedSetupItem.detail.log).not.toContain("\nfetch 1/3\n"); + expect(persistedSetupItem.detail.log).not.toContain("\nfetch 2/3\n"); + expect(persistedSetupItem.detail.commands[0]?.log).toBe("fetch 3/3\nready\n"); }); it("waits for terminal output before sending bootstrap commands", async () => { @@ -266,7 +335,7 @@ describe("runAsyncWorktreeBootstrap", () => { stdio: "pipe", }); - const worktree = await createAgentWorktree({ + const worktreeBootstrap = await createAgentWorktree({ cwd: repoDir, branchName: "feature-terminal-readiness", baseBranch: "main", @@ -280,7 +349,8 @@ describe("runAsyncWorktreeBootstrap", () => { await runAsyncWorktreeBootstrap({ agentId: "agent-terminal-readiness", - worktree, + worktree: worktreeBootstrap.worktree, + shouldBootstrap: worktreeBootstrap.shouldBootstrap, terminalManager: { async getTerminals() { return []; @@ -357,7 +427,7 @@ describe("runAsyncWorktreeBootstrap", () => { stdio: "pipe", }); - const worktree = await createAgentWorktree({ + const worktreeBootstrap = await createAgentWorktree({ cwd: repoDir, branchName: "feature-shared-runtime-port", baseBranch: "main", @@ -370,7 +440,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 +487,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); @@ -435,4 +506,85 @@ describe("runAsyncWorktreeBootstrap", () => { ); expect(terminalToolCall?.status).toBe("completed"); }); + + it("spawns services without PASEO_SERVICE_URL when the daemon has no TCP port", async () => { + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + services: { + web: { + command: "npm run dev", + }, + }, + }), + ); + execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'add service config'", { + cwd: repoDir, + stdio: "pipe", + }); + + const routeStore = new ServiceRouteStore(); + const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record }> = []; + + const results = await spawnWorktreeServices({ + repoRoot: repoDir, + workspaceId: repoDir, + branchName: "feature-socket-service", + daemonPort: null, + routeStore, + terminalManager: { + async getTerminals() { + return []; + }, + async createTerminal(options) { + createTerminalCalls.push(options); + return { + id: "term-service", + name: options.name ?? "Terminal", + cwd: options.cwd, + send: () => {}, + subscribe: () => () => {}, + onExit: () => () => {}, + getState: () => ({ + rows: 1, + cols: 1, + grid: [[{ char: "$" }]], + scrollback: [], + cursor: { row: 0, col: 0 }, + }), + kill: () => {}, + }; + }, + registerCwdEnv() {}, + getTerminal() { + return undefined; + }, + killTerminal() {}, + listDirectories() { + return []; + }, + killAll() {}, + subscribeTerminalsChanged() { + return () => {}; + }, + }, + }); + + expect(results).toHaveLength(1); + expect(routeStore.listRoutes()).toEqual([ + { + hostname: "feature-socket-service.web.localhost", + port: expect.any(Number), + workspaceId: repoDir, + serviceName: "web", + }, + ]); + expect(createTerminalCalls).toHaveLength(1); + expect(createTerminalCalls[0]?.cwd).toBe(repoDir); + expect(createTerminalCalls[0]?.name).toBe("web"); + expect(createTerminalCalls[0]?.env?.PORT).toEqual(expect.any(String)); + expect(createTerminalCalls[0]?.env?.HOST).toBe("127.0.0.1"); + expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_URL).toBeUndefined(); + }); }); diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index afeec0c29..aeebbff03 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -7,16 +7,20 @@ import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalSession } from "../terminal/terminal.js"; import { createWorktree, + getServiceConfigs, getWorktreeTerminalSpecs, listPaseoWorktrees, + processCarriageReturns, resolveWorktreeRuntimeEnv, runWorktreeSetupCommands, + slugify, WorktreeSetupError, type WorktreeConfig, type WorktreeSetupCommandResult, type WorktreeRuntimeEnv, } from "../utils/worktree.js"; -import type { AgentTimelineItem } from "./agent/agent-sdk-types.js"; +import { findFreePort, type ServiceRouteStore } from "./service-proxy.js"; +import type { AgentTimelineItem, ToolCallDetail } from "./agent/agent-sdk-types.js"; export interface WorktreeBootstrapTerminalResult { name: string | null; @@ -29,7 +33,10 @@ export interface WorktreeBootstrapTerminalResult { export interface RunAsyncWorktreeBootstrapOptions { agentId: string; worktree: WorktreeConfig; + shouldBootstrap?: boolean; terminalManager: TerminalManager | null; + serviceRouteStore?: ServiceRouteStore; + daemonPort?: number | null; appendTimelineItem: (item: AgentTimelineItem) => Promise; emitLiveTimelineItem?: (item: AgentTimelineItem) => Promise; logger?: Logger; @@ -43,6 +50,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; @@ -51,8 +63,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; @@ -60,6 +70,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"); } @@ -86,7 +102,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: "", @@ -103,8 +119,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) { @@ -161,16 +177,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({ @@ -181,8 +198,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) { @@ -221,7 +240,7 @@ function commandStatusFromResult( function buildWorktreeSetupLog(input: { results: WorktreeSetupCommandResult[]; - outputAccumulatorsByIndex?: Map; + outputAccumulatorsByIndex?: Map; }): { log: string; truncated: boolean } { const { results, outputAccumulatorsByIndex } = input; if (results.length === 0) { @@ -236,15 +255,13 @@ function buildWorktreeSetupLog(input: { const total = results.length; for (const [index, result] of results.entries()) { lines.push(`==> [${index + 1}/${total}] Running: ${result.command}`); - const accumulator = outputAccumulatorsByIndex?.get(index + 1); - const output = accumulator - ? renderMiddleTruncationAccumulator(accumulator) - : truncateTextInMiddle( - `${result.stdout ?? ""}${result.stderr ?? ""}`, - MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES, - ); - if (output.text.length > 0) { - lines.push(output.text.replace(/\n$/, "")); + const output = buildWorktreeSetupCommandLog({ + index: index + 1, + result, + outputAccumulatorsByIndex, + }); + if (output.log.length > 0) { + lines.push(output.log.replace(/\n$/, "")); } if (output.truncated) { anyTruncated = true; @@ -261,34 +278,136 @@ function buildWorktreeSetupLog(input: { }; } +function buildWorktreeSetupCommandLog(input: { + index: number; + result: WorktreeSetupCommandResult; + outputAccumulatorsByIndex?: Map; +}): { log: string; truncated: boolean } { + const { index, result, outputAccumulatorsByIndex } = input; + const accumulator = outputAccumulatorsByIndex?.get(index); + const rendered = accumulator + ? renderMiddleTruncationAccumulator(accumulator) + : truncateTextInMiddle( + `${result.stdout ?? ""}${result.stderr ?? ""}`, + MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES, + ); + + return { + log: processCarriageReturns(rendered.text), + truncated: rendered.truncated, + }; +} + +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; +}): Extract { + let anyCommandTruncated = false; + const commands = input.results.map((result, index) => { + const renderedLog = buildWorktreeSetupCommandLog({ + index: index + 1, + result, + outputAccumulatorsByIndex: input.outputAccumulatorsByIndex, + }); + if (renderedLog.truncated) { + anyCommandTruncated = true; + } + return { + index: index + 1, + command: result.command, + cwd: result.cwd, + log: renderedLog.log, + status: commandStatusFromResult(result), + exitCode: result.exitCode, + ...(result.durationMs > 0 ? { durationMs: result.durationMs } : {}), + }; + }); + const renderedLog = buildWorktreeSetupLog({ + results: input.results, + outputAccumulatorsByIndex: input.outputAccumulatorsByIndex, + }); + + return { + type: "worktree_setup", + worktreePath: input.worktree.worktreePath, + branchName: input.worktree.branchName, + log: renderedLog.log, + commands, + ...(renderedLog.truncated || anyCommandTruncated ? { truncated: true } : {}), + }; +} + function buildSetupTimelineItem(input: { callId: string; status: "running" | "completed" | "failed"; worktree: WorktreeConfig; results: WorktreeSetupCommandResult[]; - outputAccumulatorsByIndex?: Map; + outputAccumulatorsByIndex?: Map; errorMessage: string | null; }): AgentTimelineItem { - const commands = input.results.map((result, index) => ({ - index: index + 1, - command: result.command, - cwd: result.cwd, - status: commandStatusFromResult(result), - exitCode: result.exitCode, - ...(result.durationMs > 0 ? { durationMs: result.durationMs } : {}), - })); - const renderedLog = buildWorktreeSetupLog({ + const detail = buildWorktreeSetupDetail({ + worktree: input.worktree, results: input.results, outputAccumulatorsByIndex: input.outputAccumulatorsByIndex, }); - const detail = { - type: "worktree_setup" as const, - worktreePath: input.worktree.worktreePath, - branchName: input.worktree.branchName, - log: renderedLog.log, - commands, - ...(renderedLog.truncated ? { truncated: true } : {}), - }; if (input.status === "running") { return { @@ -523,7 +642,7 @@ async function runWorktreeTerminalBootstrap( export async function runAsyncWorktreeBootstrap( options: RunAsyncWorktreeBootstrapOptions, ): Promise { - if (worktreeSetupEligibility.get(options.worktree) === false) { + if (options.shouldBootstrap === false) { return; } @@ -531,17 +650,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( @@ -550,7 +666,7 @@ export async function runAsyncWorktreeBootstrap( status: "running", worktree: options.worktree, results: runningResults, - outputAccumulatorsByIndex, + outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex, errorMessage: null, }), ); @@ -579,42 +695,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(); }, }); @@ -626,7 +707,7 @@ export async function runAsyncWorktreeBootstrap( status: "completed", worktree: options.worktree, results: setupResults, - outputAccumulatorsByIndex, + outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex, errorMessage: null, }), ); @@ -645,7 +726,7 @@ export async function runAsyncWorktreeBootstrap( status: "failed", worktree: options.worktree, results: setupResults, - outputAccumulatorsByIndex, + outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex, errorMessage: message, }), ); @@ -653,4 +734,138 @@ export async function runAsyncWorktreeBootstrap( } await runWorktreeTerminalBootstrap(options, runtimeEnv); + + if (!options.terminalManager || !options.serviceRouteStore) { + return; + } + + try { + await spawnWorktreeServices({ + repoRoot: options.worktree.worktreePath, + workspaceId: options.worktree.worktreePath, + branchName: options.worktree.branchName, + daemonPort: options.daemonPort, + routeStore: options.serviceRouteStore, + terminalManager: options.terminalManager, + logger: options.logger, + }); + } catch (error) { + options.logger?.warn( + { err: error, agentId: options.agentId, worktreePath: options.worktree.worktreePath }, + "Failed to spawn worktree services", + ); + } +} + +// --------------------------------------------------------------------------- +// Service lifecycle helpers +// --------------------------------------------------------------------------- + +export interface WorktreeServiceResult { + serviceName: string; + hostname: string; + port: number; + terminalId: string; +} + +export async function spawnWorktreeServices(options: { + repoRoot: string; + workspaceId: string; + branchName: string | null; + daemonPort?: number | null; + routeStore: ServiceRouteStore; + terminalManager: TerminalManager; + logger?: Logger; +}): Promise { + const { repoRoot, workspaceId, branchName, daemonPort, routeStore, terminalManager, logger } = + options; + const serviceConfigs = getServiceConfigs(repoRoot); + if (serviceConfigs.size === 0) { + return []; + } + + const results: WorktreeServiceResult[] = []; + + for (const [serviceName, config] of serviceConfigs) { + let hostname: string | null = null; + let port: number | null = null; + + try { + port = config.port ?? (await findFreePort()); + const branchHostnameLabel = branchName ? slugify(branchName) : null; + + const isDefaultBranch = + branchName === null || branchName === "main" || branchName === "master"; + hostname = isDefaultBranch + ? `${serviceName}.localhost` + : `${branchHostnameLabel}.${serviceName}.localhost`; + + routeStore.registerRoute({ + hostname, + port, + workspaceId, + serviceName, + }); + + const env: Record = { + PORT: String(port), + HOST: "127.0.0.1", + }; + if (daemonPort !== null && daemonPort !== undefined) { + env.PASEO_SERVICE_URL = `http://${hostname}:${daemonPort}`; + } + + const terminal = await terminalManager.createTerminal({ + cwd: repoRoot, + name: serviceName, + env, + }); + + await waitForTerminalBootstrapReadiness(terminal); + terminal.send({ type: "input", data: `${config.command}\r` }); + + logger?.info( + { serviceName, hostname, port, terminalId: terminal.id }, + `Registered service proxy: ${hostname} -> 127.0.0.1:${port}`, + ); + + results.push({ + serviceName, + hostname, + port, + terminalId: terminal.id, + }); + } catch (error) { + if (hostname && port !== null) { + routeStore.removeRoute(hostname); + } + logger?.error( + { + err: error, + serviceName, + repoRoot, + branchName, + hostname, + port, + command: config.command, + }, + "Failed to spawn worktree service", + ); + throw error; + } + } + + return results; +} + +export function teardownWorktreeServices(options: { + hostnames: string[]; + routeStore: ServiceRouteStore; + logger: Logger; +}): void { + const { hostnames, routeStore, logger } = options; + for (const hostname of hostnames) { + routeStore.removeRoute(hostname); + logger.info({ hostname }, "Removed service proxy route"); + } } 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..54a7d695c --- /dev/null +++ b/packages/server/src/server/worktree-session.test.ts @@ -0,0 +1,795 @@ +import { execSync } from "node:child_process"; +import { existsSync, 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, + handleCreatePaseoWorktreeRequest, + handleWorkspaceSetupStatusRequest, +} from "./worktree-session.js"; +import { 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 running then completed snapshots 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 createdWorktree = await createWorktree({ + branchName: "feature-no-setup", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "feature-no-setup", + runSetup: false, + paseoHome, + }); + const worktreePath = createdWorktree.worktreePath; + const emitted: SessionOutboundMessage[] = []; + const snapshots = new Map(); + const routeStore = new ServiceRouteStore(); + const logger = createLogger(); + const terminalManager = createTerminalManagerStub(); + const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); + const archiveWorkspaceRecord = vi.fn(async () => {}); + + await createPaseoWorktreeInBackground( + { + paseoHome, + emitWorkspaceUpdateForCwd, + cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot), + emit: (message) => emitted.push(message), + sessionLogger: logger, + terminalManager: terminalManager.manager, + archiveWorkspaceRecord, + serviceRouteStore: routeStore, + daemonPort: 6767, + }, + { + requestCwd: repoDir, + repoRoot: repoDir, + worktree: { + branchName: "feature-no-setup", + worktreePath, + }, + shouldBootstrap: true, + }, + ); + + const progressMessages = emitted.filter( + (message): message is Extract => + message.type === "workspace_setup_progress", + ); + 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, + detail: { + type: "worktree_setup", + worktreePath, + branchName: "feature-no-setup", + log: "", + commands: [], + }, + }); + expect(snapshots.get(worktreePath)).toMatchObject({ + 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), + workspaceId: worktreePath, + serviceName: "web", + }, + ]); + expect(terminalManager.terminals).toHaveLength(1); + expect(terminalManager.terminals[0]?.cwd).toBe(worktreePath); + expect(terminalManager.terminals[0]?.env?.PORT).toEqual(expect.any(String)); + expect(terminalManager.terminals[0]?.env?.PASEO_SERVICE_URL).toBeDefined(); + 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); + + writeFileSync(path.join(repoDir, "paseo.json"), "{ invalid json\n"); + execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'broken config'", { + cwd: repoDir, + stdio: "pipe", + }); + + const paseoHome = path.join(tempDir, ".paseo"); + const createdWorktree = await createWorktree({ + branchName: "broken-feature", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "broken-feature", + runSetup: false, + paseoHome, + }); + const worktreePath = createdWorktree.worktreePath; + const emitted: SessionOutboundMessage[] = []; + const snapshots = new Map(); + const logger = createLogger(); + const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); + const archiveWorkspaceRecord = vi.fn(async () => {}); + + await createPaseoWorktreeInBackground( + { + paseoHome, + emitWorkspaceUpdateForCwd, + cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot), + emit: (message) => emitted.push(message), + sessionLogger: logger, + terminalManager: null, + archiveWorkspaceRecord, + serviceRouteStore: null, + daemonPort: null, + }, + { + requestCwd: repoDir, + repoRoot: repoDir, + worktree: { + branchName: "broken-feature", + worktreePath, + }, + shouldBootstrap: true, + }, + ); + + 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("failed"); + expect(progressMessages[1]?.payload.error).toContain("Failed to parse paseo.json"); + expect(progressMessages[1]?.payload.detail.commands).toEqual([]); + expect(snapshots.get(worktreePath)).toMatchObject({ + status: "failed", + error: expect.stringContaining("Failed to parse paseo.json"), + }); + 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 createdWorktree = await createWorktree({ + branchName: "feature-running-setup", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "feature-running-setup", + runSetup: false, + paseoHome, + }); + const worktreePath = createdWorktree.worktreePath; + const emitted: SessionOutboundMessage[] = []; + const snapshots = new Map(); + const logger = createLogger(); + const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); + const archiveWorkspaceRecord = vi.fn(async () => {}); + + await createPaseoWorktreeInBackground( + { + paseoHome, + emitWorkspaceUpdateForCwd, + cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot), + emit: (message) => emitted.push(message), + sessionLogger: logger, + terminalManager: null, + archiveWorkspaceRecord, + serviceRouteStore: null, + daemonPort: null, + }, + { + requestCwd: repoDir, + repoRoot: repoDir, + worktree: { + branchName: "feature-running-setup", + worktreePath, + }, + shouldBootstrap: true, + }, + ); + + const progressMessages = emitted.filter( + (message): message is Extract => + 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"); + expect(runningMessages.length).toBeGreaterThan(0); + expect(progressMessages.findIndex((message) => message.payload.status === "running")).toBeLessThan( + progressMessages.findIndex((message) => message.payload.status === "completed"), + ); + + const setupOutputMessage = runningMessages.find((message) => + message.payload.detail.commands[0]?.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\'"', + log: expect.stringContaining("phase-one"), + 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\'"', + log: expect.stringContaining("phase-two"), + status: "completed", + exitCode: 0, + }); + expect(snapshots.get(worktreePath)).toMatchObject({ + status: "completed", + error: null, + }); + }); + + test("emits completed when reusing an existing worktree without bootstrapping", 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 snapshots = new Map(); + const routeStore = new ServiceRouteStore(); + const logger = createLogger(); + const terminalManager = createTerminalManagerStub(); + const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); + const archiveWorkspaceRecord = vi.fn(async () => {}); + + await createPaseoWorktreeInBackground( + { + paseoHome, + emitWorkspaceUpdateForCwd, + cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot), + emit: (message) => emitted.push(message), + sessionLogger: logger, + terminalManager: terminalManager.manager, + archiveWorkspaceRecord, + serviceRouteStore: routeStore, + daemonPort: 6767, + }, + { + requestCwd: repoDir, + repoRoot: repoDir, + worktree: { + branchName: "reused-worktree", + worktreePath: existingWorktree.worktreePath, + }, + shouldBootstrap: 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([ + { + hostname: "reused-worktree.web.localhost", + port: expect.any(Number), + workspaceId: existingWorktree.worktreePath, + serviceName: "web", + }, + ]); + expect(terminalManager.terminals).toHaveLength(1); + expect(terminalManager.terminals[0]?.cwd).toBe(existingWorktree.worktreePath); + expect(terminalManager.terminals[0]?.name).toBe("web"); + expect(terminalManager.terminals[0]?.env?.PORT).toEqual(expect.any(String)); + expect(terminalManager.terminals[0]?.env?.PASEO_SERVICE_URL).toBeDefined(); + expect(terminalManager.terminals[0]?.sent).toEqual(["npm run dev\r"]); + expect( + readFileSync(path.join(existingWorktree.worktreePath, "README.md"), "utf8"), + ).toContain("hello"); + expect(() => readFileSync(path.join(existingWorktree.worktreePath, "setup-ran.txt"), "utf8")).toThrow(); + expect(snapshots.get(existingWorktree.worktreePath)).toMatchObject({ + status: "completed", + error: null, + }); + 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 createdWorktree = await createWorktree({ + branchName: "feature-service-failure", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "feature-service-failure", + runSetup: false, + paseoHome, + }); + const worktreePath = createdWorktree.worktreePath; + const emitted: SessionOutboundMessage[] = []; + const snapshots = new Map(); + 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, + cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot), + emit: (message) => emitted.push(message), + sessionLogger: logger, + terminalManager: terminalManager.manager, + archiveWorkspaceRecord, + serviceRouteStore: routeStore, + daemonPort: 6767, + }, + { + requestCwd: repoDir, + repoRoot: repoDir, + worktree: { + branchName: "feature-service-failure", + worktreePath, + }, + shouldBootstrap: true, + }, + ); + + 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.error).toHaveBeenCalledWith( + expect.objectContaining({ + err: expect.any(Error), + cwd: repoDir, + repoRoot: repoDir, + worktreeSlug: "feature-service-failure", + worktreePath, + }), + "Failed to spawn worktree services after workspace setup completed", + ); + expect(snapshots.get(worktreePath)).toMatchObject({ + status: "completed", + error: null, + }); + expect(archiveWorkspaceRecord).not.toHaveBeenCalled(); + expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath); + }); + + test("launches services in socket mode without requiring a daemon TCP port", async () => { + const { tempDir, repoDir } = createGitRepo({ + paseoConfig: { + services: { + web: { + command: "npm run dev", + }, + }, + }, + }); + cleanupPaths.push(tempDir); + + const paseoHome = path.join(tempDir, ".paseo"); + const createdWorktree = await createWorktree({ + branchName: "feature-socket-mode", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "feature-socket-mode", + runSetup: false, + paseoHome, + }); + const worktreePath = createdWorktree.worktreePath; + const emitted: SessionOutboundMessage[] = []; + const snapshots = new Map(); + const routeStore = new ServiceRouteStore(); + const logger = createLogger(); + const terminalManager = createTerminalManagerStub(); + const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); + const archiveWorkspaceRecord = vi.fn(async () => {}); + + await createPaseoWorktreeInBackground( + { + paseoHome, + emitWorkspaceUpdateForCwd, + cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot), + emit: (message) => emitted.push(message), + sessionLogger: logger, + terminalManager: terminalManager.manager, + archiveWorkspaceRecord, + serviceRouteStore: routeStore, + daemonPort: null, + }, + { + requestCwd: repoDir, + repoRoot: repoDir, + worktree: { + branchName: "feature-socket-mode", + worktreePath, + }, + shouldBootstrap: true, + }, + ); + + expect(routeStore.listRoutes()).toEqual([ + { + hostname: "feature-socket-mode.web.localhost", + port: expect.any(Number), + workspaceId: worktreePath, + serviceName: "web", + }, + ]); + expect(terminalManager.terminals).toHaveLength(1); + expect(terminalManager.terminals[0]?.cwd).toBe(worktreePath); + expect(terminalManager.terminals[0]?.env?.PORT).toEqual(expect.any(String)); + expect(terminalManager.terminals[0]?.env?.PASEO_SERVICE_URL).toBeUndefined(); + expect(terminalManager.terminals[0]?.sent).toEqual(["npm run dev\r"]); + expect(snapshots.get(worktreePath)).toMatchObject({ + status: "completed", + error: null, + }); + expect(archiveWorkspaceRecord).not.toHaveBeenCalled(); + expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath); + }); + + test("returns the cached workspace setup snapshot for status requests", async () => { + const emitted: SessionOutboundMessage[] = []; + const snapshots = new Map([ + [ + "/repo/.paseo/worktrees/feature-a", + { + status: "completed", + detail: { + type: "worktree_setup", + worktreePath: "/repo/.paseo/worktrees/feature-a", + branchName: "feature-a", + log: "done", + commands: [], + }, + error: null, + }, + ], + ]); + + await handleWorkspaceSetupStatusRequest( + { + emit: (message) => emitted.push(message), + workspaceSetupSnapshots: snapshots, + }, + { + type: "workspace_setup_status_request", + workspaceId: "/repo/.paseo/worktrees/feature-a", + requestId: "req-status", + }, + ); + + expect(emitted).toContainEqual({ + type: "workspace_setup_status_response", + payload: { + requestId: "req-status", + workspaceId: "/repo/.paseo/worktrees/feature-a", + snapshot: { + status: "completed", + detail: { + type: "worktree_setup", + worktreePath: "/repo/.paseo/worktrees/feature-a", + branchName: "feature-a", + log: "done", + commands: [], + }, + error: null, + }, + }, + }); + }); + + test("returns null when no cached workspace setup snapshot exists", async () => { + const emitted: SessionOutboundMessage[] = []; + + await handleWorkspaceSetupStatusRequest( + { + emit: (message) => emitted.push(message), + workspaceSetupSnapshots: new Map(), + }, + { + type: "workspace_setup_status_request", + workspaceId: "/repo/.paseo/worktrees/missing", + requestId: "req-missing", + }, + ); + + expect(emitted).toContainEqual({ + type: "workspace_setup_status_response", + payload: { + requestId: "req-missing", + workspaceId: "/repo/.paseo/worktrees/missing", + snapshot: null, + }, + }); + }); + +}); + +describe("handleCreatePaseoWorktreeRequest", () => { + test("creates the worktree before emitting the response", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const emitted: SessionOutboundMessage[] = []; + const backgroundWork = vi.fn(async () => {}); + + try { + await handleCreatePaseoWorktreeRequest( + { + paseoHome, + sessionLogger: createLogger(), + emit: (message) => emitted.push(message), + registerPendingWorktreeWorkspace: vi.fn(async (options) => { + expect(existsSync(options.worktreePath)).toBe(true); + return { + workspaceId: options.worktreePath, + projectId: options.repoRoot, + } as any; + }), + describeWorkspaceRecord: vi.fn(async (workspace) => ({ + id: workspace.workspaceId, + projectId: workspace.projectId, + projectDisplayName: path.basename(repoDir), + projectRootPath: repoDir, + projectKind: "git", + workspaceKind: "worktree", + name: path.basename(workspace.workspaceId), + status: "done", + activityAt: null, + })), + createPaseoWorktreeInBackground: backgroundWork, + }, + { + type: "create_paseo_worktree_request", + cwd: repoDir, + worktreeSlug: "response-after-create", + requestId: "req-1", + }, + ); + + const response = emitted.find( + (message): message is Extract => + message.type === "create_paseo_worktree_response", + ); + expect(response?.payload.error).toBeNull(); + expect(response?.payload.workspace?.id).toBeTruthy(); + expect(existsSync(response!.payload.workspace!.id)).toBe(true); + expect(backgroundWork).toHaveBeenCalledWith( + expect.objectContaining({ + requestCwd: repoDir, + repoRoot: repoDir, + worktree: { + branchName: "response-after-create", + worktreePath: response!.payload.workspace!.id, + }, + shouldBootstrap: true, + }), + ); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 0df58d1c6..4a09adbf7 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -11,6 +11,7 @@ import { type ProjectPlacementPayload, type SessionInboundMessage, type SessionOutboundMessage, + type WorkspaceSetupSnapshot, type WorkspaceDescriptorPayload, } from "./messages.js"; import type { @@ -19,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, @@ -34,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"; @@ -93,8 +105,18 @@ type CreatePaseoWorktreeInBackgroundDependencies = { cwd: string, options?: { dedupeGitState?: boolean }, ) => Promise; + cacheWorkspaceSetupSnapshot: (workspaceId: string, snapshot: WorkspaceSetupSnapshot) => void; + emit: EmitSessionMessage; sessionLogger: Logger; terminalManager: TerminalManager | null; + archiveWorkspaceRecord: (workspaceId: string) => Promise; + serviceRouteStore: ServiceRouteStore | null; + daemonPort?: number | null; +}; + +type HandleWorkspaceSetupStatusRequestDependencies = { + emit: EmitSessionMessage; + workspaceSetupSnapshots: ReadonlyMap; }; type HandleCreatePaseoWorktreeRequestDependencies = { @@ -112,9 +134,8 @@ type HandleCreatePaseoWorktreeRequestDependencies = { createPaseoWorktreeInBackground: (options: { requestCwd: string; repoRoot: string; - baseBranch: string; - slug: string; - worktreePath: string; + worktree: WorktreeConfig; + shouldBootstrap: boolean; }) => Promise; }; @@ -131,10 +152,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 { @@ -176,8 +200,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)); @@ -195,7 +219,7 @@ export async function buildAgentSessionConfig( ...config, cwd, }, - worktreeConfig, + worktreeBootstrap, }; } @@ -567,11 +591,18 @@ export async function handleCreatePaseoWorktreeRequest( throw new Error(`Invalid worktree name: ${validation.error}`); } - const worktreePath = await computeWorktreePath(repoRoot, normalizedSlug, dependencies.paseoHome); + 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, }); await createAgentWorktree({ cwd: repoRoot, @@ -594,9 +625,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"; @@ -616,58 +646,146 @@ export async function handleCreatePaseoWorktreeRequest( } } +export async function handleWorkspaceSetupStatusRequest( + dependencies: HandleWorkspaceSetupStatusRequestDependencies, + request: Extract, +): Promise { + const workspaceId = normalizePersistedWorkspaceId(request.workspaceId); + dependencies.emit({ + type: "workspace_setup_status_response", + payload: { + requestId: request.requestId, + workspaceId, + snapshot: dependencies.workspaceSetupSnapshots.get(workspaceId) ?? null, + }, + }); +} + export async function createPaseoWorktreeInBackground( dependencies: CreatePaseoWorktreeInBackgroundDependencies, options: { requestCwd: string; repoRoot: string; - baseBranch: string; - slug: string; - worktreePath: string; + worktree: WorktreeConfig; + shouldBootstrap: boolean; }, ): Promise { - let setupTerminalId: string | null = null; + let worktree: WorktreeConfig = options.worktree; + let setupResults: WorktreeSetupCommandResult[] = []; + let setupStarted = false; + const progressAccumulator = createWorktreeSetupProgressAccumulator(); + const workspaceId = normalizePersistedWorkspaceId(worktree.worktreePath); + + const emitSetupProgress = (status: "running" | "completed" | "failed", error: string | null) => { + const snapshot: WorkspaceSetupSnapshot = { + status, + detail: buildWorktreeSetupDetail({ + worktree, + results: + status === "running" ? getWorktreeSetupProgressResults(progressAccumulator) : setupResults, + outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex, + }), + error, + }; + dependencies.cacheWorkspaceSetupSnapshot(workspaceId, snapshot); + dependencies.emit({ + type: "workspace_setup_progress", + payload: { + workspaceId, + ...snapshot, + }, + }); + }; try { - const setupCommands = getWorktreeSetupCommands(options.worktreePath); - if (setupCommands.length > 0 && dependencies.terminalManager) { - const runtimeEnv = await resolveWorktreeRuntimeEnv({ - worktreePath: options.worktreePath, - 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; + try { + emitSetupProgress("running", null); - for (const command of setupCommands) { - terminal.send({ - type: "input", - data: `${command}\r`, - }); + if (!options.shouldBootstrap) { + emitSetupProgress("completed", null); + } else { + 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(worktree.worktreePath)); + } + + dependencies.sessionLogger.error( + { + err: error, + cwd: options.requestCwd, + repoRoot: options.repoRoot, + worktreeSlug: worktree.branchName, + worktreePath: worktree.worktreePath, + setupStarted, + }, + "Background worktree creation failed", + ); + return; + } + + if (!dependencies.terminalManager || !dependencies.serviceRouteStore) { + return; + } + + try { + await spawnWorktreeServices({ + repoRoot: worktree.worktreePath, + workspaceId: worktree.worktreePath, + branchName: worktree.branchName, + daemonPort: dependencies.daemonPort, + routeStore: dependencies.serviceRouteStore, + terminalManager: dependencies.terminalManager, + logger: dependencies.sessionLogger, + }); + } catch (error) { + dependencies.sessionLogger.error( + { + err: error, + cwd: options.requestCwd, + repoRoot: options.repoRoot, + worktreeSlug: worktree.branchName, + worktreePath: worktree.worktreePath, + }, + "Failed to spawn worktree services after workspace setup completed", + ); } - } catch (error) { - dependencies.sessionLogger.error( - { - err: error, - cwd: options.requestCwd, - repoRoot: options.repoRoot, - worktreeSlug: options.slug, - worktreePath: options.worktreePath, - setupTerminalId, - }, - "Background worktree creation failed", - ); } finally { - await dependencies.emitWorkspaceUpdateForCwd(options.worktreePath); + await dependencies.emitWorkspaceUpdateForCwd(worktree.worktreePath); } } diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index eb0156ccc..e14d243b8 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -197,7 +197,27 @@ const NonNullUnknownSchema = z.union([ z.object({}).passthrough(), ]); +const WorktreeSetupCommandSnapshotSchema = z.object({ + index: z.number().int().positive(), + command: z.string(), + cwd: z.string(), + log: 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(), @@ -256,23 +276,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(), @@ -1004,6 +1007,12 @@ export const CreatePaseoWorktreeRequestSchema = z.object({ requestId: z.string(), }); +export const WorkspaceSetupStatusRequestSchema = z.object({ + type: z.literal("workspace_setup_status_request"), + workspaceId: z.string(), + requestId: z.string(), +}); + export const OpenProjectRequestSchema = z.object({ type: z.literal("open_project_request"), cwd: z.string(), @@ -1251,6 +1260,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ PaseoWorktreeListRequestSchema, PaseoWorktreeArchiveRequestSchema, CreatePaseoWorktreeRequestSchema, + WorkspaceSetupStatusRequestSchema, OpenProjectRequestSchema, ArchiveWorkspaceRequestSchema, FileExplorerRequestSchema, @@ -1596,6 +1606,14 @@ export const ProjectPlacementPayloadSchema = z.object({ checkout: ProjectCheckoutLitePayloadSchema, }); +export const WorkspaceServicePayloadSchema = z.object({ + serviceName: z.string(), + hostname: z.string(), + port: z.number().int().positive(), + url: z.string().nullable(), + status: z.enum(["running", "stopped"]), +}); + export const WorkspaceDescriptorPayloadSchema = z.object({ id: z.number().int(), projectId: z.number().int(), @@ -1614,6 +1632,7 @@ export const WorkspaceDescriptorPayloadSchema = z.object({ }) .nullable() .optional(), + services: z.array(WorkspaceServicePayloadSchema).default([]), }); export const AgentUpdateMessageSchema = z.object({ @@ -1709,6 +1728,39 @@ export const WorkspaceUpdateMessageSchema = z.object({ ]), }); +export const ServiceStatusUpdateMessageSchema = z.object({ + type: z.literal("service_status_update"), + payload: z.object({ + workspaceId: z.string(), + services: z.array(WorkspaceServicePayloadSchema), + }), +}); + +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 WorkspaceSetupSnapshotSchema = z.object({ + status: z.enum(["running", "completed", "failed"]), + detail: WorktreeSetupDetailPayloadSchema, + error: z.string().nullable(), +}); + +export const WorkspaceSetupStatusResponseMessageSchema = z.object({ + type: z.literal("workspace_setup_status_response"), + payload: z.object({ + requestId: z.string(), + workspaceId: z.string(), + snapshot: WorkspaceSetupSnapshotSchema.nullable(), + }), +}); + export const OpenProjectResponseMessageSchema = z.object({ type: z.literal("open_project_response"), payload: z.object({ @@ -2289,6 +2341,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ArtifactMessageSchema, AgentUpdateMessageSchema, WorkspaceUpdateMessageSchema, + ServiceStatusUpdateMessageSchema, + WorkspaceSetupProgressMessageSchema, + WorkspaceSetupStatusResponseMessageSchema, AgentStreamMessageSchema, AgentStatusMessageSchema, FetchAgentsResponseMessageSchema, @@ -2373,14 +2428,21 @@ export type ServerInfoStatusPayload = z.infer; export type ArtifactMessage = z.infer; export type AgentUpdateMessage = z.infer; +export type WorkspaceSetupProgressMessage = z.infer; +export type WorkspaceSetupSnapshot = z.infer; +export type WorkspaceSetupStatusResponseMessage = z.infer< + typeof WorkspaceSetupStatusResponseMessageSchema +>; export type AgentStreamMessage = z.infer; export type AgentStatusMessage = z.infer; export type ProjectCheckoutLitePayload = z.infer; export type ProjectPlacementPayload = z.infer; export type WorkspaceStateBucket = z.infer; export type WorkspaceDescriptorPayload = z.infer; +export type WorkspaceServicePayload = z.infer; export type FetchAgentsResponseMessage = z.infer; export type FetchWorkspacesResponseMessage = z.infer; +export type ServiceStatusUpdateMessage = z.infer; export type OpenProjectResponseMessage = z.infer; export type ArchiveWorkspaceResponseMessage = z.infer; export type FetchAgentResponseMessage = z.infer; @@ -2493,6 +2555,7 @@ export type PaseoWorktreeListRequest = z.infer; export type PaseoWorktreeArchiveRequest = z.infer; export type PaseoWorktreeArchiveResponse = z.infer; +export type WorkspaceSetupStatusRequest = z.infer; export type OpenProjectRequest = z.infer; export type ArchiveWorkspaceRequest = z.infer; export type FileExplorerRequest = z.infer; diff --git a/packages/server/src/shared/messages.workspaces.test.ts b/packages/server/src/shared/messages.workspaces.test.ts index aed724e7d..01c9ead2c 100644 --- a/packages/server/src/shared/messages.workspaces.test.ts +++ b/packages/server/src/shared/messages.workspaces.test.ts @@ -44,10 +44,138 @@ describe("workspace message schemas", () => { name: "", status: "not-a-bucket", activityAt: null, + services: [], }, }, }); expect(result.success).toBe(false); }); + + test("parses workspace descriptors with services", () => { + const parsed = SessionOutboundMessageSchema.parse({ + type: "workspace_update", + payload: { + kind: "upsert", + workspace: { + id: "/repo", + projectId: "/repo", + projectDisplayName: "repo", + projectRootPath: "/repo", + projectKind: "non_git", + workspaceKind: "directory", + name: "repo", + status: "done", + activityAt: null, + services: [ + { + serviceName: "web", + hostname: "web.localhost", + port: 3000, + url: "http://web.localhost:6767", + status: "running", + }, + ], + }, + }, + }); + + expect(parsed.type).toBe("workspace_update"); + if (parsed.type !== "workspace_update" || parsed.payload.kind !== "upsert") { + throw new Error("Expected workspace_update upsert payload"); + } + expect(parsed.payload.workspace.services).toEqual([ + { + serviceName: "web", + hostname: "web.localhost", + port: 3000, + url: "http://web.localhost:6767", + status: "running", + }, + ]); + }); + + test("parses service_status_update payload", () => { + const parsed = SessionOutboundMessageSchema.parse({ + type: "service_status_update", + payload: { + workspaceId: "/repo", + services: [ + { + serviceName: "web", + hostname: "web.localhost", + port: 3000, + url: null, + status: "stopped", + }, + ], + }, + }); + + expect(parsed.type).toBe("service_status_update"); + expect(parsed.payload.workspaceId).toBe("/repo"); + }); + + 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", + log: "done", + status: "completed", + exitCode: 0, + durationMs: 100, + }, + ], + }, + error: null, + }, + }); + + expect(parsed.type).toBe("workspace_setup_progress"); + }); + + test("parses workspace_setup_status_request", () => { + const parsed = SessionInboundMessageSchema.parse({ + type: "workspace_setup_status_request", + workspaceId: "/repo/.paseo/worktrees/feature-a", + requestId: "req-status", + }); + + expect(parsed.type).toBe("workspace_setup_status_request"); + }); + + test("parses workspace_setup_status_response payload", () => { + const parsed = SessionOutboundMessageSchema.parse({ + type: "workspace_setup_status_response", + payload: { + requestId: "req-status", + workspaceId: "/repo/.paseo/worktrees/feature-a", + snapshot: { + status: "completed", + detail: { + type: "worktree_setup", + worktreePath: "/repo/.paseo/worktrees/feature-a", + branchName: "feature-a", + log: "done", + commands: [], + }, + error: null, + }, + }, + }); + + expect(parsed.type).toBe("workspace_setup_status_response"); + }); }); diff --git a/packages/server/src/shared/tool-call-display.test.ts b/packages/server/src/shared/tool-call-display.test.ts index 136d9e851..7ecb7ba21 100644 --- a/packages/server/src/shared/tool-call-display.test.ts +++ b/packages/server/src/shared/tool-call-display.test.ts @@ -79,6 +79,7 @@ describe("shared tool-call display mapping", () => { index: 1, command: "npm install", cwd: "/tmp/repo/.paseo/worktrees/repo/branch", + log: "==> [1/1] Running: npm install\n", status: "running", exitCode: null, }, diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 193901553..62572476f 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -4,7 +4,9 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } f import { join, basename, dirname, resolve, sep } from "path"; import net from "node:net"; import { createHash } from "node:crypto"; +import * as pty from "node-pty"; import { createNameId } from "mnemonic-id"; +import stripAnsi from "strip-ansi"; import { normalizeBaseRefName, readPaseoWorktreeMetadata, @@ -13,6 +15,7 @@ import { writePaseoWorktreeRuntimeMetadata, } from "./worktree-metadata.js"; import { resolvePaseoHome } from "../server/paseo-home.js"; +import { ensureNodePtySpawnHelperExecutableForCurrentPlatform } from "../terminal/terminal.js"; interface PaseoConfig { worktree?: { @@ -20,6 +23,7 @@ interface PaseoConfig { teardown?: string[]; terminals?: WorktreeTerminalConfig[]; }; + services?: Record; } const execAsync = promisify(exec); @@ -84,6 +88,11 @@ export interface WorktreeTerminalConfig { command: string; } +export interface ServiceConfig { + command: string; + port?: number; // explicit port override, otherwise auto-assigned +} + export class WorktreeSetupError extends Error { readonly results: WorktreeSetupCommandResult[]; @@ -194,6 +203,90 @@ export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConf return specs; } +export function getServiceConfigs(repoRoot: string): Map { + const config = readPaseoConfig(repoRoot); + const services = config?.services; + if (!services || typeof services !== "object") { + return new Map(); + } + + const result = new Map(); + for (const [name, entry] of Object.entries(services)) { + if (!entry || typeof entry !== "object") { + continue; + } + + const rawCommand = entry.command; + if (typeof rawCommand !== "string") { + continue; + } + const command = rawCommand.trim(); + if (!command) { + continue; + } + + const serviceConfig: ServiceConfig = { command }; + + if (typeof entry.port === "number" && Number.isFinite(entry.port)) { + serviceConfig.port = entry.port; + } + + result.set(name, serviceConfig); + } + + return result; +} + +export function processCarriageReturns(text: string): string { + if (!text.includes("\r")) { + return text; + } + + const output: string[] = []; + let line: string[] = []; + let cursor = 0; + + const flushLine = () => { + output.push(line.join("")); + line = []; + cursor = 0; + }; + + for (let index = 0; index < text.length; index += 1) { + const char = text[index]; + + if (char === "\r") { + if (text[index + 1] === "\n") { + flushLine(); + output.push("\n"); + index += 1; + continue; + } + cursor = 0; + continue; + } + + if (char === "\n") { + flushLine(); + output.push("\n"); + continue; + } + + if (cursor < line.length) { + line[cursor] = char; + } else { + line.push(char); + } + cursor += 1; + } + + if (line.length > 0) { + output.push(line.join("")); + } + + return output.join(""); +} + async function execSetupCommand( command: string, options: { cwd: string; env: NodeJS.ProcessEnv }, @@ -239,6 +332,27 @@ async function execSetupCommandStreamed(options: { const stderrChunks: string[] = []; let settled = false; + const emitOutput = (stream: "stdout" | "stderr", chunk: string) => { + const text = stripAnsi(chunk); + if (!text) { + return; + } + if (stream === "stdout") { + stdoutChunks.push(text); + } else { + stderrChunks.push(text); + } + options.onEvent?.({ + type: "output", + index: options.index, + total: options.total, + command: options.command, + cwd: options.cwd, + stream, + chunk: text, + }); + }; + const finish = (exitCode: number | null) => { if (settled) { return; @@ -274,48 +388,52 @@ async function execSetupCommandStreamed(options: { cwd: options.cwd, }); - const child = spawn("/bin/bash", ["-lc", options.command], { - cwd: options.cwd, - env: options.env, - stdio: ["ignore", "pipe", "pipe"], - }); - - child.stdout?.on("data", (chunk: Buffer | string) => { - const text = chunk.toString(); - stdoutChunks.push(text); - options.onEvent?.({ - type: "output", - index: options.index, - total: options.total, - command: options.command, + const spawnWithPipes = () => { + const child = spawn("/bin/bash", ["-lc", options.command], { cwd: options.cwd, - stream: "stdout", - chunk: text, + env: options.env, + stdio: ["ignore", "pipe", "pipe"], }); - }); - child.stderr?.on("data", (chunk: Buffer | string) => { - const text = chunk.toString(); - stderrChunks.push(text); - options.onEvent?.({ - type: "output", - index: options.index, - total: options.total, - command: options.command, + child.stdout?.on("data", (chunk: Buffer | string) => { + emitOutput("stdout", chunk.toString()); + }); + + child.stderr?.on("data", (chunk: Buffer | string) => { + emitOutput("stderr", chunk.toString()); + }); + + child.on("error", (error) => { + emitOutput("stderr", error instanceof Error ? error.message : String(error)); + finish(null); + }); + + child.on("close", (code) => { + finish(typeof code === "number" ? code : null); + }); + }; + + try { + ensureNodePtySpawnHelperExecutableForCurrentPlatform(); + const terminal = pty.spawn("/bin/bash", ["-lc", options.command], { cwd: options.cwd, - stream: "stderr", - chunk: text, + env: options.env, + name: "xterm-color", + cols: 120, + rows: 30, }); - }); - child.on("error", (error) => { - stderrChunks.push(error instanceof Error ? error.message : String(error)); - finish(null); - }); + terminal.onData((data) => { + emitOutput("stdout", data); + }); - child.on("close", (code) => { - finish(typeof code === "number" ? code : null); - }); + terminal.onExit(({ exitCode }) => { + finish(typeof exitCode === "number" ? exitCode : null); + }); + } catch (error) { + emitOutput("stderr", error instanceof Error ? error.message : String(error)); + spawnWithPipes(); + } }); } diff --git a/packages/website/src/components/landing-page.tsx b/packages/website/src/components/landing-page.tsx index 6d2b21ffe..b0405c53c 100644 --- a/packages/website/src/components/landing-page.tsx +++ b/packages/website/src/components/landing-page.tsx @@ -59,6 +59,7 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
+ @@ -475,6 +476,52 @@ function SelfHostedSection() { } +function ServiceProxySection() { + const workspaces = [ + { name: "fix-auth", url: "fix-auth.my-app.localhost" }, + { name: "add-search", url: "add-search.my-app.localhost" }, + { name: "upgrade-deps", url: "upgrade-deps.my-app.localhost" }, + ]; + + return ( + +
+
+ {/* Project */} +
+ + + + my-app +
+ + {/* Workspaces indented */} +
+ {workspaces.map((ws) => ( +
+
+
+ {ws.name} + npm run dev +
+ + {ws.url} + +
+ ))} +
+
+
+ + ); +} + function ShortcutsSection() { const shortcuts = [ { keys: ["⌘", "1-9"], action: "Switch panels" },