From c4f41bb534dcc1b05a86a556e16ef463efcf8f71 Mon Sep 17 00:00:00 2001 From: Konstantin Tieber <46342664+konstantintieber@users.noreply.github.com> Date: Thu, 11 Sep 2025 10:12:53 +0200 Subject: [PATCH] feat(core): Add retry execution endpoint to public api (#19132) Co-authored-by: Csaba Tuncsik Co-authored-by: Marc Littlemore --- .../scope-information.test.ts.snap | 1 + packages/@n8n/permissions/src/constants.ee.ts | 4 +- .../src/public-api-permissions.ee.ts | 3 + .../cli/src/events/maps/relay.event-map.ts | 5 + .../cli/src/executions/execution.service.ts | 22 +++- .../cli/src/executions/execution.types.ts | 6 +- packages/cli/src/public-api/types.ts | 1 + .../handlers/executions/executions.handler.ts | 41 +++++++ .../spec/paths/executions.id.retry.yml | 32 +++++ .../variables/spec/paths/variables.id.yml | 2 + packages/cli/src/public-api/v1/openapi.yml | 2 + .../v1/shared/spec/parameters/_index.yml | 2 + .../integration/public-api/executions.test.ts | 113 +++++++++++++++++- .../global/GlobalExecutionsList.vue | 4 +- .../editor-ui/src/stores/executions.store.ts | 7 +- .../src/views/WorkflowExecutionsView.vue | 4 +- 16 files changed, 229 insertions(+), 20 deletions(-) create mode 100644 packages/cli/src/public-api/v1/handlers/executions/spec/paths/executions.id.retry.yml diff --git a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap index baed19d9fb5..808720cd623 100644 --- a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap +++ b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap @@ -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:*", diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index 257d8d1023e..b79d4e3a385 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -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, diff --git a/packages/@n8n/permissions/src/public-api-permissions.ee.ts b/packages/@n8n/permissions/src/public-api-permissions.ee.ts index 47e5a454af6..29f7d0e9cd2 100644 --- a/packages/@n8n/permissions/src/public-api-permissions.ee.ts +++ b/packages/@n8n/permissions/src/public-api-permissions.ee.ts @@ -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', diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index aba94c79cd7..87080ebb509 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -206,6 +206,11 @@ export type RelayEventMap = { publicApi: boolean; }; + 'user-retried-execution': { + userId: string; + publicApi: boolean; + }; + 'user-retrieved-workflow': { userId: string; publicApi: boolean; diff --git a/packages/cli/src/executions/execution.service.ts b/packages/cli/src/executions/execution.service.ts index b07fe70e89a..fd72d0470f1 100644 --- a/packages/cli/src/executions/execution.service.ts +++ b/packages/cli/src/executions/execution.service.ts @@ -114,7 +114,7 @@ export class ExecutionService { async findOne( req: ExecutionRequest.GetOne | ExecutionRequest.Update, sharedWorkflowIds: string[], - ): Promise { + ): Promise { 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> { 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[]) { diff --git a/packages/cli/src/executions/execution.types.ts b/packages/cli/src/executions/execution.types.ts index c37a4a09b42..5c5020898d0 100644 --- a/packages/cli/src/executions/execution.types.ts +++ b/packages/cli/src/executions/execution.types.ts @@ -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; + type GetOne = AuthenticatedRequest; type Delete = AuthenticatedRequest<{}, {}, BodyParams.DeleteFilter>; - type Retry = AuthenticatedRequest; + type Retry = AuthenticatedRequest; type Stop = AuthenticatedRequest; diff --git a/packages/cli/src/public-api/types.ts b/packages/cli/src/public-api/types.ts index b67efa79420..f511d660cdd 100644 --- a/packages/cli/src/public-api/types.ts +++ b/packages/cli/src/public-api/types.ts @@ -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 { diff --git a/packages/cli/src/public-api/v1/handlers/executions/executions.handler.ts b/packages/cli/src/public-api/v1/handlers/executions/executions.handler.ts index 0e873b53dad..1ac0459812e 100644 --- a/packages/cli/src/public-api/v1/handlers/executions/executions.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/executions/executions.handler.ts @@ -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 => { + 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; + } + } + }, + ], }; diff --git a/packages/cli/src/public-api/v1/handlers/executions/spec/paths/executions.id.retry.yml b/packages/cli/src/public-api/v1/handlers/executions/spec/paths/executions.id.retry.yml new file mode 100644 index 00000000000..9b5e7379c3b --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/executions/spec/paths/executions.id.retry.yml @@ -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' diff --git a/packages/cli/src/public-api/v1/handlers/variables/spec/paths/variables.id.yml b/packages/cli/src/public-api/v1/handlers/variables/spec/paths/variables.id.yml index 62bd0de4788..bbc26094d6b 100644 --- a/packages/cli/src/public-api/v1/handlers/variables/spec/paths/variables.id.yml +++ b/packages/cli/src/public-api/v1/handlers/variables/spec/paths/variables.id.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: diff --git a/packages/cli/src/public-api/v1/openapi.yml b/packages/cli/src/public-api/v1/openapi.yml index 7afae9651cc..ceacda1d13e 100644 --- a/packages/cli/src/public-api/v1/openapi.yml +++ b/packages/cli/src/public-api/v1/openapi.yml @@ -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}: diff --git a/packages/cli/src/public-api/v1/shared/spec/parameters/_index.yml b/packages/cli/src/public-api/v1/shared/spec/parameters/_index.yml index c11178a7ae8..c8c6aba3423 100644 --- a/packages/cli/src/public-api/v1/shared/spec/parameters/_index.yml +++ b/packages/cli/src/public-api/v1/shared/spec/parameters/_index.yml @@ -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' diff --git a/packages/cli/test/integration/public-api/executions.test.ts b/packages/cli/test/integration/public-api/executions.test.ts index 97f352ce635..54a0f1ef877 100644 --- a/packages/cli/test/integration/public-api/executions.test.ts +++ b/packages/cli/test/integration/public-api/executions.test.ts @@ -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)); diff --git a/packages/frontend/editor-ui/src/components/executions/global/GlobalExecutionsList.vue b/packages/frontend/editor-ui/src/components/executions/global/GlobalExecutionsList.vue index a3fe99fd96f..22b72d4223c 100644 --- a/packages/frontend/editor-ui/src/components/executions/global/GlobalExecutionsList.vue +++ b/packages/frontend/editor-ui/src/components/executions/global/GlobalExecutionsList.vue @@ -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); diff --git a/packages/frontend/editor-ui/src/stores/executions.store.ts b/packages/frontend/editor-ui/src/stores/executions.store.ts index e5f2a0c3361..f0b3dc1d22d 100644 --- a/packages/frontend/editor-ui/src/stores/executions.store.ts +++ b/packages/frontend/editor-ui/src/stores/executions.store.ts @@ -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 { - return await makeRestApiRequest( + async function retryExecution(id: string, loadWorkflow?: boolean): Promise { + const retriedExecution = await makeRestApiRequest( rootStore.restApiContext, 'POST', `/executions/${id}/retry`, @@ -255,6 +255,7 @@ export const useExecutionsStore = defineStore('executions', () => { } : undefined, ); + return retriedExecution; } async function deleteExecutions(sendData: IExecutionDeleteFilter): Promise { diff --git a/packages/frontend/editor-ui/src/views/WorkflowExecutionsView.vue b/packages/frontend/editor-ui/src/views/WorkflowExecutionsView.vue index b25c63a58d3..eba892fa49c 100644 --- a/packages/frontend/editor-ui/src/views/WorkflowExecutionsView.vue +++ b/packages/frontend/editor-ui/src/views/WorkflowExecutionsView.vue @@ -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);