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 baed19d9fb..808720cd62 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 257d8d1023..b79d4e3a38 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 47e5a454af..29f7d0e9cd 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 aba94c79cd..87080ebb50 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 b07fe70e89..fd72d0470f 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 c37a4a09b4..5c5020898d 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 b67efa7942..f511d660cd 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 0e873b53da..1ac0459812 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 0000000000..9b5e7379c3 --- /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 62bd0de478..bbc26094d6 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 7afae9651c..ceacda1d13 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 c11178a7ae..c8c6aba342 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 97f352ce63..54a0f1ef87 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 a3fe99fd96..22b72d4223 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 e5f2a0c336..f0b3dc1d22 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 b25c63a58d..eba892fa49 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);