fix(editor): Fix sub-nodes connection labels counters (#21549)

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
This commit is contained in:
oleg
2025-11-25 12:02:14 +01:00
committed by GitHub
parent 83ea8e1f91
commit d366cb4f37
3 changed files with 581 additions and 11 deletions
@@ -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 = {
@@ -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<Workflow>,
});
// 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<Workflow>,
});
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<Workflow>,
});
// 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<Workflow>,
});
// 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<Workflow>,
});
// 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<Workflow>,
});
// 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');
});
});
});
});
@@ -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<Record<string, ExecutionOutputMap>>({});
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',
{