diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder-plugins.test.ts b/packages/@n8n/workflow-sdk/src/workflow-builder-plugins.test.ts index ec50b195eb1..a69c47e5979 100644 --- a/packages/@n8n/workflow-sdk/src/workflow-builder-plugins.test.ts +++ b/packages/@n8n/workflow-sdk/src/workflow-builder-plugins.test.ts @@ -6,6 +6,7 @@ * composite handling, and serialization. */ +import { expr } from './expression'; import type { NodeInstance, WorkflowJSON, IfElseComposite, GraphNode } from './types/base'; import { workflow } from './workflow-builder'; import { splitInBatches } from './workflow-builder/control-flow-builders/split-in-batches'; @@ -51,6 +52,84 @@ describe('WorkflowBuilder plugin integration', () => { }); describe('validate() with plugins', () => { + it('blocks invalid Execute Workflow input mappings through the default registry', () => { + registerDefaultPlugins(testRegistry); + + const createWorkflow = (workflowInputs?: unknown) => { + const executeWorkflow = node({ + type: 'n8n-nodes-base.executeWorkflow', + version: 1.3, + config: { + name: 'Process Order', + parameters: { + source: 'database', + workflowId: { __rl: true, mode: 'id', value: 'process-order' }, + mode: 'each', + ...(workflowInputs === undefined ? {} : { workflowInputs }), + }, + }, + }); + + return workflow('orders', 'Orders', { registry: testRegistry }).add( + trigger({ + type: 'n8n-nodes-base.manualTrigger', + version: 1, + config: { name: 'Start' }, + }).to(executeWorkflow), + ); + }; + + const invalidResult = createWorkflow({ + mappingMode: 'defineBelow', + value: null, + }).validate(); + expect(invalidResult.valid).toBe(false); + expect(invalidResult.errors).toContainEqual( + expect.objectContaining({ code: 'EXECUTE_WORKFLOW_INVALID_INPUT_MAPPING' }), + ); + + const passThroughResult = createWorkflow().validate(); + expect(passThroughResult.valid).toBe(true); + expect(passThroughResult.errors).not.toContainEqual( + expect.objectContaining({ code: 'EXECUTE_WORKFLOW_INVALID_INPUT_MAPPING' }), + ); + + const mappedResult = createWorkflow({ + mappingMode: 'defineBelow', + value: { + orderId: expr('{{ $json.id }}'), + amount: expr('{{ $json.total }}'), + }, + matchingColumns: [], + schema: [ + { + id: 'orderId', + displayName: 'orderId', + required: false, + defaultMatch: false, + display: true, + canBeUsedToMatch: true, + type: 'string', + }, + { + id: 'amount', + displayName: 'amount', + required: false, + defaultMatch: false, + display: true, + canBeUsedToMatch: true, + type: 'number', + }, + ], + attemptToConvertTypes: false, + convertFieldsToString: true, + }).validate(); + expect(mappedResult.valid).toBe(true); + expect(mappedResult.errors).not.toContainEqual( + expect.objectContaining({ code: 'EXECUTE_WORKFLOW_INVALID_INPUT_MAPPING' }), + ); + }); + it('runs registered validators for matching node types', () => { const mockValidateNode = vi.fn().mockReturnValue([]); const mockValidator = createMockValidator( diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/defaults.test.ts b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/defaults.test.ts index e1d285245a8..ce7a1e09eb2 100644 --- a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/defaults.test.ts +++ b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/defaults.test.ts @@ -20,6 +20,7 @@ describe('Default Plugins', () => { expect(validatorIds).toContain('core:disconnected-node'); expect(validatorIds).toContain('core:agent'); expect(validatorIds).toContain('core:http-request'); + expect(validatorIds).toContain('core:execute-workflow'); expect(validatorIds).toContain('core:memory-session-key'); expect(validatorIds).toContain('core:unknown-config-keys'); }); diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/defaults.ts b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/defaults.ts index e0457da7307..25f2cd07bf1 100644 --- a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/defaults.ts +++ b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/defaults.ts @@ -16,6 +16,7 @@ import { disconnectedNodeValidator, expressionPathValidator, expressionPrefixValidator, + executeWorkflowValidator, filterNodeValidator, fromAiValidator, httpRequestValidator, @@ -60,6 +61,7 @@ const coreValidators: ValidatorPlugin[] = [ setNodeValidator, mergeNodeValidator, filterNodeValidator, + executeWorkflowValidator, // Expression validators (lower priority) expressionPrefixValidator, diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/execute-workflow-validator.test.ts b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/execute-workflow-validator.test.ts new file mode 100644 index 00000000000..cb2b3430828 --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/execute-workflow-validator.test.ts @@ -0,0 +1,220 @@ +import type { GraphNode, NodeInstance } from '../../../types/base'; +import type { PluginContext } from '../types'; +import { executeWorkflowValidator } from './execute-workflow-validator'; + +function createMockNode( + parameters: unknown, + version = '1.2', +): NodeInstance { + return { + type: 'n8n-nodes-base.executeWorkflow', + name: 'Run Sub-workflow', + version, + config: { parameters }, + } as NodeInstance; +} + +function validate(parameters: unknown, version = '1.2') { + const node = createMockNode(parameters, version); + const graphNode: GraphNode = { instance: node, connections: new Map() }; + const ctx: PluginContext = { + nodes: new Map([[node.name, graphNode]]), + workflowId: 'test-workflow', + workflowName: 'Test Workflow', + settings: {}, + }; + + return executeWorkflowValidator.validateNode(node, graphNode, ctx); +} + +describe('executeWorkflowValidator', () => { + describe('metadata', () => { + it('targets Execute Workflow nodes at priority 40', () => { + expect(executeWorkflowValidator).toEqual( + expect.objectContaining({ + id: 'core:execute-workflow', + name: 'Execute Workflow Validator', + nodeTypes: ['n8n-nodes-base.executeWorkflow'], + priority: 40, + }), + ); + }); + }); + + describe('defineBelow mappings', () => { + it.each([ + ['null', null], + ['an array', []], + ['a string', 'order'], + ['a number', 1], + ['a boolean', true], + ])('rejects value when it is %s', (_description, value) => { + const issues = validate({ + source: 'database', + workflowInputs: { mappingMode: 'defineBelow', value }, + }); + + expect(issues).toHaveLength(1); + expect(issues[0]).toEqual( + expect.objectContaining({ + code: 'EXECUTE_WORKFLOW_INVALID_INPUT_MAPPING', + severity: 'error', + violationLevel: 'major', + nodeName: 'Run Sub-workflow', + parameterPath: 'parameters.workflowInputs.value', + }), + ); + }); + + it.each([ + ['null', null], + ['an array', []], + ['a string', 'inputs'], + ])('rejects workflowInputs when it is %s', (_description, workflowInputs) => { + const issues = validate({ source: 'database', workflowInputs }); + + expect(issues).toHaveLength(1); + expect(issues[0]?.code).toBe('EXECUTE_WORKFLOW_INVALID_INPUT_MAPPING'); + }); + + it('allows workflowInputs to be omitted for sub-workflows that accept all data', () => { + const issues = validate({ source: 'database' }); + + expect(issues).toHaveLength(0); + }); + + it.each([ + ['workflowInputs.value', { mappingMode: 'defineBelow' }], + ['mappingMode and value', {}], + ])('allows workflowInputs with omitted %s', (_description, workflowInputs) => { + const issues = validate({ source: 'database', workflowInputs }); + + expect(issues).toHaveLength(0); + }); + + it('treats an omitted mappingMode as defineBelow', () => { + const issues = validate({ + source: 'database', + workflowInputs: { value: null }, + }); + + expect(issues).toHaveLength(1); + expect(issues[0]?.code).toBe('EXECUTE_WORKFLOW_INVALID_INPUT_MAPPING'); + }); + + it('includes the failing path and a complete valid mapping example', () => { + const issues = validate({ + source: 'database', + workflowInputs: { mappingMode: 'defineBelow', value: null }, + }); + + expect(issues[0]?.message).toContain('parameters.workflowInputs.value'); + expect(issues[0]?.message).toContain('accepts all data'); + expect(issues[0]?.message).toContain('omit parameters.workflowInputs'); + expect(issues[0]?.message).toContain("orderId: expr('{{ $json.id }}')"); + expect(issues[0]?.message).toContain("amount: expr('{{ $json.total }}')"); + expect(issues[0]?.message).toContain("id: 'orderId'"); + expect(issues[0]?.message).toContain("type: 'string'"); + expect(issues[0]?.message).toContain("id: 'amount'"); + expect(issues[0]?.message).toContain("type: 'number'"); + expect(issues[0]?.message).toContain('matchingColumns: []'); + }); + + it('allows a populated value object', () => { + const issues = validate({ + source: 'database', + workflowInputs: { + mappingMode: 'defineBelow', + value: { + orderId: '={{ $json.id }}', + amount: '={{ $json.total }}', + }, + matchingColumns: [], + schema: [ + { + id: 'orderId', + displayName: 'orderId', + required: false, + defaultMatch: false, + display: true, + canBeUsedToMatch: true, + type: 'string', + }, + { + id: 'amount', + displayName: 'amount', + required: false, + defaultMatch: false, + display: true, + canBeUsedToMatch: true, + type: 'number', + }, + ], + attemptToConvertTypes: false, + convertFieldsToString: true, + }, + }); + + expect(issues).toHaveLength(0); + }); + + it('allows an empty value object for sub-workflows with no declared inputs', () => { + const issues = validate({ + source: 'database', + workflowInputs: { mappingMode: 'defineBelow', value: {} }, + }); + + expect(issues).toHaveLength(0); + }); + + it('allows an omitted mappingMode when value is an object', () => { + const issues = validate({ + source: 'database', + workflowInputs: { value: { order: '={{ $json }}' } }, + }); + + expect(issues).toHaveLength(0); + }); + }); + + describe('applicability', () => { + it('treats an omitted source as database', () => { + const issues = validate({ + workflowInputs: { mappingMode: 'defineBelow', value: null }, + }); + + expect(issues).toHaveLength(1); + expect(issues[0]?.code).toBe('EXECUTE_WORKFLOW_INVALID_INPUT_MAPPING'); + }); + + it.each(['1', '1.1'])('skips legacy node version %s', (version) => { + const issues = validate( + { + source: 'database', + workflowInputs: { mappingMode: 'defineBelow', value: null }, + }, + version, + ); + + expect(issues).toHaveLength(0); + }); + + it('skips non-database sources', () => { + const issues = validate({ + source: 'parameter', + workflowInputs: { mappingMode: 'defineBelow', value: null }, + }); + + expect(issues).toHaveLength(0); + }); + + it('skips mapping modes other than defineBelow', () => { + const issues = validate({ + source: 'database', + workflowInputs: { mappingMode: 'autoMapInputData', value: null }, + }); + + expect(issues).toHaveLength(0); + }); + }); +}); diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/execute-workflow-validator.ts b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/execute-workflow-validator.ts new file mode 100644 index 00000000000..6fe5b6aac86 --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/execute-workflow-validator.ts @@ -0,0 +1,83 @@ +/** + * Execute Workflow Node Validator Plugin + * + * Validates that database-backed Execute Workflow nodes provide executable + * input mappings instead of persisting the Resource Mapper's temporary null value. + */ + +import { isRecord } from '@n8n/utils/is-record'; + +import type { GraphNode, NodeInstance } from '../../../types/base'; +import { parseVersion } from '../../string-utils'; +import { + type PluginContext, + type ValidationIssue, + type ValidatorPlugin, + findMapKey, + formatNodeRef, + isAutoRenamed, +} from '../types'; + +const MIN_WORKFLOW_INPUTS_VERSION = 1.2; +const DATABASE_SOURCE = 'database'; +const DEFINE_BELOW_MODE = 'defineBelow'; +const WORKFLOW_INPUTS_VALUE_PATH = 'parameters.workflowInputs.value'; +const VALID_MAPPING_EXAMPLE = + "{ mappingMode: 'defineBelow', value: { orderId: expr('{{ $json.id }}'), amount: expr('{{ $json.total }}') }, matchingColumns: [], schema: [{ id: 'orderId', displayName: 'orderId', required: false, defaultMatch: false, display: true, canBeUsedToMatch: true, type: 'string' }, { id: 'amount', displayName: 'amount', required: false, defaultMatch: false, display: true, canBeUsedToMatch: true, type: 'number' }], attemptToConvertTypes: false, convertFieldsToString: true }"; + +export const executeWorkflowValidator: ValidatorPlugin = { + id: 'core:execute-workflow', + name: 'Execute Workflow Validator', + nodeTypes: ['n8n-nodes-base.executeWorkflow'], + priority: 40, + + validateNode( + node: NodeInstance, + graphNode: GraphNode, + ctx: PluginContext, + ): ValidationIssue[] { + if (parseVersion(node.version) < MIN_WORKFLOW_INPUTS_VERSION) { + return []; + } + + const parameters = isRecord(node.config?.parameters) ? node.config.parameters : {}; + const source = parameters.source ?? DATABASE_SOURCE; + + if (source !== DATABASE_SOURCE) { + return []; + } + + const workflowInputs = parameters.workflowInputs; + if (workflowInputs === undefined) { + return []; + } + + if (isRecord(workflowInputs)) { + const mappingMode = workflowInputs.mappingMode ?? DEFINE_BELOW_MODE; + const value = workflowInputs.value; + if (mappingMode !== DEFINE_BELOW_MODE || value === undefined || isRecord(value)) return []; + } + + const mapKey = findMapKey(graphNode, ctx); + const originalName = node.name; + const renamed = isAutoRenamed(mapKey, originalName); + const displayName = renamed ? mapKey : originalName; + const origForWarning = renamed ? originalName : undefined; + const nodeRef = formatNodeRef(displayName, origForWarning, node.type); + + return [ + { + code: 'EXECUTE_WORKFLOW_INVALID_INPUT_MAPPING', + message: + `${nodeRef} has an invalid ${WORKFLOW_INPUTS_VALUE_PATH}. ` + + `When parameters.workflowInputs.mappingMode is '${DEFINE_BELOW_MODE}', an explicitly provided ${WORKFLOW_INPUTS_VALUE_PATH} must be an object. ` + + `If the selected sub-workflow accepts all data, omit parameters.workflowInputs. Otherwise set parameters.workflowInputs to a full Resource Mapper mapping whose value and schema match the declared inputs, such as ${VALID_MAPPING_EXAMPLE}.`, + severity: 'error', + violationLevel: 'major', + nodeName: displayName, + parameterPath: WORKFLOW_INPUTS_VALUE_PATH, + originalName: origForWarning, + }, + ]; + }, +}; diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/index.ts b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/index.ts index 1d1917c4a1c..9f4d52345b1 100644 --- a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/index.ts +++ b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/index.ts @@ -11,6 +11,7 @@ export { disconnectedNodeValidator } from './disconnected-node-validator'; export { expressionPathValidator } from './expression-path-validator'; export { subnodeConnectionValidator } from './subnode-connection-validator'; export { expressionPrefixValidator } from './expression-prefix-validator'; +export { executeWorkflowValidator } from './execute-workflow-validator'; export { filterNodeValidator } from './filter-node-validator'; export { fromAiValidator } from './from-ai-validator'; export { httpRequestValidator } from './http-request-validator'; diff --git a/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.test.ts b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.test.ts index 2a6b01f9d80..ef47c94a53b 100644 --- a/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.test.ts +++ b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.test.ts @@ -29,6 +29,32 @@ describe('ExecuteWorkflow', () => { } as unknown as IWorkflowDataProxyData); }); + test('should document pass-through and declared workflow input mappings for the builder', () => { + const workflowInputs = executeWorkflow.description.properties.find( + (property) => property.name === 'workflowInputs', + ); + const propertyHint = workflowInputs?.builderHint?.propertyHint; + const mappingPatterns = executeWorkflow.description.builderHint?.extraTypeDefContent + ?.map(({ content }) => content) + .join('\n'); + + expect(propertyHint).toContain('temporary UI initialization state'); + expect(propertyHint).toContain('must never be emitted'); + expect(propertyHint).toContain("trigger is set to 'Accept all data'"); + expect(propertyHint).toContain('Omit workflowInputs'); + expect(propertyHint).toContain('value and schema fields exactly match'); + expect(mappingPatterns).toContain('Omit workflowInputs from parameters'); + expect(mappingPatterns).toContain("orderId: expr('{{ $json.id }}')"); + expect(mappingPatterns).toContain("amount: expr('{{ $json.total }}')"); + expect(mappingPatterns).toContain("id: 'orderId'"); + expect(mappingPatterns).toContain("type: 'string'"); + expect(mappingPatterns).toContain("id: 'amount'"); + expect(mappingPatterns).toContain("type: 'number'"); + expect(mappingPatterns).toContain('matchingColumns: []'); + expect(mappingPatterns).toContain('attemptToConvertTypes: false'); + expect(mappingPatterns).toContain('convertFieldsToString: true'); + }); + test('should execute workflow in "each" mode and wait for sub-workflow completion', async () => { executeFunctions.getNodeParameter .mockReturnValueOnce('database') // source diff --git a/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.ts b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.ts index 3a7ef7ea1cd..36d32735473 100644 --- a/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.ts +++ b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.ts @@ -28,6 +28,51 @@ export class ExecuteWorkflow implements INodeType { }, inputs: [NodeConnectionTypes.Main], outputs: [NodeConnectionTypes.Main], + builderHint: { + extraTypeDefContent: [ + { + displayOptions: { show: { mode: ['once', 'each'] } }, + content: ` +These workflowInputs patterns apply to Execute Workflow node versions 1.2 and newer. + +Omit workflowInputs from parameters. + + +workflowInputs: { + mappingMode: 'defineBelow', + value: { + orderId: expr('{{ $json.id }}'), + amount: expr('{{ $json.total }}'), + }, + matchingColumns: [], + schema: [ + { + id: 'orderId', + displayName: 'orderId', + required: false, + defaultMatch: false, + display: true, + canBeUsedToMatch: true, + type: 'string', + }, + { + id: 'amount', + displayName: 'amount', + required: false, + defaultMatch: false, + display: true, + canBeUsedToMatch: true, + type: 'number', + }, + ], + attemptToConvertTypes: false, + convertFieldsToString: true, +} + +`, + }, + ], + }, properties: [ { displayName: 'Operation', @@ -218,6 +263,10 @@ export class ExecuteWorkflow implements INodeType { value: null, }, required: true, + builderHint: { + propertyHint: + "The default { mappingMode: 'defineBelow', value: null } is only a temporary UI initialization state and must never be emitted in a workflow. Omit workflowInputs when the selected sub-workflow's trigger is set to 'Accept all data'. When the trigger declares inputs, pass the full Resource Mapper object and make the value and schema fields exactly match the declared input names and types.", + }, typeOptions: { loadOptionsDependsOn: ['workflowId.value'], resourceMapper: {