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 { WorkflowRunTimeline } from "./workflowRun/WorkflowRunTimeline";
|
||||||
import { useWorkflowRunTimelineQuery } from "./hooks/useWorkflowRunTimelineQuery";
|
import { useWorkflowRunTimelineQuery } from "./hooks/useWorkflowRunTimelineQuery";
|
||||||
import { findActiveItem } from "./workflowRun/workflowTimelineUtils";
|
import { findActiveItem } from "./workflowRun/workflowTimelineUtils";
|
||||||
|
import { isBlockItem } from "./types/workflowRunTypes";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { CodeEditor } from "./components/CodeEditor";
|
import { CodeEditor } from "./components/CodeEditor";
|
||||||
import { cn } from "@/util/utils";
|
import { cn } from "@/util/utils";
|
||||||
@@ -146,10 +147,13 @@ function WorkflowRun() {
|
|||||||
workflowRun && statusIsCancellable(workflowRun);
|
workflowRun && statusIsCancellable(workflowRun);
|
||||||
|
|
||||||
const workflowRunIsFinalized = workflowRun && statusIsFinalized(workflowRun);
|
const workflowRunIsFinalized = workflowRun && statusIsFinalized(workflowRun);
|
||||||
|
const finallyBlockLabel =
|
||||||
|
workflow?.workflow_definition?.finally_block_label ?? null;
|
||||||
const selection = findActiveItem(
|
const selection = findActiveItem(
|
||||||
workflowRunTimeline ?? [],
|
workflowRunTimeline ?? [],
|
||||||
active,
|
active,
|
||||||
!!workflowRunIsFinalized,
|
!!workflowRunIsFinalized,
|
||||||
|
finallyBlockLabel,
|
||||||
);
|
);
|
||||||
const parameters = workflowRun?.parameters ?? {};
|
const parameters = workflowRun?.parameters ?? {};
|
||||||
const proxyLocation =
|
const proxyLocation =
|
||||||
@@ -194,6 +198,23 @@ function WorkflowRun() {
|
|||||||
? "Termination Reason"
|
? "Termination Reason"
|
||||||
: "Failure 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 ? (
|
const workflowFailureReason = workflowRun?.failure_reason ? (
|
||||||
<div
|
<div
|
||||||
className="space-y-2 rounded-md border border-red-600 p-4"
|
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="font-bold">{failureReasonTitle}</div>
|
||||||
<div className="text-sm">{workflowRun.failure_reason}</div>
|
<div className="text-sm">{workflowRun.failure_reason}</div>
|
||||||
{matchedTips}
|
{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>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ function getWorkflowElements(version: WorkflowVersion) {
|
|||||||
aiFallback: version.ai_fallback ?? true,
|
aiFallback: version.ai_fallback ?? true,
|
||||||
runSequentially: version.run_sequentially ?? false,
|
runSequentially: version.run_sequentially ?? false,
|
||||||
sequentialKey: version.sequential_key ?? null,
|
sequentialKey: version.sequential_key ?? null,
|
||||||
|
finallyBlockLabel: version.workflow_definition?.finally_block_label ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
return getElements(
|
return getElements(
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ function Debugger() {
|
|||||||
aiFallback: workflow.ai_fallback ?? true,
|
aiFallback: workflow.ai_fallback ?? true,
|
||||||
runSequentially: workflow.run_sequentially ?? false,
|
runSequentially: workflow.run_sequentially ?? false,
|
||||||
sequentialKey: workflow.sequential_key ?? null,
|
sequentialKey: workflow.sequential_key ?? null,
|
||||||
|
finallyBlockLabel:
|
||||||
|
workflow.workflow_definition?.finally_block_label ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const elements = getElements(blocksToRender, settings, true);
|
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;
|
return node;
|
||||||
});
|
});
|
||||||
workflowChangesStore.setHasChanges(true);
|
workflowChangesStore.setHasChanges(true);
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ function WorkflowEditor() {
|
|||||||
aiFallback: workflow.ai_fallback ?? true,
|
aiFallback: workflow.ai_fallback ?? true,
|
||||||
runSequentially: workflow.run_sequentially ?? false,
|
runSequentially: workflow.run_sequentially ?? false,
|
||||||
sequentialKey: workflow.sequential_key ?? null,
|
sequentialKey: workflow.sequential_key ?? null,
|
||||||
|
finallyBlockLabel:
|
||||||
|
workflow.workflow_definition?.finally_block_label ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const elements = getElements(blocksToRender, settings, !isGlobalWorkflow);
|
const elements = getElements(blocksToRender, settings, !isGlobalWorkflow);
|
||||||
|
|||||||
@@ -978,6 +978,8 @@ function Workspace({
|
|||||||
aiFallback: selectedVersion.ai_fallback ?? true,
|
aiFallback: selectedVersion.ai_fallback ?? true,
|
||||||
runSequentially: selectedVersion.run_sequentially ?? false,
|
runSequentially: selectedVersion.run_sequentially ?? false,
|
||||||
sequentialKey: selectedVersion.sequential_key ?? null,
|
sequentialKey: selectedVersion.sequential_key ?? null,
|
||||||
|
finallyBlockLabel:
|
||||||
|
selectedVersion.workflow_definition?.finally_block_label ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const elements = getElements(
|
const elements = getElements(
|
||||||
@@ -1683,6 +1685,8 @@ function Workspace({
|
|||||||
aiFallback: parsedYaml.ai_fallback ?? true,
|
aiFallback: parsedYaml.ai_fallback ?? true,
|
||||||
runSequentially: parsedYaml.run_sequentially ?? false,
|
runSequentially: parsedYaml.run_sequentially ?? false,
|
||||||
sequentialKey: parsedYaml.sequential_key ?? null,
|
sequentialKey: parsedYaml.sequential_key ?? null,
|
||||||
|
finallyBlockLabel:
|
||||||
|
parsedYaml.workflow_definition?.finally_block_label ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Convert YAML blocks to internal format
|
// Convert YAML blocks to internal format
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
BranchContext,
|
BranchContext,
|
||||||
useWorkflowPanelStore,
|
useWorkflowPanelStore,
|
||||||
} from "@/store/WorkflowPanelStore";
|
} from "@/store/WorkflowPanelStore";
|
||||||
|
import { useWorkflowSettingsStore } from "@/store/WorkflowSettingsStore";
|
||||||
import type { NodeBaseData } from "../types";
|
import type { NodeBaseData } from "../types";
|
||||||
import { useRecordedBlocksStore } from "@/store/RecordedBlocksStore";
|
import { useRecordedBlocksStore } from "@/store/RecordedBlocksStore";
|
||||||
import { useRecordingStore } from "@/store/useRecordingStore";
|
import { useRecordingStore } from "@/store/useRecordingStore";
|
||||||
@@ -23,6 +24,7 @@ function NodeAdderNode({ id, parentId }: NodeProps<NodeAdderNode>) {
|
|||||||
const debugStore = useDebugStore();
|
const debugStore = useDebugStore();
|
||||||
const recordingStore = useRecordingStore();
|
const recordingStore = useRecordingStore();
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
|
const workflowSettingsStore = useWorkflowSettingsStore();
|
||||||
const setWorkflowPanelState = useWorkflowPanelStore(
|
const setWorkflowPanelState = useWorkflowPanelStore(
|
||||||
(state) => state.setWorkflowPanelState,
|
(state) => state.setWorkflowPanelState,
|
||||||
);
|
);
|
||||||
@@ -132,7 +134,10 @@ function NodeAdderNode({ id, parentId }: NodeProps<NodeAdderNode>) {
|
|||||||
workflowStatePanel.workflowPanelState.data?.parent ===
|
workflowStatePanel.workflowPanelState.data?.parent ===
|
||||||
(parentId || undefined);
|
(parentId || undefined);
|
||||||
|
|
||||||
const isDisabled = !isBusy && recordingStore.isRecording;
|
const isBlockedByFinally =
|
||||||
|
!parentId && Boolean(workflowSettingsStore.finallyBlockLabel);
|
||||||
|
const isDisabled =
|
||||||
|
isBlockedByFinally || (!isBusy && recordingStore.isRecording);
|
||||||
|
|
||||||
const updateWorkflowPanelState = (
|
const updateWorkflowPanelState = (
|
||||||
active: boolean,
|
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 type { StartNode } from "./types";
|
||||||
|
import { AppNode } from "..";
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
@@ -13,7 +22,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { ProxyLocation } from "@/api/types";
|
import { ProxyLocation } from "@/api/types";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { HelpTooltip } from "@/components/HelpTooltip";
|
import { HelpTooltip } from "@/components/HelpTooltip";
|
||||||
@@ -42,6 +51,7 @@ import { useUpdate } from "@/routes/workflows/editor/useUpdate";
|
|||||||
import { cn } from "@/util/utils";
|
import { cn } from "@/util/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { TestWebhookDialog } from "@/components/TestWebhookDialog";
|
import { TestWebhookDialog } from "@/components/TestWebhookDialog";
|
||||||
|
import { getWorkflowBlocks } from "../../workflowEditorUtils";
|
||||||
|
|
||||||
interface StartSettings {
|
interface StartSettings {
|
||||||
webhookCallbackUrl: string;
|
webhookCallbackUrl: string;
|
||||||
@@ -50,11 +60,14 @@ interface StartSettings {
|
|||||||
model: WorkflowModel | null;
|
model: WorkflowModel | null;
|
||||||
maxScreenshotScrollingTimes: number | null;
|
maxScreenshotScrollingTimes: number | null;
|
||||||
extraHttpHeaders: string | Record<string, unknown> | null;
|
extraHttpHeaders: string | Record<string, unknown> | null;
|
||||||
|
finallyBlockLabel: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StartNode({ id, data, parentId }: NodeProps<StartNode>) {
|
function StartNode({ id, data, parentId }: NodeProps<StartNode>) {
|
||||||
const workflowSettingsStore = useWorkflowSettingsStore();
|
const workflowSettingsStore = useWorkflowSettingsStore();
|
||||||
const reactFlowInstance = useReactFlow();
|
const reactFlowInstance = useReactFlow();
|
||||||
|
const nodes = useNodes<AppNode>();
|
||||||
|
const edges = useEdges();
|
||||||
const [facing, setFacing] = useState<"front" | "back">("front");
|
const [facing, setFacing] = useState<"front" | "back">("front");
|
||||||
const blockScriptStore = useBlockScriptStore();
|
const blockScriptStore = useBlockScriptStore();
|
||||||
const recordingStore = useRecordingStore();
|
const recordingStore = useRecordingStore();
|
||||||
@@ -66,6 +79,20 @@ function StartNode({ id, data, parentId }: NodeProps<StartNode>) {
|
|||||||
const parentNode = parentId ? reactFlowInstance.getNode(parentId) : null;
|
const parentNode = parentId ? reactFlowInstance.getNode(parentId) : null;
|
||||||
const isInsideConditional = parentNode?.type === "conditional";
|
const isInsideConditional = parentNode?.type === "conditional";
|
||||||
const isInsideLoop = parentNode?.type === "loop";
|
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 => {
|
const makeStartSettings = (data: StartNode["data"]): StartSettings => {
|
||||||
return {
|
return {
|
||||||
@@ -85,6 +112,9 @@ function StartNode({ id, data, parentId }: NodeProps<StartNode>) {
|
|||||||
extraHttpHeaders: data.withWorkflowSettings
|
extraHttpHeaders: data.withWorkflowSettings
|
||||||
? data.extraHttpHeaders
|
? data.extraHttpHeaders
|
||||||
: null,
|
: 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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
withWorkflowSettings &&
|
||||||
|
finallyBlockLabel &&
|
||||||
|
!terminalBlockLabelSet.has(finallyBlockLabel)
|
||||||
|
) {
|
||||||
|
update({ finallyBlockLabel: null });
|
||||||
|
}
|
||||||
|
}, [finallyBlockLabel, withWorkflowSettings, terminalBlockLabelSet, update]);
|
||||||
|
|
||||||
function nodeIsFlippable(node: Node) {
|
function nodeIsFlippable(node: Node) {
|
||||||
return (
|
return (
|
||||||
scriptableWorkflowBlockTypes.has(node.type as WorkflowBlockType) ||
|
scriptableWorkflowBlockTypes.has(node.type as WorkflowBlockType) ||
|
||||||
@@ -381,6 +421,33 @@ function StartNode({ id, data, parentId }: NodeProps<StartNode>) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type WorkflowStartNodeData = {
|
|||||||
aiFallback: boolean;
|
aiFallback: boolean;
|
||||||
runSequentially: boolean;
|
runSequentially: boolean;
|
||||||
sequentialKey: string | null;
|
sequentialKey: string | null;
|
||||||
|
finallyBlockLabel: string | null;
|
||||||
label: "__start_block__";
|
label: "__start_block__";
|
||||||
showCode: boolean;
|
showCode: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -533,35 +533,42 @@ function NodeHeader({
|
|||||||
inputClassName="text-base"
|
inputClassName="text-base"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{transmutations && transmutations.others.length ? (
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-1">
|
{transmutations && transmutations.others.length ? (
|
||||||
<span className="text-xs text-slate-400">
|
<div className="flex items-center gap-1">
|
||||||
{transmutations.blockTitle}
|
<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>
|
</span>
|
||||||
<NoticeMe trigger="viewport">
|
)}
|
||||||
<MicroDropdown
|
</div>
|
||||||
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>
|
||||||
<div className="pointer-events-auto ml-auto flex items-center gap-2">
|
<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,
|
aiFallback: version.ai_fallback ?? true,
|
||||||
runSequentially: version.run_sequentially ?? false,
|
runSequentially: version.run_sequentially ?? false,
|
||||||
sequentialKey: version.sequential_key ?? null,
|
sequentialKey: version.sequential_key ?? null,
|
||||||
|
finallyBlockLabel: version.workflow_definition?.finally_block_label ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Deep clone the blocks to ensure complete isolation from main editor
|
// Deep clone the blocks to ensure complete isolation from main editor
|
||||||
|
|||||||
@@ -1373,6 +1373,7 @@ function getElements(
|
|||||||
showCode: false,
|
showCode: false,
|
||||||
runSequentially: settings.runSequentially,
|
runSequentially: settings.runSequentially,
|
||||||
sequentialKey: settings.sequentialKey,
|
sequentialKey: settings.sequentialKey,
|
||||||
|
finallyBlockLabel: settings.finallyBlockLabel ?? null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2541,6 +2542,7 @@ function getWorkflowSettings(nodes: Array<AppNode>): WorkflowSettings {
|
|||||||
aiFallback: true,
|
aiFallback: true,
|
||||||
runSequentially: false,
|
runSequentially: false,
|
||||||
sequentialKey: null,
|
sequentialKey: null,
|
||||||
|
finallyBlockLabel: null,
|
||||||
};
|
};
|
||||||
const startNodes = nodes.filter(isStartNode);
|
const startNodes = nodes.filter(isStartNode);
|
||||||
const startNodeWithWorkflowSettings = startNodes.find(
|
const startNodeWithWorkflowSettings = startNodes.find(
|
||||||
@@ -2566,6 +2568,7 @@ function getWorkflowSettings(nodes: Array<AppNode>): WorkflowSettings {
|
|||||||
aiFallback: data.aiFallback,
|
aiFallback: data.aiFallback,
|
||||||
runSequentially: data.runSequentially,
|
runSequentially: data.runSequentially,
|
||||||
sequentialKey: data.sequentialKey,
|
sequentialKey: data.sequentialKey,
|
||||||
|
finallyBlockLabel: data.finallyBlockLabel ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return defaultSettings;
|
return defaultSettings;
|
||||||
@@ -3358,6 +3361,7 @@ function convert(workflow: WorkflowApiResponse): WorkflowCreateYAMLRequest {
|
|||||||
version: workflowDefinitionVersion,
|
version: workflowDefinitionVersion,
|
||||||
parameters: convertParametersToParameterYAML(userParameters),
|
parameters: convertParametersToParameterYAML(userParameters),
|
||||||
blocks: convertBlocksToBlockYAML(workflow.workflow_definition.blocks),
|
blocks: convertBlocksToBlockYAML(workflow.workflow_definition.blocks),
|
||||||
|
finally_block_label: workflow.workflow_definition.finally_block_label,
|
||||||
},
|
},
|
||||||
is_saved_task: workflow.is_saved_task,
|
is_saved_task: workflow.is_saved_task,
|
||||||
status: workflow.status,
|
status: workflow.status,
|
||||||
|
|||||||
@@ -558,6 +558,7 @@ export type WorkflowDefinition = {
|
|||||||
version?: number | null;
|
version?: number | null;
|
||||||
parameters: Array<Parameter>;
|
parameters: Array<Parameter>;
|
||||||
blocks: Array<WorkflowBlock>;
|
blocks: Array<WorkflowBlock>;
|
||||||
|
finally_block_label?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkflowApiResponse = {
|
export type WorkflowApiResponse = {
|
||||||
@@ -603,6 +604,7 @@ export type WorkflowSettings = {
|
|||||||
aiFallback: boolean | null;
|
aiFallback: boolean | null;
|
||||||
runSequentially: boolean;
|
runSequentially: boolean;
|
||||||
sequentialKey: string | null;
|
sequentialKey: string | null;
|
||||||
|
finallyBlockLabel: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkflowModel = JsonObjectExtendable<{ model_name: string }>;
|
export type WorkflowModel = JsonObjectExtendable<{ model_name: string }>;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export type WorkflowDefinitionYAML = {
|
|||||||
version?: number | null;
|
version?: number | null;
|
||||||
parameters: Array<ParameterYAML>;
|
parameters: Array<ParameterYAML>;
|
||||||
blocks: Array<BlockYAML>;
|
blocks: Array<BlockYAML>;
|
||||||
|
finally_block_label?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ParameterYAML =
|
export type ParameterYAML =
|
||||||
|
|||||||
@@ -77,10 +77,13 @@ function WorkflowRunOverview() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const workflowRunIsFinalized = statusIsFinalized(workflowRun);
|
const workflowRunIsFinalized = statusIsFinalized(workflowRun);
|
||||||
|
const finallyBlockLabel =
|
||||||
|
workflowRun.workflow?.workflow_definition?.finally_block_label ?? null;
|
||||||
const selection = findActiveItem(
|
const selection = findActiveItem(
|
||||||
workflowRunTimeline,
|
workflowRunTimeline,
|
||||||
active,
|
active,
|
||||||
workflowRunIsFinalized,
|
workflowRunIsFinalized,
|
||||||
|
finallyBlockLabel,
|
||||||
);
|
);
|
||||||
|
|
||||||
const browserSessionId = workflowRun.browser_session_id;
|
const browserSessionId = workflowRun.browser_session_id;
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ function WorkflowRunTimeline({
|
|||||||
const workflowRunIsNotFinalized = statusIsNotFinalized(workflowRun);
|
const workflowRunIsNotFinalized = statusIsNotFinalized(workflowRun);
|
||||||
const workflowRunIsFinalized = statusIsFinalized(workflowRun);
|
const workflowRunIsFinalized = statusIsFinalized(workflowRun);
|
||||||
|
|
||||||
|
const finallyBlockLabel =
|
||||||
|
workflowRun.workflow?.workflow_definition?.finally_block_label ?? null;
|
||||||
|
|
||||||
const numberOfActions = workflowRunTimeline.reduce((total, current) => {
|
const numberOfActions = workflowRunTimeline.reduce((total, current) => {
|
||||||
if (isTaskVariantBlockItem(current)) {
|
if (isTaskVariantBlockItem(current)) {
|
||||||
return total + current.block!.actions!.length;
|
return total + current.block!.actions!.length;
|
||||||
@@ -104,6 +107,7 @@ function WorkflowRunTimeline({
|
|||||||
onActionClick={onActionItemSelected}
|
onActionClick={onActionItemSelected}
|
||||||
onBlockItemClick={onBlockItemSelected}
|
onBlockItemClick={onBlockItemSelected}
|
||||||
onThoughtCardClick={onObserverThoughtCardSelected}
|
onThoughtCardClick={onObserverThoughtCardSelected}
|
||||||
|
finallyBlockLabel={finallyBlockLabel}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type Props = {
|
|||||||
onBlockItemClick: (block: WorkflowRunBlock) => void;
|
onBlockItemClick: (block: WorkflowRunBlock) => void;
|
||||||
onActionClick: (action: ActionItem) => void;
|
onActionClick: (action: ActionItem) => void;
|
||||||
onThoughtCardClick: (thought: ObserverThought) => void;
|
onThoughtCardClick: (thought: ObserverThought) => void;
|
||||||
|
finallyBlockLabel?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function WorkflowRunTimelineBlockItem({
|
function WorkflowRunTimelineBlockItem({
|
||||||
@@ -47,8 +48,10 @@ function WorkflowRunTimelineBlockItem({
|
|||||||
onBlockItemClick,
|
onBlockItemClick,
|
||||||
onActionClick,
|
onActionClick,
|
||||||
onThoughtCardClick,
|
onThoughtCardClick,
|
||||||
|
finallyBlockLabel,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const actions = block.actions ?? [];
|
const actions = block.actions ?? [];
|
||||||
|
const isFinallyBlock = finallyBlockLabel && block.label === finallyBlockLabel;
|
||||||
|
|
||||||
const hasActiveAction =
|
const hasActiveAction =
|
||||||
isAction(activeItem) &&
|
isAction(activeItem) &&
|
||||||
@@ -128,6 +131,11 @@ function WorkflowRunTimelineBlockItem({
|
|||||||
<span className="flex gap-2 text-xs text-slate-400">
|
<span className="flex gap-2 text-xs text-slate-400">
|
||||||
{block.label}
|
{block.label}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -235,6 +243,7 @@ function WorkflowRunTimelineBlockItem({
|
|||||||
onActionClick={onActionClick}
|
onActionClick={onActionClick}
|
||||||
onBlockItemClick={onBlockItemClick}
|
onBlockItemClick={onBlockItemClick}
|
||||||
onThoughtCardClick={onThoughtCardClick}
|
onThoughtCardClick={onThoughtCardClick}
|
||||||
|
finallyBlockLabel={finallyBlockLabel}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { useWorkflowRunQuery } from "../hooks/useWorkflowRunQuery";
|
import { useWorkflowRunQuery } from "../hooks/useWorkflowRunQuery";
|
||||||
import { useWorkflowRunTimelineQuery } from "../hooks/useWorkflowRunTimelineQuery";
|
import { useWorkflowRunTimelineQuery } from "../hooks/useWorkflowRunTimelineQuery";
|
||||||
|
import { useWorkflowRunWithWorkflowQuery } from "../hooks/useWorkflowRunWithWorkflowQuery";
|
||||||
import { statusIsFinalized } from "@/routes/tasks/types";
|
import { statusIsFinalized } from "@/routes/tasks/types";
|
||||||
import { findActiveItem } from "./workflowTimelineUtils";
|
import { findActiveItem } from "./workflowTimelineUtils";
|
||||||
import { WorkflowRunOverviewActiveElement } from "./WorkflowRunOverview";
|
import { WorkflowRunOverviewActiveElement } from "./WorkflowRunOverview";
|
||||||
@@ -13,14 +14,19 @@ function useActiveWorkflowRunItem(): [
|
|||||||
const active = searchParams.get("active");
|
const active = searchParams.get("active");
|
||||||
|
|
||||||
const { data: workflowRun } = useWorkflowRunQuery();
|
const { data: workflowRun } = useWorkflowRunQuery();
|
||||||
|
const { data: workflowRunWithWorkflow } = useWorkflowRunWithWorkflowQuery();
|
||||||
|
|
||||||
const { data: workflowRunTimeline } = useWorkflowRunTimelineQuery();
|
const { data: workflowRunTimeline } = useWorkflowRunTimelineQuery();
|
||||||
|
|
||||||
const workflowRunIsFinalized = workflowRun && statusIsFinalized(workflowRun);
|
const workflowRunIsFinalized = workflowRun && statusIsFinalized(workflowRun);
|
||||||
|
const finallyBlockLabel =
|
||||||
|
workflowRunWithWorkflow?.workflow?.workflow_definition
|
||||||
|
?.finally_block_label ?? null;
|
||||||
const activeItem = findActiveItem(
|
const activeItem = findActiveItem(
|
||||||
workflowRunTimeline ?? [],
|
workflowRunTimeline ?? [],
|
||||||
active,
|
active,
|
||||||
!!workflowRunIsFinalized,
|
!!workflowRunIsFinalized,
|
||||||
|
finallyBlockLabel,
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleSetActiveItem(id: string) {
|
function handleSetActiveItem(id: string) {
|
||||||
|
|||||||
@@ -31,11 +31,27 @@ function findActiveItem(
|
|||||||
timeline: Array<WorkflowRunTimelineItem>,
|
timeline: Array<WorkflowRunTimelineItem>,
|
||||||
target: string | null,
|
target: string | null,
|
||||||
workflowRunIsFinalized: boolean,
|
workflowRunIsFinalized: boolean,
|
||||||
|
finallyBlockLabel?: string | null,
|
||||||
): WorkflowRunOverviewActiveElement {
|
): WorkflowRunOverviewActiveElement {
|
||||||
if (target === null) {
|
if (target === null) {
|
||||||
if (!workflowRunIsFinalized) {
|
if (!workflowRunIsFinalized) {
|
||||||
return "stream";
|
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) {
|
if (timeline?.length > 0) {
|
||||||
const timelineItem = timeline![0];
|
const timelineItem = timeline![0];
|
||||||
if (isBlockItem(timelineItem)) {
|
if (isBlockItem(timelineItem)) {
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ const useWorkflowSave = (opts?: WorkflowSaveOpts) => {
|
|||||||
version: saveData.workflowDefinitionVersion,
|
version: saveData.workflowDefinitionVersion,
|
||||||
parameters: saveData.parameters,
|
parameters: saveData.parameters,
|
||||||
blocks: saveData.blocks,
|
blocks: saveData.blocks,
|
||||||
|
finally_block_label: saveData.settings.finallyBlockLabel ?? undefined,
|
||||||
},
|
},
|
||||||
is_saved_task: saveData.workflow.is_saved_task,
|
is_saved_task: saveData.workflow.is_saved_task,
|
||||||
status: opts?.status ?? saveData.workflow.status,
|
status: opts?.status ?? saveData.workflow.status,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface WorkflowSettingsState {
|
|||||||
model: WorkflowModel | null;
|
model: WorkflowModel | null;
|
||||||
maxScreenshotScrollingTimes: number | null;
|
maxScreenshotScrollingTimes: number | null;
|
||||||
extraHttpHeaders: string | Record<string, unknown> | null;
|
extraHttpHeaders: string | Record<string, unknown> | null;
|
||||||
|
finallyBlockLabel: string | null;
|
||||||
setWorkflowSettings: (
|
setWorkflowSettings: (
|
||||||
settings: Partial<
|
settings: Partial<
|
||||||
Omit<
|
Omit<
|
||||||
@@ -33,6 +34,7 @@ const defaultState: Omit<
|
|||||||
model: null,
|
model: null,
|
||||||
maxScreenshotScrollingTimes: null,
|
maxScreenshotScrollingTimes: null,
|
||||||
extraHttpHeaders: null,
|
extraHttpHeaders: null,
|
||||||
|
finallyBlockLabel: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useWorkflowSettingsStore = create<WorkflowSettingsState>(
|
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):
|
class FailedToCreateWorkflow(BaseWorkflowHTTPException):
|
||||||
def __init__(self, error_message: str) -> None:
|
def __init__(self, error_message: str) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ from typing_extensions import deprecated
|
|||||||
|
|
||||||
from skyvern.forge.sdk.schemas.files import FileInfo
|
from skyvern.forge.sdk.schemas.files import FileInfo
|
||||||
from skyvern.forge.sdk.schemas.task_v2 import TaskV2
|
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.block import BlockTypeVar
|
||||||
from skyvern.forge.sdk.workflow.models.parameter import PARAMETER_TYPE, OutputParameter
|
from skyvern.forge.sdk.workflow.models.parameter import PARAMETER_TYPE, OutputParameter
|
||||||
from skyvern.schemas.runs import ProxyLocationInput, ScriptRunResponse
|
from skyvern.schemas.runs import ProxyLocationInput, ScriptRunResponse
|
||||||
@@ -54,6 +58,7 @@ class WorkflowDefinition(BaseModel):
|
|||||||
version: int = 1
|
version: int = 1
|
||||||
parameters: list[PARAMETER_TYPE]
|
parameters: list[PARAMETER_TYPE]
|
||||||
blocks: List[BlockTypeVar]
|
blocks: List[BlockTypeVar]
|
||||||
|
finally_block_label: str | None = None
|
||||||
|
|
||||||
def validate(self) -> None:
|
def validate(self) -> None:
|
||||||
labels: set[str] = set()
|
labels: set[str] = set()
|
||||||
@@ -67,6 +72,13 @@ class WorkflowDefinition(BaseModel):
|
|||||||
if duplicate_labels:
|
if duplicate_labels:
|
||||||
raise WorkflowDefinitionHasDuplicateBlockLabels(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):
|
class Workflow(BaseModel):
|
||||||
workflow_id: str
|
workflow_id: str
|
||||||
|
|||||||
@@ -737,33 +737,59 @@ class WorkflowService:
|
|||||||
script=workflow_script,
|
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(
|
if refreshed_workflow_run := await app.DATABASE.get_workflow_run(
|
||||||
workflow_run_id=workflow_run_id,
|
workflow_run_id=workflow_run_id,
|
||||||
organization_id=organization_id,
|
organization_id=organization_id,
|
||||||
):
|
):
|
||||||
workflow_run = refreshed_workflow_run
|
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.failed,
|
||||||
WorkflowRunStatus.terminated,
|
WorkflowRunStatus.terminated,
|
||||||
WorkflowRunStatus.timed_out,
|
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,
|
workflow_run_id=workflow_run_id,
|
||||||
|
status=WorkflowRunStatus.running,
|
||||||
|
failure_reason=None,
|
||||||
)
|
)
|
||||||
await self.generate_script_if_needed(
|
await self._execute_finally_block_if_configured(
|
||||||
workflow=workflow,
|
workflow=workflow,
|
||||||
workflow_run=workflow_run,
|
workflow_run=workflow_run,
|
||||||
block_labels=block_labels,
|
organization=organization,
|
||||||
blocks_to_update=blocks_to_update,
|
browser_session_id=browser_session_id,
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
LOG.info(
|
workflow_run = await self._finalize_workflow_run_status(
|
||||||
"Workflow run is already timed_out, canceled, failed, or terminated, not marking as completed",
|
workflow_run_id=workflow_run_id,
|
||||||
workflow_run_id=workflow_run_id,
|
workflow_run=workflow_run,
|
||||||
workflow_run_status=workflow_run.status,
|
pre_finally_status=pre_finally_status,
|
||||||
run_with=workflow_run.run_with,
|
pre_finally_failure_reason=pre_finally_failure_reason,
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.clean_up_workflow(
|
await self.clean_up_workflow(
|
||||||
workflow=workflow,
|
workflow=workflow,
|
||||||
workflow_run=workflow_run,
|
workflow_run=workflow_run,
|
||||||
@@ -1340,6 +1366,46 @@ class WorkflowService:
|
|||||||
|
|
||||||
return workflow_run, False
|
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(
|
def _build_workflow_graph(
|
||||||
self,
|
self,
|
||||||
blocks: list[BlockTypeVar],
|
blocks: list[BlockTypeVar],
|
||||||
@@ -2131,6 +2197,35 @@ class WorkflowService:
|
|||||||
run_with=run_with,
|
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(
|
async def mark_workflow_run_as_failed(
|
||||||
self,
|
self,
|
||||||
workflow_run_id: str,
|
workflow_run_id: str,
|
||||||
|
|||||||
@@ -302,7 +302,12 @@ def convert_workflow_definition(
|
|||||||
if dag_version is None:
|
if dag_version is None:
|
||||||
dag_version = 2 if _has_dag_metadata(workflow_definition_yaml.blocks) else 1
|
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(
|
LOG.info(
|
||||||
f"Created workflow from request, title: {title}",
|
f"Created workflow from request, title: {title}",
|
||||||
|
|||||||
@@ -591,6 +591,7 @@ class WorkflowDefinitionYAML(BaseModel):
|
|||||||
version: int = 1
|
version: int = 1
|
||||||
parameters: list[PARAMETER_YAML_TYPES]
|
parameters: list[PARAMETER_YAML_TYPES]
|
||||||
blocks: list[BLOCK_YAML_TYPES]
|
blocks: list[BLOCK_YAML_TYPES]
|
||||||
|
finally_block_label: str | None = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_unique_block_labels(cls, workflow: "WorkflowDefinitionYAML") -> "WorkflowDefinitionYAML":
|
def validate_unique_block_labels(cls, workflow: "WorkflowDefinitionYAML") -> "WorkflowDefinitionYAML":
|
||||||
@@ -604,6 +605,12 @@ class WorkflowDefinitionYAML(BaseModel):
|
|||||||
f"Found duplicate label(s): {', '.join(unique_duplicates)}"
|
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
|
return workflow
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user