Compare commits

...

3 Commits

Author SHA1 Message Date
Thomas Trompette 2eea01e787 Batch step info updates + remove duplicated workflow run fetch 2026-03-03 14:02:55 +01:00
Thomas Trompette 12ed23ec94 Add frontend component 2026-03-03 13:11:14 +01:00
Thomas Trompette e849b67c52 Workflow iterator continues on faillure 2026-03-02 18:15:12 +01:00
33 changed files with 1478 additions and 496 deletions
@@ -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 (
<FormFieldInputContainer>
{label ? <InputLabel>{label}</InputLabel> : null}
<FormFieldInputRowContainer>
<FormFieldInputInnerContainer
formFieldInputInstanceId={instanceId}
hasRightElement
preventFocusStackUpdate
>
<StyledDescription>{description}</StyledDescription>
</FormFieldInputInnerContainer>
<StyledToggleContainer>
<Toggle
value={value}
onChange={onChange}
disabled={disabled}
toggleSize="small"
/>
</StyledToggleContainer>
</FormFieldInputRowContainer>
{hint && <InputHint>{hint}</InputHint>}
</FormFieldInputContainer>
);
};
@@ -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<typeof FormBooleanFieldToggleInput> = {
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<typeof FormBooleanFieldToggleInput>;
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);
});
},
};
@@ -41,7 +41,8 @@ export const getWorkflowDiagramColors = ({
},
};
}
case 'FAILED': {
case 'FAILED':
case 'FAILED_SAFELY': {
return {
selected: {
background: theme.color.red2,
@@ -171,7 +171,8 @@ export const WorkflowRunDiagramStepNode = ({
</StyledStatusIconsContainer>
)}
{data.runStatus === StepStatus.FAILED && (
{(data.runStatus === StepStatus.FAILED ||
data.runStatus === StepStatus.FAILED_SAFELY) && (
<StyledStatusIconsContainer>
<StyledColorIcon color={theme.tag.background.red}>
<IconX color={theme.tag.text.red} size={14} />
@@ -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}
/>
<FormBooleanFieldToggleInput
description={t`Continue on iteration failure`}
value={formData.shouldContinueOnIterationFailure}
onChange={(value) =>
handleFieldChange('shouldContinueOnIterationFailure', value)
}
disabled={actionOptions.readonly}
hint={t`Will continue to the next iteration even if the current one fails`}
/>
</WorkflowStepBody>
{!actionOptions.readonly && <WorkflowStepFooter stepId={action.id} />}
</>
@@ -486,6 +486,7 @@ export class WorkflowVersionStepOperationsWorkspaceService {
input: {
items: [],
initialLoopStepIds: [emptyNodeStep.id],
shouldContinueOnIterationFailure: true,
},
},
},
@@ -0,0 +1,8 @@
import { StepStatus } from 'twenty-shared/workflow';
export const TERMINAL_STEP_STATUSES = [
StepStatus.SUCCESS,
StepStatus.STOPPED,
StepStatus.SKIPPED,
StepStatus.FAILED_SAFELY,
];
@@ -5,4 +5,5 @@ export type WorkflowActionOutput = {
shouldEndWorkflowRun?: boolean;
shouldRemainRunning?: boolean;
shouldSkipStepExecution?: boolean;
shouldFailSafely?: boolean;
};
@@ -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}`,
@@ -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);
});
});
@@ -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
@@ -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,
});
@@ -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;
};
@@ -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,
);
};
@@ -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
);
};
@@ -192,9 +192,9 @@ describe('IteratorWorkflowAction', () => {
},
} as any;
workflowRunWorkspaceService.getWorkflowRunOrFail
.mockResolvedValueOnce(mockStepInfo)
.mockResolvedValueOnce(mockStepInfo);
workflowRunWorkspaceService.getWorkflowRunOrFail.mockResolvedValueOnce(
mockStepInfo,
);
const result = await service.execute(input);
@@ -243,9 +243,9 @@ describe('IteratorWorkflowAction', () => {
},
} as any;
workflowRunWorkspaceService.getWorkflowRunOrFail
.mockResolvedValueOnce(mockStepInfo)
.mockResolvedValueOnce(mockStepInfo);
workflowRunWorkspaceService.getWorkflowRunOrFail.mockResolvedValueOnce(
mockStepInfo,
);
const result = await service.execute(input);
@@ -113,6 +113,7 @@ export class IteratorWorkflowAction implements WorkflowActionInterface {
workflowRunId: runInfo.workflowRunId,
workspaceId: runInfo.workspaceId,
steps,
stepInfos,
});
}
@@ -129,6 +130,7 @@ export class IteratorWorkflowAction implements WorkflowActionInterface {
workflowRunId,
workspaceId,
steps,
stepInfos,
}: {
iteratorStepId: string;
initialLoopStepIds: string[];
@@ -136,17 +138,10 @@ export class IteratorWorkflowAction implements WorkflowActionInterface {
workflowRunId: string;
workspaceId: string;
steps: WorkflowAction[];
stepInfos: Record<string, WorkflowRunStepInfo>;
}) {
let stepInfosToUpdate: Record<string, WorkflowRunStepInfo> = {};
const workflowRunToUpdate =
await this.workflowRunWorkspaceService.getWorkflowRunOrFail({
workflowRunId,
workspaceId,
});
const stepInfos = workflowRunToUpdate.state.stepInfos;
if (!hasProcessedAllItems) {
const subStepsInfos = await this.buildSubStepInfosReset({
iteratorStepId,
@@ -4,6 +4,7 @@ export type WorkflowIteratorActionInput = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
items?: Array<any> | string;
initialLoopStepIds?: string[];
shouldContinueOnIterationFailure?: boolean;
};
export type WorkflowIteratorActionSettings = BaseWorkflowActionSettings & {
@@ -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');
});
});
@@ -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({
@@ -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 },
};
@@ -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);
});
});
});
@@ -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 },
};
@@ -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;
};
@@ -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,
@@ -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;
};
@@ -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,
);
};
@@ -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',
@@ -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,42 +490,56 @@ export class WorkflowExecutorWorkspaceService {
}
}
private async skipStepsAndContinue({
async skipAndFailSafelyStepsThenContinue({
stepIdsToSkip,
stepIdsToFailSafely,
steps,
workflowRunId,
workspaceId,
executedStepsCount,
}: {
stepIdsToSkip: string[];
stepIdsToFailSafely: string[];
steps: WorkflowAction[];
workflowRunId: string;
workspaceId: string;
executedStepsCount: number;
}) {
await Promise.all(
stepIdsToSkip.map(async (stepId) => {
await this.workflowRunWorkspaceService.updateWorkflowRunStepInfo({
stepId,
stepInfo: { status: StepStatus.SKIPPED },
workflowRunId,
workspaceId,
});
const stepInfos: Record<string, WorkflowRunStepInfo> = {};
const skippedStep = steps.find((step) => step.id === stepId);
const skippedStepNextStepIds = skippedStep?.nextStepIds ?? [];
for (const stepId of stepIdsToSkip) {
stepInfos[stepId] = { status: StepStatus.SKIPPED };
}
if (skippedStepNextStepIds.length > 0) {
await this.executeFromSteps({
stepIds: skippedStepNextStepIds,
workflowRunId,
workspaceId,
shouldComputeWorkflowRunStatus: false,
executedStepsCount,
});
}
}),
);
for (const stepId of stepIdsToFailSafely) {
stepInfos[stepId] = { status: StepStatus.FAILED_SAFELY };
}
await this.workflowRunWorkspaceService.updateWorkflowRunStepInfos({
stepInfos,
workflowRunId,
workspaceId,
});
const nextStepIds = new Set<string>();
for (const stepId of [...stepIdsToSkip, ...stepIdsToFailSafely]) {
const step = steps.find((step) => step.id === stepId);
for (const nextStepId of step?.nextStepIds ?? []) {
nextStepIds.add(nextStepId);
}
}
if (nextStepIds.size > 0) {
await this.executeFromSteps({
stepIds: Array.from(nextStepIds),
workflowRunId,
workspaceId,
shouldComputeWorkflowRunStatus: false,
executedStepsCount,
});
}
}
private async continueExecutionFromStepInAnotherJob({
@@ -156,13 +156,19 @@ export class RunWorkflowJob {
const lastExecutedStepOutput =
workflowRun.state?.stepInfos[lastExecutedStepId];
const { nextStepIdsToExecute } =
const { nextStepIdsToExecute, nextStepIdsToSkip, nextStepIdsToFailSafely } =
await this.workflowExecutorWorkspaceService.getNextStepIdsToExecute({
executedStep: lastExecutedStep,
executedStepOutput: lastExecutedStepOutput,
});
if (!isDefined(nextStepIdsToExecute) || nextStepIdsToExecute.length === 0) {
const hasStepsToSkipOrFailSafely =
isDefined(nextStepIdsToSkip) || isDefined(nextStepIdsToFailSafely);
const hasStepsToExecute =
isDefined(nextStepIdsToExecute) && nextStepIdsToExecute.length > 0;
if (!hasStepsToSkipOrFailSafely && !hasStepsToExecute) {
await this.workflowRunWorkspaceService.endWorkflowRun({
workflowRunId,
workspaceId,
@@ -172,11 +178,28 @@ export class RunWorkflowJob {
return;
}
await this.workflowExecutorWorkspaceService.executeFromSteps({
stepIds: nextStepIdsToExecute,
workflowRunId,
workspaceId,
});
const steps = workflowRun.state?.flow?.steps ?? [];
if (hasStepsToSkipOrFailSafely) {
await this.workflowExecutorWorkspaceService.skipAndFailSafelyStepsThenContinue(
{
stepIdsToSkip: nextStepIdsToSkip ?? [],
stepIdsToFailSafely: nextStepIdsToFailSafely ?? [],
steps,
workflowRunId,
workspaceId,
executedStepsCount: 0,
},
);
}
if (hasStepsToExecute) {
await this.workflowExecutorWorkspaceService.executeFromSteps({
stepIds: nextStepIdsToExecute,
workflowRunId,
workspaceId,
});
}
}
private async incrementTriggerMetrics({
@@ -277,13 +277,21 @@ export class WorkflowRunWorkspaceService {
workspaceId,
});
const existingStepInfos = workflowRunToUpdate.state?.stepInfos ?? {};
const mergedStepInfos = { ...existingStepInfos };
for (const [stepId, info] of Object.entries(stepInfos)) {
mergedStepInfos[stepId] = {
...(existingStepInfos[stepId] || {}),
...info,
};
}
const partialUpdate = {
state: {
...workflowRunToUpdate.state,
stepInfos: {
...workflowRunToUpdate.state?.stepInfos,
...stepInfos,
},
stepInfos: mergedStepInfos,
},
};
@@ -20,5 +20,6 @@ export const workflowIteratorActionSettingsSchema =
])
.optional(),
initialLoopStepIds: z.array(z.string()).optional(),
shouldContinueOnIterationFailure: z.boolean().optional(),
}),
});
@@ -8,6 +8,7 @@ export enum StepStatus {
SUCCESS = 'SUCCESS',
STOPPED = 'STOPPED',
FAILED = 'FAILED',
FAILED_SAFELY = 'FAILED_SAFELY',
PENDING = 'PENDING',
SKIPPED = 'SKIPPED',
}