diff --git a/packages/app/e2e/helpers/workspace-setup.ts b/packages/app/e2e/helpers/workspace-setup.ts index 844e40e0b..e4e854e0d 100644 --- a/packages/app/e2e/helpers/workspace-setup.ts +++ b/packages/app/e2e/helpers/workspace-setup.ts @@ -14,6 +14,9 @@ type WorkspaceSetupDaemonClient = { createPaseoWorktree( input: { cwd: string; worktreeSlug?: string }, ): Promise<{ workspace: { id: string; name: string } | null; error: string | null }>; + listTerminals( + cwd?: string, + ): Promise<{ cwd?: string; terminals: Array<{ id: string; name: string }>; requestId: string }>; subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void; }; @@ -95,7 +98,7 @@ export async function createWorkspaceFromSidebar(page: Page, repoPath: string): } export async function expectSetupPanel(page: Page): Promise { - await expect(page.getByText("Workspace setup", { exact: true })).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId("workspace-setup-panel")).toBeVisible({ timeout: 30_000 }); } export async function expectSetupStatus( 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 index 6e789634c..9ea4522bd 100644 --- a/packages/app/e2e/workspace-setup-streaming.spec.ts +++ b/packages/app/e2e/workspace-setup-streaming.spec.ts @@ -66,15 +66,18 @@ test.describe("Workspace setup streaming", () => { 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-expanded")) !== "true") { + if ((await explorerToggle.getAttribute("aria-label")) === "Open explorer") { await explorerToggle.click(); } - await expect(explorerToggle).toHaveAttribute("aria-expanded", "true", { timeout: 30_000 }); + 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({ @@ -194,4 +197,96 @@ test.describe("Workspace setup streaming", () => { 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/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index c8995c8d8..cf204af08 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, @@ -87,6 +88,7 @@ import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session- import { createNameId } from "mnemonic-id"; import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation"; import { openExternalUrl } from "@/utils/open-external-url"; +import { WorkspaceHoverCard } from "@/components/workspace-hover-card"; function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null { if (!icon) { @@ -855,122 +857,132 @@ function WorkspaceRowInner({ onPress(); }, [interaction.didLongPressRef, onPress]); + const isDesktop = !isMobile; + 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 || isMobile) ? ( - - [ - 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 || isMobile) ? ( + + [ + 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 b6b4e547b..faa31895e 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -51,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"; @@ -1112,11 +1113,27 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider 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); @@ -1465,7 +1482,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider unsubAgentStream(); unsubAgentTimeline(); unsubWorkspaceUpdate(); + unsubServiceStatusUpdate(); unsubWorkspaceSetupProgress(); + unsubWorkspaceSetupStatusResponse(); unsubStatus(); unsubPermissionRequest(); unsubPermissionResolved(); @@ -1491,6 +1510,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider setAgentTimelineCursor, setInitializingAgents, setAgents, + setWorkspaces, mergeWorkspaces, removeWorkspace, removeWorkspaceSetup, 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 88f4925c6..652de0683 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, @@ -19,7 +20,7 @@ function workspace( Partial< Pick< WorkspaceDescriptor, - "projectDisplayName" | "projectRootPath" | "projectKind" | "workspaceKind" + "projectDisplayName" | "projectRootPath" | "projectKind" | "workspaceKind" | "services" > >, ): WorkspaceDescriptor { @@ -34,9 +35,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({ @@ -117,6 +135,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 205deb6a4..7b5cccd20 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"; @@ -20,6 +21,8 @@ export interface SidebarWorkspaceEntry { activityAt: Date | null; statusBucket: SidebarStateBucket; diffStat: { additions: number; deletions: number } | null; + services: WorkspaceDescriptor["services"]; + hasRunningServices: boolean; } export interface SidebarProjectEntry { @@ -135,6 +138,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); @@ -257,8 +262,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/setup-panel.tsx b/packages/app/src/panels/setup-panel.tsx index 3407e0727..41ed6b143 100644 --- a/packages/app/src/panels/setup-panel.tsx +++ b/packages/app/src/panels/setup-panel.tsx @@ -1,5 +1,6 @@ -import { CheckCircle2, CircleAlert, SquareTerminal } from "lucide-react-native"; -import { ScrollView, Text, View } from "react-native"; +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"; @@ -7,6 +8,7 @@ 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 }, @@ -47,27 +49,43 @@ function useSetupPanelDescriptor( }; } -function formatCommandStatus(status: "running" | "completed" | "failed"): string { +type CommandStatus = "running" | "completed" | "failed"; + +function CommandStatusIcon({ status }: { status: CommandStatus }) { + const { theme } = useUnistyles(); + if (status === "running") { - return "Running"; + return ; } if (status === "completed") { - return "Completed"; + return ; } - return "Failed"; + return ; } -function formatSetupStatus(status: "running" | "completed" | "failed" | null): string { - if (status === "running") { - return "Running"; - } - if (status === "completed") { - return "Completed"; - } - if (status === "failed") { - return "Failed"; - } - return "Waiting for setup output"; +function 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() { @@ -75,113 +93,233 @@ function SetupPanel() { 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 statusLabel = formatSetupStatus(snapshot?.status ?? null); 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 */} + - Workspace setup - + >{statusLabel} + + {isWaiting ? ( + + + Setting up workspace... + + ) : hasNoSetupCommands ? ( + - {statusLabel} + No setup commands ran for this workspace. - + ) : ( + + {commands.map((command) => { + const isExpanded = expandedIndices.has(command.index); + const hasError = command.status === "failed" && snapshot?.error; - {snapshot?.error ? ( - - Setup error - - {snapshot.error} - - - ) : null} + // 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; - {commands.length > 0 ? ( - - Commands - - {commands.map((command) => ( - - {command.index}. - - + // 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} - - {formatCommandStatus(command.status)} - {typeof command.exitCode === "number" ? ` · exit ${command.exitCode}` : ""} - - + {command.durationMs != null ? ( + + {formatDuration(command.durationMs)} + + ) : null} + + + {showDetail ? ( + + {hasLog ? ( + + + {processedLog} + + + ) : ( + + No output + + )} + {hasError ? ( + + + {snapshot.error} + + + ) : null} + + ) : null} - ))} - - - ) : null} + ); + })} - - Log - {hasNoSetupCommands ? ( - - 0 ? ( + - No setup commands ran for this workspace. - - - ) : ( - - - {log.trim().length > 0 ? log : "Waiting for setup output..."} - - - )} - - + + {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} + + )} + ); } @@ -195,100 +333,93 @@ const styles = StyleSheet.create((theme) => ({ container: { flex: 1, minHeight: 0, - padding: theme.spacing[4], - gap: theme.spacing[4], backgroundColor: theme.colors.surface0, }, - header: { - flexDirection: "row", + contentContainer: { + padding: theme.spacing[4], + flexGrow: 1, + }, + hiddenStatus: { + position: "absolute", + width: 1, + height: 1, + overflow: "hidden", + opacity: 0, + }, + waitingContainer: { + flex: 1, alignItems: "center", - justifyContent: "space-between", + justifyContent: "center", gap: theme.spacing[3], }, - title: { - fontSize: theme.fontSize.lg, - fontWeight: "600", - color: theme.colors.foreground, - }, - statusBadge: { - borderRadius: theme.borderRadius.full, - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[1], - backgroundColor: theme.colors.surface2, - }, - statusBadgeText: { + waitingText: { fontSize: theme.fontSize.sm, - fontWeight: "600", color: theme.colors.foregroundMuted, }, - errorCard: { - borderRadius: theme.borderRadius.lg, - borderWidth: 1, - borderColor: theme.colors.palette.red[200], - backgroundColor: theme.colors.palette.red[100], - padding: theme.spacing[3], - gap: theme.spacing[2], - }, - errorTitle: { - fontSize: theme.fontSize.sm, - fontWeight: "600", - color: theme.colors.palette.red[800], - }, - errorBody: { - fontSize: theme.fontSize.sm, - color: theme.colors.palette.red[800], - }, - section: { - gap: theme.spacing[2], - }, - sectionFill: { + emptyContainer: { flex: 1, - minHeight: 0, - gap: theme.spacing[2], + alignItems: "center", + justifyContent: "center", }, - sectionTitle: { + emptyText: { fontSize: theme.fontSize.sm, - fontWeight: "600", color: theme.colors.foregroundMuted, - textTransform: "uppercase", - letterSpacing: 0.5, }, commandList: { gap: theme.spacing[2], }, + commandItem: { + borderRadius: theme.borderRadius.lg, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + overflow: "hidden", + }, commandRow: { flexDirection: "row", - alignItems: "flex-start", + alignItems: "center", gap: theme.spacing[2], - borderRadius: theme.borderRadius.md, + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], backgroundColor: theme.colors.surface1, - padding: theme.spacing[3], }, - commandIndex: { + commandRowExpanded: { + borderBottomWidth: theme.borderWidth[1], + borderBottomColor: theme.colors.border, + }, + commandRowPressed: { + opacity: 0.8, + }, + commandStatusIcon: { width: 18, - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - }, - commandTextColumn: { - flex: 1, - gap: theme.spacing[1], + height: 18, + alignItems: "center", + justifyContent: "center", + flexShrink: 0, }, commandText: { + flex: 1, fontFamily: Fonts.mono, fontSize: theme.fontSize.sm, color: theme.colors.foreground, }, - commandMeta: { + commandDuration: { fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, + flexShrink: 0, }, - logContainer: { - flex: 1, - minHeight: 0, - borderRadius: theme.borderRadius.lg, - backgroundColor: theme.colors.surface1, + chevron: { + flexShrink: 0, }, - logContent: { + chevronExpanded: { + transform: [{ rotate: "90deg" }], + }, + commandDetail: { + backgroundColor: theme.colors.surface0, + }, + logScroll: { + maxHeight: 400, + }, + logScrollContent: { padding: theme.spacing[3], }, logText: { @@ -297,13 +428,17 @@ const styles = StyleSheet.create((theme) => ({ lineHeight: 20, color: theme.colors.foreground, }, - emptyCard: { - borderRadius: theme.borderRadius.lg, - backgroundColor: theme.colors.surface1, - padding: theme.spacing[3], - }, - emptyText: { + 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-source-of-truth.test.ts b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts index 84562c96d..9dc09bfbc 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 @@ -19,6 +19,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/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 0646658cb..a08277688 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -119,6 +119,7 @@ export interface WorkspaceDescriptor { status: WorkspaceDescriptorPayload["status"]; activityAt: Date | null; diffStat: { additions: number; deletions: number } | null; + services: WorkspaceDescriptorPayload["services"]; } export function normalizeWorkspaceDescriptor( @@ -136,6 +137,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/utils/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts index 04c8a3f10..9b0871c40 100644 --- a/packages/app/src/utils/sidebar-project-row-model.test.ts +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -18,6 +18,8 @@ function workspace(overrides: Partial = {}): SidebarWorks activityAt: null, statusBucket: "done", diffStat: null, + services: [], + hasRunningServices: false, ...overrides, }; } @@ -87,8 +89,8 @@ describe("buildSidebarProjectRowModel", () => { }); }); - it("flattens git projects with a single workspace and keeps the new worktree action", () => { - const flattenedWorkspace = workspace({ + it("keeps single-workspace git projects as sections with a new worktree action", () => { + const workspaceEntry = workspace({ workspaceId: "/repo/main", workspaceKind: "local_checkout", }); @@ -96,16 +98,14 @@ describe("buildSidebarProjectRowModel", () => { const result = buildSidebarProjectRowModel({ project: project({ projectKind: "git", - workspaces: [flattenedWorkspace], + workspaces: [workspaceEntry], }), collapsed: true, }); expect(result).toEqual({ - kind: "workspace_link", - workspace: flattenedWorkspace, - selected: false, - chevron: null, + kind: "project_section", + chevron: "expand", trailingAction: "new_worktree", }); }); @@ -131,10 +131,10 @@ describe("buildSidebarProjectRowModel", () => { }); describe("isSidebarProjectFlattened", () => { - it("returns true for single-workspace projects regardless of kind", () => { + it("returns true only for single-workspace non-git projects", () => { expect( isSidebarProjectFlattened(project({ projectKind: "git", workspaces: [workspace()] })), - ).toBe(true); + ).toBe(false); expect( isSidebarProjectFlattened(project({ projectKind: "non_git", workspaces: [workspace()] })), ).toBe(true); diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index 113927177..db1041773 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -16,6 +16,8 @@ function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry { activityAt: null, statusBucket: "done", diffStat: null, + services: [], + hasRunningServices: false, }; } @@ -76,7 +78,7 @@ describe("buildSidebarShortcutModel", () => { expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "/repo/w9" }); }); - it("ignores collapsed state for flattened single-workspace projects", () => { + it("still hides collapsed single-workspace git projects because they are not flattened", () => { const projects = [project("p1", [workspace("s1", "/repo/main")])]; const model = buildSidebarShortcutModel({ @@ -84,7 +86,7 @@ describe("buildSidebarShortcutModel", () => { collapsedProjectKeys: new Set(["p1"]), }); - expect(model.visibleTargets).toEqual([{ serverId: "s1", workspaceId: "/repo/main" }]); - expect(model.shortcutTargets).toEqual([{ serverId: "s1", workspaceId: "/repo/main" }]); + expect(model.visibleTargets).toEqual([]); + expect(model.shortcutTargets).toEqual([]); }); }); 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 eeaba5bca..d5aecc092 100644 --- a/packages/app/src/utils/workspace-archive-navigation.test.ts +++ b/packages/app/src/utils/workspace-archive-navigation.test.ts @@ -19,6 +19,7 @@ function workspace( status: input.status ?? "done", activityAt: input.activityAt ?? null, diffStat: input.diffStat ?? null, + services: input.services ?? [], }; } diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index 8a01c3d83..777c11a14 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -251,6 +251,7 @@ describe("DaemonClient", () => { index: 1, command: "npm install", cwd: "/tmp/project/.paseo/worktrees/feature-a", + log: "phase-one\n", status: "running", exitCode: null, }, @@ -277,6 +278,7 @@ describe("DaemonClient", () => { index: 1, command: "npm install", cwd: "/tmp/project/.paseo/worktrees/feature-a", + log: "phase-one\n", status: "running", exitCode: null, }, diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index b5bd5514f..240c11381 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, @@ -462,6 +463,7 @@ export type InspectScheduleOptions = { }; type OpenProjectPayload = OpenProjectResponseMessage["payload"]; type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"]; +type WorkspaceSetupStatusPayload = WorkspaceSetupStatusResponseMessage["payload"]; export type FetchAgentResult = { agent: AgentSnapshotPayload; @@ -1318,6 +1320,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({ diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index a4440aa81..891ecc814 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -178,6 +178,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/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 42db80269..af593f8a8 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -118,6 +118,8 @@ import { createServiceProxyMiddleware, createServiceProxyUpgradeHandler, } from "./service-proxy.js"; +import { ServiceHealthMonitor } from "./service-health-monitor.js"; +import { createServiceStatusEmitter } from "./service-status-projection.js"; import { createVoiceMcpSocketBridgeManager, type VoiceMcpSocketBridgeManager, @@ -225,6 +227,18 @@ export async function createPaseoDaemon( 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. @@ -450,7 +464,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) @@ -676,6 +689,7 @@ export async function createPaseoDaemon( checkoutDiffManager, serviceRouteStore, () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), + (hostname) => serviceHealthMonitor.getStatusForHostname(hostname), ); logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening"); @@ -766,9 +780,11 @@ 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 () => { + serviceHealthMonitor.stop(); await closeAllAgents(logger, agentManager); await agentManager.flush().catch(() => undefined); detachAgentStoragePersistence(); 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 index a39c04eb8..3a10ca745 100644 --- a/packages/server/src/server/service-proxy.test.ts +++ b/packages/server/src/server/service-proxy.test.ts @@ -28,9 +28,14 @@ function closeServer(server: http.Server): Promise { // --------------------------------------------------------------------------- describe("ServiceRouteStore", () => { - it("addRoute and findRoute with exact match", () => { + it("registerRoute and findRoute with exact match", () => { const store = new ServiceRouteStore(); - store.addRoute("editor.localhost", 3000); + 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 }); @@ -38,7 +43,12 @@ describe("ServiceRouteStore", () => { it("findRoute strips port from host header", () => { const store = new ServiceRouteStore(); - store.addRoute("editor.localhost", 3000); + 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 }); @@ -46,25 +56,132 @@ describe("ServiceRouteStore", () => { it("findRoute subdomain match", () => { const store = new ServiceRouteStore(); - store.addRoute("editor.localhost", 3000); + 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.addRoute("editor.localhost", 3000); + 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.addRoute("a.localhost", 3000); - store.addRoute("b.localhost", 3000); - store.addRoute("c.localhost", 4000); + 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); @@ -76,22 +193,36 @@ describe("ServiceRouteStore", () => { }); }); - it("findRoute returns null for unknown hosts", () => { + it("removeRoutesForPort cleans up workspace index", () => { const store = new ServiceRouteStore(); - store.addRoute("editor.localhost", 3000); + 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", + }); - expect(store.findRoute("unknown.example.com")).toBeNull(); + store.removeRoutesForPort(3000); + + expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([]); }); - it("listRoutes returns all routes", () => { + it("findRoute returns null for unknown hosts", () => { const store = new ServiceRouteStore(); - store.addRoute("a.localhost", 3000); - store.addRoute("b.localhost", 4000); + store.registerRoute({ + hostname: "editor.localhost", + port: 3000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + serviceName: "editor", + }); - const routes = store.listRoutes(); - expect(routes).toHaveLength(2); - expect(routes).toContainEqual({ hostname: "a.localhost", port: 3000 }); - expect(routes).toContainEqual({ hostname: "b.localhost", port: 4000 }); + expect(store.findRoute("unknown.example.com")).toBeNull(); }); }); diff --git a/packages/server/src/server/service-proxy.ts b/packages/server/src/server/service-proxy.ts index 8839fadba..fc7a23d0c 100644 --- a/packages/server/src/server/service-proxy.ts +++ b/packages/server/src/server/service-proxy.ts @@ -29,21 +29,49 @@ export interface ServiceRoute { port: number; } +export interface ServiceRouteEntry extends ServiceRoute { + workspaceId: string; + serviceName: string; +} + export class ServiceRouteStore { - private routes = new Map(); + private routes = new Map(); + private workspaceHostnames = new Map>(); addRoute(hostname: string, port: number): void { - this.routes.set(hostname, port); + 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, p] of this.routes) { - if (p === port) { + for (const [hostname, entry] of this.routes) { + if (entry.port === port) { this.routes.delete(hostname); + this.removeHostnameFromWorkspaceIndex(entry.workspaceId, hostname); } } } @@ -53,29 +81,60 @@ export class ServiceRouteStore { const hostname = host.replace(/:\d+$/, ""); // 1. Exact match - const exactPort = this.routes.get(hostname); - if (exactPort !== undefined) { - return { hostname, port: exactPort }; + 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 candidatePort = this.routes.get(candidate); - if (candidatePort !== undefined) { - return { hostname: candidate, port: candidatePort }; + const candidateRoute = this.routes.get(candidate); + if (candidateRoute !== undefined) { + return { hostname: candidateRoute.hostname, port: candidateRoute.port }; } } return null; } - listRoutes(): ServiceRoute[] { - return Array.from(this.routes.entries()).map(([hostname, port]) => ({ - hostname, - port, - })); + 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); + } } } 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 091ecfc62..d60f48fc9 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -28,6 +28,7 @@ import { type UnsubscribeCheckoutDiffRequest, type DirectorySuggestionsRequest, type ProjectPlacementPayload, + type WorkspaceSetupSnapshot, type WorkspaceDescriptorPayload, type WorkspaceStateBucket, } from "./messages.js"; @@ -62,6 +63,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"; @@ -180,6 +182,7 @@ import { handleCreatePaseoWorktreeRequest as handleCreateWorktreeRequest, handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest, handlePaseoWorktreeListRequest as handleWorktreeListRequest, + handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage, killTerminalsUnderPath as killWorktreeTerminalsUnderPath, registerPendingWorktreeWorkspace as registerPendingWorktreeWorkspaceSession, } from "./worktree-session.js"; @@ -375,6 +378,7 @@ export type SessionOptions = { terminalManager: TerminalManager | null; serviceRouteStore?: ServiceRouteStore; getDaemonTcpPort?: () => number | null; + resolveServiceStatus?: (hostname: string) => "running" | "stopped" | null; voice?: { voiceAgentMcpStdio?: VoiceMcpStdioConfig | null; turnDetection?: Resolvable; @@ -572,6 +576,7 @@ export class Session { 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(); @@ -582,6 +587,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, @@ -625,6 +631,7 @@ export class Session { terminalManager, serviceRouteStore, getDaemonTcpPort, + resolveServiceStatus, voice, voiceBridge, dictation, @@ -651,6 +658,7 @@ export class Session { 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), @@ -716,6 +724,10 @@ export class Session { }; } + public emitServerMessage(message: SessionOutboundMessage): void { + this.emit(message); + } + /** * Send initial state to client after connection */ @@ -1617,6 +1629,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; @@ -4918,6 +4934,14 @@ export class Session { status: "done", activityAt: null, diffStat, + services: this.serviceRouteStore + ? buildWorkspaceServicePayloads( + this.serviceRouteStore, + workspace.workspaceId, + this.getDaemonTcpPort?.() ?? null, + this.resolveServiceStatus ?? undefined, + ) + : [], }; } @@ -5565,6 +5589,9 @@ export class Session { 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, @@ -5576,6 +5603,18 @@ export class Session { ); } + 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 00a88d219..49fdc3a6e 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -244,6 +244,7 @@ export class VoiceAssistantWebSocketServer { 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; @@ -312,6 +313,7 @@ export class VoiceAssistantWebSocketServer { checkoutDiffManager?: CheckoutDiffManager, serviceRouteStore?: ServiceRouteStore | null, getDaemonTcpPort?: () => number | null, + resolveServiceStatus?: (hostname: string) => "running" | "stopped" | null, ) { this.logger = logger.child({ module: "websocket-server" }); this.serverId = serverId; @@ -350,6 +352,7 @@ export class VoiceAssistantWebSocketServer { this.onLifecycleIntent = onLifecycleIntent ?? null; this.serviceRouteStore = serviceRouteStore ?? null; this.getDaemonTcpPort = getDaemonTcpPort ?? null; + this.resolveServiceStatus = resolveServiceStatus ?? null; this.serverCapabilities = buildServerCapabilities({ readiness: this.speech?.getReadiness() ?? null, }); @@ -420,6 +423,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 })); } @@ -652,6 +665,7 @@ export class VoiceAssistantWebSocketServer { 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 bb72bd6a1..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; @@ -114,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, }); @@ -247,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 () => { @@ -440,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 3cb8430fe..aeebbff03 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -10,6 +10,7 @@ import { getServiceConfigs, getWorktreeTerminalSpecs, listPaseoWorktrees, + processCarriageReturns, resolveWorktreeRuntimeEnv, runWorktreeSetupCommands, slugify, @@ -254,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; @@ -279,6 +278,26 @@ 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(), @@ -341,14 +360,26 @@ export function buildWorktreeSetupDetail(input: { results: WorktreeSetupCommandResult[]; outputAccumulatorsByIndex?: Map; }): Extract { - 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 } : {}), - })); + 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, @@ -360,7 +391,7 @@ export function buildWorktreeSetupDetail(input: { branchName: input.worktree.branchName, log: renderedLog.log, commands, - ...(renderedLog.truncated ? { truncated: true } : {}), + ...(renderedLog.truncated || anyCommandTruncated ? { truncated: true } : {}), }; } @@ -704,18 +735,14 @@ export async function runAsyncWorktreeBootstrap( await runWorktreeTerminalBootstrap(options, runtimeEnv); - if ( - !options.terminalManager || - !options.serviceRouteStore || - options.daemonPort === null || - options.daemonPort === undefined - ) { + 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, @@ -743,13 +770,15 @@ export interface WorktreeServiceResult { export async function spawnWorktreeServices(options: { repoRoot: string; + workspaceId: string; branchName: string | null; - daemonPort: number; + daemonPort?: number | null; routeStore: ServiceRouteStore; terminalManager: TerminalManager; logger?: Logger; }): Promise { - const { repoRoot, branchName, daemonPort, routeStore, terminalManager, logger } = options; + const { repoRoot, workspaceId, branchName, daemonPort, routeStore, terminalManager, logger } = + options; const serviceConfigs = getServiceConfigs(repoRoot); if (serviceConfigs.size === 0) { return []; @@ -758,43 +787,72 @@ export async function spawnWorktreeServices(options: { const results: WorktreeServiceResult[] = []; for (const [serviceName, config] of serviceConfigs) { - const port = config.port ?? (await findFreePort()); - const branchHostnameLabel = branchName ? slugify(branchName) : null; + let hostname: string | null = null; + let port: number | null = null; - const isDefaultBranch = - branchName === null || branchName === "main" || branchName === "master"; - const hostname = isDefaultBranch - ? `${serviceName}.localhost` - : `${branchHostnameLabel}.${serviceName}.localhost`; + try { + port = config.port ?? (await findFreePort()); + const branchHostnameLabel = branchName ? slugify(branchName) : null; - routeStore.addRoute(hostname, port); + const isDefaultBranch = + branchName === null || branchName === "main" || branchName === "master"; + hostname = isDefaultBranch + ? `${serviceName}.localhost` + : `${branchHostnameLabel}.${serviceName}.localhost`; - const env: Record = { - PORT: String(port), - HOST: "127.0.0.1", - PASEO_SERVICE_URL: `http://${hostname}:${daemonPort}`, - }; + routeStore.registerRoute({ + hostname, + port, + workspaceId, + serviceName, + }); - const terminal = await terminalManager.createTerminal({ - cwd: repoRoot, - name: serviceName, - env, - }); + const env: Record = { + PORT: String(port), + HOST: "127.0.0.1", + }; + if (daemonPort !== null && daemonPort !== undefined) { + env.PASEO_SERVICE_URL = `http://${hostname}:${daemonPort}`; + } - await waitForTerminalBootstrapReadiness(terminal); - terminal.send({ type: "input", data: `${config.command}\r` }); + const terminal = await terminalManager.createTerminal({ + cwd: repoRoot, + name: serviceName, + env, + }); - logger?.info( - { serviceName, hostname, port, terminalId: terminal.id }, - `Registered service proxy: ${hostname} -> 127.0.0.1:${port}`, - ); + await waitForTerminalBootstrapReadiness(terminal); + terminal.send({ type: "input", data: `${config.command}\r` }); - results.push({ - serviceName, - hostname, - port, - terminalId: terminal.id, - }); + 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; diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index e3064f93d..54a7d695c 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -1,13 +1,17 @@ import { execSync } from "node:child_process"; -import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +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 } from "./worktree-session.js"; -import { computeWorktreePath, createWorktree } from "../utils/worktree.js"; +import { + createPaseoWorktreeInBackground, + handleCreatePaseoWorktreeRequest, + handleWorkspaceSetupStatusRequest, +} from "./worktree-session.js"; +import { createWorktree } from "../utils/worktree.js"; function createLogger() { return { @@ -109,8 +113,17 @@ describe("createPaseoWorktreeInBackground", () => { cleanupPaths.push(tempDir); const paseoHome = path.join(tempDir, ".paseo"); - const worktreePath = await computeWorktreePath(repoDir, "feature-no-setup", paseoHome); + 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(); @@ -121,6 +134,7 @@ describe("createPaseoWorktreeInBackground", () => { { paseoHome, emitWorkspaceUpdateForCwd, + cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot), emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: terminalManager.manager, @@ -131,9 +145,11 @@ describe("createPaseoWorktreeInBackground", () => { { requestCwd: repoDir, repoRoot: repoDir, - baseBranch: "main", - slug: "feature-no-setup", - worktreePath, + worktree: { + branchName: "feature-no-setup", + worktreePath, + }, + shouldBootstrap: true, }, ); @@ -166,12 +182,30 @@ describe("createPaseoWorktreeInBackground", () => { 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) }, + { + 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); @@ -181,9 +215,25 @@ describe("createPaseoWorktreeInBackground", () => { 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 worktreePath = await computeWorktreePath(repoDir, "broken-feature", paseoHome); + 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 () => {}); @@ -192,6 +242,7 @@ describe("createPaseoWorktreeInBackground", () => { { paseoHome, emitWorkspaceUpdateForCwd, + cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot), emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: null, @@ -202,9 +253,11 @@ describe("createPaseoWorktreeInBackground", () => { { requestCwd: repoDir, repoRoot: repoDir, - baseBranch: "does-not-exist", - slug: "broken-feature", - worktreePath, + worktree: { + branchName: "broken-feature", + worktreePath, + }, + shouldBootstrap: true, }, ); @@ -216,8 +269,12 @@ describe("createPaseoWorktreeInBackground", () => { expect(progressMessages[0]?.payload.status).toBe("running"); expect(progressMessages[0]?.payload.error).toBeNull(); expect(progressMessages[1]?.payload.status).toBe("failed"); - expect(progressMessages[1]?.payload.error).toContain("does-not-exist"); + expect(progressMessages[1]?.payload.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); }); @@ -233,8 +290,17 @@ describe("createPaseoWorktreeInBackground", () => { cleanupPaths.push(tempDir); const paseoHome = path.join(tempDir, ".paseo"); - const worktreePath = await computeWorktreePath(repoDir, "feature-running-setup", paseoHome); + 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 () => {}); @@ -243,6 +309,7 @@ describe("createPaseoWorktreeInBackground", () => { { paseoHome, emitWorkspaceUpdateForCwd, + cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot), emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: null, @@ -253,9 +320,11 @@ describe("createPaseoWorktreeInBackground", () => { { requestCwd: repoDir, repoRoot: repoDir, - baseBranch: "main", - slug: "feature-running-setup", - worktreePath, + worktree: { + branchName: "feature-running-setup", + worktreePath, + }, + shouldBootstrap: true, }, ); @@ -285,12 +354,13 @@ describe("createPaseoWorktreeInBackground", () => { ); const setupOutputMessage = runningMessages.find((message) => - message.payload.detail.log.includes("phase-one"), + 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", }); @@ -308,9 +378,14 @@ describe("createPaseoWorktreeInBackground", () => { 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 () => { @@ -339,6 +414,7 @@ describe("createPaseoWorktreeInBackground", () => { }); const emitted: SessionOutboundMessage[] = []; + const snapshots = new Map(); const routeStore = new ServiceRouteStore(); const logger = createLogger(); const terminalManager = createTerminalManagerStub(); @@ -349,6 +425,7 @@ describe("createPaseoWorktreeInBackground", () => { { paseoHome, emitWorkspaceUpdateForCwd, + cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot), emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: terminalManager.manager, @@ -359,9 +436,11 @@ describe("createPaseoWorktreeInBackground", () => { { requestCwd: repoDir, repoRoot: repoDir, - baseBranch: "main", - slug: "reused-worktree", - worktreePath: existingWorktree.worktreePath, + worktree: { + branchName: "reused-worktree", + worktreePath: existingWorktree.worktreePath, + }, + shouldBootstrap: false, }, ); @@ -387,12 +466,28 @@ describe("createPaseoWorktreeInBackground", () => { commands: [], }, }); - expect(routeStore.listRoutes()).toEqual([]); - expect(terminalManager.terminals).toHaveLength(0); + 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); }); @@ -410,8 +505,17 @@ describe("createPaseoWorktreeInBackground", () => { cleanupPaths.push(tempDir); const paseoHome = path.join(tempDir, ".paseo"); - const worktreePath = await computeWorktreePath(repoDir, "feature-service-failure", paseoHome); + 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({ @@ -426,6 +530,7 @@ describe("createPaseoWorktreeInBackground", () => { { paseoHome, emitWorkspaceUpdateForCwd, + cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot), emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: terminalManager.manager, @@ -436,9 +541,11 @@ describe("createPaseoWorktreeInBackground", () => { { requestCwd: repoDir, repoRoot: repoDir, - baseBranch: "main", - slug: "feature-service-failure", - worktreePath, + worktree: { + branchName: "feature-service-failure", + worktreePath, + }, + shouldBootstrap: true, }, ); @@ -452,15 +559,237 @@ describe("createPaseoWorktreeInBackground", () => { expect(progressMessages[1]?.payload.status).toBe("completed"); expect(progressMessages[1]?.payload.error).toBeNull(); expect(emitted.some((message) => message.type === "workspace_setup_progress" && message.payload.status === "failed")).toBe(false); - expect(logger.warn).toHaveBeenCalledWith( + expect(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 221a42091..8ecae9311 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 { @@ -27,7 +28,6 @@ import { createWorktreeSetupProgressAccumulator, getWorktreeSetupProgressResults, spawnWorktreeServices, - type CreateAgentWorktreeResult, } from "./worktree-bootstrap.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { ServiceRouteStore } from "./service-proxy.js"; @@ -117,12 +117,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; + daemonPort?: number | null; +}; + +type HandleWorkspaceSetupStatusRequestDependencies = { + emit: EmitSessionMessage; + workspaceSetupSnapshots: ReadonlyMap; }; type HandleCreatePaseoWorktreeRequestDependencies = { @@ -597,7 +603,7 @@ 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, @@ -645,6 +651,21 @@ 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: { @@ -658,20 +679,25 @@ export async function createPaseoWorktreeInBackground( 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: normalizePersistedWorkspaceId(worktree.worktreePath), - status, - detail: buildWorktreeSetupDetail({ - worktree, - results: - status === "running" ? getWorktreeSetupProgressResults(progressAccumulator) : setupResults, - outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex, - }), - error, + workspaceId, + ...snapshot, }, }); }; @@ -682,36 +708,35 @@ export async function createPaseoWorktreeInBackground( if (!options.shouldBootstrap) { emitSetupProgress("completed", null); - return; - } - - const setupCommands = getWorktreeSetupCommands(worktree.worktreePath); - if (setupCommands.length === 0) { - setupStarted = true; - emitSetupProgress("completed", null); } else { - const runtimeEnv = await resolveWorktreeRuntimeEnv({ - worktreePath: worktree.worktreePath, - branchName: worktree.branchName, - repoRootPath: options.repoRoot, - }); - dependencies.terminalManager?.registerCwdEnv({ - cwd: worktree.worktreePath, - env: runtimeEnv, - }); - setupStarted = true; - setupResults = await runWorktreeSetupCommands({ - worktreePath: worktree.worktreePath, - branchName: worktree.branchName, - cleanupOnFailure: false, - repoRootPath: options.repoRoot, - runtimeEnv, - onEvent: (event) => { - applyWorktreeSetupProgressEvent(progressAccumulator, event); - emitSetupProgress("running", null); - }, - }); - emitSetupProgress("completed", null); + 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) { @@ -738,18 +763,14 @@ export async function createPaseoWorktreeInBackground( return; } - if ( - !dependencies.terminalManager || - !dependencies.serviceRouteStore || - dependencies.daemonPort === null || - dependencies.daemonPort === undefined - ) { + if (!dependencies.terminalManager || !dependencies.serviceRouteStore) { return; } try { await spawnWorktreeServices({ repoRoot: worktree.worktreePath, + workspaceId: worktree.worktreePath, branchName: worktree.branchName, daemonPort: dependencies.daemonPort, routeStore: dependencies.serviceRouteStore, @@ -757,8 +778,14 @@ export async function createPaseoWorktreeInBackground( logger: dependencies.sessionLogger, }); } catch (error) { - dependencies.sessionLogger.warn( - { err: error, worktreePath: worktree.worktreePath }, + 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", ); } diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 64c4b2b11..0dcc09fe8 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -199,6 +199,7 @@ 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(), @@ -985,6 +986,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(), @@ -1228,6 +1235,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ PaseoWorktreeListRequestSchema, PaseoWorktreeArchiveRequestSchema, CreatePaseoWorktreeRequestSchema, + WorkspaceSetupStatusRequestSchema, OpenProjectRequestSchema, ArchiveWorkspaceRequestSchema, FileExplorerRequestSchema, @@ -1573,6 +1581,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.string(), projectId: z.string(), @@ -1590,6 +1606,7 @@ export const WorkspaceDescriptorPayloadSchema = z.object({ }) .nullable() .optional(), + services: z.array(WorkspaceServicePayloadSchema).default([]), }); export const AgentUpdateMessageSchema = z.object({ @@ -1682,6 +1699,14 @@ 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({ @@ -1692,6 +1717,21 @@ export const WorkspaceSetupProgressMessageSchema = z.object({ }), }); +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({ @@ -2263,7 +2303,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ArtifactMessageSchema, AgentUpdateMessageSchema, WorkspaceUpdateMessageSchema, + ServiceStatusUpdateMessageSchema, WorkspaceSetupProgressMessageSchema, + WorkspaceSetupStatusResponseMessageSchema, AgentStreamMessageSchema, AgentStatusMessageSchema, FetchAgentsResponseMessageSchema, @@ -2348,14 +2390,20 @@ export type RpcErrorMessage = 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; @@ -2468,6 +2516,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 57741db55..68b78b90a 100644 --- a/packages/server/src/shared/messages.workspaces.test.ts +++ b/packages/server/src/shared/messages.workspaces.test.ts @@ -44,6 +44,7 @@ describe("workspace message schemas", () => { name: "", status: "not-a-bucket", activityAt: null, + services: [], }, }, }); @@ -51,6 +52,70 @@ describe("workspace message schemas", () => { 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", @@ -67,6 +132,7 @@ describe("workspace message schemas", () => { index: 1, command: "npm install", cwd: "/repo/.paseo/worktrees/feature-a", + log: "done", status: "completed", exitCode: 0, durationMs: 100, @@ -79,4 +145,37 @@ describe("workspace message schemas", () => { 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 3dcb38811..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?: { @@ -234,6 +237,56 @@ export function getServiceConfigs(repoRoot: string): Map 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 }, @@ -279,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; @@ -314,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 9cea3e24c..a15059a45 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" },