diff --git a/skyvern-frontend/src/routes/workflows/WorkflowRun.tsx b/skyvern-frontend/src/routes/workflows/WorkflowRun.tsx index 83f09e44..070afb76 100644 --- a/skyvern-frontend/src/routes/workflows/WorkflowRun.tsx +++ b/skyvern-frontend/src/routes/workflows/WorkflowRun.tsx @@ -38,6 +38,7 @@ import { useWorkflowRunWithWorkflowQuery } from "./hooks/useWorkflowRunWithWorkf import { WorkflowRunTimeline } from "./workflowRun/WorkflowRunTimeline"; import { useWorkflowRunTimelineQuery } from "./hooks/useWorkflowRunTimelineQuery"; import { findActiveItem } from "./workflowRun/workflowTimelineUtils"; +import { isBlockItem } from "./types/workflowRunTypes"; import { Label } from "@/components/ui/label"; import { CodeEditor } from "./components/CodeEditor"; import { cn } from "@/util/utils"; @@ -146,10 +147,13 @@ function WorkflowRun() { workflowRun && statusIsCancellable(workflowRun); const workflowRunIsFinalized = workflowRun && statusIsFinalized(workflowRun); + const finallyBlockLabel = + workflow?.workflow_definition?.finally_block_label ?? null; const selection = findActiveItem( workflowRunTimeline ?? [], active, !!workflowRunIsFinalized, + finallyBlockLabel, ); const parameters = workflowRun?.parameters ?? {}; const proxyLocation = @@ -194,6 +198,23 @@ function WorkflowRun() { ? "Termination Reason" : "Failure Reason"; + const finallyBlockInTimeline = finallyBlockLabel + ? workflowRunTimeline?.find( + (item) => isBlockItem(item) && item.block.label === finallyBlockLabel, + ) + : null; + + const finallyBlockStatus = + finallyBlockInTimeline && isBlockItem(finallyBlockInTimeline) + ? finallyBlockInTimeline.block.status + : null; + + const shouldShowFinallyNote = + (workflowRun?.status === Status.Terminated || + workflowRun?.status === Status.Failed) && + finallyBlockLabel && + finallyBlockInTimeline; + const workflowFailureReason = workflowRun?.failure_reason ? (
{failureReasonTitle}
{workflowRun.failure_reason}
{matchedTips} + {shouldShowFinallyNote && ( +
+ Note: + + "Execute on any outcome" block ({finallyBlockLabel}){" "} + {finallyBlockStatus === Status.Completed + ? "completed successfully" + : finallyBlockStatus === Status.Failed + ? "failed" + : "ran"} + . + +
+ )} ) : null; diff --git a/skyvern-frontend/src/routes/workflows/components/WorkflowVisualComparisonDrawer.tsx b/skyvern-frontend/src/routes/workflows/components/WorkflowVisualComparisonDrawer.tsx index b723808e..f9fd7aca 100644 --- a/skyvern-frontend/src/routes/workflows/components/WorkflowVisualComparisonDrawer.tsx +++ b/skyvern-frontend/src/routes/workflows/components/WorkflowVisualComparisonDrawer.tsx @@ -129,6 +129,7 @@ function getWorkflowElements(version: WorkflowVersion) { aiFallback: version.ai_fallback ?? true, runSequentially: version.run_sequentially ?? false, sequentialKey: version.sequential_key ?? null, + finallyBlockLabel: version.workflow_definition?.finally_block_label ?? null, }; return getElements( diff --git a/skyvern-frontend/src/routes/workflows/debugger/Debugger.tsx b/skyvern-frontend/src/routes/workflows/debugger/Debugger.tsx index 602844a8..cfb5b716 100644 --- a/skyvern-frontend/src/routes/workflows/debugger/Debugger.tsx +++ b/skyvern-frontend/src/routes/workflows/debugger/Debugger.tsx @@ -76,6 +76,8 @@ function Debugger() { aiFallback: workflow.ai_fallback ?? true, runSequentially: workflow.run_sequentially ?? false, sequentialKey: workflow.sequential_key ?? null, + finallyBlockLabel: + workflow.workflow_definition?.finally_block_label ?? null, }; const elements = getElements(blocksToRender, settings, true); diff --git a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx index cf5ba0b6..b4492bb6 100644 --- a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx @@ -529,6 +529,20 @@ function FlowRenderer({ }, }; } + // Clear finallyBlockLabel if the deleted block was the finally block + if ( + node.type === "start" && + node.data.withWorkflowSettings && + node.data.finallyBlockLabel === deletedNodeLabel + ) { + return { + ...node, + data: { + ...node.data, + finallyBlockLabel: null, + }, + }; + } return node; }); workflowChangesStore.setHasChanges(true); diff --git a/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx b/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx index 5db8b618..74db4448 100644 --- a/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx @@ -77,6 +77,8 @@ function WorkflowEditor() { aiFallback: workflow.ai_fallback ?? true, runSequentially: workflow.run_sequentially ?? false, sequentialKey: workflow.sequential_key ?? null, + finallyBlockLabel: + workflow.workflow_definition?.finally_block_label ?? null, }; const elements = getElements(blocksToRender, settings, !isGlobalWorkflow); diff --git a/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx b/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx index 6f361410..60af0c0c 100644 --- a/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx @@ -978,6 +978,8 @@ function Workspace({ aiFallback: selectedVersion.ai_fallback ?? true, runSequentially: selectedVersion.run_sequentially ?? false, sequentialKey: selectedVersion.sequential_key ?? null, + finallyBlockLabel: + selectedVersion.workflow_definition?.finally_block_label ?? null, }; const elements = getElements( @@ -1683,6 +1685,8 @@ function Workspace({ aiFallback: parsedYaml.ai_fallback ?? true, runSequentially: parsedYaml.run_sequentially ?? false, sequentialKey: parsedYaml.sequential_key ?? null, + finallyBlockLabel: + parsedYaml.workflow_definition?.finally_block_label ?? null, }; // Convert YAML blocks to internal format diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/NodeAdderNode/NodeAdderNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/NodeAdderNode/NodeAdderNode.tsx index 65b2f97c..611b28e4 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/NodeAdderNode/NodeAdderNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/NodeAdderNode/NodeAdderNode.tsx @@ -7,6 +7,7 @@ import { BranchContext, useWorkflowPanelStore, } from "@/store/WorkflowPanelStore"; +import { useWorkflowSettingsStore } from "@/store/WorkflowSettingsStore"; import type { NodeBaseData } from "../types"; import { useRecordedBlocksStore } from "@/store/RecordedBlocksStore"; import { useRecordingStore } from "@/store/useRecordingStore"; @@ -23,6 +24,7 @@ function NodeAdderNode({ id, parentId }: NodeProps) { const debugStore = useDebugStore(); const recordingStore = useRecordingStore(); const settingsStore = useSettingsStore(); + const workflowSettingsStore = useWorkflowSettingsStore(); const setWorkflowPanelState = useWorkflowPanelStore( (state) => state.setWorkflowPanelState, ); @@ -132,7 +134,10 @@ function NodeAdderNode({ id, parentId }: NodeProps) { workflowStatePanel.workflowPanelState.data?.parent === (parentId || undefined); - const isDisabled = !isBusy && recordingStore.isRecording; + const isBlockedByFinally = + !parentId && Boolean(workflowSettingsStore.finallyBlockLabel); + const isDisabled = + isBlockedByFinally || (!isBusy && recordingStore.isRecording); const updateWorkflowPanelState = ( active: boolean, diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/StartNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/StartNode.tsx index c42227ca..1d4dcb39 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/StartNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/StartNode.tsx @@ -1,5 +1,14 @@ -import { Handle, Node, NodeProps, Position, useReactFlow } from "@xyflow/react"; +import { + Handle, + Node, + NodeProps, + Position, + useEdges, + useNodes, + useReactFlow, +} from "@xyflow/react"; import type { StartNode } from "./types"; +import { AppNode } from ".."; import { Accordion, AccordionContent, @@ -13,7 +22,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { ProxyLocation } from "@/api/types"; import { Label } from "@/components/ui/label"; import { HelpTooltip } from "@/components/HelpTooltip"; @@ -42,6 +51,7 @@ import { useUpdate } from "@/routes/workflows/editor/useUpdate"; import { cn } from "@/util/utils"; import { Button } from "@/components/ui/button"; import { TestWebhookDialog } from "@/components/TestWebhookDialog"; +import { getWorkflowBlocks } from "../../workflowEditorUtils"; interface StartSettings { webhookCallbackUrl: string; @@ -50,11 +60,14 @@ interface StartSettings { model: WorkflowModel | null; maxScreenshotScrollingTimes: number | null; extraHttpHeaders: string | Record | null; + finallyBlockLabel: string | null; } function StartNode({ id, data, parentId }: NodeProps) { const workflowSettingsStore = useWorkflowSettingsStore(); const reactFlowInstance = useReactFlow(); + const nodes = useNodes(); + const edges = useEdges(); const [facing, setFacing] = useState<"front" | "back">("front"); const blockScriptStore = useBlockScriptStore(); const recordingStore = useRecordingStore(); @@ -66,6 +79,20 @@ function StartNode({ id, data, parentId }: NodeProps) { const parentNode = parentId ? reactFlowInstance.getNode(parentId) : null; const isInsideConditional = parentNode?.type === "conditional"; const isInsideLoop = parentNode?.type === "loop"; + const withWorkflowSettings = data.withWorkflowSettings; + const finallyBlockLabel = withWorkflowSettings + ? data.finallyBlockLabel + : null; + + // Only allow terminal blocks (next_block_label === null) for the finally block dropdown. + const terminalBlockLabels = useMemo(() => { + return getWorkflowBlocks(nodes, edges) + .filter((block) => (block.next_block_label ?? null) === null) + .map((block) => block.label); + }, [nodes, edges]); + const terminalBlockLabelSet = useMemo(() => { + return new Set(terminalBlockLabels); + }, [terminalBlockLabels]); const makeStartSettings = (data: StartNode["data"]): StartSettings => { return { @@ -85,6 +112,9 @@ function StartNode({ id, data, parentId }: NodeProps) { extraHttpHeaders: data.withWorkflowSettings ? data.extraHttpHeaders : null, + finallyBlockLabel: data.withWorkflowSettings + ? data.finallyBlockLabel + : null, }; }; @@ -99,6 +129,16 @@ function StartNode({ id, data, parentId }: NodeProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [data]); + useEffect(() => { + if ( + withWorkflowSettings && + finallyBlockLabel && + !terminalBlockLabelSet.has(finallyBlockLabel) + ) { + update({ finallyBlockLabel: null }); + } + }, [finallyBlockLabel, withWorkflowSettings, terminalBlockLabelSet, update]); + function nodeIsFlippable(node: Node) { return ( scriptableWorkflowBlockTypes.has(node.type as WorkflowBlockType) || @@ -381,6 +421,33 @@ function StartNode({ id, data, parentId }: NodeProps) { }} /> +
+
+ + +
+ +
diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/types.ts index a29fda40..d4e234bc 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/types.ts @@ -17,6 +17,7 @@ export type WorkflowStartNodeData = { aiFallback: boolean; runSequentially: boolean; sequentialKey: string | null; + finallyBlockLabel: string | null; label: "__start_block__"; showCode: boolean; }; 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 33656a34..627bc5ec 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx @@ -533,35 +533,42 @@ function NodeHeader({ inputClassName="text-base" /> - {transmutations && transmutations.others.length ? ( -
- - {transmutations.blockTitle} +
+ {transmutations && transmutations.others.length ? ( +
+ + {transmutations.blockTitle} + + + t.label), + ]} + selected={transmutations.self} + onChange={(label) => { + const transmutation = transmutations.others.find( + (t) => t.label === label, + ); + + if (!transmutation) { + return; + } + + transmuteNodeCallback(nodeId, transmutation.nodeName); + }} + /> + +
+ ) : ( + {blockTitle} + )} + {workflowSettingsStore.finallyBlockLabel === blockLabel && ( + + Runs on any outcome - - t.label), - ]} - selected={transmutations.self} - onChange={(label) => { - const transmutation = transmutations.others.find( - (t) => t.label === label, - ); - - if (!transmutation) { - return; - } - - transmuteNodeCallback(nodeId, transmutation.nodeName); - }} - /> - -
- ) : ( - {blockTitle} - )} + )} +
diff --git a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowComparisonPanel.tsx b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowComparisonPanel.tsx index 82aaaaf7..6675f812 100644 --- a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowComparisonPanel.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowComparisonPanel.tsx @@ -154,6 +154,7 @@ function getWorkflowElements(version: WorkflowVersion) { aiFallback: version.ai_fallback ?? true, runSequentially: version.run_sequentially ?? false, sequentialKey: version.sequential_key ?? null, + finallyBlockLabel: version.workflow_definition?.finally_block_label ?? null, }; // Deep clone the blocks to ensure complete isolation from main editor diff --git a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts index a9ea8c5f..306fbc04 100644 --- a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts +++ b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts @@ -1373,6 +1373,7 @@ function getElements( showCode: false, runSequentially: settings.runSequentially, sequentialKey: settings.sequentialKey, + finallyBlockLabel: settings.finallyBlockLabel ?? null, }), ); @@ -2541,6 +2542,7 @@ function getWorkflowSettings(nodes: Array): WorkflowSettings { aiFallback: true, runSequentially: false, sequentialKey: null, + finallyBlockLabel: null, }; const startNodes = nodes.filter(isStartNode); const startNodeWithWorkflowSettings = startNodes.find( @@ -2566,6 +2568,7 @@ function getWorkflowSettings(nodes: Array): WorkflowSettings { aiFallback: data.aiFallback, runSequentially: data.runSequentially, sequentialKey: data.sequentialKey, + finallyBlockLabel: data.finallyBlockLabel ?? null, }; } return defaultSettings; @@ -3358,6 +3361,7 @@ function convert(workflow: WorkflowApiResponse): WorkflowCreateYAMLRequest { version: workflowDefinitionVersion, parameters: convertParametersToParameterYAML(userParameters), blocks: convertBlocksToBlockYAML(workflow.workflow_definition.blocks), + finally_block_label: workflow.workflow_definition.finally_block_label, }, is_saved_task: workflow.is_saved_task, status: workflow.status, diff --git a/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts index c09a95e8..24b6faa5 100644 --- a/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts +++ b/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts @@ -558,6 +558,7 @@ export type WorkflowDefinition = { version?: number | null; parameters: Array; blocks: Array; + finally_block_label?: string | null; }; export type WorkflowApiResponse = { @@ -603,6 +604,7 @@ export type WorkflowSettings = { aiFallback: boolean | null; runSequentially: boolean; sequentialKey: string | null; + finallyBlockLabel: string | null; }; export type WorkflowModel = JsonObjectExtendable<{ model_name: string }>; diff --git a/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts index ebdda790..7b680c81 100644 --- a/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts +++ b/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts @@ -27,6 +27,7 @@ export type WorkflowDefinitionYAML = { version?: number | null; parameters: Array; blocks: Array; + finally_block_label?: string | null; }; export type ParameterYAML = diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOverview.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOverview.tsx index b3f479ab..20fea86c 100644 --- a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOverview.tsx +++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOverview.tsx @@ -77,10 +77,13 @@ function WorkflowRunOverview() { } const workflowRunIsFinalized = statusIsFinalized(workflowRun); + const finallyBlockLabel = + workflowRun.workflow?.workflow_definition?.finally_block_label ?? null; const selection = findActiveItem( workflowRunTimeline, active, workflowRunIsFinalized, + finallyBlockLabel, ); const browserSessionId = workflowRun.browser_session_id; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimeline.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimeline.tsx index a179a6f9..a0d8d121 100644 --- a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimeline.tsx +++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimeline.tsx @@ -53,6 +53,9 @@ function WorkflowRunTimeline({ const workflowRunIsNotFinalized = statusIsNotFinalized(workflowRun); const workflowRunIsFinalized = statusIsFinalized(workflowRun); + const finallyBlockLabel = + workflowRun.workflow?.workflow_definition?.finally_block_label ?? null; + const numberOfActions = workflowRunTimeline.reduce((total, current) => { if (isTaskVariantBlockItem(current)) { return total + current.block!.actions!.length; @@ -104,6 +107,7 @@ function WorkflowRunTimeline({ onActionClick={onActionItemSelected} onBlockItemClick={onBlockItemSelected} onThoughtCardClick={onObserverThoughtCardSelected} + finallyBlockLabel={finallyBlockLabel} /> ); } diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.tsx index 36e4cceb..815d72d3 100644 --- a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.tsx +++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.tsx @@ -38,6 +38,7 @@ type Props = { onBlockItemClick: (block: WorkflowRunBlock) => void; onActionClick: (action: ActionItem) => void; onThoughtCardClick: (thought: ObserverThought) => void; + finallyBlockLabel?: string | null; }; function WorkflowRunTimelineBlockItem({ @@ -47,8 +48,10 @@ function WorkflowRunTimelineBlockItem({ onBlockItemClick, onActionClick, onThoughtCardClick, + finallyBlockLabel, }: Props) { const actions = block.actions ?? []; + const isFinallyBlock = finallyBlockLabel && block.label === finallyBlockLabel; const hasActiveAction = isAction(activeItem) && @@ -128,6 +131,11 @@ function WorkflowRunTimelineBlockItem({ {block.label} + {isFinallyBlock && ( + + Execute on any outcome + + )}
@@ -235,6 +243,7 @@ function WorkflowRunTimelineBlockItem({ onActionClick={onActionClick} onBlockItemClick={onBlockItemClick} onThoughtCardClick={onThoughtCardClick} + finallyBlockLabel={finallyBlockLabel} /> ); } diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/useActiveWorkflowRunItem.ts b/skyvern-frontend/src/routes/workflows/workflowRun/useActiveWorkflowRunItem.ts index c7813960..be606ed6 100644 --- a/skyvern-frontend/src/routes/workflows/workflowRun/useActiveWorkflowRunItem.ts +++ b/skyvern-frontend/src/routes/workflows/workflowRun/useActiveWorkflowRunItem.ts @@ -1,6 +1,7 @@ import { useSearchParams } from "react-router-dom"; import { useWorkflowRunQuery } from "../hooks/useWorkflowRunQuery"; import { useWorkflowRunTimelineQuery } from "../hooks/useWorkflowRunTimelineQuery"; +import { useWorkflowRunWithWorkflowQuery } from "../hooks/useWorkflowRunWithWorkflowQuery"; import { statusIsFinalized } from "@/routes/tasks/types"; import { findActiveItem } from "./workflowTimelineUtils"; import { WorkflowRunOverviewActiveElement } from "./WorkflowRunOverview"; @@ -13,14 +14,19 @@ function useActiveWorkflowRunItem(): [ const active = searchParams.get("active"); const { data: workflowRun } = useWorkflowRunQuery(); + const { data: workflowRunWithWorkflow } = useWorkflowRunWithWorkflowQuery(); const { data: workflowRunTimeline } = useWorkflowRunTimelineQuery(); const workflowRunIsFinalized = workflowRun && statusIsFinalized(workflowRun); + const finallyBlockLabel = + workflowRunWithWorkflow?.workflow?.workflow_definition + ?.finally_block_label ?? null; const activeItem = findActiveItem( workflowRunTimeline ?? [], active, !!workflowRunIsFinalized, + finallyBlockLabel, ); function handleSetActiveItem(id: string) { diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/workflowTimelineUtils.ts b/skyvern-frontend/src/routes/workflows/workflowRun/workflowTimelineUtils.ts index f3569784..f2534e3b 100644 --- a/skyvern-frontend/src/routes/workflows/workflowRun/workflowTimelineUtils.ts +++ b/skyvern-frontend/src/routes/workflows/workflowRun/workflowTimelineUtils.ts @@ -31,11 +31,27 @@ function findActiveItem( timeline: Array, target: string | null, workflowRunIsFinalized: boolean, + finallyBlockLabel?: string | null, ): WorkflowRunOverviewActiveElement { if (target === null) { if (!workflowRunIsFinalized) { return "stream"; } + // If there's a finally block, try to show it first when workflow is finalized + if (finallyBlockLabel && timeline?.length > 0) { + const finallyBlock = timeline.find( + (item) => isBlockItem(item) && item.block.label === finallyBlockLabel, + ); + if (finallyBlock && isBlockItem(finallyBlock)) { + if ( + finallyBlock.block.actions && + finallyBlock.block.actions.length > 0 + ) { + return finallyBlock.block.actions[0]!; + } + return finallyBlock.block; + } + } if (timeline?.length > 0) { const timelineItem = timeline![0]; if (isBlockItem(timelineItem)) { diff --git a/skyvern-frontend/src/store/WorkflowHasChangesStore.ts b/skyvern-frontend/src/store/WorkflowHasChangesStore.ts index e8db0eef..f78fb1ce 100644 --- a/skyvern-frontend/src/store/WorkflowHasChangesStore.ts +++ b/skyvern-frontend/src/store/WorkflowHasChangesStore.ts @@ -143,6 +143,7 @@ const useWorkflowSave = (opts?: WorkflowSaveOpts) => { version: saveData.workflowDefinitionVersion, parameters: saveData.parameters, blocks: saveData.blocks, + finally_block_label: saveData.settings.finallyBlockLabel ?? undefined, }, is_saved_task: saveData.workflow.is_saved_task, status: opts?.status ?? saveData.workflow.status, diff --git a/skyvern-frontend/src/store/WorkflowSettingsStore.ts b/skyvern-frontend/src/store/WorkflowSettingsStore.ts index 05d6f301..6fe443b0 100644 --- a/skyvern-frontend/src/store/WorkflowSettingsStore.ts +++ b/skyvern-frontend/src/store/WorkflowSettingsStore.ts @@ -12,6 +12,7 @@ export interface WorkflowSettingsState { model: WorkflowModel | null; maxScreenshotScrollingTimes: number | null; extraHttpHeaders: string | Record | null; + finallyBlockLabel: string | null; setWorkflowSettings: ( settings: Partial< Omit< @@ -33,6 +34,7 @@ const defaultState: Omit< model: null, maxScreenshotScrollingTimes: null, extraHttpHeaders: null, + finallyBlockLabel: null, }; export const useWorkflowSettingsStore = create( diff --git a/skyvern/forge/sdk/workflow/exceptions.py b/skyvern/forge/sdk/workflow/exceptions.py index f02da543..2593b4a4 100644 --- a/skyvern/forge/sdk/workflow/exceptions.py +++ b/skyvern/forge/sdk/workflow/exceptions.py @@ -20,6 +20,24 @@ class WorkflowDefinitionHasDuplicateBlockLabels(BaseWorkflowHTTPException): ) +class InvalidFinallyBlockLabel(BaseWorkflowHTTPException): + def __init__(self, finally_block_label: str, available_labels: list[str]) -> None: + super().__init__( + f"finally_block_label '{finally_block_label}' does not reference a valid block in the workflow. " + f"Available block labels: {', '.join(available_labels) if available_labels else '(none)'}", + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + ) + + +class NonTerminalFinallyBlock(BaseWorkflowHTTPException): + def __init__(self, finally_block_label: str) -> None: + super().__init__( + f"finally_block_label '{finally_block_label}' must be a terminal block (next_block_label must be null). " + "Only blocks without a next_block_label can be used as finally blocks.", + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + ) + + class FailedToCreateWorkflow(BaseWorkflowHTTPException): def __init__(self, error_message: str) -> None: super().__init__( diff --git a/skyvern/forge/sdk/workflow/models/workflow.py b/skyvern/forge/sdk/workflow/models/workflow.py index 9a831250..e9d3eeb8 100644 --- a/skyvern/forge/sdk/workflow/models/workflow.py +++ b/skyvern/forge/sdk/workflow/models/workflow.py @@ -7,7 +7,11 @@ from typing_extensions import deprecated from skyvern.forge.sdk.schemas.files import FileInfo from skyvern.forge.sdk.schemas.task_v2 import TaskV2 -from skyvern.forge.sdk.workflow.exceptions import WorkflowDefinitionHasDuplicateBlockLabels +from skyvern.forge.sdk.workflow.exceptions import ( + InvalidFinallyBlockLabel, + NonTerminalFinallyBlock, + WorkflowDefinitionHasDuplicateBlockLabels, +) from skyvern.forge.sdk.workflow.models.block import BlockTypeVar from skyvern.forge.sdk.workflow.models.parameter import PARAMETER_TYPE, OutputParameter from skyvern.schemas.runs import ProxyLocationInput, ScriptRunResponse @@ -54,6 +58,7 @@ class WorkflowDefinition(BaseModel): version: int = 1 parameters: list[PARAMETER_TYPE] blocks: List[BlockTypeVar] + finally_block_label: str | None = None def validate(self) -> None: labels: set[str] = set() @@ -67,6 +72,13 @@ class WorkflowDefinition(BaseModel): if duplicate_labels: raise WorkflowDefinitionHasDuplicateBlockLabels(duplicate_labels) + if self.finally_block_label: + if self.finally_block_label not in labels: + raise InvalidFinallyBlockLabel(self.finally_block_label, list(labels)) + for block in self.blocks: + if block.label == self.finally_block_label and block.next_block_label is not None: + raise NonTerminalFinallyBlock(self.finally_block_label) + class Workflow(BaseModel): workflow_id: str diff --git a/skyvern/forge/sdk/workflow/service.py b/skyvern/forge/sdk/workflow/service.py index e67a014a..226ae2bc 100644 --- a/skyvern/forge/sdk/workflow/service.py +++ b/skyvern/forge/sdk/workflow/service.py @@ -737,33 +737,59 @@ class WorkflowService: script=workflow_script, ) + # Check if there's a finally block configured + finally_block_label = workflow.workflow_definition.finally_block_label + if refreshed_workflow_run := await app.DATABASE.get_workflow_run( workflow_run_id=workflow_run_id, organization_id=organization_id, ): workflow_run = refreshed_workflow_run - if workflow_run.status not in ( - WorkflowRunStatus.canceled, + + pre_finally_status = workflow_run.status + pre_finally_failure_reason = workflow_run.failure_reason + + if pre_finally_status not in ( + WorkflowRunStatus.canceled, + WorkflowRunStatus.failed, + WorkflowRunStatus.terminated, + WorkflowRunStatus.timed_out, + ): + await self.generate_script_if_needed( + workflow=workflow, + workflow_run=workflow_run, + block_labels=block_labels, + blocks_to_update=blocks_to_update, + ) + + # Execute finally block if configured. Skip for: canceled (user explicitly stopped) + should_run_finally = finally_block_label and pre_finally_status != WorkflowRunStatus.canceled + if should_run_finally: + # Temporarily set to running for terminal workflows (for frontend UX) + if pre_finally_status in ( WorkflowRunStatus.failed, WorkflowRunStatus.terminated, WorkflowRunStatus.timed_out, ): - workflow_run = await self.mark_workflow_run_as_completed( + workflow_run = await self._update_workflow_run_status( workflow_run_id=workflow_run_id, + status=WorkflowRunStatus.running, + failure_reason=None, ) - await self.generate_script_if_needed( - workflow=workflow, - workflow_run=workflow_run, - block_labels=block_labels, - blocks_to_update=blocks_to_update, - ) - else: - LOG.info( - "Workflow run is already timed_out, canceled, failed, or terminated, not marking as completed", - workflow_run_id=workflow_run_id, - workflow_run_status=workflow_run.status, - run_with=workflow_run.run_with, - ) + await self._execute_finally_block_if_configured( + workflow=workflow, + workflow_run=workflow_run, + organization=organization, + browser_session_id=browser_session_id, + ) + + workflow_run = await self._finalize_workflow_run_status( + workflow_run_id=workflow_run_id, + workflow_run=workflow_run, + pre_finally_status=pre_finally_status, + pre_finally_failure_reason=pre_finally_failure_reason, + ) + await self.clean_up_workflow( workflow=workflow, workflow_run=workflow_run, @@ -1340,6 +1366,46 @@ class WorkflowService: return workflow_run, False + async def _execute_finally_block_if_configured( + self, + workflow: Workflow, + workflow_run: WorkflowRun, + organization: Organization, + browser_session_id: str | None, + ) -> None: + finally_block_label = workflow.workflow_definition.finally_block_label + if not finally_block_label: + return + + label_to_block: dict[str, BlockTypeVar] = {block.label: block for block in workflow.workflow_definition.blocks} + + block = label_to_block.get(finally_block_label) + if not block: + LOG.warning( + "Finally block label not found", + workflow_run_id=workflow_run.workflow_run_id, + finally_block_label=finally_block_label, + ) + return + + try: + parameters = block.get_all_parameters(workflow_run.workflow_run_id) + await app.WORKFLOW_CONTEXT_MANAGER.register_block_parameters_for_workflow_run( + workflow_run.workflow_run_id, parameters, organization + ) + await block.execute_safe( + workflow_run_id=workflow_run.workflow_run_id, + organization_id=organization.organization_id, + browser_session_id=browser_session_id, + ) + except Exception as e: + LOG.warning( + "Finally block execution failed", + workflow_run_id=workflow_run.workflow_run_id, + block_label=block.label, + error=str(e), + ) + def _build_workflow_graph( self, blocks: list[BlockTypeVar], @@ -2131,6 +2197,35 @@ class WorkflowService: run_with=run_with, ) + async def _finalize_workflow_run_status( + self, + workflow_run_id: str, + workflow_run: WorkflowRun, + pre_finally_status: WorkflowRunStatus, + pre_finally_failure_reason: str | None, + ) -> WorkflowRun: + """ + Set final workflow run status based on pre-finally state. + Called unconditionally to ensure unified flow. + """ + if pre_finally_status not in ( + WorkflowRunStatus.canceled, + WorkflowRunStatus.failed, + WorkflowRunStatus.terminated, + WorkflowRunStatus.timed_out, + ): + return await self.mark_workflow_run_as_completed(workflow_run_id) + + if workflow_run.status == WorkflowRunStatus.running: + # We temporarily set to running for finally block, restore terminal status + return await self._update_workflow_run_status( + workflow_run_id=workflow_run_id, + status=pre_finally_status, + failure_reason=pre_finally_failure_reason, + ) + + return workflow_run + async def mark_workflow_run_as_failed( self, workflow_run_id: str, diff --git a/skyvern/forge/sdk/workflow/workflow_definition_converter.py b/skyvern/forge/sdk/workflow/workflow_definition_converter.py index 9b7b3aed..e6e2346f 100644 --- a/skyvern/forge/sdk/workflow/workflow_definition_converter.py +++ b/skyvern/forge/sdk/workflow/workflow_definition_converter.py @@ -302,7 +302,12 @@ def convert_workflow_definition( if dag_version is None: dag_version = 2 if _has_dag_metadata(workflow_definition_yaml.blocks) else 1 - workflow_definition = WorkflowDefinition(parameters=parameters.values(), blocks=blocks, version=dag_version) + workflow_definition = WorkflowDefinition( + parameters=parameters.values(), + blocks=blocks, + version=dag_version, + finally_block_label=workflow_definition_yaml.finally_block_label, + ) LOG.info( f"Created workflow from request, title: {title}", diff --git a/skyvern/schemas/workflows.py b/skyvern/schemas/workflows.py index 37367d1d..503bf0dd 100644 --- a/skyvern/schemas/workflows.py +++ b/skyvern/schemas/workflows.py @@ -591,6 +591,7 @@ class WorkflowDefinitionYAML(BaseModel): version: int = 1 parameters: list[PARAMETER_YAML_TYPES] blocks: list[BLOCK_YAML_TYPES] + finally_block_label: str | None = None @model_validator(mode="after") def validate_unique_block_labels(cls, workflow: "WorkflowDefinitionYAML") -> "WorkflowDefinitionYAML": @@ -604,6 +605,12 @@ class WorkflowDefinitionYAML(BaseModel): f"Found duplicate label(s): {', '.join(unique_duplicates)}" ) + if workflow.finally_block_label and workflow.finally_block_label not in labels: + raise ValueError( + f"finally_block_label '{workflow.finally_block_label}' does not reference a valid block. " + f"Available labels: {', '.join(labels) if labels else '(none)'}" + ) + return workflow