refactor: rename services to scripts and extract reusable components

This commit is contained in:
Mohamed Boudra
2026-04-09 18:54:25 +07:00
parent 5cf806dc77
commit 397d454de0
48 changed files with 1356 additions and 983 deletions
+31 -31
View File
@@ -22,7 +22,7 @@ function getServerId(): string {
// Composable helpers // Composable helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Waits for the globe icon to appear on a workspace row (proves services are running). */ /** Waits for the globe icon to appear on a workspace row (proves scripts are running). */
async function expectGlobeIcon(page: Page): Promise<void> { async function expectGlobeIcon(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-globe-icon")).toBeVisible({ timeout: 30_000 }); await expect(page.getByTestId("workspace-globe-icon")).toBeVisible({ timeout: 30_000 });
} }
@@ -34,38 +34,38 @@ async function expectHoverCard(page: Page, workspaceName: string): Promise<void>
await expect(page.getByTestId("workspace-hover-card")).toBeVisible({ timeout: 10_000 }); 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. */ /** Asserts that a script row with the given name exists in the hover card. */
async function expectServiceInCard(page: Page, serviceName: string): Promise<void> { async function expectScriptInCard(page: Page, scriptName: string): Promise<void> {
const card = page.getByTestId("workspace-hover-card"); const card = page.getByTestId("workspace-hover-card");
await expect(card.getByTestId(`hover-card-service-${serviceName}`)).toBeVisible({ await expect(card.getByTestId(`hover-card-script-${scriptName}`)).toBeVisible({
timeout: 10_000, timeout: 10_000,
}); });
} }
/** Asserts the service status dot indicates "running". */ /** Asserts the script status dot indicates "running". */
async function expectServiceRunning(page: Page, serviceName: string): Promise<void> { async function expectScriptRunning(page: Page, scriptName: string): Promise<void> {
const card = page.getByTestId("workspace-hover-card"); const card = page.getByTestId("workspace-hover-card");
await expect( await expect(
card.getByTestId(`hover-card-service-status-${serviceName}`), card.getByTestId(`hover-card-script-status-${scriptName}`),
).toHaveAttribute("aria-label", "Running", { timeout: 10_000 }); ).toHaveAttribute("aria-label", "Running", { timeout: 10_000 });
} }
/** Asserts the service lifecycle is stopped. */ /** Asserts the script lifecycle is stopped. */
async function expectServiceStopped(page: Page, serviceName: string): Promise<void> { async function expectScriptStopped(page: Page, scriptName: string): Promise<void> {
const card = page.getByTestId("workspace-hover-card"); const card = page.getByTestId("workspace-hover-card");
await expect( await expect(
card.getByTestId(`hover-card-service-status-${serviceName}`), card.getByTestId(`hover-card-script-status-${scriptName}`),
).toHaveAttribute("aria-label", "Stopped", { timeout: 10_000 }); ).toHaveAttribute("aria-label", "Stopped", { timeout: 10_000 });
} }
/** Asserts the service health label shown in the hover card. */ /** Asserts the script health label shown in the hover card. */
async function expectServiceHealth( async function expectScriptHealth(
page: Page, page: Page,
serviceName: string, scriptName: string,
health: "Healthy" | "Unhealthy" | "Unknown", health: "Healthy" | "Unhealthy" | "Unknown",
): Promise<void> { ): Promise<void> {
const card = page.getByTestId("workspace-hover-card"); const card = page.getByTestId("workspace-hover-card");
await expect(card.getByTestId(`hover-card-service-health-${serviceName}`)).toHaveAttribute( await expect(card.getByTestId(`hover-card-script-health-${scriptName}`)).toHaveAttribute(
"aria-label", "aria-label",
health, health,
{ timeout: 10_000 }, { timeout: 10_000 },
@@ -93,7 +93,7 @@ async function expectHoverCardDismissed(page: Page): Promise<void> {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
test.describe("Workspace hover card", () => { test.describe("Workspace hover card", () => {
test("shows hover card with services when hovering a workspace with running services", async ({ test("shows hover card with scripts when hovering a workspace with running scripts", async ({
page, page,
}) => { }) => {
const client = await connectWorkspaceSetupClient(); const client = await connectWorkspaceSetupClient();
@@ -102,7 +102,7 @@ test.describe("Workspace hover card", () => {
worktree: { worktree: {
setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"], setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"],
}, },
services: { scripts: {
web: { web: {
command: 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))\"", "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))\"",
@@ -133,7 +133,7 @@ test.describe("Workspace hover card", () => {
await waitForWorkspaceTabsVisible(page); await waitForWorkspaceTabsVisible(page);
// Wait for the globe icon — proves services are running and client has the data // Wait for the globe icon — proves scripts are running and client has the data
await expectGlobeIcon(page); await expectGlobeIcon(page);
// Hover the workspace row — hover card should appear // Hover the workspace row — hover card should appear
@@ -142,15 +142,15 @@ test.describe("Workspace hover card", () => {
// Assert the card shows the workspace name // Assert the card shows the workspace name
await expectWorkspaceNameInCard(page, workspace.name); await expectWorkspaceNameInCard(page, workspace.name);
// Assert the "web" service entry exists in the card // Assert the "web" script entry exists in the card
await expectServiceInCard(page, "web"); await expectScriptInCard(page, "web");
// Assert the status dot shows "running" // Assert the status dot shows "running"
await expectServiceRunning(page, "web"); await expectScriptRunning(page, "web");
// Assert the service row is a link (has role="link") // Assert the script row is a link (has role="link")
const card = page.getByTestId("workspace-hover-card"); const card = page.getByTestId("workspace-hover-card");
const serviceLink = card.getByRole("link", { name: "web service" }); const serviceLink = card.getByRole("link", { name: "web script" });
await expect(serviceLink).toBeVisible({ timeout: 10_000 }); await expect(serviceLink).toBeVisible({ timeout: 10_000 });
// Move mouse away — card should dismiss // Move mouse away — card should dismiss
@@ -161,11 +161,11 @@ test.describe("Workspace hover card", () => {
} }
}); });
test("shows stopped services and starts them from the hover card", async ({ page }) => { test("shows stopped scripts and starts them from the hover card", async ({ page }) => {
const client = await connectWorkspaceSetupClient(); const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("hovercard-start-", { const repo = await createTempGitRepo("hovercard-start-", {
paseoConfig: { paseoConfig: {
services: { scripts: {
web: { web: {
command: command:
"node -e \"const http = require('http'); const s = http.createServer((q,r) => r.end('ok')); s.listen(process.env.PORT || 3000, '127.0.0.1', () => console.log('listening on ' + s.address().port))\"", "node -e \"const http = require('http'); const s = http.createServer((q,r) => r.end('ok')); s.listen(process.env.PORT || 3000, '127.0.0.1', () => console.log('listening on ' + s.address().port))\"",
@@ -187,18 +187,18 @@ test.describe("Workspace hover card", () => {
await expectHoverCard(page, workspace.workspace.name); await expectHoverCard(page, workspace.workspace.name);
await expectWorkspaceNameInCard(page, workspace.workspace.name); await expectWorkspaceNameInCard(page, workspace.workspace.name);
await expectServiceInCard(page, "web"); await expectScriptInCard(page, "web");
await expectServiceStopped(page, "web"); await expectScriptStopped(page, "web");
await expectServiceHealth(page, "web", "Unknown"); await expectScriptHealth(page, "web", "Unknown");
const card = page.getByTestId("workspace-hover-card"); const card = page.getByTestId("workspace-hover-card");
const startButton = card.getByTestId("hover-card-service-start-web"); const startButton = card.getByTestId("hover-card-script-start-web");
await expect(startButton).toBeVisible({ timeout: 10_000 }); await expect(startButton).toBeVisible({ timeout: 10_000 });
await startButton.click(); await startButton.click();
await expectServiceRunning(page, "web"); await expectScriptRunning(page, "web");
await expectServiceHealth(page, "web", "Healthy"); await expectScriptHealth(page, "web", "Healthy");
await expect(card.getByRole("link", { name: "web service" })).toBeVisible({ timeout: 10_000 }); await expect(card.getByRole("link", { name: "web script" })).toBeVisible({ timeout: 10_000 });
await expect(startButton).not.toBeVisible({ timeout: 10_000 }); await expect(startButton).not.toBeVisible({ timeout: 10_000 });
} finally { } finally {
await client.close(); await client.close();
@@ -221,14 +221,14 @@ test.describe("Workspace setup streaming", () => {
} }
}); });
test("launches service terminals after setup completes", async ({ page }) => { test("launches script terminals after setup completes", async ({ page }) => {
const client = await connectWorkspaceSetupClient(); const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-svc-ui-", { const repo = await createTempGitRepo("setup-svc-ui-", {
paseoConfig: { paseoConfig: {
worktree: { worktree: {
setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"], setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"],
}, },
services: { scripts: {
web: { web: {
command: 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))\"", "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))\"",
@@ -256,12 +256,12 @@ test.describe("Workspace setup streaming", () => {
await waitForWorkspaceTabsVisible(page); await waitForWorkspaceTabsVisible(page);
// Wait for the service terminal tab to appear in the tabs bar. // Wait for the script terminal tab to appear in the tabs bar.
// The tab title shows the command, not the service name. // The tab title shows the command, not the script name.
const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]').first(); const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]').first();
await expect(terminalTab).toBeVisible({ timeout: 30_000 }); await expect(terminalTab).toBeVisible({ timeout: 30_000 });
// Click the service terminal tab // Click the script terminal tab
await terminalTab.click(); await terminalTab.click();
// Verify the terminal surface rendered // Verify the terminal surface rendered
@@ -277,14 +277,14 @@ test.describe("Workspace setup streaming", () => {
} }
}); });
test("launches workspace services after setup completes", async () => { test("launches workspace scripts after setup completes", async () => {
const client = await connectWorkspaceSetupClient(); const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-services-", { const repo = await createTempGitRepo("setup-scripts-", {
paseoConfig: { paseoConfig: {
worktree: { worktree: {
setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"], setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"],
}, },
services: { scripts: {
editor: { editor: {
command: "npm run dev", command: "npm run dev",
}, },
@@ -301,7 +301,7 @@ test.describe("Workspace setup streaming", () => {
const result = await client.createPaseoWorktree({ const result = await client.createPaseoWorktree({
cwd: repo.path, cwd: repo.path,
worktreeSlug: "workspace-setup-services", worktreeSlug: "workspace-setup-scripts",
}); });
if (!result.workspace) { if (!result.workspace) {
throw new Error(result.error ?? "Failed to create workspace"); throw new Error(result.error ?? "Failed to create workspace");
+35
View File
@@ -0,0 +1,35 @@
import { View, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
interface DiffStatProps {
additions: number;
deletions: number;
}
export function DiffStat({ additions, deletions }: DiffStatProps) {
return (
<View style={styles.row}>
<Text style={styles.additions}>+{additions.toLocaleString()}</Text>
<Text style={styles.deletions}>-{deletions.toLocaleString()}</Text>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
row: {
flexDirection: "row",
alignItems: "center",
gap: 4,
flexShrink: 0,
},
additions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.green[400],
},
deletions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.red[500],
},
}));
+2 -12
View File
@@ -8,6 +8,7 @@ import {
type ReactElement, type ReactElement,
} from "react"; } from "react";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { DiffStat } from "@/components/diff-stat";
import { import {
View, View,
Text, Text,
@@ -474,8 +475,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
)} )}
</View> </View>
<View style={styles.fileHeaderRight}> <View style={styles.fileHeaderRight}>
<Text style={styles.additions}>+{file.additions}</Text> <DiffStat additions={file.additions} deletions={file.deletions} />
<Text style={styles.deletions}>-{file.deletions}</Text>
</View> </View>
</Pressable> </Pressable>
</View> </View>
@@ -1738,16 +1738,6 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.red[500], color: theme.colors.palette.red[500],
}, },
additions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.green[400],
},
deletions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.red[500],
},
diffContent: { diffContent: {
borderTopWidth: theme.borderWidth[1], borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border, borderTopColor: theme.colors.border,
@@ -25,6 +25,7 @@ import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { type GestureType } from "react-native-gesture-handler"; import { type GestureType } from "react-native-gesture-handler";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import { DiffStat } from "@/components/diff-stat";
import { import {
Archive, Archive,
ArrowUpRight, ArrowUpRight,
@@ -36,7 +37,7 @@ import {
FolderPlus, FolderPlus,
FolderGit2, FolderGit2,
GitPullRequest, GitPullRequest,
Globe, Terminal,
Monitor, Monitor,
MoreVertical, MoreVertical,
Plus, Plus,
@@ -94,7 +95,8 @@ import {
requireWorkspaceExecutionDirectory, requireWorkspaceExecutionDirectory,
resolveWorkspaceExecutionDirectory, resolveWorkspaceExecutionDirectory,
} from "@/utils/workspace-execution"; } from "@/utils/workspace-execution";
import { CheckStatusIndicator, WorkspaceHoverCard } from "@/components/workspace-hover-card"; import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
import { GitHubIcon } from "@/components/icons/github-icon";
import { createNameId } from "mnemonic-id"; import { createNameId } from "mnemonic-id";
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null { function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
@@ -233,6 +235,38 @@ const prBadgeStyles = StyleSheet.create((theme) => ({
}, },
})); }));
function ChecksBadge({ checks }: { checks: PrHint["checks"] }): ReactElement | null {
const { theme } = useUnistyles();
if (!checks || checks.length === 0) return null;
const failed = checks.filter((c) => c.status === "failure").length;
if (failed === 0) return null;
const color = theme.colors.palette.red[500];
const label = `${failed} failed`;
return (
<View style={checksBadgeStyles.badge}>
<GitHubIcon size={10} color={color} />
<Text style={[checksBadgeStyles.text, { color }]}>{label}</Text>
</View>
);
}
const checksBadgeStyles = StyleSheet.create((theme) => ({
badge: {
flexDirection: "row",
alignItems: "center",
gap: 2,
},
text: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
lineHeight: 14,
},
}));
function WorkspaceStatusIndicator({ function WorkspaceStatusIndicator({
bucket, bucket,
@@ -932,7 +966,7 @@ function WorkspaceRowInner({
}, [interaction.didLongPressRef, onPress]); }, [interaction.didLongPressRef, onPress]);
const isDesktop = !isTouchPlatform; const isDesktop = !isTouchPlatform;
const showGlobe = isDesktop && workspace.hasRunningServices; const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
return ( return (
<WorkspaceHoverCard workspace={workspace} prHint={prHint} isDragging={isDragging}> <WorkspaceHoverCard workspace={workspace} prHint={prHint} isDragging={isDragging}>
@@ -980,9 +1014,9 @@ function WorkspaceRowInner({
</Text> </Text>
</View> </View>
<View style={styles.workspaceRowRight}> <View style={styles.workspaceRowRight}>
{showGlobe ? ( {showScriptsIcon ? (
<View testID="workspace-globe-icon" accessibilityLabel="Services available"> <View testID="workspace-globe-icon" accessibilityLabel="Scripts available">
<Globe size={12} color={theme.colors.foregroundMuted} /> <Terminal size={12} color={theme.colors.foregroundMuted} />
</View> </View>
) : null} ) : null}
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null} {isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
@@ -1037,10 +1071,10 @@ function WorkspaceRowInner({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) : workspace.diffStat ? ( ) : workspace.diffStat ? (
<View style={styles.diffStatRow}> <DiffStat
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text> additions={workspace.diffStat.additions}
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text> deletions={workspace.diffStat.deletions}
</View> />
) : null} ) : null}
{showShortcutBadge && shortcutNumber !== null ? ( {showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadge}> <View style={styles.shortcutBadge}>
@@ -1051,7 +1085,7 @@ function WorkspaceRowInner({
</View> </View>
{prHint ? ( {prHint ? (
<View style={styles.workspacePrBadgeRow}> <View style={styles.workspacePrBadgeRow}>
<CheckStatusIndicator status={prHint.checksStatus ?? "none"} size={12} /> <ChecksBadge checks={prHint.checks} />
<PrBadge hint={prHint} /> <PrBadge hint={prHint} />
</View> </View>
) : null} ) : null}
@@ -2431,22 +2465,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
flexShrink: 0, flexShrink: 0,
}, },
diffStatRow: {
flexDirection: "row",
alignItems: "center",
gap: 6,
flexShrink: 0,
},
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],
},
kebabButton: { kebabButton: {
padding: 2, padding: 2,
borderRadius: 4, borderRadius: 4,
@@ -10,7 +10,9 @@ import { useMutation } from "@tanstack/react-query";
import { Dimensions, Platform, Text, View } from "react-native"; import { Dimensions, Platform, Text, View } from "react-native";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Check, ExternalLink, LoaderCircle, Minus, Play, X } from "lucide-react-native"; import { ExternalLink, LoaderCircle, Play, Terminal } from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon";
import { DiffStat } from "@/components/diff-stat";
import { Pressable } from "react-native"; import { Pressable } from "react-native";
import { Portal } from "@gorhom/portal"; import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet"; import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
@@ -19,6 +21,7 @@ import type { PrHint } from "@/hooks/use-checkout-pr-status-query";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { PrBadge } from "@/components/sidebar-workspace-list"; import { PrBadge } from "@/components/sidebar-workspace-list";
interface Rect { interface Rect {
@@ -105,8 +108,8 @@ function WorkspaceHoverCardDesktop({
const triggerHoveredRef = useRef(false); const triggerHoveredRef = useRef(false);
const contentHoveredRef = useRef(false); const contentHoveredRef = useRef(false);
const hasServices = workspace.services.length > 0; const hasScripts = workspace.scripts.length > 0;
const hasContent = hasServices || prHint !== null; const hasContent = hasScripts || prHint !== null;
const clearGraceTimer = useCallback(() => { const clearGraceTimer = useCallback(() => {
if (graceTimerRef.current) { if (graceTimerRef.current) {
@@ -192,8 +195,8 @@ function WorkspaceHoverCardDesktop({
); );
} }
function getServiceHealthColor(input: { function getScriptHealthColor(input: {
health: SidebarWorkspaceEntry["services"][number]["health"]; health: SidebarWorkspaceEntry["scripts"][number]["health"];
theme: ReturnType<typeof useUnistyles>["theme"]; theme: ReturnType<typeof useUnistyles>["theme"];
}): string { }): string {
if (input.health === "healthy") { if (input.health === "healthy") {
@@ -205,8 +208,8 @@ function getServiceHealthColor(input: {
return input.theme.colors.foregroundMuted; return input.theme.colors.foregroundMuted;
} }
function getServiceHealthLabel( function getScriptHealthLabel(
health: SidebarWorkspaceEntry["services"][number]["health"], health: SidebarWorkspaceEntry["scripts"][number]["health"],
): "Healthy" | "Unhealthy" | "Unknown" { ): "Healthy" | "Unhealthy" | "Unknown" {
if (health === "healthy") { if (health === "healthy") {
return "Healthy"; return "Healthy";
@@ -218,93 +221,6 @@ function getServiceHealthLabel(
} }
function getCheckStatusColor(input: {
status: string;
theme: ReturnType<typeof useUnistyles>["theme"];
}): string {
if (input.status === "success") return input.theme.colors.palette.green[500];
if (input.status === "failure") return input.theme.colors.palette.red[500];
if (input.status === "pending") return input.theme.colors.palette.amber[500];
return input.theme.colors.foregroundMuted;
}
function getCheckStatusIcon(status: string): typeof Check {
if (status === "success") return Check;
if (status === "failure") return X;
return Minus;
}
export function CheckStatusIndicator({
status,
size = 12,
}: {
status: string;
size?: number;
}): ReactElement | null {
const { theme } = useUnistyles();
if (!status || status === "none") return null;
const color = getCheckStatusColor({ status, theme });
const IconComponent = getCheckStatusIcon(status);
return (
<View
style={{
width: size,
height: size,
borderRadius: size / 2,
borderWidth: 1,
borderColor: color,
alignItems: "center",
justifyContent: "center",
}}
>
<IconComponent size={Math.round(size * 0.5)} color={color} strokeWidth={3} />
</View>
);
}
function ChecksSummary({ checks }: { checks: Array<{ status: string }> }): ReactElement {
const { theme } = useUnistyles();
const counts: Record<string, number> = {};
for (const check of checks) {
const bucket = check.status === "success" ? "success" : check.status === "failure" ? "failure" : "pending";
counts[bucket] = (counts[bucket] ?? 0) + 1;
}
const buckets: Array<{ status: string; count: number }> = [];
if (counts.failure) buckets.push({ status: "failure", count: counts.failure });
if (counts.success) buckets.push({ status: "success", count: counts.success });
if (counts.pending) buckets.push({ status: "pending", count: counts.pending });
return (
<>
{buckets.map((bucket) => {
const color = getCheckStatusColor({ status: bucket.status, theme });
return (
<View key={bucket.status} style={checksSummaryStyles.item}>
<Text style={[checksSummaryStyles.count, { color }]}>{bucket.count}</Text>
<CheckStatusIndicator status={bucket.status} size={12} />
</View>
);
})}
</>
);
}
const checksSummaryStyles = StyleSheet.create((theme) => ({
item: {
flexDirection: "row",
alignItems: "center",
gap: 3,
},
count: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
},
}));
function WorkspaceHoverCardContent({ function WorkspaceHoverCardContent({
workspace, workspace,
prHint, prHint,
@@ -325,20 +241,29 @@ function WorkspaceHoverCardContent({
const [triggerRect, setTriggerRect] = useState<Rect | null>(null); const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null); const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const startServiceMutation = useMutation({ const startScriptMutation = useMutation({
mutationFn: async (serviceName: string) => { mutationFn: async (scriptName: string) => {
if (!client) { if (!client) {
throw new Error("Daemon client not available"); throw new Error("Daemon client not available");
} }
const result = await client.startWorkspaceService(workspace.workspaceId, serviceName); const result = await client.startWorkspaceScript(workspace.workspaceId, scriptName);
if (result.error) { if (result.error) {
throw new Error(result.error); throw new Error(result.error);
} }
return result; return result;
}, },
onError: (error, serviceName) => { onSuccess: (data) => {
if (data.terminalId) {
navigateToPreparedWorkspaceTab({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
target: { kind: "terminal", terminalId: data.terminalId },
});
}
},
onError: (error, scriptName) => {
toast.show( toast.show(
error instanceof Error ? error.message : `Failed to start ${serviceName}`, error instanceof Error ? error.message : `Failed to start ${scriptName}`,
{ variant: "error" }, { variant: "error" },
); );
}, },
@@ -392,7 +317,7 @@ function WorkspaceHoverCardContent({
onPointerEnter={onContentEnter} onPointerEnter={onContentEnter}
onPointerLeave={onContentLeave} onPointerLeave={onContentLeave}
accessibilityRole="menu" accessibilityRole="menu"
accessibilityLabel="Workspace services" accessibilityLabel="Workspace scripts"
testID="workspace-hover-card" testID="workspace-hover-card"
style={[ style={[
styles.card, styles.card,
@@ -412,44 +337,47 @@ function WorkspaceHoverCardContent({
{prHint || workspace.diffStat ? ( {prHint || workspace.diffStat ? (
<View style={styles.cardMetaRow}> <View style={styles.cardMetaRow}>
{workspace.diffStat ? ( {workspace.diffStat ? (
<View style={styles.diffStatRow}> <DiffStat
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text> additions={workspace.diffStat.additions}
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text> deletions={workspace.diffStat.deletions}
</View> />
) : null} ) : null}
{prHint ? <PrBadge hint={prHint} /> : null} {prHint ? <PrBadge hint={prHint} /> : null}
</View> </View>
) : null} ) : null}
{workspace.services.length > 0 ? ( {workspace.scripts.length > 0 ? (
<> <>
<View style={styles.separator} /> <View style={styles.separator} />
<Text style={styles.sectionLabel}>Services</Text> <View style={styles.sectionLabelRow}>
<View style={styles.sectionList} testID="hover-card-service-list"> <Terminal size={12} color={theme.colors.foregroundMuted} />
{workspace.services.map((service) => { <Text style={styles.sectionLabel}>Scripts</Text>
const isRunning = service.lifecycle === "running"; </View>
const isLinkable = isRunning && !!service.url; <View style={styles.sectionList} testID="hover-card-script-list">
{workspace.scripts.map((script) => {
const isRunning = script.lifecycle === "running";
const isLinkable = isRunning && !!script.url;
return ( return (
<Pressable <Pressable
key={service.hostname} key={script.hostname}
accessibilityRole={isLinkable ? "link" : undefined} accessibilityRole={isLinkable ? "link" : undefined}
accessibilityLabel={`${service.serviceName} service — ${isRunning ? getServiceHealthLabel(service.health) : "Stopped"}`} accessibilityLabel={`${script.scriptName} script — ${isRunning ? getScriptHealthLabel(script.health) : "Stopped"}`}
testID={`hover-card-service-${service.serviceName}`} testID={`hover-card-script-${script.scriptName}`}
style={({ hovered }) => [ style={({ hovered }) => [
styles.listRow, styles.listRow,
hovered && isLinkable && styles.listRowHovered, hovered && isLinkable && styles.listRowHovered,
]} ]}
onPress={isLinkable ? () => void openExternalUrl(service.url!) : undefined} onPress={isLinkable ? () => void openExternalUrl(script.url!) : undefined}
disabled={!isLinkable} disabled={!isLinkable}
> >
{({ hovered }) => ( {({ hovered }) => (
<> <>
<View <View
testID={`hover-card-service-health-${service.serviceName}`} testID={`hover-card-script-health-${script.scriptName}`}
style={[ style={[
styles.statusDot, styles.statusDot,
{ {
backgroundColor: isRunning backgroundColor: isRunning
? getServiceHealthColor({ health: service.health, theme }) ? getScriptHealthColor({ health: script.health, theme })
: theme.colors.foregroundMuted, : theme.colors.foregroundMuted,
}, },
]} ]}
@@ -465,44 +393,68 @@ function WorkspaceHoverCardContent({
]} ]}
numberOfLines={1} numberOfLines={1}
> >
{service.serviceName} {script.scriptName}
</Text> </Text>
{isRunning && service.url ? ( {isRunning && script.url ? (
<Text style={styles.listRowSecondary} numberOfLines={1}> <Text style={styles.listRowSecondary} numberOfLines={1}>
{service.url.replace(/^https?:\/\//, "")} {script.url.replace(/^https?:\/\//, "")}
</Text> </Text>
) : ( ) : (
<View style={styles.listRowSpacer} /> <View style={styles.listRowSpacer} />
)} )}
{isRunning ? ( {isRunning ? (
service.url ? ( script.url && hovered ? (
<ExternalLink <View
size={12} style={[
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} styles.externalLinkOverlay,
/> {
backgroundImage: `linear-gradient(to right, transparent, ${theme.colors.surface2} 40%)`,
},
]}
>
<ExternalLink
size={12}
color={theme.colors.foreground}
/>
</View>
) : null ) : null
) : ( ) : (
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`Start ${service.serviceName} service`} accessibilityLabel={`Run ${script.scriptName} script`}
testID={`hover-card-service-start-${service.serviceName}`} testID={`hover-card-script-start-${script.scriptName}`}
hitSlop={4} hitSlop={4}
disabled={startServiceMutation.isPending} disabled={startScriptMutation.isPending}
onPress={(event) => { onPress={(event) => {
event.stopPropagation(); event.stopPropagation();
startServiceMutation.mutate(service.serviceName); startScriptMutation.mutate(script.scriptName);
}} }}
style={styles.startButton}
> >
{({ hovered: actionHovered }) => {({ hovered: actionHovered }) =>
startServiceMutation.isPending && startScriptMutation.isPending &&
startServiceMutation.variables === service.serviceName ? ( startScriptMutation.variables === script.scriptName ? (
<LoaderCircle size={12} color={theme.colors.foregroundMuted} /> <LoaderCircle size={12} color={theme.colors.foregroundMuted} />
) : ( ) : (
<Play <>
size={12} <Play
color={actionHovered ? theme.colors.foreground : theme.colors.foregroundMuted} size={10}
fill="transparent" color={actionHovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/> fill="transparent"
/>
<Text
style={[
styles.startButtonLabel,
{
color: actionHovered
? theme.colors.foreground
: theme.colors.foregroundMuted,
},
]}
>
Run
</Text>
</>
) )
} }
</Pressable> </Pressable>
@@ -525,18 +477,53 @@ function WorkspaceHoverCardContent({
]} ]}
onPress={() => void openExternalUrl(`${prHint.url}/checks`)} onPress={() => void openExternalUrl(`${prHint.url}/checks`)}
> >
{({ hovered }) => ( {({ hovered }) => {
<> const checks = prHint.checks!;
<Text style={styles.checksSummaryLabel}>Checks</Text> const failed = checks.filter((c) => c.status === "failure").length;
<View style={styles.checksSummaryCounts}> const pending = checks.filter((c) => c.status === "pending").length;
<ChecksSummary checks={prHint.checks!} />
</View> let badgeColor: string;
<ExternalLink let badgeLabel: string;
size={12}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} if (failed > 0) {
/> badgeColor = theme.colors.palette.red[500];
</> badgeLabel = `${failed} failed`;
)} } else if (pending > 0) {
badgeColor = theme.colors.palette.amber[500];
badgeLabel = `${pending} running`;
} else {
badgeColor = theme.colors.palette.green[500];
badgeLabel = `${checks.length} passed`;
}
return (
<>
<GitHubIcon size={12} color={theme.colors.foregroundMuted} />
<Text style={styles.checksSummaryLabel}>Checks</Text>
<View style={styles.checksSummaryCounts}>
<View style={[styles.checksDot, { backgroundColor: badgeColor }]} />
<Text style={[styles.checksStatusText, { color: badgeColor }]}>
{badgeLabel}
</Text>
</View>
{hovered ? (
<View
style={[
styles.externalLinkOverlay,
{
backgroundImage: `linear-gradient(to right, transparent, ${theme.colors.surface2} 40%)`,
},
]}
>
<ExternalLink
size={12}
color={theme.colors.foreground}
/>
</View>
) : null}
</>
);
}}
</Pressable> </Pressable>
</> </>
) : null} ) : null}
@@ -560,7 +547,7 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: 1, borderWidth: 1,
borderColor: theme.colors.borderAccent, borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
paddingVertical: theme.spacing[2], paddingTop: theme.spacing[2],
shadowColor: "#000", shadowColor: "#000",
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2, shadowOpacity: 0.2,
@@ -589,33 +576,23 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[2], paddingBottom: theme.spacing[2],
}, },
diffStatRow: {
flexDirection: "row",
alignItems: "center",
gap: 4,
},
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],
},
separator: { separator: {
height: 1, height: 1,
backgroundColor: theme.colors.border, backgroundColor: theme.colors.border,
}, },
sectionLabel: { sectionLabelRow: {
fontSize: theme.fontSize.xs, flexDirection: "row",
fontWeight: theme.fontWeight.medium, alignItems: "center",
color: theme.colors.foregroundMuted, gap: theme.spacing[1.5],
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
paddingTop: theme.spacing[2], paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[1], paddingBottom: theme.spacing[1],
}, },
sectionLabel: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
sectionList: { sectionList: {
paddingBottom: theme.spacing[1], paddingBottom: theme.spacing[1],
}, },
@@ -630,13 +607,26 @@ const styles = StyleSheet.create((theme) => ({
listRowHovered: { listRowHovered: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
}, },
externalLinkOverlay: {
position: "absolute",
right: 0,
top: 0,
bottom: 0,
paddingLeft: theme.spacing[4],
paddingRight: theme.spacing[3],
alignItems: "center",
justifyContent: "center",
},
listRowLabel: { listRowLabel: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
lineHeight: 18,
flexShrink: 0, flexShrink: 0,
}, },
listRowSecondary: { listRowSecondary: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.sm,
lineHeight: 18,
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
textAlign: "right", textAlign: "right",
@@ -650,25 +640,44 @@ const styles = StyleSheet.create((theme) => ({
height: 8, height: 8,
borderRadius: 4, borderRadius: 4,
flexShrink: 0, flexShrink: 0,
marginLeft: 2,
}, },
checksSummaryRow: { checksSummaryRow: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[2], gap: theme.spacing[1.5],
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
paddingVertical: 6, paddingVertical: 6,
minHeight: 28, minHeight: 28,
}, },
checksSummaryLabel: { checksSummaryLabel: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium, fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}, },
checksSummaryCounts: { checksSummaryCounts: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[2], gap: 4,
flex: 1, flex: 1,
justifyContent: "flex-end", justifyContent: "flex-end",
}, },
checksDot: {
width: 6,
height: 6,
borderRadius: 3,
},
checksStatusText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
},
startButton: {
flexDirection: "row",
alignItems: "center",
gap: 3,
},
startButtonLabel: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
},
})); }));
@@ -1,11 +1,11 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { WorkspaceServicePayload } from "@server/shared/messages"; import type { WorkspaceScriptPayload } from "@server/shared/messages";
import type { WorkspaceDescriptor } from "@/stores/session-store"; import type { WorkspaceDescriptor } from "@/stores/session-store";
import { patchWorkspaceServices } from "./session-workspace-services"; import { patchWorkspaceScripts } from "./session-workspace-scripts";
function workspace(input: { function workspace(input: {
id: string; id: string;
services?: WorkspaceDescriptor["services"]; scripts?: WorkspaceDescriptor["scripts"];
}): WorkspaceDescriptor { }): WorkspaceDescriptor {
return { return {
id: input.id, id: input.id,
@@ -19,12 +19,12 @@ function workspace(input: {
status: "running", status: "running",
activityAt: null, activityAt: null,
diffStat: null, diffStat: null,
services: input.services ?? [], scripts: input.scripts ?? [],
}; };
} }
const runningService: WorkspaceServicePayload = { const runningScript: WorkspaceScriptPayload = {
serviceName: "web", scriptName: "web",
hostname: "main.web.localhost", hostname: "main.web.localhost",
port: 3000, port: 3000,
url: "http://main.web.localhost:6767", url: "http://main.web.localhost:6767",
@@ -32,21 +32,21 @@ const runningService: WorkspaceServicePayload = {
health: "healthy", health: "healthy",
}; };
describe("patchWorkspaceServices", () => { describe("patchWorkspaceScripts", () => {
it("patches only the matching workspace services", () => { it("patches only the matching workspace scripts", () => {
const other = workspace({ id: "/repo/other", services: [] }); const other = workspace({ id: "/repo/other", scripts: [] });
const current = new Map<string, WorkspaceDescriptor>([ const current = new Map<string, WorkspaceDescriptor>([
["/repo/main", workspace({ id: "/repo/main", services: [] })], ["/repo/main", workspace({ id: "/repo/main", scripts: [] })],
[other.id, other], [other.id, other],
]); ]);
const next = patchWorkspaceServices(current, { const next = patchWorkspaceScripts(current, {
workspaceId: "/repo/main", workspaceId: "/repo/main",
services: [runningService], scripts: [runningScript],
}); });
expect(next).not.toBe(current); expect(next).not.toBe(current);
expect(next.get("/repo/main")?.services).toEqual([runningService]); expect(next.get("/repo/main")?.scripts).toEqual([runningScript]);
expect(next.get("/repo/other")).toBe(other); expect(next.get("/repo/other")).toBe(other);
}); });
@@ -56,7 +56,7 @@ describe("patchWorkspaceServices", () => {
"42", "42",
workspace({ workspace({
id: "42", id: "42",
services: [], scripts: [],
}), }),
], ],
]); ]);
@@ -66,26 +66,26 @@ describe("patchWorkspaceServices", () => {
workspaceDirectory: "C:\\repo\\main\\", workspaceDirectory: "C:\\repo\\main\\",
}); });
const next = patchWorkspaceServices(current, { const next = patchWorkspaceScripts(current, {
workspaceId: "C:/repo/main", workspaceId: "C:/repo/main",
services: [runningService], scripts: [runningScript],
}); });
expect(next).not.toBe(current); expect(next).not.toBe(current);
expect(next.get("42")?.services).toEqual([runningService]); expect(next.get("42")?.scripts).toEqual([runningScript]);
}); });
it("ignores updates for unknown workspaces", () => { it("ignores updates for unknown workspaces", () => {
const current = new Map<string, WorkspaceDescriptor>([ const current = new Map<string, WorkspaceDescriptor>([
["/repo/main", workspace({ id: "/repo/main", services: [] })], ["/repo/main", workspace({ id: "/repo/main", scripts: [] })],
]); ]);
const next = patchWorkspaceServices(current, { const next = patchWorkspaceScripts(current, {
workspaceId: "/repo/missing", workspaceId: "/repo/missing",
services: [runningService], scripts: [runningScript],
}); });
expect(next).toBe(current); expect(next).toBe(current);
expect(next.get("/repo/main")?.services).toEqual([]); expect(next.get("/repo/main")?.scripts).toEqual([]);
}); });
}); });
@@ -53,7 +53,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"; import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
// 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";
@@ -1168,9 +1168,9 @@ 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) => { const unsubScriptStatusUpdate = client.on("script_status_update", (message) => {
if (message.type !== "service_status_update") return; if (message.type !== "script_status_update") return;
setWorkspaces(serverId, (prev) => patchWorkspaceServices(prev, message.payload)); setWorkspaces(serverId, (prev) => patchWorkspaceScripts(prev, message.payload));
}); });
const unsubWorkspaceSetupProgress = client.on("workspace_setup_progress", (message) => { const unsubWorkspaceSetupProgress = client.on("workspace_setup_progress", (message) => {
@@ -1538,7 +1538,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
unsubAgentStream(); unsubAgentStream();
unsubAgentTimeline(); unsubAgentTimeline();
unsubWorkspaceUpdate(); unsubWorkspaceUpdate();
unsubServiceStatusUpdate(); unsubScriptStatusUpdate();
unsubWorkspaceSetupProgress(); unsubWorkspaceSetupProgress();
unsubWorkspaceSetupStatusResponse(); unsubWorkspaceSetupStatusResponse();
unsubStatus(); unsubStatus();
@@ -1,10 +1,10 @@
import type { ServiceStatusUpdateMessage } from "@server/shared/messages"; import type { ScriptStatusUpdateMessage } from "@server/shared/messages";
import type { WorkspaceDescriptor } from "@/stores/session-store"; import type { WorkspaceDescriptor } from "@/stores/session-store";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution"; import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
export function patchWorkspaceServices( export function patchWorkspaceScripts(
workspaces: Map<string, WorkspaceDescriptor>, workspaces: Map<string, WorkspaceDescriptor>,
update: ServiceStatusUpdateMessage["payload"], update: ScriptStatusUpdateMessage["payload"],
): Map<string, WorkspaceDescriptor> { ): Map<string, WorkspaceDescriptor> {
const workspaceKey = resolveWorkspaceMapKeyByIdentity({ const workspaceKey = resolveWorkspaceMapKeyByIdentity({
workspaces, workspaces,
@@ -22,7 +22,7 @@ export function patchWorkspaceServices(
const next = new Map(workspaces); const next = new Map(workspaces);
next.set(workspaceKey, { next.set(workspaceKey, {
...existing, ...existing,
services: update.services.map((s) => ({ ...s })), scripts: update.scripts.map((s) => ({ ...s })),
}); });
return next; return next;
} }
@@ -81,7 +81,7 @@ describe("openProjectDirectly", () => {
status: "done" as const, status: "done" as const,
activityAt: null, activityAt: null,
diffStat: null, diffStat: null,
services: [], scripts: [],
}, },
})), })),
}, },
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { WorkspaceServicePayload } from "@server/shared/messages"; import type { WorkspaceScriptPayload } from "@server/shared/messages";
import { import {
appendMissingOrderKeys, appendMissingOrderKeys,
applyStoredOrdering, applyStoredOrdering,
@@ -25,7 +25,7 @@ function workspace(
| "workspaceDirectory" | "workspaceDirectory"
| "projectKind" | "projectKind"
| "workspaceKind" | "workspaceKind"
| "services" | "scripts"
> >
>, >,
): WorkspaceDescriptor { ): WorkspaceDescriptor {
@@ -41,12 +41,12 @@ function workspace(
status: input.status, status: input.status,
activityAt: input.activityAt, activityAt: input.activityAt,
diffStat: null, diffStat: null,
services: input.services ?? [], scripts: input.scripts ?? [],
}; };
} }
const runningService: WorkspaceServicePayload = { const runningScript: WorkspaceScriptPayload = {
serviceName: "web", scriptName: "web",
hostname: "main.web.localhost", hostname: "main.web.localhost",
port: 3000, port: 3000,
url: "http://main.web.localhost:6767", url: "http://main.web.localhost:6767",
@@ -54,8 +54,8 @@ const runningService: WorkspaceServicePayload = {
health: "healthy", health: "healthy",
}; };
const stoppedService: WorkspaceServicePayload = { const stoppedScript: WorkspaceScriptPayload = {
serviceName: "api", scriptName: "api",
hostname: "main.api.localhost", hostname: "main.api.localhost",
port: 3001, port: 3001,
url: "http://main.api.localhost:6767", url: "http://main.api.localhost:6767",
@@ -143,7 +143,7 @@ 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", () => { it("threads scripts into workspace rows and derives hasRunningScripts", () => {
const projects = buildSidebarProjectsFromWorkspaces({ const projects = buildSidebarProjectsFromWorkspaces({
serverId: "srv", serverId: "srv",
workspaces: [ workspaces: [
@@ -153,15 +153,15 @@ describe("buildSidebarProjectsFromWorkspaces", () => {
name: "main", name: "main",
status: "running", status: "running",
activityAt: new Date("2026-01-01T00:00:00.000Z"), activityAt: new Date("2026-01-01T00:00:00.000Z"),
services: [runningService, stoppedService], scripts: [runningScript, stoppedScript],
}), }),
], ],
projectOrder: [], projectOrder: [],
workspaceOrderByScope: {}, workspaceOrderByScope: {},
}); });
expect(projects[0]?.workspaces[0]?.services).toEqual([runningService, stoppedService]); expect(projects[0]?.workspaces[0]?.scripts).toEqual([runningScript, stoppedScript]);
expect(projects[0]?.workspaces[0]?.hasRunningServices).toBe(true); expect(projects[0]?.workspaces[0]?.hasRunningScripts).toBe(true);
}); });
it("preserves stored project order even when activity changes", () => { it("preserves stored project order even when activity changes", () => {
@@ -28,8 +28,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"]; scripts: WorkspaceDescriptor["scripts"];
hasRunningServices: boolean; hasRunningScripts: boolean;
} }
export interface SidebarProjectEntry { export interface SidebarProjectEntry {
@@ -148,8 +148,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, scripts: workspace.scripts,
hasRunningServices: workspace.services.some((service) => service.lifecycle === "running"), hasRunningScripts: workspace.scripts.some((script) => script.lifecycle === "running"),
}; };
project.workspaces.push(row); project.workspaces.push(row);
@@ -273,11 +273,11 @@ function toWorkspaceDescriptor(payload: {
name: string; name: string;
status: WorkspaceDescriptor["status"]; status: WorkspaceDescriptor["status"];
activityAt: string | null; activityAt: string | null;
services?: WorkspaceDescriptorPayload["services"]; scripts?: WorkspaceDescriptorPayload["scripts"];
}): WorkspaceDescriptor { }): WorkspaceDescriptor {
return normalizeWorkspaceDescriptor({ return normalizeWorkspaceDescriptor({
...payload, ...payload,
services: payload.services ?? [], scripts: payload.scripts ?? [],
}); });
} }
@@ -12,6 +12,7 @@ import {
} from "react-native"; } from "react-native";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import { DiffStat } from "@/components/diff-stat";
import { import {
CopyX, CopyX,
ArrowLeftToLine, ArrowLeftToLine,
@@ -50,6 +51,7 @@ import { SplitContainer } from "@/components/split-container";
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon"; import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
import { WorkspaceGitActions } from "@/screens/workspace/workspace-git-actions"; import { WorkspaceGitActions } from "@/screens/workspace/workspace-git-actions";
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button"; import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context"; import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture"; import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
@@ -2105,6 +2107,13 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
cwd={normalizedWorkspaceId} cwd={normalizedWorkspaceId}
/> />
) : null} ) : null}
{!isMobile && workspaceDescriptor && workspaceDescriptor.scripts.length > 0 ? (
<WorkspaceScriptsButton
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
scripts={workspaceDescriptor.scripts}
/>
) : null}
{!isMobile && isGitCheckout ? ( {!isMobile && isGitCheckout ? (
<> <>
{workspaceDirectory ? ( {workspaceDirectory ? (
@@ -2142,14 +2151,10 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
color={iconColor} color={iconColor}
/> />
{workspaceDescriptor?.diffStat ? ( {workspaceDescriptor?.diffStat ? (
<View style={styles.diffStatRow}> <DiffStat
<Text style={styles.diffStatAdditions}> additions={workspaceDescriptor.diffStat.additions}
+{workspaceDescriptor.diffStat.additions} deletions={workspaceDescriptor.diffStat.deletions}
</Text> />
<Text style={styles.diffStatDeletions}>
-{workspaceDescriptor.diffStat.deletions}
</Text>
</View>
) : null} ) : null}
</> </>
); );
@@ -2417,22 +2422,6 @@ const styles = StyleSheet.create((theme) => ({
sourceControlButtonHovered: { sourceControlButtonHovered: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
}, },
diffStatRow: {
flexDirection: "row",
alignItems: "center",
gap: 6,
flexShrink: 0,
},
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],
},
newTabActions: { newTabActions: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -0,0 +1,323 @@
import { type ReactElement } from "react";
import { Pressable, Text, View } from "react-native";
import { useMutation } from "@tanstack/react-query";
import { ChevronDown, ExternalLink, LoaderCircle, Play, Terminal } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/contexts/toast-context";
import { openExternalUrl } from "@/utils/open-external-url";
type Script = WorkspaceDescriptor["scripts"][number];
function getScriptHealthColor(
health: Script["health"],
theme: ReturnType<typeof useUnistyles>["theme"],
): string {
if (health === "healthy") {
return theme.colors.palette.green[500];
}
if (health === "unhealthy") {
return theme.colors.palette.red[500];
}
return theme.colors.foregroundMuted;
}
interface WorkspaceScriptsButtonProps {
serverId: string;
workspaceId: string;
scripts: WorkspaceDescriptor["scripts"];
}
export function WorkspaceScriptsButton({
serverId,
workspaceId,
scripts,
}: WorkspaceScriptsButtonProps): ReactElement | null {
const { theme } = useUnistyles();
const toast = useToast();
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const startScriptMutation = useMutation({
mutationFn: async (scriptName: string) => {
if (!client) {
throw new Error("Daemon client not available");
}
const result = await client.startWorkspaceScript(workspaceId, scriptName);
if (result.error) {
throw new Error(result.error);
}
return result;
},
onError: (error, scriptName) => {
toast.show(
error instanceof Error ? error.message : `Failed to start ${scriptName}`,
{ variant: "error" },
);
},
});
if (scripts.length === 0) {
return null;
}
const hasAnyRunning = scripts.some((s) => s.lifecycle === "running");
return (
<View style={styles.row}>
<View style={styles.splitButton}>
<DropdownMenu>
<DropdownMenuTrigger
testID="workspace-scripts-button"
style={({ hovered, pressed, open }) => [
styles.splitButtonPrimary,
(hovered || pressed || open) && styles.splitButtonPrimaryHovered,
]}
accessibilityRole="button"
accessibilityLabel="Workspace scripts"
>
<View style={styles.splitButtonContent}>
<Terminal size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.splitButtonText}>Scripts</Text>
{hasAnyRunning ? (
<View
style={[
styles.runningDot,
{ backgroundColor: theme.colors.palette.green[500] },
]}
/>
) : null}
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</View>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
minWidth={200}
maxWidth={280}
testID="workspace-scripts-menu"
>
<View style={styles.scriptList}>
{scripts.map((script) => {
const isRunning = script.lifecycle === "running";
const isLinkable = isRunning && !!script.url;
return (
<Pressable
key={script.scriptName}
testID={`workspace-scripts-item-${script.scriptName}`}
accessibilityRole={isLinkable ? "link" : undefined}
accessibilityLabel={`${script.scriptName} script`}
style={({ hovered }) => [
styles.scriptRow,
hovered && isLinkable && styles.scriptRowHovered,
]}
onPress={isLinkable ? () => void openExternalUrl(script.url!) : undefined}
disabled={!isLinkable}
>
{({ hovered }) => (
<>
<View
style={[
styles.statusDot,
{
backgroundColor: isRunning
? getScriptHealthColor(script.health, theme)
: theme.colors.foregroundMuted,
},
]}
/>
<Text
style={[
styles.scriptName,
{
color: isRunning
? theme.colors.foreground
: theme.colors.foregroundMuted,
},
]}
numberOfLines={1}
>
{script.scriptName}
</Text>
{isRunning && script.url ? (
<Text style={styles.scriptUrl} numberOfLines={1}>
{script.url.replace(/^https?:\/\//, "")}
</Text>
) : (
<View style={styles.spacer} />
)}
{isRunning ? (
script.url && hovered ? (
<View
style={[
styles.externalLinkOverlay,
{
backgroundImage: `linear-gradient(to right, transparent, ${theme.colors.surface2} 40%)`,
},
]}
>
<ExternalLink size={12} color={theme.colors.foreground} />
</View>
) : null
) : (
<Pressable
accessibilityRole="button"
accessibilityLabel={`Run ${script.scriptName} script`}
testID={`workspace-scripts-start-${script.scriptName}`}
hitSlop={4}
disabled={startScriptMutation.isPending}
onPress={(event) => {
event.stopPropagation();
startScriptMutation.mutate(script.scriptName);
}}
style={styles.startButton}
>
{({ hovered: actionHovered }) =>
startScriptMutation.isPending &&
startScriptMutation.variables === script.scriptName ? (
<LoaderCircle size={12} color={theme.colors.foregroundMuted} />
) : (
<>
<Play
size={10}
color={
actionHovered
? theme.colors.foreground
: theme.colors.foregroundMuted
}
fill="transparent"
/>
<Text
style={[
styles.startButtonLabel,
{
color: actionHovered
? theme.colors.foreground
: theme.colors.foregroundMuted,
},
]}
>
Run
</Text>
</>
)
}
</Pressable>
)}
</>
)}
</Pressable>
);
})}
</View>
</DropdownMenuContent>
</DropdownMenu>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flexShrink: 0,
},
splitButton: {
flexDirection: "row",
alignItems: "stretch",
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
overflow: "hidden",
},
splitButtonPrimary: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
justifyContent: "center",
},
splitButtonPrimaryHovered: {
backgroundColor: theme.colors.surface2,
},
splitButtonText: {
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.5,
color: theme.colors.foreground,
fontWeight: theme.fontWeight.normal,
},
splitButtonContent: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[1.5],
},
runningDot: {
width: 6,
height: 6,
borderRadius: 3,
},
scriptList: {
paddingVertical: theme.spacing[1],
},
scriptRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: 6,
minHeight: 28,
},
scriptRowHovered: {
backgroundColor: theme.colors.surface2,
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
marginLeft: 2,
flexShrink: 0,
},
scriptName: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
lineHeight: 18,
flexShrink: 0,
},
scriptUrl: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
lineHeight: 18,
flex: 1,
minWidth: 0,
textAlign: "right",
},
spacer: {
flex: 1,
minWidth: 0,
},
externalLinkOverlay: {
position: "absolute",
right: 0,
top: 0,
bottom: 0,
paddingLeft: theme.spacing[4],
paddingRight: theme.spacing[3],
alignItems: "center",
justifyContent: "center",
},
startButton: {
flexDirection: "row",
alignItems: "center",
gap: 3,
},
startButtonLabel: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
},
}));
@@ -20,7 +20,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: [], scripts: [],
}; };
const header = resolveWorkspaceHeader({ workspace }); const header = resolveWorkspaceHeader({ workspace });
+17 -17
View File
@@ -25,7 +25,7 @@ function createWorkspace(
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 ?? [], scripts: input.scripts ?? [],
}; };
} }
@@ -34,10 +34,10 @@ afterEach(() => {
}); });
describe("normalizeWorkspaceDescriptor", () => { describe("normalizeWorkspaceDescriptor", () => {
it("normalizes workspace services and invalid activity timestamps", () => { it("normalizes workspace scripts and invalid activity timestamps", () => {
const services = [ const scripts = [
{ {
serviceName: "web", scriptName: "web",
hostname: "main.web.localhost", hostname: "main.web.localhost",
port: 3000, port: 3000,
url: "http://main.web.localhost:6767", url: "http://main.web.localhost:6767",
@@ -57,13 +57,13 @@ describe("normalizeWorkspaceDescriptor", () => {
status: "running", status: "running",
activityAt: "not-a-date", activityAt: "not-a-date",
diffStat: null, diffStat: null,
services, scripts,
}); });
expect(workspace.activityAt).toBeNull(); expect(workspace.activityAt).toBeNull();
expect(workspace.services).toEqual([ expect(workspace.scripts).toEqual([
{ {
serviceName: "web", scriptName: "web",
hostname: "main.web.localhost", hostname: "main.web.localhost",
port: 3000, port: 3000,
url: "http://main.web.localhost:6767", url: "http://main.web.localhost:6767",
@@ -71,10 +71,10 @@ describe("normalizeWorkspaceDescriptor", () => {
health: "healthy", health: "healthy",
}, },
]); ]);
expect(workspace.services).not.toBe(services); expect(workspace.scripts).not.toBe(scripts);
}); });
it("defaults missing services to an empty array", () => { it("defaults missing scripts to an empty array", () => {
const payload = { const payload = {
id: "1", id: "1",
projectId: "1", projectId: "1",
@@ -87,30 +87,30 @@ describe("normalizeWorkspaceDescriptor", () => {
status: "done", status: "done",
activityAt: null, activityAt: null,
diffStat: null, diffStat: null,
services: [], scripts: [],
} as WorkspaceDescriptorPayload; } as WorkspaceDescriptorPayload;
const workspace = normalizeWorkspaceDescriptor(payload); const workspace = normalizeWorkspaceDescriptor(payload);
expect(workspace.services).toEqual([]); expect(workspace.scripts).toEqual([]);
}); });
}); });
describe("mergeWorkspaces", () => { describe("mergeWorkspaces", () => {
it("preserves services on merged workspace entries", () => { it("preserves scripts on merged workspace entries", () => {
const store = useSessionStore.getState(); const store = useSessionStore.getState();
store.initializeSession("test-server", null as unknown as DaemonClient); store.initializeSession("test-server", null as unknown as DaemonClient);
store.setWorkspaces( store.setWorkspaces(
"test-server", "test-server",
new Map([["/repo/main", createWorkspace({ id: "/repo/main", services: [] })]]), new Map([["/repo/main", createWorkspace({ id: "/repo/main", scripts: [] })]]),
); );
store.mergeWorkspaces("test-server", [ store.mergeWorkspaces("test-server", [
createWorkspace({ createWorkspace({
id: "/repo/main", id: "/repo/main",
services: [ scripts: [
{ {
serviceName: "web", scriptName: "web",
hostname: "main.web.localhost", hostname: "main.web.localhost",
port: 3000, port: 3000,
url: "http://main.web.localhost:6767", url: "http://main.web.localhost:6767",
@@ -121,9 +121,9 @@ describe("mergeWorkspaces", () => {
}), }),
]); ]);
expect(store.getSession("test-server")?.workspaces.get("/repo/main")?.services).toEqual([ expect(store.getSession("test-server")?.workspaces.get("/repo/main")?.scripts).toEqual([
{ {
serviceName: "web", scriptName: "web",
hostname: "main.web.localhost", hostname: "main.web.localhost",
port: 3000, port: 3000,
url: "http://main.web.localhost:6767", url: "http://main.web.localhost:6767",
+6 -3
View File
@@ -124,7 +124,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"]; scripts: WorkspaceDescriptorPayload["scripts"];
} }
export function normalizeWorkspaceDescriptor( export function normalizeWorkspaceDescriptor(
@@ -143,7 +143,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 })), scripts: (payload.scripts ?? []).map((s) => ({ ...s })),
}; };
} }
@@ -964,7 +964,10 @@ export const useSessionStore = create<SessionStore>()(
if (existing === workspace) { if (existing === workspace) {
continue; continue;
} }
next.set(workspace.id, workspace); next.set(
workspace.id,
mergeWorkspaceSnapshotWithExisting({ incoming: workspace, existing }),
);
changed = true; changed = true;
} }
if (!changed) { if (!changed) {
+1
View File
@@ -258,6 +258,7 @@ const commonTheme = {
spacing: { spacing: {
0: 0, 0: 0,
1: 4, 1: 4,
1.5: 6,
2: 8, 2: 8,
3: 12, 3: 12,
4: 16, 4: 16,
@@ -19,8 +19,8 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
activityAt: null, activityAt: null,
statusBucket: "done", statusBucket: "done",
diffStat: null, diffStat: null,
services: [], scripts: [],
hasRunningServices: false, hasRunningScripts: false,
...overrides, ...overrides,
}; };
} }
@@ -17,8 +17,8 @@ function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry {
activityAt: null, activityAt: null,
statusBucket: "done", statusBucket: "done",
diffStat: null, diffStat: null,
services: [], scripts: [],
hasRunningServices: false, hasRunningScripts: false,
}; };
} }
@@ -20,7 +20,7 @@ function workspace(
status: input.status ?? "running", status: input.status ?? "running",
activityAt: input.activityAt ?? null, activityAt: input.activityAt ?? null,
diffStat: input.diffStat ?? null, diffStat: input.diffStat ?? null,
services: input.services ?? [], scripts: input.scripts ?? [],
}; };
} }
@@ -23,7 +23,7 @@ function createWorkspace(
status: input.status ?? "running", status: input.status ?? "running",
activityAt: input.activityAt ?? null, activityAt: input.activityAt ?? null,
diffStat: input.diffStat ?? null, diffStat: input.diffStat ?? null,
services: input.services ?? [], scripts: input.scripts ?? [],
}; };
} }
+6 -6
View File
@@ -1331,19 +1331,19 @@ export class DaemonClient {
}); });
} }
async startWorkspaceService( async startWorkspaceScript(
workspaceId: string, workspaceId: string,
serviceName: string, scriptName: string,
requestId?: string, requestId?: string,
): Promise<Extract<SessionOutboundMessage, { type: "start_workspace_service_response" }>["payload"]> { ): Promise<Extract<SessionOutboundMessage, { type: "start_workspace_script_response" }>["payload"]> {
return this.sendCorrelatedSessionRequest({ return this.sendCorrelatedSessionRequest({
requestId, requestId,
message: { message: {
type: "start_workspace_service_request", type: "start_workspace_script_request",
workspaceId, workspaceId,
serviceName, scriptName,
}, },
responseType: "start_workspace_service_response", responseType: "start_workspace_script_response",
timeout: 10000, timeout: 10000,
}); });
} }
@@ -48,13 +48,13 @@ import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
import { expandUserPath } from "../path-utils.js"; import { expandUserPath } from "../path-utils.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js"; import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
import type { ServiceRouteStore } from "../service-proxy.js"; import type { ScriptRouteStore } from "../script-proxy.js";
export interface AgentManagementMcpOptions { export interface AgentManagementMcpOptions {
agentManager: AgentManager; agentManager: AgentManager;
agentStorage: AgentSnapshotStore; agentStorage: AgentSnapshotStore;
terminalManager?: TerminalManager | null; terminalManager?: TerminalManager | null;
serviceRouteStore?: ServiceRouteStore; scriptRouteStore?: ScriptRouteStore;
getDaemonTcpPort?: () => number | null; getDaemonTcpPort?: () => number | null;
paseoHome?: string; paseoHome?: string;
logger: Logger; logger: Logger;
@@ -350,7 +350,7 @@ export async function createAgentManagementMcpServer(
agentId: snapshot.id, agentId: snapshot.id,
item, item,
}), }),
serviceRouteStore: options.serviceRouteStore, scriptRouteStore: options.scriptRouteStore,
daemonPort: options.getDaemonTcpPort?.() ?? null, daemonPort: options.getDaemonTcpPort?.() ?? null,
logger: childLogger, logger: childLogger,
}); });
@@ -28,13 +28,13 @@ import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
import { expandUserPath, resolvePathFromBase } from "../path-utils.js"; import { expandUserPath, resolvePathFromBase } from "../path-utils.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js"; import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
import type { ServiceRouteStore } from "../service-proxy.js"; import type { ScriptRouteStore } from "../script-proxy.js";
export interface AgentMcpServerOptions { export interface AgentMcpServerOptions {
agentManager: AgentManager; agentManager: AgentManager;
agentStorage: AgentSnapshotStore; agentStorage: AgentSnapshotStore;
terminalManager?: TerminalManager | null; terminalManager?: TerminalManager | null;
serviceRouteStore?: ServiceRouteStore; scriptRouteStore?: ScriptRouteStore;
getDaemonTcpPort?: () => number | null; getDaemonTcpPort?: () => number | null;
paseoHome?: string; paseoHome?: string;
/** /**
@@ -519,7 +519,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
agentId: snapshot.id, agentId: snapshot.id,
item, item,
}), }),
serviceRouteStore: options.serviceRouteStore, scriptRouteStore: options.scriptRouteStore,
daemonPort: options.getDaemonTcpPort?.() ?? null, daemonPort: options.getDaemonTcpPort?.() ?? null,
logger: childLogger, logger: childLogger,
}); });
+34 -34
View File
@@ -9,7 +9,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import type { Logger } from "pino"; import type { Logger } from "pino";
import { createBranchChangeRouteHandler } from "./service-route-branch-handler.js"; import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js";
export type ListenTarget = export type ListenTarget =
| { type: "tcp"; host: string; port: number } | { type: "tcp"; host: string; port: number }
@@ -119,12 +119,12 @@ import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js"; import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js";
import { import {
ServiceRouteStore, ScriptRouteStore,
createServiceProxyMiddleware, createScriptProxyMiddleware,
createServiceProxyUpgradeHandler, createScriptProxyUpgradeHandler,
} from "./service-proxy.js"; } from "./script-proxy.js";
import { ServiceHealthMonitor } from "./service-health-monitor.js"; import { ScriptHealthMonitor } from "./script-health-monitor.js";
import { createServiceStatusEmitter } from "./service-status-projection.js"; import { createScriptStatusEmitter } from "./script-status-projection.js";
import { import {
createVoiceMcpSocketBridgeManager, createVoiceMcpSocketBridgeManager,
type VoiceMcpSocketBridgeManager, type VoiceMcpSocketBridgeManager,
@@ -201,7 +201,7 @@ export interface PaseoDaemon {
agentManager: AgentManager; agentManager: AgentManager;
agentStorage: AgentSnapshotStore; agentStorage: AgentSnapshotStore;
terminalManager: TerminalManager; terminalManager: TerminalManager;
serviceRouteStore: ServiceRouteStore; scriptRouteStore: ScriptRouteStore;
start(): Promise<void>; start(): Promise<void>;
stop(): Promise<void>; stop(): Promise<void>;
getListenTarget(): ListenTarget | null; getListenTarget(): ListenTarget | null;
@@ -232,25 +232,25 @@ export async function createPaseoDaemon(
const app = express(); const app = express();
let boundListenTarget: ListenTarget | null = null; let boundListenTarget: ListenTarget | null = null;
const serviceRouteStore = new ServiceRouteStore(); const scriptRouteStore = new ScriptRouteStore();
let wsServer: VoiceAssistantWebSocketServer | null = null; let wsServer: VoiceAssistantWebSocketServer | null = null;
const serviceHealthMonitor = new ServiceHealthMonitor({ const scriptHealthMonitor = new ScriptHealthMonitor({
routeStore: serviceRouteStore, routeStore: scriptRouteStore,
onChange: createServiceStatusEmitter({ onChange: createScriptStatusEmitter({
sessions: () => sessions: () =>
wsServer?.listActiveSessions().map((session) => ({ wsServer?.listActiveSessions().map((session) => ({
emit: (message) => session.emitServerMessage(message), emit: (message) => session.emitServerMessage(message),
})) ?? [], })) ?? [],
routeStore: serviceRouteStore, routeStore: scriptRouteStore,
daemonPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), daemonPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
}), }),
}); });
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore: serviceRouteStore, routeStore: scriptRouteStore,
emitServiceStatusUpdate: (workspaceId, services) => { emitScriptStatusUpdate: (workspaceId, scripts) => {
const message = { const message = {
type: "service_status_update" as const, type: "script_status_update" as const,
payload: { workspaceId, services }, payload: { workspaceId, scripts },
}; };
const activeSessions = wsServer?.listActiveSessions() ?? []; const activeSessions = wsServer?.listActiveSessions() ?? [];
for (const session of activeSessions) { for (const session of activeSessions) {
@@ -273,12 +273,12 @@ export async function createPaseoDaemon(
}); });
} }
// Service proxy — intercepts requests for registered *.localhost hostnames // Script proxy — intercepts requests for registered *.localhost hostnames
// and forwards them to the corresponding local service port. Placed after // and forwards them to the corresponding local script port. Placed after
// the host allowlist (*.localhost is already allowed) but before CORS and // the host allowlist (*.localhost is already allowed) but before CORS and
// the rest of the routes so proxied requests skip unnecessary middleware. // the rest of the routes so proxied requests skip unnecessary middleware.
app.use( app.use(
createServiceProxyMiddleware({ routeStore: serviceRouteStore, logger }), createScriptProxyMiddleware({ routeStore: scriptRouteStore, logger }),
); );
// CORS - allow same-origin + configured origins // CORS - allow same-origin + configured origins
@@ -384,15 +384,15 @@ export async function createPaseoDaemon(
database = await openPaseoDatabase(path.join(config.paseoHome, "db")); database = await openPaseoDatabase(path.join(config.paseoHome, "db"));
logger.info({ elapsed: elapsed() }, "Paseo database opened"); logger.info({ elapsed: elapsed() }, "Paseo database opened");
// Service proxy WebSocket upgrade handler — must be registered before the // Script proxy WebSocket upgrade handler — must be registered before the
// VoiceAssistantWebSocketServer attaches its own "upgrade" listener so that // VoiceAssistantWebSocketServer attaches its own "upgrade" listener so that
// service-bound upgrades are forwarded first. The handler is a no-op for // script-bound upgrades are forwarded first. The handler is a no-op for
// requests that don't match a registered service route. // requests that don't match a registered script route.
const serviceProxyUpgradeHandler = createServiceProxyUpgradeHandler({ const scriptProxyUpgradeHandler = createScriptProxyUpgradeHandler({
routeStore: serviceRouteStore, routeStore: scriptRouteStore,
logger, logger,
}); });
httpServer.on("upgrade", serviceProxyUpgradeHandler); httpServer.on("upgrade", scriptProxyUpgradeHandler);
const agentStorage = new DbAgentSnapshotStore(database.db); const agentStorage = new DbAgentSnapshotStore(database.db);
const chatService = new FileBackedChatService({ const chatService = new FileBackedChatService({
@@ -485,7 +485,7 @@ export async function createPaseoDaemon(
agentManager, agentManager,
agentStorage, agentStorage,
terminalManager, terminalManager,
serviceRouteStore, scriptRouteStore,
getDaemonTcpPort: () => getDaemonTcpPort: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null, boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
paseoHome: config.paseoHome, paseoHome: config.paseoHome,
@@ -514,7 +514,7 @@ export async function createPaseoDaemon(
agentManager, agentManager,
agentStorage, agentStorage,
terminalManager, terminalManager,
serviceRouteStore, scriptRouteStore,
getDaemonTcpPort: () => getDaemonTcpPort: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null, boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
paseoHome: config.paseoHome, paseoHome: config.paseoHome,
@@ -637,7 +637,7 @@ export async function createPaseoDaemon(
agentManager, agentManager,
agentStorage, agentStorage,
terminalManager, terminalManager,
serviceRouteStore, scriptRouteStore,
getDaemonTcpPort: () => getDaemonTcpPort: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null, boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
paseoHome: config.paseoHome, paseoHome: config.paseoHome,
@@ -701,10 +701,10 @@ export async function createPaseoDaemon(
loopService, loopService,
scheduleService, scheduleService,
checkoutDiffManager, checkoutDiffManager,
serviceRouteStore, scriptRouteStore,
handleBranchChange, handleBranchChange,
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
(hostname) => serviceHealthMonitor.getHealthForHostname(hostname), (hostname) => scriptHealthMonitor.getHealthForHostname(hostname),
); );
logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening"); logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening");
@@ -795,12 +795,12 @@ 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(); scriptHealthMonitor.start();
}; };
const stop = async () => { const stop = async () => {
reconciliationService.stop(); reconciliationService.stop();
serviceHealthMonitor.stop(); scriptHealthMonitor.stop();
await closeAllAgents(logger, agentManager); await closeAllAgents(logger, agentManager);
await agentManager.flush().catch(() => undefined); await agentManager.flush().catch(() => undefined);
await shutdownProviders(logger, { await shutdownProviders(logger, {
@@ -831,7 +831,7 @@ export async function createPaseoDaemon(
agentManager, agentManager,
agentStorage, agentStorage,
terminalManager, terminalManager,
serviceRouteStore, scriptRouteStore,
start, start,
stop, stop,
getListenTarget: () => boundListenTarget, getListenTarget: () => boundListenTarget,
@@ -1,11 +1,11 @@
import net from "node:net"; import net from "node:net";
import { scheduler } from "node:timers/promises"; import { scheduler } from "node:timers/promises";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { findFreePort, ServiceRouteStore } from "./service-proxy.js"; import { findFreePort, ScriptRouteStore } from "./script-proxy.js";
import { import {
ServiceHealthMonitor, ScriptHealthMonitor,
type ServiceHealthEntry, type ScriptHealthEntry,
} from "./service-health-monitor.js"; } from "./script-health-monitor.js";
type TcpServerHandle = { type TcpServerHandle = {
port: number; port: number;
@@ -56,7 +56,7 @@ async function advancePoll(ms: number): Promise<void> {
} }
} }
describe("ServiceHealthMonitor", () => { describe("ScriptHealthMonitor", () => {
const servers = new Set<net.Server>(); const servers = new Set<net.Server>();
afterEach(async () => { afterEach(async () => {
@@ -74,16 +74,16 @@ describe("ServiceHealthMonitor", () => {
const healthy = await startTcpServer(); const healthy = await startTcpServer();
servers.add(healthy.server); servers.add(healthy.server);
const routeStore = new ServiceRouteStore(); const routeStore = new ScriptRouteStore();
routeStore.registerRoute({ routeStore.registerRoute({
hostname: "api.localhost", hostname: "api.localhost",
port: healthy.port, port: healthy.port,
workspaceId: "workspace-a", workspaceId: "workspace-a",
serviceName: "api", scriptName: "api",
}); });
const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ServiceHealthMonitor({ const monitor = new ScriptHealthMonitor({
routeStore, routeStore,
onChange, onChange,
pollIntervalMs: 1_000, pollIntervalMs: 1_000,
@@ -98,7 +98,7 @@ describe("ServiceHealthMonitor", () => {
expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith("workspace-a", [ expect(onChange).toHaveBeenCalledWith("workspace-a", [
{ {
serviceName: "api", scriptName: "api",
hostname: "api.localhost", hostname: "api.localhost",
port: healthy.port, port: healthy.port,
health: "healthy", health: "healthy",
@@ -110,16 +110,16 @@ describe("ServiceHealthMonitor", () => {
vi.useFakeTimers(); vi.useFakeTimers();
const deadPort = await findFreePort(); const deadPort = await findFreePort();
const routeStore = new ServiceRouteStore(); const routeStore = new ScriptRouteStore();
routeStore.registerRoute({ routeStore.registerRoute({
hostname: "api.localhost", hostname: "api.localhost",
port: deadPort, port: deadPort,
workspaceId: "workspace-a", workspaceId: "workspace-a",
serviceName: "api", scriptName: "api",
}); });
const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ServiceHealthMonitor({ const monitor = new ScriptHealthMonitor({
routeStore, routeStore,
onChange, onChange,
pollIntervalMs: 1_000, pollIntervalMs: 1_000,
@@ -138,7 +138,7 @@ describe("ServiceHealthMonitor", () => {
expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith("workspace-a", [ expect(onChange).toHaveBeenCalledWith("workspace-a", [
{ {
serviceName: "api", scriptName: "api",
hostname: "api.localhost", hostname: "api.localhost",
port: deadPort, port: deadPort,
health: "unhealthy", health: "unhealthy",
@@ -152,16 +152,16 @@ describe("ServiceHealthMonitor", () => {
const healthy = await startTcpServer(); const healthy = await startTcpServer();
servers.add(healthy.server); servers.add(healthy.server);
const routeStore = new ServiceRouteStore(); const routeStore = new ScriptRouteStore();
routeStore.registerRoute({ routeStore.registerRoute({
hostname: "api.localhost", hostname: "api.localhost",
port: healthy.port, port: healthy.port,
workspaceId: "workspace-a", workspaceId: "workspace-a",
serviceName: "api", scriptName: "api",
}); });
const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ServiceHealthMonitor({ const monitor = new ScriptHealthMonitor({
routeStore, routeStore,
onChange, onChange,
pollIntervalMs: 1_000, pollIntervalMs: 1_000,
@@ -182,16 +182,16 @@ describe("ServiceHealthMonitor", () => {
const healthy = await startTcpServer(); const healthy = await startTcpServer();
servers.add(healthy.server); servers.add(healthy.server);
const routeStore = new ServiceRouteStore(); const routeStore = new ScriptRouteStore();
routeStore.registerRoute({ routeStore.registerRoute({
hostname: "api.localhost", hostname: "api.localhost",
port: healthy.port, port: healthy.port,
workspaceId: "workspace-a", workspaceId: "workspace-a",
serviceName: "api", scriptName: "api",
}); });
const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ServiceHealthMonitor({ const monitor = new ScriptHealthMonitor({
routeStore, routeStore,
onChange, onChange,
pollIntervalMs: 1_000, pollIntervalMs: 1_000,
@@ -209,7 +209,7 @@ describe("ServiceHealthMonitor", () => {
expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith("workspace-a", [ expect(onChange).toHaveBeenCalledWith("workspace-a", [
{ {
serviceName: "api", scriptName: "api",
hostname: "api.localhost", hostname: "api.localhost",
port: healthy.port, port: healthy.port,
health: "healthy", health: "healthy",
@@ -223,16 +223,16 @@ describe("ServiceHealthMonitor", () => {
const healthy = await startTcpServer(); const healthy = await startTcpServer();
servers.add(healthy.server); servers.add(healthy.server);
const routeStore = new ServiceRouteStore(); const routeStore = new ScriptRouteStore();
routeStore.registerRoute({ routeStore.registerRoute({
hostname: "api.localhost", hostname: "api.localhost",
port: healthy.port, port: healthy.port,
workspaceId: "workspace-a", workspaceId: "workspace-a",
serviceName: "api", scriptName: "api",
}); });
const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ServiceHealthMonitor({ const monitor = new ScriptHealthMonitor({
routeStore, routeStore,
onChange, onChange,
pollIntervalMs: 1_000, pollIntervalMs: 1_000,
@@ -257,7 +257,7 @@ describe("ServiceHealthMonitor", () => {
expect(onChange).toHaveBeenCalledTimes(2); expect(onChange).toHaveBeenCalledTimes(2);
expect(onChange).toHaveBeenLastCalledWith("workspace-a", [ expect(onChange).toHaveBeenLastCalledWith("workspace-a", [
{ {
serviceName: "api", scriptName: "api",
hostname: "api.localhost", hostname: "api.localhost",
port: healthy.port, port: healthy.port,
health: "unhealthy", health: "unhealthy",
@@ -271,16 +271,16 @@ describe("ServiceHealthMonitor", () => {
const healthy = await startTcpServer(); const healthy = await startTcpServer();
servers.add(healthy.server); servers.add(healthy.server);
const routeStore = new ServiceRouteStore(); const routeStore = new ScriptRouteStore();
routeStore.registerRoute({ routeStore.registerRoute({
hostname: "api.localhost", hostname: "api.localhost",
port: healthy.port, port: healthy.port,
workspaceId: "workspace-a", workspaceId: "workspace-a",
serviceName: "api", scriptName: "api",
}); });
const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ServiceHealthMonitor({ const monitor = new ScriptHealthMonitor({
routeStore, routeStore,
onChange, onChange,
pollIntervalMs: 1_000, pollIntervalMs: 1_000,
@@ -311,22 +311,22 @@ describe("ServiceHealthMonitor", () => {
servers.add(api.server); servers.add(api.server);
servers.add(web.server); servers.add(web.server);
const routeStore = new ServiceRouteStore(); const routeStore = new ScriptRouteStore();
routeStore.registerRoute({ routeStore.registerRoute({
hostname: "api.localhost", hostname: "api.localhost",
port: api.port, port: api.port,
workspaceId: "workspace-a", workspaceId: "workspace-a",
serviceName: "api", scriptName: "api",
}); });
routeStore.registerRoute({ routeStore.registerRoute({
hostname: "web.localhost", hostname: "web.localhost",
port: web.port, port: web.port,
workspaceId: "workspace-a", workspaceId: "workspace-a",
serviceName: "web", scriptName: "web",
}); });
const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ServiceHealthMonitor({ const monitor = new ScriptHealthMonitor({
routeStore, routeStore,
onChange, onChange,
pollIntervalMs: 1_000, pollIntervalMs: 1_000,
@@ -341,13 +341,13 @@ describe("ServiceHealthMonitor", () => {
expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith("workspace-a", [ expect(onChange).toHaveBeenCalledWith("workspace-a", [
{ {
serviceName: "api", scriptName: "api",
hostname: "api.localhost", hostname: "api.localhost",
port: api.port, port: api.port,
health: "healthy", health: "healthy",
}, },
{ {
serviceName: "web", scriptName: "web",
hostname: "web.localhost", hostname: "web.localhost",
port: web.port, port: web.port,
health: "healthy", health: "healthy",
@@ -361,16 +361,16 @@ describe("ServiceHealthMonitor", () => {
const healthy = await startTcpServer(); const healthy = await startTcpServer();
servers.add(healthy.server); servers.add(healthy.server);
const routeStore = new ServiceRouteStore(); const routeStore = new ScriptRouteStore();
routeStore.registerRoute({ routeStore.registerRoute({
hostname: "api.localhost", hostname: "api.localhost",
port: healthy.port, port: healthy.port,
workspaceId: "workspace-a", workspaceId: "workspace-a",
serviceName: "api", scriptName: "api",
}); });
const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ServiceHealthMonitor({ const monitor = new ScriptHealthMonitor({
routeStore, routeStore,
onChange, onChange,
pollIntervalMs: 1_000, pollIntervalMs: 1_000,
@@ -396,22 +396,22 @@ describe("ServiceHealthMonitor", () => {
servers.add(api.server); servers.add(api.server);
servers.add(web.server); servers.add(web.server);
const routeStore = new ServiceRouteStore(); const routeStore = new ScriptRouteStore();
routeStore.registerRoute({ routeStore.registerRoute({
hostname: "api.localhost", hostname: "api.localhost",
port: api.port, port: api.port,
workspaceId: "workspace-a", workspaceId: "workspace-a",
serviceName: "api", scriptName: "api",
}); });
routeStore.registerRoute({ routeStore.registerRoute({
hostname: "web.localhost", hostname: "web.localhost",
port: web.port, port: web.port,
workspaceId: "workspace-a", workspaceId: "workspace-a",
serviceName: "web", scriptName: "web",
}); });
const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ServiceHealthMonitor({ const monitor = new ScriptHealthMonitor({
routeStore, routeStore,
onChange, onChange,
pollIntervalMs: 1_000, pollIntervalMs: 1_000,
@@ -439,13 +439,13 @@ describe("ServiceHealthMonitor", () => {
expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith("workspace-a", [ expect(onChange).toHaveBeenCalledWith("workspace-a", [
{ {
serviceName: "api", scriptName: "api",
hostname: "api.localhost", hostname: "api.localhost",
port: api.port, port: api.port,
health: "unhealthy", health: "unhealthy",
}, },
{ {
serviceName: "web", scriptName: "web",
hostname: "web.localhost", hostname: "web.localhost",
port: web.port, port: web.port,
health: "unhealthy", health: "unhealthy",
@@ -1,24 +1,24 @@
import net from "node:net"; import net from "node:net";
import type { ServiceRouteEntry, ServiceRouteStore } from "./service-proxy.js"; import type { ScriptRouteEntry, ScriptRouteStore } from "./script-proxy.js";
export interface ServiceHealthEntry { export interface ScriptHealthEntry {
serviceName: string; scriptName: string;
hostname: string; hostname: string;
port: number; port: number;
health: "healthy" | "unhealthy"; health: "healthy" | "unhealthy";
} }
type RouteHealthState = { type RouteHealthState = {
health: ServiceHealthEntry["health"] | null; health: ScriptHealthEntry["health"] | null;
consecutiveFailures: number; consecutiveFailures: number;
registeredAt: number; registeredAt: number;
}; };
export class ServiceHealthMonitor { export class ScriptHealthMonitor {
private readonly routeStore: ServiceRouteStore; private readonly routeStore: ScriptRouteStore;
private readonly onChange: ( private readonly onChange: (
workspaceId: string, workspaceId: string,
services: ServiceHealthEntry[], scripts: ScriptHealthEntry[],
) => void; ) => void;
private readonly pollIntervalMs: number; private readonly pollIntervalMs: number;
private readonly probeTimeoutMs: number; private readonly probeTimeoutMs: number;
@@ -38,8 +38,8 @@ export class ServiceHealthMonitor {
graceMs = 5_000, graceMs = 5_000,
failuresBeforeStopped = 2, failuresBeforeStopped = 2,
}: { }: {
routeStore: ServiceRouteStore; routeStore: ScriptRouteStore;
onChange: (workspaceId: string, services: ServiceHealthEntry[]) => void; onChange: (workspaceId: string, scripts: ScriptHealthEntry[]) => void;
pollIntervalMs?: number; pollIntervalMs?: number;
probeTimeoutMs?: number; probeTimeoutMs?: number;
graceMs?: number; graceMs?: number;
@@ -114,14 +114,14 @@ export class ServiceHealthMonitor {
this.pruneRemovedRoutes(activeHostnames); this.pruneRemovedRoutes(activeHostnames);
for (const workspaceId of changedWorkspaceIds) { for (const workspaceId of changedWorkspaceIds) {
const services = this.buildWorkspaceServiceList(workspaceId); const scripts = this.buildWorkspaceScriptList(workspaceId);
const snapshot = JSON.stringify(services); const snapshot = JSON.stringify(scripts);
if (snapshot === this.lastEmittedSnapshots.get(workspaceId)) { if (snapshot === this.lastEmittedSnapshots.get(workspaceId)) {
continue; continue;
} }
this.lastEmittedSnapshots.set(workspaceId, snapshot); this.lastEmittedSnapshots.set(workspaceId, snapshot);
this.onChange(workspaceId, services); this.onChange(workspaceId, scripts);
} }
} finally { } finally {
this.pollInFlight = false; this.pollInFlight = false;
@@ -152,7 +152,7 @@ export class ServiceHealthMonitor {
} }
} }
private buildWorkspaceServiceList(workspaceId: string): ServiceHealthEntry[] { private buildWorkspaceScriptList(workspaceId: string): ScriptHealthEntry[] {
return this.routeStore return this.routeStore
.listRoutesForWorkspace(workspaceId) .listRoutesForWorkspace(workspaceId)
.flatMap((route) => { .flatMap((route) => {
@@ -160,20 +160,20 @@ export class ServiceHealthMonitor {
if (!state?.health) { if (!state?.health) {
return []; return [];
} }
return [this.toServiceHealthEntry(route, state.health)]; return [this.toScriptHealthEntry(route, state.health)];
}); });
} }
getHealthForHostname(hostname: string): ServiceHealthEntry["health"] | null { getHealthForHostname(hostname: string): ScriptHealthEntry["health"] | null {
return this.routeStates.get(hostname)?.health ?? null; return this.routeStates.get(hostname)?.health ?? null;
} }
private toServiceHealthEntry( private toScriptHealthEntry(
route: ServiceRouteEntry, route: ScriptRouteEntry,
health: ServiceHealthEntry["health"], health: ScriptHealthEntry["health"],
): ServiceHealthEntry { ): ScriptHealthEntry {
return { return {
serviceName: route.serviceName, scriptName: route.scriptName,
hostname: route.hostname, hostname: route.hostname,
port: route.port, port: route.port,
health, health,
@@ -5,11 +5,11 @@ import express from "express";
import WebSocket, { WebSocketServer } from "ws"; import WebSocket, { WebSocketServer } from "ws";
import pino from "pino"; import pino from "pino";
import { import {
ServiceRouteStore, ScriptRouteStore,
createServiceProxyMiddleware, createScriptProxyMiddleware,
createServiceProxyUpgradeHandler, createScriptProxyUpgradeHandler,
findFreePort, findFreePort,
} from "./service-proxy.js"; } from "./script-proxy.js";
const logger = pino({ level: "silent" }); const logger = pino({ level: "silent" });
@@ -24,17 +24,17 @@ function closeServer(server: http.Server): Promise<void> {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ServiceRouteStore // ScriptRouteStore
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe("ServiceRouteStore", () => { describe("ScriptRouteStore", () => {
it("registerRoute and findRoute with exact match", () => { it("registerRoute and findRoute with exact match", () => {
const store = new ServiceRouteStore(); const store = new ScriptRouteStore();
store.registerRoute({ store.registerRoute({
hostname: "editor.localhost", hostname: "editor.localhost",
port: 3000, port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a", workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "editor", scriptName: "editor",
}); });
const route = store.findRoute("editor.localhost"); const route = store.findRoute("editor.localhost");
@@ -42,12 +42,12 @@ describe("ServiceRouteStore", () => {
}); });
it("findRoute strips port from host header", () => { it("findRoute strips port from host header", () => {
const store = new ServiceRouteStore(); const store = new ScriptRouteStore();
store.registerRoute({ store.registerRoute({
hostname: "editor.localhost", hostname: "editor.localhost",
port: 3000, port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a", workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "editor", scriptName: "editor",
}); });
const route = store.findRoute("editor.localhost:6767"); const route = store.findRoute("editor.localhost:6767");
@@ -55,12 +55,12 @@ describe("ServiceRouteStore", () => {
}); });
it("findRoute subdomain match", () => { it("findRoute subdomain match", () => {
const store = new ServiceRouteStore(); const store = new ScriptRouteStore();
store.registerRoute({ store.registerRoute({
hostname: "editor.localhost", hostname: "editor.localhost",
port: 3000, port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a", workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "editor", scriptName: "editor",
}); });
const route = store.findRoute("fix-auth.editor.localhost"); const route = store.findRoute("fix-auth.editor.localhost");
@@ -68,18 +68,18 @@ describe("ServiceRouteStore", () => {
}); });
it("listRoutes returns enriched entries", () => { it("listRoutes returns enriched entries", () => {
const store = new ServiceRouteStore(); const store = new ScriptRouteStore();
store.registerRoute({ store.registerRoute({
hostname: "a.localhost", hostname: "a.localhost",
port: 3000, port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a", workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "web", scriptName: "web",
}); });
store.registerRoute({ store.registerRoute({
hostname: "b.localhost", hostname: "b.localhost",
port: 4000, port: 4000,
workspaceId: "/repo/.paseo/worktrees/feature-b", workspaceId: "/repo/.paseo/worktrees/feature-b",
serviceName: "docs", scriptName: "docs",
}); });
const routes = store.listRoutes(); const routes = store.listRoutes();
@@ -88,35 +88,35 @@ describe("ServiceRouteStore", () => {
hostname: "a.localhost", hostname: "a.localhost",
port: 3000, port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a", workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "web", scriptName: "web",
}); });
expect(routes).toContainEqual({ expect(routes).toContainEqual({
hostname: "b.localhost", hostname: "b.localhost",
port: 4000, port: 4000,
workspaceId: "/repo/.paseo/worktrees/feature-b", workspaceId: "/repo/.paseo/worktrees/feature-b",
serviceName: "docs", scriptName: "docs",
}); });
}); });
it("listRoutesForWorkspace returns only routes for that workspace", () => { it("listRoutesForWorkspace returns only routes for that workspace", () => {
const store = new ServiceRouteStore(); const store = new ScriptRouteStore();
store.registerRoute({ store.registerRoute({
hostname: "a.localhost", hostname: "a.localhost",
port: 3000, port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a", workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "web", scriptName: "web",
}); });
store.registerRoute({ store.registerRoute({
hostname: "b.localhost", hostname: "b.localhost",
port: 4000, port: 4000,
workspaceId: "/repo/.paseo/worktrees/feature-b", workspaceId: "/repo/.paseo/worktrees/feature-b",
serviceName: "docs", scriptName: "docs",
}); });
store.registerRoute({ store.registerRoute({
hostname: "c.localhost", hostname: "c.localhost",
port: 5000, port: 5000,
workspaceId: "/repo/.paseo/worktrees/feature-a", workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "api", scriptName: "api",
}); });
expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([ expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([
@@ -124,24 +124,24 @@ describe("ServiceRouteStore", () => {
hostname: "a.localhost", hostname: "a.localhost",
port: 3000, port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a", workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "web", scriptName: "web",
}, },
{ {
hostname: "c.localhost", hostname: "c.localhost",
port: 5000, port: 5000,
workspaceId: "/repo/.paseo/worktrees/feature-a", workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "api", scriptName: "api",
}, },
]); ]);
}); });
it("removeRoute works", () => { it("removeRoute works", () => {
const store = new ServiceRouteStore(); const store = new ScriptRouteStore();
store.registerRoute({ store.registerRoute({
hostname: "editor.localhost", hostname: "editor.localhost",
port: 3000, port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a", workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "editor", scriptName: "editor",
}); });
store.removeRoute("editor.localhost"); store.removeRoute("editor.localhost");
@@ -149,12 +149,12 @@ describe("ServiceRouteStore", () => {
}); });
it("removeRoute cleans up workspace index", () => { it("removeRoute cleans up workspace index", () => {
const store = new ServiceRouteStore(); const store = new ScriptRouteStore();
store.registerRoute({ store.registerRoute({
hostname: "editor.localhost", hostname: "editor.localhost",
port: 3000, port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a", workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "editor", scriptName: "editor",
}); });
store.removeRoute("editor.localhost"); store.removeRoute("editor.localhost");
@@ -163,24 +163,24 @@ describe("ServiceRouteStore", () => {
}); });
it("removeRoutesForPort works", () => { it("removeRoutesForPort works", () => {
const store = new ServiceRouteStore(); const store = new ScriptRouteStore();
store.registerRoute({ store.registerRoute({
hostname: "a.localhost", hostname: "a.localhost",
port: 3000, port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a", workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "web", scriptName: "web",
}); });
store.registerRoute({ store.registerRoute({
hostname: "b.localhost", hostname: "b.localhost",
port: 3000, port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a", workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "api", scriptName: "api",
}); });
store.registerRoute({ store.registerRoute({
hostname: "c.localhost", hostname: "c.localhost",
port: 4000, port: 4000,
workspaceId: "/repo/.paseo/worktrees/feature-b", workspaceId: "/repo/.paseo/worktrees/feature-b",
serviceName: "docs", scriptName: "docs",
}); });
store.removeRoutesForPort(3000); store.removeRoutesForPort(3000);
@@ -194,18 +194,18 @@ describe("ServiceRouteStore", () => {
}); });
it("removeRoutesForPort cleans up workspace index", () => { it("removeRoutesForPort cleans up workspace index", () => {
const store = new ServiceRouteStore(); const store = new ScriptRouteStore();
store.registerRoute({ store.registerRoute({
hostname: "a.localhost", hostname: "a.localhost",
port: 3000, port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a", workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "web", scriptName: "web",
}); });
store.registerRoute({ store.registerRoute({
hostname: "b.localhost", hostname: "b.localhost",
port: 3000, port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a", workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "api", scriptName: "api",
}); });
store.removeRoutesForPort(3000); store.removeRoutesForPort(3000);
@@ -214,12 +214,12 @@ describe("ServiceRouteStore", () => {
}); });
it("findRoute returns null for unknown hosts", () => { it("findRoute returns null for unknown hosts", () => {
const store = new ServiceRouteStore(); const store = new ScriptRouteStore();
store.registerRoute({ store.registerRoute({
hostname: "editor.localhost", hostname: "editor.localhost",
port: 3000, port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a", workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "editor", scriptName: "editor",
}); });
expect(store.findRoute("unknown.example.com")).toBeNull(); expect(store.findRoute("unknown.example.com")).toBeNull();
@@ -267,12 +267,12 @@ describe("HTTP proxy", () => {
/** Start an Express app with the service proxy middleware and an optional fallback. */ /** Start an Express app with the service proxy middleware and an optional fallback. */
async function startProxy( async function startProxy(
routeStore: ServiceRouteStore, routeStore: ScriptRouteStore,
opts?: { fallback?: boolean }, opts?: { fallback?: boolean },
): Promise<{ port: number; server: http.Server }> { ): Promise<{ port: number; server: http.Server }> {
const port = await findFreePort(); const port = await findFreePort();
const app = express(); const app = express();
app.use(createServiceProxyMiddleware({ routeStore, logger })); app.use(createScriptProxyMiddleware({ routeStore, logger }));
if (opts?.fallback) { if (opts?.fallback) {
app.use((_req, res) => { app.use((_req, res) => {
@@ -312,7 +312,7 @@ describe("HTTP proxy", () => {
it("proxies requests to the correct upstream based on Host header", async () => { it("proxies requests to the correct upstream based on Host header", async () => {
const upstream = await startUpstream(); const upstream = await startUpstream();
const routeStore = new ServiceRouteStore(); const routeStore = new ScriptRouteStore();
routeStore.addRoute("test-service.localhost", upstream.port); routeStore.addRoute("test-service.localhost", upstream.port);
const proxy = await startProxy(routeStore); const proxy = await startProxy(routeStore);
@@ -330,7 +330,7 @@ describe("HTTP proxy", () => {
}); });
it("falls through when no route matches", async () => { it("falls through when no route matches", async () => {
const routeStore = new ServiceRouteStore(); const routeStore = new ScriptRouteStore();
const proxy = await startProxy(routeStore, { fallback: true }); const proxy = await startProxy(routeStore, { fallback: true });
const res = await httpGet( const res = await httpGet(
@@ -346,7 +346,7 @@ describe("HTTP proxy", () => {
// Get a port that nothing is listening on // Get a port that nothing is listening on
const deadPort = await findFreePort(); const deadPort = await findFreePort();
const routeStore = new ServiceRouteStore(); const routeStore = new ScriptRouteStore();
routeStore.addRoute("dead-service.localhost", deadPort); routeStore.addRoute("dead-service.localhost", deadPort);
const proxy = await startProxy(routeStore); const proxy = await startProxy(routeStore);
@@ -403,7 +403,7 @@ describe("WebSocket proxy", () => {
httpServers.push(upstreamServer); httpServers.push(upstreamServer);
// 2. Create the proxy server with the upgrade handler // 2. Create the proxy server with the upgrade handler
const routeStore = new ServiceRouteStore(); const routeStore = new ScriptRouteStore();
routeStore.addRoute("ws-service.localhost", upstreamPort); routeStore.addRoute("ws-service.localhost", upstreamPort);
const proxyPort = await findFreePort(); const proxyPort = await findFreePort();
@@ -412,7 +412,7 @@ describe("WebSocket proxy", () => {
res.end(); res.end();
}); });
const upgradeHandler = createServiceProxyUpgradeHandler({ const upgradeHandler = createScriptProxyUpgradeHandler({
routeStore, routeStore,
logger, logger,
}); });
@@ -21,21 +21,21 @@ const HOP_BY_HOP_HEADERS = new Set([
]); ]);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ServiceRouteStore // ScriptRouteStore
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export interface ServiceRoute { export interface ScriptRoute {
hostname: string; hostname: string;
port: number; port: number;
} }
export interface ServiceRouteEntry extends ServiceRoute { export interface ScriptRouteEntry extends ScriptRoute {
workspaceId: string; workspaceId: string;
serviceName: string; scriptName: string;
} }
export class ServiceRouteStore { export class ScriptRouteStore {
private routes = new Map<string, ServiceRouteEntry>(); private routes = new Map<string, ScriptRouteEntry>();
private workspaceHostnames = new Map<string, Set<string>>(); private workspaceHostnames = new Map<string, Set<string>>();
addRoute(hostname: string, port: number): void { addRoute(hostname: string, port: number): void {
@@ -43,11 +43,11 @@ export class ServiceRouteStore {
hostname, hostname,
port, port,
workspaceId: "", workspaceId: "",
serviceName: hostname, scriptName: hostname,
}); });
} }
registerRoute(entry: ServiceRouteEntry): void { registerRoute(entry: ScriptRouteEntry): void {
const previous = this.routes.get(entry.hostname); const previous = this.routes.get(entry.hostname);
if (previous) { if (previous) {
this.removeHostnameFromWorkspaceIndex(previous.workspaceId, previous.hostname); this.removeHostnameFromWorkspaceIndex(previous.workspaceId, previous.hostname);
@@ -76,7 +76,7 @@ export class ServiceRouteStore {
} }
} }
findRoute(host: string): ServiceRoute | null { findRoute(host: string): ScriptRoute | null {
// Strip port suffix from the Host header value // Strip port suffix from the Host header value
const hostname = host.replace(/:\d+$/, ""); const hostname = host.replace(/:\d+$/, "");
@@ -99,22 +99,22 @@ export class ServiceRouteStore {
return null; return null;
} }
getRouteEntry(hostname: string): ServiceRouteEntry | null { getRouteEntry(hostname: string): ScriptRouteEntry | null {
const entry = this.routes.get(hostname); const entry = this.routes.get(hostname);
return entry ? { ...entry } : null; return entry ? { ...entry } : null;
} }
listRoutes(): ServiceRouteEntry[] { listRoutes(): ScriptRouteEntry[] {
return Array.from(this.routes.values()).map((entry) => ({ ...entry })); return Array.from(this.routes.values()).map((entry) => ({ ...entry }));
} }
listRoutesForWorkspace(workspaceId: string): ServiceRouteEntry[] { listRoutesForWorkspace(workspaceId: string): ScriptRouteEntry[] {
const hostnames = this.workspaceHostnames.get(workspaceId); const hostnames = this.workspaceHostnames.get(workspaceId);
if (!hostnames) { if (!hostnames) {
return []; return [];
} }
const routes: ServiceRouteEntry[] = []; const routes: ScriptRouteEntry[] = [];
for (const hostname of hostnames) { for (const hostname of hostnames) {
const entry = this.routes.get(hostname); const entry = this.routes.get(hostname);
if (entry) { if (entry) {
@@ -160,14 +160,14 @@ function stripHopByHopHeaders(
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// createServiceProxyMiddleware // createScriptProxyMiddleware
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function createServiceProxyMiddleware({ export function createScriptProxyMiddleware({
routeStore, routeStore,
logger, logger,
}: { }: {
routeStore: ServiceRouteStore; routeStore: ScriptRouteStore;
logger: Logger; logger: Logger;
}): RequestHandler { }): RequestHandler {
return (req, res, next) => { return (req, res, next) => {
@@ -207,7 +207,7 @@ export function createServiceProxyMiddleware({
proxyReq.on("error", (err) => { proxyReq.on("error", (err) => {
logger.warn( logger.warn(
{ err, hostname: route.hostname, port: route.port }, { err, hostname: route.hostname, port: route.port },
"Service proxy: upstream unreachable", "Script proxy: upstream unreachable",
); );
if (!res.headersSent) { if (!res.headersSent) {
res.writeHead(502, { "content-type": "text/plain" }); res.writeHead(502, { "content-type": "text/plain" });
@@ -220,14 +220,14 @@ export function createServiceProxyMiddleware({
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// createServiceProxyUpgradeHandler // createScriptProxyUpgradeHandler
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function createServiceProxyUpgradeHandler({ export function createScriptProxyUpgradeHandler({
routeStore, routeStore,
logger, logger,
}: { }: {
routeStore: ServiceRouteStore; routeStore: ScriptRouteStore;
logger: Logger; logger: Logger;
}): (req: IncomingMessage, socket: net.Socket, head: Buffer) => void { }): (req: IncomingMessage, socket: net.Socket, head: Buffer) => void {
return (req, socket, head) => { return (req, socket, head) => {
@@ -286,7 +286,7 @@ export function createServiceProxyUpgradeHandler({
targetSocket.on("error", (err) => { targetSocket.on("error", (err) => {
logger.warn( logger.warn(
{ err, hostname: route.hostname, port: route.port }, { err, hostname: route.hostname, port: route.port },
"Service proxy: WebSocket upstream unreachable", "Script proxy: WebSocket upstream unreachable",
); );
socket.end(); socket.end();
}); });
@@ -1,42 +1,42 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { ServiceRouteStore } from "./service-proxy.js"; import { ScriptRouteStore } from "./script-proxy.js";
import { createBranchChangeRouteHandler } from "./service-route-branch-handler.js"; import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js";
function registerRoute( function registerRoute(
routeStore: ServiceRouteStore, routeStore: ScriptRouteStore,
{ {
hostname, hostname,
port, port,
workspaceId = "workspace-a", workspaceId = "workspace-a",
serviceName, scriptName,
}: { }: {
hostname: string; hostname: string;
port: number; port: number;
workspaceId?: string; workspaceId?: string;
serviceName: string; scriptName: string;
}, },
): void { ): void {
routeStore.registerRoute({ routeStore.registerRoute({
hostname, hostname,
port, port,
workspaceId, workspaceId,
serviceName, scriptName,
}); });
} }
describe("service-route-branch-handler", () => { describe("script-route-branch-handler", () => {
it("updates routes on branch rename by removing old hostnames and registering new ones", () => { it("updates routes on branch rename by removing old hostnames and registering new ones", () => {
const routeStore = new ServiceRouteStore(); const routeStore = new ScriptRouteStore();
registerRoute(routeStore, { registerRoute(routeStore, {
hostname: "feature-auth.api.localhost", hostname: "feature-auth.api.localhost",
port: 3001, port: 3001,
serviceName: "api", scriptName: "api",
}); });
const emitServiceStatusUpdate = vi.fn(); const emitScriptStatusUpdate = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore, routeStore,
emitServiceStatusUpdate, emitScriptStatusUpdate,
}); });
handleBranchChange("workspace-a", "feature/auth", "feature/billing"); handleBranchChange("workspace-a", "feature/auth", "feature/billing");
@@ -49,31 +49,31 @@ describe("service-route-branch-handler", () => {
}); });
it("is a no-op when the workspace has no routes", () => { it("is a no-op when the workspace has no routes", () => {
const routeStore = new ServiceRouteStore(); const routeStore = new ScriptRouteStore();
const emitServiceStatusUpdate = vi.fn(); const emitScriptStatusUpdate = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore, routeStore,
emitServiceStatusUpdate, emitScriptStatusUpdate,
}); });
handleBranchChange("workspace-a", "feature/auth", "feature/billing"); handleBranchChange("workspace-a", "feature/auth", "feature/billing");
expect(routeStore.listRoutes()).toEqual([]); expect(routeStore.listRoutes()).toEqual([]);
expect(emitServiceStatusUpdate).not.toHaveBeenCalled(); expect(emitScriptStatusUpdate).not.toHaveBeenCalled();
}); });
it("is a no-op when the resolved hostnames do not change", () => { it("is a no-op when the resolved hostnames do not change", () => {
const routeStore = new ServiceRouteStore(); const routeStore = new ScriptRouteStore();
registerRoute(routeStore, { registerRoute(routeStore, {
hostname: "api.localhost", hostname: "api.localhost",
port: 3001, port: 3001,
serviceName: "api", scriptName: "api",
}); });
const emitServiceStatusUpdate = vi.fn(); const emitScriptStatusUpdate = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore, routeStore,
emitServiceStatusUpdate, emitScriptStatusUpdate,
}); });
handleBranchChange("workspace-a", "main", "master"); handleBranchChange("workspace-a", "main", "master");
@@ -83,31 +83,31 @@ describe("service-route-branch-handler", () => {
hostname: "api.localhost", hostname: "api.localhost",
port: 3001, port: 3001,
workspaceId: "workspace-a", workspaceId: "workspace-a",
serviceName: "api", scriptName: "api",
}, },
]); ]);
expect(emitServiceStatusUpdate).not.toHaveBeenCalled(); expect(emitScriptStatusUpdate).not.toHaveBeenCalled();
}); });
it("emits a status update with the refreshed route payload after a route change", () => { it("emits a status update with the refreshed route payload after a route change", () => {
const routeStore = new ServiceRouteStore(); const routeStore = new ScriptRouteStore();
registerRoute(routeStore, { registerRoute(routeStore, {
hostname: "feature-auth.api.localhost", hostname: "feature-auth.api.localhost",
port: 3001, port: 3001,
serviceName: "api", scriptName: "api",
}); });
const emitServiceStatusUpdate = vi.fn(); const emitScriptStatusUpdate = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore, routeStore,
emitServiceStatusUpdate, emitScriptStatusUpdate,
}); });
handleBranchChange("workspace-a", "feature/auth", "feature/billing"); handleBranchChange("workspace-a", "feature/auth", "feature/billing");
expect(emitServiceStatusUpdate).toHaveBeenCalledWith("workspace-a", [ expect(emitScriptStatusUpdate).toHaveBeenCalledWith("workspace-a", [
{ {
serviceName: "api", scriptName: "api",
hostname: "feature-billing.api.localhost", hostname: "feature-billing.api.localhost",
port: 3001, port: 3001,
url: null, url: null,
@@ -118,28 +118,28 @@ describe("service-route-branch-handler", () => {
}); });
it("updates all services for a workspace when multiple routes are registered", () => { it("updates all services for a workspace when multiple routes are registered", () => {
const routeStore = new ServiceRouteStore(); const routeStore = new ScriptRouteStore();
registerRoute(routeStore, { registerRoute(routeStore, {
hostname: "feature-auth.api.localhost", hostname: "feature-auth.api.localhost",
port: 3001, port: 3001,
serviceName: "api", scriptName: "api",
}); });
registerRoute(routeStore, { registerRoute(routeStore, {
hostname: "feature-auth.web.localhost", hostname: "feature-auth.web.localhost",
port: 3002, port: 3002,
serviceName: "web", scriptName: "web",
}); });
registerRoute(routeStore, { registerRoute(routeStore, {
hostname: "docs.localhost", hostname: "docs.localhost",
port: 3003, port: 3003,
workspaceId: "workspace-b", workspaceId: "workspace-b",
serviceName: "docs", scriptName: "docs",
}); });
const emitServiceStatusUpdate = vi.fn(); const emitScriptStatusUpdate = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore, routeStore,
emitServiceStatusUpdate, emitScriptStatusUpdate,
}); });
handleBranchChange("workspace-a", "feature/auth", "feature/billing"); handleBranchChange("workspace-a", "feature/auth", "feature/billing");
@@ -149,13 +149,13 @@ describe("service-route-branch-handler", () => {
hostname: "feature-billing.api.localhost", hostname: "feature-billing.api.localhost",
port: 3001, port: 3001,
workspaceId: "workspace-a", workspaceId: "workspace-a",
serviceName: "api", scriptName: "api",
}, },
{ {
hostname: "feature-billing.web.localhost", hostname: "feature-billing.web.localhost",
port: 3002, port: 3002,
workspaceId: "workspace-a", workspaceId: "workspace-a",
serviceName: "web", scriptName: "web",
}, },
]); ]);
expect(routeStore.listRoutesForWorkspace("workspace-b")).toEqual([ expect(routeStore.listRoutesForWorkspace("workspace-b")).toEqual([
@@ -163,27 +163,27 @@ describe("service-route-branch-handler", () => {
hostname: "docs.localhost", hostname: "docs.localhost",
port: 3003, port: 3003,
workspaceId: "workspace-b", workspaceId: "workspace-b",
serviceName: "docs", scriptName: "docs",
}, },
]); ]);
}); });
it("does not emit a status update when no changes are needed", () => { it("does not emit a status update when no changes are needed", () => {
const routeStore = new ServiceRouteStore(); const routeStore = new ScriptRouteStore();
registerRoute(routeStore, { registerRoute(routeStore, {
hostname: "web.localhost", hostname: "web.localhost",
port: 3002, port: 3002,
serviceName: "web", scriptName: "web",
}); });
const emitServiceStatusUpdate = vi.fn(); const emitScriptStatusUpdate = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore, routeStore,
emitServiceStatusUpdate, emitScriptStatusUpdate,
}); });
handleBranchChange("workspace-a", null, "main"); handleBranchChange("workspace-a", null, "main");
expect(emitServiceStatusUpdate).not.toHaveBeenCalled(); expect(emitScriptStatusUpdate).not.toHaveBeenCalled();
}); });
}); });
@@ -1,14 +1,14 @@
import type { Logger } from "pino"; import type { Logger } from "pino";
import type { WorkspaceServicePayload } from "../shared/messages.js"; import type { WorkspaceScriptPayload } from "../shared/messages.js";
import { buildServiceHostname } from "../utils/service-hostname.js"; import { buildScriptHostname } from "../utils/script-hostname.js";
import { buildWorkspaceServicePayloads } from "./service-status-projection.js"; import { buildWorkspaceScriptPayloads } from "./script-status-projection.js";
import type { ServiceRouteEntry, ServiceRouteStore } from "./service-proxy.js"; import type { ScriptRouteEntry, ScriptRouteStore } from "./script-proxy.js";
interface BranchChangeRouteHandlerOptions { interface BranchChangeRouteHandlerOptions {
routeStore: ServiceRouteStore; routeStore: ScriptRouteStore;
emitServiceStatusUpdate: ( emitScriptStatusUpdate: (
workspaceId: string, workspaceId: string,
services: WorkspaceServicePayload[], scripts: WorkspaceScriptPayload[],
) => void; ) => void;
logger?: Logger; logger?: Logger;
} }
@@ -16,7 +16,7 @@ interface BranchChangeRouteHandlerOptions {
interface RouteHostnameUpdate { interface RouteHostnameUpdate {
oldHostname: string; oldHostname: string;
newHostname: string; newHostname: string;
route: ServiceRouteEntry; route: ScriptRouteEntry;
} }
export function createBranchChangeRouteHandler( export function createBranchChangeRouteHandler(
@@ -30,7 +30,7 @@ export function createBranchChangeRouteHandler(
const updates: RouteHostnameUpdate[] = []; const updates: RouteHostnameUpdate[] = [];
for (const route of routes) { for (const route of routes) {
const newHostname = buildServiceHostname(newBranch, route.serviceName); const newHostname = buildScriptHostname(newBranch, route.scriptName);
if (newHostname !== route.hostname) { if (newHostname !== route.hostname) {
updates.push({ updates.push({
oldHostname: route.hostname, oldHostname: route.hostname,
@@ -50,21 +50,21 @@ export function createBranchChangeRouteHandler(
hostname: newHostname, hostname: newHostname,
port: route.port, port: route.port,
workspaceId: route.workspaceId, workspaceId: route.workspaceId,
serviceName: route.serviceName, scriptName: route.scriptName,
}); });
options.logger?.info( options.logger?.info(
{ {
oldHostname, oldHostname,
newHostname, newHostname,
serviceName: route.serviceName, scriptName: route.scriptName,
}, },
"Updated service route for branch rename", "Updated script route for branch rename",
); );
} }
options.emitServiceStatusUpdate( options.emitScriptStatusUpdate(
workspaceId, workspaceId,
buildWorkspaceServicePayloads(options.routeStore, workspaceId, null), buildWorkspaceScriptPayloads(options.routeStore, workspaceId, null),
); );
}; };
} }
@@ -3,17 +3,17 @@ import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { ServiceRouteStore } from "./service-proxy.js"; import { ScriptRouteStore } from "./script-proxy.js";
import { import {
buildWorkspaceServicePayloads, buildWorkspaceScriptPayloads,
createServiceStatusEmitter, createScriptStatusEmitter,
} from "./service-status-projection.js"; } from "./script-status-projection.js";
function createWorkspaceRepo(options?: { function createWorkspaceRepo(options?: {
branchName?: string; branchName?: string;
paseoConfig?: Record<string, unknown>; paseoConfig?: Record<string, unknown>;
}): { tempDir: string; repoDir: string; cleanup: () => void } { }): { tempDir: string; repoDir: string; cleanup: () => void } {
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "service-projection-"))); const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-projection-")));
const repoDir = path.join(tempDir, "repo"); const repoDir = path.join(tempDir, "repo");
execSync(`mkdir -p ${JSON.stringify(repoDir)}`); execSync(`mkdir -p ${JSON.stringify(repoDir)}`);
execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" }); execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" });
@@ -35,22 +35,22 @@ function createWorkspaceRepo(options?: {
}; };
} }
describe("service-status-projection", () => { describe("script-status-projection", () => {
it("shows configured services even before they have routes", () => { it("shows configured scripts even before they have routes", () => {
const workspace = createWorkspaceRepo({ const workspace = createWorkspaceRepo({
paseoConfig: { paseoConfig: {
services: { scripts: {
api: { command: "npm run api" }, api: { command: "npm run api" },
web: { command: "npm run web", port: 3000 }, web: { command: "npm run web", port: 3000 },
}, },
}, },
}); });
const routeStore = new ServiceRouteStore(); const routeStore = new ScriptRouteStore();
try { try {
expect(buildWorkspaceServicePayloads(routeStore, workspace.repoDir, 6767)).toEqual([ expect(buildWorkspaceScriptPayloads(routeStore, workspace.repoDir, 6767)).toEqual([
{ {
serviceName: "api", scriptName: "api",
hostname: "api.localhost", hostname: "api.localhost",
port: null, port: null,
url: "http://api.localhost:6767", url: "http://api.localhost:6767",
@@ -58,7 +58,7 @@ describe("service-status-projection", () => {
health: null, health: null,
}, },
{ {
serviceName: "web", scriptName: "web",
hostname: "web.localhost", hostname: "web.localhost",
port: 3000, port: 3000,
url: "http://web.localhost:6767", url: "http://web.localhost:6767",
@@ -71,27 +71,27 @@ describe("service-status-projection", () => {
} }
}); });
it("uses the active route port and branch-aware hostname for running services", () => { it("uses the active route port and branch-aware hostname for running scripts", () => {
const workspace = createWorkspaceRepo({ const workspace = createWorkspaceRepo({
branchName: "feature/card", branchName: "feature/card",
paseoConfig: { paseoConfig: {
services: { scripts: {
web: { command: "npm run web" }, web: { command: "npm run web" },
}, },
}, },
}); });
const routeStore = new ServiceRouteStore(); const routeStore = new ScriptRouteStore();
routeStore.registerRoute({ routeStore.registerRoute({
hostname: "feature-card.web.localhost", hostname: "feature-card.web.localhost",
port: 4321, port: 4321,
workspaceId: workspace.repoDir, workspaceId: workspace.repoDir,
serviceName: "web", scriptName: "web",
}); });
try { try {
expect(buildWorkspaceServicePayloads(routeStore, workspace.repoDir, 6767)).toEqual([ expect(buildWorkspaceScriptPayloads(routeStore, workspace.repoDir, 6767)).toEqual([
{ {
serviceName: "web", scriptName: "web",
hostname: "feature-card.web.localhost", hostname: "feature-card.web.localhost",
port: 4321, port: 4321,
url: "http://feature-card.web.localhost:6767", url: "http://feature-card.web.localhost:6767",
@@ -106,18 +106,18 @@ describe("service-status-projection", () => {
it("includes orphaned active routes even if the current config no longer declares them", () => { it("includes orphaned active routes even if the current config no longer declares them", () => {
const workspace = createWorkspaceRepo(); const workspace = createWorkspaceRepo();
const routeStore = new ServiceRouteStore(); const routeStore = new ScriptRouteStore();
routeStore.registerRoute({ routeStore.registerRoute({
hostname: "docs.localhost", hostname: "docs.localhost",
port: 3002, port: 3002,
workspaceId: workspace.repoDir, workspaceId: workspace.repoDir,
serviceName: "docs", scriptName: "docs",
}); });
try { try {
expect(buildWorkspaceServicePayloads(routeStore, workspace.repoDir, 6767)).toEqual([ expect(buildWorkspaceScriptPayloads(routeStore, workspace.repoDir, 6767)).toEqual([
{ {
serviceName: "docs", scriptName: "docs",
hostname: "docs.localhost", hostname: "docs.localhost",
port: 3002, port: 3002,
url: "http://docs.localhost:6767", url: "http://docs.localhost:6767",
@@ -130,25 +130,25 @@ describe("service-status-projection", () => {
} }
}); });
it("createServiceStatusEmitter overlays health onto the full workspace service list", () => { it("createScriptStatusEmitter overlays health onto the full workspace script list", () => {
const workspace = createWorkspaceRepo({ const workspace = createWorkspaceRepo({
paseoConfig: { paseoConfig: {
services: { scripts: {
api: { command: "npm run api" }, api: { command: "npm run api" },
web: { command: "npm run web" }, web: { command: "npm run web" },
}, },
}, },
}); });
const routeStore = new ServiceRouteStore(); const routeStore = new ScriptRouteStore();
routeStore.registerRoute({ routeStore.registerRoute({
hostname: "api.localhost", hostname: "api.localhost",
port: 3001, port: 3001,
workspaceId: workspace.repoDir, workspaceId: workspace.repoDir,
serviceName: "api", scriptName: "api",
}); });
const session = { emit: vi.fn() }; const session = { emit: vi.fn() };
const emitUpdate = createServiceStatusEmitter({ const emitUpdate = createScriptStatusEmitter({
sessions: () => [session], sessions: () => [session],
routeStore, routeStore,
daemonPort: 6767, daemonPort: 6767,
@@ -157,7 +157,7 @@ describe("service-status-projection", () => {
try { try {
emitUpdate(workspace.repoDir, [ emitUpdate(workspace.repoDir, [
{ {
serviceName: "api", scriptName: "api",
hostname: "api.localhost", hostname: "api.localhost",
port: 3001, port: 3001,
health: "healthy", health: "healthy",
@@ -165,12 +165,12 @@ describe("service-status-projection", () => {
]); ]);
expect(session.emit).toHaveBeenCalledWith({ expect(session.emit).toHaveBeenCalledWith({
type: "service_status_update", type: "script_status_update",
payload: { payload: {
workspaceId: workspace.repoDir, workspaceId: workspace.repoDir,
services: [ scripts: [
{ {
serviceName: "api", scriptName: "api",
hostname: "api.localhost", hostname: "api.localhost",
port: 3001, port: 3001,
url: "http://api.localhost:6767", url: "http://api.localhost:6767",
@@ -178,7 +178,7 @@ describe("service-status-projection", () => {
health: "healthy", health: "healthy",
}, },
{ {
serviceName: "web", scriptName: "web",
hostname: "web.localhost", hostname: "web.localhost",
port: null, port: null,
url: "http://web.localhost:6767", url: "http://web.localhost:6767",
@@ -196,17 +196,17 @@ describe("service-status-projection", () => {
it("computes URLs with and without a daemon port", () => { it("computes URLs with and without a daemon port", () => {
const workspace = createWorkspaceRepo({ const workspace = createWorkspaceRepo({
paseoConfig: { paseoConfig: {
services: { scripts: {
api: { command: "npm run api" }, api: { command: "npm run api" },
}, },
}, },
}); });
const routeStore = new ServiceRouteStore(); const routeStore = new ScriptRouteStore();
try { try {
expect(buildWorkspaceServicePayloads(routeStore, workspace.repoDir, 6767)).toEqual([ expect(buildWorkspaceScriptPayloads(routeStore, workspace.repoDir, 6767)).toEqual([
{ {
serviceName: "api", scriptName: "api",
hostname: "api.localhost", hostname: "api.localhost",
port: null, port: null,
url: "http://api.localhost:6767", url: "http://api.localhost:6767",
@@ -215,9 +215,9 @@ describe("service-status-projection", () => {
}, },
]); ]);
expect(buildWorkspaceServicePayloads(routeStore, workspace.repoDir, null)).toEqual([ expect(buildWorkspaceScriptPayloads(routeStore, workspace.repoDir, null)).toEqual([
{ {
serviceName: "api", scriptName: "api",
hostname: "api.localhost", hostname: "api.localhost",
port: null, port: null,
url: null, url: null,
@@ -0,0 +1,135 @@
import type {
ScriptStatusUpdateMessage,
SessionOutboundMessage,
WorkspaceScriptPayload,
} from "../shared/messages.js";
import { buildScriptHostname } from "../utils/script-hostname.js";
import { getScriptConfigs } from "../utils/worktree.js";
import { readGitCommand } from "./workspace-git-metadata.js";
import type { ScriptHealthEntry } from "./script-health-monitor.js";
import type { ScriptRouteEntry, ScriptRouteStore } from "./script-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}`;
}
type ConfiguredWorkspaceScript = {
scriptName: string;
hostname: string;
port: number | null;
};
function resolveWorkspaceBranchName(workspaceDirectory: string): string | null {
return readGitCommand(workspaceDirectory, "git symbolic-ref --short HEAD");
}
function listConfiguredWorkspaceScripts(workspaceDirectory: string): ConfiguredWorkspaceScript[] {
const branchName = resolveWorkspaceBranchName(workspaceDirectory);
const scriptConfigs = getScriptConfigs(workspaceDirectory);
return Array.from(scriptConfigs.entries()).map(([scriptName, config]) => ({
scriptName,
hostname: buildScriptHostname(branchName, scriptName),
port: config.port ?? null,
}));
}
function mergeWorkspaceScriptDefinitions(
workspaceDirectory: string,
routeStore: ScriptRouteStore,
): Array<ConfiguredWorkspaceScript | ScriptRouteEntry> {
const merged = new Map<string, ConfiguredWorkspaceScript | ScriptRouteEntry>();
for (const script of listConfiguredWorkspaceScripts(workspaceDirectory)) {
merged.set(script.hostname, script);
}
for (const route of routeStore.listRoutesForWorkspace(workspaceDirectory)) {
merged.set(route.hostname, route);
}
return Array.from(merged.values()).sort((left, right) =>
left.scriptName.localeCompare(right.scriptName, undefined, {
numeric: true,
sensitivity: "base",
}),
);
}
export function buildWorkspaceScriptPayloads(
routeStore: ScriptRouteStore,
workspaceDirectory: string,
daemonPort: number | null,
resolveHealth?: (hostname: string) => "healthy" | "unhealthy" | null,
): WorkspaceScriptPayload[] {
return mergeWorkspaceScriptDefinitions(workspaceDirectory, routeStore).map((script) => {
const route = routeStore.getRouteEntry(script.hostname);
return {
scriptName: script.scriptName,
hostname: script.hostname,
port: route?.port ?? script.port,
url: toServiceUrl(script.hostname, daemonPort),
lifecycle: route ? "running" : "stopped",
health: resolveHealth?.(script.hostname) ?? null,
};
});
}
function buildScriptStatusUpdateMessage(params: {
workspaceId: string;
scripts: WorkspaceScriptPayload[];
}): ScriptStatusUpdateMessage {
return {
type: "script_status_update",
payload: {
workspaceId: params.workspaceId,
scripts: params.scripts,
},
};
}
export function createScriptStatusEmitter({
sessions,
routeStore,
daemonPort,
}: {
sessions: () => SessionEmitter[];
routeStore: ScriptRouteStore;
daemonPort: number | null | (() => number | null);
}): (workspaceId: string, scripts: ScriptHealthEntry[]) => void {
return (workspaceId, scripts) => {
const resolvedDaemonPort = resolveDaemonPort(daemonPort);
const scriptHealthByHostname = new Map(
scripts.map((script) => [script.hostname, script.health] as const),
);
const projected = buildWorkspaceScriptPayloads(
routeStore,
workspaceId,
resolvedDaemonPort,
(hostname) => scriptHealthByHostname.get(hostname) ?? null,
);
const message = buildScriptStatusUpdateMessage({
workspaceId,
scripts: projected,
});
for (const session of sessions()) {
session.emit(message);
}
};
}
@@ -1,135 +0,0 @@
import type {
ServiceStatusUpdateMessage,
SessionOutboundMessage,
WorkspaceServicePayload,
} from "../shared/messages.js";
import { buildServiceHostname } from "../utils/service-hostname.js";
import { getServiceConfigs } from "../utils/worktree.js";
import { readGitCommand } from "./workspace-git-metadata.js";
import type { ServiceHealthEntry } from "./service-health-monitor.js";
import type { ServiceRouteEntry, 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}`;
}
type ConfiguredWorkspaceService = {
serviceName: string;
hostname: string;
port: number | null;
};
function resolveWorkspaceBranchName(workspaceDirectory: string): string | null {
return readGitCommand(workspaceDirectory, "git symbolic-ref --short HEAD");
}
function listConfiguredWorkspaceServices(workspaceDirectory: string): ConfiguredWorkspaceService[] {
const branchName = resolveWorkspaceBranchName(workspaceDirectory);
const serviceConfigs = getServiceConfigs(workspaceDirectory);
return Array.from(serviceConfigs.entries()).map(([serviceName, config]) => ({
serviceName,
hostname: buildServiceHostname(branchName, serviceName),
port: config.port ?? null,
}));
}
function mergeWorkspaceServiceDefinitions(
workspaceDirectory: string,
routeStore: ServiceRouteStore,
): Array<ConfiguredWorkspaceService | ServiceRouteEntry> {
const merged = new Map<string, ConfiguredWorkspaceService | ServiceRouteEntry>();
for (const service of listConfiguredWorkspaceServices(workspaceDirectory)) {
merged.set(service.hostname, service);
}
for (const route of routeStore.listRoutesForWorkspace(workspaceDirectory)) {
merged.set(route.hostname, route);
}
return Array.from(merged.values()).sort((left, right) =>
left.serviceName.localeCompare(right.serviceName, undefined, {
numeric: true,
sensitivity: "base",
}),
);
}
export function buildWorkspaceServicePayloads(
routeStore: ServiceRouteStore,
workspaceDirectory: string,
daemonPort: number | null,
resolveHealth?: (hostname: string) => "healthy" | "unhealthy" | null,
): WorkspaceServicePayload[] {
return mergeWorkspaceServiceDefinitions(workspaceDirectory, routeStore).map((service) => {
const route = routeStore.getRouteEntry(service.hostname);
return {
serviceName: service.serviceName,
hostname: service.hostname,
port: route?.port ?? service.port,
url: toServiceUrl(service.hostname, daemonPort),
lifecycle: route ? "running" : "stopped",
health: resolveHealth?.(service.hostname) ?? null,
};
});
}
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: ServiceHealthEntry[]) => void {
return (workspaceId, services) => {
const resolvedDaemonPort = resolveDaemonPort(daemonPort);
const serviceHealthByHostname = new Map(
services.map((service) => [service.hostname, service.health] as const),
);
const projected = buildWorkspaceServicePayloads(
routeStore,
workspaceId,
resolvedDaemonPort,
(hostname) => serviceHealthByHostname.get(hostname) ?? null,
);
const message = buildServiceStatusUpdateMessage({
workspaceId,
services: projected,
});
for (const session of sessions()) {
session.emit(message);
}
};
}
+47 -45
View File
@@ -19,7 +19,7 @@ import {
type SubscribeTerminalsRequest, type SubscribeTerminalsRequest,
type UnsubscribeTerminalsRequest, type UnsubscribeTerminalsRequest,
type CreateTerminalRequest, type CreateTerminalRequest,
type StartWorkspaceServiceRequest, type StartWorkspaceScriptRequest,
type SubscribeTerminalRequest, type SubscribeTerminalRequest,
type UnsubscribeTerminalRequest, type UnsubscribeTerminalRequest,
type TerminalInput, type TerminalInput,
@@ -65,8 +65,8 @@ 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"; import { buildWorkspaceScriptPayloads } from "./script-status-projection.js";
import { spawnWorkspaceService } from "./worktree-bootstrap.js"; import { spawnWorkspaceScript } from "./worktree-bootstrap.js";
import { readGitCommand } from "./workspace-git-metadata.js"; import { readGitCommand } from "./workspace-git-metadata.js";
import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js"; import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js";
@@ -134,7 +134,7 @@ import {
type WorktreeConfig, type WorktreeConfig,
} from "../utils/worktree.js"; } from "../utils/worktree.js";
import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js"; import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
import type { ServiceRouteStore } from "./service-proxy.js"; import type { ScriptRouteStore } from "./script-proxy.js";
import { import {
getCheckoutDiff, getCheckoutDiff,
getCachedCheckoutShortstat, getCachedCheckoutShortstat,
@@ -416,14 +416,14 @@ export type SessionOptions = {
tts: Resolvable<TextToSpeechProvider | null>; tts: Resolvable<TextToSpeechProvider | null>;
terminalManager: TerminalManager | null; terminalManager: TerminalManager | null;
providerSnapshotManager?: ProviderSnapshotManager; providerSnapshotManager?: ProviderSnapshotManager;
serviceRouteStore?: ServiceRouteStore; scriptRouteStore?: ScriptRouteStore;
onBranchChanged?: ( onBranchChanged?: (
workspaceId: string, workspaceId: string,
oldBranch: string | null, oldBranch: string | null,
newBranch: string | null, newBranch: string | null,
) => void; ) => void;
getDaemonTcpPort?: () => number | null; getDaemonTcpPort?: () => number | null;
resolveServiceHealth?: (hostname: string) => "healthy" | "unhealthy" | null; resolveScriptHealth?: (hostname: string) => "healthy" | "unhealthy" | null;
voice?: { voice?: {
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null; voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
turnDetection?: Resolvable<TurnDetectionProvider | null>; turnDetection?: Resolvable<TurnDetectionProvider | null>;
@@ -600,14 +600,14 @@ export class Session {
private readonly terminalManager: TerminalManager | null; private readonly terminalManager: TerminalManager | null;
private readonly providerSnapshotManager: ProviderSnapshotManager | null; private readonly providerSnapshotManager: ProviderSnapshotManager | null;
private unsubscribeProviderSnapshotEvents: (() => void) | null = null; private unsubscribeProviderSnapshotEvents: (() => void) | null = null;
private readonly serviceRouteStore: ServiceRouteStore | null; private readonly scriptRouteStore: ScriptRouteStore | null;
private readonly onBranchChanged?: ( private readonly onBranchChanged?: (
workspaceId: string, workspaceId: string,
oldBranch: string | null, oldBranch: string | null,
newBranch: string | null, newBranch: string | null,
) => void; ) => void;
private readonly getDaemonTcpPort: (() => number | null) | null; private readonly getDaemonTcpPort: (() => number | null) | null;
private readonly resolveServiceHealth: private readonly resolveScriptHealth:
| ((hostname: string) => "healthy" | "unhealthy" | null) | ((hostname: string) => "healthy" | "unhealthy" | null)
| null; | null;
private readonly subscribedTerminalDirectories = new Set<string>(); private readonly subscribedTerminalDirectories = new Set<string>();
@@ -667,10 +667,10 @@ export class Session {
tts, tts,
terminalManager, terminalManager,
providerSnapshotManager, providerSnapshotManager,
serviceRouteStore, scriptRouteStore,
onBranchChanged, onBranchChanged,
getDaemonTcpPort, getDaemonTcpPort,
resolveServiceHealth, resolveScriptHealth,
voice, voice,
voiceBridge, voiceBridge,
dictation, dictation,
@@ -711,10 +711,10 @@ export class Session {
this.createAgentMcpTransport = createAgentMcpTransport; this.createAgentMcpTransport = createAgentMcpTransport;
this.terminalManager = terminalManager; this.terminalManager = terminalManager;
this.providerSnapshotManager = providerSnapshotManager ?? null; this.providerSnapshotManager = providerSnapshotManager ?? null;
this.serviceRouteStore = serviceRouteStore ?? null; this.scriptRouteStore = scriptRouteStore ?? null;
this.onBranchChanged = onBranchChanged; this.onBranchChanged = onBranchChanged;
this.getDaemonTcpPort = getDaemonTcpPort ?? null; this.getDaemonTcpPort = getDaemonTcpPort ?? null;
this.resolveServiceHealth = resolveServiceHealth ?? null; this.resolveScriptHealth = resolveScriptHealth ?? 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),
@@ -1773,8 +1773,8 @@ export class Session {
await this.handleCreateTerminalRequest(msg); await this.handleCreateTerminalRequest(msg);
break; break;
case "start_workspace_service_request": case "start_workspace_script_request":
await this.handleStartWorkspaceServiceRequest(msg); await this.handleStartWorkspaceScriptRequest(msg);
break; break;
case "subscribe_terminal_request": case "subscribe_terminal_request":
@@ -2878,7 +2878,7 @@ export class Session {
agentId: snapshot.id, agentId: snapshot.id,
item, item,
}), }),
serviceRouteStore: this.serviceRouteStore ?? undefined, scriptRouteStore: this.scriptRouteStore ?? undefined,
daemonPort: this.getDaemonTcpPort?.() ?? null, daemonPort: this.getDaemonTcpPort?.() ?? null,
logger: this.sessionLogger, logger: this.sessionLogger,
}); });
@@ -5273,12 +5273,12 @@ export class Session {
status: "done", status: "done",
activityAt: null, activityAt: null,
diffStat, diffStat,
services: this.serviceRouteStore scripts: this.scriptRouteStore
? buildWorkspaceServicePayloads( ? buildWorkspaceScriptPayloads(
this.serviceRouteStore, this.scriptRouteStore,
workspace.directory, workspace.directory,
this.getDaemonTcpPort?.() ?? null, this.getDaemonTcpPort?.() ?? null,
this.resolveServiceHealth ?? undefined, this.resolveScriptHealth ?? undefined,
) )
: [], : [],
}; };
@@ -6102,26 +6102,26 @@ export class Session {
} }
} }
private buildWorkspaceServicePayloadSnapshot( private buildWorkspaceScriptPayloadSnapshot(
workspaceDirectory: string, workspaceDirectory: string,
): WorkspaceDescriptorPayload["services"] { ): WorkspaceDescriptorPayload["scripts"] {
if (!this.serviceRouteStore) { if (!this.scriptRouteStore) {
return []; return [];
} }
return buildWorkspaceServicePayloads( return buildWorkspaceScriptPayloads(
this.serviceRouteStore, this.scriptRouteStore,
workspaceDirectory, workspaceDirectory,
this.getDaemonTcpPort?.() ?? null, this.getDaemonTcpPort?.() ?? null,
this.resolveServiceHealth ?? undefined, this.resolveScriptHealth ?? undefined,
); );
} }
private emitWorkspaceServiceStatusUpdate(workspaceDirectory: string): void { private emitWorkspaceScriptStatusUpdate(workspaceDirectory: string): void {
this.emit({ this.emit({
type: "service_status_update", type: "script_status_update",
payload: { payload: {
workspaceId: workspaceDirectory, workspaceId: workspaceDirectory,
services: this.buildWorkspaceServicePayloadSnapshot(workspaceDirectory), scripts: this.buildWorkspaceScriptPayloadSnapshot(workspaceDirectory),
}, },
}); });
} }
@@ -6134,12 +6134,12 @@ export class Session {
await openInEditorTarget(options); await openInEditorTarget(options);
} }
private async handleStartWorkspaceServiceRequest( private async handleStartWorkspaceScriptRequest(
request: StartWorkspaceServiceRequest, request: StartWorkspaceScriptRequest,
): Promise<void> { ): Promise<void> {
try { try {
if (!this.terminalManager || !this.serviceRouteStore) { if (!this.terminalManager || !this.scriptRouteStore) {
throw new Error("Workspace services are not available on this daemon"); throw new Error("Workspace scripts are not available on this daemon");
} }
const workspace = await this.resolveWorkspaceByIdOrDirectory(request.workspaceId); const workspace = await this.resolveWorkspaceByIdOrDirectory(request.workspaceId);
@@ -6147,46 +6147,48 @@ export class Session {
throw new Error(`Workspace not found: ${request.workspaceId}`); throw new Error(`Workspace not found: ${request.workspaceId}`);
} }
await spawnWorkspaceService({ const serviceResult = await spawnWorkspaceScript({
repoRoot: workspace.directory, repoRoot: workspace.directory,
workspaceId: workspace.directory, workspaceId: workspace.directory,
branchName: readGitCommand(workspace.directory, "git symbolic-ref --short HEAD"), branchName: readGitCommand(workspace.directory, "git symbolic-ref --short HEAD"),
serviceName: request.serviceName, scriptName: request.scriptName,
daemonPort: this.getDaemonTcpPort?.() ?? null, daemonPort: this.getDaemonTcpPort?.() ?? null,
routeStore: this.serviceRouteStore, routeStore: this.scriptRouteStore,
terminalManager: this.terminalManager, terminalManager: this.terminalManager,
logger: this.sessionLogger, logger: this.sessionLogger,
onLifecycleChanged: () => { onLifecycleChanged: () => {
this.emitWorkspaceServiceStatusUpdate(workspace.directory); this.emitWorkspaceScriptStatusUpdate(workspace.directory);
}, },
}); });
this.emitWorkspaceServiceStatusUpdate(workspace.directory); this.emitWorkspaceScriptStatusUpdate(workspace.directory);
this.emit({ this.emit({
type: "start_workspace_service_response", type: "start_workspace_script_response",
payload: { payload: {
requestId: request.requestId, requestId: request.requestId,
workspaceId: request.workspaceId, workspaceId: request.workspaceId,
serviceName: request.serviceName, scriptName: request.scriptName,
terminalId: serviceResult.terminalId,
error: null, error: null,
}, },
}); });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to start workspace service"; const message = error instanceof Error ? error.message : "Failed to start workspace script";
this.sessionLogger.error( this.sessionLogger.error(
{ {
err: error, err: error,
workspaceId: request.workspaceId, workspaceId: request.workspaceId,
serviceName: request.serviceName, scriptName: request.scriptName,
}, },
"Failed to start workspace service", "Failed to start workspace script",
); );
this.emit({ this.emit({
type: "start_workspace_service_response", type: "start_workspace_script_response",
payload: { payload: {
requestId: request.requestId, requestId: request.requestId,
workspaceId: request.workspaceId, workspaceId: request.workspaceId,
serviceName: request.serviceName, scriptName: request.scriptName,
terminalId: null,
error: message, error: message,
}, },
}); });
@@ -6292,7 +6294,7 @@ export class Session {
sessionLogger: this.sessionLogger, sessionLogger: this.sessionLogger,
terminalManager: this.terminalManager, terminalManager: this.terminalManager,
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
serviceRouteStore: this.serviceRouteStore, scriptRouteStore: this.scriptRouteStore,
daemonPort: this.getDaemonTcpPort?.() ?? null, daemonPort: this.getDaemonTcpPort?.() ?? null,
}, },
options, options,
@@ -36,7 +36,7 @@ import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
import { buildProviderRegistry } from "./agent/provider-registry.js"; import { buildProviderRegistry } from "./agent/provider-registry.js";
import { PushTokenStore } from "./push/token-store.js"; import { PushTokenStore } from "./push/token-store.js";
import { PushService } from "./push/push-service.js"; import { PushService } from "./push/push-service.js";
import type { ServiceRouteStore } from "./service-proxy.js"; import type { ScriptRouteStore } from "./script-proxy.js";
import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js"; import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js";
import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js"; import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js";
import { import {
@@ -248,9 +248,9 @@ export class VoiceAssistantWebSocketServer {
private readonly createAgentMcpTransport: AgentMcpTransportFactory; private readonly createAgentMcpTransport: AgentMcpTransportFactory;
private readonly speech: SpeechService | null; private readonly speech: SpeechService | null;
private readonly terminalManager: TerminalManager | null; private readonly terminalManager: TerminalManager | null;
private readonly serviceRouteStore: ServiceRouteStore | null; private readonly scriptRouteStore: ScriptRouteStore | null;
private readonly getDaemonTcpPort: (() => number | null) | null; private readonly getDaemonTcpPort: (() => number | null) | null;
private readonly resolveServiceHealth: private readonly resolveScriptHealth:
| ((hostname: string) => "healthy" | "unhealthy" | null) | ((hostname: string) => "healthy" | "unhealthy" | null)
| null; | null;
private readonly dictation: { private readonly dictation: {
@@ -323,14 +323,14 @@ export class VoiceAssistantWebSocketServer {
loopService?: LoopService, loopService?: LoopService,
scheduleService?: ScheduleService, scheduleService?: ScheduleService,
checkoutDiffManager?: CheckoutDiffManager, checkoutDiffManager?: CheckoutDiffManager,
serviceRouteStore?: ServiceRouteStore | null, scriptRouteStore?: ScriptRouteStore | null,
onBranchChanged?: ( onBranchChanged?: (
workspaceId: string, workspaceId: string,
oldBranch: string | null, oldBranch: string | null,
newBranch: string | null, newBranch: string | null,
) => void, ) => void,
getDaemonTcpPort?: () => number | null, getDaemonTcpPort?: () => number | null,
resolveServiceHealth?: (hostname: string) => "healthy" | "unhealthy" | null, resolveScriptHealth?: (hostname: string) => "healthy" | "unhealthy" | null,
) { ) {
this.logger = logger.child({ module: "websocket-server" }); this.logger = logger.child({ module: "websocket-server" });
this.serverId = serverId; this.serverId = serverId;
@@ -377,10 +377,10 @@ export class VoiceAssistantWebSocketServer {
providerSnapshotLogger, providerSnapshotLogger,
); );
this.onLifecycleIntent = onLifecycleIntent ?? null; this.onLifecycleIntent = onLifecycleIntent ?? null;
this.serviceRouteStore = serviceRouteStore ?? null; this.scriptRouteStore = scriptRouteStore ?? null;
this.onBranchChanged = onBranchChanged ?? null; this.onBranchChanged = onBranchChanged ?? null;
this.getDaemonTcpPort = getDaemonTcpPort ?? null; this.getDaemonTcpPort = getDaemonTcpPort ?? null;
this.resolveServiceHealth = resolveServiceHealth ?? null; this.resolveScriptHealth = resolveScriptHealth ?? null;
this.serverCapabilities = buildServerCapabilities({ this.serverCapabilities = buildServerCapabilities({
readiness: this.speech?.getReadiness() ?? null, readiness: this.speech?.getReadiness() ?? null,
}); });
@@ -699,10 +699,10 @@ export class VoiceAssistantWebSocketServer {
tts: () => this.speech?.resolveTts() ?? null, tts: () => this.speech?.resolveTts() ?? null,
terminalManager: this.terminalManager, terminalManager: this.terminalManager,
providerSnapshotManager: this.providerSnapshotManager, providerSnapshotManager: this.providerSnapshotManager,
serviceRouteStore: this.serviceRouteStore ?? undefined, scriptRouteStore: this.scriptRouteStore ?? undefined,
onBranchChanged: this.onBranchChanged ?? undefined, onBranchChanged: this.onBranchChanged ?? undefined,
getDaemonTcpPort: this.getDaemonTcpPort ?? undefined, getDaemonTcpPort: this.getDaemonTcpPort ?? undefined,
resolveServiceHealth: this.resolveServiceHealth ?? undefined, resolveScriptHealth: this.resolveScriptHealth ?? undefined,
voice: { voice: {
...(this.voice ?? {}), ...(this.voice ?? {}),
turnDetection: () => this.speech?.resolveTurnDetection() ?? null, turnDetection: () => this.speech?.resolveTurnDetection() ?? null,
@@ -8,9 +8,9 @@ import type { AgentTimelineItem } from "./agent/agent-sdk-types.js";
import { import {
createAgentWorktree, createAgentWorktree,
runAsyncWorktreeBootstrap, runAsyncWorktreeBootstrap,
spawnWorktreeServices, spawnWorktreeScripts,
} from "./worktree-bootstrap.js"; } from "./worktree-bootstrap.js";
import { ServiceRouteStore } from "./service-proxy.js"; import { ScriptRouteStore } from "./script-proxy.js";
describe("runAsyncWorktreeBootstrap", () => { describe("runAsyncWorktreeBootstrap", () => {
let tempDir: string; let tempDir: string;
@@ -507,11 +507,11 @@ 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 () => { it("spawns scripts without PASEO_SCRIPT_URL when the daemon has no TCP port", async () => {
writeFileSync( writeFileSync(
join(repoDir, "paseo.json"), join(repoDir, "paseo.json"),
JSON.stringify({ JSON.stringify({
services: { scripts: {
web: { web: {
command: "npm run dev", command: "npm run dev",
}, },
@@ -519,15 +519,15 @@ describe("runAsyncWorktreeBootstrap", () => {
}), }),
); );
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add service config'", { execSync("git -c commit.gpgsign=false commit -m 'add script config'", {
cwd: repoDir, cwd: repoDir,
stdio: "pipe", stdio: "pipe",
}); });
const routeStore = new ServiceRouteStore(); const routeStore = new ScriptRouteStore();
const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record<string, string> }> = []; const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record<string, string> }> = [];
const results = await spawnWorktreeServices({ const results = await spawnWorktreeScripts({
repoRoot: repoDir, repoRoot: repoDir,
workspaceId: repoDir, workspaceId: repoDir,
branchName: "feature-socket-service", branchName: "feature-socket-service",
@@ -577,7 +577,7 @@ describe("runAsyncWorktreeBootstrap", () => {
hostname: "feature-socket-service.web.localhost", hostname: "feature-socket-service.web.localhost",
port: expect.any(Number), port: expect.any(Number),
workspaceId: repoDir, workspaceId: repoDir,
serviceName: "web", scriptName: "web",
}, },
]); ]);
expect(createTerminalCalls).toHaveLength(1); expect(createTerminalCalls).toHaveLength(1);
@@ -585,6 +585,6 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(createTerminalCalls[0]?.name).toBe("web"); expect(createTerminalCalls[0]?.name).toBe("web");
expect(createTerminalCalls[0]?.env?.PORT).toEqual(expect.any(String)); expect(createTerminalCalls[0]?.env?.PORT).toEqual(expect.any(String));
expect(createTerminalCalls[0]?.env?.HOST).toBe("127.0.0.1"); expect(createTerminalCalls[0]?.env?.HOST).toBe("127.0.0.1");
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_URL).toBeUndefined(); expect(createTerminalCalls[0]?.env?.PASEO_SCRIPT_URL).toBeUndefined();
}); });
}); });
@@ -5,10 +5,10 @@ import { promisify } from "node:util";
import { sep } from "node:path"; import { sep } from "node:path";
import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { TerminalSession } from "../terminal/terminal.js"; import type { TerminalSession } from "../terminal/terminal.js";
import { buildServiceHostname } from "../utils/service-hostname.js"; import { buildScriptHostname } from "../utils/script-hostname.js";
import { import {
createWorktree, createWorktree,
getServiceConfigs, getScriptConfigs,
getWorktreeTerminalSpecs, getWorktreeTerminalSpecs,
listPaseoWorktrees, listPaseoWorktrees,
processCarriageReturns, processCarriageReturns,
@@ -19,7 +19,7 @@ import {
type WorktreeSetupCommandResult, type WorktreeSetupCommandResult,
type WorktreeRuntimeEnv, type WorktreeRuntimeEnv,
} from "../utils/worktree.js"; } from "../utils/worktree.js";
import { findFreePort, type ServiceRouteStore } from "./service-proxy.js"; import { findFreePort, type ScriptRouteStore } from "./script-proxy.js";
import type { AgentTimelineItem, ToolCallDetail } from "./agent/agent-sdk-types.js"; import type { AgentTimelineItem, ToolCallDetail } from "./agent/agent-sdk-types.js";
export interface WorktreeBootstrapTerminalResult { export interface WorktreeBootstrapTerminalResult {
@@ -35,7 +35,7 @@ export interface RunAsyncWorktreeBootstrapOptions {
worktree: WorktreeConfig; worktree: WorktreeConfig;
shouldBootstrap?: boolean; shouldBootstrap?: boolean;
terminalManager: TerminalManager | null; terminalManager: TerminalManager | null;
serviceRouteStore?: ServiceRouteStore; scriptRouteStore?: ScriptRouteStore;
daemonPort?: number | null; daemonPort?: number | null;
appendTimelineItem: (item: AgentTimelineItem) => Promise<boolean>; appendTimelineItem: (item: AgentTimelineItem) => Promise<boolean>;
emitLiveTimelineItem?: (item: AgentTimelineItem) => Promise<boolean>; emitLiveTimelineItem?: (item: AgentTimelineItem) => Promise<boolean>;
@@ -735,79 +735,79 @@ export async function runAsyncWorktreeBootstrap(
await runWorktreeTerminalBootstrap(options, runtimeEnv); await runWorktreeTerminalBootstrap(options, runtimeEnv);
if (!options.terminalManager || !options.serviceRouteStore) { if (!options.terminalManager || !options.scriptRouteStore) {
return; return;
} }
try { try {
await spawnWorktreeServices({ await spawnWorktreeScripts({
repoRoot: options.worktree.worktreePath, repoRoot: options.worktree.worktreePath,
workspaceId: 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.scriptRouteStore,
terminalManager: options.terminalManager, terminalManager: options.terminalManager,
logger: options.logger, logger: options.logger,
}); });
} catch (error) { } catch (error) {
options.logger?.warn( options.logger?.warn(
{ err: error, agentId: options.agentId, worktreePath: options.worktree.worktreePath }, { err: error, agentId: options.agentId, worktreePath: options.worktree.worktreePath },
"Failed to spawn worktree services", "Failed to spawn worktree scripts",
); );
} }
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Service lifecycle helpers // Script lifecycle helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export interface WorktreeServiceResult { export interface WorktreeScriptResult {
serviceName: string; scriptName: string;
hostname: string; hostname: string;
port: number; port: number;
terminalId: string; terminalId: string;
} }
type SpawnWorkspaceServiceOptions = { type SpawnWorkspaceScriptOptions = {
repoRoot: string; repoRoot: string;
workspaceId: string; workspaceId: string;
branchName: string | null; branchName: string | null;
serviceName: string; scriptName: string;
daemonPort?: number | null; daemonPort?: number | null;
routeStore: ServiceRouteStore; routeStore: ScriptRouteStore;
terminalManager: TerminalManager; terminalManager: TerminalManager;
logger?: Logger; logger?: Logger;
onLifecycleChanged?: () => void; onLifecycleChanged?: () => void;
}; };
export async function spawnWorkspaceService( export async function spawnWorkspaceScript(
options: SpawnWorkspaceServiceOptions, options: SpawnWorkspaceScriptOptions,
): Promise<WorktreeServiceResult> { ): Promise<WorktreeScriptResult> {
const { const {
repoRoot, repoRoot,
workspaceId, workspaceId,
branchName, branchName,
serviceName, scriptName,
daemonPort, daemonPort,
routeStore, routeStore,
terminalManager, terminalManager,
logger, logger,
onLifecycleChanged, onLifecycleChanged,
} = options; } = options;
const serviceConfigs = getServiceConfigs(repoRoot); const scriptConfigs = getScriptConfigs(repoRoot);
const config = serviceConfigs.get(serviceName); const config = scriptConfigs.get(scriptName);
if (!config) { if (!config) {
throw new Error(`Service '${serviceName}' is not configured in paseo.json`); throw new Error(`Script '${scriptName}' is not configured in paseo.json`);
} }
let hostname: string | null = null; let hostname: string | null = null;
let port: number | null = null; let port: number | null = null;
try { try {
hostname = buildServiceHostname(branchName, serviceName); hostname = buildScriptHostname(branchName, scriptName);
const resolvedHostname = hostname; const resolvedHostname = hostname;
if (routeStore.getRouteEntry(resolvedHostname)) { if (routeStore.getRouteEntry(resolvedHostname)) {
throw new Error(`Service '${serviceName}' is already running`); throw new Error(`Script '${scriptName}' is already running`);
} }
port = config.port ?? (await findFreePort()); port = config.port ?? (await findFreePort());
@@ -816,7 +816,7 @@ export async function spawnWorkspaceService(
hostname: resolvedHostname, hostname: resolvedHostname,
port, port,
workspaceId, workspaceId,
serviceName, scriptName,
}); });
const env: Record<string, string> = { const env: Record<string, string> = {
@@ -824,12 +824,12 @@ export async function spawnWorkspaceService(
HOST: "127.0.0.1", HOST: "127.0.0.1",
}; };
if (daemonPort !== null && daemonPort !== undefined) { if (daemonPort !== null && daemonPort !== undefined) {
env.PASEO_SERVICE_URL = `http://${resolvedHostname}:${daemonPort}`; env.PASEO_SCRIPT_URL = `http://${resolvedHostname}:${daemonPort}`;
} }
const terminal = await terminalManager.createTerminal({ const terminal = await terminalManager.createTerminal({
cwd: repoRoot, cwd: repoRoot,
name: serviceName, name: scriptName,
env, env,
}); });
@@ -837,8 +837,8 @@ export async function spawnWorkspaceService(
routeStore.removeRoute(resolvedHostname); routeStore.removeRoute(resolvedHostname);
onLifecycleChanged?.(); onLifecycleChanged?.();
logger?.info( logger?.info(
{ serviceName, hostname: resolvedHostname, terminalId: terminal.id }, { scriptName, hostname: resolvedHostname, terminalId: terminal.id },
"Stopped worktree service", "Stopped worktree script",
); );
}); });
@@ -846,13 +846,13 @@ export async function spawnWorkspaceService(
terminal.send({ type: "input", data: `${config.command}\r` }); terminal.send({ type: "input", data: `${config.command}\r` });
logger?.info( logger?.info(
{ serviceName, hostname: resolvedHostname, port, terminalId: terminal.id }, { scriptName, hostname: resolvedHostname, port, terminalId: terminal.id },
`Registered service proxy: ${resolvedHostname} -> 127.0.0.1:${port}`, `Registered script proxy: ${resolvedHostname} -> 127.0.0.1:${port}`,
); );
onLifecycleChanged?.(); onLifecycleChanged?.();
return { return {
serviceName, scriptName,
hostname: resolvedHostname, hostname: resolvedHostname,
port, port,
terminalId: terminal.id, terminalId: terminal.id,
@@ -864,41 +864,41 @@ export async function spawnWorkspaceService(
logger?.error( logger?.error(
{ {
err: error, err: error,
serviceName, scriptName,
repoRoot, repoRoot,
branchName, branchName,
hostname, hostname,
port, port,
command: config.command, command: config.command,
}, },
"Failed to spawn worktree service", "Failed to spawn worktree script",
); );
throw error; throw error;
} }
} }
export async function spawnWorktreeServices(options: { export async function spawnWorktreeScripts(options: {
repoRoot: string; repoRoot: string;
workspaceId: string; workspaceId: string;
branchName: string | null; branchName: string | null;
daemonPort?: number | null; daemonPort?: number | null;
routeStore: ServiceRouteStore; routeStore: ScriptRouteStore;
terminalManager: TerminalManager; terminalManager: TerminalManager;
logger?: Logger; logger?: Logger;
onLifecycleChanged?: () => void; onLifecycleChanged?: () => void;
}): Promise<WorktreeServiceResult[]> { }): Promise<WorktreeScriptResult[]> {
const { repoRoot } = options; const { repoRoot } = options;
const serviceConfigs = getServiceConfigs(repoRoot); const scriptConfigs = getScriptConfigs(repoRoot);
if (serviceConfigs.size === 0) { if (scriptConfigs.size === 0) {
return []; return [];
} }
const results: WorktreeServiceResult[] = []; const results: WorktreeScriptResult[] = [];
for (const serviceName of serviceConfigs.keys()) { for (const scriptName of scriptConfigs.keys()) {
results.push( results.push(
await spawnWorkspaceService({ await spawnWorkspaceScript({
...options, ...options,
serviceName, scriptName,
}), }),
); );
} }
@@ -906,14 +906,14 @@ export async function spawnWorktreeServices(options: {
return results; return results;
} }
export function teardownWorktreeServices(options: { export function teardownWorktreeScripts(options: {
hostnames: string[]; hostnames: string[];
routeStore: ServiceRouteStore; routeStore: ScriptRouteStore;
logger: Logger; logger: Logger;
}): void { }): void {
const { hostnames, routeStore, logger } = options; const { hostnames, routeStore, logger } = options;
for (const hostname of hostnames) { for (const hostname of hostnames) {
routeStore.removeRoute(hostname); routeStore.removeRoute(hostname);
logger.info({ hostname }, "Removed service proxy route"); logger.info({ hostname }, "Removed script proxy route");
} }
} }
@@ -5,7 +5,7 @@ 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 { ScriptRouteStore } from "./script-proxy.js";
import * as worktreeBootstrap from "./worktree-bootstrap.js"; import * as worktreeBootstrap from "./worktree-bootstrap.js";
import { import {
createPaseoWorktreeInBackground, createPaseoWorktreeInBackground,
@@ -102,10 +102,10 @@ describe("createPaseoWorktreeInBackground", () => {
} }
}); });
test("emits running then completed snapshots for no-setup workspaces and then launches services", async () => { test("emits running then completed snapshots for no-setup workspaces and then launches scripts", async () => {
const { tempDir, repoDir } = createGitRepo({ const { tempDir, repoDir } = createGitRepo({
paseoConfig: { paseoConfig: {
services: { scripts: {
web: { web: {
command: "npm run dev", command: "npm run dev",
}, },
@@ -126,7 +126,7 @@ describe("createPaseoWorktreeInBackground", () => {
const worktreePath = createdWorktree.worktreePath; const worktreePath = createdWorktree.worktreePath;
const emitted: SessionOutboundMessage[] = []; const emitted: SessionOutboundMessage[] = [];
const snapshots = new Map<string, unknown>(); const snapshots = new Map<string, unknown>();
const routeStore = new ServiceRouteStore(); const routeStore = new ScriptRouteStore();
const logger = createLogger(); const logger = createLogger();
const terminalManager = createTerminalManagerStub(); const terminalManager = createTerminalManagerStub();
const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
@@ -141,7 +141,7 @@ describe("createPaseoWorktreeInBackground", () => {
sessionLogger: logger, sessionLogger: logger,
terminalManager: terminalManager.manager, terminalManager: terminalManager.manager,
archiveWorkspaceRecord, archiveWorkspaceRecord,
serviceRouteStore: routeStore, scriptRouteStore: routeStore,
daemonPort: 6767, daemonPort: 6767,
}, },
{ {
@@ -202,13 +202,13 @@ describe("createPaseoWorktreeInBackground", () => {
hostname: "feature-no-setup.web.localhost", hostname: "feature-no-setup.web.localhost",
port: expect.any(Number), port: expect.any(Number),
workspaceId: worktreePath, workspaceId: worktreePath,
serviceName: "web", scriptName: "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?.PORT).toEqual(expect.any(String));
expect(terminalManager.terminals[0]?.env?.PASEO_SERVICE_URL).toBeDefined(); expect(terminalManager.terminals[0]?.env?.PASEO_SCRIPT_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);
@@ -251,7 +251,7 @@ describe("createPaseoWorktreeInBackground", () => {
sessionLogger: logger, sessionLogger: logger,
terminalManager: null, terminalManager: null,
archiveWorkspaceRecord, archiveWorkspaceRecord,
serviceRouteStore: null, scriptRouteStore: null,
daemonPort: null, daemonPort: null,
}, },
{ {
@@ -319,7 +319,7 @@ describe("createPaseoWorktreeInBackground", () => {
sessionLogger: logger, sessionLogger: logger,
terminalManager: null, terminalManager: null,
archiveWorkspaceRecord, archiveWorkspaceRecord,
serviceRouteStore: null, scriptRouteStore: null,
daemonPort: null, daemonPort: null,
}, },
{ {
@@ -400,7 +400,7 @@ describe("createPaseoWorktreeInBackground", () => {
worktree: { worktree: {
setup: ["printf 'ran' > setup-ran.txt"], setup: ["printf 'ran' > setup-ran.txt"],
}, },
services: { scripts: {
web: { web: {
command: "npm run dev", command: "npm run dev",
}, },
@@ -421,7 +421,7 @@ describe("createPaseoWorktreeInBackground", () => {
const emitted: SessionOutboundMessage[] = []; const emitted: SessionOutboundMessage[] = [];
const snapshots = new Map<string, unknown>(); const snapshots = new Map<string, unknown>();
const routeStore = new ServiceRouteStore(); const routeStore = new ScriptRouteStore();
const logger = createLogger(); const logger = createLogger();
const terminalManager = createTerminalManagerStub(); const terminalManager = createTerminalManagerStub();
const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
@@ -436,7 +436,7 @@ describe("createPaseoWorktreeInBackground", () => {
sessionLogger: logger, sessionLogger: logger,
terminalManager: terminalManager.manager, terminalManager: terminalManager.manager,
archiveWorkspaceRecord, archiveWorkspaceRecord,
serviceRouteStore: routeStore, scriptRouteStore: routeStore,
daemonPort: 6767, daemonPort: 6767,
}, },
{ {
@@ -478,14 +478,14 @@ describe("createPaseoWorktreeInBackground", () => {
hostname: "reused-worktree.web.localhost", hostname: "reused-worktree.web.localhost",
port: expect.any(Number), port: expect.any(Number),
workspaceId: existingWorktree.worktreePath, workspaceId: existingWorktree.worktreePath,
serviceName: "web", scriptName: "web",
}, },
]); ]);
expect(terminalManager.terminals).toHaveLength(1); expect(terminalManager.terminals).toHaveLength(1);
expect(terminalManager.terminals[0]?.cwd).toBe(existingWorktree.worktreePath); expect(terminalManager.terminals[0]?.cwd).toBe(existingWorktree.worktreePath);
expect(terminalManager.terminals[0]?.name).toBe("web"); expect(terminalManager.terminals[0]?.name).toBe("web");
expect(terminalManager.terminals[0]?.env?.PORT).toEqual(expect.any(String)); expect(terminalManager.terminals[0]?.env?.PORT).toEqual(expect.any(String));
expect(terminalManager.terminals[0]?.env?.PASEO_SERVICE_URL).toBeDefined(); expect(terminalManager.terminals[0]?.env?.PASEO_SCRIPT_URL).toBeDefined();
expect(terminalManager.terminals[0]?.sent).toEqual(["npm run dev\r"]); 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"),
@@ -502,7 +502,7 @@ describe("createPaseoWorktreeInBackground", () => {
test("keeps setup completed when service launch fails afterward", async () => { test("keeps setup completed when service launch fails afterward", async () => {
const { tempDir, repoDir } = createGitRepo({ const { tempDir, repoDir } = createGitRepo({
paseoConfig: { paseoConfig: {
services: { scripts: {
web: { web: {
command: "npm run dev", command: "npm run dev",
}, },
@@ -523,7 +523,7 @@ describe("createPaseoWorktreeInBackground", () => {
const worktreePath = createdWorktree.worktreePath; const worktreePath = createdWorktree.worktreePath;
const emitted: SessionOutboundMessage[] = []; const emitted: SessionOutboundMessage[] = [];
const snapshots = new Map<string, unknown>(); const snapshots = new Map<string, unknown>();
const routeStore = new ServiceRouteStore(); const routeStore = new ScriptRouteStore();
const logger = createLogger(); const logger = createLogger();
const terminalManager = createTerminalManagerStub({ const terminalManager = createTerminalManagerStub({
createTerminal: async () => { createTerminal: async () => {
@@ -542,7 +542,7 @@ describe("createPaseoWorktreeInBackground", () => {
sessionLogger: logger, sessionLogger: logger,
terminalManager: terminalManager.manager, terminalManager: terminalManager.manager,
archiveWorkspaceRecord, archiveWorkspaceRecord,
serviceRouteStore: routeStore, scriptRouteStore: routeStore,
daemonPort: 6767, daemonPort: 6767,
}, },
{ {
@@ -575,7 +575,7 @@ describe("createPaseoWorktreeInBackground", () => {
worktreeSlug: "feature-service-failure", worktreeSlug: "feature-service-failure",
worktreePath, worktreePath,
}), }),
"Failed to spawn worktree services after workspace setup completed", "Failed to spawn worktree scripts after workspace setup completed",
); );
expect(snapshots.get("45")).toMatchObject({ expect(snapshots.get("45")).toMatchObject({
status: "completed", status: "completed",
@@ -585,10 +585,10 @@ describe("createPaseoWorktreeInBackground", () => {
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath); expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
}); });
test("launches services in socket mode without requiring a daemon TCP port", async () => { test("launches scripts in socket mode without requiring a daemon TCP port", async () => {
const { tempDir, repoDir } = createGitRepo({ const { tempDir, repoDir } = createGitRepo({
paseoConfig: { paseoConfig: {
services: { scripts: {
web: { web: {
command: "npm run dev", command: "npm run dev",
}, },
@@ -609,7 +609,7 @@ describe("createPaseoWorktreeInBackground", () => {
const worktreePath = createdWorktree.worktreePath; const worktreePath = createdWorktree.worktreePath;
const emitted: SessionOutboundMessage[] = []; const emitted: SessionOutboundMessage[] = [];
const snapshots = new Map<string, unknown>(); const snapshots = new Map<string, unknown>();
const routeStore = new ServiceRouteStore(); const routeStore = new ScriptRouteStore();
const logger = createLogger(); const logger = createLogger();
const terminalManager = createTerminalManagerStub(); const terminalManager = createTerminalManagerStub();
const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
@@ -624,7 +624,7 @@ describe("createPaseoWorktreeInBackground", () => {
sessionLogger: logger, sessionLogger: logger,
terminalManager: terminalManager.manager, terminalManager: terminalManager.manager,
archiveWorkspaceRecord, archiveWorkspaceRecord,
serviceRouteStore: routeStore, scriptRouteStore: routeStore,
daemonPort: null, daemonPort: null,
}, },
{ {
@@ -644,13 +644,13 @@ describe("createPaseoWorktreeInBackground", () => {
hostname: "feature-socket-mode.web.localhost", hostname: "feature-socket-mode.web.localhost",
port: expect.any(Number), port: expect.any(Number),
workspaceId: worktreePath, workspaceId: worktreePath,
serviceName: "web", scriptName: "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?.PORT).toEqual(expect.any(String));
expect(terminalManager.terminals[0]?.env?.PASEO_SERVICE_URL).toBeUndefined(); expect(terminalManager.terminals[0]?.env?.PASEO_SCRIPT_URL).toBeUndefined();
expect(terminalManager.terminals[0]?.sent).toEqual(["npm run dev\r"]); expect(terminalManager.terminals[0]?.sent).toEqual(["npm run dev\r"]);
expect(snapshots.get("46")).toMatchObject({ expect(snapshots.get("46")).toMatchObject({
status: "completed", status: "completed",
@@ -26,10 +26,10 @@ import {
createAgentWorktree, createAgentWorktree,
createWorktreeSetupProgressAccumulator, createWorktreeSetupProgressAccumulator,
getWorktreeSetupProgressResults, getWorktreeSetupProgressResults,
spawnWorktreeServices, spawnWorktreeScripts,
} 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 { ScriptRouteStore } from "./script-proxy.js";
import { import {
getCheckoutStatusLite, getCheckoutStatusLite,
resolveRepositoryDefaultBranch, resolveRepositoryDefaultBranch,
@@ -110,7 +110,7 @@ type CreatePaseoWorktreeInBackgroundDependencies = {
sessionLogger: Logger; sessionLogger: Logger;
terminalManager: TerminalManager | null; terminalManager: TerminalManager | null;
archiveWorkspaceRecord: (workspaceId: number) => Promise<void>; archiveWorkspaceRecord: (workspaceId: number) => Promise<void>;
serviceRouteStore: ServiceRouteStore | null; scriptRouteStore: ScriptRouteStore | null;
daemonPort?: number | null; daemonPort?: number | null;
}; };
@@ -765,17 +765,17 @@ export async function createPaseoWorktreeInBackground(
return; return;
} }
if (!dependencies.terminalManager || !dependencies.serviceRouteStore) { if (!dependencies.terminalManager || !dependencies.scriptRouteStore) {
return; return;
} }
try { try {
await spawnWorktreeServices({ await spawnWorktreeScripts({
repoRoot: worktree.worktreePath, repoRoot: worktree.worktreePath,
workspaceId: worktree.worktreePath, workspaceId: worktree.worktreePath,
branchName: worktree.branchName, branchName: worktree.branchName,
daemonPort: dependencies.daemonPort, daemonPort: dependencies.daemonPort,
routeStore: dependencies.serviceRouteStore, routeStore: dependencies.scriptRouteStore,
terminalManager: dependencies.terminalManager, terminalManager: dependencies.terminalManager,
logger: dependencies.sessionLogger, logger: dependencies.sessionLogger,
onLifecycleChanged: () => { onLifecycleChanged: () => {
@@ -791,7 +791,7 @@ export async function createPaseoWorktreeInBackground(
worktreeSlug: worktree.branchName, worktreeSlug: worktree.branchName,
worktreePath: worktree.worktreePath, worktreePath: worktree.worktreePath,
}, },
"Failed to spawn worktree services after workspace setup completed", "Failed to spawn worktree scripts after workspace setup completed",
); );
} }
} finally { } finally {
+28 -27
View File
@@ -1301,10 +1301,10 @@ export const CreateTerminalRequestSchema = z.object({
requestId: z.string(), requestId: z.string(),
}); });
export const StartWorkspaceServiceRequestSchema = z.object({ export const StartWorkspaceScriptRequestSchema = z.object({
type: z.literal("start_workspace_service_request"), type: z.literal("start_workspace_script_request"),
workspaceId: z.string(), workspaceId: z.string(),
serviceName: z.string(), scriptName: z.string(),
requestId: z.string(), requestId: z.string(),
}); });
@@ -1421,7 +1421,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
SubscribeTerminalsRequestSchema, SubscribeTerminalsRequestSchema,
UnsubscribeTerminalsRequestSchema, UnsubscribeTerminalsRequestSchema,
CreateTerminalRequestSchema, CreateTerminalRequestSchema,
StartWorkspaceServiceRequestSchema, StartWorkspaceScriptRequestSchema,
SubscribeTerminalRequestSchema, SubscribeTerminalRequestSchema,
UnsubscribeTerminalRequestSchema, UnsubscribeTerminalRequestSchema,
TerminalInputSchema, TerminalInputSchema,
@@ -1777,16 +1777,16 @@ export const ProjectPlacementPayloadSchema = z.object({
checkout: ProjectCheckoutLitePayloadSchema, checkout: ProjectCheckoutLitePayloadSchema,
}); });
export const WorkspaceServiceLifecycleSchema = z.enum(["running", "stopped"]); export const WorkspaceScriptLifecycleSchema = z.enum(["running", "stopped"]);
export const WorkspaceServiceHealthSchema = z.enum(["healthy", "unhealthy"]); export const WorkspaceScriptHealthSchema = z.enum(["healthy", "unhealthy"]);
export const WorkspaceServicePayloadSchema = z.object({ export const WorkspaceScriptPayloadSchema = z.object({
serviceName: z.string(), scriptName: z.string(),
hostname: z.string(), hostname: z.string(),
port: z.number().int().positive().nullable(), port: z.number().int().positive().nullable(),
url: z.string().nullable(), url: z.string().nullable(),
lifecycle: WorkspaceServiceLifecycleSchema, lifecycle: WorkspaceScriptLifecycleSchema,
health: WorkspaceServiceHealthSchema.nullable(), health: WorkspaceScriptHealthSchema.nullable(),
}); });
export const WorkspaceDescriptorPayloadSchema = z.object({ export const WorkspaceDescriptorPayloadSchema = z.object({
@@ -1808,7 +1808,7 @@ export const WorkspaceDescriptorPayloadSchema = z.object({
}) })
.nullable() .nullable()
.optional(), .optional(),
services: z.array(WorkspaceServicePayloadSchema).default([]), scripts: z.array(WorkspaceScriptPayloadSchema).default([]),
}); });
export const AgentUpdateMessageSchema = z.object({ export const AgentUpdateMessageSchema = z.object({
@@ -1904,11 +1904,11 @@ export const WorkspaceUpdateMessageSchema = z.object({
]), ]),
}); });
export const ServiceStatusUpdateMessageSchema = z.object({ export const ScriptStatusUpdateMessageSchema = z.object({
type: z.literal("service_status_update"), type: z.literal("script_status_update"),
payload: z.object({ payload: z.object({
workspaceId: z.string(), workspaceId: z.string(),
services: z.array(WorkspaceServicePayloadSchema), scripts: z.array(WorkspaceScriptPayloadSchema),
}), }),
}); });
@@ -1946,12 +1946,13 @@ export const OpenProjectResponseMessageSchema = z.object({
}), }),
}); });
export const StartWorkspaceServiceResponseMessageSchema = z.object({ export const StartWorkspaceScriptResponseMessageSchema = z.object({
type: z.literal("start_workspace_service_response"), type: z.literal("start_workspace_script_response"),
payload: z.object({ payload: z.object({
requestId: z.string(), requestId: z.string(),
workspaceId: z.string(), workspaceId: z.string(),
serviceName: z.string(), scriptName: z.string(),
terminalId: z.string().nullable(),
error: z.string().nullable(), error: z.string().nullable(),
}), }),
}); });
@@ -2657,7 +2658,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ArtifactMessageSchema, ArtifactMessageSchema,
AgentUpdateMessageSchema, AgentUpdateMessageSchema,
WorkspaceUpdateMessageSchema, WorkspaceUpdateMessageSchema,
ServiceStatusUpdateMessageSchema, ScriptStatusUpdateMessageSchema,
WorkspaceSetupProgressMessageSchema, WorkspaceSetupProgressMessageSchema,
WorkspaceSetupStatusResponseMessageSchema, WorkspaceSetupStatusResponseMessageSchema,
AgentStreamMessageSchema, AgentStreamMessageSchema,
@@ -2665,7 +2666,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
FetchAgentsResponseMessageSchema, FetchAgentsResponseMessageSchema,
FetchWorkspacesResponseMessageSchema, FetchWorkspacesResponseMessageSchema,
OpenProjectResponseMessageSchema, OpenProjectResponseMessageSchema,
StartWorkspaceServiceResponseMessageSchema, StartWorkspaceScriptResponseMessageSchema,
ListAvailableEditorsResponseMessageSchema, ListAvailableEditorsResponseMessageSchema,
OpenInEditorResponseMessageSchema, OpenInEditorResponseMessageSchema,
ArchiveWorkspaceResponseMessageSchema, ArchiveWorkspaceResponseMessageSchema,
@@ -2765,17 +2766,17 @@ export type ProjectCheckoutLitePayload = z.infer<typeof ProjectCheckoutLitePaylo
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 WorkspaceServiceLifecycle = z.infer<typeof WorkspaceServiceLifecycleSchema>; export type WorkspaceScriptLifecycle = z.infer<typeof WorkspaceScriptLifecycleSchema>;
export type WorkspaceServiceHealth = z.infer<typeof WorkspaceServiceHealthSchema>; export type WorkspaceScriptHealth = z.infer<typeof WorkspaceScriptHealthSchema>;
export type WorkspaceServicePayload = z.infer<typeof WorkspaceServicePayloadSchema>; export type WorkspaceScriptPayload = z.infer<typeof WorkspaceScriptPayloadSchema>;
export type EditorTargetId = z.infer<typeof EditorTargetIdSchema>; export type EditorTargetId = z.infer<typeof EditorTargetIdSchema>;
export type EditorTargetDescriptorPayload = z.infer<typeof EditorTargetDescriptorPayloadSchema>; export type EditorTargetDescriptorPayload = z.infer<typeof EditorTargetDescriptorPayloadSchema>;
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 ScriptStatusUpdateMessage = z.infer<typeof ScriptStatusUpdateMessageSchema>;
export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessageSchema>; export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessageSchema>;
export type StartWorkspaceServiceResponseMessage = z.infer< export type StartWorkspaceScriptResponseMessage = z.infer<
typeof StartWorkspaceServiceResponseMessageSchema typeof StartWorkspaceScriptResponseMessageSchema
>; >;
export type ListAvailableEditorsResponseMessage = z.infer< export type ListAvailableEditorsResponseMessage = z.infer<
typeof ListAvailableEditorsResponseMessageSchema typeof ListAvailableEditorsResponseMessageSchema
@@ -2956,8 +2957,8 @@ export type UnsubscribeTerminalsRequest = z.infer<typeof UnsubscribeTerminalsReq
export type TerminalsChanged = z.infer<typeof TerminalsChangedSchema>; export type TerminalsChanged = z.infer<typeof TerminalsChangedSchema>;
export type CreateTerminalRequest = z.infer<typeof CreateTerminalRequestSchema>; export type CreateTerminalRequest = z.infer<typeof CreateTerminalRequestSchema>;
export type CreateTerminalResponse = z.infer<typeof CreateTerminalResponseSchema>; export type CreateTerminalResponse = z.infer<typeof CreateTerminalResponseSchema>;
export type StartWorkspaceServiceRequest = z.infer<typeof StartWorkspaceServiceRequestSchema>; export type StartWorkspaceScriptRequest = z.infer<typeof StartWorkspaceScriptRequestSchema>;
export type StartWorkspaceServiceResponse = z.infer<typeof StartWorkspaceServiceResponseMessageSchema>; export type StartWorkspaceScriptResponse = z.infer<typeof StartWorkspaceScriptResponseMessageSchema>;
export type SubscribeTerminalRequest = z.infer<typeof SubscribeTerminalRequestSchema>; export type SubscribeTerminalRequest = z.infer<typeof SubscribeTerminalRequestSchema>;
export type SubscribeTerminalResponse = z.infer<typeof SubscribeTerminalResponseSchema>; export type SubscribeTerminalResponse = z.infer<typeof SubscribeTerminalResponseSchema>;
export type UnsubscribeTerminalRequest = z.infer<typeof UnsubscribeTerminalRequestSchema>; export type UnsubscribeTerminalRequest = z.infer<typeof UnsubscribeTerminalRequestSchema>;
@@ -65,7 +65,7 @@ describe("workspace message schemas", () => {
name: "", name: "",
status: "not-a-bucket", status: "not-a-bucket",
activityAt: null, activityAt: null,
services: [], scripts: [],
}, },
}, },
}); });
@@ -73,7 +73,7 @@ describe("workspace message schemas", () => {
expect(result.success).toBe(false); expect(result.success).toBe(false);
}); });
test("parses workspace descriptors with services", () => { test("parses workspace descriptors with scripts", () => {
const parsed = SessionOutboundMessageSchema.parse({ const parsed = SessionOutboundMessageSchema.parse({
type: "workspace_update", type: "workspace_update",
payload: { payload: {
@@ -89,9 +89,9 @@ describe("workspace message schemas", () => {
name: "repo", name: "repo",
status: "done", status: "done",
activityAt: null, activityAt: null,
services: [ scripts: [
{ {
serviceName: "web", scriptName: "web",
hostname: "web.localhost", hostname: "web.localhost",
port: 3000, port: 3000,
url: "http://web.localhost:6767", url: "http://web.localhost:6767",
@@ -107,9 +107,9 @@ describe("workspace message schemas", () => {
if (parsed.type !== "workspace_update" || parsed.payload.kind !== "upsert") { if (parsed.type !== "workspace_update" || parsed.payload.kind !== "upsert") {
throw new Error("Expected workspace_update upsert payload"); throw new Error("Expected workspace_update upsert payload");
} }
expect(parsed.payload.workspace.services).toEqual([ expect(parsed.payload.workspace.scripts).toEqual([
{ {
serviceName: "web", scriptName: "web",
hostname: "web.localhost", hostname: "web.localhost",
port: 3000, port: 3000,
url: "http://web.localhost:6767", url: "http://web.localhost:6767",
@@ -135,7 +135,7 @@ describe("workspace message schemas", () => {
name: "repo", name: "repo",
status: "done", status: "done",
activityAt: null, activityAt: null,
services: [], scripts: [],
}, },
}, },
}); });
@@ -148,14 +148,14 @@ describe("workspace message schemas", () => {
expect(parsed.payload.workspace.workspaceKind).toBe("directory"); expect(parsed.payload.workspace.workspaceKind).toBe("directory");
}); });
test("parses service_status_update payload", () => { test("parses script_status_update payload", () => {
const parsed = SessionOutboundMessageSchema.parse({ const parsed = SessionOutboundMessageSchema.parse({
type: "service_status_update", type: "script_status_update",
payload: { payload: {
workspaceId: "/repo", workspaceId: "/repo",
services: [ scripts: [
{ {
serviceName: "web", scriptName: "web",
hostname: "web.localhost", hostname: "web.localhost",
port: null, port: null,
url: null, url: null,
@@ -166,7 +166,7 @@ describe("workspace message schemas", () => {
}, },
}); });
expect(parsed.type).toBe("service_status_update"); expect(parsed.type).toBe("script_status_update");
expect(parsed.payload.workspaceId).toBe("/repo"); expect(parsed.payload.workspaceId).toBe("/repo");
}); });
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { buildScriptHostname } from "./script-hostname.js";
describe("buildScriptHostname", () => {
it("slugifies script names with spaces on default branches", () => {
expect(buildScriptHostname(null, "npm run dev")).toBe("npm-run-dev.localhost");
});
it("slugifies script names with special characters", () => {
expect(buildScriptHostname(null, "Web/API @ Dev")).toBe("web-api-dev.localhost");
});
it("omits the branch prefix for main and master", () => {
expect(buildScriptHostname("main", "npm run dev")).toBe("npm-run-dev.localhost");
expect(buildScriptHostname("master", "npm run dev")).toBe("npm-run-dev.localhost");
});
it("adds a slugified branch prefix for non-default branches", () => {
expect(buildScriptHostname("feature/cool stuff", "api")).toBe(
"feature-cool-stuff.api.localhost",
);
});
it("slugifies both the branch name and script name together", () => {
expect(buildScriptHostname("feat/add auth", "npm run dev")).toBe(
"feat-add-auth.npm-run-dev.localhost",
);
});
});
@@ -1,7 +1,7 @@
import { slugify } from "./worktree.js"; import { slugify } from "./worktree.js";
export function buildServiceHostname(branchName: string | null, serviceName: string): string { export function buildScriptHostname(branchName: string | null, scriptName: string): string {
const serviceHostnameLabel = slugify(serviceName); const serviceHostnameLabel = slugify(scriptName);
const isDefaultBranch = const isDefaultBranch =
branchName === null || branchName === "main" || branchName === "master"; branchName === null || branchName === "main" || branchName === "master";
@@ -1,29 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildServiceHostname } from "./service-hostname.js";
describe("buildServiceHostname", () => {
it("slugifies service names with spaces on default branches", () => {
expect(buildServiceHostname(null, "npm run dev")).toBe("npm-run-dev.localhost");
});
it("slugifies service names with special characters", () => {
expect(buildServiceHostname(null, "Web/API @ Dev")).toBe("web-api-dev.localhost");
});
it("omits the branch prefix for main and master", () => {
expect(buildServiceHostname("main", "npm run dev")).toBe("npm-run-dev.localhost");
expect(buildServiceHostname("master", "npm run dev")).toBe("npm-run-dev.localhost");
});
it("adds a slugified branch prefix for non-default branches", () => {
expect(buildServiceHostname("feature/cool stuff", "api")).toBe(
"feature-cool-stuff.api.localhost",
);
});
it("slugifies both the branch name and service name together", () => {
expect(buildServiceHostname("feat/add auth", "npm run dev")).toBe(
"feat-add-auth.npm-run-dev.localhost",
);
});
});
+10 -10
View File
@@ -24,7 +24,7 @@ interface PaseoConfig {
teardown?: string[]; teardown?: string[];
terminals?: WorktreeTerminalConfig[]; terminals?: WorktreeTerminalConfig[];
}; };
services?: Record<string, ServiceConfig>; scripts?: Record<string, ScriptConfig>;
} }
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -90,7 +90,7 @@ export interface WorktreeTerminalConfig {
command: string; command: string;
} }
export interface ServiceConfig { export interface ScriptConfig {
command: string; command: string;
port?: number; // explicit port override, otherwise auto-assigned port?: number; // explicit port override, otherwise auto-assigned
} }
@@ -205,15 +205,15 @@ export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConf
return specs; return specs;
} }
export function getServiceConfigs(repoRoot: string): Map<string, ServiceConfig> { export function getScriptConfigs(repoRoot: string): Map<string, ScriptConfig> {
const config = readPaseoConfig(repoRoot); const config = readPaseoConfig(repoRoot);
const services = config?.services; const scripts = config?.scripts;
if (!services || typeof services !== "object") { if (!scripts || typeof scripts !== "object") {
return new Map(); return new Map();
} }
const result = new Map<string, ServiceConfig>(); const result = new Map<string, ScriptConfig>();
for (const [name, entry] of Object.entries(services)) { for (const [name, entry] of Object.entries(scripts)) {
if (!entry || typeof entry !== "object") { if (!entry || typeof entry !== "object") {
continue; continue;
} }
@@ -227,13 +227,13 @@ export function getServiceConfigs(repoRoot: string): Map<string, ServiceConfig>
continue; continue;
} }
const serviceConfig: ServiceConfig = { command }; const scriptConfig: ScriptConfig = { command };
if (typeof entry.port === "number" && Number.isFinite(entry.port)) { if (typeof entry.port === "number" && Number.isFinite(entry.port)) {
serviceConfig.port = entry.port; scriptConfig.port = entry.port;
} }
result.set(name, serviceConfig); result.set(name, scriptConfig);
} }
return result; return result;
+8 -6
View File
@@ -4,12 +4,14 @@
"npm ci", "npm ci",
"npm run build --workspace=@getpaseo/relay", "npm run build --workspace=@getpaseo/relay",
"cp \"$PASEO_SOURCE_CHECKOUT_PATH/packages/server/.env\" \"$PASEO_WORKTREE_PATH/packages/server/.env\"" "cp \"$PASEO_SOURCE_CHECKOUT_PATH/packages/server/.env\" \"$PASEO_WORKTREE_PATH/packages/server/.env\""
],
"terminals": [
{
"name": "dev",
"command": "npm run dev"
}
] ]
},
"scripts": {
"server": {
"command": "npm run dev"
},
"typecheck": {
"command": "npm run typecheck"
}
} }
} }