diff --git a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx index 84c31996..c9ef9a89 100644 --- a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx @@ -31,7 +31,12 @@ import { import { WorkflowHeader } from "./WorkflowHeader"; import { WorkflowParametersStateContext } from "./WorkflowParametersStateContext"; import { edgeTypes } from "./edges"; -import { AppNode, nodeTypes, WorkflowBlockNode } from "./nodes"; +import { + AppNode, + isWorkflowBlockNode, + nodeTypes, + WorkflowBlockNode, +} from "./nodes"; import { WorkflowNodeLibraryPanel } from "./panels/WorkflowNodeLibraryPanel"; import { WorkflowParametersPanel } from "./panels/WorkflowParametersPanel"; import "./reactFlowOverrideStyles.css"; @@ -68,6 +73,7 @@ import { ReloadIcon } from "@radix-ui/react-icons"; import { isLoopNode, LoopNode } from "./nodes/LoopNode/types"; import { isTaskNode } from "./nodes/TaskNode/types"; import { useShouldNotifyWhenClosingTab } from "@/hooks/useShouldNotifyWhenClosingTab"; +import { isValidationNode } from "./nodes/ValidationNode/types"; function convertToParametersYAML( parameters: ParametersState, @@ -452,6 +458,14 @@ function FlowRenderer({ function getWorkflowErrors(): Array { const errors: Array = []; + const workflowBlockNodes = nodes.filter(isWorkflowBlockNode); + if (workflowBlockNodes[0]!.type === "validation") { + const label = workflowBlockNodes[0]!.data.label; + errors.push( + `${label}: Validation block can't be the first block in a workflow`, + ); + } + // check loop node parameters const loopNodes: Array = nodes.filter(isLoopNode); const emptyLoopNodes = loopNodes.filter( @@ -480,6 +494,23 @@ function FlowRenderer({ } }); + const validationNodes = nodes.filter(isValidationNode); + validationNodes.forEach((node) => { + try { + JSON.parse(node.data.errorCodeMapping); + } catch { + errors.push(`${node.data.label}: Error messages is not valid JSON`); + } + if ( + node.data.completeCriterion.length === 0 && + node.data.terminateCriterion.length === 0 + ) { + errors.push( + `${node.data.label}: At least one of completion or termination criteria must be provided`, + ); + } + }); + return errors; } diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/CodeBlockNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/CodeBlockNode/types.ts index b982c07d..4892a69f 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/CodeBlockNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/CodeBlockNode/types.ts @@ -3,7 +3,6 @@ import { NodeBaseData } from "../types"; export type CodeBlockNodeData = NodeBaseData & { code: string; - editable: boolean; }; export type CodeBlockNode = Node; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/DownloadNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/DownloadNode/types.ts index cb00a532..191b3623 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/DownloadNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/DownloadNode/types.ts @@ -4,7 +4,6 @@ import { NodeBaseData } from "../types"; export type DownloadNodeData = NodeBaseData & { url: string; - editable: boolean; }; export type DownloadNode = Node; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/FileParserNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/FileParserNode/types.ts index b2892345..2f1513b3 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/FileParserNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/FileParserNode/types.ts @@ -3,7 +3,6 @@ import { NodeBaseData } from "../types"; export type FileParserNodeData = NodeBaseData & { fileUrl: string; - editable: boolean; }; export type FileParserNode = Node; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/LoopNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/LoopNode/types.ts index a6ac3a70..4254914a 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/LoopNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/LoopNode/types.ts @@ -3,7 +3,6 @@ import { NodeBaseData } from "../types"; export type LoopNodeData = NodeBaseData & { loopValue: string; - editable: boolean; }; export type LoopNode = Node; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/SendEmailNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/SendEmailNode/types.ts index a8e7806d..79920bba 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/SendEmailNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/SendEmailNode/types.ts @@ -14,7 +14,6 @@ export type SendEmailNodeData = NodeBaseData & { subject: string; body: string; fileAttachments: string; - editable: boolean; sender: string; smtpHostSecretParameterKey?: string; smtpPortSecretParameterKey?: string; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNode.tsx index b45debd5..9f13d5d4 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNode.tsx @@ -28,14 +28,9 @@ import { getAvailableOutputParameterKeys } from "../../workflowEditorUtils"; import { EditableNodeTitle } from "../components/EditableNodeTitle"; import { NodeActionMenu } from "../NodeActionMenu"; import { ParametersMultiSelect } from "./ParametersMultiSelect"; -import { - dataSchemaExampleValue, - errorMappingExampleValue, - fieldPlaceholders, - helpTooltipContent, - type TaskNode, -} from "./types"; +import { fieldPlaceholders, helpTooltipContent, type TaskNode } from "./types"; import { Separator } from "@/components/ui/separator"; +import { dataSchemaExampleValue, errorMappingExampleValue } from "../types"; function TaskNode({ id, data }: NodeProps) { const { updateNodeData } = useReactFlow(); diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/types.ts index a733fc30..6e41cc69 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/types.ts @@ -11,7 +11,6 @@ export type TaskNodeData = NodeBaseData & { maxStepsOverride: number | null; allowDownloads: boolean; downloadSuffix: string | null; - editable: boolean; parameterKeys: Array; totpVerificationUrl: string | null; totpIdentifier: string | null; @@ -88,14 +87,3 @@ export const fieldPlaceholders = { totpVerificationUrl: "Provide your 2FA endpoint", totpIdentifier: "Add an ID that links your TOTP to the task", }; - -export const errorMappingExampleValue = { - sample_invalid_credentials: "if the credentials are incorrect, terminate", -}; - -export const dataSchemaExampleValue = { - type: "object", - properties: { - sample: { type: "string" }, - }, -}; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/TextPromptNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/TextPromptNode/types.ts index 0d1325b7..f0eeb746 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/TextPromptNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/TextPromptNode/types.ts @@ -4,8 +4,6 @@ import { NodeBaseData } from "../types"; export type TextPromptNodeData = NodeBaseData & { prompt: string; jsonSchema: string; - editable: boolean; - label: string; parameterKeys: Array; }; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/ValidationNode/ValidationNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/ValidationNode/ValidationNode.tsx new file mode 100644 index 00000000..6cf2fced --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/ValidationNode/ValidationNode.tsx @@ -0,0 +1,191 @@ +import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react"; +import { helpTooltipContent, type ValidationNode } from "./types"; +import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler"; +import { useState } from "react"; +import { CheckCircledIcon } from "@radix-ui/react-icons"; +import { EditableNodeTitle } from "../components/EditableNodeTitle"; +import { NodeActionMenu } from "../NodeActionMenu"; +import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback"; +import { Label } from "@/components/ui/label"; +import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea"; +import { HelpTooltip } from "@/components/HelpTooltip"; +import { Checkbox } from "@/components/ui/checkbox"; +import { errorMappingExampleValue } from "../types"; +import { CodeEditor } from "@/routes/workflows/components/CodeEditor"; +import { Switch } from "@/components/ui/switch"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Separator } from "@/components/ui/separator"; + +function ValidationNode({ id, data }: NodeProps) { + const { updateNodeData } = useReactFlow(); + const { editable } = data; + const [label, setLabel] = useNodeLabelChangeHandler({ + id, + initialValue: data.label, + }); + const [inputs, setInputs] = useState({ + completeCriterion: data.completeCriterion, + terminateCriterion: data.terminateCriterion, + errorCodeMapping: data.errorCodeMapping, + }); + const deleteNodeCallback = useDeleteNodeCallback(); + + function handleChange(key: string, value: unknown) { + if (!editable) { + return; + } + setInputs({ ...inputs, [key]: value }); + updateNodeData(id, { [key]: value }); + } + + return ( +
+ + +
+
+
+
+ +
+
+ + Validation Block +
+
+ { + deleteNodeCallback(id); + }} + /> +
+
+ + { + if (!editable) { + return; + } + handleChange("completeCriterion", event.target.value); + }} + value={inputs.completeCriterion} + className="nopan text-xs" + /> +
+
+ + { + if (!editable) { + return; + } + handleChange("terminateCriterion", event.target.value); + }} + value={inputs.terminateCriterion} + className="nopan text-xs" + /> +
+ + + + + Advanced Settings + + +
+
+
+
+ + +
+ { + if (!editable) { + return; + } + handleChange( + "errorCodeMapping", + checked + ? JSON.stringify(errorMappingExampleValue, null, 2) + : "null", + ); + }} + /> +
+ {inputs.errorCodeMapping !== "null" && ( +
+ { + if (!editable) { + return; + } + handleChange("errorCodeMapping", value); + }} + className="nowheel nopan" + fontSize={8} + /> +
+ )} +
+ +
+
+ + +
+
+ { + if (!editable) { + return; + } + updateNodeData(id, { continueOnFailure: checked }); + }} + /> +
+
+
+
+
+
+
+
+ ); +} + +export { ValidationNode }; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/ValidationNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/ValidationNode/types.ts new file mode 100644 index 00000000..ed2868dd --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/ValidationNode/types.ts @@ -0,0 +1,32 @@ +import type { Node } from "@xyflow/react"; +import { NodeBaseData } from "../types"; + +export type ValidationNodeData = NodeBaseData & { + completeCriterion: string; + terminateCriterion: string; + errorCodeMapping: string; + parameterKeys: Array; +}; + +export type ValidationNode = Node; + +export const validationNodeDefaultData: ValidationNodeData = { + label: "", + completeCriterion: "", + terminateCriterion: "", + errorCodeMapping: "null", + continueOnFailure: false, + editable: true, + parameterKeys: [], +}; + +export const helpTooltipContent = { + errorCodeMapping: + "Knowing about why a block terminated can be important, specify error messages here.", + continueOnFailure: + "Allow the workflow to continue if it encounters a failure.", +} as const; + +export function isValidationNode(node: Node): node is ValidationNode { + return node.type === "validation"; +} diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts index 2898ae3d..8ffe6b9f 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts @@ -19,6 +19,8 @@ import type { NodeAdderNode } from "./NodeAdderNode/types"; import { NodeAdderNode as NodeAdderNodeComponent } from "./NodeAdderNode/NodeAdderNode"; import { StartNode as StartNodeComponent } from "./StartNode/StartNode"; import type { StartNode } from "./StartNode/types"; +import type { ValidationNode } from "./ValidationNode/types"; +import { ValidationNode as ValidationNodeComponent } from "./ValidationNode/ValidationNode"; export type UtilityNode = StartNode | NodeAdderNode; @@ -30,7 +32,8 @@ export type WorkflowBlockNode = | CodeBlockNode | FileParserNode | UploadNode - | DownloadNode; + | DownloadNode + | ValidationNode; export function isUtilityNode(node: AppNode): node is UtilityNode { return node.type === "nodeAdder" || node.type === "start"; @@ -53,4 +56,5 @@ export const nodeTypes = { download: memo(DownloadNodeComponent), nodeAdder: memo(NodeAdderNodeComponent), start: memo(StartNodeComponent), + validation: memo(ValidationNodeComponent), }; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts index f9e11aae..d4c66bfe 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts @@ -1,4 +1,16 @@ export type NodeBaseData = { label: string; continueOnFailure: boolean; + editable: boolean; }; + +export const errorMappingExampleValue = { + sample_invalid_credentials: "if the credentials are incorrect, terminate", +} as const; + +export const dataSchemaExampleValue = { + type: "object", + properties: { + sample: { type: "string" }, + }, +} as const; diff --git a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx index 0a6d18aa..43056e3d 100644 --- a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx @@ -1,5 +1,6 @@ import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore"; import { + CheckCircledIcon, Cross2Icon, CursorTextIcon, EnvelopeClosedIcon, @@ -20,54 +21,60 @@ const nodeLibraryItems: Array<{ }> = [ { nodeType: "task", - icon: , + icon: , title: "Task Block", description: "Takes actions or extracts information", }, { nodeType: "loop", - icon: , + icon: , title: "For Loop Block", description: "Repeats nested elements", }, { nodeType: "textPrompt", - icon: , + icon: , title: "Text Prompt Block", description: "Generates AI response", }, { nodeType: "sendEmail", - icon: , + icon: , title: "Send Email Block", description: "Sends an email", }, // temporarily removed // { // nodeType: "codeBlock", - // icon: , + // icon: , // title: "Code Block", // description: "Executes Python code", // }, { nodeType: "fileParser", - icon: , + icon: , title: "File Parser Block", description: "Downloads and parses a file", }, // disabled // { // nodeType: "download", - // icon: , + // icon: , // title: "Download Block", // description: "Downloads a file from S3", // }, { nodeType: "upload", - icon: , + icon: , title: "Upload Block", description: "Uploads a file to S3", }, + { + nodeType: "validation", + icon: , + title: "Validation Block", + description: "Validate the state of the workflow or terminate", + }, ]; type Props = { @@ -91,7 +98,7 @@ function WorkflowNodeLibraryPanel({ onNodeClick, first }: Props) {

Node Library

{!first && ( { closeWorkflowPanel(); }} @@ -139,7 +146,7 @@ function WorkflowNodeLibraryPanel({ onNodeClick, first }: Props) { - + ); })} diff --git a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts index f84327e4..cfe05fb4 100644 --- a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts +++ b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts @@ -21,6 +21,7 @@ import { TaskBlockYAML, TextPromptBlockYAML, UploadToS3BlockYAML, + ValidationBlockYAML, WorkflowCreateYAMLRequest, } from "../types/workflowYamlTypes"; import { @@ -48,6 +49,7 @@ import { taskNodeDefaultData } from "./nodes/TaskNode/types"; import { textPromptNodeDefaultData } from "./nodes/TextPromptNode/types"; import { NodeBaseData } from "./nodes/types"; import { uploadNodeDefaultData } from "./nodes/UploadNode/types"; +import { validationNodeDefaultData } from "./nodes/ValidationNode/types"; export const NEW_NODE_LABEL_PREFIX = "block_"; @@ -133,6 +135,7 @@ function convertToNode( const commonData: NodeBaseData = { label: block.label, continueOnFailure: block.continue_on_failure, + editable: true, }; switch (block.block_type) { case "task": { @@ -142,7 +145,6 @@ function convertToNode( type: "task", data: { ...commonData, - editable: true, url: block.url ?? "", navigationGoal: block.navigation_goal ?? "", dataExtractionGoal: block.data_extraction_goal ?? "", @@ -159,6 +161,20 @@ function convertToNode( }, }; } + case "validation": { + return { + ...identifiers, + ...common, + type: "validation", + data: { + ...commonData, + errorCodeMapping: JSON.stringify(block.error_code_mapping, null, 2), + completeCriterion: block.complete_criterion ?? "", + terminateCriterion: block.terminate_criterion ?? "", + parameterKeys: block.parameters.map((p) => p.key), + }, + }; + } case "code": { return { ...identifiers, @@ -166,7 +182,6 @@ function convertToNode( type: "codeBlock", data: { ...commonData, - editable: true, code: block.code, }, }; @@ -178,7 +193,6 @@ function convertToNode( type: "sendEmail", data: { ...commonData, - editable: true, body: block.body, fileAttachments: block.file_attachments.join(", "), recipients: block.recipients.join(", "), @@ -198,7 +212,6 @@ function convertToNode( type: "textPrompt", data: { ...commonData, - editable: true, prompt: block.prompt, jsonSchema: JSON.stringify(block.json_schema, null, 2), parameterKeys: block.parameters.map((p) => p.key), @@ -212,7 +225,6 @@ function convertToNode( type: "loop", data: { ...commonData, - editable: true, loopValue: block.loop_over.key, }, }; @@ -224,7 +236,6 @@ function convertToNode( type: "fileParser", data: { ...commonData, - editable: true, fileUrl: block.file_url, }, }; @@ -237,7 +248,6 @@ function convertToNode( type: "download", data: { ...commonData, - editable: true, url: block.url, }, }; @@ -250,7 +260,6 @@ function convertToNode( type: "upload", data: { ...commonData, - editable: true, path: block.path, }, }; @@ -456,6 +465,17 @@ function createNode( }, }; } + case "validation": { + return { + ...identifiers, + ...common, + type: "validation", + data: { + ...validationNodeDefaultData, + label, + }, + }; + } case "loop": { return { ...identifiers, @@ -575,6 +595,19 @@ function getWorkflowBlock(node: WorkflowBlockNode): BlockYAML { cache_actions: node.data.cacheActions, }; } + case "validation": { + return { + ...base, + block_type: "validation", + complete_criterion: node.data.completeCriterion, + terminate_criterion: node.data.terminateCriterion, + parameter_keys: node.data.parameterKeys, + error_code_mapping: JSONParseSafe(node.data.errorCodeMapping) as Record< + string, + string + > | null, + }; + } case "sendEmail": { return { ...base, @@ -1045,6 +1078,17 @@ function convertBlocks(blocks: Array): Array { }; return blockYaml; } + case "validation": { + const blockYaml: ValidationBlockYAML = { + ...base, + block_type: "validation", + complete_criterion: block.complete_criterion, + terminate_criterion: block.terminate_criterion, + error_code_mapping: block.error_code_mapping, + parameter_keys: block.parameters.map((p) => p.key), + }; + return blockYaml; + } case "for_loop": { const blockYaml: ForLoopBlockYAML = { ...base, diff --git a/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts index 77cb493a..d20345fe 100644 --- a/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts +++ b/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts @@ -189,6 +189,14 @@ export type FileURLParserBlock = WorkflowBlockBase & { file_type: "csv"; }; +export type ValidationBlock = WorkflowBlockBase & { + block_type: "validation"; + complete_criterion: string | null; + terminate_criterion: string | null; + error_code_mapping: Record | null; + parameters: Array; +}; + export type WorkflowBlock = | TaskBlock | ForLoopBlock @@ -197,7 +205,8 @@ export type WorkflowBlock = | UploadToS3Block | DownloadToS3Block | SendEmailBlock - | FileURLParserBlock; + | FileURLParserBlock + | ValidationBlock; export type WorkflowDefinition = { parameters: Array; diff --git a/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts index be4fa884..7940c7c9 100644 --- a/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts +++ b/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts @@ -75,6 +75,7 @@ const BlockTypes = { UPLOAD_TO_S3: "upload_to_s3", SEND_EMAIL: "send_email", FILE_URL_PARSER: "file_url_parser", + VALIDATION: "validation", } as const; export type BlockType = (typeof BlockTypes)[keyof typeof BlockTypes]; @@ -87,7 +88,8 @@ export type BlockYAML = | UploadToS3BlockYAML | SendEmailBlockYAML | FileUrlParserBlockYAML - | ForLoopBlockYAML; + | ForLoopBlockYAML + | ValidationBlockYAML; export type BlockYAMLBase = { block_type: BlockType; @@ -113,6 +115,14 @@ export type TaskBlockYAML = BlockYAMLBase & { cache_actions: boolean; }; +export type ValidationBlockYAML = BlockYAMLBase & { + block_type: "validation"; + complete_criterion: string | null; + terminate_criterion: string | null; + error_code_mapping: Record | null; + parameter_keys?: Array | null; +}; + export type CodeBlockYAML = BlockYAMLBase & { block_type: "code"; code: string;