From 7b19dd736fb73b5b049e428050b3ee7eac1928a1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 6 Apr 2026 20:16:32 +0700 Subject: [PATCH] feat: add service start capability with health monitoring --- packages/app/e2e/workspace-hover-card.spec.ts | 67 +++ .../src/components/sidebar-workspace-list.tsx | 2 +- .../src/components/workspace-hover-card.tsx | 171 ++++++- .../src/components/workspace-setup-dialog.tsx | 3 +- .../session-context.service-status.test.ts | 3 +- .../hooks/use-sidebar-workspaces-list.test.ts | 6 +- .../src/hooks/use-sidebar-workspaces-list.ts | 2 +- .../app/src/screens/new-workspace-screen.tsx | 3 +- .../workspace/workspace-draft-agent-tab.tsx | 7 +- packages/app/src/stores/session-store.test.ts | 12 +- .../src/utils/workspace-archive-navigation.ts | 2 +- .../server/src/client/daemon-client.test.ts | 55 +++ packages/server/src/client/daemon-client.ts | 21 +- packages/server/src/server/bootstrap.ts | 2 +- .../src/server/service-health-monitor.test.ts | 44 +- .../src/server/service-health-monitor.ts | 38 +- packages/server/src/server/service-proxy.ts | 5 + .../service-route-branch-handler.test.ts | 3 +- .../server/service-status-projection.test.ts | 429 ++++++++---------- .../src/server/service-status-projection.ts | 87 +++- ...er-history-compatibility-ownership.test.ts | 59 +++ packages/server/src/server/session.ts | 152 ++++++- .../server/src/server/websocket-server.ts | 10 +- .../server/src/server/worktree-bootstrap.ts | 180 +++++--- .../src/server/worktree-session.test.ts | 1 + .../server/src/server/worktree-session.ts | 3 + packages/server/src/shared/messages.ts | 34 +- .../src/shared/messages.workspaces.test.ts | 11 +- 28 files changed, 995 insertions(+), 417 deletions(-) diff --git a/packages/app/e2e/workspace-hover-card.spec.ts b/packages/app/e2e/workspace-hover-card.spec.ts index b9f17f048..91cb72e0d 100644 --- a/packages/app/e2e/workspace-hover-card.spec.ts +++ b/packages/app/e2e/workspace-hover-card.spec.ts @@ -50,6 +50,28 @@ async function expectServiceRunning(page: Page, serviceName: string): Promise { + 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 { + 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. */ async function expectWorkspaceNameInCard(page: Page, name: string): Promise { const card = page.getByTestId("workspace-hover-card"); @@ -138,4 +160,49 @@ test.describe("Workspace hover card", () => { 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(); + } + }); }); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 48c07b8b2..07fc0e2be 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -262,7 +262,7 @@ function WorkspaceStatusIndicator({ } const KindIcon = - workspaceKind === "checkout" + workspaceKind === "local_checkout" ? Monitor : workspaceKind === "worktree" ? FolderGit2 diff --git a/packages/app/src/components/workspace-hover-card.tsx b/packages/app/src/components/workspace-hover-card.tsx index cc3d59ea4..eb396058f 100644 --- a/packages/app/src/components/workspace-hover-card.tsx +++ b/packages/app/src/components/workspace-hover-card.tsx @@ -6,15 +6,18 @@ import { type PropsWithChildren, type ReactElement, } from "react"; +import { useMutation } from "@tanstack/react-query"; import { Dimensions, Platform, Text, View } from "react-native"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { Check, ExternalLink, 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 { Portal } from "@gorhom/portal"; import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; 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"; interface Rect { @@ -194,6 +197,31 @@ const GITHUB_PR_STATE_LABELS: Record = { closed: "Closed", }; +function getServiceHealthColor(input: { + health: SidebarWorkspaceEntry["services"][number]["health"]; + theme: ReturnType["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({ status, @@ -287,10 +315,30 @@ function WorkspaceHoverCardContent({ onContentLeave: () => void; }): ReactElement | null { const { theme } = useUnistyles(); + const toast = useToast(); + const client = useSessionStore((state) => state.sessions[workspace.serverId]?.client ?? null); const bottomSheetInternal = useBottomSheetModalInternal(true); const [triggerRect, setTriggerRect] = useState(null); const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); const [position, setPosition] = useState<{ x: number; y: number } | null>(null); + const startServiceMutation = useMutation({ + mutationFn: async (serviceName: string) => { + 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 useEffect(() => { @@ -418,50 +466,95 @@ function WorkspaceHoverCardContent({ {workspace.services.map((service) => ( [ styles.serviceRow, - hovered && styles.serviceRowHovered, + hovered && + service.lifecycle === "running" && + service.url && + styles.serviceRowHovered, ]} onPress={() => { - if (service.url) { + if (service.lifecycle === "running" && service.url) { void openExternalUrl(service.url); } }} - disabled={!service.url} > - {service.serviceName} - {service.url ? ( + + + {service.lifecycle === "running" ? "Running" : "Stopped"} + + + {getServiceHealthLabel(service.health)} + + {service.lifecycle === "running" && service.url ? ( {service.url.replace(/^https?:\/\//, "")} - ) : null} - {service.url ? ( + ) : ( + + )} + {service.lifecycle === "running" && service.url ? ( - ) : null} + ) : ( + [ + styles.startServiceButton, + (hovered || pressed) && styles.startServiceButtonHovered, + ]} + disabled={startServiceMutation.isPending} + onPress={(event) => { + event.stopPropagation(); + startServiceMutation.mutate(service.serviceName); + }} + > + {startServiceMutation.isPending && + startServiceMutation.variables === service.serviceName ? ( + + ) : ( + + )} + + )} ))} @@ -558,12 +651,44 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, 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: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, flex: 1, 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: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.medium, diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 6804a51b7..b59ffbc6d 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -16,7 +16,6 @@ import { toErrorMessage } from "@/utils/error-messages"; import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; import { requireWorkspaceExecutionAuthority, - requireWorkspaceRecordId, } from "@/utils/workspace-execution"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import type { ImageAttachment, MessagePayload } from "./message-input"; @@ -179,7 +178,7 @@ export function WorkspaceSetupDialog() { const agent = await connectedClient.createAgent({ provider: composerState.selectedProvider, cwd: workspaceDirectory, - workspaceId: requireWorkspaceRecordId(workspace.id), + workspaceId: workspace.id, ...(composerState.modeOptions.length > 0 && composerState.selectedMode !== "" ? { modeId: composerState.selectedMode } : {}), diff --git a/packages/app/src/contexts/session-context.service-status.test.ts b/packages/app/src/contexts/session-context.service-status.test.ts index b1d9b8269..31219679f 100644 --- a/packages/app/src/contexts/session-context.service-status.test.ts +++ b/packages/app/src/contexts/session-context.service-status.test.ts @@ -28,7 +28,8 @@ const runningService: WorkspaceServicePayload = { hostname: "main.web.localhost", port: 3000, url: "http://main.web.localhost:6767", - status: "running", + lifecycle: "running", + health: "healthy", }; describe("patchWorkspaceServices", () => { diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts index 2678ce54c..d9941aae4 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts @@ -50,7 +50,8 @@ const runningService: WorkspaceServicePayload = { hostname: "main.web.localhost", port: 3000, url: "http://main.web.localhost:6767", - status: "running", + lifecycle: "running", + health: "healthy", }; const stoppedService: WorkspaceServicePayload = { @@ -58,7 +59,8 @@ const stoppedService: WorkspaceServicePayload = { hostname: "main.api.localhost", port: 3001, url: "http://main.api.localhost:6767", - status: "stopped", + lifecycle: "stopped", + health: null, }; describe("applyStoredOrdering", () => { diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index e341d32d6..29a17ba84 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -145,7 +145,7 @@ export function buildSidebarProjectsFromWorkspaces(input: { statusBucket: workspace.status, diffStat: workspace.diffStat, services: workspace.services, - hasRunningServices: workspace.services.some((service) => service.status === "running"), + hasRunningServices: workspace.services.some((service) => service.lifecycle === "running"), }; project.workspaces.push(row); diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index c0323e175..b11d001fc 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -25,7 +25,6 @@ import { encodeImages } from "@/utils/encode-images"; import { toErrorMessage } from "@/utils/error-messages"; import { requireWorkspaceExecutionAuthority, - requireWorkspaceRecordId, } from "@/utils/workspace-execution"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import type { ImageAttachment, MessagePayload } from "@/components/message-input"; @@ -151,7 +150,7 @@ export function NewWorkspaceScreen({ const agent = await connectedClient.createAgent({ provider: composerState.selectedProvider, cwd: workspaceDirectory, - workspaceId: requireWorkspaceRecordId(workspace.id), + workspaceId: workspace.id, ...(composerState.modeOptions.length > 0 && composerState.selectedMode !== "" ? { modeId: composerState.selectedMode } : {}), diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index dd4ab2b8c..30ed45c23 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -13,10 +13,7 @@ import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-dr import { buildDraftStoreKey } from "@/stores/draft-keys"; import { type Agent, useSessionStore } from "@/stores/session-store"; import { encodeImages } from "@/utils/encode-images"; -import { - getWorkspaceExecutionAuthority, - requireWorkspaceRecordId, -} from "@/utils/workspace-execution"; +import { getWorkspaceExecutionAuthority } from "@/utils/workspace-execution"; import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus"; import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types"; import type { AgentSnapshotPayload } from "@server/shared/messages"; @@ -173,7 +170,7 @@ export function WorkspaceDraftAgentTab({ const imagesData = await encodeImages(images); const result = await client.createAgent({ config, - workspaceId: requireWorkspaceRecordId(workspaceExecutionAuthority.workspaceId), + workspaceId: workspaceExecutionAuthority.workspaceId, ...(text ? { initialPrompt: text } : {}), clientMessageId: attempt.clientMessageId, ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index fb85b468c..1aec73f5a 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -38,7 +38,8 @@ describe("normalizeWorkspaceDescriptor", () => { hostname: "main.web.localhost", port: 3000, url: "http://main.web.localhost:6767", - status: "running" as const, + lifecycle: "running" as const, + health: "healthy" as const, }, ]; const workspace = normalizeWorkspaceDescriptor({ @@ -63,7 +64,8 @@ describe("normalizeWorkspaceDescriptor", () => { hostname: "main.web.localhost", port: 3000, url: "http://main.web.localhost:6767", - status: "running", + lifecycle: "running", + health: "healthy", }, ]); expect(workspace.services).not.toBe(services); @@ -109,7 +111,8 @@ describe("mergeWorkspaces", () => { hostname: "main.web.localhost", port: 3000, url: "http://main.web.localhost:6767", - status: "running", + lifecycle: "running", + health: "healthy", }, ], }), @@ -121,7 +124,8 @@ describe("mergeWorkspaces", () => { hostname: "main.web.localhost", port: 3000, url: "http://main.web.localhost:6767", - status: "running", + lifecycle: "running", + health: "healthy", }, ]); }); diff --git a/packages/app/src/utils/workspace-archive-navigation.ts b/packages/app/src/utils/workspace-archive-navigation.ts index a1ef64a0f..9eafc1a2b 100644 --- a/packages/app/src/utils/workspace-archive-navigation.ts +++ b/packages/app/src/utils/workspace-archive-navigation.ts @@ -26,7 +26,7 @@ export function resolveWorkspaceArchiveRedirectWorkspaceId(input: { const rootCheckoutWorkspace = sameProjectWorkspaces.find( (workspace) => - workspace.workspaceKind === "checkout" && workspace.id !== archivedWorkspace.id, + workspace.workspaceKind === "local_checkout" && workspace.id !== archivedWorkspace.id, ) ?? null; if (rootCheckoutWorkspace) { return rootCheckoutWorkspace.id; diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index f25ff65c7..aac4921ba 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -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 () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 073146093..cde640bed 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -197,7 +197,7 @@ export type CreateAgentRequestOptions = { config?: AgentSessionConfig; provider?: AgentProvider; cwd?: string; - workspaceId?: number; + workspaceId?: string | number; initialPrompt?: string; clientMessageId?: string; outputSchema?: Record; @@ -1322,6 +1322,23 @@ export class DaemonClient { }); } + async startWorkspaceService( + workspaceId: string, + serviceName: string, + requestId?: string, + ): Promise["payload"]> { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "start_workspace_service_request", + workspaceId, + serviceName, + }, + responseType: "start_workspace_service_response", + timeout: 10000, + }); + } + async archiveWorkspace( workspaceId: number, requestId?: string, @@ -1423,7 +1440,7 @@ export class DaemonClient { type: "create_agent_request", requestId, config, - ...(typeof options.workspaceId === "number" ? { workspaceId: options.workspaceId } : {}), + ...(options.workspaceId !== undefined ? { workspaceId: options.workspaceId } : {}), ...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}), ...(options.clientMessageId ? { clientMessageId: options.clientMessageId } : {}), ...(options.outputSchema ? { outputSchema: options.outputSchema } : {}), diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index e2ebaa28d..7ce0ca628 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -704,7 +704,7 @@ export async function createPaseoDaemon( serviceRouteStore, handleBranchChange, () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), - (hostname) => serviceHealthMonitor.getStatusForHostname(hostname), + (hostname) => serviceHealthMonitor.getHealthForHostname(hostname), ); logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening"); diff --git a/packages/server/src/server/service-health-monitor.test.ts b/packages/server/src/server/service-health-monitor.test.ts index 47f6b3b07..348e3c02b 100644 --- a/packages/server/src/server/service-health-monitor.test.ts +++ b/packages/server/src/server/service-health-monitor.test.ts @@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { findFreePort, ServiceRouteStore } from "./service-proxy.js"; import { ServiceHealthMonitor, - type ServiceStatusEntry, + type ServiceHealthEntry, } from "./service-health-monitor.js"; type TcpServerHandle = { @@ -82,7 +82,7 @@ describe("ServiceHealthMonitor", () => { serviceName: "api", }); - const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>(); + const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>(); const monitor = new ServiceHealthMonitor({ routeStore, onChange, @@ -101,7 +101,7 @@ describe("ServiceHealthMonitor", () => { serviceName: "api", hostname: "api.localhost", port: healthy.port, - status: "running", + health: "healthy", }, ]); }); @@ -118,7 +118,7 @@ describe("ServiceHealthMonitor", () => { serviceName: "api", }); - const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>(); + const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>(); const monitor = new ServiceHealthMonitor({ routeStore, onChange, @@ -141,7 +141,7 @@ describe("ServiceHealthMonitor", () => { serviceName: "api", hostname: "api.localhost", port: deadPort, - status: "stopped", + health: "unhealthy", }, ]); }); @@ -160,7 +160,7 @@ describe("ServiceHealthMonitor", () => { serviceName: "api", }); - const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>(); + const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>(); const monitor = new ServiceHealthMonitor({ routeStore, onChange, @@ -190,7 +190,7 @@ describe("ServiceHealthMonitor", () => { serviceName: "api", }); - const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>(); + const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>(); const monitor = new ServiceHealthMonitor({ routeStore, onChange, @@ -212,7 +212,7 @@ describe("ServiceHealthMonitor", () => { serviceName: "api", hostname: "api.localhost", port: healthy.port, - status: "running", + health: "healthy", }, ]); }); @@ -231,7 +231,7 @@ describe("ServiceHealthMonitor", () => { serviceName: "api", }); - const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>(); + const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>(); const monitor = new ServiceHealthMonitor({ routeStore, onChange, @@ -260,7 +260,7 @@ describe("ServiceHealthMonitor", () => { serviceName: "api", hostname: "api.localhost", port: healthy.port, - status: "stopped", + health: "unhealthy", }, ]); }); @@ -279,7 +279,7 @@ describe("ServiceHealthMonitor", () => { serviceName: "api", }); - const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>(); + const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>(); const monitor = new ServiceHealthMonitor({ routeStore, onChange, @@ -325,7 +325,7 @@ describe("ServiceHealthMonitor", () => { serviceName: "web", }); - const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>(); + const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>(); const monitor = new ServiceHealthMonitor({ routeStore, onChange, @@ -344,18 +344,18 @@ describe("ServiceHealthMonitor", () => { serviceName: "api", hostname: "api.localhost", port: api.port, - status: "running", + health: "healthy", }, { serviceName: "web", hostname: "web.localhost", 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(); const healthy = await startTcpServer(); @@ -369,7 +369,7 @@ describe("ServiceHealthMonitor", () => { serviceName: "api", }); - const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>(); + const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>(); const monitor = new ServiceHealthMonitor({ routeStore, onChange, @@ -378,14 +378,14 @@ describe("ServiceHealthMonitor", () => { graceMs: 0, }); - expect(monitor.getStatusForHostname("api.localhost")).toBeNull(); + expect(monitor.getHealthForHostname("api.localhost")).toBeNull(); monitor.start(); await advancePoll(1_000); monitor.stop(); - expect(monitor.getStatusForHostname("api.localhost")).toBe("running"); - expect(monitor.getStatusForHostname("unknown.localhost")).toBeNull(); + expect(monitor.getHealthForHostname("api.localhost")).toBe("healthy"); + expect(monitor.getHealthForHostname("unknown.localhost")).toBeNull(); }); it("coalesces multiple service changes in same workspace into one onChange call per poll cycle", async () => { @@ -410,7 +410,7 @@ describe("ServiceHealthMonitor", () => { serviceName: "web", }); - const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>(); + const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>(); const monitor = new ServiceHealthMonitor({ routeStore, onChange, @@ -442,13 +442,13 @@ describe("ServiceHealthMonitor", () => { serviceName: "api", hostname: "api.localhost", port: api.port, - status: "stopped", + health: "unhealthy", }, { serviceName: "web", hostname: "web.localhost", port: web.port, - status: "stopped", + health: "unhealthy", }, ]); }); diff --git a/packages/server/src/server/service-health-monitor.ts b/packages/server/src/server/service-health-monitor.ts index 376d014ca..fd6608ad5 100644 --- a/packages/server/src/server/service-health-monitor.ts +++ b/packages/server/src/server/service-health-monitor.ts @@ -1,15 +1,15 @@ import net from "node:net"; import type { ServiceRouteEntry, ServiceRouteStore } from "./service-proxy.js"; -export interface ServiceStatusEntry { +export interface ServiceHealthEntry { serviceName: string; hostname: string; port: number; - status: "running" | "stopped"; + health: "healthy" | "unhealthy"; } type RouteHealthState = { - status: ServiceStatusEntry["status"] | null; + health: ServiceHealthEntry["health"] | null; consecutiveFailures: number; registeredAt: number; }; @@ -18,7 +18,7 @@ export class ServiceHealthMonitor { private readonly routeStore: ServiceRouteStore; private readonly onChange: ( workspaceId: string, - services: ServiceStatusEntry[], + services: ServiceHealthEntry[], ) => void; private readonly pollIntervalMs: number; private readonly probeTimeoutMs: number; @@ -39,7 +39,7 @@ export class ServiceHealthMonitor { failuresBeforeStopped = 2, }: { routeStore: ServiceRouteStore; - onChange: (workspaceId: string, services: ServiceStatusEntry[]) => void; + onChange: (workspaceId: string, services: ServiceHealthEntry[]) => void; pollIntervalMs?: number; probeTimeoutMs?: number; graceMs?: number; @@ -94,19 +94,19 @@ export class ServiceHealthMonitor { } const isHealthy = await this.probeRoute(route.port); - const previousStatus = state.status; + const previousHealth = state.health; if (isHealthy) { state.consecutiveFailures = 0; - state.status = "running"; + state.health = "healthy"; } else { state.consecutiveFailures += 1; 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); } } @@ -135,7 +135,7 @@ export class ServiceHealthMonitor { } const state: RouteHealthState = { - status: null, + health: null, consecutiveFailures: 0, registeredAt, }; @@ -152,31 +152,31 @@ export class ServiceHealthMonitor { } } - private buildWorkspaceServiceList(workspaceId: string): ServiceStatusEntry[] { + private buildWorkspaceServiceList(workspaceId: string): ServiceHealthEntry[] { return this.routeStore .listRoutesForWorkspace(workspaceId) .flatMap((route) => { const state = this.routeStates.get(route.hostname); - if (!state?.status) { + if (!state?.health) { return []; } - return [this.toServiceStatusEntry(route, state.status)]; + return [this.toServiceHealthEntry(route, state.health)]; }); } - getStatusForHostname(hostname: string): ServiceStatusEntry["status"] | null { - return this.routeStates.get(hostname)?.status ?? null; + getHealthForHostname(hostname: string): ServiceHealthEntry["health"] | null { + return this.routeStates.get(hostname)?.health ?? null; } - private toServiceStatusEntry( + private toServiceHealthEntry( route: ServiceRouteEntry, - status: ServiceStatusEntry["status"], - ): ServiceStatusEntry { + health: ServiceHealthEntry["health"], + ): ServiceHealthEntry { return { serviceName: route.serviceName, hostname: route.hostname, port: route.port, - status, + health, }; } diff --git a/packages/server/src/server/service-proxy.ts b/packages/server/src/server/service-proxy.ts index fc7a23d0c..ac3c00545 100644 --- a/packages/server/src/server/service-proxy.ts +++ b/packages/server/src/server/service-proxy.ts @@ -99,6 +99,11 @@ export class ServiceRouteStore { return null; } + getRouteEntry(hostname: string): ServiceRouteEntry | null { + const entry = this.routes.get(hostname); + return entry ? { ...entry } : null; + } + listRoutes(): ServiceRouteEntry[] { return Array.from(this.routes.values()).map((entry) => ({ ...entry })); } diff --git a/packages/server/src/server/service-route-branch-handler.test.ts b/packages/server/src/server/service-route-branch-handler.test.ts index cbb1e13e4..1326cb865 100644 --- a/packages/server/src/server/service-route-branch-handler.test.ts +++ b/packages/server/src/server/service-route-branch-handler.test.ts @@ -111,7 +111,8 @@ describe("service-route-branch-handler", () => { hostname: "feature-billing.api.localhost", port: 3001, url: null, - status: "stopped", + lifecycle: "running", + health: null, }, ]); }); diff --git a/packages/server/src/server/service-status-projection.test.ts b/packages/server/src/server/service-status-projection.test.ts index 2074d0e13..7d6de16f8 100644 --- a/packages/server/src/server/service-status-projection.test.ts +++ b/packages/server/src/server/service-status-projection.test.ts @@ -1,269 +1,232 @@ 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 { buildWorkspaceServicePayloads, createServiceStatusEmitter, } from "./service-status-projection.js"; +function createWorkspaceRepo(options?: { + branchName?: string; + paseoConfig?: Record; +}): { tempDir: string; repoDir: string; cleanup: () => void } { + const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "service-projection-"))); + const 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", () => { - 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(); routeStore.registerRoute({ - hostname: "api.localhost", - port: 3001, - workspaceId: "workspace-a", - serviceName: "api", + hostname: "feature-card.web.localhost", + port: 4321, + workspaceId: workspace.repoDir, + 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({ hostname: "docs.localhost", port: 3002, - workspaceId: "workspace-b", + workspaceId: workspace.repoDir, serviceName: "docs", }); - routeStore.registerRoute({ - hostname: "web.localhost", - port: 3003, - workspaceId: "workspace-a", - serviceName: "web", - }); - expect(buildWorkspaceServicePayloads(routeStore, "workspace-a", 6767)).toEqual([ - { - serviceName: "api", - hostname: "api.localhost", - port: 3001, - url: "http://api.localhost:6767", - status: "stopped", - }, - { - serviceName: "web", - hostname: "web.localhost", - port: 3003, - url: "http://web.localhost:6767", - status: "stopped", - }, - ]); + try { + expect(buildWorkspaceServicePayloads(routeStore, workspace.repoDir, 6767)).toEqual([ + { + serviceName: "docs", + hostname: "docs.localhost", + port: 3002, + url: "http://docs.localhost:6767", + lifecycle: "running", + health: null, + }, + ]); + } finally { + workspace.cleanup(); + } }); - 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(); routeStore.registerRoute({ hostname: "api.localhost", port: 3001, - workspaceId: "workspace-a", + workspaceId: workspace.repoDir, 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([ - ["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 emitUpdate = createServiceStatusEmitter({ sessions: () => [session], routeStore, - daemonPort: null, + daemonPort: 6767, }); - emitUpdate("workspace-a", [ - { - serviceName: "api", - hostname: "api.localhost", - port: 3001, - status: "running", - }, - { - serviceName: "web", - hostname: "web.localhost", - port: 3002, - status: "stopped", - }, - ]); + try { + emitUpdate(workspace.repoDir, [ + { + serviceName: "api", + hostname: "api.localhost", + port: 3001, + health: "healthy", + }, + ]); - emitUpdate("workspace-b", [ - { - serviceName: "docs", - hostname: "docs.localhost", - port: 3003, - status: "running", - }, - ]); - - expect(session.emit).toHaveBeenNthCalledWith(1, { - type: "service_status_update", - payload: { - workspaceId: "workspace-a", - services: [ - { - serviceName: "api", - hostname: "api.localhost", - port: 3001, - url: null, - status: "running", - }, - { - serviceName: "web", - hostname: "web.localhost", - port: 3002, - url: null, - 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", - }, - ], - }, - }); + expect(session.emit).toHaveBeenCalledWith({ + type: "service_status_update", + payload: { + workspaceId: workspace.repoDir, + services: [ + { + serviceName: "api", + hostname: "api.localhost", + port: 3001, + url: "http://api.localhost:6767", + lifecycle: "running", + health: "healthy", + }, + { + serviceName: "web", + hostname: "web.localhost", + port: null, + url: "http://web.localhost:6767", + lifecycle: "stopped", + health: null, + }, + ], + }, + }); + } finally { + workspace.cleanup(); + } }); + 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(); + } + }); }); diff --git a/packages/server/src/server/service-status-projection.ts b/packages/server/src/server/service-status-projection.ts index 892ffa5ff..0be592a67 100644 --- a/packages/server/src/server/service-status-projection.ts +++ b/packages/server/src/server/service-status-projection.ts @@ -3,8 +3,11 @@ import type { SessionOutboundMessage, WorkspaceServicePayload, } from "../shared/messages.js"; -import type { ServiceStatusEntry } from "./service-health-monitor.js"; -import type { ServiceRouteStore } from "./service-proxy.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; @@ -24,19 +27,65 @@ function toServiceUrl(hostname: string, daemonPort: number | null): string | nul return `http://${hostname}:${daemonPort}`; } +type ConfiguredWorkspaceService = { + serviceName: string; + hostname: string; + port: number | null; +}; + +function resolveWorkspaceBranchName(workspaceDirectory: string): string | null { + return readGitCommand(workspaceDirectory, "git symbolic-ref --short HEAD"); +} + +function listConfiguredWorkspaceServices(workspaceDirectory: string): ConfiguredWorkspaceService[] { + const branchName = resolveWorkspaceBranchName(workspaceDirectory); + const serviceConfigs = getServiceConfigs(workspaceDirectory); + return Array.from(serviceConfigs.entries()).map(([serviceName, config]) => ({ + serviceName, + hostname: buildServiceHostname(branchName, serviceName), + port: config.port ?? null, + })); +} + +function mergeWorkspaceServiceDefinitions( + workspaceDirectory: string, + routeStore: ServiceRouteStore, +): Array { + const merged = new Map(); + + for (const service of listConfiguredWorkspaceServices(workspaceDirectory)) { + merged.set(service.hostname, service); + } + + for (const route of routeStore.listRoutesForWorkspace(workspaceDirectory)) { + merged.set(route.hostname, route); + } + + return Array.from(merged.values()).sort((left, right) => + left.serviceName.localeCompare(right.serviceName, undefined, { + numeric: true, + sensitivity: "base", + }), + ); +} + export function buildWorkspaceServicePayloads( routeStore: ServiceRouteStore, - workspaceId: string, + workspaceDirectory: string, daemonPort: number | null, - resolveStatus?: (hostname: string) => "running" | "stopped" | null, + resolveHealth?: (hostname: string) => "healthy" | "unhealthy" | null, ): WorkspaceServicePayload[] { - return routeStore.listRoutesForWorkspace(workspaceId).map((route) => ({ - serviceName: route.serviceName, - hostname: route.hostname, - port: route.port, - url: toServiceUrl(route.hostname, daemonPort), - status: resolveStatus?.(route.hostname) ?? "stopped", - })); + 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: { @@ -60,18 +109,18 @@ export function createServiceStatusEmitter({ sessions: () => SessionEmitter[]; routeStore: ServiceRouteStore; daemonPort: number | null | (() => number | null); -}): (workspaceId: string, services: ServiceStatusEntry[]) => void { +}): (workspaceId: string, services: ServiceHealthEntry[]) => void { return (workspaceId, services) => { const resolvedDaemonPort = resolveDaemonPort(daemonPort); - const serviceStatusByHostname = new Map( - services.map((service) => [service.hostname, service.status] as const), + const serviceHealthByHostname = new Map( + services.map((service) => [service.hostname, service.health] as const), ); - const projected = buildWorkspaceServicePayloads(routeStore, workspaceId, resolvedDaemonPort).map( - (service) => ({ - ...service, - status: serviceStatusByHostname.get(service.hostname) ?? service.status, - }), + const projected = buildWorkspaceServicePayloads( + routeStore, + workspaceId, + resolvedDaemonPort, + (hostname) => serviceHealthByHostname.get(hostname) ?? null, ); const message = buildServiceStatusUpdateMessage({ diff --git a/packages/server/src/server/session.provider-history-compatibility-ownership.test.ts b/packages/server/src/server/session.provider-history-compatibility-ownership.test.ts index 97acd7fa0..287df24ca 100644 --- a/packages/server/src/server/session.provider-history-compatibility-ownership.test.ts +++ b/packages/server/src/server/session.provider-history-compatibility-ownership.test.ts @@ -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 () => { const ensureAgentLoaded = vi.fn(async () => createCompatibilitySnapshot()); const { session, agentManager, emitted } = createSessionForOwnershipTests({ diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index c12094db2..600ca796b 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -19,6 +19,7 @@ import { type SubscribeTerminalsRequest, type UnsubscribeTerminalsRequest, type CreateTerminalRequest, + type StartWorkspaceServiceRequest, type SubscribeTerminalRequest, type UnsubscribeTerminalRequest, type TerminalInput, @@ -63,6 +64,8 @@ import { experimental_createMCPClient } from "ai"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js"; import { buildWorkspaceServicePayloads } from "./service-status-projection.js"; +import { spawnWorkspaceService } from "./worktree-bootstrap.js"; +import { readGitCommand } from "./workspace-git-metadata.js"; export type AgentMcpTransportFactory = () => Promise; import { buildProviderRegistry } from "./agent/provider-registry.js"; @@ -404,7 +407,7 @@ export type SessionOptions = { newBranch: string | null, ) => void; getDaemonTcpPort?: () => number | null; - resolveServiceStatus?: (hostname: string) => "running" | "stopped" | null; + resolveServiceHealth?: (hostname: string) => "healthy" | "unhealthy" | null; voice?: { voiceAgentMcpStdio?: VoiceMcpStdioConfig | null; turnDetection?: Resolvable; @@ -587,7 +590,9 @@ export class Session { newBranch: string | null, ) => void; 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(); private unsubscribeTerminalsChanged: (() => void) | null = null; private terminalExitSubscriptions: Map void> = new Map(); @@ -646,7 +651,7 @@ export class Session { serviceRouteStore, onBranchChanged, getDaemonTcpPort, - resolveServiceStatus, + resolveServiceHealth, voice, voiceBridge, dictation, @@ -688,7 +693,7 @@ export class Session { this.serviceRouteStore = serviceRouteStore ?? null; this.onBranchChanged = onBranchChanged; this.getDaemonTcpPort = getDaemonTcpPort ?? null; - this.resolveServiceStatus = resolveServiceStatus ?? null; + this.resolveServiceHealth = resolveServiceHealth ?? null; if (this.terminalManager) { this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) => this.handleTerminalsChanged(event), @@ -1739,6 +1744,10 @@ export class Session { await this.handleCreateTerminalRequest(msg); break; + case "start_workspace_service_request": + await this.handleStartWorkspaceServiceRequest(msg); + break; + case "subscribe_terminal_request": await this.handleSubscribeTerminalRequest(msg); break; @@ -5225,7 +5234,7 @@ export class Session { this.serviceRouteStore, workspace.directory, 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 { + 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( request: Extract, ): Promise { @@ -6095,11 +6185,38 @@ export class Session { }); const firstRow = timeline.rows[0]; 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) => ({ provider: snapshot.provider, item: row.item, timestamp: row.timestamp, seq: row.seq, + seqStart: row.seq, + seqEnd: row.seq, + sourceSeqRanges: [{ startSeq: row.seq, endSeq: row.seq }], + collapsed: [], })); this.emit({ @@ -6115,9 +6232,18 @@ export class Session { hasNewer: timeline.hasNewer, entries, error: null, + epoch, + reset: false, + staleCursor: false, + gap: false, + window, + startCursor, + endCursor, + projection: msg.projection ?? "projected", }, }); } catch (error) { + const epoch = `compat:${msg.agentId}`; this.sessionLogger.error( { err: error, agentId: msg.agentId }, "Failed to handle fetch_agent_timeline_request", @@ -6135,6 +6261,22 @@ export class Session { hasNewer: false, entries: [], 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", }, }); } diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index b82bef31f..57da8941d 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -248,7 +248,9 @@ export class VoiceAssistantWebSocketServer { private readonly terminalManager: TerminalManager | null; private readonly serviceRouteStore: ServiceRouteStore | 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: { finalTimeoutMs?: number; } | null; @@ -326,7 +328,7 @@ export class VoiceAssistantWebSocketServer { newBranch: string | null, ) => void, getDaemonTcpPort?: () => number | null, - resolveServiceStatus?: (hostname: string) => "running" | "stopped" | null, + resolveServiceHealth?: (hostname: string) => "healthy" | "unhealthy" | null, ) { this.logger = logger.child({ module: "websocket-server" }); this.serverId = serverId; @@ -373,7 +375,7 @@ export class VoiceAssistantWebSocketServer { this.serviceRouteStore = serviceRouteStore ?? null; this.onBranchChanged = onBranchChanged ?? null; this.getDaemonTcpPort = getDaemonTcpPort ?? null; - this.resolveServiceStatus = resolveServiceStatus ?? null; + this.resolveServiceHealth = resolveServiceHealth ?? null; this.serverCapabilities = buildServerCapabilities({ readiness: this.speech?.getReadiness() ?? null, }); @@ -693,7 +695,7 @@ export class VoiceAssistantWebSocketServer { serviceRouteStore: this.serviceRouteStore ?? undefined, onBranchChanged: this.onBranchChanged ?? undefined, getDaemonTcpPort: this.getDaemonTcpPort ?? undefined, - resolveServiceStatus: this.resolveServiceStatus ?? undefined, + resolveServiceHealth: this.resolveServiceHealth ?? undefined, voice: { ...(this.voice ?? {}), turnDetection: () => this.speech?.resolveTurnDetection() ?? null, diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index 80a1303d7..974cd5641 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -768,6 +768,115 @@ export interface WorktreeServiceResult { 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 { + 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 = { + 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: { repoRoot: string; workspaceId: string; @@ -776,77 +885,22 @@ export async function spawnWorktreeServices(options: { routeStore: ServiceRouteStore; terminalManager: TerminalManager; logger?: Logger; + onLifecycleChanged?: () => void; }): Promise { - const { repoRoot, workspaceId, branchName, daemonPort, routeStore, terminalManager, logger } = - options; + const { repoRoot } = options; const serviceConfigs = getServiceConfigs(repoRoot); if (serviceConfigs.size === 0) { return []; } const results: WorktreeServiceResult[] = []; - - for (const [serviceName, config] of serviceConfigs) { - let hostname: string | null = null; - let port: number | null = null; - - try { - port = config.port ?? (await findFreePort()); - hostname = buildServiceHostname(branchName, serviceName); - - routeStore.registerRoute({ - hostname, - port, - workspaceId, + for (const serviceName of serviceConfigs.keys()) { + results.push( + await spawnWorkspaceService({ + ...options, serviceName, - }); - - const env: Record = { - 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; diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index 2a8d538f8..22b476702 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -57,6 +57,7 @@ function createTerminalManagerStub(options?: { grid: [], }), subscribe: () => () => {}, + onExit: () => () => {}, send: (message: { type: string; data: string }) => { if (message.type === "input") { sent.push(message.data); diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 64cf933ab..10c27f4b7 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -778,6 +778,9 @@ export async function createPaseoWorktreeInBackground( routeStore: dependencies.serviceRouteStore, terminalManager: dependencies.terminalManager, logger: dependencies.sessionLogger, + onLifecycleChanged: () => { + void dependencies.emitWorkspaceUpdateForCwd(worktree.worktreePath); + }, }); } catch (error) { dependencies.sessionLogger.error( diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index df72eb4a7..5db18def3 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1262,6 +1262,13 @@ export const CreateTerminalRequestSchema = z.object({ 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({ type: z.literal("subscribe_terminal_request"), terminalId: z.string(), @@ -1373,6 +1380,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ SubscribeTerminalsRequestSchema, UnsubscribeTerminalsRequestSchema, CreateTerminalRequestSchema, + StartWorkspaceServiceRequestSchema, SubscribeTerminalRequestSchema, UnsubscribeTerminalRequestSchema, TerminalInputSchema, @@ -1728,12 +1736,16 @@ export const ProjectPlacementPayloadSchema = z.object({ checkout: ProjectCheckoutLitePayloadSchema, }); +export const WorkspaceServiceLifecycleSchema = z.enum(["running", "stopped"]); +export const WorkspaceServiceHealthSchema = z.enum(["healthy", "unhealthy"]); + export const WorkspaceServicePayloadSchema = z.object({ serviceName: z.string(), hostname: z.string(), - port: z.number().int().positive(), + port: z.number().int().positive().nullable(), url: z.string().nullable(), - status: z.enum(["running", "stopped"]), + lifecycle: WorkspaceServiceLifecycleSchema, + health: WorkspaceServiceHealthSchema.nullable(), }); 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({ type: z.literal("archive_workspace_response"), payload: z.object({ @@ -2585,6 +2607,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ FetchAgentsResponseMessageSchema, FetchWorkspacesResponseMessageSchema, OpenProjectResponseMessageSchema, + StartWorkspaceServiceResponseMessageSchema, ArchiveWorkspaceResponseMessageSchema, FetchAgentResponseMessageSchema, FetchAgentTimelineResponseMessageSchema, @@ -2682,11 +2705,16 @@ export type ProjectCheckoutLitePayload = z.infer; export type WorkspaceStateBucket = z.infer; export type WorkspaceDescriptorPayload = z.infer; +export type WorkspaceServiceLifecycle = z.infer; +export type WorkspaceServiceHealth = z.infer; export type WorkspaceServicePayload = z.infer; export type FetchAgentsResponseMessage = z.infer; export type FetchWorkspacesResponseMessage = z.infer; export type ServiceStatusUpdateMessage = z.infer; export type OpenProjectResponseMessage = z.infer; +export type StartWorkspaceServiceResponseMessage = z.infer< + typeof StartWorkspaceServiceResponseMessageSchema +>; export type ArchiveWorkspaceResponseMessage = z.infer; export type FetchAgentResponseMessage = z.infer; export type FetchAgentTimelineResponseMessage = z.infer< @@ -2860,6 +2888,8 @@ export type UnsubscribeTerminalsRequest = z.infer; export type CreateTerminalRequest = z.infer; export type CreateTerminalResponse = z.infer; +export type StartWorkspaceServiceRequest = z.infer; +export type StartWorkspaceServiceResponse = z.infer; export type SubscribeTerminalRequest = z.infer; export type SubscribeTerminalResponse = z.infer; export type UnsubscribeTerminalRequest = z.infer; diff --git a/packages/server/src/shared/messages.workspaces.test.ts b/packages/server/src/shared/messages.workspaces.test.ts index 8562e711a..c8c1104a9 100644 --- a/packages/server/src/shared/messages.workspaces.test.ts +++ b/packages/server/src/shared/messages.workspaces.test.ts @@ -74,7 +74,8 @@ describe("workspace message schemas", () => { hostname: "web.localhost", port: 3000, url: "http://web.localhost:6767", - status: "running", + lifecycle: "running", + health: "healthy", }, ], }, @@ -91,7 +92,8 @@ describe("workspace message schemas", () => { hostname: "web.localhost", port: 3000, url: "http://web.localhost:6767", - status: "running", + lifecycle: "running", + health: "healthy", }, ]); }); @@ -134,9 +136,10 @@ describe("workspace message schemas", () => { { serviceName: "web", hostname: "web.localhost", - port: 3000, + port: null, url: null, - status: "stopped", + lifecycle: "stopped", + health: null, }, ], },