diff --git a/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts b/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts index 202cbc854d..57b9cfb62d 100644 --- a/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts +++ b/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts @@ -52,6 +52,10 @@ import { WorkflowExecute } from '../workflow-execute'; const nodeTypes = Helpers.NodeTypes(); +beforeEach(() => { + jest.resetAllMocks(); +}); + describe('WorkflowExecute', () => { describe('v0 execution order', () => { const tests: WorkflowTestData[] = legacyWorkflowExecuteTests; @@ -455,6 +459,56 @@ describe('WorkflowExecute', () => { new Set([node1]), ); }); + + // ►► + // ┌──────┐ + // │orphan│ + // └──────┘ + // ┌───────┐ ┌───────────┐ + // │trigger├────►│destination│ + // └───────┘ └───────────┘ + test('works with a single node', async () => { + // ARRANGE + const waitPromise = createDeferredPromise(); + const nodeExecutionOrder: string[] = []; + const additionalData = Helpers.WorkflowExecuteAdditionalData(waitPromise, nodeExecutionOrder); + const workflowExecute = new WorkflowExecute(additionalData, 'manual'); + + const trigger = createNodeData({ name: 'trigger' }); + const destination = createNodeData({ name: 'destination' }); + const orphan = createNodeData({ name: 'orphan' }); + + const workflow = new DirectedGraph() + .addNodes(trigger, destination, orphan) + .addConnections({ from: trigger, to: destination }) + .toWorkflow({ name: '', active: false, nodeTypes }); + + const pinData: IPinData = {}; + const runData: IRunData = { + [trigger.name]: [toITaskData([{ data: { value: 1 } }])], + [destination.name]: [toITaskData([{ data: { nodeName: destination.name } }])], + }; + const dirtyNodeNames: string[] = []; + + const processRunExecutionDataSpy = jest + .spyOn(workflowExecute, 'processRunExecutionData') + .mockImplementationOnce(jest.fn()); + + // ACT + await workflowExecute.runPartialWorkflow2( + workflow, + runData, + pinData, + dirtyNodeNames, + orphan.name, + ); + + // ASSERT + expect(processRunExecutionDataSpy).toHaveBeenCalledTimes(1); + expect(processRunExecutionDataSpy).toHaveBeenCalledWith( + new DirectedGraph().addNode(orphan).toWorkflow({ ...workflow }), + ); + }); }); describe('checkReadyForExecution', () => { @@ -465,19 +519,20 @@ describe('WorkflowExecute', () => { const nodeParamIssuesSpy = jest.spyOn(NodeHelpers, 'getNodeParametersIssues'); const nodeTypes = mock(); - nodeTypes.getByNameAndVersion.mockImplementation((type) => { - // TODO: getByNameAndVersion signature needs to be updated to allow returning undefined - if (type === 'unknownNode') return undefined as unknown as INodeType; - return mock({ - description: { - properties: [], - }, + + beforeEach(() => { + nodeTypes.getByNameAndVersion.mockImplementation((type) => { + // TODO: getByNameAndVersion signature needs to be updated to allow returning undefined + if (type === 'unknownNode') return undefined as unknown as INodeType; + return mock({ + description: { + properties: [], + }, + }); }); }); const workflowExecute = new WorkflowExecute(mock(), 'manual'); - beforeEach(() => jest.clearAllMocks()); - it('should return null if there are no nodes', () => { const workflow = new Workflow({ nodes: [], @@ -562,7 +617,9 @@ describe('WorkflowExecute', () => { }, }); - nodeTypes.getByNameAndVersion.mockReturnValue(triggerNodeType); + beforeEach(() => { + nodeTypes.getByNameAndVersion.mockReturnValue(triggerNodeType); + }); const workflow = new Workflow({ nodeTypes, diff --git a/packages/core/src/execution-engine/partial-execution-utils/__tests__/find-subgraph.test.ts b/packages/core/src/execution-engine/partial-execution-utils/__tests__/find-subgraph.test.ts index 1a6382017a..429f8b7c4f 100644 --- a/packages/core/src/execution-engine/partial-execution-utils/__tests__/find-subgraph.test.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/__tests__/find-subgraph.test.ts @@ -33,6 +33,27 @@ describe('findSubgraph', () => { expect(subgraph).toEqual(graph); }); + // ►► + // ┌──────┐ + // │orphan│ + // └──────┘ + // ┌───────┐ ┌───────────┐ + // │trigger├────►│destination│ + // └───────┘ └───────────┘ + test('works with a single node', () => { + const trigger = createNodeData({ name: 'trigger' }); + const destination = createNodeData({ name: 'destination' }); + const orphan = createNodeData({ name: 'orphan' }); + + const graph = new DirectedGraph() + .addNodes(trigger, destination, orphan) + .addConnections({ from: trigger, to: destination }); + + const subgraph = findSubgraph({ graph, destination: orphan, trigger: orphan }); + + expect(subgraph).toEqual(new DirectedGraph().addNode(orphan)); + }); + // ►► // ┌───────┐ ┌───────────┐ // │ ├────────►│ │ diff --git a/packages/core/src/execution-engine/partial-execution-utils/find-subgraph.ts b/packages/core/src/execution-engine/partial-execution-utils/find-subgraph.ts index f333f4764e..bbf5cd1e46 100644 --- a/packages/core/src/execution-engine/partial-execution-utils/find-subgraph.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/find-subgraph.ts @@ -13,6 +13,12 @@ function findSubgraphRecursive( ) { // If the current node is the chosen trigger keep this branch. if (current === trigger) { + // If this graph consists of only one node there won't be any connections + // and the loop below won't add anything. + // We're adding the trigger here so that graphs with one node and no + // connections are handled correctly. + newGraph.addNode(trigger); + for (const connection of currentBranch) { newGraph.addNodes(connection.from, connection.to); newGraph.addConnection(connection); diff --git a/packages/core/src/execution-engine/workflow-execute.ts b/packages/core/src/execution-engine/workflow-execute.ts index c224530379..a5cea5cd11 100644 --- a/packages/core/src/execution-engine/workflow-execute.ts +++ b/packages/core/src/execution-engine/workflow-execute.ts @@ -354,16 +354,52 @@ export class WorkflowExecute { `Could not find a node with the name ${destinationNodeName} in the workflow.`, ); + let graph = DirectedGraph.fromWorkflow(workflow); + + // Edge Case 1: + // Support executing a single node that is not connected to a trigger + const destinationHasNoParents = graph.getDirectParentConnections(destination).length === 0; + if (destinationHasNoParents) { + // short cut here, only create a subgraph and the stacks + graph = findSubgraph({ + graph: filterDisabledNodes(graph), + destination, + trigger: destination, + }); + const filteredNodes = graph.getNodes(); + runData = cleanRunData(runData, graph, new Set([destination])); + const { nodeExecutionStack, waitingExecution, waitingExecutionSource } = + recreateNodeExecutionStack(graph, new Set([destination]), runData, pinData ?? {}); + + this.status = 'running'; + this.runExecutionData = { + startData: { + destinationNode: destinationNodeName, + runNodeFilter: Array.from(filteredNodes.values()).map((node) => node.name), + }, + resultData: { + runData, + pinData, + }, + executionData: { + contextData: {}, + nodeExecutionStack, + metadata: {}, + waitingExecution, + waitingExecutionSource, + }, + }; + + return this.processRunExecutionData(graph.toWorkflow({ ...workflow })); + } + // 1. Find the Trigger const trigger = findTriggerForPartialExecution(workflow, destinationNodeName); if (trigger === undefined) { - throw new ApplicationError( - 'The destination node is not connected to any trigger. Partial executions need a trigger.', - ); + throw new ApplicationError('Connect a trigger to run this node'); } // 2. Find the Subgraph - let graph = DirectedGraph.fromWorkflow(workflow); graph = findSubgraph({ graph: filterDisabledNodes(graph), destination, trigger }); const filteredNodes = graph.getNodes(); @@ -380,7 +416,7 @@ export class WorkflowExecute { // 7. Recreate Execution Stack const { nodeExecutionStack, waitingExecution, waitingExecutionSource } = - recreateNodeExecutionStack(graph, new Set(startNodes), runData, pinData ?? {}); + recreateNodeExecutionStack(graph, startNodes, runData, pinData ?? {}); // 8. Execute this.status = 'running';