Add "Execute on Any Outcome" (Finally) option to blocks - Pair Team request (#4443)

This commit is contained in:
Marc Kelechava
2026-01-13 16:56:06 -08:00
committed by GitHub
parent b321402ba9
commit 40a743e5ca
26 changed files with 373 additions and 49 deletions

View File

@@ -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 ? (
<div
className="space-y-2 rounded-md border border-red-600 p-4"
@@ -204,6 +225,20 @@ function WorkflowRun() {
<div className="font-bold">{failureReasonTitle}</div>
<div className="text-sm">{workflowRun.failure_reason}</div>
{matchedTips}
{shouldShowFinallyNote && (
<div className="mt-2 flex items-center gap-2 rounded bg-amber-500/20 px-3 py-2 text-sm text-amber-200">
<span className="font-medium">Note:</span>
<span>
"Execute on any outcome" block ({finallyBlockLabel}){" "}
{finallyBlockStatus === Status.Completed
? "completed successfully"
: finallyBlockStatus === Status.Failed
? "failed"
: "ran"}
.
</span>
</div>
)}
</div>
) : null;

View File

@@ -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(

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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

View File

@@ -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<NodeAdderNode>) {
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<NodeAdderNode>) {
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,

View File

@@ -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<string, unknown> | null;
finallyBlockLabel: string | null;
}
function StartNode({ id, data, parentId }: NodeProps<StartNode>) {
const workflowSettingsStore = useWorkflowSettingsStore();
const reactFlowInstance = useReactFlow();
const nodes = useNodes<AppNode>();
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<StartNode>) {
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<StartNode>) {
extraHttpHeaders: data.withWorkflowSettings
? data.extraHttpHeaders
: null,
finallyBlockLabel: data.withWorkflowSettings
? data.finallyBlockLabel
: null,
};
};
@@ -99,6 +129,16 @@ function StartNode({ id, data, parentId }: NodeProps<StartNode>) {
// 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<StartNode>) {
}}
/>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label>Execute on Any Outcome</Label>
<HelpTooltip content="Select a block that will always run after the workflow completes, whether it succeeds, fails, or terminates early. Useful for cleanup tasks like logging out." />
</div>
<Select
value={data.finallyBlockLabel ?? "none"}
onValueChange={(value) => {
update({
finallyBlockLabel:
value === "none" ? null : value,
});
}}
>
<SelectTrigger>
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{terminalBlockLabels.map((label) => (
<SelectItem key={label} value={label}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</AccordionContent>
</AccordionItem>

View File

@@ -17,6 +17,7 @@ export type WorkflowStartNodeData = {
aiFallback: boolean;
runSequentially: boolean;
sequentialKey: string | null;
finallyBlockLabel: string | null;
label: "__start_block__";
showCode: boolean;
};

View File

@@ -533,35 +533,42 @@ function NodeHeader({
inputClassName="text-base"
/>
{transmutations && transmutations.others.length ? (
<div className="flex items-center gap-1">
<span className="text-xs text-slate-400">
{transmutations.blockTitle}
<div className="flex items-center gap-2">
{transmutations && transmutations.others.length ? (
<div className="flex items-center gap-1">
<span className="text-xs text-slate-400">
{transmutations.blockTitle}
</span>
<NoticeMe trigger="viewport">
<MicroDropdown
selections={[
transmutations.self,
...transmutations.others.map((t) => t.label),
]}
selected={transmutations.self}
onChange={(label) => {
const transmutation = transmutations.others.find(
(t) => t.label === label,
);
if (!transmutation) {
return;
}
transmuteNodeCallback(nodeId, transmutation.nodeName);
}}
/>
</NoticeMe>
</div>
) : (
<span className="text-xs text-slate-400">{blockTitle}</span>
)}
{workflowSettingsStore.finallyBlockLabel === blockLabel && (
<span className="rounded bg-amber-600/20 px-1.5 py-0.5 text-[10px] font-medium text-amber-400">
Runs on any outcome
</span>
<NoticeMe trigger="viewport">
<MicroDropdown
selections={[
transmutations.self,
...transmutations.others.map((t) => t.label),
]}
selected={transmutations.self}
onChange={(label) => {
const transmutation = transmutations.others.find(
(t) => t.label === label,
);
if (!transmutation) {
return;
}
transmuteNodeCallback(nodeId, transmutation.nodeName);
}}
/>
</NoticeMe>
</div>
) : (
<span className="text-xs text-slate-400">{blockTitle}</span>
)}
)}
</div>
</div>
</div>
<div className="pointer-events-auto ml-auto flex items-center gap-2">

View File

@@ -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

View File

@@ -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<AppNode>): 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<AppNode>): 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,

View File

@@ -558,6 +558,7 @@ export type WorkflowDefinition = {
version?: number | null;
parameters: Array<Parameter>;
blocks: Array<WorkflowBlock>;
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 }>;

View File

@@ -27,6 +27,7 @@ export type WorkflowDefinitionYAML = {
version?: number | null;
parameters: Array<ParameterYAML>;
blocks: Array<BlockYAML>;
finally_block_label?: string | null;
};
export type ParameterYAML =

View File

@@ -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;

View File

@@ -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}
/>
);
}

View File

@@ -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({
<span className="flex gap-2 text-xs text-slate-400">
{block.label}
</span>
{isFinallyBlock && (
<span className="w-fit rounded bg-amber-500 px-1.5 py-0.5 text-[10px] font-medium text-black">
Execute on any outcome
</span>
)}
</div>
</div>
<div className="flex gap-2">
@@ -235,6 +243,7 @@ function WorkflowRunTimelineBlockItem({
onActionClick={onActionClick}
onBlockItemClick={onBlockItemClick}
onThoughtCardClick={onThoughtCardClick}
finallyBlockLabel={finallyBlockLabel}
/>
);
}

View File

@@ -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) {

View File

@@ -31,11 +31,27 @@ function findActiveItem(
timeline: Array<WorkflowRunTimelineItem>,
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)) {

View File

@@ -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,

View File

@@ -12,6 +12,7 @@ export interface WorkflowSettingsState {
model: WorkflowModel | null;
maxScreenshotScrollingTimes: number | null;
extraHttpHeaders: string | Record<string, unknown> | 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<WorkflowSettingsState>(

View File

@@ -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__(

View File

@@ -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

View File

@@ -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,

View File

@@ -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}",

View File

@@ -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