Enable filters in iterators (#15017)

Filters should not cut the whole workflow. These should only stop the
branch. This PR:
- adds a new skipped status 
- when a filter stops, it still goes to the next step
- the next step will execute if there is at least a successful step
- if only skipped step, it will be skipped as well

It allows to use filters in iterators.


https://github.com/user-attachments/assets/1cfca052-55c0-4ce5-9eb8-63736618d082
This commit is contained in:
Thomas Trompette
2025-10-09 23:14:54 +02:00
committed by GitHub
parent 6f7656f24d
commit cbfd73cbd8
15 changed files with 1692 additions and 338 deletions
@@ -4,4 +4,5 @@ export type WorkflowActionOutput = {
pendingEvent?: boolean;
shouldEndWorkflowRun?: boolean;
shouldRemainRunning?: boolean;
shouldSkipStepExecution?: boolean;
};
@@ -1,236 +0,0 @@
import { StepStatus } from 'twenty-shared/workflow';
import {
type WorkflowAction,
WorkflowActionType,
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import { canExecuteStep } from 'src/modules/workflow/workflow-executor/utils/can-execute-step.util';
import { WorkflowRunStatus } from 'src/modules/workflow/common/standard-objects/workflow-run.workspace-entity';
describe('canExecuteStep', () => {
const steps = [
{
id: 'step-1',
type: WorkflowActionType.CODE,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
},
nextStepIds: ['step-3'],
},
{
id: 'step-2',
type: WorkflowActionType.SEND_EMAIL,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
},
nextStepIds: ['step-3'],
},
{
id: 'step-3',
type: WorkflowActionType.SEND_EMAIL,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
},
nextStepIds: [],
},
] as WorkflowAction[];
it('should return true if all parents succeeded', () => {
const stepInfos = {
'step-1': {
status: StepStatus.SUCCESS,
},
'step-2': {
status: StepStatus.SUCCESS,
},
'step-3': {
status: StepStatus.NOT_STARTED,
},
};
const result = canExecuteStep({
stepInfos,
steps,
stepId: 'step-3',
workflowRunStatus: WorkflowRunStatus.RUNNING,
});
expect(result).toBe(true);
});
it('should return false if one parent is not succeeded', () => {
expect(
canExecuteStep({
stepInfos: {
'step-1': {
status: StepStatus.NOT_STARTED,
},
'step-2': {
status: StepStatus.SUCCESS,
},
'step-3': {
status: StepStatus.NOT_STARTED,
},
},
steps,
stepId: 'step-3',
workflowRunStatus: WorkflowRunStatus.RUNNING,
}),
).toBe(false);
expect(
canExecuteStep({
stepInfos: {
'step-1': {
status: StepStatus.SUCCESS,
},
'step-2': {
status: StepStatus.NOT_STARTED,
},
'step-3': {
status: StepStatus.NOT_STARTED,
},
},
steps,
stepId: 'step-3',
workflowRunStatus: WorkflowRunStatus.RUNNING,
}),
).toBe(false);
expect(
canExecuteStep({
stepInfos: {
'step-1': {
status: StepStatus.NOT_STARTED,
},
'step-2': {
status: StepStatus.NOT_STARTED,
},
'step-3': {
status: StepStatus.NOT_STARTED,
},
},
steps,
stepId: 'step-3',
workflowRunStatus: WorkflowRunStatus.RUNNING,
}),
).toBe(false);
});
it('should return false if step has already ran', () => {
expect(
canExecuteStep({
stepInfos: {
'step-1': {
status: StepStatus.SUCCESS,
},
'step-2': {
status: StepStatus.SUCCESS,
},
'step-3': {
status: StepStatus.SUCCESS,
},
},
steps,
stepId: 'step-3',
workflowRunStatus: WorkflowRunStatus.RUNNING,
}),
).toBe(false);
expect(
canExecuteStep({
stepInfos: {
'step-1': {
status: StepStatus.SUCCESS,
},
'step-2': {
status: StepStatus.SUCCESS,
},
'step-3': {
status: StepStatus.PENDING,
},
},
steps,
stepId: 'step-3',
workflowRunStatus: WorkflowRunStatus.RUNNING,
}),
).toBe(false);
expect(
canExecuteStep({
stepInfos: {
'step-1': {
status: StepStatus.SUCCESS,
},
'step-2': {
status: StepStatus.SUCCESS,
},
'step-3': {
status: StepStatus.FAILED,
},
},
steps,
stepId: 'step-3',
workflowRunStatus: WorkflowRunStatus.RUNNING,
}),
).toBe(false);
expect(
canExecuteStep({
stepInfos: {
'step-1': {
status: StepStatus.SUCCESS,
},
'step-2': {
status: StepStatus.SUCCESS,
},
'step-3': {
status: StepStatus.RUNNING,
},
},
steps,
stepId: 'step-3',
workflowRunStatus: WorkflowRunStatus.RUNNING,
}),
).toBe(false);
});
it('should return false if workflowRun is not RUNNING', () => {
const stepInfos = {
'step-1': {
status: StepStatus.SUCCESS,
},
'step-2': {
status: StepStatus.SUCCESS,
},
'step-3': {
status: StepStatus.NOT_STARTED,
},
};
for (const workflowRunStatus of [
WorkflowRunStatus.FAILED,
WorkflowRunStatus.ENQUEUED,
WorkflowRunStatus.COMPLETED,
WorkflowRunStatus.NOT_STARTED,
]) {
const result = canExecuteStep({
stepInfos,
steps,
stepId: 'step-3',
workflowRunStatus,
});
expect(result).toBe(false);
}
});
});
@@ -0,0 +1,470 @@
import { StepStatus } from 'twenty-shared/workflow';
import { WorkflowRunStatus } from 'src/modules/workflow/common/standard-objects/workflow-run.workspace-entity';
import { shouldExecuteStep } from 'src/modules/workflow/workflow-executor/utils/should-execute-step.util';
import {
type WorkflowAction,
WorkflowActionType,
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
describe('shouldExecuteStep', () => {
const steps = [
{
id: 'step-1',
type: WorkflowActionType.CODE,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
},
nextStepIds: ['step-3'],
},
{
id: 'step-2',
type: WorkflowActionType.SEND_EMAIL,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
},
nextStepIds: ['step-3'],
},
{
id: 'step-3',
type: WorkflowActionType.SEND_EMAIL,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
},
nextStepIds: [],
},
] as WorkflowAction[];
it('should return true if all parents succeeded', () => {
const stepInfos = {
'step-1': {
status: StepStatus.SUCCESS,
},
'step-2': {
status: StepStatus.SUCCESS,
},
'step-3': {
status: StepStatus.NOT_STARTED,
},
};
const result = shouldExecuteStep({
stepInfos,
steps,
step: steps[2],
workflowRunStatus: WorkflowRunStatus.RUNNING,
});
expect(result).toBe(true);
});
it('should return false if one parent is not succeeded', () => {
expect(
shouldExecuteStep({
stepInfos: {
'step-1': {
status: StepStatus.NOT_STARTED,
},
'step-2': {
status: StepStatus.SUCCESS,
},
'step-3': {
status: StepStatus.NOT_STARTED,
},
},
steps,
step: steps[2],
workflowRunStatus: WorkflowRunStatus.RUNNING,
}),
).toBe(false);
expect(
shouldExecuteStep({
stepInfos: {
'step-1': {
status: StepStatus.SUCCESS,
},
'step-2': {
status: StepStatus.NOT_STARTED,
},
'step-3': {
status: StepStatus.NOT_STARTED,
},
},
steps,
step: steps[2],
workflowRunStatus: WorkflowRunStatus.RUNNING,
}),
).toBe(false);
expect(
shouldExecuteStep({
stepInfos: {
'step-1': {
status: StepStatus.NOT_STARTED,
},
'step-2': {
status: StepStatus.NOT_STARTED,
},
'step-3': {
status: StepStatus.NOT_STARTED,
},
},
steps,
step: steps[2],
workflowRunStatus: WorkflowRunStatus.RUNNING,
}),
).toBe(false);
});
it('should return false if step has already ran', () => {
expect(
shouldExecuteStep({
stepInfos: {
'step-1': {
status: StepStatus.SUCCESS,
},
'step-2': {
status: StepStatus.SUCCESS,
},
'step-3': {
status: StepStatus.SUCCESS,
},
},
steps,
step: steps[2],
workflowRunStatus: WorkflowRunStatus.RUNNING,
}),
).toBe(false);
expect(
shouldExecuteStep({
stepInfos: {
'step-1': {
status: StepStatus.SUCCESS,
},
'step-2': {
status: StepStatus.SUCCESS,
},
'step-3': {
status: StepStatus.PENDING,
},
},
steps,
step: steps[2],
workflowRunStatus: WorkflowRunStatus.RUNNING,
}),
).toBe(false);
expect(
shouldExecuteStep({
stepInfos: {
'step-1': {
status: StepStatus.SUCCESS,
},
'step-2': {
status: StepStatus.SUCCESS,
},
'step-3': {
status: StepStatus.FAILED,
},
},
steps,
step: steps[2],
workflowRunStatus: WorkflowRunStatus.RUNNING,
}),
).toBe(false);
expect(
shouldExecuteStep({
stepInfos: {
'step-1': {
status: StepStatus.SUCCESS,
},
'step-2': {
status: StepStatus.SUCCESS,
},
'step-3': {
status: StepStatus.RUNNING,
},
},
steps,
step: steps[2],
workflowRunStatus: WorkflowRunStatus.RUNNING,
}),
).toBe(false);
});
it('should return false if workflowRun is not RUNNING', () => {
const stepInfos = {
'step-1': {
status: StepStatus.SUCCESS,
},
'step-2': {
status: StepStatus.SUCCESS,
},
'step-3': {
status: StepStatus.NOT_STARTED,
},
};
for (const workflowRunStatus of [
WorkflowRunStatus.FAILED,
WorkflowRunStatus.ENQUEUED,
WorkflowRunStatus.COMPLETED,
WorkflowRunStatus.NOT_STARTED,
]) {
const result = shouldExecuteStep({
stepInfos,
steps,
step: steps[2],
workflowRunStatus,
});
expect(result).toBe(false);
}
});
it('should return true when step has no parent steps', () => {
const stepsWithoutParents = [
{
id: 'step-1',
name: 'Mock Step',
type: WorkflowActionType.CODE,
settings: {
input: {
serverlessFunctionId: 'mock-function-id',
serverlessFunctionVersion: 'mock-function-version',
serverlessFunctionInput: {},
},
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
outputSchema: {},
},
valid: true,
nextStepIds: [],
},
] as WorkflowAction[];
const stepInfos = {
'step-1': {
status: StepStatus.NOT_STARTED,
},
};
const result = shouldExecuteStep({
stepInfos,
steps: stepsWithoutParents,
step: stepsWithoutParents[0],
workflowRunStatus: WorkflowRunStatus.RUNNING,
});
expect(result).toBe(true);
});
it('should return false when one parent is RUNNING', () => {
const result = shouldExecuteStep({
stepInfos: {
'step-1': {
status: StepStatus.SUCCESS,
},
'step-2': {
status: StepStatus.RUNNING,
},
'step-3': {
status: StepStatus.NOT_STARTED,
},
},
steps,
step: steps[2],
workflowRunStatus: WorkflowRunStatus.RUNNING,
});
expect(result).toBe(false);
});
it('should handle undefined parent steps gracefully', () => {
const stepsWithUndefined = [
{
id: 'step-1',
type: WorkflowActionType.CODE,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
},
nextStepIds: ['step-3'],
},
undefined as unknown as WorkflowAction,
{
id: 'step-3',
type: WorkflowActionType.SEND_EMAIL,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
},
nextStepIds: [],
},
] as WorkflowAction[];
const stepInfos = {
'step-1': {
status: StepStatus.SUCCESS,
},
'step-3': {
status: StepStatus.NOT_STARTED,
},
};
const result = shouldExecuteStep({
stepInfos,
steps: stepsWithUndefined,
step: stepsWithUndefined[2],
workflowRunStatus: WorkflowRunStatus.RUNNING,
});
expect(result).toBe(true);
});
it('should return false when at least one parent is FAILED', () => {
const result = shouldExecuteStep({
stepInfos: {
'step-1': {
status: StepStatus.SUCCESS,
},
'step-2': {
status: StepStatus.FAILED,
},
'step-3': {
status: StepStatus.NOT_STARTED,
},
},
steps,
step: steps[2],
workflowRunStatus: WorkflowRunStatus.RUNNING,
});
expect(result).toBe(false);
});
it('should work correctly with multiple successful parents', () => {
const multiParentSteps = [
{
id: 'step-1',
type: WorkflowActionType.CODE,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
},
nextStepIds: ['step-4'],
},
{
id: 'step-2',
type: WorkflowActionType.SEND_EMAIL,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
},
nextStepIds: ['step-4'],
},
{
id: 'step-3',
type: WorkflowActionType.CODE,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
},
nextStepIds: ['step-4'],
},
{
id: 'step-4',
type: WorkflowActionType.SEND_EMAIL,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
},
nextStepIds: [],
},
] as WorkflowAction[];
const stepInfos = {
'step-1': { status: StepStatus.SUCCESS },
'step-2': { status: StepStatus.SUCCESS },
'step-3': { status: StepStatus.SUCCESS },
'step-4': { status: StepStatus.NOT_STARTED },
};
const result = shouldExecuteStep({
stepInfos,
steps: multiParentSteps,
step: multiParentSteps[3],
workflowRunStatus: WorkflowRunStatus.RUNNING,
});
expect(result).toBe(true);
});
it('should return false when step status is SKIPPED', () => {
const result = shouldExecuteStep({
stepInfos: {
'step-1': {
status: StepStatus.SUCCESS,
},
'step-2': {
status: StepStatus.SUCCESS,
},
'step-3': {
status: StepStatus.SKIPPED,
},
},
steps,
step: steps[2],
workflowRunStatus: WorkflowRunStatus.RUNNING,
});
expect(result).toBe(false);
});
it('should return false when step status is STOPPED', () => {
const result = shouldExecuteStep({
stepInfos: {
'step-1': {
status: StepStatus.SUCCESS,
},
'step-2': {
status: StepStatus.SUCCESS,
},
'step-3': {
status: StepStatus.STOPPED,
},
},
steps,
step: steps[2],
workflowRunStatus: WorkflowRunStatus.RUNNING,
});
expect(result).toBe(false);
});
});
@@ -0,0 +1,323 @@
import { StepStatus } from 'twenty-shared/workflow';
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';
describe('shouldSkipStepExecution', () => {
const createMockStep = (
id: string,
nextStepIds: string[] = [],
): WorkflowAction => ({
id,
name: 'Mock Step',
type: WorkflowActionType.CODE,
settings: {
input: {
serverlessFunctionId: 'mock-function-id',
serverlessFunctionVersion: 'mock-function-version',
serverlessFunctionInput: {},
},
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', []),
];
const stepInfos = {
'step-1': { status: StepStatus.SKIPPED },
'step-2': { status: StepStatus.SKIPPED },
'step-3': { status: StepStatus.NOT_STARTED },
};
const result = shouldSkipStepExecution({
step: steps[2],
steps,
stepInfos,
});
expect(result).toBe(true);
});
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', []),
];
const stepInfos = {
'step-1': { status: StepStatus.STOPPED },
'step-2': { status: StepStatus.STOPPED },
'step-3': { status: StepStatus.NOT_STARTED },
};
const result = shouldSkipStepExecution({
step: steps[2],
steps,
stepInfos,
});
expect(result).toBe(true);
});
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', []),
];
const stepInfos = {
'step-1': { status: StepStatus.SKIPPED },
'step-2': { status: StepStatus.STOPPED },
'step-3': { status: StepStatus.NOT_STARTED },
};
const result = shouldSkipStepExecution({
step: steps[2],
steps,
stepInfos,
});
expect(result).toBe(true);
});
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', []),
];
const stepInfos = {
'step-1': { status: StepStatus.SKIPPED },
'step-2': { status: StepStatus.SUCCESS },
'step-3': { status: StepStatus.NOT_STARTED },
};
const result = shouldSkipStepExecution({
step: steps[2],
steps,
stepInfos,
});
expect(result).toBe(false);
});
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', []),
];
const stepInfos = {
'step-1': { status: StepStatus.SKIPPED },
'step-2': { status: StepStatus.FAILED },
'step-3': { status: StepStatus.NOT_STARTED },
};
const result = shouldSkipStepExecution({
step: steps[2],
steps,
stepInfos,
});
expect(result).toBe(false);
});
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', []),
];
const stepInfos = {
'step-1': { status: StepStatus.SKIPPED },
'step-2': { status: StepStatus.RUNNING },
'step-3': { status: StepStatus.NOT_STARTED },
};
const result = shouldSkipStepExecution({
step: steps[2],
steps,
stepInfos,
});
expect(result).toBe(false);
});
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', []),
];
const stepInfos = {
'step-1': { status: StepStatus.SKIPPED },
'step-2': { status: StepStatus.NOT_STARTED },
'step-3': { status: StepStatus.NOT_STARTED },
};
const result = shouldSkipStepExecution({
step: steps[2],
steps,
stepInfos,
});
expect(result).toBe(false);
});
it('should return false when there are no parent steps', () => {
const steps = [
createMockStep('step-1', ['step-2']),
createMockStep('step-2', []),
createMockStep('step-3', []),
];
const stepInfos = {
'step-1': { status: StepStatus.SKIPPED },
'step-2': { status: StepStatus.SKIPPED },
'step-3': { status: StepStatus.NOT_STARTED },
};
const result = shouldSkipStepExecution({
step: steps[2],
steps,
stepInfos,
});
expect(result).toBe(false);
});
it('should return true when single parent step is skipped', () => {
const steps = [
createMockStep('step-1', ['step-2']),
createMockStep('step-2', []),
];
const stepInfos = {
'step-1': { status: StepStatus.SKIPPED },
'step-2': { status: StepStatus.NOT_STARTED },
};
const result = shouldSkipStepExecution({
step: steps[1],
steps,
stepInfos,
});
expect(result).toBe(true);
});
it('should handle undefined steps gracefully', () => {
const steps = [
createMockStep('step-1', ['step-2']),
undefined as unknown as WorkflowAction,
createMockStep('step-2', []),
];
const stepInfos = {
'step-1': { status: StepStatus.SKIPPED },
'step-2': { status: StepStatus.NOT_STARTED },
};
const result = shouldSkipStepExecution({
step: steps[2],
steps,
stepInfos,
});
expect(result).toBe(true);
});
it('should return false when parent step status is pending', () => {
const steps = [
createMockStep('step-1', ['step-2']),
createMockStep('step-2', []),
];
const stepInfos = {
'step-1': { status: StepStatus.PENDING },
'step-2': { status: StepStatus.NOT_STARTED },
};
const result = shouldSkipStepExecution({
step: steps[1],
steps,
stepInfos,
});
expect(result).toBe(false);
});
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', []),
];
// Test case 1: All skipped - should return true
expect(
shouldSkipStepExecution({
step: steps[3],
steps,
stepInfos: {
'step-1': { status: StepStatus.SKIPPED },
'step-2': { status: StepStatus.SKIPPED },
'step-3': { status: StepStatus.SKIPPED },
'step-4': { status: StepStatus.NOT_STARTED },
},
}),
).toBe(true);
// Test case 2: Mixed skipped/stopped - should return true
expect(
shouldSkipStepExecution({
step: steps[3],
steps,
stepInfos: {
'step-1': { status: StepStatus.SKIPPED },
'step-2': { status: StepStatus.STOPPED },
'step-3': { status: StepStatus.SKIPPED },
'step-4': { status: StepStatus.NOT_STARTED },
},
}),
).toBe(true);
// Test case 3: One success among skipped - should return false
expect(
shouldSkipStepExecution({
step: steps[3],
steps,
stepInfos: {
'step-1': { status: StepStatus.SKIPPED },
'step-2': { status: StepStatus.SUCCESS },
'step-3': { status: StepStatus.SKIPPED },
'step-4': { status: StepStatus.NOT_STARTED },
},
}),
).toBe(false);
// Test case 4: One failed among skipped - should return false
expect(
shouldSkipStepExecution({
step: steps[3],
steps,
stepInfos: {
'step-1': { status: StepStatus.SKIPPED },
'step-2': { status: StepStatus.FAILED },
'step-3': { status: StepStatus.SKIPPED },
'step-4': { status: StepStatus.NOT_STARTED },
},
}),
).toBe(false);
});
});
@@ -4,48 +4,55 @@ import { StepStatus, type WorkflowRunStepInfos } from 'twenty-shared/workflow';
import { WorkflowRunStatus } from 'src/modules/workflow/common/standard-objects/workflow-run.workspace-entity';
import { stepHasBeenStarted } from 'src/modules/workflow/workflow-executor/utils/step-has-been-started.util';
import { isWorkflowIteratorAction } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/guards/is-workflow-iterator-action.guard';
import { canExecuteIteratorStep } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/can-execute-iterator-step.util';
import { shouldExecuteIteratorStep } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/should-execute-iterator-step.util';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
export const canExecuteStep = ({
stepId,
export const shouldExecuteStep = ({
step,
steps,
stepInfos,
workflowRunStatus,
}: {
step: WorkflowAction;
steps: WorkflowAction[];
stepInfos: WorkflowRunStepInfos;
stepId: string;
workflowRunStatus: WorkflowRunStatus;
}) => {
if (workflowRunStatus !== WorkflowRunStatus.RUNNING) {
return false;
}
const step = steps.find((step) => step.id === stepId);
if (!step) {
return false;
}
if (isWorkflowIteratorAction(step)) {
return canExecuteIteratorStep({
return shouldExecuteIteratorStep({
step,
steps,
stepInfos,
});
}
if (stepHasBeenStarted(stepId, stepInfos)) {
if (stepHasBeenStarted(step.id, stepInfos)) {
return false;
}
const parentSteps = steps.filter(
(parentStep) =>
isDefined(parentStep) && parentStep.nextStepIds?.includes(stepId),
isDefined(parentStep) && parentStep.nextStepIds?.includes(step.id),
);
return parentSteps.every(
if (parentSteps.length === 0) {
return true;
}
const hasSuccessfulParentStep = parentSteps.some(
(parentStep) => stepInfos[parentStep.id]?.status === StepStatus.SUCCESS,
);
const areAllParentsCompleted = parentSteps.every(
(parentStep) =>
stepInfos[parentStep.id]?.status === StepStatus.SUCCESS ||
stepInfos[parentStep.id]?.status === StepStatus.STOPPED ||
stepInfos[parentStep.id]?.status === StepStatus.SKIPPED,
);
return hasSuccessfulParentStep && areAllParentsCompleted;
};
@@ -0,0 +1,39 @@
import { isDefined } from 'twenty-shared/utils';
import { StepStatus, type WorkflowRunStepInfos } from 'twenty-shared/workflow';
import { isWorkflowIteratorAction } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/guards/is-workflow-iterator-action.guard';
import { shouldSkipIteratorStepExecution } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/should-skip-iterator-step-execution.util';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
export const shouldSkipStepExecution = ({
step,
steps,
stepInfos,
}: {
step: WorkflowAction;
steps: WorkflowAction[];
stepInfos: WorkflowRunStepInfos;
}) => {
if (isWorkflowIteratorAction(step)) {
return shouldSkipIteratorStepExecution({
step,
steps,
stepInfos,
});
}
const parentSteps = steps.filter(
(parentStep) =>
isDefined(parentStep) && parentStep.nextStepIds?.includes(step.id),
);
if (parentSteps.length === 0) {
return false;
}
return parentSteps.every(
(step) =>
stepInfos[step.id]?.status === StepStatus.SKIPPED ||
stepInfos[step.id]?.status === StepStatus.STOPPED,
);
};
@@ -1,8 +1,8 @@
import { StepStatus, type WorkflowRunStepInfos } from 'twenty-shared/workflow';
import { canExecuteStep } from 'src/modules/workflow/workflow-executor/utils/can-execute-step.util';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import { WorkflowRunStatus } from 'src/modules/workflow/common/standard-objects/workflow-run.workspace-entity';
import { shouldExecuteStep } from 'src/modules/workflow/workflow-executor/utils/should-execute-step.util';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
export const workflowShouldKeepRunning = ({
stepInfos,
@@ -20,16 +20,23 @@ export const workflowShouldKeepRunning = ({
const successStepWithNotStartedExecutableChildren = steps.some(
(step) =>
stepInfos[step.id]?.status === StepStatus.SUCCESS &&
(step.nextStepIds ?? []).some(
(nextStepId) =>
(step.nextStepIds ?? []).some((nextStepId) => {
const nextStep = steps.find((step) => step.id === nextStepId);
if (!nextStep) {
return false;
}
return (
stepInfos[nextStepId]?.status === StepStatus.NOT_STARTED &&
canExecuteStep({
stepId: nextStepId,
shouldExecuteStep({
step: nextStep,
steps,
stepInfos,
workflowRunStatus: WorkflowRunStatus.RUNNING,
}),
),
})
);
}),
);
return (
@@ -1,6 +1,6 @@
import { StepStatus } from 'twenty-shared/workflow';
import { canExecuteIteratorStep } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/can-execute-iterator-step.util';
import { shouldExecuteIteratorStep } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/should-execute-iterator-step.util';
import {
type WorkflowAction,
type WorkflowIteratorAction,
@@ -19,7 +19,7 @@ const { getAllStepIdsInLoop } = jest.requireMock(
'src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/get-all-step-ids-in-loop.util',
);
describe('canExecuteIteratorStep', () => {
describe('shouldExecuteIteratorStep', () => {
const createMockIteratorStep = (
id: string,
initialLoopStepIds: string[] = [],
@@ -90,7 +90,7 @@ describe('canExecuteIteratorStep', () => {
// Mock getAllStepIdsInLoop to return the loop step IDs
getAllStepIdsInLoop.mockReturnValue(['step-1', 'step-2']);
const result = canExecuteIteratorStep({
const result = shouldExecuteIteratorStep({
step: iteratorStep,
steps,
stepInfos,
@@ -121,7 +121,7 @@ describe('canExecuteIteratorStep', () => {
getAllStepIdsInLoop.mockReturnValue(['step-2']);
const result = canExecuteIteratorStep({
const result = shouldExecuteIteratorStep({
step: iteratorStep,
steps,
stepInfos,
@@ -147,7 +147,7 @@ describe('canExecuteIteratorStep', () => {
getAllStepIdsInLoop.mockReturnValue(['step-1']);
const result = canExecuteIteratorStep({
const result = shouldExecuteIteratorStep({
step: iteratorStep,
steps,
stepInfos,
@@ -171,7 +171,7 @@ describe('canExecuteIteratorStep', () => {
// Only step-1 is in the loop
getAllStepIdsInLoop.mockReturnValue(['step-1']);
const result = canExecuteIteratorStep({
const result = shouldExecuteIteratorStep({
step: iteratorStep,
steps,
stepInfos,
@@ -194,7 +194,7 @@ describe('canExecuteIteratorStep', () => {
getAllStepIdsInLoop.mockReturnValue([]);
const result = canExecuteIteratorStep({
const result = shouldExecuteIteratorStep({
step: iteratorStep,
steps,
stepInfos,
@@ -216,7 +216,7 @@ describe('canExecuteIteratorStep', () => {
getAllStepIdsInLoop.mockReturnValue(['step-1']);
const result = canExecuteIteratorStep({
const result = shouldExecuteIteratorStep({
step: iteratorStep,
steps,
stepInfos,
@@ -247,7 +247,7 @@ describe('canExecuteIteratorStep', () => {
getAllStepIdsInLoop.mockReturnValue(['step-1', 'step-2', 'step-3']);
const result = canExecuteIteratorStep({
const result = shouldExecuteIteratorStep({
step: iteratorStep,
steps,
stepInfos,
@@ -273,7 +273,7 @@ describe('canExecuteIteratorStep', () => {
getAllStepIdsInLoop.mockReturnValue(['step-1']);
const result = canExecuteIteratorStep({
const result = shouldExecuteIteratorStep({
step: iteratorStep,
steps,
stepInfos,
@@ -297,7 +297,7 @@ describe('canExecuteIteratorStep', () => {
getAllStepIdsInLoop.mockReturnValue(['step-1']);
const result = canExecuteIteratorStep({
const result = shouldExecuteIteratorStep({
step: iteratorStep,
steps,
stepInfos,
@@ -321,7 +321,7 @@ describe('canExecuteIteratorStep', () => {
getAllStepIdsInLoop.mockReturnValue(['step-1']);
const result = canExecuteIteratorStep({
const result = shouldExecuteIteratorStep({
step: iteratorStep,
steps,
stepInfos,
@@ -340,7 +340,7 @@ describe('canExecuteIteratorStep', () => {
getAllStepIdsInLoop.mockReturnValue([]);
const result = canExecuteIteratorStep({
const result = shouldExecuteIteratorStep({
step: iteratorStep,
steps,
stepInfos,
@@ -364,7 +364,7 @@ describe('canExecuteIteratorStep', () => {
getAllStepIdsInLoop.mockReturnValue(['step-1']);
const result = canExecuteIteratorStep({
const result = shouldExecuteIteratorStep({
step: iteratorStep,
steps,
stepInfos,
@@ -372,5 +372,150 @@ describe('canExecuteIteratorStep', () => {
expect(result).toBe(true);
});
it('should return false when loop step has NOT_STARTED status and iterator has been started', () => {
const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
const steps = [
createMockStep('step-1', ['iterator-1']), // In loop
createMockStep('step-2', ['iterator-1']), // Not in loop
iteratorStep,
];
const stepInfos = {
'iterator-1': { status: StepStatus.RUNNING }, // Iterator has been started
'step-1': { status: StepStatus.NOT_STARTED }, // Loop step not started
'step-2': { status: StepStatus.SUCCESS },
};
getAllStepIdsInLoop.mockReturnValue(['step-1']);
const result = shouldExecuteIteratorStep({
step: iteratorStep,
steps,
stepInfos,
});
expect(result).toBe(false);
});
});
describe('edge cases', () => {
it('should handle empty step info for parent steps', () => {
const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
const steps = [
createMockStep('step-1', ['iterator-1']),
createMockStep('step-2', ['iterator-1']),
iteratorStep,
];
const stepInfos = {
'step-1': { status: StepStatus.SUCCESS },
// step-2 doesn't have info
};
getAllStepIdsInLoop.mockReturnValue(['step-1']);
const result = shouldExecuteIteratorStep({
step: iteratorStep,
steps,
stepInfos,
});
// Should return false because step-2 info is undefined
expect(result).toBe(false);
});
it('should work with complex loop structures', () => {
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
iteratorStep,
];
const stepInfos = {
'step-1': { status: StepStatus.SUCCESS }, // Loop steps can be any status
'step-2': { status: StepStatus.FAILED },
'step-3': { status: StepStatus.NOT_STARTED },
'step-4': { status: StepStatus.SUCCESS }, // Non-loop steps must be successful
'step-5': { status: StepStatus.SUCCESS },
};
getAllStepIdsInLoop.mockReturnValue(['step-1', 'step-2', 'step-3']);
const result = shouldExecuteIteratorStep({
step: iteratorStep,
steps,
stepInfos,
});
expect(result).toBe(true);
});
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 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
iteratorStep,
];
const stepInfos = {
'step-1': { status: StepStatus.SUCCESS },
'step-2': { status: StepStatus.SUCCESS },
'step-3': { status: StepStatus.SUCCESS },
'step-4': { status: StepStatus.FAILED }, // This should prevent execution
};
getAllStepIdsInLoop.mockReturnValue(['step-1', 'step-2']);
const result = shouldExecuteIteratorStep({
step: iteratorStep,
steps,
stepInfos,
});
expect(result).toBe(false);
});
it('should handle when iterator has no initialLoopStepIds defined', () => {
const iteratorStep = {
...createMockIteratorStep('iterator-1'),
settings: {
input: {
initialLoopStepIds: undefined,
items: [],
},
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
outputSchema: {},
},
} as WorkflowIteratorAction;
const steps = [createMockStep('step-1', ['iterator-1']), iteratorStep];
const stepInfos = {
'step-1': { status: StepStatus.SUCCESS },
};
const result = shouldExecuteIteratorStep({
step: iteratorStep,
steps,
stepInfos,
});
// getAllStepIdsInLoop should not be called if initialLoopStepIds is undefined
expect(getAllStepIdsInLoop).not.toHaveBeenCalled();
expect(result).toBe(true);
});
});
});
@@ -0,0 +1,496 @@
import { StepStatus } from 'twenty-shared/workflow';
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',
() => ({
getAllStepIdsInLoop: jest.fn(),
}),
);
const { getAllStepIdsInLoop } = jest.requireMock(
'src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/get-all-step-ids-in-loop.util',
);
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: {
serverlessFunctionId: 'mock-function-id',
serverlessFunctionVersion: 'mock-function-version',
serverlessFunctionInput: {},
},
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 steps = [
createMockStep('step-1', ['iterator-1']),
createMockStep('step-2', ['iterator-1']),
iteratorStep,
];
const stepInfos = {
'step-1': { status: StepStatus.SKIPPED },
'step-2': { status: StepStatus.SKIPPED },
};
getAllStepIdsInLoop.mockReturnValue(['step-1']);
const result = shouldSkipIteratorStepExecution({
step: iteratorStep,
steps,
stepInfos,
});
expect(result).toBe(true);
});
it('should return true when all parent steps are stopped', () => {
const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
const steps = [
createMockStep('step-1', ['iterator-1']),
createMockStep('step-2', ['iterator-1']),
iteratorStep,
];
const stepInfos = {
'step-1': { status: StepStatus.STOPPED },
'step-2': { status: StepStatus.STOPPED },
};
getAllStepIdsInLoop.mockReturnValue(['step-1']);
const result = shouldSkipIteratorStepExecution({
step: iteratorStep,
steps,
stepInfos,
});
expect(result).toBe(true);
});
it('should return true when parent steps are mix of skipped and stopped', () => {
const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
const steps = [
createMockStep('step-1', ['iterator-1']),
createMockStep('step-2', ['iterator-1']),
iteratorStep,
];
const stepInfos = {
'step-1': { status: StepStatus.SKIPPED },
'step-2': { status: StepStatus.STOPPED },
};
getAllStepIdsInLoop.mockReturnValue(['step-1']);
const result = shouldSkipIteratorStepExecution({
step: iteratorStep,
steps,
stepInfos,
});
expect(result).toBe(true);
});
it('should return false when at least one parent step is successful', () => {
const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
const steps = [
createMockStep('step-1', ['iterator-1']),
createMockStep('step-2', ['iterator-1']),
iteratorStep,
];
const stepInfos = {
'step-1': { status: StepStatus.SKIPPED },
'step-2': { status: StepStatus.SUCCESS },
};
getAllStepIdsInLoop.mockReturnValue(['step-1']);
const result = shouldSkipIteratorStepExecution({
step: iteratorStep,
steps,
stepInfos,
});
expect(result).toBe(false);
});
it('should return false when at least one parent step is failed', () => {
const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
const steps = [
createMockStep('step-1', ['iterator-1']),
createMockStep('step-2', ['iterator-1']),
iteratorStep,
];
const stepInfos = {
'step-1': { status: StepStatus.SKIPPED },
'step-2': { status: StepStatus.FAILED },
};
getAllStepIdsInLoop.mockReturnValue(['step-1']);
const result = shouldSkipIteratorStepExecution({
step: iteratorStep,
steps,
stepInfos,
});
expect(result).toBe(false);
});
it('should return false when at least one parent step is not started', () => {
const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
const steps = [
createMockStep('step-1', ['iterator-1']),
createMockStep('step-2', ['iterator-1']),
iteratorStep,
];
const stepInfos = {
'step-1': { status: StepStatus.SKIPPED },
'step-2': { status: StepStatus.NOT_STARTED },
};
getAllStepIdsInLoop.mockReturnValue(['step-1']);
const result = shouldSkipIteratorStepExecution({
step: iteratorStep,
steps,
stepInfos,
});
expect(result).toBe(false);
});
it('should only check parent steps not in loop', () => {
const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
const steps = [
createMockStep('step-1', ['iterator-1']), // In loop
createMockStep('step-2', ['iterator-1']), // Not in loop
iteratorStep,
];
const stepInfos = {
'step-1': { status: StepStatus.SUCCESS }, // This should be ignored
'step-2': { status: StepStatus.SKIPPED },
};
getAllStepIdsInLoop.mockReturnValue(['step-1']);
const result = shouldSkipIteratorStepExecution({
step: iteratorStep,
steps,
stepInfos,
});
expect(result).toBe(true);
});
it('should return false when there are no parent steps', () => {
const iteratorStep = createMockIteratorStep('iterator-1', []);
const steps = [
createMockStep('step-1', ['step-2']),
createMockStep('step-2', []),
iteratorStep,
];
const stepInfos = {
'step-1': { status: StepStatus.SKIPPED },
'step-2': { status: StepStatus.SKIPPED },
};
getAllStepIdsInLoop.mockReturnValue([]);
const result = shouldSkipIteratorStepExecution({
step: iteratorStep,
steps,
stepInfos,
});
expect(result).toBe(false);
});
it('should handle undefined steps gracefully', () => {
const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
const steps = [
createMockStep('step-1', ['iterator-1']),
undefined as unknown as WorkflowAction,
createMockStep('step-2', ['iterator-1']),
iteratorStep,
];
const stepInfos = {
'step-1': { status: StepStatus.SKIPPED },
'step-2': { status: StepStatus.SKIPPED },
};
getAllStepIdsInLoop.mockReturnValue(['step-1']);
const result = shouldSkipIteratorStepExecution({
step: iteratorStep,
steps,
stepInfos,
});
expect(result).toBe(true);
});
it('should work correctly with multiple steps targeting the same iterator', () => {
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
iteratorStep,
];
const stepInfos = {
'step-1': { status: StepStatus.SUCCESS }, // Loop step, should be ignored
'step-2': { status: StepStatus.SUCCESS }, // Loop step, should be ignored
'step-3': { status: StepStatus.SKIPPED },
'step-4': { status: StepStatus.STOPPED },
};
getAllStepIdsInLoop.mockReturnValue(['step-1', 'step-2']);
const result = shouldSkipIteratorStepExecution({
step: iteratorStep,
steps,
stepInfos,
});
expect(result).toBe(true);
});
it('should return false when one non-loop parent is not skipped/stopped', () => {
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
iteratorStep,
];
const stepInfos = {
'step-1': { status: StepStatus.SUCCESS }, // Loop step, ignored
'step-2': { status: StepStatus.SKIPPED },
'step-3': { status: StepStatus.RUNNING }, // This should make it return false
};
getAllStepIdsInLoop.mockReturnValue(['step-1']);
const result = shouldSkipIteratorStepExecution({
step: iteratorStep,
steps,
stepInfos,
});
expect(result).toBe(false);
});
});
describe('when the iterator has been started', () => {
it('should return false when iterator has been started', () => {
const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
const steps = [
createMockStep('step-1', ['iterator-1']),
createMockStep('step-2', ['iterator-1']),
iteratorStep,
];
const stepInfos = {
'iterator-1': { status: StepStatus.RUNNING }, // Iterator has been started
'step-1': { status: StepStatus.SKIPPED },
'step-2': { status: StepStatus.SKIPPED },
};
getAllStepIdsInLoop.mockReturnValue(['step-1']);
const result = shouldSkipIteratorStepExecution({
step: iteratorStep,
steps,
stepInfos,
});
expect(result).toBe(false);
});
it('should return false even when all parent steps are skipped/stopped', () => {
const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
const steps = [
createMockStep('step-1', ['iterator-1']),
createMockStep('step-2', ['iterator-1']),
iteratorStep,
];
const stepInfos = {
'iterator-1': { status: StepStatus.SUCCESS }, // Iterator has been started
'step-1': { status: StepStatus.SKIPPED },
'step-2': { status: StepStatus.STOPPED },
};
getAllStepIdsInLoop.mockReturnValue(['step-1']);
const result = shouldSkipIteratorStepExecution({
step: iteratorStep,
steps,
stepInfos,
});
expect(result).toBe(false);
});
it('should return false when iterator is pending', () => {
const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
const steps = [createMockStep('step-1', ['iterator-1']), iteratorStep];
const stepInfos = {
'iterator-1': { status: StepStatus.PENDING },
'step-1': { status: StepStatus.SKIPPED },
};
getAllStepIdsInLoop.mockReturnValue(['step-1']);
const result = shouldSkipIteratorStepExecution({
step: iteratorStep,
steps,
stepInfos,
});
expect(result).toBe(false);
});
it('should return false when iterator is skipped', () => {
const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
const steps = [createMockStep('step-1', ['iterator-1']), iteratorStep];
const stepInfos = {
'iterator-1': { status: StepStatus.SKIPPED },
'step-1': { status: StepStatus.SKIPPED },
};
getAllStepIdsInLoop.mockReturnValue(['step-1']);
const result = shouldSkipIteratorStepExecution({
step: iteratorStep,
steps,
stepInfos,
});
expect(result).toBe(false);
});
it('should return false when iterator failed', () => {
const iteratorStep = createMockIteratorStep('iterator-1', ['step-1']);
const steps = [createMockStep('step-1', ['iterator-1']), iteratorStep];
const stepInfos = {
'iterator-1': { status: StepStatus.FAILED },
'step-1': { status: StepStatus.SKIPPED },
};
getAllStepIdsInLoop.mockReturnValue(['step-1']);
const result = shouldSkipIteratorStepExecution({
step: iteratorStep,
steps,
stepInfos,
});
expect(result).toBe(false);
});
});
describe('edge cases with initialLoopStepIds', () => {
it('should handle undefined initialLoopStepIds', () => {
const iteratorStep = {
...createMockIteratorStep('iterator-1'),
settings: {
input: {
initialLoopStepIds: undefined,
items: [],
},
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
outputSchema: {},
},
} as WorkflowIteratorAction;
const steps = [createMockStep('step-1', ['iterator-1']), iteratorStep];
const stepInfos = {
'step-1': { status: StepStatus.SKIPPED },
};
getAllStepIdsInLoop.mockReturnValue([]);
const result = shouldSkipIteratorStepExecution({
step: iteratorStep,
steps,
stepInfos,
});
expect(result).toBe(true);
expect(getAllStepIdsInLoop).not.toHaveBeenCalled();
});
it('should handle empty initialLoopStepIds array', () => {
const iteratorStep = createMockIteratorStep('iterator-1', []);
const steps = [createMockStep('step-1', ['iterator-1']), iteratorStep];
const stepInfos = {
'step-1': { status: StepStatus.SKIPPED },
};
getAllStepIdsInLoop.mockReturnValue([]);
const result = shouldSkipIteratorStepExecution({
step: iteratorStep,
steps,
stepInfos,
});
expect(result).toBe(true);
});
});
});
@@ -0,0 +1,59 @@
import { isDefined } from 'twenty-shared/utils';
import { StepStatus, type WorkflowRunStepInfos } from 'twenty-shared/workflow';
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 shouldExecuteIteratorStep = ({
step,
steps,
stepInfos,
}: {
step: WorkflowIteratorAction;
steps: WorkflowAction[];
stepInfos: WorkflowRunStepInfos;
}) => {
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 parentSteps = stepsTargetingIterator.filter(
(step) => !stepIdsInLoop.includes(step.id),
);
const stepsToCheck = stepHasBeenStarted(step.id, stepInfos)
? stepsTargetingIterator
: parentSteps;
if (stepsToCheck.length === 0) {
return true;
}
const hasSuccessfulParentStep = stepsToCheck.some(
(parentStep) => stepInfos[parentStep.id]?.status === StepStatus.SUCCESS,
);
const hasFailedNorNotStartedOrRunningParentStep = stepsToCheck.some(
(parentStep) =>
stepInfos[parentStep.id]?.status === StepStatus.FAILED ||
stepInfos[parentStep.id]?.status === StepStatus.NOT_STARTED ||
stepInfos[parentStep.id]?.status === StepStatus.RUNNING,
);
return hasSuccessfulParentStep && !hasFailedNorNotStartedOrRunningParentStep;
};
@@ -8,7 +8,7 @@ import {
type WorkflowIteratorAction,
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
export const canExecuteIteratorStep = ({
export const shouldSkipIteratorStepExecution = ({
step,
steps,
stepInfos,
@@ -22,28 +22,27 @@ export const canExecuteIteratorStep = ({
isDefined(parentStep) && parentStep.nextStepIds?.includes(step.id),
);
// If the step has been started, we need to check if all the steps targeting the iterator have been successful.
if (stepHasBeenStarted(step.id, stepInfos)) {
return stepsTargetingIterator.every(
(step) => stepInfos[step.id]?.status === StepStatus.SUCCESS,
);
} else {
const initialLoopStepIds = step.settings.input.initialLoopStepIds;
const initialLoopStepIds = step.settings.input.initialLoopStepIds;
const stepIdsInLoop = isDefined(initialLoopStepIds)
? getAllStepIdsInLoop({
iteratorStepId: step.id,
initialLoopStepIds,
steps,
})
: [];
const stepIdsInLoop = isDefined(initialLoopStepIds)
? getAllStepIdsInLoop({
iteratorStepId: step.id,
initialLoopStepIds,
steps,
})
: [];
const parentSteps = stepsTargetingIterator.filter(
(step) => !stepIdsInLoop.includes(step.id),
);
const parentSteps = stepsTargetingIterator.filter(
(step) => !stepIdsInLoop.includes(step.id),
);
return parentSteps.every(
(step) => stepInfos[step.id]?.status === StepStatus.SUCCESS,
);
if (stepHasBeenStarted(step.id, stepInfos) || parentSteps.length === 0) {
return false;
}
return parentSteps.every(
(step) =>
stepInfos[step.id]?.status === StepStatus.SKIPPED ||
stepInfos[step.id]?.status === StepStatus.STOPPED,
);
};
@@ -8,7 +8,7 @@ import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/bil
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { WorkflowActionFactory } from 'src/modules/workflow/workflow-executor/factories/workflow-action.factory';
import { canExecuteStep } from 'src/modules/workflow/workflow-executor/utils/can-execute-step.util';
import { shouldExecuteStep } from 'src/modules/workflow/workflow-executor/utils/should-execute-step.util';
import {
type WorkflowAction,
WorkflowActionType,
@@ -17,15 +17,15 @@ import { WorkflowExecutorWorkspaceService } from 'src/modules/workflow/workflow-
import { WorkflowRunWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service';
jest.mock(
'src/modules/workflow/workflow-executor/utils/can-execute-step.util',
'src/modules/workflow/workflow-executor/utils/should-execute-step.util',
() => {
const actual = jest.requireActual(
'src/modules/workflow/workflow-executor/utils/can-execute-step.util',
'src/modules/workflow/workflow-executor/utils/should-execute-step.util',
);
return {
...actual,
canExecuteStep: jest.fn().mockReturnValue(true), // default behavior
shouldExecuteStep: jest.fn().mockReturnValue(true), // default behavior
};
},
);
@@ -325,7 +325,7 @@ describe('WorkflowExecutorWorkspaceService', () => {
});
it('should return if step should not be executed', async () => {
(canExecuteStep as jest.Mock).mockReturnValueOnce(false);
(shouldExecuteStep as jest.Mock).mockReturnValueOnce(false);
await service.executeFromSteps({
workflowRunId: mockWorkflowRunId,
@@ -6,6 +6,7 @@ import {
getWorkflowRunContext,
StepStatus,
WorkflowRunStepInfo,
WorkflowRunStepInfos,
} from 'twenty-shared/workflow';
import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/billing-feature-used.constant';
@@ -22,7 +23,8 @@ import {
type WorkflowBranchExecutorInput,
type WorkflowExecutorInput,
} from 'src/modules/workflow/workflow-executor/types/workflow-executor-input';
import { canExecuteStep } from 'src/modules/workflow/workflow-executor/utils/can-execute-step.util';
import { shouldExecuteStep } from 'src/modules/workflow/workflow-executor/utils/should-execute-step.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';
import { isWorkflowIteratorAction } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/guards/is-workflow-iterator-action.guard';
@@ -91,51 +93,35 @@ export class WorkflowExecutorWorkspaceService {
return;
}
let actionOutput: WorkflowActionOutput;
if (
!canExecuteStep({
stepId,
shouldExecuteStep({
step: stepToExecute,
steps,
stepInfos,
workflowRunStatus: workflowRun.status,
})
) {
return;
}
let actionOutput: WorkflowActionOutput;
if (await this.canBillWorkflowNodeExecution(workspaceId)) {
const workflowAction = this.workflowActionFactory.get(stepToExecute.type);
await this.workflowRunWorkspaceService.updateWorkflowRunStepInfo({
stepId,
stepInfo: {
...stepInfos[stepId],
status: StepStatus.RUNNING,
},
actionOutput = await this.executeStep({
step: stepToExecute,
steps,
stepInfos,
workflowRunId,
workspaceId,
});
try {
actionOutput = await workflowAction.execute({
currentStepId: stepId,
steps,
context: getWorkflowRunContext(stepInfos),
runInfo: {
workflowRunId,
workspaceId,
},
});
} catch (error) {
actionOutput = {
error: error.message ?? 'Execution result error, no data or error',
};
}
} else {
} else if (
shouldSkipStepExecution({
step: stepToExecute,
steps,
stepInfos,
})
) {
actionOutput = {
error: BILLING_WORKFLOW_EXECUTION_ERROR_MESSAGE,
shouldSkipStepExecution: true,
};
} else {
return;
}
const isError = isDefined(actionOutput.error);
@@ -266,8 +252,9 @@ export class WorkflowExecutorWorkspaceService {
}): Promise<{ shouldProcessNextSteps: boolean }> {
const isPendingEvent = actionOutput.pendingEvent;
const isSuccess = isDefined(actionOutput.result);
const isStopped = actionOutput.shouldEndWorkflowRun;
const isNotFinished = actionOutput.shouldRemainRunning;
const isStopped = actionOutput.shouldEndWorkflowRun ?? false;
const isNotFinished = actionOutput.shouldRemainRunning ?? false;
const isSkipped = actionOutput.shouldSkipStepExecution ?? false;
let stepInfo: WorkflowRunStepInfo;
@@ -290,6 +277,10 @@ export class WorkflowExecutorWorkspaceService {
status: StepStatus.SUCCESS,
result: actionOutput?.result,
};
} else if (isSkipped) {
stepInfo = {
status: StepStatus.SKIPPED,
};
} else {
stepInfo = {
status: StepStatus.FAILED,
@@ -305,7 +296,59 @@ export class WorkflowExecutorWorkspaceService {
});
return {
shouldProcessNextSteps: isSuccess && !isStopped,
shouldProcessNextSteps: isSuccess || isStopped || isSkipped,
};
}
private async executeStep({
step,
steps,
stepInfos,
workflowRunId,
workspaceId,
}: {
step: WorkflowAction;
steps: WorkflowAction[];
stepInfos: WorkflowRunStepInfos;
workflowRunId: string;
workspaceId: string;
}) {
const canBill = await this.canBillWorkflowNodeExecution(workspaceId);
if (!canBill) {
return {
error: BILLING_WORKFLOW_EXECUTION_ERROR_MESSAGE,
};
}
const stepId = step.id;
const workflowAction = this.workflowActionFactory.get(step.type);
await this.workflowRunWorkspaceService.updateWorkflowRunStepInfo({
stepId,
stepInfo: {
...stepInfos[stepId],
status: StepStatus.RUNNING,
},
workflowRunId,
workspaceId,
});
try {
return await workflowAction.execute({
currentStepId: stepId,
steps,
context: getWorkflowRunContext(stepInfos),
runInfo: {
workflowRunId,
workspaceId,
},
});
} catch (error) {
return {
error: error.message ?? 'Execution result error, no data or error',
};
}
}
}