diff --git a/packages/cli/src/public-api/v1/handlers/executions/executions.handler.ts b/packages/cli/src/public-api/v1/handlers/executions/executions.handler.ts index 34d7569eaae..f2375ba68d4 100644 --- a/packages/cli/src/public-api/v1/handlers/executions/executions.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/executions/executions.handler.ts @@ -174,9 +174,12 @@ const executionHandlers: ExecutionHandlers = { return res.status(200).json({ data: [], nextCursor: null }); } - // get running executions so we exclude them from the result + // 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< diff --git a/packages/cli/test/integration/public-api/executions.test.ts b/packages/cli/test/integration/public-api/executions.test.ts index 91f42785abe..727131a211f 100644 --- a/packages/cli/test/integration/public-api/executions.test.ts +++ b/packages/cli/test/integration/public-api/executions.test.ts @@ -10,7 +10,9 @@ import { import type { ExecutionEntity, User } from '@n8n/db'; import { Container } from '@n8n/di'; import { type ExecutionStatus } from 'n8n-workflow'; +import type { MockInstance } from 'vitest'; +import { ActiveExecutions } from '@/active-executions'; import type { ActiveWorkflowManager } from '@/active-workflow-manager'; import { AbortedExecutionRetryError } from '@/errors/aborted-execution-retry.error'; import { QueuedExecutionRetryError } from '@/errors/queued-execution-retry.error'; @@ -532,6 +534,102 @@ describe('GET /executions', () => { ); }); + describe('with executions held in the active-executions map', () => { + // Mirrors a running instance: a `waiting` execution is persisted in the DB + // and its id is also held in the in-process active-executions map. + let activeExecutionsSpy: MockInstance | undefined; + + const holdInActiveExecutions = ( + stubs: Array<{ executionId: string; workflowId: string; status: ExecutionStatus }>, + ) => { + activeExecutionsSpy = vi + .spyOn(Container.get(ActiveExecutions), 'getActiveExecutions') + .mockReturnValue( + stubs.map(({ executionId, workflowId, status }) => ({ + id: executionId, + retryOf: undefined, + startedAt: new Date(), + mode: 'manual', + workflowId, + status, + })), + ); + }; + + afterEach(() => { + activeExecutionsSpy?.mockRestore(); + activeExecutionsSpy = undefined; + }); + + test.each` + query | description + ${undefined} | ${'in the default list'} + ${{ status: 'waiting' }} | ${'when filtering by status=waiting'} + `( + 'should return a held waiting execution $description', + async ({ query }: { query?: { status: string } }) => { + const workflow = await createWorkflow({}, owner); + const waitingExecution = await createdExecutionWithStatus(workflow, 'waiting'); + holdInActiveExecutions([ + { executionId: waitingExecution.id, workflowId: workflow.id, status: 'waiting' }, + ]); + + const response = await authOwnerAgent.get('/executions').query(query ?? {}); + + expect(response.statusCode).toBe(200); + expect(response.body.data.map((e: { id: string }) => e.id)).toContain(waitingExecution.id); + }, + ); + + test('should return both a held waiting execution and a finished one in the default list', async () => { + const workflow = await createWorkflow({}, owner); + const finishedExecution = await createdExecutionWithStatus(workflow, 'success'); + const waitingExecution = await createdExecutionWithStatus(workflow, 'waiting'); + holdInActiveExecutions([ + { executionId: waitingExecution.id, workflowId: workflow.id, status: 'waiting' }, + ]); + + const response = await authOwnerAgent.get('/executions'); + + expect(response.statusCode).toBe(200); + const ids = response.body.data.map((e: { id: string }) => e.id); + expect(ids).toContain(finishedExecution.id); + expect(ids).toContain(waitingExecution.id); + }); + + test('should still exclude a genuinely running execution from the default list', async () => { + const workflow = await createWorkflow({}, owner); + const runningExecution = await createdExecutionWithStatus(workflow, 'running'); + holdInActiveExecutions([ + { executionId: runningExecution.id, workflowId: workflow.id, status: 'running' }, + ]); + + const response = await authOwnerAgent.get('/executions'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.map((e: { id: string }) => e.id)).not.toContain( + runningExecution.id, + ); + }); + + test('should return a held waiting execution and exclude a held running execution when both are active', async () => { + const workflow = await createWorkflow({}, owner); + const waitingExecution = await createdExecutionWithStatus(workflow, 'waiting'); + const runningExecution = await createdExecutionWithStatus(workflow, 'running'); + holdInActiveExecutions([ + { executionId: waitingExecution.id, workflowId: workflow.id, status: 'waiting' }, + { executionId: runningExecution.id, workflowId: workflow.id, status: 'running' }, + ]); + + const response = await authOwnerAgent.get('/executions'); + + expect(response.statusCode).toBe(200); + const ids = response.body.data.map((e: { id: string }) => e.id); + expect(ids).toContain(waitingExecution.id); + expect(ids).not.toContain(runningExecution.id); + }); + }); + test('should retrieve all executions of specific workflow', async () => { const [workflow, workflow2] = await createManyWorkflows(2, {}, owner);