feat: add service start capability with health monitoring

This commit is contained in:
Mohamed Boudra
2026-04-06 20:16:32 +07:00
parent 7173342bcb
commit 7b19dd736f
28 changed files with 995 additions and 417 deletions
@@ -50,6 +50,28 @@ async function expectServiceRunning(page: Page, serviceName: string): Promise<vo
).toHaveAttribute("aria-label", "Running", { timeout: 10_000 }); ).toHaveAttribute("aria-label", "Running", { timeout: 10_000 });
} }
/** Asserts the service lifecycle is stopped. */
async function expectServiceStopped(page: Page, serviceName: string): Promise<void> {
const card = page.getByTestId("workspace-hover-card");
await expect(
card.getByTestId(`hover-card-service-status-${serviceName}`),
).toHaveAttribute("aria-label", "Stopped", { timeout: 10_000 });
}
/** Asserts the service health label shown in the hover card. */
async function expectServiceHealth(
page: Page,
serviceName: string,
health: "Healthy" | "Unhealthy" | "Unknown",
): Promise<void> {
const card = page.getByTestId("workspace-hover-card");
await expect(card.getByTestId(`hover-card-service-health-${serviceName}`)).toHaveAttribute(
"aria-label",
health,
{ timeout: 10_000 },
);
}
/** Asserts the hover card contains the workspace name. */ /** Asserts the hover card contains the workspace name. */
async function expectWorkspaceNameInCard(page: Page, name: string): Promise<void> { async function expectWorkspaceNameInCard(page: Page, name: string): Promise<void> {
const card = page.getByTestId("workspace-hover-card"); const card = page.getByTestId("workspace-hover-card");
@@ -138,4 +160,49 @@ test.describe("Workspace hover card", () => {
await repo.cleanup(); await repo.cleanup();
} }
}); });
test("shows stopped services and starts them from the hover card", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("hovercard-start-", {
paseoConfig: {
services: {
web: {
command:
"node -e \"const http = require('http'); const s = http.createServer((q,r) => r.end('ok')); s.listen(process.env.PORT || 3000, '127.0.0.1', () => console.log('listening on ' + s.address().port))\"",
},
},
},
});
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const workspace = await client.openProject(repo.path);
if (!workspace.workspace || workspace.error) {
throw new Error(workspace.error ?? `Failed to open project ${repo.path}`);
}
await openHomeWithProject(page, repo.path);
const wsRow = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspace.workspace.id}`);
await expect(wsRow).toBeVisible({ timeout: 30_000 });
await expectHoverCard(page, workspace.workspace.name);
await expectWorkspaceNameInCard(page, workspace.workspace.name);
await expectServiceInCard(page, "web");
await expectServiceStopped(page, "web");
await expectServiceHealth(page, "web", "Unknown");
const card = page.getByTestId("workspace-hover-card");
const startButton = card.getByTestId("hover-card-service-start-web");
await expect(startButton).toBeVisible({ timeout: 10_000 });
await startButton.click();
await expectServiceRunning(page, "web");
await expectServiceHealth(page, "web", "Healthy");
await expect(card.getByRole("link", { name: "web service" })).toBeVisible({ timeout: 10_000 });
await expect(startButton).not.toBeVisible({ timeout: 10_000 });
} finally {
await client.close();
await repo.cleanup();
}
});
}); });
@@ -262,7 +262,7 @@ function WorkspaceStatusIndicator({
} }
const KindIcon = const KindIcon =
workspaceKind === "checkout" workspaceKind === "local_checkout"
? Monitor ? Monitor
: workspaceKind === "worktree" : workspaceKind === "worktree"
? FolderGit2 ? FolderGit2
@@ -6,15 +6,18 @@ import {
type PropsWithChildren, type PropsWithChildren,
type ReactElement, type ReactElement,
} from "react"; } from "react";
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, GitPullRequest, Minus, X } from "lucide-react-native"; import { Check, ExternalLink, GitPullRequest, LoaderCircle, Minus, Play, X } from "lucide-react-native";
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";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { PrHint } from "@/hooks/use-checkout-pr-status-query"; import type { PrHint } from "@/hooks/use-checkout-pr-status-query";
import { useToast } from "@/contexts/toast-context";
import { useSessionStore } from "@/stores/session-store";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
interface Rect { interface Rect {
@@ -194,6 +197,31 @@ const GITHUB_PR_STATE_LABELS: Record<PrHint["state"], string> = {
closed: "Closed", closed: "Closed",
}; };
function getServiceHealthColor(input: {
health: SidebarWorkspaceEntry["services"][number]["health"];
theme: ReturnType<typeof useUnistyles>["theme"];
}): string {
if (input.health === "healthy") {
return input.theme.colors.palette.green[500];
}
if (input.health === "unhealthy") {
return input.theme.colors.palette.red[500];
}
return input.theme.colors.foregroundMuted;
}
function getServiceHealthLabel(
health: SidebarWorkspaceEntry["services"][number]["health"],
): "Healthy" | "Unhealthy" | "Unknown" {
if (health === "healthy") {
return "Healthy";
}
if (health === "unhealthy") {
return "Unhealthy";
}
return "Unknown";
}
export function CheckStatusIndicator({ export function CheckStatusIndicator({
status, status,
@@ -287,10 +315,30 @@ function WorkspaceHoverCardContent({
onContentLeave: () => void; onContentLeave: () => void;
}): ReactElement | null { }): ReactElement | null {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const toast = useToast();
const client = useSessionStore((state) => state.sessions[workspace.serverId]?.client ?? null);
const bottomSheetInternal = useBottomSheetModalInternal(true); const bottomSheetInternal = useBottomSheetModalInternal(true);
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({
mutationFn: async (serviceName: string) => {
if (!client) {
throw new Error("Daemon client not available");
}
const result = await client.startWorkspaceService(workspace.workspaceId, serviceName);
if (result.error) {
throw new Error(result.error);
}
return result;
},
onError: (error, serviceName) => {
toast.show(
error instanceof Error ? error.message : `Failed to start ${serviceName}`,
{ variant: "error" },
);
},
});
// Measure trigger — same pattern as tooltip.tsx // Measure trigger — same pattern as tooltip.tsx
useEffect(() => { useEffect(() => {
@@ -418,50 +466,95 @@ function WorkspaceHoverCardContent({
{workspace.services.map((service) => ( {workspace.services.map((service) => (
<Pressable <Pressable
key={service.hostname} key={service.hostname}
accessibilityRole="link" accessibilityRole={service.lifecycle === "running" && service.url ? "link" : undefined}
accessibilityLabel={`${service.serviceName} service`} accessibilityLabel={`${service.serviceName} service`}
testID={`hover-card-service-${service.serviceName}`} testID={`hover-card-service-${service.serviceName}`}
style={({ hovered }) => [ style={({ hovered }) => [
styles.serviceRow, styles.serviceRow,
hovered && styles.serviceRowHovered, hovered &&
service.lifecycle === "running" &&
service.url &&
styles.serviceRowHovered,
]} ]}
onPress={() => { onPress={() => {
if (service.url) { if (service.lifecycle === "running" && service.url) {
void openExternalUrl(service.url); void openExternalUrl(service.url);
} }
}} }}
disabled={!service.url}
> >
<View
testID={`hover-card-service-status-${service.serviceName}`}
accessibilityLabel={service.status === "running" ? "Running" : "Stopped"}
style={[
styles.statusDot,
{
backgroundColor:
service.status === "running"
? theme.colors.palette.green[500]
: theme.colors.foregroundMuted,
},
]}
/>
<Text <Text
style={[ style={[
styles.serviceName, styles.serviceName,
{ color: service.status === "running" ? theme.colors.foreground : theme.colors.foregroundMuted }, {
color:
service.lifecycle === "running"
? theme.colors.foreground
: theme.colors.foregroundMuted,
},
]} ]}
numberOfLines={1} numberOfLines={1}
> >
{service.serviceName} {service.serviceName}
</Text> </Text>
{service.url ? ( <View style={styles.serviceMeta}>
<Text
testID={`hover-card-service-status-${service.serviceName}`}
accessibilityLabel={service.lifecycle === "running" ? "Running" : "Stopped"}
style={styles.serviceLifecycleText}
>
{service.lifecycle === "running" ? "Running" : "Stopped"}
</Text>
<View
testID={`hover-card-service-health-${service.serviceName}`}
accessibilityLabel={getServiceHealthLabel(service.health)}
style={[
styles.statusDot,
{
backgroundColor: getServiceHealthColor({
health: service.health,
theme,
}),
},
]}
/>
<Text style={styles.serviceHealthText}>{getServiceHealthLabel(service.health)}</Text>
</View>
{service.lifecycle === "running" && service.url ? (
<Text style={styles.serviceUrl} numberOfLines={1}> <Text style={styles.serviceUrl} numberOfLines={1}>
{service.url.replace(/^https?:\/\//, "")} {service.url.replace(/^https?:\/\//, "")}
</Text> </Text>
) : null} ) : (
{service.url ? ( <View style={styles.serviceUrlSpacer} />
)}
{service.lifecycle === "running" && service.url ? (
<ExternalLink size={11} color={theme.colors.foregroundMuted} /> <ExternalLink size={11} color={theme.colors.foregroundMuted} />
) : null} ) : (
<Pressable
accessibilityRole="button"
accessibilityLabel={`Start ${service.serviceName} service`}
testID={`hover-card-service-start-${service.serviceName}`}
style={({ hovered, pressed }) => [
styles.startServiceButton,
(hovered || pressed) && styles.startServiceButtonHovered,
]}
disabled={startServiceMutation.isPending}
onPress={(event) => {
event.stopPropagation();
startServiceMutation.mutate(service.serviceName);
}}
>
{startServiceMutation.isPending &&
startServiceMutation.variables === service.serviceName ? (
<LoaderCircle
size={12}
color={theme.colors.foregroundMuted}
style={styles.startServiceSpinner}
/>
) : (
<Play size={12} color={theme.colors.foregroundMuted} fill="transparent" />
)}
</Pressable>
)}
</Pressable> </Pressable>
))} ))}
</View> </View>
@@ -558,12 +651,44 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
flexShrink: 0, flexShrink: 0,
}, },
serviceMeta: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flexShrink: 0,
},
serviceLifecycleText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
serviceHealthText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
serviceUrl: { serviceUrl: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
}, },
serviceUrlSpacer: {
flex: 1,
minWidth: 0,
},
startServiceButton: {
width: 22,
height: 22,
borderRadius: 11,
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.surface2,
},
startServiceButtonHovered: {
backgroundColor: theme.colors.surface3,
},
startServiceSpinner: {
transform: [{ rotate: "0deg" }],
},
sectionLabel: { sectionLabel: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium, fontWeight: theme.fontWeight.medium,
@@ -16,7 +16,6 @@ import { toErrorMessage } from "@/utils/error-messages";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { import {
requireWorkspaceExecutionAuthority, requireWorkspaceExecutionAuthority,
requireWorkspaceRecordId,
} from "@/utils/workspace-execution"; } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import type { ImageAttachment, MessagePayload } from "./message-input"; import type { ImageAttachment, MessagePayload } from "./message-input";
@@ -179,7 +178,7 @@ export function WorkspaceSetupDialog() {
const agent = await connectedClient.createAgent({ const agent = await connectedClient.createAgent({
provider: composerState.selectedProvider, provider: composerState.selectedProvider,
cwd: workspaceDirectory, cwd: workspaceDirectory,
workspaceId: requireWorkspaceRecordId(workspace.id), workspaceId: workspace.id,
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== "" ...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
? { modeId: composerState.selectedMode } ? { modeId: composerState.selectedMode }
: {}), : {}),
@@ -28,7 +28,8 @@ const runningService: WorkspaceServicePayload = {
hostname: "main.web.localhost", hostname: "main.web.localhost",
port: 3000, port: 3000,
url: "http://main.web.localhost:6767", url: "http://main.web.localhost:6767",
status: "running", lifecycle: "running",
health: "healthy",
}; };
describe("patchWorkspaceServices", () => { describe("patchWorkspaceServices", () => {
@@ -50,7 +50,8 @@ const runningService: WorkspaceServicePayload = {
hostname: "main.web.localhost", hostname: "main.web.localhost",
port: 3000, port: 3000,
url: "http://main.web.localhost:6767", url: "http://main.web.localhost:6767",
status: "running", lifecycle: "running",
health: "healthy",
}; };
const stoppedService: WorkspaceServicePayload = { const stoppedService: WorkspaceServicePayload = {
@@ -58,7 +59,8 @@ const stoppedService: WorkspaceServicePayload = {
hostname: "main.api.localhost", hostname: "main.api.localhost",
port: 3001, port: 3001,
url: "http://main.api.localhost:6767", url: "http://main.api.localhost:6767",
status: "stopped", lifecycle: "stopped",
health: null,
}; };
describe("applyStoredOrdering", () => { describe("applyStoredOrdering", () => {
@@ -145,7 +145,7 @@ export function buildSidebarProjectsFromWorkspaces(input: {
statusBucket: workspace.status, statusBucket: workspace.status,
diffStat: workspace.diffStat, diffStat: workspace.diffStat,
services: workspace.services, services: workspace.services,
hasRunningServices: workspace.services.some((service) => service.status === "running"), hasRunningServices: workspace.services.some((service) => service.lifecycle === "running"),
}; };
project.workspaces.push(row); project.workspaces.push(row);
@@ -25,7 +25,6 @@ import { encodeImages } from "@/utils/encode-images";
import { toErrorMessage } from "@/utils/error-messages"; import { toErrorMessage } from "@/utils/error-messages";
import { import {
requireWorkspaceExecutionAuthority, requireWorkspaceExecutionAuthority,
requireWorkspaceRecordId,
} from "@/utils/workspace-execution"; } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import type { ImageAttachment, MessagePayload } from "@/components/message-input"; import type { ImageAttachment, MessagePayload } from "@/components/message-input";
@@ -151,7 +150,7 @@ export function NewWorkspaceScreen({
const agent = await connectedClient.createAgent({ const agent = await connectedClient.createAgent({
provider: composerState.selectedProvider, provider: composerState.selectedProvider,
cwd: workspaceDirectory, cwd: workspaceDirectory,
workspaceId: requireWorkspaceRecordId(workspace.id), workspaceId: workspace.id,
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== "" ...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
? { modeId: composerState.selectedMode } ? { modeId: composerState.selectedMode }
: {}), : {}),
@@ -13,10 +13,7 @@ import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-dr
import { buildDraftStoreKey } from "@/stores/draft-keys"; import { buildDraftStoreKey } from "@/stores/draft-keys";
import { type Agent, useSessionStore } from "@/stores/session-store"; import { type Agent, useSessionStore } from "@/stores/session-store";
import { encodeImages } from "@/utils/encode-images"; import { encodeImages } from "@/utils/encode-images";
import { import { getWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
getWorkspaceExecutionAuthority,
requireWorkspaceRecordId,
} from "@/utils/workspace-execution";
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus"; import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types"; import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
import type { AgentSnapshotPayload } from "@server/shared/messages"; import type { AgentSnapshotPayload } from "@server/shared/messages";
@@ -173,7 +170,7 @@ export function WorkspaceDraftAgentTab({
const imagesData = await encodeImages(images); const imagesData = await encodeImages(images);
const result = await client.createAgent({ const result = await client.createAgent({
config, config,
workspaceId: requireWorkspaceRecordId(workspaceExecutionAuthority.workspaceId), workspaceId: workspaceExecutionAuthority.workspaceId,
...(text ? { initialPrompt: text } : {}), ...(text ? { initialPrompt: text } : {}),
clientMessageId: attempt.clientMessageId, clientMessageId: attempt.clientMessageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
@@ -38,7 +38,8 @@ describe("normalizeWorkspaceDescriptor", () => {
hostname: "main.web.localhost", hostname: "main.web.localhost",
port: 3000, port: 3000,
url: "http://main.web.localhost:6767", url: "http://main.web.localhost:6767",
status: "running" as const, lifecycle: "running" as const,
health: "healthy" as const,
}, },
]; ];
const workspace = normalizeWorkspaceDescriptor({ const workspace = normalizeWorkspaceDescriptor({
@@ -63,7 +64,8 @@ describe("normalizeWorkspaceDescriptor", () => {
hostname: "main.web.localhost", hostname: "main.web.localhost",
port: 3000, port: 3000,
url: "http://main.web.localhost:6767", url: "http://main.web.localhost:6767",
status: "running", lifecycle: "running",
health: "healthy",
}, },
]); ]);
expect(workspace.services).not.toBe(services); expect(workspace.services).not.toBe(services);
@@ -109,7 +111,8 @@ describe("mergeWorkspaces", () => {
hostname: "main.web.localhost", hostname: "main.web.localhost",
port: 3000, port: 3000,
url: "http://main.web.localhost:6767", url: "http://main.web.localhost:6767",
status: "running", lifecycle: "running",
health: "healthy",
}, },
], ],
}), }),
@@ -121,7 +124,8 @@ describe("mergeWorkspaces", () => {
hostname: "main.web.localhost", hostname: "main.web.localhost",
port: 3000, port: 3000,
url: "http://main.web.localhost:6767", url: "http://main.web.localhost:6767",
status: "running", lifecycle: "running",
health: "healthy",
}, },
]); ]);
}); });
@@ -26,7 +26,7 @@ export function resolveWorkspaceArchiveRedirectWorkspaceId(input: {
const rootCheckoutWorkspace = const rootCheckoutWorkspace =
sameProjectWorkspaces.find( sameProjectWorkspaces.find(
(workspace) => (workspace) =>
workspace.workspaceKind === "checkout" && workspace.id !== archivedWorkspace.id, workspace.workspaceKind === "local_checkout" && workspace.id !== archivedWorkspace.id,
) ?? null; ) ?? null;
if (rootCheckoutWorkspace) { if (rootCheckoutWorkspace) {
return rootCheckoutWorkspace.id; return rootCheckoutWorkspace.id;
@@ -289,6 +289,61 @@ describe("DaemonClient", () => {
}); });
}); });
test("sends create_agent_request with string workspace ids", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const createPromise = client.createAgent({
provider: "codex",
cwd: "/tmp/project/.paseo/worktrees/feature-a",
workspaceId: "/tmp/project/.paseo/worktrees/feature-a",
title: "Compat agent",
modeId: "default",
});
expect(mock.sent).toHaveLength(1);
const request = JSON.parse(String(mock.sent[0])) as {
type: "session";
message: {
type: "create_agent_request";
requestId: string;
workspaceId: string;
};
};
expect(request.message).toEqual(
expect.objectContaining({
type: "create_agent_request",
workspaceId: "/tmp/project/.paseo/worktrees/feature-a",
}),
);
mock.triggerMessage(
wrapSessionMessage({
type: "status",
payload: {
status: "agent_create_failed",
requestId: request.message.requestId,
error: "compat test sentinel",
},
}),
);
await expect(createPromise).rejects.toThrow("compat test sentinel");
});
test("sends explicit shutdown_server_request via shutdownServer", async () => { test("sends explicit shutdown_server_request via shutdownServer", async () => {
const logger = createMockLogger(); const logger = createMockLogger();
const mock = createMockTransport(); const mock = createMockTransport();
+19 -2
View File
@@ -197,7 +197,7 @@ export type CreateAgentRequestOptions = {
config?: AgentSessionConfig; config?: AgentSessionConfig;
provider?: AgentProvider; provider?: AgentProvider;
cwd?: string; cwd?: string;
workspaceId?: number; workspaceId?: string | number;
initialPrompt?: string; initialPrompt?: string;
clientMessageId?: string; clientMessageId?: string;
outputSchema?: Record<string, unknown>; outputSchema?: Record<string, unknown>;
@@ -1322,6 +1322,23 @@ export class DaemonClient {
}); });
} }
async startWorkspaceService(
workspaceId: string,
serviceName: string,
requestId?: string,
): Promise<Extract<SessionOutboundMessage, { type: "start_workspace_service_response" }>["payload"]> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "start_workspace_service_request",
workspaceId,
serviceName,
},
responseType: "start_workspace_service_response",
timeout: 10000,
});
}
async archiveWorkspace( async archiveWorkspace(
workspaceId: number, workspaceId: number,
requestId?: string, requestId?: string,
@@ -1423,7 +1440,7 @@ export class DaemonClient {
type: "create_agent_request", type: "create_agent_request",
requestId, requestId,
config, config,
...(typeof options.workspaceId === "number" ? { workspaceId: options.workspaceId } : {}), ...(options.workspaceId !== undefined ? { workspaceId: options.workspaceId } : {}),
...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}), ...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}),
...(options.clientMessageId ? { clientMessageId: options.clientMessageId } : {}), ...(options.clientMessageId ? { clientMessageId: options.clientMessageId } : {}),
...(options.outputSchema ? { outputSchema: options.outputSchema } : {}), ...(options.outputSchema ? { outputSchema: options.outputSchema } : {}),
+1 -1
View File
@@ -704,7 +704,7 @@ export async function createPaseoDaemon(
serviceRouteStore, serviceRouteStore,
handleBranchChange, handleBranchChange,
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
(hostname) => serviceHealthMonitor.getStatusForHostname(hostname), (hostname) => serviceHealthMonitor.getHealthForHostname(hostname),
); );
logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening"); logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening");
@@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { findFreePort, ServiceRouteStore } from "./service-proxy.js"; import { findFreePort, ServiceRouteStore } from "./service-proxy.js";
import { import {
ServiceHealthMonitor, ServiceHealthMonitor,
type ServiceStatusEntry, type ServiceHealthEntry,
} from "./service-health-monitor.js"; } from "./service-health-monitor.js";
type TcpServerHandle = { type TcpServerHandle = {
@@ -82,7 +82,7 @@ describe("ServiceHealthMonitor", () => {
serviceName: "api", serviceName: "api",
}); });
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>();
const monitor = new ServiceHealthMonitor({ const monitor = new ServiceHealthMonitor({
routeStore, routeStore,
onChange, onChange,
@@ -101,7 +101,7 @@ describe("ServiceHealthMonitor", () => {
serviceName: "api", serviceName: "api",
hostname: "api.localhost", hostname: "api.localhost",
port: healthy.port, port: healthy.port,
status: "running", health: "healthy",
}, },
]); ]);
}); });
@@ -118,7 +118,7 @@ describe("ServiceHealthMonitor", () => {
serviceName: "api", serviceName: "api",
}); });
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>();
const monitor = new ServiceHealthMonitor({ const monitor = new ServiceHealthMonitor({
routeStore, routeStore,
onChange, onChange,
@@ -141,7 +141,7 @@ describe("ServiceHealthMonitor", () => {
serviceName: "api", serviceName: "api",
hostname: "api.localhost", hostname: "api.localhost",
port: deadPort, port: deadPort,
status: "stopped", health: "unhealthy",
}, },
]); ]);
}); });
@@ -160,7 +160,7 @@ describe("ServiceHealthMonitor", () => {
serviceName: "api", serviceName: "api",
}); });
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>();
const monitor = new ServiceHealthMonitor({ const monitor = new ServiceHealthMonitor({
routeStore, routeStore,
onChange, onChange,
@@ -190,7 +190,7 @@ describe("ServiceHealthMonitor", () => {
serviceName: "api", serviceName: "api",
}); });
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>();
const monitor = new ServiceHealthMonitor({ const monitor = new ServiceHealthMonitor({
routeStore, routeStore,
onChange, onChange,
@@ -212,7 +212,7 @@ describe("ServiceHealthMonitor", () => {
serviceName: "api", serviceName: "api",
hostname: "api.localhost", hostname: "api.localhost",
port: healthy.port, port: healthy.port,
status: "running", health: "healthy",
}, },
]); ]);
}); });
@@ -231,7 +231,7 @@ describe("ServiceHealthMonitor", () => {
serviceName: "api", serviceName: "api",
}); });
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>();
const monitor = new ServiceHealthMonitor({ const monitor = new ServiceHealthMonitor({
routeStore, routeStore,
onChange, onChange,
@@ -260,7 +260,7 @@ describe("ServiceHealthMonitor", () => {
serviceName: "api", serviceName: "api",
hostname: "api.localhost", hostname: "api.localhost",
port: healthy.port, port: healthy.port,
status: "stopped", health: "unhealthy",
}, },
]); ]);
}); });
@@ -279,7 +279,7 @@ describe("ServiceHealthMonitor", () => {
serviceName: "api", serviceName: "api",
}); });
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>();
const monitor = new ServiceHealthMonitor({ const monitor = new ServiceHealthMonitor({
routeStore, routeStore,
onChange, onChange,
@@ -325,7 +325,7 @@ describe("ServiceHealthMonitor", () => {
serviceName: "web", serviceName: "web",
}); });
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>();
const monitor = new ServiceHealthMonitor({ const monitor = new ServiceHealthMonitor({
routeStore, routeStore,
onChange, onChange,
@@ -344,18 +344,18 @@ describe("ServiceHealthMonitor", () => {
serviceName: "api", serviceName: "api",
hostname: "api.localhost", hostname: "api.localhost",
port: api.port, port: api.port,
status: "running", health: "healthy",
}, },
{ {
serviceName: "web", serviceName: "web",
hostname: "web.localhost", hostname: "web.localhost",
port: web.port, port: web.port,
status: "running", health: "healthy",
}, },
]); ]);
}); });
it("getStatusForHostname returns current status after probe", async () => { it("getHealthForHostname returns current health after probe", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const healthy = await startTcpServer(); const healthy = await startTcpServer();
@@ -369,7 +369,7 @@ describe("ServiceHealthMonitor", () => {
serviceName: "api", serviceName: "api",
}); });
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>();
const monitor = new ServiceHealthMonitor({ const monitor = new ServiceHealthMonitor({
routeStore, routeStore,
onChange, onChange,
@@ -378,14 +378,14 @@ describe("ServiceHealthMonitor", () => {
graceMs: 0, graceMs: 0,
}); });
expect(monitor.getStatusForHostname("api.localhost")).toBeNull(); expect(monitor.getHealthForHostname("api.localhost")).toBeNull();
monitor.start(); monitor.start();
await advancePoll(1_000); await advancePoll(1_000);
monitor.stop(); monitor.stop();
expect(monitor.getStatusForHostname("api.localhost")).toBe("running"); expect(monitor.getHealthForHostname("api.localhost")).toBe("healthy");
expect(monitor.getStatusForHostname("unknown.localhost")).toBeNull(); expect(monitor.getHealthForHostname("unknown.localhost")).toBeNull();
}); });
it("coalesces multiple service changes in same workspace into one onChange call per poll cycle", async () => { it("coalesces multiple service changes in same workspace into one onChange call per poll cycle", async () => {
@@ -410,7 +410,7 @@ describe("ServiceHealthMonitor", () => {
serviceName: "web", serviceName: "web",
}); });
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>();
const monitor = new ServiceHealthMonitor({ const monitor = new ServiceHealthMonitor({
routeStore, routeStore,
onChange, onChange,
@@ -442,13 +442,13 @@ describe("ServiceHealthMonitor", () => {
serviceName: "api", serviceName: "api",
hostname: "api.localhost", hostname: "api.localhost",
port: api.port, port: api.port,
status: "stopped", health: "unhealthy",
}, },
{ {
serviceName: "web", serviceName: "web",
hostname: "web.localhost", hostname: "web.localhost",
port: web.port, port: web.port,
status: "stopped", health: "unhealthy",
}, },
]); ]);
}); });
@@ -1,15 +1,15 @@
import net from "node:net"; import net from "node:net";
import type { ServiceRouteEntry, ServiceRouteStore } from "./service-proxy.js"; import type { ServiceRouteEntry, ServiceRouteStore } from "./service-proxy.js";
export interface ServiceStatusEntry { export interface ServiceHealthEntry {
serviceName: string; serviceName: string;
hostname: string; hostname: string;
port: number; port: number;
status: "running" | "stopped"; health: "healthy" | "unhealthy";
} }
type RouteHealthState = { type RouteHealthState = {
status: ServiceStatusEntry["status"] | null; health: ServiceHealthEntry["health"] | null;
consecutiveFailures: number; consecutiveFailures: number;
registeredAt: number; registeredAt: number;
}; };
@@ -18,7 +18,7 @@ export class ServiceHealthMonitor {
private readonly routeStore: ServiceRouteStore; private readonly routeStore: ServiceRouteStore;
private readonly onChange: ( private readonly onChange: (
workspaceId: string, workspaceId: string,
services: ServiceStatusEntry[], services: ServiceHealthEntry[],
) => void; ) => void;
private readonly pollIntervalMs: number; private readonly pollIntervalMs: number;
private readonly probeTimeoutMs: number; private readonly probeTimeoutMs: number;
@@ -39,7 +39,7 @@ export class ServiceHealthMonitor {
failuresBeforeStopped = 2, failuresBeforeStopped = 2,
}: { }: {
routeStore: ServiceRouteStore; routeStore: ServiceRouteStore;
onChange: (workspaceId: string, services: ServiceStatusEntry[]) => void; onChange: (workspaceId: string, services: ServiceHealthEntry[]) => void;
pollIntervalMs?: number; pollIntervalMs?: number;
probeTimeoutMs?: number; probeTimeoutMs?: number;
graceMs?: number; graceMs?: number;
@@ -94,19 +94,19 @@ export class ServiceHealthMonitor {
} }
const isHealthy = await this.probeRoute(route.port); const isHealthy = await this.probeRoute(route.port);
const previousStatus = state.status; const previousHealth = state.health;
if (isHealthy) { if (isHealthy) {
state.consecutiveFailures = 0; state.consecutiveFailures = 0;
state.status = "running"; state.health = "healthy";
} else { } else {
state.consecutiveFailures += 1; state.consecutiveFailures += 1;
if (state.consecutiveFailures >= this.failuresBeforeStopped) { if (state.consecutiveFailures >= this.failuresBeforeStopped) {
state.status = "stopped"; state.health = "unhealthy";
} }
} }
if (state.status !== null && state.status !== previousStatus) { if (state.health !== null && state.health !== previousHealth) {
changedWorkspaceIds.add(route.workspaceId); changedWorkspaceIds.add(route.workspaceId);
} }
} }
@@ -135,7 +135,7 @@ export class ServiceHealthMonitor {
} }
const state: RouteHealthState = { const state: RouteHealthState = {
status: null, health: null,
consecutiveFailures: 0, consecutiveFailures: 0,
registeredAt, registeredAt,
}; };
@@ -152,31 +152,31 @@ export class ServiceHealthMonitor {
} }
} }
private buildWorkspaceServiceList(workspaceId: string): ServiceStatusEntry[] { private buildWorkspaceServiceList(workspaceId: string): ServiceHealthEntry[] {
return this.routeStore return this.routeStore
.listRoutesForWorkspace(workspaceId) .listRoutesForWorkspace(workspaceId)
.flatMap((route) => { .flatMap((route) => {
const state = this.routeStates.get(route.hostname); const state = this.routeStates.get(route.hostname);
if (!state?.status) { if (!state?.health) {
return []; return [];
} }
return [this.toServiceStatusEntry(route, state.status)]; return [this.toServiceHealthEntry(route, state.health)];
}); });
} }
getStatusForHostname(hostname: string): ServiceStatusEntry["status"] | null { getHealthForHostname(hostname: string): ServiceHealthEntry["health"] | null {
return this.routeStates.get(hostname)?.status ?? null; return this.routeStates.get(hostname)?.health ?? null;
} }
private toServiceStatusEntry( private toServiceHealthEntry(
route: ServiceRouteEntry, route: ServiceRouteEntry,
status: ServiceStatusEntry["status"], health: ServiceHealthEntry["health"],
): ServiceStatusEntry { ): ServiceHealthEntry {
return { return {
serviceName: route.serviceName, serviceName: route.serviceName,
hostname: route.hostname, hostname: route.hostname,
port: route.port, port: route.port,
status, health,
}; };
} }
@@ -99,6 +99,11 @@ export class ServiceRouteStore {
return null; return null;
} }
getRouteEntry(hostname: string): ServiceRouteEntry | null {
const entry = this.routes.get(hostname);
return entry ? { ...entry } : null;
}
listRoutes(): ServiceRouteEntry[] { listRoutes(): ServiceRouteEntry[] {
return Array.from(this.routes.values()).map((entry) => ({ ...entry })); return Array.from(this.routes.values()).map((entry) => ({ ...entry }));
} }
@@ -111,7 +111,8 @@ describe("service-route-branch-handler", () => {
hostname: "feature-billing.api.localhost", hostname: "feature-billing.api.localhost",
port: 3001, port: 3001,
url: null, url: null,
status: "stopped", lifecycle: "running",
health: null,
}, },
]); ]);
}); });
@@ -1,269 +1,232 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
import { ServiceRouteStore } from "./service-proxy.js"; import { ServiceRouteStore } from "./service-proxy.js";
import { import {
buildWorkspaceServicePayloads, buildWorkspaceServicePayloads,
createServiceStatusEmitter, createServiceStatusEmitter,
} from "./service-status-projection.js"; } from "./service-status-projection.js";
function createWorkspaceRepo(options?: {
branchName?: string;
paseoConfig?: Record<string, unknown>;
}): { tempDir: string; repoDir: string; cleanup: () => void } {
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "service-projection-")));
const repoDir = path.join(tempDir, "repo");
execSync(`mkdir -p ${JSON.stringify(repoDir)}`);
execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
if (options?.paseoConfig) {
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
}
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
return {
tempDir,
repoDir,
cleanup: () => {
rmSync(tempDir, { recursive: true, force: true });
},
};
}
describe("service-status-projection", () => { describe("service-status-projection", () => {
it("buildWorkspaceServicePayloads returns service payloads from workspace routes", () => { it("shows configured services even before they have routes", () => {
const workspace = createWorkspaceRepo({
paseoConfig: {
services: {
api: { command: "npm run api" },
web: { command: "npm run web", port: 3000 },
},
},
});
const routeStore = new ServiceRouteStore();
try {
expect(buildWorkspaceServicePayloads(routeStore, workspace.repoDir, 6767)).toEqual([
{
serviceName: "api",
hostname: "api.localhost",
port: null,
url: "http://api.localhost:6767",
lifecycle: "stopped",
health: null,
},
{
serviceName: "web",
hostname: "web.localhost",
port: 3000,
url: "http://web.localhost:6767",
lifecycle: "stopped",
health: null,
},
]);
} finally {
workspace.cleanup();
}
});
it("uses the active route port and branch-aware hostname for running services", () => {
const workspace = createWorkspaceRepo({
branchName: "feature/card",
paseoConfig: {
services: {
web: { command: "npm run web" },
},
},
});
const routeStore = new ServiceRouteStore(); const routeStore = new ServiceRouteStore();
routeStore.registerRoute({ routeStore.registerRoute({
hostname: "api.localhost", hostname: "feature-card.web.localhost",
port: 3001, port: 4321,
workspaceId: "workspace-a", workspaceId: workspace.repoDir,
serviceName: "api", serviceName: "web",
}); });
try {
expect(buildWorkspaceServicePayloads(routeStore, workspace.repoDir, 6767)).toEqual([
{
serviceName: "web",
hostname: "feature-card.web.localhost",
port: 4321,
url: "http://feature-card.web.localhost:6767",
lifecycle: "running",
health: null,
},
]);
} finally {
workspace.cleanup();
}
});
it("includes orphaned active routes even if the current config no longer declares them", () => {
const workspace = createWorkspaceRepo();
const routeStore = new ServiceRouteStore();
routeStore.registerRoute({ routeStore.registerRoute({
hostname: "docs.localhost", hostname: "docs.localhost",
port: 3002, port: 3002,
workspaceId: "workspace-b", workspaceId: workspace.repoDir,
serviceName: "docs", serviceName: "docs",
}); });
routeStore.registerRoute({
hostname: "web.localhost",
port: 3003,
workspaceId: "workspace-a",
serviceName: "web",
});
expect(buildWorkspaceServicePayloads(routeStore, "workspace-a", 6767)).toEqual([ try {
{ expect(buildWorkspaceServicePayloads(routeStore, workspace.repoDir, 6767)).toEqual([
serviceName: "api", {
hostname: "api.localhost", serviceName: "docs",
port: 3001, hostname: "docs.localhost",
url: "http://api.localhost:6767", port: 3002,
status: "stopped", url: "http://docs.localhost:6767",
}, lifecycle: "running",
{ health: null,
serviceName: "web", },
hostname: "web.localhost", ]);
port: 3003, } finally {
url: "http://web.localhost:6767", workspace.cleanup();
status: "stopped", }
},
]);
}); });
it("computes URLs with and without a daemon port", () => { it("createServiceStatusEmitter overlays health onto the full workspace service list", () => {
const workspace = createWorkspaceRepo({
paseoConfig: {
services: {
api: { command: "npm run api" },
web: { command: "npm run web" },
},
},
});
const routeStore = new ServiceRouteStore(); const routeStore = new ServiceRouteStore();
routeStore.registerRoute({ routeStore.registerRoute({
hostname: "api.localhost", hostname: "api.localhost",
port: 3001, port: 3001,
workspaceId: "workspace-a", workspaceId: workspace.repoDir,
serviceName: "api", serviceName: "api",
}); });
expect(buildWorkspaceServicePayloads(routeStore, "workspace-a", 6767)).toEqual([
{
serviceName: "api",
hostname: "api.localhost",
port: 3001,
url: "http://api.localhost:6767",
status: "stopped",
},
]);
expect(buildWorkspaceServicePayloads(routeStore, "workspace-a", null)).toEqual([
{
serviceName: "api",
hostname: "api.localhost",
port: 3001,
url: null,
status: "stopped",
},
]);
});
it("createServiceStatusEmitter emits updates to all active sessions", () => {
const routeStore = new ServiceRouteStore();
routeStore.registerRoute({
hostname: "api.localhost",
port: 3001,
workspaceId: "workspace-a",
serviceName: "api",
});
const sessionA = { emit: vi.fn() };
const sessionB = { emit: vi.fn() };
const emitUpdate = createServiceStatusEmitter({
sessions: () => [sessionA, sessionB],
routeStore,
daemonPort: 6767,
});
emitUpdate("workspace-a", [
{
serviceName: "api",
hostname: "api.localhost",
port: 3001,
status: "running",
},
]);
expect(sessionA.emit).toHaveBeenCalledWith({
type: "service_status_update",
payload: {
workspaceId: "workspace-a",
services: [
{
serviceName: "api",
hostname: "api.localhost",
port: 3001,
url: "http://api.localhost:6767",
status: "running",
},
],
},
});
expect(sessionB.emit).toHaveBeenCalledWith({
type: "service_status_update",
payload: {
workspaceId: "workspace-a",
services: [
{
serviceName: "api",
hostname: "api.localhost",
port: 3001,
url: "http://api.localhost:6767",
status: "running",
},
],
},
});
});
it("uses resolveStatus to set initial service status when provided", () => {
const routeStore = new ServiceRouteStore();
routeStore.registerRoute({
hostname: "api.localhost",
port: 3001,
workspaceId: "workspace-a",
serviceName: "api",
});
routeStore.registerRoute({
hostname: "web.localhost",
port: 3003,
workspaceId: "workspace-a",
serviceName: "web",
});
const statuses = new Map<string, "running" | "stopped">([
["api.localhost", "running"],
]);
expect(
buildWorkspaceServicePayloads(routeStore, "workspace-a", 6767, (hostname) =>
statuses.get(hostname) ?? null,
),
).toEqual([
{
serviceName: "api",
hostname: "api.localhost",
port: 3001,
url: "http://api.localhost:6767",
status: "running",
},
{
serviceName: "web",
hostname: "web.localhost",
port: 3003,
url: "http://web.localhost:6767",
status: "stopped",
},
]);
});
it("emits workspace-specific batches", () => {
const routeStore = new ServiceRouteStore();
routeStore.registerRoute({
hostname: "api.localhost",
port: 3001,
workspaceId: "workspace-a",
serviceName: "api",
});
routeStore.registerRoute({
hostname: "web.localhost",
port: 3002,
workspaceId: "workspace-a",
serviceName: "web",
});
routeStore.registerRoute({
hostname: "docs.localhost",
port: 3003,
workspaceId: "workspace-b",
serviceName: "docs",
});
const session = { emit: vi.fn() }; const session = { emit: vi.fn() };
const emitUpdate = createServiceStatusEmitter({ const emitUpdate = createServiceStatusEmitter({
sessions: () => [session], sessions: () => [session],
routeStore, routeStore,
daemonPort: null, daemonPort: 6767,
}); });
emitUpdate("workspace-a", [ try {
{ emitUpdate(workspace.repoDir, [
serviceName: "api", {
hostname: "api.localhost", serviceName: "api",
port: 3001, hostname: "api.localhost",
status: "running", port: 3001,
}, health: "healthy",
{ },
serviceName: "web", ]);
hostname: "web.localhost",
port: 3002,
status: "stopped",
},
]);
emitUpdate("workspace-b", [ expect(session.emit).toHaveBeenCalledWith({
{ type: "service_status_update",
serviceName: "docs", payload: {
hostname: "docs.localhost", workspaceId: workspace.repoDir,
port: 3003, services: [
status: "running", {
}, serviceName: "api",
]); hostname: "api.localhost",
port: 3001,
expect(session.emit).toHaveBeenNthCalledWith(1, { url: "http://api.localhost:6767",
type: "service_status_update", lifecycle: "running",
payload: { health: "healthy",
workspaceId: "workspace-a", },
services: [ {
{ serviceName: "web",
serviceName: "api", hostname: "web.localhost",
hostname: "api.localhost", port: null,
port: 3001, url: "http://web.localhost:6767",
url: null, lifecycle: "stopped",
status: "running", health: null,
}, },
{ ],
serviceName: "web", },
hostname: "web.localhost", });
port: 3002, } finally {
url: null, workspace.cleanup();
status: "stopped", }
},
],
},
});
expect(session.emit).toHaveBeenNthCalledWith(2, {
type: "service_status_update",
payload: {
workspaceId: "workspace-b",
services: [
{
serviceName: "docs",
hostname: "docs.localhost",
port: 3003,
url: null,
status: "running",
},
],
},
});
}); });
it("computes URLs with and without a daemon port", () => {
const workspace = createWorkspaceRepo({
paseoConfig: {
services: {
api: { command: "npm run api" },
},
},
});
const routeStore = new ServiceRouteStore();
try {
expect(buildWorkspaceServicePayloads(routeStore, workspace.repoDir, 6767)).toEqual([
{
serviceName: "api",
hostname: "api.localhost",
port: null,
url: "http://api.localhost:6767",
lifecycle: "stopped",
health: null,
},
]);
expect(buildWorkspaceServicePayloads(routeStore, workspace.repoDir, null)).toEqual([
{
serviceName: "api",
hostname: "api.localhost",
port: null,
url: null,
lifecycle: "stopped",
health: null,
},
]);
} finally {
workspace.cleanup();
}
});
}); });
@@ -3,8 +3,11 @@ import type {
SessionOutboundMessage, SessionOutboundMessage,
WorkspaceServicePayload, WorkspaceServicePayload,
} from "../shared/messages.js"; } from "../shared/messages.js";
import type { ServiceStatusEntry } from "./service-health-monitor.js"; import { buildServiceHostname } from "../utils/service-hostname.js";
import type { ServiceRouteStore } from "./service-proxy.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 = { type SessionEmitter = {
emit(message: SessionOutboundMessage): void; emit(message: SessionOutboundMessage): void;
@@ -24,19 +27,65 @@ function toServiceUrl(hostname: string, daemonPort: number | null): string | nul
return `http://${hostname}:${daemonPort}`; 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( export function buildWorkspaceServicePayloads(
routeStore: ServiceRouteStore, routeStore: ServiceRouteStore,
workspaceId: string, workspaceDirectory: string,
daemonPort: number | null, daemonPort: number | null,
resolveStatus?: (hostname: string) => "running" | "stopped" | null, resolveHealth?: (hostname: string) => "healthy" | "unhealthy" | null,
): WorkspaceServicePayload[] { ): WorkspaceServicePayload[] {
return routeStore.listRoutesForWorkspace(workspaceId).map((route) => ({ return mergeWorkspaceServiceDefinitions(workspaceDirectory, routeStore).map((service) => {
serviceName: route.serviceName, const route = routeStore.getRouteEntry(service.hostname);
hostname: route.hostname, return {
port: route.port, serviceName: service.serviceName,
url: toServiceUrl(route.hostname, daemonPort), hostname: service.hostname,
status: resolveStatus?.(route.hostname) ?? "stopped", port: route?.port ?? service.port,
})); url: toServiceUrl(service.hostname, daemonPort),
lifecycle: route ? "running" : "stopped",
health: resolveHealth?.(service.hostname) ?? null,
};
});
} }
function buildServiceStatusUpdateMessage(params: { function buildServiceStatusUpdateMessage(params: {
@@ -60,18 +109,18 @@ export function createServiceStatusEmitter({
sessions: () => SessionEmitter[]; sessions: () => SessionEmitter[];
routeStore: ServiceRouteStore; routeStore: ServiceRouteStore;
daemonPort: number | null | (() => number | null); daemonPort: number | null | (() => number | null);
}): (workspaceId: string, services: ServiceStatusEntry[]) => void { }): (workspaceId: string, services: ServiceHealthEntry[]) => void {
return (workspaceId, services) => { return (workspaceId, services) => {
const resolvedDaemonPort = resolveDaemonPort(daemonPort); const resolvedDaemonPort = resolveDaemonPort(daemonPort);
const serviceStatusByHostname = new Map( const serviceHealthByHostname = new Map(
services.map((service) => [service.hostname, service.status] as const), services.map((service) => [service.hostname, service.health] as const),
); );
const projected = buildWorkspaceServicePayloads(routeStore, workspaceId, resolvedDaemonPort).map( const projected = buildWorkspaceServicePayloads(
(service) => ({ routeStore,
...service, workspaceId,
status: serviceStatusByHostname.get(service.hostname) ?? service.status, resolvedDaemonPort,
}), (hostname) => serviceHealthByHostname.get(hostname) ?? null,
); );
const message = buildServiceStatusUpdateMessage({ const message = buildServiceStatusUpdateMessage({
@@ -170,6 +170,65 @@ describe("provider history compatibility ownership", () => {
}); });
}); });
test("fetch_agent_timeline_request emits legacy compatibility fields for older clients", async () => {
const ensureAgentLoaded = vi.fn(async () => createCompatibilitySnapshot());
const { session, emitted } = createSessionForOwnershipTests({
storedRecord: createStoredAgentRecord(),
timelineRows: [
{
seq: 7,
item: { type: "assistant_message", text: "compat row" },
timestamp: new Date("2026-03-24T00:00:07.000Z"),
},
],
agentLoadingService: {
ensureAgentLoaded,
},
});
session.buildAgentPayload = vi.fn(async () => ({ id: "agent-1" }));
await session.handleMessage({
type: "fetch_agent_timeline_request",
requestId: "req-compat",
agentId: "agent-1",
direction: "tail",
});
expect(emitted).toContainEqual({
type: "fetch_agent_timeline_response",
payload: expect.objectContaining({
requestId: "req-compat",
agentId: "agent-1",
epoch: "compat:agent-1",
reset: false,
staleCursor: false,
gap: false,
projection: "projected",
startCursor: { seq: 7, epoch: "compat:agent-1" },
endCursor: { seq: 7, epoch: "compat:agent-1" },
window: {
minSeq: 7,
maxSeq: 7,
nextSeq: 8,
startSeq: 7,
endSeq: 7,
hasOlder: false,
hasNewer: false,
},
entries: [
expect.objectContaining({
seq: 7,
seqStart: 7,
seqEnd: 7,
sourceSeqRanges: [{ startSeq: 7, endSeq: 7 }],
collapsed: [],
}),
],
}),
});
});
test("send_agent_message_request delegates unloaded bootstrap before recording and streaming", async () => { test("send_agent_message_request delegates unloaded bootstrap before recording and streaming", async () => {
const ensureAgentLoaded = vi.fn(async () => createCompatibilitySnapshot()); const ensureAgentLoaded = vi.fn(async () => createCompatibilitySnapshot());
const { session, agentManager, emitted } = createSessionForOwnershipTests({ const { session, agentManager, emitted } = createSessionForOwnershipTests({
+147 -5
View File
@@ -19,6 +19,7 @@ import {
type SubscribeTerminalsRequest, type SubscribeTerminalsRequest,
type UnsubscribeTerminalsRequest, type UnsubscribeTerminalsRequest,
type CreateTerminalRequest, type CreateTerminalRequest,
type StartWorkspaceServiceRequest,
type SubscribeTerminalRequest, type SubscribeTerminalRequest,
type UnsubscribeTerminalRequest, type UnsubscribeTerminalRequest,
type TerminalInput, type TerminalInput,
@@ -63,6 +64,8 @@ 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 { buildWorkspaceServicePayloads } from "./service-status-projection.js";
import { spawnWorkspaceService } from "./worktree-bootstrap.js";
import { readGitCommand } from "./workspace-git-metadata.js";
export type AgentMcpTransportFactory = () => Promise<Transport>; export type AgentMcpTransportFactory = () => Promise<Transport>;
import { buildProviderRegistry } from "./agent/provider-registry.js"; import { buildProviderRegistry } from "./agent/provider-registry.js";
@@ -404,7 +407,7 @@ export type SessionOptions = {
newBranch: string | null, newBranch: string | null,
) => void; ) => void;
getDaemonTcpPort?: () => number | null; getDaemonTcpPort?: () => number | null;
resolveServiceStatus?: (hostname: string) => "running" | "stopped" | null; resolveServiceHealth?: (hostname: string) => "healthy" | "unhealthy" | null;
voice?: { voice?: {
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null; voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
turnDetection?: Resolvable<TurnDetectionProvider | null>; turnDetection?: Resolvable<TurnDetectionProvider | null>;
@@ -587,7 +590,9 @@ export class Session {
newBranch: string | null, newBranch: string | null,
) => void; ) => void;
private readonly getDaemonTcpPort: (() => number | null) | null; private readonly getDaemonTcpPort: (() => number | null) | null;
private readonly resolveServiceStatus: ((hostname: string) => "running" | "stopped" | null) | null; private readonly resolveServiceHealth:
| ((hostname: string) => "healthy" | "unhealthy" | null)
| null;
private readonly subscribedTerminalDirectories = new Set<string>(); private readonly subscribedTerminalDirectories = new Set<string>();
private unsubscribeTerminalsChanged: (() => void) | null = null; private unsubscribeTerminalsChanged: (() => void) | null = null;
private terminalExitSubscriptions: Map<string, () => void> = new Map(); private terminalExitSubscriptions: Map<string, () => void> = new Map();
@@ -646,7 +651,7 @@ export class Session {
serviceRouteStore, serviceRouteStore,
onBranchChanged, onBranchChanged,
getDaemonTcpPort, getDaemonTcpPort,
resolveServiceStatus, resolveServiceHealth,
voice, voice,
voiceBridge, voiceBridge,
dictation, dictation,
@@ -688,7 +693,7 @@ export class Session {
this.serviceRouteStore = serviceRouteStore ?? null; this.serviceRouteStore = serviceRouteStore ?? null;
this.onBranchChanged = onBranchChanged; this.onBranchChanged = onBranchChanged;
this.getDaemonTcpPort = getDaemonTcpPort ?? null; this.getDaemonTcpPort = getDaemonTcpPort ?? null;
this.resolveServiceStatus = resolveServiceStatus ?? null; this.resolveServiceHealth = resolveServiceHealth ?? 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),
@@ -1739,6 +1744,10 @@ export class Session {
await this.handleCreateTerminalRequest(msg); await this.handleCreateTerminalRequest(msg);
break; break;
case "start_workspace_service_request":
await this.handleStartWorkspaceServiceRequest(msg);
break;
case "subscribe_terminal_request": case "subscribe_terminal_request":
await this.handleSubscribeTerminalRequest(msg); await this.handleSubscribeTerminalRequest(msg);
break; break;
@@ -5225,7 +5234,7 @@ export class Session {
this.serviceRouteStore, this.serviceRouteStore,
workspace.directory, workspace.directory,
this.getDaemonTcpPort?.() ?? null, this.getDaemonTcpPort?.() ?? null,
this.resolveServiceStatus ?? undefined, this.resolveServiceHealth ?? undefined,
) )
: [], : [],
}; };
@@ -5945,6 +5954,87 @@ export class Session {
} }
} }
private buildWorkspaceServicePayloadSnapshot(workspaceDirectory: string): WorkspaceDescriptorPayload["services"] {
if (!this.serviceRouteStore) {
return [];
}
return buildWorkspaceServicePayloads(
this.serviceRouteStore,
workspaceDirectory,
this.getDaemonTcpPort?.() ?? null,
this.resolveServiceHealth ?? undefined,
);
}
private emitWorkspaceServiceStatusUpdate(workspaceDirectory: string): void {
this.emit({
type: "service_status_update",
payload: {
workspaceId: workspaceDirectory,
services: this.buildWorkspaceServicePayloadSnapshot(workspaceDirectory),
},
});
}
private async handleStartWorkspaceServiceRequest(
request: StartWorkspaceServiceRequest,
): Promise<void> {
try {
if (!this.terminalManager || !this.serviceRouteStore) {
throw new Error("Workspace services are not available on this daemon");
}
const workspace = await this.resolveWorkspaceByIdOrDirectory(request.workspaceId);
if (!workspace) {
throw new Error(`Workspace not found: ${request.workspaceId}`);
}
await spawnWorkspaceService({
repoRoot: workspace.directory,
workspaceId: workspace.directory,
branchName: readGitCommand(workspace.directory, "git symbolic-ref --short HEAD"),
serviceName: request.serviceName,
daemonPort: this.getDaemonTcpPort?.() ?? null,
routeStore: this.serviceRouteStore,
terminalManager: this.terminalManager,
logger: this.sessionLogger,
onLifecycleChanged: () => {
this.emitWorkspaceServiceStatusUpdate(workspace.directory);
},
});
this.emitWorkspaceServiceStatusUpdate(workspace.directory);
this.emit({
type: "start_workspace_service_response",
payload: {
requestId: request.requestId,
workspaceId: request.workspaceId,
serviceName: request.serviceName,
error: null,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to start workspace service";
this.sessionLogger.error(
{
err: error,
workspaceId: request.workspaceId,
serviceName: request.serviceName,
},
"Failed to start workspace service",
);
this.emit({
type: "start_workspace_service_response",
payload: {
requestId: request.requestId,
workspaceId: request.workspaceId,
serviceName: request.serviceName,
error: message,
},
});
}
}
private async handleCreatePaseoWorktreeRequest( private async handleCreatePaseoWorktreeRequest(
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>, request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
): Promise<void> { ): Promise<void> {
@@ -6095,11 +6185,38 @@ export class Session {
}); });
const firstRow = timeline.rows[0]; const firstRow = timeline.rows[0];
const lastRow = timeline.rows[timeline.rows.length - 1]; const lastRow = timeline.rows[timeline.rows.length - 1];
const timelineWindow =
timeline.window ??
(() => {
const minSeq = firstRow?.seq ?? 0;
const maxSeq = lastRow?.seq ?? 0;
return {
minSeq,
maxSeq,
nextSeq: maxSeq > 0 ? maxSeq + 1 : 0,
};
})();
const epoch = `compat:${msg.agentId}`;
const startCursor = firstRow ? { seq: firstRow.seq, epoch } : null;
const endCursor = lastRow ? { seq: lastRow.seq, epoch } : null;
const window = {
minSeq: timelineWindow.minSeq,
maxSeq: timelineWindow.maxSeq,
nextSeq: timelineWindow.nextSeq,
startSeq: firstRow?.seq ?? null,
endSeq: lastRow?.seq ?? null,
hasOlder: timeline.hasOlder,
hasNewer: timeline.hasNewer,
};
const entries = timeline.rows.map((row) => ({ const entries = timeline.rows.map((row) => ({
provider: snapshot.provider, provider: snapshot.provider,
item: row.item, item: row.item,
timestamp: row.timestamp, timestamp: row.timestamp,
seq: row.seq, seq: row.seq,
seqStart: row.seq,
seqEnd: row.seq,
sourceSeqRanges: [{ startSeq: row.seq, endSeq: row.seq }],
collapsed: [],
})); }));
this.emit({ this.emit({
@@ -6115,9 +6232,18 @@ export class Session {
hasNewer: timeline.hasNewer, hasNewer: timeline.hasNewer,
entries, entries,
error: null, error: null,
epoch,
reset: false,
staleCursor: false,
gap: false,
window,
startCursor,
endCursor,
projection: msg.projection ?? "projected",
}, },
}); });
} catch (error) { } catch (error) {
const epoch = `compat:${msg.agentId}`;
this.sessionLogger.error( this.sessionLogger.error(
{ err: error, agentId: msg.agentId }, { err: error, agentId: msg.agentId },
"Failed to handle fetch_agent_timeline_request", "Failed to handle fetch_agent_timeline_request",
@@ -6135,6 +6261,22 @@ export class Session {
hasNewer: false, hasNewer: false,
entries: [], entries: [],
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
epoch,
reset: false,
staleCursor: false,
gap: false,
window: {
minSeq: 0,
maxSeq: 0,
nextSeq: 0,
startSeq: null,
endSeq: null,
hasOlder: false,
hasNewer: false,
},
startCursor: null,
endCursor: null,
projection: msg.projection ?? "projected",
}, },
}); });
} }
@@ -248,7 +248,9 @@ export class VoiceAssistantWebSocketServer {
private readonly terminalManager: TerminalManager | null; private readonly terminalManager: TerminalManager | null;
private readonly serviceRouteStore: ServiceRouteStore | null; private readonly serviceRouteStore: ServiceRouteStore | null;
private readonly getDaemonTcpPort: (() => number | null) | null; private readonly getDaemonTcpPort: (() => number | null) | null;
private readonly resolveServiceStatus: ((hostname: string) => "running" | "stopped" | null) | null; private readonly resolveServiceHealth:
| ((hostname: string) => "healthy" | "unhealthy" | null)
| null;
private readonly dictation: { private readonly dictation: {
finalTimeoutMs?: number; finalTimeoutMs?: number;
} | null; } | null;
@@ -326,7 +328,7 @@ export class VoiceAssistantWebSocketServer {
newBranch: string | null, newBranch: string | null,
) => void, ) => void,
getDaemonTcpPort?: () => number | null, getDaemonTcpPort?: () => number | null,
resolveServiceStatus?: (hostname: string) => "running" | "stopped" | null, resolveServiceHealth?: (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;
@@ -373,7 +375,7 @@ export class VoiceAssistantWebSocketServer {
this.serviceRouteStore = serviceRouteStore ?? null; this.serviceRouteStore = serviceRouteStore ?? null;
this.onBranchChanged = onBranchChanged ?? null; this.onBranchChanged = onBranchChanged ?? null;
this.getDaemonTcpPort = getDaemonTcpPort ?? null; this.getDaemonTcpPort = getDaemonTcpPort ?? null;
this.resolveServiceStatus = resolveServiceStatus ?? null; this.resolveServiceHealth = resolveServiceHealth ?? null;
this.serverCapabilities = buildServerCapabilities({ this.serverCapabilities = buildServerCapabilities({
readiness: this.speech?.getReadiness() ?? null, readiness: this.speech?.getReadiness() ?? null,
}); });
@@ -693,7 +695,7 @@ export class VoiceAssistantWebSocketServer {
serviceRouteStore: this.serviceRouteStore ?? undefined, serviceRouteStore: this.serviceRouteStore ?? undefined,
onBranchChanged: this.onBranchChanged ?? undefined, onBranchChanged: this.onBranchChanged ?? undefined,
getDaemonTcpPort: this.getDaemonTcpPort ?? undefined, getDaemonTcpPort: this.getDaemonTcpPort ?? undefined,
resolveServiceStatus: this.resolveServiceStatus ?? undefined, resolveServiceHealth: this.resolveServiceHealth ?? undefined,
voice: { voice: {
...(this.voice ?? {}), ...(this.voice ?? {}),
turnDetection: () => this.speech?.resolveTurnDetection() ?? null, turnDetection: () => this.speech?.resolveTurnDetection() ?? null,
+117 -63
View File
@@ -768,6 +768,115 @@ export interface WorktreeServiceResult {
terminalId: string; terminalId: string;
} }
type SpawnWorkspaceServiceOptions = {
repoRoot: string;
workspaceId: string;
branchName: string | null;
serviceName: string;
daemonPort?: number | null;
routeStore: ServiceRouteStore;
terminalManager: TerminalManager;
logger?: Logger;
onLifecycleChanged?: () => void;
};
export async function spawnWorkspaceService(
options: SpawnWorkspaceServiceOptions,
): Promise<WorktreeServiceResult> {
const {
repoRoot,
workspaceId,
branchName,
serviceName,
daemonPort,
routeStore,
terminalManager,
logger,
onLifecycleChanged,
} = options;
const serviceConfigs = getServiceConfigs(repoRoot);
const config = serviceConfigs.get(serviceName);
if (!config) {
throw new Error(`Service '${serviceName}' is not configured in paseo.json`);
}
let hostname: string | null = null;
let port: number | null = null;
try {
hostname = buildServiceHostname(branchName, serviceName);
const resolvedHostname = hostname;
if (routeStore.getRouteEntry(resolvedHostname)) {
throw new Error(`Service '${serviceName}' is already running`);
}
port = config.port ?? (await findFreePort());
routeStore.registerRoute({
hostname: resolvedHostname,
port,
workspaceId,
serviceName,
});
const env: Record<string, string> = {
PORT: String(port),
HOST: "127.0.0.1",
};
if (daemonPort !== null && daemonPort !== undefined) {
env.PASEO_SERVICE_URL = `http://${resolvedHostname}:${daemonPort}`;
}
const terminal = await terminalManager.createTerminal({
cwd: repoRoot,
name: serviceName,
env,
});
terminal.onExit(() => {
routeStore.removeRoute(resolvedHostname);
onLifecycleChanged?.();
logger?.info(
{ serviceName, hostname: resolvedHostname, terminalId: terminal.id },
"Stopped worktree service",
);
});
await waitForTerminalBootstrapReadiness(terminal);
terminal.send({ type: "input", data: `${config.command}\r` });
logger?.info(
{ serviceName, hostname: resolvedHostname, port, terminalId: terminal.id },
`Registered service proxy: ${resolvedHostname} -> 127.0.0.1:${port}`,
);
onLifecycleChanged?.();
return {
serviceName,
hostname: resolvedHostname,
port,
terminalId: terminal.id,
};
} catch (error) {
if (hostname && port !== null) {
routeStore.removeRoute(hostname);
}
logger?.error(
{
err: error,
serviceName,
repoRoot,
branchName,
hostname,
port,
command: config.command,
},
"Failed to spawn worktree service",
);
throw error;
}
}
export async function spawnWorktreeServices(options: { export async function spawnWorktreeServices(options: {
repoRoot: string; repoRoot: string;
workspaceId: string; workspaceId: string;
@@ -776,77 +885,22 @@ export async function spawnWorktreeServices(options: {
routeStore: ServiceRouteStore; routeStore: ServiceRouteStore;
terminalManager: TerminalManager; terminalManager: TerminalManager;
logger?: Logger; logger?: Logger;
onLifecycleChanged?: () => void;
}): Promise<WorktreeServiceResult[]> { }): Promise<WorktreeServiceResult[]> {
const { repoRoot, workspaceId, branchName, daemonPort, routeStore, terminalManager, logger } = const { repoRoot } = options;
options;
const serviceConfigs = getServiceConfigs(repoRoot); const serviceConfigs = getServiceConfigs(repoRoot);
if (serviceConfigs.size === 0) { if (serviceConfigs.size === 0) {
return []; return [];
} }
const results: WorktreeServiceResult[] = []; const results: WorktreeServiceResult[] = [];
for (const serviceName of serviceConfigs.keys()) {
for (const [serviceName, config] of serviceConfigs) { results.push(
let hostname: string | null = null; await spawnWorkspaceService({
let port: number | null = null; ...options,
try {
port = config.port ?? (await findFreePort());
hostname = buildServiceHostname(branchName, serviceName);
routeStore.registerRoute({
hostname,
port,
workspaceId,
serviceName, serviceName,
}); }),
);
const env: Record<string, string> = {
PORT: String(port),
HOST: "127.0.0.1",
};
if (daemonPort !== null && daemonPort !== undefined) {
env.PASEO_SERVICE_URL = `http://${hostname}:${daemonPort}`;
}
const terminal = await terminalManager.createTerminal({
cwd: repoRoot,
name: serviceName,
env,
});
await waitForTerminalBootstrapReadiness(terminal);
terminal.send({ type: "input", data: `${config.command}\r` });
logger?.info(
{ serviceName, hostname, port, terminalId: terminal.id },
`Registered service proxy: ${hostname} -> 127.0.0.1:${port}`,
);
results.push({
serviceName,
hostname,
port,
terminalId: terminal.id,
});
} catch (error) {
if (hostname && port !== null) {
routeStore.removeRoute(hostname);
}
logger?.error(
{
err: error,
serviceName,
repoRoot,
branchName,
hostname,
port,
command: config.command,
},
"Failed to spawn worktree service",
);
throw error;
}
} }
return results; return results;
@@ -57,6 +57,7 @@ function createTerminalManagerStub(options?: {
grid: [], grid: [],
}), }),
subscribe: () => () => {}, subscribe: () => () => {},
onExit: () => () => {},
send: (message: { type: string; data: string }) => { send: (message: { type: string; data: string }) => {
if (message.type === "input") { if (message.type === "input") {
sent.push(message.data); sent.push(message.data);
@@ -778,6 +778,9 @@ export async function createPaseoWorktreeInBackground(
routeStore: dependencies.serviceRouteStore, routeStore: dependencies.serviceRouteStore,
terminalManager: dependencies.terminalManager, terminalManager: dependencies.terminalManager,
logger: dependencies.sessionLogger, logger: dependencies.sessionLogger,
onLifecycleChanged: () => {
void dependencies.emitWorkspaceUpdateForCwd(worktree.worktreePath);
},
}); });
} catch (error) { } catch (error) {
dependencies.sessionLogger.error( dependencies.sessionLogger.error(
+32 -2
View File
@@ -1262,6 +1262,13 @@ export const CreateTerminalRequestSchema = z.object({
requestId: z.string(), requestId: z.string(),
}); });
export const StartWorkspaceServiceRequestSchema = z.object({
type: z.literal("start_workspace_service_request"),
workspaceId: z.string(),
serviceName: z.string(),
requestId: z.string(),
});
export const SubscribeTerminalRequestSchema = z.object({ export const SubscribeTerminalRequestSchema = z.object({
type: z.literal("subscribe_terminal_request"), type: z.literal("subscribe_terminal_request"),
terminalId: z.string(), terminalId: z.string(),
@@ -1373,6 +1380,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
SubscribeTerminalsRequestSchema, SubscribeTerminalsRequestSchema,
UnsubscribeTerminalsRequestSchema, UnsubscribeTerminalsRequestSchema,
CreateTerminalRequestSchema, CreateTerminalRequestSchema,
StartWorkspaceServiceRequestSchema,
SubscribeTerminalRequestSchema, SubscribeTerminalRequestSchema,
UnsubscribeTerminalRequestSchema, UnsubscribeTerminalRequestSchema,
TerminalInputSchema, TerminalInputSchema,
@@ -1728,12 +1736,16 @@ export const ProjectPlacementPayloadSchema = z.object({
checkout: ProjectCheckoutLitePayloadSchema, checkout: ProjectCheckoutLitePayloadSchema,
}); });
export const WorkspaceServiceLifecycleSchema = z.enum(["running", "stopped"]);
export const WorkspaceServiceHealthSchema = z.enum(["healthy", "unhealthy"]);
export const WorkspaceServicePayloadSchema = z.object({ export const WorkspaceServicePayloadSchema = z.object({
serviceName: z.string(), serviceName: z.string(),
hostname: z.string(), hostname: z.string(),
port: z.number().int().positive(), port: z.number().int().positive().nullable(),
url: z.string().nullable(), url: z.string().nullable(),
status: z.enum(["running", "stopped"]), lifecycle: WorkspaceServiceLifecycleSchema,
health: WorkspaceServiceHealthSchema.nullable(),
}); });
export const WorkspaceDescriptorPayloadSchema = z.object({ export const WorkspaceDescriptorPayloadSchema = z.object({
@@ -1893,6 +1905,16 @@ export const OpenProjectResponseMessageSchema = z.object({
}), }),
}); });
export const StartWorkspaceServiceResponseMessageSchema = z.object({
type: z.literal("start_workspace_service_response"),
payload: z.object({
requestId: z.string(),
workspaceId: z.string(),
serviceName: z.string(),
error: z.string().nullable(),
}),
});
export const ArchiveWorkspaceResponseMessageSchema = z.object({ export const ArchiveWorkspaceResponseMessageSchema = z.object({
type: z.literal("archive_workspace_response"), type: z.literal("archive_workspace_response"),
payload: z.object({ payload: z.object({
@@ -2585,6 +2607,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
FetchAgentsResponseMessageSchema, FetchAgentsResponseMessageSchema,
FetchWorkspacesResponseMessageSchema, FetchWorkspacesResponseMessageSchema,
OpenProjectResponseMessageSchema, OpenProjectResponseMessageSchema,
StartWorkspaceServiceResponseMessageSchema,
ArchiveWorkspaceResponseMessageSchema, ArchiveWorkspaceResponseMessageSchema,
FetchAgentResponseMessageSchema, FetchAgentResponseMessageSchema,
FetchAgentTimelineResponseMessageSchema, FetchAgentTimelineResponseMessageSchema,
@@ -2682,11 +2705,16 @@ 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 WorkspaceServiceHealth = z.infer<typeof WorkspaceServiceHealthSchema>;
export type WorkspaceServicePayload = z.infer<typeof WorkspaceServicePayloadSchema>; export type WorkspaceServicePayload = z.infer<typeof WorkspaceServicePayloadSchema>;
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 ServiceStatusUpdateMessage = z.infer<typeof ServiceStatusUpdateMessageSchema>;
export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessageSchema>; export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessageSchema>;
export type StartWorkspaceServiceResponseMessage = z.infer<
typeof StartWorkspaceServiceResponseMessageSchema
>;
export type ArchiveWorkspaceResponseMessage = z.infer<typeof ArchiveWorkspaceResponseMessageSchema>; export type ArchiveWorkspaceResponseMessage = z.infer<typeof ArchiveWorkspaceResponseMessageSchema>;
export type FetchAgentResponseMessage = z.infer<typeof FetchAgentResponseMessageSchema>; export type FetchAgentResponseMessage = z.infer<typeof FetchAgentResponseMessageSchema>;
export type FetchAgentTimelineResponseMessage = z.infer< export type FetchAgentTimelineResponseMessage = z.infer<
@@ -2860,6 +2888,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 StartWorkspaceServiceResponse = z.infer<typeof StartWorkspaceServiceResponseMessageSchema>;
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>;
@@ -74,7 +74,8 @@ describe("workspace message schemas", () => {
hostname: "web.localhost", hostname: "web.localhost",
port: 3000, port: 3000,
url: "http://web.localhost:6767", url: "http://web.localhost:6767",
status: "running", lifecycle: "running",
health: "healthy",
}, },
], ],
}, },
@@ -91,7 +92,8 @@ describe("workspace message schemas", () => {
hostname: "web.localhost", hostname: "web.localhost",
port: 3000, port: 3000,
url: "http://web.localhost:6767", url: "http://web.localhost:6767",
status: "running", lifecycle: "running",
health: "healthy",
}, },
]); ]);
}); });
@@ -134,9 +136,10 @@ describe("workspace message schemas", () => {
{ {
serviceName: "web", serviceName: "web",
hostname: "web.localhost", hostname: "web.localhost",
port: 3000, port: null,
url: null, url: null,
status: "stopped", lifecycle: "stopped",
health: null,
}, },
], ],
}, },