From cbfd73cbd8ce8cb355c8287a6ca4ddbd5a4f4f6f Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Thu, 9 Oct 2025 23:14:54 +0200 Subject: [PATCH] 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 --- .../types/workflow-action-output.type.ts | 1 + .../__tests__/can-execute-step.util.spec.ts | 236 --------- .../should-execute-step.util.spec.ts | 470 +++++++++++++++++ .../should-skip-step-execution.util.spec.ts | 323 ++++++++++++ ...ep.util.ts => should-execute-step.util.ts} | 35 +- .../utils/should-skip-step-execution.util.ts | 39 ++ .../workflow-should-keep-running.util.ts | 23 +- ...should-execute-iterator-step.util.spec.ts} | 173 +++++- ...-skip-iterator-step-execution.util.spec.ts | 496 ++++++++++++++++++ .../should-execute-iterator-step.util.ts | 59 +++ ...ould-skip-iterator-step-execution.util.ts} | 41 +- ...orkflow-executor.workspace-service.spec.ts | 10 +- .../workflow-executor.workspace-service.ts | 119 +++-- .../types/WorkflowRunStateStepInfos.ts | 1 + .../json-visualizer/components/JsonNode.tsx | 4 +- 15 files changed, 1692 insertions(+), 338 deletions(-) delete mode 100644 packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/can-execute-step.util.spec.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/should-execute-step.util.spec.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/should-skip-step-execution.util.spec.ts rename packages/twenty-server/src/modules/workflow/workflow-executor/utils/{can-execute-step.util.ts => should-execute-step.util.ts} (61%) create mode 100644 packages/twenty-server/src/modules/workflow/workflow-executor/utils/should-skip-step-execution.util.ts rename packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/{can-execute-iterator-step.util.spec.ts => should-execute-iterator-step.util.spec.ts} (65%) create mode 100644 packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/should-skip-iterator-step-execution.util.spec.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/should-execute-iterator-step.util.ts rename packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/{can-execute-iterator-step.util.ts => should-skip-iterator-step-execution.util.ts} (50%) diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/types/workflow-action-output.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/types/workflow-action-output.type.ts index 6e5f16f9e3d..aaed9318917 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/types/workflow-action-output.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/types/workflow-action-output.type.ts @@ -4,4 +4,5 @@ export type WorkflowActionOutput = { pendingEvent?: boolean; shouldEndWorkflowRun?: boolean; shouldRemainRunning?: boolean; + shouldSkipStepExecution?: boolean; }; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/can-execute-step.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/can-execute-step.util.spec.ts deleted file mode 100644 index 720f6c5dd61..00000000000 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/can-execute-step.util.spec.ts +++ /dev/null @@ -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); - } - }); -}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/should-execute-step.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/should-execute-step.util.spec.ts new file mode 100644 index 00000000000..5010354babe --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/should-execute-step.util.spec.ts @@ -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); + }); +}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/should-skip-step-execution.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/should-skip-step-execution.util.spec.ts new file mode 100644 index 00000000000..12cf0cd51dc --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/should-skip-step-execution.util.spec.ts @@ -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); + }); +}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/can-execute-step.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/should-execute-step.util.ts similarity index 61% rename from packages/twenty-server/src/modules/workflow/workflow-executor/utils/can-execute-step.util.ts rename to packages/twenty-server/src/modules/workflow/workflow-executor/utils/should-execute-step.util.ts index 15cdcbee44f..cf5ce656c8b 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/can-execute-step.util.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/should-execute-step.util.ts @@ -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; }; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/should-skip-step-execution.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/should-skip-step-execution.util.ts new file mode 100644 index 00000000000..bd5a745813c --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/should-skip-step-execution.util.ts @@ -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, + ); +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/workflow-should-keep-running.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/workflow-should-keep-running.util.ts index 6b533a0771b..40be1746c8f 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/workflow-should-keep-running.util.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/workflow-should-keep-running.util.ts @@ -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 ( diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/can-execute-iterator-step.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/should-execute-iterator-step.util.spec.ts similarity index 65% rename from packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/can-execute-iterator-step.util.spec.ts rename to packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/should-execute-iterator-step.util.spec.ts index e9e8dfff152..26d42d9d688 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/can-execute-iterator-step.util.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/should-execute-iterator-step.util.spec.ts @@ -1,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); + }); }); }); diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/should-skip-iterator-step-execution.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/should-skip-iterator-step-execution.util.spec.ts new file mode 100644 index 00000000000..994f0dfbea8 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/should-skip-iterator-step-execution.util.spec.ts @@ -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); + }); + }); +}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/should-execute-iterator-step.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/should-execute-iterator-step.util.ts new file mode 100644 index 00000000000..2206562d290 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/should-execute-iterator-step.util.ts @@ -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; +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/can-execute-iterator-step.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/should-skip-iterator-step-execution.util.ts similarity index 50% rename from packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/can-execute-iterator-step.util.ts rename to packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/should-skip-iterator-step-execution.util.ts index 44065d13a30..41351ca6a88 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/can-execute-iterator-step.util.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/should-skip-iterator-step-execution.util.ts @@ -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, + ); }; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/__tests__/workflow-executor.workspace-service.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/__tests__/workflow-executor.workspace-service.spec.ts index 053ed463166..bc57bc6be76 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/__tests__/workflow-executor.workspace-service.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/__tests__/workflow-executor.workspace-service.spec.ts @@ -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, diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/workflow-executor.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/workflow-executor.workspace-service.ts index b93e4150175..4c28da3f4ce 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/workflow-executor.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/workflow-executor.workspace-service.ts @@ -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', + }; + } + } } diff --git a/packages/twenty-shared/src/workflow/types/WorkflowRunStateStepInfos.ts b/packages/twenty-shared/src/workflow/types/WorkflowRunStateStepInfos.ts index 444cac78a3a..072dc1c5a50 100644 --- a/packages/twenty-shared/src/workflow/types/WorkflowRunStateStepInfos.ts +++ b/packages/twenty-shared/src/workflow/types/WorkflowRunStateStepInfos.ts @@ -9,6 +9,7 @@ export enum StepStatus { STOPPED = 'STOPPED', FAILED = 'FAILED', PENDING = 'PENDING', + SKIPPED = 'SKIPPED', } export type WorkflowRunStepInfo = z.infer< diff --git a/packages/twenty-ui/src/json-visualizer/components/JsonNode.tsx b/packages/twenty-ui/src/json-visualizer/components/JsonNode.tsx index e386915a9f4..821abc01d2b 100644 --- a/packages/twenty-ui/src/json-visualizer/components/JsonNode.tsx +++ b/packages/twenty-ui/src/json-visualizer/components/JsonNode.tsx @@ -1,7 +1,6 @@ import { isBoolean, isNonEmptyString, - isNull, isNumber, isString, } from '@sniptt/guards'; @@ -16,6 +15,7 @@ import { JsonObjectNode } from '@ui/json-visualizer/components/JsonObjectNode'; import { JsonValueNode } from '@ui/json-visualizer/components/JsonValueNode'; import { useJsonTreeContextOrThrow } from '@ui/json-visualizer/hooks/useJsonTreeContextOrThrow'; import { isArray } from '@ui/json-visualizer/utils/isArray'; +import { isDefined } from 'twenty-shared/utils'; import { type JsonValue } from 'type-fest'; export const JsonNode = ({ @@ -33,7 +33,7 @@ export const JsonNode = ({ const highlighting = getNodeHighlighting?.(keyPath); - if (isNull(value)) { + if (!isDefined(value)) { return (