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