From 7e6dfcc6d16823980e36e34c4f1932f60ee05c1a Mon Sep 17 00:00:00 2001 From: Celal Zamanoglu <95054566+celalzamanoglu@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:42:57 +0300 Subject: [PATCH] Prompt user for parameter values before running blocks in debugger (#SKY-6097) (#4668) --- .../workflows/WorkflowParameterInput.tsx | 12 +- .../components/BlockParametersDialog.tsx | 245 ++++++++++++++++++ .../editor/nodes/components/NodeHeader.tsx | 78 +++++- .../workflows/hooks/useDebugSessionQuery.ts | 13 + .../forge/sdk/services/org_auth_service.py | 4 +- 5 files changed, 345 insertions(+), 7 deletions(-) create mode 100644 skyvern-frontend/src/routes/workflows/editor/nodes/components/BlockParametersDialog.tsx diff --git a/skyvern-frontend/src/routes/workflows/WorkflowParameterInput.tsx b/skyvern-frontend/src/routes/workflows/WorkflowParameterInput.tsx index cbb21923..c254cc58 100644 --- a/skyvern-frontend/src/routes/workflows/WorkflowParameterInput.tsx +++ b/skyvern-frontend/src/routes/workflows/WorkflowParameterInput.tsx @@ -47,7 +47,11 @@ function WorkflowParameterInput({ type, value, onChange }: Props) { return ( onChange(parseInt(e.target.value))} + onChange={(e) => { + const val = e.target.value; + // Return null for empty input, otherwise parse as integer + onChange(val === "" ? null : parseInt(val, 10)); + }} type="number" /> ); @@ -57,7 +61,11 @@ function WorkflowParameterInput({ type, value, onChange }: Props) { return ( onChange(parseFloat(e.target.value))} + onChange={(e) => { + const val = e.target.value; + // Return null for empty input, otherwise parse as float + onChange(val === "" ? null : parseFloat(val)); + }} type="number" step="any" /> diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/components/BlockParametersDialog.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/components/BlockParametersDialog.tsx new file mode 100644 index 00000000..4a4cd58e --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/components/BlockParametersDialog.tsx @@ -0,0 +1,245 @@ +import { useState, useEffect, useMemo } from "react"; +import { ReloadIcon } from "@radix-ui/react-icons"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { ScrollArea, ScrollAreaViewport } from "@/components/ui/scroll-area"; +import { WorkflowParameterInput } from "@/routes/workflows/WorkflowParameterInput"; +import { getLabelForWorkflowParameterType } from "@/routes/workflows/editor/workflowEditorUtils"; +import type { WorkflowParameter } from "@/routes/workflows/types/workflowTypes"; + +/** + * Validates a parameter value based on its type. + * Matches the validation logic in RunWorkflowForm. + */ +function validateParameterValue(value: unknown, type: string): string | null { + switch (type) { + case "json": + if (value === null || value === undefined) { + return "This field is required"; + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed === "") { + return "This field is required"; + } + try { + JSON.parse(trimmed); + return null; + } catch (e) { + const message = e instanceof SyntaxError ? e.message : "Parse error"; + return `Invalid JSON: ${message}`; + } + } + return null; + + case "boolean": + if (value === null || value === undefined) { + return "This field is required"; + } + return null; + + case "integer": + case "float": + if (value === null || value === undefined || Number.isNaN(value)) { + return "This field is required"; + } + return null; + + case "file_url": + if ( + value === null || + value === undefined || + (typeof value === "string" && value.trim() === "") || + (typeof value === "object" && + value !== null && + "s3uri" in value && + !(value as { s3uri: unknown }).s3uri) + ) { + return "This field is required"; + } + return null; + + default: + return null; + } +} + +interface BlockParametersDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + blockLabel: string; + parameters: WorkflowParameter[]; + initialValues: Record; + onSubmit: (values: Record) => void; + isLoading?: boolean; +} + +function getDefaultValues( + parameters: WorkflowParameter[], + initialValues: Record, +): Record { + const values: Record = { ...initialValues }; + for (const param of parameters) { + if (values[param.key] === undefined || values[param.key] === null) { + // Set defaults - use null for required types to force user selection + // This matches RunWorkflowForm behavior + switch (param.workflow_parameter_type) { + case "string": + values[param.key] = ""; + break; + case "integer": + case "float": + case "boolean": + case "file_url": + values[param.key] = null; + break; + case "json": + values[param.key] = ""; + break; + default: + values[param.key] = null; + } + } + } + return values; +} + +function BlockParametersDialog({ + open, + onOpenChange, + blockLabel, + parameters, + initialValues, + onSubmit, + isLoading = false, +}: BlockParametersDialogProps) { + const [values, setValues] = useState>({}); + + // Reset values when dialog opens + useEffect(() => { + if (open) { + setValues(getDefaultValues(parameters, initialValues)); + } + // Only reset when dialog opens - don't react to parameter/initialValues changes while open + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + // Validate all parameters matching RunWorkflowForm validation + const validationErrors = useMemo(() => { + const errors: Record = {}; + for (const param of parameters) { + const error = validateParameterValue( + values[param.key], + param.workflow_parameter_type, + ); + if (error) { + errors[param.key] = error; + } + } + return errors; + }, [parameters, values]); + + const hasValidationErrors = Object.keys(validationErrors).length > 0; + + const handleValueChange = (key: string, value: unknown) => { + setValues((prev) => ({ ...prev, [key]: value })); + }; + + const handleSubmit = () => { + if (hasValidationErrors) { + return; + } + // Merge with initial values to include all parameters + const mergedValues = { ...initialValues, ...values }; + onSubmit(mergedValues); + }; + + return ( + + + + Enter Parameter Values + + The block "{blockLabel}" requires the following parameters to run. + + + + + {/* Height set to ~3.5 parameters to create visual cutoff hint */} + + {/* px-1 prevents focus ring from being clipped on left/right edges */} +
+ {parameters.map((param) => ( +
+
+ + + {getLabelForWorkflowParameterType( + param.workflow_parameter_type, + )} + +
+ {param.description && ( +

+ {param.description} +

+ )} +
+ handleValueChange(param.key, value)} + /> + {validationErrors[param.key] && ( +

+ {validationErrors[param.key]} +

+ )} +
+
+ ))} +
+
+
+ + + + + +
+
+ ); +} + +export { BlockParametersDialog }; 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 b0a7e461..de8bffd8 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx @@ -26,6 +26,8 @@ import { scriptableWorkflowBlockTypes, type WorkflowBlockType, type WorkflowApiResponse, + type WorkflowParameter, + type Parameter, } from "@/routes/workflows/types/workflowTypes"; import { getInitialValues } from "@/routes/workflows/utils"; import { useBlockOutputStore } from "@/store/BlockOutputStore"; @@ -49,6 +51,16 @@ import { NodeActionMenu } from "../NodeActionMenu"; import { WorkflowBlockIcon } from "../WorkflowBlockIcon"; import { workflowBlockTitle } from "../types"; import { MicroDropdown } from "./MicroDropdown"; +import { BlockParametersDialog } from "./BlockParametersDialog"; + +function isWorkflowParameter(param: Parameter): param is WorkflowParameter { + return ( + param?.parameter_type === "workflow" && + "workflow_parameter_type" in param && + typeof param.workflow_parameter_type === "string" && + param.workflow_parameter_type.length > 0 + ); +} interface Transmutations { blockTitle: string; @@ -213,6 +225,13 @@ function NodeHeader({ const [workflowRunStatus, setWorkflowRunStatus] = useState( workflowRun?.status, ); + const [showParamsDialog, setShowParamsDialog] = useState(false); + const [parametersToPrompt, setParametersToPrompt] = useState< + WorkflowParameter[] + >([]); + const [currentParamValues, setCurrentParamValues] = useState< + Record + >({}); const { getAutoplay, setAutoplay } = useAutoplayStore(); useEffect(() => { @@ -286,7 +305,10 @@ function NodeHeader({ ]); const runBlock = useMutation({ - mutationFn: async (opts?: { codeGen: boolean }) => { + mutationFn: async (opts?: { + codeGen: boolean; + parameterOverrides?: Record; + }) => { closeWorkflowPanel(); await saveWorkflow.mutateAsync(); @@ -332,6 +354,11 @@ function NodeHeader({ const parameters = getInitialValues(location, workflowParameters ?? []); + // Merge with parameter overrides if provided + const mergedParameters = opts?.parameterOverrides + ? { ...parameters, ...opts.parameterOverrides } + : parameters; + const client = await getClient(credentialGetter, "sans-api-v1"); const body = getPayload({ @@ -341,7 +368,7 @@ function NodeHeader({ browserSessionId: debugSession.browser_session_id, debugSessionId: debugSession.debug_session_id, codeGen: opts?.codeGen ?? false, - parameters, + parameters: mergedParameters, totpIdentifier, totpUrl, workflowPermanentId, @@ -480,9 +507,25 @@ function NodeHeader({ }); const handleOnPlay = () => { - const numBlocksInWorkflow = (workflow?.workflow_definition.blocks ?? []) - .length; + const blocks = workflow?.workflow_definition?.blocks ?? []; + const numBlocksInWorkflow = blocks.length; + // Get workflow parameters using type guard for proper type narrowing + const workflowParameters = ( + workflow?.workflow_definition?.parameters ?? [] + ).filter(isWorkflowParameter); + + // If there are any workflow parameters, always prompt the user + // The backend requires all params to be specified for each run + if (workflowParameters.length > 0) { + const currentValues = getInitialValues(location, workflowParameters); + setCurrentParamValues(currentValues); + setParametersToPrompt(workflowParameters); + setShowParamsDialog(true); + return; + } + + // No parameters, run directly runBlock.mutate({ codeGen: numBlocksInWorkflow === 1 }); }; @@ -636,6 +679,33 @@ function NodeHeader({ )} + + { + const numBlocksInWorkflow = ( + workflow?.workflow_definition.blocks ?? [] + ).length; + runBlock.mutate( + { + codeGen: numBlocksInWorkflow === 1, + parameterOverrides: values, + }, + { + onSuccess: () => { + // Close dialog on success - navigation also happens in mutation's onSuccess + setShowParamsDialog(false); + }, + // On error, dialog stays open so user can retry. Toast is shown by mutation's onError. + }, + ); + }} + isLoading={runBlock.isPending} + /> ); } diff --git a/skyvern-frontend/src/routes/workflows/hooks/useDebugSessionQuery.ts b/skyvern-frontend/src/routes/workflows/hooks/useDebugSessionQuery.ts index 6d59df11..00cbe478 100644 --- a/skyvern-frontend/src/routes/workflows/hooks/useDebugSessionQuery.ts +++ b/skyvern-frontend/src/routes/workflows/hooks/useDebugSessionQuery.ts @@ -23,6 +23,19 @@ function useDebugSessionQuery({ workflowPermanentId, enabled }: Opts) { enabled !== undefined ? enabled && !!workflowPermanentId : !!workflowPermanentId, + // Reduce polling frequency on errors + retry: 3, + retryDelay: 10000, + refetchOnWindowFocus: false, + // Don't keep retrying if in error state + refetchInterval: (query) => { + // If query is in error state, poll much less frequently (30s) + // Otherwise don't auto-refetch + if (query.state.status === "error") { + return 30000; + } + return false; + }, }); } diff --git a/skyvern/forge/sdk/services/org_auth_service.py b/skyvern/forge/sdk/services/org_auth_service.py index 57b9e948..5118d9a4 100644 --- a/skyvern/forge/sdk/services/org_auth_service.py +++ b/skyvern/forge/sdk/services/org_auth_service.py @@ -130,9 +130,11 @@ async def get_current_user_id( x_api_key: Annotated[str | None, Header(include_in_schema=False)] = None, x_user_agent: Annotated[str | None, Header(include_in_schema=False)] = None, ) -> str: - if authorization: + # Try authorization header first, but only if the authentication function is configured + if authorization and app.authenticate_user_function: return await _authenticate_user_helper(authorization) + # Fall back to API key + skyvern-ui user agent if x_api_key and x_user_agent == "skyvern-ui": organization = await _get_current_org_cached(x_api_key, app.DATABASE) if organization: