mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-30 18:01:23 +08:00
refactor(API): Route public API executions through shared execution services (no-changelog) (#35582)
This commit is contained in:
@@ -32,43 +32,43 @@ describe('ExecutionRepository', () => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('getExecutionsCountForPublicApi', () => {
|
||||
describe('countInWorkflows', () => {
|
||||
test('should get executions matching all filter parameters', async () => {
|
||||
const mockCount = 20;
|
||||
const params = {
|
||||
const workflowIds = ['3', '4'];
|
||||
const options = {
|
||||
limit: 10,
|
||||
lastId: '3',
|
||||
workflowIds: ['3', '4'],
|
||||
};
|
||||
|
||||
entityManager.count.mockResolvedValueOnce(mockCount);
|
||||
const result = await executionRepository.getExecutionsCountForPublicApi(params);
|
||||
const result = await executionRepository.countInWorkflows(workflowIds, options);
|
||||
|
||||
expect(entityManager.count).toHaveBeenCalledWith(ExecutionEntity, {
|
||||
where: {
|
||||
id: LessThan(params.lastId),
|
||||
workflowId: In(params.workflowIds),
|
||||
id: LessThan(options.lastId),
|
||||
workflowId: In(workflowIds),
|
||||
},
|
||||
take: params.limit,
|
||||
take: options.limit,
|
||||
});
|
||||
expect(result).toBe(mockCount);
|
||||
});
|
||||
|
||||
test('should get executions matching the workflowIds filter', async () => {
|
||||
const mockCount = 12;
|
||||
const params = {
|
||||
const workflowIds = ['7', '8'];
|
||||
const options = {
|
||||
limit: 10,
|
||||
workflowIds: ['7', '8'],
|
||||
};
|
||||
|
||||
entityManager.count.mockResolvedValueOnce(mockCount);
|
||||
const result = await executionRepository.getExecutionsCountForPublicApi(params);
|
||||
const result = await executionRepository.countInWorkflows(workflowIds, options);
|
||||
|
||||
expect(entityManager.count).toHaveBeenCalledWith(ExecutionEntity, {
|
||||
where: {
|
||||
workflowId: In(params.workflowIds),
|
||||
workflowId: In(workflowIds),
|
||||
},
|
||||
take: params.limit,
|
||||
take: options.limit,
|
||||
});
|
||||
expect(result).toBe(mockCount);
|
||||
});
|
||||
@@ -86,19 +86,21 @@ describe('ExecutionRepository', () => {
|
||||
'should find with id less than "$lastId" and not in "$excludedExecutionsIds"',
|
||||
async ({ lastId, excludedExecutionsIds, expectedIdCondition }) => {
|
||||
const mockCount = 15;
|
||||
const params = {
|
||||
const workflowIds = ['wf-1'];
|
||||
const options = {
|
||||
limit: 10,
|
||||
...(lastId ? { lastId } : {}),
|
||||
...(excludedExecutionsIds ? { excludedExecutionsIds } : {}),
|
||||
};
|
||||
entityManager.count.mockResolvedValueOnce(mockCount);
|
||||
const result = await executionRepository.getExecutionsCountForPublicApi(params);
|
||||
const result = await executionRepository.countInWorkflows(workflowIds, options);
|
||||
|
||||
expect(entityManager.count).toHaveBeenCalledWith(ExecutionEntity, {
|
||||
where: {
|
||||
workflowId: In(workflowIds),
|
||||
...(expectedIdCondition ? { id: expectedIdCondition } : {}),
|
||||
},
|
||||
take: params.limit,
|
||||
take: options.limit,
|
||||
});
|
||||
expect(result).toBe(mockCount);
|
||||
},
|
||||
@@ -119,15 +121,16 @@ describe('ExecutionRepository', () => {
|
||||
`('should retrieve all $filterStatus executions', async ({ filterStatus, entityStatus }) => {
|
||||
const limit = 10;
|
||||
const mockCount = 20;
|
||||
const workflowIds = ['wf-1'];
|
||||
|
||||
entityManager.count.mockResolvedValueOnce(mockCount);
|
||||
const result = await executionRepository.getExecutionsCountForPublicApi({
|
||||
const result = await executionRepository.countInWorkflows(workflowIds, {
|
||||
limit,
|
||||
status: filterStatus,
|
||||
});
|
||||
|
||||
expect(entityManager.count).toHaveBeenCalledWith(ExecutionEntity, {
|
||||
where: { status: entityStatus },
|
||||
where: { status: entityStatus, workflowId: In(workflowIds) },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
@@ -137,12 +140,13 @@ describe('ExecutionRepository', () => {
|
||||
test('should find all executions without status filter', async () => {
|
||||
const limit = 10;
|
||||
const mockCount = 20;
|
||||
const workflowIds = ['wf-1'];
|
||||
|
||||
entityManager.count.mockResolvedValueOnce(mockCount);
|
||||
const result = await executionRepository.getExecutionsCountForPublicApi({ limit });
|
||||
const result = await executionRepository.countInWorkflows(workflowIds, { limit });
|
||||
|
||||
expect(entityManager.count).toHaveBeenCalledWith(ExecutionEntity, {
|
||||
where: {},
|
||||
where: { workflowId: In(workflowIds) },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ export class AnnotationTagMappingRepository extends Repository<AnnotationTagMapp
|
||||
return await this.manager.transaction(async (tx) => {
|
||||
await tx.delete(AnnotationTagMapping, { annotationId });
|
||||
|
||||
if (tagIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagMappings = tagIds.map((tagId) => ({
|
||||
annotationId,
|
||||
tagId,
|
||||
|
||||
@@ -628,19 +628,19 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
||||
});
|
||||
}
|
||||
|
||||
async getExecutionsCountForPublicApi(params: {
|
||||
limit: number;
|
||||
lastId?: string;
|
||||
workflowIds?: string[];
|
||||
status?: ExecutionStatus;
|
||||
excludedExecutionsIds?: string[];
|
||||
}): Promise<number> {
|
||||
const executionsCount = await this.count({
|
||||
where: this.getFindExecutionsForPublicApiCondition(params),
|
||||
take: params.limit,
|
||||
async countInWorkflows(
|
||||
workflowIds: string[],
|
||||
options: {
|
||||
limit: number;
|
||||
lastId?: string;
|
||||
status?: ExecutionStatus;
|
||||
excludedExecutionsIds?: string[];
|
||||
},
|
||||
): Promise<number> {
|
||||
return await this.count({
|
||||
where: this.getFindManyInWorkflowsCondition(workflowIds, options),
|
||||
take: options.limit,
|
||||
});
|
||||
|
||||
return executionsCount;
|
||||
}
|
||||
|
||||
private getStatusCondition(status?: ExecutionStatus) {
|
||||
@@ -667,19 +667,21 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
||||
return condition;
|
||||
}
|
||||
|
||||
getFindExecutionsForPublicApiCondition(params: {
|
||||
lastId?: string;
|
||||
workflowIds?: string[];
|
||||
status?: ExecutionStatus;
|
||||
excludedExecutionsIds?: string[];
|
||||
}) {
|
||||
getFindManyInWorkflowsCondition(
|
||||
workflowIds: string[],
|
||||
options: {
|
||||
lastId?: string;
|
||||
status?: ExecutionStatus;
|
||||
excludedExecutionsIds?: string[];
|
||||
} = {},
|
||||
) {
|
||||
const where: FindOptionsWhere<IExecutionFlattedDb> = {
|
||||
...this.getIdCondition({
|
||||
lastId: params.lastId,
|
||||
excludedExecutionsIds: params.excludedExecutionsIds,
|
||||
lastId: options.lastId,
|
||||
excludedExecutionsIds: options.excludedExecutionsIds,
|
||||
}),
|
||||
...this.getStatusCondition(params.status),
|
||||
...(params.workflowIds && { workflowId: In(params.workflowIds) }),
|
||||
...this.getStatusCondition(options.status),
|
||||
workflowId: In(workflowIds),
|
||||
};
|
||||
|
||||
return where;
|
||||
|
||||
@@ -123,8 +123,6 @@ export default defineConfig(
|
||||
'./src/public-api/v1/handlers/data-tables/data-tables.service.ts',
|
||||
'./src/public-api/v1/handlers/discover/discover.handler.ts',
|
||||
'./src/public-api/v1/handlers/evaluations/evaluations.handler.ts',
|
||||
'./src/public-api/v1/handlers/executions/executions.handler.ts',
|
||||
'./src/public-api/v1/handlers/executions/executions.service.ts',
|
||||
'./src/public-api/v1/handlers/projects/projects.handler.ts',
|
||||
'./src/public-api/v1/handlers/users/users.handler.ee.ts',
|
||||
'./src/public-api/v1/handlers/users/users.service.ee.ts',
|
||||
@@ -249,7 +247,6 @@ export default defineConfig(
|
||||
'./src/eventbus/message-event-bus/message-event-bus.ts',
|
||||
'./src/evaluation.ee/evaluation-collection.service.ts',
|
||||
'./src/evaluation.ee/test-runner/test-runner.service.ee.ts',
|
||||
'./src/public-api/v1/handlers/executions/executions.handler.ts',
|
||||
'./src/public-api/v1/handlers/tags/tags.handler.ts',
|
||||
'./src/public-api/v1/handlers/users/users.service.ee.ts',
|
||||
'./src/public-api/v1/handlers/workflows/workflows.handler.ts',
|
||||
|
||||
@@ -420,12 +420,13 @@ describe('ExecutionPersistence', () => {
|
||||
expect(execution?.data?.resultData?.runData).toHaveProperty('bigNode');
|
||||
});
|
||||
|
||||
it('getExecutionsForPublicApi omits oversized data and flags it', async () => {
|
||||
it('findManyInWorkflows omits oversized data and flags it', async () => {
|
||||
const executionPersistence = Container.get(ExecutionPersistence);
|
||||
const { workflow } = await createSizedExecution(2 * ONE_MB);
|
||||
|
||||
const executions = (await executionPersistence.getExecutionsForPublicApi(
|
||||
{ limit: 10, includeData: true, workflowIds: [workflow.id] },
|
||||
const executions = (await executionPersistence.findManyInWorkflows(
|
||||
[workflow.id],
|
||||
{ limit: 10, includeData: true },
|
||||
ONE_MB,
|
||||
)) as IExecutionResponse[];
|
||||
|
||||
|
||||
@@ -1694,7 +1694,7 @@ describe('ExecutionPersistence', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExecutionsForPublicApi', () => {
|
||||
describe('findManyInWorkflows', () => {
|
||||
const wf = 'wf-1';
|
||||
const where = { workflowId: wf };
|
||||
const publicApiSelect = [
|
||||
@@ -1711,18 +1711,19 @@ describe('ExecutionPersistence', () => {
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
executionRepository.getFindExecutionsForPublicApiCondition.mockReturnValue(where);
|
||||
executionRepository.getFindManyInWorkflowsCondition.mockReturnValue(where);
|
||||
});
|
||||
|
||||
it('should query per the repository where condition, without data when not requested', async () => {
|
||||
const executionPersistence = createPersistenceService('db');
|
||||
executionRepository.findMultipleExecutions.mockResolvedValue([]);
|
||||
const params = { limit: 10, workflowIds: [wf] };
|
||||
const options = { limit: 10 };
|
||||
|
||||
await executionPersistence.getExecutionsForPublicApi(params);
|
||||
await executionPersistence.findManyInWorkflows([wf], options);
|
||||
|
||||
expect(executionRepository.getFindExecutionsForPublicApiCondition).toHaveBeenCalledWith(
|
||||
params,
|
||||
expect(executionRepository.getFindManyInWorkflowsCondition).toHaveBeenCalledWith(
|
||||
[wf],
|
||||
options,
|
||||
);
|
||||
expect(executionRepository.findMultipleExecutions).toHaveBeenCalledWith(
|
||||
{ select: publicApiSelect, where, order: { id: 'DESC' }, take: 10 },
|
||||
@@ -1755,7 +1756,7 @@ describe('ExecutionPersistence', () => {
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await executionPersistence.getExecutionsForPublicApi({
|
||||
const result = await executionPersistence.findManyInWorkflows([wf], {
|
||||
limit: 10,
|
||||
includeData: true,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import type {
|
||||
AnnotationTagMappingRepository,
|
||||
ExecutionAnnotationRepository,
|
||||
IExecutionBase,
|
||||
IExecutionDb,
|
||||
IExecutionResponse,
|
||||
ExecutionRepository,
|
||||
@@ -10,6 +13,7 @@ import type {
|
||||
} from '@n8n/db';
|
||||
import type { WorkflowHistory } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { QueryFailedError } from '@n8n/typeorm';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import type { IRun, IRunData, IRunExecutionData, ITaskData } from 'n8n-workflow';
|
||||
import { ManualExecutionCancelledError, WorkflowOperationError } from 'n8n-workflow';
|
||||
@@ -18,6 +22,7 @@ import type { ActiveExecutions } from '@/active-executions';
|
||||
import type { ConcurrencyControlService } from '@/concurrency/concurrency-control.service';
|
||||
import { AbortedExecutionRetryError } from '@/errors/aborted-execution-retry.error';
|
||||
import { MissingExecutionStopError } from '@/errors/missing-execution-stop.error';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { MissingExecutionDataError } from '@/executions/execution-data/missing-execution-data.error';
|
||||
import type { ExecutionPersistence } from '@/executions/execution-persistence';
|
||||
@@ -34,6 +39,8 @@ import type { WorkflowRunner } from '@/workflow-runner';
|
||||
describe('ExecutionService', () => {
|
||||
const scalingService = mockInstance(ScalingService);
|
||||
const activeExecutions = mock<ActiveExecutions>();
|
||||
const executionAnnotationRepository = mock<ExecutionAnnotationRepository>();
|
||||
const annotationTagMappingRepository = mock<AnnotationTagMappingRepository>();
|
||||
const executionRepository = mock<ExecutionRepository>();
|
||||
const executionPersistence = mock<ExecutionPersistence>();
|
||||
const workflowHistoryRepository = mock<WorkflowHistoryRepository>();
|
||||
@@ -48,8 +55,8 @@ describe('ExecutionService', () => {
|
||||
globalConfig,
|
||||
mock(),
|
||||
activeExecutions,
|
||||
mock(),
|
||||
mock(),
|
||||
executionAnnotationRepository,
|
||||
annotationTagMappingRepository,
|
||||
executionRepository,
|
||||
executionPersistence,
|
||||
workflowHistoryRepository,
|
||||
@@ -81,7 +88,7 @@ describe('ExecutionService', () => {
|
||||
* Arrange
|
||||
*/
|
||||
const execution = mock<IExecutionResponse>({ id: '123', data: { resultData: {} } });
|
||||
executionPersistence.findIfSharedUnflatten.mockResolvedValue(execution);
|
||||
executionPersistence.findOneInWorkflows.mockResolvedValue(execution);
|
||||
executionRedactionServiceProxy.processExecution.mockResolvedValue(execution);
|
||||
|
||||
const req = mock<ExecutionRequest.GetOne>({
|
||||
@@ -108,7 +115,7 @@ describe('ExecutionService', () => {
|
||||
* Arrange
|
||||
*/
|
||||
const execution = mock<IExecutionResponse>({ id: '123', data: { resultData: {} } });
|
||||
executionPersistence.findIfSharedUnflatten.mockResolvedValue(execution);
|
||||
executionPersistence.findOneInWorkflows.mockResolvedValue(execution);
|
||||
executionRedactionServiceProxy.processExecution.mockResolvedValue(execution);
|
||||
|
||||
const req = mock<ExecutionRequest.GetOne>({
|
||||
@@ -131,7 +138,7 @@ describe('ExecutionService', () => {
|
||||
});
|
||||
|
||||
it('should surface missing execution data as a user-facing not-found error', async () => {
|
||||
executionPersistence.findIfSharedUnflatten.mockRejectedValue(
|
||||
executionPersistence.findOneInWorkflows.mockRejectedValue(
|
||||
new MissingExecutionDataError({ workflowId: 'workflow-1', executionId: '123' }),
|
||||
);
|
||||
|
||||
@@ -147,7 +154,7 @@ describe('ExecutionService', () => {
|
||||
|
||||
it('should rethrow errors other than missing execution data unchanged', async () => {
|
||||
const error = new Error('boom');
|
||||
executionPersistence.findIfSharedUnflatten.mockRejectedValue(error);
|
||||
executionPersistence.findOneInWorkflows.mockRejectedValue(error);
|
||||
|
||||
const req = mock<ExecutionRequest.GetOne>({ params: { id: '123' }, query: {} });
|
||||
|
||||
@@ -856,4 +863,160 @@ describe('ExecutionService', () => {
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findManyAndCount', () => {
|
||||
it('should exclude live running executions when excludeRunning is true', async () => {
|
||||
activeExecutions.getActiveExecutions.mockReturnValue([
|
||||
{ id: 'run-1', status: 'running' },
|
||||
{ id: 'wait-1', status: 'waiting' },
|
||||
] as never);
|
||||
executionPersistence.findManyInWorkflows.mockResolvedValue([
|
||||
mock<IExecutionBase>({ id: '10' }),
|
||||
]);
|
||||
executionRepository.countInWorkflows.mockResolvedValue(0);
|
||||
|
||||
await executionService.findManyAndCount(['wf-1'], {
|
||||
limit: 10,
|
||||
excludeRunning: true,
|
||||
});
|
||||
|
||||
expect(executionPersistence.findManyInWorkflows).toHaveBeenCalledWith(
|
||||
['wf-1'],
|
||||
expect.objectContaining({ excludedExecutionsIds: ['run-1'] }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not exclude running executions when excludeRunning is false', async () => {
|
||||
executionPersistence.findManyInWorkflows.mockResolvedValue([]);
|
||||
executionRepository.countInWorkflows.mockResolvedValue(0);
|
||||
|
||||
await executionService.findManyAndCount(['wf-1'], {
|
||||
limit: 10,
|
||||
excludeRunning: false,
|
||||
});
|
||||
|
||||
expect(executionPersistence.findManyInWorkflows).toHaveBeenCalledWith(
|
||||
['wf-1'],
|
||||
expect.objectContaining({ excludedExecutionsIds: undefined }),
|
||||
undefined,
|
||||
);
|
||||
expect(activeExecutions.getActiveExecutions).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteOne', () => {
|
||||
it('should reject deleting a running execution', async () => {
|
||||
executionPersistence.findOneInWorkflows.mockResolvedValue(
|
||||
mock<IExecutionBase>({ id: '1', status: 'running', workflowId: 'wf-1' }),
|
||||
);
|
||||
|
||||
await expect(executionService.deleteOne('1', ['wf-1'])).rejects.toThrow(BadRequestError);
|
||||
expect(executionPersistence.hardDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should remove from concurrency control when deleting a new execution', async () => {
|
||||
const execution = mock<IExecutionBase>({
|
||||
id: '1',
|
||||
status: 'new',
|
||||
mode: 'manual',
|
||||
workflowId: 'wf-1',
|
||||
storedAt: 'db',
|
||||
});
|
||||
executionPersistence.findOneInWorkflows.mockResolvedValue(execution);
|
||||
|
||||
await executionService.deleteOne('1', ['wf-1']);
|
||||
|
||||
expect(concurrencyControl.remove).toHaveBeenCalledWith({
|
||||
executionId: '1',
|
||||
mode: 'manual',
|
||||
});
|
||||
expect(executionPersistence.hardDelete).toHaveBeenCalledWith({
|
||||
workflowId: 'wf-1',
|
||||
executionId: '1',
|
||||
storedAt: 'db',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw NotFoundError when execution is inaccessible', async () => {
|
||||
executionPersistence.findOneInWorkflows.mockResolvedValue(undefined);
|
||||
|
||||
await expect(executionService.deleteOne('1', ['wf-1'])).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExecutionTags', () => {
|
||||
it('should return mapped tags for an accessible execution', async () => {
|
||||
executionPersistence.findOneInWorkflows.mockResolvedValue(mock<IExecutionBase>({ id: '1' }));
|
||||
executionAnnotationRepository.findOne.mockResolvedValue({
|
||||
tags: [
|
||||
{
|
||||
id: 'tag-1',
|
||||
name: 'Important',
|
||||
createdAt: new Date('2025-01-01'),
|
||||
updatedAt: new Date('2025-01-02'),
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
|
||||
const result = await executionService.getExecutionTags('1', ['wf-1']);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: 'tag-1',
|
||||
name: 'Important',
|
||||
createdAt: new Date('2025-01-01'),
|
||||
updatedAt: new Date('2025-01-02'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should throw NotFoundError when execution is inaccessible', async () => {
|
||||
executionPersistence.findOneInWorkflows.mockResolvedValue(undefined);
|
||||
|
||||
await expect(executionService.getExecutionTags('1', ['wf-1'])).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateExecutionTags', () => {
|
||||
it('should overwrite tags and return the updated list', async () => {
|
||||
executionPersistence.findOneInWorkflows.mockResolvedValue(mock<IExecutionBase>({ id: '1' }));
|
||||
executionAnnotationRepository.findOneOrFail
|
||||
.mockResolvedValueOnce({ id: 42 } as never)
|
||||
.mockResolvedValueOnce({
|
||||
tags: [
|
||||
{
|
||||
id: 'tag-1',
|
||||
name: 'A',
|
||||
createdAt: new Date('2025-01-01'),
|
||||
updatedAt: new Date('2025-01-01'),
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
|
||||
const result = await executionService.updateExecutionTags('1', ['tag-1'], ['wf-1']);
|
||||
|
||||
expect(annotationTagMappingRepository.overwriteTags).toHaveBeenCalledWith(42, ['tag-1']);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: 'tag-1',
|
||||
name: 'A',
|
||||
createdAt: new Date('2025-01-01'),
|
||||
updatedAt: new Date('2025-01-01'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should map QueryFailedError to NotFoundError for missing tags', async () => {
|
||||
executionPersistence.findOneInWorkflows.mockResolvedValue(mock<IExecutionBase>({ id: '1' }));
|
||||
executionAnnotationRepository.findOneOrFail.mockResolvedValue({ id: 42 } as never);
|
||||
annotationTagMappingRepository.overwriteTags.mockRejectedValue(
|
||||
new QueryFailedError('INSERT', [], new Error('FK')),
|
||||
);
|
||||
|
||||
await expect(
|
||||
executionService.updateExecutionTags('1', ['missing'], ['wf-1']),
|
||||
).rejects.toThrow('Some tags not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -521,43 +521,37 @@ export class ExecutionPersistence {
|
||||
});
|
||||
}
|
||||
|
||||
/** Find an execution scoped to shared workflows, with unflattened data and annotation (a display read). */
|
||||
async findIfSharedUnflatten(
|
||||
/**
|
||||
* Find one execution scoped to the given workflow IDs (display read).
|
||||
* Defaults: include data + annotation, unflattened.
|
||||
*/
|
||||
async findOneInWorkflows(
|
||||
executionId: string,
|
||||
sharedWorkflowIds: string[],
|
||||
maxDataSizeBytes?: number,
|
||||
) {
|
||||
return await this.findSingleExecution(executionId, {
|
||||
where: { workflowId: In(sharedWorkflowIds) },
|
||||
includeData: true,
|
||||
unflattenData: true,
|
||||
includeAnnotation: true,
|
||||
maxDataSizeBytes,
|
||||
});
|
||||
}
|
||||
|
||||
/** Find an execution scoped to the given workflows for the public API (a display read). */
|
||||
async getExecutionInWorkflowsForPublicApi(
|
||||
id: string,
|
||||
workflowIds: string[],
|
||||
includeData?: boolean,
|
||||
maxDataSizeBytes?: number,
|
||||
): Promise<IExecutionBase | undefined> {
|
||||
return await this.findSingleExecution(id, {
|
||||
options: {
|
||||
includeData?: boolean;
|
||||
includeAnnotation?: boolean;
|
||||
maxDataSizeBytes?: number;
|
||||
} = {},
|
||||
): Promise<IExecutionResponse | IExecutionBase | undefined> {
|
||||
const { includeData = true, includeAnnotation = true, maxDataSizeBytes } = options;
|
||||
|
||||
return await this.findSingleExecution(executionId, {
|
||||
where: { workflowId: In(workflowIds) },
|
||||
includeData,
|
||||
unflattenData: true,
|
||||
includeAnnotation,
|
||||
maxDataSizeBytes,
|
||||
});
|
||||
}
|
||||
|
||||
/** Find executions scoped to the given workflows for the public API, with data per `storedAt`. */
|
||||
async getExecutionsForPublicApi(
|
||||
params: {
|
||||
/** Find executions scoped to the given workflows, with data per `storedAt`. */
|
||||
async findManyInWorkflows(
|
||||
workflowIds: string[],
|
||||
options: {
|
||||
limit: number;
|
||||
includeData?: boolean;
|
||||
lastId?: string;
|
||||
workflowIds?: string[];
|
||||
status?: ExecutionStatus;
|
||||
excludedExecutionsIds?: string[];
|
||||
},
|
||||
@@ -577,11 +571,11 @@ export class ExecutionPersistence {
|
||||
'finished',
|
||||
'status',
|
||||
],
|
||||
where: this.executionRepository.getFindExecutionsForPublicApiCondition(params),
|
||||
where: this.executionRepository.getFindManyInWorkflowsCondition(workflowIds, options),
|
||||
order: { id: 'DESC' },
|
||||
take: params.limit,
|
||||
take: options.limit,
|
||||
},
|
||||
{ includeData: params.includeData, unflattenData: true, maxDataSizeBytes },
|
||||
{ includeData: options.includeData, unflattenData: true, maxDataSizeBytes },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { GlobalConfig } from '@n8n/config';
|
||||
import type {
|
||||
CreateExecutionPayload,
|
||||
ExecutionSummaries,
|
||||
IExecutionBase,
|
||||
IExecutionResponse,
|
||||
IGetExecutionsQueryFilter,
|
||||
User,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
} from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
import { QueryFailedError } from '@n8n/typeorm';
|
||||
import { ensureError } from '@n8n/utils/errors/ensure-error';
|
||||
import { stringify } from 'flatted';
|
||||
import { validate as jsonSchemaValidate } from 'jsonschema';
|
||||
@@ -45,6 +47,7 @@ import { ConcurrencyControlService } from '@/concurrency/concurrency-control.ser
|
||||
import { AbortedExecutionRetryError } from '@/errors/aborted-execution-retry.error';
|
||||
import { MissingExecutionStopError } from '@/errors/missing-execution-stop.error';
|
||||
import { QueuedExecutionRetryError } from '@/errors/queued-execution-retry.error';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ConflictError } from '@/errors/response-errors/conflict.error';
|
||||
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
@@ -148,6 +151,13 @@ export class ExecutionService {
|
||||
return { scopes: [scope], projectRoles, workflowRoles };
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor/internal GET: load an execution for display, apply redaction, and
|
||||
* return flatted `data` (`IExecutionFlattedResponse`).
|
||||
*
|
||||
* Prefer this for the private executions API. For a domain entity with
|
||||
* caller-controlled options, use {@link findOneInWorkflows}.
|
||||
*/
|
||||
async findOne(
|
||||
req: ExecutionRequest.GetOne | ExecutionRequest.Update,
|
||||
sharedWorkflowIds: string[],
|
||||
@@ -155,12 +165,12 @@ export class ExecutionService {
|
||||
if (!sharedWorkflowIds.length) return undefined;
|
||||
|
||||
const { id: executionId } = req.params;
|
||||
let execution: IExecutionResponse | undefined;
|
||||
let execution: IExecutionResponse | IExecutionBase | undefined;
|
||||
try {
|
||||
execution = await this.executionPersistence.findIfSharedUnflatten(
|
||||
execution = await this.executionPersistence.findOneInWorkflows(
|
||||
executionId,
|
||||
sharedWorkflowIds,
|
||||
this.globalConfig.executions.maxDisplaySize,
|
||||
{ maxDataSizeBytes: this.globalConfig.executions.maxDisplaySize },
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof MissingExecutionDataError) {
|
||||
@@ -179,6 +189,10 @@ export class ExecutionService {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!('data' in execution)) {
|
||||
throw new UnexpectedError('Expected execution data for display read');
|
||||
}
|
||||
|
||||
let redactExecutionData: boolean | undefined;
|
||||
const redactQuery = ExecutionRedactionQueryDtoSchema.safeParse(req.query);
|
||||
if (redactQuery.success) {
|
||||
@@ -762,6 +776,159 @@ export class ExecutionService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load one execution scoped to `workflowIds` as a domain entity.
|
||||
* Options control data/annotation inclusion; no redaction or flatting.
|
||||
*
|
||||
* Prefer this for public API and service-to-service loads. For the editor
|
||||
* flatted response, use {@link findOne}.
|
||||
*/
|
||||
async findOneInWorkflows(
|
||||
executionId: string,
|
||||
workflowIds: string[],
|
||||
options?: {
|
||||
includeData?: boolean;
|
||||
includeAnnotation?: boolean;
|
||||
maxDataSizeBytes?: number;
|
||||
},
|
||||
) {
|
||||
return await this.executionPersistence.findOneInWorkflows(executionId, workflowIds, options);
|
||||
}
|
||||
|
||||
async findManyAndCount(
|
||||
workflowIds: string[],
|
||||
options: {
|
||||
limit: number;
|
||||
includeData?: boolean;
|
||||
lastId?: string;
|
||||
status?: ExecutionStatus;
|
||||
excludeRunning?: boolean;
|
||||
maxDataSizeBytes?: number;
|
||||
},
|
||||
): Promise<{ executions: IExecutionBase[]; count: number }> {
|
||||
const excludedExecutionsIds = options.excludeRunning
|
||||
? this.activeExecutions
|
||||
.getActiveExecutions()
|
||||
.filter(({ status }) => status === 'running')
|
||||
.map(({ id }) => id)
|
||||
: undefined;
|
||||
|
||||
const listOptions = {
|
||||
limit: options.limit,
|
||||
includeData: options.includeData,
|
||||
lastId: options.lastId,
|
||||
status: options.status,
|
||||
excludedExecutionsIds,
|
||||
};
|
||||
|
||||
const executions = await this.executionPersistence.findManyInWorkflows(
|
||||
workflowIds,
|
||||
listOptions,
|
||||
options.maxDataSizeBytes,
|
||||
);
|
||||
|
||||
const newLastId = executions.length === 0 ? '0' : executions.at(-1)!.id;
|
||||
const count = await this.executionRepository.countInWorkflows(workflowIds, {
|
||||
...listOptions,
|
||||
lastId: newLastId,
|
||||
});
|
||||
|
||||
return { executions, count };
|
||||
}
|
||||
|
||||
async deleteOne(executionId: string, sharedWorkflowIds: string[]) {
|
||||
const execution = await this.findOneInWorkflows(executionId, sharedWorkflowIds, {
|
||||
includeData: false,
|
||||
includeAnnotation: false,
|
||||
});
|
||||
|
||||
if (!execution) {
|
||||
throw new NotFoundError('Not Found');
|
||||
}
|
||||
|
||||
if (execution.status === 'running') {
|
||||
throw new BadRequestError('Cannot delete a running execution');
|
||||
}
|
||||
|
||||
if (execution.status === 'new') {
|
||||
this.concurrencyControl.remove({
|
||||
executionId: execution.id,
|
||||
mode: execution.mode,
|
||||
});
|
||||
}
|
||||
|
||||
await this.executionPersistence.hardDelete({
|
||||
workflowId: execution.workflowId,
|
||||
executionId: execution.id,
|
||||
storedAt: execution.storedAt,
|
||||
});
|
||||
|
||||
return execution;
|
||||
}
|
||||
|
||||
async getExecutionTags(executionId: string, sharedWorkflowIds: string[]) {
|
||||
const execution = await this.findOneInWorkflows(executionId, sharedWorkflowIds, {
|
||||
includeData: false,
|
||||
includeAnnotation: false,
|
||||
});
|
||||
|
||||
if (!execution) {
|
||||
throw new NotFoundError('Not Found');
|
||||
}
|
||||
|
||||
const annotation = await this.executionAnnotationRepository.findOne({
|
||||
where: { execution: { id: executionId } },
|
||||
relations: ['tags'],
|
||||
});
|
||||
|
||||
return (annotation?.tags ?? []).map(({ id, name, createdAt, updatedAt }) => ({
|
||||
id,
|
||||
name,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async updateExecutionTags(executionId: string, tagIds: string[], sharedWorkflowIds: string[]) {
|
||||
const execution = await this.findOneInWorkflows(executionId, sharedWorkflowIds, {
|
||||
includeData: false,
|
||||
includeAnnotation: false,
|
||||
});
|
||||
|
||||
if (!execution) {
|
||||
throw new NotFoundError('Not Found');
|
||||
}
|
||||
|
||||
await this.executionAnnotationRepository.upsert({ execution: { id: executionId } }, [
|
||||
'execution',
|
||||
]);
|
||||
|
||||
const annotation = await this.executionAnnotationRepository.findOneOrFail({
|
||||
where: { execution: { id: executionId } },
|
||||
});
|
||||
|
||||
try {
|
||||
await this.annotationTagMappingRepository.overwriteTags(annotation.id, tagIds);
|
||||
} catch (error) {
|
||||
if (error instanceof QueryFailedError) {
|
||||
throw new NotFoundError('Some tags not found');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const updatedAnnotation = await this.executionAnnotationRepository.findOneOrFail({
|
||||
where: { execution: { id: executionId } },
|
||||
relations: ['tags'],
|
||||
});
|
||||
|
||||
return (updatedAnnotation.tags ?? []).map(({ id, name, createdAt, updatedAt }) => ({
|
||||
id,
|
||||
name,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async getExecutedVersions(
|
||||
workflowId: string,
|
||||
): Promise<Array<{ versionId: string; name: string | null; createdAt: Date }>> {
|
||||
|
||||
@@ -1,27 +1,20 @@
|
||||
import { ExecutionRedactionQueryDtoSchema } from '@n8n/api-types';
|
||||
import { ExecutionsConfig } from '@n8n/config';
|
||||
import type { IExecutionBase } from '@n8n/db';
|
||||
import { ExecutionRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { QueryFailedError } from '@n8n/typeorm';
|
||||
import { type ExecutionStatus, replaceCircularReferences } from 'n8n-workflow';
|
||||
|
||||
import { ActiveExecutions } from '@/active-executions';
|
||||
import { ConcurrencyControlService } from '@/concurrency/concurrency-control.service';
|
||||
import { AbortedExecutionRetryError } from '@/errors/aborted-execution-retry.error';
|
||||
import { MissingExecutionStopError } from '@/errors/missing-execution-stop.error';
|
||||
import { QueuedExecutionRetryError } from '@/errors/queued-execution-retry.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 { ExecutionPersistence } from '@/executions/execution-persistence';
|
||||
import type { RedactableExecution } from '@/executions/execution-redaction';
|
||||
import { ExecutionRedactionServiceProxy } from '@/executions/execution-redaction-proxy.service';
|
||||
import { ExecutionService } from '@/executions/execution.service';
|
||||
import { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
|
||||
|
||||
import { getExecutionTags, mapAnnotationTags, updateExecutionTags } from './executions.service';
|
||||
import type { ExecutionRequest } from '../../../types';
|
||||
import type { PublicAPIEndpoint } from '../../shared/handler.types';
|
||||
import { publicApiScope, validCursor } from '../../shared/middlewares/global.middleware';
|
||||
@@ -63,43 +56,15 @@ const executionHandlers: ExecutionHandlers = {
|
||||
WorkflowSharingService,
|
||||
).getSharedWorkflowIdsForScopes(req.user, ['workflow:delete']);
|
||||
|
||||
// user does not have workflows hence no executions
|
||||
// or the execution they are trying to access belongs to a workflow they do not own
|
||||
if (!sharedWorkflowsIds.length) {
|
||||
throw new NotFoundError('Not Found');
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
|
||||
// look for the execution on the workflow the user owns
|
||||
const execution = await Container.get(
|
||||
ExecutionPersistence,
|
||||
).getExecutionInWorkflowsForPublicApi(id, sharedWorkflowsIds, false);
|
||||
const execution = await Container.get(ExecutionService).deleteOne(id, sharedWorkflowsIds);
|
||||
|
||||
if (!execution) {
|
||||
throw new NotFoundError('Not Found');
|
||||
}
|
||||
|
||||
if (execution.status === 'running') {
|
||||
throw new BadRequestError('Cannot delete a running execution');
|
||||
}
|
||||
|
||||
if (execution.status === 'new') {
|
||||
Container.get(ConcurrencyControlService).remove({
|
||||
executionId: execution.id,
|
||||
mode: execution.mode,
|
||||
});
|
||||
}
|
||||
|
||||
await Container.get(ExecutionPersistence).hardDelete({
|
||||
workflowId: execution.workflowId,
|
||||
executionId: execution.id,
|
||||
storedAt: execution.storedAt,
|
||||
});
|
||||
|
||||
execution.id = id;
|
||||
|
||||
return res.json(replaceCircularReferences(execution));
|
||||
return res.json(replaceCircularReferences({ ...execution, id }));
|
||||
},
|
||||
],
|
||||
getExecution: [
|
||||
@@ -109,8 +74,6 @@ const executionHandlers: ExecutionHandlers = {
|
||||
WorkflowSharingService,
|
||||
).getSharedWorkflowIdsForScopes(req.user, ['workflow:read']);
|
||||
|
||||
// user does not have workflows hence no executions
|
||||
// or the execution they are trying to access belongs to a workflow they do not own
|
||||
if (!sharedWorkflowsIds.length) {
|
||||
throw new NotFoundError('Not Found');
|
||||
}
|
||||
@@ -124,10 +87,15 @@ const executionHandlers: ExecutionHandlers = {
|
||||
? 0
|
||||
: Container.get(ExecutionsConfig).maxDisplaySize;
|
||||
|
||||
// look for the execution on the workflow the user owns
|
||||
const execution = await Container.get(
|
||||
ExecutionPersistence,
|
||||
).getExecutionInWorkflowsForPublicApi(id, sharedWorkflowsIds, includeData, maxDataSizeBytes);
|
||||
const execution = await Container.get(ExecutionService).findOneInWorkflows(
|
||||
id,
|
||||
sharedWorkflowsIds,
|
||||
{
|
||||
includeData,
|
||||
includeAnnotation: false,
|
||||
maxDataSizeBytes,
|
||||
},
|
||||
);
|
||||
|
||||
if (!execution) {
|
||||
throw new NotFoundError('Not Found');
|
||||
@@ -173,45 +141,27 @@ const executionHandlers: ExecutionHandlers = {
|
||||
WorkflowSharingService,
|
||||
).getSharedWorkflowIdsForScopes(req.user, ['workflow:read'], projectId);
|
||||
|
||||
// user does not have workflows hence no executions
|
||||
// or the execution they are trying to access belongs to a workflow they do not own
|
||||
if (!sharedWorkflowsIds.length || (workflowId && !sharedWorkflowsIds.includes(workflowId))) {
|
||||
return res.status(200).json({ data: [], nextCursor: null });
|
||||
}
|
||||
|
||||
// Collect genuinely running executions to exclude from the default listing.
|
||||
// The active executions list also retains `waiting` executions (persisted and
|
||||
// resumable); filter by status so waiting executions are still listed.
|
||||
const runningExecutionsIds = Container.get(ActiveExecutions)
|
||||
.getActiveExecutions()
|
||||
.filter(({ status }) => status === 'running')
|
||||
.map(({ id }) => id);
|
||||
|
||||
const filters: Parameters<
|
||||
typeof ExecutionPersistence.prototype.getExecutionsForPublicApi
|
||||
>[0] = {
|
||||
status,
|
||||
limit,
|
||||
lastId,
|
||||
includeData,
|
||||
workflowIds: workflowId ? [workflowId] : sharedWorkflowsIds,
|
||||
|
||||
// for backward compatibility `running` executions are always excluded
|
||||
// unless the user explicitly filters by `running` status
|
||||
excludedExecutionsIds: status !== 'running' ? runningExecutionsIds : undefined,
|
||||
};
|
||||
|
||||
const executions = await Container.get(ExecutionPersistence).getExecutionsForPublicApi(
|
||||
filters,
|
||||
ignoreDataSizeLimit ? 0 : Container.get(ExecutionsConfig).maxDisplaySize,
|
||||
const { executions, count } = await Container.get(ExecutionService).findManyAndCount(
|
||||
workflowId ? [workflowId] : sharedWorkflowsIds,
|
||||
{
|
||||
status,
|
||||
limit,
|
||||
lastId,
|
||||
includeData,
|
||||
// for backward compatibility `running` executions are always excluded
|
||||
// unless the user explicitly filters by `running` status
|
||||
excludeRunning: status !== 'running',
|
||||
maxDataSizeBytes: ignoreDataSizeLimit
|
||||
? 0
|
||||
: Container.get(ExecutionsConfig).maxDisplaySize,
|
||||
},
|
||||
);
|
||||
|
||||
const newLastId = !executions.length ? '0' : executions.slice(-1)[0].id;
|
||||
|
||||
filters.lastId = newLastId;
|
||||
|
||||
const count =
|
||||
await Container.get(ExecutionRepository).getExecutionsCountForPublicApi(filters);
|
||||
const newLastId = executions.length === 0 ? '0' : executions.at(-1)!.id;
|
||||
|
||||
if (includeData) {
|
||||
const redactQuery = ExecutionRedactionQueryDtoSchema.safeParse(req.query);
|
||||
@@ -253,8 +203,6 @@ const executionHandlers: ExecutionHandlers = {
|
||||
WorkflowSharingService,
|
||||
).getSharedWorkflowIdsForScopes(req.user, ['workflow:execute']);
|
||||
|
||||
// user does not have workflows hence no executions
|
||||
// or the execution they are trying to access belongs to a workflow they do not own
|
||||
if (!sharedWorkflowsIds.length) {
|
||||
throw new NotFoundError('Not Found');
|
||||
}
|
||||
@@ -279,7 +227,6 @@ const executionHandlers: ExecutionHandlers = {
|
||||
getExecutionTags: [
|
||||
publicApiScope('executionTags:list'),
|
||||
async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const sharedWorkflowsIds = await Container.get(
|
||||
WorkflowSharingService,
|
||||
).getSharedWorkflowIdsForScopes(req.user, ['workflow:read']);
|
||||
@@ -288,15 +235,10 @@ const executionHandlers: ExecutionHandlers = {
|
||||
throw new NotFoundError('Not Found');
|
||||
}
|
||||
|
||||
const execution = await Container.get(
|
||||
ExecutionPersistence,
|
||||
).getExecutionInWorkflowsForPublicApi(id, sharedWorkflowsIds, false);
|
||||
|
||||
if (!execution) {
|
||||
throw new NotFoundError('Not Found');
|
||||
}
|
||||
|
||||
const tags = await getExecutionTags(id);
|
||||
const tags = await Container.get(ExecutionService).getExecutionTags(
|
||||
req.params.id,
|
||||
sharedWorkflowsIds,
|
||||
);
|
||||
|
||||
return res.json(tags);
|
||||
},
|
||||
@@ -304,7 +246,6 @@ const executionHandlers: ExecutionHandlers = {
|
||||
updateExecutionTags: [
|
||||
publicApiScope('executionTags:update'),
|
||||
async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const newTagIds = req.body.map((tag) => tag.id);
|
||||
const sharedWorkflowsIds = await Container.get(
|
||||
WorkflowSharingService,
|
||||
@@ -314,22 +255,14 @@ const executionHandlers: ExecutionHandlers = {
|
||||
throw new NotFoundError('Not Found');
|
||||
}
|
||||
|
||||
const execution = await Container.get(
|
||||
ExecutionPersistence,
|
||||
).getExecutionInWorkflowsForPublicApi(id, sharedWorkflowsIds, false);
|
||||
|
||||
if (!execution) {
|
||||
throw new NotFoundError('Not Found');
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedTags = await updateExecutionTags(id, newTagIds);
|
||||
const tags = mapAnnotationTags(updatedTags);
|
||||
const tags = await Container.get(ExecutionService).updateExecutionTags(
|
||||
req.params.id,
|
||||
newTagIds,
|
||||
sharedWorkflowsIds,
|
||||
);
|
||||
return res.json(tags);
|
||||
} catch (error) {
|
||||
if (error instanceof QueryFailedError) {
|
||||
throw new NotFoundError('Some tags not found');
|
||||
}
|
||||
return handleError(error);
|
||||
}
|
||||
},
|
||||
@@ -341,8 +274,6 @@ const executionHandlers: ExecutionHandlers = {
|
||||
WorkflowSharingService,
|
||||
).getSharedWorkflowIdsForScopes(req.user, ['workflow:execute']);
|
||||
|
||||
// user does not have workflows hence no executions
|
||||
// or the execution they are trying to access belongs to a workflow they do not own
|
||||
if (!sharedWorkflowsIds.length) {
|
||||
throw new NotFoundError('Not Found');
|
||||
}
|
||||
@@ -363,7 +294,6 @@ const executionHandlers: ExecutionHandlers = {
|
||||
async (req, res) => {
|
||||
const { status: rawStatus, workflowId, startedAfter, startedBefore } = req.body;
|
||||
const status: ExecutionStatus[] = rawStatus.map((x) => (x === 'queued' ? 'new' : x));
|
||||
// Validate that status is provided and not empty
|
||||
if (!status || status.length === 0) {
|
||||
return res.status(400).json({
|
||||
message:
|
||||
@@ -378,12 +308,10 @@ const executionHandlers: ExecutionHandlers = {
|
||||
WorkflowSharingService,
|
||||
).getSharedWorkflowIdsForScopes(req.user, ['workflow:execute']);
|
||||
|
||||
// Return early to avoid expensive db query
|
||||
if (!sharedWorkflowsIds.length) {
|
||||
return res.json({ stopped: 0 });
|
||||
}
|
||||
|
||||
// If workflowId is provided, validate user has access to it
|
||||
if (workflowId && workflowId !== 'all' && !sharedWorkflowsIds.includes(workflowId)) {
|
||||
throw new NotFoundError('Workflow not found or not accessible');
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import {
|
||||
AnnotationTagMapping,
|
||||
ExecutionAnnotation,
|
||||
ExecutionAnnotationRepository,
|
||||
type AnnotationTagEntity,
|
||||
} from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
export function mapAnnotationTags(tags: AnnotationTagEntity[]) {
|
||||
return tags.map(({ id: tagId, name, createdAt, updatedAt }) => ({
|
||||
id: tagId,
|
||||
name,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getExecutionTags(executionId: string) {
|
||||
const annotation = await Container.get(ExecutionAnnotationRepository).findOne({
|
||||
where: { execution: { id: executionId } },
|
||||
relations: ['tags'],
|
||||
});
|
||||
|
||||
return mapAnnotationTags(annotation?.tags ?? []);
|
||||
}
|
||||
|
||||
export async function updateExecutionTags(
|
||||
executionId: string,
|
||||
newTagIds: string[],
|
||||
): Promise<AnnotationTagEntity[]> {
|
||||
const { manager: dbManager } = Container.get(ExecutionAnnotationRepository);
|
||||
return await dbManager.transaction(async (transactionManager) => {
|
||||
// Upsert annotation (create if it doesn't exist)
|
||||
await transactionManager.upsert(ExecutionAnnotation, { execution: { id: executionId } }, [
|
||||
'execution',
|
||||
]);
|
||||
|
||||
const annotation = await transactionManager.findOneOrFail(ExecutionAnnotation, {
|
||||
where: { execution: { id: executionId } },
|
||||
});
|
||||
|
||||
// Overwrite tags
|
||||
await transactionManager.delete(AnnotationTagMapping, {
|
||||
annotationId: annotation.id,
|
||||
});
|
||||
|
||||
if (newTagIds.length > 0) {
|
||||
const tagMappings = newTagIds.map((tagId) => ({
|
||||
annotationId: annotation.id,
|
||||
tagId,
|
||||
}));
|
||||
await transactionManager.insert(AnnotationTagMapping, tagMappings);
|
||||
}
|
||||
|
||||
// Fetch updated tags to return
|
||||
const updatedAnnotation = await transactionManager.findOneOrFail(ExecutionAnnotation, {
|
||||
where: { execution: { id: executionId } },
|
||||
relations: ['tags'],
|
||||
});
|
||||
|
||||
return updatedAnnotation.tags ?? [];
|
||||
});
|
||||
}
|
||||
@@ -274,6 +274,8 @@ describe('DELETE /executions/:id', () => {
|
||||
} = response.body;
|
||||
|
||||
expect(id).toBeDefined();
|
||||
expect(typeof id).toBe('number');
|
||||
expect(id).toBe(Number(execution.id));
|
||||
expect(finished).toBe(true);
|
||||
expect(mode).toEqual(execution.mode);
|
||||
expect(retrySuccessId).toBeNull();
|
||||
@@ -298,7 +300,7 @@ describe('POST /executions/:id/retry', () => {
|
||||
test('should retry an execution', async () => {
|
||||
const mockedExecutionResponse = { status: 'waiting' } as any;
|
||||
const executionServiceSpy = vi
|
||||
.mocked(Container.get(ExecutionService).retry)
|
||||
.spyOn(Container.get(ExecutionService), 'retry')
|
||||
.mockResolvedValue(mockedExecutionResponse);
|
||||
|
||||
const workflow = await createWorkflow({}, user1);
|
||||
@@ -323,7 +325,7 @@ describe('POST /executions/:id/retry', () => {
|
||||
|
||||
test('should return 409 when trying to retry a queued execution', async () => {
|
||||
const executionServiceSpy = vi
|
||||
.mocked(Container.get(ExecutionService).retry)
|
||||
.spyOn(Container.get(ExecutionService), 'retry')
|
||||
.mockRejectedValue(new QueuedExecutionRetryError());
|
||||
|
||||
const workflow = await createWorkflow({}, user1);
|
||||
@@ -341,7 +343,7 @@ describe('POST /executions/:id/retry', () => {
|
||||
|
||||
test('should return 409 when trying to retry an aborted execution without execution data', async () => {
|
||||
const executionServiceSpy = vi
|
||||
.mocked(Container.get(ExecutionService).retry)
|
||||
.spyOn(Container.get(ExecutionService), 'retry')
|
||||
.mockRejectedValue(new AbortedExecutionRetryError());
|
||||
|
||||
const workflow = await createWorkflow({}, user1);
|
||||
@@ -367,7 +369,7 @@ describe('POST /executions/:id/retry', () => {
|
||||
test('should return 404 when user only has read access to the workflow via project viewer role', async () => {
|
||||
testServer.license.enable('feat:sharing');
|
||||
|
||||
const executionServiceSpy = vi.mocked(Container.get(ExecutionService).retry);
|
||||
const executionServiceSpy = vi.spyOn(Container.get(ExecutionService), 'retry');
|
||||
|
||||
const project = await createTeamProject('project with viewer', owner);
|
||||
await linkUserToProject(user1, project, 'project:viewer');
|
||||
@@ -389,7 +391,7 @@ describe('POST /executions/:id/retry', () => {
|
||||
|
||||
const mockedExecutionResponse = { status: 'waiting' } as any;
|
||||
const executionServiceSpy = vi
|
||||
.mocked(Container.get(ExecutionService).retry)
|
||||
.spyOn(Container.get(ExecutionService), 'retry')
|
||||
.mockResolvedValue(mockedExecutionResponse);
|
||||
|
||||
const project = await createTeamProject('project with editor', owner);
|
||||
@@ -408,7 +410,7 @@ describe('POST /executions/:id/retry', () => {
|
||||
|
||||
test('should return 409 when trying to retry a finished execution', async () => {
|
||||
const executionServiceSpy = vi
|
||||
.mocked(Container.get(ExecutionService).retry)
|
||||
.spyOn(Container.get(ExecutionService), 'retry')
|
||||
.mockRejectedValue(new ConflictError('The execution succeeded, so it cannot be retried.'));
|
||||
|
||||
const workflow = await createWorkflow({}, user1);
|
||||
@@ -916,11 +918,13 @@ describe('POST /executions/:id/stop', () => {
|
||||
finished: false,
|
||||
status: 'canceled',
|
||||
} as any;
|
||||
const executionServiceSpy = vi.mocked(Container.get(ExecutionService).stop).mockResolvedValue({
|
||||
...mockedStopResponse,
|
||||
startedAt: new Date(mockedStopResponse.startedAt),
|
||||
stoppedAt: new Date(mockedStopResponse.stoppedAt),
|
||||
});
|
||||
const executionServiceSpy = vi
|
||||
.spyOn(Container.get(ExecutionService), 'stop')
|
||||
.mockResolvedValue({
|
||||
...mockedStopResponse,
|
||||
startedAt: new Date(mockedStopResponse.startedAt),
|
||||
stoppedAt: new Date(mockedStopResponse.stoppedAt),
|
||||
});
|
||||
|
||||
const workflow = await createWorkflow({}, user1);
|
||||
const execution = await createExecution({ status: 'running', finished: false }, workflow);
|
||||
@@ -967,11 +971,13 @@ describe('POST /executions/:id/stop', () => {
|
||||
finished: false,
|
||||
status: 'canceled',
|
||||
} as any;
|
||||
const executionServiceSpy = vi.mocked(Container.get(ExecutionService).stop).mockResolvedValue({
|
||||
...mockedStopResponse,
|
||||
startedAt: new Date(mockedStopResponse.startedAt),
|
||||
stoppedAt: new Date(mockedStopResponse.stoppedAt),
|
||||
});
|
||||
const executionServiceSpy = vi
|
||||
.spyOn(Container.get(ExecutionService), 'stop')
|
||||
.mockResolvedValue({
|
||||
...mockedStopResponse,
|
||||
startedAt: new Date(mockedStopResponse.startedAt),
|
||||
stoppedAt: new Date(mockedStopResponse.stoppedAt),
|
||||
});
|
||||
|
||||
const workflow = await createWorkflow({}, user1);
|
||||
const execution = await createExecution({ status: 'running', finished: false }, workflow);
|
||||
@@ -1010,7 +1016,7 @@ describe('POST /executions/stop', () => {
|
||||
|
||||
test('should stop multiple running executions', async () => {
|
||||
const executionServiceSpy = vi
|
||||
.mocked(Container.get(ExecutionService).stopMany)
|
||||
.spyOn(Container.get(ExecutionService), 'stopMany')
|
||||
.mockResolvedValue(3);
|
||||
|
||||
await createWorkflow({}, user1);
|
||||
@@ -1036,7 +1042,7 @@ describe('POST /executions/stop', () => {
|
||||
|
||||
test('should stop executions filtered by workflowId', async () => {
|
||||
const executionServiceSpy = vi
|
||||
.mocked(Container.get(ExecutionService).stopMany)
|
||||
.spyOn(Container.get(ExecutionService), 'stopMany')
|
||||
.mockResolvedValue(2);
|
||||
|
||||
const workflow = await createWorkflow({}, user1);
|
||||
@@ -1062,7 +1068,7 @@ describe('POST /executions/stop', () => {
|
||||
|
||||
test('should stop executions with date filters', async () => {
|
||||
const executionServiceSpy = vi
|
||||
.mocked(Container.get(ExecutionService).stopMany)
|
||||
.spyOn(Container.get(ExecutionService), 'stopMany')
|
||||
.mockResolvedValue(1);
|
||||
|
||||
await createWorkflow({}, user1);
|
||||
@@ -1095,7 +1101,7 @@ describe('POST /executions/stop', () => {
|
||||
const workflow = await createWorkflow({}, user1);
|
||||
|
||||
const executionServiceSpy = vi
|
||||
.mocked(Container.get(ExecutionService).stopMany)
|
||||
.spyOn(Container.get(ExecutionService), 'stopMany')
|
||||
.mockResolvedValue(1);
|
||||
|
||||
// User1 should be able to stop executions in their own workflow
|
||||
@@ -1111,7 +1117,7 @@ describe('POST /executions/stop', () => {
|
||||
});
|
||||
|
||||
test('should return 0 stopped when user has no workflows', async () => {
|
||||
const executionServiceSpy = vi.mocked(Container.get(ExecutionService).stopMany);
|
||||
const executionServiceSpy = vi.spyOn(Container.get(ExecutionService), 'stopMany');
|
||||
|
||||
// Create a new user with no workflows
|
||||
const userWithNoWorkflows = await createMemberWithApiKey();
|
||||
@@ -1134,7 +1140,7 @@ describe('POST /executions/stop', () => {
|
||||
await createManyWorkflows(2, {}, owner);
|
||||
|
||||
const executionServiceSpy = vi
|
||||
.mocked(Container.get(ExecutionService).stopMany)
|
||||
.spyOn(Container.get(ExecutionService), 'stopMany')
|
||||
.mockResolvedValue(5);
|
||||
|
||||
const response = await authOwnerAgent
|
||||
@@ -1151,7 +1157,7 @@ describe('POST /executions/stop', () => {
|
||||
testServer.license.enable('feat:sharing');
|
||||
|
||||
const executionServiceSpy = vi
|
||||
.mocked(Container.get(ExecutionService).stopMany)
|
||||
.spyOn(Container.get(ExecutionService), 'stopMany')
|
||||
.mockResolvedValue(2);
|
||||
|
||||
const [workflow1, workflow2] = await createManyWorkflows(2, {}, user1);
|
||||
|
||||
@@ -27,7 +27,6 @@ import { v4 as uuid } from 'uuid';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { AUTH_COOKIE_NAME } from '@/constants';
|
||||
import { ExecutionService } from '@/executions/execution.service';
|
||||
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
|
||||
import { Push } from '@/push';
|
||||
|
||||
@@ -48,7 +47,6 @@ export async function initActiveWorkflowManager() {
|
||||
});
|
||||
|
||||
mockInstance(Push);
|
||||
mockInstance(ExecutionService);
|
||||
const { ActiveWorkflowManager } = await import('@/active-workflow-manager.js');
|
||||
const activeWorkflowManager = Container.get(ActiveWorkflowManager);
|
||||
await activeWorkflowManager.init();
|
||||
|
||||
Reference in New Issue
Block a user