From 39e00d5853a76f2dff47fcc85c2008d2a60f6fa7 Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:31:01 +0200 Subject: [PATCH] feat(workflow): expected output schema for runtime-output steps + validation (#21744) ## Summary Extends the workflow validation layer (introduced in #21422) and adds a new "expected output schema" capability for steps whose output structure is only known at runtime. Some workflow steps (HTTP Request, Code, Logic Function, AI Agent (coming soon), Webhook trigger) don't have a statically known output shape, so downstream steps can't resolve `{{step.x.y}}` variable paths or validate them. This PR lets users declare a **sample/expected output** for those steps, derives an output schema from it, and uses that schema both to power variable resolution and to surface validation issues at build time. ## What's included ### Expected output schema (shared schemas + types) - New `expectedOutputSchemaShape` reused across the HTTP request, code, logic function and AI agent action settings schemas, plus the webhook trigger schema (`expectedOutputSchema` optional loose object). - Mirrored on the server-side action/trigger settings types. ### Output schema computation (server) - `workflow-schema.workspace-service` now computes a step's output schema from the user-declared `expectedOutputSchema` sample (via `getOutputSchemaFromValue`) when no statically computed schema is available. ### Validation layer (server) - `STEP_HAS_NO_VARIABLE_REFERENCE` (warning): flags steps of `VARIABLE_CONSUMING_ACTION_TYPES` (HTTP_REQUEST, CODE, LOGIC_FUNCTION, SEND_EMAIL, record CRUD) that reference no upstream variable. - `LOGIC_FUNCTION_OUTPUT_SCHEMA_MISMATCH` / `AI_AGENT_OUTPUT_SCHEMA_MISMATCH` (warnings): compare the declared output schema against the expected sample using the new shared `getOutputSchemaMismatchIssues` util (missing keys, leaf/object mismatches, type mismatches). - Trigger is now validated alongside steps (trigger type requirements + trigger variable references). - Validation issues no longer return both `suggestions` and `availablePaths` when they are identical (avoids redundant, costly payloads). ### Shared utilities - New `getOutputSchemaMismatchIssues` (+ tests) in `twenty-shared/logic-function`. - Moved `agentResponseSchemaToOutputSchema` from `twenty-front` into `twenty-shared/ai` so it can be reused on both sides. ### Frontend - New `WorkflowExpectedOutputBodyInput` component (JSON sample editor with validation) used by HTTP request, code, logic function and AI agent step editors. - New `resolvePersistedStepOutputSchema` util + `useStepsOutputSchema` update: resolves a step's output schema from `outputSchema`, falling back to `expectedOutputSchema`, with an AI_AGENT default. - HTTP request / code / logic function editors persist `expectedOutputSchema` and derive `outputSchema` from it. - Webhook trigger default settings include `expectedOutputSchema`. BONUS : iterator loop validation Review in cubic --- .../WorkflowExpectedOutputBodyInput.tsx | 60 +++ .../components/WorkflowEditActionCode.tsx | 22 + .../hooks/useHttpRequestOutputSchema.ts | 41 +- .../WorkflowEditActionLogicFunction.tsx | 21 + .../WorkflowEditTriggerWebhookForm.tsx | 1 + .../getWebhookTriggerDefaultSettings.test.ts | 3 + .../utils/getWebhookTriggerDefaultSettings.ts | 3 + .../hooks/useStepsOutputSchema.ts | 28 +- .../utils/resolvePersistedStepOutputSchema.ts | 45 ++ .../workflow-common.workspace-service.ts | 28 +- .../workflow-schema.workspace-service.ts | 125 +++++- ...kflow-validation.workspace-service.spec.ts | 420 +++++++++++++++++- .../workflow-validation.workspace-service.ts | 369 ++++++++++++++- ...rsion-step-operations.workspace-service.ts | 3 + .../workflow-code-action-settings.type.ts | 12 +- ...kflow-http-request-action-settings.type.ts | 12 +- ...low-logic-function-action-settings.type.ts | 12 +- .../types/workflow-action-settings.type.ts | 4 + .../types/workflow-trigger.type.ts | 1 + .../get-output-schema-mismatch-issues.test.ts | 108 +++++ .../get-output-schema-mismatch-issues.ts | 57 +++ .../twenty-shared/src/logic-function/index.ts | 1 + packages/twenty-shared/src/workflow/index.ts | 1 + .../schemas/code-action-settings-schema.ts | 2 + .../schemas/expected-output-schema-shape.ts | 10 + .../http-request-action-settings-schema.ts | 2 + .../logic-function-action-settings-schema.ts | 2 + .../schemas/webhook-trigger-schema.ts | 1 + .../types/workflow-validation.type.ts | 3 + .../utils/validate-workflow-graph.util.ts | 4 +- 30 files changed, 1338 insertions(+), 63 deletions(-) create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowExpectedOutputBodyInput.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-variables/utils/resolvePersistedStepOutputSchema.ts create mode 100644 packages/twenty-shared/src/logic-function/__tests__/get-output-schema-mismatch-issues.test.ts create mode 100644 packages/twenty-shared/src/logic-function/get-output-schema-mismatch-issues.ts create mode 100644 packages/twenty-shared/src/workflow/schemas/expected-output-schema-shape.ts 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, }); }