Merge branch 'master' into ai-430-metrics

This commit is contained in:
Oleg Ivaniv 2024-12-10 08:03:15 +01:00
commit 42926ba960
No known key found for this signature in database
122 changed files with 3235 additions and 857 deletions

View file

@ -98,6 +98,10 @@ describe('Workflow Selector Parameter', () => {
getVisiblePopper().findChildByTestId('rlc-item').eq(0).click(); 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`,
);
}); });
}); });

View file

@ -6,6 +6,7 @@
"cypress:install": "cypress install", "cypress:install": "cypress install",
"test:e2e:ui": "scripts/run-e2e.js ui", "test:e2e:ui": "scripts/run-e2e.js ui",
"test:e2e:dev": "scripts/run-e2e.js dev", "test:e2e:dev": "scripts/run-e2e.js dev",
"test:e2e:dev:v2": "scripts/run-e2e.js dev:v2",
"test:e2e:all": "scripts/run-e2e.js all", "test:e2e:all": "scripts/run-e2e.js all",
"format": "biome format --write .", "format": "biome format --write .",
"format:check": "biome ci .", "format:check": "biome ci .",

View file

@ -57,6 +57,17 @@ switch (scenario) {
}, },
}); });
break; break;
case 'dev:v2':
runTests({
startCommand: 'develop',
url: 'http://localhost:8080/favicon.ico',
testCommand: 'cypress open',
customEnv: {
CYPRESS_NODE_VIEW_VERSION: 2,
CYPRESS_BASE_URL: 'http://localhost:8080',
},
});
break;
case 'all': case 'all':
const specSuiteFilter = process.argv[3]; const specSuiteFilter = process.argv[3];
const specParam = specSuiteFilter ? ` --spec **/*${specSuiteFilter}*` : ''; const specParam = specSuiteFilter ? ` --spec **/*${specSuiteFilter}*` : '';

View file

@ -33,7 +33,7 @@ COPY docker/images/n8n/docker-entrypoint.sh /
# Setup the Task Runner Launcher # Setup the Task Runner Launcher
ARG TARGETPLATFORM ARG TARGETPLATFORM
ARG LAUNCHER_VERSION=0.7.0-rc ARG LAUNCHER_VERSION=1.0.0
COPY docker/images/n8n/n8n-task-runners.json /etc/n8n-task-runners.json COPY docker/images/n8n/n8n-task-runners.json /etc/n8n-task-runners.json
# Download, verify, then extract the launcher binary # Download, verify, then extract the launcher binary
RUN \ RUN \

View file

@ -24,7 +24,7 @@ RUN set -eux; \
# Setup the Task Runner Launcher # Setup the Task Runner Launcher
ARG TARGETPLATFORM ARG TARGETPLATFORM
ARG LAUNCHER_VERSION=0.7.0-rc ARG LAUNCHER_VERSION=1.0.0
COPY n8n-task-runners.json /etc/n8n-task-runners.json COPY n8n-task-runners.json /etc/n8n-task-runners.json
# Download, verify, then extract the launcher binary # Download, verify, then extract the launcher binary
RUN \ RUN \

View file

@ -9,12 +9,12 @@
"PATH", "PATH",
"GENERIC_TIMEZONE", "GENERIC_TIMEZONE",
"N8N_RUNNERS_GRANT_TOKEN", "N8N_RUNNERS_GRANT_TOKEN",
"N8N_RUNNERS_N8N_URI", "N8N_RUNNERS_TASK_BROKER_URI",
"N8N_RUNNERS_MAX_PAYLOAD", "N8N_RUNNERS_MAX_PAYLOAD",
"N8N_RUNNERS_MAX_CONCURRENCY", "N8N_RUNNERS_MAX_CONCURRENCY",
"N8N_RUNNERS_SERVER_ENABLED", "N8N_RUNNERS_HEALTH_CHECK_SERVER_ENABLED",
"N8N_RUNNERS_SERVER_HOST", "N8N_RUNNERS_HEALTH_CHECK_SERVER_HOST",
"N8N_RUNNERS_SERVER_PORT", "N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT",
"NODE_FUNCTION_ALLOW_BUILTIN", "NODE_FUNCTION_ALLOW_BUILTIN",
"NODE_FUNCTION_ALLOW_EXTERNAL", "NODE_FUNCTION_ALLOW_EXTERNAL",
"NODE_OPTIONS", "NODE_OPTIONS",

View file

@ -4,4 +4,5 @@ import { Z } from 'zod-class';
export class SettingsUpdateRequestDto extends Z.class({ export class SettingsUpdateRequestDto extends Z.class({
userActivated: z.boolean().optional(), userActivated: z.boolean().optional(),
allowSSOManualLogin: z.boolean().optional(), allowSSOManualLogin: z.boolean().optional(),
easyAIWorkflowOnboarded: z.boolean().optional(),
}) {} }) {}

View file

@ -163,7 +163,7 @@ export interface FrontendSettings {
pruneTime: number; pruneTime: number;
licensePruneTime: number; licensePruneTime: number;
}; };
pruning: { pruning?: {
isEnabled: boolean; isEnabled: boolean;
maxAge: number; maxAge: number;
maxCount: number; maxCount: number;
@ -173,4 +173,5 @@ export interface FrontendSettings {
}; };
betaFeatures: FrontendBetaFeatures[]; betaFeatures: FrontendBetaFeatures[];
virtualSchemaView: boolean; virtualSchemaView: boolean;
easyAIWorkflowOnboarded: boolean;
} }

View file

@ -24,7 +24,7 @@ export class TaskRunnersConfig {
authToken: string = ''; authToken: string = '';
/** IP address task runners server should listen on */ /** IP address task runners server should listen on */
@Env('N8N_RUNNERS_SERVER_PORT') @Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT')
port: number = 5679; port: number = 5679;
/** IP address task runners server should listen on */ /** IP address task runners server should listen on */

View file

@ -4,7 +4,7 @@ import type {
INodeExecutionData, INodeExecutionData,
IDataObject, IDataObject,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { NodeOperationError, updateDisplayOptions } from 'n8n-workflow'; import { ApplicationError, NodeOperationError, updateDisplayOptions } from 'n8n-workflow';
import { apiRequest } from '../../transport'; import { apiRequest } from '../../transport';
import { assistantRLC, modelRLC } from '../descriptions'; import { assistantRLC, modelRLC } from '../descriptions';
@ -116,6 +116,18 @@ const displayOptions = {
export const description = updateDisplayOptions(displayOptions, properties); 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<INodeExecutionData[]> { export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
const assistantId = this.getNodeParameter('assistantId', i, '', { extractValue: true }) as string; const assistantId = this.getNodeParameter('assistantId', i, '', { extractValue: true }) as string;
const options = this.getNodeParameter('options', i, {}); const options = this.getNodeParameter('options', i, {});
@ -137,11 +149,8 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
const body: IDataObject = {}; const body: IDataObject = {};
if (file_ids) { if (file_ids) {
let files = file_ids; const files = getFileIds(file_ids);
if (typeof files === 'string') { if (files.length > 20) {
files = files.split(',').map((file_id) => file_id.trim());
}
if ((file_ids as IDataObject[]).length > 20) {
throw new NodeOperationError( throw new NodeOperationError(
this.getNode(), this.getNode(),
'The maximum number of files that can be attached to the assistant is 20', '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<INode
body.tool_resources = { body.tool_resources = {
...((body.tool_resources as object) ?? {}), ...((body.tool_resources as object) ?? {}),
code_interpreter: { code_interpreter: {
file_ids, file_ids: files,
},
file_search: {
vector_stores: [
{
file_ids,
},
],
}, },
// updating file_ids for file_search directly is not supported by OpenAI API
// only updating vector_store_ids for file_search is supported
// support for this to be added as part of ADO-2968
// https://platform.openai.com/docs/api-reference/assistants/modifyAssistant
}; };
} }

View file

@ -210,12 +210,89 @@ describe('OpenAi, Assistant resource', () => {
code_interpreter: { code_interpreter: {
file_ids: [], file_ids: [],
}, },
file_search: { },
vector_stores: [ tools: [{ type: 'existing_tool' }, { type: 'code_interpreter' }, { type: 'file_search' }],
{ },
file_ids: [], 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' }], tools: [{ type: 'existing_tool' }, { type: 'code_interpreter' }, { type: 'file_search' }],

View file

@ -2,20 +2,20 @@ import { Config, Env, Nested } from '@n8n/config';
@Config @Config
class HealthcheckServerConfig { class HealthcheckServerConfig {
@Env('N8N_RUNNERS_SERVER_ENABLED') @Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_ENABLED')
enabled: boolean = false; enabled: boolean = false;
@Env('N8N_RUNNERS_SERVER_HOST') @Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_HOST')
host: string = '127.0.0.1'; host: string = '127.0.0.1';
@Env('N8N_RUNNERS_SERVER_PORT') @Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT')
port: number = 5681; port: number = 5681;
} }
@Config @Config
export class BaseRunnerConfig { export class BaseRunnerConfig {
@Env('N8N_RUNNERS_N8N_URI') @Env('N8N_RUNNERS_TASK_BROKER_URI')
n8nUri: string = '127.0.0.1:5679'; taskBrokerUri: string = 'http://127.0.0.1:5679';
@Env('N8N_RUNNERS_GRANT_TOKEN') @Env('N8N_RUNNERS_GRANT_TOKEN')
grantToken: string = ''; grantToken: string = '';

View file

@ -34,7 +34,7 @@ describe('JsTaskRunner', () => {
...defaultConfig.baseRunnerConfig, ...defaultConfig.baseRunnerConfig,
grantToken: 'grantToken', grantToken: 'grantToken',
maxConcurrency: 1, maxConcurrency: 1,
n8nUri: 'localhost', taskBrokerUri: 'http://localhost',
...baseRunnerOpts, ...baseRunnerOpts,
}, },
jsRunnerConfig: { jsRunnerConfig: {
@ -311,10 +311,10 @@ describe('JsTaskRunner', () => {
}); });
it("should not expose task runner's env variables even if no env state is received", async () => { 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({ const outcome = await execTaskWithParams({
task: newTaskWithSettings({ task: newTaskWithSettings({
code: 'return { val: $env.N8N_RUNNERS_N8N_URI }', code: 'return { val: $env.N8N_RUNNERS_TASK_BROKER_URI }',
nodeMode: 'runOnceForAllItems', nodeMode: 'runOnceForAllItems',
}), }),
taskData: newDataRequestResponse(inputItems.map(wrapIntoJson), { taskData: newDataRequestResponse(inputItems.map(wrapIntoJson), {

View file

@ -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/);
});
});
});

View file

@ -92,7 +92,9 @@ export abstract class TaskRunner extends EventEmitter {
this.maxConcurrency = opts.maxConcurrency; this.maxConcurrency = opts.maxConcurrency;
this.idleTimeout = opts.idleTimeout; 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, { this.ws = new WebSocket(wsUrl, {
headers: { headers: {
authorization: `Bearer ${opts.grantToken}`, authorization: `Bearer ${opts.grantToken}`,
@ -109,11 +111,11 @@ export abstract class TaskRunner extends EventEmitter {
['ECONNREFUSED', 'ENOTFOUND'].some((code) => code === error.code) ['ECONNREFUSED', 'ENOTFOUND'].some((code) => code === error.code)
) { ) {
console.error( 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); process.exit(1);
} else { } 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'); console.error('Details:', event.message || 'Unknown error');
} }
}); });

View file

@ -1,7 +1,9 @@
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import type { InstanceSettings } from 'n8n-core'; 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 { Project } from '@/databases/entities/project';
import type { ExecutionRepository } from '@/databases/repositories/execution.repository'; import type { ExecutionRepository } from '@/databases/repositories/execution.repository';
import type { IExecutionResponse } from '@/interfaces'; import type { IExecutionResponse } from '@/interfaces';
@ -12,9 +14,10 @@ import { WaitTracker } from '@/wait-tracker';
import type { WorkflowRunner } from '@/workflow-runner'; import type { WorkflowRunner } from '@/workflow-runner';
import { mockLogger } from '@test/mocking'; import { mockLogger } from '@test/mocking';
jest.useFakeTimers(); jest.useFakeTimers({ advanceTimers: true });
describe('WaitTracker', () => { describe('WaitTracker', () => {
const activeExecutions = mock<ActiveExecutions>();
const ownershipService = mock<OwnershipService>(); const ownershipService = mock<OwnershipService>();
const workflowRunner = mock<WorkflowRunner>(); const workflowRunner = mock<WorkflowRunner>();
const executionRepository = mock<ExecutionRepository>(); const executionRepository = mock<ExecutionRepository>();
@ -30,6 +33,7 @@ describe('WaitTracker', () => {
mode: 'manual', mode: 'manual',
data: mock({ data: mock({
pushRef: 'push_ref', pushRef: 'push_ref',
parentExecution: undefined,
}), }),
}); });
execution.workflowData = mock<IWorkflowBase>({ id: 'abcd' }); execution.workflowData = mock<IWorkflowBase>({ id: 'abcd' });
@ -40,6 +44,7 @@ describe('WaitTracker', () => {
mockLogger(), mockLogger(),
executionRepository, executionRepository,
ownershipService, ownershipService,
activeExecutions,
workflowRunner, workflowRunner,
orchestrationService, orchestrationService,
instanceSettings, instanceSettings,
@ -80,7 +85,9 @@ describe('WaitTracker', () => {
let startExecutionSpy: jest.SpyInstance<Promise<void>, [executionId: string]>; let startExecutionSpy: jest.SpyInstance<Promise<void>, [executionId: string]>;
beforeEach(() => { beforeEach(() => {
executionRepository.findSingleExecution.mockResolvedValue(execution); executionRepository.findSingleExecution
.calledWith(execution.id)
.mockResolvedValue(execution);
executionRepository.getWaitingExecutions.mockResolvedValue([execution]); executionRepository.getWaitingExecutions.mockResolvedValue([execution]);
ownershipService.getWorkflowProjectCached.mockResolvedValue(project); ownershipService.getWorkflowProjectCached.mockResolvedValue(project);
@ -110,13 +117,17 @@ describe('WaitTracker', () => {
}); });
describe('startExecution()', () => { describe('startExecution()', () => {
it('should query for execution to start', async () => { beforeEach(() => {
executionRepository.getWaitingExecutions.mockResolvedValue([]); executionRepository.getWaitingExecutions.mockResolvedValue([]);
waitTracker.init(); waitTracker.init();
executionRepository.findSingleExecution.mockResolvedValue(execution); executionRepository.findSingleExecution.calledWith(execution.id).mockResolvedValue(execution);
ownershipService.getWorkflowProjectCached.mockResolvedValue(project); ownershipService.getWorkflowProjectCached.mockResolvedValue(project);
execution.data.parentExecution = undefined;
});
it('should query for execution to start', async () => {
await waitTracker.startExecution(execution.id); await waitTracker.startExecution(execution.id);
expect(executionRepository.findSingleExecution).toHaveBeenCalledWith(execution.id, { expect(executionRepository.findSingleExecution).toHaveBeenCalledWith(execution.id, {
@ -137,6 +148,65 @@ describe('WaitTracker', () => {
execution.id, execution.id,
); );
}); });
it('should also resume parent execution once sub-workflow finishes', async () => {
const parentExecution = mock<IExecutionResponse>({
id: 'parent_execution_id',
finished: false,
});
parentExecution.workflowData = mock<IWorkflowBase>({ id: 'parent_workflow_id' });
execution.data.parentExecution = {
executionId: parentExecution.id,
workflowId: parentExecution.workflowData.id,
};
executionRepository.findSingleExecution
.calledWith(parentExecution.id)
.mockResolvedValue(parentExecution);
const postExecutePromise = createDeferredPromise<IRun | undefined>();
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<IRun>());
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', () => { describe('single-main setup', () => {
@ -165,6 +235,7 @@ describe('WaitTracker', () => {
mockLogger(), mockLogger(),
executionRepository, executionRepository,
ownershipService, ownershipService,
activeExecutions,
workflowRunner, workflowRunner,
orchestrationService, orchestrationService,
mock<InstanceSettings>({ isLeader: false }), mock<InstanceSettings>({ isLeader: false }),

View file

@ -1,11 +1,11 @@
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import type { IWorkflowBase } from 'n8n-workflow'; import type { IWorkflowBase } from 'n8n-workflow';
import { import type {
type IExecuteWorkflowInfo, IExecuteWorkflowInfo,
type IWorkflowExecuteAdditionalData, IWorkflowExecuteAdditionalData,
type ExecuteWorkflowOptions, ExecuteWorkflowOptions,
type IRun, IRun,
type INodeExecutionData, INodeExecutionData,
} from 'n8n-workflow'; } from 'n8n-workflow';
import type PCancelable from 'p-cancelable'; import type PCancelable from 'p-cancelable';
import Container from 'typedi'; import Container from 'typedi';
@ -50,6 +50,7 @@ const getMockRun = ({ lastNodeOutput }: { lastNodeOutput: Array<INodeExecutionDa
mode: 'manual', mode: 'manual',
startedAt: new Date(), startedAt: new Date(),
status: 'new', status: 'new',
waitTill: undefined,
}); });
const getCancelablePromise = async (run: IRun) => const getCancelablePromise = async (run: IRun) =>
@ -114,7 +115,9 @@ describe('WorkflowExecuteAdditionalData', () => {
}); });
describe('executeWorkflow', () => { describe('executeWorkflow', () => {
const runWithData = getMockRun({ lastNodeOutput: [[{ json: { test: 1 } }]] }); const runWithData = getMockRun({
lastNodeOutput: [[{ json: { test: 1 } }]],
});
beforeEach(() => { beforeEach(() => {
workflowRepository.get.mockResolvedValue( workflowRepository.get.mockResolvedValue(
@ -159,6 +162,23 @@ describe('WorkflowExecuteAdditionalData', () => {
expect(executionRepository.setRunning).toHaveBeenCalledWith(EXECUTION_ID); 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<IExecuteWorkflowInfo>(),
mock<IWorkflowExecuteAdditionalData>(),
mock<ExecuteWorkflowOptions>({ loadedWorkflowData: undefined, doNotWaitToFinish: false }),
);
expect(response).toEqual({
data: runWithData.data.resultData.runData[LAST_NODE_EXECUTED][0].data!.main,
executionId: EXECUTION_ID,
waitTill,
});
});
}); });
describe('getRunData', () => { describe('getRunData', () => {
@ -230,6 +250,10 @@ describe('WorkflowExecuteAdditionalData', () => {
waitingExecution: {}, waitingExecution: {},
waitingExecutionSource: {}, waitingExecutionSource: {},
}, },
parentExecution: {
executionId: '123',
workflowId: '567',
},
resultData: { runData: {} }, resultData: { runData: {} },
startData: {}, startData: {},
}, },

View file

@ -80,6 +80,7 @@ type ExceptionPaths = {
processedDataManager: IProcessedDataConfig; processedDataManager: IProcessedDataConfig;
'userManagement.isInstanceOwnerSetUp': boolean; 'userManagement.isInstanceOwnerSetUp': boolean;
'ui.banners.dismissed': string[] | undefined; 'ui.banners.dismissed': string[] | undefined;
easyAIWorkflowOnboarded: boolean | undefined;
}; };
// ----------------------------------- // -----------------------------------

View file

@ -5,7 +5,12 @@ import { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.
import type { TestMetric } from '@/databases/entities/test-metric.ee'; import type { TestMetric } from '@/databases/entities/test-metric.ee';
import { WorkflowEntity } from '@/databases/entities/workflow-entity'; import { WorkflowEntity } from '@/databases/entities/workflow-entity';
import { WithTimestampsAndStringId } from './abstract-entity'; import { jsonColumnType, WithTimestampsAndStringId } from './abstract-entity';
// Entity representing a node in a workflow under test, for which data should be mocked during test execution
export type MockedNodeItem = {
name: string;
};
/** /**
* Entity representing a Test Definition * Entity representing a Test Definition
@ -27,6 +32,9 @@ export class TestDefinition extends WithTimestampsAndStringId {
@Column('text') @Column('text')
description: string; description: string;
@Column(jsonColumnType, { default: '[]' })
mockedNodes: MockedNodeItem[];
/** /**
* Relation to the workflow under test * Relation to the workflow under test
*/ */

View file

@ -0,0 +1,22 @@
import type { MigrationContext, ReversibleMigration } from '@/databases/types';
// We have to use raw query migration instead of schemaBuilder helpers,
// because the typeorm schema builder implements addColumns by a table recreate for sqlite
// which causes weird issues with the migration
export class AddMockedNodesColumnToTestDefinition1733133775640 implements ReversibleMigration {
async up({ escape, runQuery }: MigrationContext) {
const tableName = escape.tableName('test_definition');
const mockedNodesColumnName = escape.columnName('mockedNodes');
await runQuery(
`ALTER TABLE ${tableName} ADD COLUMN ${mockedNodesColumnName} JSON DEFAULT '[]' NOT NULL`,
);
}
async down({ escape, runQuery }: MigrationContext) {
const tableName = escape.tableName('test_definition');
const columnName = escape.columnName('mockedNodes');
await runQuery(`ALTER TABLE ${tableName} DROP COLUMN ${columnName}`);
}
}

View file

@ -73,6 +73,7 @@ import { CreateTestDefinitionTable1730386903556 } from '../common/1730386903556-
import { AddDescriptionToTestDefinition1731404028106 } from '../common/1731404028106-AddDescriptionToTestDefinition'; import { AddDescriptionToTestDefinition1731404028106 } from '../common/1731404028106-AddDescriptionToTestDefinition';
import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-CreateTestMetricTable'; import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-CreateTestMetricTable';
import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable'; import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition';
export const mysqlMigrations: Migration[] = [ export const mysqlMigrations: Migration[] = [
InitialMigration1588157391238, InitialMigration1588157391238,
@ -148,4 +149,5 @@ export const mysqlMigrations: Migration[] = [
MigrateTestDefinitionKeyToString1731582748663, MigrateTestDefinitionKeyToString1731582748663,
CreateTestMetricTable1732271325258, CreateTestMetricTable1732271325258,
CreateTestRun1732549866705, CreateTestRun1732549866705,
AddMockedNodesColumnToTestDefinition1733133775640,
]; ];

View file

@ -73,6 +73,7 @@ import { CreateTestDefinitionTable1730386903556 } from '../common/1730386903556-
import { AddDescriptionToTestDefinition1731404028106 } from '../common/1731404028106-AddDescriptionToTestDefinition'; import { AddDescriptionToTestDefinition1731404028106 } from '../common/1731404028106-AddDescriptionToTestDefinition';
import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-CreateTestMetricTable'; import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-CreateTestMetricTable';
import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable'; import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition';
export const postgresMigrations: Migration[] = [ export const postgresMigrations: Migration[] = [
InitialMigration1587669153312, InitialMigration1587669153312,
@ -148,4 +149,5 @@ export const postgresMigrations: Migration[] = [
MigrateTestDefinitionKeyToString1731582748663, MigrateTestDefinitionKeyToString1731582748663,
CreateTestMetricTable1732271325258, CreateTestMetricTable1732271325258,
CreateTestRun1732549866705, CreateTestRun1732549866705,
AddMockedNodesColumnToTestDefinition1733133775640,
]; ];

View file

@ -70,6 +70,7 @@ import { UpdateProcessedDataValueColumnToText1729607673464 } from '../common/172
import { CreateTestDefinitionTable1730386903556 } from '../common/1730386903556-CreateTestDefinitionTable'; import { CreateTestDefinitionTable1730386903556 } from '../common/1730386903556-CreateTestDefinitionTable';
import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-CreateTestMetricTable'; import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-CreateTestMetricTable';
import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable'; import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition';
const sqliteMigrations: Migration[] = [ const sqliteMigrations: Migration[] = [
InitialMigration1588102412422, InitialMigration1588102412422,
@ -142,6 +143,7 @@ const sqliteMigrations: Migration[] = [
MigrateTestDefinitionKeyToString1731582748663, MigrateTestDefinitionKeyToString1731582748663,
CreateTestMetricTable1732271325258, CreateTestMetricTable1732271325258,
CreateTestRun1732549866705, CreateTestRun1732549866705,
AddMockedNodesColumnToTestDefinition1733133775640,
]; ];
export { sqliteMigrations }; export { sqliteMigrations };

View file

@ -211,4 +211,13 @@ export class SharedWorkflowRepository extends Repository<SharedWorkflow> {
}, },
}); });
} }
async getAllRelationsForWorkflows(workflowIds: string[]) {
return await this.find({
where: {
workflowId: In(workflowIds),
},
relations: ['project'],
});
}
} }

View file

@ -95,7 +95,8 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
.execute(); .execute();
} }
async getMany(sharedWorkflowIds: string[], options?: ListQuery.Options) { async getMany(sharedWorkflowIds: string[], originalOptions: ListQuery.Options = {}) {
const options = structuredClone(originalOptions);
if (sharedWorkflowIds.length === 0) return { workflows: [], count: 0 }; if (sharedWorkflowIds.length === 0) return { workflows: [], count: 0 };
if (typeof options?.filter?.projectId === 'string' && options.filter.projectId !== '') { if (typeof options?.filter?.projectId === 'string' && options.filter.projectId !== '') {

View file

@ -16,5 +16,6 @@ export const testDefinitionPatchRequestBodySchema = z
description: z.string().optional(), description: z.string().optional(),
evaluationWorkflowId: z.string().min(1).optional(), evaluationWorkflowId: z.string().min(1).optional(),
annotationTagId: z.string().min(1).optional(), annotationTagId: z.string().min(1).optional(),
mockedNodes: z.array(z.object({ name: z.string() })).optional(),
}) })
.strict(); .strict();

View file

@ -1,6 +1,6 @@
import { Service } from 'typedi'; import { Service } from 'typedi';
import type { TestDefinition } from '@/databases/entities/test-definition.ee'; import type { MockedNodeItem, TestDefinition } from '@/databases/entities/test-definition.ee';
import { AnnotationTagRepository } from '@/databases/repositories/annotation-tag.repository.ee'; import { AnnotationTagRepository } from '@/databases/repositories/annotation-tag.repository.ee';
import { TestDefinitionRepository } from '@/databases/repositories/test-definition.repository.ee'; import { TestDefinitionRepository } from '@/databases/repositories/test-definition.repository.ee';
import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error';
@ -31,6 +31,7 @@ export class TestDefinitionService {
evaluationWorkflowId?: string; evaluationWorkflowId?: string;
annotationTagId?: string; annotationTagId?: string;
id?: string; id?: string;
mockedNodes?: MockedNodeItem[];
}) { }) {
const entity: TestDefinitionLike = {}; const entity: TestDefinitionLike = {};
@ -64,6 +65,10 @@ export class TestDefinitionService {
}; };
} }
if (attrs.mockedNodes) {
entity.mockedNodes = attrs.mockedNodes;
}
return entity; return entity;
} }
@ -107,6 +112,24 @@ export class TestDefinitionService {
} }
} }
// If there are mocked nodes, validate them
if (attrs.mockedNodes && attrs.mockedNodes.length > 0) {
const existingTestDefinition = await this.testDefinitionRepository.findOneOrFail({
where: {
id,
},
relations: ['workflow'],
});
const existingNodeNames = new Set(existingTestDefinition.workflow.nodes.map((n) => n.name));
attrs.mockedNodes.forEach((node) => {
if (!existingNodeNames.has(node.name)) {
throw new BadRequestError(`Pinned node not found in the workflow: ${node.name}`);
}
});
}
// Update the test definition // Update the test definition
const queryResult = await this.testDefinitionRepository.update(id, this.toEntityLike(attrs)); const queryResult = await this.testDefinitionRepository.update(id, this.toEntityLike(attrs));

View file

@ -1,3 +1,4 @@
import type { MockedNodeItem } from '@/databases/entities/test-definition.ee';
import type { AuthenticatedRequest, ListQuery } from '@/requests'; import type { AuthenticatedRequest, ListQuery } from '@/requests';
// ---------------------------------- // ----------------------------------
@ -26,7 +27,12 @@ export declare namespace TestDefinitionsRequest {
type Patch = AuthenticatedRequest< type Patch = AuthenticatedRequest<
RouteParams.TestId, RouteParams.TestId,
{}, {},
{ name?: string; evaluationWorkflowId?: string; annotationTagId?: string } {
name?: string;
evaluationWorkflowId?: string;
annotationTagId?: string;
mockedNodes?: MockedNodeItem[];
}
>; >;
type Delete = AuthenticatedRequest<RouteParams.TestId>; type Delete = AuthenticatedRequest<RouteParams.TestId>;

View file

@ -1,7 +1,7 @@
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import path from 'path'; import path from 'path';
import { getPastExecutionStartNode } from '../utils.ee'; import { getPastExecutionTriggerNode } from '../utils.ee';
const executionDataJson = JSON.parse( const executionDataJson = JSON.parse(
readFileSync(path.join(__dirname, './mock-data/execution-data.json'), { encoding: 'utf-8' }), readFileSync(path.join(__dirname, './mock-data/execution-data.json'), { encoding: 'utf-8' }),
@ -21,19 +21,19 @@ const executionDataMultipleTriggersJson2 = JSON.parse(
describe('getPastExecutionStartNode', () => { describe('getPastExecutionStartNode', () => {
test('should return the start node of the past execution', () => { test('should return the start node of the past execution', () => {
const startNode = getPastExecutionStartNode(executionDataJson); const startNode = getPastExecutionTriggerNode(executionDataJson);
expect(startNode).toEqual('When clicking Test workflow'); expect(startNode).toEqual('When clicking Test workflow');
}); });
test('should return the start node of the past execution with multiple triggers', () => { test('should return the start node of the past execution with multiple triggers', () => {
const startNode = getPastExecutionStartNode(executionDataMultipleTriggersJson); const startNode = getPastExecutionTriggerNode(executionDataMultipleTriggersJson);
expect(startNode).toEqual('When clicking Test workflow'); expect(startNode).toEqual('When clicking Test workflow');
}); });
test('should return the start node of the past execution with multiple triggers - chat trigger', () => { test('should return the start node of the past execution with multiple triggers - chat trigger', () => {
const startNode = getPastExecutionStartNode(executionDataMultipleTriggersJson2); const startNode = getPastExecutionTriggerNode(executionDataMultipleTriggersJson2);
expect(startNode).toEqual('When chat message received'); expect(startNode).toEqual('When chat message received');
}); });

View file

@ -22,7 +22,7 @@ import { getRunData } from '@/workflow-execute-additional-data';
import { WorkflowRunner } from '@/workflow-runner'; import { WorkflowRunner } from '@/workflow-runner';
import { EvaluationMetrics } from './evaluation-metrics.ee'; import { EvaluationMetrics } from './evaluation-metrics.ee';
import { createPinData, getPastExecutionStartNode } from './utils.ee'; import { createPinData, getPastExecutionTriggerNode } from './utils.ee';
/** /**
* This service orchestrates the running of test cases. * This service orchestrates the running of test cases.
@ -58,7 +58,7 @@ export class TestRunnerService {
const pinData = createPinData(workflow, pastExecutionData); const pinData = createPinData(workflow, pastExecutionData);
// Determine the start node of the past execution // Determine the start node of the past execution
const pastExecutionStartNode = getPastExecutionStartNode(pastExecutionData); const pastExecutionStartNode = getPastExecutionTriggerNode(pastExecutionData);
// Prepare the data to run the workflow // Prepare the data to run the workflow
const data: IWorkflowExecutionDataProcess = { const data: IWorkflowExecutionDataProcess = {

View file

@ -26,7 +26,7 @@ export function createPinData(workflow: WorkflowEntity, executionData: IRunExecu
* Returns the start node of the past execution. * Returns the start node of the past execution.
* The start node is the node that has no source and has run data. * The start node is the node that has no source and has run data.
*/ */
export function getPastExecutionStartNode(executionData: IRunExecutionData) { export function getPastExecutionTriggerNode(executionData: IRunExecutionData) {
return Object.keys(executionData.resultData.runData).find((nodeName) => { return Object.keys(executionData.resultData.runData).find((nodeName) => {
const data = executionData.resultData.runData[nodeName]; const data = executionData.resultData.runData[nodeName];
return !data[0].source || data[0].source.length === 0 || data[0].source[0] === null; return !data[0].source || data[0].source.length === 0 || data[0].source[0] === null;

View file

@ -0,0 +1,57 @@
import { TaskRunnersConfig } from '@n8n/config';
import { mock } from 'jest-mock-extended';
import type { Logger } from '@/logging/logger.service';
import type { TaskRunnerAuthService } from '@/runners/auth/task-runner-auth.service';
import { TaskRunnerRestartLoopError } from '@/runners/errors/task-runner-restart-loop-error';
import { RunnerLifecycleEvents } from '@/runners/runner-lifecycle-events';
import { TaskRunnerProcess } from '@/runners/task-runner-process';
import { TaskRunnerProcessRestartLoopDetector } from '@/runners/task-runner-process-restart-loop-detector';
describe('TaskRunnerProcessRestartLoopDetector', () => {
const mockLogger = mock<Logger>();
const mockAuthService = mock<TaskRunnerAuthService>();
const runnerConfig = new TaskRunnersConfig();
const taskRunnerProcess = new TaskRunnerProcess(
mockLogger,
runnerConfig,
mockAuthService,
new RunnerLifecycleEvents(),
);
it('should detect a restart loop if process exits 5 times within 5s', () => {
const restartLoopDetector = new TaskRunnerProcessRestartLoopDetector(taskRunnerProcess);
let emittedError: TaskRunnerRestartLoopError | undefined = undefined;
restartLoopDetector.on('restart-loop-detected', (error) => {
emittedError = error;
});
taskRunnerProcess.emit('exit');
taskRunnerProcess.emit('exit');
taskRunnerProcess.emit('exit');
taskRunnerProcess.emit('exit');
taskRunnerProcess.emit('exit');
expect(emittedError).toBeInstanceOf(TaskRunnerRestartLoopError);
});
it('should not detect a restart loop if process exits less than 5 times within 5s', () => {
jest.useFakeTimers();
const restartLoopDetector = new TaskRunnerProcessRestartLoopDetector(taskRunnerProcess);
let emittedError: TaskRunnerRestartLoopError | undefined = undefined;
restartLoopDetector.on('restart-loop-detected', (error) => {
emittedError = error;
});
taskRunnerProcess.emit('exit');
taskRunnerProcess.emit('exit');
taskRunnerProcess.emit('exit');
taskRunnerProcess.emit('exit');
jest.advanceTimersByTime(5010);
taskRunnerProcess.emit('exit');
expect(emittedError).toBeUndefined();
});
});

View file

@ -0,0 +1,14 @@
import { ApplicationError } from 'n8n-workflow';
export class TaskRunnerRestartLoopError extends ApplicationError {
constructor(
public readonly howManyTimes: number,
public readonly timePeriodMs: number,
) {
const message = `Task runner has restarted ${howManyTimes} times within ${timePeriodMs / 1000} seconds. This is an abnormally high restart rate that suggests a bug or other issue is preventing your runner process from starting up. If this issues persists, please file a report at: https://github.com/n8n-io/n8n/issues`;
super(message, {
level: 'fatal',
});
}
}

