diff --git a/packages/@n8n/workflow-sdk/src/codegen/codegen-roundtrip.test.ts b/packages/@n8n/workflow-sdk/src/codegen/codegen-roundtrip.test.ts index abd6d9738ad..949219bd88c 100644 --- a/packages/@n8n/workflow-sdk/src/codegen/codegen-roundtrip.test.ts +++ b/packages/@n8n/workflow-sdk/src/codegen/codegen-roundtrip.test.ts @@ -12,6 +12,7 @@ import { } from '../__tests__/fixtures-download'; import type { WorkflowJSON } from '../types/base'; import { foldLegacyErrorConnections, normalizeConnections } from '../types/base'; +import { validateWorkflow } from '../validation'; import { escapeNewlinesInExpressionStrings, isPlaceholderValue, @@ -22,12 +23,18 @@ interface ExpectedWarning { nodeName?: string; } +interface ExpectedError { + code: string; + nodeName?: string; +} + interface TestWorkflow { id: string; name: string; json: WorkflowJSON; nodeCount: number; expectedWarnings?: ExpectedWarning[]; + expectedErrors?: ExpectedError[]; } function loadWorkflowsFromDir(dir: string, workflows: TestWorkflow[]): void { @@ -45,6 +52,7 @@ function loadWorkflowsFromDir(dir: string, workflows: TestWorkflow[]): void { skip?: boolean; skipReason?: string; expectedWarnings?: ExpectedWarning[]; + expectedErrors?: ExpectedError[]; }>; }; @@ -61,6 +69,7 @@ function loadWorkflowsFromDir(dir: string, workflows: TestWorkflow[]): void { json, nodeCount: json.nodes?.length ?? 0, expectedWarnings: entry.expectedWarnings, + expectedErrors: entry.expectedErrors, }); } } @@ -2620,3 +2629,87 @@ describe('Codegen Roundtrip with Real Workflows', () => { }); } }); + +describe('Committed workflows — schema validation errors', () => { + // Mirror the relevant builderHint.inputs declarations from the real node types + // so validateWorkflow can resolve required AI inputs without pulling in the + // full nodes-langchain dependency tree. + const mockNodeTypesProvider = { + getByNameAndVersion: (type: string, _version?: number) => { + if (type === '@n8n/n8n-nodes-langchain.chatTrigger') { + return { + description: { + inputs: ['main'], + builderHint: { + inputs: { + ai_memory: { + required: true, + displayOptions: { + show: { + mode: ['hostedChat', 'webhook'], + 'options.loadPreviousSession': ['memory'], + }, + }, + }, + }, + }, + }, + }; + } + if (type === '@n8n/n8n-nodes-langchain.agent') { + return { + description: { + inputs: ['main'], + builderHint: { + inputs: { + ai_languageModel: { required: true }, + ai_memory: { required: false }, + ai_tool: { required: false }, + }, + }, + }, + }; + } + return { description: { inputs: ['main'] } }; + }, + getByName: (type: string) => mockNodeTypesProvider.getByNameAndVersion(type), + getKnownTypes: () => ({}), + }; + + const normalizeError = (e: ExpectedError): string => `${e.code}:${e.nodeName ?? ''}`; + + const workflowsWithExpectedErrors = workflows.filter( + (w) => w.expectedErrors && w.expectedErrors.length > 0, + ); + + if (workflowsWithExpectedErrors.length === 0) { + it('has at least one fixture with expectedErrors declared', () => { + expect(workflowsWithExpectedErrors.length).toBeGreaterThan(0); + }); + } else { + workflowsWithExpectedErrors.forEach(({ id, name, json, expectedErrors }) => { + it(`emits expected validation errors for workflow ${id}: "${name}"`, () => { + const expectedCodes = new Set((expectedErrors ?? []).map((e) => e.code)); + + const result = validateWorkflow(json, { + nodeTypesProvider: mockNodeTypesProvider as never, + // Disconnected-node warnings are unrelated to the AI-input checks + // these fixtures are designed to exercise. + allowDisconnectedNodes: true, + }); + + const actualErrors: ExpectedError[] = result.errors + .filter((e) => expectedCodes.has(e.code)) + .map((e) => ({ code: e.code, nodeName: e.nodeName })) + .sort((a, b) => normalizeError(a).localeCompare(normalizeError(b))); + + const expected = (expectedErrors ?? []) + .slice() + .sort((a, b) => normalizeError(a).localeCompare(normalizeError(b))); + + expect(actualErrors).toEqual(expected); + expect(result.valid).toBe(false); + }); + }); + } +}); diff --git a/packages/@n8n/workflow-sdk/src/validation/index.ts b/packages/@n8n/workflow-sdk/src/validation/index.ts index 2220b15299f..dd088e28287 100644 --- a/packages/@n8n/workflow-sdk/src/validation/index.ts +++ b/packages/@n8n/workflow-sdk/src/validation/index.ts @@ -34,6 +34,7 @@ export type ValidationErrorCode = | 'SUBNODE_NOT_CONNECTED' | 'SUBNODE_PARAMETER_MISMATCH' | 'UNSUPPORTED_SUBNODE_INPUT' + | 'MISSING_REQUIRED_INPUT' | 'MAX_NODES_EXCEEDED' | 'INVALID_EXPRESSION_PATH' | 'PARTIAL_EXPRESSION_PATH' @@ -491,6 +492,8 @@ export function validateWorkflow( validateSubnodeParameters(json, options.nodeTypesProvider, warnings); // Validate parent nodes actually support their connected AI input types validateParentSupportsInputs(json, options.nodeTypesProvider, warnings); + // Validate required AI inputs on parent nodes are actually connected + validateRequiredInputsConnected(json, options.nodeTypesProvider, errors); } // Merge node input-count consistency @@ -785,6 +788,82 @@ function validateParentSupportsInputs( } } +/** + * Validate that required AI inputs declared in a parent node's builderHint.inputs + * are actually connected. + * + * For each parent node with a builderHint.inputs entry that has `required: true`, + * check whether its displayOptions (if any) match the parent's current parameters; + * if so, require that a connection of that AI type terminates at the parent. + * Emits a fatal error when the connection is missing — without it, the workflow + * silently passes validation but breaks at runtime (see INS-136: chat trigger + * with `loadPreviousSession: 'memory'` but no memory subnode connected). + */ +function validateRequiredInputsConnected( + json: WorkflowJSON, + nodeTypesProvider: INodeTypes, + errors: ValidationError[], +): void { + const connectionsByDest = mapConnectionsByDestination( + json.connections as unknown as N8nIConnections, + ); + + for (const parentNode of json.nodes) { + if (!parentNode.name) continue; + + const version = + typeof parentNode.typeVersion === 'string' + ? parseFloat(parentNode.typeVersion) + : (parentNode.typeVersion ?? 1); + + const parentNodeType = nodeTypesProvider.getByNameAndVersion(parentNode.type, version); + const builderHintInputs = parentNodeType?.description?.builderHint?.inputs; + if (!builderHintInputs) continue; + + const parentContext: DisplayOptionsContext = { + parameters: (parentNode.parameters ?? {}) as Record, + nodeVersion: version, + rootParameters: (parentNode.parameters ?? {}) as Record, + }; + + for (const [connectionType, inputConfig] of Object.entries(builderHintInputs)) { + if (!connectionType.startsWith('ai_')) continue; + if (!inputConfig?.required) continue; + + if (inputConfig.displayOptions) { + const conditionsMet = matchesDisplayOptions( + parentContext, + inputConfig.displayOptions as DisplayOptions, + ); + if (!conditionsMet) continue; + } + + const incoming = connectionsByDest[parentNode.name]?.[connectionType]; + const hasConnection = + Array.isArray(incoming) && incoming.some((slot) => Array.isArray(slot) && slot.length > 0); + if (hasConnection) continue; + + const subnodeField = AI_CONNECTION_TO_SUBNODE_FIELD[connectionType] || connectionType; + const conditionDetails = inputConfig.displayOptions + ? ` ${buildConditionSummary( + inputConfig.displayOptions, + (parentNode.parameters ?? {}) as Record, + )}` + : ''; + + errors.push( + new ValidationError( + 'MISSING_REQUIRED_INPUT', + `'${parentNode.name}' requires a ${subnodeField} subnode connected to its ${connectionType} input, but none is connected.${conditionDetails}`, + parentNode.name, + undefined, + 'major', + ), + ); + } + } +} + /** * Check if connections use valid input indices for their target nodes. * Reports warnings for connections to input indices that don't exist. diff --git a/packages/@n8n/workflow-sdk/src/validation/validation.test.ts b/packages/@n8n/workflow-sdk/src/validation/validation.test.ts index 1d6832fc3f2..01c72582d79 100644 --- a/packages/@n8n/workflow-sdk/src/validation/validation.test.ts +++ b/packages/@n8n/workflow-sdk/src/validation/validation.test.ts @@ -2220,6 +2220,273 @@ describe('Validation', () => { }); }); + describe('MISSING_REQUIRED_INPUT validation', () => { + const mockNodeTypesProvider = { + getByNameAndVersion: (type: string, _version?: number) => { + if (type === '@n8n/n8n-nodes-langchain.chatTrigger') { + return { + description: { + inputs: ['main'], + builderHint: { + inputs: { + ai_memory: { + required: true, + displayOptions: { + show: { + mode: ['hostedChat', 'webhook'], + 'options.loadPreviousSession': ['memory'], + }, + }, + }, + }, + }, + }, + }; + } + if (type === '@n8n/n8n-nodes-langchain.agent') { + return { + description: { + inputs: ['main'], + builderHint: { + inputs: { + ai_languageModel: { required: true }, + ai_memory: { required: false }, + }, + }, + }, + }; + } + return { description: { inputs: ['main'] } }; + }, + getByName: (type: string) => mockNodeTypesProvider.getByNameAndVersion(type), + getKnownTypes: () => ({}), + }; + + it('errors when chat trigger has loadPreviousSession=memory but no memory subnode', () => { + const workflowJson = { + id: 'test', + name: 'Test', + nodes: [ + { + id: 'ct-1', + name: 'Chat Trigger', + type: '@n8n/n8n-nodes-langchain.chatTrigger', + typeVersion: 1.4, + position: [0, 0] as [number, number], + parameters: { + mode: 'hostedChat', + options: { loadPreviousSession: 'memory' }, + }, + }, + ], + connections: {}, + }; + + const result = validateWorkflow(workflowJson, { + nodeTypesProvider: mockNodeTypesProvider as never, + allowDisconnectedNodes: true, + }); + + const errors = result.errors.filter((e) => e.code === 'MISSING_REQUIRED_INPUT'); + expect(errors).toHaveLength(1); + expect(errors[0].nodeName).toBe('Chat Trigger'); + expect(errors[0].message).toContain('ai_memory'); + expect(errors[0].message).toContain('loadPreviousSession'); + expect(result.valid).toBe(false); + }); + + it('passes when chat trigger has loadPreviousSession=memory and a memory subnode is connected', () => { + const workflowJson = { + id: 'test', + name: 'Test', + nodes: [ + { + id: 'ct-1', + name: 'Chat Trigger', + type: '@n8n/n8n-nodes-langchain.chatTrigger', + typeVersion: 1.4, + position: [0, 0] as [number, number], + parameters: { + mode: 'hostedChat', + options: { loadPreviousSession: 'memory' }, + }, + }, + { + id: 'mem-1', + name: 'Memory', + type: '@n8n/n8n-nodes-langchain.memoryBufferWindow', + typeVersion: 1, + position: [0, 200] as [number, number], + parameters: {}, + }, + ], + connections: { + Memory: { + ai_memory: [[{ node: 'Chat Trigger', type: 'ai_memory', index: 0 }]], + }, + }, + }; + + const result = validateWorkflow(workflowJson, { + nodeTypesProvider: mockNodeTypesProvider as never, + allowDisconnectedNodes: true, + }); + + const errors = result.errors.filter((e) => e.code === 'MISSING_REQUIRED_INPUT'); + expect(errors).toHaveLength(0); + }); + + it('passes when chat trigger has loadPreviousSession=notSupported and no memory', () => { + const workflowJson = { + id: 'test', + name: 'Test', + nodes: [ + { + id: 'ct-1', + name: 'Chat Trigger', + type: '@n8n/n8n-nodes-langchain.chatTrigger', + typeVersion: 1.4, + position: [0, 0] as [number, number], + parameters: { + mode: 'hostedChat', + options: { loadPreviousSession: 'notSupported' }, + }, + }, + ], + connections: {}, + }; + + const result = validateWorkflow(workflowJson, { + nodeTypesProvider: mockNodeTypesProvider as never, + allowDisconnectedNodes: true, + }); + + const errors = result.errors.filter((e) => e.code === 'MISSING_REQUIRED_INPUT'); + expect(errors).toHaveLength(0); + }); + + it('errors on unconditional required input (agent without language model)', () => { + const workflowJson = { + id: 'test', + name: 'Test', + nodes: [ + { + id: 'agent-1', + name: 'AI Agent', + type: '@n8n/n8n-nodes-langchain.agent', + typeVersion: 3, + position: [0, 0] as [number, number], + parameters: {}, + }, + ], + connections: {}, + }; + + const result = validateWorkflow(workflowJson, { + nodeTypesProvider: mockNodeTypesProvider as never, + allowDisconnectedNodes: true, + }); + + const errors = result.errors.filter((e) => e.code === 'MISSING_REQUIRED_INPUT'); + expect(errors).toHaveLength(1); + expect(errors[0].nodeName).toBe('AI Agent'); + expect(errors[0].message).toContain('ai_languageModel'); + // No condition details when requirement is unconditional + expect(errors[0].message).not.toContain('Required:'); + }); + + it('does not error for optional inputs', () => { + // Agent declares ai_memory as required:false — should never emit MISSING_REQUIRED_INPUT + const workflowJson = { + id: 'test', + name: 'Test', + nodes: [ + { + id: 'agent-1', + name: 'AI Agent', + type: '@n8n/n8n-nodes-langchain.agent', + typeVersion: 3, + position: [0, 0] as [number, number], + parameters: {}, + }, + { + id: 'lm-1', + name: 'Model', + type: '@n8n/n8n-nodes-langchain.lmChatOpenAi', + typeVersion: 1, + position: [0, 200] as [number, number], + parameters: {}, + }, + ], + connections: { + Model: { + ai_languageModel: [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]], + }, + }, + }; + + const result = validateWorkflow(workflowJson, { + nodeTypesProvider: mockNodeTypesProvider as never, + allowDisconnectedNodes: true, + }); + + const errors = result.errors.filter((e) => e.code === 'MISSING_REQUIRED_INPUT'); + expect(errors).toHaveLength(0); + }); + + it('is a no-op for nodes without builderHint.inputs', () => { + const workflowJson = { + id: 'test', + name: 'Test', + nodes: [ + { + id: 'n-1', + name: 'Some Node', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.2, + position: [0, 0] as [number, number], + parameters: { url: 'https://example.com' }, + }, + ], + connections: {}, + }; + + const result = validateWorkflow(workflowJson, { + nodeTypesProvider: mockNodeTypesProvider as never, + allowDisconnectedNodes: true, + }); + + const errors = result.errors.filter((e) => e.code === 'MISSING_REQUIRED_INPUT'); + expect(errors).toHaveLength(0); + }); + + it('is skipped when nodeTypesProvider is not supplied', () => { + const workflowJson = { + id: 'test', + name: 'Test', + nodes: [ + { + id: 'ct-1', + name: 'Chat Trigger', + type: '@n8n/n8n-nodes-langchain.chatTrigger', + typeVersion: 1.4, + position: [0, 0] as [number, number], + parameters: { + mode: 'hostedChat', + options: { loadPreviousSession: 'memory' }, + }, + }, + ], + connections: {}, + }; + + const result = validateWorkflow(workflowJson, { allowDisconnectedNodes: true }); + + const errors = result.errors.filter((e) => e.code === 'MISSING_REQUIRED_INPUT'); + expect(errors).toHaveLength(0); + }); + }); + describe('Invalid subnode error message enhancement', () => { beforeAll(setupTestSchemas, 120_000); afterAll(teardownTestSchemas); diff --git a/packages/@n8n/workflow-sdk/test-fixtures/committed-workflows/5.json b/packages/@n8n/workflow-sdk/test-fixtures/committed-workflows/5.json new file mode 100644 index 00000000000..510f5ea13d2 --- /dev/null +++ b/packages/@n8n/workflow-sdk/test-fixtures/committed-workflows/5.json @@ -0,0 +1,46 @@ +{ + "nodes": [ + { + "parameters": { + "mode": "hostedChat", + "public": true, + "options": { + "loadPreviousSession": "memory" + } + }, + "id": "1ad0933d-072a-4187-9138-57883fdc908f", + "name": "Chat Trigger", + "type": "@n8n/n8n-nodes-langchain.chatTrigger", + "typeVersion": 1.4, + "position": [368, 240], + "webhookId": "a9f8eff2-024f-48fa-a8d2-a0af8ec5806f" + }, + { + "parameters": { + "promptType": "auto", + "options": {} + }, + "id": "4d2b6c5f-1f8c-4d1c-9d2e-18a2b6e3a8a1", + "name": "Agent", + "type": "@n8n/n8n-nodes-langchain.agent", + "typeVersion": 3, + "position": [640, 240] + }, + { + "parameters": {}, + "id": "7c5b1f82-0b34-4e82-8b91-77c3b16c5b21", + "name": "Memory", + "type": "@n8n/n8n-nodes-langchain.memoryBufferWindow", + "typeVersion": 1.3, + "position": [760, 480] + } + ], + "connections": { + "Chat Trigger": { + "main": [[{ "node": "Agent", "type": "main", "index": 0 }]] + }, + "Memory": { + "ai_memory": [[{ "node": "Agent", "type": "ai_memory", "index": 0 }]] + } + } +} diff --git a/packages/@n8n/workflow-sdk/test-fixtures/committed-workflows/manifest.json b/packages/@n8n/workflow-sdk/test-fixtures/committed-workflows/manifest.json index ea407087f3c..aa4e679ca65 100644 --- a/packages/@n8n/workflow-sdk/test-fixtures/committed-workflows/manifest.json +++ b/packages/@n8n/workflow-sdk/test-fixtures/committed-workflows/manifest.json @@ -70,6 +70,15 @@ "id": 4, "name": "Unknown nodes with connections", "success": true + }, + { + "id": 5, + "name": "Chat trigger with loadPreviousSession=memory but no memory subnode (INS-136)", + "success": true, + "expectedErrors": [ + { "code": "MISSING_REQUIRED_INPUT", "nodeName": "Chat Trigger" }, + { "code": "MISSING_REQUIRED_INPUT", "nodeName": "Agent" } + ] } ] }