Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,15 @@ describe('evaluateConnections', () => {
inputs: `={{(() => { return [{ type: "${NodeConnectionTypes.Main}" }, { type: "${NodeConnectionTypes.AiTool}", displayName: "Tools" }]; })()}}`,
outputs: [NodeConnectionTypes.Main],
},
{
name: 'n8n-nodes-test.agent',
displayName: 'AI Agent',
inputs: [
{ type: NodeConnectionTypes.AiTool, required: true, maxConnections: -1 },
{ type: NodeConnectionTypes.Main },
],
outputs: [NodeConnectionTypes.Main],
},
{
name: 'n8n-nodes-test.merge',
displayName: 'Merge',
Expand All @@ -168,9 +177,33 @@ describe('evaluateConnections', () => {
{
name: 'n8n-nodes-test.vectorStore',
displayName: 'Vector Store',
inputs: `={{ (() => { const mode = $parameter.mode; if (mode === "retrieve") { return [{ type: "${NodeConnectionTypes.AiEmbedding}", required: true }]; } return [{ type: "${NodeConnectionTypes.Main}" }, { type: "${NodeConnectionTypes.AiDocument}" }]; })() }}`,
inputs: `={{
((parameters) => {
const mode = parameters?.mode;
const inputs = [{ displayName: "Embedding", type: "${NodeConnectionTypes.AiEmbedding}", required: true, maxConnections: 1}]

if (mode === 'retrieve-as-tool') {
return inputs;
}

if (['insert', 'load', 'update'].includes(mode)) {
inputs.push({ displayName: "", type: "${NodeConnectionTypes.Main}"})
}

if (['insert'].includes(mode)) {
inputs.push({ displayName: "Document", type: "${NodeConnectionTypes.AiDocument}", required: true, maxConnections: 1})
}
return inputs
})($parameter)
}}`,
outputs: `={{ (() => { const mode = $parameter.mode; if (mode === "retrieve-as-tool") { return [{ type: "${NodeConnectionTypes.AiTool}" }]; } return [{ type: "${NodeConnectionTypes.AiVectorStore}" }]; })() }}`,
},
{
name: 'n8n-nodes-test.embeddingsOpenAi',
displayName: 'OpenAI Embeddings',
inputs: [],
outputs: [NodeConnectionTypes.AiEmbedding],
},
]);

describe('basic workflow connections validation', () => {
Expand Down Expand Up @@ -332,6 +365,111 @@ describe('evaluateConnections', () => {
}),
);
});

it('should detect AI sub-node not connected to a root node', () => {
const workflow = mock<SimpleWorkflow>({
name: 'Test Workflow',
nodes: [
{
id: '1',
name: 'Chat Model',
type: 'n8n-nodes-test.chatOpenAi',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
],
connections: {},
});

const { violations } = evaluateConnections(workflow, mockNodeTypes);
expect(violations).toContainEqual(
expect.objectContaining({
description: expect.stringContaining(
'Sub-node Chat Model (n8n-nodes-test.chatOpenAi) provides ai_languageModel but is not connected to a root node.',
),
}),
);
});

it('should not report issues for nested sub-nodes properly connected to a root node', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '0',
name: 'Manual Trigger',
type: 'n8n-nodes-test.manualTrigger',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
{
id: '1',
parameters: {
mode: 'retrieve-as-tool',
},
name: 'Vector Store Retrieval',
type: 'n8n-nodes-test.vectorStore',
typeVersion: 1.3,
position: [0, 0],
},
{
id: '2',
parameters: {},
name: 'AI Agent',
type: 'n8n-nodes-test.agent',
typeVersion: 3,
position: [0, 0],
},
{
id: '3',
parameters: {},
name: 'OpenAI Embeddings',
type: 'n8n-nodes-test.embeddingsOpenAi',
typeVersion: 1.2,
position: [0, 0],
},
],
connections: {
'Manual Trigger': {
main: [
[
{
node: 'AI Agent',
type: 'main',
index: 0,
},
],
],
},
'Vector Store Retrieval': {
ai_tool: [
[
{
node: 'AI Agent',
type: 'ai_tool',
index: 0,
},
],
],
},
'OpenAI Embeddings': {
ai_embedding: [
[
{
node: 'Vector Store Retrieval',
type: 'ai_embedding',
index: 0,
},
],
],
},
},
});

