refactor(API): Migrate the executions stop endpoints to the decorator pattern (no-changelog) (#37531)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Uddish Verma 2026-09-03 15:40:03 +00:00 committed by GitHub
parent bc8e5ea79c
commit 4a53faff09
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 539 additions and 171 deletions

View File

@ -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<StoppablePublicExecutionStatus, ExecutionStatus>;
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',
}),
}) {}

View File

@ -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) {}

View File

@ -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<string, ZodOpenAPIMetadata>;
export const stoppedCountOpenApi: ZodOpenAPIMetadata = {
description: 'The number of executions that were successfully stopped.',
example: 5,
};

View File

@ -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';

View File

@ -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<Extract<ExecutionStatus, 'waiting' | 'running'> | 'queued'>;
workflowId?: string;
startedAfter?: string;
startedBefore?: string;
}
>;
}
export declare namespace TestRunRequest {

View File

@ -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<StoppedExecutionsPublicDto> {
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<StoppedExecutionPublicDto> {
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,
};
}

View File

@ -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<ExecutionRequest.Retry>;
stopExecution: PublicAPIEndpoint<ExecutionRequest.Stop>;
stopManyExecutions: PublicAPIEndpoint<ExecutionRequest.StopMany>;
};
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;

View File

@ -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

View File

@ -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:

View File

@ -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'

View File

@ -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'

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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}:

View File

@ -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);
});
});

View File

@ -122,7 +122,7 @@
"POST /executions/{id}/retry": {
"status": "gap"
},
"POST /executions/{id}/stop": {
"POST /executions/{executionId}/stop": {
"status": "gap"
},
"POST /executions/stop": {