diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowExpectedOutputBodyInput.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowExpectedOutputBodyInput.tsx new file mode 100644 index 0000000000..fc36655e00 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowExpectedOutputBodyInput.tsx @@ -0,0 +1,60 @@ +import { FormRawJsonFieldInput } from '@/object-record/record-field/ui/form-types/components/FormRawJsonFieldInput'; +import { parseAndValidateVariableFriendlyStringifiedJson } from '@/workflow/utils/parseAndValidateVariableFriendlyStringifiedJson'; +import { t } from '@lingui/core/macro'; +import { isNonEmptyString } from '@sniptt/guards'; +import { useState } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + +type WorkflowExpectedOutputBodyInputProps = { + label?: string; + placeholder?: string; + defaultValue: object | undefined; + readonly?: boolean; + onChange: (parsedValue: Record) => void; +}; + +export const WorkflowExpectedOutputBodyInput = ({ + label, + placeholder, + defaultValue, + readonly, + onChange, +}: WorkflowExpectedOutputBodyInputProps) => { + const [error, setError] = useState(); + const [errorVisible, setErrorVisible] = useState(false); + + const handleChange = (value: string | null) => { + if (readonly === true) { + return; + } + + const parsingResult = parseAndValidateVariableFriendlyStringifiedJson( + isNonEmptyString(value) ? value : '{}', + ); + + if (!parsingResult.isValid) { + setError(parsingResult.error); + + return; + } + + setError(undefined); + onChange(parsingResult.data); + }; + + return ( + setErrorVisible(true)} + readonly={readonly} + defaultValue={ + isDefined(defaultValue) && Object.keys(defaultValue).length > 0 + ? JSON.stringify(defaultValue, null, 2) + : null + } + onChange={handleChange} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCode.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCode.tsx index 4f1b6b0043..a21e20082c 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCode.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCode.tsx @@ -16,6 +16,7 @@ import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTab import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { WorkflowExpectedOutputBodyInput } from '@/workflow/workflow-steps/components/WorkflowExpectedOutputBodyInput'; import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody'; import { WorkflowStepCmdEnterButton } from '@/workflow/workflow-steps/components/WorkflowStepCmdEnterButton'; import { WorkflowCodeEditor } from '@/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowCodeEditor'; @@ -205,6 +206,22 @@ export const WorkflowEditActionCode = ({ }); }; + const handleExpectedOutputBodyChange = ( + parsedValue: Record, + ) => { + if (actionOptions.readonly === true) { + return; + } + + updateAction({ + ...action, + settings: { + ...action.settings, + expectedOutputSchema: parsedValue, + }, + }); + }; + const handleTestInputChange = async (value: any, path: string[]) => { if (actionOptions.readonly === true) { return; @@ -402,6 +419,11 @@ export const WorkflowEditActionCode = ({ readonly={actionOptions.readonly} onEnterFullScreen={handleEnterFullScreen} /> + )} {activeTabId === WorkflowLogicFunctionTabId.TEST && ( diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/hooks/useHttpRequestOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/hooks/useHttpRequestOutputSchema.ts index d944968ae1..fd6864575b 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/hooks/useHttpRequestOutputSchema.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/hooks/useHttpRequestOutputSchema.ts @@ -1,10 +1,11 @@ import { type WorkflowHttpRequestAction } from '@/workflow/types/Workflow'; -import { type BaseOutputSchemaV2 } from 'twenty-shared/workflow'; import { parseAndValidateVariableFriendlyStringifiedJson } from '@/workflow/utils/parseAndValidateVariableFriendlyStringifiedJson'; -import { isNonEmptyString } from '@sniptt/guards'; -import { useState } from 'react'; import { convertOutputSchemaToJson } from '@/workflow/workflow-steps/workflow-actions/http-request-action/utils/convertOutputSchemaToJson'; import { getHttpRequestOutputSchema } from '@/workflow/workflow-steps/workflow-actions/http-request-action/utils/getHttpRequestOutputSchema'; +import { isNonEmptyString } from '@sniptt/guards'; +import { useState } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { type BaseOutputSchemaV2 } from 'twenty-shared/workflow'; type UseHttpRequestOutputSchemaProps = { action: WorkflowHttpRequestAction; @@ -12,20 +13,37 @@ type UseHttpRequestOutputSchemaProps = { readonly?: boolean; }; +const getInitialExpectedBody = ( + action: WorkflowHttpRequestAction, +): object | undefined => { + const expectedOutputSchema = action.settings.expectedOutputSchema; + + if ( + isDefined(expectedOutputSchema) && + Object.keys(expectedOutputSchema).length + ) { + return expectedOutputSchema; + } + + if (Object.keys(action.settings.outputSchema).length) { + return convertOutputSchemaToJson( + action.settings.outputSchema as BaseOutputSchemaV2, + ); + } + + return undefined; +}; + export const useHttpRequestOutputSchema = ({ action, onActionUpdate, readonly, }: UseHttpRequestOutputSchemaProps) => { + const initialExpectedBody = getInitialExpectedBody(action); + const [outputSchema, setOutputSchema] = useState( - Object.keys(action.settings.outputSchema).length - ? JSON.stringify( - convertOutputSchemaToJson( - action.settings.outputSchema as BaseOutputSchemaV2, - ), - null, - 2, - ) + isDefined(initialExpectedBody) + ? JSON.stringify(initialExpectedBody, null, 2) : null, ); @@ -52,6 +70,7 @@ export const useHttpRequestOutputSchema = ({ ...action, settings: { ...action.settings, + expectedOutputSchema: parsingResult.data, outputSchema: getHttpRequestOutputSchema(parsingResult.data), }, }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/logic-function-action/components/WorkflowEditActionLogicFunction.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/logic-function-action/components/WorkflowEditActionLogicFunction.tsx index b48aa74214..e10235f913 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/logic-function-action/components/WorkflowEditActionLogicFunction.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/logic-function-action/components/WorkflowEditActionLogicFunction.tsx @@ -9,6 +9,7 @@ import { TabList } from '@/ui/layout/tab-list/components/TabList'; import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { type WorkflowLogicFunctionAction } from '@/workflow/types/Workflow'; +import { WorkflowExpectedOutputBodyInput } from '@/workflow/workflow-steps/components/WorkflowExpectedOutputBodyInput'; import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody'; import { WorkflowStepCmdEnterButton } from '@/workflow/workflow-steps/components/WorkflowStepCmdEnterButton'; import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter'; @@ -174,6 +175,21 @@ export const WorkflowEditActionLogicFunction = ({ updateLogicFunctionInput(updatedTestFunctionInput); }; + const handleExpectedOutputBodyChange = ( + parsedValue: Record, + ) => { + if (actionOptions.readonly === true) { + return; + } + + updateAction({ + settings: { + ...action.settings, + expectedOutputSchema: parsedValue, + }, + }); + }; + const handleTestFunction = async () => { if (actionOptions.readonly === true) { return; @@ -264,6 +280,11 @@ export const WorkflowEditActionLogicFunction = ({ description={t`You can see the function logic in your application settings.`} /> )} + )} diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx index ede9ca5129..165c40ccbe 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx @@ -160,6 +160,7 @@ export const WorkflowEditTriggerWebhookForm = ({ ...trigger.settings, httpMethod: 'POST', expectedBody: parsingResult.data, + expectedOutputSchema: parsingResult.data, outputSchema, } satisfies WorkflowWebhookTrigger['settings'], }, diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/__tests__/getWebhookTriggerDefaultSettings.test.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/__tests__/getWebhookTriggerDefaultSettings.test.ts index 8246c55c3c..b6a5d305a5 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/__tests__/getWebhookTriggerDefaultSettings.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/__tests__/getWebhookTriggerDefaultSettings.test.ts @@ -18,6 +18,9 @@ describe('getWebhookTriggerDefaultSettings', () => { expectedBody: { message: 'Workflow was started', }, + expectedOutputSchema: { + message: 'Workflow was started', + }, outputSchema: { message: { icon: 'IconVariable', diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/getWebhookTriggerDefaultSettings.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/getWebhookTriggerDefaultSettings.ts index d61b9ae59a..1c3653f63e 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/getWebhookTriggerDefaultSettings.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/getWebhookTriggerDefaultSettings.ts @@ -27,6 +27,9 @@ export const getWebhookTriggerDefaultSettings = ( expectedBody: { message: 'Workflow was started', }, + expectedOutputSchema: { + message: 'Workflow was started', + }, authentication: null, }; } diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/hooks/useStepsOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/hooks/useStepsOutputSchema.ts index ee7acc7662..23f769e60c 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/hooks/useStepsOutputSchema.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/hooks/useStepsOutputSchema.ts @@ -14,6 +14,7 @@ import { computeStepOutputSchema, shouldComputeOutputSchemaOnFrontend, } from '@/workflow/workflow-variables/utils/generate/computeStepOutputSchema'; +import { resolvePersistedStepOutputSchema } from '@/workflow/workflow-variables/utils/resolvePersistedStepOutputSchema'; import { useStore } from 'jotai'; import { useCallback } from 'react'; import { isDefined } from 'twenty-shared/utils'; @@ -44,29 +45,15 @@ export const useStepsOutputSchema = () => { return; } - // TODO: Remove this fallback after upgrade command - // `upgrade:1-21:migrate-ai-agent-text-to-json-response-format` - // has run on all workspaces. - const persistedOutputSchema = - step.type === 'AI_AGENT' && - (!isDefined(step.settings?.outputSchema) || - Object.keys(step.settings.outputSchema).length === 0) - ? { - response: { - isLeaf: true, - type: 'string', - label: 'Response', - value: null, - }, - } - : step.settings?.outputSchema; - const outputSchema = shouldComputeOnFrontend ? computeStepOutputSchema({ step, objectMetadataItems, }) - : persistedOutputSchema; + : resolvePersistedStepOutputSchema({ + stepType: step.type, + settings: step.settings, + }); const stepOutputSchema: StepOutputSchemaV2 = { id: step.id, @@ -115,7 +102,10 @@ export const useStepsOutputSchema = () => { step: trigger, objectMetadataItems, }) - : trigger.settings?.outputSchema; + : resolvePersistedStepOutputSchema({ + stepType: trigger.type, + settings: trigger.settings, + }); const triggerOutputSchema: StepOutputSchemaV2 = { id: TRIGGER_STEP_ID, diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/resolvePersistedStepOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/resolvePersistedStepOutputSchema.ts new file mode 100644 index 0000000000..9e230d44ff --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/resolvePersistedStepOutputSchema.ts @@ -0,0 +1,45 @@ +import { type OutputSchemaV2 } from '@/workflow/workflow-variables/types/StepOutputSchemaV2'; +import { getOutputSchemaFromValue } from 'twenty-shared/logic-function'; +import { isEmptyObject, isPlainObject } from 'twenty-shared/utils'; +import { isBaseOutputSchemaV2 } from 'twenty-shared/workflow'; + +const AI_AGENT_DEFAULT_OUTPUT_SCHEMA: OutputSchemaV2 = { + response: { + isLeaf: true, + type: 'string', + label: 'Response', + value: null, + }, +}; + +export const resolvePersistedStepOutputSchema = ({ + stepType, + settings, +}: { + stepType: string; + settings?: + | { outputSchema?: unknown; expectedOutputSchema?: unknown } + | null + | undefined; +}): OutputSchemaV2 => { + const outputSchema = settings?.outputSchema; + + if (isBaseOutputSchemaV2(outputSchema)) { + return outputSchema; + } + + const expectedOutputSchema = settings?.expectedOutputSchema; + + if ( + isPlainObject(expectedOutputSchema) && + !isEmptyObject(expectedOutputSchema) + ) { + return getOutputSchemaFromValue(expectedOutputSchema); + } + + if (stepType === 'AI_AGENT') { + return AI_AGENT_DEFAULT_OUTPUT_SCHEMA; + } + + return {}; +}; diff --git a/packages/twenty-server/src/modules/workflow/common/workspace-services/workflow-common.workspace-service.ts b/packages/twenty-server/src/modules/workflow/common/workspace-services/workflow-common.workspace-service.ts index 05d57a2dfd..eb7a639a87 100644 --- a/packages/twenty-server/src/modules/workflow/common/workspace-services/workflow-common.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/common/workspace-services/workflow-common.workspace-service.ts @@ -5,17 +5,18 @@ import { In } from 'typeorm'; import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; import { CommandMenuItemService } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.service'; +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util'; -import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; -import { LogicFunctionFromSourceService } from 'src/engine/metadata-modules/logic-function/services/logic-function-from-source.service'; import { LogicFunctionException, LogicFunctionExceptionCode, } from 'src/engine/metadata-modules/logic-function/logic-function.exception'; +import { LogicFunctionFromSourceService } from 'src/engine/metadata-modules/logic-function/services/logic-function-from-source.service'; +import { type FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; @@ -33,12 +34,12 @@ import { WorkflowStatus, type WorkflowWorkspaceEntity, } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity'; -import { WorkflowActionType } from 'twenty-shared/workflow'; import { WorkflowTriggerException, WorkflowTriggerExceptionCode, } from 'src/modules/workflow/workflow-trigger/exceptions/workflow-trigger.exception'; import { getWorkflowCommandMenuItemLabel } from 'src/modules/workflow/workflow-trigger/utils/get-workflow-command-menu-item-label.util'; +import { WorkflowActionType } from 'twenty-shared/workflow'; export type ObjectMetadataInfo = { flatObjectMetadata: FlatObjectMetadata; @@ -202,6 +203,27 @@ export class WorkflowCommonWorkspaceService { }; } + async getLogicFunctionById({ + logicFunctionId, + workspaceId, + }: { + logicFunctionId: string; + workspaceId: string; + }): Promise { + const { flatLogicFunctionMaps } = + await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatLogicFunctionMaps'], + }, + ); + + return findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: logicFunctionId, + flatEntityMaps: flatLogicFunctionMaps, + }); + } + async getObjectMetadataInfo( objectNameSingular: string, workspaceId: string, diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service.ts index 17097dcde3..834ae46d18 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service.ts @@ -1,6 +1,10 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { isString } from '@sniptt/guards'; +import { + getOutputSchemaFromValue, + inputSchemaToOutputSchema, +} from 'twenty-shared/logic-function'; import { isDefined, isValidVariable } from 'twenty-shared/utils'; import { BaseOutputSchemaV2, @@ -8,6 +12,7 @@ import { BulkRecordsAvailability, extractRawVariableNamePart, GlobalAvailability, + isBaseOutputSchemaV2, navigateOutputSchemaProperty, SingleRecordAvailability, TRIGGER_STEP_ID, @@ -20,6 +25,7 @@ import { 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 { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; 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'; @@ -43,6 +49,8 @@ import { @Injectable() export class WorkflowSchemaWorkspaceService { + private readonly logger = new Logger(WorkflowSchemaWorkspaceService.name); + constructor( private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService, private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, @@ -78,7 +86,6 @@ export class WorkflowSchemaWorkspaceService { return {}; } - case WorkflowTriggerType.WEBHOOK: case WorkflowTriggerType.CRON: { return {}; } @@ -136,7 +143,23 @@ export class WorkflowSchemaWorkspaceService { }, }; } - case WorkflowActionType.CODE: // StepOutput schema is computed on logicFunction draft execution + case WorkflowTriggerType.WEBHOOK: + case WorkflowActionType.CODE: + case WorkflowActionType.HTTP_REQUEST: { + const expectedOutputSchema = + 'expectedOutputSchema' in step.settings + ? step.settings.expectedOutputSchema + : undefined; + + return this.computeOutputSchemaFromExpectedSample(expectedOutputSchema); + } + case WorkflowActionType.LOGIC_FUNCTION: { + return this.computeLogicFunctionOutputSchema({ + logicFunctionId: step.settings.input.logicFunctionId, + expectedOutputSchema: step.settings.expectedOutputSchema, + workspaceId, + }); + } default: return {}; } @@ -172,6 +195,100 @@ export class WorkflowSchemaWorkspaceService { return result; } + private computeOutputSchemaFromExpectedSample( + expectedOutputSchema: object | undefined, + ): OutputSchema { + if ( + isDefined(expectedOutputSchema) && + Object.keys(expectedOutputSchema).length > 0 + ) { + return getOutputSchemaFromValue(expectedOutputSchema); + } + + return {}; + } + + private getOutputSchemaWithExpectedFallback(settings: { + outputSchema?: OutputSchema; + expectedOutputSchema?: object; + }): BaseOutputSchemaV2 { + const outputSchema = settings.outputSchema; + + if (isBaseOutputSchemaV2(outputSchema)) { + return outputSchema; + } + + const expectedOutputSchema = this.computeOutputSchemaFromExpectedSample( + settings.expectedOutputSchema, + ); + + return isBaseOutputSchemaV2(expectedOutputSchema) + ? expectedOutputSchema + : {}; + } + + private async computeLogicFunctionOutputSchema({ + logicFunctionId, + expectedOutputSchema, + workspaceId, + }: { + logicFunctionId: string; + expectedOutputSchema: object | undefined; + workspaceId: string; + }): Promise { + const declaredOutputSchema = + await this.getLogicFunctionDeclaredOutputSchema({ + logicFunctionId, + workspaceId, + }); + + if (isDefined(declaredOutputSchema)) { + return declaredOutputSchema; + } + + return this.computeOutputSchemaFromExpectedSample(expectedOutputSchema); + } + + private async getLogicFunctionDeclaredOutputSchema({ + logicFunctionId, + workspaceId, + }: { + logicFunctionId: string; + workspaceId: string; + }): Promise { + if (!isDefined(logicFunctionId)) { + return undefined; + } + + const { flatLogicFunctionMaps } = + await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatLogicFunctionMaps'], + }, + ); + + const flatLogicFunction = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: logicFunctionId, + flatEntityMaps: flatLogicFunctionMaps, + }); + + const declaredInputSchema = + flatLogicFunction?.workflowActionTriggerSettings?.outputSchema; + + if (!isDefined(declaredInputSchema)) { + return undefined; + } + + const declaredOutputSchema = inputSchemaToOutputSchema(declaredInputSchema); + + if (Object.keys(declaredOutputSchema).length === 0) { + return undefined; + } + + return declaredOutputSchema; + } + private async computeDatabaseEventTriggerOutputSchema({ eventName, workspaceId, @@ -481,7 +598,7 @@ export class WorkflowSchemaWorkspaceService { case WorkflowActionType.LOGIC_FUNCTION: { const propertyPath = extractPropertyPathFromVariable(items); const schemaNode = navigateOutputSchemaProperty({ - schema: step.settings.outputSchema as BaseOutputSchemaV2, + schema: this.getOutputSchemaWithExpectedFallback(step.settings), propertyPath, }); diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-validation/__tests__/workflow-validation.workspace-service.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-validation/__tests__/workflow-validation.workspace-service.spec.ts index 45f8a840ba..54ece9957d 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-validation/__tests__/workflow-validation.workspace-service.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-validation/__tests__/workflow-validation.workspace-service.spec.ts @@ -1,11 +1,20 @@ -import { WorkflowActionType } from 'twenty-shared/workflow'; import { type WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; import { type WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service'; +import { type OutputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type'; +import { WorkflowValidationWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-validation/workflow-validation.workspace-service'; import { type WorkflowAiAgentAction, + type WorkflowCodeAction, type WorkflowFindRecordsAction, + type WorkflowHttpRequestAction, + type WorkflowIteratorAction, + type WorkflowLogicFunctionAction, } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; -import { WorkflowValidationWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-validation/workflow-validation.workspace-service'; +import { + type WorkflowTrigger, + WorkflowTriggerType, +} from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type'; +import { WorkflowActionType } from 'twenty-shared/workflow'; const WORKSPACE_ID = 'workspace-id'; @@ -14,10 +23,19 @@ const ERROR_HANDLING_OPTIONS = { continueOnFailure: { value: false }, }; -const buildService = (objectIdByNameSingular: Record = {}) => { +const buildService = ({ + objectIdByNameSingular = {}, + logicFunction = null, +}: { + objectIdByNameSingular?: Record; + logicFunction?: object | null; +} = {}) => { const workflowCommonWorkspaceService = { getWorkflowVersionOrFail: jest.fn(), getFlatEntityMaps: jest.fn().mockResolvedValue({ objectIdByNameSingular }), + getLogicFunctionById: jest + .fn() + .mockResolvedValue(logicFunction ?? undefined), } as unknown as jest.Mocked; const workflowSchemaWorkspaceService = { @@ -47,6 +65,36 @@ const buildAiAgentStep = (input: { }, }); +const buildHttpRequestStep = ( + outputSchema: OutputSchema = {}, +): WorkflowHttpRequestAction => ({ + id: 'http-request-step', + name: 'HTTP Request', + type: WorkflowActionType.HTTP_REQUEST, + valid: true, + settings: { + input: { + url: 'https://example.com', + method: 'GET', + headers: {}, + body: {}, + }, + outputSchema, + errorHandlingOptions: ERROR_HANDLING_OPTIONS, + }, +}); + +const buildWebhookTrigger = (outputSchema: object = {}): WorkflowTrigger => + ({ + type: WorkflowTriggerType.WEBHOOK, + name: 'Webhook', + settings: { + outputSchema, + httpMethod: 'GET', + authentication: null, + }, + }) as unknown as WorkflowTrigger; + const buildFindRecordsStep = ( objectName: string, ): WorkflowFindRecordsAction => ({ @@ -61,6 +109,107 @@ const buildFindRecordsStep = ( }, }); +const buildCodeStep = (expectedOutputSchema?: object): WorkflowCodeAction => ({ + id: 'code-step', + name: 'Code', + type: WorkflowActionType.CODE, + valid: true, + settings: { + input: { + logicFunctionId: 'logic-function-id', + logicFunctionInput: {}, + }, + outputSchema: {}, + ...(expectedOutputSchema ? { expectedOutputSchema } : {}), + errorHandlingOptions: ERROR_HANDLING_OPTIONS, + }, +}); + +const FIND_RECORDS_OUTPUT_SCHEMA = { + first: { + isLeaf: false, + label: 'First', + value: { + _outputSchemaType: 'RECORD', + object: { objectMetadataId: 'company-metadata-id', label: 'Company' }, + fields: { + name: { + isLeaf: true, + type: 'TEXT', + label: 'Company Name', + value: 'Acme', + fieldMetadataId: 'company-name-id', + isCompositeSubField: false, + }, + }, + }, + }, + all: { + isLeaf: true, + label: 'All', + value: 'Returns an array of records', + type: 'array', + }, + totalCount: { + isLeaf: true, + label: 'Total Count', + value: 42, + type: 'number', + }, +}; + +const buildFindRecordsStepWithOutputSchema = ( + outputSchema: object, +): WorkflowFindRecordsAction => ({ + id: 'find-records-step', + name: 'Find Records', + type: WorkflowActionType.FIND_RECORDS, + valid: true, + settings: { + input: { objectName: 'company' }, + outputSchema: outputSchema as OutputSchema, + errorHandlingOptions: ERROR_HANDLING_OPTIONS, + }, +}); + +const buildIteratorStep = (items: string | unknown[]): WorkflowIteratorAction => + ({ + id: 'iterator-step', + name: 'Iterator', + type: WorkflowActionType.ITERATOR, + valid: true, + settings: { + input: { items, initialLoopStepIds: ['body-step'] }, + errorHandlingOptions: ERROR_HANDLING_OPTIONS, + }, + }) as unknown as WorkflowIteratorAction; + +const buildLogicFunctionDefinition = ( + properties: Record, +): object => ({ + workflowActionTriggerSettings: { + outputSchema: [{ type: 'object', label: 'Output', properties }], + }, +}); + +const buildLogicFunctionStep = ( + expectedOutputSchema?: object, +): WorkflowLogicFunctionAction => ({ + id: 'logic-function-step', + name: 'Logic Function', + type: WorkflowActionType.LOGIC_FUNCTION, + valid: true, + settings: { + input: { + logicFunctionId: 'logic-function-id', + logicFunctionInput: {}, + }, + outputSchema: {}, + ...(expectedOutputSchema ? { expectedOutputSchema } : {}), + errorHandlingOptions: ERROR_HANDLING_OPTIONS, + }, +}); + describe('WorkflowValidationWorkspaceService', () => { it('should flag an AI Agent step that has no agent selected', async () => { const service = buildService(); @@ -91,7 +240,7 @@ describe('WorkflowValidationWorkspaceService', () => { }); it('should flag a record step targeting an object that does not exist in the workspace', async () => { - const service = buildService({}); + const service = buildService({ objectIdByNameSingular: {} }); const result = await service.validateWorkflowDefinition({ workspaceId: WORKSPACE_ID, @@ -107,7 +256,9 @@ describe('WorkflowValidationWorkspaceService', () => { }); it('should not flag a record step targeting an existing object', async () => { - const service = buildService({ person: 'object-id-1' }); + const service = buildService({ + objectIdByNameSingular: { person: 'object-id-1' }, + }); const result = await service.validateWorkflowDefinition({ workspaceId: WORKSPACE_ID, @@ -121,4 +272,263 @@ describe('WorkflowValidationWorkspaceService', () => { ), ).toBe(false); }); + + it('should error about a code step that has no output schema', async () => { + const service = buildService(); + + const result = await service.validateWorkflowDefinition({ + workspaceId: WORKSPACE_ID, + trigger: null, + steps: [buildCodeStep()], + }); + + expect(result.errors.map((issue) => issue.code)).toContain( + 'CODE_STEP_MISSING_OUTPUT_SCHEMA', + ); + }); + + it('should not warn about a code step that declares an expected output schema', async () => { + const service = buildService(); + + const result = await service.validateWorkflowDefinition({ + workspaceId: WORKSPACE_ID, + trigger: null, + steps: [buildCodeStep({ greeting: 'hello' })], + }); + + expect(result.warnings.map((issue) => issue.code)).not.toContain( + 'CODE_STEP_MISSING_OUTPUT_SCHEMA', + ); + }); + + it('should error about a logic function step with no output schema from any source', async () => { + const service = buildService({ logicFunction: null }); + + const result = await service.validateWorkflowDefinition({ + workspaceId: WORKSPACE_ID, + trigger: null, + steps: [buildLogicFunctionStep()], + }); + + expect(result.errors.map((issue) => issue.code)).toContain( + 'CODE_STEP_MISSING_OUTPUT_SCHEMA', + ); + }); + + it('should not warn about a logic function step whose definition declares an output schema', async () => { + const service = buildService({ + logicFunction: buildLogicFunctionDefinition({ + result: { type: 'string', label: 'result' }, + }), + }); + + const result = await service.validateWorkflowDefinition({ + workspaceId: WORKSPACE_ID, + trigger: null, + steps: [buildLogicFunctionStep()], + }); + + expect(result.warnings.map((issue) => issue.code)).not.toContain( + 'CODE_STEP_MISSING_OUTPUT_SCHEMA', + ); + }); + + it('should not warn about a logic function step that declares an expected output schema on the step', async () => { + const service = buildService({ logicFunction: null }); + + const result = await service.validateWorkflowDefinition({ + workspaceId: WORKSPACE_ID, + trigger: null, + steps: [buildLogicFunctionStep({ greeting: 'hello' })], + }); + + expect(result.warnings.map((issue) => issue.code)).not.toContain( + 'CODE_STEP_MISSING_OUTPUT_SCHEMA', + ); + }); + + it('should warn when the logic function step expected output schema does not match the declared output schema', async () => { + const service = buildService({ + logicFunction: buildLogicFunctionDefinition({ + result: { type: 'string', label: 'result' }, + }), + }); + + const result = await service.validateWorkflowDefinition({ + workspaceId: WORKSPACE_ID, + trigger: null, + steps: [buildLogicFunctionStep({ result: 123 })], + }); + + expect(result.warnings.map((issue) => issue.code)).toContain( + 'LOGIC_FUNCTION_OUTPUT_SCHEMA_MISMATCH', + ); + }); + + it('should not warn when the logic function step expected output schema matches the declared output schema', async () => { + const service = buildService({ + logicFunction: buildLogicFunctionDefinition({ + result: { type: 'string', label: 'result' }, + }), + }); + + const result = await service.validateWorkflowDefinition({ + workspaceId: WORKSPACE_ID, + trigger: null, + steps: [buildLogicFunctionStep({ result: 'hello' })], + }); + + expect(result.warnings.map((issue) => issue.code)).not.toContain( + 'LOGIC_FUNCTION_OUTPUT_SCHEMA_MISMATCH', + ); + }); + + it('should error about an HTTP request step that has no output schema', async () => { + const service = buildService(); + + const result = await service.validateWorkflowDefinition({ + workspaceId: WORKSPACE_ID, + trigger: null, + steps: [buildHttpRequestStep({})], + }); + + expect(result.errors.map((issue) => issue.code)).toContain( + 'CODE_STEP_MISSING_OUTPUT_SCHEMA', + ); + }); + + it('should not error about an HTTP request step that declares an output schema', async () => { + const service = buildService(); + + const result = await service.validateWorkflowDefinition({ + workspaceId: WORKSPACE_ID, + trigger: null, + steps: [ + buildHttpRequestStep({ + status: { isLeaf: true, type: 'number', label: 'status', value: 200 }, + }), + ], + }); + + expect(result.errors.map((issue) => issue.code)).not.toContain( + 'CODE_STEP_MISSING_OUTPUT_SCHEMA', + ); + }); + + it('should warn when a variable-consuming step references no variable', async () => { + const service = buildService(); + + const result = await service.validateWorkflowDefinition({ + workspaceId: WORKSPACE_ID, + trigger: null, + steps: [buildHttpRequestStep({})], + }); + + expect(result.warnings.map((issue) => issue.code)).toContain( + 'STEP_HAS_NO_VARIABLE_REFERENCE', + ); + }); + + it('should not warn about a missing variable reference when the step references a variable', async () => { + const service = buildService(); + + const httpRequestStepWithVariable = buildHttpRequestStep({}); + + httpRequestStepWithVariable.settings.input.body = { + name: '{{trigger.body.name}}', + }; + + const result = await service.validateWorkflowDefinition({ + workspaceId: WORKSPACE_ID, + trigger: null, + steps: [httpRequestStepWithVariable], + }); + + expect(result.warnings.map((issue) => issue.code)).not.toContain( + 'STEP_HAS_NO_VARIABLE_REFERENCE', + ); + }); + + it('should error about a webhook trigger that has no output schema', async () => { + const service = buildService(); + + const result = await service.validateWorkflowDefinition({ + workspaceId: WORKSPACE_ID, + trigger: buildWebhookTrigger({}), + steps: [buildFindRecordsStep('person')], + }); + + expect(result.errors.map((issue) => issue.code)).toContain( + 'CODE_STEP_MISSING_OUTPUT_SCHEMA', + ); + }); + + it('should not error about a webhook trigger that declares an output schema', async () => { + const service = buildService({ + objectIdByNameSingular: { person: 'object-id-1' }, + }); + + const result = await service.validateWorkflowDefinition({ + workspaceId: WORKSPACE_ID, + trigger: buildWebhookTrigger({ + body: { isLeaf: false, type: 'object', label: 'body', value: {} }, + }), + steps: [buildFindRecordsStep('person')], + }); + + expect(result.errors.map((issue) => issue.code)).not.toContain( + 'CODE_STEP_MISSING_OUTPUT_SCHEMA', + ); + }); + + it('should flag an iterator whose items reference a non-array path and suggest array paths', async () => { + const service = buildService(); + + const result = await service.validateWorkflowDefinition({ + workspaceId: WORKSPACE_ID, + trigger: null, + steps: [ + buildFindRecordsStepWithOutputSchema(FIND_RECORDS_OUTPUT_SCHEMA), + buildIteratorStep('{{find-records-step.first}}'), + ], + }); + + const iteratorIssue = result.errors.find( + (issue) => issue.code === 'ITERATOR_ITEMS_NOT_ARRAY', + ); + + expect(iteratorIssue).toBeDefined(); + expect(iteratorIssue?.suggestions).toContain('find-records-step.all'); + }); + + it('should not flag an iterator whose items reference an array path', async () => { + const service = buildService(); + + const result = await service.validateWorkflowDefinition({ + workspaceId: WORKSPACE_ID, + trigger: null, + steps: [ + buildFindRecordsStepWithOutputSchema(FIND_RECORDS_OUTPUT_SCHEMA), + buildIteratorStep('{{find-records-step.all}}'), + ], + }); + + expect(result.errors.map((issue) => issue.code)).not.toContain( + 'ITERATOR_ITEMS_NOT_ARRAY', + ); + }); + + it('should not flag an iterator that iterates over an inline array', async () => { + const service = buildService(); + + const result = await service.validateWorkflowDefinition({ + workspaceId: WORKSPACE_ID, + trigger: null, + steps: [buildIteratorStep(['one', 'two'])], + }); + + expect(result.errors.map((issue) => issue.code)).not.toContain( + 'ITERATOR_ITEMS_NOT_ARRAY', + ); + }); }); diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-validation/workflow-validation.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-validation/workflow-validation.workspace-service.ts index bafc4283dc..f0baad9fde 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-validation/workflow-validation.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-validation/workflow-validation.workspace-service.ts @@ -1,12 +1,28 @@ import { Injectable } from '@nestjs/common'; -import { isNonEmptyString, isObject, isString } from '@sniptt/guards'; -import { isDefined } from 'twenty-shared/utils'; import { + isNonEmptyArray, + isNonEmptyString, + isObject, + isString, +} from '@sniptt/guards'; +import { + getOutputSchemaFromValue, + getOutputSchemaMismatchIssues, + inputSchemaToOutputSchema, +} from 'twenty-shared/logic-function'; +import { isDefined, isValidVariable } from 'twenty-shared/utils'; +import { + type BaseOutputSchemaV2, + collectOutputSchemaVariablePaths, + extractVariablesFromInput, + parseVariablePath, + resolveVariablePathInOutputSchema, + TRIGGER_STEP_ID, validateWorkflowStructure, + WorkflowActionType, type WorkflowValidationIssue, type WorkflowValidationResult, - WorkflowActionType, } from 'twenty-shared/workflow'; import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; @@ -14,8 +30,13 @@ import { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-bu import { type WorkflowAction, type WorkflowAiAgentAction, + type WorkflowIteratorAction, + type WorkflowLogicFunctionAction, } 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'; +import { + type WorkflowTrigger, + WorkflowTriggerType, +} from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type'; const RECORD_CRUD_ACTION_TYPES = new Set([ WorkflowActionType.CREATE_RECORD, @@ -25,6 +46,14 @@ const RECORD_CRUD_ACTION_TYPES = new Set([ WorkflowActionType.FIND_RECORDS, ]); +const VARIABLE_CONSUMING_ACTION_TYPES = new Set([ + WorkflowActionType.HTTP_REQUEST, + WorkflowActionType.CODE, + WorkflowActionType.LOGIC_FUNCTION, + WorkflowActionType.SEND_EMAIL, + ...RECORD_CRUD_ACTION_TYPES, +]); + @Injectable() export class WorkflowValidationWorkspaceService { constructor( @@ -77,8 +106,12 @@ export class WorkflowValidationWorkspaceService { steps: enrichedSteps, }); - const semanticIssues = this.validateStepTypeRequirements({ + const triggerIssues = this.validateTriggerTypeRequirements(enrichedTrigger); + + const semanticIssues = await this.validateStepTypeRequirements({ + workspaceId, steps: enrichedSteps ?? [], + trigger: enrichedTrigger, }); const metadataIssues = await this.validateWorkspaceMetadata({ @@ -86,9 +119,15 @@ export class WorkflowValidationWorkspaceService { steps: enrichedSteps ?? [], }); + const variableReferenceIssues = this.validateStepsHaveVariableReferences( + enrichedSteps ?? [], + ); + return mergeValidationResults(staticResult, [ + ...triggerIssues, ...semanticIssues, ...metadataIssues, + ...variableReferenceIssues, ]); } @@ -167,11 +206,15 @@ export class WorkflowValidationWorkspaceService { } } - private validateStepTypeRequirements({ + private async validateStepTypeRequirements({ + workspaceId, steps, + trigger, }: { + workspaceId: string; steps: WorkflowAction[]; - }): WorkflowValidationIssue[] { + trigger: WorkflowTrigger | null; + }): Promise { const issues: WorkflowValidationIssue[] = []; for (const step of steps) { @@ -179,12 +222,324 @@ export class WorkflowValidationWorkspaceService { case WorkflowActionType.AI_AGENT: issues.push(...this.validateAiAgentStep(step)); break; + case WorkflowActionType.CODE: + case WorkflowActionType.HTTP_REQUEST: + issues.push(...this.validateRuntimeOutputStep(step)); + break; + case WorkflowActionType.LOGIC_FUNCTION: + issues.push( + ...(await this.validateLogicFunctionStep({ step, workspaceId })), + ); + break; + case WorkflowActionType.ITERATOR: + issues.push(...this.validateIteratorStep({ step, steps, trigger })); + break; } } return issues; } + private validateIteratorStep({ + step, + steps, + trigger, + }: { + step: WorkflowIteratorAction; + steps: WorkflowAction[]; + trigger: WorkflowTrigger | null; + }): WorkflowValidationIssue[] { + const items = step.settings?.input?.items; + + if (!isString(items) || !isValidVariable(items)) { + return []; + } + + const [variable] = extractVariablesFromInput(items); + + if (!isDefined(variable)) { + return []; + } + + const [referencedStepId, ...propertyPath] = parseVariablePath(variable); + + if (!isDefined(referencedStepId)) { + return []; + } + + const outputSchema = + referencedStepId === TRIGGER_STEP_ID + ? trigger?.settings?.outputSchema + : steps.find((currentStep) => currentStep.id === referencedStepId) + ?.settings?.outputSchema; + + if (!isDefined(outputSchema) || !isObject(outputSchema)) { + return []; + } + + const resolved = resolveVariablePathInOutputSchema({ + schema: outputSchema, + propertyPath, + }); + + if (resolved.found && resolved.type === 'array') { + return []; + } + + const arrayPathSuggestions = collectOutputSchemaVariablePaths(outputSchema) + .filter( + (path) => + resolveVariablePathInOutputSchema({ + schema: outputSchema, + propertyPath: path.split('.'), + }).type === 'array', + ) + .map((path) => `${referencedStepId}.${path}`); + + const hint = isNonEmptyArray(arrayPathSuggestions) + ? `Did you mean "{{${arrayPathSuggestions[0]}}}"?${ + arrayPathSuggestions.length > 1 + ? ` Other options: ${arrayPathSuggestions + .slice(1) + .map((suggestion) => `{{${suggestion}}}`) + .join(', ')}.` + : '' + }` + : undefined; + + return [ + { + severity: 'error', + code: 'ITERATOR_ITEMS_NOT_ARRAY', + message: `Iterator step "${step.name ?? step.id}" must iterate over an array, but "{{${variable}}}" is not an array.`, + stepId: step.id, + path: variable, + ...(isDefined(hint) ? { hint } : {}), + ...(isNonEmptyArray(arrayPathSuggestions) + ? { suggestions: arrayPathSuggestions } + : {}), + }, + ]; + } + + private validateStepsHaveVariableReferences( + steps: WorkflowAction[], + ): WorkflowValidationIssue[] { + const issues: WorkflowValidationIssue[] = []; + + for (const step of steps) { + if (!VARIABLE_CONSUMING_ACTION_TYPES.has(step.type)) { + continue; + } + + const variables = extractVariablesFromInput(step.settings?.input); + + if (variables.length > 0) { + continue; + } + + issues.push({ + severity: 'warning', + code: 'STEP_HAS_NO_VARIABLE_REFERENCE', + message: `Step "${step.name ?? step.id}" does not reference any variable from previous steps.`, + stepId: step.id, + }); + } + + return issues; + } + + private validateTriggerTypeRequirements( + trigger: WorkflowTrigger | null, + ): WorkflowValidationIssue[] { + if (trigger?.type !== WorkflowTriggerType.WEBHOOK) { + return []; + } + + if (this.hasOutputSchema(trigger.settings)) { + return []; + } + + return [ + this.buildMissingOutputSchemaIssue({ + id: TRIGGER_STEP_ID, + name: trigger.name, + }), + ]; + } + + private validateRuntimeOutputStep( + step: WorkflowAction, + ): WorkflowValidationIssue[] { + if (this.hasStepLevelOutputSchema(step)) { + return []; + } + + return [ + this.buildMissingOutputSchemaIssue({ id: step.id, name: step.name }), + ]; + } + + // A CODE/LOGIC_FUNCTION step exposes an output schema either through a + // user-declared sample (expectedOutputSchema) or through a schema computed + // after a draft/test run (outputSchema). The LINK placeholder is not usable. + private hasStepLevelOutputSchema(step: WorkflowAction): boolean { + return this.hasOutputSchema(step.settings); + } + + private hasOutputSchema( + settings: + | { outputSchema?: unknown; expectedOutputSchema?: unknown } + | null + | undefined, + ): boolean { + const expectedOutputSchema = settings?.expectedOutputSchema; + + if ( + isObject(expectedOutputSchema) && + Object.keys(expectedOutputSchema).length > 0 + ) { + return true; + } + + const outputSchema = settings?.outputSchema; + + return ( + isObject(outputSchema) && + Object.keys(outputSchema).length > 0 && + !( + '_outputSchemaType' in outputSchema && + outputSchema._outputSchemaType === 'LINK' + ) + ); + } + + private buildMissingOutputSchemaIssue({ + id, + name, + }: { + id: string; + name?: string; + }): WorkflowValidationIssue { + return { + severity: 'error', + code: 'CODE_STEP_MISSING_OUTPUT_SCHEMA', + message: `Step "${name ?? id}" has no output schema. Declare an expected output schema.`, + stepId: id, + }; + } + + private async validateLogicFunctionStep({ + step, + workspaceId, + }: { + step: WorkflowLogicFunctionAction; + workspaceId: string; + }): Promise { + const issues: WorkflowValidationIssue[] = []; + + const declaredOutputSchema = + await this.getLogicFunctionDeclaredOutputSchema({ + step, + workspaceId, + }); + + issues.push( + ...this.validateLogicFunctionOutputSchemaMismatch({ + step, + declaredOutputSchema, + }), + ); + + if (this.hasStepLevelOutputSchema(step)) { + return issues; + } + + if (isDefined(declaredOutputSchema)) { + return issues; + } + + issues.push( + this.buildMissingOutputSchemaIssue({ id: step.id, name: step.name }), + ); + + return issues; + } + + private validateLogicFunctionOutputSchemaMismatch({ + step, + declaredOutputSchema, + }: { + step: WorkflowLogicFunctionAction; + declaredOutputSchema: BaseOutputSchemaV2 | undefined; + }): WorkflowValidationIssue[] { + const expectedOutputSchema = step.settings?.expectedOutputSchema; + + if ( + !isDefined(declaredOutputSchema) || + !isObject(expectedOutputSchema) || + Object.keys(expectedOutputSchema).length === 0 + ) { + return []; + } + + const mismatchIssues = getOutputSchemaMismatchIssues( + declaredOutputSchema, + getOutputSchemaFromValue(expectedOutputSchema), + ); + + return mismatchIssues.map((mismatchIssue) => ({ + severity: 'warning', + code: 'LOGIC_FUNCTION_OUTPUT_SCHEMA_MISMATCH', + message: `Step "${step.name ?? step.id}" expected output schema does not match the function's declared output schema: ${mismatchIssue}`, + stepId: step.id, + })); + } + + private async getLogicFunctionDeclaredOutputSchema({ + step, + workspaceId, + }: { + step: WorkflowAction; + workspaceId: string; + }): Promise { + const input = step.settings?.input; + const logicFunctionId = + isObject(input) && 'logicFunctionId' in input + ? input.logicFunctionId + : undefined; + + if (!isNonEmptyString(logicFunctionId)) { + return undefined; + } + + try { + const logicFunction = + await this.workflowCommonWorkspaceService.getLogicFunctionById({ + logicFunctionId, + workspaceId, + }); + + const declaredInputSchema = + logicFunction?.workflowActionTriggerSettings?.outputSchema; + + if (!isNonEmptyArray(declaredInputSchema)) { + return undefined; + } + + const declaredOutputSchema = + inputSchemaToOutputSchema(declaredInputSchema); + + if (Object.keys(declaredOutputSchema).length === 0) { + return undefined; + } + + return declaredOutputSchema; + } catch { + return undefined; + } + } + private validateAiAgentStep( step: WorkflowAiAgentAction, ): WorkflowValidationIssue[] { diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts index fd1767b4cb..a7bda9589f 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts @@ -205,6 +205,7 @@ export class WorkflowVersionStepOperationsWorkspaceService { }, _outputSchemaType: 'LINK', }, + expectedOutputSchema: {}, input: { logicFunctionId: newLogicFunction.id, logicFunctionInput: isDefined( @@ -272,6 +273,7 @@ export class WorkflowVersionStepOperationsWorkspaceService { settings: { ...BASE_STEP_DEFINITION, outputSchema: initialOutputSchema, + expectedOutputSchema: {}, input: { logicFunctionId, logicFunctionInput: isDefined( @@ -477,6 +479,7 @@ export class WorkflowVersionStepOperationsWorkspaceService { type: WorkflowActionType.HTTP_REQUEST, settings: { ...BASE_STEP_DEFINITION, + expectedOutputSchema: {}, input: { url: '', method: 'GET', diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/code/types/workflow-code-action-settings.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/code/types/workflow-code-action-settings.type.ts index 2e8d9def7e..295fab6e9d 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/code/types/workflow-code-action-settings.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/code/types/workflow-code-action-settings.type.ts @@ -1,6 +1,10 @@ import { type WorkflowCodeActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/code/types/workflow-code-action-input.type'; -import { type BaseWorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type'; +import { + type BaseWorkflowActionSettings, + type WithExpectedOutputSchema, +} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type'; -export type WorkflowCodeActionSettings = BaseWorkflowActionSettings & { - input: WorkflowCodeActionInput; -}; +export type WorkflowCodeActionSettings = BaseWorkflowActionSettings & + WithExpectedOutputSchema & { + input: WorkflowCodeActionInput; + }; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/types/workflow-http-request-action-settings.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/types/workflow-http-request-action-settings.type.ts index 3bdb9ff703..7c3e1f1f24 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/types/workflow-http-request-action-settings.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/types/workflow-http-request-action-settings.type.ts @@ -1,7 +1,11 @@ -import { type BaseWorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type'; +import { + type BaseWorkflowActionSettings, + type WithExpectedOutputSchema, +} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type'; import { type WorkflowHttpRequestActionInput } from './workflow-http-request-action-input.type'; -export type WorkflowHttpRequestActionSettings = BaseWorkflowActionSettings & { - input: WorkflowHttpRequestActionInput; -}; +export type WorkflowHttpRequestActionSettings = BaseWorkflowActionSettings & + WithExpectedOutputSchema & { + input: WorkflowHttpRequestActionInput; + }; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/logic-function/types/workflow-logic-function-action-settings.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/logic-function/types/workflow-logic-function-action-settings.type.ts index 1e055648dd..9754344488 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/logic-function/types/workflow-logic-function-action-settings.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/logic-function/types/workflow-logic-function-action-settings.type.ts @@ -1,6 +1,10 @@ import { type WorkflowLogicFunctionActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/logic-function/types/workflow-logic-function-action-input.type'; -import { type BaseWorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type'; +import { + type BaseWorkflowActionSettings, + type WithExpectedOutputSchema, +} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type'; -export type WorkflowLogicFunctionActionSettings = BaseWorkflowActionSettings & { - input: WorkflowLogicFunctionActionInput; -}; +export type WorkflowLogicFunctionActionSettings = BaseWorkflowActionSettings & + WithExpectedOutputSchema & { + input: WorkflowLogicFunctionActionInput; + }; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type.ts index cab545570a..a4593845a5 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type.ts @@ -29,6 +29,10 @@ export type BaseWorkflowActionSettings = { }; }; +export type WithExpectedOutputSchema = { + expectedOutputSchema?: object; +}; + export type WorkflowActionSettings = | WorkflowLogicFunctionActionSettings | WorkflowSendEmailActionSettings diff --git a/packages/twenty-server/src/modules/workflow/workflow-trigger/types/workflow-trigger.type.ts b/packages/twenty-server/src/modules/workflow/workflow-trigger/types/workflow-trigger.type.ts index ed995b044d..44e84b7bf4 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-trigger/types/workflow-trigger.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-trigger/types/workflow-trigger.type.ts @@ -83,6 +83,7 @@ export type WorkflowWebhookTrigger = BaseTrigger & { httpMethod: 'POST'; authentication: 'API_KEY' | null; expectedBody: object; + expectedOutputSchema?: object; } ); }; diff --git a/packages/twenty-shared/src/logic-function/__tests__/get-output-schema-mismatch-issues.test.ts b/packages/twenty-shared/src/logic-function/__tests__/get-output-schema-mismatch-issues.test.ts new file mode 100644 index 0000000000..adc3c77295 --- /dev/null +++ b/packages/twenty-shared/src/logic-function/__tests__/get-output-schema-mismatch-issues.test.ts @@ -0,0 +1,108 @@ +import { getOutputSchemaMismatchIssues } from '@/logic-function/get-output-schema-mismatch-issues'; +import { + type BaseOutputSchemaV2, + type Leaf, + type LeafType, + type Node, +} from '@/workflow/workflow-schema/types/base-output-schema.type'; + +const leaf = (type: LeafType, label = 'label'): Leaf => ({ + isLeaf: true, + type, + label, + value: null, +}); + +const node = (value: BaseOutputSchemaV2, label = 'label'): Node => ({ + isLeaf: false, + type: 'object', + label, + value, +}); + +describe('getOutputSchemaMismatchIssues', () => { + it('should return no issues when declared schema matches the expected one', () => { + const declared: BaseOutputSchemaV2 = { + name: leaf('string'), + age: leaf('number'), + }; + const expected: BaseOutputSchemaV2 = { + name: leaf('string'), + age: leaf('number'), + }; + + expect(getOutputSchemaMismatchIssues(declared, expected)).toEqual([]); + }); + + it('should ignore keys present only in the declared schema', () => { + const declared: BaseOutputSchemaV2 = { + name: leaf('string'), + extra: leaf('string'), + }; + const expected: BaseOutputSchemaV2 = { + name: leaf('string'), + }; + + expect(getOutputSchemaMismatchIssues(declared, expected)).toEqual([]); + }); + + it('should report keys missing from the declared schema', () => { + const declared: BaseOutputSchemaV2 = { + name: leaf('string'), + }; + const expected: BaseOutputSchemaV2 = { + name: leaf('string'), + age: leaf('number'), + }; + + expect(getOutputSchemaMismatchIssues(declared, expected)).toEqual([ + 'Missing key "age" in declared output schema.', + ]); + }); + + it('should report leaf type mismatches', () => { + const declared: BaseOutputSchemaV2 = { age: leaf('string') }; + const expected: BaseOutputSchemaV2 = { age: leaf('number') }; + + expect(getOutputSchemaMismatchIssues(declared, expected)).toEqual([ + 'Type mismatch at "age": expected number but declared string.', + ]); + }); + + it('should report leaf vs object mismatches', () => { + const declared: BaseOutputSchemaV2 = { user: leaf('string') }; + const expected: BaseOutputSchemaV2 = { + user: node({ name: leaf('string') }), + }; + + expect(getOutputSchemaMismatchIssues(declared, expected)).toEqual([ + 'Type mismatch at "user": expected object but declared string.', + ]); + }); + + it('should recurse into nested objects with dotted paths', () => { + const declared: BaseOutputSchemaV2 = { + user: node({ name: leaf('string'), age: leaf('string') }), + }; + const expected: BaseOutputSchemaV2 = { + user: node({ name: leaf('string'), age: leaf('number') }), + }; + + expect(getOutputSchemaMismatchIssues(declared, expected)).toEqual([ + 'Type mismatch at "user.age": expected number but declared string.', + ]); + }); + + it('should not flag mismatches when the expected leaf type is unknown', () => { + const declared: BaseOutputSchemaV2 = { maybe: leaf('string') }; + const expected: BaseOutputSchemaV2 = { maybe: leaf('unknown') }; + + expect(getOutputSchemaMismatchIssues(declared, expected)).toEqual([]); + }); + + it('should return no issues for an empty expected schema', () => { + expect(getOutputSchemaMismatchIssues({ a: leaf('string') }, {})).toEqual( + [], + ); + }); +}); diff --git a/packages/twenty-shared/src/logic-function/get-output-schema-mismatch-issues.ts b/packages/twenty-shared/src/logic-function/get-output-schema-mismatch-issues.ts new file mode 100644 index 0000000000..d660cd0445 --- /dev/null +++ b/packages/twenty-shared/src/logic-function/get-output-schema-mismatch-issues.ts @@ -0,0 +1,57 @@ +import { isDefined } from '@/utils'; +import { type BaseOutputSchemaV2 } from '@/workflow/workflow-schema/types/base-output-schema.type'; + +const buildPath = (parentPath: string, key: string): string => + parentPath ? `${parentPath}.${key}` : key; + +export const getOutputSchemaMismatchIssues = ( + declaredSchema: BaseOutputSchemaV2, + expectedSchema: BaseOutputSchemaV2, + parentPath = '', +): string[] => { + const issues: string[] = []; + + for (const [key, expectedField] of Object.entries(expectedSchema)) { + const path = buildPath(parentPath, key); + const declaredField = declaredSchema[key]; + + if (!isDefined(declaredField)) { + issues.push(`Missing key "${path}" in declared output schema.`); + continue; + } + + if (expectedField.isLeaf !== declaredField.isLeaf) { + issues.push( + `Type mismatch at "${path}": expected ${ + expectedField.isLeaf ? expectedField.type : 'object' + } but declared ${declaredField.isLeaf ? declaredField.type : 'object'}.`, + ); + continue; + } + + if (!expectedField.isLeaf && !declaredField.isLeaf) { + issues.push( + ...getOutputSchemaMismatchIssues( + declaredField.value, + expectedField.value, + path, + ), + ); + continue; + } + + if ( + expectedField.isLeaf && + declaredField.isLeaf && + expectedField.type !== 'unknown' && + declaredField.type !== 'unknown' && + expectedField.type !== declaredField.type + ) { + issues.push( + `Type mismatch at "${path}": expected ${expectedField.type} but declared ${declaredField.type}.`, + ); + } + } + + return issues; +}; diff --git a/packages/twenty-shared/src/logic-function/index.ts b/packages/twenty-shared/src/logic-function/index.ts index 9ec2fee05d..96850578cd 100644 --- a/packages/twenty-shared/src/logic-function/index.ts +++ b/packages/twenty-shared/src/logic-function/index.ts @@ -11,6 +11,7 @@ export { DEFAULT_TOOL_INPUT_SCHEMA } from './constants/DefaultToolInputSchema'; export { SEED_WORKFLOW_ACTION_TRIGGER_SETTINGS } from './constants/SeedWorkflowActionTriggerSettings'; export { getInputSchemaFromSourceCode } from './get-input-schema-from-source-code'; export { getOutputSchemaFromValue } from './get-output-schema-from-value'; +export { getOutputSchemaMismatchIssues } from './get-output-schema-mismatch-issues'; export type { InputJsonSchema } from './input-json-schema.type'; export { inputSchemaToOutputSchema } from './input-schema-to-output-schema'; export { jsonSchemaToInputSchema } from './json-schema-to-input-schema'; diff --git a/packages/twenty-shared/src/workflow/index.ts b/packages/twenty-shared/src/workflow/index.ts index 31119eae6d..22f1d18709 100644 --- a/packages/twenty-shared/src/workflow/index.ts +++ b/packages/twenty-shared/src/workflow/index.ts @@ -34,6 +34,7 @@ export { workflowDeleteRecordActionSettingsSchema } from './schemas/delete-recor export { workflowDraftEmailActionSchema } from './schemas/draft-email-action-schema'; export { workflowEmptyActionSchema } from './schemas/empty-action-schema'; export { workflowEmptyActionSettingsSchema } from './schemas/empty-action-settings-schema'; +export { expectedOutputSchemaShape } from './schemas/expected-output-schema-shape'; export { workflowFilterActionSchema } from './schemas/filter-action-schema'; export { workflowFilterActionSettingsSchema } from './schemas/filter-action-settings-schema'; export { workflowFindRecordsActionSchema } from './schemas/find-records-action-schema'; diff --git a/packages/twenty-shared/src/workflow/schemas/code-action-settings-schema.ts b/packages/twenty-shared/src/workflow/schemas/code-action-settings-schema.ts index ffe00c5126..8281254e12 100644 --- a/packages/twenty-shared/src/workflow/schemas/code-action-settings-schema.ts +++ b/packages/twenty-shared/src/workflow/schemas/code-action-settings-schema.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { baseWorkflowActionSettingsSchema } from './base-workflow-action-settings-schema'; +import { expectedOutputSchemaShape } from './expected-output-schema-shape'; export const workflowCodeActionSettingsSchema = baseWorkflowActionSettingsSchema.extend({ @@ -15,4 +16,5 @@ export const workflowCodeActionSettingsSchema = 'Key-value map of input parameters to pass to the logic function at runtime.', ), }), + ...expectedOutputSchemaShape, }); diff --git a/packages/twenty-shared/src/workflow/schemas/expected-output-schema-shape.ts b/packages/twenty-shared/src/workflow/schemas/expected-output-schema-shape.ts new file mode 100644 index 0000000000..b3a74223fd --- /dev/null +++ b/packages/twenty-shared/src/workflow/schemas/expected-output-schema-shape.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const expectedOutputSchemaShape = { + expectedOutputSchema: z + .looseObject({}) + .optional() + .describe( + 'A sample output value declared by the user for steps whose output structure is only known at runtime.', + ), +}; diff --git a/packages/twenty-shared/src/workflow/schemas/http-request-action-settings-schema.ts b/packages/twenty-shared/src/workflow/schemas/http-request-action-settings-schema.ts index 5ad5cd9100..43d61b8ce4 100644 --- a/packages/twenty-shared/src/workflow/schemas/http-request-action-settings-schema.ts +++ b/packages/twenty-shared/src/workflow/schemas/http-request-action-settings-schema.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { baseWorkflowActionSettingsSchema } from './base-workflow-action-settings-schema'; +import { expectedOutputSchemaShape } from './expected-output-schema-shape'; export const workflowHttpRequestActionSettingsSchema = baseWorkflowActionSettingsSchema.extend({ @@ -21,4 +22,5 @@ export const workflowHttpRequestActionSettingsSchema = .or(z.string()) .optional(), }), + ...expectedOutputSchemaShape, }); diff --git a/packages/twenty-shared/src/workflow/schemas/logic-function-action-settings-schema.ts b/packages/twenty-shared/src/workflow/schemas/logic-function-action-settings-schema.ts index 0b133b1279..0bb8084448 100644 --- a/packages/twenty-shared/src/workflow/schemas/logic-function-action-settings-schema.ts +++ b/packages/twenty-shared/src/workflow/schemas/logic-function-action-settings-schema.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { baseWorkflowActionSettingsSchema } from './base-workflow-action-settings-schema'; +import { expectedOutputSchemaShape } from './expected-output-schema-shape'; export const workflowLogicFunctionActionSettingsSchema = baseWorkflowActionSettingsSchema.extend({ @@ -7,4 +8,5 @@ export const workflowLogicFunctionActionSettingsSchema = logicFunctionId: z.string(), logicFunctionInput: z.record(z.string(), z.any()), }), + ...expectedOutputSchemaShape, }); diff --git a/packages/twenty-shared/src/workflow/schemas/webhook-trigger-schema.ts b/packages/twenty-shared/src/workflow/schemas/webhook-trigger-schema.ts index 70496b5333..4042b2dc2e 100644 --- a/packages/twenty-shared/src/workflow/schemas/webhook-trigger-schema.ts +++ b/packages/twenty-shared/src/workflow/schemas/webhook-trigger-schema.ts @@ -11,6 +11,7 @@ export const workflowWebhookTriggerSchema = baseTriggerSchema.extend({ }), z.object({ outputSchema: z.looseObject({}), + expectedOutputSchema: z.looseObject({}).optional(), httpMethod: z.literal('POST'), expectedBody: z.looseObject({}), authentication: z.literal('API_KEY').nullable(), diff --git a/packages/twenty-shared/src/workflow/validation/types/workflow-validation.type.ts b/packages/twenty-shared/src/workflow/validation/types/workflow-validation.type.ts index 4b62ed81a8..e35f8daa6f 100644 --- a/packages/twenty-shared/src/workflow/validation/types/workflow-validation.type.ts +++ b/packages/twenty-shared/src/workflow/validation/types/workflow-validation.type.ts @@ -24,12 +24,15 @@ export type WorkflowValidationIssueCode = | 'IF_ELSE_INSUFFICIENT_BRANCHES' | 'IF_ELSE_BRANCH_HAS_NO_NEXT_STEP' | 'ITERATOR_MISSING_LOOP_BODY' + | 'ITERATOR_ITEMS_NOT_ARRAY' | 'VARIABLE_INVALID_PATH' | 'VARIABLE_UNKNOWN_STEP' | 'VARIABLE_NOT_UPSTREAM' | 'VARIABLE_MISSING_OUTPUT_SCHEMA' | 'VARIABLE_PATH_NOT_FOUND' | 'CODE_STEP_MISSING_OUTPUT_SCHEMA' + | 'STEP_HAS_NO_VARIABLE_REFERENCE' + | 'LOGIC_FUNCTION_OUTPUT_SCHEMA_MISMATCH' | 'AI_AGENT_MISSING_AGENT' | 'AI_AGENT_MISSING_OUTPUT_VARIABLE'; diff --git a/packages/twenty-shared/src/workflow/validation/utils/validate-workflow-graph.util.ts b/packages/twenty-shared/src/workflow/validation/utils/validate-workflow-graph.util.ts index b0782850fd..29b411e326 100644 --- a/packages/twenty-shared/src/workflow/validation/utils/validate-workflow-graph.util.ts +++ b/packages/twenty-shared/src/workflow/validation/utils/validate-workflow-graph.util.ts @@ -99,9 +99,9 @@ const validateBranchingStep = ( if (branches.length < 2) { issues.push({ - severity: 'error', + severity: 'warning', code: 'IF_ELSE_INSUFFICIENT_BRANCHES', - message: `If/Else step "${step.name ?? step.id}" must have at least two branches (a condition branch and an else branch).`, + message: `If/Else step "${step.name ?? step.id}" should have at least two branches (a condition branch and an else branch).`, stepId: step.id, }); }