const { violations } = evaluateConnections(workflow, mockNodeTypes);
expect(violations).toEqual([]);
});
});

describe('dynamic input/output resolution', () => {
Expand Down Expand Up @@ -431,16 +569,19 @@ describe('evaluateConnections', () => {
typeVersion: 1,
position: [400, 0],
},
{
id: '4',
name: 'Embeddings',
type: 'n8n-nodes-test.embeddingsOpenAi',
parameters: {},
typeVersion: 1,
position: [600, 0],
},
],
connections: {
'Manual Trigger': {
main: [
[
{
node: 'Vector Store',
type: 'main',
index: 0,
},
{
node: 'OpenAI',
type: 'main',
Expand All @@ -460,6 +601,17 @@ describe('evaluateConnections', () => {
],
],
},
Embeddings: {
ai_embedding: [
[
{
node: 'Vector Store',
type: 'ai_embedding',
index: 0,
},
],
],
},
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { INodeConnections, INodeTypeDescription, NodeConnectionType } from
import { mapConnectionsByDestination } from 'n8n-workflow';

import type { SimpleWorkflow } from '@/types';
import { isSubNode } from '@/utils/node-helpers';
import { resolveNodeInputs, resolveNodeOutputs } from '@/validation/utils/resolve-connections';

import type {
Expand Down Expand Up @@ -130,6 +131,50 @@ function checkMergeNodeConnections(
return issues;
}

function checkSubNodeRootConnections(
workflow: SimpleWorkflow,
nodeInfo: NodeResolvedConnectionTypesInfo,
nodesByName: Map<string, SimpleWorkflow['nodes'][number]>,
): ProgrammaticViolation[] {
const issues: ProgrammaticViolation[] = [];

const { node, nodeType, resolvedOutputs } = nodeInfo;

if (!resolvedOutputs || resolvedOutputs.size === 0) {
return issues;
}

if (!isSubNode(nodeType, node)) {
return issues;
}

const aiOutputs = Array.from(resolvedOutputs).filter((output) => output.startsWith('ai_'));

if (aiOutputs.length === 0) {
return issues;
}

const nodeConnections = workflow.connections?.[node.name];

for (const outputType of aiOutputs) {
const connectionsForType = nodeConnections?.[outputType];

const hasRootConnection = connectionsForType?.some((connectionGroup) =>
connectionGroup?.some((connection) => connection?.node && nodesByName.has(connection.node)),
);

if (!hasRootConnection) {
issues.push({
type: 'critical',
description: `Sub-node ${node.name} (${node.type}) provides ${outputType} but is not connected to a root node.`,
pointsDeducted: 50,
});
}
}

return issues;
}

export function validateConnections(
workflow: SimpleWorkflow,
nodeTypes: INodeTypeDescription[],
Expand All @@ -141,9 +186,11 @@ export function validateConnections(
}

const connectionsByDestination = mapConnectionsByDestination(workflow.connections);
const nodesByName = new Map(workflow.nodes.map((node) => [node.name, node]));
const nodeTypeMap = new Map(nodeTypes.map((type) => [type.name, type]));

for (const node of workflow.nodes) {
const nodeType = nodeTypes.find((type) => type.name === node.type);
const nodeType = nodeTypeMap.get(node.type);
if (!nodeType) {
violations.push({
type: 'critical',
Expand Down Expand Up @@ -178,6 +225,8 @@ export function validateConnections(
violations.push(...checkUnsupportedConnections(nodeInfo, providedInputTypes));

violations.push(...checkMergeNodeConnections(nodeInfo, nodeConnections));

violations.push(...checkSubNodeRootConnections(workflow, nodeInfo, nodesByName));
}

return violations;
Expand Down
Loading