mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 09:36:44 +00:00
feat(core): Add retry execution endpoint to public api (#19132)
Co-authored-by: Csaba Tuncsik <csaba.tuncsik@gmail.com> Co-authored-by: Marc Littlemore <MarcL@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
b147709189
commit
c4f41bb534
@@ -125,6 +125,7 @@ exports[`Scope Information ensure scopes are defined correctly 1`] = `
|
||||
"dataStore:*",
|
||||
"execution:delete",
|
||||
"execution:read",
|
||||
"execution:retry",
|
||||
"execution:list",
|
||||
"execution:get",
|
||||
"execution:*",
|
||||
|
||||
@@ -27,7 +27,7 @@ export const RESOURCES = {
|
||||
insights: ['list'] as const,
|
||||
oidc: ['manage'] as const,
|
||||
dataStore: [...DEFAULT_OPERATIONS, 'readRow', 'writeRow', 'listProject'] as const,
|
||||
execution: ['delete', 'read', 'list', 'get'] as const,
|
||||
execution: ['delete', 'read', 'retry', 'list', 'get'] as const,
|
||||
workflowTags: ['update', 'list'] as const,
|
||||
role: ['manage'] as const,
|
||||
} as const;
|
||||
@@ -39,7 +39,7 @@ export const API_KEY_RESOURCES = {
|
||||
securityAudit: ['generate'] as const,
|
||||
project: ['create', 'update', 'delete', 'list'] as const,
|
||||
user: ['read', 'list', 'create', 'changeRole', 'delete', 'enforceMfa'] as const,
|
||||
execution: ['delete', 'read', 'list', 'get'] as const,
|
||||
execution: ['delete', 'read', 'retry', 'list', 'get'] as const,
|
||||
credential: ['create', 'move', 'delete'] as const,
|
||||
sourceControl: ['pull'] as const,
|
||||
workflowTags: ['update', 'list'] as const,
|
||||
|
||||
@@ -34,6 +34,7 @@ export const OWNER_API_KEY_SCOPES: ApiKeyScope[] = [
|
||||
'workflow:deactivate',
|
||||
'execution:delete',
|
||||
'execution:read',
|
||||
'execution:retry',
|
||||
'execution:list',
|
||||
'credential:create',
|
||||
'credential:move',
|
||||
@@ -59,6 +60,7 @@ export const MEMBER_API_KEY_SCOPES: ApiKeyScope[] = [
|
||||
'workflow:deactivate',
|
||||
'execution:delete',
|
||||
'execution:read',
|
||||
'execution:retry',
|
||||
'execution:list',
|
||||
'credential:create',
|
||||
'credential:move',
|
||||
@@ -84,6 +86,7 @@ export const API_KEY_SCOPES_FOR_IMPLICIT_PERSONAL_PROJECT: ApiKeyScope[] = [
|
||||
'workflow:deactivate',
|
||||
'execution:delete',
|
||||
'execution:read',
|
||||
'execution:retry',
|
||||
'execution:list',
|
||||
'credential:create',
|
||||
'credential:move',
|
||||
|
||||
@@ -206,6 +206,11 @@ export type RelayEventMap = {
|
||||
publicApi: boolean;
|
||||
};
|
||||
|
||||
'user-retried-execution': {
|
||||
userId: string;
|
||||
publicApi: boolean;
|
||||
};
|
||||
|
||||
'user-retrieved-workflow': {
|
||||
userId: string;
|
||||
publicApi: boolean;
|
||||
|
||||
@@ -114,7 +114,7 @@ export class ExecutionService {
|
||||
async findOne(
|
||||
req: ExecutionRequest.GetOne | ExecutionRequest.Update,
|
||||
sharedWorkflowIds: string[],
|
||||
): Promise<IExecutionResponse | IExecutionFlattedResponse | undefined> {
|
||||
): Promise<IExecutionFlattedResponse | undefined> {
|
||||
if (!sharedWorkflowIds.length) return undefined;
|
||||
|
||||
const { id: executionId } = req.params;
|
||||
@@ -131,7 +131,10 @@ export class ExecutionService {
|
||||
return execution;
|
||||
}
|
||||
|
||||
async retry(req: ExecutionRequest.Retry, sharedWorkflowIds: string[]) {
|
||||
async retry(
|
||||
req: ExecutionRequest.Retry,
|
||||
sharedWorkflowIds: string[],
|
||||
): Promise<Omit<IExecutionResponse, 'createdAt'>> {
|
||||
const { id: executionId } = req.params;
|
||||
const execution = await this.executionRepository.findWithUnflattenedData(
|
||||
executionId,
|
||||
@@ -243,7 +246,20 @@ export class ExecutionService {
|
||||
throw new UnexpectedError('The retry did not start for an unknown reason.');
|
||||
}
|
||||
|
||||
return executionData.status;
|
||||
return {
|
||||
id: retriedExecutionId,
|
||||
mode: executionData.mode,
|
||||
startedAt: executionData.startedAt,
|
||||
workflowId: execution.workflowId,
|
||||
finished: executionData.finished ?? false,
|
||||
retryOf: executionId,
|
||||
status: executionData.status,
|
||||
waitTill: executionData.waitTill,
|
||||
data: executionData.data,
|
||||
workflowData: execution.workflowData,
|
||||
customData: execution.customData,
|
||||
annotation: execution.annotation,
|
||||
};
|
||||
}
|
||||
|
||||
async delete(req: ExecutionRequest.Delete, sharedWorkflowIds: string[]) {
|
||||
|
||||
@@ -14,8 +14,6 @@ export declare namespace ExecutionRequest {
|
||||
lastId: string;
|
||||
firstId: string;
|
||||
};
|
||||
|
||||
type GetOne = { unflattedResponse: 'true' | 'false' };
|
||||
}
|
||||
|
||||
namespace BodyParams {
|
||||
@@ -41,11 +39,11 @@ export declare namespace ExecutionRequest {
|
||||
rangeQuery: ExecutionSummaries.RangeQuery; // parsed from query params
|
||||
};
|
||||
|
||||
type GetOne = AuthenticatedRequest<RouteParams.ExecutionId, {}, {}, QueryParams.GetOne>;
|
||||
type GetOne = AuthenticatedRequest<RouteParams.ExecutionId>;
|
||||
|
||||
type Delete = AuthenticatedRequest<{}, {}, BodyParams.DeleteFilter>;
|
||||
|
||||
type Retry = AuthenticatedRequest<RouteParams.ExecutionId, {}, { loadWorkflow: boolean }, {}>;
|
||||
type Retry = AuthenticatedRequest<RouteParams.ExecutionId, {}, { loadWorkflow?: boolean }, {}>;
|
||||
|
||||
type Stop = AuthenticatedRequest<RouteParams.ExecutionId>;
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ export declare namespace ExecutionRequest {
|
||||
|
||||
type Get = AuthenticatedRequest<{ id: string }, {}, {}, { includeData?: boolean }>;
|
||||
type Delete = Get;
|
||||
type Retry = AuthenticatedRequest<{ id: string }, {}, { loadWorkflow?: boolean }, {}>;
|
||||
}
|
||||
|
||||
export declare namespace TagRequest {
|
||||
|
||||
@@ -5,7 +5,11 @@ import { replaceCircularReferences } from 'n8n-workflow';
|
||||
|
||||
import { ActiveExecutions } from '@/active-executions';
|
||||
import { ConcurrencyControlService } from '@/concurrency/concurrency-control.service';
|
||||
import { AbortedExecutionRetryError } from '@/errors/aborted-execution-retry.error';
|
||||
import { QueuedExecutionRetryError } from '@/errors/queued-execution-retry.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { EventService } from '@/events/event.service';
|
||||
import { ExecutionService } from '@/executions/execution.service';
|
||||
|
||||
import type { ExecutionRequest } from '../../../types';
|
||||
import { apiKeyHasScope, validCursor } from '../../shared/middlewares/global.middleware';
|
||||
@@ -149,4 +153,41 @@ export = {
|
||||
});
|
||||
},
|
||||
],
|
||||
retryExecution: [
|
||||
apiKeyHasScope('execution:retry'),
|
||||
async (req: ExecutionRequest.Retry, res: express.Response): Promise<express.Response> => {
|
||||
const sharedWorkflowsIds = await getSharedWorkflowIds(req.user, ['workflow:read']);
|
||||
|
||||
// user does not have workflows hence no executions
|
||||
// or the execution they are trying to access belongs to a workflow they do not own
|
||||
if (!sharedWorkflowsIds.length) {
|
||||
return res.status(404).json({ message: 'Not Found' });
|
||||
}
|
||||
|
||||
try {
|
||||
const retriedExecution = await Container.get(ExecutionService).retry(
|
||||
req,
|
||||
sharedWorkflowsIds,
|
||||
);
|
||||
|
||||
Container.get(EventService).emit('user-retried-execution', {
|
||||
userId: req.user.id,
|
||||
publicApi: true,
|
||||
});
|
||||
|
||||
return res.json(replaceCircularReferences(retriedExecution));
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof QueuedExecutionRetryError ||
|
||||
error instanceof AbortedExecutionRetryError
|
||||
) {
|
||||
return res.status(409).json({ message: error.message });
|
||||
} else if (error instanceof NotFoundError) {
|
||||
return res.status(404).json({ message: error.message });
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
post:
|
||||
x-eov-operation-id: retryExecution
|
||||
x-eov-operation-handler: v1/handlers/executions/executions.handler
|
||||
tags:
|
||||
- Execution
|
||||
summary: Retry an execution
|
||||
description: Retry an execution from your instance.
|
||||
parameters:
|
||||
- $ref: '../schemas/parameters/executionId.yml'
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
loadWorkflow:
|
||||
type: boolean
|
||||
description: Whether to load the currently saved workflow to execute instead of the one saved at the time of the execution. If set to true, it will retry with the latest version of the workflow.
|
||||
responses:
|
||||
'200':
|
||||
description: Operation successful.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/execution.yml'
|
||||
'401':
|
||||
$ref: '../../../../shared/spec/responses/unauthorized.yml'
|
||||
'404':
|
||||
$ref: '../../../../shared/spec/responses/notFound.yml'
|
||||
'409':
|
||||
$ref: '../../../../shared/spec/responses/conflict.yml'
|
||||
@@ -22,6 +22,8 @@ put:
|
||||
- Variables
|
||||
summary: Update a variable
|
||||
description: Update a variable from your instance.
|
||||
parameters:
|
||||
- $ref: '../schemas/parameters/variableId.yml'
|
||||
requestBody:
|
||||
description: Payload for variable to update.
|
||||
content:
|
||||
|
||||
@@ -48,6 +48,8 @@ paths:
|
||||
$ref: './handlers/executions/spec/paths/executions.yml'
|
||||
/executions/{id}:
|
||||
$ref: './handlers/executions/spec/paths/executions.id.yml'
|
||||
/executions/{id}/retry:
|
||||
$ref: './handlers/executions/spec/paths/executions.id.retry.yml'
|
||||
/tags:
|
||||
$ref: './handlers/tags/spec/paths/tags.yml'
|
||||
/tags/{id}:
|
||||
|
||||
@@ -14,3 +14,5 @@ UserIdentifier:
|
||||
$ref: '../../../handlers/users/spec/schemas/parameters/userIdentifier.yml'
|
||||
IncludeRole:
|
||||
$ref: '../../../handlers/users/spec/schemas/parameters/includeRole.yml'
|
||||
VariableId:
|
||||
$ref: '../../../handlers/variables/spec/schemas/parameters/variableId.yml'
|
||||
|
||||
@@ -7,10 +7,8 @@ import {
|
||||
testDb,
|
||||
} from '@n8n/backend-test-utils';
|
||||
import type { ExecutionEntity, User } from '@n8n/db';
|
||||
import type { ExecutionStatus } from 'n8n-workflow';
|
||||
|
||||
import type { ActiveWorkflowManager } from '@/active-workflow-manager';
|
||||
import { Telemetry } from '@/telemetry';
|
||||
import { Container } from '@n8n/di';
|
||||
import { UnexpectedError, type ExecutionStatus } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
createdExecutionWithStatus,
|
||||
@@ -23,6 +21,12 @@ import { createMemberWithApiKey, createOwnerWithApiKey } from '../shared/db/user
|
||||
import type { SuperAgentTest } from '../shared/types';
|
||||
import * as utils from '../shared/utils/';
|
||||
|
||||
import type { ActiveWorkflowManager } from '@/active-workflow-manager';
|
||||
import { ExecutionService } from '@/executions/execution.service';
|
||||
import { Telemetry } from '@/telemetry';
|
||||
import { QueuedExecutionRetryError } from '@/errors/queued-execution-retry.error';
|
||||
import { AbortedExecutionRetryError } from '@/errors/aborted-execution-retry.error';
|
||||
|
||||
let owner: User;
|
||||
let user1: User;
|
||||
let user2: User;
|
||||
@@ -234,6 +238,107 @@ describe('DELETE /executions/:id', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /executions/:id/retry', () => {
|
||||
test('should fail due to missing API Key', testWithAPIKey('post', '/executions/1/retry', null));
|
||||
|
||||
test(
|
||||
'should fail due to invalid API Key',
|
||||
testWithAPIKey('post', '/executions/1/retry', 'abcXYZ'),
|
||||
);
|
||||
|
||||
test('should retry an execution', async () => {
|
||||
const mockedExecutionResponse = { status: 'waiting' } as any;
|
||||
const executionServiceSpy = jest
|
||||
.spyOn(Container.get(ExecutionService), 'retry')
|
||||
.mockResolvedValue(mockedExecutionResponse);
|
||||
|
||||
const workflow = await createWorkflow({}, user1);
|
||||
const execution = await createSuccessfulExecution(workflow);
|
||||
|
||||
const response = await authUser1Agent.post(`/executions/${execution.id}/retry`);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toEqual(mockedExecutionResponse);
|
||||
|
||||
executionServiceSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('should return 404 when execution is not found', async () => {
|
||||
const nonExistentExecutionId = 99999999;
|
||||
|
||||
const response = await authUser1Agent.post(`/executions/${nonExistentExecutionId}/retry`);
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.body.message).toBe('Not Found');
|
||||
});
|
||||
|
||||
test('should return 409 when trying to retry a queued execution', async () => {
|
||||
const executionServiceSpy = jest
|
||||
.spyOn(Container.get(ExecutionService), 'retry')
|
||||
.mockRejectedValue(new QueuedExecutionRetryError());
|
||||
|
||||
const workflow = await createWorkflow({}, user1);
|
||||
const execution = await createExecution({ status: 'new', finished: false }, workflow);
|
||||
|
||||
const response = await authUser1Agent.post(`/executions/${execution.id}/retry`);
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.body.message).toBe(
|
||||
'Execution is queued to run (not yet started) so it cannot be retried',
|
||||
);
|
||||
|
||||
executionServiceSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('should return 409 when trying to retry an aborted execution without execution data', async () => {
|
||||
const executionServiceSpy = jest
|
||||
.spyOn(Container.get(ExecutionService), 'retry')
|
||||
.mockRejectedValue(new AbortedExecutionRetryError());
|
||||
|
||||
const workflow = await createWorkflow({}, user1);
|
||||
const execution = await createExecution(
|
||||
{
|
||||
status: 'error',
|
||||
finished: false,
|
||||
data: JSON.stringify({ executionData: null }),
|
||||
},
|
||||
workflow,
|
||||
);
|
||||
|
||||
const response = await authUser1Agent.post(`/executions/${execution.id}/retry`);
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.body.message).toBe(
|
||||
'The execution was aborted before starting, so it cannot be retried',
|
||||
);
|
||||
|
||||
executionServiceSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('should return 400 when trying to retry a finished execution', async () => {
|
||||
const executionServiceSpy = jest
|
||||
.spyOn(Container.get(ExecutionService), 'retry')
|
||||
.mockRejectedValue(new UnexpectedError('The execution succeeded, so it cannot be retried.'));
|
||||
|
||||
const workflow = await createWorkflow({}, user1);
|
||||
const execution = await createExecution(
|
||||
{
|
||||
status: 'success',
|
||||
finished: true,
|
||||
data: {} as any,
|
||||
},
|
||||
workflow,
|
||||
);
|
||||
|
||||
const response = await authUser1Agent.post(`/executions/${execution.id}/retry`);
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body.message).toBe('The execution succeeded, so it cannot be retried.');
|
||||
|
||||
executionServiceSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /executions', () => {
|
||||
test('should fail due to missing API Key', testWithAPIKey('get', '/executions', null));
|
||||
|
||||
|
||||
@@ -245,8 +245,8 @@ async function retryOriginalExecution(execution: ExecutionSummary) {
|
||||
|
||||
async function retryExecution(execution: ExecutionSummary, loadWorkflow?: boolean) {
|
||||
try {
|
||||
const retryStatus = await executionsStore.retryExecution(execution.id, loadWorkflow);
|
||||
const retryMessage = executionRetryMessage(retryStatus);
|
||||
const retriedExecution = await executionsStore.retryExecution(execution.id, loadWorkflow);
|
||||
const retryMessage = executionRetryMessage(retriedExecution.status);
|
||||
|
||||
if (retryMessage) {
|
||||
toast.showMessage(retryMessage);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import type { IDataObject, ExecutionSummary, AnnotationVote, ExecutionStatus } from 'n8n-workflow';
|
||||
import type { IDataObject, ExecutionSummary, AnnotationVote } from 'n8n-workflow';
|
||||
import type {
|
||||
ExecutionFilterType,
|
||||
ExecutionsQueryFilter,
|
||||
@@ -244,8 +244,8 @@ export const useExecutionsStore = defineStore('executions', () => {
|
||||
);
|
||||
}
|
||||
|
||||
async function retryExecution(id: string, loadWorkflow?: boolean): Promise<ExecutionStatus> {
|
||||
return await makeRestApiRequest(
|
||||
async function retryExecution(id: string, loadWorkflow?: boolean): Promise<IExecutionResponse> {
|
||||
const retriedExecution = await makeRestApiRequest<IExecutionResponse>(
|
||||
rootStore.restApiContext,
|
||||
'POST',
|
||||
`/executions/${id}/retry`,
|
||||
@@ -255,6 +255,7 @@ export const useExecutionsStore = defineStore('executions', () => {
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
return retriedExecution;
|
||||
}
|
||||
|
||||
async function deleteExecutions(sendData: IExecutionDeleteFilter): Promise<void> {
|
||||
|
||||
@@ -283,9 +283,9 @@ async function onExecutionRetry(payload: { id: string; loadWorkflow: boolean })
|
||||
|
||||
async function retryExecution(payload: { id: string; loadWorkflow: boolean }) {
|
||||
try {
|
||||
const retryStatus = await executionsStore.retryExecution(payload.id, payload.loadWorkflow);
|
||||
const retriedExecution = await executionsStore.retryExecution(payload.id, payload.loadWorkflow);
|
||||
|
||||
const retryMessage = executionRetryMessage(retryStatus);
|
||||
const retryMessage = executionRetryMessage(retriedExecution.status);
|
||||
|
||||
if (retryMessage) {
|
||||
toast.showMessage(retryMessage);
|
||||
|
||||
Reference in New Issue
Block a user