diff --git a/cypress/e2e/45-workflow-selector-parameter.cy.ts b/cypress/e2e/45-workflow-selector-parameter.cy.ts index 3a4de55f50..38df9a29b8 100644 --- a/cypress/e2e/45-workflow-selector-parameter.cy.ts +++ b/cypress/e2e/45-workflow-selector-parameter.cy.ts @@ -98,6 +98,10 @@ describe('Workflow Selector Parameter', () => { getVisiblePopper().findChildByTestId('rlc-item').eq(0).click(); - cy.get('@windowOpen').should('be.calledWith', '/workflows/onboarding/0?sampleSubWorkflows=0'); + const SAMPLE_SUBWORKFLOW_TEMPLATE_ID = 'VMiAxXa3lCAizGB5f7dVZQSFfg3FtHkdTKvLuupqBls='; + cy.get('@windowOpen').should( + 'be.calledWith', + `/workflows/onboarding/${SAMPLE_SUBWORKFLOW_TEMPLATE_ID}?sampleSubWorkflows=0`, + ); }); }); diff --git a/docker/images/n8n/n8n-task-runners.json b/docker/images/n8n/n8n-task-runners.json index a37c59fccb..d9575997c0 100644 --- a/docker/images/n8n/n8n-task-runners.json +++ b/docker/images/n8n/n8n-task-runners.json @@ -9,12 +9,12 @@ "PATH", "GENERIC_TIMEZONE", "N8N_RUNNERS_GRANT_TOKEN", - "N8N_RUNNERS_N8N_URI", + "N8N_RUNNERS_TASK_BROKER_URI", "N8N_RUNNERS_MAX_PAYLOAD", "N8N_RUNNERS_MAX_CONCURRENCY", - "N8N_RUNNERS_SERVER_ENABLED", - "N8N_RUNNERS_SERVER_HOST", - "N8N_RUNNERS_SERVER_PORT", + "N8N_RUNNERS_HEALTH_CHECK_SERVER_ENABLED", + "N8N_RUNNERS_HEALTH_CHECK_SERVER_HOST", + "N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT", "NODE_FUNCTION_ALLOW_BUILTIN", "NODE_FUNCTION_ALLOW_EXTERNAL", "NODE_OPTIONS", diff --git a/packages/@n8n/api-types/src/dto/user/settings-update-request.dto.ts b/packages/@n8n/api-types/src/dto/user/settings-update-request.dto.ts index f4c0eb0af3..247b830d91 100644 --- a/packages/@n8n/api-types/src/dto/user/settings-update-request.dto.ts +++ b/packages/@n8n/api-types/src/dto/user/settings-update-request.dto.ts @@ -4,4 +4,5 @@ import { Z } from 'zod-class'; export class SettingsUpdateRequestDto extends Z.class({ userActivated: z.boolean().optional(), allowSSOManualLogin: z.boolean().optional(), + easyAIWorkflowOnboarded: z.boolean().optional(), }) {} diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index 8f9c740ad6..7a9910f510 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -173,4 +173,5 @@ export interface FrontendSettings { }; betaFeatures: FrontendBetaFeatures[]; virtualSchemaView: boolean; + easyAIWorkflowOnboarded: boolean; } diff --git a/packages/@n8n/config/src/configs/runners.config.ts b/packages/@n8n/config/src/configs/runners.config.ts index d3fca6da08..06e262fe49 100644 --- a/packages/@n8n/config/src/configs/runners.config.ts +++ b/packages/@n8n/config/src/configs/runners.config.ts @@ -24,7 +24,7 @@ export class TaskRunnersConfig { authToken: string = ''; /** IP address task runners server should listen on */ - @Env('N8N_RUNNERS_SERVER_PORT') + @Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT') port: number = 5679; /** IP address task runners server should listen on */ diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/update.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/update.operation.ts index 8a997aa99d..bd26ab0a0c 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/update.operation.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/update.operation.ts @@ -4,7 +4,7 @@ import type { INodeExecutionData, IDataObject, } from 'n8n-workflow'; -import { NodeOperationError, updateDisplayOptions } from 'n8n-workflow'; +import { ApplicationError, NodeOperationError, updateDisplayOptions } from 'n8n-workflow'; import { apiRequest } from '../../transport'; import { assistantRLC, modelRLC } from '../descriptions'; @@ -116,6 +116,18 @@ const displayOptions = { export const description = updateDisplayOptions(displayOptions, properties); +function getFileIds(file_ids: unknown): string[] { + if (Array.isArray(file_ids)) { + return file_ids; + } + + if (typeof file_ids === 'string') { + return file_ids.split(',').map((file_id) => file_id.trim()); + } + + throw new ApplicationError('Invalid file_ids type'); +} + export async function execute(this: IExecuteFunctions, i: number): Promise { const assistantId = this.getNodeParameter('assistantId', i, '', { extractValue: true }) as string; const options = this.getNodeParameter('options', i, {}); @@ -137,11 +149,8 @@ export async function execute(this: IExecuteFunctions, i: number): Promise file_id.trim()); - } - if ((file_ids as IDataObject[]).length > 20) { + const files = getFileIds(file_ids); + if (files.length > 20) { throw new NodeOperationError( this.getNode(), 'The maximum number of files that can be attached to the assistant is 20', @@ -152,15 +161,12 @@ export async function execute(this: IExecuteFunctions, i: number): Promise { code_interpreter: { file_ids: [], }, - file_search: { - vector_stores: [ - { - file_ids: [], - }, - ], + }, + tools: [{ type: 'existing_tool' }, { type: 'code_interpreter' }, { type: 'file_search' }], + }, + headers: { 'OpenAI-Beta': 'assistants=v2' }, + }); + }); + + it('update => should call apiRequest with file_ids as an array for search', async () => { + (transport.apiRequest as jest.Mock).mockResolvedValueOnce({ + tools: [{ type: 'existing_tool' }], + }); + (transport.apiRequest as jest.Mock).mockResolvedValueOnce({}); + + await assistant.update.execute.call( + createExecuteFunctionsMock({ + assistantId: 'assistant-id', + options: { + modelId: 'gpt-model', + name: 'name', + instructions: 'some instructions', + codeInterpreter: true, + knowledgeRetrieval: true, + file_ids: ['1234'], + removeCustomTools: false, + }, + }), + 0, + ); + + expect(transport.apiRequest).toHaveBeenCalledTimes(2); + expect(transport.apiRequest).toHaveBeenCalledWith('GET', '/assistants/assistant-id', { + headers: { 'OpenAI-Beta': 'assistants=v2' }, + }); + expect(transport.apiRequest).toHaveBeenCalledWith('POST', '/assistants/assistant-id', { + body: { + instructions: 'some instructions', + model: 'gpt-model', + name: 'name', + tool_resources: { + code_interpreter: { + file_ids: ['1234'], + }, + }, + tools: [{ type: 'existing_tool' }, { type: 'code_interpreter' }, { type: 'file_search' }], + }, + headers: { 'OpenAI-Beta': 'assistants=v2' }, + }); + }); + + it('update => should call apiRequest with file_ids as strings for search', async () => { + (transport.apiRequest as jest.Mock).mockResolvedValueOnce({ + tools: [{ type: 'existing_tool' }], + }); + (transport.apiRequest as jest.Mock).mockResolvedValueOnce({}); + + await assistant.update.execute.call( + createExecuteFunctionsMock({ + assistantId: 'assistant-id', + options: { + modelId: 'gpt-model', + name: 'name', + instructions: 'some instructions', + codeInterpreter: true, + knowledgeRetrieval: true, + file_ids: '1234, 5678, 90', + removeCustomTools: false, + }, + }), + 0, + ); + + expect(transport.apiRequest).toHaveBeenCalledTimes(2); + expect(transport.apiRequest).toHaveBeenCalledWith('GET', '/assistants/assistant-id', { + headers: { 'OpenAI-Beta': 'assistants=v2' }, + }); + expect(transport.apiRequest).toHaveBeenCalledWith('POST', '/assistants/assistant-id', { + body: { + instructions: 'some instructions', + model: 'gpt-model', + name: 'name', + tool_resources: { + code_interpreter: { + file_ids: ['1234', '5678', '90'], }, }, tools: [{ type: 'existing_tool' }, { type: 'code_interpreter' }, { type: 'file_search' }], 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 d70f7e2ee8..a1059adf4b 100644 --- a/packages/@n8n/task-runner/src/config/base-runner-config.ts +++ b/packages/@n8n/task-runner/src/config/base-runner-config.ts @@ -2,20 +2,20 @@ import { Config, Env, Nested } from '@n8n/config'; @Config class HealthcheckServerConfig { - @Env('N8N_RUNNERS_SERVER_ENABLED') + @Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_ENABLED') enabled: boolean = false; - @Env('N8N_RUNNERS_SERVER_HOST') + @Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_HOST') host: string = '127.0.0.1'; - @Env('N8N_RUNNERS_SERVER_PORT') + @Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT') port: number = 5681; } @Config export class BaseRunnerConfig { - @Env('N8N_RUNNERS_N8N_URI') - n8nUri: string = '127.0.0.1:5679'; + @Env('N8N_RUNNERS_TASK_BROKER_URI') + taskBrokerUri: string = 'http://127.0.0.1:5679'; @Env('N8N_RUNNERS_GRANT_TOKEN') grantToken: string = ''; diff --git a/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts b/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts index a99e8b9f07..439de19eac 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts @@ -34,7 +34,7 @@ describe('JsTaskRunner', () => { ...defaultConfig.baseRunnerConfig, grantToken: 'grantToken', maxConcurrency: 1, - n8nUri: 'localhost', + taskBrokerUri: 'http://localhost', ...baseRunnerOpts, }, jsRunnerConfig: { @@ -311,10 +311,10 @@ describe('JsTaskRunner', () => { }); it("should not expose task runner's env variables even if no env state is received", async () => { - process.env.N8N_RUNNERS_N8N_URI = 'http://127.0.0.1:5679'; + process.env.N8N_RUNNERS_TASK_BROKER_URI = 'http://127.0.0.1:5679'; const outcome = await execTaskWithParams({ task: newTaskWithSettings({ - code: 'return { val: $env.N8N_RUNNERS_N8N_URI }', + code: 'return { val: $env.N8N_RUNNERS_TASK_BROKER_URI }', nodeMode: 'runOnceForAllItems', }), taskData: newDataRequestResponse(inputItems.map(wrapIntoJson), { 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 new file mode 100644 index 0000000000..c633e95688 --- /dev/null +++ b/packages/@n8n/task-runner/src/js-task-runner/__tests__/task-runner.test.ts @@ -0,0 +1,89 @@ +import { WebSocket } from 'ws'; + +import { TaskRunner } from '@/task-runner'; + +class TestRunner extends TaskRunner {} + +jest.mock('ws'); + +describe('TestRunner', () => { + describe('constructor', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should correctly construct WebSocket URI with provided taskBrokerUri', () => { + const runner = new TestRunner({ + taskType: 'test-task', + maxConcurrency: 5, + idleTimeout: 60, + grantToken: 'test-token', + maxPayloadSize: 1024, + taskBrokerUri: 'http://localhost:8080', + timezone: 'America/New_York', + healthcheckServer: { + enabled: false, + host: 'localhost', + port: 8081, + }, + }); + + expect(WebSocket).toHaveBeenCalledWith( + `ws://localhost:8080/runners/_ws?id=${runner.id}`, + expect.objectContaining({ + headers: { + authorization: 'Bearer test-token', + }, + maxPayload: 1024, + }), + ); + }); + + it('should handle different taskBrokerUri formats correctly', () => { + const runner = new TestRunner({ + taskType: 'test-task', + maxConcurrency: 5, + idleTimeout: 60, + grantToken: 'test-token', + maxPayloadSize: 1024, + taskBrokerUri: 'https://example.com:3000/path', + timezone: 'America/New_York', + healthcheckServer: { + enabled: false, + host: 'localhost', + port: 8081, + }, + }); + + expect(WebSocket).toHaveBeenCalledWith( + `ws://example.com:3000/runners/_ws?id=${runner.id}`, + expect.objectContaining({ + headers: { + authorization: 'Bearer test-token', + }, + maxPayload: 1024, + }), + ); + }); + + it('should throw an error if taskBrokerUri is invalid', () => { + expect( + () => + new TestRunner({ + taskType: 'test-task', + maxConcurrency: 5, + idleTimeout: 60, + grantToken: 'test-token', + maxPayloadSize: 1024, + taskBrokerUri: 'not-a-valid-uri', + timezone: 'America/New_York', + healthcheckServer: { + enabled: false, + host: 'localhost', + port: 8081, + }, + }), + ).toThrowError(/Invalid URL/); + }); + }); +}); diff --git a/packages/@n8n/task-runner/src/task-runner.ts b/packages/@n8n/task-runner/src/task-runner.ts index 1486af280d..f0af115b5a 100644 --- a/packages/@n8n/task-runner/src/task-runner.ts +++ b/packages/@n8n/task-runner/src/task-runner.ts @@ -92,7 +92,9 @@ export abstract class TaskRunner extends EventEmitter { this.maxConcurrency = opts.maxConcurrency; this.idleTimeout = opts.idleTimeout; - const wsUrl = `ws://${opts.n8nUri}/runners/_ws?id=${this.id}`; + const { host: taskBrokerHost } = new URL(opts.taskBrokerUri); + + const wsUrl = `ws://${taskBrokerHost}/runners/_ws?id=${this.id}`; this.ws = new WebSocket(wsUrl, { headers: { authorization: `Bearer ${opts.grantToken}`, @@ -109,11 +111,11 @@ export abstract class TaskRunner extends EventEmitter { ['ECONNREFUSED', 'ENOTFOUND'].some((code) => code === error.code) ) { console.error( - `Error: Failed to connect to n8n. Please ensure n8n is reachable at: ${opts.n8nUri}`, + `Error: Failed to connect to n8n task broker. Please ensure n8n task broker is reachable at: ${taskBrokerHost}`, ); process.exit(1); } else { - console.error(`Error: Failed to connect to n8n at ${opts.n8nUri}`); + console.error(`Error: Failed to connect to n8n task broker at ${taskBrokerHost}`); console.error('Details:', event.message || 'Unknown error'); } }); diff --git a/packages/cli/src/__tests__/wait-tracker.test.ts b/packages/cli/src/__tests__/wait-tracker.test.ts index 49e8517272..ef420e7d78 100644 --- a/packages/cli/src/__tests__/wait-tracker.test.ts +++ b/packages/cli/src/__tests__/wait-tracker.test.ts @@ -1,7 +1,9 @@ import { mock } from 'jest-mock-extended'; import type { InstanceSettings } from 'n8n-core'; -import type { IWorkflowBase } from 'n8n-workflow'; +import type { IRun, IWorkflowBase } from 'n8n-workflow'; +import { createDeferredPromise } from 'n8n-workflow'; +import type { ActiveExecutions } from '@/active-executions'; import type { Project } from '@/databases/entities/project'; import type { ExecutionRepository } from '@/databases/repositories/execution.repository'; import type { IExecutionResponse } from '@/interfaces'; @@ -12,9 +14,10 @@ import { WaitTracker } from '@/wait-tracker'; import type { WorkflowRunner } from '@/workflow-runner'; import { mockLogger } from '@test/mocking'; -jest.useFakeTimers(); +jest.useFakeTimers({ advanceTimers: true }); describe('WaitTracker', () => { + const activeExecutions = mock(); const ownershipService = mock(); const workflowRunner = mock(); const executionRepository = mock(); @@ -30,6 +33,7 @@ describe('WaitTracker', () => { mode: 'manual', data: mock({ pushRef: 'push_ref', + parentExecution: undefined, }), }); execution.workflowData = mock({ id: 'abcd' }); @@ -40,6 +44,7 @@ describe('WaitTracker', () => { mockLogger(), executionRepository, ownershipService, + activeExecutions, workflowRunner, orchestrationService, instanceSettings, @@ -80,7 +85,9 @@ describe('WaitTracker', () => { let startExecutionSpy: jest.SpyInstance, [executionId: string]>; beforeEach(() => { - executionRepository.findSingleExecution.mockResolvedValue(execution); + executionRepository.findSingleExecution + .calledWith(execution.id) + .mockResolvedValue(execution); executionRepository.getWaitingExecutions.mockResolvedValue([execution]); ownershipService.getWorkflowProjectCached.mockResolvedValue(project); @@ -110,13 +117,17 @@ describe('WaitTracker', () => { }); describe('startExecution()', () => { - it('should query for execution to start', async () => { + beforeEach(() => { executionRepository.getWaitingExecutions.mockResolvedValue([]); waitTracker.init(); - executionRepository.findSingleExecution.mockResolvedValue(execution); + executionRepository.findSingleExecution.calledWith(execution.id).mockResolvedValue(execution); ownershipService.getWorkflowProjectCached.mockResolvedValue(project); + execution.data.parentExecution = undefined; + }); + + it('should query for execution to start', async () => { await waitTracker.startExecution(execution.id); expect(executionRepository.findSingleExecution).toHaveBeenCalledWith(execution.id, { @@ -137,6 +148,65 @@ describe('WaitTracker', () => { execution.id, ); }); + + it('should also resume parent execution once sub-workflow finishes', async () => { + const parentExecution = mock({ + id: 'parent_execution_id', + finished: false, + }); + parentExecution.workflowData = mock({ id: 'parent_workflow_id' }); + execution.data.parentExecution = { + executionId: parentExecution.id, + workflowId: parentExecution.workflowData.id, + }; + executionRepository.findSingleExecution + .calledWith(parentExecution.id) + .mockResolvedValue(parentExecution); + const postExecutePromise = createDeferredPromise(); + activeExecutions.getPostExecutePromise + .calledWith(execution.id) + .mockReturnValue(postExecutePromise.promise); + + await waitTracker.startExecution(execution.id); + + expect(executionRepository.findSingleExecution).toHaveBeenNthCalledWith(1, execution.id, { + includeData: true, + unflattenData: true, + }); + + expect(workflowRunner.run).toHaveBeenCalledTimes(1); + expect(workflowRunner.run).toHaveBeenNthCalledWith( + 1, + { + executionMode: execution.mode, + executionData: execution.data, + workflowData: execution.workflowData, + projectId: project.id, + pushRef: execution.data.pushRef, + }, + false, + false, + execution.id, + ); + + postExecutePromise.resolve(mock()); + await jest.advanceTimersByTimeAsync(100); + + expect(workflowRunner.run).toHaveBeenCalledTimes(2); + expect(workflowRunner.run).toHaveBeenNthCalledWith( + 2, + { + executionMode: parentExecution.mode, + executionData: parentExecution.data, + workflowData: parentExecution.workflowData, + projectId: project.id, + pushRef: parentExecution.data.pushRef, + }, + false, + false, + parentExecution.id, + ); + }); }); describe('single-main setup', () => { @@ -165,6 +235,7 @@ describe('WaitTracker', () => { mockLogger(), executionRepository, ownershipService, + activeExecutions, workflowRunner, orchestrationService, mock({ isLeader: false }), diff --git a/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts b/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts index d0aeb3111f..e7d94d3e34 100644 --- a/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts +++ b/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts @@ -1,11 +1,11 @@ import { mock } from 'jest-mock-extended'; import type { IWorkflowBase } from 'n8n-workflow'; -import { - type IExecuteWorkflowInfo, - type IWorkflowExecuteAdditionalData, - type ExecuteWorkflowOptions, - type IRun, - type INodeExecutionData, +import type { + IExecuteWorkflowInfo, + IWorkflowExecuteAdditionalData, + ExecuteWorkflowOptions, + IRun, + INodeExecutionData, } from 'n8n-workflow'; import type PCancelable from 'p-cancelable'; import Container from 'typedi'; @@ -50,6 +50,7 @@ const getMockRun = ({ lastNodeOutput }: { lastNodeOutput: Array @@ -114,7 +115,9 @@ describe('WorkflowExecuteAdditionalData', () => { }); describe('executeWorkflow', () => { - const runWithData = getMockRun({ lastNodeOutput: [[{ json: { test: 1 } }]] }); + const runWithData = getMockRun({ + lastNodeOutput: [[{ json: { test: 1 } }]], + }); beforeEach(() => { workflowRepository.get.mockResolvedValue( @@ -159,6 +162,23 @@ describe('WorkflowExecuteAdditionalData', () => { expect(executionRepository.setRunning).toHaveBeenCalledWith(EXECUTION_ID); }); + + it('should return waitTill property when workflow execution is waiting', async () => { + const waitTill = new Date(); + runWithData.waitTill = waitTill; + + const response = await executeWorkflow( + mock(), + mock(), + mock({ loadedWorkflowData: undefined, doNotWaitToFinish: false }), + ); + + expect(response).toEqual({ + data: runWithData.data.resultData.runData[LAST_NODE_EXECUTED][0].data!.main, + executionId: EXECUTION_ID, + waitTill, + }); + }); }); describe('getRunData', () => { @@ -230,6 +250,10 @@ describe('WorkflowExecuteAdditionalData', () => { waitingExecution: {}, waitingExecutionSource: {}, }, + parentExecution: { + executionId: '123', + workflowId: '567', + }, resultData: { runData: {} }, startData: {}, }, diff --git a/packages/cli/src/config/types.ts b/packages/cli/src/config/types.ts index 78f2358f5d..33fff9b946 100644 --- a/packages/cli/src/config/types.ts +++ b/packages/cli/src/config/types.ts @@ -80,6 +80,7 @@ type ExceptionPaths = { processedDataManager: IProcessedDataConfig; 'userManagement.isInstanceOwnerSetUp': boolean; 'ui.banners.dismissed': string[] | undefined; + easyAIWorkflowOnboarded: boolean | undefined; }; // ----------------------------------- diff --git a/packages/cli/src/runners/task-runner-process.ts b/packages/cli/src/runners/task-runner-process.ts index e33157a286..d989107718 100644 --- a/packages/cli/src/runners/task-runner-process.ts +++ b/packages/cli/src/runners/task-runner-process.ts @@ -95,19 +95,19 @@ export class TaskRunnerProcess extends TypedEmitter { const grantToken = await this.authService.createGrantToken(); - const n8nUri = `127.0.0.1:${this.runnerConfig.port}`; - this.process = this.startNode(grantToken, n8nUri); + const taskBrokerUri = `http://127.0.0.1:${this.runnerConfig.port}`; + this.process = this.startNode(grantToken, taskBrokerUri); forwardToLogger(this.logger, this.process, '[Task Runner]: '); this.monitorProcess(this.process); } - startNode(grantToken: string, n8nUri: string) { + startNode(grantToken: string, taskBrokerUri: string) { const startScript = require.resolve('@n8n/task-runner/start'); return spawn('node', [startScript], { - env: this.getProcessEnvVars(grantToken, n8nUri), + env: this.getProcessEnvVars(grantToken, taskBrokerUri), }); } @@ -159,10 +159,10 @@ export class TaskRunnerProcess extends TypedEmitter { } } - private getProcessEnvVars(grantToken: string, n8nUri: string) { + private getProcessEnvVars(grantToken: string, taskBrokerUri: string) { const envVars: Record = { N8N_RUNNERS_GRANT_TOKEN: grantToken, - N8N_RUNNERS_N8N_URI: n8nUri, + N8N_RUNNERS_TASK_BROKER_URI: taskBrokerUri, N8N_RUNNERS_MAX_PAYLOAD: this.runnerConfig.maxPayload.toString(), N8N_RUNNERS_MAX_CONCURRENCY: this.runnerConfig.maxConcurrency.toString(), ...this.getPassthroughEnvVars(), diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 79d04b2263..535239d5a4 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -232,6 +232,7 @@ export class FrontendService { }, betaFeatures: this.frontendConfig.betaFeatures, virtualSchemaView: config.getEnv('virtualSchemaView'), + easyAIWorkflowOnboarded: false, }; } @@ -274,6 +275,11 @@ export class FrontendService { } this.settings.banners.dismissed = dismissedBanners; + try { + this.settings.easyAIWorkflowOnboarded = config.getEnv('easyAIWorkflowOnboarded') ?? false; + } catch { + this.settings.easyAIWorkflowOnboarded = false; + } const isS3Selected = config.getEnv('binaryDataManager.mode') === 's3'; const isS3Available = config.getEnv('binaryDataManager.availableModes').includes('s3'); diff --git a/packages/cli/src/wait-tracker.ts b/packages/cli/src/wait-tracker.ts index 7035db3cbe..a80ae8f259 100644 --- a/packages/cli/src/wait-tracker.ts +++ b/packages/cli/src/wait-tracker.ts @@ -2,6 +2,7 @@ import { InstanceSettings } from 'n8n-core'; import { ApplicationError, type IWorkflowExecutionDataProcess } from 'n8n-workflow'; import { Service } from 'typedi'; +import { ActiveExecutions } from '@/active-executions'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { Logger } from '@/logging/logger.service'; import { OrchestrationService } from '@/services/orchestration.service'; @@ -23,6 +24,7 @@ export class WaitTracker { private readonly logger: Logger, private readonly executionRepository: ExecutionRepository, private readonly ownershipService: OwnershipService, + private readonly activeExecutions: ActiveExecutions, private readonly workflowRunner: WorkflowRunner, private readonly orchestrationService: OrchestrationService, private readonly instanceSettings: InstanceSettings, @@ -133,6 +135,14 @@ export class WaitTracker { // Start the execution again await this.workflowRunner.run(data, false, false, executionId); + + const { parentExecution } = fullExecutionData.data; + if (parentExecution) { + // on child execution completion, resume parent execution + void this.activeExecutions.getPostExecutePromise(executionId).then(() => { + void this.startExecution(parentExecution.executionId); + }); + } } stopTracking() { diff --git a/packages/cli/src/webhooks/webhook-helpers.ts b/packages/cli/src/webhooks/webhook-helpers.ts index 0dd8f576a4..259142561a 100644 --- a/packages/cli/src/webhooks/webhook-helpers.ts +++ b/packages/cli/src/webhooks/webhook-helpers.ts @@ -48,11 +48,12 @@ import type { Project } from '@/databases/entities/project'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; -import type { IExecutionDb, IWorkflowDb } from '@/interfaces'; +import type { IWorkflowDb } from '@/interfaces'; import { Logger } from '@/logging/logger.service'; import { parseBody } from '@/middlewares'; import { OwnershipService } from '@/services/ownership.service'; import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; +import { WaitTracker } from '@/wait-tracker'; import { createMultiFormDataParser } from '@/webhooks/webhook-form-data'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; import * as WorkflowHelpers from '@/workflow-helpers'; @@ -548,11 +549,21 @@ export async function executeWebhook( { executionId }, ); + const activeExecutions = Container.get(ActiveExecutions); + + // Get a promise which resolves when the workflow did execute and send then response + const executePromise = activeExecutions.getPostExecutePromise(executionId); + + const { parentExecution } = runExecutionData; + if (parentExecution) { + // on child execution completion, resume parent execution + void executePromise.then(() => { + const waitTracker = Container.get(WaitTracker); + void waitTracker.startExecution(parentExecution.executionId); + }); + } + if (!didSendResponse) { - // Get a promise which resolves when the workflow did execute and send then response - const executePromise = Container.get(ActiveExecutions).getPostExecutePromise( - executionId, - ) as Promise; executePromise // eslint-disable-next-line complexity .then(async (data) => { diff --git a/packages/cli/src/workflow-execute-additional-data.ts b/packages/cli/src/workflow-execute-additional-data.ts index 2588a442d9..e3e65bcb4d 100644 --- a/packages/cli/src/workflow-execute-additional-data.ts +++ b/packages/cli/src/workflow-execute-additional-data.ts @@ -709,6 +709,7 @@ export async function getRunData( waitingExecution: {}, waitingExecutionSource: {}, }, + parentExecution, }; return { @@ -944,6 +945,7 @@ async function startExecution( return { executionId, data: returnData!.data!.main, + waitTill: data.waitTill, }; } activeExecutions.finalizeExecution(executionId, data); diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 7ff1645335..4400609fd8 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -13,13 +13,7 @@ import type { OAuth2CredentialData, } from '@n8n/client-oauth2'; import { ClientOAuth2 } from '@n8n/client-oauth2'; -import type { - AxiosError, - AxiosHeaders, - AxiosPromise, - AxiosRequestConfig, - AxiosResponse, -} from 'axios'; +import type { AxiosError, AxiosHeaders, AxiosRequestConfig, AxiosResponse } from 'axios'; import axios from 'axios'; import crypto, { createHmac } from 'crypto'; import FileType from 'file-type'; @@ -748,6 +742,26 @@ export async function binaryToString(body: Buffer | Readable, encoding?: string) return iconv.decode(buffer, encoding ?? 'utf-8'); } +export async function invokeAxios( + axiosConfig: AxiosRequestConfig, + authOptions: IRequestOptions['auth'] = {}, +) { + try { + return await axios(axiosConfig); + } catch (error) { + if (authOptions.sendImmediately !== false || !(error instanceof axios.AxiosError)) throw error; + // for digest-auth + const { response } = error; + if (response?.status !== 401 || !response.headers['www-authenticate']?.includes('nonce')) { + throw error; + } + const { auth } = axiosConfig; + delete axiosConfig.auth; + axiosConfig = digestAuthAxiosConfig(axiosConfig, response, auth); + return await axios(axiosConfig); + } +} + export async function proxyRequestToAxios( workflow: Workflow | undefined, additionalData: IWorkflowExecuteAdditionalData | undefined, @@ -768,29 +782,8 @@ export async function proxyRequestToAxios( axiosConfig = Object.assign(axiosConfig, await parseRequestObject(configObject)); - let requestFn: () => AxiosPromise; - if (configObject.auth?.sendImmediately === false) { - // for digest-auth - requestFn = async () => { - try { - return await axios(axiosConfig); - } catch (error) { - const { response } = error; - if (response?.status !== 401 || !response.headers['www-authenticate']?.includes('nonce')) { - throw error; - } - const { auth } = axiosConfig; - delete axiosConfig.auth; - axiosConfig = digestAuthAxiosConfig(axiosConfig, response, auth); - return await axios(axiosConfig); - } - }; - } else { - requestFn = async () => await axios(axiosConfig); - } - try { - const response = await requestFn(); + const response = await invokeAxios(axiosConfig, configObject.auth); let body = response.data; if (body instanceof IncomingMessage && axiosConfig.responseType === 'stream') { parseIncomingMessage(body); @@ -982,7 +975,7 @@ export async function httpRequest( ): Promise { removeEmptyBody(requestOptions); - let axiosRequest = convertN8nRequestToAxios(requestOptions); + const axiosRequest = convertN8nRequestToAxios(requestOptions); if ( axiosRequest.data === undefined || (axiosRequest.method !== undefined && axiosRequest.method.toUpperCase() === 'GET') @@ -990,23 +983,7 @@ export async function httpRequest( delete axiosRequest.data; } - let result: AxiosResponse; - try { - result = await axios(axiosRequest); - } catch (error) { - if (requestOptions.auth?.sendImmediately === false) { - const { response } = error; - if (response?.status !== 401 || !response.headers['www-authenticate']?.includes('nonce')) { - throw error; - } - - const { auth } = axiosRequest; - delete axiosRequest.auth; - axiosRequest = digestAuthAxiosConfig(axiosRequest, response, auth); - result = await axios(axiosRequest); - } - throw error; - } + const result = await invokeAxios(axiosRequest, requestOptions.auth); if (requestOptions.returnFullResponse) { return { diff --git a/packages/core/src/node-execution-context/__tests__/shared-tests.ts b/packages/core/src/node-execution-context/__tests__/shared-tests.ts index c7262554d0..9992507bdd 100644 --- a/packages/core/src/node-execution-context/__tests__/shared-tests.ts +++ b/packages/core/src/node-execution-context/__tests__/shared-tests.ts @@ -1,4 +1,4 @@ -import { captor, mock } from 'jest-mock-extended'; +import { captor, mock, type MockProxy } from 'jest-mock-extended'; import type { IRunExecutionData, ContextType, @@ -9,11 +9,21 @@ import type { ITaskMetadata, ISourceData, IExecuteData, + IWorkflowExecuteAdditionalData, + ExecuteWorkflowData, + RelatedExecution, + IExecuteWorkflowInfo, } from 'n8n-workflow'; -import { ApplicationError, NodeHelpers } from 'n8n-workflow'; +import { ApplicationError, NodeHelpers, WAIT_INDEFINITELY } from 'n8n-workflow'; +import Container from 'typedi'; + +import { BinaryDataService } from '@/BinaryData/BinaryData.service'; import type { BaseExecuteContext } from '../base-execute-context'; +const binaryDataService = mock(); +Container.set(BinaryDataService, binaryDataService); + export const describeCommonTests = ( context: BaseExecuteContext, { @@ -31,7 +41,7 @@ export const describeCommonTests = ( }, ) => { // @ts-expect-error `additionalData` is private - const { additionalData } = context; + const additionalData = context.additionalData as MockProxy; describe('getExecutionCancelSignal', () => { it('should return the abort signal', () => { @@ -178,4 +188,55 @@ export const describeCommonTests = ( resolveSimpleParameterValueSpy.mockRestore(); }); }); + + describe('putExecutionToWait', () => { + it('should set waitTill and execution status', async () => { + const waitTill = new Date(); + + await context.putExecutionToWait(waitTill); + + expect(runExecutionData.waitTill).toEqual(waitTill); + expect(additionalData.setExecutionStatus).toHaveBeenCalledWith('waiting'); + }); + }); + + describe('executeWorkflow', () => { + const data = [[{ json: { test: true } }]]; + const executeWorkflowData = mock(); + const workflowInfo = mock(); + const parentExecution: RelatedExecution = { + executionId: 'parent_execution_id', + workflowId: 'parent_workflow_id', + }; + + it('should execute workflow and return data', async () => { + additionalData.executeWorkflow.mockResolvedValue(executeWorkflowData); + binaryDataService.duplicateBinaryData.mockResolvedValue(data); + + const result = await context.executeWorkflow(workflowInfo, undefined, undefined, { + parentExecution, + }); + + expect(result.data).toEqual(data); + expect(binaryDataService.duplicateBinaryData).toHaveBeenCalledWith( + workflow.id, + additionalData.executionId, + executeWorkflowData.data, + ); + }); + + it('should put execution to wait if waitTill is returned', async () => { + const waitTill = new Date(); + additionalData.executeWorkflow.mockResolvedValue({ ...executeWorkflowData, waitTill }); + binaryDataService.duplicateBinaryData.mockResolvedValue(data); + + const result = await context.executeWorkflow(workflowInfo, undefined, undefined, { + parentExecution, + }); + + expect(additionalData.setExecutionStatus).toHaveBeenCalledWith('waiting'); + expect(runExecutionData.waitTill).toEqual(WAIT_INDEFINITELY); + expect(result.waitTill).toBe(waitTill); + }); + }); }; diff --git a/packages/core/src/node-execution-context/base-execute-context.ts b/packages/core/src/node-execution-context/base-execute-context.ts index c13ba66dc2..0794a263b0 100644 --- a/packages/core/src/node-execution-context/base-execute-context.ts +++ b/packages/core/src/node-execution-context/base-execute-context.ts @@ -22,7 +22,7 @@ import type { ISourceData, AiEvent, } from 'n8n-workflow'; -import { ApplicationError, NodeHelpers, WorkflowDataProxy } from 'n8n-workflow'; +import { ApplicationError, NodeHelpers, WAIT_INDEFINITELY, WorkflowDataProxy } from 'n8n-workflow'; import { Container } from 'typedi'; import { BinaryDataService } from '@/BinaryData/BinaryData.service'; @@ -97,6 +97,13 @@ export class BaseExecuteContext extends NodeExecutionContext { ); } + async putExecutionToWait(waitTill: Date): Promise { + this.runExecutionData.waitTill = waitTill; + if (this.additionalData.setExecutionStatus) { + this.additionalData.setExecutionStatus('waiting'); + } + } + async executeWorkflow( workflowInfo: IExecuteWorkflowInfo, inputData?: INodeExecutionData[], @@ -106,23 +113,28 @@ export class BaseExecuteContext extends NodeExecutionContext { parentExecution?: RelatedExecution; }, ): Promise { - return await this.additionalData - .executeWorkflow(workflowInfo, this.additionalData, { - ...options, - parentWorkflowId: this.workflow.id?.toString(), - inputData, - parentWorkflowSettings: this.workflow.settings, - node: this.node, - parentCallbackManager, - }) - .then(async (result) => { - const data = await this.binaryDataService.duplicateBinaryData( - this.workflow.id, - this.additionalData.executionId!, - result.data, - ); - return { ...result, data }; - }); + const result = await this.additionalData.executeWorkflow(workflowInfo, this.additionalData, { + ...options, + parentWorkflowId: this.workflow.id, + inputData, + parentWorkflowSettings: this.workflow.settings, + node: this.node, + parentCallbackManager, + }); + + // If a sub-workflow execution goes into the waiting state + if (result.waitTill) { + // then put the parent workflow execution also into the waiting state, + // but do not use the sub-workflow `waitTill` to avoid WaitTracker resuming the parent execution at the same time as the sub-workflow + await this.putExecutionToWait(WAIT_INDEFINITELY); + } + + const data = await this.binaryDataService.duplicateBinaryData( + this.workflow.id, + this.additionalData.executionId!, + result.data, + ); + return { ...result, data }; } getNodeInputs(): INodeInputConfiguration[] { diff --git a/packages/core/src/node-execution-context/execute-context.ts b/packages/core/src/node-execution-context/execute-context.ts index 514c9cf27f..c587fb2168 100644 --- a/packages/core/src/node-execution-context/execute-context.ts +++ b/packages/core/src/node-execution-context/execute-context.ts @@ -179,13 +179,6 @@ export class ExecuteContext extends BaseExecuteContext implements IExecuteFuncti return inputData[inputIndex]; } - async putExecutionToWait(waitTill: Date): Promise { - this.runExecutionData.waitTill = waitTill; - if (this.additionalData.setExecutionStatus) { - this.additionalData.setExecutionStatus('waiting'); - } - } - logNodeOutput(...args: unknown[]): void { if (this.mode === 'manual') { this.sendMessageToUI(...args); diff --git a/packages/core/test/NodeExecuteFunctions.test.ts b/packages/core/test/NodeExecuteFunctions.test.ts index f754abec58..cb2b43ca02 100644 --- a/packages/core/test/NodeExecuteFunctions.test.ts +++ b/packages/core/test/NodeExecuteFunctions.test.ts @@ -1,3 +1,4 @@ +import FormData from 'form-data'; import { mkdtempSync, readFileSync } from 'fs'; import { IncomingMessage } from 'http'; import type { Agent } from 'https'; @@ -26,6 +27,7 @@ import { binaryToString, copyInputItems, getBinaryDataBuffer, + invokeAxios, isFilePathBlocked, parseContentDisposition, parseContentType, @@ -543,6 +545,46 @@ describe('NodeExecuteFunctions', () => { }); describe('parseRequestObject', () => { + test('should handle basic request options', async () => { + const axiosOptions = await parseRequestObject({ + url: 'https://example.com', + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: { key: 'value' }, + }); + + expect(axiosOptions).toEqual( + expect.objectContaining({ + url: 'https://example.com', + method: 'POST', + headers: { accept: '*/*', 'content-type': 'application/json' }, + data: { key: 'value' }, + maxRedirects: 0, + }), + ); + }); + + test('should set correct headers for FormData', async () => { + const formData = new FormData(); + formData.append('key', 'value'); + + const axiosOptions = await parseRequestObject({ + url: 'https://example.com', + formData, + headers: { + 'content-type': 'multipart/form-data', + }, + }); + + expect(axiosOptions.headers).toMatchObject({ + accept: '*/*', + 'content-length': 163, + 'content-type': expect.stringMatching(/^multipart\/form-data; boundary=/), + }); + + expect(axiosOptions.data).toBeInstanceOf(FormData); + }); + test('should not use Host header for SNI', async () => { const axiosOptions = await parseRequestObject({ url: 'https://example.de/foo/bar', @@ -628,6 +670,78 @@ describe('NodeExecuteFunctions', () => { }); }); + describe('invokeAxios', () => { + const baseUrl = 'http://example.de'; + + beforeEach(() => { + nock.cleanAll(); + nock.disableNetConnect(); + jest.clearAllMocks(); + }); + + it('should throw error for non-401 status codes', async () => { + nock(baseUrl).get('/test').reply(500, {}); + + await expect(invokeAxios({ url: `${baseUrl}/test` })).rejects.toThrow( + 'Request failed with status code 500', + ); + }); + + it('should throw error on 401 without digest auth challenge', async () => { + nock(baseUrl).get('/test').reply(401, {}); + + await expect( + invokeAxios( + { + url: `${baseUrl}/test`, + }, + { sendImmediately: false }, + ), + ).rejects.toThrow('Request failed with status code 401'); + }); + + it('should make successful requests', async () => { + nock(baseUrl).get('/test').reply(200, { success: true }); + + const response = await invokeAxios({ + url: `${baseUrl}/test`, + }); + + expect(response.status).toBe(200); + expect(response.data).toEqual({ success: true }); + }); + + it('should handle digest auth when receiving 401 with nonce', async () => { + nock(baseUrl) + .get('/test') + .matchHeader('authorization', 'Basic dXNlcjpwYXNz') + .once() + .reply(401, {}, { 'www-authenticate': 'Digest realm="test", nonce="abc123", qop="auth"' }); + + nock(baseUrl) + .get('/test') + .matchHeader( + 'authorization', + /^Digest username="user",realm="test",nonce="abc123",uri="\/test",qop="auth",algorithm="MD5",response="[0-9a-f]{32}"/, + ) + .reply(200, { success: true }); + + const response = await invokeAxios( + { + url: `${baseUrl}/test`, + auth: { + username: 'user', + password: 'pass', + }, + }, + { sendImmediately: false }, + ); + + expect(response.status).toBe(200); + expect(response.data).toEqual({ success: true }); + }); + }); + describe('copyInputItems', () => { it('should pick only selected properties', () => { const output = copyInputItems( diff --git a/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.test.ts b/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.test.ts index a7d9936e08..fe57291225 100644 --- a/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.test.ts +++ b/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.test.ts @@ -33,7 +33,7 @@ describe('N8nNavigationDropdown', () => { it('default slot should trigger first level', async () => { const { getByTestId, queryByTestId } = render(NavigationDropdown, { slots: { default: h('button', { 'data-test-id': 'test-trigger' }) }, - props: { menu: [{ id: 'aaa', title: 'aaa', route: { name: 'projects' } }] }, + props: { menu: [{ id: 'first', title: 'first', route: { name: 'projects' } }] }, global: { plugins: [router], }, @@ -51,9 +51,9 @@ describe('N8nNavigationDropdown', () => { props: { menu: [ { - id: 'aaa', - title: 'aaa', - submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' } }], + id: 'first', + title: 'first', + submenu: [{ id: 'nested', title: 'nested', route: { name: 'projects' } }], }, ], }, @@ -80,9 +80,9 @@ describe('N8nNavigationDropdown', () => { props: { menu: [ { - id: 'aaa', - title: 'aaa', - submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' }, icon: 'user' }], + id: 'first', + title: 'first', + submenu: [{ id: 'nested', title: 'nested', route: { name: 'projects' }, icon: 'user' }], }, ], }, @@ -100,9 +100,9 @@ describe('N8nNavigationDropdown', () => { props: { menu: [ { - id: 'aaa', - title: 'aaa', - submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' }, icon: 'user' }], + id: 'first', + title: 'first', + submenu: [{ id: 'nested', title: 'nested', route: { name: 'projects' }, icon: 'user' }], }, ], }, @@ -114,8 +114,53 @@ describe('N8nNavigationDropdown', () => { await userEvent.click(getByTestId('navigation-submenu-item')); expect(emitted('itemClick')).toStrictEqual([ - [{ active: true, index: 'bbb', indexPath: ['-1', 'aaa', 'bbb'] }], + [{ active: true, index: 'nested', indexPath: ['-1', 'first', 'nested'] }], ]); - expect(emitted('select')).toStrictEqual([['bbb']]); + expect(emitted('select')).toStrictEqual([['nested']]); + }); + + it('should open first level on click', async () => { + const { getByTestId, getByText } = render(NavigationDropdown, { + slots: { default: h('button', { 'data-test-id': 'test-trigger' }) }, + props: { + menu: [ + { + id: 'first', + title: 'first', + }, + ], + }, + }); + expect(getByText('first')).not.toBeVisible(); + await userEvent.click(getByTestId('test-trigger')); + expect(getByText('first')).toBeVisible(); + }); + + it('should toggle nested level on mouseenter / mouseleave', async () => { + const { getByTestId, getByText } = render(NavigationDropdown, { + slots: { default: h('button', { 'data-test-id': 'test-trigger' }) }, + props: { + menu: [ + { + id: 'first', + title: 'first', + submenu: [{ id: 'nested', title: 'nested' }], + }, + ], + }, + }); + expect(getByText('first')).not.toBeVisible(); + await userEvent.click(getByTestId('test-trigger')); + expect(getByText('first')).toBeVisible(); + + expect(getByText('nested')).not.toBeVisible(); + await userEvent.hover(getByTestId('navigation-submenu')); + await waitFor(() => expect(getByText('nested')).toBeVisible()); + + await userEvent.pointer([ + { target: getByTestId('navigation-submenu') }, + { target: getByTestId('test-trigger') }, + ]); + await waitFor(() => expect(getByText('nested')).not.toBeVisible()); }); }); diff --git a/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue b/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue index 1329d9a9ed..ce728a44ba 100644 --- a/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue +++ b/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue @@ -29,7 +29,7 @@ defineProps<{ }>(); const menuRef = ref(null); -const menuIndex = ref('-1'); +const ROOT_MENU_INDEX = '-1'; const emit = defineEmits<{ itemClick: [item: MenuItemRegistered]; @@ -37,7 +37,18 @@ const emit = defineEmits<{ }>(); const close = () => { - menuRef.value?.close(menuIndex.value); + menuRef.value?.close(ROOT_MENU_INDEX); +}; + +const menuTrigger = ref<'click' | 'hover'>('click'); +const onOpen = (index: string) => { + if (index !== ROOT_MENU_INDEX) return; + menuTrigger.value = 'hover'; +}; + +const onClose = (index: string) => { + if (index !== ROOT_MENU_INDEX) return; + menuTrigger.value = 'click'; }; defineExpose({ @@ -50,14 +61,16 @@ defineExpose({ ref="menuRef" mode="horizontal" unique-opened - menu-trigger="click" + :menu-trigger="menuTrigger" :ellipsis="false" :class="$style.dropdown" @select="emit('select', $event)" @keyup.escape="close" + @open="onOpen" + @close="onClose" > - + {{ item.title }} + @@ -125,17 +145,25 @@ defineExpose({ } } +.nestedSubmenu { + :global(.el-menu) { + max-height: 450px; + overflow: auto; + } +} + .submenu { padding: 5px 0 !important; :global(.el-menu--horizontal .el-menu .el-menu-item), :global(.el-menu--horizontal .el-menu .el-sub-menu__title) { color: var(--color-text-dark); + background-color: var(--color-menu-background); } :global(.el-menu--horizontal .el-menu .el-menu-item:not(.is-disabled):hover), :global(.el-menu--horizontal .el-menu .el-sub-menu__title:not(.is-disabled):hover) { - background-color: var(--color-foreground-base); + background-color: var(--color-menu-hover-background); } :global(.el-popper) { diff --git a/packages/design-system/src/css/_tokens.dark.scss b/packages/design-system/src/css/_tokens.dark.scss index 72963efcf5..a3fc653550 100644 --- a/packages/design-system/src/css/_tokens.dark.scss +++ b/packages/design-system/src/css/_tokens.dark.scss @@ -462,6 +462,10 @@ --color-configurable-node-name: var(--color-text-dark); --color-secondary-link: var(--prim-color-secondary-tint-200); --color-secondary-link-hover: var(--prim-color-secondary-tint-100); + + --color-menu-background: var(--prim-gray-740); + --color-menu-hover-background: var(--prim-gray-670); + --color-menu-active-background: var(--prim-gray-670); } body[data-theme='dark'] { diff --git a/packages/design-system/src/css/_tokens.scss b/packages/design-system/src/css/_tokens.scss index 56d5142c87..87951534ee 100644 --- a/packages/design-system/src/css/_tokens.scss +++ b/packages/design-system/src/css/_tokens.scss @@ -533,6 +533,11 @@ --color-secondary-link: var(--color-secondary); --color-secondary-link-hover: var(--color-secondary-shade-1); + // Menu + --color-menu-background: var(--prim-gray-0); + --color-menu-hover-background: var(--prim-gray-120); + --color-menu-active-background: var(--prim-gray-120); + // Generated Color Shades from 50 to 950 // Not yet used in design system @each $color in ('neutral', 'success', 'warning', 'danger') { diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index ea4e0122e6..a2a1e8e065 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -59,7 +59,7 @@ import type { ROLE, } from '@/constants'; import type { BulkCommand, Undoable } from '@/models/history'; -import type { PartialBy, TupleToUnion } from '@/utils/typeHelpers'; +import type { PartialBy } from '@/utils/typeHelpers'; import type { ProjectSharingData } from '@/types/projects.types'; @@ -249,6 +249,16 @@ export interface IWorkflowDataCreate extends IWorkflowDataUpdate { projectId?: string; } +/** + * Workflow data with mandatory `templateId` + * This is used to identify sample workflows that we create for onboarding + */ +export interface WorkflowDataWithTemplateId extends Omit { + meta: WorkflowMetadata & { + templateId: Required['templateId']; + }; +} + export interface IWorkflowToShare extends IWorkflowDataUpdate { meta: WorkflowMetadata; } @@ -1361,51 +1371,6 @@ export type SamlPreferencesExtractedData = { returnUrl: string; }; -export type SshKeyTypes = ['ed25519', 'rsa']; - -export type SourceControlPreferences = { - connected: boolean; - repositoryUrl: string; - branchName: string; - branches: string[]; - branchReadOnly: boolean; - branchColor: string; - publicKey?: string; - keyGeneratorType?: TupleToUnion; - currentBranch?: string; -}; - -export interface SourceControlStatus { - ahead: number; - behind: number; - conflicted: string[]; - created: string[]; - current: string; - deleted: string[]; - detached: boolean; - files: Array<{ - path: string; - index: string; - working_dir: string; - }>; - modified: string[]; - not_added: string[]; - renamed: string[]; - staged: string[]; - tracking: null; -} - -export interface SourceControlAggregatedFile { - conflict: boolean; - file: string; - id: string; - location: string; - name: string; - status: string; - type: string; - updatedAt?: string; -} - export declare namespace Cloud { export interface PlanData { planId: number; diff --git a/packages/editor-ui/src/__tests__/defaults.ts b/packages/editor-ui/src/__tests__/defaults.ts index dff54f420f..61c8213232 100644 --- a/packages/editor-ui/src/__tests__/defaults.ts +++ b/packages/editor-ui/src/__tests__/defaults.ts @@ -127,4 +127,5 @@ export const defaultSettings: FrontendSettings = { }, betaFeatures: [], virtualSchemaView: false, + easyAIWorkflowOnboarded: false, }; diff --git a/packages/editor-ui/src/__tests__/server/endpoints/sourceControl.ts b/packages/editor-ui/src/__tests__/server/endpoints/sourceControl.ts index 46f86fa693..653fbc3b51 100644 --- a/packages/editor-ui/src/__tests__/server/endpoints/sourceControl.ts +++ b/packages/editor-ui/src/__tests__/server/endpoints/sourceControl.ts @@ -2,7 +2,7 @@ import type { Server, Request } from 'miragejs'; import { Response } from 'miragejs'; import { jsonParse } from 'n8n-workflow'; import type { AppSchema } from '@/__tests__/server/types'; -import type { SourceControlPreferences } from '@/Interface'; +import type { SourceControlPreferences } from '@/types/sourceControl.types'; export function routesForSourceControl(server: Server) { const sourceControlApiRoot = '/rest/source-control'; diff --git a/packages/editor-ui/src/api/sourceControl.ts b/packages/editor-ui/src/api/sourceControl.ts index 615c7d6660..c8e4eebcbc 100644 --- a/packages/editor-ui/src/api/sourceControl.ts +++ b/packages/editor-ui/src/api/sourceControl.ts @@ -1,11 +1,12 @@ import type { IDataObject } from 'n8n-workflow'; +import type { IRestApiContext } from '@/Interface'; import type { - IRestApiContext, SourceControlAggregatedFile, SourceControlPreferences, SourceControlStatus, SshKeyTypes, -} from '@/Interface'; +} from '@/types/sourceControl.types'; + import { makeRestApiRequest } from '@/utils/apiUtils'; import type { TupleToUnion } from '@/utils/typeHelpers'; diff --git a/packages/editor-ui/src/components/MainSidebar.vue b/packages/editor-ui/src/components/MainSidebar.vue index 0ed9eaab58..9b294a85f1 100644 --- a/packages/editor-ui/src/components/MainSidebar.vue +++ b/packages/editor-ui/src/components/MainSidebar.vue @@ -291,7 +291,12 @@ const checkWidthAndAdjustSidebar = async (width: number) => { } }; -const { menu, handleSelect: handleMenuSelect } = useGlobalEntityCreation(); +const { + menu, + handleSelect: handleMenuSelect, + createProjectAppendSlotName, + projectsLimitReachedMessage, +} = useGlobalEntityCreation(); onClickOutside(createBtn as Ref, () => { createBtn.value?.close(); }); @@ -311,8 +316,8 @@ onClickOutside(createBtn as Ref, () => { :class="['clickable', $style.sideMenuCollapseButton]" @click="toggleCollapse" > - - + +
n8n @@ -323,9 +328,21 @@ onClickOutside(createBtn as Ref, () => { @select="handleMenuSelect" > +
- + - + diff --git a/packages/editor-ui/src/components/MainSidebarSourceControl.vue b/packages/editor-ui/src/components/MainSidebarSourceControl.vue index 1b792b4cd5..d00f5364fa 100644 --- a/packages/editor-ui/src/components/MainSidebarSourceControl.vue +++ b/packages/editor-ui/src/components/MainSidebarSourceControl.vue @@ -8,7 +8,7 @@ import { useLoadingService } from '@/composables/useLoadingService'; import { useUIStore } from '@/stores/ui.store'; import { useSourceControlStore } from '@/stores/sourceControl.store'; import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY } from '@/constants'; -import type { SourceControlAggregatedFile } from '../Interface'; +import type { SourceControlAggregatedFile } from '@/types/sourceControl.types'; import { sourceControlEventBus } from '@/event-bus/source-control'; defineProps<{ diff --git a/packages/editor-ui/src/components/Node.vue b/packages/editor-ui/src/components/Node.vue index 1f1ea90ab5..f20724bd5f 100644 --- a/packages/editor-ui/src/components/Node.vue +++ b/packages/editor-ui/src/components/Node.vue @@ -9,7 +9,6 @@ import { SIMULATE_NODE_TYPE, SIMULATE_TRIGGER_NODE_TYPE, WAIT_NODE_TYPE, - WAIT_TIME_UNLIMITED, } from '@/constants'; import type { ExecutionSummary, @@ -18,7 +17,12 @@ import type { NodeOperationError, Workflow, } from 'n8n-workflow'; -import { NodeConnectionType, NodeHelpers, SEND_AND_WAIT_OPERATION } from 'n8n-workflow'; +import { + NodeConnectionType, + NodeHelpers, + SEND_AND_WAIT_OPERATION, + WAIT_INDEFINITELY, +} from 'n8n-workflow'; import type { StyleValue } from 'vue'; import { computed, onMounted, ref, watch } from 'vue'; import xss from 'xss'; @@ -345,7 +349,7 @@ const waiting = computed(() => { return i18n.baseText('node.theNodeIsWaitingFormCall'); } const waitDate = new Date(workflowExecution.waitTill); - if (waitDate.toISOString() === WAIT_TIME_UNLIMITED) { + if (waitDate.getTime() === WAIT_INDEFINITELY.getTime()) { return i18n.baseText('node.theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall'); } return i18n.baseText('node.nodeIsWaitingTill', { diff --git a/packages/editor-ui/src/components/ParameterInputFull.vue b/packages/editor-ui/src/components/ParameterInputFull.vue index 16b6c1321e..84b697f9da 100644 --- a/packages/editor-ui/src/components/ParameterInputFull.vue +++ b/packages/editor-ui/src/components/ParameterInputFull.vue @@ -194,7 +194,8 @@ function onDrop(newParamValue: string) { watch( () => props.isReadOnly, (isReadOnly) => { - if (isReadOnly) { + // Patch fix, see https://linear.app/n8n/issue/ADO-2974/resource-mapper-values-are-emptied-when-refreshing-the-columns + if (isReadOnly && props.parameter.disabledOptions !== undefined) { valueChanged({ name: props.path, value: props.parameter.default }); } }, diff --git a/packages/editor-ui/src/components/PersonalizationModal.vue b/packages/editor-ui/src/components/PersonalizationModal.vue index c9faca5c79..a4ff73147a 100644 --- a/packages/editor-ui/src/components/PersonalizationModal.vue +++ b/packages/editor-ui/src/components/PersonalizationModal.vue @@ -79,7 +79,6 @@ import { REPORTED_SOURCE_OTHER, REPORTED_SOURCE_OTHER_KEY, VIEWS, - MORE_ONBOARDING_OPTIONS_EXPERIMENT, COMMUNITY_PLUS_ENROLLMENT_MODAL, } from '@/constants'; import { useToast } from '@/composables/useToast'; @@ -552,12 +551,9 @@ const onSave = () => { }; const closeCallback = () => { - const isPartOfOnboardingExperiment = - posthogStore.getVariant(MORE_ONBOARDING_OPTIONS_EXPERIMENT.name) === - MORE_ONBOARDING_OPTIONS_EXPERIMENT.control; // In case the redirect to homepage for new users didn't happen // we try again after closing the modal - if (route.name !== VIEWS.HOMEPAGE && !isPartOfOnboardingExperiment) { + if (route.name !== VIEWS.HOMEPAGE) { void router.replace({ name: VIEWS.HOMEPAGE }); } }; diff --git a/packages/editor-ui/src/components/Projects/ProjectHeader.test.ts b/packages/editor-ui/src/components/Projects/ProjectHeader.test.ts index 4bc8e6d43a..21a4c8c52f 100644 --- a/packages/editor-ui/src/components/Projects/ProjectHeader.test.ts +++ b/packages/editor-ui/src/components/Projects/ProjectHeader.test.ts @@ -3,11 +3,13 @@ import { within } from '@testing-library/dom'; import { createComponentRenderer } from '@/__tests__/render'; import { mockedStore } from '@/__tests__/utils'; import { createTestProject } from '@/__tests__/data/projects'; -import { useRoute } from 'vue-router'; +import * as router from 'vue-router'; +import type { RouteLocationNormalizedLoadedGeneric } from 'vue-router'; import ProjectHeader from '@/components/Projects/ProjectHeader.vue'; import { useProjectsStore } from '@/stores/projects.store'; import type { Project } from '@/types/projects.types'; import { ProjectTypes } from '@/types/projects.types'; +import { VIEWS } from '@/constants'; vi.mock('vue-router', async () => { const actual = await vi.importActual('vue-router'); @@ -35,13 +37,13 @@ const renderComponent = createComponentRenderer(ProjectHeader, { }, }); -let route: ReturnType; +let route: ReturnType; let projectsStore: ReturnType>; describe('ProjectHeader', () => { beforeEach(() => { createTestingPinia(); - route = useRoute(); + route = router.useRoute(); projectsStore = mockedStore(useProjectsStore); projectsStore.teamProjectsLimit = -1; @@ -159,4 +161,21 @@ describe('ProjectHeader', () => { expect(within(getByTestId('resource-add')).getByRole('button', { name: label })).toBeVisible(); }); + + it('should not render creation button in setting page', async () => { + projectsStore.currentProject = createTestProject({ type: ProjectTypes.Personal }); + vi.spyOn(router, 'useRoute').mockReturnValueOnce({ + name: VIEWS.PROJECT_SETTINGS, + } as RouteLocationNormalizedLoadedGeneric); + const { queryByTestId } = renderComponent({ + global: { + stubs: { + N8nNavigationDropdown: { + template: '
', + }, + }, + }, + }); + expect(queryByTestId('resource-add')).not.toBeInTheDocument(); + }); }); diff --git a/packages/editor-ui/src/components/Projects/ProjectHeader.vue b/packages/editor-ui/src/components/Projects/ProjectHeader.vue index ba04291b92..977abe7393 100644 --- a/packages/editor-ui/src/components/Projects/ProjectHeader.vue +++ b/packages/editor-ui/src/components/Projects/ProjectHeader.vue @@ -1,7 +1,7 @@