mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
fix: Show correct "canceled" node status for chat model nodes (#19366)
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user