refactor: rename services to scripts and extract reusable components
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
}));
|
||||
@@ -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();
|
||||
|
||||
+4
-4
@@ -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 });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
+51
-51
@@ -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",
|
||||
+20
-20
@@ -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,
|
||||
+43
-43
@@ -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 +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();
|
||||
});
|
||||
+41
-41
@@ -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();
|
||||
});
|
||||
});
|
||||
+14
-14
@@ -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),
|
||||
);
|
||||
};
|
||||
}
|
||||
+38
-38
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user