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
This commit is contained in:
+20
-9
@@ -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(
|
||||
|
||||
+139
@@ -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(
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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<string, InputSchemaProperty>,
|
||||
): BaseOutputSchemaV2 => {
|
||||
return Object.entries(properties).reduce<BaseOutputSchemaV2>(
|
||||
(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);
|
||||
};
|
||||
Reference in New Issue
Block a user