diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormBooleanFieldToggleInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormBooleanFieldToggleInput.tsx
new file mode 100644
index 0000000000..2619b6dbcd
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormBooleanFieldToggleInput.tsx
@@ -0,0 +1,78 @@
+import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputContainer';
+import { FormFieldInputInnerContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputInnerContainer';
+import { FormFieldInputRowContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputRowContainer';
+import { InputHint } from '@/ui/input/components/InputHint';
+import { InputLabel } from '@/ui/input/components/InputLabel';
+import styled from '@emotion/styled';
+import { useId } from 'react';
+import { Toggle } from 'twenty-ui/input';
+
+type FormBooleanFieldToggleInputProps = {
+ label?: string;
+ description: string;
+ hint?: string;
+ value: boolean;
+ onChange: (value: boolean) => void;
+ disabled?: boolean;
+};
+
+const StyledDescription = styled.span`
+ align-items: center;
+ color: ${({ theme }) => theme.font.color.secondary};
+ display: flex;
+ font-size: ${({ theme }) => theme.font.size.md};
+ padding-left: ${({ theme }) => theme.spacing(2)};
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+`;
+
+const StyledToggleContainer = styled.div`
+ align-items: center;
+ background-color: ${({ theme }) => theme.background.transparent.lighter};
+ border-top: 1px solid ${({ theme }) => theme.border.color.medium};
+ border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
+ border-right: 1px solid ${({ theme }) => theme.border.color.medium};
+ border-bottom-right-radius: ${({ theme }) => theme.border.radius.sm};
+ border-top-right-radius: ${({ theme }) => theme.border.radius.sm};
+ display: flex;
+ padding-right: ${({ theme }) => theme.spacing(2)};
+`;
+
+export const FormBooleanFieldToggleInput = ({
+ label,
+ description,
+ hint,
+ value,
+ onChange,
+ disabled,
+}: FormBooleanFieldToggleInputProps) => {
+ const instanceId = useId();
+
+ return (
+
+ {label ? {label} : null}
+
+
+
+ {description}
+
+
+
+
+
+
+
+ {hint && {hint}}
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormBooleanFieldToggleInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormBooleanFieldToggleInput.stories.tsx
new file mode 100644
index 0000000000..f13703fcfe
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormBooleanFieldToggleInput.stories.tsx
@@ -0,0 +1,86 @@
+import { FormBooleanFieldToggleInput } from '@/object-record/record-field/ui/form-types/components/FormBooleanFieldToggleInput';
+import { type Meta, type StoryObj } from '@storybook/react-vite';
+import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
+
+const meta: Meta = {
+ title: 'UI/Data/Field/Form/Input/FormBooleanFieldToggleInput',
+ component: FormBooleanFieldToggleInput,
+ args: {
+ description: 'Continue on iteration failure',
+ value: false,
+ onChange: fn(),
+ },
+ argTypes: {},
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await canvas.findByText('Continue on iteration failure');
+
+ const checkbox = canvas.getByRole('checkbox');
+
+ expect(checkbox).not.toBeChecked();
+ },
+};
+
+export const WithLabel: Story = {
+ args: {
+ label: 'Settings',
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await canvas.findByText('Settings');
+ await canvas.findByText('Continue on iteration failure');
+ },
+};
+
+export const WithHint: Story = {
+ args: {
+ hint: 'If enabled, the workflow will continue to the next iteration even if the current one fails.',
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await canvas.findByText(
+ 'If enabled, the workflow will continue to the next iteration even if the current one fails.',
+ );
+ },
+};
+
+export const ToggledOn: Story = {
+ args: {
+ value: true,
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ const checkbox = canvas.getByRole('checkbox');
+
+ expect(checkbox).toBeChecked();
+ },
+};
+
+export const TogglesValue: Story = {
+ args: {
+ value: false,
+ onChange: fn(),
+ },
+ play: async ({ canvasElement, args }) => {
+ const canvas = within(canvasElement);
+
+ const checkbox = canvas.getByRole('checkbox');
+
+ await userEvent.click(checkbox);
+
+ await waitFor(() => {
+ expect(args.onChange).toHaveBeenCalledWith(true);
+ });
+ },
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getWorkflowDiagramColors.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getWorkflowDiagramColors.ts
index ecee1b89af..338b1fabbd 100644
--- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getWorkflowDiagramColors.ts
+++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getWorkflowDiagramColors.ts
@@ -41,7 +41,8 @@ export const getWorkflowDiagramColors = ({
},
};
}
- case 'FAILED': {
+ case 'FAILED':
+ case 'FAILED_SAFELY': {
return {
selected: {
background: theme.color.red2,
diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-nodes/components/WorkflowRunDiagramStepNode.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-nodes/components/WorkflowRunDiagramStepNode.tsx
index cb56c72723..d0b2429b27 100644
--- a/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-nodes/components/WorkflowRunDiagramStepNode.tsx
+++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-nodes/components/WorkflowRunDiagramStepNode.tsx
@@ -171,7 +171,8 @@ export const WorkflowRunDiagramStepNode = ({
)}
- {data.runStatus === StepStatus.FAILED && (
+ {(data.runStatus === StepStatus.FAILED ||
+ data.runStatus === StepStatus.FAILED_SAFELY) && (
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/iterator-action/components/WorkflowEditActionIterator.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/iterator-action/components/WorkflowEditActionIterator.tsx
index 7917fe772f..c3a126581c 100644
--- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/iterator-action/components/WorkflowEditActionIterator.tsx
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/iterator-action/components/WorkflowEditActionIterator.tsx
@@ -1,4 +1,5 @@
import { FormArrayFieldInput } from '@/object-record/record-field/ui/form-types/components/FormArrayFieldInput';
+import { FormBooleanFieldToggleInput } from '@/object-record/record-field/ui/form-types/components/FormBooleanFieldToggleInput';
import { type FieldArrayValue } from '@/object-record/record-field/ui/types/FieldMetadata';
import { type WorkflowIteratorAction } from '@/workflow/types/Workflow';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
@@ -56,6 +57,8 @@ export const WorkflowEditActionIterator = ({
const [formData, setFormData] = useState({
items: parsedItems,
initialLoopStepIds: action.settings.input.initialLoopStepIds || [],
+ shouldContinueOnIterationFailure:
+ action.settings.input.shouldContinueOnIterationFailure ?? false,
});
const saveAction = useDebouncedCallback(
@@ -71,6 +74,8 @@ export const WorkflowEditActionIterator = ({
input: {
items: updatedFormData.items,
initialLoopStepIds: updatedFormData.initialLoopStepIds,
+ shouldContinueOnIterationFailure:
+ updatedFormData.shouldContinueOnIterationFailure,
},
},
});
@@ -80,7 +85,7 @@ export const WorkflowEditActionIterator = ({
const handleFieldChange = (
field: string,
- value: string | FieldArrayValue,
+ value: string | FieldArrayValue | boolean,
) => {
if (actionOptions.readonly === true) {
return;
@@ -104,6 +109,15 @@ export const WorkflowEditActionIterator = ({
readonly={actionOptions.readonly}
VariablePicker={WorkflowVariablePicker}
/>
+
+ handleFieldChange('shouldContinueOnIterationFailure', value)
+ }
+ disabled={actionOptions.readonly}
+ hint={t`Will continue to the next iteration even if the current one fails`}
+ />
{!actionOptions.readonly && }
>
diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts
index 37570f84ce..803e15927b 100644
--- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts
+++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts
@@ -486,6 +486,7 @@ export class WorkflowVersionStepOperationsWorkspaceService {
input: {
items: [],
initialLoopStepIds: [emptyNodeStep.id],
+ shouldContinueOnIterationFailure: true,
},
},
},
diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/constants/terminal-step-statuses.constant.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/constants/terminal-step-statuses.constant.ts
new file mode 100644
index 0000000000..07b9067a3f
--- /dev/null
+++ b/packages/twenty-server/src/modules/workflow/workflow-executor/constants/terminal-step-statuses.constant.ts
@@ -0,0 +1,8 @@
+import { StepStatus } from 'twenty-shared/workflow';
+
+export const TERMINAL_STEP_STATUSES = [
+ StepStatus.SUCCESS,
+ StepStatus.STOPPED,
+ StepStatus.SKIPPED,
+ StepStatus.FAILED_SAFELY,
+];
diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/types/workflow-action-output.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/types/workflow-action-output.type.ts
index aaed931891..85d71d75f3 100644
--- a/packages/twenty-server/src/modules/workflow/workflow-executor/types/workflow-action-output.type.ts
+++ b/packages/twenty-server/src/modules/workflow/workflow-executor/types/workflow-action-output.type.ts
@@ -5,4 +5,5 @@ export type WorkflowActionOutput = {
shouldEndWorkflowRun?: boolean;
shouldRemainRunning?: boolean;
shouldSkipStepExecution?: boolean;
+ shouldFailSafely?: boolean;
};
diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/should-execute-child-step.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/should-execute-child-step.util.spec.ts
index ca63d45639..a3af95071c 100644
--- a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/should-execute-child-step.util.spec.ts
+++ b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/should-execute-child-step.util.spec.ts
@@ -460,6 +460,54 @@ describe('shouldExecuteChildStep', () => {
expect(result).toBe(false);
});
+ it('should return false when one parent succeeded and another is FAILED_SAFELY', () => {
+ const stepInfos = {
+ 'parent-1': {
+ status: StepStatus.SUCCESS,
+ },
+ 'parent-2': {
+ status: StepStatus.FAILED_SAFELY,
+ },
+ };
+
+ const result = shouldExecuteChildStep({
+ parentSteps,
+ stepInfos,
+ });
+
+ expect(result).toBe(false);
+ });
+
+ it('should return false when single parent is FAILED_SAFELY', () => {
+ const singleParent = [
+ {
+ id: 'parent-1',
+ type: WorkflowActionType.CODE,
+ settings: {
+ errorHandlingOptions: {
+ continueOnFailure: { value: false },
+ retryOnFailure: { value: false },
+ },
+ outputSchema: {},
+ },
+ nextStepIds: [],
+ } as unknown as WorkflowAction,
+ ];
+
+ const stepInfos = {
+ 'parent-1': {
+ status: StepStatus.FAILED_SAFELY,
+ },
+ };
+
+ const result = shouldExecuteChildStep({
+ parentSteps: singleParent,
+ stepInfos,
+ });
+
+ expect(result).toBe(false);
+ });
+
it('should return false with large number of parents when not all completed', () => {
const manyParentSteps = Array.from({ length: 25 }, (_, i) => ({
id: `parent-${i + 1}`,
diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/should-fail-safely.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/should-fail-safely.util.spec.ts
new file mode 100644
index 0000000000..7b0a46b9de
--- /dev/null
+++ b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/should-fail-safely.util.spec.ts
@@ -0,0 +1,182 @@
+import { StepStatus } from 'twenty-shared/workflow';
+
+import {
+ createMockCodeStep,
+ createMockIteratorStep,
+} from 'src/modules/workflow/workflow-executor/utils/create-mock-workflow-steps.util';
+import { shouldFailSafely } from 'src/modules/workflow/workflow-executor/utils/should-fail-safely.util';
+
+describe('shouldFailSafely', () => {
+ it('should return false when step has no parents', () => {
+ const step = createMockCodeStep('step1');
+ const steps = [step];
+
+ const result = shouldFailSafely({
+ step,
+ steps,
+ stepInfos: {},
+ });
+
+ expect(result).toBe(false);
+ });
+
+ it('should return false when all parents are SUCCESS', () => {
+ const parentStep = createMockCodeStep('parent', ['child']);
+ const childStep = createMockCodeStep('child');
+ const steps = [parentStep, childStep];
+
+ const result = shouldFailSafely({
+ step: childStep,
+ steps,
+ stepInfos: {
+ parent: { status: StepStatus.SUCCESS, result: {} },
+ },
+ });
+
+ expect(result).toBe(false);
+ });
+
+ it('should return true when one parent is FAILED_SAFELY and all are terminal', () => {
+ const parent1 = createMockCodeStep('parent1', ['child']);
+ const parent2 = createMockCodeStep('parent2', ['child']);
+ const childStep = createMockCodeStep('child');
+ const steps = [parent1, parent2, childStep];
+
+ const result = shouldFailSafely({
+ step: childStep,
+ steps,
+ stepInfos: {
+ parent1: { status: StepStatus.FAILED_SAFELY, error: 'some error' },
+ parent2: { status: StepStatus.SUCCESS, result: {} },
+ },
+ });
+
+ expect(result).toBe(true);
+ });
+
+ it('should return false when parent is FAILED_SAFELY but another parent is still running', () => {
+ const parent1 = createMockCodeStep('parent1', ['child']);
+ const parent2 = createMockCodeStep('parent2', ['child']);
+ const childStep = createMockCodeStep('child');
+ const steps = [parent1, parent2, childStep];
+
+ const result = shouldFailSafely({
+ step: childStep,
+ steps,
+ stepInfos: {
+ parent1: { status: StepStatus.FAILED_SAFELY, error: 'some error' },
+ parent2: { status: StepStatus.RUNNING },
+ },
+ });
+
+ expect(result).toBe(false);
+ });
+
+ it('should return true when all parents are FAILED_SAFELY', () => {
+ const parent1 = createMockCodeStep('parent1', ['child']);
+ const parent2 = createMockCodeStep('parent2', ['child']);
+ const childStep = createMockCodeStep('child');
+ const steps = [parent1, parent2, childStep];
+
+ const result = shouldFailSafely({
+ step: childStep,
+ steps,
+ stepInfos: {
+ parent1: { status: StepStatus.FAILED_SAFELY },
+ parent2: { status: StepStatus.FAILED_SAFELY, error: 'err' },
+ },
+ });
+
+ expect(result).toBe(true);
+ });
+
+ it('should return true for FAILED_SAFELY + SKIPPED parents', () => {
+ const parent1 = createMockCodeStep('parent1', ['child']);
+ const parent2 = createMockCodeStep('parent2', ['child']);
+ const childStep = createMockCodeStep('child');
+ const steps = [parent1, parent2, childStep];
+
+ const result = shouldFailSafely({
+ step: childStep,
+ steps,
+ stepInfos: {
+ parent1: { status: StepStatus.FAILED_SAFELY },
+ parent2: { status: StepStatus.SKIPPED },
+ },
+ });
+
+ expect(result).toBe(true);
+ });
+});
+
+describe('shouldFailSafely for iterator steps', () => {
+ it('should return false for iterator with flag when failure from own loop', () => {
+ const iterator = createMockIteratorStep(
+ 'iterator1',
+ ['after'],
+ ['stepA'],
+ true,
+ );
+ const stepA = createMockCodeStep('stepA', ['stepB']);
+ const stepB = createMockCodeStep('stepB', ['iterator1']);
+ const steps = [iterator, stepA, stepB];
+
+ const result = shouldFailSafely({
+ step: iterator,
+ steps,
+ stepInfos: {
+ iterator1: { status: StepStatus.RUNNING },
+ stepA: { status: StepStatus.FAILED_SAFELY, error: 'actual error' },
+ stepB: { status: StepStatus.FAILED_SAFELY },
+ },
+ });
+
+ expect(result).toBe(false);
+ });
+
+ it('should return true for iterator without flag when loop-back parent is FAILED_SAFELY', () => {
+ const iterator = createMockIteratorStep(
+ 'iterator1',
+ ['after'],
+ ['stepA'],
+ false,
+ );
+ const stepA = createMockCodeStep('stepA', ['stepB']);
+ const stepB = createMockCodeStep('stepB', ['iterator1']);
+ const steps = [iterator, stepA, stepB];
+
+ const result = shouldFailSafely({
+ step: iterator,
+ steps,
+ stepInfos: {
+ iterator1: { status: StepStatus.RUNNING },
+ stepA: { status: StepStatus.FAILED_SAFELY, error: 'err' },
+ stepB: { status: StepStatus.FAILED_SAFELY },
+ },
+ });
+
+ expect(result).toBe(true);
+ });
+
+ it('should return true for unstarted iterator when external parent is FAILED_SAFELY', () => {
+ const parentStep = createMockCodeStep('parent', ['iterator1']);
+ const iterator = createMockIteratorStep(
+ 'iterator1',
+ ['after'],
+ ['stepA'],
+ true,
+ );
+ const stepA = createMockCodeStep('stepA', ['iterator1']);
+ const steps = [parentStep, iterator, stepA];
+
+ const result = shouldFailSafely({
+ step: iterator,
+ steps,
+ stepInfos: {
+ parent: { status: StepStatus.FAILED_SAFELY },
+ },
+ });
+
+ expect(result).toBe(true);
+ });
+});
diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/should-skip-step-execution.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/should-skip-step-execution.util.spec.ts
index 607e600b03..f21b0a4f90 100644
--- a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/should-skip-step-execution.util.spec.ts
+++ b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/should-skip-step-execution.util.spec.ts
@@ -1,39 +1,15 @@
import { StepStatus } from 'twenty-shared/workflow';
+import { createMockCodeStep } from 'src/modules/workflow/workflow-executor/utils/create-mock-workflow-steps.util';
import { shouldSkipStepExecution } from 'src/modules/workflow/workflow-executor/utils/should-skip-step-execution.util';
-import {
- type WorkflowAction,
- WorkflowActionType,
-} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
+import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
describe('shouldSkipStepExecution', () => {
- const createMockStep = (
- id: string,
- nextStepIds: string[] = [],
- ): WorkflowAction => ({
- id,
- name: 'Mock Step',
- type: WorkflowActionType.CODE,
- settings: {
- input: {
- logicFunctionId: 'mock-function-id',
- logicFunctionInput: {},
- },
- errorHandlingOptions: {
- continueOnFailure: { value: false },
- retryOnFailure: { value: false },
- },
- outputSchema: {},
- },
- valid: true,
- nextStepIds,
- });
-
it('should return true when all parent steps are skipped', () => {
const steps = [
- createMockStep('step-1', ['step-3']),
- createMockStep('step-2', ['step-3']),
- createMockStep('step-3', []),
+ createMockCodeStep('step-1', ['step-3']),
+ createMockCodeStep('step-2', ['step-3']),
+ createMockCodeStep('step-3', []),
];
const stepInfos = {
'step-1': { status: StepStatus.SKIPPED },
@@ -52,9 +28,9 @@ describe('shouldSkipStepExecution', () => {
it('should return true when all parent steps are stopped', () => {
const steps = [
- createMockStep('step-1', ['step-3']),
- createMockStep('step-2', ['step-3']),
- createMockStep('step-3', []),
+ createMockCodeStep('step-1', ['step-3']),
+ createMockCodeStep('step-2', ['step-3']),
+ createMockCodeStep('step-3', []),
];
const stepInfos = {
'step-1': { status: StepStatus.STOPPED },
@@ -73,9 +49,9 @@ describe('shouldSkipStepExecution', () => {
it('should return true when parent steps are mix of skipped and stopped', () => {
const steps = [
- createMockStep('step-1', ['step-3']),
- createMockStep('step-2', ['step-3']),
- createMockStep('step-3', []),
+ createMockCodeStep('step-1', ['step-3']),
+ createMockCodeStep('step-2', ['step-3']),
+ createMockCodeStep('step-3', []),
];
const stepInfos = {
'step-1': { status: StepStatus.SKIPPED },
@@ -94,9 +70,9 @@ describe('shouldSkipStepExecution', () => {
it('should return false when at least one parent step is successful', () => {
const steps = [
- createMockStep('step-1', ['step-3']),
- createMockStep('step-2', ['step-3']),
- createMockStep('step-3', []),
+ createMockCodeStep('step-1', ['step-3']),
+ createMockCodeStep('step-2', ['step-3']),
+ createMockCodeStep('step-3', []),
];
const stepInfos = {
'step-1': { status: StepStatus.SKIPPED },
@@ -115,9 +91,9 @@ describe('shouldSkipStepExecution', () => {
it('should return false when at least one parent step is failed', () => {
const steps = [
- createMockStep('step-1', ['step-3']),
- createMockStep('step-2', ['step-3']),
- createMockStep('step-3', []),
+ createMockCodeStep('step-1', ['step-3']),
+ createMockCodeStep('step-2', ['step-3']),
+ createMockCodeStep('step-3', []),
];
const stepInfos = {
'step-1': { status: StepStatus.SKIPPED },
@@ -136,9 +112,9 @@ describe('shouldSkipStepExecution', () => {
it('should return false when at least one parent step is running', () => {
const steps = [
- createMockStep('step-1', ['step-3']),
- createMockStep('step-2', ['step-3']),
- createMockStep('step-3', []),
+ createMockCodeStep('step-1', ['step-3']),
+ createMockCodeStep('step-2', ['step-3']),
+ createMockCodeStep('step-3', []),
];
const stepInfos = {
'step-1': { status: StepStatus.SKIPPED },
@@ -157,9 +133,9 @@ describe('shouldSkipStepExecution', () => {
it('should return false when at least one parent step is not started', () => {
const steps = [
- createMockStep('step-1', ['step-3']),
- createMockStep('step-2', ['step-3']),
- createMockStep('step-3', []),
+ createMockCodeStep('step-1', ['step-3']),
+ createMockCodeStep('step-2', ['step-3']),
+ createMockCodeStep('step-3', []),
];
const stepInfos = {
'step-1': { status: StepStatus.SKIPPED },
@@ -178,9 +154,9 @@ describe('shouldSkipStepExecution', () => {
it('should return false when there are no parent steps', () => {
const steps = [
- createMockStep('step-1', ['step-2']),
- createMockStep('step-2', []),
- createMockStep('step-3', []),
+ createMockCodeStep('step-1', ['step-2']),
+ createMockCodeStep('step-2', []),
+ createMockCodeStep('step-3', []),
];
const stepInfos = {
'step-1': { status: StepStatus.SKIPPED },
@@ -199,8 +175,8 @@ describe('shouldSkipStepExecution', () => {
it('should return true when single parent step is skipped', () => {
const steps = [
- createMockStep('step-1', ['step-2']),
- createMockStep('step-2', []),
+ createMockCodeStep('step-1', ['step-2']),
+ createMockCodeStep('step-2', []),
];
const stepInfos = {
'step-1': { status: StepStatus.SKIPPED },
@@ -218,9 +194,9 @@ describe('shouldSkipStepExecution', () => {
it('should handle undefined steps gracefully', () => {
const steps = [
- createMockStep('step-1', ['step-2']),
+ createMockCodeStep('step-1', ['step-2']),
undefined as unknown as WorkflowAction,
- createMockStep('step-2', []),
+ createMockCodeStep('step-2', []),
];
const stepInfos = {
'step-1': { status: StepStatus.SKIPPED },
@@ -238,8 +214,8 @@ describe('shouldSkipStepExecution', () => {
it('should return false when parent step status is pending', () => {
const steps = [
- createMockStep('step-1', ['step-2']),
- createMockStep('step-2', []),
+ createMockCodeStep('step-1', ['step-2']),
+ createMockCodeStep('step-2', []),
];
const stepInfos = {
'step-1': { status: StepStatus.PENDING },
@@ -257,10 +233,10 @@ describe('shouldSkipStepExecution', () => {
it('should work with multiple parent steps with different statuses', () => {
const steps = [
- createMockStep('step-1', ['step-4']),
- createMockStep('step-2', ['step-4']),
- createMockStep('step-3', ['step-4']),
- createMockStep('step-4', []),
+ createMockCodeStep('step-1', ['step-4']),
+ createMockCodeStep('step-2', ['step-4']),
+ createMockCodeStep('step-3', ['step-4']),
+ createMockCodeStep('step-4', []),
];
// Test case 1: All skipped - should return true
diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/create-mock-workflow-steps.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/create-mock-workflow-steps.util.ts
new file mode 100644
index 0000000000..1a7adb22bd
--- /dev/null
+++ b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/create-mock-workflow-steps.util.ts
@@ -0,0 +1,78 @@
+import { type StepIfElseBranch } from 'twenty-shared/workflow';
+
+import { type WorkflowCodeActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/code/types/workflow-code-action-settings.type';
+import { type WorkflowIfElseActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/if-else/types/workflow-if-else-action-settings.type';
+import { type WorkflowIteratorActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/types/workflow-iterator-action-settings.type';
+import {
+ WorkflowActionType,
+ type WorkflowCodeAction,
+ type WorkflowIfElseAction,
+ type WorkflowIteratorAction,
+} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
+
+export const createMockCodeStep = (
+ id: string,
+ nextStepIds: string[] = [],
+): WorkflowCodeAction => ({
+ id,
+ name: `Step ${id}`,
+ type: WorkflowActionType.CODE,
+ valid: true,
+ nextStepIds,
+ settings: {
+ input: {},
+ outputSchema: {},
+ errorHandlingOptions: {
+ continueOnFailure: { value: false },
+ retryOnFailure: { value: false },
+ },
+ } as WorkflowCodeActionSettings,
+});
+
+export const createMockIteratorStep = (
+ id: string,
+ nextStepIds: string[] = [],
+ initialLoopStepIds: string[] = [],
+ shouldContinueOnIterationFailure = false,
+): WorkflowIteratorAction => ({
+ id,
+ name: `Iterator ${id}`,
+ type: WorkflowActionType.ITERATOR,
+ valid: true,
+ nextStepIds,
+ settings: {
+ input: {
+ initialLoopStepIds,
+ shouldContinueOnIterationFailure,
+ } as WorkflowIteratorActionInput,
+ outputSchema: {},
+ errorHandlingOptions: {
+ continueOnFailure: { value: false },
+ retryOnFailure: { value: false },
+ },
+ },
+});
+
+export const createMockIfElseStep = (
+ id: string,
+ branches: StepIfElseBranch[],
+ nextStepIds: string[] = [],
+): WorkflowIfElseAction => ({
+ id,
+ name: `Step ${id}`,
+ type: WorkflowActionType.IF_ELSE,
+ valid: true,
+ nextStepIds,
+ settings: {
+ input: {
+ stepFilterGroups: [],
+ stepFilters: [],
+ branches,
+ },
+ outputSchema: {},
+ errorHandlingOptions: {
+ continueOnFailure: { value: false },
+ retryOnFailure: { value: false },
+ },
+ } as WorkflowIfElseActionSettings,
+});
diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/should-fail-safely.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/should-fail-safely.util.ts
new file mode 100644
index 0000000000..65a54d2a5f
--- /dev/null
+++ b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/should-fail-safely.util.ts
@@ -0,0 +1,45 @@
+import { isDefined } from 'twenty-shared/utils';
+import { StepStatus, type WorkflowRunStepInfos } from 'twenty-shared/workflow';
+
+import { TERMINAL_STEP_STATUSES } from 'src/modules/workflow/workflow-executor/constants/terminal-step-statuses.constant';
+import { isWorkflowIteratorAction } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/guards/is-workflow-iterator-action.guard';
+import { shouldFailSafelyIteratorStep } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/should-fail-safely-iterator-step.util';
+import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
+
+export const shouldFailSafely = ({
+ step,
+ steps,
+ stepInfos,
+}: {
+ step: WorkflowAction;
+ steps: WorkflowAction[];
+ stepInfos: WorkflowRunStepInfos;
+}): boolean => {
+ if (isWorkflowIteratorAction(step)) {
+ return shouldFailSafelyIteratorStep({
+ step,
+ steps,
+ stepInfos,
+ });
+ }
+
+ const parentSteps = steps.filter(
+ (parentStep) =>
+ isDefined(parentStep) && parentStep.nextStepIds?.includes(step.id),
+ );
+
+ if (parentSteps.length === 0) {
+ return false;
+ }
+
+ const areAllParentsTerminal = parentSteps.every((parentStep) =>
+ TERMINAL_STEP_STATUSES.includes(stepInfos[parentStep.id]?.status),
+ );
+
+ const hasFailedSafelyParent = parentSteps.some(
+ (parentStep) =>
+ stepInfos[parentStep.id]?.status === StepStatus.FAILED_SAFELY,
+ );
+
+ return areAllParentsTerminal && hasFailedSafelyParent;
+};
diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/should-skip-step-execution.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/should-skip-step-execution.util.ts
index bd5a745813..741b050012 100644
--- a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/should-skip-step-execution.util.ts
+++ b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/should-skip-step-execution.util.ts
@@ -32,8 +32,9 @@ export const shouldSkipStepExecution = ({
}
return parentSteps.every(
- (step) =>
- stepInfos[step.id]?.status === StepStatus.SKIPPED ||
- stepInfos[step.id]?.status === StepStatus.STOPPED,
+ (parentStep) =>
+ stepInfos[parentStep.id]?.status === StepStatus.SKIPPED ||
+ stepInfos[parentStep.id]?.status === StepStatus.STOPPED ||
+ stepInfos[parentStep.id]?.status === StepStatus.FAILED_SAFELY,
);
};
diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/workflow-should-keep-running.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/workflow-should-keep-running.util.ts
index 40be1746c8..f4804ef47f 100644
--- a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/workflow-should-keep-running.util.ts
+++ b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/workflow-should-keep-running.util.ts
@@ -17,11 +17,14 @@ export const workflowShouldKeepRunning = ({
),
);
- const successStepWithNotStartedExecutableChildren = steps.some(
+ const completedStepWithNotStartedExecutableChildren = steps.some(
(step) =>
- stepInfos[step.id]?.status === StepStatus.SUCCESS &&
+ (stepInfos[step.id]?.status === StepStatus.SUCCESS ||
+ stepInfos[step.id]?.status === StepStatus.FAILED_SAFELY) &&
(step.nextStepIds ?? []).some((nextStepId) => {
- const nextStep = steps.find((step) => step.id === nextStepId);
+ const nextStep = steps.find(
+ (candidateStep) => candidateStep.id === nextStepId,
+ );
if (!nextStep) {
return false;
@@ -40,6 +43,6 @@ export const workflowShouldKeepRunning = ({
);
return (
- runningOrPendingStepExists || successStepWithNotStartedExecutableChildren
+ runningOrPendingStepExists || completedStepWithNotStartedExecutableChildren
);
};
diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/types/workflow-iterator-action-settings.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/types/workflow-iterator-action-settings.type.ts
index 07233cbf39..cac6cbc083 100644
--- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/types/workflow-iterator-action-settings.type.ts
+++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/types/workflow-iterator-action-settings.type.ts
@@ -4,6 +4,7 @@ export type WorkflowIteratorActionInput = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
items?: Array | string;
initialLoopStepIds?: string[];
+ shouldContinueOnIterationFailure?: boolean;
};
export type WorkflowIteratorActionSettings = BaseWorkflowActionSettings & {
diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/find-enclosing-iterator-with-continue-on-failure.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/find-enclosing-iterator-with-continue-on-failure.util.spec.ts
new file mode 100644
index 0000000000..c3b7265047
--- /dev/null
+++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/find-enclosing-iterator-with-continue-on-failure.util.spec.ts
@@ -0,0 +1,107 @@
+import {
+ createMockCodeStep,
+ createMockIteratorStep,
+} from 'src/modules/workflow/workflow-executor/utils/create-mock-workflow-steps.util';
+import { findEnclosingIteratorWithContinueOnFailure } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/find-enclosing-iterator-with-continue-on-failure.util';
+
+describe('findEnclosingIteratorWithContinueOnFailure', () => {
+ it('should return undefined when step is not inside any iterator', () => {
+ const steps = [
+ createMockCodeStep('step1', ['step2']),
+ createMockCodeStep('step2', []),
+ ];
+
+ const result = findEnclosingIteratorWithContinueOnFailure({
+ failedStepId: 'step1',
+ steps,
+ });
+
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined when enclosing iterator does not have the flag', () => {
+ const steps = [
+ createMockIteratorStep('iterator1', ['after'], ['stepA'], false),
+ createMockCodeStep('stepA', ['stepB']),
+ createMockCodeStep('stepB', ['iterator1']),
+ createMockCodeStep('after', []),
+ ];
+
+ const result = findEnclosingIteratorWithContinueOnFailure({
+ failedStepId: 'stepA',
+ steps,
+ });
+
+ expect(result).toBeUndefined();
+ });
+
+ it('should return the enclosing iterator when it has the flag', () => {
+ const steps = [
+ createMockIteratorStep('iterator1', ['after'], ['stepA'], true),
+ createMockCodeStep('stepA', ['stepB']),
+ createMockCodeStep('stepB', ['iterator1']),
+ createMockCodeStep('after', []),
+ ];
+
+ const result = findEnclosingIteratorWithContinueOnFailure({
+ failedStepId: 'stepA',
+ steps,
+ });
+
+ expect(result?.id).toBe('iterator1');
+ });
+
+ it('should return the innermost iterator with the flag in nested iterators', () => {
+ const steps = [
+ createMockIteratorStep(
+ 'outerIterator',
+ ['after'],
+ ['innerIterator'],
+ true,
+ ),
+ createMockIteratorStep(
+ 'innerIterator',
+ ['outerIterator'],
+ ['stepA'],
+ true,
+ ),
+ createMockCodeStep('stepA', ['stepB']),
+ createMockCodeStep('stepB', ['innerIterator']),
+ createMockCodeStep('after', []),
+ ];
+
+ const result = findEnclosingIteratorWithContinueOnFailure({
+ failedStepId: 'stepA',
+ steps,
+ });
+
+ expect(result?.id).toBe('innerIterator');
+ });
+
+ it('should skip inner iterator without flag and return outer with flag', () => {
+ const steps = [
+ createMockIteratorStep(
+ 'outerIterator',
+ ['after'],
+ ['innerIterator'],
+ true,
+ ),
+ createMockIteratorStep(
+ 'innerIterator',
+ ['outerIterator'],
+ ['stepA'],
+ false,
+ ),
+ createMockCodeStep('stepA', ['stepB']),
+ createMockCodeStep('stepB', ['innerIterator']),
+ createMockCodeStep('after', []),
+ ];
+
+ const result = findEnclosingIteratorWithContinueOnFailure({
+ failedStepId: 'stepA',
+ steps,
+ });
+
+ expect(result?.id).toBe('outerIterator');
+ });
+});
diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/get-all-step-ids-in-loop.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/get-all-step-ids-in-loop.util.spec.ts
index 21833ea699..454b34ea05 100644
--- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/get-all-step-ids-in-loop.util.spec.ts
+++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/get-all-step-ids-in-loop.util.spec.ts
@@ -1,90 +1,18 @@
-import { type StepIfElseBranch } from 'twenty-shared/workflow';
-
-import { type WorkflowCodeActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/code/types/workflow-code-action-settings.type';
-import { type WorkflowIfElseActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/if-else/types/workflow-if-else-action-settings.type';
-import { type WorkflowIteratorActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/types/workflow-iterator-action-settings.type';
-import { getAllStepIdsInLoop } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/get-all-step-ids-in-loop.util';
import {
- type WorkflowAction,
- WorkflowActionType,
- type WorkflowCodeAction,
- type WorkflowIfElseAction,
- type WorkflowIteratorAction,
-} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
+ createMockCodeStep,
+ createMockIfElseStep,
+ createMockIteratorStep,
+} from 'src/modules/workflow/workflow-executor/utils/create-mock-workflow-steps.util';
+import { getAllStepIdsInLoop } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/get-all-step-ids-in-loop.util';
describe('getAllStepIdsInLoop', () => {
- const createCodeMockStep = (
- id: string,
- nextStepIds: string[],
- ): WorkflowCodeAction => ({
- id,
- name: `Step ${id}`,
- type: WorkflowActionType.CODE,
- valid: true,
- nextStepIds,
- settings: {
- input: {},
- outputSchema: {},
- errorHandlingOptions: {
- continueOnFailure: { value: false },
- retryOnFailure: { value: false },
- },
- } as WorkflowCodeActionSettings,
- });
-
- const createIteratorMockStep = (
- id: string,
- nextStepIds: string[],
- initialLoopStepIds: string[],
- ): WorkflowIteratorAction => ({
- id,
- name: `Step ${id}`,
- type: WorkflowActionType.ITERATOR,
- valid: true,
- nextStepIds,
- settings: {
- input: (initialLoopStepIds
- ? ({ initialLoopStepIds } as WorkflowIteratorActionInput)
- : {}) as WorkflowIteratorActionInput,
- outputSchema: {},
- errorHandlingOptions: {
- continueOnFailure: { value: false },
- retryOnFailure: { value: false },
- },
- },
- });
-
- const createIfElseMockStep = (
- id: string,
- branches: StepIfElseBranch[],
- nextStepIds: string[] = [],
- ): WorkflowIfElseAction => ({
- id,
- name: `Step ${id}`,
- type: WorkflowActionType.IF_ELSE,
- valid: true,
- nextStepIds,
- settings: {
- input: {
- stepFilterGroups: [],
- stepFilters: [],
- branches,
- },
- outputSchema: {},
- errorHandlingOptions: {
- continueOnFailure: { value: false },
- retryOnFailure: { value: false },
- },
- } as WorkflowIfElseActionSettings,
- });
-
describe('simple loop scenarios', () => {
it('should return all step IDs in a simple linear loop', () => {
- const steps: WorkflowAction[] = [
- createIteratorMockStep('iterator1', ['step2'], []),
- createCodeMockStep('step2', ['step3']),
- createCodeMockStep('step3', ['step4']),
- createCodeMockStep('step4', ['iterator1']), // loops back
+ const steps = [
+ createMockIteratorStep('iterator1', ['step2'], []),
+ createMockCodeStep('step2', ['step3']),
+ createMockCodeStep('step3', ['step4']),
+ createMockCodeStep('step4', ['iterator1']), // loops back
];
const result = getAllStepIdsInLoop({
@@ -97,12 +25,12 @@ describe('getAllStepIdsInLoop', () => {
});
it('should handle loop with branching paths that converge', () => {
- const steps: WorkflowAction[] = [
- createIteratorMockStep('iterator1', ['step2'], []),
- createCodeMockStep('step2', ['step3', 'step4']),
- createCodeMockStep('step3', ['step5']),
- createCodeMockStep('step4', ['step5']),
- createCodeMockStep('step5', ['iterator1']), // loops back
+ const steps = [
+ createMockIteratorStep('iterator1', ['step2'], []),
+ createMockCodeStep('step2', ['step3', 'step4']),
+ createMockCodeStep('step3', ['step5']),
+ createMockCodeStep('step4', ['step5']),
+ createMockCodeStep('step5', ['iterator1']), // loops back
];
const result = getAllStepIdsInLoop({
@@ -115,11 +43,11 @@ describe('getAllStepIdsInLoop', () => {
});
it('should handle loop with branching paths that converge to the iterator', () => {
- const steps: WorkflowAction[] = [
- createIteratorMockStep('iterator1', ['step2'], []),
- createCodeMockStep('step2', ['step3', 'step4']),
- createCodeMockStep('step3', ['iterator1']),
- createCodeMockStep('step4', ['iterator1']),
+ const steps = [
+ createMockIteratorStep('iterator1', ['step2'], []),
+ createMockCodeStep('step2', ['step3', 'step4']),
+ createMockCodeStep('step3', ['iterator1']),
+ createMockCodeStep('step4', ['iterator1']),
];
const result = getAllStepIdsInLoop({
@@ -132,11 +60,11 @@ describe('getAllStepIdsInLoop', () => {
});
it('should handle multiple entry points to the loop', () => {
- const steps: WorkflowAction[] = [
- createIteratorMockStep('iterator1', ['step2', 'step3'], []),
- createCodeMockStep('step2', ['step4']),
- createCodeMockStep('step3', ['step4']),
- createCodeMockStep('step4', ['iterator1']), // loops back
+ const steps = [
+ createMockIteratorStep('iterator1', ['step2', 'step3'], []),
+ createMockCodeStep('step2', ['step4']),
+ createMockCodeStep('step3', ['step4']),
+ createMockCodeStep('step4', ['iterator1']), // loops back
];
const result = getAllStepIdsInLoop({
@@ -151,13 +79,13 @@ describe('getAllStepIdsInLoop', () => {
describe('nested iterator scenarios', () => {
it('should handle a nested iterator within a loop', () => {
- const steps: WorkflowAction[] = [
- createIteratorMockStep('iterator1', ['step2'], []),
- createCodeMockStep('step2', ['nested_iterator']),
- createIteratorMockStep('nested_iterator', ['step5'], ['step3']),
- createCodeMockStep('step3', ['step4']),
- createCodeMockStep('step4', ['nested_iterator']), // loops back to nested iterator
- createCodeMockStep('step5', ['iterator1']), // loops back to main iterator
+ const steps = [
+ createMockIteratorStep('iterator1', ['step2'], []),
+ createMockCodeStep('step2', ['nested_iterator']),
+ createMockIteratorStep('nested_iterator', ['step5'], ['step3']),
+ createMockCodeStep('step3', ['step4']),
+ createMockCodeStep('step4', ['nested_iterator']), // loops back to nested iterator
+ createMockCodeStep('step5', ['iterator1']), // loops back to main iterator
];
const result = getAllStepIdsInLoop({
@@ -176,15 +104,15 @@ describe('getAllStepIdsInLoop', () => {
});
it('should handle multiple levels of nested iterators', () => {
- const steps: WorkflowAction[] = [
- createIteratorMockStep('iterator1', ['step2'], []),
- createCodeMockStep('step2', ['nested_iterator1']),
- createIteratorMockStep('nested_iterator1', ['step6'], ['step3']),
- createCodeMockStep('step3', ['nested_iterator2']),
- createIteratorMockStep('nested_iterator2', ['step5'], ['step4']),
- createCodeMockStep('step4', ['nested_iterator2']), // loops back to nested iterator2
- createCodeMockStep('step5', ['nested_iterator1']), // loops back to nested iterator1
- createCodeMockStep('step6', ['iterator1']), // loops back to main iterator
+ const steps = [
+ createMockIteratorStep('iterator1', ['step2'], []),
+ createMockCodeStep('step2', ['nested_iterator1']),
+ createMockIteratorStep('nested_iterator1', ['step6'], ['step3']),
+ createMockCodeStep('step3', ['nested_iterator2']),
+ createMockIteratorStep('nested_iterator2', ['step5'], ['step4']),
+ createMockCodeStep('step4', ['nested_iterator2']), // loops back to nested iterator2
+ createMockCodeStep('step5', ['nested_iterator1']), // loops back to nested iterator1
+ createMockCodeStep('step6', ['iterator1']), // loops back to main iterator
];
const result = getAllStepIdsInLoop({
@@ -207,14 +135,14 @@ describe('getAllStepIdsInLoop', () => {
describe('if-else scenarios', () => {
it('should include steps in all if-else branches within a loop', () => {
- const steps: WorkflowAction[] = [
- createIteratorMockStep('iterator1', [], ['ifElse1']),
- createIfElseMockStep('ifElse1', [
+ const steps = [
+ createMockIteratorStep('iterator1', [], ['ifElse1']),
+ createMockIfElseStep('ifElse1', [
{ id: 'branch-if', filterGroupId: 'fg1', nextStepIds: ['stepA'] },
{ id: 'branch-else', nextStepIds: ['stepB'] },
]),
- createCodeMockStep('stepA', ['iterator1']),
- createCodeMockStep('stepB', ['iterator1']),
+ createMockCodeStep('stepA', ['iterator1']),
+ createMockCodeStep('stepB', ['iterator1']),
];
const result = getAllStepIdsInLoop({
@@ -230,15 +158,15 @@ describe('getAllStepIdsInLoop', () => {
});
it('should include deeply nested steps inside if-else branches', () => {
- const steps: WorkflowAction[] = [
- createIteratorMockStep('iterator1', [], ['ifElse1']),
- createIfElseMockStep('ifElse1', [
+ const steps = [
+ createMockIteratorStep('iterator1', [], ['ifElse1']),
+ createMockIfElseStep('ifElse1', [
{ id: 'branch-if', filterGroupId: 'fg1', nextStepIds: ['stepA'] },
{ id: 'branch-else', nextStepIds: ['stepB'] },
]),
- createCodeMockStep('stepA', ['stepC']),
- createCodeMockStep('stepB', ['stepC']),
- createCodeMockStep('stepC', ['iterator1']),
+ createMockCodeStep('stepA', ['stepC']),
+ createMockCodeStep('stepB', ['stepC']),
+ createMockCodeStep('stepC', ['iterator1']),
];
const result = getAllStepIdsInLoop({
@@ -254,16 +182,16 @@ describe('getAllStepIdsInLoop', () => {
});
it('should handle if-else with steps before and after within a loop', () => {
- const steps: WorkflowAction[] = [
- createIteratorMockStep('iterator1', [], ['step1']),
- createCodeMockStep('step1', ['ifElse1']),
- createIfElseMockStep('ifElse1', [
+ const steps = [
+ createMockIteratorStep('iterator1', [], ['step1']),
+ createMockCodeStep('step1', ['ifElse1']),
+ createMockIfElseStep('ifElse1', [
{ id: 'branch-if', filterGroupId: 'fg1', nextStepIds: ['stepA'] },
{ id: 'branch-else', nextStepIds: ['stepB'] },
]),
- createCodeMockStep('stepA', ['step2']),
- createCodeMockStep('stepB', ['step2']),
- createCodeMockStep('step2', ['iterator1']),
+ createMockCodeStep('stepA', ['step2']),
+ createMockCodeStep('stepB', ['step2']),
+ createMockCodeStep('step2', ['iterator1']),
];
const result = getAllStepIdsInLoop({
@@ -281,9 +209,7 @@ describe('getAllStepIdsInLoop', () => {
describe('edge cases', () => {
it('should handle empty initial loop step IDs', () => {
- const steps: WorkflowAction[] = [
- createIteratorMockStep('iterator1', ['step2'], []),
- ];
+ const steps = [createMockIteratorStep('iterator1', ['step2'], [])];
const result = getAllStepIdsInLoop({
iteratorStepId: 'iterator1',
@@ -295,9 +221,9 @@ describe('getAllStepIdsInLoop', () => {
});
it('should handle steps with no nextStepIds', () => {
- const steps: WorkflowAction[] = [
- createIteratorMockStep('iterator1', ['step2'], []),
- createCodeMockStep('step2', []), // no nextStepIds
+ const steps = [
+ createMockIteratorStep('iterator1', ['step2'], []),
+ createMockCodeStep('step2', []), // no nextStepIds
];
const result = getAllStepIdsInLoop({
@@ -310,11 +236,11 @@ describe('getAllStepIdsInLoop', () => {
});
it('should prevent infinite loops with circular references', () => {
- const steps: WorkflowAction[] = [
- createIteratorMockStep('iterator1', ['step2'], []),
- createCodeMockStep('step2', ['step3']),
- createCodeMockStep('step3', ['step4']),
- createCodeMockStep('step4', ['step2']), // circular reference
+ const steps = [
+ createMockIteratorStep('iterator1', ['step2'], []),
+ createMockCodeStep('step2', ['step3']),
+ createMockCodeStep('step3', ['step4']),
+ createMockCodeStep('step4', ['step2']), // circular reference
];
const result = getAllStepIdsInLoop({
diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/should-execute-iterator-step.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/should-execute-iterator-step.util.spec.ts
index a27399d6c1..7cd5d3ebdd 100644
--- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/should-execute-iterator-step.util.spec.ts
+++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/should-execute-iterator-step.util.spec.ts
@@ -1,13 +1,15 @@
import { StepStatus } from 'twenty-shared/workflow';
+import {
+ createMockCodeStep,
+ createMockIteratorStep,
+} from 'src/modules/workflow/workflow-executor/utils/create-mock-workflow-steps.util';
import { shouldExecuteIteratorStep } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/should-execute-iterator-step.util';
import {
type WorkflowAction,
type WorkflowIteratorAction,
- WorkflowActionType,
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
-// Mock the getAllStepIdsInLoop utility
jest.mock(
'src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/get-all-step-ids-in-loop.util',
() => ({
@@ -20,64 +22,21 @@ const { getAllStepIdsInLoop } = jest.requireMock(
);
describe('shouldExecuteIteratorStep', () => {
- const createMockIteratorStep = (
- id: string,
- initialLoopStepIds: string[] = [],
- ): WorkflowIteratorAction => ({
- id,
- name: 'Iterator Step',
- type: WorkflowActionType.ITERATOR,
- settings: {
- input: {
- initialLoopStepIds,
- items: [],
- },
- errorHandlingOptions: {
- continueOnFailure: { value: false },
- retryOnFailure: { value: false },
- },
- outputSchema: {},
- },
- valid: true,
- nextStepIds: [],
- });
-
- const createMockStep = (
- id: string,
- nextStepIds: string[] = [],
- ): WorkflowAction => ({
- id,
- name: 'Mock Step',
- type: WorkflowActionType.CODE,
- settings: {
- input: {
- logicFunctionId: 'mock-function-id',
- logicFunctionInput: {},
- },
- errorHandlingOptions: {
- continueOnFailure: { value: false },
- retryOnFailure: { value: false },
- },
- outputSchema: {},
- },
- valid: true,
- nextStepIds,
- });
-
beforeEach(() => {
jest.clearAllMocks();
});
describe('when the step has not been started', () => {
it('should return true when all parent steps are successful', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', [
- 'step-1',
- 'step-2',
- ]);
+ const iteratorStep = createMockIteratorStep(
+ 'iterator-1',
+ [],
+ ['step-1', 'step-2'],
+ );
const steps = [
- createMockStep('step-1', ['iterator-1']),
- createMockStep('step-2', ['iterator-1']),
- createMockStep('step-3', ['step-4']),
+ createMockCodeStep('step-1', ['iterator-1']),
+ createMockCodeStep('step-2', ['iterator-1']),
+ createMockCodeStep('step-3', ['step-4']),
iteratorStep,
];
const stepInfos = {
@@ -104,13 +63,14 @@ describe('shouldExecuteIteratorStep', () => {
});
it('should return false when some parent steps not in loop have failed', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', [
- 'step-1',
- 'step-2',
- ]);
+ const iteratorStep = createMockIteratorStep(
+ 'iterator-1',
+ [],
+ ['step-1', 'step-2'],
+ );
const steps = [
- createMockStep('step-1', ['iterator-1']),
- createMockStep('step-2', ['iterator-1']),
+ createMockCodeStep('step-1', ['iterator-1']),
+ createMockCodeStep('step-2', ['iterator-1']),
iteratorStep,
];
const stepInfos = {
@@ -130,13 +90,14 @@ describe('shouldExecuteIteratorStep', () => {
});
it('should return false when some parent steps are not started', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', [
- 'step-1',
- 'step-2',
- ]);
+ const iteratorStep = createMockIteratorStep(
+ 'iterator-1',
+ [],
+ ['step-1', 'step-2'],
+ );
const steps = [
- createMockStep('step-1', ['iterator-1']),
- createMockStep('step-2', ['iterator-1']),
+ createMockCodeStep('step-1', ['iterator-1']),
+ createMockCodeStep('step-2', ['iterator-1']),
iteratorStep,
];
const stepInfos = {
@@ -156,10 +117,10 @@ describe('shouldExecuteIteratorStep', () => {
});
it('should return true even if loop step is not started', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
+ const iteratorStep = createMockIteratorStep('iterator-1', [], ['step-1']);
const steps = [
- createMockStep('step-1', ['iterator-1']), // In loop
- createMockStep('step-2', ['iterator-1']), // Not in loop
+ createMockCodeStep('step-1', ['iterator-1']), // In loop
+ createMockCodeStep('step-2', ['iterator-1']), // Not in loop
iteratorStep,
];
const stepInfos = {
@@ -180,10 +141,10 @@ describe('shouldExecuteIteratorStep', () => {
});
it('should return true when there are no parent steps targeting the iterator', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', []);
+ const iteratorStep = createMockIteratorStep('iterator-1');
const steps = [
- createMockStep('step-1', ['step-2']),
- createMockStep('step-2', []),
+ createMockCodeStep('step-1', ['step-2']),
+ createMockCodeStep('step-2', []),
iteratorStep,
];
const stepInfos = {
@@ -203,9 +164,9 @@ describe('shouldExecuteIteratorStep', () => {
});
it('should handle undefined steps gracefully', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
+ const iteratorStep = createMockIteratorStep('iterator-1', [], ['step-1']);
const steps = [
- createMockStep('step-1', ['iterator-1']),
+ createMockCodeStep('step-1', ['iterator-1']),
undefined as unknown as WorkflowAction,
iteratorStep,
];
@@ -225,16 +186,16 @@ describe('shouldExecuteIteratorStep', () => {
});
it('should work correctly with multiple steps targeting the same iterator', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', [
- 'step-1',
- 'step-2',
- 'step-3',
- ]);
+ const iteratorStep = createMockIteratorStep(
+ 'iterator-1',
+ [],
+ ['step-1', 'step-2', 'step-3'],
+ );
const steps = [
- createMockStep('step-1', ['iterator-1']),
- createMockStep('step-2', ['iterator-1']),
- createMockStep('step-3', ['iterator-1']),
- createMockStep('step-4', ['step-5']), // Not targeting iterator
+ createMockCodeStep('step-1', ['iterator-1']),
+ createMockCodeStep('step-2', ['iterator-1']),
+ createMockCodeStep('step-3', ['iterator-1']),
+ createMockCodeStep('step-4', ['step-5']), // Not targeting iterator
iteratorStep,
];
const stepInfos = {
@@ -258,10 +219,10 @@ describe('shouldExecuteIteratorStep', () => {
describe('when the step has been started', () => {
it('should return true if all the steps targeting the iterator have been successful', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
+ const iteratorStep = createMockIteratorStep('iterator-1', [], ['step-1']);
const steps = [
- createMockStep('step-1', ['iterator-1']),
- createMockStep('step-2', ['iterator-1']),
+ createMockCodeStep('step-1', ['iterator-1']),
+ createMockCodeStep('step-2', ['iterator-1']),
iteratorStep,
];
const stepInfos = {
@@ -282,10 +243,10 @@ describe('shouldExecuteIteratorStep', () => {
});
it('should return false if some of the steps targeting the iterator have failed', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
+ const iteratorStep = createMockIteratorStep('iterator-1', [], ['step-1']);
const steps = [
- createMockStep('step-1', ['iterator-1']),
- createMockStep('step-2', ['iterator-1']),
+ createMockCodeStep('step-1', ['iterator-1']),
+ createMockCodeStep('step-2', ['iterator-1']),
iteratorStep,
];
const stepInfos = {
@@ -306,10 +267,10 @@ describe('shouldExecuteIteratorStep', () => {
});
it('should return false if some of the steps targeting the iterator are still running', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
+ const iteratorStep = createMockIteratorStep('iterator-1', [], ['step-1']);
const steps = [
- createMockStep('step-1', ['iterator-1']),
- createMockStep('step-2', ['iterator-1']),
+ createMockCodeStep('step-1', ['iterator-1']),
+ createMockCodeStep('step-2', ['iterator-1']),
iteratorStep,
];
const stepInfos = {
@@ -330,8 +291,8 @@ describe('shouldExecuteIteratorStep', () => {
});
it('should return true when there are no steps targeting the iterator', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', []);
- const steps = [createMockStep('step-1', ['step-2']), iteratorStep];
+ const iteratorStep = createMockIteratorStep('iterator-1');
+ const steps = [createMockCodeStep('step-1', ['step-2']), iteratorStep];
const stepInfos = {
'iterator-1': { status: StepStatus.RUNNING }, // Iterator has been started
'step-1': { status: StepStatus.SUCCESS },
@@ -349,10 +310,10 @@ describe('shouldExecuteIteratorStep', () => {
});
it('should check all steps targeting iterator including loop steps when iterator has been started', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
+ const iteratorStep = createMockIteratorStep('iterator-1', [], ['step-1']);
const steps = [
- createMockStep('step-1', ['iterator-1']), // In loop and targeting iterator
- createMockStep('step-2', ['iterator-1']), // Not in loop but targeting iterator
+ createMockCodeStep('step-1', ['iterator-1']), // In loop and targeting iterator
+ createMockCodeStep('step-2', ['iterator-1']), // Not in loop but targeting iterator
iteratorStep,
];
const stepInfos = {
@@ -373,10 +334,10 @@ describe('shouldExecuteIteratorStep', () => {
});
it('should return false when loop step has NOT_STARTED status and iterator has been started', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
+ const iteratorStep = createMockIteratorStep('iterator-1', [], ['step-1']);
const steps = [
- createMockStep('step-1', ['iterator-1']), // In loop
- createMockStep('step-2', ['iterator-1']), // Not in loop
+ createMockCodeStep('step-1', ['iterator-1']), // In loop
+ createMockCodeStep('step-2', ['iterator-1']), // Not in loop
iteratorStep,
];
const stepInfos = {
@@ -399,10 +360,10 @@ describe('shouldExecuteIteratorStep', () => {
describe('edge cases', () => {
it('should handle empty step info for parent steps', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
+ const iteratorStep = createMockIteratorStep('iterator-1', [], ['step-1']);
const steps = [
- createMockStep('step-1', ['iterator-1']),
- createMockStep('step-2', ['iterator-1']),
+ createMockCodeStep('step-1', ['iterator-1']),
+ createMockCodeStep('step-2', ['iterator-1']),
iteratorStep,
];
const stepInfos = {
@@ -423,17 +384,17 @@ describe('shouldExecuteIteratorStep', () => {
});
it('should work with complex loop structures', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', [
- 'step-1',
- 'step-2',
- 'step-3',
- ]);
+ const iteratorStep = createMockIteratorStep(
+ 'iterator-1',
+ [],
+ ['step-1', 'step-2', 'step-3'],
+ );
const steps = [
- createMockStep('step-1', ['iterator-1']), // In loop
- createMockStep('step-2', ['iterator-1']), // In loop
- createMockStep('step-3', ['iterator-1']), // In loop
- createMockStep('step-4', ['iterator-1']), // Not in loop
- createMockStep('step-5', ['iterator-1']), // Not in loop
+ createMockCodeStep('step-1', ['iterator-1']), // In loop
+ createMockCodeStep('step-2', ['iterator-1']), // In loop
+ createMockCodeStep('step-3', ['iterator-1']), // In loop
+ createMockCodeStep('step-4', ['iterator-1']), // Not in loop
+ createMockCodeStep('step-5', ['iterator-1']), // Not in loop
iteratorStep,
];
const stepInfos = {
@@ -456,15 +417,16 @@ describe('shouldExecuteIteratorStep', () => {
});
it('should return false with complex loop when one non-loop parent is not successful', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', [
- 'step-1',
- 'step-2',
- ]);
+ const iteratorStep = createMockIteratorStep(
+ 'iterator-1',
+ [],
+ ['step-1', 'step-2'],
+ );
const steps = [
- createMockStep('step-1', ['iterator-1']), // In loop
- createMockStep('step-2', ['iterator-1']), // In loop
- createMockStep('step-3', ['iterator-1']), // Not in loop
- createMockStep('step-4', ['iterator-1']), // Not in loop
+ createMockCodeStep('step-1', ['iterator-1']), // In loop
+ createMockCodeStep('step-2', ['iterator-1']), // In loop
+ createMockCodeStep('step-3', ['iterator-1']), // Not in loop
+ createMockCodeStep('step-4', ['iterator-1']), // Not in loop
iteratorStep,
];
const stepInfos = {
@@ -501,7 +463,10 @@ describe('shouldExecuteIteratorStep', () => {
},
} as WorkflowIteratorAction;
- const steps = [createMockStep('step-1', ['iterator-1']), iteratorStep];
+ const steps = [
+ createMockCodeStep('step-1', ['iterator-1']),
+ iteratorStep,
+ ];
const stepInfos = {
'step-1': { status: StepStatus.SUCCESS },
};
diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/should-fail-safely-iterator-step.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/should-fail-safely-iterator-step.util.spec.ts
new file mode 100644
index 0000000000..4604eab07f
--- /dev/null
+++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/should-fail-safely-iterator-step.util.spec.ts
@@ -0,0 +1,150 @@
+import { StepStatus } from 'twenty-shared/workflow';
+
+import {
+ createMockCodeStep,
+ createMockIteratorStep,
+} from 'src/modules/workflow/workflow-executor/utils/create-mock-workflow-steps.util';
+import { shouldFailSafelyIteratorStep } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/should-fail-safely-iterator-step.util';
+
+describe('shouldFailSafelyIteratorStep', () => {
+ describe('unstarted iterator', () => {
+ it('should return false when external parent is SUCCESS', () => {
+ const parent = createMockCodeStep('parent', ['iterator1']);
+ const iterator = createMockIteratorStep('iterator1', [], ['stepA'], true);
+
+ const stepA = createMockCodeStep('stepA', ['iterator1']);
+ const steps = [parent, iterator, stepA];
+
+ const result = shouldFailSafelyIteratorStep({
+ step: iterator,
+ steps,
+ stepInfos: {
+ parent: { status: StepStatus.SUCCESS, result: {} },
+ },
+ });
+
+ expect(result).toBe(false);
+ });
+
+ it('should return true when external parent is FAILED_SAFELY', () => {
+ const parent = createMockCodeStep('parent', ['iterator1']);
+ const iterator = createMockIteratorStep('iterator1', [], ['stepA'], true);
+ const stepA = createMockCodeStep('stepA', ['iterator1']);
+ const steps = [parent, iterator, stepA];
+
+ const result = shouldFailSafelyIteratorStep({
+ step: iterator,
+ steps,
+ stepInfos: {
+ parent: { status: StepStatus.FAILED_SAFELY },
+ },
+ });
+
+ expect(result).toBe(true);
+ });
+
+ it('should return false when no external parents', () => {
+ const iterator = createMockIteratorStep('iterator1', [], ['stepA'], true);
+ const stepA = createMockCodeStep('stepA', ['iterator1']);
+ const steps = [iterator, stepA];
+
+ const result = shouldFailSafelyIteratorStep({
+ step: iterator,
+ steps,
+ stepInfos: {},
+ });
+
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('started iterator with flag', () => {
+ it('should return false when failure originated from own loop', () => {
+ const iterator = createMockIteratorStep('iterator1', [], ['stepA'], true);
+ const stepA = createMockCodeStep('stepA', ['stepB']);
+ const stepB = createMockCodeStep('stepB', ['iterator1']);
+ const steps = [iterator, stepA, stepB];
+
+ const result = shouldFailSafelyIteratorStep({
+ step: iterator,
+ steps,
+ stepInfos: {
+ iterator1: { status: StepStatus.RUNNING },
+ stepA: { status: StepStatus.FAILED_SAFELY, error: 'actual error' },
+ stepB: { status: StepStatus.FAILED_SAFELY },
+ },
+ });
+
+ expect(result).toBe(false);
+ });
+
+ it('should return false when loop-back parents have no FAILED_SAFELY', () => {
+ const iterator = createMockIteratorStep('iterator1', [], ['stepA'], true);
+ const stepA = createMockCodeStep('stepA', ['stepB']);
+ const stepB = createMockCodeStep('stepB', ['iterator1']);
+ const steps = [iterator, stepA, stepB];
+
+ const result = shouldFailSafelyIteratorStep({
+ step: iterator,
+ steps,
+ stepInfos: {
+ iterator1: { status: StepStatus.RUNNING },
+ stepA: { status: StepStatus.SUCCESS, result: {} },
+ stepB: { status: StepStatus.SUCCESS, result: {} },
+ },
+ });
+
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('started iterator without flag', () => {
+ it('should return true when loop-back parent is FAILED_SAFELY', () => {
+ const iterator = createMockIteratorStep(
+ 'iterator1',
+ [],
+ ['stepA'],
+ false,
+ );
+ const stepA = createMockCodeStep('stepA', ['stepB']);
+ const stepB = createMockCodeStep('stepB', ['iterator1']);
+ const steps = [iterator, stepA, stepB];
+
+ const result = shouldFailSafelyIteratorStep({
+ step: iterator,
+ steps,
+ stepInfos: {
+ iterator1: { status: StepStatus.RUNNING },
+ stepA: { status: StepStatus.FAILED_SAFELY, error: 'err' },
+ stepB: { status: StepStatus.FAILED_SAFELY },
+ },
+ });
+
+ expect(result).toBe(true);
+ });
+
+ it('should return false when loop-back parents are not all terminal', () => {
+ const iterator = createMockIteratorStep(
+ 'iterator1',
+ [],
+ ['stepA'],
+ false,
+ );
+ const stepA = createMockCodeStep('stepA', ['stepB']);
+ const stepB = createMockCodeStep('stepB', ['iterator1']);
+ const steps = [iterator, stepA, stepB];
+
+ const result = shouldFailSafelyIteratorStep({
+ step: iterator,
+ steps,
+ stepInfos: {
+ iterator1: { status: StepStatus.RUNNING },
+ stepA: { status: StepStatus.FAILED_SAFELY, error: 'err' },
+ stepB: { status: StepStatus.RUNNING },
+ },
+ });
+
+ expect(result).toBe(false);
+ });
+ });
+});
diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/should-skip-iterator-step-execution.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/should-skip-iterator-step-execution.util.spec.ts
index a4b2542f75..00e3249c28 100644
--- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/should-skip-iterator-step-execution.util.spec.ts
+++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/should-skip-iterator-step-execution.util.spec.ts
@@ -1,13 +1,15 @@
import { StepStatus } from 'twenty-shared/workflow';
+import {
+ createMockCodeStep,
+ createMockIteratorStep,
+} from 'src/modules/workflow/workflow-executor/utils/create-mock-workflow-steps.util';
import { shouldSkipIteratorStepExecution } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/should-skip-iterator-step-execution.util';
import {
type WorkflowAction,
type WorkflowIteratorAction,
- WorkflowActionType,
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
-// Mock the getAllStepIdsInLoop utility
jest.mock(
'src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/get-all-step-ids-in-loop.util',
() => ({
@@ -20,60 +22,16 @@ const { getAllStepIdsInLoop } = jest.requireMock(
);
describe('shouldSkipIteratorStepExecution', () => {
- const createMockIteratorStep = (
- id: string,
- initialLoopStepIds: string[] = [],
- ): WorkflowIteratorAction => ({
- id,
- name: 'Iterator Step',
- type: WorkflowActionType.ITERATOR,
- settings: {
- input: {
- initialLoopStepIds,
- items: [],
- },
- errorHandlingOptions: {
- continueOnFailure: { value: false },
- retryOnFailure: { value: false },
- },
- outputSchema: {},
- },
- valid: true,
- nextStepIds: [],
- });
-
- const createMockStep = (
- id: string,
- nextStepIds: string[] = [],
- ): WorkflowAction => ({
- id,
- name: 'Mock Step',
- type: WorkflowActionType.CODE,
- settings: {
- input: {
- logicFunctionId: 'mock-function-id',
- logicFunctionInput: {},
- },
- errorHandlingOptions: {
- continueOnFailure: { value: false },
- retryOnFailure: { value: false },
- },
- outputSchema: {},
- },
- valid: true,
- nextStepIds,
- });
-
beforeEach(() => {
jest.clearAllMocks();
});
describe('when the iterator has not been started', () => {
it('should return true when all parent steps are skipped', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
+ const iteratorStep = createMockIteratorStep('iterator-1', [], ['step-1']);
const steps = [
- createMockStep('step-1', ['iterator-1']),
- createMockStep('step-2', ['iterator-1']),
+ createMockCodeStep('step-1', ['iterator-1']),
+ createMockCodeStep('step-2', ['iterator-1']),
iteratorStep,
];
const stepInfos = {
@@ -93,10 +51,10 @@ describe('shouldSkipIteratorStepExecution', () => {
});
it('should return true when all parent steps are stopped', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
+ const iteratorStep = createMockIteratorStep('iterator-1', [], ['step-1']);
const steps = [
- createMockStep('step-1', ['iterator-1']),
- createMockStep('step-2', ['iterator-1']),
+ createMockCodeStep('step-1', ['iterator-1']),
+ createMockCodeStep('step-2', ['iterator-1']),
iteratorStep,
];
const stepInfos = {
@@ -116,10 +74,10 @@ describe('shouldSkipIteratorStepExecution', () => {
});
it('should return true when parent steps are mix of skipped and stopped', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
+ const iteratorStep = createMockIteratorStep('iterator-1', [], ['step-1']);
const steps = [
- createMockStep('step-1', ['iterator-1']),
- createMockStep('step-2', ['iterator-1']),
+ createMockCodeStep('step-1', ['iterator-1']),
+ createMockCodeStep('step-2', ['iterator-1']),
iteratorStep,
];
const stepInfos = {
@@ -139,10 +97,10 @@ describe('shouldSkipIteratorStepExecution', () => {
});
it('should return false when at least one parent step is successful', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
+ const iteratorStep = createMockIteratorStep('iterator-1', [], ['step-1']);
const steps = [
- createMockStep('step-1', ['iterator-1']),
- createMockStep('step-2', ['iterator-1']),
+ createMockCodeStep('step-1', ['iterator-1']),
+ createMockCodeStep('step-2', ['iterator-1']),
iteratorStep,
];
const stepInfos = {
@@ -162,10 +120,10 @@ describe('shouldSkipIteratorStepExecution', () => {
});
it('should return false when at least one parent step is failed', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
+ const iteratorStep = createMockIteratorStep('iterator-1', [], ['step-1']);
const steps = [
- createMockStep('step-1', ['iterator-1']),
- createMockStep('step-2', ['iterator-1']),
+ createMockCodeStep('step-1', ['iterator-1']),
+ createMockCodeStep('step-2', ['iterator-1']),
iteratorStep,
];
const stepInfos = {
@@ -185,10 +143,10 @@ describe('shouldSkipIteratorStepExecution', () => {
});
it('should return false when at least one parent step is not started', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
+ const iteratorStep = createMockIteratorStep('iterator-1', [], ['step-1']);
const steps = [
- createMockStep('step-1', ['iterator-1']),
- createMockStep('step-2', ['iterator-1']),
+ createMockCodeStep('step-1', ['iterator-1']),
+ createMockCodeStep('step-2', ['iterator-1']),
iteratorStep,
];
const stepInfos = {
@@ -208,10 +166,10 @@ describe('shouldSkipIteratorStepExecution', () => {
});
it('should only check parent steps not in loop', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
+ const iteratorStep = createMockIteratorStep('iterator-1', [], ['step-1']);
const steps = [
- createMockStep('step-1', ['iterator-1']), // In loop
- createMockStep('step-2', ['iterator-1']), // Not in loop
+ createMockCodeStep('step-1', ['iterator-1']), // In loop
+ createMockCodeStep('step-2', ['iterator-1']), // Not in loop
iteratorStep,
];
const stepInfos = {
@@ -231,10 +189,10 @@ describe('shouldSkipIteratorStepExecution', () => {
});
it('should return false when there are no parent steps', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', []);
+ const iteratorStep = createMockIteratorStep('iterator-1');
const steps = [
- createMockStep('step-1', ['step-2']),
- createMockStep('step-2', []),
+ createMockCodeStep('step-1', ['step-2']),
+ createMockCodeStep('step-2', []),
iteratorStep,
];
const stepInfos = {
@@ -254,11 +212,11 @@ describe('shouldSkipIteratorStepExecution', () => {
});
it('should handle undefined steps gracefully', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
+ const iteratorStep = createMockIteratorStep('iterator-1', [], ['step-1']);
const steps = [
- createMockStep('step-1', ['iterator-1']),
+ createMockCodeStep('step-1', ['iterator-1']),
undefined as unknown as WorkflowAction,
- createMockStep('step-2', ['iterator-1']),
+ createMockCodeStep('step-2', ['iterator-1']),
iteratorStep,
];
const stepInfos = {
@@ -278,15 +236,16 @@ describe('shouldSkipIteratorStepExecution', () => {
});
it('should work correctly with multiple steps targeting the same iterator', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', [
- 'step-1',
- 'step-2',
- ]);
+ const iteratorStep = createMockIteratorStep(
+ 'iterator-1',
+ [],
+ ['step-1', 'step-2'],
+ );
const steps = [
- createMockStep('step-1', ['iterator-1']), // In loop
- createMockStep('step-2', ['iterator-1']), // In loop
- createMockStep('step-3', ['iterator-1']), // Not in loop
- createMockStep('step-4', ['iterator-1']), // Not in loop
+ createMockCodeStep('step-1', ['iterator-1']), // In loop
+ createMockCodeStep('step-2', ['iterator-1']), // In loop
+ createMockCodeStep('step-3', ['iterator-1']), // Not in loop
+ createMockCodeStep('step-4', ['iterator-1']), // Not in loop
iteratorStep,
];
const stepInfos = {
@@ -308,11 +267,11 @@ describe('shouldSkipIteratorStepExecution', () => {
});
it('should return false when one non-loop parent is not skipped/stopped', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
+ const iteratorStep = createMockIteratorStep('iterator-1', [], ['step-1']);
const steps = [
- createMockStep('step-1', ['iterator-1']), // In loop
- createMockStep('step-2', ['iterator-1']), // Not in loop
- createMockStep('step-3', ['iterator-1']), // Not in loop
+ createMockCodeStep('step-1', ['iterator-1']), // In loop
+ createMockCodeStep('step-2', ['iterator-1']), // Not in loop
+ createMockCodeStep('step-3', ['iterator-1']), // Not in loop
iteratorStep,
];
const stepInfos = {
@@ -335,10 +294,10 @@ describe('shouldSkipIteratorStepExecution', () => {
describe('when the iterator has been started', () => {
it('should return false when iterator has been started', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
+ const iteratorStep = createMockIteratorStep('iterator-1', [], ['step-1']);
const steps = [
- createMockStep('step-1', ['iterator-1']),
- createMockStep('step-2', ['iterator-1']),
+ createMockCodeStep('step-1', ['iterator-1']),
+ createMockCodeStep('step-2', ['iterator-1']),
iteratorStep,
];
const stepInfos = {
@@ -359,10 +318,10 @@ describe('shouldSkipIteratorStepExecution', () => {
});
it('should return false even when all parent steps are skipped/stopped', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
+ const iteratorStep = createMockIteratorStep('iterator-1', [], ['step-1']);
const steps = [
- createMockStep('step-1', ['iterator-1']),
- createMockStep('step-2', ['iterator-1']),
+ createMockCodeStep('step-1', ['iterator-1']),
+ createMockCodeStep('step-2', ['iterator-1']),
iteratorStep,
];
const stepInfos = {
@@ -383,8 +342,11 @@ describe('shouldSkipIteratorStepExecution', () => {
});
it('should return false when iterator is pending', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
- const steps = [createMockStep('step-1', ['iterator-1']), iteratorStep];
+ const iteratorStep = createMockIteratorStep('iterator-1', [], ['step-1']);
+ const steps = [
+ createMockCodeStep('step-1', ['iterator-1']),
+ iteratorStep,
+ ];
const stepInfos = {
'iterator-1': { status: StepStatus.PENDING },
'step-1': { status: StepStatus.SKIPPED },
@@ -402,8 +364,11 @@ describe('shouldSkipIteratorStepExecution', () => {
});
it('should return false when iterator is skipped', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
- const steps = [createMockStep('step-1', ['iterator-1']), iteratorStep];
+ const iteratorStep = createMockIteratorStep('iterator-1', [], ['step-1']);
+ const steps = [
+ createMockCodeStep('step-1', ['iterator-1']),
+ iteratorStep,
+ ];
const stepInfos = {
'iterator-1': { status: StepStatus.SKIPPED },
'step-1': { status: StepStatus.SKIPPED },
@@ -421,8 +386,11 @@ describe('shouldSkipIteratorStepExecution', () => {
});
it('should return false when iterator failed', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
- const steps = [createMockStep('step-1', ['iterator-1']), iteratorStep];
+ const iteratorStep = createMockIteratorStep('iterator-1', [], ['step-1']);
+ const steps = [
+ createMockCodeStep('step-1', ['iterator-1']),
+ iteratorStep,
+ ];
const stepInfos = {
'iterator-1': { status: StepStatus.FAILED },
'step-1': { status: StepStatus.SKIPPED },
@@ -457,7 +425,10 @@ describe('shouldSkipIteratorStepExecution', () => {
},
} as WorkflowIteratorAction;
- const steps = [createMockStep('step-1', ['iterator-1']), iteratorStep];
+ const steps = [
+ createMockCodeStep('step-1', ['iterator-1']),
+ iteratorStep,
+ ];
const stepInfos = {
'step-1': { status: StepStatus.SKIPPED },
};
@@ -475,8 +446,11 @@ describe('shouldSkipIteratorStepExecution', () => {
});
it('should handle empty initialLoopStepIds array', () => {
- const iteratorStep = createMockIteratorStep('iterator-1', []);
- const steps = [createMockStep('step-1', ['iterator-1']), iteratorStep];
+ const iteratorStep = createMockIteratorStep('iterator-1');
+ const steps = [
+ createMockCodeStep('step-1', ['iterator-1']),
+ iteratorStep,
+ ];
const stepInfos = {
'step-1': { status: StepStatus.SKIPPED },
};
diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/find-enclosing-iterator-with-continue-on-failure.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/find-enclosing-iterator-with-continue-on-failure.util.ts
new file mode 100644
index 0000000000..35ce8f7e36
--- /dev/null
+++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/find-enclosing-iterator-with-continue-on-failure.util.ts
@@ -0,0 +1,38 @@
+import { isWorkflowIteratorAction } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/guards/is-workflow-iterator-action.guard';
+import { getAllStepIdsInLoop } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/get-all-step-ids-in-loop.util';
+import {
+ type WorkflowAction,
+ type WorkflowIteratorAction,
+} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
+
+export const findEnclosingIteratorWithContinueOnFailure = ({
+ failedStepId,
+ steps,
+}: {
+ failedStepId: string;
+ steps: WorkflowAction[];
+}): WorkflowIteratorAction | undefined => {
+ const iteratorSteps = steps.filter(isWorkflowIteratorAction);
+
+ const candidates = iteratorSteps
+ .filter(
+ (iterator) =>
+ iterator.settings.input.initialLoopStepIds &&
+ iterator.settings.input.initialLoopStepIds.length > 0,
+ )
+ .map((iterator) => ({
+ iterator,
+ loopStepIds: getAllStepIdsInLoop({
+ iteratorStepId: iterator.id,
+ initialLoopStepIds: iterator.settings.input.initialLoopStepIds!,
+ steps,
+ }),
+ }))
+ .filter(({ loopStepIds }) => loopStepIds.includes(failedStepId))
+ .sort((a, b) => a.loopStepIds.length - b.loopStepIds.length);
+
+ return candidates.find(
+ ({ iterator }) =>
+ iterator.settings.input.shouldContinueOnIterationFailure === true,
+ )?.iterator;
+};
diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/should-execute-iterator-step.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/should-execute-iterator-step.util.ts
index bb12b3603b..b57748aca2 100644
--- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/should-execute-iterator-step.util.ts
+++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/should-execute-iterator-step.util.ts
@@ -1,6 +1,7 @@
import { isDefined } from 'twenty-shared/utils';
-import { type WorkflowRunStepInfos } from 'twenty-shared/workflow';
+import { StepStatus, type WorkflowRunStepInfos } from 'twenty-shared/workflow';
+import { TERMINAL_STEP_STATUSES } from 'src/modules/workflow/workflow-executor/constants/terminal-step-statuses.constant';
import { shouldExecuteChildStep } from 'src/modules/workflow/workflow-executor/utils/should-execute-child-step.util';
import { stepHasBeenStarted } from 'src/modules/workflow/workflow-executor/utils/step-has-been-started.util';
import { getAllStepIdsInLoop } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/get-all-step-ids-in-loop.util';
@@ -34,13 +35,35 @@ export const shouldExecuteIteratorStep = ({
: [];
const parentSteps = stepsTargetingIterator.filter(
- (step) => !stepIdsInLoop.includes(step.id),
+ (parentStep) => !stepIdsInLoop.includes(parentStep.id),
);
const stepsToCheck = stepHasBeenStarted(step.id, stepInfos)
? stepsTargetingIterator
: parentSteps;
+ // When iterator has been started and has the continue-on-failure flag,
+ // allow re-execution even if all loop-back parents are FAILED_SAFELY/SKIPPED
+ // (i.e. don't require at least one SUCCESS parent)
+ if (
+ stepHasBeenStarted(step.id, stepInfos) &&
+ step.settings.input.shouldContinueOnIterationFailure
+ ) {
+ const hasFailureFromOwnLoop = stepIdsInLoop.some(
+ (loopStepId) =>
+ stepInfos[loopStepId]?.status === StepStatus.FAILED_SAFELY &&
+ isDefined(stepInfos[loopStepId]?.error),
+ );
+
+ if (hasFailureFromOwnLoop) {
+ const areAllParentsTerminal = stepsToCheck.every((parentStep) =>
+ TERMINAL_STEP_STATUSES.includes(stepInfos[parentStep.id]?.status),
+ );
+
+ return areAllParentsTerminal;
+ }
+ }
+
return shouldExecuteChildStep({
parentSteps: stepsToCheck,
stepInfos,
diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/should-fail-safely-iterator-step.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/should-fail-safely-iterator-step.util.ts
new file mode 100644
index 0000000000..5f36a0a68e
--- /dev/null
+++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/should-fail-safely-iterator-step.util.ts
@@ -0,0 +1,90 @@
+import { isDefined } from 'twenty-shared/utils';
+import { StepStatus, type WorkflowRunStepInfos } from 'twenty-shared/workflow';
+
+import { TERMINAL_STEP_STATUSES } from 'src/modules/workflow/workflow-executor/constants/terminal-step-statuses.constant';
+import { stepHasBeenStarted } from 'src/modules/workflow/workflow-executor/utils/step-has-been-started.util';
+import { getAllStepIdsInLoop } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/get-all-step-ids-in-loop.util';
+import {
+ type WorkflowAction,
+ type WorkflowIteratorAction,
+} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
+
+export const shouldFailSafelyIteratorStep = ({
+ step,
+ steps,
+ stepInfos,
+}: {
+ step: WorkflowIteratorAction;
+ steps: WorkflowAction[];
+ stepInfos: WorkflowRunStepInfos;
+}): boolean => {
+ const stepsTargetingIterator = steps.filter(
+ (parentStep) =>
+ isDefined(parentStep) && parentStep.nextStepIds?.includes(step.id),
+ );
+
+ const initialLoopStepIds = step.settings.input.initialLoopStepIds;
+
+ const stepIdsInLoop = isDefined(initialLoopStepIds)
+ ? getAllStepIdsInLoop({
+ iteratorStepId: step.id,
+ initialLoopStepIds,
+ steps,
+ })
+ : [];
+
+ const externalParentSteps = stepsTargetingIterator.filter(
+ (parentStep) => !stepIdsInLoop.includes(parentStep.id),
+ );
+
+ if (!stepHasBeenStarted(step.id, stepInfos)) {
+ if (externalParentSteps.length === 0) {
+ return false;
+ }
+
+ const areAllExternalParentsTerminal = externalParentSteps.every(
+ (parentStep) =>
+ TERMINAL_STEP_STATUSES.includes(stepInfos[parentStep.id]?.status),
+ );
+
+ const hasFailedSafelyExternalParent = externalParentSteps.some(
+ (parentStep) =>
+ stepInfos[parentStep.id]?.status === StepStatus.FAILED_SAFELY,
+ );
+
+ return areAllExternalParentsTerminal && hasFailedSafelyExternalParent;
+ }
+
+ const areAllParentsTerminal = stepsTargetingIterator.every((parentStep) =>
+ TERMINAL_STEP_STATUSES.includes(stepInfos[parentStep.id]?.status),
+ );
+
+ if (!areAllParentsTerminal) {
+ return false;
+ }
+
+ const hasFailedSafelyParent = stepsTargetingIterator.some(
+ (parentStep) =>
+ stepInfos[parentStep.id]?.status === StepStatus.FAILED_SAFELY,
+ );
+
+ if (!hasFailedSafelyParent) {
+ return false;
+ }
+
+ // If the iterator has the flag, check whether the failure originated from
+ // its own loop. If yes, the iterator handles it (re-execute) — not fail safely.
+ if (step.settings.input.shouldContinueOnIterationFailure) {
+ const hasFailureFromOwnLoop = stepIdsInLoop.some(
+ (loopStepId) =>
+ stepInfos[loopStepId]?.status === StepStatus.FAILED_SAFELY &&
+ isDefined(stepInfos[loopStepId]?.error),
+ );
+
+ if (hasFailureFromOwnLoop) {
+ return false;
+ }
+ }
+
+ return true;
+};
diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/should-skip-iterator-step-execution.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/should-skip-iterator-step-execution.util.ts
index 41351ca6a8..63115928f8 100644
--- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/should-skip-iterator-step-execution.util.ts
+++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/should-skip-iterator-step-execution.util.ts
@@ -41,8 +41,9 @@ export const shouldSkipIteratorStepExecution = ({
}
return parentSteps.every(
- (step) =>
- stepInfos[step.id]?.status === StepStatus.SKIPPED ||
- stepInfos[step.id]?.status === StepStatus.STOPPED,
+ (parentStep) =>
+ stepInfos[parentStep.id]?.status === StepStatus.SKIPPED ||
+ stepInfos[parentStep.id]?.status === StepStatus.STOPPED ||
+ stepInfos[parentStep.id]?.status === StepStatus.FAILED_SAFELY,
);
};
diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/__tests__/workflow-executor.workspace-service.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/__tests__/workflow-executor.workspace-service.spec.ts
index 5877d619d0..e93a0f91c6 100644
--- a/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/__tests__/workflow-executor.workspace-service.spec.ts
+++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/__tests__/workflow-executor.workspace-service.spec.ts
@@ -486,6 +486,102 @@ describe('WorkflowExecutorWorkspaceService', () => {
});
});
+ it('should return nextStepIds for a fail-safe iterator instead of entering the loop', async () => {
+ const step = {
+ id: 'iterator-1',
+ type: WorkflowActionType.ITERATOR,
+ nextStepIds: ['after-loop'],
+ settings: {
+ input: {
+ initialLoopStepIds: ['loop-step-1'],
+ },
+ },
+ } as WorkflowAction;
+
+ const result = await service.getNextStepIdsToExecute({
+ executedStep: step,
+ executedStepOutput: {
+ shouldFailSafely: true,
+ },
+ });
+
+ expect(result).toEqual({
+ nextStepIdsToExecute: ['after-loop'],
+ });
+ });
+
+ it('should return nextStepIdsToFailSafely for all branches when if-else is fail-safe', async () => {
+ const step = {
+ id: 'if-else-1',
+ type: WorkflowActionType.IF_ELSE,
+ nextStepIds: [],
+ settings: {
+ input: {
+ branches: [
+ {
+ id: 'branch-if',
+ filterGroupId: 'fg1',
+ nextStepIds: ['step-a'],
+ },
+ {
+ id: 'branch-else',
+ nextStepIds: ['step-b'],
+ },
+ ],
+ stepFilterGroups: [],
+ stepFilters: [],
+ },
+ },
+ } as unknown as WorkflowAction;
+
+ const result = await service.getNextStepIdsToExecute({
+ executedStep: step,
+ executedStepOutput: {
+ shouldFailSafely: true,
+ },
+ });
+
+ expect(result).toEqual({
+ nextStepIdsToFailSafely: ['step-a', 'step-b'],
+ });
+ });
+
+ it('should return nextStepIdsToSkip for all branches when if-else has no matching branch', async () => {
+ const step = {
+ id: 'if-else-1',
+ type: WorkflowActionType.IF_ELSE,
+ nextStepIds: [],
+ settings: {
+ input: {
+ branches: [
+ {
+ id: 'branch-if',
+ filterGroupId: 'fg1',
+ nextStepIds: ['step-a'],
+ },
+ {
+ id: 'branch-else',
+ nextStepIds: ['step-b'],
+ },
+ ],
+ stepFilterGroups: [],
+ stepFilters: [],
+ },
+ },
+ } as unknown as WorkflowAction;
+
+ const result = await service.getNextStepIdsToExecute({
+ executedStep: step,
+ executedStepOutput: {
+ shouldSkipStepExecution: true,
+ },
+ });
+
+ expect(result).toEqual({
+ nextStepIdsToSkip: ['step-a', 'step-b'],
+ });
+ });
+
it('should skip multiple non-matching branches for if-else with many branches', async () => {
const step = {
id: 'if-else-1',
diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/workflow-executor.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/workflow-executor.workspace-service.ts
index ddb6660c51..75895e2597 100644
--- a/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/workflow-executor.workspace-service.ts
+++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/workflow-executor.workspace-service.ts
@@ -28,6 +28,7 @@ import {
type WorkflowExecutorInput,
} from 'src/modules/workflow/workflow-executor/types/workflow-executor-input';
import { shouldExecuteStep } from 'src/modules/workflow/workflow-executor/utils/should-execute-step.util';
+import { shouldFailSafely } from 'src/modules/workflow/workflow-executor/utils/should-fail-safely.util';
import { shouldSkipStepExecution } from 'src/modules/workflow/workflow-executor/utils/should-skip-step-execution.util';
import { workflowShouldFail } from 'src/modules/workflow/workflow-executor/utils/workflow-should-fail.util';
import { workflowShouldKeepRunning } from 'src/modules/workflow/workflow-executor/utils/workflow-should-keep-running.util';
@@ -35,6 +36,7 @@ import { isWorkflowIfElseAction } from 'src/modules/workflow/workflow-executor/w
import { type WorkflowIfElseResult } from 'src/modules/workflow/workflow-executor/workflow-actions/if-else/types/workflow-if-else-result.type';
import { isWorkflowIteratorAction } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/guards/is-workflow-iterator-action.guard';
import { WorkflowIteratorResult } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/types/workflow-iterator-result.type';
+import { findEnclosingIteratorWithContinueOnFailure } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/find-enclosing-iterator-with-continue-on-failure.util';
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import { RUN_WORKFLOW_JOB_NAME } from 'src/modules/workflow/workflow-runner/constants/run-workflow-job-name';
import { type RunWorkflowJobData } from 'src/modules/workflow/workflow-runner/types/run-workflow-job-data.type';
@@ -125,6 +127,27 @@ export class WorkflowExecutorWorkspaceService {
workflowRunId,
workspaceId,
});
+
+ if (isDefined(actionOutput.error)) {
+ const enclosingIterator = findEnclosingIteratorWithContinueOnFailure({
+ failedStepId: stepId,
+ steps,
+ });
+
+ if (isDefined(enclosingIterator)) {
+ actionOutput.shouldFailSafely = true;
+ }
+ }
+ } else if (
+ shouldFailSafely({
+ step: stepToExecute,
+ steps,
+ stepInfos,
+ })
+ ) {
+ actionOutput = {
+ shouldFailSafely: true,
+ };
} else if (
shouldSkipStepExecution({
step: stepToExecute,
@@ -139,9 +162,10 @@ export class WorkflowExecutorWorkspaceService {
return;
}
- const isError = isDefined(actionOutput.error);
+ const isError =
+ isDefined(actionOutput.error) && !actionOutput.shouldFailSafely;
- if (!isError) {
+ if (!isError && !actionOutput.shouldFailSafely) {
this.sendWorkflowNodeRunEvent(workspaceId, workflowRun.workflowId);
}
@@ -169,15 +193,16 @@ export class WorkflowExecutorWorkspaceService {
return;
}
- const { nextStepIdsToExecute, nextStepIdsToSkip } =
+ const { nextStepIdsToExecute, nextStepIdsToSkip, nextStepIdsToFailSafely } =
await this.getNextStepIdsToExecute({
executedStep: stepToExecute,
executedStepOutput: actionOutput,
});
- if (isDefined(nextStepIdsToSkip) && nextStepIdsToSkip.length > 0) {
- await this.skipStepsAndContinue({
- stepIdsToSkip: nextStepIdsToSkip,
+ if (isDefined(nextStepIdsToSkip) || isDefined(nextStepIdsToFailSafely)) {
+ await this.skipAndFailSafelyStepsThenContinue({
+ stepIdsToSkip: nextStepIdsToSkip ?? [],
+ stepIdsToFailSafely: nextStepIdsToFailSafely ?? [],
steps,
workflowRunId,
workspaceId,
@@ -205,6 +230,7 @@ export class WorkflowExecutorWorkspaceService {
}): Promise<{
nextStepIdsToExecute?: string[];
nextStepIdsToSkip?: string[];
+ nextStepIdsToFailSafely?: string[];
}> {
const isIteratorStep = isWorkflowIteratorAction(executedStep);
@@ -213,7 +239,10 @@ export class WorkflowExecutorWorkspaceService {
| WorkflowIteratorResult
| undefined;
- if (!iteratorStepResult?.hasProcessedAllItems) {
+ if (
+ !iteratorStepResult?.hasProcessedAllItems &&
+ !executedStepOutput.shouldFailSafely
+ ) {
const nextStepIdsToExecute = isString(
executedStep.settings.input.initialLoopStepIds,
)
@@ -229,9 +258,9 @@ export class WorkflowExecutorWorkspaceService {
| WorkflowIfElseResult
| undefined;
- if (ifElseResult?.matchingBranchId) {
- const branches = executedStep.settings.input.branches;
+ const branches = executedStep.settings.input.branches;
+ if (ifElseResult?.matchingBranchId) {
const matchingBranch = branches.find(
(branch) => branch.id === ifElseResult.matchingBranchId,
);
@@ -246,6 +275,16 @@ export class WorkflowExecutorWorkspaceService {
(branch) => branch.nextStepIds,
),
};
+ } else if (executedStepOutput.shouldFailSafely) {
+ return {
+ nextStepIdsToFailSafely: branches.flatMap(
+ (branch) => branch.nextStepIds,
+ ),
+ };
+ } else {
+ return {
+ nextStepIdsToSkip: branches.flatMap((branch) => branch.nextStepIds),
+ };
}
}
@@ -347,6 +386,7 @@ export class WorkflowExecutorWorkspaceService {
const isStopped = actionOutput.shouldEndWorkflowRun ?? false;
const isNotFinished = actionOutput.shouldRemainRunning ?? false;
const isSkipped = actionOutput.shouldSkipStepExecution ?? false;
+ const isFailedSafely = actionOutput.shouldFailSafely ?? false;
let stepInfo: WorkflowRunStepInfo;
@@ -364,6 +404,11 @@ export class WorkflowExecutorWorkspaceService {
status: StepStatus.RUNNING,
result: actionOutput?.result,
};
+ } else if (isFailedSafely) {
+ stepInfo = {
+ status: StepStatus.FAILED_SAFELY,
+ error: actionOutput?.error,
+ };
} else if (isSuccess) {
stepInfo = {
status: StepStatus.SUCCESS,
@@ -388,7 +433,8 @@ export class WorkflowExecutorWorkspaceService {
});
return {
- shouldProcessNextSteps: isSuccess || isStopped || isSkipped,
+ shouldProcessNextSteps:
+ isSuccess || isStopped || isSkipped || isFailedSafely,
};
}
@@ -444,34 +490,46 @@ export class WorkflowExecutorWorkspaceService {
}
}
- private async skipStepsAndContinue({
+ private async skipAndFailSafelyStepsThenContinue({
stepIdsToSkip,
+ stepIdsToFailSafely,
steps,
workflowRunId,
workspaceId,
executedStepsCount,
}: {
stepIdsToSkip: string[];
+ stepIdsToFailSafely: string[];
steps: WorkflowAction[];
workflowRunId: string;
workspaceId: string;
executedStepsCount: number;
}) {
+ const stepsToSkip = stepIdsToSkip.map((stepId) => ({
+ stepId,
+ status: StepStatus.SKIPPED,
+ }));
+ const stepsToFailSafely = stepIdsToFailSafely.map((stepId) => ({
+ stepId,
+ status: StepStatus.FAILED_SAFELY,
+ }));
+ const stepsToProcess = [...stepsToSkip, ...stepsToFailSafely];
+
await Promise.all(
- stepIdsToSkip.map(async (stepId) => {
+ stepsToProcess.map(async ({ stepId, status }) => {
await this.workflowRunWorkspaceService.updateWorkflowRunStepInfo({
stepId,
- stepInfo: { status: StepStatus.SKIPPED },
+ stepInfo: { status },
workflowRunId,
workspaceId,
});
- const skippedStep = steps.find((step) => step.id === stepId);
- const skippedStepNextStepIds = skippedStep?.nextStepIds ?? [];
+ const step = steps.find((step) => step.id === stepId);
+ const stepNextStepIds = step?.nextStepIds ?? [];
- if (skippedStepNextStepIds.length > 0) {
+ if (stepNextStepIds.length > 0) {
await this.executeFromSteps({
- stepIds: skippedStepNextStepIds,
+ stepIds: stepNextStepIds,
workflowRunId,
workspaceId,
shouldComputeWorkflowRunStatus: false,
diff --git a/packages/twenty-shared/src/workflow/schemas/iterator-action-settings-schema.ts b/packages/twenty-shared/src/workflow/schemas/iterator-action-settings-schema.ts
index 0d3709ca66..c47a2bf57d 100644
--- a/packages/twenty-shared/src/workflow/schemas/iterator-action-settings-schema.ts
+++ b/packages/twenty-shared/src/workflow/schemas/iterator-action-settings-schema.ts
@@ -20,5 +20,6 @@ export const workflowIteratorActionSettingsSchema =
])
.optional(),
initialLoopStepIds: z.array(z.string()).optional(),
+ shouldContinueOnIterationFailure: z.boolean().optional(),
}),
});
diff --git a/packages/twenty-shared/src/workflow/types/WorkflowRunStateStepInfos.ts b/packages/twenty-shared/src/workflow/types/WorkflowRunStateStepInfos.ts
index 072dc1c5a5..81eb253673 100644
--- a/packages/twenty-shared/src/workflow/types/WorkflowRunStateStepInfos.ts
+++ b/packages/twenty-shared/src/workflow/types/WorkflowRunStateStepInfos.ts
@@ -8,6 +8,7 @@ export enum StepStatus {
SUCCESS = 'SUCCESS',
STOPPED = 'STOPPED',
FAILED = 'FAILED',
+ FAILED_SAFELY = 'FAILED_SAFELY',
PENDING = 'PENDING',
SKIPPED = 'SKIPPED',
}