From 4a53faff0949adf9ebaa4e344b97d0970b0354e0 Mon Sep 17 00:00:00 2001 From: Uddish Verma Date: Thu, 3 Sep 2026 15:40:03 +0000 Subject: [PATCH] refactor(API): Migrate the executions stop endpoints to the decorator pattern (no-changelog) (#37531) Co-authored-by: Claude Opus 5 (1M context) --- .../stop-many-executions-public.dto.ts | 45 +++++ .../stopped-execution-public.dto.ts | 21 +++ .../stopped-execution-public.openapi.ts | 22 +++ packages/@n8n/api-types/src/dto/index.ts | 12 ++ packages/cli/src/public-api/types.ts | 13 +- .../executions.public.controller.ts | 113 +++++++++++- .../handlers/executions/executions.handler.ts | 65 +------ .../spec/paths/deleteExecution.generated.yml | 2 +- .../spec/paths/executions.id.retry.yml | 2 +- .../spec/paths/executions.id.stop.yml | 21 --- .../executions/spec/paths/executions.stop.yml | 54 ------ .../spec/paths/getExecution.generated.yml | 2 +- .../spec/paths/getExecutionTags.generated.yml | 2 +- .../spec/paths/getExecutions.generated.yml | 2 +- .../spec/paths/stopExecution.generated.yml | 78 ++++++++ .../paths/stopManyExecutions.generated.yml | 70 ++++++++ .../paths/updateExecutionTags.generated.yml | 2 +- .../v1/openapi.decorator-routes.generated.yml | 6 + packages/cli/src/public-api/v1/openapi.yml | 6 +- .../integration/public-api/executions.test.ts | 170 +++++++++++++++++- .../nodes/N8n/n8n-api-coverage.json | 2 +- 21 files changed, 539 insertions(+), 171 deletions(-) create mode 100644 packages/@n8n/api-types/src/dto/executions/stop-many-executions-public.dto.ts create mode 100644 packages/@n8n/api-types/src/dto/executions/stopped-execution-public.dto.ts create mode 100644 packages/@n8n/api-types/src/dto/executions/stopped-execution-public.openapi.ts delete mode 100644 packages/cli/src/public-api/v1/handlers/executions/spec/paths/executions.id.stop.yml delete mode 100644 packages/cli/src/public-api/v1/handlers/executions/spec/paths/executions.stop.yml create mode 100644 packages/cli/src/public-api/v1/handlers/executions/spec/paths/stopExecution.generated.yml create mode 100644 packages/cli/src/public-api/v1/handlers/executions/spec/paths/stopManyExecutions.generated.yml diff --git a/packages/@n8n/api-types/src/dto/executions/stop-many-executions-public.dto.ts b/packages/@n8n/api-types/src/dto/executions/stop-many-executions-public.dto.ts new file mode 100644 index 00000000000..ec5be26fe4b --- /dev/null +++ b/packages/@n8n/api-types/src/dto/executions/stop-many-executions-public.dto.ts @@ -0,0 +1,45 @@ +import '../../openapi-extend'; + +import type { ExecutionStatus } from 'n8n-workflow'; +import { z } from 'zod'; + +import { Z } from '../../zod-class'; + +export const STOPPABLE_PUBLIC_EXECUTION_STATUSES = ['queued', 'running', 'waiting'] as const; + +export type StoppablePublicExecutionStatus = (typeof STOPPABLE_PUBLIC_EXECUTION_STATUSES)[number]; + +// The public API calls the internal `new` status `queued`, and never exposes `unknown`. +export const STOPPABLE_PUBLIC_TO_INTERNAL_STATUS = { + queued: 'new', + running: 'running', + waiting: 'waiting', +} as const satisfies Record; + +export class StopManyExecutionsPublicDto extends Z.class({ + status: z + .array(z.enum(STOPPABLE_PUBLIC_EXECUTION_STATUSES)) + .min(1, 'must include at least one status') + .openapi({ + description: 'Array of execution statuses to stop. Must include at least one status.', + example: ['queued', 'running', 'waiting'], + }), + workflowId: z + .string() + .optional() + .openapi({ + description: + 'Optional workflow ID to filter executions. If not provided, will stop executions ' + + 'across all accessible workflows. The literal value `all` has the same effect as ' + + 'omitting it.', + example: '2tUt1wbLX592XDdX', + }), + startedAfter: z.string().datetime({ offset: true }).optional().openapi({ + description: 'Only stop executions that started after this time.', + example: '2024-01-01T00:00:00.000Z', + }), + startedBefore: z.string().datetime({ offset: true }).optional().openapi({ + description: 'Only stop executions that started before this time.', + example: '2024-12-31T23:59:59.999Z', + }), +}) {} diff --git a/packages/@n8n/api-types/src/dto/executions/stopped-execution-public.dto.ts b/packages/@n8n/api-types/src/dto/executions/stopped-execution-public.dto.ts new file mode 100644 index 00000000000..b924076d6e5 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/executions/stopped-execution-public.dto.ts @@ -0,0 +1,21 @@ +import '../../openapi-extend'; +import { z } from 'zod'; + +import { stoppedCountOpenApi, stoppedExecutionFieldDocs } from './stopped-execution-public.openapi'; +import { Z } from '../../zod-class'; + +export const stoppedExecutionPublicSchema = z.object({ + mode: z.string().openapi(stoppedExecutionFieldDocs.mode), + startedAt: z.string().openapi(stoppedExecutionFieldDocs.startedAt), + stoppedAt: z.string().optional().openapi(stoppedExecutionFieldDocs.stoppedAt), + finished: z.boolean().openapi(stoppedExecutionFieldDocs.finished), + status: z.string().openapi(stoppedExecutionFieldDocs.status), +}); + +export class StoppedExecutionPublicDto extends Z.class(stoppedExecutionPublicSchema.shape) {} + +export const stoppedExecutionsPublicSchema = z.object({ + stopped: z.number().int().openapi(stoppedCountOpenApi), +}); + +export class StoppedExecutionsPublicDto extends Z.class(stoppedExecutionsPublicSchema.shape) {} diff --git a/packages/@n8n/api-types/src/dto/executions/stopped-execution-public.openapi.ts b/packages/@n8n/api-types/src/dto/executions/stopped-execution-public.openapi.ts new file mode 100644 index 00000000000..f6ac2a3a1dd --- /dev/null +++ b/packages/@n8n/api-types/src/dto/executions/stopped-execution-public.openapi.ts @@ -0,0 +1,22 @@ +import type { ZodOpenAPIMetadata } from '@asteasolutions/zod-to-openapi'; + +import { executionFieldDocs } from './execution-public.openapi'; + +export const stoppedExecutionFieldDocs = { + mode: executionFieldDocs.mode, + startedAt: { + format: 'date-time', + description: 'The time at which the execution started.', + }, + stoppedAt: { + format: 'date-time', + description: 'The time at which the execution stopped. Absent when no stop time was recorded.', + }, + finished: { ...executionFieldDocs.finished, example: false }, + status: { ...executionFieldDocs.status, example: 'canceled' }, +} as const satisfies Record; + +export const stoppedCountOpenApi: ZodOpenAPIMetadata = { + description: 'The number of executions that were successfully stopped.', + example: 5, +}; diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 63670b275e9..320f5511db9 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -376,6 +376,18 @@ export { deletedExecutionPublicSchema, executionListItemPublicSchema, } from './executions/execution-public.dto'; +export { + StoppedExecutionPublicDto, + StoppedExecutionsPublicDto, + stoppedExecutionPublicSchema, + stoppedExecutionsPublicSchema, +} from './executions/stopped-execution-public.dto'; +export { + StopManyExecutionsPublicDto, + STOPPABLE_PUBLIC_EXECUTION_STATUSES, + STOPPABLE_PUBLIC_TO_INTERNAL_STATUS, + type StoppablePublicExecutionStatus, +} from './executions/stop-many-executions-public.dto'; export { ExecutionTagsPublicDto } from './executions/execution-tags-public.dto'; export { VersionSinceDateQueryDto } from './instance-version-history/version-since-date-query.dto'; diff --git a/packages/cli/src/public-api/types.ts b/packages/cli/src/public-api/types.ts index 214685c1172..42ecf2ae55b 100644 --- a/packages/cli/src/public-api/types.ts +++ b/packages/cli/src/public-api/types.ts @@ -17,7 +17,7 @@ import type { LdapSyncDto, } from '@n8n/api-types'; import type { AuthenticatedRequest, TagEntity } from '@n8n/db'; -import type { ExecutionStatus, ICredentialDataDecryptedObject } from 'n8n-workflow'; +import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; import type { AuthlessRequest } from '@/requests'; import type { Risk } from '@/security-audit/types'; @@ -35,17 +35,6 @@ export type PaginatedRequest = AuthenticatedRequest< >; export declare namespace ExecutionRequest { type Retry = AuthenticatedRequest<{ id: string }, {}, { loadWorkflow?: boolean }, {}>; - type Stop = AuthenticatedRequest<{ id: string }>; - type StopMany = AuthenticatedRequest< - {}, - {}, - { - status: Array | 'queued'>; - workflowId?: string; - startedAfter?: string; - startedBefore?: string; - } - >; } export declare namespace TestRunRequest { diff --git a/packages/cli/src/public-api/v1/controllers/executions.public.controller.ts b/packages/cli/src/public-api/v1/controllers/executions.public.controller.ts index 2b8e8a9fa57..287b9981cb4 100644 --- a/packages/cli/src/public-api/v1/controllers/executions.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/executions.public.controller.ts @@ -6,6 +6,10 @@ import { GetExecutionQueryDto, ListExecutionsQueryDto, MAX_ITEMS_PER_PAGE, + STOPPABLE_PUBLIC_TO_INTERNAL_STATUS, + StopManyExecutionsPublicDto, + StoppedExecutionPublicDto, + StoppedExecutionsPublicDto, TagIdsPublicDto, } from '@n8n/api-types'; import { ExecutionsConfig } from '@n8n/config'; @@ -21,19 +25,23 @@ import { Delete, Get, Param, + Post, PublicApiController, Put, Query, } from '@n8n/decorators'; import type { Response } from 'express'; -import { replaceCircularReferences } from 'n8n-workflow'; +import { replaceCircularReferences, WorkflowOperationError } from 'n8n-workflow'; +import { MissingExecutionStopError } from '@/errors/missing-execution-stop.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { ConflictError } from '@/errors/response-errors/conflict.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { EventService } from '@/events/event.service'; import { isRedactableExecution } from '@/executions/execution-redaction'; import { ExecutionRedactionServiceProxy } from '@/executions/execution-redaction-proxy.service'; import { ExecutionService } from '@/executions/execution.service'; +import type { StopResult } from '@/executions/execution.types'; import { decodeCursor, encodeNextCursor } from '@/public-api/v1/shared/services/pagination.service'; import { WorkflowSharingService } from '@/workflows/workflow-sharing.service'; @@ -99,7 +107,7 @@ export class ExecutionsPublicController { @ApiKeyScope('execution:list') @ApiSummary('Retrieve all executions') @ApiDescription('Retrieve all executions from your instance.') - @ApiTags(['Execution']) + @ApiTags(['Executions']) @ApiResponse(200, ExecutionListPublicDto) @ApiErrorResponse(404) async getExecutions( @@ -165,7 +173,7 @@ export class ExecutionsPublicController { @ApiKeyScope('execution:read') @ApiSummary('Retrieve an execution') @ApiDescription('Retrieve an execution from your instance.') - @ApiTags(['Execution']) + @ApiTags(['Executions']) @ApiResponse(200, ExecutionPublicDto) @ApiErrorResponse(404) async getExecution( @@ -222,7 +230,7 @@ export class ExecutionsPublicController { @ApiKeyScope('execution:delete') @ApiSummary('Delete an execution') @ApiDescription('Deletes an execution from your instance.') - @ApiTags(['Execution']) + @ApiTags(['Executions']) @ApiResponse(200, DeletedExecutionPublicDto) @ApiErrorResponse(400) @ApiErrorResponse(404) @@ -251,7 +259,7 @@ export class ExecutionsPublicController { @ApiKeyScope('executionTags:list') @ApiSummary('Get execution tags') @ApiDescription('Get annotation tags for an execution.') - @ApiTags(['Execution']) + @ApiTags(['Executions']) @ApiResponse(200, ExecutionTagsPublicDto) @ApiErrorResponse(400) @ApiErrorResponse(404) @@ -280,7 +288,7 @@ export class ExecutionsPublicController { @ApiKeyScope('executionTags:update') @ApiSummary('Update tags of an execution') @ApiDescription('Update annotation tags of an execution.') - @ApiTags(['Execution']) + @ApiTags(['Executions']) @ApiResponse(200, ExecutionTagsPublicDto) @ApiErrorResponse(404) async updateExecutionTags( @@ -308,6 +316,89 @@ export class ExecutionsPublicController { return tags.map(toPublicTag); } + @Post('/stop') + @ApiKeyScope('execution:stop') + @ApiSummary('Stop multiple executions') + @ApiDescription('Stop multiple executions from your instance based on filter criteria.') + @ApiTags(['Executions']) + @ApiResponse(200, StoppedExecutionsPublicDto) + @ApiErrorResponse(404) + async stopManyExecutions( + req: AuthenticatedRequest, + _res: Response, + @Body body: StopManyExecutionsPublicDto, + ): Promise { + const status = body.status.map((value) => STOPPABLE_PUBLIC_TO_INTERNAL_STATUS[value]); + + const sharedWorkflowsIds = await this.workflowSharingService.getSharedWorkflowIdsForScopes( + req.user, + ['workflow:execute'], + ); + + if (!sharedWorkflowsIds.length) { + return { stopped: 0 }; + } + + const { workflowId } = body; + + if (workflowId && workflowId !== 'all' && !sharedWorkflowsIds.includes(workflowId)) { + throw new NotFoundError('Workflow not found or not accessible'); + } + + const stopped = await this.executionService.stopMany( + { + workflowId: workflowId ?? 'all', + status, + startedAfter: body.startedAfter, + startedBefore: body.startedBefore, + }, + sharedWorkflowsIds, + ); + + return { stopped }; + } + + @Post('/:executionId/stop') + @ApiKeyScope('execution:stop') + @ApiSummary('Stop an execution') + @ApiDescription('Stop an execution by id.') + @ApiTags(['Executions']) + @ApiResponse(200, StoppedExecutionPublicDto) + @ApiErrorResponse(400) + @ApiErrorResponse(404) + @ApiErrorResponse(409) + async stopExecution( + req: AuthenticatedRequest, + _res: Response, + @Param('executionId') executionId: string, + ): Promise { + assertNumericExecutionId(executionId); + + const sharedWorkflowsIds = await this.workflowSharingService.getSharedWorkflowIdsForScopes( + req.user, + ['workflow:execute'], + ); + + if (!sharedWorkflowsIds.length) { + throw new NotFoundError('Not Found'); + } + + try { + const stopResult = await this.executionService.stop(executionId, sharedWorkflowsIds); + + return toStoppedExecutionPublicDto(stopResult); + } catch (error) { + if (error instanceof MissingExecutionStopError) { + throw new NotFoundError(error.message); + } + + if (error instanceof WorkflowOperationError) { + throw new ConflictError(error.message); + } + + throw error; + } + } } function toBaseFields(execution: PublicExecution) { @@ -402,3 +493,13 @@ function toPublicTag(tag: { id: string; name: string; createdAt: Date; updatedAt updatedAt: tag.updatedAt.toISOString(), }; } + +function toStoppedExecutionPublicDto(stopResult: StopResult): StoppedExecutionPublicDto { + return { + mode: stopResult.mode, + startedAt: stopResult.startedAt.toISOString(), + stoppedAt: stopResult.stoppedAt?.toISOString(), + finished: stopResult.finished, + status: stopResult.status, + }; +} 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 0a5322e340e..5fb52256366 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 @@ -1,5 +1,5 @@ import { Container } from '@n8n/di'; -import { type ExecutionStatus, replaceCircularReferences } from 'n8n-workflow'; +import { replaceCircularReferences } from 'n8n-workflow'; import { AbortedExecutionRetryError } from '@/errors/aborted-execution-retry.error'; import { MissingExecutionStopError } from '@/errors/missing-execution-stop.error'; @@ -27,8 +27,6 @@ const handleError = (error: unknown) => { type ExecutionHandlers = { retryExecution: PublicAPIEndpoint; - stopExecution: PublicAPIEndpoint; - stopManyExecutions: PublicAPIEndpoint; }; const executionHandlers: ExecutionHandlers = { @@ -60,67 +58,6 @@ const executionHandlers: ExecutionHandlers = { } }, ], - stopExecution: [ - publicApiScope('execution:stop'), - async (req, res) => { - const sharedWorkflowsIds = await Container.get( - WorkflowSharingService, - ).getSharedWorkflowIdsForScopes(req.user, ['workflow:execute']); - - if (!sharedWorkflowsIds.length) { - throw new NotFoundError('Not Found'); - } - - const { id } = req.params; - - try { - const stopResult = await Container.get(ExecutionService).stop(id, sharedWorkflowsIds); - - return res.json(replaceCircularReferences(stopResult)); - } catch (error) { - return handleError(error); - } - }, - ], - stopManyExecutions: [ - publicApiScope('execution:stop'), - async (req, res) => { - const { status: rawStatus, workflowId, startedAfter, startedBefore } = req.body; - const status: ExecutionStatus[] = rawStatus.map((x) => (x === 'queued' ? 'new' : x)); - if (!status || status.length === 0) { - return res.status(400).json({ - message: - 'Status filter is required. Please provide at least one status to stop executions.', - example: { - status: ['running', 'waiting', 'queued'], - }, - }); - } - - const sharedWorkflowsIds = await Container.get( - WorkflowSharingService, - ).getSharedWorkflowIdsForScopes(req.user, ['workflow:execute']); - - if (!sharedWorkflowsIds.length) { - return res.json({ stopped: 0 }); - } - - if (workflowId && workflowId !== 'all' && !sharedWorkflowsIds.includes(workflowId)) { - throw new NotFoundError('Workflow not found or not accessible'); - } - - const filter = { - workflowId: workflowId ?? 'all', - status, - startedAfter, - startedBefore, - }; - - const stopped = await Container.get(ExecutionService).stopMany(filter, sharedWorkflowsIds); - - return res.json({ stopped }); - }, - ], }; export = executionHandlers; diff --git a/packages/cli/src/public-api/v1/handlers/executions/spec/paths/deleteExecution.generated.yml b/packages/cli/src/public-api/v1/handlers/executions/spec/paths/deleteExecution.generated.yml index d47b7cd19e0..08ad48fbc8e 100644 --- a/packages/cli/src/public-api/v1/handlers/executions/spec/paths/deleteExecution.generated.yml +++ b/packages/cli/src/public-api/v1/handlers/executions/spec/paths/deleteExecution.generated.yml @@ -1,6 +1,6 @@ operationId: deleteExecution tags: - - Execution + - Executions summary: Delete an execution description: Deletes an execution from your instance. x-required-scope: execution:delete 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 index f31104e00c6..e7128a2046c 100644 --- 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 @@ -3,7 +3,7 @@ post: x-required-scope: execution:retry x-eov-operation-handler: v1/handlers/executions/executions.handler tags: - - Execution + - Executions summary: Retry an execution description: Retry an execution from your instance. parameters: diff --git a/packages/cli/src/public-api/v1/handlers/executions/spec/paths/executions.id.stop.yml b/packages/cli/src/public-api/v1/handlers/executions/spec/paths/executions.id.stop.yml deleted file mode 100644 index cdf870d7b6a..00000000000 --- a/packages/cli/src/public-api/v1/handlers/executions/spec/paths/executions.id.stop.yml +++ /dev/null @@ -1,21 +0,0 @@ -post: - x-eov-operation-id: stopExecution - x-required-scope: execution:stop - x-eov-operation-handler: v1/handlers/executions/executions.handler - tags: - - Execution - summary: Stop an execution - description: Stop an execution by id. - parameters: - - $ref: '../schemas/parameters/executionId.yml' - 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' diff --git a/packages/cli/src/public-api/v1/handlers/executions/spec/paths/executions.stop.yml b/packages/cli/src/public-api/v1/handlers/executions/spec/paths/executions.stop.yml deleted file mode 100644 index 07ffa5c97f2..00000000000 --- a/packages/cli/src/public-api/v1/handlers/executions/spec/paths/executions.stop.yml +++ /dev/null @@ -1,54 +0,0 @@ -post: - x-eov-operation-id: stopManyExecutions - x-required-scope: execution:stop - x-eov-operation-handler: v1/handlers/executions/executions.handler - tags: - - Execution - summary: Stop multiple executions - description: Stop multiple executions from your instance based on filter criteria. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - status - properties: - status: - type: array - description: Array of execution statuses to stop. Must include at least one status. - items: - type: string - enum: ['queued', 'running', 'waiting'] - example: ['queued', 'running', 'waiting'] - workflowId: - type: string - description: Optional workflow ID to filter executions. If not provided, will stop executions across all accessible workflows. - example: '2tUt1wbLX592XDdX' - startedAfter: - type: string - format: date-time - description: Only stop executions that started after this time. - example: '2024-01-01T00:00:00.000Z' - startedBefore: - type: string - format: date-time - description: Only stop executions that started before this time. - example: '2024-12-31T23:59:59.999Z' - responses: - '200': - description: Operation successful. - content: - application/json: - schema: - type: object - properties: - stopped: - type: number - description: The number of executions that were successfully stopped. - example: 5 - '400': - $ref: '../../../../shared/spec/responses/badRequest.yml' - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' diff --git a/packages/cli/src/public-api/v1/handlers/executions/spec/paths/getExecution.generated.yml b/packages/cli/src/public-api/v1/handlers/executions/spec/paths/getExecution.generated.yml index b5966153d07..93aa80ca452 100644 --- a/packages/cli/src/public-api/v1/handlers/executions/spec/paths/getExecution.generated.yml +++ b/packages/cli/src/public-api/v1/handlers/executions/spec/paths/getExecution.generated.yml @@ -1,6 +1,6 @@ operationId: getExecution tags: - - Execution + - Executions summary: Retrieve an execution description: Retrieve an execution from your instance. x-required-scope: execution:read diff --git a/packages/cli/src/public-api/v1/handlers/executions/spec/paths/getExecutionTags.generated.yml b/packages/cli/src/public-api/v1/handlers/executions/spec/paths/getExecutionTags.generated.yml index fa082efd2fe..eb552d2e264 100644 --- a/packages/cli/src/public-api/v1/handlers/executions/spec/paths/getExecutionTags.generated.yml +++ b/packages/cli/src/public-api/v1/handlers/executions/spec/paths/getExecutionTags.generated.yml @@ -1,6 +1,6 @@ operationId: getExecutionTags tags: - - Execution + - Executions summary: Get execution tags description: Get annotation tags for an execution. x-required-scope: executionTags:list diff --git a/packages/cli/src/public-api/v1/handlers/executions/spec/paths/getExecutions.generated.yml b/packages/cli/src/public-api/v1/handlers/executions/spec/paths/getExecutions.generated.yml index 52363d09aee..b0d04f98ec8 100644 --- a/packages/cli/src/public-api/v1/handlers/executions/spec/paths/getExecutions.generated.yml +++ b/packages/cli/src/public-api/v1/handlers/executions/spec/paths/getExecutions.generated.yml @@ -1,6 +1,6 @@ operationId: getExecutions tags: - - Execution + - Executions summary: Retrieve all executions description: Retrieve all executions from your instance. x-required-scope: execution:list diff --git a/packages/cli/src/public-api/v1/handlers/executions/spec/paths/stopExecution.generated.yml b/packages/cli/src/public-api/v1/handlers/executions/spec/paths/stopExecution.generated.yml new file mode 100644 index 00000000000..95a7f01212b --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/executions/spec/paths/stopExecution.generated.yml @@ -0,0 +1,78 @@ +operationId: stopExecution +tags: + - Executions +summary: Stop an execution +description: Stop an execution by id. +x-required-scope: execution:stop +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +parameters: + - schema: + type: string + required: true + name: executionId + in: path +responses: + '200': + description: Operation successful. + content: + application/json: + schema: + type: object + properties: + mode: + type: string + enum: + - cli + - error + - integrated + - internal + - manual + - retry + - trigger + - webhook + - evaluation + - chat + - agent + example: manual + startedAt: + type: string + format: date-time + description: The time at which the execution started. + stoppedAt: + type: string + format: date-time + description: The time at which the execution stopped. Absent when no stop time was recorded. + finished: + type: boolean + description: Whether the execution ran to completion. Use `status` instead. + example: false + deprecated: true + status: + type: string + enum: + - canceled + - crashed + - error + - new + - running + - success + - unknown + - waiting + example: canceled + required: + - mode + - startedAt + - finished + - status + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.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/executions/spec/paths/stopManyExecutions.generated.yml b/packages/cli/src/public-api/v1/handlers/executions/spec/paths/stopManyExecutions.generated.yml new file mode 100644 index 00000000000..e580c0df326 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/executions/spec/paths/stopManyExecutions.generated.yml @@ -0,0 +1,70 @@ +operationId: stopManyExecutions +tags: + - Executions +summary: Stop multiple executions +description: Stop multiple executions from your instance based on filter criteria. +x-required-scope: execution:stop +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + status: + type: array + items: + type: string + enum: + - queued + - running + - waiting + minItems: 1 + description: Array of execution statuses to stop. Must include at least one status. + example: + - queued + - running + - waiting + workflowId: + type: string + description: Optional workflow ID to filter executions. If not provided, will stop executions across all accessible workflows. The literal value `all` has the same effect as omitting it. + example: 2tUt1wbLX592XDdX + startedAfter: + type: string + format: date-time + description: Only stop executions that started after this time. + example: 2024-01-01T00:00:00.000Z + startedBefore: + type: string + format: date-time + description: Only stop executions that started before this time. + example: 2024-12-31T23:59:59.999Z + required: + - status +responses: + '200': + description: Operation successful. + content: + application/json: + schema: + type: object + properties: + stopped: + type: integer + description: The number of executions that were successfully stopped. + example: 5 + required: + - stopped + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '404': + $ref: ../../../../shared/spec/responses/notFound.yml + '415': + $ref: ../../../../shared/spec/responses/unsupportedMediaType.yml diff --git a/packages/cli/src/public-api/v1/handlers/executions/spec/paths/updateExecutionTags.generated.yml b/packages/cli/src/public-api/v1/handlers/executions/spec/paths/updateExecutionTags.generated.yml index f94064fb246..7662b6039dd 100644 --- a/packages/cli/src/public-api/v1/handlers/executions/spec/paths/updateExecutionTags.generated.yml +++ b/packages/cli/src/public-api/v1/handlers/executions/spec/paths/updateExecutionTags.generated.yml @@ -1,6 +1,6 @@ operationId: updateExecutionTags tags: - - Execution + - Executions summary: Update tags of an execution description: Update annotation tags of an execution. x-required-scope: executionTags:update diff --git a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml index e02ebc04e71..6cf53b8bb3d 100644 --- a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml +++ b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml @@ -16,6 +16,12 @@ paths: $ref: ./handlers/executions/spec/paths/getExecutionTags.generated.yml put: $ref: ./handlers/executions/spec/paths/updateExecutionTags.generated.yml + /executions/stop: + post: + $ref: ./handlers/executions/spec/paths/stopManyExecutions.generated.yml + /executions/{executionId}/stop: + post: + $ref: ./handlers/executions/spec/paths/stopExecution.generated.yml /git-connections: post: $ref: ./handlers/git-connections/spec/paths/createGitConnection.generated.yml diff --git a/packages/cli/src/public-api/v1/openapi.yml b/packages/cli/src/public-api/v1/openapi.yml index 7fb427609ad..e81e5e7068e 100644 --- a/packages/cli/src/public-api/v1/openapi.yml +++ b/packages/cli/src/public-api/v1/openapi.yml @@ -35,7 +35,7 @@ tags: description: API capability discovery - name: Evaluation description: Operations about evaluation test runs - - name: Execution + - name: Executions description: Operations about executions - name: Folders description: Operations about folders @@ -103,10 +103,6 @@ paths: $ref: './handlers/credentials/spec/paths/credentials.id.transfer.yml' /executions/{id}/retry: $ref: './handlers/executions/spec/paths/executions.id.retry.yml' - /executions/{id}/stop: - $ref: './handlers/executions/spec/paths/executions.id.stop.yml' - /executions/stop: - $ref: './handlers/executions/spec/paths/executions.stop.yml' /tags: $ref: './handlers/tags/spec/paths/tags.yml' /tags/{id}: diff --git a/packages/cli/test/integration/public-api/executions.test.ts b/packages/cli/test/integration/public-api/executions.test.ts index b375abaa915..8b8c00dc6ba 100644 --- a/packages/cli/test/integration/public-api/executions.test.ts +++ b/packages/cli/test/integration/public-api/executions.test.ts @@ -1275,6 +1275,15 @@ describe('POST /executions/:id/stop', () => { testWithAPIKey('post', '/executions/1/stop', 'abcXYZ'), ); + test.each(['abc', '1.5', '-1', '0', '000'])( + 'should reject an execution id that cannot exist with 400: %s', + async (executionId) => { + const response = await authUser1Agent.post(`/executions/${executionId}/stop`); + + expect(response.statusCode).toBe(400); + }, + ); + test('should stop a running execution', async () => { const mockedStopResponse = { mode: 'manual', @@ -1356,6 +1365,83 @@ describe('POST /executions/:id/stop', () => { executionServiceSpy.mockRestore(); }); + + test('should omit stoppedAt when the service returns none', async () => { + const executionServiceSpy = vi + .spyOn(Container.get(ExecutionService), 'stop') + .mockResolvedValue({ + mode: 'manual', + startedAt: new Date(), + stoppedAt: undefined, + finished: false, + status: 'canceled', + }); + + const workflow = await createWorkflow({}, user1); + const execution = await createExecution({ status: 'running', finished: false }, workflow); + + const response = await authUser1Agent.post(`/executions/${execution.id}/stop`); + + expect(response.statusCode).toBe(200); + expect(response.body).not.toHaveProperty('stoppedAt'); + + executionServiceSpy.mockRestore(); + }); + + test('should return 404 when the execution is missing but a workflow is accessible', async () => { + await createWorkflow({}, user1); + + const response = await authUser1Agent.post('/executions/99999999/stop'); + + expect(response.statusCode).toBe(404); + expect(response.body.message).toBe('Failed to find execution to stop'); + }); + + test('should return 409 when the execution is in a state that cannot be stopped', async () => { + const workflow = await createWorkflow({}, user1); + const execution = await createSuccessfulExecution(workflow); + + const response = await authUser1Agent.post(`/executions/${execution.id}/stop`); + + expect(response.statusCode).toBe(409); + expect(response.body.message).toContain('is currently success'); + }); + + test('should stop when the API key has the "execution:stop" scope', async () => { + const scopedOwner = await createOwnerWithApiKey({ scopes: ['execution:stop'] }); + const scopedAgent = testServer.publicApiAgentFor(scopedOwner); + + const executionServiceSpy = vi + .spyOn(Container.get(ExecutionService), 'stop') + .mockResolvedValue({ + mode: 'manual', + startedAt: new Date(), + stoppedAt: new Date(), + finished: false, + status: 'canceled', + }); + + const workflow = await createWorkflow({}, scopedOwner); + const execution = await createExecution({ status: 'running', finished: false }, workflow); + + const response = await scopedAgent.post(`/executions/${execution.id}/stop`); + + expect(response.statusCode).toBe(200); + + executionServiceSpy.mockRestore(); + }); + + test('should return 403 when the API key lacks the "execution:stop" scope', async () => { + const scopedOwner = await createOwnerWithApiKey({ scopes: ['execution:read'] }); + const scopedAgent = testServer.publicApiAgentFor(scopedOwner); + + const workflow = await createWorkflow({}, scopedOwner); + const execution = await createExecution({ status: 'running', finished: false }, workflow); + + const response = await scopedAgent.post(`/executions/${execution.id}/stop`); + + expect(response.statusCode).toBe(403); + }); }); describe('POST /executions/stop', () => { @@ -1375,8 +1461,7 @@ describe('POST /executions/stop', () => { const response = await authUser1Agent.post('/executions/stop').send({ status: [] }); expect(response.statusCode).toBe(400); - expect(response.body.message).toContain('Status filter is required'); - expect(response.body.example).toBeDefined(); + expect(response.body.message).toBe('request/body/status must include at least one status'); }); test('should stop multiple running executions', async () => { @@ -1543,4 +1628,85 @@ describe('POST /executions/stop', () => { executionServiceSpy.mockRestore(); }); + + test('should map the queued status to the internal new status', async () => { + const executionServiceSpy = vi + .spyOn(Container.get(ExecutionService), 'stopMany') + .mockResolvedValue(1); + + await createWorkflow({}, user1); + + const response = await authUser1Agent + .post('/executions/stop') + .send({ status: ['queued', 'running'] }); + + expect(response.statusCode).toBe(200); + expect(executionServiceSpy.mock.calls[0][0].status).toEqual(['new', 'running']); + + executionServiceSpy.mockRestore(); + }); + + test('should return 400 for a status outside the stoppable set', async () => { + const response = await authUser1Agent.post('/executions/stop').send({ status: ['success'] }); + + expect(response.statusCode).toBe(400); + }); + + test('should return 404 for a workflowId the caller cannot access', async () => { + await createWorkflow({}, user1); + const otherWorkflow = await createWorkflow({}, owner); + + const response = await authUser1Agent + .post('/executions/stop') + .send({ status: ['running'], workflowId: otherWorkflow.id }); + + expect(response.statusCode).toBe(404); + expect(response.body.message).toBe('Workflow not found or not accessible'); + }); + + test('should accept "all" as the workflowId', async () => { + const executionServiceSpy = vi + .spyOn(Container.get(ExecutionService), 'stopMany') + .mockResolvedValue(4); + + await createWorkflow({}, user1); + + const response = await authUser1Agent + .post('/executions/stop') + .send({ status: ['running'], workflowId: 'all' }); + + expect(response.statusCode).toBe(200); + expect(executionServiceSpy.mock.calls[0][0].workflowId).toBe('all'); + + executionServiceSpy.mockRestore(); + }); + + test('should stop many when the API key has the "execution:stop" scope', async () => { + const scopedOwner = await createOwnerWithApiKey({ scopes: ['execution:stop'] }); + const scopedAgent = testServer.publicApiAgentFor(scopedOwner); + + const executionServiceSpy = vi + .spyOn(Container.get(ExecutionService), 'stopMany') + .mockResolvedValue(2); + + await createWorkflow({}, scopedOwner); + + const response = await scopedAgent.post('/executions/stop').send({ status: ['running'] }); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ stopped: 2 }); + + executionServiceSpy.mockRestore(); + }); + + test('should return 403 when the API key lacks the "execution:stop" scope', async () => { + const scopedOwner = await createOwnerWithApiKey({ scopes: ['execution:read'] }); + const scopedAgent = testServer.publicApiAgentFor(scopedOwner); + + await createWorkflow({}, scopedOwner); + + const response = await scopedAgent.post('/executions/stop').send({ status: ['running'] }); + + expect(response.statusCode).toBe(403); + }); }); diff --git a/packages/nodes-base/nodes/N8n/n8n-api-coverage.json b/packages/nodes-base/nodes/N8n/n8n-api-coverage.json index 3482c118257..2088ec58d09 100644 --- a/packages/nodes-base/nodes/N8n/n8n-api-coverage.json +++ b/packages/nodes-base/nodes/N8n/n8n-api-coverage.json @@ -122,7 +122,7 @@ "POST /executions/{id}/retry": { "status": "gap" }, - "POST /executions/{id}/stop": { + "POST /executions/{executionId}/stop": { "status": "gap" }, "POST /executions/stop": {