mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-07-09 16:09:13 +00:00
Improve local browser streaming devex (#6026)
This commit is contained in:
parent
47892c98e2
commit
4988f038b2
19 changed files with 983 additions and 71 deletions
|
|
@ -1,7 +1,7 @@
|
|||
# Environment that the agent will run in.
|
||||
ENV=local
|
||||
|
||||
# Browser streaming mode: "cdp" for local CDP screencast, "vnc" for VNC streaming.
|
||||
# Browser streaming mode: "cdp" for local browser streaming, "vnc" for VNC streaming.
|
||||
# Quickstart writes "cdp" for new local installs; app code falls back to "vnc" if unset.
|
||||
BROWSER_STREAMING_MODE=cdp
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# Browser streaming mode:
|
||||
# - cdp: use CDP screencast for local livestreaming
|
||||
# - cdp: use local browser streaming through the backend
|
||||
# - vnc: use VNC streaming
|
||||
# Quickstart writes cdp for new local installs; app code falls back to vnc if unset.
|
||||
VITE_BROWSER_STREAMING_MODE=cdp
|
||||
|
|
|
|||
|
|
@ -40,6 +40,10 @@ import { wssBaseUrl, newWssBaseUrl, getRuntimeApiKey } from "@/util/env";
|
|||
import { copyText } from "@/util/copyText";
|
||||
import { cn } from "@/util/utils";
|
||||
import { captureRecordBrowser } from "@/util/recordBrowserTelemetry";
|
||||
import {
|
||||
StreamStatusPanel,
|
||||
type StreamDiagnostic,
|
||||
} from "@/routes/streaming/StreamDiagnostics";
|
||||
|
||||
import { RotateThrough } from "./RotateThrough";
|
||||
import "./browser-stream.css";
|
||||
|
|
@ -779,6 +783,34 @@ function BrowserStream({
|
|||
|
||||
const theUserIsControlling =
|
||||
userIsControlling || (interactive && !showControlButtons);
|
||||
const streamDiagnostic: StreamDiagnostic =
|
||||
entity === "browserSession" && browserSessionId && !hasBrowserSession
|
||||
? {
|
||||
title: "Browser session is no longer live",
|
||||
detail: "This live browser session is no longer streaming.",
|
||||
hint: "Refresh the page or create a new browser session.",
|
||||
}
|
||||
: !isBrowserSessionBackendReady
|
||||
? {
|
||||
title: "Waiting for browser session",
|
||||
detail:
|
||||
"The session exists, but the backend has not marked the browser as ready yet.",
|
||||
}
|
||||
: !isVncConnected
|
||||
? {
|
||||
title: "Connecting to VNC stream",
|
||||
detail: "Opening the browser stream and message WebSockets.",
|
||||
hint: "If this stays here, check VNC support for the session or use local browser streaming.",
|
||||
}
|
||||
: !isCanvasReady
|
||||
? {
|
||||
title: "Preparing browser display",
|
||||
detail:
|
||||
"The VNC connection is open and the UI is waiting for the browser canvas.",
|
||||
}
|
||||
: {
|
||||
title: "Connecting to browser stream",
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -914,25 +946,18 @@ function BrowserStream({
|
|||
)}
|
||||
{!isReady && (
|
||||
<div className="absolute left-0 top-1/2 flex aspect-video max-h-full w-full -translate-y-1/2 flex-col items-center justify-center gap-2 rounded-md border border-slate-800 text-sm text-slate-400">
|
||||
{entity === "browserSession" &&
|
||||
browserSessionId &&
|
||||
!hasBrowserSession ? (
|
||||
<div>This live browser session is no longer streaming.</div>
|
||||
) : (
|
||||
<>
|
||||
<RotateThrough interval={7 * 1000}>
|
||||
<span>Hm, working on the connection...</span>
|
||||
<span>Hang tight, we're almost there...</span>
|
||||
<span>Just a moment...</span>
|
||||
<span>Backpropagating...</span>
|
||||
<span>Attention is all I need...</span>
|
||||
<span>Consulting the manual...</span>
|
||||
<span>Looking for the bat phone...</span>
|
||||
<span>Where's Shu?...</span>
|
||||
</RotateThrough>
|
||||
<AnimatedWave text=".‧₊˚ ⋅ ? ✨ ?★ ‧₊˚ ⋅" />
|
||||
</>
|
||||
)}
|
||||
<StreamStatusPanel diagnostic={streamDiagnostic}>
|
||||
{isBrowserSessionBackendReady && (
|
||||
<>
|
||||
<RotateThrough interval={7 * 1000}>
|
||||
<span>Checking browser stream readiness...</span>
|
||||
<span>Waiting for the browser display...</span>
|
||||
<span>Verifying the stream connection...</span>
|
||||
</RotateThrough>
|
||||
<AnimatedWave text=". . ." />
|
||||
</>
|
||||
)}
|
||||
</StreamStatusPanel>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
32
skyvern-frontend/src/hooks/useRuntimeConfig.test.ts
Normal file
32
skyvern-frontend/src/hooks/useRuntimeConfig.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
async function loadRuntimeConfigHelpers() {
|
||||
vi.resetModules();
|
||||
vi.stubEnv("VITE_API_BASE_URL", "http://localhost:8000/api/v1");
|
||||
vi.stubEnv("VITE_ARTIFACT_API_BASE_URL", "http://localhost:9090");
|
||||
vi.stubEnv("VITE_ENVIRONMENT", "test");
|
||||
vi.stubEnv("VITE_WSS_BASE_URL", "ws://localhost:8000/api/v1");
|
||||
vi.stubEnv("VITE_BROWSER_STREAMING_MODE", "cdp");
|
||||
return import("./useRuntimeConfig");
|
||||
}
|
||||
|
||||
describe("runtime config helpers", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("normalizes supported browser streaming modes", async () => {
|
||||
const { normalizeBrowserStreamingMode } = await loadRuntimeConfigHelpers();
|
||||
|
||||
expect(normalizeBrowserStreamingMode("CDP")).toBe("cdp");
|
||||
expect(normalizeBrowserStreamingMode("vnc")).toBe("vnc");
|
||||
});
|
||||
|
||||
it("falls back to vnc for invalid browser streaming modes", async () => {
|
||||
const { normalizeBrowserStreamingMode } = await loadRuntimeConfigHelpers();
|
||||
|
||||
expect(normalizeBrowserStreamingMode("unexpected")).toBe("vnc");
|
||||
expect(normalizeBrowserStreamingMode(undefined)).toBe("vnc");
|
||||
});
|
||||
});
|
||||
64
skyvern-frontend/src/hooks/useRuntimeConfig.ts
Normal file
64
skyvern-frontend/src/hooks/useRuntimeConfig.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { getClient } from "@/api/AxiosClient";
|
||||
import { browserStreamingMode as buildTimeBrowserStreamingMode } from "@/util/env";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export type BrowserStreamingMode = "cdp" | "vnc";
|
||||
|
||||
type RuntimeConfigResponse = {
|
||||
browser_streaming_mode?: string;
|
||||
browser_streaming_label?: string;
|
||||
environment?: string;
|
||||
warnings?: string[];
|
||||
};
|
||||
|
||||
const STREAMING_MODES = new Set(["cdp", "vnc"]);
|
||||
|
||||
function normalizeBrowserStreamingMode(
|
||||
value: string | null | undefined,
|
||||
): BrowserStreamingMode {
|
||||
const normalized = (value ?? "").trim().toLowerCase();
|
||||
return STREAMING_MODES.has(normalized)
|
||||
? (normalized as BrowserStreamingMode)
|
||||
: "vnc";
|
||||
}
|
||||
|
||||
function browserStreamingLabel(mode: BrowserStreamingMode) {
|
||||
return mode === "cdp" ? "Local browser streaming" : "VNC streaming";
|
||||
}
|
||||
|
||||
function useRuntimeConfig() {
|
||||
return useQuery<RuntimeConfigResponse>({
|
||||
queryKey: ["runtimeConfig"],
|
||||
queryFn: async () => {
|
||||
const client = await getClient(null, "sans-api-v1");
|
||||
return client.get("/config/runtime").then((response) => response.data);
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
|
||||
function useBrowserStreamingMode() {
|
||||
const query = useRuntimeConfig();
|
||||
const mode = normalizeBrowserStreamingMode(
|
||||
query.data?.browser_streaming_mode ?? buildTimeBrowserStreamingMode,
|
||||
);
|
||||
|
||||
return {
|
||||
browserStreamingMode: mode,
|
||||
browserStreamingLabel:
|
||||
query.data?.browser_streaming_label ?? browserStreamingLabel(mode),
|
||||
runtimeConfigSource: query.data ? "backend" : "build-time-fallback",
|
||||
runtimeConfigWarnings: query.data?.warnings ?? [],
|
||||
runtimeConfigQuery: query,
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
browserStreamingLabel,
|
||||
normalizeBrowserStreamingMode,
|
||||
useBrowserStreamingMode,
|
||||
useRuntimeConfig,
|
||||
};
|
||||
|
|
@ -25,15 +25,17 @@ import { SaveSessionAsBrowserProfileDialog } from "@/routes/browserProfiles/Save
|
|||
import { useBackgroundBrowserProfileCreate } from "@/routes/browserProfiles/hooks/useBackgroundBrowserProfileCreate";
|
||||
import { CopyText } from "@/routes/workflows/editor/Workspace";
|
||||
import { type BrowserSession as BrowserSessionType } from "@/routes/workflows/types/browserSessionTypes";
|
||||
import { browserStreamingMode } from "@/util/env";
|
||||
import { useBrowserStreamingMode } from "@/hooks/useRuntimeConfig";
|
||||
import {
|
||||
StreamModeBadge,
|
||||
type StreamMode,
|
||||
} from "@/routes/streaming/StreamDiagnostics";
|
||||
|
||||
import { BrowserSessionDownloads } from "./BrowserSessionDownloads";
|
||||
import { BrowserSessionVideo } from "./BrowserSessionVideo";
|
||||
import { BrowserSessionStream } from "./BrowserSessionStream";
|
||||
import { BrowserSessionWorkflowRuns } from "./BrowserSessionWorkflowRuns";
|
||||
|
||||
const isCdpMode = browserStreamingMode === "cdp";
|
||||
|
||||
type TabName = "stream" | "recordings" | "downloads" | "runs";
|
||||
|
||||
function BrowserSession() {
|
||||
|
|
@ -49,6 +51,8 @@ function BrowserSession() {
|
|||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [isSaveProfileDialogOpen, setIsSaveProfileDialogOpen] = useState(false);
|
||||
const [vncFailed, setVncFailed] = useState(false);
|
||||
const { browserStreamingMode } = useBrowserStreamingMode();
|
||||
const isCdpMode = browserStreamingMode === "cdp";
|
||||
|
||||
useEffect(() => {
|
||||
setVncFailed(false);
|
||||
|
|
@ -71,6 +75,13 @@ function BrowserSession() {
|
|||
});
|
||||
|
||||
const browserSession = query.data;
|
||||
const streamMode: StreamMode = browserSession?.vnc_streaming_supported
|
||||
? vncFailed
|
||||
? "fallback"
|
||||
: "vnc"
|
||||
: isCdpMode
|
||||
? "cdp"
|
||||
: "unavailable";
|
||||
|
||||
const closeBrowserSessionMutation = useCloseBrowserSessionMutation({
|
||||
browserSessionId,
|
||||
|
|
@ -108,6 +119,7 @@ function BrowserSession() {
|
|||
<div className="flex w-full flex-row items-center justify-start gap-2">
|
||||
<LogoMinimized />
|
||||
<div className="text-xl">Browser Session</div>
|
||||
{activeTab === "stream" && <StreamModeBadge mode={streamMode} />}
|
||||
{browserSession && (
|
||||
<div className="ml-auto flex flex-col items-end justify-end overflow-hidden">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@ import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
|||
import { newWssBaseUrl, getCredentialParam } from "@/util/env";
|
||||
import { useCdpInput } from "@/routes/streaming/useCdpInput";
|
||||
import { InteractiveStreamView } from "@/routes/streaming/InteractiveStreamView";
|
||||
import {
|
||||
StreamStatusPanel,
|
||||
type StreamDiagnostic,
|
||||
} from "@/routes/streaming/StreamDiagnostics";
|
||||
|
||||
type StreamMessage = {
|
||||
browser_session_id?: string;
|
||||
|
|
@ -14,6 +18,64 @@ type StreamMessage = {
|
|||
url?: string;
|
||||
};
|
||||
|
||||
const STARTING_DIAGNOSTIC: StreamDiagnostic = {
|
||||
title: "Starting local browser stream",
|
||||
detail:
|
||||
"Opening the stream WebSocket and waiting for the first browser frame.",
|
||||
};
|
||||
|
||||
function diagnosticForStatus(status: string): StreamDiagnostic {
|
||||
switch (status) {
|
||||
case "not_found":
|
||||
return {
|
||||
title: "Browser session not found",
|
||||
detail:
|
||||
"The backend could not find this browser session for the current organization.",
|
||||
hint: "Refresh the page or create a new browser session.",
|
||||
};
|
||||
case "timeout":
|
||||
return {
|
||||
title: "Timed out waiting for browser state",
|
||||
detail:
|
||||
"The stream connected, but the backend did not find an active page to screencast.",
|
||||
hint: "Check backend logs for browser launch errors and verify BROWSER_STREAMING_MODE=cdp.",
|
||||
};
|
||||
case "completed":
|
||||
case "failed":
|
||||
return {
|
||||
title: "Browser session is no longer live",
|
||||
detail: `The browser session status is ${status}.`,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: "Waiting for browser frames",
|
||||
detail: `The stream is connected and the session status is ${status}.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function diagnosticForClose(event: CloseEvent): StreamDiagnostic {
|
||||
if (event.code === 4001 || event.reason === "use-vnc-streaming") {
|
||||
return {
|
||||
title: "Backend is using VNC streaming",
|
||||
detail:
|
||||
"The UI tried local browser streaming, but the backend closed the stream with use-vnc-streaming.",
|
||||
hint: "Check BROWSER_STREAMING_MODE on the backend and the runtime config response.",
|
||||
};
|
||||
}
|
||||
if (event.code === 1006) {
|
||||
return {
|
||||
title: "Stream connection dropped",
|
||||
detail: "The browser stream WebSocket closed before sending a frame.",
|
||||
hint: "Check that the API server is running and reachable from the UI.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "Stream connection closed",
|
||||
detail: `WebSocket closed with code ${event.code}${event.reason ? ` (${event.reason})` : ""}.`,
|
||||
};
|
||||
}
|
||||
|
||||
interface Props {
|
||||
browserSessionId: string;
|
||||
interactive?: boolean;
|
||||
|
|
@ -32,9 +94,12 @@ function BrowserSessionStream({
|
|||
const [viewportWidth, setViewportWidth] = useState(1280);
|
||||
const [viewportHeight, setViewportHeight] = useState(720);
|
||||
const [currentUrl, setCurrentUrl] = useState("");
|
||||
const [diagnostic, setDiagnostic] =
|
||||
useState<StreamDiagnostic>(STARTING_DIAGNOSTIC);
|
||||
const credentialGetter = useCredentialGetter();
|
||||
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const hasFrameRef = useRef(false);
|
||||
|
||||
const inputWsUrl = interactive
|
||||
? `${newWssBaseUrl}/stream/cdp_input/browser_session/${browserSessionId}`
|
||||
|
|
@ -60,6 +125,8 @@ function BrowserSessionStream({
|
|||
setViewportWidth(1280);
|
||||
setViewportHeight(720);
|
||||
setCurrentUrl("");
|
||||
setDiagnostic(STARTING_DIAGNOSTIC);
|
||||
hasFrameRef.current = false;
|
||||
|
||||
async function run() {
|
||||
const credentialParam = await getCredentialParam(credentialGetter);
|
||||
|
|
@ -74,10 +141,18 @@ function BrowserSessionStream({
|
|||
`${newWssBaseUrl}/stream/browser_sessions/${browserSessionId}?${credentialParam}`,
|
||||
);
|
||||
|
||||
socketRef.current.addEventListener("open", () => {
|
||||
setDiagnostic({
|
||||
title: "Connected to stream",
|
||||
detail: "Waiting for the backend to attach to the browser page.",
|
||||
});
|
||||
});
|
||||
|
||||
socketRef.current.addEventListener("message", (event) => {
|
||||
try {
|
||||
const message: StreamMessage = JSON.parse(event.data);
|
||||
if (message.screenshot) {
|
||||
hasFrameRef.current = true;
|
||||
setStreamImgSrc(message.screenshot);
|
||||
}
|
||||
if (message.format) {
|
||||
|
|
@ -92,6 +167,9 @@ function BrowserSessionStream({
|
|||
if (message.url !== undefined) {
|
||||
setCurrentUrl(message.url);
|
||||
}
|
||||
if (!message.screenshot && message.status) {
|
||||
setDiagnostic(diagnosticForStatus(message.status));
|
||||
}
|
||||
if (
|
||||
message.status === "completed" ||
|
||||
message.status === "failed" ||
|
||||
|
|
@ -101,10 +179,25 @@ function BrowserSessionStream({
|
|||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse message", e);
|
||||
setDiagnostic({
|
||||
title: "Unexpected stream message",
|
||||
detail: "The browser stream sent a message the UI could not parse.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socketRef.current.addEventListener("close", () => {
|
||||
socketRef.current.addEventListener("error", () => {
|
||||
setDiagnostic({
|
||||
title: "Stream WebSocket error",
|
||||
detail:
|
||||
"The browser stream connection hit a network or server error.",
|
||||
});
|
||||
});
|
||||
|
||||
socketRef.current.addEventListener("close", (event) => {
|
||||
if (!cancelled && !hasFrameRef.current) {
|
||||
setDiagnostic(diagnosticForClose(event));
|
||||
}
|
||||
socketRef.current = null;
|
||||
});
|
||||
}
|
||||
|
|
@ -151,11 +244,7 @@ function BrowserSessionStream({
|
|||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-sm text-slate-400">
|
||||
Starting stream...
|
||||
</div>
|
||||
);
|
||||
return <StreamStatusPanel diagnostic={diagnostic} />;
|
||||
}
|
||||
|
||||
export { BrowserSessionStream };
|
||||
|
|
|
|||
95
skyvern-frontend/src/routes/streaming/StreamDiagnostics.tsx
Normal file
95
skyvern-frontend/src/routes/streaming/StreamDiagnostics.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { InfoCircledIcon } from "@radix-ui/react-icons";
|
||||
import { cn } from "@/util/utils";
|
||||
|
||||
type StreamDiagnostic = {
|
||||
title: string;
|
||||
detail?: string;
|
||||
hint?: string;
|
||||
};
|
||||
|
||||
type StreamMode = "cdp" | "vnc" | "fallback" | "unavailable";
|
||||
|
||||
const STREAM_MODE_COPY: Record<
|
||||
StreamMode,
|
||||
{ label: string; title: string; className: string }
|
||||
> = {
|
||||
cdp: {
|
||||
label: "Local stream",
|
||||
title: "Local browser streaming through the backend",
|
||||
className: "border-cyan-500/40 bg-cyan-500/10 text-cyan-200",
|
||||
},
|
||||
vnc: {
|
||||
label: "VNC",
|
||||
title: "VNC browser streaming",
|
||||
className: "border-emerald-500/40 bg-emerald-500/10 text-emerald-200",
|
||||
},
|
||||
fallback: {
|
||||
label: "VNC -> Local",
|
||||
title: "VNC disconnected; using local browser streaming fallback",
|
||||
className: "border-amber-500/40 bg-amber-500/10 text-amber-200",
|
||||
},
|
||||
unavailable: {
|
||||
label: "Unavailable",
|
||||
title: "Browser streaming is unavailable for this session",
|
||||
className: "border-slate-500/40 bg-slate-500/10 text-slate-300",
|
||||
},
|
||||
};
|
||||
|
||||
function StreamModeBadge({
|
||||
mode,
|
||||
className,
|
||||
}: {
|
||||
mode: StreamMode;
|
||||
className?: string;
|
||||
}) {
|
||||
const copy = STREAM_MODE_COPY[mode];
|
||||
return (
|
||||
<span
|
||||
title={copy.title}
|
||||
className={cn(
|
||||
"inline-flex h-5 items-center rounded border px-2 text-[0.68rem] font-medium uppercase leading-none tracking-normal",
|
||||
copy.className,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{copy.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamStatusPanel({
|
||||
diagnostic,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
diagnostic: StreamDiagnostic;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-md bg-slate-900 p-6 text-slate-300",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex max-w-md flex-col gap-2 text-sm">
|
||||
<div className="flex items-center gap-2 font-medium text-slate-100">
|
||||
<InfoCircledIcon className="h-4 w-4 flex-shrink-0 text-slate-400" />
|
||||
<span>{diagnostic.title}</span>
|
||||
</div>
|
||||
{diagnostic.detail && (
|
||||
<div className="text-slate-400">{diagnostic.detail}</div>
|
||||
)}
|
||||
{diagnostic.hint && (
|
||||
<div className="text-xs text-slate-500">{diagnostic.hint}</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { StreamModeBadge, StreamStatusPanel };
|
||||
export type { StreamDiagnostic, StreamMode };
|
||||
|
|
@ -7,7 +7,12 @@ import { toast } from "@/components/ui/use-toast";
|
|||
import { ZoomableImage } from "@/components/ZoomableImage";
|
||||
import { useCostCalculator } from "@/hooks/useCostCalculator";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import { browserStreamingMode, getCredentialParam } from "@/util/env";
|
||||
import { useBrowserStreamingMode } from "@/hooks/useRuntimeConfig";
|
||||
import { getCredentialParam } from "@/util/env";
|
||||
import {
|
||||
StreamStatusPanel,
|
||||
type StreamDiagnostic,
|
||||
} from "@/routes/streaming/StreamDiagnostics";
|
||||
import {
|
||||
keepPreviousData,
|
||||
useQuery,
|
||||
|
|
@ -36,6 +41,56 @@ type StreamMessage = {
|
|||
format?: string;
|
||||
};
|
||||
|
||||
const STARTING_DIAGNOSTIC: StreamDiagnostic = {
|
||||
title: "Starting browser stream",
|
||||
detail:
|
||||
"Opening the stream WebSocket and waiting for the first browser frame.",
|
||||
};
|
||||
|
||||
function diagnosticForStatus(status: string): StreamDiagnostic {
|
||||
switch (status) {
|
||||
case "not_found":
|
||||
return {
|
||||
title: "Task not found",
|
||||
detail:
|
||||
"The backend could not find this task for the current organization.",
|
||||
};
|
||||
case "timeout":
|
||||
return {
|
||||
title: "Timed out waiting for browser state",
|
||||
detail:
|
||||
"The task started, but the backend did not find an active page to stream.",
|
||||
hint: "Check backend logs for browser launch errors or a streaming-mode mismatch.",
|
||||
};
|
||||
case "completed":
|
||||
case "failed":
|
||||
case "terminated":
|
||||
return {
|
||||
title: "Task is no longer live",
|
||||
detail: `The task status is ${status}.`,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: "Waiting for browser frames",
|
||||
detail: `The stream is connected and the task status is ${status}.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function diagnosticForClose(event: CloseEvent): StreamDiagnostic {
|
||||
if (event.code === 1006) {
|
||||
return {
|
||||
title: "Stream connection dropped",
|
||||
detail: "The browser stream WebSocket closed before sending a frame.",
|
||||
hint: "Check that the API server is running and reachable from the UI.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "Stream connection closed",
|
||||
detail: `WebSocket closed with code ${event.code}${event.reason ? ` (${event.reason})` : ""}.`,
|
||||
};
|
||||
}
|
||||
|
||||
const wssBaseUrl = import.meta.env.VITE_WSS_BASE_URL;
|
||||
|
||||
function TaskActions() {
|
||||
|
|
@ -43,7 +98,10 @@ function TaskActions() {
|
|||
const credentialGetter = useCredentialGetter();
|
||||
const [streamImgSrc, setStreamImgSrc] = useState<string>("");
|
||||
const [streamFormat, setStreamFormat] = useState<string>("png");
|
||||
const [streamDiagnostic, setStreamDiagnostic] =
|
||||
useState<StreamDiagnostic>(STARTING_DIAGNOSTIC);
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const hasFrameRef = useRef(false);
|
||||
const [selectedAction, setSelectedAction] = useState<
|
||||
number | "stream" | null
|
||||
>(null);
|
||||
|
|
@ -70,6 +128,7 @@ function TaskActions() {
|
|||
const taskIsNotFinalized = task && statusIsNotFinalized(task);
|
||||
const taskIsRunningOrQueued = task && statusIsRunningOrQueued(task);
|
||||
const browserSessionId = task?.browser_session_id;
|
||||
const { browserStreamingMode } = useBrowserStreamingMode();
|
||||
const shouldUseCdpStream = browserStreamingMode === "cdp";
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -82,9 +141,15 @@ function TaskActions() {
|
|||
if (!taskIsRunningOrQueued) {
|
||||
return;
|
||||
}
|
||||
setStreamDiagnostic(STARTING_DIAGNOSTIC);
|
||||
hasFrameRef.current = false;
|
||||
let cancelled = false;
|
||||
|
||||
async function run() {
|
||||
const credentialParam = await getCredentialParam(credentialGetter);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (socketRef.current) {
|
||||
socketRef.current.close();
|
||||
|
|
@ -93,15 +158,26 @@ function TaskActions() {
|
|||
`${wssBaseUrl}/stream/tasks/${taskId}?${credentialParam}`,
|
||||
);
|
||||
|
||||
socketRef.current.addEventListener("open", () => {
|
||||
setStreamDiagnostic({
|
||||
title: "Connected to stream",
|
||||
detail: "Waiting for the backend to attach to the browser page.",
|
||||
});
|
||||
});
|
||||
|
||||
socketRef.current.addEventListener("message", (event) => {
|
||||
try {
|
||||
const message: StreamMessage = JSON.parse(event.data);
|
||||
if (message.screenshot) {
|
||||
hasFrameRef.current = true;
|
||||
setStreamImgSrc(message.screenshot);
|
||||
}
|
||||
if (message.format) {
|
||||
setStreamFormat(message.format);
|
||||
}
|
||||
if (!message.screenshot && message.status) {
|
||||
setStreamDiagnostic(diagnosticForStatus(message.status));
|
||||
}
|
||||
if (
|
||||
message.status === "completed" ||
|
||||
message.status === "failed" ||
|
||||
|
|
@ -130,16 +206,32 @@ function TaskActions() {
|
|||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse message", e);
|
||||
setStreamDiagnostic({
|
||||
title: "Unexpected stream message",
|
||||
detail: "The browser stream sent a message the UI could not parse.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socketRef.current.addEventListener("close", () => {
|
||||
socketRef.current.addEventListener("error", () => {
|
||||
setStreamDiagnostic({
|
||||
title: "Stream WebSocket error",
|
||||
detail:
|
||||
"The browser stream connection hit a network or server error.",
|
||||
});
|
||||
});
|
||||
|
||||
socketRef.current.addEventListener("close", (event) => {
|
||||
if (!cancelled && !hasFrameRef.current) {
|
||||
setStreamDiagnostic(diagnosticForClose(event));
|
||||
}
|
||||
socketRef.current = null;
|
||||
});
|
||||
}
|
||||
run();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (socketRef.current) {
|
||||
socketRef.current.close();
|
||||
socketRef.current = null;
|
||||
|
|
@ -207,7 +299,7 @@ function TaskActions() {
|
|||
|
||||
function getStream() {
|
||||
// Use VNC streaming via BrowserStream when browser_session_id is available
|
||||
// and CDP local livestreaming is not enabled.
|
||||
// and local browser streaming is not enabled.
|
||||
if (browserSessionId && !shouldUseCdpStream) {
|
||||
return (
|
||||
<AspectRatio ratio={16 / 9}>
|
||||
|
|
@ -248,11 +340,7 @@ function TaskActions() {
|
|||
}
|
||||
|
||||
if (task?.status === Status.Running && streamImgSrc.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-slate-elevation1 text-lg">
|
||||
Starting the stream...
|
||||
</div>
|
||||
);
|
||||
return <StreamStatusPanel diagnostic={streamDiagnostic} />;
|
||||
}
|
||||
|
||||
if (task?.status === Status.Running && streamImgSrc.length > 0) {
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@ import { useBrowserSessionRateLimit } from "../hooks/useBrowserSessionRateLimit"
|
|||
import { useDebugSessionQuery } from "../hooks/useDebugSessionQuery";
|
||||
import { useBlockScriptsQuery } from "@/routes/workflows/hooks/useBlockScriptsQuery";
|
||||
import { BrowserSessionStream } from "@/routes/browserSessions/BrowserSessionStream";
|
||||
import { browserStreamingMode } from "@/util/env";
|
||||
import { useBrowserStreamingMode } from "@/hooks/useRuntimeConfig";
|
||||
import { StreamModeBadge } from "@/routes/streaming/StreamDiagnostics";
|
||||
import { useCacheKeyValuesQuery } from "../hooks/useCacheKeyValuesQuery";
|
||||
import { useBlockScriptStore } from "@/store/BlockScriptStore";
|
||||
import { useRecordingStore } from "@/store/useRecordingStore";
|
||||
|
|
@ -280,6 +281,7 @@ function Workspace({
|
|||
const saveWorkflow = useWorkflowSave({ status: "published" });
|
||||
const { data: workflowRun } = useWorkflowRunQuery();
|
||||
const isFinalized = workflowRun ? statusIsFinalized(workflowRun) : false;
|
||||
const { browserStreamingMode } = useBrowserStreamingMode();
|
||||
|
||||
const [openCycleBrowserDialogue, setOpenCycleBrowserDialogue] =
|
||||
useState(false);
|
||||
|
|
@ -1868,6 +1870,7 @@ function Workspace({
|
|||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<GlobeIcon /> Live Browser
|
||||
<StreamModeBadge mode="vnc" />
|
||||
</div>
|
||||
{showBreakoutButton && (
|
||||
<BreakoutButton onClick={() => breakout()} />
|
||||
|
|
@ -1893,7 +1896,7 @@ function Workspace({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* CDP screencast: only in local mode when VNC is not supported */}
|
||||
{/* Local browser stream: only in local mode when VNC is not supported */}
|
||||
{activeDebugSession &&
|
||||
!activeDebugSession.vnc_streaming_supported &&
|
||||
browserStreamingMode === "cdp" && (
|
||||
|
|
@ -1919,6 +1922,7 @@ function Workspace({
|
|||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<GlobeIcon /> Live Browser
|
||||
<StreamModeBadge mode="cdp" />
|
||||
</div>
|
||||
<div
|
||||
className={cn("ml-auto flex items-center gap-2", {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import {
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { browserStreamingMode } from "@/util/env";
|
||||
import { useBrowserStreamingMode } from "@/hooks/useRuntimeConfig";
|
||||
|
||||
export type ActionItem = {
|
||||
block: WorkflowRunBlock;
|
||||
|
|
@ -42,6 +42,7 @@ function WorkflowRunOverview() {
|
|||
const active = searchParams.get("active");
|
||||
const queryClient = useQueryClient();
|
||||
const [vncFailed, setVncFailed] = useState(false);
|
||||
const { browserStreamingMode } = useBrowserStreamingMode();
|
||||
const { data: workflowRun, isLoading: workflowRunIsLoading } =
|
||||
useWorkflowRunWithWorkflowQuery();
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ import { toast } from "@/components/ui/use-toast";
|
|||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useCdpInput } from "@/routes/streaming/useCdpInput";
|
||||
import { InteractiveStreamView } from "@/routes/streaming/InteractiveStreamView";
|
||||
import {
|
||||
StreamStatusPanel,
|
||||
type StreamDiagnostic,
|
||||
} from "@/routes/streaming/StreamDiagnostics";
|
||||
|
||||
type StreamMessage = {
|
||||
task_id?: string;
|
||||
|
|
@ -20,6 +24,56 @@ type StreamMessage = {
|
|||
viewport_height?: number;
|
||||
};
|
||||
|
||||
const STARTING_DIAGNOSTIC: StreamDiagnostic = {
|
||||
title: "Starting browser stream",
|
||||
detail:
|
||||
"Opening the stream WebSocket and waiting for the first browser frame.",
|
||||
};
|
||||
|
||||
function diagnosticForStatus(status: string): StreamDiagnostic {
|
||||
switch (status) {
|
||||
case "not_found":
|
||||
return {
|
||||
title: "Workflow run not found",
|
||||
detail:
|
||||
"The backend could not find this workflow run for the current organization.",
|
||||
};
|
||||
case "timeout":
|
||||
return {
|
||||
title: "Timed out waiting for browser state",
|
||||
detail:
|
||||
"The run started, but the backend did not find an active page to stream.",
|
||||
hint: "Check backend logs for browser launch errors or a streaming-mode mismatch.",
|
||||
};
|
||||
case "completed":
|
||||
case "failed":
|
||||
case "terminated":
|
||||
return {
|
||||
title: "Workflow run is no longer live",
|
||||
detail: `The workflow run status is ${status}.`,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: "Waiting for browser frames",
|
||||
detail: `The stream is connected and the run status is ${status}.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function diagnosticForClose(event: CloseEvent): StreamDiagnostic {
|
||||
if (event.code === 1006) {
|
||||
return {
|
||||
title: "Stream connection dropped",
|
||||
detail: "The browser stream WebSocket closed before sending a frame.",
|
||||
hint: "Check that the API server is running and reachable from the UI.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "Stream connection closed",
|
||||
detail: `WebSocket closed with code ${event.code}${event.reason ? ` (${event.reason})` : ""}.`,
|
||||
};
|
||||
}
|
||||
|
||||
interface Props {
|
||||
alwaysShowStream?: boolean;
|
||||
interactive?: boolean;
|
||||
|
|
@ -39,6 +93,8 @@ function WorkflowRunStream({
|
|||
const [streamFormat, setStreamFormat] = useState<string>("png");
|
||||
const [viewportWidth, setViewportWidth] = useState(1280);
|
||||
const [viewportHeight, setViewportHeight] = useState(720);
|
||||
const [diagnostic, setDiagnostic] =
|
||||
useState<StreamDiagnostic>(STARTING_DIAGNOSTIC);
|
||||
const showStream =
|
||||
alwaysShowStream || (workflowRun && statusIsNotFinalized(workflowRun));
|
||||
const credentialGetter = useCredentialGetter();
|
||||
|
|
@ -47,6 +103,7 @@ function WorkflowRunStream({
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const hasFrameRef = useRef(false);
|
||||
|
||||
const inputWsUrl =
|
||||
interactive && workflowRunId
|
||||
|
|
@ -70,9 +127,15 @@ function WorkflowRunStream({
|
|||
if (!showStream) {
|
||||
return;
|
||||
}
|
||||
setDiagnostic(STARTING_DIAGNOSTIC);
|
||||
hasFrameRef.current = false;
|
||||
let cancelled = false;
|
||||
|
||||
async function run() {
|
||||
const credentialParam = await getCredentialParam(credentialGetter);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (socketRef.current) {
|
||||
socketRef.current.close();
|
||||
|
|
@ -81,10 +144,18 @@ function WorkflowRunStream({
|
|||
`${wssBaseUrl}/stream/workflow_runs/${workflowRunId}?${credentialParam}`,
|
||||
);
|
||||
|
||||
socketRef.current.addEventListener("open", () => {
|
||||
setDiagnostic({
|
||||
title: "Connected to stream",
|
||||
detail: "Waiting for the backend to attach to the browser page.",
|
||||
});
|
||||
});
|
||||
|
||||
socketRef.current.addEventListener("message", (event) => {
|
||||
try {
|
||||
const message: StreamMessage = JSON.parse(event.data);
|
||||
if (message.screenshot) {
|
||||
hasFrameRef.current = true;
|
||||
setStreamImgSrc(message.screenshot);
|
||||
}
|
||||
if (message.format) {
|
||||
|
|
@ -96,6 +167,9 @@ function WorkflowRunStream({
|
|||
if (message.viewport_height) {
|
||||
setViewportHeight(message.viewport_height);
|
||||
}
|
||||
if (!message.screenshot && message.status) {
|
||||
setDiagnostic(diagnosticForStatus(message.status));
|
||||
}
|
||||
if (
|
||||
message.status === "completed" ||
|
||||
message.status === "failed" ||
|
||||
|
|
@ -139,16 +213,32 @@ function WorkflowRunStream({
|
|||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse message", e);
|
||||
setDiagnostic({
|
||||
title: "Unexpected stream message",
|
||||
detail: "The browser stream sent a message the UI could not parse.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socketRef.current.addEventListener("close", () => {
|
||||
socketRef.current.addEventListener("error", () => {
|
||||
setDiagnostic({
|
||||
title: "Stream WebSocket error",
|
||||
detail:
|
||||
"The browser stream connection hit a network or server error.",
|
||||
});
|
||||
});
|
||||
|
||||
socketRef.current.addEventListener("close", (event) => {
|
||||
if (!cancelled && !hasFrameRef.current) {
|
||||
setDiagnostic(diagnosticForClose(event));
|
||||
}
|
||||
socketRef.current = null;
|
||||
});
|
||||
}
|
||||
run();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (socketRef.current) {
|
||||
socketRef.current.close();
|
||||
socketRef.current = null;
|
||||
|
|
@ -184,11 +274,7 @@ function WorkflowRunStream({
|
|||
}
|
||||
|
||||
if (isRunningOrPaused && streamImgSrc.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center rounded-md bg-slate-900 py-8 text-lg">
|
||||
Starting the stream...
|
||||
</div>
|
||||
);
|
||||
return <StreamStatusPanel diagnostic={diagnostic} />;
|
||||
}
|
||||
|
||||
const hasStream =
|
||||
|
|
@ -211,11 +297,7 @@ function WorkflowRunStream({
|
|||
}
|
||||
|
||||
if (alwaysShowStream) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
Waiting for stream...
|
||||
</div>
|
||||
);
|
||||
return <StreamStatusPanel diagnostic={diagnostic} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import importlib.util
|
||||
import json
|
||||
|
|
@ -14,6 +15,7 @@ import socket
|
|||
import subprocess
|
||||
import sys
|
||||
import traceback
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
|
@ -711,7 +713,7 @@ def _validate_streaming_mode_value(value: str | None, source: str) -> list[str]:
|
|||
|
||||
|
||||
def _check_local_streaming_mode() -> CheckResult:
|
||||
"""Check that local self-hosted installs default to CDP livestreaming."""
|
||||
"""Check that local self-hosted installs default to local browser streaming."""
|
||||
from dotenv import dotenv_values
|
||||
|
||||
from skyvern.utils.env_paths import resolve_backend_env_path
|
||||
|
|
@ -721,19 +723,26 @@ def _check_local_streaming_mode() -> CheckResult:
|
|||
|
||||
backend_raw = dotenv_values(backend_env).get(BACKEND_STREAMING_MODE_VAR, "") if backend_env.exists() else ""
|
||||
frontend_raw = dotenv_values(frontend_env).get(FRONTEND_STREAMING_MODE_VAR, "") if frontend_env.exists() else ""
|
||||
backend_mode = _normalize_streaming_mode(str(backend_raw or ""))
|
||||
frontend_mode = _normalize_streaming_mode(str(frontend_raw or ""))
|
||||
|
||||
problems: list[str] = []
|
||||
if backend_env.exists():
|
||||
problems.extend(_validate_streaming_mode_value(str(backend_raw or ""), "backend .env"))
|
||||
if _normalize_streaming_mode(str(backend_raw or "")) != LOCAL_STREAMING_MODE:
|
||||
problems.append(f"backend .env {BACKEND_STREAMING_MODE_VAR} should be {LOCAL_STREAMING_MODE}")
|
||||
if backend_mode != LOCAL_STREAMING_MODE:
|
||||
problems.append(
|
||||
f"backend .env {BACKEND_STREAMING_MODE_VAR}: {backend_mode or 'missing'} -> {LOCAL_STREAMING_MODE}"
|
||||
)
|
||||
else:
|
||||
problems.append("backend .env is missing")
|
||||
|
||||
if frontend_env.exists():
|
||||
problems.extend(_validate_streaming_mode_value(str(frontend_raw or ""), "skyvern-frontend/.env"))
|
||||
if _normalize_streaming_mode(str(frontend_raw or "")) != LOCAL_STREAMING_MODE:
|
||||
problems.append(f"skyvern-frontend/.env {FRONTEND_STREAMING_MODE_VAR} should be {LOCAL_STREAMING_MODE}")
|
||||
if frontend_mode != LOCAL_STREAMING_MODE:
|
||||
problems.append(
|
||||
f"skyvern-frontend/.env {FRONTEND_STREAMING_MODE_VAR}: "
|
||||
f"{frontend_mode or 'missing'} -> {LOCAL_STREAMING_MODE}"
|
||||
)
|
||||
else:
|
||||
problems.append("skyvern-frontend/.env is missing")
|
||||
|
||||
|
|
@ -742,14 +751,17 @@ def _check_local_streaming_mode() -> CheckResult:
|
|||
name="Local Streaming Mode",
|
||||
status="warn",
|
||||
detail="; ".join(problems),
|
||||
hint="Run `skyvern doctor --fix` to enable CDP livestreaming for backend and UI",
|
||||
hint="Run `skyvern doctor --fix` to enable local browser streaming for backend and UI",
|
||||
)
|
||||
|
||||
if not _docker_compose_available():
|
||||
return CheckResult(
|
||||
name="Local Streaming Mode",
|
||||
status="ok",
|
||||
detail="backend and frontend env files enable CDP livestreaming",
|
||||
detail=(
|
||||
f"backend .env {BACKEND_STREAMING_MODE_VAR}={backend_mode}; "
|
||||
f"skyvern-frontend/.env {FRONTEND_STREAMING_MODE_VAR}={frontend_mode}"
|
||||
),
|
||||
)
|
||||
|
||||
container_problems: list[str] = []
|
||||
|
|
@ -794,13 +806,21 @@ def _check_local_streaming_mode() -> CheckResult:
|
|||
return CheckResult(
|
||||
name="Local Streaming Mode",
|
||||
status="ok",
|
||||
detail="backend and frontend env files enable CDP livestreaming; running containers not checked",
|
||||
detail=(
|
||||
f"backend .env {BACKEND_STREAMING_MODE_VAR}={backend_mode}; "
|
||||
f"skyvern-frontend/.env {FRONTEND_STREAMING_MODE_VAR}={frontend_mode}; "
|
||||
"running containers not checked"
|
||||
),
|
||||
)
|
||||
|
||||
return CheckResult(
|
||||
name="Local Streaming Mode",
|
||||
status="ok",
|
||||
detail="CDP livestreaming is enabled for backend, UI, and running containers",
|
||||
detail=(
|
||||
f"backend .env {BACKEND_STREAMING_MODE_VAR}={backend_mode}; "
|
||||
f"skyvern-frontend/.env {FRONTEND_STREAMING_MODE_VAR}={frontend_mode}; "
|
||||
"running containers use local browser streaming"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1445,20 +1465,32 @@ def _ensure_frontend_env_exists() -> Path:
|
|||
|
||||
|
||||
def _fix_local_streaming_mode() -> bool:
|
||||
"""Enable local CDP livestreaming in backend and frontend env files."""
|
||||
from dotenv import set_key
|
||||
"""Enable local browser streaming in backend and frontend env files."""
|
||||
from dotenv import dotenv_values, set_key
|
||||
|
||||
from skyvern.utils.env_paths import resolve_backend_env_path
|
||||
|
||||
backend_env = resolve_backend_env_path()
|
||||
backend_before = ""
|
||||
if backend_env.exists():
|
||||
backend_before = str(dotenv_values(backend_env).get(BACKEND_STREAMING_MODE_VAR) or "")
|
||||
if not backend_env.exists():
|
||||
backend_env.touch()
|
||||
set_key(str(backend_env), BACKEND_STREAMING_MODE_VAR, LOCAL_STREAMING_MODE, quote_mode="never")
|
||||
console.print(f" [cyan]Set {BACKEND_STREAMING_MODE_VAR}={LOCAL_STREAMING_MODE} in backend .env[/cyan]")
|
||||
console.print(
|
||||
" [cyan]Set "
|
||||
f"backend .env {BACKEND_STREAMING_MODE_VAR}: "
|
||||
f"{_normalize_streaming_mode(backend_before) or 'missing'} -> {LOCAL_STREAMING_MODE}[/cyan]"
|
||||
)
|
||||
|
||||
frontend_env = _ensure_frontend_env_exists()
|
||||
frontend_before = str(dotenv_values(frontend_env).get(FRONTEND_STREAMING_MODE_VAR) or "")
|
||||
set_key(str(frontend_env), FRONTEND_STREAMING_MODE_VAR, LOCAL_STREAMING_MODE, quote_mode="never")
|
||||
console.print(f" [cyan]Set {FRONTEND_STREAMING_MODE_VAR}={LOCAL_STREAMING_MODE} in skyvern-frontend/.env[/cyan]")
|
||||
console.print(
|
||||
" [cyan]Set "
|
||||
f"skyvern-frontend/.env {FRONTEND_STREAMING_MODE_VAR}: "
|
||||
f"{_normalize_streaming_mode(frontend_before) or 'missing'} -> {LOCAL_STREAMING_MODE}[/cyan]"
|
||||
)
|
||||
|
||||
if _docker_compose_available():
|
||||
return _recreate_docker_services(["skyvern", "skyvern-ui"])
|
||||
|
|
@ -1600,6 +1632,190 @@ def _recreate_docker_services(services: list[str]) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _normalize_api_root(base_url: str | None) -> str:
|
||||
raw = (base_url or "http://localhost:8000").strip().rstrip("/")
|
||||
parsed = urllib.parse.urlsplit(raw)
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
raise ValueError(f"Invalid base URL: {raw}")
|
||||
|
||||
path = parsed.path.rstrip("/")
|
||||
for suffix in ("/api/v1", "/v1", "/api/v2", "/v2"):
|
||||
if path.endswith(suffix):
|
||||
path = path[: -len(suffix)]
|
||||
break
|
||||
|
||||
return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, path.rstrip("/"), "", ""))
|
||||
|
||||
|
||||
def _api_v1_url(api_root: str, path: str) -> str:
|
||||
return f"{api_root}/v1{path}"
|
||||
|
||||
|
||||
def _ws_v1_url(api_root: str, path: str, query: dict[str, str]) -> str:
|
||||
parsed = urllib.parse.urlsplit(api_root)
|
||||
scheme = "wss" if parsed.scheme == "https" else "ws"
|
||||
query_string = urllib.parse.urlencode(query)
|
||||
return urllib.parse.urlunsplit((scheme, parsed.netloc, f"{parsed.path.rstrip('/')}/v1{path}", query_string, ""))
|
||||
|
||||
|
||||
async def _wait_for_stream_frame(ws_url: str, timeout_seconds: int) -> str:
|
||||
import websockets
|
||||
|
||||
async with websockets.connect(ws_url, open_timeout=min(timeout_seconds, 10)) as websocket:
|
||||
deadline = asyncio.get_running_loop().time() + timeout_seconds
|
||||
last_status = "connected"
|
||||
while True:
|
||||
remaining = deadline - asyncio.get_running_loop().time()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError(f"stream connected but no frame arrived; last status={last_status}")
|
||||
raw = await asyncio.wait_for(websocket.recv(), timeout=remaining)
|
||||
if not isinstance(raw, str):
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
last_status = raw[:120]
|
||||
continue
|
||||
if payload.get("screenshot"):
|
||||
return str(payload.get("format") or "unknown")
|
||||
last_status = str(payload.get("status") or payload)
|
||||
if last_status in {"not_found", "timeout", "completed", "failed"}:
|
||||
raise RuntimeError(f"stream ended before a frame arrived: {last_status}")
|
||||
|
||||
|
||||
async def _wait_for_cdp_input_ready(ws_url: str, timeout_seconds: int) -> None:
|
||||
import websockets
|
||||
|
||||
async with websockets.connect(ws_url, open_timeout=min(timeout_seconds, 10)) as websocket:
|
||||
raw = await asyncio.wait_for(websocket.recv(), timeout=timeout_seconds)
|
||||
if not isinstance(raw, str):
|
||||
raise RuntimeError("CDP input channel returned a non-text message")
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(f"CDP input channel returned non-JSON: {raw[:120]}") from exc
|
||||
if payload.get("kind") != "ready":
|
||||
raise RuntimeError(f"CDP input channel did not become ready: {payload}")
|
||||
|
||||
|
||||
async def _streaming_smoke_test_async(
|
||||
*,
|
||||
base_url: str | None,
|
||||
api_key: str,
|
||||
browser_session_id: str | None,
|
||||
timeout_seconds: int,
|
||||
) -> CheckResult:
|
||||
api_root = _normalize_api_root(base_url)
|
||||
headers = {"X-API-Key": api_key}
|
||||
created_session = False
|
||||
session_id = browser_session_id
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
|
||||
runtime_response = await client.get(_api_v1_url(api_root, "/config/runtime"))
|
||||
runtime_response.raise_for_status()
|
||||
runtime_config = runtime_response.json()
|
||||
streaming_mode = _normalize_streaming_mode(str(runtime_config.get("browser_streaming_mode") or ""))
|
||||
if streaming_mode != LOCAL_STREAMING_MODE:
|
||||
return CheckResult(
|
||||
name="Streaming Smoke Test",
|
||||
status="warn",
|
||||
detail=f"backend reports {BACKEND_STREAMING_MODE_VAR}={streaming_mode or 'missing'}",
|
||||
hint="Set BROWSER_STREAMING_MODE=cdp for local browser streaming smoke tests",
|
||||
)
|
||||
|
||||
if not session_id:
|
||||
create_response = await client.post(
|
||||
_api_v1_url(api_root, "/browser_sessions"),
|
||||
headers=headers,
|
||||
json={"timeout": 5},
|
||||
)
|
||||
create_response.raise_for_status()
|
||||
session_id = str(create_response.json().get("browser_session_id") or "")
|
||||
created_session = True
|
||||
if not session_id:
|
||||
raise RuntimeError("browser session create response did not include browser_session_id")
|
||||
|
||||
try:
|
||||
deadline = asyncio.get_running_loop().time() + timeout_seconds
|
||||
last_status = "created"
|
||||
while True:
|
||||
session_response = await client.get(
|
||||
_api_v1_url(api_root, f"/browser_sessions/{session_id}"),
|
||||
headers=headers,
|
||||
)
|
||||
session_response.raise_for_status()
|
||||
session = session_response.json()
|
||||
last_status = str(session.get("status") or "unknown")
|
||||
if last_status == "running" or session.get("started_at") or session.get("browser_address"):
|
||||
break
|
||||
if last_status in {"completed", "failed", "terminated"}:
|
||||
raise RuntimeError(f"browser session finalized before streaming: {last_status}")
|
||||
if asyncio.get_running_loop().time() >= deadline:
|
||||
raise TimeoutError(f"browser session did not start; last status={last_status}")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
auth_query = {"apikey": api_key}
|
||||
frame_format = await _wait_for_stream_frame(
|
||||
_ws_v1_url(api_root, f"/stream/browser_sessions/{session_id}", auth_query),
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
input_query = {
|
||||
"apikey": api_key,
|
||||
"client_id": f"doctor-smoke-{secrets.token_hex(4)}",
|
||||
}
|
||||
await _wait_for_cdp_input_ready(
|
||||
_ws_v1_url(api_root, f"/stream/cdp_input/browser_session/{session_id}", input_query),
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
return CheckResult(
|
||||
name="Streaming Smoke Test",
|
||||
status="ok",
|
||||
detail=f"received {frame_format} frame and CDP input channel became ready for {session_id}",
|
||||
)
|
||||
finally:
|
||||
if created_session and session_id:
|
||||
try:
|
||||
await client.post(
|
||||
_api_v1_url(api_root, f"/browser_sessions/{session_id}/close"),
|
||||
headers=headers,
|
||||
)
|
||||
except Exception as exc:
|
||||
console.print(f" [yellow]Could not close smoke-test browser session {session_id}: {exc}[/yellow]")
|
||||
|
||||
|
||||
def _streaming_smoke_test(
|
||||
*,
|
||||
base_url: str | None,
|
||||
api_key: str | None,
|
||||
browser_session_id: str | None,
|
||||
timeout_seconds: int,
|
||||
) -> CheckResult:
|
||||
key = (api_key or os.environ.get("SKYVERN_API_KEY") or _read_generated_credential()).strip()
|
||||
if not key:
|
||||
return CheckResult(
|
||||
name="Streaming Smoke Test",
|
||||
status="error",
|
||||
detail="No API key available",
|
||||
hint="Pass --api-key or set SKYVERN_API_KEY",
|
||||
)
|
||||
try:
|
||||
return asyncio.run(
|
||||
_streaming_smoke_test_async(
|
||||
base_url=base_url,
|
||||
api_key=key,
|
||||
browser_session_id=browser_session_id,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
return CheckResult(
|
||||
name="Streaming Smoke Test",
|
||||
status="error",
|
||||
detail=str(exc),
|
||||
hint="Check that the backend is running, local browser streaming is enabled, and browser launch succeeds",
|
||||
)
|
||||
|
||||
|
||||
def _fix_install_playwright() -> bool:
|
||||
console.print(" [cyan]Installing Chromium via Playwright...[/cyan]")
|
||||
result = subprocess.run(["playwright", "install", "chromium"], capture_output=True, text=True)
|
||||
|
|
@ -1610,6 +1826,45 @@ def _fix_install_playwright() -> bool:
|
|||
return False
|
||||
|
||||
|
||||
@doctor_app.command("streaming")
|
||||
def streaming(
|
||||
base_url: str = typer.Option(
|
||||
"http://localhost:8000",
|
||||
"--base-url",
|
||||
help="Skyvern API server root or v1 URL.",
|
||||
),
|
||||
api_key: str | None = typer.Option(None, "--api-key", help="Skyvern API key. Defaults to SKYVERN_API_KEY."),
|
||||
browser_session_id: str | None = typer.Option(
|
||||
None,
|
||||
"--browser-session-id",
|
||||
help="Existing browser session to test. If omitted, doctor creates and closes one.",
|
||||
),
|
||||
timeout_seconds: int = typer.Option(45, "--timeout", min=5, max=180, help="Seconds to wait for stream readiness."),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output machine-readable JSON."),
|
||||
) -> None:
|
||||
"""Create or reuse a browser session and verify local browser streaming."""
|
||||
result = _streaming_smoke_test(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
browser_session_id=browser_session_id,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
|
||||
if json_output:
|
||||
console.print(json.dumps(result.__dict__))
|
||||
else:
|
||||
table = Table(show_header=True, header_style="bold", title="Skyvern Streaming Doctor")
|
||||
table.add_column("Check", style="bold")
|
||||
table.add_column("Status")
|
||||
table.add_column("Details")
|
||||
table.add_column("Fix")
|
||||
table.add_row(result.name, _STATUS_STYLE.get(result.status, result.status), result.detail, result.hint)
|
||||
console.print(table)
|
||||
|
||||
if result.status == "error":
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
|
||||
@doctor_app.callback(invoke_without_command=True)
|
||||
def doctor(
|
||||
ctx: typer.Context,
|
||||
|
|
|
|||
|
|
@ -300,7 +300,7 @@ def _run_server_quickstart(
|
|||
)
|
||||
run_local = bool(init_result)
|
||||
if run_local:
|
||||
_configure_cdp_livestreaming_defaults()
|
||||
_configure_local_browser_streaming_defaults()
|
||||
|
||||
# Start services
|
||||
if run_local:
|
||||
|
|
@ -535,8 +535,8 @@ def _handle_postgres_container_conflict() -> None:
|
|||
)
|
||||
|
||||
|
||||
def _configure_cdp_livestreaming_defaults() -> None:
|
||||
"""Enable local CDP livestreaming for self-hosted quickstart paths."""
|
||||
def _configure_local_browser_streaming_defaults() -> None:
|
||||
"""Enable local browser streaming for self-hosted quickstart paths."""
|
||||
from dotenv import set_key
|
||||
|
||||
from skyvern.cli.llm_setup import update_or_add_env_var
|
||||
|
|
@ -569,7 +569,7 @@ def run_docker_compose_setup() -> None:
|
|||
# Configure LLM provider
|
||||
console.print("\n[bold blue]Step 1: Configure LLM Provider[/bold blue]")
|
||||
setup_llm_providers()
|
||||
_configure_cdp_livestreaming_defaults()
|
||||
_configure_local_browser_streaming_defaults()
|
||||
|
||||
# Run docker compose up
|
||||
console.print("\n[bold blue]Step 2: Starting Docker Compose...[/bold blue]")
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from skyvern.forge.sdk.routes import debug_sessions # noqa: F401
|
|||
from skyvern.forge.sdk.routes import prompts # noqa: F401
|
||||
from skyvern.forge.sdk.routes import pylon # noqa: F401
|
||||
from skyvern.forge.sdk.routes import run_blocks # noqa: F401
|
||||
from skyvern.forge.sdk.routes import runtime_config # noqa: F401
|
||||
from skyvern.forge.sdk.routes import scripts # noqa: F401
|
||||
from skyvern.forge.sdk.routes import sdk # noqa: F401
|
||||
from skyvern.forge.sdk.routes import webhooks # noqa: F401
|
||||
|
|
|
|||
41
skyvern/forge/sdk/routes/runtime_config.py
Normal file
41
skyvern/forge/sdk/routes/runtime_config.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
from typing import Literal, cast
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from skyvern.config import settings
|
||||
from skyvern.forge.sdk.routes.routers import base_router, legacy_base_router
|
||||
|
||||
BrowserStreamingMode = Literal["cdp", "vnc"]
|
||||
_ALLOWED_STREAMING_MODES: set[str] = {"cdp", "vnc"}
|
||||
|
||||
|
||||
class RuntimeConfig(BaseModel):
|
||||
browser_streaming_mode: BrowserStreamingMode
|
||||
browser_streaming_label: str
|
||||
environment: str
|
||||
warnings: list[str] = []
|
||||
|
||||
|
||||
def _normalize_browser_streaming_mode(value: str | None) -> tuple[BrowserStreamingMode, list[str]]:
|
||||
mode = (value or "").strip().lower()
|
||||
if mode in _ALLOWED_STREAMING_MODES:
|
||||
return cast(BrowserStreamingMode, mode), []
|
||||
return "vnc", [f"Invalid BROWSER_STREAMING_MODE={value!r}; using vnc fallback"]
|
||||
|
||||
|
||||
def _browser_streaming_label(mode: BrowserStreamingMode) -> str:
|
||||
if mode == "cdp":
|
||||
return "Local browser streaming"
|
||||
return "VNC streaming"
|
||||
|
||||
|
||||
@base_router.get("/config/runtime", include_in_schema=False)
|
||||
@legacy_base_router.get("/config/runtime", include_in_schema=False)
|
||||
async def get_runtime_config() -> RuntimeConfig:
|
||||
mode, warnings = _normalize_browser_streaming_mode(settings.BROWSER_STREAMING_MODE)
|
||||
return RuntimeConfig(
|
||||
browser_streaming_mode=mode,
|
||||
browser_streaming_label=_browser_streaming_label(mode),
|
||||
environment=settings.ENV,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
|
@ -115,7 +115,7 @@ def test_local_streaming_mode_warns_when_frontend_is_vnc(
|
|||
result = doctor_module._check_local_streaming_mode()
|
||||
|
||||
assert result.status == "warn"
|
||||
assert "VITE_BROWSER_STREAMING_MODE should be cdp" in result.detail
|
||||
assert "VITE_BROWSER_STREAMING_MODE: vnc -> cdp" in result.detail
|
||||
|
||||
|
||||
def test_fix_local_streaming_mode_writes_backend_and_frontend(
|
||||
|
|
@ -135,6 +135,26 @@ def test_fix_local_streaming_mode_writes_backend_and_frontend(
|
|||
assert "VITE_BROWSER_STREAMING_MODE=cdp" in (frontend_dir / ".env").read_text()
|
||||
|
||||
|
||||
def test_normalize_api_root_strips_version_prefix() -> None:
|
||||
assert doctor_module._normalize_api_root("http://localhost:8000/api/v1") == "http://localhost:8000"
|
||||
assert doctor_module._normalize_api_root("http://localhost:8000/v1") == "http://localhost:8000"
|
||||
|
||||
|
||||
def test_streaming_smoke_test_requires_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("SKYVERN_API_KEY", raising=False)
|
||||
monkeypatch.setattr(doctor_module, "_read_generated_credential", lambda: "")
|
||||
|
||||
result = doctor_module._streaming_smoke_test(
|
||||
base_url="http://localhost:8000",
|
||||
api_key=None,
|
||||
browser_session_id=None,
|
||||
timeout_seconds=5,
|
||||
)
|
||||
|
||||
assert result.status == "error"
|
||||
assert "No API key" in result.detail
|
||||
|
||||
|
||||
def test_compose_database_connection_can_satisfy_database_check(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
|
|
|||
103
tests/test_runtime_config_route.py
Normal file
103
tests/test_runtime_config_route.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import sys
|
||||
from collections.abc import Iterator
|
||||
from types import ModuleType, SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.unit_tests._stub_streaming import import_with_stubs
|
||||
|
||||
|
||||
class _FakeAPIRouter:
|
||||
def __init__(self, *, include_in_schema: bool = True, **_: object) -> None:
|
||||
self.include_in_schema = include_in_schema
|
||||
self.routes: list[SimpleNamespace] = []
|
||||
|
||||
def get(self, path: str, *, include_in_schema: bool = True, **_: object):
|
||||
def decorator(fn):
|
||||
self.routes.append(
|
||||
SimpleNamespace(
|
||||
path=path,
|
||||
include_in_schema=include_in_schema and self.include_in_schema,
|
||||
endpoint=fn,
|
||||
)
|
||||
)
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def runtime_config_module(monkeypatch: pytest.MonkeyPatch) -> Iterator[ModuleType]:
|
||||
module_names = [
|
||||
"skyvern.forge.sdk.routes.runtime_config",
|
||||
"skyvern.forge.sdk.routes.routers",
|
||||
"skyvern.forge.sdk.routes",
|
||||
]
|
||||
missing = object()
|
||||
original_modules = {module_name: sys.modules.get(module_name, missing) for module_name in module_names}
|
||||
|
||||
fastapi_stub = ModuleType("fastapi")
|
||||
fastapi_stub.APIRouter = _FakeAPIRouter
|
||||
monkeypatch.setitem(sys.modules, "fastapi", fastapi_stub)
|
||||
|
||||
for module_name in module_names:
|
||||
sys.modules.pop(module_name, None)
|
||||
|
||||
module = import_with_stubs(
|
||||
"skyvern.forge.sdk.routes.runtime_config",
|
||||
extra_stubs=[
|
||||
"skyvern.forge.sdk.routes.workflow_schedules",
|
||||
"skyvern.forge.sdk.routes.streaming.screenshot",
|
||||
],
|
||||
)
|
||||
yield module
|
||||
|
||||
for module_name in module_names:
|
||||
sys.modules.pop(module_name, None)
|
||||
for module_name, original_module in original_modules.items():
|
||||
if original_module is not missing:
|
||||
sys.modules[module_name] = original_module
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_config_returns_normalized_browser_streaming_mode(
|
||||
runtime_config_module: ModuleType,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
runtime_config = runtime_config_module
|
||||
monkeypatch.setattr(runtime_config.settings, "BROWSER_STREAMING_MODE", "CDP")
|
||||
monkeypatch.setattr(runtime_config.settings, "ENV", "local")
|
||||
|
||||
result = await runtime_config.get_runtime_config()
|
||||
|
||||
assert result.browser_streaming_mode == "cdp"
|
||||
assert result.browser_streaming_label == "Local browser streaming"
|
||||
assert result.environment == "local"
|
||||
assert result.warnings == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_config_falls_back_for_invalid_streaming_mode(
|
||||
runtime_config_module: ModuleType,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
runtime_config = runtime_config_module
|
||||
monkeypatch.setattr(runtime_config.settings, "BROWSER_STREAMING_MODE", "unexpected")
|
||||
|
||||
result = await runtime_config.get_runtime_config()
|
||||
|
||||
assert result.browser_streaming_mode == "vnc"
|
||||
assert result.warnings
|
||||
|
||||
|
||||
def test_runtime_config_routes_are_hidden_from_openapi_schema(runtime_config_module: ModuleType) -> None:
|
||||
routers = sys.modules["skyvern.forge.sdk.routes.routers"]
|
||||
base_router = routers.base_router
|
||||
legacy_base_router = routers.legacy_base_router
|
||||
base_routes = [route for route in base_router.routes if getattr(route, "path", None) == "/config/runtime"]
|
||||
legacy_routes = [route for route in legacy_base_router.routes if getattr(route, "path", None) == "/config/runtime"]
|
||||
|
||||
assert base_routes
|
||||
assert legacy_routes
|
||||
assert all(getattr(route, "include_in_schema", True) is False for route in base_routes)
|
||||
assert all(getattr(route, "include_in_schema", True) is False for route in legacy_routes)
|
||||
|
|
@ -81,7 +81,7 @@ def test_quickstart_does_not_start_services_when_required_browser_is_missing(
|
|||
monkeypatch.setattr(quickstart_module, "check_docker_compose_file", lambda: False)
|
||||
monkeypatch.setattr(quickstart_module, "_has_server_quickstart_extra", lambda: True)
|
||||
monkeypatch.setattr(init_command, "init_env", lambda **_kwargs: init_result)
|
||||
monkeypatch.setattr(quickstart_module, "_configure_cdp_livestreaming_defaults", lambda: None)
|
||||
monkeypatch.setattr(quickstart_module, "_configure_local_browser_streaming_defaults", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
quickstart_module.typer,
|
||||
"confirm",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue