feat: service proxy, health monitor, hover card, and homepage section

Built-in service proxy with branch-based URLs, service health
monitoring, workspace hover card with service status, and
"Forget about ports" homepage section.
This commit is contained in:
Mohamed Boudra
2026-04-01 10:31:15 +07:00
parent 806079c7ad
commit 5b3ec572cb
39 changed files with 3776 additions and 498 deletions
+4 -1
View File
@@ -14,6 +14,9 @@ type WorkspaceSetupDaemonClient = {
createPaseoWorktree( createPaseoWorktree(
input: { cwd: string; worktreeSlug?: string }, input: { cwd: string; worktreeSlug?: string },
): Promise<{ workspace: { id: string; name: string } | null; error: string | null }>; ): 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; 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<void> { export async function expectSetupPanel(page: Page): Promise<void> {
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( export async function expectSetupStatus(
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
// 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();
}
});
});
@@ -66,15 +66,18 @@ test.describe("Workspace setup streaming", () => {
await expectSetupLogContains(page, "setup complete"); await expectSetupLogContains(page, "setup complete");
await waitForWorkspaceTabsVisible(page); await waitForWorkspaceTabsVisible(page);
await page.getByTestId("workspace-new-agent-tab").first().click();
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({ await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
timeout: 30_000, timeout: 30_000,
}); });
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first(); 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 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 page.getByTestId("explorer-tab-files").click();
await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible({ timeout: 30_000 }); await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("README.md", { exact: true }).first()).toBeVisible({ await expect(page.getByText("README.md", { exact: true }).first()).toBeVisible({
@@ -194,4 +197,96 @@ test.describe("Workspace setup streaming", () => {
await repo.cleanup(); 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();
}
});
}); });
@@ -34,6 +34,7 @@ import {
FolderPlus, FolderPlus,
FolderGit2, FolderGit2,
GitPullRequest, GitPullRequest,
Globe,
Monitor, Monitor,
MoreVertical, MoreVertical,
Plus, Plus,
@@ -87,6 +88,7 @@ import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-
import { createNameId } from "mnemonic-id"; import { createNameId } from "mnemonic-id";
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation"; import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null { function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) { if (!icon) {
@@ -855,122 +857,132 @@ function WorkspaceRowInner({
onPress(); onPress();
}, [interaction.didLongPressRef, onPress]); }, [interaction.didLongPressRef, onPress]);
const isDesktop = !isMobile;
const showGlobe = isDesktop && workspace.hasRunningServices;
return ( return (
<View <WorkspaceHoverCard workspace={workspace} isDragging={isDragging}>
style={styles.workspaceRowContainer} <View
onPointerEnter={() => setIsHovered(true)} style={styles.workspaceRowContainer}
onPointerLeave={() => setIsHovered(false)} onPointerEnter={() => setIsHovered(true)}
> onPointerLeave={() => setIsHovered(false)}
<Pressable
disabled={isArchiving}
style={({ pressed }) => [
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}`}
> >
<View style={styles.workspaceRowMain}> <Pressable
<View disabled={isArchiving}
{...(dragHandleProps?.attributes as any)} style={({ pressed }) => [
{...(dragHandleProps?.listeners as any)} styles.workspaceRow,
ref={dragHandleProps?.setActivatorNodeRef as any} isDragging && styles.workspaceRowDragging,
style={styles.workspaceRowLeft} selected && styles.sidebarRowSelected,
> isHovered && styles.workspaceRowHovered,
<WorkspaceStatusIndicator pressed && styles.workspaceRowPressed,
bucket={workspace.statusBucket} ]}
workspaceKind={workspace.workspaceKind} onPressIn={interaction.handlePressIn}
loading={isArchiving || isCreating} onTouchMove={interaction.handleTouchMove}
/> onPressOut={interaction.handlePressOut}
<Text onPress={handlePress}
style={[ testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
styles.workspaceBranchText, >
isHovered && styles.workspaceBranchTextHovered, <View style={styles.workspaceRowMain}>
isCreating && styles.workspaceBranchTextCreating, <View
]} {...(dragHandleProps?.attributes as any)}
numberOfLines={1} {...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
style={styles.workspaceRowLeft}
> >
{workspace.name} <WorkspaceStatusIndicator
</Text> bucket={workspace.statusBucket}
</View> workspaceKind={workspace.workspaceKind}
<View style={styles.workspaceRowRight}> loading={isArchiving || isCreating}
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null} />
{onArchive && (isHovered || isMobile) ? ( <Text
<DropdownMenu> style={[
<DropdownMenuTrigger styles.workspaceBranchText,
hitSlop={8} isHovered && styles.workspaceBranchTextHovered,
style={({ hovered = false }) => [ isCreating && styles.workspaceBranchTextCreating,
styles.kebabButton, ]}
hovered && styles.kebabButtonHovered, numberOfLines={1}
]} >
accessibilityRole="button" {workspace.name}
accessibilityLabel="Workspace actions" </Text>
testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`} </View>
> <View style={styles.workspaceRowRight}>
{({ hovered }) => ( {showGlobe ? (
<MoreVertical <View testID="workspace-globe-icon" accessibilityLabel="Services available">
size={14} <Globe size={12} color={theme.colors.foregroundMuted} />
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} </View>
/> ) : null}
)} {isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
</DropdownMenuTrigger> {onArchive && (isHovered || isMobile) ? (
<DropdownMenuContent align="end" width={260}> <DropdownMenu>
{onCopyPath ? ( <DropdownMenuTrigger
<DropdownMenuItem hitSlop={8}
testID={`sidebar-workspace-menu-copy-path-${workspace.workspaceKey}`} style={({ hovered = false }) => [
leading={<Copy size={14} color={theme.colors.foregroundMuted} />} styles.kebabButton,
onSelect={onCopyPath} hovered && styles.kebabButtonHovered,
> ]}
Copy path accessibilityRole="button"
</DropdownMenuItem> accessibilityLabel="Workspace actions"
) : null} testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`}
{onCopyBranchName ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-branch-name-${workspace.workspaceKey}`}
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
onSelect={onCopyBranchName}
>
Copy branch name
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
trailing={archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null}
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
> >
{archiveLabel ?? "Archive"} {({ hovered }) => (
</DropdownMenuItem> <MoreVertical
</DropdownMenuContent> size={14}
</DropdownMenu> color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
) : workspace.diffStat ? ( />
<View style={styles.diffStatRow}> )}
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text> </DropdownMenuTrigger>
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text> <DropdownMenuContent align="end" width={260}>
</View> {onCopyPath ? (
) : null} <DropdownMenuItem
{showShortcutBadge && shortcutNumber !== null ? ( testID={`sidebar-workspace-menu-copy-path-${workspace.workspaceKey}`}
<View style={styles.shortcutBadge}> leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text> onSelect={onCopyPath}
</View> >
) : null} Copy path
</DropdownMenuItem>
) : null}
{onCopyBranchName ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-branch-name-${workspace.workspaceKey}`}
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
onSelect={onCopyBranchName}
>
Copy branch name
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
trailing={archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null}
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
>
{archiveLabel ?? "Archive"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : workspace.diffStat ? (
<View style={styles.diffStatRow}>
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
</View>
) : null}
{showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadge}>
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
</View>
) : null}
</View>
</View> </View>
</View> {prHint ? (
{prHint ? ( <View style={styles.workspacePrBadgeRow}>
<View style={styles.workspacePrBadgeRow}> <WorkspacePrBadge hint={prHint} />
<WorkspacePrBadge hint={prHint} /> </View>
</View> ) : null}
) : null} </Pressable>
</Pressable> </View>
</View> </WorkspaceHoverCard>
); );
} }
@@ -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<Rect> {
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<WorkspaceHoverCardProps>): ReactElement {
// Desktop-only: skip on non-web platforms
if (Platform.OS !== "web") {
return <>{children}</>;
}
return (
<WorkspaceHoverCardDesktop workspace={workspace} isDragging={isDragging}>
{children}
</WorkspaceHoverCardDesktop>
);
}
function WorkspaceHoverCardDesktop({
workspace,
isDragging,
children,
}: PropsWithChildren<WorkspaceHoverCardProps>): ReactElement {
const triggerRef = useRef<View>(null);
const [open, setOpen] = useState(false);
const graceTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<View
ref={triggerRef}
collapsable={false}
onPointerEnter={handleTriggerEnter}
onPointerLeave={handleTriggerLeave}
>
{children}
{open && hasServices ? (
<WorkspaceHoverCardContent
workspace={workspace}
triggerRef={triggerRef}
onContentEnter={handleContentEnter}
onContentLeave={handleContentLeave}
/>
) : null}
</View>
);
}
const GITHUB_PR_STATE_LABELS: Record<PrHint["state"], string> = {
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 <SyncedLoader size={11} color={theme.colors.palette.amber[500]} />;
}
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 (
<View style={styles.hoverStatusIcon}>
<KindIcon size={14} color={theme.colors.foregroundMuted} />
{dotColor ? (
<View
style={[
styles.hoverStatusDotOverlay,
{
backgroundColor: dotColor,
borderColor: theme.colors.surface1,
},
]}
/>
) : null}
</View>
);
}
function WorkspaceHoverCardContent({
workspace,
triggerRef,
onContentEnter,
onContentLeave,
}: {
workspace: SidebarWorkspaceEntry;
triggerRef: React.RefObject<View | null>;
onContentEnter: () => void;
onContentLeave: () => void;
}): ReactElement | null {
const { theme } = useUnistyles();
const bottomSheetInternal = useBottomSheetModalInternal(true);
const [triggerRect, setTriggerRect] = useState<Rect | null>(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 (
<Portal hostName={bottomSheetInternal?.hostName}>
<View pointerEvents="box-none" style={styles.portalOverlay}>
<Animated.View
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
onLayout={handleLayout}
onPointerEnter={onContentEnter}
onPointerLeave={onContentLeave}
accessibilityRole="menu"
accessibilityLabel="Workspace services"
testID="workspace-hover-card"
style={[
styles.card,
{
width: HOVER_CARD_WIDTH,
position: "absolute",
top: position?.y ?? -9999,
left: position?.x ?? -9999,
},
]}
>
<View style={styles.cardHeader}>
<HoverCardStatusIndicator workspace={workspace} />
<Text style={styles.cardTitle} numberOfLines={1} testID="hover-card-workspace-name">
{workspace.name}
</Text>
</View>
{workspace.diffStat ? (
<View style={styles.cardMetaRow}>
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
</View>
) : null}
{prHint ? (
<Pressable
style={styles.cardMetaRow}
onPress={() => void openExternalUrl(prHint.url)}
>
<GitPullRequest size={12} color={theme.colors.foregroundMuted} />
<Text style={styles.prBadgeText} numberOfLines={1}>
#{prHint.number} · {GITHUB_PR_STATE_LABELS[prHint.state]}
</Text>
</Pressable>
) : null}
<View style={styles.separator} />
<View style={styles.serviceList} testID="hover-card-service-list">
{workspace.services.map((service) => (
<Pressable
key={service.hostname}
accessibilityRole="link"
accessibilityLabel={`${service.serviceName} service`}
testID={`hover-card-service-${service.serviceName}`}
style={({ hovered }) => [
styles.serviceRow,
hovered && styles.serviceRowHovered,
]}
onPress={() => {
if (service.url) {
void openExternalUrl(service.url);
}
}}
disabled={!service.url}
>
<View
testID={`hover-card-service-status-${service.serviceName}`}
accessibilityLabel={service.status === "running" ? "Running" : "Stopped"}
style={[
styles.statusDot,
{
backgroundColor:
service.status === "running"
? theme.colors.palette.green[500]
: theme.colors.palette.red[500],
},
]}
/>
<Text style={styles.serviceName} numberOfLines={1}>
{service.serviceName}
</Text>
{service.url ? (
<ExternalLink size={12} color={theme.colors.foregroundMuted} />
) : null}
</Pressable>
))}
</View>
</Animated.View>
</View>
</Portal>
);
}
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,
},
}));
@@ -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<string, WorkspaceDescriptor>([
["/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<string, WorkspaceDescriptor>([
["/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([]);
});
});
@@ -51,6 +51,7 @@ import { resolveProjectPlacement } from "@/utils/project-placement";
import { buildDraftStoreKey } from "@/stores/draft-keys"; import { buildDraftStoreKey } from "@/stores/draft-keys";
import type { AttachmentMetadata } from "@/attachments/types"; import type { AttachmentMetadata } from "@/attachments/types";
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking"; 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 // Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store"; export type { DraftInput } from "@/stores/draft-store";
@@ -1112,11 +1113,27 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(message.payload.workspace)]); 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) => { const unsubWorkspaceSetupProgress = client.on("workspace_setup_progress", (message) => {
if (message.type !== "workspace_setup_progress") return; if (message.type !== "workspace_setup_progress") return;
applyWorkspaceSetupProgress(message.payload); 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) => { const unsubStatus = client.on("status", (message) => {
if (message.type !== "status") return; if (message.type !== "status") return;
const serverInfo = parseServerInfoStatusPayload(message.payload); const serverInfo = parseServerInfoStatusPayload(message.payload);
@@ -1465,7 +1482,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
unsubAgentStream(); unsubAgentStream();
unsubAgentTimeline(); unsubAgentTimeline();
unsubWorkspaceUpdate(); unsubWorkspaceUpdate();
unsubServiceStatusUpdate();
unsubWorkspaceSetupProgress(); unsubWorkspaceSetupProgress();
unsubWorkspaceSetupStatusResponse();
unsubStatus(); unsubStatus();
unsubPermissionRequest(); unsubPermissionRequest();
unsubPermissionResolved(); unsubPermissionResolved();
@@ -1491,6 +1510,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
setAgentTimelineCursor, setAgentTimelineCursor,
setInitializingAgents, setInitializingAgents,
setAgents, setAgents,
setWorkspaces,
mergeWorkspaces, mergeWorkspaces,
removeWorkspace, removeWorkspace,
removeWorkspaceSetup, removeWorkspaceSetup,
@@ -0,0 +1,19 @@
import type { ServiceStatusUpdateMessage } from "@server/shared/messages";
import type { WorkspaceDescriptor } from "@/stores/session-store";
export function patchWorkspaceServices(
workspaces: Map<string, WorkspaceDescriptor>,
update: ServiceStatusUpdateMessage["payload"],
): Map<string, WorkspaceDescriptor> {
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;
}
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { WorkspaceServicePayload } from "@server/shared/messages";
import { import {
appendMissingOrderKeys, appendMissingOrderKeys,
applyStoredOrdering, applyStoredOrdering,
@@ -19,7 +20,7 @@ function workspace(
Partial< Partial<
Pick< Pick<
WorkspaceDescriptor, WorkspaceDescriptor,
"projectDisplayName" | "projectRootPath" | "projectKind" | "workspaceKind" "projectDisplayName" | "projectRootPath" | "projectKind" | "workspaceKind" | "services"
> >
>, >,
): WorkspaceDescriptor { ): WorkspaceDescriptor {
@@ -34,9 +35,26 @@ function workspace(
status: input.status, status: input.status,
activityAt: input.activityAt, activityAt: input.activityAt,
diffStat: 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",
};
const stoppedService: WorkspaceServicePayload = {
serviceName: "api",
hostname: "main.api.localhost",
port: 3001,
url: "http://main.api.localhost:6767",
status: "stopped",
};
describe("applyStoredOrdering", () => { describe("applyStoredOrdering", () => {
it("keeps unknown items on the baseline while applying stored order", () => { it("keeps unknown items on the baseline while applying stored order", () => {
const result = applyStoredOrdering({ const result = applyStoredOrdering({
@@ -117,6 +135,27 @@ describe("buildSidebarProjectsFromWorkspaces", () => {
expect(projects[0]?.workspaces[0]?.statusBucket).toBe("failed"); 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", () => { it("preserves stored project order even when activity changes", () => {
const initialWorkspaces: WorkspaceDescriptor[] = [ const initialWorkspaces: WorkspaceDescriptor[] = [
workspace({ workspace({
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
import type { WorkspaceDescriptorPayload } from "@server/shared/messages";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
@@ -20,6 +21,8 @@ export interface SidebarWorkspaceEntry {
activityAt: Date | null; activityAt: Date | null;
statusBucket: SidebarStateBucket; statusBucket: SidebarStateBucket;
diffStat: { additions: number; deletions: number } | null; diffStat: { additions: number; deletions: number } | null;
services: WorkspaceDescriptor["services"];
hasRunningServices: boolean;
} }
export interface SidebarProjectEntry { export interface SidebarProjectEntry {
@@ -135,6 +138,8 @@ export function buildSidebarProjectsFromWorkspaces(input: {
activityAt: workspace.activityAt, activityAt: workspace.activityAt,
statusBucket: workspace.status, statusBucket: workspace.status,
diffStat: workspace.diffStat, diffStat: workspace.diffStat,
services: workspace.services,
hasRunningServices: workspace.services.some((service) => service.status === "running"),
}; };
project.workspaces.push(row); project.workspaces.push(row);
@@ -257,8 +262,12 @@ function toWorkspaceDescriptor(payload: {
name: string; name: string;
status: WorkspaceDescriptor["status"]; status: WorkspaceDescriptor["status"];
activityAt: string | null; activityAt: string | null;
services?: WorkspaceDescriptorPayload["services"];
}): WorkspaceDescriptor { }): WorkspaceDescriptor {
return normalizeWorkspaceDescriptor(payload); return normalizeWorkspaceDescriptor({
...payload,
services: payload.services ?? [],
});
} }
export function useSidebarWorkspacesList(options?: { export function useSidebarWorkspacesList(options?: {
+302 -167
View File
@@ -1,5 +1,6 @@
import { CheckCircle2, CircleAlert, SquareTerminal } from "lucide-react-native"; import { useCallback, useEffect, useRef, useState } from "react";
import { ScrollView, Text, View } from "react-native"; import { CheckCircle2, ChevronRight, CircleAlert, SquareTerminal } from "lucide-react-native";
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
import invariant from "tiny-invariant"; import invariant from "tiny-invariant";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Fonts } from "@/constants/theme"; import { Fonts } from "@/constants/theme";
@@ -7,6 +8,7 @@ import { usePaneContext } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
function useSetupPanelDescriptor( function useSetupPanelDescriptor(
target: { kind: "setup"; workspaceId: string }, 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") { if (status === "running") {
return "Running"; return <ActivityIndicator size={14} color={theme.colors.foreground} />;
} }
if (status === "completed") { if (status === "completed") {
return "Completed"; return <CheckCircle2 size={14} color={theme.colors.palette.green[500]} />;
} }
return "Failed"; return <CircleAlert size={14} color={theme.colors.palette.red[500]} />;
} }
function formatSetupStatus(status: "running" | "completed" | "failed" | null): string { function formatDuration(ms: number): string {
if (status === "running") { if (ms < 1000) return `${ms}ms`;
return "Running"; const seconds = Math.floor(ms / 1000);
} if (seconds < 60) return `${seconds}s`;
if (status === "completed") { const minutes = Math.floor(seconds / 60);
return "Completed"; const remainingSeconds = seconds % 60;
} return `${minutes}m ${remainingSeconds}s`;
if (status === "failed") { }
return "Failed";
} /**
return "Waiting for setup output"; * 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() { function SetupPanel() {
@@ -75,113 +93,233 @@ function SetupPanel() {
const { serverId, target } = usePaneContext(); const { serverId, target } = usePaneContext();
invariant(target.kind === "setup", "SetupPanel requires setup target"); invariant(target.kind === "setup", "SetupPanel requires setup target");
const client = useHostRuntimeClient(serverId);
const key = buildWorkspaceTabPersistenceKey({ const key = buildWorkspaceTabPersistenceKey({
serverId, serverId,
workspaceId: target.workspaceId, workspaceId: target.workspaceId,
}); });
const snapshot = useWorkspaceSetupStore((state) => (key ? state.snapshots[key] ?? null : null)); 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 commands = snapshot?.detail.commands ?? [];
const log = snapshot?.detail.log ?? ""; const log = snapshot?.detail.log ?? "";
const statusLabel = formatSetupStatus(snapshot?.status ?? null);
const hasNoSetupCommands = const hasNoSetupCommands =
snapshot?.status === "completed" && commands.length === 0 && log.trim().length === 0; snapshot?.status === "completed" && commands.length === 0 && log.trim().length === 0;
const isWaiting = !snapshot || (snapshot.status === "running" && commands.length === 0);
const [expandedIndices, setExpandedIndices] = useState<Set<number>>(new Set());
const [manuallyCollapsed, setManuallyCollapsed] = useState<Set<number>>(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 ( return (
<View style={styles.container} testID="workspace-setup-panel"> <ScrollView
<View style={styles.container}
style={styles.header} contentContainerStyle={styles.contentContainer}
accessible testID="workspace-setup-panel"
accessibilityLabel={`Workspace setup status: ${statusLabel}`} >
{/* Hidden element for status — preserves testID for E2E */}
<Text
style={styles.hiddenStatus}
testID="workspace-setup-status" testID="workspace-setup-status"
> >{statusLabel}</Text>
<Text style={styles.title}>Workspace setup</Text>
<View {isWaiting ? (
style={[ <View style={styles.waitingContainer}>
styles.statusBadge, <ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
snapshot?.status === "completed" && { <Text style={styles.waitingText}>Setting up workspace...</Text>
backgroundColor: theme.colors.palette.green[100], </View>
}, ) : hasNoSetupCommands ? (
snapshot?.status === "failed" && { <View style={styles.emptyContainer}>
backgroundColor: theme.colors.palette.red[100],
},
]}
>
<Text <Text
style={[ style={styles.emptyText}
styles.statusBadgeText, accessible
snapshot?.status === "completed" && { accessibilityLabel="No setup commands ran for this workspace"
color: theme.colors.palette.green[600],
},
snapshot?.status === "failed" && {
color: theme.colors.palette.red[600],
},
]}
> >
{statusLabel} No setup commands ran for this workspace.
</Text> </Text>
</View> </View>
</View> ) : (
<View style={styles.commandList}>
{commands.map((command) => {
const isExpanded = expandedIndices.has(command.index);
const hasError = command.status === "failed" && snapshot?.error;
{snapshot?.error ? ( // Per-command log: use command.log if available, fall back to detail.log for the auto-expand target
<View style={styles.errorCard}> const commandLog = (() => {
<Text style={styles.errorTitle}>Setup error</Text> if ("log" in command && typeof command.log === "string") {
<Text selectable style={styles.errorBody}> return command.log;
{snapshot.error} }
</Text> // Fallback: show detail.log on the auto-expand target command
</View> if (command.index === autoExpandIndex) return log;
) : null} return "";
})();
const hasLog = commandLog.trim().length > 0;
{commands.length > 0 ? ( // All non-running commands are expandable (completed/failed)
<View style={styles.section}> const isExpandable = command.status !== "running" || hasLog || !!hasError;
<Text style={styles.sectionTitle}>Commands</Text>
<View style={styles.commandList}> // Auto-expand the active command unless the user manually collapsed it
{commands.map((command) => ( const isAutoExpanded =
<View key={`${command.index}:${command.command}`} style={styles.commandRow}> command.index === autoExpandIndex && !manuallyCollapsed.has(command.index);
<Text style={styles.commandIndex}>{command.index}.</Text> const showDetail = isExpanded || isAutoExpanded;
<View style={styles.commandTextColumn}>
<Text selectable style={styles.commandText}> const processedLog = hasLog ? processCarriageReturns(commandLog) : "";
return (
<View key={`${command.index}:${command.command}`} style={styles.commandItem}>
<Pressable
onPress={() => toggleExpanded(command.index, isAutoExpanded)}
style={({ pressed }) => [
styles.commandRow,
showDetail && styles.commandRowExpanded,
pressed && styles.commandRowPressed,
]}
accessibilityRole="button"
accessibilityState={{ expanded: showDetail }}
>
<View style={styles.commandStatusIcon}>
<CommandStatusIcon status={command.status} />
</View>
<Text style={styles.commandText} numberOfLines={1}>
{command.command} {command.command}
</Text> </Text>
<Text style={styles.commandMeta}> {command.durationMs != null ? (
{formatCommandStatus(command.status)} <Text style={styles.commandDuration}>
{typeof command.exitCode === "number" ? ` · exit ${command.exitCode}` : ""} {formatDuration(command.durationMs)}
</Text> </Text>
</View> ) : null}
<ChevronRight
size={14}
color={theme.colors.foregroundMuted}
style={[
styles.chevron,
showDetail && styles.chevronExpanded,
]}
/>
</Pressable>
{showDetail ? (
<View style={styles.commandDetail}>
{hasLog ? (
<ScrollView
style={styles.logScroll}
contentContainerStyle={styles.logScrollContent}
horizontal={false}
showsVerticalScrollIndicator
testID="workspace-setup-log"
accessible
accessibilityLabel="Workspace setup log"
>
<Text selectable style={styles.logText}>
{processedLog}
</Text>
</ScrollView>
) : (
<View
style={styles.logScrollContent}
testID="workspace-setup-log"
accessible
accessibilityLabel="Workspace setup log"
>
<Text style={styles.emptyLogText}>No output</Text>
</View>
)}
{hasError ? (
<View style={styles.errorCard}>
<Text selectable style={styles.errorText}>
{snapshot.error}
</Text>
</View>
) : null}
</View>
) : null}
</View> </View>
))} );
</View> })}
</View>
) : null}
<View style={styles.sectionFill}> {/* If there's log but no commands yet, or log without a target command, show standalone */}
<Text style={styles.sectionTitle}>Log</Text> {commands.length === 0 && log.trim().length > 0 ? (
{hasNoSetupCommands ? ( <ScrollView
<View style={styles.emptyCard}> style={styles.logScroll}
<Text contentContainerStyle={styles.logScrollContent}
style={styles.emptyText} showsVerticalScrollIndicator
testID="workspace-setup-log"
accessible accessible
accessibilityLabel="No setup commands ran for this workspace" accessibilityLabel="Workspace setup log"
> >
No setup commands ran for this workspace. <Text selectable style={styles.logText}>
</Text> {log}
</View> </Text>
) : ( </ScrollView>
<ScrollView ) : null}
style={styles.logContainer}
contentContainerStyle={styles.logContent} {/* Show error at top level if no commands failed but there's a setup error */}
showsVerticalScrollIndicator {snapshot?.error && !commands.some((c) => c.status === "failed") ? (
testID="workspace-setup-log" <View style={styles.errorCard}>
accessible <Text selectable style={styles.errorText}>
accessibilityLabel="Workspace setup log" {snapshot.error}
> </Text>
<Text selectable style={styles.logText}> </View>
{log.trim().length > 0 ? log : "Waiting for setup output..."} ) : null}
</Text> </View>
</ScrollView> )}
)} </ScrollView>
</View>
</View>
); );
} }
@@ -195,100 +333,93 @@ const styles = StyleSheet.create((theme) => ({
container: { container: {
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
padding: theme.spacing[4],
gap: theme.spacing[4],
backgroundColor: theme.colors.surface0, backgroundColor: theme.colors.surface0,
}, },
header: { contentContainer: {
flexDirection: "row", padding: theme.spacing[4],
flexGrow: 1,
},
hiddenStatus: {
position: "absolute",
width: 1,
height: 1,
overflow: "hidden",
opacity: 0,
},
waitingContainer: {
flex: 1,
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "center",
gap: theme.spacing[3], gap: theme.spacing[3],
}, },
title: { waitingText: {
fontSize: theme.fontSize.lg,
fontWeight: "600",
color: theme.colors.foreground,
},
statusBadge: {
borderRadius: theme.borderRadius.full,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
backgroundColor: theme.colors.surface2,
},
statusBadgeText: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontWeight: "600",
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}, },
errorCard: { emptyContainer: {
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.palette.red[200],
backgroundColor: theme.colors.palette.red[100],
padding: theme.spacing[3],
gap: theme.spacing[2],
},
errorTitle: {
fontSize: theme.fontSize.sm,
fontWeight: "600",
color: theme.colors.palette.red[800],
},
errorBody: {
fontSize: theme.fontSize.sm,
color: theme.colors.palette.red[800],
},
section: {
gap: theme.spacing[2],
},
sectionFill: {
flex: 1, flex: 1,
minHeight: 0, alignItems: "center",
gap: theme.spacing[2], justifyContent: "center",
}, },
sectionTitle: { emptyText: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontWeight: "600",
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
textTransform: "uppercase",
letterSpacing: 0.5,
}, },
commandList: { commandList: {
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
commandItem: {
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
overflow: "hidden",
},
commandRow: { commandRow: {
flexDirection: "row", flexDirection: "row",
alignItems: "flex-start", alignItems: "center",
gap: theme.spacing[2], gap: theme.spacing[2],
borderRadius: theme.borderRadius.md, paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
backgroundColor: theme.colors.surface1, 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, width: 18,
fontSize: theme.fontSize.sm, height: 18,
color: theme.colors.foregroundMuted, alignItems: "center",
}, justifyContent: "center",
commandTextColumn: { flexShrink: 0,
flex: 1,
gap: theme.spacing[1],
}, },
commandText: { commandText: {
flex: 1,
fontFamily: Fonts.mono, fontFamily: Fonts.mono,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
color: theme.colors.foreground, color: theme.colors.foreground,
}, },
commandMeta: { commandDuration: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
flexShrink: 0,
}, },
logContainer: { chevron: {
flex: 1, flexShrink: 0,
minHeight: 0,
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface1,
}, },
logContent: { chevronExpanded: {
transform: [{ rotate: "90deg" }],
},
commandDetail: {
backgroundColor: theme.colors.surface0,
},
logScroll: {
maxHeight: 400,
},
logScrollContent: {
padding: theme.spacing[3], padding: theme.spacing[3],
}, },
logText: { logText: {
@@ -297,13 +428,17 @@ const styles = StyleSheet.create((theme) => ({
lineHeight: 20, lineHeight: 20,
color: theme.colors.foreground, color: theme.colors.foreground,
}, },
emptyCard: { emptyLogText: {
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface1,
padding: theme.spacing[3],
},
emptyText: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted, 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],
}, },
})); }));
@@ -19,6 +19,7 @@ describe("workspace source of truth consumption", () => {
status: "running", status: "running",
activityAt: new Date("2026-03-01T00:00:00.000Z"), activityAt: new Date("2026-03-01T00:00:00.000Z"),
diffStat: null, diffStat: null,
services: [],
}; };
const header = resolveWorkspaceHeader({ workspace }); const header = resolveWorkspaceHeader({ workspace });
@@ -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<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
): 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",
},
]);
});
});
+2
View File
@@ -119,6 +119,7 @@ export interface WorkspaceDescriptor {
status: WorkspaceDescriptorPayload["status"]; status: WorkspaceDescriptorPayload["status"];
activityAt: Date | null; activityAt: Date | null;
diffStat: { additions: number; deletions: number } | null; diffStat: { additions: number; deletions: number } | null;
services: WorkspaceDescriptorPayload["services"];
} }
export function normalizeWorkspaceDescriptor( export function normalizeWorkspaceDescriptor(
@@ -136,6 +137,7 @@ export function normalizeWorkspaceDescriptor(
status: payload.status, status: payload.status,
activityAt: activityAt && !Number.isNaN(activityAt.getTime()) ? activityAt : null, activityAt: activityAt && !Number.isNaN(activityAt.getTime()) ? activityAt : null,
diffStat: payload.diffStat ?? null, diffStat: payload.diffStat ?? null,
services: (payload.services ?? []).map((s) => ({ ...s })),
}; };
} }
@@ -18,6 +18,8 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
activityAt: null, activityAt: null,
statusBucket: "done", statusBucket: "done",
diffStat: null, diffStat: null,
services: [],
hasRunningServices: false,
...overrides, ...overrides,
}; };
} }
@@ -87,8 +89,8 @@ describe("buildSidebarProjectRowModel", () => {
}); });
}); });
it("flattens git projects with a single workspace and keeps the new worktree action", () => { it("keeps single-workspace git projects as sections with a new worktree action", () => {
const flattenedWorkspace = workspace({ const workspaceEntry = workspace({
workspaceId: "/repo/main", workspaceId: "/repo/main",
workspaceKind: "local_checkout", workspaceKind: "local_checkout",
}); });
@@ -96,16 +98,14 @@ describe("buildSidebarProjectRowModel", () => {
const result = buildSidebarProjectRowModel({ const result = buildSidebarProjectRowModel({
project: project({ project: project({
projectKind: "git", projectKind: "git",
workspaces: [flattenedWorkspace], workspaces: [workspaceEntry],
}), }),
collapsed: true, collapsed: true,
}); });
expect(result).toEqual({ expect(result).toEqual({
kind: "workspace_link", kind: "project_section",
workspace: flattenedWorkspace, chevron: "expand",
selected: false,
chevron: null,
trailingAction: "new_worktree", trailingAction: "new_worktree",
}); });
}); });
@@ -131,10 +131,10 @@ describe("buildSidebarProjectRowModel", () => {
}); });
describe("isSidebarProjectFlattened", () => { describe("isSidebarProjectFlattened", () => {
it("returns true for single-workspace projects regardless of kind", () => { it("returns true only for single-workspace non-git projects", () => {
expect( expect(
isSidebarProjectFlattened(project({ projectKind: "git", workspaces: [workspace()] })), isSidebarProjectFlattened(project({ projectKind: "git", workspaces: [workspace()] })),
).toBe(true); ).toBe(false);
expect( expect(
isSidebarProjectFlattened(project({ projectKind: "non_git", workspaces: [workspace()] })), isSidebarProjectFlattened(project({ projectKind: "non_git", workspaces: [workspace()] })),
).toBe(true); ).toBe(true);
@@ -16,6 +16,8 @@ function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry {
activityAt: null, activityAt: null,
statusBucket: "done", statusBucket: "done",
diffStat: null, diffStat: null,
services: [],
hasRunningServices: false,
}; };
} }
@@ -76,7 +78,7 @@ describe("buildSidebarShortcutModel", () => {
expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "/repo/w9" }); 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 projects = [project("p1", [workspace("s1", "/repo/main")])];
const model = buildSidebarShortcutModel({ const model = buildSidebarShortcutModel({
@@ -84,7 +86,7 @@ describe("buildSidebarShortcutModel", () => {
collapsedProjectKeys: new Set<string>(["p1"]), collapsedProjectKeys: new Set<string>(["p1"]),
}); });
expect(model.visibleTargets).toEqual([{ serverId: "s1", workspaceId: "/repo/main" }]); expect(model.visibleTargets).toEqual([]);
expect(model.shortcutTargets).toEqual([{ serverId: "s1", workspaceId: "/repo/main" }]); expect(model.shortcutTargets).toEqual([]);
}); });
}); });
@@ -96,6 +96,7 @@ describe("tool-call-display", () => {
index: 1, index: 1,
command: "npm install", command: "npm install",
cwd: "/tmp/repo/.paseo/worktrees/repo/branch", cwd: "/tmp/repo/.paseo/worktrees/repo/branch",
log: "",
status: "running", status: "running",
exitCode: null, exitCode: null,
}, },
@@ -19,6 +19,7 @@ function workspace(
status: input.status ?? "done", status: input.status ?? "done",
activityAt: input.activityAt ?? null, activityAt: input.activityAt ?? null,
diffStat: input.diffStat ?? null, diffStat: input.diffStat ?? null,
services: input.services ?? [],
}; };
} }
@@ -251,6 +251,7 @@ describe("DaemonClient", () => {
index: 1, index: 1,
command: "npm install", command: "npm install",
cwd: "/tmp/project/.paseo/worktrees/feature-a", cwd: "/tmp/project/.paseo/worktrees/feature-a",
log: "phase-one\n",
status: "running", status: "running",
exitCode: null, exitCode: null,
}, },
@@ -277,6 +278,7 @@ describe("DaemonClient", () => {
index: 1, index: 1,
command: "npm install", command: "npm install",
cwd: "/tmp/project/.paseo/worktrees/feature-a", cwd: "/tmp/project/.paseo/worktrees/feature-a",
log: "phase-one\n",
status: "running", status: "running",
exitCode: null, exitCode: null,
}, },
@@ -36,6 +36,7 @@ import type {
ProjectIconResponse, ProjectIconResponse,
OpenProjectResponseMessage, OpenProjectResponseMessage,
ArchiveWorkspaceResponseMessage, ArchiveWorkspaceResponseMessage,
WorkspaceSetupStatusResponseMessage,
ListCommandsResponse, ListCommandsResponse,
ListProviderModelsResponseMessage, ListProviderModelsResponseMessage,
ListAvailableProvidersResponse, ListAvailableProvidersResponse,
@@ -462,6 +463,7 @@ export type InspectScheduleOptions = {
}; };
type OpenProjectPayload = OpenProjectResponseMessage["payload"]; type OpenProjectPayload = OpenProjectResponseMessage["payload"];
type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"]; type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"];
type WorkspaceSetupStatusPayload = WorkspaceSetupStatusResponseMessage["payload"];
export type FetchAgentResult = { export type FetchAgentResult = {
agent: AgentSnapshotPayload; agent: AgentSnapshotPayload;
@@ -1318,6 +1320,21 @@ export class DaemonClient {
}); });
} }
async fetchWorkspaceSetupStatus(
workspaceId: string,
requestId?: string,
): Promise<WorkspaceSetupStatusPayload> {
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<FetchAgentResult | null> { async fetchAgent(agentId: string, requestId?: string): Promise<FetchAgentResult | null> {
const resolvedRequestId = this.createRequestId(requestId); const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({ const message = SessionInboundMessageSchema.parse({
@@ -178,6 +178,7 @@ export type ToolCallDetail =
index: number; index: number;
command: string; command: string;
cwd: string; cwd: string;
log: string;
status: "running" | "completed" | "failed"; status: "running" | "completed" | "failed";
exitCode: number | null; exitCode: number | null;
durationMs?: number; durationMs?: number;
+17 -1
View File
@@ -118,6 +118,8 @@ import {
createServiceProxyMiddleware, createServiceProxyMiddleware,
createServiceProxyUpgradeHandler, createServiceProxyUpgradeHandler,
} from "./service-proxy.js"; } from "./service-proxy.js";
import { ServiceHealthMonitor } from "./service-health-monitor.js";
import { createServiceStatusEmitter } from "./service-status-projection.js";
import { import {
createVoiceMcpSocketBridgeManager, createVoiceMcpSocketBridgeManager,
type VoiceMcpSocketBridgeManager, type VoiceMcpSocketBridgeManager,
@@ -225,6 +227,18 @@ export async function createPaseoDaemon(
let boundListenTarget: ListenTarget | null = null; let boundListenTarget: ListenTarget | null = null;
const serviceRouteStore = new ServiceRouteStore(); 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). // Host allowlist / DNS rebinding protection (vite-like semantics).
// For non-TCP (unix sockets), skip host validation. // 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)", "Voice mode configured for agent-scoped resume flow (no dedicated voice assistant provider)",
); );
logger.info({ elapsed: elapsed() }, "Preparing voice and MCP runtime"); logger.info({ elapsed: elapsed() }, "Preparing voice and MCP runtime");
let wsServer: VoiceAssistantWebSocketServer | null = null;
let voiceMcpBridgeManager: VoiceMcpSocketBridgeManager | null = null; let voiceMcpBridgeManager: VoiceMcpSocketBridgeManager | null = null;
// Create in-memory transport for Session's Agent MCP client (voice assistant tools) // Create in-memory transport for Session's Agent MCP client (voice assistant tools)
@@ -676,6 +689,7 @@ export async function createPaseoDaemon(
checkoutDiffManager, checkoutDiffManager,
serviceRouteStore, serviceRouteStore,
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
(hostname) => serviceHealthMonitor.getStatusForHostname(hostname),
); );
logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening"); 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 // Start speech service after listening so synchronous Sherpa native
// model loading doesn't block the server from accepting connections. // model loading doesn't block the server from accepting connections.
speechService.start(); speechService.start();
serviceHealthMonitor.start();
}; };
const stop = async () => { const stop = async () => {
serviceHealthMonitor.stop();
await closeAllAgents(logger, agentManager); await closeAllAgents(logger, agentManager);
await agentManager.flush().catch(() => undefined); await agentManager.flush().catch(() => undefined);
detachAgentStoragePersistence(); detachAgentStoragePersistence();
@@ -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<TcpServerHandle> {
const server = net.createServer((socket) => {
socket.end();
});
await new Promise<void>((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<void> {
if (!server.listening) {
return;
}
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
async function advancePoll(ms: number): Promise<void> {
await vi.advanceTimersByTimeAsync(ms);
for (let i = 0; i < 5; i += 1) {
await scheduler.yield();
}
}
describe("ServiceHealthMonitor", () => {
const servers = new Set<net.Server>();
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",
},
]);
});
});
@@ -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<string, RouteHealthState>();
private readonly lastEmittedSnapshots = new Map<string, string>();
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<void> {
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<string>();
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<string>): 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<boolean> {
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));
});
}
}
+149 -18
View File
@@ -28,9 +28,14 @@ function closeServer(server: http.Server): Promise<void> {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe("ServiceRouteStore", () => { describe("ServiceRouteStore", () => {
it("addRoute and findRoute with exact match", () => { it("registerRoute and findRoute with exact match", () => {
const store = new ServiceRouteStore(); 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"); const route = store.findRoute("editor.localhost");
expect(route).toEqual({ hostname: "editor.localhost", port: 3000 }); expect(route).toEqual({ hostname: "editor.localhost", port: 3000 });
@@ -38,7 +43,12 @@ describe("ServiceRouteStore", () => {
it("findRoute strips port from host header", () => { it("findRoute strips port from host header", () => {
const store = new ServiceRouteStore(); 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"); const route = store.findRoute("editor.localhost:6767");
expect(route).toEqual({ hostname: "editor.localhost", port: 3000 }); expect(route).toEqual({ hostname: "editor.localhost", port: 3000 });
@@ -46,25 +56,132 @@ describe("ServiceRouteStore", () => {
it("findRoute subdomain match", () => { it("findRoute subdomain match", () => {
const store = new ServiceRouteStore(); 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"); const route = store.findRoute("fix-auth.editor.localhost");
expect(route).toEqual({ hostname: "editor.localhost", port: 3000 }); 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", () => { it("removeRoute works", () => {
const store = new ServiceRouteStore(); 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"); store.removeRoute("editor.localhost");
expect(store.findRoute("editor.localhost")).toBeNull(); 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", () => { it("removeRoutesForPort works", () => {
const store = new ServiceRouteStore(); const store = new ServiceRouteStore();
store.addRoute("a.localhost", 3000); store.registerRoute({
store.addRoute("b.localhost", 3000); hostname: "a.localhost",
store.addRoute("c.localhost", 4000); 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); 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(); 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(); const store = new ServiceRouteStore();
store.addRoute("a.localhost", 3000); store.registerRoute({
store.addRoute("b.localhost", 4000); hostname: "editor.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "editor",
});
const routes = store.listRoutes(); expect(store.findRoute("unknown.example.com")).toBeNull();
expect(routes).toHaveLength(2);
expect(routes).toContainEqual({ hostname: "a.localhost", port: 3000 });
expect(routes).toContainEqual({ hostname: "b.localhost", port: 4000 });
}); });
}); });
+74 -15
View File
@@ -29,21 +29,49 @@ export interface ServiceRoute {
port: number; port: number;
} }
export interface ServiceRouteEntry extends ServiceRoute {
workspaceId: string;
serviceName: string;
}
export class ServiceRouteStore { export class ServiceRouteStore {
private routes = new Map<string, number>(); private routes = new Map<string, ServiceRouteEntry>();
private workspaceHostnames = new Map<string, Set<string>>();
addRoute(hostname: string, port: number): void { 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 { removeRoute(hostname: string): void {
const entry = this.routes.get(hostname);
if (!entry) {
return;
}
this.routes.delete(hostname); this.routes.delete(hostname);
this.removeHostnameFromWorkspaceIndex(entry.workspaceId, hostname);
} }
removeRoutesForPort(port: number): void { removeRoutesForPort(port: number): void {
for (const [hostname, p] of this.routes) { for (const [hostname, entry] of this.routes) {
if (p === port) { if (entry.port === port) {
this.routes.delete(hostname); this.routes.delete(hostname);
this.removeHostnameFromWorkspaceIndex(entry.workspaceId, hostname);
} }
} }
} }
@@ -53,29 +81,60 @@ export class ServiceRouteStore {
const hostname = host.replace(/:\d+$/, ""); const hostname = host.replace(/:\d+$/, "");
// 1. Exact match // 1. Exact match
const exactPort = this.routes.get(hostname); const exactRoute = this.routes.get(hostname);
if (exactPort !== undefined) { if (exactRoute !== undefined) {
return { hostname, port: exactPort }; return { hostname: exactRoute.hostname, port: exactRoute.port };
} }
// 2. Subdomain match — walk up the labels looking for a registered parent // 2. Subdomain match — walk up the labels looking for a registered parent
const parts = hostname.split("."); const parts = hostname.split(".");
for (let i = 1; i < parts.length; i++) { for (let i = 1; i < parts.length; i++) {
const candidate = parts.slice(i).join("."); const candidate = parts.slice(i).join(".");
const candidatePort = this.routes.get(candidate); const candidateRoute = this.routes.get(candidate);
if (candidatePort !== undefined) { if (candidateRoute !== undefined) {
return { hostname: candidate, port: candidatePort }; return { hostname: candidateRoute.hostname, port: candidateRoute.port };
} }
} }
return null; return null;
} }
listRoutes(): ServiceRoute[] { listRoutes(): ServiceRouteEntry[] {
return Array.from(this.routes.entries()).map(([hostname, port]) => ({ return Array.from(this.routes.values()).map((entry) => ({ ...entry }));
hostname, }
port,
})); 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<string>();
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);
}
} }
} }
@@ -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<string, "running" | "stopped">([
["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",
},
],
},
});
});
});
@@ -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);
}
};
}
+39
View File
@@ -28,6 +28,7 @@ import {
type UnsubscribeCheckoutDiffRequest, type UnsubscribeCheckoutDiffRequest,
type DirectorySuggestionsRequest, type DirectorySuggestionsRequest,
type ProjectPlacementPayload, type ProjectPlacementPayload,
type WorkspaceSetupSnapshot,
type WorkspaceDescriptorPayload, type WorkspaceDescriptorPayload,
type WorkspaceStateBucket, type WorkspaceStateBucket,
} from "./messages.js"; } from "./messages.js";
@@ -62,6 +63,7 @@ import {
import { experimental_createMCPClient } from "ai"; import { experimental_createMCPClient } from "ai";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js"; import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js";
import { buildWorkspaceServicePayloads } from "./service-status-projection.js";
export type AgentMcpTransportFactory = () => Promise<Transport>; export type AgentMcpTransportFactory = () => Promise<Transport>;
import { buildProviderRegistry } from "./agent/provider-registry.js"; import { buildProviderRegistry } from "./agent/provider-registry.js";
@@ -180,6 +182,7 @@ import {
handleCreatePaseoWorktreeRequest as handleCreateWorktreeRequest, handleCreatePaseoWorktreeRequest as handleCreateWorktreeRequest,
handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest, handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest,
handlePaseoWorktreeListRequest as handleWorktreeListRequest, handlePaseoWorktreeListRequest as handleWorktreeListRequest,
handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage,
killTerminalsUnderPath as killWorktreeTerminalsUnderPath, killTerminalsUnderPath as killWorktreeTerminalsUnderPath,
registerPendingWorktreeWorkspace as registerPendingWorktreeWorkspaceSession, registerPendingWorktreeWorkspace as registerPendingWorktreeWorkspaceSession,
} from "./worktree-session.js"; } from "./worktree-session.js";
@@ -375,6 +378,7 @@ export type SessionOptions = {
terminalManager: TerminalManager | null; terminalManager: TerminalManager | null;
serviceRouteStore?: ServiceRouteStore; serviceRouteStore?: ServiceRouteStore;
getDaemonTcpPort?: () => number | null; getDaemonTcpPort?: () => number | null;
resolveServiceStatus?: (hostname: string) => "running" | "stopped" | null;
voice?: { voice?: {
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null; voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
turnDetection?: Resolvable<TurnDetectionProvider | null>; turnDetection?: Resolvable<TurnDetectionProvider | null>;
@@ -572,6 +576,7 @@ export class Session {
private readonly terminalManager: TerminalManager | null; private readonly terminalManager: TerminalManager | null;
private readonly serviceRouteStore: ServiceRouteStore | null; private readonly serviceRouteStore: ServiceRouteStore | null;
private readonly getDaemonTcpPort: (() => number | null) | null; private readonly getDaemonTcpPort: (() => number | null) | null;
private readonly resolveServiceStatus: ((hostname: string) => "running" | "stopped" | null) | null;
private readonly subscribedTerminalDirectories = new Set<string>(); private readonly subscribedTerminalDirectories = new Set<string>();
private unsubscribeTerminalsChanged: (() => void) | null = null; private unsubscribeTerminalsChanged: (() => void) | null = null;
private terminalExitSubscriptions: Map<string, () => void> = new Map(); private terminalExitSubscriptions: Map<string, () => void> = new Map();
@@ -582,6 +587,7 @@ export class Session {
private peakInflightRequests = 0; private peakInflightRequests = 0;
private readonly checkoutDiffSubscriptions = new Map<string, () => void>(); private readonly checkoutDiffSubscriptions = new Map<string, () => void>();
private readonly workspaceGitWatchTargets = new Map<string, WorkspaceGitWatchTarget>(); private readonly workspaceGitWatchTargets = new Map<string, WorkspaceGitWatchTarget>();
private readonly workspaceSetupSnapshots = new Map<string, WorkspaceSetupSnapshot>();
private readonly voiceAgentMcpStdio: VoiceMcpStdioConfig | null; private readonly voiceAgentMcpStdio: VoiceMcpStdioConfig | null;
private readonly registerVoiceSpeakHandler?: ( private readonly registerVoiceSpeakHandler?: (
agentId: string, agentId: string,
@@ -625,6 +631,7 @@ export class Session {
terminalManager, terminalManager,
serviceRouteStore, serviceRouteStore,
getDaemonTcpPort, getDaemonTcpPort,
resolveServiceStatus,
voice, voice,
voiceBridge, voiceBridge,
dictation, dictation,
@@ -651,6 +658,7 @@ export class Session {
this.terminalManager = terminalManager; this.terminalManager = terminalManager;
this.serviceRouteStore = serviceRouteStore ?? null; this.serviceRouteStore = serviceRouteStore ?? null;
this.getDaemonTcpPort = getDaemonTcpPort ?? null; this.getDaemonTcpPort = getDaemonTcpPort ?? null;
this.resolveServiceStatus = resolveServiceStatus ?? null;
if (this.terminalManager) { if (this.terminalManager) {
this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) => this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) =>
this.handleTerminalsChanged(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 * Send initial state to client after connection
*/ */
@@ -1617,6 +1629,10 @@ export class Session {
await this.handleCreatePaseoWorktreeRequest(msg); await this.handleCreatePaseoWorktreeRequest(msg);
break; break;
case "workspace_setup_status_request":
await this.handleWorkspaceSetupStatusRequest(msg);
break;
case "open_project_request": case "open_project_request":
await this.handleOpenProjectRequest(msg); await this.handleOpenProjectRequest(msg);
break; break;
@@ -4918,6 +4934,14 @@ export class Session {
status: "done", status: "done",
activityAt: null, activityAt: null,
diffStat, 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, paseoHome: this.paseoHome,
emitWorkspaceUpdateForCwd: (cwd, emitOptions) => emitWorkspaceUpdateForCwd: (cwd, emitOptions) =>
this.emitWorkspaceUpdateForCwd(cwd, emitOptions), this.emitWorkspaceUpdateForCwd(cwd, emitOptions),
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => {
this.workspaceSetupSnapshots.set(workspaceId, snapshot);
},
emit: (message) => this.emit(message), emit: (message) => this.emit(message),
sessionLogger: this.sessionLogger, sessionLogger: this.sessionLogger,
terminalManager: this.terminalManager, terminalManager: this.terminalManager,
@@ -5576,6 +5603,18 @@ export class Session {
); );
} }
private async handleWorkspaceSetupStatusRequest(
request: Extract<SessionInboundMessage, { type: "workspace_setup_status_request" }>,
): Promise<void> {
return handleWorkspaceSetupStatusRequestMessage(
{
emit: (message) => this.emit(message),
workspaceSetupSnapshots: this.workspaceSetupSnapshots,
},
request,
);
}
private async handleArchiveWorkspaceRequest( private async handleArchiveWorkspaceRequest(
request: Extract<SessionInboundMessage, { type: "archive_workspace_request" }>, request: Extract<SessionInboundMessage, { type: "archive_workspace_request" }>,
): Promise<void> { ): Promise<void> {
@@ -244,6 +244,7 @@ export class VoiceAssistantWebSocketServer {
private readonly terminalManager: TerminalManager | null; private readonly terminalManager: TerminalManager | null;
private readonly serviceRouteStore: ServiceRouteStore | null; private readonly serviceRouteStore: ServiceRouteStore | null;
private readonly getDaemonTcpPort: (() => number | null) | null; private readonly getDaemonTcpPort: (() => number | null) | null;
private readonly resolveServiceStatus: ((hostname: string) => "running" | "stopped" | null) | null;
private readonly dictation: { private readonly dictation: {
finalTimeoutMs?: number; finalTimeoutMs?: number;
} | null; } | null;
@@ -312,6 +313,7 @@ export class VoiceAssistantWebSocketServer {
checkoutDiffManager?: CheckoutDiffManager, checkoutDiffManager?: CheckoutDiffManager,
serviceRouteStore?: ServiceRouteStore | null, serviceRouteStore?: ServiceRouteStore | null,
getDaemonTcpPort?: () => number | null, getDaemonTcpPort?: () => number | null,
resolveServiceStatus?: (hostname: string) => "running" | "stopped" | null,
) { ) {
this.logger = logger.child({ module: "websocket-server" }); this.logger = logger.child({ module: "websocket-server" });
this.serverId = serverId; this.serverId = serverId;
@@ -350,6 +352,7 @@ export class VoiceAssistantWebSocketServer {
this.onLifecycleIntent = onLifecycleIntent ?? null; this.onLifecycleIntent = onLifecycleIntent ?? null;
this.serviceRouteStore = serviceRouteStore ?? null; this.serviceRouteStore = serviceRouteStore ?? null;
this.getDaemonTcpPort = getDaemonTcpPort ?? null; this.getDaemonTcpPort = getDaemonTcpPort ?? null;
this.resolveServiceStatus = resolveServiceStatus ?? null;
this.serverCapabilities = buildServerCapabilities({ this.serverCapabilities = buildServerCapabilities({
readiness: this.speech?.getReadiness() ?? null, 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 { public publishSpeechReadiness(readiness: SpeechReadinessSnapshot | null): void {
this.updateServerCapabilities(buildServerCapabilities({ readiness })); this.updateServerCapabilities(buildServerCapabilities({ readiness }));
} }
@@ -652,6 +665,7 @@ export class VoiceAssistantWebSocketServer {
terminalManager: this.terminalManager, terminalManager: this.terminalManager,
serviceRouteStore: this.serviceRouteStore ?? undefined, serviceRouteStore: this.serviceRouteStore ?? undefined,
getDaemonTcpPort: this.getDaemonTcpPort ?? undefined, getDaemonTcpPort: this.getDaemonTcpPort ?? undefined,
resolveServiceStatus: this.resolveServiceStatus ?? undefined,
voice: { voice: {
...(this.voice ?? {}), ...(this.voice ?? {}),
turnDetection: () => this.speech?.resolveTurnDetection() ?? null, turnDetection: () => this.speech?.resolveTurnDetection() ?? null,
@@ -5,7 +5,12 @@ import { join } from "path";
import { tmpdir } from "os"; import { tmpdir } from "os";
import type { AgentTimelineItem } from "./agent/agent-sdk-types.js"; 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", () => { describe("runAsyncWorktreeBootstrap", () => {
let tempDir: string; let tempDir: string;
@@ -114,12 +119,15 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(persistedSetupItems[0].detail.commands[0]).toMatchObject({ expect(persistedSetupItems[0].detail.commands[0]).toMatchObject({
index: 1, index: 1,
command: 'echo "line-one"; echo "line-two" 1>&2', command: 'echo "line-one"; echo "line-two" 1>&2',
log: expect.stringContaining("line-one"),
status: "completed", status: "completed",
exitCode: 0, exitCode: 0,
}); });
expect(persistedSetupItems[0].detail.commands[0]?.log).toContain("line-two");
expect(persistedSetupItems[0].detail.commands[1]).toMatchObject({ expect(persistedSetupItems[0].detail.commands[1]).toMatchObject({
index: 2, index: 2,
command: 'echo "line-three"', command: 'echo "line-three"',
log: "line-three\n",
status: "completed", status: "completed",
exitCode: 0, exitCode: 0,
}); });
@@ -247,6 +255,64 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(persistedSetupItem.detail.log).toContain("prefix-"); expect(persistedSetupItem.detail.log).toContain("prefix-");
expect(persistedSetupItem.detail.log).toContain("-suffix"); expect(persistedSetupItem.detail.log).toContain("-suffix");
expect(persistedSetupItem.detail.log).toContain("...<output truncated in the middle>..."); expect(persistedSetupItem.detail.log).toContain("...<output truncated in the middle>...");
expect(persistedSetupItem.detail.commands[0]?.log).toContain("prefix-");
expect(persistedSetupItem.detail.commands[0]?.log).toContain("-suffix");
expect(persistedSetupItem.detail.commands[0]?.log).toContain(
"...<output truncated in the middle>...",
);
});
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<AgentTimelineItem, { type: "tool_call" }> =>
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 () => { it("waits for terminal output before sending bootstrap commands", async () => {
@@ -440,4 +506,85 @@ describe("runAsyncWorktreeBootstrap", () => {
); );
expect(terminalToolCall?.status).toBe("completed"); 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<string, string> }> = [];
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();
});
}); });
+114 -56
View File
@@ -10,6 +10,7 @@ import {
getServiceConfigs, getServiceConfigs,
getWorktreeTerminalSpecs, getWorktreeTerminalSpecs,
listPaseoWorktrees, listPaseoWorktrees,
processCarriageReturns,
resolveWorktreeRuntimeEnv, resolveWorktreeRuntimeEnv,
runWorktreeSetupCommands, runWorktreeSetupCommands,
slugify, slugify,
@@ -254,15 +255,13 @@ function buildWorktreeSetupLog(input: {
const total = results.length; const total = results.length;
for (const [index, result] of results.entries()) { for (const [index, result] of results.entries()) {
lines.push(`==> [${index + 1}/${total}] Running: ${result.command}`); lines.push(`==> [${index + 1}/${total}] Running: ${result.command}`);
const accumulator = outputAccumulatorsByIndex?.get(index + 1); const output = buildWorktreeSetupCommandLog({
const output = accumulator index: index + 1,
? renderMiddleTruncationAccumulator(accumulator) result,
: truncateTextInMiddle( outputAccumulatorsByIndex,
`${result.stdout ?? ""}${result.stderr ?? ""}`, });
MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES, if (output.log.length > 0) {
); lines.push(output.log.replace(/\n$/, ""));
if (output.text.length > 0) {
lines.push(output.text.replace(/\n$/, ""));
} }
if (output.truncated) { if (output.truncated) {
anyTruncated = true; anyTruncated = true;
@@ -279,6 +278,26 @@ function buildWorktreeSetupLog(input: {
}; };
} }
function buildWorktreeSetupCommandLog(input: {
index: number;
result: WorktreeSetupCommandResult;
outputAccumulatorsByIndex?: Map<number, WorktreeSetupOutputAccumulator>;
}): { 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 { export function createWorktreeSetupProgressAccumulator(): WorktreeSetupProgressAccumulator {
return { return {
resultsByIndex: new Map(), resultsByIndex: new Map(),
@@ -341,14 +360,26 @@ export function buildWorktreeSetupDetail(input: {
results: WorktreeSetupCommandResult[]; results: WorktreeSetupCommandResult[];
outputAccumulatorsByIndex?: Map<number, WorktreeSetupOutputAccumulator>; outputAccumulatorsByIndex?: Map<number, WorktreeSetupOutputAccumulator>;
}): Extract<ToolCallDetail, { type: "worktree_setup" }> { }): Extract<ToolCallDetail, { type: "worktree_setup" }> {
const commands = input.results.map((result, index) => ({ let anyCommandTruncated = false;
index: index + 1, const commands = input.results.map((result, index) => {
command: result.command, const renderedLog = buildWorktreeSetupCommandLog({
cwd: result.cwd, index: index + 1,
status: commandStatusFromResult(result), result,
exitCode: result.exitCode, outputAccumulatorsByIndex: input.outputAccumulatorsByIndex,
...(result.durationMs > 0 ? { durationMs: result.durationMs } : {}), });
})); 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({ const renderedLog = buildWorktreeSetupLog({
results: input.results, results: input.results,
outputAccumulatorsByIndex: input.outputAccumulatorsByIndex, outputAccumulatorsByIndex: input.outputAccumulatorsByIndex,
@@ -360,7 +391,7 @@ export function buildWorktreeSetupDetail(input: {
branchName: input.worktree.branchName, branchName: input.worktree.branchName,
log: renderedLog.log, log: renderedLog.log,
commands, commands,
...(renderedLog.truncated ? { truncated: true } : {}), ...(renderedLog.truncated || anyCommandTruncated ? { truncated: true } : {}),
}; };
} }
@@ -704,18 +735,14 @@ export async function runAsyncWorktreeBootstrap(
await runWorktreeTerminalBootstrap(options, runtimeEnv); await runWorktreeTerminalBootstrap(options, runtimeEnv);
if ( if (!options.terminalManager || !options.serviceRouteStore) {
!options.terminalManager ||
!options.serviceRouteStore ||
options.daemonPort === null ||
options.daemonPort === undefined
) {
return; return;
} }
try { try {
await spawnWorktreeServices({ await spawnWorktreeServices({
repoRoot: options.worktree.worktreePath, repoRoot: options.worktree.worktreePath,
workspaceId: options.worktree.worktreePath,
branchName: options.worktree.branchName, branchName: options.worktree.branchName,
daemonPort: options.daemonPort, daemonPort: options.daemonPort,
routeStore: options.serviceRouteStore, routeStore: options.serviceRouteStore,
@@ -743,13 +770,15 @@ export interface WorktreeServiceResult {
export async function spawnWorktreeServices(options: { export async function spawnWorktreeServices(options: {
repoRoot: string; repoRoot: string;
workspaceId: string;
branchName: string | null; branchName: string | null;
daemonPort: number; daemonPort?: number | null;
routeStore: ServiceRouteStore; routeStore: ServiceRouteStore;
terminalManager: TerminalManager; terminalManager: TerminalManager;
logger?: Logger; logger?: Logger;
}): Promise<WorktreeServiceResult[]> { }): Promise<WorktreeServiceResult[]> {
const { repoRoot, branchName, daemonPort, routeStore, terminalManager, logger } = options; const { repoRoot, workspaceId, branchName, daemonPort, routeStore, terminalManager, logger } =
options;
const serviceConfigs = getServiceConfigs(repoRoot); const serviceConfigs = getServiceConfigs(repoRoot);
if (serviceConfigs.size === 0) { if (serviceConfigs.size === 0) {
return []; return [];
@@ -758,43 +787,72 @@ export async function spawnWorktreeServices(options: {
const results: WorktreeServiceResult[] = []; const results: WorktreeServiceResult[] = [];
for (const [serviceName, config] of serviceConfigs) { for (const [serviceName, config] of serviceConfigs) {
const port = config.port ?? (await findFreePort()); let hostname: string | null = null;
const branchHostnameLabel = branchName ? slugify(branchName) : null; let port: number | null = null;
const isDefaultBranch = try {
branchName === null || branchName === "main" || branchName === "master"; port = config.port ?? (await findFreePort());
const hostname = isDefaultBranch const branchHostnameLabel = branchName ? slugify(branchName) : null;
? `${serviceName}.localhost`
: `${branchHostnameLabel}.${serviceName}.localhost`;
routeStore.addRoute(hostname, port); const isDefaultBranch =
branchName === null || branchName === "main" || branchName === "master";
hostname = isDefaultBranch
? `${serviceName}.localhost`
: `${branchHostnameLabel}.${serviceName}.localhost`;
const env: Record<string, string> = { routeStore.registerRoute({
PORT: String(port), hostname,
HOST: "127.0.0.1", port,
PASEO_SERVICE_URL: `http://${hostname}:${daemonPort}`, workspaceId,
}; serviceName,
});
const terminal = await terminalManager.createTerminal({ const env: Record<string, string> = {
cwd: repoRoot, PORT: String(port),
name: serviceName, HOST: "127.0.0.1",
env, };
}); if (daemonPort !== null && daemonPort !== undefined) {
env.PASEO_SERVICE_URL = `http://${hostname}:${daemonPort}`;
}
await waitForTerminalBootstrapReadiness(terminal); const terminal = await terminalManager.createTerminal({
terminal.send({ type: "input", data: `${config.command}\r` }); cwd: repoRoot,
name: serviceName,
env,
});
logger?.info( await waitForTerminalBootstrapReadiness(terminal);
{ serviceName, hostname, port, terminalId: terminal.id }, terminal.send({ type: "input", data: `${config.command}\r` });
`Registered service proxy: ${hostname} -> 127.0.0.1:${port}`,
);
results.push({ logger?.info(
serviceName, { serviceName, hostname, port, terminalId: terminal.id },
hostname, `Registered service proxy: ${hostname} -> 127.0.0.1:${port}`,
port, );
terminalId: terminal.id,
}); 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; return results;
@@ -1,13 +1,17 @@
import { execSync } from "node:child_process"; 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 { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import type { SessionOutboundMessage } from "./messages.js"; import type { SessionOutboundMessage } from "./messages.js";
import { ServiceRouteStore } from "./service-proxy.js"; import { ServiceRouteStore } from "./service-proxy.js";
import { createPaseoWorktreeInBackground } from "./worktree-session.js"; import {
import { computeWorktreePath, createWorktree } from "../utils/worktree.js"; createPaseoWorktreeInBackground,
handleCreatePaseoWorktreeRequest,
handleWorkspaceSetupStatusRequest,
} from "./worktree-session.js";
import { createWorktree } from "../utils/worktree.js";
function createLogger() { function createLogger() {
return { return {
@@ -109,8 +113,17 @@ describe("createPaseoWorktreeInBackground", () => {
cleanupPaths.push(tempDir); cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo"); 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 emitted: SessionOutboundMessage[] = [];
const snapshots = new Map<string, unknown>();
const routeStore = new ServiceRouteStore(); const routeStore = new ServiceRouteStore();
const logger = createLogger(); const logger = createLogger();
const terminalManager = createTerminalManagerStub(); const terminalManager = createTerminalManagerStub();
@@ -121,6 +134,7 @@ describe("createPaseoWorktreeInBackground", () => {
{ {
paseoHome, paseoHome,
emitWorkspaceUpdateForCwd, emitWorkspaceUpdateForCwd,
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot),
emit: (message) => emitted.push(message), emit: (message) => emitted.push(message),
sessionLogger: logger, sessionLogger: logger,
terminalManager: terminalManager.manager, terminalManager: terminalManager.manager,
@@ -131,9 +145,11 @@ describe("createPaseoWorktreeInBackground", () => {
{ {
requestCwd: repoDir, requestCwd: repoDir,
repoRoot: repoDir, repoRoot: repoDir,
baseBranch: "main", worktree: {
slug: "feature-no-setup", branchName: "feature-no-setup",
worktreePath, worktreePath,
},
shouldBootstrap: true,
}, },
); );
@@ -166,12 +182,30 @@ describe("createPaseoWorktreeInBackground", () => {
commands: [], 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([ 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).toHaveLength(1);
expect(terminalManager.terminals[0]?.cwd).toBe(worktreePath); 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(terminalManager.terminals[0]?.sent).toEqual(["npm run dev\r"]);
expect(archiveWorkspaceRecord).not.toHaveBeenCalled(); expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath); expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
@@ -181,9 +215,25 @@ describe("createPaseoWorktreeInBackground", () => {
const { tempDir, repoDir } = createGitRepo(); const { tempDir, repoDir } = createGitRepo();
cleanupPaths.push(tempDir); 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 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 emitted: SessionOutboundMessage[] = [];
const snapshots = new Map<string, unknown>();
const logger = createLogger(); const logger = createLogger();
const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
const archiveWorkspaceRecord = vi.fn(async () => {}); const archiveWorkspaceRecord = vi.fn(async () => {});
@@ -192,6 +242,7 @@ describe("createPaseoWorktreeInBackground", () => {
{ {
paseoHome, paseoHome,
emitWorkspaceUpdateForCwd, emitWorkspaceUpdateForCwd,
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot),
emit: (message) => emitted.push(message), emit: (message) => emitted.push(message),
sessionLogger: logger, sessionLogger: logger,
terminalManager: null, terminalManager: null,
@@ -202,9 +253,11 @@ describe("createPaseoWorktreeInBackground", () => {
{ {
requestCwd: repoDir, requestCwd: repoDir,
repoRoot: repoDir, repoRoot: repoDir,
baseBranch: "does-not-exist", worktree: {
slug: "broken-feature", branchName: "broken-feature",
worktreePath, worktreePath,
},
shouldBootstrap: true,
}, },
); );
@@ -216,8 +269,12 @@ describe("createPaseoWorktreeInBackground", () => {
expect(progressMessages[0]?.payload.status).toBe("running"); expect(progressMessages[0]?.payload.status).toBe("running");
expect(progressMessages[0]?.payload.error).toBeNull(); expect(progressMessages[0]?.payload.error).toBeNull();
expect(progressMessages[1]?.payload.status).toBe("failed"); 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(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(archiveWorkspaceRecord).toHaveBeenCalledWith(worktreePath);
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath); expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
}); });
@@ -233,8 +290,17 @@ describe("createPaseoWorktreeInBackground", () => {
cleanupPaths.push(tempDir); cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo"); 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 emitted: SessionOutboundMessage[] = [];
const snapshots = new Map<string, unknown>();
const logger = createLogger(); const logger = createLogger();
const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
const archiveWorkspaceRecord = vi.fn(async () => {}); const archiveWorkspaceRecord = vi.fn(async () => {});
@@ -243,6 +309,7 @@ describe("createPaseoWorktreeInBackground", () => {
{ {
paseoHome, paseoHome,
emitWorkspaceUpdateForCwd, emitWorkspaceUpdateForCwd,
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot),
emit: (message) => emitted.push(message), emit: (message) => emitted.push(message),
sessionLogger: logger, sessionLogger: logger,
terminalManager: null, terminalManager: null,
@@ -253,9 +320,11 @@ describe("createPaseoWorktreeInBackground", () => {
{ {
requestCwd: repoDir, requestCwd: repoDir,
repoRoot: repoDir, repoRoot: repoDir,
baseBranch: "main", worktree: {
slug: "feature-running-setup", branchName: "feature-running-setup",
worktreePath, worktreePath,
},
shouldBootstrap: true,
}, },
); );
@@ -285,12 +354,13 @@ describe("createPaseoWorktreeInBackground", () => {
); );
const setupOutputMessage = runningMessages.find((message) => 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.log).toContain("phase-one");
expect(setupOutputMessage?.payload.detail.commands[0]).toMatchObject({ expect(setupOutputMessage?.payload.detail.commands[0]).toMatchObject({
index: 1, index: 1,
command: 'sh -c "printf \'phase-one\\\\n\'; sleep 0.1; printf \'phase-two\\\\n\'"', command: 'sh -c "printf \'phase-one\\\\n\'; sleep 0.1; printf \'phase-two\\\\n\'"',
log: expect.stringContaining("phase-one"),
status: "running", status: "running",
}); });
@@ -308,9 +378,14 @@ describe("createPaseoWorktreeInBackground", () => {
expect(progressMessages.at(-1)?.payload.detail.commands[0]).toMatchObject({ expect(progressMessages.at(-1)?.payload.detail.commands[0]).toMatchObject({
index: 1, index: 1,
command: 'sh -c "printf \'phase-one\\\\n\'; sleep 0.1; printf \'phase-two\\\\n\'"', command: 'sh -c "printf \'phase-one\\\\n\'; sleep 0.1; printf \'phase-two\\\\n\'"',
log: expect.stringContaining("phase-two"),
status: "completed", status: "completed",
exitCode: 0, exitCode: 0,
}); });
expect(snapshots.get(worktreePath)).toMatchObject({
status: "completed",
error: null,
});
}); });
test("emits completed when reusing an existing worktree without bootstrapping", async () => { test("emits completed when reusing an existing worktree without bootstrapping", async () => {
@@ -339,6 +414,7 @@ describe("createPaseoWorktreeInBackground", () => {
}); });
const emitted: SessionOutboundMessage[] = []; const emitted: SessionOutboundMessage[] = [];
const snapshots = new Map<string, unknown>();
const routeStore = new ServiceRouteStore(); const routeStore = new ServiceRouteStore();
const logger = createLogger(); const logger = createLogger();
const terminalManager = createTerminalManagerStub(); const terminalManager = createTerminalManagerStub();
@@ -349,6 +425,7 @@ describe("createPaseoWorktreeInBackground", () => {
{ {
paseoHome, paseoHome,
emitWorkspaceUpdateForCwd, emitWorkspaceUpdateForCwd,
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot),
emit: (message) => emitted.push(message), emit: (message) => emitted.push(message),
sessionLogger: logger, sessionLogger: logger,
terminalManager: terminalManager.manager, terminalManager: terminalManager.manager,
@@ -359,9 +436,11 @@ describe("createPaseoWorktreeInBackground", () => {
{ {
requestCwd: repoDir, requestCwd: repoDir,
repoRoot: repoDir, repoRoot: repoDir,
baseBranch: "main", worktree: {
slug: "reused-worktree", branchName: "reused-worktree",
worktreePath: existingWorktree.worktreePath, worktreePath: existingWorktree.worktreePath,
},
shouldBootstrap: false,
}, },
); );
@@ -387,12 +466,28 @@ describe("createPaseoWorktreeInBackground", () => {
commands: [], commands: [],
}, },
}); });
expect(routeStore.listRoutes()).toEqual([]); expect(routeStore.listRoutes()).toEqual([
expect(terminalManager.terminals).toHaveLength(0); {
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( expect(
readFileSync(path.join(existingWorktree.worktreePath, "README.md"), "utf8"), readFileSync(path.join(existingWorktree.worktreePath, "README.md"), "utf8"),
).toContain("hello"); ).toContain("hello");
expect(() => readFileSync(path.join(existingWorktree.worktreePath, "setup-ran.txt"), "utf8")).toThrow(); 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(archiveWorkspaceRecord).not.toHaveBeenCalled();
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(existingWorktree.worktreePath); expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(existingWorktree.worktreePath);
}); });
@@ -410,8 +505,17 @@ describe("createPaseoWorktreeInBackground", () => {
cleanupPaths.push(tempDir); cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo"); 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 emitted: SessionOutboundMessage[] = [];
const snapshots = new Map<string, unknown>();
const routeStore = new ServiceRouteStore(); const routeStore = new ServiceRouteStore();
const logger = createLogger(); const logger = createLogger();
const terminalManager = createTerminalManagerStub({ const terminalManager = createTerminalManagerStub({
@@ -426,6 +530,7 @@ describe("createPaseoWorktreeInBackground", () => {
{ {
paseoHome, paseoHome,
emitWorkspaceUpdateForCwd, emitWorkspaceUpdateForCwd,
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot),
emit: (message) => emitted.push(message), emit: (message) => emitted.push(message),
sessionLogger: logger, sessionLogger: logger,
terminalManager: terminalManager.manager, terminalManager: terminalManager.manager,
@@ -436,9 +541,11 @@ describe("createPaseoWorktreeInBackground", () => {
{ {
requestCwd: repoDir, requestCwd: repoDir,
repoRoot: repoDir, repoRoot: repoDir,
baseBranch: "main", worktree: {
slug: "feature-service-failure", branchName: "feature-service-failure",
worktreePath, worktreePath,
},
shouldBootstrap: true,
}, },
); );
@@ -452,15 +559,237 @@ describe("createPaseoWorktreeInBackground", () => {
expect(progressMessages[1]?.payload.status).toBe("completed"); expect(progressMessages[1]?.payload.status).toBe("completed");
expect(progressMessages[1]?.payload.error).toBeNull(); expect(progressMessages[1]?.payload.error).toBeNull();
expect(emitted.some((message) => message.type === "workspace_setup_progress" && message.payload.status === "failed")).toBe(false); 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({ expect.objectContaining({
err: expect.any(Error), err: expect.any(Error),
cwd: repoDir,
repoRoot: repoDir,
worktreeSlug: "feature-service-failure",
worktreePath, worktreePath,
}), }),
"Failed to spawn worktree services after workspace setup completed", "Failed to spawn worktree services after workspace setup completed",
); );
expect(snapshots.get(worktreePath)).toMatchObject({
status: "completed",
error: null,
});
expect(archiveWorkspaceRecord).not.toHaveBeenCalled(); expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath); 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<string, unknown>();
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<SessionOutboundMessage, { type: "create_paseo_worktree_response" }> =>
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 });
}
});
}); });
+76 -49
View File
@@ -11,6 +11,7 @@ import {
type ProjectPlacementPayload, type ProjectPlacementPayload,
type SessionInboundMessage, type SessionInboundMessage,
type SessionOutboundMessage, type SessionOutboundMessage,
type WorkspaceSetupSnapshot,
type WorkspaceDescriptorPayload, type WorkspaceDescriptorPayload,
} from "./messages.js"; } from "./messages.js";
import type { import type {
@@ -27,7 +28,6 @@ import {
createWorktreeSetupProgressAccumulator, createWorktreeSetupProgressAccumulator,
getWorktreeSetupProgressResults, getWorktreeSetupProgressResults,
spawnWorktreeServices, spawnWorktreeServices,
type CreateAgentWorktreeResult,
} from "./worktree-bootstrap.js"; } from "./worktree-bootstrap.js";
import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { ServiceRouteStore } from "./service-proxy.js"; import type { ServiceRouteStore } from "./service-proxy.js";
@@ -117,12 +117,18 @@ type CreatePaseoWorktreeInBackgroundDependencies = {
cwd: string, cwd: string,
options?: { dedupeGitState?: boolean }, options?: { dedupeGitState?: boolean },
) => Promise<void>; ) => Promise<void>;
cacheWorkspaceSetupSnapshot: (workspaceId: string, snapshot: WorkspaceSetupSnapshot) => void;
emit: EmitSessionMessage; emit: EmitSessionMessage;
sessionLogger: Logger; sessionLogger: Logger;
terminalManager: TerminalManager | null; terminalManager: TerminalManager | null;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>; archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
serviceRouteStore: ServiceRouteStore | null; serviceRouteStore: ServiceRouteStore | null;
daemonPort: number | null; daemonPort?: number | null;
};
type HandleWorkspaceSetupStatusRequestDependencies = {
emit: EmitSessionMessage;
workspaceSetupSnapshots: ReadonlyMap<string, WorkspaceSetupSnapshot>;
}; };
type HandleCreatePaseoWorktreeRequestDependencies = { type HandleCreatePaseoWorktreeRequestDependencies = {
@@ -597,7 +603,7 @@ export async function handleCreatePaseoWorktreeRequest(
throw new Error(`Invalid worktree name: ${validation.error}`); 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({ const createdWorktree = await createAgentWorktree({
cwd: repoRoot, cwd: repoRoot,
branchName: normalizedSlug, branchName: normalizedSlug,
@@ -645,6 +651,21 @@ export async function handleCreatePaseoWorktreeRequest(
} }
} }
export async function handleWorkspaceSetupStatusRequest(
dependencies: HandleWorkspaceSetupStatusRequestDependencies,
request: Extract<SessionInboundMessage, { type: "workspace_setup_status_request" }>,
): Promise<void> {
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( export async function createPaseoWorktreeInBackground(
dependencies: CreatePaseoWorktreeInBackgroundDependencies, dependencies: CreatePaseoWorktreeInBackgroundDependencies,
options: { options: {
@@ -658,20 +679,25 @@ export async function createPaseoWorktreeInBackground(
let setupResults: WorktreeSetupCommandResult[] = []; let setupResults: WorktreeSetupCommandResult[] = [];
let setupStarted = false; let setupStarted = false;
const progressAccumulator = createWorktreeSetupProgressAccumulator(); const progressAccumulator = createWorktreeSetupProgressAccumulator();
const workspaceId = normalizePersistedWorkspaceId(worktree.worktreePath);
const emitSetupProgress = (status: "running" | "completed" | "failed", error: string | null) => { 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({ dependencies.emit({
type: "workspace_setup_progress", type: "workspace_setup_progress",
payload: { payload: {
workspaceId: normalizePersistedWorkspaceId(worktree.worktreePath), workspaceId,
status, ...snapshot,
detail: buildWorktreeSetupDetail({
worktree,
results:
status === "running" ? getWorktreeSetupProgressResults(progressAccumulator) : setupResults,
outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex,
}),
error,
}, },
}); });
}; };
@@ -682,36 +708,35 @@ export async function createPaseoWorktreeInBackground(
if (!options.shouldBootstrap) { if (!options.shouldBootstrap) {
emitSetupProgress("completed", null); emitSetupProgress("completed", null);
return;
}
const setupCommands = getWorktreeSetupCommands(worktree.worktreePath);
if (setupCommands.length === 0) {
setupStarted = true;
emitSetupProgress("completed", null);
} else { } else {
const runtimeEnv = await resolveWorktreeRuntimeEnv({ const setupCommands = getWorktreeSetupCommands(worktree.worktreePath);
worktreePath: worktree.worktreePath, if (setupCommands.length === 0) {
branchName: worktree.branchName, setupStarted = true;
repoRootPath: options.repoRoot, emitSetupProgress("completed", null);
}); } else {
dependencies.terminalManager?.registerCwdEnv({ const runtimeEnv = await resolveWorktreeRuntimeEnv({
cwd: worktree.worktreePath, worktreePath: worktree.worktreePath,
env: runtimeEnv, branchName: worktree.branchName,
}); repoRootPath: options.repoRoot,
setupStarted = true; });
setupResults = await runWorktreeSetupCommands({ dependencies.terminalManager?.registerCwdEnv({
worktreePath: worktree.worktreePath, cwd: worktree.worktreePath,
branchName: worktree.branchName, env: runtimeEnv,
cleanupOnFailure: false, });
repoRootPath: options.repoRoot, setupStarted = true;
runtimeEnv, setupResults = await runWorktreeSetupCommands({
onEvent: (event) => { worktreePath: worktree.worktreePath,
applyWorktreeSetupProgressEvent(progressAccumulator, event); branchName: worktree.branchName,
emitSetupProgress("running", null); cleanupOnFailure: false,
}, repoRootPath: options.repoRoot,
}); runtimeEnv,
emitSetupProgress("completed", null); onEvent: (event) => {
applyWorktreeSetupProgressEvent(progressAccumulator, event);
emitSetupProgress("running", null);
},
});
emitSetupProgress("completed", null);
}
} }
} catch (error) { } catch (error) {
if (error instanceof WorktreeSetupError) { if (error instanceof WorktreeSetupError) {
@@ -738,18 +763,14 @@ export async function createPaseoWorktreeInBackground(
return; return;
} }
if ( if (!dependencies.terminalManager || !dependencies.serviceRouteStore) {
!dependencies.terminalManager ||
!dependencies.serviceRouteStore ||
dependencies.daemonPort === null ||
dependencies.daemonPort === undefined
) {
return; return;
} }
try { try {
await spawnWorktreeServices({ await spawnWorktreeServices({
repoRoot: worktree.worktreePath, repoRoot: worktree.worktreePath,
workspaceId: worktree.worktreePath,
branchName: worktree.branchName, branchName: worktree.branchName,
daemonPort: dependencies.daemonPort, daemonPort: dependencies.daemonPort,
routeStore: dependencies.serviceRouteStore, routeStore: dependencies.serviceRouteStore,
@@ -757,8 +778,14 @@ export async function createPaseoWorktreeInBackground(
logger: dependencies.sessionLogger, logger: dependencies.sessionLogger,
}); });
} catch (error) { } catch (error) {
dependencies.sessionLogger.warn( dependencies.sessionLogger.error(
{ err: error, worktreePath: worktree.worktreePath }, {
err: error,
cwd: options.requestCwd,
repoRoot: options.repoRoot,
worktreeSlug: worktree.branchName,
worktreePath: worktree.worktreePath,
},
"Failed to spawn worktree services after workspace setup completed", "Failed to spawn worktree services after workspace setup completed",
); );
} }
+49
View File
@@ -199,6 +199,7 @@ const WorktreeSetupCommandSnapshotSchema = z.object({
index: z.number().int().positive(), index: z.number().int().positive(),
command: z.string(), command: z.string(),
cwd: z.string(), cwd: z.string(),
log: z.string(),
status: z.enum(["running", "completed", "failed"]), status: z.enum(["running", "completed", "failed"]),
exitCode: z.number().nullable(), exitCode: z.number().nullable(),
durationMs: z.number().nonnegative().optional(), durationMs: z.number().nonnegative().optional(),
@@ -985,6 +986,12 @@ export const CreatePaseoWorktreeRequestSchema = z.object({
requestId: z.string(), 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({ export const OpenProjectRequestSchema = z.object({
type: z.literal("open_project_request"), type: z.literal("open_project_request"),
cwd: z.string(), cwd: z.string(),
@@ -1228,6 +1235,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
PaseoWorktreeListRequestSchema, PaseoWorktreeListRequestSchema,
PaseoWorktreeArchiveRequestSchema, PaseoWorktreeArchiveRequestSchema,
CreatePaseoWorktreeRequestSchema, CreatePaseoWorktreeRequestSchema,
WorkspaceSetupStatusRequestSchema,
OpenProjectRequestSchema, OpenProjectRequestSchema,
ArchiveWorkspaceRequestSchema, ArchiveWorkspaceRequestSchema,
FileExplorerRequestSchema, FileExplorerRequestSchema,
@@ -1573,6 +1581,14 @@ export const ProjectPlacementPayloadSchema = z.object({
checkout: ProjectCheckoutLitePayloadSchema, 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({ export const WorkspaceDescriptorPayloadSchema = z.object({
id: z.string(), id: z.string(),
projectId: z.string(), projectId: z.string(),
@@ -1590,6 +1606,7 @@ export const WorkspaceDescriptorPayloadSchema = z.object({
}) })
.nullable() .nullable()
.optional(), .optional(),
services: z.array(WorkspaceServicePayloadSchema).default([]),
}); });
export const AgentUpdateMessageSchema = z.object({ 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({ export const WorkspaceSetupProgressMessageSchema = z.object({
type: z.literal("workspace_setup_progress"), type: z.literal("workspace_setup_progress"),
payload: z.object({ 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({ export const OpenProjectResponseMessageSchema = z.object({
type: z.literal("open_project_response"), type: z.literal("open_project_response"),
payload: z.object({ payload: z.object({
@@ -2263,7 +2303,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ArtifactMessageSchema, ArtifactMessageSchema,
AgentUpdateMessageSchema, AgentUpdateMessageSchema,
WorkspaceUpdateMessageSchema, WorkspaceUpdateMessageSchema,
ServiceStatusUpdateMessageSchema,
WorkspaceSetupProgressMessageSchema, WorkspaceSetupProgressMessageSchema,
WorkspaceSetupStatusResponseMessageSchema,
AgentStreamMessageSchema, AgentStreamMessageSchema,
AgentStatusMessageSchema, AgentStatusMessageSchema,
FetchAgentsResponseMessageSchema, FetchAgentsResponseMessageSchema,
@@ -2348,14 +2390,20 @@ export type RpcErrorMessage = z.infer<typeof RpcErrorMessageSchema>;
export type ArtifactMessage = z.infer<typeof ArtifactMessageSchema>; export type ArtifactMessage = z.infer<typeof ArtifactMessageSchema>;
export type AgentUpdateMessage = z.infer<typeof AgentUpdateMessageSchema>; export type AgentUpdateMessage = z.infer<typeof AgentUpdateMessageSchema>;
export type WorkspaceSetupProgressMessage = z.infer<typeof WorkspaceSetupProgressMessageSchema>; export type WorkspaceSetupProgressMessage = z.infer<typeof WorkspaceSetupProgressMessageSchema>;
export type WorkspaceSetupSnapshot = z.infer<typeof WorkspaceSetupSnapshotSchema>;
export type WorkspaceSetupStatusResponseMessage = z.infer<
typeof WorkspaceSetupStatusResponseMessageSchema
>;
export type AgentStreamMessage = z.infer<typeof AgentStreamMessageSchema>; export type AgentStreamMessage = z.infer<typeof AgentStreamMessageSchema>;
export type AgentStatusMessage = z.infer<typeof AgentStatusMessageSchema>; export type AgentStatusMessage = z.infer<typeof AgentStatusMessageSchema>;
export type ProjectCheckoutLitePayload = z.infer<typeof ProjectCheckoutLitePayloadSchema>; export type ProjectCheckoutLitePayload = z.infer<typeof ProjectCheckoutLitePayloadSchema>;
export type ProjectPlacementPayload = z.infer<typeof ProjectPlacementPayloadSchema>; export type ProjectPlacementPayload = z.infer<typeof ProjectPlacementPayloadSchema>;
export type WorkspaceStateBucket = z.infer<typeof WorkspaceStateBucketSchema>; export type WorkspaceStateBucket = z.infer<typeof WorkspaceStateBucketSchema>;
export type WorkspaceDescriptorPayload = z.infer<typeof WorkspaceDescriptorPayloadSchema>; export type WorkspaceDescriptorPayload = z.infer<typeof WorkspaceDescriptorPayloadSchema>;
export type WorkspaceServicePayload = z.infer<typeof WorkspaceServicePayloadSchema>;
export type FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessageSchema>; export type FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessageSchema>;
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>; export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>;
export type ServiceStatusUpdateMessage = z.infer<typeof ServiceStatusUpdateMessageSchema>;
export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessageSchema>; export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessageSchema>;
export type ArchiveWorkspaceResponseMessage = z.infer<typeof ArchiveWorkspaceResponseMessageSchema>; export type ArchiveWorkspaceResponseMessage = z.infer<typeof ArchiveWorkspaceResponseMessageSchema>;
export type FetchAgentResponseMessage = z.infer<typeof FetchAgentResponseMessageSchema>; export type FetchAgentResponseMessage = z.infer<typeof FetchAgentResponseMessageSchema>;
@@ -2468,6 +2516,7 @@ export type PaseoWorktreeListRequest = z.infer<typeof PaseoWorktreeListRequestSc
export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponseSchema>; export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponseSchema>;
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>; export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
export type PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema>; export type PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema>;
export type WorkspaceSetupStatusRequest = z.infer<typeof WorkspaceSetupStatusRequestSchema>;
export type OpenProjectRequest = z.infer<typeof OpenProjectRequestSchema>; export type OpenProjectRequest = z.infer<typeof OpenProjectRequestSchema>;
export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>; export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>;
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>; export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
@@ -44,6 +44,7 @@ describe("workspace message schemas", () => {
name: "", name: "",
status: "not-a-bucket", status: "not-a-bucket",
activityAt: null, activityAt: null,
services: [],
}, },
}, },
}); });
@@ -51,6 +52,70 @@ describe("workspace message schemas", () => {
expect(result.success).toBe(false); 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", () => { test("parses workspace_setup_progress payload", () => {
const parsed = SessionOutboundMessageSchema.parse({ const parsed = SessionOutboundMessageSchema.parse({
type: "workspace_setup_progress", type: "workspace_setup_progress",
@@ -67,6 +132,7 @@ describe("workspace message schemas", () => {
index: 1, index: 1,
command: "npm install", command: "npm install",
cwd: "/repo/.paseo/worktrees/feature-a", cwd: "/repo/.paseo/worktrees/feature-a",
log: "done",
status: "completed", status: "completed",
exitCode: 0, exitCode: 0,
durationMs: 100, durationMs: 100,
@@ -79,4 +145,37 @@ describe("workspace message schemas", () => {
expect(parsed.type).toBe("workspace_setup_progress"); 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");
});
}); });
@@ -79,6 +79,7 @@ describe("shared tool-call display mapping", () => {
index: 1, index: 1,
command: "npm install", command: "npm install",
cwd: "/tmp/repo/.paseo/worktrees/repo/branch", cwd: "/tmp/repo/.paseo/worktrees/repo/branch",
log: "==> [1/1] Running: npm install\n",
status: "running", status: "running",
exitCode: null, exitCode: null,
}, },
+113 -35
View File
@@ -4,7 +4,9 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } f
import { join, basename, dirname, resolve, sep } from "path"; import { join, basename, dirname, resolve, sep } from "path";
import net from "node:net"; import net from "node:net";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import * as pty from "node-pty";
import { createNameId } from "mnemonic-id"; import { createNameId } from "mnemonic-id";
import stripAnsi from "strip-ansi";
import { import {
normalizeBaseRefName, normalizeBaseRefName,
readPaseoWorktreeMetadata, readPaseoWorktreeMetadata,
@@ -13,6 +15,7 @@ import {
writePaseoWorktreeRuntimeMetadata, writePaseoWorktreeRuntimeMetadata,
} from "./worktree-metadata.js"; } from "./worktree-metadata.js";
import { resolvePaseoHome } from "../server/paseo-home.js"; import { resolvePaseoHome } from "../server/paseo-home.js";
import { ensureNodePtySpawnHelperExecutableForCurrentPlatform } from "../terminal/terminal.js";
interface PaseoConfig { interface PaseoConfig {
worktree?: { worktree?: {
@@ -234,6 +237,56 @@ export function getServiceConfigs(repoRoot: string): Map<string, ServiceConfig>
return result; 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( async function execSetupCommand(
command: string, command: string,
options: { cwd: string; env: NodeJS.ProcessEnv }, options: { cwd: string; env: NodeJS.ProcessEnv },
@@ -279,6 +332,27 @@ async function execSetupCommandStreamed(options: {
const stderrChunks: string[] = []; const stderrChunks: string[] = [];
let settled = false; 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) => { const finish = (exitCode: number | null) => {
if (settled) { if (settled) {
return; return;
@@ -314,48 +388,52 @@ async function execSetupCommandStreamed(options: {
cwd: options.cwd, cwd: options.cwd,
}); });
const child = spawn("/bin/bash", ["-lc", options.command], { const spawnWithPipes = () => {
cwd: options.cwd, const child = spawn("/bin/bash", ["-lc", options.command], {
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,
cwd: options.cwd, cwd: options.cwd,
stream: "stdout", env: options.env,
chunk: text, stdio: ["ignore", "pipe", "pipe"],
}); });
});
child.stderr?.on("data", (chunk: Buffer | string) => { child.stdout?.on("data", (chunk: Buffer | string) => {
const text = chunk.toString(); emitOutput("stdout", chunk.toString());
stderrChunks.push(text); });
options.onEvent?.({
type: "output", child.stderr?.on("data", (chunk: Buffer | string) => {
index: options.index, emitOutput("stderr", chunk.toString());
total: options.total, });
command: options.command,
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, cwd: options.cwd,
stream: "stderr", env: options.env,
chunk: text, name: "xterm-color",
cols: 120,
rows: 30,
}); });
});
child.on("error", (error) => { terminal.onData((data) => {
stderrChunks.push(error instanceof Error ? error.message : String(error)); emitOutput("stdout", data);
finish(null); });
});
child.on("close", (code) => { terminal.onExit(({ exitCode }) => {
finish(typeof code === "number" ? code : null); finish(typeof exitCode === "number" ? exitCode : null);
}); });
} catch (error) {
emitOutput("stderr", error instanceof Error ? error.message : String(error));
spawnWithPipes();
}
}); });
} }
@@ -59,6 +59,7 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
<div className="space-y-24"> <div className="space-y-24">
<MultiProviderSection /> <MultiProviderSection />
<SelfHostedSection /> <SelfHostedSection />
<ServiceProxySection />
<ShortcutsSection /> <ShortcutsSection />
<LocalVoiceSection /> <LocalVoiceSection />
<CLISection /> <CLISection />
@@ -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 (
<FeatureSection
title="Forget about ports"
description="When agents work in parallel, they all run dev servers. Paseo gives each one a URL based on the branch name, no port conflicts, no guessing."
>
<div className="rounded-2xl border border-white/10 bg-white/[0.02] overflow-hidden">
<div className="px-5 py-4 space-y-3">
{/* Project */}
<div className="flex items-center gap-2.5">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-white/40">
<path d="M3 7V17C3 18.1046 3.89543 19 5 19H19C20.1046 19 21 18.1046 21 17V9C21 7.89543 20.1046 7 19 7H13L11 5H5C3.89543 5 3 5.89543 3 7Z" />
</svg>
<span className="text-sm font-medium text-white/60">my-app</span>
</div>
{/* Workspaces indented */}
<div className="pl-6 space-y-2">
{workspaces.map((ws) => (
<div
key={ws.name}
className="flex items-center justify-between"
>
<div className="flex items-center gap-2.5">
<div className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
<span className="text-sm text-white/80">{ws.name}</span>
<span className="text-xs text-white/25 font-mono">npm run dev</span>
</div>
<span className="text-xs font-mono text-white/30">
{ws.url}
</span>
</div>
))}
</div>
</div>
</div>
</FeatureSection>
);
}
function ShortcutsSection() { function ShortcutsSection() {
const shortcuts = [ const shortcuts = [
{ keys: ["⌘", "1-9"], action: "Switch panels" }, { keys: ["⌘", "1-9"], action: "Switch panels" },