Merge master, fix conflicts

This commit is contained in:
Iván Ovejero 2024-10-11 11:06:07 +02:00
commit 2a97a38297
No known key found for this signature in database
728 changed files with 29759 additions and 13816 deletions

View file

@ -1,3 +1,102 @@
# [1.63.0](https://github.com/n8n-io/n8n/compare/n8n@1.62.1...n8n@1.63.0) (2024-10-09)
### Bug Fixes
* **Convert to File Node:** Convert to ICS start date defaults to now ([#11114](https://github.com/n8n-io/n8n/issues/11114)) ([1146c4e](https://github.com/n8n-io/n8n/commit/1146c4e98d8c85c15ac67fa1c3bfb731234531e3))
* **core:** Allow loading nodes from multiple custom directories ([#11130](https://github.com/n8n-io/n8n/issues/11130)) ([1b84b0e](https://github.com/n8n-io/n8n/commit/1b84b0e5e7485d9f99d61a8ae3df49efadca0745))
* **core:** Always set `startedAt` when executions start running ([#11098](https://github.com/n8n-io/n8n/issues/11098)) ([722f4a8](https://github.com/n8n-io/n8n/commit/722f4a8b771058800b992a482ad5f644b650960d))
* **core:** Fix AI nodes not working with new partial execution flow ([#11055](https://github.com/n8n-io/n8n/issues/11055)) ([0eee5df](https://github.com/n8n-io/n8n/commit/0eee5dfd597817819dbe0463a63f671fde53432f))
* **core:** Print errors that happen before the execution starts on the worker instead of just on the main instance ([#11099](https://github.com/n8n-io/n8n/issues/11099)) ([1d14557](https://github.com/n8n-io/n8n/commit/1d145574611661ecd9ab1a39d815c0ea915b9a1c))
* **core:** Separate error handlers for main and worker ([#11091](https://github.com/n8n-io/n8n/issues/11091)) ([bb59cc7](https://github.com/n8n-io/n8n/commit/bb59cc71acc9e494e54abc8402d58db39e5a664e))
* **editor:** Shorten overflowing Node Label in InputLabels on hover and focus ([#11110](https://github.com/n8n-io/n8n/issues/11110)) ([87a0b68](https://github.com/n8n-io/n8n/commit/87a0b68f9009c1c776d937c6ca62096e88c95ed6))
* **editor:** Add safety to prevent undefined errors ([#11104](https://github.com/n8n-io/n8n/issues/11104)) ([565b117](https://github.com/n8n-io/n8n/commit/565b117a52f8eac9202a1a62c43daf78b293dcf8))
* **editor:** Fix design system form element sizing ([#11040](https://github.com/n8n-io/n8n/issues/11040)) ([67c3453](https://github.com/n8n-io/n8n/commit/67c3453885bc619fedc8338a6dd0d8d66dead931))
* **editor:** Fix getInitials when Intl.Segmenter is not supported ([#11103](https://github.com/n8n-io/n8n/issues/11103)) ([7e8955b](https://github.com/n8n-io/n8n/commit/7e8955b322b1d2c84c0f479a5977484d8d5e3135))
* **editor:** Fix schema view in AI tools ([#11089](https://github.com/n8n-io/n8n/issues/11089)) ([09cfdbd](https://github.com/n8n-io/n8n/commit/09cfdbd1817eba46c935308880fe9f95ded252b0))
* **editor:** Respect tag querystring filter when listing workflows ([#11029](https://github.com/n8n-io/n8n/issues/11029)) ([59c5ff6](https://github.com/n8n-io/n8n/commit/59c5ff61354302562ba5a2340c66811afdd1523b))
* **editor:** Show previous nodes autocomplete in AI tool nodes ([#11111](https://github.com/n8n-io/n8n/issues/11111)) ([8566b3a](https://github.com/n8n-io/n8n/commit/8566b3a99939f45ac263830eee30d0d4ade9305c))
* **editor:** Update Usage page for Community+ edition ([#11074](https://github.com/n8n-io/n8n/issues/11074)) ([3974981](https://github.com/n8n-io/n8n/commit/3974981ea5c67f6f2bbb90a96b405d9d0cfa21af))
* Fix transaction handling for 'revert' command ([#11145](https://github.com/n8n-io/n8n/issues/11145)) ([a782336](https://github.com/n8n-io/n8n/commit/a7823367f13c3dba0c339eaafaad0199bd524b13))
* Forbid access to files outside source control work directory ([#11152](https://github.com/n8n-io/n8n/issues/11152)) ([606eedb](https://github.com/n8n-io/n8n/commit/606eedbf1b302e153bd13b7cef80847711e3a9ee))
* **Gitlab Node:** Author name and email not being set ([#11077](https://github.com/n8n-io/n8n/issues/11077)) ([fce1233](https://github.com/n8n-io/n8n/commit/fce1233b58624d502c9c68f4b32a4bb7d76f1814))
* Incorrect error message on calling wrong webhook method ([#11093](https://github.com/n8n-io/n8n/issues/11093)) ([d974b01](https://github.com/n8n-io/n8n/commit/d974b015d030c608158ff0c3fa3b7f4cbb8eadd3))
* **n8n Form Trigger Node:** When clicking on a multiple choice label, the wrong one is selected ([#11059](https://github.com/n8n-io/n8n/issues/11059)) ([948edd1](https://github.com/n8n-io/n8n/commit/948edd1a047cf3dbddb3b0e9ec5de4bac3e97b9f))
* **NASA Node:** Astronomy-Picture-Of-The-Day fails when it's YouTube video ([#11046](https://github.com/n8n-io/n8n/issues/11046)) ([c70969d](https://github.com/n8n-io/n8n/commit/c70969da2bcabeb33394073a69ccef208311461b))
* **Postgres PGVector Store Node:** Fix filtering in retriever mode ([#11075](https://github.com/n8n-io/n8n/issues/11075)) ([dbd2ae1](https://github.com/n8n-io/n8n/commit/dbd2ae199506a24c2df4c983111a56f2adf63eee))
* Show result of waiting execution on canvas after execution complete ([#10815](https://github.com/n8n-io/n8n/issues/10815)) ([90b4bfc](https://github.com/n8n-io/n8n/commit/90b4bfc472ef132d2280b175ae7410dfb8e549b2))
* **Slack Node:** User id not sent correctly to API when updating user profile ([#11153](https://github.com/n8n-io/n8n/issues/11153)) ([ed9e61c](https://github.com/n8n-io/n8n/commit/ed9e61c46055d8e636a70c9c175d7d4ba596dd48))
### Features
* **core:** Introduce scoped logging ([#11127](https://github.com/n8n-io/n8n/issues/11127)) ([c68782c](https://github.com/n8n-io/n8n/commit/c68782c633b7ef6253ea705c5a222d4536491fd5))
* **editor:** Add navigation dropdown component ([#11047](https://github.com/n8n-io/n8n/issues/11047)) ([e081fd1](https://github.com/n8n-io/n8n/commit/e081fd1f0b5a0700017a8dc92f013f0abdbad319))
* **editor:** Add route for create / edit / share credentials ([#11134](https://github.com/n8n-io/n8n/issues/11134)) ([5697de4](https://github.com/n8n-io/n8n/commit/5697de4429c5d94f25ce1bd14c84fb4266ea47a7))
* **editor:** Community+ enrollment ([#10776](https://github.com/n8n-io/n8n/issues/10776)) ([92cf860](https://github.com/n8n-io/n8n/commit/92cf860f9f2994442facfddc758bc60f5cbec520))
* Human in the loop ([#10675](https://github.com/n8n-io/n8n/issues/10675)) ([41228b4](https://github.com/n8n-io/n8n/commit/41228b472de11affc8cd0821284427c2c9e8b421))
* **OpenAI Node:** Allow to specify thread ID for Assistant -> Message operation ([#11080](https://github.com/n8n-io/n8n/issues/11080)) ([6a2f9e7](https://github.com/n8n-io/n8n/commit/6a2f9e72959fb0e89006b69c31fbcee1ead1cde9))
* Opt in to additional features on community for existing users ([#11166](https://github.com/n8n-io/n8n/issues/11166)) ([c2adfc8](https://github.com/n8n-io/n8n/commit/c2adfc85451c5103eaad068f882066fd36c4aebe))
### Performance Improvements
* **core:** Optimize worker healthchecks ([#11092](https://github.com/n8n-io/n8n/issues/11092)) ([19fb728](https://github.com/n8n-io/n8n/commit/19fb728da0839c57603e55da4e407715e6c5b081))
## [1.62.1](https://github.com/n8n-io/n8n/compare/n8n@1.61.0...n8n@1.62.1) (2024-10-02)
### Bug Fixes
* **AI Agent Node:** Fix output parsing and empty tool input handling in AI Agent node ([#10970](https://github.com/n8n-io/n8n/issues/10970)) ([3a65bdc](https://github.com/n8n-io/n8n/commit/3a65bdc1f522932d463b4da0e67d29076887d06c))
* **API:** Fix workflow project transfer ([#10651](https://github.com/n8n-io/n8n/issues/10651)) ([5f89e3a](https://github.com/n8n-io/n8n/commit/5f89e3a01c1bbb3589ff0464fd5bc991426f55dc))
* **AwsS3 Node:** Fix search only using first input parameters ([#10998](https://github.com/n8n-io/n8n/issues/10998)) ([846cfde](https://github.com/n8n-io/n8n/commit/846cfde8dcaf7bf80f0a4ca7d65fc2a7b61d0e23))
* **Chat Trigger Node:** Fix Allowed Origins paramter ([#11011](https://github.com/n8n-io/n8n/issues/11011)) ([b5f4afe](https://github.com/n8n-io/n8n/commit/b5f4afe12ec77f527080a4b7f812e12f9f73f8df))
* **core:** Fix ownerless project case in statistics service ([#11051](https://github.com/n8n-io/n8n/issues/11051)) ([bdaadf1](https://github.com/n8n-io/n8n/commit/bdaadf10e058e2c0b1141289189d6526c030a2ca))
* **core:** Handle Redis disconnects gracefully ([#11007](https://github.com/n8n-io/n8n/issues/11007)) ([cd91648](https://github.com/n8n-io/n8n/commit/cd916480c2d2b55f2215c72309dc432340fc3f30))
* **core:** Prevent backend from loading duplicate copies of nodes packages ([#10979](https://github.com/n8n-io/n8n/issues/10979)) ([4584f22](https://github.com/n8n-io/n8n/commit/4584f22a9b16883779d8555cda309fd8bd113f6c))
* **core:** Upgrade @n8n/typeorm to address a rare mutex release issue ([#10993](https://github.com/n8n-io/n8n/issues/10993)) ([2af0fbf](https://github.com/n8n-io/n8n/commit/2af0fbf52f0b404697f5148f81ad0035c9ffb6b9))
* **editor:** Allow resources to move between personal and team projects ([#10683](https://github.com/n8n-io/n8n/issues/10683)) ([136d491](https://github.com/n8n-io/n8n/commit/136d49132567558b7d27069c857c0e0bfee70ce2))
* **editor:** Color scheme for a markdown code blocks in dark mode ([#11008](https://github.com/n8n-io/n8n/issues/11008)) ([b20d2eb](https://github.com/n8n-io/n8n/commit/b20d2eb403f71fe1dc21c92df118adcebef51ffe))
* **editor:** Fix filter execution by "Queued" ([#10987](https://github.com/n8n-io/n8n/issues/10987)) ([819d20f](https://github.com/n8n-io/n8n/commit/819d20fa2eee314b88a7ce1c4db632afac514704))
* **editor:** Fix performance issue in credentials list ([#10988](https://github.com/n8n-io/n8n/issues/10988)) ([7073ec6](https://github.com/n8n-io/n8n/commit/7073ec6fe5384cc8c50dcb242212999a1fbc9041))
* **editor:** Fix schema view pill highlighting ([#10936](https://github.com/n8n-io/n8n/issues/10936)) ([1b973dc](https://github.com/n8n-io/n8n/commit/1b973dcd8dbce598e6ada490fd48fad52f7b4f3a))
* **editor:** Fix workflow executions list page redirection ([#10981](https://github.com/n8n-io/n8n/issues/10981)) ([fe7d060](https://github.com/n8n-io/n8n/commit/fe7d0605681dc963f5e5d1607f9d40c5173e0f9f))
* **editor:** Format action names properly when action is not defined ([#11030](https://github.com/n8n-io/n8n/issues/11030)) ([9c43fb3](https://github.com/n8n-io/n8n/commit/9c43fb301d1ccb82e42f46833e19587289803cd3))
* **Elasticsearch Node:** Fix issue with self signed certificates not working ([#10954](https://github.com/n8n-io/n8n/issues/10954)) ([79622b5](https://github.com/n8n-io/n8n/commit/79622b5f267f2a4a53f3eb48e228939d6e3a9caa))
* **Facebook Lead Ads Trigger Node:** Pagination fix in RLC ([#10956](https://github.com/n8n-io/n8n/issues/10956)) ([6322372](https://github.com/n8n-io/n8n/commit/632237261087ada0177b67922f9f48ca02ef1d9e))
* **Github Document Loader Node:** Pass through apiUrl from credentials & fix log output ([#11049](https://github.com/n8n-io/n8n/issues/11049)) ([a7af981](https://github.com/n8n-io/n8n/commit/a7af98183c47a5e215869c8269729b0fb2f318b5))
* **Google Sheets Node:** Updating on row_number using automatic matching ([#10940](https://github.com/n8n-io/n8n/issues/10940)) ([ed91495](https://github.com/n8n-io/n8n/commit/ed91495ebc1e09b89533ffef4b775eaa0139f365))
* **HTTP Request Tool Node:** Remove default user agent header ([#10971](https://github.com/n8n-io/n8n/issues/10971)) ([5a99e93](https://github.com/n8n-io/n8n/commit/5a99e93f8d2c66d7dbcef382478badd63bc4a0b5))
* **Postgres Node:** Falsy query parameters ignored ([#10960](https://github.com/n8n-io/n8n/issues/10960)) ([4a63cff](https://github.com/n8n-io/n8n/commit/4a63cff5ec722c810e3ff2bd7b0bb1e32f7f403b))
* **Respond to Webhook Node:** Node does not work with Wait node ([#10992](https://github.com/n8n-io/n8n/issues/10992)) ([2df5a5b](https://github.com/n8n-io/n8n/commit/2df5a5b649f8ba3b747782d6d5045820aa74955d))
* **RSS Feed Trigger Node:** Fix regression on missing timestamps ([#10991](https://github.com/n8n-io/n8n/issues/10991)) ([d2bc076](https://github.com/n8n-io/n8n/commit/d2bc0760e2b5c977fcc683f0a0281f099a9c538d))
* **Supabase Node:** Fix issue with delete not always working ([#10952](https://github.com/n8n-io/n8n/issues/10952)) ([1944b46](https://github.com/n8n-io/n8n/commit/1944b46fd472bb59552b5fbf7783168a622a2bd2))
* **Text Classifier Node:** Default system prompt template ([#11018](https://github.com/n8n-io/n8n/issues/11018)) ([77fec19](https://github.com/n8n-io/n8n/commit/77fec195d92e0fe23c60552a72e8c030cf7e5e5c))
* **Todoist Node:** Fix listSearch filter bug in Todoist Node ([#10989](https://github.com/n8n-io/n8n/issues/10989)) ([c4b3272](https://github.com/n8n-io/n8n/commit/c4b327248d7aa1352e8d6acec5627ff406aea3d4))
* **Todoist Node:** Make Section Name optional in Move Task operation ([#10732](https://github.com/n8n-io/n8n/issues/10732)) ([799006a](https://github.com/n8n-io/n8n/commit/799006a3cce6abe210469c839ae392d0c1aec486))
### Features
* Add more context to support chat ([#11014](https://github.com/n8n-io/n8n/issues/11014)) ([8a30f92](https://github.com/n8n-io/n8n/commit/8a30f92156d6a4fe73113bd3cdfb751b8c9ce4b4))
* Add Sysdig API credentials for SecOps ([#7033](https://github.com/n8n-io/n8n/issues/7033)) ([a8d1a1e](https://github.com/n8n-io/n8n/commit/a8d1a1ea854fb2c69643b0a5738440b389121ca3))
* **core:** Filter executions by project ID in internal API ([#10976](https://github.com/n8n-io/n8n/issues/10976)) ([06d749f](https://github.com/n8n-io/n8n/commit/06d749ffa7ced503141d8b07e22c47d971eb1623))
* **core:** Implement Dynamic Parameters within regular nodes used as AI Tools ([#10862](https://github.com/n8n-io/n8n/issues/10862)) ([ef5b7cf](https://github.com/n8n-io/n8n/commit/ef5b7cf9b77b653111eb5b1d9de8116c9f6b9f92))
* **editor:** Do not show error for remote options when credentials aren't specified ([#10944](https://github.com/n8n-io/n8n/issues/10944)) ([9fc3699](https://github.com/n8n-io/n8n/commit/9fc3699beb0c150909889ed17740a5cd9e0461c3))
* **editor:** Enable drag and drop in code editors (Code/SQL/HTML) ([#10888](https://github.com/n8n-io/n8n/issues/10888)) ([af9e227](https://github.com/n8n-io/n8n/commit/af9e227ad4848995b9d82c72f814dbf9d1de506f))
* **editor:** Overhaul document title management ([#10999](https://github.com/n8n-io/n8n/issues/10999)) ([bb28956](https://github.com/n8n-io/n8n/commit/bb2895689fb006897bc244271aca6f0bfa1839b9))
* **editor:** Remove execution annotation feature flag ([#11020](https://github.com/n8n-io/n8n/issues/11020)) ([e7199db](https://github.com/n8n-io/n8n/commit/e7199dbfccdbdf1c4273f916e3006ca610c230e9))
* **editor:** Support node-creator actions for vector store nodes ([#11032](https://github.com/n8n-io/n8n/issues/11032)) ([72b70d9](https://github.com/n8n-io/n8n/commit/72b70d9d98daeba654baf6785ff1ae234c73c977))
* **Google BigQuery Node:** Return numeric values as integers ([#10943](https://github.com/n8n-io/n8n/issues/10943)) ([d7c1d24](https://github.com/n8n-io/n8n/commit/d7c1d24f74648740b2f425640909037ba06c5030))
* **Invoice Ninja Node:** Add more query params to getAll requests ([#9238](https://github.com/n8n-io/n8n/issues/9238)) ([50b7238](https://github.com/n8n-io/n8n/commit/50b723836e70bbe405594f690b73057f9c33fbe4))
* **Iterable Node:** Add support for EDC and USDC selection ([#10908](https://github.com/n8n-io/n8n/issues/10908)) ([0ca9c07](https://github.com/n8n-io/n8n/commit/0ca9c076ca51d313392e45c3b013f2e83aaea843))
* **Question and Answer Chain Node:** Customize question and answer system prompt ([#10385](https://github.com/n8n-io/n8n/issues/10385)) ([08a27b3](https://github.com/n8n-io/n8n/commit/08a27b3148aac2282f64339ddc33ac7c90835d84))
# [1.61.0](https://github.com/n8n-io/n8n/compare/n8n@1.60.0...n8n@1.61.0) (2024-09-25)

View file

@ -59,7 +59,7 @@ export function setCredentialByName(name: string) {
export function clickCreateNewCredential() {
openCredentialSelect();
getCreateNewCredentialOption().click();
getCreateNewCredentialOption().click({ force: true });
}
export function clickGetBackToCanvas() {

View file

@ -32,8 +32,6 @@ export const addProjectMember = (email: string, role?: string) => {
}
};
export const getResourceMoveModal = () => cy.getByTestId('project-move-resource-modal');
export const getResourceMoveConfirmModal = () =>
cy.getByTestId('project-move-resource-confirm-modal');
export const getProjectMoveSelect = () => cy.getByTestId('project-move-resource-modal-select');
export function createProject(name: string) {

View file

@ -144,6 +144,12 @@ export function addToolNodeToParent(nodeName: string, parentNodeName: string) {
export function addOutputParserNodeToParent(nodeName: string, parentNodeName: string) {
addSupplementalNodeToParent(nodeName, 'ai_outputParser', parentNodeName);
}
export function addVectorStoreNodeToParent(nodeName: string, parentNodeName: string) {
addSupplementalNodeToParent(nodeName, 'ai_vectorStore', parentNodeName);
}
export function addRetrieverNodeToParent(nodeName: string, parentNodeName: string) {
addSupplementalNodeToParent(nodeName, 'ai_retriever', parentNodeName);
}
export function clickExecuteWorkflowButton() {
getExecuteWorkflowButton().click();

View file

@ -73,4 +73,28 @@ describe('Workflows', () => {
WorkflowsPage.getters.newWorkflowButtonCard().should('be.visible');
});
it('should respect tag querystring filter when listing workflows', () => {
WorkflowsPage.getters.newWorkflowButtonCard().click();
cy.createFixtureWorkflow('Test_workflow_2.json', getUniqueWorkflowName('My New Workflow'));
cy.visit(WorkflowsPage.url);
WorkflowsPage.getters.createWorkflowButton().click();
cy.createFixtureWorkflow('Test_workflow_1.json', 'Empty State Card Workflow');
cy.visit(WorkflowsPage.url);
WorkflowsPage.getters.workflowFilterButton().click();
WorkflowsPage.getters.workflowTagsDropdown().click();
WorkflowsPage.getters.workflowTagItem('some-tag-1').click();
cy.reload();
WorkflowsPage.getters.workflowCards().should('have.length', 1);
});
});

View file

@ -229,6 +229,35 @@ describe('Workflow Executions', () => {
cy.getByTestId('executions-filter-reset-button').should('be.visible').click();
executionsTab.getters.executionListItems().eq(11).should('be.visible');
});
it('should redirect back to editor after seeing a couple of execution using browser back button', () => {
createMockExecutions();
cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions');
executionsTab.actions.switchToExecutionsTab();
cy.wait(['@getExecutions']);
executionsTab.getters.workflowExecutionPreviewIframe().should('exist');
executionsTab.getters.executionListItems().eq(2).click();
executionsTab.getters.workflowExecutionPreviewIframe().should('exist');
executionsTab.getters.executionListItems().eq(4).click();
executionsTab.getters.workflowExecutionPreviewIframe().should('exist');
executionsTab.getters.executionListItems().eq(6).click();
executionsTab.getters.workflowExecutionPreviewIframe().should('exist');
cy.go('back');
executionsTab.getters.workflowExecutionPreviewIframe().should('exist');
cy.go('back');
executionsTab.getters.workflowExecutionPreviewIframe().should('exist');
cy.go('back');
executionsTab.getters.workflowExecutionPreviewIframe().should('exist');
cy.go('back');
cy.url().should('not.include', '/executions');
cy.url().should('include', '/workflow/');
workflowPage.getters.nodeViewRoot().should('be.visible');
});
});
describe('when new workflow is not saved', () => {

View file

@ -65,7 +65,7 @@ describe('Resource Locator', () => {
});
it('should show appropriate errors when search filter is required', () => {
workflowPage.actions.addNodeToCanvas('Github', true, true, 'On Pull Request');
workflowPage.actions.addNodeToCanvas('Github', true, true, 'On pull request');
ndv.getters.resourceLocator('owner').should('be.visible');
ndv.getters.resourceLocatorInput('owner').click();
ndv.getters.resourceLocatorErrorMessage().should('contain', NO_CREDENTIALS_MESSAGE);

View file

@ -1,5 +1,11 @@
import * as projects from '../composables/projects';
import { INSTANCE_MEMBERS, MANUAL_TRIGGER_NODE_NAME, NOTION_NODE_NAME } from '../constants';
import {
INSTANCE_ADMIN,
INSTANCE_MEMBERS,
INSTANCE_OWNER,
MANUAL_TRIGGER_NODE_NAME,
NOTION_NODE_NAME,
} from '../constants';
import {
WorkflowsPage,
WorkflowPage,
@ -481,44 +487,15 @@ describe('Projects', { disableAutoLogin: true }, () => {
projects
.getResourceMoveModal()
.should('be.visible')
.find('button:contains("Next")')
.find('button:contains("Move workflow")')
.should('be.disabled');
projects.getProjectMoveSelect().click();
getVisibleSelect()
.find('li')
.should('have.length', 2)
.first()
.should('contain.text', 'Project 1')
.click();
projects.getResourceMoveModal().find('button:contains("Next")').click();
projects
.getResourceMoveConfirmModal()
.should('be.visible')
.find('button:contains("Confirm")')
.should('be.disabled');
projects
.getResourceMoveConfirmModal()
.find('input[type="checkbox"]')
.first()
.parents('label')
.click();
projects
.getResourceMoveConfirmModal()
.find('button:contains("Confirm")')
.should('be.disabled');
projects
.getResourceMoveConfirmModal()
.find('input[type="checkbox"]')
.last()
.parents('label')
.click();
projects
.getResourceMoveConfirmModal()
.find('button:contains("Confirm")')
.should('not.be.disabled')
.should('have.length', 5)
.filter(':contains("Project 1")')
.click();
projects.getResourceMoveModal().find('button:contains("Move workflow")').click();
workflowsPage.getters
.workflowCards()
@ -526,9 +503,77 @@ describe('Projects', { disableAutoLogin: true }, () => {
.filter(':contains("Owned by me")')
.should('not.exist');
// Move the credential from Project 1 to Project 2
// Move the workflow from Project 1 to Project 2
projects.getMenuItems().first().click();
workflowsPage.getters.workflowCards().should('have.length', 2);
workflowsPage.getters.workflowCardActions('Workflow in Home project').click();
workflowsPage.getters.workflowMoveButton().click();
projects
.getResourceMoveModal()
.should('be.visible')
.find('button:contains("Move workflow")')
.should('be.disabled');
projects.getProjectMoveSelect().click();
getVisibleSelect()
.find('li')
.should('have.length', 5)
.filter(':contains("Project 2")')
.click();
projects.getResourceMoveModal().find('button:contains("Move workflow")').click();
// Move the workflow from Project 2 to a member user
projects.getMenuItems().last().click();
workflowsPage.getters.workflowCards().should('have.length', 2);
workflowsPage.getters.workflowCardActions('Workflow in Home project').click();
workflowsPage.getters.workflowMoveButton().click();
projects
.getResourceMoveModal()
.should('be.visible')
.find('button:contains("Move workflow")')
.should('be.disabled');
projects.getProjectMoveSelect().click();
getVisibleSelect()
.find('li')
.should('have.length', 5)
.filter(`:contains("${INSTANCE_MEMBERS[0].email}")`)
.click();
projects.getResourceMoveModal().find('button:contains("Move workflow")').click();
workflowsPage.getters.workflowCards().should('have.length', 1);
// Move the workflow from member user back to Home
projects.getHomeButton().click();
workflowsPage.getters
.workflowCards()
.should('have.length', 3)
.filter(':has(.n8n-badge:contains("Project"))')
.should('have.length', 2);
workflowsPage.getters.workflowCardActions('Workflow in Home project').click();
workflowsPage.getters.workflowMoveButton().click();
projects
.getResourceMoveModal()
.should('be.visible')
.find('button:contains("Move workflow")')
.should('be.disabled');
projects.getProjectMoveSelect().click();
getVisibleSelect()
.find('li')
.should('have.length', 5)
.filter(`:contains("${INSTANCE_OWNER.email}")`)
.click();
projects.getResourceMoveModal().find('button:contains("Move workflow")').click();
workflowsPage.getters
.workflowCards()
.should('have.length', 3)
.filter(':contains("Owned by me")')
.should('have.length', 1);
// Move the credential from Project 1 to Project 2
projects.getMenuItems().first().click();
projects.getProjectTabCredentials().click();
credentialsPage.getters.credentialCards().should('have.length', 1);
credentialsPage.getters.credentialCardActions('Credential in Project 1').click();
@ -537,48 +582,162 @@ describe('Projects', { disableAutoLogin: true }, () => {
projects
.getResourceMoveModal()
.should('be.visible')
.find('button:contains("Next")')
.find('button:contains("Move credential")')
.should('be.disabled');
projects.getProjectMoveSelect().click();
getVisibleSelect()
.find('li')
.should('have.length', 1)
.first()
.should('contain.text', 'Project 2')
.should('have.length', 5)
.filter(':contains("Project 2")')
.click();
projects.getResourceMoveModal().find('button:contains("Next")').click();
projects.getResourceMoveModal().find('button:contains("Move credential")').click();
projects
.getResourceMoveConfirmModal()
.should('be.visible')
.find('button:contains("Confirm")')
.should('be.disabled');
projects
.getResourceMoveConfirmModal()
.find('input[type="checkbox"]')
.first()
.parents('label')
.click();
projects
.getResourceMoveConfirmModal()
.find('button:contains("Confirm")')
.should('be.disabled');
projects
.getResourceMoveConfirmModal()
.find('input[type="checkbox"]')
.last()
.parents('label')
.click();
projects
.getResourceMoveConfirmModal()
.find('button:contains("Confirm")')
.should('not.be.disabled')
.click();
credentialsPage.getters.credentialCards().should('not.have.length');
// Move the credential from Project 2 to admin user
projects.getMenuItems().last().click();
projects.getProjectTabCredentials().click();
credentialsPage.getters.credentialCards().should('have.length', 2);
credentialsPage.getters.credentialCardActions('Credential in Project 1').click();
credentialsPage.getters.credentialMoveButton().click();
projects
.getResourceMoveModal()
.should('be.visible')
.find('button:contains("Move credential")')
.should('be.disabled');
projects.getProjectMoveSelect().click();
getVisibleSelect()
.find('li')
.should('have.length', 5)
.filter(`:contains("${INSTANCE_ADMIN.email}")`)
.click();
projects.getResourceMoveModal().find('button:contains("Move credential")').click();
credentialsPage.getters.credentialCards().should('have.length', 1);
// Move the credential from admin user back to instance owner
projects.getHomeButton().click();
projects.getProjectTabCredentials().click();
credentialsPage.getters.credentialCards().should('have.length', 3);
credentialsPage.getters.credentialCardActions('Credential in Project 1').click();
credentialsPage.getters.credentialMoveButton().click();
projects
.getResourceMoveModal()
.should('be.visible')
.find('button:contains("Move credential")')
.should('be.disabled');
projects.getProjectMoveSelect().click();
getVisibleSelect()
.find('li')
.should('have.length', 5)
.filter(`:contains("${INSTANCE_OWNER.email}")`)
.click();
projects.getResourceMoveModal().find('button:contains("Move credential")').click();
credentialsPage.getters
.credentialCards()
.should('have.length', 3)
.filter(':contains("Owned by me")')
.should('have.length', 2);
// Move the credential from admin user back to its original project (Project 1)
credentialsPage.getters.credentialCardActions('Credential in Project 1').click();
credentialsPage.getters.credentialMoveButton().click();
projects
.getResourceMoveModal()
.should('be.visible')
.find('button:contains("Move credential")')
.should('be.disabled');
projects.getProjectMoveSelect().click();
getVisibleSelect()
.find('li')
.should('have.length', 5)
.filter(':contains("Project 1")')
.click();
projects.getResourceMoveModal().find('button:contains("Move credential")').click();
projects.getMenuItems().first().click();
projects.getProjectTabCredentials().click();
credentialsPage.getters
.credentialCards()
.filter(':contains("Credential in Project 1")')
.should('have.length', 1);
});
it('should allow to change inaccessible credential when the workflow was moved to a team project', () => {
cy.signinAsOwner();
cy.visit(workflowsPage.url);
// Create a credential in the Home project
projects.getProjectTabCredentials().should('be.visible').click();
credentialsPage.getters.emptyListCreateCredentialButton().click();
projects.createCredential('Credential in Home project');
// Create a workflow in the Home project
projects.getHomeButton().click();
workflowsPage.getters.workflowCards().should('not.have.length');
workflowsPage.getters.newWorkflowButtonCard().click();
workflowsPage.getters.workflowCards().should('not.have.length');
workflowsPage.getters.newWorkflowButtonCard().click();
workflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
ndv.getters.backToCanvas().click();
workflowPage.actions.saveWorkflowOnButtonClick();
// Create a project and add a user to it
projects.createProject('Project 1');
projects.addProjectMember(INSTANCE_MEMBERS[0].email);
projects.getProjectSettingsSaveButton().click();
// Move the workflow from Home to Project 1
projects.getHomeButton().click();
workflowsPage.getters
.workflowCards()
.should('have.length', 1)
.filter(':contains("Owned by me")')
.should('exist');
workflowsPage.getters.workflowCardActions('My workflow').click();
workflowsPage.getters.workflowMoveButton().click();
projects
.getResourceMoveModal()
.should('be.visible')
.find('button:contains("Move workflow")')
.should('be.disabled');
projects.getProjectMoveSelect().click();
getVisibleSelect()
.find('li')
.should('have.length', 4)
.filter(':contains("Project 1")')
.click();
projects.getResourceMoveModal().find('button:contains("Move workflow")').click();
workflowsPage.getters
.workflowCards()
.should('have.length', 1)
.filter(':contains("Owned by me")')
.should('not.exist');
//Log out with instance owner and log in with the member user
mainSidebar.actions.openUserMenu();
cy.getByTestId('user-menu-item-logout').click();
cy.get('input[name="email"]').type(INSTANCE_MEMBERS[0].email);
cy.get('input[name="password"]').type(INSTANCE_MEMBERS[0].password);
cy.getByTestId('form-submit-button').click();
// Open the moved workflow
workflowsPage.getters.workflowCards().should('have.length', 1);
workflowsPage.getters.workflowCards().first().click();
// Check if the credential can be changed
workflowPage.getters.canvasNodeByName(NOTION_NODE_NAME).should('be.visible').dblclick();
ndv.getters.credentialInput().find('input').should('be.enabled');
});
it('should handle viewer role', () => {

View file

@ -1,3 +1,9 @@
import {
addNodeToCanvas,
addRetrieverNodeToParent,
addVectorStoreNodeToParent,
getNodeCreatorItems,
} from '../composables/workflow';
import { IF_NODE_NAME } from '../constants';
import { NodeCreator } from '../pages/features/node-creator';
import { NDV } from '../pages/ndv';
@ -504,4 +510,38 @@ describe('Node Creator', () => {
nodeCreatorFeature.getters.searchBar().find('input').clear().type('gith');
nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'GitHub');
});
it('should show vector stores actions', () => {
const actions = [
'Get ranked documents from vector store',
'Add documents to vector store',
'Retrieve documents for AI processing',
];
nodeCreatorFeature.actions.openNodeCreator();
nodeCreatorFeature.getters.searchBar().find('input').clear().type('Vector Store');
getNodeCreatorItems().then((items) => {
const vectorStores = items.map((_i, el) => el.innerText);
// Loop over all vector stores and check if they have the three actions
vectorStores.each((_i, vectorStore) => {
nodeCreatorFeature.getters.getCreatorItem(vectorStore).click();
actions.forEach((action) => {
nodeCreatorFeature.getters.getCreatorItem(action).should('be.visible');
});
cy.realPress('ArrowLeft');
});
});
});
it('should add node directly for sub-connection', () => {
addNodeToCanvas('Question and Answer Chain', true);
addRetrieverNodeToParent('Vector Store Retriever', 'Question and Answer Chain');
cy.realPress('Escape');
addVectorStoreNodeToParent('In-Memory Vector Store', 'Vector Store Retriever');
cy.realPress('Escape');
WorkflowPage.getters.canvasNodes().should('have.length', 4);
});
});

View file

@ -78,11 +78,11 @@ describe('AI Assistant::enabled', () => {
});
it('should start chat session from node error view', () => {
cy.intercept('POST', '/rest/ai-assistant/chat', {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/simple_message_response.json',
fixture: 'aiAssistant/responses/simple_message_response.json',
}).as('chatRequest');
cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
cy.createFixtureWorkflow('aiAssistant/workflows/test_workflow.json');
wf.actions.openNode('Stop and Error');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click();
@ -96,11 +96,11 @@ describe('AI Assistant::enabled', () => {
});
it('should render chat input correctly', () => {
cy.intercept('POST', '/rest/ai-assistant/chat', {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/simple_message_response.json',
fixture: 'aiAssistant/responses/simple_message_response.json',
}).as('chatRequest');
cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
cy.createFixtureWorkflow('aiAssistant/workflows/test_workflow.json');
wf.actions.openNode('Stop and Error');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click();
@ -129,11 +129,11 @@ describe('AI Assistant::enabled', () => {
});
it('should render and handle quick replies', () => {
cy.intercept('POST', '/rest/ai-assistant/chat', {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/quick_reply_message_response.json',
fixture: 'aiAssistant/responses/quick_reply_message_response.json',
}).as('chatRequest');
cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
cy.createFixtureWorkflow('aiAssistant/workflows/test_workflow.json');
wf.actions.openNode('Stop and Error');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click();
@ -146,18 +146,24 @@ describe('AI Assistant::enabled', () => {
});
it('should show quick replies when node is executed after new suggestion', () => {
cy.intercept('POST', '/rest/ai-assistant/chat', (req) => {
cy.intercept('POST', '/rest/ai/chat', (req) => {
req.reply((res) => {
if (['init-error-helper', 'message'].includes(req.body.payload.type)) {
res.send({ statusCode: 200, fixture: 'aiAssistant/simple_message_response.json' });
res.send({
statusCode: 200,
fixture: 'aiAssistant/responses/simple_message_response.json',
});
} else if (req.body.payload.type === 'event') {
res.send({ statusCode: 200, fixture: 'aiAssistant/node_execution_error_response.json' });
res.send({
statusCode: 200,
fixture: 'aiAssistant/responses/node_execution_error_response.json',
});
} else {
res.send({ statusCode: 500 });
}
});
}).as('chatRequest');
cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
cy.createFixtureWorkflow('aiAssistant/workflows/test_workflow.json');
wf.actions.openNode('Edit Fields');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click();
@ -172,16 +178,15 @@ describe('AI Assistant::enabled', () => {
aiAssistant.getters.quickReplies().should('not.exist');
ndv.getters.nodeExecuteButton().click();
// But after executing the node again, quick replies should be shown
aiAssistant.getters.chatMessagesAssistant().should('have.length', 4);
aiAssistant.getters.quickReplies().should('have.length', 2);
});
it('should warn before starting a new session', () => {
cy.intercept('POST', '/rest/ai-assistant/chat', {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/simple_message_response.json',
fixture: 'aiAssistant/responses/simple_message_response.json',
}).as('chatRequest');
cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
cy.createFixtureWorkflow('aiAssistant/workflows/test_workflow.json');
wf.actions.openNode('Edit Fields');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click({ force: true });
@ -204,15 +209,15 @@ describe('AI Assistant::enabled', () => {
});
it('should apply code diff to code node', () => {
cy.intercept('POST', '/rest/ai-assistant/chat', {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/code_diff_suggestion_response.json',
fixture: 'aiAssistant/responses/code_diff_suggestion_response.json',
}).as('chatRequest');
cy.intercept('POST', '/rest/ai-assistant/chat/apply-suggestion', {
cy.intercept('POST', '/rest/ai/chat/apply-suggestion', {
statusCode: 200,
fixture: 'aiAssistant/apply_code_diff_response.json',
fixture: 'aiAssistant/responses/apply_code_diff_response.json',
}).as('applySuggestion');
cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
cy.createFixtureWorkflow('aiAssistant/workflows/test_workflow.json');
wf.actions.openNode('Code');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click({ force: true });
@ -254,11 +259,11 @@ describe('AI Assistant::enabled', () => {
});
it('should end chat session when `end_session` event is received', () => {
cy.intercept('POST', '/rest/ai-assistant/chat', {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/end_session_response.json',
fixture: 'aiAssistant/responses/end_session_response.json',
}).as('chatRequest');
cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
cy.createFixtureWorkflow('aiAssistant/workflows/test_workflow.json');
wf.actions.openNode('Stop and Error');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click();
@ -268,12 +273,15 @@ describe('AI Assistant::enabled', () => {
});
it('should reset session after it ended and sidebar is closed', () => {
cy.intercept('POST', '/rest/ai-assistant/chat', (req) => {
cy.intercept('POST', '/rest/ai/chat', (req) => {
req.reply((res) => {
if (['init-support-chat'].includes(req.body.payload.type)) {
res.send({ statusCode: 200, fixture: 'aiAssistant/simple_message_response.json' });
res.send({
statusCode: 200,
fixture: 'aiAssistant/responses/simple_message_response.json',
});
} else {
res.send({ statusCode: 200, fixture: 'aiAssistant/end_session_response.json' });
res.send({ statusCode: 200, fixture: 'aiAssistant/responses/end_session_response.json' });
}
});
}).as('chatRequest');
@ -296,9 +304,9 @@ describe('AI Assistant::enabled', () => {
});
it('Should not reset assistant session when workflow is saved', () => {
cy.intercept('POST', '/rest/ai-assistant/chat', {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/simple_message_response.json',
fixture: 'aiAssistant/responses/simple_message_response.json',
}).as('chatRequest');
wf.actions.addInitialNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
aiAssistant.actions.openChat();
@ -321,9 +329,9 @@ describe('AI Assistant Credential Help', () => {
});
it('should start credential help from node credential', () => {
cy.intercept('POST', '/rest/ai-assistant/chat', {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/simple_message_response.json',
fixture: 'aiAssistant/responses/simple_message_response.json',
}).as('chatRequest');
wf.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
wf.actions.addNodeToCanvas(GMAIL_NODE_NAME);
@ -347,9 +355,9 @@ describe('AI Assistant Credential Help', () => {
});
it('should start credential help from credential list', () => {
cy.intercept('POST', '/rest/ai-assistant/chat', {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/simple_message_response.json',
fixture: 'aiAssistant/responses/simple_message_response.json',
}).as('chatRequest');
cy.visit(credentialsPage.url);
@ -446,9 +454,9 @@ describe('General help', () => {
});
it('assistant returns code snippet', () => {
cy.intercept('POST', '/rest/ai-assistant/chat', {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/code_snippet_response.json',
fixture: 'aiAssistant/responses/code_snippet_response.json',
}).as('chatRequest');
aiAssistant.getters.askAssistantFloatingButton().should('be.visible');
@ -492,4 +500,65 @@ describe('General help', () => {
);
aiAssistant.getters.codeSnippet().should('have.text', '{{$json.body.city}}');
});
it('should send current context to support chat', () => {
cy.createFixtureWorkflow('aiAssistant/workflows/simple_http_request_workflow.json');
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/responses/simple_message_response.json',
}).as('chatRequest');
aiAssistant.getters.askAssistantFloatingButton().click();
aiAssistant.actions.sendMessage('What is wrong with this workflow?');
cy.wait('@chatRequest').then((interception) => {
const { body } = interception.request;
// Body should contain the current workflow context
expect(body.payload).to.have.property('context');
expect(body.payload.context).to.have.property('currentView');
expect(body.payload.context.currentView.name).to.equal('NodeViewExisting');
expect(body.payload.context).to.have.property('currentWorkflow');
});
});
it('should not send workflow context if nothing changed', () => {
cy.createFixtureWorkflow('aiAssistant/workflows/simple_http_request_workflow.json');
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/responses/simple_message_response.json',
}).as('chatRequest');
aiAssistant.getters.askAssistantFloatingButton().click();
aiAssistant.actions.sendMessage('What is wrong with this workflow?');
cy.wait('@chatRequest');
// Send another message without changing workflow or executing any node
aiAssistant.actions.sendMessage('And now?');
cy.wait('@chatRequest').then((interception) => {
const { body } = interception.request;
// Workflow context should be empty
expect(body.payload).to.have.property('context');
expect(body.payload.context).not.to.have.property('currentWorkflow');
});
// Update http request node url
wf.actions.openNode('HTTP Request');
ndv.actions.typeIntoParameterInput('url', 'https://example.com');
ndv.actions.close();
// Also execute the workflow
wf.actions.executeWorkflow();
// Send another message
aiAssistant.actions.sendMessage('What about now?');
cy.wait('@chatRequest').then((interception) => {
const { body } = interception.request;
// Both workflow and execution context should be sent
expect(body.payload).to.have.property('context');
expect(body.payload.context).to.have.property('currentWorkflow');
expect(body.payload.context.currentWorkflow).not.to.be.empty;
expect(body.payload.context).to.have.property('executionData');
expect(body.payload.context.executionData).not.to.be.empty;
});
});
});

View file

@ -674,6 +674,23 @@ describe('NDV', () => {
ndv.getters.parameterInput('operation').find('input').should('have.value', 'Delete');
});
it('Should show a notice when remote options cannot be fetched because of missing credentials', () => {
cy.intercept('POST', '/rest/dynamic-node-parameters/options', { statusCode: 403 }).as(
'parameterOptions',
);
workflowPage.actions.addInitialNodeToCanvas(NOTION_NODE_NAME, {
keepNdvOpen: true,
action: 'Update a database page',
});
ndv.actions.addItemToFixedCollection('propertiesUi');
ndv.getters
.parameterInput('key')
.find('input')
.should('have.value', 'Set up credential to see options');
});
it('Should show error state when remote options cannot be fetched', () => {
cy.intercept('POST', '/rest/dynamic-node-parameters/options', { statusCode: 500 }).as(
'parameterOptions',
@ -684,6 +701,11 @@ describe('NDV', () => {
action: 'Update a database page',
});
clickCreateNewCredential();
setCredentialValues({
apiKey: 'sk_test_123',
});
ndv.actions.addItemToFixedCollection('propertiesUi');
ndv.getters
.parameterInput('key')

View file

@ -91,28 +91,12 @@ return []
});
describe('Ask AI', () => {
it('tab should display based on experiment', () => {
WorkflowPage.actions.visit();
cy.window().then((win) => {
win.featureFlags.override('011_ask_AI', 'control');
WorkflowPage.actions.addInitialNodeToCanvas('Manual');
WorkflowPage.actions.addNodeToCanvas('Code');
WorkflowPage.actions.openNode('Code');
cy.getByTestId('code-node-tab-ai').should('not.exist');
ndv.actions.close();
win.featureFlags.override('011_ask_AI', undefined);
WorkflowPage.actions.openNode('Code');
cy.getByTestId('code-node-tab-ai').should('not.exist');
});
});
describe('Enabled', () => {
beforeEach(() => {
cy.enableFeature('askAi');
WorkflowPage.actions.visit();
cy.window().then((win) => {
win.featureFlags.override('011_ask_AI', 'gpt3');
cy.window().then(() => {
WorkflowPage.actions.addInitialNodeToCanvas('Manual');
WorkflowPage.actions.addNodeToCanvas('Code', true, true);
});
@ -157,7 +141,7 @@ return []
cy.getByTestId('ask-ai-prompt-input').type(prompt);
cy.intercept('POST', '/rest/ask-ai', {
cy.intercept('POST', '/rest/ai/ask-ai', {
statusCode: 200,
body: {
data: {
@ -169,9 +153,7 @@ return []
cy.getByTestId('ask-ai-cta').click();
const askAiReq = cy.wait('@ask-ai');
askAiReq
.its('request.body')
.should('have.keys', ['question', 'model', 'context', 'n8nVersion']);
askAiReq.its('request.body').should('have.keys', ['question', 'context', 'forNode']);
askAiReq.its('context').should('have.keys', ['schema', 'ndvPushRef', 'pushRef']);
@ -195,7 +177,7 @@ return []
];
handledCodes.forEach(({ code, message }) => {
cy.intercept('POST', '/rest/ask-ai', {
cy.intercept('POST', '/rest/ai/ask-ai', {
statusCode: code,
status: code,
}).as('ask-ai');

View file

@ -0,0 +1,35 @@
{
"nodes": [
{
"parameters": {},
"id": "298d3dc9-5e99-4b3f-919e-05fdcdfbe2d0",
"name": "When clicking Test workflow",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [360, 220]
},
{
"parameters": {
"options": {}
},
"id": "65c32346-e939-4ec7-88a9-1f9184e2258d",
"name": "HTTP Request",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [580, 220]
}
],
"connections": {
"When clicking Test workflow": {
"main": [
[
{
"node": "HTTP Request",
"type": "main",
"index": 0
}
]
]
}
}
}

View file

@ -156,7 +156,7 @@ export class NDV extends BasePage {
this.getters.nodeExecuteButton().first().click();
},
close: () => {
this.getters.backToCanvas().click();
this.getters.backToCanvas().click({ force: true });
},
openInlineExpressionEditor: () => {
cy.contains('Expression').invoke('show').click();

View file

@ -8,7 +8,7 @@ pre-commit:
- merge
- rebase
prettier_check:
glob: 'packages/**/*.{vue,yml,md}'
glob: 'packages/**/*.{vue,yml,md,css,scss}'
run: ./node_modules/.bin/prettier --write --ignore-unknown --no-error-on-unmatched-pattern {staged_files}
stage_fixed: true
skip:

View file

@ -1,7 +0,0 @@
{
"folders": [
{
"path": "."
}
]
}

View file

@ -1,6 +1,6 @@
{
"name": "n8n-monorepo",
"version": "1.61.0",
"version": "1.63.0",
"private": true,
"engines": {
"node": ">=20.15",
@ -15,7 +15,7 @@
"build:frontend": "turbo run build:frontend",
"build:nodes": "turbo run build:nodes",
"typecheck": "turbo typecheck",
"dev": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat",
"dev": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat --filter=!@n8n/task-runner",
"dev:ai": "turbo run dev --parallel --env-mode=loose --filter=@n8n/nodes-langchain --filter=n8n --filter=n8n-core",
"clean": "turbo run clean --parallel",
"reset": "node scripts/ensure-zx.mjs && zx scripts/reset.mjs",
@ -59,7 +59,7 @@
"ts-jest": "^29.1.1",
"tsc-alias": "^1.8.7",
"tsc-watch": "^6.0.4",
"turbo": "2.0.6",
"turbo": "2.1.2",
"typescript": "*",
"zx": "^8.1.4"
},
@ -69,14 +69,15 @@
],
"overrides": {
"@types/node": "^18.16.16",
"chokidar": "3.5.2",
"esbuild": "^0.20.2",
"chokidar": "^4.0.1",
"esbuild": "^0.24.0",
"formidable": "3.5.1",
"pug": "^3.0.3",
"semver": "^7.5.4",
"tslib": "^2.6.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.6.2",
"vue-tsc": "^2.1.6",
"ws": ">=8.17.1"
},
"patchedDependencies": {

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/api-types",
"version": "0.3.0",
"version": "0.4.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View file

@ -2,3 +2,4 @@ export { PasswordUpdateRequestDto } from './user/password-update-request.dto';
export { RoleChangeRequestDto } from './user/role-change-request.dto';
export { SettingsUpdateRequestDto } from './user/settings-update-request.dto';
export { UserUpdateRequestDto } from './user/user-update-request.dto';
export { CommunityRegisteredRequestDto } from './license/community-registered-request.dto';

View file

@ -0,0 +1,27 @@
import { CommunityRegisteredRequestDto } from '../community-registered-request.dto';
describe('CommunityRegisteredRequestDto', () => {
it('should fail validation for missing email', () => {
const invalidRequest = {};
const result = CommunityRegisteredRequestDto.safeParse(invalidRequest);
expect(result.success).toBe(false);
expect(result.error?.issues[0]).toEqual(
expect.objectContaining({ message: 'Required', path: ['email'] }),
);
});
it('should fail validation for an invalid email', () => {
const invalidRequest = {
email: 'invalid-email',
};
const result = CommunityRegisteredRequestDto.safeParse(invalidRequest);
expect(result.success).toBe(false);
expect(result.error?.issues[0]).toEqual(
expect.objectContaining({ message: 'Invalid email', path: ['email'] }),
);
});
});

View file

@ -0,0 +1,4 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export class CommunityRegisteredRequestDto extends Z.class({ email: z.string().email() }) {}

View file

@ -33,6 +33,7 @@ export interface FrontendSettings {
endpointFormWaiting: string;
endpointWebhook: string;
endpointWebhookTest: string;
endpointWebhookWaiting: string;
saveDataErrorExecution: WorkflowSettings.SaveDataExecution;
saveDataSuccessExecution: WorkflowSettings.SaveDataExecution;
saveManualExecutions: boolean;
@ -106,6 +107,9 @@ export interface FrontendSettings {
aiAssistant: {
enabled: boolean;
};
askAi: {
enabled: boolean;
};
deployment: {
type: string;
};
@ -153,9 +157,6 @@ export interface FrontendSettings {
banners: {
dismissed: string[];
};
ai: {
enabled: boolean;
};
workflowHistory: {
pruneTime: number;
licensePruneTime: number;

View file

@ -11,7 +11,7 @@ export type RunningJobSummary = {
};
export type WorkerStatus = {
workerId: string;
senderId: string;
runningJobsSummary: RunningJobSummary[];
freeMem: number;
totalMem: number;

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/n8n-benchmark",
"version": "1.5.0",
"version": "1.7.0",
"description": "Cli for running benchmark tests for n8n",
"main": "dist/index",
"scripts": {

View file

@ -1,7 +0,0 @@
{
"$schema": "../scenario.schema.json",
"name": "CodeNodeJsOnceForEach",
"description": "A JS Code Node that runs once for each item and adds, modifies and removes properties. The data of 5 items is generated using DebugHelper Node, and returned with RespondToWebhook Node.",
"scenarioData": { "workflowFiles": ["js-code-node-once-for-each.json"] },
"scriptPath": "js-code-node-once-for-each.script.js"
}

View file

@ -1,9 +1,31 @@
{
"createdAt": "2024-08-06T12:19:51.268Z",
"updatedAt": "2024-08-06T12:20:45.000Z",
"name": "JS Code Node Once For Each",
"name": "JS Code Node",
"active": true,
"nodes": [
{
"parameters": {
"respondWith": "allIncomingItems",
"options": {}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [1280, 460],
"id": "0067e317-09b8-478a-8c50-e19b4c9e294c",
"name": "Respond to Webhook"
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Add new field\n$input.item.json.age = 10 + Math.floor(Math.random() * 30);\n// Mutate existing field\n$input.item.json.password = $input.item.json.password.split('').map(() => '*').join(\"\")\n// Remove field\ndelete $input.item.json.lastname\n// New object field\nconst emailParts = $input.item.json.email.split(\"@\")\n$input.item.json.emailData = {\n user: emailParts[0],\n domain: emailParts[1]\n}\n\nreturn $input.item;"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1040, 460],
"id": "56d751c0-0d30-43c3-89fa-bebf3a9d436f",
"name": "OnceForEachItemJSCode"
},
{
"parameters": {
"httpMethod": "POST",
@ -13,68 +35,23 @@
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [0, 0],
"id": "849350b3-4212-4416-a462-1cf331157d37",
"position": [580, 460],
"id": "417d749d-156c-4ffe-86ea-336f702dc5da",
"name": "Webhook",
"webhookId": "34ca1895-ccf4-4a4a-8bb8-a042f5edb567"
},
{
"parameters": {
"respondWith": "allIncomingItems",
"options": {}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [660, 0],
"id": "f0660aa1-8a65-490f-b5cd-f8d134070c13",
"name": "Respond to Webhook"
},
{
"parameters": {
"category": "randomData",
"randomDataCount": 5
},
"type": "n8n-nodes-base.debugHelper",
"typeVersion": 1,
"position": [220, 0],
"id": "50f1efe8-bd2d-4061-9f51-b38c0e3daeb2",
"name": "DebugHelper"
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Add new field\n$input.item.json.age = 10 + Math.floor(Math.random() * 30);\n// Mutate existing field\n$input.item.json.password = $input.item.json.password.split('').map(() => '*').join(\"\")\n// Remove field\ndelete $input.item.json.lastname\n// New object field\nconst emailParts = $input.item.json.email.split(\"@\")\n$input.item.json.emailData = {\n user: emailParts[0],\n domain: emailParts[1]\n}\n\nreturn $input.item;"
"jsCode": "const digits = '0123456789';\nconst uppercaseLetters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';\nconst lowercaseLetters = uppercaseLetters.toLowerCase();\nconst alphabet = [digits, uppercaseLetters, lowercaseLetters].join('').split('')\n\nconst randomInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;\nconst randomItem = (arr) => arr.at(randomInt(0, arr.length - 1))\nconst randomString = (len) => Array.from({ length: len }).map(() => randomItem(alphabet)).join('')\n\nconst randomUid = () => [8,4,4,4,8].map(len => randomString(len)).join(\"-\")\nconst randomEmail = () => `${randomString(8)}@${randomString(10)}.com`\n\nconst randomPerson = () => ({\n uid: randomUid(),\n email: randomEmail(),\n firstname: randomString(5),\n lastname: randomString(12),\n password: randomString(10)\n})\n\nreturn Array.from({ length: 100 }).map(() => ({\n json: randomPerson()\n}))"
},
"id": "c30db155-73ca-48b9-8860-c3fe7a0926fb",
"name": "Code",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [440, 0],
"id": "f9f2f865-e228-403d-8e47-72308359e207",
"name": "OnceForEachItemJSCode"
"position": [820, 460]
}
],
"connections": {
"Webhook": {
"main": [
[
{
"node": "DebugHelper",
"type": "main",
"index": 0
}
]
]
},
"DebugHelper": {
"main": [
[
{
"node": "OnceForEachItemJSCode",
"type": "main",
"index": 0
}
]
]
},
"OnceForEachItemJSCode": {
"main": [
[
@ -85,6 +62,28 @@
}
]
]
},
"Webhook": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
},
"Code": {
"main": [
[
{
"node": "OnceForEachItemJSCode",
"type": "main",
"index": 0
}
]
]
}
},
"settings": { "executionOrder": "v1" },

View file

@ -0,0 +1,7 @@
{
"$schema": "../scenario.schema.json",
"name": "CodeNodeJs",
"description": "A JS Code Node that first generates 100 items and then runs once for each item and adds, modifies and removes properties. The data returned with RespondToWebhook Node.",
"scenarioData": { "workflowFiles": ["js-code-node.json"] },
"scriptPath": "js-code-node.script.js"
}

View file

@ -12,7 +12,7 @@ export default function () {
try {
const body = JSON.parse(r.body);
return Array.isArray(body) ? body.length === 5 : false;
return Array.isArray(body) ? body.length === 100 : false;
} catch (error) {
console.error('Error parsing response body: ', error);
return false;

View file

@ -7,7 +7,7 @@ services:
- ${MOCK_API_DATA_PATH}/mappings:/home/wiremock/mappings
postgres:
image: postgres:16
image: postgres:16.4
restart: always
user: root:root
environment:

View file

@ -7,7 +7,7 @@ services:
- ${MOCK_API_DATA_PATH}/mappings:/home/wiremock/mappings
redis:
image: redis:6-alpine
image: redis:6.2.14-alpine
restart: always
ports:
- 6379:6379
@ -17,7 +17,7 @@ services:
timeout: 3s
postgres:
image: postgres:16
image: postgres:16.4
restart: always
environment:
- POSTGRES_DB=n8n

View file

@ -7,7 +7,7 @@ services:
- ${MOCK_API_DATA_PATH}/mappings:/home/wiremock/mappings
redis:
image: redis:6-alpine
image: redis:6.2.14-alpine
ports:
- 6379:6379
healthcheck:
@ -16,7 +16,7 @@ services:
timeout: 3s
postgres:
image: postgres:16
image: postgres:16.4
user: root:root
restart: always
environment:

View file

@ -105,9 +105,8 @@ async function main() {
console.error(error.message);
console.error('');
await printContainerStatus(dockerComposeClient);
console.error('');
await dumpLogs(dockerComposeClient);
} finally {
await dumpLogs(dockerComposeClient);
await dockerComposeClient.$('down');
}
}
@ -118,7 +117,7 @@ async function printContainerStatus(dockerComposeClient) {
}
async function dumpLogs(dockerComposeClient) {
console.error('Container logs:');
console.info('Container logs:');
await dockerComposeClient.$('logs');
}

View file

@ -184,6 +184,16 @@ createChat({
- **Type**: `string[]`
- **Description**: The initial messages to be displayed in the Chat window.
### `allowFileUploads`
- **Type**: `Ref<boolean> | boolean`
- **Default**: `false`
- **Description**: Whether to allow file uploads in the chat. If set to `true`, users will be able to upload files through the chat interface.
### `allowedFilesMimeTypes`
- **Type**: `Ref<string> | string`
- **Default**: `''`
- **Description**: A comma-separated list of allowed MIME types for file uploads. Only applicable if `allowFileUploads` is set to `true`. If left empty, all file types are allowed. For example: `'image/*,application/pdf'`.
## Customization
The Chat window is entirely customizable using CSS variables.

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/chat",
"version": "0.26.0",
"version": "0.28.0",
"scripts": {
"dev": "pnpm run storybook",
"build": "pnpm build:vite && pnpm build:bundle",
@ -50,7 +50,7 @@
"unplugin-icons": "^0.19.0",
"vite": "catalog:frontend",
"vitest": "catalog:frontend",
"vite-plugin-dts": "^3.9.1",
"vite-plugin-dts": "^4.2.3",
"vue-tsc": "catalog:frontend"
},
"files": [

View file

@ -1,4 +1,20 @@
@import 'highlight.js/styles/github.css';
@use 'sass:meta';
@include meta.load-css('highlight.js/styles/github.css');
@mixin hljs-dark-theme {
@include meta.load-css('highlight.js/styles/github-dark-dimmed.css');
}
body {
&[data-theme='dark'] {
@include hljs-dark-theme;
}
@media (prefers-color-scheme: dark) {
@include hljs-dark-theme;
}
}
// https://github.com/pxlrbt/markdown-css
.chat-message-markdown {
@ -561,7 +577,6 @@
kbd, /* different style for kbd? */
code {
background: #eee;
padding: 0.1em 0.25em;
border-radius: 0.2rem;
-webkit-box-decoration-break: clone;

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/config",
"version": "1.11.0",
"version": "1.13.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View file

@ -0,0 +1,73 @@
import { Config, Env, Nested } from '../decorators';
import { StringArray } from '../utils';
/**
* Scopes (areas of functionality) to filter logs by.
*
* `executions` -> execution lifecycle
* `license` -> license SDK
* `scaling` -> scaling mode
*/
export const LOG_SCOPES = ['executions', 'license', 'scaling'] as const;
export type LogScope = (typeof LOG_SCOPES)[number];
@Config
class FileLoggingConfig {
/**
* Max number of log files to keep, or max number of days to keep logs for.
* Once the limit is reached, the oldest log files will be rotated out.
* If using days, append a `d` suffix. Only for `file` log output.
*
* @example `N8N_LOG_FILE_COUNT_MAX=7` will keep at most 7 files.
* @example `N8N_LOG_FILE_COUNT_MAX=7d` will keep at most 7 days worth of files.
*/
@Env('N8N_LOG_FILE_COUNT_MAX')
fileCountMax: number = 100;
/** Max size (in MiB) for each log file. Only for `file` log output. */
@Env('N8N_LOG_FILE_SIZE_MAX')
fileSizeMax: number = 16;
/** Location of the log files inside `~/.n8n`. Only for `file` log output. */
@Env('N8N_LOG_FILE_LOCATION')
location: string = 'logs/n8n.log';
}
@Config
export class LoggingConfig {
/**
* Minimum level of logs to output. Logs with this or higher level will be output;
* logs with lower levels will not. Exception: `silent` disables all logging.
*
* @example `N8N_LOG_LEVEL=info` will output `error`, `warn` and `info` logs, but not `debug`.
*/
@Env('N8N_LOG_LEVEL')
level: 'error' | 'warn' | 'info' | 'debug' | 'silent' = 'info';
/**
* Where to output logs to. Options are: `console` or `file` or both in a comma separated list.
*
* @example `N8N_LOG_OUTPUT=console,file` will output to both console and file.
*/
@Env('N8N_LOG_OUTPUT')
outputs: StringArray<'console' | 'file'> = ['console'];
@Nested
file: FileLoggingConfig;
/**
* Scopes to filter logs by. Nothing is filtered by default.
*
* Currently supported log scopes:
* - `executions`
* - `license`
* - `scaling`
*
* @example
* `N8N_LOG_SCOPES=license`
* `N8N_LOG_SCOPES=license,executions`
*/
@Env('N8N_LOG_SCOPES')
scopes: StringArray<LogScope> = [];
}

View file

@ -0,0 +1,22 @@
import { Config, Env } from '../decorators';
@Config
export class TaskRunnersConfig {
// Defaults to true for now
@Env('N8N_RUNNERS_DISABLED')
disabled: boolean = true;
@Env('N8N_RUNNERS_PATH')
path: string = '/runners';
@Env('N8N_RUNNERS_AUTH_TOKEN')
authToken: string = '';
/** IP address task runners server should listen on */
@Env('N8N_RUNNERS_SERVER_PORT')
port: number = 5679;
/** IP address task runners server should listen on */
@Env('N8N_RUNNERS_SERVER_LISTEN_ADDRESS')
listen_address: string = '127.0.0.1';
}

View file

@ -2,13 +2,21 @@ import { Config, Env, Nested } from '../decorators';
@Config
class HealthConfig {
/** Whether to enable the worker health check endpoint `/healthz`. */
/**
* Whether to enable the worker health check endpoints:
* - `/healthz` (worker alive)
* - `/healthz/readiness` (worker connected to migrated database and connected to Redis)
*/
@Env('QUEUE_HEALTH_CHECK_ACTIVE')
active: boolean = false;
/** Port for worker to respond to health checks requests on, if enabled. */
/** Port for worker server to listen on. */
@Env('QUEUE_HEALTH_CHECK_PORT')
port: number = 5678;
/** IP address for worker server to listen on. */
@Env('N8N_WORKER_SERVER_ADDRESS')
address: string = '0.0.0.0';
}
@Config

View file

@ -5,8 +5,11 @@ import { EndpointsConfig } from './configs/endpoints.config';
import { EventBusConfig } from './configs/event-bus.config';
import { ExternalSecretsConfig } from './configs/external-secrets.config';
import { ExternalStorageConfig } from './configs/external-storage.config';
import { LoggingConfig } from './configs/logging.config';
import { NodesConfig } from './configs/nodes.config';
import { PublicApiConfig } from './configs/public-api.config';
import { TaskRunnersConfig } from './configs/runners.config';
export { TaskRunnersConfig } from './configs/runners.config';
import { ScalingModeConfig } from './configs/scaling-mode.config';
import { SentryConfig } from './configs/sentry.config';
import { TemplatesConfig } from './configs/templates.config';
@ -15,6 +18,9 @@ import { VersionNotificationsConfig } from './configs/version-notifications.conf
import { WorkflowsConfig } from './configs/workflows.config';
import { Config, Env, Nested } from './decorators';
export { LOG_SCOPES } from './configs/logging.config';
export type { LogScope } from './configs/logging.config';
@Config
export class GlobalConfig {
@Nested
@ -81,4 +87,10 @@ export class GlobalConfig {
@Nested
queue: ScalingModeConfig;
@Nested
logging: LoggingConfig;
@Nested
taskRunners: TaskRunnersConfig;
}

View file

@ -0,0 +1,7 @@
export class StringArray<T extends string> extends Array<T> {
constructor(str: string) {
super();
const parsed = str.split(',') as StringArray<T>;
return parsed.every((i) => typeof i === 'string') ? parsed : [];
}
}

View file

@ -198,6 +198,7 @@ describe('GlobalConfig', () => {
health: {
active: false,
port: 5678,
address: '0.0.0.0',
},
bull: {
redis: {
@ -221,16 +222,32 @@ describe('GlobalConfig', () => {
},
},
},
taskRunners: {
disabled: true,
path: '/runners',
authToken: '',
listen_address: '127.0.0.1',
port: 5679,
},
sentry: {
backendDsn: '',
frontendDsn: '',
},
logging: {
level: 'info',
outputs: ['console'],
file: {
fileCountMax: 100,
fileSizeMax: 16,
location: 'logs/n8n.log',
},
scopes: [],
},
};
it('should use all default values when no env variables are defined', () => {
process.env = {};
const config = Container.get(GlobalConfig);
expect(deepCopy(config)).toEqual(defaultConfig);
expect(mockFs.readFileSync).not.toHaveBeenCalled();
});

View file

@ -1,27 +1,28 @@
import { BINARY_ENCODING, NodeConnectionType, NodeOperationError } from 'n8n-workflow';
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import type { AgentAction, AgentFinish } from 'langchain/agents';
import { AgentExecutor, createToolCallingAgent } from 'langchain/agents';
import type { BaseChatMemory } from '@langchain/community/memory/chat_memory';
import { HumanMessage } from '@langchain/core/messages';
import type { BaseMessage } from '@langchain/core/messages';
import type { BaseOutputParser, StructuredOutputParser } from '@langchain/core/output_parsers';
import type { BaseMessagePromptTemplateLike } from '@langchain/core/prompts';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { omit } from 'lodash';
import { RunnableSequence } from '@langchain/core/runnables';
import type { Tool } from '@langchain/core/tools';
import { DynamicStructuredTool } from '@langchain/core/tools';
import type { AgentAction, AgentFinish } from 'langchain/agents';
import { AgentExecutor, createToolCallingAgent } from 'langchain/agents';
import { OutputFixingParser } from 'langchain/output_parsers';
import { omit } from 'lodash';
import { BINARY_ENCODING, jsonParse, NodeConnectionType, NodeOperationError } from 'n8n-workflow';
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import type { ZodObject } from 'zod';
import { z } from 'zod';
import type { BaseOutputParser, StructuredOutputParser } from '@langchain/core/output_parsers';
import { OutputFixingParser } from 'langchain/output_parsers';
import { HumanMessage } from '@langchain/core/messages';
import { RunnableSequence } from '@langchain/core/runnables';
import { SYSTEM_MESSAGE } from './prompt';
import {
isChatInstance,
getPromptInputByType,
getOptionalOutputParsers,
getConnectedTools,
} from '../../../../../utils/helpers';
import { SYSTEM_MESSAGE } from './prompt';
function getOutputParserSchema(outputParser: BaseOutputParser): ZodObject<any, any, any, any> {
const parserType = outputParser.lc_namespace[outputParser.lc_namespace.length - 1];
@ -74,6 +75,39 @@ async function extractBinaryMessages(ctx: IExecuteFunctions) {
content: [...binaryMessages],
});
}
/**
* Fixes empty content messages in agent steps.
*
* This function is necessary when using RunnableSequence.from in LangChain.
* If a tool doesn't have any arguments, LangChain returns input: '' (empty string).
* This can throw an error for some providers (like Anthropic) which expect the input to always be an object.
* This function replaces empty string inputs with empty objects to prevent such errors.
*
* @param steps - The agent steps to fix
* @returns The fixed agent steps
*/
function fixEmptyContentMessage(steps: AgentFinish | AgentAction[]) {
if (!Array.isArray(steps)) return steps;
steps.forEach((step) => {
if ('messageLog' in step && step.messageLog !== undefined) {
if (Array.isArray(step.messageLog)) {
step.messageLog.forEach((message: BaseMessage) => {
if ('content' in message && Array.isArray(message.content)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(message.content as Array<{ input?: string | object }>).forEach((content) => {
if (content.input === '') {
content.input = {};
}
});
}
});
}
}
});
return steps;
}
export async function toolsAgentExecute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
this.logger.debug('Executing Tools Agent');
@ -156,6 +190,14 @@ export async function toolsAgentExecute(this: IExecuteFunctions): Promise<INodeE
// If the steps do not contain multiple outputs, return them as is
return agentFinishSteps;
}
// If memory is connected we need to stringify the returnValues so that it can be saved in the memory as a string
function handleParsedStepOutput(output: Record<string, unknown>) {
return {
returnValues: memory ? { output: JSON.stringify(output) } : output,
log: 'Final response formatted',
};
}
async function agentStepsParser(
steps: AgentFinish | AgentAction[],
): Promise<AgentFinish | AgentAction[]> {
@ -168,24 +210,18 @@ export async function toolsAgentExecute(this: IExecuteFunctions): Promise<INodeE
unknown
>;
return {
returnValues,
log: 'Final response formatted',
};
return handleParsedStepOutput(returnValues);
}
}
// If the steps are an AgentFinish and the outputParser is defined it must mean that the LLM didn't use `format_final_response` tool so we will parse the output manually
// If the steps are an AgentFinish and the outputParser is defined it must mean that the LLM didn't use `format_final_response` tool so we will try to parse the output manually
if (outputParser && typeof steps === 'object' && (steps as AgentFinish).returnValues) {
const finalResponse = (steps as AgentFinish).returnValues;
const returnValues = (await outputParser.parse(finalResponse as unknown as string)) as Record<
string,
unknown
>;
return {
returnValues,
log: 'Final response formatted',
};
return handleParsedStepOutput(returnValues);
}
return handleAgentFinishOutput(steps);
}
@ -233,7 +269,7 @@ export async function toolsAgentExecute(this: IExecuteFunctions): Promise<INodeE
});
agent.streamRunnable = false;
const runnableAgent = RunnableSequence.from([agent, agentStepsParser]);
const runnableAgent = RunnableSequence.from([agent, agentStepsParser, fixEmptyContentMessage]);
const executor = AgentExecutor.fromAgentAndTools({
agent: runnableAgent,
@ -273,6 +309,13 @@ export async function toolsAgentExecute(this: IExecuteFunctions): Promise<INodeE
'IMPORTANT: Always call `format_final_response` to format your final response!',
});
if (memory && outputParser) {
const parsedOutput = jsonParse<{ output: Record<string, unknown> }>(
response.output as string,
);
response.output = parsedOutput?.output ?? parsedOutput;
}
returnData.push({
json: omit(
response,

View file

@ -10,10 +10,21 @@ import {
import { RetrievalQAChain } from 'langchain/chains';
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
import type { BaseRetriever } from '@langchain/core/retrievers';
import {
ChatPromptTemplate,
SystemMessagePromptTemplate,
HumanMessagePromptTemplate,
PromptTemplate,
} from '@langchain/core/prompts';
import { getTemplateNoticeField } from '../../../utils/sharedFields';
import { getPromptInputByType } from '../../../utils/helpers';
import { getPromptInputByType, isChatInstance } from '../../../utils/helpers';
import { getTracingConfig } from '../../../utils/tracing';
const SYSTEM_PROMPT_TEMPLATE = `Use the following pieces of context to answer the users question.
If you don't know the answer, just say that you don't know, don't try to make up an answer.
----------------
{context}`;
export class ChainRetrievalQa implements INodeType {
description: INodeTypeDescription = {
displayName: 'Question and Answer Chain',
@ -137,6 +148,26 @@ export class ChainRetrievalQa implements INodeType {
},
},
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
default: {},
placeholder: 'Add Option',
options: [
{
displayName: 'System Prompt Template',
name: 'systemPromptTemplate',
type: 'string',
default: SYSTEM_PROMPT_TEMPLATE,
description:
'Template string used for the system prompt. This should include the variable `{context}` for the provided context. For text completion models, you should also include the variable `{question}` for the users query.',
typeOptions: {
rows: 6,
},
},
],
},
],
};
@ -154,7 +185,6 @@ export class ChainRetrievalQa implements INodeType {
)) as BaseRetriever;
const items = this.getInputData();
const chain = RetrievalQAChain.fromLLM(model, retriever);
const returnData: INodeExecutionData[] = [];
@ -178,6 +208,35 @@ export class ChainRetrievalQa implements INodeType {
throw new NodeOperationError(this.getNode(), 'The query parameter is empty.');
}
const options = this.getNodeParameter('options', itemIndex, {}) as {
systemPromptTemplate?: string;
};
const chainParameters = {} as {
prompt?: PromptTemplate | ChatPromptTemplate;
};
if (options.systemPromptTemplate !== undefined) {
if (isChatInstance(model)) {
const messages = [
SystemMessagePromptTemplate.fromTemplate(options.systemPromptTemplate),
HumanMessagePromptTemplate.fromTemplate('{question}'),
];
const chatPromptTemplate = ChatPromptTemplate.fromMessages(messages);
chainParameters.prompt = chatPromptTemplate;
} else {
const completionPromptTemplate = new PromptTemplate({
template: options.systemPromptTemplate,
inputVariables: ['context', 'question'],
});
chainParameters.prompt = completionPromptTemplate;
}
}
const chain = RetrievalQAChain.fromLLM(model, retriever, chainParameters);
const response = await chain.withConfig(getTracingConfig(this)).invoke({ query });
returnData.push({ json: { response } });
} catch (error) {

View file

@ -262,7 +262,7 @@ export class InformationExtractor implements INodeType {
}
const zodSchemaSandbox = getSandboxWithZod(this, jsonSchema, 0);
const zodSchema = (await zodSchemaSandbox.runCode()) as z.ZodSchema<object>;
const zodSchema = await zodSchemaSandbox.runCode<z.ZodSchema<object>>();
parser = OutputFixingParser.fromLLM(llm, StructuredOutputParser.fromZodSchema(zodSchema));
}

View file

@ -1,3 +1,8 @@
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
import { HumanMessage } from '@langchain/core/messages';
import { SystemMessagePromptTemplate, ChatPromptTemplate } from '@langchain/core/prompts';
import { OutputFixingParser, StructuredOutputParser } from 'langchain/output_parsers';
import { NodeOperationError, NodeConnectionType } from 'n8n-workflow';
import type {
IDataObject,
IExecuteFunctions,
@ -6,14 +11,8 @@ import type {
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow';
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
import { HumanMessage } from '@langchain/core/messages';
import { SystemMessagePromptTemplate, ChatPromptTemplate } from '@langchain/core/prompts';
import { OutputFixingParser, StructuredOutputParser } from 'langchain/output_parsers';
import { z } from 'zod';
import { getTracingConfig } from '../../../utils/tracing';
const SYSTEM_PROMPT_TEMPLATE =
@ -172,11 +171,15 @@ export class TextClassifier implements INodeType {
0,
)) as BaseLanguageModel;
const categories = this.getNodeParameter('categories.categories', 0) as Array<{
const categories = this.getNodeParameter('categories.categories', 0, []) as Array<{
category: string;
description: string;
}>;
if (categories.length === 0) {
throw new NodeOperationError(this.getNode(), 'At least one category must be defined');
}
const options = this.getNodeParameter('options', 0, {}) as {
multiClass: boolean;
fallback?: string;
@ -229,6 +232,7 @@ export class TextClassifier implements INodeType {
const systemPromptTemplateOpt = this.getNodeParameter(
'options.systemPromptTemplate',
itemIdx,
SYSTEM_PROMPT_TEMPLATE,
) as string;
const systemPromptTemplate = SystemMessagePromptTemplate.fromTemplate(
`${systemPromptTemplateOpt ?? SYSTEM_PROMPT_TEMPLATE}

View file

@ -107,7 +107,7 @@ function getSandbox(
}
// eslint-disable-next-line @typescript-eslint/unbound-method
const sandbox = new JavaScriptSandbox(context, code, itemIndex, this.helpers, {
const sandbox = new JavaScriptSandbox(context, code, this.helpers, {
resolver: vmResolver,
});
@ -368,7 +368,7 @@ export class Code implements INodeType {
}
const sandbox = getSandbox.call(this, code.supplyData.code, { itemIndex });
const response = (await sandbox.runCode()) as Tool;
const response = await sandbox.runCode<Tool>();
return {
response: logWrapper(response, this),

View file

@ -109,17 +109,22 @@ export class DocumentGithubLoader implements INodeType {
0,
)) as CharacterTextSplitter | undefined;
const { index } = this.addInputData(NodeConnectionType.AiDocument, [
[{ json: { repository, branch, ignorePaths, recursive } }],
]);
const docs = new GithubRepoLoader(repository, {
branch,
ignorePaths: (ignorePaths ?? '').split(',').map((p) => p.trim()),
recursive,
accessToken: (credentials.accessToken as string) || '',
apiUrl: credentials.server as string,
});
const loadedDocs = textSplitter
? await textSplitter.splitDocuments(await docs.load())
: await docs.load();
this.addOutputData(NodeConnectionType.AiDocument, index, [[{ json: { loadedDocs } }]]);
return {
response: logWrapper(loadedDocs, this),
};

View file

@ -48,7 +48,7 @@ export class N8nStructuredOutputParser<T extends z.ZodTypeAny> extends Structure
sandboxedSchema: JavaScriptSandbox,
nodeVersion: number,
): Promise<StructuredOutputParser<z.ZodType<object, z.ZodTypeDef, object>>> {
const zodSchema = (await sandboxedSchema.runCode()) as z.ZodSchema<object>;
const zodSchema = await sandboxedSchema.runCode<z.ZodSchema<object>>();
let returnSchema: z.ZodSchema<object>;
if (nodeVersion === 1) {

View file

@ -199,9 +199,9 @@ export class ToolCode implements INodeType {
let sandbox: Sandbox;
if (language === 'javaScript') {
sandbox = new JavaScriptSandbox(context, code, index, this.helpers);
sandbox = new JavaScriptSandbox(context, code, this.helpers);
} else {
sandbox = new PythonSandbox(context, code, index, this.helpers);
sandbox = new PythonSandbox(context, code, this.helpers);
}
sandbox.on(
@ -216,7 +216,7 @@ export class ToolCode implements INodeType {
const runFunction = async (query: string | IDataObject): Promise<string> => {
const sandbox = getSandbox(query, itemIndex);
return await (sandbox.runCode() as Promise<string>);
return await sandbox.runCode<string>();
};
const toolHandler = async (query: string | IDataObject): Promise<string> => {
@ -274,7 +274,7 @@ export class ToolCode implements INodeType {
: jsonParse<JSONSchema7>(inputSchema);
const zodSchemaSandbox = getSandboxWithZod(this, jsonSchema, 0);
const zodSchema = (await zodSchemaSandbox.runCode()) as DynamicZodObject;
const zodSchema = await zodSchemaSandbox.runCode<DynamicZodObject>();
tool = new DynamicStructuredTool<typeof zodSchema>({
schema: zodSchema,

View file

@ -275,7 +275,11 @@ export class ToolHttpRequest implements INodeType {
method: this.getNodeParameter('method', itemIndex, 'GET') as IHttpRequestMethods,
url: this.getNodeParameter('url', itemIndex) as string,
qs: {},
headers: {},
headers: {
// FIXME: This is a workaround to prevent the node from sending a default User-Agent (`n8n`) when the header is not set.
// Needs to be replaced with a proper fix after NODE-1777 is resolved
'User-Agent': undefined,
},
body: {},
};

View file

@ -530,7 +530,7 @@ export class ToolWorkflow implements INodeType {
: jsonParse<JSONSchema7>(inputSchema);
const zodSchemaSandbox = getSandboxWithZod(this, jsonSchema, 0);
const zodSchema = (await zodSchemaSandbox.runCode()) as DynamicZodObject;
const zodSchema = await zodSchemaSandbox.runCode<DynamicZodObject>();
tool = new DynamicStructuredTool<typeof zodSchema>({
schema: zodSchema,

View file

@ -1,4 +1,6 @@
import { Node, NodeConnectionType } from 'n8n-workflow';
import type { BaseChatMemory } from '@langchain/community/memory/chat_memory';
import { pick } from 'lodash';
import { Node, NodeConnectionType, commonCORSParameters } from 'n8n-workflow';
import type {
IDataObject,
IWebhookFunctions,
@ -10,10 +12,8 @@ import type {
INodeProperties,
} from 'n8n-workflow';
import { pick } from 'lodash';
import type { BaseChatMemory } from '@langchain/community/memory/chat_memory';
import { createPage } from './templates';
import { validateAuth } from './GenericFunctions';
import { createPage } from './templates';
import type { LoadPreviousSessionChatOption } from './types';
const CHAT_TRIGGER_PATH_IDENTIFIER = 'chat';
@ -56,7 +56,6 @@ export class ChatTrigger extends Node {
],
},
},
supportsCORS: true,
maxNodes: 1,
inputs: `={{ (() => {
if (!['hostedChat', 'webhook'].includes($parameter.mode)) {
@ -241,6 +240,15 @@ export class ChatTrigger extends Node {
placeholder: 'Add Field',
default: {},
options: [
// CORS parameters are only valid for when chat is used in hosted or webhook mode
...commonCORSParameters.map((p) => ({
...p,
displayOptions: {
show: {
'/mode': ['hostedChat', 'webhook'],
},
},
})),
{
...allowFileUploadsOption,
displayOptions: {

View file

@ -1,14 +1,16 @@
import { type INodeProperties } from 'n8n-workflow';
import {
PGVectorStore,
type DistanceStrategy,
type PGVectorStoreArgs,
} from '@langchain/community/vectorstores/pgvector';
import { configurePostgres } from 'n8n-nodes-base/dist/nodes/Postgres/v2/transport';
import type { EmbeddingsInterface } from '@langchain/core/embeddings';
import type { PostgresNodeCredentials } from 'n8n-nodes-base/dist/nodes/Postgres/v2/helpers/interfaces';
import { configurePostgres } from 'n8n-nodes-base/dist/nodes/Postgres/v2/transport';
import type { INodeProperties } from 'n8n-workflow';
import type pg from 'pg';
import { createVectorStoreNode } from '../shared/createVectorStoreNode';
import { metadataFilterField } from '../../../utils/sharedFields';
import { createVectorStoreNode } from '../shared/createVectorStoreNode';
type CollectionOptions = {
useCollection?: boolean;
@ -177,13 +179,46 @@ const retrieveFields: INodeProperties[] = [
},
];
/**
* Extended PGVectorStore class to handle custom filtering.
* This wrapper is necessary because when used as a retriever,
* similaritySearchVectorWithScore should use this.filter instead of
* expecting it from the parameter
*/
class ExtendedPGVectorStore extends PGVectorStore {
static async initialize(
embeddings: EmbeddingsInterface,
args: PGVectorStoreArgs & { dimensions?: number },
): Promise<ExtendedPGVectorStore> {
const { dimensions, ...rest } = args;
const postgresqlVectorStore = new this(embeddings, rest);
await postgresqlVectorStore._initializeClient();
await postgresqlVectorStore.ensureTableInDatabase(dimensions);
if (postgresqlVectorStore.collectionTableName) {
await postgresqlVectorStore.ensureCollectionTableInDatabase();
}
return postgresqlVectorStore;
}
async similaritySearchVectorWithScore(
query: number[],
k: number,
filter?: PGVectorStore['FilterType'],
) {
const mergedFilter = { ...this.filter, ...filter };
return await super.similaritySearchVectorWithScore(query, k, mergedFilter);
}
}
export const VectorStorePGVector = createVectorStoreNode({
meta: {
description: 'Work with your data in Postgresql with the PGVector extension',
icon: 'file:postgres.svg',
displayName: 'Postgres PGVector Store',
docsUrl:
'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.vectorstoresupabase/',
'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.vectorstorepgvector/',
name: 'vectorStorePGVector',
credentials: [
{
@ -236,7 +271,7 @@ export const VectorStorePGVector = createVectorStoreNode({
'cosine',
) as DistanceStrategy;
return await PGVectorStore.initialize(embeddings, config);
return await ExtendedPGVectorStore.initialize(embeddings, config);
},
async populateVectorStore(context, embeddings, documents, itemIndex) {
// NOTE: if you are to create the HNSW index before use, you need to consider moving the distanceStrategy field to

View file

@ -88,25 +88,25 @@ function getOperationModeOptions(args: VectorStoreNodeConstructorArgs): INodePro
name: 'Get Many',
value: 'load',
description: 'Get many ranked documents from vector store for query',
action: 'Get many ranked documents from vector store for query',
action: 'Get ranked documents from vector store',
},
{
name: 'Insert Documents',
value: 'insert',
description: 'Insert documents into vector store',
action: 'Insert documents into vector store',
action: 'Add documents to vector store',
},
{
name: 'Retrieve Documents (For Agent/Chain)',
value: 'retrieve',
description: 'Retrieve documents from vector store to be used with AI nodes',
action: 'Retrieve documents from vector store to be used with AI nodes',
action: 'Retrieve documents for AI processing',
},
{
name: 'Update Documents',
value: 'update',
description: 'Update documents in vector store by ID',
action: 'Update documents in vector store by ID',
action: 'Update vector store documents',
},
];

View file

@ -1,30 +1,27 @@
import type { BaseMessage } from '@langchain/core/messages';
import { AgentExecutor } from 'langchain/agents';
import { OpenAIAssistantRunnable } from 'langchain/experimental/openai_assistant';
import type { OpenAIToolType } from 'langchain/dist/experimental/openai_assistant/schema';
import { OpenAI as OpenAIClient } from 'openai';
import {
ApplicationError,
NodeConnectionType,
NodeOperationError,
updateDisplayOptions,
} from 'n8n-workflow';
import { OpenAIAssistantRunnable } from 'langchain/experimental/openai_assistant';
import type { BufferWindowMemory } from 'langchain/memory';
import omit from 'lodash/omit';
import type {
IDataObject,
IExecuteFunctions,
INodeExecutionData,
INodeProperties,
} from 'n8n-workflow';
import type { BufferWindowMemory } from 'langchain/memory';
import omit from 'lodash/omit';
import type { BaseMessage } from '@langchain/core/messages';
import { formatToOpenAIAssistantTool } from '../../helpers/utils';
import { assistantRLC } from '../descriptions';
import {
ApplicationError,
NodeConnectionType,
NodeOperationError,
updateDisplayOptions,
} from 'n8n-workflow';
import { OpenAI as OpenAIClient } from 'openai';
import { getConnectedTools } from '../../../../../utils/helpers';
import { getTracingConfig } from '../../../../../utils/tracing';
import { formatToOpenAIAssistantTool } from '../../helpers/utils';
import { assistantRLC } from '../descriptions';
const properties: INodeProperties[] = [
assistantRLC,
@ -63,6 +60,46 @@ const properties: INodeProperties[] = [
},
},
},
{
displayName: 'Memory',
name: 'memory',
type: 'options',
options: [
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'Use memory connector',
value: 'connector',
description: 'Connect one of the supported memory nodes',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'Use thread ID',
value: 'threadId',
description: 'Specify the ID of the thread to continue',
},
],
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.6 } }],
},
},
default: 'connector',
},
{
displayName: 'Thread ID',
name: 'threadId',
type: 'string',
default: '',
placeholder: '',
description: 'The ID of the thread to continue, a new thread will be created if not specified',
hint: 'If the thread ID is empty or undefined a new thread will be created and included in the response',
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.6 } }],
memory: ['threadId'],
},
},
},
{
displayName: 'Connect your own custom n8n tools to this node on the canvas',
name: 'noticeTools',
@ -201,9 +238,19 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
tools: tools ?? [],
});
const memory = (await this.getInputConnectionData(NodeConnectionType.AiMemory, 0)) as
| BufferWindowMemory
| undefined;
const useMemoryConnector =
nodeVersion >= 1.6 && this.getNodeParameter('memory', i) === 'connector';
const memory =
useMemoryConnector || nodeVersion < 1.6
? ((await this.getInputConnectionData(NodeConnectionType.AiMemory, 0)) as
| BufferWindowMemory
| undefined)
: undefined;
const threadId =
nodeVersion >= 1.6 && !useMemoryConnector
? (this.getNodeParameter('threadId', i) as string)
: undefined;
const chainValues: IDataObject = {
content: input,
@ -231,6 +278,8 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
chainValues.threadId = thread.id;
}
} else if (threadId) {
chainValues.threadId = threadId;
}
let filteredResponse: IDataObject = {};
@ -257,7 +306,8 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
tools: assistantTools,
});
}
filteredResponse = omit(response, ['signal', 'timeout']) as IDataObject;
// Remove configuration properties and runId added by Langchain that are not relevant to the user
filteredResponse = omit(response, ['signal', 'timeout', 'content', 'runId']) as IDataObject;
} catch (error) {
if (!(error instanceof ApplicationError)) {
throw new NodeOperationError(this.getNode(), error.message, { itemIndex: i });

View file

@ -278,7 +278,7 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
for (const tool of externalTools ?? []) {
if (tool.name === functionName) {
const parsedArgs: { input: string } = jsonParse(functionArgs);
const functionInput = parsedArgs.input ?? functionArgs;
const functionInput = parsedArgs.input ?? parsedArgs ?? functionArgs;
functionResponse = await tool.invoke(functionInput);
}
}

View file

@ -1,5 +1,5 @@
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
import type { INodeTypeDescription } from 'n8n-workflow';
import type { INodeInputConfiguration, INodeTypeDescription } from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow';
import * as assistant from './assistant';
@ -42,13 +42,21 @@ const prettifyOperation = (resource: string, operation: string) => {
return `${capitalize(operation)} ${capitalize(resource)}`;
};
const configureNodeInputs = (resource: string, operation: string, hideTools: string) => {
const configureNodeInputs = (
resource: string,
operation: string,
hideTools: string,
memory: string | undefined,
) => {
if (resource === 'assistant' && operation === 'message') {
return [
const inputs: INodeInputConfiguration[] = [
{ type: NodeConnectionType.Main },
{ type: NodeConnectionType.AiMemory, displayName: 'Memory', maxConnections: 1 },
{ type: NodeConnectionType.AiTool, displayName: 'Tools' },
];
if (memory !== 'threadId') {
inputs.push({ type: NodeConnectionType.AiMemory, displayName: 'Memory', maxConnections: 1 });
}
return inputs;
}
if (resource === 'text' && operation === 'message') {
if (hideTools === 'hide') {
@ -69,7 +77,7 @@ export const versionDescription: INodeTypeDescription = {
name: 'openAi',
icon: { light: 'file:openAi.svg', dark: 'file:openAi.dark.svg' },
group: ['transform'],
version: [1, 1.1, 1.2, 1.3, 1.4, 1.5],
version: [1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6],
subtitle: `={{(${prettifyOperation})($parameter.resource, $parameter.operation)}}`,
description: 'Message an assistant or GPT, analyze images, generate audio, etc.',
defaults: {
@ -89,7 +97,7 @@ export const versionDescription: INodeTypeDescription = {
],
},
},
inputs: `={{(${configureNodeInputs})($parameter.resource, $parameter.operation, $parameter.hideTools)}}`,
inputs: `={{(${configureNodeInputs})($parameter.resource, $parameter.operation, $parameter.hideTools, $parameter.memory ?? undefined)}}`,
outputs: [NodeConnectionType.Main],
credentials: [
{

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/n8n-nodes-langchain",
"version": "1.61.0",
"version": "1.63.0",
"description": "",
"main": "index.js",
"scripts": {
@ -124,18 +124,20 @@
"@types/cheerio": "^0.22.15",
"@types/html-to-text": "^9.0.1",
"@types/json-schema": "^7.0.15",
"@types/pg": "^8.11.6",
"@types/temp": "^0.9.1",
"n8n-core": "workspace:*"
},
"dependencies": {
"@getzep/zep-cloud": "1.0.11",
"@aws-sdk/client-sso-oidc": "3.666.0",
"@getzep/zep-cloud": "1.0.12",
"@getzep/zep-js": "0.9.0",
"@google-ai/generativelanguage": "2.6.0",
"@google-cloud/resource-manager": "5.3.0",
"@google/generative-ai": "0.19.0",
"@huggingface/inference": "2.8.0",
"@langchain/anthropic": "0.3.1",
"@langchain/aws": "^0.1.0",
"@langchain/aws": "0.1.0",
"@langchain/cohere": "0.3.0",
"@langchain/community": "0.3.2",
"@langchain/core": "catalog:",
@ -149,23 +151,22 @@
"@langchain/qdrant": "0.1.0",
"@langchain/redis": "0.1.0",
"@langchain/textsplitters": "0.1.0",
"@mozilla/readability": "^0.5.0",
"@n8n/typeorm": "0.3.20-10",
"@mozilla/readability": "0.5.0",
"@n8n/typeorm": "0.3.20-12",
"@n8n/vm2": "3.9.25",
"@pinecone-database/pinecone": "3.0.3",
"@qdrant/js-client-rest": "1.11.0",
"@supabase/supabase-js": "2.45.4",
"@types/pg": "^8.11.6",
"@xata.io/client": "0.30.0",
"@xata.io/client": "0.28.4",
"basic-auth": "catalog:",
"cheerio": "1.0.0-rc.12",
"cheerio": "1.0.0",
"cohere-ai": "7.13.2",
"d3-dsv": "2.0.0",
"epub2": "3.0.2",
"form-data": "catalog:",
"generate-schema": "2.6.0",
"html-to-text": "9.0.5",
"jsdom": "^23.0.1",
"jsdom": "23.0.1",
"json-schema-to-zod": "2.1.0",
"langchain": "0.3.2",
"lodash": "catalog:",

View file

@ -57,7 +57,6 @@ export function getSandboxWithZod(ctx: IExecuteFunctions, schema: JSONSchema7, i
const itemSchema = new Function('z', 'return (' + zodSchema + ')')(z)
return itemSchema
`,
itemIndex,
ctx.helpers,
{ resolver: vmResolver },
);

View file

@ -4,18 +4,18 @@
"version": "0.0.1",
"devDependencies": {
"@chromatic-com/storybook": "^2.0.2",
"@storybook/addon-a11y": "^8.3.1",
"@storybook/addon-actions": "^8.3.1",
"@storybook/addon-docs": "^8.3.1",
"@storybook/addon-essentials": "^8.3.1",
"@storybook/addon-interactions": "^8.3.1",
"@storybook/addon-links": "^8.3.1",
"@storybook/addon-themes": "^8.3.1",
"@storybook/blocks": "^8.3.1",
"@storybook/test": "^8.3.1",
"@storybook/vue3": "^8.3.1",
"@storybook/vue3-vite": "^8.3.1",
"@storybook/addon-a11y": "^8.3.5",
"@storybook/addon-actions": "^8.3.5",
"@storybook/addon-docs": "^8.3.5",
"@storybook/addon-essentials": "^8.3.5",
"@storybook/addon-interactions": "^8.3.5",
"@storybook/addon-links": "^8.3.5",
"@storybook/addon-themes": "^8.3.5",
"@storybook/blocks": "^8.3.5",
"@storybook/test": "^8.3.5",
"@storybook/vue3": "^8.3.5",
"@storybook/vue3-vite": "^8.3.5",
"chromatic": "^11.10.2",
"storybook": "^8.3.1"
"storybook": "^8.3.5"
}
}

View file

@ -0,0 +1,19 @@
const sharedOptions = require('@n8n_io/eslint-config/shared');
/**
* @type {import('@types/eslint').ESLint.ConfigData}
*/
module.exports = {
extends: ['@n8n_io/eslint-config/node'],
...sharedOptions(__dirname),
ignorePatterns: ['jest.config.js'],
rules: {
'unicorn/filename-case': ['error', { case: 'kebabCase' }],
'@typescript-eslint/no-duplicate-imports': 'off',
complexity: 'error',
},
};

View file

@ -0,0 +1,5 @@
/** @type {import('jest').Config} */
module.exports = {
...require('../../../jest.config'),
testTimeout: 10_000,
};

View file

@ -0,0 +1,33 @@
{
"name": "@n8n/task-runner",
"version": "1.1.0",
"scripts": {
"clean": "rimraf dist .turbo",
"start": "node dist/start.js",
"dev": "pnpm build && pnpm start",
"typecheck": "tsc --noEmit",
"build": "tsc -p ./tsconfig.build.json && tsc-alias -p tsconfig.build.json",
"format": "biome format --write src",
"format:check": "biome ci src",
"test": "jest",
"test:watch": "jest --watch",
"lint": "eslint . --quiet",
"lintfix": "eslint . --fix",
"watch": "concurrently \"tsc -w -p tsconfig.build.json\" \"tsc-alias -w -p tsconfig.build.json\""
},
"main": "dist/start.js",
"module": "src/start.ts",
"types": "dist/start.d.ts",
"files": [
"dist/**/*"
],
"dependencies": {
"n8n-workflow": "workspace:*",
"n8n-core": "workspace:*",
"nanoid": "^3.3.6",
"ws": "^8.18.0"
},
"devDependencies": {
"luxon": "catalog:"
}
}

View file

@ -0,0 +1,47 @@
import { ApplicationError } from 'n8n-workflow';
import * as a from 'node:assert/strict';
export type AuthOpts = {
n8nUri: string;
authToken: string;
};
/**
* Requests a one-time token that can be used to establish a task runner connection
*/
export async function authenticate(opts: AuthOpts) {
try {
const authEndpoint = `http://${opts.n8nUri}/runners/auth`;
const response = await fetch(authEndpoint, {
method: 'POST',
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Content-Type': 'application/json',
},
body: JSON.stringify({
token: opts.authToken,
}),
});
if (!response.ok) {
throw new ApplicationError(
`Invalid response status ${response.status}: ${await response.text()}`,
);
}
const { data } = (await response.json()) as { data: { token: string } };
const grantToken = data.token;
a.ok(grantToken);
return grantToken;
} catch (e) {
console.error(e);
const error = e as Error;
throw new ApplicationError(
`Could not connect to n8n message broker ${opts.n8nUri}: ${error.message}`,
{
cause: error,
},
);
}
}

View file

@ -0,0 +1,2 @@
export * from './task-runner';
export * from './runner-types';

View file

@ -0,0 +1,762 @@
import { DateTime } from 'luxon';
import type { CodeExecutionMode, IDataObject } from 'n8n-workflow';
import fs from 'node:fs';
import { builtinModules } from 'node:module';
import { ValidationError } from '@/js-task-runner/errors/validation-error';
import type { JsTaskRunnerOpts } from '@/js-task-runner/js-task-runner';
import {
JsTaskRunner,
type AllCodeTaskData,
type JSExecSettings,
} from '@/js-task-runner/js-task-runner';
import type { Task } from '@/task-runner';
import { newAllCodeTaskData, newTaskWithSettings, withPairedItem, wrapIntoJson } from './test-data';
import { ExecutionError } from '../errors/execution-error';
jest.mock('ws');
describe('JsTaskRunner', () => {
const createRunnerWithOpts = (opts: Partial<JsTaskRunnerOpts> = {}) =>
new JsTaskRunner({
wsUrl: 'ws://localhost',
grantToken: 'grantToken',
maxConcurrency: 1,
...opts,
});
const defaultTaskRunner = createRunnerWithOpts();
const execTaskWithParams = async ({
task,
taskData,
runner = defaultTaskRunner,
}: {
task: Task<JSExecSettings>;
taskData: AllCodeTaskData;
runner?: JsTaskRunner;
}) => {
jest.spyOn(runner, 'requestData').mockResolvedValue(taskData);
return await runner.executeTask(task);
};
afterEach(() => {
jest.restoreAllMocks();
});
const executeForAllItems = async ({
code,
inputItems,
settings,
runner,
}: {
code: string;
inputItems: IDataObject[];
settings?: Partial<JSExecSettings>;
runner?: JsTaskRunner;
}) => {
return await execTaskWithParams({
task: newTaskWithSettings({
code,
nodeMode: 'runOnceForAllItems',
...settings,
}),
taskData: newAllCodeTaskData(inputItems.map(wrapIntoJson)),
runner,
});
};
const executeForEachItem = async ({
code,
inputItems,
settings,
runner,
}: {
code: string;
inputItems: IDataObject[];
settings?: Partial<JSExecSettings>;
runner?: JsTaskRunner;
}) => {
return await execTaskWithParams({
task: newTaskWithSettings({
code,
nodeMode: 'runOnceForEachItem',
...settings,
}),
taskData: newAllCodeTaskData(inputItems.map(wrapIntoJson)),
runner,
});
};
describe('console', () => {
test.each<[CodeExecutionMode]>([['runOnceForAllItems'], ['runOnceForEachItem']])(
'should make an rpc call for console log in %s mode',
async (nodeMode) => {
jest.spyOn(defaultTaskRunner, 'makeRpcCall').mockResolvedValue(undefined);
const task = newTaskWithSettings({
code: "console.log('Hello', 'world!'); return {}",
nodeMode,
});
await execTaskWithParams({
task,
taskData: newAllCodeTaskData([wrapIntoJson({})]),
});
expect(defaultTaskRunner.makeRpcCall).toHaveBeenCalledWith(task.taskId, 'logNodeOutput', [
'Hello world!',
]);
},
);
});
describe('built-in methods and variables available in the context', () => {
const inputItems = [{ a: 1 }];
const testExpressionForAllItems = async (
expression: string,
expected: IDataObject | string | number | boolean,
) => {
const needsWrapping = typeof expected !== 'object';
const outcome = await executeForAllItems({
code: needsWrapping ? `return { val: ${expression} }` : `return ${expression}`,
inputItems,
});
expect(outcome.result).toEqual([wrapIntoJson(needsWrapping ? { val: expected } : expected)]);
};
const testExpressionForEachItem = async (
expression: string,
expected: IDataObject | string | number | boolean,
) => {
const needsWrapping = typeof expected !== 'object';
const outcome = await executeForEachItem({
code: needsWrapping ? `return { val: ${expression} }` : `return ${expression}`,
inputItems,
});
expect(outcome.result).toEqual([
withPairedItem(0, wrapIntoJson(needsWrapping ? { val: expected } : expected)),
]);
};
const testGroups = {
// https://docs.n8n.io/code/builtin/current-node-input/
'current node input': [
['$input.first()', inputItems[0]],
['$input.last()', inputItems[inputItems.length - 1]],
['$input.params', { manualTriggerParam: 'empty' }],
],
// https://docs.n8n.io/code/builtin/output-other-nodes/
'output of other nodes': [
['$("Trigger").first()', inputItems[0]],
['$("Trigger").last()', inputItems[inputItems.length - 1]],
['$("Trigger").params', { manualTriggerParam: 'empty' }],
],
// https://docs.n8n.io/code/builtin/date-time/
'date and time': [
['$now', expect.any(DateTime)],
['$today', expect.any(DateTime)],
['{dt: DateTime}', { dt: expect.any(Function) }],
],
// https://docs.n8n.io/code/builtin/jmespath/
JMESPath: [['{ val: $jmespath([{ f: 1 },{ f: 2 }], "[*].f") }', { val: [1, 2] }]],
// https://docs.n8n.io/code/builtin/n8n-metadata/
'n8n metadata': [
[
'$execution',
{
id: 'exec-id',
mode: 'test',
resumeFormUrl: 'http://formWaitingBaseUrl/exec-id',
resumeUrl: 'http://webhookWaitingBaseUrl/exec-id',
customData: {
get: expect.any(Function),
getAll: expect.any(Function),
set: expect.any(Function),
setAll: expect.any(Function),
},
},
],
['$("Trigger").isExecuted', true],
['$nodeVersion', 2],
['$prevNode.name', 'Trigger'],
['$prevNode.outputIndex', 0],
['$runIndex', 0],
['{ wf: $workflow }', { wf: { active: true, id: '1', name: 'Test Workflow' } }],
['$vars', { var: 'value' }],
],
};
for (const [groupName, tests] of Object.entries(testGroups)) {
describe(`${groupName} runOnceForAllItems`, () => {
test.each(tests)(
'should have the %s available in the context',
async (expression, expected) => {
await testExpressionForAllItems(expression, expected);
},
);
});
describe(`${groupName} runOnceForEachItem`, () => {
test.each(tests)(
'should have the %s available in the context',
async (expression, expected) => {
await testExpressionForEachItem(expression, expected);
},
);
});
}
describe('$env', () => {
it('should have the env available in context when access has not been blocked', async () => {
const outcome = await execTaskWithParams({
task: newTaskWithSettings({
code: 'return { val: $env.VAR1 }',
nodeMode: 'runOnceForAllItems',
}),
taskData: newAllCodeTaskData(inputItems.map(wrapIntoJson), {
envProviderState: {
isEnvAccessBlocked: false,
isProcessAvailable: true,
env: { VAR1: 'value' },
},
}),
});
expect(outcome.result).toEqual([wrapIntoJson({ val: 'value' })]);
});
it('should be possible to access env if it has been blocked', async () => {
await expect(
execTaskWithParams({
task: newTaskWithSettings({
code: 'return { val: $env.VAR1 }',
nodeMode: 'runOnceForAllItems',
}),
taskData: newAllCodeTaskData(inputItems.map(wrapIntoJson), {
envProviderState: {
isEnvAccessBlocked: true,
isProcessAvailable: true,
env: { VAR1: 'value' },
},
}),
}),
).rejects.toThrow('access to env vars denied');
});
it('should not be possible to iterate $env', async () => {
const outcome = await execTaskWithParams({
task: newTaskWithSettings({
code: 'return Object.values($env).concat(Object.keys($env))',
nodeMode: 'runOnceForAllItems',
}),
taskData: newAllCodeTaskData(inputItems.map(wrapIntoJson), {
envProviderState: {
isEnvAccessBlocked: false,
isProcessAvailable: true,
env: { VAR1: '1', VAR2: '2', VAR3: '3' },
},
}),
});
expect(outcome.result).toEqual([]);
});
it("should not expose task runner's env variables even if no env state is received", async () => {
process.env.N8N_RUNNERS_N8N_URI = 'http://127.0.0.1:5679';
const outcome = await execTaskWithParams({
task: newTaskWithSettings({
code: 'return { val: $env.N8N_RUNNERS_N8N_URI }',
nodeMode: 'runOnceForAllItems',
}),
taskData: newAllCodeTaskData(inputItems.map(wrapIntoJson), {
envProviderState: undefined,
}),
});
expect(outcome.result).toEqual([wrapIntoJson({ val: undefined })]);
});
});
});
describe('runOnceForAllItems', () => {
describe('continue on fail', () => {
it('should return an item with the error if continueOnFail is true', async () => {
const outcome = await executeForAllItems({
code: 'throw new Error("Error message")',
inputItems: [{ a: 1 }],
settings: { continueOnFail: true },
});
expect(outcome).toEqual({
result: [wrapIntoJson({ error: 'Error message [line 1]' })],
customData: undefined,
});
});
it('should throw an error if continueOnFail is false', async () => {
await expect(
executeForAllItems({
code: 'throw new Error("Error message")',
inputItems: [{ a: 1 }],
settings: { continueOnFail: false },
}),
).rejects.toThrow('Error message');
});
});
describe('invalid output', () => {
test.each([['undefined'], ['42'], ['"a string"']])(
'should throw a ValidationError if the code output is %s',
async (output) => {
await expect(
executeForAllItems({
code: `return ${output}`,
inputItems: [{ a: 1 }],
}),
).rejects.toThrow(ValidationError);
},
);
it('should throw a ValidationError if some items are wrapped in json and some are not', async () => {
await expect(
executeForAllItems({
code: 'return [{b: 1}, {json: {b: 2}}]',
inputItems: [{ a: 1 }],
}),
).rejects.toThrow(ValidationError);
});
});
it('should return static items', async () => {
const outcome = await executeForAllItems({
code: 'return [{json: {b: 1}}]',
inputItems: [{ a: 1 }],
});
expect(outcome).toEqual({
result: [wrapIntoJson({ b: 1 })],
customData: undefined,
});
});
it('maps null into an empty array', async () => {
const outcome = await executeForAllItems({
code: 'return null',
inputItems: [{ a: 1 }],
});
expect(outcome).toEqual({
result: [],
customData: undefined,
});
});
it("should wrap items into json if they aren't", async () => {
const outcome = await executeForAllItems({
code: 'return [{b: 1}]',
inputItems: [{ a: 1 }],
});
expect(outcome).toEqual({
result: [wrapIntoJson({ b: 1 })],
customData: undefined,
});
});
it('should wrap single item into an array and json', async () => {
const outcome = await executeForAllItems({
code: 'return {b: 1}',
inputItems: [{ a: 1 }],
});
expect(outcome).toEqual({
result: [wrapIntoJson({ b: 1 })],
customData: undefined,
});
});
test.each([['items'], ['$input.all()'], ["$('Trigger').all()"]])(
'should have all input items in the context as %s',
async (expression) => {
const outcome = await executeForAllItems({
code: `return ${expression}`,
inputItems: [{ a: 1 }, { a: 2 }],
});
expect(outcome).toEqual({
result: [wrapIntoJson({ a: 1 }), wrapIntoJson({ a: 2 })],
customData: undefined,
});
},
);
});
describe('runForEachItem', () => {
describe('continue on fail', () => {
it('should return an item with the error if continueOnFail is true', async () => {
const outcome = await executeForEachItem({
code: 'throw new Error("Error message")',
inputItems: [{ a: 1 }, { a: 2 }],
settings: { continueOnFail: true },
});
expect(outcome).toEqual({
result: [
withPairedItem(0, wrapIntoJson({ error: 'Error message [line 1]' })),
withPairedItem(1, wrapIntoJson({ error: 'Error message [line 1]' })),
],
customData: undefined,
});
});
it('should throw an error if continueOnFail is false', async () => {
await expect(
executeForEachItem({
code: 'throw new Error("Error message")',
inputItems: [{ a: 1 }],
settings: { continueOnFail: false },
}),
).rejects.toThrow('Error message');
});
});
describe('invalid output', () => {
test.each([['undefined'], ['42'], ['"a string"'], ['[]'], ['[1,2,3]']])(
'should throw a ValidationError if the code output is %s',
async (output) => {
await expect(
executeForEachItem({
code: `return ${output}`,
inputItems: [{ a: 1 }],
}),
).rejects.toThrow(ValidationError);
},
);
});
it('should return static items', async () => {
const outcome = await executeForEachItem({
code: 'return {json: {b: 1}}',
inputItems: [{ a: 1 }],
});
expect(outcome).toEqual({
result: [withPairedItem(0, wrapIntoJson({ b: 1 }))],
customData: undefined,
});
});
it('should filter out null values', async () => {
const outcome = await executeForEachItem({
code: 'return item.json.a === 1 ? item : null',
inputItems: [{ a: 1 }, { a: 2 }, { a: 3 }],
});
expect(outcome).toEqual({
result: [withPairedItem(0, wrapIntoJson({ a: 1 }))],
customData: undefined,
});
});
test.each([['item'], ['$input.item'], ['{ json: $json }']])(
'should have the current input item in the context as %s',
async (expression) => {
const outcome = await executeForEachItem({
code: `return ${expression}`,
inputItems: [{ a: 1 }, { a: 2 }],
});
expect(outcome).toEqual({
result: [
withPairedItem(0, wrapIntoJson({ a: 1 })),
withPairedItem(1, wrapIntoJson({ a: 2 })),
],
customData: undefined,
});
},
);
});
describe('require', () => {
const inputItems = [{ a: 1 }];
const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8'));
describe('blocked by default', () => {
const testCases = [...builtinModules, ...Object.keys(packageJson.dependencies)];
test.each(testCases)(
'should throw an error when requiring %s in runOnceForAllItems mode',
async (module) => {
await expect(
executeForAllItems({
code: `return require('${module}')`,
inputItems,
}),
).rejects.toThrow(`Cannot find module '${module}'`);
},
);
test.each(testCases)(
'should throw an error when requiring %s in runOnceForEachItem mode',
async (module) => {
await expect(
executeForEachItem({
code: `return require('${module}')`,
inputItems,
}),
).rejects.toThrow(`Cannot find module '${module}'`);
},
);
});
describe('all built-ins allowed with *', () => {
const testCases = builtinModules;
const runner = createRunnerWithOpts({
allowedBuiltInModules: '*',
});
test.each(testCases)(
'should be able to require %s in runOnceForAllItems mode',
async (module) => {
await expect(
executeForAllItems({
code: `return { val: require('${module}') }`,
inputItems,
runner,
}),
).resolves.toBeDefined();
},
);
test.each(testCases)(
'should be able to require %s in runOnceForEachItem mode',
async (module) => {
await expect(
executeForEachItem({
code: `return { val: require('${module}') }`,
inputItems,
runner,
}),
).resolves.toBeDefined();
},
);
});
describe('all external modules allowed with *', () => {
const testCases = Object.keys(packageJson.dependencies);
const runner = createRunnerWithOpts({
allowedExternalModules: '*',
});
test.each(testCases)(
'should be able to require %s in runOnceForAllItems mode',
async (module) => {
await expect(
executeForAllItems({
code: `return { val: require('${module}') }`,
inputItems,
runner,
}),
).resolves.toBeDefined();
},
);
test.each(testCases)(
'should be able to require %s in runOnceForEachItem mode',
async (module) => {
await expect(
executeForEachItem({
code: `return { val: require('${module}') }`,
inputItems,
runner,
}),
).resolves.toBeDefined();
},
);
});
describe('specifically allowed built-in modules', () => {
const runner = createRunnerWithOpts({
allowedBuiltInModules: 'crypto,path',
});
const allowedCases = [
['crypto', 'require("crypto").randomBytes(16).toString("hex")', expect.any(String)],
['path', 'require("path").normalize("/root/./dir")', '/root/dir'],
];
const blockedCases = [['http'], ['process']];
test.each(allowedCases)(
'should allow requiring %s in runOnceForAllItems mode',
async (_moduleName, expression, expected) => {
const outcome = await executeForAllItems({
code: `return { val: ${expression} }`,
inputItems,
runner,
});
expect(outcome.result).toEqual([wrapIntoJson({ val: expected })]);
},
);
test.each(allowedCases)(
'should allow requiring %s in runOnceForEachItem mode',
async (_moduleName, expression, expected) => {
const outcome = await executeForEachItem({
code: `return { val: ${expression} }`,
inputItems,
runner,
});
expect(outcome.result).toEqual([withPairedItem(0, wrapIntoJson({ val: expected }))]);
},
);
test.each(blockedCases)(
'should throw when trying to require %s in runOnceForAllItems mode',
async (moduleName) => {
await expect(
executeForAllItems({
code: `require("${moduleName}")`,
inputItems,
runner,
}),
).rejects.toThrow(`Cannot find module '${moduleName}'`);
},
);
test.each(blockedCases)(
'should throw when trying to require %s in runOnceForEachItem mode',
async (moduleName) => {
await expect(
executeForEachItem({
code: `require("${moduleName}")`,
inputItems,
runner,
}),
).rejects.toThrow(`Cannot find module '${moduleName}'`);
},
);
});
describe('specifically allowed external modules', () => {
const runner = createRunnerWithOpts({
allowedExternalModules: 'nanoid',
});
const allowedCases = [['nanoid', 'require("nanoid").nanoid()', expect.any(String)]];
const blockedCases = [['n8n-core']];
test.each(allowedCases)(
'should allow requiring %s in runOnceForAllItems mode',
async (_moduleName, expression, expected) => {
const outcome = await executeForAllItems({
code: `return { val: ${expression} }`,
inputItems,
runner,
});
expect(outcome.result).toEqual([wrapIntoJson({ val: expected })]);
},
);
test.each(allowedCases)(
'should allow requiring %s in runOnceForEachItem mode',
async (_moduleName, expression, expected) => {
const outcome = await executeForEachItem({
code: `return { val: ${expression} }`,
inputItems,
runner,
});
expect(outcome.result).toEqual([withPairedItem(0, wrapIntoJson({ val: expected }))]);
},
);
test.each(blockedCases)(
'should throw when trying to require %s in runOnceForAllItems mode',
async (moduleName) => {
await expect(
executeForAllItems({
code: `require("${moduleName}")`,
inputItems,
runner,
}),
).rejects.toThrow(`Cannot find module '${moduleName}'`);
},
);
test.each(blockedCases)(
'should throw when trying to require %s in runOnceForEachItem mode',
async (moduleName) => {
await expect(
executeForEachItem({
code: `require("${moduleName}")`,
inputItems,
runner,
}),
).rejects.toThrow(`Cannot find module '${moduleName}'`);
},
);
});
});
describe('errors', () => {
test.each<[CodeExecutionMode]>([['runOnceForAllItems'], ['runOnceForEachItem']])(
'should throw an ExecutionError if the code is invalid in %s mode',
async (nodeMode) => {
await expect(
execTaskWithParams({
task: newTaskWithSettings({
code: 'unknown',
nodeMode,
}),
taskData: newAllCodeTaskData([wrapIntoJson({ a: 1 })]),
}),
).rejects.toThrow(ExecutionError);
},
);
it('sends serializes an error correctly', async () => {
const runner = createRunnerWithOpts({});
const taskId = '1';
const task = newTaskWithSettings({
code: 'unknown; return []',
nodeMode: 'runOnceForAllItems',
continueOnFail: false,
mode: 'manual',
workflowMode: 'manual',
});
runner.runningTasks.set(taskId, task);
const sendSpy = jest.spyOn(runner.ws, 'send').mockImplementation(() => {});
jest.spyOn(runner, 'sendOffers').mockImplementation(() => {});
jest
.spyOn(runner, 'requestData')
.mockResolvedValue(newAllCodeTaskData([wrapIntoJson({ a: 1 })]));
await runner.receivedSettings(taskId, task.settings);
expect(sendSpy).toHaveBeenCalledWith(
JSON.stringify({
type: 'runner:taskerror',
taskId,
error: {
message: 'unknown is not defined [line 1]',
description: 'ReferenceError',
lineNumber: 1,
},
}),
);
console.log('DONE');
}, 1000);
});
});

View file

@ -0,0 +1,168 @@
import type { IDataObject, INode, INodeExecutionData, ITaskData } from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow';
import { nanoid } from 'nanoid';
import type { AllCodeTaskData, JSExecSettings } from '@/js-task-runner/js-task-runner';
import type { Task } from '@/task-runner';
/**
* Creates a new task with the given settings
*/
export const newTaskWithSettings = (
settings: Partial<JSExecSettings> & Pick<JSExecSettings, 'code' | 'nodeMode'>,
): Task<JSExecSettings> => ({
taskId: '1',
settings: {
workflowMode: 'manual',
continueOnFail: false,
mode: 'manual',
...settings,
},
active: true,
cancelled: false,
});
/**
* Creates a new node with the given options
*/
export const newNode = (opts: Partial<INode> = {}): INode => ({
id: nanoid(),
name: 'Test Node' + nanoid(),
parameters: {},
position: [0, 0],
type: 'n8n-nodes-base.code',
typeVersion: 1,
...opts,
});
/**
* Creates a new task data with the given options
*/
export const newTaskData = (opts: Partial<ITaskData> & Pick<ITaskData, 'source'>): ITaskData => ({
startTime: Date.now(),
executionTime: 0,
executionStatus: 'success',
...opts,
});
/**
* Creates a new all code task data with the given options
*/
export const newAllCodeTaskData = (
codeNodeInputData: INodeExecutionData[],
opts: Partial<AllCodeTaskData> = {},
): AllCodeTaskData => {
const codeNode = newNode({
name: 'JsCode',
parameters: {
mode: 'runOnceForEachItem',
language: 'javaScript',
jsCode: 'return item',
},
type: 'n8n-nodes-base.code',
typeVersion: 2,
});
const manualTriggerNode = newNode({
name: 'Trigger',
type: 'n8n-nodes-base.manualTrigger',
parameters: {
manualTriggerParam: 'empty',
},
});
return {
workflow: {
id: '1',
name: 'Test Workflow',
active: true,
connections: {
[manualTriggerNode.name]: {
main: [[{ node: codeNode.name, type: NodeConnectionType.Main, index: 0 }]],
},
},
nodes: [manualTriggerNode, codeNode],
},
inputData: {
main: [codeNodeInputData],
},
connectionInputData: codeNodeInputData,
node: codeNode,
runExecutionData: {
startData: {},
resultData: {
runData: {
[manualTriggerNode.name]: [
newTaskData({
source: [],
data: {
main: [codeNodeInputData],
},
}),
],
},
pinData: {},
lastNodeExecuted: manualTriggerNode.name,
},
executionData: {
contextData: {},
nodeExecutionStack: [],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
},
},
runIndex: 0,
itemIndex: 0,
activeNodeName: codeNode.name,
contextNodeName: codeNode.name,
defaultReturnRunIndex: -1,
siblingParameters: {},
mode: 'manual',
selfData: {},
envProviderState: {
env: {},
isEnvAccessBlocked: true,
isProcessAvailable: true,
},
additionalData: {
executionId: 'exec-id',
instanceBaseUrl: '',
restartExecutionId: '',
restApiUrl: '',
formWaitingBaseUrl: 'http://formWaitingBaseUrl',
webhookBaseUrl: 'http://webhookBaseUrl',
webhookTestBaseUrl: 'http://webhookTestBaseUrl',
webhookWaitingBaseUrl: 'http://webhookWaitingBaseUrl',
variables: {
var: 'value',
},
},
executeData: {
node: codeNode,
data: {
main: [codeNodeInputData],
},
source: {
main: [{ previousNode: manualTriggerNode.name }],
},
},
...opts,
};
};
/**
* Wraps the given value into an INodeExecutionData object's json property
*/
export const wrapIntoJson = (json: IDataObject): INodeExecutionData => ({
json,
});
/**
* Adds the given index as the pairedItem property to the given INodeExecutionData object
*/
export const withPairedItem = (index: number, data: INodeExecutionData): INodeExecutionData => ({
...data,
pairedItem: {
item: index,
},
});

View file

@ -0,0 +1,53 @@
import { ExecutionError } from '../execution-error';
describe('ExecutionError', () => {
const defaultStack = `TypeError: a.unknown is not a function
at VmCodeWrapper (evalmachine.<anonymous>:2:3)
at evalmachine.<anonymous>:7:2
at Script.runInContext (node:vm:148:12)
at Script.runInNewContext (node:vm:153:17)
at runInNewContext (node:vm:309:38)
at JsTaskRunner.runForAllItems (/n8n/packages/@n8n/task-runner/dist/js-task-runner/js-task-runner.js:90:65)
at JsTaskRunner.executeTask (/n8n/packages/@n8n/task-runner/dist/js-task-runner/js-task-runner.js:71:26)
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
at async JsTaskRunner.receivedSettings (/n8n/packages/@n8n/task-runner/dist/task-runner.js:190:26)`;
it('should parse error details from stack trace without itemIndex', () => {
const error = new Error('a.unknown is not a function');
error.stack = defaultStack;
const executionError = new ExecutionError(error);
expect(executionError.message).toBe('a.unknown is not a function [line 2]');
expect(executionError.lineNumber).toBe(2);
expect(executionError.description).toBe('TypeError');
expect(executionError.context).toBeUndefined();
});
it('should parse error details from stack trace with itemIndex', () => {
const error = new Error('a.unknown is not a function');
error.stack = defaultStack;
const executionError = new ExecutionError(error, 1);
expect(executionError.message).toBe('a.unknown is not a function [line 2, for item 1]');
expect(executionError.lineNumber).toBe(2);
expect(executionError.description).toBe('TypeError');
expect(executionError.context).toEqual({ itemIndex: 1 });
});
it('should serialize correctly', () => {
const error = new Error('a.unknown is not a function');
error.stack = defaultStack;
const executionError = new ExecutionError(error, 1);
expect(JSON.stringify(executionError)).toBe(
JSON.stringify({
message: 'a.unknown is not a function [line 2, for item 1]',
description: 'TypeError',
itemIndex: 1,
context: { itemIndex: 1 },
lineNumber: 2,
}),
);
});
});

View file

@ -0,0 +1,12 @@
export interface ErrorLike {
message: string;
stack?: string;
}
export function isErrorLike(value: unknown): value is ErrorLike {
if (typeof value !== 'object' || value === null) return false;
const errorLike = value as ErrorLike;
return typeof errorLike.message === 'string';
}

View file

@ -0,0 +1,94 @@
import type { ErrorLike } from './error-like';
import { SerializableError } from './serializable-error';
const VM_WRAPPER_FN_NAME = 'VmCodeWrapper';
export class ExecutionError extends SerializableError {
description: string | null = null;
itemIndex: number | undefined = undefined;
context: { itemIndex: number } | undefined = undefined;
stack = '';
lineNumber: number | undefined = undefined;
constructor(error: ErrorLike, itemIndex?: number) {
super(error.message);
this.itemIndex = itemIndex;
if (this.itemIndex !== undefined) {
this.context = { itemIndex: this.itemIndex };
}
this.stack = error.stack ?? '';
this.populateFromStack();
}
/**
* Populate error `message` and `description` from error `stack`.
*/
private populateFromStack() {
const stackRows = this.stack.split('\n');
if (stackRows.length === 0) {
this.message = 'Unknown error';
return;
}
const messageRow = stackRows.find((line) => line.includes('Error:'));
const lineNumberRow = stackRows.find((line) => line.includes(`at ${VM_WRAPPER_FN_NAME} `));
const lineNumberDisplay = this.toLineNumberDisplay(lineNumberRow);
if (!messageRow) {
this.message = `Unknown error ${lineNumberDisplay}`;
return;
}
const [errorDetails, errorType] = this.toErrorDetailsAndType(messageRow);
if (errorType) this.description = errorType;
if (!errorDetails) {
this.message = `Unknown error ${lineNumberDisplay}`;
return;
}
this.message = `${errorDetails} ${lineNumberDisplay}`;
}
private toLineNumberDisplay(lineNumberRow?: string) {
if (!lineNumberRow) return '';
// TODO: This doesn't work if there is a function definition in the code
// and the error is thrown from that function.
const regex = new RegExp(
`at ${VM_WRAPPER_FN_NAME} \\(evalmachine\\.<anonymous>:(?<lineNumber>\\d+):`,
);
const errorLineNumberMatch = lineNumberRow.match(regex);
if (!errorLineNumberMatch?.groups?.lineNumber) return null;
const lineNumber = errorLineNumberMatch.groups.lineNumber;
if (!lineNumber) return '';
this.lineNumber = Number(lineNumber);
return this.itemIndex === undefined
? `[line ${lineNumber}]`
: `[line ${lineNumber}, for item ${this.itemIndex}]`;
}
private toErrorDetailsAndType(messageRow?: string) {
if (!messageRow) return [null, null];
const [errorDetails, errorType] = messageRow
.split(':')
.reverse()
.map((i) => i.trim());
return [errorDetails, errorType === 'Error' ? null : errorType];
}
}

View file

@ -0,0 +1,21 @@
/**
* Error that has its message property serialized as well. Used to transport
* errors over the wire.
*/
export abstract class SerializableError extends Error {
constructor(message: string) {
super(message);
// So it is serialized as well
this.makeMessageEnumerable();
}
private makeMessageEnumerable() {
Object.defineProperty(this, 'message', {
value: this.message,
enumerable: true, // This makes the message property enumerable
writable: true,
configurable: true,
});
}
}

View file

@ -0,0 +1,44 @@
import { SerializableError } from './serializable-error';
export class ValidationError extends SerializableError {
description = '';
itemIndex: number | undefined = undefined;
context: { itemIndex: number } | undefined = undefined;
lineNumber: number | undefined = undefined;
constructor({
message,
description,
itemIndex,
lineNumber,
}: {
message: string;
description: string;
itemIndex?: number;
lineNumber?: number;
}) {
super(message);
this.lineNumber = lineNumber;
this.itemIndex = itemIndex;
if (this.lineNumber !== undefined && this.itemIndex !== undefined) {
this.message = `${message} [line ${lineNumber}, for item ${itemIndex}]`;
} else if (this.lineNumber !== undefined) {
this.message = `${message} [line ${lineNumber}]`;
} else if (this.itemIndex !== undefined) {
this.message = `${message} [item ${itemIndex}]`;
} else {
this.message = message;
}
this.description = description;
if (this.itemIndex !== undefined) {
this.context = { itemIndex: this.itemIndex };
}
}
}

View file

@ -0,0 +1,324 @@
import { getAdditionalKeys } from 'n8n-core';
import {
WorkflowDataProxy,
// type IWorkflowDataProxyAdditionalKeys,
Workflow,
} from 'n8n-workflow';
import type {
CodeExecutionMode,
INode,
INodeType,
ITaskDataConnections,
IWorkflowExecuteAdditionalData,
WorkflowParameters,
IDataObject,
IExecuteData,
INodeExecutionData,
INodeParameters,
IRunExecutionData,
WorkflowExecuteMode,
EnvProviderState,
} from 'n8n-workflow';
import * as a from 'node:assert';
import { runInNewContext, type Context } from 'node:vm';
import type { TaskResultData } from '@/runner-types';
import { type Task, TaskRunner } from '@/task-runner';
import { isErrorLike } from './errors/error-like';
import { ExecutionError } from './errors/execution-error';
import type { RequireResolver } from './require-resolver';
import { createRequireResolver } from './require-resolver';
import { validateRunForAllItemsOutput, validateRunForEachItemOutput } from './result-validation';
export interface JSExecSettings {
code: string;
nodeMode: CodeExecutionMode;
workflowMode: WorkflowExecuteMode;
continueOnFail: boolean;
// For workflow data proxy
mode: WorkflowExecuteMode;
}
export interface PartialAdditionalData {
executionId?: string;
restartExecutionId?: string;
restApiUrl: string;
instanceBaseUrl: string;
formWaitingBaseUrl: string;
webhookBaseUrl: string;
webhookWaitingBaseUrl: string;
webhookTestBaseUrl: string;
currentNodeParameters?: INodeParameters;
executionTimeoutTimestamp?: number;
userId?: string;
variables: IDataObject;
}
export interface AllCodeTaskData {
workflow: Omit<WorkflowParameters, 'nodeTypes'>;
inputData: ITaskDataConnections;
node: INode;
runExecutionData: IRunExecutionData;
runIndex: number;
itemIndex: number;
activeNodeName: string;
connectionInputData: INodeExecutionData[];
siblingParameters: INodeParameters;
mode: WorkflowExecuteMode;
envProviderState?: EnvProviderState;
executeData?: IExecuteData;
defaultReturnRunIndex: number;
selfData: IDataObject;
contextNodeName: string;
additionalData: PartialAdditionalData;
}
export interface JsTaskRunnerOpts {
wsUrl: string;
grantToken: string;
maxConcurrency: number;
name?: string;
/**
* List of built-in nodejs modules that are allowed to be required in the
* execution sandbox. Asterisk (*) can be used to allow all.
*/
allowedBuiltInModules?: string;
/**
* List of npm modules that are allowed to be required in the execution
* sandbox. Asterisk (*) can be used to allow all.
*/
allowedExternalModules?: string;
}
type CustomConsole = {
log: (...args: unknown[]) => void;
};
export class JsTaskRunner extends TaskRunner {
private readonly requireResolver: RequireResolver;
constructor({
grantToken,
maxConcurrency,
wsUrl,
name = 'JS Task Runner',
allowedBuiltInModules,
allowedExternalModules,
}: JsTaskRunnerOpts) {
super('javascript', wsUrl, grantToken, maxConcurrency, name);
const parseModuleAllowList = (moduleList: string) =>
moduleList === '*' ? null : new Set(moduleList.split(',').map((x) => x.trim()));
this.requireResolver = createRequireResolver({
allowedBuiltInModules: parseModuleAllowList(allowedBuiltInModules ?? ''),
allowedExternalModules: parseModuleAllowList(allowedExternalModules ?? ''),
});
}
async executeTask(task: Task<JSExecSettings>): Promise<TaskResultData> {
const allData = await this.requestData<AllCodeTaskData>(task.taskId, 'all');
const settings = task.settings;
a.ok(settings, 'JS Code not sent to runner');
const workflowParams = allData.workflow;
const workflow = new Workflow({
...workflowParams,
nodeTypes: {
getByNameAndVersion() {
return undefined as unknown as INodeType;
},
getByName() {
return undefined as unknown as INodeType;
},
getKnownTypes() {
return {};
},
},
});
const customConsole = {
// Send log output back to the main process. It will take care of forwarding
// it to the UI or printing to console.
log: (...args: unknown[]) => {
const logOutput = args
.map((arg) => (typeof arg === 'object' && arg !== null ? JSON.stringify(arg) : arg))
.join(' ');
void this.makeRpcCall(task.taskId, 'logNodeOutput', [logOutput]);
},
};
const result =
settings.nodeMode === 'runOnceForAllItems'
? await this.runForAllItems(task.taskId, settings, allData, workflow, customConsole)
: await this.runForEachItem(task.taskId, settings, allData, workflow, customConsole);
return {
result,
customData: allData.runExecutionData.resultData.metadata,
};
}
/**
* Executes the requested code for all items in a single run
*/
private async runForAllItems(
taskId: string,
settings: JSExecSettings,
allData: AllCodeTaskData,
workflow: Workflow,
customConsole: CustomConsole,
): Promise<INodeExecutionData[]> {
const dataProxy = this.createDataProxy(allData, workflow, allData.itemIndex);
const inputItems = allData.connectionInputData;
const context: Context = {
require: this.requireResolver,
module: {},
console: customConsole,
items: inputItems,
...dataProxy,
...this.buildRpcCallObject(taskId),
};
try {
const result = (await runInNewContext(
`module.exports = async function VmCodeWrapper() {${settings.code}\n}()`,
context,
)) as TaskResultData['result'];
if (result === null) {
return [];
}
return validateRunForAllItemsOutput(result);
} catch (e) {
// Errors thrown by the VM are not instances of Error, so map them to an ExecutionError
const error = this.toExecutionErrorIfNeeded(e);
if (settings.continueOnFail) {
return [{ json: { error: error.message } }];
}
throw error;
}
}
/**
* Executes the requested code for each item in the input data
*/
private async runForEachItem(
taskId: string,
settings: JSExecSettings,
allData: AllCodeTaskData,
workflow: Workflow,
customConsole: CustomConsole,
): Promise<INodeExecutionData[]> {
const inputItems = allData.connectionInputData;
const returnData: INodeExecutionData[] = [];
for (let index = 0; index < inputItems.length; index++) {
const item = inputItems[index];
const dataProxy = this.createDataProxy(allData, workflow, index);
const context: Context = {
require: this.requireResolver,
module: {},
console: customConsole,
item,
...dataProxy,
...this.buildRpcCallObject(taskId),
};
try {
let result = (await runInNewContext(
`module.exports = async function VmCodeWrapper() {${settings.code}\n}()`,
context,
)) as INodeExecutionData | undefined;
// Filter out null values
if (result === null) {
continue;
}
result = validateRunForEachItemOutput(result, index);
if (result) {
returnData.push(
result.binary
? {
json: result.json,
pairedItem: { item: index },
binary: result.binary,
}
: {
json: result.json,
pairedItem: { item: index },
},
);
}
} catch (e) {
// Errors thrown by the VM are not instances of Error, so map them to an ExecutionError
const error = this.toExecutionErrorIfNeeded(e);
if (!settings.continueOnFail) {
throw error;
}
returnData.push({
json: { error: error.message },
pairedItem: {
item: index,
},
});
}
}
return returnData;
}
private createDataProxy(allData: AllCodeTaskData, workflow: Workflow, itemIndex: number) {
return new WorkflowDataProxy(
workflow,
allData.runExecutionData,
allData.runIndex,
itemIndex,
allData.activeNodeName,
allData.connectionInputData,
allData.siblingParameters,
allData.mode,
getAdditionalKeys(
allData.additionalData as IWorkflowExecuteAdditionalData,
allData.mode,
allData.runExecutionData,
),
allData.executeData,
allData.defaultReturnRunIndex,
allData.selfData,
allData.contextNodeName,
// Make sure that even if we don't receive the envProviderState for
// whatever reason, we don't expose the task runner's env to the code
allData.envProviderState ?? {
env: {},
isEnvAccessBlocked: false,
isProcessAvailable: true,
},
).getDataProxy();
}
private toExecutionErrorIfNeeded(error: unknown): Error {
if (error instanceof Error) {
return error;
}
if (isErrorLike(error)) {
return new ExecutionError(error);
}
return new ExecutionError({ message: JSON.stringify(error) });
}
}

View file

@ -0,0 +1,5 @@
export function isObject(maybe: unknown): maybe is { [key: string]: unknown } {
return (
typeof maybe === 'object' && maybe !== null && !Array.isArray(maybe) && !(maybe instanceof Date)
);
}

View file

@ -0,0 +1,43 @@
import { ApplicationError } from 'n8n-workflow';
import { isBuiltin } from 'node:module';
import { ExecutionError } from './errors/execution-error';
export type RequireResolverOpts = {
/**
* List of built-in nodejs modules that are allowed to be required in the
* execution sandbox. `null` means all are allowed.
*/
allowedBuiltInModules: Set<string> | null;
/**
* List of external modules that are allowed to be required in the
* execution sandbox. `null` means all are allowed.
*/
allowedExternalModules: Set<string> | null;
};
export type RequireResolver = (request: string) => unknown;
export function createRequireResolver({
allowedBuiltInModules,
allowedExternalModules,
}: RequireResolverOpts) {
return (request: string) => {
const checkIsAllowed = (allowList: Set<string> | null, moduleName: string) => {
return allowList ? allowList.has(moduleName) : true;
};
const isAllowed = isBuiltin(request)
? checkIsAllowed(allowedBuiltInModules, request)
: checkIsAllowed(allowedExternalModules, request);
if (!isAllowed) {
const error = new ApplicationError(`Cannot find module '${request}'`);
throw new ExecutionError(error);
}
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require(request) as unknown;
};
}

View file

@ -0,0 +1,116 @@
import { normalizeItems } from 'n8n-core';
import type { INodeExecutionData } from 'n8n-workflow';
import { ValidationError } from './errors/validation-error';
import { isObject } from './obj-utils';
export const REQUIRED_N8N_ITEM_KEYS = new Set(['json', 'binary', 'pairedItem', 'error']);
function validateTopLevelKeys(item: INodeExecutionData, itemIndex: number) {
for (const key in item) {
if (Object.prototype.hasOwnProperty.call(item, key)) {
if (REQUIRED_N8N_ITEM_KEYS.has(key)) return;
throw new ValidationError({
message: `Unknown top-level item key: ${key}`,
description: 'Access the properties of an item under `.json`, e.g. `item.json`',
itemIndex,
});
}
}
}
function validateItem({ json, binary }: INodeExecutionData, itemIndex: number) {
if (json === undefined || !isObject(json)) {
throw new ValidationError({
message: "A 'json' property isn't an object",
description: "In the returned data, every key named 'json' must point to an object.",
itemIndex,
});
}
if (binary !== undefined && !isObject(binary)) {
throw new ValidationError({
message: "A 'binary' property isn't an object",
description: "In the returned data, every key named 'binary' must point to an object.",
itemIndex,
});
}
}
/**
* Validates the output of a code node in 'Run for All Items' mode.
*/
export function validateRunForAllItemsOutput(
executionResult: INodeExecutionData | INodeExecutionData[] | undefined,
) {
if (typeof executionResult !== 'object') {
throw new ValidationError({
message: "Code doesn't return items properly",
description: 'Please return an array of objects, one for each item you would like to output.',
});
}
if (Array.isArray(executionResult)) {
/**
* If at least one top-level key is an n8n item key (`json`, `binary`, etc.),
* then require all item keys to be an n8n item key.
*
* If no top-level key is an n8n key, then skip this check, allowing non-n8n
* item keys to be wrapped in `json` when normalizing items below.
*/
const mustHaveTopLevelN8nKey = executionResult.some((item) =>
Object.keys(item).find((key) => REQUIRED_N8N_ITEM_KEYS.has(key)),
);
if (mustHaveTopLevelN8nKey) {
for (let index = 0; index < executionResult.length; index++) {
const item = executionResult[index];
validateTopLevelKeys(item, index);
}
}
}
const returnData = normalizeItems(executionResult);
returnData.forEach(validateItem);
return returnData;
}
/**
* Validates the output of a code node in 'Run for Each Item' mode for single item
*/
export function validateRunForEachItemOutput(
executionResult: INodeExecutionData | undefined,
itemIndex: number,
) {
if (typeof executionResult !== 'object') {
throw new ValidationError({
message: "Code doesn't return an object",
description: `Please return an object representing the output item. ('${executionResult}' was returned instead.)`,
itemIndex,
});
}
if (Array.isArray(executionResult)) {
const firstSentence =
executionResult.length > 0
? `An array of ${typeof executionResult[0]}s was returned.`
: 'An empty array was returned.';
throw new ValidationError({
message: "Code doesn't return a single object",
description: `${firstSentence} If you need to output multiple items, please use the 'Run Once for All Items' mode instead.`,
itemIndex,
});
}
const [returnData] = normalizeItems([executionResult]);
validateItem(returnData, itemIndex);
// If at least one top-level key is a supported item key (`json`, `binary`, etc.),
// and another top-level key is unrecognized, then the user mis-added a property
// directly on the item, when they intended to add it on the `json` property
validateTopLevelKeys(returnData, itemIndex);
return returnData;
}

View file

@ -0,0 +1,231 @@
import type { INodeExecutionData } from 'n8n-workflow';
export type DataRequestType = 'input' | 'node' | 'all';
export interface TaskResultData {
result: INodeExecutionData[];
customData?: Record<string, string>;
}
export namespace N8nMessage {
export namespace ToRunner {
export interface InfoRequest {
type: 'broker:inforequest';
}
export interface RunnerRegistered {
type: 'broker:runnerregistered';
}
export interface TaskOfferAccept {
type: 'broker:taskofferaccept';
taskId: string;
offerId: string;
}
export interface TaskCancel {
type: 'broker:taskcancel';
taskId: string;
reason: string;
}
export interface TaskSettings {
type: 'broker:tasksettings';
taskId: string;
settings: unknown;
}
export interface RPCResponse {
type: 'broker:rpcresponse';
callId: string;
taskId: string;
status: 'success' | 'error';
data: unknown;
}
export interface TaskDataResponse {
type: 'broker:taskdataresponse';
taskId: string;
requestId: string;
data: unknown;
}
export type All =
| InfoRequest
| TaskOfferAccept
| TaskCancel
| TaskSettings
| RunnerRegistered
| RPCResponse
| TaskDataResponse;
}
export namespace ToRequester {
export interface TaskReady {
type: 'broker:taskready';
requestId: string;
taskId: string;
}
export interface TaskDone {
type: 'broker:taskdone';
taskId: string;
data: TaskResultData;
}
export interface TaskError {
type: 'broker:taskerror';
taskId: string;
error: unknown;
}
export interface TaskDataRequest {
type: 'broker:taskdatarequest';
taskId: string;
requestId: string;
requestType: DataRequestType;
param?: string;
}
export interface RPC {
type: 'broker:rpc';
callId: string;
taskId: string;
name: (typeof RPC_ALLOW_LIST)[number];
params: unknown[];
}
export type All = TaskReady | TaskDone | TaskError | TaskDataRequest | RPC;
}
}
export namespace RequesterMessage {
export namespace ToN8n {
export interface TaskSettings {
type: 'requester:tasksettings';
taskId: string;
settings: unknown;
}
export interface TaskCancel {
type: 'requester:taskcancel';
taskId: string;
reason: string;
}
export interface TaskDataResponse {
type: 'requester:taskdataresponse';
taskId: string;
requestId: string;
data: unknown;
}
export interface RPCResponse {
type: 'requester:rpcresponse';
taskId: string;
callId: string;
status: 'success' | 'error';
data: unknown;
}
export interface TaskRequest {
type: 'requester:taskrequest';
requestId: string;
taskType: string;
}
export type All = TaskSettings | TaskCancel | RPCResponse | TaskDataResponse | TaskRequest;
}
}
export namespace RunnerMessage {
export namespace ToN8n {
export interface Info {
type: 'runner:info';
name: string;
types: string[];
}
export interface TaskAccepted {
type: 'runner:taskaccepted';
taskId: string;
}
export interface TaskRejected {
type: 'runner:taskrejected';
taskId: string;
reason: string;
}
export interface TaskDone {
type: 'runner:taskdone';
taskId: string;
data: TaskResultData;
}
export interface TaskError {
type: 'runner:taskerror';
taskId: string;
error: unknown;
}
export interface TaskOffer {
type: 'runner:taskoffer';
offerId: string;
taskType: string;
validFor: number;
}
export interface TaskDataRequest {
type: 'runner:taskdatarequest';
taskId: string;
requestId: string;
requestType: DataRequestType;
param?: string;
}
export interface RPC {
type: 'runner:rpc';
callId: string;
taskId: string;
name: (typeof RPC_ALLOW_LIST)[number];
params: unknown[];
}
export type All =
| Info
| TaskDone
| TaskError
| TaskAccepted
| TaskRejected
| TaskOffer
| RPC
| TaskDataRequest;
}
}
export const RPC_ALLOW_LIST = [
'helpers.httpRequestWithAuthentication',
'helpers.requestWithAuthenticationPaginated',
// "helpers.normalizeItems"
// "helpers.constructExecutionMetaData"
// "helpers.assertBinaryData"
'helpers.getBinaryDataBuffer',
// "helpers.copyInputItems"
// "helpers.returnJsonArray"
'helpers.getSSHClient',
'helpers.createReadStream',
// "helpers.getStoragePath"
'helpers.writeContentToFile',
'helpers.prepareBinaryData',
'helpers.setBinaryDataBuffer',
'helpers.copyBinaryFile',
'helpers.binaryToBuffer',
// "helpers.binaryToString"
// "helpers.getBinaryPath"
'helpers.getBinaryStream',
'helpers.getBinaryMetadata',
'helpers.createDeferredPromise',
'helpers.httpRequest',
'logNodeOutput',
] as const;

View file

@ -0,0 +1,83 @@
import { ApplicationError, ensureError } from 'n8n-workflow';
import * as a from 'node:assert/strict';
import { authenticate } from './authenticator';
import { JsTaskRunner } from './js-task-runner/js-task-runner';
let runner: JsTaskRunner | undefined;
let isShuttingDown = false;
type Config = {
n8nUri: string;
authToken?: string;
grantToken?: string;
};
function readAndParseConfig(): Config {
const authToken = process.env.N8N_RUNNERS_AUTH_TOKEN;
const grantToken = process.env.N8N_RUNNERS_GRANT_TOKEN;
if (!authToken && !grantToken) {
throw new ApplicationError(
'Missing task runner authentication. Use either N8N_RUNNERS_AUTH_TOKEN or N8N_RUNNERS_GRANT_TOKEN to configure it',
);
}
return {
n8nUri: process.env.N8N_RUNNERS_N8N_URI ?? '127.0.0.1:5679',
authToken,
grantToken,
};
}
function createSignalHandler(signal: string) {
return async function onSignal() {
if (isShuttingDown) {
return;
}
console.log(`Received ${signal} signal, shutting down...`);
isShuttingDown = true;
try {
if (runner) {
await runner.stop();
runner = undefined;
}
} catch (e) {
const error = ensureError(e);
console.error('Error stopping task runner', { error });
} finally {
process.exit(0);
}
};
}
void (async function start() {
const config = readAndParseConfig();
let grantToken = config.grantToken;
if (!grantToken) {
a.ok(config.authToken);
grantToken = await authenticate({
authToken: config.authToken,
n8nUri: config.n8nUri,
});
}
const wsUrl = `ws://${config.n8nUri}/runners/_ws`;
runner = new JsTaskRunner({
wsUrl,
grantToken,
maxConcurrency: 5,
allowedBuiltInModules: process.env.NODE_FUNCTION_ALLOW_BUILTIN,
allowedExternalModules: process.env.NODE_FUNCTION_ALLOW_EXTERNAL,
});
process.on('SIGINT', createSignalHandler('SIGINT'));
process.on('SIGTERM', createSignalHandler('SIGTERM'));
})().catch((e) => {
const error = ensureError(e);
console.error('Task runner failed to start', { error });
process.exit(1);
});

View file

@ -0,0 +1,390 @@
import { ApplicationError } from 'n8n-workflow';
import { nanoid } from 'nanoid';
import { URL } from 'node:url';
import { type MessageEvent, WebSocket } from 'ws';
import {
RPC_ALLOW_LIST,
type RunnerMessage,
type N8nMessage,
type TaskResultData,
} from './runner-types';
export interface Task<T = unknown> {
taskId: string;
settings?: T;
active: boolean;
cancelled: boolean;
}
export interface TaskOffer {
offerId: string;
validUntil: bigint;
}
interface DataRequest {
requestId: string;
resolve: (data: unknown) => void;
reject: (error: unknown) => void;
}
interface RPCCall {
callId: string;
resolve: (data: unknown) => void;
reject: (error: unknown) => void;
}
export interface RPCCallObject {
[name: string]: ((...args: unknown[]) => Promise<unknown>) | RPCCallObject;
}
const VALID_TIME_MS = 1000;
const VALID_EXTRA_MS = 100;
export abstract class TaskRunner {
id: string = nanoid();
ws: WebSocket;
canSendOffers = false;
runningTasks: Map<Task['taskId'], Task> = new Map();
offerInterval: NodeJS.Timeout | undefined;
openOffers: Map<TaskOffer['offerId'], TaskOffer> = new Map();
dataRequests: Map<DataRequest['requestId'], DataRequest> = new Map();
rpcCalls: Map<RPCCall['callId'], RPCCall> = new Map();
constructor(
public taskType: string,
wsUrl: string,
grantToken: string,
private maxConcurrency: number,
public name?: string,
) {
const url = new URL(wsUrl);
url.searchParams.append('id', this.id);
this.ws = new WebSocket(url.toString(), {
headers: {
authorization: `Bearer ${grantToken}`,
},
});
this.ws.addEventListener('message', this.receiveMessage);
this.ws.addEventListener('close', this.stopTaskOffers);
}
private receiveMessage = (message: MessageEvent) => {
// eslint-disable-next-line n8n-local-rules/no-uncaught-json-parse
const data = JSON.parse(message.data as string) as N8nMessage.ToRunner.All;
void this.onMessage(data);
};
private stopTaskOffers = () => {
this.canSendOffers = false;
if (this.offerInterval) {
clearInterval(this.offerInterval);
this.offerInterval = undefined;
}
};
private startTaskOffers() {
this.canSendOffers = true;
if (this.offerInterval) {
clearInterval(this.offerInterval);
}
this.offerInterval = setInterval(() => this.sendOffers(), 250);
}
deleteStaleOffers() {
this.openOffers.forEach((offer, key) => {
if (offer.validUntil < process.hrtime.bigint()) {
this.openOffers.delete(key);
}
});
}
sendOffers() {
this.deleteStaleOffers();
const offersToSend =
this.maxConcurrency -
(Object.values(this.openOffers).length + Object.values(this.runningTasks).length);
for (let i = 0; i < offersToSend; i++) {
const offer: TaskOffer = {
offerId: nanoid(),
validUntil: process.hrtime.bigint() + BigInt((VALID_TIME_MS + VALID_EXTRA_MS) * 1_000_000), // Adding a little extra time to account for latency
};
this.openOffers.set(offer.offerId, offer);
this.send({
type: 'runner:taskoffer',
taskType: this.taskType,
offerId: offer.offerId,
validFor: VALID_TIME_MS,
});
}
}
send(message: RunnerMessage.ToN8n.All) {
this.ws.send(JSON.stringify(message));
}
onMessage(message: N8nMessage.ToRunner.All) {
switch (message.type) {
case 'broker:inforequest':
this.send({
type: 'runner:info',
name: this.name ?? 'Node.js Task Runner SDK',
types: [this.taskType],
});
break;
case 'broker:runnerregistered':
this.startTaskOffers();
break;
case 'broker:taskofferaccept':
this.offerAccepted(message.offerId, message.taskId);
break;
case 'broker:taskcancel':
this.taskCancelled(message.taskId);
break;
case 'broker:tasksettings':
void this.receivedSettings(message.taskId, message.settings);
break;
case 'broker:taskdataresponse':
this.processDataResponse(message.requestId, message.data);
break;
case 'broker:rpcresponse':
this.handleRpcResponse(message.callId, message.status, message.data);
}
}
processDataResponse(requestId: string, data: unknown) {
const request = this.dataRequests.get(requestId);
if (!request) {
return;
}
// Deleting of the request is handled in `requestData`, using a
// `finally` wrapped around the return
request.resolve(data);
}
hasOpenTasks() {
return Object.values(this.runningTasks).length < this.maxConcurrency;
}
offerAccepted(offerId: string, taskId: string) {
if (!this.hasOpenTasks()) {
this.send({
type: 'runner:taskrejected',
taskId,
reason: 'No open task slots',
});
return;
}
const offer = this.openOffers.get(offerId);
if (!offer) {
this.send({
type: 'runner:taskrejected',
taskId,
reason: 'Offer expired and no open task slots',
});
return;
} else {
this.openOffers.delete(offerId);
}
this.runningTasks.set(taskId, {
taskId,
active: false,
cancelled: false,
});
this.send({
type: 'runner:taskaccepted',
taskId,
});
}
taskCancelled(taskId: string) {
const task = this.runningTasks.get(taskId);
if (!task) {
return;
}
task.cancelled = true;
if (task.active) {
// TODO
} else {
this.runningTasks.delete(taskId);
}
this.sendOffers();
}
taskErrored(taskId: string, error: unknown) {
this.send({
type: 'runner:taskerror',
taskId,
error,
});
this.runningTasks.delete(taskId);
this.sendOffers();
}
taskDone(taskId: string, data: RunnerMessage.ToN8n.TaskDone['data']) {
this.send({
type: 'runner:taskdone',
taskId,
data,
});
this.runningTasks.delete(taskId);
this.sendOffers();
}
async receivedSettings(taskId: string, settings: unknown) {
const task = this.runningTasks.get(taskId);
if (!task) {
return;
}
if (task.cancelled) {
this.runningTasks.delete(taskId);
return;
}
task.settings = settings;
task.active = true;
try {
const data = await this.executeTask(task);
this.taskDone(taskId, data);
} catch (error) {
this.taskErrored(taskId, error);
}
}
// eslint-disable-next-line @typescript-eslint/naming-convention
async executeTask(_task: Task): Promise<TaskResultData> {
throw new ApplicationError('Unimplemented');
}
async requestData<T = unknown>(
taskId: Task['taskId'],
type: RunnerMessage.ToN8n.TaskDataRequest['requestType'],
param?: string,
): Promise<T> {
const requestId = nanoid();
const p = new Promise<T>((resolve, reject) => {
this.dataRequests.set(requestId, {
requestId,
resolve: resolve as (data: unknown) => void,
reject,
});
});
this.send({
type: 'runner:taskdatarequest',
taskId,
requestId,
requestType: type,
param,
});
try {
return await p;
} finally {
this.dataRequests.delete(requestId);
}
}
async makeRpcCall(taskId: string, name: RunnerMessage.ToN8n.RPC['name'], params: unknown[]) {
const callId = nanoid();
const dataPromise = new Promise((resolve, reject) => {
this.rpcCalls.set(callId, {
callId,
resolve,
reject,
});
});
this.send({
type: 'runner:rpc',
callId,
taskId,
name,
params,
});
try {
return await dataPromise;
} finally {
this.rpcCalls.delete(callId);
}
}
handleRpcResponse(
callId: string,
status: N8nMessage.ToRunner.RPCResponse['status'],
data: unknown,
) {
const call = this.rpcCalls.get(callId);
if (!call) {
return;
}
if (status === 'success') {
call.resolve(data);
} else {
call.reject(typeof data === 'string' ? new Error(data) : data);
}
}
buildRpcCallObject(taskId: string) {
const rpcObject: RPCCallObject = {};
for (const r of RPC_ALLOW_LIST) {
const splitPath = r.split('.');
let obj = rpcObject;
splitPath.forEach((s, index) => {
if (index !== splitPath.length - 1) {
obj[s] = {};
obj = obj[s];
return;
}
obj[s] = async (...args: unknown[]) => await this.makeRpcCall(taskId, r, args);
});
}
return rpcObject;
}
/** Close the connection gracefully and wait until has been closed */
async stop() {
this.stopTaskOffers();
await this.waitUntilAllTasksAreDone();
await this.closeConnection();
}
private async closeConnection() {
// 1000 is the standard close code
// https://www.rfc-editor.org/rfc/rfc6455.html#section-7.1.5
this.ws.close(1000, 'Shutting down');
await new Promise((resolve) => {
this.ws.once('close', resolve);
});
}
private async waitUntilAllTasksAreDone(maxWaitTimeInMs = 30_000) {
// TODO: Make maxWaitTimeInMs configurable
const start = Date.now();
while (this.runningTasks.size > 0) {
if (Date.now() - start > maxWaitTimeInMs) {
throw new ApplicationError('Timeout while waiting for tasks to finish');
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
}

View file

@ -0,0 +1,11 @@
{
"extends": ["./tsconfig.json", "../../../tsconfig.build.json"],
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/build.tsbuildinfo"
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/__tests__/**"]
}

View file

@ -0,0 +1,12 @@
{
"extends": ["../../../tsconfig.json", "../../../tsconfig.backend.json"],
"compilerOptions": {
"rootDir": ".",
"baseUrl": "src",
"paths": {
"@/*": ["./*"]
},
"tsBuildInfoFile": "dist/typecheck.tsbuildinfo"
},
"include": ["src/**/*.ts"]
}

View file

@ -71,5 +71,11 @@ module.exports = {
],
},
},
{
files: ['./test/**/*.ts', './src/**/__tests__/**/*.ts'],
rules: {
'n8n-local-rules/no-dynamic-import-template': 'off',
},
},
],
};

View file

@ -2,6 +2,28 @@
This list shows all the versions which include breaking changes and how to upgrade.
# 1.63.0
### What changed?
1. The worker server used to bind to IPv6 by default. It now binds to IPv4 by default.
2. The worker server's `/healthz` used to report healthy status based on database and Redis checks. It now reports healthy status regardless of database and Redis status, and the database and Redis checks are part of `/healthz/readiness`.
### When is action necessary?
1. If you experience a port conflict error when starting a worker server using its default port, set a different port for the worker server with `QUEUE_HEALTH_CHECK_PORT`.
2. If you are relying on database and Redis checks for worker health status, switch to checking `/healthz/readiness` instead of `/healthz`.
## 1.57.0
### What changed?
The `verbose` log level was merged into the `debug` log level.
### When is action necessary?
If you are setting the env var `N8N_LOG_LEVEL=verbose`, please update your log level to `N8N_LOG_LEVEL=debug`.
## 1.55.0
### What changed?

View file

@ -1,6 +1,6 @@
{
"name": "n8n",
"version": "1.61.0",
"version": "1.63.0",
"description": "n8n Workflow Automation Tool",
"main": "dist/index",
"types": "dist/index.d.ts",
@ -51,13 +51,13 @@
"!dist/**/e2e.*"
],
"devDependencies": {
"@redocly/cli": "^1.6.0",
"@redocly/cli": "^1.25.5",
"@types/archiver": "^6.0.2",
"@types/aws4": "^1.5.1",
"@types/bcryptjs": "^2.4.2",
"@types/compression": "1.0.1",
"@types/convict": "^6.1.1",
"@types/cookie-parser": "^1.4.2",
"@types/cookie-parser": "^1.4.7",
"@types/express": "catalog:",
"@types/flat": "^5.0.5",
"@types/formidable": "^3.4.5",
@ -78,7 +78,6 @@
"@types/xml2js": "catalog:",
"@types/yamljs": "^0.2.31",
"@vvo/tzdb": "^6.141.0",
"chokidar": "^3.5.2",
"concurrently": "^8.2.0",
"ioredis-mock": "^8.8.1",
"mjml": "^4.15.3",
@ -94,8 +93,9 @@
"@n8n/localtunnel": "3.0.0",
"@n8n/n8n-nodes-langchain": "workspace:*",
"@n8n/permissions": "workspace:*",
"@n8n/typeorm": "0.3.20-10",
"@n8n_io/ai-assistant-sdk": "1.9.4",
"@n8n/task-runner": "workspace:*",
"@n8n/typeorm": "0.3.20-12",
"@n8n_io/ai-assistant-sdk": "1.10.3",
"@n8n_io/license-sdk": "2.13.1",
"@oclif/core": "4.0.7",
"@rudderstack/rudder-sdk-node": "2.0.9",
@ -114,14 +114,14 @@
"class-validator": "0.14.0",
"compression": "1.7.4",
"convict": "6.2.4",
"cookie-parser": "1.4.6",
"cookie-parser": "1.4.7",
"csrf": "3.1.0",
"curlconverter": "3.21.0",
"dotenv": "8.6.0",
"express": "4.21.0",
"express": "4.21.1",
"express-async-errors": "3.1.1",
"express-handlebars": "7.1.2",
"express-openapi-validator": "5.3.3",
"express-openapi-validator": "5.3.7",
"express-prom-bundle": "6.6.0",
"express-rate-limit": "7.2.0",
"fast-glob": "catalog:",
@ -148,7 +148,7 @@
"nodemailer": "6.9.9",
"oauth-1.0a": "2.2.6",
"open": "7.4.2",
"openapi-types": "10.0.0",
"openapi-types": "12.1.3",
"otpauth": "9.1.1",
"p-cancelable": "2.1.1",
"p-lazy": "3.1.0",
@ -167,16 +167,15 @@
"simple-git": "3.17.0",
"source-map-support": "0.5.21",
"sqlite3": "5.1.7",
"sse-channel": "4.0.0",
"sshpk": "1.17.0",
"swagger-ui-express": "5.0.0",
"swagger-ui-express": "5.0.1",
"syslog-client": "1.1.1",
"tar-stream": "^3.1.7",
"typedi": "catalog:",
"unzip-stream": "0.3.4",
"uuid": "catalog:",
"validator": "13.7.0",
"winston": "3.8.2",
"winston": "3.14.2",
"ws": "8.17.1",
"xml2js": "catalog:",
"xmllint-wasm": "3.0.1",

View file

@ -5,7 +5,7 @@ import type { InstanceSettings } from 'n8n-core';
import config from '@/config';
import { N8N_VERSION } from '@/constants';
import { License } from '@/license';
import type { Logger } from '@/logger';
import { mockLogger } from '@test/mocking';
jest.mock('@n8n_io/license-sdk');
@ -25,37 +25,39 @@ describe('License', () => {
});
let license: License;
const logger = mock<Logger>();
const instanceSettings = mock<InstanceSettings>({
instanceId: MOCK_INSTANCE_ID,
instanceType: 'main',
});
beforeEach(async () => {
license = new License(logger, instanceSettings, mock(), mock(), mock());
license = new License(mockLogger(), instanceSettings, mock(), mock(), mock());
await license.init();
});
test('initializes license manager', async () => {
expect(LicenseManager).toHaveBeenCalledWith({
autoRenewEnabled: true,
autoRenewOffset: MOCK_RENEW_OFFSET,
offlineMode: false,
renewOnInit: true,
deviceFingerprint: expect.any(Function),
productIdentifier: `n8n-${N8N_VERSION}`,
logger,
loadCertStr: expect.any(Function),
saveCertStr: expect.any(Function),
onFeatureChange: expect.any(Function),
collectUsageMetrics: expect.any(Function),
collectPassthroughData: expect.any(Function),
server: MOCK_SERVER_URL,
tenantId: 1,
});
expect(LicenseManager).toHaveBeenCalledWith(
expect.objectContaining({
autoRenewEnabled: true,
autoRenewOffset: MOCK_RENEW_OFFSET,
offlineMode: false,
renewOnInit: true,
deviceFingerprint: expect.any(Function),
productIdentifier: `n8n-${N8N_VERSION}`,
loadCertStr: expect.any(Function),
saveCertStr: expect.any(Function),
onFeatureChange: expect.any(Function),
collectUsageMetrics: expect.any(Function),
collectPassthroughData: expect.any(Function),
server: MOCK_SERVER_URL,
tenantId: 1,
}),
);
});
test('initializes license manager for worker', async () => {
const logger = mockLogger();
license = new License(
logger,
mock<InstanceSettings>({ instanceType: 'worker' }),
@ -64,22 +66,23 @@ describe('License', () => {
mock(),
);
await license.init();
expect(LicenseManager).toHaveBeenCalledWith({
autoRenewEnabled: false,
autoRenewOffset: MOCK_RENEW_OFFSET,
offlineMode: true,
renewOnInit: false,
deviceFingerprint: expect.any(Function),
productIdentifier: `n8n-${N8N_VERSION}`,
logger,
loadCertStr: expect.any(Function),
saveCertStr: expect.any(Function),
onFeatureChange: expect.any(Function),
collectUsageMetrics: expect.any(Function),
collectPassthroughData: expect.any(Function),
server: MOCK_SERVER_URL,
tenantId: 1,
});
expect(LicenseManager).toHaveBeenCalledWith(
expect.objectContaining({
autoRenewEnabled: false,
autoRenewOffset: MOCK_RENEW_OFFSET,
offlineMode: true,
renewOnInit: false,
deviceFingerprint: expect.any(Function),
productIdentifier: `n8n-${N8N_VERSION}`,
loadCertStr: expect.any(Function),
saveCertStr: expect.any(Function),
onFeatureChange: expect.any(Function),
collectUsageMetrics: expect.any(Function),
collectPassthroughData: expect.any(Function),
server: MOCK_SERVER_URL,
tenantId: 1,
}),
);
});
test('attempts to activate license with provided key', async () => {
@ -196,7 +199,7 @@ describe('License', () => {
it('should enable renewal', async () => {
config.set('multiMainSetup.enabled', false);
await new License(mock(), mock(), mock(), mock(), mock()).init();
await new License(mockLogger(), mock(), mock(), mock(), mock()).init();
expect(LicenseManager).toHaveBeenCalledWith(
expect.objectContaining({ autoRenewEnabled: true, renewOnInit: true }),
@ -208,7 +211,7 @@ describe('License', () => {
it('should disable renewal', async () => {
config.set('license.autoRenewEnabled', false);
await new License(mock(), mock(), mock(), mock(), mock()).init();
await new License(mockLogger(), mock(), mock(), mock(), mock()).init();
expect(LicenseManager).toHaveBeenCalledWith(
expect.objectContaining({ autoRenewEnabled: false, renewOnInit: false }),
@ -226,7 +229,7 @@ describe('License', () => {
config.set('multiMainSetup.instanceType', status);
config.set('license.autoRenewEnabled', false);
await new License(mock(), mock(), mock(), mock(), mock()).init();
await new License(mockLogger(), mock(), mock(), mock(), mock()).init();
expect(LicenseManager).toHaveBeenCalledWith(
expect.objectContaining({ autoRenewEnabled: false, renewOnInit: false }),
@ -241,7 +244,7 @@ describe('License', () => {
config.set('multiMainSetup.instanceType', status);
config.set('license.autoRenewEnabled', false);
await new License(mock(), mock(), mock(), mock(), mock()).init();
await new License(mockLogger(), mock(), mock(), mock(), mock()).init();
expect(LicenseManager).toHaveBeenCalledWith(
expect.objectContaining({ autoRenewEnabled: false, renewOnInit: false }),
@ -252,7 +255,7 @@ describe('License', () => {
config.set('multiMainSetup.enabled', true);
config.set('multiMainSetup.instanceType', 'leader');
await new License(mock(), mock(), mock(), mock(), mock()).init();
await new License(mockLogger(), mock(), mock(), mock(), mock()).init();
expect(LicenseManager).toHaveBeenCalledWith(
expect.objectContaining({ autoRenewEnabled: true, renewOnInit: true }),
@ -264,7 +267,7 @@ describe('License', () => {
describe('reinit', () => {
it('should reinitialize license manager', async () => {
const license = new License(mock(), mock(), mock(), mock(), mock());
const license = new License(mockLogger(), mock(), mock(), mock(), mock());
await license.init();
const initSpy = jest.spyOn(license, 'init');

View file

@ -0,0 +1,37 @@
import { mock } from 'jest-mock-extended';
import type { DirectoryLoader } from 'n8n-core';
import { LoadNodesAndCredentials } from '../load-nodes-and-credentials';
describe('LoadNodesAndCredentials', () => {
describe('resolveIcon', () => {
let instance: LoadNodesAndCredentials;
beforeEach(() => {
instance = new LoadNodesAndCredentials(mock(), mock(), mock());
instance.loaders.package1 = mock<DirectoryLoader>({
directory: '/icons/package1',
});
});
it('should return undefined if the loader for the package is not found', () => {
const result = instance.resolveIcon('unknownPackage', '/icons/unknownPackage/icon.png');
expect(result).toBeUndefined();
});
it('should return undefined if the resolved file path is outside the loader directory', () => {
const result = instance.resolveIcon('package1', '/some/other/path/icon.png');
expect(result).toBeUndefined();
});
it('should return the file path if the file is within the loader directory', () => {
const result = instance.resolveIcon('package1', '/icons/package1/icon.png');
expect(result).toBe('/icons/package1/icon.png');
});
it('should return undefined if the URL is outside the package directory', () => {
const result = instance.resolveIcon('package1', '/icons/package1/../../../etc/passwd');
expect(result).toBeUndefined();
});
});
});

View file

@ -5,6 +5,7 @@ import type { IExecutionResponse } from '@/interfaces';
import type { MultiMainSetup } from '@/services/orchestration/main/multi-main-setup.ee';
import { OrchestrationService } from '@/services/orchestration.service';
import { WaitTracker } from '@/wait-tracker';
import { mockLogger } from '@test/mocking';
jest.useFakeTimers();
@ -21,7 +22,7 @@ describe('WaitTracker', () => {
let waitTracker: WaitTracker;
beforeEach(() => {
waitTracker = new WaitTracker(
mock(),
mockLogger(),
executionRepository,
mock(),
mock(),

View file

@ -1,21 +1,80 @@
import { mock } from 'jest-mock-extended';
import type {
IExecuteWorkflowInfo,
IWorkflowExecuteAdditionalData,
ExecuteWorkflowOptions,
IRun,
} from 'n8n-workflow';
import type PCancelable from 'p-cancelable';
import Container from 'typedi';
import { ActiveExecutions } from '@/active-executions';
import { CredentialsHelper } from '@/credentials-helper';
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import { VariablesService } from '@/environments/variables/variables.service.ee';
import { EventService } from '@/events/event.service';
import { ExternalHooks } from '@/external-hooks';
import { SecretsHelper } from '@/secrets-helpers';
import { getBase } from '@/workflow-execute-additional-data';
import { WorkflowStatisticsService } from '@/services/workflow-statistics.service';
import { SubworkflowPolicyChecker } from '@/subworkflows/subworkflow-policy-checker.service';
import { Telemetry } from '@/telemetry';
import { PermissionChecker } from '@/user-management/permission-checker';
import { executeWorkflow, getBase } from '@/workflow-execute-additional-data';
import { mockInstance } from '@test/mocking';
const run = mock<IRun>({
data: { resultData: {} },
finished: true,
mode: 'manual',
startedAt: new Date(),
status: 'new',
});
const cancelablePromise = mock<PCancelable<IRun>>({
then: jest
.fn()
.mockImplementation(async (onfulfilled) => await Promise.resolve(run).then(onfulfilled)),
catch: jest
.fn()
.mockImplementation(async (onrejected) => await Promise.resolve(run).catch(onrejected)),
finally: jest
.fn()
.mockImplementation(async (onfinally) => await Promise.resolve(run).finally(onfinally)),
[Symbol.toStringTag]: 'PCancelable',
});
jest.mock('n8n-core', () => ({
__esModule: true,
...jest.requireActual('n8n-core'),
WorkflowExecute: jest.fn().mockImplementation(() => ({
processRunExecutionData: jest.fn().mockReturnValue(cancelablePromise),
})),
}));
jest.mock('../workflow-helpers', () => ({
...jest.requireActual('../workflow-helpers'),
getDataLastExecutedNodeData: jest.fn().mockReturnValue({ data: { main: [] } }),
}));
describe('WorkflowExecuteAdditionalData', () => {
const variablesService = mockInstance(VariablesService);
variablesService.getAllCached.mockResolvedValue([]);
const credentialsHelper = mockInstance(CredentialsHelper);
const secretsHelper = mockInstance(SecretsHelper);
const eventService = mockInstance(EventService);
mockInstance(ExternalHooks);
Container.set(VariablesService, variablesService);
Container.set(CredentialsHelper, credentialsHelper);
Container.set(SecretsHelper, secretsHelper);
const executionRepository = mockInstance(ExecutionRepository);
mockInstance(Telemetry);
const workflowRepository = mockInstance(WorkflowRepository);
const activeExecutions = mockInstance(ActiveExecutions);
mockInstance(PermissionChecker);
mockInstance(SubworkflowPolicyChecker);
mockInstance(WorkflowStatisticsService);
test('logAiEvent should call MessageEventBus', async () => {
const additionalData = await getBase('user-id');
@ -35,4 +94,18 @@ describe('WorkflowExecuteAdditionalData', () => {
expect(eventService.emit).toHaveBeenCalledTimes(1);
expect(eventService.emit).toHaveBeenCalledWith(eventName, payload);
});
it('`executeWorkflow` should set subworkflow execution as running', async () => {
const executionId = '123';
workflowRepository.get.mockResolvedValue(mock<WorkflowEntity>({ id: executionId, nodes: [] }));
activeExecutions.add.mockResolvedValue(executionId);
await executeWorkflow(
mock<IExecuteWorkflowInfo>(),
mock<IWorkflowExecuteAdditionalData>(),
mock<ExecuteWorkflowOptions>({ loadedWorkflowData: undefined }),
);
expect(executionRepository.setRunning).toHaveBeenCalledWith(executionId);
});
});

View file

@ -13,7 +13,7 @@ import { N8N_VERSION, TEMPLATES_DIR, inDevelopment, inTest } from '@/constants';
import * as Db from '@/db';
import { OnShutdown } from '@/decorators/on-shutdown';
import { ExternalHooks } from '@/external-hooks';
import { Logger } from '@/logger';
import { Logger } from '@/logging/logger.service';
import { rawBodyReader, bodyParser, corsMiddleware } from '@/middlewares';
import { send, sendErrorResponse } from '@/response-helper';
import { WaitingForms } from '@/waiting-forms';

View file

@ -13,12 +13,12 @@ import { Service } from 'typedi';
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
import { ExecutionNotFoundError } from '@/errors/execution-not-found-error';
import type {
ExecutionPayload,
CreateExecutionPayload,
IExecutingWorkflowData,
IExecutionDb,
IExecutionsCurrentSummary,
} from '@/interfaces';
import { Logger } from '@/logger';
import { Logger } from '@/logging/logger.service';
import { isWorkflowIdValid } from '@/utils';
import { ConcurrencyControlService } from './concurrency/concurrency-control.service';
@ -52,11 +52,10 @@ export class ActiveExecutions {
if (executionId === undefined) {
// Is a new execution so save in DB
const fullExecutionData: ExecutionPayload = {
const fullExecutionData: CreateExecutionPayload = {
data: executionData.executionData!,
mode,
finished: false,
startedAt: new Date(),
workflowData: executionData.workflowData,
status: executionStatus,
workflowId: executionData.workflowData.id,
@ -74,7 +73,10 @@ export class ActiveExecutions {
executionId = await this.executionRepository.createNewExecution(fullExecutionData);
assert(executionId);
await this.concurrencyControl.throttle({ mode, executionId });
if (config.getEnv('executions.mode') === 'regular') {
await this.concurrencyControl.throttle({ mode, executionId });
await this.executionRepository.setRunning(executionId);
}
executionStatus = 'running';
} else {
// Is an existing execution we want to finish so update in DB
@ -86,6 +88,7 @@ export class ActiveExecutions {
data: executionData.executionData!,
waitTill: null,
status: executionStatus,
// this is resuming, so keep `startedAt` as it was
};
await this.executionRepository.updateExistingExecution(executionId, execution);

View file

@ -37,6 +37,7 @@ import { WorkflowRepository } from '@/databases/repositories/workflow.repository
import { OnShutdown } from '@/decorators/on-shutdown';
import { ExternalHooks } from '@/external-hooks';
import type { IWorkflowDb } from '@/interfaces';
import { Logger } from '@/logging/logger.service';
import { NodeTypes } from '@/node-types';
import { ActiveWorkflowsService } from '@/services/active-workflows.service';
import { OrchestrationService } from '@/services/orchestration.service';
@ -47,7 +48,6 @@ import { WorkflowExecutionService } from '@/workflows/workflow-execution.service
import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service';
import { ExecutionService } from './executions/execution.service';
import { Logger } from './logger';
interface QueuedActivation {
activationMode: WorkflowActivateMode;
@ -750,7 +750,7 @@ export class ActiveWorkflowManager {
const wasRemoved = await this.activeWorkflows.remove(workflowId);
if (wasRemoved) {
this.logger.warn(`Removed triggers and pollers for workflow "${workflowId}"`, {
this.logger.debug(`Removed triggers and pollers for workflow "${workflowId}"`, {
workflowId,
});
}

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