mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-19 16:55:02 +02:00
feat(core): Run a workflow from the catalog without opening the editor
Adds POST /catalog/workflows/:id/run for people who were granted execute but not read: the endpoint never returns or requires the graph, and @ProjectScope rejects before the handler. The run is a production one even though a person pressed the button. A manual run would honour a workflow-level "don't save manual executions" and silently erase their history, and would feed them the builder's pinned test data instead of a real result. Values not named by the workflow's declared contract are dropped rather than passed through, and the execution is tagged with the person who ran it so "my runs" is a filter over existing execution storage rather than a new column. Start-node precedence is extracted from findWorkflowStart rather than repeated, so a catalog run enters a workflow through the same node a sub-workflow call would. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0a36cf18a7
commit
f78b151d7f
|
|
@ -118,6 +118,7 @@ export {
|
|||
} from './workflows/base-workflow.dto';
|
||||
export { CreateWorkflowDto } from './workflows/create-workflow.dto';
|
||||
export { UpdateWorkflowDto } from './workflows/update-workflow.dto';
|
||||
export { CatalogRunDto } from './workflows/catalog-run.dto';
|
||||
export {
|
||||
ManualRunDto,
|
||||
type ManualRunPayload,
|
||||
|
|
|
|||
14
packages/@n8n/api-types/src/dto/workflows/catalog-run.dto.ts
Normal file
14
packages/@n8n/api-types/src/dto/workflows/catalog-run.dto.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
/**
|
||||
* Values a person supplies when running a workflow from the catalog.
|
||||
*
|
||||
* Left as an open record: the accepted keys are whatever the workflow declares
|
||||
* on its trigger, so they cannot be known here. The run service drops anything
|
||||
* the contract does not name.
|
||||
*/
|
||||
export class CatalogRunDto extends Z.class({
|
||||
inputs: z.record(z.string(), z.unknown()).optional(),
|
||||
}) {}
|
||||
|
|
@ -65,6 +65,7 @@ import '@/evaluation.ee/test-runs.controller.ee';
|
|||
import '@/evaluation.ee/evaluation-config.controller';
|
||||
import '@/evaluation.ee/evaluation-collections.controller.ee';
|
||||
import '@/evaluation.ee/insights/eval-insights.controller.ee';
|
||||
import '@/workflows/catalog.controller';
|
||||
import '@/workflows/workflow-history/workflow-history.controller';
|
||||
import '@/workflows/workflows.controller';
|
||||
import '@/modules/workflow-index/workflow-dependency.controller';
|
||||
|
|
|
|||
|
|
@ -29,15 +29,24 @@ export function satisfiesToolCapability(syntheticToolName: string, nodeType: INo
|
|||
return isHitlToolType(syntheticToolName) || !!nodeType.description.usableAsTool;
|
||||
}
|
||||
|
||||
/**
|
||||
* The node a caller starts a workflow from, or `undefined` when it declares
|
||||
* none. A trigger that names its inputs wins over a bare start node, so a
|
||||
* workflow offering an input contract is entered through it wherever it is run.
|
||||
*/
|
||||
export function resolveWorkflowStart(nodes: INode[]): INode | undefined {
|
||||
const executeWorkflowTriggerNode = nodes.find(
|
||||
(node) => node.type === 'n8n-nodes-base.executeWorkflowTrigger',
|
||||
);
|
||||
|
||||
if (executeWorkflowTriggerNode) return executeWorkflowTriggerNode;
|
||||
|
||||
return nodes.find((node) => STARTING_NODES.includes(node.type));
|
||||
}
|
||||
|
||||
function findWorkflowStart(executionMode: 'integrated' | 'cli') {
|
||||
return function (nodes: INode[]) {
|
||||
const executeWorkflowTriggerNode = nodes.find(
|
||||
(node) => node.type === 'n8n-nodes-base.executeWorkflowTrigger',
|
||||
);
|
||||
|
||||
if (executeWorkflowTriggerNode) return executeWorkflowTriggerNode;
|
||||
|
||||
const startNode = nodes.find((node) => STARTING_NODES.includes(node.type));
|
||||
const startNode = resolveWorkflowStart(nodes);
|
||||
|
||||
if (startNode) return startNode;
|
||||
|
||||
|
|
|
|||
129
packages/cli/src/workflows/__tests__/catalog-run.service.test.ts
Normal file
129
packages/cli/src/workflows/__tests__/catalog-run.service.test.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import type { User } from '@n8n/db';
|
||||
import type { INode, IWorkflowBase } from 'n8n-workflow';
|
||||
import {
|
||||
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
||||
MANUAL_TRIGGER_NODE_TYPE,
|
||||
UserError,
|
||||
} from 'n8n-workflow';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { ExecutionMetadataService } from '@/services/execution-metadata.service';
|
||||
import { CATALOG_RUN_USER_KEY, CatalogRunService } from '@/workflows/catalog-run.service';
|
||||
import type { WorkflowExecutionService } from '@/workflows/workflow-execution.service';
|
||||
import type { WorkflowInputSchemaService } from '@/workflows/workflow-input-schema.service';
|
||||
|
||||
vi.mock('@/workflow-execute-additional-data', () => ({
|
||||
getBase: vi.fn(async () => ({ userId: 'user-1' })),
|
||||
}));
|
||||
|
||||
const node = (type: string, disabled = false): INode =>
|
||||
({
|
||||
id: `${type}-id`,
|
||||
name: type,
|
||||
type,
|
||||
typeVersion: 1.2,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
disabled,
|
||||
}) as INode;
|
||||
|
||||
const workflow = (nodes: INode[]): IWorkflowBase =>
|
||||
({
|
||||
id: 'workflow-1',
|
||||
name: 'Report',
|
||||
nodes,
|
||||
connections: {},
|
||||
active: false,
|
||||
settings: {},
|
||||
}) as IWorkflowBase;
|
||||
|
||||
describe('CatalogRunService', () => {
|
||||
const user = mock<User>({ id: 'user-1' });
|
||||
|
||||
let service: CatalogRunService;
|
||||
let executions: ReturnType<typeof mock<WorkflowExecutionService>>;
|
||||
let schemas: ReturnType<typeof mock<WorkflowInputSchemaService>>;
|
||||
let metadata: ReturnType<typeof mock<ExecutionMetadataService>>;
|
||||
|
||||
beforeEach(() => {
|
||||
executions = mock<WorkflowExecutionService>();
|
||||
schemas = mock<WorkflowInputSchemaService>();
|
||||
metadata = mock<ExecutionMetadataService>();
|
||||
|
||||
executions.runWorkflow.mockResolvedValue('execution-1');
|
||||
schemas.describe.mockResolvedValue({
|
||||
eligible: true,
|
||||
fields: [{ name: 'customer', type: 'string' }],
|
||||
});
|
||||
|
||||
service = new CatalogRunService(executions, schemas, metadata);
|
||||
});
|
||||
|
||||
it('should start from the trigger that declares the contract', async () => {
|
||||
const wf = workflow([node(MANUAL_TRIGGER_NODE_TYPE), node(EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE)]);
|
||||
|
||||
await service.run(wf, user, { customer: 'Acme Corp' });
|
||||
|
||||
const [, startNode] = executions.runWorkflow.mock.calls[0];
|
||||
expect(startNode.type).toBe(EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE);
|
||||
});
|
||||
|
||||
it('should run in production mode, not manual', async () => {
|
||||
// A manual run would honour "don't save manual executions" and apply the
|
||||
// builder's pinned data, both of which break the run history people see.
|
||||
await service.run(workflow([node(EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE)]), user);
|
||||
|
||||
const [, , , , mode] = executions.runWorkflow.mock.calls[0];
|
||||
expect(mode).toBe('trigger');
|
||||
});
|
||||
|
||||
it('should pass declared inputs through as the first item', async () => {
|
||||
await service.run(workflow([node(EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE)]), user, {
|
||||
customer: 'Acme Corp',
|
||||
});
|
||||
|
||||
const [, , data] = executions.runWorkflow.mock.calls[0];
|
||||
expect(data).toEqual([[{ json: { customer: 'Acme Corp' } }]]);
|
||||
});
|
||||
|
||||
it('should drop values the contract does not declare', async () => {
|
||||
await service.run(workflow([node(EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE)]), user, {
|
||||
customer: 'Acme Corp',
|
||||
smuggled: 'value',
|
||||
});
|
||||
|
||||
const [, , data] = executions.runWorkflow.mock.calls[0];
|
||||
expect(data).toEqual([[{ json: { customer: 'Acme Corp' } }]]);
|
||||
});
|
||||
|
||||
it('should tag the execution with the person who ran it', async () => {
|
||||
const result = await service.run(workflow([node(EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE)]), user);
|
||||
|
||||
expect(result).toEqual({ executionId: 'execution-1' });
|
||||
expect(metadata.save).toHaveBeenCalledWith('execution-1', {
|
||||
[CATALOG_RUN_USER_KEY]: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should refuse a workflow the catalog does not offer', async () => {
|
||||
schemas.describe.mockResolvedValue({ eligible: false, reason: 'own-schedule' });
|
||||
|
||||
await expect(
|
||||
service.run(workflow([node(EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE)]), user),
|
||||
).rejects.toThrow(UserError);
|
||||
|
||||
expect(executions.runWorkflow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ignore a disabled trigger when choosing the start node', async () => {
|
||||
const wf = workflow([
|
||||
node(EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE, true),
|
||||
node(MANUAL_TRIGGER_NODE_TYPE),
|
||||
]);
|
||||
|
||||
await service.run(wf, user);
|
||||
|
||||
const [, startNode] = executions.runWorkflow.mock.calls[0];
|
||||
expect(startNode.type).toBe(MANUAL_TRIGGER_NODE_TYPE);
|
||||
});
|
||||
});
|
||||
79
packages/cli/src/workflows/catalog-run.service.ts
Normal file
79
packages/cli/src/workflows/catalog-run.service.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import type { User } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import type { IDataObject, IWorkflowBase } from 'n8n-workflow';
|
||||
import { UserError } from 'n8n-workflow';
|
||||
|
||||
import { ExecutionMetadataService } from '@/services/execution-metadata.service';
|
||||
import { resolveWorkflowStart } from '@/utils';
|
||||
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
|
||||
import { WorkflowExecutionService } from '@/workflows/workflow-execution.service';
|
||||
import { WorkflowInputSchemaService } from '@/workflows/workflow-input-schema.service';
|
||||
|
||||
/** Marks an execution as belonging to a person's catalog run rather than the workflow's own. */
|
||||
export const CATALOG_RUN_USER_KEY = 'catalogRunUserId';
|
||||
|
||||
@Service()
|
||||
export class CatalogRunService {
|
||||
constructor(
|
||||
private readonly workflowExecutionService: WorkflowExecutionService,
|
||||
private readonly workflowInputSchemaService: WorkflowInputSchemaService,
|
||||
private readonly executionMetadataService: ExecutionMetadataService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Runs a workflow for a person who is present, from the trigger it declares
|
||||
* its input contract on.
|
||||
*
|
||||
* Deliberately a production run, not a manual one. A manual run would let a
|
||||
* workflow-level "don't save manual executions" setting silently erase the
|
||||
* person's history, and would feed them the builder's pinned test data
|
||||
* instead of a real result.
|
||||
*/
|
||||
async run(
|
||||
workflowData: IWorkflowBase,
|
||||
user: User,
|
||||
inputs: IDataObject = {},
|
||||
): Promise<{ executionId: string }> {
|
||||
const schema = await this.workflowInputSchemaService.describe(workflowData);
|
||||
|
||||
if (!schema.eligible) {
|
||||
throw new UserError('This workflow cannot be run directly', {
|
||||
extra: { workflowId: workflowData.id, reason: schema.reason },
|
||||
});
|
||||
}
|
||||
|
||||
const startNode = resolveWorkflowStart(workflowData.nodes.filter((node) => !node.disabled));
|
||||
|
||||
if (!startNode) {
|
||||
// describe() already vetted this; a miss here means the two disagree.
|
||||
throw new UserError('This workflow has no node to start from', {
|
||||
extra: { workflowId: workflowData.id },
|
||||
});
|
||||
}
|
||||
|
||||
const declared = new Set(schema.fields.map((field) => field.name));
|
||||
const json = Object.fromEntries(Object.entries(inputs).filter(([name]) => declared.has(name)));
|
||||
|
||||
const additionalData = await WorkflowExecuteAdditionalData.getBase({
|
||||
userId: user.id,
|
||||
workflowId: workflowData.id,
|
||||
workflowSettings: workflowData.settings,
|
||||
});
|
||||
|
||||
const executionId = await this.workflowExecutionService.runWorkflow(
|
||||
workflowData,
|
||||
startNode,
|
||||
[[{ json }]],
|
||||
additionalData,
|
||||
'trigger',
|
||||
);
|
||||
|
||||
// Attribution rides on metadata rather than a column, so "my runs" is a
|
||||
// filter over existing execution storage.
|
||||
await this.executionMetadataService.save(executionId, {
|
||||
[CATALOG_RUN_USER_KEY]: user.id,
|
||||
});
|
||||
|
||||
return { executionId };
|
||||
}
|
||||
}
|
||||
46
packages/cli/src/workflows/catalog.controller.ts
Normal file
46
packages/cli/src/workflows/catalog.controller.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { CatalogRunDto } from '@n8n/api-types';
|
||||
import type { AuthenticatedRequest } from '@n8n/db';
|
||||
import { Body, Post, ProjectScope, RestController } from '@n8n/decorators';
|
||||
import type { IDataObject } from 'n8n-workflow';
|
||||
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { CatalogRunService } from '@/workflows/catalog-run.service';
|
||||
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||
|
||||
type WorkflowParam = { workflowId: string };
|
||||
|
||||
/**
|
||||
* Running a workflow you were given access to, without opening the editor.
|
||||
*
|
||||
* `@ProjectScope` resolves workflow → project from `:workflowId` and rejects
|
||||
* before the handler, so someone with execute but not read never reaches it —
|
||||
* which is the point: the catalog is for people who run workflows, not build
|
||||
* them, and it never hands out the graph.
|
||||
*/
|
||||
@RestController('/catalog/workflows')
|
||||
export class CatalogController {
|
||||
constructor(
|
||||
private readonly catalogRunService: CatalogRunService,
|
||||
private readonly workflowFinderService: WorkflowFinderService,
|
||||
) {}
|
||||
|
||||
@Post('/:workflowId/run')
|
||||
@ProjectScope('workflow:execute')
|
||||
async run(req: AuthenticatedRequest<WorkflowParam>, _res: unknown, @Body payload: CatalogRunDto) {
|
||||
const workflow = await this.workflowFinderService.findWorkflowForUser(
|
||||
req.params.workflowId,
|
||||
req.user,
|
||||
['workflow:execute'],
|
||||
);
|
||||
|
||||
if (!workflow) {
|
||||
throw new NotFoundError('Could not find workflow');
|
||||
}
|
||||
|
||||
return await this.catalogRunService.run(
|
||||
workflow,
|
||||
req.user,
|
||||
(payload.inputs ?? {}) as IDataObject,
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user