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