View file

@ -1,9 +1,13 @@
import { TaskRunnersConfig } from '@n8n/config'; import { TaskRunnersConfig } from '@n8n/config';
import { ErrorReporterProxy, sleep } from 'n8n-workflow';
import * as a from 'node:assert/strict'; import * as a from 'node:assert/strict';
import Container, { Service } from 'typedi'; import Container, { Service } from 'typedi';
import { OnShutdown } from '@/decorators/on-shutdown'; import { OnShutdown } from '@/decorators/on-shutdown';
import { Logger } from '@/logging/logger.service';
import type { TaskRunnerRestartLoopError } from '@/runners/errors/task-runner-restart-loop-error';
import type { TaskRunnerProcess } from '@/runners/task-runner-process'; import type { TaskRunnerProcess } from '@/runners/task-runner-process';
import { TaskRunnerProcessRestartLoopDetector } from '@/runners/task-runner-process-restart-loop-detector';
import { MissingAuthTokenError } from './errors/missing-auth-token.error'; import { MissingAuthTokenError } from './errors/missing-auth-token.error';
import { TaskRunnerWsServer } from './runner-ws-server'; import { TaskRunnerWsServer } from './runner-ws-server';
@ -25,7 +29,14 @@ export class TaskRunnerModule {
private taskRunnerProcess: TaskRunnerProcess | undefined; private taskRunnerProcess: TaskRunnerProcess | undefined;
constructor(private readonly runnerConfig: TaskRunnersConfig) {} private taskRunnerProcessRestartLoopDetector: TaskRunnerProcessRestartLoopDetector | undefined;
constructor(
private readonly logger: Logger,
private readonly runnerConfig: TaskRunnersConfig,
) {
this.logger = this.logger.scoped('task-runner');
}
async start() { async start() {
a.ok(this.runnerConfig.enabled, 'Task runner is disabled'); a.ok(this.runnerConfig.enabled, 'Task runner is disabled');
@ -83,6 +94,14 @@ export class TaskRunnerModule {
const { TaskRunnerProcess } = await import('@/runners/task-runner-process'); const { TaskRunnerProcess } = await import('@/runners/task-runner-process');
this.taskRunnerProcess = Container.get(TaskRunnerProcess); this.taskRunnerProcess = Container.get(TaskRunnerProcess);
this.taskRunnerProcessRestartLoopDetector = new TaskRunnerProcessRestartLoopDetector(
this.taskRunnerProcess,
);
this.taskRunnerProcessRestartLoopDetector.on(
'restart-loop-detected',
this.onRunnerRestartLoopDetected,
);
await this.taskRunnerProcess.start(); await this.taskRunnerProcess.start();
const { InternalTaskRunnerDisconnectAnalyzer } = await import( const { InternalTaskRunnerDisconnectAnalyzer } = await import(
@ -92,4 +111,13 @@ export class TaskRunnerModule {
Container.get(InternalTaskRunnerDisconnectAnalyzer), Container.get(InternalTaskRunnerDisconnectAnalyzer),
); );
} }
private onRunnerRestartLoopDetected = async (error: TaskRunnerRestartLoopError) => {
this.logger.error(error.message);
ErrorReporterProxy.error(error);
// Allow some time for the error to be flushed
await sleep(1000);
process.exit(1);
};
} }

View file

@ -0,0 +1,73 @@
import { Time } from '@/constants';
import { TaskRunnerRestartLoopError } from '@/runners/errors/task-runner-restart-loop-error';
import type { TaskRunnerProcess } from '@/runners/task-runner-process';
import { TypedEmitter } from '@/typed-emitter';
const MAX_RESTARTS = 5;
const RESTARTS_WINDOW = 2 * Time.seconds.toMilliseconds;
type TaskRunnerProcessRestartLoopDetectorEventMap = {
'restart-loop-detected': TaskRunnerRestartLoopError;
};
/**
* A class to monitor the task runner process for restart loops
*/
export class TaskRunnerProcessRestartLoopDetector extends TypedEmitter<TaskRunnerProcessRestartLoopDetectorEventMap> {
/**
* How many times the process needs to restart for it to be detected
* being in a loop.
*/
private readonly maxCount = MAX_RESTARTS;
/**
* The time interval in which the process needs to restart `maxCount` times
* to be detected as being in a loop.
*/
private readonly restartsWindow = RESTARTS_WINDOW;
private numRestarts = 0;
/** Time when the first restart of a loop happened within a time window */
private firstRestartedAt = Date.now();
constructor(private readonly taskRunnerProcess: TaskRunnerProcess) {
super();
this.taskRunnerProcess.on('exit', () => {
this.increment();
if (this.isMaxCountExceeded()) {
this.emit(
'restart-loop-detected',
new TaskRunnerRestartLoopError(this.numRestarts, this.msSinceFirstIncrement()),
);
}
});
}
/**
* Increments the counter
*/
private increment() {
const now = Date.now();
if (now > this.firstRestartedAt + this.restartsWindow) {
this.reset();
}
this.numRestarts++;
}
private reset() {
this.numRestarts = 0;
this.firstRestartedAt = Date.now();
}
private isMaxCountExceeded() {
return this.numRestarts >= this.maxCount;
}
private msSinceFirstIncrement() {
return Date.now() - this.firstRestartedAt;
}
}

View file

@ -95,19 +95,19 @@ export class TaskRunnerProcess extends TypedEmitter<TaskRunnerProcessEventMap> {
const grantToken = await this.authService.createGrantToken(); const grantToken = await this.authService.createGrantToken();
const n8nUri = `127.0.0.1:${this.runnerConfig.port}`; const taskBrokerUri = `http://127.0.0.1:${this.runnerConfig.port}`;
this.process = this.startNode(grantToken, n8nUri); this.process = this.startNode(grantToken, taskBrokerUri);
forwardToLogger(this.logger, this.process, '[Task Runner]: '); forwardToLogger(this.logger, this.process, '[Task Runner]: ');
this.monitorProcess(this.process); this.monitorProcess(this.process);
} }
startNode(grantToken: string, n8nUri: string) { startNode(grantToken: string, taskBrokerUri: string) {
const startScript = require.resolve('@n8n/task-runner/start'); const startScript = require.resolve('@n8n/task-runner/start');
return spawn('node', [startScript], { return spawn('node', [startScript], {
env: this.getProcessEnvVars(grantToken, n8nUri), env: this.getProcessEnvVars(grantToken, taskBrokerUri),
}); });
} }
@ -159,10 +159,10 @@ export class TaskRunnerProcess extends TypedEmitter<TaskRunnerProcessEventMap> {
} }
} }
private getProcessEnvVars(grantToken: string, n8nUri: string) { private getProcessEnvVars(grantToken: string, taskBrokerUri: string) {
const envVars: Record<string, string> = { const envVars: Record<string, string> = {
N8N_RUNNERS_GRANT_TOKEN: grantToken, 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_PAYLOAD: this.runnerConfig.maxPayload.toString(),
N8N_RUNNERS_MAX_CONCURRENCY: this.runnerConfig.maxConcurrency.toString(), N8N_RUNNERS_MAX_CONCURRENCY: this.runnerConfig.maxConcurrency.toString(),
...this.getPassthroughEnvVars(), ...this.getPassthroughEnvVars(),

View file

@ -232,6 +232,7 @@ export class FrontendService {
}, },
betaFeatures: this.frontendConfig.betaFeatures, betaFeatures: this.frontendConfig.betaFeatures,
virtualSchemaView: config.getEnv('virtualSchemaView'), virtualSchemaView: config.getEnv('virtualSchemaView'),
easyAIWorkflowOnboarded: false,
}; };
} }
@ -274,6 +275,11 @@ export class FrontendService {
} }
this.settings.banners.dismissed = dismissedBanners; 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 isS3Selected = config.getEnv('binaryDataManager.mode') === 's3';
const isS3Available = config.getEnv('binaryDataManager.availableModes').includes('s3'); const isS3Available = config.getEnv('binaryDataManager.availableModes').includes('s3');

View file

