From 397d454de0ec09e1c501a65dac7f1015740841bf Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 8 Apr 2026 18:17:47 +0700 Subject: [PATCH] refactor: rename services to scripts and extract reusable components --- packages/app/e2e/workspace-hover-card.spec.ts | 62 ++-- .../app/e2e/workspace-setup-streaming.spec.ts | 18 +- packages/app/src/components/diff-stat.tsx | 35 ++ packages/app/src/components/git-diff-pane.tsx | 14 +- .../src/components/sidebar-workspace-list.tsx | 72 ++-- .../src/components/workspace-hover-card.tsx | 351 +++++++++--------- .../session-context.service-status.test.ts | 42 +-- packages/app/src/contexts/session-context.tsx | 10 +- ...rvices.ts => session-workspace-scripts.ts} | 8 +- .../app/src/hooks/use-open-project.test.ts | 2 +- .../hooks/use-sidebar-workspaces-list.test.ts | 22 +- .../src/hooks/use-sidebar-workspaces-list.ts | 12 +- .../screens/workspace/workspace-screen.tsx | 37 +- .../workspace/workspace-scripts-button.tsx | 323 ++++++++++++++++ .../workspace-source-of-truth.test.ts | 2 +- packages/app/src/stores/session-store.test.ts | 34 +- packages/app/src/stores/session-store.ts | 9 +- packages/app/src/styles/theme.ts | 1 + .../utils/sidebar-project-row-model.test.ts | 4 +- .../app/src/utils/sidebar-shortcuts.test.ts | 4 +- .../workspace-archive-navigation.test.ts | 2 +- .../app/src/utils/workspace-execution.test.ts | 2 +- packages/server/src/client/daemon-client.ts | 12 +- .../src/server/agent/agent-management-mcp.ts | 6 +- .../server/src/server/agent/mcp-server.ts | 6 +- packages/server/src/server/bootstrap.ts | 68 ++-- ....test.ts => script-health-monitor.test.ts} | 102 ++--- ...th-monitor.ts => script-health-monitor.ts} | 40 +- ...ice-proxy.test.ts => script-proxy.test.ts} | 86 ++--- .../{service-proxy.ts => script-proxy.ts} | 42 +-- ...ts => script-route-branch-handler.test.ts} | 82 ++-- ...dler.ts => script-route-branch-handler.ts} | 28 +- ...st.ts => script-status-projection.test.ts} | 76 ++-- .../src/server/script-status-projection.ts | 135 +++++++ .../src/server/service-status-projection.ts | 135 ------- packages/server/src/server/session.ts | 92 ++--- .../server/src/server/websocket-server.ts | 18 +- .../src/server/worktree-bootstrap.test.ts | 18 +- .../server/src/server/worktree-bootstrap.ts | 90 ++--- .../src/server/worktree-session.test.ts | 48 +-- .../server/src/server/worktree-session.ts | 14 +- packages/server/src/shared/messages.ts | 55 +-- .../src/shared/messages.workspaces.test.ts | 24 +- .../server/src/utils/script-hostname.test.ts | 29 ++ ...service-hostname.ts => script-hostname.ts} | 4 +- .../server/src/utils/service-hostname.test.ts | 29 -- packages/server/src/utils/worktree.ts | 20 +- paseo.json | 14 +- 48 files changed, 1356 insertions(+), 983 deletions(-) create mode 100644 packages/app/src/components/diff-stat.tsx rename packages/app/src/contexts/{session-workspace-services.ts => session-workspace-scripts.ts} (73%) create mode 100644 packages/app/src/screens/workspace/workspace-scripts-button.tsx rename packages/server/src/server/{service-health-monitor.test.ts => script-health-monitor.test.ts} (79%) rename packages/server/src/server/{service-health-monitor.ts => script-health-monitor.ts} (82%) rename packages/server/src/server/{service-proxy.test.ts => script-proxy.test.ts} (89%) rename packages/server/src/server/{service-proxy.ts => script-proxy.ts} (90%) rename packages/server/src/server/{service-route-branch-handler.test.ts => script-route-branch-handler.test.ts} (69%) rename packages/server/src/server/{service-route-branch-handler.ts => script-route-branch-handler.ts} (63%) rename packages/server/src/server/{service-status-projection.test.ts => script-status-projection.test.ts} (74%) create mode 100644 packages/server/src/server/script-status-projection.ts delete mode 100644 packages/server/src/server/service-status-projection.ts create mode 100644 packages/server/src/utils/script-hostname.test.ts rename packages/server/src/utils/{service-hostname.ts => script-hostname.ts} (66%) delete mode 100644 packages/server/src/utils/service-hostname.test.ts diff --git a/packages/app/e2e/workspace-hover-card.spec.ts b/packages/app/e2e/workspace-hover-card.spec.ts index 91cb72e0d..f58ff4898 100644 --- a/packages/app/e2e/workspace-hover-card.spec.ts +++ b/packages/app/e2e/workspace-hover-card.spec.ts @@ -22,7 +22,7 @@ function getServerId(): string { // 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 { await expect(page.getByTestId("workspace-globe-icon")).toBeVisible({ timeout: 30_000 }); } @@ -34,38 +34,38 @@ async function expectHoverCard(page: Page, workspaceName: string): Promise await expect(page.getByTestId("workspace-hover-card")).toBeVisible({ timeout: 10_000 }); } -/** Asserts that a service row with the given name exists in the hover card. */ -async function expectServiceInCard(page: Page, serviceName: string): Promise { +/** Asserts that a script row with the given name exists in the hover card. */ +async function expectScriptInCard(page: Page, scriptName: string): Promise { 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, }); } -/** Asserts the service status dot indicates "running". */ -async function expectServiceRunning(page: Page, serviceName: string): Promise { +/** Asserts the script status dot indicates "running". */ +async function expectScriptRunning(page: Page, scriptName: string): Promise { const card = page.getByTestId("workspace-hover-card"); await expect( - card.getByTestId(`hover-card-service-status-${serviceName}`), + card.getByTestId(`hover-card-script-status-${scriptName}`), ).toHaveAttribute("aria-label", "Running", { timeout: 10_000 }); } -/** Asserts the service lifecycle is stopped. */ -async function expectServiceStopped(page: Page, serviceName: string): Promise { +/** Asserts the script lifecycle is stopped. */ +async function expectScriptStopped(page: Page, scriptName: string): Promise { const card = page.getByTestId("workspace-hover-card"); await expect( - card.getByTestId(`hover-card-service-status-${serviceName}`), + card.getByTestId(`hover-card-script-status-${scriptName}`), ).toHaveAttribute("aria-label", "Stopped", { timeout: 10_000 }); } -/** Asserts the service health label shown in the hover card. */ -async function expectServiceHealth( +/** Asserts the script health label shown in the hover card. */ +async function expectScriptHealth( page: Page, - serviceName: string, + scriptName: string, health: "Healthy" | "Unhealthy" | "Unknown", ): Promise { 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", health, { timeout: 10_000 }, @@ -93,7 +93,7 @@ async function expectHoverCardDismissed(page: Page): Promise { // --------------------------------------------------------------------------- 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, }) => { const client = await connectWorkspaceSetupClient(); @@ -102,7 +102,7 @@ test.describe("Workspace hover card", () => { worktree: { setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"], }, - services: { + scripts: { web: { command: "node -e \"const http = require('http'); const s = http.createServer((q,r) => r.end('ok')); s.listen(process.env.PORT || 3000, () => console.log('listening on ' + s.address().port))\"", @@ -133,7 +133,7 @@ test.describe("Workspace hover card", () => { 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); // Hover the workspace row — hover card should appear @@ -142,15 +142,15 @@ test.describe("Workspace hover card", () => { // Assert the card shows the workspace name await expectWorkspaceNameInCard(page, workspace.name); - // Assert the "web" service entry exists in the card - await expectServiceInCard(page, "web"); + // Assert the "web" script entry exists in the card + await expectScriptInCard(page, "web"); // 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 serviceLink = card.getByRole("link", { name: "web service" }); + const serviceLink = card.getByRole("link", { name: "web script" }); await expect(serviceLink).toBeVisible({ timeout: 10_000 }); // 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 repo = await createTempGitRepo("hovercard-start-", { paseoConfig: { - services: { + scripts: { web: { 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))\"", @@ -187,18 +187,18 @@ test.describe("Workspace hover card", () => { await expectHoverCard(page, workspace.workspace.name); await expectWorkspaceNameInCard(page, workspace.workspace.name); - await expectServiceInCard(page, "web"); - await expectServiceStopped(page, "web"); - await expectServiceHealth(page, "web", "Unknown"); + await expectScriptInCard(page, "web"); + await expectScriptStopped(page, "web"); + await expectScriptHealth(page, "web", "Unknown"); 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 startButton.click(); - await expectServiceRunning(page, "web"); - await expectServiceHealth(page, "web", "Healthy"); - await expect(card.getByRole("link", { name: "web service" })).toBeVisible({ timeout: 10_000 }); + await expectScriptRunning(page, "web"); + await expectScriptHealth(page, "web", "Healthy"); + await expect(card.getByRole("link", { name: "web script" })).toBeVisible({ timeout: 10_000 }); await expect(startButton).not.toBeVisible({ timeout: 10_000 }); } finally { await client.close(); diff --git a/packages/app/e2e/workspace-setup-streaming.spec.ts b/packages/app/e2e/workspace-setup-streaming.spec.ts index 27624da64..72c8fa240 100644 --- a/packages/app/e2e/workspace-setup-streaming.spec.ts +++ b/packages/app/e2e/workspace-setup-streaming.spec.ts @@ -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 repo = await createTempGitRepo("setup-svc-ui-", { paseoConfig: { worktree: { setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"], }, - services: { + scripts: { web: { command: "node -e \"const http = require('http'); const s = http.createServer((q,r) => r.end('ok')); s.listen(process.env.PORT || 3000, () => console.log('listening on ' + s.address().port))\"", @@ -256,12 +256,12 @@ test.describe("Workspace setup streaming", () => { await waitForWorkspaceTabsVisible(page); - // Wait for the service terminal tab to appear in the tabs bar. - // The tab title shows the command, not the service name. + // Wait for the script terminal tab to appear in the tabs bar. + // The tab title shows the command, not the script name. const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]').first(); await expect(terminalTab).toBeVisible({ timeout: 30_000 }); - // Click the service terminal tab + // Click the script terminal tab await terminalTab.click(); // 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 repo = await createTempGitRepo("setup-services-", { + const repo = await createTempGitRepo("setup-scripts-", { paseoConfig: { worktree: { setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"], }, - services: { + scripts: { editor: { command: "npm run dev", }, @@ -301,7 +301,7 @@ test.describe("Workspace setup streaming", () => { const result = await client.createPaseoWorktree({ cwd: repo.path, - worktreeSlug: "workspace-setup-services", + worktreeSlug: "workspace-setup-scripts", }); if (!result.workspace) { throw new Error(result.error ?? "Failed to create workspace"); diff --git a/packages/app/src/components/diff-stat.tsx b/packages/app/src/components/diff-stat.tsx new file mode 100644 index 000000000..969d7d9f8 --- /dev/null +++ b/packages/app/src/components/diff-stat.tsx @@ -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 ( + + +{additions.toLocaleString()} + -{deletions.toLocaleString()} + + ); +} + +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], + }, +})); diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 5ffe4f365..19f504106 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -8,6 +8,7 @@ import { type ReactElement, } from "react"; import { useRouter } from "expo-router"; +import { DiffStat } from "@/components/diff-stat"; import { View, Text, @@ -474,8 +475,7 @@ const DiffFileHeader = memo(function DiffFileHeader({ )} - +{file.additions} - -{file.deletions} + @@ -1738,16 +1738,6 @@ const styles = StyleSheet.create((theme) => ({ fontWeight: theme.fontWeight.normal, 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: { borderTopWidth: theme.borderWidth[1], borderTopColor: theme.colors.border, diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 1f016d1ba..bed7bbed1 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -25,6 +25,7 @@ import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { type GestureType } from "react-native-gesture-handler"; import * as Clipboard from "expo-clipboard"; +import { DiffStat } from "@/components/diff-stat"; import { Archive, ArrowUpRight, @@ -36,7 +37,7 @@ import { FolderPlus, FolderGit2, GitPullRequest, - Globe, + Terminal, Monitor, MoreVertical, Plus, @@ -94,7 +95,8 @@ import { requireWorkspaceExecutionDirectory, resolveWorkspaceExecutionDirectory, } 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"; 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 ( + + + {label} + + ); +} + +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({ bucket, @@ -932,7 +966,7 @@ function WorkspaceRowInner({ }, [interaction.didLongPressRef, onPress]); const isDesktop = !isTouchPlatform; - const showGlobe = isDesktop && workspace.hasRunningServices; + const showScriptsIcon = isDesktop && workspace.hasRunningScripts; return ( @@ -980,9 +1014,9 @@ function WorkspaceRowInner({ - {showGlobe ? ( - - + {showScriptsIcon ? ( + + ) : null} {isCreating ? Creating... : null} @@ -1037,10 +1071,10 @@ function WorkspaceRowInner({ ) : workspace.diffStat ? ( - - +{workspace.diffStat.additions} - -{workspace.diffStat.deletions} - + ) : null} {showShortcutBadge && shortcutNumber !== null ? ( @@ -1051,7 +1085,7 @@ function WorkspaceRowInner({ {prHint ? ( - + ) : null} @@ -2431,22 +2465,6 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, 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: { padding: 2, borderRadius: 4, diff --git a/packages/app/src/components/workspace-hover-card.tsx b/packages/app/src/components/workspace-hover-card.tsx index 83f3cc196..610d7aff7 100644 --- a/packages/app/src/components/workspace-hover-card.tsx +++ b/packages/app/src/components/workspace-hover-card.tsx @@ -10,7 +10,9 @@ import { useMutation } from "@tanstack/react-query"; import { Dimensions, Platform, Text, View } from "react-native"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; 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 { Portal } from "@gorhom/portal"; 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 { useSessionStore } from "@/stores/session-store"; import { openExternalUrl } from "@/utils/open-external-url"; +import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import { PrBadge } from "@/components/sidebar-workspace-list"; interface Rect { @@ -105,8 +108,8 @@ function WorkspaceHoverCardDesktop({ const triggerHoveredRef = useRef(false); const contentHoveredRef = useRef(false); - const hasServices = workspace.services.length > 0; - const hasContent = hasServices || prHint !== null; + const hasScripts = workspace.scripts.length > 0; + const hasContent = hasScripts || prHint !== null; const clearGraceTimer = useCallback(() => { if (graceTimerRef.current) { @@ -192,8 +195,8 @@ function WorkspaceHoverCardDesktop({ ); } -function getServiceHealthColor(input: { - health: SidebarWorkspaceEntry["services"][number]["health"]; +function getScriptHealthColor(input: { + health: SidebarWorkspaceEntry["scripts"][number]["health"]; theme: ReturnType["theme"]; }): string { if (input.health === "healthy") { @@ -205,8 +208,8 @@ function getServiceHealthColor(input: { return input.theme.colors.foregroundMuted; } -function getServiceHealthLabel( - health: SidebarWorkspaceEntry["services"][number]["health"], +function getScriptHealthLabel( + health: SidebarWorkspaceEntry["scripts"][number]["health"], ): "Healthy" | "Unhealthy" | "Unknown" { if (health === "healthy") { return "Healthy"; @@ -218,93 +221,6 @@ function getServiceHealthLabel( } -function getCheckStatusColor(input: { - status: string; - theme: ReturnType["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 ( - - - - ); -} - -function ChecksSummary({ checks }: { checks: Array<{ status: string }> }): ReactElement { - const { theme } = useUnistyles(); - const counts: Record = {}; - 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 ( - - {bucket.count} - - - ); - })} - - ); -} - -const checksSummaryStyles = StyleSheet.create((theme) => ({ - item: { - flexDirection: "row", - alignItems: "center", - gap: 3, - }, - count: { - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.medium, - }, -})); - function WorkspaceHoverCardContent({ workspace, prHint, @@ -325,20 +241,29 @@ function WorkspaceHoverCardContent({ const [triggerRect, setTriggerRect] = useState(null); const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); const [position, setPosition] = useState<{ x: number; y: number } | null>(null); - const startServiceMutation = useMutation({ - mutationFn: async (serviceName: string) => { + const startScriptMutation = useMutation({ + mutationFn: async (scriptName: string) => { if (!client) { 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) { throw new Error(result.error); } 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( - error instanceof Error ? error.message : `Failed to start ${serviceName}`, + error instanceof Error ? error.message : `Failed to start ${scriptName}`, { variant: "error" }, ); }, @@ -392,7 +317,7 @@ function WorkspaceHoverCardContent({ onPointerEnter={onContentEnter} onPointerLeave={onContentLeave} accessibilityRole="menu" - accessibilityLabel="Workspace services" + accessibilityLabel="Workspace scripts" testID="workspace-hover-card" style={[ styles.card, @@ -412,44 +337,47 @@ function WorkspaceHoverCardContent({ {prHint || workspace.diffStat ? ( {workspace.diffStat ? ( - - +{workspace.diffStat.additions} - -{workspace.diffStat.deletions} - + ) : null} {prHint ? : null} ) : null} - {workspace.services.length > 0 ? ( + {workspace.scripts.length > 0 ? ( <> - Services - - {workspace.services.map((service) => { - const isRunning = service.lifecycle === "running"; - const isLinkable = isRunning && !!service.url; + + + Scripts + + + {workspace.scripts.map((script) => { + const isRunning = script.lifecycle === "running"; + const isLinkable = isRunning && !!script.url; return ( [ styles.listRow, hovered && isLinkable && styles.listRowHovered, ]} - onPress={isLinkable ? () => void openExternalUrl(service.url!) : undefined} + onPress={isLinkable ? () => void openExternalUrl(script.url!) : undefined} disabled={!isLinkable} > {({ hovered }) => ( <> - {service.serviceName} + {script.scriptName} - {isRunning && service.url ? ( + {isRunning && script.url ? ( - {service.url.replace(/^https?:\/\//, "")} + {script.url.replace(/^https?:\/\//, "")} ) : ( )} {isRunning ? ( - service.url ? ( - + script.url && hovered ? ( + + + ) : null ) : ( { event.stopPropagation(); - startServiceMutation.mutate(service.serviceName); + startScriptMutation.mutate(script.scriptName); }} + style={styles.startButton} > {({ hovered: actionHovered }) => - startServiceMutation.isPending && - startServiceMutation.variables === service.serviceName ? ( + startScriptMutation.isPending && + startScriptMutation.variables === script.scriptName ? ( ) : ( - + <> + + + Run + + ) } @@ -525,18 +477,53 @@ function WorkspaceHoverCardContent({ ]} onPress={() => void openExternalUrl(`${prHint.url}/checks`)} > - {({ hovered }) => ( - <> - Checks - - - - - - )} + {({ hovered }) => { + const checks = prHint.checks!; + const failed = checks.filter((c) => c.status === "failure").length; + const pending = checks.filter((c) => c.status === "pending").length; + + let badgeColor: string; + let badgeLabel: string; + + 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 ( + <> + + Checks + + + + {badgeLabel} + + + {hovered ? ( + + + + ) : null} + + ); + }} ) : null} @@ -560,7 +547,7 @@ const styles = StyleSheet.create((theme) => ({ borderWidth: 1, borderColor: theme.colors.borderAccent, borderRadius: theme.borderRadius.lg, - paddingVertical: theme.spacing[2], + paddingTop: theme.spacing[2], shadowColor: "#000", shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.2, @@ -589,33 +576,23 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: theme.spacing[3], 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: { height: 1, backgroundColor: theme.colors.border, }, - sectionLabel: { - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.medium, - color: theme.colors.foregroundMuted, + sectionLabelRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1.5], paddingHorizontal: theme.spacing[3], paddingTop: theme.spacing[2], paddingBottom: theme.spacing[1], }, + sectionLabel: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + color: theme.colors.foregroundMuted, + }, sectionList: { paddingBottom: theme.spacing[1], }, @@ -630,13 +607,26 @@ const styles = StyleSheet.create((theme) => ({ listRowHovered: { 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: { fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, + lineHeight: 18, flexShrink: 0, }, listRowSecondary: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, + fontSize: theme.fontSize.sm, + lineHeight: 18, flex: 1, minWidth: 0, textAlign: "right", @@ -650,25 +640,44 @@ const styles = StyleSheet.create((theme) => ({ height: 8, borderRadius: 4, flexShrink: 0, + marginLeft: 2, }, checksSummaryRow: { flexDirection: "row", alignItems: "center", - gap: theme.spacing[2], + gap: theme.spacing[1.5], paddingHorizontal: theme.spacing[3], paddingVertical: 6, minHeight: 28, }, checksSummaryLabel: { fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.medium, + fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, }, checksSummaryCounts: { flexDirection: "row", alignItems: "center", - gap: theme.spacing[2], + gap: 4, flex: 1, 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, + }, })); diff --git a/packages/app/src/contexts/session-context.service-status.test.ts b/packages/app/src/contexts/session-context.service-status.test.ts index 31219679f..ddac8baa5 100644 --- a/packages/app/src/contexts/session-context.service-status.test.ts +++ b/packages/app/src/contexts/session-context.service-status.test.ts @@ -1,11 +1,11 @@ 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 { patchWorkspaceServices } from "./session-workspace-services"; +import { patchWorkspaceScripts } from "./session-workspace-scripts"; function workspace(input: { id: string; - services?: WorkspaceDescriptor["services"]; + scripts?: WorkspaceDescriptor["scripts"]; }): WorkspaceDescriptor { return { id: input.id, @@ -19,12 +19,12 @@ function workspace(input: { status: "running", activityAt: null, diffStat: null, - services: input.services ?? [], + scripts: input.scripts ?? [], }; } -const runningService: WorkspaceServicePayload = { - serviceName: "web", +const runningScript: WorkspaceScriptPayload = { + scriptName: "web", hostname: "main.web.localhost", port: 3000, url: "http://main.web.localhost:6767", @@ -32,21 +32,21 @@ const runningService: WorkspaceServicePayload = { health: "healthy", }; -describe("patchWorkspaceServices", () => { - it("patches only the matching workspace services", () => { - const other = workspace({ id: "/repo/other", services: [] }); +describe("patchWorkspaceScripts", () => { + it("patches only the matching workspace scripts", () => { + const other = workspace({ id: "/repo/other", scripts: [] }); const current = new Map([ - ["/repo/main", workspace({ id: "/repo/main", services: [] })], + ["/repo/main", workspace({ id: "/repo/main", scripts: [] })], [other.id, other], ]); - const next = patchWorkspaceServices(current, { + const next = patchWorkspaceScripts(current, { workspaceId: "/repo/main", - services: [runningService], + scripts: [runningScript], }); 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); }); @@ -56,7 +56,7 @@ describe("patchWorkspaceServices", () => { "42", workspace({ id: "42", - services: [], + scripts: [], }), ], ]); @@ -66,26 +66,26 @@ describe("patchWorkspaceServices", () => { workspaceDirectory: "C:\\repo\\main\\", }); - const next = patchWorkspaceServices(current, { + const next = patchWorkspaceScripts(current, { workspaceId: "C:/repo/main", - services: [runningService], + scripts: [runningScript], }); 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", () => { const current = new Map([ - ["/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", - services: [runningService], + scripts: [runningScript], }); expect(next).toBe(current); - expect(next.get("/repo/main")?.services).toEqual([]); + expect(next.get("/repo/main")?.scripts).toEqual([]); }); }); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 5694be493..174704e77 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -53,7 +53,7 @@ import { resolveProjectPlacement } from "@/utils/project-placement"; import { buildDraftStoreKey } from "@/stores/draft-keys"; import type { AttachmentMetadata } from "@/attachments/types"; import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking"; -import { patchWorkspaceServices } from "@/contexts/session-workspace-services"; +import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts"; // Re-export types from session-store and draft-store for backward compatibility export type { DraftInput } from "@/stores/draft-store"; @@ -1168,9 +1168,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(message.payload.workspace)]); }); - const unsubServiceStatusUpdate = client.on("service_status_update", (message) => { - if (message.type !== "service_status_update") return; - setWorkspaces(serverId, (prev) => patchWorkspaceServices(prev, message.payload)); + const unsubScriptStatusUpdate = client.on("script_status_update", (message) => { + if (message.type !== "script_status_update") return; + setWorkspaces(serverId, (prev) => patchWorkspaceScripts(prev, message.payload)); }); const unsubWorkspaceSetupProgress = client.on("workspace_setup_progress", (message) => { @@ -1538,7 +1538,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider unsubAgentStream(); unsubAgentTimeline(); unsubWorkspaceUpdate(); - unsubServiceStatusUpdate(); + unsubScriptStatusUpdate(); unsubWorkspaceSetupProgress(); unsubWorkspaceSetupStatusResponse(); unsubStatus(); diff --git a/packages/app/src/contexts/session-workspace-services.ts b/packages/app/src/contexts/session-workspace-scripts.ts similarity index 73% rename from packages/app/src/contexts/session-workspace-services.ts rename to packages/app/src/contexts/session-workspace-scripts.ts index 9536a81f8..c2ccf9b2a 100644 --- a/packages/app/src/contexts/session-workspace-services.ts +++ b/packages/app/src/contexts/session-workspace-scripts.ts @@ -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 { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution"; -export function patchWorkspaceServices( +export function patchWorkspaceScripts( workspaces: Map, - update: ServiceStatusUpdateMessage["payload"], + update: ScriptStatusUpdateMessage["payload"], ): Map { const workspaceKey = resolveWorkspaceMapKeyByIdentity({ workspaces, @@ -22,7 +22,7 @@ export function patchWorkspaceServices( const next = new Map(workspaces); next.set(workspaceKey, { ...existing, - services: update.services.map((s) => ({ ...s })), + scripts: update.scripts.map((s) => ({ ...s })), }); return next; } diff --git a/packages/app/src/hooks/use-open-project.test.ts b/packages/app/src/hooks/use-open-project.test.ts index 3bf87a915..3ddd8315b 100644 --- a/packages/app/src/hooks/use-open-project.test.ts +++ b/packages/app/src/hooks/use-open-project.test.ts @@ -81,7 +81,7 @@ describe("openProjectDirectly", () => { status: "done" as const, activityAt: null, diffStat: null, - services: [], + scripts: [], }, })), }, diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts index d9941aae4..26a33eed2 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { WorkspaceServicePayload } from "@server/shared/messages"; +import type { WorkspaceScriptPayload } from "@server/shared/messages"; import { appendMissingOrderKeys, applyStoredOrdering, @@ -25,7 +25,7 @@ function workspace( | "workspaceDirectory" | "projectKind" | "workspaceKind" - | "services" + | "scripts" > >, ): WorkspaceDescriptor { @@ -41,12 +41,12 @@ function workspace( status: input.status, activityAt: input.activityAt, diffStat: null, - services: input.services ?? [], + scripts: input.scripts ?? [], }; } -const runningService: WorkspaceServicePayload = { - serviceName: "web", +const runningScript: WorkspaceScriptPayload = { + scriptName: "web", hostname: "main.web.localhost", port: 3000, url: "http://main.web.localhost:6767", @@ -54,8 +54,8 @@ const runningService: WorkspaceServicePayload = { health: "healthy", }; -const stoppedService: WorkspaceServicePayload = { - serviceName: "api", +const stoppedScript: WorkspaceScriptPayload = { + scriptName: "api", hostname: "main.api.localhost", port: 3001, url: "http://main.api.localhost:6767", @@ -143,7 +143,7 @@ describe("buildSidebarProjectsFromWorkspaces", () => { 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({ serverId: "srv", workspaces: [ @@ -153,15 +153,15 @@ describe("buildSidebarProjectsFromWorkspaces", () => { name: "main", status: "running", activityAt: new Date("2026-01-01T00:00:00.000Z"), - services: [runningService, stoppedService], + scripts: [runningScript, stoppedScript], }), ], projectOrder: [], workspaceOrderByScope: {}, }); - expect(projects[0]?.workspaces[0]?.services).toEqual([runningService, stoppedService]); - expect(projects[0]?.workspaces[0]?.hasRunningServices).toBe(true); + expect(projects[0]?.workspaces[0]?.scripts).toEqual([runningScript, stoppedScript]); + expect(projects[0]?.workspaces[0]?.hasRunningScripts).toBe(true); }); it("preserves stored project order even when activity changes", () => { diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index d3f13a9b4..8711661f5 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -28,8 +28,8 @@ export interface SidebarWorkspaceEntry { activityAt: Date | null; statusBucket: SidebarStateBucket; diffStat: { additions: number; deletions: number } | null; - services: WorkspaceDescriptor["services"]; - hasRunningServices: boolean; + scripts: WorkspaceDescriptor["scripts"]; + hasRunningScripts: boolean; } export interface SidebarProjectEntry { @@ -148,8 +148,8 @@ export function buildSidebarProjectsFromWorkspaces(input: { activityAt: workspace.activityAt, statusBucket: workspace.status, diffStat: workspace.diffStat, - services: workspace.services, - hasRunningServices: workspace.services.some((service) => service.lifecycle === "running"), + scripts: workspace.scripts, + hasRunningScripts: workspace.scripts.some((script) => script.lifecycle === "running"), }; project.workspaces.push(row); @@ -273,11 +273,11 @@ function toWorkspaceDescriptor(payload: { name: string; status: WorkspaceDescriptor["status"]; activityAt: string | null; - services?: WorkspaceDescriptorPayload["services"]; + scripts?: WorkspaceDescriptorPayload["scripts"]; }): WorkspaceDescriptor { return normalizeWorkspaceDescriptor({ ...payload, - services: payload.services ?? [], + scripts: payload.scripts ?? [], }); } diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index dd96be66d..161fffb9a 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -12,6 +12,7 @@ import { } from "react-native"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import * as Clipboard from "expo-clipboard"; +import { DiffStat } from "@/components/diff-stat"; import { CopyX, ArrowLeftToLine, @@ -50,6 +51,7 @@ import { SplitContainer } from "@/components/split-container"; import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon"; import { WorkspaceGitActions } from "@/screens/workspace/workspace-git-actions"; 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 { useToast } from "@/contexts/toast-context"; import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture"; @@ -2105,6 +2107,13 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) cwd={normalizedWorkspaceId} /> ) : null} + {!isMobile && workspaceDescriptor && workspaceDescriptor.scripts.length > 0 ? ( + + ) : null} {!isMobile && isGitCheckout ? ( <> {workspaceDirectory ? ( @@ -2142,14 +2151,10 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) color={iconColor} /> {workspaceDescriptor?.diffStat ? ( - - - +{workspaceDescriptor.diffStat.additions} - - - -{workspaceDescriptor.diffStat.deletions} - - + ) : null} ); @@ -2417,22 +2422,6 @@ const styles = StyleSheet.create((theme) => ({ sourceControlButtonHovered: { 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: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/screens/workspace/workspace-scripts-button.tsx b/packages/app/src/screens/workspace/workspace-scripts-button.tsx new file mode 100644 index 000000000..ef524e159 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-scripts-button.tsx @@ -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["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 ( + + + + [ + styles.splitButtonPrimary, + (hovered || pressed || open) && styles.splitButtonPrimaryHovered, + ]} + accessibilityRole="button" + accessibilityLabel="Workspace scripts" + > + + + Scripts + {hasAnyRunning ? ( + + ) : null} + + + + + + {scripts.map((script) => { + const isRunning = script.lifecycle === "running"; + const isLinkable = isRunning && !!script.url; + + return ( + [ + styles.scriptRow, + hovered && isLinkable && styles.scriptRowHovered, + ]} + onPress={isLinkable ? () => void openExternalUrl(script.url!) : undefined} + disabled={!isLinkable} + > + {({ hovered }) => ( + <> + + + {script.scriptName} + + {isRunning && script.url ? ( + + {script.url.replace(/^https?:\/\//, "")} + + ) : ( + + )} + {isRunning ? ( + script.url && hovered ? ( + + + + ) : null + ) : ( + { + event.stopPropagation(); + startScriptMutation.mutate(script.scriptName); + }} + style={styles.startButton} + > + {({ hovered: actionHovered }) => + startScriptMutation.isPending && + startScriptMutation.variables === script.scriptName ? ( + + ) : ( + <> + + + Run + + + ) + } + + )} + + )} + + ); + })} + + + + + + ); +} + +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, + }, +})); diff --git a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts index e17e9a940..438883a02 100644 --- a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts +++ b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts @@ -20,7 +20,7 @@ describe("workspace source of truth consumption", () => { status: "running", activityAt: new Date("2026-03-01T00:00:00.000Z"), diffStat: null, - services: [], + scripts: [], }; const header = resolveWorkspaceHeader({ workspace }); diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index ae5330ca0..50819bbee 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -25,7 +25,7 @@ function createWorkspace( status: input.status ?? "done", activityAt: input.activityAt ?? null, diffStat: input.diffStat ?? null, - services: input.services ?? [], + scripts: input.scripts ?? [], }; } @@ -34,10 +34,10 @@ afterEach(() => { }); describe("normalizeWorkspaceDescriptor", () => { - it("normalizes workspace services and invalid activity timestamps", () => { - const services = [ + it("normalizes workspace scripts and invalid activity timestamps", () => { + const scripts = [ { - serviceName: "web", + scriptName: "web", hostname: "main.web.localhost", port: 3000, url: "http://main.web.localhost:6767", @@ -57,13 +57,13 @@ describe("normalizeWorkspaceDescriptor", () => { status: "running", activityAt: "not-a-date", diffStat: null, - services, + scripts, }); expect(workspace.activityAt).toBeNull(); - expect(workspace.services).toEqual([ + expect(workspace.scripts).toEqual([ { - serviceName: "web", + scriptName: "web", hostname: "main.web.localhost", port: 3000, url: "http://main.web.localhost:6767", @@ -71,10 +71,10 @@ describe("normalizeWorkspaceDescriptor", () => { 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 = { id: "1", projectId: "1", @@ -87,30 +87,30 @@ describe("normalizeWorkspaceDescriptor", () => { status: "done", activityAt: null, diffStat: null, - services: [], + scripts: [], } as WorkspaceDescriptorPayload; const workspace = normalizeWorkspaceDescriptor(payload); - expect(workspace.services).toEqual([]); + expect(workspace.scripts).toEqual([]); }); }); describe("mergeWorkspaces", () => { - it("preserves services on merged workspace entries", () => { + it("preserves scripts on merged workspace entries", () => { const store = useSessionStore.getState(); store.initializeSession("test-server", null as unknown as DaemonClient); store.setWorkspaces( "test-server", - new Map([["/repo/main", createWorkspace({ id: "/repo/main", services: [] })]]), + new Map([["/repo/main", createWorkspace({ id: "/repo/main", scripts: [] })]]), ); store.mergeWorkspaces("test-server", [ createWorkspace({ id: "/repo/main", - services: [ + scripts: [ { - serviceName: "web", + scriptName: "web", hostname: "main.web.localhost", port: 3000, 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", port: 3000, url: "http://main.web.localhost:6767", diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 7fd67ae1c..9f21e2c3e 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -124,7 +124,7 @@ export interface WorkspaceDescriptor { status: WorkspaceDescriptorPayload["status"]; activityAt: Date | null; diffStat: { additions: number; deletions: number } | null; - services: WorkspaceDescriptorPayload["services"]; + scripts: WorkspaceDescriptorPayload["scripts"]; } export function normalizeWorkspaceDescriptor( @@ -143,7 +143,7 @@ export function normalizeWorkspaceDescriptor( status: payload.status, activityAt: activityAt && !Number.isNaN(activityAt.getTime()) ? activityAt : null, diffStat: payload.diffStat ?? null, - services: (payload.services ?? []).map((s) => ({ ...s })), + scripts: (payload.scripts ?? []).map((s) => ({ ...s })), }; } @@ -964,7 +964,10 @@ export const useSessionStore = create()( if (existing === workspace) { continue; } - next.set(workspace.id, workspace); + next.set( + workspace.id, + mergeWorkspaceSnapshotWithExisting({ incoming: workspace, existing }), + ); changed = true; } if (!changed) { diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index 1c72d8b59..4c8f8ce0a 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -258,6 +258,7 @@ const commonTheme = { spacing: { 0: 0, 1: 4, + 1.5: 6, 2: 8, 3: 12, 4: 16, diff --git a/packages/app/src/utils/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts index e52f78e84..d13da244a 100644 --- a/packages/app/src/utils/sidebar-project-row-model.test.ts +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -19,8 +19,8 @@ function workspace(overrides: Partial = {}): SidebarWorks activityAt: null, statusBucket: "done", diffStat: null, - services: [], - hasRunningServices: false, + scripts: [], + hasRunningScripts: false, ...overrides, }; } diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index 928c898a3..a00c77f84 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -17,8 +17,8 @@ function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry { activityAt: null, statusBucket: "done", diffStat: null, - services: [], - hasRunningServices: false, + scripts: [], + hasRunningScripts: false, }; } diff --git a/packages/app/src/utils/workspace-archive-navigation.test.ts b/packages/app/src/utils/workspace-archive-navigation.test.ts index a307c5376..e5252fc73 100644 --- a/packages/app/src/utils/workspace-archive-navigation.test.ts +++ b/packages/app/src/utils/workspace-archive-navigation.test.ts @@ -20,7 +20,7 @@ function workspace( status: input.status ?? "running", activityAt: input.activityAt ?? null, diffStat: input.diffStat ?? null, - services: input.services ?? [], + scripts: input.scripts ?? [], }; } diff --git a/packages/app/src/utils/workspace-execution.test.ts b/packages/app/src/utils/workspace-execution.test.ts index 019ea4a78..6b7c4932d 100644 --- a/packages/app/src/utils/workspace-execution.test.ts +++ b/packages/app/src/utils/workspace-execution.test.ts @@ -23,7 +23,7 @@ function createWorkspace( status: input.status ?? "running", activityAt: input.activityAt ?? null, diffStat: input.diffStat ?? null, - services: input.services ?? [], + scripts: input.scripts ?? [], }; } diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 768b517dc..420fb77ad 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -1331,19 +1331,19 @@ export class DaemonClient { }); } - async startWorkspaceService( + async startWorkspaceScript( workspaceId: string, - serviceName: string, + scriptName: string, requestId?: string, - ): Promise["payload"]> { + ): Promise["payload"]> { return this.sendCorrelatedSessionRequest({ requestId, message: { - type: "start_workspace_service_request", + type: "start_workspace_script_request", workspaceId, - serviceName, + scriptName, }, - responseType: "start_workspace_service_response", + responseType: "start_workspace_script_response", timeout: 10000, }); } diff --git a/packages/server/src/server/agent/agent-management-mcp.ts b/packages/server/src/server/agent/agent-management-mcp.ts index edfdc794e..8922c45e2 100644 --- a/packages/server/src/server/agent/agent-management-mcp.ts +++ b/packages/server/src/server/agent/agent-management-mcp.ts @@ -48,13 +48,13 @@ import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js"; import { expandUserPath } from "../path-utils.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js"; -import type { ServiceRouteStore } from "../service-proxy.js"; +import type { ScriptRouteStore } from "../script-proxy.js"; export interface AgentManagementMcpOptions { agentManager: AgentManager; agentStorage: AgentSnapshotStore; terminalManager?: TerminalManager | null; - serviceRouteStore?: ServiceRouteStore; + scriptRouteStore?: ScriptRouteStore; getDaemonTcpPort?: () => number | null; paseoHome?: string; logger: Logger; @@ -350,7 +350,7 @@ export async function createAgentManagementMcpServer( agentId: snapshot.id, item, }), - serviceRouteStore: options.serviceRouteStore, + scriptRouteStore: options.scriptRouteStore, daemonPort: options.getDaemonTcpPort?.() ?? null, logger: childLogger, }); diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 7f7911ffb..9223d509e 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -28,13 +28,13 @@ import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js"; import { expandUserPath, resolvePathFromBase } from "../path-utils.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js"; -import type { ServiceRouteStore } from "../service-proxy.js"; +import type { ScriptRouteStore } from "../script-proxy.js"; export interface AgentMcpServerOptions { agentManager: AgentManager; agentStorage: AgentSnapshotStore; terminalManager?: TerminalManager | null; - serviceRouteStore?: ServiceRouteStore; + scriptRouteStore?: ScriptRouteStore; getDaemonTcpPort?: () => number | null; paseoHome?: string; /** @@ -519,7 +519,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom agentId: snapshot.id, item, }), - serviceRouteStore: options.serviceRouteStore, + scriptRouteStore: options.scriptRouteStore, daemonPort: options.getDaemonTcpPort?.() ?? null, logger: childLogger, }); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 7ce0ca628..d411050e8 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -9,7 +9,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import type { Logger } from "pino"; -import { createBranchChangeRouteHandler } from "./service-route-branch-handler.js"; +import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js"; export type ListenTarget = | { 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 { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js"; import { - ServiceRouteStore, - createServiceProxyMiddleware, - createServiceProxyUpgradeHandler, -} from "./service-proxy.js"; -import { ServiceHealthMonitor } from "./service-health-monitor.js"; -import { createServiceStatusEmitter } from "./service-status-projection.js"; + ScriptRouteStore, + createScriptProxyMiddleware, + createScriptProxyUpgradeHandler, +} from "./script-proxy.js"; +import { ScriptHealthMonitor } from "./script-health-monitor.js"; +import { createScriptStatusEmitter } from "./script-status-projection.js"; import { createVoiceMcpSocketBridgeManager, type VoiceMcpSocketBridgeManager, @@ -201,7 +201,7 @@ export interface PaseoDaemon { agentManager: AgentManager; agentStorage: AgentSnapshotStore; terminalManager: TerminalManager; - serviceRouteStore: ServiceRouteStore; + scriptRouteStore: ScriptRouteStore; start(): Promise; stop(): Promise; getListenTarget(): ListenTarget | null; @@ -232,25 +232,25 @@ export async function createPaseoDaemon( const app = express(); let boundListenTarget: ListenTarget | null = null; - const serviceRouteStore = new ServiceRouteStore(); + const scriptRouteStore = new ScriptRouteStore(); let wsServer: VoiceAssistantWebSocketServer | null = null; - const serviceHealthMonitor = new ServiceHealthMonitor({ - routeStore: serviceRouteStore, - onChange: createServiceStatusEmitter({ + const scriptHealthMonitor = new ScriptHealthMonitor({ + routeStore: scriptRouteStore, + onChange: createScriptStatusEmitter({ sessions: () => wsServer?.listActiveSessions().map((session) => ({ emit: (message) => session.emitServerMessage(message), })) ?? [], - routeStore: serviceRouteStore, + routeStore: scriptRouteStore, daemonPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), }), }); const handleBranchChange = createBranchChangeRouteHandler({ - routeStore: serviceRouteStore, - emitServiceStatusUpdate: (workspaceId, services) => { + routeStore: scriptRouteStore, + emitScriptStatusUpdate: (workspaceId, scripts) => { const message = { - type: "service_status_update" as const, - payload: { workspaceId, services }, + type: "script_status_update" as const, + payload: { workspaceId, scripts }, }; const activeSessions = wsServer?.listActiveSessions() ?? []; for (const session of activeSessions) { @@ -273,12 +273,12 @@ export async function createPaseoDaemon( }); } - // Service proxy — intercepts requests for registered *.localhost hostnames - // and forwards them to the corresponding local service port. Placed after + // Script proxy — intercepts requests for registered *.localhost hostnames + // and forwards them to the corresponding local script port. Placed after // the host allowlist (*.localhost is already allowed) but before CORS and // the rest of the routes so proxied requests skip unnecessary middleware. app.use( - createServiceProxyMiddleware({ routeStore: serviceRouteStore, logger }), + createScriptProxyMiddleware({ routeStore: scriptRouteStore, logger }), ); // CORS - allow same-origin + configured origins @@ -384,15 +384,15 @@ export async function createPaseoDaemon( database = await openPaseoDatabase(path.join(config.paseoHome, "db")); logger.info({ elapsed: elapsed() }, "Paseo database opened"); - // Service proxy WebSocket upgrade handler — must be registered before the + // Script proxy WebSocket upgrade handler — must be registered before the // VoiceAssistantWebSocketServer attaches its own "upgrade" listener so that - // service-bound upgrades are forwarded first. The handler is a no-op for - // requests that don't match a registered service route. - const serviceProxyUpgradeHandler = createServiceProxyUpgradeHandler({ - routeStore: serviceRouteStore, + // script-bound upgrades are forwarded first. The handler is a no-op for + // requests that don't match a registered script route. + const scriptProxyUpgradeHandler = createScriptProxyUpgradeHandler({ + routeStore: scriptRouteStore, logger, }); - httpServer.on("upgrade", serviceProxyUpgradeHandler); + httpServer.on("upgrade", scriptProxyUpgradeHandler); const agentStorage = new DbAgentSnapshotStore(database.db); const chatService = new FileBackedChatService({ @@ -485,7 +485,7 @@ export async function createPaseoDaemon( agentManager, agentStorage, terminalManager, - serviceRouteStore, + scriptRouteStore, getDaemonTcpPort: () => boundListenTarget?.type === "tcp" ? boundListenTarget.port : null, paseoHome: config.paseoHome, @@ -514,7 +514,7 @@ export async function createPaseoDaemon( agentManager, agentStorage, terminalManager, - serviceRouteStore, + scriptRouteStore, getDaemonTcpPort: () => boundListenTarget?.type === "tcp" ? boundListenTarget.port : null, paseoHome: config.paseoHome, @@ -637,7 +637,7 @@ export async function createPaseoDaemon( agentManager, agentStorage, terminalManager, - serviceRouteStore, + scriptRouteStore, getDaemonTcpPort: () => boundListenTarget?.type === "tcp" ? boundListenTarget.port : null, paseoHome: config.paseoHome, @@ -701,10 +701,10 @@ export async function createPaseoDaemon( loopService, scheduleService, checkoutDiffManager, - serviceRouteStore, + scriptRouteStore, handleBranchChange, () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), - (hostname) => serviceHealthMonitor.getHealthForHostname(hostname), + (hostname) => scriptHealthMonitor.getHealthForHostname(hostname), ); 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 // model loading doesn't block the server from accepting connections. speechService.start(); - serviceHealthMonitor.start(); + scriptHealthMonitor.start(); }; const stop = async () => { reconciliationService.stop(); - serviceHealthMonitor.stop(); + scriptHealthMonitor.stop(); await closeAllAgents(logger, agentManager); await agentManager.flush().catch(() => undefined); await shutdownProviders(logger, { @@ -831,7 +831,7 @@ export async function createPaseoDaemon( agentManager, agentStorage, terminalManager, - serviceRouteStore, + scriptRouteStore, start, stop, getListenTarget: () => boundListenTarget, diff --git a/packages/server/src/server/service-health-monitor.test.ts b/packages/server/src/server/script-health-monitor.test.ts similarity index 79% rename from packages/server/src/server/service-health-monitor.test.ts rename to packages/server/src/server/script-health-monitor.test.ts index 348e3c02b..1212b11c1 100644 --- a/packages/server/src/server/service-health-monitor.test.ts +++ b/packages/server/src/server/script-health-monitor.test.ts @@ -1,11 +1,11 @@ import net from "node:net"; import { scheduler } from "node:timers/promises"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { findFreePort, ServiceRouteStore } from "./service-proxy.js"; +import { findFreePort, ScriptRouteStore } from "./script-proxy.js"; import { - ServiceHealthMonitor, - type ServiceHealthEntry, -} from "./service-health-monitor.js"; + ScriptHealthMonitor, + type ScriptHealthEntry, +} from "./script-health-monitor.js"; type TcpServerHandle = { port: number; @@ -56,7 +56,7 @@ async function advancePoll(ms: number): Promise { } } -describe("ServiceHealthMonitor", () => { +describe("ScriptHealthMonitor", () => { const servers = new Set(); afterEach(async () => { @@ -74,16 +74,16 @@ describe("ServiceHealthMonitor", () => { const healthy = await startTcpServer(); servers.add(healthy.server); - const routeStore = new ServiceRouteStore(); + const routeStore = new ScriptRouteStore(); routeStore.registerRoute({ hostname: "api.localhost", port: healthy.port, workspaceId: "workspace-a", - serviceName: "api", + scriptName: "api", }); - const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>(); - const monitor = new ServiceHealthMonitor({ + const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>(); + const monitor = new ScriptHealthMonitor({ routeStore, onChange, pollIntervalMs: 1_000, @@ -98,7 +98,7 @@ describe("ServiceHealthMonitor", () => { expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith("workspace-a", [ { - serviceName: "api", + scriptName: "api", hostname: "api.localhost", port: healthy.port, health: "healthy", @@ -110,16 +110,16 @@ describe("ServiceHealthMonitor", () => { vi.useFakeTimers(); const deadPort = await findFreePort(); - const routeStore = new ServiceRouteStore(); + const routeStore = new ScriptRouteStore(); routeStore.registerRoute({ hostname: "api.localhost", port: deadPort, workspaceId: "workspace-a", - serviceName: "api", + scriptName: "api", }); - const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>(); - const monitor = new ServiceHealthMonitor({ + const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>(); + const monitor = new ScriptHealthMonitor({ routeStore, onChange, pollIntervalMs: 1_000, @@ -138,7 +138,7 @@ describe("ServiceHealthMonitor", () => { expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith("workspace-a", [ { - serviceName: "api", + scriptName: "api", hostname: "api.localhost", port: deadPort, health: "unhealthy", @@ -152,16 +152,16 @@ describe("ServiceHealthMonitor", () => { const healthy = await startTcpServer(); servers.add(healthy.server); - const routeStore = new ServiceRouteStore(); + const routeStore = new ScriptRouteStore(); routeStore.registerRoute({ hostname: "api.localhost", port: healthy.port, workspaceId: "workspace-a", - serviceName: "api", + scriptName: "api", }); - const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>(); - const monitor = new ServiceHealthMonitor({ + const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>(); + const monitor = new ScriptHealthMonitor({ routeStore, onChange, pollIntervalMs: 1_000, @@ -182,16 +182,16 @@ describe("ServiceHealthMonitor", () => { const healthy = await startTcpServer(); servers.add(healthy.server); - const routeStore = new ServiceRouteStore(); + const routeStore = new ScriptRouteStore(); routeStore.registerRoute({ hostname: "api.localhost", port: healthy.port, workspaceId: "workspace-a", - serviceName: "api", + scriptName: "api", }); - const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>(); - const monitor = new ServiceHealthMonitor({ + const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>(); + const monitor = new ScriptHealthMonitor({ routeStore, onChange, pollIntervalMs: 1_000, @@ -209,7 +209,7 @@ describe("ServiceHealthMonitor", () => { expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith("workspace-a", [ { - serviceName: "api", + scriptName: "api", hostname: "api.localhost", port: healthy.port, health: "healthy", @@ -223,16 +223,16 @@ describe("ServiceHealthMonitor", () => { const healthy = await startTcpServer(); servers.add(healthy.server); - const routeStore = new ServiceRouteStore(); + const routeStore = new ScriptRouteStore(); routeStore.registerRoute({ hostname: "api.localhost", port: healthy.port, workspaceId: "workspace-a", - serviceName: "api", + scriptName: "api", }); - const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>(); - const monitor = new ServiceHealthMonitor({ + const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>(); + const monitor = new ScriptHealthMonitor({ routeStore, onChange, pollIntervalMs: 1_000, @@ -257,7 +257,7 @@ describe("ServiceHealthMonitor", () => { expect(onChange).toHaveBeenCalledTimes(2); expect(onChange).toHaveBeenLastCalledWith("workspace-a", [ { - serviceName: "api", + scriptName: "api", hostname: "api.localhost", port: healthy.port, health: "unhealthy", @@ -271,16 +271,16 @@ describe("ServiceHealthMonitor", () => { const healthy = await startTcpServer(); servers.add(healthy.server); - const routeStore = new ServiceRouteStore(); + const routeStore = new ScriptRouteStore(); routeStore.registerRoute({ hostname: "api.localhost", port: healthy.port, workspaceId: "workspace-a", - serviceName: "api", + scriptName: "api", }); - const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>(); - const monitor = new ServiceHealthMonitor({ + const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>(); + const monitor = new ScriptHealthMonitor({ routeStore, onChange, pollIntervalMs: 1_000, @@ -311,22 +311,22 @@ describe("ServiceHealthMonitor", () => { servers.add(api.server); servers.add(web.server); - const routeStore = new ServiceRouteStore(); + const routeStore = new ScriptRouteStore(); routeStore.registerRoute({ hostname: "api.localhost", port: api.port, workspaceId: "workspace-a", - serviceName: "api", + scriptName: "api", }); routeStore.registerRoute({ hostname: "web.localhost", port: web.port, workspaceId: "workspace-a", - serviceName: "web", + scriptName: "web", }); - const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>(); - const monitor = new ServiceHealthMonitor({ + const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>(); + const monitor = new ScriptHealthMonitor({ routeStore, onChange, pollIntervalMs: 1_000, @@ -341,13 +341,13 @@ describe("ServiceHealthMonitor", () => { expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith("workspace-a", [ { - serviceName: "api", + scriptName: "api", hostname: "api.localhost", port: api.port, health: "healthy", }, { - serviceName: "web", + scriptName: "web", hostname: "web.localhost", port: web.port, health: "healthy", @@ -361,16 +361,16 @@ describe("ServiceHealthMonitor", () => { const healthy = await startTcpServer(); servers.add(healthy.server); - const routeStore = new ServiceRouteStore(); + const routeStore = new ScriptRouteStore(); routeStore.registerRoute({ hostname: "api.localhost", port: healthy.port, workspaceId: "workspace-a", - serviceName: "api", + scriptName: "api", }); - const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>(); - const monitor = new ServiceHealthMonitor({ + const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>(); + const monitor = new ScriptHealthMonitor({ routeStore, onChange, pollIntervalMs: 1_000, @@ -396,22 +396,22 @@ describe("ServiceHealthMonitor", () => { servers.add(api.server); servers.add(web.server); - const routeStore = new ServiceRouteStore(); + const routeStore = new ScriptRouteStore(); routeStore.registerRoute({ hostname: "api.localhost", port: api.port, workspaceId: "workspace-a", - serviceName: "api", + scriptName: "api", }); routeStore.registerRoute({ hostname: "web.localhost", port: web.port, workspaceId: "workspace-a", - serviceName: "web", + scriptName: "web", }); - const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>(); - const monitor = new ServiceHealthMonitor({ + const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>(); + const monitor = new ScriptHealthMonitor({ routeStore, onChange, pollIntervalMs: 1_000, @@ -439,13 +439,13 @@ describe("ServiceHealthMonitor", () => { expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith("workspace-a", [ { - serviceName: "api", + scriptName: "api", hostname: "api.localhost", port: api.port, health: "unhealthy", }, { - serviceName: "web", + scriptName: "web", hostname: "web.localhost", port: web.port, health: "unhealthy", diff --git a/packages/server/src/server/service-health-monitor.ts b/packages/server/src/server/script-health-monitor.ts similarity index 82% rename from packages/server/src/server/service-health-monitor.ts rename to packages/server/src/server/script-health-monitor.ts index fd6608ad5..e18ddfb2f 100644 --- a/packages/server/src/server/service-health-monitor.ts +++ b/packages/server/src/server/script-health-monitor.ts @@ -1,24 +1,24 @@ import net from "node:net"; -import type { ServiceRouteEntry, ServiceRouteStore } from "./service-proxy.js"; +import type { ScriptRouteEntry, ScriptRouteStore } from "./script-proxy.js"; -export interface ServiceHealthEntry { - serviceName: string; +export interface ScriptHealthEntry { + scriptName: string; hostname: string; port: number; health: "healthy" | "unhealthy"; } type RouteHealthState = { - health: ServiceHealthEntry["health"] | null; + health: ScriptHealthEntry["health"] | null; consecutiveFailures: number; registeredAt: number; }; -export class ServiceHealthMonitor { - private readonly routeStore: ServiceRouteStore; +export class ScriptHealthMonitor { + private readonly routeStore: ScriptRouteStore; private readonly onChange: ( workspaceId: string, - services: ServiceHealthEntry[], + scripts: ScriptHealthEntry[], ) => void; private readonly pollIntervalMs: number; private readonly probeTimeoutMs: number; @@ -38,8 +38,8 @@ export class ServiceHealthMonitor { graceMs = 5_000, failuresBeforeStopped = 2, }: { - routeStore: ServiceRouteStore; - onChange: (workspaceId: string, services: ServiceHealthEntry[]) => void; + routeStore: ScriptRouteStore; + onChange: (workspaceId: string, scripts: ScriptHealthEntry[]) => void; pollIntervalMs?: number; probeTimeoutMs?: number; graceMs?: number; @@ -114,14 +114,14 @@ export class ServiceHealthMonitor { this.pruneRemovedRoutes(activeHostnames); for (const workspaceId of changedWorkspaceIds) { - const services = this.buildWorkspaceServiceList(workspaceId); - const snapshot = JSON.stringify(services); + const scripts = this.buildWorkspaceScriptList(workspaceId); + const snapshot = JSON.stringify(scripts); if (snapshot === this.lastEmittedSnapshots.get(workspaceId)) { continue; } this.lastEmittedSnapshots.set(workspaceId, snapshot); - this.onChange(workspaceId, services); + this.onChange(workspaceId, scripts); } } finally { this.pollInFlight = false; @@ -152,7 +152,7 @@ export class ServiceHealthMonitor { } } - private buildWorkspaceServiceList(workspaceId: string): ServiceHealthEntry[] { + private buildWorkspaceScriptList(workspaceId: string): ScriptHealthEntry[] { return this.routeStore .listRoutesForWorkspace(workspaceId) .flatMap((route) => { @@ -160,20 +160,20 @@ export class ServiceHealthMonitor { if (!state?.health) { 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; } - private toServiceHealthEntry( - route: ServiceRouteEntry, - health: ServiceHealthEntry["health"], - ): ServiceHealthEntry { + private toScriptHealthEntry( + route: ScriptRouteEntry, + health: ScriptHealthEntry["health"], + ): ScriptHealthEntry { return { - serviceName: route.serviceName, + scriptName: route.scriptName, hostname: route.hostname, port: route.port, health, diff --git a/packages/server/src/server/service-proxy.test.ts b/packages/server/src/server/script-proxy.test.ts similarity index 89% rename from packages/server/src/server/service-proxy.test.ts rename to packages/server/src/server/script-proxy.test.ts index 3a10ca745..8b89990ad 100644 --- a/packages/server/src/server/service-proxy.test.ts +++ b/packages/server/src/server/script-proxy.test.ts @@ -5,11 +5,11 @@ import express from "express"; import WebSocket, { WebSocketServer } from "ws"; import pino from "pino"; import { - ServiceRouteStore, - createServiceProxyMiddleware, - createServiceProxyUpgradeHandler, + ScriptRouteStore, + createScriptProxyMiddleware, + createScriptProxyUpgradeHandler, findFreePort, -} from "./service-proxy.js"; +} from "./script-proxy.js"; const logger = pino({ level: "silent" }); @@ -24,17 +24,17 @@ function closeServer(server: http.Server): Promise { } // --------------------------------------------------------------------------- -// ServiceRouteStore +// ScriptRouteStore // --------------------------------------------------------------------------- -describe("ServiceRouteStore", () => { +describe("ScriptRouteStore", () => { it("registerRoute and findRoute with exact match", () => { - const store = new ServiceRouteStore(); + const store = new ScriptRouteStore(); store.registerRoute({ hostname: "editor.localhost", port: 3000, workspaceId: "/repo/.paseo/worktrees/feature-a", - serviceName: "editor", + scriptName: "editor", }); const route = store.findRoute("editor.localhost"); @@ -42,12 +42,12 @@ describe("ServiceRouteStore", () => { }); it("findRoute strips port from host header", () => { - const store = new ServiceRouteStore(); + const store = new ScriptRouteStore(); store.registerRoute({ hostname: "editor.localhost", port: 3000, workspaceId: "/repo/.paseo/worktrees/feature-a", - serviceName: "editor", + scriptName: "editor", }); const route = store.findRoute("editor.localhost:6767"); @@ -55,12 +55,12 @@ describe("ServiceRouteStore", () => { }); it("findRoute subdomain match", () => { - const store = new ServiceRouteStore(); + const store = new ScriptRouteStore(); store.registerRoute({ hostname: "editor.localhost", port: 3000, workspaceId: "/repo/.paseo/worktrees/feature-a", - serviceName: "editor", + scriptName: "editor", }); const route = store.findRoute("fix-auth.editor.localhost"); @@ -68,18 +68,18 @@ describe("ServiceRouteStore", () => { }); it("listRoutes returns enriched entries", () => { - const store = new ServiceRouteStore(); + const store = new ScriptRouteStore(); store.registerRoute({ hostname: "a.localhost", port: 3000, workspaceId: "/repo/.paseo/worktrees/feature-a", - serviceName: "web", + scriptName: "web", }); store.registerRoute({ hostname: "b.localhost", port: 4000, workspaceId: "/repo/.paseo/worktrees/feature-b", - serviceName: "docs", + scriptName: "docs", }); const routes = store.listRoutes(); @@ -88,35 +88,35 @@ describe("ServiceRouteStore", () => { hostname: "a.localhost", port: 3000, workspaceId: "/repo/.paseo/worktrees/feature-a", - serviceName: "web", + scriptName: "web", }); expect(routes).toContainEqual({ hostname: "b.localhost", port: 4000, workspaceId: "/repo/.paseo/worktrees/feature-b", - serviceName: "docs", + scriptName: "docs", }); }); it("listRoutesForWorkspace returns only routes for that workspace", () => { - const store = new ServiceRouteStore(); + const store = new ScriptRouteStore(); store.registerRoute({ hostname: "a.localhost", port: 3000, workspaceId: "/repo/.paseo/worktrees/feature-a", - serviceName: "web", + scriptName: "web", }); store.registerRoute({ hostname: "b.localhost", port: 4000, workspaceId: "/repo/.paseo/worktrees/feature-b", - serviceName: "docs", + scriptName: "docs", }); store.registerRoute({ hostname: "c.localhost", port: 5000, workspaceId: "/repo/.paseo/worktrees/feature-a", - serviceName: "api", + scriptName: "api", }); expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([ @@ -124,24 +124,24 @@ describe("ServiceRouteStore", () => { hostname: "a.localhost", port: 3000, workspaceId: "/repo/.paseo/worktrees/feature-a", - serviceName: "web", + scriptName: "web", }, { hostname: "c.localhost", port: 5000, workspaceId: "/repo/.paseo/worktrees/feature-a", - serviceName: "api", + scriptName: "api", }, ]); }); it("removeRoute works", () => { - const store = new ServiceRouteStore(); + const store = new ScriptRouteStore(); store.registerRoute({ hostname: "editor.localhost", port: 3000, workspaceId: "/repo/.paseo/worktrees/feature-a", - serviceName: "editor", + scriptName: "editor", }); store.removeRoute("editor.localhost"); @@ -149,12 +149,12 @@ describe("ServiceRouteStore", () => { }); it("removeRoute cleans up workspace index", () => { - const store = new ServiceRouteStore(); + const store = new ScriptRouteStore(); store.registerRoute({ hostname: "editor.localhost", port: 3000, workspaceId: "/repo/.paseo/worktrees/feature-a", - serviceName: "editor", + scriptName: "editor", }); store.removeRoute("editor.localhost"); @@ -163,24 +163,24 @@ describe("ServiceRouteStore", () => { }); it("removeRoutesForPort works", () => { - const store = new ServiceRouteStore(); + const store = new ScriptRouteStore(); store.registerRoute({ hostname: "a.localhost", port: 3000, workspaceId: "/repo/.paseo/worktrees/feature-a", - serviceName: "web", + scriptName: "web", }); store.registerRoute({ hostname: "b.localhost", port: 3000, workspaceId: "/repo/.paseo/worktrees/feature-a", - serviceName: "api", + scriptName: "api", }); store.registerRoute({ hostname: "c.localhost", port: 4000, workspaceId: "/repo/.paseo/worktrees/feature-b", - serviceName: "docs", + scriptName: "docs", }); store.removeRoutesForPort(3000); @@ -194,18 +194,18 @@ describe("ServiceRouteStore", () => { }); it("removeRoutesForPort cleans up workspace index", () => { - const store = new ServiceRouteStore(); + const store = new ScriptRouteStore(); store.registerRoute({ hostname: "a.localhost", port: 3000, workspaceId: "/repo/.paseo/worktrees/feature-a", - serviceName: "web", + scriptName: "web", }); store.registerRoute({ hostname: "b.localhost", port: 3000, workspaceId: "/repo/.paseo/worktrees/feature-a", - serviceName: "api", + scriptName: "api", }); store.removeRoutesForPort(3000); @@ -214,12 +214,12 @@ describe("ServiceRouteStore", () => { }); it("findRoute returns null for unknown hosts", () => { - const store = new ServiceRouteStore(); + const store = new ScriptRouteStore(); store.registerRoute({ hostname: "editor.localhost", port: 3000, workspaceId: "/repo/.paseo/worktrees/feature-a", - serviceName: "editor", + scriptName: "editor", }); 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. */ async function startProxy( - routeStore: ServiceRouteStore, + routeStore: ScriptRouteStore, opts?: { fallback?: boolean }, ): Promise<{ port: number; server: http.Server }> { const port = await findFreePort(); const app = express(); - app.use(createServiceProxyMiddleware({ routeStore, logger })); + app.use(createScriptProxyMiddleware({ routeStore, logger })); if (opts?.fallback) { app.use((_req, res) => { @@ -312,7 +312,7 @@ describe("HTTP proxy", () => { it("proxies requests to the correct upstream based on Host header", async () => { const upstream = await startUpstream(); - const routeStore = new ServiceRouteStore(); + const routeStore = new ScriptRouteStore(); routeStore.addRoute("test-service.localhost", upstream.port); const proxy = await startProxy(routeStore); @@ -330,7 +330,7 @@ describe("HTTP proxy", () => { }); it("falls through when no route matches", async () => { - const routeStore = new ServiceRouteStore(); + const routeStore = new ScriptRouteStore(); const proxy = await startProxy(routeStore, { fallback: true }); const res = await httpGet( @@ -346,7 +346,7 @@ describe("HTTP proxy", () => { // Get a port that nothing is listening on const deadPort = await findFreePort(); - const routeStore = new ServiceRouteStore(); + const routeStore = new ScriptRouteStore(); routeStore.addRoute("dead-service.localhost", deadPort); const proxy = await startProxy(routeStore); @@ -403,7 +403,7 @@ describe("WebSocket proxy", () => { httpServers.push(upstreamServer); // 2. Create the proxy server with the upgrade handler - const routeStore = new ServiceRouteStore(); + const routeStore = new ScriptRouteStore(); routeStore.addRoute("ws-service.localhost", upstreamPort); const proxyPort = await findFreePort(); @@ -412,7 +412,7 @@ describe("WebSocket proxy", () => { res.end(); }); - const upgradeHandler = createServiceProxyUpgradeHandler({ + const upgradeHandler = createScriptProxyUpgradeHandler({ routeStore, logger, }); diff --git a/packages/server/src/server/service-proxy.ts b/packages/server/src/server/script-proxy.ts similarity index 90% rename from packages/server/src/server/service-proxy.ts rename to packages/server/src/server/script-proxy.ts index ac3c00545..c980c89a2 100644 --- a/packages/server/src/server/service-proxy.ts +++ b/packages/server/src/server/script-proxy.ts @@ -21,21 +21,21 @@ const HOP_BY_HOP_HEADERS = new Set([ ]); // --------------------------------------------------------------------------- -// ServiceRouteStore +// ScriptRouteStore // --------------------------------------------------------------------------- -export interface ServiceRoute { +export interface ScriptRoute { hostname: string; port: number; } -export interface ServiceRouteEntry extends ServiceRoute { +export interface ScriptRouteEntry extends ScriptRoute { workspaceId: string; - serviceName: string; + scriptName: string; } -export class ServiceRouteStore { - private routes = new Map(); +export class ScriptRouteStore { + private routes = new Map(); private workspaceHostnames = new Map>(); addRoute(hostname: string, port: number): void { @@ -43,11 +43,11 @@ export class ServiceRouteStore { hostname, port, workspaceId: "", - serviceName: hostname, + scriptName: hostname, }); } - registerRoute(entry: ServiceRouteEntry): void { + registerRoute(entry: ScriptRouteEntry): void { const previous = this.routes.get(entry.hostname); if (previous) { 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 const hostname = host.replace(/:\d+$/, ""); @@ -99,22 +99,22 @@ export class ServiceRouteStore { return null; } - getRouteEntry(hostname: string): ServiceRouteEntry | null { + getRouteEntry(hostname: string): ScriptRouteEntry | null { const entry = this.routes.get(hostname); return entry ? { ...entry } : null; } - listRoutes(): ServiceRouteEntry[] { + listRoutes(): ScriptRouteEntry[] { return Array.from(this.routes.values()).map((entry) => ({ ...entry })); } - listRoutesForWorkspace(workspaceId: string): ServiceRouteEntry[] { + listRoutesForWorkspace(workspaceId: string): ScriptRouteEntry[] { const hostnames = this.workspaceHostnames.get(workspaceId); if (!hostnames) { return []; } - const routes: ServiceRouteEntry[] = []; + const routes: ScriptRouteEntry[] = []; for (const hostname of hostnames) { const entry = this.routes.get(hostname); if (entry) { @@ -160,14 +160,14 @@ function stripHopByHopHeaders( } // --------------------------------------------------------------------------- -// createServiceProxyMiddleware +// createScriptProxyMiddleware // --------------------------------------------------------------------------- -export function createServiceProxyMiddleware({ +export function createScriptProxyMiddleware({ routeStore, logger, }: { - routeStore: ServiceRouteStore; + routeStore: ScriptRouteStore; logger: Logger; }): RequestHandler { return (req, res, next) => { @@ -207,7 +207,7 @@ export function createServiceProxyMiddleware({ proxyReq.on("error", (err) => { logger.warn( { err, hostname: route.hostname, port: route.port }, - "Service proxy: upstream unreachable", + "Script proxy: upstream unreachable", ); if (!res.headersSent) { res.writeHead(502, { "content-type": "text/plain" }); @@ -220,14 +220,14 @@ export function createServiceProxyMiddleware({ } // --------------------------------------------------------------------------- -// createServiceProxyUpgradeHandler +// createScriptProxyUpgradeHandler // --------------------------------------------------------------------------- -export function createServiceProxyUpgradeHandler({ +export function createScriptProxyUpgradeHandler({ routeStore, logger, }: { - routeStore: ServiceRouteStore; + routeStore: ScriptRouteStore; logger: Logger; }): (req: IncomingMessage, socket: net.Socket, head: Buffer) => void { return (req, socket, head) => { @@ -286,7 +286,7 @@ export function createServiceProxyUpgradeHandler({ targetSocket.on("error", (err) => { logger.warn( { err, hostname: route.hostname, port: route.port }, - "Service proxy: WebSocket upstream unreachable", + "Script proxy: WebSocket upstream unreachable", ); socket.end(); }); diff --git a/packages/server/src/server/service-route-branch-handler.test.ts b/packages/server/src/server/script-route-branch-handler.test.ts similarity index 69% rename from packages/server/src/server/service-route-branch-handler.test.ts rename to packages/server/src/server/script-route-branch-handler.test.ts index 1326cb865..ca7e9880e 100644 --- a/packages/server/src/server/service-route-branch-handler.test.ts +++ b/packages/server/src/server/script-route-branch-handler.test.ts @@ -1,42 +1,42 @@ import { describe, expect, it, vi } from "vitest"; -import { ServiceRouteStore } from "./service-proxy.js"; -import { createBranchChangeRouteHandler } from "./service-route-branch-handler.js"; +import { ScriptRouteStore } from "./script-proxy.js"; +import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js"; function registerRoute( - routeStore: ServiceRouteStore, + routeStore: ScriptRouteStore, { hostname, port, workspaceId = "workspace-a", - serviceName, + scriptName, }: { hostname: string; port: number; workspaceId?: string; - serviceName: string; + scriptName: string; }, ): void { routeStore.registerRoute({ hostname, port, 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", () => { - const routeStore = new ServiceRouteStore(); + const routeStore = new ScriptRouteStore(); registerRoute(routeStore, { hostname: "feature-auth.api.localhost", port: 3001, - serviceName: "api", + scriptName: "api", }); - const emitServiceStatusUpdate = vi.fn(); + const emitScriptStatusUpdate = vi.fn(); const handleBranchChange = createBranchChangeRouteHandler({ routeStore, - emitServiceStatusUpdate, + emitScriptStatusUpdate, }); 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", () => { - const routeStore = new ServiceRouteStore(); - const emitServiceStatusUpdate = vi.fn(); + const routeStore = new ScriptRouteStore(); + const emitScriptStatusUpdate = vi.fn(); const handleBranchChange = createBranchChangeRouteHandler({ routeStore, - emitServiceStatusUpdate, + emitScriptStatusUpdate, }); handleBranchChange("workspace-a", "feature/auth", "feature/billing"); expect(routeStore.listRoutes()).toEqual([]); - expect(emitServiceStatusUpdate).not.toHaveBeenCalled(); + expect(emitScriptStatusUpdate).not.toHaveBeenCalled(); }); it("is a no-op when the resolved hostnames do not change", () => { - const routeStore = new ServiceRouteStore(); + const routeStore = new ScriptRouteStore(); registerRoute(routeStore, { hostname: "api.localhost", port: 3001, - serviceName: "api", + scriptName: "api", }); - const emitServiceStatusUpdate = vi.fn(); + const emitScriptStatusUpdate = vi.fn(); const handleBranchChange = createBranchChangeRouteHandler({ routeStore, - emitServiceStatusUpdate, + emitScriptStatusUpdate, }); handleBranchChange("workspace-a", "main", "master"); @@ -83,31 +83,31 @@ describe("service-route-branch-handler", () => { hostname: "api.localhost", port: 3001, 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", () => { - const routeStore = new ServiceRouteStore(); + const routeStore = new ScriptRouteStore(); registerRoute(routeStore, { hostname: "feature-auth.api.localhost", port: 3001, - serviceName: "api", + scriptName: "api", }); - const emitServiceStatusUpdate = vi.fn(); + const emitScriptStatusUpdate = vi.fn(); const handleBranchChange = createBranchChangeRouteHandler({ routeStore, - emitServiceStatusUpdate, + emitScriptStatusUpdate, }); 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", port: 3001, url: null, @@ -118,28 +118,28 @@ describe("service-route-branch-handler", () => { }); it("updates all services for a workspace when multiple routes are registered", () => { - const routeStore = new ServiceRouteStore(); + const routeStore = new ScriptRouteStore(); registerRoute(routeStore, { hostname: "feature-auth.api.localhost", port: 3001, - serviceName: "api", + scriptName: "api", }); registerRoute(routeStore, { hostname: "feature-auth.web.localhost", port: 3002, - serviceName: "web", + scriptName: "web", }); registerRoute(routeStore, { hostname: "docs.localhost", port: 3003, workspaceId: "workspace-b", - serviceName: "docs", + scriptName: "docs", }); - const emitServiceStatusUpdate = vi.fn(); + const emitScriptStatusUpdate = vi.fn(); const handleBranchChange = createBranchChangeRouteHandler({ routeStore, - emitServiceStatusUpdate, + emitScriptStatusUpdate, }); handleBranchChange("workspace-a", "feature/auth", "feature/billing"); @@ -149,13 +149,13 @@ describe("service-route-branch-handler", () => { hostname: "feature-billing.api.localhost", port: 3001, workspaceId: "workspace-a", - serviceName: "api", + scriptName: "api", }, { hostname: "feature-billing.web.localhost", port: 3002, workspaceId: "workspace-a", - serviceName: "web", + scriptName: "web", }, ]); expect(routeStore.listRoutesForWorkspace("workspace-b")).toEqual([ @@ -163,27 +163,27 @@ describe("service-route-branch-handler", () => { hostname: "docs.localhost", port: 3003, workspaceId: "workspace-b", - serviceName: "docs", + scriptName: "docs", }, ]); }); it("does not emit a status update when no changes are needed", () => { - const routeStore = new ServiceRouteStore(); + const routeStore = new ScriptRouteStore(); registerRoute(routeStore, { hostname: "web.localhost", port: 3002, - serviceName: "web", + scriptName: "web", }); - const emitServiceStatusUpdate = vi.fn(); + const emitScriptStatusUpdate = vi.fn(); const handleBranchChange = createBranchChangeRouteHandler({ routeStore, - emitServiceStatusUpdate, + emitScriptStatusUpdate, }); handleBranchChange("workspace-a", null, "main"); - expect(emitServiceStatusUpdate).not.toHaveBeenCalled(); + expect(emitScriptStatusUpdate).not.toHaveBeenCalled(); }); }); diff --git a/packages/server/src/server/service-route-branch-handler.ts b/packages/server/src/server/script-route-branch-handler.ts similarity index 63% rename from packages/server/src/server/service-route-branch-handler.ts rename to packages/server/src/server/script-route-branch-handler.ts index ed4039607..ddc4dc7aa 100644 --- a/packages/server/src/server/service-route-branch-handler.ts +++ b/packages/server/src/server/script-route-branch-handler.ts @@ -1,14 +1,14 @@ import type { Logger } from "pino"; -import type { WorkspaceServicePayload } from "../shared/messages.js"; -import { buildServiceHostname } from "../utils/service-hostname.js"; -import { buildWorkspaceServicePayloads } from "./service-status-projection.js"; -import type { ServiceRouteEntry, ServiceRouteStore } from "./service-proxy.js"; +import type { WorkspaceScriptPayload } from "../shared/messages.js"; +import { buildScriptHostname } from "../utils/script-hostname.js"; +import { buildWorkspaceScriptPayloads } from "./script-status-projection.js"; +import type { ScriptRouteEntry, ScriptRouteStore } from "./script-proxy.js"; interface BranchChangeRouteHandlerOptions { - routeStore: ServiceRouteStore; - emitServiceStatusUpdate: ( + routeStore: ScriptRouteStore; + emitScriptStatusUpdate: ( workspaceId: string, - services: WorkspaceServicePayload[], + scripts: WorkspaceScriptPayload[], ) => void; logger?: Logger; } @@ -16,7 +16,7 @@ interface BranchChangeRouteHandlerOptions { interface RouteHostnameUpdate { oldHostname: string; newHostname: string; - route: ServiceRouteEntry; + route: ScriptRouteEntry; } export function createBranchChangeRouteHandler( @@ -30,7 +30,7 @@ export function createBranchChangeRouteHandler( const updates: RouteHostnameUpdate[] = []; for (const route of routes) { - const newHostname = buildServiceHostname(newBranch, route.serviceName); + const newHostname = buildScriptHostname(newBranch, route.scriptName); if (newHostname !== route.hostname) { updates.push({ oldHostname: route.hostname, @@ -50,21 +50,21 @@ export function createBranchChangeRouteHandler( hostname: newHostname, port: route.port, workspaceId: route.workspaceId, - serviceName: route.serviceName, + scriptName: route.scriptName, }); options.logger?.info( { oldHostname, newHostname, - serviceName: route.serviceName, + scriptName: route.scriptName, }, - "Updated service route for branch rename", + "Updated script route for branch rename", ); } - options.emitServiceStatusUpdate( + options.emitScriptStatusUpdate( workspaceId, - buildWorkspaceServicePayloads(options.routeStore, workspaceId, null), + buildWorkspaceScriptPayloads(options.routeStore, workspaceId, null), ); }; } diff --git a/packages/server/src/server/service-status-projection.test.ts b/packages/server/src/server/script-status-projection.test.ts similarity index 74% rename from packages/server/src/server/service-status-projection.test.ts rename to packages/server/src/server/script-status-projection.test.ts index 7d6de16f8..bb7933beb 100644 --- a/packages/server/src/server/service-status-projection.test.ts +++ b/packages/server/src/server/script-status-projection.test.ts @@ -3,17 +3,17 @@ import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; import { tmpdir } from "node:os"; import { execSync } from "node:child_process"; -import { ServiceRouteStore } from "./service-proxy.js"; +import { ScriptRouteStore } from "./script-proxy.js"; import { - buildWorkspaceServicePayloads, - createServiceStatusEmitter, -} from "./service-status-projection.js"; + buildWorkspaceScriptPayloads, + createScriptStatusEmitter, +} from "./script-status-projection.js"; function createWorkspaceRepo(options?: { branchName?: string; paseoConfig?: Record; }): { 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"); execSync(`mkdir -p ${JSON.stringify(repoDir)}`); execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" }); @@ -35,22 +35,22 @@ function createWorkspaceRepo(options?: { }; } -describe("service-status-projection", () => { - it("shows configured services even before they have routes", () => { +describe("script-status-projection", () => { + it("shows configured scripts even before they have routes", () => { const workspace = createWorkspaceRepo({ paseoConfig: { - services: { + scripts: { api: { command: "npm run api" }, web: { command: "npm run web", port: 3000 }, }, }, }); - const routeStore = new ServiceRouteStore(); + const routeStore = new ScriptRouteStore(); try { - expect(buildWorkspaceServicePayloads(routeStore, workspace.repoDir, 6767)).toEqual([ + expect(buildWorkspaceScriptPayloads(routeStore, workspace.repoDir, 6767)).toEqual([ { - serviceName: "api", + scriptName: "api", hostname: "api.localhost", port: null, url: "http://api.localhost:6767", @@ -58,7 +58,7 @@ describe("service-status-projection", () => { health: null, }, { - serviceName: "web", + scriptName: "web", hostname: "web.localhost", port: 3000, 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({ branchName: "feature/card", paseoConfig: { - services: { + scripts: { web: { command: "npm run web" }, }, }, }); - const routeStore = new ServiceRouteStore(); + const routeStore = new ScriptRouteStore(); routeStore.registerRoute({ hostname: "feature-card.web.localhost", port: 4321, workspaceId: workspace.repoDir, - serviceName: "web", + scriptName: "web", }); try { - expect(buildWorkspaceServicePayloads(routeStore, workspace.repoDir, 6767)).toEqual([ + expect(buildWorkspaceScriptPayloads(routeStore, workspace.repoDir, 6767)).toEqual([ { - serviceName: "web", + scriptName: "web", hostname: "feature-card.web.localhost", port: 4321, 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", () => { const workspace = createWorkspaceRepo(); - const routeStore = new ServiceRouteStore(); + const routeStore = new ScriptRouteStore(); routeStore.registerRoute({ hostname: "docs.localhost", port: 3002, workspaceId: workspace.repoDir, - serviceName: "docs", + scriptName: "docs", }); try { - expect(buildWorkspaceServicePayloads(routeStore, workspace.repoDir, 6767)).toEqual([ + expect(buildWorkspaceScriptPayloads(routeStore, workspace.repoDir, 6767)).toEqual([ { - serviceName: "docs", + scriptName: "docs", hostname: "docs.localhost", port: 3002, 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({ paseoConfig: { - services: { + scripts: { api: { command: "npm run api" }, web: { command: "npm run web" }, }, }, }); - const routeStore = new ServiceRouteStore(); + const routeStore = new ScriptRouteStore(); routeStore.registerRoute({ hostname: "api.localhost", port: 3001, workspaceId: workspace.repoDir, - serviceName: "api", + scriptName: "api", }); const session = { emit: vi.fn() }; - const emitUpdate = createServiceStatusEmitter({ + const emitUpdate = createScriptStatusEmitter({ sessions: () => [session], routeStore, daemonPort: 6767, @@ -157,7 +157,7 @@ describe("service-status-projection", () => { try { emitUpdate(workspace.repoDir, [ { - serviceName: "api", + scriptName: "api", hostname: "api.localhost", port: 3001, health: "healthy", @@ -165,12 +165,12 @@ describe("service-status-projection", () => { ]); expect(session.emit).toHaveBeenCalledWith({ - type: "service_status_update", + type: "script_status_update", payload: { workspaceId: workspace.repoDir, - services: [ + scripts: [ { - serviceName: "api", + scriptName: "api", hostname: "api.localhost", port: 3001, url: "http://api.localhost:6767", @@ -178,7 +178,7 @@ describe("service-status-projection", () => { health: "healthy", }, { - serviceName: "web", + scriptName: "web", hostname: "web.localhost", port: null, url: "http://web.localhost:6767", @@ -196,17 +196,17 @@ describe("service-status-projection", () => { it("computes URLs with and without a daemon port", () => { const workspace = createWorkspaceRepo({ paseoConfig: { - services: { + scripts: { api: { command: "npm run api" }, }, }, }); - const routeStore = new ServiceRouteStore(); + const routeStore = new ScriptRouteStore(); try { - expect(buildWorkspaceServicePayloads(routeStore, workspace.repoDir, 6767)).toEqual([ + expect(buildWorkspaceScriptPayloads(routeStore, workspace.repoDir, 6767)).toEqual([ { - serviceName: "api", + scriptName: "api", hostname: "api.localhost", port: null, 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", port: null, url: null, diff --git a/packages/server/src/server/script-status-projection.ts b/packages/server/src/server/script-status-projection.ts new file mode 100644 index 000000000..afd0c78c6 --- /dev/null +++ b/packages/server/src/server/script-status-projection.ts @@ -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 { + const merged = new Map(); + + 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); + } + }; +} diff --git a/packages/server/src/server/service-status-projection.ts b/packages/server/src/server/service-status-projection.ts deleted file mode 100644 index 0be592a67..000000000 --- a/packages/server/src/server/service-status-projection.ts +++ /dev/null @@ -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 { - const merged = new Map(); - - 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); - } - }; -} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index dba2a9a86..b8b83d927 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -19,7 +19,7 @@ import { type SubscribeTerminalsRequest, type UnsubscribeTerminalsRequest, type CreateTerminalRequest, - type StartWorkspaceServiceRequest, + type StartWorkspaceScriptRequest, type SubscribeTerminalRequest, type UnsubscribeTerminalRequest, type TerminalInput, @@ -65,8 +65,8 @@ import { import { experimental_createMCPClient } from "ai"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js"; -import { buildWorkspaceServicePayloads } from "./service-status-projection.js"; -import { spawnWorkspaceService } from "./worktree-bootstrap.js"; +import { buildWorkspaceScriptPayloads } from "./script-status-projection.js"; +import { spawnWorkspaceScript } from "./worktree-bootstrap.js"; import { readGitCommand } from "./workspace-git-metadata.js"; import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js"; @@ -134,7 +134,7 @@ import { type WorktreeConfig, } from "../utils/worktree.js"; import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js"; -import type { ServiceRouteStore } from "./service-proxy.js"; +import type { ScriptRouteStore } from "./script-proxy.js"; import { getCheckoutDiff, getCachedCheckoutShortstat, @@ -416,14 +416,14 @@ export type SessionOptions = { tts: Resolvable; terminalManager: TerminalManager | null; providerSnapshotManager?: ProviderSnapshotManager; - serviceRouteStore?: ServiceRouteStore; + scriptRouteStore?: ScriptRouteStore; onBranchChanged?: ( workspaceId: string, oldBranch: string | null, newBranch: string | null, ) => void; getDaemonTcpPort?: () => number | null; - resolveServiceHealth?: (hostname: string) => "healthy" | "unhealthy" | null; + resolveScriptHealth?: (hostname: string) => "healthy" | "unhealthy" | null; voice?: { voiceAgentMcpStdio?: VoiceMcpStdioConfig | null; turnDetection?: Resolvable; @@ -600,14 +600,14 @@ export class Session { private readonly terminalManager: TerminalManager | null; private readonly providerSnapshotManager: ProviderSnapshotManager | null; private unsubscribeProviderSnapshotEvents: (() => void) | null = null; - private readonly serviceRouteStore: ServiceRouteStore | null; + private readonly scriptRouteStore: ScriptRouteStore | null; private readonly onBranchChanged?: ( workspaceId: string, oldBranch: string | null, newBranch: string | null, ) => void; private readonly getDaemonTcpPort: (() => number | null) | null; - private readonly resolveServiceHealth: + private readonly resolveScriptHealth: | ((hostname: string) => "healthy" | "unhealthy" | null) | null; private readonly subscribedTerminalDirectories = new Set(); @@ -667,10 +667,10 @@ export class Session { tts, terminalManager, providerSnapshotManager, - serviceRouteStore, + scriptRouteStore, onBranchChanged, getDaemonTcpPort, - resolveServiceHealth, + resolveScriptHealth, voice, voiceBridge, dictation, @@ -711,10 +711,10 @@ export class Session { this.createAgentMcpTransport = createAgentMcpTransport; this.terminalManager = terminalManager; this.providerSnapshotManager = providerSnapshotManager ?? null; - this.serviceRouteStore = serviceRouteStore ?? null; + this.scriptRouteStore = scriptRouteStore ?? null; this.onBranchChanged = onBranchChanged; this.getDaemonTcpPort = getDaemonTcpPort ?? null; - this.resolveServiceHealth = resolveServiceHealth ?? null; + this.resolveScriptHealth = resolveScriptHealth ?? null; if (this.terminalManager) { this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) => this.handleTerminalsChanged(event), @@ -1773,8 +1773,8 @@ export class Session { await this.handleCreateTerminalRequest(msg); break; - case "start_workspace_service_request": - await this.handleStartWorkspaceServiceRequest(msg); + case "start_workspace_script_request": + await this.handleStartWorkspaceScriptRequest(msg); break; case "subscribe_terminal_request": @@ -2878,7 +2878,7 @@ export class Session { agentId: snapshot.id, item, }), - serviceRouteStore: this.serviceRouteStore ?? undefined, + scriptRouteStore: this.scriptRouteStore ?? undefined, daemonPort: this.getDaemonTcpPort?.() ?? null, logger: this.sessionLogger, }); @@ -5273,12 +5273,12 @@ export class Session { status: "done", activityAt: null, diffStat, - services: this.serviceRouteStore - ? buildWorkspaceServicePayloads( - this.serviceRouteStore, + scripts: this.scriptRouteStore + ? buildWorkspaceScriptPayloads( + this.scriptRouteStore, workspace.directory, this.getDaemonTcpPort?.() ?? null, - this.resolveServiceHealth ?? undefined, + this.resolveScriptHealth ?? undefined, ) : [], }; @@ -6102,26 +6102,26 @@ export class Session { } } - private buildWorkspaceServicePayloadSnapshot( + private buildWorkspaceScriptPayloadSnapshot( workspaceDirectory: string, - ): WorkspaceDescriptorPayload["services"] { - if (!this.serviceRouteStore) { + ): WorkspaceDescriptorPayload["scripts"] { + if (!this.scriptRouteStore) { return []; } - return buildWorkspaceServicePayloads( - this.serviceRouteStore, + return buildWorkspaceScriptPayloads( + this.scriptRouteStore, workspaceDirectory, this.getDaemonTcpPort?.() ?? null, - this.resolveServiceHealth ?? undefined, + this.resolveScriptHealth ?? undefined, ); } - private emitWorkspaceServiceStatusUpdate(workspaceDirectory: string): void { + private emitWorkspaceScriptStatusUpdate(workspaceDirectory: string): void { this.emit({ - type: "service_status_update", + type: "script_status_update", payload: { workspaceId: workspaceDirectory, - services: this.buildWorkspaceServicePayloadSnapshot(workspaceDirectory), + scripts: this.buildWorkspaceScriptPayloadSnapshot(workspaceDirectory), }, }); } @@ -6134,12 +6134,12 @@ export class Session { await openInEditorTarget(options); } - private async handleStartWorkspaceServiceRequest( - request: StartWorkspaceServiceRequest, + private async handleStartWorkspaceScriptRequest( + request: StartWorkspaceScriptRequest, ): Promise { try { - if (!this.terminalManager || !this.serviceRouteStore) { - throw new Error("Workspace services are not available on this daemon"); + if (!this.terminalManager || !this.scriptRouteStore) { + throw new Error("Workspace scripts are not available on this daemon"); } const workspace = await this.resolveWorkspaceByIdOrDirectory(request.workspaceId); @@ -6147,46 +6147,48 @@ export class Session { throw new Error(`Workspace not found: ${request.workspaceId}`); } - await spawnWorkspaceService({ + const serviceResult = await spawnWorkspaceScript({ repoRoot: workspace.directory, workspaceId: workspace.directory, branchName: readGitCommand(workspace.directory, "git symbolic-ref --short HEAD"), - serviceName: request.serviceName, + scriptName: request.scriptName, daemonPort: this.getDaemonTcpPort?.() ?? null, - routeStore: this.serviceRouteStore, + routeStore: this.scriptRouteStore, terminalManager: this.terminalManager, logger: this.sessionLogger, onLifecycleChanged: () => { - this.emitWorkspaceServiceStatusUpdate(workspace.directory); + this.emitWorkspaceScriptStatusUpdate(workspace.directory); }, }); - this.emitWorkspaceServiceStatusUpdate(workspace.directory); + this.emitWorkspaceScriptStatusUpdate(workspace.directory); this.emit({ - type: "start_workspace_service_response", + type: "start_workspace_script_response", payload: { requestId: request.requestId, workspaceId: request.workspaceId, - serviceName: request.serviceName, + scriptName: request.scriptName, + terminalId: serviceResult.terminalId, error: null, }, }); } 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( { err: error, workspaceId: request.workspaceId, - serviceName: request.serviceName, + scriptName: request.scriptName, }, - "Failed to start workspace service", + "Failed to start workspace script", ); this.emit({ - type: "start_workspace_service_response", + type: "start_workspace_script_response", payload: { requestId: request.requestId, workspaceId: request.workspaceId, - serviceName: request.serviceName, + scriptName: request.scriptName, + terminalId: null, error: message, }, }); @@ -6292,7 +6294,7 @@ export class Session { sessionLogger: this.sessionLogger, terminalManager: this.terminalManager, archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), - serviceRouteStore: this.serviceRouteStore, + scriptRouteStore: this.scriptRouteStore, daemonPort: this.getDaemonTcpPort?.() ?? null, }, options, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 0f34fede5..41dc1c74b 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -36,7 +36,7 @@ import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import { buildProviderRegistry } from "./agent/provider-registry.js"; import { PushTokenStore } from "./push/token-store.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 { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js"; import { @@ -248,9 +248,9 @@ export class VoiceAssistantWebSocketServer { private readonly createAgentMcpTransport: AgentMcpTransportFactory; private readonly speech: SpeechService | null; private readonly terminalManager: TerminalManager | null; - private readonly serviceRouteStore: ServiceRouteStore | null; + private readonly scriptRouteStore: ScriptRouteStore | null; private readonly getDaemonTcpPort: (() => number | null) | null; - private readonly resolveServiceHealth: + private readonly resolveScriptHealth: | ((hostname: string) => "healthy" | "unhealthy" | null) | null; private readonly dictation: { @@ -323,14 +323,14 @@ export class VoiceAssistantWebSocketServer { loopService?: LoopService, scheduleService?: ScheduleService, checkoutDiffManager?: CheckoutDiffManager, - serviceRouteStore?: ServiceRouteStore | null, + scriptRouteStore?: ScriptRouteStore | null, onBranchChanged?: ( workspaceId: string, oldBranch: string | null, newBranch: string | null, ) => void, getDaemonTcpPort?: () => number | null, - resolveServiceHealth?: (hostname: string) => "healthy" | "unhealthy" | null, + resolveScriptHealth?: (hostname: string) => "healthy" | "unhealthy" | null, ) { this.logger = logger.child({ module: "websocket-server" }); this.serverId = serverId; @@ -377,10 +377,10 @@ export class VoiceAssistantWebSocketServer { providerSnapshotLogger, ); this.onLifecycleIntent = onLifecycleIntent ?? null; - this.serviceRouteStore = serviceRouteStore ?? null; + this.scriptRouteStore = scriptRouteStore ?? null; this.onBranchChanged = onBranchChanged ?? null; this.getDaemonTcpPort = getDaemonTcpPort ?? null; - this.resolveServiceHealth = resolveServiceHealth ?? null; + this.resolveScriptHealth = resolveScriptHealth ?? null; this.serverCapabilities = buildServerCapabilities({ readiness: this.speech?.getReadiness() ?? null, }); @@ -699,10 +699,10 @@ export class VoiceAssistantWebSocketServer { tts: () => this.speech?.resolveTts() ?? null, terminalManager: this.terminalManager, providerSnapshotManager: this.providerSnapshotManager, - serviceRouteStore: this.serviceRouteStore ?? undefined, + scriptRouteStore: this.scriptRouteStore ?? undefined, onBranchChanged: this.onBranchChanged ?? undefined, getDaemonTcpPort: this.getDaemonTcpPort ?? undefined, - resolveServiceHealth: this.resolveServiceHealth ?? undefined, + resolveScriptHealth: this.resolveScriptHealth ?? undefined, voice: { ...(this.voice ?? {}), turnDetection: () => this.speech?.resolveTurnDetection() ?? null, diff --git a/packages/server/src/server/worktree-bootstrap.test.ts b/packages/server/src/server/worktree-bootstrap.test.ts index e6b20a5f9..b0ca17184 100644 --- a/packages/server/src/server/worktree-bootstrap.test.ts +++ b/packages/server/src/server/worktree-bootstrap.test.ts @@ -8,9 +8,9 @@ import type { AgentTimelineItem } from "./agent/agent-sdk-types.js"; import { createAgentWorktree, runAsyncWorktreeBootstrap, - spawnWorktreeServices, + spawnWorktreeScripts, } from "./worktree-bootstrap.js"; -import { ServiceRouteStore } from "./service-proxy.js"; +import { ScriptRouteStore } from "./script-proxy.js"; describe("runAsyncWorktreeBootstrap", () => { let tempDir: string; @@ -507,11 +507,11 @@ describe("runAsyncWorktreeBootstrap", () => { 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( join(repoDir, "paseo.json"), JSON.stringify({ - services: { + scripts: { web: { command: "npm run dev", }, @@ -519,15 +519,15 @@ describe("runAsyncWorktreeBootstrap", () => { }), ); 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, stdio: "pipe", }); - const routeStore = new ServiceRouteStore(); + const routeStore = new ScriptRouteStore(); const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record }> = []; - const results = await spawnWorktreeServices({ + const results = await spawnWorktreeScripts({ repoRoot: repoDir, workspaceId: repoDir, branchName: "feature-socket-service", @@ -577,7 +577,7 @@ describe("runAsyncWorktreeBootstrap", () => { hostname: "feature-socket-service.web.localhost", port: expect.any(Number), workspaceId: repoDir, - serviceName: "web", + scriptName: "web", }, ]); expect(createTerminalCalls).toHaveLength(1); @@ -585,6 +585,6 @@ describe("runAsyncWorktreeBootstrap", () => { expect(createTerminalCalls[0]?.name).toBe("web"); expect(createTerminalCalls[0]?.env?.PORT).toEqual(expect.any(String)); expect(createTerminalCalls[0]?.env?.HOST).toBe("127.0.0.1"); - expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_URL).toBeUndefined(); + expect(createTerminalCalls[0]?.env?.PASEO_SCRIPT_URL).toBeUndefined(); }); }); diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index 974cd5641..5bb8d7ff9 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -5,10 +5,10 @@ import { promisify } from "node:util"; import { sep } from "node:path"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalSession } from "../terminal/terminal.js"; -import { buildServiceHostname } from "../utils/service-hostname.js"; +import { buildScriptHostname } from "../utils/script-hostname.js"; import { createWorktree, - getServiceConfigs, + getScriptConfigs, getWorktreeTerminalSpecs, listPaseoWorktrees, processCarriageReturns, @@ -19,7 +19,7 @@ import { type WorktreeSetupCommandResult, type WorktreeRuntimeEnv, } 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"; export interface WorktreeBootstrapTerminalResult { @@ -35,7 +35,7 @@ export interface RunAsyncWorktreeBootstrapOptions { worktree: WorktreeConfig; shouldBootstrap?: boolean; terminalManager: TerminalManager | null; - serviceRouteStore?: ServiceRouteStore; + scriptRouteStore?: ScriptRouteStore; daemonPort?: number | null; appendTimelineItem: (item: AgentTimelineItem) => Promise; emitLiveTimelineItem?: (item: AgentTimelineItem) => Promise; @@ -735,79 +735,79 @@ export async function runAsyncWorktreeBootstrap( await runWorktreeTerminalBootstrap(options, runtimeEnv); - if (!options.terminalManager || !options.serviceRouteStore) { + if (!options.terminalManager || !options.scriptRouteStore) { return; } try { - await spawnWorktreeServices({ + await spawnWorktreeScripts({ repoRoot: options.worktree.worktreePath, workspaceId: options.worktree.worktreePath, branchName: options.worktree.branchName, daemonPort: options.daemonPort, - routeStore: options.serviceRouteStore, + routeStore: options.scriptRouteStore, terminalManager: options.terminalManager, logger: options.logger, }); } catch (error) { options.logger?.warn( { err: error, agentId: options.agentId, worktreePath: options.worktree.worktreePath }, - "Failed to spawn worktree services", + "Failed to spawn worktree scripts", ); } } // --------------------------------------------------------------------------- -// Service lifecycle helpers +// Script lifecycle helpers // --------------------------------------------------------------------------- -export interface WorktreeServiceResult { - serviceName: string; +export interface WorktreeScriptResult { + scriptName: string; hostname: string; port: number; terminalId: string; } -type SpawnWorkspaceServiceOptions = { +type SpawnWorkspaceScriptOptions = { repoRoot: string; workspaceId: string; branchName: string | null; - serviceName: string; + scriptName: string; daemonPort?: number | null; - routeStore: ServiceRouteStore; + routeStore: ScriptRouteStore; terminalManager: TerminalManager; logger?: Logger; onLifecycleChanged?: () => void; }; -export async function spawnWorkspaceService( - options: SpawnWorkspaceServiceOptions, -): Promise { +export async function spawnWorkspaceScript( + options: SpawnWorkspaceScriptOptions, +): Promise { const { repoRoot, workspaceId, branchName, - serviceName, + scriptName, daemonPort, routeStore, terminalManager, logger, onLifecycleChanged, } = options; - const serviceConfigs = getServiceConfigs(repoRoot); - const config = serviceConfigs.get(serviceName); + const scriptConfigs = getScriptConfigs(repoRoot); + const config = scriptConfigs.get(scriptName); 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 port: number | null = null; try { - hostname = buildServiceHostname(branchName, serviceName); + hostname = buildScriptHostname(branchName, scriptName); const resolvedHostname = hostname; 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()); @@ -816,7 +816,7 @@ export async function spawnWorkspaceService( hostname: resolvedHostname, port, workspaceId, - serviceName, + scriptName, }); const env: Record = { @@ -824,12 +824,12 @@ export async function spawnWorkspaceService( HOST: "127.0.0.1", }; if (daemonPort !== null && daemonPort !== undefined) { - env.PASEO_SERVICE_URL = `http://${resolvedHostname}:${daemonPort}`; + env.PASEO_SCRIPT_URL = `http://${resolvedHostname}:${daemonPort}`; } const terminal = await terminalManager.createTerminal({ cwd: repoRoot, - name: serviceName, + name: scriptName, env, }); @@ -837,8 +837,8 @@ export async function spawnWorkspaceService( routeStore.removeRoute(resolvedHostname); onLifecycleChanged?.(); logger?.info( - { serviceName, hostname: resolvedHostname, terminalId: terminal.id }, - "Stopped worktree service", + { scriptName, hostname: resolvedHostname, terminalId: terminal.id }, + "Stopped worktree script", ); }); @@ -846,13 +846,13 @@ export async function spawnWorkspaceService( terminal.send({ type: "input", data: `${config.command}\r` }); logger?.info( - { serviceName, hostname: resolvedHostname, port, terminalId: terminal.id }, - `Registered service proxy: ${resolvedHostname} -> 127.0.0.1:${port}`, + { scriptName, hostname: resolvedHostname, port, terminalId: terminal.id }, + `Registered script proxy: ${resolvedHostname} -> 127.0.0.1:${port}`, ); onLifecycleChanged?.(); return { - serviceName, + scriptName, hostname: resolvedHostname, port, terminalId: terminal.id, @@ -864,41 +864,41 @@ export async function spawnWorkspaceService( logger?.error( { err: error, - serviceName, + scriptName, repoRoot, branchName, hostname, port, command: config.command, }, - "Failed to spawn worktree service", + "Failed to spawn worktree script", ); throw error; } } -export async function spawnWorktreeServices(options: { +export async function spawnWorktreeScripts(options: { repoRoot: string; workspaceId: string; branchName: string | null; daemonPort?: number | null; - routeStore: ServiceRouteStore; + routeStore: ScriptRouteStore; terminalManager: TerminalManager; logger?: Logger; onLifecycleChanged?: () => void; -}): Promise { +}): Promise { const { repoRoot } = options; - const serviceConfigs = getServiceConfigs(repoRoot); - if (serviceConfigs.size === 0) { + const scriptConfigs = getScriptConfigs(repoRoot); + if (scriptConfigs.size === 0) { return []; } - const results: WorktreeServiceResult[] = []; - for (const serviceName of serviceConfigs.keys()) { + const results: WorktreeScriptResult[] = []; + for (const scriptName of scriptConfigs.keys()) { results.push( - await spawnWorkspaceService({ + await spawnWorkspaceScript({ ...options, - serviceName, + scriptName, }), ); } @@ -906,14 +906,14 @@ export async function spawnWorktreeServices(options: { return results; } -export function teardownWorktreeServices(options: { +export function teardownWorktreeScripts(options: { hostnames: string[]; - routeStore: ServiceRouteStore; + routeStore: ScriptRouteStore; logger: Logger; }): void { const { hostnames, routeStore, logger } = options; for (const hostname of hostnames) { routeStore.removeRoute(hostname); - logger.info({ hostname }, "Removed service proxy route"); + logger.info({ hostname }, "Removed script proxy route"); } } diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index 22b476702..488930047 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { afterEach, describe, expect, test, vi } from "vitest"; import type { SessionOutboundMessage } from "./messages.js"; -import { ServiceRouteStore } from "./service-proxy.js"; +import { ScriptRouteStore } from "./script-proxy.js"; import * as worktreeBootstrap from "./worktree-bootstrap.js"; import { 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({ paseoConfig: { - services: { + scripts: { web: { command: "npm run dev", }, @@ -126,7 +126,7 @@ describe("createPaseoWorktreeInBackground", () => { const worktreePath = createdWorktree.worktreePath; const emitted: SessionOutboundMessage[] = []; const snapshots = new Map(); - const routeStore = new ServiceRouteStore(); + const routeStore = new ScriptRouteStore(); const logger = createLogger(); const terminalManager = createTerminalManagerStub(); const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); @@ -141,7 +141,7 @@ describe("createPaseoWorktreeInBackground", () => { sessionLogger: logger, terminalManager: terminalManager.manager, archiveWorkspaceRecord, - serviceRouteStore: routeStore, + scriptRouteStore: routeStore, daemonPort: 6767, }, { @@ -202,13 +202,13 @@ describe("createPaseoWorktreeInBackground", () => { hostname: "feature-no-setup.web.localhost", port: expect.any(Number), workspaceId: worktreePath, - serviceName: "web", + scriptName: "web", }, ]); expect(terminalManager.terminals).toHaveLength(1); expect(terminalManager.terminals[0]?.cwd).toBe(worktreePath); expect(terminalManager.terminals[0]?.env?.PORT).toEqual(expect.any(String)); - expect(terminalManager.terminals[0]?.env?.PASEO_SERVICE_URL).toBeDefined(); + expect(terminalManager.terminals[0]?.env?.PASEO_SCRIPT_URL).toBeDefined(); expect(terminalManager.terminals[0]?.sent).toEqual(["npm run dev\r"]); expect(archiveWorkspaceRecord).not.toHaveBeenCalled(); expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath); @@ -251,7 +251,7 @@ describe("createPaseoWorktreeInBackground", () => { sessionLogger: logger, terminalManager: null, archiveWorkspaceRecord, - serviceRouteStore: null, + scriptRouteStore: null, daemonPort: null, }, { @@ -319,7 +319,7 @@ describe("createPaseoWorktreeInBackground", () => { sessionLogger: logger, terminalManager: null, archiveWorkspaceRecord, - serviceRouteStore: null, + scriptRouteStore: null, daemonPort: null, }, { @@ -400,7 +400,7 @@ describe("createPaseoWorktreeInBackground", () => { worktree: { setup: ["printf 'ran' > setup-ran.txt"], }, - services: { + scripts: { web: { command: "npm run dev", }, @@ -421,7 +421,7 @@ describe("createPaseoWorktreeInBackground", () => { const emitted: SessionOutboundMessage[] = []; const snapshots = new Map(); - const routeStore = new ServiceRouteStore(); + const routeStore = new ScriptRouteStore(); const logger = createLogger(); const terminalManager = createTerminalManagerStub(); const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); @@ -436,7 +436,7 @@ describe("createPaseoWorktreeInBackground", () => { sessionLogger: logger, terminalManager: terminalManager.manager, archiveWorkspaceRecord, - serviceRouteStore: routeStore, + scriptRouteStore: routeStore, daemonPort: 6767, }, { @@ -478,14 +478,14 @@ describe("createPaseoWorktreeInBackground", () => { hostname: "reused-worktree.web.localhost", port: expect.any(Number), workspaceId: existingWorktree.worktreePath, - serviceName: "web", + scriptName: "web", }, ]); expect(terminalManager.terminals).toHaveLength(1); expect(terminalManager.terminals[0]?.cwd).toBe(existingWorktree.worktreePath); expect(terminalManager.terminals[0]?.name).toBe("web"); expect(terminalManager.terminals[0]?.env?.PORT).toEqual(expect.any(String)); - expect(terminalManager.terminals[0]?.env?.PASEO_SERVICE_URL).toBeDefined(); + expect(terminalManager.terminals[0]?.env?.PASEO_SCRIPT_URL).toBeDefined(); expect(terminalManager.terminals[0]?.sent).toEqual(["npm run dev\r"]); expect( readFileSync(path.join(existingWorktree.worktreePath, "README.md"), "utf8"), @@ -502,7 +502,7 @@ describe("createPaseoWorktreeInBackground", () => { test("keeps setup completed when service launch fails afterward", async () => { const { tempDir, repoDir } = createGitRepo({ paseoConfig: { - services: { + scripts: { web: { command: "npm run dev", }, @@ -523,7 +523,7 @@ describe("createPaseoWorktreeInBackground", () => { const worktreePath = createdWorktree.worktreePath; const emitted: SessionOutboundMessage[] = []; const snapshots = new Map(); - const routeStore = new ServiceRouteStore(); + const routeStore = new ScriptRouteStore(); const logger = createLogger(); const terminalManager = createTerminalManagerStub({ createTerminal: async () => { @@ -542,7 +542,7 @@ describe("createPaseoWorktreeInBackground", () => { sessionLogger: logger, terminalManager: terminalManager.manager, archiveWorkspaceRecord, - serviceRouteStore: routeStore, + scriptRouteStore: routeStore, daemonPort: 6767, }, { @@ -575,7 +575,7 @@ describe("createPaseoWorktreeInBackground", () => { worktreeSlug: "feature-service-failure", worktreePath, }), - "Failed to spawn worktree services after workspace setup completed", + "Failed to spawn worktree scripts after workspace setup completed", ); expect(snapshots.get("45")).toMatchObject({ status: "completed", @@ -585,10 +585,10 @@ describe("createPaseoWorktreeInBackground", () => { 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({ paseoConfig: { - services: { + scripts: { web: { command: "npm run dev", }, @@ -609,7 +609,7 @@ describe("createPaseoWorktreeInBackground", () => { const worktreePath = createdWorktree.worktreePath; const emitted: SessionOutboundMessage[] = []; const snapshots = new Map(); - const routeStore = new ServiceRouteStore(); + const routeStore = new ScriptRouteStore(); const logger = createLogger(); const terminalManager = createTerminalManagerStub(); const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); @@ -624,7 +624,7 @@ describe("createPaseoWorktreeInBackground", () => { sessionLogger: logger, terminalManager: terminalManager.manager, archiveWorkspaceRecord, - serviceRouteStore: routeStore, + scriptRouteStore: routeStore, daemonPort: null, }, { @@ -644,13 +644,13 @@ describe("createPaseoWorktreeInBackground", () => { hostname: "feature-socket-mode.web.localhost", port: expect.any(Number), workspaceId: worktreePath, - serviceName: "web", + scriptName: "web", }, ]); expect(terminalManager.terminals).toHaveLength(1); expect(terminalManager.terminals[0]?.cwd).toBe(worktreePath); expect(terminalManager.terminals[0]?.env?.PORT).toEqual(expect.any(String)); - expect(terminalManager.terminals[0]?.env?.PASEO_SERVICE_URL).toBeUndefined(); + expect(terminalManager.terminals[0]?.env?.PASEO_SCRIPT_URL).toBeUndefined(); expect(terminalManager.terminals[0]?.sent).toEqual(["npm run dev\r"]); expect(snapshots.get("46")).toMatchObject({ status: "completed", diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 10c27f4b7..c1108a01d 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -26,10 +26,10 @@ import { createAgentWorktree, createWorktreeSetupProgressAccumulator, getWorktreeSetupProgressResults, - spawnWorktreeServices, + spawnWorktreeScripts, } from "./worktree-bootstrap.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; -import type { ServiceRouteStore } from "./service-proxy.js"; +import type { ScriptRouteStore } from "./script-proxy.js"; import { getCheckoutStatusLite, resolveRepositoryDefaultBranch, @@ -110,7 +110,7 @@ type CreatePaseoWorktreeInBackgroundDependencies = { sessionLogger: Logger; terminalManager: TerminalManager | null; archiveWorkspaceRecord: (workspaceId: number) => Promise; - serviceRouteStore: ServiceRouteStore | null; + scriptRouteStore: ScriptRouteStore | null; daemonPort?: number | null; }; @@ -765,17 +765,17 @@ export async function createPaseoWorktreeInBackground( return; } - if (!dependencies.terminalManager || !dependencies.serviceRouteStore) { + if (!dependencies.terminalManager || !dependencies.scriptRouteStore) { return; } try { - await spawnWorktreeServices({ + await spawnWorktreeScripts({ repoRoot: worktree.worktreePath, workspaceId: worktree.worktreePath, branchName: worktree.branchName, daemonPort: dependencies.daemonPort, - routeStore: dependencies.serviceRouteStore, + routeStore: dependencies.scriptRouteStore, terminalManager: dependencies.terminalManager, logger: dependencies.sessionLogger, onLifecycleChanged: () => { @@ -791,7 +791,7 @@ export async function createPaseoWorktreeInBackground( worktreeSlug: worktree.branchName, worktreePath: worktree.worktreePath, }, - "Failed to spawn worktree services after workspace setup completed", + "Failed to spawn worktree scripts after workspace setup completed", ); } } finally { diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index e43a36c29..de3e3e478 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1301,10 +1301,10 @@ export const CreateTerminalRequestSchema = z.object({ requestId: z.string(), }); -export const StartWorkspaceServiceRequestSchema = z.object({ - type: z.literal("start_workspace_service_request"), +export const StartWorkspaceScriptRequestSchema = z.object({ + type: z.literal("start_workspace_script_request"), workspaceId: z.string(), - serviceName: z.string(), + scriptName: z.string(), requestId: z.string(), }); @@ -1421,7 +1421,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ SubscribeTerminalsRequestSchema, UnsubscribeTerminalsRequestSchema, CreateTerminalRequestSchema, - StartWorkspaceServiceRequestSchema, + StartWorkspaceScriptRequestSchema, SubscribeTerminalRequestSchema, UnsubscribeTerminalRequestSchema, TerminalInputSchema, @@ -1777,16 +1777,16 @@ export const ProjectPlacementPayloadSchema = z.object({ checkout: ProjectCheckoutLitePayloadSchema, }); -export const WorkspaceServiceLifecycleSchema = z.enum(["running", "stopped"]); -export const WorkspaceServiceHealthSchema = z.enum(["healthy", "unhealthy"]); +export const WorkspaceScriptLifecycleSchema = z.enum(["running", "stopped"]); +export const WorkspaceScriptHealthSchema = z.enum(["healthy", "unhealthy"]); -export const WorkspaceServicePayloadSchema = z.object({ - serviceName: z.string(), +export const WorkspaceScriptPayloadSchema = z.object({ + scriptName: z.string(), hostname: z.string(), port: z.number().int().positive().nullable(), url: z.string().nullable(), - lifecycle: WorkspaceServiceLifecycleSchema, - health: WorkspaceServiceHealthSchema.nullable(), + lifecycle: WorkspaceScriptLifecycleSchema, + health: WorkspaceScriptHealthSchema.nullable(), }); export const WorkspaceDescriptorPayloadSchema = z.object({ @@ -1808,7 +1808,7 @@ export const WorkspaceDescriptorPayloadSchema = z.object({ }) .nullable() .optional(), - services: z.array(WorkspaceServicePayloadSchema).default([]), + scripts: z.array(WorkspaceScriptPayloadSchema).default([]), }); export const AgentUpdateMessageSchema = z.object({ @@ -1904,11 +1904,11 @@ export const WorkspaceUpdateMessageSchema = z.object({ ]), }); -export const ServiceStatusUpdateMessageSchema = z.object({ - type: z.literal("service_status_update"), +export const ScriptStatusUpdateMessageSchema = z.object({ + type: z.literal("script_status_update"), payload: z.object({ 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({ - type: z.literal("start_workspace_service_response"), +export const StartWorkspaceScriptResponseMessageSchema = z.object({ + type: z.literal("start_workspace_script_response"), payload: z.object({ requestId: z.string(), workspaceId: z.string(), - serviceName: z.string(), + scriptName: z.string(), + terminalId: z.string().nullable(), error: z.string().nullable(), }), }); @@ -2657,7 +2658,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ArtifactMessageSchema, AgentUpdateMessageSchema, WorkspaceUpdateMessageSchema, - ServiceStatusUpdateMessageSchema, + ScriptStatusUpdateMessageSchema, WorkspaceSetupProgressMessageSchema, WorkspaceSetupStatusResponseMessageSchema, AgentStreamMessageSchema, @@ -2665,7 +2666,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ FetchAgentsResponseMessageSchema, FetchWorkspacesResponseMessageSchema, OpenProjectResponseMessageSchema, - StartWorkspaceServiceResponseMessageSchema, + StartWorkspaceScriptResponseMessageSchema, ListAvailableEditorsResponseMessageSchema, OpenInEditorResponseMessageSchema, ArchiveWorkspaceResponseMessageSchema, @@ -2765,17 +2766,17 @@ export type ProjectCheckoutLitePayload = z.infer; export type WorkspaceStateBucket = z.infer; export type WorkspaceDescriptorPayload = z.infer; -export type WorkspaceServiceLifecycle = z.infer; -export type WorkspaceServiceHealth = z.infer; -export type WorkspaceServicePayload = z.infer; +export type WorkspaceScriptLifecycle = z.infer; +export type WorkspaceScriptHealth = z.infer; +export type WorkspaceScriptPayload = z.infer; export type EditorTargetId = z.infer; export type EditorTargetDescriptorPayload = z.infer; export type FetchAgentsResponseMessage = z.infer; export type FetchWorkspacesResponseMessage = z.infer; -export type ServiceStatusUpdateMessage = z.infer; +export type ScriptStatusUpdateMessage = z.infer; export type OpenProjectResponseMessage = z.infer; -export type StartWorkspaceServiceResponseMessage = z.infer< - typeof StartWorkspaceServiceResponseMessageSchema +export type StartWorkspaceScriptResponseMessage = z.infer< + typeof StartWorkspaceScriptResponseMessageSchema >; export type ListAvailableEditorsResponseMessage = z.infer< typeof ListAvailableEditorsResponseMessageSchema @@ -2956,8 +2957,8 @@ export type UnsubscribeTerminalsRequest = z.infer; export type CreateTerminalRequest = z.infer; export type CreateTerminalResponse = z.infer; -export type StartWorkspaceServiceRequest = z.infer; -export type StartWorkspaceServiceResponse = z.infer; +export type StartWorkspaceScriptRequest = z.infer; +export type StartWorkspaceScriptResponse = z.infer; export type SubscribeTerminalRequest = z.infer; export type SubscribeTerminalResponse = z.infer; export type UnsubscribeTerminalRequest = z.infer; diff --git a/packages/server/src/shared/messages.workspaces.test.ts b/packages/server/src/shared/messages.workspaces.test.ts index a38673039..239ffa14f 100644 --- a/packages/server/src/shared/messages.workspaces.test.ts +++ b/packages/server/src/shared/messages.workspaces.test.ts @@ -65,7 +65,7 @@ describe("workspace message schemas", () => { name: "", status: "not-a-bucket", activityAt: null, - services: [], + scripts: [], }, }, }); @@ -73,7 +73,7 @@ describe("workspace message schemas", () => { expect(result.success).toBe(false); }); - test("parses workspace descriptors with services", () => { + test("parses workspace descriptors with scripts", () => { const parsed = SessionOutboundMessageSchema.parse({ type: "workspace_update", payload: { @@ -89,9 +89,9 @@ describe("workspace message schemas", () => { name: "repo", status: "done", activityAt: null, - services: [ + scripts: [ { - serviceName: "web", + scriptName: "web", hostname: "web.localhost", port: 3000, url: "http://web.localhost:6767", @@ -107,9 +107,9 @@ describe("workspace message schemas", () => { if (parsed.type !== "workspace_update" || parsed.payload.kind !== "upsert") { 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", port: 3000, url: "http://web.localhost:6767", @@ -135,7 +135,7 @@ describe("workspace message schemas", () => { name: "repo", status: "done", activityAt: null, - services: [], + scripts: [], }, }, }); @@ -148,14 +148,14 @@ describe("workspace message schemas", () => { expect(parsed.payload.workspace.workspaceKind).toBe("directory"); }); - test("parses service_status_update payload", () => { + test("parses script_status_update payload", () => { const parsed = SessionOutboundMessageSchema.parse({ - type: "service_status_update", + type: "script_status_update", payload: { workspaceId: "/repo", - services: [ + scripts: [ { - serviceName: "web", + scriptName: "web", hostname: "web.localhost", port: 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"); }); diff --git a/packages/server/src/utils/script-hostname.test.ts b/packages/server/src/utils/script-hostname.test.ts new file mode 100644 index 000000000..ec16b44ef --- /dev/null +++ b/packages/server/src/utils/script-hostname.test.ts @@ -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", + ); + }); +}); diff --git a/packages/server/src/utils/service-hostname.ts b/packages/server/src/utils/script-hostname.ts similarity index 66% rename from packages/server/src/utils/service-hostname.ts rename to packages/server/src/utils/script-hostname.ts index 49687d8a6..c923f6cd1 100644 --- a/packages/server/src/utils/service-hostname.ts +++ b/packages/server/src/utils/script-hostname.ts @@ -1,7 +1,7 @@ import { slugify } from "./worktree.js"; -export function buildServiceHostname(branchName: string | null, serviceName: string): string { - const serviceHostnameLabel = slugify(serviceName); +export function buildScriptHostname(branchName: string | null, scriptName: string): string { + const serviceHostnameLabel = slugify(scriptName); const isDefaultBranch = branchName === null || branchName === "main" || branchName === "master"; diff --git a/packages/server/src/utils/service-hostname.test.ts b/packages/server/src/utils/service-hostname.test.ts deleted file mode 100644 index da92ab79d..000000000 --- a/packages/server/src/utils/service-hostname.test.ts +++ /dev/null @@ -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", - ); - }); -}); diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index fd7e2bbfe..35296a81d 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -24,7 +24,7 @@ interface PaseoConfig { teardown?: string[]; terminals?: WorktreeTerminalConfig[]; }; - services?: Record; + scripts?: Record; } const execAsync = promisify(exec); @@ -90,7 +90,7 @@ export interface WorktreeTerminalConfig { command: string; } -export interface ServiceConfig { +export interface ScriptConfig { command: string; port?: number; // explicit port override, otherwise auto-assigned } @@ -205,15 +205,15 @@ export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConf return specs; } -export function getServiceConfigs(repoRoot: string): Map { +export function getScriptConfigs(repoRoot: string): Map { const config = readPaseoConfig(repoRoot); - const services = config?.services; - if (!services || typeof services !== "object") { + const scripts = config?.scripts; + if (!scripts || typeof scripts !== "object") { return new Map(); } - const result = new Map(); - for (const [name, entry] of Object.entries(services)) { + const result = new Map(); + for (const [name, entry] of Object.entries(scripts)) { if (!entry || typeof entry !== "object") { continue; } @@ -227,13 +227,13 @@ export function getServiceConfigs(repoRoot: string): Map continue; } - const serviceConfig: ServiceConfig = { command }; + const scriptConfig: ScriptConfig = { command }; 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; diff --git a/paseo.json b/paseo.json index 68dd0e929..408392e7e 100644 --- a/paseo.json +++ b/paseo.json @@ -4,12 +4,14 @@ "npm ci", "npm run build --workspace=@getpaseo/relay", "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" + } } }