Stop catching all workflow errors (#18392)

Steps now throw WorkflowStepExecutorException. Then workflow executor
decides if error should be catch or not.

Since tools are not only used in workflow and these do not throw, we may
still miss errors here.

Workflow jobs now only catch errors to end the workflow run and throw.
This commit is contained in:
Thomas Trompette
2026-03-05 11:24:36 +01:00
committed by GitHub
parent abd9709291
commit a2f80d882b
12 changed files with 125 additions and 154 deletions
@@ -7,10 +7,6 @@ import { type Repository } from 'typeorm';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import { AgentAsyncExecutorService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service';
import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { AIBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
import { DEFAULT_SMART_MODEL } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
@@ -56,60 +52,47 @@ export class AiAgentWorkflowAction implements WorkflowAction {
const { agentId, prompt } = step.settings.input;
const workspaceId = context.workspaceId as string;
try {
let agent: AgentEntity | null = null;
let agent: AgentEntity | null = null;
if (agentId) {
agent = await this.agentRepository.findOne({
where: {
id: agentId,
workspaceId,
},
});
}
if (agentId && !agent) {
throw new AgentException(
`Agent with id ${agentId} not found`,
AgentExceptionCode.AGENT_NOT_FOUND,
);
}
const executionContext =
await this.workflowExecutionContextService.getExecutionContext(runInfo);
const { result, usage, cacheCreationTokens } =
await this.aiAgentExecutionService.executeAgent({
agent,
userPrompt: resolveInput(prompt, context) as string,
actorContext: executionContext.isActingOnBehalfOfUser
? executionContext.initiator
: undefined,
rolePermissionConfig: executionContext.rolePermissionConfig,
authContext: executionContext.authContext,
});
await this.aiBillingService.calculateAndBillUsage(
agent?.modelId ?? DEFAULT_SMART_MODEL,
{ usage, cacheCreationTokens },
workspaceId,
agent?.id || null,
);
return {
result,
};
} catch (error) {
if (error instanceof AgentException) {
return {
error: `${error.message} (${error.code})`,
};
}
return {
error:
error instanceof Error ? error.message : 'AI Agent execution failed',
};
if (agentId) {
agent = await this.agentRepository.findOne({
where: {
id: agentId,
workspaceId,
},
});
}
if (agentId && !agent) {
throw new WorkflowStepExecutorException(
`Agent with id ${agentId} not found`,
WorkflowStepExecutorExceptionCode.INVALID_STEP_INPUT,
);
}
const executionContext =
await this.workflowExecutionContextService.getExecutionContext(runInfo);
const { result, usage, cacheCreationTokens } =
await this.aiAgentExecutionService.executeAgent({
agent,
userPrompt: resolveInput(prompt, context) as string,
actorContext: executionContext.isActingOnBehalfOfUser
? executionContext.initiator
: undefined,
rolePermissionConfig: executionContext.rolePermissionConfig,
authContext: executionContext.authContext,
});
await this.aiBillingService.calculateAndBillUsage(
agent?.modelId ?? DEFAULT_SMART_MODEL,
{ usage, cacheCreationTokens },
workspaceId,
agent?.id || null,
);
return {
result,
};
}
}
@@ -4,6 +4,7 @@ import { resolveInput } from 'twenty-shared/utils';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service';
import {
WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode,
@@ -13,7 +14,6 @@ import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executo
import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/find-step-or-throw.util';
import { isWorkflowCodeAction } from 'src/modules/workflow/workflow-executor/workflow-actions/code/guards/is-workflow-code-action.guard';
import { type WorkflowCodeActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/code/types/workflow-code-action-input.type';
import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service';
@Injectable()
export class CodeWorkflowAction implements WorkflowAction {
@@ -44,22 +44,18 @@ export class CodeWorkflowAction implements WorkflowAction {
context,
) as WorkflowCodeActionInput;
try {
const { workspaceId } = runInfo;
const { workspaceId } = runInfo;
const result = await this.logicFunctionExecutorService.execute({
logicFunctionId: workflowActionInput.logicFunctionId,
workspaceId,
payload: workflowActionInput.logicFunctionInput,
});
const result = await this.logicFunctionExecutorService.execute({
logicFunctionId: workflowActionInput.logicFunctionId,
workspaceId,
payload: workflowActionInput.logicFunctionInput,
});
if (result.error) {
return { error: result.error.errorMessage };
}
return { result: result.data || {} };
} catch (error) {
return { error: error.message };
if (result.error) {
return { error: result.error.errorMessage };
}
return { result: result.data || {} };
}
}
@@ -103,6 +103,8 @@ export class ResumeDelayedWorkflowJob {
? error.message
: `Error during delay resume: ${String(error)}`,
});
throw error;
}
}, authContext);
}
@@ -4,6 +4,7 @@ import { resolveInput } from 'twenty-shared/utils';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import {
@@ -15,7 +16,6 @@ import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executo
import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/find-step-or-throw.util';
import { isWorkflowLogicFunctionAction } from 'src/modules/workflow/workflow-executor/workflow-actions/logic-function/guards/is-workflow-logic-function-action.guard';
import { WorkflowLogicFunctionActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/logic-function/types/workflow-logic-function-action-input.type';
import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service';
@Injectable()
export class LogicFunctionWorkflowAction implements WorkflowAction {
@@ -47,42 +47,38 @@ export class LogicFunctionWorkflowAction implements WorkflowAction {
context,
) as WorkflowLogicFunctionActionInput;
try {
const { workspaceId } = runInfo;
const { workspaceId } = runInfo;
const { flatLogicFunctionMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatLogicFunctionMaps'],
},
);
const { flatLogicFunctionMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatLogicFunctionMaps'],
},
);
const logicFunction = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: workflowActionInput.logicFunctionId,
flatEntityMaps: flatLogicFunctionMaps,
});
const logicFunction = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: workflowActionInput.logicFunctionId,
flatEntityMaps: flatLogicFunctionMaps,
});
if (!logicFunction) {
throw new WorkflowStepExecutorException(
`Logic function with id ${workflowActionInput.logicFunctionId} not found`,
WorkflowStepExecutorExceptionCode.INVALID_STEP_TYPE,
);
}
const result = await this.logicFunctionExecutorService.execute({
logicFunctionId: workflowActionInput.logicFunctionId,
workspaceId,
payload: workflowActionInput.logicFunctionInput,
});
if (result.error) {
return { error: result.error.errorMessage };
}
return { result: result.data || {} };
} catch (error) {
return { error: error.message };
if (!logicFunction) {
throw new WorkflowStepExecutorException(
`Logic function with id ${workflowActionInput.logicFunctionId} not found`,
WorkflowStepExecutorExceptionCode.INVALID_STEP_TYPE,
);
}
const result = await this.logicFunctionExecutorService.execute({
logicFunctionId: workflowActionInput.logicFunctionId,
workspaceId,
payload: workflowActionInput.logicFunctionInput,
});
if (result.error) {
return { error: result.error.errorMessage };
}
return { result: result.data || {} };
}
}
@@ -4,10 +4,6 @@ import { resolveInput } from 'twenty-shared/utils';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import {
RecordCrudException,
RecordCrudExceptionCode,
} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception';
import { CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service';
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service';
@@ -82,10 +78,7 @@ export class CreateRecordWorkflowAction implements WorkflowAction {
});
if (!toolOutput.success) {
throw new RecordCrudException(
toolOutput.error || toolOutput.message,
RecordCrudExceptionCode.RECORD_CREATION_FAILED,
);
return { error: toolOutput.error || toolOutput.message };
}
return {
@@ -4,10 +4,6 @@ import { isDefined, isValidUuid, resolveInput } from 'twenty-shared/utils';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import {
RecordCrudException,
RecordCrudExceptionCode,
} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception';
import { DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service';
import {
WorkflowStepExecutorException,
@@ -55,9 +51,9 @@ export class DeleteRecordWorkflowAction implements WorkflowAction {
!isValidUuid(workflowActionInput.objectRecordId) ||
!isDefined(workflowActionInput.objectName)
) {
throw new RecordCrudException(
throw new WorkflowStepExecutorException(
'Failed to delete: Object record ID and name are required',
RecordCrudExceptionCode.INVALID_REQUEST,
WorkflowStepExecutorExceptionCode.INVALID_STEP_INPUT,
);
}
@@ -73,10 +69,7 @@ export class DeleteRecordWorkflowAction implements WorkflowAction {
});
if (!toolOutput.success) {
throw new RecordCrudException(
toolOutput.error || toolOutput.message,
RecordCrudExceptionCode.RECORD_DELETION_FAILED,
);
return { error: toolOutput.error || toolOutput.message };
}
return {
@@ -12,10 +12,6 @@ import {
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import {
RecordCrudException,
RecordCrudExceptionCode,
} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception';
import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
@@ -121,10 +117,7 @@ export class FindRecordsWorkflowAction implements WorkflowAction {
});
if (!toolOutput.success) {
throw new RecordCrudException(
toolOutput.error || toolOutput.message,
RecordCrudExceptionCode.QUERY_FAILED,
);
return { error: toolOutput.error || toolOutput.message };
}
const records = toolOutput.result?.records ?? [];
@@ -4,10 +4,6 @@ import { isDefined, isValidUuid, resolveInput } from 'twenty-shared/utils';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import {
RecordCrudException,
RecordCrudExceptionCode,
} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception';
import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service';
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
import {
@@ -79,9 +75,9 @@ export class UpdateRecordWorkflowAction implements WorkflowAction {
!isValidUuid(workflowActionInput.objectRecordId) ||
!isDefined(workflowActionInput.objectName)
) {
throw new RecordCrudException(
throw new WorkflowStepExecutorException(
'Failed to update: Object record ID and name are required',
RecordCrudExceptionCode.INVALID_REQUEST,
WorkflowStepExecutorExceptionCode.INVALID_STEP_INPUT,
);
}
@@ -96,9 +92,9 @@ export class UpdateRecordWorkflowAction implements WorkflowAction {
);
if (filteredFieldsToUpdate?.length === 0) {
throw new RecordCrudException(
throw new WorkflowStepExecutorException(
'Failed to update: No fields to update',
RecordCrudExceptionCode.INVALID_REQUEST,
WorkflowStepExecutorExceptionCode.INVALID_STEP_INPUT,
);
}
@@ -118,10 +114,7 @@ export class UpdateRecordWorkflowAction implements WorkflowAction {
});
if (!toolOutput.success) {
throw new RecordCrudException(
toolOutput.error || toolOutput.message,
RecordCrudExceptionCode.RECORD_UPDATE_FAILED,
);
return { error: toolOutput.error || toolOutput.message };
}
return {
@@ -4,10 +4,6 @@ import { isDefined, resolveInput } from 'twenty-shared/utils';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import {
RecordCrudException,
RecordCrudExceptionCode,
} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception';
import { UpsertRecordService } from 'src/engine/core-modules/record-crud/services/upsert-record.service';
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
import {
@@ -74,9 +70,9 @@ export class UpsertRecordWorkflowAction implements WorkflowAction {
) as WorkflowUpsertRecordActionInput;
if (!isDefined(workflowActionInput.objectName)) {
throw new RecordCrudException(
throw new WorkflowStepExecutorException(
'Failed to upsert: Object name is required',
RecordCrudExceptionCode.INVALID_REQUEST,
WorkflowStepExecutorExceptionCode.INVALID_STEP_INPUT,
);
}
@@ -97,10 +93,7 @@ export class UpsertRecordWorkflowAction implements WorkflowAction {
});
if (!toolOutput.success) {
throw new RecordCrudException(
toolOutput.error || toolOutput.message,
RecordCrudExceptionCode.RECORD_UPSERT_FAILED,
);
return { error: toolOutput.error || toolOutput.message };
}
return {
@@ -14,6 +14,7 @@ import {
type WorkflowAction,
WorkflowActionType,
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
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';
@@ -56,6 +57,10 @@ describe('WorkflowExecutorWorkspaceService', () => {
canBillMeteredProduct: jest.fn().mockReturnValue(true),
};
const mockExceptionHandlerService = {
captureExceptions: jest.fn(),
};
const mockMessageQueueService = {
add: jest.fn(),
};
@@ -84,6 +89,10 @@ describe('WorkflowExecutorWorkspaceService', () => {
provide: BillingService,
useValue: mockBillingService,
},
{
provide: ExceptionHandlerService,
useValue: mockExceptionHandlerService,
},
{
provide: `MESSAGE_QUEUE_${MessageQueue.workflowQueue}`,
useValue: mockMessageQueueService,
@@ -15,12 +15,17 @@ import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/bil
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 { type BillingUsageEvent } from 'src/engine/core-modules/billing/types/billing-usage-event.type';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
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 { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { WorkflowRunStatus } from 'src/modules/workflow/common/standard-objects/workflow-run.workspace-entity';
import { workflowHasRunningSteps } from 'src/modules/workflow/common/utils/workflow-has-running-steps.util';
import {
WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode,
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { WorkflowActionFactory } from 'src/modules/workflow/workflow-executor/factories/workflow-action.factory';
import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import {
@@ -51,6 +56,7 @@ export class WorkflowExecutorWorkspaceService {
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
private readonly workflowRunWorkspaceService: WorkflowRunWorkspaceService,
private readonly billingService: BillingService,
private readonly exceptionHandlerService: ExceptionHandlerService,
@InjectMessageQueue(MessageQueue.workflowQueue)
private readonly messageQueueService: MessageQueueService,
) {}
@@ -484,6 +490,18 @@ export class WorkflowExecutorWorkspaceService {
},
});
} catch (error) {
const isUserError =
error instanceof WorkflowStepExecutorException &&
(error.code === WorkflowStepExecutorExceptionCode.INVALID_STEP_TYPE ||
error.code === WorkflowStepExecutorExceptionCode.INVALID_STEP_INPUT ||
error.code === WorkflowStepExecutorExceptionCode.STEP_NOT_FOUND);
if (!isUserError) {
this.exceptionHandlerService.captureExceptions([error], {
workspace: { id: workspaceId },
});
}
return {
error: error.message ?? 'Execution result error, no data or error',
};