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 });
}
/** 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. */
async function expectWorkspaceNameInCard(page: Page, name: string): Promise<void> {
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();
}
});
});
@@ -262,7 +262,7 @@ function WorkspaceStatusIndicator({
}
const KindIcon =
workspaceKind === "checkout"
workspaceKind === "local_checkout"
? Monitor
: workspaceKind === "worktree"
? FolderGit2
@@ -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<PrHint["state"], string> = {
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({
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<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const startServiceMutation = useMutation({
mutationFn: async (serviceName: string) => {
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) => (
<Pressable
key={service.hostname}
accessibilityRole="link"
accessibilityRole={service.lifecycle === "running" && service.url ? "link" : undefined}
accessibilityLabel={`${service.serviceName} service`}
testID={`hover-card-service-${service.serviceName}`}
style={({ hovered }) => [
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}
>
<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
style={[
styles.serviceName,
{ color: service.status === "running" ? theme.colors.foreground : theme.colors.foregroundMuted },
{
color:
service.lifecycle === "running"
? theme.colors.foreground
: theme.colors.foregroundMuted,
},
]}
numberOfLines={1}
>
{service.serviceName}
</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}>
{service.url.replace(/^https?:\/\//, "")}
</Text>
) : null}
{service.url ? (
) : (
<View style={styles.serviceUrlSpacer} />
)}
{service.lifecycle === "running" && service.url ? (
<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>
))}
</View>
@@ -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,
@@ -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 }
: {}),
@@ -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", () => {
@@ -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", () => {
@@ -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);
@@ -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 }
: {}),
@@ -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 } : {}),
@@ -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",
},
]);
});
@@ -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;
@@ -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();
+19 -2
View File
@@ -197,7 +197,7 @@ export type CreateAgentRequestOptions = {
config?: AgentSessionConfig;
provider?: AgentProvider;
cwd?: string;
workspaceId?: number;
workspaceId?: string | number;
initialPrompt?: string;
clientMessageId?: string;
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(
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 } : {}),
+1 -1
View File
@@ -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");
@@ -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",
},
]);
});
@@ -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,
};
}
@@ -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 }));
}
@@ -111,7 +111,8 @@ describe("service-route-branch-handler", () => {
hostname: "feature-billing.api.localhost",
port: 3001,
url: null,
status: "stopped",
lifecycle: "running",
health: null,
},
]);
});
@@ -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<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", () => {
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<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 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();
}
});
});
@@ -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<ConfiguredWorkspaceService | ServiceRouteEntry> {
const merged = new Map<string, ConfiguredWorkspaceService | ServiceRouteEntry>();
for (const service of listConfiguredWorkspaceServices(workspaceDirectory)) {
merged.set(service.hostname, service);
}
for (const route of routeStore.listRoutesForWorkspace(workspaceDirectory)) {
merged.set(route.hostname, route);
}
return Array.from(merged.values()).sort((left, right) =>
left.serviceName.localeCompare(right.serviceName, undefined, {
numeric: true,
sensitivity: "base",
}),
);
}
export function buildWorkspaceServicePayloads(
routeStore: ServiceRouteStore,
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({
@@ -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({
+147 -5
View File
@@ -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<Transport>;
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<TurnDetectionProvider | null>;
@@ -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<string>();
private unsubscribeTerminalsChanged: (() => void) | null = null;
private terminalExitSubscriptions: Map<string, () => 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<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(
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
): Promise<void> {
@@ -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",
},
});
}
@@ -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,
+117 -63
View File
@@ -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<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: {
repoRoot: string;
workspaceId: string;
@@ -776,77 +885,22 @@ export async function spawnWorktreeServices(options: {
routeStore: ServiceRouteStore;
terminalManager: TerminalManager;
logger?: Logger;
onLifecycleChanged?: () => void;
}): Promise<WorktreeServiceResult[]> {
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<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;
@@ -57,6 +57,7 @@ function createTerminalManagerStub(options?: {
grid: [],
}),
subscribe: () => () => {},
onExit: () => () => {},
send: (message: { type: string; data: string }) => {
if (message.type === "input") {
sent.push(message.data);
@@ -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(
+32 -2
View File
@@ -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<typeof ProjectCheckoutLitePaylo
export type ProjectPlacementPayload = z.infer<typeof ProjectPlacementPayloadSchema>;
export type WorkspaceStateBucket = z.infer<typeof WorkspaceStateBucketSchema>;
export type WorkspaceDescriptorPayload = z.infer<typeof WorkspaceDescriptorPayloadSchema>;
export type WorkspaceServiceLifecycle = z.infer<typeof WorkspaceServiceLifecycleSchema>;
export type WorkspaceServiceHealth = z.infer<typeof WorkspaceServiceHealthSchema>;
export type WorkspaceServicePayload = z.infer<typeof WorkspaceServicePayloadSchema>;
export type FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessageSchema>;
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>;
export type ServiceStatusUpdateMessage = z.infer<typeof ServiceStatusUpdateMessageSchema>;
export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessageSchema>;
export type StartWorkspaceServiceResponseMessage = z.infer<
typeof StartWorkspaceServiceResponseMessageSchema
>;
export type ArchiveWorkspaceResponseMessage = z.infer<typeof ArchiveWorkspaceResponseMessageSchema>;
export type FetchAgentResponseMessage = z.infer<typeof FetchAgentResponseMessageSchema>;
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 CreateTerminalRequest = z.infer<typeof CreateTerminalRequestSchema>;
export type CreateTerminalResponse = z.infer<typeof CreateTerminalResponseSchema>;
export type StartWorkspaceServiceRequest = z.infer<typeof StartWorkspaceServiceRequestSchema>;
export type StartWorkspaceServiceResponse = z.infer<typeof StartWorkspaceServiceResponseMessageSchema>;
export type SubscribeTerminalRequest = z.infer<typeof SubscribeTerminalRequestSchema>;
export type SubscribeTerminalResponse = z.infer<typeof SubscribeTerminalResponseSchema>;
export type UnsubscribeTerminalRequest = z.infer<typeof UnsubscribeTerminalRequestSchema>;
@@ -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,
},
],
},