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" && (
+
+
+
+
+
+ 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,
+ };
+};