diff --git a/cypress/e2e/233-AI-switch-to-logs-on-error.cy.ts b/cypress/e2e/233-AI-switch-to-logs-on-error.cy.ts index 73ecf81679..217e70dbdb 100644 --- a/cypress/e2e/233-AI-switch-to-logs-on-error.cy.ts +++ b/cypress/e2e/233-AI-switch-to-logs-on-error.cy.ts @@ -75,6 +75,7 @@ function createRunDataWithError(inputMessage: string) { ], ], }, + source: [{ previousNode: AGENT_NODE_NAME, previousNodeRun: 0 }], error: { message: 'Internal error', timestamp: 1722591723244, diff --git a/cypress/e2e/30-langchain.cy.ts b/cypress/e2e/30-langchain.cy.ts index 6720a3f1ac..98e4ad1e44 100644 --- a/cypress/e2e/30-langchain.cy.ts +++ b/cypress/e2e/30-langchain.cy.ts @@ -253,6 +253,7 @@ describe('Langchain Integration', () => { metadata: { subRun: [{ node: AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, runIndex: 0 }], }, + source: [{ previousNode: AGENT_NODE_NAME, previousNodeRun: 0 }], inputOverride: { ai_languageModel: [ [ diff --git a/packages/core/src/execution-engine/node-execution-context/supply-data-context.ts b/packages/core/src/execution-engine/node-execution-context/supply-data-context.ts index d739313fa6..c6e4d35823 100644 --- a/packages/core/src/execution-engine/node-execution-context/supply-data-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/supply-data-context.ts @@ -16,6 +16,7 @@ import type { Workflow, WorkflowExecuteMode, NodeConnectionType, + ISourceData, } from 'n8n-workflow'; import { createDeferredPromise, NodeConnectionTypes } from 'n8n-workflow'; @@ -42,6 +43,8 @@ export class SupplyDataContext extends BaseExecuteContext implements ISupplyData readonly getNodeParameter: ISupplyDataFunctions['getNodeParameter']; + readonly parentNode?: INode; + constructor( workflow: Workflow, node: INode, @@ -55,6 +58,7 @@ export class SupplyDataContext extends BaseExecuteContext implements ISupplyData executeData: IExecuteData, private readonly closeFunctions: CloseFunction[], abortSignal?: AbortSignal, + parentNode?: INode, ) { super( workflow, @@ -69,6 +73,8 @@ export class SupplyDataContext extends BaseExecuteContext implements ISupplyData abortSignal, ); + this.parentNode = parentNode; + this.helpers = { createDeferredPromise, copyInputItems, @@ -126,6 +132,7 @@ export class SupplyDataContext extends BaseExecuteContext implements ISupplyData this.executeData, this.closeFunctions, this.abortSignal, + this.parentNode, ); context.addInputData(NodeConnectionTypes.AiTool, replacements.inputData); return context; @@ -230,13 +237,17 @@ export class SupplyDataContext extends BaseExecuteContext implements ISupplyData } = this; let taskData: ITaskData | undefined; + const source: ISourceData[] = this.parentNode + ? [{ previousNode: this.parentNode.name, previousNodeRun: sourceNodeRunIndex }] + : []; + if (type === 'input') { taskData = { startTime: Date.now(), executionTime: 0, executionIndex: additionalData.currentNodeExecutionIndex++, executionStatus: 'running', - source: [null], + source, }; } else { // At the moment we expect that there is always an input sent before the output @@ -249,6 +260,7 @@ export class SupplyDataContext extends BaseExecuteContext implements ISupplyData return; } taskData.metadata = metadata; + taskData.source = source; } taskData = taskData!; diff --git a/packages/core/src/execution-engine/node-execution-context/utils/get-input-connection-data.ts b/packages/core/src/execution-engine/node-execution-context/utils/get-input-connection-data.ts index d48a3fa06c..9123284f55 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/get-input-connection-data.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/get-input-connection-data.ts @@ -141,6 +141,7 @@ export async function getInputConnectionData( executeData, closeFunctions, abortSignal, + parentNode, ); if (!connectedNodeType.supplyData) { diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts index 0cab3469ed..ded3e13d76 100644 --- a/packages/frontend/editor-ui/src/Interface.ts +++ b/packages/frontend/editor-ui/src/Interface.ts @@ -43,6 +43,7 @@ import type { IPersonalizationSurveyAnswersV4, AnnotationVote, ITaskData, + ISourceData, } from 'n8n-workflow'; import type { @@ -189,6 +190,7 @@ export interface IAiDataContent { data: INodeExecutionData[] | null; inOut: 'input' | 'output'; type: NodeConnectionType; + source?: Array; metadata: { executionTime: number; startTime: number; diff --git a/packages/frontend/editor-ui/src/components/RunDataAi/utils.test.ts b/packages/frontend/editor-ui/src/components/RunDataAi/utils.test.ts index 731873c256..649c317f5b 100644 --- a/packages/frontend/editor-ui/src/components/RunDataAi/utils.test.ts +++ b/packages/frontend/editor-ui/src/components/RunDataAi/utils.test.ts @@ -189,6 +189,313 @@ describe(getTreeNodeData, () => { }, ]); }); + + it('should filter node executions based on source node', () => { + const workflowWithSharedSubNode = createTestWorkflowObject({ + nodes: [ + createTestNode({ name: 'RootNode1' }), + createTestNode({ name: 'RootNode2' }), + createTestNode({ name: 'SharedSubNode' }), + ], + connections: { + SharedSubNode: { + ai_tool: [ + [{ node: 'RootNode1', type: NodeConnectionTypes.AiTool, index: 0 }], + [{ node: 'RootNode2', type: NodeConnectionTypes.AiTool, index: 0 }], + ], + }, + }, + }); + + // Create test AI data with source information + const sharedSubNodeData1 = { + node: 'SharedSubNode', + runIndex: 0, + data: { + data: [{ json: { result: 'from RootNode1' } }], + inOut: 'output' as const, + type: NodeConnectionTypes.AiTool, + source: [{ previousNode: 'RootNode1', previousNodeRun: 0 }], + metadata: { + executionTime: 100, + startTime: Date.parse('2025-02-26T00:00:01.000Z'), + subExecution: undefined, + }, + }, + }; + + const sharedSubNodeData2 = { + node: 'SharedSubNode', + runIndex: 1, + data: { + data: [{ json: { result: 'from RootNode2' } }], + inOut: 'output' as const, + type: NodeConnectionTypes.AiTool, + source: [{ previousNode: 'RootNode2', previousNodeRun: 0 }], + metadata: { + executionTime: 100, + startTime: Date.parse('2025-02-26T00:00:02.000Z'), + subExecution: undefined, + }, + }, + }; + + // Create test AI data array + const aiData = [sharedSubNodeData1, sharedSubNodeData2]; + + // Test for RootNode1 - should only show SharedSubNode with source RootNode1 + const rootNode1Tree = getTreeNodeData('RootNode1', workflowWithSharedSubNode, aiData); + expect(rootNode1Tree[0].children.length).toBe(1); + expect(rootNode1Tree[0].children[0].node).toBe('SharedSubNode'); + expect(rootNode1Tree[0].children[0].runIndex).toBe(0); + + // Test for RootNode2 - should only show SharedSubNode with source RootNode2 + const rootNode2Tree = getTreeNodeData('RootNode2', workflowWithSharedSubNode, aiData); + expect(rootNode2Tree[0].children.length).toBe(1); + expect(rootNode2Tree[0].children[0].node).toBe('SharedSubNode'); + expect(rootNode2Tree[0].children[0].runIndex).toBe(1); + }); + + it('should filter node executions based on source run index', () => { + const workflow = createTestWorkflowObject({ + nodes: [createTestNode({ name: 'RootNode' }), createTestNode({ name: 'SubNode' })], + connections: { + SubNode: { + ai_tool: [[{ node: 'RootNode', type: NodeConnectionTypes.AiTool, index: 0 }]], + }, + }, + }); + + // Create test AI data with source information + const subNodeData1 = { + node: 'SubNode', + runIndex: 0, + data: { + data: [{ json: { result: 'from RootNode' } }], + inOut: 'output' as const, + type: NodeConnectionTypes.AiTool, + source: [{ previousNode: 'RootNode', previousNodeRun: 0 }], + metadata: { + executionTime: 100, + startTime: Date.parse('2025-02-26T00:00:01.000Z'), + subExecution: undefined, + }, + }, + }; + + const subNodeData2 = { + node: 'SubNode', + runIndex: 1, + data: { + data: [{ json: { result: 'from RootNode' } }], + inOut: 'output' as const, + type: NodeConnectionTypes.AiTool, + source: [{ previousNode: 'RootNode', previousNodeRun: 1 }], + metadata: { + executionTime: 100, + startTime: Date.parse('2025-02-26T00:00:02.000Z'), + subExecution: undefined, + }, + }, + }; + + // Create test AI data array + const aiData = [subNodeData1, subNodeData2]; + + // Test for run #1 of RootNode - should only show SubNode with source run index 0 + const rootNode1Tree = getTreeNodeData('RootNode', workflow, aiData, 0); + expect(rootNode1Tree[0].children.length).toBe(1); + expect(rootNode1Tree[0].children[0].node).toBe('SubNode'); + expect(rootNode1Tree[0].children[0].runIndex).toBe(0); + + // Test for run #2 of RootNode - should only show SubNode with source run index 1 + const rootNode2Tree = getTreeNodeData('RootNode', workflow, aiData, 1); + expect(rootNode2Tree[0].children.length).toBe(1); + expect(rootNode2Tree[0].children[0].node).toBe('SubNode'); + expect(rootNode2Tree[0].children[0].runIndex).toBe(1); + }); + + it('should include nodes without source information', () => { + const workflow = createTestWorkflowObject({ + nodes: [createTestNode({ name: 'RootNode' }), createTestNode({ name: 'SubNode' })], + connections: { + SubNode: { + ai_tool: [[{ node: 'RootNode', type: NodeConnectionTypes.AiTool, index: 0 }]], + }, + }, + }); + + // Create test AI data with a node that has no source information + const subNodeData = { + node: 'SubNode', + runIndex: 0, + data: { + data: [{ json: { result: 'from RootNode' } }], + inOut: 'output' as const, + type: NodeConnectionTypes.AiTool, + // No source field intentionally + metadata: { + executionTime: 100, + startTime: Date.parse('2025-02-26T00:00:01.000Z'), + subExecution: undefined, + }, + }, + }; + + // Create test AI data array + const aiData = [subNodeData]; + + // Test for RootNode - should still show SubNode even without source info + const rootNodeTree = getTreeNodeData('RootNode', workflow, aiData); + expect(rootNodeTree[0].children.length).toBe(1); + expect(rootNodeTree[0].children[0].node).toBe('SubNode'); + expect(rootNodeTree[0].children[0].runIndex).toBe(0); + }); + + it('should include nodes with empty source array', () => { + const workflow = createTestWorkflowObject({ + nodes: [createTestNode({ name: 'RootNode' }), createTestNode({ name: 'SubNode' })], + connections: { + SubNode: { + ai_tool: [[{ node: 'RootNode', type: NodeConnectionTypes.AiTool, index: 0 }]], + }, + }, + }); + + // Create test AI data with a node that has empty source array + const subNodeData = { + node: 'SubNode', + runIndex: 0, + data: { + data: [{ json: { result: 'from RootNode' } }], + inOut: 'output' as const, + type: NodeConnectionTypes.AiTool, + source: [], // Empty array + metadata: { + executionTime: 100, + startTime: Date.parse('2025-02-26T00:00:01.000Z'), + subExecution: undefined, + }, + }, + }; + + // Create test AI data array + const aiData = [subNodeData]; + + // Test for RootNode - should still show SubNode even with empty source array + const rootNodeTree = getTreeNodeData('RootNode', workflow, aiData); + expect(rootNodeTree[0].children.length).toBe(1); + expect(rootNodeTree[0].children[0].node).toBe('SubNode'); + expect(rootNodeTree[0].children[0].runIndex).toBe(0); + }); + + it('should filter deeper nested nodes based on source', () => { + const workflow = createTestWorkflowObject({ + nodes: [ + createTestNode({ name: 'RootNode1' }), + createTestNode({ name: 'RootNode2' }), + createTestNode({ name: 'SharedSubNode' }), + createTestNode({ name: 'DeepSubNode' }), + ], + connections: { + SharedSubNode: { + ai_tool: [ + [{ node: 'RootNode1', type: NodeConnectionTypes.AiTool, index: 0 }], + [{ node: 'RootNode2', type: NodeConnectionTypes.AiTool, index: 0 }], + ], + }, + DeepSubNode: { + ai_tool: [[{ node: 'SharedSubNode', type: NodeConnectionTypes.AiTool, index: 0 }]], + }, + }, + }); + + // Create test AI data with source information + const sharedSubNodeData1 = { + node: 'SharedSubNode', + runIndex: 0, + data: { + data: [{ json: { result: 'from RootNode1' } }], + inOut: 'output' as const, + type: NodeConnectionTypes.AiTool, + source: [{ previousNode: 'RootNode1', previousNodeRun: 0 }], + metadata: { + executionTime: 100, + startTime: Date.parse('2025-02-26T00:00:01.000Z'), + subExecution: undefined, + }, + }, + }; + + const sharedSubNodeData2 = { + node: 'SharedSubNode', + runIndex: 1, + data: { + data: [{ json: { result: 'from RootNode2' } }], + inOut: 'output' as const, + type: NodeConnectionTypes.AiTool, + source: [{ previousNode: 'RootNode2', previousNodeRun: 0 }], + metadata: { + executionTime: 100, + startTime: Date.parse('2025-02-26T00:00:02.000Z'), + subExecution: undefined, + }, + }, + }; + + const deepSubNodeData1 = { + node: 'DeepSubNode', + runIndex: 0, + data: { + data: [{ json: { result: 'from SharedSubNode run 0' } }], + inOut: 'output' as const, + type: NodeConnectionTypes.AiTool, + source: [{ previousNode: 'SharedSubNode', previousNodeRun: 0 }], + metadata: { + executionTime: 100, + startTime: Date.parse('2025-02-26T00:00:03.000Z'), + subExecution: undefined, + }, + }, + }; + + const deepSubNodeData2 = { + node: 'DeepSubNode', + runIndex: 1, + data: { + data: [{ json: { result: 'from SharedSubNode run 1' } }], + inOut: 'output' as const, + type: NodeConnectionTypes.AiTool, + source: [{ previousNode: 'SharedSubNode', previousNodeRun: 1 }], + metadata: { + executionTime: 100, + startTime: Date.parse('2025-02-26T00:00:04.000Z'), + subExecution: undefined, + }, + }, + }; + + // Create test AI data array + const aiData = [sharedSubNodeData1, sharedSubNodeData2, deepSubNodeData1, deepSubNodeData2]; + + // Test filtering for RootNode1 + const rootNode1Tree = getTreeNodeData('RootNode1', workflow, aiData); + expect(rootNode1Tree[0].children.length).toBe(1); + expect(rootNode1Tree[0].children[0].node).toBe('SharedSubNode'); + expect(rootNode1Tree[0].children[0].runIndex).toBe(0); + expect(rootNode1Tree[0].children[0].children.length).toBe(1); + expect(rootNode1Tree[0].children[0].children[0].node).toBe('DeepSubNode'); + expect(rootNode1Tree[0].children[0].children[0].runIndex).toBe(0); + + // Test filtering for RootNode2 + const rootNode2Tree = getTreeNodeData('RootNode2', workflow, aiData); + expect(rootNode2Tree[0].children.length).toBe(1); + expect(rootNode2Tree[0].children[0].node).toBe('SharedSubNode'); + expect(rootNode2Tree[0].children[0].runIndex).toBe(1); + expect(rootNode2Tree[0].children[0].children.length).toBe(1); + expect(rootNode2Tree[0].children[0].children[0].node).toBe('DeepSubNode'); + expect(rootNode2Tree[0].children[0].children[0].runIndex).toBe(1); + }); }); describe(getTreeNodeDataV2, () => { @@ -321,6 +628,358 @@ describe(getTreeNodeDataV2, () => { }, ]); }); + + it('should filter node executions based on source node', () => { + const workflow = createTestWorkflowObject({ + nodes: [ + createTestNode({ name: 'RootNode1' }), + createTestNode({ name: 'RootNode2' }), + createTestNode({ name: 'SharedSubNode' }), + ], + connections: { + SharedSubNode: { + ai_tool: [ + [{ node: 'RootNode1', type: NodeConnectionTypes.AiTool, index: 0 }], + [{ node: 'RootNode2', type: NodeConnectionTypes.AiTool, index: 0 }], + ], + }, + }, + }); + + // Create test run data with source information + const runData = { + RootNode1: [ + createTestTaskData({ + startTime: Date.parse('2025-02-26T00:00:00.000Z'), + executionIndex: 0, + }), + ], + RootNode2: [ + createTestTaskData({ + startTime: Date.parse('2025-02-26T00:00:01.000Z'), + executionIndex: 1, + }), + ], + SharedSubNode: [ + createTestTaskData({ + startTime: Date.parse('2025-02-26T00:00:02.000Z'), + executionIndex: 2, + source: [{ previousNode: 'RootNode1', previousNodeRun: 0 }], + data: { main: [[{ json: { result: 'from RootNode1' } }]] }, + }), + createTestTaskData({ + startTime: Date.parse('2025-02-26T00:00:03.000Z'), + executionIndex: 3, + source: [{ previousNode: 'RootNode2', previousNodeRun: 1 }], + data: { main: [[{ json: { result: 'from RootNode2' } }]] }, + }), + ], + }; + + // Test for RootNode1 - should only show SharedSubNode with source RootNode1 + const rootNode1Tree = getTreeNodeDataV2('RootNode1', runData.RootNode1[0], workflow, runData); + expect(rootNode1Tree[0].children.length).toBe(1); + expect(rootNode1Tree[0].children[0].node.name).toBe('SharedSubNode'); + expect(rootNode1Tree[0].children[0].runIndex).toBe(0); + + // Test for RootNode2 - should only show SharedSubNode with source RootNode2 + const rootNode2Tree = getTreeNodeDataV2('RootNode2', runData.RootNode2[0], workflow, runData); + expect(rootNode2Tree[0].children.length).toBe(1); + expect(rootNode2Tree[0].children[0].node.name).toBe('SharedSubNode'); + expect(rootNode2Tree[0].children[0].runIndex).toBe(1); + }); + + it('should filter node executions based on source run index', () => { + const workflow = createTestWorkflowObject({ + nodes: [createTestNode({ name: 'RootNode' }), createTestNode({ name: 'SubNode' })], + connections: { + SubNode: { + ai_tool: [[{ node: 'RootNode', type: NodeConnectionTypes.AiTool, index: 0 }]], + }, + }, + }); + + // Create test run data with source information + const runData = { + RootNode: [ + createTestTaskData({ + startTime: Date.parse('2025-02-26T00:00:00.000Z'), + executionIndex: 0, + }), + createTestTaskData({ + startTime: Date.parse('2025-02-26T00:00:02.000Z'), + executionIndex: 2, + }), + ], + SubNode: [ + createTestTaskData({ + startTime: Date.parse('2025-02-26T00:00:01.000Z'), + executionIndex: 1, + }), + createTestTaskData({ + startTime: Date.parse('2025-02-26T00:00:03.000Z'), + executionIndex: 3, + }), + ], + }; + + // Test for run #1 of RootNode - should only show SubNode with source run index 0 + const rootNode1Tree = getTreeNodeDataV2('RootNode', runData.RootNode[0], workflow, runData, 0); + expect(rootNode1Tree[0].children.length).toBe(1); + expect(rootNode1Tree[0].children[0].node.name).toBe('SubNode'); + expect(rootNode1Tree[0].children[0].runIndex).toBe(0); + + // Test for run #2 of RootNode - should only show SubNode with source run index 1 + const rootNode2Tree = getTreeNodeDataV2('RootNode', runData.RootNode[1], workflow, runData, 1); + expect(rootNode2Tree[0].children.length).toBe(1); + expect(rootNode2Tree[0].children[0].node.name).toBe('SubNode'); + expect(rootNode2Tree[0].children[0].runIndex).toBe(1); + }); + + it('should include nodes without source information (v2)', () => { + const workflow = createTestWorkflowObject({ + nodes: [createTestNode({ name: 'RootNode' }), createTestNode({ name: 'SubNode' })], + connections: { + SubNode: { + ai_tool: [[{ node: 'RootNode', type: NodeConnectionTypes.AiTool, index: 0 }]], + }, + }, + }); + + // Create test run data with a node that has no source field + const runData = { + RootNode: [ + createTestTaskData({ + startTime: Date.parse('2025-02-26T00:00:00.000Z'), + executionIndex: 0, + }), + ], + SubNode: [ + createTestTaskData({ + startTime: Date.parse('2025-02-26T00:00:01.000Z'), + executionIndex: 1, + // No source field + data: { main: [[{ json: { result: 'from RootNode' } }]] }, + }), + ], + }; + + // Test for RootNode - should still show SubNode even without source info + const rootNodeTree = getTreeNodeDataV2('RootNode', runData.RootNode[0], workflow, runData); + expect(rootNodeTree[0].children.length).toBe(1); + expect(rootNodeTree[0].children[0].node.name).toBe('SubNode'); + expect(rootNodeTree[0].children[0].runIndex).toBe(0); + }); + + it('should include nodes with empty source array (v2)', () => { + const workflow = createTestWorkflowObject({ + nodes: [createTestNode({ name: 'RootNode' }), createTestNode({ name: 'SubNode' })], + connections: { + SubNode: { + ai_tool: [[{ node: 'RootNode', type: NodeConnectionTypes.AiTool, index: 0 }]], + }, + }, + }); + + // Create test run data with a node that has empty source array + const runData = { + RootNode: [ + createTestTaskData({ + startTime: Date.parse('2025-02-26T00:00:00.000Z'), + executionIndex: 0, + }), + ], + SubNode: [ + createTestTaskData({ + startTime: Date.parse('2025-02-26T00:00:01.000Z'), + executionIndex: 1, + source: [], // Empty array + data: { main: [[{ json: { result: 'from RootNode' } }]] }, + }), + ], + }; + + // Test for RootNode - should still show SubNode even with empty source array + const rootNodeTree = getTreeNodeDataV2('RootNode', runData.RootNode[0], workflow, runData); + expect(rootNodeTree[0].children.length).toBe(1); + expect(rootNodeTree[0].children[0].node.name).toBe('SubNode'); + expect(rootNodeTree[0].children[0].runIndex).toBe(0); + }); + + it('should filter deeper nested nodes based on source (v2)', () => { + const workflow = createTestWorkflowObject({ + nodes: [ + createTestNode({ name: 'RootNode1' }), + createTestNode({ name: 'RootNode2' }), + createTestNode({ name: 'SharedSubNode' }), + createTestNode({ name: 'DeepSubNode' }), + ], + connections: { + SharedSubNode: { + ai_tool: [ + [{ node: 'RootNode1', type: NodeConnectionTypes.AiTool, index: 0 }], + [{ node: 'RootNode2', type: NodeConnectionTypes.AiTool, index: 0 }], + ], + }, + DeepSubNode: { + ai_tool: [[{ node: 'SharedSubNode', type: NodeConnectionTypes.AiTool, index: 0 }]], + }, + }, + }); + + // Create test run data with source information + const runData = { + RootNode1: [ + createTestTaskData({ + startTime: Date.parse('2025-02-26T00:00:00.000Z'), + executionIndex: 0, + }), + ], + RootNode2: [ + createTestTaskData({ + startTime: Date.parse('2025-02-26T00:00:01.000Z'), + executionIndex: 1, + }), + ], + SharedSubNode: [ + createTestTaskData({ + startTime: Date.parse('2025-02-26T00:00:02.000Z'), + executionIndex: 2, + source: [{ previousNode: 'RootNode1' }], + data: { main: [[{ json: { result: 'from RootNode1' } }]] }, + }), + createTestTaskData({ + startTime: Date.parse('2025-02-26T00:00:03.000Z'), + executionIndex: 3, + source: [{ previousNode: 'RootNode2' }], + data: { main: [[{ json: { result: 'from RootNode2' } }]] }, + }), + ], + DeepSubNode: [ + createTestTaskData({ + startTime: Date.parse('2025-02-26T00:00:04.000Z'), + executionIndex: 4, + source: [{ previousNode: 'SharedSubNode' }], + data: { main: [[{ json: { result: 'from SharedSubNode run 0' } }]] }, + }), + createTestTaskData({ + startTime: Date.parse('2025-02-26T00:00:05.000Z'), + executionIndex: 5, + source: [{ previousNode: 'SharedSubNode' }], + data: { main: [[{ json: { result: 'from SharedSubNode run 1' } }]] }, + }), + ], + }; + + // Test filtering for RootNode1 + const rootNode1Tree = getTreeNodeDataV2('RootNode1', runData.RootNode1[0], workflow, runData); + expect(rootNode1Tree[0].children.length).toBe(1); + expect(rootNode1Tree[0].children[0].node.name).toBe('SharedSubNode'); + expect(rootNode1Tree[0].children[0].runIndex).toBe(0); + expect(rootNode1Tree[0].children[0].children.length).toBe(1); + expect(rootNode1Tree[0].children[0].children[0].node.name).toBe('DeepSubNode'); + expect(rootNode1Tree[0].children[0].children[0].runIndex).toBe(0); + + // Test filtering for RootNode2 + const rootNode2Tree = getTreeNodeDataV2('RootNode2', runData.RootNode2[0], workflow, runData); + expect(rootNode2Tree[0].children.length).toBe(1); + expect(rootNode2Tree[0].children[0].node.name).toBe('SharedSubNode'); + expect(rootNode2Tree[0].children[0].runIndex).toBe(1); + expect(rootNode2Tree[0].children[0].children.length).toBe(1); + expect(rootNode2Tree[0].children[0].children[0].node.name).toBe('DeepSubNode'); + expect(rootNode2Tree[0].children[0].children[0].runIndex).toBe(1); + }); + + it('should handle complex tree with multiple branches and filters correctly', () => { + const workflow = createTestWorkflowObject({ + nodes: [ + createTestNode({ name: 'RootNode1' }), + createTestNode({ name: 'RootNode2' }), + createTestNode({ name: 'SubNodeA' }), + createTestNode({ name: 'SubNodeB' }), + createTestNode({ name: 'DeepNode' }), + ], + connections: { + SubNodeA: { + ai_tool: [[{ node: 'RootNode1', type: NodeConnectionTypes.AiTool, index: 0 }]], + }, + SubNodeB: { + ai_tool: [[{ node: 'RootNode2', type: NodeConnectionTypes.AiTool, index: 0 }]], + }, + DeepNode: { + ai_tool: [ + [{ node: 'SubNodeA', type: NodeConnectionTypes.AiTool, index: 0 }], + [{ node: 'SubNodeB', type: NodeConnectionTypes.AiTool, index: 0 }], + ], + }, + }, + }); + + // Create test run data with source information + const runData = { + RootNode1: [ + createTestTaskData({ + startTime: Date.parse('2025-02-26T00:00:00.000Z'), + executionIndex: 0, + }), + ], + RootNode2: [ + createTestTaskData({ + startTime: Date.parse('2025-02-26T00:00:01.000Z'), + executionIndex: 1, + }), + ], + SubNodeA: [ + createTestTaskData({ + startTime: Date.parse('2025-02-26T00:00:02.000Z'), + executionIndex: 2, + source: [{ previousNode: 'RootNode1' }], + data: { main: [[{ json: { result: 'from RootNode1' } }]] }, + }), + ], + SubNodeB: [ + createTestTaskData({ + startTime: Date.parse('2025-02-26T00:00:03.000Z'), + executionIndex: 3, + source: [{ previousNode: 'RootNode2' }], + data: { main: [[{ json: { result: 'from RootNode2' } }]] }, + }), + ], + DeepNode: [ + createTestTaskData({ + startTime: Date.parse('2025-02-26T00:00:04.000Z'), + executionIndex: 4, + source: [{ previousNode: 'SubNodeA' }], + data: { main: [[{ json: { result: 'from SubNodeA' } }]] }, + }), + createTestTaskData({ + startTime: Date.parse('2025-02-26T00:00:05.000Z'), + executionIndex: 5, + source: [{ previousNode: 'SubNodeB' }], + data: { main: [[{ json: { result: 'from SubNodeB' } }]] }, + }), + ], + }; + + // Test filtering for RootNode1 -> SubNodeA -> DeepNode + const rootNode1Tree = getTreeNodeDataV2('RootNode1', runData.RootNode1[0], workflow, runData); + expect(rootNode1Tree[0].children.length).toBe(1); + expect(rootNode1Tree[0].children[0].node.name).toBe('SubNodeA'); + expect(rootNode1Tree[0].children[0].children.length).toBe(1); + expect(rootNode1Tree[0].children[0].children[0].node.name).toBe('DeepNode'); + expect(rootNode1Tree[0].children[0].children[0].runIndex).toBe(0); // First DeepNode execution + + // Test filtering for RootNode2 -> SubNodeB -> DeepNode + const rootNode2Tree = getTreeNodeDataV2('RootNode2', runData.RootNode2[0], workflow, runData); + + expect(rootNode2Tree[0].children.length).toBe(1); + expect(rootNode2Tree[0].children[0].node.name).toBe('SubNodeB'); + expect(rootNode2Tree[0].children[0].children.length).toBe(1); + expect(rootNode2Tree[0].children[0].children[0].node.name).toBe('DeepNode'); + + const deepNodeRunIndex = rootNode2Tree[0].children[0].children[0].runIndex; + expect(typeof deepNodeRunIndex).toBe('number'); + }); }); describe(findSelectedLogEntry, () => { diff --git a/packages/frontend/editor-ui/src/components/RunDataAi/utils.ts b/packages/frontend/editor-ui/src/components/RunDataAi/utils.ts index 75d80b042b..b8ac4d4acd 100644 --- a/packages/frontend/editor-ui/src/components/RunDataAi/utils.ts +++ b/packages/frontend/editor-ui/src/components/RunDataAi/utils.ts @@ -80,13 +80,31 @@ function getTreeNodeDataRec( return resultData.map((d) => createNode(parent, nodeName, currentDepth, d.runIndex, d)); } + // When at root depth, filter AI data to only show executions that were triggered by this node + // This prevents duplicate entries in logs when a sub-node is connected to multiple root nodes + // Nodes without source info or with empty source arrays are always included + const filteredAiData = + currentDepth === 0 + ? aiData?.filter(({ data }) => { + if (!data?.source || data.source.length === 0) { + return true; + } + + return data.source.some( + (source) => + source?.previousNode === nodeName && + (runIndex === undefined || source.previousNodeRun === runIndex), + ); + }) + : aiData; + // Get the first level of children const connectedSubNodes = workflow.getParentNodes(nodeName, 'ALL_NON_MAIN', 1); const treeNode = createNode(parent, nodeName, currentDepth, runIndex ?? 0); // Only include sub-nodes which have data - const children = (aiData ?? []).flatMap((data) => + const children = (filteredAiData ?? []).flatMap((data) => connectedSubNodes.includes(data.node) && (runIndex === undefined || data.runIndex === runIndex) ? getTreeNodeDataRec(treeNode, data.node, currentDepth + 1, workflow, aiData, data.runIndex) : [], @@ -152,6 +170,9 @@ export function getReferencedData( data: data[type][0], inOut, type: type as NodeConnectionType, + // Include source information in AI content to track which node triggered the execution + // This enables filtering in the UI to show only relevant executions + source: taskData.source, metadata: { executionTime: taskData.executionTime, startTime: taskData.startTime, @@ -310,10 +331,23 @@ function getTreeNodeDataRecV2( // Get the first level of children const connectedSubNodes = workflow.getParentNodes(node.name, 'ALL_NON_MAIN', 1); const treeNode = createNodeV2(parent, node, currentDepth, runIndex ?? 0, runData); + const children = connectedSubNodes .flatMap((subNodeName) => (data[subNodeName] ?? []).flatMap((t, index) => { - if (runIndex !== undefined && index !== runIndex) { + // At root depth, filter out node executions that weren't triggered by this node + // This prevents showing duplicate executions when a sub-node is connected to multiple parents + // Only filter nodes that have source information with valid previousNode references + const isMatched = + currentDepth === 0 && t.source?.length > 0 + ? t.source.some( + (source) => + source?.previousNode === node.name && + (runIndex === undefined || source.previousNodeRun === runIndex), + ) + : runIndex === undefined || index === runIndex; + + if (!isMatched) { return []; } @@ -387,7 +421,12 @@ export function createLogEntries(workflow: Workflow, runData: IRunData) { workflow.getChildNodes(nodeName, 'ALL_NON_MAIN').length > 0 || workflow.getNode(nodeName)?.disabled ? [] // skip sub nodes and disabled nodes - : taskData.map((task, runIndex) => ({ nodeName, task, runIndex })), + : taskData.map((task, runIndex) => ({ + nodeName, + task, + runIndex, + nodeHasMultipleRuns: taskData.length > 1, + })), ) .sort((a, b) => { if (a.task.executionIndex !== undefined && b.task.executionIndex !== undefined) { @@ -399,13 +438,15 @@ export function createLogEntries(workflow: Workflow, runData: IRunData) { : a.task.startTime - b.task.startTime; }); - return runs.flatMap(({ nodeName, runIndex, task }) => { - if (workflow.getParentNodes(nodeName, 'ALL_NON_MAIN').length > 0) { - return getTreeNodeDataV2(nodeName, task, workflow, runData, undefined); - } - - return getTreeNodeDataV2(nodeName, task, workflow, runData, runIndex); - }); + return runs.flatMap(({ nodeName, runIndex, task, nodeHasMultipleRuns }) => + getTreeNodeDataV2( + nodeName, + task, + workflow, + runData, + nodeHasMultipleRuns ? runIndex : undefined, + ), + ); } export function includesLogEntry(log: LogEntry, logs: LogEntry[]): boolean {