refactor(core): Remove the catalog runs endpoint

Nothing calls it: the catalog now sends people to the workflow's own execution
list, which shows the same runs with their data instead of a name, a time and a
status. Takes `CatalogService.listRuns`, its limit and its response types with
it, and drops the `ExecutionService` dependency that existed only for it.

The `catalogRunUserId` metadata the run service writes stays. It is the only
record of which person a run belongs to — a scheduled run has no session behind
it, so nothing else on the execution says whose it is — and unlike a query, it
cannot be reconstructed once it is not written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yuliia Pominchuk 2026-08-05 13:10:24 +02:00
parent 4923503526
commit af797b24d1
No known key found for this signature in database
4 changed files with 5 additions and 138 deletions

View File

@ -68,7 +68,7 @@ explicitly in the service before deleting rows.
| `0a36cf18a7d` | Input-contract reader, extracted from `nodes-base` into `n8n-workflow` |
| `f78b151d7f2` | `POST /catalog/workflows/:id/run` |
| `1c7cde1ae96` | `GET /catalog/workflows` |
| `32fb2405e58` | `GET /catalog/runs` |
| `32fb2405e58` | `GET /catalog/runs` — since removed, superseded by the workflow's own execution list |
| `d400001ec62` | Frontend `/catalog` tab |
| _(uncommitted)_ | Binding + subscription tables, scheduler owner scope, schedule UI |
@ -89,7 +89,7 @@ explicitly in the service before deleting rows.
- `WorkflowInputSchemaService` — answers eligibility and schema together
- `CatalogRunService` — start node, production mode, input filtering, execution
tagging
- `CatalogService` — listing + own-runs history
- `CatalogService` — listing
- `CatalogController` at `/rest/catalog`
### Frontend

View File

