feat: add configurable response format for AI agents (text/JSON) (#15953)

## Summary
This PR adds configurable response format support for AI agents,
allowing them to return either plain text or structured JSON data based
on a defined schema.

## Key Features

### 1. Agent Response Format Configuration
- Added `AgentResponseFormat` type supporting:
  - `text`: Returns plain text responses (default)
  - `json`: Returns structured JSON based on defined schema
- New `AgentResponseSchema` type moved to `twenty-shared/ai` for sharing
between frontend/backend

### 2. Settings UI
- New `SettingsAgentResponseFormat` component for configuring response
format
- Visual schema builder for defining JSON output structure
- Real-time validation and preview
- Integrated into agent settings tab

### 3. Workflow Integration
- AI Agent workflow action automatically uses agent's configured
response format
- Output schema dynamically generated from agent's response format
- Workflow variable picker shows structured fields for JSON responses
- Backward compatible with existing text-only agents

### 4. Backend Implementation
- Added `convertAgentSchemaToZod` utility to validate JSON responses
- Agent executor service handles both text and JSON generation
- Automatic agent creation/cloning when adding AI agent steps to
workflows
- Unique agent naming with conflict resolution

### 5. Database Migration
- Migration `1763622159656-update-agent-response-format.ts` 
- Sets default `responseFormat` to `{"type":"text"}` for existing agents
- Updated all standard agents with proper response format

## Changes by Module

### Frontend (`twenty-front`)
- 🆕 `AgentResponseFormat` type
- 🆕 `SettingsAgentResponseFormat` component
- ✏️ Updated `WorkflowEditActionAiAgent` to support response format
configuration
- 🗑️ Removed deprecated `useAiAgentOutputSchema` hook and
`AiAgentOutputSchema` type

### Backend (`twenty-server`)
- 🆕 `AgentResponseFormat` type in agent entity
- 🆕 `convertAgentSchemaToZod` utility for schema validation
- ✏️ Updated `AiAgentExecutorService` to handle both text and JSON
generation
- ✏️ Updated `WorkflowSchemaWorkspaceService` to generate output schema
from agent config
- ✏️ Enhanced `WorkflowVersionStepOperationsWorkspaceService` with agent
creation/cloning
- 🆕 Agent naming constants for conflict resolution

### Shared (`twenty-shared`)
- 🆕 `AgentResponseSchema` type
- 🆕 `ModelConfiguration` type moved to shared package
- Updated exports in `ai/index.ts`

## Code Quality
- Removed useless comments following code style guidelines
- All linter checks passed
- Type-safe implementation with proper TypeScript types

## Testing
-  Database migration tested
-  Agent creation/cloning in workflows verified
-  Response format switching (text ↔ JSON) validated
-  Backward compatibility with existing agents confirmed

## Migration Notes
- Existing agents will have `responseFormat: {type: 'text'}` set
automatically
- No breaking changes - all existing functionality preserved
- Agents can be updated to use JSON format through settings UI
This commit is contained in:
Félix Malfait
2025-11-20 18:32:44 +01:00
committed by GitHub
parent 5476879f77
commit a281f2a773
49 changed files with 1207 additions and 641 deletions
@@ -1,11 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module';
import { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service';
@Module({
imports: [WorkflowCommonModule, FeatureFlagModule],
imports: [
WorkflowCommonModule,
FeatureFlagModule,
TypeOrmModule.forFeature([AgentEntity]),
],
providers: [WorkflowSchemaWorkspaceService],
exports: [WorkflowSchemaWorkspaceService],
})
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isString } from '@sniptt/guards';
import { isDefined, isValidVariable } from 'twenty-shared/utils';
@@ -11,9 +12,11 @@ import {
SingleRecordAvailability,
TRIGGER_STEP_ID,
} from 'twenty-shared/workflow';
import { Repository } from 'typeorm';
import { type DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { checkStringIsDatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/utils/check-string-is-database-event-action';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { generateFakeValue } from 'src/engine/utils/generate-fake-value';
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
import { DEFAULT_ITERATOR_CURRENT_ITEM } from 'src/modules/workflow/workflow-builder/workflow-schema/constants/default-iterator-current-item.const';
@@ -42,6 +45,8 @@ import {
export class WorkflowSchemaWorkspaceService {
constructor(
private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService,
@InjectRepository(AgentEntity)
private readonly agentRepository: Repository<AgentEntity>,
) {}
async computeStepOutputSchema({
@@ -122,6 +127,39 @@ export class WorkflowSchemaWorkspaceService {
},
};
}
case WorkflowActionType.AI_AGENT: {
const agentId = step.settings.input.agentId;
if (!isDefined(agentId) || agentId === '') {
return {};
}
const agent = await this.agentRepository.findOne({
where: { id: agentId, workspaceId },
});
if (
!isDefined(agent) ||
agent.responseFormat?.type !== 'json' ||
!isDefined(agent.responseFormat.schema)
) {
return {};
}
return Object.fromEntries(
Object.entries(agent.responseFormat.schema.properties).map(
([key, field]) => [
key,
{
isLeaf: true,
type: field.type,
label: field.description || key,
value: null,
},
],
),
) as OutputSchema;
}
case WorkflowActionType.CODE: // StepOutput schema is computed on serverlessFunction draft execution
default:
return {};
@@ -139,13 +177,12 @@ export class WorkflowSchemaWorkspaceService {
}): Promise<WorkflowAction> {
// We don't enrich on the fly for code and HTTP request workflow actions.
// For code actions, OutputSchema is computed and updated when testing the serverless function.
// For HTTP requests and AI agent, OutputSchema is determined by the example response input
// For HTTP requests, OutputSchema is determined by the example response input
// AI agent OutputSchema is enriched from agent's responseFormat
if (
[
WorkflowActionType.CODE,
WorkflowActionType.HTTP_REQUEST,
WorkflowActionType.AI_AGENT,
].includes(step.type)
[WorkflowActionType.CODE, WorkflowActionType.HTTP_REQUEST].includes(
step.type,
)
) {
return step;
}
@@ -9,6 +9,10 @@ import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/work
import { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service';
import { WorkflowVersionStepOperationsWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service';
import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service';
import { WorkflowVersionStepHelpersWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-helpers.workspace-service';
import { WorkflowVersionStepCreationWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-creation.workspace-service';
import { WorkflowVersionStepUpdateWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-update.workspace-service';
import { WorkflowVersionStepDeletionWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-deletion.workspace-service';
import {
type WorkflowAction,
WorkflowActionType,
@@ -113,6 +117,10 @@ describe('WorkflowVersionStepWorkspaceService', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
WorkflowVersionStepWorkspaceService,
WorkflowVersionStepHelpersWorkspaceService,
WorkflowVersionStepCreationWorkspaceService,
WorkflowVersionStepUpdateWorkspaceService,
WorkflowVersionStepDeletionWorkspaceService,
{
provide: TwentyORMGlobalManager,
useValue: twentyORMGlobalManager,
@@ -140,6 +148,14 @@ describe('WorkflowVersionStepWorkspaceService', () => {
additionalCreatedSteps: [],
})),
runWorkflowVersionStepDeletionSideEffects: jest.fn(),
cloneStep: jest.fn().mockImplementation(({ step }) => ({
...step,
id: 'cloned-step-id',
})),
markStepAsDuplicate: jest
.fn()
.mockImplementation(({ step }) => step),
createDraftStep: jest.fn().mockImplementation(({ step }) => step),
},
},
{
@@ -0,0 +1,176 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { type CreateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto';
import { type WorkflowVersionStepChangesDTO } from 'src/engine/core-modules/workflow/dtos/workflow-version-step-changes.dto';
import {
WorkflowVersionStepException,
WorkflowVersionStepExceptionCode,
} from 'src/modules/workflow/common/exceptions/workflow-version-step.exception';
import { computeWorkflowVersionStepChanges } from 'src/modules/workflow/workflow-builder/utils/compute-workflow-version-step-updates.util';
import { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service';
import { insertStep } from 'src/modules/workflow/workflow-builder/workflow-version-step/utils/insert-step';
import { WorkflowVersionStepOperationsWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service';
import { WorkflowVersionStepHelpersWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-helpers.workspace-service';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
@Injectable()
export class WorkflowVersionStepCreationWorkspaceService {
constructor(
private readonly workflowSchemaWorkspaceService: WorkflowSchemaWorkspaceService,
private readonly workflowVersionStepOperationsWorkspaceService: WorkflowVersionStepOperationsWorkspaceService,
private readonly workflowVersionStepHelpersWorkspaceService: WorkflowVersionStepHelpersWorkspaceService,
) {}
async createWorkflowVersionStep({
workspaceId,
input,
}: {
workspaceId: string;
input: CreateWorkflowVersionStepInput;
}): Promise<WorkflowVersionStepChangesDTO> {
const {
workflowVersionId,
stepType,
parentStepId,
nextStepId,
position,
parentStepConnectionOptions,
id,
} = input;
const workflowVersion =
await this.workflowVersionStepHelpersWorkspaceService.getValidatedDraftWorkflowVersion(
{
workflowVersionId,
workspaceId,
},
);
const existingSteps = workflowVersion.steps;
const existingTrigger = workflowVersion.trigger;
const { builtStep, additionalCreatedSteps } =
await this.workflowVersionStepOperationsWorkspaceService.runStepCreationSideEffectsAndBuildStep(
{
type: stepType,
workspaceId,
position,
workflowVersionId,
id,
},
);
const enrichedNewStep =
await this.workflowSchemaWorkspaceService.enrichOutputSchema({
step: builtStep,
workspaceId,
workflowVersionId,
});
const { updatedSteps, updatedTrigger } = insertStep({
existingSteps: existingSteps ?? [],
existingTrigger,
insertedStep: enrichedNewStep,
parentStepId,
nextStepId,
parentStepConnectionOptions,
});
if (isDefined(additionalCreatedSteps)) {
updatedSteps.push(...additionalCreatedSteps);
}
await this.workflowVersionStepHelpersWorkspaceService.updateWorkflowVersionStepsAndTrigger(
{
workspaceId,
workflowVersionId: workflowVersion.id,
trigger: updatedTrigger,
steps: updatedSteps,
},
);
return computeWorkflowVersionStepChanges({
existingTrigger,
existingSteps,
updatedTrigger,
updatedSteps,
});
}
async duplicateWorkflowVersionStep({
workspaceId,
workflowVersionId,
stepId,
}: {
workspaceId: string;
workflowVersionId: string;
stepId: string;
}): Promise<WorkflowVersionStepChangesDTO> {
const workflowVersion =
await this.workflowVersionStepHelpersWorkspaceService.getValidatedDraftWorkflowVersion(
{
workflowVersionId,
workspaceId,
},
);
const stepToDuplicate = workflowVersion.steps?.find(
(step) => step.id === stepId,
);
if (!isDefined(stepToDuplicate)) {
throw new WorkflowVersionStepException(
'Step not found',
WorkflowVersionStepExceptionCode.NOT_FOUND,
);
}
const clonedStep =
await this.workflowVersionStepOperationsWorkspaceService.cloneStep({
step: stepToDuplicate,
workspaceId,
});
const duplicatedStep =
this.workflowVersionStepOperationsWorkspaceService.markStepAsDuplicate({
step: clonedStep,
});
const { updatedSteps, updatedTrigger } = insertStep({
existingSteps: workflowVersion.steps ?? [],
existingTrigger: workflowVersion.trigger,
insertedStep: duplicatedStep,
});
await this.workflowVersionStepHelpersWorkspaceService.updateWorkflowVersionStepsAndTrigger(
{
workspaceId,
workflowVersionId: workflowVersion.id,
steps: updatedSteps,
trigger: updatedTrigger,
},
);
return computeWorkflowVersionStepChanges({
existingTrigger: workflowVersion.trigger,
existingSteps: workflowVersion.steps,
updatedTrigger,
updatedSteps,
});
}
async createDraftStep({
step,
workspaceId,
}: {
step: WorkflowAction;
workspaceId: string;
}): Promise<WorkflowAction> {
return this.workflowVersionStepOperationsWorkspaceService.createDraftStep({
step,
workspaceId,
});
}
}
@@ -0,0 +1,106 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { type WorkflowVersionStepChangesDTO } from 'src/engine/core-modules/workflow/dtos/workflow-version-step-changes.dto';
import {
WorkflowVersionStepException,
WorkflowVersionStepExceptionCode,
} from 'src/modules/workflow/common/exceptions/workflow-version-step.exception';
import { computeWorkflowVersionStepChanges } from 'src/modules/workflow/workflow-builder/utils/compute-workflow-version-step-updates.util';
import { removeStep } from 'src/modules/workflow/workflow-builder/workflow-version-step/utils/remove-step';
import { WorkflowVersionStepOperationsWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service';
import { WorkflowVersionStepHelpersWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-helpers.workspace-service';
@Injectable()
export class WorkflowVersionStepDeletionWorkspaceService {
constructor(
private readonly workflowVersionStepOperationsWorkspaceService: WorkflowVersionStepOperationsWorkspaceService,
private readonly workflowVersionStepHelpersWorkspaceService: WorkflowVersionStepHelpersWorkspaceService,
) {}
async deleteWorkflowVersionStep({
workspaceId,
workflowVersionId,
stepIdToDelete,
}: {
workspaceId: string;
workflowVersionId: string;
stepIdToDelete: string;
}): Promise<WorkflowVersionStepChangesDTO> {
const workflowVersion =
await this.workflowVersionStepHelpersWorkspaceService.getValidatedDraftWorkflowVersion(
{
workflowVersionId,
workspaceId,
},
);
const existingTrigger = workflowVersion.trigger;
const isDeletingTrigger =
stepIdToDelete === TRIGGER_STEP_ID && isDefined(existingTrigger);
if (!isDeletingTrigger && !isDefined(workflowVersion.steps)) {
throw new WorkflowVersionStepException(
"Can't delete step from undefined steps",
WorkflowVersionStepExceptionCode.INVALID_REQUEST,
);
}
const stepToDelete = workflowVersion.steps?.find(
(step) => step.id === stepIdToDelete,
);
if (!isDeletingTrigger && !isDefined(stepToDelete)) {
throw new WorkflowVersionStepException(
"Can't delete not existing step",
WorkflowVersionStepExceptionCode.NOT_FOUND,
);
}
const stepToDeleteChildrenIds = isDeletingTrigger
? (existingTrigger?.nextStepIds ?? [])
: (stepToDelete?.nextStepIds ?? []);
const { updatedSteps, updatedTrigger, removedStepIds } = removeStep({
existingTrigger,
existingSteps: workflowVersion.steps,
stepIdToDelete,
stepToDeleteChildrenIds,
});
await this.workflowVersionStepHelpersWorkspaceService.updateWorkflowVersionStepsAndTrigger(
{
workspaceId,
workflowVersionId: workflowVersion.id,
steps: updatedSteps,
trigger: updatedTrigger,
},
);
const removedSteps =
workflowVersion.steps?.filter((step) =>
removedStepIds.includes(step.id),
) ?? [];
await Promise.all(
removedSteps.map((step) =>
this.workflowVersionStepOperationsWorkspaceService.runWorkflowVersionStepDeletionSideEffects(
{
step,
workspaceId,
},
),
),
);
return computeWorkflowVersionStepChanges({
existingTrigger,
existingSteps: workflowVersion.steps,
updatedTrigger,
updatedSteps,
});
}
}
@@ -0,0 +1,65 @@
import { Injectable } from '@nestjs/common';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { type WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import { assertWorkflowVersionIsDraft } from 'src/modules/workflow/common/utils/assert-workflow-version-is-draft.util';
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import { type WorkflowTrigger } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
@Injectable()
export class WorkflowVersionStepHelpersWorkspaceService {
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService,
) {}
async getValidatedDraftWorkflowVersion({
workflowVersionId,
workspaceId,
}: {
workflowVersionId: string;
workspaceId: string;
}): Promise<WorkflowVersionWorkspaceEntity> {
const workflowVersion =
await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({
workflowVersionId,
workspaceId,
});
assertWorkflowVersionIsDraft(workflowVersion);
return workflowVersion;
}
async updateWorkflowVersionStepsAndTrigger({
workspaceId,
workflowVersionId,
steps,
trigger,
}: {
workspaceId: string;
workflowVersionId: string;
steps?: WorkflowAction[] | null;
trigger?: WorkflowTrigger | null;
}): Promise<void> {
const workflowVersionRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkflowVersionWorkspaceEntity>(
workspaceId,
'workflowVersion',
{ shouldBypassPermissionChecks: true },
);
const updateData: Partial<WorkflowVersionWorkspaceEntity> = {};
if (steps !== undefined) {
updateData.steps = steps;
}
if (trigger !== undefined) {
updateData.trigger = trigger;
}
await workflowVersionRepository.update(workflowVersionId, updateData);
}
}
@@ -336,6 +336,32 @@ export class WorkflowVersionStepOperationsWorkspaceService {
};
}
case WorkflowActionType.AI_AGENT: {
// Get workflow version to use workflow ID and name in agent name
const workflowVersion =
await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({
workflowVersionId,
workspaceId,
});
const newAgent = await this.agentRepository.save({
name: 'workflow-service-agent' + v4(),
label: 'Workflow Agent' + workflowVersion.workflowId.substring(0, 4),
icon: 'IconRobot',
description: '',
prompt: '',
modelId: 'auto',
responseFormat: { type: 'text' },
workspaceId,
isCustom: true,
});
if (!isDefined(newAgent)) {
throw new WorkflowVersionStepException(
'Failed to create AI Agent step',
WorkflowVersionStepExceptionCode.AI_AGENT_STEP_FAILURE,
);
}
return {
builtStep: {
...baseStep,
@@ -344,7 +370,7 @@ export class WorkflowVersionStepOperationsWorkspaceService {
settings: {
...BASE_STEP_DEFINITION,
input: {
agentId: '',
agentId: newAgent.id,
prompt: '',
},
},
@@ -510,6 +536,45 @@ export class WorkflowVersionStepOperationsWorkspaceService {
},
};
}
case WorkflowActionType.AI_AGENT: {
const existingAgent = await this.agentRepository.findOne({
where: { id: step.settings.input.agentId, workspaceId },
});
if (!isDefined(existingAgent)) {
throw new WorkflowVersionStepException(
'Agent not found for cloning',
WorkflowVersionStepExceptionCode.AI_AGENT_STEP_FAILURE,
);
}
const clonedAgent = await this.agentRepository.save({
name: 'workflow-service-agent' + v4(),
label: existingAgent.label,
icon: existingAgent.icon,
description: existingAgent.description,
prompt: existingAgent.prompt,
modelId: existingAgent.modelId,
responseFormat: existingAgent.responseFormat,
workspaceId,
isCustom: true,
modelConfiguration: existingAgent.modelConfiguration,
});
return {
...step,
id: v4(),
nextStepIds: [],
position: duplicatedStepPosition,
settings: {
...step.settings,
input: {
...step.settings.input,
agentId: clonedAgent.id,
},
},
};
}
case WorkflowActionType.ITERATOR: {
return {
...step,
@@ -0,0 +1,147 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { WorkflowActionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-action.dto';
import {
WorkflowVersionStepException,
WorkflowVersionStepExceptionCode,
} from 'src/modules/workflow/common/exceptions/workflow-version-step.exception';
import { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service';
import { WorkflowVersionStepOperationsWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service';
import { WorkflowVersionStepHelpersWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-helpers.workspace-service';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
@Injectable()
export class WorkflowVersionStepUpdateWorkspaceService {
constructor(
private readonly workflowSchemaWorkspaceService: WorkflowSchemaWorkspaceService,
private readonly workflowVersionStepOperationsWorkspaceService: WorkflowVersionStepOperationsWorkspaceService,
private readonly workflowVersionStepHelpersWorkspaceService: WorkflowVersionStepHelpersWorkspaceService,
) {}
async updateWorkflowVersionStep({
workspaceId,
workflowVersionId,
step,
}: {
workspaceId: string;
workflowVersionId: string;
step: WorkflowAction;
}): Promise<WorkflowActionDTO> {
const workflowVersion =
await this.workflowVersionStepHelpersWorkspaceService.getValidatedDraftWorkflowVersion(
{
workflowVersionId,
workspaceId,
},
);
if (!isDefined(workflowVersion.steps)) {
throw new WorkflowVersionStepException(
"Can't update step from undefined steps",
WorkflowVersionStepExceptionCode.INVALID_REQUEST,
);
}
const existingStep = workflowVersion.steps.find(
(existingStep) => existingStep.id === step.id,
);
if (!isDefined(existingStep)) {
throw new WorkflowVersionStepException(
'Step not found',
WorkflowVersionStepExceptionCode.NOT_FOUND,
);
}
const isStepTypeChanged = existingStep.type !== step.type;
const updatedStep = isStepTypeChanged
? await this.updateWorkflowVersionStepType({
existingStep,
newStep: step,
workspaceId,
workflowVersionId,
})
: await this.updateWorkflowVersionStepSettings({
newStep: step,
workspaceId,
workflowVersionId,
});
const updatedSteps = workflowVersion.steps.map((existingStep) => {
if (existingStep.id === step.id) {
return updatedStep;
} else {
return existingStep;
}
});
await this.workflowVersionStepHelpersWorkspaceService.updateWorkflowVersionStepsAndTrigger(
{
workspaceId,
workflowVersionId: workflowVersion.id,
steps: updatedSteps,
},
);
return updatedStep;
}
private async updateWorkflowVersionStepType({
existingStep,
newStep,
workspaceId,
workflowVersionId,
}: {
existingStep: WorkflowAction;
newStep: WorkflowAction;
workspaceId: string;
workflowVersionId: string;
}): Promise<WorkflowAction> {
await this.workflowVersionStepOperationsWorkspaceService.runWorkflowVersionStepDeletionSideEffects(
{
step: existingStep,
workspaceId,
},
);
const { builtStep } =
await this.workflowVersionStepOperationsWorkspaceService.runStepCreationSideEffectsAndBuildStep(
{
type: newStep.type,
workspaceId,
position: newStep.position,
workflowVersionId,
},
);
return this.workflowSchemaWorkspaceService.enrichOutputSchema({
step: {
...builtStep,
id: existingStep.id,
nextStepIds: existingStep.nextStepIds,
position: existingStep.position,
},
workspaceId,
workflowVersionId,
});
}
private async updateWorkflowVersionStepSettings({
newStep,
workspaceId,
workflowVersionId,
}: {
newStep: WorkflowAction;
workspaceId: string;
workflowVersionId: string;
}): Promise<WorkflowAction> {
return this.workflowSchemaWorkspaceService.enrichOutputSchema({
step: newStep,
workspaceId,
workflowVersionId,
});
}
}
@@ -9,6 +9,10 @@ import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-commo
import { WorkflowSchemaModule } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.module';
import { WorkflowVersionStepOperationsWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service';
import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service';
import { WorkflowVersionStepHelpersWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-helpers.workspace-service';
import { WorkflowVersionStepCreationWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-creation.workspace-service';
import { WorkflowVersionStepUpdateWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-update.workspace-service';
import { WorkflowVersionStepDeletionWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-deletion.workspace-service';
@Module({
imports: [
@@ -20,6 +24,10 @@ import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workfl
providers: [
WorkflowVersionStepWorkspaceService,
WorkflowVersionStepOperationsWorkspaceService,
WorkflowVersionStepHelpersWorkspaceService,
WorkflowVersionStepCreationWorkspaceService,
WorkflowVersionStepUpdateWorkspaceService,
WorkflowVersionStepDeletionWorkspaceService,
],
exports: [
WorkflowVersionStepWorkspaceService,
@@ -1,33 +1,19 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { type CreateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto';
import { WorkflowActionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-action.dto';
import { type WorkflowVersionStepChangesDTO } from 'src/engine/core-modules/workflow/dtos/workflow-version-step-changes.dto';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import {
WorkflowVersionStepException,
WorkflowVersionStepExceptionCode,
} from 'src/modules/workflow/common/exceptions/workflow-version-step.exception';
import { type WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import { assertWorkflowVersionIsDraft } from 'src/modules/workflow/common/utils/assert-workflow-version-is-draft.util';
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
import { computeWorkflowVersionStepChanges } from 'src/modules/workflow/workflow-builder/utils/compute-workflow-version-step-updates.util';
import { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service';
import { insertStep } from 'src/modules/workflow/workflow-builder/workflow-version-step/utils/insert-step';
import { removeStep } from 'src/modules/workflow/workflow-builder/workflow-version-step/utils/remove-step';
import { WorkflowVersionStepOperationsWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service';
import { WorkflowVersionStepCreationWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-creation.workspace-service';
import { WorkflowVersionStepDeletionWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-deletion.workspace-service';
import { WorkflowVersionStepUpdateWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-update.workspace-service';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
@Injectable()
export class WorkflowVersionStepWorkspaceService {
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly workflowSchemaWorkspaceService: WorkflowSchemaWorkspaceService,
private readonly workflowVersionStepOperationsWorkspaceService: WorkflowVersionStepOperationsWorkspaceService,
private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService,
private readonly workflowVersionStepCreationWorkspaceService: WorkflowVersionStepCreationWorkspaceService,
private readonly workflowVersionStepUpdateWorkspaceService: WorkflowVersionStepUpdateWorkspaceService,
private readonly workflowVersionStepDeletionWorkspaceService: WorkflowVersionStepDeletionWorkspaceService,
) {}
async createWorkflowVersionStep({
@@ -37,77 +23,12 @@ export class WorkflowVersionStepWorkspaceService {
workspaceId: string;
input: CreateWorkflowVersionStepInput;
}): Promise<WorkflowVersionStepChangesDTO> {
const {
workflowVersionId,
stepType,
parentStepId,
nextStepId,
position,
parentStepConnectionOptions,
id,
} = input;
const workflowVersion =
await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({
workflowVersionId,
return this.workflowVersionStepCreationWorkspaceService.createWorkflowVersionStep(
{
workspaceId,
});
assertWorkflowVersionIsDraft(workflowVersion);
const existingSteps = workflowVersion.steps;
const existingTrigger = workflowVersion.trigger;
const { builtStep, additionalCreatedSteps } =
await this.workflowVersionStepOperationsWorkspaceService.runStepCreationSideEffectsAndBuildStep(
{
type: stepType,
workspaceId,
position,
workflowVersionId,
id,
},
);
const enrichedNewStep =
await this.workflowSchemaWorkspaceService.enrichOutputSchema({
step: builtStep,
workspaceId,
workflowVersionId,
});
const { updatedSteps, updatedTrigger } = insertStep({
existingSteps: existingSteps ?? [],
existingTrigger,
insertedStep: enrichedNewStep,
parentStepId,
nextStepId,
parentStepConnectionOptions,
});
if (isDefined(additionalCreatedSteps)) {
updatedSteps.push(...additionalCreatedSteps);
}
const workflowVersionRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkflowVersionWorkspaceEntity>(
workspaceId,
'workflowVersion',
{ shouldBypassPermissionChecks: true },
);
await workflowVersionRepository.update(workflowVersion.id, {
trigger: updatedTrigger,
steps: updatedSteps,
});
return computeWorkflowVersionStepChanges({
existingTrigger,
existingSteps,
updatedTrigger,
updatedSteps,
});
input,
},
);
}
async updateWorkflowVersionStep({
@@ -119,67 +40,13 @@ export class WorkflowVersionStepWorkspaceService {
workflowVersionId: string;
step: WorkflowAction;
}): Promise<WorkflowActionDTO> {
const workflowVersion =
await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({
return this.workflowVersionStepUpdateWorkspaceService.updateWorkflowVersionStep(
{
workspaceId,
workflowVersionId,
workspaceId,
});
assertWorkflowVersionIsDraft(workflowVersion);
if (!isDefined(workflowVersion.steps)) {
throw new WorkflowVersionStepException(
"Can't update step from undefined steps",
WorkflowVersionStepExceptionCode.INVALID_REQUEST,
);
}
const existingStep = workflowVersion.steps.find(
(existingStep) => existingStep.id === step.id,
step,
},
);
if (!isDefined(existingStep)) {
throw new WorkflowVersionStepException(
'Step not found',
WorkflowVersionStepExceptionCode.NOT_FOUND,
);
}
const isStepTypeChanged = existingStep.type !== step.type;
const updatedStep = isStepTypeChanged
? await this.updateWorkflowVersionStepType({
existingStep,
newStep: step,
workspaceId,
workflowVersionId,
})
: await this.updateWorkflowVersionStepSettings({
newStep: step,
workspaceId,
workflowVersionId,
});
const updatedSteps = workflowVersion.steps.map((existingStep) => {
if (existingStep.id === step.id) {
return updatedStep;
} else {
return existingStep;
}
});
const workflowVersionRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkflowVersionWorkspaceEntity>(
workspaceId,
'workflowVersion',
{ shouldBypassPermissionChecks: true },
);
await workflowVersionRepository.update(workflowVersion.id, {
steps: updatedSteps,
});
return updatedStep;
}
async deleteWorkflowVersionStep({
@@ -191,82 +58,13 @@ export class WorkflowVersionStepWorkspaceService {
workflowVersionId: string;
stepIdToDelete: string;
}): Promise<WorkflowVersionStepChangesDTO> {
const workflowVersion =
await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({
return this.workflowVersionStepDeletionWorkspaceService.deleteWorkflowVersionStep(
{
workspaceId,
workflowVersionId,
workspaceId,
});
assertWorkflowVersionIsDraft(workflowVersion);
const existingTrigger = workflowVersion.trigger;
const isDeletingTrigger =
stepIdToDelete === TRIGGER_STEP_ID && isDefined(existingTrigger);
if (!isDeletingTrigger && !isDefined(workflowVersion.steps)) {
throw new WorkflowVersionStepException(
"Can't delete step from undefined steps",
WorkflowVersionStepExceptionCode.INVALID_REQUEST,
);
}
const stepToDelete = workflowVersion.steps?.find(
(step) => step.id === stepIdToDelete,
stepIdToDelete,
},
);
if (!isDeletingTrigger && !isDefined(stepToDelete)) {
throw new WorkflowVersionStepException(
"Can't delete not existing step",
WorkflowVersionStepExceptionCode.NOT_FOUND,
);
}
const stepToDeleteChildrenIds = isDeletingTrigger
? (existingTrigger?.nextStepIds ?? [])
: (stepToDelete?.nextStepIds ?? []);
const { updatedSteps, updatedTrigger, removedStepIds } = removeStep({
existingTrigger,
existingSteps: workflowVersion.steps,
stepIdToDelete,
stepToDeleteChildrenIds,
});
const workflowVersionRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkflowVersionWorkspaceEntity>(
workspaceId,
'workflowVersion',
{ shouldBypassPermissionChecks: true },
);
await workflowVersionRepository.update(workflowVersion.id, {
steps: updatedSteps,
trigger: updatedTrigger,
});
const removedSteps =
workflowVersion.steps?.filter((step) =>
removedStepIds.includes(step.id),
) ?? [];
await Promise.all(
removedSteps.map((step) =>
this.workflowVersionStepOperationsWorkspaceService.runWorkflowVersionStepDeletionSideEffects(
{
step,
workspaceId,
},
),
),
);
return computeWorkflowVersionStepChanges({
existingTrigger,
existingSteps: workflowVersion.steps,
updatedTrigger,
updatedSteps,
});
}
async duplicateWorkflowVersionStep({
@@ -278,59 +76,13 @@ export class WorkflowVersionStepWorkspaceService {
workflowVersionId: string;
stepId: string;
}): Promise<WorkflowVersionStepChangesDTO> {
const workflowVersion =
await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({
return this.workflowVersionStepCreationWorkspaceService.duplicateWorkflowVersionStep(
{
workspaceId,
workflowVersionId,
workspaceId,
});
assertWorkflowVersionIsDraft(workflowVersion);
const stepToDuplicate = workflowVersion.steps?.find(
(step) => step.id === stepId,
stepId,
},
);
if (!isDefined(stepToDuplicate)) {
throw new WorkflowVersionStepException(
'Step not found',
WorkflowVersionStepExceptionCode.NOT_FOUND,
);
}
const clonedStep =
await this.workflowVersionStepOperationsWorkspaceService.cloneStep({
step: stepToDuplicate,
workspaceId,
});
const duplicatedStep =
this.workflowVersionStepOperationsWorkspaceService.markStepAsDuplicate({
step: clonedStep,
});
const { updatedSteps, updatedTrigger } = insertStep({
existingSteps: workflowVersion.steps ?? [],
existingTrigger: workflowVersion.trigger,
insertedStep: duplicatedStep,
});
const workflowVersionRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkflowVersionWorkspaceEntity>(
workspaceId,
'workflowVersion',
{ shouldBypassPermissionChecks: true },
);
await workflowVersionRepository.update(workflowVersion.id, {
steps: updatedSteps,
trigger: updatedTrigger,
});
return computeWorkflowVersionStepChanges({
existingTrigger: workflowVersion.trigger,
existingSteps: workflowVersion.steps,
updatedTrigger,
updatedSteps,
});
}
async createDraftStep({
@@ -340,65 +92,9 @@ export class WorkflowVersionStepWorkspaceService {
step: WorkflowAction;
workspaceId: string;
}): Promise<WorkflowAction> {
return this.workflowVersionStepOperationsWorkspaceService.createDraftStep({
return this.workflowVersionStepCreationWorkspaceService.createDraftStep({
step,
workspaceId,
});
}
private async updateWorkflowVersionStepType({
existingStep,
newStep,
workspaceId,
workflowVersionId,
}: {
existingStep: WorkflowAction;
newStep: WorkflowAction;
workspaceId: string;
workflowVersionId: string;
}): Promise<WorkflowAction> {
await this.workflowVersionStepOperationsWorkspaceService.runWorkflowVersionStepDeletionSideEffects(
{
step: existingStep,
workspaceId,
},
);
const { builtStep } =
await this.workflowVersionStepOperationsWorkspaceService.runStepCreationSideEffectsAndBuildStep(
{
type: newStep.type,
workspaceId,
position: newStep.position,
workflowVersionId,
},
);
return this.workflowSchemaWorkspaceService.enrichOutputSchema({
step: {
...builtStep,
id: existingStep.id,
nextStepIds: existingStep.nextStepIds,
position: existingStep.position,
},
workspaceId,
workflowVersionId,
});
}
private async updateWorkflowVersionStepSettings({
newStep,
workspaceId,
workflowVersionId,
}: {
newStep: WorkflowAction;
workspaceId: string;
workflowVersionId: string;
}): Promise<WorkflowAction> {
return this.workflowSchemaWorkspaceService.enrichOutputSchema({
step: newStep,
workspaceId,
workflowVersionId,
});
}
}