Add "Execute on Any Outcome" (Finally) option to blocks - Pair Team request (#4443)
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -17,6 +17,7 @@ export type WorkflowStartNodeData = {
|
||||
aiFallback: boolean;
|
||||
runSequentially: boolean;
|
||||
sequentialKey: string | null;
|
||||
finallyBlockLabel: string | null;
|
||||
label: "__start_block__";
|
||||
showCode: boolean;
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -27,6 +27,7 @@ export type WorkflowDefinitionYAML = {
|
||||
version?: number | null;
|
||||
parameters: Array<ParameterYAML>;
|
||||
blocks: Array<BlockYAML>;
|
||||
finally_block_label?: string | null;
|
||||
};
|
||||
|
||||
export type ParameterYAML =
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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__(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user