@ -2,6 +2,7 @@ import { InstanceSettings } from 'n8n-core';
import { ApplicationError, type IWorkflowExecutionDataProcess } from 'n8n-workflow'; import { ApplicationError, type IWorkflowExecutionDataProcess } from 'n8n-workflow';
import { Service } from 'typedi'; import { Service } from 'typedi';
import { ActiveExecutions } from '@/active-executions';
import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { ExecutionRepository } from '@/databases/repositories/execution.repository';
import { Logger } from '@/logging/logger.service'; import { Logger } from '@/logging/logger.service';
import { OrchestrationService } from '@/services/orchestration.service'; import { OrchestrationService } from '@/services/orchestration.service';
@ -23,6 +24,7 @@ export class WaitTracker {
private readonly logger: Logger, private readonly logger: Logger,
private readonly executionRepository: ExecutionRepository, private readonly executionRepository: ExecutionRepository,
private readonly ownershipService: OwnershipService, private readonly ownershipService: OwnershipService,
private readonly activeExecutions: ActiveExecutions,
private readonly workflowRunner: WorkflowRunner, private readonly workflowRunner: WorkflowRunner,
private readonly orchestrationService: OrchestrationService, private readonly orchestrationService: OrchestrationService,
private readonly instanceSettings: InstanceSettings, private readonly instanceSettings: InstanceSettings,
@ -133,6 +135,14 @@ export class WaitTracker {
// Start the execution again // Start the execution again
await this.workflowRunner.run(data, false, false, executionId); 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() { stopTracking() {

View file

@ -48,11 +48,12 @@ import type { Project } from '@/databases/entities/project';
import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.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 { Logger } from '@/logging/logger.service';
import { parseBody } from '@/middlewares'; import { parseBody } from '@/middlewares';
import { OwnershipService } from '@/services/ownership.service'; import { OwnershipService } from '@/services/ownership.service';
import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; import { WorkflowStatisticsService } from '@/services/workflow-statistics.service';
import { WaitTracker } from '@/wait-tracker';
import { createMultiFormDataParser } from '@/webhooks/webhook-form-data'; import { createMultiFormDataParser } from '@/webhooks/webhook-form-data';
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
import * as WorkflowHelpers from '@/workflow-helpers'; import * as WorkflowHelpers from '@/workflow-helpers';
@ -548,11 +549,21 @@ export async function executeWebhook(
{ executionId }, { 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) { 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<IExecutionDb | undefined>;
executePromise executePromise
// eslint-disable-next-line complexity // eslint-disable-next-line complexity
.then(async (data) => { .then(async (data) => {

View file

@ -709,6 +709,7 @@ export async function getRunData(
waitingExecution: {}, waitingExecution: {},
waitingExecutionSource: {}, waitingExecutionSource: {},
}, },
parentExecution,
}; };
return { return {
@ -944,6 +945,7 @@ async function startExecution(
return { return {
executionId, executionId,
data: returnData!.data!.main, data: returnData!.data!.main,
waitTill: data.waitTill,
}; };
} }
activeExecutions.finalizeExecution(executionId, data); activeExecutions.finalizeExecution(executionId, data);

View file

@ -66,6 +66,20 @@ export class WorkflowService {
let { workflows, count } = await this.workflowRepository.getMany(sharedWorkflowIds, options); let { workflows, count } = await this.workflowRepository.getMany(sharedWorkflowIds, options);
if (hasSharing(workflows)) { if (hasSharing(workflows)) {
// Since we're filtering using project ID as part of the relation,
// we end up filtering out all the other relations, meaning that if
// it's shared to a project, it won't be able to find the home project.
// To solve this, we have to get all the relation now, even though
// we're deleting them later.
if (typeof options?.filter?.projectId === 'string' && options.filter.projectId !== '') {
const relations = await this.sharedWorkflowRepository.getAllRelationsForWorkflows(
workflows.map((c) => c.id),
);
workflows.forEach((c) => {
c.shared = relations.filter((r) => r.workflowId === c.id);
});
}
workflows = workflows.map((w) => this.ownershipService.addOwnedByAndSharedWith(w)); workflows = workflows.map((w) => this.ownershipService.addOwnedByAndSharedWith(w));
} }
@ -75,8 +89,8 @@ export class WorkflowService {
} }
workflows.forEach((w) => { workflows.forEach((w) => {
// @ts-expect-error: This is to emulate the old behaviour of removing the shared // This is to emulate the old behaviour of removing the shared field as
// field as part of `addOwnedByAndSharedWith`. We need this field in `addScopes` // part of `addOwnedByAndSharedWith`. We need this field in `addScopes`
// though. So to avoid leaking the information we just delete it. // though. So to avoid leaking the information we just delete it.
delete w.shared; delete w.shared;
}); });

View file

@ -394,6 +394,57 @@ describe('PATCH /evaluation/test-definitions/:id', () => {
expect(resp.statusCode).toBe(400); expect(resp.statusCode).toBe(400);
expect(resp.body.message).toBe('Annotation tag not found'); expect(resp.body.message).toBe('Annotation tag not found');
}); });
test('should update pinned nodes', async () => {
const newTest = Container.get(TestDefinitionRepository).create({
name: 'test',
workflow: { id: workflowUnderTest.id },
});
await Container.get(TestDefinitionRepository).save(newTest);
const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({
mockedNodes: [
{
name: 'Schedule Trigger',
},
],
});
expect(resp.statusCode).toBe(200);
expect(resp.body.data.mockedNodes).toEqual([{ name: 'Schedule Trigger' }]);
});
test('should return error if pinned nodes are invalid', async () => {
const newTest = Container.get(TestDefinitionRepository).create({
name: 'test',
workflow: { id: workflowUnderTest.id },
});
await Container.get(TestDefinitionRepository).save(newTest);
const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({
mockedNodes: ['Simple string'],
});
expect(resp.statusCode).toBe(400);
});
test('should return error if pinned nodes are not in the workflow', async () => {
const newTest = Container.get(TestDefinitionRepository).create({
name: 'test',
workflow: { id: workflowUnderTest.id },
});
await Container.get(TestDefinitionRepository).save(newTest);
const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({
mockedNodes: [
{
name: 'Invalid Node',
},
],
});
expect(resp.statusCode).toBe(400);
});
}); });
describe('DELETE /evaluation/test-definitions/:id', () => { describe('DELETE /evaluation/test-definitions/:id', () => {

View file

@ -1,4 +1,5 @@
import { TaskRunnersConfig } from '@n8n/config'; import { TaskRunnersConfig } from '@n8n/config';
import { mock } from 'jest-mock-extended';
import Container from 'typedi'; import Container from 'typedi';
import { MissingAuthTokenError } from '@/runners/errors/missing-auth-token.error'; import { MissingAuthTokenError } from '@/runners/errors/missing-auth-token.error';
@ -32,7 +33,7 @@ describe('TaskRunnerModule in external mode', () => {
runnerConfig.enabled = true; runnerConfig.enabled = true;
runnerConfig.authToken = ''; runnerConfig.authToken = '';
const module = new TaskRunnerModule(runnerConfig); const module = new TaskRunnerModule(mock(), runnerConfig);
await expect(module.start()).rejects.toThrowError(MissingAuthTokenError); await expect(module.start()).rejects.toThrowError(MissingAuthTokenError);
}); });

View file

@ -3,6 +3,7 @@ import Container from 'typedi';
import { TaskRunnerWsServer } from '@/runners/runner-ws-server'; import { TaskRunnerWsServer } from '@/runners/runner-ws-server';
import { TaskBroker } from '@/runners/task-broker.service'; import { TaskBroker } from '@/runners/task-broker.service';
import { TaskRunnerProcess } from '@/runners/task-runner-process'; import { TaskRunnerProcess } from '@/runners/task-runner-process';
import { TaskRunnerProcessRestartLoopDetector } from '@/runners/task-runner-process-restart-loop-detector';
import { retryUntil } from '@test-integration/retry-until'; import { retryUntil } from '@test-integration/retry-until';
import { setupBrokerTestServer } from '@test-integration/utils/task-broker-test-server'; import { setupBrokerTestServer } from '@test-integration/utils/task-broker-test-server';
@ -84,4 +85,33 @@ describe('TaskRunnerProcess', () => {
expect(getNumRegisteredRunners()).toBe(1); expect(getNumRegisteredRunners()).toBe(1);
expect(runnerProcess.pid).not.toBe(processId); expect(runnerProcess.pid).not.toBe(processId);
}); });
it('should work together with restart loop detector', async () => {
// Arrange
const restartLoopDetector = new TaskRunnerProcessRestartLoopDetector(runnerProcess);
let restartLoopDetectedEventEmitted = false;
restartLoopDetector.once('restart-loop-detected', () => {
restartLoopDetectedEventEmitted = true;
});
// Act
await runnerProcess.start();
// Simulate a restart loop
for (let i = 0; i < 5; i++) {
await retryUntil(() => {
expect(runnerProcess.pid).toBeDefined();
});
// @ts-expect-error private property
runnerProcess.process?.kill();
await new Promise((resolve) => {
runnerProcess.once('exit', resolve);
});
}
// Assert
expect(restartLoopDetectedEventEmitted).toBe(true);
});
}); });

View file

@ -17,10 +17,14 @@ import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee';
import { mockInstance } from '../../shared/mocking'; import { mockInstance } from '../../shared/mocking';
import { saveCredential } from '../shared/db/credentials'; import { saveCredential } from '../shared/db/credentials';
import { createTeamProject, linkUserToProject } from '../shared/db/projects'; import { createTeamProject, getPersonalProject, linkUserToProject } from '../shared/db/projects';
import { createTag } from '../shared/db/tags'; import { createTag } from '../shared/db/tags';
import { createManyUsers, createMember, createOwner } from '../shared/db/users'; import { createManyUsers, createMember, createOwner } from '../shared/db/users';
import { createWorkflow, shareWorkflowWithProjects } from '../shared/db/workflows'; import {
createWorkflow,
shareWorkflowWithProjects,
shareWorkflowWithUsers,
} from '../shared/db/workflows';
import { randomCredentialPayload } from '../shared/random'; import { randomCredentialPayload } from '../shared/random';
import * as testDb from '../shared/test-db'; import * as testDb from '../shared/test-db';
import type { SuperAgentTest } from '../shared/types'; import type { SuperAgentTest } from '../shared/types';
@ -676,6 +680,21 @@ describe('GET /workflows', () => {
expect(response2.body.data).toHaveLength(0); expect(response2.body.data).toHaveLength(0);
}); });
test('should return homeProject when filtering workflows by projectId', async () => {
const workflow = await createWorkflow({ name: 'First' }, owner);
await shareWorkflowWithUsers(workflow, [member]);
const pp = await getPersonalProject(member);
const response = await authMemberAgent
.get('/workflows')
.query(`filter={ "projectId": "${pp.id}" }`)
.expect(200);
expect(response.body.data).toHaveLength(1);
expect(response.body.data[0].id).toBe(workflow.id);
expect(response.body.data[0].homeProject).not.toBeNull();
});
}); });
describe('select', () => { describe('select', () => {

View file

@ -13,13 +13,7 @@ import type {
OAuth2CredentialData, OAuth2CredentialData,
} from '@n8n/client-oauth2'; } from '@n8n/client-oauth2';
import { ClientOAuth2 } from '@n8n/client-oauth2'; import { ClientOAuth2 } from '@n8n/client-oauth2';
import type { import type { AxiosError, AxiosHeaders, AxiosRequestConfig, AxiosResponse } from 'axios';
AxiosError,
AxiosHeaders,
AxiosPromise,
AxiosRequestConfig,
AxiosResponse,
} from 'axios';
import axios from 'axios'; import axios from 'axios';
import crypto, { createHmac } from 'crypto'; import crypto, { createHmac } from 'crypto';
import FileType from 'file-type'; 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'); 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( export async function proxyRequestToAxios(
workflow: Workflow | undefined, workflow: Workflow | undefined,
additionalData: IWorkflowExecuteAdditionalData | undefined, additionalData: IWorkflowExecuteAdditionalData | undefined,
@ -768,29 +782,8 @@ export async function proxyRequestToAxios(
axiosConfig = Object.assign(axiosConfig, await parseRequestObject(configObject)); 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 { try {
const response = await requestFn(); const response = await invokeAxios(axiosConfig, configObject.auth);
let body = response.data; let body = response.data;
if (body instanceof IncomingMessage && axiosConfig.responseType === 'stream') { if (body instanceof IncomingMessage && axiosConfig.responseType === 'stream') {
parseIncomingMessage(body); parseIncomingMessage(body);
@ -982,7 +975,7 @@ export async function httpRequest(
): Promise<IN8nHttpFullResponse | IN8nHttpResponse> { ): Promise<IN8nHttpFullResponse | IN8nHttpResponse> {
removeEmptyBody(requestOptions); removeEmptyBody(requestOptions);
let axiosRequest = convertN8nRequestToAxios(requestOptions); const axiosRequest = convertN8nRequestToAxios(requestOptions);
if ( if (
axiosRequest.data === undefined || axiosRequest.data === undefined ||
(axiosRequest.method !== undefined && axiosRequest.method.toUpperCase() === 'GET') (axiosRequest.method !== undefined && axiosRequest.method.toUpperCase() === 'GET')
@ -990,23 +983,7 @@ export async function httpRequest(
delete axiosRequest.data; delete axiosRequest.data;
} }
let result: AxiosResponse<any>; const result = await invokeAxios(axiosRequest, requestOptions.auth);
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;
}
if (requestOptions.returnFullResponse) { if (requestOptions.returnFullResponse) {
return { return {

View file

@ -42,6 +42,10 @@ export class DirectedGraph {
private connections: Map<DirectedGraphKey, GraphConnection> = new Map(); private connections: Map<DirectedGraphKey, GraphConnection> = new Map();
hasNode(nodeName: string) {
return this.nodes.has(nodeName);
}
getNodes() { getNodes() {
return new Map(this.nodes.entries()); return new Map(this.nodes.entries());
} }

View file

@ -43,8 +43,8 @@ describe('DirectedGraph', () => {
}); });
// ┌─────┐ ┌─────┐──► null // ┌─────┐ ┌─────┐──► null
// │node1├───►│node2| ┌─────┐ // │node1├───►│node2 ┌─────┐
// └─────┘ └─────┘──►│node3| // └─────┘ └─────┘──►│node3
// └─────┘ // └─────┘
// //
test('linear workflow with null connections', () => { test('linear workflow with null connections', () => {
@ -472,4 +472,24 @@ describe('DirectedGraph', () => {
expect(graph).toEqual(expectedGraph); expect(graph).toEqual(expectedGraph);
}); });
}); });
describe('hasNode', () => {
test("returns node if it's part of the graph", () => {
// ARRANGE
const node = createNodeData({ name: 'node' });
const graph = new DirectedGraph().addNodes(node);
// ACT & ASSERT
expect(graph.hasNode(node.name)).toBe(true);
});
test('returns undefined if there is no node with that name in the graph', () => {
// ARRANGE
const node = createNodeData({ name: 'node' });
const graph = new DirectedGraph().addNodes(node);
// ACT & ASSERT
expect(graph.hasNode(node.name + 'foo')).toBe(false);
});
});
}); });

View file

@ -84,4 +84,31 @@ describe('cleanRunData', () => {
// TODO: Find out if this is a desirable result in milestone 2 // TODO: Find out if this is a desirable result in milestone 2
expect(newRunData).toEqual({}); expect(newRunData).toEqual({});
}); });
// ┌─────┐ ┌─────┐
// │node1├───►│node2│
// └─────┘ └─────┘
test('removes run data of nodes that are not in the subgraph', () => {
// ARRANGE
const node1 = createNodeData({ name: 'Node1' });
const node2 = createNodeData({ name: 'Node2' });
const graph = new DirectedGraph()
.addNodes(node1, node2)
.addConnections({ from: node1, to: node2 });
// not part of the graph
const node3 = createNodeData({ name: 'Node3' });
const runData: IRunData = {
[node1.name]: [toITaskData([{ data: { value: 1 } }])],
[node2.name]: [toITaskData([{ data: { value: 2 } }])],
[node3.name]: [toITaskData([{ data: { value: 3 } }])],
};
// ACT
const newRunData = cleanRunData(runData, graph, new Set([node2]));
// ASSERT
expect(newRunData).toEqual({
[node1.name]: [toITaskData([{ data: { value: 1 } }])],
});
});
}); });

View file

@ -442,4 +442,126 @@ describe('findStartNodes', () => {
expect(startNodes.size).toBe(1); expect(startNodes.size).toBe(1);
expect(startNodes).toContainEqual(node2); expect(startNodes).toContainEqual(node2);
}); });
describe('custom loop logic', () => {
test('if the last run of loop node has no data (null) on the done output, then the loop is the start node', () => {
// ARRANGE
const trigger = createNodeData({ name: 'trigger' });
const loop = createNodeData({ name: 'loop', type: 'n8n-nodes-base.splitInBatches' });
const inLoop = createNodeData({ name: 'inLoop' });
const afterLoop = createNodeData({ name: 'afterLoop' });
const graph = new DirectedGraph()
.addNodes(trigger, loop, inLoop, afterLoop)
.addConnections(
{ from: trigger, to: loop },
{ from: loop, outputIndex: 1, to: inLoop },
{ from: inLoop, to: loop },
{ from: loop, to: afterLoop },
);
const runData: IRunData = {
[trigger.name]: [toITaskData([{ data: { name: 'trigger' } }])],
[loop.name]: [
// only output on the `loop` branch, but no output on the `done`
// branch
toITaskData([{ outputIndex: 1, data: { name: 'loop' } }]),
],
[inLoop.name]: [toITaskData([{ data: { name: 'inLoop' } }])],
};
// ACT
const startNodes = findStartNodes({
graph,
trigger,
destination: afterLoop,
runData,
pinData: {},
});
// ASSERT
expect(startNodes.size).toBe(1);
expect(startNodes).toContainEqual(loop);
});
test('if the last run of loop node has no data (empty array) on the done output, then the loop is the start node', () => {
// ARRANGE
const trigger = createNodeData({ name: 'trigger' });
const loop = createNodeData({ name: 'loop', type: 'n8n-nodes-base.splitInBatches' });
const inLoop = createNodeData({ name: 'inLoop' });
const afterLoop = createNodeData({ name: 'afterLoop' });
const graph = new DirectedGraph()
.addNodes(trigger, loop, inLoop, afterLoop)
.addConnections(
{ from: trigger, to: loop },
{ from: loop, outputIndex: 1, to: inLoop },
{ from: inLoop, to: loop },
{ from: loop, to: afterLoop },
);
const runData: IRunData = {
[trigger.name]: [toITaskData([{ data: { name: 'trigger' } }])],
[loop.name]: [
// This is handcrafted because `toITaskData` does not allow inserting
// an empty array like the first element of `main` below. But the
// execution engine creates ITaskData like this.
{
executionStatus: 'success',
executionTime: 0,
startTime: 0,
source: [],
data: { main: [[], [{ json: { name: 'loop' } }]] },
},
],
[inLoop.name]: [toITaskData([{ data: { name: 'inLoop' } }])],
};
// ACT
const startNodes = findStartNodes({
graph,
trigger,
destination: afterLoop,
runData,
pinData: {},
});
// ASSERT
expect(startNodes.size).toBe(1);
expect(startNodes).toContainEqual(loop);
});
test('if the loop has data on the done output in the last run it does not become a start node', () => {
// ARRANGE
const trigger = createNodeData({ name: 'trigger' });
const loop = createNodeData({ name: 'loop', type: 'n8n-nodes-base.splitInBatches' });
const inLoop = createNodeData({ name: 'inLoop' });
const afterLoop = createNodeData({ name: 'afterLoop' });
const graph = new DirectedGraph()
.addNodes(trigger, loop, inLoop, afterLoop)
.addConnections(
{ from: trigger, to: loop },
{ from: loop, outputIndex: 1, to: inLoop },
{ from: inLoop, to: loop },
{ from: loop, to: afterLoop },
);
const runData: IRunData = {
[trigger.name]: [toITaskData([{ data: { name: 'trigger' } }])],
[loop.name]: [
toITaskData([{ outputIndex: 1, data: { name: 'loop' } }]),
toITaskData([{ outputIndex: 0, data: { name: 'done' } }]),
],
[inLoop.name]: [toITaskData([{ data: { name: 'inLoop' } }])],
};
// ACT
const startNodes = findStartNodes({
graph,
trigger,
destination: afterLoop,
runData,
pinData: {},
});
// ASSERT
expect(startNodes.size).toBe(1);
expect(startNodes).toContainEqual(afterLoop);
});
});
}); });

View file

@ -5,7 +5,7 @@ interface StubNode {
name: string; name: string;
parameters?: INodeParameters; parameters?: INodeParameters;
disabled?: boolean; disabled?: boolean;
type?: string; type?: 'n8n-nodes-base.manualTrigger' | 'n8n-nodes-base.splitInBatches' | (string & {});
} }
export function createNodeData(stubData: StubNode): INode { export function createNodeData(stubData: StubNode): INode {

View file

@ -23,5 +23,13 @@ export function cleanRunData(
} }
} }
// Remove run data for all nodes that are not part of the subgraph
for (const nodeName of Object.keys(newRunData)) {
if (!graph.hasNode(nodeName)) {
// remove run data for node that is not part of the graph
delete newRunData[nodeName];
}
}
return newRunData; return newRunData;
} }

View file

