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