From 4488dd8f8cbbc781291edec516018cd824f55468 Mon Sep 17 00:00:00 2001 From: Charlie Kolb Date: Mon, 10 Aug 2026 08:25:02 +0200 Subject: [PATCH 001/151] test: Pin system time for all workflow history compaction tests (#35916) Co-authored-by: Claude Fable 5 --- ...orkflow-history-compaction.service.test.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/services/pruning/__tests__/workflow-history-compaction.service.test.ts b/packages/cli/src/services/pruning/__tests__/workflow-history-compaction.service.test.ts index 3787345c52d..0d28b588ed1 100644 --- a/packages/cli/src/services/pruning/__tests__/workflow-history-compaction.service.test.ts +++ b/packages/cli/src/services/pruning/__tests__/workflow-history-compaction.service.test.ts @@ -27,6 +27,16 @@ describe('WorkflowHistoryCompactionService', () => { }, }); + beforeEach(() => { + // Set the system to a time that isn't 3 AM to avoid hitting the "trim once a day" window + const mockDate = new Date(2026, 10, 10, 1, 0, 0); + vi.setSystemTime(mockDate); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + describe('init', () => { it('should start compacting on main instance that is the leader', () => { const compactingService = new WorkflowHistoryCompactionService( @@ -64,16 +74,6 @@ describe('WorkflowHistoryCompactionService', () => { }); describe('startCompacting', () => { - beforeEach(() => { - // Set the system to a time that isn't 3 AM to avoid hitting the "trim once a day" window - const mockDate = new Date(2026, 10, 10, 1, 0, 0); - vi.setSystemTime(mockDate); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - it('should start compacting if service is enabled and DB is migrated', () => { const compactingService = new WorkflowHistoryCompactionService( config, From ddcc94f896b052530e4ca63f0ccf4ca9a2c69a1e Mon Sep 17 00:00:00 2001 From: James Martin <393605+ymhr@users.noreply.github.com> Date: Mon, 10 Aug 2026 08:09:54 +0100 Subject: [PATCH 002/151] feat(core): Add backend form-trigger submission gate for end-user credentials (no changelog) (#35539) --- .../form-trigger-submit-gate.api.test.ts | 240 ++++++++++++++++++ .../nodes-base/nodes/Form/test/utils.test.ts | 148 ++++++++++- packages/nodes-base/nodes/Form/utils/utils.ts | 41 ++- packages/workflow/src/index.ts | 1 + packages/workflow/src/interfaces.ts | 5 +- .../workflow/src/trigger-credential-gate.ts | 47 ++++ .../test/trigger-credential-gate.test.ts | 87 +++++++ 7 files changed, 552 insertions(+), 17 deletions(-) create mode 100644 packages/cli/test/integration/dynamic-credentials.ee/form-trigger-submit-gate.api.test.ts create mode 100644 packages/workflow/src/trigger-credential-gate.ts create mode 100644 packages/workflow/test/trigger-credential-gate.test.ts diff --git a/packages/cli/test/integration/dynamic-credentials.ee/form-trigger-submit-gate.api.test.ts b/packages/cli/test/integration/dynamic-credentials.ee/form-trigger-submit-gate.api.test.ts new file mode 100644 index 00000000000..cc46762f5ff --- /dev/null +++ b/packages/cli/test/integration/dynamic-credentials.ee/form-trigger-submit-gate.api.test.ts @@ -0,0 +1,240 @@ +import { + createWorkflowWithHistory, + getPersonalProject, + setActiveVersion, + testDb, +} from '@n8n/backend-test-utils'; +import { GlobalConfig } from '@n8n/config'; +import type { User, WorkflowEntity } from '@n8n/db'; +import { ExecutionRepository, WebhookRepository } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { Cipher } from 'n8n-core'; +import { FormTrigger } from 'n8n-nodes-base/nodes/Form/FormTrigger.node'; +import type { CredentialConnectionsRequiredResponse, INode } from 'n8n-workflow'; +import { FORM_TRIGGER_NODE_TYPE } from 'n8n-workflow'; +import { randomUUID } from 'node:crypto'; +import { agent as testAgent } from 'supertest'; + +import { SYSTEM_RESOLVER_ID } from '@/modules/dynamic-credentials.ee/constants'; +import { DynamicCredentialUserEntryStorage } from '@/modules/dynamic-credentials.ee/credential-resolvers/storage/dynamic-credential-user-entry-storage'; +import { N8nResolverSeeder } from '@/modules/dynamic-credentials.ee/services/n8n-resolver-seeder.service'; +import { OAuthClientRepository } from '@/modules/oauth-server/database/repositories/oauth-client.repository'; +import { OAuthTokenService } from '@/modules/oauth-server/oauth-token.service'; +import { CacheService } from '@/services/cache/cache.service'; +import { UrlService } from '@/services/url.service'; +import { WebhookServer } from '@/webhooks/webhook-server'; + +import { createCredentials } from '../shared/db/credentials'; +import { createOwner } from '../shared/db/users'; +import type { SuperAgentTest } from '../shared/types'; +import { initNodeTypes, setupTestServer } from '../shared/utils'; + +setupTestServer({ + endpointGroups: ['credentials'], + enabledFeatures: ['feat:dynamicCredentials'], + modules: ['dynamic-credentials', 'oauth-server'], +}); + +let owner: User; +let submitter: User; +let agent: SuperAgentTest; +let formEndpoint: string; + +const resourceUrlFor = (webhookPath: string) => + `${Container.get(UrlService).getWebhookBaseUrl().replace(/\/$/, '')}/${formEndpoint}/${webhookPath}`; + +const formTriggerNode = (webhookPath: string): INode => ({ + id: randomUUID(), + name: 'On form submission', + type: FORM_TRIGGER_NODE_TYPE, + typeVersion: 2.6, + position: [0, 0], + // v2.6 drops the `path` parameter, so the webhook path travels as `$webhookId`. + webhookId: webhookPath, + parameters: { + authentication: 'n8nUserAuth', + formTitle: 'Test Form', + formDescription: '', + responseMode: 'onReceived', + formFields: { values: [{ fieldLabel: 'Name', fieldType: 'text' }] }, + options: {}, + }, +}); + +/** + * Published, webhook-registered form workflow whose trigger node carries an + * end-user (resolvable) credential. Writes the rows directly — the point of the + * test is the runtime gate, not publish-time validation. + */ +const setupPublishedForm = async () => { + const webhookPath = randomUUID(); + const node = formTriggerNode(webhookPath); + + const credential = await createCredentials( + { name: 'Submitter Gmail', type: 'gmailOAuth2', data: '', isResolvable: true }, + await getPersonalProject(owner), + ); + + node.credentials = { gmailOAuth2: { id: credential.id, name: credential.name } }; + + const workflow = await createWorkflowWithHistory({ active: true, nodes: [node] }, owner); + await setActiveVersion(workflow.id, workflow.versionId); + await Container.get(WebhookRepository).insert({ + workflowId: workflow.id, + webhookPath, + method: 'POST', + node: node.name, + }); + + return { workflow, webhookPath, credential }; +}; + +/** Mints a real audience-scoped access token for the form resource. */ +const mintAccessToken = async (userId: string, resourceUrl: string) => { + const tokenService = Container.get(OAuthTokenService); + // A registered client is needed only to satisfy the token rows' FK. + const clientId = `client-${randomUUID()}`; + await Container.get(OAuthClientRepository).save({ + id: clientId, + name: 'Form submit gate tests', + redirectUris: ['https://example.com/callback'], + grantTypes: ['authorization_code'], + tokenEndpointAuthMethod: 'none', + }); + const pair = tokenService.generateTokenPair(userId, clientId, resourceUrl, []); + await tokenService.saveTokenPair(pair.accessToken, pair.refreshToken, clientId, userId, []); + return pair.accessToken; +}; + +/** What the connect flow persists once the submitter has connected the credential. */ +const connectCredential = async (credentialId: string, userId: string) => { + const encrypted = await Container.get(Cipher).encryptV2({ accessToken: 'submitter-secret' }); + await Container.get(DynamicCredentialUserEntryStorage).setCredentialData( + credentialId, + userId, + SYSTEM_RESOLVER_ID, + encrypted, + {}, + ); +}; + +const submitForm = async (webhookPath: string, token: string) => + await agent + .post(`/${formEndpoint}/${webhookPath}`) + .set('x-auth-token', token) + .set('content-type', 'multipart/form-data') + .field('field-0', 'John'); + +const executionCountFor = async (workflowId: string) => + await Container.get(ExecutionRepository).count({ where: { workflowId } }); + +beforeAll(async () => { + process.env.N8N_ENV_FEAT_FORM_TRIGGER_OAUTH2 = 'true'; + formEndpoint = Container.get(GlobalConfig).endpoints.form; + + // The webhook path is served by a real `WebhookServer` running the real Form + // Trigger, so that is the only node type the (single-node) workflow needs. + await initNodeTypes({ + [FORM_TRIGGER_NODE_TYPE]: { type: new FormTrigger(), sourcePath: '' }, + }); + + owner = await createOwner(); + submitter = await createOwner(); + + await Container.get(CacheService).init(); // WebhookService caches static webhook lookups + + const server = new WebhookServer(); + await server.start(); + agent = testAgent(server.app) as unknown as SuperAgentTest; +}); + +afterAll(() => { + delete process.env.N8N_ENV_FEAT_FORM_TRIGGER_OAUTH2; +}); + +beforeEach(async () => { + await testDb.truncate([ + 'ExecutionEntity', + 'AccessToken', + 'RefreshToken', + 'AuthorizationCode', + 'OAuthClient', + 'WebhookEntity', + 'SharedWorkflow', + 'WorkflowEntity', + 'WorkflowHistory', + 'DynamicCredentialUserEntry', + 'SharedCredentials', + 'CredentialsEntity', + 'DynamicCredentialResolver', + ]); + await Container.get(CacheService).reset(); + + // Re-seed the system resolver, which backs any resolvable credential without + // an explicit `resolverId`. Seeding (not a hand-written row) matters: the + // resolver's config has to be encrypted for it to be readable at resolve time. + await Container.get(N8nResolverSeeder).seed(); +}); + +describe('form trigger submit-time credential gate', () => { + let workflow: WorkflowEntity; + let webhookPath: string; + let credentialId: string; + let token: string; + + beforeEach(async () => { + const fixture = await setupPublishedForm(); + workflow = fixture.workflow; + webhookPath = fixture.webhookPath; + credentialId = fixture.credential.id; + token = await mintAccessToken(submitter.id, resourceUrlFor(webhookPath)); + }); + + test('rejects the submission and creates no execution when the credential is not connected', async () => { + const response = await submitForm(webhookPath, token); + + expect(response.statusCode).toBe(428); + expect(response.body).toEqual({ + status: 'credential_connections_required', + readyToExecute: false, + credentials: [ + { + credentialId, + credentialName: 'Submitter Gmail', + credentialType: 'gmailOAuth2', + credentialStatus: 'missing', + }, + ], + }); + await expect(executionCountFor(workflow.id)).resolves.toBe(0); + }); + + test('accepts the submission and creates one execution once the credential is connected', async () => { + await connectCredential(credentialId, submitter.id); + + const response = await submitForm(webhookPath, token); + + expect(response.statusCode).toBe(200); + await expect(executionCountFor(workflow.id)).resolves.toBe(1); + }); + + test('rejects a submission made after the connection is revoked', async () => { + await connectCredential(credentialId, submitter.id); + expect((await submitForm(webhookPath, token)).statusCode).toBe(200); + + await Container.get(DynamicCredentialUserEntryStorage).deleteCredentialData( + credentialId, + submitter.id, + SYSTEM_RESOLVER_ID, + {}, + ); + + const response = await submitForm(webhookPath, token); + + expect(response.statusCode).toBe(428); + expect((response.body as CredentialConnectionsRequiredResponse).status).toBe( + 'credential_connections_required', + ); + await expect(executionCountFor(workflow.id)).resolves.toBe(1); // only the pre-revoke run + }); +}); diff --git a/packages/nodes-base/nodes/Form/test/utils.test.ts b/packages/nodes-base/nodes/Form/test/utils.test.ts index 3ebaf576c7d..a91d210ffe3 100644 --- a/packages/nodes-base/nodes/Form/test/utils.test.ts +++ b/packages/nodes-base/nodes/Form/test/utils.test.ts @@ -23,6 +23,7 @@ import { mock } from 'vitest-mock-extended'; import { DateTime } from 'luxon'; import { InstanceSettings } from 'n8n-core'; import type { + CredentialCheckResult, FormFieldsParameter, IDataObject, INode, @@ -32,7 +33,7 @@ import type { MultiPartFormData, NodeTypeAndVersion, } from 'n8n-workflow'; -import { BINARY_MODE_COMBINED } from 'n8n-workflow'; +import { BINARY_MODE_COMBINED, FORM_TRIGGER_NODE_TYPE, WAIT_NODE_TYPE } from 'n8n-workflow'; import { formWebhook, @@ -1046,10 +1047,12 @@ describe('FormTrigger, formWebhook', () => { query?: IDataObject; headers?: Record; originalUrl?: string; + nodeType?: string; } = { method: 'GET' }, ) => { const send = vi.fn(); - const status = vi.fn(() => ({ send })) as any; + const json = vi.fn(); + const status = vi.fn(() => ({ send, json })) as any; const writeHead = vi.fn(); const end = vi.fn(); const setHeader = vi.fn(); @@ -1065,7 +1068,10 @@ describe('FormTrigger, formWebhook', () => { contentType: overrides.method === 'POST' ? 'multipart/form-data' : undefined, }; - ctx.getNode.mockReturnValue({ typeVersion: 2.6 } as INode); + ctx.getNode.mockReturnValue({ + typeVersion: 2.6, + type: overrides.nodeType ?? FORM_TRIGGER_NODE_TYPE, + } as INode); ctx.getNodeParameter.calledWith('options').mockReturnValue({}); ctx.getNodeParameter.calledWith('formTitle').mockReturnValue('Test Form'); ctx.getNodeParameter.calledWith('formDescription').mockReturnValue('Test Description'); @@ -1091,7 +1097,7 @@ describe('FormTrigger, formWebhook', () => { ctx.getChildNodes.mockReturnValue([]); (ctx as any).logger = { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() }; - return { status, send, writeHead, end, setHeader, render, cookie, clearCookie }; + return { status, send, json, writeHead, end, setHeader, render, cookie, clearCookie }; }; beforeEach(() => { @@ -1315,6 +1321,140 @@ describe('FormTrigger, formWebhook', () => { expect(send).toHaveBeenCalled(); expect(result).toEqual({ noWebhookResponse: true }); }); + + describe('submit-time credential readiness gate', () => { + const notReady: CredentialCheckResult = { + readyToExecute: false, + credentials: [ + { + credentialId: 'cred-missing', + credentialName: 'My Gmail', + credentialType: 'gmailOAuth2', + resolverId: 'resolver-1', + status: 'missing', + authorizationUrl: 'https://example.com/authorize', + revokeUrl: 'https://example.com/revoke', + }, + { + credentialId: 'cred-connected', + credentialName: 'My CRM', + credentialType: 'hubspotOAuth2', + resolverId: 'resolver-2', + status: 'configured', + }, + ], + }; + + const setupAuthedPost = ( + ctx: ReturnType>, + nodeType?: string, + ) => { + const res = setupContext(ctx, { + method: 'POST', + headers: { 'x-auth-token': 'as-token' }, + nodeType, + }); + ctx.validateN8nOAuth2Token.mockResolvedValue({ valid: true, user: authedUser }); + return res; + }; + + it('returns 428 with the structured body and no workflowData when not ready', async () => { + const ctx = mock(); + const { status, json } = setupAuthedPost(ctx); + ctx.checkTriggerCredentialStatus.mockResolvedValue(notReady); + + const result = await formWebhook(ctx); + + expect(status).toHaveBeenCalledWith(428); + expect(json).toHaveBeenCalledWith({ + status: 'credential_connections_required', + readyToExecute: false, + credentials: [ + { + credentialId: 'cred-missing', + credentialName: 'My Gmail', + credentialType: 'gmailOAuth2', + credentialStatus: 'missing', + }, + { + credentialId: 'cred-connected', + credentialName: 'My CRM', + credentialType: 'hubspotOAuth2', + credentialStatus: 'configured', + }, + ], + }); + // The connect links belong to the trusted host, not the sandboxed page. + for (const credential of json.mock.calls[0][0].credentials) { + expect(credential).not.toHaveProperty('authorizationUrl'); + expect(credential).not.toHaveProperty('revokeUrl'); + } + expect(result).toEqual({ noWebhookResponse: true }); + }); + + it('enqueues the execution when the check reports ready', async () => { + const ctx = mock(); + const { status } = setupAuthedPost(ctx); + ctx.checkTriggerCredentialStatus.mockResolvedValue({ + readyToExecute: true, + credentials: [notReady.credentials[1]], + }); + + const result = await formWebhook(ctx); + + expect(status).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + webhookResponse: { status: 200 }, + workflowData: [[expect.anything()]], + }); + }); + + it('enqueues the execution when no check applies', async () => { + const ctx = mock(); + const { status } = setupAuthedPost(ctx); + ctx.checkTriggerCredentialStatus.mockResolvedValue(undefined); + + const result = await formWebhook(ctx); + + expect(status).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + webhookResponse: { status: 200 }, + workflowData: [[expect.anything()]], + }); + }); + + it('fails closed with 503 when the check throws', async () => { + const ctx = mock(); + const { status, json } = setupAuthedPost(ctx); + const error = new Error('could not decrypt credential context'); + ctx.checkTriggerCredentialStatus.mockRejectedValue(error); + + const result = await formWebhook(ctx); + + expect(status).toHaveBeenCalledWith(503); + expect(json).toHaveBeenCalledWith({ status: 'credential_readiness_check_failed' }); + expect(ctx.logger.error).toHaveBeenCalledWith( + 'Form submit credential readiness check failed', + { error }, + ); + expect(result).toEqual({ noWebhookResponse: true }); + }); + + it('does not gate a Wait node form resume', async () => { + const ctx = mock(); + const { status } = setupAuthedPost(ctx, WAIT_NODE_TYPE); + ctx.checkTriggerCredentialStatus.mockResolvedValue(notReady); + + const result = await formWebhook(ctx); + + expect(ctx.checkTriggerCredentialStatus).not.toHaveBeenCalled(); + expect(status).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + webhookResponse: { status: 200 }, + workflowData: [[expect.anything()]], + }); + }); + }); }); }); diff --git a/packages/nodes-base/nodes/Form/utils/utils.ts b/packages/nodes-base/nodes/Form/utils/utils.ts index f45d5bfbb41..536887a12c1 100644 --- a/packages/nodes-base/nodes/Form/utils/utils.ts +++ b/packages/nodes-base/nodes/Form/utils/utils.ts @@ -16,6 +16,7 @@ import type { IWebhookFunctions, FormFieldsParameter, NodeTypeAndVersion, + CredentialCheckResult, } from 'n8n-workflow'; import { FORM_NODE_TYPE, @@ -28,6 +29,7 @@ import { BINARY_MODE_COMBINED, tryToParseJsonToFormFields, UnexpectedError, + buildCredentialConnectionsRequiredResponse, } from 'n8n-workflow'; import * as a from 'node:assert'; import sanitize from 'sanitize-html'; @@ -1209,23 +1211,40 @@ export async function formWebhook( }; } + // Submit-time readiness gate, and the only real enforcement: the hosting shell's + // disabled submit button is UX, re-enablable by author script inside the form's + // iframe. It also works off the state at render time, so a required credential can + // be revoked — or the page simply left open — between render and submit. Re-check + // here, after identity establishment and before the execution is enqueued, so a + // stale page can't spawn a run that dies at credential resolution. Scoped to the + // trigger: the Wait node shares `formWebhook`, but its form resume continues an + // already-running execution. + if (node.type === FORM_TRIGGER_NODE_TYPE) { + let readiness: CredentialCheckResult | undefined; + try { + readiness = await context.checkTriggerCredentialStatus(); + } catch (error) { + // Fail closed. Throwing here is swallowed by the webhook layer, which then + // enqueues the execution anyway — exactly the doomed run we're preventing. + context.logger.error('Form submit credential readiness check failed', { error }); + res.status(503).json({ status: 'credential_readiness_check_failed' }); + return { noWebhookResponse: true }; + } + + if (readiness && !readiness.readyToExecute) { + // 428, matching the webhook trigger's gate (webhook-helpers.ts) so both + // trigger paths answer an unconnected credential the same way. + res.status(428).json(buildCredentialConnectionsRequiredResponse(readiness)); + return { noWebhookResponse: true }; + } + } + let { useWorkflowTimezone } = options; if (useWorkflowTimezone === undefined && node.typeVersion > 2) { useWorkflowTimezone = true; } - // Fail-closed submit gate: the shell panel / disabled button is UX only — this - // server-side re-check is the real guarantee (author script in the iframe can - // re-enable the button). Identity was established during POST authentication; - // reject if any required credential is still missing (also covers a credential - // revoked while the form was open — TOCTOU). - const submitGate = await context.checkTriggerCredentialStatus(); - if (submitGate && !submitGate.readyToExecute) { - res.status(409).json({ message: 'Required credentials are not connected yet' }); - return { noWebhookResponse: true }; - } - const userForOutput = options.includeUserInOutput === false ? undefined : authedUser; const returnItem = await prepareFormReturnItem( context, diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts index 1233244644d..c33f38c70d8 100644 --- a/packages/workflow/src/index.ts +++ b/packages/workflow/src/index.ts @@ -38,6 +38,7 @@ export * from './node-validation'; export * from './node-grouping-validation'; export * from './mcp-helpers'; export * from './tool-helpers'; +export * from './trigger-credential-gate'; export * from './trigger-identity'; export * from './n8n-oauth2-auth'; export * from './node-reference-parser-utils'; diff --git a/packages/workflow/src/interfaces.ts b/packages/workflow/src/interfaces.ts index 7fa7c12cb98..a0cdc061e9d 100644 --- a/packages/workflow/src/interfaces.ts +++ b/packages/workflow/src/interfaces.ts @@ -1517,11 +1517,12 @@ export interface IWebhookFunctions extends FunctionsBaseWithRequiredKeys<'getMod */ establishTriggerIdentity(token: string, resource: string): Promise; /** - * Checks the status of the triggering identity's resolvable (private) credentials + * Checks the status of the triggering identity's resolvable (end-user) credentials * for this workflow, using the execution context established by * `establishTriggerIdentity`. Returns connection URLs for any missing credential, or * `undefined` when no check applies (dynamic-credentials disabled or no identity - * established). Used by the MCP trigger to gate a tool call before execution. + * established). Used by the MCP trigger to gate a tool call, and by the Form trigger + * to gate a submission, before an execution is enqueued. */ checkTriggerCredentialStatus(): Promise; getInputConnectionData( diff --git a/packages/workflow/src/trigger-credential-gate.ts b/packages/workflow/src/trigger-credential-gate.ts new file mode 100644 index 00000000000..03ce8c4cf1e --- /dev/null +++ b/packages/workflow/src/trigger-credential-gate.ts @@ -0,0 +1,47 @@ +import type { CredentialCheckResult, CredentialCheckStatus } from './interfaces'; + +/** Discriminator so a client can tell a readiness rejection from any other 4xx. */ +export const CREDENTIAL_CONNECTIONS_REQUIRED = 'credential_connections_required'; + +/** + * One required credential in a readiness rejection. Field names mirror the + * `GET /workflows/:id/execution-status` response, so a client that can render + * readiness there needs no new code here. Deliberately omits `authorizationUrl` + * and `revokeUrl`: this body is read inside the sandboxed, author-scriptable form + * page, and the connect links belong to the trusted host that owns the session. + */ +export interface RequiredCredentialConnection { + credentialId: string; + credentialName: string; + credentialType: string; + credentialStatus: CredentialCheckStatus['status']; +} + +export interface CredentialConnectionsRequiredResponse { + status: typeof CREDENTIAL_CONNECTIONS_REQUIRED; + readyToExecute: false; + credentials: RequiredCredentialConnection[]; +} + +/** + * Maps a readiness check into the body a trigger returns when it refuses to + * start an execution. Keeps every required credential, not just the missing ones, + * so a client can render "{n} of {m} connected" without a second round-trip — the + * missing ones are identifiable by `credentialStatus`. + */ +export function buildCredentialConnectionsRequiredResponse( + result: CredentialCheckResult, +): CredentialConnectionsRequiredResponse { + return { + status: CREDENTIAL_CONNECTIONS_REQUIRED, + readyToExecute: false, + credentials: result.credentials.map( + ({ credentialId, credentialName, credentialType, status }) => ({ + credentialId, + credentialName, + credentialType, + credentialStatus: status, + }), + ), + }; +} diff --git a/packages/workflow/test/trigger-credential-gate.test.ts b/packages/workflow/test/trigger-credential-gate.test.ts new file mode 100644 index 00000000000..beeac795d0c --- /dev/null +++ b/packages/workflow/test/trigger-credential-gate.test.ts @@ -0,0 +1,87 @@ +import type { CredentialCheckResult } from '../src/interfaces'; +import { + CREDENTIAL_CONNECTIONS_REQUIRED, + buildCredentialConnectionsRequiredResponse, +} from '../src/trigger-credential-gate'; + +describe('buildCredentialConnectionsRequiredResponse', () => { + const missing = { + credentialId: 'cred-missing', + credentialName: 'My Gmail', + credentialType: 'gmailOAuth2', + resolverId: 'resolver-1', + status: 'missing' as const, + authorizationUrl: 'https://example.com/authorize', + revokeUrl: 'https://example.com/revoke', + }; + const configured = { + credentialId: 'cred-configured', + credentialName: 'My CRM', + credentialType: 'hubspotOAuth2', + resolverId: 'resolver-2', + status: 'configured' as const, + }; + + it('renames status to credentialStatus and drops resolver/URL fields', () => { + const result: CredentialCheckResult = { readyToExecute: false, credentials: [missing] }; + + expect(buildCredentialConnectionsRequiredResponse(result)).toEqual({ + // Asserted as a literal so a rename can't silently break clients reading the wire. + status: 'credential_connections_required', + readyToExecute: false, + credentials: [ + { + credentialId: 'cred-missing', + credentialName: 'My Gmail', + credentialType: 'gmailOAuth2', + credentialStatus: 'missing', + }, + ], + }); + }); + + it('keeps connected credentials and preserves input order', () => { + const result: CredentialCheckResult = { + readyToExecute: false, + credentials: [configured, missing], + }; + + const response = buildCredentialConnectionsRequiredResponse(result); + + expect(response.credentials.map((c) => c.credentialId)).toEqual([ + 'cred-configured', + 'cred-missing', + ]); + expect(response.credentials.map((c) => c.credentialStatus)).toEqual(['configured', 'missing']); + }); + + it('always reports readyToExecute false, even for a ready result', () => { + const result: CredentialCheckResult = { readyToExecute: true, credentials: [configured] }; + + expect(buildCredentialConnectionsRequiredResponse(result).readyToExecute).toBe(false); + }); + + it('handles an empty credential list', () => { + const result: CredentialCheckResult = { readyToExecute: false, credentials: [] }; + + expect(buildCredentialConnectionsRequiredResponse(result)).toEqual({ + status: CREDENTIAL_CONNECTIONS_REQUIRED, + readyToExecute: false, + credentials: [], + }); + }); + + it('maps a resolver_missing status through unchanged', () => { + const result: CredentialCheckResult = { + readyToExecute: false, + credentials: [{ ...missing, status: 'resolver_missing' }], + }; + + expect(buildCredentialConnectionsRequiredResponse(result).credentials[0]).toEqual({ + credentialId: 'cred-missing', + credentialName: 'My Gmail', + credentialType: 'gmailOAuth2', + credentialStatus: 'resolver_missing', + }); + }); +}); From fcc4ef59d6af9cc250fadf455e167835c488a91e Mon Sep 17 00:00:00 2001 From: Jaakko Husso Date: Mon, 10 Aug 2026 10:14:01 +0300 Subject: [PATCH 003/151] feat(core): Enable 'instance-ai' module by default and remove startup warning (#35670) --- .../src/modules/__tests__/module-registry.test.ts | 15 +++++++++++++-- .../backend-common/src/modules/module-registry.ts | 1 + .../src/modules/instance-ai/instance-ai.module.ts | 11 +---------- packages/testing/playwright/pages/n8nPage.ts | 12 ++++++++++++ .../tests/e2e/auth/authenticated.spec.ts | 12 +++++++----- .../playwright/tests/e2e/auth/signin.spec.ts | 2 +- .../tests/e2e/chat-hub/chat-hub-chat-user.spec.ts | 4 ++-- 7 files changed, 37 insertions(+), 20 deletions(-) diff --git a/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts b/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts index 9513e43bbc0..7bbc2c66c48 100644 --- a/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts +++ b/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts @@ -30,7 +30,16 @@ describe('getModuleEntryUrl', () => { describe('eligibleModules', () => { it('should not include opt-in modules by default', () => { const eligible = Container.get(ModuleRegistry).eligibleModules; - expect(eligible).not.toContain('instance-ai'); + expect(eligible).not.toContain('agents'); + }); + + it('should include instance-ai by default', () => { + expect(Container.get(ModuleRegistry).eligibleModules).toContain('instance-ai'); + }); + + it('should allow opting out of a default module via env var', () => { + process.env.N8N_DISABLED_MODULES = 'instance-ai'; + expect(Container.get(ModuleRegistry).eligibleModules).not.toContain('instance-ai'); }); it('should consider a module ineligible if it was disabled via env var', () => { @@ -64,11 +73,12 @@ describe('eligibleModules', () => { 'runtime-credentials', 'mcp-registry', 'workflow-reviews', + 'instance-ai', ]); }); it('should consider a module eligible if it was enabled via env var', () => { - process.env.N8N_ENABLED_MODULES = 'instance-ai'; + process.env.N8N_ENABLED_MODULES = 'agents'; expect(Container.get(ModuleRegistry).eligibleModules).toEqual([ 'insights', 'external-secrets', @@ -100,6 +110,7 @@ describe('eligibleModules', () => { 'mcp-registry', 'workflow-reviews', 'instance-ai', + 'agents', ]); }); diff --git a/packages/@n8n/backend-common/src/modules/module-registry.ts b/packages/@n8n/backend-common/src/modules/module-registry.ts index 689f38798cc..ff56438a421 100644 --- a/packages/@n8n/backend-common/src/modules/module-registry.ts +++ b/packages/@n8n/backend-common/src/modules/module-registry.ts @@ -72,6 +72,7 @@ export class ModuleRegistry { 'runtime-credentials', 'mcp-registry', 'workflow-reviews', + 'instance-ai', ]; private readonly activeModules: string[] = []; diff --git a/packages/cli/src/modules/instance-ai/instance-ai.module.ts b/packages/cli/src/modules/instance-ai/instance-ai.module.ts index 0d034800dd5..a35e56f29aa 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.module.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.module.ts @@ -3,19 +3,9 @@ import type { ModuleInterface } from '@n8n/decorators'; import { BackendModule, OnShutdown } from '@n8n/decorators'; import { Container } from '@n8n/di'; -const YELLOW = '\x1b[33m'; -const CLEAR = '\x1b[0m'; -const WARNING_MESSAGE = - "[Instance AI] 'instance-ai' module is experimental, undocumented and subject to change. " + - 'Before its official release any features may become inaccessible at any point, ' + - 'and using the module could compromise the stability of your system. Use at your own risk!'; - @BackendModule({ name: 'instance-ai', instanceTypes: ['main'] }) export class InstanceAiModule implements ModuleInterface { async init() { - const logger = Container.get(Logger).scoped('instance-ai'); - logger.warn(`${YELLOW}${WARNING_MESSAGE}${CLEAR}`); - const { InstanceCredentialBroker } = await import( '@/credentials/instance-credential-broker.js' ); @@ -48,6 +38,7 @@ export class InstanceAiModule implements ModuleInterface { if (Container.get(GlobalConfig).instanceAi.durableLog) { const { InterruptedRunSweeper } = await import('./event-bus/interrupted-run-sweeper.js'); const { InstanceAiService } = await import('./instance-ai.service.js'); + const logger = Container.get(Logger).scoped('instance-ai'); const sweeper = Container.get(InterruptedRunSweeper); sweeper.setResumeHost(Container.get(InstanceAiService)); void sweeper.sweep().catch((error: unknown) => { diff --git a/packages/testing/playwright/pages/n8nPage.ts b/packages/testing/playwright/pages/n8nPage.ts index 4913461d76f..e51249475b0 100644 --- a/packages/testing/playwright/pages/n8nPage.ts +++ b/packages/testing/playwright/pages/n8nPage.ts @@ -239,7 +239,19 @@ export class n8nPage { this.clipboard = new ClipboardHelper(page); } + /** + * Navigate to the workflow overview. Goes there directly rather than via `/`, + * because the root route lands users on the AI Assistant when the `instance-ai` + * module is active. Use {@link goToRoot} to exercise that root routing itself. + */ async goHome() { + await this.page.goto('/home/workflows'); + } + + /** + * Navigate to the app root and let it decide where the user lands. + */ + async goToRoot() { await this.page.goto('/'); } } diff --git a/packages/testing/playwright/tests/e2e/auth/authenticated.spec.ts b/packages/testing/playwright/tests/e2e/auth/authenticated.spec.ts index 037bad48b45..8c3297ddd13 100644 --- a/packages/testing/playwright/tests/e2e/auth/authenticated.spec.ts +++ b/packages/testing/playwright/tests/e2e/auth/authenticated.spec.ts @@ -6,17 +6,19 @@ test.describe( annotation: [{ type: 'owner', description: 'Identity & Access' }], }, () => { + // Every signed-in role holds `instanceAi:message`, so the root route lands + // them on the AI Assistant while the `instance-ai` module is active. const testCases = [ - { role: 'default', expectedUrl: /\/workflow/, auth: '' }, - { role: 'owner', expectedUrl: /\/workflow/, auth: '@auth:owner' }, - { role: 'admin', expectedUrl: /\/workflow/, auth: '@auth:admin' }, - { role: 'member', expectedUrl: /\/workflow/, auth: '@auth:member' }, + { role: 'default', expectedUrl: /\/assistant/, auth: '' }, + { role: 'owner', expectedUrl: /\/assistant/, auth: '@auth:owner' }, + { role: 'admin', expectedUrl: /\/assistant/, auth: '@auth:admin' }, + { role: 'member', expectedUrl: /\/assistant/, auth: '@auth:member' }, { role: 'none', expectedUrl: /\/signin/, auth: '@auth:none' }, ]; for (const { role, expectedUrl, auth } of testCases) { test(`${role} authentication ${auth}`, async ({ n8n }) => { - await n8n.goHome(); + await n8n.goToRoot(); await expect(n8n.page).toHaveURL(expectedUrl); }); } diff --git a/packages/testing/playwright/tests/e2e/auth/signin.spec.ts b/packages/testing/playwright/tests/e2e/auth/signin.spec.ts index 8689432c1d7..8d8bb92d1b1 100644 --- a/packages/testing/playwright/tests/e2e/auth/signin.spec.ts +++ b/packages/testing/playwright/tests/e2e/auth/signin.spec.ts @@ -8,7 +8,7 @@ test.describe( }, () => { test('should login and logout @auth:none', async ({ n8n }) => { - await n8n.goHome(); + await n8n.goToRoot(); await n8n.signIn.goto(); diff --git a/packages/testing/playwright/tests/e2e/chat-hub/chat-hub-chat-user.spec.ts b/packages/testing/playwright/tests/e2e/chat-hub/chat-hub-chat-user.spec.ts index 881a8af4c49..acfa0098835 100644 --- a/packages/testing/playwright/tests/e2e/chat-hub/chat-hub-chat-user.spec.ts +++ b/packages/testing/playwright/tests/e2e/chat-hub/chat-hub-chat-user.spec.ts @@ -22,8 +22,8 @@ test.describe( }, }); - await n8n.goHome(); - await n8n.page.waitForURL('/home/chat'); // home is chat UI for chat users + await n8n.goToRoot(); + await n8n.page.waitForURL('/home/chat'); // root is the chat UI for chat users // Verify global credential is available and pre-selected await expect(n8n.chatHubChat.getModelSelectorButton()).toContainText(/claude/i); // pre-selected From a2dd7f8438201373644c2ed2adbecb50c2dd0bf0 Mon Sep 17 00:00:00 2001 From: Kai Date: Mon, 10 Aug 2026 09:21:31 +0200 Subject: [PATCH 004/151] refactor(core): Pass the whole deleted batch to the post-delete workflow hook (no-changelog) (#35884) Co-authored-by: Claude Fable 5 --- .../source-control-import.service.ee.test.ts | 23 +++++++++++-------- .../source-control-import.service.ee.ts | 9 ++++---- ...workflow-review-auto-close.service.test.ts | 23 ++++++++++++++----- .../workflow-review-auto-close.service.ts | 13 ++++++----- ...kflow-mutation-hooks-proxy.service.test.ts | 9 +++++--- .../__tests__/workflow.service.test.ts | 12 +++++----- .../workflow-mutation-hooks-proxy.service.ts | 11 +++++---- .../cli/src/workflows/workflow.service.ts | 2 +- 8 files changed, 60 insertions(+), 42 deletions(-) diff --git a/packages/cli/src/modules/source-control.ee/__tests__/source-control-import.service.ee.test.ts b/packages/cli/src/modules/source-control.ee/__tests__/source-control-import.service.ee.test.ts index 9ac44ccaee4..7f8c75401b5 100644 --- a/packages/cli/src/modules/source-control.ee/__tests__/source-control-import.service.ee.test.ts +++ b/packages/cli/src/modules/source-control.ee/__tests__/source-control-import.service.ee.test.ts @@ -2980,7 +2980,7 @@ describe('SourceControlImportService', () => { expect(folderRepository.delete).not.toHaveBeenCalled(); }); - it('should fire the afterWorkflowDeleted sweep once, after the folder row delete', async () => { + it('should fire the afterWorkflowsDeleted sweep once, after the folder row delete', async () => { const candidates = [mock({ id: 'folder1' })]; const straggler = Object.assign(new WorkflowEntity(), { id: 'wf-1', active: false }); folderRepository.getAllFolderIdsInHierarchy.mockResolvedValueOnce([]); @@ -2989,11 +2989,11 @@ describe('SourceControlImportService', () => { await service.deleteFoldersNotInWorkfolder(candidates as any); - expect(workflowMutationHooks.afterWorkflowDeleted).toHaveBeenCalledTimes(1); - expect(workflowMutationHooks.afterWorkflowDeleted).toHaveBeenCalledWith('wf-1'); + expect(workflowMutationHooks.afterWorkflowsDeleted).toHaveBeenCalledTimes(1); + expect(workflowMutationHooks.afterWorkflowsDeleted).toHaveBeenCalledWith(['wf-1']); // Only the row delete cascades the workflows away, so the sweep must run after it expect( - workflowMutationHooks.afterWorkflowDeleted.mock.invocationCallOrder[0], + workflowMutationHooks.afterWorkflowsDeleted.mock.invocationCallOrder[0], ).toBeGreaterThan(folderRepository.delete.mock.invocationCallOrder[0]); }); @@ -3004,7 +3004,7 @@ describe('SourceControlImportService', () => { await service.deleteFoldersNotInWorkfolder(candidates as any); - expect(workflowMutationHooks.afterWorkflowDeleted).not.toHaveBeenCalled(); + expect(workflowMutationHooks.afterWorkflowsDeleted).not.toHaveBeenCalled(); }); }); }); @@ -3640,7 +3640,7 @@ describe('SourceControlImportService', () => { expect(projectRepository.delete).not.toHaveBeenCalled(); }); - it('should fire the afterWorkflowDeleted sweep once, after the project row delete', async () => { + it('should fire the afterWorkflowsDeleted sweep once for the whole batch, after the project row delete', async () => { const candidates = [mock({ id: 'project-1' })]; sharedWorkflowRepository.find.mockResolvedValueOnce([ { workflowId: 'wf-active' }, @@ -3657,10 +3657,13 @@ describe('SourceControlImportService', () => { await service.deleteTeamProjectsNotInWorkfolder(candidates); // The sweep searches globally for orphaned requests, so one call covers the batch - expect(workflowMutationHooks.afterWorkflowDeleted).toHaveBeenCalledTimes(1); - expect(workflowMutationHooks.afterWorkflowDeleted).toHaveBeenCalledWith('wf-active'); + expect(workflowMutationHooks.afterWorkflowsDeleted).toHaveBeenCalledTimes(1); + expect(workflowMutationHooks.afterWorkflowsDeleted).toHaveBeenCalledWith([ + 'wf-active', + 'wf-inactive', + ]); expect( - workflowMutationHooks.afterWorkflowDeleted.mock.invocationCallOrder[0], + workflowMutationHooks.afterWorkflowsDeleted.mock.invocationCallOrder[0], ).toBeGreaterThan(projectRepository.delete.mock.invocationCallOrder[0]); }); @@ -3670,7 +3673,7 @@ describe('SourceControlImportService', () => { await service.deleteTeamProjectsNotInWorkfolder(candidates); - expect(workflowMutationHooks.afterWorkflowDeleted).not.toHaveBeenCalled(); + expect(workflowMutationHooks.afterWorkflowsDeleted).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts b/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts index af6c6a695d4..668a54962a7 100644 --- a/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts +++ b/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts @@ -1858,7 +1858,7 @@ export class SourceControlImportService { * regardless — so we do the physical cleanup directly beforehand. The * `beforeWorkflowDeleted` mutation hook fires here so modules can run their * pre-delete side effects (e.g. closing open review requests), and the - * caller fires `afterWorkflowDeleted` once the cascade has run (see + * caller fires `afterWorkflowsDeleted` once the cascade has run (see * {@link sweepAfterWorkflowCascade}) — but external hooks * (`workflow.delete`/`workflow.afterDelete`) and the `workflow-deleted` * event still don't fire, a pre-existing gap for any cascade-deleted @@ -1898,15 +1898,14 @@ export class SourceControlImportService { } /** - * Fire `afterWorkflowDeleted` once the folder/project row delete has + * Fire `afterWorkflowsDeleted` once the folder/project row delete has * cascaded the given workflows away, mirroring `WorkflowService.delete`: * the sweep behind the hook closes review requests opened after the - * pre-delete hooks ran and now left without a workflow. It searches - * globally for such orphans, so one call covers the whole batch. + * pre-delete hooks ran and now left without a workflow. */ private async sweepAfterWorkflowCascade(cascadedWorkflowIds: string[]) { if (cascadedWorkflowIds.length === 0) return; - await this.workflowMutationHooks.afterWorkflowDeleted(cascadedWorkflowIds[0]); + await this.workflowMutationHooks.afterWorkflowsDeleted(cascadedWorkflowIds); } /** Contextual error for a failed deletion during pull, so the operator learns which resource to look at. */ diff --git a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-auto-close.service.test.ts b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-auto-close.service.test.ts index 81cfbf6e051..543067cc2ee 100644 --- a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-auto-close.service.test.ts +++ b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-auto-close.service.test.ts @@ -132,23 +132,31 @@ describe('WorkflowReviewAutoCloseService', () => { expect(logger.error).toHaveBeenCalled(); }); - describe('afterWorkflowDeleted', () => { + describe('afterWorkflowsDeleted', () => { it('closes the requests the delete orphaned, outside any lock', async () => { requestRepository.closeOrphanedOpenRequests.mockResolvedValue(['req-9']); - await service.afterWorkflowDeleted('wf-1'); + await service.afterWorkflowsDeleted(['wf-1', 'wf-2']); // A single atomic statement pair, so it needs no lock — and must not take one // after a delete, where camping on the create lock would serialize submissions. + // The sweep is global, so the batch never reaches the query; it is log context only. expect(requestRepository.closeOrphanedOpenRequests).toHaveBeenCalledExactlyOnceWith({}); expect(dbLockService.withLockContext).not.toHaveBeenCalled(); - expect(logger.info).toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledExactlyOnceWith( + expect.any(String), + expect.objectContaining({ + reason: 'workflow-deleted', + workflowIds: ['wf-1', 'wf-2'], + workflowReviewRequestIds: ['req-9'], + }), + ); }); it('stays quiet when the delete orphaned nothing', async () => { requestRepository.closeOrphanedOpenRequests.mockResolvedValue([]); - await service.afterWorkflowDeleted('wf-1'); + await service.afterWorkflowsDeleted(['wf-1']); expect(logger.info).not.toHaveBeenCalled(); }); @@ -157,9 +165,12 @@ describe('WorkflowReviewAutoCloseService', () => { it('swallows repository errors, unlike the pre-delete hook', async () => { requestRepository.closeOrphanedOpenRequests.mockRejectedValue(new Error('db down')); - await expect(service.afterWorkflowDeleted('wf-1')).resolves.toBeUndefined(); + await expect(service.afterWorkflowsDeleted(['wf-1', 'wf-2'])).resolves.toBeUndefined(); - expect(logger.error).toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledExactlyOnceWith( + expect.any(String), + expect.objectContaining({ workflowIds: ['wf-1', 'wf-2'] }), + ); }); }); diff --git a/packages/cli/src/modules/workflow-reviews.ee/workflow-review-auto-close.service.ts b/packages/cli/src/modules/workflow-reviews.ee/workflow-review-auto-close.service.ts index c3b748c4849..86448b32c0d 100644 --- a/packages/cli/src/modules/workflow-reviews.ee/workflow-review-auto-close.service.ts +++ b/packages/cli/src/modules/workflow-reviews.ee/workflow-review-auto-close.service.ts @@ -42,11 +42,12 @@ export class WorkflowReviewAutoCloseService implements WorkflowMutationHooks { } /** - * Closes reviews whose workflow is gone: the cascade took their link row, so they can - * only be found by "open with nothing linked", not by workflow id. Catches reviews - * opened after the pre-delete hook ran, and any left by a delete that skips the hooks. + * Closes reviews whose workflow is gone: the cascade took their link rows, so they + * can only be found by "open with nothing linked", not by workflow id — the sweep is + * global and one call covers the whole batch. Catches reviews opened after the + * pre-delete hooks ran, and any left by a delete that skips the hooks. */ - async afterWorkflowDeleted(workflowId: string): Promise { + async afterWorkflowsDeleted(workflowIds: string[]): Promise { try { const closedRequestIds = await this.workflowReviewRequestRepository.closeOrphanedOpenRequests( {}, @@ -56,14 +57,14 @@ export class WorkflowReviewAutoCloseService implements WorkflowMutationHooks { this.logger.info('Closed workflow review request(s) left without a workflow', { reason: 'workflow-deleted', - workflowId, + workflowIds, workflowReviewRequestIds: closedRequestIds, }); } catch (error) { // The delete has already committed; failing it now would be worse than a // request that stays open until the next sweep closes it. this.logger.error('Failed to close workflow review request(s) left without a workflow', { - workflowId, + workflowIds, error, }); } diff --git a/packages/cli/src/workflows/__tests__/workflow-mutation-hooks-proxy.service.test.ts b/packages/cli/src/workflows/__tests__/workflow-mutation-hooks-proxy.service.test.ts index 084b8fe1ce9..1af377d138d 100644 --- a/packages/cli/src/workflows/__tests__/workflow-mutation-hooks-proxy.service.test.ts +++ b/packages/cli/src/workflows/__tests__/workflow-mutation-hooks-proxy.service.test.ts @@ -12,7 +12,7 @@ describe('WorkflowMutationHooksProxy', () => { await expect(proxy.afterWorkflowArchived('workflow-1')).resolves.toBeUndefined(); await expect(proxy.afterWorkflowsTransferred(['workflow-1'])).resolves.toBeUndefined(); await expect(proxy.beforeWorkflowDeleted('workflow-1')).resolves.toBeUndefined(); - await expect(proxy.afterWorkflowDeleted('workflow-1')).resolves.toBeUndefined(); + await expect(proxy.afterWorkflowsDeleted(['workflow-1'])).resolves.toBeUndefined(); }); test('forwards each hook to the registered provider', async () => { @@ -23,7 +23,7 @@ describe('WorkflowMutationHooksProxy', () => { await proxy.afterWorkflowArchived('workflow-1'); await proxy.afterWorkflowsTransferred(['workflow-1', 'workflow-2']); await proxy.beforeWorkflowDeleted('workflow-3'); - await proxy.afterWorkflowDeleted('workflow-4'); + await proxy.afterWorkflowsDeleted(['workflow-4', 'workflow-5']); expect(provider.afterWorkflowArchived).toHaveBeenCalledExactlyOnceWith('workflow-1'); expect(provider.afterWorkflowsTransferred).toHaveBeenCalledExactlyOnceWith([ @@ -31,6 +31,9 @@ describe('WorkflowMutationHooksProxy', () => { 'workflow-2', ]); expect(provider.beforeWorkflowDeleted).toHaveBeenCalledExactlyOnceWith('workflow-3'); - expect(provider.afterWorkflowDeleted).toHaveBeenCalledExactlyOnceWith('workflow-4'); + expect(provider.afterWorkflowsDeleted).toHaveBeenCalledExactlyOnceWith([ + 'workflow-4', + 'workflow-5', + ]); }); }); diff --git a/packages/cli/src/workflows/__tests__/workflow.service.test.ts b/packages/cli/src/workflows/__tests__/workflow.service.test.ts index a3578c47224..45f8601d24e 100644 --- a/packages/cli/src/workflows/__tests__/workflow.service.test.ts +++ b/packages/cli/src/workflows/__tests__/workflow.service.test.ts @@ -1847,21 +1847,21 @@ describe('WorkflowService', () => { }); // It cleans up rows the cascade orphaned, which cannot be found until the row is gone. - test('runs the afterWorkflowDeleted lifecycle hook once the row is deleted', async () => { + test('runs the afterWorkflowsDeleted lifecycle hook once the row is deleted', async () => { const workflow = makeWorkflowEntity({ isArchived: true, activeVersionId: null }); workflowFinderServiceMock.findWorkflowForUser.mockResolvedValue(workflow); await workflowService.delete(mock(), WORKFLOW_ID, true); - expect(workflowMutationHooksMock.afterWorkflowDeleted).toHaveBeenCalledExactlyOnceWith( + expect(workflowMutationHooksMock.afterWorkflowsDeleted).toHaveBeenCalledExactlyOnceWith([ WORKFLOW_ID, - ); + ]); expect( - workflowMutationHooksMock.afterWorkflowDeleted.mock.invocationCallOrder[0], + workflowMutationHooksMock.afterWorkflowsDeleted.mock.invocationCallOrder[0], ).toBeGreaterThan(workflowRepositoryMock.delete.mock.invocationCallOrder[0]); }); - test('does not run the afterWorkflowDeleted lifecycle hook when the deletion is aborted', async () => { + test('does not run the afterWorkflowsDeleted lifecycle hook when the deletion is aborted', async () => { const workflow = makeWorkflowEntity({ isArchived: true, activeVersionId: null }); workflowFinderServiceMock.findWorkflowForUser.mockResolvedValue(workflow); workflowMutationHooksMock.beforeWorkflowDeleted.mockRejectedValue(new Error('db down')); @@ -1870,7 +1870,7 @@ describe('WorkflowService', () => { 'db down', ); - expect(workflowMutationHooksMock.afterWorkflowDeleted).not.toHaveBeenCalled(); + expect(workflowMutationHooksMock.afterWorkflowsDeleted).not.toHaveBeenCalled(); }); test('deletes the workflow executions before the workflow itself', async () => { diff --git a/packages/cli/src/workflows/workflow-mutation-hooks-proxy.service.ts b/packages/cli/src/workflows/workflow-mutation-hooks-proxy.service.ts index 99cd1bec23a..669c524baaf 100644 --- a/packages/cli/src/workflows/workflow-mutation-hooks-proxy.service.ts +++ b/packages/cli/src/workflows/workflow-mutation-hooks-proxy.service.ts @@ -25,11 +25,12 @@ export interface WorkflowMutationHooks { beforeWorkflowDeleted(workflowId: string): Promise; /** - * Called once the workflow row is gone, for cleanup that can only be done + * Called once the workflow rows are gone, for cleanup that can only be done * after the delete cascades — rows orphaned by it, which by definition cannot - * be found while the workflow still exists. + * be found while the workflows still exist. One call covers a whole batch + * (e.g. a folder cascade); the ids identify what triggered the cleanup. */ - afterWorkflowDeleted(workflowId: string): Promise; + afterWorkflowsDeleted(workflowIds: string[]): Promise; } @Service() @@ -52,7 +53,7 @@ export class WorkflowMutationHooksProxy implements WorkflowMutationHooks { await this.provider?.beforeWorkflowDeleted(workflowId); } - async afterWorkflowDeleted(workflowId: string): Promise { - await this.provider?.afterWorkflowDeleted(workflowId); + async afterWorkflowsDeleted(workflowIds: string[]): Promise { + await this.provider?.afterWorkflowsDeleted(workflowIds); } } diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index 525bf2469b6..43bcbd697ab 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -1198,7 +1198,7 @@ export class WorkflowService { // After the cascade, so it can see the rows the delete orphaned. Observes a // committed delete, so it must not throw — the module swallows its own errors. - await this.workflowMutationHooks.afterWorkflowDeleted(workflowId); + await this.workflowMutationHooks.afterWorkflowsDeleted([workflowId]); this.eventService.emit('workflow-deleted', { user, workflowId, publicApi: false }); await this.externalHooks.run('workflow.afterDelete', [ From 39e0a25b0cbfad69077428d0e8ce95c9771b6734 Mon Sep 17 00:00:00 2001 From: "Guillaume F." Date: Mon, 10 Aug 2026 09:23:36 +0200 Subject: [PATCH 005/151] chore(core): Exempt trigger nodes from usableAsTool lint requirement (no-changelog) (#35769) --- .../docs/rules/node-usable-as-tool.md | 59 +++++++++++++++++ .../src/rules/node-usable-as-tool.test.ts | 65 ++++++++++++++++++- .../src/rules/node-usable-as-tool.ts | 28 ++++++++ .../src/rules/trigger-node-conventions.ts | 3 +- .../src/utils/ast-utils.ts | 26 ++++++++ 5 files changed, 177 insertions(+), 4 deletions(-) diff --git a/packages/@n8n/eslint-plugin-community-nodes/docs/rules/node-usable-as-tool.md b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/node-usable-as-tool.md index f1ac566c93a..b719281266d 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/docs/rules/node-usable-as-tool.md +++ b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/node-usable-as-tool.md @@ -10,6 +10,11 @@ Ensures your nodes declare whether they can be used as tools in AI workflows. This property helps n8n determine if your node is suitable for AI-assisted automation. +Two categories of node are exempt from the "must declare `usableAsTool`" requirement, and are additionally forbidden from setting `usableAsTool: true`, since doing so gets them converted into a synthetic tool variant that pollutes the AI Agent's tool picker even though they can't be meaningfully invoked as one: + +- **Trigger nodes** — cannot be invoked as AI tools. A node is treated as a trigger if `description.group` includes `'trigger'`, or (as a fallback) its class name ends with `Trigger`. +- **AI-only nodes** — nodes with a non-`main` output (e.g. `NodeConnectionTypes.AiLanguageModel`, `AiMemory`, `AiEmbedding`) and no inputs. These are only usable through their AI connection type (e.g. plugged into an Agent as its memory or language model), not as a generic callable tool. + ## Examples ### ❌ Incorrect @@ -27,6 +32,35 @@ export class MyNode implements INodeType { } ``` +```typescript +export class MyTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'My Trigger', + name: 'myTrigger', + group: ['trigger'], + version: 1, + // Trigger nodes must not opt into the AI tool picker + usableAsTool: true, + properties: [], + }; +} +``` + +```typescript +export class MyMemory implements INodeType { + description: INodeTypeDescription = { + displayName: 'My Memory', + name: 'myMemory', + version: 1, + inputs: [], + outputs: [NodeConnectionTypes.AiMemory], + // AI-only nodes must not opt into the AI tool picker + usableAsTool: true, + properties: [], + }; +} +``` + ### ✅ Correct ```typescript @@ -41,3 +75,28 @@ export class MyNode implements INodeType { }; } ``` + +```typescript +export class MyTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'My Trigger', + name: 'myTrigger', + group: ['trigger'], + version: 1, + properties: [], + }; +} +``` + +```typescript +export class MyMemory implements INodeType { + description: INodeTypeDescription = { + displayName: 'My Memory', + name: 'myMemory', + version: 1, + inputs: [], + outputs: [NodeConnectionTypes.AiMemory], + properties: [], + }; +} +``` diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/node-usable-as-tool.test.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/node-usable-as-tool.test.ts index d41b0f2641e..f93774b2545 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/node-usable-as-tool.test.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/node-usable-as-tool.test.ts @@ -51,6 +51,37 @@ export class RegularClass { }`; } +function createTriggerNodeCode( + options: { className?: string; usableAsTool?: boolean } = {}, +): string { + const { className = 'TestTrigger', usableAsTool } = options; + const group = className.endsWith('Trigger') ? "['trigger']" : "['trigger', 'schedule']"; + + const properties = [ + `displayName: '${className}'`, + `name: '${className.charAt(0).toLowerCase()}${className.slice(1)}'`, + `group: ${group}`, + 'version: 1', + "description: 'A test trigger node'", + `defaults: { name: '${className}' }`, + 'inputs: []', + "outputs: ['main']", + 'properties: []', + ]; + if (usableAsTool) { + properties.push('usableAsTool: true'); + } + + return ` +import type { INodeType, INodeTypeDescription } from 'n8n-workflow'; + +export class ${className} implements INodeType { + description: INodeTypeDescription = { + ${properties.join(',\n\t\t')}, + }; +}`; +} + function createNodeCodeWithOutputsInputs( outputs: string, inputs: string, @@ -97,22 +128,50 @@ ruleTester.run('node-usable-as-tool', NodeUsableAsToolRule, { code: createNodeCode(undefined, false), }, { - name: 'AI-only node: NodeConnectionTypes non-Main output and empty inputs skips usableAsTool check', + name: 'AI-only node: NodeConnectionTypes non-Main output and empty inputs does not require usableAsTool', code: createNodeCodeWithOutputsInputs('[NodeConnectionTypes.AiAgent]', '[]'), }, { - name: 'AI-only node: multiple non-Main NodeConnectionTypes outputs and empty inputs skips usableAsTool check', + name: 'AI-only node: multiple non-Main NodeConnectionTypes outputs and empty inputs does not require usableAsTool', code: createNodeCodeWithOutputsInputs( '[NodeConnectionTypes.AiAgent, NodeConnectionTypes.AiTool]', '[]', ), }, { - name: 'AI-only node: non-main string literal output and empty inputs skips usableAsTool check', + name: 'AI-only node: non-main string literal output and empty inputs does not require usableAsTool', code: createNodeCodeWithOutputsInputs("['ai_agent']", '[]'), }, + { + name: 'trigger node (class name ends with Trigger) without usableAsTool skips check', + code: createTriggerNodeCode(), + }, + { + name: "trigger node identified by group: ['trigger'] (class name does not end with Trigger) skips check", + code: createTriggerNodeCode({ className: 'TestCron' }), + }, ], invalid: [ + { + name: 'trigger node (class name ends with Trigger) with usableAsTool set to true is forbidden', + code: createTriggerNodeCode({ usableAsTool: true }), + errors: [{ messageId: 'triggerUsableAsTool' }], + }, + { + name: "trigger node identified by group: ['trigger'] with usableAsTool set to true is forbidden", + code: createTriggerNodeCode({ className: 'TestCron', usableAsTool: true }), + errors: [{ messageId: 'triggerUsableAsTool' }], + }, + { + name: 'AI-only node: non-Main output and empty inputs with usableAsTool set to true is forbidden', + code: createNodeCodeWithOutputsInputs('[NodeConnectionTypes.AiAgent]', '[]', true), + errors: [{ messageId: 'aiOnlyUsableAsTool' }], + }, + { + name: 'AI-only node: non-main string literal output and empty inputs with usableAsTool set to true is forbidden', + code: createNodeCodeWithOutputsInputs("['ai_agent']", '[]', true), + errors: [{ messageId: 'aiOnlyUsableAsTool' }], + }, { name: 'node missing usableAsTool property', code: createNodeCode('missing'), diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/node-usable-as-tool.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/node-usable-as-tool.ts index 5e5b0ce8ab3..0d5bc07354a 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/node-usable-as-tool.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/node-usable-as-tool.ts @@ -2,11 +2,16 @@ import { TSESTree } from '@typescript-eslint/utils'; import { isNodeTypeClass, + isTriggerNode, findClassProperty, findObjectProperty, createRule, } from '../utils/index.js'; +function isSetToTrue(property: TSESTree.Property | null): property is TSESTree.Property { + return property?.value.type === TSESTree.AST_NODE_TYPES.Literal && property.value.value === true; +} + export const NodeUsableAsToolRule = createRule({ name: 'node-usable-as-tool', meta: { @@ -17,6 +22,10 @@ export const NodeUsableAsToolRule = createRule({ messages: { missingUsableAsTool: 'Node class should have usableAsTool property. When in doubt, set it to true.', + triggerUsableAsTool: + 'Trigger nodes must not set usableAsTool: true. Trigger nodes cannot be invoked as AI tools and doing so pollutes the tool picker. Remove this property.', + aiOnlyUsableAsTool: + 'AI-only nodes (non-main output, no inputs) must not set usableAsTool: true. These nodes are only usable through their AI connection type, not as a generic tool, and doing so pollutes the tool picker. Remove this property.', }, fixable: 'code', schema: [], @@ -40,6 +49,17 @@ export const NodeUsableAsToolRule = createRule({ } const usableAsToolProperty = findObjectProperty(descriptionValue, 'usableAsTool'); + + if (isTriggerNode(node, descriptionValue)) { + if (isSetToTrue(usableAsToolProperty)) { + context.report({ + node: usableAsToolProperty, + messageId: 'triggerUsableAsTool', + }); + } + return; + } + const outputsProperty = findObjectProperty(descriptionValue, 'outputs'); const inputsProperty = findObjectProperty(descriptionValue, 'inputs'); if ( @@ -59,6 +79,14 @@ export const NodeUsableAsToolRule = createRule({ }); const isEmptyInputs = inputsProperty?.value?.elements?.length === 0; if (isAiOutput && isEmptyInputs) { + // These nodes are only ever consumed via their AI connection type (e.g. as an + // Agent's memory/language model), never invoked directly like a regular tool. + if (isSetToTrue(usableAsToolProperty)) { + context.report({ + node: usableAsToolProperty, + messageId: 'aiOnlyUsableAsTool', + }); + } return; } } diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/trigger-node-conventions.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/trigger-node-conventions.ts index 9c37cc44b9d..746612b38f8 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/trigger-node-conventions.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/trigger-node-conventions.ts @@ -2,6 +2,7 @@ import { TSESTree } from '@typescript-eslint/utils'; import { isNodeTypeClass, + isTriggerNodeClass, findClassProperty, findObjectProperty, getStringLiteralValue, @@ -37,7 +38,7 @@ export const TriggerNodeConventionsRule = createRule({ return; } - if (!node.id?.name.endsWith('Trigger')) { + if (!isTriggerNodeClass(node)) { return; } diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/utils/ast-utils.ts b/packages/@n8n/eslint-plugin-community-nodes/src/utils/ast-utils.ts index aeb9f0c0e06..39556f3206e 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/utils/ast-utils.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/utils/ast-utils.ts @@ -29,6 +29,32 @@ export function isCredentialTypeClass(node: TSESTree.ClassDeclaration): boolean return implementsInterface(node, 'ICredentialType'); } +/** Matches this plugin's convention for identifying trigger nodes: class name ends with `Trigger`. */ +export function isTriggerNodeClass(node: TSESTree.ClassDeclaration): boolean { + return node.id?.name.endsWith('Trigger') ?? false; +} + +function hasTriggerGroup(descriptionValue: TSESTree.ObjectExpression): boolean { + const groupArray = findArrayLiteralProperty(descriptionValue, 'group'); + return ( + groupArray?.elements.some( + (element) => element?.type === AST_NODE_TYPES.Literal && element.value === 'trigger', + ) ?? false + ); +} + +/** + * `group` is the authoritative signal for "is this a trigger node" (also catches e.g. Cron, + * Webhook, versioned `*TriggerV1` classes); the name suffix is kept as a fallback for dynamic + * `group` values. + */ +export function isTriggerNode( + node: TSESTree.ClassDeclaration, + descriptionValue: TSESTree.ObjectExpression, +): boolean { + return hasTriggerGroup(descriptionValue) || isTriggerNodeClass(node); +} + export function findClassProperty( node: TSESTree.ClassDeclaration, propertyName: string, From c0a767434c0c415c45c5f4d3a7d096ac9cecb0c5 Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Mon, 10 Aug 2026 15:26:52 +0800 Subject: [PATCH 006/151] fix(core): Normalize the $fromAI type argument before coercing defaults (#35641) --- packages/workflow/src/from-ai-parse-utils.ts | 7 +++++-- packages/workflow/test/from-ai-parse-utils.test.ts | 7 +++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/workflow/src/from-ai-parse-utils.ts b/packages/workflow/src/from-ai-parse-utils.ts index c00a7d54df3..b769e693c29 100644 --- a/packages/workflow/src/from-ai-parse-utils.ts +++ b/packages/workflow/src/from-ai-parse-utils.ts @@ -213,10 +213,13 @@ function parseArguments(argsString: string): FromAIArgument { return trimmed; }); - const type = cleanArgs?.[2] ?? 'string'; + const rawType = cleanArgs?.[2] ?? 'string'; + // `isFromAIArgumentType` accepts any casing, so normalize before narrowing: + // consumers of `FromAIArgument.type` compare against the lowercase literals. + const type = rawType.toLowerCase(); if (!isFromAIArgumentType(type)) { - throw new ParseError(`Invalid type: ${type}`); + throw new ParseError(`Invalid type: ${rawType}`); } return { diff --git a/packages/workflow/test/from-ai-parse-utils.test.ts b/packages/workflow/test/from-ai-parse-utils.test.ts index 95e96205cdf..ee063d65fad 100644 --- a/packages/workflow/test/from-ai-parse-utils.test.ts +++ b/packages/workflow/test/from-ai-parse-utils.test.ts @@ -30,6 +30,13 @@ describe('extractFromAICalls', () => { ['{{ $fromAI("a", "b", "string", "5") }}', ['a', 'b', 'string', '5']], ['{{ $fromAI("a", "b", "string", "true") }}', ['a', 'b', 'string', 'true']], ['{{ $fromAI("a", "b", "string", "{}") }}', ['a', 'b', 'string', '{}']], + // The type argument is accepted case-insensitively, so it has to be normalized + // before it is used to coerce the default value. + ['{{ $fromAI("a", "b", "String", "true") }}', ['a', 'b', 'string', 'true']], + ['{{ $fromAI("a", "b", "STRING", "5") }}', ['a', 'b', 'string', '5']], + ['{{ $fromAI("a", "b", "String", "{}") }}', ['a', 'b', 'string', '{}']], + ['{{ $fromAI("a", "b", "Number", "5") }}', ['a', 'b', 'number', 5]], + ['{{ $fromAI("a", "b", "Boolean", "true") }}', ['a', 'b', 'boolean', true]], ])('should parse args as expected for %s', (formula, [key, description, type, defaultValue]) => { expect(extractFromAICalls(formula)).toEqual([ { From 4664e9029a8fdf9b3b39ae20057fde09910ad980 Mon Sep 17 00:00:00 2001 From: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:47:28 +0300 Subject: [PATCH 007/151] test: Stabilize SSE push backend test (#35920) --- .../tests/e2e/push/sse-push-backend.spec.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/testing/playwright/tests/e2e/push/sse-push-backend.spec.ts b/packages/testing/playwright/tests/e2e/push/sse-push-backend.spec.ts index 83da52e46fc..a10dd5fca97 100644 --- a/packages/testing/playwright/tests/e2e/push/sse-push-backend.spec.ts +++ b/packages/testing/playwright/tests/e2e/push/sse-push-backend.spec.ts @@ -2,11 +2,11 @@ import { test, expect } from '../../../fixtures/base'; /** * The default container stack runs the `websocket` push backend, so `sse` needs a - * container of its own. Only meaningful in container mode: the origin check this - * covers is gated on `NODE_ENV=production`, which the image sets but a local - * `pnpm start` does not. + * container of its own. `NODE_ENV` is pinned to `production` because the origin + * check this covers is gated on it, and the stack's base env otherwise forces + * `development` — without the override the connection is accepted unchecked. */ -test.use({ capability: { env: { N8N_PUSH_BACKEND: 'sse' } } }); +test.use({ capability: { env: { N8N_PUSH_BACKEND: 'sse', NODE_ENV: 'production' } } }); test.beforeEach(({ n8nContainer }) => { test.skip(!n8nContainer, 'container-only: requires the SSE backend configuration'); @@ -29,9 +29,12 @@ test.describe( expect((await pushResponse).status()).toBe(200); // Run status per node arrives over that connection, so it cannot show up unless - // the stream stayed open. + // the stream stayed open. The container is freshly booted here, so this is its + // first Code node run and pays the task runner's cold start — measured at 3-4s, + // well past the helper's 3s default. await n8n.workflowComposer.executeWorkflowAndWaitForNotification( 'Workflow executed successfully', + { timeout: 20_000 }, ); await expect(n8n.canvas.getNodeSuccessStatusIndicator('Code')).toBeVisible(); }); From a6f07dbba00c6a31758cc3ac8ba2a5e29039f475 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 10 Aug 2026 09:31:37 +0100 Subject: [PATCH 008/151] feat(GitHub Node): Add timeout option to Dispatch and Wait for Completion (#35837) Co-authored-by: Claude Opus 5 (1M context) --- .../nodes-base/nodes/Github/Github.node.ts | 35 ++++-- .../Github/__tests__/node/Github.node.test.ts | 101 +++++++++++++++++- 2 files changed, 128 insertions(+), 8 deletions(-) diff --git a/packages/nodes-base/nodes/Github/Github.node.ts b/packages/nodes-base/nodes/Github/Github.node.ts index 0913e352562..5c73329f660 100644 --- a/packages/nodes-base/nodes/Github/Github.node.ts +++ b/packages/nodes-base/nodes/Github/Github.node.ts @@ -10,12 +10,7 @@ import type { IWebhookResponseData, JsonObject, } from 'n8n-workflow'; -import { - NodeApiError, - NodeConnectionTypes, - NodeOperationError, - WAIT_INDEFINITELY, -} from 'n8n-workflow'; +import { NodeApiError, NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; import { getFileSha, @@ -25,6 +20,8 @@ import { validateJSON, } from './GenericFunctions'; import { getRefs, getRepositories, getUsers, getWorkflows } from './SearchFunctions'; +import { configureWaitTillDate } from '../../utils/sendAndWait/configureWaitTillDate.util'; +import { limitWaitTimeOption } from '../../utils/sendAndWait/descriptions'; import { removeTrailingSlash } from '../../utils/utilities'; import { defaultWebhookDescription } from '../Webhook/description'; import { pullRequestFields } from './descriptions/PullRequestDescription'; @@ -798,6 +795,26 @@ export class Github implements INodeType { }, description: 'JSON object with input parameters for the workflow', }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add option', + default: {}, + displayOptions: { + show: { + resource: ['workflow'], + operation: ['dispatchAndWait'], + }, + }, + options: [ + { + ...limitWaitTimeOption, + description: + 'Whether to limit how long to wait for the webhook to be called before the execution resumes', + }, + ], + }, // ---------------------------------- // file @@ -2422,6 +2439,10 @@ export class Github implements INodeType { throw new NodeOperationError(this.getNode(), 'Inputs: Invalid JSON'); } + // Resolved before dispatching so a misconfigured wait limit doesn't leave an + // already-triggered GitHub workflow with nothing waiting on it + const waitTill = configureWaitTillDate(this); + endpoint = `/repos/${owner}/${repository}/actions/workflows/${workflowId}/dispatches`; body = { @@ -2453,7 +2474,7 @@ export class Github implements INodeType { // Add signed resumeUrl to metadata for frontend to use in waiting tooltip this.setMetadata({ resumeUrl }); - await this.putExecutionToWait(WAIT_INDEFINITELY); + await this.putExecutionToWait(waitTill); return [this.getInputData()]; } diff --git a/packages/nodes-base/nodes/Github/__tests__/node/Github.node.test.ts b/packages/nodes-base/nodes/Github/__tests__/node/Github.node.test.ts index b4913826376..bdf880ae254 100644 --- a/packages/nodes-base/nodes/Github/__tests__/node/Github.node.test.ts +++ b/packages/nodes-base/nodes/Github/__tests__/node/Github.node.test.ts @@ -1,5 +1,5 @@ import { NodeTestHarness } from '@nodes-testing/node-test-harness'; -import { NodeApiError, NodeOperationError } from 'n8n-workflow'; +import { NodeApiError, NodeOperationError, WAIT_INDEFINITELY } from 'n8n-workflow'; import nock from 'nock'; import * as utilities from '../../../../utils/utilities'; @@ -699,4 +699,103 @@ describe('Test Github Node', () => { ); }); }); + + describe('Dispatch and Wait - Limit Wait Time', () => { + const now = 1683028800000; + let githubNode: Github; + let mockExecutionContext: any; + + // Only the `limitWaitTime` values vary between these tests; everything else is a + // valid dispatchAndWait configuration. + const setLimitWaitTime = (values: object | undefined) => { + mockExecutionContext.getNodeParameter.mockImplementation( + (parameterName: string, _itemIndex: number, defaultValue: unknown) => { + if (parameterName === 'options.limitWaitTime.values') return values ?? defaultValue; + if (parameterName === 'resource') return 'workflow'; + if (parameterName === 'operation') return 'dispatchAndWait'; + if (parameterName === 'authentication') return 'accessToken'; + if (parameterName === 'owner') return 'testOwner'; + if (parameterName === 'repository') return 'testRepository'; + if (parameterName === 'workflowId') return 147025216; + if (parameterName === 'ref') return 'main'; + if (parameterName === 'inputs') return '{}'; + return defaultValue; + }, + ); + }; + + beforeEach(() => { + vi.useFakeTimers({ now }); + githubNode = new Github(); + mockExecutionContext = { + getNode: vi.fn().mockReturnValue({ name: 'Github' }), + getNodeParameter: vi.fn(), + getInputData: vi.fn().mockReturnValue([{ json: {} }]), + continueOnFail: vi.fn().mockReturnValue(false), + putExecutionToWait: vi.fn(), + setMetadata: vi.fn(), + getCredentials: vi.fn().mockResolvedValue({ + server: 'https://api.github.com', + user: 'test', + accessToken: 'test', + }), + helpers: { + returnJsonArray: vi.fn().mockReturnValue([{ json: {} }]), + requestWithAuthentication: vi.fn().mockResolvedValue({}), + constructExecutionMetaData: vi.fn().mockReturnValue([{ json: {} }]), + }, + getWorkflowDataProxy: vi.fn().mockReturnValue({ + $execution: { resumeUrl: 'https://example.com/webhook' }, + }), + }; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should wait indefinitely when no limit is set', async () => { + setLimitWaitTime(undefined); + + await githubNode.execute.call(mockExecutionContext); + + expect(mockExecutionContext.putExecutionToWait).toHaveBeenCalledWith(WAIT_INDEFINITELY); + }); + + it('should wait for the configured time interval', async () => { + setLimitWaitTime({ + limitType: 'afterTimeInterval', + resumeAmount: 30, + resumeUnit: 'minutes', + }); + + await githubNode.execute.call(mockExecutionContext); + + expect(mockExecutionContext.putExecutionToWait).toHaveBeenCalledWith( + new Date(now + 30 * 60 * 1000), + ); + }); + + it('should wait until the configured date and time', async () => { + const maxDateAndTime = '2023-05-02T12:00:00.000Z'; + setLimitWaitTime({ limitType: 'atSpecifiedTime', maxDateAndTime }); + + await githubNode.execute.call(mockExecutionContext); + + expect(mockExecutionContext.putExecutionToWait).toHaveBeenCalledWith( + new Date(maxDateAndTime), + ); + }); + + it('should throw for an invalid date and time without dispatching the workflow', async () => { + setLimitWaitTime({ limitType: 'atSpecifiedTime', maxDateAndTime: 'not-a-date' }); + + await expect(githubNode.execute.call(mockExecutionContext)).rejects.toThrow( + NodeOperationError, + ); + + expect(mockExecutionContext.helpers.requestWithAuthentication).not.toHaveBeenCalled(); + expect(mockExecutionContext.putExecutionToWait).not.toHaveBeenCalled(); + }); + }); }); From 4e8cfbda42b799a5374f4b2c5167c72548511fde Mon Sep 17 00:00:00 2001 From: Matsu Date: Mon, 10 Aug 2026 11:40:33 +0300 Subject: [PATCH 009/151] =?UTF-8?q?ci:=20Auto-resolve=20mechanical=20maste?= =?UTF-8?q?r=E2=86=923.x=20sync=20conflicts=20(no-changelog)=20(#35922)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Fable 5 --- .github/DEVELOPING_V3.md | 32 +- .github/WORKFLOWS.md | 13 +- .github/scripts/sync-conflict-owners.mjs | 44 +- .github/scripts/sync-conflict-owners.test.mjs | 45 +- .github/scripts/sync-master-to-3x.mjs | 435 ++++++++++-- .github/scripts/sync-master-to-3x.test.mjs | 646 +++++++++++++++--- .github/workflows/util-sync-master-to-3x.yml | 23 +- 7 files changed, 1077 insertions(+), 161 deletions(-) diff --git a/.github/DEVELOPING_V3.md b/.github/DEVELOPING_V3.md index ff97d5b3083..89a894f1325 100644 --- a/.github/DEVELOPING_V3.md +++ b/.github/DEVELOPING_V3.md @@ -133,14 +133,26 @@ of the breaking removal on `3.x`. original message and author. Nothing is ever squashed. Merge commits in the range (breaking PRs merged into `3.x`) are flattened away, and a breaking commit that also landed on `master` is dropped as empty. -3. On a **conflict**, `3.x` is left **untouched** and a **draft conflict PR** (labeled - `automation:v3-sync`) carrying the conflict markers is opened on `sync/master-to-3x`, plus a - post to the **`#alerts-v3-sync`** Slack channel. **Syncs pause until that PR is merged** — - so conflicts never pile up silently. +3. Conflicts confined to **mechanical files** — tool-generated content with a deterministic + resolution (`pnpm-lock.yaml`, `packages/frontend/editor-ui/data/node-popularity.json`, + `.github/test-metrics/e2e-impact-map.json`) — are **auto-resolved during the replay**, + exactly as a human resolver would: the lockfile is regenerated with + `pnpm install --lockfile-only` (pnpm merges its own conflict markers), bot-maintained data + files take `master`'s side. The resolution is folded into the stalled commit, so this + still adds **no commit and no PR**. The list lives in `MECHANICAL_PATHS` in + [`sync-master-to-3x.mjs`](./scripts/sync-master-to-3x.mjs). +4. On a **real code conflict**, `3.x` is left **untouched** and a **draft conflict PR** + (labeled `automation:v3-sync`) carrying the conflict markers is opened on + `sync/master-to-3x` — with the mechanical files already pre-resolved — plus a post to the + **`#alerts-v3-sync`** Slack channel. **Syncs pause until that PR is merged** — so + conflicts never pile up silently. Whatever route it takes, the sync verifies that the content it is about to push is **exactly the tree a merge of `3.x` and `master` produces** (`git merge-tree`), and that no conflict -markers are present. Either check failing fails the run instead of rewriting `3.x`. +markers are present. When mechanical files had to be regenerated, the exactness check applies +to every path **except** those files, and a regenerated lockfile must additionally be +consistent with the manifests in the pushed tree. Any check failing fails the run instead of +rewriting `3.x`. > **`3.x` is force-pushed daily.** Branch breaking-change PRs off **`master`** and target > `3.x` — then your merge-base is a `master` commit that survives every rewrite and your @@ -152,7 +164,8 @@ markers are present. Either check failing fails the run instead of rewriting `3. The conflict branch is `master` merged into `3.x` with the **conflict markers committed**, so you see exactly what clashed — and the required checks stay red until they're gone, so the PR -can't be merged half-resolved: +can't be merged half-resolved. Mechanical files arrive **pre-resolved** (listed in the PR +under "Auto-resolved for you"), so only the real code conflicts need you: ```bash git fetch origin sync/master-to-3x && git switch sync/master-to-3x @@ -160,8 +173,13 @@ git fetch origin sync/master-to-3x && git switch sync/master-to-3x git push origin sync/master-to-3x ``` +If the PR says the lockfile was **deferred** (a `package.json` / `pnpm-workspace.yaml` is +conflicted too), resolve the manifests first, then regenerate it with +`pnpm install --lockfile-only` and include the result in your fix commit. + Then **merge the PR with the normal merge button.** `master`'s commits arrive as-is and your -fix stays its own commit. +fix stays its own commit. **Never close a conflict PR unmerged** — closing resolves nothing, +the same conflict reopens on the next sync, and the new PR will call out the abandoned one. `3.x` never holds markers at its tip, so nightly images keep building; the merge commit that carries them drops out of `3.x`'s history at the next sync (the replay takes the queue's diff --git a/.github/WORKFLOWS.md b/.github/WORKFLOWS.md index 488cbb379c1..54c26bbcc37 100644 --- a/.github/WORKFLOWS.md +++ b/.github/WORKFLOWS.md @@ -425,11 +425,14 @@ During the v3 release window, `master` carries normal feature work (behind opt-i flags) and the long-lived `3.x` branch carries breaking changes. `util-sync-master-to-3x.yml` syncs daily by **replaying the `3.x`-only commits onto `master` and force-pushing `3.x`**, so a clean sync adds no commit and nothing is squashed. What it pushes is always verified to be -exactly the tree a merge of `3.x` and `master` produces, and marker-free. On a real conflict -`3.x` is left untouched and a draft PR carrying the conflict markers (labeled -`automation:v3-sync`) is opened on `sync/master-to-3x`, requesting the breaking-commit authors -as reviewers via `sync-conflict-owners.mjs`, posting to `#alerts-v3-sync` and pausing further -syncs until it is resolved and merged normally. +exactly the tree a merge of `3.x` and `master` produces, and marker-free. Conflicts confined +to mechanical, tool-generated files (the pnpm lockfile, bot-maintained data files — see +`MECHANICAL_PATHS` in `sync-master-to-3x.mjs`) are auto-resolved during the replay; the tree +check then applies to every path except those files. On a real code conflict `3.x` is left +untouched and a draft PR carrying the conflict markers (labeled `automation:v3-sync`, with +mechanical files pre-resolved) is opened on `sync/master-to-3x`, requesting the +breaking-commit authors as reviewers via `sync-conflict-owners.mjs`, posting to +`#alerts-v3-sync` and pausing further syncs until it is resolved and merged normally. `build-v3-nightly.yml` publishes `n8nio/n8n:v3-nightly[-]` images from `3.x` by calling `docker-build-push.yml` with `ref: 3.x` + `date_tag`. diff --git a/.github/scripts/sync-conflict-owners.mjs b/.github/scripts/sync-conflict-owners.mjs index c8fcf488bc9..656e756c94a 100644 --- a/.github/scripts/sync-conflict-owners.mjs +++ b/.github/scripts/sync-conflict-owners.mjs @@ -80,16 +80,35 @@ export async function resolveLogins(repo, shas, token, fetchFn = fetch) { } // Build the conflict-PR body, reviewer CSV, and Slack owner line. -export function buildOutputs({ syncBranch, targetBranch = '3.x', files, owners }) { +export function buildOutputs({ + syncBranch, + targetBranch = '3.x', + files, + owners, + preResolved = [], + lockfileDeferred = false, + abandoned = [], +}) { const filesMd = files.map((f) => `- \`${f}\``).join('\n') || '_none detected_'; const ownersMd = owners.length ? owners.map((o) => `- @${o}`).join('\n') : '_Could not auto-attribute — review the conflicted files manually._'; - const slack = owners.length - ? `Likely owners (GitHub): ${owners.map((o) => `@${o}`).join(' ')}` - : 'Could not auto-attribute owners.'; + const abandonedWarning = abandoned.length + ? `A previous PR for this recurring conflict (${abandoned.map((pr) => `#${pr.number}`).join(', ')}) was closed without being merged. Closing resolves nothing — the conflict comes back on the next sync. **Merge, don't close.**` + : ''; + const slack = [ + owners.length + ? `Likely owners (GitHub): ${owners.map((o) => `@${o}`).join(' ')}` + : 'Could not auto-attribute owners.', + abandoned.length + ? `⚠️ ${abandoned.map((pr) => `<${pr.url}|#${pr.number}>`).join(', ')} was closed without merging and the conflict is back — merge this one, don't close it.` + : '', + ] + .filter(Boolean) + .join(' '); const body = [ `Automated \`master\`→\`${targetBranch}\` sync hit a conflict.`, + ...(abandonedWarning ? ['', '> [!WARNING]', `> ${abandonedWarning}`] : []), '', `**\`${targetBranch}\` was not touched.** This branch is \`master\` merged into \`${targetBranch}\` with the **conflict markers committed**, so you can see exactly what clashed. The required checks stay red until they are resolved, so this PR cannot be merged half-done.`, '', @@ -97,12 +116,27 @@ export function buildOutputs({ syncBranch, targetBranch = '3.x', files, owners } `1. \`git fetch origin ${syncBranch} && git switch ${syncBranch}\``, '2. Fix the conflict markers and commit them **in one commit of your own** (rather than amending the merge).', `3. \`git push origin ${syncBranch}\``, - `4. **Merge this PR with the normal merge button.** \`master\`'s commits come in as-is and your fix stays its own commit — nothing is squashed.`, + `4. **Merge this PR with the normal merge button.** \`master\`'s commits come in as-is and your fix stays its own commit — nothing is squashed. Never close this PR unmerged: closing resolves nothing and the same conflict reopens on the next sync.`, '', `**Daily syncs are paused until this PR is merged.** The next sync then replays \`${targetBranch}\` onto master linearly again — which also drops this merge commit and its markers out of \`${targetBranch}\`'s history — and verifies the result is exactly what a merge would produce.`, '', '### Conflicted files', filesMd, + ...(preResolved.length + ? [ + '', + '### Auto-resolved for you', + 'These tool-generated files also conflicted and were resolved mechanically — no action needed:', + preResolved.map((f) => `- \`${f}\``).join('\n'), + ] + : []), + ...(lockfileDeferred + ? [ + '', + '> [!NOTE]', + '> `pnpm-lock.yaml` still carries its conflict markers because a manifest (`package.json` / `pnpm-workspace.yaml`) is conflicted too, or its regeneration failed. After resolving the manifests, regenerate it with `pnpm install --lockfile-only` and commit the result.', + ] + : []), '', '### Likely owners', `Authors of the ${targetBranch} commits behind the conflicted files, requested as reviewers:`, diff --git a/.github/scripts/sync-conflict-owners.test.mjs b/.github/scripts/sync-conflict-owners.test.mjs index ad213e9fc76..9552e00ccba 100644 --- a/.github/scripts/sync-conflict-owners.test.mjs +++ b/.github/scripts/sync-conflict-owners.test.mjs @@ -93,8 +93,51 @@ test('buildOutputs formats reviewers, slack line, and PR body with owners', () = }); test('buildOutputs degrades gracefully when nothing could be attributed', () => { - const out = buildOutputs({ syncBranch: 'sync/master-to-3x', syncBase: 'abc1234', files: ['x.ts'], owners: [] }); + const out = buildOutputs({ + syncBranch: 'sync/master-to-3x', + syncBase: 'abc1234', + files: ['x.ts'], + owners: [], + }); assert.equal(out.ownersCsv, ''); assert.equal(out.slack, 'Could not auto-attribute owners.'); assert.match(out.body, /Could not auto-attribute/); }); + +test('buildOutputs lists mechanically pre-resolved files apart from the code conflicts', () => { + const out = buildOutputs({ + syncBranch: 'sync/master-to-3x', + files: ['packages/cli/x.ts'], + owners: ['alice'], + preResolved: ['pnpm-lock.yaml'], + }); + assert.match(out.body, /### Conflicted files\n- `packages\/cli\/x\.ts`/); + assert.match(out.body, /### Auto-resolved for you/); + assert.match(out.body, /resolved mechanically — no action needed/); + assert.ok(out.body.indexOf('pnpm-lock.yaml') > out.body.indexOf('Auto-resolved')); +}); + +test('buildOutputs carries the regen instruction when the lockfile was deferred', () => { + const out = buildOutputs({ + syncBranch: 'sync/master-to-3x', + files: ['packages/cli/package.json'], + owners: [], + lockfileDeferred: true, + }); + assert.match(out.body, /still carries its conflict markers/); + assert.match(out.body, /pnpm install --lockfile-only/); +}); + +test('buildOutputs warns about conflict PRs that were closed without merging', () => { + const abandoned = [{ number: 42, url: 'https://github.com/n8n-io/n8n/pull/42' }]; + const out = buildOutputs({ + syncBranch: 'sync/master-to-3x', + files: ['x.ts'], + owners: ['alice'], + abandoned, + }); + assert.match(out.body, /#42\) was closed without being merged/); + assert.match(out.body, /Merge, don't close/); + assert.match(out.slack, //); + assert.match(out.slack, /merge this one, don't close it/); +}); diff --git a/.github/scripts/sync-master-to-3x.mjs b/.github/scripts/sync-master-to-3x.mjs index 8c664c3446e..867cfea9caf 100644 --- a/.github/scripts/sync-master-to-3x.mjs +++ b/.github/scripts/sync-master-to-3x.mjs @@ -11,24 +11,37 @@ * * Whatever route it takes, the content 3.x receives is verified to be exactly the tree that * merging 3.x with master produces (`git merge-tree`); a mismatch fails the run instead of - * pushing. That invariant is what makes the rewrite safe. + * pushing. That invariant is what makes the rewrite safe. When MECHANICAL files (see + * `MECHANICAL_PATHS`) had to be regenerated, the verification demands exactness everywhere + * BUT those files, which get their own checks instead (no markers, lockfile consistent + * with the pushed tree's manifests). * - * Three cases: + * Four cases: * 1. The replay is clean → force-push. No commit. * 2. The replay stalls, but 3.x's content still reconciles with master → an earlier * conflict was resolved in a merge, which leaves no patch to replay. Replay again * favouring the 3.x side (`-X theirs`), mirroring the side the resolved merge kept; * the human's fix commit is in the queue and does the real work. Tree is then proven. - * 3. The content does NOT reconcile → a genuinely new conflict. 3.x is left UNTOUCHED and a - * draft PR is opened on the sync branch carrying the conflict markers, attributed to the - * authors of the breaking commits behind the conflicted files. Syncs pause until it is - * merged. + * 3. master conflicts with 3.x, but ONLY on mechanical files — tool-generated content + * with a deterministic resolution (the pnpm lockfile, bot-maintained data files). + * These are resolved in place while the replay is stopped, exactly as a human + * resolver would (regenerate the lockfile, take master's blob), and folded into the + * stalled commit — still no commit of its own and no PR. When the `-X theirs` route + * resolves lockfile hunks without stalling, the lockfile is reconciled at the tip + * instead (folded into the tip commit by amending — never a commit of its own). + * 4. The content does NOT reconcile on a real code path → a genuinely new conflict. 3.x + * is left UNTOUCHED and a draft PR is opened on the sync branch carrying the conflict + * markers — with the mechanical files pre-resolved, so the resolver only deals with + * real code — attributed to the authors of the breaking commits behind the conflicted + * files. Syncs pause until it is merged. * * The conflict branch carries the conflict markers, so the resolver sees exactly what clashed * and the required checks stay red until they fix it in a commit of their own. That PR is * merged with the normal GitHub merge button — master's commits arrive as-is and the fix stays - * its own commit. 3.x itself never has markers at its tip (nightly images build from it), and - * the merge commit holding them is dropped from its history by the next replay. + * its own commit. NEVER close a conflict PR unmerged: closing resolves nothing and the same + * conflict reopens on the next sync. 3.x itself never has markers at its tip (nightly images + * build from it), and the merge commit holding them is dropped from its history by the next + * replay. * * Runs from a checkout of the target branch (fetch-depth 0). Assumes credentials are NOT * persisted by checkout — pushes go through an explicit token URL. @@ -39,7 +52,8 @@ * Env: GH_TOKEN (installation token with contents/pull-requests/issues write), * GITHUB_REPOSITORY (owner/repo, auto-provided by Actions), * SYNC_TARGET_BRANCH (optional — override 3.x to rehearse against a scratch branch). - * Requires Node 18+ (global fetch), git 2.38+ (`merge-tree --write-tree`) and `gh` on PATH. + * Requires Node 18+ (global fetch), git 2.38+ (`merge-tree --write-tree`), `gh` on PATH, + * and `pnpm` on PATH (lockfile conflict resolution). * * See .github/DEVELOPING_V3.md for the full v3 development model. */ @@ -47,12 +61,33 @@ import { execFileSync } from 'node:child_process'; import { appendFileSync } from 'node:fs'; -import { conflictedFiles, breakingShas, resolveLogins, buildOutputs } from './sync-conflict-owners.mjs'; +import { + conflictedFiles, + breakingShas, + resolveLogins, + buildOutputs, +} from './sync-conflict-owners.mjs'; export const TARGET_BRANCH = '3.x'; export const SYNC_BRANCH = 'sync/master-to-3x'; export const CONFLICT_LABEL = 'automation:v3-sync'; +export const LOCKFILE = 'pnpm-lock.yaml'; + +/** + * Paths whose conflicts are MECHANICAL: tool-generated files with a deterministic + * resolution, so no human judgement is lost by resolving them automatically. + * Keys are exact repo-relative paths; values pick the resolution strategy: + * - 'pnpm-regen': pnpm natively merges a conflicted lockfile when regenerating + * (`pnpm install --lockfile-only`). + * - 'take-master': bots regenerate the file on master, so master's side wins. + */ +export const MECHANICAL_PATHS = { + [LOCKFILE]: 'pnpm-regen', + 'packages/frontend/editor-ui/data/node-popularity.json': 'take-master', + '.github/test-metrics/e2e-impact-map.json': 'take-master', +}; + const BOT_NAME = 'n8n-assistant[bot]'; const BOT_EMAIL = 'n8n-assistant[bot]@users.noreply.github.com'; @@ -60,6 +95,8 @@ const BOT_EMAIL = 'n8n-assistant[bot]@users.noreply.github.com'; // throwing on a non-zero exit (mirrors `set -e`). Injectable for tests. const runGit = (args, opts = {}) => execFileSync('git', args, { encoding: 'utf8', ...opts }).trim(); const runGh = (args, opts = {}) => execFileSync('gh', args, { encoding: 'utf8', ...opts }).trim(); +const runPnpm = (args, opts = {}) => + execFileSync('pnpm', args, { encoding: 'utf8', ...opts }).trim(); // The branch to sync into. Overridable so the whole flow can be rehearsed end-to-end // against a throwaway branch before it is pointed at the real 3.x. @@ -93,29 +130,136 @@ export function isAncestor(git, maybeAncestor, descendant) { * The tree a merge of the two commits would produce — the definition of the content 3.x * should end up with, computed without touching the working tree. * - * `ok: false` means the two sides genuinely conflict: there is a new conflict that no - * existing commit on 3.x resolves. + * `ok: false` means the two sides genuinely conflict; `conflictedPaths` then names the + * clashing files, and `tree` is still written (conflicted blobs carry their markers) — + * it is the comparison baseline for the relaxed tree guard. */ export function mergeTree(git, a, b) { - const res = attempt(git, ['merge-tree', '--write-tree', a, b]); - return { ok: res.ok, tree: res.ok ? res.out.split('\n')[0].trim() : '', out: res.out }; + const res = attempt(git, ['merge-tree', '--write-tree', '--name-only', a, b]); + // Line 0 is the toplevel tree OID (written even on conflict); the lines up to the + // first blank line are the conflicted filenames (`--name-only`). Informational + // messages follow the blank line, but `attempt` folds stderr in too — filter both. + const lines = res.out.split('\n'); + const tree = lines[0]?.trim() ?? ''; + let conflictedPaths = []; + if (!res.ok) { + const blank = lines.indexOf('', 1); + const section = lines.slice(1, blank === -1 ? undefined : blank); + conflictedPaths = [ + ...new Set(section.filter((l) => l && !/^(CONFLICT|Auto-merging|warning)/.test(l))), + ]; + } + return { + ok: res.ok, + tree: res.ok || conflictedPaths.length ? tree : '', + conflictedPaths, + out: res.out, + }; } -// Replay the 3.x-only commits onto master. Merge commits in the range (breaking PR merges, -// past sync merges) are flattened; commits already applied to master are dropped as empty. -// Individual commits, messages and authors are preserved — nothing is squashed. -export function tryRebase(git, masterSha, log = console.log, extraArgs = []) { - const { ok, out } = attempt(git, ['rebase', ...extraArgs, masterSha]); - if (!ok && out) log(out); - return ok; +// Split conflicted paths into mechanically-resolvable ones and real code conflicts. +export function classifyPaths(paths) { + const mechanical = []; + const code = []; + for (const p of paths) (MECHANICAL_PATHS[p] ? mechanical : code).push(p); + return { mechanical, code }; } -// The content guard for every push: whichever route produced HEAD, its tree must be exactly -// what merging 3.x with master yields. -export function assertTree(git, expectedTree) { +// A conflicted manifest makes lockfile regeneration meaningless until it is resolved +// (catalogs live in pnpm-workspace.yaml, so it counts as a manifest too). +export function blocksLockfileRegen(codePaths) { + return codePaths.some( + (p) => p === 'package.json' || p.endsWith('/package.json') || p === 'pnpm-workspace.yaml', + ); +} + +/** + * Resolve one mechanical path in the working tree (valid mid-merge and mid-rebase alike) + * and stage the result. + */ +export function resolveMechanicalPath({ git, pnpm, path, masterSha, log = console.log }) { + const strategy = MECHANICAL_PATHS[path]; + if (strategy === 'pnpm-regen') { + // pnpm merges the conflict markers itself while regenerating. `--no-frozen-lockfile` + // is required: pnpm flips `--frozen-lockfile` on by default when CI=true. + log(`Regenerating ${path} with pnpm...`); + pnpm(['install', '--lockfile-only', '--no-frozen-lockfile']); + git(['add', '--', path]); + return; + } + log(`Resolving ${path} with master's version...`); + if (attempt(git, ['cat-file', '-e', `${masterSha}:${path}`]).ok) { + git(['checkout', masterSha, '--', path]); + } else { + git(['rm', '--force', '--', path]); // master deleted it + } +} + +/** + * Replay the 3.x-only commits onto master, resolving stalls that involve ONLY mechanical + * paths in place — folded into the stalled commit exactly as a human's `rebase --continue` + * would, so no commit is added. Bails (leaving the rebase stopped, for the caller to + * abort) as soon as a stall touches a real code path. + * + * Merge commits in the range (breaking PR merges, past conflict-PR merges) are flattened; + * commits already applied to master — or fully absorbed by a mechanical resolution — are + * dropped as empty. Individual commits, messages and authors are preserved. + */ +export function rebaseResolvingMechanical({ + git, + pnpm, + masterSha, + log = console.log, + extraArgs = [], + maxStalls = 50, +}) { + const resolved = new Set(); + let res = attempt(git, ['rebase', ...extraArgs, masterSha]); + let stalls = 0; + while (!res.ok) { + if (++stalls > maxStalls) { + log(`Giving up after ${maxStalls} rebase stalls.`); + return { ok: false, resolved: [...resolved] }; + } + const { mechanical, code } = classifyPaths(conflictedFiles(git)); + // No conflicted files means the rebase failed for some other reason — don't guess. + if (mechanical.length === 0 || code.length > 0) { + if (res.out) log(res.out); + return { ok: false, resolved: [...resolved], conflictedCode: code }; + } + for (const path of mechanical) { + resolveMechanicalPath({ git, pnpm, path, masterSha, log }); + resolved.add(path); + } + // The resolution may have absorbed the whole commit — skip it rather than + // letting `--continue` refuse an empty commit. + const empty = attempt(git, ['diff-index', '--cached', '--quiet', 'HEAD']).ok; + res = attempt(git, empty ? ['rebase', '--skip'] : ['rebase', '--continue']); + } + return { ok: true, resolved: [...resolved] }; +} + +/** + * The content guard for every push: whichever route produced HEAD, its tree must be exactly + * what merging 3.x with master yields — except on `allowedPaths`, the mechanical files the + * merge itself could not resolve (always derived from the merge-tree conflict list, never + * from what the run happened to touch). With no `allowedPaths` this is exact equality. + */ +export function assertTreeMatches(git, expectedTree, allowedPaths = []) { const actual = git(['rev-parse', 'HEAD^{tree}']); - if (actual !== expectedTree) { - throw new Error(`Replayed tree ${actual} does not match the merge tree ${expectedTree}; refusing to push.`); + if (actual === expectedTree) return; + if (allowedPaths.length === 0) { + throw new Error( + `Replayed tree ${actual} does not match the merge tree ${expectedTree}; refusing to push.`, + ); + } + const out = git(['diff-tree', '-r', '--name-only', '--no-renames', expectedTree, actual]); + const allowed = new Set(allowedPaths); + const violations = (out ? out.split('\n') : []).filter((p) => p && !allowed.has(p)); + if (violations.length > 0) { + throw new Error( + `Replayed tree deviates from the merge tree on non-mechanical paths:\n${violations.join('\n')}\nrefusing to push.`, + ); } } @@ -123,8 +267,27 @@ export function assertTree(git, expectedTree) { // with matches, 1 with none and >1 on error, so an error must not be read as "clean". export function assertNoMarkers(git, rev = 'HEAD') { const found = attempt(git, ['grep', '-I', '-l', '-e', '^<<<<<<< ', '-e', '^>>>>>>> ', rev]); - if (found.ok) throw new Error(`Refusing to continue: conflict markers present in ${rev}:\n${found.out}`); - if (found.out.includes('fatal:')) throw new Error(`Could not scan ${rev} for conflict markers:\n${found.out}`); + if (found.ok) + throw new Error(`Refusing to continue: conflict markers present in ${rev}:\n${found.out}`); + if (found.out.includes('fatal:')) + throw new Error(`Could not scan ${rev} for conflict markers:\n${found.out}`); +} + +/** + * Make the lockfile at the replayed tip consistent with the tip's own manifests. A regen + * at a stall usually already did this, but the `-X theirs` route resolves lockfile hunks + * without stalling — so the reconciliation happens here, folded into the tip commit by + * amending. Never a commit of its own, and never a rewrite of a master commit. + */ +export function reconcileLockfileAtTip({ git, pnpm, masterSha, log = console.log }) { + pnpm(['install', '--lockfile-only', '--no-frozen-lockfile']); + if (attempt(git, ['diff', '--quiet', '--', LOCKFILE]).ok) return; + if (git(['rev-parse', 'HEAD']) === masterSha) { + throw new Error('Lockfile reconciliation would amend a master commit; refusing.'); + } + log('Folding a lockfile reconciliation into the tip commit.'); + git(['add', '--', LOCKFILE]); + git(['commit', '--amend', '--no-edit', '--no-verify']); } // Append key=value lines to $GITHUB_OUTPUT (no-op when running outside Actions). @@ -139,32 +302,86 @@ export function writeGithubOutput(obj, env = process.env) { /** * Build the conflict branch: master merged into 3.x with the conflict markers committed as - * they are. The markers are the review surface — the resolver sees exactly what clashed, and - * the required checks stay red until they fix it, so the PR cannot be merged half-resolved - * (an auto-resolved branch would be green with master's change silently dropped). + * they are — except mechanical files, which are pre-resolved so the resolver only deals + * with real code conflicts. The remaining markers are the review surface: the resolver sees + * exactly what clashed, and the required checks stay red until they fix it, so the PR + * cannot be merged half-resolved (an auto-resolved branch would be green with master's + * change silently dropped). * - * 3.x never carries them at its tip, and not for long in its history either: this merge - * commit is dropped by the next replay, which takes the queue's commits only. + * The lockfile is left with its markers when a manifest is among the code conflicts + * (regenerating is meaningless until the manifests are resolved) or when the regen fails + * transiently — flagged via `lockfileDeferred` so the PR body carries the instruction. * - * @returns {string[]} the conflicted files + * 3.x never carries the markers at its tip, and not for long in its history either: this + * merge commit is dropped by the next replay, which takes the queue's commits only. + * + * @returns {{ files: string[], preResolved: string[], lockfileDeferred: boolean }} + * `files` is the list the PR reports and attributes owners for: the code conflicts, + * or every conflict when none are code (a fallback after a failed auto-resolution). */ -export function buildConflictBranch({ git, masterSha, log = console.log }) { +export function buildConflictBranch({ git, pnpm, masterSha, log = console.log }) { const merge = attempt(git, ['merge', '--no-edit', masterSha]); if (merge.ok) { // merge-tree said these conflict; if a real merge disagrees, don't guess. - throw new Error('Expected a merge conflict but the merge succeeded; aborting to avoid guessing.'); + throw new Error( + 'Expected a merge conflict but the merge succeeded; aborting to avoid guessing.', + ); } if (merge.out) log(merge.out); - const files = conflictedFiles(git); + const all = conflictedFiles(git); + const { mechanical, code } = classifyPaths(all); + + const preResolved = []; + let lockfileDeferred = false; + for (const path of mechanical) { + const needsManifests = MECHANICAL_PATHS[path] === 'pnpm-regen' && blocksLockfileRegen(code); + if (needsManifests) { + lockfileDeferred = true; + continue; + } + try { + resolveMechanicalPath({ git, pnpm, path, masterSha, log }); + preResolved.push(path); + } catch (error) { + // Degrade gracefully (e.g. a transient registry failure): leave the markers + // for the resolver rather than failing the PR-opening path. + log(`warning: could not pre-resolve ${path}: ${error.message}`); + if (MECHANICAL_PATHS[path] === 'pnpm-regen') lockfileDeferred = true; + } + } + git(['add', '-A']); git(['commit', '--no-edit', '--no-verify']); - return files; + return { files: code.length > 0 ? code : all, preResolved, lockfileDeferred }; +} + +// Conflict PRs that were recently closed WITHOUT being merged — closing resolves nothing, +// so the same conflict is about to come back; the new PR and Slack message call it out. +export function recentAbandonedConflictPrs( + gh, + { label = CONFLICT_LABEL, sinceDays = 14, now = Date.now() } = {}, +) { + const out = gh([ + 'pr', + 'list', + '--state', + 'closed', + '--label', + label, + '--json', + 'number,url,mergedAt,closedAt', + '--limit', + '10', + ]); + return JSON.parse(out || '[]').filter( + (pr) => !pr.mergedAt && pr.closedAt && now - Date.parse(pr.closedAt) < sinceDays * 86_400_000, + ); } /** - * Push the marker-free conflict branch and open a draft PR, attributing it to the authors of - * the breaking commits behind the conflicted files. 3.x is left untouched. + * Push the marker-carrying conflict branch and open a draft PR, attributing it to the + * authors of the breaking commits behind the conflicted files. 3.x is left untouched. * * @returns {Promise<{ prUrl: string, ownersSlack: string }>} */ @@ -178,6 +395,8 @@ export async function openConflictPr({ pushUrl, target = TARGET_BRANCH, files = [], + preResolved = [], + lockfileDeferred = false, fetchFn = fetch, log = console.log, }) { @@ -193,24 +412,50 @@ export async function openConflictPr({ log(`warning: could not resolve owners: ${error.message}`); } + let abandoned = []; + try { + abandoned = recentAbandonedConflictPrs(gh); + } catch (error) { + log(`warning: could not check for abandoned conflict PRs: ${error.message}`); + } + const { ownersCsv, slack, body } = buildOutputs({ syncBranch: SYNC_BRANCH, targetBranch: target, files, owners, + preResolved, + lockfileDeferred, + abandoned, }); git(['push', '--force', pushUrl, `HEAD:refs/heads/${SYNC_BRANCH}`]); // Ensure the label exists (idempotent), then open the draft conflict PR. - gh(['label', 'create', CONFLICT_LABEL, '--color', 'B60205', '--description', 'master→3.x sync conflict', '--force']); + gh([ + 'label', + 'create', + CONFLICT_LABEL, + '--color', + 'B60205', + '--description', + 'master→3.x sync conflict', + '--force', + ]); const prUrl = gh([ - 'pr', 'create', '--draft', - '--base', target, - '--head', SYNC_BRANCH, - '--label', CONFLICT_LABEL, - '--title', 'chore: Resolve master→3.x sync conflict', - '--body', body, + 'pr', + 'create', + '--draft', + '--base', + target, + '--head', + SYNC_BRANCH, + '--label', + CONFLICT_LABEL, + '--title', + 'chore: Resolve master→3.x sync conflict', + '--body', + body, ]); // Request owners as reviewers (best-effort: the API rejects the PR author @@ -229,6 +474,7 @@ export async function openConflictPr({ export async function sync({ git = runGit, gh = runGh, + pnpm = runPnpm, env = process.env, fetchFn = fetch, log = console.log, @@ -244,7 +490,12 @@ export async function sync({ const pushUrl = `https://x-access-token:${token}@github.com/${repo}.git`; // The lease pins the tip we replayed from, so a concurrent push is never overwritten. const pushReplay = (from) => - git(['push', `--force-with-lease=refs/heads/${target}:${from}`, pushUrl, `HEAD:refs/heads/${target}`]); + git([ + 'push', + `--force-with-lease=refs/heads/${target}:${from}`, + pushUrl, + `HEAD:refs/heads/${target}`, + ]); // Halt gate: if a previous conflict PR is still open, do nothing until it is merged. if (hasOpenConflictPr(gh)) { @@ -254,6 +505,8 @@ export async function sync({ git(['config', 'user.name', BOT_NAME]); git(['config', 'user.email', BOT_EMAIL]); + // `rebase --continue` insists on an editor for the commit message; keep it headless. + git(['config', 'core.editor', 'true']); git(['fetch', 'origin', 'master']); // Pin to the fetched SHA — a command-line refspec doesn't reliably update the @@ -266,43 +519,107 @@ export async function sync({ return; } - const queue = git(['log', '--no-merges', '--reverse', '--format=%h %s', `${masterSha}..${preHead}`]); + const queue = git([ + 'log', + '--no-merges', + '--reverse', + '--format=%h %s', + `${masterSha}..${preHead}`, + ]); log(`Replaying onto master ${masterSha}:\n${queue || '(none)'}`); // What the content must end up as, and whether a new conflict exists at all. const merged = mergeTree(git, preHead, masterSha); if (!merged.ok) { - log(`master conflicts with ${target} — leaving ${target} untouched and opening a conflict PR.`); - const files = buildConflictBranch({ git, masterSha, log }); + const { mechanical, code } = classifyPaths(merged.conflictedPaths); + + if (code.length === 0 && mechanical.length > 0) { + log( + `master conflicts with ${target} only on mechanical files (${mechanical.join(', ')}); auto-resolving.`, + ); + let replay = rebaseResolvingMechanical({ git, pnpm, masterSha, log }); + if (!replay.ok) { + // An intermediate stall on a code file whose endpoints still reconcile — + // same "resolved in a merge" situation as the clean-tree second pass. + attempt(git, ['rebase', '--abort']); + replay = rebaseResolvingMechanical({ + git, + pnpm, + masterSha, + log, + extraArgs: ['-X', 'theirs'], + }); + } + if (replay.ok) { + if (mechanical.includes(LOCKFILE)) reconcileLockfileAtTip({ git, pnpm, masterSha, log }); + assertTreeMatches(git, merged.tree, mechanical); + assertNoMarkers(git); + pushReplay(preHead); + log( + `Replayed ${target} onto master, auto-resolving ${mechanical.join(', ')} — no PR, no sync commit.`, + ); + return; + } + attempt(git, ['rebase', '--abort']); + log('Mechanical auto-resolution did not complete; falling back to a conflict PR.'); + } else { + log( + `master conflicts with ${target} — leaving ${target} untouched and opening a conflict PR.`, + ); + } + + const { files, preResolved, lockfileDeferred } = buildConflictBranch({ + git, + pnpm, + masterSha, + log, + }); const { prUrl, ownersSlack } = await openConflictPr({ - git, gh, repo, token, masterSha, preHead, pushUrl, target, files, fetchFn, log, + git, + gh, + repo, + token, + masterSha, + preHead, + pushUrl, + target, + files, + preResolved, + lockfileDeferred, + fetchFn, + log, }); writeGithubOutput({ conflict_pr: prUrl, conflict_owners: ownersSlack }, env); return; } - if (tryRebase(git, masterSha, log)) { - assertTree(git, merged.tree); + const plainReplay = attempt(git, ['rebase', masterSha]); + if (plainReplay.ok) { + assertTreeMatches(git, merged.tree); assertNoMarkers(git); pushReplay(preHead); log(`Replayed ${target} onto master — no sync commit created.`); return; } + if (plainReplay.out) log(plainReplay.out); // The content reconciles but the patches no longer apply on their own: an earlier // conflict was resolved in a merge, and a merge resolution leaves no patch to replay. // Replay favouring the 3.x side — the same side that resolved merge kept — and let the - // resolver's own fix commit, which is in the queue, do the real work. Nothing is - // squashed; the tree guard below proves the outcome. + // resolver's own fix commit, which is in the queue, do the real work. Mechanical stalls + // the strategy cannot settle (e.g. modify/delete) are resolved in place. Nothing is + // squashed; the tree guard below proves the outcome exactly, since the merge was clean. git(['rebase', '--abort']); log(`Patches no longer apply individually; replaying with ${target}'s side favoured.`); - if (!tryRebase(git, masterSha, log, ['-X', 'theirs'])) { + if (!rebaseResolvingMechanical({ git, pnpm, masterSha, log, extraArgs: ['-X', 'theirs'] }).ok) { git(['rebase', '--abort']); - throw new Error(`Could not replay ${target} onto master even with its own side favoured; needs a human.`); + throw new Error( + `Could not replay ${target} onto master even with its own side favoured; needs a human.`, + ); } - assertTree(git, merged.tree); + assertTreeMatches(git, merged.tree); assertNoMarkers(git); pushReplay(preHead); log(`Replayed ${target} onto master — no sync commit created.`); diff --git a/.github/scripts/sync-master-to-3x.test.mjs b/.github/scripts/sync-master-to-3x.test.mjs index 5afde00e21c..604a9e1d187 100644 --- a/.github/scripts/sync-master-to-3x.test.mjs +++ b/.github/scripts/sync-master-to-3x.test.mjs @@ -4,8 +4,13 @@ import { test } from 'node:test'; import { hasOpenConflictPr, mergeTree, - tryRebase, - assertTree, + classifyPaths, + blocksLockfileRegen, + resolveMechanicalPath, + rebaseResolvingMechanical, + reconcileLockfileAtTip, + recentAbandonedConflictPrs, + assertTreeMatches, assertNoMarkers, buildConflictBranch, openConflictPr, @@ -14,9 +19,10 @@ import { CONFLICT_LABEL, SYNC_BRANCH, TARGET_BRANCH, + LOCKFILE, } from './sync-master-to-3x.mjs'; -// A git/gh stub: routes calls by a matcher, records every invocation. +// A git/gh/pnpm stub: routes calls by a matcher, records every invocation. function makeStub(routes = []) { const calls = []; const fn = (args) => { @@ -30,24 +36,39 @@ function makeStub(routes = []) { return fn; } -const fail = (stdout = '') => () => { - const err = new Error('command failed'); - err.status = 1; - err.stdout = stdout; - throw err; -}; +const fail = + (stdout = '') => + () => { + const err = new Error('command failed'); + err.status = 1; + err.stdout = stdout; + throw err; + }; const okFetch = (logins) => async () => ({ ok: true, - json: async () => ({ data: { repository: Object.fromEntries(logins.map((l, i) => [`c${i}`, { author: { user: { login: l } } }])) } }), + json: async () => ({ + data: { + repository: Object.fromEntries( + logins.map((l, i) => [`c${i}`, { author: { user: { login: l } } }]), + ), + }, + }), }); const PRE_HEAD = 'PREHEAD'; const MASTER = 'MASTERSHA'; const MERGE_TREE = 'MERGETREEOID'; +const POPULARITY = 'packages/frontend/editor-ui/data/node-popularity.json'; const isRebase = (a) => a[0] === 'rebase' && a[1] !== '--abort'; const favouringOwnSide = (a) => a[0] === 'rebase' && a.includes('-X') && a.includes('theirs'); +const isConflictedFiles = (a) => a[0] === 'diff' && a.includes('--diff-filter=U'); + +// What `git merge-tree --write-tree --name-only` emits on a conflict: the (marker-carrying) +// tree OID, the conflicted paths, a blank line, then informational messages. +const conflictedMergeTree = (...paths) => + fail(`${MERGE_TREE}\n${paths.join('\n')}\n\nCONFLICT (content): Merge conflict in ${paths[0]}`); // Routes shared by every sync path: master fetched, 3.x hasn't absorbed it yet, and the // merge tree is computable (no new conflict). git grep exits non-zero => no markers. @@ -71,45 +92,235 @@ test('targetBranch defaults to 3.x and honours the rehearsal override', () => { test('hasOpenConflictPr reflects the open-PR count from gh', () => { const empty = makeStub([[() => true, '[]']]); assert.equal(hasOpenConflictPr(empty), false); - assert.deepEqual(empty.calls[0], ['pr', 'list', '--state', 'open', '--label', CONFLICT_LABEL, '--json', 'number']); + assert.deepEqual(empty.calls[0], [ + 'pr', + 'list', + '--state', + 'open', + '--label', + CONFLICT_LABEL, + '--json', + 'number', + ]); const one = makeStub([[() => true, JSON.stringify([{ number: 42 }])]]); assert.equal(hasOpenConflictPr(one), true); }); -test('mergeTree reports the tree when clean and not-ok when the sides conflict', () => { +test('mergeTree reports the tree when clean, and the conflicted paths when the sides conflict', () => { const clean = makeStub([[() => true, `${MERGE_TREE}\nsome extra output`]]); - assert.deepEqual(mergeTree(clean, 'A', 'B'), { ok: true, tree: MERGE_TREE, out: `${MERGE_TREE}\nsome extra output` }); - assert.deepEqual(clean.calls[0], ['merge-tree', '--write-tree', 'A', 'B']); + assert.deepEqual(mergeTree(clean, 'A', 'B'), { + ok: true, + tree: MERGE_TREE, + conflictedPaths: [], + out: `${MERGE_TREE}\nsome extra output`, + }); + assert.deepEqual(clean.calls[0], ['merge-tree', '--write-tree', '--name-only', 'A', 'B']); - const conflicting = makeStub([[() => true, fail('CONFLICT (content): x.ts')]]); - assert.equal(mergeTree(conflicting, 'A', 'B').ok, false); + const conflicting = makeStub([ + [ + () => true, + fail( + `${MERGE_TREE}\n${LOCKFILE}\npackages/cli/x.ts\n\nAuto-merging ${LOCKFILE}\nCONFLICT (content): Merge conflict in ${LOCKFILE}`, + ), + ], + ]); + const res = mergeTree(conflicting, 'A', 'B'); + assert.equal(res.ok, false); + // The conflicted tree is still written — it is the baseline for the relaxed guard. + assert.equal(res.tree, MERGE_TREE); + assert.deepEqual(res.conflictedPaths, [LOCKFILE, 'packages/cli/x.ts']); }); -test('tryRebase replays onto master and can favour a side', () => { - const git = makeStub([[(a) => a[0] === 'rebase', '']]); - assert.equal(tryRebase(git, MASTER, () => {}), true); - assert.deepEqual(git.calls[0], ['rebase', MASTER]); +test('classifyPaths and blocksLockfileRegen split mechanical from code conflicts', () => { + const { mechanical, code } = classifyPaths([ + LOCKFILE, + 'packages/cli/x.ts', + '.github/test-metrics/e2e-impact-map.json', + ]); + assert.deepEqual(mechanical, [LOCKFILE, '.github/test-metrics/e2e-impact-map.json']); + assert.deepEqual(code, ['packages/cli/x.ts']); - assert.equal(tryRebase(git, MASTER, () => {}, ['-X', 'theirs']), true); - assert.deepEqual(git.calls[1], ['rebase', '-X', 'theirs', MASTER]); + assert.equal(blocksLockfileRegen(['packages/cli/package.json']), true); + assert.equal(blocksLockfileRegen(['package.json']), true); + assert.equal(blocksLockfileRegen(['pnpm-workspace.yaml']), true); + assert.equal(blocksLockfileRegen(['packages/cli/x.ts']), false); }); -test('assertTree and assertNoMarkers are the push guards', () => { - assert.doesNotThrow(() => assertTree(makeStub([[() => true, MERGE_TREE]]), MERGE_TREE)); - assert.throws(() => assertTree(makeStub([[() => true, 'OTHER']]), MERGE_TREE), /does not match the merge tree/); +test('resolveMechanicalPath takes the blob from master, or the deletion when master removed the file', () => { + const present = makeStub([[(a) => a[0] === 'cat-file', '']]); + resolveMechanicalPath({ + git: present, + pnpm: makeStub(), + path: POPULARITY, + masterSha: MASTER, + log: () => {}, + }); + assert.ok( + present.calls.some((a) => a[0] === 'checkout' && a[1] === MASTER && a.includes(POPULARITY)), + ); + const deleted = makeStub([[(a) => a[0] === 'cat-file', fail()]]); + resolveMechanicalPath({ + git: deleted, + pnpm: makeStub(), + path: POPULARITY, + masterSha: MASTER, + log: () => {}, + }); + assert.ok( + deleted.calls.some((a) => a[0] === 'rm' && a.includes('--force') && a.includes(POPULARITY)), + ); +}); + +test('resolveMechanicalPath regenerates the lockfile with pnpm and stages it', () => { + const git = makeStub(); + const pnpm = makeStub(); + resolveMechanicalPath({ git, pnpm, path: LOCKFILE, masterSha: MASTER, log: () => {} }); + // `--no-frozen-lockfile` is load-bearing: pnpm defaults to frozen when CI=true. + assert.deepEqual(pnpm.calls[0], ['install', '--lockfile-only', '--no-frozen-lockfile']); + assert.ok(git.calls.some((a) => a[0] === 'add' && a.includes(LOCKFILE))); +}); + +test('rebaseResolvingMechanical resolves mechanical stalls in place and skips emptied commits', () => { + const git = makeStub([ + [(a) => a[0] === 'rebase' && a[1] === '--skip', ''], + [isRebase, fail(`CONFLICT (content): Merge conflict in ${POPULARITY}`)], + [isConflictedFiles, POPULARITY], + [(a) => a[0] === 'cat-file', ''], + [(a) => a[0] === 'diff-index', ''], // resolution emptied the commit + ]); + + const res = rebaseResolvingMechanical({ + git, + pnpm: makeStub(), + masterSha: MASTER, + log: () => {}, + }); + + assert.equal(res.ok, true); + assert.deepEqual(res.resolved, [POPULARITY]); + assert.ok(git.calls.some((a) => a[0] === 'checkout' && a[1] === MASTER)); + assert.ok(git.calls.some((a) => a[0] === 'rebase' && a[1] === '--skip')); +}); + +test('rebaseResolvingMechanical bails as soon as a stall touches a code path', () => { + const git = makeStub([ + [isRebase, fail('CONFLICT (content): Merge conflict in packages/cli/x.ts')], + [isConflictedFiles, `packages/cli/x.ts\n${LOCKFILE}`], + ]); + const pnpm = makeStub(); + + const res = rebaseResolvingMechanical({ git, pnpm, masterSha: MASTER, log: () => {} }); + + assert.equal(res.ok, false); + assert.deepEqual(res.conflictedCode, ['packages/cli/x.ts']); + // Nothing may be auto-resolved when human judgement is needed for the same stall. + assert.equal(pnpm.calls.length, 0); + assert.equal( + git.calls.some((a) => a[0] === 'checkout'), + false, + ); +}); + +test('assertTreeMatches is exact by default and scoped to the allowed paths otherwise', () => { + assert.doesNotThrow(() => assertTreeMatches(makeStub([[() => true, MERGE_TREE]]), MERGE_TREE)); + assert.throws( + () => assertTreeMatches(makeStub([[() => true, 'OTHER']]), MERGE_TREE), + /does not match the merge tree/, + ); + + const onlyLockfile = makeStub([ + [(a) => a[0] === 'rev-parse', 'OTHER'], + [(a) => a[0] === 'diff-tree', LOCKFILE], + ]); + assert.doesNotThrow(() => assertTreeMatches(onlyLockfile, MERGE_TREE, [LOCKFILE])); + + const alsoCode = makeStub([ + [(a) => a[0] === 'rev-parse', 'OTHER'], + [(a) => a[0] === 'diff-tree', `${LOCKFILE}\npackages/cli/x.ts`], + ]); + assert.throws(() => assertTreeMatches(alsoCode, MERGE_TREE, [LOCKFILE]), /non-mechanical paths/); +}); + +test('assertNoMarkers is a push guard', () => { assert.doesNotThrow(() => assertNoMarkers(makeStub([[() => true, fail()]]))); - assert.throws(() => assertNoMarkers(makeStub([[() => true, 'packages/cli/x.ts']])), /conflict markers present/); + assert.throws( + () => assertNoMarkers(makeStub([[() => true, 'packages/cli/x.ts']])), + /conflict markers present/, + ); // git grep failing for any other reason must not read as "clean". - assert.throws(() => assertNoMarkers(makeStub([[() => true, fail('fatal: bad object')]])), /Could not scan/); + assert.throws( + () => assertNoMarkers(makeStub([[() => true, fail('fatal: bad object')]])), + /Could not scan/, + ); +}); + +test('reconcileLockfileAtTip folds an inconsistent lockfile into the tip commit, never a master commit', () => { + const consistent = makeStub([[(a) => a[0] === 'diff', '']]); + reconcileLockfileAtTip({ git: consistent, pnpm: makeStub(), masterSha: MASTER, log: () => {} }); + assert.equal( + consistent.calls.some((a) => a[0] === 'commit'), + false, + ); + + const inconsistent = makeStub([ + [(a) => a[0] === 'diff', fail()], + [(a) => a[0] === 'rev-parse', PRE_HEAD], + ]); + const pnpm = makeStub(); + reconcileLockfileAtTip({ git: inconsistent, pnpm, masterSha: MASTER, log: () => {} }); + assert.deepEqual(pnpm.calls[0], ['install', '--lockfile-only', '--no-frozen-lockfile']); + assert.ok(inconsistent.calls.some((a) => a[0] === 'commit' && a.includes('--amend'))); + + const atMasterTip = makeStub([ + [(a) => a[0] === 'diff', fail()], + [(a) => a[0] === 'rev-parse', MASTER], + ]); + assert.throws( + () => + reconcileLockfileAtTip({ + git: atMasterTip, + pnpm: makeStub(), + masterSha: MASTER, + log: () => {}, + }), + /amend a master commit/, + ); +}); + +test('recentAbandonedConflictPrs keeps only recently closed-unmerged conflict PRs', () => { + const now = Date.parse('2026-08-10T00:00:00Z'); + const gh = makeStub([ + [ + () => true, + JSON.stringify([ + { + number: 1, + url: 'u1', + mergedAt: '2026-08-01T00:00:00Z', + closedAt: '2026-08-01T00:00:00Z', + }, + { number: 2, url: 'u2', mergedAt: null, closedAt: '2026-07-01T00:00:00Z' }, // too old + { number: 3, url: 'u3', mergedAt: null, closedAt: '2026-08-08T00:00:00Z' }, + ]), + ], + ]); + + const abandoned = recentAbandonedConflictPrs(gh, { now }); + + assert.deepEqual( + abandoned.map((pr) => pr.number), + [3], + ); + assert.equal(gh.calls[0][gh.calls[0].indexOf('--state') + 1], 'closed'); }); test('sync replays and force-pushes with a lease, creating no commit', async () => { const git = makeStub([...baseGitRoutes, [isRebase, '']]); const gh = makeStub(noOpenPr); - await sync({ git, gh, env, log: () => {} }); + await sync({ git, gh, pnpm: makeStub(), env, log: () => {} }); const push = git.calls.find((a) => a[0] === 'push'); assert.deepEqual(push, [ @@ -119,16 +330,31 @@ test('sync replays and force-pushes with a lease, creating no commit', async () `HEAD:refs/heads/${TARGET_BRANCH}`, ]); // The point of the change: no merge, no squash, no PR on the clean path. - assert.equal(git.calls.some((a) => a[0] === 'merge'), false); - assert.equal(git.calls.some((a) => a[0] === 'commit'), false); - assert.equal(gh.calls.some((a) => a[0] === 'pr' && a[1] === 'create'), false); + assert.equal( + git.calls.some((a) => a[0] === 'merge'), + false, + ); + assert.equal( + git.calls.some((a) => a[0] === 'commit'), + false, + ); + assert.equal( + gh.calls.some((a) => a[0] === 'pr' && a[1] === 'create'), + false, + ); }); test('sync targets the rehearsal branch when SYNC_TARGET_BRANCH is set', async () => { const git = makeStub([...baseGitRoutes, [isRebase, '']]); const gh = makeStub(noOpenPr); - await sync({ git, gh, env: { ...env, SYNC_TARGET_BRANCH: '3x-sync-test' }, log: () => {} }); + await sync({ + git, + gh, + pnpm: makeStub(), + env: { ...env, SYNC_TARGET_BRANCH: '3x-sync-test' }, + log: () => {}, + }); assert.equal(git.calls.find((a) => a[0] === 'push').at(-1), 'HEAD:refs/heads/3x-sync-test'); }); @@ -141,10 +367,18 @@ test('sync does nothing when 3.x already contains master', async () => { ]); const gh = makeStub(noOpenPr); - await sync({ git, gh, env, log: () => {} }); + await sync({ git, gh, pnpm: makeStub(), env, log: () => {} }); - assert.equal(git.calls.some((a) => a[0] === 'rebase'), false, 'must not rebase'); - assert.equal(git.calls.some((a) => a[0] === 'push'), false, 'must not push'); + assert.equal( + git.calls.some((a) => a[0] === 'rebase'), + false, + 'must not rebase', + ); + assert.equal( + git.calls.some((a) => a[0] === 'push'), + false, + 'must not push', + ); }); test('sync refuses to push when the replayed tree is not the merge tree', async () => { @@ -155,15 +389,22 @@ test('sync refuses to push when the replayed tree is not the merge tree', async ]); const gh = makeStub(noOpenPr); - await assert.rejects(() => sync({ git, gh, env, log: () => {} }), /does not match the merge tree/); - assert.equal(git.calls.some((a) => a[0] === 'push'), false, 'must not push a suspect rewrite'); + await assert.rejects( + () => sync({ git, gh, pnpm: makeStub(), env, log: () => {} }), + /does not match the merge tree/, + ); + assert.equal( + git.calls.some((a) => a[0] === 'push'), + false, + 'must not push a suspect rewrite', + ); }); test('sync halts (no fetch/rebase) when a conflict PR is already open', async () => { const git = makeStub(); const gh = makeStub([[(a) => a[0] === 'pr' && a[1] === 'list', JSON.stringify([{ number: 7 }])]]); - await sync({ git, gh, env, log: () => {} }); + await sync({ git, gh, pnpm: makeStub(), env, log: () => {} }); assert.equal(git.calls.length, 0, 'must not touch git while halted'); }); @@ -178,55 +419,93 @@ test('sync replays favouring 3.x when the patches no longer apply on their own', ]); const gh = makeStub(noOpenPr); - await sync({ git, gh, env, log: () => {} }); + await sync({ git, gh, pnpm: makeStub(), env, log: () => {} }); - assert.ok(git.calls.some((a) => a[0] === 'rebase' && a[1] === '--abort'), 'stalled rebase must be aborted'); + assert.ok( + git.calls.some((a) => a[0] === 'rebase' && a[1] === '--abort'), + 'stalled rebase must be aborted', + ); assert.ok(git.calls.some(favouringOwnSide), 'expected the second replay to favour 3.x'); - assert.ok(git.calls.some((a) => a[0] === 'push'), 'expected the replay to be pushed'); + assert.ok( + git.calls.some((a) => a[0] === 'push'), + 'expected the replay to be pushed', + ); // Still no new commit and no PR: the resolver's own fix commit is already in the queue. - assert.equal(git.calls.some((a) => a[0] === 'commit'), false); - assert.equal(gh.calls.some((a) => a[0] === 'pr' && a[1] === 'create'), false); + assert.equal( + git.calls.some((a) => a[0] === 'commit'), + false, + ); + assert.equal( + gh.calls.some((a) => a[0] === 'pr' && a[1] === 'create'), + false, + ); }); test('sync fails without pushing when even the favoured replay cannot finish', async () => { - const git = makeStub([...baseGitRoutes, [isRebase, fail('CONFLICT')]]); + const git = makeStub([ + ...baseGitRoutes, + [isRebase, fail('CONFLICT')], + // The favoured replay stalls on a real code file, so the driver must not resolve it. + [isConflictedFiles, 'packages/cli/x.ts'], + ]); const gh = makeStub(noOpenPr); - await assert.rejects(() => sync({ git, gh, env, log: () => {} }), /needs a human/); - assert.equal(git.calls.some((a) => a[0] === 'push'), false); + await assert.rejects( + () => sync({ git, gh, pnpm: makeStub(), env, log: () => {} }), + /needs a human/, + ); + assert.equal( + git.calls.some((a) => a[0] === 'push'), + false, + ); assert.equal(git.calls.filter((a) => a[0] === 'rebase' && a[1] === '--abort').length, 2); }); -test('buildConflictBranch commits the conflicted state, markers and all', () => { - const git = makeStub([ - [(a) => a[0] === 'merge', fail('CONFLICT (content): packages/cli/x.ts')], - [(a) => a[0] === 'diff', 'packages/cli/x.ts'], - ]); - - const files = buildConflictBranch({ git, masterSha: MASTER, log: () => {} }); - - assert.deepEqual(files, ['packages/cli/x.ts']); - assert.deepEqual(git.calls[0], ['merge', '--no-edit', MASTER]); - assert.ok(git.calls.some((a) => a[0] === 'add' && a[1] === '-A')); - assert.ok(git.calls.some((a) => a[0] === 'commit' && a.includes('--no-edit'))); - // The markers ARE the review surface here, so nothing may auto-resolve them. - assert.equal(git.calls.some((a) => a.includes('-X')), false); - assert.equal(git.calls.some((a) => a[0] === 'merge' && a[1] === '--abort'), false); -}); - -test('buildConflictBranch refuses to guess when the merge unexpectedly succeeds', () => { - const git = makeStub([[(a) => a[0] === 'merge', '']]); - - assert.throws(() => buildConflictBranch({ git, masterSha: MASTER, log: () => {} }), /Expected a merge conflict/); - assert.equal(git.calls.some((a) => a[0] === 'commit'), false); -}); - -test('sync opens a draft conflict PR and leaves 3.x untouched on a real conflict', async () => { +test('sync auto-resolves a lockfile-only conflict during the replay — no PR, no commit', async () => { const git = makeStub([ ...baseGitRoutes.filter((r) => !r[0](['merge-tree'])), - [(a) => a[0] === 'merge-tree', fail('CONFLICT (content): packages/cli/x.ts')], - [(a) => a[0] === 'merge', fail('CONFLICT (content): packages/cli/x.ts')], - [(a) => a[0] === 'diff', 'packages/cli/x.ts'], + [(a) => a[0] === 'merge-tree', conflictedMergeTree(LOCKFILE)], + [(a) => a[0] === 'rebase' && a[1] === '--continue', ''], + [isRebase, fail(`CONFLICT (content): Merge conflict in ${LOCKFILE}`)], + [isConflictedFiles, LOCKFILE], + [(a) => a[0] === 'diff' && a.includes('--quiet'), ''], // tip lockfile already consistent + [(a) => a[0] === 'diff-index', fail()], // staged resolution -> continue, not skip + ]); + const gh = makeStub(noOpenPr); + const pnpm = makeStub(); + + await sync({ git, gh, pnpm, env, log: () => {} }); + + // The stall regen plus the tip reconciliation check. + assert.deepEqual(pnpm.calls[0], ['install', '--lockfile-only', '--no-frozen-lockfile']); + assert.equal(pnpm.calls.length, 2); + assert.ok(git.calls.some((a) => a[0] === 'add' && a.includes(LOCKFILE))); + assert.ok(git.calls.some((a) => a[0] === 'rebase' && a[1] === '--continue')); + const push = git.calls.find((a) => a[0] === 'push'); + assert.equal(push[1], `--force-with-lease=refs/heads/${TARGET_BRANCH}:${PRE_HEAD}`); + // No human surface: no merge commit, no amend, no conflict PR. + assert.equal( + git.calls.some((a) => a[0] === 'merge'), + false, + ); + assert.equal( + git.calls.some((a) => a[0] === 'commit'), + false, + ); + assert.equal( + gh.calls.some((a) => a[0] === 'pr' && a[1] === 'create'), + false, + ); +}); + +test('sync falls back to a conflict PR when mechanical auto-resolution cannot complete', async () => { + const git = makeStub([ + ...baseGitRoutes.filter((r) => !r[0](['merge-tree'])), + [(a) => a[0] === 'merge-tree', conflictedMergeTree(LOCKFILE)], + [isRebase, fail('CONFLICT')], + // Both replay attempts stall on a code file the endpoints do not reconcile. + [isConflictedFiles, 'packages/cli/x.ts'], + [(a) => a[0] === 'merge', fail('CONFLICT (content): Merge conflict in packages/cli/x.ts')], [(a) => a[0] === 'log', 'breaking-sha'], ]); const gh = makeStub([ @@ -234,7 +513,142 @@ test('sync opens a draft conflict PR and leaves 3.x untouched on a real conflict [(a) => a[0] === 'pr' && a[1] === 'create', 'https://github.com/n8n-io/n8n/pull/99'], ]); - await sync({ git, gh, env, fetchFn: okFetch(['alice']), log: () => {} }); + await sync({ git, gh, pnpm: makeStub(), env, fetchFn: okFetch(['alice']), log: () => {} }); + + assert.ok( + gh.calls.some((a) => a[0] === 'pr' && a[1] === 'create'), + 'expected the fallback conflict PR', + ); + // Only the sync branch is pushed — 3.x must not move after a failed auto-resolution. + const pushes = git.calls.filter((a) => a[0] === 'push'); + assert.equal(pushes.length, 1); + assert.equal(pushes[0].at(-1), `HEAD:refs/heads/${SYNC_BRANCH}`); +}); + +test('buildConflictBranch commits the conflicted state, markers and all', () => { + const git = makeStub([ + [(a) => a[0] === 'merge', fail('CONFLICT (content): packages/cli/x.ts')], + [isConflictedFiles, 'packages/cli/x.ts'], + ]); + + const { files, preResolved, lockfileDeferred } = buildConflictBranch({ + git, + pnpm: makeStub(), + masterSha: MASTER, + log: () => {}, + }); + + assert.deepEqual(files, ['packages/cli/x.ts']); + assert.deepEqual(preResolved, []); + assert.equal(lockfileDeferred, false); + assert.deepEqual(git.calls[0], ['merge', '--no-edit', MASTER]); + assert.ok(git.calls.some((a) => a[0] === 'add' && a[1] === '-A')); + assert.ok(git.calls.some((a) => a[0] === 'commit' && a.includes('--no-edit'))); + // The markers ARE the review surface here, so nothing may auto-resolve them. + assert.equal( + git.calls.some((a) => a.includes('-X')), + false, + ); + assert.equal( + git.calls.some((a) => a[0] === 'merge' && a[1] === '--abort'), + false, + ); +}); + +test('buildConflictBranch pre-resolves mechanical files so only code conflicts remain', () => { + const git = makeStub([ + [(a) => a[0] === 'merge', fail('CONFLICT')], + [isConflictedFiles, `packages/cli/x.ts\n${LOCKFILE}\n${POPULARITY}`], + [(a) => a[0] === 'cat-file', ''], + ]); + const pnpm = makeStub(); + + const { files, preResolved, lockfileDeferred } = buildConflictBranch({ + git, + pnpm, + masterSha: MASTER, + log: () => {}, + }); + + assert.deepEqual(files, ['packages/cli/x.ts']); + assert.deepEqual(preResolved, [LOCKFILE, POPULARITY]); + assert.equal(lockfileDeferred, false); + assert.deepEqual(pnpm.calls[0], ['install', '--lockfile-only', '--no-frozen-lockfile']); + assert.ok( + git.calls.some((a) => a[0] === 'checkout' && a[1] === MASTER && a.includes(POPULARITY)), + ); +}); + +test('buildConflictBranch defers the lockfile when a manifest is conflicted too', () => { + const git = makeStub([ + [(a) => a[0] === 'merge', fail('CONFLICT')], + [isConflictedFiles, `packages/cli/package.json\n${LOCKFILE}`], + ]); + const pnpm = makeStub(); + + const { files, preResolved, lockfileDeferred } = buildConflictBranch({ + git, + pnpm, + masterSha: MASTER, + log: () => {}, + }); + + assert.deepEqual(files, ['packages/cli/package.json']); + assert.deepEqual(preResolved, []); + assert.equal(lockfileDeferred, true); + assert.equal(pnpm.calls.length, 0, 'regen is meaningless until the manifests are resolved'); +}); + +test('buildConflictBranch degrades to a deferred lockfile when the regen fails', () => { + const git = makeStub([ + [(a) => a[0] === 'merge', fail('CONFLICT')], + [isConflictedFiles, `packages/cli/x.ts\n${LOCKFILE}`], + ]); + const pnpm = makeStub([[() => true, fail('ERR_PNPM_REGISTRY unreachable')]]); + + const { files, preResolved, lockfileDeferred } = buildConflictBranch({ + git, + pnpm, + masterSha: MASTER, + log: () => {}, + }); + + assert.deepEqual(files, ['packages/cli/x.ts']); + assert.deepEqual(preResolved, []); + assert.equal(lockfileDeferred, true); + assert.ok( + git.calls.some((a) => a[0] === 'commit'), + 'the conflict branch must still be committed', + ); +}); + +test('buildConflictBranch refuses to guess when the merge unexpectedly succeeds', () => { + const git = makeStub([[(a) => a[0] === 'merge', '']]); + + assert.throws( + () => buildConflictBranch({ git, pnpm: makeStub(), masterSha: MASTER, log: () => {} }), + /Expected a merge conflict/, + ); + assert.equal( + git.calls.some((a) => a[0] === 'commit'), + false, + ); +}); + +test('sync opens a draft conflict PR and leaves 3.x untouched on a real conflict', async () => { + const git = makeStub([ + ...baseGitRoutes.filter((r) => !r[0](['merge-tree'])), + [(a) => a[0] === 'merge-tree', conflictedMergeTree('packages/cli/x.ts')], + [(a) => a[0] === 'merge', fail('CONFLICT (content): packages/cli/x.ts')], + [isConflictedFiles, 'packages/cli/x.ts'], + [(a) => a[0] === 'log', 'breaking-sha'], + ]); + const gh = makeStub([ + ...noOpenPr, + [(a) => a[0] === 'pr' && a[1] === 'create', 'https://github.com/n8n-io/n8n/pull/99'], + ]); + + await sync({ git, gh, pnpm: makeStub(), env, fetchFn: okFetch(['alice']), log: () => {} }); const create = gh.calls.find((a) => a[0] === 'pr' && a[1] === 'create'); assert.ok(create.includes('--draft')); @@ -253,12 +667,52 @@ test('sync opens a draft conflict PR and leaves 3.x untouched on a real conflict const pushes = git.calls.filter((a) => a[0] === 'push'); assert.equal(pushes.length, 1); assert.equal(pushes[0].at(-1), `HEAD:refs/heads/${SYNC_BRANCH}`); - assert.equal(git.calls.some((a) => a[0] === 'rebase'), false, 'no replay attempt while a conflict is unresolved'); + assert.equal( + git.calls.some((a) => a[0] === 'rebase'), + false, + 'no replay attempt while a code conflict is unresolved', + ); +}); + +test('sync reports only the code conflicts on a mixed conflict, with mechanical files pre-resolved', async () => { + const git = makeStub([ + ...baseGitRoutes.filter((r) => !r[0](['merge-tree'])), + [(a) => a[0] === 'merge-tree', conflictedMergeTree(LOCKFILE, 'packages/cli/x.ts')], + [(a) => a[0] === 'merge', fail('CONFLICT')], + [isConflictedFiles, `packages/cli/x.ts\n${LOCKFILE}`], + [(a) => a[0] === 'log' && a.includes('--format=%H'), 'breaking-sha'], + ]); + const gh = makeStub([ + ...noOpenPr, + [(a) => a[0] === 'pr' && a[1] === 'create', 'https://github.com/n8n-io/n8n/pull/99'], + ]); + const pnpm = makeStub(); + + await sync({ git, gh, pnpm, env, fetchFn: okFetch(['alice']), log: () => {} }); + + assert.equal(pnpm.calls.length, 1, 'the lockfile is regenerated for the conflict branch'); + + const create = gh.calls.find((a) => a[0] === 'pr' && a[1] === 'create'); + const body = create[create.indexOf('--body') + 1]; + assert.match(body, /### Conflicted files\n- `packages\/cli\/x\.ts`/); + assert.match(body, /### Auto-resolved for you/); + assert.ok( + body.indexOf(LOCKFILE) > body.indexOf('Auto-resolved'), + 'the lockfile belongs to the auto-resolved section', + ); + + // Owner attribution is scoped to the real code conflicts only. + const attributions = git.calls.filter((a) => a[0] === 'log' && a.includes('--format=%H')); + assert.equal(attributions.length, 1); + assert.equal(attributions[0].at(-1), 'packages/cli/x.ts'); }); test('openConflictPr degrades gracefully when owner resolution fails', async () => { const git = makeStub([[(a) => a[0] === 'log', 'sha1']]); - const gh = makeStub([[(a) => a[0] === 'pr' && a[1] === 'create', 'https://github.com/n8n-io/n8n/pull/1']]); + const gh = makeStub([ + [(a) => a[0] === 'pr' && a[1] === 'list', '[]'], + [(a) => a[0] === 'pr' && a[1] === 'create', 'https://github.com/n8n-io/n8n/pull/1'], + ]); const failingFetch = async () => ({ ok: false, status: 500, json: async () => ({}) }); const { prUrl, ownersSlack } = await openConflictPr({ @@ -277,5 +731,41 @@ test('openConflictPr degrades gracefully when owner resolution fails', async () assert.equal(prUrl, 'https://github.com/n8n-io/n8n/pull/1'); assert.equal(ownersSlack, 'Could not auto-attribute owners.'); // No reviewer request when there are no owners. - assert.equal(gh.calls.some((a) => a[0] === 'pr' && a[1] === 'edit'), false); + assert.equal( + gh.calls.some((a) => a[0] === 'pr' && a[1] === 'edit'), + false, + ); +}); + +test('openConflictPr calls out a recently abandoned conflict PR', async () => { + const git = makeStub([[(a) => a[0] === 'log', 'sha1']]); + const closedAt = new Date(Date.now() - 2 * 86_400_000).toISOString(); + const gh = makeStub([ + [ + (a) => a[0] === 'pr' && a[1] === 'list' && a.includes('closed'), + JSON.stringify([ + { number: 42, url: 'https://github.com/n8n-io/n8n/pull/42', mergedAt: null, closedAt }, + ]), + ], + [(a) => a[0] === 'pr' && a[1] === 'create', 'https://github.com/n8n-io/n8n/pull/43'], + ]); + + const { ownersSlack } = await openConflictPr({ + git, + gh, + repo: 'n8n-io/n8n', + token: 't', + masterSha: MASTER, + preHead: PRE_HEAD, + pushUrl: 'https://push', + files: ['x.ts'], + fetchFn: okFetch(['alice']), + log: () => {}, + }); + + const create = gh.calls.find((a) => a[0] === 'pr' && a[1] === 'create'); + const body = create[create.indexOf('--body') + 1]; + assert.match(body, /#42\) was closed without being merged/); + assert.match(body, /Merge, don't close/); + assert.match(ownersSlack, //); }); diff --git a/.github/workflows/util-sync-master-to-3x.yml b/.github/workflows/util-sync-master-to-3x.yml index 2efc4cd60da..9f8c1f14e0f 100644 --- a/.github/workflows/util-sync-master-to-3x.yml +++ b/.github/workflows/util-sync-master-to-3x.yml @@ -7,12 +7,15 @@ # authors preserved). The content pushed is always verified to be exactly what a merge of # 3.x and master would produce. # -# When master genuinely conflicts with 3.x, 3.x is left untouched: a draft conflict PR -# carrying the conflict markers is opened on the sync branch and #alerts-v3-sync is notified. -# The resolver fixes the markers in a commit of their own and merges that PR with the normal -# merge button. No further syncs run until it is merged. 3.x itself never has markers at its -# tip (nightly images build from it) and the merge commit holding them drops out of its -# history at the next replay. +# Conflicts confined to mechanical, tool-generated files (the pnpm lockfile, bot-maintained +# data files) are resolved in place during the replay — no PR, no commit, no human. When +# master genuinely conflicts with 3.x on real code, 3.x is left untouched: a draft conflict +# PR carrying the conflict markers (mechanical files pre-resolved) is opened on the sync +# branch and #alerts-v3-sync is notified. The resolver fixes the markers in a commit of their +# own and merges that PR with the normal merge button — never closes it, since closing +# resolves nothing and the conflict returns. No further syncs run until it is merged. 3.x +# itself never has markers at its tip (nightly images build from it) and the merge commit +# holding them drops out of its history at the next replay. # # See .github/DEVELOPING_V3.md for the full v3 development model. @@ -61,6 +64,14 @@ jobs: fetch-depth: 0 persist-credentials: false # we push with an explicit token URL instead + # Node + pnpm toolchain for the mechanical lockfile resolution + # (`pnpm install --lockfile-only`). No dependency install, no build. + - name: Setup Node.js + uses: ./.github/actions/setup-nodejs + with: + install-command: '' + build-command: '' + - name: Sync master into 3.x id: sync env: From 21b52fbddf6acc639b872fd1a9109e4275dcc3b9 Mon Sep 17 00:00:00 2001 From: Yen Su <49794855+yens1@users.noreply.github.com> Date: Mon, 10 Aug 2026 01:41:24 -0700 Subject: [PATCH 010/151] feat(Kafka Node): Add compression options to version 2 (#35727) Co-authored-by: Claude Opus 5 (1M context) --- .../nodes/Kafka/test/v2/KafkaV2.node.test.ts | 34 ++++++++++++++++--- .../nodes-base/nodes/Kafka/v2/KafkaV2.node.ts | 26 +++++++++++--- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/packages/nodes-base/nodes/Kafka/test/v2/KafkaV2.node.test.ts b/packages/nodes-base/nodes/Kafka/test/v2/KafkaV2.node.test.ts index 881937bb904..f3f65c806ba 100644 --- a/packages/nodes-base/nodes/Kafka/test/v2/KafkaV2.node.test.ts +++ b/packages/nodes-base/nodes/Kafka/test/v2/KafkaV2.node.test.ts @@ -182,7 +182,7 @@ describe('KafkaV2 Node', () => { test('publishes input data as messages with key and headers unchanged, acks/timeout on the producer config', async () => { const params: IDataObject = { - options: { acks: true, timeout: 1000 }, + options: { acks: true, compression: 'none', timeout: 1000 }, sendInputData: true, useSchemaRegistry: false, topic: 'test-topic', @@ -199,7 +199,7 @@ describe('KafkaV2 Node', () => { await new KafkaV2(baseDescription).execute.call(createExecuteFunctions(params, items)); expect(mockProducerFactory).toHaveBeenCalledWith({ - kafkaJS: { acks: -1, timeout: 1000, allowAutoTopicCreation: true }, + kafkaJS: { acks: -1, timeout: 1000, allowAutoTopicCreation: true, compression: 'none' }, }); expect(mockProducerConnect).toHaveBeenCalledTimes(1); expect(mockProducerSendBatch).toHaveBeenCalledTimes(1); @@ -230,7 +230,7 @@ describe('KafkaV2 Node', () => { expect(mockProducerDisconnect).toHaveBeenCalledTimes(1); }); - test('maps acks off to 0 and falls back to the default timeout when the option is unset', async () => { + test('maps acks off to 0 and falls back to the default timeout and compression when the options are unset', async () => { const params: IDataObject = { options: {}, sendInputData: false, @@ -246,7 +246,7 @@ describe('KafkaV2 Node', () => { await new KafkaV2(baseDescription).execute.call(createExecuteFunctions(params, items)); expect(mockProducerFactory).toHaveBeenCalledWith({ - kafkaJS: { acks: 0, timeout: 30000, allowAutoTopicCreation: true }, + kafkaJS: { acks: 0, timeout: 30000, allowAutoTopicCreation: true, compression: 'none' }, }); expect(mockProducerSendBatch).toHaveBeenCalledWith({ topicMessages: [ @@ -258,6 +258,32 @@ describe('KafkaV2 Node', () => { }); }); + // 'none' included: it must reach the config as an explicit codec, since the + // native library crashes on an undefined value. + test.each(['none', 'gzip', 'snappy', 'lz4', 'zstd'])( + 'passes the %s compression codec to the producer', + async (compression) => { + const params: IDataObject = { + options: { compression }, + sendInputData: false, + useSchemaRegistry: false, + topic: 'test-topic', + jsonParameters: false, + useKey: false, + message: 'plain message', + headersUi: {}, + }; + + await new KafkaV2(baseDescription).execute.call( + createExecuteFunctions(params, [{ json: {} }]), + ); + + expect(mockProducerFactory).toHaveBeenCalledWith({ + kafkaJS: { acks: 0, timeout: 30000, allowAutoTopicCreation: true, compression }, + }); + }, + ); + test('reads the topic and key per item', async () => { const params: NodeParams = { options: {}, diff --git a/packages/nodes-base/nodes/Kafka/v2/KafkaV2.node.ts b/packages/nodes-base/nodes/Kafka/v2/KafkaV2.node.ts index bd218c32563..8671b9895ff 100644 --- a/packages/nodes-base/nodes/Kafka/v2/KafkaV2.node.ts +++ b/packages/nodes-base/nodes/Kafka/v2/KafkaV2.node.ts @@ -36,10 +36,12 @@ function toProducerOptions(options: IDataObject): KafkaProducerOptions { // `true` to 1 (leader only) — a bug not worth carrying into a new version. acks: options.acks === true ? -1 : 0, // Unlike v1 (kafkajs tolerates `undefined`), confluent's native library - // crashes if `timeout` reaches the producer config as `undefined` — which - // it would be here if the user never added the option, since a `collection` - // param only carries keys the user explicitly set, ignoring its declared - // UI default. Fall back to that same default explicitly. + // crashes if either of these reaches the producer config as `undefined` — + // which they would be if the user never added the option, since a + // `collection` param only carries the keys the user explicitly set, + // ignoring its declared UI default. Fall back to those defaults here. + // 'none' is a codec of its own, so it reaches the config explicitly. + compression: (options.compression ?? 'none') as KafkaJS.CompressionTypes, timeout: (options.timeout as number | undefined) ?? DEFAULT_TIMEOUT_MS, }; } @@ -280,6 +282,22 @@ const versionDescription: INodeTypeDescription = { default: false, description: 'Whether or not producer must wait for acknowledgement from all replicas', }, + { + displayName: 'Compression', + name: 'compression', + type: 'options', + default: 'none', + description: + 'Codec used to compress messages. Version 1 of the Kafka Trigger cannot read Snappy, LZ4 or Zstd — use GZIP or None while version 1 triggers consume this topic.', + // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items -- 'None' (no compression) reads better last than between LZ4 and Snappy + options: [ + { name: 'GZIP', value: 'gzip' }, + { name: 'LZ4', value: 'lz4' }, + { name: 'Snappy', value: 'snappy' }, + { name: 'Zstd', value: 'zstd' }, + { name: 'None', value: 'none' }, + ], + }, { displayName: 'Timeout', name: 'timeout', From 34ca40bde06734fcf3450f47ec45b994ca66be4a Mon Sep 17 00:00:00 2001 From: Predrag Ristic Date: Mon, 10 Aug 2026 10:56:39 +0200 Subject: [PATCH 011/151] fix(Execute Workflow Node): Prevent invalid input mappings (#35767) --- .../src/workflow-builder-plugins.test.ts | 79 +++++++ .../workflow-builder/plugins/defaults.test.ts | 1 + .../src/workflow-builder/plugins/defaults.ts | 2 + .../execute-workflow-validator.test.ts | 220 ++++++++++++++++++ .../validators/execute-workflow-validator.ts | 83 +++++++ .../plugins/validators/index.ts | 1 + .../ExecuteWorkflow.node.test.ts | 26 +++ .../ExecuteWorkflow/ExecuteWorkflow.node.ts | 49 ++++ 8 files changed, 461 insertions(+) create mode 100644 packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/execute-workflow-validator.test.ts create mode 100644 packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/execute-workflow-validator.ts diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder-plugins.test.ts b/packages/@n8n/workflow-sdk/src/workflow-builder-plugins.test.ts index ec50b195eb1..a69c47e5979 100644 --- a/packages/@n8n/workflow-sdk/src/workflow-builder-plugins.test.ts +++ b/packages/@n8n/workflow-sdk/src/workflow-builder-plugins.test.ts @@ -6,6 +6,7 @@ * composite handling, and serialization. */ +import { expr } from './expression'; import type { NodeInstance, WorkflowJSON, IfElseComposite, GraphNode } from './types/base'; import { workflow } from './workflow-builder'; import { splitInBatches } from './workflow-builder/control-flow-builders/split-in-batches'; @@ -51,6 +52,84 @@ describe('WorkflowBuilder plugin integration', () => { }); describe('validate() with plugins', () => { + it('blocks invalid Execute Workflow input mappings through the default registry', () => { + registerDefaultPlugins(testRegistry); + + const createWorkflow = (workflowInputs?: unknown) => { + const executeWorkflow = node({ + type: 'n8n-nodes-base.executeWorkflow', + version: 1.3, + config: { + name: 'Process Order', + parameters: { + source: 'database', + workflowId: { __rl: true, mode: 'id', value: 'process-order' }, + mode: 'each', + ...(workflowInputs === undefined ? {} : { workflowInputs }), + }, + }, + }); + + return workflow('orders', 'Orders', { registry: testRegistry }).add( + trigger({ + type: 'n8n-nodes-base.manualTrigger', + version: 1, + config: { name: 'Start' }, + }).to(executeWorkflow), + ); + }; + + const invalidResult = createWorkflow({ + mappingMode: 'defineBelow', + value: null, + }).validate(); + expect(invalidResult.valid).toBe(false); + expect(invalidResult.errors).toContainEqual( + expect.objectContaining({ code: 'EXECUTE_WORKFLOW_INVALID_INPUT_MAPPING' }), + ); + + const passThroughResult = createWorkflow().validate(); + expect(passThroughResult.valid).toBe(true); + expect(passThroughResult.errors).not.toContainEqual( + expect.objectContaining({ code: 'EXECUTE_WORKFLOW_INVALID_INPUT_MAPPING' }), + ); + + const mappedResult = createWorkflow({ + mappingMode: 'defineBelow', + value: { + orderId: expr('{{ $json.id }}'), + amount: expr('{{ $json.total }}'), + }, + matchingColumns: [], + schema: [ + { + id: 'orderId', + displayName: 'orderId', + required: false, + defaultMatch: false, + display: true, + canBeUsedToMatch: true, + type: 'string', + }, + { + id: 'amount', + displayName: 'amount', + required: false, + defaultMatch: false, + display: true, + canBeUsedToMatch: true, + type: 'number', + }, + ], + attemptToConvertTypes: false, + convertFieldsToString: true, + }).validate(); + expect(mappedResult.valid).toBe(true); + expect(mappedResult.errors).not.toContainEqual( + expect.objectContaining({ code: 'EXECUTE_WORKFLOW_INVALID_INPUT_MAPPING' }), + ); + }); + it('runs registered validators for matching node types', () => { const mockValidateNode = vi.fn().mockReturnValue([]); const mockValidator = createMockValidator( diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/defaults.test.ts b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/defaults.test.ts index e1d285245a8..ce7a1e09eb2 100644 --- a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/defaults.test.ts +++ b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/defaults.test.ts @@ -20,6 +20,7 @@ describe('Default Plugins', () => { expect(validatorIds).toContain('core:disconnected-node'); expect(validatorIds).toContain('core:agent'); expect(validatorIds).toContain('core:http-request'); + expect(validatorIds).toContain('core:execute-workflow'); expect(validatorIds).toContain('core:memory-session-key'); expect(validatorIds).toContain('core:unknown-config-keys'); }); diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/defaults.ts b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/defaults.ts index e0457da7307..25f2cd07bf1 100644 --- a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/defaults.ts +++ b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/defaults.ts @@ -16,6 +16,7 @@ import { disconnectedNodeValidator, expressionPathValidator, expressionPrefixValidator, + executeWorkflowValidator, filterNodeValidator, fromAiValidator, httpRequestValidator, @@ -60,6 +61,7 @@ const coreValidators: ValidatorPlugin[] = [ setNodeValidator, mergeNodeValidator, filterNodeValidator, + executeWorkflowValidator, // Expression validators (lower priority) expressionPrefixValidator, diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/execute-workflow-validator.test.ts b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/execute-workflow-validator.test.ts new file mode 100644 index 00000000000..cb2b3430828 --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/execute-workflow-validator.test.ts @@ -0,0 +1,220 @@ +import type { GraphNode, NodeInstance } from '../../../types/base'; +import type { PluginContext } from '../types'; +import { executeWorkflowValidator } from './execute-workflow-validator'; + +function createMockNode( + parameters: unknown, + version = '1.2', +): NodeInstance { + return { + type: 'n8n-nodes-base.executeWorkflow', + name: 'Run Sub-workflow', + version, + config: { parameters }, + } as NodeInstance; +} + +function validate(parameters: unknown, version = '1.2') { + const node = createMockNode(parameters, version); + const graphNode: GraphNode = { instance: node, connections: new Map() }; + const ctx: PluginContext = { + nodes: new Map([[node.name, graphNode]]), + workflowId: 'test-workflow', + workflowName: 'Test Workflow', + settings: {}, + }; + + return executeWorkflowValidator.validateNode(node, graphNode, ctx); +} + +describe('executeWorkflowValidator', () => { + describe('metadata', () => { + it('targets Execute Workflow nodes at priority 40', () => { + expect(executeWorkflowValidator).toEqual( + expect.objectContaining({ + id: 'core:execute-workflow', + name: 'Execute Workflow Validator', + nodeTypes: ['n8n-nodes-base.executeWorkflow'], + priority: 40, + }), + ); + }); + }); + + describe('defineBelow mappings', () => { + it.each([ + ['null', null], + ['an array', []], + ['a string', 'order'], + ['a number', 1], + ['a boolean', true], + ])('rejects value when it is %s', (_description, value) => { + const issues = validate({ + source: 'database', + workflowInputs: { mappingMode: 'defineBelow', value }, + }); + + expect(issues).toHaveLength(1); + expect(issues[0]).toEqual( + expect.objectContaining({ + code: 'EXECUTE_WORKFLOW_INVALID_INPUT_MAPPING', + severity: 'error', + violationLevel: 'major', + nodeName: 'Run Sub-workflow', + parameterPath: 'parameters.workflowInputs.value', + }), + ); + }); + + it.each([ + ['null', null], + ['an array', []], + ['a string', 'inputs'], + ])('rejects workflowInputs when it is %s', (_description, workflowInputs) => { + const issues = validate({ source: 'database', workflowInputs }); + + expect(issues).toHaveLength(1); + expect(issues[0]?.code).toBe('EXECUTE_WORKFLOW_INVALID_INPUT_MAPPING'); + }); + + it('allows workflowInputs to be omitted for sub-workflows that accept all data', () => { + const issues = validate({ source: 'database' }); + + expect(issues).toHaveLength(0); + }); + + it.each([ + ['workflowInputs.value', { mappingMode: 'defineBelow' }], + ['mappingMode and value', {}], + ])('allows workflowInputs with omitted %s', (_description, workflowInputs) => { + const issues = validate({ source: 'database', workflowInputs }); + + expect(issues).toHaveLength(0); + }); + + it('treats an omitted mappingMode as defineBelow', () => { + const issues = validate({ + source: 'database', + workflowInputs: { value: null }, + }); + + expect(issues).toHaveLength(1); + expect(issues[0]?.code).toBe('EXECUTE_WORKFLOW_INVALID_INPUT_MAPPING'); + }); + + it('includes the failing path and a complete valid mapping example', () => { + const issues = validate({ + source: 'database', + workflowInputs: { mappingMode: 'defineBelow', value: null }, + }); + + expect(issues[0]?.message).toContain('parameters.workflowInputs.value'); + expect(issues[0]?.message).toContain('accepts all data'); + expect(issues[0]?.message).toContain('omit parameters.workflowInputs'); + expect(issues[0]?.message).toContain("orderId: expr('{{ $json.id }}')"); + expect(issues[0]?.message).toContain("amount: expr('{{ $json.total }}')"); + expect(issues[0]?.message).toContain("id: 'orderId'"); + expect(issues[0]?.message).toContain("type: 'string'"); + expect(issues[0]?.message).toContain("id: 'amount'"); + expect(issues[0]?.message).toContain("type: 'number'"); + expect(issues[0]?.message).toContain('matchingColumns: []'); + }); + + it('allows a populated value object', () => { + const issues = validate({ + source: 'database', + workflowInputs: { + mappingMode: 'defineBelow', + value: { + orderId: '={{ $json.id }}', + amount: '={{ $json.total }}', + }, + matchingColumns: [], + schema: [ + { + id: 'orderId', + displayName: 'orderId', + required: false, + defaultMatch: false, + display: true, + canBeUsedToMatch: true, + type: 'string', + }, + { + id: 'amount', + displayName: 'amount', + required: false, + defaultMatch: false, + display: true, + canBeUsedToMatch: true, + type: 'number', + }, + ], + attemptToConvertTypes: false, + convertFieldsToString: true, + }, + }); + + expect(issues).toHaveLength(0); + }); + + it('allows an empty value object for sub-workflows with no declared inputs', () => { + const issues = validate({ + source: 'database', + workflowInputs: { mappingMode: 'defineBelow', value: {} }, + }); + + expect(issues).toHaveLength(0); + }); + + it('allows an omitted mappingMode when value is an object', () => { + const issues = validate({ + source: 'database', + workflowInputs: { value: { order: '={{ $json }}' } }, + }); + + expect(issues).toHaveLength(0); + }); + }); + + describe('applicability', () => { + it('treats an omitted source as database', () => { + const issues = validate({ + workflowInputs: { mappingMode: 'defineBelow', value: null }, + }); + + expect(issues).toHaveLength(1); + expect(issues[0]?.code).toBe('EXECUTE_WORKFLOW_INVALID_INPUT_MAPPING'); + }); + + it.each(['1', '1.1'])('skips legacy node version %s', (version) => { + const issues = validate( + { + source: 'database', + workflowInputs: { mappingMode: 'defineBelow', value: null }, + }, + version, + ); + + expect(issues).toHaveLength(0); + }); + + it('skips non-database sources', () => { + const issues = validate({ + source: 'parameter', + workflowInputs: { mappingMode: 'defineBelow', value: null }, + }); + + expect(issues).toHaveLength(0); + }); + + it('skips mapping modes other than defineBelow', () => { + const issues = validate({ + source: 'database', + workflowInputs: { mappingMode: 'autoMapInputData', value: null }, + }); + + expect(issues).toHaveLength(0); + }); + }); +}); diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/execute-workflow-validator.ts b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/execute-workflow-validator.ts new file mode 100644 index 00000000000..6fe5b6aac86 --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/execute-workflow-validator.ts @@ -0,0 +1,83 @@ +/** + * Execute Workflow Node Validator Plugin + * + * Validates that database-backed Execute Workflow nodes provide executable + * input mappings instead of persisting the Resource Mapper's temporary null value. + */ + +import { isRecord } from '@n8n/utils/is-record'; + +import type { GraphNode, NodeInstance } from '../../../types/base'; +import { parseVersion } from '../../string-utils'; +import { + type PluginContext, + type ValidationIssue, + type ValidatorPlugin, + findMapKey, + formatNodeRef, + isAutoRenamed, +} from '../types'; + +const MIN_WORKFLOW_INPUTS_VERSION = 1.2; +const DATABASE_SOURCE = 'database'; +const DEFINE_BELOW_MODE = 'defineBelow'; +const WORKFLOW_INPUTS_VALUE_PATH = 'parameters.workflowInputs.value'; +const VALID_MAPPING_EXAMPLE = + "{ mappingMode: 'defineBelow', value: { orderId: expr('{{ $json.id }}'), amount: expr('{{ $json.total }}') }, matchingColumns: [], schema: [{ id: 'orderId', displayName: 'orderId', required: false, defaultMatch: false, display: true, canBeUsedToMatch: true, type: 'string' }, { id: 'amount', displayName: 'amount', required: false, defaultMatch: false, display: true, canBeUsedToMatch: true, type: 'number' }], attemptToConvertTypes: false, convertFieldsToString: true }"; + +export const executeWorkflowValidator: ValidatorPlugin = { + id: 'core:execute-workflow', + name: 'Execute Workflow Validator', + nodeTypes: ['n8n-nodes-base.executeWorkflow'], + priority: 40, + + validateNode( + node: NodeInstance, + graphNode: GraphNode, + ctx: PluginContext, + ): ValidationIssue[] { + if (parseVersion(node.version) < MIN_WORKFLOW_INPUTS_VERSION) { + return []; + } + + const parameters = isRecord(node.config?.parameters) ? node.config.parameters : {}; + const source = parameters.source ?? DATABASE_SOURCE; + + if (source !== DATABASE_SOURCE) { + return []; + } + + const workflowInputs = parameters.workflowInputs; + if (workflowInputs === undefined) { + return []; + } + + if (isRecord(workflowInputs)) { + const mappingMode = workflowInputs.mappingMode ?? DEFINE_BELOW_MODE; + const value = workflowInputs.value; + if (mappingMode !== DEFINE_BELOW_MODE || value === undefined || isRecord(value)) return []; + } + + const mapKey = findMapKey(graphNode, ctx); + const originalName = node.name; + const renamed = isAutoRenamed(mapKey, originalName); + const displayName = renamed ? mapKey : originalName; + const origForWarning = renamed ? originalName : undefined; + const nodeRef = formatNodeRef(displayName, origForWarning, node.type); + + return [ + { + code: 'EXECUTE_WORKFLOW_INVALID_INPUT_MAPPING', + message: + `${nodeRef} has an invalid ${WORKFLOW_INPUTS_VALUE_PATH}. ` + + `When parameters.workflowInputs.mappingMode is '${DEFINE_BELOW_MODE}', an explicitly provided ${WORKFLOW_INPUTS_VALUE_PATH} must be an object. ` + + `If the selected sub-workflow accepts all data, omit parameters.workflowInputs. Otherwise set parameters.workflowInputs to a full Resource Mapper mapping whose value and schema match the declared inputs, such as ${VALID_MAPPING_EXAMPLE}.`, + severity: 'error', + violationLevel: 'major', + nodeName: displayName, + parameterPath: WORKFLOW_INPUTS_VALUE_PATH, + originalName: origForWarning, + }, + ]; + }, +}; diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/index.ts b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/index.ts index 1d1917c4a1c..9f4d52345b1 100644 --- a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/index.ts +++ b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/index.ts @@ -11,6 +11,7 @@ export { disconnectedNodeValidator } from './disconnected-node-validator'; export { expressionPathValidator } from './expression-path-validator'; export { subnodeConnectionValidator } from './subnode-connection-validator'; export { expressionPrefixValidator } from './expression-prefix-validator'; +export { executeWorkflowValidator } from './execute-workflow-validator'; export { filterNodeValidator } from './filter-node-validator'; export { fromAiValidator } from './from-ai-validator'; export { httpRequestValidator } from './http-request-validator'; diff --git a/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.test.ts b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.test.ts index 2a6b01f9d80..ef47c94a53b 100644 --- a/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.test.ts +++ b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.test.ts @@ -29,6 +29,32 @@ describe('ExecuteWorkflow', () => { } as unknown as IWorkflowDataProxyData); }); + test('should document pass-through and declared workflow input mappings for the builder', () => { + const workflowInputs = executeWorkflow.description.properties.find( + (property) => property.name === 'workflowInputs', + ); + const propertyHint = workflowInputs?.builderHint?.propertyHint; + const mappingPatterns = executeWorkflow.description.builderHint?.extraTypeDefContent + ?.map(({ content }) => content) + .join('\n'); + + expect(propertyHint).toContain('temporary UI initialization state'); + expect(propertyHint).toContain('must never be emitted'); + expect(propertyHint).toContain("trigger is set to 'Accept all data'"); + expect(propertyHint).toContain('Omit workflowInputs'); + expect(propertyHint).toContain('value and schema fields exactly match'); + expect(mappingPatterns).toContain('Omit workflowInputs from parameters'); + expect(mappingPatterns).toContain("orderId: expr('{{ $json.id }}')"); + expect(mappingPatterns).toContain("amount: expr('{{ $json.total }}')"); + expect(mappingPatterns).toContain("id: 'orderId'"); + expect(mappingPatterns).toContain("type: 'string'"); + expect(mappingPatterns).toContain("id: 'amount'"); + expect(mappingPatterns).toContain("type: 'number'"); + expect(mappingPatterns).toContain('matchingColumns: []'); + expect(mappingPatterns).toContain('attemptToConvertTypes: false'); + expect(mappingPatterns).toContain('convertFieldsToString: true'); + }); + test('should execute workflow in "each" mode and wait for sub-workflow completion', async () => { executeFunctions.getNodeParameter .mockReturnValueOnce('database') // source diff --git a/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.ts b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.ts index 3a7ef7ea1cd..36d32735473 100644 --- a/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.ts +++ b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.ts @@ -28,6 +28,51 @@ export class ExecuteWorkflow implements INodeType { }, inputs: [NodeConnectionTypes.Main], outputs: [NodeConnectionTypes.Main], + builderHint: { + extraTypeDefContent: [ + { + displayOptions: { show: { mode: ['once', 'each'] } }, + content: ` +These workflowInputs patterns apply to Execute Workflow node versions 1.2 and newer. + +Omit workflowInputs from parameters. + + +workflowInputs: { + mappingMode: 'defineBelow', + value: { + orderId: expr('{{ $json.id }}'), + amount: expr('{{ $json.total }}'), + }, + matchingColumns: [], + schema: [ + { + id: 'orderId', + displayName: 'orderId', + required: false, + defaultMatch: false, + display: true, + canBeUsedToMatch: true, + type: 'string', + }, + { + id: 'amount', + displayName: 'amount', + required: false, + defaultMatch: false, + display: true, + canBeUsedToMatch: true, + type: 'number', + }, + ], + attemptToConvertTypes: false, + convertFieldsToString: true, +} + +`, + }, + ], + }, properties: [ { displayName: 'Operation', @@ -218,6 +263,10 @@ export class ExecuteWorkflow implements INodeType { value: null, }, required: true, + builderHint: { + propertyHint: + "The default { mappingMode: 'defineBelow', value: null } is only a temporary UI initialization state and must never be emitted in a workflow. Omit workflowInputs when the selected sub-workflow's trigger is set to 'Accept all data'. When the trigger declares inputs, pass the full Resource Mapper object and make the value and schema fields exactly match the declared input names and types.", + }, typeOptions: { loadOptionsDependsOn: ['workflowId.value'], resourceMapper: { From 323ec1461cd3447edee8f416106c911d395beb1c Mon Sep 17 00:00:00 2001 From: Declan Carroll Date: Mon, 10 Aug 2026 10:34:39 +0100 Subject: [PATCH 012/151] feat: Graduate Codespaces agent-session config with POC fixes and session script (no-changelog) (#35879) Co-authored-by: Claude Fable 5 --- .devcontainer/codespaces/Dockerfile | 2 + .devcontainer/codespaces/README.md | 99 +++++++++++++ .../codespaces/codespaces-secrets.sh | 8 ++ .devcontainer/codespaces/devcontainer.json | 7 +- .github/workflows/ci-pull-requests.yml | 4 + package.json | 1 + scripts/cloud-session.mjs | 134 ++++++++++++++++++ 7 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 .devcontainer/codespaces/README.md create mode 100644 .devcontainer/codespaces/codespaces-secrets.sh create mode 100644 scripts/cloud-session.mjs diff --git a/.devcontainer/codespaces/Dockerfile b/.devcontainer/codespaces/Dockerfile index 524f8ffcf45..d578997d2e0 100644 --- a/.devcontainer/codespaces/Dockerfile +++ b/.devcontainer/codespaces/Dockerfile @@ -16,6 +16,8 @@ RUN npx -y playwright@${PLAYWRIGHT_VERSION} install-deps chromium ENV DISABLE_AUTOUPDATER=1 RUN npm install -g @anthropic-ai/claude-code opencode-ai +COPY codespaces-secrets.sh /etc/profile.d/codespaces-secrets.sh + RUN echo node ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/node && chmod 0440 /etc/sudoers.d/node RUN mkdir -p /workspaces && chown node:node /workspaces RUN corepack enable diff --git a/.devcontainer/codespaces/README.md b/.devcontainer/codespaces/README.md new file mode 100644 index 00000000000..9329aa86ae2 --- /dev/null +++ b/.devcontainer/codespaces/README.md @@ -0,0 +1,99 @@ +# Cloud agent sessions (Codespaces) + +Run long-lived, human-steered agent sessions (Claude Code / OpenCode) on a +GitHub Codespace instead of your laptop: start a task, close the lid, steer it +from anywhere with a terminal, resume tomorrow. + +This is a separate devcontainer config from the laptop one in +`.devcontainer/` — it ships both agent CLIs, `tmux` for session persistence, +and Playwright system deps, on the same Postgres sidecar setup. + +## One-time setup (~5 min) + +1. Add your key at [github.com/settings/codespaces](https://github.com/settings/codespaces) + → **New secret** → name `ANTHROPIC_API_KEY`, repository access `n8n-io/n8n`. + (Alternative for Max subscriptions: `CLAUDE_CODE_OAUTH_TOKEN` from `claude setup-token`.) +2. Give the GitHub CLI the codespace scope: + + ```bash + gh auth refresh -h github.com -s codespace + ``` + +## Daily flow + +```bash +pnpm session # attach the default agent session (creates everything on first run) +pnpm session fix-flaky # a second, parallel agent in its own git worktree +pnpm session ls # what's running +pnpm session tunnel # forward n8n ports (default 5678, 8080) to localhost; Ctrl-C to stop +pnpm session stop # end of day: billing stops, disk survives +pnpm session rm # delete the codespace +``` + +- **Detach** with `Ctrl-b d` — the agent keeps working without you. +- **Reattach** by running the same `pnpm session ` from any machine. +- Each named session gets its own worktree (`/workspaces/wt-`, branch + `session/`), so parallel agents never touch each other's tree. Builds + in fresh worktrees are cache-hits via a shared turbo cache. +- First codespace creation takes ~20 min uncached (image + full build). After + that, sessions attach instantly; new worktrees cost a `pnpm install` (~1–2 min). + +## Viewing the dev UI locally + +Two terminal windows: + +```bash +pnpm session # window 1: attach the agent session +pnpm session tunnel # window 2: forward 5678 + 8080 to localhost +``` + +Attaching lands you in the agent (Claude), not a shell — open a shell in a +new tmux window with `Ctrl-b c` (or ask the agent) and run `pnpm dev` there. +Then open http://localhost:8080. + +The tunnel prints nothing while forwarding — that's normal. `Connection +refused` means nothing is listening on that port in the codespace yet; it +starts serving as soon as `pnpm dev` is up, no restart needed. Pass ports to +override the defaults (`pnpm session tunnel 5678 8080 5679`), but always +forward the pair together with matching numbers: the Vite dev UI points its +API base at `localhost:5678` (the `N8N_PORT` default), so an asymmetric or +partial mapping breaks it. + +## What survives what + +| Event | Running processes | Disk (checkout, worktrees, chat history) | +|---|---|---| +| Detach / close laptop / network drop | ✅ keep running | ✅ | +| Stop, or idle timeout (default 30 min, max 4 h) | ❌ killed | ✅ | +| Delete (`pnpm session rm`) | ❌ | ❌ (push your branches first) | + +After a stop, `pnpm session ` restarts the codespace (~30–60 s); run +`claude --resume` (or `--continue`) inside to restore the conversation from disk. + +## Gotchas (learned the hard way) + +- **Codespaces clones the repo to `/workspaces/n8n`**, not `/workspaces` — + `workspaceFolder` here differs from the laptop config on purpose. +- **A failing `onCreateCommand` is fatal**: Codespaces discards the container + and drops you into a minimal recovery container (no node, no CLIs). If your + codespace has nothing installed, check + `/workspaces/.codespaces/.persistedshare/creation.log`. +- **`gh codespace ssh` needs sshd inside the container** — provided by the + `sshd` devcontainer feature, don't remove it. +- **User secrets aren't visible in ssh shells by default**: the codespace + agent injects them into VS Code sessions only; they're delivered + base64-encoded to `/workspaces/.codespaces/shared/.env-secrets`. The image's + profile shim exports them for ssh/tmux sessions. +- Codespaces created by org members on this repo are **org-owned and + org-billed** (organization ownership + a monthly Codespaces budget are + enabled for n8n-io). Codespaces created before that change, or by + non-members, bill to the personal account (free tier: 120 core-hours/month). + If creation unexpectedly falls back to personal billing, the org budget is + exhausted for the period. + +## Costs + +Compute bills only while the codespace runs: ~$0.36/hr (4-core) / ~$0.72/hr +(8-core), ~nothing stopped. Usage draws from a shared monthly org budget, so +`pnpm session stop` when you leave; the idle timeout is the backstop, not the +plan. diff --git a/.devcontainer/codespaces/codespaces-secrets.sh b/.devcontainer/codespaces/codespaces-secrets.sh new file mode 100644 index 00000000000..a5bf51c32fc --- /dev/null +++ b/.devcontainer/codespaces/codespaces-secrets.sh @@ -0,0 +1,8 @@ +# Codespaces delivers user secrets base64-encoded to a shared env file and only +# injects them into VS Code sessions; ssh/tmux login shells must source them here. +if [ -f /workspaces/.codespaces/shared/.env-secrets ]; then + while IFS='=' read -r key val; do + case "$key" in '' | *[!A-Za-z0-9_]*) continue ;; esac + dec=$(printf %s "$val" | base64 -d 2>/dev/null) && export "$key=$dec" + done , unlike the laptop config + "workspaceFolder": "/workspaces/n8n", "hostRequirements": { "cpus": 8, "memory": "32gb", "storage": "64gb" }, "features": { - "ghcr.io/devcontainers/features/github-cli:1": {} + "ghcr.io/devcontainers/features/github-cli:1": {}, + // gh codespace ssh needs an SSH server inside the container + "ghcr.io/devcontainers/features/sshd:1": {} }, "forwardPorts": [8080, 5678], // Runs in prebuilds, so a fresh codespace starts with deps installed and the build warm diff --git a/.github/workflows/ci-pull-requests.yml b/.github/workflows/ci-pull-requests.yml index ce1fda701f8..38f49894608 100644 --- a/.github/workflows/ci-pull-requests.yml +++ b/.github/workflows/ci-pull-requests.yml @@ -70,8 +70,10 @@ jobs: !**/*.spec.ts !packages/testing/playwright/** !packages/frontend/@n8n/storybook/** + !.devcontainer/** !scripts/agent-setup.mjs !scripts/backend-module/** + !scripts/cloud-session.mjs !scripts/licenses/** !scripts/mutation-health/** !scripts/sync-agent-skill-links.mjs @@ -83,8 +85,10 @@ jobs: !**/*.md !**/LICENSE !**/CHANGELOG.md + !.devcontainer/** !scripts/agent-setup.mjs !scripts/backend-module/** + !scripts/cloud-session.mjs !scripts/licenses/** !scripts/mutation-health/** !scripts/sync-agent-skill-links.mjs diff --git a/package.json b/package.json index e5cdeca5f8c..00cda4c0058 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "dev-metrics:status": "node scripts/dev-metrics/setup.mjs --status", "dev-metrics:reset": "node scripts/dev-metrics/setup.mjs --reset", "clean": "turbo run clean", + "session": "node scripts/cloud-session.mjs", "reset": "node scripts/ensure-zx.mjs && zx scripts/reset.mjs", "format": "turbo run format && node scripts/format.mjs", "grind": "node scripts/grind.mjs", diff --git a/scripts/cloud-session.mjs b/scripts/cloud-session.mjs new file mode 100644 index 00000000000..6792ccfa8c8 --- /dev/null +++ b/scripts/cloud-session.mjs @@ -0,0 +1,134 @@ +#!/usr/bin/env node +// Manage cloud agent sessions: one Codespace per developer, one tmux session + +// git worktree per agent, so parallel sessions never trample each other's tree. +// Requires `gh` with the codespace scope (gh auth refresh -s codespace). +// +// pnpm session attach the default session (main checkout, /workspaces/n8n) +// pnpm session attach a named session in its own worktree (/workspaces/wt-) +// pnpm session ls list codespaces and the tmux sessions inside +// pnpm session tunnel [port…] forward ports (default 5678, 8080) to localhost; Ctrl-C to stop +// pnpm session stop stop the codespace (compute billing stops; disk survives) +// pnpm session rm delete the codespace +import { execFileSync, spawnSync } from 'node:child_process'; + +const REPO = 'n8n-io/n8n'; +const DEVCONTAINER = '.devcontainer/codespaces/devcontainer.json'; +const MACHINE = process.env.CODESPACE_MACHINE ?? 'premiumLinux'; // 8-core/32GB +const FALLBACK_MACHINE = 'standardLinux32gb'; // 4-core/16GB, until org policy allows bigger + +const gh = (...args) => execFileSync('gh', args, { encoding: 'utf8' }).trim(); +const ghTty = (...args) => spawnSync('gh', args, { stdio: 'inherit' }); + +function findCodespace() { + const list = JSON.parse(gh('codespace', 'list', '-R', REPO, '--json', 'name,state')); + return list[0]; +} + +function ensureCodespace() { + let cs = findCodespace(); + if (!cs) { + console.log(`No codespace on ${REPO} — creating one (first build takes a while)…`); + const create = (machine) => + gh('codespace', 'create', '-R', REPO, '--devcontainer-path', DEVCONTAINER, '-m', machine); + let name; + try { + name = create(MACHINE); + } catch { + console.log(`Machine type ${MACHINE} unavailable — falling back to ${FALLBACK_MACHINE}`); + name = create(FALLBACK_MACHINE); + } + cs = { name }; + } + return cs.name; +} + +// Worktrees share the pnpm store but not the turbo cache; a shared TURBO_CACHE_DIR +// (seeded from the main checkout) keeps new-worktree builds at cache-hit speed. +// No single quotes allowed here: the whole prelude rides inside tmux's '…' arg. +const CACHE = 'export TURBO_CACHE_DIR=/workspaces/.turbo-cache; [ -d "$TURBO_CACHE_DIR" ] || cp -r /workspaces/n8n/.turbo/cache "$TURBO_CACHE_DIR" 2>/dev/null || mkdir -p "$TURBO_CACHE_DIR"'; + +function remoteCommand(session, extraArgs) { + const claude = `claude ${extraArgs}`.trim(); + if (session === 'agent') return `${CACHE}; cd /workspaces/n8n && ${claude}`; + const wt = `/workspaces/wt-${session}`; + const branch = `session/${session}`; + return [ + CACHE, + `if [ ! -d "${wt}" ]; then echo "Setting up worktree ${wt}…"`, + `git -C /workspaces/n8n worktree add "${wt}" -b "${branch}" 2>/dev/null || git -C /workspaces/n8n worktree add "${wt}" "${branch}"`, + `(cd "${wt}" && pnpm install); fi`, + `cd "${wt}" && ${claude}`, + ].join('; '); +} + +const [cmd = 'agent', ...rest] = process.argv.slice(2); + +switch (cmd) { + case 'ls': { + const cs = findCodespace(); + if (!cs) { + console.log(`No codespace on ${REPO}. Run \`pnpm session\` to create one.`); + break; + } + console.log(`${cs.name} [${cs.state}]`); + if (cs.state === 'Available') { + ghTty('codespace', 'ssh', '-c', cs.name, '--', 'tmux ls 2>/dev/null || echo " no active sessions"'); + } + break; + } + case 'tunnel': { + // Local ports must match remote ones: the Vite dev UI (8080) points its API + // base at localhost:5678 (N8N_PORT's default), so an asymmetric or partial + // mapping breaks it. + const ports = rest.length ? rest : ['5678', '8080']; + if (ports.some((p) => !/^\d+$/.test(p) || +p < 1 || +p > 65535)) { + console.error(`Ports must be 1-65535, got: ${ports.join(' ')}`); + process.exit(1); + } + const cs = findCodespace(); + if (!cs) { + console.log(`No codespace on ${REPO}. Run \`pnpm session\` first.`); + break; + } + if (cs.state !== 'Available') { + console.error(`${cs.name} is ${cs.state} — run \`pnpm session\` to start it first.`); + process.exit(1); + } + console.log(`Forwarding ${ports.map((p) => `localhost:${p}`).join(', ')} from ${cs.name} — Ctrl-C to stop…`); + // ssh -L over `gh codespace ports forward`: gh binds all interfaces (LAN-exposed), + // ssh binds loopback only. ExitOnForwardFailure fails fast if a port is taken. + const { status } = ghTty( + 'codespace', 'ssh', '-c', cs.name, '--', '-N', '-o', 'ExitOnForwardFailure=yes', + ...ports.flatMap((p) => ['-L', `${p}:localhost:${p}`]), + ); + process.exitCode = status ?? 1; + break; + } + case 'stop': + case 'rm': { + const cs = findCodespace(); + if (!cs) break; + if (cmd === 'stop') { + gh('codespace', 'stop', '-c', cs.name); + } else { + const { status } = ghTty('codespace', 'delete', '-c', cs.name, '--force'); + if (status !== 0) process.exit(status ?? 1); + } + console.log(`${cmd === 'stop' ? 'Stopped' : 'Deleted'} ${cs.name}`); + break; + } + default: { + // treat cmd as the session name; each name = an independent agent in its own worktree + if (!/^[\w-]+$/.test(cmd)) { + console.error(`Invalid session name '${cmd}' — use letters, digits, - or _`); + process.exit(1); + } + const name = ensureCodespace(); + console.log(`Attaching to session '${cmd}' on ${name} (detach: Ctrl-b d)…`); + const { status } = ghTty( + 'codespace', 'ssh', '-c', name, '--', '-t', + `tmux new -As ${cmd} '${remoteCommand(cmd, rest.join(' '))}'`, + ); + process.exitCode = status ?? 1; + } +} From 65083ab1b7768d6ef140954608f0e6bb5af9726c Mon Sep 17 00:00:00 2001 From: Robin Braumann <50590409+bjorger@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:39:43 +0200 Subject: [PATCH 013/151] fix(core): Resolve expressions in ephemeral agent tools (#35876) Co-authored-by: Cursor --- .../__tests__/ephemeral-node-executor.test.ts | 53 +++++++++++++++++++ .../node-execution/ephemeral-node-executor.ts | 35 ++++++------ 2 files changed, 72 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/node-execution/__tests__/ephemeral-node-executor.test.ts b/packages/cli/src/node-execution/__tests__/ephemeral-node-executor.test.ts index 0e555a439b7..b1fb0d4759c 100644 --- a/packages/cli/src/node-execution/__tests__/ephemeral-node-executor.test.ts +++ b/packages/cli/src/node-execution/__tests__/ephemeral-node-executor.test.ts @@ -9,11 +9,13 @@ import { } from '@n8n/db'; import { StructuredToolkit } from 'n8n-core'; import { + Expression, NodeConnectionTypes, type IExecuteFunctions, type INodeCredentialsDetails, type INodeType, type INodeTypeDescription, + type ISupplyDataFunctions, type IWorkflowExecuteAdditionalData, } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; @@ -836,4 +838,55 @@ describe('EphemeralNodeExecutor', () => { expect(base.evalLlmMockHandler).toBeUndefined(); }); }); + + it('resolves expressions when invoking a supplyData tool with the VM engine', async () => { + await Expression.initExpressionEngine({ + engine: 'vm', + bridgeTimeout: 1000, + bridgeMemoryLimit: 128, + poolSize: 1, + maxCodeCacheSize: 10, + }); + + try { + nodeTypes.getByNameAndVersion.mockReturnValue( + mockNodeType({ + description: { + ...toolDescription, + properties: [ + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + }, + ], + }, + async supplyData(this: ISupplyDataFunctions) { + const resolvedValue = this.getNodeParameter('value', 0); + return { + response: { + invoke: async () => resolvedValue, + }, + }; + }, + }), + ); + + const result = await executor.executeInline({ + nodeType: '@n8n/n8n-nodes-langchain.toolHttpRequest', + nodeTypeVersion: 1, + nodeParameters: { value: '={{ 1 + 1 }}' }, + inputData: [{ json: {} }], + projectId: 'p-1', + }); + + expect(result).toEqual({ + status: 'success', + data: [{ json: { response: 2 } }], + }); + } finally { + await Expression.disposeExpressionEngine(); + } + }); }); diff --git a/packages/cli/src/node-execution/ephemeral-node-executor.ts b/packages/cli/src/node-execution/ephemeral-node-executor.ts index 98414ab1aa7..8354c9c4aea 100644 --- a/packages/cli/src/node-execution/ephemeral-node-executor.ts +++ b/packages/cli/src/node-execution/ephemeral-node-executor.ts @@ -482,27 +482,30 @@ export class EphemeralNodeExecutor { ); const nodeType = this.nodeTypes.getByNameAndVersion(tool.nodeType, tool.nodeTypeVersion); - if (typeof nodeType.supplyData !== 'function') { + const supplyData = nodeType.supplyData; + if (typeof supplyData !== 'function') { return { ok: false, error: 'Node does not implement supplyData' }; } try { - const supplyDataResult = await nodeType.supplyData.call(context, 0); - const response = supplyDataResult.response as - | LangChainToolType - | StructuredToolkit - | undefined; + return await withExpressionIsolate(parts.workflow, async () => { + const supplyDataResult = await supplyData.call(context, 0); + const response = supplyDataResult.response as + | LangChainToolType + | StructuredToolkit + | undefined; - if (response instanceof StructuredToolkit) { - return { ok: true, value: await onTool(response) }; - } - if (response && typeof response.invoke === 'function') { - return { ok: true, value: await onTool(response) }; - } - return { - ok: false, - error: `Node "${tool.nodeType}" did not return a valid LangChain tool or toolkit`, - }; + if (response instanceof StructuredToolkit) { + return { ok: true, value: await onTool(response) }; + } + if (response && typeof response.invoke === 'function') { + return { ok: true, value: await onTool(response) }; + } + return { + ok: false, + error: `Node "${tool.nodeType}" did not return a valid LangChain tool or toolkit`, + }; + }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return { ok: false, error: message }; From 6cc9b287ede63d6e2b1327c8e0eb6fde65467c7e Mon Sep 17 00:00:00 2001 From: Michael Drury Date: Mon, 10 Aug 2026 10:50:04 +0100 Subject: [PATCH 014/151] fix(core): Run published agent workflow tools as production (#35784) --- .../agent-runtime-cache.service.test.ts | 10 + .../agents-service-reconstruct-gating.test.ts | 67 ++++++ .../__tests__/workflow-tool-factory.test.ts | 120 ++++++++-- .../agents/agent-runtime-cache.service.ts | 2 + .../agent-runtime-reconstruction.service.ts | 28 ++- .../sub-agent-foreground-runner.test.ts | 16 ++ .../sub-agents/sub-agent-foreground-runner.ts | 4 + .../__tests__/workflow-tool-factory.test.ts | 156 ++++++++++++- ...kflow-tool-workflow-loader.service.test.ts | 109 +++++++++ .../agents/tools/workflow-tool-factory.ts | 206 ++++++++++++------ .../workflow-tool-workflow-loader.service.ts | 51 +++++ 11 files changed, 680 insertions(+), 89 deletions(-) create mode 100644 packages/cli/src/modules/agents/tools/__tests__/workflow-tool-workflow-loader.service.test.ts create mode 100644 packages/cli/src/modules/agents/tools/workflow-tool-workflow-loader.service.ts diff --git a/packages/cli/src/modules/agents/__tests__/agent-runtime-cache.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-runtime-cache.service.test.ts index e1f0fbf3242..96b7da92911 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-runtime-cache.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-runtime-cache.service.test.ts @@ -92,6 +92,8 @@ describe('AgentRuntimeCacheService', () => { 'test', undefined, undefined, + undefined, + 'manual', ); }); @@ -123,6 +125,8 @@ describe('AgentRuntimeCacheService', () => { 'test', 'n8n_chat', undefined, + undefined, + 'manual', ); }); @@ -154,6 +158,8 @@ describe('AgentRuntimeCacheService', () => { 'test', undefined, userA, + undefined, + 'manual', ); expect(reconstructionService.reconstructFromAgentEntity).toHaveBeenNthCalledWith( 2, @@ -162,6 +168,8 @@ describe('AgentRuntimeCacheService', () => { 'test', undefined, userB, + undefined, + 'manual', ); }); @@ -286,6 +294,8 @@ describe('AgentRuntimeCacheService', () => { 'production', 'slack', undefined, + undefined, + 'integrated', ); }); diff --git a/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts b/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts index 93bfa65e7bf..89c2370deb3 100644 --- a/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts @@ -302,6 +302,16 @@ describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — sub-a return undefined; } + function getInjectedDelegateTool() { + for (const call of builtAgent.tool.mock.calls) { + for (const item of Array.isArray(call[0]) ? call[0] : [call[0]]) { + const tool = item as BuiltTool; + if (tool.name === DELEGATE_SUB_AGENT_TOOL_NAME) return tool; + } + } + return undefined; + } + function getInjectedAvailableSubAgents() { for (const call of builtAgent.tool.mock.calls) { for (const item of Array.isArray(call[0]) ? call[0] : [call[0]]) { @@ -365,6 +375,63 @@ describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — sub-a }); }); + it.each(['integrated', 'manual'] as const)( + 'passes the %s workflow execution mode to configured sub-agents', + async (workflowToolExecutionMode) => { + const credentialProvider = mock(); + const foregroundRunner = mock(); + foregroundRunner.runForeground.mockResolvedValue({ + taskPath: '/root/research_api_0', + threadId: 'child-thread-1', + status: 'completed', + result: { + runId: 'child-run-1', + messages: [], + getState: () => mock(), + }, + }); + Container.set(SubAgentForegroundRunner, foregroundRunner); + const agentRepository = mock(); + agentRepository.findByIdAndProjectId.mockResolvedValue({ + id: 'agent-2', + name: 'Research Agent', + activeVersionId: 'version-2', + } as Agent); + const service = makeReconstructionService([], { agentRepository }); + const entity = makeAgentEntity(undefined, { + subAgents: { agents: [{ agentId: 'agent-2' }] }, + }); + + await service.reconstructFromAgentEntity( + entity, + credentialProvider, + 'production', + undefined, + undefined, + undefined, + workflowToolExecutionMode, + ); + + const delegateTool = getInjectedDelegateTool(); + if (!delegateTool?.handler) throw new Error('Expected delegate tool handler'); + + await expect( + delegateTool.handler( + { + subAgentId: 'agent-2', + taskName: 'Research API', + goal: 'Find the API behavior.', + }, + { runId: 'parent-run-1' }, + ), + ).resolves.toMatchObject({ status: 'completed' }); + expect(foregroundRunner.runForeground).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ workflowToolExecutionMode }), + ); + }, + ); + it('passes saved sub-agent useWhen guidance into delegate tool metadata', async () => { const credentialProvider = mock(); const agentRepository = mock(); diff --git a/packages/cli/src/modules/agents/__tests__/workflow-tool-factory.test.ts b/packages/cli/src/modules/agents/__tests__/workflow-tool-factory.test.ts index b4f19fa47cd..23ea8edeb2e 100644 --- a/packages/cli/src/modules/agents/__tests__/workflow-tool-factory.test.ts +++ b/packages/cli/src/modules/agents/__tests__/workflow-tool-factory.test.ts @@ -1,8 +1,10 @@ import type { WorkflowRepository, WorkflowEntity } from '@n8n/db'; +import { Container } from '@n8n/di'; import type { INode } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; import type { ActiveExecutions } from '@/active-executions'; +import { ExecutionPersistence } from '@/executions/execution-persistence'; import type { WorkflowRunner } from '@/workflow-runner'; import { @@ -13,6 +15,7 @@ import { } from '../tools/workflow-tool-factory'; import type { WorkflowToolContext } from '../tools/workflow-tool-factory'; import { findWorkflowToolWorkflows } from '../tools/workflow-tool-workflow-resolver'; +import type { WorkflowToolWorkflowLoader } from '../tools/workflow-tool-workflow-loader.service'; // --------------------------------------------------------------------------- // Helpers @@ -85,17 +88,18 @@ function makeWorkflow( } function makeContext(foundWorkflow: WorkflowEntity | null): WorkflowToolContext { - const workflowRepository = mock(); const workflowRunner = mock(); const activeExecutions = mock(); + const workflowLoader = mock(); - workflowRepository.findOneByAgentToolReference.mockResolvedValue(foundWorkflow); + workflowLoader.loadPublishedWorkflow.mockResolvedValue(foundWorkflow); return { - workflowRepository, + workflowLoader, workflowRunner, activeExecutions, projectId: 'project-1', + executionMode: 'manual', }; } @@ -104,6 +108,10 @@ function makeContext(foundWorkflow: WorkflowEntity | null): WorkflowToolContext // --------------------------------------------------------------------------- describe('resolveWorkflowTool() — metadata attachment', () => { + afterEach(() => { + Container.reset(); + }); + it('attaches metadata with triggerType "manual" for a manual trigger workflow', async () => { const workflow = makeWorkflow( { id: 'wf-manual-1', name: 'Manual Workflow' }, @@ -173,10 +181,9 @@ describe('resolveWorkflowTool() — metadata attachment', () => { await resolveWorkflowTool({ type: 'workflow', workflow: 'Scoped Workflow' }, context); - expect(context.workflowRepository.findOneByAgentToolReference).toHaveBeenCalledWith( - 'project-1', - { workflowName: 'Scoped Workflow' }, - ); + expect(context.workflowLoader.loadPublishedWorkflow).toHaveBeenCalledWith('project-1', { + workflowName: 'Scoped Workflow', + }); }); it('does not fall back to the cached name when an id is present', async () => { @@ -189,10 +196,10 @@ describe('resolveWorkflowTool() — metadata attachment', () => { ), ).rejects.toThrow('Workflow "Existing Workflow" not found'); - expect(context.workflowRepository.findOneByAgentToolReference).toHaveBeenCalledWith( - 'project-1', - { workflowId: 'missing-id', workflowName: 'Existing Workflow' }, - ); + expect(context.workflowLoader.loadPublishedWorkflow).toHaveBeenCalledWith('project-1', { + workflowId: 'missing-id', + workflowName: 'Existing Workflow', + }); }); it('throws when the workflow is not shared with the project', async () => { @@ -202,6 +209,82 @@ describe('resolveWorkflowTool() — metadata attachment', () => { resolveWorkflowTool({ type: 'workflow', workflow: 'Missing Workflow' }, context), ).rejects.toThrow('Workflow "Missing Workflow" not found'); }); + + it('loads the current published workflow for every invocation', async () => { + const initial = makeWorkflow({ id: 'wf-current', name: 'Current Workflow' }); + const secondVersion = makeWorkflow({ + id: 'wf-current', + name: 'Current Workflow', + versionId: 'version-2', + nodes: [makeManualTriggerNode(), { ...makeRespondToWebhookNode(), name: 'Version 2' }], + }); + const thirdVersion = makeWorkflow({ + id: 'wf-current', + name: 'Current Workflow', + versionId: 'version-3', + nodes: [makeManualTriggerNode(), { ...makeRespondToWebhookNode(), name: 'Version 3' }], + }); + const context = makeContext(initial); + const loadPublishedWorkflow = vi + .fn() + .mockResolvedValueOnce(initial) + .mockResolvedValueOnce(secondVersion) + .mockResolvedValueOnce(thirdVersion); + Object.assign(context, { + workflowLoader: { loadPublishedWorkflow }, + executionMode: 'integrated', + }); + context.workflowRunner.run = vi + .fn() + .mockResolvedValueOnce('exec-1') + .mockResolvedValueOnce('exec-2'); + context.activeExecutions.has = vi.fn().mockReturnValue(false); + Container.set(ExecutionPersistence, { + findSingleExecution: vi + .fn() + .mockResolvedValue({ status: 'success', data: { resultData: { runData: {} } } }), + } as unknown as ExecutionPersistence); + + const tool = await resolveWorkflowTool( + { type: 'workflow', workflowId: 'wf-current', workflow: 'Current Workflow' }, + context, + ); + await tool.handler?.({}, {}); + await tool.handler?.({}, {}); + + expect(loadPublishedWorkflow).toHaveBeenNthCalledWith(1, 'project-1', { + workflowId: 'wf-current', + workflowName: 'Current Workflow', + }); + expect(loadPublishedWorkflow).toHaveBeenNthCalledWith(2, 'project-1', { + workflowId: 'wf-current', + workflowName: 'Current Workflow', + }); + expect(loadPublishedWorkflow).toHaveBeenNthCalledWith(3, 'project-1', { + workflowId: 'wf-current', + workflowName: 'Current Workflow', + }); + expect(context.workflowRunner.run).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + workflowData: expect.objectContaining({ versionId: 'version-2' }), + }), + undefined, + undefined, + undefined, + expect.anything(), + ); + expect(context.workflowRunner.run).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + workflowData: expect.objectContaining({ versionId: 'version-3' }), + }), + undefined, + undefined, + undefined, + expect.anything(), + ); + }); }); describe('workflow tool compatibility', () => { @@ -244,10 +327,15 @@ describe('workflow tool compatibility', () => { it('normalizes webhook tool input into the webhook trigger output shape', () => { const triggerNode = makeWebhookTriggerNode(); - const pinData = normalizeTriggerInput(triggerNode, 'webhook', { - customerId: '123', - priority: 'high', - }); + const pinData = normalizeTriggerInput( + triggerNode, + 'webhook', + { + customerId: '123', + priority: 'high', + }, + 'integrated', + ); expect(pinData).toEqual({ Webhook: [ @@ -258,7 +346,7 @@ describe('workflow tool compatibility', () => { query: {}, body: { customerId: '123', priority: 'high' }, webhookUrl: '', - executionMode: 'agent', + executionMode: 'production', }, }, ], diff --git a/packages/cli/src/modules/agents/agent-runtime-cache.service.ts b/packages/cli/src/modules/agents/agent-runtime-cache.service.ts index d0471bab581..e4d8752bb66 100644 --- a/packages/cli/src/modules/agents/agent-runtime-cache.service.ts +++ b/packages/cli/src/modules/agents/agent-runtime-cache.service.ts @@ -235,6 +235,8 @@ export class AgentRuntimeCacheService { usePublishedVersion ? 'production' : 'test', integrationType, user, + undefined, + usePublishedVersion ? 'integrated' : 'manual', ); return { diff --git a/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts b/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts index e8117c6e959..2b13a166b99 100644 --- a/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts +++ b/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts @@ -77,7 +77,9 @@ import { createN8nDelegateSubAgentTool } from './sub-agents/delegate-sub-agent-t import { SubAgentForegroundRunner } from './sub-agents/sub-agent-foreground-runner'; import { buildToolRegistry, type ToolRegistry } from './tool-registry'; import { createGetEnvironmentTool } from './tools/environment-tool'; +import type { WorkflowToolExecutionMode } from './tools/workflow-tool-factory'; import { findWorkflowToolWorkflow } from './tools/workflow-tool-workflow-resolver'; +import { WorkflowToolWorkflowLoader } from './tools/workflow-tool-workflow-loader.service'; import { resolveUniqueSubAgents } from './utils/sub-agent-resolver'; /** * `inline` runs an agent defined in a workflow node's parameters: no entity @@ -109,6 +111,11 @@ export interface ReconstructAgentRuntimeParams { * its own published snapshot. */ runType: AgentRunTelemetryType; + /** + * Execution classification for workflow tools. It stays separate from runType + * because production-classified agents can run inside a workflow execution. + */ + workflowToolExecutionMode?: WorkflowToolExecutionMode; /** Delegating parent agent id for sub-agent runs; defaults to memoryOwnerAgentId for top-level. */ parentAgentIdForDelegation?: string; /** Top-level chat/integration runtimes only. */ @@ -182,6 +189,7 @@ export class AgentRuntimeReconstructionService { integrationType?: string, user?: User, instrumentation?: AgentRuntimeInstrumentation, + workflowToolExecutionMode: WorkflowToolExecutionMode = 'manual', ): Promise<{ agent: RuntimeAgent; toolRegistry: ToolRegistry }> { let config = agentEntity.schema; if (!config) { @@ -222,6 +230,7 @@ export class AgentRuntimeReconstructionService { skills: agentEntity.skills ?? {}, runtimeProfile: 'top-level', runType, + workflowToolExecutionMode, parentAgentIdForDelegation: agentEntity.id, integrationType, credentialIntegrations: agentEntity.integrations ?? [], @@ -334,6 +343,7 @@ export class AgentRuntimeReconstructionService { skills: Record; runtimeProfile: AgentRuntimeProfile; runType: AgentRunTelemetryType; + workflowToolExecutionMode?: WorkflowToolExecutionMode; parentAgentIdForDelegation?: string; integrationType?: string; credentialIntegrations: AgentIntegrationConfig[]; @@ -351,6 +361,7 @@ export class AgentRuntimeReconstructionService { skills, runtimeProfile, runType, + workflowToolExecutionMode = 'manual', parentAgentIdForDelegation, integrationType, credentialIntegrations, @@ -360,7 +371,11 @@ export class AgentRuntimeReconstructionService { } = options; const toolExecutor = this.secureRuntime.createToolExecutor(toolCodeByName); - const toolResolver = this.makeToolResolver(projectId, instrumentation); + const toolResolver = this.makeToolResolver( + projectId, + workflowToolExecutionMode, + instrumentation, + ); const resolvedTools: BuiltTool[] = []; // Transport for LLM calls @@ -426,6 +441,7 @@ export class AgentRuntimeReconstructionService { credentialProvider, runtimeProfile, runType, + workflowToolExecutionMode, config, subAgentDelegation, parentAgentIdForDelegation: parentAgentIdForDelegation ?? memoryOwnerAgentId, @@ -512,6 +528,7 @@ export class AgentRuntimeReconstructionService { } private makeToolResolver( projectId: string, + workflowToolExecutionMode: WorkflowToolExecutionMode, instrumentation?: AgentRuntimeInstrumentation, ): ToolResolver { const instrumentToolAdditionalData = instrumentation?.configureToolAdditionalData; @@ -519,10 +536,11 @@ export class AgentRuntimeReconstructionService { if (ref.type === 'workflow') { const { resolveWorkflowTool } = await import('./tools/workflow-tool-factory.js'); return await resolveWorkflowTool(ref, { - workflowRepository: this.workflowRepository, + workflowLoader: Container.get(WorkflowToolWorkflowLoader), workflowRunner: await getWorkflowRunner(), activeExecutions: this.activeExecutions, projectId, + executionMode: workflowToolExecutionMode, webhookBaseUrl: this.urlService.getWebhookBaseUrl(), instrumentToolAdditionalData, }); @@ -548,6 +566,7 @@ export class AgentRuntimeReconstructionService { credentialProvider: CredentialProvider; runtimeProfile: AgentRuntimeProfile; runType: AgentRunTelemetryType; + workflowToolExecutionMode: WorkflowToolExecutionMode; config: AgentJsonConfig; subAgentDelegation: SubAgentDelegationConfig; parentAgentIdForDelegation: string; @@ -563,6 +582,7 @@ export class AgentRuntimeReconstructionService { credentialProvider, runtimeProfile, runType, + workflowToolExecutionMode, config, subAgentDelegation, parentAgentIdForDelegation, @@ -657,6 +677,7 @@ export class AgentRuntimeReconstructionService { projectId, credentialProvider, runType, + workflowToolExecutionMode, delegation: subAgentDelegation, user, instrumentation, @@ -688,6 +709,7 @@ export class AgentRuntimeReconstructionService { projectId: string; credentialProvider: CredentialProvider; runType: AgentRunTelemetryType; + workflowToolExecutionMode: WorkflowToolExecutionMode; delegation: SubAgentDelegationConfig; user?: User; instrumentation?: AgentRuntimeInstrumentation; @@ -699,6 +721,7 @@ export class AgentRuntimeReconstructionService { projectId, credentialProvider, runType, + workflowToolExecutionMode, delegation, user, instrumentation, @@ -715,6 +738,7 @@ export class AgentRuntimeReconstructionService { parentAgentId, credentialProvider, runType, + workflowToolExecutionMode, user, instrumentation, policy: this.buildSubAgentPolicy(config), diff --git a/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-foreground-runner.test.ts b/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-foreground-runner.test.ts index f7afd1d3434..93f3fa297b1 100644 --- a/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-foreground-runner.test.ts +++ b/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-foreground-runner.test.ts @@ -248,6 +248,22 @@ describe('SubAgentForegroundRunner', () => { ); }); + it.each(['integrated', 'manual'] as const)( + 'reconstructs child workflow tools with the parent %s execution mode', + async (workflowToolExecutionMode) => { + await runner.runForeground(spawnRequest, { + projectId, + credentialProvider, + runType: 'production', + workflowToolExecutionMode, + }); + + expect(reconstructionService.reconstructFromResolvedSource).toHaveBeenCalledWith( + expect.objectContaining({ workflowToolExecutionMode }), + ); + }, + ); + it('filters sub-agent tools by the delegating user access when the parent run has a user', async () => { const user = mock({ id: 'user-1' }); diff --git a/packages/cli/src/modules/agents/sub-agents/sub-agent-foreground-runner.ts b/packages/cli/src/modules/agents/sub-agents/sub-agent-foreground-runner.ts index 70231a88cb3..71344109f48 100644 --- a/packages/cli/src/modules/agents/sub-agents/sub-agent-foreground-runner.ts +++ b/packages/cli/src/modules/agents/sub-agents/sub-agent-foreground-runner.ts @@ -31,6 +31,7 @@ import { buildAgentConfigurationTelemetryFromConfig } from '../agent-telemetry'; import type { MessageRecord } from '../execution-recorder'; import { ExecutionRecorder } from '../execution-recorder'; import { N8NCheckpointStorage } from '../integrations/n8n-checkpoint-storage'; +import type { WorkflowToolExecutionMode } from '../tools/workflow-tool-factory'; import { streamAgentChunks } from '../utils/agent-stream'; import { SubAgentSourceResolver } from './sub-agent-source-resolver'; @@ -46,6 +47,8 @@ export interface SubAgentForegroundRunContext { * while the parent is being tested in the builder preview. */ runType: AgentRunTelemetryType; + /** Workflow execution classification inherited from the parent runtime. */ + workflowToolExecutionMode?: WorkflowToolExecutionMode; executionCounter?: AgentExecutionCounter; /** Parent run's abort signal — cancelling the parent cancels this child. */ abortSignal?: AbortSignal; @@ -179,6 +182,7 @@ export class SubAgentForegroundRunner { skills: runtimeSource.skills, runtimeProfile: 'sub-agent', runType: context.runType, + workflowToolExecutionMode: context.workflowToolExecutionMode, parentAgentIdForDelegation: context.parentAgentId, user: context.user, instrumentation: context.instrumentation, diff --git a/packages/cli/src/modules/agents/tools/__tests__/workflow-tool-factory.test.ts b/packages/cli/src/modules/agents/tools/__tests__/workflow-tool-factory.test.ts index 766c4541736..136fd0f65d0 100644 --- a/packages/cli/src/modules/agents/tools/__tests__/workflow-tool-factory.test.ts +++ b/packages/cli/src/modules/agents/tools/__tests__/workflow-tool-factory.test.ts @@ -4,8 +4,10 @@ import type { IDeferredPromise } from '@n8n/utils/promise/deferred-promise'; import type { IExecuteResponsePromiseData, INode, + IRun, IWorkflowExecutionDataProcess, } from 'n8n-workflow'; +import { createRunExecutionData } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; import type { ActiveExecutions } from '@/active-executions'; @@ -33,14 +35,166 @@ const workflow = { function buildContext(run: ReturnType, extras: Partial = {}) { return { - workflowRepository: {} as never, + workflowLoader: {} as never, workflowRunner: { run } as unknown as WorkflowRunner, activeExecutions: { has: vi.fn().mockReturnValue(false) } as unknown as ActiveExecutions, projectId: 'p1', + executionMode: 'manual', ...extras, } satisfies WorkflowToolContext; } +describe('executeWorkflow → execution classification', () => { + beforeEach(() => { + Container.set(ExecutionPersistence, { + findSingleExecution: vi + .fn() + .mockResolvedValue({ status: 'success', data: { resultData: { runData: {} } } }), + } as unknown as ExecutionPersistence); + }); + + afterEach(() => { + Container.reset(); + }); + + it.each([ + ['manual', 'test'], + ['integrated', 'production'], + ] as const)( + 'runs %s agent workflow tools as %s executions', + async (executionMode, publicMode) => { + const run = vi.fn().mockResolvedValue('exec-1'); + const context = { + ...buildContext(run), + executionMode, + } as WorkflowToolContext; + + await executeWorkflow(workflow, triggerNode, 'webhook', { body: { value: 1 } }, context); + + const runData = run.mock.calls[0][0] as IWorkflowExecutionDataProcess; + expect(runData.executionMode).toBe(executionMode); + expect( + runData.executionData?.executionData?.nodeExecutionStack[0].data.main[0]?.[0]?.json, + ).toMatchObject({ executionMode: publicMode }); + }, + ); + + it('does not execute saved editor pin data', async () => { + const run = vi.fn().mockResolvedValue('exec-1'); + const workflowWithPinData = { + ...workflow, + pinData: { 'Pinned Node': [{ json: { value: 'editor-only' } }] }, + } as WorkflowEntity; + + await executeWorkflow(workflowWithPinData, triggerNode, 'manual', { input: 'live' }, { + ...buildContext(run), + executionMode: 'integrated', + } as WorkflowToolContext); + + const runData = run.mock.calls[0][0] as IWorkflowExecutionDataProcess; + expect(runData.pinData).toEqual({ + [triggerNode.name]: [{ json: { input: 'live' } }], + }); + expect(runData.workflowData.pinData).toBeUndefined(); + }); + + it('returns output from the completed integrated run when execution data is not retained', async () => { + const completedRun = { + mode: 'integrated', + status: 'success', + finished: true, + startedAt: new Date(), + stoppedAt: new Date(), + storedAt: 'db', + data: createRunExecutionData({ + resultData: { + runData: { + Result: [ + { + data: { main: [[{ json: { answer: 42 } }]] }, + executionIndex: 0, + startTime: 0, + executionTime: 1, + source: [], + }, + ], + }, + }, + }), + } satisfies IRun; + Container.set(ExecutionPersistence, { + findSingleExecution: vi.fn().mockResolvedValue(undefined), + } as unknown as ExecutionPersistence); + const run = vi.fn().mockResolvedValue('exec-1'); + const activeExecutions = { + has: vi.fn().mockReturnValue(true), + getPostExecutePromise: vi.fn().mockResolvedValue(completedRun), + } as unknown as ActiveExecutions; + + const result = await executeWorkflow(workflow, triggerNode, 'manual', {}, { + ...buildContext(run), + activeExecutions, + executionMode: 'integrated', + } as WorkflowToolContext); + + expect(result).toEqual({ + executionId: 'exec-1', + status: 'success', + data: { Result: [{ answer: 42 }] }, + }); + }); + + it('reads persisted output for a completed manual run', async () => { + const completedRun = { + mode: 'manual', + status: 'success', + finished: true, + startedAt: new Date(), + stoppedAt: new Date(), + storedAt: 'db', + data: createRunExecutionData({ resultData: { runData: {} } }), + } satisfies IRun; + Container.set(ExecutionPersistence, { + findSingleExecution: vi.fn().mockResolvedValue({ + status: 'success', + data: createRunExecutionData({ + resultData: { + runData: { + Result: [ + { + data: { main: [[{ json: { answer: 42 } }]] }, + executionIndex: 0, + startTime: 0, + executionTime: 1, + source: [], + }, + ], + }, + }, + }), + }), + } as unknown as ExecutionPersistence); + const run = vi.fn().mockResolvedValue('exec-1'); + const activeExecutions = { + has: vi.fn().mockReturnValue(true), + getPostExecutePromise: vi.fn().mockResolvedValue(completedRun), + } as unknown as ActiveExecutions; + + const result = await executeWorkflow( + workflow, + triggerNode, + 'manual', + {}, + { + ...buildContext(run), + activeExecutions, + }, + ); + + expect(result.data).toEqual({ Result: [{ answer: 42 }] }); + }); +}); + describe('executeWorkflow → eval instrumentation', () => { beforeEach(() => { Container.set(ExecutionPersistence, { diff --git a/packages/cli/src/modules/agents/tools/__tests__/workflow-tool-workflow-loader.service.test.ts b/packages/cli/src/modules/agents/tools/__tests__/workflow-tool-workflow-loader.service.test.ts new file mode 100644 index 00000000000..43639cc88e5 --- /dev/null +++ b/packages/cli/src/modules/agents/tools/__tests__/workflow-tool-workflow-loader.service.test.ts @@ -0,0 +1,109 @@ +import type { WorkflowsConfig } from '@n8n/config'; +import type { + PublishedWorkflowDataForExecution, + WorkflowEntity, + WorkflowRepository, +} from '@n8n/db'; +import { mock } from 'vitest-mock-extended'; + +import type { WorkflowPublishedDataService } from '@/workflows/workflow-published-data.service'; + +import { WorkflowToolWorkflowLoader } from '../workflow-tool-workflow-loader.service'; + +const reference = { workflowId: 'workflow-1', workflowName: 'Workflow' }; + +function makeWorkflow(overrides: Partial = {}) { + return { + id: 'workflow-1', + name: 'Workflow', + isArchived: false, + nodes: [{ id: 'draft-node' }], + connections: {}, + pinData: { Draft: [{ json: { pinned: true } }] }, + activeVersion: null, + ...overrides, + } as unknown as WorkflowEntity; +} + +function makeService(useWorkflowPublicationService: boolean) { + const workflowsConfig = mock({ useWorkflowPublicationService }); + const workflowRepository = mock(); + const workflowPublishedDataService = mock(); + const service = new WorkflowToolWorkflowLoader( + workflowsConfig, + workflowRepository, + workflowPublishedDataService, + ); + + return { service, workflowRepository, workflowPublishedDataService }; +} + +describe('WorkflowToolWorkflowLoader', () => { + it('loads the current publication directly from the database when publication service is enabled', async () => { + const { service, workflowRepository, workflowPublishedDataService } = makeService(true); + workflowRepository.findOneByAgentToolReference.mockResolvedValue(makeWorkflow()); + workflowPublishedDataService.getPublishedWorkflowDataForExecution.mockResolvedValue({ + ...makeWorkflow(), + versionId: 'published-version-2', + nodes: [{ id: 'published-node' }], + } as unknown as PublishedWorkflowDataForExecution); + + const workflow = await service.loadPublishedWorkflow('project-1', reference); + + expect(workflowRepository.findOneByAgentToolReference).toHaveBeenCalledWith( + 'project-1', + reference, + ); + expect(workflowPublishedDataService.getPublishedWorkflowDataForExecution).toHaveBeenCalledWith( + 'workflow-1', + ); + expect( + workflowPublishedDataService.getCachedPublishedWorkflowDataForExecution, + ).not.toHaveBeenCalled(); + expect(workflow).toMatchObject({ + versionId: 'published-version-2', + nodes: [{ id: 'published-node' }], + }); + expect(workflow?.pinData).toBeUndefined(); + }); + + it('loads activeVersion when publication service is disabled', async () => { + const { service, workflowRepository, workflowPublishedDataService } = makeService(false); + workflowRepository.findOneByAgentToolReference.mockResolvedValue(makeWorkflow()); + workflowRepository.findById.mockResolvedValue( + makeWorkflow({ + activeVersion: { + versionId: 'active-version-3', + nodes: [{ id: 'active-node' }], + connections: { Trigger: { main: [[]] } }, + nodeGroups: [], + } as unknown as NonNullable, + }), + ); + + const workflow = await service.loadPublishedWorkflow('project-1', reference); + + expect( + workflowPublishedDataService.getPublishedWorkflowDataForExecution, + ).not.toHaveBeenCalled(); + expect(workflow).toMatchObject({ + versionId: 'active-version-3', + nodes: [{ id: 'active-node' }], + connections: { Trigger: { main: [[]] } }, + }); + expect(workflow?.pinData).toBeUndefined(); + }); + + it.each([ + ['unshared', null, makeWorkflow()], + ['unpublished', makeWorkflow(), null], + ] as const)('returns null when the workflow is %s', async (_label, accessible, published) => { + const { service, workflowRepository, workflowPublishedDataService } = makeService(true); + workflowRepository.findOneByAgentToolReference.mockResolvedValue(accessible); + workflowPublishedDataService.getPublishedWorkflowDataForExecution.mockResolvedValue( + published as PublishedWorkflowDataForExecution | null, + ); + + await expect(service.loadPublishedWorkflow('project-1', reference)).resolves.toBeNull(); + }); +}); diff --git a/packages/cli/src/modules/agents/tools/workflow-tool-factory.ts b/packages/cli/src/modules/agents/tools/workflow-tool-factory.ts index 320349b7f67..ddfea9fa976 100644 --- a/packages/cli/src/modules/agents/tools/workflow-tool-factory.ts +++ b/packages/cli/src/modules/agents/tools/workflow-tool-factory.ts @@ -5,7 +5,7 @@ import { type AgentJsonToolConfig, type SUPPORTED_WORKFLOW_TOOL_TRIGGERS, } from '@n8n/api-types'; -import type { WorkflowRepository, WorkflowEntity } from '@n8n/db'; +import type { WorkflowEntity } from '@n8n/db'; import { Container } from '@n8n/di'; import { isRecord } from '@n8n/utils/is-record'; import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise'; @@ -14,6 +14,9 @@ import type { IExecuteResponsePromiseData, INode, IPinData, + IRun, + IRunData, + ITaskData, IWorkflowExecutionDataProcess, WorkflowExecuteMode, } from 'n8n-workflow'; @@ -35,7 +38,10 @@ import type { WorkflowRunner } from '@/workflow-runner'; import type { InstrumentToolAdditionalData } from '../agent-runtime-instrumentation'; import { sanitizeToolName } from '../json-config/agent-config-composition'; -import { findWorkflowToolWorkflow } from './workflow-tool-workflow-resolver'; +import type { + WorkflowToolWorkflowLoader, + WorkflowToolWorkflowReference, +} from './workflow-tool-workflow-loader.service'; // --------------------------------------------------------------------------- // Constants @@ -78,11 +84,14 @@ function isWorkflowToolResponse(value: unknown): value is IExecuteResponsePromis // Context passed from the compile step // --------------------------------------------------------------------------- +export type WorkflowToolExecutionMode = Extract; + export interface WorkflowToolContext { - workflowRepository: WorkflowRepository; + workflowLoader: WorkflowToolWorkflowLoader; workflowRunner: WorkflowRunner; activeExecutions: ActiveExecutions; projectId: string; + executionMode: WorkflowToolExecutionMode; /** Base URL for webhooks/forms (e.g. http://localhost:5678/) */ webhookBaseUrl?: string; /** Eval-only additionalData decoration for the sub-execution — absent on every production path. */ @@ -138,6 +147,7 @@ export function normalizeTriggerInput( triggerNode: INode, triggerType: string, inputData: Record, + executionMode: WorkflowToolExecutionMode, ): IPinData { switch (triggerType) { case 'chat': @@ -167,7 +177,7 @@ export function normalizeTriggerInput( query: isRecord(query) ? query : {}, body: isRecord(body) ? body : inputData, webhookUrl: '', - executionMode: 'agent', + executionMode: executionMode === 'manual' ? 'test' : 'production', }, }, ], @@ -285,24 +295,24 @@ export async function executeWorkflow( const { workflowRunner, activeExecutions } = context; // Build pin data for the trigger - const triggerPinData = normalizeTriggerInput(triggerNode, triggerType, inputData); - - // Merge with workflow's existing pinData - const workflowPinData = workflow.pinData ?? {}; - const mergedPinData: IPinData = { ...workflowPinData, ...triggerPinData }; - - // Determine execution mode from trigger type - const executionMode: WorkflowExecuteMode = triggerType === 'chat' ? 'chat' : 'manual'; + const triggerPinData = normalizeTriggerInput( + triggerNode, + triggerType, + inputData, + context.executionMode, + ); + const workflowData = + workflow.pinData === undefined ? workflow : { ...workflow, pinData: undefined }; // Build execution data following Instance AI adapter's pattern const runData: IWorkflowExecutionDataProcess = { - executionMode, - workflowData: workflow, + executionMode: context.executionMode, + workflowData, startNodes: [{ name: triggerNode.name, sourceData: null }], - pinData: mergedPinData, + pinData: triggerPinData, executionData: createRunExecutionData({ startData: {}, - resultData: { pinData: mergedPinData, runData: {} }, + resultData: { pinData: triggerPinData, runData: {} }, executionData: { contextData: {}, metadata: {}, @@ -348,6 +358,7 @@ export async function executeWorkflow( // Wait for completion with timeout protection const timeoutMs = DEFAULT_TIMEOUT_MS; + let completedRun: IRun | undefined; if (activeExecutions.has(executionId)) { let timeoutId: NodeJS.Timeout | undefined; const timeoutPromise = new Promise((_, reject) => { @@ -357,7 +368,10 @@ export async function executeWorkflow( }); try { - await Promise.race([activeExecutions.getPostExecutePromise(executionId), timeoutPromise]); + completedRun = await Promise.race([ + activeExecutions.getPostExecutePromise(executionId), + timeoutPromise, + ]); clearTimeout(timeoutId); } catch (error) { clearTimeout(timeoutId); @@ -380,7 +394,10 @@ export async function executeWorkflow( } } - const result = await extractResult(executionId, allOutputs); + const result = + completedRun && context.executionMode === 'integrated' + ? formatResult(executionId, completedRun.status, completedRun.data, allOutputs) + : await extractResult(executionId, allOutputs); if (isWorkflowToolResponse(webhookResponse)) { const response = await Container.get(WebhookResponseRelay).restoreOffloadedBody( webhookResponse, @@ -407,22 +424,14 @@ function normaliseExecutionStatus(status: string | undefined): string { } /** Extract the JSON items produced by the last run of a node. */ -function outputItemsFromNodeRuns( - nodeRuns: Array<{ data?: { main?: Array> } }>, -): unknown[] { +function outputItemsFromNodeRuns(nodeRuns: ITaskData[]): unknown[] { const lastRun = nodeRuns[nodeRuns.length - 1]; if (!lastRun?.data?.main) return []; - return lastRun.data.main - .flat() - .filter((item): item is NonNullable => item !== null && item !== undefined) - .map((item) => item.json); + return lastRun.data.main.flatMap((items) => items ?? []).map((item) => item.json); } /** Build the resultData map from an execution's runData, scoped by `allOutputs`. */ -function collectResultData( - runData: Record[0]>, - allOutputs: boolean, -): Record { +function collectResultData(runData: IRunData, allOutputs: boolean): Record { const resultData: Record = {}; if (allOutputs) { @@ -446,6 +455,23 @@ function collectResultData( return resultData; } +function formatResult( + executionId: string, + status: string | undefined, + data: IRun['data'] | undefined, + allOutputs: boolean, +) { + const runData = data?.resultData?.runData; + const resultData = runData ? collectResultData(runData, allOutputs) : {}; + + return { + executionId, + status: normaliseExecutionStatus(status), + data: Object.keys(resultData).length > 0 ? truncateResultData(resultData) : undefined, + error: data?.resultData?.error?.message, + }; +} + export async function extractResult( executionId: string, allOutputs: boolean, @@ -464,20 +490,7 @@ export async function extractResult( return { executionId, status: 'unknown' }; } - const runData = execution.data?.resultData?.runData; - const resultData = runData - ? collectResultData( - runData as Record[0]>, - allOutputs, - ) - : {}; - - return { - executionId, - status: normaliseExecutionStatus(execution.status), - data: Object.keys(resultData).length > 0 ? truncateResultData(resultData) : undefined, - error: execution.data?.resultData?.error?.message, - }; + return formatResult(executionId, execution.status, execution.data, allOutputs); } // --------------------------------------------------------------------------- @@ -588,21 +601,18 @@ async function buildWorkflowTool( context: WorkflowToolContext, ): Promise { const workflowName = descriptor.workflow; - - // Access control is project sharing: the - // workflow must be shared with the agent's project. - const candidateWorkflow = await findWorkflowToolWorkflow( - context.workflowRepository, - descriptor, + const initialReference: WorkflowToolWorkflowReference = { + workflowName, + ...(descriptor.workflowId !== undefined ? { workflowId: descriptor.workflowId } : {}), + }; + const workflow = await context.workflowLoader.loadPublishedWorkflow( context.projectId, + initialReference, ); - - if (!candidateWorkflow) { + if (!workflow) { throw new Error(`Workflow "${workflowName}" not found`); } - const workflow = candidateWorkflow; - validateCompatibility(workflow); const { node: triggerNode, triggerType } = detectTriggerNode(workflow); @@ -615,18 +625,14 @@ async function buildWorkflowTool( const toolDescription = descriptor.description ?? `Execute the "${workflowName}" workflow`; const inputSchema = inferInputSchema(triggerNode, triggerType); const allOutputs = descriptor.allOutputs ?? false; + const reference: WorkflowToolWorkflowReference = { + workflowId: workflow.id, + workflowName: workflow.name, + }; // Form triggers return a link — the user fills out the form in their browser, // and the workflow executes independently when they submit. if (triggerType === 'form') { - const formPath = - (triggerNode.parameters?.path as string) ?? - ((triggerNode.parameters?.options as Record)?.path as string) ?? - triggerNode.webhookId ?? - workflow.id; - const baseUrl = (context.webhookBaseUrl ?? 'http://localhost:5678/').replace(/\/$/, ''); - const formUrl = `${baseUrl}/form/${formPath}`; - const builder = new Tool(toolName) .description( toolDescription === `Execute the "${workflowName}" workflow` @@ -634,19 +640,38 @@ async function buildWorkflowTool( : toolDescription, ) .input(inputSchema) + .output( + z.object({ + status: z.literal('form_link_sent'), + formUrl: z.string(), + message: z.string(), + }), + ) .toMessage( - () => + (output) => ({ type: 'custom', components: [ - { type: 'section', text: `📋 *<${formUrl}|Click here to open the form>*` }, + { + type: 'section', + text: `📋 *<${output.formUrl}|Click here to open the form>*`, + }, ], }) as never, ) - // eslint-disable-next-line @typescript-eslint/require-await -- Tool.handler() expects an async callback .handler(async (input: Record) => { - const reason = (input.reason as string) ?? `Please fill out the ${workflowName} form`; - return { status: 'form_link_sent', formUrl, message: reason }; + const current = await loadCurrentPublishedWorkflow(context, reference, triggerType); + const parsedInput = inferInputSchema(current.triggerNode, current.triggerType).parse(input); + const formUrl = getFormUrl(current.workflow, current.triggerNode, context.webhookBaseUrl); + const reason = parsedInput.reason; + return { + status: 'form_link_sent' as const, + formUrl, + message: + typeof reason === 'string' + ? reason + : `Please fill out the ${current.workflow.name} form`, + }; }); const built = builder.build(); @@ -674,11 +699,13 @@ async function buildWorkflowTool( }), ) .handler(async (input: Record) => { + const current = await loadCurrentPublishedWorkflow(context, reference, triggerType); + const parsedInput = inferInputSchema(current.triggerNode, current.triggerType).parse(input); return await executeWorkflow( - workflow, - triggerNode, - triggerType, - input, + current.workflow, + current.triggerNode, + current.triggerType, + parsedInput, context, allOutputs, toolName, @@ -697,6 +724,45 @@ async function buildWorkflowTool( }; } +async function loadCurrentPublishedWorkflow( + context: WorkflowToolContext, + reference: WorkflowToolWorkflowReference, + expectedTriggerType: string, +) { + const workflow = await context.workflowLoader.loadPublishedWorkflow(context.projectId, reference); + if (!workflow) { + throw new Error(`Workflow "${reference.workflowName}" is no longer published or accessible`); + } + + validateCompatibility(workflow); + const { node: triggerNode, triggerType } = detectTriggerNode(workflow); + if (triggerType !== expectedTriggerType) { + throw new Error( + `Workflow "${reference.workflowName}" changed trigger type from ${expectedTriggerType} to ${triggerType}`, + ); + } + + return { workflow, triggerNode, triggerType }; +} + +function getFormUrl( + workflow: WorkflowEntity, + triggerNode: INode, + webhookBaseUrl: string | undefined, +): string { + const directPath = triggerNode.parameters?.path; + const options: unknown = triggerNode.parameters?.options; + const optionPath = isRecord(options) ? options.path : undefined; + const formPath = + typeof directPath === 'string' + ? directPath + : typeof optionPath === 'string' + ? optionPath + : (triggerNode.webhookId ?? workflow.id); + const baseUrl = (webhookBaseUrl ?? 'http://localhost:5678/').replace(/\/$/, ''); + return `${baseUrl}/form/${formPath}`; +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/packages/cli/src/modules/agents/tools/workflow-tool-workflow-loader.service.ts b/packages/cli/src/modules/agents/tools/workflow-tool-workflow-loader.service.ts new file mode 100644 index 00000000000..d03ad07afb3 --- /dev/null +++ b/packages/cli/src/modules/agents/tools/workflow-tool-workflow-loader.service.ts @@ -0,0 +1,51 @@ +import { WorkflowsConfig } from '@n8n/config'; +import { type WorkflowEntity, WorkflowRepository } from '@n8n/db'; +import { Service } from '@n8n/di'; + +import { WorkflowPublishedDataService } from '@/workflows/workflow-published-data.service'; + +export interface WorkflowToolWorkflowReference { + workflowId?: string; + workflowName: string; +} + +@Service() +export class WorkflowToolWorkflowLoader { + constructor( + private readonly workflowsConfig: WorkflowsConfig, + private readonly workflowRepository: WorkflowRepository, + private readonly workflowPublishedDataService: WorkflowPublishedDataService, + ) {} + + async loadPublishedWorkflow( + projectId: string, + reference: WorkflowToolWorkflowReference, + ): Promise { + const accessibleWorkflow = await this.workflowRepository.findOneByAgentToolReference( + projectId, + reference, + ); + if (!accessibleWorkflow || accessibleWorkflow.isArchived) return null; + + if (this.workflowsConfig.useWorkflowPublicationService) { + const publishedWorkflow = + await this.workflowPublishedDataService.getPublishedWorkflowDataForExecution( + accessibleWorkflow.id, + ); + if (!publishedWorkflow || publishedWorkflow.isArchived) return null; + + return Object.assign(accessibleWorkflow, publishedWorkflow, { pinData: undefined }); + } + + const workflow = await this.workflowRepository.findById(accessibleWorkflow.id); + if (!workflow || workflow.isArchived || !workflow.activeVersion) return null; + + return Object.assign(workflow, { + versionId: workflow.activeVersion.versionId, + nodes: workflow.activeVersion.nodes, + connections: workflow.activeVersion.connections, + nodeGroups: workflow.activeVersion.nodeGroups, + pinData: undefined, + }); + } +} From 4493e5a8b82e16e7a62a8dd264a083304290b1f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20G=C3=B3mez=20Morales?= Date: Mon, 10 Aug 2026 12:02:39 +0200 Subject: [PATCH 015/151] fix: Skip setup cards for credentials the workflow already settled (#35701) --- .../instance-ai/src/tools/credentials.tool.ts | 21 +-- .../__tests__/resolve-credentials.test.ts | 151 ++++++++++++++++++ .../__tests__/setup-workflow.service.test.ts | 14 +- .../src/tools/workflows/credential-utils.ts | 19 ++- .../tools/workflows/resolve-credentials.ts | 55 ++++++- .../tools/workflows/setup-workflow.service.ts | 15 +- 6 files changed, 243 insertions(+), 32 deletions(-) diff --git a/packages/@n8n/instance-ai/src/tools/credentials.tool.ts b/packages/@n8n/instance-ai/src/tools/credentials.tool.ts index 9197f288405..57b7d7acb34 100644 --- a/packages/@n8n/instance-ai/src/tools/credentials.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/credentials.tool.ts @@ -14,7 +14,11 @@ import { z } from 'zod'; import { sanitizeInputSchema } from '../agent/sanitize-mcp-schemas'; import type { InstanceAiContext } from '../types'; import { CREDENTIALS_TOOL_ID } from './tool-ids'; -import { extractServiceHost, N8N_CONNECT_DISPLAY_NAME } from './workflows/credential-utils'; +import { + extractServiceHost, + GENERIC_AUTH_CREDENTIAL_TYPES, + N8N_CONNECT_DISPLAY_NAME, +} from './workflows/credential-utils'; // ── Constants ────────────────────────────────────────────────────────────── @@ -22,19 +26,6 @@ export { CREDENTIALS_TOOL_ID }; const DEFAULT_LIMIT = 50; -/** Generic auth types that should be excluded from search results — the AI should prefer dedicated types. */ -const GENERIC_AUTH_TYPES = new Set([ - 'httpHeaderAuth', - 'httpBearerAuth', - 'httpQueryAuth', - 'httpBasicAuth', - 'httpCustomAuth', - 'httpTemplatedCustomAuth', - 'httpDigestAuth', - 'oAuth1Api', - 'oAuth2Api', -]); - // ── Shared fields (single source of truth for fields used across actions) ─── const credentialIdField = z.string().describe('Credential ID'); @@ -579,7 +570,7 @@ async function handleSearchTypes( const allResults = await context.credentialService.searchCredentialTypes(input.query); // Filter out generic auth types — the AI should use dedicated types - const results = allResults.filter((r) => !GENERIC_AUTH_TYPES.has(r.type)); + const results = allResults.filter((r) => !GENERIC_AUTH_CREDENTIAL_TYPES.has(r.type)); if (results.length === 0) { return { 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 c0cfa532b97..9841a3ed450 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 @@ -513,6 +513,157 @@ describe('resolveCredentials', () => { }); }); + describe('sibling node credential reuse', () => { + function makeNotionNode(name: string, credentials: Record) { + return { + id: name, + name, + type: 'n8n-nodes-base.notion', + typeVersion: 2, + position: [0, 0] as [number, number], + credentials: credentials as unknown as { [key: string]: { id: string; name: string } }, + }; + } + + const twoNotionCreds = makeCredentialMap([ + { id: 'cred-1', name: 'Notion main', type: 'notionApi' }, + { id: 'cred-2', name: 'Notion other', type: 'notionApi' }, + ]); + + it('reuses the credential bound to a sibling node of the same type', async () => { + const json = makeWorkflow({ + nodes: [ + makeNotionNode('Notion', { notionApi: { id: 'cred-1', name: 'Notion main' } }), + makeNotionNode('Notion 2', { notionApi: undefined }), + ], + }); + + const result = await resolveCredentials(json, undefined, createMockContext(), twoNotionCreds); + + expect(result.mockedNodeNames).toEqual([]); + expect(json.nodes[1].credentials).toEqual({ + notionApi: { id: 'cred-1', name: 'Notion main' }, + }); + expect(result.resolvedCredentialsByNode['Notion 2']).toEqual([ + { type: 'notionApi', id: 'cred-1', name: 'Notion main' }, + ]); + }); + + it('reuses a saved-workflow binding for a node the build added', async () => { + const json = makeWorkflow({ + nodes: [makeNotionNode('Notion 2', { notionApi: undefined })], + }); + const existingWorkflow = makeWorkflow({ + nodes: [makeNotionNode('Notion', { notionApi: { id: 'cred-1', name: 'Notion main' } })], + }); + + const result = await resolveCredentials( + json, + 'wf-123', + createMockContext(existingWorkflow), + twoNotionCreds, + ); + + expect(result.mockedNodeNames).toEqual([]); + expect(json.nodes[0].credentials).toEqual({ + notionApi: { id: 'cred-1', name: 'Notion main' }, + }); + }); + + it('rebinds an unknown raw id to the sibling credential instead of mocking', async () => { + const json = makeWorkflow({ + nodes: [ + makeNotionNode('Notion', { notionApi: { id: 'cred-1', name: 'Notion main' } }), + makeNotionNode('Notion 2', { notionApi: { id: 'fake-id', name: 'Made up' } }), + ], + }); + + const result = await resolveCredentials(json, undefined, createMockContext(), twoNotionCreds); + + expect(result.mockedNodeNames).toEqual([]); + expect(json.nodes[1].credentials).toEqual({ + notionApi: { id: 'cred-1', name: 'Notion main' }, + }); + }); + + it('does not reuse a sibling binding whose id is not stored', async () => { + const json = makeWorkflow({ + nodes: [ + makeNotionNode('Notion', { notionApi: { id: 'cred-gone', name: 'Stale' } }), + makeNotionNode('Notion 2', { notionApi: undefined }), + ], + }); + + const result = await resolveCredentials(json, undefined, createMockContext(), twoNotionCreds); + + expect(result.mockedNodeNames).toContain('Notion 2'); + expect(json.nodes[1].credentials).toEqual({}); + }); + + it('does not reuse a sibling Templated Custom Auth credential across nodes', async () => { + // One shared type serves every service — the sibling's key may belong to + // a different service, so the slot must route to setup instead. + const json = makeWorkflow({ + nodes: [ + makeNotionNode('Call Pexels', { + httpTemplatedCustomAuth: { id: 'cred-pexels', name: 'Pexels API' }, + }), + makeNotionNode('Call fal.ai', { httpTemplatedCustomAuth: undefined }), + ], + }); + const credentials = makeCredentialMap([ + { id: 'cred-pexels', name: 'Pexels API', type: 'httpTemplatedCustomAuth' }, + { id: 'cred-fal', name: 'fal.ai API', type: 'httpTemplatedCustomAuth' }, + ]); + + const result = await resolveCredentials(json, undefined, createMockContext(), credentials); + + expect(result.mockedNodeNames).toContain('Call fal.ai'); + expect(json.nodes[1].credentials).toEqual({}); + }); + + it.each(['httpBearerAuth', 'oAuth2Api'])( + 'does not reuse a sibling %s credential across nodes', + async (credentialType) => { + // Same shared-type rule as Templated Custom Auth: the binding on one + // node may belong to a different service than the new node calls. + const json = makeWorkflow({ + nodes: [ + makeNotionNode('Call service A', { + [credentialType]: { id: 'cred-a', name: 'Service A auth' }, + }), + makeNotionNode('Call service B', { [credentialType]: undefined }), + ], + }); + const credentials = makeCredentialMap([ + { id: 'cred-a', name: 'Service A auth', type: credentialType }, + { id: 'cred-b', name: 'Service B auth', type: credentialType }, + ]); + + const result = await resolveCredentials(json, undefined, createMockContext(), credentials); + + expect(result.mockedNodeNames).toContain('Call service B'); + expect(json.nodes[1].credentials).toEqual({}); + }, + ); + + it('ignores gateway-managed sibling markers', async () => { + const json = makeWorkflow({ + nodes: [ + makeNotionNode('Notion', { + notionApi: { id: null, name: 'n8n Connect', __aiGatewayManaged: true }, + }), + makeNotionNode('Notion 2', { notionApi: undefined }), + ], + }); + + const result = await resolveCredentials(json, undefined, createMockContext(), twoNotionCreds); + + expect(result.mockedNodeNames).toContain('Notion 2'); + expect(json.nodes[1].credentials).toEqual({}); + }); + }); + describe('credential mocking', () => { it('mocks unresolved credentials and preserves existing pinData', async () => { const json = makeWorkflow({ diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts index 6e43da243fd..3378acab1e3 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts @@ -952,7 +952,7 @@ describe('analyzeWorkflow', () => { expect(result[0].needsAction).toBe(true); }); - it('keeps testable trigger requests even when their credential is already valid', async () => { + it('hides settled trigger requests whose credential is already valid', async () => { const trigger = makeNode({ name: 'Webhook', type: 'n8n-nodes-base.webhook', @@ -974,10 +974,14 @@ describe('analyzeWorkflow', () => { const result = await analyzeWorkflow(context, 'wf-1'); - expect(result).toHaveLength(1); - expect(result[0].isTrigger).toBe(true); - expect(result[0].isTestable).toBe(true); - expect(result[0].needsAction).toBe(false); + expect(result).toHaveLength(0); + + const settled = await analyzeWorkflow(context, 'wf-1', undefined, { includeSettled: true }); + + expect(settled).toHaveLength(1); + expect(settled[0].isTrigger).toBe(true); + expect(settled[0].isTestable).toBe(true); + expect(settled[0].needsAction).toBe(false); }); it('keeps requests with parameter issues regardless of credential validity', async () => { diff --git a/packages/@n8n/instance-ai/src/tools/workflows/credential-utils.ts b/packages/@n8n/instance-ai/src/tools/workflows/credential-utils.ts index b303fe9b133..2967f8b4148 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/credential-utils.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/credential-utils.ts @@ -1,8 +1,25 @@ -import { AI_GATEWAY_MANAGED_TAG } from '@n8n/api-types'; +import { AI_GATEWAY_MANAGED_TAG, TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE } from '@n8n/api-types'; import type { NodeJSON } from '@n8n/workflow-sdk'; import type { InstanceAiContext } from '../../types'; +/** + * The HTTP Request node's generic auth family: one credential type serves many + * services, so the type alone never identifies a service. Kept out of + * search-by-service results and out of cross-node credential reuse. + */ +export const GENERIC_AUTH_CREDENTIAL_TYPES: ReadonlySet = new Set([ + TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE, + 'httpHeaderAuth', + 'httpBearerAuth', + 'httpQueryAuth', + 'httpBasicAuth', + 'httpDigestAuth', + 'httpCustomAuth', + 'oAuth1Api', + 'oAuth2Api', +]); + export interface AiGatewayCredential { id: null; name: string; 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 4ab1165c437..2f243944a38 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/resolve-credentials.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/resolve-credentials.ts @@ -10,7 +10,11 @@ import { TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE } from '@n8n/api-types'; import type { NodeJSON, WorkflowJSON } from '@n8n/workflow-sdk'; -import { AI_GATEWAY_CREDENTIAL, N8N_CONNECT_DISPLAY_NAME } from './credential-utils'; +import { + AI_GATEWAY_CREDENTIAL, + GENERIC_AUTH_CREDENTIAL_TYPES, + N8N_CONNECT_DISPLAY_NAME, +} from './credential-utils'; import type { ResolvedCredential } from './resolved-credential.schema'; import { getCredentialActivationParameters, @@ -199,6 +203,34 @@ export async function resolveCredentials( } } + // First stored-credential binding per type, across the in-flight JSON and the + // saved workflow, so per-slot sibling reuse below is a map lookup instead of a + // rescan of every node. Bindings created during resolution register themselves + // so they stay visible to later slots of the same type. + const siblingBindingsByType = new Map(); + const registerSiblingBinding = (credentialType: string, value: unknown) => { + // A generic-auth binding on a sibling node may belong to a different + // service than this node calls, so it never qualifies for reuse. + // (Templated Custom Auth instances do record their serviceHost, but + // matching it against the node's target URL is the setup card's job.) + if (GENERIC_AUTH_CREDENTIAL_TYPES.has(credentialType)) return; + if (siblingBindingsByType.has(credentialType)) return; + const id = getCredentialId(value); + if (!id) return; + if (!isKnownCredentialForType(value, credentialType, availableCredentials)) return; + siblingBindingsByType.set(credentialType, { id, name: getCredentialName(value) ?? id }); + }; + for (const node of json.nodes ?? []) { + for (const [credentialType, value] of Object.entries(node.credentials ?? {})) { + registerSiblingBinding(credentialType, value); + } + } + for (const savedCreds of existingCredsByNode.values()) { + for (const [credentialType, value] of Object.entries(savedCreds)) { + registerSiblingBinding(credentialType, value); + } + } + for (const node of json.nodes ?? []) { if (!node.credentials) continue; const creds = node.credentials as Record; @@ -228,6 +260,19 @@ export async function resolveCredentials( return true; }; + // Try 2: reuse a credential of the same type already bound to another + // node (in the in-flight JSON or the saved workflow). The workflow has + // already settled on that credential for the service, so a new node of + // the same service must not re-prompt setup for it. + const reuseSiblingNodeCredential = () => { + const sibling = siblingBindingsByType.get(key); + if (!sibling) return false; + creds[key] = { id: sibling.id, name: sibling.name }; + recordResolvedCredential(sibling.id, sibling.name); + cleanupMockPinData(json, node.name); + return true; + }; + const mockCredential = () => { const nodeName = node.name ?? ''; delete creds[key]; @@ -314,6 +359,9 @@ export async function resolveCredentials( if (restoreExistingCredential()) { continue; } + if (reuseSiblingNodeCredential()) { + continue; + } await mockOrAttachGateway(); continue; } @@ -322,6 +370,10 @@ export async function resolveCredentials( continue; } + if (reuseSiblingNodeCredential()) { + continue; + } + // The sole-credential fallback is skipped for Templated Custom Auth: the // type is shared by every service, so "the only stored credential" may // belong to a different service — mock instead so setup asks. @@ -329,6 +381,7 @@ export async function resolveCredentials( if (credentialsForType?.length === 1 && key !== TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE) { const [credential] = credentialsForType; creds[key] = { id: credential.id, name: credential.name }; + registerSiblingBinding(key, creds[key]); recordResolvedCredential(credential.id, credential.name); cleanupMockPinData(json, node.name); continue; diff --git a/packages/@n8n/instance-ai/src/tools/workflows/setup-workflow.service.ts b/packages/@n8n/instance-ai/src/tools/workflows/setup-workflow.service.ts index b087a685897..f3b8b96a3d5 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/setup-workflow.service.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/setup-workflow.service.ts @@ -1462,16 +1462,11 @@ export async function analyzeWorkflow( req.isTrigger || (req.parameterIssues && Object.keys(req.parameterIssues).length > 0), ) - // Hide cards the user has nothing to do on: credentials already set, - // no parameter issues, not a trigger awaiting testing. Trigger steps - // are always kept — triggers require user testing regardless of - // credential state. - .filter( - (req) => - options?.includeSettled === true || - !!req.needsAction || - (req.isTrigger && !!req.isTestable), - ); + // Hide cards the user has nothing to do on: credentials already set + // and no parameter issues. Settled trigger requests are hidden too — + // no wizard step acts on them, so keeping them only renders an + // already-complete credential card as if it were pending work. + .filter((req) => options?.includeSettled === true || !!req.needsAction); sortByExecutionOrder( setupRequests, From e743620e885fdccbbbf5299ffa6da05a6b9de39a Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Mon, 10 Aug 2026 07:08:27 -0400 Subject: [PATCH 016/151] test(core): Guard log streaming event names against known event groups (#35656) --- .../__tests__/event-names.test.ts | 36 +++++++++++++++++++ .../eventbus/event-message-classes/index.ts | 15 ++++++++ 2 files changed, 51 insertions(+) create mode 100644 packages/cli/src/eventbus/event-message-classes/__tests__/event-names.test.ts diff --git a/packages/cli/src/eventbus/event-message-classes/__tests__/event-names.test.ts b/packages/cli/src/eventbus/event-message-classes/__tests__/event-names.test.ts new file mode 100644 index 00000000000..b775e704253 --- /dev/null +++ b/packages/cli/src/eventbus/event-message-classes/__tests__/event-names.test.ts @@ -0,0 +1,36 @@ +import { eventNamesAll } from '../index'; + +/** + * Every log-streaming event group in existence. The destination UI derives its + * group checkboxes from the first two segments of the event name, and external + * consumers (SIEM and compliance pipelines) filter the audit trail by the + * `n8n.audit.` prefix. + */ +const KNOWN_EVENT_GROUPS = [ + 'n8n.audit.', + 'n8n.workflow.', + 'n8n.node.', + 'n8n.worker.', + 'n8n.ai.', + 'n8n.runner.', + 'n8n.queue.', + 'n8n.execution.', +]; + +describe('log streaming event names', () => { + it('should only contain names under a known event group prefix', () => { + const offenders = eventNamesAll.filter( + (eventName) => !KNOWN_EVENT_GROUPS.some((prefix) => eventName.startsWith(prefix)), + ); + + expect( + offenders, + 'Event names are a public contract with log-streaming consumers and cannot be renamed once released. ' + + 'Events that record a user action belong under the `n8n.audit.` prefix: SIEM pipelines filter the ' + + 'audit trail by that prefix, and a new top-level prefix creates its own opt-in group in the ' + + 'destination UI (see `eventNamesMcp` for a group with its own payload shape named under the audit ' + + 'prefix). Only deliberately introduce a new operational group with sign-off from the owning team, ' + + 'then add its prefix to KNOWN_EVENT_GROUPS.', + ).toEqual([]); + }); +}); diff --git a/packages/cli/src/eventbus/event-message-classes/index.ts b/packages/cli/src/eventbus/event-message-classes/index.ts index db77dca6be2..08e989b7eae 100644 --- a/packages/cli/src/eventbus/event-message-classes/index.ts +++ b/packages/cli/src/eventbus/event-message-classes/index.ts @@ -8,6 +8,21 @@ import type { EventMessageQueue } from './event-message-queue'; import type { EventMessageRunner } from './event-message-runner'; import type { EventMessageWorkflow } from './event-message-workflow'; +/** + * Naming rules for log streaming events: + * + * Event names are a public contract with log streaming consumers and cannot be + * renamed once released. Events that record a user action (logins, grants, + * tool calls, settings changes) must be named under `n8n.audit.`: external + * pipelines filter the audit trail by that prefix, and the destination UI + * groups events by the first two name segments, so a new top-level prefix + * creates its own opt-in group outside the audit trail. A group can keep its + * own name list and message class while living under the audit prefix (see + * `eventNamesMcp`). Introducing a new operational group (like `n8n.runner.`) + * is a taxonomy decision that needs sign-off from the owning team; the guard + * test in `__tests__/event-names.test.ts` enforces this. + */ + export const eventNamesAiNodes = [ 'n8n.ai.memory.get.messages', 'n8n.ai.memory.added.message', From b6bb202b7b7680c556b123c52131bb35624ab7f2 Mon Sep 17 00:00:00 2001 From: Lorent Lempereur Date: Mon, 10 Aug 2026 13:20:37 +0200 Subject: [PATCH 017/151] refactor: Assign task runner IDs from n8n (#35494) --- .../src/config/task_runner_config.py | 6 ++ .../@n8n/task-runner-python/src/constants.py | 1 + .../task-runner-python/src/task_runner.py | 2 +- .../tests/unit/test_task_runner.py | 65 ++++++++------ .../src/config/base-runner-config.ts | 11 +++ .../__tests__/task-runner.test.ts | 24 +++++ packages/@n8n/task-runner/src/task-runner.ts | 3 +- .../__tests__/task-runner-process-py.test.ts | 21 +++++ .../__tests__/task-runner-process.test.ts | 49 +++++++++-- .../__tests__/task-broker-server.test.ts | 66 +++++++++++++- .../__tests__/task-broker-ws-server.test.ts | 87 ++++++++++++------- .../__tests__/task-broker.service.test.ts | 40 +++++---- .../task-broker-auth.controller.test.ts | 12 +++ .../task-broker-auth.service.test.ts | 59 ++++++++++--- .../auth/task-broker-auth.controller.ts | 17 ++-- .../auth/task-broker-auth.service.ts | 31 +++++-- .../task-broker/task-broker-server.ts | 30 ++++++- .../task-broker/task-broker-ws-server.ts | 28 ++++-- .../task-broker/task-broker.service.ts | 18 ++-- .../task-runner-lifecycle-events.ts | 2 +- .../task-runners/task-runner-process-base.ts | 39 +++++---- .../task-runners/task-runner-process-js.ts | 9 +- .../task-runners/task-runner-process-py.ts | 5 +- 23 files changed, 467 insertions(+), 158 deletions(-) diff --git a/packages/@n8n/task-runner-python/src/config/task_runner_config.py b/packages/@n8n/task-runner-python/src/config/task_runner_config.py index e1085af5cca..ea9459dda75 100644 --- a/packages/@n8n/task-runner-python/src/config/task_runner_config.py +++ b/packages/@n8n/task-runner-python/src/config/task_runner_config.py @@ -17,6 +17,7 @@ from src.constants import ( ENV_GRANT_TOKEN, ENV_MAX_CONCURRENCY, ENV_MAX_PAYLOAD_SIZE, + ENV_RUNNER_ID, ENV_STDLIB_ALLOW, ENV_TASK_BROKER_URI, ENV_TASK_TIMEOUT, @@ -48,6 +49,10 @@ def parse_allowlist(allowlist_str: str, list_name: str) -> set[str]: @dataclass class TaskRunnerConfig: grant_token: str + # Empty to self-assign an ID, the default in `external` mode where no one else + # knows this runner beforehand. Must be unique per runner when set: the broker keys + # connections by runner ID, so two runners sharing one keep evicting each other. + runner_id: str task_broker_uri: str max_concurrency: int max_payload_size: int @@ -102,6 +107,7 @@ class TaskRunnerConfig: return cls( grant_token=grant_token, + runner_id=read_str_env(ENV_RUNNER_ID, ""), task_broker_uri=read_str_env(ENV_TASK_BROKER_URI, DEFAULT_TASK_BROKER_URI), max_concurrency=read_int_env(ENV_MAX_CONCURRENCY, DEFAULT_MAX_CONCURRENCY), max_payload_size=max_payload_size, diff --git a/packages/@n8n/task-runner-python/src/constants.py b/packages/@n8n/task-runner-python/src/constants.py index 345b421cb5a..50ab0e756d7 100644 --- a/packages/@n8n/task-runner-python/src/constants.py +++ b/packages/@n8n/task-runner-python/src/constants.py @@ -62,6 +62,7 @@ DEFAULT_HEALTH_CHECK_SERVER_PORT = 5681 # Env vars ENV_TASK_BROKER_URI = "N8N_RUNNERS_TASK_BROKER_URI" ENV_GRANT_TOKEN = "N8N_RUNNERS_GRANT_TOKEN" +ENV_RUNNER_ID = "N8N_RUNNERS_ID" ENV_MAX_CONCURRENCY = "N8N_RUNNERS_MAX_CONCURRENCY" ENV_MAX_PAYLOAD_SIZE = "N8N_RUNNERS_MAX_PAYLOAD" ENV_TASK_TIMEOUT = "N8N_RUNNERS_TASK_TIMEOUT" diff --git a/packages/@n8n/task-runner-python/src/task_runner.py b/packages/@n8n/task-runner-python/src/task_runner.py index 2da8afb9113..8f8791c1a05 100644 --- a/packages/@n8n/task-runner-python/src/task_runner.py +++ b/packages/@n8n/task-runner-python/src/task_runner.py @@ -76,7 +76,7 @@ class TaskRunner: self, config: TaskRunnerConfig, ): - self.runner_id = nanoid() + self.runner_id = config.runner_id or nanoid() self.name = RUNNER_NAME self.config = config diff --git a/packages/@n8n/task-runner-python/tests/unit/test_task_runner.py b/packages/@n8n/task-runner-python/tests/unit/test_task_runner.py index 94fd7fec4d4..43a97d7f24f 100644 --- a/packages/@n8n/task-runner-python/tests/unit/test_task_runner.py +++ b/packages/@n8n/task-runner-python/tests/unit/test_task_runner.py @@ -9,23 +9,45 @@ from src.task_runner import TaskRunner from src.config.task_runner_config import TaskRunnerConfig +def make_config(**overrides): + defaults = { + "grant_token": "test-token", + "runner_id": "", + "task_broker_uri": "http://127.0.0.1:5679", + "max_concurrency": 5, + "max_payload_size": 1024 * 1024, + "task_timeout": 60, + "auto_shutdown_timeout": 0, + "graceful_shutdown_timeout": 10, + "stdlib_allow": {"*"}, + "external_allow": {"*"}, + "builtins_deny": set(), + "env_deny": False, + "allow_transitive_imports": False, + } + return TaskRunnerConfig(**{**defaults, **overrides}) + + +class TestTaskRunnerId: + def test_identifies_as_assigned_runner_id(self): + runner = TaskRunner(make_config(runner_id="assigned-by-n8n")) + + assert runner.runner_id == "assigned-by-n8n" + assert runner.websocket_url.endswith("?id=assigned-by-n8n") + + def test_self_assigns_unique_runner_id_when_assigned_none(self): + first = TaskRunner(make_config(runner_id="")) + second = TaskRunner(make_config(runner_id="")) + + assert first.runner_id != "" + assert second.runner_id != first.runner_id + assert first.websocket_url.endswith(f"?id={first.runner_id}") + + class TestTaskRunnerConnectionRetry: @pytest.fixture def config(self): - return TaskRunnerConfig( - grant_token="test-token", - task_broker_uri="http://127.0.0.1:5679", - max_concurrency=5, - max_payload_size=1024 * 1024, - task_timeout=60, - auto_shutdown_timeout=0, - graceful_shutdown_timeout=10, - stdlib_allow={"*"}, - external_allow={"*"}, - builtins_deny=set(), - env_deny=False, - allow_transitive_imports=False, - ) + return make_config() @pytest.mark.asyncio async def test_connection_failure_logs_warning_not_crash(self, config): @@ -76,20 +98,7 @@ class TestTaskRunnerConnectionRetry: class TestTaskRunnerDrain: @pytest.fixture def config(self): - return TaskRunnerConfig( - grant_token="test-token", - task_broker_uri="http://127.0.0.1:5679", - max_concurrency=5, - max_payload_size=1024 * 1024, - task_timeout=60, - auto_shutdown_timeout=0, - graceful_shutdown_timeout=10, - stdlib_allow={"*"}, - external_allow={"*"}, - builtins_deny=set(), - env_deny=False, - allow_transitive_imports=False, - ) + return make_config() @pytest.mark.asyncio async def test_drain_stops_sending_offers(self, config): diff --git a/packages/@n8n/task-runner/src/config/base-runner-config.ts b/packages/@n8n/task-runner/src/config/base-runner-config.ts index 843c7dd2f3e..eee38da4b98 100644 --- a/packages/@n8n/task-runner/src/config/base-runner-config.ts +++ b/packages/@n8n/task-runner/src/config/base-runner-config.ts @@ -20,6 +20,17 @@ export class BaseRunnerConfig { @Env('N8N_RUNNERS_GRANT_TOKEN') grantToken: string = ''; + /** + * ID to identify as to the task broker. Empty to self-assign one, which is the + * default in `external` mode, where no one else knows this runner beforehand. + * + * Must be unique per runner when set: the broker keys connections by runner ID, so + * two runners sharing one keep evicting each other's connection. In `internal` mode + * n8n sets this itself, per runner it spawns. + */ + @Env('N8N_RUNNERS_ID') + runnerId: string = ''; + @Env('N8N_RUNNERS_MAX_PAYLOAD') maxPayloadSize: number = 1024 * 1024 * 1024; diff --git a/packages/@n8n/task-runner/src/js-task-runner/__tests__/task-runner.test.ts b/packages/@n8n/task-runner/src/js-task-runner/__tests__/task-runner.test.ts index bf650cc5141..681e45b7a9b 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/__tests__/task-runner.test.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/__tests__/task-runner.test.ts @@ -16,6 +16,7 @@ describe('TestRunner', () => { const newTestRunner = (opts: Partial = {}) => new TestRunner({ taskType: 'test-task', + runnerId: '', maxConcurrency: 5, idleTimeout: 60, grantToken: 'test-token', @@ -81,6 +82,29 @@ describe('TestRunner', () => { }), ).toThrowError(/Invalid URL/); }); + + it('should identify as the runner ID it was assigned', () => { + runner = newTestRunner({ runnerId: 'assigned-by-n8n' }); + + expect(runner.id).toBe('assigned-by-n8n'); + expect(WebSocket).toHaveBeenCalledWith( + 'ws://localhost:8080/runners/_ws?id=assigned-by-n8n', + expect.anything(), + ); + }); + + it('should self-assign a unique runner ID when assigned none', () => { + // `afterEach` only clears `runner`, so these two must start no idle timer + const first = newTestRunner({ runnerId: '', idleTimeout: 0 }); + const second = newTestRunner({ runnerId: '', idleTimeout: 0 }); + + expect(first.id).not.toBe(''); + expect(second.id).not.toBe(first.id); + expect(WebSocket).toHaveBeenCalledWith( + `ws://localhost:8080/runners/_ws?id=${first.id}`, + expect.anything(), + ); + }); }); describe('sendOffers', () => { diff --git a/packages/@n8n/task-runner/src/task-runner.ts b/packages/@n8n/task-runner/src/task-runner.ts index e45b76c1ca7..f5921eb9471 100644 --- a/packages/@n8n/task-runner/src/task-runner.ts +++ b/packages/@n8n/task-runner/src/task-runner.ts @@ -59,7 +59,7 @@ export interface TaskRunnerOpts extends BaseRunnerConfig { } export abstract class TaskRunner extends EventEmitter { - id: string = nanoid(); + id: string; ws: WebSocket; @@ -111,6 +111,7 @@ export abstract class TaskRunner extends EventEmitter { constructor(opts: TaskRunnerOpts) { super(); + this.id = opts.runnerId || nanoid(); this.taskType = opts.taskType; this.name = opts.name ?? 'Node.js Task Runner SDK'; this.maxConcurrency = opts.maxConcurrency; diff --git a/packages/cli/src/task-runners/__tests__/task-runner-process-py.test.ts b/packages/cli/src/task-runners/__tests__/task-runner-process-py.test.ts index 027ed658a3e..913fde8d6e8 100644 --- a/packages/cli/src/task-runners/__tests__/task-runner-process-py.test.ts +++ b/packages/cli/src/task-runners/__tests__/task-runner-process-py.test.ts @@ -63,6 +63,27 @@ describe('PyTaskRunnerProcess', () => { ); }); + it('should pass the runner ID it was assigned', async () => { + await taskRunnerProcess.start(); + + const { env } = spawnMock.mock.calls[0][2] as SpawnOptions; + expect(env!.N8N_RUNNERS_ID).toEqual(expect.stringMatching(/.+/)); + expect(authService.createGrantToken).toHaveBeenCalledWith(env!.N8N_RUNNERS_ID); + }); + + it('should not let an operator-set runner ID override the assigned one', async () => { + process.env.N8N_RUNNERS_ID = 'operator-set'; + + try { + await taskRunnerProcess.start(); + + const { env } = spawnMock.mock.calls[0][2] as SpawnOptions; + expect(env!.N8N_RUNNERS_ID).not.toBe('operator-set'); + } finally { + delete process.env.N8N_RUNNERS_ID; + } + }); + it('should build env with a null prototype', async () => { await taskRunnerProcess.start(); diff --git a/packages/cli/src/task-runners/__tests__/task-runner-process.test.ts b/packages/cli/src/task-runners/__tests__/task-runner-process.test.ts index 9fa9911681f..82409af3dae 100644 --- a/packages/cli/src/task-runners/__tests__/task-runner-process.test.ts +++ b/packages/cli/src/task-runners/__tests__/task-runner-process.test.ts @@ -161,6 +161,15 @@ describe('TaskRunnerProcess', () => { expect(Object.getPrototypeOf(options.env)).toBeNull(); }); + it('should bind the assigned runner ID to the grant token', async () => { + authService.createGrantToken.mockResolvedValue('grantToken'); + + await taskRunnerProcess.start(); + + const { env } = spawnMock.mock.calls[0][2] as SpawnOptions; + expect(authService.createGrantToken).toHaveBeenCalledWith(env!.N8N_RUNNERS_ID); + }); + it('should not inherit env keys from Object.prototype', async () => { authService.createGrantToken.mockResolvedValue('grantToken'); runnerConfig.maxOldSpaceSize = ''; @@ -479,22 +488,28 @@ describe('TaskRunnerProcess', () => { 'runner:unresponsive', ] as const; - const report = (event: (typeof RESTART_EVENTS)[number], taskTypes: string[]) => { - runnerLifecycleEvents.emit(event, { runnerId: 'runner1', taskTypes }); + /** ID the runner in the nth spawned process was told to identify as. */ + const assignedRunnerId = (spawnIndex: number) => { + const { env } = spawnMock.mock.calls[spawnIndex][2] as SpawnOptions; + return env!.N8N_RUNNERS_ID as string; }; - const reportUnresponsive = (taskTypes: string[]) => { - report('runner:unresponsive', taskTypes); + const report = (event: (typeof RESTART_EVENTS)[number], runnerId: string) => { + runnerLifecycleEvents.emit(event, { runnerId }); + }; + + const reportUnresponsive = (runnerId: string) => { + report('runner:unresponsive', runnerId); }; it.each(RESTART_EVENTS)( - 'should force-kill and relaunch on %s for a runner of its own task type', + 'should force-kill and relaunch on %s the runner it spawned', async (event) => { const child = createChildProcess(42); spawnMock.mockReturnValue(child); await taskRunnerProcess.start(); - report(event, ['javascript']); + report(event, assignedRunnerId(0)); // a runner with a blocked event loop never exits on its own expect(child.kill).toHaveBeenCalledWith('SIGKILL'); @@ -512,7 +527,7 @@ describe('TaskRunnerProcess', () => { await taskRunnerProcess.start(); const stopPromise = taskRunnerProcess.stop(); - reportUnresponsive(['javascript']); + reportUnresponsive(assignedRunnerId(0)); expect(child.kill).not.toHaveBeenCalledWith('SIGKILL'); @@ -523,16 +538,32 @@ describe('TaskRunnerProcess', () => { expect(spawnMock).toHaveBeenCalledTimes(1); }); - it.each(RESTART_EVENTS)('should ignore %s for a runner of another task type', async (event) => { + it.each(RESTART_EVENTS)('should ignore %s for another runner', async (event) => { const child = createChildProcess(42); spawnMock.mockReturnValue(child); await taskRunnerProcess.start(); - report(event, ['python']); + report(event, 'someone-elses-runner'); expect(child.kill).not.toHaveBeenCalled(); expect(spawnMock).toHaveBeenCalledTimes(1); }); + + it('should ignore a report for the runner it replaced', async () => { + const replaced = createChildProcess(42); + const relaunched = createChildProcess(43); + spawnMock.mockReturnValueOnce(replaced).mockReturnValueOnce(relaunched); + await taskRunnerProcess.start(); + const replacedRunnerId = assignedRunnerId(0); + + replaced.emit('exit', null); + await vi.advanceTimersByTimeAsync(0); + + reportUnresponsive(replacedRunnerId); + + expect(relaunched.kill).not.toHaveBeenCalled(); + expect(assignedRunnerId(1)).not.toBe(replacedRunnerId); + }); }); describe('restartRetryDelay', () => { diff --git a/packages/cli/src/task-runners/task-broker/__tests__/task-broker-server.test.ts b/packages/cli/src/task-runners/task-broker/__tests__/task-broker-server.test.ts index 2af044128b0..01620a23d76 100644 --- a/packages/cli/src/task-runners/task-broker/__tests__/task-broker-server.test.ts +++ b/packages/cli/src/task-runners/task-broker/__tests__/task-broker-server.test.ts @@ -1,3 +1,4 @@ +import type { Logger } from '@n8n/backend-common'; import type { GlobalConfig } from '@n8n/config'; import type { Socket } from 'node:net'; import request from 'supertest'; @@ -12,12 +13,13 @@ import type { TaskBrokerWsServer } from '@/task-runners/task-broker/task-broker- describe('TaskBrokerServer', () => { const createServer = (overrides?: { authController?: TaskBrokerAuthController; + logger?: Logger; }) => { const authController = overrides?.authController ?? mock(); const taskBrokerWsServer = mock(); const server = new TaskBrokerServer( - mock(), + overrides?.logger ?? mock(), mock({ taskRunners: { path: '/runners', authToken: 'token' }, endpoints: { health: '/health' }, @@ -201,5 +203,67 @@ describe('TaskBrokerServer', () => { expect(taskBrokerWsServer.add).toHaveBeenCalledWith('runner1', mockWs); expect(socket.destroy).not.toHaveBeenCalled(); }); + + describe('runner ID', () => { + const upgradeWith = async ( + reportedRunnerId: string, + boundRunnerId?: string, + logger = mock(), + ) => { + const authController = mock(); + authController.validateUpgradeRequest.mockResolvedValue({ + isValid: true, + statusCode: 200, + boundRunnerId, + }); + + const { server, taskBrokerWsServer } = createServer({ authController, logger }); + const mockWs = mock(); + const wsServerMock = mock(); + wsServerMock.handleUpgrade.mockImplementation( + (_req: unknown, _socket: unknown, _head: unknown, cb: (ws: WebSocket) => void) => { + cb(mockWs); + }, + ); + + // @ts-expect-error Private property + server.wsServer = wsServerMock; + + // @ts-expect-error Private property + await server.handleUpgradeRequest( + mock({ + url: `/runners/_ws?id=${reportedRunnerId}`, + headers: { authorization: 'Bearer valid-token' }, + }), + createSocket(), + Buffer.from(''), + ); + + return { taskBrokerWsServer, mockWs, logger }; + }; + + it('should register under the ID bound to the grant token', async () => { + const { taskBrokerWsServer, mockWs } = await upgradeWith('assigned-1', 'assigned-1'); + + expect(taskBrokerWsServer.add).toHaveBeenCalledWith('assigned-1', mockWs); + }); + + it('should prefer the bound ID over a self-assigned one and warn', async () => { + const { taskBrokerWsServer, mockWs, logger } = await upgradeWith( + 'self-assigned', + 'assigned-1', + ); + + expect(taskBrokerWsServer.add).toHaveBeenCalledWith('assigned-1', mockWs); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('N8N_RUNNERS_ID')); + }); + + it('should fall back to the reported ID when the token binds none', async () => { + const { taskBrokerWsServer, mockWs, logger } = await upgradeWith('external-1', undefined); + + expect(taskBrokerWsServer.add).toHaveBeenCalledWith('external-1', mockWs); + expect(logger.warn).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/packages/cli/src/task-runners/task-broker/__tests__/task-broker-ws-server.test.ts b/packages/cli/src/task-runners/task-broker/__tests__/task-broker-ws-server.test.ts index d6ff11d6946..9fa3ba69006 100644 --- a/packages/cli/src/task-runners/task-broker/__tests__/task-broker-ws-server.test.ts +++ b/packages/cli/src/task-runners/task-broker/__tests__/task-broker-ws-server.test.ts @@ -1,3 +1,4 @@ +import type { Logger } from '@n8n/backend-common'; import type { GlobalConfig, TaskRunnersConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; import { mock } from 'vitest-mock-extended'; @@ -26,31 +27,23 @@ const mockWs = (readyState = WS_OPEN, isAlive = true) => { return ws; }; -type KnownRunner = NonNullable['get']>>; - -const mockTaskBroker = (runners: Array<{ id: string; taskTypes: string[] }> = []) => { - const taskBroker = mock(); - taskBroker.getKnownRunners.mockReturnValue( - new Map(runners.map((runner) => [runner.id, { runner } as KnownRunner])), - ); - return taskBroker; -}; - const createServer = ({ - taskBroker = mockTaskBroker(), + taskBroker = mock(), disconnectAnalyzer = mock(), heartbeatInterval = 30, runnerLifecycleEvents = mock(), eventService = mock(), + logger = mock(), }: { taskBroker?: TaskBroker; disconnectAnalyzer?: DefaultTaskRunnerDisconnectAnalyzer; heartbeatInterval?: number; runnerLifecycleEvents?: TaskRunnerLifecycleEvents; eventService?: EventService; + logger?: Logger; } = {}) => new TaskBrokerWsServer( - mock(), + logger, taskBroker, disconnectAnalyzer, mock({ path: '/runners', heartbeatInterval, mode: 'internal' }), @@ -236,6 +229,53 @@ describe('TaskBrokerWsServer', () => { }); }); + describe('duplicate runner ID', () => { + const idIsAlreadyTaken = () => expect.stringContaining('N8N_RUNNERS_ID'); + + it('should warn when an ID a live runner holds is claimed again', async () => { + const logger = mock(); + const server = createServer({ logger }); + + await registerOverWs(server, 'test-runner', mockWs()); + await registerOverWs(server, 'test-runner', mockWs()); + + expect(logger.warn).toHaveBeenCalledWith(idIsAlreadyTaken()); + }); + + it('should not warn on a first registration', async () => { + const logger = mock(); + const server = createServer({ logger }); + + await registerOverWs(server, 'test-runner', mockWs()); + + expect(logger.warn).not.toHaveBeenCalledWith(idIsAlreadyTaken()); + }); + + it('should not warn when the holder is already closing, as on a reconnect', async () => { + const logger = mock(); + const server = createServer({ logger }); + + await registerOverWs(server, 'test-runner', mockWs(WS_CLOSING)); + await registerOverWs(server, 'test-runner', mockWs()); + + expect(logger.warn).not.toHaveBeenCalledWith(idIsAlreadyTaken()); + }); + + it('should not warn when the holder stopped answering heartbeats', async () => { + const logger = mock(); + const server = createServer({ logger }); + + // `add` marks every new connection alive, so go stale only after registering + const holder = mockWs(); + await registerOverWs(server, 'test-runner', holder); + holder.isAlive = false; + + await registerOverWs(server, 'test-runner', mockWs()); + + expect(logger.warn).not.toHaveBeenCalledWith(idIsAlreadyTaken()); + }); + }); + describe('heartbeat timer', () => { const DEAD = false; @@ -309,21 +349,14 @@ describe('TaskBrokerWsServer', () => { expect(liveWs.close).not.toHaveBeenCalled(); }); - it('should report the dead runner with its task types', async () => { + it('should report only the dead runner', async () => { const runnerLifecycleEvents = mock(); - const taskBroker = mockTaskBroker([ - { id: 'runner-0', taskTypes: ['python'] }, - { id: 'runner-1', taskTypes: ['javascript'] }, - ]); - await runHeartbeatCheck([mockWs(WS_OPEN, DEAD), mockWs()], { - taskBroker, - runnerLifecycleEvents, - }); + await runHeartbeatCheck([mockWs(WS_OPEN, DEAD), mockWs()], { runnerLifecycleEvents }); expect(runnerLifecycleEvents.emit).toHaveBeenCalledExactlyOnceWith( 'runner:failed-heartbeat-check', - { runnerId: 'runner-0', taskTypes: ['python'] }, + { runnerId: 'runner-0' }, ); }); }); @@ -337,10 +370,7 @@ describe('TaskBrokerWsServer', () => { server.runnerConnections.set('test-runner', ws); server.start(); - runnerLifecycleEvents.emit('runner:unresponsive', { - runnerId: 'test-runner', - taskTypes: ['javascript'], - }); + runnerLifecycleEvents.emit('runner:unresponsive', { runnerId: 'test-runner' }); expect(ws.close).toHaveBeenCalledWith(WsStatusCodes.CloseProtocolError); expect(disconnectAnalyzer.toDisconnectError).toHaveBeenCalledWith( @@ -358,10 +388,7 @@ describe('TaskBrokerWsServer', () => { const ws = mockWs(); server.runnerConnections.set('test-runner', ws); - runnerLifecycleEvents.emit('runner:unresponsive', { - runnerId: 'test-runner', - taskTypes: ['javascript'], - }); + runnerLifecycleEvents.emit('runner:unresponsive', { runnerId: 'test-runner' }); expect(ws.close).not.toHaveBeenCalled(); }); diff --git a/packages/cli/src/task-runners/task-broker/__tests__/task-broker.service.test.ts b/packages/cli/src/task-runners/task-broker/__tests__/task-broker.service.test.ts index ed5c0f6bcd2..48d344d0e4c 100644 --- a/packages/cli/src/task-runners/task-broker/__tests__/task-broker.service.test.ts +++ b/packages/cli/src/task-runners/task-broker/__tests__/task-broker.service.test.ts @@ -21,12 +21,6 @@ import type { const createValidUntil = (ms: number) => process.hrtime.bigint() + BigInt(ms * 1_000_000); -const createRunner = (id: string, taskTypes: string[]): TaskRunner => ({ - id, - taskTypes, - lastSeen: new Date(), -}); - describe('TaskBroker', () => { let taskBroker: TaskBroker; @@ -1172,8 +1166,7 @@ describe('TaskBroker', () => { const taskId = 'task1'; const runnerId = 'runner1'; const requesterId = 'requester1'; - // a literal rather than a mock: `mock` proxies the array, breaking deep equality - const runner: TaskRunner = { id: runnerId, taskTypes: ['test'], lastSeen: new Date() }; + const runner = mock({ id: runnerId }); const runnerCallback = vi.fn(); const requesterCallback = vi.fn(); @@ -1192,7 +1185,6 @@ describe('TaskBroker', () => { expect(runnerLifecycleEvents.emit).toHaveBeenCalledWith('runner:timed-out-during-task', { runnerId, - taskTypes: ['test'], }); await Promise.resolve(); @@ -1518,7 +1510,7 @@ describe('TaskBroker', () => { lifecycleEvents, mock(), ); - taskBroker.registerRunner(createRunner('runner1', ['taskType1']), vi.fn()); + taskBroker.registerRunner(mock({ id: 'runner1' }), vi.fn()); }); // a failing assertion must not leak fake timers into later tests @@ -1562,7 +1554,6 @@ describe('TaskBroker', () => { expect(lifecycleEvents.emit).toHaveBeenCalledTimes(1); expect(lifecycleEvents.emit).toHaveBeenCalledWith('runner:unresponsive', { runnerId: 'runner1', - taskTypes: ['taskType1'], }); await timeOutAcceptance(); @@ -1607,7 +1598,7 @@ describe('TaskBroker', () => { await timeOutAcceptance(); taskBroker.deregisterRunner('runner1', new Error('connection lost')); - taskBroker.registerRunner(createRunner('runner1', ['taskType1']), vi.fn()); + taskBroker.registerRunner(mock({ id: 'runner1' }), vi.fn()); await timeOutAcceptance(); await timeOutAcceptance(); @@ -1615,8 +1606,25 @@ describe('TaskBroker', () => { expect(lifecycleEvents.emit).not.toHaveBeenCalled(); }); + it('should report a runner that reached the threshold after deregistering', async () => { + const acceptances = Array.from( + { length: 3 }, + async () => await taskBroker.acceptOffer(offerFrom('runner1'), requestFor()), + ); + + // the transport deregisters the runner while the acceptances are still settling, + // so their timeouts are all counted against a runner no longer known + vi.advanceTimersByTime(ACCEPT_TIMEOUT_MS); + taskBroker.deregisterRunner('runner1', new Error('connection lost')); + await Promise.all(acceptances); + + expect(lifecycleEvents.emit).toHaveBeenCalledWith('runner:unresponsive', { + runnerId: 'runner1', + }); + }); + it('should count acknowledgment timeouts per runner', async () => { - taskBroker.registerRunner(createRunner('runner2', ['taskType1']), vi.fn()); + taskBroker.registerRunner(mock({ id: 'runner2' }), vi.fn()); await timeOutAcceptance('runner1'); await timeOutAcceptance('runner1'); @@ -1628,7 +1636,6 @@ describe('TaskBroker', () => { expect(lifecycleEvents.emit).toHaveBeenCalledTimes(1); expect(lifecycleEvents.emit).toHaveBeenCalledWith('runner:unresponsive', { runnerId: 'runner2', - taskTypes: ['taskType1'], }); }); }); @@ -1658,7 +1665,7 @@ describe('TaskBroker', () => { const registerRunner = (isRunnerReachable?: () => boolean) => { taskBroker.registerRunner( - createRunner('runner1', ['taskType1', 'taskType2']), + mock({ id: 'runner1', taskTypes: ['taskType1', 'taskType2'] }), vi.fn(), isRunnerReachable, ); @@ -1682,7 +1689,6 @@ describe('TaskBroker', () => { expect(lifecycleEvents.emit).toHaveBeenCalledTimes(1); expect(lifecycleEvents.emit).toHaveBeenCalledWith('runner:unresponsive', { runnerId: 'runner1', - taskTypes: ['taskType1', 'taskType2'], }); expect(requesterCallback).toHaveBeenCalledWith({ type: 'broker:requestexpired', @@ -1761,7 +1767,7 @@ describe('TaskBroker', () => { }); it('should not report a runner that does not support the task type', () => { - taskBroker.registerRunner(createRunner('runner1', ['other']), vi.fn()); + taskBroker.registerRunner(mock({ id: 'runner1', taskTypes: ['other'] }), vi.fn()); letRequestExpire(); diff --git a/packages/cli/src/task-runners/task-broker/auth/__tests__/task-broker-auth.controller.test.ts b/packages/cli/src/task-runners/task-broker/auth/__tests__/task-broker-auth.controller.test.ts index 4ac175b09bc..dec7304afb9 100644 --- a/packages/cli/src/task-runners/task-broker/auth/__tests__/task-broker-auth.controller.test.ts +++ b/packages/cli/src/task-runners/task-broker/auth/__tests__/task-broker-auth.controller.test.ts @@ -108,6 +108,18 @@ describe('TaskBrokerAuthController', () => { }); }); + it('should return the runner ID bound to the grant token', async () => { + const token = await authService.createGrantToken('runner1'); + + const result = await authController.validateUpgradeRequest(`Bearer ${token}`); + + expect(result).toStrictEqual({ + isValid: true, + statusCode: 200, + boundRunnerId: 'runner1', + }); + }); + it('should return 403 when grant token is used twice', async () => { const { token } = await authController.createGrantToken( createMockGrantTokenReq('random-secret'), diff --git a/packages/cli/src/task-runners/task-broker/auth/__tests__/task-broker-auth.service.test.ts b/packages/cli/src/task-runners/task-broker/auth/__tests__/task-broker-auth.service.test.ts index b9df4725f52..7598c931cb9 100644 --- a/packages/cli/src/task-runners/task-broker/auth/__tests__/task-broker-auth.service.test.ts +++ b/packages/cli/src/task-runners/task-broker/auth/__tests__/task-broker-auth.service.test.ts @@ -56,33 +56,67 @@ describe('TaskBrokerAuthService', () => { const token = await authService.createGrantToken(); // Assert - expect(cacheSetSpy).toHaveBeenCalledWith(`grant-token:${token}`, '1', TTL); + expect(cacheSetSpy).toHaveBeenCalledWith(`grant-token:${token}`, '-', TTL); + }); + + it('should store the bound runner ID in cache', async () => { + // Arrange + const cacheSetSpy = vi.spyOn(cacheService, 'set'); + + // Act + const token = await authService.createGrantToken('runner1'); + + // Assert + expect(cacheSetSpy).toHaveBeenCalledWith(`grant-token:${token}`, 'runner1', TTL); }); }); describe('tryConsumeGrantToken', () => { - it('should return false for an invalid grant token', async () => { - expect(await authService.tryConsumeGrantToken('random-secret')).toBe(false); + it('should be invalid for an invalid grant token', async () => { + expect(await authService.tryConsumeGrantToken('random-secret')).toEqual({ + isValid: false, + boundRunnerId: null, + }); }); - it('should return true for a valid grant token', async () => { + it('should be valid for a valid grant token', async () => { // Arrange const grantToken = await authService.createGrantToken(); // Act - expect(await authService.tryConsumeGrantToken(grantToken)).toBe(true); + expect(await authService.tryConsumeGrantToken(grantToken)).toEqual({ + isValid: true, + boundRunnerId: null, + }); }); - it('should return false for a already used grant token', async () => { + it('should return the runner ID bound at creation', async () => { // Arrange - const grantToken = await authService.createGrantToken(); + const grantToken = await authService.createGrantToken('runner1'); // Act - expect(await authService.tryConsumeGrantToken(grantToken)).toBe(true); - expect(await authService.tryConsumeGrantToken(grantToken)).toBe(false); + expect(await authService.tryConsumeGrantToken(grantToken)).toEqual({ + isValid: true, + boundRunnerId: 'runner1', + }); }); - it('should return false for an expired grant token', async () => { + it('should be invalid for a already used grant token', async () => { + // Arrange + const grantToken = await authService.createGrantToken('runner1'); + + // Act + expect(await authService.tryConsumeGrantToken(grantToken)).toEqual({ + isValid: true, + boundRunnerId: 'runner1', + }); + expect(await authService.tryConsumeGrantToken(grantToken)).toEqual({ + isValid: false, + boundRunnerId: null, + }); + }); + + it('should be invalid for an expired grant token', async () => { // Arrange const grantToken = await authService.createGrantToken(); @@ -90,7 +124,10 @@ describe('TaskBrokerAuthService', () => { await sleep(TTL + 1); await retryUntil(async () => - expect(await authService.tryConsumeGrantToken(grantToken)).toBe(false), + expect(await authService.tryConsumeGrantToken(grantToken)).toEqual({ + isValid: false, + boundRunnerId: null, + }), ); }); }); diff --git a/packages/cli/src/task-runners/task-broker/auth/task-broker-auth.controller.ts b/packages/cli/src/task-runners/task-broker/auth/task-broker-auth.controller.ts index 94c760be79e..c60cf2440c4 100644 --- a/packages/cli/src/task-runners/task-broker/auth/task-broker-auth.controller.ts +++ b/packages/cli/src/task-runners/task-broker/auth/task-broker-auth.controller.ts @@ -37,11 +37,16 @@ export class TaskBrokerAuthController { /** * Validates a WebSocket upgrade request by checking runner ID and grant token - * @returns Object with validation result and appropriate HTTP status code + * @returns Object with validation result and appropriate HTTP status code. On success, + * `boundRunnerId` is the ID the grant token was minted for, or `null` if it was minted + * without one. */ - async validateUpgradeRequest( - authHeader: string | undefined, - ): Promise<{ isValid: boolean; statusCode: number; reason?: string }> { + async validateUpgradeRequest(authHeader: string | undefined): Promise<{ + isValid: boolean; + statusCode: number; + reason?: string; + boundRunnerId?: string; + }> { const result = bearerTokenSchema.safeParse(authHeader); if (!result.success) { return { @@ -52,7 +57,7 @@ export class TaskBrokerAuthController { } const grantToken = result.data; - const isValid = await this.authService.tryConsumeGrantToken(grantToken); + const { isValid, boundRunnerId } = await this.authService.tryConsumeGrantToken(grantToken); if (!isValid) { return { isValid: false, @@ -61,6 +66,6 @@ export class TaskBrokerAuthController { }; } - return { isValid: true, statusCode: 200 }; + return { isValid: true, statusCode: 200, ...(boundRunnerId && { boundRunnerId }) }; } } diff --git a/packages/cli/src/task-runners/task-broker/auth/task-broker-auth.service.ts b/packages/cli/src/task-runners/task-broker/auth/task-broker-auth.service.ts index 072c3028141..7a79b4dee2e 100644 --- a/packages/cli/src/task-runners/task-broker/auth/task-broker-auth.service.ts +++ b/packages/cli/src/task-runners/task-broker/auth/task-broker-auth.service.ts @@ -5,6 +5,18 @@ import { randomBytes, timingSafeEqual } from 'crypto'; import { CacheService } from '@/services/cache/cache.service'; +/** + * Cached in place of a runner ID for a grant token minted without one bound. + * Far shorter than a generated runner ID, so it can never be mistaken for one. + */ +const NO_BOUND_RUNNER = '-'; + +export type GrantTokenConsumption = { + isValid: boolean; + /** ID bound to the token when it was minted, if any. Always `null` for an invalid token. */ + boundRunnerId: string | null; +}; + @Service() export class TaskBrokerAuthService { private readonly authToken = Buffer.from(this.runnersConfig.authToken); @@ -26,13 +38,16 @@ export class TaskBrokerAuthService { } /** - * @returns grant token that can be used to establish a task runner connection + * Mints a single-use grant token for establishing a task runner connection. + * + * @param runnerId ID to bind to the token, so that whoever presents it is known to be + * the runner the token was minted for. Omit when the runner is not known in advance. */ - async createGrantToken() { + async createGrantToken(runnerId?: string) { const grantToken = this.generateGrantToken(); const key = this.cacheKeyForGrantToken(grantToken); - await this.cacheService.set(key, '1', this.grantTokenTtlInMs); + await this.cacheService.set(key, runnerId ?? NO_BOUND_RUNNER, this.grantTokenTtlInMs); return grantToken; } @@ -41,14 +56,18 @@ export class TaskBrokerAuthService { * Checks if the given `grantToken` is a valid token and marks it as * used. */ - async tryConsumeGrantToken(grantToken: string) { + async tryConsumeGrantToken(grantToken: string): Promise { const key = this.cacheKeyForGrantToken(grantToken); const consumed = await this.cacheService.get(key); // Not found from cache --> Invalid token - if (consumed === undefined) return false; + if (consumed === undefined) return { isValid: false, boundRunnerId: null }; await this.cacheService.delete(key); - return true; + + return { + isValid: true, + boundRunnerId: consumed === NO_BOUND_RUNNER ? null : consumed, + }; } private generateGrantToken() { diff --git a/packages/cli/src/task-runners/task-broker/task-broker-server.ts b/packages/cli/src/task-runners/task-broker/task-broker-server.ts index 3790ce15d27..eec87c71a97 100644 --- a/packages/cli/src/task-runners/task-broker/task-broker-server.ts +++ b/packages/cli/src/task-runners/task-broker/task-broker-server.ts @@ -228,8 +228,9 @@ export class TaskBrokerServer { return; } - const runnerId = typeof parsedUrl.query.id === 'string' ? parsedUrl.query.id : undefined; - if (!runnerId) { + const reportedRunnerId = + typeof parsedUrl.query.id === 'string' ? parsedUrl.query.id : undefined; + if (!reportedRunnerId) { this.logger.warn( 'Task runner connection attempt failed: missing runner ID in query parameters', ); @@ -246,7 +247,7 @@ export class TaskBrokerServer { if (!result.isValid) { this.logger.warn( `Task runner connection attempt failed: ${result.reason}. If the runner startup exceeds grant token TTL ${this.globalConfig.taskRunners.grantTokenTtl}s, increase N8N_RUNNERS_GRANT_TOKEN_TTL`, - { runnerId }, + { runnerId: reportedRunnerId }, ); this.failUpgradeRequest(socket, result.statusCode); return; @@ -258,6 +259,8 @@ export class TaskBrokerServer { return; } + const runnerId = this.resolveRunnerId(reportedRunnerId, result.boundRunnerId); + const wsServer = this.wsServer; wsServer.handleUpgrade(request, socket, head, (ws) => { request.ws = ws; @@ -271,6 +274,27 @@ export class TaskBrokerServer { } }; + /** + * Resolves the ID to register a connecting runner under. + * + * An ID bound to the grant token wins over the one the runner reports: the bound ID is + * the one n8n minted for the process it spawned, so recovery keyed on it acts on the + * right process even if the runner never received the ID and self-assigned instead. + */ + private resolveRunnerId(reportedRunnerId: string, boundRunnerId?: string) { + if (!boundRunnerId) { + return reportedRunnerId; + } + + if (boundRunnerId !== reportedRunnerId) { + this.logger.warn( + `Task runner identified as "${reportedRunnerId}" but was assigned "${boundRunnerId}", registering it under the assigned ID. Check that N8N_RUNNERS_ID reaches the runner.`, + ); + } + + return boundRunnerId; + } + private failUpgradeRequest(socket: Socket, statusCode: number) { const statusMessage = STATUS_CODES[statusCode] ?? 'Error'; socket.write(`HTTP/1.1 ${statusCode} ${statusMessage}\r\n\r\n`); diff --git a/packages/cli/src/task-runners/task-broker/task-broker-ws-server.ts b/packages/cli/src/task-runners/task-broker/task-broker-ws-server.ts index ec67e42099c..3d49f25ac86 100644 --- a/packages/cli/src/task-runners/task-broker/task-broker-ws-server.ts +++ b/packages/cli/src/task-runners/task-broker/task-broker-ws-server.ts @@ -93,18 +93,13 @@ export class TaskBrokerWsServer { connection.isAlive = false; connection.ping(); } else { - const taskTypes = this.taskBroker.getKnownRunners().get(runnerId)?.runner.taskTypes ?? []; - void this.removeConnection(runnerId, { reason: 'failed-heartbeat-check', code: WsStatusCodes.CloseProtocolError, expectedConnection: connection, }); - this.runnerLifecycleEvents.emit('runner:failed-heartbeat-check', { - runnerId, - taskTypes, - }); + this.runnerLifecycleEvents.emit('runner:failed-heartbeat-check', { runnerId }); } } } @@ -152,6 +147,12 @@ export class TaskBrokerWsServer { if (!isConnected) { if (message.type === 'runner:info') { + if (this.hasLiveConnection(id)) { + this.logger.warn( + `Runner "${message.name}" registered as "${id}", an ID another live runner holds, and replaced its connection. Give each runner a unique N8N_RUNNERS_ID, else they will keep evicting each other.`, + ); + } + await this.removeConnection(id); isConnected = true; @@ -260,6 +261,21 @@ export class TaskBrokerWsServer { return this.isCurrentConnection(id, connection) && connection.readyState === connection.OPEN; } + /** + * Whether `id` is already held by a connection that is open and answering heartbeats. + * + * A reconnecting runner reuses its ID, but only once its previous connection is gone, so + * a live holder means two runners claim the same ID. Requires answered heartbeats + * because a dropped socket stays `OPEN` until the liveness check notices. + */ + private hasLiveConnection(id: TaskRunner['id']) { + const connection = this.runnerConnections.get(id); + + return ( + connection !== undefined && connection.readyState === connection.OPEN && connection.isAlive + ); + } + private async stopConnectedRunners() { await this.drainActiveTasks(); diff --git a/packages/cli/src/task-runners/task-broker/task-broker.service.ts b/packages/cli/src/task-runners/task-broker/task-broker.service.ts index 213de64803d..6dbf8b5cd75 100644 --- a/packages/cli/src/task-runners/task-broker/task-broker.service.ts +++ b/packages/cli/src/task-runners/task-broker/task-broker.service.ts @@ -591,7 +591,6 @@ export class TaskBroker { if (this.taskRunnersConfig.mode === 'internal') { this.taskRunnerLifecycleEvents.emit('runner:timed-out-during-task', { runnerId: task.runnerId, - taskTypes: this.knownRunners.get(task.runnerId)?.runner.taskTypes ?? [], }); } else if (this.taskRunnersConfig.mode === 'external') { await this.messageRunner(task.runnerId, { @@ -811,18 +810,15 @@ export class TaskBroker { /** * Reports a runner as unresponsive, so its transport can be torn down and, in internal - * mode, its process force-restarted. A no-op for a runner that is no longer registered, - * as it has nothing left to tear down. + * mode, its process force-restarted. + * + * Reports a runner that is no longer registered too: concurrent acceptances can reach the + * timeout threshold after the transport deregistered the runner, and a process that + * outlived its transport is exactly what still needs restarting. */ private reportUnresponsive(runnerId: TaskRunner['id'], cause: string) { - const runner = this.knownRunners.get(runnerId)?.runner; - if (runner) { - this.logger.warn(`Runner (${runnerId}) ${cause}, reporting it as unresponsive`); - this.taskRunnerLifecycleEvents.emit('runner:unresponsive', { - runnerId, - taskTypes: runner.taskTypes, - }); - } + this.logger.warn(`Runner (${runnerId}) ${cause}, reporting it as unresponsive`); + this.taskRunnerLifecycleEvents.emit('runner:unresponsive', { runnerId }); } private isSilent(runnerId: TaskRunner['id']) { diff --git a/packages/cli/src/task-runners/task-runner-lifecycle-events.ts b/packages/cli/src/task-runners/task-runner-lifecycle-events.ts index 8663c8ff662..7e00b8da7d2 100644 --- a/packages/cli/src/task-runners/task-runner-lifecycle-events.ts +++ b/packages/cli/src/task-runners/task-runner-lifecycle-events.ts @@ -1,7 +1,7 @@ import { TypedEmitter } from '@n8n/backend-common'; import { Service } from '@n8n/di'; -type RunnerIdentity = { runnerId: string; taskTypes: string[] }; +type RunnerIdentity = { runnerId: string }; export type TaskRunnerLifecycleEventMap = { 'runner:failed-heartbeat-check': RunnerIdentity; diff --git a/packages/cli/src/task-runners/task-runner-process-base.ts b/packages/cli/src/task-runners/task-runner-process-base.ts index 7dcefcbaefa..a2222891cb9 100644 --- a/packages/cli/src/task-runners/task-runner-process-base.ts +++ b/packages/cli/src/task-runners/task-runner-process-base.ts @@ -3,6 +3,7 @@ import { LogScope, TaskRunnersConfig } from '@n8n/config'; import { OnShutdown } from '@n8n/decorators'; import { Service } from '@n8n/di'; import { sleep } from '@n8n/utils/sleep'; +import { nanoid } from 'nanoid'; import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; @@ -31,7 +32,8 @@ export type TaskRunnerProcessEventMap = { export abstract class TaskRunnerProcessBase extends TypedEmitter { protected readonly name: string; - protected readonly taskType: string; + /** ID assigned to the runner in the current process, for it to identify as to the broker. */ + private runnerId: string | null = null; protected process: ChildProcess | null = null; @@ -51,28 +53,22 @@ export abstract class TaskRunnerProcessBase extends TypedEmitter { - this.restartIfOwnRunner(taskTypes, 'failed heartbeat check'); + this.runnerLifecycleEvents.on('runner:failed-heartbeat-check', ({ runnerId }) => { + this.restartIfOwnRunner(runnerId, 'failed heartbeat check'); }); - this.runnerLifecycleEvents.on('runner:timed-out-during-task', ({ taskTypes }) => { - this.restartIfOwnRunner(taskTypes, 'timed out during task'); + this.runnerLifecycleEvents.on('runner:timed-out-during-task', ({ runnerId }) => { + this.restartIfOwnRunner(runnerId, 'timed out during task'); }); - this.runnerLifecycleEvents.on('runner:unresponsive', ({ taskTypes }) => { - this.restartIfOwnRunner(taskTypes, 'was reported unresponsive'); + this.runnerLifecycleEvents.on('runner:unresponsive', ({ runnerId }) => { + this.restartIfOwnRunner(runnerId, 'was reported unresponsive'); }); } - /** - * Force-restarts this process if the reported runner is the one it spawned. - * - * Internal mode runs one process per task type, so the task type is what says - * which process to restart. - * The runner ID cannot: runners mint their own, so it is never tied to a process n8n spawned. - */ - private restartIfOwnRunner(taskTypes: string[], cause: string) { - if (taskTypes.includes(this.taskType)) { + /** Force-restarts this process if the reported runner is the one currently running in it. */ + private restartIfOwnRunner(runnerId: string, cause: string) { + if (runnerId === this.runnerId) { this.logger.warn(`Task runner ${cause}, restarting...`); void this.forceRestart(); } @@ -107,9 +103,13 @@ export abstract class TaskRunnerProcessBase extends TypedEmitter void) { this.process = null; + this.runnerId = null; const exitReason = this.analyzeExitReason?.(code) ?? { reason: 'unknown' }; this.emit('exit', exitReason); resolveFn(); @@ -204,7 +205,7 @@ export abstract class TaskRunnerProcessBase extends TypedEmitter = Object.assign(Object.create(null), { // system environment PATH: process.env.PATH, @@ -71,6 +69,7 @@ export class JsTaskRunnerProcess extends TaskRunnerProcessBase { DEPLOYMENT_NAME: process.env.DEPLOYMENT_NAME, // runner + N8N_RUNNERS_ID: runnerId, N8N_RUNNERS_GRANT_TOKEN: grantToken, N8N_RUNNERS_TASK_BROKER_URI: taskBrokerUri, N8N_RUNNERS_MAX_PAYLOAD: this.runnerConfig.maxPayload.toString(), diff --git a/packages/cli/src/task-runners/task-runner-process-py.ts b/packages/cli/src/task-runners/task-runner-process-py.ts index b3f3aa41718..be0276cdf63 100644 --- a/packages/cli/src/task-runners/task-runner-process-py.ts +++ b/packages/cli/src/task-runners/task-runner-process-py.ts @@ -20,8 +20,6 @@ const asyncExec = promisify(exec); export class PyTaskRunnerProcess extends TaskRunnerProcessBase { protected readonly name = 'runner:py'; - protected readonly taskType = 'python'; - constructor( logger: Logger, runnerConfig: TaskRunnersConfig, @@ -31,7 +29,7 @@ export class PyTaskRunnerProcess extends TaskRunnerProcessBase { super('task-runner-py', logger, runnerConfig, authService, runnerLifecycleEvents); } - startProcess(grantToken: string, taskBrokerUri: string) { + startProcess(grantToken: string, taskBrokerUri: string, runnerId: string) { const pythonDir = path.join(__dirname, '../../../@n8n/task-runner-python'); const venvPath = PyTaskRunnerProcess.getVenvPath(); @@ -43,6 +41,7 @@ export class PyTaskRunnerProcess extends TaskRunnerProcessBase { HOME: process.env.HOME ?? process.env.USERPROFILE, // runner + N8N_RUNNERS_ID: runnerId, N8N_RUNNERS_GRANT_TOKEN: grantToken, N8N_RUNNERS_TASK_BROKER_URI: taskBrokerUri, N8N_RUNNERS_MAX_PAYLOAD: this.runnerConfig.maxPayload.toString(), From f8388053b7df9b95cfc5406f5b207fbd291409c0 Mon Sep 17 00:00:00 2001 From: Matsu Date: Mon, 10 Aug 2026 14:24:26 +0300 Subject: [PATCH 018/151] chore: Update codespace scope if missing on pnpm session (#35941) --- scripts/cloud-session.mjs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/scripts/cloud-session.mjs b/scripts/cloud-session.mjs index 6792ccfa8c8..7e62c606883 100644 --- a/scripts/cloud-session.mjs +++ b/scripts/cloud-session.mjs @@ -19,9 +19,23 @@ const FALLBACK_MACHINE = 'standardLinux32gb'; // 4-core/16GB, until org policy a const gh = (...args) => execFileSync('gh', args, { encoding: 'utf8' }).trim(); const ghTty = (...args) => spawnSync('gh', args, { stdio: 'inherit' }); -function findCodespace() { - const list = JSON.parse(gh('codespace', 'list', '-R', REPO, '--json', 'name,state')); - return list[0]; +function findCodespace(retry = false) { + try { + const list = JSON.parse(gh('codespace', 'list', '-R', REPO, '--json', 'name,state')); + return list[0]; + } catch (ex) { + if (ex.message.includes('This API operation needs the "codespace" scope') && !retry) { + requestCodespaceScope(); + return findCodespace(true); + } else { + throw new Error(ex.message); + } + } +} + +function requestCodespaceScope() { + console.log("Requesting codespace scope"); + ghTty("auth", "refresh", "-h", "github.com", "-s", "codespace") } function ensureCodespace() { From a02498f9877da0edc8c7e89ef0655861afcde191 Mon Sep 17 00:00:00 2001 From: Jaakko Husso Date: Mon, 10 Aug 2026 14:37:17 +0300 Subject: [PATCH 019/151] test: Pin Instance AI off for the legacy AI Assistant e2e suites (#35928) Co-authored-by: Claude Opus 5 (1M context) --- packages/testing/playwright/Types.ts | 11 ++++- .../config/ai-assistant-fixtures.ts | 19 +++++++++ .../playwright/config/ai-builder-fixtures.ts | 2 + .../config/ai-builder-wizard-fixtures.ts | 2 + .../testing/playwright/config/intercepts.ts | 40 +++++++++++++++++++ .../tests/e2e/auth/authenticated.spec.ts | 39 +++++++++++++----- .../testing/playwright/utils/requirements.ts | 7 +++- 7 files changed, 109 insertions(+), 11 deletions(-) diff --git a/packages/testing/playwright/Types.ts b/packages/testing/playwright/Types.ts index 52ff51df946..2b671e622ce 100644 --- a/packages/testing/playwright/Types.ts +++ b/packages/testing/playwright/Types.ts @@ -1,4 +1,10 @@ -import type { FrontendSettings } from '@n8n/api-types'; +import type { FrontendModuleSettings, FrontendSettings } from '@n8n/api-types'; + +type DeepPartial = T extends Array + ? Array> + : T extends object + ? { [K in keyof T]?: DeepPartial } + : T; export class TestError extends Error { constructor(message: string) { @@ -48,6 +54,9 @@ export interface TestRequirements { /** Frontend settings to override (merged with default settings) */ settings?: Partial; + /** Backend module settings to override (merged with the ones the instance reports) */ + moduleSettings?: DeepPartial; + /** Feature flags to enable/disable for the test */ features?: Record; }; diff --git a/packages/testing/playwright/config/ai-assistant-fixtures.ts b/packages/testing/playwright/config/ai-assistant-fixtures.ts index 709745a952f..3166647bc66 100644 --- a/packages/testing/playwright/config/ai-assistant-fixtures.ts +++ b/packages/testing/playwright/config/ai-assistant-fixtures.ts @@ -107,11 +107,22 @@ export const codeSnippetAssistantResponse = { // #region Test Requirements for different scenarios +/** + * The legacy AI Assistant entry points (canvas action button, node error view + * button, credential help) only render while Instance AI is off, so these + * suites pin the module off. Instance AI's own entry points are covered by + * `tests/e2e/instance-ai`. + */ +export const INSTANCE_AI_DISABLED: NonNullable['moduleSettings'] = { + 'instance-ai': { enabled: false }, +}; + export const aiDisabledRequirements: TestRequirements = { config: { settings: { aiAssistant: { enabled: false, setup: false }, }, + moduleSettings: INSTANCE_AI_DISABLED, features: { aiAssistant: false }, }, }; @@ -121,6 +132,7 @@ export const aiEnabledRequirements: TestRequirements = { settings: { aiAssistant: { enabled: true, setup: true }, }, + moduleSettings: INSTANCE_AI_DISABLED, features: { aiAssistant: true, setup: true }, }, }; @@ -130,6 +142,7 @@ export const aiEnabledWithWorkflowRequirements: TestRequirements = { settings: { aiAssistant: { enabled: true, setup: true }, }, + moduleSettings: INSTANCE_AI_DISABLED, features: { aiAssistant: true, setup: true }, }, workflow: { @@ -148,6 +161,7 @@ export const aiEnabledWithQuickRepliesRequirements: TestRequirements = { settings: { aiAssistant: { enabled: true, setup: true }, }, + moduleSettings: INSTANCE_AI_DISABLED, features: { aiAssistant: true }, }, workflow: { @@ -185,6 +199,7 @@ export const aiEnabledWithEndSessionRequirements: TestRequirements = { settings: { aiAssistant: { enabled: true, setup: true }, }, + moduleSettings: INSTANCE_AI_DISABLED, features: { aiAssistant: true }, }, workflow: { @@ -218,6 +233,7 @@ export const aiEnabledWorkflowBaseRequirements: TestRequirements = { settings: { aiAssistant: { enabled: true, setup: true }, }, + moduleSettings: INSTANCE_AI_DISABLED, features: { aiAssistant: true, setup: true }, }, workflow: { @@ -240,6 +256,7 @@ export const aiEnabledWithSimpleChatRequirements: TestRequirements = { settings: { aiAssistant: { enabled: true, setup: true }, }, + moduleSettings: INSTANCE_AI_DISABLED, features: { aiAssistant: true, setup: true }, }, intercepts: { @@ -255,6 +272,7 @@ export const aiEnabledWithCodeSnippetRequirements: TestRequirements = { settings: { aiAssistant: { enabled: true, setup: true }, }, + moduleSettings: INSTANCE_AI_DISABLED, features: { aiAssistant: true, setup: true }, }, intercepts: { @@ -270,6 +288,7 @@ export const aiEnabledWithHttpWorkflowRequirements: TestRequirements = { settings: { aiAssistant: { enabled: true, setup: true }, }, + moduleSettings: INSTANCE_AI_DISABLED, features: { aiAssistant: true, setup: true }, }, workflow: { diff --git a/packages/testing/playwright/config/ai-builder-fixtures.ts b/packages/testing/playwright/config/ai-builder-fixtures.ts index 13ec02d9d44..a00a16e61cc 100644 --- a/packages/testing/playwright/config/ai-builder-fixtures.ts +++ b/packages/testing/playwright/config/ai-builder-fixtures.ts @@ -1,3 +1,4 @@ +import { INSTANCE_AI_DISABLED } from './ai-assistant-fixtures'; import type { TestRequirements } from '../Types'; /** @@ -11,6 +12,7 @@ export const workflowBuilderEnabledRequirements: TestRequirements = { aiAssistant: { enabled: true, setup: true }, aiBuilder: { enabled: true, setup: true }, }, + moduleSettings: INSTANCE_AI_DISABLED, features: { aiAssistant: true, aiBuilder: true, diff --git a/packages/testing/playwright/config/ai-builder-wizard-fixtures.ts b/packages/testing/playwright/config/ai-builder-wizard-fixtures.ts index cc0dd7b8c96..6ba6114e55c 100644 --- a/packages/testing/playwright/config/ai-builder-wizard-fixtures.ts +++ b/packages/testing/playwright/config/ai-builder-wizard-fixtures.ts @@ -1,3 +1,4 @@ +import { INSTANCE_AI_DISABLED } from './ai-assistant-fixtures'; import type { TestRequirements } from '../Types'; // #region Mock Builder Responses @@ -441,6 +442,7 @@ export const builderWizardRequirements: TestRequirements = { aiAssistant: { enabled: true, setup: true }, aiBuilder: { enabled: true, setup: true }, }, + moduleSettings: INSTANCE_AI_DISABLED, features: { aiAssistant: true, aiBuilder: true, diff --git a/packages/testing/playwright/config/intercepts.ts b/packages/testing/playwright/config/intercepts.ts index 50103402b10..c67979285c0 100644 --- a/packages/testing/playwright/config/intercepts.ts +++ b/packages/testing/playwright/config/intercepts.ts @@ -3,6 +3,7 @@ import cloneDeep from 'lodash/cloneDeep'; import merge from 'lodash/merge'; const contextSettings = new Map>>(); +const contextModuleSettings = new Map>>(); export function setContextSettings( context: BrowserContext, @@ -15,6 +16,17 @@ export function getContextSettings(context: BrowserContext) { return contextSettings.get(context); } +export function setContextModuleSettings( + context: BrowserContext, + moduleSettings: Partial>, +) { + contextModuleSettings.set(context, moduleSettings); +} + +export function getContextModuleSettings(context: BrowserContext) { + return contextModuleSettings.get(context); +} + export async function setupDefaultInterceptors(target: BrowserContext) { // Global /rest/settings intercept - always active like Cypress // TODO: Remove this as a global and move it per test @@ -46,6 +58,34 @@ export async function setupDefaultInterceptors(target: BrowserContext) { } }); + // Global /rest/module-settings intercept - lets a test pin a backend module's + // client settings (e.g. turn `instance-ai` off) without changing the instance. + await target.route('**/rest/module-settings', async (route: Route) => { + try { + const originalResponse = await route.fetch(); + const originalJson = await originalResponse.json(); + + const testModuleSettings = getContextModuleSettings(target); + + const modifiedData = { + data: + testModuleSettings && Object.keys(testModuleSettings).length > 0 + ? merge(cloneDeep(originalJson.data), testModuleSettings) + : originalJson.data, + }; + + await route.fulfill({ + status: originalResponse.status(), + headers: originalResponse.headers(), + contentType: 'application/json', + body: JSON.stringify(modifiedData), + }); + } catch (error) { + console.error('Error in /rest/module-settings intercept:', error); + await route.continue(); + } + }); + // POST /rest/credentials/test await target.route('**/rest/credentials/test', async (route: Route) => { if (route.request().method() === 'POST') { diff --git a/packages/testing/playwright/tests/e2e/auth/authenticated.spec.ts b/packages/testing/playwright/tests/e2e/auth/authenticated.spec.ts index 8c3297ddd13..902e1848ac9 100644 --- a/packages/testing/playwright/tests/e2e/auth/authenticated.spec.ts +++ b/packages/testing/playwright/tests/e2e/auth/authenticated.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '../../../fixtures/base'; +import type { TestRequirements } from '../../../Types'; test.describe( 'Authentication', @@ -6,18 +7,38 @@ test.describe( annotation: [{ type: 'owner', description: 'Identity & Access' }], }, () => { - // Every signed-in role holds `instanceAi:message`, so the root route lands - // them on the AI Assistant while the `instance-ai` module is active. + // The root route lands users on the AI Assistant while the `instance-ai` + // module is active — but only if they may manage it, or setup is complete. + // Owner and admin hold `instanceAi:manage`; members only hold + // `instanceAi:message`, so they land there once Instance AI is set up. + // The module's `setupCompleted` flag is pinned per case so the expectation + // doesn't depend on how the instance under test happens to be configured. + const instanceAiSetup = (setupCompleted: boolean): TestRequirements => ({ + config: { moduleSettings: { 'instance-ai': { setupCompleted } } }, + }); + const testCases = [ - { role: 'default', expectedUrl: /\/assistant/, auth: '' }, - { role: 'owner', expectedUrl: /\/assistant/, auth: '@auth:owner' }, - { role: 'admin', expectedUrl: /\/assistant/, auth: '@auth:admin' }, - { role: 'member', expectedUrl: /\/assistant/, auth: '@auth:member' }, - { role: 'none', expectedUrl: /\/signin/, auth: '@auth:none' }, + { role: 'default', expectedUrl: /\/assistant/, auth: '', requirements: {} }, + { role: 'owner', expectedUrl: /\/assistant/, auth: '@auth:owner', requirements: {} }, + { role: 'admin', expectedUrl: /\/assistant/, auth: '@auth:admin', requirements: {} }, + { + role: 'member without Instance AI set up', + expectedUrl: /\/home\/workflows/, + auth: '@auth:member', + requirements: instanceAiSetup(false), + }, + { + role: 'member with Instance AI set up', + expectedUrl: /\/assistant/, + auth: '@auth:member', + requirements: instanceAiSetup(true), + }, + { role: 'none', expectedUrl: /\/signin/, auth: '@auth:none', requirements: {} }, ]; - for (const { role, expectedUrl, auth } of testCases) { - test(`${role} authentication ${auth}`, async ({ n8n }) => { + for (const { role, expectedUrl, auth, requirements } of testCases) { + test(`${role} authentication ${auth}`, async ({ n8n, setupRequirements }) => { + await setupRequirements(requirements); await n8n.goToRoot(); await expect(n8n.page).toHaveURL(expectedUrl); }); diff --git a/packages/testing/playwright/utils/requirements.ts b/packages/testing/playwright/utils/requirements.ts index e30200e9504..609f14ef863 100644 --- a/packages/testing/playwright/utils/requirements.ts +++ b/packages/testing/playwright/utils/requirements.ts @@ -1,6 +1,6 @@ import type { BrowserContext } from '@playwright/test'; -import { setContextSettings } from '../config/intercepts'; +import { setContextModuleSettings, setContextSettings } from '../config/intercepts'; import type { n8nPage } from '../pages/n8nPage'; import { TestError, type TestRequirements } from '../Types'; @@ -25,6 +25,11 @@ export async function setupTestRequirements( setContextSettings(context, requirements.config.settings); } + // 1b. Setup module settings override + if (requirements.config?.moduleSettings) { + setContextModuleSettings(context, requirements.config.moduleSettings); + } + // 2. Setup feature flags if (requirements.config?.features) { for (const [feature, enabled] of Object.entries(requirements.config.features)) { From bf860f349c310f4a72ad784978b0b08adf541859 Mon Sep 17 00:00:00 2001 From: Kai Date: Mon, 10 Aug 2026 14:03:55 +0200 Subject: [PATCH 020/151] fix(editor): Disable no-op workflow review updates (#35890) --- .../WorkflowHeaderDraftPublishActions.test.ts | 40 ++++++++++++++++++- .../WorkflowHeaderDraftPublishActions.vue | 19 ++++++++- .../WorkflowUpdateReviewDialog.test.ts | 26 +++++++++++- .../components/WorkflowUpdateReviewDialog.vue | 30 +++++++++----- 4 files changed, 101 insertions(+), 14 deletions(-) diff --git a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowHeaderDraftPublishActions.test.ts b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowHeaderDraftPublishActions.test.ts index 4bcad1849e6..a1b411ad9e8 100644 --- a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowHeaderDraftPublishActions.test.ts +++ b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowHeaderDraftPublishActions.test.ts @@ -547,7 +547,7 @@ describe('WorkflowHeaderDraftPublishActions', () => { ).not.toBeInTheDocument(); }); - const seedOpenReview = () => { + const seedOpenReview = (overrides: Partial = {}) => { vi.mocked(fetchWorkflowReviewRequests).mockResolvedValue({ count: 1, data: [ @@ -560,11 +560,49 @@ describe('WorkflowHeaderDraftPublishActions', () => { updatedAt: '2026-07-20T10:00:00.000Z', decisionBy: null, approvedVersionPublicationState: null, + ...overrides, }, ], }); }; + it('disables Publish when the open review already contains the saved version', async () => { + setWorkflowReviewGates(); + setupEnabledPublishButton(); + seedOpenReview({ workflowVersionId: 'version-1' }); + + const { getByTestId, findByTestId } = renderComponent(); + await waitFor(() => + expect(useWorkflowReviewStatusStore().hasOpenReview(defaultWorkflowProps.id)).toBe(true), + ); + + expect(getByTestId('workflow-open-publish-modal-button')).toBeDisabled(); + expect(await findByTestId('workflow-review-status-pill')).toHaveTextContent( + 'Waiting for review', + ); + }); + + it('rechecks the saved version after saving unsaved workflow changes', async () => { + setWorkflowReviewGates(); + setupEnabledPublishButton(); + seedOpenReview({ workflowVersionId: 'version-1' }); + uiStore.markStateDirty(); + + const { getByTestId, queryByRole } = renderComponent(); + await waitFor(() => + expect(useWorkflowReviewStatusStore().hasOpenReview(defaultWorkflowProps.id)).toBe(true), + ); + + const publishButton = getByTestId('workflow-open-publish-modal-button'); + expect(publishButton).toBeEnabled(); + await userEvent.click(publishButton); + + expect(mockSaveCurrentWorkflow).toHaveBeenCalledWith({}, true); + expect( + queryByRole('dialog', { name: 'Submit latest changes to existing review' }), + ).not.toBeInTheDocument(); + }); + it('opens the update-review dialog for an open review even with the local preference off', async () => { const openModalSpy = vi.spyOn(uiStore, 'openModalWithData'); setWorkflowReviewGates(); diff --git a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowHeaderDraftPublishActions.vue b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowHeaderDraftPublishActions.vue index 0efd291d5cb..82ed3f1ed41 100644 --- a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowHeaderDraftPublishActions.vue +++ b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowHeaderDraftPublishActions.vue @@ -111,9 +111,22 @@ const showWorkflowReviewControls = computed( const latestReviewRequest = computed(() => reviewStatusStore.latestReviewRequest(props.id)); +const openReviewRequest = computed(() => reviewStatusStore.openReviewRequest(props.id)); + /** Saved version of the working copy — what a review is pinned to, or diverges from. */ const savedVersionId = computed(() => workflowDocumentStore.value.versionId || undefined); +/** Whether the open review already contains the current saved workflow version. */ +const reviewContainsSavedVersion = computed(() => { + const reviewVersionId = openReviewRequest.value?.workflowVersionId; + return !!reviewVersionId && reviewVersionId === savedVersionId.value; +}); + +/** Unsaved workflow edits can still produce a new version when Publish saves first. */ +const isReviewUpdateBlocked = computed( + () => reviewContainsSavedVersion.value && !uiStore.hasUnsavedWorkflowChanges, +); + const autoSaveForPublish = ref(false); const isSaving = ref(false); const showPublishChoiceDialog = ref(false); @@ -301,6 +314,7 @@ const onReviewConflict = () => { */ const onSubmitChangesFromBanner = async () => { if (!(await ensureWorkflowSaved())) return; + if (reviewContainsSavedVersion.value) return; showUpdateReviewDialog.value = true; }; @@ -351,6 +365,7 @@ const onPublishButtonClick = async () => { // on-mount fetch resolves) reads as "no open review" — accepted, since // only backend enforcement can close that hole. if (reviewStatusStore.hasOpenReview(props.id)) { + if (reviewContainsSavedVersion.value) return; showUpdateReviewDialog.value = true; return; } @@ -526,7 +541,8 @@ const shouldDisablePublishButton = computed(() => { props.isNewWorkflow || collaborationReadOnly.value || !publishButtonConfig.value.enabled || - !hasPublishPermission.value + !hasPublishPermission.value || + (isWorkflowReviewsEnabled.value && isReviewUpdateBlocked.value) ); }); @@ -893,6 +909,7 @@ onBeforeUnmount(() => { v-model:open="showUpdateReviewDialog" :workflow-id="props.id" :flush-save="flushSaveForReview" + :can-submit="!isReviewUpdateBlocked" @updated="onReviewUpdated" /> diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowUpdateReviewDialog.test.ts b/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowUpdateReviewDialog.test.ts index f19bbea791d..8faf72bf263 100644 --- a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowUpdateReviewDialog.test.ts +++ b/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowUpdateReviewDialog.test.ts @@ -64,6 +64,8 @@ const renderComponent = createComponentRenderer(WorkflowUpdateReviewDialog, { const renderDialog = async ({ flushSave = vi.fn().mockResolvedValue(SAVED_VERSION_ID), seedOpenReview = true, + reviewData = openReview, + canSubmit = true, versionData = { versionId: SAVED_VERSION_ID, name: null, @@ -78,7 +80,7 @@ const renderDialog = async ({ documentStore.setVersionData(versionData); const reviewStatusStore = useWorkflowReviewStatusStore(pinia); if (seedOpenReview) { - vi.mocked(fetchWorkflowReviewRequests).mockResolvedValue({ count: 1, data: [openReview] }); + vi.mocked(fetchWorkflowReviewRequests).mockResolvedValue({ count: 1, data: [reviewData] }); await reviewStatusStore.fetchStatus('workflow-1'); } else { vi.mocked(fetchWorkflowReviewRequests).mockResolvedValue({ count: 0, data: [] }); @@ -89,6 +91,7 @@ const renderDialog = async ({ open: false, workflowId: 'workflow-1', flushSave, + canSubmit, }; const result = renderComponent({ pinia, props }); await result.rerender({ ...props, open: true }); @@ -157,6 +160,27 @@ describe('WorkflowUpdateReviewDialog', () => { expect(mockShowError).not.toHaveBeenCalled(); }); + it('disables submission when the caller knows the review is up to date', async () => { + const { getByTestId, flushSave } = await renderDialog({ canSubmit: false }); + + expect(getByTestId('workflow-update-review-submit-button')).toBeDisabled(); + expect(flushSave).not.toHaveBeenCalled(); + }); + + it('closes without updating when saving resolves to the reviewed version', async () => { + const { getByTestId, flushSave, emitted } = await renderDialog({ + reviewData: { ...openReview, workflowVersionId: SAVED_VERSION_ID }, + }); + + await userEvent.click(getByTestId('workflow-update-review-submit-button')); + + await waitFor(() => expect(emitted('update:open')).toContainEqual([false])); + expect(flushSave).toHaveBeenCalledOnce(); + expect(updateWorkflowReviewRequestVersion).not.toHaveBeenCalled(); + expect(emitted('updated')).toBeUndefined(); + expect(mockShowError).not.toHaveBeenCalled(); + }); + it('shows an error and keeps the dialog open when saving fails', async () => { const flushSave = vi.fn().mockResolvedValue(undefined); const { getByTestId, emitted } = await renderDialog({ flushSave }); diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowUpdateReviewDialog.vue b/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowUpdateReviewDialog.vue index ae08a02bfe5..ce720466da0 100644 --- a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowUpdateReviewDialog.vue +++ b/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowUpdateReviewDialog.vue @@ -18,11 +18,15 @@ import { WORKFLOW_REVIEW_REQUESTS_VIEW } from '@/features/workflow-reviews/const import { useWorkflowReviewStatusStore } from '@/features/workflow-reviews/reviewStatus.store'; import { updateWorkflowReviewRequestVersion } from '@/features/workflow-reviews/workflowReviews.api'; -const props = defineProps<{ - open: boolean; - workflowId: string; - flushSave: () => Promise; -}>(); +const props = withDefaults( + defineProps<{ + open: boolean; + workflowId: string; + flushSave: () => Promise; + canSubmit?: boolean; + }>(), + { canSubmit: true }, +); const emit = defineEmits<{ 'update:open': [value: boolean]; @@ -47,7 +51,7 @@ const workflowReviewRequestId = computed( ); const isSubmitDisabled = computed( - () => isSubmitting.value || versionName.value.trim().length === 0, + () => isSubmitting.value || !props.canSubmit || versionName.value.trim().length === 0, ); watch( @@ -63,13 +67,13 @@ const close = () => { }; /** The open review may have been closed elsewhere in the meantime — refetch once before giving up. */ -const resolveOpenReviewRequestId = async (workflowId: string): Promise => { +const resolveOpenReviewRequest = async (workflowId: string) => { let request = reviewStatusStore.openReviewRequest(workflowId); if (!request) { await reviewStatusStore.fetchStatus(workflowId); request = reviewStatusStore.openReviewRequest(workflowId); } - return request?.id; + return request; }; const submit = async () => { @@ -97,8 +101,8 @@ const submit = async () => { return; } - const workflowReviewRequestId = await resolveOpenReviewRequestId(workflowId); - if (!workflowReviewRequestId) { + const openReviewRequest = await resolveOpenReviewRequest(workflowId); + if (!openReviewRequest) { toast.showError( new Error(i18n.baseText('workflowReviews.updateReview.error.noOpenReview')), i18n.baseText('workflowReviews.updateReview.error.title'), @@ -106,8 +110,12 @@ const submit = async () => { emit('update:open', false); return; } + if (openReviewRequest.workflowVersionId === workflowVersionId) { + emit('update:open', false); + return; + } - await updateWorkflowReviewRequestVersion(rootStore.restApiContext, workflowReviewRequestId, { + await updateWorkflowReviewRequestVersion(rootStore.restApiContext, openReviewRequest.id, { workflowId, workflowVersionId, workflowVersionName: trimmedVersionName, From 7784aa229888eb871453584cc1297264fee04e92 Mon Sep 17 00:00:00 2001 From: Michael Drury Date: Mon, 10 Aug 2026 13:31:07 +0100 Subject: [PATCH 021/151] fix(core): Show custom OpenAI-compatible models in Agents (no-changelog) (#35781) --- .../__tests__/model-discovery.test.ts | 14 +- .../ai-utilities/src/model-discovery/index.ts | 6 +- .../src/model-discovery/providers/openai.ts | 12 +- .../agent-model-catalog.service.test.ts | 149 +++++++++++---- .../agents/agent-model-catalog.service.ts | 73 ++++++-- .../builder-model-live-lookup.service.test.ts | 169 +++++++++++++++++- .../builder-model-live-lookup.service.ts | 138 ++++++++++---- 7 files changed, 471 insertions(+), 90 deletions(-) diff --git a/packages/@n8n/ai-utilities/src/model-discovery/__tests__/model-discovery.test.ts b/packages/@n8n/ai-utilities/src/model-discovery/__tests__/model-discovery.test.ts index 9c136bd59a4..04e8c9ba85a 100644 --- a/packages/@n8n/ai-utilities/src/model-discovery/__tests__/model-discovery.test.ts +++ b/packages/@n8n/ai-utilities/src/model-discovery/__tests__/model-discovery.test.ts @@ -1,4 +1,4 @@ -import { listModelsForProvider, MODEL_DISCOVERY_PROVIDERS } from '../index'; +import { isOpenAiCustomEndpoint, listModelsForProvider, MODEL_DISCOVERY_PROVIDERS } from '../index'; function mockFetch(body: unknown, ok = true, status = 200) { return vi.fn().mockResolvedValue({ @@ -53,6 +53,18 @@ describe('model-discovery', () => { }); describe('openai', () => { + describe('isOpenAiCustomEndpoint', () => { + it.each([ + [undefined, false], + ['https://api.openai.com/v1/', false], + ['https://ai-assistant.n8n.io/v1', false], + ['https://litellm.example.com/v1', true], + ['not-a-valid-url', true], + ] as const)('classifies %s as custom: %s', (baseURL, expected) => { + expect(isOpenAiCustomEndpoint(baseURL)).toBe(expected); + }); + }); + it('filters out non-chat models on the official API and sorts by id', async () => { const fetch = mockFetch({ data: [ diff --git a/packages/@n8n/ai-utilities/src/model-discovery/index.ts b/packages/@n8n/ai-utilities/src/model-discovery/index.ts index 44c81300c10..cd4b38c509c 100644 --- a/packages/@n8n/ai-utilities/src/model-discovery/index.ts +++ b/packages/@n8n/ai-utilities/src/model-discovery/index.ts @@ -58,7 +58,11 @@ export { listGoogleModels } from './providers/google'; export { listGroqModels } from './providers/groq'; export { listMistralModels } from './providers/mistral'; export { listNvidiaModels } from './providers/nvidia'; -export { listOpenAiModels, shouldIncludeOpenAiModel } from './providers/openai'; +export { + isOpenAiCustomEndpoint, + listOpenAiModels, + shouldIncludeOpenAiModel, +} from './providers/openai'; export { listOpenRouterModels } from './providers/openrouter'; export { listVercelModels } from './providers/vercel'; export { listXaiModels } from './providers/xai'; diff --git a/packages/@n8n/ai-utilities/src/model-discovery/providers/openai.ts b/packages/@n8n/ai-utilities/src/model-discovery/providers/openai.ts index 623df1fb910..667ae7fe748 100644 --- a/packages/@n8n/ai-utilities/src/model-discovery/providers/openai.ts +++ b/packages/@n8n/ai-utilities/src/model-discovery/providers/openai.ts @@ -3,6 +3,16 @@ import type { ListModelsFn } from '../types'; const OFFICIAL_OPENAI_HOSTNAMES = ['api.openai.com', 'ai-assistant.n8n.io']; +export function isOpenAiCustomEndpoint(baseURL?: string): boolean { + if (baseURL === undefined) return false; + + try { + return !OFFICIAL_OPENAI_HOSTNAMES.includes(new URL(baseURL).hostname); + } catch { + return true; + } +} + /** * Source: LMChatOpenAi `methods/loadModels.ts` — on the official API, exclude * non-chat model families; on custom (proxy/self-hosted) hosts include all. @@ -28,7 +38,7 @@ export function shouldIncludeOpenAiModel(modelId: string, isCustomApi: boolean): /** Source: LMChatOpenAi `methods/loadModels.ts` (GET /models, filtered, id asc). */ export const listOpenAiModels: ListModelsFn = async (options) => { const base = baseUrl(options, 'https://api.openai.com/v1'); - const isCustomApi = !OFFICIAL_OPENAI_HOSTNAMES.includes(new URL(base).hostname); + const isCustomApi = isOpenAiCustomEndpoint(base); const data = (await getJson(`${base}/models`, bearerHeaders(options), options, 'openai')) as { data?: IdItem[]; }; 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 10ae6391719..8ea0e317fe9 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 @@ -50,8 +50,9 @@ const catalogFixture = { function makeService() { const lookupService = mock(); - const service = new AgentModelCatalogService(mockLogger(), lookupService); - return { service, lookupService }; + const logger = mockLogger(); + const service = new AgentModelCatalogService(logger, lookupService); + return { service, lookupService, logger }; } describe('AgentModelCatalogService', () => { @@ -63,9 +64,11 @@ describe('AgentModelCatalogService', () => { it('keeps catalog models the provider still reports live, with catalog metadata, and prunes the rest', async () => { const { service, lookupService } = makeService(); // Provider reports Sonnet but not Opus — Opus (retired) must be pruned. - lookupService.list.mockResolvedValue([ - { name: 'Claude Sonnet 4.6', value: 'claude-sonnet-4-6' }, - ]); + lookupService.lookup.mockResolvedValue({ + status: 'success', + policy: 'curated', + models: [{ name: 'Claude Sonnet 4.6', value: 'claude-sonnet-4-6' }], + }); const result = await service.getProviderModels(user, 'project-1', 'anthropic', credentialId); @@ -76,7 +79,7 @@ describe('AgentModelCatalogService', () => { name: 'Claude Sonnet 4.6', cost: { input: 3, output: 15 }, }); - expect(lookupService.list).toHaveBeenCalledWith( + expect(lookupService.lookup).toHaveBeenCalledWith( user, 'project-1', credentialId, @@ -88,9 +91,11 @@ describe('AgentModelCatalogService', () => { 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. - lookupService.list.mockResolvedValue([ - { name: 'Claude Sonnet 4.6', value: 'claude-sonnet-4-6' }, - ]); + lookupService.lookup.mockResolvedValue({ + status: 'success', + policy: 'managed', + models: [{ name: 'Claude Sonnet 4.6', value: 'claude-sonnet-4-6' }], + }); const result = await service.getProviderModels( user, @@ -101,7 +106,7 @@ describe('AgentModelCatalogService', () => { expect(result.verified).toBe(true); expect(result.models.map((m) => m.id)).toEqual(['claude-sonnet-4-6']); - expect(lookupService.list).toHaveBeenCalledWith( + expect(lookupService.lookup).toHaveBeenCalledWith( user, 'project-1', AI_GATEWAY_MANAGED_TAG, @@ -110,9 +115,9 @@ describe('AgentModelCatalogService', () => { ); }); - it('flags a failed managed lookup as unavailable instead of an empty allowlist', async () => { + it('does not use the static catalog when a managed lookup throws before returning a policy', async () => { const { service, lookupService } = makeService(); - lookupService.list.mockRejectedValue(new Error('gateway unreachable')); + lookupService.lookup.mockRejectedValue(new Error('gateway unreachable')); const result = await service.getProviderModels( user, @@ -123,8 +128,13 @@ describe('AgentModelCatalogService', () => { // No static-catalog fallback for a managed slot: it would offer models the // gateway won't serve. But an outage must not read as "allowlist is empty". - expect(result.models).toEqual([]); - expect(result.unavailable).toBe(true); + expect(result).toEqual({ + provider: 'anthropic', + verified: true, + unavailable: true, + models: [], + }); + expect(fetchProviderCatalog).not.toHaveBeenCalled(); }); it('uses the gateway exact (snapshot) id for the managed tag, not the catalog alias', async () => { @@ -144,9 +154,11 @@ describe('AgentModelCatalogService', () => { }, }); // The gateway returns the dated snapshot — its allowlist matches this, not the alias. - lookupService.list.mockResolvedValue([ - { name: 'Claude Haiku 4.5', value: 'claude-haiku-4-5-20251001' }, - ]); + lookupService.lookup.mockResolvedValue({ + status: 'success', + policy: 'managed', + models: [{ name: 'Claude Haiku 4.5', value: 'claude-haiku-4-5-20251001' }], + }); const result = await service.getProviderModels( user, @@ -164,7 +176,11 @@ describe('AgentModelCatalogService', () => { it('leaves reasoning support unknown for managed models missing from the catalog', async () => { const { service, lookupService } = makeService(); - lookupService.list.mockResolvedValue([{ name: 'Claude Brand New', value: 'claude-brand-new' }]); + lookupService.lookup.mockResolvedValue({ + status: 'success', + policy: 'managed', + models: [{ name: 'Claude Brand New', value: 'claude-brand-new' }], + }); const result = await service.getProviderModels( user, @@ -204,9 +220,11 @@ describe('AgentModelCatalogService', () => { }); // …while Anthropic's API lists the dated snapshot only. The snapshot must // verify its alias; the retired alias with no live counterpart stays pruned. - lookupService.list.mockResolvedValue([ - { name: 'Claude Haiku 4.5', value: 'claude-haiku-4-5-20251001' }, - ]); + lookupService.lookup.mockResolvedValue({ + status: 'success', + policy: 'curated', + models: [{ name: 'Claude Haiku 4.5', value: 'claude-haiku-4-5-20251001' }], + }); const result = await service.getProviderModels(user, 'project-1', 'anthropic', credentialId); @@ -217,10 +235,14 @@ describe('AgentModelCatalogService', () => { it('does not add live models that are missing from the catalog', async () => { const { service, lookupService } = makeService(); // Live list includes a model models.dev has no entry for, alongside a known one. - lookupService.list.mockResolvedValue([ - { name: 'Claude Sonnet 4.6', value: 'claude-sonnet-4-6' }, - { name: 'Claude Brand New', value: 'claude-brand-new' }, - ]); + lookupService.lookup.mockResolvedValue({ + status: 'success', + policy: 'curated', + models: [ + { name: 'Claude Sonnet 4.6', value: 'claude-sonnet-4-6' }, + { name: 'Claude Brand New', value: 'claude-brand-new' }, + ], + }); const result = await service.getProviderModels(user, 'project-1', 'anthropic', credentialId); @@ -231,9 +253,11 @@ describe('AgentModelCatalogService', () => { it('strips the "models/" prefix from google model ids before matching', async () => { const { service, lookupService } = makeService(); - lookupService.list.mockResolvedValue([ - { name: 'models/gemini-2.5-flash', value: 'models/gemini-2.5-flash' }, - ]); + lookupService.lookup.mockResolvedValue({ + status: 'success', + policy: 'curated', + models: [{ name: 'models/gemini-2.5-flash', value: 'models/gemini-2.5-flash' }], + }); const result = await service.getProviderModels(user, 'project-1', 'google', credentialId); @@ -244,7 +268,7 @@ describe('AgentModelCatalogService', () => { it('falls back to the catalog list (verified: false) when the live lookup fails', async () => { const { service, lookupService } = makeService(); - lookupService.list.mockRejectedValue(new Error('provider is down')); + lookupService.lookup.mockRejectedValue(new Error('provider is down')); const result = await service.getProviderModels(user, 'project-1', 'anthropic', credentialId); @@ -252,6 +276,55 @@ describe('AgentModelCatalogService', () => { expect(result.models.map((m) => m.id).sort()).toEqual(['claude-opus-4-0', 'claude-sonnet-4-6']); }); + it('uses only exact live models for a custom OpenAI-compatible endpoint', async () => { + const { service, lookupService } = makeService(); + lookupService.lookup.mockResolvedValue({ + status: 'success', + policy: 'endpoint-only', + models: [ + { name: 'Gemini 2.5 Flash', value: 'gemini-2.5-flash' }, + { name: 'GLM 4.5', value: 'glm-4.5' }, + { name: '', value: 'qwen3-coder' }, + ], + }); + + const result = await service.getProviderModels(user, 'project-1', 'openai', credentialId); + + expect(result).toEqual({ + provider: 'openai', + verified: true, + models: [ + { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', toolCall: true }, + { id: 'glm-4.5', name: 'GLM 4.5', toolCall: true }, + { id: 'qwen3-coder', name: 'qwen3-coder', toolCall: true }, + ], + }); + expect(fetchProviderCatalog).not.toHaveBeenCalled(); + }); + + it('reports a custom OpenAI-compatible endpoint as unavailable without catalog fallback', async () => { + const { service, lookupService, logger } = makeService(); + lookupService.lookup.mockResolvedValue({ + status: 'unavailable', + policy: 'endpoint-only', + error: new Error('endpoint unavailable'), + }); + + const result = await service.getProviderModels(user, 'project-1', 'openai', credentialId); + + expect(result).toEqual({ + provider: 'openai', + verified: true, + unavailable: true, + models: [], + }); + expect(fetchProviderCatalog).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Live model list failed'), + expect.objectContaining({ provider: 'openai', error: 'endpoint unavailable' }), + ); + }); + it('falls back to the catalog list (verified: false) when no credential is provided', async () => { const { service, lookupService } = makeService(); @@ -259,7 +332,7 @@ describe('AgentModelCatalogService', () => { expect(result.verified).toBe(false); expect(result.models).toHaveLength(2); - expect(lookupService.list).not.toHaveBeenCalled(); + expect(lookupService.lookup).not.toHaveBeenCalled(); }); it('falls back to the catalog list for providers without a live lookup', async () => { @@ -283,15 +356,17 @@ describe('AgentModelCatalogService', () => { expect(result.verified).toBe(false); expect(result.models).toHaveLength(1); - expect(lookupService.list).not.toHaveBeenCalled(); + expect(lookupService.lookup).not.toHaveBeenCalled(); }); it('still returns live models (verified: true) when the catalog fetch fails', async () => { const { service, lookupService } = makeService(); fetchProviderCatalog.mockRejectedValue(new Error('models.dev unreachable')); - lookupService.list.mockResolvedValue([ - { name: 'Claude Sonnet 4.6', value: 'claude-sonnet-4-6' }, - ]); + lookupService.lookup.mockResolvedValue({ + status: 'success', + policy: 'curated', + models: [{ name: 'Claude Sonnet 4.6', value: 'claude-sonnet-4-6' }], + }); const result = await service.getProviderModels(user, 'project-1', 'anthropic', credentialId); @@ -305,7 +380,11 @@ describe('AgentModelCatalogService', () => { it('returns an empty unverified list when both the lookup and the catalog fail', async () => { const { service, lookupService } = makeService(); fetchProviderCatalog.mockRejectedValue(new Error('models.dev unreachable')); - lookupService.list.mockRejectedValue(new Error('provider is down')); + lookupService.lookup.mockResolvedValue({ + status: 'unavailable', + policy: 'curated', + error: new Error('provider is down'), + }); const result = await service.getProviderModels(user, 'project-1', 'anthropic', credentialId); 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 3c7ce510173..37f9fe98ab4 100644 --- a/packages/cli/src/modules/agents/agent-model-catalog.service.ts +++ b/packages/cli/src/modules/agents/agent-model-catalog.service.ts @@ -10,7 +10,10 @@ import { Service } from '@n8n/di'; import { isModelDiscoveryProvider } from '@n8n/ai-utilities/model-discovery'; -import { BuilderModelLiveLookupService } from './builder/builder-model-live-lookup.service'; +import { + BuilderModelLiveLookupService, + type LiveModelLookupResult, +} from './builder/builder-model-live-lookup.service'; /** Google's models API returns ids as `models/`; the AI SDK expects the bare id. */ const GOOGLE_MODEL_ID_PREFIX = 'models/'; @@ -45,15 +48,11 @@ function liveModelIdVariants(id: string): string[] { /** * Builds the model list offered in the agent model picker for one provider. * - * The curated models.dev catalog is the display list — it is the up-to-date set - * of chat models with names, cost, and limits. But it lags provider - * retirements, so a listed model can 404 at call time. When a credential is - * available we verify the catalog against the provider's own model API (via the - * shared `@n8n/ai-utilities/model-discovery` functions) and prune any catalog - * entry the provider no longer reports. We never add live-only - * models: provider `/models` endpoints return every variant/snapshot and would - * overload the picker. Without a credential, or when the provider has no list - * API wired up, the catalog list is returned unpruned with `verified: false`. + * For curated providers, models.dev is the display list and live discovery + * verifies/prunes it. Custom OpenAI-compatible endpoints are the exception: + * their live `/models` response is authoritative and models.dev is never read. + * The managed gateway also uses exact live ids, enriched from models.dev when + * available. Without live discovery, curated catalog models remain unverified. */ @Service() export class AgentModelCatalogService { @@ -62,22 +61,23 @@ export class AgentModelCatalogService { private readonly builderModelLiveLookupService: BuilderModelLiveLookupService, ) {} + /** Returns the provider's models according to the live lookup's catalog policy. */ async getProviderModels( user: User, projectId: string, provider: string, credentialId?: string, ): Promise { - const catalogModels = await this.getCatalogModels(provider); const credentialType = getProviderCredentialType(provider); if (!credentialId || !credentialType) { + const catalogModels = await this.getCatalogModels(provider); return { provider, verified: false, models: Object.values(catalogModels) }; } - let liveModels: Array<{ name: string; value: string }>; + let lookup: LiveModelLookupResult; try { - liveModels = await this.builderModelLiveLookupService.list( + lookup = await this.builderModelLiveLookupService.lookup( user, projectId, credentialId, @@ -85,10 +85,7 @@ export class AgentModelCatalogService { provider, ); } catch (error) { - this.logger.warn('Live model list failed — falling back to the static catalog', { - provider, - error: error instanceof Error ? error.message : String(error), - }); + this.logLiveLookupFailure(provider, error); // Managed slot: the gateway allowlist is the contract, so don't fall back // to the static catalog (it may list models the gateway won't serve). // Flagged unavailable so the picker distinguishes a failed lookup from a @@ -96,14 +93,47 @@ export class AgentModelCatalogService { if (credentialId === AI_GATEWAY_MANAGED_TAG) { return { provider, verified: true, unavailable: true, models: [] }; } + const catalogModels = await this.getCatalogModels(provider); return { provider, verified: false, models: Object.values(catalogModels) }; } + // A custom endpoint can expose any OpenAI-compatible model id. Its live + // response is authoritative, so keep exact ids and attach no inferred or + // models.dev metadata. + if (lookup.policy === 'endpoint-only') { + if (lookup.status === 'unavailable') { + this.logLiveLookupFailure(provider, lookup.error); + return { provider, verified: true, unavailable: true, models: [] }; + } + + return { + provider, + verified: true, + models: lookup.models.map((live) => ({ + id: live.value, + name: live.name || live.value, + toolCall: true, + })), + }; + } + + if (lookup.status === 'unavailable') { + this.logLiveLookupFailure(provider, lookup.error); + if (lookup.policy === 'managed' || credentialId === AI_GATEWAY_MANAGED_TAG) { + return { provider, verified: true, unavailable: true, models: [] }; + } + const catalogModels = await this.getCatalogModels(provider); + return { provider, verified: false, models: Object.values(catalogModels) }; + } + + const liveModels = lookup.models; + const catalogModels = await this.getCatalogModels(provider); + // n8n Connect managed slot: the gateway's `/models` is the authoritative, // allowlist-filtered set, and its exact ids are what the gateway's model // allowlist matches (e.g. a dated snapshot, not the catalog's versionless // alias). Use those ids verbatim, enriched with catalog display/metadata. - if (credentialId === AI_GATEWAY_MANAGED_TAG) { + if (lookup.policy === 'managed') { return { provider, verified: true, @@ -157,6 +187,13 @@ export class AgentModelCatalogService { }; } + private logLiveLookupFailure(provider: string, error: unknown): void { + this.logger.warn('Live model list failed', { + provider, + error: error instanceof Error ? error.message : String(error), + }); + } + private async getCatalogModels(provider: string): Promise> { try { const { fetchProviderCatalog } = await import('@n8n/agents'); diff --git a/packages/cli/src/modules/agents/builder/__tests__/builder-model-live-lookup.service.test.ts b/packages/cli/src/modules/agents/builder/__tests__/builder-model-live-lookup.service.test.ts index f4cc16bdb20..689cc1fbff2 100644 --- a/packages/cli/src/modules/agents/builder/__tests__/builder-model-live-lookup.service.test.ts +++ b/packages/cli/src/modules/agents/builder/__tests__/builder-model-live-lookup.service.test.ts @@ -10,7 +10,8 @@ import type { AiGatewayService } from '@/services/ai-gateway.service'; import { BuilderModelLiveLookupService } from '../builder-model-live-lookup.service'; const listModelsForProvider = vi.fn(); -vi.mock('@n8n/ai-utilities/model-discovery', () => ({ +vi.mock('@n8n/ai-utilities/model-discovery', async (importOriginal) => ({ + ...(await importOriginal()), listModelsForProvider: (...args: unknown[]) => listModelsForProvider(...args) as unknown, })); @@ -46,6 +47,172 @@ describe('BuilderModelLiveLookupService', () => { listModelsForProvider.mockReset(); }); + describe('lookup', () => { + it('returns endpoint-only models and forwards OpenAI credential request options', async () => { + const { service, credentialsService, credentialsFinderService } = makeService(); + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue( + usable('cred-1', 'openAiApi'), + ); + credentialsFinderService.findCredentialById.mockResolvedValue(mock()); + credentialsService.decrypt.mockResolvedValue({ + apiKey: 'sk-key', + url: 'https://openai-compatible.example/v1', + organizationId: 'org-123', + header: true, + headerName: 'authorization', + headerValue: 'Bearer custom-token', + }); + listModelsForProvider.mockResolvedValue([{ id: 'custom-model', name: 'Custom model' }]); + + const result = await service.lookup(user, projectId, 'cred-1', 'openAiApi', 'openai'); + + expect(result).toEqual({ + status: 'success', + policy: 'endpoint-only', + models: [{ name: 'Custom model', value: 'custom-model' }], + }); + expect(listModelsForProvider).toHaveBeenCalledWith( + 'openai', + expect.objectContaining({ + apiKey: 'sk-key', + baseURL: 'https://openai-compatible.example/v1', + headers: { + Authorization: 'Bearer custom-token', + 'OpenAI-Organization': 'org-123', + }, + }), + ); + }); + + it('lets a custom OpenAI organization header override the credential organization', async () => { + const { service, credentialsService, credentialsFinderService } = makeService(); + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue( + usable('cred-1', 'openAiApi'), + ); + credentialsFinderService.findCredentialById.mockResolvedValue(mock()); + credentialsService.decrypt.mockResolvedValue({ + apiKey: 'sk-key', + url: 'https://openai-compatible.example/v1', + organizationId: 'org-default', + header: true, + headerName: 'openai-organization', + headerValue: 'org-custom', + }); + listModelsForProvider.mockResolvedValue([{ id: 'custom-model', name: 'Custom model' }]); + + await service.lookup(user, projectId, 'cred-1', 'openAiApi', 'openai'); + + expect(listModelsForProvider).toHaveBeenCalledWith( + 'openai', + expect.objectContaining({ + headers: { 'OpenAI-Organization': 'org-custom' }, + }), + ); + }); + + it('returns curated models for the official OpenAI endpoint', async () => { + const { service, credentialsService, credentialsFinderService } = makeService(); + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue( + usable('cred-1', 'openAiApi'), + ); + credentialsFinderService.findCredentialById.mockResolvedValue(mock()); + credentialsService.decrypt.mockResolvedValue({ + apiKey: 'sk-key', + url: 'https://api.openai.com/v1', + }); + listModelsForProvider.mockResolvedValue([{ id: 'gpt-5', name: 'GPT-5' }]); + + const result = await service.lookup(user, projectId, 'cred-1', 'openAiApi', 'openai'); + + expect(result).toEqual({ + status: 'success', + policy: 'curated', + models: [{ name: 'GPT-5', value: 'gpt-5' }], + }); + }); + + it('preserves endpoint-only policy when custom OpenAI discovery fails', async () => { + const { service, credentialsService, credentialsFinderService } = makeService(); + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue( + usable('cred-1', 'openAiApi'), + ); + credentialsFinderService.findCredentialById.mockResolvedValue(mock()); + credentialsService.decrypt.mockResolvedValue({ + apiKey: 'sk-key', + url: 'https://openai-compatible.example/v1', + }); + const error = new Error('Model listing failed'); + listModelsForProvider.mockRejectedValue(error); + + const result = await service.lookup(user, projectId, 'cred-1', 'openAiApi', 'openai'); + + expect(result).toEqual({ status: 'unavailable', policy: 'endpoint-only', error }); + }); + + it('preserves endpoint-only policy when custom OpenAI discovery is empty', async () => { + const { service, credentialsService, credentialsFinderService } = makeService(); + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue( + usable('cred-1', 'openAiApi'), + ); + credentialsFinderService.findCredentialById.mockResolvedValue(mock()); + credentialsService.decrypt.mockResolvedValue({ + apiKey: 'sk-key', + url: 'https://openai-compatible.example/v1', + }); + listModelsForProvider.mockResolvedValue([]); + + const result = await service.lookup(user, projectId, 'cred-1', 'openAiApi', 'openai'); + + expect(result).toEqual({ + status: 'unavailable', + policy: 'endpoint-only', + error: expect.objectContaining({ message: 'Provider openai returned no models' }), + }); + }); + + it('returns managed policy for the n8n Connect managed tag', async () => { + const { service, aiGatewayService } = makeService(); + aiGatewayService.getCredentialTypeForProvider.mockResolvedValue('openAiApi'); + aiGatewayService.getSyntheticCredential.mockResolvedValue({ + apiKey: 'gateway-jwt', + url: 'https://gw.example/v1/gateway/openai/v1', + }); + listModelsForProvider.mockResolvedValue([{ id: 'gpt-5-mini', name: 'GPT-5 mini' }]); + + const result = await service.lookup( + user, + projectId, + AI_GATEWAY_MANAGED_TAG, + 'openAiApi', + 'openai', + ); + + expect(result).toEqual({ + status: 'success', + policy: 'managed', + models: [{ name: 'GPT-5 mini', value: 'gpt-5-mini' }], + }); + }); + + it('keeps list compatible with successful and unavailable lookups', async () => { + const { service } = makeService(); + const models = [{ name: 'GPT-5', value: 'gpt-5' }]; + const error = new Error('Model listing failed'); + const lookup = vi + .spyOn(service, 'lookup') + .mockResolvedValueOnce({ status: 'success', policy: 'curated', models }) + .mockResolvedValueOnce({ status: 'unavailable', policy: 'curated', error }); + + await expect(service.list(user, projectId, 'cred-1', 'openAiApi', 'openai')).resolves.toBe( + models, + ); + await expect(service.list(user, projectId, 'cred-1', 'openAiApi', 'openai')).rejects.toBe( + error, + ); + expect(lookup).toHaveBeenCalledTimes(2); + }); + }); + it('lists models for a credential the user can use in the project', async () => { const { service, credentialsService, credentialsFinderService } = makeService(); credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue( diff --git a/packages/cli/src/modules/agents/builder/builder-model-live-lookup.service.ts b/packages/cli/src/modules/agents/builder/builder-model-live-lookup.service.ts index 3b311825cbc..6085a0716ea 100644 --- a/packages/cli/src/modules/agents/builder/builder-model-live-lookup.service.ts +++ b/packages/cli/src/modules/agents/builder/builder-model-live-lookup.service.ts @@ -10,6 +10,16 @@ import { createAiProxyFetch } from '@/utils/ai-proxy-fetch'; import { mapCredentialForProvider } from '../json-config/credential-field-mapping'; +export type ModelCatalogPolicy = 'curated' | 'endpoint-only' | 'managed'; + +export type LiveModelLookupResult = + | { + status: 'success'; + models: Array<{ name: string; value: string }>; + policy: ModelCatalogPolicy; + } + | { status: 'unavailable'; error: unknown; policy: ModelCatalogPolicy }; + /** * Fetches a provider's live chat-model list for a credential, via the shared * `@n8n/ai-utilities/model-discovery` functions (the same provider knowledge @@ -42,10 +52,20 @@ export class BuilderModelLiveLookupService { credentialType: string, provider: string, ): Promise> { - // n8n Connect managed slot: there is no stored credential to decrypt — - // resolve the synthetic gateway credential instead, then discover as usual. + const result = await this.lookup(user, projectId, credentialId, credentialType, provider); + if (result.status === 'unavailable') throw result.error; + return result.models; + } + + async lookup( + user: User, + projectId: string, + credentialId: string, + credentialType: string, + provider: string, + ): Promise { if (credentialId === AI_GATEWAY_MANAGED_TAG) { - return await this.listAiGatewayManagedModels(projectId, provider, user); + return await this.lookupAiGatewayManagedModels(projectId, provider, user); } const usableCredentials = await this.credentialsService.getCredentialsAUserCanUseInAWorkflow( @@ -67,55 +87,107 @@ export class BuilderModelLiveLookupService { } /** - * Lists the chat models n8n Connect (AI Gateway) allows for a provider, via - * the synthetic gateway credential — discovery hits the gateway's `/models`, - * already filtered to the allowlist. Throws if the gateway does not serve it. + * Looks up the chat models n8n Connect (AI Gateway) allows for a provider. + * Gateway resolution or discovery failures are returned as managed-policy + * unavailability so `list` can preserve its throwing behavior. */ - private async listAiGatewayManagedModels( + private async lookupAiGatewayManagedModels( projectId: string, provider: string, user?: User, - ): Promise> { - const credentialType = await this.aiGatewayService.getCredentialTypeForProvider(provider); - if (!credentialType) { - throw new Error(`n8n credits does not support the "${provider}" model provider`); + ): Promise { + try { + const credentialType = await this.aiGatewayService.getCredentialTypeForProvider(provider); + if (!credentialType) { + throw new Error(`n8n credits does not support the "${provider}" model provider`); + } + const raw = await this.aiGatewayService.getSyntheticCredential({ + credentialType, + userId: user?.id, + projectId, + }); + return await this.discoverModels(provider, raw, 'managed'); + } catch (error) { + return { status: 'unavailable', error, policy: 'managed' }; } - const raw = await this.aiGatewayService.getSyntheticCredential({ - credentialType, - userId: user?.id, - projectId, - }); - return await this.discoverModels(provider, raw); } /** * Runs provider model discovery against a raw credential record (real or the - * gateway synthetic credential), returning `{ name, value }` pairs. + * gateway synthetic credential), preserving the applicable catalog policy. */ private async discoverModels( provider: string, rawData: object, - ): Promise> { - const { listModelsForProvider } = await import('@n8n/ai-utilities/model-discovery'); - const mapped = mapCredentialForProvider(provider, { apiKey: '', ...rawData }); + policyOverride?: ModelCatalogPolicy, + ): Promise { + const { isOpenAiCustomEndpoint, listModelsForProvider } = await import( + '@n8n/ai-utilities/model-discovery' + ); + const credentialData: Record = { apiKey: '', ...rawData }; + const mapped = mapCredentialForProvider(provider, credentialData); const apiKey = typeof mapped.apiKey === 'string' ? mapped.apiKey : ''; const baseURL = typeof mapped.baseURL === 'string' && mapped.baseURL ? mapped.baseURL : undefined; + const policy = + policyOverride ?? + (provider === 'openai' && isOpenAiCustomEndpoint(baseURL) ? 'endpoint-only' : 'curated'); + const headers = this.getOpenAiHeaders(provider, credentialData); - const models = await listModelsForProvider(provider, { - apiKey, - baseURL, - fetch: createAiProxyFetch(this.outboundHttp) as typeof globalThis.fetch, - }); + try { + const models = await listModelsForProvider(provider, { + apiKey, + baseURL, + fetch: createAiProxyFetch(this.outboundHttp) as typeof globalThis.fetch, + ...(headers ? { headers } : {}), + }); - // Every supported chat provider offers models, so an empty list means a - // broken request or a drifted response shape, not a zero-model account. - // Throw so callers fall back (unverified catalog / lookup-failed) instead - // of treating "nothing" as a verified answer and pruning every model. - if (models.length === 0) { - throw new Error(`Provider ${provider} returned no models`); + // Every supported chat provider offers models, so an empty list means a + // broken request or a drifted response shape, not a zero-model account. + if (models.length === 0) { + throw new Error(`Provider ${provider} returned no models`); + } + + return { + status: 'success', + models: models.map((model) => ({ name: model.name, value: model.id })), + policy, + }; + } catch (error) { + return { status: 'unavailable', error, policy }; + } + } + + private getOpenAiHeaders( + provider: string, + credentialData: Record, + ): Record | undefined { + if (provider !== 'openai') return undefined; + + const headers: Record = {}; + if ( + typeof credentialData.organizationId === 'string' && + credentialData.organizationId.length > 0 + ) { + headers['OpenAI-Organization'] = credentialData.organizationId; } - return models.map((model) => ({ name: model.name, value: model.id })); + if ( + credentialData.header === true && + typeof credentialData.headerName === 'string' && + credentialData.headerName.length > 0 && + typeof credentialData.headerValue === 'string' + ) { + const normalizedName = credentialData.headerName.toLowerCase(); + const headerName = + normalizedName === 'authorization' + ? 'Authorization' + : normalizedName === 'openai-organization' + ? 'OpenAI-Organization' + : credentialData.headerName; + headers[headerName] = credentialData.headerValue; + } + + return Object.keys(headers).length > 0 ? headers : undefined; } } From aa03666bc69e5d642865ba221ecb33d0655c4559 Mon Sep 17 00:00:00 2001 From: Mat Leszkiewicz <137778020+mleszkiewicz@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:45:59 +0200 Subject: [PATCH 022/151] fix(core): Strip leading BOM before parsing webhook request bodies (#35933) --- packages/cli/src/middlewares/body-parser.ts | 12 ++- .../middlewares/body-parser.test.ts | 79 ++++++++++++++++++- 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/middlewares/body-parser.ts b/packages/cli/src/middlewares/body-parser.ts index 2c26098fb5c..f5b9844faf4 100644 --- a/packages/cli/src/middlewares/body-parser.ts +++ b/packages/cli/src/middlewares/body-parser.ts @@ -23,6 +23,14 @@ const xmlParser = new XmlParser({ const payloadSizeMax = Container.get(GlobalConfig).endpoints.payloadSizeMax; +/** + * A leading byte order mark is not valid JSON, but RFC 8259 §8.1 permits + * implementations to ignore it rather than treating it as an error. Both the + * UTF-8 (EF BB BF) and UTF-16 (FF FE) BOMs decode to U+FEFF, so stripping the + * decoded character covers every declared charset. + */ +const stripBom = (text: string) => (text.charCodeAt(0) === 0xfeff ? text.slice(1) : text); + const isClientAbortError = (error: unknown): boolean => error instanceof Error && 'type' in error && @@ -87,12 +95,12 @@ export const parseBody = async (req: Request) => { try { if (contentType === 'application/json') { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - req.body = jsonParse(rawBody.toString(encoding)); + req.body = jsonParse(stripBom(rawBody.toString(encoding))); } else if (contentType?.endsWith('/xml') || contentType?.endsWith('+xml')) { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment req.body = await xmlParser.parseStringPromise(rawBody.toString(encoding)); } else if (contentType === 'application/x-www-form-urlencoded') { - req.body = parseQueryString(rawBody.toString(encoding), undefined, undefined, { + req.body = parseQueryString(stripBom(rawBody.toString(encoding)), undefined, undefined, { maxKeys: 1000, }); } else if (contentType === 'text/plain') { diff --git a/packages/cli/test/integration/middlewares/body-parser.test.ts b/packages/cli/test/integration/middlewares/body-parser.test.ts index 516df8fe282..de3bcdb80e5 100644 --- a/packages/cli/test/integration/middlewares/body-parser.test.ts +++ b/packages/cli/test/integration/middlewares/body-parser.test.ts @@ -5,7 +5,8 @@ import request from 'supertest'; import { gzipSync, deflateSync } from 'zlib'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { rawBodyReader, bodyParser } from '@/middlewares/body-parser'; +import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; +import { rawBodyReader, bodyParser, parseBody } from '@/middlewares/body-parser'; describe('bodyParser', () => { const server = createServer((req, res) => { @@ -65,4 +66,80 @@ describe('bodyParser', () => { await expect(req.readRawBody()).rejects.toThrow(BadRequestError); }); + + describe('leading byte order mark', () => { + const utf8Bom = Buffer.from([0xef, 0xbb, 0xbf]); + const utf16leBom = Buffer.from([0xff, 0xfe]); + + // supertest/superagent JSON-serializes any non-string `.send()` payload + // whose content-type matches a registered serializer (application/json, + // x-www-form-urlencoded), which would silently mangle these BOM-prefixed + // buffers. An identity `.serialize()` override keeps the raw bytes intact + // on the wire — necessary for a byte-for-byte BOM test. + const identitySerializer = ((data: unknown) => data) as (obj: unknown) => string; + const sendRaw = (body: Buffer) => + request(server).post('/').serialize(identitySerializer).send(body); + + it('should strip a UTF-8 BOM before parsing an application/json body', async () => { + const body = Buffer.concat([utf8Bom, Buffer.from(JSON.stringify({ hello: 'world' }))]); + const response = await sendRaw(body).set('content-type', 'application/json').expect(200); + expect(response.text).toEqual('{"hello":"world"}'); + }); + + it('should parse an application/json body without a BOM unchanged', async () => { + const body = Buffer.from(JSON.stringify({ hello: 'world' })); + const response = await sendRaw(body).set('content-type', 'application/json').expect(200); + expect(response.text).toEqual('{"hello":"world"}'); + }); + + it('should strip a UTF-16LE BOM before parsing an application/json body', async () => { + const body = Buffer.concat([ + utf16leBom, + Buffer.from(JSON.stringify({ hello: 'world' }), 'utf16le'), + ]); + const response = await sendRaw(body) + .set('content-type', 'application/json; charset=utf-16le') + .expect(200); + expect(response.text).toEqual('{"hello":"world"}'); + }); + + it('should strip a BOM before parsing an x-www-form-urlencoded body', async () => { + const body = Buffer.concat([utf8Bom, Buffer.from('a=1')]); + const response = await sendRaw(body) + .set('content-type', 'application/x-www-form-urlencoded') + .expect(200); + expect(JSON.parse(response.text)).toEqual({ a: '1' }); + }); + + it('should not crash on a body that is only a BOM', async () => { + // The bare http.Server test harness above has no error-handling + // middleware to turn a thrown ResponseError into an HTTP response + // (that's the real app's job), so this asserts against parseBody + // directly rather than hanging the request/response round trip. + const req = { + contentType: 'application/json', + encoding: 'utf8', + rawBody: utf8Bom, + readRawBody: async () => {}, + } as unknown as Request; + + await expect(parseBody(req)).rejects.toThrow(UnprocessableRequestError); + }); + + it('should not strip a BOM character inside a JSON string value', async () => { + const valueWithEmbeddedBom = String.fromCharCode(0xfeff) + 'world'; + const body = Buffer.concat([ + utf8Bom, + Buffer.from(JSON.stringify({ hello: valueWithEmbeddedBom })), + ]); + const response = await sendRaw(body).set('content-type', 'application/json').expect(200); + expect(JSON.parse(response.text)).toEqual({ hello: valueWithEmbeddedBom }); + }); + + it('should parse an application/xml body with a leading BOM unchanged', async () => { + const body = Buffer.concat([utf8Bom, Buffer.from('1')]); + const response = await sendRaw(body).set('content-type', 'application/xml').expect(200); + expect(JSON.parse(response.text)).toEqual({ test: { a: '1' } }); + }); + }); }); From f0c507d638f84efb259bea41bdd9114e9cfa8e29 Mon Sep 17 00:00:00 2001 From: Matsu Date: Mon, 10 Aug 2026 15:59:28 +0300 Subject: [PATCH 023/151] ci: Resolve replay stalls toward 3.x in the master sync (no-changelog) (#35951) Co-authored-by: Claude Fable 5 --- .github/DEVELOPING_V3.md | 4 +- .github/scripts/sync-master-to-3x.mjs | 103 ++++++++- .github/scripts/sync-master-to-3x.test.mjs | 213 ++++++++++++++++++- .github/workflows/util-sync-master-to-3x.yml | 4 +- 4 files changed, 312 insertions(+), 12 deletions(-) diff --git a/.github/DEVELOPING_V3.md b/.github/DEVELOPING_V3.md index 89a894f1325..7ac7f289cda 100644 --- a/.github/DEVELOPING_V3.md +++ b/.github/DEVELOPING_V3.md @@ -188,7 +188,9 @@ commits only). The next sync then makes `3.x` linear again. The plain replay stalls at that point (a fix recorded around a merge commit leaves no patch to replay), so it replays a second time with `3.x`'s side favoured, and your fix commit — which is in the queue — does the real work. -Nothing is squashed, and the tree guard proves the result is exactly the merge of `3.x` and +Stalls that favouring cannot settle on its own (e.g. modify/delete, when `master` touched a +file a breaking commit deletes) are resolved toward `3.x`'s side during the replay. Nothing +is squashed, and the tree guard proves the result is exactly the merge of `3.x` and `master`. **Who gets pinged.** The conflict is attributed to the authors of the `3.x` commits behind the diff --git a/.github/scripts/sync-master-to-3x.mjs b/.github/scripts/sync-master-to-3x.mjs index 867cfea9caf..4e57399b293 100644 --- a/.github/scripts/sync-master-to-3x.mjs +++ b/.github/scripts/sync-master-to-3x.mjs @@ -21,7 +21,9 @@ * 2. The replay stalls, but 3.x's content still reconciles with master → an earlier * conflict was resolved in a merge, which leaves no patch to replay. Replay again * favouring the 3.x side (`-X theirs`), mirroring the side the resolved merge kept; - * the human's fix commit is in the queue and does the real work. Tree is then proven. + * the human's fix commit is in the queue and does the real work. Stalls the strategy + * option cannot settle (modify/delete — `-X` never resolves those) are resolved in + * place toward the queue commit's side. Tree is then proven. * 3. master conflicts with 3.x, but ONLY on mechanical files — tool-generated content * with a deterministic resolution (the pnpm lockfile, bot-maintained data files). * These are resolved in place while the replay is stopped, exactly as a human @@ -195,11 +197,35 @@ export function resolveMechanicalPath({ git, pnpm, path, masterSha, log = consol } } +/** + * Resolve one stalled path by taking the side of the queue commit being replayed + * ("theirs" while a rebase is stopped). Exists for the stalls `-X theirs` cannot settle + * itself — modify/delete, which strategy options never resolve: when the queue commit + * deleted the file there is no stage 3, so the deletion wins. Only meaningful on the + * favoured replay passes, where the callers' tree guard proves the final content — a + * wrong pick here fails the run instead of pushing. + */ +export function resolveQueueSidePath({ git, path, log = console.log }) { + // `ls-files -u` lines are ` \t`; stage 3 is the queue side. + const stages = git(['ls-files', '-u', '--', path]); + const queueSideExists = stages.split('\n').some((line) => /^\S+ \S+ 3\t/.test(line)); + if (queueSideExists) { + log(`Resolving ${path} with the replayed commit's version...`); + git(['checkout', '--theirs', '--', path]); + git(['add', '--', path]); + } else { + log(`Resolving ${path} with the replayed commit's deletion...`); + git(['rm', '--force', '--', path]); + } +} + /** * Replay the 3.x-only commits onto master, resolving stalls that involve ONLY mechanical * paths in place — folded into the stalled commit exactly as a human's `rebase --continue` * would, so no commit is added. Bails (leaving the rebase stopped, for the caller to - * abort) as soon as a stall touches a real code path. + * abort) as soon as a stall touches a real code path — unless `favourQueue` is set, in + * which case code-path stalls are resolved with the queue commit's side too. That flag + * belongs only on the favoured (`-X theirs`) passes, whose tree guard proves the outcome. * * Merge commits in the range (breaking PR merges, past conflict-PR merges) are flattened; * commits already applied to master — or fully absorbed by a mechanical resolution — are @@ -211,6 +237,7 @@ export function rebaseResolvingMechanical({ masterSha, log = console.log, extraArgs = [], + favourQueue = false, maxStalls = 50, }) { const resolved = new Set(); @@ -223,7 +250,7 @@ export function rebaseResolvingMechanical({ } const { mechanical, code } = classifyPaths(conflictedFiles(git)); // No conflicted files means the rebase failed for some other reason — don't guess. - if (mechanical.length === 0 || code.length > 0) { + if (mechanical.length + code.length === 0 || (code.length > 0 && !favourQueue)) { if (res.out) log(res.out); return { ok: false, resolved: [...resolved], conflictedCode: code }; } @@ -231,6 +258,9 @@ export function rebaseResolvingMechanical({ resolveMechanicalPath({ git, pnpm, path, masterSha, log }); resolved.add(path); } + for (const path of code) { + resolveQueueSidePath({ git, path, log }); + } // The resolution may have absorbed the whole commit — skip it rather than // letting `--continue` refuse an empty commit. const empty = attempt(git, ['diff-index', '--cached', '--quiet', 'HEAD']).ok; @@ -273,6 +303,42 @@ export function assertNoMarkers(git, rev = 'HEAD') { throw new Error(`Could not scan ${rev} for conflict markers:\n${found.out}`); } +/** + * Fold any residual deviation between the replayed tip and the proven merge tree into the + * tip commit. `-X theirs` resolves overlapping hunks toward the queue side without + * stalling — silently dropping master-side changes (lockfile hunks are the usual case) + * that no fix commit in the queue reasserts. The merge tree is the definition of the + * correct content, so its blobs are taken verbatim. `excludePaths` names files the merge + * tree itself could not resolve (marker-carrying mechanical blobs), which get their own + * reconciliation. Never a commit of its own, and never a rewrite of a master commit. + */ +export function reconcileWithMergeTreeAtTip({ + git, + mergedTree, + masterSha, + excludePaths = [], + log = console.log, +}) { + if (git(['rev-parse', 'HEAD^{tree}']) === mergedTree) return []; + const out = git(['diff-tree', '-r', '--name-only', '--no-renames', mergedTree, 'HEAD']); + const excluded = new Set(excludePaths); + const paths = (out ? out.split('\n') : []).filter((p) => p && !excluded.has(p)); + if (paths.length === 0) return []; + if (git(['rev-parse', 'HEAD']) === masterSha) { + throw new Error('Merge-tree reconciliation would amend a master commit; refusing.'); + } + log(`Folding the merge tree's content for ${paths.join(', ')} into the tip commit.`); + for (const path of paths) { + if (attempt(git, ['cat-file', '-e', `${mergedTree}:${path}`]).ok) { + git(['checkout', mergedTree, '--', path]); + } else { + git(['rm', '--force', '--', path]); // the merge deleted it + } + } + git(['commit', '--amend', '--no-edit', '--no-verify']); + return paths; +} + /** * Make the lockfile at the replayed tip consistent with the tip's own manifests. A regen * at a stall usually already did this, but the `-X theirs` route resolves lockfile hunks @@ -549,9 +615,19 @@ export async function sync({ masterSha, log, extraArgs: ['-X', 'theirs'], + favourQueue: true, }); } if (replay.ok) { + // The favoured retry may have drifted from the merge tree on cleanly-merging + // paths; fold those back before the mechanical files get their own treatment. + reconcileWithMergeTreeAtTip({ + git, + mergedTree: merged.tree, + masterSha, + excludePaths: mechanical, + log, + }); if (mechanical.includes(LOCKFILE)) reconcileLockfileAtTip({ git, pnpm, masterSha, log }); assertTreeMatches(git, merged.tree, mechanical); assertNoMarkers(git); @@ -607,18 +683,31 @@ export async function sync({ // The content reconciles but the patches no longer apply on their own: an earlier // conflict was resolved in a merge, and a merge resolution leaves no patch to replay. // Replay favouring the 3.x side — the same side that resolved merge kept — and let the - // resolver's own fix commit, which is in the queue, do the real work. Mechanical stalls - // the strategy cannot settle (e.g. modify/delete) are resolved in place. Nothing is - // squashed; the tree guard below proves the outcome exactly, since the merge was clean. + // resolver's own fix commit, which is in the queue, do the real work. Stalls the + // strategy option cannot settle (modify/delete — `-X` never resolves those) are + // resolved in place toward the queue commit's side. Nothing is squashed; the tree + // guard below proves the outcome exactly, since the merge was clean. git(['rebase', '--abort']); log(`Patches no longer apply individually; replaying with ${target}'s side favoured.`); - if (!rebaseResolvingMechanical({ git, pnpm, masterSha, log, extraArgs: ['-X', 'theirs'] }).ok) { + const favoured = rebaseResolvingMechanical({ + git, + pnpm, + masterSha, + log, + extraArgs: ['-X', 'theirs'], + favourQueue: true, + }); + if (!favoured.ok) { git(['rebase', '--abort']); throw new Error( `Could not replay ${target} onto master even with its own side favoured; needs a human.`, ); } + // Favouring resolves overlapping hunks toward 3.x without stalling, which can drop + // master-side changes no fix commit reasserts; the merge was clean, so the merge tree + // is the correct content — fold any drift back into the tip. + reconcileWithMergeTreeAtTip({ git, mergedTree: merged.tree, masterSha, log }); assertTreeMatches(git, merged.tree); assertNoMarkers(git); pushReplay(preHead); diff --git a/.github/scripts/sync-master-to-3x.test.mjs b/.github/scripts/sync-master-to-3x.test.mjs index 604a9e1d187..f520ec66ab1 100644 --- a/.github/scripts/sync-master-to-3x.test.mjs +++ b/.github/scripts/sync-master-to-3x.test.mjs @@ -7,7 +7,9 @@ import { classifyPaths, blocksLockfileRegen, resolveMechanicalPath, + resolveQueueSidePath, rebaseResolvingMechanical, + reconcileWithMergeTreeAtTip, reconcileLockfileAtTip, recentAbandonedConflictPrs, assertTreeMatches, @@ -223,6 +225,53 @@ test('rebaseResolvingMechanical bails as soon as a stall touches a code path', ( ); }); +test('resolveQueueSidePath takes the queue commit side, or its deletion when there is no stage 3', () => { + const FILE = 'packages/cli/x.ts'; + const bothSides = makeStub([ + [(a) => a[0] === 'ls-files', `100644 aaa 2\t${FILE}\n100644 bbb 3\t${FILE}`], + ]); + resolveQueueSidePath({ git: bothSides, path: FILE, log: () => {} }); + assert.ok(bothSides.calls.some((a) => a[0] === 'checkout' && a[1] === '--theirs')); + assert.ok(bothSides.calls.some((a) => a[0] === 'add' && a.includes(FILE))); + + // modify/delete with the queue commit deleting: stages 1 and 2 only. + const queueDeleted = makeStub([ + [(a) => a[0] === 'ls-files', `100644 aaa 1\t${FILE}\n100644 bbb 2\t${FILE}`], + ]); + resolveQueueSidePath({ git: queueDeleted, path: FILE, log: () => {} }); + assert.ok(queueDeleted.calls.some((a) => a[0] === 'rm' && a.includes(FILE))); + assert.equal( + queueDeleted.calls.some((a) => a[0] === 'checkout'), + false, + ); +}); + +test('rebaseResolvingMechanical with favourQueue resolves a modify/delete code stall and continues', () => { + const FILE = 'packages/nodes-base/nodes/Function/Function.node.ts'; + const git = makeStub([ + [(a) => a[0] === 'rebase' && a[1] === '--continue', ''], + [isRebase, fail(`CONFLICT (modify/delete): ${FILE} deleted in 0ff923a066`)], + [isConflictedFiles, FILE], + [(a) => a[0] === 'ls-files', `100644 aaa 1\t${FILE}\n100644 bbb 2\t${FILE}`], + [(a) => a[0] === 'diff-index', fail()], // staged deletion -> continue, not skip + ]); + const pnpm = makeStub(); + + const res = rebaseResolvingMechanical({ + git, + pnpm, + masterSha: MASTER, + favourQueue: true, + log: () => {}, + }); + + assert.equal(res.ok, true); + assert.ok(git.calls.some((a) => a[0] === 'rm' && a.includes(FILE))); + assert.ok(git.calls.some((a) => a[0] === 'rebase' && a[1] === '--continue')); + // The stall carried no mechanical file, so nothing mechanical may be touched. + assert.equal(pnpm.calls.length, 0); +}); + test('assertTreeMatches is exact by default and scoped to the allowed paths otherwise', () => { assert.doesNotThrow(() => assertTreeMatches(makeStub([[() => true, MERGE_TREE]]), MERGE_TREE)); assert.throws( @@ -256,6 +305,97 @@ test('assertNoMarkers is a push guard', () => { ); }); +test('reconcileWithMergeTreeAtTip folds favoured-replay drift into the tip commit, never a master commit', () => { + // No drift: nothing to do. + const clean = makeStub([[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD^{tree}', MERGE_TREE]]); + assert.deepEqual( + reconcileWithMergeTreeAtTip({ + git: clean, + mergedTree: MERGE_TREE, + masterSha: MASTER, + log: () => {}, + }), + [], + ); + assert.equal( + clean.calls.some((a) => a[0] === 'commit'), + false, + ); + + // Lockfile drift: take the merge tree's blob and amend the tip. + const drifted = makeStub([ + [(a) => a[0] === 'rev-parse' && a[1] === 'HEAD^{tree}', 'OTHER'], + [(a) => a[0] === 'rev-parse' && a[1] === 'HEAD', PRE_HEAD], + [(a) => a[0] === 'diff-tree', LOCKFILE], + [(a) => a[0] === 'cat-file', ''], + ]); + assert.deepEqual( + reconcileWithMergeTreeAtTip({ + git: drifted, + mergedTree: MERGE_TREE, + masterSha: MASTER, + log: () => {}, + }), + [LOCKFILE], + ); + assert.ok( + drifted.calls.some((a) => a[0] === 'checkout' && a[1] === MERGE_TREE && a.includes(LOCKFILE)), + ); + assert.ok(drifted.calls.some((a) => a[0] === 'commit' && a.includes('--amend'))); + + // Excluded (mechanical) paths are left to their own reconciliation. + const excluded = makeStub([ + [(a) => a[0] === 'rev-parse' && a[1] === 'HEAD^{tree}', 'OTHER'], + [(a) => a[0] === 'diff-tree', LOCKFILE], + ]); + assert.deepEqual( + reconcileWithMergeTreeAtTip({ + git: excluded, + mergedTree: MERGE_TREE, + masterSha: MASTER, + excludePaths: [LOCKFILE], + log: () => {}, + }), + [], + ); + assert.equal( + excluded.calls.some((a) => a[0] === 'commit'), + false, + ); + + // A path the merge deleted is removed, not checked out. + const deleted = makeStub([ + [(a) => a[0] === 'rev-parse' && a[1] === 'HEAD^{tree}', 'OTHER'], + [(a) => a[0] === 'rev-parse' && a[1] === 'HEAD', PRE_HEAD], + [(a) => a[0] === 'diff-tree', 'packages/cli/gone.ts'], + [(a) => a[0] === 'cat-file', fail()], + ]); + reconcileWithMergeTreeAtTip({ + git: deleted, + mergedTree: MERGE_TREE, + masterSha: MASTER, + log: () => {}, + }); + assert.ok(deleted.calls.some((a) => a[0] === 'rm' && a.includes('packages/cli/gone.ts'))); + + // Never rewrite a master commit. + const atMasterTip = makeStub([ + [(a) => a[0] === 'rev-parse' && a[1] === 'HEAD^{tree}', 'OTHER'], + [(a) => a[0] === 'rev-parse' && a[1] === 'HEAD', MASTER], + [(a) => a[0] === 'diff-tree', LOCKFILE], + ]); + assert.throws( + () => + reconcileWithMergeTreeAtTip({ + git: atMasterTip, + mergedTree: MERGE_TREE, + masterSha: MASTER, + log: () => {}, + }), + /amend a master commit/, + ); +}); + test('reconcileLockfileAtTip folds an inconsistent lockfile into the tip commit, never a master commit', () => { const consistent = makeStub([[(a) => a[0] === 'diff', '']]); reconcileLockfileAtTip({ git: consistent, pnpm: makeStub(), masterSha: MASTER, log: () => {} }); @@ -441,12 +581,79 @@ test('sync replays favouring 3.x when the patches no longer apply on their own', ); }); +test('sync recovers when the favoured replay stalls on a modify/delete conflict', async () => { + // The endpoints reconcile (merge-tree is clean) because a fix commit in the queue + // re-deletes the file — but replaying the deleting commit itself stalls on + // modify/delete, which `-X theirs` never settles on its own. + const FILE = 'packages/nodes-base/nodes/Function/Function.node.ts'; + const git = makeStub([ + ...baseGitRoutes, + [(a) => a[0] === 'rebase' && a[1] === '--continue', ''], + [isRebase, fail(`CONFLICT (modify/delete): ${FILE} deleted in 0ff923a066`)], + [isConflictedFiles, FILE], + [(a) => a[0] === 'ls-files', `100644 aaa 1\t${FILE}\n100644 bbb 2\t${FILE}`], + [(a) => a[0] === 'diff-index', fail()], + ]); + const gh = makeStub(noOpenPr); + + await sync({ git, gh, pnpm: makeStub(), env, log: () => {} }); + + assert.ok( + git.calls.some((a) => a[0] === 'rm' && a.includes(FILE)), + 'expected the queue-side deletion to be taken', + ); + assert.ok( + git.calls.some((a) => a[0] === 'push'), + 'expected the replay to be pushed', + ); + // Still no new commit and no PR — the fix commit in the queue does the real work. + assert.equal( + git.calls.some((a) => a[0] === 'commit'), + false, + ); + assert.equal( + gh.calls.some((a) => a[0] === 'pr' && a[1] === 'create'), + false, + ); +}); + +test('sync folds favoured-replay drift back to the merge tree before pushing', async () => { + // The favoured replay finishes but its tip drifts from the merge tree (the lockfile + // hunks `-X theirs` resolved toward 3.x): the drift is folded back, then pushed. + let treeReads = 0; + const git = makeStub([ + ...baseGitRoutes.filter((r) => !r[0](['rev-parse', 'HEAD^{tree}'])), + [ + (a) => a[0] === 'rev-parse' && a[1] === 'HEAD^{tree}', + () => (treeReads++ === 0 ? 'DRIFTED' : MERGE_TREE), + ], + [(a) => a[0] === 'diff-tree', LOCKFILE], + [(a) => a[0] === 'cat-file', ''], + [favouringOwnSide, ''], + [isRebase, fail('CONFLICT (content): packages/cli/x.ts')], + ]); + const gh = makeStub(noOpenPr); + + await sync({ git, gh, pnpm: makeStub(), env, log: () => {} }); + + assert.ok(git.calls.some((a) => a[0] === 'checkout' && a[1] === MERGE_TREE)); + assert.ok(git.calls.some((a) => a[0] === 'commit' && a.includes('--amend'))); + assert.ok( + git.calls.some((a) => a[0] === 'push'), + 'expected the reconciled replay to be pushed', + ); + assert.equal( + gh.calls.some((a) => a[0] === 'pr' && a[1] === 'create'), + false, + ); +}); + test('sync fails without pushing when even the favoured replay cannot finish', async () => { const git = makeStub([ ...baseGitRoutes, - [isRebase, fail('CONFLICT')], - // The favoured replay stalls on a real code file, so the driver must not resolve it. - [isConflictedFiles, 'packages/cli/x.ts'], + [isRebase, fail('rebase failed for a non-conflict reason')], + // No conflicted files: nothing the driver may resolve — don't guess. + [isConflictedFiles, ''], ]); const gh = makeStub(noOpenPr); diff --git a/.github/workflows/util-sync-master-to-3x.yml b/.github/workflows/util-sync-master-to-3x.yml index 9f8c1f14e0f..ce8b44f5d59 100644 --- a/.github/workflows/util-sync-master-to-3x.yml +++ b/.github/workflows/util-sync-master-to-3x.yml @@ -5,7 +5,9 @@ # true by REPLAYING the 3.x-only commits onto master and force-pushing 3.x — so a clean # sync adds no commit at all, and every replayed commit is kept as-is (nothing squashed, # authors preserved). The content pushed is always verified to be exactly what a merge of -# 3.x and master would produce. +# 3.x and master would produce. Per-commit stalls the replay cannot settle with a strategy +# option alone (e.g. modify/delete) are resolved toward 3.x's side, under that same +# verification. # # Conflicts confined to mechanical, tool-generated files (the pnpm lockfile, bot-maintained # data files) are resolved in place during the replay — no PR, no commit, no human. When From 868a532b74f8791ba92ec8e57be91cee87861458 Mon Sep 17 00:00:00 2001 From: Uddish Verma Date: Mon, 10 Aug 2026 13:54:55 +0100 Subject: [PATCH 024/151] refactor(API): Migrate GET /workflows/{id} to the decorator pattern (no-changelog) (#35830) Co-authored-by: Claude Sonnet 5 --- packages/@n8n/api-types/src/dto/index.ts | 7 + .../__tests__/get-workflow-query.dto.test.ts | 32 ++ .../__tests__/workflow-public.dto.test.ts | 130 +++++++++ .../dto/workflows/get-workflow-query.dto.ts | 6 + .../src/dto/workflows/workflow-public.dto.ts | 111 +++++++ packages/cli/eslint.config.mjs | 1 - .../workflows.public.controller.ts | 159 +++++++++- .../spec/paths/getWorkflow.generated.yml | 275 ++++++++++++++++++ .../workflows/spec/paths/workflows.id.yml | 28 -- .../handlers/workflows/workflows.handler.ts | 37 --- .../v1/openapi.decorator-routes.generated.yml | 3 + .../integration/public-api/workflows.test.ts | 53 ++++ 12 files changed, 773 insertions(+), 69 deletions(-) create mode 100644 packages/@n8n/api-types/src/dto/workflows/__tests__/get-workflow-query.dto.test.ts create mode 100644 packages/@n8n/api-types/src/dto/workflows/__tests__/workflow-public.dto.test.ts create mode 100644 packages/@n8n/api-types/src/dto/workflows/get-workflow-query.dto.ts create mode 100644 packages/@n8n/api-types/src/dto/workflows/workflow-public.dto.ts create mode 100644 packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getWorkflow.generated.yml diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index c351e6931df..fcac677213a 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -132,6 +132,13 @@ export { DeactivateWorkflowDto } from './workflows/deactivate-workflow.dto'; export { ArchiveWorkflowDto } from './workflows/archive-workflow.dto'; export { GetResourceDependencyCountsDto } from './workflows/get-resource-dependency-counts.dto'; export { GetResourceDependenciesDto } from './workflows/get-resource-dependencies.dto'; +export { GetWorkflowQueryDto } from './workflows/get-workflow-query.dto'; +export { + WorkflowPublicDto, + workflowPublicSchema, + sharedWorkflowPublicSchema, + activeWorkflowVersionPublicSchema, +} from './workflows/workflow-public.dto'; export { CreateOrUpdateTagRequestDto } from './tag/create-or-update-tag-request.dto'; export { RetrieveTagQueryDto } from './tag/retrieve-tag-query.dto'; diff --git a/packages/@n8n/api-types/src/dto/workflows/__tests__/get-workflow-query.dto.test.ts b/packages/@n8n/api-types/src/dto/workflows/__tests__/get-workflow-query.dto.test.ts new file mode 100644 index 00000000000..414ab50d88a --- /dev/null +++ b/packages/@n8n/api-types/src/dto/workflows/__tests__/get-workflow-query.dto.test.ts @@ -0,0 +1,32 @@ +import { GetWorkflowQueryDto } from '../get-workflow-query.dto'; + +describe('GetWorkflowQueryDto', () => { + describe('Valid requests', () => { + test.each([ + { name: 'with "true"', request: { excludePinnedData: 'true' }, expected: true }, + { name: 'with "false"', request: { excludePinnedData: 'false' }, expected: false }, + { name: 'with no value', request: {}, expected: false }, + ])('should pass validation for excludePinnedData $name', ({ request, expected }) => { + const result = GetWorkflowQueryDto.safeParse(request); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.excludePinnedData).toBe(expected); + } + }); + }); + + describe('Invalid requests', () => { + test.each([ + { name: 'with number', request: { excludePinnedData: 1 } }, + { name: 'with boolean (true)', request: { excludePinnedData: true } }, + { name: 'with boolean (false)', request: { excludePinnedData: false } }, + { name: 'with invalid string', request: { excludePinnedData: 'invalid' } }, + ])('should fail validation for excludePinnedData $name', ({ request }) => { + const result = GetWorkflowQueryDto.safeParse(request); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].path).toEqual(['excludePinnedData']); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/workflows/__tests__/workflow-public.dto.test.ts b/packages/@n8n/api-types/src/dto/workflows/__tests__/workflow-public.dto.test.ts new file mode 100644 index 00000000000..c7870b2866c --- /dev/null +++ b/packages/@n8n/api-types/src/dto/workflows/__tests__/workflow-public.dto.test.ts @@ -0,0 +1,130 @@ +import { WorkflowPublicDto } from '../workflow-public.dto'; + +const baseWorkflow = { + id: '1', + name: 'My workflow', + description: null, + active: false, + activeVersionId: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + isArchived: false, + versionId: 'version-1', + versionCounter: 3, + sourceWorkflowId: null, + triggerCount: 0, + nodes: [], + connections: {}, + nodeGroups: [], + settings: null, + staticData: null, + meta: null, + shared: [ + { + role: 'workflow:owner', + workflowId: '1', + projectId: 'project-1', + project: { + id: 'project-1', + name: 'My project', + type: 'personal', + icon: null, + description: null, + customTelemetryTags: [], + creatorId: 'user-1', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + activeVersion: null, +}; + +describe('WorkflowPublicDto', () => { + test('accepts a full workflow shape', () => { + const result = WorkflowPublicDto.safeParse(baseWorkflow); + expect(result.success).toBe(true); + }); + + test('accepts an active workflow with pinData, tags, and activeVersion populated', () => { + const result = WorkflowPublicDto.safeParse({ + ...baseWorkflow, + active: true, + activeVersionId: 'version-1', + pinData: { Webhook1: [{ json: { first: 'first' } }] }, + tags: [ + { + id: 't1', + name: 'prod', + createdAt: baseWorkflow.createdAt, + updatedAt: baseWorkflow.updatedAt, + }, + ], + activeVersion: { + versionId: 'version-1', + workflowId: '1', + nodes: [], + connections: {}, + nodeGroups: [], + authors: 'Nathan Nathaniel', + name: null, + description: null, + autosaved: false, + createdAt: baseWorkflow.createdAt, + updatedAt: baseWorkflow.updatedAt, + workflowPublishHistory: [ + { + id: 1, + workflowId: '1', + versionId: 'version-1', + event: 'activated', + userId: 'user-1', + createdAt: baseWorkflow.createdAt, + }, + ], + }, + }); + + expect(result.success).toBe(true); + }); + + test('omits pinData when the field is absent (excludePinnedData)', () => { + const result = WorkflowPublicDto.safeParse(baseWorkflow); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).not.toHaveProperty('pinData'); + } + }); + + test('strips fields not part of the public contract', () => { + const result = WorkflowPublicDto.safeParse({ + ...baseWorkflow, + tagMappings: [{ workflowId: '1', tagId: 't1' }], + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).not.toHaveProperty('tagMappings'); + } + }); + + test('passes through fields mirrored from the raw entity (versionCounter, sourceWorkflowId, project details, publish history)', () => { + const result = WorkflowPublicDto.safeParse(baseWorkflow); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.versionCounter).toBe(3); + expect(result.data.sourceWorkflowId).toBeNull(); + expect(result.data.shared[0].project.creatorId).toBe('user-1'); + expect(result.data.shared[0].project.customTelemetryTags).toEqual([]); + } + }); + + test('rejects a missing required field', () => { + const { id: _id, ...withoutId } = baseWorkflow; + const result = WorkflowPublicDto.safeParse(withoutId); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/workflows/get-workflow-query.dto.ts b/packages/@n8n/api-types/src/dto/workflows/get-workflow-query.dto.ts new file mode 100644 index 00000000000..6217e8bb211 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/workflows/get-workflow-query.dto.ts @@ -0,0 +1,6 @@ +import { booleanFromString } from '../../schemas/boolean-from-string'; +import { Z } from '../../zod-class'; + +export class GetWorkflowQueryDto extends Z.class({ + 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 new file mode 100644 index 00000000000..baf53f4329e --- /dev/null +++ b/packages/@n8n/api-types/src/dto/workflows/workflow-public.dto.ts @@ -0,0 +1,111 @@ +import type { IConnections, INode, IWorkflowGroup } from 'n8n-workflow'; +import { z } from 'zod'; + +import { Z } from '../../zod-class'; +import { tagPublicSchema } from '../tag/tag-public.dto'; + +// These fields can look different for every workflow, so we only check +// whether each one is the right basic type (a list or an object), not what's +// inside it. Checking more than that could reject real, already-saved +// workflows that were created before this check existed. +const nodesPublicSchema = z.custom((value) => Array.isArray(value), { + message: 'Nodes must be an array', +}); + +const connectionsPublicSchema = z.custom( + (value) => typeof value === 'object' && value !== null && !Array.isArray(value), + { message: 'Connections must be an object' }, +); + +const nodeGroupsPublicSchema = z.custom((value) => Array.isArray(value), { + message: 'Node groups must be an array', +}); + +const nullableObjectPublicSchema = z.custom | null>( + (value) => value === null || (typeof value === 'object' && !Array.isArray(value)), + { message: 'Must be an object or null' }, +); + +const projectIconPublicSchema = z + .object({ + type: z.enum(['emoji', 'icon']), + value: z.string(), + }) + .nullable(); + +const projectCustomTelemetryTagPublicSchema = z.object({ + key: z.string(), + value: z.string(), +}); + +const workflowProjectPublicSchema = z.object({ + id: z.string(), + name: z.string(), + type: z.enum(['personal', 'team']), + icon: projectIconPublicSchema, + description: z.string().nullable(), + customTelemetryTags: z.array(projectCustomTelemetryTagPublicSchema), + creatorId: z.string().nullable(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}); + +export const sharedWorkflowPublicSchema = z.object({ + role: z.string(), + workflowId: z.string(), + projectId: z.string(), + project: workflowProjectPublicSchema, + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}); + +const workflowPublishHistoryPublicSchema = z.object({ + id: z.number(), + workflowId: z.string(), + versionId: z.string().nullable(), + event: z.enum(['activated', 'deactivated']), + userId: z.string().nullable(), + createdAt: z.string().datetime(), +}); + +export const activeWorkflowVersionPublicSchema = z.object({ + versionId: z.string(), + workflowId: z.string(), + nodes: nodesPublicSchema, + connections: connectionsPublicSchema, + nodeGroups: nodeGroupsPublicSchema, + authors: z.string(), + name: z.string().nullable(), + description: z.string().nullable(), + autosaved: z.boolean(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + workflowPublishHistory: z.array(workflowPublishHistoryPublicSchema), +}); + +export const workflowPublicSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + active: z.boolean(), + activeVersionId: z.string().nullable(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + isArchived: z.boolean(), + versionId: z.string(), + versionCounter: z.number(), + sourceWorkflowId: z.string().nullable(), + triggerCount: z.number(), + nodes: nodesPublicSchema, + connections: connectionsPublicSchema, + nodeGroups: nodeGroupsPublicSchema, + settings: nullableObjectPublicSchema, + staticData: nullableObjectPublicSchema, + pinData: nullableObjectPublicSchema.optional(), + meta: nullableObjectPublicSchema, + tags: z.array(tagPublicSchema).optional(), + shared: z.array(sharedWorkflowPublicSchema), + activeVersion: activeWorkflowVersionPublicSchema.nullable(), +}); + +export class WorkflowPublicDto extends Z.class(workflowPublicSchema.shape) {} diff --git a/packages/cli/eslint.config.mjs b/packages/cli/eslint.config.mjs index cc924eb3db1..b06940d464f 100644 --- a/packages/cli/eslint.config.mjs +++ b/packages/cli/eslint.config.mjs @@ -127,7 +127,6 @@ export default defineConfig( './src/public-api/v1/handlers/users/users.handler.ee.ts', './src/public-api/v1/handlers/users/users.service.ee.ts', './src/public-api/v1/handlers/workflows/workflows.handler.ts', - './src/public-api/v1/handlers/workflows/workflows.service.ts', ], rules: { 'n8n-local-rules/no-repository-in-public-api-handler': 'off', 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 5b24b3f831a..12709c627e8 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,5 +1,18 @@ -import { ListWorkflowHistoryQueryDto, WorkflowVersionHistoryListPublicDto } from '@n8n/api-types'; -import type { AuthenticatedRequest } from '@n8n/db'; +import { + GetWorkflowQueryDto, + ListWorkflowHistoryQueryDto, + WorkflowPublicDto, + WorkflowVersionHistoryListPublicDto, +} from '@n8n/api-types'; +import { GlobalConfig } from '@n8n/config'; +import type { + AuthenticatedRequest, + SharedWorkflow, + TagEntity, + WorkflowEntity, + WorkflowHistory, + WorkflowPublishHistory, +} from '@n8n/db'; import { ApiDescription, ApiErrorResponse, @@ -18,12 +31,152 @@ import type { Response } from 'express'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { SharedWorkflowNotFoundError } from '@/errors/shared-workflow-not-found.error'; +import { EventService } from '@/events/event.service'; import { decodeCursor, encodeNextCursor } from '@/public-api/v1/shared/services/pagination.service'; +import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service'; +function toPublicJson(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function toPublicTag(tag: TagEntity) { + return { + id: tag.id, + name: tag.name, + createdAt: tag.createdAt.toISOString(), + updatedAt: tag.updatedAt.toISOString(), + }; +} + +function toPublicSharedWorkflow(sharedWorkflow: SharedWorkflow) { + return { + role: sharedWorkflow.role, + workflowId: sharedWorkflow.workflowId, + projectId: sharedWorkflow.projectId, + project: { + id: sharedWorkflow.project.id, + name: sharedWorkflow.project.name, + type: sharedWorkflow.project.type, + icon: sharedWorkflow.project.icon, + description: sharedWorkflow.project.description, + customTelemetryTags: sharedWorkflow.project.customTelemetryTags, + creatorId: sharedWorkflow.project.creatorId, + createdAt: sharedWorkflow.project.createdAt.toISOString(), + updatedAt: sharedWorkflow.project.updatedAt.toISOString(), + }, + createdAt: sharedWorkflow.createdAt.toISOString(), + updatedAt: sharedWorkflow.updatedAt.toISOString(), + }; +} + +function toPublicWorkflowPublishHistory(entry: WorkflowPublishHistory) { + return { + id: entry.id, + workflowId: entry.workflowId, + versionId: entry.versionId, + event: entry.event, + userId: entry.userId, + createdAt: entry.createdAt.toISOString(), + }; +} + +function toPublicActiveVersion(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(), + workflowPublishHistory: activeVersion.workflowPublishHistory.map( + toPublicWorkflowPublishHistory, + ), + }; +} + @PublicApiController('/workflows') export class WorkflowsPublicController { - constructor(private readonly workflowHistoryService: WorkflowHistoryService) {} + constructor( + private readonly workflowHistoryService: WorkflowHistoryService, + private readonly workflowFinderService: WorkflowFinderService, + private readonly eventService: EventService, + private readonly globalConfig: GlobalConfig, + ) {} + + @Get('/:workflowId') + @ApiKeyScope('workflow:read') + @ProjectScope('workflow:read') + @ApiSummary('Retrieve a workflow') + @ApiDescription('Retrieve a workflow.') + @ApiTags(['Workflow']) + @ApiResponse(200, WorkflowPublicDto) + @ApiErrorResponse(404) + async getWorkflow( + req: AuthenticatedRequest, + _res: Response, + @Param('workflowId') workflowId: string, + @Query query: GetWorkflowQueryDto, + ): Promise { + const workflow = await this.workflowFinderService.findWorkflowForUser( + workflowId, + req.user, + ['workflow:read'], + { + includeTags: !this.globalConfig.tags.disabled, + includeActiveVersion: true, + }, + ); + + if (!workflow) { + throw new NotFoundError('Not Found'); + } + + this.eventService.emit('user-retrieved-workflow', { + userId: req.user.id, + publicApi: true, + }); + + return this.toWorkflowPublicDto(workflow, { excludePinnedData: query.excludePinnedData }); + } + + /** Builds the public response shape for a single workflow, from the internal entity n8n stores. */ + private toWorkflowPublicDto( + workflow: WorkflowEntity, + options: { excludePinnedData?: boolean } = {}, + ): WorkflowPublicDto { + return { + id: workflow.id, + name: workflow.name, + description: workflow.description, + active: workflow.active, + activeVersionId: workflow.activeVersionId, + createdAt: workflow.createdAt.toISOString(), + updatedAt: workflow.updatedAt.toISOString(), + isArchived: workflow.isArchived, + versionId: workflow.versionId, + versionCounter: workflow.versionCounter, + sourceWorkflowId: workflow.sourceWorkflowId, + triggerCount: workflow.triggerCount, + nodes: workflow.nodes, + connections: workflow.connections, + nodeGroups: workflow.nodeGroups, + settings: toPublicJson(workflow.settings), + staticData: toPublicJson(workflow.staticData), + meta: toPublicJson(workflow.meta), + ...(options.excludePinnedData ? {} : { pinData: toPublicJson(workflow.pinData) }), + ...(workflow.tags ? { tags: workflow.tags.map(toPublicTag) } : {}), + shared: workflow.shared.map(toPublicSharedWorkflow), + activeVersion: workflow.activeVersion ? toPublicActiveVersion(workflow.activeVersion) : null, + }; + } @Get('/:workflowId/history') @ApiKeyScope('workflow:read') diff --git a/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getWorkflow.generated.yml b/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getWorkflow.generated.yml new file mode 100644 index 00000000000..261b56d01d6 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getWorkflow.generated.yml @@ -0,0 +1,275 @@ +operationId: getWorkflow +tags: + - Workflow +summary: Retrieve a workflow +description: Retrieve a workflow. +x-required-scope: workflow:read +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +parameters: + - schema: + type: string + required: true + name: workflowId + in: path + - schema: + type: string + enum: + - 'true' + - 'false' + default: 'false' + required: false + name: excludePinnedData + in: query +responses: + '200': + description: Operation successful. + content: + application/json: + schema: + type: object + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + 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 + versionCounter: + type: number + sourceWorkflowId: + type: string + nullable: true + 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 + project: + type: object + properties: + id: + type: string + name: + type: string + type: + type: string + enum: + - personal + - team + icon: + type: object + nullable: true + properties: + type: + type: string + enum: + - emoji + - icon + value: + type: string + required: + - type + - value + description: + type: string + nullable: true + customTelemetryTags: + type: array + items: + type: object + properties: + key: + type: string + value: + type: string + required: + - key + - value + creatorId: + type: string + nullable: true + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + required: + - id + - name + - type + - icon + - description + - customTelemetryTags + - creatorId + - createdAt + - updatedAt + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + required: + - role + - workflowId + - projectId + - project + - 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 + workflowPublishHistory: + type: array + items: + type: object + properties: + id: + type: number + workflowId: + type: string + versionId: + type: string + nullable: true + event: + type: string + enum: + - activated + - deactivated + userId: + type: string + nullable: true + createdAt: + type: string + format: date-time + required: + - id + - workflowId + - versionId + - event + - userId + - createdAt + required: + - versionId + - workflowId + - nodes + - connections + - nodeGroups + - authors + - name + - description + - autosaved + - createdAt + - updatedAt + - workflowPublishHistory + required: + - id + - name + - description + - active + - activeVersionId + - createdAt + - updatedAt + - isArchived + - versionId + - versionCounter + - sourceWorkflowId + - triggerCount + - nodes + - connections + - nodeGroups + - settings + - staticData + - meta + - shared + - activeVersion + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '404': + $ref: ../../../../shared/spec/responses/notFound.yml diff --git a/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/workflows.id.yml b/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/workflows.id.yml index 9262bde1898..64c62dc9d33 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/workflows.id.yml +++ b/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/workflows.id.yml @@ -1,31 +1,3 @@ -get: - x-eov-operation-id: getWorkflow - x-required-scope: workflow:read - x-eov-operation-handler: v1/handlers/workflows/workflows.handler - tags: - - Workflow - summary: Retrieve a workflow - description: Retrieve a workflow. - parameters: - - name: excludePinnedData - in: query - required: false - description: Set this to avoid retrieving pinned data - schema: - type: boolean - example: true - - $ref: '../schemas/parameters/workflowId.yml' - responses: - '200': - description: Operation successful. - content: - application/json: - schema: - $ref: '../schemas/workflow.yml' - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' - '404': - $ref: '../../../../shared/spec/responses/notFound.yml' delete: x-eov-operation-id: deleteWorkflow x-required-scope: workflow:delete 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 600f7679064..8d01412d87a 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 @@ -53,7 +53,6 @@ type WorkflowHandlers = { createWorkflow: PublicAPIEndpoint; transferWorkflow: PublicAPIEndpoint; deleteWorkflow: PublicAPIEndpoint; - getWorkflow: PublicAPIEndpoint; getWorkflowVersion: PublicAPIEndpoint; getWorkflows: PublicAPIEndpoint; updateWorkflow: PublicAPIEndpoint; @@ -172,42 +171,6 @@ const workflowHandlers: WorkflowHandlers = { return res.json(workflow); }, ], - getWorkflow: [ - publicApiScope('workflow:read'), - projectScope('workflow:read', 'workflow'), - async (req, res) => { - const { id } = req.params; - const { excludePinnedData = false } = req.query; - - const workflow = await Container.get(WorkflowFinderService).findWorkflowForUser( - id, - req.user, - ['workflow:read'], - { - includeTags: areWorkflowTagsEnabled(), - includeActiveVersion: true, - }, - ); - - if (!workflow) { - // user trying to access a workflow they do not own - // and was not shared to them - // Or does not exist. - throw new NotFoundError('Not Found'); - } - - if (excludePinnedData) { - delete workflow.pinData; - } - - Container.get(EventService).emit('user-retrieved-workflow', { - userId: req.user.id, - publicApi: true, - }); - - return res.json(workflow); - }, - ], getWorkflowVersion: [ publicApiScope('workflow:read'), projectScope('workflow:read', '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 268b9f8a386..0b0b5bae7bc 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 @@ -9,6 +9,9 @@ paths: /tags: get: $ref: ./handlers/tags/spec/paths/getTags.generated.yml + /workflows/{workflowId}: + get: + $ref: ./handlers/workflows/spec/paths/getWorkflow.generated.yml /workflows/{workflowId}/history: get: $ref: ./handlers/workflows/spec/paths/getWorkflowHistory.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 d6789d3c577..ca743c41c57 100644 --- a/packages/cli/test/integration/public-api/workflows.test.ts +++ b/packages/cli/test/integration/public-api/workflows.test.ts @@ -816,9 +816,12 @@ describe('GET /workflows/:id', () => { updatedAt, isArchived, versionId, + versionCounter, + sourceWorkflowId, triggerCount, meta, tags, + shared, } = response.body; expect(id).toEqual(workflow.id); @@ -834,8 +837,50 @@ describe('GET /workflows/:id', () => { expect(updatedAt).toEqual(workflow.updatedAt.toISOString()); expect(isArchived).toBe(false); expect(versionId).toBeDefined(); + expect(versionCounter).toEqual(workflow.versionCounter); + expect(sourceWorkflowId).toEqual(workflow.sourceWorkflowId); expect(triggerCount).toBe(0); expect(meta).toBeDefined(); + expect(shared).toEqual([ + expect.objectContaining({ + role: 'workflow:owner', + workflowId: workflow.id, + project: expect.objectContaining({ + id: memberPersonalProject.id, + name: memberPersonalProject.name, + type: memberPersonalProject.type, + icon: memberPersonalProject.icon, + description: memberPersonalProject.description, + customTelemetryTags: memberPersonalProject.customTelemetryTags ?? [], + creatorId: memberPersonalProject.creatorId, + }), + }), + ]); + }); + + test('should retrieve workflow with its tags', async () => { + const tag = await createTag({ name: 'production' }); + const workflow = await createWorkflowWithHistory({ tags: [tag] }, member); + + const response = await authMemberAgent.get(`/workflows/${workflow.id}`); + + expect(response.statusCode).toBe(200); + expect(response.body.tags).toEqual([expect.objectContaining({ id: tag.id, name: tag.name })]); + }); + + test('should omit tags entirely when tags are disabled', async () => { + globalConfig.tags.disabled = true; + + try { + const workflow = await createWorkflowWithHistory({}, member); + + const response = await authMemberAgent.get(`/workflows/${workflow.id}`); + + expect(response.statusCode).toBe(200); + expect(response.body).not.toHaveProperty('tags'); + } finally { + globalConfig.tags.disabled = false; + } }); test('should retrieve non-owned workflow for custom global role with workflow:read', async () => { @@ -935,6 +980,14 @@ describe('GET /workflows/:id', () => { expect(response.body.activeVersion.versionId).toBe(workflow.versionId); expect(response.body.activeVersion.nodes).toEqual(workflow.nodes); expect(response.body.activeVersion.connections).toEqual(workflow.connections); + expect(response.body.activeVersion.workflowPublishHistory).toEqual([ + expect.objectContaining({ + workflowId: workflow.id, + versionId: workflow.versionId, + event: 'activated', + userId: member.id, + }), + ]); }); }); From 269a45d7dd1b9c0a50ab1335bfb455b09475f87d Mon Sep 17 00:00:00 2001 From: oleg Date: Mon, 10 Aug 2026 14:56:24 +0200 Subject: [PATCH 025/151] feat: Load Instance AI models dynamically (#35927) --- packages/@n8n/agents/src/index.ts | 1 + .../agents/src/sdk/__tests__/catalog.test.ts | 65 +++- packages/@n8n/agents/src/sdk/catalog.ts | 81 +++-- packages/@n8n/api-types/src/index.ts | 4 + .../src/schemas/instance-ai.schema.ts | 13 + .../instance-ai-model-catalog.service.test.ts | 286 ++++++++++++++++++ .../__tests__/instance-ai.controller.test.ts | 20 ++ .../instance-ai-model-catalog.service.ts | 151 +++++++++ .../instance-ai/instance-ai.controller.ts | 8 + .../__tests__/instanceAiModelCatalog.test.ts | 101 +++++++ .../instanceAiSettings.store.test.ts | 51 ++++ .../ai/instanceAi/instanceAi.settings.api.ts | 7 + .../instanceAiConnection.constants.ts | 24 +- .../ai/instanceAi/instanceAiModelCatalog.ts | 89 ++++++ .../ai/instanceAi/instanceAiSettings.store.ts | 29 ++ .../InstanceAiOnboardingWizard.test.ts | 111 +++++++ .../onboarding/InstanceAiOnboardingWizard.vue | 41 +-- 17 files changed, 1033 insertions(+), 49 deletions(-) create mode 100644 packages/cli/src/modules/instance-ai/__tests__/instance-ai-model-catalog.service.test.ts create mode 100644 packages/cli/src/modules/instance-ai/instance-ai-model-catalog.service.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiModelCatalog.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiModelCatalog.ts diff --git a/packages/@n8n/agents/src/index.ts b/packages/@n8n/agents/src/index.ts index c5369b44cfb..94291b170d0 100644 --- a/packages/@n8n/agents/src/index.ts +++ b/packages/@n8n/agents/src/index.ts @@ -248,6 +248,7 @@ export type { ModelInfo, ModelCost, ModelLimits, + ModelModalities, } from './sdk/catalog'; export { BaseMemory } from './storage/base-memory'; export { BaseVectorStore } from './storage/base-vector-store'; diff --git a/packages/@n8n/agents/src/sdk/__tests__/catalog.test.ts b/packages/@n8n/agents/src/sdk/__tests__/catalog.test.ts index dcde696c044..129138ce64c 100644 --- a/packages/@n8n/agents/src/sdk/__tests__/catalog.test.ts +++ b/packages/@n8n/agents/src/sdk/__tests__/catalog.test.ts @@ -9,6 +9,7 @@ describe('fetchProviderCatalog', () => { }); it('returns provider ids that match the agents runtime', async () => { + const abortController = new AbortController(); const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => @@ -21,6 +22,7 @@ describe('fetchProviderCatalog', () => { id: 'gpt-5', name: 'GPT-5', tool_call: true, + modalities: { input: ['text', 'image'], output: ['text'] }, }, 'gpt-4.1-mini': { id: 'gpt-4.1-mini', @@ -63,9 +65,13 @@ describe('fetchProviderCatalog', () => { }); global.fetch = fetchMock as typeof fetch; - const catalog = await fetchProviderCatalog(); + const catalog = await fetchProviderCatalog({ signal: abortController.signal }); expect(catalog.openai.models['gpt-5'].toolCall).toBe(true); + expect(catalog.openai.models['gpt-5'].modalities).toEqual({ + input: ['text', 'image'], + output: ['text'], + }); expect(catalog.openai.models['gpt-5']).not.toHaveProperty('reasoning'); expect(catalog.openai.models['gpt-4.1-mini'].reasoning).toBe(false); expect(catalog['aws-bedrock'].models['anthropic.claude-sonnet-4-5-v1:0'].name).toBe( @@ -76,6 +82,63 @@ describe('fetchProviderCatalog', () => { expect(catalog['amazon-bedrock']).toBeUndefined(); expect(catalog.azure).toBeUndefined(); expect(catalog['azure-cognitive-services']).toBeUndefined(); + expect(fetchMock).toHaveBeenCalledWith('https://models.dev/api.json', { + signal: abortController.signal, + }); + }); + + it('rejects a malformed catalog response', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => await Promise.resolve([]), + }); + global.fetch = fetchMock as typeof fetch; + + await expect(fetchProviderCatalog()).rejects.toThrow(); + }); + + it('omits malformed providers and models while retaining valid entries', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => + await Promise.resolve({ + invalidProvider: { + id: 'invalid-provider', + models: {}, + }, + openai: { + id: 'openai', + name: 'OpenAI', + models: { + 'gpt-valid': { + id: 'gpt-valid', + name: 'GPT Valid', + tool_call: true, + }, + 'gpt-missing-name': { + id: 'gpt-missing-name', + }, + 'gpt-invalid-tool-call': { + id: 'gpt-invalid-tool-call', + name: 'GPT Invalid Tool Call', + tool_call: 'yes', + }, + }, + }, + }), + }); + global.fetch = fetchMock as typeof fetch; + + const catalog = await fetchProviderCatalog(); + + expect(catalog.invalidProvider).toBeUndefined(); + expect(catalog.openai.models).toEqual({ + 'gpt-valid': { + id: 'gpt-valid', + name: 'GPT Valid', + toolCall: true, + }, + }); }); it('drops models marked deprecated on models.dev', async () => { diff --git a/packages/@n8n/agents/src/sdk/catalog.ts b/packages/@n8n/agents/src/sdk/catalog.ts index 6842c5918e3..3ec77e0e42a 100644 --- a/packages/@n8n/agents/src/sdk/catalog.ts +++ b/packages/@n8n/agents/src/sdk/catalog.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + const MODELS_DEV_URL = 'https://models.dev/api.json'; const MODELS_DEV_PROVIDER_ALIASES: Record = { @@ -31,6 +33,14 @@ export interface ModelLimits { output?: number; } +/** Input and output types supported by a model. */ +export interface ModelModalities { + /** Supported input types. */ + input?: string[]; + /** Supported output types. */ + output?: string[]; +} + /** Information about a single model. */ export interface ModelInfo { /** Model ID (e.g. 'claude-sonnet-4-5'). */ @@ -43,6 +53,8 @@ export interface ModelInfo { reasoning?: boolean; /** Whether the model supports tool calling. */ toolCall: boolean; + /** Input and output types supported by the model. */ + modalities?: ModelModalities; /** Cost per million tokens. */ cost?: ModelCost; /** Token limits. */ @@ -62,22 +74,42 @@ export interface ProviderInfo { /** The full catalog of providers and their models. */ export type ProviderCatalog = Record; -interface ModelsDevModel { - id: string; - name: string; - release_date?: string; - reasoning?: boolean; - tool_call?: boolean; - status?: string; - cost?: { input?: number; output?: number; cache_read?: number; cache_write?: number }; - limit?: { context?: number; output?: number }; -} +const modelsDevModelSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + release_date: z.string().optional(), + reasoning: z.boolean().optional(), + tool_call: z.boolean().optional(), + status: z.string().optional(), + modalities: z + .object({ + input: z.array(z.string()).optional(), + output: z.array(z.string()).optional(), + }) + .optional(), + cost: z + .object({ + input: z.number().optional(), + output: z.number().optional(), + cache_read: z.number().optional(), + cache_write: z.number().optional(), + }) + .optional(), + limit: z + .object({ + context: z.number().optional(), + output: z.number().optional(), + }) + .optional(), +}); -interface ModelsDevProvider { - id: string; - name: string; - models?: Record; -} +const modelsDevProviderSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + models: z.record(z.string(), z.unknown()).optional(), +}); + +const modelsDevCatalogSchema = z.record(z.string(), z.unknown()); function toAgentProviderId(modelsDevProviderId: string): string { return MODELS_DEV_PROVIDER_ALIASES[modelsDevProviderId] ?? modelsDevProviderId; @@ -125,20 +157,28 @@ function normalizeLatestModelNames(models: Record): void { * console.log(catalog.anthropic.models['claude-sonnet-4-5'].reasoning); // true * ``` */ -export async function fetchProviderCatalog(): Promise { - const response = await fetch(MODELS_DEV_URL); +export async function fetchProviderCatalog(options?: { + signal?: AbortSignal; +}): Promise { + const response = await fetch(MODELS_DEV_URL, { signal: options?.signal }); if (!response.ok) { throw new Error(`Failed to fetch provider catalog: ${response.statusText}`); } - const data = (await response.json()) as Record; + const data = modelsDevCatalogSchema.parse(await response.json()); const catalog: ProviderCatalog = {}; - for (const [key, provider] of Object.entries(data)) { + for (const [key, rawProvider] of Object.entries(data)) { + const providerResult = modelsDevProviderSchema.safeParse(rawProvider); + if (!providerResult.success) continue; + const provider = providerResult.data; if (!provider.models || Object.keys(provider.models).length === 0) continue; const models: Record = {}; - for (const [modelId, model] of Object.entries(provider.models)) { + for (const [modelId, rawModel] of Object.entries(provider.models)) { + const modelResult = modelsDevModelSchema.safeParse(rawModel); + if (!modelResult.success) continue; + const model = modelResult.data; // Deprecated models still 404 at call time when the provider retires // them, so never offer them. if (model.status === 'deprecated') continue; @@ -148,6 +188,7 @@ export async function fetchProviderCatalog(): Promise { ...(model.release_date !== undefined && { releaseDate: model.release_date }), ...(model.reasoning !== undefined && { reasoning: model.reasoning }), toolCall: model.tool_call ?? false, + ...(model.modalities !== undefined && { modalities: model.modalities }), }; if (model.cost?.input !== undefined && model.cost?.output !== undefined) { info.cost = { diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index f6944ea299a..8cebfb7896e 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -348,6 +348,7 @@ export { isInstanceAiSandboxProvider, INSTANCE_AI_MODEL_CREDENTIAL_TYPES, INSTANCE_AI_SEARCH_CREDENTIAL_TYPES, + INSTANCE_AI_CATALOG_PROVIDERS, GATEWAY_CONFIRMATION_REQUIRED_PREFIX, InstanceAiSendMessageRequest, InstanceAiEvalExecutionRequest, @@ -388,6 +389,9 @@ export type { InstanceAiThreadSource, InstanceAiThreadSourcePersisted, InstanceAiThreadOrigin, + InstanceAiCatalogProvider, + InstanceAiCatalogModel, + InstanceAiModelCatalogResponse, } from './schemas/instance-ai.schema'; export type { diff --git a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts index bda0d92e3e6..ff4cfc81e10 100644 --- a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts +++ b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts @@ -1505,6 +1505,19 @@ export const INSTANCE_AI_MODEL_CREDENTIAL_TYPES = [ export const INSTANCE_AI_SEARCH_CREDENTIAL_TYPES = ['braveSearchApi', 'searXngApi'] as const; +export const INSTANCE_AI_CATALOG_PROVIDERS = ['anthropic', 'openai', 'openrouter'] as const; +export type InstanceAiCatalogProvider = (typeof INSTANCE_AI_CATALOG_PROVIDERS)[number]; + +export interface InstanceAiCatalogModel { + id: string; + name: string; + releaseDate?: string; +} + +export interface InstanceAiModelCatalogResponse { + models: Record; +} + export interface InstanceAiEnvManagedFields { model: { provider: boolean; diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-model-catalog.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-model-catalog.service.test.ts new file mode 100644 index 00000000000..97b2f606101 --- /dev/null +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-model-catalog.service.test.ts @@ -0,0 +1,286 @@ +import type { ProviderCatalog } from '@n8n/agents/catalog'; +import type { Logger } from '@n8n/backend-common'; +import { mock } from 'vitest-mock-extended'; + +import { InstanceAiModelCatalogService } from '../instance-ai-model-catalog.service'; + +const fetchProviderCatalog = vi.fn<(signal: AbortSignal) => Promise>(); + +class TestInstanceAiModelCatalogService extends InstanceAiModelCatalogService { + constructor( + logger: Logger, + private readonly fetcher: (signal: AbortSignal) => Promise, + ) { + super(logger); + } + + protected override async fetchCatalog(signal: AbortSignal): Promise { + return await this.fetcher(signal); + } +} + +const catalog: ProviderCatalog = { + anthropic: { + id: 'anthropic', + name: 'Anthropic', + models: { + zulu: { + id: 'zulu', + name: 'Zulu', + releaseDate: '2026-03-01', + toolCall: true, + modalities: { output: ['text'] }, + cost: { input: 1, output: 2 }, + }, + alpha: { + id: 'alpha', + name: 'Alpha', + releaseDate: '2026-01-01', + toolCall: true, + modalities: { output: ['text'] }, + }, + beta: { + id: 'beta', + name: 'Beta', + releaseDate: '2026-01-01', + toolCall: true, + modalities: { output: ['text'] }, + }, + invalidDate: { + id: 'invalid-date', + name: 'Invalid date', + releaseDate: 'unknown', + toolCall: true, + modalities: { output: ['text'] }, + }, + undated: { + id: 'undated', + name: 'Undated', + toolCall: true, + modalities: { output: ['text'] }, + }, + 'image-only': { + id: 'image-only', + name: 'Image only', + toolCall: true, + modalities: { output: ['image'] }, + }, + 'mixed-output': { + id: 'mixed-output', + name: 'Mixed output', + toolCall: true, + modalities: { output: ['text', 'audio'] }, + }, + 'missing-modalities': { + id: 'missing-modalities', + name: 'Missing modalities', + toolCall: true, + }, + 'no-tools': { + id: 'no-tools', + name: 'No tools', + toolCall: false, + modalities: { output: ['text'] }, + }, + }, + }, + openai: { + id: 'openai', + name: 'OpenAI', + models: { + 'gpt-tools': { + id: 'gpt-tools', + name: 'GPT Tools', + toolCall: true, + modalities: { output: ['text'] }, + }, + }, + }, + openrouter: { + id: 'openrouter', + name: 'OpenRouter', + models: { + 'provider/model': { + id: 'provider/model', + name: 'Provider Model', + toolCall: true, + modalities: { output: ['text'] }, + }, + }, + }, + google: { + id: 'google', + name: 'Google', + models: { + gemini: { + id: 'gemini', + name: 'Gemini', + toolCall: true, + modalities: { output: ['text'] }, + }, + }, + }, +}; + +function makeService() { + const logger = mock(); + logger.scoped.mockReturnValue(logger); + return { + service: new TestInstanceAiModelCatalogService(logger, fetchProviderCatalog), + logger, + }; +} + +describe('InstanceAiModelCatalogService', () => { + beforeEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('returns newest compatible models first for supported providers only', async () => { + fetchProviderCatalog.mockResolvedValue(catalog); + const { service, logger } = makeService(); + + const result = await service.getModels(); + expect(fetchProviderCatalog).toHaveBeenCalledOnce(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(result).toEqual({ + models: { + anthropic: [ + { id: 'zulu', name: 'Zulu', releaseDate: '2026-03-01' }, + { id: 'alpha', name: 'Alpha', releaseDate: '2026-01-01' }, + { id: 'beta', name: 'Beta', releaseDate: '2026-01-01' }, + { id: 'invalid-date', name: 'Invalid date', releaseDate: 'unknown' }, + { id: 'undated', name: 'Undated' }, + ], + openai: [{ id: 'gpt-tools', name: 'GPT Tools' }], + openrouter: [{ id: 'provider/model', name: 'Provider Model' }], + }, + }); + }); + + it('omits malformed catalog entries', async () => { + fetchProviderCatalog.mockResolvedValue({ + anthropic: { + id: 'anthropic', + name: 'Anthropic', + models: { + missingName: { + id: 'missing-name', + toolCall: true, + modalities: { output: ['text'] }, + }, + null: null, + valid: { + id: 'valid', + name: 'Valid', + toolCall: true, + modalities: { output: ['text'] }, + }, + }, + }, + } as unknown as ProviderCatalog); + const { service } = makeService(); + + await expect(service.getModels()).resolves.toEqual({ + models: { + anthropic: [{ id: 'valid', name: 'Valid' }], + openai: [], + openrouter: [], + }, + }); + }); + + it('de-duplicates concurrent fetches and caches successful results for one hour', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-06T10:00:00Z')); + let resolveFetch: (value: ProviderCatalog) => void = () => {}; + fetchProviderCatalog.mockImplementation( + async () => + await new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + const { service } = makeService(); + + const first = service.getModels(); + const second = service.getModels(); + expect(fetchProviderCatalog).toHaveBeenCalledOnce(); + resolveFetch(catalog); + await expect(Promise.all([first, second])).resolves.toHaveLength(2); + + await service.getModels(); + expect(fetchProviderCatalog).toHaveBeenCalledOnce(); + + vi.advanceTimersByTime(60 * 60 * 1000 + 1); + fetchProviderCatalog.mockResolvedValue(catalog); + await service.getModels(); + expect(fetchProviderCatalog).toHaveBeenCalledTimes(2); + }); + + it('falls back to stale data or empty provider arrays when refresh fails', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-06T10:00:00Z')); + fetchProviderCatalog.mockResolvedValueOnce(catalog).mockRejectedValue(new Error('offline')); + const { service, logger } = makeService(); + const initial = await service.getModels(); + + vi.advanceTimersByTime(60 * 60 * 1000 + 1); + await expect(service.getModels()).resolves.toEqual(initial); + expect(logger.warn).toHaveBeenCalled(); + + fetchProviderCatalog.mockRejectedValue(new Error('still offline')); + const { service: emptyService } = makeService(); + await expect(emptyService.getModels()).resolves.toEqual({ + models: { anthropic: [], openai: [], openrouter: [] }, + }); + }); + + it('aborts a timed-out fetch before allowing a retry', async () => { + vi.useFakeTimers(); + let activeRequests = 0; + let maxActiveRequests = 0; + let firstSignal: AbortSignal | undefined; + fetchProviderCatalog + .mockImplementationOnce( + async (signal) => + await new Promise((_resolve, reject) => { + firstSignal = signal; + activeRequests++; + maxActiveRequests = Math.max(maxActiveRequests, activeRequests); + signal.addEventListener( + 'abort', + () => { + activeRequests--; + reject(new Error('aborted')); + }, + { once: true }, + ); + }), + ) + .mockImplementationOnce(async () => { + activeRequests++; + maxActiveRequests = Math.max(maxActiveRequests, activeRequests); + activeRequests--; + return catalog; + }); + const { service, logger } = makeService(); + + const request = service.getModels(); + await vi.advanceTimersByTimeAsync(5000); + + await expect(request).resolves.toEqual({ + models: { anthropic: [], openai: [], openrouter: [] }, + }); + expect(logger.warn).toHaveBeenCalledWith('Failed to load the Instance AI model catalog', { + error: 'Model catalog request timed out', + }); + expect(firstSignal?.aborted).toBe(true); + + await expect(service.getModels()).resolves.toEqual( + expect.objectContaining({ models: expect.objectContaining({ openai: expect.any(Array) }) }), + ); + expect(fetchProviderCatalog).toHaveBeenCalledTimes(2); + expect(maxActiveRequests).toBe(1); + }); +}); diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts index 9ad4813f400..ee7f167ffe2 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts @@ -85,6 +85,7 @@ import type { InProcessEventBus } from '../event-bus/in-process-event-bus'; import type { LocalGateway } from '../filesystem/local-gateway'; import type { InstanceAiGatewayService } from '../instance-ai-gateway.service'; import type { InstanceAiMemoryService } from '../instance-ai-memory.service'; +import type { InstanceAiModelCatalogService } from '../instance-ai-model-catalog.service'; import type { InstanceAiSettingsService } from '../instance-ai-settings.service'; import { InstanceAiController } from '../instance-ai.controller'; import type { InstanceAiService } from '../instance-ai.service'; @@ -110,6 +111,7 @@ describe('InstanceAiController', () => { const browserSessionService = mock(); const memoryService = mock(); const settingsService = mock(); + const modelCatalogService = mock(); const eventBus = mock(); const eventLog = mock(); const durableLogMetrics = mock(); @@ -137,6 +139,7 @@ describe('InstanceAiController', () => { browserSessionService, memoryService, settingsService, + modelCatalogService, mock(), mock(), evalCredentialAllowlists, @@ -1044,6 +1047,22 @@ describe('InstanceAiController', () => { }); }); + describe('getModelCatalog', () => { + it('should require instanceAi:manage scope', () => { + expect(scopeOf('getModelCatalog')).toEqual({ + scope: 'instanceAi:manage', + globalOnly: true, + }); + }); + + it('should return the model catalog service response', async () => { + const response = { models: { anthropic: [], openai: [], openrouter: [] } }; + modelCatalogService.getModels.mockResolvedValue(response); + + await expect(controller.getModelCatalog(req)).resolves.toEqual(response); + }); + }); + describe('updateAdminSettings', () => { it('should require instanceAi:manage scope', () => { expect(scopeOf('updateAdminSettings')).toEqual({ @@ -1893,6 +1912,7 @@ describe('InstanceAiController — durable-log SSE replay (flag on)', () => { mock(), memoryService, settingsService, + mock(), mock(), mock(), new EvalThreadCredentialAllowlistService(), diff --git a/packages/cli/src/modules/instance-ai/instance-ai-model-catalog.service.ts b/packages/cli/src/modules/instance-ai/instance-ai-model-catalog.service.ts new file mode 100644 index 00000000000..60015105900 --- /dev/null +++ b/packages/cli/src/modules/instance-ai/instance-ai-model-catalog.service.ts @@ -0,0 +1,151 @@ +import { + INSTANCE_AI_CATALOG_PROVIDERS, + type InstanceAiCatalogModel, + type InstanceAiCatalogProvider, + type InstanceAiModelCatalogResponse, +} from '@n8n/api-types'; +import type { ProviderCatalog } from '@n8n/agents/catalog'; +import { Logger } from '@n8n/backend-common'; +import { Service } from '@n8n/di'; +import { ensureError } from '@n8n/utils/errors/ensure-error'; + +const CATALOG_CACHE_TTL_MS = 60 * 60 * 1000; +const CATALOG_FETCH_TIMEOUT_MS = 5000; + +function emptyCatalogResponse(): InstanceAiModelCatalogResponse { + return { + models: { + anthropic: [], + openai: [], + openrouter: [], + }, + }; +} + +function compareModels(a: InstanceAiCatalogModel, b: InstanceAiCatalogModel): number { + const aReleaseTime = a.releaseDate ? Date.parse(a.releaseDate) : Number.NaN; + const bReleaseTime = b.releaseDate ? Date.parse(b.releaseDate) : Number.NaN; + const aHasReleaseDate = !Number.isNaN(aReleaseTime); + const bHasReleaseDate = !Number.isNaN(bReleaseTime); + + if (aHasReleaseDate && bHasReleaseDate && aReleaseTime !== bReleaseTime) { + return bReleaseTime - aReleaseTime; + } + if (aHasReleaseDate !== bHasReleaseDate) return aHasReleaseDate ? -1 : 1; + + return a.name.localeCompare(b.name) || a.id.localeCompare(b.id); +} + +function isEligibleModel(model: unknown): model is { + id: string; + name: string; + toolCall: true; + modalities: { output: ['text'] }; + releaseDate?: unknown; +} { + const id = typeof model === 'object' && model !== null ? Reflect.get(model, 'id') : undefined; + const name = typeof model === 'object' && model !== null ? Reflect.get(model, 'name') : undefined; + const modalities = + typeof model === 'object' && model !== null ? Reflect.get(model, 'modalities') : undefined; + const output = + typeof modalities === 'object' && modalities !== null + ? Reflect.get(modalities, 'output') + : undefined; + return ( + typeof model === 'object' && + model !== null && + Reflect.get(model, 'toolCall') === true && + Array.isArray(output) && + output.length === 1 && + output[0] === 'text' && + typeof id === 'string' && + id.length > 0 && + typeof name === 'string' && + name.length > 0 + ); +} + +@Service() +export class InstanceAiModelCatalogService { + private readonly logger: Logger; + + private cached?: { value: InstanceAiModelCatalogResponse; expiresAt: number }; + + private fetchPromise?: Promise; + + constructor(logger: Logger) { + this.logger = logger.scoped('instance-ai'); + } + + async getModels(): Promise { + if (this.cached && Date.now() < this.cached.expiresAt) return this.cached.value; + + this.fetchPromise ??= this.refresh().finally(() => { + this.fetchPromise = undefined; + }); + + return await this.fetchPromise; + } + + private async refresh(): Promise { + try { + const catalog = await this.fetchCatalogWithTimeout(); + const value = this.toResponse(catalog); + this.cached = { value, expiresAt: Date.now() + CATALOG_CACHE_TTL_MS }; + return value; + } catch (error) { + this.logger.warn('Failed to load the Instance AI model catalog', { + error: ensureError(error).message, + }); + return this.cached?.value ?? emptyCatalogResponse(); + } + } + + private async fetchCatalogWithTimeout(): Promise { + const abortController = new AbortController(); + let timeout: ReturnType | undefined; + const timeoutPromise = new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + reject(new Error('Model catalog request timed out')); + abortController.abort(); + }, CATALOG_FETCH_TIMEOUT_MS); + }); + const catalogPromise = this.fetchCatalog(abortController.signal); + void catalogPromise.catch(() => undefined); + + try { + return await Promise.race([catalogPromise, timeoutPromise]); + } finally { + if (timeout) clearTimeout(timeout); + } + } + + protected async fetchCatalog(signal: AbortSignal): Promise { + const { fetchProviderCatalog } = await import('@n8n/agents/catalog'); + return await fetchProviderCatalog({ signal }); + } + + private toResponse(catalog: ProviderCatalog): InstanceAiModelCatalogResponse { + const response = emptyCatalogResponse(); + + for (const provider of INSTANCE_AI_CATALOG_PROVIDERS) { + response.models[provider] = this.getProviderModels(catalog, provider); + } + + return response; + } + + private getProviderModels( + catalog: ProviderCatalog, + provider: InstanceAiCatalogProvider, + ): InstanceAiCatalogModel[] { + return Object.values(catalog[provider]?.models ?? {}) + .filter(isEligibleModel) + .map((model) => ({ + id: model.id, + name: model.name, + ...(typeof model.releaseDate === 'string' ? { releaseDate: model.releaseDate } : {}), + })) + .sort(compareModels); + } +} diff --git a/packages/cli/src/modules/instance-ai/instance-ai.controller.ts b/packages/cli/src/modules/instance-ai/instance-ai.controller.ts index 90fa9fcf1d6..b52bba4555d 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.controller.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.controller.ts @@ -66,6 +66,7 @@ import { InProcessEventBus } from './event-bus/in-process-event-bus'; import { InstanceAiErrorReporterService } from './instance-ai-error-reporter.service'; import { InstanceAiGatewayService } from './instance-ai-gateway.service'; import { InstanceAiMemoryService } from './instance-ai-memory.service'; +import { InstanceAiModelCatalogService } from './instance-ai-model-catalog.service'; import { InstanceAiSettingsService } from './instance-ai-settings.service'; import { InstanceAiVerificationService } from './instance-ai-verification.service'; import { InstanceAiService } from './instance-ai.service'; @@ -128,6 +129,7 @@ export class InstanceAiController { private readonly browserSessionService: InstanceAiBrowserSessionService, private readonly memoryService: InstanceAiMemoryService, private readonly settingsService: InstanceAiSettingsService, + private readonly modelCatalogService: InstanceAiModelCatalogService, private readonly evalExecutionService: EvalExecutionService, private readonly evalAgentExecutionService: EvalAgentExecutionService, private readonly evalCredentialAllowlists: EvalThreadCredentialAllowlistService, @@ -681,6 +683,12 @@ export class InstanceAiController { return await this.settingsService.getAdminSettings(); } + @Get('/settings/models') + @GlobalScope('instanceAi:manage') + async getModelCatalog(_req: AuthenticatedRequest) { + return await this.modelCatalogService.getModels(); + } + @Put('/settings') @GlobalScope('instanceAi:manage') async updateAdminSettings( diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiModelCatalog.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiModelCatalog.test.ts new file mode 100644 index 00000000000..90b28d4d4d8 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiModelCatalog.test.ts @@ -0,0 +1,101 @@ +import type { InstanceAiModelCatalogResponse } from '@n8n/api-types'; + +import { INSTANCE_AI_CURATED_MODELS } from '../instanceAiConnection.constants'; +import { getAllInstanceAiModelOptions, getInstanceAiModelOptions } from '../instanceAiModelCatalog'; + +const catalog: InstanceAiModelCatalogResponse['models'] = { + anthropic: [ + { id: 'claude-opus-5', name: 'Claude Opus 5' }, + { id: 'claude-zeta', name: 'Claude Zeta', releaseDate: '2026-05-01' }, + { id: 'claude-beta', name: 'Claude Beta', releaseDate: '2026-03-01' }, + { id: 'claude-delta', name: 'Claude Delta', releaseDate: '2026-03-01' }, + { id: 'claude-alpha', name: 'Claude Alpha' }, + { id: 'claude-invalid', name: 'Claude Invalid', releaseDate: 'unknown' }, + ], + openai: [], + openrouter: [], +}; + +describe('getInstanceAiModelOptions', () => { + it('uses curated recommendations immediately without a catalog', () => { + const options = getInstanceAiModelOptions('anthropic', null, ''); + + expect(options.map(({ id }) => id)).toEqual(INSTANCE_AI_CURATED_MODELS.anthropic); + expect(options.map(({ recommended }) => recommended)).toEqual([true, false]); + }); + + it('enriches and de-duplicates recommendations before newest dynamic models', () => { + const options = getInstanceAiModelOptions('anthropic', catalog, ''); + + expect(options).toEqual([ + { id: 'claude-opus-5', name: 'Claude Opus 5', recommended: true }, + { id: 'claude-sonnet-5', name: 'claude-sonnet-5', recommended: false }, + { id: 'claude-zeta', name: 'Claude Zeta', recommended: false }, + { id: 'claude-beta', name: 'Claude Beta', recommended: false }, + { id: 'claude-delta', name: 'Claude Delta', recommended: false }, + { id: 'claude-alpha', name: 'Claude Alpha', recommended: false }, + { id: 'claude-invalid', name: 'Claude Invalid', recommended: false }, + ]); + }); + + it('keeps an unknown saved model selectable', () => { + const options = getInstanceAiModelOptions('openai', catalog, 'retired-model'); + + expect(options.map(({ id }) => id)).toEqual([ + ...INSTANCE_AI_CURATED_MODELS.openai, + 'retired-model', + ]); + }); + + it('sorts a saved catalog model by its release date', () => { + const options = getInstanceAiModelOptions('anthropic', catalog, 'claude-beta'); + + expect(options.map(({ id }) => id)).toEqual([ + ...INSTANCE_AI_CURATED_MODELS.anthropic, + 'claude-zeta', + 'claude-beta', + 'claude-delta', + 'claude-alpha', + 'claude-invalid', + ]); + }); + + it('does not provide catalog options for custom endpoints', () => { + expect(getInstanceAiModelOptions('custom', catalog, 'custom-model')).toEqual([]); + }); + + it('combines every provider when the configured provider is masked', () => { + const options = getAllInstanceAiModelOptions( + { + ...catalog, + openai: [{ id: 'gpt-dynamic', name: 'GPT Dynamic', releaseDate: '2026-04-01' }], + openrouter: [ + { + id: 'openrouter/dynamic', + name: 'OpenRouter Dynamic', + releaseDate: '2026-02-01', + }, + ], + }, + 'saved-env-model', + ); + const curatedIds = [ + ...INSTANCE_AI_CURATED_MODELS.anthropic, + ...INSTANCE_AI_CURATED_MODELS.openai, + ...INSTANCE_AI_CURATED_MODELS.openrouter, + ]; + + expect(options.slice(0, curatedIds.length).map(({ id }) => id)).toEqual(curatedIds); + expect(options.map(({ id }) => id)).toEqual( + expect.arrayContaining([ + 'claude-zeta', + 'gpt-dynamic', + 'openrouter/dynamic', + 'saved-env-model', + ]), + ); + expect(options.filter(({ recommended }) => recommended).map(({ id }) => id)).toEqual([ + 'claude-opus-5', + ]); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiSettings.store.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiSettings.store.test.ts index 5039536740e..9e363623ebb 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiSettings.store.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiSettings.store.test.ts @@ -36,6 +36,7 @@ const mockFetchPreferences = vi.fn(); const mockUpdatePreferences = vi.fn(); const mockFetchServiceCredentials = vi.fn().mockResolvedValue([]); const mockFetchInstanceModelCredentials = vi.fn().mockResolvedValue([]); +const mockFetchModelCatalog = vi.fn(); const mockVerifyModel = vi.fn(); const mockVerifySandbox = vi.fn(); const mockVerifySearch = vi.fn(); @@ -49,6 +50,7 @@ vi.mock('../instanceAi.settings.api', () => ({ updatePreferences: (...args: unknown[]) => mockUpdatePreferences(...args), fetchServiceCredentials: (...args: unknown[]) => mockFetchServiceCredentials(...args), fetchInstanceModelCredentials: (...args: unknown[]) => mockFetchInstanceModelCredentials(...args), + fetchModelCatalog: (...args: unknown[]) => mockFetchModelCatalog(...args), verifyModel: (...args: unknown[]) => mockVerifyModel(...args), verifySandbox: (...args: unknown[]) => mockVerifySandbox(...args), verifySearch: (...args: unknown[]) => mockVerifySearch(...args), @@ -417,6 +419,55 @@ describe('useInstanceAiSettingsStore', () => { }); }); + describe('model catalog', () => { + const response = { + models: { + anthropic: [{ id: 'claude-opus-5', name: 'Claude Opus 5' }], + openai: [], + openrouter: [], + }, + }; + + it('de-duplicates in-flight requests and keeps the successful catalog', async () => { + let resolveFetch: (value: typeof response) => void = () => {}; + mockFetchModelCatalog.mockImplementation( + async () => + await new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + const first = store.loadModelCatalog(); + const second = store.loadModelCatalog(); + expect(store.isModelCatalogLoading).toBe(true); + expect(mockFetchModelCatalog).toHaveBeenCalledOnce(); + + resolveFetch(response); + await Promise.all([first, second]); + expect(store.modelCatalog).toEqual(response.models); + expect(store.isModelCatalogLoading).toBe(false); + + await store.loadModelCatalog(); + expect(mockFetchModelCatalog).toHaveBeenCalledOnce(); + }); + + it('allows a retry after a failed or empty response', async () => { + mockFetchModelCatalog + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce({ models: { anthropic: [], openai: [], openrouter: [] } }) + .mockResolvedValueOnce(response); + + await store.loadModelCatalog(); + expect(store.modelCatalog).toBeNull(); + await store.loadModelCatalog(); + expect(store.modelCatalog).toBeNull(); + await store.loadModelCatalog(); + + expect(store.modelCatalog).toEqual(response.models); + expect(mockFetchModelCatalog).toHaveBeenCalledTimes(3); + }); + }); + describe('provider credentials', () => { it('refreshes n8n Sandbox credentials when the assistant proxy is enabled', async () => { setModuleSettings(settingsStore, { proxyEnabled: true, cloudManaged: false }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.settings.api.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.settings.api.ts index 6b1bf113851..b8793919e1e 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.settings.api.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.settings.api.ts @@ -3,6 +3,7 @@ import type { IRestApiContext } from '@n8n/rest-api-client'; import type { InstanceAiAdminSettingsResponse, InstanceAiAdminSettingsUpdateRequest, + InstanceAiModelCatalogResponse, InstanceAiUserPreferencesResponse, InstanceAiUserPreferencesUpdateRequest, InstanceAiProviderConnection, @@ -50,6 +51,12 @@ export async function fetchInstanceModelCredentials( return await makeRestApiRequest(context, 'GET', '/instance-ai/settings/model-credentials'); } +export async function fetchModelCatalog( + context: IRestApiContext, +): Promise { + return await makeRestApiRequest(context, 'GET', '/instance-ai/settings/models'); +} + export async function verifyModel( context: IRestApiContext, body: InstanceAiVerifyModelRequest, diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiConnection.constants.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiConnection.constants.ts index d2d7c007336..4424beba2df 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiConnection.constants.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiConnection.constants.ts @@ -9,46 +9,48 @@ import { SANDBOX_PROVIDER_LABELS } from './constants'; export type InstanceAiModelProvider = 'anthropic' | 'openai' | 'openrouter' | 'custom'; export type InstanceAiSearchProvider = 'searxng' | 'brave' | 'disabled'; +export const INSTANCE_AI_CURATED_MODELS = { + anthropic: ['claude-opus-5', 'claude-sonnet-5'], + openai: ['gpt-5.6-sol', 'gpt-5.6-terra'], + openrouter: [ + 'anthropic/claude-opus-5', + 'anthropic/claude-sonnet-5', + 'openai/gpt-5.6-sol', + 'openai/gpt-5.6-terra', + 'moonshotai/kimi-k3', + ], + custom: [], +} as const satisfies Record; + export const INSTANCE_AI_MODEL_PROVIDERS = [ { id: 'anthropic', credentialType: 'anthropicApi', label: 'Anthropic', - models: ['claude-opus-5', 'claude-sonnet-5'], placeholder: 'sk-ant-…', }, { id: 'openai', credentialType: 'openAiApi', label: 'OpenAI', - models: ['gpt-5.6-sol', 'gpt-5.6-terra'], placeholder: 'sk-…', }, { id: 'openrouter', credentialType: 'openRouterApi', label: 'OpenRouter', - models: [ - 'anthropic/claude-opus-5', - 'anthropic/claude-sonnet-5', - 'openai/gpt-5.6-sol', - 'openai/gpt-5.6-terra', - 'moonshotai/kimi-k3', - ], placeholder: 'sk-or-…', }, { id: 'custom', credentialType: 'openAiApi', label: null, - models: [], placeholder: 'Leave empty for Ollama', }, ] as const satisfies ReadonlyArray<{ id: InstanceAiModelProvider; credentialType: (typeof INSTANCE_AI_MODEL_CREDENTIAL_TYPES)[number]; label: string | null; - models: readonly string[]; placeholder: string; }>; diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiModelCatalog.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiModelCatalog.ts new file mode 100644 index 00000000000..3590f965702 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiModelCatalog.ts @@ -0,0 +1,89 @@ +import { + INSTANCE_AI_CATALOG_PROVIDERS, + type InstanceAiCatalogModel, + type InstanceAiCatalogProvider, + type InstanceAiModelCatalogResponse, +} from '@n8n/api-types'; + +import { + INSTANCE_AI_CURATED_MODELS, + type InstanceAiModelProvider, +} from './instanceAiConnection.constants'; + +export interface InstanceAiModelOption { + id: string; + name: string; + recommended: boolean; +} + +function compareModels(a: InstanceAiCatalogModel, b: InstanceAiCatalogModel): number { + const aReleaseTime = a.releaseDate ? Date.parse(a.releaseDate) : Number.NaN; + const bReleaseTime = b.releaseDate ? Date.parse(b.releaseDate) : Number.NaN; + const aHasReleaseDate = !Number.isNaN(aReleaseTime); + const bHasReleaseDate = !Number.isNaN(bReleaseTime); + + if (aHasReleaseDate && bHasReleaseDate && aReleaseTime !== bReleaseTime) { + return bReleaseTime - aReleaseTime; + } + if (aHasReleaseDate !== bHasReleaseDate) return aHasReleaseDate ? -1 : 1; + + return a.name.localeCompare(b.name) || a.id.localeCompare(b.id); +} + +function getOptionsForProviders( + providers: readonly InstanceAiCatalogProvider[], + catalog: InstanceAiModelCatalogResponse['models'] | null, + currentModel: string, +): InstanceAiModelOption[] { + const catalogModels = providers.flatMap((provider) => catalog?.[provider] ?? []); + const catalogById = new Map(); + for (const model of catalogModels) { + if (!catalogById.has(model.id)) catalogById.set(model.id, model); + } + + const seen = new Set(); + const recommended: InstanceAiModelOption[] = []; + for (const [providerIndex, provider] of providers.entries()) { + for (const [modelIndex, id] of INSTANCE_AI_CURATED_MODELS[provider].entries()) { + if (seen.has(id)) continue; + seen.add(id); + recommended.push({ + id, + name: catalogById.get(id)?.name ?? id, + recommended: providerIndex === 0 && modelIndex === 0, + }); + } + } + + const dynamicModels: InstanceAiCatalogModel[] = catalogModels + .filter((model) => { + if (!model.id || !model.name || seen.has(model.id)) return false; + seen.add(model.id); + return true; + }) + .map((model) => ({ ...model })); + const current = currentModel.trim(); + if (current && !seen.has(current)) dynamicModels.push({ id: current, name: current }); + + const dynamic = dynamicModels + .sort(compareModels) + .map((model) => ({ id: model.id, name: model.name, recommended: false })); + + return [...recommended, ...dynamic]; +} + +export function getInstanceAiModelOptions( + provider: InstanceAiModelProvider, + catalog: InstanceAiModelCatalogResponse['models'] | null, + currentModel: string, +): InstanceAiModelOption[] { + if (provider === 'custom') return []; + return getOptionsForProviders([provider], catalog, currentModel); +} + +export function getAllInstanceAiModelOptions( + catalog: InstanceAiModelCatalogResponse['models'] | null, + currentModel: string, +): InstanceAiModelOption[] { + return getOptionsForProviders(INSTANCE_AI_CATALOG_PROVIDERS, catalog, currentModel); +} diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiSettings.store.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiSettings.store.ts index 9532260edc1..56eb23516c2 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiSettings.store.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiSettings.store.ts @@ -11,6 +11,7 @@ import { updatePreferences, fetchServiceCredentials, fetchInstanceModelCredentials, + fetchModelCatalog, verifyModel as verifyModelRequest, verifySandbox as verifySandboxRequest, verifySearch as verifySearchRequest, @@ -32,6 +33,7 @@ import type { InstanceAiProviderConnection, InstanceAiPermissions, InstanceAiPermissionMode, + InstanceAiModelCatalogResponse, ToolCategory, InstanceAiVerifyModelRequest, InstanceAiVerifySandboxRequest, @@ -58,6 +60,9 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () = const preferences = ref(null); const serviceCredentials = ref([]); const instanceModelCredentials = ref([]); + const modelCatalog = ref(null); + const isModelCatalogLoading = ref(false); + let modelCatalogFetchPromise: Promise | null = null; const draft = reactive({}); // ── Gateway / daemon state ────────────────────────────────────────── @@ -604,6 +609,27 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () = } catch {} } + async function loadModelCatalog(): Promise { + if (modelCatalog.value) return; + if (modelCatalogFetchPromise) return await modelCatalogFetchPromise; + + isModelCatalogLoading.value = true; + const request = fetchModelCatalog(rootStore.restApiContext) + .then((response) => { + if (Object.values(response.models).some((models) => models.length > 0)) { + modelCatalog.value = response.models; + } + }) + .catch(() => {}) + .finally(() => { + isModelCatalogLoading.value = false; + modelCatalogFetchPromise = null; + }); + modelCatalogFetchPromise = request; + + await request; + } + async function refreshModuleSettings(): Promise { const promises: Array> = [settingsStore.getModuleSettings()]; if (!preferences.value) { @@ -642,9 +668,11 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () = preferences, serviceCredentials, instanceModelCredentials, + modelCatalog, draft, isLoading, isSaving, + isModelCatalogLoading, fetch, save, persistEnabled, @@ -681,6 +709,7 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () = clearSetupCommand, refreshCredentials, refreshInstanceModelCredentials, + loadModelCatalog, refreshModuleSettings, verifyModel, verifySandbox, diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingWizard.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingWizard.test.ts index b86c139e186..255477a0bdd 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingWizard.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingWizard.test.ts @@ -74,6 +74,7 @@ function setupStore(overrides: Record = {}) { vi.mocked(store.save).mockResolvedValue(true); vi.mocked(store.refreshCredentials).mockResolvedValue(undefined); vi.mocked(store.refreshInstanceModelCredentials).mockResolvedValue(undefined); + vi.mocked(store.loadModelCatalog).mockResolvedValue(undefined); return { pinia, store, credentialsStore: useCredentialsStore() }; } @@ -82,6 +83,89 @@ describe('InstanceAiOnboardingWizard', () => { vi.clearAllMocks(); }); + it('loads and merges the model catalog without changing the selected model', async () => { + const { pinia, store } = setupStore(); + const { findByTestId, findByText } = renderWizard({ pinia }); + + await waitFor(() => expect(store.loadModelCatalog).toHaveBeenCalledOnce()); + await fireEvent.click(inputFor(await findByTestId('assistant-model-name'))); + expect(await findByText('claude-opus-5 · instanceAi.onboarding.recommended')).toBeVisible(); + + store.$patch({ + modelCatalog: { + anthropic: [ + { id: 'claude-opus-5', name: 'Claude Opus 5' }, + { id: 'claude-haiku-5', name: 'Claude Haiku 5' }, + ], + openai: [], + openrouter: [], + }, + }); + + expect(await findByText('Claude Opus 5 · instanceAi.onboarding.recommended')).toBeVisible(); + expect(await findByText('Claude Haiku 5')).toBeVisible(); + + await fireEvent.update(inputFor(await findByTestId('assistant-model-api-key')), 'model-key'); + vi.mocked(store.verifyModel).mockResolvedValue({ ok: true }); + await fireEvent.click(await findByTestId('wizard-primary')); + await waitFor(() => + expect(store.verifyModel).toHaveBeenCalledWith( + expect.objectContaining({ modelName: 'claude-opus-5' }), + ), + ); + }); + + it('keeps a user-selected model when catalog data arrives after a provider switch', async () => { + const { pinia, store } = setupStore(); + vi.mocked(store.verifyModel).mockResolvedValue({ ok: true }); + const { findByTestId, findByText } = renderWizard({ pinia }); + + await fireEvent.click(inputFor(await findByTestId('assistant-model-provider'))); + await fireEvent.click(await findByText('OpenAI')); + await fireEvent.click(inputFor(await findByTestId('assistant-model-name'))); + await fireEvent.click(await findByText('gpt-5.6-terra')); + + store.$patch({ + modelCatalog: { + anthropic: [], + openai: [ + { id: 'gpt-5.6-terra', name: 'GPT 5.6 Terra' }, + { id: 'gpt-dynamic', name: 'GPT Dynamic' }, + ], + openrouter: [], + }, + }); + await fireEvent.update(inputFor(await findByTestId('assistant-model-api-key')), 'model-key'); + await fireEvent.click(await findByTestId('wizard-primary')); + + await waitFor(() => + expect(store.verifyModel).toHaveBeenCalledWith( + expect.objectContaining({ modelName: 'gpt-5.6-terra' }), + ), + ); + }); + + it('does not load the catalog for a saved custom endpoint', async () => { + const { pinia, store, credentialsStore } = setupStore({ + modelCredentialId: 'custom-model-credential', + modelName: 'qwen3-coder', + }); + store.$patch({ + instanceModelCredentials: [ + { id: 'custom-model-credential', name: 'Custom model', type: 'openAiApi' }, + ], + }); + vi.mocked(credentialsStore.getCredentialData).mockResolvedValue({ + data: { url: 'http://ollama.internal:11434/v1' }, + } as never); + + const { findByTestId } = renderWizard({ pinia }); + expect(inputFor(await findByTestId('assistant-model-base-url')).value).toBe( + 'http://ollama.internal:11434/v1', + ); + expect(store.loadModelCatalog).not.toHaveBeenCalled(); + }); + it('verifies and saves a model connection before advancing', async () => { const { pinia, store } = setupStore(); vi.mocked(store.verifyModel).mockResolvedValue({ ok: true, latencyMs: 125 }); @@ -151,6 +235,33 @@ describe('InstanceAiOnboardingWizard', () => { await waitFor(() => expect(emitted().advance).toEqual([[]]), { timeout: 2500 }); }); + it('offers models from every provider when the environment-managed provider is masked', async () => { + const { pinia, store } = setupStore({ + modelName: 'saved-env-model', + modelEnvConfigured: true, + envManaged: { + model: { provider: true, apiKey: true, baseUrl: false, model: false }, + sandbox: { provider: false, serviceUrl: false, apiKey: false }, + search: { provider: false, apiKey: false, url: false }, + }, + }); + store.$patch({ + modelCatalog: { + anthropic: [{ id: 'claude-dynamic', name: 'Claude Dynamic' }], + openai: [{ id: 'gpt-dynamic', name: 'GPT Dynamic' }], + openrouter: [{ id: 'openrouter/dynamic', name: 'OpenRouter Dynamic' }], + }, + }); + const { findByTestId, findByText } = renderWizard({ pinia }); + + await fireEvent.click(inputFor(await findByTestId('assistant-model-name'))); + + expect(await findByText('Claude Dynamic')).toBeVisible(); + expect(await findByText('GPT Dynamic')).toBeVisible(); + expect(await findByText('OpenRouter Dynamic')).toBeVisible(); + expect(await findByText('saved-env-model')).toBeVisible(); + }); + it('supports a custom OpenAI-compatible model without an API key', async () => { const { pinia, store } = setupStore(); vi.mocked(store.verifyModel).mockResolvedValue({ ok: true }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingWizard.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingWizard.vue index cc84af9e4cb..53603038769 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingWizard.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingWizard.vue @@ -30,11 +30,13 @@ import { SANDBOX_PROVIDER_LABELS } from '../constants'; import { useInstanceCredentialTest } from '../composables/useInstanceCredentialTest'; import { INSTANCE_AI_MODEL_PROVIDERS, + INSTANCE_AI_CURATED_MODELS, INSTANCE_AI_SANDBOX_PROVIDERS, INSTANCE_AI_SEARCH_PROVIDERS, type InstanceAiModelProvider, type InstanceAiSearchProvider, } from '../instanceAiConnection.constants'; +import { getAllInstanceAiModelOptions, getInstanceAiModelOptions } from '../instanceAiModelCatalog'; import { useInstanceAiSettingsStore } from '../instanceAiSettings.store'; import type { InstanceAiOnboardingStep } from './useInstanceAiOnboarding'; @@ -48,6 +50,7 @@ const BRAVE_SEARCH_KEYS_URL = 'https://api-dashboard.search.brave.com/app/keys'; const ENV_DOCS_URL = 'https://docs.n8n.io/deploy/host-n8n/configure-n8n/set-up-ai-assistant'; const SUCCESS_PAUSE_MS = TIME.SECOND * 1.5; const DEFAULT_MODEL_PROVIDER = INSTANCE_AI_MODEL_PROVIDERS[0]!; +const DEFAULT_MODEL_NAME = INSTANCE_AI_CURATED_MODELS[DEFAULT_MODEL_PROVIDER.id][0] ?? ''; type VerificationSuccess = Extract; const VERIFICATION_FAILURE_COPY: Record = { unauthorized: 'instanceAi.onboarding.verification.unauthorized', @@ -95,7 +98,7 @@ const success = ref(null); const modelProvider = ref('anthropic'); const modelApiKey = ref(''); const modelBaseUrl = ref(''); -const modelName = ref(DEFAULT_MODEL_PROVIDER.models[0] ?? ''); +const modelName = ref(DEFAULT_MODEL_NAME); const sandboxProvider = ref<'n8n-sandbox' | 'daytona' | null>(null); const sandboxServiceUrl = ref(''); const sandboxApiKey = ref(''); @@ -114,13 +117,11 @@ const modelConfig = computed( ); const modelConnectionLocked = computed(() => store.settings?.envManaged?.model?.provider === true); const modelNameLocked = computed(() => store.settings?.envManaged?.model?.model === true); -const modelOptions = computed(() => { - const options: string[] = modelConnectionLocked.value - ? INSTANCE_AI_MODEL_PROVIDERS.flatMap(({ models }) => models) - : [...modelConfig.value.models]; - if (modelName.value && !options.includes(modelName.value)) options.unshift(modelName.value); - return [...new Set(options)]; -}); +const modelOptions = computed(() => + modelConnectionLocked.value + ? getAllInstanceAiModelOptions(store.modelCatalog, modelName.value) + : getInstanceAiModelOptions(modelProvider.value, store.modelCatalog, modelName.value), +); const sandboxEnvManaged = computed(() => store.settings?.sandboxEnvConfigured === true); const searchEnvManaged = computed(() => store.settings?.searchEnvConfigured === true); const readOnly = computed(() => !store.canManageInstanceCredentials); @@ -312,7 +313,7 @@ function applyExistingCredential(credential: InstanceAiProviderConnection): void modelProvider.value = modelProviderForCredentialType(credential.type); modelName.value = (credential.id === assignedCredentialId() ? store.settings?.modelName : null) || - INSTANCE_AI_MODEL_PROVIDERS.find(({ id }) => id === modelProvider.value)?.models[0] || + INSTANCE_AI_CURATED_MODELS[modelProvider.value][0] || ''; } else if (props.step === 'sandbox') { sandboxProvider.value = credential.type === 'daytonaApi' ? 'daytona' : 'n8n-sandbox'; @@ -340,7 +341,7 @@ async function hydrateModel(generation: number, rememberProvider = true): Promis modelProvider.value = 'anthropic'; modelApiKey.value = ''; modelBaseUrl.value = ''; - modelName.value = store.settings?.modelName || DEFAULT_MODEL_PROVIDER.models[0] || ''; + modelName.value = store.settings?.modelName || DEFAULT_MODEL_NAME; if (modelConnectionLocked.value) { if (modelNameLocked.value) modelName.value = ''; return; @@ -423,7 +424,11 @@ async function hydrate(): Promise { watch( () => [props.open, props.step] as const, async ([open]) => { - if (open) await hydrate(); + if (!open) return; + await hydrate(); + if (props.step === 'model' && modelProvider.value !== 'custom') { + void store.loadModelCatalog(); + } }, { immediate: true }, ); @@ -463,7 +468,8 @@ async function selectModelProvider(provider: unknown): Promise { modelProvider.value = next.id; modelApiKey.value = ''; modelBaseUrl.value = ''; - modelName.value = next.models[0] ?? ''; + modelName.value = INSTANCE_AI_CURATED_MODELS[next.id][0] ?? ''; + if (next.id !== 'custom') void store.loadModelCatalog(); } function selectSandboxProvider(provider: unknown): void { @@ -846,6 +852,7 @@ const existingCredentialLabel = (credential: InstanceAiProviderConnection) => id="assistant-model-name" :model-value="modelName" :teleported="true" + filterable :data-test-id=" surface === 'settings' ? 'n8n-agent-model-name-input' : 'assistant-model-name' " @@ -853,12 +860,12 @@ const existingCredentialLabel = (credential: InstanceAiProviderConnection) => > From c1334647a348e002f3f0f3d782c5e73862c99bec Mon Sep 17 00:00:00 2001 From: Declan Carroll Date: Mon, 10 Aug 2026 14:32:02 +0100 Subject: [PATCH 026/151] chore: Align dev environments to Postgres 18 (no-changelog) (#35938) Co-authored-by: Claude Fable 5 --- .devcontainer/codespaces/docker-compose.yml | 4 ++-- .devcontainer/docker-compose.yml | 4 ++-- .../benchmark/scripts/n8n-setups/postgres/docker-compose.yml | 2 +- .../scripts/n8n-setups/scaling-multi-main/docker-compose.yml | 2 +- .../scripts/n8n-setups/scaling-single-main/docker-compose.yml | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.devcontainer/codespaces/docker-compose.yml b/.devcontainer/codespaces/docker-compose.yml index 3ab32136ada..a03fd5facb1 100644 --- a/.devcontainer/codespaces/docker-compose.yml +++ b/.devcontainer/codespaces/docker-compose.yml @@ -3,10 +3,10 @@ volumes: services: postgres: - image: postgres:16-alpine + image: postgres:18-alpine restart: unless-stopped volumes: - - postgres-data:/var/lib/postgresql/data + - postgres-data:/var/lib/postgresql environment: - POSTGRES_DB=n8n - POSTGRES_PASSWORD=password diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 16103c2a4fd..146db221f6b 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -3,10 +3,10 @@ volumes: services: postgres: - image: postgres:16-alpine + image: postgres:18-alpine restart: unless-stopped volumes: - - postgres-data:/var/lib/postgresql/data + - postgres-data:/var/lib/postgresql environment: - POSTGRES_DB=n8n - POSTGRES_PASSWORD=password diff --git a/packages/@n8n/benchmark/scripts/n8n-setups/postgres/docker-compose.yml b/packages/@n8n/benchmark/scripts/n8n-setups/postgres/docker-compose.yml index 596a40b8511..82553451283 100644 --- a/packages/@n8n/benchmark/scripts/n8n-setups/postgres/docker-compose.yml +++ b/packages/@n8n/benchmark/scripts/n8n-setups/postgres/docker-compose.yml @@ -7,7 +7,7 @@ services: - ${MOCK_API_DATA_PATH}/mappings:/home/wiremock/mappings postgres: - image: postgres:16.4 + image: postgres:18.4 restart: always user: root:root environment: diff --git a/packages/@n8n/benchmark/scripts/n8n-setups/scaling-multi-main/docker-compose.yml b/packages/@n8n/benchmark/scripts/n8n-setups/scaling-multi-main/docker-compose.yml index b554d5b4b91..61f725603e7 100644 --- a/packages/@n8n/benchmark/scripts/n8n-setups/scaling-multi-main/docker-compose.yml +++ b/packages/@n8n/benchmark/scripts/n8n-setups/scaling-multi-main/docker-compose.yml @@ -17,7 +17,7 @@ services: timeout: 3s postgres: - image: postgres:16.4 + image: postgres:18.4 restart: always environment: - POSTGRES_DB=n8n diff --git a/packages/@n8n/benchmark/scripts/n8n-setups/scaling-single-main/docker-compose.yml b/packages/@n8n/benchmark/scripts/n8n-setups/scaling-single-main/docker-compose.yml index ee2f221c937..2c5b8b51913 100644 --- a/packages/@n8n/benchmark/scripts/n8n-setups/scaling-single-main/docker-compose.yml +++ b/packages/@n8n/benchmark/scripts/n8n-setups/scaling-single-main/docker-compose.yml @@ -16,7 +16,7 @@ services: timeout: 3s postgres: - image: postgres:16.4 + image: postgres:18.4 user: root:root restart: always environment: From ec3fbda8468d892946c44576aec1338bb1a26239 Mon Sep 17 00:00:00 2001 From: yehorkardash Date: Mon, 10 Aug 2026 15:32:55 +0200 Subject: [PATCH 027/151] refactor(core): Decouple agent integration setup (#35589) --- packages/@n8n/api-types/src/agents/dto.ts | 4 - packages/@n8n/api-types/src/agents/index.ts | 1 + .../@n8n/api-types/src/agents/slack/dto.ts | 7 + .../@n8n/api-types/src/agents/slack/index.ts | 2 + .../@n8n/api-types/src/agents/slack/types.ts | 44 + packages/@n8n/api-types/src/agents/types.ts | 45 - ...ent-integration-management.service.test.ts | 149 ++++ .../agent-integrations.controller.test.ts | 773 ++---------------- ...gent-slack-integrations.controller.test.ts | 55 ++ .../agent-integration-management.service.ts | 105 +++ .../agents/agent-integrations.controller.ts | 146 +--- .../agent-slack-integrations.controller.ts | 89 ++ .../cli/src/modules/agents/agents.module.ts | 5 +- .../__tests__/agent-chat-bridge.test.ts | 2 +- .../__tests__/component-mapper.test.ts | 2 +- .../helpers/slack/replay-test-context.ts | 2 +- .../integration-action-executor.test.ts | 2 +- ...integration-context-query-executor.test.ts | 2 +- .../__tests__/slack-app-setup.service.test.ts | 472 ----------- .../__tests__/slack-integration.test.ts | 27 +- .../slack-manual-setup.service.test.ts | 171 ++++ .../__tests__/slack-methods.service.test.ts | 106 +++ .../integrations/agent-chat-integration.ts | 12 +- .../integrations/chat-integration.service.ts | 28 +- .../integration-action-executor.ts | 35 +- .../slack/synthetic-integration.test.ts | 2 +- .../__tests__/telegram/integration.test.ts | 11 + .../{ => slack}/slack-bridge-behavior.ts | 6 +- .../{ => slack}/slack-integration.ts | 39 +- .../slack/slack-manual-setup.service.ts | 194 +++++ .../platforms/slack/slack-methods.service.ts | 211 +++++ .../platforms/{ => slack}/slack-operations.ts | 6 +- .../platforms/telegram-integration.ts | 11 +- .../integrations/slack-app-setup.service.ts | 418 ---------- .../mcp/__tests__/agent-tools.service.test.ts | 113 +-- .../mcp/tools/agents/agent-tools.service.ts | 74 +- .../cache/__tests__/cache.service.test.ts | 9 + .../cli/src/services/cache/cache.service.ts | 22 + .../src/services/cache/redis.cache-manager.ts | 6 + .../frontend/@n8n/i18n/src/locales/en.json | 1 + .../__tests__/AgentChannelListItem.test.ts | 31 +- .../__tests__/AgentChannelModal.test.ts | 597 ++++---------- .../__tests__/AgentChannelSlackSetup.test.ts | 2 +- .../__tests__/AgentSlackSettingsForm.test.ts | 102 --- .../__tests__/useAgentChannelSetup.test.ts | 177 ++-- .../channels/AgentChannelStandardEditView.vue | 66 ++ .../discord/AgentChannelDiscordEditView.vue | 29 + .../fallback/AgentChannelFallbackView.vue | 71 ++ .../linear/AgentChannelLinearEditView.vue | 30 + .../linear}/AgentChannelLinearSetup.vue | 24 +- .../features/agents/channels/registry.test.ts | 38 + .../src/features/agents/channels/registry.ts | 76 ++ .../slack/AgentChannelSlackEditView.vue | 35 + .../slack/AgentChannelSlackSetupView.vue | 54 ++ .../src/features/agents/channels/slack/api.ts | 23 + .../slack/useSlackChannelRuntime.test.ts | 71 ++ .../channels/slack/useSlackChannelRuntime.ts | 126 +++ .../telegram/AgentChannelTelegramEditView.vue | 30 + .../AgentChannelTelegramSetup.test.ts | 46 ++ .../telegram}/AgentChannelTelegramSetup.vue | 55 +- .../src/features/agents/channels/types.ts | 78 ++ .../components/AgentChannelListItem.vue | 142 ++-- .../agents/components/AgentChannelModal.vue | 320 +++----- .../components/AgentChannelSlackSetup.vue | 2 +- .../components/AgentChannelsSection.vue | 2 +- .../AgentIntegrationSettingsForm.vue | 77 -- .../components/AgentSlackSettingsForm.vue | 313 ------- .../agents/composables/useAgentApi.ts | 47 +- .../composables/useAgentChannelSetup.ts | 104 --- .../components/ChannelSetupCard.test.ts | 13 +- .../ai/shared/components/ChannelSetupCard.vue | 221 +++-- 71 files changed, 2860 insertions(+), 3551 deletions(-) create mode 100644 packages/@n8n/api-types/src/agents/slack/dto.ts create mode 100644 packages/@n8n/api-types/src/agents/slack/index.ts create mode 100644 packages/@n8n/api-types/src/agents/slack/types.ts create mode 100644 packages/cli/src/modules/agents/__tests__/agent-integration-management.service.test.ts create mode 100644 packages/cli/src/modules/agents/__tests__/agent-slack-integrations.controller.test.ts create mode 100644 packages/cli/src/modules/agents/agent-integration-management.service.ts create mode 100644 packages/cli/src/modules/agents/agent-slack-integrations.controller.ts delete mode 100644 packages/cli/src/modules/agents/integrations/__tests__/slack-app-setup.service.test.ts create mode 100644 packages/cli/src/modules/agents/integrations/__tests__/slack-manual-setup.service.test.ts create mode 100644 packages/cli/src/modules/agents/integrations/__tests__/slack-methods.service.test.ts rename packages/cli/src/modules/agents/integrations/platforms/{ => slack}/slack-bridge-behavior.ts (98%) rename packages/cli/src/modules/agents/integrations/platforms/{ => slack}/slack-integration.ts (83%) create mode 100644 packages/cli/src/modules/agents/integrations/platforms/slack/slack-manual-setup.service.ts create mode 100644 packages/cli/src/modules/agents/integrations/platforms/slack/slack-methods.service.ts rename packages/cli/src/modules/agents/integrations/platforms/{ => slack}/slack-operations.ts (97%) delete mode 100644 packages/cli/src/modules/agents/integrations/slack-app-setup.service.ts delete mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/AgentSlackSettingsForm.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/channels/AgentChannelStandardEditView.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/channels/discord/AgentChannelDiscordEditView.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/channels/fallback/AgentChannelFallbackView.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/channels/linear/AgentChannelLinearEditView.vue rename packages/frontend/editor-ui/src/features/agents/{components => channels/linear}/AgentChannelLinearSetup.vue (93%) create mode 100644 packages/frontend/editor-ui/src/features/agents/channels/registry.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/channels/registry.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/channels/slack/AgentChannelSlackEditView.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/channels/slack/AgentChannelSlackSetupView.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/channels/slack/api.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/channels/slack/useSlackChannelRuntime.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/channels/slack/useSlackChannelRuntime.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/channels/telegram/AgentChannelTelegramEditView.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/channels/telegram/AgentChannelTelegramSetup.test.ts rename packages/frontend/editor-ui/src/features/agents/{components => channels/telegram}/AgentChannelTelegramSetup.vue (76%) create mode 100644 packages/frontend/editor-ui/src/features/agents/channels/types.ts delete mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentIntegrationSettingsForm.vue delete mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentSlackSettingsForm.vue diff --git a/packages/@n8n/api-types/src/agents/dto.ts b/packages/@n8n/api-types/src/agents/dto.ts index fe3c6a2fa41..954107e285d 100644 --- a/packages/@n8n/api-types/src/agents/dto.ts +++ b/packages/@n8n/api-types/src/agents/dto.ts @@ -231,10 +231,6 @@ export class RevertAgentToVersionDto extends Z.class({ versionId: z.string().min(1), }) {} -export class CreateSlackAgentAppDto extends Z.class({ - appConfigurationToken: z.string().min(1), -}) {} - export class TestAgentVectorStoreDto extends Z.class({ vectorStore: AgentVectorStoreConfigSchema, }) {} diff --git a/packages/@n8n/api-types/src/agents/index.ts b/packages/@n8n/api-types/src/agents/index.ts index 24d1fb4a42e..ccbdad686f2 100644 --- a/packages/@n8n/api-types/src/agents/index.ts +++ b/packages/@n8n/api-types/src/agents/index.ts @@ -10,6 +10,7 @@ export * from './agent-skill.schema'; export * from './child-trace'; export * from './inline-agent-config.schema'; export * from './sanitize-agent-json-config'; +export * from './slack'; export * from './agent-task.schema'; export * from './dto'; export * from './model-providers'; diff --git a/packages/@n8n/api-types/src/agents/slack/dto.ts b/packages/@n8n/api-types/src/agents/slack/dto.ts new file mode 100644 index 00000000000..36ba9cb7a6d --- /dev/null +++ b/packages/@n8n/api-types/src/agents/slack/dto.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +import { Z } from '../../zod-class'; + +export class CreateSlackAgentAppDto extends Z.class({ + appConfigurationToken: z.string().min(1), +}) {} diff --git a/packages/@n8n/api-types/src/agents/slack/index.ts b/packages/@n8n/api-types/src/agents/slack/index.ts new file mode 100644 index 00000000000..986213b43af --- /dev/null +++ b/packages/@n8n/api-types/src/agents/slack/index.ts @@ -0,0 +1,2 @@ +export * from './dto'; +export type * from './types'; diff --git a/packages/@n8n/api-types/src/agents/slack/types.ts b/packages/@n8n/api-types/src/agents/slack/types.ts new file mode 100644 index 00000000000..a7b8a3ef246 --- /dev/null +++ b/packages/@n8n/api-types/src/agents/slack/types.ts @@ -0,0 +1,44 @@ +export interface CreateSlackAgentAppResponse { + appId: string; + installUrl: string; +} + +export interface SlackAgentAppManifest { + display_information: { + name: string; + }; + features: { + app_home: { + home_tab_enabled: boolean; + messages_tab_enabled: boolean; + messages_tab_read_only_enabled: boolean; + }; + bot_user: { + display_name: string; + always_online: boolean; + }; + }; + oauth_config: { + redirect_urls?: string[]; + scopes: { + bot: string[]; + }; + }; + settings: { + event_subscriptions: { + request_url: string; + bot_events: string[]; + }; + interactivity: { + is_enabled: boolean; + request_url: string; + }; + org_deploy_enabled: boolean; + socket_mode_enabled: boolean; + token_rotation_enabled: boolean; + }; +} + +export interface SlackAgentAppManifestResponse { + manifest: SlackAgentAppManifest; +} diff --git a/packages/@n8n/api-types/src/agents/types.ts b/packages/@n8n/api-types/src/agents/types.ts index 95797cbd4cb..28a9cd60b40 100644 --- a/packages/@n8n/api-types/src/agents/types.ts +++ b/packages/@n8n/api-types/src/agents/types.ts @@ -46,51 +46,6 @@ export interface AgentIntegrationStatusResponse { integrations: AgentIntegrationStatusEntry[]; } -export interface CreateSlackAgentAppResponse { - appId: string; - installUrl: string; -} - -export interface SlackAgentAppManifest { - display_information: { - name: string; - }; - features: { - app_home: { - home_tab_enabled: boolean; - messages_tab_enabled: boolean; - messages_tab_read_only_enabled: boolean; - }; - bot_user: { - display_name: string; - always_online: boolean; - }; - }; - oauth_config: { - redirect_urls?: string[]; - scopes: { - bot: string[]; - }; - }; - settings: { - event_subscriptions: { - request_url: string; - bot_events: string[]; - }; - interactivity: { - is_enabled: boolean; - request_url: string; - }; - org_deploy_enabled: boolean; - socket_mode_enabled: boolean; - token_rotation_enabled: boolean; - }; -} - -export interface SlackAgentAppManifestResponse { - manifest: SlackAgentAppManifest; -} - export interface AgentSkillReference { path: string; content: string; diff --git a/packages/cli/src/modules/agents/__tests__/agent-integration-management.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-integration-management.service.test.ts new file mode 100644 index 00000000000..2dbfc05efb4 --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/agent-integration-management.service.test.ts @@ -0,0 +1,149 @@ +/* eslint-disable @typescript-eslint/unbound-method -- mock-based tests intentionally reference unbound methods */ +import type { AgentIntegrationConfig } from '@n8n/api-types'; +import { mock } from 'vitest-mock-extended'; + +import type { CredentialsService } from '@/credentials/credentials.service'; + +import { AgentIntegrationManagementService } from '../agent-integration-management.service'; +import type { AgentIntegrationPersistenceService } from '../agent-integration-persistence.service'; +import type { Agent } from '../entities/agent.entity'; +import type { + AgentChatIntegration, + ChatIntegrationRegistry, +} from '../integrations/agent-chat-integration'; +import type { ChatIntegrationService } from '../integrations/chat-integration.service'; + +describe('AgentIntegrationManagementService', () => { + const user = { id: 'user-1' }; + const integration = { + type: 'slack', + credentialId: 'credential-1', + } satisfies AgentIntegrationConfig; + const agent = { + id: 'agent-1', + projectId: 'project-1', + activeVersionId: 'version-1', + integrations: [], + } as unknown as Agent; + + function makeService() { + const persistenceService = mock(); + const credentialsService = mock(); + const chatService = mock(); + const registry = mock(); + const implementation = mock({ + type: 'slack', + displayLabel: 'Slack', + credentialTypes: ['slackApi'], + }); + registry.require.mockReturnValue(implementation); + return { + service: new AgentIntegrationManagementService( + persistenceService, + credentialsService, + chatService, + registry, + ), + persistenceService, + credentialsService, + chatService, + implementation, + }; + } + + it('persists, connects, and broadcasts for a published integration', async () => { + const { service, persistenceService, credentialsService, chatService, implementation } = + makeService(); + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([ + { id: integration.credentialId, type: 'slackApi' }, + ] as never); + persistenceService.saveCredentialIntegration.mockResolvedValue(agent); + + await service.connect({ agent, user: user as never, integration }); + + expect(implementation.validateConfig).toHaveBeenCalledWith(integration); + expect(chatService.validateBeforeConnect).toHaveBeenCalledWith( + agent.id, + integration, + agent.projectId, + ); + expect(persistenceService.saveCredentialIntegration).toHaveBeenCalledWith(agent, integration, { + user, + modifiedBy: 'user', + broadcast: false, + }); + expect(chatService.connect).toHaveBeenCalledWith(agent.id, integration, agent.projectId); + expect(chatService.broadcastIntegrationChange).toHaveBeenCalledWith( + agent.id, + integration, + 'connect', + ); + }); + + it('persists but does not initialize an unpublished integration', async () => { + const { service, persistenceService, credentialsService, chatService } = makeService(); + const draftAgent = { ...agent, activeVersionId: null }; + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([ + { id: integration.credentialId, type: 'slackApi' }, + ] as never); + persistenceService.saveCredentialIntegration.mockResolvedValue(draftAgent); + + await service.connect({ agent: draftAgent, user: user as never, integration }); + + expect(persistenceService.saveCredentialIntegration).toHaveBeenCalledWith( + draftAgent, + integration, + { user, modifiedBy: 'user', broadcast: false }, + ); + expect(chatService.validateBeforeConnect).toHaveBeenCalledWith( + draftAgent.id, + integration, + draftAgent.projectId, + ); + expect(chatService.connect).not.toHaveBeenCalled(); + expect(chatService.broadcastIntegrationChange).not.toHaveBeenCalled(); + }); + + it('does not broadcast when live connection fails', async () => { + const { service, persistenceService, credentialsService, chatService } = makeService(); + const connectionError = new Error('Slack connect failed'); + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([ + { id: integration.credentialId, type: 'slackApi' }, + ] as never); + persistenceService.saveCredentialIntegration.mockResolvedValue(agent); + chatService.connect.mockRejectedValue(connectionError); + + await expect(service.connect({ agent, user: user as never, integration })).rejects.toBe( + connectionError, + ); + + expect(persistenceService.saveCredentialIntegration).toHaveBeenCalled(); + expect(chatService.connect).toHaveBeenCalled(); + expect(chatService.broadcastIntegrationChange).not.toHaveBeenCalled(); + }); + + it('disconnects the runtime channel before removing persistence', async () => { + const { service, persistenceService, chatService } = makeService(); + const connectedAgent = { ...agent, integrations: [integration] } as Agent; + persistenceService.removeCredentialIntegration.mockResolvedValue({ + ...connectedAgent, + integrations: [], + }); + + await service.disconnect({ + agent: connectedAgent, + user: user as never, + type: integration.type, + credentialId: integration.credentialId, + modifiedBy: 'mcp', + }); + + expect(chatService.disconnectChannel).toHaveBeenCalledWith(agent.id, integration); + expect(persistenceService.removeCredentialIntegration).toHaveBeenCalledWith( + connectedAgent, + integration.type, + integration.credentialId, + { user, modifiedBy: 'mcp', broadcast: false }, + ); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/agent-integrations.controller.test.ts b/packages/cli/src/modules/agents/__tests__/agent-integrations.controller.test.ts index 50a7db558ae..812de1d51e0 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-integrations.controller.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-integrations.controller.test.ts @@ -1,69 +1,42 @@ +/* eslint-disable @typescript-eslint/unbound-method -- mock-based tests intentionally reference unbound methods */ +import type { AgentIntegrationConfig } from '@n8n/api-types'; import type { Mocked } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import type { CredentialsService } from '@/credentials/credentials.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; - -import type { AgentIntegrationPersistenceService } from '../agent-integration-persistence.service'; +import type { AgentIntegrationManagementService } from '../agent-integration-management.service'; import { AgentIntegrationsController } from '../agent-integrations.controller'; +import type { Agent } from '../entities/agent.entity'; import type { ChatIntegrationRegistry } from '../integrations/agent-chat-integration'; import type { ChatIntegrationService } from '../integrations/chat-integration.service'; -import type { SlackAppSetupService } from '../integrations/slack-app-setup.service'; import type { AgentRepository } from '../repositories/agent.repository'; import { expectProjectScopedAgentRoutes, getRoutesByHandlerName, } from './test-utils/controller-route-metadata'; -const UNAUTHENTICATED_HANDLERS = new Set(['handleWebhook', 'handleSlackAppOAuthCallback']); +const UNAUTHENTICATED_HANDLERS = new Set(['handleWebhook']); function makeController({ - agentIntegrationPersistenceService = mock(), - credentialsService = mock(), + managementService = mock(), chatIntegrationService = mock(), agentRepository = mock(), chatIntegrationRegistry = mock(), - slackAppSetupService = mock(), }: { - agentIntegrationPersistenceService?: Mocked; - credentialsService?: Mocked; + managementService?: Mocked; chatIntegrationService?: Mocked; agentRepository?: Mocked; chatIntegrationRegistry?: Mocked; - slackAppSetupService?: Mocked; } = {}) { - if (!chatIntegrationRegistry.require.getMockImplementation()) { - chatIntegrationRegistry.require.mockImplementation( - (type: string) => - ({ - type, - displayLabel: type, - credentialTypes: - type === 'telegram' - ? ['telegramApi'] - : type === 'linear' - ? ['linearOAuth2Api'] - : [`${type}Api`], - }) as never, - ); - } - return { controller: new AgentIntegrationsController( - agentIntegrationPersistenceService, - credentialsService, + managementService, chatIntegrationService, agentRepository, chatIntegrationRegistry, - slackAppSetupService, ), - agentIntegrationPersistenceService, - credentialsService, + managementService, chatIntegrationService, agentRepository, - chatIntegrationRegistry, - slackAppSetupService, }; } @@ -74,8 +47,6 @@ describe('AgentIntegrationsController route access scopes', () => { it.each([ ['connectIntegration', 'agent:update'], - ['createSlackApp', 'agent:update'], - ['getSlackAppManifest', 'agent:read'], ['disconnectIntegration', 'agent:update'], ['integrationStatus', 'agent:read'], ])('%s uses %s', (handlerName, scope) => { @@ -83,667 +54,91 @@ describe('AgentIntegrationsController route access scopes', () => { }); }); -describe('AgentIntegrationsController integration credentials', () => { - it('rejects credentials that are not usable in the agent project', async () => { - const credentialsService = mock(); - credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([ - { - id: 'cred-allowed', - name: 'Allowed Slack', - type: 'slackApi', - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-01T00:00:00.000Z', - scopes: [], - isManaged: false, - isGlobal: false, - isResolvable: true, - currentUserHasAccess: true, - homeProject: null, - sharedWithProjects: [], - }, - ]); - - const chatIntegrationService = mock(); - const agentRepository = mock(); - agentRepository.findByIdAndProjectId.mockResolvedValue({ - id: 'agent-1', - projectId: 'project-1', - activeVersionId: 'v1', - activeVersion: {}, - integrations: [], - } as never); - - const { controller } = makeController({ - credentialsService, - chatIntegrationService, - agentRepository, - }); - - await expect( - controller.connectIntegration( - { - params: { projectId: 'project-1' }, - user: { id: 'user-1' }, - body: { type: 'slack', credentialId: 'cred-outside-project' }, - } as never, - undefined as never, - 'agent-1', - ), - ).rejects.toThrow(NotFoundError); - - expect(credentialsService.getCredentialsAUserCanUseInAWorkflow).toHaveBeenCalledWith( - { id: 'user-1' }, - { projectId: 'project-1' }, - ); - expect(chatIntegrationService.connect).not.toHaveBeenCalled(); - }); - - it('requires Telegram settings when connecting Telegram', async () => { - const { controller, chatIntegrationService } = makeController(); - - await expect( - controller.connectIntegration( - { - params: { projectId: 'project-1' }, - user: { id: 'user-1' }, - body: { type: 'telegram', credentialId: 'cred-telegram' }, - } as never, - undefined as never, - 'agent-1', - ), - ).rejects.toThrow(BadRequestError); - - expect(chatIntegrationService.connect).not.toHaveBeenCalled(); - }); - - it('rejects credentials whose type is not supported by the chat integration', async () => { - const credentialsService = mock(); - credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([ - { - id: 'cred-oauth', - name: 'Slack OAuth', - type: 'slackOAuth2Api', - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-01T00:00:00.000Z', - scopes: [], - isManaged: false, - isGlobal: false, - isResolvable: true, - currentUserHasAccess: true, - homeProject: null, - sharedWithProjects: [], - }, - ]); - - const agentRepository = mock(); - agentRepository.findByIdAndProjectId.mockResolvedValue({ - id: 'agent-1', - projectId: 'project-1', - activeVersionId: 'v1', - activeVersion: {}, - integrations: [], - } as never); - - const chatIntegrationService = mock(); - const chatIntegrationRegistry = mock(); - chatIntegrationRegistry.require.mockReturnValue({ - type: 'slack', - credentialTypes: ['slackApi'], - } as never); - - const { controller } = makeController({ - credentialsService, - chatIntegrationService, - agentRepository, - chatIntegrationRegistry, - }); - - await expect( - controller.connectIntegration( - { - params: { projectId: 'project-1' }, - user: { id: 'user-1' }, - body: { type: 'slack', credentialId: 'cred-oauth' }, - } as never, - undefined as never, - 'agent-1', - ), - ).rejects.toThrow(BadRequestError); - - expect(chatIntegrationService.connect).not.toHaveBeenCalled(); - }); - - it('persists, connects, and broadcasts Telegram settings for a published agent', async () => { - const credentialsService = mock(); - credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([ - { - id: 'cred-telegram', - name: 'Telegram Bot', - type: 'telegramApi', - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-01T00:00:00.000Z', - scopes: [], - isManaged: false, - isGlobal: false, - isResolvable: true, - currentUserHasAccess: true, - homeProject: null, - sharedWithProjects: [], - }, - ]); - - const agentRepository = mock(); - const agent = { - id: 'agent-1', - projectId: 'project-1', - activeVersionId: 'v1', - activeVersion: {}, - integrations: [], - }; - agentRepository.findByIdAndProjectId.mockResolvedValue(agent as never); - - const chatIntegrationService = mock(); - const agentIntegrationPersistenceService = mock(); - agentIntegrationPersistenceService.saveCredentialIntegration.mockResolvedValue(agent as never); - const { controller } = makeController({ - agentIntegrationPersistenceService, - credentialsService, - chatIntegrationService, - agentRepository, - }); - const settings = { - accessMode: 'private' as const, - allowedUsers: ['123'], - }; - - await expect( - controller.connectIntegration( - { - params: { projectId: 'project-1' }, - user: { id: 'user-1' }, - body: { - type: 'telegram', - credentialId: 'cred-telegram', - settings, - }, - } as never, - undefined as never, - 'agent-1', - ), - ).resolves.toEqual({ status: 'connected' }); +describe('AgentIntegrationsController integration management', () => { + const user = { id: 'user-1' }; + const agent = { + id: 'agent-1', + projectId: 'project-1', + activeVersionId: 'version-1', + integrations: [], + } as unknown as Agent; + it('delegates a connect and reports connected for a published agent', async () => { + const { controller, managementService, agentRepository } = makeController(); const integration = { - type: 'telegram', - credentialId: 'cred-telegram', - settings, - }; - expect(agentIntegrationPersistenceService.saveCredentialIntegration).toHaveBeenCalledWith( - agent, - integration, - { - user: { id: 'user-1' }, - modifiedBy: 'user', - broadcast: false, - }, - ); - expect(chatIntegrationService.connect).toHaveBeenCalledWith( - 'agent-1', - integration, - 'project-1', - ); - expect(chatIntegrationService.broadcastIntegrationChange).toHaveBeenCalledWith( - 'agent-1', - integration, - 'connect', - ); - expect( - agentIntegrationPersistenceService.saveCredentialIntegration.mock.invocationCallOrder[0], - ).toBeLessThan(chatIntegrationService.connect.mock.invocationCallOrder[0]); - expect(chatIntegrationService.connect.mock.invocationCallOrder[0]).toBeLessThan( - chatIntegrationService.broadcastIntegrationChange.mock.invocationCallOrder[0], - ); - }); - - it.each([ - { type: 'slack', credentialId: 'cred-slack', credentialType: 'slackApi' }, - { type: 'linear', credentialId: 'cred-linear', credentialType: 'linearOAuth2Api' }, - ])('persists $type without connecting or publishing an unpublished agent', async (testCase) => { - const credentialsService = mock(); - credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([ - { - id: testCase.credentialId, - name: 'Channel credential', - type: testCase.credentialType, - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-01T00:00:00.000Z', - scopes: [], - isManaged: false, - isGlobal: false, - isResolvable: true, - currentUserHasAccess: true, - homeProject: null, - sharedWithProjects: [], - }, - ]); - - const agentRepository = mock(); - const agent = { - id: 'agent-1', - projectId: 'project-1', - activeVersionId: null, - activeVersion: null, - integrations: [], - }; - const savedAgent = { - ...agent, - integrations: [{ type: testCase.type, credentialId: testCase.credentialId }], - }; - agentRepository.findByIdAndProjectId.mockResolvedValue(agent as never); - - const agentIntegrationPersistenceService = mock(); - agentIntegrationPersistenceService.saveCredentialIntegration.mockResolvedValue( - savedAgent as never, - ); - const chatIntegrationService = mock(); - chatIntegrationService.connect.mockResolvedValue(undefined); - chatIntegrationService.broadcastIntegrationChange.mockResolvedValue(undefined); - const { controller } = makeController({ - agentIntegrationPersistenceService, - credentialsService, - chatIntegrationService, - agentRepository, - }); - - await expect( - controller.connectIntegration( - { - params: { projectId: 'project-1' }, - user: { id: 'user-1' }, - body: { type: testCase.type, credentialId: testCase.credentialId }, - } as never, - undefined as never, - 'agent-1', - ), - ).resolves.toEqual({ status: 'configured' }); - - const integration = { type: testCase.type, credentialId: testCase.credentialId }; - expect(agentIntegrationPersistenceService.saveCredentialIntegration).toHaveBeenCalledWith( - agent, - integration, - { - user: { id: 'user-1' }, - modifiedBy: 'user', - broadcast: false, - }, - ); - expect(chatIntegrationService.connect).not.toHaveBeenCalled(); - expect(chatIntegrationService.broadcastIntegrationChange).not.toHaveBeenCalled(); - }); - - it('does not broadcast when connecting a published agent fails', async () => { - const credentialsService = mock(); - credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([ - { - id: 'cred-slack', - name: 'Slack Bot', - type: 'slackApi', - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-01T00:00:00.000Z', - scopes: [], - isManaged: false, - isGlobal: false, - isResolvable: true, - currentUserHasAccess: true, - homeProject: null, - sharedWithProjects: [], - }, - ]); - - const agentRepository = mock(); - const agent = { - id: 'agent-1', - projectId: 'project-1', - activeVersionId: 'v1', - activeVersion: {}, - integrations: [], - }; - const savedAgent = { - ...agent, - integrations: [{ type: 'slack', credentialId: 'cred-slack' }], - }; - agentRepository.findByIdAndProjectId.mockResolvedValue(agent as never); - - const agentIntegrationPersistenceService = mock(); - agentIntegrationPersistenceService.saveCredentialIntegration.mockResolvedValue( - savedAgent as never, - ); - const chatIntegrationService = mock(); - chatIntegrationService.connect.mockRejectedValue(new Error('Slack connect failed')); - const { controller } = makeController({ - agentIntegrationPersistenceService, - credentialsService, - chatIntegrationService, - agentRepository, - }); - - await expect( - controller.connectIntegration( - { - params: { projectId: 'project-1' }, - user: { id: 'user-1' }, - body: { type: 'slack', credentialId: 'cred-slack' }, - } as never, - undefined as never, - 'agent-1', - ), - ).rejects.toThrow('Slack connect failed'); - - expect(chatIntegrationService.broadcastIntegrationChange).not.toHaveBeenCalled(); - }); - - it('reports complete persisted integrations as configured for an unpublished agent', async () => { - const settings = { - accessMode: 'private' as const, - allowedUsers: ['123'], - }; - const agentRepository = mock(); - agentRepository.findByIdAndProjectId.mockResolvedValue({ - id: 'agent-1', - projectId: 'project-1', - activeVersionId: null, - integrations: [ - { - type: 'telegram', - credentialId: 'cred-telegram', - settings, - }, - ], - } as never); - - const { controller } = makeController({ agentRepository }); - - await expect( - controller.integrationStatus( - { params: { projectId: 'project-1' } } as never, - undefined as never, - 'agent-1', - ), - ).resolves.toEqual({ - status: 'configured', - integrations: [ - { - type: 'telegram', - credentialId: 'cred-telegram', - settings, - }, - ], - }); - }); - - it('reports complete persisted integrations as connected for a published agent', async () => { - const agentRepository = mock(); - agentRepository.findByIdAndProjectId.mockResolvedValue({ - id: 'agent-1', - projectId: 'project-1', - activeVersionId: 'v1', - integrations: [{ type: 'linear', credentialId: 'cred-linear' }], - } as never); - - const { controller } = makeController({ agentRepository }); - - await expect( - controller.integrationStatus( - { params: { projectId: 'project-1' } } as never, - undefined as never, - 'agent-1', - ), - ).resolves.toEqual({ - status: 'connected', - integrations: [{ type: 'linear', credentialId: 'cred-linear' }], - }); - }); - - it('reports a draft integration (empty credentialId) as disconnected', async () => { - const agentRepository = mock(); - agentRepository.findByIdAndProjectId.mockResolvedValue({ - id: 'agent-1', - projectId: 'project-1', - activeVersionId: 'v1', - integrations: [{ type: 'slack', credentialId: '' }], - } as never); - - const { controller } = makeController({ agentRepository }); - - await expect( - controller.integrationStatus( - { params: { projectId: 'project-1' } } as never, - undefined as never, - 'agent-1', - ), - ).resolves.toEqual({ status: 'disconnected', integrations: [] }); - }); - - it('disconnects the channel before removing the persisted integration', async () => { - const agentRepository = mock(); - const agent = { - id: 'agent-1', - projectId: 'project-1', - integrations: [{ type: 'slack', credentialId: 'cred-slack' }], - }; - agentRepository.findByIdAndProjectId.mockResolvedValue(agent as never); - - const chatIntegrationService = mock(); - const agentIntegrationPersistenceService = mock(); - const { controller } = makeController({ - agentRepository, - chatIntegrationService, - agentIntegrationPersistenceService, - }); - - await expect( - controller.disconnectIntegration( - { - params: { projectId: 'project-1' }, - user: { id: 'user-1' }, - body: { type: 'slack', credentialId: 'cred-slack' }, - } as never, - undefined as never, - 'agent-1', - { type: 'slack', credentialId: 'cred-slack' }, - ), - ).resolves.toEqual({ status: 'disconnected' }); - - expect(chatIntegrationService.disconnectChannel).toHaveBeenCalledWith('agent-1', { type: 'slack', - credentialId: 'cred-slack', - }); - expect(agentIntegrationPersistenceService.removeCredentialIntegration).toHaveBeenCalledWith( + credentialId: 'credential-1', + } satisfies AgentIntegrationConfig; + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + managementService.connect.mockResolvedValue({ integration, savedAgent: agent }); + + const result = await controller.connectIntegration( + { + params: { projectId: agent.projectId }, + user, + body: integration, + } as never, + undefined as never, + agent.id, + ); + + expect(managementService.validateConfig).toHaveBeenCalledWith(integration); + expect(managementService.connect).toHaveBeenCalledWith({ agent, - 'slack', - 'cred-slack', - { user: { id: 'user-1' }, modifiedBy: 'user', broadcast: false }, - ); - expect(chatIntegrationService.disconnectChannel.mock.invocationCallOrder[0]).toBeLessThan( - agentIntegrationPersistenceService.removeCredentialIntegration.mock.invocationCallOrder[0], - ); + user, + integration, + }); + expect(result).toEqual({ status: 'connected' }); }); - it('disconnects a draft integration entry with an empty credentialId', async () => { - const agentRepository = mock(); - const agent = { - id: 'agent-1', - projectId: 'project-1', - integrations: [{ type: 'slack', credentialId: '' }], - }; - agentRepository.findByIdAndProjectId.mockResolvedValue(agent as never); - - const chatIntegrationService = mock(); - const agentIntegrationPersistenceService = mock(); - const { controller } = makeController({ - agentRepository, - chatIntegrationService, - agentIntegrationPersistenceService, - }); - - await expect( - controller.disconnectIntegration( - { - params: { projectId: 'project-1' }, - user: { id: 'user-1' }, - body: { type: 'slack', credentialId: '' }, - } as never, - undefined as never, - 'agent-1', - { type: 'slack', credentialId: '' }, - ), - ).resolves.toEqual({ status: 'disconnected' }); - - expect(chatIntegrationService.disconnectChannel).toHaveBeenCalledWith('agent-1', { + it('reports configured when the saved agent is unpublished', async () => { + const { controller, managementService, agentRepository } = makeController(); + const integration = { type: 'slack', - credentialId: '', + credentialId: 'credential-1', + } satisfies AgentIntegrationConfig; + const draftAgent = { ...agent, activeVersionId: null } as Agent; + agentRepository.findByIdAndProjectId.mockResolvedValue(draftAgent); + managementService.connect.mockResolvedValue({ + integration, + savedAgent: draftAgent, }); - expect(agentIntegrationPersistenceService.removeCredentialIntegration).toHaveBeenCalledWith( + + const result = await controller.connectIntegration( + { + params: { projectId: agent.projectId }, + user, + body: integration, + } as never, + undefined as never, + agent.id, + ); + + expect(result).toEqual({ status: 'configured' }); + }); + + it('delegates disconnect without platform-specific cleanup', async () => { + const { controller, managementService, agentRepository } = makeController(); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + managementService.disconnect.mockResolvedValue({ savedAgent: agent }); + + const result = await controller.disconnectIntegration( + { + params: { projectId: agent.projectId }, + user, + } as never, + undefined as never, + agent.id, + { type: 'slack', credentialId: 'credential-1' }, + ); + + expect(managementService.disconnect).toHaveBeenCalledWith({ agent, - 'slack', - '', - { user: { id: 'user-1' }, modifiedBy: 'user', broadcast: false }, - ); - }); - - it('starts Slack app setup with the temporary app configuration token', async () => { - const slackAppSetupService = mock(); - slackAppSetupService.createApp.mockResolvedValue({ - appId: 'A123', - installUrl: 'https://slack.com/oauth/v2/authorize?state=setup-state', - }); - const { controller } = makeController({ slackAppSetupService }); - - await expect( - controller.createSlackApp( - { - params: { projectId: 'project-1' }, - user: { id: 'user-1' }, - } as never, - undefined as never, - 'agent-1', - { appConfigurationToken: 'xoxe-config' }, - ), - ).resolves.toEqual({ - appId: 'A123', - installUrl: 'https://slack.com/oauth/v2/authorize?state=setup-state', - }); - - expect(slackAppSetupService.createApp).toHaveBeenCalledWith({ - projectId: 'project-1', - agentId: 'agent-1', - appConfigurationToken: 'xoxe-config', - user: { id: 'user-1' }, - }); - }); - - it('returns the manual Slack app manifest', async () => { - const slackAppSetupService = mock(); - slackAppSetupService.getManualManifest.mockResolvedValue({ - manifest: { - display_information: { name: 'Support Agent' }, - features: { - app_home: { - home_tab_enabled: true, - messages_tab_enabled: true, - messages_tab_read_only_enabled: false, - }, - bot_user: { - display_name: 'Support Agent', - always_online: true, - }, - }, - oauth_config: { - scopes: { bot: ['chat:write'] }, - }, - settings: { - event_subscriptions: { - request_url: - 'https://hooks.example/rest/projects/project-1/agents/v2/agent-1/webhooks/slack', - bot_events: ['app_mention'], - }, - interactivity: { - is_enabled: true, - request_url: - 'https://hooks.example/rest/projects/project-1/agents/v2/agent-1/webhooks/slack', - }, - org_deploy_enabled: false, - socket_mode_enabled: false, - token_rotation_enabled: false, - }, - }, - }); - const { controller } = makeController({ slackAppSetupService }); - - await expect( - controller.getSlackAppManifest( - { params: { projectId: 'project-1' } } as never, - undefined as never, - 'agent-1', - ), - ).resolves.toEqual({ - manifest: expect.objectContaining({ - display_information: { name: 'Support Agent' }, - oauth_config: { - scopes: { bot: ['chat:write'] }, - }, - }), - }); - - expect(slackAppSetupService.getManualManifest).toHaveBeenCalledWith({ - projectId: 'project-1', - agentId: 'agent-1', - }); - }); - - it('completes Slack app setup from the OAuth callback and renders the success template', async () => { - const slackAppSetupService = mock(); - const { controller } = makeController({ slackAppSetupService }); - const res = { render: vi.fn() }; - - await controller.handleSlackAppOAuthCallback( - { - params: { projectId: 'project-1' }, - query: { code: 'slack-code', state: 'setup-state' }, - } as never, - res as never, - 'agent-1', - ); - - expect(slackAppSetupService.completeInstall).toHaveBeenCalledWith({ - projectId: 'project-1', - agentId: 'agent-1', - code: 'slack-code', - state: 'setup-state', - }); - expect(res.render).toHaveBeenCalledWith('oauth-callback'); - }); - - it('renders the Slack OAuth error callback when Slack denies setup', async () => { - const slackAppSetupService = mock(); - const { controller } = makeController({ slackAppSetupService }); - const res = { render: vi.fn() }; - - await controller.handleSlackAppOAuthCallback( - { - params: { projectId: 'project-1' }, - query: { error: 'access_denied', error_description: 'User denied install' }, - } as never, - res as never, - 'agent-1', - ); - - expect(slackAppSetupService.completeInstall).not.toHaveBeenCalled(); - expect(res.render).toHaveBeenCalledWith('oauth-error-callback', { - error: { - message: 'access_denied', - reason: 'User denied install', - }, + user, + type: 'slack', + credentialId: 'credential-1', }); + expect(result).toEqual({ status: 'disconnected' }); }); it('returns a platform webhook rejection without looking up a handler', async () => { diff --git a/packages/cli/src/modules/agents/__tests__/agent-slack-integrations.controller.test.ts b/packages/cli/src/modules/agents/__tests__/agent-slack-integrations.controller.test.ts new file mode 100644 index 00000000000..c5114dedf96 --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/agent-slack-integrations.controller.test.ts @@ -0,0 +1,55 @@ +/* eslint-disable @typescript-eslint/unbound-method -- mock-based tests intentionally reference unbound methods */ +import { mock } from 'vitest-mock-extended'; + +import { AgentSlackIntegrationsController } from '../agent-slack-integrations.controller'; +import { + expectProjectScopedAgentRoutes, + getRoutesByHandlerName, +} from './test-utils/controller-route-metadata'; +import type { SlackManualSetupService } from '../integrations/platforms/slack/slack-manual-setup.service'; + +const UNAUTHENTICATED_HANDLERS = new Set(['handleSlackAppOAuthCallback']); + +describe('AgentSlackIntegrationsController', () => { + expectProjectScopedAgentRoutes(AgentSlackIntegrationsController, UNAUTHENTICATED_HANDLERS); + + const routes = getRoutesByHandlerName(AgentSlackIntegrationsController); + + it.each([ + ['createSlackApp', 'agent:update'], + ['getSlackAppManifest', 'agent:read'], + ])('%s uses %s', (handlerName, scope) => { + expect(routes.get(handlerName)?.accessScope?.scope).toBe(scope); + }); + + it('keeps the manual Slack route contracts', () => { + expect([...routes.values()].map((route) => route.path).sort()).toEqual([ + '/:agentId/integrations/slack/app', + '/:agentId/integrations/slack/manifest', + '/:agentId/integrations/slack/oauth/callback', + ]); + }); + + it('binds the callback state to the route project and agent', async () => { + const manualSetup = mock(); + const controller = new AgentSlackIntegrationsController(manualSetup); + const response = mock<{ render: (template: string, data?: unknown) => void }>(); + + await controller.handleSlackAppOAuthCallback( + { + params: { projectId: 'project-1', agentId: 'agent-1' }, + query: { code: 'code-1', state: 'state-1' }, + } as never, + response as never, + 'agent-1', + ); + + expect(manualSetup.completeInstall).toHaveBeenCalledWith({ + projectId: 'project-1', + agentId: 'agent-1', + code: 'code-1', + state: 'state-1', + }); + expect(response.render).toHaveBeenCalledWith('oauth-callback'); + }); +}); diff --git a/packages/cli/src/modules/agents/agent-integration-management.service.ts b/packages/cli/src/modules/agents/agent-integration-management.service.ts new file mode 100644 index 00000000000..5e4d6084f8a --- /dev/null +++ b/packages/cli/src/modules/agents/agent-integration-management.service.ts @@ -0,0 +1,105 @@ +import { AgentIntegrationSchema, type AgentIntegrationConfig } from '@n8n/api-types'; +import type { User } from '@n8n/db'; +import { Service } from '@n8n/di'; + +import { CredentialsService } from '@/credentials/credentials.service'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; + +import { AgentIntegrationPersistenceService } from './agent-integration-persistence.service'; +import type { Agent } from './entities/agent.entity'; +import { ChatIntegrationRegistry } from './integrations/agent-chat-integration'; +import { ChatIntegrationService } from './integrations/chat-integration.service'; + +@Service() +export class AgentIntegrationManagementService { + constructor( + private readonly persistenceService: AgentIntegrationPersistenceService, + private readonly credentialsService: CredentialsService, + private readonly chatService: ChatIntegrationService, + private readonly registry: ChatIntegrationRegistry, + ) {} + + async validateConfig(input: unknown): Promise { + const parsed = await AgentIntegrationSchema.safeParseAsync(input); + if (!parsed.success) throw new BadRequestError(parsed.error.message); + + const integration = parsed.data; + this.registry.require(integration.type).validateConfig?.(integration); + return integration; + } + + async connect(options: { + agent: Agent; + user: User; + integration: unknown; + modifiedBy?: 'user' | 'mcp'; + }): Promise<{ integration: AgentIntegrationConfig; savedAgent: Agent }> { + const integration = await this.validateConfig(options.integration); + const implementation = this.registry.require(integration.type); + const usableCredentials = await this.credentialsService.getCredentialsAUserCanUseInAWorkflow( + options.user, + { projectId: options.agent.projectId }, + ); + const credential = usableCredentials.find((item) => item.id === integration.credentialId); + if (!credential) { + throw new NotFoundError(`Credential "${integration.credentialId}" not found`); + } + if (!implementation.credentialTypes.includes(credential.type)) { + throw new BadRequestError( + `${implementation.displayLabel} integrations do not support ${credential.type} credentials`, + ); + } + + await this.chatService.validateBeforeConnect( + options.agent.id, + integration, + options.agent.projectId, + ); + + const savedAgent = await this.persistenceService.saveCredentialIntegration( + options.agent, + integration, + { user: options.user, modifiedBy: options.modifiedBy ?? 'user', broadcast: false }, + ); + if (savedAgent.activeVersionId === null) return { integration, savedAgent }; + + await this.chatService.connect(options.agent.id, integration, options.agent.projectId); + await this.chatService.broadcastIntegrationChange(options.agent.id, integration, 'connect'); + return { integration, savedAgent }; + } + + async disconnect(options: { + agent: Agent; + user: User; + type: string; + credentialId: string; + modifiedBy?: 'user' | 'mcp'; + }): Promise<{ savedAgent: Agent }> { + const persisted = (options.agent.integrations ?? []).find( + (item) => item.type === options.type && item.credentialId === options.credentialId, + ); + const parsed = AgentIntegrationSchema.safeParse({ + type: options.type, + credentialId: options.credentialId, + }); + const integration = persisted ?? (parsed.success ? parsed.data : undefined); + + if (integration) { + await this.chatService.disconnectChannel(options.agent.id, integration); + } else { + await this.chatService.disconnect(options.agent.id, { + type: options.type, + credentialId: options.credentialId, + }); + } + + const savedAgent = await this.persistenceService.removeCredentialIntegration( + options.agent, + options.type, + options.credentialId, + { user: options.user, modifiedBy: options.modifiedBy ?? 'user', broadcast: false }, + ); + return { savedAgent }; + } +} diff --git a/packages/cli/src/modules/agents/agent-integrations.controller.ts b/packages/cli/src/modules/agents/agent-integrations.controller.ts index 31aee39078a..fc55ff74578 100644 --- a/packages/cli/src/modules/agents/agent-integrations.controller.ts +++ b/packages/cli/src/modules/agents/agent-integrations.controller.ts @@ -1,50 +1,29 @@ import { AgentDisconnectIntegrationDto, - AgentIntegrationSchema, isDraftIntegration, type AgentIntegrationStatusResponse, - CreateSlackAgentAppDto, - type CreateSlackAgentAppResponse, - type SlackAgentAppManifestResponse, } from '@n8n/api-types'; import type { AuthenticatedRequest } from '@n8n/db'; import { Body, Get, Param, Post, ProjectScope, RestController } from '@n8n/decorators'; import type { Request, Response } from 'express'; -import { CredentialsService } from '@/credentials/credentials.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { AgentIntegrationPersistenceService } from './agent-integration-persistence.service'; +import { AgentIntegrationManagementService } from './agent-integration-management.service'; import { ChatIntegrationRegistry } from './integrations/agent-chat-integration'; import { ChatIntegrationService } from './integrations/chat-integration.service'; import { channelIntegrationRecorder } from './integrations/recording/channel-integration-recorder'; -import { SlackAppSetupService } from './integrations/slack-app-setup.service'; import { AgentRepository } from './repositories/agent.repository'; @RestController('/projects/:projectId/agents/v2') export class AgentIntegrationsController { constructor( - private readonly agentIntegrationPersistenceService: AgentIntegrationPersistenceService, - private readonly credentialsService: CredentialsService, + private readonly integrationManagementService: AgentIntegrationManagementService, private readonly chatIntegrationService: ChatIntegrationService, private readonly agentRepository: AgentRepository, private readonly chatIntegrationRegistry: ChatIntegrationRegistry, - private readonly slackAppSetupService: SlackAppSetupService, ) {} - private async validateIntegration(dto: unknown) { - const integrationParseResult = await AgentIntegrationSchema.safeParseAsync(dto); - if (!integrationParseResult.success) { - throw new BadRequestError(integrationParseResult.error.message); - } - const integration = integrationParseResult.data; - if (integration.type === 'telegram' && !integration.settings) { - throw new BadRequestError('Telegram integration settings are required'); - } - return integration; - } - @Post('/:agentId/integrations/connect') @ProjectScope('agent:update') async connectIntegration( @@ -52,112 +31,19 @@ export class AgentIntegrationsController { _res: Response, @Param('agentId') agentId: string, ) { - const integration = await this.validateIntegration(req.body); - const { credentialId } = integration; + await this.integrationManagementService.validateConfig(req.body); const agent = await this.agentRepository.findByIdAndProjectId(agentId, req.params.projectId); if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`); - - const usableCredentials = await this.credentialsService.getCredentialsAUserCanUseInAWorkflow( - req.user, - { projectId: agent.projectId }, - ); - const credential = usableCredentials.find((c) => c.id === credentialId); - if (!credential) throw new NotFoundError(`Credential "${credentialId}" not found`); - - const integrationImpl = this.chatIntegrationRegistry.require(integration.type); - if (!integrationImpl.credentialTypes.includes(credential.type)) { - throw new BadRequestError( - `${integrationImpl.displayLabel} integrations do not support ${credential.type} credentials`, - ); - } - - const savedAgent = await this.agentIntegrationPersistenceService.saveCredentialIntegration( + const { savedAgent } = await this.integrationManagementService.connect({ agent, - integration, - { user: req.user, modifiedBy: 'user', broadcast: false }, - ); + user: req.user, + integration: req.body, + }); if (savedAgent.activeVersionId === null) return { status: 'configured' }; - await this.chatIntegrationService.connect(agentId, integration, agent.projectId); - await this.chatIntegrationService.broadcastIntegrationChange(agentId, integration, 'connect'); - return { status: 'connected' }; } - @Post('/:agentId/integrations/slack/app') - @ProjectScope('agent:update') - async createSlackApp( - req: AuthenticatedRequest<{ projectId: string }>, - _res: Response, - @Param('agentId') agentId: string, - @Body payload: CreateSlackAgentAppDto, - ): Promise { - return await this.slackAppSetupService.createApp({ - projectId: req.params.projectId, - agentId, - appConfigurationToken: payload.appConfigurationToken, - user: req.user, - }); - } - - @Get('/:agentId/integrations/slack/manifest') - @ProjectScope('agent:read') - async getSlackAppManifest( - req: AuthenticatedRequest<{ projectId: string }>, - _res: Response, - @Param('agentId') agentId: string, - ): Promise { - return await this.slackAppSetupService.getManualManifest({ - projectId: req.params.projectId, - agentId, - }); - } - - // Slack OAuth callback: do not add @ProjectScope. Authentication happens via - // the one-time setup state generated by the authenticated createSlackApp route. - @Get('/:agentId/integrations/slack/oauth/callback', { skipAuth: true, usesTemplates: true }) - async handleSlackAppOAuthCallback( - req: Request< - { projectId: string; agentId: string }, - unknown, - unknown, - { code?: string; state?: string; error?: string; error_description?: string } - >, - res: Response, - @Param('agentId') agentId: string, - ) { - const { code, state, error, error_description: errorDescription } = req.query; - if (error) { - return res.render('oauth-error-callback', { - error: { - message: error, - ...(errorDescription ? { reason: errorDescription } : {}), - }, - }); - } - if (!code || !state) { - return res.render('oauth-error-callback', { - error: { message: 'Insufficient parameters for Slack app setup callback.' }, - }); - } - - try { - await this.slackAppSetupService.completeInstall({ - projectId: req.params.projectId, - agentId, - code, - state, - }); - return res.render('oauth-callback'); - } catch (callbackError) { - const message = - callbackError instanceof Error ? callbackError.message : 'Slack app setup failed'; - return res.render('oauth-error-callback', { - error: { message }, - }); - } - } - @Post('/:agentId/integrations/disconnect') @ProjectScope('agent:update') async disconnectIntegration( @@ -169,24 +55,12 @@ export class AgentIntegrationsController { const { type, credentialId } = payload; const agent = await this.agentRepository.findByIdAndProjectId(agentId, req.params.projectId); if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`); - const persistedIntegration = agent.integrations?.find( - (integration) => integration.type === type && integration.credentialId === credentialId, - ); - const parsedIntegration = AgentIntegrationSchema.safeParse({ type, credentialId }); - const integration = - persistedIntegration ?? (parsedIntegration.success ? parsedIntegration.data : undefined); - if (integration) { - await this.chatIntegrationService.disconnectChannel(agentId, integration); - } else { - await this.chatIntegrationService.disconnect(agentId, { type, credentialId }); - } - - await this.agentIntegrationPersistenceService.removeCredentialIntegration( + await this.integrationManagementService.disconnect({ agent, + user: req.user, type, credentialId, - { user: req.user, modifiedBy: 'user', broadcast: false }, - ); + }); return { status: 'disconnected' }; } diff --git a/packages/cli/src/modules/agents/agent-slack-integrations.controller.ts b/packages/cli/src/modules/agents/agent-slack-integrations.controller.ts new file mode 100644 index 00000000000..7efe1b722b2 --- /dev/null +++ b/packages/cli/src/modules/agents/agent-slack-integrations.controller.ts @@ -0,0 +1,89 @@ +import { + CreateSlackAgentAppDto, + type CreateSlackAgentAppResponse, + type SlackAgentAppManifestResponse, +} from '@n8n/api-types'; +import type { AuthenticatedRequest } from '@n8n/db'; +import { Body, Get, Param, Post, ProjectScope, RestController } from '@n8n/decorators'; +import type { Request, Response } from 'express'; + +import { SlackManualSetupService } from './integrations/platforms/slack/slack-manual-setup.service'; + +@RestController('/projects/:projectId/agents/v2') +export class AgentSlackIntegrationsController { + constructor(private readonly manualSetup: SlackManualSetupService) {} + + @Post('/:agentId/integrations/slack/app') + @ProjectScope('agent:update') + async createSlackApp( + req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('agentId') agentId: string, + @Body payload: CreateSlackAgentAppDto, + ): Promise { + return await this.manualSetup.createApp({ + projectId: req.params.projectId, + agentId, + appConfigurationToken: payload.appConfigurationToken, + user: req.user, + }); + } + + @Get('/:agentId/integrations/slack/manifest') + @ProjectScope('agent:read') + async getSlackAppManifest( + req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('agentId') agentId: string, + ): Promise { + return await this.manualSetup.getManifest({ + projectId: req.params.projectId, + agentId, + }); + } + + // Slack OAuth callback: authentication uses the encrypted one-time state + // generated by the authenticated createSlackApp route. + @Get('/:agentId/integrations/slack/oauth/callback', { skipAuth: true, usesTemplates: true }) + async handleSlackAppOAuthCallback( + req: Request< + { projectId: string; agentId: string }, + unknown, + unknown, + { code?: string; state?: string; error?: string; error_description?: string } + >, + res: Response, + @Param('agentId') agentId: string, + ) { + const { code, state, error, error_description: errorDescription } = req.query; + if (error) { + return res.render('oauth-error-callback', { + error: { + message: error, + ...(errorDescription ? { reason: errorDescription } : {}), + }, + }); + } + if (!code || !state) { + return res.render('oauth-error-callback', { + error: { message: 'Insufficient parameters for Slack app setup callback.' }, + }); + } + + try { + await this.manualSetup.completeInstall({ + projectId: req.params.projectId, + agentId, + code, + state, + }); + return res.render('oauth-callback'); + } catch (callbackError) { + const message = + callbackError instanceof Error ? callbackError.message : 'Slack app setup failed'; + return res.render('oauth-error-callback', { + error: { message }, + }); + } + } +} diff --git a/packages/cli/src/modules/agents/agents.module.ts b/packages/cli/src/modules/agents/agents.module.ts index b94f54364f1..6801b69ca85 100644 --- a/packages/cli/src/modules/agents/agents.module.ts +++ b/packages/cli/src/modules/agents/agents.module.ts @@ -19,6 +19,7 @@ export class AgentsModule implements ModuleInterface { await import('./agent-publish.controller.js'); await import('./agent-chat.controller.js'); await import('./agent-integrations.controller.js'); + await import('./agent-slack-integrations.controller.js'); await import('./agent-vector-stores.controller.js'); await import('./agent-tasks.controller.js'); await import('./agent-sandbox.controller.js'); @@ -72,7 +73,9 @@ export class AgentsModule implements ModuleInterface { // Populate the integration registry with supported chat platforms. // Adding a new platform is adding one subclass + one register() call. const { ChatIntegrationRegistry } = await import('./integrations/agent-chat-integration.js'); - const { SlackIntegration } = await import('./integrations/platforms/slack-integration.js'); + const { SlackIntegration } = await import( + './integrations/platforms/slack/slack-integration.js' + ); const { TelegramIntegration } = await import( './integrations/platforms/telegram-integration.js' ); diff --git a/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts index ed30dc7505b..90191230ca5 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts @@ -14,7 +14,7 @@ import { } from '../agent-chat-integration'; import type { ComponentMapper } from '../component-mapper'; import type { IntegrationMessageContextService } from '../integration-message-context.service'; -import { SlackIntegration } from '../platforms/slack-integration'; +import { SlackIntegration } from '../platforms/slack/slack-integration'; import type { AgentIntegrationConfig } from '@n8n/api-types'; import type { RichCardComponentType } from '@n8n/api-types'; diff --git a/packages/cli/src/modules/agents/integrations/__tests__/component-mapper.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/component-mapper.test.ts index 62d315b3dd5..36d40abc209 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/component-mapper.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/component-mapper.test.ts @@ -70,7 +70,7 @@ vi.mock('../esm-loader', () => { import { ComponentMapper } from '../component-mapper'; import { ChatIntegrationRegistry } from '../agent-chat-integration'; -import { SlackIntegration } from '../platforms/slack-integration'; +import { SlackIntegration } from '../platforms/slack/slack-integration'; import { Container } from '@n8n/di'; describe('ComponentMapper', () => { diff --git a/packages/cli/src/modules/agents/integrations/__tests__/helpers/slack/replay-test-context.ts b/packages/cli/src/modules/agents/integrations/__tests__/helpers/slack/replay-test-context.ts index 8b25740fca6..0a4618e3c55 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/helpers/slack/replay-test-context.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/helpers/slack/replay-test-context.ts @@ -9,7 +9,7 @@ import type { getIntegrationToolConnectionDescriptors, IntegrationMessageContext, } from '../../../integration-tools'; -import { SlackIntegration } from '../../../platforms/slack-integration'; +import { SlackIntegration } from '../../../platforms/slack/slack-integration'; import { createReplayContextSetup, type MemoryMessageContextStore, diff --git a/packages/cli/src/modules/agents/integrations/__tests__/integration-action-executor.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/integration-action-executor.test.ts index 0acee68178a..ad0194304e7 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/integration-action-executor.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/integration-action-executor.test.ts @@ -56,7 +56,7 @@ import { import { ChatIntegrationActionExecutor } from '../integration-action-executor'; import { getIntegrationToolConnectionDescriptors } from '../integration-tools'; import { LinearIntegration } from '../platforms/linear-integration'; -import { SlackIntegration } from '../platforms/slack-integration'; +import { SlackIntegration } from '../platforms/slack/slack-integration'; import type { ChatIntegrationService, ChatInstance } from '../chat-integration.service'; import type { AgentIntegrationConfig } from '@n8n/api-types'; import type { RichCardComponentType } from '@n8n/api-types'; diff --git a/packages/cli/src/modules/agents/integrations/__tests__/integration-context-query-executor.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/integration-context-query-executor.test.ts index ff10bd9598f..fb94a00ae10 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/integration-context-query-executor.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/integration-context-query-executor.test.ts @@ -7,7 +7,7 @@ import type { ChatIntegrationService, ChatInstance } from '../chat-integration.s import { ChatIntegrationContextQueryExecutor } from '../integration-context-query-executor'; import { getIntegrationToolConnectionDescriptors } from '../integration-tools'; import { LinearIntegration } from '../platforms/linear-integration'; -import { SlackIntegration } from '../platforms/slack-integration'; +import { SlackIntegration } from '../platforms/slack/slack-integration'; import type { AgentIntegrationConfig } from '@n8n/api-types'; const slack: AgentIntegrationConfig = { diff --git a/packages/cli/src/modules/agents/integrations/__tests__/slack-app-setup.service.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/slack-app-setup.service.test.ts deleted file mode 100644 index 2a21f425fea..00000000000 --- a/packages/cli/src/modules/agents/integrations/__tests__/slack-app-setup.service.test.ts +++ /dev/null @@ -1,472 +0,0 @@ -import type { Mock, Mocked } from 'vitest'; -import type { HttpRequestClient, OutboundHttp } from '@n8n/backend-network'; -import type { User, UserRepository } from '@n8n/db'; -import { mock } from 'vitest-mock-extended'; -import type { Cipher } from 'n8n-core'; - -import type { CredentialsService } from '@/credentials/credentials.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import type { CacheService } from '@/services/cache/cache.service'; -import type { UrlService } from '@/services/url.service'; - -import type { AgentIntegrationPersistenceService } from '../../agent-integration-persistence.service'; -import type { AgentRepository } from '../../repositories/agent.repository'; -import type { ChatIntegrationService } from '../chat-integration.service'; -import { SlackAppSetupService } from '../slack-app-setup.service'; - -const agent = { - id: 'agent-1', - projectId: 'project-1', - name: 'Support Agent', - activeVersionId: 'v1', - activeVersion: {}, - integrations: [], -}; - -const unpublishedAgent = { - ...agent, - activeVersionId: null, - activeVersion: null, -}; - -const user = { id: 'user-1' } as User; - -function slackResponse(body: Record) { - return { statusCode: 200, body }; -} - -function slackAppCreatedResponse() { - return slackResponse({ - ok: true, - app_id: 'A123', - credentials: { - client_id: 'C123', - client_secret: 'client-secret', - signing_secret: 'signing-secret', - }, - oauth_authorize_url: 'https://slack.com/oauth/v2/authorize?client_id=C123&scope=chat%3Awrite', - }); -} - -function slackOAuthResponse() { - return slackResponse({ - ok: true, - access_token: 'xoxb-installed-token', - token_type: 'bot', - app_id: 'A123', - }); -} - -function fetchParams(requestMock: Mock, callIndex: number) { - const request = requestMock.mock.calls[callIndex]?.[0] as { - headers?: Record; - body: Record; - }; - // The body is passed as a plain object; OutboundHttp/axios only serializes it as - // form-urlencoded when this content-type is set, so assert the contract here. - expect(request.headers?.['Content-Type']).toBe('application/x-www-form-urlencoded'); - return new URLSearchParams(request.body); -} - -describe('SlackAppSetupService', () => { - let requestMock: Mock; - let outboundHttp: Mocked; - let cacheStore: Map; - let cacheService: Mocked; - let cipher: Mocked; - let credentialsService: Mocked; - let userRepository: Mocked; - let agentRepository: Mocked; - let agentIntegrationPersistenceService: Mocked< - Pick - >; - let chatIntegrationService: Mocked; - let service: SlackAppSetupService; - - beforeEach(() => { - const httpClient = mock(); - requestMock = httpClient.request as Mock; - outboundHttp = mock(); - outboundHttp.requests.mockReturnValue(httpClient); - - cacheStore = new Map(); - cacheService = mock(); - cacheService.set.mockImplementation(async (key: string, value: unknown) => { - cacheStore.set(key, value); - }); - cacheService.get.mockImplementation(async (key: string) => cacheStore.get(key)); - cacheService.delete.mockImplementation(async (key: string) => { - cacheStore.delete(key); - }); - - cipher = mock(); - cipher.encryptV2.mockImplementation(async (data: string | object) => { - const plaintext = typeof data === 'string' ? data : JSON.stringify(data); - return `encrypted:${Buffer.from(plaintext).toString('base64')}`; - }); - cipher.decryptV2.mockImplementation(async (data: string) => - Buffer.from(data.replace(/^encrypted:/, ''), 'base64').toString(), - ); - - credentialsService = mock(); - userRepository = mock(); - agentRepository = mock(); - agentRepository.findByIdAndProjectId.mockResolvedValue(agent as never); - agentIntegrationPersistenceService = - mock>(); - chatIntegrationService = mock(); - const urlService = mock(); - urlService.getWebhookBaseUrl.mockReturnValue('https://hooks.example/'); - - service = new SlackAppSetupService( - cacheService, - cipher, - credentialsService, - userRepository, - agentRepository, - agentIntegrationPersistenceService as unknown as AgentIntegrationPersistenceService, - chatIntegrationService, - urlService, - outboundHttp, - ); - }); - - async function beginInstall() { - requestMock.mockResolvedValueOnce(slackAppCreatedResponse()); - const { installUrl } = await service.createApp({ - projectId: 'project-1', - agentId: 'agent-1', - appConfigurationToken: 'xoxe-config', - user, - }); - return new URL(installUrl).searchParams.get('state') ?? ''; - } - - it('creates a Slack app from an agent manifest and returns an install URL with state', async () => { - requestMock.mockResolvedValueOnce(slackAppCreatedResponse()); - - const result = await service.createApp({ - projectId: 'project-1', - agentId: 'agent-1', - appConfigurationToken: 'xoxe-config', - user, - }); - - expect(requestMock).toHaveBeenCalledWith( - expect.objectContaining({ - url: 'https://slack.com/api/apps.manifest.create', - method: 'POST', - headers: expect.objectContaining({ - 'Content-Type': 'application/x-www-form-urlencoded', - }), - }), - ); - - const createParams = fetchParams(requestMock, 0); - expect(createParams.get('token')).toBe('xoxe-config'); - const manifest = JSON.parse(createParams.get('manifest') ?? '') as { - features: { - app_home: { - home_tab_enabled: boolean; - messages_tab_enabled: boolean; - messages_tab_read_only_enabled: boolean; - }; - }; - oauth_config: { redirect_urls: string[]; scopes: { bot: string[] } }; - settings: { - event_subscriptions: { request_url: string; bot_events: string[] }; - interactivity: { is_enabled: boolean; request_url: string }; - socket_mode_enabled: boolean; - token_rotation_enabled: boolean; - }; - }; - const webhookUrl = - 'https://hooks.example/rest/projects/project-1/agents/v2/agent-1/webhooks/slack'; - const callbackUrl = - 'https://hooks.example/rest/projects/project-1/agents/v2/agent-1/integrations/slack/oauth/callback'; - expect(manifest.oauth_config.redirect_urls).toEqual([callbackUrl]); - expect(manifest.features.app_home).toEqual({ - home_tab_enabled: false, - messages_tab_enabled: true, - messages_tab_read_only_enabled: false, - }); - expect(manifest.oauth_config.scopes.bot).toEqual( - expect.arrayContaining(['channels:history', 'groups:history', 'im:history', 'mpim:history']), - ); - expect(manifest.settings.event_subscriptions.request_url).toBe(webhookUrl); - expect(manifest.settings.event_subscriptions.bot_events).toEqual([ - 'app_mention', - 'assistant_thread_started', - 'assistant_thread_context_changed', - 'message.channels', - 'message.groups', - 'message.im', - 'message.mpim', - ]); - expect(manifest.settings.interactivity).toEqual({ - is_enabled: true, - request_url: webhookUrl, - }); - expect(manifest.settings.socket_mode_enabled).toBe(false); - expect(manifest.settings.token_rotation_enabled).toBe(false); - - expect(result.appId).toBe('A123'); - const installUrl = new URL(result.installUrl); - const state = installUrl.searchParams.get('state'); - expect(state).toBeTruthy(); - expect(installUrl.searchParams.get('redirect_uri')).toBe(callbackUrl); - expect(cacheService.set).toHaveBeenCalledWith( - `agents:slack-app-setup:${state}`, - expect.stringMatching(/^encrypted:/), - 60 * 60 * 1000, - ); - const cachedSession = cacheStore.get(`agents:slack-app-setup:${state}`); - expect(cachedSession).not.toEqual(expect.objectContaining({ clientSecret: 'client-secret' })); - expect(String(cachedSession)).not.toContain('client-secret'); - expect(String(cachedSession)).not.toContain('signing-secret'); - - const plaintextSession = JSON.parse(cipher.encryptV2.mock.calls[0]?.[0] as string) as { - agentId: string; - projectId: string; - userId: string; - appId: string; - clientId: string; - clientSecret: string; - signingSecret: string; - redirectUrl: string; - }; - expect(plaintextSession).toEqual({ - agentId: 'agent-1', - projectId: 'project-1', - userId: 'user-1', - appId: 'A123', - clientId: 'C123', - clientSecret: 'client-secret', - signingSecret: 'signing-secret', - redirectUrl: callbackUrl, - }); - expect(credentialsService.createUnmanagedCredential).not.toHaveBeenCalled(); - expect(chatIntegrationService.connect).not.toHaveBeenCalled(); - }); - - it('returns the manual Slack app manifest without OAuth redirect URLs', async () => { - const result = await service.getManualManifest({ - projectId: 'project-1', - agentId: 'agent-1', - }); - - expect(result.manifest.display_information.name).toBe('Support Agent'); - expect(result.manifest.features.app_home).toEqual({ - home_tab_enabled: false, - messages_tab_enabled: true, - messages_tab_read_only_enabled: false, - }); - expect(result.manifest.oauth_config).not.toHaveProperty('redirect_urls'); - expect(result.manifest.oauth_config.scopes.bot).toContain('chat:write'); - expect(result.manifest.settings.event_subscriptions.request_url).toBe( - 'https://hooks.example/rest/projects/project-1/agents/v2/agent-1/webhooks/slack', - ); - expect(result.manifest.settings.interactivity).toEqual({ - is_enabled: true, - request_url: 'https://hooks.example/rest/projects/project-1/agents/v2/agent-1/webhooks/slack', - }); - expect(requestMock).not.toHaveBeenCalled(); - }); - - it('saves, connects, and broadcasts a Slack integration for a published agent', async () => { - requestMock.mockResolvedValueOnce(slackAppCreatedResponse()).mockResolvedValueOnce( - slackResponse({ - ok: true, - access_token: 'xoxb-installed-token', - token_type: 'bot', - app_id: 'A123', - }), - ); - userRepository.findOne.mockResolvedValue(user); - credentialsService.createUnmanagedCredential.mockResolvedValue({ id: 'cred-slack' } as never); - agentIntegrationPersistenceService.saveCredentialIntegration.mockResolvedValue(agent as never); - - const { installUrl } = await service.createApp({ - projectId: 'project-1', - agentId: 'agent-1', - appConfigurationToken: 'xoxe-config', - user, - }); - const state = new URL(installUrl).searchParams.get('state'); - expect(state).toBeTruthy(); - const encryptedSession = cacheStore.get(`agents:slack-app-setup:${state}`); - - await service.completeInstall({ - projectId: 'project-1', - agentId: 'agent-1', - code: 'slack-code', - state: state ?? '', - }); - - const tokenRequest = requestMock.mock.calls[1]?.[0] as { - url: string; - headers: Record; - }; - expect(tokenRequest.url).toBe('https://slack.com/api/oauth.v2.access'); - expect(tokenRequest.headers).toEqual( - expect.objectContaining({ - Authorization: `Basic ${Buffer.from('C123:client-secret').toString('base64')}`, - 'Content-Type': 'application/x-www-form-urlencoded', - }), - ); - const tokenParams = fetchParams(requestMock, 1); - expect(tokenParams.get('code')).toBe('slack-code'); - expect(tokenParams.get('redirect_uri')).toBe( - 'https://hooks.example/rest/projects/project-1/agents/v2/agent-1/integrations/slack/oauth/callback', - ); - - expect(userRepository.findOne).toHaveBeenCalledWith({ - where: { id: 'user-1' }, - relations: ['role'], - }); - expect(credentialsService.createUnmanagedCredential).toHaveBeenCalledWith( - { - name: 'Slack - Support Agent', - type: 'slackApi', - data: { - accessToken: 'xoxb-installed-token', - signatureSecret: 'signing-secret', - }, - projectId: 'project-1', - }, - user, - ); - const integration = { type: 'slack', credentialId: 'cred-slack' }; - expect(chatIntegrationService.connect).toHaveBeenCalledWith( - 'agent-1', - integration, - 'project-1', - ); - expect(agentIntegrationPersistenceService.saveCredentialIntegration).toHaveBeenCalledWith( - agent, - integration, - { - user, - modifiedBy: 'user', - broadcast: false, - }, - ); - expect(chatIntegrationService.broadcastIntegrationChange).toHaveBeenCalledWith( - 'agent-1', - integration, - 'connect', - ); - expect( - agentIntegrationPersistenceService.saveCredentialIntegration.mock.invocationCallOrder[0], - ).toBeLessThan(chatIntegrationService.connect.mock.invocationCallOrder[0]); - expect(chatIntegrationService.connect.mock.invocationCallOrder[0]).toBeLessThan( - chatIntegrationService.broadcastIntegrationChange.mock.invocationCallOrder[0], - ); - expect(cacheService.delete).toHaveBeenCalledWith(`agents:slack-app-setup:${state}`); - expect(cipher.decryptV2).toHaveBeenCalledWith(encryptedSession); - }); - - it('does not broadcast when connecting a published Slack install fails', async () => { - const state = await beginInstall(); - requestMock.mockResolvedValueOnce(slackOAuthResponse()); - userRepository.findOne.mockResolvedValue(user); - credentialsService.createUnmanagedCredential.mockResolvedValue({ id: 'cred-slack' } as never); - agentIntegrationPersistenceService.saveCredentialIntegration.mockResolvedValue(agent as never); - const connectError = new Error('Slack connect failed'); - chatIntegrationService.connect.mockRejectedValue(connectError); - - await expect( - service.completeInstall({ - projectId: 'project-1', - agentId: 'agent-1', - code: 'slack-code', - state, - }), - ).rejects.toBe(connectError); - - expect(agentIntegrationPersistenceService.saveCredentialIntegration).toHaveBeenCalledWith( - agent, - { type: 'slack', credentialId: 'cred-slack' }, - { user, modifiedBy: 'user', broadcast: false }, - ); - expect(chatIntegrationService.connect).toHaveBeenCalledWith( - 'agent-1', - { type: 'slack', credentialId: 'cred-slack' }, - 'project-1', - ); - expect( - agentIntegrationPersistenceService.saveCredentialIntegration.mock.invocationCallOrder[0], - ).toBeLessThan(chatIntegrationService.connect.mock.invocationCallOrder[0]); - expect(chatIntegrationService.broadcastIntegrationChange).not.toHaveBeenCalled(); - }); - - it('saves without connecting or broadcasting for an unpublished agent', async () => { - agentRepository.findByIdAndProjectId - .mockResolvedValueOnce(agent as never) - .mockResolvedValueOnce(unpublishedAgent as never); - agentIntegrationPersistenceService.saveCredentialIntegration.mockResolvedValue( - unpublishedAgent as never, - ); - requestMock.mockResolvedValueOnce(slackAppCreatedResponse()).mockResolvedValueOnce( - slackResponse({ - ok: true, - access_token: 'xoxb-installed-token', - token_type: 'bot', - app_id: 'A123', - }), - ); - userRepository.findOne.mockResolvedValue(user); - credentialsService.createUnmanagedCredential.mockResolvedValue({ id: 'cred-slack' } as never); - - const { installUrl } = await service.createApp({ - projectId: 'project-1', - agentId: 'agent-1', - appConfigurationToken: 'xoxe-config', - user, - }); - const state = new URL(installUrl).searchParams.get('state') ?? ''; - - await service.completeInstall({ - projectId: 'project-1', - agentId: 'agent-1', - code: 'slack-code', - state, - }); - - const integration = { type: 'slack', credentialId: 'cred-slack' }; - expect(agentIntegrationPersistenceService.saveCredentialIntegration).toHaveBeenCalledWith( - unpublishedAgent, - integration, - { - user, - modifiedBy: 'user', - broadcast: false, - }, - ); - expect(chatIntegrationService.connect).not.toHaveBeenCalled(); - expect(chatIntegrationService.broadcastIntegrationChange).not.toHaveBeenCalled(); - }); - - it('rejects a callback state that does not belong to the requested project and agent', async () => { - requestMock.mockResolvedValueOnce(slackAppCreatedResponse()); - - const { installUrl } = await service.createApp({ - projectId: 'project-1', - agentId: 'agent-1', - appConfigurationToken: 'xoxe-config', - user, - }); - const state = new URL(installUrl).searchParams.get('state') ?? ''; - - await expect( - service.completeInstall({ - projectId: 'project-2', - agentId: 'agent-1', - code: 'slack-code', - state, - }), - ).rejects.toThrow(BadRequestError); - expect(credentialsService.createUnmanagedCredential).not.toHaveBeenCalled(); - expect(chatIntegrationService.connect).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/cli/src/modules/agents/integrations/__tests__/slack-integration.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/slack-integration.test.ts index 873f83423c4..39e3760f072 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/slack-integration.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/slack-integration.test.ts @@ -1,5 +1,11 @@ -import { SlackIntegration } from '../platforms/slack-integration'; +/* eslint-disable @typescript-eslint/unbound-method -- mock-based tests intentionally reference unbound methods */ +import { mock } from 'vitest-mock-extended'; + +import { ConflictError } from '@/errors/response-errors/conflict.error'; + +import type { AgentRepository } from '../../repositories/agent.repository'; import type { ChatInstance } from '../chat-integration.service'; +import { SlackIntegration } from '../platforms/slack/slack-integration'; describe('SlackIntegration', () => { let integration: SlackIntegration; @@ -22,6 +28,25 @@ describe('SlackIntegration', () => { expect(integration.credentialTypes).toEqual(['slackApi']); }); + it('rejects a credential already connected to another agent', async () => { + const agentRepository = mock(); + agentRepository.findByIntegrationCredential.mockResolvedValue([ + { id: 'other-agent', name: 'Other Agent' }, + ] as never); + integration = new SlackIntegration(agentRepository); + + await expect( + integration.onBeforeConnect({ + agentId: 'agent-1', + projectId: 'project-1', + credentialId: 'credential-1', + credential: {}, + ingressEnabled: true, + webhookUrlFor: vi.fn(), + }), + ).rejects.toThrow(ConflictError); + }); + it('extracts the Slack bot user ID for bridge message context', () => { const chat = { getAdapter: vi.fn().mockReturnValue({ botUserId: 'U_BOT' }), diff --git a/packages/cli/src/modules/agents/integrations/__tests__/slack-manual-setup.service.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/slack-manual-setup.service.test.ts new file mode 100644 index 00000000000..7b3e18d470a --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/__tests__/slack-manual-setup.service.test.ts @@ -0,0 +1,171 @@ +/* eslint-disable @typescript-eslint/unbound-method -- mock-based tests intentionally reference unbound methods */ +import type { UserRepository } from '@n8n/db'; +import type { Cipher } from 'n8n-core'; +import { mock } from 'vitest-mock-extended'; + +import type { CacheService } from '@/services/cache/cache.service'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; + +import { SlackManualSetupService } from '../platforms/slack/slack-manual-setup.service'; +import type { SlackMethodsService } from '../platforms/slack/slack-methods.service'; + +describe('SlackManualSetupService', () => { + function makeService() { + const methods = mock(); + const userRepository = mock(); + const cacheService = mock(); + const cipher = mock(); + return { + service: new SlackManualSetupService(methods, userRepository, cacheService, cipher), + methods, + userRepository, + cacheService, + cipher, + }; + } + + it('creates a manual app and stores encrypted callback state', async () => { + const { service, methods, cacheService, cipher } = makeService(); + const user = { id: 'user-1' }; + const redirectUrl = 'https://n8n.example/callback'; + methods.getAgent.mockResolvedValue({ id: 'agent-1', name: 'Support Agent' } as never); + methods.callbackUrl.mockReturnValue(redirectUrl); + methods.buildManifest.mockReturnValue({ + display_information: { name: 'Support Agent' }, + } as never); + methods.callSlackApi.mockResolvedValue({ + ok: true, + app_id: 'app-1', + oauth_authorize_url: 'https://slack.com/oauth/v2/authorize', + credentials: { + client_id: 'client-1', + client_secret: 'secret-1', + signing_secret: 'signing-1', + }, + }); + methods.childRecord.mockImplementation((record, key) => record[key] as never); + methods.stringProperty.mockImplementation((record, key) => { + const value = record?.[key]; + return typeof value === 'string' ? value : undefined; + }); + methods.installUrl.mockImplementation((oauthUrl, state, callbackUrl) => { + const installUrl = new URL(oauthUrl); + installUrl.searchParams.set('state', state); + installUrl.searchParams.set('redirect_uri', callbackUrl); + return installUrl.toString(); + }); + cipher.encryptV2.mockResolvedValue('encrypted-session-without-secrets'); + + const result = await service.createApp({ + projectId: 'project-1', + agentId: 'agent-1', + appConfigurationToken: 'configuration-token', + user: user as never, + }); + + const installUrl = new URL(result.installUrl); + const state = installUrl.searchParams.get('state'); + expect(state).toBeTruthy(); + expect(installUrl.searchParams.get('redirect_uri')).toBe(redirectUrl); + expect(methods.buildManifest).toHaveBeenCalledWith('Support Agent', 'project-1', 'agent-1', { + redirectUrl, + }); + expect(cacheService.set).toHaveBeenCalledWith( + `agents:slack-app-setup:${state}`, + 'encrypted-session-without-secrets', + 60 * 60 * 1000, + ); + const session = JSON.parse(cipher.encryptV2.mock.calls[0]?.[0] as string) as { + projectId: string; + agentId: string; + userId: string; + appId: string; + clientId: string; + clientSecret: string; + signingSecret: string; + redirectUrl: string; + }; + expect(session).toEqual({ + projectId: 'project-1', + agentId: 'agent-1', + userId: 'user-1', + appId: 'app-1', + clientId: 'client-1', + clientSecret: 'secret-1', + signingSecret: 'signing-1', + redirectUrl, + }); + expect(result.appId).toBe('app-1'); + }); + + it('completes OAuth by atomically consuming state and creating the bot credential', async () => { + const { service, methods, userRepository, cacheService, cipher } = makeService(); + const user = { id: 'user-1' }; + const agent = { + id: 'agent-1', + projectId: 'project-1', + name: 'Support Agent', + }; + const session = { + projectId: 'project-1', + agentId: 'agent-1', + userId: 'user-1', + appId: 'app-1', + clientId: 'client-1', + clientSecret: 'secret-1', + signingSecret: 'signing-1', + redirectUrl: 'https://n8n.example/callback', + }; + cacheService.take.mockResolvedValue('encrypted-session'); + cipher.decryptV2.mockResolvedValue(JSON.stringify(session)); + userRepository.findOne.mockResolvedValue(user as never); + methods.getAgent.mockResolvedValue(agent as never); + methods.callSlackApi.mockResolvedValue({ ok: true, access_token: 'xoxb-token' }); + methods.stringProperty.mockReturnValue('xoxb-token'); + + await service.completeInstall({ + projectId: 'project-1', + agentId: 'agent-1', + code: 'oauth-code', + state: 'state-1', + }); + + expect(cacheService.take).toHaveBeenCalledWith('agents:slack-app-setup:state-1'); + expect(cacheService.delete).not.toHaveBeenCalled(); + expect(methods.createAndConnectBotCredential).toHaveBeenCalledWith({ + agent, + user, + accessToken: 'xoxb-token', + signingSecret: 'signing-1', + }); + }); + + it('rejects callback state for a different project or agent', async () => { + const { service, methods, cacheService, cipher } = makeService(); + cacheService.take.mockResolvedValue('encrypted-session'); + cipher.decryptV2.mockResolvedValue( + JSON.stringify({ + projectId: 'project-1', + agentId: 'agent-1', + userId: 'user-1', + appId: 'app-1', + clientId: 'client-1', + clientSecret: 'secret-1', + signingSecret: 'signing-1', + redirectUrl: 'https://n8n.example/callback', + }), + ); + + await expect( + service.completeInstall({ + projectId: 'project-2', + agentId: 'agent-1', + code: 'oauth-code', + state: 'state-1', + }), + ).rejects.toThrow(BadRequestError); + + expect(methods.callSlackApi).not.toHaveBeenCalled(); + expect(methods.createAndConnectBotCredential).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/modules/agents/integrations/__tests__/slack-methods.service.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/slack-methods.service.test.ts new file mode 100644 index 00000000000..9ad5e106fea --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/__tests__/slack-methods.service.test.ts @@ -0,0 +1,106 @@ +/* eslint-disable @typescript-eslint/unbound-method -- mock-based tests intentionally reference unbound methods */ +import type { OutboundHttp } from '@n8n/backend-network'; +import { mock } from 'vitest-mock-extended'; + +import type { CredentialsService } from '@/credentials/credentials.service'; +import type { UrlService } from '@/services/url.service'; + +import type { AgentIntegrationManagementService } from '../../agent-integration-management.service'; +import type { Agent } from '../../entities/agent.entity'; +import type { AgentRepository } from '../../repositories/agent.repository'; +import { SlackMethodsService } from '../platforms/slack/slack-methods.service'; + +describe('SlackMethodsService', () => { + function makeService() { + const credentialsService = mock(); + const managementService = mock(); + const urlService = mock(); + urlService.getWebhookBaseUrl.mockReturnValue('https://hooks.example/'); + return { + service: new SlackMethodsService( + credentialsService, + mock(), + managementService, + urlService, + mock(), + ), + credentialsService, + managementService, + }; + } + + it('creates a bot credential and delegates published activation to integration management', async () => { + const { service, credentialsService, managementService } = makeService(); + const agent = { + id: 'agent-1', + projectId: 'project-1', + name: 'Support Agent', + activeVersionId: 'version-1', + } as unknown as Agent; + const user = { id: 'user-1' }; + credentialsService.createUnmanagedCredential.mockResolvedValue({ + id: 'credential-1', + } as never); + managementService.connect.mockResolvedValue({ + integration: { type: 'slack', credentialId: 'credential-1' }, + savedAgent: agent, + }); + await service.createAndConnectBotCredential({ + agent, + user: user as never, + accessToken: 'xoxb-token', + signingSecret: 'signing-secret', + }); + + expect(credentialsService.createUnmanagedCredential).toHaveBeenCalledWith( + { + name: 'Slack - Support Agent', + type: 'slackApi', + data: { + accessToken: 'xoxb-token', + signatureSecret: 'signing-secret', + }, + projectId: 'project-1', + }, + user, + ); + expect(managementService.connect).toHaveBeenCalledWith({ + agent, + user, + integration: { type: 'slack', credentialId: 'credential-1' }, + }); + }); + + it('builds the manual manifest without OAuth redirect URLs', () => { + const { service } = makeService(); + + const manifest = service.buildManifest('Support Agent', 'project-1', 'agent-1'); + + expect(manifest.display_information.name).toBe('Support Agent'); + expect(manifest.oauth_config).not.toHaveProperty('redirect_urls'); + expect(manifest.oauth_config.scopes.bot).toContain('chat:write'); + expect(manifest.settings.event_subscriptions.request_url).toBe( + 'https://hooks.example/rest/projects/project-1/agents/v2/agent-1/webhooks/slack', + ); + expect(manifest.settings.interactivity).toEqual({ + is_enabled: true, + request_url: 'https://hooks.example/rest/projects/project-1/agents/v2/agent-1/webhooks/slack', + }); + }); + + it('adds callback state and redirect URL to the Slack install URL', () => { + const { service } = makeService(); + + const result = new URL( + service.installUrl( + 'https://slack.com/oauth/v2/authorize?client_id=client-1', + 'state-1', + 'https://n8n.example/callback', + ), + ); + + expect(result.searchParams.get('client_id')).toBe('client-1'); + expect(result.searchParams.get('state')).toBe('state-1'); + expect(result.searchParams.get('redirect_uri')).toBe('https://n8n.example/callback'); + }); +}); diff --git a/packages/cli/src/modules/agents/integrations/agent-chat-integration.ts b/packages/cli/src/modules/agents/integrations/agent-chat-integration.ts index b6d13736581..fc030b95cff 100644 --- a/packages/cli/src/modules/agents/integrations/agent-chat-integration.ts +++ b/packages/cli/src/modules/agents/integrations/agent-chat-integration.ts @@ -1,9 +1,8 @@ +import { AgentIntegrationConfig, type RichCardComponentType } from '@n8n/api-types'; import { Service } from '@n8n/di'; import type { Thread, Author, Message } from 'chat'; import type { Logger } from 'n8n-workflow'; -import { AgentIntegrationConfig } from '@n8n/api-types'; -import type { RichCardComponentType } from '@n8n/api-types'; import type { ChatInstance } from './chat-integration.service'; import type { SuspendComponent } from './component-mapper'; import { @@ -260,6 +259,9 @@ export abstract class AgentChatIntegration { /** Build the Chat SDK adapter for this platform. */ abstract createAdapter(ctx: AgentChatIntegrationContext): Promise; + /** Validate platform-specific configuration before credentials or persistence are touched. */ + validateConfig?(integration: AgentIntegrationConfig): void; + /** * Handle a webhook request that arrives before an integration is connected * (i.e. before credentials are configured). The canonical case is Slack's @@ -313,6 +315,12 @@ export abstract class AgentChatIntegration { */ onBeforeDisconnect?(ctx: AgentChatIntegrationContext): Promise; + /** + * Prepare a thread created or selected by an outbound send. Platforms can + * use this to receive follow-up messages in that thread. + */ + prepareSentThread?(thread: Thread): Promise; + /** * Optional hook run on EVERY main once the connection is live, regardless * of `skipExternalHooks`. Unlike `onAfterConnect`, this is for local runtime diff --git a/packages/cli/src/modules/agents/integrations/chat-integration.service.ts b/packages/cli/src/modules/agents/integrations/chat-integration.service.ts index d98267482c8..0fa26952d1a 100644 --- a/packages/cli/src/modules/agents/integrations/chat-integration.service.ts +++ b/packages/cli/src/modules/agents/integrations/chat-integration.service.ts @@ -68,6 +68,7 @@ interface ChatAgentConnection { interface ConnectOptions { ingressEnabled?: boolean; skipExternalHooks?: boolean; + skipBeforeConnect?: boolean; settings?: AgentIntegrationSettings; } @@ -157,6 +158,26 @@ export class ChatIntegrationService { return type ? this.integrationRegistry.get(type) : undefined; } + async validateBeforeConnect( + agentId: string, + integration: AgentIntegrationConfig, + projectId: string, + ): Promise { + const implementation = this.integrationRegistry.require(integration.type); + implementation.validateConfig?.(integration); + if (!implementation.onBeforeConnect) return; + + const credential = await this.decryptCredentialForProject(integration.credentialId, projectId); + await implementation.onBeforeConnect({ + agentId, + projectId, + credentialId: integration.credentialId, + credential, + ingressEnabled: true, + webhookUrlFor: (platform) => this.buildWebhookUrl(agentId, projectId, platform), + }); + } + /** * Connect an agent to a chat platform via the Chat SDK. * @@ -206,7 +227,12 @@ export class ChatIntegrationService { // Pre-connect hook — webhook-based platforms use this to detect // credential conflicts (e.g. a Telegram bot token already in use) and // abort the connect before we touch any external API. - if (ingressEnabled && integrationImpl.onBeforeConnect && !options.skipExternalHooks) { + if ( + ingressEnabled && + integrationImpl.onBeforeConnect && + !options.skipExternalHooks && + !options.skipBeforeConnect + ) { await integrationImpl.onBeforeConnect(ctx); } diff --git a/packages/cli/src/modules/agents/integrations/integration-action-executor.ts b/packages/cli/src/modules/agents/integrations/integration-action-executor.ts index dc7596a2e4a..f1c57bdac5d 100644 --- a/packages/cli/src/modules/agents/integrations/integration-action-executor.ts +++ b/packages/cli/src/modules/agents/integrations/integration-action-executor.ts @@ -4,7 +4,7 @@ import { isRecord } from '@n8n/utils/is-record'; import type { Adapter, SentMessage } from 'chat'; import { z } from 'zod'; -import { ChatIntegrationRegistry } from './agent-chat-integration'; +import { type AgentChatIntegration, ChatIntegrationRegistry } from './agent-chat-integration'; import type { CallbackMetadata } from './callback-store'; import { ChatIntegrationService, type ChatInstance } from './chat-integration.service'; import { @@ -26,7 +26,6 @@ import type { IntegrationMessageContext, IntegrationToolConnectionDescriptor, } from './integration-tools'; -import { subscribeSlackThread } from './platforms/slack-operations'; // The shared wire schema from @n8n/api-types — the same definition the tool // boundary validates against and the editor-ui renderer parses with. @@ -260,7 +259,7 @@ export class ChatIntegrationActionExecutor implements IntegrationActionExecutor } const thread = chat.thread(threadId); - await maybeSubscribeSlackThread(params.descriptor, thread); + await this.prepareSentThread(params.descriptor, thread); const sent = await thread.post(await this.toPostable(params.descriptor, input.message, params)); return { @@ -279,7 +278,7 @@ export class ChatIntegrationActionExecutor implements IntegrationActionExecutor ): Promise { const input = sendDmInputSchema.parse(params.input); const thread = await chat.openDM(input.userId); - await maybeSubscribeSlackThread(params.descriptor, thread); + await this.prepareSentThread(params.descriptor, thread); const sent = await thread.post(await this.toPostable(params.descriptor, input.message, params)); return { @@ -340,7 +339,9 @@ export class ChatIntegrationActionExecutor implements IntegrationActionExecutor const sent = await channel.post( await this.toPostable(params.descriptor, input.message, params), ); - await maybeSubscribeSlackSentThread(params.descriptor, chat, sent.threadId); + if (sent.threadId) { + await this.prepareSentThread(params.descriptor, chat.thread(sent.threadId)); + } return { ok: true, @@ -399,6 +400,13 @@ export class ChatIntegrationActionExecutor implements IntegrationActionExecutor metadata, ); } + + private async prepareSentThread( + descriptor: IntegrationToolConnectionDescriptor, + thread: Parameters>[0], + ): Promise { + await this.integrationRegistry.get(descriptor.integration.type)?.prepareSentThread?.(thread); + } } function supportsMessageEditing(adapter: unknown): adapter is Pick { @@ -447,20 +455,3 @@ function buildReactionTarget( : undefined; return { type: 'thread', threadId, ...(channelId ? { channelId } : {}) }; } - -async function maybeSubscribeSlackThread( - descriptor: IntegrationToolConnectionDescriptor, - thread: { subscribe?: () => Promise }, -): Promise { - if (descriptor.integration.type !== 'slack') return; - await subscribeSlackThread(thread); -} - -async function maybeSubscribeSlackSentThread( - descriptor: IntegrationToolConnectionDescriptor, - chat: ChatInstance, - threadId: string | undefined, -): Promise { - if (descriptor.integration.type !== 'slack' || !threadId) return; - await subscribeSlackThread(chat.thread(threadId)); -} diff --git a/packages/cli/src/modules/agents/integrations/platforms/__tests__/slack/synthetic-integration.test.ts b/packages/cli/src/modules/agents/integrations/platforms/__tests__/slack/synthetic-integration.test.ts index 25dde78b3a0..3e8473bbc43 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/__tests__/slack/synthetic-integration.test.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/__tests__/slack/synthetic-integration.test.ts @@ -4,7 +4,7 @@ import { slackReplayFixtures, slackUser, } from '../../../__tests__/helpers/slack/synthetic-fixtures'; -import { SlackIntegration } from '../../slack-integration'; +import { SlackIntegration } from '../../slack/slack-integration'; describe('Slack channel integration scenarios', () => { it('handles Slack URL verification without an active connection', () => { diff --git a/packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram/integration.test.ts b/packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram/integration.test.ts index c8adf279f26..f96b597738c 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram/integration.test.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram/integration.test.ts @@ -8,6 +8,7 @@ import { createHmac } from 'crypto'; import { mock } from 'vitest-mock-extended'; import type { InstanceSettings } from 'n8n-core'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ConflictError } from '@/errors/response-errors/conflict.error'; import type { UrlService } from '@/services/url.service'; @@ -98,6 +99,16 @@ describe('TelegramIntegration capabilities', () => { }); }); +describe('TelegramIntegration.validateConfig', () => { + it('requires Telegram settings', () => { + const { integration } = makeIntegration(); + + expect(() => + integration.validateConfig({ type: 'telegram', credentialId: 'credential-1' }), + ).toThrow(BadRequestError); + }); +}); + describe('TelegramIntegration.onBeforeConnect', () => { let agentRepository: Mocked; let integration: TelegramIntegration; diff --git a/packages/cli/src/modules/agents/integrations/platforms/slack-bridge-behavior.ts b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-bridge-behavior.ts similarity index 98% rename from packages/cli/src/modules/agents/integrations/platforms/slack-bridge-behavior.ts rename to packages/cli/src/modules/agents/integrations/platforms/slack/slack-bridge-behavior.ts index f746206b99c..c50a8e093f9 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/slack-bridge-behavior.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-bridge-behavior.ts @@ -8,9 +8,9 @@ import type { BridgeResumeExecutionContext, BridgeStatusHandle, PlatformAgentContext, -} from '../agent-chat-integration'; -import type { ChatInstance } from '../chat-integration.service'; -import type { ReplyExpectation } from '../integration-tools'; +} from '../../agent-chat-integration'; +import type { ChatInstance } from '../../chat-integration.service'; +import type { ReplyExpectation } from '../../integration-tools'; const SLACK_THINKING_STATUS = 'Thinking...'; const SLACK_STATUS_RETRY_DELAY_MS = 750; diff --git a/packages/cli/src/modules/agents/integrations/platforms/slack-integration.ts b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-integration.ts similarity index 83% rename from packages/cli/src/modules/agents/integrations/platforms/slack-integration.ts rename to packages/cli/src/modules/agents/integrations/platforms/slack/slack-integration.ts index ee12c7823be..538253ffdff 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/slack-integration.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-integration.ts @@ -1,6 +1,10 @@ import { Service } from '@n8n/di'; import type { RichCardComponentType } from '@n8n/api-types'; +import type { Thread } from 'chat'; +import { ConflictError } from '@/errors/response-errors/conflict.error'; + +import { AgentRepository } from '../../../repositories/agent.repository'; import { AgentChatIntegration, type AgentChatIntegrationContext, @@ -10,15 +14,15 @@ import { type PlatformAgentContext, type PlatformContextQueryParams, type UnauthenticatedWebhookResponse, -} from '../agent-chat-integration'; -import type { ChatInstance } from '../chat-integration.service'; -import { loadSlackAdapter } from '../esm-loader'; +} from '../../agent-chat-integration'; +import type { ChatInstance } from '../../chat-integration.service'; +import { loadSlackAdapter } from '../../esm-loader'; import { resolveIntegrationActionDefinitions, resolveIntegrationContextQueryDefinitions, -} from '../integration-tool-definitions'; -import { connectionUnavailable } from '../integration-helpers'; -import type { ReplyExpectation } from '../integration-tools'; +} from '../../integration-tool-definitions'; +import { connectionUnavailable } from '../../integration-helpers'; +import type { ReplyExpectation } from '../../integration-tools'; import { createSlackBridgeExecutionContext, createSlackResumeExecutionContext, @@ -26,7 +30,7 @@ import { getSlackReplyExpectation, prepareSlackInboundText, } from './slack-bridge-behavior'; -import { executeSlackContextQuery } from './slack-operations'; +import { executeSlackContextQuery, subscribeSlackThread } from './slack-operations'; /** * Slack platform integration. @@ -35,6 +39,10 @@ import { executeSlackContextQuery } from './slack-operations'; */ @Service() export class SlackIntegration extends AgentChatIntegration { + constructor(private readonly agentRepository?: AgentRepository) { + super(); + } + readonly type = 'slack'; readonly credentialTypes = ['slackApi']; @@ -90,6 +98,23 @@ export class SlackIntegration extends AgentChatIntegration { 'do_not_respond', ]); + async onBeforeConnect(ctx: AgentChatIntegrationContext): Promise { + if (!this.agentRepository) return; + const others = await this.agentRepository.findByIntegrationCredential( + this.type, + ctx.credentialId, + ctx.projectId, + ctx.agentId, + ); + if (others.length > 0) { + throw new ConflictError(`Slack credential is already connected to agent "${others[0].name}"`); + } + } + + async prepareSentThread(thread: Thread): Promise { + await subscribeSlackThread(thread); + } + getPlatformAgentContext(chat: ChatInstance): PlatformAgentContext { return getSlackPlatformAgentContext(chat); } diff --git a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-manual-setup.service.ts b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-manual-setup.service.ts new file mode 100644 index 00000000000..524dc8ff832 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-manual-setup.service.ts @@ -0,0 +1,194 @@ +import { randomBytes } from 'node:crypto'; + +import type { CreateSlackAgentAppResponse, SlackAgentAppManifestResponse } from '@n8n/api-types'; +import type { User } from '@n8n/db'; +import { UserRepository } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { isRecord } from '@n8n/utils/is-record'; +import { Cipher } from 'n8n-core'; +import { jsonParse } from 'n8n-workflow'; + +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { CacheService } from '@/services/cache/cache.service'; + +import { SlackMethodsService } from './slack-methods.service'; + +const SLACK_APP_SETUP_CACHE_PREFIX = 'agents:slack-app-setup:'; +const SLACK_APP_SETUP_TTL_MS = 60 * 60 * 1000; + +interface CreateSlackAppOptions { + projectId: string; + agentId: string; + appConfigurationToken: string; + user: User; +} + +interface GetSlackAppManifestOptions { + projectId: string; + agentId: string; +} + +interface CompleteSlackAppInstallOptions { + projectId: string; + agentId: string; + code: string; + state: string; +} + +interface SlackAppSetupSession { + projectId: string; + agentId: string; + userId: string; + appId: string; + clientId: string; + clientSecret: string; + signingSecret: string; + redirectUrl: string; +} + +function hasSessionShape(value: unknown): value is SlackAppSetupSession { + const keys: Array = [ + 'projectId', + 'agentId', + 'userId', + 'appId', + 'clientId', + 'clientSecret', + 'signingSecret', + 'redirectUrl', + ]; + return isRecord(value) && keys.every((key) => typeof value[key] === 'string'); +} + +@Service() +export class SlackManualSetupService { + constructor( + private readonly methods: SlackMethodsService, + private readonly userRepository: UserRepository, + private readonly cacheService: CacheService, + private readonly cipher: Cipher, + ) {} + + async createApp(options: CreateSlackAppOptions): Promise { + const appConfigurationToken = options.appConfigurationToken.trim(); + if (!appConfigurationToken) { + throw new BadRequestError('Slack app configuration token is required'); + } + + const agent = await this.methods.getAgent(options.agentId, options.projectId); + const redirectUrl = this.methods.callbackUrl(options.projectId, options.agentId); + const manifest = this.methods.buildManifest(agent.name, options.projectId, options.agentId, { + redirectUrl, + }); + const response = await this.methods.callSlackApi('apps.manifest.create', { + token: appConfigurationToken, + manifest: JSON.stringify(manifest), + }); + if (response.ok !== true) { + throw this.methods.slackError('create the Slack app', response); + } + + const credentials = this.methods.childRecord(response, 'credentials'); + const appId = this.methods.stringProperty(response, 'app_id'); + const clientId = this.methods.stringProperty(credentials, 'client_id'); + const clientSecret = this.methods.stringProperty(credentials, 'client_secret'); + const signingSecret = this.methods.stringProperty(credentials, 'signing_secret'); + const oauthAuthorizeUrl = this.methods.stringProperty(response, 'oauth_authorize_url'); + if (!appId || !clientId || !clientSecret || !signingSecret || !oauthAuthorizeUrl) { + throw new BadRequestError('Slack returned an incomplete app setup response'); + } + + const state = randomBytes(32).toString('hex'); + const setupSession = { + projectId: options.projectId, + agentId: options.agentId, + userId: options.user.id, + appId, + clientId, + clientSecret, + signingSecret, + redirectUrl, + } satisfies SlackAppSetupSession; + await this.cacheService.set( + this.cacheKey(state), + await this.cipher.encryptV2(JSON.stringify(setupSession)), + SLACK_APP_SETUP_TTL_MS, + ); + + return { + appId, + installUrl: this.methods.installUrl(oauthAuthorizeUrl, state, redirectUrl), + }; + } + + async getManifest(options: GetSlackAppManifestOptions): Promise { + const agent = await this.methods.getAgent(options.agentId, options.projectId); + return { + manifest: this.methods.buildManifest(agent.name, options.projectId, options.agentId), + }; + } + + async completeInstall(options: CompleteSlackAppInstallOptions): Promise { + const session = await this.consumeSession(options.state); + if (session.projectId !== options.projectId || session.agentId !== options.agentId) { + throw new BadRequestError('Slack app setup state does not match this agent'); + } + + const user = await this.userRepository.findOne({ + where: { id: session.userId }, + relations: ['role'], + }); + if (!user) throw new NotFoundError(`User "${session.userId}" not found`); + + const agent = await this.methods.getAgent(session.agentId, session.projectId); + const tokenResponse = await this.methods.callSlackApi( + 'oauth.v2.access', + { + code: options.code, + redirect_uri: session.redirectUrl, + }, + { + authorization: `Basic ${Buffer.from(`${session.clientId}:${session.clientSecret}`).toString( + 'base64', + )}`, + }, + ); + if (tokenResponse.ok !== true) { + throw this.methods.slackError('finish Slack app installation', tokenResponse); + } + + const accessToken = this.methods.stringProperty(tokenResponse, 'access_token'); + if (!accessToken?.startsWith('xoxb-')) { + throw new BadRequestError('Slack did not return a Bot User OAuth Token'); + } + + await this.methods.createAndConnectBotCredential({ + agent, + user, + accessToken, + signingSecret: session.signingSecret, + }); + } + + private async consumeSession(state: string): Promise { + const cached = await this.cacheService.take(this.cacheKey(state)); + if (typeof cached !== 'string') { + throw new BadRequestError('Slack app setup state has expired or is invalid'); + } + + try { + const decrypted = await this.cipher.decryptV2(cached); + const session = jsonParse(decrypted, { fallbackValue: null }); + if (hasSessionShape(session)) return session; + } catch { + // Invalid encrypted state falls through to the shared callback error. + } + + throw new BadRequestError('Slack app setup state has expired or is invalid'); + } + + private cacheKey(state: string): string { + return `${SLACK_APP_SETUP_CACHE_PREFIX}${state}`; + } +} diff --git a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-methods.service.ts b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-methods.service.ts new file mode 100644 index 00000000000..fb9b58eede9 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-methods.service.ts @@ -0,0 +1,211 @@ +import type { AgentIntegrationConfig, SlackAgentAppManifest } from '@n8n/api-types'; +import { OutboundHttp } from '@n8n/backend-network'; +import type { User } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { isRecord } from '@n8n/utils/is-record'; + +import { CredentialsService } from '@/credentials/credentials.service'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { UrlService } from '@/services/url.service'; + +import { AgentIntegrationManagementService } from '../../../agent-integration-management.service'; +import type { Agent } from '../../../entities/agent.entity'; +import { AgentRepository } from '../../../repositories/agent.repository'; + +const DEFAULT_SLACK_APP_NAME = 'n8n Agent'; +const SLACK_CREDENTIAL_TYPE = 'slackApi'; + +const REQUIRED_BOT_EVENTS = [ + 'app_mention', + 'assistant_thread_started', + 'assistant_thread_context_changed', + 'message.channels', + 'message.groups', + 'message.im', + 'message.mpim', +] as const; + +const REQUIRED_BOT_SCOPES = [ + 'app_mentions:read', + 'assistant:write', + 'channels:history', + 'channels:join', + 'channels:manage', + 'channels:read', + 'chat:write', + 'chat:write.customize', + 'files:read', + 'files:write', + 'groups:history', + 'groups:read', + 'im:history', + 'im:read', + 'im:write', + 'mpim:history', + 'mpim:read', + 'mpim:write', + 'reactions:write', + 'search:read.public', + 'users:read', + 'users:read.email', +] as const; + +@Service() +export class SlackMethodsService { + constructor( + private readonly credentialsService: CredentialsService, + private readonly agentRepository: AgentRepository, + private readonly integrationManagementService: AgentIntegrationManagementService, + private readonly urlService: UrlService, + private readonly outboundHttp: OutboundHttp, + ) {} + + async callSlackApi( + method: string, + params: Record, + headers: Record = {}, + ): Promise> { + try { + const response = await this.outboundHttp.requests({ ssrf: 'disabled' }).request({ + method: 'POST', + url: `https://slack.com/api/${method}`, + headers: { + ...headers, + 'content-type': 'application/x-www-form-urlencoded', + }, + body: params, + returnFullResponse: true, + ignoreHttpStatusErrors: true, + }); + const data: unknown = response.body; + return isRecord(data) ? data : { ok: false, error: 'invalid_response' }; + } catch { + return { ok: false, error: 'slack_request_failed' }; + } + } + + slackError(action: string, response: Record): BadRequestError { + const error = this.stringProperty(response, 'error') ?? 'unknown_error'; + return new BadRequestError(`Slack could not ${action}: ${error}`); + } + + async getAgent(agentId: string, projectId: string): Promise { + const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId); + if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`); + return agent; + } + + buildManifest( + agentName: string, + projectId: string, + agentId: string, + options: { redirectUrl?: string } = {}, + ): SlackAgentAppManifest { + const slackAppName = this.sanitiseSlackAppName(agentName); + const webhookUrl = this.webhookUrl(projectId, agentId); + return { + display_information: { name: slackAppName }, + features: { + app_home: { + home_tab_enabled: false, + messages_tab_enabled: true, + messages_tab_read_only_enabled: false, + }, + bot_user: { + display_name: slackAppName, + always_online: true, + }, + }, + oauth_config: { + ...(options.redirectUrl ? { redirect_urls: [options.redirectUrl] } : {}), + scopes: { bot: [...REQUIRED_BOT_SCOPES] }, + }, + settings: { + event_subscriptions: { + request_url: webhookUrl, + bot_events: [...REQUIRED_BOT_EVENTS], + }, + interactivity: { + is_enabled: true, + request_url: webhookUrl, + }, + org_deploy_enabled: false, + socket_mode_enabled: false, + token_rotation_enabled: false, + }, + }; + } + + callbackUrl(projectId: string, agentId: string): string { + return `${this.urlService.getWebhookBaseUrl()}rest/projects/${projectId}/agents/v2/${agentId}/integrations/slack/oauth/callback`; + } + + installUrl(oauthAuthorizeUrl: string, state: string, redirectUrl: string): string { + try { + const url = new URL(oauthAuthorizeUrl); + url.searchParams.set('state', state); + url.searchParams.set('redirect_uri', redirectUrl); + return url.toString(); + } catch { + throw new BadRequestError('Slack returned an invalid installation URL'); + } + } + + async createAndConnectBotCredential(options: { + agent: Agent; + user: User; + accessToken: string; + signingSecret: string; + }): Promise { + const credential = await this.credentialsService.createUnmanagedCredential( + { + name: this.credentialName(options.agent.name), + type: SLACK_CREDENTIAL_TYPE, + data: { + accessToken: options.accessToken, + signatureSecret: options.signingSecret, + }, + projectId: options.agent.projectId, + }, + options.user, + ); + const integration = { + type: 'slack', + credentialId: credential.id, + } satisfies AgentIntegrationConfig; + await this.integrationManagementService.connect({ + agent: options.agent, + user: options.user, + integration, + }); + return credential.id; + } + + childRecord(record: Record, key: string): Record | undefined { + const child = record[key]; + return isRecord(child) ? child : undefined; + } + + stringProperty(record: Record | undefined, key: string): string | undefined { + const value = record?.[key]; + return typeof value === 'string' ? value : undefined; + } + + private webhookUrl(projectId: string, agentId: string): string { + return `${this.urlService.getWebhookBaseUrl()}rest/projects/${projectId}/agents/v2/${agentId}/webhooks/slack`; + } + + private credentialName(agentName: string): string { + return `Slack - ${agentName || DEFAULT_SLACK_APP_NAME}`.slice(0, 128); + } + + private sanitiseSlackAppName(raw: string): string { + const cleaned = raw + .replace(/[^a-zA-Z0-9 ._-]/g, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 35); + return cleaned.length > 0 ? cleaned : DEFAULT_SLACK_APP_NAME; + } +} diff --git a/packages/cli/src/modules/agents/integrations/platforms/slack-operations.ts b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-operations.ts similarity index 97% rename from packages/cli/src/modules/agents/integrations/platforms/slack-operations.ts rename to packages/cli/src/modules/agents/integrations/platforms/slack/slack-operations.ts index ab9746a7cae..82529ca0e30 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/slack-operations.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-operations.ts @@ -1,9 +1,9 @@ import { z } from 'zod'; import { isRecord } from '@n8n/utils/is-record'; -import type { ChatInstance } from '../chat-integration.service'; -import { stringValue, unsupportedQuery } from '../integration-helpers'; -import type { IntegrationContextQuery } from '../integration-tools'; +import type { ChatInstance } from '../../chat-integration.service'; +import { stringValue, unsupportedQuery } from '../../integration-helpers'; +import type { IntegrationContextQuery } from '../../integration-tools'; const PLATFORM = 'slack'; diff --git a/packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts b/packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts index ab8702bf68a..bdfbdea29c8 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts @@ -10,6 +10,7 @@ import { createHmac } from 'crypto'; import { InstanceSettings } from 'n8n-core'; import { UnexpectedError } from 'n8n-workflow'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ConflictError } from '@/errors/response-errors/conflict.error'; import { UrlService } from '@/services/url.service'; @@ -155,6 +156,12 @@ export class TelegramIntegration extends AgentChatIntegration { return createTelegramAdapter({ botToken, mode, secretToken }); } + validateConfig(integration: AgentIntegrationConfig): void { + if (integration.type === this.type && !integration.settings) { + throw new BadRequestError('Telegram integration settings are required'); + } + } + /** * In polling mode the Chat SDK adapter long-polls Telegram, which must be * done by exactly one main — otherwise multiple instances race for the same @@ -243,7 +250,7 @@ export class TelegramIntegration extends AgentChatIntegration { async createBridgeExecutionContext( params: BridgeMessageContextParams, ): Promise { - return createTelegramBridgeExecutionContext(params); + return await Promise.resolve(createTelegramBridgeExecutionContext(params)); } async createResumeExecutionContext(params: { @@ -251,7 +258,7 @@ export class TelegramIntegration extends AgentChatIntegration { logger: BridgeMessageContextParams['logger']; agentId: string; }): Promise { - return createTelegramResumeExecutionContext(params); + return await Promise.resolve(createTelegramResumeExecutionContext(params)); } normalizeComponents(components: SuspendComponent[]): SuspendComponent[] { diff --git a/packages/cli/src/modules/agents/integrations/slack-app-setup.service.ts b/packages/cli/src/modules/agents/integrations/slack-app-setup.service.ts deleted file mode 100644 index 40511e44b3e..00000000000 --- a/packages/cli/src/modules/agents/integrations/slack-app-setup.service.ts +++ /dev/null @@ -1,418 +0,0 @@ -import { randomBytes } from 'node:crypto'; - -import type { - AgentIntegrationConfig, - CreateSlackAgentAppResponse, - SlackAgentAppManifest, - SlackAgentAppManifestResponse, -} from '@n8n/api-types'; -import { OutboundHttp } from '@n8n/backend-network'; -import type { User } from '@n8n/db'; -import { UserRepository } from '@n8n/db'; -import { Service } from '@n8n/di'; -import { isRecord } from '@n8n/utils/is-record'; -import { Cipher } from 'n8n-core'; -import { jsonParse } from 'n8n-workflow'; - -import { CredentialsService } from '@/credentials/credentials.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { CacheService } from '@/services/cache/cache.service'; -import { UrlService } from '@/services/url.service'; - -import { AgentIntegrationPersistenceService } from '../agent-integration-persistence.service'; -import type { Agent } from '../entities/agent.entity'; -import { AgentRepository } from '../repositories/agent.repository'; -import { ChatIntegrationService } from './chat-integration.service'; - -const SLACK_APP_SETUP_CACHE_PREFIX = 'agents:slack-app-setup:'; -const SLACK_APP_SETUP_TTL_MS = 60 * 60 * 1000; -const DEFAULT_SLACK_APP_NAME = 'n8n Agent'; -const SLACK_CREDENTIAL_TYPE = 'slackApi'; - -const REQUIRED_BOT_EVENTS = [ - 'app_mention', - 'assistant_thread_started', - 'assistant_thread_context_changed', - 'message.channels', - 'message.groups', - 'message.im', - 'message.mpim', -] as const; - -const REQUIRED_BOT_SCOPES = [ - 'app_mentions:read', - 'assistant:write', - 'channels:history', - 'channels:join', - 'channels:manage', - 'channels:read', - 'chat:write', - 'chat:write.customize', - 'files:read', - 'files:write', - 'groups:history', - 'groups:read', - 'im:history', - 'im:read', - 'im:write', - 'mpim:history', - 'mpim:read', - 'mpim:write', - 'reactions:write', - 'search:read.public', - 'users:read', - 'users:read.email', -] as const; - -interface CreateSlackAppOptions { - projectId: string; - agentId: string; - appConfigurationToken: string; - user: User; -} - -interface GetSlackAppManifestOptions { - projectId: string; - agentId: string; -} - -interface CompleteSlackAppInstallOptions { - projectId: string; - agentId: string; - code: string; - state: string; -} - -interface SlackAppSetupSession { - projectId: string; - agentId: string; - userId: string; - appId: string; - clientId: string; - clientSecret: string; - signingSecret: string; - redirectUrl: string; -} - -function childRecord( - record: Record, - key: string, -): Record | undefined { - const child = record[key]; - return isRecord(child) ? child : undefined; -} - -function stringProperty( - record: Record | undefined, - key: string, -): string | undefined { - const value = record?.[key]; - return typeof value === 'string' ? value : undefined; -} - -function hasSessionShape(value: unknown): value is SlackAppSetupSession { - const keys: Array = [ - 'projectId', - 'agentId', - 'userId', - 'appId', - 'clientId', - 'clientSecret', - 'signingSecret', - 'redirectUrl', - ]; - return isRecord(value) && keys.every((k) => typeof value[k] === 'string'); -} - -@Service() -export class SlackAppSetupService { - constructor( - private readonly cacheService: CacheService, - private readonly cipher: Cipher, - private readonly credentialsService: CredentialsService, - private readonly userRepository: UserRepository, - private readonly agentRepository: AgentRepository, - private readonly agentIntegrationPersistenceService: AgentIntegrationPersistenceService, - private readonly chatIntegrationService: ChatIntegrationService, - private readonly urlService: UrlService, - private readonly outboundHttp: OutboundHttp, - ) {} - - async createApp(options: CreateSlackAppOptions): Promise { - const appConfigurationToken = options.appConfigurationToken.trim(); - if (!appConfigurationToken) { - throw new BadRequestError('Slack app configuration token is required'); - } - - const agent = await this.getAgent(options.agentId, options.projectId); - const redirectUrl = this.callbackUrl(options.projectId, options.agentId); - const manifest = this.buildManifest(agent.name, options.projectId, options.agentId, { - redirectUrl, - }); - const response = await this.callSlackApi('apps.manifest.create', { - token: appConfigurationToken, - manifest: JSON.stringify(manifest), - }); - - if (response.ok !== true) { - throw this.slackError('create the Slack app', response); - } - - const credentials = childRecord(response, 'credentials'); - const appId = stringProperty(response, 'app_id'); - const clientId = stringProperty(credentials, 'client_id'); - const clientSecret = stringProperty(credentials, 'client_secret'); - const signingSecret = stringProperty(credentials, 'signing_secret'); - const oauthAuthorizeUrl = stringProperty(response, 'oauth_authorize_url'); - if (!appId || !clientId || !clientSecret || !signingSecret || !oauthAuthorizeUrl) { - throw new BadRequestError('Slack returned an incomplete app setup response'); - } - - const state = randomBytes(32).toString('hex'); - const setupSession = { - projectId: options.projectId, - agentId: options.agentId, - userId: options.user.id, - appId, - clientId, - clientSecret, - signingSecret, - redirectUrl, - } satisfies SlackAppSetupSession; - await this.cacheService.set( - this.cacheKey(state), - await this.cipher.encryptV2(JSON.stringify(setupSession)), - SLACK_APP_SETUP_TTL_MS, - ); - - return { - appId, - installUrl: this.installUrl(oauthAuthorizeUrl, state, redirectUrl), - }; - } - - async getManualManifest( - options: GetSlackAppManifestOptions, - ): Promise { - const agent = await this.getAgent(options.agentId, options.projectId); - return { - manifest: this.buildManifest(agent.name, options.projectId, options.agentId), - }; - } - - async completeInstall(options: CompleteSlackAppInstallOptions): Promise { - const session = await this.consumeSession(options.state); - if (session.projectId !== options.projectId || session.agentId !== options.agentId) { - throw new BadRequestError('Slack app setup state does not match this agent'); - } - - const user = await this.userRepository.findOne({ - where: { id: session.userId }, - relations: ['role'], - }); - if (!user) { - throw new NotFoundError(`User "${session.userId}" not found`); - } - - const agent = await this.getAgent(session.agentId, session.projectId); - const tokenResponse = await this.callSlackApi( - 'oauth.v2.access', - { - code: options.code, - redirect_uri: session.redirectUrl, - }, - { - Authorization: `Basic ${Buffer.from(`${session.clientId}:${session.clientSecret}`).toString( - 'base64', - )}`, - }, - ); - if (tokenResponse.ok !== true) { - throw this.slackError('finish Slack app installation', tokenResponse); - } - - const accessToken = stringProperty(tokenResponse, 'access_token'); - if (!accessToken?.startsWith('xoxb-')) { - throw new BadRequestError('Slack did not return a Bot User OAuth Token'); - } - - const credential = await this.credentialsService.createUnmanagedCredential( - { - name: this.credentialName(agent.name), - type: SLACK_CREDENTIAL_TYPE, - data: { - accessToken, - signatureSecret: session.signingSecret, - }, - projectId: session.projectId, - }, - user, - ); - - const integration = { - type: 'slack', - credentialId: credential.id, - } satisfies AgentIntegrationConfig; - - const savedAgent = await this.agentIntegrationPersistenceService.saveCredentialIntegration( - agent, - integration, - { - user, - modifiedBy: 'user', - broadcast: false, - }, - ); - if (savedAgent.activeVersionId === null) return; - - await this.chatIntegrationService.connect(session.agentId, integration, session.projectId); - await this.chatIntegrationService.broadcastIntegrationChange( - session.agentId, - integration, - 'connect', - ); - } - - private async getAgent(agentId: string, projectId: string): Promise { - const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId); - if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`); - return agent; - } - - private buildManifest( - agentName: string, - projectId: string, - agentId: string, - options: { redirectUrl?: string } = {}, - ): SlackAgentAppManifest { - const slackAppName = this.sanitiseSlackAppName(agentName); - const webhookUrl = this.webhookUrl(projectId, agentId); - return { - display_information: { - name: slackAppName, - }, - features: { - app_home: { - home_tab_enabled: false, - messages_tab_enabled: true, - messages_tab_read_only_enabled: false, - }, - bot_user: { - display_name: slackAppName, - always_online: true, - }, - }, - oauth_config: { - ...(options.redirectUrl ? { redirect_urls: [options.redirectUrl] } : {}), - scopes: { - bot: [...REQUIRED_BOT_SCOPES], - }, - }, - settings: { - event_subscriptions: { - request_url: webhookUrl, - bot_events: [...REQUIRED_BOT_EVENTS], - }, - interactivity: { - is_enabled: true, - request_url: webhookUrl, - }, - org_deploy_enabled: false, - socket_mode_enabled: false, - token_rotation_enabled: false, - }, - }; - } - - private webhookUrl(projectId: string, agentId: string): string { - return `${this.urlService.getWebhookBaseUrl()}rest/projects/${projectId}/agents/v2/${agentId}/webhooks/slack`; - } - - private callbackUrl(projectId: string, agentId: string): string { - return `${this.urlService.getWebhookBaseUrl()}rest/projects/${projectId}/agents/v2/${agentId}/integrations/slack/oauth/callback`; - } - - private installUrl(oauthAuthorizeUrl: string, state: string, redirectUrl: string): string { - try { - const url = new URL(oauthAuthorizeUrl); - url.searchParams.set('state', state); - url.searchParams.set('redirect_uri', redirectUrl); - return url.toString(); - } catch { - throw new BadRequestError('Slack returned an invalid installation URL'); - } - } - - private async consumeSession(state: string): Promise { - const key = this.cacheKey(state); - const cached = await this.cacheService.get(key); - await this.cacheService.delete(key); - if (typeof cached !== 'string') { - throw new BadRequestError('Slack app setup state has expired or is invalid'); - } - - try { - const decrypted = await this.cipher.decryptV2(cached); - const session = jsonParse(decrypted, { fallbackValue: null }); - if (hasSessionShape(session)) { - return session; - } - } catch {} - - throw new BadRequestError('Slack app setup state has expired or is invalid'); - } - - private cacheKey(state: string): string { - return `${SLACK_APP_SETUP_CACHE_PREFIX}${state}`; - } - - private credentialName(agentName: string): string { - return `Slack - ${agentName || DEFAULT_SLACK_APP_NAME}`.slice(0, 128); - } - - private sanitiseSlackAppName(raw: string): string { - const cleaned = raw - .replace(/[^a-zA-Z0-9 ._-]/g, '') - .replace(/\s+/g, ' ') - .trim() - .slice(0, 35); - return cleaned.length > 0 ? cleaned : DEFAULT_SLACK_APP_NAME; - } - - private async callSlackApi( - method: string, - params: Record, - headers: Record = {}, - ): Promise> { - try { - const response = await this.outboundHttp - .requests({ - ssrf: 'disabled', // the Slack API host is fixed and public - }) - .request({ - method: 'POST', - url: `https://slack.com/api/${method}`, - headers: { - ...headers, - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: params, - returnFullResponse: true, - ignoreHttpStatusErrors: true, // Status errors are ignored because Slack signals failures in the JSON body - }); - const data: unknown = response.body; - if (!isRecord(data)) { - return { ok: false, error: 'invalid_response' }; - } - return data; - } catch { - return { ok: false, error: 'slack_request_failed' }; - } - } - - private slackError(action: string, response: Record): BadRequestError { - const error = stringProperty(response, 'error') ?? 'unknown_error'; - return new BadRequestError(`Slack could not ${action}: ${error}`); - } -} diff --git a/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts b/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts index 3b7e53393a0..3fc4e671c02 100644 --- a/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts @@ -30,6 +30,7 @@ import { CredentialsService } from '@/credentials/credentials.service'; import type { EventService } from '@/events/event.service'; import { AgentConfigService } from '@/modules/agents/agent-config.service'; import { AgentCustomToolsService } from '@/modules/agents/agent-custom-tools.service'; +import { AgentIntegrationManagementService } from '@/modules/agents/agent-integration-management.service'; import { AgentIntegrationPersistenceService } from '@/modules/agents/agent-integration-persistence.service'; import { AgentModelCatalogService } from '@/modules/agents/agent-model-catalog.service'; import { AgentModificationTelemetryService } from '@/modules/agents/agent-modification-telemetry.service'; @@ -46,8 +47,6 @@ import { AgentValidationService } from '@/modules/agents/agent-validation.servic import { AgentsService } from '@/modules/agents/agents.service'; import { AttachableWorkflowsService } from '@/modules/agents/attachable-workflows.service'; import type { Agent } from '@/modules/agents/entities/agent.entity'; -import { ChatIntegrationRegistry } from '@/modules/agents/integrations/agent-chat-integration'; -import { ChatIntegrationService } from '@/modules/agents/integrations/chat-integration.service'; import type { NodeToolAiGatewayService } from '@/modules/agents/json-config/node-tool-ai-gateway.service'; import type { AgentTaskRepository } from '@/modules/agents/repositories/agent-task.repository'; import type { AgentRepository } from '@/modules/agents/repositories/agent.repository'; @@ -134,8 +133,7 @@ describe('McpAgentToolsService', () => { const agentCustomToolsService = mockInstance(AgentCustomToolsService); const agentSecureRuntime = mockInstance(AgentSecureRuntime); const integrationPersistenceService = mockInstance(AgentIntegrationPersistenceService); - const chatIntegrationService = mockInstance(ChatIntegrationService); - const chatIntegrationRegistry = mockInstance(ChatIntegrationRegistry); + const integrationManagementService = mockInstance(AgentIntegrationManagementService); const mcpRegistryService = mockInstance(McpRegistryService); const outboundHttp = mockInstance(OutboundHttp); const urlService = mockInstance(UrlService); @@ -154,8 +152,7 @@ describe('McpAgentToolsService', () => { agentCustomToolsService, agentSecureRuntime, integrationPersistenceService, - chatIntegrationService, - chatIntegrationRegistry, + integrationManagementService, mockInstance(AgentModelCatalogService), mockInstance(AttachableWorkflowsService), mcpRegistryService, @@ -1812,19 +1809,13 @@ describe('McpAgentToolsService', () => { beforeEach(() => { agentsService.findByIdForUser.mockResolvedValue(agentEntity({ activeVersionId: 'v1' })); - credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([ - { id: 'cred-1', type: 'slackApi', name: 'Slack cred' }, - ] as never); - chatIntegrationRegistry.require.mockReturnValue({ - credentialTypes: ['slackApi'], - displayLabel: 'Slack', - } as never); - integrationPersistenceService.saveCredentialIntegration.mockResolvedValue( - agentEntity({ + integrationManagementService.connect.mockResolvedValue({ + integration: { type: 'slack', credentialId: 'cred-1' }, + savedAgent: agentEntity({ activeVersionId: 'v1', integrations: [{ type: 'slack', credentialId: 'cred-1' }], }), - ); + }); }); it('persists and connects the channel without publishing for a published Agent', async () => { @@ -1834,11 +1825,12 @@ describe('McpAgentToolsService', () => { const result = await callTool('update_agent_integration', input); - expect(integrationPersistenceService.saveCredentialIntegration).toHaveBeenCalledWith( - expect.objectContaining({ id: 'agent-1' }), - { type: 'slack', credentialId: 'cred-1' }, - { user, modifiedBy: 'mcp', broadcast: false }, - ); + expect(integrationManagementService.connect).toHaveBeenCalledWith({ + agent: expect.objectContaining({ id: 'agent-1' }), + user, + integration: { type: 'slack', credentialId: 'cred-1' }, + modifiedBy: 'mcp', + }); expect(agentPublishService.publishAgent).not.toHaveBeenCalled(); expect(userHasScopesMock).toHaveBeenCalledWith(user, ['agent:update'], false, { projectId: 'project-1', @@ -1846,16 +1838,6 @@ describe('McpAgentToolsService', () => { expect(userHasScopesMock).not.toHaveBeenCalledWith(user, ['agent:publish'], false, { projectId: 'project-1', }); - expect(chatIntegrationService.connect).toHaveBeenCalledWith( - 'agent-1', - { type: 'slack', credentialId: 'cred-1' }, - 'project-1', - ); - expect(chatIntegrationService.broadcastIntegrationChange).toHaveBeenCalledWith( - 'agent-1', - { type: 'slack', credentialId: 'cred-1' }, - 'connect', - ); expect(result.structuredContent).toMatchObject({ ok: true, configured: true, @@ -1867,23 +1849,23 @@ describe('McpAgentToolsService', () => { it('persists without publishing, connecting, or broadcasting for an unpublished Agent', async () => { agentsService.findByIdForUser.mockResolvedValue(agentEntity({ activeVersionId: null })); - integrationPersistenceService.saveCredentialIntegration.mockResolvedValue( - agentEntity({ + integrationManagementService.connect.mockResolvedValue({ + integration: { type: 'slack', credentialId: 'cred-1' }, + savedAgent: agentEntity({ activeVersionId: null, integrations: [{ type: 'slack', credentialId: 'cred-1' }], }), - ); + }); const result = await callTool('update_agent_integration', input); - expect(integrationPersistenceService.saveCredentialIntegration).toHaveBeenCalledWith( - expect.objectContaining({ id: 'agent-1' }), - { type: 'slack', credentialId: 'cred-1' }, - { user, modifiedBy: 'mcp', broadcast: false }, - ); + expect(integrationManagementService.connect).toHaveBeenCalledWith({ + agent: expect.objectContaining({ id: 'agent-1' }), + user, + integration: { type: 'slack', credentialId: 'cred-1' }, + modifiedBy: 'mcp', + }); expect(agentPublishService.publishAgent).not.toHaveBeenCalled(); - expect(chatIntegrationService.connect).not.toHaveBeenCalled(); - expect(chatIntegrationService.broadcastIntegrationChange).not.toHaveBeenCalled(); expect(result.structuredContent).toMatchObject({ ok: true, configured: true, @@ -1894,19 +1876,22 @@ describe('McpAgentToolsService', () => { }); it('requires settings for telegram integrations', async () => { + integrationManagementService.connect.mockRejectedValueOnce( + new Error('Telegram integration settings are required'), + ); const result = await callTool('update_agent_integration', { ...input, type: 'telegram' }); expect(result.isError).toBe(true); expect(result.structuredContent).toMatchObject({ error: 'Telegram integration settings are required', }); - expect(integrationPersistenceService.saveCredentialIntegration).not.toHaveBeenCalled(); + expect(integrationManagementService.connect).toHaveBeenCalled(); }); it('rejects a credential whose type the integration does not support', async () => { - credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([ - { id: 'cred-1', type: 'telegramApi', name: 'Telegram cred' }, - ] as never); + integrationManagementService.connect.mockRejectedValueOnce( + new Error('Slack integrations do not support telegramApi credentials'), + ); const result = await callTool('update_agent_integration', input); @@ -1914,7 +1899,7 @@ describe('McpAgentToolsService', () => { expect(result.structuredContent).toMatchObject({ error: 'Slack integrations do not support telegramApi credentials', }); - expect(integrationPersistenceService.saveCredentialIntegration).not.toHaveBeenCalled(); + expect(integrationManagementService.connect).toHaveBeenCalled(); }); }); @@ -1928,9 +1913,9 @@ describe('McpAgentToolsService', () => { }; beforeEach(() => { - integrationPersistenceService.removeCredentialIntegration.mockResolvedValue( - agentEntity({ integrations: [] }), - ); + integrationManagementService.disconnect.mockResolvedValue({ + savedAgent: agentEntity({ integrations: [] }), + }); }); it('disconnects a persisted integration and removes its record', async () => { @@ -1939,14 +1924,13 @@ describe('McpAgentToolsService', () => { const result = await callTool('update_agent_integration', input); - expect(chatIntegrationService.disconnectChannel).toHaveBeenCalledWith('agent-1', persisted); - expect(chatIntegrationService.disconnect).not.toHaveBeenCalled(); - expect(integrationPersistenceService.removeCredentialIntegration).toHaveBeenCalledWith( - expect.objectContaining({ id: 'agent-1' }), - 'slack', - 'cred-1', - { user, modifiedBy: 'mcp', broadcast: false }, - ); + expect(integrationManagementService.disconnect).toHaveBeenCalledWith({ + agent: expect.objectContaining({ id: 'agent-1' }), + user, + type: 'slack', + credentialId: 'cred-1', + modifiedBy: 'mcp', + }); expect(result.structuredContent).toMatchObject({ ok: true, connected: false }); }); @@ -1955,11 +1939,7 @@ describe('McpAgentToolsService', () => { const result = await callTool('update_agent_integration', input); - expect(chatIntegrationService.disconnectChannel).toHaveBeenCalledWith('agent-1', { - type: 'slack', - credentialId: 'cred-1', - }); - expect(integrationPersistenceService.removeCredentialIntegration).toHaveBeenCalled(); + expect(integrationManagementService.disconnect).toHaveBeenCalled(); expect(result.structuredContent).toMatchObject({ ok: true, connected: false }); }); @@ -1968,12 +1948,9 @@ describe('McpAgentToolsService', () => { await callTool('update_agent_integration', { ...input, type: 'bogus' }); - expect(chatIntegrationService.disconnectChannel).not.toHaveBeenCalled(); - expect(chatIntegrationService.disconnect).toHaveBeenCalledWith('agent-1', { - type: 'bogus', - credentialId: 'cred-1', - }); - expect(integrationPersistenceService.removeCredentialIntegration).toHaveBeenCalled(); + expect(integrationManagementService.disconnect).toHaveBeenCalledWith( + expect.objectContaining({ type: 'bogus', credentialId: 'cred-1' }), + ); }); }); diff --git a/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts b/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts index 522adb412d5..034034342f6 100644 --- a/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts +++ b/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts @@ -7,7 +7,6 @@ import { } from '@n8n/ai-utilities/agent-config'; import { AGENT_MODEL_PROVIDERS, - AgentIntegrationSchema, AgentJsonConfigBaseSchema, AgentJsonConfigSchema, isDraftAgentConfig, @@ -32,6 +31,7 @@ import { CredentialsService } from '@/credentials/credentials.service'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { AgentConfigService } from '@/modules/agents/agent-config.service'; import { AgentCustomToolsService } from '@/modules/agents/agent-custom-tools.service'; +import { AgentIntegrationManagementService } from '@/modules/agents/agent-integration-management.service'; import { AgentIntegrationPersistenceService } from '@/modules/agents/agent-integration-persistence.service'; import { AgentModelCatalogService } from '@/modules/agents/agent-model-catalog.service'; import { AgentPublishService } from '@/modules/agents/agent-publish.service'; @@ -48,8 +48,6 @@ import { AgentValidationService } from '@/modules/agents/agent-validation.servic import { AgentsService } from '@/modules/agents/agents.service'; import { AttachableWorkflowsService } from '@/modules/agents/attachable-workflows.service'; import type { Agent } from '@/modules/agents/entities/agent.entity'; -import { ChatIntegrationRegistry } from '@/modules/agents/integrations/agent-chat-integration'; -import { ChatIntegrationService } from '@/modules/agents/integrations/chat-integration.service'; import { composeJsonConfig } from '@/modules/agents/json-config/agent-config-composition'; import { listMcpServerTools } from '@/modules/agents/json-config/mcp-client-factory'; import { sanitizeUnknownAgentCredentials } from '@/modules/agents/json-config/sanitize-unknown-agent-credentials'; @@ -391,8 +389,7 @@ export class McpAgentToolsService { private readonly agentCustomToolsService: AgentCustomToolsService, private readonly agentSecureRuntime: AgentSecureRuntime, private readonly integrationPersistenceService: AgentIntegrationPersistenceService, - private readonly chatIntegrationService: ChatIntegrationService, - private readonly chatIntegrationRegistry: ChatIntegrationRegistry, + private readonly integrationManagementService: AgentIntegrationManagementService, private readonly agentModelCatalogService: AgentModelCatalogService, private readonly attachableWorkflowsService: AttachableWorkflowsService, private readonly mcpRegistryService: McpRegistryService, @@ -1640,35 +1637,17 @@ export class McpAgentToolsService { await this.assertScope(user, projectId, 'agent:update'); return input.action === 'disconnect' ? await this.disconnectIntegration(user, input, agent) - : await this.connectIntegration(user, input, agent, projectId); + : await this.connectIntegration(user, input, agent); } private async disconnectIntegration(user: User, input: UpdateIntegrationInput, agent: Agent) { - const persisted = (agent.integrations ?? []).find( - (item) => item.type === input.type && item.credentialId === input.credentialId, - ); - // Mirrors AgentIntegrationsController.disconnectIntegration: tear down - // the runtime channel even when persistence has no matching record - // (e.g. the integration was removed via a config mutation). - const parsed = AgentIntegrationSchema.safeParse({ + const { savedAgent: saved } = await this.integrationManagementService.disconnect({ + agent, + user, type: input.type, credentialId: input.credentialId, + modifiedBy: 'mcp', }); - const integration = persisted ?? (parsed.success ? parsed.data : undefined); - if (integration) { - await this.chatIntegrationService.disconnectChannel(input.agentId, integration); - } else { - await this.chatIntegrationService.disconnect(input.agentId, { - type: input.type, - credentialId: input.credentialId, - }); - } - const saved = await this.integrationPersistenceService.removeCredentialIntegration( - agent, - input.type, - input.credentialId, - { user, modifiedBy: 'mcp', broadcast: false }, - ); return { ok: true, agentId: input.agentId, @@ -1680,39 +1659,18 @@ export class McpAgentToolsService { }; } - private async connectIntegration( - user: User, - input: UpdateIntegrationInput, - agent: Agent, - projectId: string, - ) { + private async connectIntegration(user: User, input: UpdateIntegrationInput, agent: Agent) { const candidate = { type: input.type, credentialId: input.credentialId, ...(input.settings ? { settings: input.settings } : {}), }; - const parsed = AgentIntegrationSchema.safeParse(candidate); - if (!parsed.success) throw new UserError(`Invalid integration: ${parsed.error.message}`); - if (parsed.data.type === 'telegram' && !parsed.data.settings) { - throw new UserError('Telegram integration settings are required'); - } - - const credential = await this.requireAccessibleCredential( - this.credentialProvider(user, projectId), - input.credentialId, - ); - const implementation = this.chatIntegrationRegistry.require(parsed.data.type); - if (!implementation.credentialTypes.includes(credential.type)) { - throw new UserError( - `${implementation.displayLabel} integrations do not support ${credential.type} credentials`, - ); - } - - const saved = await this.integrationPersistenceService.saveCredentialIntegration( + const { savedAgent: saved } = await this.integrationManagementService.connect({ agent, - parsed.data, - { user, modifiedBy: 'mcp', broadcast: false }, - ); + user, + integration: candidate, + modifiedBy: 'mcp', + }); const result = { ok: true, agentId: input.agentId, @@ -1724,12 +1682,6 @@ export class McpAgentToolsService { }; if (saved.activeVersionId === null) return { ...result, connected: false }; - await this.chatIntegrationService.connect(input.agentId, parsed.data, projectId); - await this.chatIntegrationService.broadcastIntegrationChange( - input.agentId, - parsed.data, - 'connect', - ); return { ...result, connected: true, diff --git a/packages/cli/src/services/cache/__tests__/cache.service.test.ts b/packages/cli/src/services/cache/__tests__/cache.service.test.ts index 31119daaf55..00a60534a6c 100644 --- a/packages/cli/src/services/cache/__tests__/cache.service.test.ts +++ b/packages/cli/src/services/cache/__tests__/cache.service.test.ts @@ -202,6 +202,15 @@ for (const backend of ['memory', 'redis'] as const) { } }); + describe('take', () => { + test('should return a value only once', async () => { + await cacheService.set('single-use', 'value'); + + await expect(cacheService.take('single-use')).resolves.toBe('value'); + await expect(cacheService.take('single-use')).resolves.toBeUndefined(); + }); + }); + describe('delete', () => { test('should delete a key', async () => { await cacheService.set('key', 'value'); diff --git a/packages/cli/src/services/cache/cache.service.ts b/packages/cli/src/services/cache/cache.service.ts index 780e8acf2e6..5fa772b1e89 100644 --- a/packages/cli/src/services/cache/cache.service.ts +++ b/packages/cli/src/services/cache/cache.service.ts @@ -23,6 +23,8 @@ type CacheEvents = { @Service() export class CacheService extends TypedEmitter { + private readonly takingKeys = new Set(); + constructor(private readonly globalConfig: GlobalConfig) { super(); } @@ -210,6 +212,26 @@ export class CacheService extends TypedEmitter { return fallbackValue; } + /** Atomically retrieve and delete a primitive value. */ + async take(key: string): Promise { + if (!this.cache) await this.init(); + if (!key) return undefined; + + if (this.cache.kind === 'redis') { + return await this.cache.store.getdel(key); + } + + if (this.takingKeys.has(key)) return undefined; + this.takingKeys.add(key); + try { + const value = await this.cache.store.get(key); + await this.cache.store.del(key); + return value; + } finally { + this.takingKeys.delete(key); + } + } + /** * Retrieve a [Redis hash](https://redis.io/docs/data-types/hashes/) under a key. * If in-memory, the hash is a regular JS object. To retrieve a primitive value diff --git a/packages/cli/src/services/cache/redis.cache-manager.ts b/packages/cli/src/services/cache/redis.cache-manager.ts index f3a9e0b6b33..c106f77538a 100644 --- a/packages/cli/src/services/cache/redis.cache-manager.ts +++ b/packages/cli/src/services/cache/redis.cache-manager.ts @@ -32,6 +32,7 @@ export type RedisCache = Cache; export interface RedisStore extends Store { readonly isCacheable: (value: unknown) => boolean; get client(): Redis | Cluster; + getdel(key: string): Promise; hget(key: string, field: string): Promise; hgetall(key: string): Promise | undefined>; hset(key: string, fieldValueRecord: Record): Promise; @@ -57,6 +58,11 @@ function builder( if (val === undefined || val === null) return undefined; else return jsonParse(val); }, + async getdel(key: string) { + const val = await redisCache.getdel(key); + if (val === undefined || val === null) return undefined; + return jsonParse(val); + }, async expire(key: string, ttlSeconds: number) { await redisCache.expire(key, ttlSeconds); }, diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index c6eb56693ad..ddde237107b 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -8004,6 +8004,7 @@ "agents.channels.modal.description": "Choose a channel to connect to your agent. Channels allow your agent to receive messages and respond to events.", "agents.channels.modal.connectDescription": "Connect your {channel} account", "agents.channels.modal.setupLoadError": "Channel setup couldn't load. Try again.", + "agents.channels.modal.setupPlaceholder": "Setup view for {channel} will go here.", "agents.channels.modal.editPlaceholder": "Edit view for {channel} will go here.", "agents.channels.modal.configured": "Configured", "agents.channels.modal.connected": "Connected", diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelListItem.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelListItem.test.ts index 6bfad52c05a..27603f450da 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelListItem.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelListItem.test.ts @@ -16,7 +16,12 @@ const integration = { function mountItem(configured: boolean, connected: boolean) { return mount(AgentChannelListItem, { - props: { integration, configured, connected }, + props: { + integration, + configured, + connected, + connectAction: { label: 'generic.connect' }, + }, global: { stubs: { N8nButton: { template: '' }, @@ -43,4 +48,28 @@ describe('AgentChannelListItem', () => { connected, ); }); + + it('renders registry-provided connect action metadata', () => { + const wrapper = mount(AgentChannelListItem, { + props: { + integration, + configured: false, + connected: false, + connectAction: { label: 'Add to Slack', icon: 'plus' }, + }, + global: { + stubs: { + N8nButton: { + props: ['icon'], + template: '', + }, + N8nIcon: { template: '' }, + N8nText: { template: '' }, + }, + }, + }); + + expect(wrapper.get('button').text()).toContain('Add to Slack'); + expect(wrapper.get('button').attributes('data-icon')).toBe('plus'); + }); }); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelModal.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelModal.test.ts index 00549c71c80..5d191af23d1 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelModal.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelModal.test.ts @@ -1,47 +1,71 @@ import { flushPromises, mount } from '@vue/test-utils'; import { ref } from 'vue'; -import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -import AgentChannelModal from '../components/AgentChannelModal.vue'; +import AgentChannelModal, { type ChannelView } from '../components/AgentChannelModal.vue'; -vi.mock('@n8n/i18n', () => ({ - useI18n: () => ({ - baseText: (key: string) => key, - }), +const mocks = vi.hoisted(() => ({ + connect: vi.fn(), + disconnect: vi.fn(), + fetchStatus: vi.fn(), + beforeSave: vi.fn(), + ensureAgentPersisted: vi.fn(), })); const catalog = ref([ - { type: 'slack', label: 'Slack', icon: 'zap' }, - { type: 'linear', label: 'Linear', icon: 'zap' }, - { type: 'telegram', label: 'Telegram', icon: 'zap' }, + { + type: 'example', + label: 'Example', + icon: 'zap', + credentialTypes: ['exampleApi'], + }, ]); - -const integrationSettings = ref({ - slack: { accessMode: 'all' }, - linear: { accessMode: 'all' }, - telegram: { accessMode: 'all' }, -}); +const statuses = ref>({}); const connectedCredentials = ref>({}); const selectedCredentials = ref>({}); const loadingMap = ref>({}); -const fetchStatusMock = vi.fn().mockResolvedValue(undefined); -const connectMock = vi.fn( - async (channelType: string, credentialId: string): Promise<{ status: string }> => { - connectedCredentials.value[channelType] = credentialId; - return { status: 'connected' }; - }, -); -const disconnectMock = vi - .fn() - .mockImplementation(async (channelType: string, credentialId: string) => { - if (connectedCredentials.value[channelType] === credentialId) { - delete connectedCredentials.value[channelType]; - } - }); -const createCredentialMock = vi.fn(); -const editCredentialMock = vi.fn(); -const setupSlackAppMock = vi.fn().mockResolvedValue(true); -const vueErrorHandlerMock = vi.fn(); + +vi.mock('@n8n/i18n', () => ({ + useI18n: () => ({ baseText: (key: string) => key }), +})); + +vi.mock('../channels/registry', () => { + const platformView = { + props: ['modelValue', 'mode', 'isPublished'], + emits: ['update:modelValue', 'connect'], + setup: () => ({ + currentSettings: { accessMode: 'all' }, + validationError: null, + beforeSave: mocks.beforeSave, + }), + template: ` +
+
+ `, + }; + const platform = { + type: 'example', + setupComponent: platformView, + editComponent: platformView, + getConnectAction: () => ({ label: 'Connect example', icon: 'zap' }), + getConnectedDescription: () => 'Example connected', + }; + const runtime = { + loading: { value: false }, + load: vi.fn().mockResolvedValue(undefined), + }; + return { + agentChannelPlatforms: { example: platform }, + getAgentChannelPlatform: () => platform, + createAgentChannelRuntime: () => runtime, + }; +}); vi.mock('../composables/useAgentIntegrationsCatalog', () => ({ useAgentIntegrationsCatalog: () => ({ @@ -52,127 +76,79 @@ vi.mock('../composables/useAgentIntegrationsCatalog', () => ({ vi.mock('../composables/useAgentIntegrationStatus', () => ({ useAgentIntegrationStatus: () => ({ - fetchStatus: fetchStatusMock, + fetchStatus: mocks.fetchStatus, connectedCredentials, - integrationSettings, + integrationSettings: ref({ example: { accessMode: 'all' } }), loadingMap, errorMessages: ref({}), errorIsConflict: ref({}), - isConnected: () => false, - isConfigured: (channelType: string) => Boolean(connectedCredentials.value[channelType]), - connect: connectMock, - disconnect: disconnectMock, + isConnected: (type: string) => statuses.value[type] === 'connected', + isConfigured: (type: string) => + ['configured', 'connected'].includes(statuses.value[type] ?? 'disconnected'), + connect: mocks.connect, + disconnect: mocks.disconnect, }), })); vi.mock('../composables/useAgentChannelSetup', () => ({ useAgentChannelSetup: () => ({ - channelSetupRef: ref(), selectedCredentials, credentialsLoading: ref(false), - credentialPermissions: ref({}), + credentialPermissions: ref({ create: true }), credentialModalOpen: ref(false), - getChannelCredentialId: (channelType: string | null | undefined) => - (channelType && - (selectedCredentials.value[channelType] || connectedCredentials.value[channelType])) || - '', - getCredentials: (channelType: string) => [ - { id: `${channelType}-credential`, name: `${channelType} credential` }, - { id: `${channelType}-credential-new`, name: `New ${channelType} credential` }, + getChannelCredentialId: (type?: string | null) => + type ? (selectedCredentials.value[type] ?? connectedCredentials.value[type] ?? '') : '', + getCredentials: () => [ + { id: 'credential-old', name: 'Old credential' }, + { id: 'credential-new', name: 'New credential' }, ], - loadChannelState: vi.fn().mockImplementation(async () => { - for (const [channelType, credentialId] of Object.entries(connectedCredentials.value)) { - if (!selectedCredentials.value[channelType]) { - selectedCredentials.value[channelType] = credentialId; - } - } - }), - createCredential: createCredentialMock, - editCredential: editCredentialMock, - setupSlackApp: setupSlackAppMock, + loadChannelState: vi.fn().mockResolvedValue(undefined), + createCredential: vi.fn(), + editCredential: vi.fn(), }), })); -const channelSetupStub = (testId: string) => ({ - props: ['mode', 'loading', 'connectedDescription', 'setupSlackApp'], - emits: ['connect'], - setup: () => ({ - currentSettings: { accessMode: 'all' }, - validationError: null, - }), - template: `
-
`, -}); - -function mountModal(props: Record) { +function mountModal(view: ChannelView = 'example_setup', isPublished = false) { return mount(AgentChannelModal, { props: { open: true, agentId: 'agent-1', projectId: 'project-1', - isPublished: false, - view: 'linear_setup', - ...props, + view, + isPublished, + ensureAgentPersisted: mocks.ensureAgentPersisted, }, global: { - config: { - errorHandler: vueErrorHandlerMock, - }, stubs: { - // The N8nDialog family's SFCs don't set an explicit `defineOptions({ name })`, - // so Vue infers the component name from the *filename* (Dialog.vue, - // DialogHeader.vue, ...) rather than the `N8n`-prefixed name they're imported - // under -- stubs must be keyed by that inferred name to be picked up. Dialog: { props: ['open', 'showCloseButton'], emits: ['update:open'], template: - '
', + '
', }, DialogHeader: { template: '
' }, DialogTitle: { template: '

' }, DialogFooter: { template: '
' }, N8nButton: { + props: ['disabled'], emits: ['click'], - template: '', - }, - N8nIconButton: { - emits: ['click'], - template: '', + template: '', }, + N8nIconButton: { template: '' }, N8nIcon: { template: '' }, N8nText: { template: '' }, AgentChannelListItem: { - props: ['configured', 'connected'], - template: - '
  • ', - }, - AgentChannelSlackSetup: channelSetupStub('slack-setup'), - AgentChannelLinearSetup: channelSetupStub('linear-setup'), - AgentChannelTelegramSetup: channelSetupStub('telegram-setup'), - AgentIntegrationCredentialConnection: { - props: ['integrationType', 'modelValue', 'disabled'], - emits: ['update:modelValue', 'create', 'edit'], + props: ['integration', 'configured', 'connected', 'connectAction'], + emits: ['setup'], template: ` -
    -
    +
  • `, }, }, @@ -182,359 +158,88 @@ function mountModal(props: Record) { describe('AgentChannelModal', () => { beforeEach(() => { + vi.clearAllMocks(); + statuses.value = {}; connectedCredentials.value = {}; selectedCredentials.value = {}; loadingMap.value = {}; - vi.clearAllMocks(); - }); - - it('renders the channel list for the list view', () => { - const wrapper = mountModal({ view: 'list' }); - - expect(wrapper.findAll('[data-testid="channel-list-item"]')).toHaveLength(catalog.value.length); - }); - - it('does not describe a configured draft channel as connected', async () => { - connectedCredentials.value.linear = 'linear-credential'; - const wrapper = mountModal({ view: 'linear_setup' }); - await flushPromises(); - - expect( - wrapper.get('[data-testid="linear-setup"]').attributes('data-connected-description'), - ).toBe(''); - }); - - it('renders the per-channel setup view for a setup view', () => { - const wrapper = mountModal({ view: 'linear_setup' }); - - const linearSetup = wrapper.find('[data-testid="linear-setup"]'); - expect(linearSetup.attributes('data-mode')).toBe('setup'); - }); - - it('waits for a pending agent to persist before saving a channel configuration', async () => { - let finishPersisting = () => {}; - const persisting = new Promise((resolve) => { - finishPersisting = resolve; - }); - selectedCredentials.value.linear = 'linear-credential'; - const wrapper = mountModal({ - view: 'linear_setup', - ensureAgentPersisted: async () => await persisting, - }); - - await wrapper.get('[data-testid="linear-setup-connect"]').trigger('click'); - await flushPromises(); - - expect(connectMock).not.toHaveBeenCalled(); - - finishPersisting(); - await flushPromises(); - - expect(connectMock).toHaveBeenCalledWith('linear', 'linear-credential', { - accessMode: 'all', - }); - }); - - it('waits for a pending agent to persist before starting Slack app setup', async () => { - let finishPersisting = () => {}; - const persisting = new Promise((resolve) => { - finishPersisting = resolve; - }); - const wrapper = mountModal({ - view: 'slack_setup', - ensureAgentPersisted: async () => await persisting, - }); - - await wrapper.get('[data-testid="slack-setup-automatic-setup"]').trigger('click'); - await flushPromises(); - - expect(setupSlackAppMock).not.toHaveBeenCalled(); - - finishPersisting(); - await flushPromises(); - - expect(setupSlackAppMock).toHaveBeenCalledWith('app-token', expect.any(Function)); - }); - - it('renders the per-channel edit view for an edit view', () => { - const wrapper = mountModal({ view: 'linear_edit' }); - - const linearSetup = wrapper.find('[data-testid="linear-setup"]'); - expect(linearSetup.attributes('data-mode')).toBe('edit'); - }); - - it.each(['slack', 'linear', 'telegram'])( - 'renders the configured credential and edit action for the %s edit view', - async (channelType) => { - connectedCredentials.value[channelType] = `${channelType}-credential`; - const wrapper = mountModal({ - view: `${channelType}_edit`, - }); - await flushPromises(); - - const credentialConnection = wrapper.get('[data-testid="shared-credential-connection"]'); - expect(credentialConnection.attributes('data-channel-type')).toBe(channelType); - expect(credentialConnection.attributes('data-credential-id')).toBe( - `${channelType}-credential`, - ); - - await credentialConnection.get('[data-testid="edit-credential"]').trigger('click'); - expect(editCredentialMock).toHaveBeenCalledOnce(); - }, - ); - - it.each(['slack', 'linear', 'telegram'])( - 'removes the exact %s channel binding and closes the modal', - async (channelType) => { - connectedCredentials.value[channelType] = `${channelType}-credential`; - const wrapper = mountModal({ - view: `${channelType}_edit`, - }); - await flushPromises(); - - await wrapper.get('[data-testid="agent-channel-remove-channel"]').trigger('click'); - await flushPromises(); - - expect(disconnectMock).toHaveBeenCalledWith(channelType, `${channelType}-credential`); - expect(fetchStatusMock).toHaveBeenCalledWith([channelType]); - expect(connectMock).not.toHaveBeenCalled(); - expect(createCredentialMock).not.toHaveBeenCalled(); - expect(wrapper.emitted('channel-disconnected')).toEqual([[channelType]]); - expect(wrapper.emitted('agent-changed')).toHaveLength(1); - expect(wrapper.emitted('update:open')).toEqual([[false]]); - }, - ); - - it('keeps the channel listed when another binding of the same type remains', async () => { - connectedCredentials.value.linear = 'linear-credential'; - disconnectMock.mockImplementationOnce(async () => { - connectedCredentials.value.linear = 'linear-credential-other'; - }); - const wrapper = mountModal({ - view: 'linear_edit', - }); - await flushPromises(); - - await wrapper.get('[data-testid="agent-channel-remove-channel"]').trigger('click'); - await flushPromises(); - - expect(disconnectMock).toHaveBeenCalledWith('linear', 'linear-credential'); - expect(fetchStatusMock).toHaveBeenCalledWith(['linear']); - expect(wrapper.emitted('channel-disconnected')).toBeUndefined(); - expect(wrapper.emitted('update:open')).toEqual([[false]]); - }); - - it('connects a replacement credential before detaching the original binding and refetching status', async () => { - connectedCredentials.value.linear = 'linear-credential'; - const wrapper = mountModal({ - view: 'linear_edit', - }); - await flushPromises(); - - await wrapper.get('[data-testid="change-credential"]').trigger('click'); - await wrapper.get('[data-testid="agent-channel-save-channel-config"]').trigger('click'); - await flushPromises(); - - expect(connectMock).toHaveBeenCalledWith('linear', 'linear-credential-new', { - accessMode: 'all', - }); - expect(disconnectMock).toHaveBeenCalledWith('linear', 'linear-credential'); - expect(fetchStatusMock).toHaveBeenCalledWith(['linear']); - expect(connectMock.mock.invocationCallOrder[0]).toBeLessThan( - disconnectMock.mock.invocationCallOrder[0], - ); - expect(disconnectMock.mock.invocationCallOrder[0]).toBeLessThan( - fetchStatusMock.mock.invocationCallOrder[0], - ); - }); - - it('finishes replacing the original binding if the view changes while connecting', async () => { - connectedCredentials.value.linear = 'linear-credential'; - let finishConnect = () => {}; - const connectPending = new Promise((resolve) => { - finishConnect = resolve; - }); - connectMock.mockImplementationOnce(async (channelType: string, credentialId: string) => { - await connectPending; - connectedCredentials.value[channelType] = credentialId; + mocks.connect.mockImplementation(async (type: string, credentialId: string) => { + statuses.value[type] = 'connected'; + connectedCredentials.value[type] = credentialId; return { status: 'connected' }; }); - const wrapper = mountModal({ - view: 'linear_edit', + mocks.disconnect.mockImplementation(async (type: string) => { + statuses.value[type] = 'disconnected'; + delete connectedCredentials.value[type]; + return { status: 'disconnected' }; }); - await flushPromises(); - - await wrapper.get('[data-testid="change-credential"]').trigger('click'); - await wrapper.get('[data-testid="agent-channel-save-channel-config"]').trigger('click'); - await wrapper.setProps({ view: 'list' }); - finishConnect(); - await flushPromises(); - - expect(disconnectMock).toHaveBeenCalledWith('linear', 'linear-credential'); - expect(fetchStatusMock).toHaveBeenCalledWith(['linear']); + mocks.fetchStatus.mockResolvedValue(undefined); + mocks.beforeSave.mockResolvedValue(undefined); + mocks.ensureAgentPersisted.mockResolvedValue(undefined); }); - it('prevents the dialog from closing while a replacement is connecting', async () => { - connectedCredentials.value.linear = 'linear-credential'; - let finishConnect = () => {}; - const connectPending = new Promise((resolve) => { - finishConnect = resolve; - }); - connectMock.mockImplementationOnce(async (channelType: string, credentialId: string) => { - loadingMap.value[channelType] = true; - try { - await connectPending; - connectedCredentials.value[channelType] = credentialId; - return { status: 'connected' }; - } finally { - loadingMap.value[channelType] = false; - } - }); - const wrapper = mountModal({ - view: 'linear_edit', - }); + it('uses registry metadata and setup rendering without platform checks', async () => { + const list = mountModal('list'); await flushPromises(); + expect(list.get('[data-testid="channel-list-item"]').attributes('data-action')).toBe( + 'Connect example', + ); - await wrapper.get('[data-testid="change-credential"]').trigger('click'); - await wrapper.get('[data-testid="agent-channel-save-channel-config"]').trigger('click'); - await flushPromises(); - expect(wrapper.find('[data-testid="close-dialog"]').exists()).toBe(false); - await wrapper.get('[data-testid="escape-dialog"]').trigger('click'); - - expect(wrapper.emitted('update:open')).toBeUndefined(); - - finishConnect(); - await flushPromises(); - - expect(disconnectMock).toHaveBeenCalledWith('linear', 'linear-credential'); - expect(wrapper.emitted('update:open')).toEqual([[false]]); + const setup = mountModal(); + expect(setup.get('[data-testid="platform-view"]').attributes('data-mode')).toBe('setup'); }); - it('resets an unsaved credential change when the edit modal reopens', async () => { - connectedCredentials.value.linear = 'linear-credential'; - const wrapper = mountModal({ - view: 'linear_edit', + it('presents configured and connected as distinct list states', async () => { + statuses.value.example = 'configured'; + const configured = mountModal('list'); + await flushPromises(); + expect(configured.get('[data-testid="channel-list-item"]').attributes()).toMatchObject({ + 'data-configured': 'true', + 'data-connected': 'false', }); - await flushPromises(); - await wrapper.get('[data-testid="change-credential"]').trigger('click'); - expect(selectedCredentials.value.linear).toBe('linear-credential-new'); - - await wrapper.setProps({ open: false }); - await wrapper.setProps({ open: true }); - await flushPromises(); - - expect( - wrapper.get('[data-testid="shared-credential-connection"]').attributes('data-credential-id'), - ).toBe('linear-credential'); + statuses.value.example = 'connected'; + await configured.vm.$nextTick(); + expect(configured.get('[data-testid="channel-list-item"]').attributes('data-connected')).toBe( + 'true', + ); }); - it('keeps the original binding and modal open when connecting a replacement credential fails', async () => { - connectedCredentials.value.linear = 'linear-credential'; - connectMock.mockRejectedValueOnce(new Error('Failed to connect replacement credential')); - const wrapper = mountModal({ - view: 'linear_edit', - }); + it('forwards publication state and persists before platform save', async () => { + selectedCredentials.value.example = 'credential-new'; + const wrapper = mountModal('example_setup', true); + + expect(wrapper.get('[data-testid="platform-view"]').attributes('data-published')).toBe('true'); + await wrapper.get('[data-testid="connect-channel"]').trigger('click'); await flushPromises(); - await wrapper.get('[data-testid="change-credential"]').trigger('click'); - await wrapper.get('[data-testid="agent-channel-save-channel-config"]').trigger('click'); - await flushPromises(); - - expect(connectMock).toHaveBeenCalledWith('linear', 'linear-credential-new', { + expect(mocks.ensureAgentPersisted).toHaveBeenCalledOnce(); + expect(mocks.beforeSave).toHaveBeenCalledOnce(); + expect(mocks.connect).toHaveBeenCalledWith('example', 'credential-new', { accessMode: 'all', }); - expect(disconnectMock).not.toHaveBeenCalled(); - expect(fetchStatusMock).not.toHaveBeenCalled(); - expect(wrapper.emitted('update:open')).toBeUndefined(); - expect(vueErrorHandlerMock).toHaveBeenCalledWith( - expect.objectContaining({ message: 'Failed to connect replacement credential' }), - expect.anything(), - expect.any(String), + expect(mocks.ensureAgentPersisted.mock.invocationCallOrder[0]).toBeLessThan( + mocks.connect.mock.invocationCallOrder[0], ); - }); - - it('locks the replacement credential and retries detaching the original binding', async () => { - connectedCredentials.value.linear = 'linear-credential'; - disconnectMock.mockRejectedValueOnce(new Error('Failed to detach original credential')); - const wrapper = mountModal({ - view: 'linear_edit', - }); - await flushPromises(); - - await wrapper.get('[data-testid="change-credential"]').trigger('click'); - await wrapper.get('[data-testid="agent-channel-save-channel-config"]').trigger('click'); - await flushPromises(); - - expect(connectMock).toHaveBeenCalledOnce(); - expect(disconnectMock).toHaveBeenCalledWith('linear', 'linear-credential'); - expect(wrapper.get('[data-testid="change-credential"]').attributes('disabled')).toBeDefined(); - expect(wrapper.get('[data-testid="linear-setup"]').attributes('data-loading')).toBe('true'); - expect(wrapper.get('[data-testid="agent-channel-save-channel-config"]').text()).toBe( - 'generic.retry', - ); - expect(wrapper.get('[data-testid="agent-channel-credential-replacement-error"]').text()).toBe( - 'agents.channels.modal.credentialReplacementError', - ); - - await wrapper.get('[data-testid="agent-channel-save-channel-config"]').trigger('click'); - await flushPromises(); - - expect(connectMock).toHaveBeenCalledOnce(); - expect(disconnectMock).toHaveBeenCalledTimes(2); - expect(disconnectMock).toHaveBeenLastCalledWith('linear', 'linear-credential'); - expect(fetchStatusMock).toHaveBeenCalledWith(['linear']); - expect(wrapper.emitted('update:open')).toEqual([[false]]); - }); - - it('allows the modal to close and reopen after detaching the original binding fails', async () => { - connectedCredentials.value.linear = 'linear-credential'; - disconnectMock.mockRejectedValueOnce(new Error('Failed to detach original credential')); - const wrapper = mountModal({ - view: 'linear_edit', - }); - await flushPromises(); - - await wrapper.get('[data-testid="change-credential"]').trigger('click'); - await wrapper.get('[data-testid="agent-channel-save-channel-config"]').trigger('click'); - await flushPromises(); - - expect(wrapper.get('[data-testid="agent-channel-credential-replacement-error"]').text()).toBe( - 'agents.channels.modal.credentialReplacementError', - ); - await wrapper.get('[data-testid="close-dialog"]').trigger('click'); - - expect(wrapper.emitted('update:open')).toEqual([[false]]); - - await wrapper.setProps({ open: false }); - await wrapper.setProps({ open: true }); - await flushPromises(); - - expect( - wrapper.find('[data-testid="agent-channel-credential-replacement-error"]').exists(), - ).toBe(false); - expect(wrapper.find('[data-testid="close-dialog"]').exists()).toBe(true); - expect(wrapper.get('[data-testid="agent-channel-save-channel-config"]').text()).toBe( - 'generic.save', - ); - }); - - it('saves settings without disconnecting when the credential is unchanged', async () => { - connectedCredentials.value.telegram = 'telegram-credential'; - const wrapper = mountModal({ - view: 'telegram_edit', - }); - await flushPromises(); - - await wrapper.get('[data-testid="agent-channel-save-channel-config"]').trigger('click'); - await flushPromises(); - - expect(connectMock).toHaveBeenCalledWith('telegram', 'telegram-credential', { - accessMode: 'all', - }); - expect(disconnectMock).not.toHaveBeenCalled(); - expect(wrapper.emitted('channel-connected')).toEqual([['telegram']]); expect(wrapper.emitted('agent-changed')).toHaveLength(1); - expect(wrapper.emitted('update:open')).toEqual([[false]]); + }); + + it('connects a replacement before disconnecting the original credential', async () => { + statuses.value.example = 'connected'; + connectedCredentials.value.example = 'credential-old'; + const wrapper = mountModal('example_edit'); + await flushPromises(); + + await wrapper.get('[data-testid="select-credential"]').trigger('click'); + await wrapper.get('[data-testid="agent-channel-save-channel-config"]').trigger('click'); + await flushPromises(); + + expect(mocks.connect).toHaveBeenCalledWith('example', 'credential-new', { + accessMode: 'all', + }); + expect(mocks.disconnect).toHaveBeenCalledWith('example', 'credential-old'); + expect(mocks.connect.mock.invocationCallOrder[0]).toBeLessThan( + mocks.disconnect.mock.invocationCallOrder[0], + ); }); }); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelSlackSetup.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelSlackSetup.test.ts index 365477321b9..954ba184554 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelSlackSetup.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelSlackSetup.test.ts @@ -29,7 +29,7 @@ vi.mock('../components/AgentChannelSlackSetupSnapshots.vue', () => ({ }, })); -vi.mock('../composables/useAgentApi', () => ({ +vi.mock('../channels/slack/api', () => ({ getSlackAgentAppManifest: vi.fn().mockResolvedValue({ manifest: { display_information: {} } }), })); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSlackSettingsForm.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSlackSettingsForm.test.ts deleted file mode 100644 index 912525d5d84..00000000000 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSlackSettingsForm.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -/* eslint-disable import-x/no-extraneous-dependencies -- test-only pattern */ -import { flushPromises, mount } from '@vue/test-utils'; -import { describe, expect, it, vi } from 'vitest'; - -import AgentSlackSettingsForm from '../components/AgentSlackSettingsForm.vue'; - -const { getSlackAgentAppManifest } = vi.hoisted(() => ({ - getSlackAgentAppManifest: vi.fn(), -})); - -vi.mock('@n8n/i18n', () => ({ - useI18n: () => ({ baseText: (key: string) => key }), -})); - -vi.mock('@n8n/stores/useRootStore', () => ({ - useRootStore: () => ({ - restApiContext: {}, - urlBaseWebhook: 'https://hooks.example', - OAuthCallbackUrls: { oauth2: 'https://hooks.example/rest/oauth2-credential/callback' }, - }), -})); - -vi.mock('../composables/useAgentApi', () => ({ - getSlackAgentAppManifest, -})); - -describe('AgentSlackSettingsForm', () => { - it('renders the fetched Slack app manifest', async () => { - getSlackAgentAppManifest.mockResolvedValue({ - manifest: { - display_information: { name: 'Support Agent' }, - oauth_config: { - scopes: { - bot: [ - 'assistant:write', - 'groups:history', - 'mpim:history', - 'reactions:read', - 'reactions:write', - ], - }, - }, - settings: { - event_subscriptions: { - bot_events: [ - 'assistant_thread_started', - 'assistant_thread_context_changed', - 'message.channels', - 'message.groups', - 'message.im', - 'message.mpim', - ], - }, - }, - }, - }); - - const wrapper = mount(AgentSlackSettingsForm, { - props: { - agentName: 'Support Agent', - projectId: 'project-1', - agentId: 'agent-1', - }, - global: { - stubs: { - N8nButton: { template: '' }, - N8nIcon: { template: '' }, - N8nText: { template: '' }, - N8nInput: { template: '' }, - N8nCollapsiblePanel: { template: '
    ' }, - }, - }, - }); - await flushPromises(); - - const manifest = JSON.parse(wrapper.find('pre').text()) as { - oauth_config: { scopes: { bot: string[] } }; - settings: { event_subscriptions: { bot_events: string[] } }; - }; - - expect(getSlackAgentAppManifest).toHaveBeenCalledWith({}, 'project-1', 'agent-1'); - expect(manifest.oauth_config.scopes.bot).toEqual( - expect.arrayContaining([ - 'assistant:write', - 'groups:history', - 'mpim:history', - 'reactions:read', - 'reactions:write', - ]), - ); - expect(manifest.settings.event_subscriptions.bot_events).toEqual( - expect.arrayContaining([ - 'assistant_thread_started', - 'assistant_thread_context_changed', - 'message.channels', - 'message.groups', - 'message.im', - 'message.mpim', - ]), - ); - }); -}); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentChannelSetup.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentChannelSetup.test.ts index d4cac466a8e..4ecdf3a38f7 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentChannelSetup.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentChannelSetup.test.ts @@ -2,29 +2,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useAgentChannelSetup } from '../composables/useAgentChannelSetup'; -const { - fetchAllCredentialsForWorkflowMock, - fetchProjectMock, - projectsStoreMock, - setCredentialsMock, -} = vi.hoisted(() => ({ - fetchAllCredentialsForWorkflowMock: vi.fn(), - fetchProjectMock: vi.fn(), - projectsStoreMock: { +const { fetchCredentials, fetchProject, projectsStore } = vi.hoisted(() => ({ + fetchCredentials: vi.fn(), + fetchProject: vi.fn(), + projectsStore: { currentProject: null as { id: string; scopes?: string[] } | null, personalProject: null as { id: string; scopes?: string[] } | null, myProjects: [] as Array<{ id: string; scopes?: string[] }>, fetchProject: vi.fn(), }, - setCredentialsMock: vi.fn(), })); -vi.mock('../composables/useAgentApi', () => ({ - createSlackAgentApp: vi.fn().mockResolvedValue({ installUrl: 'https://slack.test/install' }), -})); - -vi.mock('@n8n/stores/useRootStore', () => ({ - useRootStore: () => ({ restApiContext: {} }), +vi.mock('@n8n/permissions', () => ({ + getResourcePermissions: (scopes?: string[]) => ({ + credential: { create: scopes?.includes('credential:create') ?? false }, + }), })); vi.mock('@/app/stores/ui.store', () => ({ @@ -37,119 +29,90 @@ vi.mock('@/app/stores/ui.store', () => ({ vi.mock('@/features/credentials/credentials.store', () => ({ useCredentialsStore: () => ({ - setCredentials: setCredentialsMock, - fetchAllCredentialsForWorkflow: fetchAllCredentialsForWorkflowMock, + setCredentials: vi.fn(), + fetchAllCredentialsForWorkflow: fetchCredentials, getCredentialTypeByName: vi.fn(), }), })); vi.mock('@/features/collaboration/projects/projects.store', () => ({ - useProjectsStore: () => projectsStoreMock, + useProjectsStore: () => projectsStore, })); -function createChannelSetup() { - return useAgentChannelSetup({ - projectId: () => 'artifact-project', - agentId: () => 'agent-1', - currentIntegration: null, - connectedCredentials: {}, - fetchStatus: vi.fn().mockResolvedValue(undefined), - isIntegrationConfigured: () => false, - }); -} +const integrations = [ + { + type: 'example', + label: 'Example', + icon: 'zap', + credentialTypes: ['exampleApi'], + }, +]; describe('useAgentChannelSetup', () => { beforeEach(() => { vi.clearAllMocks(); - projectsStoreMock.currentProject = null; - projectsStoreMock.personalProject = null; - projectsStoreMock.myProjects = []; - projectsStoreMock.fetchProject = fetchProjectMock; - fetchAllCredentialsForWorkflowMock.mockResolvedValue([]); - fetchProjectMock.mockResolvedValue({ - id: 'artifact-project', - name: 'Artifact project', - icon: null, - type: 'team', - description: null, - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - relations: [], + projectsStore.currentProject = null; + projectsStore.personalProject = null; + projectsStore.myProjects = []; + projectsStore.fetchProject = fetchProject; + fetchProject.mockResolvedValue({ + id: 'project-1', scopes: ['credential:create'], - rolesManaged: false, }); + fetchCredentials.mockResolvedValue([ + { id: 'credential-1', name: 'Example credential', type: 'exampleApi' }, + { id: 'other', name: 'Other credential', type: 'otherApi' }, + ]); + }); + + it('loads project permissions, integration credentials, and status generically', async () => { + const fetchStatus = vi.fn().mockResolvedValue(undefined); + const setup = useAgentChannelSetup({ + projectId: () => 'project-1', + currentIntegration: () => integrations[0], + connectedCredentials: () => ({ example: 'credential-1' }), + fetchStatus, + }); + + await setup.loadChannelState(integrations); + + expect(fetchProject).toHaveBeenCalledWith('project-1'); + expect(fetchStatus).toHaveBeenCalledWith(['example']); + expect(setup.credentialPermissions.value.create).toBe(true); + expect(setup.getCredentials('example')).toEqual([ + expect.objectContaining({ id: 'credential-1', name: 'Example credential' }), + ]); + expect(setup.selectedCredentials.value.example).toBe('credential-1'); }); it('uses project scopes already available in the store', async () => { - projectsStoreMock.myProjects = [{ id: 'artifact-project', scopes: ['credential:create'] }]; - const { credentialPermissions, loadChannelState } = createChannelSetup(); + projectsStore.myProjects = [{ id: 'project-1', scopes: ['credential:create'] }]; + const setup = useAgentChannelSetup({ + projectId: () => 'project-1', + currentIntegration: () => integrations[0], + connectedCredentials: () => ({}), + fetchStatus: vi.fn().mockResolvedValue(undefined), + }); - await loadChannelState([]); + await setup.loadChannelState(integrations); - expect(credentialPermissions.value.create).toBe(true); - expect(fetchProjectMock).not.toHaveBeenCalled(); + expect(setup.credentialPermissions.value.create).toBe(true); + expect(fetchProject).not.toHaveBeenCalled(); }); - it('loads scopes when an artifact project is missing from the store', async () => { - // AGENT-443: artifact mode supplies its project through props rather than the route. - const { credentialPermissions, loadChannelState } = createChannelSetup(); - - expect(credentialPermissions.value.create).toBe(false); - - await loadChannelState([]); - - expect(fetchProjectMock).toHaveBeenCalledWith('artifact-project'); - expect(credentialPermissions.value.create).toBe(true); - }); - - it('resolves setupSlackApp successfully when the popup closes while an in-flight poll confirms the configuration', async () => { - vi.useFakeTimers(); - - class FakeBroadcastChannel { - addEventListener() {} - close() {} - postMessage() {} - } - vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel); - - const fakePopup = { closed: false, close: vi.fn() }; - vi.spyOn(window, 'open').mockReturnValue(fakePopup as unknown as Window); - - let resolveFirstPoll!: () => void; - const firstPoll = new Promise((resolve) => { - resolveFirstPoll = resolve; - }); - let isConfigured = false; - const fetchStatus = vi.fn().mockImplementation(async () => { - if (fetchStatus.mock.calls.length === 1) { - await firstPoll; - } + it('fails closed when credentials and project permissions cannot load', async () => { + fetchProject.mockRejectedValue(new Error('unavailable')); + fetchCredentials.mockRejectedValue(new Error('unavailable')); + const setup = useAgentChannelSetup({ + projectId: () => 'project-1', + currentIntegration: () => integrations[0], + connectedCredentials: () => ({}), + fetchStatus: vi.fn().mockResolvedValue(undefined), }); - const onConnected = vi.fn(); - const { setupSlackApp } = useAgentChannelSetup({ - projectId: () => 'artifact-project', - agentId: () => 'agent-1', - currentIntegration: null, - connectedCredentials: {}, - fetchStatus, - isIntegrationConfigured: () => isConfigured, - }); + await setup.loadChannelState(integrations); - const setupPromise = setupSlackApp('token', onConnected); - await vi.advanceTimersByTimeAsync(0); - - fakePopup.closed = true; - await vi.advanceTimersByTimeAsync(2000); - - isConfigured = true; - resolveFirstPoll(); - - await expect(setupPromise).resolves.toBe(true); - expect(onConnected).toHaveBeenCalled(); - - vi.useRealTimers(); - vi.unstubAllGlobals(); - vi.restoreAllMocks(); + expect(setup.credentialPermissions.value.create).toBe(false); + expect(setup.getCredentials('example')).toEqual([]); }); }); diff --git a/packages/frontend/editor-ui/src/features/agents/channels/AgentChannelStandardEditView.vue b/packages/frontend/editor-ui/src/features/agents/channels/AgentChannelStandardEditView.vue new file mode 100644 index 00000000000..3f3b4f9d0b9 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/channels/AgentChannelStandardEditView.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/agents/channels/discord/AgentChannelDiscordEditView.vue b/packages/frontend/editor-ui/src/features/agents/channels/discord/AgentChannelDiscordEditView.vue new file mode 100644 index 00000000000..20627604f99 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/channels/discord/AgentChannelDiscordEditView.vue @@ -0,0 +1,29 @@ + + + diff --git a/packages/frontend/editor-ui/src/features/agents/channels/fallback/AgentChannelFallbackView.vue b/packages/frontend/editor-ui/src/features/agents/channels/fallback/AgentChannelFallbackView.vue new file mode 100644 index 00000000000..476c80ded25 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/channels/fallback/AgentChannelFallbackView.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/agents/channels/linear/AgentChannelLinearEditView.vue b/packages/frontend/editor-ui/src/features/agents/channels/linear/AgentChannelLinearEditView.vue new file mode 100644 index 00000000000..ad157829a5d --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/channels/linear/AgentChannelLinearEditView.vue @@ -0,0 +1,30 @@ + + + diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChannelLinearSetup.vue b/packages/frontend/editor-ui/src/features/agents/channels/linear/AgentChannelLinearSetup.vue similarity index 93% rename from packages/frontend/editor-ui/src/features/agents/components/AgentChannelLinearSetup.vue rename to packages/frontend/editor-ui/src/features/agents/channels/linear/AgentChannelLinearSetup.vue index a7494cb2102..a032976a7a2 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentChannelLinearSetup.vue +++ b/packages/frontend/editor-ui/src/features/agents/channels/linear/AgentChannelLinearSetup.vue @@ -1,14 +1,13 @@ + + diff --git a/packages/frontend/editor-ui/src/features/agents/channels/slack/AgentChannelSlackSetupView.vue b/packages/frontend/editor-ui/src/features/agents/channels/slack/AgentChannelSlackSetupView.vue new file mode 100644 index 00000000000..b6f8f01c2cc --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/channels/slack/AgentChannelSlackSetupView.vue @@ -0,0 +1,54 @@ + + + diff --git a/packages/frontend/editor-ui/src/features/agents/channels/slack/api.ts b/packages/frontend/editor-ui/src/features/agents/channels/slack/api.ts new file mode 100644 index 00000000000..84e90c8f7d8 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/channels/slack/api.ts @@ -0,0 +1,23 @@ +import type { CreateSlackAgentAppResponse, SlackAgentAppManifestResponse } from '@n8n/api-types'; +import type { IRestApiContext } from '@n8n/rest-api-client'; +import { makeRestApiRequest } from '@n8n/rest-api-client'; + +const integrationPath = (projectId: string, agentId: string) => + `/projects/${projectId}/agents/v2/${agentId}/integrations/slack`; + +export const createSlackAgentApp = async ( + context: IRestApiContext, + projectId: string, + agentId: string, + appConfigurationToken: string, +): Promise => + await makeRestApiRequest(context, 'POST', `${integrationPath(projectId, agentId)}/app`, { + appConfigurationToken, + }); + +export const getSlackAgentAppManifest = async ( + context: IRestApiContext, + projectId: string, + agentId: string, +): Promise => + await makeRestApiRequest(context, 'GET', `${integrationPath(projectId, agentId)}/manifest`); diff --git a/packages/frontend/editor-ui/src/features/agents/channels/slack/useSlackChannelRuntime.test.ts b/packages/frontend/editor-ui/src/features/agents/channels/slack/useSlackChannelRuntime.test.ts new file mode 100644 index 00000000000..a73970dc91f --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/channels/slack/useSlackChannelRuntime.test.ts @@ -0,0 +1,71 @@ +import { ref } from 'vue'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useSlackChannelRuntime } from './useSlackChannelRuntime'; + +const { createSlackAgentApp } = vi.hoisted(() => ({ + createSlackAgentApp: vi.fn().mockResolvedValue({ installUrl: 'https://slack.test/install' }), +})); + +vi.mock('@n8n/stores/useRootStore', () => ({ + useRootStore: () => ({ restApiContext: {} }), +})); + +vi.mock('./api', () => ({ + createSlackAgentApp, +})); + +describe('useSlackChannelRuntime', () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('completes manual setup when a final poll confirms the connection', async () => { + vi.useFakeTimers(); + + class FakeBroadcastChannel { + addEventListener() {} + close() {} + } + vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel); + + const popup = { closed: false, close: vi.fn() }; + vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window); + + let resolveFirstPoll!: () => void; + const firstPoll = new Promise((resolve) => { + resolveFirstPoll = resolve; + }); + let isConfigured = false; + const fetchStatus = vi.fn().mockImplementation(async () => { + if (fetchStatus.mock.calls.length === 1) await firstPoll; + }); + const ensureAgentPersisted = vi.fn().mockResolvedValue(undefined); + const onConnected = vi.fn(); + const runtime = useSlackChannelRuntime({ + projectId: ref('project-1'), + agentId: ref('agent-1'), + selectedCredentialId: ref(''), + credentialModalOpen: ref(false), + fetchStatus, + isConnected: () => false, + isConfigured: () => isConfigured, + ensureAgentPersisted, + }); + + const setupPromise = runtime.setupApp('token', onConnected); + await vi.advanceTimersByTimeAsync(0); + + popup.closed = true; + await vi.advanceTimersByTimeAsync(2000); + isConfigured = true; + resolveFirstPoll(); + + await expect(setupPromise).resolves.toBe(true); + expect(ensureAgentPersisted).toHaveBeenCalledOnce(); + expect(createSlackAgentApp).toHaveBeenCalledWith({}, 'project-1', 'agent-1', 'token'); + expect(onConnected).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/channels/slack/useSlackChannelRuntime.ts b/packages/frontend/editor-ui/src/features/agents/channels/slack/useSlackChannelRuntime.ts new file mode 100644 index 00000000000..c66e67b51b1 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/channels/slack/useSlackChannelRuntime.ts @@ -0,0 +1,126 @@ +import { useRootStore } from '@n8n/stores/useRootStore'; +import { readonly, ref } from 'vue'; + +import type { AgentChannelRuntime, AgentChannelRuntimeContext } from '../types'; +import { createSlackAgentApp } from './api'; + +const SLACK_APP_SETUP_POLL_INTERVAL_MS = 2000; +const SLACK_APP_SETUP_TIMEOUT_MS = 2 * 60 * 1000; + +export interface SlackChannelRuntime extends AgentChannelRuntime { + setupApp: ( + appConfigurationToken: string, + onConnected: () => void | Promise, + ) => Promise; +} + +export function isSlackChannelRuntime( + runtime: AgentChannelRuntime, +): runtime is SlackChannelRuntime { + return 'setupApp' in runtime && typeof runtime.setupApp === 'function'; +} + +export function useSlackChannelRuntime(context: AgentChannelRuntimeContext): SlackChannelRuntime { + const rootStore = useRootStore(); + const loading = ref(false); + + function openAuthorizationPopup(installUrl: string): Window { + const parsedUrl = new URL(installUrl); + if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + throw new Error('Invalid Slack installation URL'); + } + + const popup = window.open( + parsedUrl.toString(), + 'Slack App Authorization', + 'scrollbars=no,resizable=yes,status=no,titlebar=no,location=no,toolbar=no,menubar=no,width=500,height=700', + ); + if (!popup) throw new Error('Slack authorization popup was blocked'); + return popup; + } + + async function waitForSetupCompletion(popup: Window): Promise { + return await new Promise((resolve) => { + const oauthChannel = new BroadcastChannel('oauth-callback'); + let activePoll: Promise | null = null; + let settled = false; + + const closePopup = () => { + try { + popup.close(); + } catch {} + }; + + const settle = (success: boolean) => { + if (settled) return; + settled = true; + window.clearInterval(pollInterval); + window.clearTimeout(timeout); + oauthChannel.close(); + if (success) closePopup(); + resolve(success); + }; + + const pollStatus = async () => { + if (activePoll || settled) return; + activePoll = (async () => { + try { + await context.fetchStatus(['slack']); + if (context.isConfigured('slack')) settle(true); + } finally { + activePoll = null; + } + })(); + await activePoll; + }; + + const pollInterval = window.setInterval(() => { + if (popup.closed) { + void (activePoll ?? Promise.resolve()) + .catch(() => {}) + .then(pollStatus) + .finally(() => settle(false)); + return; + } + void pollStatus(); + }, SLACK_APP_SETUP_POLL_INTERVAL_MS); + const timeout = window.setTimeout(() => settle(false), SLACK_APP_SETUP_TIMEOUT_MS); + + oauthChannel.addEventListener('message', (event: MessageEvent) => { + settle(event.data === 'success'); + }); + + void pollStatus(); + }); + } + + async function setupApp( + appConfigurationToken: string, + onConnected: () => void | Promise, + ): Promise { + loading.value = true; + try { + await context.ensureAgentPersisted?.(); + const { installUrl } = await createSlackAgentApp( + rootStore.restApiContext, + context.projectId.value, + context.agentId.value, + appConfigurationToken, + ); + const connected = await waitForSetupCompletion(openAuthorizationPopup(installUrl)); + if (!connected) throw new Error('Slack app installation was not completed'); + + await context.fetchStatus(['slack']); + await onConnected(); + return true; + } finally { + loading.value = false; + } + } + + return { + load: async () => {}, + loading: readonly(loading), + setupApp, + }; +} diff --git a/packages/frontend/editor-ui/src/features/agents/channels/telegram/AgentChannelTelegramEditView.vue b/packages/frontend/editor-ui/src/features/agents/channels/telegram/AgentChannelTelegramEditView.vue new file mode 100644 index 00000000000..ece95daea31 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/channels/telegram/AgentChannelTelegramEditView.vue @@ -0,0 +1,30 @@ + + + diff --git a/packages/frontend/editor-ui/src/features/agents/channels/telegram/AgentChannelTelegramSetup.test.ts b/packages/frontend/editor-ui/src/features/agents/channels/telegram/AgentChannelTelegramSetup.test.ts new file mode 100644 index 00000000000..1284e9e2624 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/channels/telegram/AgentChannelTelegramSetup.test.ts @@ -0,0 +1,46 @@ +import { createComponentRenderer } from '@/__tests__/render'; +import { describe, expect, it, vi } from 'vitest'; + +import AgentChannelTelegramSetup from './AgentChannelTelegramSetup.vue'; + +vi.mock('@n8n/i18n', async (importOriginal) => ({ + ...(await importOriginal()), + useI18n: () => ({ + baseText: (key: string) => key, + }), +})); + +vi.mock('@n8n/design-system', async (importOriginal) => ({ + ...(await importOriginal()), + N8nStepper: { + template: `
    `, + }, +})); + +const renderComponent = createComponentRenderer(AgentChannelTelegramSetup); + +describe('AgentChannelTelegramSetup', () => { + it('shows connection errors below the connect button', () => { + const { getByText } = renderComponent({ + props: { + mode: 'setup', + modelValue: 'telegram-credential', + integration: { + type: 'telegram', + label: 'Telegram', + icon: 'telegram', + credentialTypes: ['telegramApi'], + }, + credentials: [], + credentialPermissions: { create: true }, + agentName: 'Agent', + projectId: 'project-id', + agentId: 'agent-id', + errorMessage: 'Telegram credential is already connected to agent "Alex"', + errorIsConflict: true, + }, + }); + + expect(getByText('Telegram credential is already connected to agent "Alex"')).toBeVisible(); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChannelTelegramSetup.vue b/packages/frontend/editor-ui/src/features/agents/channels/telegram/AgentChannelTelegramSetup.vue similarity index 76% rename from packages/frontend/editor-ui/src/features/agents/components/AgentChannelTelegramSetup.vue rename to packages/frontend/editor-ui/src/features/agents/channels/telegram/AgentChannelTelegramSetup.vue index 5dd43a00b1f..d78a673a754 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentChannelTelegramSetup.vue +++ b/packages/frontend/editor-ui/src/features/agents/channels/telegram/AgentChannelTelegramSetup.vue @@ -1,12 +1,17 @@ @@ -98,16 +105,11 @@ defineExpose({ credentialId, currentSettings, validationError }); @edit="emit('edit')" /> -
    {{ i18n.baseText('agents.builder.addTrigger.connect') }} + + {{ errorMessage }} + + {{ i18n.baseText('agents.builder.addTrigger.editCredential') }} + +
    @@ -139,19 +157,14 @@ defineExpose({ credentialId, currentSettings, validationError }); @edit="emit('edit')" /> {{ connectedDescription }} - - + {{ errorMessage }} Promise; +} + +export interface AgentChannelRuntimeContext { + projectId: Ref; + agentId: Ref; + selectedCredentialId: Ref; + credentialModalOpen: Readonly>; + fetchStatus: (integrationTypes: string[]) => Promise; + isConnected: (integrationType: string) => boolean; + isConfigured: (integrationType: string) => boolean; + ensureAgentPersisted?: () => Promise; +} + +export interface AgentChannelRuntime { + load: () => Promise; + loading: Readonly>; +} + +export interface AgentChannelViewProps { + mode: AgentChannelMode; + integration: ChatIntegrationDescriptor; + modelValue: string; + credentials: AgentCredentialOption[]; + credentialPermissions: PermissionsRecord['credential']; + credentialsLoading: boolean; + loading: boolean; + disabled?: boolean; + connected: boolean; + connectedDescription: string; + errorMessage: string; + errorIsConflict: boolean; + savedSettings?: AgentIntegrationSettings; + isPublished: boolean; + agentName: string; + projectId: string; + agentId: string; + forceNewCredential: boolean; + simpleSetup: boolean; + credentialReplacementPending: boolean; + runtime: AgentChannelRuntime; +} + +export interface AgentChannelPresentationContext { + text: (key: BaseTextKey) => string; +} + +export interface ChannelPlatformDefinition { + type: string; + setupComponent: Component; + editComponent: Component; + createRuntime?: (context: AgentChannelRuntimeContext) => AgentChannelRuntime; + getConnectAction: ( + context: AgentChannelPresentationContext, + runtime: AgentChannelRuntime, + ) => AgentChannelConnectAction; + getConnectedDescription?: (context: AgentChannelPresentationContext) => string; +} diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChannelListItem.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentChannelListItem.vue index 4140c19d8f2..94f38d9e254 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentChannelListItem.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentChannelListItem.vue @@ -1,10 +1,18 @@ - - diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentSlackSettingsForm.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentSlackSettingsForm.vue deleted file mode 100644 index 7f38b1641f9..00000000000 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentSlackSettingsForm.vue +++ /dev/null @@ -1,313 +0,0 @@ - - - - - diff --git a/packages/frontend/editor-ui/src/features/agents/composables/useAgentApi.ts b/packages/frontend/editor-ui/src/features/agents/composables/useAgentApi.ts index 919f6ee48a5..0e862909b11 100644 --- a/packages/frontend/editor-ui/src/features/agents/composables/useAgentApi.ts +++ b/packages/frontend/editor-ui/src/features/agents/composables/useAgentApi.ts @@ -14,8 +14,6 @@ import type { AgentProviderModelsResponse, AgentVersionListItemDto, ChatIntegrationDescriptor, - CreateSlackAgentAppResponse, - SlackAgentAppManifestResponse, VectorStoreTestResult, } from '@n8n/api-types'; import { getFullApiResponse, makeRestApiRequest } from '@n8n/rest-api-client'; @@ -198,7 +196,7 @@ export const disconnectIntegration = async ( type: string, credentialId: string, ): Promise<{ status: string }> => { - return await makeRestApiRequest( + return await makeRestApiRequest<{ status: string }>( context, 'POST', `/projects/${projectId}/agents/v2/${agentId}/integrations/disconnect`, @@ -285,49 +283,6 @@ export const runAgentTask = async ( ); }; -// Backward-compatible aliases -export const connectSlack = async ( - ctx: IRestApiContext, - projectId: string, - agentId: string, - credentialId: string, -) => await connectIntegration(ctx, projectId, agentId, 'slack', credentialId); - -export const disconnectSlack = async ( - ctx: IRestApiContext, - projectId: string, - agentId: string, - credentialId: string, -) => await disconnectIntegration(ctx, projectId, agentId, 'slack', credentialId); - -export const getSlackStatus = getIntegrationStatus; - -export const createSlackAgentApp = async ( - context: IRestApiContext, - projectId: string, - agentId: string, - appConfigurationToken: string, -): Promise => { - return await makeRestApiRequest( - context, - 'POST', - `/projects/${projectId}/agents/v2/${agentId}/integrations/slack/app`, - { appConfigurationToken }, - ); -}; - -export const getSlackAgentAppManifest = async ( - context: IRestApiContext, - projectId: string, - agentId: string, -): Promise => { - return await makeRestApiRequest( - context, - 'GET', - `/projects/${projectId}/agents/v2/${agentId}/integrations/slack/manifest`, - ); -}; - export type ModelInfo = AgentCatalogModel; export interface ProviderInfo { diff --git a/packages/frontend/editor-ui/src/features/agents/composables/useAgentChannelSetup.ts b/packages/frontend/editor-ui/src/features/agents/composables/useAgentChannelSetup.ts index 7c9ed3486b7..d16a9c4e5cb 100644 --- a/packages/frontend/editor-ui/src/features/agents/composables/useAgentChannelSetup.ts +++ b/packages/frontend/editor-ui/src/features/agents/composables/useAgentChannelSetup.ts @@ -1,6 +1,5 @@ import type { AgentIntegrationSettings, ChatIntegrationDescriptor } from '@n8n/api-types'; import { getResourcePermissions } from '@n8n/permissions'; -import { useRootStore } from '@n8n/stores/useRootStore'; import { computed, ref, toValue, watch, type MaybeRefOrGetter } from 'vue'; import { useUIStore } from '@/app/stores/ui.store'; @@ -10,10 +9,6 @@ import { useProjectsStore } from '@/features/collaboration/projects/projects.sto import type { Project } from '@/features/collaboration/projects/projects.types'; import type { AgentCredentialOption } from '../components/AgentCredentialSelect.vue'; -import { createSlackAgentApp } from './useAgentApi'; - -const SLACK_APP_SETUP_POLL_INTERVAL_MS = 2000; -const SLACK_APP_SETUP_TIMEOUT_MS = 2 * 60 * 1000; type ChannelSetupComponent = { credentialId: string; @@ -23,15 +18,12 @@ type ChannelSetupComponent = { type UseAgentChannelSetupOptions = { projectId: MaybeRefOrGetter; - agentId: MaybeRefOrGetter; currentIntegration: MaybeRefOrGetter; connectedCredentials: MaybeRefOrGetter>; fetchStatus: (integrationTypes: string[]) => Promise; - isIntegrationConfigured: (type: string) => boolean; }; export function useAgentChannelSetup(options: UseAgentChannelSetupOptions) { - const rootStore = useRootStore(); const uiStore = useUIStore(); const credentialsStore = useCredentialsStore(); const projectsStore = useProjectsStore(); @@ -46,7 +38,6 @@ export function useAgentChannelSetup(options: UseAgentChannelSetupOptions) { const fetchedProjectForPermissions = ref(null); const projectId = computed(() => toValue(options.projectId)); - const agentId = computed(() => toValue(options.agentId)); const currentIntegration = computed(() => toValue(options.currentIntegration) ?? null); const connectedCredentials = computed(() => toValue(options.connectedCredentials)); @@ -171,100 +162,6 @@ export function useAgentChannelSetup(options: UseAgentChannelSetupOptions) { } } - function openSlackAppAuthorizationPopup(installUrl: string): Window { - const parsedUrl = new URL(installUrl); - if (!['http:', 'https:'].includes(parsedUrl.protocol)) { - throw new Error('Invalid Slack installation URL'); - } - - const params = - 'scrollbars=no,resizable=yes,status=no,titlebar=no,location=no,toolbar=no,menubar=no,width=500,height=700'; - const popup = window.open(parsedUrl.toString(), 'Slack App Authorization', params); - if (!popup) { - throw new Error('Slack authorization popup was blocked'); - } - return popup; - } - - async function waitForSlackAppSetupCompletion(popup: Window): Promise { - return await new Promise((resolve) => { - const oauthChannel = new BroadcastChannel('oauth-callback'); - let activePoll: Promise | null = null; - let settled = false; - - const closePopup = () => { - try { - popup.close(); - } catch {} - }; - - const settle = (success: boolean) => { - if (settled) return; - settled = true; - window.clearInterval(pollInterval); - window.clearTimeout(timeout); - oauthChannel.close(); - if (success) closePopup(); - resolve(success); - }; - - const pollStatus = async () => { - if (activePoll || settled) return; - activePoll = (async () => { - try { - await options.fetchStatus(['slack']); - if (options.isIntegrationConfigured('slack')) settle(true); - } finally { - activePoll = null; - } - })(); - await activePoll; - }; - - const pollInterval = window.setInterval(() => { - // User closed the popup — the OAuth flow can't complete anymore. Let any - // in-flight poll finish (it may confirm success), check status once more, - // then give up instead of blocking the UI until the full timeout. - if (popup.closed) { - void (activePoll ?? Promise.resolve()) - .catch(() => {}) - .then(pollStatus) - .finally(() => settle(false)); - return; - } - void pollStatus(); - }, SLACK_APP_SETUP_POLL_INTERVAL_MS); - const timeout = window.setTimeout(() => settle(false), SLACK_APP_SETUP_TIMEOUT_MS); - - oauthChannel.addEventListener('message', (event: MessageEvent) => { - settle(event.data === 'success'); - }); - - void pollStatus(); - }); - } - - async function setupSlackApp( - appConfigurationToken: string, - onConfigured: () => void | Promise, - ): Promise { - const { installUrl } = await createSlackAgentApp( - rootStore.restApiContext, - projectId.value, - agentId.value, - appConfigurationToken, - ); - const popup = openSlackAppAuthorizationPopup(installUrl); - const configured = await waitForSlackAppSetupCompletion(popup); - if (!configured) { - throw new Error('Slack app installation was not completed'); - } - - await options.fetchStatus(['slack']); - await onConfigured(); - return true; - } - watch(credentialModalOpen, async (isOpen, wasOpen) => { if (!wasOpen || isOpen) return; const type = pendingNewCredentialType.value; @@ -300,6 +197,5 @@ export function useAgentChannelSetup(options: UseAgentChannelSetupOptions) { loadChannelState, createCredential, editCredential, - setupSlackApp, }; } diff --git a/packages/frontend/editor-ui/src/features/ai/shared/components/ChannelSetupCard.test.ts b/packages/frontend/editor-ui/src/features/ai/shared/components/ChannelSetupCard.test.ts index fa3a602e249..1817c3a16bb 100644 --- a/packages/frontend/editor-ui/src/features/ai/shared/components/ChannelSetupCard.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/shared/components/ChannelSetupCard.test.ts @@ -86,6 +86,9 @@ vi.mock('@/features/agents/composables/useAgentIntegrationStatus', () => ({ vi.mock('@/features/agents/composables/useAgentApi', () => ({ getAgent: mocks.getAgent, +})); + +vi.mock('@/features/agents/channels/slack/api', () => ({ createSlackAgentApp: mocks.createSlackAgentApp, })); @@ -129,7 +132,7 @@ vi.mock('@/features/agents/components/AgentChannelSlackSetup.vue', () => ({ }, })); -vi.mock('@/features/agents/components/AgentChannelLinearSetup.vue', () => ({ +vi.mock('@/features/agents/channels/linear/AgentChannelLinearSetup.vue', () => ({ default: { props: ['connectedDescription'], template: @@ -307,6 +310,14 @@ describe('ChannelSetupCard', () => { expect(wrapper.emitted('resolve')).toBeUndefined(); }); + it('renders the safe fallback for an unknown catalog integration', async () => { + const wrapper = mountCard({ integrationType: 'unknown-channel' }); + await flushPromises(); + + expect(wrapper.find('[data-testid="channel-setup-catalog-loading"]').exists()).toBe(false); + expect(wrapper.find('[data-testid="channel-setup-catalog-error"]').exists()).toBe(false); + }); + it('shows a loading state until the integration catalog arrives', async () => { let resolveCatalog: (integrations: ChatIntegrationDescriptor[]) => void = () => {}; mocks.setCatalog([]); diff --git a/packages/frontend/editor-ui/src/features/ai/shared/components/ChannelSetupCard.vue b/packages/frontend/editor-ui/src/features/ai/shared/components/ChannelSetupCard.vue index 5e10768d4e3..5dab1126896 100644 --- a/packages/frontend/editor-ui/src/features/ai/shared/components/ChannelSetupCard.vue +++ b/packages/frontend/editor-ui/src/features/ai/shared/components/ChannelSetupCard.vue @@ -8,22 +8,31 @@ * `resolve` event that the consumer translates into its own confirm/resolve * transport call. */ -import { N8nButton, N8nIcon, N8nLoading, N8nText } from '@n8n/design-system'; -import type { IconName } from '@n8n/design-system'; +import { + N8nButton, + N8nIcon, + N8nLoading, + N8nText, + updatedIconSet, + type IconName, +} from '@n8n/design-system'; import { useI18n } from '@n8n/i18n'; import type { ChatIntegrationDescriptor } from '@n8n/api-types'; import { useRootStore } from '@n8n/stores/useRootStore'; import { computed, ref, watch } from 'vue'; import { agentsEventBus } from '@/features/agents/agents.eventBus'; +import { + agentChannelPlatforms, + createAgentChannelRuntime, + getAgentChannelPlatform, + isRegisteredAgentChannelPlatform, +} from '@/features/agents/channels/registry'; +import type { AgentChannelRuntime, AgentChannelViewExpose } from '@/features/agents/channels/types'; import { getAgent } from '@/features/agents/composables/useAgentApi'; import { useAgentChannelSetup } from '@/features/agents/composables/useAgentChannelSetup'; import { useAgentIntegrationStatus } from '@/features/agents/composables/useAgentIntegrationStatus'; import { useAgentIntegrationsCatalog } from '@/features/agents/composables/useAgentIntegrationsCatalog'; -import AgentChannelDiscordSetup from '@/features/agents/components/AgentChannelDiscordSetup.vue'; -import AgentChannelLinearSetup from '@/features/agents/components/AgentChannelLinearSetup.vue'; -import AgentChannelSlackSetup from '@/features/agents/components/AgentChannelSlackSetup.vue'; -import AgentChannelTelegramSetup from '@/features/agents/components/AgentChannelTelegramSetup.vue'; import type { AgentResource } from '@/features/agents/types'; const props = defineProps<{ @@ -65,42 +74,74 @@ const agent = ref(null); const catalogLoading = ref(false); const catalogLoadFailed = ref(false); -const currentIntegration = computed(() => { - return catalog.value?.find((integration) => integration.type === props.integrationType) ?? null; +const currentIntegration = computed(() => { + return ( + catalog.value?.find((integration) => integration.type === props.integrationType) ?? { + type: props.integrationType, + label: props.integrationType, + icon: 'zap', + credentialTypes: [], + } + ); }); const { - channelSetupRef, selectedCredentials, credentialsLoading, credentialPermissions, + credentialModalOpen, getChannelCredentialId, getCredentials, loadChannelState: loadSharedChannelState, createCredential, editCredential, - setupSlackApp: runSlackAppSetup, } = useAgentChannelSetup({ projectId: () => props.projectId, - agentId: () => props.agentId, currentIntegration, connectedCredentials, fetchStatus, - isIntegrationConfigured, }); -const integrationLabel = computed(() => currentIntegration.value?.label ?? props.integrationType); - -const connectedDescriptionKeys = { - telegram: 'agents.builder.addTrigger.connectedText.telegram', - linear: 'agents.builder.addTrigger.connectedText.linear', -} as const; +const projectIdRef = computed(() => props.projectId); +const agentIdRef = computed(() => props.agentId); +const runtimes: Record = Object.fromEntries( + Object.values(agentChannelPlatforms).map((platform) => [ + platform.type, + createAgentChannelRuntime(platform, { + projectId: projectIdRef, + agentId: agentIdRef, + selectedCredentialId: computed(() => getChannelCredentialId(platform.type)), + credentialModalOpen, + fetchStatus, + isConnected: isIntegrationConnected, + isConfigured: isIntegrationConfigured, + }), + ]), +); +const fallbackRuntime = createAgentChannelRuntime(getAgentChannelPlatform('unknown'), { + projectId: projectIdRef, + agentId: agentIdRef, + selectedCredentialId: computed(() => getChannelCredentialId(props.integrationType)), + credentialModalOpen, + fetchStatus, + isConnected: isIntegrationConnected, + isConfigured: isIntegrationConfigured, +}); +const currentPlatform = computed(() => getAgentChannelPlatform(props.integrationType)); +const currentRuntime = computed(() => runtimes[props.integrationType] ?? fallbackRuntime); +const channelActionInFlight = computed( + () => connectionInFlight.value || currentRuntime.value.loading.value, +); +const channelViewRef = ref(); +const integrationLabel = computed(() => currentIntegration.value.label); const connectedDescription = computed(() => { if (!isIntegrationConnected(props.integrationType)) return ''; - const key = - connectedDescriptionKeys[props.integrationType as keyof typeof connectedDescriptionKeys]; - return key ? i18n.baseText(key) : ''; + return ( + currentPlatform.value.getConnectedDescription?.({ + text: (key) => i18n.baseText(key), + }) ?? '' + ); }); const currentChannelCredentialId = computed(() => getChannelCredentialId(props.integrationType)); @@ -115,8 +156,8 @@ const cardTitle = computed(() => }), ); -function toIconName(icon: string): IconName { - return icon as IconName; +function isIconName(icon: string): icon is IconName { + return icon in updatedIconSet; } function isBlocked() { @@ -140,18 +181,19 @@ function notifyAgentUpdated() { } function skipSetup() { - if (connectionInFlight.value) return; + if (channelActionInFlight.value) return; finish(false); } async function saveChannelConfig() { - if (isBlocked() || connectionInFlight.value) return; + if (isBlocked() || channelActionInFlight.value) return; const credentialId = currentChannelCredentialId.value; - if (!credentialId || channelSetupRef.value?.validationError) return; + if (!credentialId || channelViewRef.value?.validationError) return; connectionInFlight.value = true; try { - await connect(props.integrationType, credentialId, channelSetupRef.value?.currentSettings); + await channelViewRef.value?.beforeSave?.(); + await connect(props.integrationType, credentialId, channelViewRef.value?.currentSettings); notifyAgentUpdated(); finish(true); } catch { @@ -161,17 +203,10 @@ async function saveChannelConfig() { } } -async function setupSlackApp(appConfigurationToken: string): Promise { - if (isBlocked() || connectionInFlight.value) return false; - connectionInFlight.value = true; - try { - return await runSlackAppSetup(appConfigurationToken, () => { - notifyAgentUpdated(); - finish(true); - }); - } finally { - connectionInFlight.value = false; - } +function handlePlatformConnected() { + if (isBlocked()) return; + notifyAgentUpdated(); + finish(true); } async function loadChannelState(forceReload = false) { @@ -181,7 +216,8 @@ async function loadChannelState(forceReload = false) { let integrations = await (forceReload ? reloadCatalog(props.projectId) : ensureLoaded(props.projectId)); - const requiresDescriptor = props.integrationType !== 'slack'; + const requiresDescriptor = + props.integrationType !== 'slack' && isRegisteredAgentChannelPlatform(props.integrationType); if ( requiresDescriptor && @@ -199,14 +235,12 @@ async function loadChannelState(forceReload = false) { return; } - await loadSharedChannelState(integrations); + await Promise.all([loadSharedChannelState(integrations), currentRuntime.value.load()]); - if (requiresDescriptor) { - try { - agent.value = await getAgent(rootStore.restApiContext, props.projectId, props.agentId); - } catch { - agent.value = null; - } + try { + agent.value = await getAgent(rootStore.restApiContext, props.projectId, props.agentId); + } catch { + agent.value = null; } } catch { catalogLoadFailed.value = true; @@ -226,8 +260,8 @@ watch(
    @@ -261,96 +295,35 @@ watch(
    - - - - - - - @@ -358,7 +331,7 @@ watch( From 32db83c6b6cab046180adefadf7bcd0e8c884722 Mon Sep 17 00:00:00 2001 From: Rob Hough Date: Mon, 10 Aug 2026 14:46:09 +0100 Subject: [PATCH 028/151] feat(editor): Add more Markdown editor toolbar options (#35630) --- .../frontend/@n8n/design-system/package.json | 1 + .../src/components/N8nIcon/icons.ts | 4 + .../src/components/N8nInput/Input.types.ts | 6 +- .../src/components/N8nInput/Input.vue | 3 + .../components/N8nInput/component-input.md | 2 +- .../MarkdownEditorToolbar.vue | 390 +++++++++++++++--- .../composables/useMarkdownEditor.ts | 2 + .../src/components/N8nToggle/Toggle.vue | 2 +- .../@n8n/design-system/src/locale/lang/en.ts | 12 + .../@n8n/design-system/src/types/input.ts | 2 +- pnpm-lock.yaml | 14 +- pnpm-workspace.yaml | 1 + 12 files changed, 365 insertions(+), 74 deletions(-) diff --git a/packages/frontend/@n8n/design-system/package.json b/packages/frontend/@n8n/design-system/package.json index e9e9fc21db6..ae5648cfc23 100644 --- a/packages/frontend/@n8n/design-system/package.json +++ b/packages/frontend/@n8n/design-system/package.json @@ -79,6 +79,7 @@ "@tiptap/extension-table-row": "catalog:", "@tiptap/extension-task-item": "catalog:", "@tiptap/extension-task-list": "catalog:", + "@tiptap/extension-underline": "catalog:", "@tiptap/markdown": "catalog:", "@tiptap/starter-kit": "catalog:", "@tiptap/vue-3": "catalog:", diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts index 782b1952fcf..3d4db66a223 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts @@ -166,6 +166,7 @@ import IconLucideLightbulb from '~icons/lucide/lightbulb'; import IconLucideLink from '~icons/lucide/link'; import IconLucideList from '~icons/lucide/list'; import IconLucideListChecks from '~icons/lucide/list-checks'; +import IconLucideListOrdered from '~icons/lucide/list-ordered'; import IconLucideListPlus from '~icons/lucide/list-plus'; import IconLucideListTree from '~icons/lucide/list-tree'; import IconLucideLoaderCircle from '~icons/lucide/loader-circle'; @@ -254,6 +255,7 @@ import IconLucideTrendingDown from '~icons/lucide/trending-down'; import IconLucideTrendingUp from '~icons/lucide/trending-up'; import IconLucideTriangleAlert from '~icons/lucide/triangle-alert'; import IconLucideType from '~icons/lucide/type'; +import IconLucideUnderline from '~icons/lucide/underline'; import IconLucideUndo2 from '~icons/lucide/undo-2'; import IconLucideUngroup from '~icons/lucide/ungroup'; import IconLucideUnlink from '~icons/lucide/unlink'; @@ -669,6 +671,7 @@ export const updatedIconSet = { 'list-checks': IconLucideListChecks, 'list-plus': IconLucideListPlus, 'list-tree': IconLucideListTree, + 'list-ordered': IconLucideListOrdered, lock: IconLucideLock, 'log-in': IconLucideLogIn, 'log-out': IconLucideLogOut, @@ -781,6 +784,7 @@ export const updatedIconSet = { 'loader-circle': IconLucideLoaderCircle, quote: IconLucideQuote, 'quick-connect': IconLucideFlame, + underline: IconLucideUnderline, } as const; export type IconName = keyof typeof updatedIconSet; // only new icon names should be used moving forward diff --git a/packages/frontend/@n8n/design-system/src/components/N8nInput/Input.types.ts b/packages/frontend/@n8n/design-system/src/components/N8nInput/Input.types.ts index 17279e48d20..1e590d86533 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nInput/Input.types.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nInput/Input.types.ts @@ -1,5 +1,6 @@ -export type InputType = 'text' | 'textarea' | 'password' | 'number' | 'email'; -export type InputSize = 'xlarge' | 'large' | 'medium' | 'small' | 'mini'; +import type { InputSize, InputType } from '../../types/input'; + +export type { InputSize, InputType }; export type InputAutocomplete = | 'off' | 'on' @@ -17,6 +18,7 @@ export interface InputProps { placeholder?: string; disabled?: boolean; readonly?: boolean; + required?: boolean; clearable?: boolean; rows?: number; // Masks a textarea's content (dots) for secrets that span multiple lines, diff --git a/packages/frontend/@n8n/design-system/src/components/N8nInput/Input.vue b/packages/frontend/@n8n/design-system/src/components/N8nInput/Input.vue index 5e7a9becf7c..6f1a7707f82 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nInput/Input.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nInput/Input.vue @@ -17,6 +17,7 @@ const props = withDefaults(defineProps(), { placeholder: '', disabled: false, readonly: false, + required: false, clearable: false, rows: 2, masked: false, @@ -210,6 +211,7 @@ defineExpose({ focus, blur, select }); :placeholder="placeholder" :disabled="disabled" :readonly="readonly" + :required="required" :maxlength="maxlength" :autocomplete="autocomplete" :name="name" @@ -230,6 +232,7 @@ defineExpose({ focus, blur, select }); :placeholder="placeholder" :disabled="disabled" :readonly="readonly" + :required="required" :rows="autosize ? undefined : rows" :maxlength="maxlength" :autocomplete="autocomplete" diff --git a/packages/frontend/@n8n/design-system/src/components/N8nInput/component-input.md b/packages/frontend/@n8n/design-system/src/components/N8nInput/component-input.md index a1e42dedd7d..36791f25df0 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nInput/component-input.md +++ b/packages/frontend/@n8n/design-system/src/components/N8nInput/component-input.md @@ -13,7 +13,7 @@ A versatile text input component for collecting user text, numbers, passwords, a **Props** - `modelValue?: string | number | null` - The bound value of the input (v-model). Default: `''` -- `type?: InputType` - Type of input field. Values: `'text' | 'textarea' | 'password' | 'number' | 'email'`. Default: `'text'` +- `type?: InputType` - Type of input field. Values: `'text' | 'textarea' | 'password' | 'number' | 'email' | 'url'`. Default: `'text'` - `size?: InputSize` - Size of the input. Values: `'xlarge' | 'large' | 'medium' | 'small' | 'mini'`. Default: `'large'` - `placeholder?: string` - Placeholder text displayed when input is empty. Default: `''` - `disabled?: boolean` - When `true`, prevents user interaction and dims the input. Default: `false` diff --git a/packages/frontend/@n8n/design-system/src/components/N8nMarkdownEditor/MarkdownEditorToolbar.vue b/packages/frontend/@n8n/design-system/src/components/N8nMarkdownEditor/MarkdownEditorToolbar.vue index 6fa0449552a..45895ac27e2 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nMarkdownEditor/MarkdownEditorToolbar.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nMarkdownEditor/MarkdownEditorToolbar.vue @@ -1,16 +1,21 @@ -