mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
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:
parent
bc8e5ea79c
commit
4a53faff09
|
|
@ -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',
|
||||
}),
|
||||
}) {}
|
||||
|
|
@ -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) {}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
@ -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'
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@
|
|||
"POST /executions/{id}/retry": {
|
||||
"status": "gap"
|
||||
},
|
||||
"POST /executions/{id}/stop": {
|
||||
"POST /executions/{executionId}/stop": {
|
||||
"status": "gap"
|
||||
},
|
||||
"POST /executions/stop": {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user