diff --git a/packages/core/src/execution-engine/partial-execution-utils/__tests__/find-trigger-for-partial-execution.test.ts b/packages/core/src/execution-engine/partial-execution-utils/__tests__/find-trigger-for-partial-execution.test.ts index c0bc6d51d55b..b9f924220aa0 100644 --- a/packages/core/src/execution-engine/partial-execution-utils/__tests__/find-trigger-for-partial-execution.test.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/__tests__/find-trigger-for-partial-execution.test.ts @@ -1,10 +1,13 @@ import { mock } from 'jest-mock-extended'; import type { IConnections, INode, INodeType, INodeTypes, IPinData, IRunData } from 'n8n-workflow'; -import { Workflow } from 'n8n-workflow'; +import { NodeConnectionTypes, Workflow } from 'n8n-workflow'; import { createNodeData, toIConnections, toITaskData } from './helpers'; import { DirectedGraph } from '../directed-graph'; -import { findTriggerForPartialExecution } from '../find-trigger-for-partial-execution'; +import { + anyReachableRootHasRunData, + findTriggerForPartialExecution, +} from '../find-trigger-for-partial-execution'; describe('findTriggerForPartialExecution', () => { const nodeTypes = mock(); @@ -236,3 +239,61 @@ describe('findTriggerForPartialExecution', () => { }); }); }); + +describe('anyReachableRootHasRunData', () => { + // ┌────────────┐ Main ┌───────┐ + // │ChatTrigger ├─────────►│NodeX │ + // └──────▲──────┘ └───────┘ + // │ ai_chatMemory + // ┌──────┴──────┐ + // │ ChatMemory │ + // └─────────────┘ + it('should treat trigger with only sub-node parents as a root node', () => { + const chatTrigger = createNodeData({ name: 'Chat Trigger' }); + const chatMemory = createNodeData({ name: 'Chat Memory' }); + const nodeX = createNodeData({ name: 'Node X' }); + + const graph = new DirectedGraph() + .addNodes(chatTrigger, chatMemory, nodeX) + .addConnections( + { from: chatTrigger, to: nodeX }, + { from: chatMemory, to: chatTrigger, type: NodeConnectionTypes.AiMemory }, + ); + + const runData: IRunData = { + [chatTrigger.name]: [toITaskData([{ data: { chatInput: 'hello' } }])], + }; + + expect(anyReachableRootHasRunData(graph, nodeX.name, runData)).toBe(true); + }); + + it('should return false when trigger has no run data and sub-node has no run data', () => { + const chatTrigger = createNodeData({ name: 'Chat Trigger' }); + const chatMemory = createNodeData({ name: 'Chat Memory' }); + const nodeX = createNodeData({ name: 'Node X' }); + + const graph = new DirectedGraph() + .addNodes(chatTrigger, chatMemory, nodeX) + .addConnections( + { from: chatTrigger, to: nodeX }, + { from: chatMemory, to: chatTrigger, type: NodeConnectionTypes.AiMemory }, + ); + + expect(anyReachableRootHasRunData(graph, nodeX.name, {})).toBe(false); + }); + + it('should return true for simple trigger with run data and no sub-nodes', () => { + const trigger = createNodeData({ name: 'Trigger' }); + const nodeX = createNodeData({ name: 'Node X' }); + + const graph = new DirectedGraph() + .addNodes(trigger, nodeX) + .addConnections({ from: trigger, to: nodeX }); + + const runData: IRunData = { + [trigger.name]: [toITaskData([{ data: { value: 1 } }])], + }; + + expect(anyReachableRootHasRunData(graph, nodeX.name, runData)).toBe(true); + }); +}); diff --git a/packages/core/src/execution-engine/partial-execution-utils/find-trigger-for-partial-execution.ts b/packages/core/src/execution-engine/partial-execution-utils/find-trigger-for-partial-execution.ts index c9f58349dd0d..ba657c3a74d7 100644 --- a/packages/core/src/execution-engine/partial-execution-utils/find-trigger-for-partial-execution.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/find-trigger-for-partial-execution.ts @@ -1,5 +1,11 @@ import * as assert from 'assert/strict'; -import type { INode, INodeType, IRunData, Workflow } from 'n8n-workflow'; +import { + NodeConnectionTypes, + type INode, + type INodeType, + type IRunData, + type Workflow, +} from 'n8n-workflow'; import type { DirectedGraph } from './directed-graph'; @@ -44,11 +50,16 @@ export function anyReachableRootHasRunData( parentNodes.add(connection.from); } - // Find all root nodes (nodes with no incoming connections) + // Find all root nodes (nodes with no incoming Main connections). + // Sub-node connections (ai_chatMemory, ai_languageModel, etc.) should not + // disqualify a trigger from being a root – sub-nodes are executed internally + // by their parent and are not independent starting points. const rootNodes = new Set(); for (const parentNode of parentNodes) { - const hasParents = workflow.getDirectParentConnections(parentNode).length > 0; - if (!hasParents) { + const hasMainParents = workflow + .getDirectParentConnections(parentNode) + .some((c) => c.type === NodeConnectionTypes.Main); + if (!hasMainParents) { rootNodes.add(parentNode); } }