feat: workflow delay action (Pause - Wait/Sleep/Delay) (#14915)

## Description

- This PR focuses on issue
https://github.com/orgs/twentyhq/projects/1/views/33?pane=issue&itemId=93150683&issue=twentyhq%7Ccore-team-issues%7C20
- added Workflow delay as a Flow action
- for V1 added Type 1: Resume at a specific date or time


## Visual Appearance
<img width="1792" height="1038" alt="Screenshot 2025-10-09 at 5 46
18 PM"
src="https://github.com/user-attachments/assets/e62980e9-59c7-4e5a-b8ec-1e848a462d3f"
/>

<img width="1792" height="1037" alt="Screenshot 2025-10-09 at 5 46
35 PM"
src="https://github.com/user-attachments/assets/7c3f4e39-ab0a-40ed-97a8-4f0cdb86f295"
/>

---------

Co-authored-by: Thomas Trompette <thomas.trompette@sfr.fr>
This commit is contained in:
Harshit Singh
2025-10-20 15:15:55 +02:00
committed by GitHub
co-authored by Thomas Trompette
parent 11564f135e
commit 4e5783eaf4
33 changed files with 643 additions and 12 deletions
@@ -8,6 +8,7 @@ import {
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { AiAgentWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action';
import { CodeWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/code/code.workflow-action';
import { DelayWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/delay/delay.workflow-action';
import { EmptyWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/empty/empty.workflow-action';
import { FilterWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/filter.workflow-action';
import { FormWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/form/form.workflow-action';
@@ -33,6 +34,7 @@ export class WorkflowActionFactory {
private readonly toolExecutorWorkflowAction: ToolExecutorWorkflowAction,
private readonly aiAgentWorkflowAction: AiAgentWorkflowAction,
private readonly emptyWorkflowAction: EmptyWorkflowAction,
private readonly delayWorkflowAction: DelayWorkflowAction,
) {}
get(stepType: WorkflowActionType): WorkflowAction {
@@ -61,6 +63,8 @@ export class WorkflowActionFactory {
return this.aiAgentWorkflowAction;
case WorkflowActionType.EMPTY:
return this.emptyWorkflowAction;
case WorkflowActionType.DELAY:
return this.delayWorkflowAction;
default:
throw new WorkflowStepExecutorException(
`Workflow step executor not found for step type '${stepType}'`,
@@ -0,0 +1 @@
export const RESUME_DELAYED_WORKFLOW_JOB_NAME = 'ResumeDelayedWorkflowJob';
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { DelayWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/delay/delay.workflow-action';
import { ResumeDelayedWorkflowJob } from 'src/modules/workflow/workflow-executor/workflow-actions/delay/jobs/resume-delayed-workflow.job';
import { WorkflowRunQueueModule } from 'src/modules/workflow/workflow-runner/workflow-run-queue/workflow-run-queue.module';
import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.module';
@Module({
imports: [WorkflowRunModule, WorkflowRunQueueModule],
providers: [DelayWorkflowAction, ResumeDelayedWorkflowJob],
exports: [DelayWorkflowAction],
})
export class DelayActionModule {}
@@ -0,0 +1,116 @@
import { Injectable } from '@nestjs/common';
import { resolveInput } from 'twenty-shared/utils';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import {
WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode,
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/find-step-or-throw.util';
import { RESUME_DELAYED_WORKFLOW_JOB_NAME } from 'src/modules/workflow/workflow-executor/workflow-actions/delay/contants/resume-delayed-workflow-job-name';
import { isWorkflowDelayAction } from 'src/modules/workflow/workflow-executor/workflow-actions/delay/guards/is-workflow-delay-action.guard';
import { ResumeDelayedWorkflowJobData } from 'src/modules/workflow/workflow-executor/workflow-actions/delay/types/resume-delayed-workflow-job-data.type';
import { WorkflowDelayActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/delay/types/workflow-delay-action-input.type';
@Injectable()
export class DelayWorkflowAction implements WorkflowAction {
constructor(
@InjectMessageQueue(MessageQueue.delayedJobsQueue)
private readonly messageQueueService: MessageQueueService,
) {}
async execute({
currentStepId,
steps,
runInfo,
context,
}: WorkflowActionInput): Promise<WorkflowActionOutput> {
const step = findStepOrThrow({
stepId: currentStepId,
steps,
});
if (!isWorkflowDelayAction(step)) {
throw new WorkflowStepExecutorException(
'Step is not a delay action',
WorkflowStepExecutorExceptionCode.INVALID_STEP_TYPE,
);
}
const workflowActionInput = resolveInput(
step.settings.input,
context,
) as WorkflowDelayActionInput;
let delayInMs: number;
if (workflowActionInput.delayType === 'SCHEDULED_DATE') {
if (!workflowActionInput.scheduledDateTime) {
throw new WorkflowStepExecutorException(
'Scheduled date time is required for scheduled date delay',
WorkflowStepExecutorExceptionCode.INVALID_STEP_TYPE,
);
}
const scheduledDate = new Date(workflowActionInput.scheduledDateTime);
const now = new Date();
delayInMs = scheduledDate.getTime() - now.getTime();
if (delayInMs < 0) {
throw new WorkflowStepExecutorException(
'Scheduled date cannot be in the past',
WorkflowStepExecutorExceptionCode.INVALID_STEP_TYPE,
);
}
} else if (workflowActionInput.delayType === 'DURATION') {
if (!workflowActionInput.duration) {
throw new WorkflowStepExecutorException(
'Duration is required for duration delay',
WorkflowStepExecutorExceptionCode.INVALID_STEP_TYPE,
);
}
const {
days = 0,
hours = 0,
minutes = 0,
seconds = 0,
} = workflowActionInput.duration;
delayInMs =
days * 24 * 60 * 60 * 1000 +
hours * 60 * 60 * 1000 +
minutes * 60 * 1000 +
seconds * 1000;
} else {
throw new WorkflowStepExecutorException(
'Invalid delay type',
WorkflowStepExecutorExceptionCode.INVALID_STEP_TYPE,
);
}
await this.messageQueueService.add<ResumeDelayedWorkflowJobData>(
RESUME_DELAYED_WORKFLOW_JOB_NAME,
{
workspaceId: runInfo.workspaceId,
workflowRunId: runInfo.workflowRunId,
stepId: currentStepId,
},
{
delay: delayInMs,
},
);
return {
pendingEvent: true,
};
}
}
@@ -0,0 +1,11 @@
import {
type WorkflowAction,
WorkflowActionType,
type WorkflowDelayAction,
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
export const isWorkflowDelayAction = (
action: WorkflowAction,
): action is WorkflowDelayAction => {
return action.type === WorkflowActionType.DELAY;
};
@@ -0,0 +1,108 @@
import { Scope } from '@nestjs/common';
import { StepStatus } from 'twenty-shared/workflow';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { WorkflowRunStatus } from 'src/modules/workflow/common/standard-objects/workflow-run.workspace-entity';
import { RESUME_DELAYED_WORKFLOW_JOB_NAME } from 'src/modules/workflow/workflow-executor/workflow-actions/delay/contants/resume-delayed-workflow-job-name';
import { isWorkflowDelayAction } from 'src/modules/workflow/workflow-executor/workflow-actions/delay/guards/is-workflow-delay-action.guard';
import { ResumeDelayedWorkflowJobData } from 'src/modules/workflow/workflow-executor/workflow-actions/delay/types/resume-delayed-workflow-job-data.type';
import {
WorkflowRunException,
WorkflowRunExceptionCode,
} from 'src/modules/workflow/workflow-runner/exceptions/workflow-run.exception';
import { RunWorkflowJob } from 'src/modules/workflow/workflow-runner/jobs/run-workflow.job';
import { type RunWorkflowJobData } from 'src/modules/workflow/workflow-runner/types/run-workflow-job-data.type';
import { WorkflowRunQueueWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run-queue/workspace-services/workflow-run-queue.workspace-service';
import { WorkflowRunWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service';
@Processor({
queueName: MessageQueue.delayedJobsQueue,
scope: Scope.REQUEST,
})
export class ResumeDelayedWorkflowJob {
constructor(
@InjectMessageQueue(MessageQueue.workflowQueue)
private readonly messageQueueService: MessageQueueService,
private readonly workflowRunWorkspaceService: WorkflowRunWorkspaceService,
private readonly workflowRunQueueWorkspaceService: WorkflowRunQueueWorkspaceService,
) {}
@Process(RESUME_DELAYED_WORKFLOW_JOB_NAME)
async handle({
workspaceId,
workflowRunId,
stepId,
}: ResumeDelayedWorkflowJobData): Promise<void> {
try {
const workflowRun =
await this.workflowRunWorkspaceService.getWorkflowRunOrFail({
workflowRunId,
workspaceId,
});
if (workflowRun.status !== WorkflowRunStatus.RUNNING) {
return;
}
const step = workflowRun.state?.flow?.steps?.find(
(step) => step.id === stepId,
);
const stepInfo = workflowRun.state?.stepInfos[stepId];
if (!step || !isWorkflowDelayAction(step)) {
throw new WorkflowRunException(
'Step not found or is not a delay action',
WorkflowRunExceptionCode.INVALID_INPUT,
);
}
if (stepInfo?.status !== StepStatus.PENDING) {
throw new WorkflowRunException(
'Step is not pending',
WorkflowRunExceptionCode.INVALID_INPUT,
);
}
await this.workflowRunWorkspaceService.updateWorkflowRunStepInfo({
stepId,
stepInfo: {
status: StepStatus.SUCCESS,
result: {
success: true,
},
},
workspaceId,
workflowRunId,
});
await this.messageQueueService.add<RunWorkflowJobData>(
RunWorkflowJob.name,
{
workspaceId,
workflowRunId,
lastExecutedStepId: stepId,
},
);
await this.workflowRunQueueWorkspaceService.increaseWorkflowRunQueuedCount(
workspaceId,
);
} catch (error) {
await this.workflowRunWorkspaceService.endWorkflowRun({
workflowRunId,
workspaceId,
status: WorkflowRunStatus.FAILED,
error:
error instanceof Error
? error.message
: 'Unknown error during delay resume',
});
}
}
}
@@ -0,0 +1,5 @@
export type ResumeDelayedWorkflowJobData = {
workspaceId: string;
workflowRunId: string;
stepId: string;
};
@@ -0,0 +1,18 @@
export type WorkflowDelayActionInput =
| WorkflowScheduledDateActionInput
| WorkflowDurationDelayActionInput;
export type WorkflowScheduledDateActionInput = {
delayType: 'SCHEDULED_DATE';
scheduledDateTime: string;
};
export type WorkflowDurationDelayActionInput = {
delayType: 'DURATION';
duration: {
days?: number;
hours?: number;
minutes?: number;
seconds?: number;
};
};
@@ -0,0 +1,6 @@
import { type WorkflowDelayActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/delay/types/workflow-delay-action-input.type';
import { type BaseWorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
export type WorkflowDelayActionSettings = BaseWorkflowActionSettings & {
input: WorkflowDelayActionInput;
};
@@ -1,6 +1,7 @@
import { type OutputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type';
import { type WorkflowAiAgentActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/ai-agent/types/workflow-ai-agent-action-settings.type';
import { type WorkflowCodeActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/code/types/workflow-code-action-settings.type';
import { type WorkflowDelayActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/delay/types/workflow-delay-action-settings.type';
import { type WorkflowFilterActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/types/workflow-filter-action-settings.type';
import { type WorkflowFormActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/form/types/workflow-form-action-settings.type';
import { type WorkflowHttpRequestActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/types/workflow-http-request-action-settings.type';
@@ -36,4 +37,5 @@ export type WorkflowActionSettings =
| WorkflowFilterActionSettings
| WorkflowHttpRequestActionSettings
| WorkflowAiAgentActionSettings
| WorkflowDelayActionSettings
| WorkflowIteratorActionSettings;
@@ -12,6 +12,7 @@ import {
type WorkflowUpdateRecordActionSettings,
} from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-settings.type';
import { type WorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
import { type WorkflowDelayActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/delay/types/workflow-delay-action-settings.type';
export enum WorkflowActionType {
CODE = 'CODE',
@@ -26,6 +27,7 @@ export enum WorkflowActionType {
AI_AGENT = 'AI_AGENT',
ITERATOR = 'ITERATOR',
EMPTY = 'EMPTY',
DELAY = 'DELAY',
}
type BaseWorkflowAction = {
@@ -100,6 +102,11 @@ export type WorkflowEmptyAction = BaseWorkflowAction & {
type: WorkflowActionType.EMPTY;
};
export type WorkflowDelayAction = BaseWorkflowAction & {
type: WorkflowActionType.DELAY;
settings: WorkflowDelayActionSettings;
};
export type WorkflowAction =
| WorkflowCodeAction
| WorkflowSendEmailAction
@@ -112,4 +119,5 @@ export type WorkflowAction =
| WorkflowHttpRequestAction
| WorkflowAiAgentAction
| WorkflowIteratorAction
| WorkflowEmptyAction;
| WorkflowEmptyAction
| WorkflowDelayAction;
@@ -8,6 +8,7 @@ import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-commo
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 { DelayActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/delay/delay-action.module';
import { EmptyActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/empty/empty-action.module';
import { FilterActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/filter-action.module';
import { FormActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/form/form-action.module';
@@ -22,6 +23,7 @@ import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow
imports: [
WorkflowCommonModule,
CodeActionModule,
DelayActionModule,
RecordCRUDActionModule,
FormActionModule,
WorkflowRunModule,