From d366cb4f37eacc422cafc7795180837a3f1087e8 Mon Sep 17 00:00:00 2001 From: oleg Date: Tue, 25 Nov 2025 12:02:14 +0100 Subject: [PATCH] fix(editor): Fix sub-nodes connection labels counters (#21549) Signed-off-by: Oleg Ivaniv --- .../features/workflows/canvas/canvas.types.ts | 6 + .../composables/useCanvasMapping.test.ts | 484 +++++++++++++++++- .../canvas/composables/useCanvasMapping.ts | 102 +++- 3 files changed, 581 insertions(+), 11 deletions(-) diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/canvas.types.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/canvas.types.ts index 7aea4861eee..41a027aa67a 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/canvas.types.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/canvas.types.ts @@ -221,6 +221,12 @@ export type CanvasNodeMoveEvent = { id: string; position: CanvasNode['position'] export type ExecutionOutputMapData = { total: number; iterations: number; + byTarget?: { + [targetNodeId: string]: { + total: number; + iterations: number; + }; + }; }; export type ExecutionOutputMap = { diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.test.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.test.ts index 02bc1194265..b94645efb57 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.test.ts @@ -549,6 +549,7 @@ describe('useCanvasMapping', () => { 0: { iterations: 1, total: 2, + byTarget: {}, }, }, }, @@ -729,6 +730,175 @@ describe('useCanvasMapping', () => { }, }); }); + + it('should populate byTarget field for non-main connections with per-target counts', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const modelNode = createTestNode({ name: 'OpenAI Chat Model' }); + const agent1Node = createTestNode({ name: 'AI Agent 1' }); + const agent2Node = createTestNode({ name: 'AI Agent 2' }); + const nodes = [modelNode, agent1Node, agent2Node]; + const connections = { + [modelNode.name]: { + [NodeConnectionTypes.AiLanguageModel]: [ + [ + { node: agent1Node.name, type: NodeConnectionTypes.AiLanguageModel, index: 0 }, + { node: agent2Node.name, type: NodeConnectionTypes.AiLanguageModel, index: 0 }, + ], + ], + }, + }; + const workflowObject = createTestWorkflowObject({ + nodes, + connections, + }); + + // Model node has multiple executions from different sources + workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { + if (nodeName === modelNode.name) { + return [ + { + startTime: 0, + executionTime: 0, + executionIndex: 0, + source: [{ previousNode: agent1Node.name }], + executionStatus: 'success', + data: { + [NodeConnectionTypes.AiLanguageModel]: [[{ json: {} }, { json: {} }]], + }, + }, + { + startTime: 0, + executionTime: 0, + executionIndex: 1, + source: [{ previousNode: agent2Node.name }], + executionStatus: 'success', + data: { + [NodeConnectionTypes.AiLanguageModel]: [[{ json: {} }]], + }, + }, + { + startTime: 0, + executionTime: 0, + executionIndex: 2, + source: [{ previousNode: agent1Node.name }], + executionStatus: 'success', + data: { + [NodeConnectionTypes.AiLanguageModel]: [ + [{ json: {} }, { json: {} }, { json: {} }], + ], + }, + }, + ]; + } + return null; + }); + + const { nodeExecutionRunDataOutputMapById } = useCanvasMapping({ + nodes: ref(nodes), + connections: ref(connections), + workflowObject: ref(workflowObject) as Ref, + }); + + // Should have byTarget field for non-main connections + const modelOutputData = nodeExecutionRunDataOutputMapById.value[modelNode.id]; + expect(modelOutputData).toBeDefined(); + expect(modelOutputData[NodeConnectionTypes.AiLanguageModel]).toBeDefined(); + expect(modelOutputData[NodeConnectionTypes.AiLanguageModel][0]).toBeDefined(); + + const outputData = modelOutputData[NodeConnectionTypes.AiLanguageModel][0]; + + // Check aggregated totals + expect(outputData.iterations).toBe(3); + expect(outputData.total).toBe(6); + + // Check per-target tracking + expect(outputData.byTarget).toBeDefined(); + assert(outputData.byTarget); + + expect(outputData.byTarget[agent1Node.id]).toBeDefined(); + expect(outputData.byTarget[agent2Node.id]).toBeDefined(); + + // Agent 1 was called twice with 2 + 3 = 5 items total + expect(outputData.byTarget[agent1Node.id].iterations).toBe(2); + expect(outputData.byTarget[agent1Node.id].total).toBe(5); + + // Agent 2 was called once with 1 item + expect(outputData.byTarget[agent2Node.id].iterations).toBe(1); + expect(outputData.byTarget[agent2Node.id].total).toBe(1); + }); + + it('should count items inside response field when aggregating for non-main connections', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const embeddingNode = createTestNode({ name: 'Embeddings OpenAI' }); + const vectorStoreNode = createTestNode({ name: 'Vector Store' }); + const nodes = [embeddingNode, vectorStoreNode]; + const connections = { + [embeddingNode.name]: { + [NodeConnectionTypes.AiEmbedding]: [ + [{ node: vectorStoreNode.name, type: NodeConnectionTypes.AiEmbedding, index: 0 }], + ], + }, + }; + const workflowObject = createTestWorkflowObject({ + nodes, + connections, + }); + + // Embedding node returns data wrapped in response field + workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { + if (nodeName === embeddingNode.name) { + return [ + { + startTime: 0, + executionTime: 0, + executionIndex: 0, + source: [{ previousNode: vectorStoreNode.name }], + executionStatus: 'success', + data: { + [NodeConnectionTypes.AiEmbedding]: [ + [ + { + json: { + response: [ + { embedding: [0.1, 0.2] }, + { embedding: [0.3, 0.4] }, + { embedding: [0.5, 0.6] }, + ], + }, + }, + ], + ], + }, + }, + ]; + } + return null; + }); + + const { nodeExecutionRunDataOutputMapById } = useCanvasMapping({ + nodes: ref(nodes), + connections: ref(connections), + workflowObject: ref(workflowObject) as Ref, + }); + + const embeddingOutputData = nodeExecutionRunDataOutputMapById.value[embeddingNode.id]; + expect(embeddingOutputData).toBeDefined(); + expect(embeddingOutputData[NodeConnectionTypes.AiEmbedding]).toBeDefined(); + expect(embeddingOutputData[NodeConnectionTypes.AiEmbedding][0]).toBeDefined(); + + const outputData = embeddingOutputData[NodeConnectionTypes.AiEmbedding][0]; + + // Should count the 3 items inside response, not just 1 wrapper + expect(outputData.iterations).toBe(1); + expect(outputData.total).toBe(3); + + // Should also apply to per-target counts + expect(outputData.byTarget).toBeDefined(); + assert(outputData.byTarget); + + expect(outputData.byTarget[vectorStoreNode.id]).toBeDefined(); + expect(outputData.byTarget[vectorStoreNode.id].total).toBe(3); + }); }); describe('additionalNodePropertiesById', () => { @@ -2603,13 +2773,28 @@ describe('useCanvasMapping', () => { startTime: 0, executionTime: 0, executionIndex: 0, - source: [], + source: [{ previousNode: setNode.name }], data: { [NodeConnectionTypes.AiTool]: [[{ json: {} }, { json: {} }]], }, }, ]; } + // Add execution data for target node so connection shows as executed + if (nodeName === setNode.name) { + return [ + { + startTime: 0, + executionTime: 0, + executionIndex: 0, + source: [], + executionStatus: 'success', + data: { + [NodeConnectionTypes.Main]: [[{ json: {} }]], + }, + }, + ]; + } return null; }); @@ -3173,6 +3358,21 @@ describe('useCanvasMapping', () => { }, ]; } + // Add execution data for target node so connection shows as executed + if (nodeName === setNode.name) { + return [ + { + startTime: 0, + executionTime: 0, + executionIndex: 0, + source: [{ previousNode: manualTriggerNode.name }], + executionStatus: 'success', + data: { + [NodeConnectionTypes.Main]: [[{ json: {} }]], + }, + }, + ]; + } return null; }); @@ -3184,6 +3384,288 @@ describe('useCanvasMapping', () => { expect(mappedConnections.value[0]?.data?.status).toEqual('success'); }); + + it('should not mark non-main connections as executed when only source has run data', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const embeddingNode = createTestNode({ name: 'Embeddings OpenAI' }); + const vectorStoreNode = createTestNode({ name: 'Vector Store' }); + const nodes = [embeddingNode, vectorStoreNode]; + const connections = { + [embeddingNode.name]: { + [NodeConnectionTypes.AiEmbedding]: [ + [{ node: vectorStoreNode.name, type: NodeConnectionTypes.AiEmbedding, index: 0 }], + ], + }, + }; + const workflowObject = createTestWorkflowObject({ + nodes, + connections, + }); + + // Only source node has execution data, target does not + workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { + if (nodeName === embeddingNode.name) { + return [ + { + startTime: 0, + executionTime: 0, + executionIndex: 0, + source: [], + executionStatus: 'success', + data: { + [NodeConnectionTypes.AiEmbedding]: [[{ json: {} }]], + }, + }, + ]; + } + return null; + }); + + const { connections: mappedConnections } = useCanvasMapping({ + nodes: ref(nodes), + connections: ref(connections), + workflowObject: ref(workflowObject) as Ref, + }); + + // Non-main connection should not be marked as success when target hasn't executed + expect(mappedConnections.value[0]?.data?.status).toBeUndefined(); + expect(mappedConnections.value[0]?.label).toBe(''); + }); + + it('should mark non-main connections as executed when both source and target have run data', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const embeddingNode = createTestNode({ name: 'Embeddings OpenAI' }); + const vectorStoreNode = createTestNode({ name: 'Vector Store' }); + const nodes = [embeddingNode, vectorStoreNode]; + const connections = { + [embeddingNode.name]: { + [NodeConnectionTypes.AiEmbedding]: [ + [{ node: vectorStoreNode.name, type: NodeConnectionTypes.AiEmbedding, index: 0 }], + ], + }, + }; + const workflowObject = createTestWorkflowObject({ + nodes, + connections, + }); + + // Both source and target have execution data + workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { + if (nodeName === embeddingNode.name) { + return [ + { + startTime: 0, + executionTime: 0, + executionIndex: 0, + source: [{ previousNode: vectorStoreNode.name }], + executionStatus: 'success', + data: { + [NodeConnectionTypes.AiEmbedding]: [[{ json: {} }, { json: {} }]], + }, + }, + ]; + } + if (nodeName === vectorStoreNode.name) { + return [ + { + startTime: 0, + executionTime: 0, + executionIndex: 0, + source: [], + executionStatus: 'success', + data: { + [NodeConnectionTypes.Main]: [[{ json: {} }]], + }, + }, + ]; + } + return null; + }); + + const { connections: mappedConnections } = useCanvasMapping({ + nodes: ref(nodes), + connections: ref(connections), + workflowObject: ref(workflowObject) as Ref, + }); + + // Non-main connection should be marked as success when both source and target have executed + expect(mappedConnections.value[0]?.data?.status).toEqual('success'); + expect(mappedConnections.value[0]?.label).toBe('2 items'); + }); + + it('should show per-target counts when multiple agents share a model', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const modelNode = createTestNode({ name: 'OpenAI Chat Model' }); + const agent1Node = createTestNode({ name: 'AI Agent 1' }); + const agent2Node = createTestNode({ name: 'AI Agent 2' }); + const nodes = [modelNode, agent1Node, agent2Node]; + const connections = { + [modelNode.name]: { + [NodeConnectionTypes.AiLanguageModel]: [ + [ + { node: agent1Node.name, type: NodeConnectionTypes.AiLanguageModel, index: 0 }, + { node: agent2Node.name, type: NodeConnectionTypes.AiLanguageModel, index: 0 }, + ], + ], + }, + }; + const workflowObject = createTestWorkflowObject({ + nodes, + connections, + }); + + // Model node has execution data with source tracking + workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { + if (nodeName === modelNode.name) { + return [ + { + startTime: 0, + executionTime: 0, + executionIndex: 0, + source: [{ previousNode: agent1Node.name }], + executionStatus: 'success', + data: { + [NodeConnectionTypes.AiLanguageModel]: [[{ json: {} }, { json: {} }]], + }, + }, + { + startTime: 0, + executionTime: 0, + executionIndex: 1, + source: [{ previousNode: agent2Node.name }], + executionStatus: 'success', + data: { + [NodeConnectionTypes.AiLanguageModel]: [[{ json: {} }]], + }, + }, + ]; + } + if (nodeName === agent1Node.name) { + return [ + { + startTime: 0, + executionTime: 0, + executionIndex: 0, + source: [], + executionStatus: 'success', + data: { + [NodeConnectionTypes.Main]: [[{ json: {} }]], + }, + }, + ]; + } + if (nodeName === agent2Node.name) { + return [ + { + startTime: 0, + executionTime: 0, + executionIndex: 0, + source: [], + executionStatus: 'success', + data: { + [NodeConnectionTypes.Main]: [[{ json: {} }]], + }, + }, + ]; + } + return null; + }); + + const { connections: mappedConnections } = useCanvasMapping({ + nodes: ref(nodes), + connections: ref(connections), + workflowObject: ref(workflowObject) as Ref, + }); + + // Should have two connections from the model node + expect(mappedConnections.value).toHaveLength(2); + + // Find connections for each agent + const agent1Connection = mappedConnections.value.find((c) => c.target === agent1Node.id); + const agent2Connection = mappedConnections.value.find((c) => c.target === agent2Node.id); + + // Each connection should show its specific count + expect(agent1Connection?.label).toBe('2 items'); + expect(agent2Connection?.label).toBe('1 item'); + expect(agent1Connection?.data?.status).toEqual('success'); + expect(agent2Connection?.data?.status).toEqual('success'); + }); + + it('should count items inside response field for non-main connections', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const embeddingNode = createTestNode({ name: 'Embeddings OpenAI' }); + const vectorStoreNode = createTestNode({ name: 'Vector Store' }); + const nodes = [embeddingNode, vectorStoreNode]; + const connections = { + [embeddingNode.name]: { + [NodeConnectionTypes.AiEmbedding]: [ + [{ node: vectorStoreNode.name, type: NodeConnectionTypes.AiEmbedding, index: 0 }], + ], + }, + }; + const workflowObject = createTestWorkflowObject({ + nodes, + connections, + }); + + // Embedding node returns data with response array containing 6 items + workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { + if (nodeName === embeddingNode.name) { + return [ + { + startTime: 0, + executionTime: 0, + executionIndex: 0, + source: [{ previousNode: vectorStoreNode.name }], + executionStatus: 'success', + data: { + [NodeConnectionTypes.AiEmbedding]: [ + [ + { + json: { + response: [ + { embedding: [0.1, 0.2] }, + { embedding: [0.3, 0.4] }, + { embedding: [0.5, 0.6] }, + { embedding: [0.7, 0.8] }, + { embedding: [0.9, 1.0] }, + { embedding: [1.1, 1.2] }, + ], + }, + }, + ], + ], + }, + }, + ]; + } + if (nodeName === vectorStoreNode.name) { + return [ + { + startTime: 0, + executionTime: 0, + executionIndex: 0, + source: [], + executionStatus: 'success', + data: { + [NodeConnectionTypes.Main]: [[{ json: {} }]], + }, + }, + ]; + } + return null; + }); + + const { connections: mappedConnections } = useCanvasMapping({ + nodes: ref(nodes), + connections: ref(connections), + workflowObject: ref(workflowObject) as Ref, + }); + + // Should count the 6 items inside response, not just 1 wrapper object + expect(mappedConnections.value[0]?.data?.status).toEqual('success'); + expect(mappedConnections.value[0]?.label).toBe('6 items'); + }); }); }); }); diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.ts index aeb23689557..84be45b105e 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.ts @@ -364,6 +364,9 @@ export function useCanvasMapping({ }, {}), ); + // Create a map for O(1) node lookups by name + const nodesByName = computed(() => new Map(nodes.value.map((n) => [n.name, n]))); + const nodeExecutionRunDataOutputMapById = ref>({}); throttledWatch( @@ -388,12 +391,61 @@ export function useCanvasMapping({ acc[nodeId][connectionType][outputIndex] = acc[nodeId][connectionType][ outputIndex - ] ?? { ...outputData }; + ] ?? { + ...outputData, + ...(connectionType !== NodeConnectionTypes.Main ? { byTarget: {} } : {}), + }; + // For non-main connections, check if items are wrapped in a response field + // (common for AI nodes like embeddings, tools, etc.) + // Note: We check only the first item assuming uniform structure across all items + let itemCount = connectionTypeOutputIndexData.length; + if ( + connectionType !== NodeConnectionTypes.Main && + connectionTypeOutputIndexData.length > 0 + ) { + const firstItem = connectionTypeOutputIndexData[0]; + // AI nodes typically wrap all items uniformly in response field + if ( + firstItem?.json && + typeof firstItem.json === 'object' && + 'response' in firstItem.json && + Array.isArray(firstItem.json.response) + ) { + // Use response array length for all items (assuming uniform structure) + itemCount = firstItem.json.response.length; + } + } + if (runIteration.executionStatus !== 'canceled') { acc[nodeId][connectionType][outputIndex].iterations += 1; } - acc[nodeId][connectionType][outputIndex].total += - connectionTypeOutputIndexData.length; + acc[nodeId][connectionType][outputIndex].total += itemCount; + + // For non-main connections, track per-target execution counts + if (connectionType !== NodeConnectionTypes.Main) { + const callingNodeName = runIteration.source?.[0]?.previousNode; + if (callingNodeName) { + const callingNode = nodesByName.value.get(callingNodeName); + if (callingNode) { + const targetId = callingNode.id; + const outputEntry = acc[nodeId][connectionType][outputIndex]; + + if (outputEntry.byTarget) { + if (!outputEntry.byTarget[targetId]) { + outputEntry.byTarget[targetId] = { + total: 0, + iterations: 0, + }; + } + + if (runIteration.executionStatus !== 'canceled') { + outputEntry.byTarget[targetId].iterations += 1; + } + outputEntry.byTarget[targetId].total += itemCount; + } + } + } + } } } } @@ -711,7 +763,14 @@ export function useCanvasMapping({ } else if (nodeHasIssuesById.value[connection.source]) { status = 'error'; } else if (runDataTotal > 0 && lastSourceTask?.executionStatus !== 'canceled') { - status = 'success'; + // For non-main connections (model, memory, tool, etc.), only mark as executed + // if the target node also executed, since these are passive connections + const isMainConnection = type === NodeConnectionTypes.Main; + const targetNodeHasAnyExecution = nodeExecutionRunDataById.value[connection.target]; + + if (isMainConnection || targetNodeHasAnyExecution) { + status = 'success'; + } } const maxConnections = [ @@ -754,13 +813,36 @@ export function useCanvasMapping({ : ''; } else if (nodeExecutionRunDataById.value[fromNode.id]) { const { type, index } = parseCanvasConnectionHandleString(connection.sourceHandle); - const runDataTotal = - nodeExecutionRunDataOutputMapById.value[fromNode.id]?.[type]?.[index]?.total ?? 0; - const hasMultipleRunDataIterations = - (nodeExecutionRunDataOutputMapById.value[fromNode.id]?.[type]?.[index]?.iterations ?? 1) > - 1; + const outputData = nodeExecutionRunDataOutputMapById.value[fromNode.id]?.[type]?.[index]; - return runDataTotal > 0 + // For non-main connections, use per-target data if available + const isMainConnection = type === NodeConnectionTypes.Main; + const targetHasExecutionData = nodeExecutionRunDataById.value[connection.target]; + + if (!isMainConnection && outputData?.byTarget) { + // Look up the target node to get per-connection counts + const targetNodeId = connection.target; + const targetData = outputData.byTarget[targetNodeId]; + + if (targetData && targetData.total > 0 && targetHasExecutionData) { + return i18n.baseText( + targetData.iterations > 1 ? 'ndv.output.itemsTotal' : 'ndv.output.items', + { + adjustToNumber: targetData.total, + interpolate: { count: String(targetData.total) }, + }, + ); + } + + // Target hasn't executed, show no label + return ''; + } + + // For main connections, use aggregate counts + const runDataTotal = outputData?.total ?? 0; + const hasMultipleRunDataIterations = (outputData?.iterations ?? 1) > 1; + + return runDataTotal > 0 && (isMainConnection || targetHasExecutionData) ? i18n.baseText( hasMultipleRunDataIterations ? 'ndv.output.itemsTotal' : 'ndv.output.items', {