From 5b03945eb061d8aea0905e74bd2f3607391e1fbe Mon Sep 17 00:00:00 2001 From: Uddish Verma Date: Thu, 13 Aug 2026 12:28:12 +0000 Subject: [PATCH 0001/1458] refactor(API): Migrate GET /workflows to the decorator pattern (no-changelog) (#36015) Co-authored-by: Claude Opus 5 (1M context) --- packages/@n8n/api-types/src/dto/index.ts | 3 + .../list-workflows-query.dto.test.ts | 14 ++ .../dto/workflows/list-workflows-query.dto.ts | 15 ++ .../src/dto/workflows/workflow-public.dto.ts | 28 +++ packages/cli/src/public-api/types.ts | 18 -- .../workflows.public.controller.ts | 118 +++++++++- .../spec/paths/getWorkflows.generated.yml | 202 ++++++++++++++++++ .../workflows/spec/paths/workflows.yml | 56 ----- .../handlers/workflows/workflows.handler.ts | 60 ------ .../v1/openapi.decorator-routes.generated.yml | 3 + .../integration/public-api/workflows.test.ts | 57 +++++ 11 files changed, 438 insertions(+), 136 deletions(-) create mode 100644 packages/@n8n/api-types/src/dto/workflows/__tests__/list-workflows-query.dto.test.ts create mode 100644 packages/@n8n/api-types/src/dto/workflows/list-workflows-query.dto.ts create mode 100644 packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getWorkflows.generated.yml diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 3f2bc6245e3..01618c770e3 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -134,9 +134,12 @@ export { GetResourceDependencyCountsDto } from './workflows/get-resource-depende export { GetResourceDependenciesDto } from './workflows/get-resource-dependencies.dto'; export { TagIdsPublicDto, WorkflowTagsPublicDto } from './workflows/workflow-tags-public.dto'; export { GetWorkflowQueryDto } from './workflows/get-workflow-query.dto'; +export { ListWorkflowsQueryDto } from './workflows/list-workflows-query.dto'; export { WorkflowPublicDto, + WorkflowListPublicDto, workflowPublicSchema, + workflowListItemPublicSchema, sharedWorkflowPublicSchema, activeWorkflowVersionPublicSchema, } from './workflows/workflow-public.dto'; diff --git a/packages/@n8n/api-types/src/dto/workflows/__tests__/list-workflows-query.dto.test.ts b/packages/@n8n/api-types/src/dto/workflows/__tests__/list-workflows-query.dto.test.ts new file mode 100644 index 00000000000..f0962f432b5 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/workflows/__tests__/list-workflows-query.dto.test.ts @@ -0,0 +1,14 @@ +import { ListWorkflowsQueryDto } from '../list-workflows-query.dto'; + +describe('ListWorkflowsQueryDto', () => { + test('accepts an empty query and applies defaults', () => { + const result = ListWorkflowsQueryDto.safeParse({}); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.offset).toBe(0); + expect(result.data.limit).toBe(100); + expect(result.data.excludePinnedData).toBe(false); + } + }); +}); diff --git a/packages/@n8n/api-types/src/dto/workflows/list-workflows-query.dto.ts b/packages/@n8n/api-types/src/dto/workflows/list-workflows-query.dto.ts new file mode 100644 index 00000000000..48178f165d4 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/workflows/list-workflows-query.dto.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +import { booleanFromString } from '../../schemas/boolean-from-string'; +import { Z } from '../../zod-class'; +import { publicApiPaginationSchema } from '../pagination/pagination.dto'; + +export class ListWorkflowsQueryDto extends Z.class({ + ...publicApiPaginationSchema, + cursor: z.string().optional(), + active: booleanFromString.optional(), + tags: z.string().optional(), + name: z.string().optional(), + projectId: z.string().optional(), + excludePinnedData: booleanFromString.optional().default('false'), +}) {} diff --git a/packages/@n8n/api-types/src/dto/workflows/workflow-public.dto.ts b/packages/@n8n/api-types/src/dto/workflows/workflow-public.dto.ts index baf53f4329e..2d77c801d40 100644 --- a/packages/@n8n/api-types/src/dto/workflows/workflow-public.dto.ts +++ b/packages/@n8n/api-types/src/dto/workflows/workflow-public.dto.ts @@ -109,3 +109,31 @@ export const workflowPublicSchema = z.object({ }); export class WorkflowPublicDto extends Z.class(workflowPublicSchema.shape) {} + +// The list query selects fewer columns than a single-workflow fetch, so these are absent from every +// item — adding them back makes the response fail its own validation. +export const workflowListItemSharedPublicSchema = sharedWorkflowPublicSchema.omit({ + project: true, +}); + +export const workflowListItemActiveVersionPublicSchema = activeWorkflowVersionPublicSchema.omit({ + workflowPublishHistory: true, +}); + +export const workflowListItemPublicSchema = workflowPublicSchema + .omit({ + description: true, + versionCounter: true, + sourceWorkflowId: true, + shared: true, + activeVersion: true, + }) + .extend({ + shared: z.array(workflowListItemSharedPublicSchema), + activeVersion: workflowListItemActiveVersionPublicSchema.nullable(), + }); + +export class WorkflowListPublicDto extends Z.class({ + data: z.array(workflowListItemPublicSchema), + nextCursor: z.string().nullable(), +}) {} diff --git a/packages/cli/src/public-api/types.ts b/packages/cli/src/public-api/types.ts index 211592accff..4193679754b 100644 --- a/packages/cli/src/public-api/types.ts +++ b/packages/cli/src/public-api/types.ts @@ -118,24 +118,6 @@ export declare namespace CredentialTypeRequest { } export declare namespace WorkflowRequest { - type GetAll = AuthenticatedRequest< - {}, - {}, - {}, - { - tags?: string; - status?: ExecutionStatus; - limit?: number; - cursor?: string; - offset?: number; - workflowId?: number; - active: boolean; - name?: string; - projectId?: string; - excludePinnedData?: boolean; - } - >; - type Create = AuthenticatedRequest< {}, {}, diff --git a/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts b/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts index 947974b4ece..5e8baa0d4a0 100644 --- a/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts @@ -1,7 +1,9 @@ import { GetWorkflowQueryDto, ListWorkflowHistoryQueryDto, + ListWorkflowsQueryDto, TagIdsPublicDto, + WorkflowListPublicDto, WorkflowPublicDto, WorkflowTagsPublicDto, WorkflowVersionHistoryListPublicDto, @@ -48,6 +50,10 @@ function toPublicJson(value: unknown): Record | null { : null; } +function parseTagNames(tags: string): string[] { + return tags.split(',').map((tag) => tag.trim()); +} + function toPublicTag(tag: TagEntity) { return { id: tag.id, @@ -78,6 +84,34 @@ function toPublicSharedWorkflow(sharedWorkflow: SharedWorkflow) { }; } +/** List rows come back without the joined project, so they map a share row without one. */ +function toPublicListSharedWorkflow(sharedWorkflow: SharedWorkflow) { + return { + role: sharedWorkflow.role, + workflowId: sharedWorkflow.workflowId, + projectId: sharedWorkflow.projectId, + createdAt: sharedWorkflow.createdAt.toISOString(), + updatedAt: sharedWorkflow.updatedAt.toISOString(), + }; +} + +/** Same, for the active version: the list query does not load its publish history. */ +function toPublicListActiveVersion(activeVersion: WorkflowHistory) { + return { + versionId: activeVersion.versionId, + workflowId: activeVersion.workflowId, + nodes: activeVersion.nodes, + connections: activeVersion.connections, + nodeGroups: activeVersion.nodeGroups, + authors: activeVersion.authors, + name: activeVersion.name, + description: activeVersion.description, + autosaved: activeVersion.autosaved, + createdAt: activeVersion.createdAt.toISOString(), + updatedAt: activeVersion.updatedAt.toISOString(), + }; +} + function toPublicWorkflowPublishHistory(entry: WorkflowPublishHistory) { return { id: entry.id, @@ -119,12 +153,92 @@ export class WorkflowsPublicController { private readonly workflowService: WorkflowService, ) {} + private get workflowTagsEnabled(): boolean { + return !this.globalConfig.tags.disabled; + } + private assertWorkflowTagsEnabled() { - if (this.globalConfig.tags.disabled) { + if (!this.workflowTagsEnabled) { throw new BadRequestError('Workflow Tags Disabled'); } } + @Get('/') + @ApiKeyScope('workflow:list') + @ApiSummary('Retrieve all workflows') + @ApiDescription('Retrieve all workflows from your instance.') + @ApiTags(['Workflow']) + @ApiResponse(200, WorkflowListPublicDto) + async getWorkflows( + req: AuthenticatedRequest, + _res: Response, + @Query query: ListWorkflowsQueryDto, + ): Promise { + let { offset, limit } = query; + + if (query.cursor) { + try { + const decoded = decodeCursor(query.cursor); + if ('offset' in decoded) { + offset = decoded.offset; + } + limit = decoded.limit ?? limit; + } catch { + throw new BadRequestError('An invalid cursor was provided'); + } + } + + const { workflows, count } = await this.workflowFinderService.findWorkflowsForUser( + req.user, + ['workflow:read'], + { + filters: { + name: query.name, + active: query.active, + tagNames: query.tags ? parseTagNames(query.tags) : undefined, + projectId: query.projectId, + }, + offset, + limit, + includePinnedData: !query.excludePinnedData, + includeTags: this.workflowTagsEnabled, + includeActiveVersion: true, + }, + ); + + this.eventService.emit('user-retrieved-all-workflows', { + userId: req.user.id, + publicApi: true, + }); + + return { + data: workflows.map((workflow) => ({ + id: workflow.id, + name: workflow.name, + active: workflow.active, + activeVersionId: workflow.activeVersionId, + createdAt: workflow.createdAt.toISOString(), + updatedAt: workflow.updatedAt.toISOString(), + isArchived: workflow.isArchived, + versionId: workflow.versionId, + triggerCount: workflow.triggerCount, + nodes: workflow.nodes, + connections: workflow.connections, + nodeGroups: workflow.nodeGroups, + settings: toPublicJson(workflow.settings), + staticData: toPublicJson(workflow.staticData), + meta: toPublicJson(workflow.meta), + ...(query.excludePinnedData ? {} : { pinData: toPublicJson(workflow.pinData) }), + ...(workflow.tags ? { tags: workflow.tags.map(toPublicTag) } : {}), + shared: workflow.shared.map(toPublicListSharedWorkflow), + activeVersion: workflow.activeVersion + ? toPublicListActiveVersion(workflow.activeVersion) + : null, + })), + nextCursor: encodeNextCursor({ offset, limit, numberOfTotalRecords: count }), + }; + } + @Get('/:workflowId') @ApiKeyScope('workflow:read') @ProjectScope('workflow:read') @@ -144,7 +258,7 @@ export class WorkflowsPublicController { req.user, ['workflow:read'], { - includeTags: !this.globalConfig.tags.disabled, + includeTags: this.workflowTagsEnabled, includeActiveVersion: true, }, ); diff --git a/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getWorkflows.generated.yml b/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getWorkflows.generated.yml new file mode 100644 index 00000000000..e5bc4d6b723 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getWorkflows.generated.yml @@ -0,0 +1,202 @@ +operationId: getWorkflows +tags: + - Workflow +summary: Retrieve all workflows +description: Retrieve all workflows from your instance. +x-required-scope: workflow:list +parameters: + - $ref: ../../../../shared/spec/parameters/offset.yml + - $ref: ../../../../shared/spec/parameters/limit.yml + - $ref: ../../../../shared/spec/parameters/cursor.yml + - schema: + type: string + enum: + - 'true' + - 'false' + required: false + name: active + in: query + - schema: + type: string + required: false + name: tags + in: query + - schema: + type: string + required: false + name: name + in: query + - schema: + type: string + required: false + name: projectId + in: query + - schema: + type: string + enum: + - 'true' + - 'false' + default: 'false' + required: false + name: excludePinnedData + in: query +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +responses: + '200': + description: Operation successful. + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + active: + type: boolean + activeVersionId: + type: string + nullable: true + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + isArchived: + type: boolean + versionId: + type: string + triggerCount: + type: number + nodes: {} + connections: {} + nodeGroups: {} + settings: + nullable: true + staticData: + nullable: true + pinData: + nullable: true + meta: + nullable: true + tags: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + required: + - id + - name + - createdAt + - updatedAt + shared: + type: array + items: + type: object + properties: + role: + type: string + workflowId: + type: string + projectId: + type: string + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + required: + - role + - workflowId + - projectId + - createdAt + - updatedAt + activeVersion: + type: object + nullable: true + properties: + versionId: + type: string + workflowId: + type: string + nodes: {} + connections: {} + nodeGroups: {} + authors: + type: string + name: + type: string + nullable: true + description: + type: string + nullable: true + autosaved: + type: boolean + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + required: + - versionId + - workflowId + - nodes + - connections + - nodeGroups + - authors + - name + - description + - autosaved + - createdAt + - updatedAt + required: + - id + - name + - active + - activeVersionId + - createdAt + - updatedAt + - isArchived + - versionId + - triggerCount + - nodes + - connections + - nodeGroups + - settings + - staticData + - meta + - shared + - activeVersion + nextCursor: + type: string + nullable: true + required: + - data + - nextCursor + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml diff --git a/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/workflows.yml b/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/workflows.yml index 7ea3b3e0396..000c4b788de 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/workflows.yml +++ b/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/workflows.yml @@ -28,59 +28,3 @@ post: $ref: '../../../../shared/spec/responses/forbidden.yml' '404': $ref: '../../../../shared/spec/responses/notFound.yml' -get: - x-eov-operation-id: getWorkflows - x-required-scope: workflow:list - x-eov-operation-handler: v1/handlers/workflows/workflows.handler - tags: - - Workflow - summary: Retrieve all workflows - description: Retrieve all workflows from your instance. - parameters: - - name: active - in: query - schema: - type: boolean - example: true - - name: tags - in: query - required: false - explode: false - allowReserved: true - schema: - type: string - example: test,production - - name: name - in: query - required: false - explode: false - allowReserved: true - schema: - type: string - example: My Workflow - - name: projectId - in: query - required: false - explode: false - allowReserved: true - schema: - type: string - example: VmwOO9HeTEj20kxM - - name: excludePinnedData - in: query - required: false - description: Set this to avoid retrieving pinned data - schema: - type: boolean - example: true - - $ref: '../../../../shared/spec/parameters/limit.yml' - - $ref: '../../../../shared/spec/parameters/cursor.yml' - responses: - '200': - description: Operation successful. - content: - application/json: - schema: - $ref: '../schemas/workflowList.yml' - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' diff --git a/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts b/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts index 55d972db7f9..b4a12cd2571 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts @@ -1,4 +1,3 @@ -import { GlobalConfig } from '@n8n/config'; import { WorkflowEntity } from '@n8n/db'; import { Container } from '@n8n/di'; import { PROJECT_ROOT } from 'n8n-workflow'; @@ -12,7 +11,6 @@ import { EventService } from '@/events/event.service'; import { RedactionEnforcementService } from '@/modules/redaction/redaction-enforcement.service'; import { WorkflowCreationService } from '@/workflows/workflow-creation.service'; import { createWorkflowEntityFromPayload } from '@/workflows/workflow-entity-mapper'; -import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service'; import { WorkflowService } from '@/workflows/workflow.service'; import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee'; @@ -22,10 +20,8 @@ import type { PublicAPIEndpoint } from '../../shared/handler.types'; import { publicApiScope, projectScope, - validCursor, deprecated, } from '../../shared/middlewares/global.middleware'; -import { encodeNextCursor } from '../../shared/services/pagination.service'; const handleError = (error: unknown) => { if (error instanceof FolderNotFoundError) { @@ -40,20 +36,11 @@ const handleError = (error: unknown) => { throw error; }; -function parseTagNames(tags: string): string[] { - return tags.split(',').map((tag) => tag.trim()); -} - -function areWorkflowTagsEnabled(): boolean { - return !Container.get(GlobalConfig).tags.disabled; -} - type WorkflowHandlers = { createWorkflow: PublicAPIEndpoint; transferWorkflow: PublicAPIEndpoint; deleteWorkflow: PublicAPIEndpoint; getWorkflowVersion: PublicAPIEndpoint; - getWorkflows: PublicAPIEndpoint; updateWorkflow: PublicAPIEndpoint; publishWorkflow: PublicAPIEndpoint; unpublishWorkflow: PublicAPIEndpoint; @@ -195,53 +182,6 @@ const workflowHandlers: WorkflowHandlers = { } }, ], - getWorkflows: [ - publicApiScope('workflow:list'), - validCursor, - async (req, res) => { - const { - offset = 0, - limit = 100, - excludePinnedData = false, - active, - tags, - name, - projectId, - } = req.query; - - const { workflows, count } = await Container.get(WorkflowFinderService).findWorkflowsForUser( - req.user, - ['workflow:read'], - { - filters: { - name, - active, - tagNames: tags ? parseTagNames(tags) : undefined, - projectId, - }, - offset, - limit, - includePinnedData: !excludePinnedData, - includeTags: areWorkflowTagsEnabled(), - includeActiveVersion: true, - }, - ); - - Container.get(EventService).emit('user-retrieved-all-workflows', { - userId: req.user.id, - publicApi: true, - }); - - return res.json({ - data: workflows, - nextCursor: encodeNextCursor({ - offset, - limit, - numberOfTotalRecords: count, - }), - }); - }, - ], updateWorkflow: [ publicApiScope('workflow:update'), projectScope('workflow:update', 'workflow'), diff --git a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml index cb6a131bc12..70da43be27e 100644 --- a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml +++ b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml @@ -14,6 +14,9 @@ paths: /tags: get: $ref: ./handlers/tags/spec/paths/getTags.generated.yml + /workflows: + get: + $ref: ./handlers/workflows/spec/paths/getWorkflows.generated.yml /workflows/{workflowId}: get: $ref: ./handlers/workflows/spec/paths/getWorkflow.generated.yml diff --git a/packages/cli/test/integration/public-api/workflows.test.ts b/packages/cli/test/integration/public-api/workflows.test.ts index 365eaef30f0..72390bb1d85 100644 --- a/packages/cli/test/integration/public-api/workflows.test.ts +++ b/packages/cli/test/integration/public-api/workflows.test.ts @@ -323,6 +323,46 @@ describe('GET /workflows', () => { expect(response.body.data[0].id).not.toEqual(response2.body.data[0].id); }); + test('should reject a cursor that does not decode to JSON', async () => { + await createWorkflowWithHistory({}, member); + + const cursor = Buffer.from('not json').toString('base64'); + const response = await authMemberAgent.get(`/workflows?cursor=${cursor}`); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toBe('An invalid cursor was provided'); + }); + + test('should take the limit from a cursor that carries no offset', async () => { + await Promise.all([ + createWorkflowWithHistory({}, member), + createWorkflowWithHistory({}, member), + createWorkflowWithHistory({}, member), + ]); + + const cursor = Buffer.from(JSON.stringify({ lastId: 'abc', limit: 2 })).toString('base64'); + const response = await authMemberAgent.get(`/workflows?cursor=${cursor}`); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toHaveLength(2); + expect(response.body.nextCursor).not.toBeNull(); + }); + + test('should fall back to the default limit for a cursor that carries none', async () => { + await Promise.all([ + createWorkflowWithHistory({}, member), + createWorkflowWithHistory({}, member), + createWorkflowWithHistory({}, member), + ]); + + const cursor = Buffer.from(JSON.stringify({ offset: 1 })).toString('base64'); + const response = await authMemberAgent.get(`/workflows?cursor=${cursor}`); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toHaveLength(2); + expect(response.body.nextCursor).toBeNull(); + }); + test('should return all owned workflows filtered by tag', async () => { const tag = await createTag({}); @@ -365,6 +405,23 @@ describe('GET /workflows', () => { expect(wfTags[0].id).toBe(tag.id); }); + test('should omit tags entirely when tags are disabled', async () => { + globalConfig.tags.disabled = true; + + try { + const tag = await createTag({ name: 'production' }); + await createWorkflowWithHistory({ tags: [tag] }, member); + + const response = await authMemberAgent.get('/workflows'); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toHaveLength(1); + expect(response.body.data[0]).not.toHaveProperty('tags'); + } finally { + globalConfig.tags.disabled = false; + } + }); + test('should return all owned workflows filtered by tags', async () => { const tags = await Promise.all([await createTag({}), await createTag({})]); const tagNames = tags.map((tag) => tag.name).join(','); From 82e5b566f7754894081f9bef6c7071e935433b98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel=20Moreno?= <1411774+miguelsaddress@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:30:14 +0000 Subject: [PATCH 0002/1458] refactor(core): Split the update_workflow MCP tool handler into named steps (no-changelog) (#35458) Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Ricardo Espinoza --- .../workflow-builder/update-workflow.tool.ts | 1420 ++++++++++------- 1 file changed, 864 insertions(+), 556 deletions(-) diff --git a/packages/cli/src/modules/mcp/tools/workflow-builder/update-workflow.tool.ts b/packages/cli/src/modules/mcp/tools/workflow-builder/update-workflow.tool.ts index b8c0b8aeeb6..927c034979e 100644 --- a/packages/cli/src/modules/mcp/tools/workflow-builder/update-workflow.tool.ts +++ b/packages/cli/src/modules/mcp/tools/workflow-builder/update-workflow.tool.ts @@ -4,17 +4,34 @@ import { type User, type SharedWorkflowRepository, WorkflowEntity } from '@n8n/d import { hasGlobalScope } from '@n8n/permissions'; import type { WorkflowJSON } from '@n8n/workflow-sdk'; import { Workflow, type INode, type IWorkflowSettings } from 'n8n-workflow'; -import z from 'zod'; +import { z } from 'zod'; + +import type { CollaborationService } from '@/collaboration/collaboration.service'; +import type { CredentialsService } from '@/credentials/credentials.service'; +import { SubworkflowPolicyDenialError } from '@/errors/subworkflow-policy-denial.error'; +import type { SubworkflowPolicyChecker } from '@/executions/pre-execution-checks/subworkflow-policy-checker'; +import type { DataTableUserOperations } from '@/modules/data-table/data-table-proxy.service'; +import type { NodeTypes } from '@/node-types'; +import type { AiGatewayService } from '@/services/ai-gateway.service'; +import type { TagService } from '@/services/tag.service'; +import type { UrlService } from '@/services/url.service'; +import type { Telemetry } from '@/telemetry'; +import { + dropInvalidNodeGroups, + makeGetNodeTypeForGrouping, + resolveNodeWebhookIds, +} from '@/workflow-helpers'; +import type { WorkflowFinderService } from '@/workflows/workflow-finder.service'; +import type { WorkflowPublishedDataService } from '@/workflows/workflow-published-data.service'; +import type { WorkflowService } from '@/workflows/workflow.service'; -import { USER_CALLED_MCP_TOOL_EVENT } from '../../mcp.constants'; -import type { ToolDefinition, UserCalledMCPToolEventPayload } from '../../mcp.types'; import { buildInvalidAiToolSourceErrorResponse } from './connection-structure-check'; import { MCP_UPDATE_WORKFLOW_TOOL } from './constants'; import { validateCredentialReferences } from './credential-validation'; import { autoPopulateNodeCredentials, trackAutoassignOutcomes, - type SlotOutcome, + type AutoAssignResult, } from './credentials-auto-assign'; import { validateDataTableReferencesForUpdate } from './data-table-validation'; import { sanitizeSkillsUsed, SKILLS_USED_PARAM_DESCRIPTION } from './skills-used'; @@ -34,30 +51,11 @@ import { type PartialUpdateOperation, type SkippedOperation, } from './workflow-operations'; - -import type { CollaborationService } from '@/collaboration/collaboration.service'; -import type { CredentialsService } from '@/credentials/credentials.service'; -import { SubworkflowPolicyDenialError } from '@/errors/subworkflow-policy-denial.error'; -import type { SubworkflowPolicyChecker } from '@/executions/pre-execution-checks/subworkflow-policy-checker'; -import type { WorkflowPublishedDataService } from '@/workflows/workflow-published-data.service'; -import type { DataTableUserOperations } from '@/modules/data-table/data-table-proxy.service'; -import type { NodeTypes } from '@/node-types'; -import type { TagService } from '@/services/tag.service'; -import type { AiGatewayService } from '@/services/ai-gateway.service'; -import type { UrlService } from '@/services/url.service'; -import type { Telemetry } from '@/telemetry'; -import { - dropInvalidNodeGroups, - makeGetNodeTypeForGrouping, - resolveNodeWebhookIds, -} from '@/workflow-helpers'; -import type { WorkflowFinderService } from '@/workflows/workflow-finder.service'; -import type { WorkflowService } from '@/workflows/workflow.service'; - +import { USER_CALLED_MCP_TOOL_EVENT } from '../../mcp.constants'; +import type { ToolDefinition, UserCalledMCPToolEventPayload } from '../../mcp.types'; import { getMcpWorkflow } from '../workflow-validation.utils'; const MAX_OPERATIONS_PER_CALL = 100; - const baseOperationTypes = [ 'updateNodeParameters', 'setNodeParameter', @@ -75,26 +73,30 @@ const baseOperationTypes = [ 'addTags', 'removeTags', 'setNodeGroups', -] as const; - +] as const satisfies ReadonlyArray; // Granular group ops roll out behind the `102_mcp_canvas_groups` flag; // `setNodeGroups` predates the flag and stays ungated. -const gatedGroupOperationTypes = ['addNodeGroup', 'removeNodeGroup', 'updateNodeGroup'] as const; - -const GATED_GROUP_OP_TYPES: ReadonlySet = new Set(gatedGroupOperationTypes); - +const gatedGroupOperationTypes = [ + 'addNodeGroup', + 'removeNodeGroup', + 'updateNodeGroup', +] as const satisfies ReadonlyArray; +// The `satisfies` on both tuples above is what catches a renamed operation type +// in workflow-operations.ts at compile time, instead of silently leaving the gate +// unmatched or an implemented operation unreachable. The set element type is +// narrowed to match so `.has()` only accepts a real operation type. +const GATED_GROUP_OP_TYPES: ReadonlySet = new Set( + gatedGroupOperationTypes, +); const buildOperationTypeSchema = (canvasGroupsEnabled: boolean) => canvasGroupsEnabled ? z.enum([...baseOperationTypes, ...gatedGroupOperationTypes]) : z.enum(baseOperationTypes); - const positionInputSchema = z.array(z.number()).length(2).describe('Canvas [x, y].'); - const credentialsInputSchema = z.record( z.string(), z.object({ id: z.string().optional(), name: z.string() }), ); - const nodeInputSchema = z.object({ name: z.string().describe('Unique node name.'), type: z.string().describe('Node type, e.g. "n8n-nodes-base.set".'), @@ -106,7 +108,6 @@ const nodeInputSchema = z.object({ notes: z.string().optional(), id: z.string().optional(), }); - const nodeSettingsInputSchema = z.object({ onError: z .enum(['stopWorkflow', 'continueRegularOutput', 'continueErrorOutput']) @@ -118,7 +119,6 @@ const nodeSettingsInputSchema = z.object({ alwaysOutputData: z.boolean().optional(), executeOnce: z.boolean().optional(), }); - // Published (loose) shape for the `settings` field. It is the superset of the // node-level keys (setNodeSettings) and workflow-level keys // (setWorkflowSettings); there is no key overlap. The discriminated union in @@ -132,7 +132,6 @@ const combinedSettingsInputSchema = z .describe( 'Settings to write. For setNodeSettings use the node-level keys (onError, retryOnFail, maxTries, waitBetweenTries, alwaysOutputData, executeOnce). For setWorkflowSettings use the workflow-level keys (errorWorkflow, timezone, executionOrder, saveExecutionProgress, saveManualExecutions, saveDataErrorExecution, saveDataSuccessExecution, executionTimeout, timeSavedPerExecution, callerPolicy, callerIds). Provide only the keys for the operation you are running.', ); - const buildOperationInputSchema = (canvasGroupsEnabled: boolean) => z .object({ @@ -218,36 +217,16 @@ const buildOperationInputSchema = (canvasGroupsEnabled: boolean) => : {}), }) .describe('Workflow update operation. Provide fields matching type.'); - type OperationInput = { type: (typeof baseOperationTypes)[number] | (typeof gatedGroupOperationTypes)[number]; [key: string]: unknown; }; - const strictOperationsSchema = z.array(partialUpdateOperationSchema); - -// LLM-supplied tag batches routinely repeat a name in different casings; collapse -// those (first-seen case wins) before hitting the tag API. Tag names are -// case-sensitively unique, so this is an MCP-only semantic — the service itself -// treats case-variant names as distinct tags. -function dedupeNamesPreservingCase(names: string[]): string[] { - const seen = new Set(); - const result: string[] = []; - for (const raw of names) { - const trimmed = raw.trim(); - if (trimmed.length === 0) continue; - const key = trimmed.toLowerCase(); - if (seen.has(key)) continue; - seen.add(key); - result.push(trimmed); - } - return result; -} - function parseStrictOperations(operations: OperationInput[]): PartialUpdateOperation[] { const parsed = strictOperationsSchema.safeParse(operations); - if (parsed.success) return parsed.data; - + if (parsed.success) { + return parsed.data; + } const details = parsed.error.issues .map(({ path, message }) => { const [index, ...rest] = path; @@ -257,77 +236,10 @@ function parseStrictOperations(operations: OperationInput[]): PartialUpdateOpera return `${path.length ? path.join('.') : 'operations'}: ${message}`; }) .join('; '); - throw new Error(`Invalid operations: ${details}`); } -// Renames are followed so the key matches the node's name in the post-apply -// workflow. -function collectTouchedNodes(operations: PartialUpdateOperation[]): Map { - const touched = new Map(); - const recordTouch = (name: string, opIndex: number) => { - if (!touched.has(name)) touched.set(name, opIndex); - }; - - for (let i = 0; i < operations.length; i++) { - const op = operations[i]; - if (op.type === 'addNode') { - recordTouch(op.node.name, i); - } else if (op.type === 'updateNodeParameters' || op.type === 'setNodeParameter') { - recordTouch(op.nodeName, i); - } else if (op.type === 'renameNode') { - const idx = touched.get(op.oldName); - if (idx !== undefined) { - touched.delete(op.oldName); - touched.set(op.newName, idx); - } - } else if (op.type === 'removeNode') { - touched.delete(op.nodeName); - } - } - - return touched; -} - -/** - * How many operations had no effect, so `appliedOperations` can discount them. - * - * An operation counts only if *all* the groups it produced were skipped or - * dropped: one `setNodeGroups` can define several groups and still apply the - * rest after losing one. - * - * Known imprecision: each group remembers only the last operation that touched - * it, so in an `addNodeGroup` + `updateNodeGroup` batch on the same dropped - * group only the second operation is discounted. - */ -const countOperationsWithNoEffect = ( - skippedOperations: Array>, - groupOperations: ApplyOperationsSuccess['groupOperations'], -): number => { - const producedPerOp = new Map(); - for (const { opIndex } of Object.values(groupOperations)) { - producedPerOp.set(opIndex, (producedPerOp.get(opIndex) ?? 0) + 1); - } - - const skippedPerOp = new Map(); - for (const { opIndex } of skippedOperations) { - skippedPerOp.set(opIndex, (skippedPerOp.get(opIndex) ?? 0) + 1); - } - - let count = 0; - for (const [opIndex, skipped] of skippedPerOp) { - if (skipped >= (producedPerOp.get(opIndex) ?? 0)) { - count++; - } - } - return count; -}; - -// The concrete return type (not a widened z.ZodRawShape) keeps the tool's -// generic coupled to the real schema shape, so the handler's argument -// annotation is compile-checked against it via ToolHandler's parameter types. const NON_FATAL_OPERATION_TYPES_LIST = [...NON_FATAL_OPERATION_TYPES].join(', '); - const buildToolDescription = (canvasGroupsEnabled: boolean) => { const base = 'Atomically update an existing workflow with operation objects. Edits nodes/connections and also workflow-level settings via setWorkflowSettings — including the error workflow that runs automatically on failure to send alerts (e.g. when a user asks to "add error handling" or "notify me if this breaks"). Pass skillsUsed if n8n skills were used.'; @@ -335,7 +247,9 @@ const buildToolDescription = (canvasGroupsEnabled: boolean) => { ? `${base} Node-group operations (${NON_FATAL_OPERATION_TYPES_LIST}) are the one exception to "atomically": an invalid one is skipped and reported in skippedOperations instead of aborting the whole update. Separately, if other edits in the batch make an existing group invalid, that group is removed and reported in removedGroups.` : base; }; - +// The concrete return type (not a widened z.ZodRawShape) keeps the tool's +// generic coupled to the real schema shape, so the handler's argument +// annotation is compile-checked against it via ToolHandler's parameter types. const buildInputSchema = (canvasGroupsEnabled: boolean) => ({ workflowId: z.string().describe('The ID of the workflow to update.'), @@ -356,7 +270,6 @@ const buildInputSchema = (canvasGroupsEnabled: boolean) => 'Longer description of what changed and why, shown in the version history alongside the version name.', ), }) satisfies z.ZodRawShape; - // The MCP SDK publishes this schema with `additionalProperties: false` and // validates `structuredContent` against it on every response. Success returns // the full payload below; the error path returns only `{ error }`. To keep @@ -435,6 +348,13 @@ const outputSchema = { .optional() .describe('Error message explaining why the update failed. Present only on failure.'), } satisfies z.ZodRawShape; +/** + * The success payload, derived from `outputSchema` so the handler cannot build a + * field the published schema does not declare. That matters because the MCP SDK + * validates `structuredContent` against the schema on every response, so an + * undeclared field turns a successful update into an opaque `-32602`. + */ +type UpdateWorkflowOutput = z.infer>; /** * Validates a freshly-set `errorWorkflow` reference. Throws a teaching-oriented @@ -464,7 +384,9 @@ async function assertErrorWorkflowIsUsable({ subworkflowPolicyChecker: SubworkflowPolicyChecker; errorTriggerType: string; }): Promise { - if (!errorWorkflowId || errorWorkflowId === 'DEFAULT') return; + if (!errorWorkflowId || errorWorkflowId === 'DEFAULT') { + return; + } // Read access is required intentionally, mirroring the editor UI (the error // workflow picker only lists workflows the user can read). Resolving the @@ -481,6 +403,7 @@ async function assertErrorWorkflowIsUsable({ // service is off; otherwise we read it from the service below. { includeActiveVersion: !useWorkflowPublicationService }, ); + if (!errorWorkflow) { throw new Error( `Error workflow '${errorWorkflowId}' was not found or you do not have access to it. Find a valid workflow ID with search_workflows, or create an error-handler workflow first.`, @@ -492,6 +415,7 @@ async function assertErrorWorkflowIsUsable({ // WorkflowExecutionService.loadErrorWorkflowData exactly so we neither reject a // workflow runtime would run nor accept a version runtime will not use. let publishedNodes: INode[] | undefined; + if (useWorkflowPublicationService) { const published = await workflowPublishedDataService.getPublishedWorkflowData(errorWorkflowId); publishedNodes = published?.publishedVersion.nodes; @@ -508,6 +432,7 @@ async function assertErrorWorkflowIsUsable({ const hasErrorTrigger = publishedNodes.some( (node) => node.type === errorTriggerType && node.disabled !== true, ); + if (!hasErrorTrigger) { throw new Error( `The published version of workflow '${errorWorkflow.name}' (${errorWorkflowId}) has no active Error Trigger node, so it would never run when this workflow fails. Add an Error Trigger node (${errorTriggerType}) and publish it, pick a different error workflow, or create a new error-handler workflow.`, @@ -527,6 +452,7 @@ async function assertErrorWorkflowIsUsable({ active: false, settings: errorWorkflow.settings ?? {}, }); + try { await subworkflowPolicyChecker.check( errorWorkflowInstance, @@ -540,6 +466,7 @@ async function assertErrorWorkflowIsUsable({ `Error workflow '${errorWorkflow.name}' (${errorWorkflowId}) cannot be called by this workflow because of its caller policy, so n8n would block it at runtime. Update that workflow's settings ("This workflow can be called by …") to allow this one — set it to any workflow, or add this workflow to its allowlist — or pick a different error workflow.`, ); } + throw error; } } @@ -551,7 +478,9 @@ async function assertErrorWorkflowIsUsable({ * only one of the two fields is validated against the final state. */ function assertCallerPolicyConsistent(settings: IWorkflowSettings | undefined): void { - if (settings?.callerPolicy !== 'workflowsFromAList') return; + if (settings?.callerPolicy !== 'workflowsFromAList') { + return; + } const callerIds = (settings.callerIds ?? '') .split(',') @@ -576,7 +505,9 @@ function assertExecutionTimeoutWithinMax( maxTimeout: number, ): void { // `executionTimeout <= 0` is the "unlimited" sentinel (-1) and is never capped. - if (executionTimeout === undefined || executionTimeout <= 0 || maxTimeout <= 0) return; + if (executionTimeout === undefined || executionTimeout <= 0 || maxTimeout <= 0) { + return; + } if (executionTimeout > maxTimeout) { throw new Error( @@ -585,6 +516,567 @@ function assertExecutionTimeoutWithinMax( } } +/** + * The dependencies these guards need. Separated from the per-call data so a call site + * shows what is being validated, not the plumbing it takes to validate it. Note + * the leaf guards above still take primitives rather than `globalConfig` — this + * type is the boundary where that unpacking happens, not a licence to pass config + * around. + */ +type WorkflowSettingsGuardDependencies = { + user: User; + nodeTypes: NodeTypes; + globalConfig: GlobalConfig; + workflowFinderService: WorkflowFinderService; + workflowPublishedDataService: WorkflowPublishedDataService; + subworkflowPolicyChecker: SubworkflowPolicyChecker; +}; + +/** + * Runs the workflow-level settings validations that only apply when this batch + * actually touched the setting in question — so a partial edit isn't rejected + * for pre-existing state, and a value set in one operation can be satisfied by + * another operation of the same batch (or by what is already on the workflow). + * + * Takes the effective (post-apply) settings, and throws on the first violation. + * Check order is part of the contract: error workflow, then caller policy, then + * execution timeout. + */ +async function assertWorkflowSettingsValid( + { + strictOperations, + settings, + workflowId, + }: { + strictOperations: PartialUpdateOperation[]; + settings: IWorkflowSettings | undefined; + workflowId: string; + }, + { + user, + nodeTypes, + globalConfig, + workflowFinderService, + workflowPublishedDataService, + subworkflowPolicyChecker, + }: WorkflowSettingsGuardDependencies, +): Promise { + // Validate a freshly-set error workflow so the agent can self-correct in + // context: the target must exist, be accessible, and contain an Error + // Trigger node — otherwise it would silently never run on failure. + const setsErrorWorkflow = strictOperations.some( + (op) => op.type === 'setWorkflowSettings' && op.settings.errorWorkflow !== undefined, + ); + + if (setsErrorWorkflow) { + await assertErrorWorkflowIsUsable({ + errorWorkflowId: settings?.errorWorkflow, + parentWorkflowId: workflowId, + user, + workflowFinderService, + workflowPublishedDataService, + useWorkflowPublicationService: globalConfig.workflows.useWorkflowPublicationService, + nodeTypes, + subworkflowPolicyChecker, + errorTriggerType: globalConfig.nodes.errorTriggerType, + }); + } + + // Validate the effective (merged) caller policy, but only when this batch + // touched it — so a partial edit isn't rejected for pre-existing state, and + // `callerPolicy` set in one op can be satisfied by `callerIds` already on + // the workflow (or set in another op of the same batch). + const setsCallerConfig = strictOperations.some( + (op) => + op.type === 'setWorkflowSettings' && + (op.settings.callerPolicy !== undefined || op.settings.callerIds !== undefined), + ); + + if (setsCallerConfig) { + assertCallerPolicyConsistent(settings); + } + + const setsExecutionTimeout = strictOperations.some( + (op) => op.type === 'setWorkflowSettings' && op.settings.executionTimeout !== undefined, + ); + + if (setsExecutionTimeout) { + assertExecutionTimeoutWithinMax(settings?.executionTimeout, globalConfig.executions.maxTimeout); + } +} + +/** + * A settings change on a published workflow makes WorkflowService.update + * reactivate it *after* persisting (activateWorkflow → requires + * workflow:publish). Without this preflight, a user with edit-but-not-publish + * access would persist the settings and only then fail activation, breaking the + * update tool's atomicity and leaving the running version stale. + * + * The guards below must stay ahead of the `await`: a global publish scope + * already guarantees access, so instance owners/admins (the common MCP case) + * and every node-only edit skip the DB lookup entirely, and we only probe when + * the permission could come from a project/resource role. + */ +async function assertPublishAllowedForSettingsChange( + { + hasSettingsOperations, + activeVersionId, + workflowId, + }: { + hasSettingsOperations: boolean; + activeVersionId: string | null | undefined; + workflowId: string; + }, + { + user, + workflowFinderService, + }: Pick, +): Promise { + if (!hasSettingsOperations) { + return; + } + + if (!activeVersionId) { + return; + } + + if (hasGlobalScope(user, 'workflow:publish')) { + return; + } + + const canPublish = await workflowFinderService.findWorkflowHeadForUser(workflowId, user, [ + 'workflow:publish', + ]); + + if (!canPublish) { + throw new Error( + 'Changing settings on a published workflow reactivates it, which requires publish permission. Your account can edit but not publish this workflow. Ask the owner for publish access, or unpublish the workflow first.', + ); + } +} + +// LLM-supplied tag batches routinely repeat a name in different casings; collapse +// those (first-seen case wins) before hitting the tag API. Tag names are +// case-sensitively unique, so this is an MCP-only semantic — the service itself +// treats case-variant names as distinct tags. +function dedupeNamesPreservingCase(names: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + + for (const raw of names) { + const trimmed = raw.trim(); + + if (trimmed.length === 0) { + continue; + } + const key = trimmed.toLowerCase(); + + if (seen.has(key)) { + continue; + } + + seen.add(key); + result.push(trimmed); + } + + return result; +} + +// Renames are followed so the key matches the node's name in the post-apply +// workflow. +function collectTouchedNodes(operations: PartialUpdateOperation[]): Map { + const touched = new Map(); + const recordTouch = (name: string, opIndex: number) => { + if (!touched.has(name)) { + touched.set(name, opIndex); + } + }; + + for (let i = 0; i < operations.length; i++) { + const op = operations[i]; + + if (op.type === 'addNode') { + recordTouch(op.node.name, i); + } else if (op.type === 'updateNodeParameters' || op.type === 'setNodeParameter') { + recordTouch(op.nodeName, i); + } else if (op.type === 'renameNode') { + const idx = touched.get(op.oldName); + + if (idx !== undefined) { + touched.delete(op.oldName); + touched.set(op.newName, idx); + } + } else if (op.type === 'removeNode') { + touched.delete(op.nodeName); + } + } + + return touched; +} + +/** + * How many operations had no effect, so `appliedOperations` can discount them. + * + * An operation counts only if *all* the groups it produced were skipped or + * dropped: one `setNodeGroups` can define several groups and still apply the + * rest after losing one. + * + * Known imprecision: each group remembers only the last operation that touched + * it, so in an `addNodeGroup` + `updateNodeGroup` batch on the same dropped + * group only the second operation is discounted. + */ +const countOperationsWithNoEffect = ( + skippedOperations: Array>, + groupOperations: ApplyOperationsSuccess['groupOperations'], +): number => { + const producedPerOp = new Map(); + + for (const { opIndex } of Object.values(groupOperations)) { + producedPerOp.set(opIndex, (producedPerOp.get(opIndex) ?? 0) + 1); + } + + const skippedPerOp = new Map(); + + for (const { opIndex } of skippedOperations) { + skippedPerOp.set(opIndex, (skippedPerOp.get(opIndex) ?? 0) + 1); + } + + let count = 0; + + for (const [opIndex, skipped] of skippedPerOp) { + if (skipped >= (producedPerOp.get(opIndex) ?? 0)) { + count++; + } + } + + return count; +}; + +const isTagOperation = (op: PartialUpdateOperation) => + op.type === 'addTags' || op.type === 'removeTags'; + +const isSettingsOperation = (op: PartialUpdateOperation) => op.type === 'setWorkflowSettings'; + +/** + * Rejects operations this instance cannot serve, before anything is loaded or + * applied. Throw order is part of the contract: gated group ops first, then + * tag ops. + */ +function assertOperationsSupported( + strictOperations: PartialUpdateOperation[], + { canvasGroupsEnabled, tagsDisabled }: { canvasGroupsEnabled: boolean; tagsDisabled: boolean }, +): void { + // Defense in depth: with the flag off, the published schema already + // rejects these op types at the enum level; this guards against the + // loose and strict schemas drifting apart. Flag first so the scan only + // runs on instances where it can actually reject something. + if (!canvasGroupsEnabled && strictOperations.some((op) => GATED_GROUP_OP_TYPES.has(op.type))) { + throw new Error( + 'Node group operations (addNodeGroup, removeNodeGroup, updateNodeGroup) are not available on this instance.', + ); + } + + if (tagsDisabled && strictOperations.some(isTagOperation)) { + throw new Error('Tag operations are not supported on this instance because tags are disabled.'); + } +} + +/** + * Group rules depend on how the workflow looks after the whole batch, so they + * are checked once here rather than per operation. A broken group is dropped + * and reported; the update still goes through. + * + * NOT PURE: `dropInvalidNodeGroups` removes the offending groups from + * `result.workflow.nodeGroups` **in place**, and that mutation is what + * `buildWorkflowUpdateEntity` later persists. `result` must be passed by + * reference — cloning it makes the dropped groups silently come back. + */ +function resolveNodeGroupViolations( + result: ApplyOperationsSuccess, + canvasGroupsEnabled: boolean, + nodeTypes: NodeTypes, +): { + skippedOperations: SkippedOperation[]; + removedGroups: Array<{ groupName: string; reason: string }>; + nodeGroupsNeedPersisting: boolean; +} { + const skippedOperations: SkippedOperation[] = [...result.skippedOperations]; + // Groups the batch never asked for, removed because these operations made + // them invalid. Reported apart from skippedOperations: no submitted + // operation failed here, an existing group was destroyed as a side effect. + const removedGroups: Array<{ groupName: string; reason: string }> = []; + + // Two passes, ordered by blame: an overlap between a new and an existing group + // makes the validator flag both, so the batch's own groups go first and the + // innocent existing one survives the re-check. `groupOperations` records which + // groups this batch touched, not which one caused a given violation — two group + // ops that collide take each other down. + const getNodeType = makeGetNodeTypeForGrouping(nodeTypes); + const violations = canvasGroupsEnabled + ? [ + ...dropInvalidNodeGroups( + result.workflow, + getNodeType, + (violation) => result.groupOperations[violation.groupId] !== undefined, + ), + ...dropInvalidNodeGroups(result.workflow, getNodeType), + ] + : []; + + for (const violation of violations) { + const requestedBy = result.groupOperations[violation.groupId]; + + if (requestedBy) { + skippedOperations.push({ ...requestedBy, reason: violation.message }); + } else { + removedGroups.push({ + groupName: violation.groupName, + reason: violation.message, + }); + } + } + + return { + skippedOperations, + removedGroups, + // A violation found here always changes what must be persisted, even if + // no group op ran this batch — otherwise the omitted `nodeGroups` key + // falls back to preserve-on-omit and the still-invalid stored groups get + // re-validated (and rejected) by WorkflowService.update right after. + nodeGroupsNeedPersisting: violations.length > 0 || result.nodeGroupsChanged, + }; +} + +/** + * Assembles the entity handed to `WorkflowService.update`. Every conditional + * spread below is load-bearing: an omitted key means "leave what is stored + * alone", while writing `undefined` would create an own property and change + * what TypeORM sees. + */ +function buildWorkflowUpdateEntity({ + workflow, + existingMeta, + hasSettingsOperations, + hasNonTagOperations, + nodeGroupsNeedPersisting, +}: { + workflow: ApplyOperationsSuccess['workflow']; + existingMeta: WorkflowEntity['meta']; + hasSettingsOperations: boolean; + hasNonTagOperations: boolean; + nodeGroupsNeedPersisting: boolean; +}): WorkflowEntity { + const workflowUpdateData = new WorkflowEntity(); + Object.assign(workflowUpdateData, { + name: workflow.name, + ...(workflow.description !== undefined ? { description: workflow.description } : {}), + nodes: workflow.nodes, + connections: workflow.connections, + // Only attach settings when a settings op ran, so node-only edits + // don't re-save (and re-clean) the existing settings object. + ...(hasSettingsOperations ? { settings: workflow.settings } : {}), + // Only persist nodeGroups when they actually need to change (a group op + // ran, removing a node pruned a group, or the structural check above + // dropped a group some other op invalidated); otherwise omit the key so + // WorkflowService preserves the existing groups (preserve-on-omit). + ...(nodeGroupsNeedPersisting ? { nodeGroups: workflow.nodeGroups } : {}), + meta: hasNonTagOperations + ? { + ...(existingMeta ?? {}), + aiBuilderAssisted: true, + builderVariant: 'mcp', + } + : (existingMeta ?? {}), + }); + + return workflowUpdateData; +} + +/** + * Builds the telemetry payload. The caller keeps the returned object and mutates + * `.results` on it later — from the success path, from the catch, and from + * `buildInvalidAiToolSourceErrorResponse`, which tracks by reference — so it must + * stay a plain mutable object held outside the handler's `try`. + * + * Reads the raw `operations`, not the parsed ones: the payload is built before + * parsing so a parse failure is still reported. + */ +function buildUpdateTelemetryPayload({ + userId, + workflowId, + operations, + sanitizedSkillsUsed, + versionName, + versionDescription, +}: { + userId: string; + workflowId: string; + operations: OperationInput[]; + sanitizedSkillsUsed: string[] | undefined; + versionName?: string; + versionDescription?: string; +}): UserCalledMCPToolEventPayload { + return { + user_id: userId, + tool_name: MCP_UPDATE_WORKFLOW_TOOL.toolName, + parameters: { + workflowId, + // Spread, not `skillsUsed: sanitizedSkillsUsed` — the key must be absent + // when unset, not present-but-undefined. + ...(sanitizedSkillsUsed !== undefined ? { skillsUsed: sanitizedSkillsUsed } : {}), + opCount: operations.length, + opTypes: operations.map((op) => op.type), + hasVersionName: !!versionName, + hasVersionDescription: !!versionDescription, + }, + }; +} + +/** + * Auto-assigns credentials to the nodes this batch added. + * + * `autoPopulateNodeCredentials` mutates the node objects in place, and that is how + * the assigned credentials reach the saved entity — so the nodes passed in must be + * the very same references held by `workflowUpdateData.nodes`. Copying them makes + * auto-assignment silently never persist while still reporting assignments. + */ +async function autoAssignCredentialsForAddedNodes( + { + workflowUpdateData, + addedNodeNames, + projectId, + }: { + workflowUpdateData: WorkflowEntity; + addedNodeNames: string[]; + projectId: string; + }, + { + user, + nodeTypes, + credentialsService, + aiGatewayService, + }: { + user: User; + nodeTypes: NodeTypes; + credentialsService: CredentialsService; + aiGatewayService: AiGatewayService; + }, +): Promise { + if (addedNodeNames.length === 0) { + return { assignments: [], skippedHttpNodes: [], outcomes: [] }; + } + + const addedNodeSet = new Set(addedNodeNames); + const addedNodes = workflowUpdateData.nodes.filter((n) => addedNodeSet.has(n.name)); + + return await autoPopulateNodeCredentials( + { ...workflowUpdateData, nodes: addedNodes }, + user, + nodeTypes, + credentialsService, + projectId, + aiGatewayService, + ); +} + +/** + * Validates the resulting workflow and marks the warnings that were already + * there before this update. + * + * Validation covers the whole workflow, so warnings on nodes this batch never + * touched (e.g. discriminators the editor strips when they equal node defaults) + * would otherwise read as caused by these operations. Diffing against the + * pre-update state lets the agent self-correct only what its edit broke. + * `getWarningKey` matches by location, not message, so reworded messages still + * match; a renamed node intentionally misses — the rename touched it, so its + * warnings count as new. + * + * Note the deliberate asymmetry: only the pre-update pass is wrapped in a + * try/catch. + */ +async function collectValidationWarnings( + updated: Pick, + existing: Pick, + nodeTypes: NodeTypes, +): Promise> { + const { ParseValidateHandler, getWarningKey } = await import('@n8n/ai-workflow-builder'); + + const validator = new ParseValidateHandler({ + generatePinData: false, + nodeTypesProvider: nodeTypes, + }); + const validate = (workflow: Pick) => + validator.validateJSON({ + name: workflow.name, + nodes: workflow.nodes, + connections: workflow.connections, + } as unknown as WorkflowJSON); + + const postUpdateWarnings = validate(updated); + + if (postUpdateWarnings.length === 0) { + return postUpdateWarnings; + } + + let preUpdateWarnings: ValidationWarning[] = []; + + try { + preUpdateWarnings = validate(existing); + } catch { + /* + * deliberate: A pre-update state too broken to validate (which this batch may be + * fixing) must not fail the update, while a failure validating the *result* has + * to surface to the caller. + */ + } + + const preUpdateKeys = new Set(preUpdateWarnings.map(getWarningKey)); + + return postUpdateWarnings.map((warning) => + preUpdateKeys.has(getWarningKey(warning)) + ? { ...warning, message: `[pre-existing] ${warning.message}`, preExisting: true } + : warning, + ); +} + +/** + * Resolves tag names to ids, creating missing tags when the user may. + * + * Returns `undefined` when the batch had no tag operation, which the caller turns + * into an omitted `tagIds` key meaning "leave the workflow's tags alone". It must + * never return `[]` for that case: an empty array is a valid instruction to clear + * every tag on the workflow. + */ +async function resolveTagIds( + tagNames: string[] | undefined, + user: User, + tagService: TagService, +): Promise { + if (tagNames === undefined) { + return undefined; + } + + const uniqueTagNames = dedupeNamesPreservingCase(tagNames); + + if (hasGlobalScope(user, 'tag:create')) { + const resolvedTags = await tagService.findOrCreateByNames(uniqueTagNames); + + return resolvedTags.map((t) => t.id); + } + + const resolvedTags = await tagService.getByNames(uniqueTagNames); + const resolvedNames = new Set(resolvedTags.map((t) => t.name)); + const missing = uniqueTagNames.filter((name) => !resolvedNames.has(name)); + + if (missing.length > 0) { + throw new Error( + `Cannot apply the following tags because they don't exist and your account does not have permission to create them: ${missing.join(', ')}`, + ); + } + + return resolvedTags.map((t) => t.id); +} + /** * MCP tool that updates a workflow by applying a small list of named operations * (addNode, removeNode, updateNodeParameters, addConnection, …) directly to the @@ -620,454 +1112,270 @@ export const createUpdateWorkflowTool = ( */ canvasGroupsEnabled?: boolean; } = {}, -): ToolDefinition> => ({ - name: MCP_UPDATE_WORKFLOW_TOOL.toolName, - config: { - description: buildToolDescription(options.canvasGroupsEnabled === true), - inputSchema: buildInputSchema(options.canvasGroupsEnabled === true), - outputSchema, - annotations: { - title: MCP_UPDATE_WORKFLOW_TOOL.displayTitle, - readOnlyHint: false, - destructiveHint: true, - idempotentHint: false, - openWorldHint: false, - }, - }, - handler: async ({ - workflowId, - skillsUsed, - operations, - versionName, - versionDescription, - }: { - workflowId: string; - skillsUsed?: string[]; - operations: OperationInput[]; - versionName?: string; - versionDescription?: string; - }) => { - const sanitizedSkillsUsed = sanitizeSkillsUsed(skillsUsed); - const telemetryPayload: UserCalledMCPToolEventPayload = { - user_id: user.id, - tool_name: MCP_UPDATE_WORKFLOW_TOOL.toolName, - parameters: { - workflowId, - ...(sanitizedSkillsUsed !== undefined ? { skillsUsed: sanitizedSkillsUsed } : {}), - opCount: operations.length, - opTypes: operations.map((op) => op.type), - hasVersionName: !!versionName, - hasVersionDescription: !!versionDescription, +): ToolDefinition> => { + const canvasGroupsEnabled = options.canvasGroupsEnabled === true; + + // Bound once: these never vary per call, so the call sites below show only + // what is being validated. + const settingsGuardDependencies: WorkflowSettingsGuardDependencies = { + user, + nodeTypes, + globalConfig, + workflowFinderService, + workflowPublishedDataService, + subworkflowPolicyChecker, + }; + + return { + name: MCP_UPDATE_WORKFLOW_TOOL.toolName, + config: { + description: buildToolDescription(canvasGroupsEnabled), + inputSchema: buildInputSchema(canvasGroupsEnabled), + outputSchema, + annotations: { + title: MCP_UPDATE_WORKFLOW_TOOL.displayTitle, + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, }, - }; - - try { - const strictOperations = parseStrictOperations(operations); - - // Defense in depth: with the flag off, the published schema already - // rejects these op types at the enum level; this guards against the - // loose and strict schemas drifting apart. - const hasGatedGroupOperations = strictOperations.some((op) => - GATED_GROUP_OP_TYPES.has(op.type), - ); - if (hasGatedGroupOperations && options.canvasGroupsEnabled !== true) { - throw new Error( - 'Node group operations (addNodeGroup, removeNodeGroup, updateNodeGroup) are not available on this instance.', - ); - } - - const hasTagOperations = strictOperations.some( - (op) => op.type === 'addTags' || op.type === 'removeTags', - ); - - if (hasTagOperations && globalConfig.tags.disabled) { - throw new Error( - 'Tag operations are not supported on this instance because tags are disabled.', - ); - } - - const existingWorkflow = await getMcpWorkflow( + }, + handler: async ({ + workflowId, + skillsUsed, + operations, + versionName, + versionDescription, + }: { + workflowId: string; + skillsUsed?: string[]; + operations: OperationInput[]; + versionName?: string; + versionDescription?: string; + }) => { + // Held outside the `try` so the catch below can report on it. + const telemetryPayload = buildUpdateTelemetryPayload({ + userId: user.id, workflowId, - user, - ['workflow:update'], - workflowFinderService, - { includeTags: hasTagOperations }, - ); - - await collaborationService.ensureWorkflowEditable(existingWorkflow.id); - - const result = applyOperations( - toWorkflowSlice(existingWorkflow, { includeTags: hasTagOperations }), - strictOperations, - { canvasGroupsEnabled: options.canvasGroupsEnabled }, - ); - - if (!result.success) { - throw new Error(result.error); - } - - // Group rules depend on how the workflow looks after the whole batch, - // so we check them here once rather than per operation. A broken group - // is dropped and reported; the update still goes through. - const skippedOperations: SkippedOperation[] = [...result.skippedOperations]; - // Groups the batch never asked for, removed because these operations made - // them invalid. Reported apart from skippedOperations: no submitted - // operation failed here, an existing group was destroyed as a side effect. - const removedGroups: Array<{ groupName: string; reason: string }> = []; - let nodeGroupsNeedPersisting = result.nodeGroupsChanged; - - if (options.canvasGroupsEnabled) { - const getNodeType = makeGetNodeTypeForGrouping(nodeTypes); - - // Two passes, ordered by blame: an overlap between a new and an existing - // group makes the validator flag both, so the batch's own groups go first - // and the innocent existing one survives the re-check. `groupOperations` - // records which groups this batch touched, not which one caused a given - // violation — two group ops that collide take each other down. - const ownGroups = dropInvalidNodeGroups( - result.workflow, - getNodeType, - (violation) => result.groupOperations[violation.groupId] !== undefined, - ); - const violations = [...ownGroups, ...dropInvalidNodeGroups(result.workflow, getNodeType)]; - - if (violations.length > 0) { - // A violation found here always changes what must be persisted, even if - // no group op ran this batch — otherwise the omitted `nodeGroups` key - // falls back to preserve-on-omit and the still-invalid stored groups get - // re-validated (and rejected) by WorkflowService.update right after. - nodeGroupsNeedPersisting = true; - - for (const violation of violations) { - const requestedBy = result.groupOperations[violation.groupId]; - if (requestedBy) { - skippedOperations.push({ ...requestedBy, reason: violation.message }); - } else { - removedGroups.push({ - groupName: violation.groupName, - reason: violation.message, - }); - } - } - } - } - - const credentialCheck = await validateCredentialReferences( - strictOperations, - existingWorkflow, - user, - credentialsService, - nodeTypes, - { workflowId: existingWorkflow.id }, - ); - - if (!credentialCheck.ok) { - throw new Error(credentialCheck.error); - } - - const invalidToolSourceResponse = buildInvalidAiToolSourceErrorResponse( - { nodes: result.workflow.nodes, connections: result.workflow.connections }, - nodeTypes, - (errorMessage) => ({ error: errorMessage }), - telemetryPayload, - telemetry, - ); - - if (invalidToolSourceResponse) { - return invalidToolSourceResponse; - } - - const { projectId: workflowProjectId } = await sharedWorkflowRepository.findOneOrFail({ - where: { workflowId, role: 'workflow:owner' }, - select: ['projectId'], + operations, + sanitizedSkillsUsed: sanitizeSkillsUsed(skillsUsed), + versionName, + versionDescription, }); - const dataTableCheck = await validateDataTableReferencesForUpdate( - result.workflow.nodes, - collectTouchedNodes(strictOperations), - workflowProjectId, - dataTableOps, - ); + try { + const strictOperations = parseStrictOperations(operations); + const hasTagOperations = strictOperations.some(isTagOperation); + const hasNonTagOperations = strictOperations.some((op) => !isTagOperation(op)); + const hasSettingsOperations = strictOperations.some(isSettingsOperation); - if (!dataTableCheck.ok) { - throw new Error(dataTableCheck.error); - } - - // Validate a freshly-set error workflow so the agent can self-correct in - // context: the target must exist, be accessible, and contain an Error - // Trigger node — otherwise it would silently never run on failure. - const setsErrorWorkflow = strictOperations.some( - (op) => op.type === 'setWorkflowSettings' && op.settings.errorWorkflow !== undefined, - ); - - if (setsErrorWorkflow) { - await assertErrorWorkflowIsUsable({ - errorWorkflowId: result.workflow.settings?.errorWorkflow, - parentWorkflowId: workflowId, - user, - workflowFinderService, - workflowPublishedDataService, - useWorkflowPublicationService: globalConfig.workflows.useWorkflowPublicationService, - nodeTypes, - subworkflowPolicyChecker, - errorTriggerType: globalConfig.nodes.errorTriggerType, + assertOperationsSupported(strictOperations, { + canvasGroupsEnabled, + tagsDisabled: globalConfig.tags.disabled, }); - } - // Validate the effective (merged) caller policy, but only when this batch - // touched it — so a partial edit isn't rejected for pre-existing state, and - // `callerPolicy` set in one op can be satisfied by `callerIds` already on - // the workflow (or set in another op of the same batch). - const setsCallerConfig = strictOperations.some( - (op) => - op.type === 'setWorkflowSettings' && - (op.settings.callerPolicy !== undefined || op.settings.callerIds !== undefined), - ); - - if (setsCallerConfig) { - assertCallerPolicyConsistent(result.workflow.settings); - } - - const setsExecutionTimeout = strictOperations.some( - (op) => op.type === 'setWorkflowSettings' && op.settings.executionTimeout !== undefined, - ); - - if (setsExecutionTimeout) { - assertExecutionTimeoutWithinMax( - result.workflow.settings?.executionTimeout, - globalConfig.executions.maxTimeout, + const existingWorkflow = await getMcpWorkflow( + workflowId, + user, + ['workflow:update'], + workflowFinderService, + { includeTags: hasTagOperations }, ); - } - const hasNonTagOperations = strictOperations.some( - (op) => op.type !== 'addTags' && op.type !== 'removeTags', - ); + await collaborationService.ensureWorkflowEditable(existingWorkflow.id); - const hasSettingsOperations = strictOperations.some( - (op) => op.type === 'setWorkflowSettings', - ); + const result = applyOperations( + toWorkflowSlice(existingWorkflow, { includeTags: hasTagOperations }), + strictOperations, + { canvasGroupsEnabled }, + ); - // A settings change on a published workflow makes WorkflowService.update - // reactivate it *after* persisting (activateWorkflow → requires - // workflow:publish). Without this preflight, a user with edit-but-not- - // publish access would persist the settings and only then fail activation, - // breaking this tool's atomicity and leaving the running version stale. - // A global publish scope already guarantees access, so skip the DB lookup - // for instance owners/admins (the common MCP case) and only probe when the - // permission could come from a project/resource role. - if ( - hasSettingsOperations && - existingWorkflow.activeVersionId && - !hasGlobalScope(user, 'workflow:publish') - ) { - const canPublish = await workflowFinderService.findWorkflowHeadForUser(workflowId, user, [ - 'workflow:publish', - ]); + if (!result.success) { + throw new Error(result.error); + } - if (!canPublish) { - throw new Error( - 'Changing settings on a published workflow reactivates it, which requires publish permission. Your account can edit but not publish this workflow. Ask the owner for publish access, or unpublish the workflow first.', + const { skippedOperations, removedGroups, nodeGroupsNeedPersisting } = + resolveNodeGroupViolations(result, canvasGroupsEnabled, nodeTypes); + + const credentialCheck = await validateCredentialReferences( + strictOperations, + existingWorkflow, + user, + credentialsService, + nodeTypes, + { workflowId: existingWorkflow.id }, + ); + + if (!credentialCheck.ok) { + throw new Error(credentialCheck.error); + } + + const invalidToolSourceResponse = buildInvalidAiToolSourceErrorResponse( + { nodes: result.workflow.nodes, connections: result.workflow.connections }, + nodeTypes, + (errorMessage) => ({ error: errorMessage }), + telemetryPayload, + telemetry, + ); + + if (invalidToolSourceResponse) { + return invalidToolSourceResponse; + } + + const { projectId: workflowProjectId } = await sharedWorkflowRepository.findOneOrFail({ + where: { workflowId, role: 'workflow:owner' }, + select: ['projectId'], + }); + + const dataTableCheck = await validateDataTableReferencesForUpdate( + result.workflow.nodes, + collectTouchedNodes(strictOperations), + workflowProjectId, + dataTableOps, + ); + + if (!dataTableCheck.ok) { + throw new Error(dataTableCheck.error); + } + + await assertWorkflowSettingsValid( + { strictOperations, settings: result.workflow.settings, workflowId }, + settingsGuardDependencies, + ); + + await assertPublishAllowedForSettingsChange( + { + hasSettingsOperations, + activeVersionId: existingWorkflow.activeVersionId, + workflowId, + }, + settingsGuardDependencies, + ); + + const workflowUpdateData = buildWorkflowUpdateEntity({ + workflow: result.workflow, + existingMeta: existingWorkflow.meta, + hasSettingsOperations, + hasNonTagOperations, + nodeGroupsNeedPersisting, + }); + + resolveNodeWebhookIds(workflowUpdateData, nodeTypes); + + const { + assignments: credentialAssignments, + skippedHttpNodes, + outcomes: autoAssignOutcomes, + } = await autoAssignCredentialsForAddedNodes( + { + workflowUpdateData, + addedNodeNames: result.addedNodeNames, + projectId: workflowProjectId, + }, + { user, nodeTypes, credentialsService, aiGatewayService }, + ); + + // After auto-assign, so the nodes being validated carry their credentials. + const validationWarnings = await collectValidationWarnings( + workflowUpdateData, + existingWorkflow, + nodeTypes, + ); + + const tagIds = await resolveTagIds(result.tagNames, user, tagService); + + // Fallback is diff-based; it only ends up persisted when the update + // actually produces a new history version (node/connection/group changes). + const versionMetadata = resolveVersionMetadata( + { versionName, versionDescription }, + buildUpdateVersionMetadata( + { nodes: existingWorkflow.nodes, connections: existingWorkflow.connections }, + { nodes: workflowUpdateData.nodes, connections: workflowUpdateData.connections }, + ), + ); + + const updatedWorkflow = await workflowService.update(user, workflowUpdateData, workflowId, { + aiBuilderAssisted: hasNonTagOperations, + source: 'n8n-mcp', + versionName: versionMetadata.name, + versionDescription: versionMetadata.description, + ...(tagIds !== undefined ? { tagIds } : {}), + }); + + if (autoAssignOutcomes.length > 0) { + const nodeTypesByName = new Map(updatedWorkflow.nodes.map((n) => [n.name, n.type])); + trackAutoassignOutcomes( + telemetry, + user.id, + 'update_workflow', + autoAssignOutcomes, + nodeTypesByName, + workflowId, ); } - } - const workflowUpdateData = new WorkflowEntity(); - Object.assign(workflowUpdateData, { - name: result.workflow.name, - ...(result.workflow.description !== undefined - ? { description: result.workflow.description } - : {}), - nodes: result.workflow.nodes, - connections: result.workflow.connections, - // Only attach settings when a settings op ran, so node-only edits - // don't re-save (and re-clean) the existing settings object. - ...(hasSettingsOperations ? { settings: result.workflow.settings } : {}), - // Only persist nodeGroups when they actually need to change (a group op - // ran, removing a node pruned a group, or the structural check above - // dropped a group some other op invalidated); otherwise omit the key so - // WorkflowService preserves the existing groups (preserve-on-omit). - ...(nodeGroupsNeedPersisting ? { nodeGroups: result.workflow.nodeGroups } : {}), - meta: hasNonTagOperations - ? { - ...(existingWorkflow.meta ?? {}), - aiBuilderAssisted: true, - builderVariant: 'mcp', - } - : (existingWorkflow.meta ?? {}), - }); + void collaborationService.broadcastWorkflowUpdate(workflowId, user.id).catch(() => {}); - resolveNodeWebhookIds(workflowUpdateData, nodeTypes); + const baseUrl = urlService.getInstanceBaseUrl(); + const workflowUrl = `${baseUrl}/workflow/${updatedWorkflow.id}`; - let credentialAssignments: Array<{ - nodeName: string; - credentialName: string; - credentialType: string; - source?: 'user' | 'aiGateway'; - }> = []; - let skippedHttpNodes: string[] = []; - let autoAssignOutcomes: SlotOutcome[] = []; + telemetryPayload.results = { + success: true, + data: { + workflowId: updatedWorkflow.id, + nodeCount: updatedWorkflow.nodes.length, + }, + }; - if (result.addedNodeNames.length > 0) { - const addedNodeSet = new Set(result.addedNodeNames); - const addedNodes = workflowUpdateData.nodes.filter((n) => addedNodeSet.has(n.name)); + telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload); - const autoAssign = await autoPopulateNodeCredentials( - { ...workflowUpdateData, nodes: addedNodes }, - user, - nodeTypes, - credentialsService, - workflowProjectId, - aiGatewayService, + const notAppliedCount = countOperationsWithNoEffect( + skippedOperations, + result.groupOperations, ); - credentialAssignments = autoAssign.assignments; - skippedHttpNodes = autoAssign.skippedHttpNodes; - autoAssignOutcomes = autoAssign.outcomes; - } - const { ParseValidateHandler, getWarningKey } = await import('@n8n/ai-workflow-builder'); - const validator = new ParseValidateHandler({ - generatePinData: false, - nodeTypesProvider: nodeTypes, - }); - const postUpdateWarnings = validator.validateJSON({ - name: workflowUpdateData.name, - nodes: workflowUpdateData.nodes, - connections: workflowUpdateData.connections, - } as unknown as WorkflowJSON); - - // Validation covers the whole resulting workflow, so warnings on nodes - // this batch never touched (e.g. discriminators the editor strips when - // they equal node defaults) would otherwise read as caused by these - // operations. Diff against the pre-update state and annotate the - // carried-over ones so the agent only self-corrects what its edit broke. - // getWarningKey matches by location, not message, so reworded messages - // still match; a renamed node intentionally misses — the rename touched - // it, so its warnings count as new. - let validationWarnings: Array = - postUpdateWarnings; - if (postUpdateWarnings.length > 0) { - // A pre-update state broken enough to not even validate (which this - // batch may be fixing) must not fail the update — skip the annotation. - let preUpdateWarnings: ValidationWarning[] = []; - try { - preUpdateWarnings = validator.validateJSON({ - name: existingWorkflow.name, - nodes: existingWorkflow.nodes, - connections: existingWorkflow.connections, - } as unknown as WorkflowJSON); - } catch {} - const preUpdateKeys = new Set(preUpdateWarnings.map(getWarningKey)); - validationWarnings = postUpdateWarnings.map((warning) => - preUpdateKeys.has(getWarningKey(warning)) - ? { ...warning, message: `[pre-existing] ${warning.message}`, preExisting: true } - : warning, - ); - } - - let tagIds: string[] | undefined; - if (result.tagNames !== undefined) { - const uniqueTagNames = dedupeNamesPreservingCase(result.tagNames); - if (hasGlobalScope(user, 'tag:create')) { - const resolvedTags = await tagService.findOrCreateByNames(uniqueTagNames); - tagIds = resolvedTags.map((t) => t.id); - } else { - const resolvedTags = await tagService.getByNames(uniqueTagNames); - const resolvedNames = new Set(resolvedTags.map((t) => t.name)); - const missing = uniqueTagNames.filter((name) => !resolvedNames.has(name)); - if (missing.length > 0) { - throw new Error( - `Cannot apply the following tags because they don't exist and your account does not have permission to create them: ${missing.join(', ')}`, - ); - } - tagIds = resolvedTags.map((t) => t.id); - } - } - - // Fallback is diff-based; it only ends up persisted when the update - // actually produces a new history version (node/connection/group changes). - const versionMetadata = resolveVersionMetadata( - { versionName, versionDescription }, - buildUpdateVersionMetadata( - { nodes: existingWorkflow.nodes, connections: existingWorkflow.connections }, - { nodes: workflowUpdateData.nodes, connections: workflowUpdateData.connections }, - ), - ); - - const updatedWorkflow = await workflowService.update(user, workflowUpdateData, workflowId, { - aiBuilderAssisted: hasNonTagOperations, - source: 'n8n-mcp', - versionName: versionMetadata.name, - versionDescription: versionMetadata.description, - ...(tagIds !== undefined ? { tagIds } : {}), - }); - - if (autoAssignOutcomes.length > 0) { - const nodeTypesByName = new Map(updatedWorkflow.nodes.map((n) => [n.name, n.type])); - trackAutoassignOutcomes( - telemetry, - user.id, - 'update_workflow', - autoAssignOutcomes, - nodeTypesByName, - workflowId, - ); - } - - void collaborationService.broadcastWorkflowUpdate(workflowId, user.id).catch(() => {}); - - const baseUrl = urlService.getInstanceBaseUrl(); - const workflowUrl = `${baseUrl}/workflow/${updatedWorkflow.id}`; - - telemetryPayload.results = { - success: true, - data: { + const output: UpdateWorkflowOutput = { workflowId: updatedWorkflow.id, + name: updatedWorkflow.name, nodeCount: updatedWorkflow.nodes.length, - }, - }; - telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload); + url: workflowUrl, + appliedOperations: strictOperations.length - notAppliedCount, + autoAssignedCredentials: credentialAssignments, + validationWarnings, + note: skippedHttpNodes.length + ? `HTTP Request nodes (${skippedHttpNodes.join(', ')}) were skipped during credential auto-assignment. Their credentials must be configured manually.` + : undefined, + skippedOperations: skippedOperations.length > 0 ? skippedOperations : undefined, + removedGroups: removedGroups.length > 0 ? removedGroups : undefined, + // `IWorkflowSettings` is a closed interface while the published schema is an + // open record — server-side cleanup decides which keys survive, so the tool + // reports whatever came back rather than a fixed set. + settings: hasSettingsOperations + ? ((updatedWorkflow.settings ?? {}) as Record) + : undefined, + }; - const notAppliedCount = countOperationsWithNoEffect( - skippedOperations, - result.groupOperations, - ); + return { + content: [{ type: 'text', text: JSON.stringify(output, null, 2) }], + structuredContent: output, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); - const output = { - workflowId: updatedWorkflow.id, - name: updatedWorkflow.name, - nodeCount: updatedWorkflow.nodes.length, - url: workflowUrl, - appliedOperations: strictOperations.length - notAppliedCount, - autoAssignedCredentials: credentialAssignments, - validationWarnings, - note: skippedHttpNodes.length - ? `HTTP Request nodes (${skippedHttpNodes.join(', ')}) were skipped during credential auto-assignment. Their credentials must be configured manually.` - : undefined, - skippedOperations: skippedOperations.length > 0 ? skippedOperations : undefined, - removedGroups: removedGroups.length > 0 ? removedGroups : undefined, - settings: hasSettingsOperations ? (updatedWorkflow.settings ?? {}) : undefined, - }; + telemetryPayload.results = { + success: false, + error: errorMessage, + }; + telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload); - return { - content: [{ type: 'text', text: JSON.stringify(output, null, 2) }], - structuredContent: output, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); + const output = { error: errorMessage }; - telemetryPayload.results = { - success: false, - error: errorMessage, - }; - telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload); - - const output = { error: errorMessage }; - - return { - content: [{ type: 'text', text: JSON.stringify(output, null, 2) }], - structuredContent: output, - isError: true, - }; - } - }, -}); + return { + content: [{ type: 'text', text: JSON.stringify(output, null, 2) }], + structuredContent: output, + isError: true, + }; + } + }, + }; +}; From a94ffe922edbc5c69dd9101fd5877ade2d41c454 Mon Sep 17 00:00:00 2001 From: Michael Drury Date: Thu, 13 Aug 2026 12:41:39 +0000 Subject: [PATCH 0003/1458] fix(editor): Display tool execution data in Agent sessions (#36085) --- .../frontend/@n8n/i18n/src/locales/en.json | 5 +- packages/frontend/editor-ui/eslint.config.mjs | 17 + .../__tests__/AgentSessionsListView.spec.ts | 46 ++- .../agents/__tests__/ToolIoView.spec.ts | 153 +++++---- .../WorkflowExecutionLogViewer.spec.ts | 236 +++++++++++-- .../features/agents/components/ToolIoView.vue | 165 ++------- .../components/WorkflowExecutionLogViewer.vue | 169 +++------ .../agents/views/AgentSessionsListView.vue | 75 ++-- .../runData/components/StandaloneRunData.vue | 53 +++ .../components/StandaloneRunDataHost.test.ts | 322 ++++++++++++++++++ .../components/StandaloneRunDataHost.vue | 184 ++++++++++ .../features/ndv/runData/standaloneRunData.ts | 3 + .../playwright/pages/AgentSessionsPage.ts | 189 ++++++++++ packages/testing/playwright/pages/n8nPage.ts | 3 + .../tests/e2e/agents/agent-sessions.spec.ts | 164 +++++++++ 15 files changed, 1372 insertions(+), 412 deletions(-) create mode 100644 packages/frontend/editor-ui/src/features/ndv/runData/components/StandaloneRunData.vue create mode 100644 packages/frontend/editor-ui/src/features/ndv/runData/components/StandaloneRunDataHost.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ndv/runData/components/StandaloneRunDataHost.vue create mode 100644 packages/frontend/editor-ui/src/features/ndv/runData/standaloneRunData.ts create mode 100644 packages/testing/playwright/pages/AgentSessionsPage.ts create mode 100644 packages/testing/playwright/tests/e2e/agents/agent-sessions.spec.ts diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index ad9a6e1de7f..410d972fd67 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -1437,10 +1437,12 @@ "agentSessions.tokenUsage": "Token Usage", "agentSessions.testChat": "Test chat", "agentSessions.origin": "Origin", - "agentSessions.origin.agent": "Agent", + "agentSessions.origin.preview": "Preview", "agentSessions.origin.instanceAi": "AI Assistant", "agentSessions.origin.mcp": "MCP", "agentSessions.origin.subAgent": "Sub-agent", + "agentSessions.origin.schedule": "Schedule", + "agentSessions.origin.workflow": "Workflow", "agentSessions.goToParentRun": "Go to parent run", "agentSessions.viewTrace": "View session trace", "agentSessions.actions": "Actions", @@ -1455,7 +1457,6 @@ "agentSessions.status": "Status", "agentSessions.duration": "Duration", "agentSessions.sessionId": "Session ID", - "agentSessions.origin.task": "Task", "agentSessions.success": "Success", "agentSessions.detail.selectMessage": "Click an assistant message to view details", "agentSessions.detail.tokenUsage": "Token Usage", diff --git a/packages/frontend/editor-ui/eslint.config.mjs b/packages/frontend/editor-ui/eslint.config.mjs index 29c602a14e3..5d90c62a61e 100644 --- a/packages/frontend/editor-ui/eslint.config.mjs +++ b/packages/frontend/editor-ui/eslint.config.mjs @@ -249,6 +249,23 @@ export default defineConfig( 'no-restricted-syntax': 'off', }, }, + { + files: ['src/features/agents/**/*.ts', 'src/features/agents/**/*.vue'], + rules: { + '@typescript-eslint/no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['**/ndv/runData/components/RunData.vue'], + message: + 'Use StandaloneRunData inside StandaloneRunDataHost so scoped providers and cleanup are owned consistently.', + }, + ], + }, + ], + }, + }, { files: [ 'src/**/*.test.ts', diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionsListView.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionsListView.spec.ts index 005730da2d1..f068730261f 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionsListView.spec.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionsListView.spec.ts @@ -40,11 +40,12 @@ vi.mock('@n8n/i18n', () => ({ baseText: (key: string) => ({ 'agentSessions.viewTrace': 'View session trace', - 'agentSessions.origin.agent': 'Agent', + 'agentSessions.origin.preview': 'Preview', 'agentSessions.origin.instanceAi': 'AI Assistant', 'agentSessions.origin.mcp': 'MCP', 'agentSessions.origin.subAgent': 'Sub-agent', - 'agentSessions.origin.task': 'Task', + 'agentSessions.origin.schedule': 'Schedule', + 'agentSessions.origin.workflow': 'Workflow', 'agentSessions.empty': 'No agent sessions', })[key] ?? key, }), @@ -63,7 +64,7 @@ vi.mock('@n8n/design-system', () => ({ emits: ['select'], }, N8nButton: { template: '' }, - N8nIcon: { template: '', props: ['icon', 'size'] }, + N8nIcon: { template: '', props: ['icon', 'size'] }, N8nIconButton: { template: '', }, @@ -363,19 +364,30 @@ describe('AgentSessionsListView', () => { }); it.each([ - [{ source: 'slack' }, 'Slack'], - [{ source: 'telegram' }, 'Telegram'], - [{ source: 'instance-ai' }, 'AI Assistant'], - [{ source: 'mcp' }, 'MCP'], - [{ source: null }, 'Agent'], - [{ source: 'chat' }, 'Agent'], - [{ parentThreadId: 'parent-1', source: 'slack' }, 'Sub-agent'], - [{ taskId: 'task-1', source: 'slack' }, 'Task'], - ] as const)('renders origin chip label for %j as %s', async (overrides, expectedLabel) => { - const wrapper = await mountView({ threads: [makeThread(overrides)] }); + [{ source: 'slack' }, 'Slack', 'slack'], + [{ source: 'telegram' }, 'Telegram', 'telegram'], + [{ source: 'linear' }, 'Linear', 'linear'], + [{ source: 'discord' }, 'Discord', 'discord'], + [{ source: 'instance-ai' }, 'AI Assistant', 'flask-conical'], + [{ source: 'mcp' }, 'MCP', 'flask-conical'], + [{ source: null }, 'Preview', 'flask-conical'], + [{ source: 'chat' }, 'Preview', 'flask-conical'], + [{ source: 'n8n_chat' }, 'Preview', 'flask-conical'], + [{ source: 'workflow' }, 'Workflow', 'workflow'], + [{ source: 'subagent' }, 'Sub-agent', 'bot'], + [{ parentThreadId: 'parent-1', source: 'slack' }, 'Sub-agent', 'bot'], + [{ source: 'task' }, 'Schedule', 'clock'], + [{ taskId: 'task-1', source: 'slack' }, 'Schedule', 'clock'], + [{ source: 'teams' }, 'Teams', 'plug'], + [{ source: ' Slack ' }, 'Slack', 'slack'], + ] as const)( + 'renders origin chip for %j as %s with the %s icon', + async (overrides, expectedLabel, expectedIcon) => { + const wrapper = await mountView({ threads: [makeThread(overrides)] }); + const originPill = wrapper.get('[data-test-id="agent-session-origin-pill"]'); - expect(wrapper.find('[data-test-id="agent-session-origin-pill"]').text()).toContain( - expectedLabel, - ); - }); + expect(originPill.text()).toContain(expectedLabel); + expect(originPill.get('[data-icon]').attributes('data-icon')).toBe(expectedIcon); + }, + ); }); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/ToolIoView.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/ToolIoView.spec.ts index 0c7ead00279..de76850cc33 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/ToolIoView.spec.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/ToolIoView.spec.ts @@ -1,39 +1,47 @@ /* eslint-disable import-x/no-extraneous-dependencies -- test-only patterns */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { setActivePinia, createPinia, getActivePinia } from 'pinia'; +import { setActivePinia, createPinia } from 'pinia'; import { shallowRef } from 'vue'; import { flushPromises } from '@vue/test-utils'; import ToolIoView from '../components/ToolIoView.vue'; import { createComponentRenderer } from '@/__tests__/render'; import { WorkflowDocumentStoreKey } from '@/app/constants/injectionKeys'; import { useWorkflowsStore } from '@/app/stores/workflows.store'; -import { - createWorkflowDocumentId, - getWorkflowDocumentStoreId, -} from '@/app/stores/workflowDocument.store'; -import { - getWorkflowExecutionStateStoreId, - useWorkflowExecutionStateStore, -} from '@/app/stores/workflowExecutionState.store'; -import { createExecutionDataId, getExecutionDataStoreId } from '@/app/stores/executionData.store'; -import { getNDVStoreId } from '@/features/ndv/shared/ndv.store'; +import { createWorkflowDocumentId } from '@/app/stores/workflowDocument.store'; +import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store'; // Keep RunData lightweight while exercising the same strict NDV injection that failed in sessions. vi.mock('@/features/ndv/runData/components/RunData.vue', async () => { - const [{ computed, defineComponent }, { injectNDVStore }] = await Promise.all([ - import('vue'), - import('@/features/ndv/shared/ndv.store'), - ]); + const [{ computed, defineComponent }, { injectNDVStore }, { injectWorkflowExecutionStateStore }] = + await Promise.all([ + import('vue'), + import('@/features/ndv/shared/ndv.store'), + import('@/app/stores/workflowExecutionState.store'), + ]); return { default: defineComponent({ - props: ['paneType'], - setup() { + props: ['node', 'paneType'], + setup(props) { const ndvStore = injectNDVStore(); - return { ndvStoreId: computed(() => ndvStore.value.$id) }; + const executionStateStore = injectWorkflowExecutionStateStore(); + const paneValue = computed(() => { + const nodeName = props.node?.name as string | undefined; + if (!nodeName) return ''; + + const taskData = + executionStateStore.value.activeExecution?.data?.resultData.runData[nodeName]?.[0]; + const items = + props.paneType === 'input' + ? taskData?.inputOverride?.main?.[0] + : taskData?.data?.main?.[0]; + + return JSON.stringify(items?.[0]?.json ?? {}); + }); + return { ndvStoreId: computed(() => ndvStore.value.$id), paneValue }; }, template: - '
', + '
', }), }; }); @@ -42,15 +50,11 @@ vi.mock('@/app/stores/nodeTypes.store', () => ({ useNodeTypesStore: () => ({ loadNodeTypesIfNotLoaded: vi.fn().mockResolvedValue(undefined), getNodeType: () => null, - getAllNodeTypes: () => ({}), - }), -})); - -vi.mock('@/app/composables/useWorkflowHelpers', () => ({ - useWorkflowHelpers: () => ({ - // `Workflow` constructor tolerates unknown node types (it just flags the - // node), so an empty INodeTypes shim is enough for the synth workflow. - getNodeTypes: () => ({ + communityNodeType: () => null, + isTriggerNode: () => false, + isConfigNode: () => false, + isConfigurableNode: () => false, + getAllNodeTypes: () => ({ getByName: () => undefined, getByNameAndVersion: () => undefined, getKnownTypes: () => ({}), @@ -58,11 +62,19 @@ vi.mock('@/app/composables/useWorkflowHelpers', () => ({ }), })); +vi.mock('@/app/composables/useWorkflowNormalization', () => ({ + useWorkflowNormalization: () => ({ + normalizeWorkflowData: ({ nodes, connections }: { nodes: unknown[]; connections: object }) => ({ + nodes, + connections, + }), + }), +})); + const renderComponent = createComponentRenderer(ToolIoView); // Simulate the sessions page: App.vue provides `shallowRef(null)` because no -// real workflow is loaded. ToolIoView must re-provide a non-null synthetic -// document store for its RunData subtree (the fix under test). +// real workflow is loaded. The standalone host must provide the RunData scope. const SESSIONS_PAGE_PROVIDE = { [WorkflowDocumentStoreKey as symbol]: shallowRef(null), }; @@ -81,63 +93,26 @@ function mountIt(overrides: Record = {}) { }); } -const TOOL_IO_DOCUMENT_ID = createWorkflowDocumentId('__tool_io__'); - -describe('ToolIoView — scopes synth execution under a synthetic document store', () => { +describe('ToolIoView', () => { beforeEach(() => { setActivePinia(createPinia()); }); - it('writes the synth execution to the __tool_io__ document scope, not the editor workflow scope', async () => { - // The editor's workflow id is the scope the old code wrote to — make it - // deterministic so a regression (writing to workflowsStore.workflowId) - // is caught. + it('renders the synthetic input and output without writing to the editor workflow scope', async () => { useWorkflowsStore().workflowId = 'editor-wf'; - mountIt(); + const { getAllByTestId } = mountIt(); await flushPromises(); - const toolIoScope = useWorkflowExecutionStateStore(TOOL_IO_DOCUMENT_ID); - expect(toolIoScope.activeExecution).not.toBeNull(); - const runData = toolIoScope.activeExecution?.data?.resultData?.runData; - expect(runData).toBeTruthy(); - expect(runData).toHaveProperty('HTTP Request'); - expect(runData?.['HTTP Request']?.[0]?.inputOverride?.main?.[0]?.[0]?.json).toEqual({ - url: 'https://x', - }); - expect(runData?.['HTTP Request']?.[0]?.data?.main?.[0]?.[0]?.json).toEqual({ - status: 200, - }); + expect(getAllByTestId('run-data').map((pane) => pane.getAttribute('data-pane-value'))).toEqual([ + JSON.stringify({ url: 'https://x' }), + JSON.stringify({ status: 200 }), + ]); - // Regression guard: the synth payload must NOT leak into the editor's - // workflow scope (the old `createWorkflowDocumentId(workflowsStore.workflowId)` write). const editorScope = useWorkflowExecutionStateStore(createWorkflowDocumentId('editor-wf')); expect(editorScope.activeExecution).toBeNull(); }); - it('disposes the synthetic stores and execution payload on unmount', async () => { - const { unmount } = mountIt(); - await flushPromises(); - - const storeIds = [ - getExecutionDataStoreId(createExecutionDataId('__tool_io__')), - getNDVStoreId(TOOL_IO_DOCUMENT_ID), - getWorkflowExecutionStateStoreId(TOOL_IO_DOCUMENT_ID), - getWorkflowDocumentStoreId(TOOL_IO_DOCUMENT_ID), - ]; - const pinia = getActivePinia(); - for (const storeId of storeIds) { - expect(pinia?.state.value[storeId]).toBeDefined(); - } - - unmount(); - await flushPromises(); - - for (const storeId of storeIds) { - expect(pinia?.state.value[storeId]).toBeUndefined(); - } - }); - it('renders Input and Output panes (two RunData instances) without throwing', async () => { const { getAllByTestId } = mountIt(); await flushPromises(); @@ -145,9 +120,33 @@ describe('ToolIoView — scopes synth execution under a synthetic document store const panes = getAllByTestId('run-data'); expect(panes).toHaveLength(2); expect(panes.map((el) => el.getAttribute('data-pane-type'))).toEqual(['input', 'output']); - expect(panes.map((el) => el.getAttribute('data-ndv-store-id'))).toEqual([ - getNDVStoreId(TOOL_IO_DOCUMENT_ID), - getNDVStoreId(TOOL_IO_DOCUMENT_ID), + expect(panes[0]?.getAttribute('data-ndv-store-id')).toBeTruthy(); + expect(panes[1]?.getAttribute('data-ndv-store-id')).toBe( + panes[0]?.getAttribute('data-ndv-store-id'), + ); + }); + + it('updates both panes when the mounted view is reused with new tool data', async () => { + const { getAllByTestId, rerender } = mountIt(); + await flushPromises(); + + expect(getAllByTestId('run-data').map((pane) => pane.getAttribute('data-pane-value'))).toEqual([ + JSON.stringify({ url: 'https://x' }), + JSON.stringify({ status: 200 }), + ]); + + await rerender({ + name: 'HTTP Request', + input: { url: 'https://updated.example' }, + output: { status: 201 }, + nodeParameters: { url: 'https://updated.example' }, + success: true, + }); + await flushPromises(); + + expect(getAllByTestId('run-data').map((pane) => pane.getAttribute('data-pane-value'))).toEqual([ + JSON.stringify({ url: 'https://updated.example' }), + JSON.stringify({ status: 201 }), ]); }); }); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/WorkflowExecutionLogViewer.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/WorkflowExecutionLogViewer.spec.ts index 85bedf13a7a..a07311c838e 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/WorkflowExecutionLogViewer.spec.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/WorkflowExecutionLogViewer.spec.ts @@ -1,10 +1,26 @@ /* eslint-disable import-x/no-extraneous-dependencies -- test-only patterns */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { mount, flushPromises } from '@vue/test-utils'; +import { createPinia, getActivePinia, setActivePinia } from 'pinia'; +import { shallowRef } from 'vue'; +import { createRunExecutionData, NodeConnectionTypes } from 'n8n-workflow'; import { createRouter, createMemoryHistory, type Router } from 'vue-router'; import WorkflowExecutionLogViewer from '../components/WorkflowExecutionLogViewer.vue'; +import { WorkflowDocumentStoreKey } from '@/app/constants/injectionKeys'; +import { createWorkflowDocumentId } from '@/app/stores/workflowDocument.store'; +import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store'; +import { getNDVStoreId } from '@/features/ndv/shared/ndv.store'; +import { + createTestNode, + createTestTaskData, + createTestWorkflow, + createTestWorkflowExecutionResponse, +} from '@/__tests__/mocks'; const fetchExecution = vi.fn(); +const loadNodeTypesIfNotLoaded = vi.fn(); +const CHILD_WORKFLOW_ID = 'child-wf'; +const CHILD_EXECUTION_ID = 'exec-1'; vi.mock('@/features/execution/executions/executions.store', () => ({ useExecutionsStore: () => ({ fetchExecution }), @@ -12,7 +28,7 @@ vi.mock('@/features/execution/executions/executions.store', () => ({ vi.mock('@/app/stores/workflows.store', () => ({ useWorkflowsStore: () => ({ - workflowId: 'wf-1', + workflowId: 'child-wf', workflowExecutionData: null, getNodeTypes: () => ({ getByName: () => undefined, @@ -22,12 +38,6 @@ vi.mock('@/app/stores/workflows.store', () => ({ }), })); -vi.mock('@/app/stores/workflowExecutionState.store', () => ({ - useWorkflowExecutionStateStore: () => ({ - setWorkflowExecutionData: vi.fn(), - }), -})); - vi.mock('@/app/composables/useWorkflowHelpers', () => ({ useWorkflowHelpers: () => ({ getNodeTypes: () => ({ @@ -38,10 +48,24 @@ vi.mock('@/app/composables/useWorkflowHelpers', () => ({ }), })); +vi.mock('@/app/composables/useWorkflowNormalization', () => ({ + useWorkflowNormalization: () => ({ + normalizeWorkflowData: ({ nodes, connections }: { nodes: unknown[]; connections: object }) => ({ + nodes, + connections, + }), + }), +})); + vi.mock('@/app/stores/nodeTypes.store', () => ({ useNodeTypesStore: () => ({ getNodeType: () => null, - loadNodeTypesIfNotLoaded: vi.fn().mockResolvedValue(undefined), + getAllNodeTypes: () => ({ + getByName: () => undefined, + getByNameAndVersion: () => undefined, + getKnownTypes: () => ({}), + }), + loadNodeTypesIfNotLoaded, }), })); @@ -54,12 +78,36 @@ vi.mock('@/features/execution/logs/components/LogsOverviewRow.vue', () => ({ }, })); -vi.mock('@/features/ndv/runData/components/RunData.vue', () => ({ - default: { - props: ['node', 'runIndex', 'paneType', 'displayMode'], - template: '
', - }, -})); +// Keep RunData lightweight while exercising the strict NDV injection used in production. +vi.mock('@/features/ndv/runData/components/RunData.vue', async () => { + const [{ computed, defineComponent }, { injectNDVStore }, { injectWorkflowExecutionStateStore }] = + await Promise.all([ + import('vue'), + import('@/features/ndv/shared/ndv.store'), + import('@/app/stores/workflowExecutionState.store'), + ]); + + return { + default: defineComponent({ + props: ['paneType'], + setup() { + const ndvStore = injectNDVStore(); + const executionStateStore = injectWorkflowExecutionStateStore(); + const outputStatus = computed( + () => + executionStateStore.value.activeExecution?.data?.resultData.runData['HTTP Request']?.[0] + ?.data?.main?.[0]?.[0]?.json.status, + ); + return { + ndvStoreId: computed(() => ndvStore.value.$id), + outputStatus, + }; + }, + template: + '
', + }), + }; +}); vi.mock('@/features/ndv/runData/components/error/NodeErrorView.vue', () => ({ default: { @@ -84,33 +132,91 @@ function makeRouter(): Router { function mountIt(props: { workflowId: string; workflowExecutionId: string }) { return mount(WorkflowExecutionLogViewer, { props, - global: { plugins: [makeRouter()] }, + global: { + plugins: [makeRouter()], + provide: { + [WorkflowDocumentStoreKey as symbol]: shallowRef(null), + }, + }, + }); +} + +function makeChildExecution(outputStatus = 200) { + const trigger = createTestNode({ + id: 'trigger', + name: 'When Executed by Another Workflow', + type: 'n8n-nodes-base.executeWorkflowTrigger', + }); + const action = createTestNode({ id: 'http', name: 'HTTP Request' }); + + return createTestWorkflowExecutionResponse({ + id: CHILD_EXECUTION_ID, + status: 'success', + workflowData: createTestWorkflow({ + id: CHILD_WORKFLOW_ID, + nodes: [trigger, action], + connections: { + [trigger.name]: { + main: [[{ node: action.name, type: NodeConnectionTypes.Main, index: 0 }]], + }, + }, + }), + data: createRunExecutionData({ + resultData: { + runData: { + [trigger.name]: [ + createTestTaskData({ data: { main: [[{ json: { query: 'emperor' } }]] } }), + ], + [action.name]: [ + createTestTaskData({ + source: [ + { + previousNode: trigger.name, + previousNodeOutput: 0, + previousNodeRun: 0, + }, + ], + data: { main: [[{ json: { status: outputStatus } }]] }, + }), + ], + }, + }, + }), }); } beforeEach(() => { + setActivePinia(createPinia()); fetchExecution.mockReset(); + loadNodeTypesIfNotLoaded.mockReset(); + loadNodeTypesIfNotLoaded.mockResolvedValue(undefined); }); describe('WorkflowExecutionLogViewer', () => { it('shows "Still running" banner when status is running', async () => { fetchExecution.mockResolvedValueOnce({ - id: 'exec-1', + ...makeChildExecution(), + finished: false, status: 'running', - data: { resultData: { runData: {} } }, }); - const w = mountIt({ workflowId: 'wf-1', workflowExecutionId: 'exec-1' }); + const w = mountIt({ + workflowId: CHILD_WORKFLOW_ID, + workflowExecutionId: CHILD_EXECUTION_ID, + }); await flushPromises(); expect(w.text()).toContain('Still running'); }); it('shows "Waiting" banner when status is waiting', async () => { fetchExecution.mockResolvedValueOnce({ - id: 'exec-1', + ...makeChildExecution(), + finished: false, status: 'waiting', - data: { resultData: { runData: {} } }, }); - const w = mountIt({ workflowId: 'wf-1', workflowExecutionId: 'exec-1' }); + const w = mountIt({ + workflowId: CHILD_WORKFLOW_ID, + workflowExecutionId: CHILD_EXECUTION_ID, + }); await flushPromises(); expect(w.text()).toContain('Waiting'); }); @@ -129,14 +235,88 @@ describe('WorkflowExecutionLogViewer', () => { expect(w.text()).toContain('unavailable'); }); - it('calls fetchExecution with the executionId on mount', async () => { - fetchExecution.mockResolvedValueOnce({ - id: 'exec-1', - status: 'success', - data: { resultData: { runData: {} } }, + it('shows error banner when the RunData scope cannot be prepared', async () => { + loadNodeTypesIfNotLoaded.mockRejectedValueOnce(new Error('node types unavailable')); + fetchExecution.mockResolvedValueOnce(makeChildExecution()); + const w = mountIt({ + workflowId: CHILD_WORKFLOW_ID, + workflowExecutionId: CHILD_EXECUTION_ID, }); - mountIt({ workflowId: 'wf-1', workflowExecutionId: 'exec-1' }); + await flushPromises(); - expect(fetchExecution).toHaveBeenCalledWith('exec-1'); + + expect(w.text()).toContain('unavailable'); + }); + + it('calls fetchExecution with the executionId on mount', async () => { + fetchExecution.mockResolvedValueOnce(makeChildExecution()); + mountIt({ + workflowId: CHILD_WORKFLOW_ID, + workflowExecutionId: CHILD_EXECUTION_ID, + }); + await flushPromises(); + expect(fetchExecution).toHaveBeenCalledWith(CHILD_EXECUTION_ID); + }); + + it('provides input and output panes with isolated execution data', async () => { + fetchExecution.mockResolvedValueOnce(makeChildExecution()); + + const wrapper = mountIt({ + workflowId: CHILD_WORKFLOW_ID, + workflowExecutionId: CHILD_EXECUTION_ID, + }); + await flushPromises(); + + const rows = wrapper.findAll('[data-test-id="log-node-row"]'); + expect(rows).toHaveLength(2); + await rows[1].trigger('click'); + + const panes = wrapper.findAll('[data-test-id="run-data"]'); + expect(panes.map((pane) => pane.attributes('data-pane-type'))).toEqual(['input', 'output']); + const ndvStoreIds = panes.map((pane) => pane.attributes('data-ndv-store-id')); + expect(ndvStoreIds[0]).toBe(ndvStoreIds[1]); + expect(ndvStoreIds[0]).not.toBe(getNDVStoreId(createWorkflowDocumentId(CHILD_WORKFLOW_ID))); + expect(panes.map((pane) => pane.attributes('data-output-status'))).toEqual(['200', '200']); + }); + + it('does not replace execution data in the editor workflow scope', async () => { + const editorDocumentId = createWorkflowDocumentId(CHILD_WORKFLOW_ID); + const editorExecution = makeChildExecution(102); + useWorkflowExecutionStateStore(editorDocumentId).setWorkflowExecutionData(editorExecution); + fetchExecution.mockResolvedValueOnce(makeChildExecution()); + + mountIt({ workflowId: CHILD_WORKFLOW_ID, workflowExecutionId: CHILD_EXECUTION_ID }); + await flushPromises(); + + const storedEditorExecution = useWorkflowExecutionStateStore(editorDocumentId).activeExecution; + expect(storedEditorExecution?.id).toBe(CHILD_EXECUTION_ID); + expect( + storedEditorExecution?.data?.resultData.runData['HTTP Request']?.[0]?.data?.main?.[0]?.[0] + ?.json, + ).toEqual({ status: 102 }); + }); + + it('does not install scoped stores when unmounted before the fetch resolves', async () => { + let resolveExecution: (execution: ReturnType) => void = () => {}; + fetchExecution.mockReturnValueOnce( + new Promise((resolve) => { + resolveExecution = resolve; + }), + ); + const wrapper = mountIt({ + workflowId: CHILD_WORKFLOW_ID, + workflowExecutionId: CHILD_EXECUTION_ID, + }); + await flushPromises(); + expect(fetchExecution).toHaveBeenCalledWith(CHILD_EXECUTION_ID); + + wrapper.unmount(); + resolveExecution(makeChildExecution()); + await flushPromises(); + + const standaloneStoreIds = Object.keys(getActivePinia()?.state.value ?? {}).filter((storeId) => + storeId.includes('standalone-run-data'), + ); + expect(standaloneStoreIds).toEqual([]); }); }); diff --git a/packages/frontend/editor-ui/src/features/agents/components/ToolIoView.vue b/packages/frontend/editor-ui/src/features/agents/components/ToolIoView.vue index 12de1980c8e..954cf2f6049 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/ToolIoView.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/ToolIoView.vue @@ -1,34 +1,11 @@ diff --git a/packages/frontend/editor-ui/src/features/ndv/runData/standaloneRunData.ts b/packages/frontend/editor-ui/src/features/ndv/runData/standaloneRunData.ts new file mode 100644 index 00000000000..1739cf474a0 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ndv/runData/standaloneRunData.ts @@ -0,0 +1,3 @@ +import type { InjectionKey } from 'vue'; + +export const StandaloneRunDataHostKey: InjectionKey = Symbol('StandaloneRunDataHost'); diff --git a/packages/testing/playwright/pages/AgentSessionsPage.ts b/packages/testing/playwright/pages/AgentSessionsPage.ts new file mode 100644 index 00000000000..1a73ebdbea9 --- /dev/null +++ b/packages/testing/playwright/pages/AgentSessionsPage.ts @@ -0,0 +1,189 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +import { BasePage } from './BasePage'; + +interface ToolCallMock { + name: string; + input: Record; + output: Record; +} + +interface WorkflowToolCallMock { + name: string; + workflowId: string; + executionId: string; +} + +interface AgentSessionMock { + projectId: string; + agentId: string; + threadId: string; + sessionTitle?: string; + nodeTool: ToolCallMock; + workflowTool: WorkflowToolCallMock; +} + +interface ElementWidth { + clientWidth: number; + scrollWidth: number; +} + +export class AgentSessionsPage extends BasePage { + constructor(page: Page) { + super(page); + } + + async mockSession({ + projectId, + agentId, + threadId, + sessionTitle = 'Tool input and output', + nodeTool, + workflowTool, + }: AgentSessionMock): Promise { + const createdAt = '2026-01-01T12:00:00.000Z'; + const stoppedAt = '2026-01-01T12:00:03.000Z'; + const startedAtMs = Date.parse(createdAt); + const thread = { + id: threadId, + agentId, + agentName: 'Agent session E2E', + parentThreadId: null, + parentAgentId: null, + projectId, + taskId: null, + sessionNumber: 1, + title: sessionTitle, + emoji: null, + totalPromptTokens: 10, + totalCompletionTokens: 5, + totalCost: 0, + totalDuration: 3_000, + createdAt, + updatedAt: stoppedAt, + firstMessage: null, + source: 'chat', + }; + const execution = { + id: 'agent-execution-e2e', + threadId, + agentId, + status: 'success', + createdAt, + startedAt: createdAt, + stoppedAt, + duration: 3_000, + userMessage: null, + attachments: null, + model: null, + promptTokens: 10, + completionTokens: 5, + totalTokens: 15, + cost: 0, + timeline: [ + { + type: 'tool-call', + kind: 'node', + name: nodeTool.name, + toolCallId: 'node-tool-call-e2e', + input: nodeTool.input, + output: nodeTool.output, + startTime: startedAtMs + 500, + endTime: startedAtMs + 1_000, + success: true, + nodeType: 'n8n-nodes-base.set', + nodeTypeVersion: 3.4, + nodeDisplayName: nodeTool.name, + nodeParameters: nodeTool.input, + }, + { + type: 'tool-call', + kind: 'workflow', + name: workflowTool.name, + toolCallId: 'workflow-tool-call-e2e', + input: {}, + output: { executionId: workflowTool.executionId, status: 'success' }, + startTime: startedAtMs + 1_500, + endTime: startedAtMs + 2_500, + success: true, + workflowId: workflowTool.workflowId, + workflowName: workflowTool.name, + workflowExecutionId: workflowTool.executionId, + triggerType: 'executeWorkflow', + }, + ], + error: null, + hitlStatus: null, + source: 'chat', + }; + const threadsPath = `/rest/projects/${projectId}/agents/v2/${agentId}/threads`; + + await this.page.route( + (url) => url.pathname === threadsPath, + async (route) => { + await route.fulfill({ json: { data: { threads: [thread], nextCursor: null } } }); + }, + ); + await this.page.route( + (url) => url.pathname === `${threadsPath}/${threadId}`, + async (route) => { + await route.fulfill({ json: { data: { thread, executions: [execution] } } }); + }, + ); + } + + async goto(projectId: string, agentId: string, threadId: string): Promise { + await this.page.goto(`/projects/${projectId}/agents/${agentId}/sessions/${threadId}`); + await expect(this.getTimelineRows().first()).toBeVisible(); + } + + async gotoList(projectId: string, agentId: string): Promise { + await this.page.goto(`/projects/${projectId}/agents/${agentId}/sessions`); + await expect(this.getSessionTitle()).toBeVisible(); + } + + async setViewportWidth(width: number): Promise { + await this.page.setViewportSize({ + width, + height: this.page.viewportSize()?.height ?? 720, + }); + } + + async getSessionTableWidth(): Promise { + return await this.page.getByTestId('table-base-scroll').evaluate((element) => ({ + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + })); + } + + async getSessionTitleWidth(): Promise { + return await this.getSessionTitle().evaluate((element) => ({ + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + })); + } + + getSessionTitle(): Locator { + return this.page.getByTestId('agent-session-title'); + } + + getTimelineRows(): Locator { + return this.page.getByTestId('timeline-row'); + } + + async openTimelineItem(name: string): Promise { + await this.getTimelineRows().filter({ hasText: name }).click(); + } + + getInputRunData(): Locator { + return this.page.getByTestId('agent-session-run-data-input'); + } + + getOutputRunData(): Locator { + return this.page.getByTestId('agent-session-run-data-output'); + } + + async openWorkflowLogNode(name: string): Promise { + await this.page.getByTestId('log-node-row').filter({ hasText: name }).click(); + } +} diff --git a/packages/testing/playwright/pages/n8nPage.ts b/packages/testing/playwright/pages/n8nPage.ts index e51249475b0..b2a36140f76 100644 --- a/packages/testing/playwright/pages/n8nPage.ts +++ b/packages/testing/playwright/pages/n8nPage.ts @@ -1,5 +1,6 @@ import type { Page } from '@playwright/test'; +import { AgentSessionsPage } from './AgentSessionsPage'; import { AIAssistantPage } from './AIAssistantPage'; import { CanvasPage } from './CanvasPage'; import { ChatHubChatPage } from './ChatHubChatPage'; @@ -78,6 +79,7 @@ export class n8nPage { // Pages readonly aiAssistant: AIAssistantPage; readonly aiBuilder: AIBuilderPage; + readonly agentSessions: AgentSessionsPage; readonly canvas: CanvasPage; readonly chatHubChat: ChatHubChatPage; readonly chatHubPersonalAgents: ChatHubPersonalAgentsPage; @@ -162,6 +164,7 @@ export class n8nPage { // Pages this.aiAssistant = new AIAssistantPage(page); this.aiBuilder = new AIBuilderPage(page); + this.agentSessions = new AgentSessionsPage(page); this.canvas = new CanvasPage(page); this.chatHubChat = new ChatHubChatPage(page); this.chatHubPersonalAgents = new ChatHubPersonalAgentsPage(page); diff --git a/packages/testing/playwright/tests/e2e/agents/agent-sessions.spec.ts b/packages/testing/playwright/tests/e2e/agents/agent-sessions.spec.ts new file mode 100644 index 00000000000..b461f6d6053 --- /dev/null +++ b/packages/testing/playwright/tests/e2e/agents/agent-sessions.spec.ts @@ -0,0 +1,164 @@ +import type { IWorkflowBase } from 'n8n-workflow'; +import { nanoid } from 'nanoid'; + +import { expect, test } from '../../../fixtures/base'; + +const CHILD_TRIGGER_NAME = 'Manual Trigger'; +const CHILD_INPUT_NODE_NAME = 'Prepare workflow tool input'; +const CHILD_OUTPUT_NODE_NAME = 'Prepare workflow tool output'; +const NODE_TOOL_NAME = 'Node tool'; +const WORKFLOW_TOOL_NAME = 'Workflow tool'; + +const NODE_INPUT_MARKER = 'node-tool-input-visible'; +const NODE_OUTPUT_MARKER = 'node-tool-output-visible'; +const WORKFLOW_INPUT_MARKER = 'workflow-tool-input-visible'; +const WORKFLOW_OUTPUT_MARKER = 'workflow-tool-output-visible'; +const LONG_SESSION_TITLE = + 'This is a long Agent session title that should be truncated to keep every column visible without horizontal scrolling'; + +test.use({ + capability: { + env: { + N8N_ENABLED_MODULES: 'agents', + TEST_ISOLATION: 'agent-sessions-tool-run-data', + }, + }, +}); + +function childWorkflow(): Partial { + return { + name: `Agent session child workflow ${nanoid(8)}`, + active: false, + nodes: [ + { + id: nanoid(), + name: CHILD_TRIGGER_NAME, + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + { + id: nanoid(), + name: CHILD_INPUT_NODE_NAME, + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + position: [240, 0], + parameters: { + assignments: { + assignments: [ + { + id: nanoid(), + name: 'workflowInput', + value: WORKFLOW_INPUT_MARKER, + type: 'string', + }, + ], + }, + options: {}, + }, + }, + { + id: nanoid(), + name: CHILD_OUTPUT_NODE_NAME, + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + position: [480, 0], + parameters: { + assignments: { + assignments: [ + { + id: nanoid(), + name: 'workflowOutput', + value: WORKFLOW_OUTPUT_MARKER, + type: 'string', + }, + ], + }, + options: {}, + }, + }, + ], + connections: { + [CHILD_TRIGGER_NAME]: { + main: [[{ node: CHILD_INPUT_NODE_NAME, type: 'main', index: 0 }]], + }, + [CHILD_INPUT_NODE_NAME]: { + main: [[{ node: CHILD_OUTPUT_NODE_NAME, type: 'main', index: 0 }]], + }, + }, + settings: { saveManualExecutions: true }, + }; +} + +test.describe('Agent sessions', { annotation: [{ type: 'owner', description: 'AI' }] }, () => { + test('fits long session titles within the sessions table', async ({ n8n, api }) => { + const project = await api.projects.getMyPersonalProject(); + const agentId = `agent-${nanoid(8)}`; + const threadId = `thread-${nanoid(8)}`; + await n8n.agentSessions.mockSession({ + projectId: project.id, + agentId, + threadId, + sessionTitle: LONG_SESSION_TITLE, + nodeTool: { name: NODE_TOOL_NAME, input: {}, output: {} }, + workflowTool: { + name: WORKFLOW_TOOL_NAME, + workflowId: 'workflow-id', + executionId: 'execution-id', + }, + }); + + await n8n.agentSessions.setViewportWidth(800); + await n8n.start.fromHome(); + await n8n.agentSessions.gotoList(project.id, agentId); + + const tableWidth = await n8n.agentSessions.getSessionTableWidth(); + const narrowTitleWidth = await n8n.agentSessions.getSessionTitleWidth(); + expect(tableWidth.scrollWidth).toBe(tableWidth.clientWidth); + expect(narrowTitleWidth.scrollWidth).toBeGreaterThan(narrowTitleWidth.clientWidth); + + await n8n.agentSessions.setViewportWidth(1_400); + await expect + .poll(async () => (await n8n.agentSessions.getSessionTitleWidth()).clientWidth) + .toBeGreaterThan(narrowTitleWidth.clientWidth); + }); + + test('shows input and output data for node and workflow tools', async ({ n8n, api }) => { + const project = await api.projects.getMyPersonalProject(); + const { workflowId } = await api.workflows.createWorkflowFromDefinition(childWorkflow(), { + projectId: project.id, + }); + const { executionId } = await api.workflows.runManually(workflowId, CHILD_TRIGGER_NAME); + + const agentId = `agent-${nanoid(8)}`; + const threadId = `thread-${nanoid(8)}`; + await n8n.agentSessions.mockSession({ + projectId: project.id, + agentId, + threadId, + nodeTool: { + name: NODE_TOOL_NAME, + input: { request: NODE_INPUT_MARKER }, + output: { response: NODE_OUTPUT_MARKER }, + }, + workflowTool: { + name: WORKFLOW_TOOL_NAME, + workflowId, + executionId, + }, + }); + + await n8n.start.fromHome(); + await n8n.agentSessions.goto(project.id, agentId, threadId); + + await n8n.agentSessions.openTimelineItem(NODE_TOOL_NAME); + await expect(n8n.agentSessions.getInputRunData()).toContainText(NODE_INPUT_MARKER); + await expect(n8n.agentSessions.getOutputRunData()).toContainText(NODE_OUTPUT_MARKER); + + await n8n.agentSessions.openTimelineItem(WORKFLOW_TOOL_NAME); + await n8n.agentSessions.openWorkflowLogNode(CHILD_OUTPUT_NODE_NAME); + await expect(n8n.agentSessions.getInputRunData()).toContainText(WORKFLOW_INPUT_MARKER); + await expect(n8n.agentSessions.getOutputRunData()).toContainText(WORKFLOW_OUTPUT_MARKER); + }); +}); From 28307f35eb9768c94d86085784cfdc0dffe6d58c Mon Sep 17 00:00:00 2001 From: mfsiega <93014743+mfsiega@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:44:31 +0000 Subject: [PATCH 0004/1458] feat(core): Validate trigger node id uniqueness before publish (no-changelog) (#35379) Co-authored-by: Claude Opus 5 (1M context) --- .../workflow-validation.service.test.ts | 51 +++++++++ .../__tests__/workflow.service.test.ts | 2 + .../triggers/enabled-trigger-nodes.ts | 36 ++++++ .../triggers/workflow-trigger-activator.ts | 27 ++--- .../workflows/workflow-validation.service.ts | 47 ++++++++ .../cli/src/workflows/workflow.service.ts | 18 +++ .../workflows/workflow.service.test.ts | 107 +++++++++++++++++- 7 files changed, 267 insertions(+), 21 deletions(-) create mode 100644 packages/cli/src/workflows/triggers/enabled-trigger-nodes.ts diff --git a/packages/cli/src/workflows/__tests__/workflow-validation.service.test.ts b/packages/cli/src/workflows/__tests__/workflow-validation.service.test.ts index 7be61e67e30..7142b6152ed 100644 --- a/packages/cli/src/workflows/__tests__/workflow-validation.service.test.ts +++ b/packages/cli/src/workflows/__tests__/workflow-validation.service.test.ts @@ -1460,4 +1460,55 @@ describe('WorkflowValidationService', () => { expect(result.error).toMatch(/Slack/); }); }); + + describe('validateTriggerNodeIds', () => { + const triggerNode = (name: string, id?: string): INode => + ({ + name, + id, + type: 'n8n-nodes-base.cron', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }) as INode; + + it('accepts trigger nodes that each carry a distinct id', () => { + const result = service.validateTriggerNodeIds([ + triggerNode('Cron', 'node-1'), + triggerNode('Webhook', 'node-2'), + ]); + + expect(result.isValid).toBe(true); + }); + + it('accepts a workflow with no trigger nodes', () => { + const result = service.validateTriggerNodeIds([]); + + expect(result.isValid).toBe(true); + }); + + it('rejects trigger nodes that share an id, naming each node and the id', () => { + const result = service.validateTriggerNodeIds([ + triggerNode('Cron', 'node-1'), + triggerNode('Webhook', 'node-1'), + ]); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('Cannot publish workflow'); + expect(result.error).toContain('"Cron"'); + expect(result.error).toContain('"Webhook"'); + expect(result.error).toContain('"node-1"'); + }); + + it('rejects a trigger node that carries no id', () => { + const result = service.validateTriggerNodeIds([ + triggerNode('Cron', 'node-1'), + triggerNode('Webhook', undefined), + ]); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('Cannot publish workflow'); + expect(result.error).toContain('"Webhook"'); + }); + }); }); diff --git a/packages/cli/src/workflows/__tests__/workflow.service.test.ts b/packages/cli/src/workflows/__tests__/workflow.service.test.ts index 84bc9257738..6c4e15aa057 100644 --- a/packages/cli/src/workflows/__tests__/workflow.service.test.ts +++ b/packages/cli/src/workflows/__tests__/workflow.service.test.ts @@ -1131,11 +1131,13 @@ describe('WorkflowService', () => { _validateNodes: () => void; _validateDynamicCredentials: () => Promise; _validateSubWorkflowReferences: () => Promise; + _validateTriggerNodeIds: () => void; }; vi.spyOn(internals, '_detectWebhookConflicts').mockResolvedValue(undefined); vi.spyOn(internals, '_validateNodes').mockReturnValue(undefined); vi.spyOn(internals, '_validateDynamicCredentials').mockResolvedValue(undefined); vi.spyOn(internals, '_validateSubWorkflowReferences').mockResolvedValue(undefined); + vi.spyOn(internals, '_validateTriggerNodeIds').mockReturnValue(undefined); }); test.each([ diff --git a/packages/cli/src/workflows/triggers/enabled-trigger-nodes.ts b/packages/cli/src/workflows/triggers/enabled-trigger-nodes.ts new file mode 100644 index 00000000000..085d4fb6872 --- /dev/null +++ b/packages/cli/src/workflows/triggers/enabled-trigger-nodes.ts @@ -0,0 +1,36 @@ +import type { IConnections, INode, INodeTypes } from 'n8n-workflow'; +import { Workflow } from 'n8n-workflow'; + +export type WorkflowTriggerVersion = { nodes: INode[]; connections: IConnections }; + +/** + * Returns the enabled trigger-like nodes (active, poll, schedule and webhook + * triggers) of a workflow version. Disabled nodes are excluded, so the result + * is the set of nodes that actually drive trigger registration. + * + * A free function rather than a method so callers that only need the + * classification — the publish-time node id check on the API side — can reuse it + * without depending on `WorkflowTriggerActivator`, which owns trigger + * registration and only constructs when the publication service is enabled. + * One implementation keeps that check and publication agreeing on which nodes + * get a trigger status row. + */ +export function getEnabledTriggerNodes( + version: WorkflowTriggerVersion | null, + nodeTypes: INodeTypes, +): INode[] { + if (!version) return []; + + const workflow = new Workflow({ + id: 'trigger-diff', + name: 'trigger-diff', + nodes: version.nodes, + connections: version.connections, + active: false, + nodeTypes, + }); + + return workflow.queryNodes( + (nodeType) => !!nodeType.trigger || !!nodeType.poll || !!nodeType.webhook, + ); +} diff --git a/packages/cli/src/workflows/triggers/workflow-trigger-activator.ts b/packages/cli/src/workflows/triggers/workflow-trigger-activator.ts index 1565682da6a..69827ae0ce7 100644 --- a/packages/cli/src/workflows/triggers/workflow-trigger-activator.ts +++ b/packages/cli/src/workflows/triggers/workflow-trigger-activator.ts @@ -9,7 +9,6 @@ import { ErrorReporter, SpanStatus, Tracing } from 'n8n-core'; import { ensureError } from '@n8n/utils/errors/ensure-error'; import { createResultError, createResultOk, type Result } from '@n8n/utils/result'; import type { - IConnections, INode, IWebhookData, IWorkflowBase, @@ -35,6 +34,8 @@ import type { } from '@/events/maps/workflow-publication-metrics.event-map'; import { NodeTypes } from '@/node-types'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; +import type { WorkflowTriggerVersion } from '@/workflows/triggers/enabled-trigger-nodes'; +import { getEnabledTriggerNodes } from '@/workflows/triggers/enabled-trigger-nodes'; import type { PreparedNonWebhookTriggerRegistration } from '@/workflows/triggers/non-webhook-trigger-registrar'; import { NonWebhookTriggerRegistrar } from '@/workflows/triggers/non-webhook-trigger-registrar'; import { retryTriggerActivation } from '@/workflows/triggers/trigger-activation-retry'; @@ -43,7 +44,7 @@ import { TriggerExecutionContextFactory } from '@/workflows/triggers/trigger-exe import { WebhookTriggerRegistrar } from '@/workflows/triggers/webhook-trigger-registrar'; import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service'; -export type WorkflowTriggerVersion = { nodes: INode[]; connections: IConnections }; +export type { WorkflowTriggerVersion }; // Their trigger() is a no-op — fired by the execution engine, never the // registry — so reconciling them against the registry would re-enqueue forever. @@ -101,26 +102,12 @@ export class WorkflowTriggerActivator { } /** - * Returns the enabled trigger-like nodes (active, poll, schedule and webhook - * triggers) of a workflow version. Disabled nodes are excluded, so the result - * is the set of nodes that actually drive trigger registration. Used to - * compute the trigger-level diff during publication. + * The enabled trigger-like nodes of a workflow version, used to compute the + * trigger-level diff during publication. See `getEnabledTriggerNodes`, shared + * with the publish-time node id check. */ getEnabledTriggerNodes(version: WorkflowTriggerVersion | null): INode[] { - if (!version) return []; - - const workflow = new Workflow({ - id: 'trigger-diff', - name: 'trigger-diff', - nodes: version.nodes, - connections: version.connections, - active: false, - nodeTypes: this.nodeTypes, - }); - - return workflow.queryNodes( - (nodeType) => !!nodeType.trigger || !!nodeType.poll || !!nodeType.webhook, - ); + return getEnabledTriggerNodes(version, this.nodeTypes); } /** diff --git a/packages/cli/src/workflows/workflow-validation.service.ts b/packages/cli/src/workflows/workflow-validation.service.ts index e5e0ff972e0..1b58241c3ff 100644 --- a/packages/cli/src/workflows/workflow-validation.service.ts +++ b/packages/cli/src/workflows/workflow-validation.service.ts @@ -229,6 +229,53 @@ export class WorkflowValidationService { }; } + /** + * Rejects trigger nodes whose ids do not uniquely identify them. + * + * Publication records one `workflow_publication_trigger_status` row per + * enabled trigger node, keyed by the composite primary key + * `(workflowId, nodeId)`. A shared or absent id therefore collapses two + * triggers onto one key: the diff in `computeTriggerDiff` loses one of them, + * and the row insert fails on the constraint. Catching it here turns a + * constraint violation raised mid-publication into a message naming the + * offending nodes. + * + * An absent id is the same defect as a shared one — `undefined` is just an id + * that every id-less node has in common — so both are reported together. + */ + validateTriggerNodeIds(triggerNodes: INode[]): WorkflowValidationResult { + const violations: string[] = []; + const namesById = new Map(); + + for (const node of triggerNodes) { + if (!node.id) { + violations.push(`trigger "${node.name}" has no node ID`); + continue; + } + + const names = namesById.get(node.id); + if (names) { + names.push(node.name); + } else { + namesById.set(node.id, [node.name]); + } + } + + for (const [nodeId, names] of namesById) { + if (names.length < 2) continue; + + const nameList = names.map((name) => `"${name}"`).join(', '); + violations.push(`triggers ${nameList} share the node ID "${nodeId}"`); + } + + if (violations.length === 0) return { isValid: true }; + + return { + isValid: false, + error: `Cannot publish workflow: ${violations.join('; ')}. Remove and re-add the affected nodes to give them new IDs.`, + }; + } + /** * Returns the credential types that are actively in use on a node — the * subset of `node.credentials` keys we should validate against. diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index 5c741ba3de3..827ebbe4c6b 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -29,6 +29,7 @@ import { PROJECT_ROOT, Workflow, assert, calculateWorkflowChecksum } from 'n8n-w import { v4 as uuid } from 'uuid'; import { WorkflowPublicationNotifier } from './publication/workflow-publication-notifier'; +import { getEnabledTriggerNodes } from './triggers/enabled-trigger-nodes'; import { getErrorDescription, getErrorNodeId, getRequiredRedactionScopes } from './utils'; import { WorkflowFinderService } from './workflow-finder.service'; import { WorkflowHistoryService } from './workflow-history/workflow-history.service'; @@ -836,6 +837,9 @@ export class WorkflowService { this._validateNodes(workflowId, versionToActivate.nodes, versionToActivate.connections); await this._validateDynamicCredentials(workflowId, versionToActivate.nodes, workflow.settings); await this._validateSubWorkflowReferences(workflowId, versionToActivate.nodes); + if (this.globalConfig.workflows.useWorkflowPublicationService) { + this._validateTriggerNodeIds(workflowId, versionToActivate); + } // Run hook before destructive state changes so a rejection leaves // the previous active version running instead of deactivating it. @@ -1455,6 +1459,20 @@ export class WorkflowService { } } + private _validateTriggerNodeIds(workflowId: string, version: WorkflowHistory) { + const validation = this.workflowValidationService.validateTriggerNodeIds( + getEnabledTriggerNodes(version, this.nodeTypes), + ); + + if (!validation.isValid) { + this.logger.warn('Workflow activation failed trigger node id validation', { + workflowId, + error: validation.error, + }); + throw new WorkflowValidationError(validation.error ?? 'Trigger node id validation failed'); + } + } + private async _validateDynamicCredentials( workflowId: string, nodes: INode[], diff --git a/packages/cli/test/integration/workflows/workflow.service.test.ts b/packages/cli/test/integration/workflows/workflow.service.test.ts index 60be3edba33..33e3aacaa55 100644 --- a/packages/cli/test/integration/workflows/workflow.service.test.ts +++ b/packages/cli/test/integration/workflows/workflow.service.test.ts @@ -19,7 +19,7 @@ import { ProjectRepository, } from '@n8n/db'; import { Container } from '@n8n/di'; -import type { INode } from 'n8n-workflow'; +import type { INode, INodeType } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; import { mock } from 'vitest-mock-extended'; @@ -43,6 +43,13 @@ import { createCustomRoleWithScopeSlugs, cleanupRolesAndScopes } from '../shared import { createOwner, createMember } from '../shared/db/users'; import { createWorkflowHistoryItem } from '../shared/db/workflow-history'; +/** + * A node type that classifies as a trigger. `properties` must be a real array: + * `getEnabledTriggerNodes` builds a `Workflow`, which reads it. + */ +const triggerNodeType = () => + ({ description: { properties: [] }, trigger: async () => ({}) }) as unknown as INodeType; + let globalConfig: GlobalConfig; let workflowRepository: WorkflowRepository; let workflowService: WorkflowService; @@ -105,6 +112,10 @@ beforeAll(async () => { beforeEach(() => { workflowPublishGuard.assertCanPublish.mockResolvedValue(undefined); + // Leaks into `_detectWebhookConflicts` in later tests otherwise, which builds a real Workflow. + nodeTypes.getByNameAndVersion.mockReset(); + workflowValidationService.validateTriggerNodeIds.mockReset(); + workflowValidationService.validateTriggerNodeIds.mockReturnValue({ isValid: true }); workflowValidationService.validateForActivation.mockReturnValue({ isValid: true }); workflowValidationService.validateDynamicCredentials.mockResolvedValue({ isValid: true }); workflowValidationService.validateSubWorkflowReferences.mockResolvedValue({ isValid: true }); @@ -786,9 +797,103 @@ describe('workflow publication outbox', () => { 'Cannot delete a published workflow. Unpublish it before deleting.', ); }); + + test('should reject the publish before any write when trigger node ids are not unique', async () => { + const owner = await createOwner(); + const workflow = await createWorkflowWithHistory({}, owner); + + workflowValidationService.validateTriggerNodeIds.mockReturnValue({ + isValid: false, + error: 'Cannot publish workflow: triggers "Cron", "Webhook" share the node ID "node-1".', + }); + + await expect(workflowService.activateWorkflow(owner, workflow.id)).rejects.toThrow( + 'share the node ID', + ); + + // The gate sits before the transaction, so neither half of it may have run. + const updated = await workflowRepository.findOne({ where: { id: workflow.id } }); + expect(updated?.activeVersionId).toBeNull(); + expect(updated?.active).toBe(false); + + const outboxCount = await outboxRepository.count(); + expect(outboxCount).toBe(0); + }); + + test('should check the trigger nodes of the version being published, not the current draft', async () => { + const owner = await createOwner(); + const workflow = await createWorkflowWithHistory({}, owner); + + const newVersionId = uuid(); + await createWorkflowHistoryItem(workflow.id, { + versionId: newVersionId, + nodes: [ + { + id: 'node-in-new-version', + name: 'Cron', + parameters: {}, + position: [0, 0], + type: 'n8n-nodes-base.cron', + typeVersion: 1, + }, + ], + }); + nodeTypes.getByNameAndVersion.mockReturnValue(triggerNodeType()); + + await workflowService.activateWorkflow(owner, workflow.id, { versionId: newVersionId }); + + expect(workflowValidationService.validateTriggerNodeIds).toHaveBeenCalledWith([ + expect.objectContaining({ id: 'node-in-new-version' }), + ]); + }); + + test('should skip disabled trigger nodes, which publication never records a row for', async () => { + const owner = await createOwner(); + const workflow = await createWorkflowWithHistory({}, owner); + + const newVersionId = uuid(); + await createWorkflowHistoryItem(workflow.id, { + versionId: newVersionId, + nodes: [ + { + id: 'shared-id', + name: 'Cron', + parameters: {}, + position: [0, 0], + type: 'n8n-nodes-base.cron', + typeVersion: 1, + }, + { + id: 'shared-id', + name: 'Disabled Cron', + parameters: {}, + position: [0, 0], + type: 'n8n-nodes-base.cron', + typeVersion: 1, + disabled: true, + }, + ], + }); + nodeTypes.getByNameAndVersion.mockReturnValue(triggerNodeType()); + + await workflowService.activateWorkflow(owner, workflow.id, { versionId: newVersionId }); + + expect(workflowValidationService.validateTriggerNodeIds).toHaveBeenCalledWith([ + expect.objectContaining({ name: 'Cron' }), + ]); + }); }); describe('when feature flag is disabled', () => { + test('should not run the trigger node id check', async () => { + const owner = await createOwner(); + const workflow = await createWorkflowWithHistory({}, owner); + + await workflowService.activateWorkflow(owner, workflow.id); + + expect(workflowValidationService.validateTriggerNodeIds).not.toHaveBeenCalled(); + }); + test('should not enqueue an outbox record on activation', async () => { const owner = await createOwner(); const workflow = await createWorkflowWithHistory({}, owner); From 79a7bd251c7e92893e5e85d7a4619b7602726181 Mon Sep 17 00:00:00 2001 From: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:03:38 +0000 Subject: [PATCH 0005/1458] fix(core): Resolve operation defaults per resource in generated node schemas (#35952) Co-authored-by: Claude Opus 5 (1M context) --- .../generate-zod-schemas.test.ts | 104 +++++++++++++++++ .../generate-types/generate-zod-schemas.ts | 41 ++++++- .../schema-validation-integration.test.ts | 105 ++++++++++++++++++ .../node-parameter-schema/schema-validator.ts | 47 +++++++- 4 files changed, 287 insertions(+), 10 deletions(-) diff --git a/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.test.ts b/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.test.ts index 885ffdd0b73..908c09a28ea 100644 --- a/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.test.ts +++ b/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.test.ts @@ -5,6 +5,7 @@ import { } from './generate-types'; import { generateConditionalSchemaLine, + generateResourceIndexSchemaFile, generateSingleVersionSchemaFile, stripDiscriminatorKeysFromDisplayOptions, generateDiscriminatorSchemaFile, @@ -2481,3 +2482,106 @@ describe('mapPropertyToZodSchema for agentSelector', () => { expect(schema).toContain('expressionSchema'); }); }); + +describe('per-resource operation defaults', () => { + // Mirrors nodes like Salesforce that declare one `operation` property per + // resource, each gated by displayOptions and carrying its own default. + const multiResourceNode: NodeTypeDescription = { + group: ['transform'], + inputs: ['main'], + outputs: ['main'], + name: 'n8n-nodes-base.testNode', + displayName: 'Test Node', + version: 1, + properties: [ + { + name: 'resource', + displayName: 'Resource', + type: 'options', + options: [ + { name: 'Lead', value: 'lead' }, + { name: 'Search', value: 'search' }, + ], + default: 'lead', + }, + { + name: 'operation', + displayName: 'Operation', + type: 'options', + displayOptions: { show: { resource: ['lead'] } }, + options: [ + { name: 'Create', value: 'create' }, + { name: 'Get', value: 'get' }, + ], + default: 'create', + }, + { + name: 'operation', + displayName: 'Operation', + type: 'options', + displayOptions: { show: { resource: ['search'] } }, + options: [{ name: 'Query', value: 'query' }], + default: 'query', + }, + ], + }; + + it('applies the default of the operation property scoped to the resource', () => { + const code = generateResourceIndexSchemaFile(multiResourceNode, 1, 'search', ['query']); + + expect(code).toContain("operation: 'query'"); + }); + + it('does not apply another resource’s operation default', () => { + const code = generateResourceIndexSchemaFile(multiResourceNode, 1, 'search', ['query']); + + expect(code).not.toContain("operation: 'create'"); + }); + + it('still applies the default for the first resource', () => { + const code = generateResourceIndexSchemaFile(multiResourceNode, 1, 'lead', ['create', 'get']); + + expect(code).toContain("operation: 'create'"); + }); + + // The editor omits `operation` when it equals the resource's default, so the + // leaf schema has to accept it as absent. + it('makes the operation literal defaultable when it is the resource default', () => { + const code = generateDiscriminatorSchemaFile( + multiResourceNode, + 1, + { resource: 'search', operation: 'query' }, + [], + 5, + [], + ); + + expect(code).toContain("operation: z.literal('query').default('query')"); + }); + + it('keeps the operation literal required when it is not the resource default', () => { + const code = generateDiscriminatorSchemaFile( + multiResourceNode, + 1, + { resource: 'lead', operation: 'get' }, + [], + 5, + [], + ); + + expect(code).toContain("operation: z.literal('get'),"); + }); + + it('makes the operation literal defaultable for the first resource default', () => { + const code = generateDiscriminatorSchemaFile( + multiResourceNode, + 1, + { resource: 'lead', operation: 'create' }, + [], + 5, + [], + ); + + expect(code).toContain("operation: z.literal('create').default('create')"); + }); +}); diff --git a/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.ts b/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.ts index 2d0ed519da7..9019c389b76 100644 --- a/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.ts +++ b/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.ts @@ -26,6 +26,7 @@ import { extractAIInputTypesFromBuilderHint, narrowDisplayOptionsByDisabled, } from './generate-types'; +import { checkConditions } from '../validation/display-options'; // ============================================================================= // Constants @@ -1514,6 +1515,32 @@ export function generateSchemaIndexFile(node: NodeTypeDescription, versions: num return lines.join('\n'); } +/** + * Resolve the default of the `operation` property that applies to `resource`. + * + * Nodes commonly declare one `operation` property per resource, each gated by + * `displayOptions.show.resource` and carrying its own default. Picking the first + * `operation` property regardless of resource applies one resource's default to + * all of them, which rejects valid configs that omit `operation` (the editor + * strips values equal to the default on save). + */ +function findOperationDefaultForResource( + node: NodeTypeDescription, + resource: string, +): NodeProperty['default'] { + const operationProps = node.properties.filter((p) => p.name === 'operation'); + + const scoped = operationProps.find((p) => { + const shown = p.displayOptions?.show?.resource; + return shown !== undefined && checkConditions(shown, [resource]); + }); + if (scoped) return scoped.default; + + // Nodes with a single resource-agnostic `operation` property have no + // resource condition to match against. + return operationProps.find((p) => p.displayOptions?.show?.resource === undefined)?.default; +} + // ============================================================================= // Split Version Schema Generation (for nodes with resource/operation discriminators) // ============================================================================= @@ -1691,9 +1718,14 @@ export function generateDiscriminatorSchemaFile( // Add discriminator fields as literals (with defaults if they have matching defaults) for (const [key, value] of Object.entries(combo)) { if (value !== undefined) { - // Check if this discriminator has a matching default value in node properties - const discProp = node.properties?.find((p) => p.name === key); - const hasMatchingDefault = discProp?.default === value; + // Check if this discriminator has a matching default value in node properties. + // `operation` is resolved against the combo's resource, since nodes declare one + // `operation` property per resource. + const defaultValue = + key === 'operation' && combo.resource !== undefined + ? findOperationDefaultForResource(node, combo.resource) + : node.properties?.find((p) => p.name === key)?.default; + const hasMatchingDefault = defaultValue === value; if (hasMatchingDefault) { // Accept undefined and default to the expected value @@ -1780,8 +1812,7 @@ export function generateResourceIndexSchemaFile( lines.push(''); // Find default for 'operation' discriminator - const operationProp = node.properties.find((p) => p.name === 'operation'); - const operationDefault = operationProp?.default; + const operationDefault = findOperationDefaultForResource(node, resource); // Export factory function via module.exports - receives all helpers and passes them through lines.push('module.exports = function getSchema(helpers) {'); diff --git a/packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/schema-validation-integration.test.ts b/packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/schema-validation-integration.test.ts index 847b04136fe..8128599af69 100644 --- a/packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/schema-validation-integration.test.ts +++ b/packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/schema-validation-integration.test.ts @@ -604,3 +604,108 @@ export default workflow('test-id', 'EWS Raw SOAP') }); }); }); + +describe('Per-resource operation defaults (Salesforce)', () => { + beforeAll(setupTestSchemas, 120_000); + afterAll(teardownTestSchemas); + + // Salesforce declares one `operation` property per resource. The `search` + // resource defaults to `query`, so the editor omits it on save. + it('accepts the search resource when operation is omitted', () => { + requireSchema('n8n-nodes-base.salesforce', 1); + + const result = validateNodeConfig('n8n-nodes-base.salesforce', 1, { + parameters: { resource: 'search', query: 'SELECT Id FROM CampaignMember' }, + }); + + expect(result.errors).toEqual([]); + expect(result.valid).toBe(true); + }); + + it('accepts the search resource when operation is explicit', () => { + requireSchema('n8n-nodes-base.salesforce', 1); + + const result = validateNodeConfig('n8n-nodes-base.salesforce', 1, { + parameters: { + resource: 'search', + operation: 'query', + query: 'SELECT Id FROM CampaignMember', + }, + }); + + expect(result.valid).toBe(true); + }); + + it('still rejects the search resource when a required field is missing', () => { + requireSchema('n8n-nodes-base.salesforce', 1); + + const result = validateNodeConfig('n8n-nodes-base.salesforce', 1, { + parameters: { resource: 'search' }, + }); + + expect(result.valid).toBe(false); + }); +}); + +describe('Union discriminator error messages (Salesforce)', () => { + beforeAll(setupTestSchemas, 120_000); + afterAll(teardownTestSchemas); + + it('lists all valid resources when the resource value is invalid', () => { + requireSchema('n8n-nodes-base.salesforce', 1); + + const result = validateNodeConfig('n8n-nodes-base.salesforce', 1, { + parameters: { resource: 'bogus', operation: 'create' }, + }); + + expect(result.valid).toBe(false); + const message = result.errors.map((e) => e.message).join('\n'); + expect(message).toContain('"account"'); + expect(message).toContain('"lead"'); + expect(message).toContain('"search"'); + }); + + it('lists all valid operations when the operation value is invalid', () => { + requireSchema('n8n-nodes-base.salesforce', 1); + + const result = validateNodeConfig('n8n-nodes-base.salesforce', 1, { + parameters: { resource: 'lead', operation: 'bogus' }, + }); + + expect(result.valid).toBe(false); + const message = result.errors.map((e) => e.message).join('\n'); + expect(message).toContain('"create"'); + expect(message).toContain('"delete"'); + expect(message).toContain('"upsert"'); + }); +}); + +describe('Union discriminator error messages scoped to the selected resource', () => { + beforeAll(setupTestSchemas, 120_000); + afterAll(teardownTestSchemas); + + it('omits operations belonging to other resources', () => { + requireSchema('n8n-nodes-base.salesforce', 1); + + const result = validateNodeConfig('n8n-nodes-base.salesforce', 1, { + parameters: { resource: 'lead', operation: 'bogus' }, + }); + + const message = result.errors.map((e) => e.message).join('\n'); + // "invoke" belongs to flow, "query" to search, "addComment" to case + expect(message).not.toContain('"invoke"'); + expect(message).not.toContain('"query"'); + expect(message).not.toContain('"addComment"'); + }); + + it('lists only the single valid operation for a one-operation resource', () => { + requireSchema('n8n-nodes-base.salesforce', 1); + + const result = validateNodeConfig('n8n-nodes-base.salesforce', 1, { + parameters: { resource: 'search', operation: 'bogus' }, + }); + + const message = result.errors.map((e) => e.message).join('\n'); + expect(message).toContain('expected one of: "query".'); + }); +}); diff --git a/packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/schema-validator.ts b/packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/schema-validator.ts index b811c235461..6fc8cde0c1d 100644 --- a/packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/schema-validator.ts +++ b/packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/schema-validator.ts @@ -397,16 +397,49 @@ function collectIssuesFromBestPath(unionErrors: Array<{ issues: ZodIssue[] }>): } /** - * Recursively collect ALL discriminator values from all union variants. - * Used when a discriminator is missing to show all valid options. + * Discriminators from outermost to innermost. An `operation` is chosen within a + * `resource`, so a variant for a different resource offers different operations. + */ +const DISCRIMINATOR_ORDER = ['mode', 'resource', 'operation']; + +function discriminatorRank(path: string): number { + return DISCRIMINATOR_ORDER.findIndex((field) => path.endsWith(field)); +} + +/** + * Whether a union variant already failed on a discriminator that outranks `rank`. + * + * When listing the operations valid for `resource: "lead"`, the `account` variant + * describes a different resource, so its operations are not valid answers. Zod + * still reports them, because a failing object surfaces an issue per key. + */ +function failsHigherLevelDiscriminator(issues: ZodIssue[], rank: number): boolean { + return issues.some((iss) => { + if (iss.code !== 'invalid_literal') return false; + const otherRank = discriminatorRank(iss.path.join('.')); + return otherRank !== -1 && otherRank < rank; + }); +} + +/** + * Recursively collect ALL discriminator values from the union variants that are + * reachable given the discriminators already supplied. Used to list valid options + * for a discriminator that is missing or invalid. */ function collectAllDiscriminatorValues( unionErrors: Array<{ issues: ZodIssue[] }>, discriminatorPath: string, ): unknown[] { const values: unknown[] = []; + const rank = discriminatorRank(discriminatorPath); for (const unionError of unionErrors) { + // Nested unions surface the resource literal one level down, so this check + // applies at every level rather than only at the top. + if (rank !== -1 && failsHigherLevelDiscriminator(unionError.issues, rank)) { + continue; + } + for (const iss of unionError.issues) { if (iss.code === 'invalid_literal' && iss.path.join('.') === discriminatorPath) { values.push((iss as { expected?: unknown }).expected); @@ -459,10 +492,14 @@ function extractUnionErrorSummary(unionErrors: Array<{ issues: ZodIssue[] }>): s if (literalIssues.length > 0) { const receivedValue = (literalIssues[0] as { received?: unknown }).received; - // For missing discriminators, collect ALL valid values from ALL variants + // Collect ALL valid values from ALL variants, not just the best-matching + // one — otherwise a node with 12 resources reports only the first. + // The best-path literals are a fallback for schemas whose variants + // don't surface an issue at this exact path. + const allValues = collectAllDiscriminatorValues(unionErrors, path); const expectedValues = - receivedValue === undefined - ? collectAllDiscriminatorValues(unionErrors, path) + allValues.length > 0 + ? allValues : [...new Set(literalIssues.map((i) => (i as { expected?: unknown }).expected))]; const expectedStr = expectedValues.map((v) => `"${String(v)}"`).join(', '); From 1b4c0bab8ea35acf02f521618950f7f73c6e3fda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20G=C3=B3mez=20Morales?= Date: Thu, 13 Aug 2026 13:10:33 +0000 Subject: [PATCH 0006/1458] fix(core): Prefer managed OAuth auth types when the AI builder resolves credentials (#35929) --- .../__tests__/resolve-credentials.test.ts | 123 ++++++++++++++++++ .../tools/workflows/resolve-credentials.ts | 53 +++++++- packages/@n8n/instance-ai/src/types.ts | 4 + .../__tests__/credentials-overwrites.test.ts | 103 +++++++++++++++ packages/cli/src/credentials-overwrites.ts | 26 ++++ .../instance-ai.adapter.service.ts | 13 ++ 6 files changed, 318 insertions(+), 4 deletions(-) diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/resolve-credentials.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/resolve-credentials.test.ts index 3767ee75612..2fd9012bf25 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/resolve-credentials.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/resolve-credentials.test.ts @@ -475,6 +475,129 @@ describe('resolveCredentials', () => { }); }); + describe('managed OAuth auth preference', () => { + function makeNotionNode(withCredentialSlot = true) { + return { + id: '1', + name: 'Notion', + type: 'n8n-nodes-base.notion', + typeVersion: 2, + position: [0, 0] as [number, number], + parameters: { authentication: 'apiKey' }, + ...(withCredentialSlot + ? { credentials: { notionApi: undefined as unknown as { id: string; name: string } } } + : {}), + }; + } + + function mockNotionDescription(ctx: InstanceAiContext) { + (ctx.nodeService as unknown as { getDescription: Mock }).getDescription = vi + .fn() + .mockResolvedValue({ + credentials: [ + { name: 'notionApi', displayOptions: { show: { authentication: ['apiKey'] } } }, + { name: 'notionOAuth2Api', displayOptions: { show: { authentication: ['oAuth2'] } } }, + ], + }); + } + + function setManagedOAuthTypes(ctx: InstanceAiContext, types: string[]) { + ( + ctx.credentialService as unknown as { isManagedOAuthCredentialType: Mock } + ).isManagedOAuthCredentialType = vi.fn( + async (type: string) => await Promise.resolve(types.includes(type)), + ); + } + + it('switches the node auth to a managed-OAuth sibling instead of mocking the written API-key type', async () => { + const json = makeWorkflow({ nodes: [makeNotionNode()] }); + const ctx = createMockContext(); + (ctx.credentialService.list as Mock).mockResolvedValue([]); + mockNotionDescription(ctx); + setManagedOAuthTypes(ctx, ['notionOAuth2Api']); + + const result = await resolveCredentials(json, undefined, ctx); + + // Auth switched so every setup surface derives the OAuth type. + expect(json.nodes[0].parameters).toEqual({ authentication: 'oAuth2' }); + // Nothing is attached — setup asks for the OAuth credential. + expect(json.nodes[0].credentials).toEqual({}); + expect(result.resolvedCredentialsByNode).toEqual({}); + expect(result.mockedNodeNames).toEqual(['Notion']); + expect(result.mockedCredentialTypes).toEqual(['notionOAuth2Api']); + expect(result.mockedCredentialsByNode).toEqual({ Notion: ['notionOAuth2Api'] }); + }); + + it('keeps the written type when the user has stored credentials of it', async () => { + const json = makeWorkflow({ nodes: [makeNotionNode()] }); + const ctx = createMockContext(); + mockNotionDescription(ctx); + setManagedOAuthTypes(ctx, ['notionOAuth2Api']); + // Two stored credentials: the sole-credential fallback doesn't bind, and + // the stored-credential guard must keep the node on the API-key type. + const credentialMap = makeCredentialMap([ + { id: 'cred-1', name: 'Notion account 1', type: 'notionApi' }, + { id: 'cred-2', name: 'Notion account 2', type: 'notionApi' }, + ]); + + const result = await resolveCredentials(json, undefined, ctx, credentialMap); + + expect(json.nodes[0].parameters).toEqual({ authentication: 'apiKey' }); + expect(result.mockedCredentialsByNode).toEqual({ Notion: ['notionApi'] }); + }); + + it('prefers n8n credits over a managed-OAuth sibling', async () => { + const json = makeWorkflow({ nodes: [makeNotionNode()] }); + const ctx = createMockContext(); + (ctx.credentialService.list as Mock).mockResolvedValue([]); + mockNotionDescription(ctx); + setManagedOAuthTypes(ctx, ['notionOAuth2Api']); + ( + ctx.credentialService as unknown as { isAiGatewayCredentialType: Mock } + ).isAiGatewayCredentialType = vi.fn( + async (type: string) => await Promise.resolve(type === 'notionApi'), + ); + + const result = await resolveCredentials(json, undefined, ctx); + + expect(json.nodes[0].credentials).toEqual({ + notionApi: { id: null, name: 'n8n credits', __aiGatewayManaged: true }, + }); + expect(json.nodes[0].parameters).toEqual({ authentication: 'apiKey' }); + expect(result.mockedCredentialsByNode).toEqual({}); + }); + + it('mocks the written type when no managed-OAuth sibling is available', async () => { + // The adapter method is absent (older adapter / self-hosted) — behave as before. + const json = makeWorkflow({ nodes: [makeNotionNode()] }); + const ctx = createMockContext(); + (ctx.credentialService.list as Mock).mockResolvedValue([]); + mockNotionDescription(ctx); + + const result = await resolveCredentials(json, undefined, ctx); + + expect(json.nodes[0].parameters).toEqual({ authentication: 'apiKey' }); + expect(result.mockedCredentialsByNode).toEqual({ Notion: ['notionApi'] }); + }); + + it('switches auth for a credential-less node whose required type has a managed-OAuth sibling', async () => { + // The LLM omitted the credential slot entirely — the second pass still + // prefers the managed-OAuth auth option, without attaching anything. + const json = makeWorkflow({ nodes: [makeNotionNode(false)] }); + const ctx = createMockContext(); + (ctx.credentialService.list as Mock).mockResolvedValue([]); + mockNotionDescription(ctx); + setManagedOAuthTypes(ctx, ['notionOAuth2Api']); + + const result = await resolveCredentials(json, undefined, ctx); + + expect(json.nodes[0].parameters).toEqual({ authentication: 'oAuth2' }); + expect(json.nodes[0].credentials).toBeUndefined(); + expect(result.resolvedCredentialsByNode).toEqual({}); + expect(result.mockedNodeNames).toEqual([]); + }); + }); + describe('existing workflow restoration', () => { it('restores credentials from existing workflow for updates', async () => { const json = makeWorkflow({ diff --git a/packages/@n8n/instance-ai/src/tools/workflows/resolve-credentials.ts b/packages/@n8n/instance-ai/src/tools/workflows/resolve-credentials.ts index fd9df0348ba..c9061f2bbb1 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/resolve-credentials.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/resolve-credentials.ts @@ -167,6 +167,35 @@ export async function resolveCredentials( hasStoredCredential, ); + // Managed OAuth (instance-provided OAuth client) is instance-global config; + // memoize per type for this call. + const managedOAuthCache = new Map(); + const isManagedOAuthType = async (credType: string): Promise => { + if (!ctx.credentialService.isManagedOAuthCredentialType) return false; + const cached = managedOAuthCache.get(credType); + if (cached !== undefined) return cached; + const supported = await ctx.credentialService + .isManagedOAuthCredentialType(credType) + .catch(() => false); + managedOAuthCache.set(credType, supported); + return supported; + }; + + // Fallback for a slot the user would otherwise fill by hand: a sibling + // credential type whose OAuth client the instance provides, so setup offers + // one-click connect instead of an API-key form (INS-973). + const resolveManagedOAuthSiblingType = async ( + node: NodeJSON, + currentType: string, + ): Promise => + await resolveSupportedSiblingCredentialType( + ctx, + node, + currentType, + isManagedOAuthType, + hasStoredCredential, + ); + // Switch the node's parameters so the attached credential type is the active // slot (e.g. set `authentication` to match). No-op when the slot is already // active (never rewrite a valid value) or no switch can reach it (version-gated). @@ -273,10 +302,10 @@ export async function resolveCredentials( return true; }; - const mockCredential = () => { + const mockCredential = (credentialType = key) => { const nodeName = node.name ?? ''; delete creds[key]; - mockedCredentialTypesSet.add(key); + mockedCredentialTypesSet.add(credentialType); nodeMocked = true; if (nodeName) { @@ -284,7 +313,7 @@ export async function resolveCredentials( // simulation plan forces these nodes to `simulate`, so verification // pins them with generated fixtures instead of executing them. mockedCredentialsByNode[nodeName] ??= []; - mockedCredentialsByNode[nodeName].push(key); + mockedCredentialsByNode[nodeName].push(credentialType); } }; @@ -317,6 +346,9 @@ export async function resolveCredentials( // With no stored credential for the type, prefer n8n credits over mocking: // attach directly if the written type is gateway-supported, else attach to // a supported sibling (switching auth to it) and drop the unusable slot. + // With no gateway option either, still prefer a managed-OAuth sibling — + // switch auth to it and mock under that type, so setup asks for one-click + // OAuth instead of an API key. const mockOrAttachGateway = async () => { if (!hasStoredCredential(key)) { if (await isGatewayCredentialType(key)) { @@ -329,6 +361,12 @@ export async function resolveCredentials( await attachGatewayCredential(siblingType); return; } + const managedOAuthSibling = await resolveManagedOAuthSiblingType(node, key); + if (managedOAuthSibling) { + await applyManagedAuth(node, managedOAuthSibling); + mockCredential(managedOAuthSibling); + return; + } } mockCredential(); }; @@ -417,7 +455,14 @@ export async function resolveCredentials( let managedType = credType; if (!(await isGatewayCredentialType(credType))) { const siblingType = await resolveSupportedSiblingType(node, credType); - if (!siblingType) continue; + if (!siblingType) { + // No n8n-credits option — still prefer a managed-OAuth sibling so + // setup offers one-click OAuth instead of an API-key form. Nothing + // is attached; the slot stays open for setup like today. + const managedOAuthSibling = await resolveManagedOAuthSiblingType(node, credType); + if (managedOAuthSibling) await applyManagedAuth(node, managedOAuthSibling); + continue; + } managedType = siblingType; } diff --git a/packages/@n8n/instance-ai/src/types.ts b/packages/@n8n/instance-ai/src/types.ts index 3d44d2c4ef5..45c6a3d270c 100644 --- a/packages/@n8n/instance-ai/src/types.ts +++ b/packages/@n8n/instance-ai/src/types.ts @@ -474,6 +474,10 @@ export interface InstanceAiCredentialService { isAiGatewayCredentialType?(credType: string): Promise; /** List all credential types supported by n8n Connect on this instance. */ listAiGatewayCredentialTypes?(): Promise; + /** Whether the credential type is an OAuth type whose client the instance + * provides via credential overwrites (managed OAuth) — the editor offers + * one-click connect for these instead of an API-key form. */ + isManagedOAuthCredentialType?(credType: string): Promise; } export interface CredentialFieldInfo { diff --git a/packages/cli/src/__tests__/credentials-overwrites.test.ts b/packages/cli/src/__tests__/credentials-overwrites.test.ts index a03064b6a9d..9f30f1d9722 100644 --- a/packages/cli/src/__tests__/credentials-overwrites.test.ts +++ b/packages/cli/src/__tests__/credentials-overwrites.test.ts @@ -1203,4 +1203,107 @@ describe('CredentialsOverwrites', () => { expect(settingsRepository.findByKey).not.toHaveBeenCalled(); }); }); + + describe('isManagedOAuthType', () => { + const oauthCredentialType = mock({ + name: 'notionOAuth2Api', + extends: ['oAuth2Api'], + }); + const baseOAuth2Type = mock({ name: 'oAuth2Api', extends: undefined }); + const oauth1CredentialType = mock({ + name: 'trelloOAuth1Api', + extends: ['oAuth1Api'], + }); + const baseOAuth1Type = mock({ name: 'oAuth1Api', extends: undefined }); + const apiKeyCredentialType = mock({ name: 'notionApi', extends: undefined }); + + const createInstance = async (data: ICredentialsOverwrite) => { + globalConfig.credentials.overwrite.data = JSON.stringify(data); + const instance = new CredentialsOverwrites( + globalConfig, + credentialTypes, + logger, + mock(), + mock(), + ); + await instance.init(); + return instance; + }; + + beforeEach(() => { + credentialTypes.recognizes.mockReturnValue(true); + credentialTypes.getByName.mockImplementation((name) => { + if (name === 'notionOAuth2Api') return oauthCredentialType; + if (name === 'oAuth2Api') return baseOAuth2Type; + if (name === 'trelloOAuth1Api') return oauth1CredentialType; + if (name === 'oAuth1Api') return baseOAuth1Type; + if (name === 'notionApi') return apiKeyCredentialType; + throw new UnrecognizedCredentialTypeError(name); + }); + credentialTypes.getParentTypes.mockImplementation((name) => { + if (name === 'notionOAuth2Api') return ['oAuth2Api']; + if (name === 'trelloOAuth1Api') return ['oAuth1Api']; + return []; + }); + }); + + it('returns true for an OAuth type whose clientId and clientSecret are overwritten', async () => { + const instance = await createInstance({ + notionOAuth2Api: { clientId: 'client-id', clientSecret: 'client-secret' }, + }); + + expect(instance.isManagedOAuthType('notionOAuth2Api')).toBe(true); + }); + + it('returns true for an OAuth1 type whose consumerKey and consumerSecret are overwritten', async () => { + const instance = await createInstance({ + trelloOAuth1Api: { consumerKey: 'consumer-key', consumerSecret: 'consumer-secret' }, + }); + + expect(instance.isManagedOAuthType('trelloOAuth1Api')).toBe(true); + }); + + it('returns false for an OAuth1 type overwritten with the OAuth2 client fields', async () => { + // clientId/clientSecret are meaningless for OAuth1; only consumerKey/consumerSecret count. + const instance = await createInstance({ + trelloOAuth1Api: { clientId: 'client-id', clientSecret: 'client-secret' }, + }); + + expect(instance.isManagedOAuthType('trelloOAuth1Api')).toBe(false); + }); + + it('returns false for a non-OAuth type even when overwritten', async () => { + const instance = await createInstance({ + notionApi: { apiKey: 'key' }, + }); + + expect(instance.isManagedOAuthType('notionApi')).toBe(false); + }); + + it('returns false when the client pair is not fully overwritten', async () => { + const instance = await createInstance({ + notionOAuth2Api: { clientId: 'client-id' }, + }); + + expect(instance.isManagedOAuthType('notionOAuth2Api')).toBe(false); + }); + + it('returns false for skip-list types', async () => { + globalConfig.credentials.overwrite.skipTypes = [ + 'notionOAuth2Api', + ] as unknown as CommaSeparatedStringArray; + const instance = await createInstance({ + notionOAuth2Api: { clientId: 'client-id', clientSecret: 'client-secret' }, + }); + + expect(instance.isManagedOAuthType('notionOAuth2Api')).toBe(false); + }); + + it('returns false for unrecognized types', async () => { + const instance = await createInstance({}); + credentialTypes.recognizes.mockReturnValue(false); + + expect(instance.isManagedOAuthType('somethingUnknown')).toBe(false); + }); + }); }); diff --git a/packages/cli/src/credentials-overwrites.ts b/packages/cli/src/credentials-overwrites.ts index 0c4dcae83c0..6bca4e67b96 100644 --- a/packages/cli/src/credentials-overwrites.ts +++ b/packages/cli/src/credentials-overwrites.ts @@ -230,6 +230,32 @@ export class CredentialsOverwrites { return this.get(type) !== undefined; } + /** + * OAuth credential type whose client this instance provides via overwrites + * (clientId + clientSecret), excluding skip-list types where managed + * creation is disabled. These types support one-click connect in the editor. + */ + isManagedOAuthType(type: string): boolean { + if (!this.credentialTypes.recognizes(type)) return false; + + // OAuth2 identifies its client with clientId/clientSecret, OAuth1 with + // consumerKey/consumerSecret. Detect the version so the right pair is checked + // (checking clientId/clientSecret for both would never match an OAuth1 client). + const parentTypes = this.credentialTypes.getParentTypes(type); + const extendsBase = (base: string) => type === base || parentTypes.includes(base); + const clientFields = extendsBase('oAuth2Api') + ? (['clientId', 'clientSecret'] as const) + : extendsBase('oAuth1Api') + ? (['consumerKey', 'consumerSecret'] as const) + : undefined; + if (!clientFields) return false; + + if (this.globalConfig.credentials.overwrite?.skipTypes?.includes(type)) return false; + + const overwrites = this.get(type); + return !!overwrites && clientFields.every((field) => field in overwrites); + } + usesManagedAuth(type: string, data: Record): boolean { const overwrites = this.get(type); if (overwrites === undefined) return false; diff --git a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts index 0ac474f9ea8..51d793e6136 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts @@ -130,6 +130,7 @@ import { ConflictError } from '@/errors/response-errors/conflict.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; import { CredentialsService } from '@/credentials/credentials.service'; +import { CredentialsOverwrites } from '@/credentials-overwrites'; import { EvaluationConfigService } from '@/evaluation.ee/evaluation-config.service'; import { LlmJudgeProviderRegistry } from '@/evaluation.ee/llm-judge-provider-registry'; import { EventService } from '@/events/event.service'; @@ -1900,6 +1901,18 @@ export class InstanceAiAdapterService { const config = await getGatewayConfig(); return config?.credentialTypes ?? []; }, + + async isManagedOAuthCredentialType(credType: string): Promise { + // Lets resolve-credentials prefer one-click managed OAuth over API-key + // auth types when materializing built workflows. Best-effort: never throws. + try { + return await Promise.resolve( + Container.get(CredentialsOverwrites).isManagedOAuthType(credType), + ); + } catch { + return false; + } + }, }; if (!credentialIdAllowlist) return adapter; From de5ec12fd0fbeab31bde43d5927f406eb70211ae Mon Sep 17 00:00:00 2001 From: Alex Grozav Date: Thu, 13 Aug 2026 13:11:18 +0000 Subject: [PATCH 0007/1458] build(editor): Make frontend-utils htmlUtils loadable as native ESM (no-changelog) (#35622) Co-authored-by: Claude --- packages/frontend/@n8n/frontend-utils/src/htmlUtils.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/frontend/@n8n/frontend-utils/src/htmlUtils.ts b/packages/frontend/@n8n/frontend-utils/src/htmlUtils.ts index 414f1b15520..510042e9b85 100644 --- a/packages/frontend/@n8n/frontend-utils/src/htmlUtils.ts +++ b/packages/frontend/@n8n/frontend-utils/src/htmlUtils.ts @@ -1,5 +1,5 @@ import { toValue, type MaybeRef } from 'vue'; -import xss, { escapeAttrValue, escapeHtml } from 'xss'; +import xss from 'xss'; import { ALLOWED_HTML_ATTRIBUTES, ALLOWED_HTML_TAGS } from './constants/sanitization'; @@ -7,6 +7,13 @@ import { ALLOWED_HTML_ATTRIBUTES, ALLOWED_HTML_TAGS } from './constants/sanitiza Constants and utility functions that help in HTML, CSS and DOM manipulation */ +// `xss` is CJS with no `exports` map, so Node's lexer cannot see these as named +// exports and `import { ... }` throws at link time under native ESM. +const { escapeAttrValue, escapeHtml } = xss as unknown as { + escapeAttrValue: (str: string) => string; + escapeHtml: (str: string) => string; +}; + /** * Escapes HTML entities in a string to prevent HTML injection. * This should be used for user input that will be displayed in HTML contexts. From 547cfb90020535a28d2a3233b2be0d29569a92e1 Mon Sep 17 00:00:00 2001 From: Anne Aguirre Date: Thu, 13 Aug 2026 13:15:36 +0000 Subject: [PATCH 0008/1458] fix(ai-builder): Prevent agent from claiming integration tool call success early (#36196) --- .../integrations/__tests__/integration-tools.test.ts | 12 ++++++++++++ .../integrations/integration-tool-descriptions.ts | 1 + 2 files changed, 13 insertions(+) diff --git a/packages/cli/src/modules/agents/integrations/__tests__/integration-tools.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/integration-tools.test.ts index 23d5f32b2aa..d43919003dc 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/integration-tools.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/integration-tools.test.ts @@ -1248,6 +1248,18 @@ describe('integration tools', () => { }); }); + it('action tool description forbids claiming an action succeeded before the tool call returns', () => { + const tool = createIntegrationActionTool({ + descriptor: getIntegrationToolConnectionDescriptors([slackA])[0], + messageContextStore: mock(), + actionExecutor: mock(), + }).build(); + + expect(tool.description).toContain( + 'Never state that an action has been performed before its tool call returns', + ); + }); + it('action tool preserves the current subject when updating message context', async () => { const messageContextStore = mock(); messageContextStore.getLatest.mockResolvedValue({ diff --git a/packages/cli/src/modules/agents/integrations/integration-tool-descriptions.ts b/packages/cli/src/modules/agents/integrations/integration-tool-descriptions.ts index 25b7dd5e348..4f02230ac67 100644 --- a/packages/cli/src/modules/agents/integrations/integration-tool-descriptions.ts +++ b/packages/cli/src/modules/agents/integrations/integration-tool-descriptions.ts @@ -26,6 +26,7 @@ export function buildActionToolDescription( ...descriptor.actionToolDefinitions.map((definition) => `- ${definition.description}`), `Batch form: pass actions as an array of up to ${MAX_BATCH_OPERATIONS} { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.`, 'respond uses the latest message context for this integration connection.', + 'Never state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.', 'If this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.', ...(descriptor.actions.includes('do_not_respond') ? [ From d0424b444f6c51cfb322eafca69ac0f0d44ea062 Mon Sep 17 00:00:00 2001 From: Anne Aguirre Date: Thu, 13 Aug 2026 13:15:36 +0000 Subject: [PATCH 0009/1458] feat(ai-builder): Add MCP server as allowed tools in skills (#35969) --- .../useAgentCapabilitiesActions.test.ts | 57 ++++++++++++++++++- .../useAgentCapabilitiesActions.ts | 9 +++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentCapabilitiesActions.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentCapabilitiesActions.test.ts index 6b9b03770f1..a717a1fb5b7 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentCapabilitiesActions.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentCapabilitiesActions.test.ts @@ -11,6 +11,7 @@ import { createAgentSkill } from '../composables/useAgentApi'; import type { AgentJsonConfig, AgentJsonMcpServerConfig, + AgentJsonToolConfig, AgentResource, AgentSkill, } from '../types'; @@ -134,6 +135,7 @@ describe('useAgentCapabilitiesActions — tools modal host seam', () => { type SkillModalData = { skillId?: string; existingSkillNames?: string[]; + availableTools?: Array<{ name: string; label: string; icon?: string }>; onConfirm: (payload: { id?: string; skill: AgentSkill }) => void; onRemove?: (skillId: string) => void; }; @@ -150,6 +152,8 @@ describe('useAgentCapabilitiesActions — localSkills host seam', () => { hostId?: ReturnType>; skillRefs?: AgentJsonConfig['skills']; bodies?: Record; + tools?: AgentJsonConfig['tools']; + mcpServers?: AgentJsonConfig['mcpServers']; } = {}, ) { setActivePinia(createTestingPinia({ stubActions: false })); @@ -157,7 +161,11 @@ describe('useAgentCapabilitiesActions — localSkills host seam', () => { const hostId = options.hostId ?? ref('inline:node-1'); const localConfig = ref( - makeConfig({ skills: options.skillRefs ?? [{ type: 'skill', id: 'skill_triage' }] }), + makeConfig({ + skills: options.skillRefs ?? [{ type: 'skill', id: 'skill_triage' }], + ...(options.tools ? { tools: options.tools } : {}), + ...(options.mcpServers ? { mcpServers: options.mcpServers } : {}), + }), ); const scheduleConfigUpdate = vi.fn(); const createSkill = vi.fn(); @@ -250,6 +258,53 @@ describe('useAgentCapabilitiesActions — localSkills host seam', () => { ); }); + it('includes MCP server names in skill availableTools', () => { + const { uiStore, actions } = setupLocal({ + tools: [{ type: 'node', name: 'darwin' } as AgentJsonToolConfig], + mcpServers: [ + { + name: 'Notion mcp', + url: 'https://mcp.notion.example', + transport: 'streamableHttp', + authentication: 'none', + }, + ], + }); + + actions.onOpenAddSkillModal(); + const modalData = uiStore.modalsById[AGENT_SKILL_MODAL_KEY].data as unknown as SkillModalData; + + expect(modalData.availableTools).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'darwin', icon: 'globe' }), + expect.objectContaining({ name: 'Notion mcp', icon: 'mcp' }), + ]), + ); + }); + + it('keeps MCP server names in allowedTools on persist', () => { + const { uiStore, actions, createSkill } = setupLocal({ + mcpServers: [ + { + name: 'Notion mcp', + url: 'https://mcp.notion.example', + transport: 'streamableHttp', + authentication: 'none', + }, + ], + }); + + actions.onOpenAddSkillModal(); + const modalData = uiStore.modalsById[AGENT_SKILL_MODAL_KEY].data as unknown as SkillModalData; + modalData.onConfirm({ + skill: { ...triage, name: 'Second Skill', allowedTools: ['Notion mcp'] }, + }); + + expect(createSkill).toHaveBeenCalledWith( + expect.objectContaining({ allowedTools: ['Notion mcp'] }), + ); + }); + it('rejects an add whose name duplicates an applied skill (case-insensitive, trimmed)', () => { const { uiStore, actions, createSkill } = setupLocal(); diff --git a/packages/frontend/editor-ui/src/features/agents/composables/useAgentCapabilitiesActions.ts b/packages/frontend/editor-ui/src/features/agents/composables/useAgentCapabilitiesActions.ts index 75b2aacb6de..bb4c17061e7 100644 --- a/packages/frontend/editor-ui/src/features/agents/composables/useAgentCapabilitiesActions.ts +++ b/packages/frontend/editor-ui/src/features/agents/composables/useAgentCapabilitiesActions.ts @@ -333,6 +333,15 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep } } + for (const server of localConfig.value?.mcpServers ?? []) { + if (!server.name) continue; + tools.push({ + name: server.name, + label: formatToolNameForDisplay(server.name) || server.name, + icon: 'mcp', + }); + } + return tools; } From 36f2b68c8b6f125eb479a9b815f7c006e483af85 Mon Sep 17 00:00:00 2001 From: Mas <7367872+mxhifi@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:33:02 +0000 Subject: [PATCH 0010/1458] fix(editor): Refresh chat history when switching executions (#35312) Co-authored-by: Cursor Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> --- .../execution/logs/components/LogsPanel.vue | 2 +- ...n-host-POST-_v1_messages-hey-a1b2c3d4.json | 120 ++++++++++++++++++ .../workflows/editor/execution/logs.spec.ts | 44 +++++++ 3 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 packages/testing/playwright/expectations/execution.logs/1777281716392-unknown-host-POST-_v1_messages-hey-a1b2c3d4.json diff --git a/packages/frontend/editor-ui/src/features/execution/logs/components/LogsPanel.vue b/packages/frontend/editor-ui/src/features/execution/logs/components/LogsPanel.vue index 9edfc1ef65d..482619646bd 100644 --- a/packages/frontend/editor-ui/src/features/execution/logs/components/LogsPanel.vue +++ b/packages/frontend/editor-ui/src/features/execution/logs/components/LogsPanel.vue @@ -183,7 +183,7 @@ function handleChangeOutputTableColumnCollapsing(columnName: string | null) { @resizeend="onChatPanelResizeEnd" > { + await setupRequirements({ workflow: 'Workflow_ai_agent.json' }); + + await n8n.canvas.clickZoomToFitButton(); + await n8n.canvas.openNode('E2E Chat Model'); + await expect(n8n.ndv.getCredentialSelect()).toHaveValue(anthropicCredential.name); + await n8n.ndv.close(); + await n8n.canvas.logsPanel.open(); + + // Prompts must match MockServer expectations under expectations/execution.logs + // (strictBodyMatching). Use refreshSession between turns so each Anthropic + // request stays single-message and matches a recorded expectation. + await n8n.canvas.logsPanel.sendManualChatMessage('Hi!'); + await expect(n8n.canvas.logsPanel.getManualChatMessages().nth(0)).toContainText('Hi!'); + await expect(n8n.canvas.logsPanel.getManualChatMessages().nth(1)).toContainText('Hello'); + + await n8n.canvas.logsPanel.refreshSession(); + await expect(n8n.canvas.logsPanel.getManualChatMessages()).not.toBeAttached(); + + await n8n.canvas.logsPanel.sendManualChatMessage('Hey!'); + await expect(n8n.canvas.logsPanel.getManualChatMessages().nth(0)).toContainText('Hey!'); + await expect(n8n.canvas.logsPanel.getManualChatMessages().nth(1)).toContainText('Hello'); + + await n8n.canvas.openExecutions(); + await n8n.executions.getAutoRefreshButton().click(); + await expect(n8n.executions.getExecutionItems()).toHaveCount(2); + + // Newest execution first + await n8n.executions.getExecutionItems().nth(0).click(); + await expect(n8n.executions.logsPanel.getManualChatMessages().nth(0)).toContainText('Hey!'); + await expect(n8n.executions.logsPanel.getManualChatMessages().nth(1)).toContainText('Hello'); + + await n8n.executions.getExecutionItems().nth(1).click(); + await expect(n8n.executions.logsPanel.getManualChatMessages().nth(0)).toContainText('Hi!'); + await expect(n8n.executions.logsPanel.getManualChatMessages().nth(1)).toContainText('Hello'); + + await n8n.executions.getExecutionItems().nth(0).click(); + await expect(n8n.executions.logsPanel.getManualChatMessages().nth(0)).toContainText('Hey!'); + }); + test('should show logs for a workflow with a node that waits for webhook', async ({ n8n, api, From 2d37afc13dacd37df6b0c36cf9011c0fae0a97f1 Mon Sep 17 00:00:00 2001 From: Rodrigo Santos da Silva Date: Thu, 13 Aug 2026 13:33:11 +0000 Subject: [PATCH 0011/1458] test(ai-builder): Let a case declare a credential already broken before the conversation started (no-changelog) (#36029) Co-authored-by: Claude Sonnet 5 --- .../build-workflow-seed-cleanup.test.ts | 24 +++++++++++++++++++ .../evaluations/harness/build-workflow.ts | 15 ++++++++++-- .../instance-ai/evaluations/harness/schema.ts | 1 + .../@n8n/instance-ai/evaluations/types.ts | 6 +++++ 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/@n8n/instance-ai/evaluations/__tests__/build-workflow-seed-cleanup.test.ts b/packages/@n8n/instance-ai/evaluations/__tests__/build-workflow-seed-cleanup.test.ts index eafc7dd56c7..860b9881342 100644 --- a/packages/@n8n/instance-ai/evaluations/__tests__/build-workflow-seed-cleanup.test.ts +++ b/packages/@n8n/instance-ai/evaluations/__tests__/build-workflow-seed-cleanup.test.ts @@ -153,4 +153,28 @@ describe('buildWorkflow declared credentials', () => { ['cred-seeded'], ); }); + + it('filters an already-broken credential out of the connection-test bypass list', async () => { + const setThreadCredentialAllowlist = vi.fn().mockResolvedValue(undefined); + const createCredential = vi + .fn() + .mockResolvedValueOnce({ id: 'cred-working' }) + .mockResolvedValueOnce({ id: 'cred-broken' }); + const client = makeClient({ setThreadCredentialAllowlist, createCredential }); + + const build = await buildWorkflow({ + client, + ...baseConfig, + credentials: [{ type: 'slackApi' }, { type: 'notionApi', valid: false }], + }); + + expect(build.success).toBe(true); + // Both credentials are created for real and visible to the build (2nd arg) — + // only the one NOT marked already-broken bypasses its connection test (3rd arg). + expect(setThreadCredentialAllowlist).toHaveBeenCalledWith( + expect.any(String), + ['cred-working', 'cred-broken'], + ['cred-working'], + ); + }); }); diff --git a/packages/@n8n/instance-ai/evaluations/harness/build-workflow.ts b/packages/@n8n/instance-ai/evaluations/harness/build-workflow.ts index bb56da4fe81..70fb21298b7 100644 --- a/packages/@n8n/instance-ai/evaluations/harness/build-workflow.ts +++ b/packages/@n8n/instance-ai/evaluations/harness/build-workflow.ts @@ -425,11 +425,22 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise c.id); + // `createDeclaredCredentials` returns one entry per `declaredCredentials`, in + // the same order — index-zip to find which seeded ids the case marked + // already-broken (`valid: false`) and must NOT bypass, so their real + // connection test runs and fails. + const bypassCredentialTestIds = createdCredentials + .filter((_, i) => declaredCredentials[i]?.valid !== false) + .map((c) => c.id); try { // A seeded credential models one the user already has connected, so its // connection test resolves as passing — same as one set up on a card // during the run. Both carry a placeholder token that would really fail. - await client.setThreadCredentialAllowlist(threadId, seededCredentialIds, seededCredentialIds); + await client.setThreadCredentialAllowlist( + threadId, + seededCredentialIds, + bypassCredentialTestIds, + ); } catch (error: unknown) { // Only a missing endpoint (older backend) may degrade to the legacy // unpinned view, and only for cases that declared nothing — any other @@ -598,7 +609,7 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise Date: Thu, 13 Aug 2026 13:36:06 +0000 Subject: [PATCH 0012/1458] feat(core): Enable the UI to call the public API (no-changelog) (#35692) --- packages/@n8n/constants/src/browser.ts | 17 +++ packages/cli/src/config/index.ts | 1 - .../src/controllers/api-keys.controller.ts | 18 +-- .../__tests__/http-error-classifier.test.ts | 24 +++- .../cli/src/errors/http-error-classifier.ts | 18 ++- .../mcp-api-key.service.integration.test.ts | 15 ++- ...ce.public-api-disabled.integration.test.ts | 2 +- packages/cli/src/public-api/index.ts | 16 ++- .../public-api-error-response.test.ts | 13 ++- packages/cli/src/public-api/v1/openapi.yml | 6 + .../v1/public-api-error-response.ts | 9 +- .../risk-reporters/instance-risk-reporter.ts | 4 +- packages/cli/src/server.ts | 32 +++--- .../api-key-auth.strategy.integration.test.ts | 16 +++ .../__tests__/frontend.service.test.ts | 5 +- ...n-cookie-auth.strategy.integration.test.ts | 103 ++++++++++++++++++ .../cli/src/services/api-key-auth.strategy.ts | 6 + packages/cli/src/services/frontend.service.ts | 4 +- .../services/session-cookie-auth.strategy.ts | 68 ++++++++++++ .../integration/public-api/insights.test.ts | 42 +++++++ .../instance-risk-reporter.test.ts | 2 +- packages/cli/test/integration/shared/types.ts | 1 + .../integration/shared/utils/test-server.ts | 19 ++++ .../@n8n/rest-api-client/src/utils.ts | 11 +- .../@n8n/stores/src/settings.store.test.ts | 11 ++ .../@n8n/stores/src/settings.store.ts | 3 + .../frontend/@n8n/stores/src/useRootStore.ts | 12 ++ 27 files changed, 426 insertions(+), 52 deletions(-) create mode 100644 packages/cli/src/services/__tests__/session-cookie-auth.strategy.integration.test.ts create mode 100644 packages/cli/src/services/session-cookie-auth.strategy.ts diff --git a/packages/@n8n/constants/src/browser.ts b/packages/@n8n/constants/src/browser.ts index 0fae7238d33..ffeb4172818 100644 --- a/packages/@n8n/constants/src/browser.ts +++ b/packages/@n8n/constants/src/browser.ts @@ -1 +1,18 @@ +/** `localStorage` key the per-browser identifier is persisted under. */ export const BROWSER_ID_STORAGE_KEY = 'n8n-browserId'; + +/** + * Returns a stable per-browser identifier, generating and persisting one to + * `localStorage` on first use. Sent as the `browser-id` header on authenticated + * requests so the backend can bind session cookies to the browser that + * requested them (see `AuthService.validateBrowserId`), preventing a stolen + * cookie from being replayed from a different client. + */ +export function getBrowserId(): string { + let browserId = localStorage.getItem(BROWSER_ID_STORAGE_KEY); + if (!browserId) { + browserId = crypto.randomUUID(); + localStorage.setItem(BROWSER_ID_STORAGE_KEY, browserId); + } + return browserId; +} diff --git a/packages/cli/src/config/index.ts b/packages/cli/src/config/index.ts index 353ffa8b50d..94a7ef08e1a 100644 --- a/packages/cli/src/config/index.ts +++ b/packages/cli/src/config/index.ts @@ -19,7 +19,6 @@ if (inE2ETests) { process.env.N8N_AI_ENABLED = 'true'; } else if (inTest) { globalConfig.logging.level = 'silent'; - globalConfig.publicApi.disabled = true; process.env.SKIP_STATISTICS_EVENTS = 'true'; globalConfig.auth.cookie.secure = false; process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK = 'false'; diff --git a/packages/cli/src/controllers/api-keys.controller.ts b/packages/cli/src/controllers/api-keys.controller.ts index d3611a4cfc9..f788cf96fd2 100644 --- a/packages/cli/src/controllers/api-keys.controller.ts +++ b/packages/cli/src/controllers/api-keys.controller.ts @@ -20,11 +20,11 @@ import type { RequestHandler } from 'express'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { EventService } from '@/events/event.service'; -import { isApiEnabled } from '@/public-api'; +import { isApiKeyAuthEnabled } from '@/public-api'; import { PublicApiKeyService } from '@/services/public-api-key.service'; -export const isApiEnabledMiddleware: RequestHandler = (_, res, next) => { - if (isApiEnabled()) { +export const isApiKeyAuthEnabledMiddleware: RequestHandler = (_, res, next) => { + if (isApiKeyAuthEnabled()) { next(); } else { res.status(404).end(); @@ -39,7 +39,7 @@ export class ApiKeysController { ) {} @GlobalScope('apiKey:create') - @Post('/', { middlewares: [isApiEnabledMiddleware] }) + @Post('/', { middlewares: [isApiKeyAuthEnabledMiddleware] }) async createApiKey( req: AuthenticatedRequest, _res: Response, @@ -64,7 +64,7 @@ export class ApiKeysController { // Every authenticated user may list their own keys. The service only // includes other users' keys for `apiKey:manage` callers; `ownership=mine` // narrows back to own. - @Get('/', { middlewares: [isApiEnabledMiddleware] }) + @Get('/', { middlewares: [isApiKeyAuthEnabledMiddleware] }) async getApiKeys(req: AuthenticatedRequest, _res: Response, @Query query: ListApiKeysQueryDto) { return await this.publicApiKeyService.getRedactedApiKeys(req.user, { take: query.take, @@ -78,7 +78,7 @@ export class ApiKeysController { // No role scope required: own keys are always revocable. The service // restricts deleting other users' keys to `apiKey:manage` holders. - @Delete('/:id', { middlewares: [isApiEnabledMiddleware] }) + @Delete('/:id', { middlewares: [isApiKeyAuthEnabledMiddleware] }) async deleteApiKey(req: AuthenticatedRequest, _res: Response, @Param('id') apiKeyId: string) { const { isOwn } = await this.publicApiKeyService.deleteApiKey(req.user, apiKeyId); @@ -93,7 +93,7 @@ export class ApiKeysController { // Owner-only — `apiKey:manage` doesn't extend to editing someone else's key. @GlobalScope('apiKey:update') - @Patch('/:id', { middlewares: [isApiEnabledMiddleware] }) + @Patch('/:id', { middlewares: [isApiKeyAuthEnabledMiddleware] }) async updateApiKey( req: AuthenticatedRequest, _res: Response, @@ -111,7 +111,7 @@ export class ApiKeysController { // Owner-only — re-issues the secret in place, keeping label, scopes and expiry. @GlobalScope('apiKey:update') - @Post('/:id/rotate', { middlewares: [isApiEnabledMiddleware] }) + @Post('/:id/rotate', { middlewares: [isApiKeyAuthEnabledMiddleware] }) async rotateApiKey(req: AuthenticatedRequest, _res: Response, @Param('id') apiKeyId: string) { const rotatedApiKey = await this.publicApiKeyService.rotateApiKey(req.user, apiKeyId); @@ -127,7 +127,7 @@ export class ApiKeysController { // No role scope required: returns the scopes the caller's role can assign // to a key — empty-ish for roles without apiKey grants. - @Get('/scopes', { middlewares: [isApiEnabledMiddleware] }) + @Get('/scopes', { middlewares: [isApiKeyAuthEnabledMiddleware] }) async getApiKeyScopes(req: AuthenticatedRequest, _res: Response) { const scopes = getApiKeyScopesForRole(req.user); return scopes; diff --git a/packages/cli/src/errors/__tests__/http-error-classifier.test.ts b/packages/cli/src/errors/__tests__/http-error-classifier.test.ts index b584a575d0b..69c8bb0c91c 100644 --- a/packages/cli/src/errors/__tests__/http-error-classifier.test.ts +++ b/packages/cli/src/errors/__tests__/http-error-classifier.test.ts @@ -1,4 +1,4 @@ -import { BadRequest } from 'express-openapi-validator/dist/framework/types'; +import { BadRequest, Unauthorized } from 'express-openapi-validator/dist/framework/types'; import { OperationalError, UnexpectedError, UserError } from 'n8n-workflow'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; @@ -52,6 +52,28 @@ describe('classifyHttpError', () => { }); }); + describe('Unauthorized', () => { + it('keeps the express-openapi-validator message when no session cookie was sent', () => { + const err = new Unauthorized({ path: '/x', message: "'X-N8N-API-KEY' header required" }); + const d = classifyHttpError(err); + expect(d).toEqual({ + kind: HttpErrorKind.httpError, + status: 401, + message: "'X-N8N-API-KEY' header required", + }); + }); + + it('replaces the api key hint with a generic message when a session cookie was sent', () => { + const err = new Unauthorized({ path: '/x', message: "'X-N8N-API-KEY' header required" }); + const d = classifyHttpError(err, { hasSessionCookie: true }); + expect(d).toEqual({ + kind: HttpErrorKind.httpError, + status: 401, + message: 'Unauthorized', + }); + }); + }); + it('tags plain Error as serverError', () => { const d = classifyHttpError(new Error('plain')); expect(d).toEqual({ diff --git a/packages/cli/src/errors/http-error-classifier.ts b/packages/cli/src/errors/http-error-classifier.ts index 9fbe3d9eb8c..d80dff32da2 100644 --- a/packages/cli/src/errors/http-error-classifier.ts +++ b/packages/cli/src/errors/http-error-classifier.ts @@ -1,4 +1,4 @@ -import { HttpError } from 'express-openapi-validator/dist/framework/types'; +import { HttpError, Unauthorized } from 'express-openapi-validator/dist/framework/types'; import { UnexpectedError, UserError } from 'n8n-workflow'; import { ResponseError } from '@/errors/response-errors/abstract/response.error'; @@ -55,7 +55,15 @@ export function isResponseError(error: Error): error is ResponseError { return false; } -export function classifyHttpError(error: Error): HttpErrorDescriptor { +export type HttpErrorClassifierContext = { + /** Whether the request carried a session cookie, regardless of whether it was valid. */ + hasSessionCookie?: boolean; +}; + +export function classifyHttpError( + error: Error, + context?: HttpErrorClassifierContext, +): HttpErrorDescriptor { if (isResponseError(error)) { const descriptor: HttpErrorDescriptor & { kind: HttpErrorKind.responseError } = { kind: HttpErrorKind.responseError, @@ -80,6 +88,12 @@ export function classifyHttpError(error: Error): HttpErrorDescriptor { return { kind: HttpErrorKind.unexpectedError, message: error.message }; } + // Replacing express-openapi-validator's 'api key header required' error message + // for requests that contained a session cookie. + if (error instanceof Unauthorized && context?.hasSessionCookie) { + return { kind: HttpErrorKind.httpError, status: 401, message: 'Unauthorized' }; + } + if (error instanceof HttpError) { return { kind: HttpErrorKind.httpError, diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-api-key.service.integration.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-api-key.service.integration.test.ts index a768ffa38e1..5bb79652aea 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp-api-key.service.integration.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp-api-key.service.integration.test.ts @@ -4,11 +4,13 @@ import { ApiKey, ApiKeyRepository, UserRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import { randomString } from 'n8n-workflow'; +import { AuthService } from '@/auth/auth.service'; import { ScopedJwtStrategy } from '@/modules/token-exchange/services/scoped-jwt.strategy'; import { TOKEN_EXCHANGE_ISSUER } from '@/modules/token-exchange/token-exchange.types'; import { ApiKeyAuthStrategy } from '@/services/api-key-auth.strategy'; import { AuthStrategyRegistry } from '@/services/auth-strategy.registry'; import { JwtService } from '@/services/jwt.service'; +import { SessionCookieAuthStrategy } from '@/services/session-cookie-auth.strategy'; import { createMember, createOwner, createOwnerWithApiKey } from '@test-integration/db/users'; import { McpServerApiKeyService } from '../mcp-api-key.service'; @@ -40,10 +42,11 @@ describe('McpServerApiKeyService.verifyApiKey (integration)', () => { beforeAll(async () => { await testDb.init(); - // Mirror the registration order set up by public API + token-exchange module init. + // Mirror the registration order set up by server.ts + token-exchange module init. const registry = Container.get(AuthStrategyRegistry); jwtService = Container.get(JwtService); registry.register(Container.get(ApiKeyAuthStrategy)); + registry.register(Container.get(SessionCookieAuthStrategy)); registry.register(Container.get(ScopedJwtStrategy)); service = Container.get(McpServerApiKeyService); @@ -140,6 +143,16 @@ describe('McpServerApiKeyService.verifyApiKey (integration)', () => { name: 'scoped JWT whose subject is not in the database', token: () => makeScopedJwt(jwtService, '00000000-0000-0000-0000-000000000000'), }, + { + // SessionCookieAuthStrategy is registered into the same registry above, + // so this proves its buildTokenGrant genuinely abstains rather than the + // audience check merely never encountering the strategy. + name: 'a valid browser session cookie (must not satisfy the mcp-server-api audience)', + token: async () => { + const owner = await createOwner(); + return Container.get(AuthService).issueJWT(owner, false); + }, + }, ])('rejects $name', async ({ token }) => { const result = await service.verifyApiKey(await token()); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-api-key.service.public-api-disabled.integration.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-api-key.service.public-api-disabled.integration.test.ts index ae7345460c0..e12679242ae 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp-api-key.service.public-api-disabled.integration.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp-api-key.service.public-api-disabled.integration.test.ts @@ -8,7 +8,7 @@ import { McpServerApiKeyService } from '../mcp-api-key.service'; // Regression guard for IAM-574: ApiKeyAuthStrategy must be registered regardless // of whether the public API is enabled, otherwise MCP API keys stop working on // instances that disable the public REST API. Omitting 'publicApi' from -// endpointGroups mirrors a production instance where isApiEnabled() returns +// endpointGroups mirrors a production instance where isApiKeyAuthEnabled() returns // false — the MCP endpoint group is still set up so we can exercise the real // strategy-registration wiring. // Loading the MCP module plus DB init can exceed the default 30s hook timeout diff --git a/packages/cli/src/public-api/index.ts b/packages/cli/src/public-api/index.ts index edeb9025cfb..35569de2b40 100644 --- a/packages/cli/src/public-api/index.ts +++ b/packages/cli/src/public-api/index.ts @@ -13,6 +13,7 @@ import validator from 'validator'; import { PublicApiControllerRegistry } from './public-api-controller.registry'; import { sendPublicApiErrorResponse } from './v1/public-api-error-response'; +import { AUTH_COOKIE_NAME } from '@/constants'; import { EventService } from '@/events/event.service'; import { License } from '@/license'; import { createN8nPackageMulterOptions } from '@/modules/n8n-packages/utils/import-package-upload'; @@ -321,6 +322,7 @@ function createLazyValidatorMiddleware( handlers: { ApiKeyAuth: authenticate, BearerAuth: authenticate, + CookieAuth: authenticate, }, }, }), @@ -379,11 +381,13 @@ function createApiRouter( const publicApiErrorHandler: ErrorRequestHandler = ( error: Error, - _req: express.Request, + req: express.Request, res: express.Response, _next: express.NextFunction, ) => { - sendPublicApiErrorResponse(res, error); + sendPublicApiErrorResponse(res, error, { + hasSessionCookie: Boolean(req.cookies?.[AUTH_COOKIE_NAME]), + }); }; apiController.use(publicApiErrorHandler); @@ -410,6 +414,12 @@ export const loadPublicApiVersions = async ( }; }; -export function isApiEnabled(): boolean { +/** + * Whether API-key (token) based authentication is accepted on the public API. + * Public API routes are always registered regardless of this flag — the UI + * authenticates against them with the user's session cookie instead, which + * must keep working even when token-based access is disabled. + */ +export function isApiKeyAuthEnabled(): boolean { return !Container.get(GlobalConfig).publicApi.disabled && !Container.get(License).isAPIDisabled(); } diff --git a/packages/cli/src/public-api/v1/__tests__/public-api-error-response.test.ts b/packages/cli/src/public-api/v1/__tests__/public-api-error-response.test.ts index b2c5554a82b..5bd30ffb371 100644 --- a/packages/cli/src/public-api/v1/__tests__/public-api-error-response.test.ts +++ b/packages/cli/src/public-api/v1/__tests__/public-api-error-response.test.ts @@ -1,5 +1,5 @@ import type { Response } from 'express'; -import { BadRequest } from 'express-openapi-validator/dist/framework/types'; +import { BadRequest, Unauthorized } from 'express-openapi-validator/dist/framework/types'; import { UnexpectedError, UserError, OperationalError } from 'n8n-workflow'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; @@ -96,6 +96,17 @@ describe('sendPublicApiErrorResponse', () => { expect(res._payload.body).toEqual({ message: 'schema failed' }); }); + it('forwards classifier context so session-cookie Unauthorized is serialized as a generic 401', () => { + const res = createMockRes(); + const err = new Unauthorized({ + path: '/api/v1/insights/summary', + message: "'X-N8N-API-KEY' header required", + }); + sendPublicApiErrorResponse(res, err, { hasSessionCookie: true }); + expect(res._payload.statusCode).toBe(401); + expect(res._payload.body).toEqual({ message: 'Unauthorized' }); + }); + it('maps unknown errors to 500 with a generic message', () => { const res = createMockRes(); sendPublicApiErrorResponse(res, new Error('plain')); diff --git a/packages/cli/src/public-api/v1/openapi.yml b/packages/cli/src/public-api/v1/openapi.yml index 2c9f9743ca2..943e9c7f3bc 100644 --- a/packages/cli/src/public-api/v1/openapi.yml +++ b/packages/cli/src/public-api/v1/openapi.yml @@ -221,7 +221,13 @@ components: type: http scheme: bearer bearerFormat: JWT + CookieAuth: + type: apiKey + in: cookie + # Must match AUTH_COOKIE_NAME in packages/cli/src/constants.ts + name: n8n-auth security: - ApiKeyAuth: [] - BearerAuth: [] + - CookieAuth: [] diff --git a/packages/cli/src/public-api/v1/public-api-error-response.ts b/packages/cli/src/public-api/v1/public-api-error-response.ts index 4d0fda96b09..77c430ccf67 100644 --- a/packages/cli/src/public-api/v1/public-api-error-response.ts +++ b/packages/cli/src/public-api/v1/public-api-error-response.ts @@ -1,5 +1,6 @@ import type { Response } from 'express'; +import type { HttpErrorClassifierContext } from '@/errors/http-error-classifier'; import { classifyHttpError } from '@/errors/http-error-classifier'; import { serializePublicApiError } from '@/errors/http-error-serializers'; @@ -7,8 +8,12 @@ import { serializePublicApiError } from '@/errors/http-error-serializers'; * Maps errors from the public API stack to HTTP responses. Used by the * Express error middleware in `index.ts`. */ -export function sendPublicApiErrorResponse(res: Response, error: Error): void { - const descriptor = classifyHttpError(error); +export function sendPublicApiErrorResponse( + res: Response, + error: Error, + context?: HttpErrorClassifierContext, +): void { + const descriptor = classifyHttpError(error, context); const { status, body } = serializePublicApiError(descriptor); res.status(status).json(body); } diff --git a/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts b/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts index 2c73a0e1acb..02792f394cf 100644 --- a/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts +++ b/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts @@ -9,7 +9,7 @@ import type { IWorkflowBase } from 'n8n-workflow'; import { N8N_VERSION } from '@/constants'; import { CommunityPackagesConfig } from '@/modules/community-packages/community-packages.config'; -import { isApiEnabled } from '@/public-api'; +import { isApiKeyAuthEnabled } from '@/public-api'; import { ENV_VARS_DOCS_URL, INSTANCE_REPORT, @@ -103,7 +103,7 @@ export class InstanceRiskReporter implements RiskReporter { communityPackagesEnabled: Container.get(CommunityPackagesConfig).enabled, versionNotificationsEnabled: this.globalConfig.versionNotifications.enabled, templatesEnabled: this.globalConfig.templates.enabled, - publicApiEnabled: isApiEnabled(), + publicApiEnabled: isApiKeyAuthEnabled(), }; const { exclude, include } = this.globalConfig.nodes; diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index 59d1824d4a3..48f907d6fde 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -25,7 +25,7 @@ import type { ICredentialsOverwrite } from '@/interfaces'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { handleMfaDisable, isMfaFeatureEnabled } from '@/mfa/helpers'; import { PostHogClient } from '@/posthog'; -import { isApiEnabled, loadPublicApiVersions } from '@/public-api'; +import { loadPublicApiVersions } from '@/public-api'; import { Push } from '@/push'; import * as ResponseHelper from '@/response-helper'; import type { FrontendService } from '@/services/frontend.service'; @@ -77,6 +77,7 @@ import { BrowserUseServer } from './modules/instance-ai/browser/browser-use-serv import { PubSubRegistry } from './scaling/pubsub/pubsub.registry'; import { ApiKeyAuthStrategy } from './services/api-key-auth.strategy'; import { AuthStrategyRegistry } from './services/auth-strategy.registry'; +import { SessionCookieAuthStrategy } from './services/session-cookie-auth.strategy'; @Service() export class Server extends AbstractServer { @@ -184,22 +185,17 @@ export class Server extends AbstractServer { // Register auth strategies in priority order. The registry evaluates them // sequentially — the first strategy that returns a non-null result wins. // API key auth is registered first so existing behavior is preserved. + // Session cookie auth is registered last: an explicit but wrong API + // key/bearer token fails fast rather than silently falling back to an + // ambient browser session cookie. // Additional strategies (e.g. scoped JWT from the token-exchange module) // can be appended later during their own module initialization. const registry = Container.get(AuthStrategyRegistry); registry.register(Container.get(ApiKeyAuthStrategy)); + registry.register(Container.get(SessionCookieAuthStrategy)); - // ---------------------------------------- - // Public API - // ---------------------------------------- - - if (isApiEnabled()) { - const { apiRouters, apiLatestVersion } = await loadPublicApiVersions(publicApiEndpoint); - this.app.use(...apiRouters); - if (frontendService) { - (await frontendService.getSettings()).publicApi.latestVersion = apiLatestVersion; - } - } + // Parse cookies for easier access + this.app.use(cookieParser()); // Extract BrowserId from headers this.app.use((req: APIRequest, _, next) => { @@ -207,8 +203,15 @@ export class Server extends AbstractServer { next(); }); - // Parse cookies for easier access - this.app.use(cookieParser()); + // ---------------------------------------- + // Public API + // ---------------------------------------- + + const { apiRouters, apiLatestVersion } = await loadPublicApiVersions(publicApiEndpoint); + this.app.use(...apiRouters); + if (frontendService) { + (await frontendService.getSettings()).publicApi.latestVersion = apiLatestVersion; + } const { restEndpoint, app } = this; @@ -437,7 +440,6 @@ export class Server extends AbstractServer { 'e2e', this.restEndpoint, this.endpointPresetCredentials, - isApiEnabled() ? '' : publicApiEndpoint, ...this.globalConfig.endpoints.additionalNonUIRoutes.split(':'), ].filter((u) => !!u); const nonUIRoutesRegex = new RegExp(`^/(${nonUIRoutes.join('|')})/?.*$`); diff --git a/packages/cli/src/services/__tests__/api-key-auth.strategy.integration.test.ts b/packages/cli/src/services/__tests__/api-key-auth.strategy.integration.test.ts index 1de65f0ee26..8c2beff2261 100644 --- a/packages/cli/src/services/__tests__/api-key-auth.strategy.integration.test.ts +++ b/packages/cli/src/services/__tests__/api-key-auth.strategy.integration.test.ts @@ -1,5 +1,6 @@ import { Logger } from '@n8n/backend-common'; import { testDb } from '@n8n/backend-test-utils'; +import { GlobalConfig } from '@n8n/config'; import type { AuthenticatedRequest, User } from '@n8n/db'; import { ApiKey, ApiKeyRepository, UserRepository } from '@n8n/db'; import { Container } from '@n8n/di'; @@ -215,5 +216,20 @@ describe('ApiKeyAuthStrategy', () => { expect(req.user.id).toBe(owner.id); expect(req.tokenGrant?.subject.id).toBe(owner.id); }); + + it('abstains (returns null) when N8N_PUBLIC_API_DISABLED is set, even with a valid API key', async () => { + const globalConfig = Container.get(GlobalConfig); + globalConfig.publicApi.disabled = true; + + try { + const owner = await createOwnerWithApiKey(); + const [{ apiKey }] = owner.apiKeys; + const req = mockReqWith(apiKey); + + expect(await strategy.authenticate(req)).toBeNull(); + } finally { + globalConfig.publicApi.disabled = false; + } + }); }); }); diff --git a/packages/cli/src/services/__tests__/frontend.service.test.ts b/packages/cli/src/services/__tests__/frontend.service.test.ts index a2a8caf61e7..d945de89171 100644 --- a/packages/cli/src/services/__tests__/frontend.service.test.ts +++ b/packages/cli/src/services/__tests__/frontend.service.test.ts @@ -9,7 +9,7 @@ import type { ICredentialType, INodeTypeDescription } from 'n8n-workflow'; import type { CredentialTypes } from '@/credential-types'; import type { CredentialsOverwrites } from '@/credentials-overwrites'; -import type { License } from '@/license'; +import { License } from '@/license'; import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import type { MfaService } from '@/mfa/mfa.service'; import { CommunityPackagesConfig } from '@/modules/community-packages/community-packages.config'; @@ -200,6 +200,9 @@ describe('FrontendService', () => { enabled: false, }), ); + // isApiKeyAuthEnabled() reads License via the container directly, so the + // constructor-injected mock above must also be registered here. + Container.set(License, license); return { service: new FrontendService( diff --git a/packages/cli/src/services/__tests__/session-cookie-auth.strategy.integration.test.ts b/packages/cli/src/services/__tests__/session-cookie-auth.strategy.integration.test.ts new file mode 100644 index 00000000000..1bef088ef3e --- /dev/null +++ b/packages/cli/src/services/__tests__/session-cookie-auth.strategy.integration.test.ts @@ -0,0 +1,103 @@ +import { LicenseState } from '@n8n/backend-common'; +import { testDb } from '@n8n/backend-test-utils'; +import type { AuthenticatedRequest } from '@n8n/db'; +import { InvalidAuthTokenRepository, UserRepository } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { mock } from 'vitest-mock-extended'; + +import { AuthService } from '@/auth/auth.service'; +import { AUTH_COOKIE_NAME } from '@/constants'; +import { createOwner } from '@test-integration/db/users'; + +import { SessionCookieAuthStrategy } from '../session-cookie-auth.strategy'; + +// MFA enforcement gates inside AuthService.authenticateUserBasedOnToken call into the +// license state; stub it so any feature check returns "not licensed". +const licenseMock = mock(); +licenseMock.isLicensed.mockReturnValue(false); +Container.set(LicenseState, licenseMock); + +const mockReqWith = (token: string): AuthenticatedRequest => { + const req = mock({ path: '/test', method: 'GET' }); + // Override the deep-mock proxy so the cookie lookup returns the exact value. + req.cookies = { [AUTH_COOKIE_NAME]: token }; + return req; +}; + +const mockReqWithoutCookie = (): AuthenticatedRequest => { + const req = mock({ path: '/test', method: 'GET' }); + // Override the deep-mock proxy so the cookie lookup returns undefined. + req.cookies = {}; + return req; +}; + +let authService: AuthService; +let authenticator: SessionCookieAuthStrategy; + +describe('SessionCookieAuthStrategy', () => { + beforeAll(async () => { + await testDb.init(); + authService = Container.get(AuthService); + authenticator = new SessionCookieAuthStrategy(authService); + }); + + beforeEach(async () => { + await testDb.truncate(['User']); + }); + + afterAll(async () => { + await testDb.terminate(); + }); + + it('returns null when no session cookie is present', async () => { + expect(await authenticator.authenticate(mockReqWithoutCookie())).toBeNull(); + }); + + it('returns true and populates req.user + req.tokenGrant on success', async () => { + const owner = await createOwner(); + const token = authService.issueJWT(owner, false); + const req = mockReqWith(token); + + expect(await authenticator.authenticate(req)).toBe(true); + expect(req.user.id).toBe(owner.id); + expect(req.tokenGrant?.subject.id).toBe(owner.id); + expect(req.tokenGrant?.scopes.length).toBeGreaterThan(0); + expect(req.tokenGrant?.apiKeyScopes?.length).toBeGreaterThan(0); + }); + + it('returns false for an invalid session cookie', async () => { + expect(await authenticator.authenticate(mockReqWith('not-a-jwt'))).toBe(false); + }); + + it('returns false for a revoked token', async () => { + const owner = await createOwner(); + const token = authService.issueJWT(owner, false); + await Container.get(InvalidAuthTokenRepository).insert({ + token, + expiresAt: new Date(Date.now() + 60_000), + }); + + expect(await authenticator.authenticate(mockReqWith(token))).toBe(false); + }); + + it('returns false for a disabled user', async () => { + const owner = await createOwner(); + const token = authService.issueJWT(owner, false); + await Container.get(UserRepository).update({ id: owner.id }, { disabled: true }); + + expect(await authenticator.authenticate(mockReqWith(token))).toBe(false); + }); + + it('buildTokenGrant always abstains — session cookies must never satisfy audience-scoped bearer-token checks', async () => { + expect(await authenticator.buildTokenGrant('anything')).toBeNull(); + expect(await authenticator.buildTokenGrant('')).toBeNull(); + + // Even a genuinely valid session JWT, passed as a raw bearer token with an + // audience mirroring a real scoped-token check, must still be refused. + const owner = await createOwner(); + const validSessionJwt = authService.issueJWT(owner, false); + expect( + await authenticator.buildTokenGrant(validSessionJwt, { audience: 'mcp-server-api' }), + ).toBeNull(); + }); +}); diff --git a/packages/cli/src/services/api-key-auth.strategy.ts b/packages/cli/src/services/api-key-auth.strategy.ts index 3647b81d457..1c71f70c976 100644 --- a/packages/cli/src/services/api-key-auth.strategy.ts +++ b/packages/cli/src/services/api-key-auth.strategy.ts @@ -5,6 +5,8 @@ import { ApiKeyRepository } from '@n8n/db'; import { Service } from '@n8n/di'; import { TokenExpiredError } from 'jsonwebtoken'; +import { isApiKeyAuthEnabled } from '@/public-api'; + import type { AuthStrategy, AuthStrategyOptions } from './auth-strategy.types'; import { JwtService } from './jwt.service'; import { API_KEY_AUDIENCE, API_KEY_ISSUER, PREFIX_LEGACY_API_KEY } from './public-api-key.service'; @@ -82,6 +84,10 @@ export class ApiKeyAuthStrategy implements AuthStrategy { } async authenticate(req: AuthenticatedRequest): Promise { + // Abstain when API-key auth is disabled so other strategies (e.g. session + // cookie) can still authenticate the request. + if (!isApiKeyAuthEnabled()) return null; + const providedApiKey = req.headers[API_KEY_HEADER]; if (typeof providedApiKey !== 'string' || !providedApiKey) return null; diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 04c3490fd2b..bcc4281395e 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -22,7 +22,7 @@ import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { MfaService } from '@/mfa/mfa.service'; import { CommunityPackagesConfig } from '@/modules/community-packages/community-packages.config'; import type { CommunityPackagesService } from '@/modules/community-packages/community-packages.service'; -import { isApiEnabled } from '@/public-api'; +import { isApiKeyAuthEnabled } from '@/public-api'; import { PushConfig } from '@/push/push.config'; import { OwnershipService } from '@/services/ownership.service'; import { getSamlLoginLabel, getCurrentAuthenticationMethod } from '@/sso.ee/sso-helpers'; @@ -300,7 +300,7 @@ export class FrontendService { maxSize: this.globalConfig.dataTable.maxSize, }, publicApi: { - enabled: isApiEnabled(), + enabled: isApiKeyAuthEnabled(), latestVersion: 1, path: this.globalConfig.publicApi.path, swaggerUi: { diff --git a/packages/cli/src/services/session-cookie-auth.strategy.ts b/packages/cli/src/services/session-cookie-auth.strategy.ts new file mode 100644 index 00000000000..66d4db01dc3 --- /dev/null +++ b/packages/cli/src/services/session-cookie-auth.strategy.ts @@ -0,0 +1,68 @@ +import type { AuthenticatedRequest, TokenGrant, User } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { getApiKeyScopesForRole } from '@n8n/permissions'; +import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken'; + +import { AuthService } from '@/auth/auth.service'; +import { AUTH_COOKIE_NAME } from '@/constants'; +import { AuthError } from '@/errors/response-errors/auth.error'; + +import type { AuthStrategy, AuthStrategyOptions } from './auth-strategy.types'; + +/** + * Lets the public API accept the browser's `n8n-auth` session cookie as an + * alternative to an API key. + */ +@Service() +export class SessionCookieAuthStrategy implements AuthStrategy { + constructor(private readonly authService: AuthService) {} + + /** + * Session cookies authenticate a request via req.cookies, never via an arbitrary + * bearer token string, so this strategy has no legitimate way to answer "is this + * token valid for audience X". It must always abstain here, ignoring `token`/`options` + * entirely — otherwise a browser session JWT could satisfy an audience-scoped + * bearer-token check it was never meant to (e.g. McpServerApiKeyService.verifyApiKey). + */ + async buildTokenGrant(_token: string, _options?: AuthStrategyOptions): Promise { + return null; + } + + async authenticate(req: AuthenticatedRequest): Promise { + const token = req.cookies[AUTH_COOKIE_NAME]; + if (!token) return null; + + try { + const user = await this.authService.authenticateUserBasedOnToken( + token, + req.method, + this.authService.getEndpoint(req), + this.authService.getBrowserId(req), + ); + + const tokenGrant = this.toGrant(user); + req.user = user; + req.tokenGrant = tokenGrant; + return true; + } catch (error) { + if (this.isAuthFailure(error)) return false; + throw error; + } + } + + private toGrant(user: User): TokenGrant { + return { + scopes: user.role.scopes.map((s) => s.slug), + subject: user, + apiKeyScopes: getApiKeyScopesForRole(user), + }; + } + + private isAuthFailure(error: unknown): boolean { + return ( + error instanceof AuthError || + error instanceof TokenExpiredError || + error instanceof JsonWebTokenError + ); + } +} diff --git a/packages/cli/test/integration/public-api/insights.test.ts b/packages/cli/test/integration/public-api/insights.test.ts index 6b2bd35e447..b981e6b9456 100644 --- a/packages/cli/test/integration/public-api/insights.test.ts +++ b/packages/cli/test/integration/public-api/insights.test.ts @@ -3,6 +3,7 @@ import { createTeamProject, createWorkflow, testDb } from '@n8n/backend-test-uti import { type User } from '@n8n/db'; import { DateTime } from 'luxon'; +import { AUTH_COOKIE_NAME } from '@/constants'; import { createCompactedInsightsEvent } from '@/modules/insights/database/entities/__tests__/db-utils'; import { createOwnerWithApiKey } from '../shared/db/users'; @@ -81,6 +82,47 @@ describe('GET /insights/summary', () => { await testServer.publicApiAgentWithoutApiKey().get('/insights/summary').expect(401); }); + test('returns data via session cookie, without an API key', async () => { + const project = await createTeamProject(); + const workflow = await createWorkflow({}, project); + + await createSummaryMetrics(workflow, { + success: 3, + failure: 1, + runtimeMs: 400, + timeSavedMin: 20, + }); + + const response = await testServer + .publicApiAgentWithCookie(scopedOwner) + .get('/insights/summary') + .query({ + startDate: DateTime.utc().minus({ days: 2 }).toISO(), + endDate: DateTime.utc().plus({ days: 1 }).toISO(), + }) + .expect(200); + + expect(response.body.total.value).toBe(4); + expect(response.body.failed.value).toBe(1); + }); + + test('returns 401 with an invalid session cookie', async () => { + const agent = testServer.publicApiAgentWithoutApiKey(); + agent.jar.setCookie(`${AUTH_COOKIE_NAME}=invalid`); + + const response = await agent.get('/insights/summary').expect(401); + + expect(response.body).toEqual({ message: 'Unauthorized' }); + }); + + test('returns 401 with a hint to use an API key when no credentials are sent at all', async () => { + const agent = testServer.publicApiAgentWithoutApiKey(); + + const response = await agent.get('/insights/summary').expect(401); + + expect(response.body).toEqual({ message: "'X-N8N-API-KEY' header required" }); + }); + test('returns 403 without insights:read scope', async () => { await authUnscopedAgent.get('/insights/summary').expect(403); }); diff --git a/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts b/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts index b2aed9e6699..7530ecd87e2 100644 --- a/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts +++ b/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts @@ -255,7 +255,7 @@ test('should report security settings', async () => { communityPackagesEnabled: true, versionNotificationsEnabled: true, templatesEnabled: true, - publicApiEnabled: false, + publicApiEnabled: true, }, nodes: { nodesExclude: 'n8n-nodes-base.executeCommand, n8n-nodes-base.localFileTrigger', diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index ae19c10d123..3a02a1e0310 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -91,6 +91,7 @@ export interface TestServer { publicApiAgentFor: (user: User) => TestAgent; publicApiAgentWithApiKey: (apiKey: string) => TestAgent; publicApiAgentWithoutApiKey: () => TestAgent; + publicApiAgentWithCookie: (user: User) => TestAgent; authlessAgent: TestAgent; restlessAgent: TestAgent; license: LicenseMocker; diff --git a/packages/cli/test/integration/shared/utils/test-server.ts b/packages/cli/test/integration/shared/utils/test-server.ts index 085817e7df1..5c6fb69689c 100644 --- a/packages/cli/test/integration/shared/utils/test-server.ts +++ b/packages/cli/test/integration/shared/utils/test-server.ts @@ -19,6 +19,7 @@ import { PostHogClient } from '@/posthog'; import { Push } from '@/push'; import { ApiKeyAuthStrategy } from '@/services/api-key-auth.strategy'; import { AuthStrategyRegistry } from '@/services/auth-strategy.registry'; +import { SessionCookieAuthStrategy } from '@/services/session-cookie-auth.strategy'; import { Telemetry } from '@/telemetry'; import { resolveBackendHealthEndpointPath } from '@/utils/health-endpoint.util'; import { LicenseMocker } from '@test-integration/license'; @@ -93,6 +94,19 @@ const publicApiAgent = ( return agent; }; +const publicApiAgentWithCookie = (app: express.Application, user: User, version = 1) => { + const agent = request.agent(app); + void agent.use(prefix(`${PUBLIC_API_REST_PATH_SEGMENT}/v${version}`)); + // browser-id is needed for session cookie auth + void agent.use(async (req: superagent.SuperAgentRequest) => { + req.set('browser-id', browserId); + return await req; + }); + const token = Container.get(AuthService).issueJWT(user, user.mfaEnabled, browserId); + agent.jar.setCookie(`${AUTH_COOKIE_NAME}=${token}`); + return agent; +}; + export const setupTestServer = ({ endpointGroups, enabledFeatures, @@ -124,6 +138,7 @@ export const setupTestServer = ({ publicApiAgentFor: (user) => publicApiAgent(app, { user }), publicApiAgentWithApiKey: (apiKey) => publicApiAgent(app, { apiKey }), publicApiAgentWithoutApiKey: () => publicApiAgent(app, {}), + publicApiAgentWithCookie: (user) => publicApiAgentWithCookie(app, user), license: new LicenseMocker(), }; @@ -153,10 +168,14 @@ export const setupTestServer = ({ // Register auth strategies in priority order. The registry evaluates them // sequentially — the first strategy that returns a non-null result wins. // API key auth is registered first so existing behavior is preserved. + // Session cookie auth is registered last: an explicit but wrong API + // key/bearer token fails fast rather than silently falling back to an + // ambient browser session cookie. // Additional strategies (e.g. scoped JWT from the token-exchange module) // can be appended later during their own module initialization. const registry = Container.get(AuthStrategyRegistry); registry.register(Container.get(ApiKeyAuthStrategy)); + registry.register(Container.get(SessionCookieAuthStrategy)); const enablePublicAPI = endpointGroups?.includes('publicApi'); if (enablePublicAPI) { diff --git a/packages/frontend/@n8n/rest-api-client/src/utils.ts b/packages/frontend/@n8n/rest-api-client/src/utils.ts index 3362d753262..343a1f48058 100644 --- a/packages/frontend/@n8n/rest-api-client/src/utils.ts +++ b/packages/frontend/@n8n/rest-api-client/src/utils.ts @@ -1,4 +1,4 @@ -import { BROWSER_ID_STORAGE_KEY } from '@n8n/constants'; +import { getBrowserId } from '@n8n/constants'; import { assert } from '@n8n/utils/assert'; import type { AxiosRequestConfig, Method, RawAxiosRequestHeaders } from 'axios'; import axios from 'axios'; @@ -7,15 +7,6 @@ import type { GenericValue, IDataObject } from 'n8n-workflow'; import type { IRestApiContext } from './types'; -const getBrowserId = () => { - let browserId = localStorage.getItem(BROWSER_ID_STORAGE_KEY); - if (!browserId) { - browserId = crypto.randomUUID(); - localStorage.setItem(BROWSER_ID_STORAGE_KEY, browserId); - } - return browserId; -}; - export const NO_NETWORK_ERROR_CODE = 999; export const STREAM_SEPARATOR = '⧉⇋⇋➽⌑⧉§§\n'; diff --git a/packages/frontend/@n8n/stores/src/settings.store.test.ts b/packages/frontend/@n8n/stores/src/settings.store.test.ts index 15922616581..770becffadc 100644 --- a/packages/frontend/@n8n/stores/src/settings.store.test.ts +++ b/packages/frontend/@n8n/stores/src/settings.store.test.ts @@ -23,6 +23,7 @@ const mockRootStore = { setExecutionTimeout: vi.fn(), setMaxExecutionTimeout: vi.fn(), setInstanceId: vi.fn(), + setPublicApiPath: vi.fn(), setOauthCallbackUrls: vi.fn(), setJwksUri: vi.fn(), setN8nMetadata: vi.fn(), @@ -73,6 +74,12 @@ const mockSettings = mock({ telemetry: { enabled: false, }, + publicApi: { + enabled: true, + latestVersion: 1, + path: 'api', + swaggerUi: { enabled: true }, + }, }); const publicSettingsResponse = { @@ -337,6 +344,7 @@ describe('settings.store', () => { expect(mockRootStore.setMaxExecutionTimeout).not.toHaveBeenCalled(); expect(mockRootStore.setN8nMetadata).not.toHaveBeenCalled(); expect(mockRootStore.setBinaryDataMode).not.toHaveBeenCalled(); + expect(mockRootStore.setPublicApiPath).not.toHaveBeenCalled(); // side effects expect(sessionStarted).not.toHaveBeenCalled(); @@ -391,6 +399,9 @@ describe('settings.store', () => { expect(mockRootStore.setN8nMetadata).toHaveBeenCalled(); expect(mockRootStore.setDefaultLocale).toHaveBeenCalled(); expect(mockRootStore.setBinaryDataMode).toHaveBeenCalled(); + expect(mockRootStore.setPublicApiPath).toHaveBeenCalledWith( + `${mockSettings.publicApi.path}/v${mockSettings.publicApi.latestVersion}`, + ); // side effects expect(sessionStarted).toHaveBeenCalled(); diff --git a/packages/frontend/@n8n/stores/src/settings.store.ts b/packages/frontend/@n8n/stores/src/settings.store.ts index 1b60f972b40..42b8b23cf1d 100644 --- a/packages/frontend/@n8n/stores/src/settings.store.ts +++ b/packages/frontend/@n8n/stores/src/settings.store.ts @@ -367,6 +367,9 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { rootStore.setTimezone(fetchedSettings.timezone); rootStore.setExecutionTimeout(fetchedSettings.executionTimeout); rootStore.setMaxExecutionTimeout(fetchedSettings.maxExecutionTimeout); + rootStore.setPublicApiPath( + `${fetchedSettings.publicApi.path}/v${fetchedSettings.publicApi.latestVersion}`, + ); rootStore.setInstanceId(fetchedSettings.instanceId); rootStore.setOauthCallbackUrls(fetchedSettings.oauthCallbackUrls); rootStore.setN8nMetadata(fetchedSettings.n8nMetadata ?? {}); diff --git a/packages/frontend/@n8n/stores/src/useRootStore.ts b/packages/frontend/@n8n/stores/src/useRootStore.ts index b2bed813024..f69102928f0 100644 --- a/packages/frontend/@n8n/stores/src/useRootStore.ts +++ b/packages/frontend/@n8n/stores/src/useRootStore.ts @@ -29,6 +29,7 @@ export type RootStoreState = { [key: string]: string | number | undefined; }; pushRef: string; + publicApiPath: string; urlBaseWebhook: string; urlBaseEditor: string; urlBaseWebhookTest: string; @@ -57,6 +58,7 @@ export const useRootStore = defineStore(STORES.ROOT, () => { jwksUri: '', n8nMetadata: {}, pushRef: randomString(10).toLowerCase(), + publicApiPath: 'api/v1', urlBaseWebhook: 'http://localhost:5678/', urlBaseEditor: 'http://localhost:5678', urlBaseWebhookTest: 'http://localhost:5678/', @@ -127,6 +129,10 @@ export const useRootStore = defineStore(STORES.ROOT, () => { pushRef: state.value.pushRef, })); + const publicApiContext = computed(() => ({ + baseUrl: `${state.value.baseUrl}${state.value.publicApiPath}`, + })); + // #endregion // --------------------------------------------------------------------------- @@ -225,6 +231,10 @@ export const useRootStore = defineStore(STORES.ROOT, () => { state.value.pushRef = value; }; + const setPublicApiPath = (value: string) => { + state.value.publicApiPath = value; + }; + // #endregion return { @@ -239,6 +249,7 @@ export const useRootStore = defineStore(STORES.ROOT, () => { webhookWaitingUrl, restUrl, restApiContext, + publicApiContext, urlBaseEditor, urlBaseWebhook, versionCli, @@ -273,5 +284,6 @@ export const useRootStore = defineStore(STORES.ROOT, () => { setDefaultLocale, setBinaryDataMode, setPushRef, + setPublicApiPath, }; }); From 55927c4af5ab1b80653b79c567794b525cb14301 Mon Sep 17 00:00:00 2001 From: yehorkardash Date: Thu, 13 Aug 2026 13:40:29 +0000 Subject: [PATCH 0013/1458] fix(core): Allow `details` field in `continueErrorOutput` mode (#35939) --- .../__tests__/workflow-execute.test.ts | 37 +++++++++++++++++++ .../src/execution-engine/workflow-execute.ts | 7 ++-- .../test/node/HttpRequestV3.test.ts | 1 + packages/workflow/src/interfaces.ts | 4 ++ 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts b/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts index 97ba4ac170e..089ad0180ad 100644 --- a/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts +++ b/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts @@ -1789,6 +1789,43 @@ describe('WorkflowExecute', () => { ]); }); + test.each([ + { + name: 'details', + json: { error: 'Error occurred', details: { httpCode: '500' } }, + }, + { + name: 'message and details', + json: { + error: 'Error occurred', + message: 'Error details', + details: { httpCode: '500' }, + }, + }, + ])('should handle error in json with $name properties', ({ json }) => { + const nodeSuccessData: INodeExecutionData[][] = [ + [ + { + json, + pairedItem: { item: 0, input: 0 }, + }, + ], + ]; + + workflowExecute.handleNodeErrorOutput(workflow, executionData, nodeSuccessData, 0); + + expect(nodeSuccessData[0]).toEqual([]); + expect(nodeSuccessData[1]).toEqual([ + { + json: { + ...json, + someData: 'test', + }, + pairedItem: { item: 0, input: 0 }, + }, + ]); + }); + test('should preserve pairedItem data when routing errors', () => { const nodeSuccessData: INodeExecutionData[][] = [ [ diff --git a/packages/core/src/execution-engine/workflow-execute.ts b/packages/core/src/execution-engine/workflow-execute.ts index da6dd6ca6b3..cc797c7ffa9 100644 --- a/packages/core/src/execution-engine/workflow-execute.ts +++ b/packages/core/src/execution-engine/workflow-execute.ts @@ -2755,9 +2755,10 @@ export class WorkflowExecute { let errorData: GenericValue | undefined; if (item.error) { errorData = item.error; - } else if (item.json.error && Object.keys(item.json).length === 1) { - errorData = item.json.error; - } else if (item.json.error && item.json.message && Object.keys(item.json).length === 2) { + } else if ( + item.json.error && + Object.keys(item.json).every((key) => ['error', 'message', 'details'].includes(key)) + ) { errorData = item.json.error; } diff --git a/packages/nodes-base/nodes/HttpRequest/test/node/HttpRequestV3.test.ts b/packages/nodes-base/nodes/HttpRequest/test/node/HttpRequestV3.test.ts index f8b768216be..aeb18c5245d 100644 --- a/packages/nodes-base/nodes/HttpRequest/test/node/HttpRequestV3.test.ts +++ b/packages/nodes-base/nodes/HttpRequest/test/node/HttpRequestV3.test.ts @@ -783,6 +783,7 @@ describe('HttpRequestV3', () => { }, pairedItem: { item: 0 }, }); + expect(Object.keys(result[0][0].json)).toEqual(['error', 'details']); }, ); diff --git a/packages/workflow/src/interfaces.ts b/packages/workflow/src/interfaces.ts index 9753bcbba5f..e26d97ab5fe 100644 --- a/packages/workflow/src/interfaces.ts +++ b/packages/workflow/src/interfaces.ts @@ -1713,6 +1713,10 @@ export interface INodeExecutionData { | number | string | undefined; + /** + * JSON output. + * In `continueErrorOutput` mode, engine will try to read item.error or fallback to item.json.error with allowed optional keys: message, details. + */ json: IDataObject; binary?: IBinaryKeyData; error?: NodeApiError | NodeOperationError; From c0278b07a03ae04dc809f28ca8b03eac233713b3 Mon Sep 17 00:00:00 2001 From: mfsiega <93014743+mfsiega@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:43:46 +0000 Subject: [PATCH 0014/1458] fix(core): Bound workflow publication outbox record processing with an abort deadline (#36197) Co-authored-by: Claude Fable 5 --- .../config/src/configs/workflows.config.ts | 8 +- packages/@n8n/config/test/config.test.ts | 18 ++ packages/@n8n/utils/src/sleep.test.ts | 35 +++- packages/@n8n/utils/src/sleep.ts | 26 +-- .../workflow-publication-applier.test.ts | 110 ++++++++--- ...rkflow-publication-outbox-consumer.test.ts | 180 +++++++++++++++++- .../workflow-publication-applier.ts | 24 ++- .../workflow-publication-outbox-consumer.ts | 129 ++++++++++++- .../trigger-activation-retry.test.ts | 35 +++- .../workflow-trigger-activator.test.ts | 136 +++++++++++++ .../triggers/trigger-activation-retry.ts | 13 +- .../triggers/workflow-trigger-activator.ts | 165 ++++++++++++---- ...rkflow-publication-outbox-consumer.test.ts | 10 +- .../workflow-publication-reconciler.test.ts | 34 ++-- 14 files changed, 792 insertions(+), 131 deletions(-) diff --git a/packages/@n8n/config/src/configs/workflows.config.ts b/packages/@n8n/config/src/configs/workflows.config.ts index fef46d31541..7aa30d5892b 100644 --- a/packages/@n8n/config/src/configs/workflows.config.ts +++ b/packages/@n8n/config/src/configs/workflows.config.ts @@ -7,6 +7,9 @@ import { positiveIntSchema } from '../schemas'; const callerPolicySchema = z.enum(['any', 'none', 'workflowsFromAList', 'workflowsFromSameOwner']); type CallerPolicy = z.infer; +// Bounded so lease-derived timeouts stay far below Node's max timer delay (~24.8 days). +const outboxLeaseSecondsSchema = positiveIntSchema.max(Time.days.toSeconds); + @Config export class WorkflowsConfig { /** Default name suggested when creating a new workflow. */ @@ -34,8 +37,9 @@ export class WorkflowsConfig { publicationOutboxPollIntervalMs: number = 15 * Time.seconds.toMilliseconds; /** Seconds after which an `in_progress` workflow publication outbox record - * is considered stale (its leader likely died) and may be reclaimed by a poll cycle. */ - @Env('N8N_WORKFLOW_PUBLICATION_OUTBOX_LEASE_SECONDS') + * is considered stale (its leader likely died) and may be reclaimed by a poll cycle. + * Must be at most one day. */ + @Env('N8N_WORKFLOW_PUBLICATION_OUTBOX_LEASE_SECONDS', outboxLeaseSecondsSchema) publicationOutboxLeaseSeconds: number = 2 * Time.minutes.toSeconds; /** Number of workflow publication outbox records the leader processes in parallel per drain. */ diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index 91ca2570ca0..08deb571145 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -983,6 +983,24 @@ describe('GlobalConfig', () => { const config = Container.get(GlobalConfig); expect(config.workflows.workflowPublicationConcurrency).toBe(3); }); + + it('should reject an outbox lease above one day and fall back to the default', () => { + process.env = { N8N_WORKFLOW_PUBLICATION_OUTBOX_LEASE_SECONDS: `${100 * 24 * 60 * 60}` }; + const config = Container.get(GlobalConfig); + expect(config.workflows.publicationOutboxLeaseSeconds).toBe(120); + expect(consoleWarnMock).toHaveBeenCalledWith( + expect.stringContaining('N8N_WORKFLOW_PUBLICATION_OUTBOX_LEASE_SECONDS'), + ); + }); + + it('should reject an outbox lease of 0 and fall back to the default', () => { + process.env = { N8N_WORKFLOW_PUBLICATION_OUTBOX_LEASE_SECONDS: '0' }; + const config = Container.get(GlobalConfig); + expect(config.workflows.publicationOutboxLeaseSeconds).toBe(120); + expect(consoleWarnMock).toHaveBeenCalledWith( + expect.stringContaining('N8N_WORKFLOW_PUBLICATION_OUTBOX_LEASE_SECONDS'), + ); + }); }); it('should clamp password min length to valid range', () => { diff --git a/packages/@n8n/utils/src/sleep.test.ts b/packages/@n8n/utils/src/sleep.test.ts index 95da1bab86c..71a7e079996 100644 --- a/packages/@n8n/utils/src/sleep.test.ts +++ b/packages/@n8n/utils/src/sleep.test.ts @@ -30,24 +30,33 @@ describe('sleep', () => { expect(onResolve).toHaveBeenCalled(); }); - it('should reject immediately if abort signal is already aborted', async () => { + it('should reject with the abort reason if abort signal is already aborted', async () => { const abortController = new AbortController(); - abortController.abort(); + const reason = new Error('deadline'); + abortController.abort(reason); - await expect(sleep(1000, abortController.signal)).rejects.toThrow('Aborted'); + await expect(sleep(1000, abortController.signal)).rejects.toBe(reason); }); - it('should reject when abort signal is triggered during sleep', async () => { + it('should reject with the abort reason when abort signal is triggered during sleep', async () => { const abortController = new AbortController(); + const reason = new Error('deadline'); const onSettled = vi.fn(); const promise = sleep(1000, abortController.signal).catch(onSettled); await vi.advanceTimersByTimeAsync(50); - abortController.abort(); + abortController.abort(reason); await promise; - expect(onSettled).toHaveBeenCalledWith(expect.objectContaining({ message: 'Aborted' })); + expect(onSettled).toHaveBeenCalledWith(reason); + }); + + it('should reject with the default abort reason when none is given', async () => { + const abortController = new AbortController(); + abortController.abort(); + + await expect(sleep(1000, abortController.signal)).rejects.toThrow('This operation was aborted'); }); it('should clean up timeout when aborted during sleep', async () => { @@ -59,10 +68,22 @@ describe('sleep', () => { await vi.advanceTimersByTimeAsync(50); abortController.abort(); - await expect(sleepPromise).rejects.toThrow('Aborted'); + await expect(sleepPromise).rejects.toThrow('This operation was aborted'); expect(clearTimeoutSpy).toHaveBeenCalled(); clearTimeoutSpy.mockRestore(); }); + + it('should remove the abort listener when the sleep completes', async () => { + const abortController = new AbortController(); + const removeListenerSpy = vi.spyOn(abortController.signal, 'removeEventListener'); + + const sleepPromise = sleep(100, abortController.signal); + + await vi.advanceTimersByTimeAsync(100); + await sleepPromise; + + expect(removeListenerSpy).toHaveBeenCalledWith('abort', expect.any(Function)); + }); }); diff --git a/packages/@n8n/utils/src/sleep.ts b/packages/@n8n/utils/src/sleep.ts index 3a7f7c4bc42..db568211254 100644 --- a/packages/@n8n/utils/src/sleep.ts +++ b/packages/@n8n/utils/src/sleep.ts @@ -1,25 +1,29 @@ +import { ensureError } from './errors/ensure-error'; + async function sleepWithAbort(ms: number, abortSignal: AbortSignal): Promise { return await new Promise((resolve, reject) => { if (abortSignal.aborted) { - reject(new Error('Aborted')); + reject(ensureError(abortSignal.reason)); return; } - const timeout = setTimeout(resolve, ms); + const onAbort = () => { + clearTimeout(timeout); + reject(ensureError(abortSignal.reason)); + }; - abortSignal.addEventListener( - 'abort', - () => { - clearTimeout(timeout); - reject(new Error('Aborted')); - }, - { once: true }, - ); + const timeout = setTimeout(() => { + abortSignal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + + abortSignal.addEventListener('abort', onAbort, { once: true }); }); } /** - * Resolves after `ms` milliseconds, or rejects early if `abortSignal` is aborted. + * Resolves after `ms` milliseconds, or rejects with the signal's abort reason + * if `abortSignal` fires first. */ export async function sleep(ms: number, abortSignal?: AbortSignal): Promise { if (!abortSignal) { diff --git a/packages/cli/src/workflows/publication/__tests__/workflow-publication-applier.test.ts b/packages/cli/src/workflows/publication/__tests__/workflow-publication-applier.test.ts index 6f5a80e3655..19d1d8f9708 100644 --- a/packages/cli/src/workflows/publication/__tests__/workflow-publication-applier.test.ts +++ b/packages/cli/src/workflows/publication/__tests__/workflow-publication-applier.test.ts @@ -93,6 +93,8 @@ describe('WorkflowPublicationApplier', () => { const newVersion = makeVersion('v-2'); const oldVersion = makeVersion('v-1'); + const abort = { signal: new AbortController().signal, onDetached: vi.fn() }; + /** Drives the trigger diff: first call returns old triggers, second returns new. */ function setTriggerSets(oldTriggers: INode[], newTriggers: INode[]) { workflowTriggerActivator.getEnabledTriggerNodes @@ -121,7 +123,7 @@ describe('WorkflowPublicationApplier', () => { test('skips with workflow-not-found when the workflow is gone', async () => { workflowRepository.findOneBy.mockResolvedValue(null); - const result = await applier.apply(makeRecord()); + const result = await applier.apply(makeRecord(), abort); expect(result).toEqual({ type: 'skipped', reason: 'workflow-not-found' }); expect(workflowTriggerActivator.getEnabledTriggerNodes).not.toHaveBeenCalled(); @@ -145,7 +147,7 @@ describe('WorkflowPublicationApplier', () => { triggerNode('b'), ]); - const result = await applier.apply(makeRecord()); + const result = await applier.apply(makeRecord(), abort); expect(result).toEqual({ type: 'unpublished' }); expect(workflowTriggerActivator.getEnabledTriggerNodes).toHaveBeenCalledWith(oldVersion); @@ -153,6 +155,7 @@ describe('WorkflowPublicationApplier', () => { expect.objectContaining({ id: 'wf-1' }), oldVersion, new Set(['a', 'b']), + abort, ); expect(workflowPublishedVersionRepository.removePublishedVersion).toHaveBeenCalledWith( 'wf-1', @@ -168,7 +171,7 @@ describe('WorkflowPublicationApplier', () => { ); workflowTriggerActivator.getEnabledTriggerNodes.mockReturnValue([]); - const result = await applier.apply(makeRecord()); + const result = await applier.apply(makeRecord(), abort); expect(result).toEqual({ type: 'unpublished' }); expect(workflowTriggerActivator.deactivate).not.toHaveBeenCalled(); @@ -184,7 +187,7 @@ describe('WorkflowPublicationApplier', () => { // can leave rows behind after the mapping was removed. workflowPublishedVersionRepository.findOne.mockResolvedValue(makePublishedVersion(null)); - const result = await applier.apply(makeRecord()); + const result = await applier.apply(makeRecord(), abort); expect(result).toEqual({ type: 'unpublished' }); expect(workflowTriggerActivator.deactivate).not.toHaveBeenCalled(); @@ -201,7 +204,7 @@ describe('WorkflowPublicationApplier', () => { workflowTriggerActivator.getEnabledTriggerNodes.mockReturnValue([triggerNode('a')]); workflowTriggerActivator.deactivate.mockRejectedValue(new Error('teardown boom')); - await expect(applier.apply(makeRecord())).rejects.toThrow('teardown boom'); + await expect(applier.apply(makeRecord(), abort)).rejects.toThrow('teardown boom'); expect(workflowPublishedVersionRepository.removePublishedVersion).not.toHaveBeenCalled(); }); }); @@ -209,7 +212,7 @@ describe('WorkflowPublicationApplier', () => { test('returns version-missing when the published version history row is gone', async () => { workflowHistoryRepository.findOneBy.mockResolvedValue(null); - const result = await applier.apply(makeRecord()); + const result = await applier.apply(makeRecord(), abort); expect(result).toEqual({ type: 'version-missing' }); expect(workflowTriggerActivator.getEnabledTriggerNodes).not.toHaveBeenCalled(); @@ -220,7 +223,7 @@ describe('WorkflowPublicationApplier', () => { const trigger = triggerNode('a'); setTriggerSets([trigger], [{ ...trigger }]); - const result = await applier.apply(makeRecord()); + const result = await applier.apply(makeRecord(), abort); expect(result).toEqual({ type: 'completed', @@ -255,7 +258,7 @@ describe('WorkflowPublicationApplier', () => { failures: [], }); - const result = await applier.apply(makeRecord()); + const result = await applier.apply(makeRecord(), abort); expect(result).toEqual({ type: 'completed', @@ -269,7 +272,7 @@ describe('WorkflowPublicationApplier', () => { test('registers only added triggers', async () => { setTriggerSets([triggerNode('a')], [triggerNode('a'), triggerNode('b')]); - const result = await applier.apply(makeRecord()); + const result = await applier.apply(makeRecord(), abort); expect(result).toEqual({ type: 'completed', @@ -284,6 +287,7 @@ describe('WorkflowPublicationApplier', () => { newVersion, new Set(['b']), 'update', + abort, ); expect(workflowPublishedVersionRepository.setPublishedVersion).toHaveBeenCalledWith( 'wf-1', @@ -300,26 +304,28 @@ describe('WorkflowPublicationApplier', () => { ] as const)('reason %s activates with mode %s', async (reason, expectedMode) => { setTriggerSets([], [triggerNode('a')]); - await applier.apply(makeRecord({ reason })); + await applier.apply(makeRecord({ reason }), abort); expect(workflowTriggerActivator.activate).toHaveBeenCalledWith( expect.objectContaining({ id: 'wf-1' }), newVersion, new Set(['a']), expectedMode, + abort, ); }); test('a record without a reason (pre-migration row) activates with update', async () => { setTriggerSets([], [triggerNode('a')]); - await applier.apply(makeRecord({ reason: undefined })); + await applier.apply(makeRecord({ reason: undefined }), abort); expect(workflowTriggerActivator.activate).toHaveBeenCalledWith( expect.objectContaining({ id: 'wf-1' }), newVersion, new Set(['a']), 'update', + abort, ); }); }); @@ -333,7 +339,7 @@ describe('WorkflowPublicationApplier', () => { new Set(['a']), ); - const result = await applier.apply(makeRecord()); + const result = await applier.apply(makeRecord(), abort); expect(result).toEqual({ type: 'completed', @@ -351,6 +357,7 @@ describe('WorkflowPublicationApplier', () => { newVersion, new Set(['a']), 'update', + abort, ); }); @@ -359,7 +366,7 @@ describe('WorkflowPublicationApplier', () => { setTriggerSets([trigger], [{ ...trigger }]); workflowTriggerActivator.getUnregisteredNonWebhookTriggerNodeIds.mockReturnValue(new Set()); - const result = await applier.apply(makeRecord()); + const result = await applier.apply(makeRecord(), abort); expect(result).toEqual({ type: 'completed', @@ -383,7 +390,7 @@ describe('WorkflowPublicationApplier', () => { setTriggerSets([trigger], [{ ...trigger }]); workflowTriggerActivator.getNodesWithUnregisteredWebhooks.mockResolvedValue(new Set(['a'])); - const result = await applier.apply(makeRecord()); + const result = await applier.apply(makeRecord(), abort); expect(result).toEqual({ type: 'completed', @@ -401,6 +408,7 @@ describe('WorkflowPublicationApplier', () => { newVersion, new Set(['a']), 'update', + abort, ); }); @@ -409,7 +417,7 @@ describe('WorkflowPublicationApplier', () => { setTriggerSets([triggerNode('a')], [triggerNode('a'), triggerNode('b')]); workflowTriggerActivator.getNodesWithUnregisteredWebhooks.mockResolvedValue(new Set(['c'])); - const result = await applier.apply(makeRecord()); + const result = await applier.apply(makeRecord(), abort); expect(result).toEqual({ type: 'completed', @@ -423,13 +431,14 @@ describe('WorkflowPublicationApplier', () => { newVersion, new Set(['b', 'c']), 'update', + abort, ); }); test('deregisters only removed triggers and refreshes the trigger count', async () => { setTriggerSets([triggerNode('a'), triggerNode('b')], [triggerNode('a')]); - const result = await applier.apply(makeRecord()); + const result = await applier.apply(makeRecord(), abort); expect(result).toEqual({ type: 'completed', @@ -441,6 +450,7 @@ describe('WorkflowPublicationApplier', () => { expect.objectContaining({ id: 'wf-1' }), oldVersion, new Set(['b']), + abort, ); expect(workflowTriggerActivator.activate).not.toHaveBeenCalled(); expect(workflowTriggerActivator.updateTriggerCount).toHaveBeenCalledWith( @@ -473,7 +483,7 @@ describe('WorkflowPublicationApplier', () => { return { activated: ['a'], failures: [] }; }); - const result = await applier.apply(makeRecord()); + const result = await applier.apply(makeRecord(), abort); expect(result).toEqual({ type: 'completed', @@ -485,12 +495,14 @@ describe('WorkflowPublicationApplier', () => { expect.objectContaining({ id: 'wf-1' }), oldVersion, new Set(['a']), + abort, ); expect(workflowTriggerActivator.activate).toHaveBeenCalledWith( expect.objectContaining({ id: 'wf-1' }), newVersion, new Set(['a']), 'update', + abort, ); // The cache is invalidated before the version is advanced and repopulated // straight after, so the empty window never serves a stale version, all @@ -505,7 +517,7 @@ describe('WorkflowPublicationApplier', () => { // A teardown failure happens before the version advances, so it bubbles up // to the consumer (which turns it into a failed result) rather than leaving // a half-applied publication marked completed. - await expect(applier.apply(makeRecord())).rejects.toThrow('teardown failed'); + await expect(applier.apply(makeRecord(), abort)).rejects.toThrow('teardown failed'); expect(workflowPublishedVersionRepository.setPublishedVersion).not.toHaveBeenCalled(); expect(workflowTriggerActivator.activate).not.toHaveBeenCalled(); @@ -515,7 +527,7 @@ describe('WorkflowPublicationApplier', () => { setTriggerSets([triggerNode('a')], [triggerNode('a'), triggerNode('b')]); workflowTriggerActivator.activate.mockRejectedValue(new Error('registration failed')); - const result = await applier.apply(makeRecord()); + const result = await applier.apply(makeRecord(), abort); expect(result).toEqual({ type: 'failed', @@ -535,7 +547,7 @@ describe('WorkflowPublicationApplier', () => { failures: [{ nodeId: 'b', nodeName: 'b', error }], }); - const result = await applier.apply(makeRecord()); + const result = await applier.apply(makeRecord(), abort); expect(result).toEqual({ type: 'partial', @@ -566,7 +578,7 @@ describe('WorkflowPublicationApplier', () => { failures: [{ nodeId: 'b', nodeName: 'b', error }], }); - const result = await applier.apply(makeRecord()); + const result = await applier.apply(makeRecord(), abort); expect(result).toEqual({ type: 'partial', @@ -595,7 +607,7 @@ describe('WorkflowPublicationApplier', () => { failures: [{ nodeId: 'b', nodeName: 'b', error }], }); - const result = await applier.apply(makeRecord()); + const result = await applier.apply(makeRecord(), abort); // A single failure passes its error through, preserving the type. expect(result).toEqual({ @@ -627,7 +639,7 @@ describe('WorkflowPublicationApplier', () => { ]; workflowTriggerActivator.activate.mockResolvedValue({ activated: [], failures }); - const result = await applier.apply(makeRecord()); + const result = await applier.apply(makeRecord(), abort); // Nothing is running, so the publication failed; the combined error names both nodes. expect(result).toEqual({ @@ -664,7 +676,7 @@ describe('WorkflowPublicationApplier', () => { failures: [{ nodeId: 'b', nodeName: 'b', error }], }); - const result = await applier.apply(makeRecord()); + const result = await applier.apply(makeRecord(), abort); expect(result).toEqual({ type: 'partial', @@ -689,7 +701,7 @@ describe('WorkflowPublicationApplier', () => { workflowPublishedVersionRepository.findOne.mockResolvedValue(null); setTriggerSets([], [triggerNode('a')]); - const result = await applier.apply(makeRecord()); + const result = await applier.apply(makeRecord(), abort); expect(result).toEqual({ type: 'completed', @@ -703,6 +715,54 @@ describe('WorkflowPublicationApplier', () => { newVersion, new Set(['a']), 'update', + abort, ); }); + + describe('abort', () => { + function abortedContext() { + const controller = new AbortController(); + controller.abort(new Error('deadline')); + return { signal: controller.signal, onDetached: vi.fn() }; + } + + test('a publish aborted before teardown neither deactivates nor advances the version', async () => { + setTriggerSets([triggerNode('a')], []); + + await expect(applier.apply(makeRecord(), abortedContext())).rejects.toThrow('deadline'); + + expect(workflowTriggerActivator.deactivate).not.toHaveBeenCalled(); + expect(workflowPublishedVersionRepository.setPublishedVersion).not.toHaveBeenCalled(); + }); + + test('a publish aborted after teardown advances the version but fails before activation', async () => { + setTriggerSets([triggerNode('a')], [triggerNode('b')]); + const controller = new AbortController(); + workflowTriggerActivator.deactivate.mockImplementation(async () => { + controller.abort(new Error('deadline')); + }); + + const result = await applier.apply(makeRecord(), { + signal: controller.signal, + onDetached: vi.fn(), + }); + + expect(result).toEqual({ + type: 'failed', + error: expect.objectContaining({ message: 'deadline' }), + }); + expect(workflowPublishedVersionRepository.setPublishedVersion).toHaveBeenCalled(); + expect(workflowTriggerActivator.activate).not.toHaveBeenCalled(); + }); + + test('an unpublish aborted before teardown leaves the published-version mapping in place', async () => { + workflowRepository.findOneBy.mockResolvedValue(makeWorkflow({ activeVersionId: null })); + workflowTriggerActivator.getEnabledTriggerNodes.mockReturnValue([triggerNode('a')]); + + await expect(applier.apply(makeRecord(), abortedContext())).rejects.toThrow('deadline'); + + expect(workflowTriggerActivator.deactivate).not.toHaveBeenCalled(); + expect(workflowPublishedVersionRepository.removePublishedVersion).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/cli/src/workflows/publication/__tests__/workflow-publication-outbox-consumer.test.ts b/packages/cli/src/workflows/publication/__tests__/workflow-publication-outbox-consumer.test.ts index 62c6d7090b0..0dcb27044de 100644 --- a/packages/cli/src/workflows/publication/__tests__/workflow-publication-outbox-consumer.test.ts +++ b/packages/cli/src/workflows/publication/__tests__/workflow-publication-outbox-consumer.test.ts @@ -21,17 +21,31 @@ describe('WorkflowPublicationOutboxConsumer', () => { const reporter = mock(); const tracing = mock(); const eventService = mock(); + const abortSignal = new AbortController().signal; let consumer: WorkflowPublicationOutboxConsumer; const POLL_INTERVAL_MS = 15_000; + const LEASE_SECONDS = 120; + // Abort fires at 70% of the lease; abandonment follows after the grace period. + const ABORT_AFTER_MS = LEASE_SECONDS * 0.7 * 1000; + const ABANDON_GRACE_MS = 10_000; - function createConsumer(useWorkflowPublicationService = true, isLeader = true, concurrency = 1) { + let lifecycleLock: WorkflowPublicationLifecycleLock; + + function createConsumer( + useWorkflowPublicationService = true, + isLeader = true, + concurrency = 1, + leaseSeconds = LEASE_SECONDS, + ) { const workflowsConfig = mock({ useWorkflowPublicationService, publicationOutboxPollIntervalMs: POLL_INTERVAL_MS, workflowPublicationConcurrency: concurrency, + publicationOutboxLeaseSeconds: leaseSeconds, }); + lifecycleLock = new WorkflowPublicationLifecycleLock(); return new WorkflowPublicationOutboxConsumer( logger, workflowsConfig, @@ -40,7 +54,7 @@ describe('WorkflowPublicationOutboxConsumer', () => { applier, reporter, mock({ isLeader }), - new WorkflowPublicationLifecycleLock(), + lifecycleLock, tracing, eventService, ); @@ -291,16 +305,19 @@ describe('WorkflowPublicationOutboxConsumer', () => { const result: PublicationResult = { type: 'completed', triggerStatuses: [] }; applier.apply.mockResolvedValue(result); - await consumer.processRecord(record); + await consumer.processRecord(record, abortSignal); - expect(applier.apply).toHaveBeenCalledWith(record); + expect(applier.apply).toHaveBeenCalledWith( + record, + expect.objectContaining({ signal: abortSignal }), + ); expect(reporter.report).toHaveBeenCalledWith(record, result); }); test('reports a failed result when the applier throws unexpectedly', async () => { applier.apply.mockRejectedValue(new Error('teardown failed')); - await consumer.processRecord(makeRecord()); + await consumer.processRecord(makeRecord(), abortSignal); expect(reporter.report).toHaveBeenCalledWith( expect.anything(), @@ -315,7 +332,7 @@ describe('WorkflowPublicationOutboxConsumer', () => { const reportError = new Error('db write failed'); reporter.report.mockRejectedValue(reportError); - await expect(consumer.processRecord(makeRecord())).resolves.toBeUndefined(); + await expect(consumer.processRecord(makeRecord(), abortSignal)).resolves.toBeUndefined(); expect(errorReporter.error).toHaveBeenCalledWith(reportError, { shouldBeLogged: true }); }); @@ -324,7 +341,7 @@ describe('WorkflowPublicationOutboxConsumer', () => { consumer = createConsumer(true, false); const record = makeRecord({ id: 7, workflowId: 'wf-7' }); - await consumer.processRecord(record); + await consumer.processRecord(record, abortSignal); expect(outboxRepository.returnToPending).toHaveBeenCalledWith(7); expect(applier.apply).not.toHaveBeenCalled(); @@ -332,6 +349,149 @@ describe('WorkflowPublicationOutboxConsumer', () => { }); }); + describe('abort and abandon', () => { + test('abandons a record that ignores the abort signal and keeps the drain alive', async () => { + const stuck = makeRecord({ id: 1, workflowId: 'wf-stuck' }); + outboxRepository.claimNextPendingRecord.mockResolvedValueOnce(stuck).mockResolvedValue(null); + applier.apply.mockImplementationOnce(async () => await new Promise(() => {})); + consumer.startPolling(); + + const drain = consumer.drainPending(); + await vi.advanceTimersByTimeAsync(ABORT_AFTER_MS + ABANDON_GRACE_MS); + + // The drain settles without the stuck record; no terminal status is + // written for it (it stays in_progress for lease reclaim). + await expect(drain).resolves.toBe(0); + expect(reporter.report).not.toHaveBeenCalled(); + expect(errorReporter.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Abandoned workflow publication outbox record'), + }), + { shouldBeLogged: true }, + ); + + // A fresh drain still processes new records. + const next = makeRecord({ id: 2, workflowId: 'wf-2' }); + outboxRepository.claimNextPendingRecord.mockResolvedValueOnce(next).mockResolvedValue(null); + await expect(consumer.drainPending()).resolves.toBe(1); + expect(reporter.report).toHaveBeenCalledTimes(1); + }); + + test('a record that settles during the grace period still gets its terminal status', async () => { + const record = makeRecord({ id: 1 }); + outboxRepository.claimNextPendingRecord.mockResolvedValueOnce(record).mockResolvedValue(null); + // Settles after the abort deadline but within the grace period. + applier.apply.mockImplementationOnce( + async () => + await new Promise((resolve) => + setTimeout( + () => resolve({ type: 'completed', triggerStatuses: [] }), + ABORT_AFTER_MS + 1000, + ), + ), + ); + consumer.startPolling(); + + const drain = consumer.drainPending(); + await vi.advanceTimersByTimeAsync(ABORT_AFTER_MS + 2000); + + await expect(drain).resolves.toBe(1); + expect(reporter.report).toHaveBeenCalledWith( + record, + expect.objectContaining({ type: 'completed' }), + ); + }); + + test('an abort honored by the applier still writes a terminal failed status', async () => { + const record = makeRecord({ id: 1 }); + outboxRepository.claimNextPendingRecord.mockResolvedValueOnce(record).mockResolvedValue(null); + applier.apply.mockImplementationOnce( + async (_record, abort) => + await new Promise((_resolve, reject) => { + abort?.signal.addEventListener('abort', () => reject(abort.signal.reason)); + }), + ); + consumer.startPolling(); + + const drain = consumer.drainPending(); + await vi.advanceTimersByTimeAsync(ABORT_AFTER_MS); + + await expect(drain).resolves.toBe(1); + expect(reporter.report).toHaveBeenCalledWith( + record, + expect.objectContaining({ + type: 'failed', + error: expect.objectContaining({ + message: expect.stringContaining('exceeded its deadline'), + }), + }), + ); + }); + + test('holds the workflow lock until abandoned trigger operations settle', async () => { + const record = makeRecord({ id: 1, workflowId: 'wf-1' }); + let releaseDetached!: () => void; + const detachedWork = new Promise((resolve) => { + releaseDetached = resolve; + }); + applier.apply.mockImplementationOnce(async (_record, abort) => { + abort?.onDetached(detachedWork); + return { type: 'failed', error: new Error('deadline') }; + }); + const controller = new AbortController(); + + const processing = consumer.processRecord(record, controller.signal); + await vi.advanceTimersByTimeAsync(0); + + // The terminal status is written, but the lock is still held for the orphan. + expect(reporter.report).toHaveBeenCalledWith( + record, + expect.objectContaining({ type: 'failed' }), + ); + expect(lifecycleLock.isLocked('wf-1')).toBe(true); + + releaseDetached(); + await processing; + expect(lifecycleLock.isLocked('wf-1')).toBe(false); + }); + + test('leaves an aborted record in progress for lease reclaim instead of applying it', async () => { + const record = makeRecord({ id: 7, workflowId: 'wf-7' }); + const controller = new AbortController(); + controller.abort(); + + await consumer.processRecord(record, controller.signal); + + // Not returned to pending: the wait for the lock may have outlived the + // lease, and flipping the row would release a newer claimant's claim. + expect(outboxRepository.returnToPending).not.toHaveBeenCalled(); + expect(applier.apply).not.toHaveBeenCalled(); + expect(reporter.report).not.toHaveBeenCalled(); + }); + + test('scales the abandon grace down for short leases so abandonment stays within the lease', async () => { + // Lease 16s: abort fires at 11.2s, grace is capped at 4s (a quarter of + // the lease) instead of the fixed 10s, so abandonment lands at 15.2s — + // still inside the lease. + consumer = createConsumer(true, true, 1, 16); + const stuck = makeRecord({ id: 1 }); + outboxRepository.claimNextPendingRecord.mockResolvedValueOnce(stuck).mockResolvedValue(null); + applier.apply.mockImplementationOnce(async () => await new Promise(() => {})); + consumer.startPolling(); + + const drain = consumer.drainPending(); + await vi.advanceTimersByTimeAsync(15_200); + + await expect(drain).resolves.toBe(0); + expect(errorReporter.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Abandoned workflow publication outbox record'), + }), + { shouldBeLogged: true }, + ); + }); + }); + describe('metrics events', () => { function lastOutcome() { const calls = eventService.emit.mock.calls.filter( @@ -354,7 +514,7 @@ describe('WorkflowPublicationOutboxConsumer', () => { async (result, expectedResult, expectedReason) => { applier.apply.mockResolvedValue(result); - await consumer.processRecord(makeRecord()); + await consumer.processRecord(makeRecord(), abortSignal); expect(lastOutcome()).toEqual( expect.objectContaining({ result: expectedResult, reason: expectedReason }), @@ -366,7 +526,7 @@ describe('WorkflowPublicationOutboxConsumer', () => { applier.apply.mockResolvedValue({ type: 'completed', triggerStatuses: [] }); reporter.report.mockRejectedValue(new Error('db write failed')); - await consumer.processRecord(makeRecord()); + await consumer.processRecord(makeRecord(), abortSignal); expect(lastOutcome()).toEqual(expect.objectContaining({ result: 'failed', reason: 'none' })); }); @@ -374,7 +534,7 @@ describe('WorkflowPublicationOutboxConsumer', () => { test('does not emit when the record is returned to the queue (no longer leader)', async () => { consumer = createConsumer(true, false); - await consumer.processRecord(makeRecord()); + await consumer.processRecord(makeRecord(), abortSignal); expect(eventService.emit).not.toHaveBeenCalledWith( 'workflow-publication-outbox-record-processed', diff --git a/packages/cli/src/workflows/publication/workflow-publication-applier.ts b/packages/cli/src/workflows/publication/workflow-publication-applier.ts index 32f475543c1..1c546929b1e 100644 --- a/packages/cli/src/workflows/publication/workflow-publication-applier.ts +++ b/packages/cli/src/workflows/publication/workflow-publication-applier.ts @@ -22,6 +22,7 @@ import { WorkflowTriggerActivator, type TriggerActivationFailure, type TriggerActivationOutcome, + type TriggerOperationAbort, } from '@/workflows/triggers/workflow-trigger-activator'; import { WorkflowPublishedDataService } from '@/workflows/workflow-published-data.service'; @@ -81,7 +82,10 @@ export class WorkflowPublicationApplier { * must correspond to that version; otherwise the wrong triggers are * (de)registered. */ - async apply(record: WorkflowPublicationOutbox): Promise { + async apply( + record: WorkflowPublicationOutbox, + abort: TriggerOperationAbort, + ): Promise { const { workflow, oldVersion, newVersion } = await this.resolveVersions(record); if (!workflow) return { type: 'skipped', reason: 'workflow-not-found' }; @@ -90,12 +94,12 @@ export class WorkflowPublicationApplier { // A null `activeVersionId` means the workflow has been unpublished, so we // reconcile its triggers down to nothing rather than to a target version. if (workflow.activeVersionId === null) { - return await this.unpublish(workflow, oldVersion, record); + return await this.unpublish(workflow, oldVersion, record, abort); } if (!newVersion) return { type: 'version-missing' }; - return await this.publish(workflow, oldVersion, newVersion, record); + return await this.publish(workflow, oldVersion, newVersion, record, abort); } /** @@ -111,6 +115,7 @@ export class WorkflowPublicationApplier { oldVersion: WorkflowHistory | null, newVersion: WorkflowHistory, record: WorkflowPublicationOutbox, + abort: TriggerOperationAbort, ): Promise { const oldTriggerNodes = this.workflowTriggerActivator.getEnabledTriggerNodes(oldVersion); const desiredTriggerNodes = this.workflowTriggerActivator.getEnabledTriggerNodes(newVersion); @@ -155,16 +160,22 @@ export class WorkflowPublicationApplier { }; } + // Abort only before the trigger (de)activation phases — those run node + // code and can be slow; every state they leave behind on a mid-apply stop + // is one a crashed leader could also leave, which retries already handle. + abort.signal.throwIfAborted(); + // Must happen BEFORE advancing the version, using the currently published // version so the right webhooks are deregistered. A teardown failure here // bubbles up so the version is not advanced. if (toRemove.size > 0 && oldVersion) { - await this.workflowTriggerActivator.deactivate(workflow, oldVersion, toRemove); + await this.workflowTriggerActivator.deactivate(workflow, oldVersion, toRemove, abort); } await this.advancePublishedVersion(record); try { + abort.signal.throwIfAborted(); if (toAdd.size > 0) { const activationMode = ACTIVATION_MODE_BY_REASON[record.reason ?? WorkflowPublicationReason.Publish]; @@ -173,6 +184,7 @@ export class WorkflowPublicationApplier { newVersion, toAdd, activationMode, + abort, ); return this.classifyActivationOutcome(outcome, desiredTriggerNodes, triggerKinds); } @@ -212,6 +224,7 @@ export class WorkflowPublicationApplier { workflow: WorkflowEntity, oldVersion: WorkflowHistory | null, record: WorkflowPublicationOutbox, + abort: TriggerOperationAbort, ): Promise { // If there is no oldVersion we may be retrying an unpublish that was // interrupted after removing the mapping: nothing to tear down, but we @@ -221,7 +234,8 @@ export class WorkflowPublicationApplier { ); if (oldVersion && toRemove.size > 0) { - await this.workflowTriggerActivator.deactivate(workflow, oldVersion, toRemove); + abort.signal.throwIfAborted(); + await this.workflowTriggerActivator.deactivate(workflow, oldVersion, toRemove, abort); } // Invalidate before the mapping is removed, so reads fall through to the diff --git a/packages/cli/src/workflows/publication/workflow-publication-outbox-consumer.ts b/packages/cli/src/workflows/publication/workflow-publication-outbox-consumer.ts index d2d2d860b84..eb2624942c5 100644 --- a/packages/cli/src/workflows/publication/workflow-publication-outbox-consumer.ts +++ b/packages/cli/src/workflows/publication/workflow-publication-outbox-consumer.ts @@ -1,11 +1,12 @@ import { Logger } from '@n8n/backend-common'; import { WorkflowsConfig } from '@n8n/config'; +import { Time } from '@n8n/constants'; import { WorkflowPublicationOutbox, WorkflowPublicationOutboxRepository } from '@n8n/db'; import { OnLeaderTakeover, OnPubSubEvent, OnShutdown } from '@n8n/decorators'; import { Service } from '@n8n/di'; import { ErrorReporter, InstanceSettings, SpanStatus, Tracing } from 'n8n-core'; import { ensureError } from '@n8n/utils/errors/ensure-error'; -import { UnexpectedError } from 'n8n-workflow'; +import { OperationalError, UnexpectedError } from 'n8n-workflow'; import { EventService } from '@/events/event.service'; import type { @@ -16,6 +17,28 @@ import type { PublicationResult } from '@/workflows/publication/publication-resu import { PublicationStatusReporter } from '@/workflows/publication/publication-status-reporter'; import { WorkflowPublicationLifecycleLock } from '@/workflows/publication/workflow-publication-lifecycle-lock'; import { WorkflowPublicationApplier } from '@/workflows/publication/workflow-publication-applier'; +import type { TriggerOperationAbort } from '@/workflows/triggers/workflow-trigger-activator'; + +/** + * Resolved by {@link WorkflowPublicationOutboxConsumer.raceTimeout} when the + * timeout elapses before the raced promise settles. + */ +const TIMED_OUT = Symbol('timed-out'); + +/** + * Fraction of the outbox lease after which a record's processing is aborted. + * Below 1 so that an abandoned record is not reclaimable the moment its worker + * moves on — the same drain could otherwise claim it straight back. + */ +const ABORT_AFTER_LEASE_FRACTION = 0.7; + +/** + * How long an aborted record may take to unwind before it is abandoned, capped + * at a quarter of the lease: deadline plus grace must stay within the lease so + * a record is never still being waited on after it became reclaimable. + */ +const ABANDON_GRACE_MS = 10 * Time.seconds.toMilliseconds; +const ABANDON_GRACE_LEASE_FRACTION = 0.25; /** * Consumes the workflow publication outbox on the leader instance. It owns the @@ -165,20 +188,19 @@ export class WorkflowPublicationOutboxConsumer { let processed = 0; // Run worker loops in parallel. Each claims and processes records // until none remain (claiming is atomic, so workers never grab the same record). - let aborted = false; + let workerFailed = false; const runWorker = async () => { - while (!aborted && this.shouldKeepPolling()) { + while (!workerFailed && this.shouldKeepPolling()) { const record = await this.outboxRepository.claimNextPendingRecord(); if (!record) break; - await this.processRecord(record); - processed++; + if (await this.processRecordWithAbort(record)) processed++; } }; const workerTasks = Array.from({ length: concurrency }, async () => { await runWorker().catch((error) => { - aborted = true; + workerFailed = true; throw error; }); }); @@ -199,6 +221,59 @@ export class WorkflowPublicationOutboxConsumer { return this.isPolling && !this.isShuttingDown; } + /** + * Processes a record under a deadline: on expiry the record's abort signal + * fires, and after a further grace period the record is abandoned (left + * `in_progress` for lease reclaim) so a hung record never wedges the drain. + * Returns whether the record settled in time. + */ + private async processRecordWithAbort(record: WorkflowPublicationOutbox): Promise { + const leaseMs = + this.workflowsConfig.publicationOutboxLeaseSeconds * Time.seconds.toMilliseconds; + const abortAfterMs = leaseMs * ABORT_AFTER_LEASE_FRACTION; + const abandonGraceMs = Math.min(ABANDON_GRACE_MS, leaseMs * ABANDON_GRACE_LEASE_FRACTION); + + const controller = new AbortController(); + const work = this.processRecord(record, controller.signal); + + if ((await this.raceTimeout(work, abortAfterMs)) !== TIMED_OUT) return true; + + controller.abort(new OperationalError('Workflow publication processing exceeded its deadline')); + + if ((await this.raceTimeout(work, abandonGraceMs)) !== TIMED_OUT) return true; + + // A late rejection of the abandoned work must not become an unhandled rejection. + void work.catch((error) => + this.errorReporter.error(ensureError(error), { shouldBeLogged: true }), + ); + this.errorReporter.error( + new OperationalError( + 'Abandoned workflow publication outbox record: processing did not stop after abort', + { extra: { outboxId: record.id, workflowId: record.workflowId } }, + ), + { shouldBeLogged: true }, + ); + return false; + } + + /** Resolves with the raced promise, or with {@link TIMED_OUT} after `timeoutMs`. */ + private async raceTimeout( + promise: Promise, + timeoutMs: number, + ): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + promise, + new Promise((resolve) => { + timer = setTimeout(() => resolve(TIMED_OUT), timeoutMs); + }), + ]); + } finally { + clearTimeout(timer); + } + } + /** * Applies a single claimed record and reports the outcome. The applier returns * a `failed` result for expected failures; an unexpected throw is wrapped into @@ -212,7 +287,7 @@ export class WorkflowPublicationOutboxConsumer { * between claiming the record and entering the critical section, the record is returned * to the queue (so the new leader reprocesses it) and nothing is applied here. */ - async processRecord(record: WorkflowPublicationOutbox): Promise { + async processRecord(record: WorkflowPublicationOutbox, signal: AbortSignal): Promise { await this.tracing.startSpan( { name: 'Publication outbox record', @@ -237,6 +312,19 @@ export class WorkflowPublicationOutboxConsumer { return; } + // The worker may have aborted this record while it queued on the lock; + // starting now would apply work long after it was abandoned. The row is + // left `in_progress` for lease reclaim rather than returned to pending: + // the wait may have outlived the lease, and flipping the row here would + // release the claim of whichever worker has since reclaimed it. + if (signal.aborted) { + this.logger.debug('Skipped applying publication outbox record: aborted', { + outboxId: record.id, + workflowId: record.workflowId, + }); + return; + } + this.logger.debug('Started processing workflow publication outbox record', { outboxId: record.id, workflowId: record.workflowId, @@ -246,13 +334,25 @@ export class WorkflowPublicationOutboxConsumer { const startedAt = Date.now(); let result: PublicationResult; + // An aborted per-node operation is abandoned, not cancelled; collect + // every orphan so the lock outlives whatever may still mutate this + // workflow's registrations. + const detachedWork: Array> = []; + const abort: TriggerOperationAbort = { + signal, + onDetached: (work) => detachedWork.push(work), + }; + try { - result = await this.applier.apply(record); + result = await this.applier.apply(record, abort); } catch (error) { const cause = ensureError(error); result = { type: 'failed', - error: new UnexpectedError(`Unexpected: ${cause.message}`, { cause }), + // An abort is our own doing, not an unexpected applier failure. + error: signal.aborted + ? cause + : new UnexpectedError(`Unexpected: ${cause.message}`, { cause }), }; } @@ -276,6 +376,17 @@ export class WorkflowPublicationOutboxConsumer { }); span.setAttribute('n8n.publication.result', result.type); + + // The terminal status is already written; keep the lock until the + // abandoned operations settle so the next record for this workflow + // can never run concurrently with them. + if (detachedWork.length > 0) { + this.logger.warn( + 'Keeping workflow publication lock held until abandoned trigger operations settle', + { outboxId: record.id, workflowId: record.workflowId }, + ); + await Promise.allSettled(detachedWork); + } }); span.setStatus({ code: SpanStatus.ok }); diff --git a/packages/cli/src/workflows/triggers/__tests__/trigger-activation-retry.test.ts b/packages/cli/src/workflows/triggers/__tests__/trigger-activation-retry.test.ts index 2590fc14e59..32b8f520490 100644 --- a/packages/cli/src/workflows/triggers/__tests__/trigger-activation-retry.test.ts +++ b/packages/cli/src/workflows/triggers/__tests__/trigger-activation-retry.test.ts @@ -1,3 +1,5 @@ +import { ensureError } from '@n8n/utils/errors/ensure-error'; +import { sleep } from '@n8n/utils/sleep'; import { WebhookPathTakenError } from 'n8n-workflow'; import { @@ -9,15 +11,19 @@ vi.mock('@n8n/utils/sleep', () => ({ sleep: vi.fn(), })); +const flushPromises = async () => await new Promise((resolve) => setImmediate(resolve)); + const MAX_ATTEMPTS = 3; describe('retryTriggerActivation', () => { + const signal = new AbortController().signal; + beforeEach(() => vi.clearAllMocks()); test('resolves without retrying when activation succeeds', async () => { const activate = vi.fn().mockResolvedValue(undefined); - await retryTriggerActivation(activate, MAX_ATTEMPTS); + await retryTriggerActivation(activate, MAX_ATTEMPTS, signal); expect(activate).toHaveBeenCalledTimes(1); }); @@ -28,7 +34,7 @@ describe('retryTriggerActivation', () => { .mockRejectedValueOnce(new Error('transient')) .mockResolvedValueOnce(undefined); - await retryTriggerActivation(activate, MAX_ATTEMPTS); + await retryTriggerActivation(activate, MAX_ATTEMPTS, signal); expect(activate).toHaveBeenCalledTimes(2); }); @@ -37,7 +43,7 @@ describe('retryTriggerActivation', () => { const error = new Error('transient'); const activate = vi.fn().mockRejectedValue(error); - await expect(retryTriggerActivation(activate, MAX_ATTEMPTS)).rejects.toBe(error); + await expect(retryTriggerActivation(activate, MAX_ATTEMPTS, signal)).rejects.toBe(error); expect(activate).toHaveBeenCalledTimes(MAX_ATTEMPTS); }); @@ -45,9 +51,30 @@ describe('retryTriggerActivation', () => { const error = new WebhookPathTakenError('Webhook'); const activate = vi.fn().mockRejectedValue(error); - await expect(retryTriggerActivation(activate, MAX_ATTEMPTS)).rejects.toBe(error); + await expect(retryTriggerActivation(activate, MAX_ATTEMPTS, signal)).rejects.toBe(error); expect(activate).toHaveBeenCalledTimes(1); }); + + test('stops sleeping and rethrows the abort reason when the signal fires during backoff', async () => { + vi.mocked(sleep).mockImplementationOnce( + async (_ms, sleepSignal) => + await new Promise((_resolve, reject) => { + sleepSignal?.addEventListener('abort', () => reject(ensureError(sleepSignal.reason)), { + once: true, + }); + }), + ); + const activate = vi.fn().mockRejectedValue(new Error('transient')); + const controller = new AbortController(); + + const retrying = retryTriggerActivation(activate, MAX_ATTEMPTS, controller.signal); + await flushPromises(); + controller.abort(new Error('deadline')); + + await expect(retrying).rejects.toThrow('deadline'); + expect(activate).toHaveBeenCalledTimes(1); + expect(sleep).toHaveBeenCalledWith(expect.any(Number), controller.signal); + }); }); describe('isTransientActivationError', () => { diff --git a/packages/cli/src/workflows/triggers/__tests__/workflow-trigger-activator.test.ts b/packages/cli/src/workflows/triggers/__tests__/workflow-trigger-activator.test.ts index b6e2648fdae..832541bd69e 100644 --- a/packages/cli/src/workflows/triggers/__tests__/workflow-trigger-activator.test.ts +++ b/packages/cli/src/workflows/triggers/__tests__/workflow-trigger-activator.test.ts @@ -31,6 +31,8 @@ const MAX_ATTEMPTS = TRIGGER_ACTIVATION_MAX_ATTEMPTS; const flushPromises = async () => await new Promise((resolve) => setImmediate(resolve)); +const abort = { signal: new AbortController().signal, onDetached: vi.fn() }; + const tracing = mock(); const eventService = mock(); @@ -286,6 +288,7 @@ describe('WorkflowTriggerActivator', () => { }, new Set(['t', 'p', 'webhook-node']), 'update', + abort, ); // Both phases overlap inside one isolate bracket, so their relative order is @@ -336,6 +339,7 @@ describe('WorkflowTriggerActivator', () => { }, new Set(['t', 'webhook-node']), 'init', + abort, ); expect(nonWebhookTriggerRegistrar.createRegistrationContext).toHaveBeenCalledWith( @@ -391,6 +395,7 @@ describe('WorkflowTriggerActivator', () => { }, new Set(['webhook-node', 'trigger-node']), 'update', + abort, ), ).rejects.toThrow('webhook discovery failed'); @@ -438,6 +443,7 @@ describe('WorkflowTriggerActivator', () => { connections: {}, }, new Set(['webhook-node', 'trigger-a', 'trigger-b']), + abort, ) .then(() => { deactivateSettled = true; @@ -505,6 +511,7 @@ describe('WorkflowTriggerActivator', () => { connections: {}, }, new Set(['webhook-node', 'trigger-node']), + abort, ), ).rejects.toThrow('webhook discovery failed'); @@ -548,6 +555,7 @@ describe('WorkflowTriggerActivator', () => { }, new Set(['webhook-ok', 'webhook-bad', 't', 'p']), 'update', + abort, ); // Both registrars ran; each phase surfaced its own failure while keeping the @@ -592,6 +600,7 @@ describe('WorkflowTriggerActivator', () => { }, new Set(['webhook-a', 'webhook-b']), 'update', + abort, ); // Parallel fan-out: assert by membership, not order. @@ -632,6 +641,7 @@ describe('WorkflowTriggerActivator', () => { { nodes: [node('webhook-a', 'webhook', { name: 'Webhook A' })], connections: {} }, new Set(['webhook-a']), 'update', + abort, ); expect(outcome).toEqual({ activated: ['webhook-a'], failures: [] }); @@ -678,6 +688,7 @@ describe('WorkflowTriggerActivator', () => { }, new Set(['webhook-a', 'webhook-b']), 'update', + abort, ); // Parallel fan-out: assert by membership, not order. @@ -719,6 +730,7 @@ describe('WorkflowTriggerActivator', () => { { nodes: [node('webhook-node', 'webhook', { name: 'Webhook' })], connections: {} }, new Set(['webhook-node']), 'update', + abort, ); expect(outcome.activated).toEqual([]); @@ -757,6 +769,7 @@ describe('WorkflowTriggerActivator', () => { { nodes: [node('t', 'trigger'), node('p', 'poll')], connections: {} }, new Set(['t', 'p']), 'update', + abort, ); expect(outcome.activated).toEqual(expect.arrayContaining(['t'])); @@ -799,6 +812,7 @@ describe('WorkflowTriggerActivator', () => { { nodes: [node('p', 'poll')], connections: {} }, new Set(['p']), 'update', + abort, ); expect(outcome).toEqual({ activated: ['p'], failures: [] }); @@ -839,6 +853,7 @@ describe('WorkflowTriggerActivator', () => { { nodes: [node('t', 'trigger')], connections: {} }, new Set(['t']), 'update', + abort, ); const context = deps.nonWebhookTriggerRegistrar.createRegistrationContext.mock.calls[0][1]; @@ -982,6 +997,7 @@ describe('WorkflowTriggerActivator', () => { { nodes: [node('webhook-a', 'webhook', { name: 'Webhook A' })], connections: {} }, new Set(['webhook-a']), 'update', + abort, ); expect(getEmissions('workflow-publication-trigger-operation')).toContainEqual( @@ -1011,6 +1027,7 @@ describe('WorkflowTriggerActivator', () => { { nodes: [node('webhook-b', 'webhook', { name: 'Webhook B' })], connections: {} }, new Set(['webhook-b']), 'update', + abort, ); expect(getEmissions('workflow-publication-trigger-operation')).toContainEqual( @@ -1036,6 +1053,7 @@ describe('WorkflowTriggerActivator', () => { mock({ id: 'wf-1', name: 'Test workflow', staticData: {}, settings: {} }), { nodes: [node('trigger-a', 'trigger')], connections: {} }, new Set(['trigger-a']), + abort, ); expect(getEmissions('workflow-publication-trigger-operation')).toContainEqual( @@ -1046,4 +1064,122 @@ describe('WorkflowTriggerActivator', () => { ); }); }); + + describe('abort', () => { + beforeEach(() => { + vi.spyOn(WorkflowExpression.prototype, 'acquireIsolate').mockResolvedValue(true); + vi.spyOn(WorkflowExpression.prototype, 'releaseIsolate').mockResolvedValue(); + vi.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue( + mock(), + ); + }); + + test('records a node whose registration hangs as failed once the signal aborts, keeping other nodes', async () => { + const webhookTriggerRegistrar = mock(); + webhookTriggerRegistrar.getWebhookTriggers.mockReturnValue([]); + const nonWebhookTriggerRegistrar = mock(); + nonWebhookTriggerRegistrar.createRegistrationContext.mockReturnValue( + mock(), + ); + nonWebhookTriggerRegistrar.getTriggerNodeIds.mockReturnValue(['ok', 'stuck']); + nonWebhookTriggerRegistrar.register.mockImplementation(async (_workflow, _reg, nodeId) => { + if (nodeId === 'stuck') await new Promise(() => {}); + }); + + const activator = buildActivator({ webhookTriggerRegistrar, nonWebhookTriggerRegistrar }); + const controller = new AbortController(); + const onDetached = vi.fn(); + + const activation = activator.activate( + mock({ id: 'wf-1', name: 'Test workflow', staticData: {}, settings: {} }), + { nodes: [node('ok', 'trigger'), node('stuck', 'trigger')], connections: {} }, + new Set(['ok', 'stuck']), + 'update', + { signal: controller.signal, onDetached }, + ); + await flushPromises(); + controller.abort(new Error('deadline')); + + const outcome = await activation; + expect(outcome.activated).toEqual(['ok']); + expect(outcome.failures).toEqual([ + expect.objectContaining({ + nodeId: 'stuck', + error: expect.objectContaining({ message: 'deadline' }), + }), + ]); + // The hung registration is handed back so the caller can outlive it. + expect(onDetached).toHaveBeenCalledTimes(1); + expect(onDetached).toHaveBeenCalledWith(expect.any(Promise)); + }); + + test('a deactivation whose teardown hangs rejects with the abort reason once the signal fires', async () => { + const webhookTriggerRegistrar = mock(); + webhookTriggerRegistrar.getWebhookTriggers.mockReturnValue([]); + const nonWebhookTriggerRegistrar = mock(); + nonWebhookTriggerRegistrar.getTriggerNodeIds.mockReturnValue(['stuck']); + nonWebhookTriggerRegistrar.deregister.mockImplementation( + async () => await new Promise(() => {}), + ); + + const activator = buildActivator({ webhookTriggerRegistrar, nonWebhookTriggerRegistrar }); + const controller = new AbortController(); + const onDetached = vi.fn(); + + const deactivation = activator.deactivate( + mock({ id: 'wf-1', name: 'Test workflow', staticData: {}, settings: {} }), + { nodes: [node('stuck', 'trigger')], connections: {} }, + new Set(['stuck']), + { signal: controller.signal, onDetached }, + ); + await flushPromises(); + controller.abort(new Error('deadline')); + + await expect(deactivation).rejects.toThrow('deadline'); + expect(onDetached).toHaveBeenCalledWith(expect.any(Promise)); + }); + + test('clears webhook rows only for nodes whose every webhook deregistered before the abort', async () => { + const webhookTriggerRegistrar = mock(); + const webhookOk = mock({ node: 'Webhook OK', path: 'ok' }); + // The stuck node has two webhooks; only one of them hangs. Its fulfilled + // sibling must not cause the node's rows to be cleared. + const webhookStuck = mock({ node: 'Webhook Stuck', path: 'hang' }); + const webhookStuckSibling = mock({ node: 'Webhook Stuck', path: 'fine' }); + webhookTriggerRegistrar.getWebhookTriggers.mockReturnValue([ + webhookOk, + webhookStuck, + webhookStuckSibling, + ]); + webhookTriggerRegistrar.deregister.mockImplementation(async ({ webhookData }) => { + if (webhookData.path === 'hang') await new Promise(() => {}); + return webhookData.node; + }); + const nonWebhookTriggerRegistrar = mock(); + nonWebhookTriggerRegistrar.getTriggerNodeIds.mockReturnValue([]); + + const activator = buildActivator({ webhookTriggerRegistrar, nonWebhookTriggerRegistrar }); + const controller = new AbortController(); + + const deactivation = activator.deactivate( + mock({ id: 'wf-1', name: 'Test workflow', staticData: {}, settings: {} }), + { + nodes: [ + node('ok-node', 'webhook', { name: 'Webhook OK' }), + node('stuck-node', 'webhook', { name: 'Webhook Stuck' }), + ], + connections: {}, + }, + new Set(['ok-node', 'stuck-node']), + { signal: controller.signal, onDetached: vi.fn() }, + ); + await flushPromises(); + controller.abort(new Error('deadline')); + + await expect(deactivation).rejects.toThrow('deadline'); + expect(webhookTriggerRegistrar.clearWorkflowWebhooksForNodes).toHaveBeenCalledWith('wf-1', [ + 'Webhook OK', + ]); + }); + }); }); diff --git a/packages/cli/src/workflows/triggers/trigger-activation-retry.ts b/packages/cli/src/workflows/triggers/trigger-activation-retry.ts index 32b1b70d172..622c7f04925 100644 --- a/packages/cli/src/workflows/triggers/trigger-activation-retry.ts +++ b/packages/cli/src/workflows/triggers/trigger-activation-retry.ts @@ -12,7 +12,9 @@ export const isTransientActivationError = (error: Error): boolean => /** * Activates a single trigger node, retrying transient failures in-process with - * exponential backoff up to `maxAttempts` before giving up. + * exponential backoff up to `maxAttempts` before giving up. Once `signal` + * aborts, no further attempt is started: a caller that abandoned this + * activation must not have an old registration committed behind its back. * * The activate function must be self-atomic — it must leave no partial state behind on * failure — so a re-attempt does not conflict with itself and needs no cleanup. @@ -20,20 +22,27 @@ export const isTransientActivationError = (error: Error): boolean => export async function retryTriggerActivation( activate: () => Promise, maxAttempts: number, + signal: AbortSignal, ): Promise { for (let attempt = 0; ; attempt++) { + signal.throwIfAborted(); try { await activate(); return; } catch (error) { const isLastAttempt = attempt >= maxAttempts - 1; - if (!isTransientActivationError(ensureError(error)) || isLastAttempt) throw error; + if (!isTransientActivationError(ensureError(error)) || isLastAttempt || signal.aborted) + throw error; + // `sleep` rejects with the abort reason as soon as the signal fires, so + // an abandoned retry unwinds promptly instead of sleeping out the backoff + // (up to a day) while its caller holds the workflow's lifecycle lock. await sleep( Math.min( WORKFLOW_REACTIVATE_INITIAL_TIMEOUT * 2 ** attempt, WORKFLOW_REACTIVATE_MAX_TIMEOUT, ), + signal, ); } } diff --git a/packages/cli/src/workflows/triggers/workflow-trigger-activator.ts b/packages/cli/src/workflows/triggers/workflow-trigger-activator.ts index 69827ae0ce7..133a71d4bb8 100644 --- a/packages/cli/src/workflows/triggers/workflow-trigger-activator.ts +++ b/packages/cli/src/workflows/triggers/workflow-trigger-activator.ts @@ -46,6 +46,45 @@ import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.serv export type { WorkflowTriggerVersion }; +/** + * How callers request that trigger (de)activation be abortable. Node + * registration and teardown code cannot be cancelled, only abandoned, and an + * abandoned operation may still mutate this workflow's registrations when it + * eventually settles. `onDetached` hands every such orphan back to the caller, + * which must not release the workflow's lifecycle lock until they settle. + */ +export interface TriggerOperationAbort { + signal: AbortSignal; + onDetached: (work: Promise) => void; +} + +/** + * Resolves with `promise`, or rejects with the abort reason once the signal + * fires — reporting the abandoned promise through `onDetached`. + */ +async function raceAbort(promise: Promise, abort: TriggerOperationAbort): Promise { + const { signal } = abort; + if (signal.aborted) { + abort.onDetached(promise); + throw ensureError(signal.reason); + } + + let onAbort!: () => void; + const aborted = new Promise((_resolve, reject) => { + onAbort = () => reject(ensureError(signal.reason)); + signal.addEventListener('abort', onAbort, { once: true }); + }); + try { + return await Promise.race([promise, aborted]); + } catch (error) { + // Reporting a promise that lost to its own rejection is harmless. + if (signal.aborted) abort.onDetached(promise); + throw error; + } finally { + signal.removeEventListener('abort', onAbort); + } +} + // Their trigger() is a no-op — fired by the execution engine, never the // registry — so reconciling them against the registry would re-enqueue forever. const PSEUDO_TRIGGER_NODE_TYPES = new Set([ @@ -232,10 +271,17 @@ export class WorkflowTriggerActivator { version: WorkflowTriggerVersion, nodeIds: Set, activationMode: WorkflowActivateMode, + abort: TriggerOperationAbort, ): Promise { const startedAt = Date.now(); try { - const outcome = await this.activateInternal(dbWorkflow, version, nodeIds, activationMode); + const outcome = await this.activateInternal( + dbWorkflow, + version, + nodeIds, + activationMode, + abort, + ); this.emitTriggerOperation( 'activate', outcome.failures.length === 0 ? 'success' : 'failure', @@ -254,6 +300,7 @@ export class WorkflowTriggerActivator { version: WorkflowTriggerVersion, nodeIds: Set, activationMode: WorkflowActivateMode, + abort: TriggerOperationAbort, ): Promise { return await this.tracing.startSpan( { @@ -289,6 +336,7 @@ export class WorkflowTriggerActivator { nodeIds, outcome, activationMode, + abort, ), this.registerNonWebhookTriggers( dbWorkflow, @@ -298,6 +346,7 @@ export class WorkflowTriggerActivator { nodeIds, outcome, activationMode, + abort, ), ]); this.throwRejectedPhaseError(phaseResults); @@ -333,12 +382,13 @@ export class WorkflowTriggerActivator { dbWorkflow: WorkflowEntity, version: WorkflowTriggerVersion, nodeIds: Set, + abort: TriggerOperationAbort, ) { if (nodeIds.size === 0) return; const startedAt = Date.now(); try { - await this.deactivateInternal(dbWorkflow, version, nodeIds); + await this.deactivateInternal(dbWorkflow, version, nodeIds, abort); this.emitTriggerOperation('deactivate', 'success', startedAt); this.emitTriggerNodeOperations('deactivate', nodeIds.size, 0); } catch (error) { @@ -352,6 +402,7 @@ export class WorkflowTriggerActivator { dbWorkflow: WorkflowEntity, version: WorkflowTriggerVersion, nodeIds: Set, + abort: TriggerOperationAbort, ) { await this.tracing.startSpan( { @@ -374,14 +425,8 @@ export class WorkflowTriggerActivator { // The non-webhook phase doesn't touch the expression isolate that the // webhook deregister acquires, so the two phases can overlap. const phaseResults = await Promise.allSettled([ - this.deregisterWebhookTriggers(workflow, additionalData, nodeIds).then( - async (removedNodeNames) => - await this.webhookTriggerRegistrar.clearWorkflowWebhooksForNodes( - dbWorkflow.id, - removedNodeNames, - ), - ), - this.deregisterNonWebhookTriggers(dbWorkflow.id, workflow, nodeIds), + this.deregisterWebhookTriggers(workflow, additionalData, nodeIds, abort), + this.deregisterNonWebhookTriggers(dbWorkflow.id, workflow, nodeIds, abort), ]); this.throwRejectedPhaseError(phaseResults); @@ -492,6 +537,7 @@ export class WorkflowTriggerActivator { nodeIds: Set, outcome: TriggerActivationOutcome, activationMode: WorkflowActivateMode, + abort: TriggerOperationAbort, ) { const webhooksByNode = this.groupWebhookTriggersByNode(workflow, additionalData, nodeIds); @@ -503,6 +549,7 @@ export class WorkflowTriggerActivator { nodeName, webhooks, activationMode, + abort, ), ); @@ -523,18 +570,23 @@ export class WorkflowTriggerActivator { nodeName: string, webhooks: IWebhookData[], activationMode: WorkflowActivateMode, + abort: TriggerOperationAbort, ): Promise> { try { for (const webhookData of webhooks) { - await retryTriggerActivation( - async () => - await this.webhookTriggerRegistrar.register({ - workflow, - webhookData, - mode: 'trigger', - activation: activationMode, - }), - TRIGGER_ACTIVATION_MAX_ATTEMPTS, + await raceAbort( + retryTriggerActivation( + async () => + await this.webhookTriggerRegistrar.register({ + workflow, + webhookData, + mode: 'trigger', + activation: activationMode, + }), + TRIGGER_ACTIVATION_MAX_ATTEMPTS, + abort.signal, + ), + abort, ); } return createResultOk({ nodeId }); @@ -575,8 +627,10 @@ export class WorkflowTriggerActivator { workflow: Workflow, additionalData: IWorkflowExecuteAdditionalData, nodeIds: Set, + abort: TriggerOperationAbort, ) { const removedNodeNames: string[] = []; + let firstFailure: Error | undefined; await workflow.expression.acquireIsolate(); try { @@ -585,18 +639,43 @@ export class WorkflowTriggerActivator { const deregistrationResults = await Promise.allSettled( webhooks.map( async (webhookData) => - await this.webhookTriggerRegistrar.deregister({ workflow, webhookData }), + await raceAbort( + this.webhookTriggerRegistrar.deregister({ workflow, webhookData }), + abort, + ), ), ); - for (const result of deregistrationResults) { - if (result.status === 'rejected') throw ensureError(result.reason); - removedNodeNames.push(result.value); + // Row cleanup is per node, so only a node whose EVERY webhook + // deregistered may be cleared: a failed or abandoned webhook's row is + // the only record left for deregistering it externally later. + const pendingWebhooksByNode = new Map(); + for (const webhookData of webhooks) { + pendingWebhooksByNode.set( + webhookData.node, + (pendingWebhooksByNode.get(webhookData.node) ?? 0) + 1, + ); } + deregistrationResults.forEach((result, index) => { + if (result.status === 'rejected') { + firstFailure ??= ensureError(result.reason); + return; + } + const nodeName = webhooks[index].node; + const pending = (pendingWebhooksByNode.get(nodeName) ?? 0) - 1; + pendingWebhooksByNode.set(nodeName, pending); + if (pending === 0) removedNodeNames.push(nodeName); + }); } finally { await workflow.expression.releaseIsolate(); } + // Clear rows for the nodes that fully deregistered even when another + // node's teardown failed or was abandoned, so their `webhook_entity` rows + // don't outlive the deregistration. + await this.webhookTriggerRegistrar.clearWorkflowWebhooksForNodes(workflow.id, removedNodeNames); + if (firstFailure) throw firstFailure; + await this.workflowStaticDataService.saveStaticData(workflow); return removedNodeNames; @@ -627,6 +706,7 @@ export class WorkflowTriggerActivator { nodeIds: Set, outcome: TriggerActivationOutcome, activationMode: WorkflowActivateMode, + abort: TriggerOperationAbort, ) { const triggerNodeIds = this.getNonWebhookTriggerNodeIdsForNodeIds(workflow, nodeIds); if (triggerNodeIds.length === 0) return; @@ -652,10 +732,16 @@ export class WorkflowTriggerActivator { const results = await Promise.all( triggerNodeIds.map(async (nodeId): Promise> => { try { - await retryTriggerActivation( - async () => - await this.nonWebhookTriggerRegistrar.register(workflow, registration, nodeId), - TRIGGER_ACTIVATION_MAX_ATTEMPTS, + // NOTE: to abort the actual trigger operation, we would need to pass the signal all the way + // down to the node. This doesn't happen today, but could in the future. + await raceAbort( + retryTriggerActivation( + async () => + await this.nonWebhookTriggerRegistrar.register(workflow, registration, nodeId), + TRIGGER_ACTIVATION_MAX_ATTEMPTS, + abort.signal, + ), + abort, ); return createResultOk(nodeId); @@ -690,12 +776,14 @@ export class WorkflowTriggerActivator { workflowId: WorkflowId, workflow: Workflow, nodeIds: Set, + abort: TriggerOperationAbort, ) { const triggerNodeIds = this.getNonWebhookTriggerNodeIdsForNodeIds(workflow, nodeIds); await Promise.all( triggerNodeIds.map( - async (nodeId) => await this.nonWebhookTriggerRegistrar.deregister(workflowId, nodeId), + async (nodeId) => + await raceAbort(this.nonWebhookTriggerRegistrar.deregister(workflowId, nodeId), abort), ), ); } @@ -759,14 +847,19 @@ export class WorkflowTriggerActivator { this.triggerExecutionContextFactory.executeErrorWorkflow(activationError, workflowData, mode); // `addTriggers` does not own the expression isolate, so acquire it per attempt. - await retryTriggerActivation(async () => { - await workflow.expression.acquireIsolate(); - try { - await this.nonWebhookTriggerRegistrar.register(workflow, registration, node.id); - } finally { - await workflow.expression.releaseIsolate(); - } - }, TRIGGER_ACTIVATION_MAX_ATTEMPTS); + await retryTriggerActivation( + async () => { + await workflow.expression.acquireIsolate(); + try { + await this.nonWebhookTriggerRegistrar.register(workflow, registration, node.id); + } finally { + await workflow.expression.releaseIsolate(); + } + }, + TRIGGER_ACTIVATION_MAX_ATTEMPTS, + // Runtime reactivation has no abort context; never aborts. + new AbortController().signal, + ); await this.workflowStaticDataService.saveStaticData(workflow); diff --git a/packages/cli/test/integration/workflows/workflow-publication-outbox-consumer.test.ts b/packages/cli/test/integration/workflows/workflow-publication-outbox-consumer.test.ts index 355fa4ee8d8..fe4c4baa167 100644 --- a/packages/cli/test/integration/workflows/workflow-publication-outbox-consumer.test.ts +++ b/packages/cli/test/integration/workflows/workflow-publication-outbox-consumer.test.ts @@ -38,6 +38,8 @@ mockInstance(WorkflowService); mockInstance(OwnershipService); mockInstance(ExternalHooks); +const abortSignal = new AbortController().signal; + let consumer: WorkflowPublicationOutboxConsumer; let activeWorkflowManager: ActiveWorkflowManager; let activeWorkflowTriggers: ActiveWorkflowTriggers; @@ -125,7 +127,7 @@ describe('WorkflowPublicationOutboxConsumer (integration)', () => { const record = await outboxRepository.claimNextPendingRecord(); expect(record).not.toBeNull(); - await consumer.processRecord(record!); + await consumer.processRecord(record!, abortSignal); // Surgical in-memory result: unchanged kept, removed gone, added registered. const state = activeWorkflowTriggers.get(workflow.id); @@ -165,7 +167,7 @@ describe('WorkflowPublicationOutboxConsumer (integration)', () => { await outboxRepository.enqueue(workflow.id, workflow.versionId, 'publish'); const record = await outboxRepository.claimNextPendingRecord(); - await consumer.processRecord(record!); + await consumer.processRecord(record!, abortSignal); // `missing` got re-registered; `present` was left untouched (same response object). const state = activeWorkflowTriggers.get(workflow.id); @@ -192,7 +194,7 @@ describe('WorkflowPublicationOutboxConsumer (integration)', () => { await outboxRepository.enqueue(workflow.id, workflow.versionId, 'publish'); const record = await outboxRepository.claimNextPendingRecord(); - await consumer.processRecord(record!); + await consumer.processRecord(record!, abortSignal); // Nothing re-registered (same response object) and the version is unchanged. expect(activeWorkflowTriggers.get(workflow.id)?.get(trigger.id)).toBe(responseBefore); @@ -226,7 +228,7 @@ describe('WorkflowPublicationOutboxConsumer (integration)', () => { await outboxRepository.enqueue(workflow.id, newVersionId, 'publish'); const record = await outboxRepository.claimNextPendingRecord(); - await consumer.processRecord(record!); + await consumer.processRecord(record!, abortSignal); expect(activeWorkflowTriggers.get(workflow.id)?.has(trigger.id)).toBe(true); const published = await publishedVersionRepository.getPublishedVersionWithRelations( diff --git a/packages/cli/test/integration/workflows/workflow-publication-reconciler.test.ts b/packages/cli/test/integration/workflows/workflow-publication-reconciler.test.ts index 0cc373316ea..fbc2c4be782 100644 --- a/packages/cli/test/integration/workflows/workflow-publication-reconciler.test.ts +++ b/packages/cli/test/integration/workflows/workflow-publication-reconciler.test.ts @@ -43,6 +43,8 @@ mockInstance(OwnershipService); mockInstance(ExternalHooks); let reconciler: WorkflowPublicationReconciler; +const abortSignal = new AbortController().signal; + let consumer: WorkflowPublicationOutboxConsumer; let activeWorkflowTriggers: ActiveWorkflowTriggers; let outboxRepository: WorkflowPublicationOutboxRepository; @@ -123,7 +125,7 @@ describe('WorkflowPublicationReconciler (integration)', () => { // the reporter persists the `activated` trigger-status rows with kinds. await outboxRepository.enqueue(workflow.id, workflow.versionId, 'publish'); const record = await outboxRepository.claimNextPendingRecord(); - await consumer.processRecord(record!); + await consumer.processRecord(record!, abortSignal); expect(activeWorkflowTriggers.get(workflow.id)?.has(trigger.id)).toBe(true); // The leader-transition race: a demoted main consumed the outbox record @@ -154,7 +156,7 @@ describe('WorkflowPublicationReconciler (integration)', () => { await outboxRepository.enqueue(workflow.id, workflow.versionId, 'publish'); const record = await outboxRepository.claimNextPendingRecord(); - await consumer.processRecord(record!); + await consumer.processRecord(record!, abortSignal); // A fresh boot: the registry is empty, the published state is intact. await activeWorkflowTriggers.remove(workflow.id); @@ -197,7 +199,7 @@ describe('WorkflowPublicationReconciler (integration)', () => { // `activated` trigger-status rows. await outboxRepository.enqueue(workflow.id, workflow.versionId, 'publish'); const record = await outboxRepository.claimNextPendingRecord(); - await consumer.processRecord(record!); + await consumer.processRecord(record!, abortSignal); // An unpublish interrupted after removing the published-version mapping // but before the reporter cleared the trigger-status rows, with its outbox @@ -227,7 +229,7 @@ describe('WorkflowPublicationReconciler (integration)', () => { await outboxRepository.enqueue(workflow.id, workflow.versionId, 'publish'); const record = await outboxRepository.claimNextPendingRecord(); - await consumer.processRecord(record!); + await consumer.processRecord(record!, abortSignal); expect(activeWorkflowTriggers.get(workflow.id)?.has(trigger.id)).toBe(true); // A demoted main consumed the unpublish record: workflow deactivated, @@ -254,7 +256,7 @@ describe('WorkflowPublicationReconciler (integration)', () => { await outboxRepository.enqueue(workflow.id, workflow.versionId, 'publish'); const record = await outboxRepository.claimNextPendingRecord(); - await consumer.processRecord(record!); + await consumer.processRecord(record!, abortSignal); // A re-leased unpublish torn between two mains: mapping removed and the // record completed elsewhere, but this leader's registry AND the @@ -280,7 +282,7 @@ describe('WorkflowPublicationReconciler (integration)', () => { await outboxRepository.enqueue(workflow.id, workflow.versionId, 'publish'); const record = await outboxRepository.claimNextPendingRecord(); - await consumer.processRecord(record!); + await consumer.processRecord(record!, abortSignal); // Mid-unpublish: activeVersionId already cleared, pending record owns the // teardown. Reconciliation must not race it. @@ -300,7 +302,7 @@ describe('WorkflowPublicationReconciler (integration)', () => { await setActiveVersion(workflow.id, workflow.versionId); await outboxRepository.enqueue(workflow.id, workflow.versionId, 'publish'); - await consumer.processRecord((await outboxRepository.claimNextPendingRecord())!); + await consumer.processRecord((await outboxRepository.claimNextPendingRecord())!, abortSignal); // A parameter-only newer version is published: the trigger node set is // identical, so no node-id diff can distinguish the two versions. @@ -311,7 +313,7 @@ describe('WorkflowPublicationReconciler (integration)', () => { }); await setActiveVersion(workflow.id, newVersionId); await outboxRepository.enqueue(workflow.id, newVersionId, 'publish'); - await consumer.processRecord((await outboxRepository.claimNextPendingRecord())!); + await consumer.processRecord((await outboxRepository.claimNextPendingRecord())!, abortSignal); expect(await publishedVersionRepository.getPublishedVersionId(workflow.id)).toBe(newVersionId); // A stalled processor (zombie writer) rolls the mapping back to the old @@ -336,7 +338,7 @@ describe('WorkflowPublicationReconciler (integration)', () => { await setActiveVersion(workflow.id, workflow.versionId); await outboxRepository.enqueue(workflow.id, workflow.versionId, 'publish'); - await consumer.processRecord((await outboxRepository.claimNextPendingRecord())!); + await consumer.processRecord((await outboxRepository.claimNextPendingRecord())!, abortSignal); // An unpublish fully applied elsewhere (triggers down, status rows // cleared, record terminal), after which a zombie writer restored the @@ -363,7 +365,7 @@ describe('WorkflowPublicationReconciler (integration)', () => { await setActiveVersion(workflow.id, workflow.versionId); await outboxRepository.enqueue(workflow.id, workflow.versionId, 'publish'); - await consumer.processRecord((await outboxRepository.claimNextPendingRecord())!); + await consumer.processRecord((await outboxRepository.claimNextPendingRecord())!, abortSignal); // Mid-flight publish of a parameter-only new version: `activeVersionId` // commits together with the pending record, and the mapping still points @@ -400,7 +402,7 @@ describe('WorkflowPublicationReconciler (integration)', () => { await setActiveVersion(workflow.id, workflow.versionId); await outboxRepository.enqueue(workflow.id, workflow.versionId, 'publish'); - await consumer.processRecord((await outboxRepository.claimNextPendingRecord())!); + await consumer.processRecord((await outboxRepository.claimNextPendingRecord())!, abortSignal); const newVersionId = 'version-2-status-drift'; await createWorkflowHistory(workflow, owner, undefined, { @@ -409,7 +411,7 @@ describe('WorkflowPublicationReconciler (integration)', () => { }); await setActiveVersion(workflow.id, newVersionId); await outboxRepository.enqueue(workflow.id, newVersionId, 'publish'); - await consumer.processRecord((await outboxRepository.claimNextPendingRecord())!); + await consumer.processRecord((await outboxRepository.claimNextPendingRecord())!, abortSignal); // A zombie writer rewrites the status rows for the old version after its // record already resolved. The mapping still agrees with `activeVersionId`, @@ -478,7 +480,7 @@ describe('WorkflowPublicationReconciler (integration)', () => { await setActiveVersion(workflow.id, workflow.versionId); await outboxRepository.enqueue(workflow.id, workflow.versionId, 'publish'); - await consumer.processRecord((await outboxRepository.claimNextPendingRecord())!); + await consumer.processRecord((await outboxRepository.claimNextPendingRecord())!, abortSignal); // A v2 publish that crashed after advancing the mapping but before the // reporter rewrote the rows: mapping equals `activeVersionId`, the record @@ -511,7 +513,7 @@ describe('WorkflowPublicationReconciler (integration)', () => { const drifted = await createWorkflowWithHistory({ active: true, nodes: [driftTrigger] }, owner); await setActiveVersion(drifted.id, drifted.versionId); await outboxRepository.enqueue(drifted.id, drifted.versionId, 'publish'); - await consumer.processRecord((await outboxRepository.claimNextPendingRecord())!); + await consumer.processRecord((await outboxRepository.claimNextPendingRecord())!, abortSignal); const newVersionId = 'version-2-drift-in-flight'; await createWorkflowHistory(drifted, owner, undefined, { versionId: newVersionId, @@ -552,7 +554,7 @@ describe('WorkflowPublicationReconciler (integration)', () => { await setActiveVersion(workflow.id, workflow.versionId); await outboxRepository.enqueue(workflow.id, workflow.versionId, 'publish'); - await consumer.processRecord((await outboxRepository.claimNextPendingRecord())!); + await consumer.processRecord((await outboxRepository.claimNextPendingRecord())!, abortSignal); // Activation is untouched: a genuine publish still registers the no-op // trigger's registry slot, and the status row (whose presence drives the @@ -584,7 +586,7 @@ describe('WorkflowPublicationReconciler (integration)', () => { await outboxRepository.enqueue(workflow.id, workflow.versionId, 'publish'); const record = await outboxRepository.claimNextPendingRecord(); - await consumer.processRecord(record!); + await consumer.processRecord(record!, abortSignal); const registeredBefore = activeWorkflowTriggers.get(workflow.id)?.get(trigger.id); expect(registeredBefore).toBeDefined(); From 7792fdbbc7695898137984bfda2ca15e105f1ba0 Mon Sep 17 00:00:00 2001 From: Nour Alhadi Mahmoud Date: Thu, 13 Aug 2026 13:51:15 +0000 Subject: [PATCH 0015/1458] fix(editor): Clarify external secret expression previews (#36064) Co-authored-by: Cursor --- .../frontend/@n8n/i18n/src/locales/en.json | 2 + .../composables/useResolvedExpression.test.ts | 28 ++++++ .../app/composables/useResolvedExpression.ts | 22 ++++- .../src/app/utils/expressions.test.ts | 60 ++++++++++++ .../editor-ui/src/app/utils/expressions.ts | 92 +++++++++++++++++++ .../components/ExpressionEditModal.test.ts | 26 ++++++ .../components/ExpressionEditModal.vue | 5 +- .../ExpressionEditorModalInput.vue | 4 + .../parameters/components/ParameterInput.vue | 1 + .../composables/useExpressionEditor.test.ts | 69 ++++++++++++++ .../composables/useExpressionEditor.ts | 46 ++++++++-- 11 files changed, 339 insertions(+), 16 deletions(-) diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 410d972fd67..a004bc3d0ca 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -1672,6 +1672,8 @@ "expressionEditor.uncalledFunction": "[this is a function, please add ()]", "expressionEditor.deprecated.getPairedItem": "$getPairedItem is deprecated and will be removed", "expressionModalInput.empty": "[empty]", + "expressionModalInput.evaluatedDuringExecution": "[evaluated during execution]", + "expressionModalInput.secretNotFound": "[secret not found]", "expressionModalInput.undefined": "[undefined]", "expressionModalInput.null": "null", "expressionTip.noExecutionData": "Execute previous nodes to use input data", diff --git a/packages/frontend/editor-ui/src/app/composables/useResolvedExpression.test.ts b/packages/frontend/editor-ui/src/app/composables/useResolvedExpression.test.ts index 0dff0ba8f64..31780728b7e 100644 --- a/packages/frontend/editor-ui/src/app/composables/useResolvedExpression.test.ts +++ b/packages/frontend/editor-ui/src/app/composables/useResolvedExpression.test.ts @@ -85,6 +85,34 @@ describe('useResolvedExpression', () => { expect(toValue(resolvedExpressionString)).toBe('[ERROR: Test error]'); }); + it('should defer transformed credential secret previews until execution', async () => { + mockResolveExpression().mockResolvedValue(undefined); + const { resolvedExpressionString } = await renderTestComponent({ + expression: "={{ JSON.parse($secrets.aws['preview-test']).password }}", + isForCredential: true, + additionalData: { + $secrets: { aws: { 'preview-test': '*********' } }, + }, + }); + + await nextTick(); + expect(toValue(resolvedExpressionString)).toBe('[evaluated during execution]'); + }); + + it('should not defer credential secret previews with an unknown secret reference', async () => { + mockResolveExpression().mockResolvedValue(undefined); + const { resolvedExpressionString } = await renderTestComponent({ + expression: "={{ JSON.parse($secrets.aws['name-with-typo']).password }}", + isForCredential: true, + additionalData: { + $secrets: { aws: { 'preview-test': '*********' } }, + }, + }); + + await nextTick(); + expect(toValue(resolvedExpressionString)).toBe('[secret not found]'); + }); + it('should debounce updates', async () => { const resolveExpressionSpy = mockResolveExpression().mockResolvedValue(4); const expression = ref('={{ testValue }}'); diff --git a/packages/frontend/editor-ui/src/app/composables/useResolvedExpression.ts b/packages/frontend/editor-ui/src/app/composables/useResolvedExpression.ts index b6fe0549fa1..f3fab43a337 100644 --- a/packages/frontend/editor-ui/src/app/composables/useResolvedExpression.ts +++ b/packages/frontend/editor-ui/src/app/composables/useResolvedExpression.ts @@ -1,6 +1,7 @@ import { useNDVStore } from '@/features/ndv/shared/ndv.store'; import { injectWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store'; import { + getExternalSecretPreview, isExpression as isExpressionUtil, stringifyExpressionResult, } from '@/app/utils/expressions'; @@ -108,11 +109,22 @@ export function useResolvedExpression({ if (currentInvocation !== updateExpressionInvocation) return; resolvedExpression.value = resolved.ok ? resolved.result : null; - resolvedExpressionString.value = stringifyExpressionResult( - resolved, - workflowDocumentStore.value.getPinDataSnapshot(), - hasRunData.value, - ); + const expressionString = toValue(expression); + const secretPreview = + resolved.ok && + resolved.result === undefined && + toValue(isForCredential) && + typeof expressionString === 'string' + ? getExternalSecretPreview(expressionString, toValue(additionalData)?.$secrets) + : undefined; + + resolvedExpressionString.value = + secretPreview?.text ?? + stringifyExpressionResult( + resolved, + workflowDocumentStore.value.getPinDataSnapshot(), + hasRunData.value, + ); } else { resolvedExpression.value = null; resolvedExpressionString.value = ''; diff --git a/packages/frontend/editor-ui/src/app/utils/expressions.test.ts b/packages/frontend/editor-ui/src/app/utils/expressions.test.ts index ddf434a59ca..affa6ffbd5d 100644 --- a/packages/frontend/editor-ui/src/app/utils/expressions.test.ts +++ b/packages/frontend/editor-ui/src/app/utils/expressions.test.ts @@ -1,6 +1,7 @@ import { ExpressionError } from 'n8n-workflow'; import { completeExpressionSyntax, + getExternalSecretPreview, shouldConvertToExpression, removeExpressionPrefix, stringifyExpressionResult, @@ -9,6 +10,65 @@ import { import { executionRetryMessage } from '@/features/execution/executions/executions.utils'; describe('Utils: Expressions', () => { + describe('getExternalSecretPreview()', () => { + const secrets = { + vault: { + key: '*********', + 'json/path': '*********', + }, + }; + + it.each([ + '={{ $secrets.vault.key }}', + "={{ JSON.parse($secrets.vault['json/path']).password }}", + '={{ JSON.parse($secrets . vault ["json/path"]).password }}', + '={{ $secrets.vault.key + $secrets.vault["json/path"] }}', + '={{ $secrets[\'vault\']["key"] }}', + ])('should defer an existing secret in "%s"', (expression) => { + expect(getExternalSecretPreview(expression, secrets)).toEqual({ + text: '[evaluated during execution]', + exists: true, + }); + }); + + it.each([ + '={{ $secrets.invalidVault.key }}', + '={{ $secrets.vault.nameWithTypo }}', + '={{ $secrets.vault.key + $secrets.vault.nameWithTypo }}', + '={{ $secrets.invalidVault[$vars.key] }}', + ])('should report a secret that does not exist in "%s"', (expression) => { + expect(getExternalSecretPreview(expression, secrets)).toEqual({ + text: '[secret not found]', + exists: false, + }); + }); + + it.each([ + '={{ $secrets.vault.key.nameWithTypo }}', + '={{ $secrets.vault.key?.nameWithTypo }}', + '={{ $secrets[provider].key }}', + '={{ $secrets.vault[$vars.secret] }}', + ])('should not judge a secret path it cannot read in "%s"', (expression) => { + expect(getExternalSecretPreview(expression, secrets)).toBeUndefined(); + }); + + it.each(['={{ $json.password }}', 'plain $secrets.vault.key'])( + 'should ignore non-expression secret references in "%s"', + (expression) => { + expect(getExternalSecretPreview(expression, secrets)).toBeUndefined(); + }, + ); + + it.each([undefined, {}, { vault: {} }])( + 'should not claim a missing secret when metadata is %s', + (unavailableSecrets) => { + expect( + getExternalSecretPreview('={{ $secrets.vault.key }}', unavailableSecrets), + ).toBeUndefined(); + }, + ); + }); + describe('stringifyExpressionResult()', () => { it('should return empty string for non-critical errors', () => { expect( diff --git a/packages/frontend/editor-ui/src/app/utils/expressions.ts b/packages/frontend/editor-ui/src/app/utils/expressions.ts index 7ba6c14a2c1..6e4079721c3 100644 --- a/packages/frontend/editor-ui/src/app/utils/expressions.ts +++ b/packages/frontend/editor-ui/src/app/utils/expressions.ts @@ -2,9 +2,101 @@ import { i18n } from '@n8n/i18n'; import type { ResolvableState } from '@/app/types/expressions'; import type { Result } from '@n8n/utils/result'; import { ExpressionError, ExpressionParser, isExpression, type IPinData } from 'n8n-workflow'; +import { isObject } from '@/app/utils/objectUtils'; export { isExpression }; +type ExternalSecretReferenceState = 'none' | 'known' | 'missing' | 'unknown'; + +const SECRET_REFERENCE = /\$secrets\b/; +/** The only key forms we can read at edit time: `.key`, `['key']`, `["key"]`. */ +const LITERAL_KEY_ACCESS = + /^\s*(?:\.\s*(?[a-zA-Z_$][\w$]*)|\[\s*(?['"])(?[^\\]*?)\k\s*\])/; +/** Any further access, e.g. `[$vars.key]`, `?.key`, `['a\'b']`. */ +const UNREAD_ACCESS = /^\s*(?:\?\.|\.|\[)/; + +/** + * Reads the keys directly after a `$secrets` occurrence, so `.vault['key']` yields + * `['vault', 'key']`. `hasUnreadAccess` flags a chain that goes on in some other form. + */ +const readLiteralKeys = (afterReference: string): { keys: string[]; hasUnreadAccess: boolean } => { + const keys: string[] = []; + let rest = afterReference; + + let access = LITERAL_KEY_ACCESS.exec(rest); + + while (access) { + keys.push(access.groups?.dotKey ?? access.groups?.quotedKey ?? ''); + rest = rest.slice(access[0].length); + access = LITERAL_KEY_ACCESS.exec(rest); + } + + return { keys, hasUnreadAccess: UNREAD_ACCESS.test(rest) }; +}; + +/** + * Walks the keys through the masked secrets metadata: `['vault', 'key']` in + * `{ vault: { key: '***' } }` is `known`, an absent key is `missing`, anything else `unknown`. + */ +const lookUpKeys = ( + keys: string[], + secrets: unknown, +): Exclude => { + let value: unknown = secrets; + + for (const key of keys) { + if (!isObject(value)) return 'unknown'; + // Metadata that never loaded is empty, which must not read as a wrong path. + if (Object.keys(value).length === 0) return 'unknown'; + if (!Object.hasOwn(value, key)) return 'missing'; + value = value[key]; + } + + return typeof value === 'string' ? 'known' : 'unknown'; +}; + +/** + * Looks up every `$secrets` reference in the expression's code against the secrets metadata, + * reporting `known` only when each one reads in full and lands on an existing secret. + */ +const getExternalSecretReferenceState = ( + expression: string, + secrets: unknown, +): ExternalSecretReferenceState => { + let state: ExternalSecretReferenceState = 'none'; + + for (const { type, text: code } of ExpressionParser.splitExpression(expression)) { + if (type === 'text') continue; + + const [, ...afterReferences] = code.split(SECRET_REFERENCE); + + for (const afterReference of afterReferences) { + const { keys, hasUnreadAccess } = readLiteralKeys(afterReference); + const keyState = lookUpKeys(keys, secrets); + const referenceState = hasUnreadAccess && keyState === 'known' ? 'unknown' : keyState; + + if (referenceState !== 'known') return referenceState; + state = 'known'; + } + } + + return state; +}; + +export const getExternalSecretPreview = ( + expression: string, + secrets: unknown, +): { text: string; exists: boolean } | undefined => { + switch (getExternalSecretReferenceState(expression, secrets)) { + case 'known': + return { text: i18n.baseText('expressionModalInput.evaluatedDuringExecution'), exists: true }; + case 'missing': + return { text: i18n.baseText('expressionModalInput.secretNotFound'), exists: false }; + default: + return undefined; + } +}; + export const isEmptyExpression = (expr: string) => { return /\{\{\s*\}\}/.test(expr); }; diff --git a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditModal.test.ts b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditModal.test.ts index b30df2ab7af..95447c2ca89 100644 --- a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditModal.test.ts +++ b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditModal.test.ts @@ -6,6 +6,9 @@ import { setActivePinia, type Pinia } from 'pinia'; import { defaultSettings } from '@/__tests__/defaults'; import { useSettingsStore } from '@n8n/stores/settings.store'; import { createTestNodeProperties } from '@/__tests__/mocks'; +import { useUIStore } from '@/app/stores/ui.store'; +import { CREDENTIAL_EDIT_MODAL_KEY } from '@/features/credentials/credentials.constants'; +import { Expression } from 'n8n-workflow'; vi.mock('vue-router', () => { const push = vi.fn(); @@ -82,6 +85,29 @@ describe('ExpressionEditModal', () => { }); }); + it('previews external secrets with the data passed by the credential modal', async () => { + // The evaluator returns undefined for a transformed secret, which its type does not admit. + vi.spyOn(Expression, 'resolveWithoutWorkflow').mockReturnValue(undefined as unknown as string); + useUIStore().modalsById[CREDENTIAL_EDIT_MODAL_KEY].open = true; + + const { getByTestId } = renderModal({ + pinia, + props: { + parameter: createTestNodeProperties({ name: 'foo', type: 'string' }), + path: '', + modelValue: "={{ JSON.parse($secrets.vault['json/path']).password }}", + dialogVisible: true, + additionalExpressionData: { $secrets: { vault: { 'json/path': '*********' } } }, + }, + }); + + await waitFor(() => { + expect(getByTestId('expression-modal-output')).toHaveTextContent( + '[evaluated during execution]', + ); + }); + }); + describe('output render mode', () => { it('renders all three render mode options', async () => { const { getByRole } = renderModal({ diff --git a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditModal.vue b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditModal.vue index eb69a8e9614..c12cab7ea51 100644 --- a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditModal.vue +++ b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditModal.vue @@ -10,7 +10,7 @@ import { createExpressionTelemetryPayload } from '@/app/utils/telemetryUtils'; import { useTelemetry } from '@n8n/composables/useTelemetry'; import type { Segment } from '@/app/types/expressions'; -import type { INodeProperties } from 'n8n-workflow'; +import type { IDataObject, INodeProperties } from 'n8n-workflow'; import { NodeConnectionTypes } from 'n8n-workflow'; import { outputTheme } from './ExpressionEditorModal/theme'; import ExpressionOutput from '@/features/shared/editors/components/InlineExpressionEditor/ExpressionOutput.vue'; @@ -44,6 +44,7 @@ type Props = { eventSource?: string; redactValues?: boolean; isReadOnly?: boolean; + additionalExpressionData?: IDataObject; }; const props = withDefaults(defineProps(), { @@ -51,6 +52,7 @@ const props = withDefaults(defineProps(), { dialogVisible: false, redactValues: false, isReadOnly: false, + additionalExpressionData: () => ({}), }); const emit = defineEmits<{ 'update:model-value': [value: string]; @@ -223,6 +225,7 @@ const onResizeThrottle = useThrottleFn(onResize, 10); :model-value="modelValue" :is-read-only="isReadOnly" :path="path" + :additional-data="additionalExpressionData" :class="[ $style.editor, { diff --git a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditorModal/ExpressionEditorModalInput.vue b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditorModal/ExpressionEditorModalInput.vue index ce26c9c4c8e..0bfd6082d18 100644 --- a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditorModal/ExpressionEditorModalInput.vue +++ b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditorModal/ExpressionEditorModalInput.vue @@ -16,17 +16,20 @@ import { mappingDropCursor } from '@/features/shared/editors/plugins/codemirror/ import { editorKeymap } from '@/features/shared/editors/plugins/codemirror/keymap'; import { expressionCloseBrackets } from '@/features/shared/editors/plugins/codemirror/expressionCloseBrackets'; import type { TargetNodeParameterContext } from '@/Interface'; +import type { IDataObject } from 'n8n-workflow'; type Props = { modelValue: string; path: string; targetNodeParameterContext?: TargetNodeParameterContext; isReadOnly?: boolean; + additionalData?: IDataObject; }; const props = withDefaults(defineProps(), { isReadOnly: false, targetNodeParameterContext: undefined, + additionalData: () => ({}), }); const emit = defineEmits<{ @@ -60,6 +63,7 @@ const { segments, readEditorValue, editor, hasFocus, focus } = useExpressionEdit parameterPath: props.path, }, targetNodeParameterContext: props.targetNodeParameterContext, + additionalData: () => props.additionalData, }); watch( diff --git a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ParameterInput.vue b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ParameterInput.vue index 6e243e66f5f..9347fdc25ac 100644 --- a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ParameterInput.vue +++ b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ParameterInput.vue @@ -1497,6 +1497,7 @@ onUpdated(async () => { :event-source="eventSource || 'ndv'" :is-read-only="isReadOnly" :redact-values="shouldRedactValue" + :additional-expression-data="additionalExpressionData" @close-dialog="closeExpressionEditDialog" @update:model-value="expressionUpdated" /> diff --git a/packages/frontend/editor-ui/src/features/shared/editors/composables/useExpressionEditor.test.ts b/packages/frontend/editor-ui/src/features/shared/editors/composables/useExpressionEditor.test.ts index 5939c1e749c..e32a8bca3a0 100644 --- a/packages/frontend/editor-ui/src/features/shared/editors/composables/useExpressionEditor.test.ts +++ b/packages/frontend/editor-ui/src/features/shared/editors/composables/useExpressionEditor.test.ts @@ -476,5 +476,74 @@ describe('useExpressionEditor', () => { ); expect(resolveExpressionMock).not.toHaveBeenCalled(); }); + + test('should defer previewing transformed external secrets until execution', async () => { + vi.spyOn(completionUtils, 'isCredentialsModalOpen').mockReturnValueOnce(true); + + const { + expressionEditor: { segments }, + } = await renderExpressionEditor({ + editorValue: "{{ JSON.parse($secrets.awsSecretsManager['cred']).password }}", + extensions: [n8nLang()], + additionalData: { + $secrets: { awsSecretsManager: { cred: '*********' } }, + }, + }); + + await waitFor(() => { + expect(toValue(segments.resolvable)).toEqual([ + expect.objectContaining({ + resolved: '[evaluated during execution]', + state: 'pending', + }), + ]); + }); + }); + + test('should keep unknown external secret references invalid', async () => { + vi.spyOn(completionUtils, 'isCredentialsModalOpen').mockReturnValueOnce(true); + + const { + expressionEditor: { segments }, + } = await renderExpressionEditor({ + editorValue: "{{ JSON.parse($secrets.awsSecretsManager['name-with-typo']).password }}", + extensions: [n8nLang()], + additionalData: { + $secrets: { awsSecretsManager: { cred: '*********' } }, + }, + }); + + await waitFor(() => { + expect(toValue(segments.resolvable)).toEqual([ + expect.objectContaining({ + resolved: '[secret not found]', + state: 'invalid', + }), + ]); + }); + }); + + test('should leave secret previews to the credential modal', async () => { + mockResolveExpression(); + + const { + expressionEditor: { segments }, + } = await renderExpressionEditor({ + editorValue: "{{ JSON.parse($secrets.awsSecretsManager['cred']).password }}", + extensions: [n8nLang()], + additionalData: { + $secrets: { awsSecretsManager: { cred: '*********' } }, + }, + }); + + await waitFor(() => { + expect(toValue(segments.resolvable)).toEqual([ + expect.objectContaining({ + resolved: '[undefined]', + state: 'invalid', + }), + ]); + }); + }); }); }); diff --git a/packages/frontend/editor-ui/src/features/shared/editors/composables/useExpressionEditor.ts b/packages/frontend/editor-ui/src/features/shared/editors/composables/useExpressionEditor.ts index 334d19b994d..9beb9dc8868 100644 --- a/packages/frontend/editor-ui/src/features/shared/editors/composables/useExpressionEditor.ts +++ b/packages/frontend/editor-ui/src/features/shared/editors/composables/useExpressionEditor.ts @@ -30,8 +30,19 @@ import { } from '@/app/composables/useWorkflowHelpers'; import { highlighter } from '../plugins/codemirror/resolvableHighlighter'; import { closeCursorInfoBox } from '../plugins/codemirror/tooltips/InfoBoxTooltip'; -import type { Html, Plaintext, RawSegment, Resolvable, Segment } from '@/app/types/expressions'; -import { getExpressionErrorMessage, getResolvableState } from '@/app/utils/expressions'; +import type { + Html, + Plaintext, + RawSegment, + Resolvable, + ResolvableState, + Segment, +} from '@/app/types/expressions'; +import { + getExpressionErrorMessage, + getExternalSecretPreview, + getResolvableState, +} from '@/app/utils/expressions'; import { isCredentialsModalOpen } from '../plugins/codemirror/completions/utils'; import { usesDeprecatedExpressionFunction } from '../plugins/codemirror/expressionDeprecations'; import { closeCompletion, completionStatus } from '@codemirror/autocomplete'; @@ -144,7 +155,7 @@ export const useExpressionEditor = ({ const { from, to, text, token } = segment; if (token === 'Resolvable') { - const { resolved, error, fullError } = await resolve(text, targetItem.value); + const { resolved, error, fullError, state } = await resolve(text, targetItem.value); return { kind: 'resolvable' as const, from, @@ -154,7 +165,8 @@ export const useExpressionEditor = ({ // For some reason, expressions that resolve to a number 0 are breaking preview in the SQL editor // This fixes that but as as TODO we should figure out why this is happening resolved: String(resolved), - state: getResolvableState(fullError ?? error, autocompleteStatus.value !== null), + state: + state ?? getResolvableState(fullError ?? error, autocompleteStatus.value !== null), error: fullError, }; } @@ -378,11 +390,17 @@ export const useExpressionEditor = ({ } async function resolve(resolvable: string, target: TargetItem | null) { - const result: { resolved: unknown; error: boolean; fullError: Error | null } = { + const result: { + resolved: unknown; + error: boolean; + fullError: Error | null; + state?: ResolvableState; + } = { resolved: undefined, error: false, fullError: null, }; + const isCredentialModal = !expressionLocalResolveContext.value && isCredentialsModalOpen(); try { // Deprecated functions still resolve on the backend, but we surface them @@ -397,7 +415,7 @@ export const useExpressionEditor = ({ additionalKeys: toValue(additionalData), }); } else if ( - isCredentialsModalOpen() || + isCredentialModal || (!ndvStore.value.activeNode && toValue(targetNodeParameterContext) === undefined) ) { // e.g. credential modal @@ -435,11 +453,19 @@ export const useExpressionEditor = ({ } if (result.resolved === undefined) { - result.resolved = isUncalledExpressionExtension(resolvable) - ? i18n.baseText('expressionEditor.uncalledFunction') - : i18n.baseText('expressionModalInput.undefined'); + const secretPreview = isCredentialModal + ? getExternalSecretPreview(resolvable, toValue(additionalData).$secrets) + : undefined; - result.error = true; + if (secretPreview) { + result.resolved = secretPreview.text; + result.state = secretPreview.exists ? 'pending' : 'invalid'; + } else { + result.resolved = isUncalledExpressionExtension(resolvable) + ? i18n.baseText('expressionEditor.uncalledFunction') + : i18n.baseText('expressionModalInput.undefined'); + result.error = true; + } } return result; From 30aff5d740ff3bb4e15aa0e0b6b88b2a02faaad1 Mon Sep 17 00:00:00 2001 From: Anne Aguirre Date: Thu, 13 Aug 2026 14:06:31 +0000 Subject: [PATCH 0016/1458] feat(ai-builder): Auto-select a sensible default model for new agents (#36058) --- .../api-types/src/agents/model-providers.ts | 2 + ...ent-default-model-resolver.service.test.ts | 146 ++++++++++++ .../agent-model-catalog.service.test.ts | 78 ++++++- .../__tests__/agents.controller.test.ts | 25 ++- .../agents/__tests__/agents.service.test.ts | 21 ++ .../agent-default-model-resolver.service.ts | 171 ++++++++++++++ .../agents/agent-model-catalog.service.ts | 44 ++-- .../src/modules/agents/agents.controller.ts | 4 + .../cli/src/modules/agents/agents.service.ts | 3 + .../agents/builder/agents-builder-prompts.ts | 13 +- .../__tests__/resolve-llm.tool.test.ts | 10 +- .../builder/interactive/resolve-llm.tool.ts | 10 +- .../builder/prompts/llm-selection.prompt.ts | 2 +- .../modules/agents/llm-provider-defaults.ts | 19 +- .../agents/utils/model-snapshot-alias.ts | 7 + .../frontend/@n8n/i18n/src/locales/en.json | 3 + .../agents/__tests__/AgentInfoPanel.spec.ts | 211 +++++++++++++++++- .../agents/__tests__/useModelCatalog.test.ts | 55 +++++ .../agents/components/AgentInfoPanel.vue | 104 ++++++++- .../agents/components/AgentModelSelector.vue | 2 + .../agents/composables/useModelCatalog.ts | 34 ++- 21 files changed, 922 insertions(+), 42 deletions(-) create mode 100644 packages/cli/src/modules/agents/__tests__/agent-default-model-resolver.service.test.ts create mode 100644 packages/cli/src/modules/agents/agent-default-model-resolver.service.ts create mode 100644 packages/cli/src/modules/agents/utils/model-snapshot-alias.ts diff --git a/packages/@n8n/api-types/src/agents/model-providers.ts b/packages/@n8n/api-types/src/agents/model-providers.ts index 6a66fcd15b1..9e465ec9f71 100644 --- a/packages/@n8n/api-types/src/agents/model-providers.ts +++ b/packages/@n8n/api-types/src/agents/model-providers.ts @@ -67,6 +67,8 @@ export interface AgentCatalogModel { /** Response of `GET /agents/v2/catalog/models/:provider`. */ export interface AgentProviderModelsResponse { provider: string; + /** The provider default verified for the selected credential, when available. */ + defaultModelId?: string; /** * True when the list was confirmed against the provider's own model API * (only models the credential can actually call). False means the static diff --git a/packages/cli/src/modules/agents/__tests__/agent-default-model-resolver.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-default-model-resolver.service.test.ts new file mode 100644 index 00000000000..946739c688d --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/agent-default-model-resolver.service.test.ts @@ -0,0 +1,146 @@ +import { AI_GATEWAY_MANAGED_TAG } from '@n8n/api-types'; +import { mock } from 'vitest-mock-extended'; + +import type { CredentialsService } from '@/credentials/credentials.service'; +import type { AiGatewayService } from '@/services/ai-gateway.service'; + +import { AgentDefaultModelResolverService } from '../agent-default-model-resolver.service'; +import type { BuilderModelLiveLookupService } from '../builder/builder-model-live-lookup.service'; + +const user = { id: 'user-1' } as never; + +function makeService(credentials: Array<{ id: string; name: string; type: string }> = []) { + const credentialsService = mock(); + const modelLookupService = mock(); + const aiGatewayService = mock(); + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue(credentials as never); + + return { + service: new AgentDefaultModelResolverService( + credentialsService, + modelLookupService, + aiGatewayService, + ), + modelLookupService, + aiGatewayService, + }; +} + +describe('AgentDefaultModelResolverService', () => { + it('uses a provider default only when live discovery confirms it', async () => { + const { service, modelLookupService } = makeService([ + { id: 'anthropic-credential', name: 'Anthropic', type: 'anthropicApi' }, + ]); + modelLookupService.list.mockResolvedValue([ + { name: 'Claude Sonnet 5', value: 'claude-sonnet-5' }, + ]); + + await expect(service.resolve(user, 'project-1')).resolves.toEqual({ + model: 'anthropic/claude-sonnet-5', + credential: 'anthropic-credential', + }); + }); + + it('resolves creation to the dated snapshot when the live list has only that form', async () => { + const { service, modelLookupService } = makeService([ + { id: 'anthropic-credential', name: 'Anthropic', type: 'anthropicApi' }, + ]); + modelLookupService.list.mockResolvedValue([ + { name: 'Claude Sonnet 5', value: 'claude-sonnet-5-20260630' }, + ]); + + await expect(service.resolve(user, 'project-1')).resolves.toEqual({ + model: 'anthropic/claude-sonnet-5-20260630', + credential: 'anthropic-credential', + }); + }); + + it('leaves a credential unresolved when its default is not live', async () => { + const { service, modelLookupService } = makeService([ + { id: 'anthropic-credential', name: 'Anthropic', type: 'anthropicApi' }, + ]); + modelLookupService.list.mockResolvedValue([{ name: 'Claude Opus', value: 'claude-opus-4-6' }]); + + await expect(service.resolve(user, 'project-1')).resolves.toBeNull(); + }); + + it('uses the managed OpenAI fallback only when the gateway allows it', async () => { + const { service, modelLookupService, aiGatewayService } = makeService(); + aiGatewayService.getCredentialTypeForProvider.mockResolvedValue('openAiApi'); + modelLookupService.list.mockResolvedValue([{ name: 'GPT-5.6 Terra', value: 'gpt-5.6-terra' }]); + + await expect(service.resolve(user, 'project-1')).resolves.toEqual({ + model: 'openai/gpt-5.6-terra', + credential: AI_GATEWAY_MANAGED_TAG, + }); + }); + + it('resolves to null instead of throwing when the gateway config lookup fails', async () => { + const { service, aiGatewayService } = makeService(); + aiGatewayService.getCredentialTypeForProvider.mockRejectedValue( + new Error('n8n credits config fetch recently failed; retry is throttled.'), + ); + + await expect(service.resolve(user, 'project-1')).resolves.toBeNull(); + }); + + it('resolveFromVerifiedModelIds returns the default when it is in the verified list', () => { + const { service } = makeService(); + + expect( + service.resolveFromVerifiedModelIds('anthropic', 'anthropic-credential', [ + 'claude-opus-4-6', + 'claude-sonnet-5', + ]), + ).toEqual({ + model: 'anthropic/claude-sonnet-5', + credential: 'anthropic-credential', + }); + }); + + it('resolveFromVerifiedModelIds resolves a snapshot-only list to the verified snapshot id', () => { + const { service } = makeService(); + + // The managed gateway may list only the dated snapshot of the maintained + // default — the snapshot is the only callable id there, so it is returned. + expect( + service.resolveFromVerifiedModelIds('anthropic', 'anthropic-credential', [ + 'claude-opus-4-6', + 'claude-sonnet-5-20260630', + ]), + ).toEqual({ + model: 'anthropic/claude-sonnet-5-20260630', + credential: 'anthropic-credential', + }); + }); + + it('resolveFromVerifiedModelIds prefers the exact default over a snapshot of it', () => { + const { service } = makeService(); + + expect( + service.resolveFromVerifiedModelIds('anthropic', 'anthropic-credential', [ + 'claude-sonnet-5-20260630', + 'claude-sonnet-5', + ]), + ).toEqual({ + model: 'anthropic/claude-sonnet-5', + credential: 'anthropic-credential', + }); + }); + + it('resolveFromVerifiedModelIds returns null when the default is not in the verified list', () => { + const { service } = makeService(); + + expect( + service.resolveFromVerifiedModelIds('anthropic', 'anthropic-credential', ['claude-opus-4-6']), + ).toBeNull(); + }); + + it('resolveFromVerifiedModelIds returns null for an unknown provider', () => { + const { service } = makeService(); + + expect( + service.resolveFromVerifiedModelIds('unknown-provider', 'credential-1', ['any-model']), + ).toBeNull(); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/agent-model-catalog.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-model-catalog.service.test.ts index 8ea0e317fe9..81ebf44fa92 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-model-catalog.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-model-catalog.service.test.ts @@ -4,6 +4,7 @@ import type { User } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; import { AgentModelCatalogService } from '../agent-model-catalog.service'; +import type { AgentDefaultModelResolverService } from '../agent-default-model-resolver.service'; import type { BuilderModelLiveLookupService } from '../builder/builder-model-live-lookup.service'; const fetchProviderCatalog = vi.fn(); @@ -50,9 +51,10 @@ const catalogFixture = { function makeService() { const lookupService = mock(); + const defaultModelResolver = mock(); const logger = mockLogger(); - const service = new AgentModelCatalogService(logger, lookupService); - return { service, lookupService, logger }; + const service = new AgentModelCatalogService(logger, lookupService, defaultModelResolver); + return { service, lookupService, defaultModelResolver, logger }; } describe('AgentModelCatalogService', () => { @@ -88,6 +90,23 @@ describe('AgentModelCatalogService', () => { ); }); + it('returns the verified default model when the resolver selects one in the response', async () => { + const { service, lookupService, defaultModelResolver } = makeService(); + lookupService.lookup.mockResolvedValue({ + status: 'success', + policy: 'curated', + models: [{ name: 'Claude Sonnet 4.6', value: 'claude-sonnet-4-6' }], + }); + defaultModelResolver.resolveFromVerifiedModelIds.mockReturnValue({ + model: 'anthropic/claude-sonnet-4-6', + credential: credentialId, + }); + + const result = await service.getProviderModels(user, 'project-1', 'anthropic', credentialId); + + expect(result.defaultModelId).toBe('claude-sonnet-4-6'); + }); + it('verifies against the gateway allowlist for the n8n Connect managed tag', async () => { const { service, lookupService } = makeService(); // Gateway serves only Sonnet — the retired/unsupported Opus must be pruned. @@ -174,6 +193,36 @@ describe('AgentModelCatalogService', () => { ]); }); + it('returns the snapshot id as the default when the gateway lists only the dated snapshot', async () => { + const { service, lookupService, defaultModelResolver } = makeService(); + // The gateway exposes only the dated snapshot of the maintained default; + // the resolver matches it via its snapshot-stripped alias and returns the + // callable snapshot id, which must survive the membership check. + lookupService.lookup.mockResolvedValue({ + status: 'success', + policy: 'managed', + models: [{ name: 'Claude Sonnet 4.6', value: 'claude-sonnet-4-6-20251001' }], + }); + defaultModelResolver.resolveFromVerifiedModelIds.mockReturnValue({ + model: 'anthropic/claude-sonnet-4-6-20251001', + credential: AI_GATEWAY_MANAGED_TAG, + }); + + const result = await service.getProviderModels( + user, + 'project-1', + 'anthropic', + AI_GATEWAY_MANAGED_TAG, + ); + + expect(defaultModelResolver.resolveFromVerifiedModelIds).toHaveBeenCalledWith( + 'anthropic', + AI_GATEWAY_MANAGED_TAG, + ['claude-sonnet-4-6-20251001'], + ); + expect(result.defaultModelId).toBe('claude-sonnet-4-6-20251001'); + }); + it('leaves reasoning support unknown for managed models missing from the catalog', async () => { const { service, lookupService } = makeService(); lookupService.lookup.mockResolvedValue({ @@ -302,6 +351,31 @@ describe('AgentModelCatalogService', () => { expect(fetchProviderCatalog).not.toHaveBeenCalled(); }); + it('returns the default model when a custom endpoint exposes it', async () => { + const { service, lookupService, defaultModelResolver } = makeService(); + lookupService.lookup.mockResolvedValue({ + status: 'success', + policy: 'endpoint-only', + models: [ + { name: 'GLM 4.5', value: 'glm-4.5' }, + { name: 'GPT-5 mini', value: 'gpt-5-mini' }, + ], + }); + defaultModelResolver.resolveFromVerifiedModelIds.mockReturnValue({ + model: 'openai/gpt-5-mini', + credential: credentialId, + }); + + const result = await service.getProviderModels(user, 'project-1', 'openai', credentialId); + + expect(defaultModelResolver.resolveFromVerifiedModelIds).toHaveBeenCalledWith( + 'openai', + credentialId, + ['glm-4.5', 'gpt-5-mini'], + ); + expect(result.defaultModelId).toBe('gpt-5-mini'); + }); + it('reports a custom OpenAI-compatible endpoint as unavailable without catalog fallback', async () => { const { service, lookupService, logger } = makeService(); lookupService.lookup.mockResolvedValue({ diff --git a/packages/cli/src/modules/agents/__tests__/agents.controller.test.ts b/packages/cli/src/modules/agents/__tests__/agents.controller.test.ts index aff21f2689a..4f2e8cd7ce0 100644 --- a/packages/cli/src/modules/agents/__tests__/agents.controller.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents.controller.test.ts @@ -5,6 +5,7 @@ import { mock } from 'vitest-mock-extended'; import type { CredentialsService } from '@/credentials/credentials.service'; import { AgentsCredentialProvider } from '../adapters/agents-credential-provider'; +import type { AgentDefaultModelResolverService } from '../agent-default-model-resolver.service'; import type { AgentPublishService } from '../agent-publish.service'; import { AgentRunnableStateService } from '../agent-runnable-state.service'; import type { AgentsService } from '../agents.service'; @@ -25,6 +26,7 @@ function makeController({ agentPublishService = mock(), agentValidationService = mock(), credentialsService = mock(), + agentDefaultModelResolverService = mock(), }: { agentsService?: Mocked< Pick< @@ -35,6 +37,7 @@ function makeController({ agentPublishService?: Mocked; agentValidationService?: Mocked; credentialsService?: Mocked; + agentDefaultModelResolverService?: Mocked; } = {}) { const agentRunnableStateService = new AgentRunnableStateService( credentialsService, @@ -46,6 +49,7 @@ function makeController({ controller: new AgentsController( agentsService as unknown as AgentsService, agentRunnableStateService, + agentDefaultModelResolverService, ), agentsService, agentPublishService, @@ -75,7 +79,9 @@ describe('AgentsController create', () => { const agentPublishService = mock(); const agentValidationService = mock(); const agentsService = mock>(); + const agentDefaultModelResolverService = mock(); agentsService.create.mockResolvedValue({ id: createdId, projectId: 'project-1' } as never); + agentDefaultModelResolverService.resolve.mockResolvedValue(null); agentValidationService.validateLoadedAgentConfiguration.mockResolvedValue({ status: 'valid', issues: [], @@ -86,8 +92,9 @@ describe('AgentsController create', () => { agentsService: agentsService as never, agentPublishService, agentValidationService, + agentDefaultModelResolverService, }); - return { controller, agentsService }; + return { controller, agentsService, agentDefaultModelResolverService }; } it('creates the agent under the id the client minted', async () => { @@ -112,6 +119,22 @@ describe('AgentsController create', () => { id: undefined, }); }); + + it('passes a resolved default model to the service', async () => { + const { controller, agentsService, agentDefaultModelResolverService } = + makeCreateController('server-minted'); + agentDefaultModelResolverService.resolve.mockResolvedValue({ + model: 'openai/gpt-5-mini', + credential: 'managed', + }); + + await controller.create(req, mock(), { name: 'Support Agent' } as never); + + expect(agentsService.create).toHaveBeenCalledWith('project-1', 'Support Agent', { + id: undefined, + defaultModel: { model: 'openai/gpt-5-mini', credential: 'managed' }, + }); + }); }); describe('AgentsController list', () => { diff --git a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts index bb84ec5019a..93abf6aff6e 100644 --- a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts @@ -135,6 +135,27 @@ describe('AgentsService', () => { }); }); + it('creates an agent with a resolved default model and credential', async () => { + const { service, agentRepository } = makeService(); + const saved = makeAgent(); + agentRepository.create.mockReturnValue(saved); + agentRepository.save.mockResolvedValue(saved); + + await service.create(projectId, 'Support Agent', { + defaultModel: { + model: 'openai/gpt-5-mini', + credential: 'managed', + }, + }); + + const [entity] = agentRepository.create.mock.calls[0]; + expect(entity.schema).toMatchObject({ + name: 'Support Agent', + model: 'openai/gpt-5-mini', + credential: 'managed', + }); + }); + it('splits a seeded config so integrations land on their own column', async () => { // `composeJsonConfig` reads integrations from the entity column, so leaving // them inside `schema` loses every trigger on the next read — an eval seed diff --git a/packages/cli/src/modules/agents/agent-default-model-resolver.service.ts b/packages/cli/src/modules/agents/agent-default-model-resolver.service.ts new file mode 100644 index 00000000000..4905e0c6bb9 --- /dev/null +++ b/packages/cli/src/modules/agents/agent-default-model-resolver.service.ts @@ -0,0 +1,171 @@ +import type { CredentialListItem } from '@n8n/agents'; +import { AI_GATEWAY_MANAGED_TAG } from '@n8n/api-types'; +import { isModelDiscoveryProvider } from '@n8n/ai-utilities/model-discovery'; +import type { User } from '@n8n/db'; +import { Service } from '@n8n/di'; + +import { CredentialsService } from '@/credentials/credentials.service'; +import { AiGatewayService } from '@/services/ai-gateway.service'; + +import { BuilderModelLiveLookupService } from './builder/builder-model-live-lookup.service'; +import { LLM_PROVIDER_DEFAULTS, LLM_PROVIDER_PRIORITY } from './llm-provider-defaults'; +import { createAgentCredentialProvider } from './utils/agent-credential-provider'; +import { stripSnapshotSuffix } from './utils/model-snapshot-alias'; + +export interface ResolvedAgentDefaultModel { + model: string; + credential: string; +} + +/** + * Resolves a sensible default model+credential for a new agent, and answers + * "is the maintained default still in this provider's verified model list?" + * + * The creation path ({@link resolve}) does its own live discovery — it has no + * verified list yet. The catalog path ({@link resolveFromVerifiedModelIds}) + * is pure: the caller already fetched the live list, so this must not repeat + * that network call. + */ +@Service() +export class AgentDefaultModelResolverService { + constructor( + private readonly credentialsService: CredentialsService, + private readonly modelLookupService: BuilderModelLiveLookupService, + private readonly aiGatewayService: AiGatewayService, + ) {} + + /** + * Full resolution for agent creation: lists credentials, picks the + * highest-priority provider with exactly one credential, and verifies + * its maintained default against the provider's live model list. Falls + * back to the n8n Connect managed OpenAI slot when the user has no + * personal LLM credential. Returns `null` when the choice is ambiguous + * or the default is not live — the caller keeps the agent as a draft. + */ + async resolve(user: User, projectId: string): Promise { + const credentials = await createAgentCredentialProvider( + this.credentialsService, + projectId, + user, + ).list(); + const supportedCredentials = credentials.filter((credential) => + Object.hasOwn(LLM_PROVIDER_DEFAULTS, credential.type), + ); + + if (supportedCredentials.length === 0) { + return await this.resolveManagedOpenAi(user, projectId); + } + + const credentialsByProvider = new Map(); + for (const credential of supportedCredentials) { + const provider = LLM_PROVIDER_DEFAULTS[credential.type].provider; + credentialsByProvider.set(provider, [ + ...(credentialsByProvider.get(provider) ?? []), + credential, + ]); + } + + const provider = LLM_PROVIDER_PRIORITY.find( + (candidate) => credentialsByProvider.get(candidate)?.length === 1, + ); + if (!provider) return null; + + return await this.resolveCredential(user, projectId, credentialsByProvider.get(provider)?.[0]); + } + + /** + * Pure counterpart for the model-catalog path. The caller has already + * fetched and verified the provider's model list (e.g. via + * `BuilderModelLiveLookupService`); this just checks whether the + * maintained default for the provider is in that list, without any + * additional I/O. + */ + resolveFromVerifiedModelIds( + provider: string, + credentialId: string, + verifiedModelIds: readonly string[], + ): ResolvedAgentDefaultModel | null { + const defaults = this.findProviderDefault(provider); + if (!defaults) return null; + + const lowerDefault = defaults.defaultModel.toLowerCase(); + // Exact match first; otherwise a verified id whose snapshot-stripped alias + // is the default — the managed gateway may list only the dated snapshot + // (e.g. `claude-sonnet-4-6-20251001`), and only that exact id is callable + // there. Return the verified id (original casing) so callers can use it + // verbatim against the verified list. + const match = + verifiedModelIds.find((id) => id.toLowerCase() === lowerDefault) ?? + verifiedModelIds.find((id) => stripSnapshotSuffix(id).toLowerCase() === lowerDefault); + return match ? { model: `${provider}/${match}`, credential: credentialId } : null; + } + + private findProviderDefault( + provider: string, + ): { provider: string; defaultModel: string; credentialType: string } | undefined { + const entry = Object.entries(LLM_PROVIDER_DEFAULTS).find( + ([, defaults]) => defaults.provider === provider, + ); + return entry + ? { + provider: entry[1].provider, + defaultModel: entry[1].defaultModel, + credentialType: entry[0], + } + : undefined; + } + + private async resolveManagedOpenAi( + user: User, + projectId: string, + ): Promise { + const defaults = LLM_PROVIDER_DEFAULTS.openAiApi; + let credentialType: string | undefined; + try { + credentialType = await this.aiGatewayService.getCredentialTypeForProvider(defaults.provider); + } catch { + // Gateway config fetch can fail transiently (and is failure-throttled). + // Resolution is best-effort: creation proceeds without a default. + return null; + } + if (credentialType !== 'openAiApi') return null; + + return await this.resolveCredential(user, projectId, { + id: AI_GATEWAY_MANAGED_TAG, + name: 'n8n credits', + type: credentialType, + }); + } + + private async resolveCredential( + user: User, + projectId: string, + credential: CredentialListItem | undefined, + ): Promise { + if (!credential) return null; + + const defaults = LLM_PROVIDER_DEFAULTS[credential.type]; + if (!defaults) return null; + + if (!isModelDiscoveryProvider(defaults.provider)) { + return { model: `${defaults.provider}/${defaults.defaultModel}`, credential: credential.id }; + } + + try { + const availableModels = await this.modelLookupService.list( + user, + projectId, + credential.id, + credential.type, + defaults.provider, + ); + return this.resolveFromVerifiedModelIds( + defaults.provider, + credential.id, + availableModels.map(({ value }) => value), + ); + } catch { + return null; + } + } +} diff --git a/packages/cli/src/modules/agents/agent-model-catalog.service.ts b/packages/cli/src/modules/agents/agent-model-catalog.service.ts index 37f9fe98ab4..979e6410b7e 100644 --- a/packages/cli/src/modules/agents/agent-model-catalog.service.ts +++ b/packages/cli/src/modules/agents/agent-model-catalog.service.ts @@ -14,6 +14,8 @@ import { BuilderModelLiveLookupService, type LiveModelLookupResult, } from './builder/builder-model-live-lookup.service'; +import { AgentDefaultModelResolverService } from './agent-default-model-resolver.service'; +import { stripSnapshotSuffix } from './utils/model-snapshot-alias'; /** Google's models API returns ids as `models/`; the AI SDK expects the bare id. */ const GOOGLE_MODEL_ID_PREFIX = 'models/'; @@ -30,9 +32,6 @@ function normalizeLiveModelValue(provider: string, value: string): string { return value; } -/** Dated snapshot suffixes: Anthropic `-20251001`, OpenAI `-2024-08-06`. */ -const SNAPSHOT_SUFFIX = /-(?:\d{8}|\d{4}-\d{2}-\d{2})$/; - /** * The ids a live model verifies. Providers list older models only as dated * snapshots (e.g. `claude-haiku-4-5-20251001`) while the catalog prefers the @@ -41,7 +40,7 @@ const SNAPSHOT_SUFFIX = /-(?:\d{8}|\d{4}-\d{2}-\d{2})$/; * still prunes: retired models have no live snapshot either. */ function liveModelIdVariants(id: string): string[] { - const alias = id.replace(SNAPSHOT_SUFFIX, ''); + const alias = stripSnapshotSuffix(id); return alias === id ? [id] : [id, alias]; } @@ -59,6 +58,7 @@ export class AgentModelCatalogService { constructor( private readonly logger: Logger, private readonly builderModelLiveLookupService: BuilderModelLiveLookupService, + private readonly agentDefaultModelResolverService: AgentDefaultModelResolverService, ) {} /** Returns the provider's models according to the live lookup's catalog policy. */ @@ -106,7 +106,7 @@ export class AgentModelCatalogService { return { provider, verified: true, unavailable: true, models: [] }; } - return { + return this.withDefaultModel(provider, credentialId, { provider, verified: true, models: lookup.models.map((live) => ({ @@ -114,7 +114,7 @@ export class AgentModelCatalogService { name: live.name || live.value, toolCall: true, })), - }; + }); } if (lookup.status === 'unavailable') { @@ -134,12 +134,12 @@ export class AgentModelCatalogService { // allowlist matches (e.g. a dated snapshot, not the catalog's versionless // alias). Use those ids verbatim, enriched with catalog display/metadata. if (lookup.policy === 'managed') { - return { + return this.withDefaultModel(provider, credentialId, { provider, verified: true, models: liveModels.map((live) => { const id = normalizeLiveModelValue(provider, live.value); - const catalogMatch = catalogModels[id] ?? catalogModels[id.replace(SNAPSHOT_SUFFIX, '')]; + const catalogMatch = catalogModels[id] ?? catalogModels[stripSnapshotSuffix(id)]; return catalogMatch ? { ...catalogMatch, id } : { @@ -148,7 +148,7 @@ export class AgentModelCatalogService { toolCall: true, }; }), - }; + }); } const liveModelIds = new Set( @@ -163,17 +163,17 @@ export class AgentModelCatalogService { // add live-only models — we only prune catalog entries the provider no // longer reports (retired ids that would 404 at call time). if (catalogList.length > 0) { - return { + return this.withDefaultModel(provider, credentialId, { provider, verified: true, models: catalogList.filter((model) => liveModelIds.has(model.id)), - }; + }); } // Catalog unavailable (models.dev down or no entry for this provider): there // is no curated list to prune against, so show the verified live list rather // than an empty picker. - return { + return this.withDefaultModel(provider, credentialId, { provider, verified: true, models: liveModels.map((live) => { @@ -184,7 +184,25 @@ export class AgentModelCatalogService { toolCall: true, }; }), - }; + }); + } + + private withDefaultModel( + provider: string, + credentialId: string, + result: AgentProviderModelsResponse, + ): AgentProviderModelsResponse { + const resolved = this.agentDefaultModelResolverService.resolveFromVerifiedModelIds( + provider, + credentialId, + result.models.map((model) => model.id), + ); + if (!resolved) return result; + + const modelId = resolved.model.slice(`${provider}/`.length); + return result.models.some((model) => model.id === modelId) + ? { ...result, defaultModelId: modelId } + : result; } private logLiveLookupFailure(provider: string, error: unknown): void { diff --git a/packages/cli/src/modules/agents/agents.controller.ts b/packages/cli/src/modules/agents/agents.controller.ts index 9aebab2eaea..5ae192bfb80 100644 --- a/packages/cli/src/modules/agents/agents.controller.ts +++ b/packages/cli/src/modules/agents/agents.controller.ts @@ -15,6 +15,7 @@ import type { Response } from 'express'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { AgentRunnableStateService } from './agent-runnable-state.service'; +import { AgentDefaultModelResolverService } from './agent-default-model-resolver.service'; import { AgentsService } from './agents.service'; @RestController('/projects/:projectId/agents/v2') @@ -22,6 +23,7 @@ export class AgentsController { constructor( private readonly agentsService: AgentsService, private readonly agentRunnableStateService: AgentRunnableStateService, + private readonly agentDefaultModelResolverService: AgentDefaultModelResolverService, ) {} @Post('/') @@ -32,9 +34,11 @@ export class AgentsController { @Body payload: CreateAgentDto, ) { const { projectId } = req.params; + const defaultModel = await this.agentDefaultModelResolverService.resolve(req.user, projectId); const agent = await this.agentsService.create(projectId, payload.name, { id: payload.id, + ...(defaultModel ? { defaultModel } : {}), }); return await this.agentRunnableStateService.addRunnableState(agent, projectId, req.user); } diff --git a/packages/cli/src/modules/agents/agents.service.ts b/packages/cli/src/modules/agents/agents.service.ts index 5d084b75a24..a0f260a5395 100644 --- a/packages/cli/src/modules/agents/agents.service.ts +++ b/packages/cli/src/modules/agents/agents.service.ts @@ -69,12 +69,14 @@ export class AgentsService { availableInMCP = false, id, adoptUnconfiguredOnCollision = false, + defaultModel, schema, skills, }: { availableInMCP?: boolean; id?: string; adoptUnconfiguredOnCollision?: boolean; + defaultModel?: { model: string; credential: string }; /** Create with this config instead of the empty draft below, so eval thread * seeding can recreate an already-built agent in one insert. */ schema?: AgentJsonConfig; @@ -85,6 +87,7 @@ export class AgentsService { name, model: '', instructions: '', + ...(defaultModel ?? {}), tools: [], skills: [], // Seeded at birth so every agent has a distinct tile, and so the builder diff --git a/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts b/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts index b0d14b58e1f..db782abdaa1 100644 --- a/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts +++ b/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts @@ -166,8 +166,11 @@ export const WORKFLOW_SECTION = `\ with the full plan first — even short ones. Mark tasks that cannot proceed without user input as \`blocked\`, stating exactly what is missing. -2. For fresh agents, call \`resolve_llm\` once, silently. If it resolves — - including an auto-picked provider or newly provisioned free OpenAI +2. For fresh agents, call \`read_config\` first. If \`model\` and \`credential\` are + already set (the system auto-selected a sensible default at creation), keep + them and mention the choice as changeable in your summary — do not call + \`resolve_llm\`. If \`model\` is empty, call \`resolve_llm\` once, silently. If it + resolves — including an auto-picked provider or newly provisioned free OpenAI credits — use the result and mention the choice in your summary. If it reports missing or ambiguous credentials, mark the model task \`blocked\` and keep building: write the config with \`model: ""\` and @@ -201,8 +204,10 @@ export const FEW_SHOT_FLOWS_SECTION = `\ ## Example flows ### New agent: "Build me an agent teammates can @mention in Slack to triage messages" -1. \`write_todos\` with the plan. \`resolve_llm({})\` once, silently; if it - reports missing credentials, mark the model task \`blocked\`. +1. \`write_todos\` with the plan. \`read_config()\` first — if a model and + credential are already set (system auto-selected default), keep them and + mention the choice as changeable; otherwise \`resolve_llm({})\` once, + silently; if it reports missing credentials, mark the model task \`blocked\`. 2. \`read_config()\`. 3. \`write_config(...)\` with the instructions, and the resolved model and credential — or \`model: ""\` and no \`credential\` while the model task diff --git a/packages/cli/src/modules/agents/builder/interactive/__tests__/resolve-llm.tool.test.ts b/packages/cli/src/modules/agents/builder/interactive/__tests__/resolve-llm.tool.test.ts index 84e2ce86e49..246403532d2 100644 --- a/packages/cli/src/modules/agents/builder/interactive/__tests__/resolve-llm.tool.test.ts +++ b/packages/cli/src/modules/agents/builder/interactive/__tests__/resolve-llm.tool.test.ts @@ -51,7 +51,7 @@ describe('resolve_llm tool', () => { expect(result).toEqual({ ok: true, provider: 'openai', - model: 'gpt-5-mini', + model: 'gpt-5.6-terra', credentialId: 'c1', credentialName: 'My OpenAI', }); @@ -74,7 +74,7 @@ describe('resolve_llm tool', () => { expect(result).toEqual({ ok: true, provider: 'openrouter', - model: 'anthropic/claude-sonnet-4.6', + model: 'anthropic/claude-sonnet-5', credentialId: 'c2', credentialName: 'My OpenRouter', }); @@ -311,7 +311,7 @@ describe('resolve_llm tool', () => { expect(result).toEqual({ ok: true, provider: 'openrouter', - model: 'anthropic/claude-sonnet-4.6', + model: 'anthropic/claude-sonnet-5', credentialId: 'c2', credentialName: 'Work OpenRouter', }); @@ -355,7 +355,7 @@ describe('resolve_llm tool', () => { expect(result).toEqual({ ok: true, provider: 'anthropic', - model: 'claude-sonnet-4-6', + model: 'claude-sonnet-5', credentialId: 'c2', credentialName: 'My Anthropic', autoPicked: true, @@ -385,7 +385,7 @@ describe('resolve_llm tool', () => { expect(result).toEqual({ ok: true, provider: 'anthropic', - model: 'claude-sonnet-4-6', + model: 'claude-sonnet-5', credentialId: 'c1', credentialName: 'My Anthropic', }); diff --git a/packages/cli/src/modules/agents/builder/interactive/resolve-llm.tool.ts b/packages/cli/src/modules/agents/builder/interactive/resolve-llm.tool.ts index db09c0d718f..249dee2b117 100644 --- a/packages/cli/src/modules/agents/builder/interactive/resolve-llm.tool.ts +++ b/packages/cli/src/modules/agents/builder/interactive/resolve-llm.tool.ts @@ -230,8 +230,12 @@ export function buildResolveLlmTool(deps: ResolveLlmToolDeps): BuiltTool { return new Tool(BUILDER_TOOLS.RESOLVE_LLM) .description( 'Resolve the agent main LLM without showing a picker. ' + - 'For fresh agents, call it once, silently, before the first config write to detect existing ' + - 'credentials — with provider/model when the user named them, otherwise without arguments. ' + + 'A fresh agent may already have a model and credential persisted by the system at creation ' + + '(a sensible default was auto-selected). Before calling this tool on a fresh agent, call ' + + 'read_config first: if model and credential are already set, keep them, mention the choice ' + + 'in your summary as changeable, and do not call resolve_llm. Only when model is empty call ' + + 'resolve_llm once, silently, before the first config write to detect existing credentials — ' + + 'with provider/model when the user named them, otherwise without arguments. ' + 'Also call it whenever the user names or changes a provider or model. ' + 'If provider is given, resolves only that provider; if model is omitted, uses the ' + 'provider default model. For "Anthropic via OpenRouter", pass provider="openrouter" ' + @@ -266,7 +270,7 @@ export function buildResolveLlmTool(deps: ResolveLlmToolDeps): BuiltTool { .string() .optional() .describe( - 'Requested model without the selected provider prefix. For OpenRouter use the routed id, e.g. "anthropic/claude-sonnet-4.6".', + 'Requested model without the selected provider prefix. For OpenRouter use the routed id, e.g. "anthropic/claude-sonnet-5".', ), credentialId: z .string() diff --git a/packages/cli/src/modules/agents/builder/prompts/llm-selection.prompt.ts b/packages/cli/src/modules/agents/builder/prompts/llm-selection.prompt.ts index deb2e059ba0..575cff5b4f5 100644 --- a/packages/cli/src/modules/agents/builder/prompts/llm-selection.prompt.ts +++ b/packages/cli/src/modules/agents/builder/prompts/llm-selection.prompt.ts @@ -12,7 +12,7 @@ Use this to resolve the target agent's main \`model\` and \`credential\`. ### Workflow -1. For fresh agents, call \`resolve_llm\` once, silently, before the first config write — with provider/model when the user named them, otherwise with no arguments. +1. For fresh agents, call \`read_config\` first. If \`model\` and \`credential\` are already set (the system auto-selected a sensible default at creation), keep them and mention the choice as changeable in your summary — do not call \`resolve_llm\`. Otherwise call \`resolve_llm\` once, silently, before the first config write — with provider/model when the user named them, otherwise with no arguments. 2. If \`resolve_llm\` succeeds, persist \`model = "{provider}/{model}"\` and \`credential = credentialId\`. If the result has \`claimedFreeOpenAiCredits: true\`, tell the user you set them up with free OpenAI credits. If the result's \`credentialName\` is "n8n credits" (its \`credentialId\` is the managed tag), persist it like any credential and tell the user the model runs on n8n credits — never surface the internal "n8n Connect" or "AI Gateway" names. If it has \`autoPicked: true\`, tell the user which provider and model you picked and that they can ask to change it — do not ask for confirmation and do not raise a trailing model question. 3. If the user asks to pick, change, confirm, or configure a model or main credential, ask via \`ask_questions\`; do not ask in prose. - Exception — when the user explicitly asks to use n8n credits for the main model, do NOT ask: call \`resolve_llm\` with \`useN8nCredits: true\` (and \`provider\` when the user named one). It resolves n8n credits for that provider even if the user already has their own credential for it; persist the result (\`credentialName\` "n8n credits") and tell the user the model runs on n8n credits. If it returns \`n8n_credits_unsupported_provider\`, tell the user n8n credits does not cover that provider and offer their own credential; if \`ambiguous_n8n_credits_provider\`, ask which provider via \`ask_questions\` using the returned \`providers\`; if \`n8n_credits_unavailable\`, tell the user n8n credits is not available on this instance and resolve their own credential instead. On \`unknown_model\`, retry with a value from the returned \`availableModels\` or surface those options. diff --git a/packages/cli/src/modules/agents/llm-provider-defaults.ts b/packages/cli/src/modules/agents/llm-provider-defaults.ts index 7b0c02c5ca6..a5d193b7d6a 100644 --- a/packages/cli/src/modules/agents/llm-provider-defaults.ts +++ b/packages/cli/src/modules/agents/llm-provider-defaults.ts @@ -22,23 +22,26 @@ export interface LlmProviderDefault { export const LLM_PROVIDER_DEFAULTS: Record = { anthropicApi: { provider: 'anthropic', - defaultModel: 'claude-sonnet-4-6', + defaultModel: 'claude-sonnet-5', }, openAiApi: { provider: 'openai', - defaultModel: 'gpt-5-mini', + defaultModel: 'gpt-5.6-terra', }, googlePalmApi: { provider: 'google', - defaultModel: 'gemini-2.5-pro', + // The 3.x Pro line is still preview-only; 3.6 Flash is the stable GA pick. + defaultModel: 'gemini-3.6-flash', }, xAiApi: { provider: 'xai', - defaultModel: 'grok-4', + defaultModel: 'grok-4.6', }, groqApi: { provider: 'groq', - defaultModel: 'llama-3.3-70b-versatile', + // Groq shut down its Llama 3.x models in Aug 2026; this is their + // recommended replacement. + defaultModel: 'openai/gpt-oss-120b', }, mistralCloudApi: { provider: 'mistral', @@ -50,11 +53,11 @@ export const LLM_PROVIDER_DEFAULTS: Record = { }, cohereApi: { provider: 'cohere', - defaultModel: 'command-r-plus', + defaultModel: 'command-a-plus-05-2026', }, openRouterApi: { provider: 'openrouter', - defaultModel: 'anthropic/claude-sonnet-4.6', + defaultModel: 'anthropic/claude-sonnet-5', }, nvidiaApi: { provider: 'nvidia', @@ -62,7 +65,7 @@ export const LLM_PROVIDER_DEFAULTS: Record = { }, vercelAiGatewayApi: { provider: 'vercel', - defaultModel: 'anthropic/claude-sonnet-4.6', + defaultModel: 'anthropic/claude-sonnet-5', }, }; diff --git a/packages/cli/src/modules/agents/utils/model-snapshot-alias.ts b/packages/cli/src/modules/agents/utils/model-snapshot-alias.ts new file mode 100644 index 00000000000..ca1a1c77916 --- /dev/null +++ b/packages/cli/src/modules/agents/utils/model-snapshot-alias.ts @@ -0,0 +1,7 @@ +/** Dated snapshot suffixes: Anthropic `-20251001`, OpenAI `-2024-08-06`. */ +export const SNAPSHOT_SUFFIX = /-(?:\d{8}|\d{4}-\d{2}-\d{2})$/; + +/** Versionless alias of a dated snapshot id (`claude-haiku-4-5-20251001` → `claude-haiku-4-5`). */ +export function stripSnapshotSuffix(id: string): string { + return id.replace(SNAPSHOT_SUFFIX, ''); +} diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index a004bc3d0ca..c6ef82791ed 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -7481,6 +7481,9 @@ "agents.builder.agent.name.placeholder": "My agent", "agents.builder.agent.personalisation.change": "Change agent icon", "agents.builder.agent.model.label": "Model", + "agents.builder.agent.model.defaultSelected.title": "Default model selected", + "agents.builder.agent.model.defaultSelected.description": "A sensible default has been chosen for you. You can change it anytime.", + "agents.builder.agent.model.defaultSelected.dismiss": "Got it", "agents.builder.agent.instructions.label": "Instructions", "agents.builder.agent.instructions.placeholder": "Enter instructions here", "agents.builder.agent.instructions.characterCount": "{count} characters", diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentInfoPanel.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentInfoPanel.spec.ts index 65c833f9e1f..9fb47213445 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentInfoPanel.spec.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentInfoPanel.spec.ts @@ -9,6 +9,11 @@ import type { AgentJsonConfig } from '../types'; const ensureLoadedMock = vi.fn(); const selectCredentialMock = vi.fn(); +// Reactive holder mirroring the real composable, whose verified-default cache +// is a ref that updates when the backend response lands — watch getters that +// call the mock must re-run when tests change the value. +const defaultModelHolder = ref | null>(null); +const getDefaultModelForPickerMock = vi.fn(() => defaultModelHolder.value); function makeCatalog(): ProviderCatalog { return { @@ -52,6 +57,10 @@ vi.mock('@n8n/i18n', () => ({ 'agents.builder.agent.instructions.label': 'Instructions', 'agents.builder.agent.instructions.placeholder': 'Enter instructions here', 'agents.builder.agent.instructions.characterCount': `${options?.interpolate?.count ?? '0'} characters`, + 'agents.builder.agent.model.defaultSelected.title': 'Default model selected', + 'agents.builder.agent.model.defaultSelected.description': + 'A sensible default has been chosen for you. You can change it anytime.', + 'agents.builder.agent.model.defaultSelected.dismiss': 'Got it', })[key] ?? key, }), })); @@ -65,6 +74,17 @@ vi.mock('@n8n/design-system', () => ({ '
{{ modelValue }} {{ placeholder }}
', }, N8nText: { template: '', props: ['tag', 'bold', 'size', 'color'] }, + N8nCallout: { + name: 'N8nCallout', + props: ['theme', 'slim', 'icon'], + template: '
', + }, + N8nIconButton: { + name: 'N8nIconButton', + props: ['icon', 'size', 'variant', 'title', 'text'], + emits: ['click'], + template: '