feat: add service start capability with health monitoring
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user