fix(editor): Fix AI Node Logs View to Filter Duplicate Executions (#15049)

Co-authored-by: autologie <suguru@n8n.io>
This commit is contained in:
oleg
2025-05-07 08:55:58 +02:00
committed by GitHub
parent 51190255c8
commit 86807978c1
7 changed files with 728 additions and 11 deletions

View File

@@ -75,6 +75,7 @@ function createRunDataWithError(inputMessage: string) {
], ],
], ],
}, },
source: [{ previousNode: AGENT_NODE_NAME, previousNodeRun: 0 }],
error: { error: {
message: 'Internal error', message: 'Internal error',
timestamp: 1722591723244, timestamp: 1722591723244,

View File

@@ -253,6 +253,7 @@ describe('Langchain Integration', () => {
metadata: { metadata: {
subRun: [{ node: AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, runIndex: 0 }], subRun: [{ node: AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, runIndex: 0 }],
}, },
source: [{ previousNode: AGENT_NODE_NAME, previousNodeRun: 0 }],
inputOverride: { inputOverride: {
ai_languageModel: [ ai_languageModel: [
[ [

View File

@@ -16,6 +16,7 @@ import type {
Workflow, Workflow,
WorkflowExecuteMode, WorkflowExecuteMode,
NodeConnectionType, NodeConnectionType,
ISourceData,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { createDeferredPromise, NodeConnectionTypes } 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 getNodeParameter: ISupplyDataFunctions['getNodeParameter'];
readonly parentNode?: INode;
constructor( constructor(
workflow: Workflow, workflow: Workflow,
node: INode, node: INode,
@@ -55,6 +58,7 @@ export class SupplyDataContext extends BaseExecuteContext implements ISupplyData
executeData: IExecuteData, executeData: IExecuteData,
private readonly closeFunctions: CloseFunction[], private readonly closeFunctions: CloseFunction[],
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
parentNode?: INode,
) { ) {
super( super(
workflow, workflow,
@@ -69,6 +73,8 @@ export class SupplyDataContext extends BaseExecuteContext implements ISupplyData
abortSignal, abortSignal,
); );
this.parentNode = parentNode;
this.helpers = { this.helpers = {
createDeferredPromise, createDeferredPromise,
copyInputItems, copyInputItems,
@@ -126,6 +132,7 @@ export class SupplyDataContext extends BaseExecuteContext implements ISupplyData
this.executeData, this.executeData,
this.closeFunctions, this.closeFunctions,
this.abortSignal, this.abortSignal,
this.parentNode,
); );
context.addInputData(NodeConnectionTypes.AiTool, replacements.inputData); context.addInputData(NodeConnectionTypes.AiTool, replacements.inputData);
return context; return context;
@@ -230,13 +237,17 @@ export class SupplyDataContext extends BaseExecuteContext implements ISupplyData
} = this; } = this;
let taskData: ITaskData | undefined; let taskData: ITaskData | undefined;
const source: ISourceData[] = this.parentNode
? [{ previousNode: this.parentNode.name, previousNodeRun: sourceNodeRunIndex }]
: [];
if (type === 'input') { if (type === 'input') {
taskData = { taskData = {
startTime: Date.now(), startTime: Date.now(),
executionTime: 0, executionTime: 0,
executionIndex: additionalData.currentNodeExecutionIndex++, executionIndex: additionalData.currentNodeExecutionIndex++,
executionStatus: 'running', executionStatus: 'running',
source: [null], source,
}; };
} else { } else {
// At the moment we expect that there is always an input sent before the output // 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; return;
} }
taskData.metadata = metadata; taskData.metadata = metadata;
taskData.source = source;
} }
taskData = taskData!; taskData = taskData!;

View File

@@ -141,6 +141,7 @@ export async function getInputConnectionData(
executeData, executeData,
closeFunctions, closeFunctions,
abortSignal, abortSignal,
parentNode,
); );
if (!connectedNodeType.supplyData) { if (!connectedNodeType.supplyData) {

View File

@@ -43,6 +43,7 @@ import type {
IPersonalizationSurveyAnswersV4, IPersonalizationSurveyAnswersV4,
AnnotationVote, AnnotationVote,
ITaskData, ITaskData,
ISourceData,
} from 'n8n-workflow'; } from 'n8n-workflow';
import type { import type {
@@ -189,6 +190,7 @@ export interface IAiDataContent {
data: INodeExecutionData[] | null; data: INodeExecutionData[] | null;
inOut: 'input' | 'output'; inOut: 'input' | 'output';
type: NodeConnectionType; type: NodeConnectionType;
source?: Array<ISourceData | null>;
metadata: { metadata: {
executionTime: number; executionTime: number;
startTime: number; startTime: number;

View File

@@ -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, () => { 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, () => { describe(findSelectedLogEntry, () => {

View File

@@ -80,13 +80,31 @@ function getTreeNodeDataRec(
return resultData.map((d) => createNode(parent, nodeName, currentDepth, d.runIndex, d)); 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 // Get the first level of children
const connectedSubNodes = workflow.getParentNodes(nodeName, 'ALL_NON_MAIN', 1); const connectedSubNodes = workflow.getParentNodes(nodeName, 'ALL_NON_MAIN', 1);
const treeNode = createNode(parent, nodeName, currentDepth, runIndex ?? 0); const treeNode = createNode(parent, nodeName, currentDepth, runIndex ?? 0);
// Only include sub-nodes which have data // 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) connectedSubNodes.includes(data.node) && (runIndex === undefined || data.runIndex === runIndex)
? getTreeNodeDataRec(treeNode, data.node, currentDepth + 1, workflow, aiData, data.runIndex) ? getTreeNodeDataRec(treeNode, data.node, currentDepth + 1, workflow, aiData, data.runIndex)
: [], : [],
@@ -152,6 +170,9 @@ export function getReferencedData(
data: data[type][0], data: data[type][0],
inOut, inOut,
type: type as NodeConnectionType, 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: { metadata: {
executionTime: taskData.executionTime, executionTime: taskData.executionTime,
startTime: taskData.startTime, startTime: taskData.startTime,
@@ -310,10 +331,23 @@ function getTreeNodeDataRecV2(
// Get the first level of children // Get the first level of children
const connectedSubNodes = workflow.getParentNodes(node.name, 'ALL_NON_MAIN', 1); const connectedSubNodes = workflow.getParentNodes(node.name, 'ALL_NON_MAIN', 1);
const treeNode = createNodeV2(parent, node, currentDepth, runIndex ?? 0, runData); const treeNode = createNodeV2(parent, node, currentDepth, runIndex ?? 0, runData);
const children = connectedSubNodes const children = connectedSubNodes
.flatMap((subNodeName) => .flatMap((subNodeName) =>
(data[subNodeName] ?? []).flatMap((t, index) => { (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 []; return [];
} }
@@ -387,7 +421,12 @@ export function createLogEntries(workflow: Workflow, runData: IRunData) {
workflow.getChildNodes(nodeName, 'ALL_NON_MAIN').length > 0 || workflow.getChildNodes(nodeName, 'ALL_NON_MAIN').length > 0 ||
workflow.getNode(nodeName)?.disabled workflow.getNode(nodeName)?.disabled
? [] // skip sub nodes and disabled nodes ? [] // 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) => { .sort((a, b) => {
if (a.task.executionIndex !== undefined && b.task.executionIndex !== undefined) { 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; : a.task.startTime - b.task.startTime;
}); });
return runs.flatMap(({ nodeName, runIndex, task }) => { return runs.flatMap(({ nodeName, runIndex, task, nodeHasMultipleRuns }) =>
if (workflow.getParentNodes(nodeName, 'ALL_NON_MAIN').length > 0) { getTreeNodeDataV2(
return getTreeNodeDataV2(nodeName, task, workflow, runData, undefined); nodeName,
} task,
workflow,
return getTreeNodeDataV2(nodeName, task, workflow, runData, runIndex); runData,
}); nodeHasMultipleRuns ? runIndex : undefined,
),
);
} }
export function includesLogEntry(log: LogEntry, logs: LogEntry[]): boolean { export function includesLogEntry(log: LogEntry, logs: LogEntry[]): boolean {