@ -1,7 +1,7 @@
import type { INode, IPinData, IRunData } from 'n8n-workflow'; import { NodeConnectionType, type INode, type IPinData, type IRunData } from 'n8n-workflow';
import type { DirectedGraph } from './DirectedGraph'; import type { DirectedGraph } from './DirectedGraph';
import { getIncomingData } from './getIncomingData'; import { getIncomingData, getIncomingDataFromAnyRun } from './getIncomingData';
/** /**
* A node is dirty if either of the following is true: * A node is dirty if either of the following is true:
@ -73,6 +73,25 @@ function findStartNodesRecursive(
return startNodes; return startNodes;
} }
// If the current node is a loop node, check if the `done` output has data on
// the last run. If it doesn't the loop wasn't fully executed and needs to be
// re-run from the start. Thus the loop node become the start node.
if (current.type === 'n8n-nodes-base.splitInBatches') {
const nodeRunData = getIncomingData(
runData,
current.name,
// last run
-1,
NodeConnectionType.Main,
0,
);
if (nodeRunData === null || nodeRunData.length === 0) {
startNodes.add(current);
return startNodes;
}
}
// If we detect a cycle stop following the branch, there is no start node on // If we detect a cycle stop following the branch, there is no start node on
// this branch. // this branch.
if (seen.has(current)) { if (seen.has(current)) {
@ -82,19 +101,16 @@ function findStartNodesRecursive(
// Recurse with every direct child that is part of the sub graph. // Recurse with every direct child that is part of the sub graph.
const outGoingConnections = graph.getDirectChildConnections(current); const outGoingConnections = graph.getDirectChildConnections(current);
for (const outGoingConnection of outGoingConnections) { for (const outGoingConnection of outGoingConnections) {
const nodeRunData = getIncomingData( const nodeRunData = getIncomingDataFromAnyRun(
runData, runData,
outGoingConnection.from.name, outGoingConnection.from.name,
// NOTE: It's always 0 until I fix the bug that removes the run data for
// old runs. The FE only sends data for one run for each node.
0,
outGoingConnection.type, outGoingConnection.type,
outGoingConnection.outputIndex, outGoingConnection.outputIndex,
); );
// If the node has multiple outputs, only follow the outputs that have run data. // If the node has multiple outputs, only follow the outputs that have run data.
const hasNoRunData = const hasNoRunData =
nodeRunData === null || nodeRunData === undefined || nodeRunData.length === 0; nodeRunData === null || nodeRunData === undefined || nodeRunData.data.length === 0;
if (hasNoRunData) { if (hasNoRunData) {
continue; continue;
} }

View file

@ -1,4 +1,3 @@
import * as a from 'assert';
import type { INodeExecutionData, IRunData, NodeConnectionType } from 'n8n-workflow'; import type { INodeExecutionData, IRunData, NodeConnectionType } from 'n8n-workflow';
export function getIncomingData( export function getIncomingData(
@ -7,18 +6,8 @@ export function getIncomingData(
runIndex: number, runIndex: number,
connectionType: NodeConnectionType, connectionType: NodeConnectionType,
outputIndex: number, outputIndex: number,
): INodeExecutionData[] | null | undefined { ): INodeExecutionData[] | null {
a.ok(runData[nodeName], `Can't find node with name '${nodeName}' in runData.`); return runData[nodeName]?.at(runIndex)?.data?.[connectionType].at(outputIndex) ?? null;
a.ok(
runData[nodeName][runIndex],
`Can't find a run for index '${runIndex}' for node name '${nodeName}'`,
);
a.ok(
runData[nodeName][runIndex].data,
`Can't find data for index '${runIndex}' for node name '${nodeName}'`,
);
return runData[nodeName][runIndex].data[connectionType][outputIndex];
} }
function getRunIndexLength(runData: IRunData, nodeName: string) { function getRunIndexLength(runData: IRunData, nodeName: string) {

View file

@ -354,24 +354,24 @@ export class WorkflowExecute {
} }
// 2. Find the Subgraph // 2. Find the Subgraph
const graph = DirectedGraph.fromWorkflow(workflow); let graph = DirectedGraph.fromWorkflow(workflow);
const subgraph = findSubgraph({ graph: filterDisabledNodes(graph), destination, trigger }); graph = findSubgraph({ graph: filterDisabledNodes(graph), destination, trigger });
const filteredNodes = subgraph.getNodes(); const filteredNodes = graph.getNodes();
// 3. Find the Start Nodes // 3. Find the Start Nodes
runData = omit(runData, dirtyNodeNames); runData = omit(runData, dirtyNodeNames);
let startNodes = findStartNodes({ graph: subgraph, trigger, destination, runData, pinData }); let startNodes = findStartNodes({ graph, trigger, destination, runData, pinData });
// 4. Detect Cycles // 4. Detect Cycles
// 5. Handle Cycles // 5. Handle Cycles
startNodes = handleCycles(graph, startNodes, trigger); startNodes = handleCycles(graph, startNodes, trigger);
// 6. Clean Run Data // 6. Clean Run Data
const newRunData: IRunData = cleanRunData(runData, graph, startNodes); runData = cleanRunData(runData, graph, startNodes);
// 7. Recreate Execution Stack // 7. Recreate Execution Stack
const { nodeExecutionStack, waitingExecution, waitingExecutionSource } = const { nodeExecutionStack, waitingExecution, waitingExecutionSource } =
recreateNodeExecutionStack(subgraph, new Set(startNodes), runData, pinData ?? {}); recreateNodeExecutionStack(graph, new Set(startNodes), runData, pinData ?? {});
// 8. Execute // 8. Execute
this.status = 'running'; this.status = 'running';
@ -381,7 +381,7 @@ export class WorkflowExecute {
runNodeFilter: Array.from(filteredNodes.values()).map((node) => node.name), runNodeFilter: Array.from(filteredNodes.values()).map((node) => node.name),
}, },
resultData: { resultData: {
runData: newRunData, runData,
pinData, pinData,
}, },
executionData: { executionData: {
@ -393,7 +393,7 @@ export class WorkflowExecute {
}, },
}; };
return this.processRunExecutionData(subgraph.toWorkflow({ ...workflow })); return this.processRunExecutionData(graph.toWorkflow({ ...workflow }));
} }
/** /**

View file

@ -1,4 +1,4 @@
import { captor, mock } from 'jest-mock-extended'; import { captor, mock, type MockProxy } from 'jest-mock-extended';
import type { import type {
IRunExecutionData, IRunExecutionData,
ContextType, ContextType,
@ -9,11 +9,21 @@ import type {
ITaskMetadata, ITaskMetadata,
ISourceData, ISourceData,
IExecuteData, IExecuteData,
IWorkflowExecuteAdditionalData,
ExecuteWorkflowData,
RelatedExecution,
IExecuteWorkflowInfo,
} from 'n8n-workflow'; } 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'; import type { BaseExecuteContext } from '../base-execute-context';
const binaryDataService = mock<BinaryDataService>();
Container.set(BinaryDataService, binaryDataService);
export const describeCommonTests = ( export const describeCommonTests = (
context: BaseExecuteContext, context: BaseExecuteContext,
{ {
@ -31,7 +41,7 @@ export const describeCommonTests = (
}, },
) => { ) => {
// @ts-expect-error `additionalData` is private // @ts-expect-error `additionalData` is private
const { additionalData } = context; const additionalData = context.additionalData as MockProxy<IWorkflowExecuteAdditionalData>;
describe('getExecutionCancelSignal', () => { describe('getExecutionCancelSignal', () => {
it('should return the abort signal', () => { it('should return the abort signal', () => {
@ -178,4 +188,55 @@ export const describeCommonTests = (
resolveSimpleParameterValueSpy.mockRestore(); 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<ExecuteWorkflowData>();
const workflowInfo = mock<IExecuteWorkflowInfo>();
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);
});
});
}; };

View file

@ -22,7 +22,7 @@ import type {
ISourceData, ISourceData,
AiEvent, AiEvent,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { ApplicationError, NodeHelpers, WorkflowDataProxy } from 'n8n-workflow'; import { ApplicationError, NodeHelpers, WAIT_INDEFINITELY, WorkflowDataProxy } from 'n8n-workflow';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { BinaryDataService } from '@/BinaryData/BinaryData.service'; import { BinaryDataService } from '@/BinaryData/BinaryData.service';
@ -97,6 +97,13 @@ export class BaseExecuteContext extends NodeExecutionContext {
); );
} }
async putExecutionToWait(waitTill: Date): Promise<void> {
this.runExecutionData.waitTill = waitTill;
if (this.additionalData.setExecutionStatus) {
this.additionalData.setExecutionStatus('waiting');
}
}
async executeWorkflow( async executeWorkflow(
workflowInfo: IExecuteWorkflowInfo, workflowInfo: IExecuteWorkflowInfo,
inputData?: INodeExecutionData[], inputData?: INodeExecutionData[],
@ -106,23 +113,28 @@ export class BaseExecuteContext extends NodeExecutionContext {
parentExecution?: RelatedExecution; parentExecution?: RelatedExecution;
}, },
): Promise<ExecuteWorkflowData> { ): Promise<ExecuteWorkflowData> {
return await this.additionalData const result = await this.additionalData.executeWorkflow(workflowInfo, this.additionalData, {
.executeWorkflow(workflowInfo, this.additionalData, { ...options,
...options, parentWorkflowId: this.workflow.id,
parentWorkflowId: this.workflow.id?.toString(), inputData,
inputData, parentWorkflowSettings: this.workflow.settings,
parentWorkflowSettings: this.workflow.settings, node: this.node,
node: this.node, parentCallbackManager,
parentCallbackManager, });
})
.then(async (result) => { // If a sub-workflow execution goes into the waiting state
const data = await this.binaryDataService.duplicateBinaryData( if (result.waitTill) {
this.workflow.id, // then put the parent workflow execution also into the waiting state,
this.additionalData.executionId!, // but do not use the sub-workflow `waitTill` to avoid WaitTracker resuming the parent execution at the same time as the sub-workflow
result.data, await this.putExecutionToWait(WAIT_INDEFINITELY);
); }
return { ...result, data };
}); const data = await this.binaryDataService.duplicateBinaryData(
this.workflow.id,
this.additionalData.executionId!,
result.data,
);
return { ...result, data };
} }
getNodeInputs(): INodeInputConfiguration[] { getNodeInputs(): INodeInputConfiguration[] {

View file

@ -179,13 +179,6 @@ export class ExecuteContext extends BaseExecuteContext implements IExecuteFuncti
return inputData[inputIndex]; return inputData[inputIndex];
} }
async putExecutionToWait(waitTill: Date): Promise<void> {
this.runExecutionData.waitTill = waitTill;
if (this.additionalData.setExecutionStatus) {
this.additionalData.setExecutionStatus('waiting');
}
}
logNodeOutput(...args: unknown[]): void { logNodeOutput(...args: unknown[]): void {
if (this.mode === 'manual') { if (this.mode === 'manual') {
this.sendMessageToUI(...args); this.sendMessageToUI(...args);

View file

@ -1,3 +1,4 @@
import FormData from 'form-data';
import { mkdtempSync, readFileSync } from 'fs'; import { mkdtempSync, readFileSync } from 'fs';
import { IncomingMessage } from 'http'; import { IncomingMessage } from 'http';
import type { Agent } from 'https'; import type { Agent } from 'https';
@ -26,6 +27,7 @@ import {
binaryToString, binaryToString,
copyInputItems, copyInputItems,
getBinaryDataBuffer, getBinaryDataBuffer,
invokeAxios,
isFilePathBlocked, isFilePathBlocked,
parseContentDisposition, parseContentDisposition,
parseContentType, parseContentType,
@ -543,6 +545,46 @@ describe('NodeExecuteFunctions', () => {
}); });
describe('parseRequestObject', () => { 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 () => { test('should not use Host header for SNI', async () => {
const axiosOptions = await parseRequestObject({ const axiosOptions = await parseRequestObject({
url: 'https://example.de/foo/bar', 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', () => { describe('copyInputItems', () => {
it('should pick only selected properties', () => { it('should pick only selected properties', () => {
const output = copyInputItems( const output = copyInputItems(

View file

@ -9,6 +9,7 @@
// XX denotes that the node is disabled // XX denotes that the node is disabled
// PD denotes that the node has pinned data // PD denotes that the node has pinned data
import { pick } from 'lodash';
import type { IPinData, IRun, IRunData, WorkflowTestData } from 'n8n-workflow'; import type { IPinData, IRun, IRunData, WorkflowTestData } from 'n8n-workflow';
import { import {
ApplicationError, ApplicationError,
@ -18,6 +19,7 @@ import {
} from 'n8n-workflow'; } from 'n8n-workflow';
import { DirectedGraph } from '@/PartialExecutionUtils'; import { DirectedGraph } from '@/PartialExecutionUtils';
import * as partialExecutionUtils from '@/PartialExecutionUtils';
import { createNodeData, toITaskData } from '@/PartialExecutionUtils/__tests__/helpers'; import { createNodeData, toITaskData } from '@/PartialExecutionUtils/__tests__/helpers';
import { WorkflowExecute } from '@/WorkflowExecute'; import { WorkflowExecute } from '@/WorkflowExecute';
@ -324,5 +326,122 @@ describe('WorkflowExecute', () => {
expect(nodes).toContain(node2.name); expect(nodes).toContain(node2.name);
expect(nodes).not.toContain(node1.name); expect(nodes).not.toContain(node1.name);
}); });
// ►►
// ┌────┐0 ┌─────────┐
// ┌───────┐1 │ ├──────►afterLoop│
// │trigger├───┬──►loop│1 └─────────┘
// └───────┘ │ │ ├─┐
// │ └────┘ │
// │ │ ┌──────┐1
// │ └─►inLoop├─┐
// │ └──────┘ │
// └────────────────────┘
test('passes filtered run data to `recreateNodeExecutionStack`', async () => {
// ARRANGE
const waitPromise = createDeferredPromise<IRun>();
const nodeExecutionOrder: string[] = [];
const additionalData = Helpers.WorkflowExecuteAdditionalData(waitPromise, nodeExecutionOrder);
const workflowExecute = new WorkflowExecute(additionalData, 'manual');
const trigger = createNodeData({ name: 'trigger', type: 'n8n-nodes-base.manualTrigger' });
const loop = createNodeData({ name: 'loop', type: 'n8n-nodes-base.splitInBatches' });
const inLoop = createNodeData({ name: 'inLoop' });
const afterLoop = createNodeData({ name: 'afterLoop' });
const workflow = new DirectedGraph()
.addNodes(trigger, loop, inLoop, afterLoop)
.addConnections(
{ from: trigger, to: loop },
{ from: loop, to: afterLoop },
{ from: loop, to: inLoop, outputIndex: 1 },
{ from: inLoop, to: loop },
)
.toWorkflow({ name: '', active: false, nodeTypes });
const pinData: IPinData = {};
const runData: IRunData = {
[trigger.name]: [toITaskData([{ data: { value: 1 } }])],
[loop.name]: [toITaskData([{ data: { nodeName: loop.name }, outputIndex: 1 }])],
[inLoop.name]: [toITaskData([{ data: { nodeName: inLoop.name } }])],
};
const dirtyNodeNames: string[] = [];
jest.spyOn(workflowExecute, 'processRunExecutionData').mockImplementationOnce(jest.fn());
const recreateNodeExecutionStackSpy = jest.spyOn(
partialExecutionUtils,
'recreateNodeExecutionStack',
);
// ACT
await workflowExecute.runPartialWorkflow2(
workflow,
runData,
pinData,
dirtyNodeNames,
afterLoop.name,
);
// ASSERT
expect(recreateNodeExecutionStackSpy).toHaveBeenNthCalledWith(
1,
expect.any(DirectedGraph),
expect.any(Set),
// The run data should only contain the trigger node because the loop
// node has no data on the done branch. That means we have to rerun the
// whole loop, because we don't know how many iterations would be left.
pick(runData, trigger.name),
expect.any(Object),
);
});
// ┌───────┐ ┌─────┐
// │trigger├┬──►│node1│
// └───────┘│ └─────┘
// │ ┌─────┐
// └──►│node2│
// └─────┘
test('passes subgraph to `cleanRunData`', async () => {
// ARRANGE
const waitPromise = createDeferredPromise<IRun>();
const nodeExecutionOrder: string[] = [];
const additionalData = Helpers.WorkflowExecuteAdditionalData(waitPromise, nodeExecutionOrder);
const workflowExecute = new WorkflowExecute(additionalData, 'manual');
const trigger = createNodeData({ name: 'trigger', type: 'n8n-nodes-base.manualTrigger' });
const node1 = createNodeData({ name: 'node1' });
const node2 = createNodeData({ name: 'node2' });
const workflow = new DirectedGraph()
.addNodes(trigger, node1, node2)
.addConnections({ from: trigger, to: node1 }, { from: trigger, to: node2 })
.toWorkflow({ name: '', active: false, nodeTypes });
const pinData: IPinData = {};
const runData: IRunData = {
[trigger.name]: [toITaskData([{ data: { value: 1 } }])],
[node1.name]: [toITaskData([{ data: { nodeName: node1.name } }])],
[node2.name]: [toITaskData([{ data: { nodeName: node2.name } }])],
};
const dirtyNodeNames: string[] = [];
jest.spyOn(workflowExecute, 'processRunExecutionData').mockImplementationOnce(jest.fn());
const cleanRunDataSpy = jest.spyOn(partialExecutionUtils, 'cleanRunData');
// ACT
await workflowExecute.runPartialWorkflow2(
workflow,
runData,
pinData,
dirtyNodeNames,
node1.name,
);
// ASSERT
expect(cleanRunDataSpy).toHaveBeenNthCalledWith(
1,
runData,
new DirectedGraph().addNodes(trigger, node1).addConnections({ from: trigger, to: node1 }),
new Set([node1]),
);
});
}); });
}); });

View file

@ -11,6 +11,7 @@ import { ManualTrigger } from '../../../nodes-base/dist/nodes/ManualTrigger/Manu
import { Merge } from '../../../nodes-base/dist/nodes/Merge/Merge.node'; import { Merge } from '../../../nodes-base/dist/nodes/Merge/Merge.node';
import { NoOp } from '../../../nodes-base/dist/nodes/NoOp/NoOp.node'; import { NoOp } from '../../../nodes-base/dist/nodes/NoOp/NoOp.node';
import { Set } from '../../../nodes-base/dist/nodes/Set/Set.node'; import { Set } from '../../../nodes-base/dist/nodes/Set/Set.node';
import { SplitInBatches } from '../../../nodes-base/dist/nodes/SplitInBatches/SplitInBatches.node';
import { Start } from '../../../nodes-base/dist/nodes/Start/Start.node'; import { Start } from '../../../nodes-base/dist/nodes/Start/Start.node';
export const predefinedNodesTypes: INodeTypeData = { export const predefinedNodesTypes: INodeTypeData = {
@ -38,6 +39,10 @@ export const predefinedNodesTypes: INodeTypeData = {
type: new ManualTrigger(), type: new ManualTrigger(),
sourcePath: '', sourcePath: '',
}, },
'n8n-nodes-base.splitInBatches': {
type: new SplitInBatches(),
sourcePath: '',
},
'n8n-nodes-base.versionTest': { 'n8n-nodes-base.versionTest': {
sourcePath: '', sourcePath: '',
type: { type: {

View file

@ -33,7 +33,7 @@ describe('N8nNavigationDropdown', () => {
it('default slot should trigger first level', async () => { it('default slot should trigger first level', async () => {
const { getByTestId, queryByTestId } = render(NavigationDropdown, { const { getByTestId, queryByTestId } = render(NavigationDropdown, {
slots: { default: h('button', { 'data-test-id': 'test-trigger' }) }, 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: { global: {
plugins: [router], plugins: [router],
}, },
@ -51,9 +51,9 @@ describe('N8nNavigationDropdown', () => {
props: { props: {
menu: [ menu: [
{ {
id: 'aaa', id: 'first',
title: 'aaa', title: 'first',
submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' } }], submenu: [{ id: 'nested', title: 'nested', route: { name: 'projects' } }],
}, },
], ],
}, },
@ -80,9 +80,9 @@ describe('N8nNavigationDropdown', () => {
props: { props: {
menu: [ menu: [
{ {
id: 'aaa', id: 'first',
title: 'aaa', title: 'first',
submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' }, icon: 'user' }], submenu: [{ id: 'nested', title: 'nested', route: { name: 'projects' }, icon: 'user' }],
}, },
], ],
}, },
@ -100,9 +100,9 @@ describe('N8nNavigationDropdown', () => {
props: { props: {
menu: [ menu: [
{ {
id: 'aaa', id: 'first',
title: 'aaa', title: 'first',
submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' }, icon: 'user' }], submenu: [{ id: 'nested', title: 'nested', route: { name: 'projects' }, icon: 'user' }],
}, },
], ],
}, },
@ -114,8 +114,53 @@ describe('N8nNavigationDropdown', () => {
await userEvent.click(getByTestId('navigation-submenu-item')); await userEvent.click(getByTestId('navigation-submenu-item'));
expect(emitted('itemClick')).toStrictEqual([ 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());
}); });
}); });

View file

@ -29,7 +29,7 @@ defineProps<{
}>(); }>();
const menuRef = ref<typeof ElMenu | null>(null); const menuRef = ref<typeof ElMenu | null>(null);
const menuIndex = ref('-1'); const ROOT_MENU_INDEX = '-1';
const emit = defineEmits<{ const emit = defineEmits<{
itemClick: [item: MenuItemRegistered]; itemClick: [item: MenuItemRegistered];
@ -37,7 +37,18 @@ const emit = defineEmits<{
}>(); }>();
const close = () => { 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({ defineExpose({
@ -50,14 +61,16 @@ defineExpose({
ref="menuRef" ref="menuRef"
mode="horizontal" mode="horizontal"
unique-opened unique-opened
menu-trigger="click" :menu-trigger="menuTrigger"
:ellipsis="false" :ellipsis="false"
:class="$style.dropdown" :class="$style.dropdown"
@select="emit('select', $event)" @select="emit('select', $event)"
@keyup.escape="close" @keyup.escape="close"
@open="onOpen"
@close="onClose"
> >
<ElSubMenu <ElSubMenu
:index="menuIndex" :index="ROOT_MENU_INDEX"
:class="$style.trigger" :class="$style.trigger"
:popper-offset="-10" :popper-offset="-10"
:popper-class="$style.submenu" :popper-class="$style.submenu"
@ -70,10 +83,15 @@ defineExpose({
<template v-for="item in menu" :key="item.id"> <template v-for="item in menu" :key="item.id">
<template v-if="item.submenu"> <template v-if="item.submenu">
<ElSubMenu :index="item.id" :popper-offset="-10" data-test-id="navigation-submenu"> <ElSubMenu
:popper-class="$style.nestedSubmenu"
:index="item.id"
:popper-offset="-10"
data-test-id="navigation-submenu"
>
<template #title>{{ item.title }}</template> <template #title>{{ item.title }}</template>
<template v-for="subitem in item.submenu" :key="subitem.id"> <template v-for="subitem in item.submenu" :key="subitem.id">
<ConditionalRouterLink :to="!subitem.disabled && subitem.route"> <ConditionalRouterLink :to="(!subitem.disabled && subitem.route) || undefined">
<ElMenuItem <ElMenuItem
data-test-id="navigation-submenu-item" data-test-id="navigation-submenu-item"
:index="subitem.id" :index="subitem.id"
@ -82,18 +100,20 @@ defineExpose({
> >
<N8nIcon v-if="subitem.icon" :icon="subitem.icon" :class="$style.submenu__icon" /> <N8nIcon v-if="subitem.icon" :icon="subitem.icon" :class="$style.submenu__icon" />
{{ subitem.title }} {{ subitem.title }}
<slot :name="`item.append.${item.id}`" v-bind="{ item }" />
</ElMenuItem> </ElMenuItem>
</ConditionalRouterLink> </ConditionalRouterLink>
</template> </template>
</ElSubMenu> </ElSubMenu>
</template> </template>
<ConditionalRouterLink v-else :to="!item.disabled && item.route"> <ConditionalRouterLink v-else :to="(!item.disabled && item.route) || undefined">
<ElMenuItem <ElMenuItem
:index="item.id" :index="item.id"
:disabled="item.disabled" :disabled="item.disabled"
data-test-id="navigation-menu-item" data-test-id="navigation-menu-item"
> >
{{ item.title }} {{ item.title }}
<slot :name="`item.append.${item.id}`" v-bind="{ item }" />
</ElMenuItem> </ElMenuItem>
</ConditionalRouterLink> </ConditionalRouterLink>
</template> </template>
@ -125,17 +145,25 @@ defineExpose({
} }
} }
.nestedSubmenu {
:global(.el-menu) {
max-height: 450px;
overflow: auto;
}
}
.submenu { .submenu {
padding: 5px 0 !important; padding: 5px 0 !important;
:global(.el-menu--horizontal .el-menu .el-menu-item), :global(.el-menu--horizontal .el-menu .el-menu-item),
:global(.el-menu--horizontal .el-menu .el-sub-menu__title) { :global(.el-menu--horizontal .el-menu .el-sub-menu__title) {
color: var(--color-text-dark); 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-menu-item:not(.is-disabled):hover),
:global(.el-menu--horizontal .el-menu .el-sub-menu__title: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) { :global(.el-popper) {

View file

@ -462,6 +462,10 @@
--color-configurable-node-name: var(--color-text-dark); --color-configurable-node-name: var(--color-text-dark);
--color-secondary-link: var(--prim-color-secondary-tint-200); --color-secondary-link: var(--prim-color-secondary-tint-200);
--color-secondary-link-hover: var(--prim-color-secondary-tint-100); --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'] { body[data-theme='dark'] {

View file

@ -533,6 +533,11 @@
--color-secondary-link: var(--color-secondary); --color-secondary-link: var(--color-secondary);
--color-secondary-link-hover: var(--color-secondary-shade-1); --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 // Generated Color Shades from 50 to 950
// Not yet used in design system // Not yet used in design system
@each $color in ('neutral', 'success', 'warning', 'danger') { @each $color in ('neutral', 'success', 'warning', 'danger') {

View file

@ -59,7 +59,7 @@ import type {
ROLE, ROLE,
} from '@/constants'; } from '@/constants';
import type { BulkCommand, Undoable } from '@/models/history'; 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'; import type { ProjectSharingData } from '@/types/projects.types';
@ -249,6 +249,16 @@ export interface IWorkflowDataCreate extends IWorkflowDataUpdate {
projectId?: string; projectId?: string;
} }
/**
* Workflow data with mandatory `templateId`
* This is used to identify sample workflows that we create for onboarding
*/
export interface WorkflowDataWithTemplateId extends Omit<IWorkflowDataCreate, 'meta'> {
meta: WorkflowMetadata & {
templateId: Required<WorkflowMetadata>['templateId'];
};
}
export interface IWorkflowToShare extends IWorkflowDataUpdate { export interface IWorkflowToShare extends IWorkflowDataUpdate {
meta: WorkflowMetadata; meta: WorkflowMetadata;
} }
@ -1361,51 +1371,6 @@ export type SamlPreferencesExtractedData = {
returnUrl: string; 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<SshKeyTypes>;
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 declare namespace Cloud {
export interface PlanData { export interface PlanData {
planId: number; planId: number;

View file

@ -127,4 +127,5 @@ export const defaultSettings: FrontendSettings = {
}, },
betaFeatures: [], betaFeatures: [],
virtualSchemaView: false, virtualSchemaView: false,
easyAIWorkflowOnboarded: false,
}; };

View file

@ -2,7 +2,7 @@ import type { Server, Request } from 'miragejs';
import { Response } from 'miragejs'; import { Response } from 'miragejs';
import { jsonParse } from 'n8n-workflow'; import { jsonParse } from 'n8n-workflow';
import type { AppSchema } from '@/__tests__/server/types'; import type { AppSchema } from '@/__tests__/server/types';
import type { SourceControlPreferences } from '@/Interface'; import type { SourceControlPreferences } from '@/types/sourceControl.types';
export function routesForSourceControl(server: Server) { export function routesForSourceControl(server: Server) {
const sourceControlApiRoot = '/rest/source-control'; const sourceControlApiRoot = '/rest/source-control';

View file

@ -1,11 +1,12 @@
import type { IDataObject } from 'n8n-workflow'; import type { IDataObject } from 'n8n-workflow';
import type { IRestApiContext } from '@/Interface';
import type { import type {
IRestApiContext,
SourceControlAggregatedFile, SourceControlAggregatedFile,
SourceControlPreferences, SourceControlPreferences,
SourceControlStatus, SourceControlStatus,
SshKeyTypes, SshKeyTypes,
} from '@/Interface'; } from '@/types/sourceControl.types';
import { makeRestApiRequest } from '@/utils/apiUtils'; import { makeRestApiRequest } from '@/utils/apiUtils';
import type { TupleToUnion } from '@/utils/typeHelpers'; import type { TupleToUnion } from '@/utils/typeHelpers';

View file

@ -1,8 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { generateCodeForAiTransform } from './utils'; import { generateCodeForAiTransform, reducePayloadSizeOrThrow } from './utils';
import { createPinia, setActivePinia } from 'pinia'; import { createPinia, setActivePinia } from 'pinia';
import { generateCodeForPrompt } from '@/api/ai'; import { generateCodeForPrompt } from '@/api/ai';
import type { AskAiRequest } from '@/types/assistant.types';
import type { Schema } from '@/Interface';
vi.mock('./utils', async () => { vi.mock('./utils', async () => {
const actual = await vi.importActual('./utils'); const actual = await vi.importActual('./utils');
@ -86,3 +88,69 @@ describe('generateCodeForAiTransform - Retry Tests', () => {
expect(generateCodeForPrompt).toHaveBeenCalledTimes(1); expect(generateCodeForPrompt).toHaveBeenCalledTimes(1);
}); });
}); });
const mockPayload = () =>
({
context: {
schema: [
{ nodeName: 'node1', data: 'some data' },
{ nodeName: 'node2', data: 'other data' },
],
inputSchema: {
schema: {
value: [
{ key: 'prop1', value: 'value1' },
{ key: 'prop2', value: 'value2' },
],
},
},
},
question: 'What is node1 and prop1?',
}) as unknown as AskAiRequest.RequestPayload;
describe('reducePayloadSizeOrThrow', () => {
it('reduces schema size when tokens exceed the limit', () => {
const payload = mockPayload();
const error = new Error('Limit is 100 tokens, but 104 were provided');
reducePayloadSizeOrThrow(payload, error);
expect(payload.context.schema.length).toBe(1);
expect(payload.context.schema[0]).toEqual({ nodeName: 'node1', data: 'some data' });
});
it('removes unreferenced properties in input schema', () => {
const payload = mockPayload();
const error = new Error('Limit is 100 tokens, but 150 were provided');
reducePayloadSizeOrThrow(payload, error);
expect(payload.context.inputSchema.schema.value.length).toBe(1);
expect((payload.context.inputSchema.schema.value as Schema[])[0].key).toBe('prop1');
});
it('removes all parent nodes if needed', () => {
const payload = mockPayload();
const error = new Error('Limit is 100 tokens, but 150 were provided');
payload.question = '';
reducePayloadSizeOrThrow(payload, error);
expect(payload.context.schema.length).toBe(0);
});
it('throws error if tokens still exceed after reductions', () => {
const payload = mockPayload();
const error = new Error('Limit is 100 tokens, but 200 were provided');
expect(() => reducePayloadSizeOrThrow(payload, error)).toThrowError(error);
});
it('throws error if message format is invalid', () => {
const payload = mockPayload();
const error = new Error('Invalid token message format');
expect(() => reducePayloadSizeOrThrow(payload, error)).toThrowError(error);
});
});

View file

@ -57,6 +57,134 @@ export function getSchemas() {
}; };
} }
//------ Reduce payload ------
const estimateNumberOfTokens = (item: unknown, averageTokenLength: number): number => {
if (typeof item === 'object') {
return Math.ceil(JSON.stringify(item).length / averageTokenLength);
}
return 0;
};
const calculateRemainingTokens = (error: Error) => {
// Expected message format:
//'This model's maximum context length is 8192 tokens. However, your messages resulted in 10514 tokens.'
const tokens = error.message.match(/\d+/g);
if (!tokens || tokens.length < 2) throw error;
const maxTokens = parseInt(tokens[0], 10);
const currentTokens = parseInt(tokens[1], 10);
return currentTokens - maxTokens;
};
const trimParentNodesSchema = (
payload: AskAiRequest.RequestPayload,
remainingTokensToReduce: number,
averageTokenLength: number,
) => {
//check if parent nodes schema takes more tokens than available
let parentNodesTokenCount = estimateNumberOfTokens(payload.context.schema, averageTokenLength);
if (remainingTokensToReduce > parentNodesTokenCount) {
remainingTokensToReduce -= parentNodesTokenCount;
payload.context.schema = [];
}
//remove parent nodes not referenced in the prompt
if (payload.context.schema.length) {
const nodes = [...payload.context.schema];
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex++) {
if (payload.question.includes(nodes[nodeIndex].nodeName)) continue;
const nodeTokens = estimateNumberOfTokens(nodes[nodeIndex], averageTokenLength);
remainingTokensToReduce -= nodeTokens;
parentNodesTokenCount -= nodeTokens;
payload.context.schema.splice(nodeIndex, 1);
if (remainingTokensToReduce <= 0) break;
}
}
return [remainingTokensToReduce, parentNodesTokenCount];
};
const trimInputSchemaProperties = (
payload: AskAiRequest.RequestPayload,
remainingTokensToReduce: number,
averageTokenLength: number,
parentNodesTokenCount: number,
) => {
if (remainingTokensToReduce <= 0) return remainingTokensToReduce;
//remove properties not referenced in the prompt from the input schema
if (Array.isArray(payload.context.inputSchema.schema.value)) {
const props = [...payload.context.inputSchema.schema.value];
for (let index = 0; index < props.length; index++) {
const key = props[index].key;
if (key && payload.question.includes(key)) continue;
const propTokens = estimateNumberOfTokens(props[index], averageTokenLength);
remainingTokensToReduce -= propTokens;
payload.context.inputSchema.schema.value.splice(index, 1);
if (remainingTokensToReduce <= 0) break;
}
}
//if tokensToReduce is still remaining, remove all parent nodes
if (remainingTokensToReduce > 0) {
payload.context.schema = [];
remainingTokensToReduce -= parentNodesTokenCount;
}
return remainingTokensToReduce;
};
/**
* Attempts to reduce the size of the payload to fit within token limits or throws an error if unsuccessful,
* payload would be modified in place
*
* @param {AskAiRequest.RequestPayload} payload - The request payload to be trimmed,
* 'schema' and 'inputSchema.schema' will be modified.
* @param {Error} error - The error to throw if the token reduction fails.
* @param {number} [averageTokenLength=4] - The average token length used for estimation.
* @throws {Error} - Throws the provided error if the payload cannot be reduced sufficiently.
*/
export function reducePayloadSizeOrThrow(
payload: AskAiRequest.RequestPayload,
error: Error,
averageTokenLength = 4,
) {
try {
let remainingTokensToReduce = calculateRemainingTokens(error);
const [remaining, parentNodesTokenCount] = trimParentNodesSchema(
payload,
remainingTokensToReduce,
averageTokenLength,
);
remainingTokensToReduce = remaining;
remainingTokensToReduce = trimInputSchemaProperties(
payload,
remainingTokensToReduce,
averageTokenLength,
parentNodesTokenCount,
);
if (remainingTokensToReduce > 0) throw error;
} catch (e) {
throw e;
}
}
export async function generateCodeForAiTransform(prompt: string, path: string, retries = 1) { export async function generateCodeForAiTransform(prompt: string, path: string, retries = 1) {
const schemas = getSchemas(); const schemas = getSchemas();
@ -83,6 +211,11 @@ export async function generateCodeForAiTransform(prompt: string, path: string, r
code = generatedCode; code = generatedCode;
break; break;
} catch (e) { } catch (e) {
if (e.message.includes('maximum context length')) {
reducePayloadSizeOrThrow(payload, e);
continue;
}
retries--; retries--;
if (!retries) throw e; if (!retries) throw e;
} }

View file

@ -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<VueInstance>, () => { onClickOutside(createBtn as Ref<VueInstance>, () => {
createBtn.value?.close(); createBtn.value?.close();
}); });
@ -311,8 +316,8 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
:class="['clickable', $style.sideMenuCollapseButton]" :class="['clickable', $style.sideMenuCollapseButton]"
@click="toggleCollapse" @click="toggleCollapse"
> >
<n8n-icon v-if="isCollapsed" icon="chevron-right" size="xsmall" class="ml-5xs" /> <N8nIcon v-if="isCollapsed" icon="chevron-right" size="xsmall" class="ml-5xs" />
<n8n-icon v-else icon="chevron-left" size="xsmall" class="mr-5xs" /> <N8nIcon v-else icon="chevron-left" size="xsmall" class="mr-5xs" />
</div> </div>
<div :class="$style.logo"> <div :class="$style.logo">
<img :src="logoPath" data-test-id="n8n-logo" :class="$style.icon" alt="n8n" /> <img :src="logoPath" data-test-id="n8n-logo" :class="$style.icon" alt="n8n" />
@ -323,9 +328,21 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
@select="handleMenuSelect" @select="handleMenuSelect"
> >
<N8nIconButton icon="plus" type="secondary" outline /> <N8nIconButton icon="plus" type="secondary" outline />
<template #[createProjectAppendSlotName]="{ item }">
<N8nTooltip v-if="item.disabled" placement="right" :content="projectsLimitReachedMessage">
<N8nButton
:size="'mini'"
style="margin-left: auto"
type="tertiary"
@click="handleMenuSelect(item.id)"
>
{{ i18n.baseText('generic.upgrade') }}
</N8nButton>
</N8nTooltip>
</template>
</N8nNavigationDropdown> </N8nNavigationDropdown>
</div> </div>
<n8n-menu :items="mainMenuItems" :collapsed="isCollapsed" @select="handleSelect"> <N8nMenu :items="mainMenuItems" :collapsed="isCollapsed" @select="handleSelect">
<template #header> <template #header>
<ProjectNavigation <ProjectNavigation
:collapsed="isCollapsed" :collapsed="isCollapsed"
@ -347,14 +364,14 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
<div :class="$style.giftContainer"> <div :class="$style.giftContainer">
<GiftNotificationIcon /> <GiftNotificationIcon />
</div> </div>
<n8n-text <N8nText
:class="{ ['ml-xs']: true, [$style.expanded]: fullyExpanded }" :class="{ ['ml-xs']: true, [$style.expanded]: fullyExpanded }"
color="text-base" color="text-base"
> >
{{ nextVersions.length > 99 ? '99+' : nextVersions.length }} update{{ {{ nextVersions.length > 99 ? '99+' : nextVersions.length }} update{{
nextVersions.length > 1 ? 's' : '' nextVersions.length > 1 ? 's' : ''
}} }}
</n8n-text> </N8nText>
</div> </div>
<MainSidebarSourceControl :is-collapsed="isCollapsed" /> <MainSidebarSourceControl :is-collapsed="isCollapsed" />
</div> </div>
@ -363,35 +380,35 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
<div ref="user" :class="$style.userArea"> <div ref="user" :class="$style.userArea">
<div class="ml-3xs" data-test-id="main-sidebar-user-menu"> <div class="ml-3xs" data-test-id="main-sidebar-user-menu">
<!-- This dropdown is only enabled when sidebar is collapsed --> <!-- This dropdown is only enabled when sidebar is collapsed -->
<el-dropdown placement="right-end" trigger="click" @command="onUserActionToggle"> <ElDropdown placement="right-end" trigger="click" @command="onUserActionToggle">
<div :class="{ [$style.avatar]: true, ['clickable']: isCollapsed }"> <div :class="{ [$style.avatar]: true, ['clickable']: isCollapsed }">
<n8n-avatar <N8nAvatar
:first-name="usersStore.currentUser?.firstName" :first-name="usersStore.currentUser?.firstName"
:last-name="usersStore.currentUser?.lastName" :last-name="usersStore.currentUser?.lastName"
size="small" size="small"
/> />
</div> </div>
<template v-if="isCollapsed" #dropdown> <template v-if="isCollapsed" #dropdown>
<el-dropdown-menu> <ElDropdownMenu>
<el-dropdown-item command="settings"> <ElDropdownItem command="settings">
{{ i18n.baseText('settings') }} {{ i18n.baseText('settings') }}
</el-dropdown-item> </ElDropdownItem>
<el-dropdown-item command="logout"> <ElDropdownItem command="logout">
{{ i18n.baseText('auth.signout') }} {{ i18n.baseText('auth.signout') }}
</el-dropdown-item> </ElDropdownItem>
</el-dropdown-menu> </ElDropdownMenu>
</template> </template>
</el-dropdown> </ElDropdown>
</div> </div>
<div <div
:class="{ ['ml-2xs']: true, [$style.userName]: true, [$style.expanded]: fullyExpanded }" :class="{ ['ml-2xs']: true, [$style.userName]: true, [$style.expanded]: fullyExpanded }"
> >
<n8n-text size="small" :bold="true" color="text-dark">{{ <N8nText size="small" :bold="true" color="text-dark">{{
usersStore.currentUser?.fullName usersStore.currentUser?.fullName
}}</n8n-text> }}</N8nText>
</div> </div>
<div :class="{ [$style.userActions]: true, [$style.expanded]: fullyExpanded }"> <div :class="{ [$style.userActions]: true, [$style.expanded]: fullyExpanded }">
<n8n-action-dropdown <N8nActionDropdown
:items="userMenuItems" :items="userMenuItems"
placement="top-start" placement="top-start"
data-test-id="user-menu" data-test-id="user-menu"
@ -400,7 +417,7 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
</div> </div>
</div> </div>
</template> </template>
</n8n-menu> </N8nMenu>
</div> </div>
</template> </template>