@ -2,13 +2,7 @@ import type { Logger } from '@n8n/backend-common';
import type { User } from '@n8n/db';
import { mock } from 'vitest-mock-extended';
import type { ExecutionService } from '@/executions/execution.service';
import { CATALOG_RUN_USER_KEY } from '@/workflows/catalog-run.service';
import {
CATALOG_LIST_LIMIT,
CATALOG_RUNS_LIMIT,
CatalogService,
} from '@/workflows/catalog.service';
import { CATALOG_LIST_LIMIT, CatalogService } from '@/workflows/catalog.service';
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import type { WorkflowInputSchemaService } from '@/workflows/workflow-input-schema.service';
@ -25,22 +19,19 @@ describe('CatalogService', () => {
let logger: ReturnType<typeof mock<Logger>>;
let finder: ReturnType<typeof mock<WorkflowFinderService>>;
let schemas: ReturnType<typeof mock<WorkflowInputSchemaService>>;
let executions: ReturnType<typeof mock<ExecutionService>>;
beforeEach(() => {
logger = mock<Logger>();
finder = mock<WorkflowFinderService>();
schemas = mock<WorkflowInputSchemaService>();
executions = mock<ExecutionService>();
schemas.describe.mockResolvedValue({
eligible: true,
trigger: 'manual-trigger',
fields: [],
});
executions.findRangeWithCount.mockResolvedValue({ results: [], count: 0, estimated: false });
service = new CatalogService(logger, finder, schemas, executions);
service = new CatalogService(logger, finder, schemas);
});
it('should list only workflows the user may execute', async () => {
@ -110,64 +101,4 @@ describe('CatalogService', () => {
expect(result.workflows).toHaveLength(CATALOG_LIST_LIMIT);
expect(logger.warn).toHaveBeenCalled();
});
describe('listRuns', () => {
it('should select only runs the person started themselves', async () => {
await service.listRuns(user);
expect(executions.findRangeWithCount).toHaveBeenCalledWith(
expect.objectContaining({
user,
metadata: [{ key: CATALOG_RUN_USER_KEY, value: 'user-1', exactMatch: true }],
range: { limit: CATALOG_RUNS_LIMIT },
order: { startedAt: 'DESC' },
}),
);
});
it('should scope visibility to workflows the person may execute', async () => {
executions.buildSharingOptions.mockResolvedValue({ scopes: ['workflow:execute'] });
await service.listRuns(user);
expect(executions.buildSharingOptions).toHaveBeenCalledWith('workflow:execute');
expect(executions.findRangeWithCount).toHaveBeenCalledWith(
expect.objectContaining({ sharingOptions: { scopes: ['workflow:execute'] } }),
);
});
it('should not expose node names through the run summary', async () => {
executions.findRangeWithCount.mockResolvedValue({
results: [
{
id: 'exec-1',
workflowId: 'wf-1',
workflowName: 'Weekly report',
status: 'success',
startedAt: new Date('2026-01-01'),
stoppedAt: new Date('2026-01-02'),
mode: 'trigger',
createdAt: new Date('2026-01-01'),
lastNodeExecuted: 'Send Email',
},
],
count: 1,
estimated: false,
});
const result = await service.listRuns(user);
expect(result.runs).toEqual([
{
id: 'exec-1',
workflowId: 'wf-1',
workflowName: 'Weekly report',
status: 'success',
startedAt: new Date('2026-01-01'),
stoppedAt: new Date('2026-01-02'),
},
]);
expect(result).toEqual(expect.objectContaining({ count: 1, estimated: false }));
});
});
});

View File

@ -48,16 +48,6 @@ export class CatalogController {
return await this.catalogService.list(req.user);
}
/**
* Scoped to executions the person started themselves, by the marker the run
* service writes — not to the workflows they can reach.
*/
@Get('/runs')
@GlobalScope('workflow:list')
async listRuns(req: AuthenticatedRequest) {
return await this.catalogService.listRuns(req.user);
}
@Post('/workflows/:workflowId/run')
@ProjectScope('workflow:execute')
async run(req: AuthenticatedRequest<WorkflowParam>, _res: unknown, @Body payload: CatalogRunDto) {

View File

@ -1,10 +1,8 @@
import { Logger } from '@n8n/backend-common';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import type { ExecutionStatus, FieldValueOption } from 'n8n-workflow';
import type { FieldValueOption } from 'n8n-workflow';
import { ExecutionService } from '@/executions/execution.service';
import { CATALOG_RUN_USER_KEY } from '@/workflows/catalog-run.service';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import type { StartTrigger } from '@/workflows/workflow-input-schema.service';
import { WorkflowInputSchemaService } from '@/workflows/workflow-input-schema.service';
@ -35,31 +33,12 @@ export type CatalogListing = {
truncated: boolean;
};
/** How many past runs one page of history returns. */
export const CATALOG_RUNS_LIMIT = 50;
export type CatalogRun = {
id: string;
workflowId: string;
workflowName?: string;
status: ExecutionStatus;
startedAt: Date | null;
stoppedAt?: Date;
};
export type CatalogRunListing = {
runs: CatalogRun[];
count: number;
estimated: boolean;
};
@Service()
export class CatalogService {
constructor(
private readonly logger: Logger,
private readonly workflowFinderService: WorkflowFinderService,
private readonly workflowInputSchemaService: WorkflowInputSchemaService,
private readonly executionService: ExecutionService,
) {}
/**
@ -111,37 +90,4 @@ export class CatalogService {
truncated,
};
}
/**
* A person's own catalog runs, most recent first.
*
* Scoped by the run marker rather than by workflow, so someone sharing a
* workflow with colleagues sees only what they themselves started.
*/
async listRuns(user: User): Promise<CatalogRunListing> {
const sharingOptions = await this.executionService.buildSharingOptions('workflow:execute');
const { results, count, estimated } = await this.executionService.findRangeWithCount({
kind: 'range',
user,
sharingOptions,
metadata: [{ key: CATALOG_RUN_USER_KEY, value: user.id, exactMatch: true }],
range: { limit: CATALOG_RUNS_LIMIT },
order: { startedAt: 'DESC' },
});
// Projected down from the execution summary on purpose: it also carries
// `lastNodeExecuted`, per-node statuses and error details, which would
// hand node names to someone who cannot read the workflow.
const runs = results.map(({ id, workflowId, workflowName, status, startedAt, stoppedAt }) => ({
id,
workflowId,
workflowName,
status,
startedAt,
stoppedAt,
}));
return { runs, count, estimated };
}
}