feat(core): Add workflowExecuteResume lifecycle hook (#25240)

This commit is contained in:
Jaakko Husso
2026-02-04 11:58:08 +02:00
committed by GitHub
parent a5f84ec040
commit ef22db2c62
9 changed files with 126 additions and 2 deletions
@@ -43,9 +43,12 @@ describe('OnLifecycleEvent', () => {
@OnLifecycleEvent('workflowExecuteAfter')
async handleWorkflowExecuteAfter() {}
@OnLifecycleEvent('workflowExecuteResume')
async handleWorkflowExecuteResume() {}
}
expect(lifecycleMetadata.register).toHaveBeenCalledTimes(4);
expect(lifecycleMetadata.register).toHaveBeenCalledTimes(5);
expect(lifecycleMetadata.register).toHaveBeenCalledWith(
expect.objectContaining({ eventName: 'nodeExecuteBefore' }),
);
@@ -58,6 +61,9 @@ describe('OnLifecycleEvent', () => {
expect(lifecycleMetadata.register).toHaveBeenCalledWith(
expect.objectContaining({ eventName: 'workflowExecuteAfter' }),
);
expect(lifecycleMetadata.register).toHaveBeenCalledWith(
expect.objectContaining({ eventName: 'workflowExecuteResume' }),
);
});
it('should register multiple handlers in the same class', () => {
@@ -5,5 +5,6 @@ export type {
NodeExecuteAfterContext,
WorkflowExecuteBeforeContext,
WorkflowExecuteAfterContext,
WorkflowExecuteResumeContext,
} from './lifecycle-metadata';
export { LifecycleMetadata } from './lifecycle-metadata';
@@ -35,6 +35,7 @@ export type WorkflowExecuteBeforeContext = {
workflow: IWorkflowBase;
workflowInstance: Workflow;
executionData?: IRunExecutionData;
executionId: string;
};
export type WorkflowExecuteAfterContext = {
@@ -42,6 +43,15 @@ export type WorkflowExecuteAfterContext = {
workflow: IWorkflowBase;
runData: IRun;
newStaticData: IDataObject;
executionId: string;
};
export type WorkflowExecuteResumeContext = {
type: 'workflowExecuteResume';
workflow: IWorkflowBase;
workflowInstance: Workflow;
executionData: IRunExecutionData;
executionId: string;
};
/** Context arg passed to a lifecycle event handler method. */
@@ -49,7 +59,8 @@ export type LifecycleContext =
| NodeExecuteBeforeContext
| NodeExecuteAfterContext
| WorkflowExecuteBeforeContext
| WorkflowExecuteAfterContext;
| WorkflowExecuteAfterContext
| WorkflowExecuteResumeContext;
type LifecycleHandler = {
/** Class holding the method to call on a lifecycle event. */
@@ -326,6 +326,7 @@ describe('Execution Lifecycle Hooks', () => {
expect(handlers.nodeExecuteAfter).toHaveLength(2);
expect(handlers.workflowExecuteBefore).toHaveLength(3);
expect(handlers.workflowExecuteAfter).toHaveLength(5);
expect(handlers.workflowExecuteResume).toHaveLength(0);
expect(handlers.nodeFetchedData).toHaveLength(1);
expect(handlers.sendResponse).toHaveLength(0);
expect(handlers.sendChunk).toHaveLength(0);
@@ -733,6 +734,7 @@ describe('Execution Lifecycle Hooks', () => {
expect(handlers.nodeExecuteAfter).toHaveLength(0);
expect(handlers.workflowExecuteBefore).toHaveLength(2);
expect(handlers.workflowExecuteAfter).toHaveLength(4);
expect(handlers.workflowExecuteResume).toHaveLength(0);
expect(handlers.nodeFetchedData).toHaveLength(0);
expect(handlers.sendResponse).toHaveLength(0);
expect(handlers.sendChunk).toHaveLength(0);
@@ -868,6 +870,7 @@ describe('Execution Lifecycle Hooks', () => {
expect(handlers.nodeExecuteAfter).toHaveLength(2);
expect(handlers.workflowExecuteBefore).toHaveLength(2);
expect(handlers.workflowExecuteAfter).toHaveLength(4);
expect(handlers.workflowExecuteResume).toHaveLength(0);
expect(handlers.nodeFetchedData).toHaveLength(1);
expect(handlers.sendResponse).toHaveLength(0);
expect(handlers.sendChunk).toHaveLength(0);
@@ -992,6 +995,7 @@ describe('Execution Lifecycle Hooks', () => {
expect(handlers.nodeExecuteAfter).toHaveLength(1);
expect(handlers.workflowExecuteBefore).toHaveLength(2);
expect(handlers.workflowExecuteAfter).toHaveLength(4);
expect(handlers.workflowExecuteResume).toHaveLength(0);
expect(handlers.nodeFetchedData).toHaveLength(1);
expect(handlers.sendResponse).toHaveLength(0);
expect(handlers.sendChunk).toHaveLength(0);
@@ -57,6 +57,7 @@ class ModulesHooksRegistry {
workflow: this.workflowData,
runData,
newStaticData,
executionId: this.executionId,
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return await instance[methodName].call(instance, context);
@@ -97,6 +98,21 @@ class ModulesHooksRegistry {
workflow: this.workflowData,
workflowInstance,
executionData,
executionId: this.executionId,
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return await instance[methodName].call(instance, context);
});
break;
case 'workflowExecuteResume':
hooks.addHandler(eventName, async function (workflowInstance, executionData) {
const context = {
type: 'workflowExecuteResume',
workflow: this.workflowData,
workflowInstance,
executionData,
executionId: this.executionId,
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return await instance[methodName].call(instance, context);
@@ -39,6 +39,7 @@ describe('ExecutionLifecycleHooks', () => {
sendResponse: [],
workflowExecuteAfter: [],
workflowExecuteBefore: [],
workflowExecuteResume: [],
sendChunk: [],
});
});
@@ -63,6 +64,7 @@ describe('ExecutionLifecycleHooks', () => {
},
{ hook: 'workflowExecuteBefore', args: [mock<Workflow>(), mock<IRunExecutionData>()] },
{ hook: 'workflowExecuteAfter', args: [mock<IRun>(), mock<IDataObject>()] },
{ hook: 'workflowExecuteResume', args: [mock<Workflow>(), mock<IRunExecutionData>()] },
{ hook: 'sendResponse', args: [mock<IExecuteResponsePromiseData>()] },
{ hook: 'nodeFetchedData', args: ['workflow123', mock<INode>()] },
];
@@ -464,6 +464,79 @@ describe('WorkflowExecute', () => {
});
});
describe('workflowExecuteResume hook', () => {
const executionMode = 'manual';
const executionOrder = 'v1';
const nodeTypes = Helpers.NodeTypes();
test('should call workflowExecuteResume instead of workflowExecuteBefore when restartExecutionId is set', async () => {
// ARRANGE
const trigger = createNodeData({ name: 'trigger', type: 'n8n-nodes-base.manualTrigger' });
const node1 = createNodeData({ name: 'node1' });
const workflowInstance = new DirectedGraph()
.addNodes(trigger, node1)
.addConnections({ from: trigger, to: node1 })
.toWorkflow({ name: '', active: false, nodeTypes, settings: { executionOrder } });
const additionalData = Helpers.WorkflowExecuteAdditionalData(createDeferredPromise<IRun>());
// Set restartExecutionId to simulate a resumed execution
additionalData.restartExecutionId = 'previous-execution-id';
const runHookSpy = jest.spyOn(additionalData.hooks!, 'runHook');
const workflowExecute = new WorkflowExecute(additionalData, executionMode);
// ACT
await workflowExecute.run({ workflow: workflowInstance, startNode: trigger });
// ASSERT
const workflowHooks = runHookSpy.mock.calls.filter(
(call) =>
call[0] === 'workflowExecuteBefore' ||
call[0] === 'workflowExecuteAfter' ||
call[0] === 'workflowExecuteResume',
);
// Should have workflowExecuteResume instead of workflowExecuteBefore
expect(workflowHooks.map((hook) => hook[0])).toEqual([
'workflowExecuteResume',
'workflowExecuteAfter',
]);
});
test('should call workflowExecuteBefore when restartExecutionId is not set', async () => {
// ARRANGE
const trigger = createNodeData({ name: 'trigger', type: 'n8n-nodes-base.manualTrigger' });
const node1 = createNodeData({ name: 'node1' });
const workflowInstance = new DirectedGraph()
.addNodes(trigger, node1)
.addConnections({ from: trigger, to: node1 })
.toWorkflow({ name: '', active: false, nodeTypes, settings: { executionOrder } });
const additionalData = Helpers.WorkflowExecuteAdditionalData(createDeferredPromise<IRun>());
// restartExecutionId is undefined by default
const runHookSpy = jest.spyOn(additionalData.hooks!, 'runHook');
const workflowExecute = new WorkflowExecute(additionalData, executionMode);
// ACT
await workflowExecute.run({ workflow: workflowInstance, startNode: trigger });
// ASSERT
const workflowHooks = runHookSpy.mock.calls.filter(
(call) =>
call[0] === 'workflowExecuteBefore' ||
call[0] === 'workflowExecuteAfter' ||
call[0] === 'workflowExecuteResume',
);
// Should have workflowExecuteBefore, not workflowExecuteResume
expect(workflowHooks.map((hook) => hook[0])).toEqual([
'workflowExecuteBefore',
'workflowExecuteAfter',
]);
});
});
//run tests on json files from specified directory, default 'workflows'
//workflows must have pinned data that would be used to test output after execution
describe('run test workflows', () => {
@@ -38,6 +38,14 @@ export type ExecutionLifecycleHookHandlers = {
) => Promise<void> | void
>;
workflowExecuteResume: Array<
(
this: ExecutionLifecycleHooks,
workflow: Workflow,
data?: IRunExecutionData,
) => Promise<void> | void
>;
workflowExecuteAfter: Array<
(this: ExecutionLifecycleHooks, data: IRun, newStaticData: IDataObject) => Promise<void> | void
>;
@@ -88,6 +96,7 @@ export class ExecutionLifecycleHooks {
sendResponse: [],
workflowExecuteAfter: [],
workflowExecuteBefore: [],
workflowExecuteResume: [],
sendChunk: [],
};
@@ -1438,6 +1438,8 @@ export class WorkflowExecute {
if (!this.additionalData.restartExecutionId) {
await hooks.runHook('workflowExecuteBefore', [workflow, this.runExecutionData]);
} else {
await hooks.runHook('workflowExecuteResume', [workflow, this.runExecutionData]);
}
} catch (error) {
const e = error as unknown as ExecutionBaseError;