From 47d51be796f3eb15346994d1e08d2c96eec2361a Mon Sep 17 00:00:00 2001 From: Jonathan Dobson Date: Fri, 29 Aug 2025 13:30:53 -0400 Subject: [PATCH] Debugger Continuity (FE) (#3318) --- skyvern-frontend/src/components/SwitchBar.tsx | 22 +- skyvern-frontend/src/components/Timer.tsx | 17 +- .../src/components/icons/OutputIcon.tsx | 43 +++ .../workflows/components/BlockOutputs.tsx | 183 ++++++++++++ .../workflows/components/CodeEditor.tsx | 18 +- .../routes/workflows/debugger/Debugger.tsx | 22 ++ .../workflows/editor/WorkflowEditor.tsx | 7 +- .../nodes/ExtractionNode/ExtractionNode.tsx | 7 +- .../editor/nodes/components/NodeFooter.tsx | 106 +++++++ .../editor/nodes/components/NodeHeader.tsx | 37 ++- .../hooks/useDebugSessionBlockOutputsQuery.ts | 25 ++ .../src/store/BlockOutputStore.ts | 273 ++++++++++++++++++ skyvern-frontend/src/util/utils.ts | 32 ++ 13 files changed, 767 insertions(+), 25 deletions(-) create mode 100644 skyvern-frontend/src/components/icons/OutputIcon.tsx create mode 100644 skyvern-frontend/src/routes/workflows/components/BlockOutputs.tsx create mode 100644 skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeFooter.tsx create mode 100644 skyvern-frontend/src/routes/workflows/hooks/useDebugSessionBlockOutputsQuery.ts create mode 100644 skyvern-frontend/src/store/BlockOutputStore.ts diff --git a/skyvern-frontend/src/components/SwitchBar.tsx b/skyvern-frontend/src/components/SwitchBar.tsx index 47d98d53..6eb11a13 100644 --- a/skyvern-frontend/src/components/SwitchBar.tsx +++ b/skyvern-frontend/src/components/SwitchBar.tsx @@ -1,8 +1,15 @@ +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { cn } from "@/util/utils"; type Option = { label: string; value: string; + helpText?: string; }; type Props = { @@ -23,7 +30,7 @@ function SwitchBar({ className, highlight, options, value, onChange }: Props) { > {options.map((option) => { const selected = option.value === value; - return ( + const optionElement = (
); + + if (option.helpText) { + return ( + + + {optionElement} + {option.helpText} + + + ); + } + + return optionElement; })}
); diff --git a/skyvern-frontend/src/components/Timer.tsx b/skyvern-frontend/src/components/Timer.tsx index d5e6a2e4..d12509c8 100644 --- a/skyvern-frontend/src/components/Timer.tsx +++ b/skyvern-frontend/src/components/Timer.tsx @@ -1,5 +1,7 @@ import { useEffect, useState } from "react"; +import { formatMs } from "@/util/utils"; + interface HMS { hour: number; minute: number; @@ -11,21 +13,6 @@ interface Props { startAt?: HMS; } -const formatMs = (elapsed: number) => { - let seconds = Math.floor(elapsed / 1000); - let minutes = Math.floor(seconds / 60); - let hours = Math.floor(minutes / 60); - seconds = seconds % 60; - minutes = minutes % 60; - hours = hours % 24; - - return { - hour: hours, - minute: minutes, - second: seconds, - }; -}; - function Timer({ override, startAt }: Props) { const [time, setTime] = useState({ hour: 0, diff --git a/skyvern-frontend/src/components/icons/OutputIcon.tsx b/skyvern-frontend/src/components/icons/OutputIcon.tsx new file mode 100644 index 00000000..9faa02c4 --- /dev/null +++ b/skyvern-frontend/src/components/icons/OutputIcon.tsx @@ -0,0 +1,43 @@ +type Props = { + className?: string; +}; + +function OutputIcon({ className }: Props) { + return ( + + + + + + + ); +} + +export { OutputIcon }; diff --git a/skyvern-frontend/src/routes/workflows/components/BlockOutputs.tsx b/skyvern-frontend/src/routes/workflows/components/BlockOutputs.tsx new file mode 100644 index 00000000..0864c5ce --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/components/BlockOutputs.tsx @@ -0,0 +1,183 @@ +import { useState } from "react"; +import { useParams } from "react-router-dom"; + +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { HelpTooltip } from "@/components/HelpTooltip"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { SwitchBar } from "@/components/SwitchBar"; +import { useBlockOutputStore } from "@/store/BlockOutputStore"; +import { cn, formatMs } from "@/util/utils"; + +import { CodeEditor } from "./CodeEditor"; + +type PageName = "output" | "override"; + +function BlockOutputs({ + blockLabel, + blockOutput, +}: { + blockLabel: string; + blockOutput: { [k: string]: unknown } | null; +}) { + const { workflowPermanentId } = useParams(); + const blockOutputStore = useBlockOutputStore(); + const [pageName, setPageName] = useState("output"); + const [overrideHasError, setOverrideHasError] = useState(false); + const useOverride = useBlockOutputStore((state) => + workflowPermanentId + ? state.useOverrides[workflowPermanentId]?.[blockLabel] ?? false + : false, + ); + + let createdAt: Date | null = null; + + if (blockOutput) { + delete blockOutput.task_id; + delete blockOutput.status; + delete blockOutput.failure_reason; + delete blockOutput.errors; + + if ("created_at" in blockOutput) { + const _createdAt = blockOutput.created_at; + + if (typeof _createdAt === "string") { + // ensure UTC parsing by appending 'Z' if not present + const utcString = _createdAt.endsWith("Z") + ? _createdAt + : _createdAt + "Z"; + createdAt = new Date(utcString); + } + } + } + + const codeOutput = + blockOutput === null ? null : JSON.stringify(blockOutput, null, 2); + + const ago = createdAt ? formatMs(Date.now() - createdAt.getTime()).ago : null; + + const override = blockOutputStore.getOverride({ + wpid: workflowPermanentId, + blockLabel, + }); + + const codeOverride = override ? JSON.stringify(override, null, 2) : null; + + return ( +
+
+ setPageName(value as PageName)} + value={pageName} + options={[ + { + label: "Output", + value: "output", + helpText: + "The last output from this block, when it completed successfully.", + }, + { + label: "Override", + value: "override", + helpText: "Supply your own override output.", + }, + ]} + /> + {pageName === "output" && ( + + + +
{ago}
+
+ When the output was created +
+
+ )} + {pageName === "override" && ( +
+ + + { + blockOutputStore.setUseOverride({ + wpid: workflowPermanentId, + blockLabel, + value, + }); + }} + /> +
+ )} +
+ {pageName === "output" ? ( +
+ {codeOutput ? ( + <> + + + ) : ( +
+ No output defined +
+ )} +
+ ) : ( +
+ { + try { + JSON.parse(value), setOverrideHasError(false); + } catch { + setOverrideHasError(true); + return; + } + const wasStored = blockOutputStore.setOverride({ + wpid: workflowPermanentId, + blockLabel, + data: JSON.parse(value), + }); + + if (!wasStored) { + setOverrideHasError(true); + } + }} + /> +
+ )} +
+ ); +} + +export { BlockOutputs }; diff --git a/skyvern-frontend/src/routes/workflows/components/CodeEditor.tsx b/skyvern-frontend/src/routes/workflows/components/CodeEditor.tsx index d56c2b47..bb34fdbe 100644 --- a/skyvern-frontend/src/routes/workflows/components/CodeEditor.tsx +++ b/skyvern-frontend/src/routes/workflows/components/CodeEditor.tsx @@ -26,8 +26,14 @@ type Props = { maxHeight?: string; className?: string; fontSize?: number; + fullHeight?: boolean; }; +const fullHeightExtension = EditorView.theme({ + "&": { height: "100%" }, // the root + ".cm-scroller": { flex: 1 }, // makes the scrollable area expand +}); + function CodeEditor({ value, onChange, @@ -38,11 +44,19 @@ function CodeEditor({ className, readOnly = false, fontSize = 12, + fullHeight = false, }: Props) { const extensions = language ? [getLanguageExtension(language), lineWrap ? EditorView.lineWrapping : []] : [lineWrap ? EditorView.lineWrapping : []]; + const style: React.CSSProperties = { fontSize }; + + if (fullHeight) { + extensions.push(fullHeightExtension); + style.height = "100%"; + } + return ( ); } diff --git a/skyvern-frontend/src/routes/workflows/debugger/Debugger.tsx b/skyvern-frontend/src/routes/workflows/debugger/Debugger.tsx index 71cb6c62..60d9a2bd 100644 --- a/skyvern-frontend/src/routes/workflows/debugger/Debugger.tsx +++ b/skyvern-frontend/src/routes/workflows/debugger/Debugger.tsx @@ -7,18 +7,25 @@ import { WorkflowSettings } from "../types/workflowTypes"; import { getElements } from "@/routes/workflows/editor/workflowEditorUtils"; import { getInitialParameters } from "@/routes/workflows/editor/utils"; import { Workspace } from "@/routes/workflows/editor/Workspace"; +import { useDebugSessionBlockOutputsQuery } from "../hooks/useDebugSessionBlockOutputsQuery"; import { useWorkflowParametersStore } from "@/store/WorkflowParametersStore"; +import { useBlockOutputStore } from "@/store/BlockOutputStore"; function Debugger() { const { workflowPermanentId } = useParams(); const { data: workflow } = useWorkflowQuery({ workflowPermanentId, }); + const { data: outputParameters } = useDebugSessionBlockOutputsQuery({ + workflowPermanentId, + }); const setParameters = useWorkflowParametersStore( (state) => state.setParameters, ); + const setBlockOutputs = useBlockOutputStore((state) => state.setOutputs); + useEffect(() => { if (workflow) { const initialParameters = getInitialParameters(workflow); @@ -26,6 +33,21 @@ function Debugger() { } }, [workflow, setParameters]); + useEffect(() => { + if (!outputParameters) { + return; + } + + const blockOutputs = Object.entries(outputParameters).reduce<{ + [k: string]: Record; + }>((acc, [blockLabel, outputs]) => { + acc[blockLabel] = outputs ?? null; + return acc; + }, {}); + + setBlockOutputs(blockOutputs); + }, [outputParameters, setBlockOutputs]); + if (!workflow) { return null; } diff --git a/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx b/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx index ce15be32..3c3250f2 100644 --- a/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx @@ -6,13 +6,14 @@ import { getElements } from "./workflowEditorUtils"; import { LogoMinimized } from "@/components/LogoMinimized"; import { WorkflowSettings } from "../types/workflowTypes"; import { useGlobalWorkflowsQuery } from "../hooks/useGlobalWorkflowsQuery"; +import { useBlockOutputStore } from "@/store/BlockOutputStore"; import { useWorkflowParametersStore } from "@/store/WorkflowParametersStore"; import { getInitialParameters } from "./utils"; import { Workspace } from "./Workspace"; +import { useMountEffect } from "@/hooks/useMountEffect"; function WorkflowEditor() { const { workflowPermanentId } = useParams(); - const { data: workflow, isLoading } = useWorkflowQuery({ workflowPermanentId, }); @@ -24,6 +25,10 @@ function WorkflowEditor() { (state) => state.setParameters, ); + const blockOutputStore = useBlockOutputStore(); + + useMountEffect(() => blockOutputStore.reset()); + useEffect(() => { if (workflow) { const initialParameters = getInitialParameters(workflow); diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/ExtractionNode/ExtractionNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/ExtractionNode/ExtractionNode.tsx index 2f645ee2..6330e4cb 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/ExtractionNode/ExtractionNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/ExtractionNode/ExtractionNode.tsx @@ -36,6 +36,7 @@ import { ModelSelector } from "@/components/ModelSelector"; import { useBlockScriptStore } from "@/store/BlockScriptStore"; import { cn } from "@/util/utils"; import { NodeHeader } from "../components/NodeHeader"; +import { NodeFooter } from "../components/NodeFooter"; import { useParams } from "react-router-dom"; import { statusIsRunningOrQueued } from "@/routes/tasks/types"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; @@ -103,9 +104,8 @@ function ExtractionNode({ id, data, type }: NodeProps) { className={cn( "transform-origin-center w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4 transition-all", { - "pointer-events-none": thisBlockIsPlaying, - "bg-slate-950 outline outline-2 outline-slate-300": - thisBlockIsTargetted, + "pointer-events-none bg-slate-950": thisBlockIsPlaying, + "outline outline-2 outline-slate-300": thisBlockIsTargetted, }, )} > @@ -278,6 +278,7 @@ function ExtractionNode({ id, data, type }: NodeProps) { + diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeFooter.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeFooter.tsx new file mode 100644 index 00000000..7d0f4f63 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeFooter.tsx @@ -0,0 +1,106 @@ +import { useState } from "react"; +import { useParams } from "react-router-dom"; +import { CrossCircledIcon } from "@radix-ui/react-icons"; +import { OutputIcon } from "@/components/icons/OutputIcon"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { statusIsRunningOrQueued } from "@/routes/tasks/types"; +import { BlockOutputs } from "@/routes/workflows/components/BlockOutputs"; +import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; +import { useBlockOutputStore } from "@/store/BlockOutputStore"; +import { cn } from "@/util/utils"; + +interface Props { + blockLabel: string; +} + +function NodeFooter({ blockLabel }: Props) { + const { blockLabel: urlBlockLabel } = useParams(); + const blockOutput = useBlockOutputStore((state) => state.outputs[blockLabel]); + const [isExpanded, setIsExpanded] = useState(false); + const { data: workflowRun } = useWorkflowRunQuery(); + const workflowRunIsRunningOrQueued = + workflowRun && statusIsRunningOrQueued(workflowRun); + const thisBlockIsPlaying = + workflowRunIsRunningOrQueued && + urlBlockLabel !== undefined && + urlBlockLabel === blockLabel; + const thisBlockIsTargetted = + urlBlockLabel !== undefined && urlBlockLabel === blockLabel; + + if (thisBlockIsPlaying) { + return null; + } + + return ( + <> +
+
+
+ +
+
+
+
+ + + +
+ +
+
+ + {isExpanded ? "Close Outputs" : "Open Outputs"} + +
+
+
+ + ); +} + +export { NodeFooter }; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx index 383f9e4e..835aba2d 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx @@ -1,15 +1,16 @@ import { AxiosError } from "axios"; import { ReloadIcon, PlayIcon, StopIcon } from "@radix-ui/react-icons"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useLocation, useNavigate, useParams } from "react-router-dom"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { getClient } from "@/api/AxiosClient"; -import { ProxyLocation } from "@/api/types"; +import { ProxyLocation, Status } from "@/api/types"; import { Timer } from "@/components/Timer"; import { toast } from "@/components/ui/use-toast"; import { useLogging } from "@/hooks/useLogging"; import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import { useOnChange } from "@/hooks/useOnChange"; import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler"; import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback"; @@ -23,6 +24,7 @@ import { type WorkflowApiResponse, } from "@/routes/workflows/types/workflowTypes"; import { getInitialValues } from "@/routes/workflows/utils"; +import { useBlockOutputStore } from "@/store/BlockOutputStore"; import { useDebugStore } from "@/store/useDebugStore"; import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore"; import { useWorkflowSave } from "@/store/WorkflowHasChangesStore"; @@ -54,6 +56,7 @@ interface Props { type Payload = Record & { block_labels: string[]; + block_outputs: Record; browser_session_id: string | null; extra_http_headers: Record | null; max_screenshot_scrolls: number | null; @@ -67,6 +70,7 @@ type Payload = Record & { const getPayload = (opts: { blockLabel: string; + blockOutputs: Record; browserSessionId: string | null; parameters: Record; totpIdentifier: string | null; @@ -109,6 +113,7 @@ const getPayload = (opts: { const payload: Payload = { block_labels: [opts.blockLabel], + block_outputs: opts.blockOutputs, browser_session_id: opts.browserSessionId, extra_http_headers: extraHttpHeaders, max_screenshot_scrolls: opts.workflowSettings.maxScreenshotScrollingTimes, @@ -138,6 +143,7 @@ function NodeHeader({ workflowPermanentId, workflowRunId, } = useParams(); + const blockOutputsStore = useBlockOutputStore(); const debugStore = useDebugStore(); const { closeWorkflowPanel } = useWorkflowPanelStore(); const workflowSettingsStore = useWorkflowSettingsStore(); @@ -177,6 +183,26 @@ function NodeHeader({ 3500 : null; + const [workflowRunStatus, setWorkflowRunStatus] = useState( + workflowRun?.status, + ); + + useEffect(() => { + setWorkflowRunStatus(workflowRun?.status); + }, [workflowRun, setWorkflowRunStatus]); + + useOnChange(workflowRunStatus, (newValue, oldValue) => { + if (!thisBlockIsTargetted) { + return; + } + + if (newValue !== oldValue && oldValue && newValue === Status.Completed) { + queryClient.invalidateQueries({ + queryKey: ["block-outputs", workflowPermanentId], + }); + } + }); + useEffect(() => { if (!workflowRun || !workflowPermanentId || !workflowRunId) { return; @@ -202,6 +228,7 @@ function NodeHeader({ } } }, [ + queryClient, urlBlockLabel, navigate, workflowPermanentId, @@ -226,6 +253,10 @@ function NodeHeader({ } if (!debugSession) { + // TODO: kind of redundant; investigate if this is necessary; either + // Sentry's log should output to the console, or Sentry should just + // gather native console.error output. + console.error("Run block: there is no debug session, yet"); log.error("Run block: there is no debug session, yet"); toast({ variant: "destructive", @@ -256,6 +287,8 @@ function NodeHeader({ const body = getPayload({ blockLabel, + blockOutputs: + blockOutputsStore.getOutputsWithOverrides(workflowPermanentId), browserSessionId: debugSession.browser_session_id, parameters, totpIdentifier, diff --git a/skyvern-frontend/src/routes/workflows/hooks/useDebugSessionBlockOutputsQuery.ts b/skyvern-frontend/src/routes/workflows/hooks/useDebugSessionBlockOutputsQuery.ts new file mode 100644 index 00000000..5f39edfd --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/hooks/useDebugSessionBlockOutputsQuery.ts @@ -0,0 +1,25 @@ +import { getClient } from "@/api/AxiosClient"; +import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import { useQuery } from "@tanstack/react-query"; + +type Props = { + workflowPermanentId?: string; +}; + +function useDebugSessionBlockOutputsQuery({ workflowPermanentId }: Props) { + const credentialGetter = useCredentialGetter(); + + return useQuery<{ [k: string]: { extracted_information: unknown } }>({ + queryKey: ["block-outputs", workflowPermanentId], + queryFn: async () => { + const client = await getClient(credentialGetter, "sans-api-v1"); + const result = await client + .get(`/debug-session/${workflowPermanentId}/block-outputs`) + .then((response) => response.data); + return result; + }, + enabled: !!workflowPermanentId, + }); +} + +export { useDebugSessionBlockOutputsQuery }; diff --git a/skyvern-frontend/src/store/BlockOutputStore.ts b/skyvern-frontend/src/store/BlockOutputStore.ts new file mode 100644 index 00000000..4be66228 --- /dev/null +++ b/skyvern-frontend/src/store/BlockOutputStore.ts @@ -0,0 +1,273 @@ +/** + * A store to hold block outputs for the debugger. Overrides for block outputs, + * keyed by (wpid, blockLabel), are kept in local storage. + */ + +import { create } from "zustand"; + +interface BlockOutputStore { + outputs: { [blockLabel: string]: { [k: string]: unknown } }; + useOverrides: { [wpid: string]: { [blockLabel: string]: boolean } }; + // -- + getOverride: (opts: { + wpid: string | undefined; + blockLabel: string; + }) => { [k: string]: unknown } | null; + getUseOverride: (opts: { + wpid: string | undefined; + blockLabel: string; + }) => boolean; + getOutputsWithOverrides: (wpid: string | undefined) => { + [blockLabel: string]: { [k: string]: unknown }; + }; + setOutputs: (outputs: { + [blockLabel: string]: { [k: string]: unknown }; + }) => void; + setOverride: (opts: { + wpid: string | undefined; + blockLabel: string; + data: { [k: string]: unknown }; + }) => boolean; + setUseOverride: (opts: { + wpid: string | undefined; + blockLabel: string; + value: boolean; + }) => void; + reset: () => void; +} + +const getStorageKey = (wpid: string, blockLabel: string) => { + return `skyvern.block-output.${wpid}.${blockLabel}`; +}; + +const getStorageKeyForUse = (wpid: string, blockLabel: string) => { + return `skyvern.block-output.use.${wpid}.${blockLabel}`; +}; + +const serialize = ( + blockLabel: string, + data: { [k: string]: unknown } | boolean, +) => { + let serialized: string | null = null; + + try { + serialized = JSON.stringify(data); + } catch (e) { + console.error(`Cannot serialize data for ${blockLabel}`, e, data); + } + + if (serialized === null) { + return false; + } + + if (serialized.trim() === "") { + serialized = "null"; + } + + return serialized; +}; + +const loadUse = (wpid: string, blockLabel: string) => { + const key = getStorageKeyForUse(wpid, blockLabel); + const serialized = localStorage.getItem(key); + + if (!serialized) { + return false; + } + + try { + return Boolean(JSON.parse(serialized)); + } catch (e) { + console.error(`Cannot deserialize use override for ${blockLabel}`, e); + return null; + } +}; + +const load = (wpid: string, blockLabel: string) => { + const key = getStorageKey(wpid, blockLabel); + const serialized = localStorage.getItem(key); + + if (!serialized) { + return null; + } + + try { + return JSON.parse(serialized) as { [k: string]: unknown }; + } catch (e) { + console.error( + `Cannot deserialize block output override for ${blockLabel}`, + e, + ); + return null; + } +}; + +const store = ( + wpid: string, + blockLabel: string, + data: { [k: string]: unknown }, +) => { + const key = getStorageKey(wpid, blockLabel); + const serialized = serialize(blockLabel, data); + + if (serialized === false) { + return false; + } + + localStorage.setItem(key, serialized); + + return true; +}; + +const storeUse = (wpid: string, blockLabel: string, value: boolean) => { + const key = getStorageKeyForUse(wpid, blockLabel); + const serialized = serialize(blockLabel, value); + + if (serialized === false) { + return false; + } + + localStorage.setItem(key, serialized); + + return true; +}; + +// Helper function to load all useOverrides from localStorage +const loadAllUseOverrides = (): { + [wpid: string]: { [blockLabel: string]: boolean }; +} => { + const useOverrides: { + [wpid: string]: { [blockLabel: string]: boolean }; + } = {}; + + // Iterate through all localStorage keys to find useOverride entries + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key?.startsWith("skyvern.block-output.use.")) { + try { + const value = localStorage.getItem(key); + if (value) { + const parsed = JSON.parse(value); + // Extract wpid and blockLabel from key: skyvern.block-output.use.{wpid}.{blockLabel} + const keyParts = key.split("."); + if (keyParts.length >= 5) { + const wpid = keyParts[3]; + const blockLabel = keyParts.slice(4).join("."); + + if (wpid && blockLabel) { + useOverrides[wpid] ??= {}; + useOverrides[wpid][blockLabel] = Boolean(parsed); + } + } + } + } catch (e) { + console.error( + `Failed to parse useOverride from localStorage key: ${key}`, + e, + ); + } + } + } + + return useOverrides; +}; + +const useBlockOutputStore = create((set, get) => { + return { + outputs: {}, + useOverrides: loadAllUseOverrides(), + // -- + getOverride: (opts) => { + const { wpid, blockLabel } = opts; + + if (!wpid) { + return null; + } + + const data = load(wpid, blockLabel); + + return data; + }, + getUseOverride: (opts) => { + const { wpid, blockLabel } = opts; + + if (!wpid) { + return false; + } + + const use = loadUse(wpid, blockLabel); + + return use || false; + }, + getOutputsWithOverrides: (wpid) => { + const state = get(); + const baseOutputs = { ...state.outputs }; + + if (!wpid) { + return baseOutputs; + } + + // Apply overrides for blocks where useOverrides[wpid][blockLabel] is true + const workflowOverrides = state.useOverrides[wpid]; + if (workflowOverrides) { + Object.entries(workflowOverrides).forEach( + ([blockLabel, useOverride]) => { + if (useOverride) { + const override = state.getOverride({ wpid, blockLabel }); + if (override) { + baseOutputs[blockLabel] = override; + } + } + }, + ); + } + + return baseOutputs; + }, + setOutputs: (outputs) => { + set(() => ({ + outputs, + })); + }, + setOverride: (opts) => { + const { wpid, blockLabel, data } = opts; + + if (!wpid) { + return false; + } + + const wasStored = store(wpid, blockLabel, data); + + return wasStored; + }, + setUseOverride: (opts) => { + const { wpid, blockLabel, value } = opts; + + if (!wpid) { + return false; + } + + const wasStored = storeUse(wpid, blockLabel, value); + + set((state) => ({ + ...state, + useOverrides: { + ...state.useOverrides, + [wpid]: { + ...state.useOverrides[wpid], + [blockLabel]: value, + }, + }, + })); + + return wasStored; + }, + reset: () => { + set({ + outputs: {}, + }); + }, + }; +}); + +export { useBlockOutputStore }; diff --git a/skyvern-frontend/src/util/utils.ts b/skyvern-frontend/src/util/utils.ts index 365058ce..a1773605 100644 --- a/skyvern-frontend/src/util/utils.ts +++ b/skyvern-frontend/src/util/utils.ts @@ -4,3 +4,35 @@ import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +const clampToZero = (n: number) => Math.max(n, 0); + +export const formatMs = (elapsed: number) => { + let seconds = clampToZero(Math.floor(elapsed / 1000)); + let minutes = clampToZero(Math.floor(seconds / 60)); + let hours = clampToZero(Math.floor(minutes / 60)); + const days = clampToZero(Math.floor(hours / 24)); + + seconds = seconds % 60; + minutes = minutes % 60; + hours = hours % 24; + + const ago = + days === 0 && hours === 0 && minutes === 0 && seconds === 0 + ? "now" + : days === 0 && hours === 0 && minutes === 0 + ? `${seconds}s ago` + : days === 0 && hours === 0 + ? `${minutes}m ago` + : days === 0 + ? `${hours}h ago` + : `${days}d ago`; + + return { + ago, + hour: hours, + minute: minutes, + second: seconds, + day: days, + }; +};