View file

@ -8,7 +8,7 @@ import { useLoadingService } from '@/composables/useLoadingService';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useSourceControlStore } from '@/stores/sourceControl.store';
import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY } from '@/constants'; 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'; import { sourceControlEventBus } from '@/event-bus/source-control';
defineProps<{ defineProps<{

View file

@ -9,7 +9,6 @@ import {
SIMULATE_NODE_TYPE, SIMULATE_NODE_TYPE,
SIMULATE_TRIGGER_NODE_TYPE, SIMULATE_TRIGGER_NODE_TYPE,
WAIT_NODE_TYPE, WAIT_NODE_TYPE,
WAIT_TIME_UNLIMITED,
} from '@/constants'; } from '@/constants';
import type { import type {
ExecutionSummary, ExecutionSummary,
@ -18,7 +17,12 @@ import type {
NodeOperationError, NodeOperationError,
Workflow, Workflow,
} from 'n8n-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 type { StyleValue } from 'vue';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import xss from 'xss'; import xss from 'xss';
@ -345,7 +349,7 @@ const waiting = computed(() => {
return i18n.baseText('node.theNodeIsWaitingFormCall'); return i18n.baseText('node.theNodeIsWaitingFormCall');
} }
const waitDate = new Date(workflowExecution.waitTill); 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.theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall');
} }
return i18n.baseText('node.nodeIsWaitingTill', { return i18n.baseText('node.nodeIsWaitingTill', {

View file

@ -194,7 +194,8 @@ function onDrop(newParamValue: string) {
watch( watch(
() => props.isReadOnly, () => props.isReadOnly,
(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 }); valueChanged({ name: props.path, value: props.parameter.default });
} }
}, },

View file

@ -79,7 +79,6 @@ import {
REPORTED_SOURCE_OTHER, REPORTED_SOURCE_OTHER,
REPORTED_SOURCE_OTHER_KEY, REPORTED_SOURCE_OTHER_KEY,
VIEWS, VIEWS,
MORE_ONBOARDING_OPTIONS_EXPERIMENT,
COMMUNITY_PLUS_ENROLLMENT_MODAL, COMMUNITY_PLUS_ENROLLMENT_MODAL,
} from '@/constants'; } from '@/constants';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
@ -552,12 +551,9 @@ const onSave = () => {
}; };
const closeCallback = () => { 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 // In case the redirect to homepage for new users didn't happen
// we try again after closing the modal // we try again after closing the modal
if (route.name !== VIEWS.HOMEPAGE && !isPartOfOnboardingExperiment) { if (route.name !== VIEWS.HOMEPAGE) {
void router.replace({ name: VIEWS.HOMEPAGE }); void router.replace({ name: VIEWS.HOMEPAGE });
} }
}; };

View file

@ -3,11 +3,13 @@ import { within } from '@testing-library/dom';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils'; import { mockedStore } from '@/__tests__/utils';
import { createTestProject } from '@/__tests__/data/projects'; 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 ProjectHeader from '@/components/Projects/ProjectHeader.vue';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import type { Project } from '@/types/projects.types'; import type { Project } from '@/types/projects.types';
import { ProjectTypes } from '@/types/projects.types'; import { ProjectTypes } from '@/types/projects.types';
import { VIEWS } from '@/constants';
vi.mock('vue-router', async () => { vi.mock('vue-router', async () => {
const actual = await vi.importActual('vue-router'); const actual = await vi.importActual('vue-router');
@ -35,13 +37,13 @@ const renderComponent = createComponentRenderer(ProjectHeader, {
}, },
}); });
let route: ReturnType<typeof useRoute>; let route: ReturnType<typeof router.useRoute>;
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>; let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
describe('ProjectHeader', () => { describe('ProjectHeader', () => {
beforeEach(() => { beforeEach(() => {
createTestingPinia(); createTestingPinia();
route = useRoute(); route = router.useRoute();
projectsStore = mockedStore(useProjectsStore); projectsStore = mockedStore(useProjectsStore);
projectsStore.teamProjectsLimit = -1; projectsStore.teamProjectsLimit = -1;
@ -159,4 +161,21 @@ describe('ProjectHeader', () => {
expect(within(getByTestId('resource-add')).getByRole('button', { name: label })).toBeVisible(); 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: '<div><slot></slot></div>',
},
},
},
});
expect(queryByTestId('resource-add')).not.toBeInTheDocument();
});
}); });

