diff --git a/.github/workflows/ci-postgres-mysql.yml b/.github/workflows/ci-postgres-mysql.yml index 2277917532d2d..9cf864b7074e2 100644 --- a/.github/workflows/ci-postgres-mysql.yml +++ b/.github/workflows/ci-postgres-mysql.yml @@ -102,6 +102,7 @@ jobs: timeout-minutes: 20 env: DB_POSTGRESDB_PASSWORD: password + DB_POSTGRESDB_POOL_SIZE: 1 # Detect connection pooling deadlocks steps: - uses: actions/checkout@v4.1.1 - run: corepack enable diff --git a/CHANGELOG.md b/CHANGELOG.md index 451497cb0740b..c65abb8258084 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,55 @@ +# [1.43.0](https://github.com/n8n-io/n8n/compare/n8n@1.42.0...n8n@1.43.0) (2024-05-22) + + +### Bug Fixes + +* **core:** Account for retry of execution aborted by pre-execute hook ([#9474](https://github.com/n8n-io/n8n/issues/9474)) ([a217866](https://github.com/n8n-io/n8n/commit/a217866cef6caaef9244f3d16d90f7027adc0c12)) +* **core:** Add an option to disable STARTTLS for SMTP connections ([#9415](https://github.com/n8n-io/n8n/issues/9415)) ([0d73588](https://github.com/n8n-io/n8n/commit/0d7358807b4244be574060726388bd49fc90dc64)) +* **core:** Do not allow admins to delete the instance owner ([#9489](https://github.com/n8n-io/n8n/issues/9489)) ([fc83005](https://github.com/n8n-io/n8n/commit/fc83005ba0876ebea70f93de700adbd6e3095c96)) +* **core:** Do not allow admins to generate password-reset links for instance owner ([#9488](https://github.com/n8n-io/n8n/issues/9488)) ([88b9a40](https://github.com/n8n-io/n8n/commit/88b9a4070b7df943c3ba22047c0656a5d0a2111c)) +* **core:** Fix 431 for large dynamic node parameters ([#9384](https://github.com/n8n-io/n8n/issues/9384)) ([d21ad15](https://github.com/n8n-io/n8n/commit/d21ad15c1f12739af6a28983a6469347c26f1e08)) +* **core:** Handle credential in body for oauth2 refresh token ([#9179](https://github.com/n8n-io/n8n/issues/9179)) ([c9855e3](https://github.com/n8n-io/n8n/commit/c9855e3dce42f8830636914458d1061668a466a8)) +* **core:** Remove excess args from routing error ([#9377](https://github.com/n8n-io/n8n/issues/9377)) ([b1f977e](https://github.com/n8n-io/n8n/commit/b1f977ebd084ab3a8fb1d13109063de7d2a15296)) +* **core:** Retry before continue on fail ([#9395](https://github.com/n8n-io/n8n/issues/9395)) ([9b2ce81](https://github.com/n8n-io/n8n/commit/9b2ce819d42c4a541ae94956aaab608a989ec588)) +* **editor:** Emit change events from filter component on update ([#9479](https://github.com/n8n-io/n8n/issues/9479)) ([62df433](https://github.com/n8n-io/n8n/commit/62df4331d448dfdabd51db33560a87dd5d805a13)) +* **editor:** Fix blank Public API page ([#9409](https://github.com/n8n-io/n8n/issues/9409)) ([14fe9f2](https://github.com/n8n-io/n8n/commit/14fe9f268feeb0ca106ddaaa94c69cb356011524)) +* **editor:** Fix i18n translation addition ([#9451](https://github.com/n8n-io/n8n/issues/9451)) ([04dd476](https://github.com/n8n-io/n8n/commit/04dd4760e173bfc8a938413a5915d63291da8afe)) +* **editor:** Fix node execution errors showing undefined ([#9487](https://github.com/n8n-io/n8n/issues/9487)) ([62ee796](https://github.com/n8n-io/n8n/commit/62ee79689569b5d2c9823afac238e66e4c645d9b)) +* **editor:** Fix outdated roles in variables labels ([#9411](https://github.com/n8n-io/n8n/issues/9411)) ([38b498e](https://github.com/n8n-io/n8n/commit/38b498e73a71a9ca8b10a89e498aa8330acf2626)) +* **editor:** Fix project settings layout ([#9475](https://github.com/n8n-io/n8n/issues/9475)) ([96cf41f](https://github.com/n8n-io/n8n/commit/96cf41f8516881f0ba15b0b01dda7712f1edc845)) +* **editor:** Fix type errors in `components/executions/workflow` ([#9448](https://github.com/n8n-io/n8n/issues/9448)) ([9c768a0](https://github.com/n8n-io/n8n/commit/9c768a0443520f0c031d4d807d955d7778a00997)) +* **editor:** Fix type errors in i18n plugin ([#9441](https://github.com/n8n-io/n8n/issues/9441)) ([a7d3e59](https://github.com/n8n-io/n8n/commit/a7d3e59aef36dd65429ad0b2ea4696b107620eeb)) +* **editor:** Fix workflow history TS errors ([#9433](https://github.com/n8n-io/n8n/issues/9433)) ([bc05faf](https://github.com/n8n-io/n8n/commit/bc05faf0a6a0913013e4d46eefb1e45abc390883)) +* **editor:** Secondary button in dark mode ([#9401](https://github.com/n8n-io/n8n/issues/9401)) ([aad43d8](https://github.com/n8n-io/n8n/commit/aad43d8cdcc9621fbd864fbe0235c9ff4ddbfe3e)) +* **Email Trigger (IMAP) Node:** Handle attachments correctly ([#9410](https://github.com/n8n-io/n8n/issues/9410)) ([68a6c81](https://github.com/n8n-io/n8n/commit/68a6c8172973091e8474a9f173fa4a5e97284f18)) +* Fix color picker type errors ([#9436](https://github.com/n8n-io/n8n/issues/9436)) ([2967df2](https://github.com/n8n-io/n8n/commit/2967df2fe098278dd20126dc033b03cbb4b903ce)) +* Fix type errors in community nodes components ([#9445](https://github.com/n8n-io/n8n/issues/9445)) ([aac19d3](https://github.com/n8n-io/n8n/commit/aac19d328564bfecda53b338e2c56e5e30e5c0c1)) +* **Gmail Trigger Node:** Fetching duplicate emails ([#9424](https://github.com/n8n-io/n8n/issues/9424)) ([3761537](https://github.com/n8n-io/n8n/commit/3761537880f53d9e54b0200a63b067dc3d154787)) +* **HTML Node:** Fix typo preventing row attributes from being set in tables ([#9440](https://github.com/n8n-io/n8n/issues/9440)) ([28e3e21](https://github.com/n8n-io/n8n/commit/28e3e211771fd73a88e34b81858188156fca5fbb)) +* **HubSpot Trigger Node:** Fix issue with ticketId not being set ([#9403](https://github.com/n8n-io/n8n/issues/9403)) ([b5c7c06](https://github.com/n8n-io/n8n/commit/b5c7c061b7e854a06bd725f7905a7f3ac8dfedc2)) +* **Mattermost Node:** Change loadOptions to fetch all items ([#9413](https://github.com/n8n-io/n8n/issues/9413)) ([1377e21](https://github.com/n8n-io/n8n/commit/1377e212c709bc9ca6586c030ec083e89a3d8c37)) +* **Microsoft OneDrive Trigger Node:** Fix issue with test run failing ([#9386](https://github.com/n8n-io/n8n/issues/9386)) ([92a1d65](https://github.com/n8n-io/n8n/commit/92a1d65c4b00683cc334c70f183e5f8c99bfae65)) +* **RSS Feed Trigger Node:** Use newest date instead of first item for new items ([#9182](https://github.com/n8n-io/n8n/issues/9182)) ([7236a55](https://github.com/n8n-io/n8n/commit/7236a558b945c69fa5680e42c538af7c5276cc31)) +* Update operations to run per item ([#8967](https://github.com/n8n-io/n8n/issues/8967)) ([ef9d4ab](https://github.com/n8n-io/n8n/commit/ef9d4aba90c92f9b72a17de242a4ffeb7c034802)) + + +### Features + +* Add Slack trigger node ([#9190](https://github.com/n8n-io/n8n/issues/9190)) ([bf54930](https://github.com/n8n-io/n8n/commit/bf549301df541c43931fe4493b4bad7905fb0c8a)) +* **Custom n8n Workflow Tool Node:** Add support for tool input schema ([#9470](https://github.com/n8n-io/n8n/issues/9470)) ([2fa46b6](https://github.com/n8n-io/n8n/commit/2fa46b6faac5618a10403066c3dddf4ea9def12c)) +* **editor:** Add examples for Luxon DateTime expression methods ([#9361](https://github.com/n8n-io/n8n/issues/9361)) ([40bce7f](https://github.com/n8n-io/n8n/commit/40bce7f44332042bf8dba0442044acd76cc9bf21)) +* **editor:** Add examples for root expression methods ([#9373](https://github.com/n8n-io/n8n/issues/9373)) ([a591f63](https://github.com/n8n-io/n8n/commit/a591f63e3ff51c19fe48185144725e881c418b23)) +* **editor:** Expand supported Unicode range for expressions ([#9420](https://github.com/n8n-io/n8n/issues/9420)) ([2118236](https://github.com/n8n-io/n8n/commit/211823650ba298aac899ff944819290f0bd4654a)) +* **editor:** Update Node Details View header tabs structure ([#9425](https://github.com/n8n-io/n8n/issues/9425)) ([2782534](https://github.com/n8n-io/n8n/commit/2782534d78e9613bda41675b4574c8016b10b0a4)) +* **Extract from File Node:** Add option to set encoding for CSV files ([#9392](https://github.com/n8n-io/n8n/issues/9392)) ([f13dbc9](https://github.com/n8n-io/n8n/commit/f13dbc9cc31fba20b4cb0bedf11e56e16079f946)) +* **Linear Node:** Add identifier to outputs ([#9469](https://github.com/n8n-io/n8n/issues/9469)) ([ffe034c](https://github.com/n8n-io/n8n/commit/ffe034c72e07346cdbea4dda96c7e2c38ea73c45)) +* **OpenAI Node:** Use v2 assistants API and add support for memory ([#9406](https://github.com/n8n-io/n8n/issues/9406)) ([ce3eb12](https://github.com/n8n-io/n8n/commit/ce3eb12a6ba325d3785d54d90ff5a32152afd4c0)) +* RBAC ([#8922](https://github.com/n8n-io/n8n/issues/8922)) ([596c472](https://github.com/n8n-io/n8n/commit/596c472ecc756bf934c51e7efae0075fb23313b4)) +* **Strava Node:** Update to use sport type ([#9462](https://github.com/n8n-io/n8n/issues/9462)) ([9da9368](https://github.com/n8n-io/n8n/commit/9da93680c28f9191eac7edc452e5123749e5c148)) +* **Telegram Node:** Add support for local bot api server ([#8437](https://github.com/n8n-io/n8n/issues/8437)) ([87f965e](https://github.com/n8n-io/n8n/commit/87f965e9055904486f5fd815c060abb4376296a0)) + + + # [1.42.0](https://github.com/n8n-io/n8n/compare/n8n@1.41.0...n8n@1.42.0) (2024-05-15) diff --git a/cypress/e2e/39-projects.cy.ts b/cypress/e2e/39-projects.cy.ts index 5cf1ac1fdcb2c..a3758b1fdaa84 100644 --- a/cypress/e2e/39-projects.cy.ts +++ b/cypress/e2e/39-projects.cy.ts @@ -1,22 +1,30 @@ import { INSTANCE_ADMIN, INSTANCE_MEMBERS } from '../constants'; -import { WorkflowsPage, WorkflowPage, CredentialsModal, CredentialsPage } from '../pages'; +import { + WorkflowsPage, + WorkflowPage, + CredentialsModal, + CredentialsPage, + WorkflowExecutionsTab, +} from '../pages'; import * as projects from '../composables/projects'; const workflowsPage = new WorkflowsPage(); const workflowPage = new WorkflowPage(); const credentialsPage = new CredentialsPage(); const credentialsModal = new CredentialsModal(); +const executionsTab = new WorkflowExecutionsTab(); describe('Projects', () => { beforeEach(() => { cy.resetDatabase(); + cy.enableFeature('sharing'); cy.enableFeature('advancedPermissions'); cy.enableFeature('projectRole:admin'); cy.enableFeature('projectRole:editor'); cy.changeQuota('maxTeamProjects', -1); }); - it('should handle workflows and credentials', () => { + it('should handle workflows and credentials and menu items', () => { cy.signin(INSTANCE_ADMIN); cy.visit(workflowsPage.url); workflowsPage.getters.workflowCards().should('not.have.length'); @@ -147,5 +155,68 @@ describe('Projects', () => { cy.wait('@credentialsList').then((interception) => { expect(interception.request.url).not.to.contain('filter'); }); + + let menuItems = cy.getByTestId('menu-item'); + + menuItems.filter('[class*=active_]').should('have.length', 1); + menuItems.filter(':contains("Home")[class*=active_]').should('exist'); + + projects.getMenuItems().first().click(); + + menuItems = cy.getByTestId('menu-item'); + + menuItems.filter('[class*=active_]').should('have.length', 1); + menuItems.filter(':contains("Development")[class*=active_]').should('exist'); + + cy.intercept('GET', '/rest/workflows/*').as('loadWorkflow'); + workflowsPage.getters.workflowCards().first().click(); + + cy.wait('@loadWorkflow'); + menuItems = cy.getByTestId('menu-item'); + + menuItems.filter('[class*=active_]').should('have.length', 1); + menuItems.filter(':contains("Development")[class*=active_]').should('exist'); + + cy.intercept('GET', '/rest/executions*').as('loadExecutions'); + executionsTab.actions.switchToExecutionsTab(); + + cy.wait('@loadExecutions'); + menuItems = cy.getByTestId('menu-item'); + + menuItems.filter('[class*=active_]').should('have.length', 1); + menuItems.filter(':contains("Development")[class*=active_]').should('exist'); + + executionsTab.actions.switchToEditorTab(); + + menuItems = cy.getByTestId('menu-item'); + + menuItems.filter('[class*=active_]').should('have.length', 1); + menuItems.filter(':contains("Development")[class*=active_]').should('exist'); + + cy.getByTestId('menu-item').filter(':contains("Variables")').click(); + cy.getByTestId('unavailable-resources-list').should('be.visible'); + + menuItems = cy.getByTestId('menu-item'); + + menuItems.filter('[class*=active_]').should('have.length', 1); + menuItems.filter(':contains("Variables")[class*=active_]').should('exist'); + + projects.getHomeButton().click(); + menuItems = cy.getByTestId('menu-item'); + + menuItems.filter('[class*=active_]').should('have.length', 1); + menuItems.filter(':contains("Home")[class*=active_]').should('exist'); + + workflowsPage.getters.workflowCards().should('have.length', 2).first().click(); + + cy.wait('@loadWorkflow'); + cy.getByTestId('execute-workflow-button').should('be.visible'); + + menuItems = cy.getByTestId('menu-item'); + menuItems.filter(':contains("Home")[class*=active_]').should('not.exist'); + + menuItems = cy.getByTestId('menu-item'); + menuItems.filter('[class*=active_]').should('have.length', 1); + menuItems.filter(':contains("Development")[class*=active_]').should('exist'); }); }); diff --git a/cypress/e2e/5-ndv.cy.ts b/cypress/e2e/5-ndv.cy.ts index 04da53af234f9..76efdb32cc4bc 100644 --- a/cypress/e2e/5-ndv.cy.ts +++ b/cypress/e2e/5-ndv.cy.ts @@ -105,13 +105,26 @@ describe('NDV', () => { }); it('should show all validation errors when opening pasted node', () => { - cy.fixture('Test_workflow_ndv_errors.json').then((data) => { - cy.get('body').paste(JSON.stringify(data)); - workflowPage.getters.canvasNodes().should('have.have.length', 1); - workflowPage.actions.openNode('Airtable'); - cy.get('.has-issues').should('have.length', 3); - cy.get('[class*=hasIssues]').should('have.length', 1); - }); + cy.createFixtureWorkflow('Test_workflow_ndv_errors.json', 'Validation errors'); + workflowPage.getters.canvasNodes().should('have.have.length', 1); + workflowPage.actions.openNode('Airtable'); + cy.get('.has-issues').should('have.length', 3); + cy.get('[class*=hasIssues]').should('have.length', 1); + }); + + it('should render run errors correctly', () => { + cy.createFixtureWorkflow('Test_workflow_ndv_run_error.json', 'Run error'); + workflowPage.actions.openNode('Error'); + ndv.actions.execute(); + ndv.getters + .nodeRunErrorMessage() + .should('have.text', 'Info for expression missing from previous node'); + ndv.getters + .nodeRunErrorDescription() + .should( + 'contains.text', + "An expression here won't work because it uses .item and n8n can't figure out the matching item.", + ); }); it('should save workflow using keyboard shortcut from NDV', () => { diff --git a/cypress/fixtures/Test_workflow_ndv_run_error.json b/cypress/fixtures/Test_workflow_ndv_run_error.json new file mode 100644 index 0000000000000..45a045851de4b --- /dev/null +++ b/cypress/fixtures/Test_workflow_ndv_run_error.json @@ -0,0 +1,162 @@ +{ + "name": "My workflow 52", + "nodes": [ + { + "parameters": { + "jsCode": "\nreturn [\n {\n \"field\": \"the same\"\n }\n];" + }, + "id": "38c14c4a-7af1-4b04-be76-f8e474c95569", + "name": "Break pairedItem chain", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 240, + 1020 + ] + }, + { + "parameters": { + "options": {} + }, + "id": "78c4964a-c4e8-47e5-81f3-89ba778feb8b", + "name": "Edit Fields", + "type": "n8n-nodes-base.set", + "typeVersion": 3.2, + "position": [ + 40, + 1020 + ] + }, + { + "parameters": {}, + "id": "4f4c6527-d565-448a-96bd-8f5414caf8cc", + "name": "When clicking \"Test workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + -180, + 1020 + ] + }, + { + "parameters": { + "fields": { + "values": [ + { + "stringValue": "={{ $('Edit Fields').item.json.name }}" + } + ] + }, + "options": {} + }, + "id": "44f4e5da-bfe9-4dc3-8d1f-f38e9f364754", + "name": "Error", + "type": "n8n-nodes-base.set", + "typeVersion": 3.2, + "position": [ + 460, + 1020 + ] + } + ], + "pinData": { + "Edit Fields": [ + { + "json": { + "id": "23423532", + "name": "Jay Gatsby", + "email": "gatsby@west-egg.com", + "notes": "Keeps asking about a green light??", + "country": "US", + "created": "1925-04-10" + } + }, + { + "json": { + "id": "23423533", + "name": "José Arcadio Buendía", + "email": "jab@macondo.co", + "notes": "Lots of people named after him. Very confusing", + "country": "CO", + "created": "1967-05-05" + } + }, + { + "json": { + "id": "23423534", + "name": "Max Sendak", + "email": "info@in-and-out-of-weeks.org", + "notes": "Keeps rolling his terrible eyes", + "country": "US", + "created": "1963-04-09" + } + }, + { + "json": { + "id": "23423535", + "name": "Zaphod Beeblebrox", + "email": "captain@heartofgold.com", + "notes": "Felt like I was talking to more than one person", + "country": null, + "created": "1979-10-12" + } + }, + { + "json": { + "id": "23423536", + "name": "Edmund Pevensie", + "email": "edmund@narnia.gov", + "notes": "Passionate sailor", + "country": "UK", + "created": "1950-10-16" + } + } + ] + }, + "connections": { + "Break pairedItem chain": { + "main": [ + [ + { + "node": "Error", + "type": "main", + "index": 0 + } + ] + ] + }, + "Edit Fields": { + "main": [ + [ + { + "node": "Break pairedItem chain", + "type": "main", + "index": 0 + } + ] + ] + }, + "When clicking \"Test workflow\"": { + "main": [ + [ + { + "node": "Edit Fields", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "ca53267f-4eb4-481d-9e09-ecb97f6b09e2", + "meta": { + "templateCredsSetupCompleted": true, + "instanceId": "27cc9b56542ad45b38725555722c50a1c3fee1670bbb67980558314ee08517c4" + }, + "id": "6fr8GiRyMlZCiDQW", + "tags": [] + } diff --git a/cypress/pages/ndv.ts b/cypress/pages/ndv.ts index d9e40e42914e0..32cc4329b3190 100644 --- a/cypress/pages/ndv.ts +++ b/cypress/pages/ndv.ts @@ -124,6 +124,8 @@ export class NDV extends BasePage { codeEditorFullscreen: () => this.getters.codeEditorDialog().find('.cm-content'), nodeRunSuccessIndicator: () => cy.getByTestId('node-run-info-success'), nodeRunErrorIndicator: () => cy.getByTestId('node-run-info-danger'), + nodeRunErrorMessage: () => cy.getByTestId('node-error-message'), + nodeRunErrorDescription: () => cy.getByTestId('node-error-description'), }; actions = { diff --git a/package.json b/package.json index db1f0d70a9f76..e477ccb7aa264 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-monorepo", - "version": "1.42.0", + "version": "1.43.0", "private": true, "homepage": "https://n8n.io", "engines": { diff --git a/packages/@n8n/chat/package.json b/packages/@n8n/chat/package.json index 092987cf0e7d7..b161ccd947102 100644 --- a/packages/@n8n/chat/package.json +++ b/packages/@n8n/chat/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/chat", - "version": "0.14.0", + "version": "0.15.0", "scripts": { "dev": "pnpm run storybook", "build": "pnpm type-check && pnpm build:vite && pnpm run build:individual && npm run build:prepare", diff --git a/packages/@n8n/client-oauth2/package.json b/packages/@n8n/client-oauth2/package.json index 8d4edbf487f72..96f568f060f0a 100644 --- a/packages/@n8n/client-oauth2/package.json +++ b/packages/@n8n/client-oauth2/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/client-oauth2", - "version": "0.14.0", + "version": "0.15.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/imap/package.json b/packages/@n8n/imap/package.json index 66e1fcd49158c..b0c19477304b0 100644 --- a/packages/@n8n/imap/package.json +++ b/packages/@n8n/imap/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/imap", - "version": "0.2.0", + "version": "0.3.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts index f655ebd2543c0..1d15d718401d4 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts @@ -46,7 +46,7 @@ function getInputs( [NodeConnectionType.AiOutputParser]: 'Output Parser', }; - return inputs.map(({ type, filter, required }) => { + return inputs.map(({ type, filter }) => { const input: INodeInputConfiguration = { type, displayName: type in displayNames ? displayNames[type] : undefined, @@ -370,13 +370,13 @@ export class Agent implements INodeType { if (agentType === 'conversationalAgent') { return await conversationalAgentExecute.call(this, nodeVersion); } else if (agentType === 'toolsAgent') { - return await toolsAgentExecute.call(this, nodeVersion); + return await toolsAgentExecute.call(this); } else if (agentType === 'openAiFunctionsAgent') { return await openAiFunctionsAgentExecute.call(this, nodeVersion); } else if (agentType === 'reActAgent') { return await reActAgentAgentExecute.call(this, nodeVersion); } else if (agentType === 'sqlAgent') { - return await sqlAgentAgentExecute.call(this, nodeVersion); + return await sqlAgentAgentExecute.call(this); } else if (agentType === 'planAndExecuteAgent') { return await planAndExecuteAgentExecute.call(this, nodeVersion); } diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/execute.ts index fd14107627de4..3749547f83491 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/execute.ts @@ -13,6 +13,7 @@ import { getConnectedTools, } from '../../../../../utils/helpers'; import { getTracingConfig } from '../../../../../utils/tracing'; +import { throwIfToolSchema } from '../../../../../utils/schemaParsing'; export async function conversationalAgentExecute( this: IExecuteFunctions, @@ -111,6 +112,8 @@ export async function conversationalAgentExecute( returnData.push({ json: response }); } catch (error) { + throwIfToolSchema(this, error); + if (this.continueOnFail()) { returnData.push({ json: { error: error.message }, pairedItem: { item: itemIndex } }); continue; @@ -120,5 +123,5 @@ export async function conversationalAgentExecute( } } - return await this.prepareOutputData(returnData); + return [returnData]; } diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts index d6173c2847f00..5a58b9a46d227 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts @@ -125,5 +125,5 @@ export async function openAiFunctionsAgentExecute( } } - return await this.prepareOutputData(returnData); + return [returnData]; } diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/PlanAndExecuteAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/PlanAndExecuteAgent/execute.ts index 3957f867cd2ce..d0cc3a90a8b42 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/PlanAndExecuteAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/PlanAndExecuteAgent/execute.ts @@ -16,6 +16,7 @@ import { getPromptInputByType, } from '../../../../../utils/helpers'; import { getTracingConfig } from '../../../../../utils/tracing'; +import { throwIfToolSchema } from '../../../../../utils/schemaParsing'; export async function planAndExecuteAgentExecute( this: IExecuteFunctions, @@ -91,6 +92,7 @@ export async function planAndExecuteAgentExecute( returnData.push({ json: response }); } catch (error) { + throwIfToolSchema(this, error); if (this.continueOnFail()) { returnData.push({ json: { error: error.message }, pairedItem: { item: itemIndex } }); continue; @@ -100,5 +102,5 @@ export async function planAndExecuteAgentExecute( } } - return await this.prepareOutputData(returnData); + return [returnData]; } diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/execute.ts index a2a6392a5ff13..6f847432a4ff2 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/execute.ts @@ -18,6 +18,7 @@ import { isChatInstance, } from '../../../../../utils/helpers'; import { getTracingConfig } from '../../../../../utils/tracing'; +import { throwIfToolSchema } from '../../../../../utils/schemaParsing'; export async function reActAgentAgentExecute( this: IExecuteFunctions, @@ -112,6 +113,7 @@ export async function reActAgentAgentExecute( returnData.push({ json: response }); } catch (error) { + throwIfToolSchema(this, error); if (this.continueOnFail()) { returnData.push({ json: { error: error.message }, pairedItem: { item: itemIndex } }); continue; @@ -121,5 +123,5 @@ export async function reActAgentAgentExecute( } } - return await this.prepareOutputData(returnData); + return [returnData]; } diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/execute.ts index 36bc126868198..1820c0e9117cc 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/execute.ts @@ -28,7 +28,6 @@ const parseTablesString = (tablesString: string) => export async function sqlAgentAgentExecute( this: IExecuteFunctions, - nodeVersion: number, ): Promise { this.logger.verbose('Executing SQL Agent'); @@ -152,5 +151,5 @@ export async function sqlAgentAgentExecute( } } - return await this.prepareOutputData(returnData); + return [returnData]; } diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts index 65265f704e54c..11cc3a4de2047 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts @@ -39,10 +39,7 @@ function getOutputParserSchema(outputParser: BaseOutputParser): ZodObject { +export async function toolsAgentExecute(this: IExecuteFunctions): Promise { this.logger.verbose('Executing Tools Agent'); const model = await this.getInputConnectionData(NodeConnectionType.AiLanguageModel, 0); @@ -185,5 +182,5 @@ export async function toolsAgentExecute( } } - return await this.prepareOutputData(returnData); + return [returnData]; } diff --git a/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts index 77fa3911e02f5..449fcd41c493b 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts @@ -392,6 +392,6 @@ export class OpenAiAssistant implements INodeType { } } - return await this.prepareOutputData(returnData); + return [returnData]; } } diff --git a/packages/@n8n/nodes-langchain/nodes/chains/ChainRetrievalQA/ChainRetrievalQa.node.ts b/packages/@n8n/nodes-langchain/nodes/chains/ChainRetrievalQA/ChainRetrievalQa.node.ts index cc5cba5b7ca9c..8647db9b953be 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/ChainRetrievalQA/ChainRetrievalQa.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/ChainRetrievalQA/ChainRetrievalQa.node.ts @@ -189,6 +189,6 @@ export class ChainRetrievalQa implements INodeType { throw error; } } - return await this.prepareOutputData(returnData); + return [returnData]; } } diff --git a/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/V1/ChainSummarizationV1.node.ts b/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/V1/ChainSummarizationV1.node.ts index a5d19432774e8..bc18739647dad 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/V1/ChainSummarizationV1.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/V1/ChainSummarizationV1.node.ts @@ -258,6 +258,6 @@ export class ChainSummarizationV1 implements INodeType { returnData.push({ json: { response } }); } - return await this.prepareOutputData(returnData); + return [returnData]; } } diff --git a/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/V2/ChainSummarizationV2.node.ts b/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/V2/ChainSummarizationV2.node.ts index 9d05b0a7c5115..d441e6f728eb7 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/V2/ChainSummarizationV2.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/V2/ChainSummarizationV2.node.ts @@ -425,6 +425,6 @@ export class ChainSummarizationV2 implements INodeType { } } - return await this.prepareOutputData(returnData); + return [returnData]; } } diff --git a/packages/@n8n/nodes-langchain/nodes/code/Code.node.ts b/packages/@n8n/nodes-langchain/nodes/code/Code.node.ts index be15860b27970..4100d12348032 100644 --- a/packages/@n8n/nodes-langchain/nodes/code/Code.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/code/Code.node.ts @@ -92,6 +92,8 @@ function getSandbox( // eslint-disable-next-line @typescript-eslint/unbound-method context.executeWorkflow = this.executeWorkflow; // eslint-disable-next-line @typescript-eslint/unbound-method + context.getWorkflowDataProxy = this.getWorkflowDataProxy; + // eslint-disable-next-line @typescript-eslint/unbound-method context.logger = this.logger; if (options?.addItems) { diff --git a/packages/@n8n/nodes-langchain/nodes/memory/MemoryChatRetriever/MemoryChatRetriever.node.ts b/packages/@n8n/nodes-langchain/nodes/memory/MemoryChatRetriever/MemoryChatRetriever.node.ts index 9d8b220f493fb..6b9bf6203f964 100644 --- a/packages/@n8n/nodes-langchain/nodes/memory/MemoryChatRetriever/MemoryChatRetriever.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/memory/MemoryChatRetriever/MemoryChatRetriever.node.ts @@ -98,7 +98,7 @@ export class MemoryChatRetriever implements INodeType { const messages = await memory?.chatHistory.getMessages(); if (simplifyOutput && messages) { - return await this.prepareOutputData(simplifyMessages(messages)); + return [simplifyMessages(messages)]; } const serializedMessages = @@ -107,6 +107,6 @@ export class MemoryChatRetriever implements INodeType { return { json: serializedMessage as unknown as IDataObject }; }) ?? []; - return await this.prepareOutputData(serializedMessages); + return [serializedMessages]; } } diff --git a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/OutputParserStructured.node.ts b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/OutputParserStructured.node.ts index e41cd75fcb1cc..354ba8fbb0387 100644 --- a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/OutputParserStructured.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/OutputParserStructured.node.ts @@ -13,11 +13,15 @@ import type { JSONSchema7 } from 'json-schema'; import { StructuredOutputParser } from 'langchain/output_parsers'; import { OutputParserException } from '@langchain/core/output_parsers'; import get from 'lodash/get'; -import { getSandboxContext } from 'n8n-nodes-base/dist/nodes/Code/Sandbox'; -import { JavaScriptSandbox } from 'n8n-nodes-base/dist/nodes/Code/JavaScriptSandbox'; -import { makeResolverFromLegacyOptions } from '@n8n/vm2'; +import type { JavaScriptSandbox } from 'n8n-nodes-base/dist/nodes/Code/JavaScriptSandbox'; import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; import { logWrapper } from '../../../utils/logWrapper'; +import { generateSchema, getSandboxWithZod } from '../../../utils/schemaParsing'; +import { + inputSchemaField, + jsonSchemaExampleField, + schemaTypeField, +} from '../../../utils/descriptions'; const STRUCTURED_OUTPUT_KEY = '__structured__output'; const STRUCTURED_OUTPUT_OBJECT_KEY = '__structured__output__object'; @@ -87,8 +91,8 @@ export class OutputParserStructured implements INodeType { name: 'outputParserStructured', icon: 'fa:code', group: ['transform'], - version: [1, 1.1], - defaultVersion: 1.1, + version: [1, 1.1, 1.2], + defaultVersion: 1.2, description: 'Return data in a defined JSON format', defaults: { name: 'Structured Output Parser', @@ -115,6 +119,33 @@ export class OutputParserStructured implements INodeType { outputNames: ['Output Parser'], properties: [ getConnectionHintNoticeField([NodeConnectionType.AiChain, NodeConnectionType.AiAgent]), + { ...schemaTypeField, displayOptions: { show: { '@version': [{ _cnd: { gte: 1.2 } }] } } }, + { + ...jsonSchemaExampleField, + default: `{ + "state": "California", + "cities": ["Los Angeles", "San Francisco", "San Diego"] +}`, + }, + { + ...inputSchemaField, + displayName: 'JSON Schema', + description: 'JSON Schema to structure and validate the output against', + default: `{ + "type": "object", + "properties": { + "state": { + "type": "string" + }, + "cities": { + "type": "array", + "items": { + "type": "string" + } + } + } +}`, + }, { displayName: 'JSON Schema', name: 'jsonSchema', @@ -138,6 +169,11 @@ export class OutputParserStructured implements INodeType { rows: 10, }, required: true, + displayOptions: { + show: { + '@version': [{ _cnd: { lte: 1.1 } }], + }, + }, }, { displayName: @@ -145,72 +181,36 @@ export class OutputParserStructured implements INodeType { name: 'notice', type: 'notice', default: '', + displayOptions: { + hide: { + schemaType: ['fromJson'], + }, + }, }, ], }; async supplyData(this: IExecuteFunctions, itemIndex: number): Promise { - const schema = this.getNodeParameter('jsonSchema', itemIndex) as string; + const schemaType = this.getNodeParameter('schemaType', itemIndex, '') as 'fromJson' | 'manual'; + // We initialize these even though one of them will always be empty + // it makes it easer to navigate the ternary operator + const jsonExample = this.getNodeParameter('jsonSchemaExample', itemIndex, '') as string; + let inputSchema: string; - let itemSchema: JSONSchema7; - try { - itemSchema = jsonParse(schema); - - // If the type is not defined, we assume it's an object - if (itemSchema.type === undefined) { - itemSchema = { - type: 'object', - properties: itemSchema.properties ?? (itemSchema as { [key: string]: JSONSchema7 }), - }; - } - } catch (error) { - throw new NodeOperationError(this.getNode(), 'Error during parsing of JSON Schema.'); + if (this.getNode().typeVersion <= 1.1) { + inputSchema = this.getNodeParameter('jsonSchema', itemIndex, '') as string; + } else { + inputSchema = this.getNodeParameter('inputSchema', itemIndex, '') as string; } - const vmResolver = makeResolverFromLegacyOptions({ - external: { - modules: ['json-schema-to-zod', 'zod'], - transitive: false, - }, - resolve(moduleName, parentDirname) { - if (moduleName === 'json-schema-to-zod') { - return require.resolve( - '@n8n/n8n-nodes-langchain/node_modules/json-schema-to-zod/dist/cjs/jsonSchemaToZod.js', - { - paths: [parentDirname], - }, - ); - } - if (moduleName === 'zod') { - return require.resolve('@n8n/n8n-nodes-langchain/node_modules/zod.cjs', { - paths: [parentDirname], - }); - } - return; - }, - builtin: [], - }); - const context = getSandboxContext.call(this, itemIndex); - // Make sure to remove the description from root schema - const { description, ...restOfSchema } = itemSchema; - const sandboxedSchema = new JavaScriptSandbox( - context, - ` - const { z } = require('zod'); - const { parseSchema } = require('json-schema-to-zod'); - const zodSchema = parseSchema(${JSON.stringify(restOfSchema)}); - const itemSchema = new Function('z', 'return (' + zodSchema + ')')(z) - return itemSchema - `, - itemIndex, - this.helpers, - { resolver: vmResolver }, - ); + const jsonSchema = + schemaType === 'fromJson' ? generateSchema(jsonExample) : jsonParse(inputSchema); + const zodSchemaSandbox = getSandboxWithZod(this, jsonSchema, 0); const nodeVersion = this.getNode().typeVersion; try { const parser = await N8nStructuredOutputParser.fromZedJsonSchema( - sandboxedSchema, + zodSchemaSandbox, nodeVersion, ); return { diff --git a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/test/OutputParserStructured.node.test.ts b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/test/OutputParserStructured.node.test.ts index 6daac7ab9d139..b4dd6708eb629 100644 --- a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/test/OutputParserStructured.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/test/OutputParserStructured.node.test.ts @@ -1,4 +1,4 @@ -import type { IExecuteFunctions, IWorkflowDataProxyData } from 'n8n-workflow'; +import type { IExecuteFunctions, INode, IWorkflowDataProxyData } from 'n8n-workflow'; import { mock } from 'jest-mock-extended'; import { normalizeItems } from 'n8n-core'; import type { z } from 'zod'; @@ -12,7 +12,7 @@ describe('OutputParserStructured', () => { }); const workflowDataProxy = mock({ $input: mock() }); thisArg.getWorkflowDataProxy.mockReturnValue(workflowDataProxy); - thisArg.getNode.mockReturnValue({ typeVersion: 1.1 }); + thisArg.getNode.mockReturnValue(mock({ typeVersion: 1.1 })); thisArg.addInputData.mockReturnValue({ index: 0 }); thisArg.addOutputData.mockReturnValue(); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts index 5130cf7d2924d..3b06c841864da 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts @@ -9,16 +9,23 @@ import type { ExecutionError, IDataObject, } from 'n8n-workflow'; -import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; +import { NodeConnectionType, NodeOperationError, jsonParse } from 'n8n-workflow'; import type { SetField, SetNodeOptions } from 'n8n-nodes-base/dist/nodes/Set/v2/helpers/interfaces'; import * as manual from 'n8n-nodes-base/dist/nodes/Set/v2/manual.mode'; -import { DynamicTool } from '@langchain/core/tools'; +import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools'; import get from 'lodash/get'; import isObject from 'lodash/isObject'; import type { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager'; +import type { JSONSchema7 } from 'json-schema'; import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; - +import type { DynamicZodObject } from '../../../types/zod.types'; +import { generateSchema, getSandboxWithZod } from '../../../utils/schemaParsing'; +import { + jsonSchemaExampleField, + schemaTypeField, + inputSchemaField, +} from '../../../utils/descriptions'; export class ToolWorkflow implements INodeType { description: INodeTypeDescription = { displayName: 'Custom n8n Workflow Tool', @@ -314,6 +321,21 @@ export class ToolWorkflow implements INodeType { }, ], }, + // ---------------------------------- + // Output Parsing + // ---------------------------------- + { + displayName: 'Specify Input Schema', + name: 'specifyInputSchema', + type: 'boolean', + description: + 'Whether to specify the schema for the function. This would require the LLM to provide the input in the correct format and would validate it against the schema.', + noDataExpression: true, + default: false, + }, + { ...schemaTypeField, displayOptions: { show: { specifyInputSchema: [true] } } }, + jsonSchemaExampleField, + inputSchemaField, ], }; @@ -321,8 +343,11 @@ export class ToolWorkflow implements INodeType { const name = this.getNodeParameter('name', itemIndex) as string; const description = this.getNodeParameter('description', itemIndex) as string; + const useSchema = this.getNodeParameter('specifyInputSchema', itemIndex) as boolean; + let tool: DynamicTool | DynamicStructuredTool | undefined = undefined; + const runFunction = async ( - query: string, + query: string | IDataObject, runManager?: CallbackManagerForToolRun, ): Promise => { const source = this.getNodeParameter('source', itemIndex) as string; @@ -416,50 +441,86 @@ export class ToolWorkflow implements INodeType { return response; }; - return { - response: new DynamicTool({ - name, - description, + const toolHandler = async ( + query: string | IDataObject, + runManager?: CallbackManagerForToolRun, + ): Promise => { + const { index } = this.addInputData(NodeConnectionType.AiTool, [[{ json: { query } }]]); - func: async (query: string, runManager?: CallbackManagerForToolRun): Promise => { - const { index } = this.addInputData(NodeConnectionType.AiTool, [[{ json: { query } }]]); + let response: string = ''; + let executionError: ExecutionError | undefined; + try { + response = await runFunction(query, runManager); + } catch (error) { + // TODO: Do some more testing. Issues here should actually fail the workflow + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + executionError = error; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + response = `There was an error: "${error.message}"`; + } - let response: string = ''; - let executionError: ExecutionError | undefined; - try { - response = await runFunction(query, runManager); - } catch (error) { - // TODO: Do some more testing. Issues here should actually fail the workflow - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - executionError = error; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - response = `There was an error: "${error.message}"`; - } + if (typeof response === 'number') { + response = (response as number).toString(); + } - if (typeof response === 'number') { - response = (response as number).toString(); - } + if (isObject(response)) { + response = JSON.stringify(response, null, 2); + } - if (isObject(response)) { - response = JSON.stringify(response, null, 2); - } + if (typeof response !== 'string') { + // TODO: Do some more testing. Issues here should actually fail the workflow + executionError = new NodeOperationError(this.getNode(), 'Wrong output type returned', { + description: `The response property should be a string, but it is an ${typeof response}`, + }); + response = `There was an error: "${executionError.message}"`; + } - if (typeof response !== 'string') { - // TODO: Do some more testing. Issues here should actually fail the workflow - executionError = new NodeOperationError(this.getNode(), 'Wrong output type returned', { - description: `The response property should be a string, but it is an ${typeof response}`, - }); - response = `There was an error: "${executionError.message}"`; - } + if (executionError) { + void this.addOutputData(NodeConnectionType.AiTool, index, executionError); + } else { + void this.addOutputData(NodeConnectionType.AiTool, index, [[{ json: { response } }]]); + } + return response; + }; - if (executionError) { - void this.addOutputData(NodeConnectionType.AiTool, index, executionError); - } else { - void this.addOutputData(NodeConnectionType.AiTool, index, [[{ json: { response } }]]); - } - return response; - }, - }), + const functionBase = { + name, + description, + func: toolHandler, + }; + + if (useSchema) { + try { + // We initialize these even though one of them will always be empty + // it makes it easer to navigate the ternary operator + const jsonExample = this.getNodeParameter('jsonSchemaExample', itemIndex, '') as string; + const inputSchema = this.getNodeParameter('inputSchema', itemIndex, '') as string; + + const schemaType = this.getNodeParameter('schemaType', itemIndex) as 'fromJson' | 'manual'; + const jsonSchema = + schemaType === 'fromJson' + ? generateSchema(jsonExample) + : jsonParse(inputSchema); + + const zodSchemaSandbox = getSandboxWithZod(this, jsonSchema, 0); + const zodSchema = (await zodSchemaSandbox.runCode()) as DynamicZodObject; + + tool = new DynamicStructuredTool({ + schema: zodSchema, + ...functionBase, + }); + } catch (error) { + throw new NodeOperationError( + this.getNode(), + 'Error during parsing of JSON Schema. \n ' + error, + ); + } + } else { + tool = new DynamicTool(functionBase); + } + + return { + response: tool, }; } } diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemoryInsert/VectorStoreInMemoryInsert.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemoryInsert/VectorStoreInMemoryInsert.node.ts index c9c1b560b9c17..225201a5e1265 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemoryInsert/VectorStoreInMemoryInsert.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemoryInsert/VectorStoreInMemoryInsert.node.ts @@ -108,6 +108,6 @@ export class VectorStoreInMemoryInsert implements INodeType { clearStore, ); - return await this.prepareOutputData(serializedDocuments); + return [serializedDocuments]; } } diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePineconeInsert/VectorStorePineconeInsert.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePineconeInsert/VectorStorePineconeInsert.node.ts index b024f9b09bd49..93e3e4d0419fa 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePineconeInsert/VectorStorePineconeInsert.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePineconeInsert/VectorStorePineconeInsert.node.ts @@ -134,6 +134,6 @@ export class VectorStorePineconeInsert implements INodeType { pineconeIndex, }); - return await this.prepareOutputData(serializedDocuments); + return [serializedDocuments]; } } diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreQdrant/VectorStoreQdrant.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreQdrant/VectorStoreQdrant.node.ts index f183d1b1fe9e6..7714b800f5ef3 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreQdrant/VectorStoreQdrant.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreQdrant/VectorStoreQdrant.node.ts @@ -46,7 +46,7 @@ export const VectorStoreQdrant = createVectorStoreNode({ methods: { listSearch: { qdrantCollectionsSearch } }, insertFields, sharedFields, - async getVectorStoreClient(context, filter, embeddings, itemIndex) { + async getVectorStoreClient(context, _, embeddings, itemIndex) { const collection = context.getNodeParameter('qdrantCollection', itemIndex, '', { extractValue: true, }) as string; diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabaseInsert/VectorStoreSupabaseInsert.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabaseInsert/VectorStoreSupabaseInsert.node.ts index d2e772af9e4f3..1eae86971a325 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabaseInsert/VectorStoreSupabaseInsert.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabaseInsert/VectorStoreSupabaseInsert.node.ts @@ -122,6 +122,6 @@ export class VectorStoreSupabaseInsert implements INodeType { queryName, }); - return await this.prepareOutputData(serializedDocuments); + return [serializedDocuments]; } } diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreZepInsert/VectorStoreZepInsert.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreZepInsert/VectorStoreZepInsert.node.ts index 53606927700b2..6b2581970840f 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreZepInsert/VectorStoreZepInsert.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreZepInsert/VectorStoreZepInsert.node.ts @@ -139,6 +139,6 @@ export class VectorStoreZepInsert implements INodeType { await ZepVectorStore.fromDocuments(processedDocuments, embeddings, zepConfig); - return await this.prepareOutputData(serializedDocuments); + return [serializedDocuments]; } } diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode.ts index e9045bd107309..7eb40d6371040 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode.ts @@ -240,7 +240,7 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) => void logAiEvent(this, 'n8n.ai.vector.store.searched', { query: prompt }); } - return await this.prepareOutputData(resultData); + return [resultData]; } if (mode === 'insert') { @@ -270,7 +270,7 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) => } } - return await this.prepareOutputData(resultData); + return [resultData]; } throw new NodeOperationError( diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index 23854907f7534..8cd8f5f11aced 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.42.0", + "version": "1.43.0", "description": "", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -157,6 +157,7 @@ "d3-dsv": "2.0.0", "epub2": "3.0.2", "form-data": "4.0.0", + "generate-schema": "^2.6.0", "html-to-text": "9.0.5", "jest-mock-extended": "^3.0.4", "json-schema-to-zod": "2.0.14", diff --git a/packages/@n8n/nodes-langchain/tsconfig.build.json b/packages/@n8n/nodes-langchain/tsconfig.build.json index d7b07412f61eb..a3b8ff9a405a2 100644 --- a/packages/@n8n/nodes-langchain/tsconfig.build.json +++ b/packages/@n8n/nodes-langchain/tsconfig.build.json @@ -11,7 +11,8 @@ "credentials/**/*.ts", "nodes/**/*.ts", "nodes/**/*.json", - "credentials/translations/**/*.json" + "credentials/translations/**/*.json", + "types/*.ts" ], "exclude": ["nodes/**/*.test.ts", "test/**"] } diff --git a/packages/@n8n/nodes-langchain/tsconfig.json b/packages/@n8n/nodes-langchain/tsconfig.json index 8377c89500383..734160344cb9d 100644 --- a/packages/@n8n/nodes-langchain/tsconfig.json +++ b/packages/@n8n/nodes-langchain/tsconfig.json @@ -20,5 +20,5 @@ "skipLibCheck": true, "outDir": "./dist/" }, - "include": ["credentials/**/*", "nodes/**/*", "utils/**/*.ts", "nodes/**/*.json"] + "include": ["credentials/**/*", "nodes/**/*", "utils/**/*.ts", "nodes/**/*.json", "types/*.ts"] } diff --git a/packages/@n8n/nodes-langchain/types/generate-schema.d.ts b/packages/@n8n/nodes-langchain/types/generate-schema.d.ts new file mode 100644 index 0000000000000..90e0e15b05cac --- /dev/null +++ b/packages/@n8n/nodes-langchain/types/generate-schema.d.ts @@ -0,0 +1,27 @@ +declare module 'generate-schema' { + export interface SchemaObject { + $schema: string; + title?: string; + type: string; + properties?: { + [key: string]: SchemaObject | SchemaArray | SchemaProperty; + }; + required?: string[]; + items?: SchemaObject | SchemaArray; + } + + export interface SchemaArray { + type: string; + items?: SchemaObject | SchemaArray | SchemaProperty; + oneOf?: Array; + required?: string[]; + } + + export interface SchemaProperty { + type: string | string[]; + format?: string; + } + + export function json(title: string, schema: SchemaObject): SchemaObject; + export function json(schema: SchemaObject): SchemaObject; +} diff --git a/packages/@n8n/nodes-langchain/types/zod.types.ts b/packages/@n8n/nodes-langchain/types/zod.types.ts new file mode 100644 index 0000000000000..933bd1e33d34b --- /dev/null +++ b/packages/@n8n/nodes-langchain/types/zod.types.ts @@ -0,0 +1,4 @@ +import type { z } from 'zod'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type DynamicZodObject = z.ZodObject; diff --git a/packages/@n8n/nodes-langchain/utils/descriptions.ts b/packages/@n8n/nodes-langchain/utils/descriptions.ts index 19ef99213fb6e..b779df1be4a12 100644 --- a/packages/@n8n/nodes-langchain/utils/descriptions.ts +++ b/packages/@n8n/nodes-langchain/utils/descriptions.ts @@ -1,5 +1,70 @@ import type { INodeProperties } from 'n8n-workflow'; +export const schemaTypeField: INodeProperties = { + displayName: 'Schema Type', + name: 'schemaType', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Generate From JSON Example', + value: 'fromJson', + description: 'Generate a schema from an example JSON object', + }, + { + name: 'Define Below', + value: 'manual', + description: 'Define the JSON schema manually', + }, + ], + default: 'fromJson', + description: 'How to specify the schema for the function', +}; + +export const jsonSchemaExampleField: INodeProperties = { + displayName: 'JSON Example', + name: 'jsonSchemaExample', + type: 'json', + default: `{ + "some_input": "some_value" +}`, + noDataExpression: true, + typeOptions: { + rows: 10, + }, + displayOptions: { + show: { + schemaType: ['fromJson'], + }, + }, + description: 'Example JSON object to use to generate the schema', +}; + +export const inputSchemaField: INodeProperties = { + displayName: 'Input Schema', + name: 'inputSchema', + type: 'json', + default: `{ +"type": "object", +"properties": { + "some_input": { + "type": "string", + "description": "Some input to the function" + } + } +}`, + noDataExpression: true, + typeOptions: { + rows: 10, + }, + displayOptions: { + show: { + schemaType: ['manual'], + }, + }, + description: 'Schema to use for the function', +}; + export const promptTypeOptions: INodeProperties = { displayName: 'Prompt', name: 'promptType', diff --git a/packages/@n8n/nodes-langchain/utils/schemaParsing.ts b/packages/@n8n/nodes-langchain/utils/schemaParsing.ts new file mode 100644 index 0000000000000..8d5f61153dace --- /dev/null +++ b/packages/@n8n/nodes-langchain/utils/schemaParsing.ts @@ -0,0 +1,81 @@ +import { makeResolverFromLegacyOptions } from '@n8n/vm2'; +import { json as generateJsonSchema } from 'generate-schema'; +import type { SchemaObject } from 'generate-schema'; +import type { JSONSchema7 } from 'json-schema'; +import { JavaScriptSandbox } from 'n8n-nodes-base/dist/nodes/Code/JavaScriptSandbox'; +import { getSandboxContext } from 'n8n-nodes-base/dist/nodes/Code/Sandbox'; +import type { IExecuteFunctions } from 'n8n-workflow'; +import { NodeOperationError, jsonParse } from 'n8n-workflow'; + +const vmResolver = makeResolverFromLegacyOptions({ + external: { + modules: ['json-schema-to-zod', 'zod'], + transitive: false, + }, + resolve(moduleName, parentDirname) { + if (moduleName === 'json-schema-to-zod') { + return require.resolve( + '@n8n/n8n-nodes-langchain/node_modules/json-schema-to-zod/dist/cjs/jsonSchemaToZod.js', + { + paths: [parentDirname], + }, + ); + } + if (moduleName === 'zod') { + return require.resolve('@n8n/n8n-nodes-langchain/node_modules/zod.cjs', { + paths: [parentDirname], + }); + } + return; + }, + builtin: [], +}); + +export function getSandboxWithZod(ctx: IExecuteFunctions, schema: JSONSchema7, itemIndex: number) { + const context = getSandboxContext.call(ctx, itemIndex); + let itemSchema: JSONSchema7 = schema; + try { + // If the root type is not defined, we assume it's an object + if (itemSchema.type === undefined) { + itemSchema = { + type: 'object', + properties: itemSchema.properties ?? (itemSchema as { [key: string]: JSONSchema7 }), + }; + } + } catch (error) { + throw new NodeOperationError(ctx.getNode(), 'Error during parsing of JSON Schema.'); + } + + // Make sure to remove the description from root schema + const { description, ...restOfSchema } = itemSchema; + const sandboxedSchema = new JavaScriptSandbox( + context, + ` + const { z } = require('zod'); + const { parseSchema } = require('json-schema-to-zod'); + const zodSchema = parseSchema(${JSON.stringify(restOfSchema)}); + const itemSchema = new Function('z', 'return (' + zodSchema + ')')(z) + return itemSchema + `, + itemIndex, + ctx.helpers, + { resolver: vmResolver }, + ); + return sandboxedSchema; +} + +export function generateSchema(schemaString: string): JSONSchema7 { + const parsedSchema = jsonParse(schemaString); + + return generateJsonSchema(parsedSchema) as JSONSchema7; +} + +export function throwIfToolSchema(ctx: IExecuteFunctions, error: Error) { + if (error?.message?.includes('tool input did not match expected schema')) { + throw new NodeOperationError( + ctx.getNode(), + `${error.message}. + This is most likely because some of your tools are configured to require a specific schema. This is not supported by Conversational Agent. Remove the schema from the tool configuration or use Tools agent instead.`, + ); + } +} diff --git a/packages/@n8n/permissions/package.json b/packages/@n8n/permissions/package.json index 914aba2955501..be4777416995a 100644 --- a/packages/@n8n/permissions/package.json +++ b/packages/@n8n/permissions/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/permissions", - "version": "0.6.0", + "version": "0.7.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/cli/package.json b/packages/cli/package.json index 88175092efaf7..90ab4474ddb35 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.42.0", + "version": "1.43.0", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts index 6a7cfa208d802..20b7d5f949488 100644 --- a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts @@ -69,6 +69,7 @@ export async function saveCredential( const personalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( user.id, + transactionManager, ); Object.assign(newSharedCredential, { diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts index bb7b8bebd262a..d301e61c93966 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts @@ -7,7 +7,6 @@ import { SharedWorkflow, type WorkflowSharingRole } from '@db/entities/SharedWor import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import type { Project } from '@/databases/entities/Project'; -import { WorkflowTagMappingRepository } from '@db/repositories/workflowTagMapping.repository'; import { TagRepository } from '@db/repositories/tag.repository'; import { License } from '@/License'; import { WorkflowSharingService } from '@/workflows/workflowSharing.service'; @@ -113,9 +112,7 @@ export async function getWorkflowTags(workflowId: string) { export async function updateTags(workflowId: string, newTags: string[]): Promise { await Db.transaction(async (transactionManager) => { - const oldTags = await Container.get(WorkflowTagMappingRepository).findBy({ - workflowId, - }); + const oldTags = await transactionManager.findBy(WorkflowTagMapping, { workflowId }); if (oldTags.length > 0) { await transactionManager.delete(WorkflowTagMapping, oldTags); } diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index c8054a78b3fd5..446ec367a596b 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -371,7 +371,8 @@ export class Server extends AbstractServer { const isPreviewMode = process.env.N8N_PREVIEW_MODE === 'true'; const securityHeadersMiddleware = helmet({ contentSecurityPolicy: false, - xFrameOptions: isPreviewMode || inE2ETests ? false : { action: 'sameorigin' }, + xFrameOptions: + isPreviewMode || inE2ETests || inDevelopment ? false : { action: 'sameorigin' }, dnsPrefetchControl: false, // This is only relevant for Internet-explorer, which we do not support ieNoOpen: false, diff --git a/packages/cli/src/WaitTracker.ts b/packages/cli/src/WaitTracker.ts index ae6dbf9a627ad..067a58e224544 100644 --- a/packages/cli/src/WaitTracker.ts +++ b/packages/cli/src/WaitTracker.ts @@ -3,7 +3,7 @@ import { ErrorReporterProxy as ErrorReporter, WorkflowOperationError, } from 'n8n-workflow'; -import { Container, Service } from 'typedi'; +import { Service } from 'typedi'; import type { ExecutionStopResult, IWorkflowExecutionDataProcess } from '@/Interfaces'; import { WorkflowRunner } from '@/WorkflowRunner'; import { ExecutionRepository } from '@db/repositories/execution.repository'; @@ -137,10 +137,7 @@ export class WaitTracker { fullExecutionData.waitTill = null; fullExecutionData.status = 'canceled'; - await Container.get(ExecutionRepository).updateExistingExecution( - executionId, - fullExecutionData, - ); + await this.executionRepository.updateExistingExecution(executionId, fullExecutionData); return { mode: fullExecutionData.mode, diff --git a/packages/cli/src/commands/import/credentials.ts b/packages/cli/src/commands/import/credentials.ts index 03e96aa646b89..6c47a25d96d13 100644 --- a/packages/cli/src/commands/import/credentials.ts +++ b/packages/cli/src/commands/import/credentials.ts @@ -13,9 +13,9 @@ import { BaseCommand } from '../BaseCommand'; import type { ICredentialsEncrypted } from 'n8n-workflow'; import { ApplicationError, jsonParse } from 'n8n-workflow'; import { UM_FIX_INSTRUCTION } from '@/constants'; -import { UserRepository } from '@db/repositories/user.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository'; -import type { Project } from '@/databases/entities/Project'; +import { Project } from '@/databases/entities/Project'; +import { User } from '@/databases/entities/User'; export class ImportCredentialsCommand extends BaseCommand { static description = 'Import credentials'; @@ -75,13 +75,13 @@ export class ImportCredentialsCommand extends BaseCommand { ); } - const project = await this.getProject(flags.userId, flags.projectId); - const credentials = await this.readCredentials(flags.input, flags.separate); await Db.getConnection().transaction(async (transactionManager) => { this.transactionManager = transactionManager; + const project = await this.getProject(flags.userId, flags.projectId); + const result = await this.checkRelations(credentials, flags.projectId, flags.userId); if (!result.success) { @@ -130,19 +130,6 @@ export class ImportCredentialsCommand extends BaseCommand { } } - private async getOwnerProject() { - const owner = await Container.get(UserRepository).findOneBy({ role: 'global:owner' }); - if (!owner) { - throw new ApplicationError(`Failed to find owner. ${UM_FIX_INSTRUCTION}`); - } - - const project = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( - owner.id, - ); - - return project; - } - private async checkRelations( credentials: ICredentialsEncrypted[], projectId?: string, @@ -244,7 +231,7 @@ export class ImportCredentialsCommand extends BaseCommand { }); if (sharedCredential && sharedCredential.project.type === 'personal') { - const user = await Container.get(UserRepository).findOneByOrFail({ + const user = await this.transactionManager.findOneByOrFail(User, { projectRelations: { role: 'project:personalOwner', projectId: sharedCredential.projectId, @@ -263,13 +250,20 @@ export class ImportCredentialsCommand extends BaseCommand { private async getProject(userId?: string, projectId?: string) { if (projectId) { - return await Container.get(ProjectRepository).findOneByOrFail({ id: projectId }); + return await this.transactionManager.findOneByOrFail(Project, { id: projectId }); } - if (userId) { - return await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(userId); + if (!userId) { + const owner = await this.transactionManager.findOneBy(User, { role: 'global:owner' }); + if (!owner) { + throw new ApplicationError(`Failed to find owner. ${UM_FIX_INSTRUCTION}`); + } + userId = owner.id; } - return await this.getOwnerProject(); + return await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + userId, + this.transactionManager, + ); } } diff --git a/packages/cli/src/commands/import/workflow.ts b/packages/cli/src/commands/import/workflow.ts index 7a6b7c38f2426..87bb590d6b09c 100644 --- a/packages/cli/src/commands/import/workflow.ts +++ b/packages/cli/src/commands/import/workflow.ts @@ -160,19 +160,6 @@ export class ImportWorkflowsCommand extends BaseCommand { this.logger.info(`Successfully imported ${total} ${total === 1 ? 'workflow.' : 'workflows.'}`); } - private async getOwnerProject() { - const owner = await Container.get(UserRepository).findOneBy({ role: 'global:owner' }); - if (!owner) { - throw new ApplicationError(`Failed to find owner. ${UM_FIX_INSTRUCTION}`); - } - - const project = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( - owner.id, - ); - - return project; - } - private async getWorkflowOwner(workflow: WorkflowEntity) { const sharing = await Container.get(SharedWorkflowRepository).findOne({ where: { workflowId: workflow.id, role: 'workflow:owner' }, @@ -234,10 +221,14 @@ export class ImportWorkflowsCommand extends BaseCommand { return await Container.get(ProjectRepository).findOneByOrFail({ id: projectId }); } - if (userId) { - return await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(userId); + if (!userId) { + const owner = await Container.get(UserRepository).findOneBy({ role: 'global:owner' }); + if (!owner) { + throw new ApplicationError(`Failed to find owner. ${UM_FIX_INSTRUCTION}`); + } + userId = owner.id; } - return await this.getOwnerProject(); + return await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(userId); } } diff --git a/packages/cli/src/controllers/users.controller.ts b/packages/cli/src/controllers/users.controller.ts index 391c98c70fff8..b4bfad1f91720 100644 --- a/packages/cli/src/controllers/users.controller.ts +++ b/packages/cli/src/controllers/users.controller.ts @@ -115,6 +115,10 @@ export class UsersController { throw new NotFoundError('User not found'); } + if (req.user.role === 'global:admin' && user.role === 'global:owner') { + throw new ForbiddenError('Admin cannot reset password of global owner'); + } + const link = this.authService.generatePasswordResetUrl(user); return { link }; } @@ -164,6 +168,10 @@ export class UsersController { ); } + if (userToDelete.role === 'global:owner') { + throw new ForbiddenError('Instance owner cannot be deleted.'); + } + const personalProjectToDelete = await this.projectRepository.getPersonalProjectForUserOrFail( userToDelete.id, ); diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index d23dbf0cc1eb4..8ce9cdb1d1e4e 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -262,7 +262,10 @@ export class CredentialsService { const project = projectId === undefined - ? await this.projectRepository.getPersonalProjectForUserOrFail(user.id) + ? await this.projectRepository.getPersonalProjectForUserOrFail( + user.id, + transactionManager, + ) : await this.projectService.getProjectWithScope( user, projectId, diff --git a/packages/cli/src/databases/entities/ExecutionEntity.ts b/packages/cli/src/databases/entities/ExecutionEntity.ts index 14fad4d50e905..dbd597a82869f 100644 --- a/packages/cli/src/databases/entities/ExecutionEntity.ts +++ b/packages/cli/src/databases/entities/ExecutionEntity.ts @@ -40,7 +40,7 @@ export class ExecutionEntity { @Column({ nullable: true }) retrySuccessId: string; - @Column('varchar', { nullable: true }) + @Column('varchar') status: ExecutionStatus; @Column(datetimeColumnType) diff --git a/packages/cli/src/databases/migrations/common/1714133768521-MakeExecutionStatusNonNullable.ts b/packages/cli/src/databases/migrations/common/1714133768521-MakeExecutionStatusNonNullable.ts new file mode 100644 index 0000000000000..c3ccfed43d70a --- /dev/null +++ b/packages/cli/src/databases/migrations/common/1714133768521-MakeExecutionStatusNonNullable.ts @@ -0,0 +1,22 @@ +import type { IrreversibleMigration, MigrationContext } from '@/databases/types'; + +export class MakeExecutionStatusNonNullable1714133768521 implements IrreversibleMigration { + async up({ escape, runQuery, schemaBuilder }: MigrationContext) { + const executionEntity = escape.tableName('execution_entity'); + const status = escape.columnName('status'); + const finished = escape.columnName('finished'); + + const query = ` + UPDATE ${executionEntity} + SET ${status} = CASE + WHEN ${finished} = true THEN 'success' + WHEN ${finished} = false THEN 'error' + END + WHERE ${status} IS NULL; + `; + + await runQuery(query); + + await schemaBuilder.addNotNull('execution_entity', 'status'); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index daa57b2c5bcce..8b467999f52ae 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -56,6 +56,7 @@ import { DropRoleMapping1705429061930 } from '../common/1705429061930-DropRoleMa import { RemoveFailedExecutionStatus1711018413374 } from '../common/1711018413374-RemoveFailedExecutionStatus'; import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase'; import { RemoveNodesAccess1712044305787 } from '../common/1712044305787-RemoveNodesAccess'; +import { MakeExecutionStatusNonNullable1714133768521 } from '../common/1714133768521-MakeExecutionStatusNonNullable'; export const mysqlMigrations: Migration[] = [ InitialMigration1588157391238, @@ -115,4 +116,5 @@ export const mysqlMigrations: Migration[] = [ MoveSshKeysToDatabase1711390882123, RemoveNodesAccess1712044305787, CreateProject1714133768519, + MakeExecutionStatusNonNullable1714133768521, ]; diff --git a/packages/cli/src/databases/migrations/postgresdb/index.ts b/packages/cli/src/databases/migrations/postgresdb/index.ts index b31e2970d2af8..6ca797c1da97f 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -55,6 +55,7 @@ import { DropRoleMapping1705429061930 } from '../common/1705429061930-DropRoleMa import { RemoveFailedExecutionStatus1711018413374 } from '../common/1711018413374-RemoveFailedExecutionStatus'; import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase'; import { RemoveNodesAccess1712044305787 } from '../common/1712044305787-RemoveNodesAccess'; +import { MakeExecutionStatusNonNullable1714133768521 } from '../common/1714133768521-MakeExecutionStatusNonNullable'; export const postgresMigrations: Migration[] = [ InitialMigration1587669153312, @@ -113,4 +114,5 @@ export const postgresMigrations: Migration[] = [ MoveSshKeysToDatabase1711390882123, RemoveNodesAccess1712044305787, CreateProject1714133768519, + MakeExecutionStatusNonNullable1714133768521, ]; diff --git a/packages/cli/src/databases/migrations/sqlite/index.ts b/packages/cli/src/databases/migrations/sqlite/index.ts index 834354fd6b9b8..aefd1649b46e1 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -53,6 +53,7 @@ import { DropRoleMapping1705429061930 } from './1705429061930-DropRoleMapping'; import { RemoveFailedExecutionStatus1711018413374 } from '../common/1711018413374-RemoveFailedExecutionStatus'; import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase'; import { RemoveNodesAccess1712044305787 } from '../common/1712044305787-RemoveNodesAccess'; +import { MakeExecutionStatusNonNullable1714133768521 } from '../common/1714133768521-MakeExecutionStatusNonNullable'; const sqliteMigrations: Migration[] = [ InitialMigration1588102412422, @@ -109,6 +110,7 @@ const sqliteMigrations: Migration[] = [ MoveSshKeysToDatabase1711390882123, RemoveNodesAccess1712044305787, CreateProject1714133768519, + MakeExecutionStatusNonNullable1714133768521, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 8043ee5e5d396..33ec7ed7ec993 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -728,12 +728,17 @@ export class ExecutionRepository extends Repository { if (startedBefore) qb.andWhere({ startedAt: lessThanOrEqual(startedBefore) }); if (startedAfter) qb.andWhere({ startedAt: moreThanOrEqual(startedAfter) }); - if (metadata) { - qb.leftJoin(ExecutionMetadata, 'md', 'md.executionId = execution.id'); + if (metadata?.length === 1) { + const [{ key, value }] = metadata; - for (const item of metadata) { - qb.andWhere('md.key = :key AND md.value = :value', item); - } + qb.innerJoin( + ExecutionMetadata, + 'md', + 'md.executionId = execution.id AND md.key = :key AND md.value = :value', + ); + + qb.setParameter('key', key); + qb.setParameter('value', value); } return qb; diff --git a/packages/cli/src/databases/repositories/project.repository.ts b/packages/cli/src/databases/repositories/project.repository.ts index faae0bb9cf8e7..086dfbc7cf00b 100644 --- a/packages/cli/src/databases/repositories/project.repository.ts +++ b/packages/cli/src/databases/repositories/project.repository.ts @@ -17,8 +17,10 @@ export class ProjectRepository extends Repository { }); } - async getPersonalProjectForUserOrFail(userId: string) { - return await this.findOneOrFail({ + async getPersonalProjectForUserOrFail(userId: string, entityManager?: EntityManager) { + const em = entityManager ?? this.manager; + + return await em.findOneOrFail(Project, { where: { type: 'personal', projectRelations: { userId, role: 'project:personalOwner' } }, }); } diff --git a/packages/cli/src/databases/subscribers/UserSubscriber.ts b/packages/cli/src/databases/subscribers/UserSubscriber.ts index e5fad5bf53698..b925965a0c912 100644 --- a/packages/cli/src/databases/subscribers/UserSubscriber.ts +++ b/packages/cli/src/databases/subscribers/UserSubscriber.ts @@ -1,12 +1,12 @@ +import { Container } from 'typedi'; import type { EntitySubscriberInterface, UpdateEvent } from '@n8n/typeorm'; import { EventSubscriber } from '@n8n/typeorm'; -import { User } from '../entities/User'; -import Container from 'typedi'; -import { ProjectRepository } from '../repositories/project.repository'; import { ApplicationError, ErrorReporterProxy } from 'n8n-workflow'; import { Logger } from '@/Logger'; -import { UserRepository } from '../repositories/user.repository'; + import { Project } from '../entities/Project'; +import { User } from '../entities/User'; +import { UserRepository } from '../repositories/user.repository'; @EventSubscriber() export class UserSubscriber implements EntitySubscriberInterface { @@ -27,14 +27,17 @@ export class UserSubscriber implements EntitySubscriberInterface { fields.includes('email') ) { const oldUser = event.databaseEntity; - const name = + const userEntity = newUserData instanceof User - ? newUserData.createPersonalProjectName() - : Container.get(UserRepository).create(newUserData).createPersonalProjectName(); + ? newUserData + : Container.get(UserRepository).create(newUserData); + + const projectName = userEntity.createPersonalProjectName(); - const project = await Container.get(ProjectRepository).getPersonalProjectForUser( - oldUser.id, - ); + const project = await event.manager.findOneBy(Project, { + type: 'personal', + projectRelations: { userId: oldUser.id }, + }); if (!project) { // Since this is benign we're not throwing the exception. We don't @@ -47,7 +50,7 @@ export class UserSubscriber implements EntitySubscriberInterface { return; } - project.name = name; + project.name = projectName; await event.manager.save(Project, project); } diff --git a/packages/cli/src/errors/aborted-execution-retry.error.ts b/packages/cli/src/errors/aborted-execution-retry.error.ts new file mode 100644 index 0000000000000..20d8b57e14087 --- /dev/null +++ b/packages/cli/src/errors/aborted-execution-retry.error.ts @@ -0,0 +1,9 @@ +import { ApplicationError } from 'n8n-workflow'; + +export class AbortedExecutionRetryError extends ApplicationError { + constructor() { + super('The execution was aborted before starting, so it cannot be retried', { + level: 'warning', + }); + } +} diff --git a/packages/cli/src/executions/execution.service.ts b/packages/cli/src/executions/execution.service.ts index 2487dac2be556..81735c5f8362f 100644 --- a/packages/cli/src/executions/execution.service.ts +++ b/packages/cli/src/executions/execution.service.ts @@ -9,6 +9,7 @@ import type { ExecutionStatus, } from 'n8n-workflow'; import { + ErrorReporterProxy as EventReporter, ApplicationError, ExecutionStatusList, Workflow, @@ -36,6 +37,7 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error'; import config from '@/config'; import { WaitTracker } from '@/WaitTracker'; import type { ExecutionEntity } from '@/databases/entities/ExecutionEntity'; +import { AbortedExecutionRetryError } from '@/errors/aborted-execution-retry.error'; export const schemaGetExecutionsQueryFilter = { $id: '/IGetExecutionsQueryFilter', @@ -105,6 +107,8 @@ export class ExecutionService { } if (!execution.status) { + const { data, workflowData, ...rest } = execution; + EventReporter.info('Detected `null` execution status', { extra: { execution: rest } }); execution.status = getStatusUsingPreviousExecutionStatusMethod(execution); } @@ -129,6 +133,8 @@ export class ExecutionService { throw new NotFoundError(`The execution with the ID "${executionId}" does not exist.`); } + if (!execution.data.executionData) throw new AbortedExecutionRetryError(); + if (execution.finished) { throw new ApplicationError('The execution succeeded, so it cannot be retried.'); } diff --git a/packages/cli/src/services/import.service.ts b/packages/cli/src/services/import.service.ts index c2226c65b9896..96892e2745273 100644 --- a/packages/cli/src/services/import.service.ts +++ b/packages/cli/src/services/import.service.ts @@ -1,4 +1,4 @@ -import Container, { Service } from 'typedi'; +import { Service } from 'typedi'; import { v4 as uuid } from 'uuid'; import { type INode, type INodeCredentialsDetails } from 'n8n-workflow'; @@ -8,11 +8,11 @@ import { CredentialsRepository } from '@db/repositories/credentials.repository'; import { TagRepository } from '@db/repositories/tag.repository'; import { SharedWorkflow } from '@db/entities/SharedWorkflow'; import { replaceInvalidCredentials } from '@/WorkflowHelpers'; +import { Project } from '@db/entities/Project'; import { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { WorkflowTagMapping } from '@db/entities/WorkflowTagMapping'; import type { TagEntity } from '@db/entities/TagEntity'; import type { ICredentialsDb } from '@/Interfaces'; -import { ProjectRepository } from '@/databases/repositories/project.repository'; @Service() export class ImportService { @@ -59,9 +59,7 @@ export class ImportService { const upsertResult = await tx.upsert(WorkflowEntity, workflow, ['id']); const workflowId = upsertResult.identifiers.at(0)?.id as string; - const personalProject = await Container.get(ProjectRepository).findOneByOrFail({ - id: projectId, - }); + const personalProject = await tx.findOneByOrFail(Project, { id: projectId }); // Create relationship if the workflow was inserted instead of updated. if (!exists) { diff --git a/packages/cli/test/integration/execution.service.integration.test.ts b/packages/cli/test/integration/execution.service.integration.test.ts index 834720696d582..ba8cc89d369dc 100644 --- a/packages/cli/test/integration/execution.service.integration.test.ts +++ b/packages/cli/test/integration/execution.service.integration.test.ts @@ -257,6 +257,32 @@ describe('ExecutionService', () => { ]); }); + test('should filter executions by `metadata`', async () => { + const workflow = await createWorkflow(); + + const metadata = [{ key: 'myKey', value: 'myValue' }]; + + await Promise.all([ + createExecution({ status: 'success', metadata }, workflow), + createExecution({ status: 'error' }, workflow), + ]); + + const query: ExecutionSummaries.RangeQuery = { + kind: 'range', + range: { limit: 20 }, + accessibleWorkflowIds: [workflow.id], + metadata, + }; + + const output = await executionService.findRangeWithCount(query); + + expect(output).toEqual({ + count: 1, + estimated: false, + results: [expect.objectContaining({ status: 'success' })], + }); + }); + test('should exclude executions by inaccessible `workflowId`', async () => { const accessibleWorkflow = await createWorkflow(); const inaccessibleWorkflow = await createWorkflow(); diff --git a/packages/cli/test/integration/security-audit/CredentialsRiskReporter.test.ts b/packages/cli/test/integration/security-audit/CredentialsRiskReporter.test.ts index c395147e1d5a6..4fdc54dbc1b06 100644 --- a/packages/cli/test/integration/security-audit/CredentialsRiskReporter.test.ts +++ b/packages/cli/test/integration/security-audit/CredentialsRiskReporter.test.ts @@ -161,6 +161,7 @@ test('should report credential in not recently executed workflow', async () => { stoppedAt: date, workflowId: workflow.id, waitTill: null, + status: 'success', }); await Container.get(ExecutionDataRepository).save({ execution: savedExecution, @@ -228,6 +229,7 @@ test('should not report credentials in recently executed workflow', async () => stoppedAt: date, workflowId: workflow.id, waitTill: null, + status: 'success', }); await Container.get(ExecutionDataRepository).save({ diff --git a/packages/cli/test/integration/shared/db/executions.ts b/packages/cli/test/integration/shared/db/executions.ts index 7e791a08fe4d1..199cf9c90a535 100644 --- a/packages/cli/test/integration/shared/db/executions.ts +++ b/packages/cli/test/integration/shared/db/executions.ts @@ -4,6 +4,7 @@ import type { ExecutionEntity } from '@db/entities/ExecutionEntity'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { ExecutionRepository } from '@db/repositories/execution.repository'; import { ExecutionDataRepository } from '@db/repositories/executionData.repository'; +import { ExecutionMetadataRepository } from '@/databases/repositories/executionMetadata.repository'; export async function createManyExecutions( amount: number, @@ -18,10 +19,14 @@ export async function createManyExecutions( * Store a execution in the DB and assign it to a workflow. */ export async function createExecution( - attributes: Partial, + attributes: Partial< + Omit & + ExecutionData & { metadata: Array<{ key: string; value: string }> } + >, workflow: WorkflowEntity, ) { - const { data, finished, mode, startedAt, stoppedAt, waitTill, status, deletedAt } = attributes; + const { data, finished, mode, startedAt, stoppedAt, waitTill, status, deletedAt, metadata } = + attributes; const execution = await Container.get(ExecutionRepository).save({ finished: finished ?? true, @@ -30,10 +35,20 @@ export async function createExecution( ...(workflow !== undefined && { workflowId: workflow.id }), stoppedAt: stoppedAt ?? new Date(), waitTill: waitTill ?? null, - status, + status: status ?? 'success', deletedAt, }); + if (metadata?.length) { + const metadataToSave = metadata.map(({ key, value }) => ({ + key, + value, + execution: { id: execution.id }, + })); + + await Container.get(ExecutionMetadataRepository).save(metadataToSave); + } + await Container.get(ExecutionDataRepository).save({ data: data ?? '[]', workflowData: workflow ?? {}, diff --git a/packages/cli/test/integration/users.api.test.ts b/packages/cli/test/integration/users.api.test.ts index b58f88795de34..b164ae89a3991 100644 --- a/packages/cli/test/integration/users.api.test.ts +++ b/packages/cli/test/integration/users.api.test.ts @@ -35,12 +35,6 @@ const testServer = utils.setupTestServer({ enabledFeatures: ['feat:advancedPermissions'], }); -let projectRepository: ProjectRepository; - -beforeAll(() => { - projectRepository = Container.get(ProjectRepository); -}); - describe('GET /users', () => { let owner: User; let member: User; @@ -243,6 +237,39 @@ describe('GET /users', () => { }); }); +describe('GET /users/:id/password-reset-link', () => { + let owner: User; + let admin: User; + let member: User; + + beforeAll(async () => { + await testDb.truncate(['User']); + + [owner, admin, member] = await Promise.all([createOwner(), createAdmin(), createMember()]); + }); + + it('should allow owners to generate password reset links for admins and members', async () => { + const ownerAgent = testServer.authAgentFor(owner); + await ownerAgent.get(`/users/${owner.id}/password-reset-link`).expect(200); + await ownerAgent.get(`/users/${admin.id}/password-reset-link`).expect(200); + await ownerAgent.get(`/users/${member.id}/password-reset-link`).expect(200); + }); + + it('should allow admins to generate password reset links for admins and members, but not owners', async () => { + const adminAgent = testServer.authAgentFor(admin); + await adminAgent.get(`/users/${owner.id}/password-reset-link`).expect(403); + await adminAgent.get(`/users/${admin.id}/password-reset-link`).expect(200); + await adminAgent.get(`/users/${member.id}/password-reset-link`).expect(200); + }); + + it('should not allow members to generate password reset links for anyone', async () => { + const memberAgent = testServer.authAgentFor(member); + await memberAgent.get(`/users/${owner.id}/password-reset-link`).expect(403); + await memberAgent.get(`/users/${admin.id}/password-reset-link`).expect(403); + await memberAgent.get(`/users/${member.id}/password-reset-link`).expect(403); + }); +}); + describe('DELETE /users/:id', () => { let owner: User; let ownerAgent: SuperAgentTest; @@ -555,6 +582,15 @@ describe('DELETE /users/:id', () => { expect(user).toBeDefined(); }); + test('should fail to delete the instance owner', async () => { + const admin = await createAdmin(); + const adminAgent = testServer.authAgentFor(admin); + await adminAgent.delete(`/users/${owner.id}`).expect(403); + + const user = await getUserById(owner.id); + expect(user).toBeDefined(); + }); + test('should fail to delete a user that does not exist', async () => { await ownerAgent.delete(`/users/${uuid()}`).query({ transferId: '' }).expect(404); }); diff --git a/packages/cli/test/unit/License.test.ts b/packages/cli/test/unit/License.test.ts index 182656f39a8f1..317bfe1e6a667 100644 --- a/packages/cli/test/unit/License.test.ts +++ b/packages/cli/test/unit/License.test.ts @@ -252,4 +252,20 @@ describe('License', () => { }); }); }); + + describe('reinit', () => { + it('should reinitialize license manager', async () => { + const license = new License(mock(), mock(), mock(), mock(), mock()); + await license.init(); + + const initSpy = jest.spyOn(license, 'init'); + + await license.reinit(); + + expect(initSpy).toHaveBeenCalledWith('main', true); + + expect(LicenseManager.prototype.reset).toHaveBeenCalled(); + expect(LicenseManager.prototype.initialize).toHaveBeenCalled(); + }); + }); }); diff --git a/packages/cli/test/unit/services/execution.service.test.ts b/packages/cli/test/unit/services/execution.service.test.ts new file mode 100644 index 0000000000000..e607fe0b6945f --- /dev/null +++ b/packages/cli/test/unit/services/execution.service.test.ts @@ -0,0 +1,30 @@ +import type { IExecutionResponse } from '@/Interfaces'; +import type { ExecutionRepository } from '@/databases/repositories/execution.repository'; +import { AbortedExecutionRetryError } from '@/errors/aborted-execution-retry.error'; +import { ExecutionService } from '@/executions/execution.service'; +import type { ExecutionRequest } from '@/executions/execution.types'; +import { mock } from 'jest-mock-extended'; + +describe('ExecutionService', () => { + const executionRepository = mock(); + const executionService = new ExecutionService( + mock(), + mock(), + mock(), + executionRepository, + mock(), + mock(), + mock(), + mock(), + ); + + it('should error on retrying an aborted execution', async () => { + const abortedExecutionData = mock({ data: { executionData: undefined } }); + executionRepository.findWithUnflattenedData.mockResolvedValue(abortedExecutionData); + const req = mock(); + + const retry = executionService.retry(req, []); + + await expect(retry).rejects.toThrow(AbortedExecutionRetryError); + }); +}); diff --git a/packages/core/package.json b/packages/core/package.json index b0c53c9c1e5b2..188a894edd001 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "1.42.0", + "version": "1.43.0", "description": "Core functionality of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/core/src/Secrets.ts b/packages/core/src/Secrets.ts index dbe8730dbe6e6..d5693dd8f1634 100644 --- a/packages/core/src/Secrets.ts +++ b/packages/core/src/Secrets.ts @@ -3,7 +3,7 @@ import { ExpressionError } from 'n8n-workflow'; function buildSecretsValueProxy(value: IDataObject): unknown { return new Proxy(value, { - get(target, valueName) { + get(_target, valueName) { if (typeof valueName !== 'string') { return; } @@ -27,7 +27,7 @@ export function getSecretsProxy(additionalData: IWorkflowExecuteAdditionalData): return new Proxy( {}, { - get(target, providerName) { + get(_target, providerName) { if (typeof providerName !== 'string') { return {}; } @@ -35,7 +35,7 @@ export function getSecretsProxy(additionalData: IWorkflowExecuteAdditionalData): return new Proxy( {}, { - get(target2, secretName) { + get(_target2, secretName) { if (typeof secretName !== 'string') { return; } diff --git a/packages/core/test/ObjectStore.manager.test.ts b/packages/core/test/ObjectStore.manager.test.ts index dc91e3322173b..abc1f24c3aa01 100644 --- a/packages/core/test/ObjectStore.manager.test.ts +++ b/packages/core/test/ObjectStore.manager.test.ts @@ -1,7 +1,9 @@ import fs from 'node:fs/promises'; +import { mock } from 'jest-mock-extended'; import { ObjectStoreManager } from '@/BinaryData/ObjectStore.manager'; import { ObjectStoreService } from '@/ObjectStore/ObjectStore.service.ee'; import { isStream } from '@/ObjectStore/utils'; +import type { MetadataResponseHeaders } from '@/ObjectStore/types'; import { mockInstance, toFileId, toStream } from './utils'; jest.mock('fs/promises'); @@ -74,11 +76,13 @@ describe('getMetadata()', () => { const mimeType = 'text/plain'; const fileName = 'file.txt'; - objectStoreService.getMetadata.mockResolvedValue({ - 'content-length': '1', - 'content-type': mimeType, - 'x-amz-meta-filename': fileName, - }); + objectStoreService.getMetadata.mockResolvedValue( + mock({ + 'content-length': '1', + 'content-type': mimeType, + 'x-amz-meta-filename': fileName, + }), + ); const metadata = await objectStoreManager.getMetadata(fileId); diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 09b3de3ab28b7..7deb9642a4778 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -1,6 +1,6 @@ { "name": "n8n-design-system", - "version": "1.32.0", + "version": "1.33.0", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", "author": { diff --git a/packages/design-system/src/css/_tokens.scss b/packages/design-system/src/css/_tokens.scss index 7278517474b16..d40e4d8ae0c98 100644 --- a/packages/design-system/src/css/_tokens.scss +++ b/packages/design-system/src/css/_tokens.scss @@ -225,8 +225,8 @@ // Callout --color-callout-info-border: var(--color-foreground-base); --color-callout-info-background: var(--color-foreground-xlight); - --color-callout-info-font: var(--color-info); - --color-callout-info-icon: var(--color-info); + --color-callout-info-font: var(--color-text-base); + --color-callout-info-icon: var(--color-text-light); --color-callout-success-border: var(--color-success-light-2); --color-callout-success-background: var(--color-success-tint-2); --color-callout-success-font: var(--color-success); diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index e314346024e14..34dd4b37db576 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "1.42.0", + "version": "1.43.0", "description": "Workflow Editor UI for n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/editor-ui/src/__tests__/data/projects.ts b/packages/editor-ui/src/__tests__/data/projects.ts index d0e0666f45c11..9f87d7f781e97 100644 --- a/packages/editor-ui/src/__tests__/data/projects.ts +++ b/packages/editor-ui/src/__tests__/data/projects.ts @@ -4,11 +4,14 @@ import type { ProjectSharingData, ProjectType, } from '@/features/projects/projects.types'; +import { ProjectTypes } from '@/features/projects/projects.utils'; export const createProjectSharingData = (projectType?: ProjectType): ProjectSharingData => ({ id: faker.string.uuid(), name: faker.lorem.words({ min: 1, max: 3 }), - type: projectType || 'personal', + type: projectType ?? ProjectTypes.Personal, + createdAt: faker.date.past().toISOString(), + updatedAt: faker.date.recent().toISOString(), }); export const createProjectListItem = (projectType?: ProjectType): ProjectListItem => { diff --git a/packages/editor-ui/src/components/CodeNodeEditor/completions/base.completions.ts b/packages/editor-ui/src/components/CodeNodeEditor/completions/base.completions.ts index 8c19880f485e4..e45075e025e44 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/completions/base.completions.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/completions/base.completions.ts @@ -1,5 +1,5 @@ import { NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION } from '../constants'; -import { addVarType } from '../utils'; +import { addInfoRenderer, addVarType } from '../utils'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; import type { INodeUi } from '@/Interface'; import { useWorkflowsStore } from '@/stores/workflows.store'; @@ -128,7 +128,7 @@ export function useBaseCompletions( return { from: preCursor.from, - options, + options: options.map(addInfoRenderer), }; }; diff --git a/packages/editor-ui/src/components/CodeNodeEditor/completions/execution.completions.ts b/packages/editor-ui/src/components/CodeNodeEditor/completions/execution.completions.ts index fcd229a86d920..47bad52b2a481 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/completions/execution.completions.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/completions/execution.completions.ts @@ -1,4 +1,4 @@ -import { addVarType, escape } from '../utils'; +import { addInfoRenderer, addVarType, escape } from '../utils'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; import { useI18n } from '@/composables/useI18n'; @@ -18,15 +18,6 @@ export function useExecutionCompletions() { if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; - const buildLinkNode = (text: string) => { - const wrapper = document.createElement('span'); - // This is being loaded from the locales file. This could - // cause an XSS of some kind but multiple other locales strings - // do the same thing. - wrapper.innerHTML = text; - return () => wrapper; - }; - const options: Completion[] = [ { label: `${matcher}.id`, @@ -46,29 +37,25 @@ export function useExecutionCompletions() { }, { label: `${matcher}.customData.set("key", "value")`, - info: buildLinkNode(i18n.baseText('codeNodeEditor.completer.$execution.customData.set()')), + info: i18n.baseText('codeNodeEditor.completer.$execution.customData.set'), }, { label: `${matcher}.customData.get("key")`, - info: buildLinkNode(i18n.baseText('codeNodeEditor.completer.$execution.customData.get()')), + info: i18n.baseText('codeNodeEditor.completer.$execution.customData.get'), }, { label: `${matcher}.customData.setAll({})`, - info: buildLinkNode( - i18n.baseText('codeNodeEditor.completer.$execution.customData.setAll()'), - ), + info: i18n.baseText('codeNodeEditor.completer.$execution.customData.setAll'), }, { label: `${matcher}.customData.getAll()`, - info: buildLinkNode( - i18n.baseText('codeNodeEditor.completer.$execution.customData.getAll()'), - ), + info: i18n.baseText('codeNodeEditor.completer.$execution.customData.getAll'), }, ]; return { from: preCursor.from, - options: options.map(addVarType), + options: options.map(addVarType).map(addInfoRenderer), }; }; diff --git a/packages/editor-ui/src/components/CodeNodeEditor/utils.ts b/packages/editor-ui/src/components/CodeNodeEditor/utils.ts index be8560a61e352..d37bc876482df 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/utils.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/utils.ts @@ -2,6 +2,7 @@ import type * as esprima from 'esprima-next'; import type { Completion } from '@codemirror/autocomplete'; import type { Node } from 'estree'; import type { RangeNode } from './types'; +import { sanitizeHtml } from '@/utils/htmlUtils'; export function walk( node: Node | esprima.Program, @@ -40,3 +41,15 @@ export const escape = (str: string) => export const toVariableOption = (label: string) => ({ label, type: 'variable' }); export const addVarType = (option: Completion) => ({ ...option, type: 'variable' }); + +export const addInfoRenderer = (option: Completion): Completion => { + const { info } = option; + if (typeof info === 'string') { + option.info = () => { + const wrapper = document.createElement('span'); + wrapper.innerHTML = sanitizeHtml(info); + return wrapper; + }; + } + return option; +}; diff --git a/packages/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue b/packages/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue index 388101969cfaa..d94364e7a36b6 100644 --- a/packages/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue +++ b/packages/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue @@ -96,6 +96,7 @@ import type { CredentialScope } from '@n8n/permissions'; import type { EventBus } from 'n8n-design-system/utils'; import { useRolesStore } from '@/stores/roles.store'; import type { RoleMap } from '@/types/roles.types'; +import { ProjectTypes } from '@/features/projects/projects.utils'; export default defineComponent({ name: 'CredentialSharing', @@ -178,7 +179,7 @@ export default defineComponent({ ); }, isHomeTeamProject(): boolean { - return this.homeProject?.type === 'team'; + return this.homeProject?.type === ProjectTypes.Team; }, numberOfMembersInHomeTeamProject(): number { return this.teamProject?.relations.length ?? 0; diff --git a/packages/editor-ui/src/components/Error/NodeErrorView.vue b/packages/editor-ui/src/components/Error/NodeErrorView.vue index 767daef3f61f3..078631cccf972 100644 --- a/packages/editor-ui/src/components/Error/NodeErrorView.vue +++ b/packages/editor-ui/src/components/Error/NodeErrorView.vue @@ -1,6 +1,5 @@ diff --git a/packages/editor-ui/src/components/FilterConditions/__tests__/FilterConditions.test.ts b/packages/editor-ui/src/components/FilterConditions/__tests__/FilterConditions.test.ts index 568d65c9bc8d5..a41a58759aea7 100644 --- a/packages/editor-ui/src/components/FilterConditions/__tests__/FilterConditions.test.ts +++ b/packages/editor-ui/src/components/FilterConditions/__tests__/FilterConditions.test.ts @@ -5,8 +5,9 @@ import { STORES } from '@/constants'; import { useNDVStore } from '@/stores/ndv.store'; import { createTestingPinia } from '@pinia/testing'; import userEvent from '@testing-library/user-event'; -import { within } from '@testing-library/vue'; +import { within, waitFor } from '@testing-library/vue'; import { getFilterOperator } from '../utils'; +import { get } from 'lodash-es'; const DEFAULT_SETUP = { pinia: createTestingPinia({ @@ -274,6 +275,68 @@ describe('FilterConditions.vue', () => { expect(conditions[0].querySelector('[data-test-id="filter-remove-condition"]')).toBeNull(); }); + it('can edit conditions', async () => { + const { getByTestId, emitted } = renderComponent({ + ...DEFAULT_SETUP, + props: { + ...DEFAULT_SETUP.props, + value: { + options: { + caseSensitive: true, + leftValue: '', + }, + conditions: [ + { + leftValue: '={{ $json.name }}', + rightValue: 'John', + operator: getFilterOperator('string:equals'), + }, + ], + }, + }, + }); + + const condition = getByTestId('filter-condition'); + await waitFor(() => + expect(within(condition).getByTestId('filter-condition-left')).toHaveTextContent( + '{{ $json.name }}', + ), + ); + + expect(emitted('valueChanged')).toBeUndefined(); + + const expressionEditor = within(condition) + .getByTestId('filter-condition-left') + .querySelector('.cm-line'); + + if (expressionEditor) { + await userEvent.type(expressionEditor, 'test'); + } + + await waitFor(() => { + expect(get(emitted('valueChanged')[0], '0.value.conditions.0.leftValue')).toEqual( + expect.stringContaining('test'), + ); + }); + + const parameterInput = within(condition) + .getByTestId('filter-condition-right') + .querySelector('input'); + + if (parameterInput) { + await userEvent.type(parameterInput, 'test'); + } + + await waitFor(() => { + expect(get(emitted('valueChanged')[0], '0.value.conditions.0.leftValue')).toEqual( + expect.stringContaining('test'), + ); + expect(get(emitted('valueChanged')[0], '0.value.conditions.0.rightValue')).toEqual( + expect.stringContaining('test'), + ); + }); + }); + it('renders correctly in read only mode', async () => { const { findAllByTestId, queryByTestId } = renderComponent({ props: { diff --git a/packages/editor-ui/src/components/RunData.vue b/packages/editor-ui/src/components/RunData.vue index cd970b91e3bc8..6b28cd1a7c626 100644 --- a/packages/editor-ui/src/components/RunData.vue +++ b/packages/editor-ui/src/components/RunData.vue @@ -837,13 +837,10 @@ export default defineComponent({ return Boolean(this.workflowsStore.subWorkflowExecutionError); }, workflowRunErrorAsNodeError(): NodeError { - return { - node: this.node, - messages: [this.workflowRunData?.[this.node?.name]?.[this.runIndex]?.error?.message ?? ''], - } as NodeError; + return this.workflowRunData?.[this.node?.name]?.[this.runIndex]?.error as NodeError; }, hasRunError(): boolean { - return Boolean(this.node && this.workflowRunData?.[this.node.name]?.[this.runIndex]?.error); + return Boolean(this.node && this.workflowRunErrorAsNodeError); }, executionHints(): NodeHint[] { if (this.hasNodeRun) { @@ -1021,6 +1018,24 @@ export default defineComponent({ showIoSearchNoMatchContent(): boolean { return this.hasNodeRun && !this.inputData.length && !!this.search; }, + parentNodeOutputData(): INodeExecutionData[] { + const workflow = this.workflowsStore.getCurrentWorkflow(); + + const parentNode = workflow.getParentNodesByDepth(this.node.name)[0]; + let parentNodeData: INodeExecutionData[] = []; + + if (parentNode?.name) { + parentNodeData = this.nodeHelpers.getNodeInputData( + workflow.getNode(parentNode?.name), + this.runIndex, + this.outputIndex, + 'input', + this.connectionType, + ); + } + + return parentNodeData; + }, }, watch: { node(newNode: INodeUi, prevNode: INodeUi) { @@ -1118,19 +1133,18 @@ export default defineComponent({ }, shouldHintBeDisplayed(hint: NodeHint): boolean { const { location, whenToDisplay } = hint; + if (location) { - if (location === 'ndv') { - return true; + if (location === 'ndv' && !['input', 'output'].includes(this.paneType)) { + return false; } - if (location === 'inputPane' && this.paneType === 'input') { - return true; + if (location === 'inputPane' && this.paneType !== 'input') { + return false; } - if (location === 'outputPane' && this.paneType === 'output') { - return true; + if (location === 'outputPane' && this.paneType !== 'output') { + return false; } - - return false; } if (whenToDisplay === 'afterExecution' && !this.hasNodeRun) { @@ -1150,7 +1164,12 @@ export default defineComponent({ if (workflowNode) { const executionHints = this.executionHints; - const nodeHints = NodeHelpers.getNodeHints(workflow, workflowNode, this.nodeType); + const nodeHints = NodeHelpers.getNodeHints(workflow, workflowNode, this.nodeType, { + runExecutionData: this.workflowExecution?.data ?? null, + runIndex: this.runIndex, + connectionInputData: this.parentNodeOutputData, + }); + return executionHints.concat(nodeHints).filter(this.shouldHintBeDisplayed); } } diff --git a/packages/editor-ui/src/components/WorkflowSettings.vue b/packages/editor-ui/src/components/WorkflowSettings.vue index ecaa56de3e799..8d7ac218ba43a 100644 --- a/packages/editor-ui/src/components/WorkflowSettings.vue +++ b/packages/editor-ui/src/components/WorkflowSettings.vue @@ -384,6 +384,7 @@ import type { WorkflowScope } from '@n8n/permissions'; import { getWorkflowPermissions } from '@/permissions'; import { useExternalHooks } from '@/composables/useExternalHooks'; import { useSourceControlStore } from '@/stores/sourceControl.store'; +import { ProjectTypes } from '@/features/projects/projects.utils'; export default defineComponent({ name: 'WorkflowSettings', @@ -604,7 +605,7 @@ export default defineComponent({ { key: 'workflowsFromSameOwner', value: this.$locale.baseText( - this.workflow.homeProject?.type === 'personal' + this.workflow.homeProject?.type === ProjectTypes.Personal ? 'workflowSettings.callerPolicy.options.workflowsFromPersonalProject' : 'workflowSettings.callerPolicy.options.workflowsFromTeamProject', { diff --git a/packages/editor-ui/src/components/WorkflowShareModal.ee.vue b/packages/editor-ui/src/components/WorkflowShareModal.ee.vue index 489a46c7316f7..6557492c59b2a 100644 --- a/packages/editor-ui/src/components/WorkflowShareModal.ee.vue +++ b/packages/editor-ui/src/components/WorkflowShareModal.ee.vue @@ -152,6 +152,7 @@ import type { } from '@/features/projects/projects.types'; import { useRolesStore } from '@/stores/roles.store'; import type { RoleMap } from '@/types/roles.types'; +import { ProjectTypes } from '@/features/projects/projects.utils'; export default defineComponent({ name: 'WorkflowShareModal', @@ -238,7 +239,7 @@ export default defineComponent({ ); }, isHomeTeamProject(): boolean { - return this.workflow.homeProject?.type === 'team'; + return this.workflow.homeProject?.type === ProjectTypes.Team; }, numberOfMembersInHomeTeamProject(): number { return this.teamProject?.relations.length ?? 0; diff --git a/packages/editor-ui/src/components/forms/ResourceFiltersDropdown.vue b/packages/editor-ui/src/components/forms/ResourceFiltersDropdown.vue index b1724ab1a7e1f..851230bd71819 100644 --- a/packages/editor-ui/src/components/forms/ResourceFiltersDropdown.vue +++ b/packages/editor-ui/src/components/forms/ResourceFiltersDropdown.vue @@ -32,6 +32,7 @@ class="pt-2xs" :projects="projectsStore.projects" :placeholder="$locale.baseText('forms.resourceFiltersDropdown.owner.placeholder')" + :empty-options-text="$locale.baseText('projects.sharing.noMatchingProjects')" @update:model-value="setKeyValue('homeProject', ($event as ProjectSharingData).id)" /> diff --git a/packages/editor-ui/src/features/projects/components/ProjectCardBadge.vue b/packages/editor-ui/src/features/projects/components/ProjectCardBadge.vue index 7c9143e1fc143..4f967891ab9af 100644 --- a/packages/editor-ui/src/features/projects/components/ProjectCardBadge.vue +++ b/packages/editor-ui/src/features/projects/components/ProjectCardBadge.vue @@ -1,7 +1,7 @@