22 branches 2 (#13051)

This PR is purely technical, it does produces any functional change to
the user

- add Lock mecanism to run steps concurrently
- update `workflow-executor.workspace-service.ts` to handle multi branch
workflow execution
  - stop passing `context` through steps, it causes race condition issue
  - refactor a little bit
- simplify `workflow-run.workspace-service.ts` to prepare `output` and
`context` removal
- move workflowRun status computing from `run-workflow.job.ts` to
`workflow-executor.workspace-service.ts`

## NOTA BENE
When a code step depends of 2 parents like in this config (see image
below)

If the form is submitted before the "Code - 2s" step succeed, the branch
merge "Form" step is launched twice.
- once because form is submission Succeed resumes the workflow in an
asynchronous job
- the second time is when the asynchronous job is launched when "Code -
2s" is succeeded
- the merge "Form" step makes the workflow waiting for response to
trigger the resume in another job
- during that time, the first resume job is launched, running the merge
"Form" step again

This issue only occurs with branch workflows. It will be solved by
checking if the currentStepToExecute is already in a SUCCESS state or
not

<img width="505" alt="image"
src="https://github.com/user-attachments/assets/b73839a1-16fe-45e1-a0d9-3efa26ab4f8b"
/>
This commit is contained in:
martmull
2025-07-07 22:50:34 +02:00
committed by GitHub
parent 51d02c13bf
commit 2f7d8c76af
31 changed files with 876 additions and 523 deletions
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { WorkflowExecutor } from 'src/modules/workflow/workflow-executor/interfaces/workflow-executor.interface';
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import {
WorkflowStepExecutorException,
@@ -19,7 +19,7 @@ import { UpdateRecordWorkflowAction } from 'src/modules/workflow/workflow-execut
import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
@Injectable()
export class WorkflowExecutorFactory {
export class WorkflowActionFactory {
constructor(
private readonly codeWorkflowAction: CodeWorkflowAction,
private readonly sendEmailWorkflowAction: SendEmailWorkflowAction,
@@ -33,7 +33,7 @@ export class WorkflowExecutorFactory {
private readonly aiAgentWorkflowAction: AiAgentWorkflowAction,
) {}
get(stepType: WorkflowActionType): WorkflowExecutor {
get(stepType: WorkflowActionType): WorkflowAction {
switch (stepType) {
case WorkflowActionType.CODE:
return this.codeWorkflowAction;
@@ -0,0 +1,8 @@
import { WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
import { WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
export interface WorkflowAction {
execute(
workflowActionInput: WorkflowActionInput,
): Promise<WorkflowActionOutput>;
}
@@ -1,8 +0,0 @@
import { WorkflowExecutorInput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-input';
import { WorkflowExecutorOutput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-output.type';
export interface WorkflowExecutor {
execute(
workflowExecutorInput: WorkflowExecutorInput,
): Promise<WorkflowExecutorOutput>;
}
@@ -0,0 +1,7 @@
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
export type WorkflowActionInput = {
currentStepId: string;
steps: WorkflowAction[];
context: Record<string, unknown>;
};
@@ -1,4 +1,4 @@
export type WorkflowExecutorOutput = {
export type WorkflowActionOutput = {
result?: object;
error?: string;
pendingEvent?: boolean;
@@ -1,9 +1,12 @@
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
export type WorkflowExecutorInput = {
currentStepId: string;
steps: WorkflowAction[];
context: Record<string, unknown>;
stepIds: string[];
workflowRunId: string;
workspaceId: string;
};
export type WorkflowBranchExecutorInput = {
stepId: string;
attemptCount?: number;
workflowRunId: string;
workspaceId: string;
};
@@ -0,0 +1,90 @@
import {
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.utils';
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 context = {
trigger: 'trigger result',
'step-1': 'step-1 result',
'step-2': 'step-2 result',
};
const result = canExecuteStep({ context, steps, stepId: 'step-3' });
expect(result).toBe(true);
});
it('should return false if one parent is not succeeded', () => {
expect(
canExecuteStep({
context: {
trigger: 'trigger result',
'step-2': 'step-2 result',
},
steps,
stepId: 'step-3',
}),
).toBe(false);
expect(
canExecuteStep({
context: {
trigger: 'trigger result',
'step-1': 'step-1 result',
},
steps,
stepId: 'step-3',
}),
).toBe(false);
expect(
canExecuteStep({
context: {
trigger: 'trigger result',
'step-1': {},
},
steps,
stepId: 'step-3',
}),
).toBe(false);
});
});
@@ -0,0 +1,23 @@
import { isDefined } from 'twenty-shared/utils';
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
export const canExecuteStep = ({
context,
stepId,
steps,
}: {
steps: WorkflowAction[];
context: Record<string, unknown>;
stepId: string;
}) => {
const parentSteps = steps.filter(
(parentStep) =>
isDefined(parentStep) && parentStep.nextStepIds?.includes(stepId),
);
// TODO use workflowRun.state to check if step status is not COMPLETED. Return false in this case
return parentSteps.every((parentStep) =>
Object.keys(context).includes(parentStep.id),
);
};
@@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WorkflowExecutor } from 'src/modules/workflow/workflow-executor/interfaces/workflow-executor.interface';
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
import { AgentExecutionService } from 'src/engine/metadata-modules/agent/agent-execution.service';
@@ -16,13 +16,13 @@ import {
WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode,
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { WorkflowExecutorInput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-input';
import { WorkflowExecutorOutput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-output.type';
import { WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
import { WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { isWorkflowAiAgentAction } from './guards/is-workflow-ai-agent-action.guard';
@Injectable()
export class AiAgentWorkflowAction implements WorkflowExecutor {
export class AiAgentWorkflowAction implements WorkflowAction {
constructor(
private readonly agentExecutionService: AgentExecutionService,
private readonly aiBillingService: AIBillingService,
@@ -34,7 +34,7 @@ export class AiAgentWorkflowAction implements WorkflowExecutor {
currentStepId,
steps,
context,
}: WorkflowExecutorInput): Promise<WorkflowExecutorOutput> {
}: WorkflowActionInput): Promise<WorkflowActionOutput> {
const step = steps.find((step) => step.id === currentStepId);
if (!step) {
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { WorkflowExecutor } from 'src/modules/workflow/workflow-executor/interfaces/workflow-executor.interface';
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
@@ -8,14 +8,14 @@ import {
WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode,
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { WorkflowExecutorInput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-input';
import { WorkflowExecutorOutput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-output.type';
import { WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
import { WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { resolveInput } from 'src/modules/workflow/workflow-executor/utils/variable-resolver.util';
import { isWorkflowCodeAction } from 'src/modules/workflow/workflow-executor/workflow-actions/code/guards/is-workflow-code-action.guard';
import { WorkflowCodeActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/code/types/workflow-code-action-input.type';
@Injectable()
export class CodeWorkflowAction implements WorkflowExecutor {
export class CodeWorkflowAction implements WorkflowAction {
constructor(
private readonly serverlessFunctionService: ServerlessFunctionService,
private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory,
@@ -25,7 +25,7 @@ export class CodeWorkflowAction implements WorkflowExecutor {
currentStepId,
steps,
context,
}: WorkflowExecutorInput): Promise<WorkflowExecutorOutput> {
}: WorkflowActionInput): Promise<WorkflowActionOutput> {
const step = steps.find((step) => step.id === currentStepId);
if (!step) {
@@ -1,20 +1,20 @@
import { Injectable } from '@nestjs/common';
import { WorkflowExecutor } from 'src/modules/workflow/workflow-executor/interfaces/workflow-executor.interface';
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import {
WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode,
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { WorkflowExecutorInput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-input';
import { WorkflowExecutorOutput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-output.type';
import { WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
import { WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { resolveInput } from 'src/modules/workflow/workflow-executor/utils/variable-resolver.util';
import { isWorkflowFilterAction } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/guards/is-workflow-filter-action.guard';
import { evaluateFilterConditions } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util';
@Injectable()
export class FilterWorkflowAction implements WorkflowExecutor {
async execute(input: WorkflowExecutorInput): Promise<WorkflowExecutorOutput> {
export class FilterWorkflowAction implements WorkflowAction {
async execute(input: WorkflowActionInput): Promise<WorkflowActionOutput> {
const { currentStepId, steps, context } = input;
const step = steps.find((step) => step.id === currentStepId);
@@ -1,21 +1,21 @@
import { Injectable } from '@nestjs/common';
import { WorkflowExecutor } from 'src/modules/workflow/workflow-executor/interfaces/workflow-executor.interface';
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import {
WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode,
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { WorkflowExecutorInput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-input';
import { WorkflowExecutorOutput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-output.type';
import { WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
import { WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { isWorkflowFormAction } from 'src/modules/workflow/workflow-executor/workflow-actions/form/guards/is-workflow-form-action.guard';
@Injectable()
export class FormWorkflowAction implements WorkflowExecutor {
export class FormWorkflowAction implements WorkflowAction {
async execute({
currentStepId,
steps,
}: WorkflowExecutorInput): Promise<WorkflowExecutorOutput> {
}: WorkflowActionInput): Promise<WorkflowActionOutput> {
const step = steps.find((step) => step.id === currentStepId);
if (!step) {
@@ -3,26 +3,26 @@ import { Injectable } from '@nestjs/common';
import { isString } from '@sniptt/guards';
import axios, { AxiosRequestConfig } from 'axios';
import { WorkflowExecutor } from 'src/modules/workflow/workflow-executor/interfaces/workflow-executor.interface';
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import {
WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode,
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { WorkflowExecutorInput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-input';
import { WorkflowExecutorOutput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-output.type';
import { WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
import { WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { resolveInput } from 'src/modules/workflow/workflow-executor/utils/variable-resolver.util';
import { isWorkflowHttpRequestAction } from './guards/is-workflow-http-request-action.guard';
import { WorkflowHttpRequestActionInput } from './types/workflow-http-request-action-input.type';
@Injectable()
export class HttpRequestWorkflowAction implements WorkflowExecutor {
export class HttpRequestWorkflowAction implements WorkflowAction {
async execute({
currentStepId,
steps,
context,
}: WorkflowExecutorInput): Promise<WorkflowExecutorOutput> {
}: WorkflowActionInput): Promise<WorkflowActionOutput> {
const step = steps.find((step) => step.id === currentStepId);
if (!step) {
@@ -5,7 +5,7 @@ import { JSDOM } from 'jsdom';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { z } from 'zod';
import { WorkflowExecutor } from 'src/modules/workflow/workflow-executor/interfaces/workflow-executor.interface';
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@@ -15,8 +15,8 @@ import {
WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode,
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { WorkflowExecutorInput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-input';
import { WorkflowExecutorOutput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-output.type';
import { WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
import { WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { resolveInput } from 'src/modules/workflow/workflow-executor/utils/variable-resolver.util';
import {
SendEmailActionException,
@@ -30,7 +30,7 @@ export type WorkflowSendEmailStepOutputSchema = {
};
@Injectable()
export class SendEmailWorkflowAction implements WorkflowExecutor {
export class SendEmailWorkflowAction implements WorkflowAction {
private readonly logger = new Logger(SendEmailWorkflowAction.name);
constructor(
private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory,
@@ -78,7 +78,7 @@ export class SendEmailWorkflowAction implements WorkflowExecutor {
currentStepId,
steps,
context,
}: WorkflowExecutorInput): Promise<WorkflowExecutorOutput> {
}: WorkflowActionInput): Promise<WorkflowActionOutput> {
const step = steps.find((step) => step.id === currentStepId);
if (!step) {
@@ -4,7 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'class-validator';
import { Repository } from 'typeorm';
import { WorkflowExecutor } from 'src/modules/workflow/workflow-executor/interfaces/workflow-executor.interface';
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
@@ -19,8 +19,8 @@ import {
WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode,
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { WorkflowExecutorInput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-input';
import { WorkflowExecutorOutput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-output.type';
import { WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
import { WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { resolveInput } from 'src/modules/workflow/workflow-executor/utils/variable-resolver.util';
import {
RecordCRUDActionException,
@@ -30,7 +30,7 @@ import { isWorkflowCreateRecordAction } from 'src/modules/workflow/workflow-exec
import { WorkflowCreateRecordActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type';
@Injectable()
export class CreateRecordWorkflowAction implements WorkflowExecutor {
export class CreateRecordWorkflowAction implements WorkflowAction {
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
@InjectRepository(ObjectMetadataEntity, 'core')
@@ -46,7 +46,7 @@ export class CreateRecordWorkflowAction implements WorkflowExecutor {
currentStepId,
steps,
context,
}: WorkflowExecutorInput): Promise<WorkflowExecutorOutput> {
}: WorkflowActionInput): Promise<WorkflowActionOutput> {
const step = steps.find((step) => step.id === currentStepId);
if (!step) {
@@ -5,7 +5,7 @@ import { isDefined } from 'class-validator';
import { isValidUuid } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { WorkflowExecutor } from 'src/modules/workflow/workflow-executor/interfaces/workflow-executor.interface';
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
@@ -16,8 +16,8 @@ import {
WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode,
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { WorkflowExecutorInput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-input';
import { WorkflowExecutorOutput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-output.type';
import { WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
import { WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { resolveInput } from 'src/modules/workflow/workflow-executor/utils/variable-resolver.util';
import {
RecordCRUDActionException,
@@ -27,7 +27,7 @@ import { isWorkflowDeleteRecordAction } from 'src/modules/workflow/workflow-exec
import { WorkflowDeleteRecordActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type';
@Injectable()
export class DeleteRecordWorkflowAction implements WorkflowExecutor {
export class DeleteRecordWorkflowAction implements WorkflowAction {
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
@InjectRepository(ObjectMetadataEntity, 'core')
@@ -40,7 +40,7 @@ export class DeleteRecordWorkflowAction implements WorkflowExecutor {
currentStepId,
steps,
context,
}: WorkflowExecutorInput): Promise<WorkflowExecutorOutput> {
}: WorkflowActionInput): Promise<WorkflowActionOutput> {
const step = steps.find((step) => step.id === currentStepId);
if (!step) {
@@ -9,7 +9,7 @@ import {
ObjectRecordOrderBy,
OrderByDirection,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { WorkflowExecutor } from 'src/modules/workflow/workflow-executor/interfaces/workflow-executor.interface';
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
@@ -23,8 +23,8 @@ import {
WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode,
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { WorkflowExecutorInput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-input';
import { WorkflowExecutorOutput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-output.type';
import { WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
import { WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { resolveInput } from 'src/modules/workflow/workflow-executor/utils/variable-resolver.util';
import {
RecordCRUDActionException,
@@ -34,7 +34,7 @@ import { isWorkflowFindRecordsAction } from 'src/modules/workflow/workflow-execu
import { WorkflowFindRecordsActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type';
@Injectable()
export class FindRecordsWorkflowAction implements WorkflowExecutor {
export class FindRecordsWorkflowAction implements WorkflowAction {
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory,
@@ -45,7 +45,7 @@ export class FindRecordsWorkflowAction implements WorkflowExecutor {
currentStepId,
steps,
context,
}: WorkflowExecutorInput): Promise<WorkflowExecutorOutput> {
}: WorkflowActionInput): Promise<WorkflowActionOutput> {
const step = steps.find((step) => step.id === currentStepId);
if (!step) {
@@ -5,7 +5,7 @@ import deepEqual from 'deep-equal';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { WorkflowExecutor } from 'src/modules/workflow/workflow-executor/interfaces/workflow-executor.interface';
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { objectRecordChangedValues } from 'src/engine/core-modules/event-emitter/utils/object-record-changed-values';
@@ -20,8 +20,8 @@ import {
WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode,
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { WorkflowExecutorInput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-input';
import { WorkflowExecutorOutput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-output.type';
import { WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
import { WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { resolveInput } from 'src/modules/workflow/workflow-executor/utils/variable-resolver.util';
import {
RecordCRUDActionException,
@@ -31,7 +31,7 @@ import { isWorkflowUpdateRecordAction } from 'src/modules/workflow/workflow-exec
import { WorkflowUpdateRecordActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type';
@Injectable()
export class UpdateRecordWorkflowAction implements WorkflowExecutor {
export class UpdateRecordWorkflowAction implements WorkflowAction {
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory,
@@ -46,7 +46,7 @@ export class UpdateRecordWorkflowAction implements WorkflowExecutor {
currentStepId,
steps,
context,
}: WorkflowExecutorInput): Promise<WorkflowExecutorOutput> {
}: WorkflowActionInput): Promise<WorkflowActionOutput> {
const step = steps.find((step) => step.id === currentStepId);
if (!step) {
@@ -4,7 +4,7 @@ import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module';
import { WorkflowExecutorFactory } from 'src/modules/workflow/workflow-executor/factories/workflow-executor.factory';
import { WorkflowActionFactory } from 'src/modules/workflow/workflow-executor/factories/workflow-action.factory';
import { AiAgentActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent-action.module';
import { CodeActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/code/code-action.module';
import { FilterActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/filter-action.module';
@@ -32,7 +32,7 @@ import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow
providers: [
WorkflowExecutorWorkspaceService,
ScopedWorkspaceContextFactory,
WorkflowExecutorFactory,
WorkflowActionFactory,
],
exports: [WorkflowExecutorWorkspaceService],
})
@@ -4,9 +4,8 @@ import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/
import { BILLING_WORKFLOW_EXECUTION_ERROR_MESSAGE } from 'src/engine/core-modules/billing/constants/billing-workflow-execution-error-message.constant';
import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { WorkflowExecutorFactory } from 'src/modules/workflow/workflow-executor/factories/workflow-executor.factory';
import { WorkflowActionFactory } from 'src/modules/workflow/workflow-executor/factories/workflow-action.factory';
import {
WorkflowAction,
WorkflowActionType,
@@ -14,10 +13,26 @@ import {
import { WorkflowExecutorWorkspaceService } from 'src/modules/workflow/workflow-executor/workspace-services/workflow-executor.workspace-service';
import { WorkflowRunWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service';
import { StepStatus } from 'src/modules/workflow/workflow-executor/types/workflow-run-step-info.type';
import { WorkflowRunStatus } from 'src/modules/workflow/common/standard-objects/workflow-run.workspace-entity';
import { canExecuteStep } from 'src/modules/workflow/workflow-executor/utils/can-execute-step.utils';
jest.mock(
'src/modules/workflow/workflow-executor/utils/can-execute-step.utils',
() => {
const actual = jest.requireActual(
'src/modules/workflow/workflow-executor/utils/can-execute-step.utils',
);
return {
...actual,
canExecuteStep: jest.fn().mockReturnValue(true), // default behavior
};
},
);
describe('WorkflowExecutorWorkspaceService', () => {
let service: WorkflowExecutorWorkspaceService;
let workflowExecutorFactory: WorkflowExecutorFactory;
let workflowActionFactory: WorkflowActionFactory;
let workspaceEventEmitter: WorkspaceEventEmitter;
let workflowRunWorkspaceService: WorkflowRunWorkspaceService;
@@ -29,22 +44,16 @@ describe('WorkflowExecutorWorkspaceService', () => {
emitCustomBatchEvent: jest.fn(),
};
const mockScopedWorkspaceContext = {
workspaceId: 'workspace-id',
};
const mockScopedWorkspaceContextFactory = {
create: jest.fn().mockReturnValue(mockScopedWorkspaceContext),
};
const mockWorkflowRunWorkspaceService = {
saveWorkflowRunState: jest.fn(),
endWorkflowRun: jest.fn(),
updateWorkflowRunStepStatus: jest.fn(),
saveWorkflowRunState: jest.fn(),
getWorkflowRun: jest.fn(),
};
const mockBillingService = {
isBillingEnabled: jest.fn(),
canBillMeteredProduct: jest.fn(),
isBillingEnabled: jest.fn().mockReturnValue(true),
canBillMeteredProduct: jest.fn().mockReturnValue(true),
};
beforeEach(async () => {
@@ -54,7 +63,7 @@ describe('WorkflowExecutorWorkspaceService', () => {
providers: [
WorkflowExecutorWorkspaceService,
{
provide: WorkflowExecutorFactory,
provide: WorkflowActionFactory,
useValue: {
get: jest.fn().mockReturnValue(mockWorkflowExecutor),
},
@@ -63,10 +72,6 @@ describe('WorkflowExecutorWorkspaceService', () => {
provide: WorkspaceEventEmitter,
useValue: mockWorkspaceEventEmitter,
},
{
provide: ScopedWorkspaceContextFactory,
useValue: mockScopedWorkspaceContextFactory,
},
{
provide: WorkflowRunWorkspaceService,
useValue: mockWorkflowRunWorkspaceService,
@@ -81,8 +86,8 @@ describe('WorkflowExecutorWorkspaceService', () => {
service = module.get<WorkflowExecutorWorkspaceService>(
WorkflowExecutorWorkspaceService,
);
workflowExecutorFactory = module.get<WorkflowExecutorFactory>(
WorkflowExecutorFactory,
workflowActionFactory = module.get<WorkflowActionFactory>(
WorkflowActionFactory,
);
workspaceEventEmitter = module.get<WorkspaceEventEmitter>(
WorkspaceEventEmitter,
@@ -94,7 +99,8 @@ describe('WorkflowExecutorWorkspaceService', () => {
describe('execute', () => {
const mockWorkflowRunId = 'workflow-run-id';
const mockContext = { data: 'some-data' };
const mockWorkspaceId = 'workspace-id';
const mockContext = { trigger: 'trigger-result' };
const mockSteps = [
{
id: 'step-1',
@@ -120,20 +126,9 @@ describe('WorkflowExecutorWorkspaceService', () => {
},
] as WorkflowAction[];
it('should return success when all steps are completed', async () => {
// No steps to execute
const result = await service.execute({
workflowRunId: mockWorkflowRunId,
currentStepId: 'step-2',
steps: mockSteps,
context: mockContext,
});
expect(result).toEqual({
result: {
success: true,
},
});
mockWorkflowRunWorkspaceService.getWorkflowRun.mockReturnValue({
output: { flow: { steps: mockSteps } },
context: mockContext,
});
it('should execute a step and continue to the next step on success', async () => {
@@ -143,24 +138,22 @@ describe('WorkflowExecutorWorkspaceService', () => {
mockWorkflowExecutor.execute.mockResolvedValueOnce(mockStepResult);
const result = await service.execute({
await service.executeFromSteps({
workflowRunId: mockWorkflowRunId,
stepIds: ['step-1'],
workspaceId: mockWorkspaceId,
});
expect(workflowActionFactory.get).toHaveBeenCalledWith(
WorkflowActionType.CODE,
);
expect(mockWorkflowExecutor.execute).toHaveBeenCalledWith({
currentStepId: 'step-1',
steps: mockSteps,
context: mockContext,
});
// execute first step
expect(workflowExecutorFactory.get).toHaveBeenCalledWith(
WorkflowActionType.CODE,
);
expect(mockWorkflowExecutor.execute).toHaveBeenCalledWith({
workflowRunId: mockWorkflowRunId,
currentStepId: 'step-1',
steps: mockSteps,
context: mockContext,
attemptCount: 1,
});
expect(workspaceEventEmitter.emitCustomBatchEvent).toHaveBeenCalledWith(
BILLING_FEATURE_USED,
[
@@ -171,9 +164,11 @@ describe('WorkflowExecutorWorkspaceService', () => {
],
'workspace-id',
);
expect(
workflowRunWorkspaceService.updateWorkflowRunStepStatus,
).toHaveBeenCalledTimes(2);
expect(
workflowRunWorkspaceService.updateWorkflowRunStepStatus,
).toHaveBeenCalledWith({
@@ -182,9 +177,11 @@ describe('WorkflowExecutorWorkspaceService', () => {
workspaceId: 'workspace-id',
stepStatus: StepStatus.RUNNING,
});
expect(
workflowRunWorkspaceService.saveWorkflowRunState,
).toHaveBeenCalledTimes(2);
expect(
workflowRunWorkspaceService.saveWorkflowRunState,
).toHaveBeenCalledWith({
@@ -193,20 +190,12 @@ describe('WorkflowExecutorWorkspaceService', () => {
id: 'step-1',
output: mockStepResult,
},
context: {
data: 'some-data',
'step-1': {
stepOutput: 'success',
},
},
workspaceId: 'workspace-id',
stepStatus: StepStatus.SUCCESS,
});
expect(result).toEqual({ result: { success: true } });
// execute second step
expect(workflowExecutorFactory.get).toHaveBeenCalledWith(
expect(workflowActionFactory.get).toHaveBeenCalledWith(
WorkflowActionType.SEND_EMAIL,
);
});
@@ -216,20 +205,18 @@ describe('WorkflowExecutorWorkspaceService', () => {
new Error('Step execution failed'),
);
const result = await service.execute({
await service.executeFromSteps({
workflowRunId: mockWorkflowRunId,
currentStepId: 'step-1',
steps: mockSteps,
context: mockContext,
stepIds: ['step-1'],
workspaceId: mockWorkspaceId,
});
expect(result).toEqual({
error: 'Step execution failed',
});
expect(workspaceEventEmitter.emitCustomBatchEvent).not.toHaveBeenCalled();
expect(
workflowRunWorkspaceService.updateWorkflowRunStepStatus,
).toHaveBeenCalledTimes(1);
expect(
workflowRunWorkspaceService.updateWorkflowRunStepStatus,
).toHaveBeenCalledWith({
@@ -238,9 +225,11 @@ describe('WorkflowExecutorWorkspaceService', () => {
workspaceId: 'workspace-id',
stepStatus: StepStatus.RUNNING,
});
expect(
workflowRunWorkspaceService.saveWorkflowRunState,
).toHaveBeenCalledTimes(1);
expect(
workflowRunWorkspaceService.saveWorkflowRunState,
).toHaveBeenCalledWith({
@@ -251,7 +240,6 @@ describe('WorkflowExecutorWorkspaceService', () => {
error: 'Step execution failed',
},
},
context: mockContext,
workspaceId: 'workspace-id',
stepStatus: StepStatus.FAILED,
});
@@ -264,17 +252,16 @@ describe('WorkflowExecutorWorkspaceService', () => {
mockWorkflowExecutor.execute.mockResolvedValueOnce(mockPendingEvent);
const result = await service.execute({
await service.executeFromSteps({
workflowRunId: mockWorkflowRunId,
currentStepId: 'step-1',
steps: mockSteps,
context: mockContext,
stepIds: ['step-1'],
workspaceId: mockWorkspaceId,
});
expect(result).toEqual(mockPendingEvent);
expect(
workflowRunWorkspaceService.updateWorkflowRunStepStatus,
).toHaveBeenCalledTimes(1);
expect(
workflowRunWorkspaceService.updateWorkflowRunStepStatus,
).toHaveBeenCalledWith({
@@ -283,9 +270,11 @@ describe('WorkflowExecutorWorkspaceService', () => {
workspaceId: 'workspace-id',
stepStatus: StepStatus.RUNNING,
});
expect(
workflowRunWorkspaceService.saveWorkflowRunState,
).toHaveBeenCalledTimes(1);
expect(
workflowRunWorkspaceService.saveWorkflowRunState,
).toHaveBeenCalledWith({
@@ -294,13 +283,12 @@ describe('WorkflowExecutorWorkspaceService', () => {
id: 'step-1',
output: mockPendingEvent,
},
context: mockContext,
workspaceId: 'workspace-id',
stepStatus: StepStatus.PENDING,
});
// No recursive call to execute should happen
expect(workflowExecutorFactory.get).not.toHaveBeenCalledWith(
expect(workflowActionFactory.get).not.toHaveBeenCalledWith(
WorkflowActionType.SEND_EMAIL,
);
});
@@ -330,15 +318,19 @@ describe('WorkflowExecutorWorkspaceService', () => {
},
] as WorkflowAction[];
mockWorkflowRunWorkspaceService.getWorkflowRun.mockReturnValueOnce({
output: { flow: { steps: stepsWithContinueOnFailure } },
context: mockContext,
});
mockWorkflowExecutor.execute.mockResolvedValueOnce({
error: 'Step execution failed but continue',
});
const result = await service.execute({
await service.executeFromSteps({
workflowRunId: mockWorkflowRunId,
currentStepId: 'step-1',
steps: stepsWithContinueOnFailure,
context: mockContext,
stepIds: ['step-1'],
workspaceId: mockWorkspaceId,
});
expect(
@@ -368,14 +360,12 @@ describe('WorkflowExecutorWorkspaceService', () => {
error: 'Step execution failed but continue',
},
},
context: mockContext,
workspaceId: 'workspace-id',
stepStatus: StepStatus.FAILED,
});
expect(result).toEqual({ result: { success: true } });
// execute second step
expect(workflowExecutorFactory.get).toHaveBeenCalledWith(
expect(workflowActionFactory.get).toHaveBeenCalledWith(
WorkflowActionType.SEND_EMAIL,
);
});
@@ -394,122 +384,86 @@ describe('WorkflowExecutorWorkspaceService', () => {
},
] as WorkflowAction[];
mockWorkflowExecutor.execute.mockResolvedValueOnce({
mockWorkflowRunWorkspaceService.getWorkflowRun.mockReturnValue({
output: { flow: { steps: stepsWithRetryOnFailure } },
context: mockContext,
});
mockWorkflowExecutor.execute.mockResolvedValue({
error: 'Step execution failed, will retry',
});
await service.execute({
await service.executeFromSteps({
workflowRunId: mockWorkflowRunId,
currentStepId: 'step-1',
steps: stepsWithRetryOnFailure,
context: mockContext,
stepIds: ['step-1'],
workspaceId: mockWorkspaceId,
});
// Should call execute again with increased attemptCount
expect(workflowExecutorFactory.get).toHaveBeenCalledWith(
WorkflowActionType.CODE,
);
expect(workflowExecutorFactory.get).not.toHaveBeenCalledWith(
for (let attempt = 1; attempt <= 3; attempt++) {
expect(workflowActionFactory.get).toHaveBeenNthCalledWith(
attempt,
WorkflowActionType.CODE,
);
}
expect(workflowActionFactory.get).not.toHaveBeenCalledWith(
WorkflowActionType.SEND_EMAIL,
);
expect(workflowExecutorFactory.get).toHaveBeenCalledTimes(2);
});
it('should stop retrying after MAX_RETRIES_ON_FAILURE', async () => {
const stepsWithRetryOnFailure = [
{
id: 'step-1',
type: WorkflowActionType.CODE,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: true },
},
},
},
] as WorkflowAction[];
const errorOutput = {
error: 'Step execution failed, max retries reached',
};
mockWorkflowExecutor.execute.mockResolvedValueOnce(errorOutput);
const result = await service.execute({
workflowRunId: mockWorkflowRunId,
currentStepId: 'step-1',
steps: stepsWithRetryOnFailure,
context: mockContext,
attemptCount: 3, // MAX_RETRIES_ON_FAILURE is 3
});
// Should not retry anymore
expect(workflowExecutorFactory.get).toHaveBeenCalledTimes(1);
expect(
workflowRunWorkspaceService.updateWorkflowRunStepStatus,
).toHaveBeenCalledTimes(1);
expect(
workflowRunWorkspaceService.updateWorkflowRunStepStatus,
).toHaveBeenCalledWith({
workflowRunId: mockWorkflowRunId,
stepId: 'step-1',
workspaceId: 'workspace-id',
stepStatus: StepStatus.RUNNING,
});
expect(
workflowRunWorkspaceService.saveWorkflowRunState,
).toHaveBeenCalledTimes(1);
expect(
workflowRunWorkspaceService.saveWorkflowRunState,
).toHaveBeenCalledWith({
workflowRunId: mockWorkflowRunId,
stepOutput: {
id: 'step-1',
output: errorOutput,
},
context: mockContext,
workspaceId: 'workspace-id',
stepStatus: StepStatus.FAILED,
});
expect(result).toEqual(errorOutput);
});
it('should stop when billing validation fails', async () => {
mockBillingService.isBillingEnabled.mockReturnValueOnce(true);
mockBillingService.canBillMeteredProduct.mockReturnValueOnce(false);
const result = await service.execute({
await service.executeFromSteps({
workflowRunId: mockWorkflowRunId,
currentStepId: 'step-1',
steps: mockSteps,
context: mockContext,
stepIds: ['step-1'],
workspaceId: mockWorkspaceId,
});
expect(workflowExecutorFactory.get).toHaveBeenCalledTimes(1);
expect(workflowActionFactory.get).toHaveBeenCalledTimes(0);
expect(
workflowRunWorkspaceService.saveWorkflowRunState,
).toHaveBeenCalledTimes(1);
expect(
workflowRunWorkspaceService.updateWorkflowRunStepStatus,
).not.toHaveBeenCalled();
expect(workflowRunWorkspaceService.endWorkflowRun).toHaveBeenCalledTimes(
1,
);
expect(
workflowRunWorkspaceService.saveWorkflowRunState,
).toHaveBeenCalledWith({
workflowRunId: mockWorkflowRunId,
workspaceId: 'workspace-id',
stepOutput: {
id: 'step-1',
output: {
error: BILLING_WORKFLOW_EXECUTION_ERROR_MESSAGE,
},
},
context: mockContext,
workspaceId: 'workspace-id',
stepStatus: StepStatus.FAILED,
});
expect(result).toEqual({
expect(workflowRunWorkspaceService.endWorkflowRun).toHaveBeenCalledWith({
workflowRunId: mockWorkflowRunId,
workspaceId: 'workspace-id',
status: WorkflowRunStatus.FAILED,
error: BILLING_WORKFLOW_EXECUTION_ERROR_MESSAGE,
});
});
it('should return if step should not be executed', async () => {
(canExecuteStep as jest.Mock).mockReturnValueOnce(false);
await service.executeFromSteps({
workflowRunId: mockWorkflowRunId,
stepIds: ['step-1'],
workspaceId: mockWorkspaceId,
});
expect(workflowActionFactory.get).not.toHaveBeenCalled();
});
});
describe('sendWorkflowNodeRunEvent', () => {
@@ -2,112 +2,103 @@ import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { WorkflowExecutor } from 'src/modules/workflow/workflow-executor/interfaces/workflow-executor.interface';
import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/billing-feature-used.constant';
import { BILLING_WORKFLOW_EXECUTION_ERROR_MESSAGE } from 'src/engine/core-modules/billing/constants/billing-workflow-execution-error-message.constant';
import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { BillingUsageEvent } from 'src/engine/core-modules/billing/types/billing-usage-event.type';
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import {
StepOutput,
WorkflowRunOutput,
WorkflowRunStatus,
} from 'src/modules/workflow/common/standard-objects/workflow-run.workspace-entity';
import { WorkflowExecutorFactory } from 'src/modules/workflow/workflow-executor/factories/workflow-executor.factory';
import { WorkflowExecutorInput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-input';
import { WorkflowExecutorOutput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-output.type';
import { WorkflowActionFactory } from 'src/modules/workflow/workflow-executor/factories/workflow-action.factory';
import { WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { WorkflowRunWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service';
import {
WorkflowTriggerException,
WorkflowTriggerExceptionCode,
} from 'src/modules/workflow/workflow-trigger/exceptions/workflow-trigger.exception';
import { StepStatus } from 'src/modules/workflow/workflow-executor/types/workflow-run-step-info.type';
import {
WorkflowBranchExecutorInput,
WorkflowExecutorInput,
} from 'src/modules/workflow/workflow-executor/types/workflow-executor-input';
import { canExecuteStep } from 'src/modules/workflow/workflow-executor/utils/can-execute-step.utils';
const MAX_RETRIES_ON_FAILURE = 3;
export type WorkflowExecutorState = {
stepsOutput: WorkflowRunOutput['stepsOutput'];
status: WorkflowRunStatus;
};
@Injectable()
export class WorkflowExecutorWorkspaceService implements WorkflowExecutor {
export class WorkflowExecutorWorkspaceService {
constructor(
private readonly workflowExecutorFactory: WorkflowExecutorFactory,
private readonly workflowActionFactory: WorkflowActionFactory,
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory,
private readonly workflowRunWorkspaceService: WorkflowRunWorkspaceService,
private readonly billingService: BillingService,
) {}
async execute({
currentStepId,
steps,
context,
async executeFromSteps({
stepIds,
workflowRunId,
workspaceId,
}: WorkflowExecutorInput) {
await Promise.all(
stepIds.map(async (stepIdToExecute) => {
await this.executeFromStep({
stepId: stepIdToExecute,
workflowRunId,
workspaceId,
});
}),
);
}
private async executeFromStep({
stepId,
attemptCount = 1,
workflowRunId,
}: WorkflowExecutorInput): Promise<WorkflowExecutorOutput> {
const step = steps.find((step) => step.id === currentStepId);
workspaceId,
}: WorkflowBranchExecutorInput) {
const workflowRunInfo = await this.getWorkflowRunInfoOrEndWorkflowRun({
stepId: stepId,
workflowRunId,
workspaceId,
});
if (!step) {
return {
error: 'Step not found',
};
if (!isDefined(workflowRunInfo)) {
return;
}
const workflowExecutor = this.workflowExecutorFactory.get(step.type);
const { stepToExecute, steps, context } = workflowRunInfo;
let actionOutput: WorkflowExecutorOutput;
const { workspaceId } = this.scopedWorkspaceContextFactory.create();
if (!workspaceId) {
throw new WorkflowTriggerException(
'No workspace id found',
WorkflowTriggerExceptionCode.INTERNAL_ERROR,
);
if (!canExecuteStep({ stepId: stepToExecute.id, steps, context })) {
return;
}
if (
this.billingService.isBillingEnabled() &&
!(await this.canBillWorkflowNodeExecution(workspaceId))
) {
const billingOutput = {
error: BILLING_WORKFLOW_EXECUTION_ERROR_MESSAGE,
};
await this.workflowRunWorkspaceService.saveWorkflowRunState({
workspaceId,
const checkCanBillWorkflowNodeExecution =
await this.checkCanBillWorkflowNodeExecutionOrEndWorkflowRun({
stepIdToExecute: stepToExecute.id,
workflowRunId,
stepOutput: {
id: step.id,
output: billingOutput,
},
context,
stepStatus: StepStatus.FAILED,
workspaceId,
});
return billingOutput;
if (!checkCanBillWorkflowNodeExecution) {
return;
}
const workflowAction = this.workflowActionFactory.get(stepToExecute.type);
let actionOutput: WorkflowActionOutput;
await this.workflowRunWorkspaceService.updateWorkflowRunStepStatus({
workflowRunId,
stepId: step.id,
stepId: stepToExecute.id,
workspaceId,
stepStatus: StepStatus.RUNNING,
});
try {
actionOutput = await workflowExecutor.execute({
currentStepId,
actionOutput = await workflowAction.execute({
currentStepId: stepId,
steps,
context,
attemptCount,
workflowRunId,
});
} catch (error) {
actionOutput = {
@@ -120,7 +111,7 @@ export class WorkflowExecutorWorkspaceService implements WorkflowExecutor {
}
const stepOutput: StepOutput = {
id: step.id,
id: stepToExecute.id,
output: actionOutput,
};
@@ -128,73 +119,145 @@ export class WorkflowExecutorWorkspaceService implements WorkflowExecutor {
await this.workflowRunWorkspaceService.saveWorkflowRunState({
workflowRunId,
stepOutput,
context,
workspaceId,
stepStatus: StepStatus.PENDING,
});
return actionOutput;
return;
}
const actionOutputSuccess = isDefined(actionOutput.result);
const shouldContinue =
actionOutputSuccess ||
step.settings.errorHandlingOptions.continueOnFailure.value;
stepToExecute.settings.errorHandlingOptions.continueOnFailure.value;
if (shouldContinue) {
const updatedContext = isDefined(actionOutput.result)
? {
...context,
[step.id]: actionOutput.result,
}
: context;
await this.workflowRunWorkspaceService.saveWorkflowRunState({
workflowRunId,
stepOutput,
context: updatedContext,
workspaceId,
stepStatus: isDefined(actionOutput.result)
? StepStatus.SUCCESS
: StepStatus.FAILED,
});
if (!isDefined(step.nextStepIds?.[0])) {
return actionOutput;
if (
!isDefined(stepToExecute.nextStepIds) ||
stepToExecute.nextStepIds.length === 0
) {
await this.workflowRunWorkspaceService.endWorkflowRun({
workflowRunId,
workspaceId,
status: WorkflowRunStatus.COMPLETED,
});
return;
}
// TODO: handle multiple next steps
return await this.execute({
await this.executeFromSteps({
stepIds: stepToExecute.nextStepIds,
workflowRunId,
currentStepId: step.nextStepIds[0],
steps,
context: updatedContext,
workspaceId,
});
return;
}
if (
step.settings.errorHandlingOptions.retryOnFailure.value &&
stepToExecute.settings.errorHandlingOptions.retryOnFailure.value &&
attemptCount < MAX_RETRIES_ON_FAILURE
) {
return await this.execute({
workflowRunId,
currentStepId,
steps,
context,
await this.executeFromStep({
stepId,
attemptCount: attemptCount + 1,
workflowRunId,
workspaceId,
});
return;
}
await this.workflowRunWorkspaceService.saveWorkflowRunState({
workflowRunId,
stepOutput,
context,
workspaceId,
stepStatus: StepStatus.FAILED,
});
return actionOutput;
await this.workflowRunWorkspaceService.endWorkflowRun({
workflowRunId,
workspaceId,
status: WorkflowRunStatus.FAILED,
error: stepOutput.output.error,
});
}
private async getWorkflowRunInfoOrEndWorkflowRun({
stepId,
workflowRunId,
workspaceId,
}: {
stepId: string;
workflowRunId: string;
workspaceId: string;
}) {
const workflowRun = await this.workflowRunWorkspaceService.getWorkflowRun({
workflowRunId,
workspaceId,
});
if (!isDefined(workflowRun)) {
await this.workflowRunWorkspaceService.endWorkflowRun({
workflowRunId,
workspaceId,
status: WorkflowRunStatus.FAILED,
error: `WorkflowRun ${workflowRunId} not found`,
});
return;
}
const steps = workflowRun.output?.flow.steps;
const context = workflowRun.context;
if (!isDefined(steps)) {
await this.workflowRunWorkspaceService.endWorkflowRun({
workflowRunId,
workspaceId,
status: WorkflowRunStatus.FAILED,
error: 'Steps undefined',
});
return;
}
if (!isDefined(context)) {
await this.workflowRunWorkspaceService.endWorkflowRun({
workflowRunId,
workspaceId,
status: WorkflowRunStatus.FAILED,
error: 'Context not found',
});
return;
}
const stepToExecute = steps.find((step) => step.id === stepId);
if (!stepToExecute) {
await this.workflowRunWorkspaceService.endWorkflowRun({
workflowRunId,
workspaceId,
status: WorkflowRunStatus.FAILED,
error: 'Step not found',
});
return;
}
return { stepToExecute, steps, context };
}
private sendWorkflowNodeRunEvent(workspaceId: string) {
@@ -210,10 +273,45 @@ export class WorkflowExecutorWorkspaceService implements WorkflowExecutor {
);
}
private async canBillWorkflowNodeExecution(workspaceId: string) {
return this.billingService.canBillMeteredProduct(
workspaceId,
BillingProductKey.WORKFLOW_NODE_EXECUTION,
);
private async checkCanBillWorkflowNodeExecutionOrEndWorkflowRun({
stepIdToExecute,
workflowRunId,
workspaceId,
}: {
stepIdToExecute: string;
workflowRunId: string;
workspaceId: string;
}) {
const canBillWorkflowNodeExecution =
!this.billingService.isBillingEnabled() ||
(await this.billingService.canBillMeteredProduct(
workspaceId,
BillingProductKey.WORKFLOW_NODE_EXECUTION,
));
if (!canBillWorkflowNodeExecution) {
const billingOutput = {
error: BILLING_WORKFLOW_EXECUTION_ERROR_MESSAGE,
};
await this.workflowRunWorkspaceService.saveWorkflowRunState({
workspaceId,
workflowRunId,
stepOutput: {
id: stepIdToExecute,
output: billingOutput,
},
stepStatus: StepStatus.FAILED,
});
await this.workflowRunWorkspaceService.endWorkflowRun({
workflowRunId,
workspaceId,
status: WorkflowRunStatus.FAILED,
error: BILLING_WORKFLOW_EXECUTION_ERROR_MESSAGE,
});
}
return canBillWorkflowNodeExecution;
}
}