mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 09:36:44 +00:00
fix(core): AI agent node data accessibility (#18757)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1657,11 +1657,14 @@ export function isTool(
|
||||
}
|
||||
|
||||
// Check for other tool nodes
|
||||
for (const output of nodeTypeDescription.outputs) {
|
||||
if (typeof output === 'string') {
|
||||
return output === NodeConnectionTypes.AiTool;
|
||||
} else if (output?.type && output.type === NodeConnectionTypes.AiTool) {
|
||||
return true;
|
||||
if (Array.isArray(nodeTypeDescription.outputs)) {
|
||||
// Handle static outputs (array case)
|
||||
for (const output of nodeTypeDescription.outputs) {
|
||||
if (typeof output === 'string') {
|
||||
return output === NodeConnectionTypes.AiTool;
|
||||
} else if (output?.type && output.type === NodeConnectionTypes.AiTool) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -402,12 +402,17 @@ export class WorkflowDataProxy {
|
||||
!that.runExecutionData.resultData.runData.hasOwnProperty(nodeName) &&
|
||||
!getPinDataIfManualExecution(that.workflow, nodeName, that.mode)
|
||||
) {
|
||||
throw new ExpressionError('Referenced node is unexecuted', {
|
||||
throw new ExpressionError(`Node '${nodeName}' hasn't been executed`, {
|
||||
messageTemplate:
|
||||
'An expression references this node, but the node is unexecuted. Consider re-wiring your nodes or checking for execution first, i.e. {{ $if( $("{{nodeName}}").isExecuted, <action_if_executed>, "") }}',
|
||||
functionality: 'pairedItem',
|
||||
descriptionKey: isScriptingNode(nodeName, that.workflow)
|
||||
? 'pairedItemNoConnectionCodeNode'
|
||||
: 'pairedItemNoConnection',
|
||||
type: 'no_execution_data',
|
||||
nodeCause: nodeName,
|
||||
runIndex: that.runIndex,
|
||||
itemIndex: that.itemIndex,
|
||||
type: 'no_node_execution_data',
|
||||
descriptionKey: 'noNodeExecutionData',
|
||||
nodeCause: nodeName,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -496,11 +501,16 @@ export class WorkflowDataProxy {
|
||||
name = name.toString();
|
||||
|
||||
if (!node) {
|
||||
throw new ExpressionError("Referenced node doesn't exist", {
|
||||
throw new ExpressionError('Referenced node does not exist', {
|
||||
messageTemplate: 'Make sure to double-check the node name for typos',
|
||||
functionality: 'pairedItem',
|
||||
descriptionKey: isScriptingNode(nodeName, that.workflow)
|
||||
? 'pairedItemNoConnectionCodeNode'
|
||||
: 'pairedItemNoConnection',
|
||||
type: 'paired_item_no_connection',
|
||||
nodeCause: nodeName,
|
||||
runIndex: that.runIndex,
|
||||
itemIndex: that.itemIndex,
|
||||
nodeCause: nodeName,
|
||||
descriptionKey: 'nodeNotFound',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -514,31 +524,36 @@ export class WorkflowDataProxy {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Ultra-simple execution-based validation: if no execution data exists, throw error
|
||||
if (executionData.length === 0) {
|
||||
if (that.workflow.getParentNodes(nodeName).length === 0) {
|
||||
throw new ExpressionError('No execution data available', {
|
||||
messageTemplate:
|
||||
'No execution data available to expression under ‘%%PARAMETER%%’',
|
||||
descriptionKey: 'noInputConnection',
|
||||
nodeCause: nodeName,
|
||||
runIndex: that.runIndex,
|
||||
itemIndex: that.itemIndex,
|
||||
type: 'no_input_connection',
|
||||
});
|
||||
}
|
||||
|
||||
throw new ExpressionError('No execution data available', {
|
||||
throw new ExpressionError(`Node '${nodeName}' hasn't been executed`, {
|
||||
messageTemplate:
|
||||
'An expression references this node, but the node is unexecuted. Consider re-wiring your nodes or checking for execution first, i.e. {{ $if( $("{{nodeName}}").isExecuted, <action_if_executed>, "") }}',
|
||||
functionality: 'pairedItem',
|
||||
descriptionKey: isScriptingNode(nodeName, that.workflow)
|
||||
? 'pairedItemNoConnectionCodeNode'
|
||||
: 'pairedItemNoConnection',
|
||||
type: 'no_execution_data',
|
||||
nodeCause: nodeName,
|
||||
runIndex: that.runIndex,
|
||||
itemIndex: that.itemIndex,
|
||||
type: 'no_execution_data',
|
||||
});
|
||||
}
|
||||
|
||||
if (executionData.length <= that.itemIndex) {
|
||||
throw new ExpressionError(`No data found for item-index: "${that.itemIndex}"`, {
|
||||
runIndex: that.runIndex,
|
||||
itemIndex: that.itemIndex,
|
||||
});
|
||||
throw new ExpressionError(
|
||||
`"${nodeName}" node has ${executionData.length} item(s) but you're trying to access item ${that.itemIndex}`,
|
||||
{
|
||||
messageTemplate:
|
||||
'Adjust your expression to access an existing item index (0-{{maxIndex}})',
|
||||
functionality: 'pairedItem',
|
||||
descriptionKey: 'pairedItemInvalidIndex',
|
||||
type: 'no_execution_data',
|
||||
nodeCause: nodeName,
|
||||
runIndex: that.runIndex,
|
||||
itemIndex: that.itemIndex,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (['data', 'json'].includes(name)) {
|
||||
@@ -1066,12 +1081,17 @@ export class WorkflowDataProxy {
|
||||
!that?.runExecutionData?.resultData?.runData.hasOwnProperty(nodeName) &&
|
||||
!getPinDataIfManualExecution(that.workflow, nodeName, that.mode)
|
||||
) {
|
||||
throw createExpressionError('Referenced node is unexecuted', {
|
||||
throw createExpressionError(`Node '${nodeName}' hasn't been executed`, {
|
||||
messageTemplate:
|
||||
'An expression references this node, but the node is unexecuted. Consider re-wiring your nodes or checking for execution first, i.e. {{ $if( $("{{nodeName}}").isExecuted, <action_if_executed>, "") }}',
|
||||
functionality: 'pairedItem',
|
||||
descriptionKey: isScriptingNode(nodeName, that.workflow)
|
||||
? 'pairedItemNoConnectionCodeNode'
|
||||
: 'pairedItemNoConnection',
|
||||
type: 'no_execution_data',
|
||||
nodeCause: nodeName,
|
||||
runIndex: that.runIndex,
|
||||
itemIndex: that.itemIndex,
|
||||
type: 'no_node_execution_data',
|
||||
descriptionKey: 'noNodeExecutionData',
|
||||
nodeCause: nodeName,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1114,7 +1134,7 @@ export class WorkflowDataProxy {
|
||||
let contextNode = that.contextNodeName;
|
||||
if (activeNode) {
|
||||
const parentMainInputNode = that.workflow.getParentMainInputNode(activeNode);
|
||||
contextNode = parentMainInputNode.name ?? contextNode;
|
||||
contextNode = parentMainInputNode?.name ?? contextNode;
|
||||
}
|
||||
const parentNodes = that.workflow.getParentNodes(contextNode);
|
||||
if (!parentNodes.includes(nodeName)) {
|
||||
|
||||
@@ -35,7 +35,6 @@ import type {
|
||||
INodeConnection,
|
||||
IObservableObject,
|
||||
NodeParameterValueType,
|
||||
INodeOutputConfiguration,
|
||||
NodeConnectionType,
|
||||
} from './interfaces';
|
||||
import { NodeConnectionTypes } from './interfaces';
|
||||
@@ -691,36 +690,51 @@ export class Workflow {
|
||||
getParentMainInputNode(node: INode): INode {
|
||||
if (node) {
|
||||
const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
||||
const outputs = NodeHelpers.getNodeOutputs(this, node, nodeType.description);
|
||||
if (!nodeType?.description.outputs) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (
|
||||
outputs.find(
|
||||
(output) =>
|
||||
((output as INodeOutputConfiguration)?.type ?? output) !== NodeConnectionTypes.Main,
|
||||
)
|
||||
) {
|
||||
// Get the first node which is connected to a non-main output
|
||||
const nonMainNodesConnected = outputs?.reduce((acc, outputName) => {
|
||||
const parentNodes = this.getChildNodes(
|
||||
node.name,
|
||||
(outputName as INodeOutputConfiguration)?.type ?? outputName,
|
||||
);
|
||||
if (parentNodes.length > 0) {
|
||||
acc.push(...parentNodes);
|
||||
const outputs = NodeHelpers.getNodeOutputs(this, node, nodeType.description);
|
||||
const nonMainConnectionTypes: NodeConnectionType[] = [];
|
||||
|
||||
// Defensive check: NodeHelpers.getNodeOutputs should always return an array,
|
||||
// but in some edge cases (particularly during testing with incomplete node setup),
|
||||
// it may return undefined or null
|
||||
if (Array.isArray(outputs)) {
|
||||
for (const output of outputs) {
|
||||
const type = typeof output === 'string' ? output : output.type;
|
||||
if (type !== NodeConnectionTypes.Main) {
|
||||
nonMainConnectionTypes.push(type);
|
||||
}
|
||||
return acc;
|
||||
}, [] as string[]);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort for deterministic behavior: prevents non-deterministic selection when multiple
|
||||
// non-main outputs exist (AI agents with multiple tools). Object.keys() ordering
|
||||
// can vary across runs, causing inconsistent first-choice selection.
|
||||
nonMainConnectionTypes.sort();
|
||||
|
||||
if (nonMainConnectionTypes.length > 0) {
|
||||
const nonMainNodesConnected: string[] = [];
|
||||
const nodeConnections = this.connectionsBySourceNode[node.name];
|
||||
|
||||
for (const type of nonMainConnectionTypes) {
|
||||
// Only include connection types that exist in actual execution data
|
||||
if (nodeConnections?.[type]) {
|
||||
const childNodes = this.getChildNodes(node.name, type);
|
||||
if (childNodes.length > 0) {
|
||||
nonMainNodesConnected.push(...childNodes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nonMainNodesConnected.length) {
|
||||
// Sort for deterministic behavior, then get first node
|
||||
nonMainNodesConnected.sort();
|
||||
const returnNode = this.getNode(nonMainNodesConnected[0]);
|
||||
if (returnNode === null) {
|
||||
// This should theoretically never happen as the node is connected
|
||||
// but who knows and it makes TS happy
|
||||
if (!returnNode) {
|
||||
throw new ApplicationError(`Node "${nonMainNodesConnected[0]}" not found`);
|
||||
}
|
||||
|
||||
// The chain of non-main nodes is potentially not finished yet so
|
||||
// keep on going
|
||||
return this.getParentMainInputNode(returnNode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,10 +273,15 @@ export const baseFixtures: ExpressionTestFixture[] = [
|
||||
{
|
||||
type: 'evaluation',
|
||||
input: [],
|
||||
error: new ExpressionError('No execution data available', {
|
||||
error: new ExpressionError("Node 'node' hasn't been executed", {
|
||||
runIndex: 0,
|
||||
itemIndex: -1,
|
||||
type: 'no_execution_data',
|
||||
functionality: 'pairedItem',
|
||||
messageTemplate:
|
||||
'An expression references this node, but the node is unexecuted. Consider re-wiring your nodes or checking for execution first, i.e. {{ $if( $("{{nodeName}}").isExecuted, <action_if_executed>, "") }}',
|
||||
descriptionKey: 'pairedItemNoConnection',
|
||||
nodeCause: 'node',
|
||||
}),
|
||||
},
|
||||
{ type: 'transform' },
|
||||
|
||||
@@ -160,6 +160,10 @@ describe('WorkflowDataProxy', () => {
|
||||
expect(proxy.$('Rename').params).toEqual({ value1: 'data', value2: 'initialName' });
|
||||
});
|
||||
|
||||
test('$("NodeName").context', () => {
|
||||
expect(proxy.$('Rename').context).toBeDefined();
|
||||
});
|
||||
|
||||
test('$("NodeName") not in workflow should throw', () => {
|
||||
expect(() => proxy.$('doNotExist')).toThrowError(ExpressionError);
|
||||
});
|
||||
@@ -188,6 +192,12 @@ describe('WorkflowDataProxy', () => {
|
||||
test('$input.item', () => {
|
||||
expect(proxy.$input.item?.json?.data).toEqual(105);
|
||||
});
|
||||
test('$input.context', () => {
|
||||
expect(proxy.$input.context).toBeDefined();
|
||||
});
|
||||
test('$input.params', () => {
|
||||
expect(proxy.$input.params).toBeDefined();
|
||||
});
|
||||
test('$thisItem', () => {
|
||||
expect(proxy.$thisItem.json.data).toEqual(105);
|
||||
});
|
||||
@@ -262,8 +272,8 @@ describe('WorkflowDataProxy', () => {
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ExpressionError);
|
||||
const exprError = error as ExpressionError;
|
||||
expect(exprError.message).toEqual('Referenced node is unexecuted');
|
||||
expect(exprError.context.type).toEqual('no_node_execution_data');
|
||||
expect(exprError.message).toEqual("Node 'Impossible' hasn't been executed");
|
||||
expect(exprError.context.type).toEqual('no_execution_data');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -274,8 +284,8 @@ describe('WorkflowDataProxy', () => {
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ExpressionError);
|
||||
const exprError = error as ExpressionError;
|
||||
expect(exprError.message).toEqual('No execution data available');
|
||||
expect(exprError.context.type).toEqual('no_input_connection');
|
||||
expect(exprError.message).toEqual("Node 'NoInputConnection' hasn't been executed");
|
||||
expect(exprError.context.type).toEqual('no_execution_data');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -286,8 +296,8 @@ describe('WorkflowDataProxy', () => {
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ExpressionError);
|
||||
const exprError = error as ExpressionError;
|
||||
expect(exprError.message).toEqual('Referenced node is unexecuted');
|
||||
expect(exprError.context.type).toEqual('no_node_execution_data');
|
||||
expect(exprError.message).toEqual("Node 'Impossible if' hasn't been executed");
|
||||
expect(exprError.context.type).toEqual('no_execution_data');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -298,7 +308,7 @@ describe('WorkflowDataProxy', () => {
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ExpressionError);
|
||||
const exprError = error as ExpressionError;
|
||||
expect(exprError.message).toEqual('No execution data available');
|
||||
expect(exprError.message).toEqual("Node 'Impossible' hasn't been executed");
|
||||
expect(exprError.context.type).toEqual('no_execution_data');
|
||||
}
|
||||
});
|
||||
@@ -826,4 +836,306 @@ describe('WorkflowDataProxy', () => {
|
||||
expect(proxy.$('Set main variable').item.json.main_variable).toEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Improved error messages for missing execution data', () => {
|
||||
test('should show helpful error message when accessing node without execution data', () => {
|
||||
// Create a simple workflow with two connected nodes
|
||||
const workflow: IWorkflowBase = {
|
||||
id: '1',
|
||||
name: 'test-workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Telegram Trigger',
|
||||
type: 'n8n-nodes-base.telegramTrigger',
|
||||
typeVersion: 1.2,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Send a text message',
|
||||
type: 'n8n-nodes-base.telegram',
|
||||
typeVersion: 1.2,
|
||||
position: [576, 0],
|
||||
parameters: {
|
||||
chatId: "={{ $('Telegram Trigger').item.json.message.chat.id }}",
|
||||
text: 'Test message',
|
||||
},
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
'Telegram Trigger': {
|
||||
main: [[{ node: 'Send a text message', type: NodeConnectionTypes.Main, index: 0 }]],
|
||||
},
|
||||
},
|
||||
active: false,
|
||||
isArchived: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Create run data without execution data for Telegram Trigger
|
||||
const run = {
|
||||
data: {
|
||||
resultData: {
|
||||
runData: {}, // Empty - no nodes have executed
|
||||
},
|
||||
},
|
||||
mode: 'manual' as const,
|
||||
startedAt: new Date(),
|
||||
status: 'success' as const,
|
||||
};
|
||||
|
||||
const proxy = getProxyFromFixture(workflow, run, 'Send a text message');
|
||||
|
||||
// Should throw helpful error when trying to access Telegram Trigger data
|
||||
let error: ExpressionError | undefined;
|
||||
try {
|
||||
proxy.$('Telegram Trigger').item;
|
||||
} catch (e) {
|
||||
error = e as ExpressionError;
|
||||
}
|
||||
|
||||
expect(error).toBeDefined();
|
||||
expect(error).toBeInstanceOf(ExpressionError);
|
||||
expect(error!.message).toBe("Node 'Telegram Trigger' hasn't been executed");
|
||||
expect(error!.context.type).toBe('no_execution_data');
|
||||
expect(error!.context.messageTemplate).toBe(
|
||||
'An expression references this node, but the node is unexecuted. Consider re-wiring your nodes or checking for execution first, i.e. {{ $if( $("{{nodeName}}").isExecuted, <action_if_executed>, "") }}',
|
||||
);
|
||||
});
|
||||
|
||||
test('should show helpful error message for different node names', () => {
|
||||
const workflow: IWorkflowBase = {
|
||||
id: '1',
|
||||
name: 'test-workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Process Data',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [300, 0],
|
||||
parameters: {
|
||||
jsCode: "return $('HTTP Request').all();",
|
||||
},
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
'HTTP Request': {
|
||||
main: [[{ node: 'Process Data', type: NodeConnectionTypes.Main, index: 0 }]],
|
||||
},
|
||||
},
|
||||
active: false,
|
||||
isArchived: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const run = {
|
||||
data: {
|
||||
resultData: {
|
||||
runData: {}, // Empty - no nodes have executed
|
||||
},
|
||||
},
|
||||
mode: 'manual' as const,
|
||||
startedAt: new Date(),
|
||||
status: 'success' as const,
|
||||
};
|
||||
|
||||
const proxy = getProxyFromFixture(workflow, run, 'Process Data');
|
||||
|
||||
let error: ExpressionError | undefined;
|
||||
try {
|
||||
proxy.$('HTTP Request').item;
|
||||
} catch (e) {
|
||||
error = e as ExpressionError;
|
||||
}
|
||||
|
||||
expect(error).toBeDefined();
|
||||
expect(error!.message).toBe("Node 'HTTP Request' hasn't been executed");
|
||||
expect(error!.context.type).toBe('no_execution_data');
|
||||
expect(error!.context.messageTemplate).toBe(
|
||||
'An expression references this node, but the node is unexecuted. Consider re-wiring your nodes or checking for execution first, i.e. {{ $if( $("{{nodeName}}").isExecuted, <action_if_executed>, "") }}',
|
||||
);
|
||||
});
|
||||
|
||||
test('should use improved error for first(), last(), and all() methods', () => {
|
||||
const workflow: IWorkflowBase = {
|
||||
id: '1',
|
||||
name: 'test-workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Start Node',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'End Node',
|
||||
type: 'n8n-nodes-base.noOp',
|
||||
typeVersion: 1,
|
||||
position: [300, 0],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
'Start Node': {
|
||||
main: [[{ node: 'End Node', type: NodeConnectionTypes.Main, index: 0 }]],
|
||||
},
|
||||
},
|
||||
active: false,
|
||||
isArchived: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const run = {
|
||||
data: {
|
||||
resultData: {
|
||||
runData: {}, // Empty - no nodes have executed
|
||||
},
|
||||
},
|
||||
mode: 'manual' as const,
|
||||
startedAt: new Date(),
|
||||
status: 'success' as const,
|
||||
};
|
||||
|
||||
const proxy = getProxyFromFixture(workflow, run, 'End Node');
|
||||
|
||||
// Test first() method
|
||||
let error: ExpressionError | undefined;
|
||||
try {
|
||||
proxy.$('Start Node').first();
|
||||
} catch (e) {
|
||||
error = e as ExpressionError;
|
||||
}
|
||||
expect(error).toBeDefined();
|
||||
expect(error!.message).toBe("Node 'Start Node' hasn't been executed");
|
||||
expect(error!.context.messageTemplate).toBe(
|
||||
'An expression references this node, but the node is unexecuted. Consider re-wiring your nodes or checking for execution first, i.e. {{ $if( $("{{nodeName}}").isExecuted, <action_if_executed>, "") }}',
|
||||
);
|
||||
|
||||
// Test last() method
|
||||
error = undefined;
|
||||
try {
|
||||
proxy.$('Start Node').last();
|
||||
} catch (e) {
|
||||
error = e as ExpressionError;
|
||||
}
|
||||
expect(error).toBeDefined();
|
||||
expect(error!.message).toBe("Node 'Start Node' hasn't been executed");
|
||||
expect(error!.context.messageTemplate).toBe(
|
||||
'An expression references this node, but the node is unexecuted. Consider re-wiring your nodes or checking for execution first, i.e. {{ $if( $("{{nodeName}}").isExecuted, <action_if_executed>, "") }}',
|
||||
);
|
||||
|
||||
// Test all() method
|
||||
error = undefined;
|
||||
try {
|
||||
proxy.$('Start Node').all();
|
||||
} catch (e) {
|
||||
error = e as ExpressionError;
|
||||
}
|
||||
expect(error).toBeDefined();
|
||||
expect(error!.message).toBe("Node 'Start Node' hasn't been executed");
|
||||
expect(error!.context.messageTemplate).toBe(
|
||||
'An expression references this node, but the node is unexecuted. Consider re-wiring your nodes or checking for execution first, i.e. {{ $if( $("{{nodeName}}").isExecuted, <action_if_executed>, "") }}',
|
||||
);
|
||||
});
|
||||
|
||||
test('should show helpful error message when accessing non-existent node', () => {
|
||||
const workflow: IWorkflowBase = {
|
||||
id: '1',
|
||||
name: 'test-workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Real Node',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
active: false,
|
||||
isArchived: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const run = {
|
||||
data: {
|
||||
resultData: {
|
||||
runData: {
|
||||
'Real Node': [
|
||||
{
|
||||
data: {
|
||||
main: [[{ json: { test: 'data' } }]],
|
||||
},
|
||||
source: [null],
|
||||
startTime: 123,
|
||||
executionTime: 456,
|
||||
executionIndex: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
mode: 'manual' as const,
|
||||
startedAt: new Date(),
|
||||
status: 'success' as const,
|
||||
};
|
||||
|
||||
const proxy = getProxyFromFixture(workflow, run, 'Real Node');
|
||||
|
||||
// Should throw helpful error when trying to access a non-existent node
|
||||
let error: ExpressionError | undefined;
|
||||
try {
|
||||
proxy.$('NonExistentNode').item;
|
||||
} catch (e) {
|
||||
error = e as ExpressionError;
|
||||
}
|
||||
|
||||
expect(error).toBeDefined();
|
||||
expect(error).toBeInstanceOf(ExpressionError);
|
||||
expect(error!.message).toBe("Referenced node doesn't exist");
|
||||
expect(error!.context.descriptionKey).toBe('nodeNotFound');
|
||||
expect(error!.context.nodeCause).toBe('NonExistentNode');
|
||||
});
|
||||
|
||||
test('should show error when accessing item with invalid index via direct proxy access', () => {
|
||||
// Use existing fixture data to test the item index validation path
|
||||
const fixture = loadFixture('base');
|
||||
|
||||
// Create a proxy with itemIndex that exceeds available items for a node
|
||||
const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'Set Node', 'manual', {
|
||||
throwOnMissingExecutionData: true,
|
||||
runIndex: 10, // itemIndex way too high
|
||||
});
|
||||
|
||||
let error: ExpressionError | undefined;
|
||||
try {
|
||||
// This should trigger the error path for invalid item index
|
||||
proxy.$('Set Node').item;
|
||||
} catch (e) {
|
||||
error = e as ExpressionError;
|
||||
}
|
||||
|
||||
expect(error).toBeDefined();
|
||||
expect(error).toBeInstanceOf(ExpressionError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import { UserError } from '../src/errors';
|
||||
import { NodeConnectionTypes } from '../src/interfaces';
|
||||
import type {
|
||||
IBinaryKeyData,
|
||||
IConnection,
|
||||
IConnections,
|
||||
IDataObject,
|
||||
INode,
|
||||
@@ -3103,4 +3102,307 @@ describe('Workflow', () => {
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
test('should handle unknown node type in constructor', () => {
|
||||
// Create a workflow with a node that has an unknown type
|
||||
const workflow = new Workflow({
|
||||
nodeTypes: {
|
||||
getByNameAndVersion: () => undefined, // Always return undefined to simulate unknown node type
|
||||
getAll: () => [],
|
||||
} as any,
|
||||
nodes: [
|
||||
{
|
||||
id: 'unknown-node',
|
||||
name: 'UnknownNode',
|
||||
type: 'unknown.type',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
active: false,
|
||||
});
|
||||
|
||||
// Should not throw error, just continue processing
|
||||
expect(workflow).toBeDefined();
|
||||
expect(workflow.getNode('UnknownNode')).toBeDefined();
|
||||
});
|
||||
|
||||
test('should throw error for unknown context type', () => {
|
||||
expect(() => {
|
||||
SIMPLE_WORKFLOW.getStaticData('invalid' as any);
|
||||
}).toThrow('Unknown context type. Only `global` and `node` are supported.');
|
||||
});
|
||||
|
||||
test('should throw error when node parameter is undefined for node context', () => {
|
||||
expect(() => {
|
||||
SIMPLE_WORKFLOW.getStaticData('node', undefined);
|
||||
}).toThrow('The request data of context type "node" the node parameter has to be set!');
|
||||
});
|
||||
|
||||
test('should return deterministic results for AI agent nodes', () => {
|
||||
// Test that deterministic sorting works for AI agent scenarios
|
||||
const workflow = new Workflow({
|
||||
nodeTypes,
|
||||
nodes: [
|
||||
{
|
||||
id: 'aiAgent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
typeVersion: 1.8,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
id: 'tool1',
|
||||
name: 'Tool1',
|
||||
type: '@n8n/n8n-nodes-langchain.toolWikipedia',
|
||||
typeVersion: 1,
|
||||
position: [100, 0],
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
id: 'tool2',
|
||||
name: 'Tool2',
|
||||
type: '@n8n/n8n-nodes-langchain.toolCalculator',
|
||||
typeVersion: 1,
|
||||
position: [200, 0],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
Tool1: {
|
||||
[NodeConnectionTypes.AiTool]: [
|
||||
[{ node: 'AI Agent', type: NodeConnectionTypes.AiTool, index: 0 }],
|
||||
],
|
||||
},
|
||||
Tool2: {
|
||||
[NodeConnectionTypes.AiTool]: [
|
||||
[{ node: 'AI Agent', type: NodeConnectionTypes.AiTool, index: 0 }],
|
||||
],
|
||||
},
|
||||
},
|
||||
active: false,
|
||||
});
|
||||
|
||||
const aiAgent = workflow.getNode('AI Agent')!;
|
||||
const result1 = workflow.getParentMainInputNode(aiAgent);
|
||||
const result2 = workflow.getParentMainInputNode(aiAgent);
|
||||
|
||||
// Results should be consistent across multiple calls
|
||||
expect(result1.name).toBe(result2.name);
|
||||
});
|
||||
|
||||
test('should handle multiple non-main connection types deterministically', () => {
|
||||
// This test demonstrates why alphabetical sorting is crucial:
|
||||
// Without sorting, Object.keys() could return different orders across JavaScript engines/runs
|
||||
// We test that the same input always produces the same output
|
||||
|
||||
const createTestWorkflow = () => {
|
||||
const workflow = new Workflow({
|
||||
nodeTypes,
|
||||
nodes: [
|
||||
{
|
||||
id: 'aiAgent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
typeVersion: 1.8,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
id: 'tool1',
|
||||
name: 'Tool1',
|
||||
type: '@n8n/n8n-nodes-langchain.toolCalculator',
|
||||
typeVersion: 1,
|
||||
position: [100, 0],
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
id: 'tool2',
|
||||
name: 'Tool2',
|
||||
type: '@n8n/n8n-nodes-langchain.toolWikipedia',
|
||||
typeVersion: 1,
|
||||
position: [200, 0],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
Tool1: {
|
||||
[NodeConnectionTypes.AiTool]: [
|
||||
[{ node: 'AI Agent', type: NodeConnectionTypes.AiTool, index: 0 }],
|
||||
],
|
||||
},
|
||||
Tool2: {
|
||||
[NodeConnectionTypes.AiTool]: [
|
||||
[{ node: 'AI Agent', type: NodeConnectionTypes.AiTool, index: 1 }],
|
||||
],
|
||||
},
|
||||
},
|
||||
active: false,
|
||||
});
|
||||
return workflow;
|
||||
};
|
||||
|
||||
// Create multiple identical workflows
|
||||
const workflow1 = createTestWorkflow();
|
||||
const workflow2 = createTestWorkflow();
|
||||
const workflow3 = createTestWorkflow();
|
||||
|
||||
const aiAgent1 = workflow1.getNode('AI Agent')!;
|
||||
const aiAgent2 = workflow2.getNode('AI Agent')!;
|
||||
const aiAgent3 = workflow3.getNode('AI Agent')!;
|
||||
|
||||
const result1 = workflow1.getParentMainInputNode(aiAgent1);
|
||||
const result2 = workflow2.getParentMainInputNode(aiAgent2);
|
||||
const result3 = workflow3.getParentMainInputNode(aiAgent3);
|
||||
|
||||
// All results should be identical (demonstrates deterministic behavior)
|
||||
expect(result1.name).toBe(result2.name);
|
||||
expect(result2.name).toBe(result3.name);
|
||||
expect(result1.name).toBe(result3.name);
|
||||
});
|
||||
|
||||
test('should demonstrate the problem that deterministic sorting solves', () => {
|
||||
// This test verifies that alphabetical sorting ensures consistent results
|
||||
// regardless of the order in which connection types are processed
|
||||
|
||||
// Create a workflow with multiple AI connection types in a specific structure
|
||||
// that would trigger the non-deterministic behavior without sorting
|
||||
const workflow = new Workflow({
|
||||
nodeTypes,
|
||||
nodes: [
|
||||
{
|
||||
id: 'aiAgent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
typeVersion: 1.8,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
id: 'tool1',
|
||||
name: 'ZZZ Tool', // Intentionally named to come last alphabetically
|
||||
type: '@n8n/n8n-nodes-langchain.toolCalculator',
|
||||
typeVersion: 1,
|
||||
position: [100, 0],
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
id: 'tool2',
|
||||
name: 'AAA Tool', // Intentionally named to come first alphabetically
|
||||
type: '@n8n/n8n-nodes-langchain.toolWikipedia',
|
||||
typeVersion: 1,
|
||||
position: [200, 0],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
'ZZZ Tool': {
|
||||
[NodeConnectionTypes.AiTool]: [
|
||||
[{ node: 'AI Agent', type: NodeConnectionTypes.AiTool, index: 0 }],
|
||||
],
|
||||
},
|
||||
'AAA Tool': {
|
||||
[NodeConnectionTypes.AiTool]: [
|
||||
[{ node: 'AI Agent', type: NodeConnectionTypes.AiTool, index: 1 }],
|
||||
],
|
||||
},
|
||||
},
|
||||
active: false,
|
||||
});
|
||||
|
||||
const aiAgent = workflow.getNode('AI Agent')!;
|
||||
|
||||
// Test multiple times to ensure consistent behavior
|
||||
// Without proper sorting, the result could vary based on internal iteration order
|
||||
const results: string[] = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const result = workflow.getParentMainInputNode(aiAgent);
|
||||
results.push(result.name);
|
||||
}
|
||||
|
||||
// All results should be identical (proving deterministic sorting works)
|
||||
const uniqueResults = new Set(results);
|
||||
expect(uniqueResults.size).toBe(1);
|
||||
|
||||
// The result should be consistent across all calls
|
||||
const firstResult = results[0];
|
||||
results.forEach((result) => {
|
||||
expect(result).toBe(firstResult);
|
||||
});
|
||||
});
|
||||
|
||||
test('should explain why sorting is needed with real-world AI agent scenarios', () => {
|
||||
// Simulates a complex AI agent workflow - the exact type that was experiencing
|
||||
// non-deterministic behavior before the fix
|
||||
// This test documents the business value of the deterministic sorting fix
|
||||
|
||||
const workflow = new Workflow({
|
||||
nodeTypes,
|
||||
nodes: [
|
||||
{
|
||||
id: 'aiAgent1',
|
||||
name: 'ChatGPT Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
typeVersion: 1.8,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
id: 'calculatorTool',
|
||||
name: 'Calculator Tool',
|
||||
type: '@n8n/n8n-nodes-langchain.toolCalculator',
|
||||
typeVersion: 1,
|
||||
position: [100, 0],
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
id: 'wikipediaTool',
|
||||
name: 'Wikipedia Tool',
|
||||
type: '@n8n/n8n-nodes-langchain.toolWikipedia',
|
||||
typeVersion: 1,
|
||||
position: [100, 100],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
'Calculator Tool': {
|
||||
[NodeConnectionTypes.AiTool]: [
|
||||
[{ node: 'ChatGPT Agent', type: NodeConnectionTypes.AiTool, index: 0 }],
|
||||
],
|
||||
},
|
||||
'Wikipedia Tool': {
|
||||
[NodeConnectionTypes.AiTool]: [
|
||||
[{ node: 'ChatGPT Agent', type: NodeConnectionTypes.AiTool, index: 1 }],
|
||||
],
|
||||
},
|
||||
},
|
||||
active: false,
|
||||
});
|
||||
|
||||
const chatGPTAgent = workflow.getNode('ChatGPT Agent')!;
|
||||
|
||||
// Test what the original bug report described:
|
||||
// "AI agent node problem" with non-deterministic behavior
|
||||
const results = Array.from(
|
||||
{ length: 100 },
|
||||
() => workflow.getParentMainInputNode(chatGPTAgent).name,
|
||||
);
|
||||
|
||||
// The fix ensures all results are identical (deterministic)
|
||||
// Previously, this could return different tool nodes across runs
|
||||
expect(new Set(results).size).toBe(1);
|
||||
|
||||
// Additionally, verify that consecutive calls return the same result
|
||||
const result1 = workflow.getParentMainInputNode(chatGPTAgent);
|
||||
const result2 = workflow.getParentMainInputNode(chatGPTAgent);
|
||||
const result3 = workflow.getParentMainInputNode(chatGPTAgent);
|
||||
|
||||
expect(result1.name).toBe(result2.name);
|
||||
expect(result2.name).toBe(result3.name);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user