From af5b64a61d5dc132a73e974818e8b5f6780f8762 Mon Sep 17 00:00:00 2001 From: Alex Grozav Date: Tue, 21 Jan 2025 12:01:45 +0200 Subject: [PATCH 01/28] fix: Prepare expression editor modal e2e tests for new canvas (no-changelog) (#12743) --- cypress/e2e/9-expression-editor-modal.cy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cypress/e2e/9-expression-editor-modal.cy.ts b/cypress/e2e/9-expression-editor-modal.cy.ts index 840e3d4fc6..42095b06fe 100644 --- a/cypress/e2e/9-expression-editor-modal.cy.ts +++ b/cypress/e2e/9-expression-editor-modal.cy.ts @@ -105,7 +105,7 @@ describe('Expression editor modal', () => { // Run workflow cy.get('body').type('{esc}'); ndv.actions.close(); - WorkflowPage.actions.executeNode('No Operation'); + WorkflowPage.actions.executeNode('No Operation, do nothing', { anchor: 'topLeft' }); WorkflowPage.actions.openNode('Hacker News'); WorkflowPage.actions.openExpressionEditorModal(); From 36bc164da486f2e2d05091b457b8eea6521ca22e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Tue, 21 Jan 2025 11:49:43 +0100 Subject: [PATCH 02/28] fix(core): AugmentObject should handle the constructor property correctly (#12744) --- packages/workflow/src/AugmentObject.ts | 3 +++ packages/workflow/test/AugmentObject.test.ts | 28 ++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/packages/workflow/src/AugmentObject.ts b/packages/workflow/src/AugmentObject.ts index 24d6ca0a16..bb060a167d 100644 --- a/packages/workflow/src/AugmentObject.ts +++ b/packages/workflow/src/AugmentObject.ts @@ -36,6 +36,7 @@ export function augmentArray(data: T[]): T[] { return Reflect.deleteProperty(getData(), key); }, get(target, key: string, receiver): unknown { + if (key === 'constructor') return Array; const value = Reflect.get(newData ?? target, key, receiver) as unknown; const newValue = augment(value); if (newValue !== value) { @@ -83,6 +84,8 @@ export function augmentObject(data: T): T { const proxy = new Proxy(data, { get(target, key: string, receiver): unknown { + if (key === 'constructor') return Object; + if (deletedProperties.has(key)) { return undefined; } diff --git a/packages/workflow/test/AugmentObject.test.ts b/packages/workflow/test/AugmentObject.test.ts index 1bbe64561f..831f08e020 100644 --- a/packages/workflow/test/AugmentObject.test.ts +++ b/packages/workflow/test/AugmentObject.test.ts @@ -10,6 +10,8 @@ describe('AugmentObject', () => { const augmentedObject = augmentArray(originalObject); + expect(augmentedObject.constructor.name).toEqual('Array'); + expect(augmentedObject.push(5)).toEqual(6); expect(augmentedObject).toEqual([1, 2, 3, 4, null, 5]); expect(originalObject).toEqual(copyOriginal); @@ -207,6 +209,8 @@ describe('AugmentObject', () => { const augmentedObject = augmentObject(originalObject); + expect(augmentedObject.constructor.name).toEqual('Object'); + augmentedObject[1] = 911; expect(originalObject[1]).toEqual(11); expect(augmentedObject[1]).toEqual(911); @@ -589,5 +593,29 @@ describe('AugmentObject', () => { delete augmentedObject.toString; expect(augmentedObject.toString).toBeUndefined(); }); + + test('should handle constructor property correctly', () => { + const originalObject: any = { + a: { + b: { + c: { + d: '4', + }, + }, + }, + }; + const augmentedObject = augmentObject(originalObject); + + expect(augmentedObject.constructor.name).toEqual('Object'); + expect(augmentedObject.a.constructor.name).toEqual('Object'); + expect(augmentedObject.a.b.constructor.name).toEqual('Object'); + expect(augmentedObject.a.b.c.constructor.name).toEqual('Object'); + + augmentedObject.constructor = {}; + expect(augmentedObject.constructor.name).toEqual('Object'); + + delete augmentedObject.constructor; + expect(augmentedObject.constructor.name).toEqual('Object'); + }); }); }); From d1b6692736182fa2eab768ba3ad0adb8504ebbbd Mon Sep 17 00:00:00 2001 From: oleg Date: Tue, 21 Jan 2025 12:01:30 +0100 Subject: [PATCH 03/28] fix(OpenAI Chat Model Node): Restore default model value (#12745) --- .../nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts index be057f9a5a..99d7345939 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts @@ -147,7 +147,7 @@ export class LmChatOpenAi implements INodeType { displayName: 'Model', name: 'model', type: 'resourceLocator', - default: { mode: 'list', value: '' }, + default: { mode: 'list', value: 'gpt-4o-mini' }, required: true, modes: [ { @@ -164,7 +164,7 @@ export class LmChatOpenAi implements INodeType { displayName: 'ID', name: 'id', type: 'string', - placeholder: '2302163813', + placeholder: 'gpt-4o-mini', }, ], description: 'The model. Choose from the list, or specify an ID.', From d410b8f5a7e99658e1e8dcb2e02901bd01ce9c59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 21 Jan 2025 12:40:38 +0100 Subject: [PATCH 04/28] fix(core): Sync `hookFunctionsSave` and `hookFunctionsSaveWorker` (#12740) --- .../execution-lifecycle-hooks.test.ts | 82 +++++++++++++++++++ .../src/workflow-execute-additional-data.ts | 31 +++++-- 2 files changed, 104 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/__tests__/execution-lifecycle-hooks.test.ts b/packages/cli/src/__tests__/execution-lifecycle-hooks.test.ts index 8138c10f07..227f89dd40 100644 --- a/packages/cli/src/__tests__/execution-lifecycle-hooks.test.ts +++ b/packages/cli/src/__tests__/execution-lifecycle-hooks.test.ts @@ -26,6 +26,7 @@ import { mockInstance } from '@test/mocking'; import { getWorkflowHooksMain, + getWorkflowHooksWorkerExecuter, getWorkflowHooksWorkerMain, } from '../workflow-execute-additional-data'; @@ -532,4 +533,85 @@ describe('Execution Lifecycle Hooks', () => { }); }); }); + + describe('getWorkflowHooksWorkerExecuter', () => { + let hooks: WorkflowHooks; + + beforeEach(() => { + hooks = getWorkflowHooksWorkerExecuter(executionMode, executionId, workflowData, { + pushRef, + retryOf, + }); + }); + + describe('saving static data', () => { + it('should skip saving static data for manual executions', async () => { + hooks.mode = 'manual'; + + await hooks.executeHookFunctions('workflowExecuteAfter', [successfulRun, staticData]); + + expect(workflowStaticDataService.saveStaticDataById).not.toHaveBeenCalled(); + }); + + it('should save static data for prod executions', async () => { + hooks.mode = 'trigger'; + + await hooks.executeHookFunctions('workflowExecuteAfter', [successfulRun, staticData]); + + expect(workflowStaticDataService.saveStaticDataById).toHaveBeenCalledWith( + workflowId, + staticData, + ); + }); + + it('should handle static data saving errors', async () => { + hooks.mode = 'trigger'; + const error = new Error('Static data save failed'); + workflowStaticDataService.saveStaticDataById.mockRejectedValueOnce(error); + + await hooks.executeHookFunctions('workflowExecuteAfter', [successfulRun, staticData]); + + expect(errorReporter.error).toHaveBeenCalledWith(error); + }); + }); + + describe('error workflow', () => { + it('should not execute error workflow for manual executions', async () => { + hooks.mode = 'manual'; + + await hooks.executeHookFunctions('workflowExecuteAfter', [failedRun, {}]); + + expect(workflowExecutionService.executeErrorWorkflow).not.toHaveBeenCalled(); + }); + + it('should execute error workflow for failed non-manual executions', async () => { + hooks.mode = 'trigger'; + const errorWorkflow = 'error-workflow-id'; + workflowData.settings = { errorWorkflow }; + const project = mock(); + ownershipService.getWorkflowProjectCached.calledWith(workflowId).mockResolvedValue(project); + + await hooks.executeHookFunctions('workflowExecuteAfter', [failedRun, {}]); + + expect(workflowExecutionService.executeErrorWorkflow).toHaveBeenCalledWith( + errorWorkflow, + { + workflow: { + id: workflowId, + name: workflowData.name, + }, + execution: { + id: executionId, + error: expressionError, + mode: 'trigger', + retryOf, + lastNodeExecuted: undefined, + url: `http://localhost:5678/workflow/${workflowId}/executions/${executionId}`, + }, + }, + project, + ); + }); + }); + }); }); diff --git a/packages/cli/src/workflow-execute-additional-data.ts b/packages/cli/src/workflow-execute-additional-data.ts index ac1199ba3c..7d69084357 100644 --- a/packages/cli/src/workflow-execute-additional-data.ts +++ b/packages/cli/src/workflow-execute-additional-data.ts @@ -576,8 +576,11 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { executionId: this.executionId, workflowId: this.workflowData.id, }); + + const isManualMode = this.mode === 'manual'; + try { - if (isWorkflowIdValid(this.workflowData.id) && newStaticData) { + if (!isManualMode && isWorkflowIdValid(this.workflowData.id) && newStaticData) { // Workflow is saved so update in database try { await Container.get(WorkflowStaticDataService).saveStaticDataById( @@ -596,7 +599,11 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { const workflowStatusFinal = determineFinalExecutionStatus(fullRunData); fullRunData.status = workflowStatusFinal; - if (workflowStatusFinal !== 'success' && workflowStatusFinal !== 'waiting') { + if ( + !isManualMode && + workflowStatusFinal !== 'success' && + workflowStatusFinal !== 'waiting' + ) { executeErrorWorkflow( this.workflowData, fullRunData, @@ -615,19 +622,25 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { retryOf: this.retryOf, }); + // When going into the waiting state, store the pushRef in the execution-data + if (fullRunData.waitTill && isManualMode) { + fullExecutionData.data.pushRef = this.pushRef; + } + await updateExistingExecution({ executionId: this.executionId, workflowId: this.workflowData.id, executionData: fullExecutionData, }); } catch (error) { - executeErrorWorkflow( - this.workflowData, - fullRunData, - this.mode, - this.executionId, - this.retryOf, - ); + if (!isManualMode) + executeErrorWorkflow( + this.workflowData, + fullRunData, + this.mode, + this.executionId, + this.retryOf, + ); } finally { workflowStatisticsService.emit('workflowExecutionCompleted', { workflowData: this.workflowData, From 353df7941117e20547cd4f3fc514979a54619720 Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Tue, 21 Jan 2025 14:01:05 +0200 Subject: [PATCH 05/28] fix(Jira Software Node): Get custom fields(RLC) in update operation for server deployment type (#12719) --- packages/nodes-base/nodes/Jira/Jira.node.ts | 24 ++++ .../nodes/Jira/test/node.methods.test.ts | 126 ++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 packages/nodes-base/nodes/Jira/test/node.methods.test.ts diff --git a/packages/nodes-base/nodes/Jira/Jira.node.ts b/packages/nodes-base/nodes/Jira/Jira.node.ts index 26770e2eae..294c5d85ad 100644 --- a/packages/nodes-base/nodes/Jira/Jira.node.ts +++ b/packages/nodes-base/nodes/Jira/Jira.node.ts @@ -276,8 +276,12 @@ export class Jira implements INodeType { async getCustomFields(this: ILoadOptionsFunctions): Promise { const returnData: INodeListSearchItems[] = []; const operation = this.getCurrentNodeParameter('operation') as string; + const jiraVersion = this.getNodeParameter('jiraVersion', 0) as string; + let projectId: string; let issueTypeId: string; + let issueId: string = ''; // /editmeta endpoint requires issueId + if (operation === 'create') { projectId = this.getCurrentNodeParameter('project', { extractValue: true }) as string; issueTypeId = this.getCurrentNodeParameter('issueType', { extractValue: true }) as string; @@ -292,6 +296,26 @@ export class Jira implements INodeType { ); projectId = res.fields.project.id; issueTypeId = res.fields.issuetype.id; + issueId = res.id; + } + + if (jiraVersion === 'server' && operation === 'update' && issueId) { + // https://developer.atlassian.com/server/jira/platform/jira-rest-api-example-edit-issues-6291632/?utm_source=chatgpt.com + const { fields } = await jiraSoftwareCloudApiRequest.call( + this, + `/api/2/issue/${issueId}/editmeta`, + 'GET', + ); + + for (const field of Object.keys(fields || {})) { + if (field.startsWith('customfield_')) { + returnData.push({ + name: fields[field].name, + value: field, + }); + } + } + return { results: returnData }; } const res = await jiraSoftwareCloudApiRequest.call( diff --git a/packages/nodes-base/nodes/Jira/test/node.methods.test.ts b/packages/nodes-base/nodes/Jira/test/node.methods.test.ts new file mode 100644 index 0000000000..973ec9556d --- /dev/null +++ b/packages/nodes-base/nodes/Jira/test/node.methods.test.ts @@ -0,0 +1,126 @@ +import type { MockProxy } from 'jest-mock-extended'; +import { mock } from 'jest-mock-extended'; +import type { IHttpRequestMethods, ILoadOptionsFunctions } from 'n8n-workflow'; + +import { Jira } from '../Jira.node'; + +const ISSUE_KEY = 'KEY-1'; + +jest.mock('../GenericFunctions', () => { + const originalModule = jest.requireActual('../GenericFunctions'); + return { + ...originalModule, + jiraSoftwareCloudApiRequest: jest.fn(async function ( + endpoint: string, + method: IHttpRequestMethods, + ) { + if (method === 'GET' && endpoint === `/api/2/issue/${ISSUE_KEY}`) { + return { + id: 10000, + fields: { + project: { + id: 10001, + }, + issuetype: { + id: 10002, + }, + }, + }; + } else if (method === 'GET' && endpoint === '/api/2/issue/10000/editmeta') { + return { + fields: { + customfield_123: { + name: 'Field 123', + }, + customfield_456: { + name: 'Field 456', + }, + }, + }; + } else if ( + method === 'GET' && + endpoint === + '/api/2/issue/createmeta?projectIds=10001&issueTypeIds=10002&expand=projects.issuetypes.fields' + ) { + return { + projects: [ + { + id: 10001, + issuetypes: [ + { + id: 10002, + fields: { + customfield_abc: { + name: 'Field ABC', + schema: { customId: 'customfield_abc' }, + fieldId: 'customfield_abc', + }, + customfield_def: { + name: 'Field DEF', + schema: { customId: 'customfield_def' }, + fieldId: 'customfield_def', + }, + }, + }, + ], + }, + ], + }; + } + }), + }; +}); + +describe('Jira Node, methods', () => { + let jira: Jira; + let loadOptionsFunctions: MockProxy; + + beforeEach(() => { + jira = new Jira(); + loadOptionsFunctions = mock(); + }); + + describe('listSearch.getCustomFields', () => { + it('should call correct endpoint and return custom fields for server version', async () => { + loadOptionsFunctions.getCurrentNodeParameter.mockReturnValueOnce('update'); + loadOptionsFunctions.getNodeParameter.mockReturnValue('server'); + loadOptionsFunctions.getCurrentNodeParameter.mockReturnValueOnce(ISSUE_KEY); + + const { results } = await jira.methods.listSearch.getCustomFields.call( + loadOptionsFunctions as ILoadOptionsFunctions, + ); + + expect(results).toEqual([ + { + name: 'Field 123', + value: 'customfield_123', + }, + { + name: 'Field 456', + value: 'customfield_456', + }, + ]); + }); + + it('should call correct endpoint and return custom fields for cloud version', async () => { + loadOptionsFunctions.getCurrentNodeParameter.mockReturnValueOnce('update'); + loadOptionsFunctions.getNodeParameter.mockReturnValue('cloud'); + loadOptionsFunctions.getCurrentNodeParameter.mockReturnValueOnce(ISSUE_KEY); + + const { results } = await jira.methods.listSearch.getCustomFields.call( + loadOptionsFunctions as ILoadOptionsFunctions, + ); + + expect(results).toEqual([ + { + name: 'Field ABC', + value: 'customfield_abc', + }, + { + name: 'Field DEF', + value: 'customfield_def', + }, + ]); + }); + }); +}); From 9e2a01aeaf36766a1cf7a1d9a4d6e02f45739bd3 Mon Sep 17 00:00:00 2001 From: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Date: Tue, 21 Jan 2025 14:31:06 +0200 Subject: [PATCH 06/28] feat(core): Enable task runner by default (#12726) --- packages/@n8n/config/src/configs/runners.config.ts | 3 +-- packages/@n8n/config/test/config.test.ts | 2 +- packages/cli/src/task-runners/task-runner-process.ts | 1 + 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/@n8n/config/src/configs/runners.config.ts b/packages/@n8n/config/src/configs/runners.config.ts index 02ebdf5df9..2fa8cd6bc1 100644 --- a/packages/@n8n/config/src/configs/runners.config.ts +++ b/packages/@n8n/config/src/configs/runners.config.ts @@ -10,9 +10,8 @@ export type TaskRunnerMode = 'internal' | 'external'; @Config export class TaskRunnersConfig { @Env('N8N_RUNNERS_ENABLED') - enabled: boolean = false; + enabled: boolean = true; - // Defaults to true for now @Env('N8N_RUNNERS_MODE') mode: TaskRunnerMode = 'internal'; diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index d9499d7849..7c891f73d5 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -221,7 +221,7 @@ describe('GlobalConfig', () => { }, }, taskRunners: { - enabled: false, + enabled: true, mode: 'internal', path: '/runners', authToken: '', diff --git a/packages/cli/src/task-runners/task-runner-process.ts b/packages/cli/src/task-runners/task-runner-process.ts index a657a35e5b..c2e769e6ec 100644 --- a/packages/cli/src/task-runners/task-runner-process.ts +++ b/packages/cli/src/task-runners/task-runner-process.ts @@ -54,6 +54,7 @@ export class TaskRunnerProcess extends TypedEmitter { private readonly passthroughEnvVars = [ 'PATH', + 'HOME', // So home directory can be resolved correctly 'GENERIC_TIMEZONE', 'NODE_FUNCTION_ALLOW_BUILTIN', 'NODE_FUNCTION_ALLOW_EXTERNAL', From 56c93caae026738c1c0bebb4187b238e34a330f6 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Tue, 21 Jan 2025 13:32:48 +0100 Subject: [PATCH 07/28] fix(editor): Fix JsonEditor with expressions (#12739) --- .../src/components/JsonEditor.test.ts | 25 ++++++++++++++----- .../src/components/JsonEditor/JsonEditor.vue | 3 --- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/packages/editor-ui/src/components/JsonEditor.test.ts b/packages/editor-ui/src/components/JsonEditor.test.ts index 74f5e17be7..1743a556fc 100644 --- a/packages/editor-ui/src/components/JsonEditor.test.ts +++ b/packages/editor-ui/src/components/JsonEditor.test.ts @@ -1,6 +1,8 @@ import { createTestingPinia } from '@pinia/testing'; import JsonEditor from '@/components/JsonEditor/JsonEditor.vue'; import { renderComponent } from '@/__tests__/render'; +import { waitFor } from '@testing-library/vue'; +import { userEvent } from '@testing-library/user-event'; describe('JsonEditor', () => { const renderEditor = (jsonString: string) => @@ -13,18 +15,29 @@ describe('JsonEditor', () => { it('renders simple json', async () => { const modelValue = '{ "testing": [true, 5] }'; - const result = renderEditor(modelValue); - expect(result.container.querySelector('.cm-content')?.textContent).toEqual(modelValue); + const { getByRole } = renderEditor(modelValue); + expect(getByRole('textbox').textContent).toEqual(modelValue); }); it('renders multiline json', async () => { const modelValue = '{\n\t"testing": [true, 5]\n}'; - const result = renderEditor(modelValue); - const gutter = result.container.querySelector('.cm-gutters'); + const { getByRole, container } = renderEditor(modelValue); + const gutter = container.querySelector('.cm-gutters'); expect(gutter?.querySelectorAll('.cm-lineNumbers .cm-gutterElement').length).toEqual(4); - const content = result.container.querySelector('.cm-content'); - const lines = [...content!.querySelectorAll('.cm-line').values()].map((l) => l.textContent); + const content = getByRole('textbox'); + const lines = [...content.querySelectorAll('.cm-line').values()].map((l) => l.textContent); expect(lines).toEqual(['{', '\t"testing": [true, 5]', '}']); }); + + it('emits update:model-value events', async () => { + const modelValue = '{ "test": 1 }'; + + const { emitted, getByRole } = renderEditor(modelValue); + + const textbox = await waitFor(() => getByRole('textbox')); + await userEvent.type(textbox, 'test'); + + await waitFor(() => expect(emitted('update:modelValue')).toContainEqual(['test{ "test": 1 }'])); + }); }); diff --git a/packages/editor-ui/src/components/JsonEditor/JsonEditor.vue b/packages/editor-ui/src/components/JsonEditor/JsonEditor.vue index 4b2c92d87a..6f5073bfcf 100644 --- a/packages/editor-ui/src/components/JsonEditor/JsonEditor.vue +++ b/packages/editor-ui/src/components/JsonEditor/JsonEditor.vue @@ -36,7 +36,6 @@ const emit = defineEmits<{ const jsonEditorRef = ref(); const editor = ref(null); const editorState = ref(null); -const isDirty = ref(false); const extensions = computed(() => { const extensionsToApply: Extension[] = [ @@ -66,7 +65,6 @@ const extensions = computed(() => { bracketMatching(), mappingDropCursor(), EditorView.updateListener.of((viewUpdate: ViewUpdate) => { - isDirty.value = true; if (!viewUpdate.docChanged || !editor.value) return; emit('update:modelValue', editor.value?.state.doc.toString()); }), @@ -81,7 +79,6 @@ onMounted(() => { onBeforeUnmount(() => { if (!editor.value) return; - if (isDirty.value) emit('update:modelValue', editor.value.state.doc.toString()); editor.value.destroy(); }); From ee08e9e1feca63cff5382752eb25febd73a3409c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Tue, 21 Jan 2025 14:47:02 +0100 Subject: [PATCH 08/28] refactor(core): Extract hooks out of workflow-execute-additional-data (no-changelog) (#12749) --- packages/cli/src/active-workflow-manager.ts | 3 +- .../events/relays/telemetry.event-relay.ts | 2 +- .../execution-lifecycle-hooks.test.ts | 2 +- .../__tests__/restore-binary-data-id.test.ts | 2 +- .../__tests__/save-execution-progress.test.ts | 5 +- .../__tests__/to-save-settings.test.ts | 3 +- .../execute-error-workflow.ts | 130 +++ .../execution-lifecycle-hooks.ts | 628 ++++++++++++++ .../restore-binary-data-id.ts | 0 .../save-execution-progress.ts | 3 +- .../__tests__/shared-hook-functions.test.ts | 0 .../shared/shared-hook-functions.ts | 0 .../to-save-settings.ts | 0 .../executions/execution-recovery.service.ts | 2 +- packages/cli/src/scaling/job-processor.ts | 3 +- .../__tests__/object-to-error.test.ts | 2 +- packages/cli/src/utils/object-to-error.ts | 53 ++ .../src/workflow-execute-additional-data.ts | 810 +----------------- packages/cli/src/workflow-runner.ts | 31 +- 19 files changed, 856 insertions(+), 823 deletions(-) rename packages/cli/src/{ => execution-lifecycle}/__tests__/execution-lifecycle-hooks.test.ts (99%) rename packages/cli/src/{execution-lifecycle-hooks => execution-lifecycle}/__tests__/restore-binary-data-id.test.ts (98%) rename packages/cli/src/{execution-lifecycle-hooks => execution-lifecycle}/__tests__/save-execution-progress.test.ts (94%) rename packages/cli/src/{execution-lifecycle-hooks => execution-lifecycle}/__tests__/to-save-settings.test.ts (98%) create mode 100644 packages/cli/src/execution-lifecycle/execute-error-workflow.ts create mode 100644 packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts rename packages/cli/src/{execution-lifecycle-hooks => execution-lifecycle}/restore-binary-data-id.ts (100%) rename packages/cli/src/{execution-lifecycle-hooks => execution-lifecycle}/save-execution-progress.ts (97%) rename packages/cli/src/{execution-lifecycle-hooks => execution-lifecycle}/shared/__tests__/shared-hook-functions.test.ts (100%) rename packages/cli/src/{execution-lifecycle-hooks => execution-lifecycle}/shared/shared-hook-functions.ts (100%) rename packages/cli/src/{execution-lifecycle-hooks => execution-lifecycle}/to-save-settings.ts (100%) rename packages/cli/src/{ => utils}/__tests__/object-to-error.test.ts (94%) create mode 100644 packages/cli/src/utils/object-to-error.ts diff --git a/packages/cli/src/active-workflow-manager.ts b/packages/cli/src/active-workflow-manager.ts index a002bc4054..403e60f51d 100644 --- a/packages/cli/src/active-workflow-manager.ts +++ b/packages/cli/src/active-workflow-manager.ts @@ -40,6 +40,7 @@ import { import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { OnShutdown } from '@/decorators/on-shutdown'; +import { executeErrorWorkflow } from '@/execution-lifecycle/execute-error-workflow'; import { ExecutionService } from '@/executions/execution.service'; import { ExternalHooks } from '@/external-hooks'; import type { IWorkflowDb } from '@/interfaces'; @@ -400,7 +401,7 @@ export class ActiveWorkflowManager { status: 'running', }; - WorkflowExecuteAdditionalData.executeErrorWorkflow(workflowData, fullRunData, mode); + executeErrorWorkflow(workflowData, fullRunData, mode); } /** diff --git a/packages/cli/src/events/relays/telemetry.event-relay.ts b/packages/cli/src/events/relays/telemetry.event-relay.ts index 67fbacb107..8d0f050dee 100644 --- a/packages/cli/src/events/relays/telemetry.event-relay.ts +++ b/packages/cli/src/events/relays/telemetry.event-relay.ts @@ -15,7 +15,7 @@ import { SharedWorkflowRepository } from '@/databases/repositories/shared-workfl import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { EventService } from '@/events/event.service'; import type { RelayEventMap } from '@/events/maps/relay.event-map'; -import { determineFinalExecutionStatus } from '@/execution-lifecycle-hooks/shared/shared-hook-functions'; +import { determineFinalExecutionStatus } from '@/execution-lifecycle/shared/shared-hook-functions'; import type { IExecutionTrackProperties } from '@/interfaces'; import { License } from '@/license'; import { NodeTypes } from '@/node-types'; diff --git a/packages/cli/src/__tests__/execution-lifecycle-hooks.test.ts b/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts similarity index 99% rename from packages/cli/src/__tests__/execution-lifecycle-hooks.test.ts rename to packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts index 227f89dd40..5ea8e411ad 100644 --- a/packages/cli/src/__tests__/execution-lifecycle-hooks.test.ts +++ b/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts @@ -28,7 +28,7 @@ import { getWorkflowHooksMain, getWorkflowHooksWorkerExecuter, getWorkflowHooksWorkerMain, -} from '../workflow-execute-additional-data'; +} from '../execution-lifecycle-hooks'; describe('Execution Lifecycle Hooks', () => { mockInstance(Logger); diff --git a/packages/cli/src/execution-lifecycle-hooks/__tests__/restore-binary-data-id.test.ts b/packages/cli/src/execution-lifecycle/__tests__/restore-binary-data-id.test.ts similarity index 98% rename from packages/cli/src/execution-lifecycle-hooks/__tests__/restore-binary-data-id.test.ts rename to packages/cli/src/execution-lifecycle/__tests__/restore-binary-data-id.test.ts index f4f7a463bc..76ac0d4e21 100644 --- a/packages/cli/src/execution-lifecycle-hooks/__tests__/restore-binary-data-id.test.ts +++ b/packages/cli/src/execution-lifecycle/__tests__/restore-binary-data-id.test.ts @@ -2,7 +2,7 @@ import { BinaryDataService } from 'n8n-core'; import type { IRun } from 'n8n-workflow'; import config from '@/config'; -import { restoreBinaryDataId } from '@/execution-lifecycle-hooks/restore-binary-data-id'; +import { restoreBinaryDataId } from '@/execution-lifecycle/restore-binary-data-id'; import { mockInstance } from '@test/mocking'; function toIRun(item?: object) { diff --git a/packages/cli/src/execution-lifecycle-hooks/__tests__/save-execution-progress.test.ts b/packages/cli/src/execution-lifecycle/__tests__/save-execution-progress.test.ts similarity index 94% rename from packages/cli/src/execution-lifecycle-hooks/__tests__/save-execution-progress.test.ts rename to packages/cli/src/execution-lifecycle/__tests__/save-execution-progress.test.ts index ac52cf3920..863006d9e7 100644 --- a/packages/cli/src/execution-lifecycle-hooks/__tests__/save-execution-progress.test.ts +++ b/packages/cli/src/execution-lifecycle/__tests__/save-execution-progress.test.ts @@ -3,11 +3,12 @@ import { Logger } from 'n8n-core'; import type { IRunExecutionData, ITaskData, IWorkflowBase } from 'n8n-workflow'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; -import { saveExecutionProgress } from '@/execution-lifecycle-hooks/save-execution-progress'; -import * as fnModule from '@/execution-lifecycle-hooks/to-save-settings'; import type { IExecutionResponse } from '@/interfaces'; import { mockInstance } from '@test/mocking'; +import { saveExecutionProgress } from '../save-execution-progress'; +import * as fnModule from '../to-save-settings'; + mockInstance(Logger); const errorReporter = mockInstance(ErrorReporter); const executionRepository = mockInstance(ExecutionRepository); diff --git a/packages/cli/src/execution-lifecycle-hooks/__tests__/to-save-settings.test.ts b/packages/cli/src/execution-lifecycle/__tests__/to-save-settings.test.ts similarity index 98% rename from packages/cli/src/execution-lifecycle-hooks/__tests__/to-save-settings.test.ts rename to packages/cli/src/execution-lifecycle/__tests__/to-save-settings.test.ts index f12c209827..142b3c34ce 100644 --- a/packages/cli/src/execution-lifecycle-hooks/__tests__/to-save-settings.test.ts +++ b/packages/cli/src/execution-lifecycle/__tests__/to-save-settings.test.ts @@ -1,5 +1,6 @@ import config from '@/config'; -import { toSaveSettings } from '@/execution-lifecycle-hooks/to-save-settings'; + +import { toSaveSettings } from '../to-save-settings'; afterEach(() => { config.load(config.default); diff --git a/packages/cli/src/execution-lifecycle/execute-error-workflow.ts b/packages/cli/src/execution-lifecycle/execute-error-workflow.ts new file mode 100644 index 0000000000..fefce8a97b --- /dev/null +++ b/packages/cli/src/execution-lifecycle/execute-error-workflow.ts @@ -0,0 +1,130 @@ +import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; +import { ErrorReporter, Logger } from 'n8n-core'; +import type { IRun, IWorkflowBase, WorkflowExecuteMode } from 'n8n-workflow'; + +import type { IWorkflowErrorData } from '@/interfaces'; +import { OwnershipService } from '@/services/ownership.service'; +import { UrlService } from '@/services/url.service'; +import { WorkflowExecutionService } from '@/workflows/workflow-execution.service'; + +/** + * Checks if there was an error and if errorWorkflow or a trigger is defined. If so it collects + * all the data and executes it + * + * @param {IWorkflowBase} workflowData The workflow which got executed + * @param {IRun} fullRunData The run which produced the error + * @param {WorkflowExecuteMode} mode The mode in which the workflow got started in + * @param {string} [executionId] The id the execution got saved as + */ +export function executeErrorWorkflow( + workflowData: IWorkflowBase, + fullRunData: IRun, + mode: WorkflowExecuteMode, + executionId?: string, + retryOf?: string, +): void { + const logger = Container.get(Logger); + + // Check if there was an error and if so if an errorWorkflow or a trigger is set + let pastExecutionUrl: string | undefined; + if (executionId !== undefined) { + pastExecutionUrl = `${Container.get(UrlService).getWebhookBaseUrl()}workflow/${ + workflowData.id + }/executions/${executionId}`; + } + + if (fullRunData.data.resultData.error !== undefined) { + let workflowErrorData: IWorkflowErrorData; + const workflowId = workflowData.id; + + if (executionId) { + // The error did happen in an execution + workflowErrorData = { + execution: { + id: executionId, + url: pastExecutionUrl, + error: fullRunData.data.resultData.error, + lastNodeExecuted: fullRunData.data.resultData.lastNodeExecuted!, + mode, + retryOf, + }, + workflow: { + id: workflowId, + name: workflowData.name, + }, + }; + } else { + // The error did happen in a trigger + workflowErrorData = { + trigger: { + error: fullRunData.data.resultData.error, + mode, + }, + workflow: { + id: workflowId, + name: workflowData.name, + }, + }; + } + + const { errorTriggerType } = Container.get(GlobalConfig).nodes; + // Run the error workflow + // To avoid an infinite loop do not run the error workflow again if the error-workflow itself failed and it is its own error-workflow. + const { errorWorkflow } = workflowData.settings ?? {}; + if (errorWorkflow && !(mode === 'error' && workflowId && errorWorkflow === workflowId)) { + logger.debug('Start external error workflow', { + executionId, + errorWorkflowId: errorWorkflow, + workflowId, + }); + // If a specific error workflow is set run only that one + + // First, do permission checks. + if (!workflowId) { + // Manual executions do not trigger error workflows + // So this if should never happen. It was added to + // make sure there are no possible security gaps + return; + } + + Container.get(OwnershipService) + .getWorkflowProjectCached(workflowId) + .then((project) => { + void Container.get(WorkflowExecutionService).executeErrorWorkflow( + errorWorkflow, + workflowErrorData, + project, + ); + }) + .catch((error: Error) => { + Container.get(ErrorReporter).error(error); + logger.error( + `Could not execute ErrorWorkflow for execution ID ${executionId} because of error querying the workflow owner`, + { + executionId, + errorWorkflowId: errorWorkflow, + workflowId, + error, + workflowErrorData, + }, + ); + }); + } else if ( + mode !== 'error' && + workflowId !== undefined && + workflowData.nodes.some((node) => node.type === errorTriggerType) + ) { + logger.debug('Start internal error workflow', { executionId, workflowId }); + void Container.get(OwnershipService) + .getWorkflowProjectCached(workflowId) + .then((project) => { + void Container.get(WorkflowExecutionService).executeErrorWorkflow( + workflowId, + workflowErrorData, + project, + ); + }); + } + } +} diff --git a/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts b/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts new file mode 100644 index 0000000000..1296f53958 --- /dev/null +++ b/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts @@ -0,0 +1,628 @@ +import { Container } from '@n8n/di'; +import { stringify } from 'flatted'; +import { ErrorReporter, Logger, InstanceSettings } from 'n8n-core'; +import { WorkflowHooks } from 'n8n-workflow'; +import type { + IDataObject, + INode, + IRun, + IRunExecutionData, + ITaskData, + IWorkflowBase, + IWorkflowExecuteHooks, + IWorkflowHooksOptionalParameters, + WorkflowExecuteMode, + IWorkflowExecutionDataProcess, + Workflow, +} from 'n8n-workflow'; + +import { ExecutionRepository } from '@/databases/repositories/execution.repository'; +import { EventService } from '@/events/event.service'; +import { ExternalHooks } from '@/external-hooks'; +import { Push } from '@/push'; +import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; +import { isWorkflowIdValid } from '@/utils'; +import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service'; + +import { executeErrorWorkflow } from './execute-error-workflow'; +import { restoreBinaryDataId } from './restore-binary-data-id'; +import { saveExecutionProgress } from './save-execution-progress'; +import { + determineFinalExecutionStatus, + prepareExecutionDataForDbUpdate, + updateExistingExecution, +} from './shared/shared-hook-functions'; +import { toSaveSettings } from './to-save-settings'; + +/** + * Returns hook functions to push data to Editor-UI + */ +function hookFunctionsPush(): IWorkflowExecuteHooks { + const logger = Container.get(Logger); + const pushInstance = Container.get(Push); + return { + nodeExecuteBefore: [ + async function (this: WorkflowHooks, nodeName: string): Promise { + const { pushRef, executionId } = this; + // Push data to session which started workflow before each + // node which starts rendering + if (pushRef === undefined) { + return; + } + + logger.debug(`Executing hook on node "${nodeName}" (hookFunctionsPush)`, { + executionId, + pushRef, + workflowId: this.workflowData.id, + }); + + pushInstance.send({ type: 'nodeExecuteBefore', data: { executionId, nodeName } }, pushRef); + }, + ], + nodeExecuteAfter: [ + async function (this: WorkflowHooks, nodeName: string, data: ITaskData): Promise { + const { pushRef, executionId } = this; + // Push data to session which started workflow after each rendered node + if (pushRef === undefined) { + return; + } + + logger.debug(`Executing hook on node "${nodeName}" (hookFunctionsPush)`, { + executionId, + pushRef, + workflowId: this.workflowData.id, + }); + + pushInstance.send( + { type: 'nodeExecuteAfter', data: { executionId, nodeName, data } }, + pushRef, + ); + }, + ], + workflowExecuteBefore: [ + async function (this: WorkflowHooks, _workflow, data): Promise { + const { pushRef, executionId } = this; + const { id: workflowId, name: workflowName } = this.workflowData; + logger.debug('Executing hook (hookFunctionsPush)', { + executionId, + pushRef, + workflowId, + }); + // Push data to session which started the workflow + if (pushRef === undefined) { + return; + } + pushInstance.send( + { + type: 'executionStarted', + data: { + executionId, + mode: this.mode, + startedAt: new Date(), + retryOf: this.retryOf, + workflowId, + workflowName, + flattedRunData: data?.resultData.runData + ? stringify(data.resultData.runData) + : stringify({}), + }, + }, + pushRef, + ); + }, + ], + workflowExecuteAfter: [ + async function (this: WorkflowHooks, fullRunData: IRun): Promise { + const { pushRef, executionId } = this; + if (pushRef === undefined) return; + + const { id: workflowId } = this.workflowData; + logger.debug('Executing hook (hookFunctionsPush)', { + executionId, + pushRef, + workflowId, + }); + + const { status } = fullRunData; + if (status === 'waiting') { + pushInstance.send({ type: 'executionWaiting', data: { executionId } }, pushRef); + } else { + const rawData = stringify(fullRunData.data); + pushInstance.send( + { type: 'executionFinished', data: { executionId, workflowId, status, rawData } }, + pushRef, + ); + } + }, + ], + }; +} + +function hookFunctionsPreExecute(): IWorkflowExecuteHooks { + const externalHooks = Container.get(ExternalHooks); + return { + workflowExecuteBefore: [ + async function (this: WorkflowHooks, workflow: Workflow): Promise { + await externalHooks.run('workflow.preExecute', [workflow, this.mode]); + }, + ], + nodeExecuteAfter: [ + async function ( + this: WorkflowHooks, + nodeName: string, + data: ITaskData, + executionData: IRunExecutionData, + ): Promise { + await saveExecutionProgress( + this.workflowData, + this.executionId, + nodeName, + data, + executionData, + this.pushRef, + ); + }, + ], + }; +} + +/** + * Returns hook functions to save workflow execution and call error workflow + */ +function hookFunctionsSave(): IWorkflowExecuteHooks { + const logger = Container.get(Logger); + const workflowStatisticsService = Container.get(WorkflowStatisticsService); + const eventService = Container.get(EventService); + return { + nodeExecuteBefore: [ + async function (this: WorkflowHooks, nodeName: string): Promise { + const { executionId, workflowData: workflow } = this; + + eventService.emit('node-pre-execute', { executionId, workflow, nodeName }); + }, + ], + nodeExecuteAfter: [ + async function (this: WorkflowHooks, nodeName: string): Promise { + const { executionId, workflowData: workflow } = this; + + eventService.emit('node-post-execute', { executionId, workflow, nodeName }); + }, + ], + workflowExecuteBefore: [], + workflowExecuteAfter: [ + async function ( + this: WorkflowHooks, + fullRunData: IRun, + newStaticData: IDataObject, + ): Promise { + logger.debug('Executing hook (hookFunctionsSave)', { + executionId: this.executionId, + workflowId: this.workflowData.id, + }); + + await restoreBinaryDataId(fullRunData, this.executionId, this.mode); + + const isManualMode = this.mode === 'manual'; + + try { + if (!isManualMode && isWorkflowIdValid(this.workflowData.id) && newStaticData) { + // Workflow is saved so update in database + try { + await Container.get(WorkflowStaticDataService).saveStaticDataById( + this.workflowData.id, + newStaticData, + ); + } catch (e) { + Container.get(ErrorReporter).error(e); + logger.error( + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + `There was a problem saving the workflow with id "${this.workflowData.id}" to save changed staticData: "${e.message}" (hookFunctionsSave)`, + { executionId: this.executionId, workflowId: this.workflowData.id }, + ); + } + } + + const executionStatus = determineFinalExecutionStatus(fullRunData); + fullRunData.status = executionStatus; + + const saveSettings = toSaveSettings(this.workflowData.settings); + + if (isManualMode && !saveSettings.manual && !fullRunData.waitTill) { + /** + * When manual executions are not being saved, we only soft-delete + * the execution so that the user can access its binary data + * while building their workflow. + * + * The manual execution and its binary data will be hard-deleted + * on the next pruning cycle after the grace period set by + * `EXECUTIONS_DATA_HARD_DELETE_BUFFER`. + */ + await Container.get(ExecutionRepository).softDelete(this.executionId); + + return; + } + + const shouldNotSave = + (executionStatus === 'success' && !saveSettings.success) || + (executionStatus !== 'success' && !saveSettings.error); + + if (shouldNotSave && !fullRunData.waitTill && !isManualMode) { + executeErrorWorkflow( + this.workflowData, + fullRunData, + this.mode, + this.executionId, + this.retryOf, + ); + + await Container.get(ExecutionRepository).hardDelete({ + workflowId: this.workflowData.id, + executionId: this.executionId, + }); + + return; + } + + // Although it is treated as IWorkflowBase here, it's being instantiated elsewhere with properties that may be sensitive + // As a result, we should create an IWorkflowBase object with only the data we want to save in it. + const fullExecutionData = prepareExecutionDataForDbUpdate({ + runData: fullRunData, + workflowData: this.workflowData, + workflowStatusFinal: executionStatus, + retryOf: this.retryOf, + }); + + // When going into the waiting state, store the pushRef in the execution-data + if (fullRunData.waitTill && isManualMode) { + fullExecutionData.data.pushRef = this.pushRef; + } + + await updateExistingExecution({ + executionId: this.executionId, + workflowId: this.workflowData.id, + executionData: fullExecutionData, + }); + + if (!isManualMode) { + executeErrorWorkflow( + this.workflowData, + fullRunData, + this.mode, + this.executionId, + this.retryOf, + ); + } + } catch (error) { + Container.get(ErrorReporter).error(error); + logger.error(`Failed saving execution data to DB on execution ID ${this.executionId}`, { + executionId: this.executionId, + workflowId: this.workflowData.id, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + error, + }); + if (!isManualMode) { + executeErrorWorkflow( + this.workflowData, + fullRunData, + this.mode, + this.executionId, + this.retryOf, + ); + } + } finally { + workflowStatisticsService.emit('workflowExecutionCompleted', { + workflowData: this.workflowData, + fullRunData, + }); + } + }, + ], + nodeFetchedData: [ + async (workflowId: string, node: INode) => { + workflowStatisticsService.emit('nodeFetchedData', { workflowId, node }); + }, + ], + }; +} + +/** + * Returns hook functions to save workflow execution and call error workflow + * for running with queues. Manual executions should never run on queues as + * they are always executed in the main process. + */ +function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { + const logger = Container.get(Logger); + const workflowStatisticsService = Container.get(WorkflowStatisticsService); + const eventService = Container.get(EventService); + return { + nodeExecuteBefore: [ + async function (this: WorkflowHooks, nodeName: string): Promise { + const { executionId, workflowData: workflow } = this; + + eventService.emit('node-pre-execute', { executionId, workflow, nodeName }); + }, + ], + nodeExecuteAfter: [ + async function (this: WorkflowHooks, nodeName: string): Promise { + const { executionId, workflowData: workflow } = this; + + eventService.emit('node-post-execute', { executionId, workflow, nodeName }); + }, + ], + workflowExecuteBefore: [ + async function (this: WorkflowHooks): Promise { + const { executionId, workflowData } = this; + + eventService.emit('workflow-pre-execute', { executionId, data: workflowData }); + }, + ], + workflowExecuteAfter: [ + async function ( + this: WorkflowHooks, + fullRunData: IRun, + newStaticData: IDataObject, + ): Promise { + logger.debug('Executing hook (hookFunctionsSaveWorker)', { + executionId: this.executionId, + workflowId: this.workflowData.id, + }); + + const isManualMode = this.mode === 'manual'; + + try { + if (!isManualMode && isWorkflowIdValid(this.workflowData.id) && newStaticData) { + // Workflow is saved so update in database + try { + await Container.get(WorkflowStaticDataService).saveStaticDataById( + this.workflowData.id, + newStaticData, + ); + } catch (e) { + Container.get(ErrorReporter).error(e); + logger.error( + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + `There was a problem saving the workflow with id "${this.workflowData.id}" to save changed staticData: "${e.message}" (workflowExecuteAfter)`, + { pushRef: this.pushRef, workflowId: this.workflowData.id }, + ); + } + } + + const workflowStatusFinal = determineFinalExecutionStatus(fullRunData); + fullRunData.status = workflowStatusFinal; + + if ( + !isManualMode && + workflowStatusFinal !== 'success' && + workflowStatusFinal !== 'waiting' + ) { + executeErrorWorkflow( + this.workflowData, + fullRunData, + this.mode, + this.executionId, + this.retryOf, + ); + } + + // Although it is treated as IWorkflowBase here, it's being instantiated elsewhere with properties that may be sensitive + // As a result, we should create an IWorkflowBase object with only the data we want to save in it. + const fullExecutionData = prepareExecutionDataForDbUpdate({ + runData: fullRunData, + workflowData: this.workflowData, + workflowStatusFinal, + retryOf: this.retryOf, + }); + + // When going into the waiting state, store the pushRef in the execution-data + if (fullRunData.waitTill && isManualMode) { + fullExecutionData.data.pushRef = this.pushRef; + } + + await updateExistingExecution({ + executionId: this.executionId, + workflowId: this.workflowData.id, + executionData: fullExecutionData, + }); + } catch (error) { + if (!isManualMode) { + executeErrorWorkflow( + this.workflowData, + fullRunData, + this.mode, + this.executionId, + this.retryOf, + ); + } + } finally { + workflowStatisticsService.emit('workflowExecutionCompleted', { + workflowData: this.workflowData, + fullRunData, + }); + } + }, + async function (this: WorkflowHooks, runData: IRun): Promise { + const { executionId, workflowData: workflow } = this; + + eventService.emit('workflow-post-execute', { + workflow, + executionId, + runData, + }); + }, + async function (this: WorkflowHooks, fullRunData: IRun) { + const externalHooks = Container.get(ExternalHooks); + if (externalHooks.exists('workflow.postExecute')) { + try { + await externalHooks.run('workflow.postExecute', [ + fullRunData, + this.workflowData, + this.executionId, + ]); + } catch (error) { + Container.get(ErrorReporter).error(error); + Container.get(Logger).error( + 'There was a problem running hook "workflow.postExecute"', + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + error, + ); + } + } + }, + ], + nodeFetchedData: [ + async (workflowId: string, node: INode) => { + workflowStatisticsService.emit('nodeFetchedData', { workflowId, node }); + }, + ], + }; +} + +/** + * Returns WorkflowHooks instance for running integrated workflows + * (Workflows which get started inside of another workflow) + */ +export function getWorkflowHooksIntegrated( + mode: WorkflowExecuteMode, + executionId: string, + workflowData: IWorkflowBase, +): WorkflowHooks { + const hookFunctions = hookFunctionsSave(); + const preExecuteFunctions = hookFunctionsPreExecute(); + for (const key of Object.keys(preExecuteFunctions)) { + const hooks = hookFunctions[key] ?? []; + hooks.push.apply(hookFunctions[key], preExecuteFunctions[key]); + } + return new WorkflowHooks(hookFunctions, mode, executionId, workflowData); +} + +/** + * Returns WorkflowHooks instance for worker in scaling mode. + */ +export function getWorkflowHooksWorkerExecuter( + mode: WorkflowExecuteMode, + executionId: string, + workflowData: IWorkflowBase, + optionalParameters?: IWorkflowHooksOptionalParameters, +): WorkflowHooks { + optionalParameters = optionalParameters || {}; + const hookFunctions = hookFunctionsSaveWorker(); + const preExecuteFunctions = hookFunctionsPreExecute(); + for (const key of Object.keys(preExecuteFunctions)) { + const hooks = hookFunctions[key] ?? []; + hooks.push.apply(hookFunctions[key], preExecuteFunctions[key]); + } + + if (mode === 'manual' && Container.get(InstanceSettings).isWorker) { + const pushHooks = hookFunctionsPush(); + for (const key of Object.keys(pushHooks)) { + if (hookFunctions[key] === undefined) { + hookFunctions[key] = []; + } + // eslint-disable-next-line prefer-spread + hookFunctions[key].push.apply(hookFunctions[key], pushHooks[key]); + } + } + + return new WorkflowHooks(hookFunctions, mode, executionId, workflowData, optionalParameters); +} + +/** + * Returns WorkflowHooks instance for main process if workflow runs via worker + */ +export function getWorkflowHooksWorkerMain( + mode: WorkflowExecuteMode, + executionId: string, + workflowData: IWorkflowBase, + optionalParameters?: IWorkflowHooksOptionalParameters, +): WorkflowHooks { + optionalParameters = optionalParameters || {}; + const hookFunctions = hookFunctionsPreExecute(); + + // TODO: why are workers pushing to frontend? + // TODO: simplifying this for now to just leave the bare minimum hooks + + // const hookFunctions = hookFunctionsPush(); + // const preExecuteFunctions = hookFunctionsPreExecute(); + // for (const key of Object.keys(preExecuteFunctions)) { + // if (hookFunctions[key] === undefined) { + // hookFunctions[key] = []; + // } + // hookFunctions[key]!.push.apply(hookFunctions[key], preExecuteFunctions[key]); + // } + + // When running with worker mode, main process executes + // Only workflowExecuteBefore + workflowExecuteAfter + // So to avoid confusion, we are removing other hooks. + hookFunctions.nodeExecuteBefore = []; + hookFunctions.nodeExecuteAfter = []; + hookFunctions.workflowExecuteAfter = [ + async function (this: WorkflowHooks, fullRunData: IRun): Promise { + // Don't delete executions before they are finished + if (!fullRunData.finished) return; + + const executionStatus = determineFinalExecutionStatus(fullRunData); + fullRunData.status = executionStatus; + + const saveSettings = toSaveSettings(this.workflowData.settings); + + const isManualMode = this.mode === 'manual'; + + if (isManualMode && !saveSettings.manual && !fullRunData.waitTill) { + /** + * When manual executions are not being saved, we only soft-delete + * the execution so that the user can access its binary data + * while building their workflow. + * + * The manual execution and its binary data will be hard-deleted + * on the next pruning cycle after the grace period set by + * `EXECUTIONS_DATA_HARD_DELETE_BUFFER`. + */ + await Container.get(ExecutionRepository).softDelete(this.executionId); + + return; + } + + const shouldNotSave = + (executionStatus === 'success' && !saveSettings.success) || + (executionStatus !== 'success' && !saveSettings.error); + + if (!isManualMode && shouldNotSave && !fullRunData.waitTill) { + await Container.get(ExecutionRepository).hardDelete({ + workflowId: this.workflowData.id, + executionId: this.executionId, + }); + } + }, + ]; + + return new WorkflowHooks(hookFunctions, mode, executionId, workflowData, optionalParameters); +} + +/** + * Returns WorkflowHooks instance for running the main workflow + */ +export function getWorkflowHooksMain( + data: IWorkflowExecutionDataProcess, + executionId: string, +): WorkflowHooks { + const hookFunctions = hookFunctionsSave(); + const pushFunctions = hookFunctionsPush(); + for (const key of Object.keys(pushFunctions)) { + const hooks = hookFunctions[key] ?? []; + hooks.push.apply(hookFunctions[key], pushFunctions[key]); + } + + const preExecuteFunctions = hookFunctionsPreExecute(); + for (const key of Object.keys(preExecuteFunctions)) { + const hooks = hookFunctions[key] ?? []; + hooks.push.apply(hookFunctions[key], preExecuteFunctions[key]); + } + + if (!hookFunctions.nodeExecuteBefore) hookFunctions.nodeExecuteBefore = []; + if (!hookFunctions.nodeExecuteAfter) hookFunctions.nodeExecuteAfter = []; + + return new WorkflowHooks(hookFunctions, data.executionMode, executionId, data.workflowData, { + pushRef: data.pushRef, + retryOf: data.retryOf as string, + }); +} diff --git a/packages/cli/src/execution-lifecycle-hooks/restore-binary-data-id.ts b/packages/cli/src/execution-lifecycle/restore-binary-data-id.ts similarity index 100% rename from packages/cli/src/execution-lifecycle-hooks/restore-binary-data-id.ts rename to packages/cli/src/execution-lifecycle/restore-binary-data-id.ts diff --git a/packages/cli/src/execution-lifecycle-hooks/save-execution-progress.ts b/packages/cli/src/execution-lifecycle/save-execution-progress.ts similarity index 97% rename from packages/cli/src/execution-lifecycle-hooks/save-execution-progress.ts rename to packages/cli/src/execution-lifecycle/save-execution-progress.ts index 9e751c90f6..8ca33c7095 100644 --- a/packages/cli/src/execution-lifecycle-hooks/save-execution-progress.ts +++ b/packages/cli/src/execution-lifecycle/save-execution-progress.ts @@ -3,7 +3,8 @@ import { ErrorReporter, Logger } from 'n8n-core'; import type { IRunExecutionData, ITaskData, IWorkflowBase } from 'n8n-workflow'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; -import { toSaveSettings } from '@/execution-lifecycle-hooks/to-save-settings'; + +import { toSaveSettings } from './to-save-settings'; export async function saveExecutionProgress( workflowData: IWorkflowBase, diff --git a/packages/cli/src/execution-lifecycle-hooks/shared/__tests__/shared-hook-functions.test.ts b/packages/cli/src/execution-lifecycle/shared/__tests__/shared-hook-functions.test.ts similarity index 100% rename from packages/cli/src/execution-lifecycle-hooks/shared/__tests__/shared-hook-functions.test.ts rename to packages/cli/src/execution-lifecycle/shared/__tests__/shared-hook-functions.test.ts diff --git a/packages/cli/src/execution-lifecycle-hooks/shared/shared-hook-functions.ts b/packages/cli/src/execution-lifecycle/shared/shared-hook-functions.ts similarity index 100% rename from packages/cli/src/execution-lifecycle-hooks/shared/shared-hook-functions.ts rename to packages/cli/src/execution-lifecycle/shared/shared-hook-functions.ts diff --git a/packages/cli/src/execution-lifecycle-hooks/to-save-settings.ts b/packages/cli/src/execution-lifecycle/to-save-settings.ts similarity index 100% rename from packages/cli/src/execution-lifecycle-hooks/to-save-settings.ts rename to packages/cli/src/execution-lifecycle/to-save-settings.ts diff --git a/packages/cli/src/executions/execution-recovery.service.ts b/packages/cli/src/executions/execution-recovery.service.ts index 503e53d023..bb759eae2c 100644 --- a/packages/cli/src/executions/execution-recovery.service.ts +++ b/packages/cli/src/executions/execution-recovery.service.ts @@ -9,9 +9,9 @@ import { ExecutionRepository } from '@/databases/repositories/execution.reposito import { NodeCrashedError } from '@/errors/node-crashed.error'; import { WorkflowCrashedError } from '@/errors/workflow-crashed.error'; import { EventService } from '@/events/event.service'; +import { getWorkflowHooksMain } from '@/execution-lifecycle/execution-lifecycle-hooks'; import type { IExecutionResponse } from '@/interfaces'; import { Push } from '@/push'; -import { getWorkflowHooksMain } from '@/workflow-execute-additional-data'; // @TODO: Dependency cycle import type { EventMessageTypes } from '../eventbus/event-message-classes'; diff --git a/packages/cli/src/scaling/job-processor.ts b/packages/cli/src/scaling/job-processor.ts index 2aff0787c4..768338d9b0 100644 --- a/packages/cli/src/scaling/job-processor.ts +++ b/packages/cli/src/scaling/job-processor.ts @@ -13,6 +13,7 @@ import type PCancelable from 'p-cancelable'; import config from '@/config'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; +import { getWorkflowHooksWorkerExecuter } from '@/execution-lifecycle/execution-lifecycle-hooks'; import { ManualExecutionService } from '@/manual-execution.service'; import { NodeTypes } from '@/node-types'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; @@ -124,7 +125,7 @@ export class JobProcessor { const { pushRef } = job.data; - additionalData.hooks = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerExecuter( + additionalData.hooks = getWorkflowHooksWorkerExecuter( execution.mode, job.data.executionId, execution.workflowData, diff --git a/packages/cli/src/__tests__/object-to-error.test.ts b/packages/cli/src/utils/__tests__/object-to-error.test.ts similarity index 94% rename from packages/cli/src/__tests__/object-to-error.test.ts rename to packages/cli/src/utils/__tests__/object-to-error.test.ts index 311f4dce55..c65676a426 100644 --- a/packages/cli/src/__tests__/object-to-error.test.ts +++ b/packages/cli/src/utils/__tests__/object-to-error.test.ts @@ -2,7 +2,7 @@ import { mock } from 'jest-mock-extended'; import type { INode } from 'n8n-workflow'; import { NodeOperationError, type Workflow } from 'n8n-workflow'; -import { objectToError } from '../workflow-execute-additional-data'; +import { objectToError } from '../object-to-error'; describe('objectToError', () => { describe('node error handling', () => { diff --git a/packages/cli/src/utils/object-to-error.ts b/packages/cli/src/utils/object-to-error.ts new file mode 100644 index 0000000000..ffb0cd8fb3 --- /dev/null +++ b/packages/cli/src/utils/object-to-error.ts @@ -0,0 +1,53 @@ +import { isObjectLiteral } from 'n8n-core'; +import { NodeOperationError } from 'n8n-workflow'; +import type { Workflow } from 'n8n-workflow'; + +export function objectToError(errorObject: unknown, workflow: Workflow): Error { + // TODO: Expand with other error types + if (errorObject instanceof Error) { + // If it's already an Error instance, return it as is. + return errorObject; + } else if ( + isObjectLiteral(errorObject) && + 'message' in errorObject && + typeof errorObject.message === 'string' + ) { + // If it's an object with a 'message' property, create a new Error instance. + let error: Error | undefined; + if ( + 'node' in errorObject && + isObjectLiteral(errorObject.node) && + typeof errorObject.node.name === 'string' + ) { + const node = workflow.getNode(errorObject.node.name); + + if (node) { + error = new NodeOperationError( + node, + errorObject as unknown as Error, + errorObject as object, + ); + } + } + + if (error === undefined) { + error = new Error(errorObject.message); + } + + if ('description' in errorObject) { + // @ts-expect-error Error descriptions are surfaced by the UI but + // not all backend errors account for this property yet. + error.description = errorObject.description as string; + } + + if ('stack' in errorObject) { + // If there's a 'stack' property, set it on the new Error instance. + error.stack = errorObject.stack as string; + } + + return error; + } else { + // If it's neither an Error nor an object with a 'message' property, create a generic Error. + return new Error('An error occurred'); + } +} diff --git a/packages/cli/src/workflow-execute-additional-data.ts b/packages/cli/src/workflow-execute-additional-data.ts index 7d69084357..c3b3ed8693 100644 --- a/packages/cli/src/workflow-execute-additional-data.ts +++ b/packages/cli/src/workflow-execute-additional-data.ts @@ -5,15 +5,8 @@ import type { PushMessage, PushType } from '@n8n/api-types'; import { GlobalConfig } from '@n8n/config'; import { Container } from '@n8n/di'; -import { stringify } from 'flatted'; -import { - ErrorReporter, - Logger, - InstanceSettings, - WorkflowExecute, - isObjectLiteral, -} from 'n8n-core'; -import { ApplicationError, NodeOperationError, Workflow, WorkflowHooks } from 'n8n-workflow'; +import { Logger, WorkflowExecute } from 'n8n-core'; +import { ApplicationError, Workflow } from 'n8n-workflow'; import type { IDataObject, IExecuteData, @@ -23,11 +16,8 @@ import type { INodeParameters, IRun, IRunExecutionData, - ITaskData, IWorkflowBase, IWorkflowExecuteAdditionalData, - IWorkflowExecuteHooks, - IWorkflowHooksOptionalParameters, IWorkflowSettings, WorkflowExecuteMode, ExecutionStatus, @@ -44,646 +34,23 @@ import type { import { ActiveExecutions } from '@/active-executions'; import { CredentialsHelper } from '@/credentials-helper'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; +import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; +import { EventService } from '@/events/event.service'; import type { AiEventMap, AiEventPayload } from '@/events/maps/ai.event-map'; +import { getWorkflowHooksIntegrated } from '@/execution-lifecycle/execution-lifecycle-hooks'; import { ExternalHooks } from '@/external-hooks'; -import type { IWorkflowErrorData, UpdateExecutionPayload } from '@/interfaces'; +import type { UpdateExecutionPayload } from '@/interfaces'; import { NodeTypes } from '@/node-types'; import { Push } from '@/push'; -import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; -import { findSubworkflowStart, isWorkflowIdValid } from '@/utils'; +import { SecretsHelper } from '@/secrets-helpers.ee'; +import { UrlService } from '@/services/url.service'; +import { SubworkflowPolicyChecker } from '@/subworkflows/subworkflow-policy-checker.service'; +import { TaskRequester } from '@/task-runners/task-managers/task-requester'; +import { PermissionChecker } from '@/user-management/permission-checker'; +import { findSubworkflowStart } from '@/utils'; +import { objectToError } from '@/utils/object-to-error'; import * as WorkflowHelpers from '@/workflow-helpers'; -import { WorkflowRepository } from './databases/repositories/workflow.repository'; -import { EventService } from './events/event.service'; -import { restoreBinaryDataId } from './execution-lifecycle-hooks/restore-binary-data-id'; -import { saveExecutionProgress } from './execution-lifecycle-hooks/save-execution-progress'; -import { - determineFinalExecutionStatus, - prepareExecutionDataForDbUpdate, - updateExistingExecution, -} from './execution-lifecycle-hooks/shared/shared-hook-functions'; -import { toSaveSettings } from './execution-lifecycle-hooks/to-save-settings'; -import { SecretsHelper } from './secrets-helpers.ee'; -import { OwnershipService } from './services/ownership.service'; -import { UrlService } from './services/url.service'; -import { SubworkflowPolicyChecker } from './subworkflows/subworkflow-policy-checker.service'; -import { TaskRequester } from './task-runners/task-managers/task-requester'; -import { PermissionChecker } from './user-management/permission-checker'; -import { WorkflowExecutionService } from './workflows/workflow-execution.service'; -import { WorkflowStaticDataService } from './workflows/workflow-static-data.service'; - -export function objectToError(errorObject: unknown, workflow: Workflow): Error { - // TODO: Expand with other error types - if (errorObject instanceof Error) { - // If it's already an Error instance, return it as is. - return errorObject; - } else if ( - isObjectLiteral(errorObject) && - 'message' in errorObject && - typeof errorObject.message === 'string' - ) { - // If it's an object with a 'message' property, create a new Error instance. - let error: Error | undefined; - if ( - 'node' in errorObject && - isObjectLiteral(errorObject.node) && - typeof errorObject.node.name === 'string' - ) { - const node = workflow.getNode(errorObject.node.name); - - if (node) { - error = new NodeOperationError( - node, - errorObject as unknown as Error, - errorObject as object, - ); - } - } - - if (error === undefined) { - error = new Error(errorObject.message); - } - - if ('description' in errorObject) { - // @ts-expect-error Error descriptions are surfaced by the UI but - // not all backend errors account for this property yet. - error.description = errorObject.description as string; - } - - if ('stack' in errorObject) { - // If there's a 'stack' property, set it on the new Error instance. - error.stack = errorObject.stack as string; - } - - return error; - } else { - // If it's neither an Error nor an object with a 'message' property, create a generic Error. - return new Error('An error occurred'); - } -} - -/** - * Checks if there was an error and if errorWorkflow or a trigger is defined. If so it collects - * all the data and executes it - * - * @param {IWorkflowBase} workflowData The workflow which got executed - * @param {IRun} fullRunData The run which produced the error - * @param {WorkflowExecuteMode} mode The mode in which the workflow got started in - * @param {string} [executionId] The id the execution got saved as - */ -export function executeErrorWorkflow( - workflowData: IWorkflowBase, - fullRunData: IRun, - mode: WorkflowExecuteMode, - executionId?: string, - retryOf?: string, -): void { - const logger = Container.get(Logger); - - // Check if there was an error and if so if an errorWorkflow or a trigger is set - let pastExecutionUrl: string | undefined; - if (executionId !== undefined) { - pastExecutionUrl = `${Container.get(UrlService).getWebhookBaseUrl()}workflow/${ - workflowData.id - }/executions/${executionId}`; - } - - if (fullRunData.data.resultData.error !== undefined) { - let workflowErrorData: IWorkflowErrorData; - const workflowId = workflowData.id; - - if (executionId) { - // The error did happen in an execution - workflowErrorData = { - execution: { - id: executionId, - url: pastExecutionUrl, - error: fullRunData.data.resultData.error, - lastNodeExecuted: fullRunData.data.resultData.lastNodeExecuted!, - mode, - retryOf, - }, - workflow: { - id: workflowId, - name: workflowData.name, - }, - }; - } else { - // The error did happen in a trigger - workflowErrorData = { - trigger: { - error: fullRunData.data.resultData.error, - mode, - }, - workflow: { - id: workflowId, - name: workflowData.name, - }, - }; - } - - const { errorTriggerType } = Container.get(GlobalConfig).nodes; - // Run the error workflow - // To avoid an infinite loop do not run the error workflow again if the error-workflow itself failed and it is its own error-workflow. - const { errorWorkflow } = workflowData.settings ?? {}; - if (errorWorkflow && !(mode === 'error' && workflowId && errorWorkflow === workflowId)) { - logger.debug('Start external error workflow', { - executionId, - errorWorkflowId: errorWorkflow, - workflowId, - }); - // If a specific error workflow is set run only that one - - // First, do permission checks. - if (!workflowId) { - // Manual executions do not trigger error workflows - // So this if should never happen. It was added to - // make sure there are no possible security gaps - return; - } - - Container.get(OwnershipService) - .getWorkflowProjectCached(workflowId) - .then((project) => { - void Container.get(WorkflowExecutionService).executeErrorWorkflow( - errorWorkflow, - workflowErrorData, - project, - ); - }) - .catch((error: Error) => { - Container.get(ErrorReporter).error(error); - logger.error( - `Could not execute ErrorWorkflow for execution ID ${this.executionId} because of error querying the workflow owner`, - { - executionId, - errorWorkflowId: errorWorkflow, - workflowId, - error, - workflowErrorData, - }, - ); - }); - } else if ( - mode !== 'error' && - workflowId !== undefined && - workflowData.nodes.some((node) => node.type === errorTriggerType) - ) { - logger.debug('Start internal error workflow', { executionId, workflowId }); - void Container.get(OwnershipService) - .getWorkflowProjectCached(workflowId) - .then((project) => { - void Container.get(WorkflowExecutionService).executeErrorWorkflow( - workflowId, - workflowErrorData, - project, - ); - }); - } - } -} - -/** - * Returns hook functions to push data to Editor-UI - * - */ -function hookFunctionsPush(): IWorkflowExecuteHooks { - const logger = Container.get(Logger); - const pushInstance = Container.get(Push); - return { - nodeExecuteBefore: [ - async function (this: WorkflowHooks, nodeName: string): Promise { - const { pushRef, executionId } = this; - // Push data to session which started workflow before each - // node which starts rendering - if (pushRef === undefined) { - return; - } - - logger.debug(`Executing hook on node "${nodeName}" (hookFunctionsPush)`, { - executionId, - pushRef, - workflowId: this.workflowData.id, - }); - - pushInstance.send({ type: 'nodeExecuteBefore', data: { executionId, nodeName } }, pushRef); - }, - ], - nodeExecuteAfter: [ - async function (this: WorkflowHooks, nodeName: string, data: ITaskData): Promise { - const { pushRef, executionId } = this; - // Push data to session which started workflow after each rendered node - if (pushRef === undefined) { - return; - } - - logger.debug(`Executing hook on node "${nodeName}" (hookFunctionsPush)`, { - executionId, - pushRef, - workflowId: this.workflowData.id, - }); - - pushInstance.send( - { type: 'nodeExecuteAfter', data: { executionId, nodeName, data } }, - pushRef, - ); - }, - ], - workflowExecuteBefore: [ - async function (this: WorkflowHooks, _workflow, data): Promise { - const { pushRef, executionId } = this; - const { id: workflowId, name: workflowName } = this.workflowData; - logger.debug('Executing hook (hookFunctionsPush)', { - executionId, - pushRef, - workflowId, - }); - // Push data to session which started the workflow - if (pushRef === undefined) { - return; - } - pushInstance.send( - { - type: 'executionStarted', - data: { - executionId, - mode: this.mode, - startedAt: new Date(), - retryOf: this.retryOf, - workflowId, - workflowName, - flattedRunData: data?.resultData.runData - ? stringify(data.resultData.runData) - : stringify({}), - }, - }, - pushRef, - ); - }, - ], - workflowExecuteAfter: [ - async function (this: WorkflowHooks, fullRunData: IRun): Promise { - const { pushRef, executionId } = this; - if (pushRef === undefined) return; - - const { id: workflowId } = this.workflowData; - logger.debug('Executing hook (hookFunctionsPush)', { - executionId, - pushRef, - workflowId, - }); - - const { status } = fullRunData; - if (status === 'waiting') { - pushInstance.send({ type: 'executionWaiting', data: { executionId } }, pushRef); - } else { - const rawData = stringify(fullRunData.data); - pushInstance.send( - { type: 'executionFinished', data: { executionId, workflowId, status, rawData } }, - pushRef, - ); - } - }, - ], - }; -} - -export function hookFunctionsPreExecute(): IWorkflowExecuteHooks { - const externalHooks = Container.get(ExternalHooks); - return { - workflowExecuteBefore: [ - async function (this: WorkflowHooks, workflow: Workflow): Promise { - await externalHooks.run('workflow.preExecute', [workflow, this.mode]); - }, - ], - nodeExecuteAfter: [ - async function ( - this: WorkflowHooks, - nodeName: string, - data: ITaskData, - executionData: IRunExecutionData, - ): Promise { - await saveExecutionProgress( - this.workflowData, - this.executionId, - nodeName, - data, - executionData, - this.pushRef, - ); - }, - ], - }; -} - -/** - * Returns hook functions to save workflow execution and call error workflow - * - */ -function hookFunctionsSave(): IWorkflowExecuteHooks { - const logger = Container.get(Logger); - const workflowStatisticsService = Container.get(WorkflowStatisticsService); - const eventService = Container.get(EventService); - return { - nodeExecuteBefore: [ - async function (this: WorkflowHooks, nodeName: string): Promise { - const { executionId, workflowData: workflow } = this; - - eventService.emit('node-pre-execute', { executionId, workflow, nodeName }); - }, - ], - nodeExecuteAfter: [ - async function (this: WorkflowHooks, nodeName: string): Promise { - const { executionId, workflowData: workflow } = this; - - eventService.emit('node-post-execute', { executionId, workflow, nodeName }); - }, - ], - workflowExecuteBefore: [], - workflowExecuteAfter: [ - async function ( - this: WorkflowHooks, - fullRunData: IRun, - newStaticData: IDataObject, - ): Promise { - logger.debug('Executing hook (hookFunctionsSave)', { - executionId: this.executionId, - workflowId: this.workflowData.id, - }); - - await restoreBinaryDataId(fullRunData, this.executionId, this.mode); - - const isManualMode = this.mode === 'manual'; - - try { - if (!isManualMode && isWorkflowIdValid(this.workflowData.id) && newStaticData) { - // Workflow is saved so update in database - try { - await Container.get(WorkflowStaticDataService).saveStaticDataById( - this.workflowData.id, - newStaticData, - ); - } catch (e) { - Container.get(ErrorReporter).error(e); - logger.error( - `There was a problem saving the workflow with id "${this.workflowData.id}" to save changed staticData: "${e.message}" (hookFunctionsSave)`, - { executionId: this.executionId, workflowId: this.workflowData.id }, - ); - } - } - - const executionStatus = determineFinalExecutionStatus(fullRunData); - fullRunData.status = executionStatus; - - const saveSettings = toSaveSettings(this.workflowData.settings); - - if (isManualMode && !saveSettings.manual && !fullRunData.waitTill) { - /** - * When manual executions are not being saved, we only soft-delete - * the execution so that the user can access its binary data - * while building their workflow. - * - * The manual execution and its binary data will be hard-deleted - * on the next pruning cycle after the grace period set by - * `EXECUTIONS_DATA_HARD_DELETE_BUFFER`. - */ - await Container.get(ExecutionRepository).softDelete(this.executionId); - - return; - } - - const shouldNotSave = - (executionStatus === 'success' && !saveSettings.success) || - (executionStatus !== 'success' && !saveSettings.error); - - if (shouldNotSave && !fullRunData.waitTill && !isManualMode) { - executeErrorWorkflow( - this.workflowData, - fullRunData, - this.mode, - this.executionId, - this.retryOf, - ); - - await Container.get(ExecutionRepository).hardDelete({ - workflowId: this.workflowData.id, - executionId: this.executionId, - }); - - return; - } - - // Although it is treated as IWorkflowBase here, it's being instantiated elsewhere with properties that may be sensitive - // As a result, we should create an IWorkflowBase object with only the data we want to save in it. - const fullExecutionData = prepareExecutionDataForDbUpdate({ - runData: fullRunData, - workflowData: this.workflowData, - workflowStatusFinal: executionStatus, - retryOf: this.retryOf, - }); - - // When going into the waiting state, store the pushRef in the execution-data - if (fullRunData.waitTill && isManualMode) { - fullExecutionData.data.pushRef = this.pushRef; - } - - await updateExistingExecution({ - executionId: this.executionId, - workflowId: this.workflowData.id, - executionData: fullExecutionData, - }); - - if (!isManualMode) { - executeErrorWorkflow( - this.workflowData, - fullRunData, - this.mode, - this.executionId, - this.retryOf, - ); - } - } catch (error) { - Container.get(ErrorReporter).error(error); - logger.error(`Failed saving execution data to DB on execution ID ${this.executionId}`, { - executionId: this.executionId, - workflowId: this.workflowData.id, - error, - }); - if (!isManualMode) { - executeErrorWorkflow( - this.workflowData, - fullRunData, - this.mode, - this.executionId, - this.retryOf, - ); - } - } finally { - workflowStatisticsService.emit('workflowExecutionCompleted', { - workflowData: this.workflowData, - fullRunData, - }); - } - }, - ], - nodeFetchedData: [ - async (workflowId: string, node: INode) => { - workflowStatisticsService.emit('nodeFetchedData', { workflowId, node }); - }, - ], - }; -} - -/** - * Returns hook functions to save workflow execution and call error workflow - * for running with queues. Manual executions should never run on queues as - * they are always executed in the main process. - * - */ -function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { - const logger = Container.get(Logger); - const workflowStatisticsService = Container.get(WorkflowStatisticsService); - const eventService = Container.get(EventService); - return { - nodeExecuteBefore: [ - async function (this: WorkflowHooks, nodeName: string): Promise { - const { executionId, workflowData: workflow } = this; - - eventService.emit('node-pre-execute', { executionId, workflow, nodeName }); - }, - ], - nodeExecuteAfter: [ - async function (this: WorkflowHooks, nodeName: string): Promise { - const { executionId, workflowData: workflow } = this; - - eventService.emit('node-post-execute', { executionId, workflow, nodeName }); - }, - ], - workflowExecuteBefore: [ - async function (): Promise { - const { executionId, workflowData } = this; - - eventService.emit('workflow-pre-execute', { executionId, data: workflowData }); - }, - ], - workflowExecuteAfter: [ - async function ( - this: WorkflowHooks, - fullRunData: IRun, - newStaticData: IDataObject, - ): Promise { - logger.debug('Executing hook (hookFunctionsSaveWorker)', { - executionId: this.executionId, - workflowId: this.workflowData.id, - }); - - const isManualMode = this.mode === 'manual'; - - try { - if (!isManualMode && isWorkflowIdValid(this.workflowData.id) && newStaticData) { - // Workflow is saved so update in database - try { - await Container.get(WorkflowStaticDataService).saveStaticDataById( - this.workflowData.id, - newStaticData, - ); - } catch (e) { - Container.get(ErrorReporter).error(e); - logger.error( - `There was a problem saving the workflow with id "${this.workflowData.id}" to save changed staticData: "${e.message}" (workflowExecuteAfter)`, - { pushRef: this.pushRef, workflowId: this.workflowData.id }, - ); - } - } - - const workflowStatusFinal = determineFinalExecutionStatus(fullRunData); - fullRunData.status = workflowStatusFinal; - - if ( - !isManualMode && - workflowStatusFinal !== 'success' && - workflowStatusFinal !== 'waiting' - ) { - executeErrorWorkflow( - this.workflowData, - fullRunData, - this.mode, - this.executionId, - this.retryOf, - ); - } - - // Although it is treated as IWorkflowBase here, it's being instantiated elsewhere with properties that may be sensitive - // As a result, we should create an IWorkflowBase object with only the data we want to save in it. - const fullExecutionData = prepareExecutionDataForDbUpdate({ - runData: fullRunData, - workflowData: this.workflowData, - workflowStatusFinal, - retryOf: this.retryOf, - }); - - // When going into the waiting state, store the pushRef in the execution-data - if (fullRunData.waitTill && isManualMode) { - fullExecutionData.data.pushRef = this.pushRef; - } - - await updateExistingExecution({ - executionId: this.executionId, - workflowId: this.workflowData.id, - executionData: fullExecutionData, - }); - } catch (error) { - if (!isManualMode) - executeErrorWorkflow( - this.workflowData, - fullRunData, - this.mode, - this.executionId, - this.retryOf, - ); - } finally { - workflowStatisticsService.emit('workflowExecutionCompleted', { - workflowData: this.workflowData, - fullRunData, - }); - } - }, - async function (this: WorkflowHooks, runData: IRun): Promise { - const { executionId, workflowData: workflow } = this; - - eventService.emit('workflow-post-execute', { - workflow, - executionId, - runData, - }); - }, - async function (this: WorkflowHooks, fullRunData: IRun) { - const externalHooks = Container.get(ExternalHooks); - if (externalHooks.exists('workflow.postExecute')) { - try { - await externalHooks.run('workflow.postExecute', [ - fullRunData, - this.workflowData, - this.executionId, - ]); - } catch (error) { - Container.get(ErrorReporter).error(error); - Container.get(Logger).error( - 'There was a problem running hook "workflow.postExecute"', - error, - ); - } - } - }, - ], - nodeFetchedData: [ - async (workflowId: string, node: INode) => { - workflowStatisticsService.emit('nodeFetchedData', { workflowId, node }); - }, - ], - }; -} - export async function getRunData( workflowData: IWorkflowBase, inputData?: INodeExecutionData[], @@ -1074,154 +441,3 @@ export async function getBase( eventService.emit(eventName, payload), }; } - -/** - * Returns WorkflowHooks instance for running integrated workflows - * (Workflows which get started inside of another workflow) - */ -function getWorkflowHooksIntegrated( - mode: WorkflowExecuteMode, - executionId: string, - workflowData: IWorkflowBase, -): WorkflowHooks { - const hookFunctions = hookFunctionsSave(); - const preExecuteFunctions = hookFunctionsPreExecute(); - for (const key of Object.keys(preExecuteFunctions)) { - const hooks = hookFunctions[key] ?? []; - hooks.push.apply(hookFunctions[key], preExecuteFunctions[key]); - } - return new WorkflowHooks(hookFunctions, mode, executionId, workflowData); -} - -/** - * Returns WorkflowHooks instance for worker in scaling mode. - */ -export function getWorkflowHooksWorkerExecuter( - mode: WorkflowExecuteMode, - executionId: string, - workflowData: IWorkflowBase, - optionalParameters?: IWorkflowHooksOptionalParameters, -): WorkflowHooks { - optionalParameters = optionalParameters || {}; - const hookFunctions = hookFunctionsSaveWorker(); - const preExecuteFunctions = hookFunctionsPreExecute(); - for (const key of Object.keys(preExecuteFunctions)) { - const hooks = hookFunctions[key] ?? []; - hooks.push.apply(hookFunctions[key], preExecuteFunctions[key]); - } - - if (mode === 'manual' && Container.get(InstanceSettings).isWorker) { - const pushHooks = hookFunctionsPush(); - for (const key of Object.keys(pushHooks)) { - if (hookFunctions[key] === undefined) { - hookFunctions[key] = []; - } - // eslint-disable-next-line prefer-spread - hookFunctions[key].push.apply(hookFunctions[key], pushHooks[key]); - } - } - - return new WorkflowHooks(hookFunctions, mode, executionId, workflowData, optionalParameters); -} - -/** - * Returns WorkflowHooks instance for main process if workflow runs via worker - */ -export function getWorkflowHooksWorkerMain( - mode: WorkflowExecuteMode, - executionId: string, - workflowData: IWorkflowBase, - optionalParameters?: IWorkflowHooksOptionalParameters, -): WorkflowHooks { - optionalParameters = optionalParameters || {}; - const hookFunctions = hookFunctionsPreExecute(); - - // TODO: why are workers pushing to frontend? - // TODO: simplifying this for now to just leave the bare minimum hooks - - // const hookFunctions = hookFunctionsPush(); - // const preExecuteFunctions = hookFunctionsPreExecute(); - // for (const key of Object.keys(preExecuteFunctions)) { - // if (hookFunctions[key] === undefined) { - // hookFunctions[key] = []; - // } - // hookFunctions[key]!.push.apply(hookFunctions[key], preExecuteFunctions[key]); - // } - - // When running with worker mode, main process executes - // Only workflowExecuteBefore + workflowExecuteAfter - // So to avoid confusion, we are removing other hooks. - hookFunctions.nodeExecuteBefore = []; - hookFunctions.nodeExecuteAfter = []; - hookFunctions.workflowExecuteAfter = [ - async function (this: WorkflowHooks, fullRunData: IRun): Promise { - // Don't delete executions before they are finished - if (!fullRunData.finished) return; - - const executionStatus = determineFinalExecutionStatus(fullRunData); - fullRunData.status = executionStatus; - - const saveSettings = toSaveSettings(this.workflowData.settings); - - const isManualMode = this.mode === 'manual'; - - if (isManualMode && !saveSettings.manual && !fullRunData.waitTill) { - /** - * When manual executions are not being saved, we only soft-delete - * the execution so that the user can access its binary data - * while building their workflow. - * - * The manual execution and its binary data will be hard-deleted - * on the next pruning cycle after the grace period set by - * `EXECUTIONS_DATA_HARD_DELETE_BUFFER`. - */ - await Container.get(ExecutionRepository).softDelete(this.executionId); - - return; - } - - const shouldNotSave = - (executionStatus === 'success' && !saveSettings.success) || - (executionStatus !== 'success' && !saveSettings.error); - - if (!isManualMode && shouldNotSave && !fullRunData.waitTill) { - await Container.get(ExecutionRepository).hardDelete({ - workflowId: this.workflowData.id, - executionId: this.executionId, - }); - } - }, - ]; - - return new WorkflowHooks(hookFunctions, mode, executionId, workflowData, optionalParameters); -} - -/** - * Returns WorkflowHooks instance for running the main workflow - * - */ -export function getWorkflowHooksMain( - data: IWorkflowExecutionDataProcess, - executionId: string, -): WorkflowHooks { - const hookFunctions = hookFunctionsSave(); - const pushFunctions = hookFunctionsPush(); - for (const key of Object.keys(pushFunctions)) { - const hooks = hookFunctions[key] ?? []; - hooks.push.apply(hookFunctions[key], pushFunctions[key]); - } - - const preExecuteFunctions = hookFunctionsPreExecute(); - for (const key of Object.keys(preExecuteFunctions)) { - const hooks = hookFunctions[key] ?? []; - hooks.push.apply(hookFunctions[key], preExecuteFunctions[key]); - } - - if (!hookFunctions.nodeExecuteBefore) hookFunctions.nodeExecuteBefore = []; - if (!hookFunctions.nodeExecuteAfter) hookFunctions.nodeExecuteAfter = []; - - return new WorkflowHooks(hookFunctions, data.executionMode, executionId, data.workflowData, { - pushRef: data.pushRef, - retryOf: data.retryOf as string, - }); -} diff --git a/packages/cli/src/workflow-runner.ts b/packages/cli/src/workflow-runner.ts index 598e6a8b58..148df7edcd 100644 --- a/packages/cli/src/workflow-runner.ts +++ b/packages/cli/src/workflow-runner.ts @@ -20,7 +20,15 @@ import PCancelable from 'p-cancelable'; import { ActiveExecutions } from '@/active-executions'; import config from '@/config'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; +import { ExecutionNotFoundError } from '@/errors/execution-not-found-error'; +import { EventService } from '@/events/event.service'; +import { + getWorkflowHooksMain, + getWorkflowHooksWorkerExecuter, + getWorkflowHooksWorkerMain, +} from '@/execution-lifecycle/execution-lifecycle-hooks'; import { ExternalHooks } from '@/external-hooks'; +import { ManualExecutionService } from '@/manual-execution.service'; import { NodeTypes } from '@/node-types'; import type { ScalingService } from '@/scaling/scaling.service'; import type { Job, JobData } from '@/scaling/scaling.types'; @@ -29,10 +37,6 @@ import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-da import { generateFailedExecutionFromError } from '@/workflow-helpers'; import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service'; -import { ExecutionNotFoundError } from './errors/execution-not-found-error'; -import { EventService } from './events/event.service'; -import { ManualExecutionService } from './manual-execution.service'; - @Service() export class WorkflowRunner { private scalingService: ScalingService; @@ -138,7 +142,7 @@ export class WorkflowRunner { } catch (error) { // Create a failed execution with the data for the node, save it and abort execution const runData = generateFailedExecutionFromError(data.executionMode, error, error.node); - const workflowHooks = WorkflowExecuteAdditionalData.getWorkflowHooksMain(data, executionId); + const workflowHooks = getWorkflowHooksMain(data, executionId); await workflowHooks.executeHookFunctions('workflowExecuteBefore', [ undefined, data.executionData, @@ -267,7 +271,7 @@ export class WorkflowRunner { await this.executionRepository.setRunning(executionId); // write try { - additionalData.hooks = WorkflowExecuteAdditionalData.getWorkflowHooksMain(data, executionId); + additionalData.hooks = getWorkflowHooksMain(data, executionId); additionalData.hooks.hookFunctions.sendResponse = [ async (response: IExecuteResponsePromiseData): Promise => { @@ -368,12 +372,9 @@ export class WorkflowRunner { try { job = await this.scalingService.addJob(jobData, { priority: realtime ? 50 : 100 }); - hooks = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerMain( - data.executionMode, - executionId, - data.workflowData, - { retryOf: data.retryOf ? data.retryOf.toString() : undefined }, - ); + hooks = getWorkflowHooksWorkerMain(data.executionMode, executionId, data.workflowData, { + retryOf: data.retryOf ? data.retryOf.toString() : undefined, + }); // Normally also workflow should be supplied here but as it only used for sending // data to editor-UI is not needed. @@ -381,7 +382,7 @@ export class WorkflowRunner { } catch (error) { // We use "getWorkflowHooksWorkerExecuter" as "getWorkflowHooksWorkerMain" does not contain the // "workflowExecuteAfter" which we require. - const hooks = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerExecuter( + const hooks = getWorkflowHooksWorkerExecuter( data.executionMode, executionId, data.workflowData, @@ -399,7 +400,7 @@ export class WorkflowRunner { // We use "getWorkflowHooksWorkerExecuter" as "getWorkflowHooksWorkerMain" does not contain the // "workflowExecuteAfter" which we require. - const hooksWorker = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerExecuter( + const hooksWorker = getWorkflowHooksWorkerExecuter( data.executionMode, executionId, data.workflowData, @@ -417,7 +418,7 @@ export class WorkflowRunner { } catch (error) { // We use "getWorkflowHooksWorkerExecuter" as "getWorkflowHooksWorkerMain" does not contain the // "workflowExecuteAfter" which we require. - const hooks = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerExecuter( + const hooks = getWorkflowHooksWorkerExecuter( data.executionMode, executionId, data.workflowData, From 223ad7d71ae36f9f5a4aeacd006996bb564c83ec Mon Sep 17 00:00:00 2001 From: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Date: Tue, 21 Jan 2025 15:47:21 +0200 Subject: [PATCH 09/28] test: Fix failing core tests (#12752) --- .../execution-engine/__tests__/workflow-execute.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts b/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts index 3bdc4b5c78..6faa595432 100644 --- a/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts +++ b/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts @@ -9,6 +9,8 @@ // XX denotes that the node is disabled // PD denotes that the node has pinned data +import { TaskRunnersConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import { pick } from 'lodash'; import type { @@ -48,6 +50,11 @@ import { WorkflowExecute } from '../workflow-execute'; const nodeTypes = Helpers.NodeTypes(); describe('WorkflowExecute', () => { + const taskRunnersConfig = Container.get(TaskRunnersConfig); + // Disable task runners until we have fixed the "run test workflows" test + // to mock the Code Node execution + taskRunnersConfig.enabled = false; + describe('v0 execution order', () => { const tests: WorkflowTestData[] = legacyWorkflowExecuteTests; From fb4cb5afbb5550020a88e0ee786eee0991c224a3 Mon Sep 17 00:00:00 2001 From: Dana <152518854+dana-gill@users.noreply.github.com> Date: Tue, 21 Jan 2025 15:21:42 +0100 Subject: [PATCH 10/28] chore(API): Add styling to credential callback and autoclose window (#12648) --- packages/cli/src/abstract-server.ts | 4 + .../oauth2-credential.controller.test.ts | 6 +- .../oauth/oauth2-credential.controller.ts | 2 +- .../cli/templates/oauth-callback.handlebars | 89 +++++++++++++++++-- .../controllers/oauth/oauth2.api.test.ts | 2 +- 5 files changed, 91 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/abstract-server.ts b/packages/cli/src/abstract-server.ts index a9340b0a87..8a2ba38b4a 100644 --- a/packages/cli/src/abstract-server.ts +++ b/packages/cli/src/abstract-server.ts @@ -7,6 +7,7 @@ import { readFile } from 'fs/promises'; import type { Server } from 'http'; import isbot from 'isbot'; import { Logger } from 'n8n-core'; +import path from 'path'; import config from '@/config'; import { N8N_VERSION, TEMPLATES_DIR, inDevelopment, inTest } from '@/constants'; @@ -67,6 +68,9 @@ export abstract class AbstractServer { this.app.set('view engine', 'handlebars'); this.app.set('views', TEMPLATES_DIR); + const assetsPath: string = path.join(__dirname, '../../../assets'); + this.app.use(express.static(assetsPath)); + const proxyHops = config.getEnv('proxy_hops'); if (proxyHops > 0) this.app.set('trust proxy', proxyHops); diff --git a/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts b/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts index 1984d12f59..5281378fe0 100644 --- a/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts +++ b/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts @@ -255,7 +255,7 @@ describe('OAuth2CredentialController', () => { type: 'oAuth2Api', }), ); - expect(res.render).toHaveBeenCalledWith('oauth-callback'); + expect(res.render).toHaveBeenCalledWith('oauth-callback', { imagePath: 'n8n-logo.png' }); }); it('merges oauthTokenData if it already exists', async () => { @@ -297,7 +297,7 @@ describe('OAuth2CredentialController', () => { type: 'oAuth2Api', }), ); - expect(res.render).toHaveBeenCalledWith('oauth-callback'); + expect(res.render).toHaveBeenCalledWith('oauth-callback', { imagePath: 'n8n-logo.png' }); }); it('overwrites oauthTokenData if it is a string', async () => { @@ -335,7 +335,7 @@ describe('OAuth2CredentialController', () => { type: 'oAuth2Api', }), ); - expect(res.render).toHaveBeenCalledWith('oauth-callback'); + expect(res.render).toHaveBeenCalledWith('oauth-callback', { imagePath: 'n8n-logo.png' }); }); }); }); diff --git a/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts b/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts index e188670fde..c4c24de0bc 100644 --- a/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts +++ b/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts @@ -149,7 +149,7 @@ export class OAuth2CredentialController extends AbstractOAuthController { credentialId: credential.id, }); - return res.render('oauth-callback'); + return res.render('oauth-callback', { imagePath: 'n8n-logo.png' }); } catch (error) { return this.renderCallbackError( res, diff --git a/packages/cli/templates/oauth-callback.handlebars b/packages/cli/templates/oauth-callback.handlebars index c0d8a0cfab..74d57db303 100644 --- a/packages/cli/templates/oauth-callback.handlebars +++ b/packages/cli/templates/oauth-callback.handlebars @@ -1,10 +1,85 @@ - + + + + +
+
+
+ +
+
+ + + + +

Connection successful

+
+
+

This window will close automatically in 5 seconds.

+
+
+
+ + diff --git a/packages/cli/test/integration/controllers/oauth/oauth2.api.test.ts b/packages/cli/test/integration/controllers/oauth/oauth2.api.test.ts index f20f9df550..6411966dbf 100644 --- a/packages/cli/test/integration/controllers/oauth/oauth2.api.test.ts +++ b/packages/cli/test/integration/controllers/oauth/oauth2.api.test.ts @@ -116,7 +116,7 @@ describe('OAuth2 API', () => { .query({ code: 'auth_code', state }) .expect(200); - expect(renderSpy).toHaveBeenCalledWith('oauth-callback'); + expect(renderSpy).toHaveBeenCalledWith('oauth-callback', { imagePath: 'n8n-logo.png' }); const updatedCredential = await Container.get(CredentialsHelper).getCredentials( credential, From 565c7b8b9cfd3e10f6a2c60add96fea4c4d95d33 Mon Sep 17 00:00:00 2001 From: Stanimira Rikova <104592468+Stamsy@users.noreply.github.com> Date: Tue, 21 Jan 2025 16:30:26 +0200 Subject: [PATCH 11/28] feat: Add SSM endpoint to AWS credentials (#12212) --- .../nodes-base/credentials/Aws.credentials.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/nodes-base/credentials/Aws.credentials.ts b/packages/nodes-base/credentials/Aws.credentials.ts index 9e968fdce5..3d5b37c6f2 100644 --- a/packages/nodes-base/credentials/Aws.credentials.ts +++ b/packages/nodes-base/credentials/Aws.credentials.ts @@ -138,6 +138,7 @@ export type AwsCredentialsType = { sesEndpoint?: string; sqsEndpoint?: string; s3Endpoint?: string; + ssmEndpoint?: string; }; // Some AWS services are global and don't have a region @@ -294,6 +295,19 @@ export class Aws implements ICredentialType { default: '', placeholder: 'https://s3.{region}.amazonaws.com', }, + { + displayName: 'SSM Endpoint', + name: 'ssmEndpoint', + description: 'Endpoint for AWS Systems Manager (SSM)', + type: 'string', + displayOptions: { + show: { + customEndpoints: [true], + }, + }, + default: '', + placeholder: 'https://ssm.{region}.amazonaws.com', + }, ]; async authenticate( @@ -356,6 +370,8 @@ export class Aws implements ICredentialType { endpointString = credentials.sqsEndpoint; } else if (service) { endpointString = `https://${service}.${region}.amazonaws.com`; + } else if (service === 'ssm' && credentials.ssmEndpoint) { + endpointString = credentials.ssmEndpoint; } endpoint = new URL(endpointString!.replace('{region}', region) + path); } else { From 96ab5bc7e645cc1bd52e1e4b88295fc96f065c76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Tue, 21 Jan 2025 17:53:59 +0100 Subject: [PATCH 12/28] ci: Disable task runner for core and nodes tests (#12757) --- .../__tests__/workflow-execute.test.ts | 11 ++++------- packages/nodes-base/test/setup.ts | 4 ++++ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts b/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts index 6faa595432..c5924e26f6 100644 --- a/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts +++ b/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts @@ -1,3 +1,7 @@ +// Disable task runners until we have fixed the "run test workflows" test +// to mock the Code Node execution +process.env.N8N_RUNNERS_ENABLED = 'false'; + // NOTE: Diagrams in this file have been created with https://asciiflow.com/#/ // If you update the tests, please update the diagrams as well. // If you add a test, please create a new diagram. @@ -9,8 +13,6 @@ // XX denotes that the node is disabled // PD denotes that the node has pinned data -import { TaskRunnersConfig } from '@n8n/config'; -import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import { pick } from 'lodash'; import type { @@ -50,11 +52,6 @@ import { WorkflowExecute } from '../workflow-execute'; const nodeTypes = Helpers.NodeTypes(); describe('WorkflowExecute', () => { - const taskRunnersConfig = Container.get(TaskRunnersConfig); - // Disable task runners until we have fixed the "run test workflows" test - // to mock the Code Node execution - taskRunnersConfig.enabled = false; - describe('v0 execution order', () => { const tests: WorkflowTestData[] = legacyWorkflowExecuteTests; diff --git a/packages/nodes-base/test/setup.ts b/packages/nodes-base/test/setup.ts index d2c9bc6e64..7ee571837c 100644 --- a/packages/nodes-base/test/setup.ts +++ b/packages/nodes-base/test/setup.ts @@ -1 +1,5 @@ import 'reflect-metadata'; + +// Disable task runners until we have fixed the "run test workflows" test +// to mock the Code Node execution +process.env.N8N_RUNNERS_ENABLED = 'false'; From 97e651433b77bcbdd979fa34bf6632812f656291 Mon Sep 17 00:00:00 2001 From: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Date: Wed, 22 Jan 2025 08:52:02 +0200 Subject: [PATCH 13/28] test: Set `N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=false` in nodes tests (#12762) --- packages/nodes-base/test/setup.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/nodes-base/test/setup.ts b/packages/nodes-base/test/setup.ts index 7ee571837c..ff0fa69274 100644 --- a/packages/nodes-base/test/setup.ts +++ b/packages/nodes-base/test/setup.ts @@ -3,3 +3,4 @@ import 'reflect-metadata'; // Disable task runners until we have fixed the "run test workflows" test // to mock the Code Node execution process.env.N8N_RUNNERS_ENABLED = 'false'; +process.env.N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS = 'false'; From a39b8bd32be50c8323e415f820b25b4bcb81d960 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 22 Jan 2025 09:00:17 +0100 Subject: [PATCH 14/28] fix(core): Recover successful data-less executions (#12720) --- .../execution-recovery.service.test.ts | 41 +++++++++++++++++-- .../executions/execution-recovery.service.ts | 2 +- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/executions/__tests__/execution-recovery.service.test.ts b/packages/cli/src/executions/__tests__/execution-recovery.service.test.ts index 9cb681a7ef..20abce70cd 100644 --- a/packages/cli/src/executions/__tests__/execution-recovery.service.test.ts +++ b/packages/cli/src/executions/__tests__/execution-recovery.service.test.ts @@ -3,6 +3,7 @@ import { stringify } from 'flatted'; import { mock } from 'jest-mock-extended'; import { InstanceSettings } from 'n8n-core'; import { randomInt } from 'n8n-workflow'; +import assert from 'node:assert'; import { ARTIFICIAL_TASK_DATA } from '@/constants'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; @@ -127,12 +128,15 @@ describe('ExecutionRecoveryService', () => { }); describe('if leader, with 1+ messages', () => { - test('should return `null` if execution succeeded', async () => { + test('for successful dataful execution, should return `null`', async () => { /** * Arrange */ const workflow = await createWorkflow(); - const execution = await createExecution({ status: 'success' }, workflow); + const execution = await createExecution( + { status: 'success', data: stringify({ runData: { foo: 'bar' } }) }, + workflow, + ); const messages = setupMessages(execution.id, 'Some workflow'); /** @@ -170,7 +174,38 @@ describe('ExecutionRecoveryService', () => { expect(amendedExecution).toBeNull(); }); - test('should update `status`, `stoppedAt` and `data` if last node did not finish', async () => { + test('for successful dataless execution, should update `status`, `stoppedAt` and `data`', async () => { + /** + * Arrange + */ + const workflow = await createWorkflow(); + const execution = await createExecution( + { + status: 'success', + data: stringify(undefined), // saved execution but likely crashed while saving high-volume data + }, + workflow, + ); + const messages = setupMessages(execution.id, 'Some workflow'); + + /** + * Act + */ + const amendedExecution = await executionRecoveryService.recoverFromLogs( + execution.id, + messages, + ); + + /** + * Assert + */ + assert(amendedExecution); + expect(amendedExecution.stoppedAt).not.toBe(execution.stoppedAt); + expect(amendedExecution.data).toEqual({ resultData: { runData: {} } }); + expect(amendedExecution.status).toBe('crashed'); + }); + + test('for running execution, should update `status`, `stoppedAt` and `data` if last node did not finish', async () => { /** * Arrange */ diff --git a/packages/cli/src/executions/execution-recovery.service.ts b/packages/cli/src/executions/execution-recovery.service.ts index bb759eae2c..f3cae91967 100644 --- a/packages/cli/src/executions/execution-recovery.service.ts +++ b/packages/cli/src/executions/execution-recovery.service.ts @@ -73,7 +73,7 @@ export class ExecutionRecoveryService { unflattenData: true, }); - if (!execution || execution.status === 'success') return null; + if (!execution || (execution.status === 'success' && execution.data)) return null; const runExecutionData = execution.data ?? { resultData: { runData: {} } }; From 024ada822c1bc40958e594bb08707cf77d3397ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 22 Jan 2025 09:00:39 +0100 Subject: [PATCH 15/28] fix(core): Fix license CLI commands showing incorrect renewal setting (#12759) --- packages/cli/src/__tests__/license.test.ts | 16 +++++++++++++++- packages/cli/src/commands/license/clear.ts | 2 +- packages/cli/src/commands/license/info.ts | 2 +- packages/cli/src/license.ts | 14 ++++++++++---- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/__tests__/license.test.ts b/packages/cli/src/__tests__/license.test.ts index d48c361dcc..0e26d0d81c 100644 --- a/packages/cli/src/__tests__/license.test.ts +++ b/packages/cli/src/__tests__/license.test.ts @@ -251,6 +251,20 @@ describe('License', () => { expect(LicenseManager).toHaveBeenCalledWith(expect.objectContaining(expectedRenewalSettings)); }); + + it('when CLI command with N8N_LICENSE_AUTO_RENEW_ENABLED=true, should enable renewal', async () => { + const globalConfig = mock({ + license: { ...licenseConfig, autoRenewalEnabled: true }, + }); + + await new License(mockLogger(), mock(), mock(), mock(), globalConfig).init({ + isCli: true, + }); + + expect(LicenseManager).toHaveBeenCalledWith( + expect.objectContaining({ autoRenewEnabled: true, renewOnInit: true }), + ); + }); }); describe('reinit', () => { @@ -262,7 +276,7 @@ describe('License', () => { await license.reinit(); - expect(initSpy).toHaveBeenCalledWith(true); + expect(initSpy).toHaveBeenCalledWith({ forceRecreate: true }); expect(LicenseManager.prototype.reset).toHaveBeenCalled(); expect(LicenseManager.prototype.initialize).toHaveBeenCalled(); diff --git a/packages/cli/src/commands/license/clear.ts b/packages/cli/src/commands/license/clear.ts index 03a2ea4dd4..732401ed47 100644 --- a/packages/cli/src/commands/license/clear.ts +++ b/packages/cli/src/commands/license/clear.ts @@ -16,7 +16,7 @@ export class ClearLicenseCommand extends BaseCommand { // Attempt to invoke shutdown() to force any floating entitlements to be released const license = Container.get(License); - await license.init(); + await license.init({ isCli: true }); try { await license.shutdown(); } catch { diff --git a/packages/cli/src/commands/license/info.ts b/packages/cli/src/commands/license/info.ts index cc99e925f7..f99648d0d5 100644 --- a/packages/cli/src/commands/license/info.ts +++ b/packages/cli/src/commands/license/info.ts @@ -11,7 +11,7 @@ export class LicenseInfoCommand extends BaseCommand { async run() { const license = Container.get(License); - await license.init(); + await license.init({ isCli: true }); this.logger.info('Printing license information:\n' + license.getInfo()); } diff --git a/packages/cli/src/license.ts b/packages/cli/src/license.ts index ed9b66a1a6..0b02e7da1a 100644 --- a/packages/cli/src/license.ts +++ b/packages/cli/src/license.ts @@ -43,7 +43,10 @@ export class License { this.logger = this.logger.scoped('license'); } - async init(forceRecreate = false) { + async init({ + forceRecreate = false, + isCli = false, + }: { forceRecreate?: boolean; isCli?: boolean } = {}) { if (this.manager && !forceRecreate) { this.logger.warn('License manager already initialized or shutting down'); return; @@ -73,10 +76,13 @@ export class License { const { isLeader } = this.instanceSettings; const { autoRenewalEnabled } = this.globalConfig.license; + const eligibleToRenew = isCli || isLeader; - const shouldRenew = isLeader && autoRenewalEnabled; + const shouldRenew = eligibleToRenew && autoRenewalEnabled; - if (isLeader && !autoRenewalEnabled) this.logger.warn(LICENSE_RENEWAL_DISABLED_WARNING); + if (eligibleToRenew && !autoRenewalEnabled) { + this.logger.warn(LICENSE_RENEWAL_DISABLED_WARNING); + } try { this.manager = new LicenseManager({ @@ -390,7 +396,7 @@ export class License { async reinit() { this.manager?.reset(); - await this.init(true); + await this.init({ forceRecreate: true }); this.logger.debug('License reinitialized'); } } From 5b29086e2f9b7f638fac4440711f673438e57492 Mon Sep 17 00:00:00 2001 From: mgosal Date: Wed, 22 Jan 2025 10:18:14 +0000 Subject: [PATCH 16/28] feat: Add Miro credential only node (#12746) Co-authored-by: Jonathan Bennetts --- .../credentials/MiroOAuth2Api.credentials.ts | 61 +++++++++++++++++++ .../nodes-base/credentials/icons/Miro.svg | 22 +++++++ packages/nodes-base/package.json | 1 + 3 files changed, 84 insertions(+) create mode 100644 packages/nodes-base/credentials/MiroOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/credentials/icons/Miro.svg diff --git a/packages/nodes-base/credentials/MiroOAuth2Api.credentials.ts b/packages/nodes-base/credentials/MiroOAuth2Api.credentials.ts new file mode 100644 index 0000000000..16e5a0bb7a --- /dev/null +++ b/packages/nodes-base/credentials/MiroOAuth2Api.credentials.ts @@ -0,0 +1,61 @@ +import type { Icon, ICredentialType, INodeProperties } from 'n8n-workflow'; + +export class MiroOAuth2Api implements ICredentialType { + name = 'miroOAuth2Api'; + + extends = ['oAuth2Api']; + + displayName = 'Miro OAuth2 API'; + + documentationUrl = 'miro'; + + icon: Icon = 'file:icons/Miro.svg'; + + httpRequestNode = { + name: 'Miro', + docsUrl: 'https://developers.miro.com/reference/overview', + apiBaseUrl: 'https://api.miro.com/v2/', + }; + + properties: INodeProperties[] = [ + { + displayName: 'Grant Type', + name: 'grantType', + type: 'hidden', + default: 'authorizationCode', + }, + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden', + default: 'https://miro.com/oauth/authorize', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden', + default: 'https://api.miro.com/v1/oauth/token', + required: true, + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden', + default: '', + required: true, + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden', + default: '', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden', + default: 'body', + }, + ]; +} diff --git a/packages/nodes-base/credentials/icons/Miro.svg b/packages/nodes-base/credentials/icons/Miro.svg new file mode 100644 index 0000000000..b5c804f467 --- /dev/null +++ b/packages/nodes-base/credentials/icons/Miro.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 309d93c310..0f67a3657f 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -231,6 +231,7 @@ "dist/credentials/MicrosoftToDoOAuth2Api.credentials.js", "dist/credentials/MindeeInvoiceApi.credentials.js", "dist/credentials/MindeeReceiptApi.credentials.js", + "dist/credentials/MiroOAuth2Api.credentials.js", "dist/credentials/MispApi.credentials.js", "dist/credentials/MistApi.credentials.js", "dist/credentials/MoceanApi.credentials.js", From 4f00d7cfe4715524aa52a442ffd161f4d590ce11 Mon Sep 17 00:00:00 2001 From: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Date: Wed, 22 Jan 2025 13:15:12 +0200 Subject: [PATCH 17/28] refactor: Disable task runner by default (#12776) --- packages/@n8n/config/src/configs/runners.config.ts | 2 +- packages/@n8n/config/test/config.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@n8n/config/src/configs/runners.config.ts b/packages/@n8n/config/src/configs/runners.config.ts index 2fa8cd6bc1..7e192b1be6 100644 --- a/packages/@n8n/config/src/configs/runners.config.ts +++ b/packages/@n8n/config/src/configs/runners.config.ts @@ -10,7 +10,7 @@ export type TaskRunnerMode = 'internal' | 'external'; @Config export class TaskRunnersConfig { @Env('N8N_RUNNERS_ENABLED') - enabled: boolean = true; + enabled: boolean = false; @Env('N8N_RUNNERS_MODE') mode: TaskRunnerMode = 'internal'; diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index 7c891f73d5..d9499d7849 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -221,7 +221,7 @@ describe('GlobalConfig', () => { }, }, taskRunners: { - enabled: true, + enabled: false, mode: 'internal', path: '/runners', authToken: '', From 819ebd058d1d60b3663d92b4a652728da7134a3b Mon Sep 17 00:00:00 2001 From: jeanpaul Date: Wed, 22 Jan 2025 12:16:01 +0100 Subject: [PATCH 18/28] fix(editor): Add unicode code points to expression language for emoji (#12633) --- .../src/expressions/expressions.grammar | 2 +- .../codemirror-lang/src/expressions/grammar.ts | 2 +- .../codemirror-lang/test/expressions/cases.txt | 16 ++++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/@n8n/codemirror-lang/src/expressions/expressions.grammar b/packages/@n8n/codemirror-lang/src/expressions/expressions.grammar index 9217f2c2fb..6e9efee787 100644 --- a/packages/@n8n/codemirror-lang/src/expressions/expressions.grammar +++ b/packages/@n8n/codemirror-lang/src/expressions/expressions.grammar @@ -15,7 +15,7 @@ entity { Plaintext | Resolvable } resolvableChar { unicodeChar | "}" ![}] | "\\}}" } - unicodeChar { $[\u0000-\u007C] | $[\u007E-\u20CF] | $[\u{1F300}-\u{1F64F}] | $[\u4E00-\u9FFF] } + unicodeChar { $[\u0000-\u007C] | $[\u007E-\u20CF] | $[\u{1F300}-\u{1FAF8}] | $[\u4E00-\u9FFF] } } @detectDelim diff --git a/packages/@n8n/codemirror-lang/src/expressions/grammar.ts b/packages/@n8n/codemirror-lang/src/expressions/grammar.ts index bd081b4832..fc3a2c9e31 100644 --- a/packages/@n8n/codemirror-lang/src/expressions/grammar.ts +++ b/packages/@n8n/codemirror-lang/src/expressions/grammar.ts @@ -10,7 +10,7 @@ export const parser = LRParser.deserialize({ skippedNodes: [0], repeatNodeCount: 1, tokenData: - "&U~RTO#ob#o#p!h#p;'Sb;'S;=`!]<%lOb~gTQ~O#ob#o#pv#p;'Sb;'S;=`!]<%lOb~yUO#ob#p;'Sb;'S;=`!]<%l~b~Ob~~!c~!`P;=`<%lb~!hOQ~~!kVO#ob#o#p#Q#p;'Sb;'S;=`!]<%l~b~Ob~~!c~#TWO#O#Q#O#P#m#P#q#Q#q#r%Z#r$Ml#Q*5S41d#Q;(b;(c%x;(c;(d&O~#pWO#O#Q#O#P#m#P#q#Q#q#r$Y#r$Ml#Q*5S41d#Q;(b;(c%x;(c;(d&O~$]TO#q#Q#q#r$l#r;'S#Q;'S;=`%r<%lO#Q~$qWR~O#O#Q#O#P#m#P#q#Q#q#r%Z#r$Ml#Q*5S41d#Q;(b;(c%x;(c;(d&O~%^TO#q#Q#q#r%m#r;'S#Q;'S;=`%r<%lO#Q~%rOR~~%uP;=`<%l#Q~%{P;NQ<%l#Q~&RP;=`;JY#Q", + "&_~RTO#ob#o#p!h#p;'Sb;'S;=`!]<%lOb~gTQ~O#ob#o#pv#p;'Sb;'S;=`!]<%lOb~yUO#ob#p;'Sb;'S;=`!]<%l~b~Ob~~!c~!`P;=`<%lb~!hOQ~~!kVO#ob#o#p#Q#p;'Sb;'S;=`!]<%l~b~Ob~~!c~#TXO#O#Q#O#P#p#P#q#Q#q#r%d#r$Ml#Q*5S41d#Q;(b;(c&R;(c;(d%{;(d;(e&X~#sXO#O#Q#O#P#p#P#q#Q#q#r$`#r$Ml#Q*5S41d#Q;(b;(c&R;(c;(d%{;(d;(e&X~$cTO#q#Q#q#r$r#r;'S#Q;'S;=`%{<%lO#Q~$wXR~O#O#Q#O#P#p#P#q#Q#q#r%d#r$Ml#Q*5S41d#Q;(b;(c&R;(c;(d%{;(d;(e&X~%gTO#q#Q#q#r%v#r;'S#Q;'S;=`%{<%lO#Q~%{OR~~&OP;=`<%l#Q~&UP;NQ<%l#Q~&[P;=`;My#Q", tokenizers: [0], topRules: { Program: [0, 1] }, tokenPrec: 0, diff --git a/packages/@n8n/codemirror-lang/test/expressions/cases.txt b/packages/@n8n/codemirror-lang/test/expressions/cases.txt index 36f41ddccd..37db1e0bc0 100644 --- a/packages/@n8n/codemirror-lang/test/expressions/cases.txt +++ b/packages/@n8n/codemirror-lang/test/expressions/cases.txt @@ -277,3 +277,19 @@ Program(Resolvable) ==> Program(Resolvable) + +# Resolvable with new emoji range + +{{ '🟢' }} + +==> + +Program(Resolvable) + +# Resolvable with new emoji range end of range + +{{ '🫸' }} + +==> + +Program(Resolvable) From 46bd58cdfe0d1269881b2a77383902bfacbd7a13 Mon Sep 17 00:00:00 2001 From: Sumin Hong <35161069+suminhong@users.noreply.github.com> Date: Wed, 22 Jan 2025 20:20:18 +0900 Subject: [PATCH 19/28] chore: Fix typo in Jenkins credential (no-changelog) (#12242) --- packages/nodes-base/credentials/JenkinsApi.credentials.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/credentials/JenkinsApi.credentials.ts b/packages/nodes-base/credentials/JenkinsApi.credentials.ts index 5c8166be21..cac3bbcc01 100644 --- a/packages/nodes-base/credentials/JenkinsApi.credentials.ts +++ b/packages/nodes-base/credentials/JenkinsApi.credentials.ts @@ -9,7 +9,7 @@ export class JenkinsApi implements ICredentialType { properties: INodeProperties[] = [ { - displayName: 'Jenking Username', + displayName: 'Jenkins Username', name: 'username', type: 'string', default: '', From 9062d5040bed08b95c8003b607aedfbef3e5f31d Mon Sep 17 00:00:00 2001 From: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Date: Wed, 22 Jan 2025 13:25:10 +0200 Subject: [PATCH 20/28] chore: Update bug report template (#12774) --- .../src/composables/__snapshots__/useBugReporting.test.ts.snap | 2 +- packages/editor-ui/src/composables/useBugReporting.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/editor-ui/src/composables/__snapshots__/useBugReporting.test.ts.snap b/packages/editor-ui/src/composables/__snapshots__/useBugReporting.test.ts.snap index 1060a9a9fa..f073af549e 100644 --- a/packages/editor-ui/src/composables/__snapshots__/useBugReporting.test.ts.snap +++ b/packages/editor-ui/src/composables/__snapshots__/useBugReporting.test.ts.snap @@ -1,3 +1,3 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`useBugReporting > should generate the correct reporting URL 1`] = `"https://github.com/n8n-io/n8n/issues/new?labels=bug-report&body=%0A%3C%21--+Please+follow+the+template+below.+Skip+the+questions+that+are+not+relevant+to+you.+--%3E%0A%0A%23%23+Describe+the+problem%2Ferror%2Fquestion%0A%0A%0A%23%23+What+is+the+error+message+%28if+any%29%3F%0A%0A%0A%23%23+Please+share+your+workflow%2Fscreenshots%2Frecording%0A%0A%60%60%60%0A%28Select+the+nodes+on+your+canvas+and+use+the+keyboard+shortcuts+CMD%2BC%2FCTRL%2BC+and+CMD%2BV%2FCTRL%2BV+to+copy+and+paste+the+workflow.%29%0A%60%60%60%0A%0A%0A%23%23+Share+the+output+returned+by+the+last+node%0A%3C%21--+If+you+need+help+with+data+transformations%2C+please+also+share+your+expected+output.+--%3E%0A%0A%0Amocked+debug+info%7D"`; +exports[`useBugReporting > should generate the correct reporting URL 1`] = `"https://github.com/n8n-io/n8n/issues/new?labels=bug-report&body=%0A%3C%21--+Please+follow+the+template+below.+Skip+the+questions+that+are+not+relevant+to+you.+--%3E%0A%0A%23%23+Describe+the+problem%2Ferror%2Fquestion%0A%0A%0A%23%23+What+is+the+error+message+%28if+any%29%3F%0A%0A%0A%23%23+Please+share+your+workflow%2Fscreenshots%2Frecording%0A%0A%60%60%60%0A%28Select+the+nodes+on+your+canvas+and+use+the+keyboard+shortcuts+CMD%2BC%2FCTRL%2BC+and+CMD%2BV%2FCTRL%2BV+to+copy+and+paste+the+workflow.%29%0A%E2%9A%A0%EF%B8%8F+WARNING+%E2%9A%A0%EF%B8%8F+If+you+have+sensitive+data+in+your+workflow+%28like+API+keys%29%2C+please+remove+it+before+sharing.%0A%60%60%60%0A%0A%0A%23%23+Share+the+output+returned+by+the+last+node%0A%3C%21--+If+you+need+help+with+data+transformations%2C+please+also+share+your+expected+output.+--%3E%0A%0A%0Amocked+debug+info%7D"`; diff --git a/packages/editor-ui/src/composables/useBugReporting.ts b/packages/editor-ui/src/composables/useBugReporting.ts index 48c9f621f7..739dd4832b 100644 --- a/packages/editor-ui/src/composables/useBugReporting.ts +++ b/packages/editor-ui/src/composables/useBugReporting.ts @@ -15,6 +15,7 @@ const REPORT_TEMPLATE = ` \`\`\` (Select the nodes on your canvas and use the keyboard shortcuts CMD+C/CTRL+C and CMD+V/CTRL+V to copy and paste the workflow.) +⚠️ WARNING ⚠️ If you have sensitive data in your workflow (like API keys), please remove it before sharing. \`\`\` From 69c215327977ea247b5cecbf7e67e4f3dde1843c Mon Sep 17 00:00:00 2001 From: Justin Ellingwood Date: Wed, 22 Jan 2025 11:38:24 +0000 Subject: [PATCH 21/28] docs: Update benchmark README file to fix secenario filter (no-changelog) (#11680) --- packages/@n8n/benchmark/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@n8n/benchmark/README.md b/packages/@n8n/benchmark/README.md index af8726d0ea..70c20f9731 100644 --- a/packages/@n8n/benchmark/README.md +++ b/packages/@n8n/benchmark/README.md @@ -27,7 +27,7 @@ docker run ghcr.io/n8n-io/n8n-benchmark:latest run \ --n8nUserPassword=InstanceOwnerPassword \ --vus=5 \ --duration=1m \ - --scenarioFilter SingleWebhook + --scenarioFilter=single-webhook ``` ### Using custom scenarios with the Docker image From 1c7a38f6bab108daa47401cd98c185590bf299a8 Mon Sep 17 00:00:00 2001 From: Gerard de Vries <85509036+GKdeVries@users.noreply.github.com> Date: Wed, 22 Jan 2025 12:43:13 +0100 Subject: [PATCH 22/28] feat(Jira Software Node): Personal Access Token credential type (#11038) --- .../JiraSoftwareServerPatApi.credentials.ts | 47 +++++++++++++++++++ .../nodes-base/nodes/Jira/GenericFunctions.ts | 5 +- packages/nodes-base/nodes/Jira/Jira.node.ts | 36 ++++++++++---- .../nodes-base/nodes/Jira/JiraTrigger.node.ts | 14 ++++++ packages/nodes-base/package.json | 1 + 5 files changed, 92 insertions(+), 11 deletions(-) create mode 100644 packages/nodes-base/credentials/JiraSoftwareServerPatApi.credentials.ts diff --git a/packages/nodes-base/credentials/JiraSoftwareServerPatApi.credentials.ts b/packages/nodes-base/credentials/JiraSoftwareServerPatApi.credentials.ts new file mode 100644 index 0000000000..96dabd5808 --- /dev/null +++ b/packages/nodes-base/credentials/JiraSoftwareServerPatApi.credentials.ts @@ -0,0 +1,47 @@ +import type { + IAuthenticateGeneric, + ICredentialTestRequest, + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + +export class JiraSoftwareServerPatApi implements ICredentialType { + name = 'jiraSoftwareServerPatApi'; + + displayName = 'Jira SW Server (PAT) API'; + + documentationUrl = 'jira'; + + properties: INodeProperties[] = [ + { + displayName: 'Personal Access Token', + name: 'personalAccessToken', + typeOptions: { password: true }, + type: 'string', + default: '', + }, + { + displayName: 'Domain', + name: 'domain', + type: 'string', + default: '', + placeholder: 'https://example.com', + }, + ]; + + authenticate: IAuthenticateGeneric = { + type: 'generic', + properties: { + headers: { + Authorization: '=Bearer {{$credentials.personalAccessToken}}', + }, + }, + }; + + test: ICredentialTestRequest = { + request: { + baseURL: '={{$credentials?.domain}}', + url: '/rest/api/2/project', + }, + }; +} diff --git a/packages/nodes-base/nodes/Jira/GenericFunctions.ts b/packages/nodes-base/nodes/Jira/GenericFunctions.ts index b5ea100ca1..129b78ad73 100644 --- a/packages/nodes-base/nodes/Jira/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Jira/GenericFunctions.ts @@ -28,6 +28,9 @@ export async function jiraSoftwareCloudApiRequest( if (jiraVersion === 'server') { domain = (await this.getCredentials('jiraSoftwareServerApi')).domain as string; credentialType = 'jiraSoftwareServerApi'; + } else if (jiraVersion === 'serverPat') { + domain = (await this.getCredentials('jiraSoftwareServerPatApi')).domain as string; + credentialType = 'jiraSoftwareServerPatApi'; } else { domain = (await this.getCredentials('jiraSoftwareCloudApi')).domain as string; credentialType = 'jiraSoftwareCloudApi'; @@ -233,7 +236,7 @@ export async function getUsers(this: ILoadOptionsFunctions): Promise Date: Wed, 22 Jan 2025 13:00:06 +0100 Subject: [PATCH 23/28] :rocket: Release 1.76.0 (#12779) Co-authored-by: netroy <196144+netroy@users.noreply.github.com> --- CHANGELOG.md | 52 ++++++++++++++++++++++ package.json | 2 +- packages/@n8n/benchmark/package.json | 2 +- packages/@n8n/config/package.json | 2 +- packages/@n8n/nodes-langchain/package.json | 2 +- packages/@n8n/task-runner/package.json | 2 +- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/design-system/package.json | 2 +- packages/editor-ui/package.json | 2 +- packages/node-dev/package.json | 2 +- packages/nodes-base/package.json | 2 +- packages/workflow/package.json | 2 +- 13 files changed, 64 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1629f7ec29..92770f1598 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,55 @@ +# [1.76.0](https://github.com/n8n-io/n8n/compare/n8n@1.75.0...n8n@1.76.0) (2025-01-22) + + +### Bug Fixes + +* **core:** Align saving behavior in `workflowExecuteAfter` hooks ([#12731](https://github.com/n8n-io/n8n/issues/12731)) ([9d76210](https://github.com/n8n-io/n8n/commit/9d76210a570e025d01d1f6596667abf40fbd8d12)) +* **core:** AugmentObject should handle the constructor property correctly ([#12744](https://github.com/n8n-io/n8n/issues/12744)) ([36bc164](https://github.com/n8n-io/n8n/commit/36bc164da486f2e2d05091b457b8eea6521ca22e)) +* **core:** Fix keyboard shortcuts for non-ansi layouts ([#12672](https://github.com/n8n-io/n8n/issues/12672)) ([4c8193f](https://github.com/n8n-io/n8n/commit/4c8193fedc2e3967c9a06c0652483128df509653)) +* **core:** Fix license CLI commands showing incorrect renewal setting ([#12759](https://github.com/n8n-io/n8n/issues/12759)) ([024ada8](https://github.com/n8n-io/n8n/commit/024ada822c1bc40958e594bb08707cf77d3397ec)) +* **core:** Fix license initialization failure on startup ([#12737](https://github.com/n8n-io/n8n/issues/12737)) ([ac2f647](https://github.com/n8n-io/n8n/commit/ac2f6476c114f51fafb9b7b66e41e0c87f4a1bf6)) +* **core:** Recover successful data-less executions ([#12720](https://github.com/n8n-io/n8n/issues/12720)) ([a39b8bd](https://github.com/n8n-io/n8n/commit/a39b8bd32be50c8323e415f820b25b4bcb81d960)) +* **core:** Remove run data of utility nodes for partial executions v2 ([#12673](https://github.com/n8n-io/n8n/issues/12673)) ([b66a9dc](https://github.com/n8n-io/n8n/commit/b66a9dc8fb6f7b19122cacbb7e2f86b4c921c3fb)) +* **core:** Sync `hookFunctionsSave` and `hookFunctionsSaveWorker` ([#12740](https://github.com/n8n-io/n8n/issues/12740)) ([d410b8f](https://github.com/n8n-io/n8n/commit/d410b8f5a7e99658e1e8dcb2e02901bd01ce9c59)) +* **core:** Update isDocker check to return true on kubernetes/containerd ([#12603](https://github.com/n8n-io/n8n/issues/12603)) ([c55dac6](https://github.com/n8n-io/n8n/commit/c55dac66ed97a2317d4c696c3b505790ec5d72fe)) +* **editor:** Add unicode code points to expression language for emoji ([#12633](https://github.com/n8n-io/n8n/issues/12633)) ([819ebd0](https://github.com/n8n-io/n8n/commit/819ebd058d1d60b3663d92b4a652728da7134a3b)) +* **editor:** Correct missing whitespace in JSON output ([#12677](https://github.com/n8n-io/n8n/issues/12677)) ([b098b19](https://github.com/n8n-io/n8n/commit/b098b19c7f0e3a9848c3fcfa012999050f2d3c7a)) +* **editor:** Defer crypto.randomUUID call in CodeNodeEditor ([#12630](https://github.com/n8n-io/n8n/issues/12630)) ([58f6532](https://github.com/n8n-io/n8n/commit/58f6532630bacd288d3c0a79b40150f465898419)) +* **editor:** Fix Code node bug erasing and overwriting code when switching between nodes ([#12637](https://github.com/n8n-io/n8n/issues/12637)) ([02d953d](https://github.com/n8n-io/n8n/commit/02d953db34ec4e44977a8ca908628b62cca82fde)) +* **editor:** Fix execution list hover & selection colour in dark mode ([#12628](https://github.com/n8n-io/n8n/issues/12628)) ([95c40c0](https://github.com/n8n-io/n8n/commit/95c40c02cb8fef77cf633cf5aec08e98746cff36)) +* **editor:** Fix JsonEditor with expressions ([#12739](https://github.com/n8n-io/n8n/issues/12739)) ([56c93ca](https://github.com/n8n-io/n8n/commit/56c93caae026738c1c0bebb4187b238e34a330f6)) +* **editor:** Fix navbar height flickering during load ([#12738](https://github.com/n8n-io/n8n/issues/12738)) ([a96b3f0](https://github.com/n8n-io/n8n/commit/a96b3f0091798a52bb33107b919b5d8287ba7506)) +* **editor:** Open chat when executing agent node in canvas v2 ([#12617](https://github.com/n8n-io/n8n/issues/12617)) ([457edd9](https://github.com/n8n-io/n8n/commit/457edd99bb853d8ccf3014605d5823933f3c0bc6)) +* **editor:** Partial execution of a workflow with manual chat trigger ([#12662](https://github.com/n8n-io/n8n/issues/12662)) ([2f81b29](https://github.com/n8n-io/n8n/commit/2f81b29d341535b512df0aa01b25a91d109f113f)) +* **editor:** Show connector label above the line when it's straight ([#12622](https://github.com/n8n-io/n8n/issues/12622)) ([c97bd48](https://github.com/n8n-io/n8n/commit/c97bd48a77643b9c2a5d7218e21b957af15cee0b)) +* **editor:** Show run workflow button when chat trigger has pinned data ([#12616](https://github.com/n8n-io/n8n/issues/12616)) ([da8aafc](https://github.com/n8n-io/n8n/commit/da8aafc0e3a1b5d862f0723d0d53d2c38bcaebc3)) +* **editor:** Update workflow re-initialization to use query parameter ([#12650](https://github.com/n8n-io/n8n/issues/12650)) ([982131a](https://github.com/n8n-io/n8n/commit/982131a75a32f741c120156826c303989aac189c)) +* **Execute Workflow Node:** Pass binary data to sub-workflow ([#12635](https://github.com/n8n-io/n8n/issues/12635)) ([e9c152e](https://github.com/n8n-io/n8n/commit/e9c152e369a4c2762bd8e6ad17eaa704bb3771bb)) +* **Google Gemini Chat Model Node:** Add base URL support for Google Gemini Chat API ([#12643](https://github.com/n8n-io/n8n/issues/12643)) ([14f4bc7](https://github.com/n8n-io/n8n/commit/14f4bc769027789513808b4000444edf99dc5d1c)) +* **GraphQL Node:** Change default request format to json instead of graphql ([#11346](https://github.com/n8n-io/n8n/issues/11346)) ([c7c122f](https://github.com/n8n-io/n8n/commit/c7c122f9173df824cc1b5ab864333bffd0d31f82)) +* **Jira Software Node:** Get custom fields(RLC) in update operation for server deployment type ([#12719](https://github.com/n8n-io/n8n/issues/12719)) ([353df79](https://github.com/n8n-io/n8n/commit/353df7941117e20547cd4f3fc514979a54619720)) +* **n8n Form Node:** Remove the ability to change the formatting of dates ([#12666](https://github.com/n8n-io/n8n/issues/12666)) ([14904ff](https://github.com/n8n-io/n8n/commit/14904ff77951fef23eb789a43947492a4cd3fa20)) +* **OpenAI Chat Model Node:** Fix loading of custom models when using custom credential URL ([#12634](https://github.com/n8n-io/n8n/issues/12634)) ([7cc553e](https://github.com/n8n-io/n8n/commit/7cc553e3b277a16682bfca1ea08cb98178e38580)) +* **OpenAI Chat Model Node:** Restore default model value ([#12745](https://github.com/n8n-io/n8n/issues/12745)) ([d1b6692](https://github.com/n8n-io/n8n/commit/d1b6692736182fa2eab768ba3ad0adb8504ebbbd)) +* **Postgres Chat Memory Node:** Do not terminate the connection pool ([#12674](https://github.com/n8n-io/n8n/issues/12674)) ([e7f00bc](https://github.com/n8n-io/n8n/commit/e7f00bcb7f2dce66ca07a9322d50f96356c1a43d)) +* **Postgres Node:** Allow using composite key in upsert queries ([#12639](https://github.com/n8n-io/n8n/issues/12639)) ([83ce3a9](https://github.com/n8n-io/n8n/commit/83ce3a90963ba76601234f4314363a8ccc310f0f)) +* **Wait Node:** Fix for hasNextPage in waiting forms ([#12636](https://github.com/n8n-io/n8n/issues/12636)) ([652b8d1](https://github.com/n8n-io/n8n/commit/652b8d170b9624d47b5f2d8d679c165cc14ea548)) + + +### Features + +* Add credential only node for Microsoft Azure Monitor ([#12645](https://github.com/n8n-io/n8n/issues/12645)) ([6ef8882](https://github.com/n8n-io/n8n/commit/6ef8882a108c672ab097c9dd1c590d4e9e7f3bcc)) +* Add Miro credential only node ([#12746](https://github.com/n8n-io/n8n/issues/12746)) ([5b29086](https://github.com/n8n-io/n8n/commit/5b29086e2f9b7f638fac4440711f673438e57492)) +* Add SSM endpoint to AWS credentials ([#12212](https://github.com/n8n-io/n8n/issues/12212)) ([565c7b8](https://github.com/n8n-io/n8n/commit/565c7b8b9cfd3e10f6a2c60add96fea4c4d95d33)) +* **core:** Enable task runner by default ([#12726](https://github.com/n8n-io/n8n/issues/12726)) ([9e2a01a](https://github.com/n8n-io/n8n/commit/9e2a01aeaf36766a1cf7a1d9a4d6e02f45739bd3)) +* **editor:** Force final canvas v2 migration and remove switcher from UI ([#12717](https://github.com/n8n-io/n8n/issues/12717)) ([29335b9](https://github.com/n8n-io/n8n/commit/29335b9b6acf97c817bea70688e8a2786fbd8889)) +* **editor:** VariablesView Reskin - Add Filters for missing values ([#12611](https://github.com/n8n-io/n8n/issues/12611)) ([1eeb788](https://github.com/n8n-io/n8n/commit/1eeb788d327287d21eab7ad6f2156453ab7642c7)) +* **Jira Software Node:** Personal Access Token credential type ([#11038](https://github.com/n8n-io/n8n/issues/11038)) ([1c7a38f](https://github.com/n8n-io/n8n/commit/1c7a38f6bab108daa47401cd98c185590bf299a8)) +* **n8n Form Trigger Node:** Form Improvements ([#12590](https://github.com/n8n-io/n8n/issues/12590)) ([f167578](https://github.com/n8n-io/n8n/commit/f167578b3251e553a4d000e731e1bb60348916ad)) +* Synchronize deletions when pulling from source control ([#12170](https://github.com/n8n-io/n8n/issues/12170)) ([967ee4b](https://github.com/n8n-io/n8n/commit/967ee4b89b94b92fc3955c56bf4c9cca0bd64eac)) + + + # [1.75.0](https://github.com/n8n-io/n8n/compare/n8n@1.74.0...n8n@1.75.0) (2025-01-15) diff --git a/package.json b/package.json index 3e05dba4c0..8e1d8da85a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-monorepo", - "version": "1.75.0", + "version": "1.76.0", "private": true, "engines": { "node": ">=20.15", diff --git a/packages/@n8n/benchmark/package.json b/packages/@n8n/benchmark/package.json index f42bd7c508..606f43a7ba 100644 --- a/packages/@n8n/benchmark/package.json +++ b/packages/@n8n/benchmark/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/n8n-benchmark", - "version": "1.9.0", + "version": "1.10.0", "description": "Cli for running benchmark tests for n8n", "main": "dist/index", "scripts": { diff --git a/packages/@n8n/config/package.json b/packages/@n8n/config/package.json index 31e618dc32..949bafdce5 100644 --- a/packages/@n8n/config/package.json +++ b/packages/@n8n/config/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/config", - "version": "1.25.0", + "version": "1.26.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index 4b741c173b..b93deba806 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/n8n-nodes-langchain", - "version": "1.75.0", + "version": "1.76.0", "description": "", "main": "index.js", "scripts": { diff --git a/packages/@n8n/task-runner/package.json b/packages/@n8n/task-runner/package.json index 880308d968..ec53c53dcd 100644 --- a/packages/@n8n/task-runner/package.json +++ b/packages/@n8n/task-runner/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/task-runner", - "version": "1.13.0", + "version": "1.14.0", "scripts": { "clean": "rimraf dist .turbo", "start": "node dist/start.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index 229e3d20d5..15511d5ef8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.75.0", + "version": "1.76.0", "description": "n8n Workflow Automation Tool", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/packages/core/package.json b/packages/core/package.json index a4a0e651a3..ec0c5e3c6c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "1.75.0", + "version": "1.76.0", "description": "Core functionality of n8n", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 7bac94c405..a4c97de675 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -1,6 +1,6 @@ { "name": "n8n-design-system", - "version": "1.65.0", + "version": "1.66.0", "main": "src/main.ts", "import": "src/main.ts", "scripts": { diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 8f5bbe408b..83a84ded1e 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "1.75.0", + "version": "1.76.0", "description": "Workflow Editor UI for n8n", "main": "index.js", "scripts": { diff --git a/packages/node-dev/package.json b/packages/node-dev/package.json index 47b3b0b892..671708f99f 100644 --- a/packages/node-dev/package.json +++ b/packages/node-dev/package.json @@ -1,6 +1,6 @@ { "name": "n8n-node-dev", - "version": "1.75.0", + "version": "1.76.0", "description": "CLI to simplify n8n credentials/node development", "main": "dist/src/index", "types": "dist/src/index.d.ts", diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index a92406c8b6..c217712316 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-base", - "version": "1.75.0", + "version": "1.76.0", "description": "Base nodes of n8n", "main": "index.js", "scripts": { diff --git a/packages/workflow/package.json b/packages/workflow/package.json index 7515af68ee..3e5cfdcc2a 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -1,6 +1,6 @@ { "name": "n8n-workflow", - "version": "1.74.0", + "version": "1.75.0", "description": "Workflow base code of n8n", "main": "dist/index.js", "module": "src/index.ts", From ba8aa3921613c590caaac627fbb9837ccaf87783 Mon Sep 17 00:00:00 2001 From: Dana <152518854+dana-gill@users.noreply.github.com> Date: Wed, 22 Jan 2025 13:05:30 +0100 Subject: [PATCH 24/28] feat(n8n Form Node): Add read-only/custom HTML form elements (#12760) --- .../cli/templates/form-trigger.handlebars | 6 ++ .../nodes/Form/common.descriptions.ts | 55 +++++++++++++++++-- .../nodes-base/nodes/Form/test/utils.test.ts | 40 ++++++++++++++ packages/nodes-base/nodes/Form/utils.ts | 31 ++++++++++- packages/workflow/src/Interfaces.ts | 1 + 5 files changed, 126 insertions(+), 7 deletions(-) diff --git a/packages/cli/templates/form-trigger.handlebars b/packages/cli/templates/form-trigger.handlebars index 695ef79472..6bad1a02d8 100644 --- a/packages/cli/templates/form-trigger.handlebars +++ b/packages/cli/templates/form-trigger.handlebars @@ -371,6 +371,12 @@ {{/if}} + {{#if isHtml}} +
+ {{{html}}} +
+ {{/if}} + {{#if isTextarea}}
diff --git a/packages/nodes-base/nodes/Form/common.descriptions.ts b/packages/nodes-base/nodes/Form/common.descriptions.ts index dc0d8bf076..67cf3515d1 100644 --- a/packages/nodes-base/nodes/Form/common.descriptions.ts +++ b/packages/nodes-base/nodes/Form/common.descriptions.ts @@ -2,6 +2,12 @@ import type { INodeProperties } from 'n8n-workflow'; import { appendAttributionOption } from '../../utils/descriptions'; +export const placeholder: string = ` + + + +`.trimStart(); + export const webhookPath: INodeProperties = { displayName: 'Form Path', name: 'path', @@ -36,9 +42,9 @@ export const formDescription: INodeProperties = { }; export const formFields: INodeProperties = { - displayName: 'Form Fields', + displayName: 'Form Elements', name: 'formFields', - placeholder: 'Add Form Field', + placeholder: 'Add Form Element', type: 'fixedCollection', default: { values: [{ label: '', fieldType: 'text' }] }, typeOptions: { @@ -60,12 +66,16 @@ export const formFields: INodeProperties = { required: true, }, { - displayName: 'Field Type', + displayName: 'Element Type', name: 'fieldType', type: 'options', default: 'text', description: 'The type of field to add to the form', options: [ + { + name: 'Custom HTML', + value: 'html', + }, { name: 'Date', value: 'date', @@ -109,7 +119,7 @@ export const formFields: INodeProperties = { default: '', displayOptions: { hide: { - fieldType: ['dropdown', 'date', 'file'], + fieldType: ['dropdown', 'date', 'file', 'html'], }, }, }, @@ -158,6 +168,21 @@ export const formFields: INodeProperties = { }, }, }, + { + displayName: 'HTML Template', + name: 'html', + typeOptions: { + editor: 'htmlEditor', + }, + type: 'string', + default: placeholder, + description: 'HTML template to render', + displayOptions: { + show: { + fieldType: ['html'], + }, + }, + }, { displayName: 'Multiple Files', name: 'multipleFiles', @@ -190,6 +215,23 @@ export const formFields: INodeProperties = { name: 'formatDate', type: 'notice', default: '', + displayOptions: { + show: { + fieldType: ['date'], + }, + }, + }, + { + displayName: + 'Does not accept <style> <script> or <input> tags.', + name: 'htmlTips', + type: 'notice', + default: '', + displayOptions: { + show: { + fieldType: ['html'], + }, + }, }, { displayName: 'Required Field', @@ -198,6 +240,11 @@ export const formFields: INodeProperties = { default: false, description: 'Whether to require the user to enter a value for this field before submitting the form', + displayOptions: { + hide: { + fieldType: ['html'], + }, + }, }, ], }, diff --git a/packages/nodes-base/nodes/Form/test/utils.test.ts b/packages/nodes-base/nodes/Form/test/utils.test.ts index 65decaa285..4d1bf39c36 100644 --- a/packages/nodes-base/nodes/Form/test/utils.test.ts +++ b/packages/nodes-base/nodes/Form/test/utils.test.ts @@ -15,6 +15,7 @@ import { prepareFormReturnItem, resolveRawData, isFormConnected, + sanitizeHtml, } from '../utils'; describe('FormTrigger, parseFormDescription', () => { @@ -42,6 +43,29 @@ describe('FormTrigger, parseFormDescription', () => { }); }); +describe('FormTrigger, sanitizeHtml', () => { + it('should remove forbidden HTML tags', () => { + const givenHtml = [ + { + html: '', + expected: '', + }, + { + html: '', + expected: '', + }, + { + html: '', + expected: '', + }, + ]; + + givenHtml.forEach(({ html, expected }) => { + expect(sanitizeHtml(html)).toBe(expected); + }); + }); +}); + describe('FormTrigger, formWebhook', () => { const executeFunctions = mock(); executeFunctions.getNode.mockReturnValue({ typeVersion: 2.1 } as any); @@ -80,6 +104,12 @@ describe('FormTrigger, formWebhook', () => { acceptFileTypes: '.pdf,.doc', multipleFiles: false, }, + { + fieldLabel: 'Custom HTML', + fieldType: 'html', + html: '
Test HTML
', + requiredField: false, + }, ]; executeFunctions.getNodeParameter.calledWith('formFields.values').mockReturnValue(formFields); @@ -134,6 +164,16 @@ describe('FormTrigger, formWebhook', () => { multipleFiles: '', placeholder: undefined, }, + { + id: 'field-4', + errorId: 'error-field-4', + label: 'Custom HTML', + inputRequired: '', + defaultValue: '', + placeholder: undefined, + html: '
Test HTML
', + isHtml: true, + }, ], formSubmittedText: 'Your response has been recorded', formTitle: 'Test Form', diff --git a/packages/nodes-base/nodes/Form/utils.ts b/packages/nodes-base/nodes/Form/utils.ts index e4b46c72fd..2510be56ac 100644 --- a/packages/nodes-base/nodes/Form/utils.ts +++ b/packages/nodes-base/nodes/Form/utils.ts @@ -24,11 +24,16 @@ import { getResolvables } from '../../utils/utilities'; import { WebhookAuthorizationError } from '../Webhook/error'; import { validateWebhookAuthentication } from '../Webhook/utils'; -function sanitizeHtml(text: string) { +export function sanitizeHtml(text: string) { return sanitize(text, { allowedTags: [ 'b', + 'div', 'i', + 'iframe', + 'img', + 'video', + 'source', 'em', 'strong', 'a', @@ -48,8 +53,18 @@ function sanitizeHtml(text: string) { ], allowedAttributes: { a: ['href', 'target', 'rel'], + img: ['src', 'alt', 'width', 'height'], + video: ['*'], + iframe: ['*'], + source: ['*'], + }, + transformTags: { + iframe: sanitize.simpleTransform('iframe', { + sandbox: '', + referrerpolicy: 'strict-origin-when-cross-origin', + allow: 'fullscreen; autoplay; encrypted-media', + }), }, - nonBooleanAttributes: ['*'], }); } @@ -149,6 +164,9 @@ export function prepareFormData({ input.selectOptions = fieldOptions.map((e) => e.option); } else if (fieldType === 'textarea') { input.isTextarea = true; + } else if (fieldType === 'html') { + input.isHtml = true; + input.html = field.html as string; } else { input.isInput = true; input.type = fieldType as 'text' | 'number' | 'date' | 'email'; @@ -409,7 +427,14 @@ export async function formWebhook( } const mode = context.getMode() === 'manual' ? 'test' : 'production'; - const formFields = context.getNodeParameter('formFields.values', []) as FormFieldsParameter; + const formFields = (context.getNodeParameter('formFields.values', []) as FormFieldsParameter).map( + (field) => { + if (field.fieldType === 'html') { + field.html = sanitizeHtml(field.html as string); + } + return field; + }, + ); const method = context.getRequestObject().method; checkResponseModeConfiguration(context); diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 10fc8e9b11..dd6c0ef1ce 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -2684,6 +2684,7 @@ export type FormFieldsParameter = Array<{ multipleFiles?: boolean; acceptFileTypes?: string; formatDate?: string; + html?: string; placeholder?: string; }>; From 60187cab9bc9d21aa6ba710d772c068324e429f1 Mon Sep 17 00:00:00 2001 From: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Date: Wed, 22 Jan 2025 15:39:02 +0200 Subject: [PATCH 25/28] feat(core): Rename two task runner env vars (#12763) --- packages/@n8n/config/src/configs/runners.config.ts | 8 ++++---- packages/cli/src/task-runners/task-runner-server.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/@n8n/config/src/configs/runners.config.ts b/packages/@n8n/config/src/configs/runners.config.ts index 7e192b1be6..af7e911877 100644 --- a/packages/@n8n/config/src/configs/runners.config.ts +++ b/packages/@n8n/config/src/configs/runners.config.ts @@ -22,12 +22,12 @@ export class TaskRunnersConfig { @Env('N8N_RUNNERS_AUTH_TOKEN') authToken: string = ''; - /** IP address task runners server should listen on */ - @Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT') + /** IP address task runners broker should listen on */ + @Env('N8N_RUNNERS_BROKER_PORT') port: number = 5679; - /** IP address task runners server should listen on */ - @Env('N8N_RUNNERS_SERVER_LISTEN_ADDRESS') + /** IP address task runners broker should listen on */ + @Env('N8N_RUNNERS_BROKER_LISTEN_ADDRESS') listenAddress: string = '127.0.0.1'; /** Maximum size of a payload sent to the runner in bytes, Default 1G */ diff --git a/packages/cli/src/task-runners/task-runner-server.ts b/packages/cli/src/task-runners/task-runner-server.ts index 80679f8c41..cadfd7aadd 100644 --- a/packages/cli/src/task-runners/task-runner-server.ts +++ b/packages/cli/src/task-runners/task-runner-server.ts @@ -100,7 +100,7 @@ export class TaskRunnerServer { this.server.on('error', (error: Error & { code: string }) => { if (error.code === 'EADDRINUSE') { this.logger.info( - `n8n Task Runner's port ${port} is already in use. Do you have another instance of n8n running already?`, + `n8n Task Broker's port ${port} is already in use. Do you have another instance of n8n running already?`, ); process.exit(1); } @@ -111,7 +111,7 @@ export class TaskRunnerServer { this.server.listen(port, address, () => resolve()); }); - this.logger.info(`n8n Task Runner server ready on ${address}, port ${port}`); + this.logger.info(`n8n Task Broker ready on ${address}, port ${port}`); } /** Creates WebSocket server for handling upgrade requests */ From eb4dea1ca891bb7ac07c8bbbae8803de080c4623 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milorad=20FIlipovi=C4=87?= Date: Wed, 22 Jan 2025 14:50:28 +0100 Subject: [PATCH 26/28] fix(editor): Handle large payloads in the AI Assistant requests better (#12747) --- packages/editor-ui/src/api/ai.ts | 12 + .../useAIAssistantHelpers.test.constants.ts | 430 ++++++++++++++++++ .../composables/useAIAssistantHelpers.test.ts | 71 +++ .../src/composables/useAIAssistantHelpers.ts | 62 ++- packages/editor-ui/src/constants.ts | 2 + .../src/plugins/i18n/locales/en.json | 3 +- .../editor-ui/src/stores/assistant.store.ts | 33 +- .../editor-ui/src/types/assistant.types.ts | 2 +- packages/editor-ui/src/utils/apiUtils.test.ts | 50 +- packages/editor-ui/src/utils/apiUtils.ts | 17 +- .../editor-ui/src/utils/objectUtils.test.ts | 61 ++- packages/editor-ui/src/utils/objectUtils.ts | 32 ++ 12 files changed, 751 insertions(+), 24 deletions(-) create mode 100644 packages/editor-ui/src/composables/useAIAssistantHelpers.test.constants.ts diff --git a/packages/editor-ui/src/api/ai.ts b/packages/editor-ui/src/api/ai.ts index 04c08c78f2..a6721d2652 100644 --- a/packages/editor-ui/src/api/ai.ts +++ b/packages/editor-ui/src/api/ai.ts @@ -1,6 +1,9 @@ +import { useAIAssistantHelpers } from '@/composables/useAIAssistantHelpers'; +import { AI_ASSISTANT_MAX_CONTENT_LENGTH } from '@/constants'; import type { ICredentialsResponse, IRestApiContext } from '@/Interface'; import type { AskAiRequest, ChatRequest, ReplaceCodeRequest } from '@/types/assistant.types'; import { makeRestApiRequest, streamRequest } from '@/utils/apiUtils'; +import { getObjectSizeInKB } from '@/utils/objectUtils'; import type { IDataObject } from 'n8n-workflow'; export function chatWithAssistant( @@ -10,6 +13,15 @@ export function chatWithAssistant( onDone: () => void, onError: (e: Error) => void, ): void { + try { + const payloadSize = getObjectSizeInKB(payload.payload); + if (payloadSize > AI_ASSISTANT_MAX_CONTENT_LENGTH) { + useAIAssistantHelpers().trimPayloadSize(payload); + } + } catch (e) { + onError(e); + return; + } void streamRequest( ctx, '/ai/chat', diff --git a/packages/editor-ui/src/composables/useAIAssistantHelpers.test.constants.ts b/packages/editor-ui/src/composables/useAIAssistantHelpers.test.constants.ts new file mode 100644 index 0000000000..f09cf24ff3 --- /dev/null +++ b/packages/editor-ui/src/composables/useAIAssistantHelpers.test.constants.ts @@ -0,0 +1,430 @@ +import { VIEWS } from '@/constants'; +import type { ChatRequest } from '@/types/assistant.types'; +import { NodeConnectionType } from 'n8n-workflow'; + +export const PAYLOAD_SIZE_FOR_1_PASS = 4; +export const PAYLOAD_SIZE_FOR_2_PASSES = 2; + +export const ERROR_HELPER_TEST_PAYLOAD: ChatRequest.RequestPayload = { + payload: { + role: 'user', + type: 'init-error-helper', + user: { + firstName: 'Milorad', + }, + error: { + name: 'NodeOperationError', + message: "Referenced node doesn't exist", + description: + "The node 'Hey' doesn't exist, but it's used in an expression here.", + }, + node: { + position: [0, 0], + parameters: { + mode: 'manual', + duplicateItem: false, + assignments: { + assignments: { + '0': { + id: '0957fbdb-a021-413b-9d42-fc847666f999', + name: 'text', + value: 'Lorem ipsum dolor sit amet', + type: 'string', + }, + '1': { + id: '8efecfa7-8df7-492e-83e7-3d517ad03e60', + name: 'foo', + value: { + value: "={{ $('Hey').json.name }}", + resolvedExpressionValue: 'Error in expression: "Referenced node doesn\'t exist"', + }, + type: 'string', + }, + }, + }, + includeOtherFields: false, + options: {}, + }, + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + id: '6dc70bf3-ba54-4481-b9f5-ce255bdd5fb8', + name: 'This is fine', + }, + executionSchema: [], + }, +}; + +export const SUPPORT_CHAT_TEST_PAYLOAD: ChatRequest.RequestPayload = { + payload: { + role: 'user', + type: 'init-support-chat', + user: { + firstName: 'Milorad', + }, + context: { + currentView: { + name: VIEWS.WORKFLOW, + description: + 'The user is currently looking at the current workflow in n8n editor, without any specific node selected.', + }, + activeNodeInfo: { + node: { + position: [0, 0], + parameters: { + mode: 'manual', + duplicateItem: false, + assignments: { + assignments: { + '0': { + id: '969e86d0-76de-44f6-b07d-44a8a953f564', + name: 'name', + value: { + value: "={{ $('Edit Fields 2').name }}", + resolvedExpressionValue: + 'Error in expression: "Referenced node doesn\'t exist"', + }, + type: 'number', + }, + }, + }, + includeOtherFields: false, + options: {}, + }, + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + id: '8eac1591-ddc6-4d93-bec7-998cbfe27cc7', + name: 'Edit Fields1', + }, + executionStatus: { + status: 'error', + error: { + name: 'NodeOperationError', + message: "Referenced node doesn't exist", + stack: + "NodeOperationError: Referenced node doesn't exist\n at ExecuteContext.execute (/Users/miloradfilipovic/workspace/n8n/packages/nodes-base/nodes/Set/v2/manual.mode.ts:256:9)\n at ExecuteContext.execute (/Users/miloradfilipovic/workspace/n8n/packages/nodes-base/nodes/Set/v2/SetV2.node.ts:351:48)\n at WorkflowExecute.runNode (/Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:1097:31)\n at /Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:1505:38\n at /Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:2066:11", + }, + }, + referencedNodes: [], + }, + currentWorkflow: { + name: '🧪 Assistant context test', + active: false, + connections: { + 'When clicking ‘Test workflow’': { + main: [ + [ + { + node: 'Edit Fields', + type: NodeConnectionType.Main, + index: 0, + }, + ], + ], + }, + 'Edit Fields': { + main: [ + [ + { + node: 'Bad request no chat found', + type: NodeConnectionType.Main, + index: 0, + }, + { + node: 'Slack', + type: NodeConnectionType.Main, + index: 0, + }, + { + node: 'Edit Fields1', + type: NodeConnectionType.Main, + index: 0, + }, + { + node: 'Edit Fields2', + type: NodeConnectionType.Main, + index: 0, + }, + ], + ], + }, + }, + nodes: [ + { + parameters: { + notice: '', + }, + id: 'c457ff96-3b0c-4dbc-b47f-dc88396a46ae', + name: 'When clicking ‘Test workflow’', + type: 'n8n-nodes-base.manualTrigger', + position: [-60, 200], + typeVersion: 1, + }, + { + parameters: { + resource: 'chat', + operation: 'get', + chatId: '13', + }, + id: '60ddc045-d4e3-4b62-9832-12ecf78937a6', + name: 'Bad request no chat found', + type: 'n8n-nodes-base.telegram', + typeVersion: 1.1, + position: [540, 0], + issues: {}, + disabled: true, + }, + { + parameters: { + mode: 'manual', + duplicateItem: false, + assignments: { + assignments: [ + { + id: '70448b12-9b2b-4bfb-abee-6432c4c58de1', + name: 'name', + value: 'Joe', + type: 'string', + }, + ], + }, + includeOtherFields: false, + options: {}, + }, + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + position: [200, 200], + id: '0a831739-13cd-4541-b20b-7db73abbcaf0', + name: 'Edit Fields', + }, + { + parameters: { + authentication: 'oAuth2', + resource: 'channel', + operation: 'archive', + channelId: { + __rl: true, + mode: 'list', + value: '', + }, + }, + type: 'n8n-nodes-base.slack', + typeVersion: 2.2, + position: [540, 200], + id: 'aff7471e-b2bc-4274-abe1-97897a17eaa6', + name: 'Slack', + webhookId: '7f8b574c-7729-4220-bbe9-bf5aa382406a', + credentials: { + slackOAuth2Api: { + id: 'mZRj4wi3gavIzu9b', + name: 'Slack account', + }, + }, + disabled: true, + }, + { + parameters: { + mode: 'manual', + duplicateItem: false, + assignments: { + assignments: [ + { + id: '969e86d0-76de-44f6-b07d-44a8a953f564', + name: 'name', + value: "={{ $('Edit Fields 2').name }}", + type: 'number', + }, + ], + }, + includeOtherFields: false, + options: {}, + }, + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + position: [540, 400], + id: '8eac1591-ddc6-4d93-bec7-998cbfe27cc7', + name: 'Edit Fields1', + issues: { + execution: true, + }, + }, + { + parameters: { + mode: 'manual', + duplicateItem: false, + assignments: { + assignments: [ + { + id: '9bdfc283-64f7-41c5-9a55-b8d8ccbe3e9d', + name: 'age', + value: '={{ $json.name }}', + type: 'number', + }, + ], + }, + includeOtherFields: false, + options: {}, + }, + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + position: [440, 560], + id: '34e56e14-d1a9-4a73-9208-15d39771a9ba', + name: 'Edit Fields2', + }, + ], + }, + executionData: { + runData: { + 'When clicking ‘Test workflow’': [ + { + hints: [], + startTime: 1737540693122, + executionTime: 1, + source: [], + executionStatus: 'success', + }, + ], + 'Edit Fields': [ + { + hints: [], + startTime: 1737540693124, + executionTime: 2, + source: [ + { + previousNode: 'When clicking ‘Test workflow’', + }, + ], + executionStatus: 'success', + }, + ], + 'Bad request no chat found': [ + { + hints: [], + startTime: 1737540693126, + executionTime: 0, + source: [ + { + previousNode: 'Edit Fields', + }, + ], + executionStatus: 'success', + }, + ], + Slack: [ + { + hints: [], + startTime: 1737540693127, + executionTime: 0, + source: [ + { + previousNode: 'Edit Fields', + }, + ], + executionStatus: 'success', + }, + ], + 'Edit Fields1': [ + { + hints: [], + startTime: 1737540693127, + executionTime: 28, + source: [ + { + previousNode: 'Edit Fields', + }, + ], + executionStatus: 'error', + // @ts-expect-error Incomplete mock objects are expected + error: { + level: 'warning', + tags: { + packageName: 'workflow', + }, + context: { + itemIndex: 0, + nodeCause: 'Edit Fields 2', + descriptionKey: 'nodeNotFound', + parameter: 'assignments', + }, + functionality: 'regular', + name: 'NodeOperationError', + timestamp: 1737540693141, + node: { + parameters: { + mode: 'manual', + duplicateItem: false, + assignments: { + assignments: [ + { + id: '969e86d0-76de-44f6-b07d-44a8a953f564', + name: 'name', + value: "={{ $('Edit Fields 2').name }}", + type: 'number', + }, + ], + }, + includeOtherFields: false, + options: {}, + }, + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + position: [540, 400], + id: '8eac1591-ddc6-4d93-bec7-998cbfe27cc7', + name: 'Edit Fields1', + }, + messages: [], + message: "Referenced node doesn't exist", + stack: + "NodeOperationError: Referenced node doesn't exist\n at ExecuteContext.execute (/Users/miloradfilipovic/workspace/n8n/packages/nodes-base/nodes/Set/v2/manual.mode.ts:256:9)\n at ExecuteContext.execute (/Users/miloradfilipovic/workspace/n8n/packages/nodes-base/nodes/Set/v2/SetV2.node.ts:351:48)\n at WorkflowExecute.runNode (/Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:1097:31)\n at /Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:1505:38\n at /Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:2066:11", + }, + }, + ], + }, + // @ts-expect-error Incomplete mock objects are expected + error: { + level: 'warning', + tags: { + packageName: 'workflow', + }, + context: { + itemIndex: 0, + nodeCause: 'Edit Fields 2', + descriptionKey: 'nodeNotFound', + parameter: 'assignments', + }, + functionality: 'regular', + name: 'NodeOperationError', + timestamp: 1737540693141, + node: { + parameters: { + mode: 'manual', + duplicateItem: false, + assignments: { + assignments: [ + { + id: '969e86d0-76de-44f6-b07d-44a8a953f564', + name: 'name', + value: "={{ $('Edit Fields 2').name }}", + type: 'number', + }, + ], + }, + includeOtherFields: false, + options: {}, + }, + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + position: [540, 400], + id: '8eac1591-ddc6-4d93-bec7-998cbfe27cc7', + name: 'Edit Fields1', + }, + messages: [], + message: "Referenced node doesn't exist", + stack: + "NodeOperationError: Referenced node doesn't exist\n at ExecuteContext.execute (/Users/miloradfilipovic/workspace/n8n/packages/nodes-base/nodes/Set/v2/manual.mode.ts:256:9)\n at ExecuteContext.execute (/Users/miloradfilipovic/workspace/n8n/packages/nodes-base/nodes/Set/v2/SetV2.node.ts:351:48)\n at WorkflowExecute.runNode (/Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:1097:31)\n at /Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:1505:38\n at /Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:2066:11", + }, + lastNodeExecuted: 'Edit Fields1', + }, + }, + question: 'Hey', + }, +}; diff --git a/packages/editor-ui/src/composables/useAIAssistantHelpers.test.ts b/packages/editor-ui/src/composables/useAIAssistantHelpers.test.ts index 7d0ac11398..09a72fc47f 100644 --- a/packages/editor-ui/src/composables/useAIAssistantHelpers.test.ts +++ b/packages/editor-ui/src/composables/useAIAssistantHelpers.test.ts @@ -4,6 +4,13 @@ import { useAIAssistantHelpers } from './useAIAssistantHelpers'; import { createTestingPinia } from '@pinia/testing'; import { setActivePinia } from 'pinia'; import type { IWorkflowDb } from '@/Interface'; +import type { ChatRequest } from '@/types/assistant.types'; +import { + ERROR_HELPER_TEST_PAYLOAD, + PAYLOAD_SIZE_FOR_1_PASS, + PAYLOAD_SIZE_FOR_2_PASSES, + SUPPORT_CHAT_TEST_PAYLOAD, +} from './useAIAssistantHelpers.test.constants'; const referencedNodesTestCases: Array<{ caseName: string; node: INode; expected: string[] }> = [ { @@ -549,3 +556,67 @@ describe('Simplify assistant payloads', () => { } }); }); + +describe('Trim Payload Size', () => { + let aiAssistantHelpers: ReturnType; + + beforeEach(() => { + setActivePinia(createTestingPinia()); + aiAssistantHelpers = useAIAssistantHelpers(); + }); + + it('Should trim active node parameters in error helper payload', () => { + const payload = ERROR_HELPER_TEST_PAYLOAD; + aiAssistantHelpers.trimPayloadSize(payload); + expect((payload.payload as ChatRequest.InitErrorHelper).node.parameters).toEqual({}); + }); + + it('Should trim all node parameters in support chat', () => { + // Testing the scenario where only one trimming pass is needed + // (payload is under the limit after removing all node parameters and execution data) + const payload: ChatRequest.RequestPayload = SUPPORT_CHAT_TEST_PAYLOAD; + const supportPayload: ChatRequest.InitSupportChat = + payload.payload as ChatRequest.InitSupportChat; + + // Trimming to 4kb should be successful + expect(() => + aiAssistantHelpers.trimPayloadSize(payload, PAYLOAD_SIZE_FOR_1_PASS), + ).not.toThrow(); + // All active node parameters should be removed + expect(supportPayload?.context?.activeNodeInfo?.node?.parameters).toEqual({}); + // Also, all node parameters in the workflow should be removed + supportPayload.context?.currentWorkflow?.nodes?.forEach((node) => { + expect(node.parameters).toEqual({}); + }); + // Node parameters in the execution data should be removed + expect(supportPayload.context?.executionData?.runData).toEqual({}); + if ( + supportPayload.context?.executionData?.error && + 'node' in supportPayload.context.executionData.error + ) { + expect(supportPayload.context?.executionData?.error?.node?.parameters).toEqual({}); + } + // Context object should still be there + expect(supportPayload.context).to.be.an('object'); + }); + + it('Should trim the whole context in support chat', () => { + // Testing the scenario where both trimming passes are needed + // (payload is over the limit after removing all node parameters and execution data) + const payload: ChatRequest.RequestPayload = SUPPORT_CHAT_TEST_PAYLOAD; + const supportPayload: ChatRequest.InitSupportChat = + payload.payload as ChatRequest.InitSupportChat; + + // Trimming should be successful + expect(() => + aiAssistantHelpers.trimPayloadSize(payload, PAYLOAD_SIZE_FOR_2_PASSES), + ).not.toThrow(); + // The whole context object should be removed + expect(supportPayload.context).not.toBeDefined(); + }); + + it('Should throw an error if payload is too big after trimming', () => { + const payload = ERROR_HELPER_TEST_PAYLOAD; + expect(() => aiAssistantHelpers.trimPayloadSize(payload, 0.2)).toThrow(); + }); +}); diff --git a/packages/editor-ui/src/composables/useAIAssistantHelpers.ts b/packages/editor-ui/src/composables/useAIAssistantHelpers.ts index b19f2817cd..4d0923a2ff 100644 --- a/packages/editor-ui/src/composables/useAIAssistantHelpers.ts +++ b/packages/editor-ui/src/composables/useAIAssistantHelpers.ts @@ -14,9 +14,10 @@ import { executionDataToJson, getMainAuthField, getNodeAuthOptions } from '@/uti import type { ChatRequest } from '@/types/assistant.types'; import { useWorkflowsStore } from '@/stores/workflows.store'; import { useDataSchema } from './useDataSchema'; -import { VIEWS } from '@/constants'; +import { AI_ASSISTANT_MAX_CONTENT_LENGTH, VIEWS } from '@/constants'; import { useI18n } from './useI18n'; import type { IWorkflowDb } from '@/Interface'; +import { getObjectSizeInKB } from '@/utils/objectUtils'; const CANVAS_VIEWS = [VIEWS.NEW_WORKFLOW, VIEWS.WORKFLOW, VIEWS.EXECUTION_DEBUG]; const EXECUTION_VIEWS = [VIEWS.EXECUTION_PREVIEW]; @@ -251,6 +252,64 @@ export const useAIAssistantHelpers = () => { nodes: workflow.nodes, }); + /** + * Reduces AI Assistant request payload size to make it fit the specified content length. + * If, after two passes, the payload is still too big, throws an error' + * @param payload The request payload to trim + * @param size The maximum size of the payload in KB + */ + const trimPayloadToSize = ( + payload: ChatRequest.RequestPayload, + size = AI_ASSISTANT_MAX_CONTENT_LENGTH, + ): void => { + const requestPayload = payload.payload; + // For support chat, remove parameters from the active node object and all nodes in the workflow + if (requestPayload.type === 'init-support-chat') { + if (requestPayload.context?.activeNodeInfo?.node) { + requestPayload.context.activeNodeInfo.node.parameters = {}; + } + if (requestPayload.context?.currentWorkflow) { + requestPayload.context.currentWorkflow?.nodes?.forEach((node) => { + node.parameters = {}; + }); + } + if (requestPayload.context?.executionData?.runData) { + requestPayload.context.executionData.runData = {}; + } + if ( + requestPayload.context?.executionData?.error && + 'node' in requestPayload.context?.executionData?.error + ) { + if (requestPayload.context?.executionData?.error?.node) { + requestPayload.context.executionData.error.node.parameters = {}; + } + } + // If the payload is still too big, remove the whole context object + if (getRequestPayloadSize(payload) > size) { + requestPayload.context = undefined; + } + // For error helper, remove parameters from the active node object + // This will leave just the error, user info and basic node structure in the payload + } else if (requestPayload.type === 'init-error-helper') { + requestPayload.node.parameters = {}; + } + // If the payload is still too big, throw an error that will be shown to the user + if (getRequestPayloadSize(payload) > size) { + throw new Error(locale.baseText('aiAssistant.payloadTooBig.message')); + } + }; + + /** + * Get the size of the request payload in KB, returns 0 if the payload is not a valid object + */ + const getRequestPayloadSize = (payload: ChatRequest.RequestPayload): number => { + try { + return getObjectSizeInKB(payload.payload); + } catch (error) { + return 0; + } + }; + return { processNodeForAssistant, getNodeInfoForAssistant, @@ -261,5 +320,6 @@ export const useAIAssistantHelpers = () => { getReferencedNodes, simplifyResultData, simplifyWorkflowForAssistant, + trimPayloadSize: trimPayloadToSize, }; }; diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index b1d5546183..c5a425cbfa 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -907,3 +907,5 @@ export const APP_MODALS_ELEMENT_ID = 'app-modals'; export const NEW_SAMPLE_WORKFLOW_CREATED_CHANNEL = 'new-sample-sub-workflow-created'; export const AI_NODES_PACKAGE_NAME = '@n8n/n8n-nodes-langchain'; + +export const AI_ASSISTANT_MAX_CONTENT_LENGTH = 100; // in kilobytes diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 363f70d42d..009a77d8ad 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -155,7 +155,8 @@ "aiAssistant.newSessionModal.message": "You already have an active AI Assistant session. Starting a new session will clear your current conversation history.", "aiAssistant.newSessionModal.question": "Are you sure you want to start a new session?", "aiAssistant.newSessionModal.confirm": "Start new session", - "aiAssistant.serviceError.message": "Unable to connect to n8n's AI service", + "aiAssistant.serviceError.message": "Unable to connect to n8n's AI service ({message})", + "aiAssistant.payloadTooBig.message": "Payload size is too large", "aiAssistant.codeUpdated.message.title": "Assistant modified workflow", "aiAssistant.codeUpdated.message.body1": "Open the", "aiAssistant.codeUpdated.message.body2": "node to see the changes", diff --git a/packages/editor-ui/src/stores/assistant.store.ts b/packages/editor-ui/src/stores/assistant.store.ts index 446501e9c4..0837376934 100644 --- a/packages/editor-ui/src/stores/assistant.store.ts +++ b/packages/editor-ui/src/stores/assistant.store.ts @@ -283,7 +283,7 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => { stopStreaming(); assistantThinkingMessage.value = undefined; addAssistantError( - `${locale.baseText('aiAssistant.serviceError.message')}: (${e.message})`, + locale.baseText('aiAssistant.serviceError.message', { interpolate: { message: e.message } }), id, retry, ); @@ -487,24 +487,25 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => { openChat(); streaming.value = true; + const payload: ChatRequest.RequestPayload['payload'] = { + role: 'user', + type: 'init-error-helper', + user: { + firstName: usersStore.currentUser?.firstName ?? '', + }, + error: context.error, + node: assistantHelpers.processNodeForAssistant(context.node, [ + 'position', + 'parameters.notice', + ]), + nodeInputData, + executionSchema: schemas, + authType, + }; chatWithAssistant( rootStore.restApiContext, { - payload: { - role: 'user', - type: 'init-error-helper', - user: { - firstName: usersStore.currentUser?.firstName ?? '', - }, - error: context.error, - node: assistantHelpers.processNodeForAssistant(context.node, [ - 'position', - 'parameters.notice', - ]), - nodeInputData, - executionSchema: schemas, - authType, - }, + payload, }, (msg) => onEachStreamingMessage(msg, id), () => onDoneStreaming(id), diff --git a/packages/editor-ui/src/types/assistant.types.ts b/packages/editor-ui/src/types/assistant.types.ts index 3f1c4c7aae..b1c989ae6f 100644 --- a/packages/editor-ui/src/types/assistant.types.ts +++ b/packages/editor-ui/src/types/assistant.types.ts @@ -58,7 +58,7 @@ export namespace ChatRequest { user: { firstName: string; }; - context?: UserContext; + context?: UserContext & WorkflowContext; workflowContext?: WorkflowContext; question: string; } diff --git a/packages/editor-ui/src/utils/apiUtils.test.ts b/packages/editor-ui/src/utils/apiUtils.test.ts index f6be9036b2..43b361eee9 100644 --- a/packages/editor-ui/src/utils/apiUtils.test.ts +++ b/packages/editor-ui/src/utils/apiUtils.test.ts @@ -1,4 +1,4 @@ -import { STREAM_SEPERATOR, streamRequest } from './apiUtils'; +import { ResponseError, STREAM_SEPERATOR, streamRequest } from './apiUtils'; describe('streamRequest', () => { it('should stream data from the API endpoint', async () => { @@ -54,6 +54,54 @@ describe('streamRequest', () => { expect(onErrorMock).not.toHaveBeenCalled(); }); + it('should stream error response from the API endpoint', async () => { + const testError = { code: 500, message: 'Error happened' }; + const encoder = new TextEncoder(); + const mockResponse = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(JSON.stringify(testError))); + controller.close(); + }, + }); + + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + body: mockResponse, + }); + + global.fetch = mockFetch; + + const onChunkMock = vi.fn(); + const onDoneMock = vi.fn(); + const onErrorMock = vi.fn(); + + await streamRequest( + { + baseUrl: 'https://api.example.com', + pushRef: '', + }, + '/data', + { key: 'value' }, + onChunkMock, + onDoneMock, + onErrorMock, + ); + + expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/data', { + method: 'POST', + body: JSON.stringify({ key: 'value' }), + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'browser-id': expect.stringContaining('-'), + }, + }); + + expect(onChunkMock).not.toHaveBeenCalled(); + expect(onErrorMock).toHaveBeenCalledTimes(1); + expect(onErrorMock).toHaveBeenCalledWith(new ResponseError(testError.message)); + }); + it('should handle broken stream data', async () => { const encoder = new TextEncoder(); const mockResponse = new ReadableStream({ diff --git a/packages/editor-ui/src/utils/apiUtils.ts b/packages/editor-ui/src/utils/apiUtils.ts index e86dcadf26..97596fe578 100644 --- a/packages/editor-ui/src/utils/apiUtils.ts +++ b/packages/editor-ui/src/utils/apiUtils.ts @@ -198,7 +198,7 @@ export function unflattenExecutionData(fullExecutionData: IExecutionFlattedRespo return returnData; } -export async function streamRequest( +export async function streamRequest( context: IRestApiContext, apiEndpoint: string, payload: object, @@ -220,7 +220,7 @@ export async function streamRequest( try { const response = await fetch(`${context.baseUrl}${apiEndpoint}`, assistantRequest); - if (response.ok && response.body) { + if (response.body) { // Handle the streaming response const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); @@ -252,7 +252,18 @@ export async function streamRequest( } try { - onChunk?.(data); + if (response.ok) { + // Call chunk callback if request was successful + onChunk?.(data); + } else { + // Otherwise, call error callback + const message = 'message' in data ? data.message : response.statusText; + onError?.( + new ResponseError(String(message), { + httpStatusCode: response.status, + }), + ); + } } catch (e: unknown) { if (e instanceof Error) { onError?.(e); diff --git a/packages/editor-ui/src/utils/objectUtils.test.ts b/packages/editor-ui/src/utils/objectUtils.test.ts index 0dd51f5e80..ae2b7cf877 100644 --- a/packages/editor-ui/src/utils/objectUtils.test.ts +++ b/packages/editor-ui/src/utils/objectUtils.test.ts @@ -1,4 +1,4 @@ -import { isObjectOrArray, isObject, searchInObject } from '@/utils/objectUtils'; +import { isObjectOrArray, isObject, searchInObject, getObjectSizeInKB } from '@/utils/objectUtils'; const testData = [1, '', true, null, undefined, new Date(), () => {}].map((value) => [ value, @@ -95,4 +95,63 @@ describe('objectUtils', () => { assert(searchInObject({ a: ['b', { c: 'd' }] }, 'd')); }); }); + + describe('getObjectSizeInKB', () => { + // Test null/undefined cases + it('returns 0 for null', () => { + expect(getObjectSizeInKB(null)).toBe(0); + }); + + it('returns 0 for undefined', () => { + expect(getObjectSizeInKB(undefined)).toBe(0); + }); + + // Test empty objects/arrays + it('returns correct size for empty object', () => { + expect(getObjectSizeInKB({})).toBe(0); + }); + + it('returns correct size for empty array', () => { + expect(getObjectSizeInKB([])).toBe(0); + }); + + // Test regular cases + it('calculates size for simple object correctly', () => { + const obj = { name: 'test' }; + expect(getObjectSizeInKB(obj)).toBe(0.01); + }); + + it('calculates size for array correctly', () => { + const arr = [1, 2, 3]; + expect(getObjectSizeInKB(arr)).toBe(0.01); + }); + + it('calculates size for nested object correctly', () => { + const obj = { + name: 'test', + nested: { + value: 123, + }, + }; + expect(getObjectSizeInKB(obj)).toBe(0.04); + }); + + // Test error cases + it('throws error for circular reference', () => { + type CircularObj = { + name: string; + self?: CircularObj; + }; + + const obj: CircularObj = { name: 'test' }; + obj.self = obj; + + expect(() => getObjectSizeInKB(obj)).toThrow('Failed to calculate object size'); + }); + + it('handles special characters correctly', () => { + const obj = { name: '测试' }; + expect(getObjectSizeInKB(obj)).toBe(0.02); + }); + }); }); diff --git a/packages/editor-ui/src/utils/objectUtils.ts b/packages/editor-ui/src/utils/objectUtils.ts index d29bd15866..1958dcb3ea 100644 --- a/packages/editor-ui/src/utils/objectUtils.ts +++ b/packages/editor-ui/src/utils/objectUtils.ts @@ -18,3 +18,35 @@ export const searchInObject = (obj: ObjectOrArray, searchString: string): boolea ? searchInObject(entry, searchString) : entry?.toString().toLowerCase().includes(searchString.toLowerCase()), ); + +/** + * Calculate the size of a stringified object in KB. + * @param {unknown} obj - The object to calculate the size of + * @returns {number} The size of the object in KB + * @throws {Error} If the object is not serializable + */ +export const getObjectSizeInKB = (obj: unknown): number => { + if (obj === null || obj === undefined) { + return 0; + } + + if ( + (typeof obj === 'object' && Object.keys(obj).length === 0) || + (Array.isArray(obj) && obj.length === 0) + ) { + // "{}" and "[]" both take 2 bytes in UTF-8 + return Number((2 / 1024).toFixed(2)); + } + + try { + const str = JSON.stringify(obj); + // Using TextEncoder to get actual UTF-8 byte length (what we see in chrome dev tools) + const bytes = new TextEncoder().encode(str).length; + const kb = bytes / 1024; + return Number(kb.toFixed(2)); + } catch (error) { + throw new Error( + `Failed to calculate object size: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } +}; From 9139dc3c2916186648fb5bf63d14fcb90773eb1c Mon Sep 17 00:00:00 2001 From: Danny Martini Date: Wed, 22 Jan 2025 15:18:25 +0100 Subject: [PATCH 27/28] fix(editor): Don't send run data for full manual executions (#12687) --- .../components/CanvasChat/CanvasChat.test.ts | 38 +++++----- .../src/composables/useRunWorkflow.test.ts | 71 +++++++++++++------ .../src/composables/useRunWorkflow.ts | 15 +++- 3 files changed, 81 insertions(+), 43 deletions(-) diff --git a/packages/editor-ui/src/components/CanvasChat/CanvasChat.test.ts b/packages/editor-ui/src/components/CanvasChat/CanvasChat.test.ts index 47869583a4..1172768ace 100644 --- a/packages/editor-ui/src/components/CanvasChat/CanvasChat.test.ts +++ b/packages/editor-ui/src/components/CanvasChat/CanvasChat.test.ts @@ -230,28 +230,28 @@ describe('CanvasChat', () => { // Verify workflow execution expect(workflowsStore.runWorkflow).toHaveBeenCalledWith( expect.objectContaining({ - runData: { - 'When chat message received': [ - { - data: { - main: [ - [ - { - json: { - action: 'sendMessage', - chatInput: 'Hello AI!', - sessionId: expect.any(String), - }, + runData: undefined, + triggerToStartFrom: { + name: 'When chat message received', + data: { + data: { + main: [ + [ + { + json: { + action: 'sendMessage', + chatInput: 'Hello AI!', + sessionId: expect.any(String), }, - ], + }, ], - }, - executionStatus: 'success', - executionTime: 0, - source: [null], - startTime: expect.any(Number), + ], }, - ], + executionStatus: 'success', + executionTime: 0, + source: [null], + startTime: expect.any(Number), + }, }, }), ); diff --git a/packages/editor-ui/src/composables/useRunWorkflow.test.ts b/packages/editor-ui/src/composables/useRunWorkflow.test.ts index e6b8c586f5..5d3b100590 100644 --- a/packages/editor-ui/src/composables/useRunWorkflow.test.ts +++ b/packages/editor-ui/src/composables/useRunWorkflow.test.ts @@ -21,7 +21,7 @@ import { useToast } from './useToast'; import { useI18n } from '@/composables/useI18n'; import { useLocalStorage } from '@vueuse/core'; import { ref } from 'vue'; -import { mock } from 'vitest-mock-extended'; +import { captor, mock } from 'vitest-mock-extended'; vi.mock('@/stores/workflows.store', () => ({ useWorkflowsStore: vi.fn().mockReturnValue({ @@ -409,27 +409,28 @@ describe('useRunWorkflow({ router })', () => { const mockExecutionResponse = { executionId: '123' }; const mockRunData = { nodeName: [] }; const { runWorkflow } = useRunWorkflow({ router }); + const dataCaptor = captor(); + const workflow = mock({ name: 'Test Workflow' }); + workflow.getParentNodes.mockReturnValue([]); vi.mocked(useLocalStorage).mockReturnValueOnce(ref(0)); vi.mocked(rootStore).pushConnectionActive = true; vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse); vi.mocked(workflowsStore).nodesIssuesExist = false; - vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue({ - name: 'Test Workflow', - } as Workflow); - vi.mocked(workflowHelpers).getWorkflowDataToSave.mockResolvedValue({ - id: 'workflowId', - nodes: [], - } as unknown as IWorkflowData); + vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue(workflow); + vi.mocked(workflowHelpers).getWorkflowDataToSave.mockResolvedValue( + mock({ id: 'workflowId', nodes: [] }), + ); vi.mocked(workflowsStore).getWorkflowRunData = mockRunData; // ACT - const result = await runWorkflow({}); + const result = await runWorkflow({ destinationNode: 'some node name' }); // ASSERT expect(result).toEqual(mockExecutionResponse); expect(workflowsStore.setWorkflowExecutionData).toHaveBeenCalledTimes(1); - expect(vi.mocked(workflowsStore.setWorkflowExecutionData).mock.calls[0][0]).toMatchObject({ + expect(workflowsStore.setWorkflowExecutionData).toHaveBeenCalledWith(dataCaptor); + expect(dataCaptor.value).toMatchObject({ data: { resultData: { runData: {} } }, }); }); @@ -439,18 +440,47 @@ describe('useRunWorkflow({ router })', () => { const mockExecutionResponse = { executionId: '123' }; const mockRunData = { nodeName: [] }; const { runWorkflow } = useRunWorkflow({ router }); + const dataCaptor = captor(); + const workflow = mock({ name: 'Test Workflow' }); + workflow.getParentNodes.mockReturnValue([]); vi.mocked(useLocalStorage).mockReturnValueOnce(ref(1)); vi.mocked(rootStore).pushConnectionActive = true; vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse); vi.mocked(workflowsStore).nodesIssuesExist = false; - vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue({ - name: 'Test Workflow', - } as Workflow); - vi.mocked(workflowHelpers).getWorkflowDataToSave.mockResolvedValue({ - id: 'workflowId', - nodes: [], - } as unknown as IWorkflowData); + vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue(workflow); + vi.mocked(workflowHelpers).getWorkflowDataToSave.mockResolvedValue( + mock({ id: 'workflowId', nodes: [] }), + ); + vi.mocked(workflowsStore).getWorkflowRunData = mockRunData; + + // ACT + const result = await runWorkflow({ destinationNode: 'some node name' }); + + // ASSERT + expect(result).toEqual(mockExecutionResponse); + expect(workflowsStore.setWorkflowExecutionData).toHaveBeenCalledTimes(1); + expect(workflowsStore.setWorkflowExecutionData).toHaveBeenCalledWith(dataCaptor); + expect(dataCaptor.value).toMatchObject({ data: { resultData: { runData: mockRunData } } }); + }); + + it("does not send run data if it's not a partial execution even if `PartialExecution.version` is set to 1", async () => { + // ARRANGE + const mockExecutionResponse = { executionId: '123' }; + const mockRunData = { nodeName: [] }; + const { runWorkflow } = useRunWorkflow({ router }); + const dataCaptor = captor(); + const workflow = mock({ name: 'Test Workflow' }); + workflow.getParentNodes.mockReturnValue([]); + + vi.mocked(useLocalStorage).mockReturnValueOnce(ref(1)); + vi.mocked(rootStore).pushConnectionActive = true; + vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse); + vi.mocked(workflowsStore).nodesIssuesExist = false; + vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue(workflow); + vi.mocked(workflowHelpers).getWorkflowDataToSave.mockResolvedValue( + mock({ id: 'workflowId', nodes: [] }), + ); vi.mocked(workflowsStore).getWorkflowRunData = mockRunData; // ACT @@ -458,10 +488,9 @@ describe('useRunWorkflow({ router })', () => { // ASSERT expect(result).toEqual(mockExecutionResponse); - expect(workflowsStore.setWorkflowExecutionData).toHaveBeenCalledTimes(1); - expect(vi.mocked(workflowsStore.setWorkflowExecutionData).mock.calls[0][0]).toMatchObject({ - data: { resultData: { runData: mockRunData } }, - }); + expect(workflowsStore.runWorkflow).toHaveBeenCalledTimes(1); + expect(workflowsStore.runWorkflow).toHaveBeenCalledWith(dataCaptor); + expect(dataCaptor.value).toHaveProperty('runData', undefined); }); }); diff --git a/packages/editor-ui/src/composables/useRunWorkflow.ts b/packages/editor-ui/src/composables/useRunWorkflow.ts index e765922156..880713e1e5 100644 --- a/packages/editor-ui/src/composables/useRunWorkflow.ts +++ b/packages/editor-ui/src/composables/useRunWorkflow.ts @@ -264,14 +264,23 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType Date: Wed, 22 Jan 2025 15:49:55 +0100 Subject: [PATCH 28/28] fix(editor): Fix workflow move project select filtering (#12764) --- .../Projects/ProjectMoveResourceModal.test.ts | 65 +++++++++++++------ .../Projects/ProjectMoveResourceModal.vue | 6 +- 2 files changed, 50 insertions(+), 21 deletions(-) diff --git a/packages/editor-ui/src/components/Projects/ProjectMoveResourceModal.test.ts b/packages/editor-ui/src/components/Projects/ProjectMoveResourceModal.test.ts index 795c38e5e5..5da1d7d050 100644 --- a/packages/editor-ui/src/components/Projects/ProjectMoveResourceModal.test.ts +++ b/packages/editor-ui/src/components/Projects/ProjectMoveResourceModal.test.ts @@ -1,12 +1,16 @@ import { createTestingPinia } from '@pinia/testing'; +import userEvent from '@testing-library/user-event'; import { createComponentRenderer } from '@/__tests__/render'; +import { createProjectListItem } from '@/__tests__/data/projects'; +import { getDropdownItems, mockedStore } from '@/__tests__/utils'; +import type { MockedStore } from '@/__tests__/utils'; import { PROJECT_MOVE_RESOURCE_MODAL } from '@/constants'; import ProjectMoveResourceModal from '@/components/Projects/ProjectMoveResourceModal.vue'; import { useTelemetry } from '@/composables/useTelemetry'; -import { mockedStore } from '@/__tests__/utils'; import { useProjectsStore } from '@/stores/projects.store'; const renderComponent = createComponentRenderer(ProjectMoveResourceModal, { + pinia: createTestingPinia(), global: { stubs: { Modal: { @@ -18,28 +22,19 @@ const renderComponent = createComponentRenderer(ProjectMoveResourceModal, { }); let telemetry: ReturnType; +let projectsStore: MockedStore; describe('ProjectMoveResourceModal', () => { beforeEach(() => { + vi.clearAllMocks(); telemetry = useTelemetry(); + projectsStore = mockedStore(useProjectsStore); }); it('should send telemetry when mounted', async () => { - const pinia = createTestingPinia(); const telemetryTrackSpy = vi.spyOn(telemetry, 'track'); - const projectsStore = mockedStore(useProjectsStore); - projectsStore.availableProjects = [ - { - id: '1', - name: 'My Project', - icon: { type: 'icon', value: 'folder' }, - type: 'personal', - role: 'project:personalOwner', - createdAt: '2021-01-01T00:00:00.000Z', - updatedAt: '2021-01-01T00:00:00.000Z', - }, - ]; + projectsStore.availableProjects = [createProjectListItem()]; const props = { modalName: PROJECT_MOVE_RESOURCE_MODAL, @@ -55,7 +50,7 @@ describe('ProjectMoveResourceModal', () => { }, }, }; - renderComponent({ props, pinia }); + renderComponent({ props }); expect(telemetryTrackSpy).toHaveBeenCalledWith( 'User clicked to move a workflow', expect.objectContaining({ workflow_id: '1' }), @@ -63,9 +58,6 @@ describe('ProjectMoveResourceModal', () => { }); it('should show no available projects message', async () => { - const pinia = createTestingPinia(); - - const projectsStore = mockedStore(useProjectsStore); projectsStore.availableProjects = []; const props = { @@ -82,7 +74,42 @@ describe('ProjectMoveResourceModal', () => { }, }, }; - const { getByText } = renderComponent({ props, pinia }); + const { getByText } = renderComponent({ props }); expect(getByText(/Currently there are not any projects or users available/)).toBeVisible(); }); + + it('should not hide project select if filter has no result', async () => { + const projects = Array.from({ length: 5 }, createProjectListItem); + projectsStore.availableProjects = projects; + + const props = { + modalName: PROJECT_MOVE_RESOURCE_MODAL, + data: { + resourceType: 'workflow', + resourceTypeLabel: 'Workflow', + resource: { + id: '1', + homeProject: { + id: projects[0].id, + name: projects[0].name, + }, + }, + }, + }; + + const { getByTestId, getByRole } = renderComponent({ props }); + + const projectSelect = getByTestId('project-move-resource-modal-select'); + const projectSelectInput: HTMLInputElement = getByRole('combobox'); + expect(projectSelectInput).toBeVisible(); + expect(projectSelect).toBeVisible(); + + const projectSelectDropdownItems = await getDropdownItems(projectSelect); + expect(projectSelectDropdownItems).toHaveLength(projects.length - 1); + + await userEvent.click(projectSelectInput); + await userEvent.type(projectSelectInput, 'non-existing project'); + + expect(projectSelect).toBeVisible(); + }); }); diff --git a/packages/editor-ui/src/components/Projects/ProjectMoveResourceModal.vue b/packages/editor-ui/src/components/Projects/ProjectMoveResourceModal.vue index c4814550cd..2fffdc4c8d 100644 --- a/packages/editor-ui/src/components/Projects/ProjectMoveResourceModal.vue +++ b/packages/editor-ui/src/components/Projects/ProjectMoveResourceModal.vue @@ -39,12 +39,14 @@ const availableProjects = computed(() => 'name', projectsStore.availableProjects.filter( (p) => - p.name?.toLowerCase().includes(filter.value.toLowerCase()) && p.id !== props.data.resource.homeProject?.id && (!p.scopes || getResourcePermissions(p.scopes)[props.data.resourceType].create), ), ), ); +const filteredProjects = computed(() => + availableProjects.value.filter((p) => p.name?.toLowerCase().includes(filter.value.toLowerCase())), +); const selectedProject = computed(() => availableProjects.value.find((p) => p.id === projectId.value), ); @@ -169,7 +171,7 @@ onMounted(() => {