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', }