Remove frontend hack for requesting persistent browser sessions, part iii (frontend) (#3053)

This commit is contained in:
Jonathan Dobson
2025-07-29 10:12:42 -04:00
committed by GitHub
parent 8ff1c5dfa2
commit 9c00cff362
6 changed files with 131 additions and 169 deletions

View File

@@ -266,6 +266,14 @@ export interface EvalTask extends Eval {
export type EvalApiResponse = EvalWorkflow[] | EvalTask[]; export type EvalApiResponse = EvalWorkflow[] | EvalTask[];
export type DebugSessionApiResponse = {
debug_session_id: string;
browser_session_id: string;
workflow_permanent_id: string | null;
created_at: string;
modified_at: string;
};
export type WorkflowRunApiResponse = { export type WorkflowRunApiResponse = {
created_at: string; created_at: string;
failure_reason: string | null; failure_reason: string | null;

View File

@@ -1,5 +1,7 @@
import { ReactFlowProvider } from "@xyflow/react"; import { ReactFlowProvider } from "@xyflow/react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { BrowserStream } from "@/components/BrowserStream"; import { BrowserStream } from "@/components/BrowserStream";
import { FloatingWindow } from "@/components/FloatingWindow"; import { FloatingWindow } from "@/components/FloatingWindow";
@@ -10,6 +12,7 @@ import { useSidebarStore } from "@/store/SidebarStore";
import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore"; import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore";
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
import { useWorkflowQuery } from "../hooks/useWorkflowQuery"; import { useWorkflowQuery } from "../hooks/useWorkflowQuery";
import { useDebugSessionQuery } from "../hooks/useDebugSessionQuery";
import { WorkflowSettings } from "../types/workflowTypes"; import { WorkflowSettings } from "../types/workflowTypes";
import { FlowRenderer } from "./FlowRenderer"; import { FlowRenderer } from "./FlowRenderer";
import { getElements } from "./workflowEditorUtils"; import { getElements } from "./workflowEditorUtils";
@@ -17,12 +20,19 @@ import { getInitialParameters } from "./utils";
function WorkflowDebugger() { function WorkflowDebugger() {
const { workflowPermanentId } = useParams(); const { workflowPermanentId } = useParams();
const queryClient = useQueryClient();
const [shouldFetchDebugSession, setShouldFetchDebugSession] = useState(false);
const { data: workflowRun } = useWorkflowRunQuery(); const { data: workflowRun } = useWorkflowRunQuery();
const { data: workflow } = useWorkflowQuery({ const { data: workflow } = useWorkflowQuery({
workflowPermanentId, workflowPermanentId,
}); });
const { data: debugSession } = useDebugSessionQuery({
workflowPermanentId,
enabled: shouldFetchDebugSession && !!workflowPermanentId,
});
const setCollapsed = useSidebarStore((state) => { const setCollapsed = useSidebarStore((state) => {
return state.setCollapsed; return state.setCollapsed;
}); });
@@ -34,8 +44,42 @@ function WorkflowDebugger() {
useMountEffect(() => { useMountEffect(() => {
setCollapsed(true); setCollapsed(true);
setHasChanges(false); setHasChanges(false);
if (workflowPermanentId) {
queryClient.removeQueries({
queryKey: ["debugSession", workflowPermanentId],
});
setShouldFetchDebugSession(true);
}
}); });
const intervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (
(!debugSession || !debugSession.browser_session_id) &&
shouldFetchDebugSession &&
workflowPermanentId
) {
intervalRef.current = setInterval(() => {
queryClient.invalidateQueries({
queryKey: ["debugSession", workflowPermanentId],
});
}, 2000);
} else {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [debugSession, shouldFetchDebugSession, workflowPermanentId, queryClient]);
if (!workflow) { if (!workflow) {
return null; return null;
} }
@@ -73,7 +117,7 @@ function WorkflowDebugger() {
/> />
</ReactFlowProvider> </ReactFlowProvider>
{workflowRun && ( {debugSession && (
<FloatingWindow <FloatingWindow
title={browserTitle} title={browserTitle}
bounded={false} bounded={false}
@@ -83,10 +127,10 @@ function WorkflowDebugger() {
showMinimizeButton={true} showMinimizeButton={true}
showReloadButton={true} showReloadButton={true}
> >
{workflowRun && workflowRun.browser_session_id ? ( {debugSession && debugSession.browser_session_id ? (
<BrowserStream <BrowserStream
interactive={interactor === "human"} interactive={interactor === "human"}
browserSessionId={workflowRun.browser_session_id} browserSessionId={debugSession.browser_session_id}
/> />
) : ( ) : (
<Skeleton className="h-full w-full" /> <Skeleton className="h-full w-full" />

View File

@@ -5,12 +5,14 @@ import { useLocation, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { getClient } from "@/api/AxiosClient"; import { getClient } from "@/api/AxiosClient";
import { ProxyLocation, User } from "@/api/types"; import { ProxyLocation } from "@/api/types";
import { Timer } from "@/components/Timer"; import { Timer } from "@/components/Timer";
import { toast } from "@/components/ui/use-toast"; import { toast } from "@/components/ui/use-toast";
import { useCredentialGetter } from "@/hooks/useCredentialGetter"; import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler"; import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler";
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback"; import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
import { useDebugSessionQuery } from "@/routes/workflows/hooks/useDebugSessionQuery";
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
import { import {
debuggableWorkflowBlockTypes, debuggableWorkflowBlockTypes,
@@ -29,11 +31,6 @@ import {
statusIsFinalized, statusIsFinalized,
statusIsRunningOrQueued, statusIsRunningOrQueued,
} from "@/routes/tasks/types"; } from "@/routes/tasks/types";
import {
useOptimisticallyRequestBrowserSessionId,
type OptimisticBrowserSession,
} from "@/store/useOptimisticallyRequestBrowserSessionId";
import { useUser } from "@/hooks/useUser";
import { EditableNodeTitle } from "../components/EditableNodeTitle"; import { EditableNodeTitle } from "../components/EditableNodeTitle";
import { NodeActionMenu } from "../NodeActionMenu"; import { NodeActionMenu } from "../NodeActionMenu";
@@ -73,24 +70,13 @@ const blockTypeToTitle = (type: WorkflowBlockType): string => {
const getPayload = (opts: { const getPayload = (opts: {
blockLabel: string; blockLabel: string;
optimistic: OptimisticBrowserSession; browserSessionId: string | null;
parameters: Record<string, unknown>; parameters: Record<string, unknown>;
totpIdentifier: string | null; totpIdentifier: string | null;
totpUrl: string | null; totpUrl: string | null;
user: User | null;
workflowPermanentId: string; workflowPermanentId: string;
workflowSettings: WorkflowSettingsState; workflowSettings: WorkflowSettingsState;
}): Payload | null => { }): Payload | null => {
if (!opts.user) {
toast({
variant: "warning",
title: "Error",
description: "No user found",
});
return null;
}
const webhook_url = opts.workflowSettings.webhookCallbackUrl.trim(); const webhook_url = opts.workflowSettings.webhookCallbackUrl.trim();
let extraHttpHeaders = null; let extraHttpHeaders = null;
@@ -108,14 +94,7 @@ const getPayload = (opts: {
}); });
} }
const browserSessionData = opts.optimistic.get( if (!opts.browserSessionId) {
opts.user,
opts.workflowPermanentId,
);
const browserSessionId = browserSessionData?.browser_session_id;
if (!browserSessionId) {
toast({ toast({
variant: "warning", variant: "warning",
title: "Error", title: "Error",
@@ -127,13 +106,13 @@ const getPayload = (opts: {
toast({ toast({
variant: "default", variant: "default",
title: "Success", title: "Success",
description: `Browser session ID found: ${browserSessionId}`, description: `Browser session ID found: ${opts.browserSessionId}`,
}); });
} }
const payload: Payload = { const payload: Payload = {
block_labels: [opts.blockLabel], block_labels: [opts.blockLabel],
browser_session_id: browserSessionId, browser_session_id: opts.browserSessionId,
extra_http_headers: extraHttpHeaders, extra_http_headers: extraHttpHeaders,
max_screenshot_scrolls: opts.workflowSettings.maxScreenshotScrollingTimes, max_screenshot_scrolls: opts.workflowSettings.maxScreenshotScrollingTimes,
parameters: opts.parameters, parameters: opts.parameters,
@@ -181,8 +160,9 @@ function NodeHeader({
const { data: workflowRun } = useWorkflowRunQuery(); const { data: workflowRun } = useWorkflowRunQuery();
const workflowRunIsRunningOrQueued = const workflowRunIsRunningOrQueued =
workflowRun && statusIsRunningOrQueued(workflowRun); workflowRun && statusIsRunningOrQueued(workflowRun);
const optimistic = useOptimisticallyRequestBrowserSessionId(); const { data: debugSession } = useDebugSessionQuery({
const user = useUser().get(); workflowPermanentId,
});
useEffect(() => { useEffect(() => {
if (!workflowRun || !workflowPermanentId || !workflowRunId) { if (!workflowRun || !workflowPermanentId || !workflowRunId) {
@@ -220,6 +200,21 @@ function NodeHeader({
mutationFn: async () => { mutationFn: async () => {
if (!workflowPermanentId) { if (!workflowPermanentId) {
console.error("There is no workflowPermanentId"); console.error("There is no workflowPermanentId");
toast({
variant: "destructive",
title: "Failed to start workflow block run",
description: "There is no workflowPermanentId",
});
return;
}
if (!debugSession) {
console.error("There is no debug session, yet");
toast({
variant: "destructive",
title: "Failed to start workflow block run",
description: "There is no debug session, yet",
});
return; return;
} }
@@ -244,16 +239,20 @@ function NodeHeader({
const body = getPayload({ const body = getPayload({
blockLabel, blockLabel,
optimistic, browserSessionId: debugSession.browser_session_id,
parameters, parameters,
totpIdentifier, totpIdentifier,
totpUrl, totpUrl,
user,
workflowPermanentId, workflowPermanentId,
workflowSettings: workflowSettingsStore, workflowSettings: workflowSettingsStore,
}); });
if (!body) { if (!body) {
toast({
variant: "destructive",
title: "Failed to start workflow block run",
description: "Could not construct run payload",
});
return; return;
} }
@@ -265,6 +264,11 @@ function NodeHeader({
onSuccess: (response) => { onSuccess: (response) => {
if (!response) { if (!response) {
console.error("No response"); console.error("No response");
toast({
variant: "destructive",
title: "Failed to start workflow block run",
description: "No response",
});
return; return;
} }
@@ -290,11 +294,17 @@ function NodeHeader({
const cancelBlock = useMutation({ const cancelBlock = useMutation({
mutationFn: async () => { mutationFn: async () => {
const browserSessionId = if (!debugSession) {
user && workflowPermanentId console.error("Missing debug session");
? optimistic.get(user, workflowPermanentId)?.browser_session_id ?? toast({
"<missing-browser-session-id>" variant: "destructive",
: "<missing-user-or-workflow-permanent-id>"; title: "Failed to cancel workflow block run",
description: "Missing debug session",
});
return;
}
const browserSessionId = debugSession.browser_session_id;
const client = await getClient(credentialGetter); const client = await getClient(credentialGetter);
return client return client
.post(`/runs/${browserSessionId}/workflow_run/${workflowRunId}/cancel/`) .post(`/runs/${browserSessionId}/workflow_run/${workflowRunId}/cancel/`)

View File

@@ -0,0 +1,29 @@
import { getClient } from "@/api/AxiosClient";
import { DebugSessionApiResponse } from "@/api/types";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { useQuery } from "@tanstack/react-query";
interface Opts {
workflowPermanentId?: string;
enabled?: boolean;
}
function useDebugSessionQuery({ workflowPermanentId, enabled }: Opts) {
const credentialGetter = useCredentialGetter();
return useQuery<DebugSessionApiResponse>({
queryKey: ["debugSession", workflowPermanentId],
queryFn: async () => {
const client = await getClient(credentialGetter, "sans-api-v1");
return client
.get(`/debug-session/${workflowPermanentId}`)
.then((response) => response.data);
},
enabled:
enabled !== undefined
? enabled && !!workflowPermanentId
: !!workflowPermanentId,
});
}
export { useDebugSessionQuery };

View File

@@ -1,128 +0,0 @@
import { AxiosInstance } from "axios";
import { create as createStore } from "zustand";
import { User } from "@/api/types";
import { lsKeys } from "@/util/env";
export interface BrowserSessionData {
browser_session_id: string | null;
expires_at: number | null; // seconds since epoch
}
interface RunOpts {
client: AxiosInstance;
reason?: string;
user: User;
workflowPermanentId?: string;
}
export interface OptimisticBrowserSession {
get: (user: User, workflowPermanentId: string) => BrowserSessionData | null;
run: (runOpts: RunOpts) => Promise<BrowserSessionData>;
}
const SESSION_TIMEOUT_MINUTES = 60 * 4;
const SPARE = "spare";
const makeKey = (user: User, workflowPermanentId?: string | undefined) => {
return `${lsKeys.optimisticBrowserSession}:${user.id}:${workflowPermanentId ?? SPARE}`;
};
/**
* Read a `BrowserSessionData` from localStorage cache. If the entry is expired,
* return `null`. If the entry is invalid, return `null`. Otherwise return it.
*/
const read = (key: string): BrowserSessionData | null => {
const stored = localStorage.getItem(key);
if (stored) {
try {
const parsed = JSON.parse(stored);
const { browser_session_id, expires_at } = parsed;
const now = Math.floor(Date.now() / 1000); // seconds since epoch
if (
browser_session_id &&
typeof browser_session_id === "string" &&
expires_at &&
typeof expires_at === "number" &&
now < expires_at
) {
return { browser_session_id, expires_at };
}
} catch (e) {
// pass
}
}
return null;
};
/**
* Write a `BrowserSessionData` to localStorage cache.
*/
const write = (key: string, browserSessionData: BrowserSessionData) => {
localStorage.setItem(key, JSON.stringify(browserSessionData));
};
/**
* Delete a localStorage key.
*/
const del = (key: string) => {
localStorage.removeItem(key);
};
/**
* Create a new browser session and return the `BrowserSessionData`.
*/
const create = async (client: AxiosInstance): Promise<BrowserSessionData> => {
const resp = await client.post("/browser_sessions", {
timeout: SESSION_TIMEOUT_MINUTES,
});
const { browser_session_id: newBrowserSessionId, timeout } = resp.data;
const newExpiresAt = Math.floor(Date.now() / 1000) + timeout * 60 * 0.9;
return {
browser_session_id: newBrowserSessionId,
expires_at: newExpiresAt,
};
};
export const useOptimisticallyRequestBrowserSessionId =
createStore<OptimisticBrowserSession>(() => ({
get: (user: User, workflowPermanentId: string) => {
return read(makeKey(user, workflowPermanentId));
},
run: async ({ client, user, workflowPermanentId }: RunOpts) => {
if (workflowPermanentId) {
const userKey = makeKey(user, workflowPermanentId);
const exists = read(userKey);
if (exists) {
return exists;
}
const spareKey = makeKey(user, SPARE);
const spare = read(spareKey);
if (spare) {
del(spareKey);
write(userKey, spare);
create(client).then((newSpare) => write(spareKey, newSpare));
return spare;
}
}
const key = makeKey(user, workflowPermanentId);
const browserSessionData = read(key);
if (browserSessionData) {
return browserSessionData;
}
const knew = await create(client);
write(key, knew);
return knew;
},
}));

View File

@@ -23,7 +23,6 @@ const apiPathPrefix = import.meta.env.VITE_API_PATH_PREFIX ?? "";
const lsKeys = { const lsKeys = {
browserSessionId: "skyvern.browserSessionId", browserSessionId: "skyvern.browserSessionId",
optimisticBrowserSession: "skyvern.optimisticBrowserSession",
}; };
const wssBaseUrl = import.meta.env.VITE_WSS_BASE_URL; const wssBaseUrl = import.meta.env.VITE_WSS_BASE_URL;