From 08e7e4819b0edaa473d337b9aa4248f8130968a2 Mon Sep 17 00:00:00 2001 From: Abdul Rahman <81605929+abdulrahmancodes@users.noreply.github.com> Date: Tue, 19 May 2026 20:11:00 +0530 Subject: [PATCH] use declared outputSchema for logic-function steps (#20679) When a logic function declares `workflowActionTriggerSettings.outputSchema`, use it as the step's initial output schema so downstream steps can pick variables without first running the Test tab. A successful test run still overrides the schema with the inferred shape, preserving "test wins" behavior. Falls back to the existing "Generate Function Output" LINK placeholder when no schema is declared (custom code steps, older functions). https://github.com/user-attachments/assets/af9c45ed-d623-4234-be9f-46812fd06e2e --- ...rsion-step-operations.workspace-service.ts | 29 ++-- .../input-schema-to-output-schema.test.ts | 139 ++++++++++++++++++ .../twenty-shared/src/logic-function/index.ts | 1 + .../input-schema-to-output-schema.ts | 73 +++++++++ 4 files changed, 233 insertions(+), 9 deletions(-) create mode 100644 packages/twenty-shared/src/logic-function/__tests__/input-schema-to-output-schema.test.ts create mode 100644 packages/twenty-shared/src/logic-function/input-schema-to-output-schema.ts 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 e318eec74c..6c8b76486c 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 @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { inputSchemaToOutputSchema } from 'twenty-shared/logic-function'; import { FieldMetadataType, StepLogicalOperator, @@ -33,6 +34,7 @@ import { } from 'src/modules/workflow/common/exceptions/workflow-version-step.exception'; import { type WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity'; import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; +import { type OutputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type'; import { CodeStepBuildService } from 'src/modules/workflow/workflow-builder/workflow-version-step/code-step/services/code-step-build.service'; import { type BaseWorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type'; import { @@ -220,6 +222,23 @@ export class WorkflowVersionStepOperationsWorkspaceService { flatLogicFunctionMaps, }); + const declaredOutputSchema = + flatLogicFunction.workflowActionTriggerSettings?.outputSchema; + + const initialOutputSchema: OutputSchema = isDefined( + declaredOutputSchema, + ) + ? inputSchemaToOutputSchema(declaredOutputSchema) + : { + link: { + isLeaf: true, + icon: 'IconVariable', + tab: 'test', + label: 'Generate Function Output', + }, + _outputSchemaType: 'LINK', + }; + return { builtStep: { ...baseStep, @@ -229,15 +248,7 @@ export class WorkflowVersionStepOperationsWorkspaceService { type: WorkflowActionType.LOGIC_FUNCTION, settings: { ...BASE_STEP_DEFINITION, - outputSchema: { - link: { - isLeaf: true, - icon: 'IconVariable', - tab: 'test', - label: 'Generate Function Output', - }, - _outputSchemaType: 'LINK', - }, + outputSchema: initialOutputSchema, input: { logicFunctionId, logicFunctionInput: isDefined( diff --git a/packages/twenty-shared/src/logic-function/__tests__/input-schema-to-output-schema.test.ts b/packages/twenty-shared/src/logic-function/__tests__/input-schema-to-output-schema.test.ts new file mode 100644 index 0000000000..1a71a3489f --- /dev/null +++ b/packages/twenty-shared/src/logic-function/__tests__/input-schema-to-output-schema.test.ts @@ -0,0 +1,139 @@ +import { inputSchemaToOutputSchema } from '@/logic-function/input-schema-to-output-schema'; + +describe('inputSchemaToOutputSchema', () => { + it('should convert a flat object schema to a BaseOutputSchemaV2', () => { + const inputSchema = [ + { + type: 'object' as const, + properties: { + success: { type: 'boolean' as const }, + message: { type: 'string' as const }, + error: { type: 'string' as const }, + messageId: { type: 'string' as const }, + channelId: { type: 'string' as const }, + }, + }, + ]; + + expect(inputSchemaToOutputSchema(inputSchema)).toEqual({ + success: { isLeaf: true, type: 'boolean', label: 'success', value: null }, + message: { isLeaf: true, type: 'string', label: 'message', value: null }, + error: { isLeaf: true, type: 'string', label: 'error', value: null }, + messageId: { + isLeaf: true, + type: 'string', + label: 'messageId', + value: null, + }, + channelId: { + isLeaf: true, + type: 'string', + label: 'channelId', + value: null, + }, + }); + }); + + it('should convert nested object properties into Node entries', () => { + const inputSchema = [ + { + type: 'object' as const, + properties: { + user: { + type: 'object' as const, + properties: { + name: { type: 'string' as const }, + age: { type: 'number' as const }, + }, + }, + }, + }, + ]; + + expect(inputSchemaToOutputSchema(inputSchema)).toEqual({ + user: { + isLeaf: false, + type: 'object', + label: 'user', + value: { + name: { isLeaf: true, type: 'string', label: 'name', value: null }, + age: { isLeaf: true, type: 'number', label: 'age', value: null }, + }, + }, + }); + }); + + it('should treat arrays as leaves with type "array"', () => { + const inputSchema = [ + { + type: 'object' as const, + properties: { + tags: { + type: 'array' as const, + items: { type: 'string' as const }, + }, + }, + }, + ]; + + expect(inputSchemaToOutputSchema(inputSchema)).toEqual({ + tags: { isLeaf: true, type: 'array', label: 'tags', value: null }, + }); + }); + + it('should map non-leaf, non-object property types to "unknown"', () => { + const inputSchema = [ + { + type: 'object' as const, + properties: { + richText: { type: 'RICH_TEXT' as const as 'string' }, + }, + }, + ]; + + expect(inputSchemaToOutputSchema(inputSchema)).toEqual({ + richText: { + isLeaf: true, + type: 'unknown', + label: 'richText', + value: null, + }, + }); + }); + + it('should respect declared labels when present', () => { + const inputSchema = [ + { + type: 'object' as const, + properties: { + messageId: { type: 'string' as const, label: 'Message ID' }, + }, + }, + ]; + + expect(inputSchemaToOutputSchema(inputSchema)).toEqual({ + messageId: { + isLeaf: true, + type: 'string', + label: 'Message ID', + value: null, + }, + }); + }); + + it('should return an empty schema when the input is empty', () => { + expect(inputSchemaToOutputSchema([])).toEqual({}); + }); + + it('should return an empty schema when the root is not an object', () => { + expect(inputSchemaToOutputSchema([{ type: 'string' as const }])).toEqual( + {}, + ); + }); + + it('should return an empty schema when the root object has no properties', () => { + expect(inputSchemaToOutputSchema([{ type: 'object' as const }])).toEqual( + {}, + ); + }); +}); diff --git a/packages/twenty-shared/src/logic-function/index.ts b/packages/twenty-shared/src/logic-function/index.ts index 31713ac7c8..19455750d7 100644 --- a/packages/twenty-shared/src/logic-function/index.ts +++ b/packages/twenty-shared/src/logic-function/index.ts @@ -11,4 +11,5 @@ export { DEFAULT_TOOL_INPUT_SCHEMA } from './constants/DefaultToolInputSchema'; export { getInputSchemaFromSourceCode } from './get-input-schema-from-source-code'; export { getOutputSchemaFromValue } from './get-output-schema-from-value'; 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/logic-function/input-schema-to-output-schema.ts b/packages/twenty-shared/src/logic-function/input-schema-to-output-schema.ts new file mode 100644 index 0000000000..f350343016 --- /dev/null +++ b/packages/twenty-shared/src/logic-function/input-schema-to-output-schema.ts @@ -0,0 +1,73 @@ +import { + type InputSchema, + type InputSchemaProperty, +} from '@/workflow/types/InputSchema'; +import { + type BaseOutputSchemaV2, + type Leaf, + type LeafType, + type Node, +} from '@/workflow/workflow-schema/types/base-output-schema.type'; +import { isObject } from '@sniptt/guards'; + +const LEAF_TYPES: LeafType[] = [ + 'string', + 'number', + 'boolean', + 'array', + 'unknown', +]; + +const isLeafType = (type: string): type is LeafType => { + return (LEAF_TYPES as string[]).includes(type); +}; + +const convertProperty = ( + key: string, + property: InputSchemaProperty, +): Leaf | Node => { + const label = property.label ?? key; + + if (property.type === 'object') { + return { + isLeaf: false, + type: 'object', + label, + value: isObject(property.properties) + ? convertProperties(property.properties) + : {}, + }; + } + + return { + isLeaf: true, + type: isLeafType(property.type) ? property.type : 'unknown', + label, + value: null, + }; +}; + +const convertProperties = ( + properties: Record, +): BaseOutputSchemaV2 => { + return Object.entries(properties).reduce( + (acc, [key, value]) => { + acc[key] = convertProperty(key, value); + + return acc; + }, + {}, + ); +}; + +export const inputSchemaToOutputSchema = ( + inputSchema: InputSchema, +): BaseOutputSchemaV2 => { + const root = inputSchema[0]; + + if (root?.type !== 'object' || !isObject(root.properties)) { + return {}; + } + + return convertProperties(root.properties); +};