refactor: rename services to scripts and extract reusable components

This commit is contained in:
Mohamed Boudra
2026-04-09 18:54:25 +07:00
parent 5cf806dc77
commit 397d454de0
48 changed files with 1356 additions and 983 deletions
+31 -31
View File
@@ -22,7 +22,7 @@ function getServerId(): string {
// Composable helpers
// ---------------------------------------------------------------------------
/** Waits for the globe icon to appear on a workspace row (proves services are running). */
/** Waits for the globe icon to appear on a workspace row (proves scripts are running). */
async function expectGlobeIcon(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-globe-icon")).toBeVisible({ timeout: 30_000 });
}
@@ -34,38 +34,38 @@ async function expectHoverCard(page: Page, workspaceName: string): Promise<void>
await expect(page.getByTestId("workspace-hover-card")).toBeVisible({ timeout: 10_000 });
}
/** Asserts that a service row with the given name exists in the hover card. */
async function expectServiceInCard(page: Page, serviceName: string): Promise<void> {
/** Asserts that a script row with the given name exists in the hover card. */
async function expectScriptInCard(page: Page, scriptName: string): Promise<void> {
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<void> {
/** Asserts the script status dot indicates "running". */
async function expectScriptRunning(page: Page, scriptName: string): Promise<void> {
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<void> {
/** Asserts the script lifecycle is stopped. */
async function expectScriptStopped(page: Page, scriptName: string): Promise<void> {
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<void> {
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<void> {
// ---------------------------------------------------------------------------
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();
@@ -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");
+35
View File
@@ -0,0 +1,35 @@
import { View, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
interface DiffStatProps {
additions: number;
deletions: number;
}
export function DiffStat({ additions, deletions }: DiffStatProps) {
return (
<View style={styles.row}>
<Text style={styles.additions}>+{additions.toLocaleString()}</Text>
<Text style={styles.deletions}>-{deletions.toLocaleString()}</Text>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
row: {
flexDirection: "row",
alignItems: "center",
gap: 4,
flexShrink: 0,
},
additions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.green[400],
},
deletions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.red[500],
},
}));
+2 -12
View File
@@ -8,6 +8,7 @@ import {
type ReactElement,
} 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({
)}
</View>
<View style={styles.fileHeaderRight}>
<Text style={styles.additions}>+{file.additions}</Text>
<Text style={styles.deletions}>-{file.deletions}</Text>
<DiffStat additions={file.additions} deletions={file.deletions} />
</View>
</Pressable>
</View>
@@ -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,
@@ -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 (
<View style={checksBadgeStyles.badge}>
<GitHubIcon size={10} color={color} />
<Text style={[checksBadgeStyles.text, { color }]}>{label}</Text>
</View>
);
}
const checksBadgeStyles = StyleSheet.create((theme) => ({
badge: {
flexDirection: "row",
alignItems: "center",
gap: 2,
},
text: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
lineHeight: 14,
},
}));
function WorkspaceStatusIndicator({
bucket,
@@ -932,7 +966,7 @@ function WorkspaceRowInner({
}, [interaction.didLongPressRef, onPress]);
const isDesktop = !isTouchPlatform;
const showGlobe = isDesktop && workspace.hasRunningServices;
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
return (
<WorkspaceHoverCard workspace={workspace} prHint={prHint} isDragging={isDragging}>
@@ -980,9 +1014,9 @@ function WorkspaceRowInner({
</Text>
</View>
<View style={styles.workspaceRowRight}>
{showGlobe ? (
<View testID="workspace-globe-icon" accessibilityLabel="Services available">
<Globe size={12} color={theme.colors.foregroundMuted} />
{showScriptsIcon ? (
<View testID="workspace-globe-icon" accessibilityLabel="Scripts available">
<Terminal size={12} color={theme.colors.foregroundMuted} />
</View>
) : null}
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
@@ -1037,10 +1071,10 @@ function WorkspaceRowInner({
</DropdownMenuContent>
</DropdownMenu>
) : workspace.diffStat ? (
<View style={styles.diffStatRow}>
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
</View>
<DiffStat
additions={workspace.diffStat.additions}
deletions={workspace.diffStat.deletions}
/>
) : null}
{showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadge}>
@@ -1051,7 +1085,7 @@ function WorkspaceRowInner({
</View>
{prHint ? (
<View style={styles.workspacePrBadgeRow}>
<CheckStatusIndicator status={prHint.checksStatus ?? "none"} size={12} />
<ChecksBadge checks={prHint.checks} />
<PrBadge hint={prHint} />
</View>
) : 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,
@@ -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<typeof useUnistyles>["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<typeof useUnistyles>["theme"];
}): string {
if (input.status === "success") return input.theme.colors.palette.green[500];
if (input.status === "failure") return input.theme.colors.palette.red[500];
if (input.status === "pending") return input.theme.colors.palette.amber[500];
return input.theme.colors.foregroundMuted;
}
function getCheckStatusIcon(status: string): typeof Check {
if (status === "success") return Check;
if (status === "failure") return X;
return Minus;
}
export function CheckStatusIndicator({
status,
size = 12,
}: {
status: string;
size?: number;
}): ReactElement | null {
const { theme } = useUnistyles();
if (!status || status === "none") return null;
const color = getCheckStatusColor({ status, theme });
const IconComponent = getCheckStatusIcon(status);
return (
<View
style={{
width: size,
height: size,
borderRadius: size / 2,
borderWidth: 1,
borderColor: color,
alignItems: "center",
justifyContent: "center",
}}
>
<IconComponent size={Math.round(size * 0.5)} color={color} strokeWidth={3} />
</View>
);
}
function ChecksSummary({ checks }: { checks: Array<{ status: string }> }): ReactElement {
const { theme } = useUnistyles();
const counts: Record<string, number> = {};
for (const check of checks) {
const bucket = check.status === "success" ? "success" : check.status === "failure" ? "failure" : "pending";
counts[bucket] = (counts[bucket] ?? 0) + 1;
}
const buckets: Array<{ status: string; count: number }> = [];
if (counts.failure) buckets.push({ status: "failure", count: counts.failure });
if (counts.success) buckets.push({ status: "success", count: counts.success });
if (counts.pending) buckets.push({ status: "pending", count: counts.pending });
return (
<>
{buckets.map((bucket) => {
const color = getCheckStatusColor({ status: bucket.status, theme });
return (
<View key={bucket.status} style={checksSummaryStyles.item}>
<Text style={[checksSummaryStyles.count, { color }]}>{bucket.count}</Text>
<CheckStatusIndicator status={bucket.status} size={12} />
</View>
);
})}
</>
);
}
const checksSummaryStyles = StyleSheet.create((theme) => ({
item: {
flexDirection: "row",
alignItems: "center",
gap: 3,
},
count: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
},
}));
function WorkspaceHoverCardContent({
workspace,
prHint,
@@ -325,20 +241,29 @@ function WorkspaceHoverCardContent({
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const 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 ? (
<View style={styles.cardMetaRow}>
{workspace.diffStat ? (
<View style={styles.diffStatRow}>
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
</View>
<DiffStat
additions={workspace.diffStat.additions}
deletions={workspace.diffStat.deletions}
/>
) : null}
{prHint ? <PrBadge hint={prHint} /> : null}
</View>
) : null}
{workspace.services.length > 0 ? (
{workspace.scripts.length > 0 ? (
<>
<View style={styles.separator} />
<Text style={styles.sectionLabel}>Services</Text>
<View style={styles.sectionList} testID="hover-card-service-list">
{workspace.services.map((service) => {
const isRunning = service.lifecycle === "running";
const isLinkable = isRunning && !!service.url;
<View style={styles.sectionLabelRow}>
<Terminal size={12} color={theme.colors.foregroundMuted} />
<Text style={styles.sectionLabel}>Scripts</Text>
</View>
<View style={styles.sectionList} testID="hover-card-script-list">
{workspace.scripts.map((script) => {
const isRunning = script.lifecycle === "running";
const isLinkable = isRunning && !!script.url;
return (
<Pressable
key={service.hostname}
key={script.hostname}
accessibilityRole={isLinkable ? "link" : undefined}
accessibilityLabel={`${service.serviceName} service — ${isRunning ? getServiceHealthLabel(service.health) : "Stopped"}`}
testID={`hover-card-service-${service.serviceName}`}
accessibilityLabel={`${script.scriptName} script — ${isRunning ? getScriptHealthLabel(script.health) : "Stopped"}`}
testID={`hover-card-script-${script.scriptName}`}
style={({ hovered }) => [
styles.listRow,
hovered && isLinkable && styles.listRowHovered,
]}
onPress={isLinkable ? () => void openExternalUrl(service.url!) : undefined}
onPress={isLinkable ? () => void openExternalUrl(script.url!) : undefined}
disabled={!isLinkable}
>
{({ hovered }) => (
<>
<View
testID={`hover-card-service-health-${service.serviceName}`}
testID={`hover-card-script-health-${script.scriptName}`}
style={[
styles.statusDot,
{
backgroundColor: isRunning
? getServiceHealthColor({ health: service.health, theme })
? getScriptHealthColor({ health: script.health, theme })
: theme.colors.foregroundMuted,
},
]}
@@ -465,44 +393,68 @@ function WorkspaceHoverCardContent({
]}
numberOfLines={1}
>
{service.serviceName}
{script.scriptName}
</Text>
{isRunning && service.url ? (
{isRunning && script.url ? (
<Text style={styles.listRowSecondary} numberOfLines={1}>
{service.url.replace(/^https?:\/\//, "")}
{script.url.replace(/^https?:\/\//, "")}
</Text>
) : (
<View style={styles.listRowSpacer} />
)}
{isRunning ? (
service.url ? (
script.url && hovered ? (
<View
style={[
styles.externalLinkOverlay,
{
backgroundImage: `linear-gradient(to right, transparent, ${theme.colors.surface2} 40%)`,
},
]}
>
<ExternalLink
size={12}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
color={theme.colors.foreground}
/>
</View>
) : null
) : (
<Pressable
accessibilityRole="button"
accessibilityLabel={`Start ${service.serviceName} service`}
testID={`hover-card-service-start-${service.serviceName}`}
accessibilityLabel={`Run ${script.scriptName} script`}
testID={`hover-card-script-start-${script.scriptName}`}
hitSlop={4}
disabled={startServiceMutation.isPending}
disabled={startScriptMutation.isPending}
onPress={(event) => {
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 ? (
<LoaderCircle size={12} color={theme.colors.foregroundMuted} />
) : (
<>
<Play
size={12}
size={10}
color={actionHovered ? theme.colors.foreground : theme.colors.foregroundMuted}
fill="transparent"
/>
<Text
style={[
styles.startButtonLabel,
{
color: actionHovered
? theme.colors.foreground
: theme.colors.foregroundMuted,
},
]}
>
Run
</Text>
</>
)
}
</Pressable>
@@ -525,18 +477,53 @@ function WorkspaceHoverCardContent({
]}
onPress={() => void openExternalUrl(`${prHint.url}/checks`)}
>
{({ hovered }) => (
{({ 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 (
<>
<GitHubIcon size={12} color={theme.colors.foregroundMuted} />
<Text style={styles.checksSummaryLabel}>Checks</Text>
<View style={styles.checksSummaryCounts}>
<ChecksSummary checks={prHint.checks!} />
<View style={[styles.checksDot, { backgroundColor: badgeColor }]} />
<Text style={[styles.checksStatusText, { color: badgeColor }]}>
{badgeLabel}
</Text>
</View>
{hovered ? (
<View
style={[
styles.externalLinkOverlay,
{
backgroundImage: `linear-gradient(to right, transparent, ${theme.colors.surface2} 40%)`,
},
]}
>
<ExternalLink
size={12}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
color={theme.colors.foreground}
/>
</View>
) : null}
</>
)}
);
}}
</Pressable>
</>
) : 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,
},
}));
@@ -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<string, WorkspaceDescriptor>([
["/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<string, WorkspaceDescriptor>([
["/repo/main", workspace({ id: "/repo/main", services: [] })],
["/repo/main", workspace({ id: "/repo/main", scripts: [] })],
]);
const next = patchWorkspaceServices(current, {
const next = patchWorkspaceScripts(current, {
workspaceId: "/repo/missing",
services: [runningService],
scripts: [runningScript],
});
expect(next).toBe(current);
expect(next.get("/repo/main")?.services).toEqual([]);
expect(next.get("/repo/main")?.scripts).toEqual([]);
});
});
@@ -53,7 +53,7 @@ import { resolveProjectPlacement } from "@/utils/project-placement";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import 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();
@@ -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<string, WorkspaceDescriptor>,
update: ServiceStatusUpdateMessage["payload"],
update: ScriptStatusUpdateMessage["payload"],
): Map<string, WorkspaceDescriptor> {
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;
}
@@ -81,7 +81,7 @@ describe("openProjectDirectly", () => {
status: "done" as const,
activityAt: null,
diffStat: null,
services: [],
scripts: [],
},
})),
},
@@ -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", () => {
@@ -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 ?? [],
});
}
@@ -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 ? (
<WorkspaceScriptsButton
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
scripts={workspaceDescriptor.scripts}
/>
) : null}
{!isMobile && isGitCheckout ? (
<>
{workspaceDirectory ? (
@@ -2142,14 +2151,10 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
color={iconColor}
/>
{workspaceDescriptor?.diffStat ? (
<View style={styles.diffStatRow}>
<Text style={styles.diffStatAdditions}>
+{workspaceDescriptor.diffStat.additions}
</Text>
<Text style={styles.diffStatDeletions}>
-{workspaceDescriptor.diffStat.deletions}
</Text>
</View>
<DiffStat
additions={workspaceDescriptor.diffStat.additions}
deletions={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",
@@ -0,0 +1,323 @@
import { type ReactElement } from "react";
import { Pressable, Text, View } from "react-native";
import { useMutation } from "@tanstack/react-query";
import { ChevronDown, ExternalLink, LoaderCircle, Play, Terminal } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/contexts/toast-context";
import { openExternalUrl } from "@/utils/open-external-url";
type Script = WorkspaceDescriptor["scripts"][number];
function getScriptHealthColor(
health: Script["health"],
theme: ReturnType<typeof useUnistyles>["theme"],
): string {
if (health === "healthy") {
return theme.colors.palette.green[500];
}
if (health === "unhealthy") {
return theme.colors.palette.red[500];
}
return theme.colors.foregroundMuted;
}
interface WorkspaceScriptsButtonProps {
serverId: string;
workspaceId: string;
scripts: WorkspaceDescriptor["scripts"];
}
export function WorkspaceScriptsButton({
serverId,
workspaceId,
scripts,
}: WorkspaceScriptsButtonProps): ReactElement | null {
const { theme } = useUnistyles();
const toast = useToast();
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const startScriptMutation = useMutation({
mutationFn: async (scriptName: string) => {
if (!client) {
throw new Error("Daemon client not available");
}
const result = await client.startWorkspaceScript(workspaceId, scriptName);
if (result.error) {
throw new Error(result.error);
}
return result;
},
onError: (error, scriptName) => {
toast.show(
error instanceof Error ? error.message : `Failed to start ${scriptName}`,
{ variant: "error" },
);
},
});
if (scripts.length === 0) {
return null;
}
const hasAnyRunning = scripts.some((s) => s.lifecycle === "running");
return (
<View style={styles.row}>
<View style={styles.splitButton}>
<DropdownMenu>
<DropdownMenuTrigger
testID="workspace-scripts-button"
style={({ hovered, pressed, open }) => [
styles.splitButtonPrimary,
(hovered || pressed || open) && styles.splitButtonPrimaryHovered,
]}
accessibilityRole="button"
accessibilityLabel="Workspace scripts"
>
<View style={styles.splitButtonContent}>
<Terminal size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.splitButtonText}>Scripts</Text>
{hasAnyRunning ? (
<View
style={[
styles.runningDot,
{ backgroundColor: theme.colors.palette.green[500] },
]}
/>
) : null}
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</View>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
minWidth={200}
maxWidth={280}
testID="workspace-scripts-menu"
>
<View style={styles.scriptList}>
{scripts.map((script) => {
const isRunning = script.lifecycle === "running";
const isLinkable = isRunning && !!script.url;
return (
<Pressable
key={script.scriptName}
testID={`workspace-scripts-item-${script.scriptName}`}
accessibilityRole={isLinkable ? "link" : undefined}
accessibilityLabel={`${script.scriptName} script`}
style={({ hovered }) => [
styles.scriptRow,
hovered && isLinkable && styles.scriptRowHovered,
]}
onPress={isLinkable ? () => void openExternalUrl(script.url!) : undefined}
disabled={!isLinkable}
>
{({ hovered }) => (
<>
<View
style={[
styles.statusDot,
{
backgroundColor: isRunning
? getScriptHealthColor(script.health, theme)
: theme.colors.foregroundMuted,
},
]}
/>
<Text
style={[
styles.scriptName,
{
color: isRunning
? theme.colors.foreground
: theme.colors.foregroundMuted,
},
]}
numberOfLines={1}
>
{script.scriptName}
</Text>
{isRunning && script.url ? (
<Text style={styles.scriptUrl} numberOfLines={1}>
{script.url.replace(/^https?:\/\//, "")}
</Text>
) : (
<View style={styles.spacer} />
)}
{isRunning ? (
script.url && hovered ? (
<View
style={[
styles.externalLinkOverlay,
{
backgroundImage: `linear-gradient(to right, transparent, ${theme.colors.surface2} 40%)`,
},
]}
>
<ExternalLink size={12} color={theme.colors.foreground} />
</View>
) : null
) : (
<Pressable
accessibilityRole="button"
accessibilityLabel={`Run ${script.scriptName} script`}
testID={`workspace-scripts-start-${script.scriptName}`}
hitSlop={4}
disabled={startScriptMutation.isPending}
onPress={(event) => {
event.stopPropagation();
startScriptMutation.mutate(script.scriptName);
}}
style={styles.startButton}
>
{({ hovered: actionHovered }) =>
startScriptMutation.isPending &&
startScriptMutation.variables === script.scriptName ? (
<LoaderCircle size={12} color={theme.colors.foregroundMuted} />
) : (
<>
<Play
size={10}
color={
actionHovered
? theme.colors.foreground
: theme.colors.foregroundMuted
}
fill="transparent"
/>
<Text
style={[
styles.startButtonLabel,
{
color: actionHovered
? theme.colors.foreground
: theme.colors.foregroundMuted,
},
]}
>
Run
</Text>
</>
)
}
</Pressable>
)}
</>
)}
</Pressable>
);
})}
</View>
</DropdownMenuContent>
</DropdownMenu>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flexShrink: 0,
},
splitButton: {
flexDirection: "row",
alignItems: "stretch",
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
overflow: "hidden",
},
splitButtonPrimary: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
justifyContent: "center",
},
splitButtonPrimaryHovered: {
backgroundColor: theme.colors.surface2,
},
splitButtonText: {
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.5,
color: theme.colors.foreground,
fontWeight: theme.fontWeight.normal,
},
splitButtonContent: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[1.5],
},
runningDot: {
width: 6,
height: 6,
borderRadius: 3,
},
scriptList: {
paddingVertical: theme.spacing[1],
},
scriptRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: 6,
minHeight: 28,
},
scriptRowHovered: {
backgroundColor: theme.colors.surface2,
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
marginLeft: 2,
flexShrink: 0,
},
scriptName: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
lineHeight: 18,
flexShrink: 0,
},
scriptUrl: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
lineHeight: 18,
flex: 1,
minWidth: 0,
textAlign: "right",
},
spacer: {
flex: 1,
minWidth: 0,
},
externalLinkOverlay: {
position: "absolute",
right: 0,
top: 0,
bottom: 0,
paddingLeft: theme.spacing[4],
paddingRight: theme.spacing[3],
alignItems: "center",
justifyContent: "center",
},
startButton: {
flexDirection: "row",
alignItems: "center",
gap: 3,
},
startButtonLabel: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
},
}));
@@ -20,7 +20,7 @@ describe("workspace source of truth consumption", () => {
status: "running",
activityAt: new Date("2026-03-01T00:00:00.000Z"),
diffStat: null,
services: [],
scripts: [],
};
const header = resolveWorkspaceHeader({ workspace });
+17 -17
View File
@@ -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",
+6 -3
View File
@@ -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<SessionStore>()(
if (existing === workspace) {
continue;
}
next.set(workspace.id, workspace);
next.set(
workspace.id,
mergeWorkspaceSnapshotWithExisting({ incoming: workspace, existing }),
);
changed = true;
}
if (!changed) {
+1
View File
@@ -258,6 +258,7 @@ const commonTheme = {
spacing: {
0: 0,
1: 4,
1.5: 6,
2: 8,
3: 12,
4: 16,
@@ -19,8 +19,8 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
activityAt: null,
statusBucket: "done",
diffStat: null,
services: [],
hasRunningServices: false,
scripts: [],
hasRunningScripts: false,
...overrides,
};
}
@@ -17,8 +17,8 @@ function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry {
activityAt: null,
statusBucket: "done",
diffStat: null,
services: [],
hasRunningServices: false,
scripts: [],
hasRunningScripts: false,
};
}
@@ -20,7 +20,7 @@ function workspace(
status: input.status ?? "running",
activityAt: input.activityAt ?? null,
diffStat: input.diffStat ?? null,
services: input.services ?? [],
scripts: input.scripts ?? [],
};
}
@@ -23,7 +23,7 @@ function createWorkspace(
status: input.status ?? "running",
activityAt: input.activityAt ?? null,
diffStat: input.diffStat ?? null,
services: input.services ?? [],
scripts: input.scripts ?? [],
};
}
+6 -6
View File
@@ -1331,19 +1331,19 @@ export class DaemonClient {
});
}
async startWorkspaceService(
async startWorkspaceScript(
workspaceId: string,
serviceName: string,
scriptName: string,
requestId?: string,
): Promise<Extract<SessionOutboundMessage, { type: "start_workspace_service_response" }>["payload"]> {
): Promise<Extract<SessionOutboundMessage, { type: "start_workspace_script_response" }>["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,
});
}
@@ -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,
});
@@ -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,
});
+34 -34
View File
@@ -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<void>;
stop(): Promise<void>;
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,
@@ -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<void> {
}
}
describe("ServiceHealthMonitor", () => {
describe("ScriptHealthMonitor", () => {
const servers = new Set<net.Server>();
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",
@@ -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,
@@ -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<void> {
}
// ---------------------------------------------------------------------------
// 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,
});
@@ -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<string, ServiceRouteEntry>();
export class ScriptRouteStore {
private routes = new Map<string, ScriptRouteEntry>();
private workspaceHostnames = new Map<string, Set<string>>();
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();
});
@@ -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();
});
});
@@ -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),
);
};
}
@@ -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<string, unknown>;
}): { 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,
@@ -0,0 +1,135 @@
import type {
ScriptStatusUpdateMessage,
SessionOutboundMessage,
WorkspaceScriptPayload,
} from "../shared/messages.js";
import { buildScriptHostname } from "../utils/script-hostname.js";
import { getScriptConfigs } from "../utils/worktree.js";
import { readGitCommand } from "./workspace-git-metadata.js";
import type { ScriptHealthEntry } from "./script-health-monitor.js";
import type { ScriptRouteEntry, ScriptRouteStore } from "./script-proxy.js";
type SessionEmitter = {
emit(message: SessionOutboundMessage): void;
};
function resolveDaemonPort(daemonPort: number | null | (() => number | null)): number | null {
if (typeof daemonPort === "function") {
return daemonPort();
}
return daemonPort;
}
function toServiceUrl(hostname: string, daemonPort: number | null): string | null {
if (daemonPort === null) {
return null;
}
return `http://${hostname}:${daemonPort}`;
}
type ConfiguredWorkspaceScript = {
scriptName: string;
hostname: string;
port: number | null;
};
function resolveWorkspaceBranchName(workspaceDirectory: string): string | null {
return readGitCommand(workspaceDirectory, "git symbolic-ref --short HEAD");
}
function listConfiguredWorkspaceScripts(workspaceDirectory: string): ConfiguredWorkspaceScript[] {
const branchName = resolveWorkspaceBranchName(workspaceDirectory);
const scriptConfigs = getScriptConfigs(workspaceDirectory);
return Array.from(scriptConfigs.entries()).map(([scriptName, config]) => ({
scriptName,
hostname: buildScriptHostname(branchName, scriptName),
port: config.port ?? null,
}));
}
function mergeWorkspaceScriptDefinitions(
workspaceDirectory: string,
routeStore: ScriptRouteStore,
): Array<ConfiguredWorkspaceScript | ScriptRouteEntry> {
const merged = new Map<string, ConfiguredWorkspaceScript | ScriptRouteEntry>();
for (const script of listConfiguredWorkspaceScripts(workspaceDirectory)) {
merged.set(script.hostname, script);
}
for (const route of routeStore.listRoutesForWorkspace(workspaceDirectory)) {
merged.set(route.hostname, route);
}
return Array.from(merged.values()).sort((left, right) =>
left.scriptName.localeCompare(right.scriptName, undefined, {
numeric: true,
sensitivity: "base",
}),
);
}
export function buildWorkspaceScriptPayloads(
routeStore: ScriptRouteStore,
workspaceDirectory: string,
daemonPort: number | null,
resolveHealth?: (hostname: string) => "healthy" | "unhealthy" | null,
): WorkspaceScriptPayload[] {
return mergeWorkspaceScriptDefinitions(workspaceDirectory, routeStore).map((script) => {
const route = routeStore.getRouteEntry(script.hostname);
return {
scriptName: script.scriptName,
hostname: script.hostname,
port: route?.port ?? script.port,
url: toServiceUrl(script.hostname, daemonPort),
lifecycle: route ? "running" : "stopped",
health: resolveHealth?.(script.hostname) ?? null,
};
});
}
function buildScriptStatusUpdateMessage(params: {
workspaceId: string;
scripts: WorkspaceScriptPayload[];
}): ScriptStatusUpdateMessage {
return {
type: "script_status_update",
payload: {
workspaceId: params.workspaceId,
scripts: params.scripts,
},
};
}
export function createScriptStatusEmitter({
sessions,
routeStore,
daemonPort,
}: {
sessions: () => SessionEmitter[];
routeStore: ScriptRouteStore;
daemonPort: number | null | (() => number | null);
}): (workspaceId: string, scripts: ScriptHealthEntry[]) => void {
return (workspaceId, scripts) => {
const resolvedDaemonPort = resolveDaemonPort(daemonPort);
const scriptHealthByHostname = new Map(
scripts.map((script) => [script.hostname, script.health] as const),
);
const projected = buildWorkspaceScriptPayloads(
routeStore,
workspaceId,
resolvedDaemonPort,
(hostname) => scriptHealthByHostname.get(hostname) ?? null,
);
const message = buildScriptStatusUpdateMessage({
workspaceId,
scripts: projected,
});
for (const session of sessions()) {
session.emit(message);
}
};
}
@@ -1,135 +0,0 @@
import type {
ServiceStatusUpdateMessage,
SessionOutboundMessage,
WorkspaceServicePayload,
} from "../shared/messages.js";
import { buildServiceHostname } from "../utils/service-hostname.js";
import { getServiceConfigs } from "../utils/worktree.js";
import { readGitCommand } from "./workspace-git-metadata.js";
import type { ServiceHealthEntry } from "./service-health-monitor.js";
import type { ServiceRouteEntry, ServiceRouteStore } from "./service-proxy.js";
type SessionEmitter = {
emit(message: SessionOutboundMessage): void;
};
function resolveDaemonPort(daemonPort: number | null | (() => number | null)): number | null {
if (typeof daemonPort === "function") {
return daemonPort();
}
return daemonPort;
}
function toServiceUrl(hostname: string, daemonPort: number | null): string | null {
if (daemonPort === null) {
return null;
}
return `http://${hostname}:${daemonPort}`;
}
type ConfiguredWorkspaceService = {
serviceName: string;
hostname: string;
port: number | null;
};
function resolveWorkspaceBranchName(workspaceDirectory: string): string | null {
return readGitCommand(workspaceDirectory, "git symbolic-ref --short HEAD");
}
function listConfiguredWorkspaceServices(workspaceDirectory: string): ConfiguredWorkspaceService[] {
const branchName = resolveWorkspaceBranchName(workspaceDirectory);
const serviceConfigs = getServiceConfigs(workspaceDirectory);
return Array.from(serviceConfigs.entries()).map(([serviceName, config]) => ({
serviceName,
hostname: buildServiceHostname(branchName, serviceName),
port: config.port ?? null,
}));
}
function mergeWorkspaceServiceDefinitions(
workspaceDirectory: string,
routeStore: ServiceRouteStore,
): Array<ConfiguredWorkspaceService | ServiceRouteEntry> {
const merged = new Map<string, ConfiguredWorkspaceService | ServiceRouteEntry>();
for (const service of listConfiguredWorkspaceServices(workspaceDirectory)) {
merged.set(service.hostname, service);
}
for (const route of routeStore.listRoutesForWorkspace(workspaceDirectory)) {
merged.set(route.hostname, route);
}
return Array.from(merged.values()).sort((left, right) =>
left.serviceName.localeCompare(right.serviceName, undefined, {
numeric: true,
sensitivity: "base",
}),
);
}
export function buildWorkspaceServicePayloads(
routeStore: ServiceRouteStore,
workspaceDirectory: string,
daemonPort: number | null,
resolveHealth?: (hostname: string) => "healthy" | "unhealthy" | null,
): WorkspaceServicePayload[] {
return mergeWorkspaceServiceDefinitions(workspaceDirectory, routeStore).map((service) => {
const route = routeStore.getRouteEntry(service.hostname);
return {
serviceName: service.serviceName,
hostname: service.hostname,
port: route?.port ?? service.port,
url: toServiceUrl(service.hostname, daemonPort),
lifecycle: route ? "running" : "stopped",
health: resolveHealth?.(service.hostname) ?? null,
};
});
}
function buildServiceStatusUpdateMessage(params: {
workspaceId: string;
services: WorkspaceServicePayload[];
}): ServiceStatusUpdateMessage {
return {
type: "service_status_update",
payload: {
workspaceId: params.workspaceId,
services: params.services,
},
};
}
export function createServiceStatusEmitter({
sessions,
routeStore,
daemonPort,
}: {
sessions: () => SessionEmitter[];
routeStore: ServiceRouteStore;
daemonPort: number | null | (() => number | null);
}): (workspaceId: string, services: ServiceHealthEntry[]) => void {
return (workspaceId, services) => {
const resolvedDaemonPort = resolveDaemonPort(daemonPort);
const serviceHealthByHostname = new Map(
services.map((service) => [service.hostname, service.health] as const),
);
const projected = buildWorkspaceServicePayloads(
routeStore,
workspaceId,
resolvedDaemonPort,
(hostname) => serviceHealthByHostname.get(hostname) ?? null,
);
const message = buildServiceStatusUpdateMessage({
workspaceId,
services: projected,
});
for (const session of sessions()) {
session.emit(message);
}
};
}
+47 -45
View File
@@ -19,7 +19,7 @@ import {
type SubscribeTerminalsRequest,
type 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<TextToSpeechProvider | null>;
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<TurnDetectionProvider | null>;
@@ -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<string>();
@@ -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<void> {
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,
@@ -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,
@@ -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<string, string> }> = [];
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();
});
});
@@ -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<boolean>;
emitLiveTimelineItem?: (item: AgentTimelineItem) => Promise<boolean>;
@@ -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<WorktreeServiceResult> {
export async function spawnWorkspaceScript(
options: SpawnWorkspaceScriptOptions,
): Promise<WorktreeScriptResult> {
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<string, string> = {
@@ -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<WorktreeServiceResult[]> {
}): Promise<WorktreeScriptResult[]> {
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");
}
}
@@ -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<string, unknown>();
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<string, unknown>();
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<string, unknown>();
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<string, unknown>();
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",
@@ -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<void>;
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 {
+28 -27
View File
@@ -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<typeof ProjectCheckoutLitePaylo
export type ProjectPlacementPayload = z.infer<typeof ProjectPlacementPayloadSchema>;
export type WorkspaceStateBucket = z.infer<typeof WorkspaceStateBucketSchema>;
export type WorkspaceDescriptorPayload = z.infer<typeof WorkspaceDescriptorPayloadSchema>;
export type WorkspaceServiceLifecycle = z.infer<typeof WorkspaceServiceLifecycleSchema>;
export type WorkspaceServiceHealth = z.infer<typeof WorkspaceServiceHealthSchema>;
export type WorkspaceServicePayload = z.infer<typeof WorkspaceServicePayloadSchema>;
export type WorkspaceScriptLifecycle = z.infer<typeof WorkspaceScriptLifecycleSchema>;
export type WorkspaceScriptHealth = z.infer<typeof WorkspaceScriptHealthSchema>;
export type WorkspaceScriptPayload = z.infer<typeof WorkspaceScriptPayloadSchema>;
export type EditorTargetId = z.infer<typeof EditorTargetIdSchema>;
export type EditorTargetDescriptorPayload = z.infer<typeof EditorTargetDescriptorPayloadSchema>;
export type FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessageSchema>;
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>;
export type ServiceStatusUpdateMessage = z.infer<typeof ServiceStatusUpdateMessageSchema>;
export type ScriptStatusUpdateMessage = z.infer<typeof ScriptStatusUpdateMessageSchema>;
export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessageSchema>;
export type 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<typeof UnsubscribeTerminalsReq
export type TerminalsChanged = z.infer<typeof TerminalsChangedSchema>;
export type CreateTerminalRequest = z.infer<typeof CreateTerminalRequestSchema>;
export type CreateTerminalResponse = z.infer<typeof CreateTerminalResponseSchema>;
export type StartWorkspaceServiceRequest = z.infer<typeof StartWorkspaceServiceRequestSchema>;
export type StartWorkspaceServiceResponse = z.infer<typeof StartWorkspaceServiceResponseMessageSchema>;
export type StartWorkspaceScriptRequest = z.infer<typeof StartWorkspaceScriptRequestSchema>;
export type StartWorkspaceScriptResponse = z.infer<typeof StartWorkspaceScriptResponseMessageSchema>;
export type SubscribeTerminalRequest = z.infer<typeof SubscribeTerminalRequestSchema>;
export type SubscribeTerminalResponse = z.infer<typeof SubscribeTerminalResponseSchema>;
export type UnsubscribeTerminalRequest = z.infer<typeof UnsubscribeTerminalRequestSchema>;
@@ -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");
});
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { buildScriptHostname } from "./script-hostname.js";
describe("buildScriptHostname", () => {
it("slugifies script names with spaces on default branches", () => {
expect(buildScriptHostname(null, "npm run dev")).toBe("npm-run-dev.localhost");
});
it("slugifies script names with special characters", () => {
expect(buildScriptHostname(null, "Web/API @ Dev")).toBe("web-api-dev.localhost");
});
it("omits the branch prefix for main and master", () => {
expect(buildScriptHostname("main", "npm run dev")).toBe("npm-run-dev.localhost");
expect(buildScriptHostname("master", "npm run dev")).toBe("npm-run-dev.localhost");
});
it("adds a slugified branch prefix for non-default branches", () => {
expect(buildScriptHostname("feature/cool stuff", "api")).toBe(
"feature-cool-stuff.api.localhost",
);
});
it("slugifies both the branch name and script name together", () => {
expect(buildScriptHostname("feat/add auth", "npm run dev")).toBe(
"feat-add-auth.npm-run-dev.localhost",
);
});
});
@@ -1,7 +1,7 @@
import { slugify } from "./worktree.js";
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";
@@ -1,29 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildServiceHostname } from "./service-hostname.js";
describe("buildServiceHostname", () => {
it("slugifies service names with spaces on default branches", () => {
expect(buildServiceHostname(null, "npm run dev")).toBe("npm-run-dev.localhost");
});
it("slugifies service names with special characters", () => {
expect(buildServiceHostname(null, "Web/API @ Dev")).toBe("web-api-dev.localhost");
});
it("omits the branch prefix for main and master", () => {
expect(buildServiceHostname("main", "npm run dev")).toBe("npm-run-dev.localhost");
expect(buildServiceHostname("master", "npm run dev")).toBe("npm-run-dev.localhost");
});
it("adds a slugified branch prefix for non-default branches", () => {
expect(buildServiceHostname("feature/cool stuff", "api")).toBe(
"feature-cool-stuff.api.localhost",
);
});
it("slugifies both the branch name and service name together", () => {
expect(buildServiceHostname("feat/add auth", "npm run dev")).toBe(
"feat-add-auth.npm-run-dev.localhost",
);
});
});
+10 -10
View File
@@ -24,7 +24,7 @@ interface PaseoConfig {
teardown?: string[];
terminals?: WorktreeTerminalConfig[];
};
services?: Record<string, ServiceConfig>;
scripts?: Record<string, ScriptConfig>;
}
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<string, ServiceConfig> {
export function getScriptConfigs(repoRoot: string): Map<string, ScriptConfig> {
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<string, ServiceConfig>();
for (const [name, entry] of Object.entries(services)) {
const result = new Map<string, ScriptConfig>();
for (const [name, entry] of Object.entries(scripts)) {
if (!entry || typeof entry !== "object") {
continue;
}
@@ -227,13 +227,13 @@ export function getServiceConfigs(repoRoot: string): Map<string, ServiceConfig>
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;
+8 -6
View File
@@ -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"
}
}
}