View file

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, type Ref, ref } from 'vue'; import { computed, type Ref, ref } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { N8nNavigationDropdown } from 'n8n-design-system'; import { N8nNavigationDropdown, N8nButton, N8nIconButton, N8nTooltip } from 'n8n-design-system';
import { onClickOutside, type VueInstance } from '@vueuse/core'; import { onClickOutside, type VueInstance } from '@vueuse/core';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { ProjectTypes } from '@/types/projects.types'; import { ProjectTypes } from '@/types/projects.types';
@ -9,6 +9,7 @@ import { useProjectsStore } from '@/stores/projects.store';
import ProjectTabs from '@/components/Projects/ProjectTabs.vue'; import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
import { getResourcePermissions } from '@/permissions'; import { getResourcePermissions } from '@/permissions';
import { useGlobalEntityCreation } from '@/composables/useGlobalEntityCreation'; import { useGlobalEntityCreation } from '@/composables/useGlobalEntityCreation';
import { VIEWS } from '@/constants';
const route = useRoute(); const route = useRoute();
const i18n = useI18n(); const i18n = useI18n();
@ -47,9 +48,8 @@ const showSettings = computed(
projectsStore.currentProject?.type === ProjectTypes.Team, projectsStore.currentProject?.type === ProjectTypes.Team,
); );
const { menu, handleSelect } = useGlobalEntityCreation( const { menu, handleSelect, createProjectAppendSlotName, projectsLimitReachedMessage } =
computed(() => !Boolean(projectsStore.currentProject)), useGlobalEntityCreation(computed(() => !Boolean(projectsStore.currentProject)));
);
const createLabel = computed(() => { const createLabel = computed(() => {
if (!projectsStore.currentProject) { if (!projectsStore.currentProject) {
@ -82,17 +82,35 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
</slot> </slot>
</N8nText> </N8nText>
</div> </div>
<div v-if="route.name !== VIEWS.PROJECT_SETTINGS" :class="[$style.headerActions]">
<N8nNavigationDropdown
ref="createBtn"
data-test-id="resource-add"
:menu="menu"
@select="handleSelect"
>
<N8nIconButton :label="createLabel" icon="plus" style="width: auto" />
<template #[createProjectAppendSlotName]="{ item }">
<N8nTooltip
v-if="item.disabled"
placement="right"
:content="projectsLimitReachedMessage"
>
<N8nButton
:size="'mini'"
style="margin-left: auto"
type="tertiary"
@click="handleSelect(item.id)"
>
{{ i18n.baseText('generic.upgrade') }}
</N8nButton>
</N8nTooltip>
</template>
</N8nNavigationDropdown>
</div>
</div> </div>
<div :class="$style.actions"> <div :class="$style.actions">
<ProjectTabs :show-settings="showSettings" /> <ProjectTabs :show-settings="showSettings" />
<N8nNavigationDropdown
ref="createBtn"
data-test-id="resource-add"
:menu="menu"
@select="handleSelect"
>
<N8nIconButton :label="createLabel" icon="plus" style="width: auto" />
</N8nNavigationDropdown>
</div> </div>
</div> </div>
</template> </template>
@ -106,6 +124,10 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
min-height: 64px; min-height: 64px;
} }
.headerActions {
margin-left: auto;
}
.icon { .icon {
border: 1px solid var(--color-foreground-light); border: 1px solid var(--color-foreground-light);
padding: 6px; padding: 6px;

View file

@ -2,7 +2,7 @@
import Modal from './Modal.vue'; import Modal from './Modal.vue';
import { SOURCE_CONTROL_PULL_MODAL_KEY } from '@/constants'; import { SOURCE_CONTROL_PULL_MODAL_KEY } from '@/constants';
import type { EventBus } from 'n8n-design-system/utils'; import type { EventBus } from 'n8n-design-system/utils';
import type { SourceControlAggregatedFile } from '@/Interface'; import type { SourceControlAggregatedFile } from '@/types/sourceControl.types';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useLoadingService } from '@/composables/useLoadingService'; import { useLoadingService } from '@/composables/useLoadingService';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';

View file

@ -1,16 +1,20 @@
import { within } from '@testing-library/dom'; import { within, waitFor } from '@testing-library/dom';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue'; import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { createEventBus } from 'n8n-design-system'; import { createEventBus } from 'n8n-design-system';
import type { SourceControlAggregatedFile } from '@/Interface'; import type { SourceControlAggregatedFile } from '@/types/sourceControl.types';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { mockedStore } from '@/__tests__/utils';
import { VIEWS } from '@/constants';
const eventBus = createEventBus(); const eventBus = createEventBus();
vi.mock('vue-router', () => ({ vi.mock('vue-router', () => ({
useRoute: vi.fn().mockReturnValue({ useRoute: vi.fn().mockReturnValue({
name: vi.fn(),
params: vi.fn(), params: vi.fn(),
fullPath: vi.fn(), fullPath: vi.fn(),
}), }),
@ -20,9 +24,17 @@ vi.mock('vue-router', () => ({
let route: ReturnType<typeof useRoute>; let route: ReturnType<typeof useRoute>;
const RecycleScroller = {
props: {
items: Array,
},
template: '<div><template v-for="item in items"><slot v-bind="{ item }"></slot></template></div>',
};
const renderModal = createComponentRenderer(SourceControlPushModal, { const renderModal = createComponentRenderer(SourceControlPushModal, {
global: { global: {
stubs: { stubs: {
RecycleScroller,
Modal: { Modal: {
template: ` template: `
<div> <div>
@ -40,12 +52,13 @@ const renderModal = createComponentRenderer(SourceControlPushModal, {
describe('SourceControlPushModal', () => { describe('SourceControlPushModal', () => {
beforeEach(() => { beforeEach(() => {
route = useRoute(); route = useRoute();
createTestingPinia();
}); });
it('mounts', () => { it('mounts', () => {
vi.spyOn(route, 'fullPath', 'get').mockReturnValue(''); vi.spyOn(route, 'fullPath', 'get').mockReturnValue('');
const { getByTitle } = renderModal({ const { getByText } = renderModal({
pinia: createTestingPinia(), pinia: createTestingPinia(),
props: { props: {
data: { data: {
@ -54,7 +67,7 @@ describe('SourceControlPushModal', () => {
}, },
}, },
}); });
expect(getByTitle('Commit and push changes')).toBeInTheDocument(); expect(getByText('Commit and push changes')).toBeInTheDocument();
}); });
it('should toggle checkboxes', async () => { it('should toggle checkboxes', async () => {
@ -81,10 +94,7 @@ describe('SourceControlPushModal', () => {
}, },
]; ];
vi.spyOn(route, 'fullPath', 'get').mockReturnValue('/home/workflows');
const { getByTestId, getAllByTestId } = renderModal({ const { getByTestId, getAllByTestId } = renderModal({
pinia: createTestingPinia(),
props: { props: {
data: { data: {
eventBus, eventBus,
@ -148,4 +158,222 @@ describe('SourceControlPushModal', () => {
expect(within(files[0]).getByRole('checkbox')).not.toBeChecked(); expect(within(files[0]).getByRole('checkbox')).not.toBeChecked();
expect(within(files[1]).getByRole('checkbox')).not.toBeChecked(); expect(within(files[1]).getByRole('checkbox')).not.toBeChecked();
}); });
it('should push non workflow entities', async () => {
const status: SourceControlAggregatedFile[] = [
{
id: 'gTbbBkkYTnNyX1jD',
name: 'credential',
type: 'credential',
status: 'created',
location: 'local',
conflict: false,
file: '',
updatedAt: '2024-09-20T10:31:40.000Z',
},
{
id: 'JIGKevgZagmJAnM6',
name: 'variables',
type: 'variables',
status: 'created',
location: 'local',
conflict: false,
file: '',
updatedAt: '2024-09-20T14:42:51.968Z',
},
{
id: 'mappings',
name: 'tags',
type: 'tags',
status: 'modified',
location: 'local',
conflict: false,
file: '/Users/raul/.n8n/git/tags.json',
updatedAt: '2024-12-04T11:29:22.095Z',
},
];
const sourceControlStore = mockedStore(useSourceControlStore);
const { getByTestId, getByText } = renderModal({
props: {
data: {
eventBus,
status,
},
},
});
const submitButton = getByTestId('source-control-push-modal-submit');
const commitMessage = 'commit message';
expect(submitButton).toBeDisabled();
expect(
getByText(
'No workflow changes to push. Only modified credentials, variables, and tags will be pushed.',
),
).toBeInTheDocument();
await userEvent.type(getByTestId('source-control-push-modal-commit'), commitMessage);
expect(submitButton).not.toBeDisabled();
await userEvent.click(submitButton);
expect(sourceControlStore.pushWorkfolder).toHaveBeenCalledWith(
expect.objectContaining({
commitMessage,
fileNames: expect.arrayContaining(status),
force: true,
}),
);
});
it('should auto select currentWorkflow', async () => {
const status: SourceControlAggregatedFile[] = [
{
id: 'gTbbBkkYTnNyX1jD',
name: 'My workflow 1',
type: 'workflow',
status: 'created',
location: 'local',
conflict: false,
file: '/home/user/.n8n/git/workflows/gTbbBkkYTnNyX1jD.json',
updatedAt: '2024-09-20T10:31:40.000Z',
},
{
id: 'JIGKevgZagmJAnM6',
name: 'My workflow 2',
type: 'workflow',
status: 'created',
location: 'local',
conflict: false,
file: '/home/user/.n8n/git/workflows/JIGKevgZagmJAnM6.json',
updatedAt: '2024-09-20T14:42:51.968Z',
},
];
vi.spyOn(route, 'name', 'get').mockReturnValue(VIEWS.WORKFLOW);
vi.spyOn(route, 'params', 'get').mockReturnValue({ name: 'gTbbBkkYTnNyX1jD' });
const { getByTestId, getAllByTestId } = renderModal({
props: {
data: {
eventBus,
status,
},
},
});
const submitButton = getByTestId('source-control-push-modal-submit');
expect(submitButton).toBeDisabled();
const files = getAllByTestId('source-control-push-modal-file-checkbox');
expect(files).toHaveLength(2);
await waitFor(() => expect(within(files[0]).getByRole('checkbox')).toBeChecked());
expect(within(files[1]).getByRole('checkbox')).not.toBeChecked();
await userEvent.type(getByTestId('source-control-push-modal-commit'), 'message');
expect(submitButton).not.toBeDisabled();
});
describe('filters', () => {
it('should filter by name', async () => {
const status: SourceControlAggregatedFile[] = [
{
id: 'gTbbBkkYTnNyX1jD',
name: 'My workflow 1',
type: 'workflow',
status: 'created',
location: 'local',
conflict: false,
file: '/home/user/.n8n/git/workflows/gTbbBkkYTnNyX1jD.json',
updatedAt: '2024-09-20T10:31:40.000Z',
},
{
id: 'JIGKevgZagmJAnM6',
name: 'My workflow 2',
type: 'workflow',
status: 'created',
location: 'local',
conflict: false,
file: '/home/user/.n8n/git/workflows/JIGKevgZagmJAnM6.json',
updatedAt: '2024-09-20T14:42:51.968Z',
},
];
const { getByTestId, getAllByTestId } = renderModal({
props: {
data: {
eventBus,
status,
},
},
});
expect(getAllByTestId('source-control-push-modal-file-checkbox')).toHaveLength(2);
await userEvent.type(getByTestId('source-control-push-search'), '1');
await waitFor(() =>
expect(getAllByTestId('source-control-push-modal-file-checkbox')).toHaveLength(1),
);
});
it('should filter by status', async () => {
const status: SourceControlAggregatedFile[] = [
{
id: 'gTbbBkkYTnNyX1jD',
name: 'Created Workflow',
type: 'workflow',
status: 'created',
location: 'local',
conflict: false,
file: '/home/user/.n8n/git/workflows/gTbbBkkYTnNyX1jD.json',
updatedAt: '2024-09-20T10:31:40.000Z',
},
{
id: 'JIGKevgZagmJAnM6',
name: 'Modified workflow',
type: 'workflow',
status: 'modified',
location: 'local',
conflict: false,
file: '/home/user/.n8n/git/workflows/JIGKevgZagmJAnM6.json',
updatedAt: '2024-09-20T14:42:51.968Z',
},
];
const { getByTestId, getAllByTestId } = renderModal({
props: {
data: {
eventBus,
status,
},
},
});
expect(getAllByTestId('source-control-push-modal-file-checkbox')).toHaveLength(2);
await userEvent.click(getByTestId('source-control-filter-dropdown'));
expect(getByTestId('source-control-status-filter')).toBeVisible();
await userEvent.click(
within(getByTestId('source-control-status-filter')).getByRole('combobox'),
);
await waitFor(() =>
expect(getAllByTestId('source-control-status-filter-option')[0]).toBeVisible(),
);
const menu = getAllByTestId('source-control-status-filter-option')[0]
.parentElement as HTMLElement;
await userEvent.click(within(menu).getByText('New'));
await waitFor(() => {
const items = getAllByTestId('source-control-push-modal-file-checkbox');
expect(items).toHaveLength(1);
expect(items[0]).toHaveTextContent('Created Workflow');
});
});
});
}); });

View file

@ -1,9 +1,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import Modal from './Modal.vue'; import Modal from './Modal.vue';
import { CREDENTIAL_EDIT_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY } from '@/constants'; import { SOURCE_CONTROL_PUSH_MODAL_KEY, VIEWS } from '@/constants';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import type { EventBus } from 'n8n-design-system/utils'; import type { EventBus } from 'n8n-design-system/utils';
import type { SourceControlAggregatedFile } from '@/Interface';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useLoadingService } from '@/composables/useLoadingService'; import { useLoadingService } from '@/composables/useLoadingService';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
@ -11,13 +10,38 @@ import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import dateformat from 'dateformat'; import dateformat from 'dateformat';
import { RecycleScroller } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
import type { BaseTextKey } from '@/plugins/i18n';
import { refDebounced } from '@vueuse/core';
import {
N8nHeading,
N8nText,
N8nLink,
N8nCheckbox,
N8nInput,
N8nIcon,
N8nButton,
N8nBadge,
N8nNotice,
N8nPopover,
N8nSelect,
N8nOption,
N8nInputLabel,
} from 'n8n-design-system';
import {
SOURCE_CONTROL_FILE_STATUS,
SOURCE_CONTROL_FILE_TYPE,
SOURCE_CONTROL_FILE_LOCATION,
type SourceControlledFileStatus,
type SourceControlAggregatedFile,
} from '@/types/sourceControl.types';
import { orderBy } from 'lodash-es';
const props = defineProps<{ const props = defineProps<{
data: { eventBus: EventBus; status: SourceControlAggregatedFile[] }; data: { eventBus: EventBus; status: SourceControlAggregatedFile[] };
}>(); }>();
const defaultStagedFileTypes = ['tags', 'variables', 'credential'];
const loadingService = useLoadingService(); const loadingService = useLoadingService();
const uiStore = useUIStore(); const uiStore = useUIStore();
const toast = useToast(); const toast = useToast();
@ -25,165 +49,178 @@ const i18n = useI18n();
const sourceControlStore = useSourceControlStore(); const sourceControlStore = useSourceControlStore();
const route = useRoute(); const route = useRoute();
const staged = ref<Record<string, boolean>>({}); type Changes = {
const files = ref<SourceControlAggregatedFile[]>( tags: SourceControlAggregatedFile[];
props.data.status.filter((file, index, self) => { variables: SourceControlAggregatedFile[];
// do not show remote workflows that are not yet created locally during push credentials: SourceControlAggregatedFile[];
if (file.location === 'remote' && file.type === 'workflow' && file.status === 'created') { workflows: SourceControlAggregatedFile[];
return false; currentWorkflow?: SourceControlAggregatedFile;
}
return self.findIndex((f) => f.id === file.id) === index;
}) || [],
);
const commitMessage = ref('');
const loading = ref(true);
const context = ref<'workflow' | 'workflows' | 'credentials' | ''>('');
const statusToBadgeThemeMap: Record<string, string> = {
created: 'success',
deleted: 'danger',
modified: 'warning',
renamed: 'warning',
}; };
const isSubmitDisabled = computed(() => { const classifyFilesByType = (
return !commitMessage.value || Object.values(staged.value).every((value) => !value); files: SourceControlAggregatedFile[],
}); currentWorkflowId?: string,
): Changes =>
const workflowId = computed(() => { files.reduce<Changes>(
if (context.value === 'workflow') { (acc, file) => {
return route.params.name as string; // do not show remote workflows that are not yet created locally during push
} if (
file.location === SOURCE_CONTROL_FILE_LOCATION.REMOTE &&
return ''; file.type === SOURCE_CONTROL_FILE_TYPE.WORKFLOW &&
}); file.status === SOURCE_CONTROL_FILE_STATUS.CREATED
) {
const sortedFiles = computed(() => { return acc;
const statusPriority: Record<string, number> = {
modified: 1,
renamed: 2,
created: 3,
deleted: 4,
};
return [...files.value].sort((a, b) => {
if (context.value === 'workflow') {
if (a.id === workflowId.value) {
return -1;
} else if (b.id === workflowId.value) {
return 1;
} }
if (file.type === SOURCE_CONTROL_FILE_TYPE.VARIABLES) {
acc.variables.push(file);
return acc;
}
if (file.type === SOURCE_CONTROL_FILE_TYPE.TAGS) {
acc.tags.push(file);
return acc;
}
if (file.type === SOURCE_CONTROL_FILE_TYPE.WORKFLOW && currentWorkflowId === file.id) {
acc.currentWorkflow = file;
}
if (file.type === SOURCE_CONTROL_FILE_TYPE.WORKFLOW) {
acc.workflows.push(file);
return acc;
}
if (file.type === SOURCE_CONTROL_FILE_TYPE.CREDENTIAL) {
acc.credentials.push(file);
return acc;
}
return acc;
},
{ tags: [], variables: [], credentials: [], workflows: [], currentWorkflow: undefined },
);
const workflowId = computed(
() =>
([VIEWS.WORKFLOW].includes(route.name as VIEWS) && route.params.name?.toString()) || undefined,
);
const changes = computed(() => classifyFilesByType(props.data.status, workflowId.value));
const selectedChanges = ref<Set<string>>(new Set());
const toggleSelected = (id: string) => {
if (selectedChanges.value.has(id)) {
selectedChanges.value.delete(id);
} else {
selectedChanges.value.add(id);
}
};
const maybeSelectCurrentWorkflow = (workflow?: SourceControlAggregatedFile) =>
workflow && selectedChanges.value.add(workflow.id);
onMounted(() => maybeSelectCurrentWorkflow(changes.value.currentWorkflow));
const filters = ref<{ status?: SourceControlledFileStatus }>({
status: undefined,
});
const statusFilterOptions: Array<{ label: string; value: SourceControlledFileStatus }> = [
{
label: 'New',
value: SOURCE_CONTROL_FILE_STATUS.CREATED,
},
{
label: 'Modified',
value: SOURCE_CONTROL_FILE_STATUS.MODIFIED,
},
{
label: 'Deleted',
value: SOURCE_CONTROL_FILE_STATUS.DELETED,
},
] as const;
const search = ref('');
const debouncedSearch = refDebounced(search, 250);
const filterCount = computed(() =>
Object.values(filters.value).reduce((acc, item) => (item ? acc + 1 : acc), 0),
);
const filteredWorkflows = computed(() => {
const searchQuery = debouncedSearch.value.toLocaleLowerCase();
return changes.value.workflows.filter((workflow) => {
if (!workflow.name.toLocaleLowerCase().includes(searchQuery)) {
return false;
} }
if (statusPriority[a.status] < statusPriority[b.status]) { if (filters.value.status && filters.value.status !== workflow.status) {
return -1; return false;
} else if (statusPriority[a.status] > statusPriority[b.status]) {
return 1;
} }
return (a.updatedAt ?? 0) < (b.updatedAt ?? 0) return true;
? 1
: (a.updatedAt ?? 0) > (b.updatedAt ?? 0)
? -1
: 0;
}); });
}); });
const selectAll = computed(() => { const statusPriority: Partial<Record<SourceControlledFileStatus, number>> = {
return files.value.every((file) => staged.value[file.file]); [SOURCE_CONTROL_FILE_STATUS.MODIFIED]: 1,
}); [SOURCE_CONTROL_FILE_STATUS.RENAMED]: 2,
[SOURCE_CONTROL_FILE_STATUS.CREATED]: 3,
[SOURCE_CONTROL_FILE_STATUS.DELETED]: 4,
} as const;
const getPriorityByStatus = (status: SourceControlledFileStatus): number =>
statusPriority[status] ?? 0;
const workflowFiles = computed(() => { const sortedWorkflows = computed(() => {
return files.value.filter((file) => file.type === 'workflow'); const sorted = orderBy(
}); filteredWorkflows.value,
[
const stagedWorkflowFiles = computed(() => { // keep the current workflow at the top of the list
return workflowFiles.value.filter((workflow) => staged.value[workflow.file]); ({ id }) => id === changes.value.currentWorkflow?.id,
}); ({ status }) => getPriorityByStatus(status),
'updatedAt',
const selectAllIndeterminate = computed(() => { ],
return ( ['desc', 'asc', 'desc'],
stagedWorkflowFiles.value.length > 0 &&
stagedWorkflowFiles.value.length < workflowFiles.value.length
); );
return sorted;
}); });
onMounted(async () => { const commitMessage = ref('');
context.value = getContext(); const isSubmitDisabled = computed(() => {
try { if (!commitMessage.value.trim()) {
staged.value = getStagedFilesByContext(files.value); return true;
} catch (error) {
toast.showError(error, i18n.baseText('error'));
} finally {
loading.value = false;
} }
const toBePushed =
changes.value.credentials.length +
changes.value.tags.length +
changes.value.variables.length +
selectedChanges.value.size;
if (toBePushed <= 0) {
return true;
}
return false;
}); });
const selectAll = computed(
() =>
selectedChanges.value.size > 0 && selectedChanges.value.size === sortedWorkflows.value.length,
);
const selectAllIndeterminate = computed(
() => selectedChanges.value.size > 0 && selectedChanges.value.size < sortedWorkflows.value.length,
);
function onToggleSelectAll() { function onToggleSelectAll() {
if (selectAll.value) { if (selectAll.value) {
files.value.forEach((file) => { selectedChanges.value.clear();
if (!defaultStagedFileTypes.includes(file.type)) {
staged.value[file.file] = false;
}
});
} else { } else {
files.value.forEach((file) => { selectedChanges.value = new Set(changes.value.workflows.map((file) => file.id));
if (!defaultStagedFileTypes.includes(file.type)) {
staged.value[file.file] = true;
}
});
} }
} }
function getContext() {
if (route.fullPath.startsWith('/workflows')) {
return 'workflows';
} else if (
route.fullPath.startsWith('/credentials') ||
uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY].open
) {
return 'credentials';
} else if (route.fullPath.startsWith('/workflow/')) {
return 'workflow';
}
return '';
}
function getStagedFilesByContext(
filesByContext: SourceControlAggregatedFile[],
): Record<string, boolean> {
const stagedFiles = filesByContext.reduce(
(acc, file) => {
acc[file.file] = false;
return acc;
},
{} as Record<string, boolean>,
);
filesByContext.forEach((file) => {
if (defaultStagedFileTypes.includes(file.type)) {
stagedFiles[file.file] = true;
}
if (context.value === 'workflow') {
if (file.type === 'workflow' && file.id === workflowId.value) {
stagedFiles[file.file] = true;
}
}
});
return stagedFiles;
}
function setStagedStatus(file: SourceControlAggregatedFile, status: boolean) {
staged.value = {
...staged.value,
[file.file]: status,
};
}
function close() { function close() {
uiStore.closeModal(SOURCE_CONTROL_PUSH_MODAL_KEY); uiStore.closeModal(SOURCE_CONTROL_PUSH_MODAL_KEY);
} }
@ -209,7 +246,10 @@ async function onCommitKeyDownEnter() {
} }
async function commitAndPush() { async function commitAndPush() {
const fileNames = files.value.filter((file) => staged.value[file.file]); const files = changes.value.tags
.concat(changes.value.variables)
.concat(changes.value.credentials)
.concat(changes.value.workflows.filter((file) => selectedChanges.value.has(file.id)));
loadingService.startLoading(i18n.baseText('settings.sourceControl.loading.push')); loadingService.startLoading(i18n.baseText('settings.sourceControl.loading.push'));
close(); close();
@ -218,7 +258,7 @@ async function commitAndPush() {
await sourceControlStore.pushWorkfolder({ await sourceControlStore.pushWorkfolder({
force: true, force: true,
commitMessage: commitMessage.value, commitMessage: commitMessage.value,
fileNames, fileNames: files,
}); });
toast.showToast({ toast.showToast({
@ -233,159 +273,203 @@ async function commitAndPush() {
} }
} }
function getStatusText(file: SourceControlAggregatedFile): string { const getStatusText = (status: SourceControlledFileStatus) =>
if (file.status === 'deleted') { i18n.baseText(`settings.sourceControl.status.${status}` as BaseTextKey);
return i18n.baseText('settings.sourceControl.status.deleted'); const getStatusTheme = (status: SourceControlledFileStatus) => {
} const statusToBadgeThemeMap: Partial<
Record<SourceControlledFileStatus, 'success' | 'danger' | 'warning'>
if (file.status === 'created') { > = {
return i18n.baseText('settings.sourceControl.status.created'); [SOURCE_CONTROL_FILE_STATUS.CREATED]: 'success',
} [SOURCE_CONTROL_FILE_STATUS.DELETED]: 'danger',
[SOURCE_CONTROL_FILE_STATUS.MODIFIED]: 'warning',
if (file.status === 'modified') { } as const;
return i18n.baseText('settings.sourceControl.status.modified'); return statusToBadgeThemeMap[status];
} };
return i18n.baseText('settings.sourceControl.status.renamed');
}
</script> </script>
<template> <template>
<Modal <Modal
width="812px" width="812px"
:title="i18n.baseText('settings.sourceControl.modals.push.title')"
:event-bus="data.eventBus" :event-bus="data.eventBus"
:name="SOURCE_CONTROL_PUSH_MODAL_KEY" :name="SOURCE_CONTROL_PUSH_MODAL_KEY"
max-height="80%" max-height="80%"
:custom-class="$style.sourceControlPush"
> >
<template #content> <template #header>
<div :class="$style.container"> <N8nHeading tag="h1" size="xlarge">
<div v-if="files.length > 0"> {{ i18n.baseText('settings.sourceControl.modals.push.title') }}
<div v-if="workflowFiles.length > 0"> </N8nHeading>
<n8n-text tag="div" class="mb-l"> <div class="mb-l mt-l">
{{ i18n.baseText('settings.sourceControl.modals.push.description') }} <N8nText tag="div">
<n8n-link :to="i18n.baseText('settings.sourceControl.docs.using.pushPull.url')"> {{ i18n.baseText('settings.sourceControl.modals.push.description') }}
{{ i18n.baseText('settings.sourceControl.modals.push.description.learnMore') }} <N8nLink :to="i18n.baseText('settings.sourceControl.docs.using.pushPull.url')">
</n8n-link> {{ i18n.baseText('settings.sourceControl.modals.push.description.learnMore') }}
</n8n-text> </N8nLink>
</N8nText>
<n8n-checkbox <N8nNotice v-if="!changes.workflows.length" class="mt-xs">
:class="$style.selectAll" <i18n-t keypath="settings.sourceControl.modals.push.noWorkflowChanges">
:indeterminate="selectAllIndeterminate" <template #link>
:model-value="selectAll" <N8nLink size="small" :to="i18n.baseText('settings.sourceControl.docs.using.url')">
data-test-id="source-control-push-modal-toggle-all" {{ i18n.baseText('settings.sourceControl.modals.push.noWorkflowChanges.moreInfo') }}
@update:model-value="onToggleSelectAll" </N8nLink>
> </template>
<n8n-text bold tag="strong"> </i18n-t>
{{ i18n.baseText('settings.sourceControl.modals.push.workflowsToCommit') }} </N8nNotice>
</n8n-text>
<n8n-text v-show="workflowFiles.length > 0" tag="strong">
({{ stagedWorkflowFiles.length }}/{{ workflowFiles.length }})
</n8n-text>
</n8n-checkbox>
<n8n-checkbox
v-for="file in sortedFiles"
:key="file.file"
:class="[
'scopedListItem',
$style.listItem,
{ [$style.hiddenListItem]: defaultStagedFileTypes.includes(file.type) },
]"
data-test-id="source-control-push-modal-file-checkbox"
:model-value="staged[file.file]"
@update:model-value="setStagedStatus(file, !staged[file.file])"
>
<span>
<n8n-text v-if="file.status === 'deleted'" color="text-light">
<span v-if="file.type === 'workflow'"> Deleted Workflow: </span>
<span v-if="file.type === 'credential'"> Deleted Credential: </span>
<strong>{{ file.name || file.id }}</strong>
</n8n-text>
<n8n-text v-else bold> {{ file.name }} </n8n-text>
<n8n-text
v-if="file.updatedAt"
tag="p"
class="mt-0"
color="text-light"
size="small"
>
{{ renderUpdatedAt(file) }}
</n8n-text>
</span>
<span>
<n8n-badge v-if="workflowId === file.id && file.type === 'workflow'" class="mr-2xs">
Current workflow
</n8n-badge>
<n8n-badge :theme="statusToBadgeThemeMap[file.status] || 'default'">
{{ getStatusText(file) }}
</n8n-badge>
</span>
</n8n-checkbox>
</div>
<n8n-notice v-else class="mt-0">
<i18n-t keypath="settings.sourceControl.modals.push.noWorkflowChanges">
<template #link>
<n8n-link size="small" :to="i18n.baseText('settings.sourceControl.docs.using.url')">
{{
i18n.baseText('settings.sourceControl.modals.push.noWorkflowChanges.moreInfo')
}}
</n8n-link>
</template>
</i18n-t>
</n8n-notice>
<n8n-text bold tag="p" class="mt-l mb-2xs">
{{ i18n.baseText('settings.sourceControl.modals.push.commitMessage') }}
</n8n-text>
<n8n-input
v-model="commitMessage"
type="text"
:placeholder="
i18n.baseText('settings.sourceControl.modals.push.commitMessage.placeholder')
"
@keydown.enter="onCommitKeyDownEnter"
/>
</div>
<div v-else-if="!loading">
<n8n-notice class="mt-0 mb-0">
{{ i18n.baseText('settings.sourceControl.modals.push.everythingIsUpToDate') }}
</n8n-notice>
</div>
</div> </div>
<div :class="[$style.filers]">
<N8nCheckbox
:class="$style.selectAll"
:indeterminate="selectAllIndeterminate"
:model-value="selectAll"
data-test-id="source-control-push-modal-toggle-all"
@update:model-value="onToggleSelectAll"
>
<N8nText bold tag="strong">
{{ i18n.baseText('settings.sourceControl.modals.push.workflowsToCommit') }}
</N8nText>
<N8nText tag="strong">
({{ selectedChanges.size }}/{{ sortedWorkflows.length }})
</N8nText>
</N8nCheckbox>
<N8nPopover trigger="click" width="304" style="align-self: normal">
<template #reference>
<N8nButton
icon="filter"
type="tertiary"
style="height: 100%"
:active="Boolean(filterCount)"
data-test-id="source-control-filter-dropdown"
>
<N8nBadge v-show="filterCount" theme="primary" class="mr-4xs">
{{ filterCount }}
</N8nBadge>
{{ i18n.baseText('forms.resourceFiltersDropdown.filters') }}
</N8nButton>
</template>
<N8nInputLabel
:label="i18n.baseText('workflows.filters.status')"
:bold="false"
size="small"
color="text-base"
class="mb-3xs"
/>
<N8nSelect v-model="filters.status" data-test-id="source-control-status-filter" clearable>
<N8nOption
v-for="option in statusFilterOptions"
:key="option.label"
data-test-id="source-control-status-filter-option"
v-bind="option"
>
</N8nOption>
</N8nSelect>
</N8nPopover>
<N8nInput
v-model="search"
data-test-id="source-control-push-search"
:placeholder="i18n.baseText('workflows.search.placeholder')"
clearable
>
<template #prefix>
<N8nIcon icon="search" />
</template>
</N8nInput>
</div>
</template>
<template #content>
<RecycleScroller
:class="[$style.scroller]"
:items="sortedWorkflows"
:item-size="69"
key-field="id"
>
<template #default="{ item: file }">
<N8nCheckbox
:class="['scopedListItem', $style.listItem]"
data-test-id="source-control-push-modal-file-checkbox"
:model-value="selectedChanges.has(file.id)"
@update:model-value="toggleSelected(file.id)"
>
<span>
<N8nText v-if="file.status === SOURCE_CONTROL_FILE_STATUS.DELETED" color="text-light">
<span v-if="file.type === SOURCE_CONTROL_FILE_TYPE.WORKFLOW">
Deleted Workflow:
</span>
<span v-if="file.type === SOURCE_CONTROL_FILE_TYPE.CREDENTIAL">
Deleted Credential:
</span>
<strong>{{ file.name || file.id }}</strong>
</N8nText>
<N8nText v-else bold> {{ file.name }} </N8nText>
<N8nText v-if="file.updatedAt" tag="p" class="mt-0" color="text-light" size="small">
{{ renderUpdatedAt(file) }}
</N8nText>
</span>
<span :class="[$style.badges]">
<N8nBadge
v-if="changes.currentWorkflow && file.id === changes.currentWorkflow.id"
class="mr-2xs"
>
Current workflow
</N8nBadge>
<N8nBadge :theme="getStatusTheme(file.status)">
{{ getStatusText(file.status) }}
</N8nBadge>
</span>
</N8nCheckbox>
</template>
</RecycleScroller>
</template> </template>
<template #footer> <template #footer>
<N8nText bold tag="p" class="mb-2xs">
{{ i18n.baseText('settings.sourceControl.modals.push.commitMessage') }}
</N8nText>
<N8nInput
v-model="commitMessage"
data-test-id="source-control-push-modal-commit"
:placeholder="i18n.baseText('settings.sourceControl.modals.push.commitMessage.placeholder')"
@keydown.enter="onCommitKeyDownEnter"
/>
<div :class="$style.footer"> <div :class="$style.footer">
<n8n-button type="tertiary" class="mr-2xs" @click="close"> <N8nButton type="tertiary" class="mr-2xs" @click="close">
{{ i18n.baseText('settings.sourceControl.modals.push.buttons.cancel') }} {{ i18n.baseText('settings.sourceControl.modals.push.buttons.cancel') }}
</n8n-button> </N8nButton>
<n8n-button type="primary" :disabled="isSubmitDisabled" @click="commitAndPush"> <N8nButton
data-test-id="source-control-push-modal-submit"
type="primary"
:disabled="isSubmitDisabled"
@click="commitAndPush"
>
{{ i18n.baseText('settings.sourceControl.modals.push.buttons.save') }} {{ i18n.baseText('settings.sourceControl.modals.push.buttons.save') }}
</n8n-button> </N8nButton>
</div> </div>
</template> </template>
</Modal> </Modal>
</template> </template>
<style module lang="scss"> <style module lang="scss">
.container > * { .filers {
overflow-wrap: break-word; display: flex;
align-items: center;
gap: 8px;
} }
.actionButtons { .selectAll {
display: flex; flex-shrink: 0;
justify-content: flex-end; margin-bottom: 0;
align-items: center; }
.scroller {
height: 380px;
max-height: 100%;
} }
.listItem { .listItem {
display: flex;
width: 100%;
align-items: center; align-items: center;
margin: var(--spacing-2xs) 0 var(--spacing-2xs);
padding: var(--spacing-xs); padding: var(--spacing-xs);
cursor: pointer;
transition: border 0.3s ease; transition: border 0.3s ease;
border-radius: var(--border-radius-large); border-radius: var(--border-radius-large);
border: var(--border-base); border: var(--border-base);
@ -394,37 +478,32 @@ function getStatusText(file: SourceControlAggregatedFile): string {
border-color: var(--color-foreground-dark); border-color: var(--color-foreground-dark);
} }
&:first-child { :global(.el-checkbox__label) {
margin-top: 0; display: flex;
width: 100%;
justify-content: space-between;
align-items: center;
} }
&:last-child { :global(.el-checkbox__inner) {
margin-bottom: 0; transition: none;
}
&.hiddenListItem {
display: none !important;
} }
} }
.selectAll { .badges {
float: left; display: flex;
clear: both;
margin: 0 0 var(--spacing-2xs);
} }
.footer { .footer {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-end; justify-content: flex-end;
margin-top: 20px;
} }
</style>
<style scoped lang="scss"> .sourceControlPush {
.scopedListItem :deep(.el-checkbox__label) { :global(.el-dialog__header) {
display: flex; padding-bottom: var(--spacing-xs);
width: 100%; }
justify-content: space-between;
align-items: center;
} }
</style> </style>

View file

@ -20,7 +20,8 @@ import { useWorkflowResourceLocatorModes } from './useWorkflowResourceLocatorMod
import { useWorkflowResourcesLocator } from './useWorkflowResourcesLocator'; import { useWorkflowResourcesLocator } from './useWorkflowResourcesLocator';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { NEW_SAMPLE_WORKFLOW_CREATED_CHANNEL, SAMPLE_SUBWORKFLOW_WORKFLOW_ID } from '@/constants'; import { NEW_SAMPLE_WORKFLOW_CREATED_CHANNEL } from '@/constants';
import { SAMPLE_SUBWORKFLOW_WORKFLOW } from '@/constants.workflows';
interface Props { interface Props {
modelValue: INodeParameterResourceLocator; modelValue: INodeParameterResourceLocator;
@ -231,7 +232,7 @@ const onAddResourceClicked = () => {
}; };
window.open( window.open(
`/workflows/onboarding/${SAMPLE_SUBWORKFLOW_WORKFLOW_ID}?${urlSearchParams.toString()}`, `/workflows/onboarding/${SAMPLE_SUBWORKFLOW_WORKFLOW.meta.templateId}?${urlSearchParams.toString()}`,
'_blank', '_blank',
); );
}; };

View file

@ -2,6 +2,7 @@
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { computed, useSlots } from 'vue'; import { computed, useSlots } from 'vue';
import type { BannerName } from 'n8n-workflow'; import type { BannerName } from 'n8n-workflow';
import { useI18n } from '@/composables/useI18n';
interface Props { interface Props {
name: BannerName; name: BannerName;
@ -10,6 +11,8 @@ interface Props {
dismissible?: boolean; dismissible?: boolean;
} }
const i18n = useI18n();
const uiStore = useUIStore(); const uiStore = useUIStore();
const slots = useSlots(); const slots = useSlots();
@ -51,7 +54,7 @@ async function onCloseClick() {
v-if="dismissible" v-if="dismissible"
size="small" size="small"
icon="times" icon="times"
title="Dismiss" :title="i18n.baseText('generic.dismiss')"
class="clickable" class="clickable"
:data-test-id="`banner-${props.name}-close`" :data-test-id="`banner-${props.name}-close`"
@click="onCloseClick" @click="onCloseClick"

View file

@ -200,13 +200,18 @@ describe('Canvas', () => {
describe('background', () => { describe('background', () => {
it('should render default background', () => { it('should render default background', () => {
const { container } = renderComponent(); const { container } = renderComponent();
expect(container.querySelector('#pattern-canvas')).toBeInTheDocument(); const patternCanvas = container.querySelector('#pattern-canvas');
expect(patternCanvas).toBeInTheDocument();
expect(patternCanvas?.innerHTML).toContain('<circle');
expect(patternCanvas?.innerHTML).not.toContain('<path');
}); });
it('should render striped background', () => { it('should render striped background', () => {
const { container } = renderComponent({ props: { readOnly: true } }); const { container } = renderComponent({ props: { readOnly: true } });
expect(container.querySelector('#pattern-canvas')).not.toBeInTheDocument(); const patternCanvas = container.querySelector('#pattern-canvas');
expect(container.querySelector('#diagonalHatch')).toBeInTheDocument(); expect(patternCanvas).toBeInTheDocument();
expect(patternCanvas?.innerHTML).toContain('<path');
expect(patternCanvas?.innerHTML).not.toContain('<circle');
}); });
}); });
}); });

View file

@ -690,8 +690,13 @@ provide(CanvasKey, {
<CanvasArrowHeadMarker :id="arrowHeadMarkerId" /> <CanvasArrowHeadMarker :id="arrowHeadMarkerId" />
<Background data-test-id="canvas-background" pattern-color="#aaa" :gap="GRID_SIZE"> <Background data-test-id="canvas-background" pattern-color="#aaa" :gap="GRID_SIZE">
<template v-if="readOnly" #pattern-container> <template v-if="readOnly" #pattern-container="patternProps">
<CanvasBackgroundStripedPattern :x="viewport.x" :y="viewport.y" :zoom="viewport.zoom" /> <CanvasBackgroundStripedPattern
:id="patternProps.id"
:x="viewport.x"
:y="viewport.y"
:zoom="viewport.zoom"
/>
</template> </template>
</Background> </Background>

View file

@ -5,6 +5,7 @@
*/ */
import { computed } from 'vue'; import { computed } from 'vue';
const props = defineProps<{ const props = defineProps<{
id: string;
x: number; x: number;
y: number; y: number;
zoom: number; zoom: number;
@ -16,7 +17,7 @@ const patternOffset = computed(() => scaledGap.value / 2);
<template> <template>
<pattern <pattern
id="diagonalHatch" :id="id"
patternUnits="userSpaceOnUse" patternUnits="userSpaceOnUse"
:x="x % scaledGap" :x="x % scaledGap"
:y="y % scaledGap" :y="y % scaledGap"
@ -26,7 +27,6 @@ const patternOffset = computed(() => scaledGap.value / 2);
> >
<path :d="`M0 ${scaledGap / 2} H${scaledGap}`" :stroke-width="scaledGap / 2" /> <path :d="`M0 ${scaledGap / 2} H${scaledGap}`" :stroke-width="scaledGap / 2" />
</pattern> </pattern>
<rect x="0" y="0" width="100%" height="100%" fill="url(#diagonalHatch)" />
</template> </template>
<style scoped> <style scoped>

View file

@ -46,13 +46,12 @@ describe('ConcurrentExecutionsHeader', () => {
}, },
); );
it('should show tooltip on hover and call "goToUpgrade" on click', async () => { it('should show tooltip on hover with Upgrade link and emit "goToUpgrade" on click when on cloud', async () => {
const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null); const { container, getByText, getByRole, queryByRole, emitted } = renderComponent({
const { container, getByText, getByRole, queryByRole } = renderComponent({
props: { props: {
runningExecutionsCount: 2, runningExecutionsCount: 2,
concurrencyCap: 5, concurrencyCap: 5,
isCloudDeployment: true,
}, },
}); });
@ -68,6 +67,25 @@ describe('ConcurrentExecutionsHeader', () => {
await userEvent.click(getByText('Upgrade now')); await userEvent.click(getByText('Upgrade now'));
expect(windowOpenSpy).toHaveBeenCalled(); expect(emitted().goToUpgrade).toHaveLength(1);
});
it('should show tooltip on hover with Viev docs link when self-hosted', async () => {
const { container, getByText, getByRole, queryByRole } = renderComponent({
props: {
runningExecutionsCount: 2,
concurrencyCap: 5,
},
});
const tooltipTrigger = container.querySelector('svg') as SVGSVGElement;
expect(tooltipTrigger).toBeVisible();
expect(queryByRole('tooltip')).not.toBeInTheDocument();
await userEvent.hover(tooltipTrigger);
expect(getByRole('tooltip')).toBeVisible();
expect(getByText('View docs')).toBeVisible();
}); });
}); });

View file

@ -1,15 +1,18 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, defineProps } from 'vue'; import { computed } from 'vue';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
const props = defineProps<{ const props = defineProps<{
runningExecutionsCount: number; runningExecutionsCount: number;
concurrencyCap: number; concurrencyCap: number;
isCloudDeployment?: boolean;
}>();
const emit = defineEmits<{
goToUpgrade: [];
}>(); }>();
const i18n = useI18n(); const i18n = useI18n();
const pageRedirectionHelper = usePageRedirectionHelper();
const tooltipText = computed(() => const tooltipText = computed(() =>
i18n.baseText('executionsList.activeExecutions.tooltip', { i18n.baseText('executionsList.activeExecutions.tooltip', {
@ -31,10 +34,6 @@ const headerText = computed(() => {
}, },
}); });
}); });
const goToUpgrade = () => {
void pageRedirectionHelper.goToUpgrade('concurrency', 'upgrade-concurrency');
};
</script> </script>
<template> <template>
@ -43,9 +42,22 @@ const goToUpgrade = () => {
<template #content> <template #content>
<div :class="$style.tooltip"> <div :class="$style.tooltip">
{{ tooltipText }} {{ tooltipText }}
<n8n-link bold size="small" :class="$style.upgrade" @click="goToUpgrade"> <N8nLink
v-if="props.isCloudDeployment"
bold
size="small"
:class="$style.link"
@click="emit('goToUpgrade')"
>
{{ i18n.baseText('generic.upgradeNow') }} {{ i18n.baseText('generic.upgradeNow') }}
</n8n-link> </N8nLink>
<N8nLink
v-else
:class="$style.link"
:href="i18n.baseText('executions.concurrency.docsLink')"
target="_blank"
>{{ i18n.baseText('generic.viewDocs') }}</N8nLink
>
</div> </div>
</template> </template>
<font-awesome-icon icon="info-circle" class="mr-2xs" /> <font-awesome-icon icon="info-circle" class="mr-2xs" />
@ -54,12 +66,12 @@ const goToUpgrade = () => {
</div> </div>
</template> </template>
<style module scoped> <style lang="scss" module>
.tooltip { .tooltip {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.upgrade { .link {
margin-top: var(--spacing-xs); margin-top: var(--spacing-xs);
} }
</style> </style>

View file

@ -16,6 +16,7 @@ import { getResourcePermissions } from '@/permissions';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import ProjectHeader from '@/components/Projects/ProjectHeader.vue'; import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
import ConcurrentExecutionsHeader from '@/components/executions/ConcurrentExecutionsHeader.vue'; import ConcurrentExecutionsHeader from '@/components/executions/ConcurrentExecutionsHeader.vue';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@ -40,6 +41,7 @@ const telemetry = useTelemetry();
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const executionsStore = useExecutionsStore(); const executionsStore = useExecutionsStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const pageRedirectionHelper = usePageRedirectionHelper();
const isMounted = ref(false); const isMounted = ref(false);
const allVisibleSelected = ref(false); const allVisibleSelected = ref(false);
@ -317,6 +319,10 @@ async function onAutoRefreshToggle(value: boolean) {
executionsStore.stopAutoRefreshInterval(); executionsStore.stopAutoRefreshInterval();
} }
} }
const goToUpgrade = () => {
void pageRedirectionHelper.goToUpgrade('concurrency', 'upgrade-concurrency');
};
</script> </script>
<template> <template>
@ -330,6 +336,8 @@ async function onAutoRefreshToggle(value: boolean) {
class="mr-xl" class="mr-xl"
:running-executions-count="runningExecutionsCount" :running-executions-count="runningExecutionsCount"
:concurrency-cap="settingsStore.concurrency" :concurrency-cap="settingsStore.concurrency"
:is-cloud-deployment="settingsStore.isCloudDeployment"
@go-to-upgrade="goToUpgrade"
/> />
<N8nLoading v-if="!isMounted" :class="$style.filterLoader" variant="custom" /> <N8nLoading v-if="!isMounted" :class="$style.filterLoader" variant="custom" />
<ElCheckbox <ElCheckbox
@ -400,12 +408,14 @@ async function onAutoRefreshToggle(value: boolean) {
:workflow-permissions="getExecutionWorkflowPermissions(execution)" :workflow-permissions="getExecutionWorkflowPermissions(execution)"
:selected="selectedItems[execution.id] || allExistingSelected" :selected="selectedItems[execution.id] || allExistingSelected"
:concurrency-cap="settingsStore.concurrency" :concurrency-cap="settingsStore.concurrency"
:is-cloud-deployment="settingsStore.isCloudDeployment"
data-test-id="global-execution-list-item" data-test-id="global-execution-list-item"
@stop="stopExecution" @stop="stopExecution"
@delete="deleteExecution" @delete="deleteExecution"
@select="toggleSelectExecution" @select="toggleSelectExecution"
@retry-saved="retrySavedExecution" @retry-saved="retrySavedExecution"
@retry-original="retryOriginalExecution" @retry-original="retryOriginalExecution"
@go-to-upgrade="goToUpgrade"
/> />
</TransitionGroup> </TransitionGroup>
</table> </table>

View file

@ -1,6 +1,7 @@
import { describe, it, expect, vi } from 'vitest'; import { vi } from 'vitest';
import { fireEvent } from '@testing-library/vue'; import { fireEvent } from '@testing-library/vue';
import GlobalExecutionsListItem from './GlobalExecutionsListItem.vue'; import { WAIT_INDEFINITELY } from 'n8n-workflow';
import GlobalExecutionsListItem from '@/components/executions/global/GlobalExecutionsListItem.vue';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
@ -15,9 +16,20 @@ vi.mock('vue-router', async () => {
}; };
}); });
const globalExecutionsListItemQueuedTooltipRenderSpy = vi.fn();
const renderComponent = createComponentRenderer(GlobalExecutionsListItem, { const renderComponent = createComponentRenderer(GlobalExecutionsListItem, {
global: { global: {
stubs: ['font-awesome-icon', 'n8n-tooltip', 'n8n-button', 'i18n-t'], //stubs: ['font-awesome-icon', 'n8n-tooltip', 'n8n-button', 'i18n-t'],
stubs: {
'font-awesome-icon': true,
'n8n-tooltip': true,
'n8n-button': true,
'i18n-t': true,
GlobalExecutionsListItemQueuedTooltip: {
render: globalExecutionsListItemQueuedTooltipRenderSpy,
},
},
}, },
}); });
@ -98,6 +110,7 @@ describe('GlobalExecutionsListItem', () => {
await fireEvent.click(getByText('TestWorkflow')); await fireEvent.click(getByText('TestWorkflow'));
expect(window.open).toHaveBeenCalledWith('mockedRoute', '_blank'); expect(window.open).toHaveBeenCalledWith('mockedRoute', '_blank');
expect(globalExecutionsListItemQueuedTooltipRenderSpy).not.toHaveBeenCalled();
}); });
it('should show formatted start date', () => { it('should show formatted start date', () => {
@ -113,4 +126,50 @@ describe('GlobalExecutionsListItem', () => {
getByText(`1 Jan, 2022 at ${DateTime.fromJSDate(new Date(testDate)).toFormat('HH')}:00:00`), getByText(`1 Jan, 2022 at ${DateTime.fromJSDate(new Date(testDate)).toFormat('HH')}:00:00`),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it('should not render queued tooltip for a not indefinitely waiting execution', async () => {
renderComponent({
props: {
execution: {
status: 'waiting',
waitTill: new Date(Date.now() + 10000000).toISOString(),
id: 123,
workflowName: 'Test Workflow',
},
workflowPermissions: {},
concurrencyCap: 5,
},
});
expect(globalExecutionsListItemQueuedTooltipRenderSpy).not.toHaveBeenCalled();
});
it('should render queued tooltip for an indefinitely waiting execution', async () => {
renderComponent({
props: {
execution: {
status: 'waiting',
waitTill: WAIT_INDEFINITELY,
id: 123,
workflowName: 'Test Workflow',
},
workflowPermissions: {},
concurrencyCap: 5,
},
});
expect(globalExecutionsListItemQueuedTooltipRenderSpy).toHaveBeenCalled();
});
it('should render queued tooltip for a new execution', async () => {
renderComponent({
props: {
execution: { status: 'new', id: 123, workflowName: 'Test Workflow' },
workflowPermissions: {},
concurrencyCap: 5,
},
});
expect(globalExecutionsListItemQueuedTooltipRenderSpy).toHaveBeenCalled();
});
}); });

View file

@ -1,13 +1,14 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed, useCssModule } from 'vue'; import { ref, computed, useCssModule } from 'vue';
import type { ExecutionSummary } from 'n8n-workflow'; import type { ExecutionSummary } from 'n8n-workflow';
import { WAIT_INDEFINITELY } from 'n8n-workflow';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { WAIT_TIME_UNLIMITED } from '@/constants';
import { convertToDisplayDate } from '@/utils/formatters/dateFormatter'; import { convertToDisplayDate } from '@/utils/formatters/dateFormatter';
import { i18n as locale } from '@/plugins/i18n'; import { i18n as locale } from '@/plugins/i18n';
import ExecutionsTime from '@/components/executions/ExecutionsTime.vue'; import ExecutionsTime from '@/components/executions/ExecutionsTime.vue';
import { useExecutionHelpers } from '@/composables/useExecutionHelpers'; import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
import type { PermissionsRecord } from '@/permissions'; import type { PermissionsRecord } from '@/permissions';
import GlobalExecutionsListItemQueuedTooltip from '@/components/executions/global/GlobalExecutionsListItemQueuedTooltip.vue';
type Command = 'retrySaved' | 'retryOriginal' | 'delete'; type Command = 'retrySaved' | 'retryOriginal' | 'delete';
@ -17,6 +18,7 @@ const emit = defineEmits<{
retrySaved: [data: ExecutionSummary]; retrySaved: [data: ExecutionSummary];
retryOriginal: [data: ExecutionSummary]; retryOriginal: [data: ExecutionSummary];
delete: [data: ExecutionSummary]; delete: [data: ExecutionSummary];
goToUpgrade: [];
}>(); }>();
const props = withDefaults( const props = withDefaults(
@ -26,6 +28,7 @@ const props = withDefaults(
workflowName?: string; workflowName?: string;
workflowPermissions: PermissionsRecord['workflow']; workflowPermissions: PermissionsRecord['workflow'];
concurrencyCap: number; concurrencyCap: number;
isCloudDeployment?: boolean;
}>(), }>(),
{ {
selected: false, selected: false,
@ -52,7 +55,7 @@ const isWaitTillIndefinite = computed(() => {
return false; return false;
} }
return new Date(props.execution.waitTill).toISOString() === WAIT_TIME_UNLIMITED; return new Date(props.execution.waitTill).getTime() === WAIT_INDEFINITELY.getTime();
}); });
const isRetriable = computed(() => executionHelpers.isExecutionRetriable(props.execution)); const isRetriable = computed(() => executionHelpers.isExecutionRetriable(props.execution));
@ -84,19 +87,6 @@ const formattedStoppedAtDate = computed(() => {
: ''; : '';
}); });
const statusTooltipText = computed(() => {
if (isQueued.value) {
return i18n.baseText('executionsList.statusTooltipText.waitingForConcurrencyCapacity', {
interpolate: { concurrencyCap: props.concurrencyCap },
});
}
if (props.execution.status === 'waiting' && isWaitTillIndefinite.value) {
return i18n.baseText('executionsList.statusTooltipText.theWorkflowIsWaitingIndefinitely');
}
return '';
});
const statusText = computed(() => { const statusText = computed(() => {
switch (props.execution.status) { switch (props.execution.status) {
case 'waiting': case 'waiting':
@ -208,12 +198,15 @@ async function handleActionItemClick(commandData: Command) {
/> />
</template> </template>
</i18n-t> </i18n-t>
<N8nTooltip v-else placement="top"> <GlobalExecutionsListItemQueuedTooltip
<template #content> v-else
<span>{{ statusTooltipText }}</span> :status="props.execution.status"
</template> :concurrency-cap="props.concurrencyCap"
:is-cloud-deployment="props.isCloudDeployment"
@go-to-upgrade="emit('goToUpgrade')"
>
<span :class="$style.status">{{ statusText }}</span> <span :class="$style.status">{{ statusText }}</span>
</N8nTooltip> </GlobalExecutionsListItemQueuedTooltip>
</div> </div>
</td> </td>
<td> <td>

View file

@ -0,0 +1,76 @@
import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import GlobalExecutionsListItemQueuedTooltip from '@/components/executions/global/GlobalExecutionsListItemQueuedTooltip.vue';
const renderComponent = createComponentRenderer(GlobalExecutionsListItemQueuedTooltip);
describe('GlobalExecutionsListItemQueuedTooltip', () => {
it('should not throw error when rendered', async () => {
expect(() =>
renderComponent({
props: {
status: 'waiting',
concurrencyCap: 0,
},
slots: {
default: 'Waiting',
},
}),
).not.toThrow();
});
it('should show waiting indefinitely tooltip', async () => {
const { getByText } = renderComponent({
props: {
status: 'waiting',
concurrencyCap: 0,
},
slots: {
default: 'Waiting',
},
});
await userEvent.hover(getByText('Waiting'));
expect(getByText(/waiting indefinitely/)).toBeVisible();
});
it('should show queued tooltip for self-hosted', async () => {
const { getByText } = renderComponent({
props: {
status: 'new',
concurrencyCap: 0,
},
slots: {
default: 'Queued',
},
});
await userEvent.hover(getByText('Queued'));
expect(getByText(/instance is limited/)).toBeVisible();
expect(getByText('View docs')).toBeVisible();
});
it('should show queued tooltip for cloud', async () => {
const { getByText, emitted } = renderComponent({
props: {
status: 'new',
concurrencyCap: 0,
isCloudDeployment: true,
},
slots: {
default: 'Queued',
},
});
await userEvent.hover(getByText('Queued'));
expect(getByText(/plan is limited/)).toBeVisible();
expect(getByText('Upgrade now')).toBeVisible();
await userEvent.click(getByText('Upgrade now'));
expect(emitted().goToUpgrade).toHaveLength(1);
});
});

View file

@ -0,0 +1,67 @@
<script lang="ts" setup="">
import type { ExecutionStatus } from 'n8n-workflow';
import { useI18n } from '@/composables/useI18n';
const props = defineProps<{
status: ExecutionStatus;
concurrencyCap: number;
isCloudDeployment?: boolean;
}>();
const emit = defineEmits<{
goToUpgrade: [];
}>();
const i18n = useI18n();
</script>
<template>
<N8nTooltip placement="top">
<template #content>
<i18n-t
v-if="props.status === 'waiting'"
keypath="executionsList.statusTooltipText.theWorkflowIsWaitingIndefinitely"
/>
<i18n-t
v-if="props.status === 'new'"
keypath="executionsList.statusTooltipText.waitingForConcurrencyCapacity"
>
<template #instance>
<i18n-t
v-if="props.isCloudDeployment"
keypath="executionsList.statusTooltipText.waitingForConcurrencyCapacity.cloud"
>
<template #concurrencyCap>{{ props.concurrencyCap }}</template>
<template #link>
<N8nLink bold size="small" :class="$style.link" @click="emit('goToUpgrade')">
{{ i18n.baseText('generic.upgradeNow') }}
</N8nLink>
</template>
</i18n-t>
<i18n-t
v-else
keypath="executionsList.statusTooltipText.waitingForConcurrencyCapacity.self"
>
<template #concurrencyCap>{{ props.concurrencyCap }}</template>
<template #link>
<N8nLink
:class="$style.link"
:href="i18n.baseText('executions.concurrency.docsLink')"
target="_blank"
>{{ i18n.baseText('generic.viewDocs') }}</N8nLink
>
</template>
</i18n-t>
</template>
</i18n-t>
</template>
<slot />
</N8nTooltip>
</template>
<style lang="scss" module>
.link {
display: inline-block;
margin-top: var(--spacing-xs);
}
</style>

View file

@ -15,6 +15,7 @@ import { getResourcePermissions } from '@/permissions';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import ConcurrentExecutionsHeader from '@/components/executions/ConcurrentExecutionsHeader.vue'; import ConcurrentExecutionsHeader from '@/components/executions/ConcurrentExecutionsHeader.vue';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
type AutoScrollDeps = { activeExecutionSet: boolean; cardsMounted: boolean; scroll: boolean }; type AutoScrollDeps = { activeExecutionSet: boolean; cardsMounted: boolean; scroll: boolean };
@ -39,6 +40,7 @@ const i18n = useI18n();
const executionsStore = useExecutionsStore(); const executionsStore = useExecutionsStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const pageRedirectionHelper = usePageRedirectionHelper();
const mountedItems = ref<string[]>([]); const mountedItems = ref<string[]>([]);
const autoScrollDeps = ref<AutoScrollDeps>({ const autoScrollDeps = ref<AutoScrollDeps>({
@ -169,6 +171,10 @@ function scrollToActiveCard(): void {
} }
} }
} }
const goToUpgrade = () => {
void pageRedirectionHelper.goToUpgrade('concurrency', 'upgrade-concurrency');
};
</script> </script>
<template> <template>
@ -186,6 +192,8 @@ function scrollToActiveCard(): void {
v-if="settingsStore.isConcurrencyEnabled" v-if="settingsStore.isConcurrencyEnabled"
:running-executions-count="runningExecutionsCount" :running-executions-count="runningExecutionsCount"
:concurrency-cap="settingsStore.concurrency" :concurrency-cap="settingsStore.concurrency"
:is-cloud-deployment="settingsStore.isCloudDeployment"
@go-to-upgrade="goToUpgrade"
/> />
</div> </div>
<div :class="$style.controls"> <div :class="$style.controls">

View file

@ -37,15 +37,14 @@ import type {
ITaskData, ITaskData,
Workflow, Workflow,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { NodeConnectionType, NodeHelpers, SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
import type { INodeUi } from '@/Interface';
import { import {
CUSTOM_API_CALL_KEY, NodeConnectionType,
FORM_NODE_TYPE, NodeHelpers,
STICKY_NODE_TYPE, SEND_AND_WAIT_OPERATION,
WAIT_NODE_TYPE, WAIT_INDEFINITELY,
WAIT_TIME_UNLIMITED, } from 'n8n-workflow';
} from '@/constants'; import type { INodeUi } from '@/Interface';
import { CUSTOM_API_CALL_KEY, FORM_NODE_TYPE, STICKY_NODE_TYPE, WAIT_NODE_TYPE } from '@/constants';
import { sanitizeHtml } from '@/utils/htmlUtils'; import { sanitizeHtml } from '@/utils/htmlUtils';
import { MarkerType } from '@vue-flow/core'; import { MarkerType } from '@vue-flow/core';
import { useNodeHelpers } from './useNodeHelpers'; import { useNodeHelpers } from './useNodeHelpers';
@ -419,7 +418,7 @@ export function useCanvasMapping({
const waitDate = new Date(workflowExecution.waitTill); const waitDate = new Date(workflowExecution.waitTill);
if (waitDate.toISOString() === WAIT_TIME_UNLIMITED) { if (waitDate.getTime() === WAIT_INDEFINITELY.getTime()) {
acc[node.id] = i18n.baseText( acc[node.id] = i18n.baseText(
'node.theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall', 'node.theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall',
); );

View file

@ -96,12 +96,12 @@ export function useDebugInfo() {
}; };
const pruningInfo = () => { const pruningInfo = () => {
if (!settingsStore.pruning.isEnabled) return { enabled: false } as const; if (!settingsStore.pruning?.isEnabled) return { enabled: false } as const;
return { return {
enabled: true, enabled: true,
maxAge: `${settingsStore.pruning.maxAge} hours`, maxAge: `${settingsStore.pruning?.maxAge} hours`,
maxCount: `${settingsStore.pruning.maxCount} executions`, maxCount: `${settingsStore.pruning?.maxCount} executions`,
} as const; } as const;
}; };

View file

@ -7,6 +7,9 @@ import type router from 'vue-router';
import { flushPromises } from '@vue/test-utils'; import { flushPromises } from '@vue/test-utils';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper'; import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
import { useSettingsStore } from '@/stores/settings.store';
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import type { CloudPlanState } from '@/Interface';
import { VIEWS } from '@/constants'; import { VIEWS } from '@/constants';
import type { Project, ProjectListItem } from '@/types/projects.types'; import type { Project, ProjectListItem } from '@/types/projects.types';
@ -153,6 +156,7 @@ describe('useGlobalEntityCreation', () => {
describe('global', () => { describe('global', () => {
it('should show personal + all team projects', () => { it('should show personal + all team projects', () => {
const projectsStore = mockedStore(useProjectsStore); const projectsStore = mockedStore(useProjectsStore);
projectsStore.teamProjectsLimit = -1;
const personalProjectId = 'personal-project'; const personalProjectId = 'personal-project';
projectsStore.isTeamProjectFeatureEnabled = true; projectsStore.isTeamProjectFeatureEnabled = true;
@ -222,4 +226,25 @@ describe('useGlobalEntityCreation', () => {
expect(redirect.goToUpgrade).toHaveBeenCalled(); expect(redirect.goToUpgrade).toHaveBeenCalled();
}); });
}); });
it('should show plan and limit according to deployment type', () => {
const settingsStore = mockedStore(useSettingsStore);
const cloudPlanStore = mockedStore(useCloudPlanStore);
cloudPlanStore.currentPlanData = { displayName: 'Pro' } as CloudPlanState['data'];
const projectsStore = mockedStore(useProjectsStore);
projectsStore.isTeamProjectFeatureEnabled = true;
projectsStore.teamProjectsLimit = 10;
settingsStore.isCloudDeployment = true;
const { projectsLimitReachedMessage } = useGlobalEntityCreation(true);
expect(projectsLimitReachedMessage.value).toContain(
'You have reached the Pro plan limit of 10.',
);
settingsStore.isCloudDeployment = false;
expect(projectsLimitReachedMessage.value).toContain(
'Upgrade to unlock projects for more granular control over sharing, access and organisation of workflows',
);
});
}); });

View file

@ -5,6 +5,8 @@ import { useI18n } from '@/composables/useI18n';
import { sortByProperty } from '@/utils/sortUtils'; import { sortByProperty } from '@/utils/sortUtils';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useSourceControlStore } from '@/stores/sourceControl.store';
import { getResourcePermissions } from '@/permissions'; import { getResourcePermissions } from '@/permissions';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper'; import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
@ -28,6 +30,8 @@ export const useGlobalEntityCreation = (
) => { ) => {
const CREATE_PROJECT_ID = 'create-project'; const CREATE_PROJECT_ID = 'create-project';
const settingsStore = useSettingsStore();
const cloudPlanStore = useCloudPlanStore();
const projectsStore = useProjectsStore(); const projectsStore = useProjectsStore();
const sourceControlStore = useSourceControlStore(); const sourceControlStore = useSourceControlStore();
const router = useRouter(); const router = useRouter();
@ -172,6 +176,7 @@ export const useGlobalEntityCreation = (
{ {
id: CREATE_PROJECT_ID, id: CREATE_PROJECT_ID,
title: 'Project', title: 'Project',
disabled: !projectsStore.canCreateProjects,
}, },
]; ];
}); });
@ -204,5 +209,21 @@ export const useGlobalEntityCreation = (
void usePageRedirectionHelper().goToUpgrade('rbac', 'upgrade-rbac'); void usePageRedirectionHelper().goToUpgrade('rbac', 'upgrade-rbac');
}; };
return { menu, handleSelect }; const projectsLimitReachedMessage = computed(() => {
if (settingsStore.isCloudDeployment) {
return i18n.baseText('projects.create.limitReached', {
adjustToNumber: projectsStore.teamProjectsLimit,
interpolate: {
planName: cloudPlanStore.currentPlanData?.displayName ?? '',
limit: projectsStore.teamProjectsLimit,
},
});
}
return i18n.baseText('projects.create.limitReached.self');
});
const createProjectAppendSlotName = computed(() => `item.append.${CREATE_PROJECT_ID}`);
return { menu, handleSelect, createProjectAppendSlotName, projectsLimitReachedMessage };
}; };

View file

@ -36,6 +36,7 @@ import type { PushMessageQueueItem } from '@/types';
import { useAssistantStore } from '@/stores/assistant.store'; import { useAssistantStore } from '@/stores/assistant.store';
import NodeExecutionErrorMessage from '@/components/NodeExecutionErrorMessage.vue'; import NodeExecutionErrorMessage from '@/components/NodeExecutionErrorMessage.vue';
import type { IExecutionResponse } from '@/Interface'; import type { IExecutionResponse } from '@/Interface';
import { EASY_AI_WORKFLOW_JSON } from '@/constants.workflows';
export function usePushConnection({ router }: { router: ReturnType<typeof useRouter> }) { export function usePushConnection({ router }: { router: ReturnType<typeof useRouter> }) {
const workflowHelpers = useWorkflowHelpers({ router }); const workflowHelpers = useWorkflowHelpers({ router });
@ -199,6 +200,23 @@ export function usePushConnection({ router }: { router: ReturnType<typeof useRou
return false; return false;
} }
if (receivedData.type === 'executionFinished') {
const workflow = workflowsStore.getWorkflowById(receivedData.data.workflowId);
if (workflow?.meta?.templateId) {
const isEasyAIWorkflow =
workflow.meta.templateId === EASY_AI_WORKFLOW_JSON.meta.templateId;
if (isEasyAIWorkflow) {
telemetry.track(
'User executed test AI workflow',
{
status: receivedData.data.status,
},
{ withPostHog: true },
);
}
}
}
const { executionId } = receivedData.data; const { executionId } = receivedData.data;
const { activeExecutionId } = workflowsStore; const { activeExecutionId } = workflowsStore;
if (executionId !== activeExecutionId) { if (executionId !== activeExecutionId) {

View file

@ -1181,6 +1181,14 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
tagsStore.upsertTags(tags); tagsStore.upsertTags(tags);
} }
/**
* Check if workflow contains any node from specified package
* by performing a quick check based on the node type name.
*/
const containsNodeFromPackage = (workflow: IWorkflowDb, packageName: string) => {
return workflow.nodes.some((node) => node.type.startsWith(packageName));
};
return { return {
setDocumentTitle, setDocumentTitle,
resolveParameter, resolveParameter,
@ -1207,5 +1215,6 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
promptSaveUnsavedWorkflowChanges, promptSaveUnsavedWorkflowChanges,
initState, initState,
getNodeParametersWithResolvedExpressions, getNodeParametersWithResolvedExpressions,
containsNodeFromPackage,
}; };
} }

Some files were not shown because too many files have changed in this diff Show more