fix: Show correct "canceled" node status for chat model nodes (#19366)

This commit is contained in:
Mutasem Aldmour
2025-09-12 10:21:35 +02:00
committed by GitHub
parent 6f4dcf1f58
commit b6abd1ef69
12 changed files with 943 additions and 11 deletions

View File

@@ -44,6 +44,7 @@ import {
NodeOperationError,
Workflow,
} from 'n8n-workflow';
import assert from 'node:assert';
import * as Helpers from '@test/helpers';
import { legacyWorkflowExecuteTests, v1WorkflowExecuteTests } from '@test/helpers/constants';
@@ -2485,4 +2486,87 @@ describe('WorkflowExecute', () => {
]);
});
});
describe('Cancellation', () => {
test('should update only running task statuses to cancelled when workflow is cancelled', () => {
// Arrange - create a workflow with some nodes
const startNode = createNodeData({ name: 'Start' });
const processingNode = createNodeData({ name: 'Processing' });
const completedNode = createNodeData({ name: 'Completed' });
const workflow = new Workflow({
id: 'test-workflow',
nodes: [startNode, processingNode, completedNode],
connections: {},
active: false,
nodeTypes,
});
const waitPromise = createDeferredPromise<IRun>();
const additionalData = Helpers.WorkflowExecuteAdditionalData(waitPromise);
const workflowExecute = new WorkflowExecute(additionalData, 'manual');
// Create run execution data with tasks in various statuses
const runExecutionData: IRunExecutionData = {
startData: { startNodes: [{ name: 'Start', sourceData: null }] },
resultData: {
runData: {
Start: [toITaskData([{ data: { test: 'data' } }], { executionStatus: 'success' })],
Processing: [toITaskData([{ data: { test: 'data' } }], { executionStatus: 'running' })],
Completed: [
toITaskData([{ data: { test: 'data1' } }], { executionStatus: 'error' }),
toITaskData([{ data: { test: 'data2' } }], { executionStatus: 'running' }),
toITaskData([{ data: { test: 'data3' } }], { executionStatus: 'waiting' }),
],
},
lastNodeExecuted: 'Processing',
},
executionData: mock<IRunExecutionData['executionData']>({
nodeExecutionStack: [],
metadata: {},
}),
};
// Set the run execution data on the workflow execute instance
// @ts-expect-error private data
workflowExecute.runExecutionData = runExecutionData;
assert(additionalData.hooks);
const runHook = jest.fn();
additionalData.hooks.runHook = runHook;
const promise = workflowExecute.processRunExecutionData(workflow);
promise.cancel('reason');
const updatedExecutionData = {
data: {
startData: { startNodes: [{ name: 'Start', sourceData: null }] },
resultData: {
runData: {
Start: [toITaskData([{ data: { test: 'data' } }], { executionStatus: 'success' })],
Processing: [
toITaskData([{ data: { test: 'data' } }], { executionStatus: 'canceled' }),
],
Completed: [
toITaskData([{ data: { test: 'data1' } }], { executionStatus: 'error' }),
toITaskData([{ data: { test: 'data2' } }], { executionStatus: 'canceled' }),
toITaskData([{ data: { test: 'data3' } }], { executionStatus: 'waiting' }),
],
},
lastNodeExecuted: 'Processing',
},
executionData: mock<IRunExecutionData['executionData']>({
nodeExecutionStack: [],
metadata: {},
}),
},
};
expect(runHook.mock.lastCall[0]).toEqual('workflowExecuteAfter');
expect(JSON.stringify(runHook.mock.lastCall[1][0].data)).toBe(
JSON.stringify(updatedExecutionData.data),
);
expect(runHook.mock.lastCall[1][0].status).toEqual('canceled');
});
});
});

View File

@@ -16,7 +16,7 @@ import type {
NodeConnectionType,
IRunData,
} from 'n8n-workflow';
import { ApplicationError, NodeConnectionTypes } from 'n8n-workflow';
import { ApplicationError, ExecutionCancelledError, NodeConnectionTypes } from 'n8n-workflow';
import { describeCommonTests } from './shared-tests';
import { SupplyDataContext } from '../supply-data-context';
@@ -219,4 +219,70 @@ describe('SupplyDataContext', () => {
expect(latestRunIndex).toBe(2);
});
});
describe('addExecutionDataFunctions', () => {
it('should preserve canceled status when execution is aborted and output has error', async () => {
const errorData = new ExecutionCancelledError('Execution was aborted');
const abortedSignal = mock<AbortSignal>({ aborted: true });
const mockHooks = {
runHook: jest.fn().mockResolvedValue(undefined),
};
const testAdditionalData = mock<IWorkflowExecuteAdditionalData>({
credentialsHelper,
hooks: mockHooks,
currentNodeExecutionIndex: 0,
});
const testRunExecutionData = mock<IRunExecutionData>({
resultData: {
runData: {
[node.name]: [
{
executionStatus: 'canceled',
startTime: Date.now(),
executionTime: 0,
executionIndex: 0,
error: undefined,
},
],
},
error: undefined,
},
executionData: { metadata: {} },
});
const contextWithAbort = new SupplyDataContext(
workflow,
node,
testAdditionalData,
mode,
testRunExecutionData,
runIndex,
connectionInputData,
inputData,
'ai_agent',
executeData,
[closeFn],
abortedSignal,
);
await contextWithAbort.addExecutionDataFunctions(
'output',
errorData,
'ai_agent',
node.name,
0,
);
const taskData = testRunExecutionData.resultData.runData[node.name][0];
expect(taskData.executionStatus).toBe('canceled');
expect(taskData.error).toBeUndefined();
// Verify nodeExecuteAfter hook was called correctly
expect(mockHooks.runHook).toHaveBeenCalledWith('nodeExecuteAfter', [
node.name,
taskData,
testRunExecutionData,
]);
});
});
});

View File

@@ -280,8 +280,14 @@ export class SupplyDataContext extends BaseExecuteContext implements ISupplyData
taskData = taskData!;
if (data instanceof Error) {
taskData.executionStatus = 'error';
taskData.error = data;
// if running node was already marked as "canceled" because execution was aborted
// leave as "canceled" instead of showing "This operation was aborted" error
if (
!(type === 'output' && this.abortSignal?.aborted && taskData.executionStatus === 'canceled')
) {
taskData.executionStatus = 'error';
taskData.error = data;
}
} else {
if (type === 'output') {
taskData.executionStatus = 'success';

View File

@@ -1584,6 +1584,7 @@ export class WorkflowExecute {
onCancel.shouldReject = false;
onCancel(() => {
this.status = 'canceled';
this.updateTaskStatusesToCancelled();
this.abortController.abort();
const fullRunData = this.getFullRunData(startedAt);
void hooks.runHook('workflowExecuteAfter', [fullRunData]);
@@ -2654,6 +2655,17 @@ export class WorkflowExecute {
return nodeSuccessData;
}
private updateTaskStatusesToCancelled(): void {
Object.keys(this.runExecutionData.resultData.runData).forEach((nodeName) => {
const taskDataArray = this.runExecutionData.resultData.runData[nodeName];
taskDataArray.forEach((taskData) => {
if (taskData.executionStatus === 'running') {
taskData.executionStatus = 'canceled';
}
});
});
}
private get isCancelled() {
return this.abortController.signal.aborted;
}