diff --git a/.github/workflows/e2e-reusable.yml b/.github/workflows/e2e-reusable.yml index e69c4ab2609..dfd8fc93363 100644 --- a/.github/workflows/e2e-reusable.yml +++ b/.github/workflows/e2e-reusable.yml @@ -161,7 +161,6 @@ jobs: env: NODE_OPTIONS: --dns-result-order=ipv4first CYPRESS_NODE_VIEW_VERSION: 2 - N8N_FOLDERS_ENABLED: true CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} E2E_TESTS: true diff --git a/cypress/composables/folders.ts b/cypress/composables/folders.ts index 7d604e71bcd..56085c0a3e2 100644 --- a/cypress/composables/folders.ts +++ b/cypress/composables/folders.ts @@ -22,6 +22,31 @@ export function getFolderCards() { export function getFolderCard(name: string) { return cy.getByTestId('folder-card-name').contains(name).closest('[data-test-id="folder-card"]'); } + +export function getWorkflowCards() { + return cy.getByTestId('resources-list-item-workflow'); +} + +export function getWorkflowCard(name: string) { + return cy + .getByTestId('workflow-card-name') + .contains(name) + .closest('[data-test-id="resources-list-item-workflow"]'); +} + +export function getWorkflowCardActions(name: string) { + return getWorkflowCard(name).find('[data-test-id="workflow-card-actions"]'); +} + +export function getWorkflowCardActionItem(workflowName: string, actionName: string) { + return getWorkflowCardActions(workflowName) + .find('span[aria-controls]') + .invoke('attr', 'aria-controls') + .then((popperId) => { + return cy.get(`#${popperId}`).find(`[data-test-id="action-${actionName}"]`); + }); +} + export function getAddFolderButton() { return cy.getByTestId('add-folder-button'); } @@ -34,6 +59,10 @@ export function getHomeProjectBreadcrumb() { return getListBreadcrumbs().findChildByTestId('home-project'); } +export function getListBreadcrumbItem(name: string) { + return getListBreadcrumbs().findChildByTestId('breadcrumbs-item').contains(name); +} + export function getVisibleListBreadcrumbs() { return getListBreadcrumbs().findChildByTestId('breadcrumbs-item'); } @@ -94,13 +123,14 @@ export function getFolderCardActionToggle(folderName: string) { return getFolderCard(folderName).find('[data-test-id="folder-card-actions"]'); } -export function getFolderCardActionItem(name: string) { - return cy - .getByTestId('folder-card-actions') +export function getFolderCardActionItem(folderName: string, actionName: string) { + return getFolderCard(folderName) + .findChildByTestId('folder-card-actions') + .filter(':visible') .find('span[aria-controls]') .invoke('attr', 'aria-controls') .then((popperId) => { - return cy.get(`#${popperId}`).find(`[data-test-id="action-${name}"]`); + return cy.get(`#${popperId}`).find(`[data-test-id="action-${actionName}"]`); }); } @@ -108,10 +138,18 @@ export function getFolderDeleteModal() { return cy.getByTestId('deleteFolder-modal'); } +export function getMoveFolderModal() { + return cy.getByTestId('moveFolder-modal'); +} + export function getDeleteRadioButton() { return cy.getByTestId('delete-content-radio'); } +export function getTransferContentRadioButton() { + return cy.getByTestId('transfer-content-radio'); +} + export function getConfirmDeleteInput() { return getFolderDeleteModal().findChildByTestId('delete-data-input').find('input'); } @@ -119,6 +157,61 @@ export function getConfirmDeleteInput() { export function getDeleteFolderModalConfirmButton() { return getFolderDeleteModal().findChildByTestId('confirm-delete-folder-button'); } + +export function getProjectEmptyState() { + return cy.getByTestId('list-empty-state'); +} + +export function getFolderEmptyState() { + return cy.getByTestId('empty-folder-container'); +} + +export function getProjectMenuItem(name: string) { + if (name.toLowerCase() === 'personal') { + return getPersonalProjectMenuItem(); + } + return cy.getByTestId('project-menu-item').contains(name); +} + +export function getMoveToFolderDropdown() { + return cy.getByTestId('move-to-folder-dropdown'); +} + +export function getMoveToFolderOption(name: string) { + return cy.getByTestId('move-to-folder-option').contains(name); +} + +export function getMoveToFolderInput() { + return getMoveToFolderDropdown().find('input'); +} + +export function getEmptyFolderDropdownMessage(text: string) { + return cy.get('.el-select-dropdown__empty').contains(text); +} + +export function getMoveFolderConfirmButton() { + return cy.getByTestId('confirm-move-folder-button'); +} + +export function getMoveWorkflowModal() { + return cy.getByTestId('moveFolder-modal'); +} + +export function getWorkflowCardBreadcrumbs(workflowName: string) { + return getWorkflowCard(workflowName).find('[data-test-id="workflow-card-breadcrumbs"]'); +} + +export function getWorkflowCardBreadcrumbsEllipsis(workflowName: string) { + return getWorkflowCardBreadcrumbs(workflowName).find('[data-test-id="ellipsis"]'); +} + +export function getNewFolderNameInput() { + return cy.get('.add-folder-modal').filter(':visible').find('input.el-input__inner'); +} + +export function getNewFolderModalErrorMessage() { + return cy.get('.el-message-box__errormsg').filter(':visible'); +} /** * Actions */ @@ -136,8 +229,46 @@ export function createFolderFromListHeaderButton(folderName: string) { createNewFolder(folderName); } +export function createWorkflowFromEmptyState(workflowName?: string) { + getFolderEmptyState().find('button').contains('Create Workflow').click(); + if (workflowName) { + cy.getByTestId('workflow-name-input').type(`{selectAll}{backspace}${workflowName}`, { + delay: 50, + }); + } + cy.getByTestId('workflow-save-button').click(); + successToast().should('exist'); +} + +export function createWorkflowFromProjectHeader(folderName?: string, workflowName?: string) { + cy.getByTestId('add-resource-workflow').click(); + if (workflowName) { + cy.getByTestId('workflow-name-input').type(`{selectAll}{backspace}${workflowName}`, { + delay: 50, + }); + } + cy.getByTestId('workflow-save-button').click(); + if (folderName) { + successToast().should( + 'contain.text', + `Workflow successfully created in "Personal", within "${folderName}"`, + ); + } +} + +export function createWorkflowFromListDropdown(workflowName?: string) { + getListActionsToggle().click(); + getListActionItem('create_workflow').click(); + if (workflowName) { + cy.getByTestId('workflow-name-input').type(`{selectAll}{backspace}${workflowName}`, { + delay: 50, + }); + } + cy.getByTestId('workflow-save-button').click(); + successToast().should('exist'); +} + export function createFolderFromProjectHeader(folderName: string) { - getPersonalProjectMenuItem().click(); getAddResourceDropdown().click(); cy.getByTestId('action-folder').click(); createNewFolder(folderName); @@ -151,7 +282,7 @@ export function createFolderFromListDropdown(folderName: string) { export function createFolderFromCardActions(parentName: string, folderName: string) { getFolderCardActionToggle(parentName).click(); - getFolderCardActionItem('create').click(); + getFolderCardActionItem(parentName, 'create').click(); createNewFolder(folderName); } @@ -164,7 +295,7 @@ export function renameFolderFromListActions(folderName: string, newName: string) export function renameFolderFromCardActions(folderName: string, newName: string) { getFolderCardActionToggle(folderName).click(); - getFolderCardActionItem('rename').click(); + getFolderCardActionItem(folderName, 'rename').click(); renameFolder(newName); } @@ -194,9 +325,63 @@ export function deleteFolderWithContentsFromListDropdown(folderName: string) { export function deleteFolderWithContentsFromCardDropdown(folderName: string) { getFolderCardActionToggle(folderName).click(); - getFolderCardActionItem('delete').click(); + getFolderCardActionItem(folderName, 'delete').click(); confirmFolderDelete(folderName); } + +export function deleteAndTransferFolderContentsFromCardDropdown( + folderName: string, + destinationName: string, +) { + getFolderCardActionToggle(folderName).click(); + getFolderCardActionItem(folderName, 'delete').click(); + deleteFolderAndMoveContents(folderName, destinationName); +} + +export function deleteAndTransferFolderContentsFromListDropdown(destinationName: string) { + getListActionsToggle().click(); + getListActionItem('delete').click(); + getCurrentBreadcrumb() + .find('span') + .invoke('text') + .then((currentFolderName) => { + deleteFolderAndMoveContents(currentFolderName, destinationName); + }); +} + +export function createNewProject(projectName: string, options: { openAfterCreate?: boolean } = {}) { + cy.getByTestId('universal-add').should('exist').click(); + cy.getByTestId('navigation-menu-item').contains('Project').click(); + cy.getByTestId('project-settings-name-input').type(projectName, { delay: 50 }); + cy.getByTestId('project-settings-save-button').click(); + successToast().should('exist'); + if (options.openAfterCreate) { + getProjectMenuItem(projectName).click(); + } +} + +export function moveFolderFromFolderCardActions(folderName: string, destinationName: string) { + getFolderCardActionToggle(folderName).click(); + getFolderCardActionItem(folderName, 'move').click(); + moveFolder(folderName, destinationName); +} + +export function moveFolderFromListActions(folderName: string, destinationName: string) { + getFolderCard(folderName).click(); + getListActionsToggle().click(); + getListActionItem('move').click(); + moveFolder(folderName, destinationName); +} + +export function moveWorkflowToFolder(workflowName: string, folderName: string) { + getWorkflowCardActions(workflowName).click(); + getWorkflowCardActionItem(workflowName, 'moveToFolder').click(); + getMoveFolderModal().should('be.visible'); + getMoveToFolderDropdown().click(); + getMoveToFolderInput().type(folderName, { delay: 50 }); + getMoveToFolderOption(folderName).should('be.visible').click(); + getMoveFolderConfirmButton().should('be.enabled').click(); +} /** * Utils */ @@ -240,3 +425,34 @@ function confirmFolderDelete(folderName: string) { cy.wait('@deleteFolder'); successToast().contains('Folder deleted').should('exist'); } + +function deleteFolderAndMoveContents(folderName: string, destinationName: string) { + cy.intercept('DELETE', '/rest/projects/**').as('deleteFolder'); + getFolderDeleteModal().should('be.visible'); + getFolderDeleteModal().find('h1').first().contains(`Delete "${folderName}"`); + getTransferContentRadioButton().should('be.visible').click(); + getMoveToFolderDropdown().click(); + getMoveToFolderInput().type(destinationName); + getMoveToFolderOption(destinationName).click(); + getDeleteFolderModalConfirmButton().should('be.enabled').click(); + cy.wait('@deleteFolder'); + successToast().should('contain.text', `Data transferred to "${destinationName}"`); +} + +function moveFolder(folderName: string, destinationName: string) { + cy.intercept('PATCH', '/rest/projects/**').as('moveFolder'); + getMoveFolderModal().should('be.visible'); + getMoveFolderModal().find('h1').first().contains(`Move "${folderName}" to another folder`); + getMoveToFolderDropdown().click(); + // Try to find current folder in the dropdown + getMoveToFolderInput().type(folderName, { delay: 50 }); + // Should not be available + getEmptyFolderDropdownMessage('No folders found').should('exist'); + // Select destination folder + getMoveToFolderInput().type(`{selectall}{backspace}${destinationName}`, { + delay: 50, + }); + getMoveToFolderOption(destinationName).should('be.visible').click(); + getMoveFolderConfirmButton().should('be.enabled').click(); + cy.wait('@moveFolder'); +} diff --git a/cypress/composables/ndv.ts b/cypress/composables/ndv.ts index 36ae10669b4..1423801a5e6 100644 --- a/cypress/composables/ndv.ts +++ b/cypress/composables/ndv.ts @@ -105,11 +105,13 @@ export function getNodeOutputHint() { } export function getWorkflowCards() { - return cy.getByTestId('resources-list-item'); + return cy.getByTestId('resources-list-item-workflow'); } export function getWorkflowCard(workflowName: string) { - return getWorkflowCards().contains(workflowName).parents('[data-test-id="resources-list-item"]'); + return getWorkflowCards() + .contains(workflowName) + .parents('[data-test-id="resources-list-item-workflow"]'); } export function getWorkflowCardContent(workflowName: string) { diff --git a/cypress/e2e/30-editor-after-route-changes.cy.ts b/cypress/e2e/30-editor-after-route-changes.cy.ts index 89c64e1156c..6b069dfbf18 100644 --- a/cypress/e2e/30-editor-after-route-changes.cy.ts +++ b/cypress/e2e/30-editor-after-route-changes.cy.ts @@ -21,7 +21,7 @@ const switchBetweenEditorAndWorkflowlist = () => { cy.getByTestId('menu-item').first().click(); cy.wait(['@getUsers', '@getWorkflows', '@getActiveWorkflows', '@getProjects']); - cy.getByTestId('resources-list-item').first().click(); + cy.getByTestId('resources-list-item-workflow').first().click(); workflowPage.getters.canvasNodes().first().should('be.visible'); workflowPage.getters.canvasNodes().last().should('be.visible'); diff --git a/cypress/e2e/39-projects.cy.ts b/cypress/e2e/39-projects.cy.ts index b6dcb449da1..12881739076 100644 --- a/cypress/e2e/39-projects.cy.ts +++ b/cypress/e2e/39-projects.cy.ts @@ -514,7 +514,7 @@ describe('Projects', { disableAutoLogin: true }, () => { workflowsPage.getters.workflowCards().should('have.length', 3); workflowsPage.getters .workflowCards() - .filter(':has(.n8n-badge:contains("Project"))') + .filter(':has([data-test-id="workflow-card-breadcrumbs"]:contains("Project"))') .should('have.length', 2); workflowsPage.getters.workflowCardActions('Workflow in Home project').click(); workflowsPage.getters.workflowMoveButton().click(); diff --git a/cypress/e2e/49-folders.cy.ts b/cypress/e2e/49-folders.cy.ts index 8591daaaa04..a0ee846c1c7 100644 --- a/cypress/e2e/49-folders.cy.ts +++ b/cypress/e2e/49-folders.cy.ts @@ -4,6 +4,12 @@ import { createFolderFromListHeaderButton, createFolderFromProjectHeader, createFolderInsideFolder, + createNewProject, + createWorkflowFromEmptyState, + createWorkflowFromListDropdown, + createWorkflowFromProjectHeader, + deleteAndTransferFolderContentsFromCardDropdown, + deleteAndTransferFolderContentsFromListDropdown, deleteEmptyFolderFromCardDropdown, deleteEmptyFolderFromListDropdown, deleteFolderWithContentsFromCardDropdown, @@ -14,14 +20,27 @@ import { getFolderCardActionItem, getFolderCardActionToggle, getFolderCards, + getFolderEmptyState, getHomeProjectBreadcrumb, + getListBreadcrumbItem, getListBreadcrumbs, getMainBreadcrumbsEllipsis, getMainBreadcrumbsEllipsisMenuItems, + getNewFolderModalErrorMessage, + getNewFolderNameInput, getOverviewMenuItem, getPersonalProjectMenuItem, + getProjectEmptyState, + getProjectMenuItem, getVisibleListBreadcrumbs, + getWorkflowCard, + getWorkflowCardBreadcrumbs, + getWorkflowCardBreadcrumbsEllipsis, + getWorkflowCards, goToPersonalProject, + moveFolderFromFolderCardActions, + moveFolderFromListActions, + moveWorkflowToFolder, renameFolderFromCardActions, renameFolderFromListActions, } from '../composables/folders'; @@ -32,6 +51,7 @@ describe('Folders', () => { before(() => { cy.resetDatabase(); cy.enableFeature('sharing'); + cy.enableFeature('folders'); cy.enableFeature('advancedPermissions'); cy.enableFeature('projectRole:admin'); cy.enableFeature('projectRole:editor'); @@ -44,6 +64,7 @@ describe('Folders', () => { describe('Create and navigate folders', () => { it('should create folder from the project header', () => { + getPersonalProjectMenuItem().click(); createFolderFromProjectHeader('My Folder'); getFolderCards().should('have.length.greaterThan', 0); // Clicking on the success toast should navigate to the folder @@ -51,6 +72,33 @@ describe('Folders', () => { getCurrentBreadcrumb().should('contain.text', 'My Folder'); }); + it('should not allow illegal folder names', () => { + // Validation logic is thoroughly tested in unit tests + // Here we just make sure everything is working in the full UI + const ILLEGAL_CHARACTERS_NAME = 'hello['; + const ONLY_DOTS_NAME = '...'; + const REGULAR_NAME = 'My Folder'; + + getPersonalProjectMenuItem().click(); + getAddResourceDropdown().click(); + cy.getByTestId('action-folder').click(); + getNewFolderNameInput().type(ILLEGAL_CHARACTERS_NAME, { delay: 50 }); + getNewFolderModalErrorMessage().should( + 'contain.text', + 'Folder name cannot contain the following characters', + ); + getNewFolderNameInput().clear(); + getNewFolderNameInput().type(ONLY_DOTS_NAME, { delay: 50 }); + getNewFolderModalErrorMessage().should( + 'contain.text', + 'Folder name cannot contain only dots', + ); + getNewFolderNameInput().clear(); + getNewFolderModalErrorMessage().should('contain.text', 'Folder name cannot be empty'); + getNewFolderNameInput().type(REGULAR_NAME, { delay: 50 }); + getNewFolderModalErrorMessage().should('not.exist'); + }); + it('should create folder from the list header button', () => { goToPersonalProject(); // First create a folder so list appears @@ -78,9 +126,9 @@ describe('Folders', () => { getFolderCard('Created from card dropdown').should('exist'); createFolderFromCardActions('Created from card dropdown', 'Child Folder'); successToast().should('exist'); - // Open parent folder to see the new child folder - getFolderCard('Created from card dropdown').click(); + // Should be automatically navigated to the new folder getFolderCard('Child Folder').should('exist'); + getCurrentBreadcrumb().should('contain.text', 'Created from card dropdown'); }); it('should navigate folders using breadcrumbs and dropdown menu', () => { @@ -88,7 +136,7 @@ describe('Folders', () => { createFolderFromProjectHeader('Navigate Test'); // Open folder using menu item getFolderCardActionToggle('Navigate Test').click(); - getFolderCardActionItem('open').click(); + getFolderCardActionItem('Navigate Test', 'open').click(); getCurrentBreadcrumb().should('contain.text', 'Navigate Test'); // Create new child folder and navigate to it createFolderFromListHeaderButton('Child Folder'); @@ -165,12 +213,72 @@ describe('Folders', () => { // In personal, we should see previously created folders getPersonalProjectMenuItem().click(); + getAddResourceDropdown().click(); cy.getByTestId('action-folder').should('exist'); createFolderFromProjectHeader('Personal Folder'); getFolderCards().should('exist'); }); }); + describe('Empty State', () => { + it('should show project empty state when no folders exist', () => { + createNewProject('Test empty project', { openAfterCreate: true }); + getProjectEmptyState().should('exist'); + }); + + it('should toggle folder empty state correctly', () => { + createNewProject('Test empty folder', { openAfterCreate: true }); + createFolderFromProjectHeader('My Folder'); + getProjectEmptyState().should('not.exist'); + getFolderCard('My Folder').should('exist'); + getFolderCard('My Folder').click(); + getFolderEmptyState().should('exist'); + // Create a new workflow from the empty state + createWorkflowFromEmptyState('My Workflow'); + // Toast should inform that the workflow was created in the folder + successToast().should( + 'contain.text', + 'Workflow successfully created in "Test empty folder", within "My Folder"', + ); + // Go back to the folder + getProjectMenuItem('Test empty folder').click(); + getFolderCard('My Folder').should('exist'); + getFolderCard('My Folder').click(); + // Should not show empty state anymore + getFolderEmptyState().should('not.exist'); + getWorkflowCards().should('have.length.greaterThan', 0); + // Also when filtering and there are no results, empty state CTA should not show + cy.getByTestId('resources-list-search').type('non-existing', { delay: 20 }); + getWorkflowCards().should('not.exist'); + getFolderEmptyState().should('not.exist'); + // But there should be a message saying that no results were found + cy.getByTestId('resources-list-empty').should('exist'); + }); + }); + + describe('Create workflows inside folders', () => { + it('should create workflows in folders in all supported ways', () => { + goToPersonalProject(); + createFolderFromProjectHeader('Workflows go here'); + // 1. From empty state + getFolderCard('Workflows go here').should('exist').click(); + createWorkflowFromEmptyState('Created from empty state'); + goToPersonalProject(); + getFolderCard('Workflows go here').click(); + getWorkflowCard('Created from empty state').should('exist'); + // 2. From the project header + createWorkflowFromProjectHeader('Workflows go here', 'Created from project header'); + goToPersonalProject(); + getFolderCard('Workflows go here').click(); + getWorkflowCard('Created from project header').should('exist'); + // 3. From list breadcrumbs + createWorkflowFromListDropdown('Created from list breadcrumbs'); + goToPersonalProject(); + getFolderCard('Workflows go here').click(); + getWorkflowCard('Created from list breadcrumbs').should('exist'); + }); + }); + describe('Rename and delete folders', () => { it('should rename folder from main dropdown', () => { goToPersonalProject(); @@ -224,6 +332,175 @@ describe('Folders', () => { deleteFolderWithContentsFromCardDropdown('I also have family'); }); - // TODO: Once we have backend endpoint that lists project folders, test transfer when deleting + it('should transfer contents when deleting non-empty folder - from card dropdown', () => { + goToPersonalProject(); + createFolderFromProjectHeader('Move my contents'); + createFolderFromProjectHeader('Destination'); + createFolderInsideFolder('Child 1', 'Move my contents'); + getHomeProjectBreadcrumb().click(); + getFolderCard('Move my contents').should('exist'); + deleteAndTransferFolderContentsFromCardDropdown('Move my contents', 'Destination'); + getFolderCard('Destination').click(); + // Should show the contents of the moved folder + getFolderCard('Child 1').should('exist'); + }); + + it('should transfer contents when deleting non-empty folder - from list breadcrumbs', () => { + goToPersonalProject(); + createFolderFromProjectHeader('Move me too'); + createFolderFromProjectHeader('Destination 2'); + createFolderInsideFolder('Child 1', 'Move me too'); + deleteAndTransferFolderContentsFromListDropdown('Destination 2'); + getFolderCard('Destination').click(); + // Should show the contents of the moved folder + getFolderCard('Child 1').should('exist'); + }); + }); + + describe('Move folders and workflows', () => { + it('should move empty folder to another folder - from folder card action', () => { + goToPersonalProject(); + createFolderFromProjectHeader('Move me - I am empty'); + createFolderFromProjectHeader('Destination 3'); + moveFolderFromFolderCardActions('Move me - I am empty', 'Destination 3'); + getFolderCard('Destination 3').click(); + getFolderCard('Move me - I am empty').should('exist'); + getFolderCard('Move me - I am empty').click(); + getFolderEmptyState().should('exist'); + successToast().should('contain.text', 'Move me - I am empty has been moved to Destination 3'); + // Breadcrumbs should show the destination folder + getListBreadcrumbItem('Destination 3').should('exist'); + }); + + it('should move folder with contents to another folder - from folder card action', () => { + goToPersonalProject(); + createFolderFromProjectHeader('Move me - I have family'); + createFolderFromProjectHeader('Destination 4'); + // Create a workflow and a folder inside the folder + createFolderInsideFolder('Child 1', 'Move me - I have family'); + createWorkflowFromProjectHeader('Move me - I have family'); + goToPersonalProject(); + // Move the folder + moveFolderFromFolderCardActions('Move me - I have family', 'Destination 4'); + successToast().should( + 'contain.text', + 'Move me - I have family has been moved to Destination 4', + ); + // Go to destination folder and check if contents are there + getFolderCard('Destination 4').click(); + // Moved folder should be there + getFolderCard('Move me - I have family').should('exist').click(); + // Both the workflow and the folder should be there + getFolderCards().should('have.length', 1); + getWorkflowCards().should('have.length', 1); + // Breadcrumbs should show the destination folder + getListBreadcrumbItem('Destination 4').should('exist'); + }); + + it('should move empty folder to another folder - from list breadcrumbs', () => { + goToPersonalProject(); + createFolderFromProjectHeader('Move me too - I am empty'); + createFolderFromProjectHeader('Destination 5'); + moveFolderFromListActions('Move me too - I am empty', 'Destination 5'); + // Since we moved the current folder, we should be in the destination folder + getCurrentBreadcrumb().should('contain.text', 'Destination 5'); + }); + + it('should move folder with contents to another folder - from list dropdown', () => { + goToPersonalProject(); + createFolderFromProjectHeader('Move me - I have family 2'); + createFolderFromProjectHeader('Destination 6'); + // Create a workflow and a folder inside the folder + createFolderInsideFolder('Child 1', 'Move me - I have family 2'); + createWorkflowFromProjectHeader('Move me - I have family 2'); + // Navigate back to folder + goToPersonalProject(); + getFolderCard('Move me - I have family 2').should('exist'); + // Move the folder + moveFolderFromListActions('Move me - I have family 2', 'Destination 6'); + // Since we moved the current folder, we should be in the destination folder + getCurrentBreadcrumb().should('contain.text', 'Destination 6'); + // Moved folder should be there + getFolderCard('Move me - I have family 2').should('exist').click(); + // After navigating to the moved folder, both the workflow and the folder should be there + getFolderCards().should('have.length', 1); + getWorkflowCards().should('have.length', 1); + // Breadcrumbs should show the destination folder + getListBreadcrumbItem('Destination 6').should('exist'); + }); + + it('should move folder to project root - from folder card action', () => { + goToPersonalProject(); + createFolderFromProjectHeader('Test parent'); + createFolderInsideFolder('Move me to root', 'Test parent'); + moveFolderFromFolderCardActions('Move me to root', 'Personal'); + // Parent folder should be empty + getFolderEmptyState().should('exist'); + // Child folder should be in the root + goToPersonalProject(); + getFolderCard('Move me to root').should('exist'); + // Navigate to the moved folder and check breadcrumbs + getFolderCard('Move me to root').click(); + getHomeProjectBreadcrumb().should('contain.text', 'Personal'); + getListBreadcrumbs().findChildByTestId('breadcrumbs-item').should('not.exist'); + getCurrentBreadcrumb().should('contain.text', 'Move me to root'); + }); + + it('should move workflow from project root to folder', () => { + goToPersonalProject(); + createWorkflowFromProjectHeader(undefined, 'Move me'); + goToPersonalProject(); + createFolderFromProjectHeader('Workflow destination'); + moveWorkflowToFolder('Move me', 'Workflow destination'); + successToast().should('contain.text', 'Move me has been moved to Workflow destination'); + // Navigate to the destination folder + getFolderCard('Workflow destination').click(); + // Moved workflow should be there + getWorkflowCards().should('have.length', 1); + getWorkflowCard('Move me').should('exist'); + }); + + it('should move workflow to another folder', () => { + goToPersonalProject(); + createFolderFromProjectHeader('Moving workflow from here'); + createFolderFromProjectHeader('Moving workflow to here'); + getFolderCard('Moving workflow from here').click(); + createWorkflowFromProjectHeader(undefined, 'Move me'); + goToPersonalProject(); + getFolderCard('Moving workflow from here').click(); + getWorkflowCard('Move me').should('exist'); + moveWorkflowToFolder('Move me', 'Moving workflow to here'); + // Now folder should be empty + getFolderEmptyState().should('exist'); + // Navigate to the destination folder + getHomeProjectBreadcrumb().click(); + getFolderCard('Moving workflow to here').click(); + // Moved workflow should be there + getWorkflowCards().should('have.length', 1); + getWorkflowCard('Move me').should('exist'); + }); + }); + + describe('Workflow card breadcrumbs', () => { + it('should correctly show workflow card breadcrumbs', () => { + createNewProject('Test workflow breadcrumbs', { openAfterCreate: true }); + createFolderFromProjectHeader('Parent Folder'); + createFolderInsideFolder('Child Folder', 'Parent Folder'); + getFolderCard('Child Folder').click(); + createFolderFromListHeaderButton('Child Folder 2'); + getFolderCard('Child Folder 2').click(); + createWorkflowFromEmptyState('Breadcrumbs Test'); + // Go to overview page + getOverviewMenuItem().click(); + getWorkflowCard('Breadcrumbs Test').should('exist'); + getWorkflowCardBreadcrumbs('Breadcrumbs Test').should('exist'); + getWorkflowCardBreadcrumbsEllipsis('Breadcrumbs Test').should('exist'); + getWorkflowCardBreadcrumbsEllipsis('Breadcrumbs Test').realHover({ position: 'topLeft' }); + cy.get('[role=tooltip]').should('exist'); + cy.get('[role=tooltip]').should( + 'contain.text', + 'est workflow breadcrumbs / Parent Folder / Child Folder / Child Folder 2', + ); + }); }); }); diff --git a/cypress/pages/workflows.ts b/cypress/pages/workflows.ts index 5e7a298890c..a57d9d22eaf 100644 --- a/cypress/pages/workflows.ts +++ b/cypress/pages/workflows.ts @@ -19,12 +19,12 @@ export class WorkflowsPage extends BasePage { cy.getByTestId('add-resource-workflow').should('be.visible'); return cy.getByTestId('add-resource-workflow'); }, - workflowCards: () => cy.getByTestId('resources-list-item'), + workflowCards: () => cy.getByTestId('resources-list-item-workflow'), workflowCard: (workflowName: string) => this.getters .workflowCards() .contains(workflowName) - .parents('[data-test-id="resources-list-item"]'), + .parents('[data-test-id="resources-list-item-workflow"]'), workflowTags: (workflowName: string) => this.getters.workflowCard(workflowName).findChildByTestId('workflow-card-tags'), workflowCardContent: (workflowName: string) => diff --git a/cypress/scripts/run-e2e.js b/cypress/scripts/run-e2e.js index 545b88bcf9f..c7f9ccf7490 100755 --- a/cypress/scripts/run-e2e.js +++ b/cypress/scripts/run-e2e.js @@ -47,7 +47,6 @@ switch (scenario) { testCommand: 'cypress open', customEnv: { CYPRESS_NODE_VIEW_VERSION: 2, - N8N_FOLDERS_ENABLED: true, }, }); break; @@ -59,7 +58,6 @@ switch (scenario) { customEnv: { CYPRESS_NODE_VIEW_VERSION: 1, CYPRESS_BASE_URL: 'http://localhost:8080', - N8N_FOLDERS_ENABLED: true, }, }); break; @@ -71,7 +69,6 @@ switch (scenario) { customEnv: { CYPRESS_NODE_VIEW_VERSION: 2, CYPRESS_BASE_URL: 'http://localhost:8080', - N8N_FOLDERS_ENABLED: true, }, }); break; @@ -85,7 +82,6 @@ switch (scenario) { testCommand: `cypress run --headless ${specParam}`, customEnv: { CYPRESS_NODE_VIEW_VERSION: 2, - N8N_FOLDERS_ENABLED: true, }, }); break; diff --git a/packages/@n8n/api-types/src/schemas/source-controlled-file.schema.ts b/packages/@n8n/api-types/src/schemas/source-controlled-file.schema.ts index 31f2db8f92a..8488469d35c 100644 --- a/packages/@n8n/api-types/src/schemas/source-controlled-file.schema.ts +++ b/packages/@n8n/api-types/src/schemas/source-controlled-file.schema.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -const FileTypeSchema = z.enum(['credential', 'workflow', 'tags', 'variables', 'file']); +const FileTypeSchema = z.enum(['credential', 'workflow', 'tags', 'variables', 'file', 'folders']); export const SOURCE_CONTROL_FILE_TYPE = FileTypeSchema.Values; const FileStatusSchema = z.enum([ diff --git a/packages/@n8n/config/src/configs/external-hooks.config.ts b/packages/@n8n/config/src/configs/external-hooks.config.ts index 20c8eb86549..0ecb31294d5 100644 --- a/packages/@n8n/config/src/configs/external-hooks.config.ts +++ b/packages/@n8n/config/src/configs/external-hooks.config.ts @@ -1,14 +1,6 @@ +import { ColonSeparatedStringArray } from '../custom-types'; import { Config, Env } from '../decorators'; -class ColonSeparatedStringArray extends Array { - constructor(str: string) { - super(); - const parsed = str.split(':') as this; - const filtered = parsed.filter((i) => typeof i === 'string' && i.length); - return filtered.length ? filtered : []; - } -} - @Config export class ExternalHooksConfig { /** Files containing external hooks. Multiple files can be separated by colon (":") */ diff --git a/packages/@n8n/config/src/configs/logging.config.ts b/packages/@n8n/config/src/configs/logging.config.ts index 733278c3e35..75c1836d2d5 100644 --- a/packages/@n8n/config/src/configs/logging.config.ts +++ b/packages/@n8n/config/src/configs/logging.config.ts @@ -1,5 +1,5 @@ +import { CommaSeperatedStringArray } from '../custom-types'; import { Config, Env, Nested } from '../decorators'; -import { StringArray } from '../utils'; /** Scopes (areas of functionality) to filter logs by. */ export const LOG_SCOPES = [ @@ -14,6 +14,7 @@ export const LOG_SCOPES = [ 'scaling', 'waiting-executions', 'task-runner', + 'insights', ] as const; export type LogScope = (typeof LOG_SCOPES)[number]; @@ -57,7 +58,7 @@ export class LoggingConfig { * @example `N8N_LOG_OUTPUT=console,file` will output to both console and file. */ @Env('N8N_LOG_OUTPUT') - outputs: StringArray<'console' | 'file'> = ['console']; + outputs: CommaSeperatedStringArray<'console' | 'file'> = ['console']; @Nested file: FileLoggingConfig; @@ -84,5 +85,5 @@ export class LoggingConfig { * `N8N_LOG_SCOPES=license,waiting-executions` */ @Env('N8N_LOG_SCOPES') - scopes: StringArray = []; + scopes: CommaSeperatedStringArray = []; } diff --git a/packages/@n8n/config/src/custom-types.ts b/packages/@n8n/config/src/custom-types.ts new file mode 100644 index 00000000000..b89cad1597a --- /dev/null +++ b/packages/@n8n/config/src/custom-types.ts @@ -0,0 +1,19 @@ +abstract class StringArray extends Array { + constructor(str: string, delimiter: string) { + super(); + const parsed = str.split(delimiter) as this; + return parsed.filter((i) => typeof i === 'string' && i.length); + } +} + +export class CommaSeperatedStringArray extends StringArray { + constructor(str: string) { + super(str, ','); + } +} + +export class ColonSeparatedStringArray extends StringArray { + constructor(str: string) { + super(str, ':'); + } +} diff --git a/packages/@n8n/config/src/index.ts b/packages/@n8n/config/src/index.ts index e86a6ab478c..bc7d88ae60b 100644 --- a/packages/@n8n/config/src/index.ts +++ b/packages/@n8n/config/src/index.ts @@ -36,6 +36,7 @@ export { S3Config } from './configs/external-storage.config'; export { LOG_SCOPES } from './configs/logging.config'; export type { LogScope } from './configs/logging.config'; export { WorkflowsConfig } from './configs/workflows.config'; +export * from './custom-types'; @Config export class GlobalConfig { diff --git a/packages/@n8n/config/src/utils.ts b/packages/@n8n/config/src/utils.ts deleted file mode 100644 index c90fcb8266c..00000000000 --- a/packages/@n8n/config/src/utils.ts +++ /dev/null @@ -1,7 +0,0 @@ -export class StringArray extends Array { - constructor(str: string) { - super(); - const parsed = str.split(',') as StringArray; - return parsed.every((i) => typeof i === 'string') ? parsed : []; - } -} diff --git a/packages/@n8n/config/test/custom-types.test.ts b/packages/@n8n/config/test/custom-types.test.ts new file mode 100644 index 00000000000..0e1ca808724 --- /dev/null +++ b/packages/@n8n/config/test/custom-types.test.ts @@ -0,0 +1,25 @@ +import { CommaSeperatedStringArray, ColonSeparatedStringArray } from '../src/custom-types'; + +describe('CommaSeperatedStringArray', () => { + it('should parse comma-separated string into array', () => { + const result = new CommaSeperatedStringArray('a,b,c'); + expect(result).toEqual(['a', 'b', 'c']); + }); + + it('should handle empty strings', () => { + const result = new CommaSeperatedStringArray('a,b,,,'); + expect(result).toEqual(['a', 'b']); + }); +}); + +describe('ColonSeparatedStringArray', () => { + it('should parse colon-separated string into array', () => { + const result = new ColonSeparatedStringArray('a:b:c'); + expect(result).toEqual(['a', 'b', 'c']); + }); + + it('should handle empty strings', () => { + const result = new ColonSeparatedStringArray('a::b:::'); + expect(result).toEqual(['a', 'b']); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts index fbb48d07d7e..2830ca02c30 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts @@ -1,4 +1,4 @@ -import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; +import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; import type { INodeInputConfiguration, INodeInputFilter, @@ -7,6 +7,7 @@ import type { INodeType, INodeTypeDescription, INodeProperties, + NodeConnectionType, } from 'n8n-workflow'; import { promptTypeOptions, textFromPreviousNode, textInput } from '@utils/descriptions'; @@ -46,14 +47,14 @@ function getInputs( inputs: SpecialInput[], ): Array => { const displayNames: { [key: string]: string } = { - [NodeConnectionType.AiLanguageModel]: 'Model', - [NodeConnectionType.AiMemory]: 'Memory', - [NodeConnectionType.AiTool]: 'Tool', - [NodeConnectionType.AiOutputParser]: 'Output Parser', + ai_languageModel: 'Model', + ai_memory: 'Memory', + ai_tool: 'Tool', + ai_outputParser: 'Output Parser', }; return inputs.map(({ type, filter }) => { - const isModelType = type === NodeConnectionType.AiLanguageModel; + const isModelType = type === ('ai_languageModel' as NodeConnectionType); let displayName = type in displayNames ? displayNames[type] : undefined; if ( isModelType && @@ -65,11 +66,9 @@ function getInputs( type, displayName, required: isModelType, - maxConnections: [ - NodeConnectionType.AiLanguageModel, - NodeConnectionType.AiMemory, - NodeConnectionType.AiOutputParser, - ].includes(type as NodeConnectionType) + maxConnections: ['ai_languageModel', 'ai_memory', 'ai_outputParser'].includes( + type as NodeConnectionType, + ) ? 1 : undefined, }; @@ -87,7 +86,7 @@ function getInputs( if (agent === 'conversationalAgent') { specialInputs = [ { - type: NodeConnectionType.AiLanguageModel, + type: 'ai_languageModel', filter: { nodes: [ '@n8n/n8n-nodes-langchain.lmChatAnthropic', @@ -105,19 +104,19 @@ function getInputs( }, }, { - type: NodeConnectionType.AiMemory, + type: 'ai_memory', }, { - type: NodeConnectionType.AiTool, + type: 'ai_tool', }, { - type: NodeConnectionType.AiOutputParser, + type: 'ai_outputParser', }, ]; } else if (agent === 'toolsAgent') { specialInputs = [ { - type: NodeConnectionType.AiLanguageModel, + type: 'ai_languageModel', filter: { nodes: [ '@n8n/n8n-nodes-langchain.lmChatAnthropic', @@ -135,20 +134,20 @@ function getInputs( }, }, { - type: NodeConnectionType.AiMemory, + type: 'ai_memory', }, { - type: NodeConnectionType.AiTool, + type: 'ai_tool', required: true, }, { - type: NodeConnectionType.AiOutputParser, + type: 'ai_outputParser', }, ]; } else if (agent === 'openAiFunctionsAgent') { specialInputs = [ { - type: NodeConnectionType.AiLanguageModel, + type: 'ai_languageModel', filter: { nodes: [ '@n8n/n8n-nodes-langchain.lmChatOpenAi', @@ -157,57 +156,55 @@ function getInputs( }, }, { - type: NodeConnectionType.AiMemory, + type: 'ai_memory', }, { - type: NodeConnectionType.AiTool, + type: 'ai_tool', required: true, }, { - type: NodeConnectionType.AiOutputParser, + type: 'ai_outputParser', }, ]; } else if (agent === 'reActAgent') { specialInputs = [ { - type: NodeConnectionType.AiLanguageModel, + type: 'ai_languageModel', }, { - type: NodeConnectionType.AiTool, + type: 'ai_tool', }, { - type: NodeConnectionType.AiOutputParser, + type: 'ai_outputParser', }, ]; } else if (agent === 'sqlAgent') { specialInputs = [ { - type: NodeConnectionType.AiLanguageModel, + type: 'ai_languageModel', }, { - type: NodeConnectionType.AiMemory, + type: 'ai_memory', }, ]; } else if (agent === 'planAndExecuteAgent') { specialInputs = [ { - type: NodeConnectionType.AiLanguageModel, + type: 'ai_languageModel', }, { - type: NodeConnectionType.AiTool, + type: 'ai_tool', }, { - type: NodeConnectionType.AiOutputParser, + type: 'ai_outputParser', }, ]; } if (hasOutputParser === false) { - specialInputs = specialInputs.filter( - (input) => input.type !== NodeConnectionType.AiOutputParser, - ); + specialInputs = specialInputs.filter((input) => input.type !== 'ai_outputParser'); } - return [NodeConnectionType.Main, ...getInputData(specialInputs)]; + return ['main', ...getInputData(specialInputs)]; } const agentTypeProperty: INodeProperties = { @@ -292,7 +289,7 @@ export class Agent implements INodeType { return getInputs(agent, hasOutputParser) })($parameter.agent, $parameter.hasOutputParser === undefined || $parameter.hasOutputParser === true) }}`, - outputs: [NodeConnectionType.Main], + outputs: [NodeConnectionTypes.Main], credentials: [ { // eslint-disable-next-line n8n-nodes-base/node-class-description-credentials-name-unsuffixed @@ -432,7 +429,7 @@ export class Agent implements INodeType { }, }, { - displayName: `Connect an output parser on the canvas to specify the output format you require`, + displayName: `Connect an output parser on the canvas to specify the output format you require`, name: 'notice', type: 'notice', default: '', diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/execute.ts index ba90ddfea51..b7197cebdff 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/execute.ts @@ -2,7 +2,7 @@ import type { BaseChatMemory } from '@langchain/community/memory/chat_memory'; import { PromptTemplate } from '@langchain/core/prompts'; import { initializeAgentExecutorWithOptions } from 'langchain/agents'; import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; -import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; +import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; import { isChatInstance, getPromptInputByType, getConnectedTools } from '@utils/helpers'; import { getOptionalOutputParser } from '@utils/output_parsers/N8nOutputParser'; @@ -16,13 +16,13 @@ export async function conversationalAgentExecute( nodeVersion: number, ): Promise { this.logger.debug('Executing Conversational Agent'); - const model = await this.getInputConnectionData(NodeConnectionType.AiLanguageModel, 0); + const model = await this.getInputConnectionData(NodeConnectionTypes.AiLanguageModel, 0); if (!isChatInstance(model)) { throw new NodeOperationError(this.getNode(), 'Conversational Agent requires Chat Model'); } - const memory = (await this.getInputConnectionData(NodeConnectionType.AiMemory, 0)) as + const memory = (await this.getInputConnectionData(NodeConnectionTypes.AiMemory, 0)) as | BaseChatMemory | undefined; diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts index cfc3ff5a030..d71ff645ddb 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts @@ -6,7 +6,7 @@ import { BufferMemory, type BaseChatMemory } from 'langchain/memory'; import { type IExecuteFunctions, type INodeExecutionData, - NodeConnectionType, + NodeConnectionTypes, NodeOperationError, } from 'n8n-workflow'; @@ -22,7 +22,7 @@ export async function openAiFunctionsAgentExecute( ): Promise { this.logger.debug('Executing OpenAi Functions Agent'); const model = (await this.getInputConnectionData( - NodeConnectionType.AiLanguageModel, + NodeConnectionTypes.AiLanguageModel, 0, )) as ChatOpenAI; @@ -32,7 +32,7 @@ export async function openAiFunctionsAgentExecute( 'OpenAI Functions Agent requires OpenAI Chat Model', ); } - const memory = (await this.getInputConnectionData(NodeConnectionType.AiMemory, 0)) as + const memory = (await this.getInputConnectionData(NodeConnectionTypes.AiMemory, 0)) as | BaseChatMemory | undefined; const tools = await getConnectedTools(this, nodeVersion >= 1.5, false); diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/PlanAndExecuteAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/PlanAndExecuteAgent/execute.ts index 8a73b2a2dbc..576d25d0cc9 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/PlanAndExecuteAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/PlanAndExecuteAgent/execute.ts @@ -4,7 +4,7 @@ import { PlanAndExecuteAgentExecutor } from 'langchain/experimental/plan_and_exe import { type IExecuteFunctions, type INodeExecutionData, - NodeConnectionType, + NodeConnectionTypes, NodeOperationError, } from 'n8n-workflow'; @@ -21,7 +21,7 @@ export async function planAndExecuteAgentExecute( ): Promise { this.logger.debug('Executing PlanAndExecute Agent'); const model = (await this.getInputConnectionData( - NodeConnectionType.AiLanguageModel, + NodeConnectionTypes.AiLanguageModel, 0, )) as BaseChatModel; diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/execute.ts index 117cd306c58..21a358c327b 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/execute.ts @@ -5,7 +5,7 @@ import { AgentExecutor, ChatAgent, ZeroShotAgent } from 'langchain/agents'; import { type IExecuteFunctions, type INodeExecutionData, - NodeConnectionType, + NodeConnectionTypes, NodeOperationError, } from 'n8n-workflow'; @@ -22,7 +22,7 @@ export async function reActAgentAgentExecute( ): Promise { this.logger.debug('Executing ReAct Agent'); - const model = (await this.getInputConnectionData(NodeConnectionType.AiLanguageModel, 0)) as + const model = (await this.getInputConnectionData(NodeConnectionTypes.AiLanguageModel, 0)) as | BaseLanguageModel | BaseChatModel; diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/execute.ts index 369ca109af4..e61439bcdaf 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/execute.ts @@ -7,7 +7,7 @@ import { SqlDatabase } from 'langchain/sql_db'; import { type IExecuteFunctions, type INodeExecutionData, - NodeConnectionType, + NodeConnectionTypes, NodeOperationError, type IDataObject, } from 'n8n-workflow'; @@ -32,7 +32,7 @@ export async function sqlAgentAgentExecute( this.logger.debug('Executing SQL Agent'); const model = (await this.getInputConnectionData( - NodeConnectionType.AiLanguageModel, + NodeConnectionTypes.AiLanguageModel, 0, )) as BaseLanguageModel; const items = this.getInputData(); @@ -113,7 +113,7 @@ export async function sqlAgentAgentExecute( const toolkit = new SqlToolkit(dbInstance, model); const agentExecutor = createSqlAgent(model, toolkit, agentOptions); - const memory = (await this.getInputConnectionData(NodeConnectionType.AiMemory, 0)) as + const memory = (await this.getInputConnectionData(NodeConnectionTypes.AiMemory, 0)) as | BaseChatMemory | undefined; diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts index adbf9de87bf..db1e0ffbe76 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts @@ -11,7 +11,7 @@ import type { AgentAction, AgentFinish } from 'langchain/agents'; import { AgentExecutor, createToolCallingAgent } from 'langchain/agents'; import type { ToolsAgentAction } from 'langchain/dist/agents/tool_calling/output_parser'; import { omit } from 'lodash'; -import { BINARY_ENCODING, jsonParse, NodeConnectionType, NodeOperationError } from 'n8n-workflow'; +import { BINARY_ENCODING, jsonParse, NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; import type { ZodObject } from 'zod'; import { z } from 'zod'; @@ -275,7 +275,7 @@ export const getAgentStepsParser = * @returns The validated chat model */ export async function getChatModel(ctx: IExecuteFunctions): Promise { - const model = await ctx.getInputConnectionData(NodeConnectionType.AiLanguageModel, 0); + const model = await ctx.getInputConnectionData(NodeConnectionTypes.AiLanguageModel, 0); if (!isChatInstance(model) || !model.bindTools) { throw new NodeOperationError( ctx.getNode(), @@ -294,7 +294,7 @@ export async function getChatModel(ctx: IExecuteFunctions): Promise { - return (await ctx.getInputConnectionData(NodeConnectionType.AiMemory, 0)) as + return (await ctx.getInputConnectionData(NodeConnectionTypes.AiMemory, 0)) as | BaseChatMemory | undefined; } diff --git a/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts index e44ad8f9d23..10f8808ff97 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts @@ -1,7 +1,7 @@ import { AgentExecutor } from 'langchain/agents'; import type { OpenAIToolType } from 'langchain/dist/experimental/openai_assistant/schema'; import { OpenAIAssistantRunnable } from 'langchain/experimental/openai_assistant'; -import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; +import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; import type { IExecuteFunctions, INodeExecutionData, @@ -44,10 +44,10 @@ export class OpenAiAssistant implements INodeType { }, }, inputs: [ - { type: NodeConnectionType.Main }, - { type: NodeConnectionType.AiTool, displayName: 'Tools' }, + { type: NodeConnectionTypes.Main }, + { type: NodeConnectionTypes.AiTool, displayName: 'Tools' }, ], - outputs: [NodeConnectionType.Main], + outputs: [NodeConnectionTypes.Main], credentials: [ { name: 'openAiApi', diff --git a/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/ChainLlm.node.ts b/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/ChainLlm.node.ts index 06f79f0b9ba..1419e5823cd 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/ChainLlm.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/ChainLlm.node.ts @@ -5,7 +5,7 @@ import type { INodeType, INodeTypeDescription, } from 'n8n-workflow'; -import { NodeApiError, NodeConnectionType, NodeOperationError } from 'n8n-workflow'; +import { NodeApiError, NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; import { getPromptInputByType } from '@utils/helpers'; import { getOptionalOutputParser } from '@utils/output_parsers/N8nOutputParser'; @@ -55,7 +55,7 @@ export class ChainLlm implements INodeType { }, }, inputs: `={{ ((parameter) => { ${getInputs.toString()}; return getInputs(parameter) })($parameter) }}`, - outputs: [NodeConnectionType.Main], + outputs: [NodeConnectionTypes.Main], credentials: [], properties: nodeProperties, }; @@ -73,7 +73,7 @@ export class ChainLlm implements INodeType { try { // Get the language model const llm = (await this.getInputConnectionData( - NodeConnectionType.AiLanguageModel, + NodeConnectionTypes.AiLanguageModel, 0, )) as BaseLanguageModel; diff --git a/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/methods/config.ts b/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/methods/config.ts index 973034714fc..098bc8a8e19 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/methods/config.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/methods/config.ts @@ -3,8 +3,8 @@ import { HumanMessagePromptTemplate, SystemMessagePromptTemplate, } from '@langchain/core/prompts'; -import type { IDataObject, INodeProperties } from 'n8n-workflow'; -import { NodeConnectionType } from 'n8n-workflow'; +import type { IDataObject, INodeInputConfiguration, INodeProperties } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import { promptTypeOptions, textFromPreviousNode } from '@utils/descriptions'; import { getTemplateNoticeField } from '@utils/sharedFields'; @@ -13,12 +13,12 @@ import { getTemplateNoticeField } from '@utils/sharedFields'; * Dynamic input configuration generation based on node parameters */ export function getInputs(parameters: IDataObject) { - const inputs = [ - { displayName: '', type: NodeConnectionType.Main }, + const inputs: INodeInputConfiguration[] = [ + { displayName: '', type: 'main' }, { displayName: 'Model', maxConnections: 1, - type: NodeConnectionType.AiLanguageModel, + type: 'ai_languageModel', required: true, }, ]; @@ -29,7 +29,7 @@ export function getInputs(parameters: IDataObject) { if (hasOutputParser === undefined || hasOutputParser === true) { inputs.push({ displayName: 'Output Parser', - type: NodeConnectionType.AiOutputParser, + type: 'ai_outputParser', maxConnections: 1, required: false, }); @@ -260,7 +260,7 @@ export const nodeProperties: INodeProperties[] = [ ], }, { - displayName: `Connect an output parser on the canvas to specify the output format you require`, + displayName: `Connect an output parser on the canvas to specify the output format you require`, name: 'notice', type: 'notice', default: '', diff --git a/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/methods/imageUtils.ts b/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/methods/imageUtils.ts index 5cbe2990ed7..bd3e41a6817 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/methods/imageUtils.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/methods/imageUtils.ts @@ -3,7 +3,7 @@ import { HumanMessage } from '@langchain/core/messages'; import { ChatGoogleGenerativeAI } from '@langchain/google-genai'; import { ChatOllama } from '@langchain/ollama'; import type { IExecuteFunctions, IBinaryData } from 'n8n-workflow'; -import { NodeOperationError, NodeConnectionType, OperationalError } from 'n8n-workflow'; +import { NodeOperationError, NodeConnectionTypes, OperationalError } from 'n8n-workflow'; import type { MessageTemplate } from './types'; @@ -69,7 +69,7 @@ export async function createImageMessage({ const bufferData = await context.helpers.getBinaryDataBuffer(itemIndex, binaryDataKey); const model = (await context.getInputConnectionData( - NodeConnectionType.AiLanguageModel, + NodeConnectionTypes.AiLanguageModel, 0, )) as BaseLanguageModel; diff --git a/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/test/ChainLlm.node.test.ts b/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/test/ChainLlm.node.test.ts index 0ddbd983c3f..75fa9ae1eb4 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/test/ChainLlm.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/test/ChainLlm.node.test.ts @@ -3,7 +3,7 @@ import { FakeChatModel } from '@langchain/core/utils/testing'; import { mock } from 'jest-mock-extended'; import type { IExecuteFunctions, INode } from 'n8n-workflow'; -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import * as helperModule from '@utils/helpers'; import * as outputParserModule from '@utils/output_parsers/N8nOutputParser'; @@ -64,7 +64,7 @@ describe('ChainLlm Node', () => { expect(node.description.version).toContain(1.5); expect(node.description.properties).toBeDefined(); expect(node.description.inputs).toBeDefined(); - expect(node.description.outputs).toEqual([NodeConnectionType.Main]); + expect(node.description.outputs).toEqual([NodeConnectionTypes.Main]); }); }); diff --git a/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/test/config.test.ts b/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/test/config.test.ts index 997b48fb541..2ec49822d72 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/test/config.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/test/config.test.ts @@ -1,4 +1,4 @@ -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import { getInputs, nodeProperties } from '../methods/config'; @@ -8,24 +8,24 @@ describe('config', () => { const inputs = getInputs({}); expect(inputs).toHaveLength(3); - expect(inputs[0].type).toBe(NodeConnectionType.Main); - expect(inputs[1].type).toBe(NodeConnectionType.AiLanguageModel); - expect(inputs[2].type).toBe(NodeConnectionType.AiOutputParser); + expect(inputs[0].type).toBe(NodeConnectionTypes.Main); + expect(inputs[1].type).toBe(NodeConnectionTypes.AiLanguageModel); + expect(inputs[2].type).toBe(NodeConnectionTypes.AiOutputParser); }); it('should exclude the OutputParser when hasOutputParser is false', () => { const inputs = getInputs({ hasOutputParser: false }); expect(inputs).toHaveLength(2); - expect(inputs[0].type).toBe(NodeConnectionType.Main); - expect(inputs[1].type).toBe(NodeConnectionType.AiLanguageModel); + expect(inputs[0].type).toBe(NodeConnectionTypes.Main); + expect(inputs[1].type).toBe(NodeConnectionTypes.AiLanguageModel); }); it('should include the OutputParser when hasOutputParser is true', () => { const inputs = getInputs({ hasOutputParser: true }); expect(inputs).toHaveLength(3); - expect(inputs[2].type).toBe(NodeConnectionType.AiOutputParser); + expect(inputs[2].type).toBe(NodeConnectionTypes.AiOutputParser); }); }); diff --git a/packages/@n8n/nodes-langchain/nodes/chains/ChainRetrievalQA/ChainRetrievalQa.node.ts b/packages/@n8n/nodes-langchain/nodes/chains/ChainRetrievalQA/ChainRetrievalQa.node.ts index 14e443c6a39..4640603f695 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/ChainRetrievalQA/ChainRetrievalQa.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/ChainRetrievalQA/ChainRetrievalQa.node.ts @@ -8,7 +8,7 @@ import { import type { BaseRetriever } from '@langchain/core/retrievers'; import { createStuffDocumentsChain } from 'langchain/chains/combine_documents'; import { createRetrievalChain } from 'langchain/chains/retrieval'; -import { NodeConnectionType, NodeOperationError, parseErrorMetadata } from 'n8n-workflow'; +import { NodeConnectionTypes, NodeOperationError, parseErrorMetadata } from 'n8n-workflow'; import { type INodeProperties, type IExecuteFunctions, @@ -70,21 +70,21 @@ export class ChainRetrievalQa implements INodeType { }, // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [ - NodeConnectionType.Main, + NodeConnectionTypes.Main, { displayName: 'Model', maxConnections: 1, - type: NodeConnectionType.AiLanguageModel, + type: NodeConnectionTypes.AiLanguageModel, required: true, }, { displayName: 'Retriever', maxConnections: 1, - type: NodeConnectionType.AiRetriever, + type: NodeConnectionTypes.AiRetriever, required: true, }, ], - outputs: [NodeConnectionType.Main], + outputs: [NodeConnectionTypes.Main], credentials: [], properties: [ getTemplateNoticeField(1960), @@ -192,12 +192,12 @@ export class ChainRetrievalQa implements INodeType { for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { try { const model = (await this.getInputConnectionData( - NodeConnectionType.AiLanguageModel, + NodeConnectionTypes.AiLanguageModel, 0, )) as BaseLanguageModel; const retriever = (await this.getInputConnectionData( - NodeConnectionType.AiRetriever, + NodeConnectionTypes.AiRetriever, 0, )) as BaseRetriever; diff --git a/packages/@n8n/nodes-langchain/nodes/chains/ChainRetrievalQA/test/ChainRetrievalQa.node.test.ts b/packages/@n8n/nodes-langchain/nodes/chains/ChainRetrievalQA/test/ChainRetrievalQa.node.test.ts index cd1169aceeb..36e118464bb 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/ChainRetrievalQA/test/ChainRetrievalQa.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/ChainRetrievalQA/test/ChainRetrievalQa.node.test.ts @@ -3,8 +3,8 @@ import type { BaseLanguageModel } from '@langchain/core/language_models/base'; import type { BaseRetriever } from '@langchain/core/retrievers'; import { FakeChatModel, FakeLLM, FakeRetriever } from '@langchain/core/utils/testing'; import get from 'lodash/get'; -import type { IDataObject, IExecuteFunctions } from 'n8n-workflow'; -import { NodeConnectionType, NodeOperationError, UnexpectedError } from 'n8n-workflow'; +import type { IDataObject, IExecuteFunctions, NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionTypes, NodeOperationError, UnexpectedError } from 'n8n-workflow'; import { ChainRetrievalQa } from '../ChainRetrievalQa.node'; @@ -27,10 +27,10 @@ const createExecuteFunctionsMock = ( }; }, getInputConnectionData(type: NodeConnectionType) { - if (type === NodeConnectionType.AiLanguageModel) { + if (type === NodeConnectionTypes.AiLanguageModel) { return fakeLlm; } - if (type === NodeConnectionType.AiRetriever) { + if (type === NodeConnectionTypes.AiRetriever) { return fakeRetriever; } return null; diff --git a/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/V1/ChainSummarizationV1.node.ts b/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/V1/ChainSummarizationV1.node.ts index fedf9790829..6507e8617d4 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/V1/ChainSummarizationV1.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/V1/ChainSummarizationV1.node.ts @@ -4,7 +4,7 @@ import { PromptTemplate } from '@langchain/core/prompts'; import type { SummarizationChainParams } from 'langchain/chains'; import { loadSummarizationChain } from 'langchain/chains'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeTypeBaseDescription, type IExecuteFunctions, type INodeExecutionData, @@ -31,21 +31,21 @@ export class ChainSummarizationV1 implements INodeType { }, // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [ - NodeConnectionType.Main, + NodeConnectionTypes.Main, { displayName: 'Model', maxConnections: 1, - type: NodeConnectionType.AiLanguageModel, + type: NodeConnectionTypes.AiLanguageModel, required: true, }, { displayName: 'Document', maxConnections: 1, - type: NodeConnectionType.AiDocument, + type: NodeConnectionTypes.AiDocument, required: true, }, ], - outputs: [NodeConnectionType.Main], + outputs: [NodeConnectionTypes.Main], credentials: [], properties: [ getTemplateNoticeField(1951), @@ -167,11 +167,11 @@ export class ChainSummarizationV1 implements INodeType { const type = this.getNodeParameter('type', 0) as 'map_reduce' | 'stuff' | 'refine'; const model = (await this.getInputConnectionData( - NodeConnectionType.AiLanguageModel, + NodeConnectionTypes.AiLanguageModel, 0, )) as BaseLanguageModel; - const documentInput = (await this.getInputConnectionData(NodeConnectionType.AiDocument, 0)) as + const documentInput = (await this.getInputConnectionData(NodeConnectionTypes.AiDocument, 0)) as | N8nJsonLoader | Array>>; diff --git a/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/V2/ChainSummarizationV2.node.ts b/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/V2/ChainSummarizationV2.node.ts index 4e17adb18db..7cb2bed603c 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/V2/ChainSummarizationV2.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/V2/ChainSummarizationV2.node.ts @@ -10,8 +10,9 @@ import type { INodeType, INodeTypeDescription, IDataObject, + INodeInputConfiguration, } from 'n8n-workflow'; -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import { N8nBinaryLoader } from '@utils/N8nBinaryLoader'; import { N8nJsonLoader } from '@utils/N8nJsonLoader'; @@ -24,12 +25,12 @@ import { REFINE_PROMPT_TEMPLATE, DEFAULT_PROMPT_TEMPLATE } from '../prompt'; function getInputs(parameters: IDataObject) { const chunkingMode = parameters?.chunkingMode; const operationMode = parameters?.operationMode; - const inputs = [ - { displayName: '', type: NodeConnectionType.Main }, + const inputs: INodeInputConfiguration[] = [ + { displayName: '', type: 'main' }, { displayName: 'Model', maxConnections: 1, - type: NodeConnectionType.AiLanguageModel, + type: 'ai_languageModel', required: true, }, ]; @@ -37,7 +38,7 @@ function getInputs(parameters: IDataObject) { if (operationMode === 'documentLoader') { inputs.push({ displayName: 'Document', - type: NodeConnectionType.AiDocument, + type: 'ai_document', required: true, maxConnections: 1, }); @@ -47,7 +48,7 @@ function getInputs(parameters: IDataObject) { if (chunkingMode === 'advanced') { inputs.push({ displayName: 'Text Splitter', - type: NodeConnectionType.AiTextSplitter, + type: 'ai_textSplitter', required: false, maxConnections: 1, }); @@ -69,7 +70,7 @@ export class ChainSummarizationV2 implements INodeType { }, // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: `={{ ((parameter) => { ${getInputs.toString()}; return getInputs(parameter) })($parameter) }}`, - outputs: [NodeConnectionType.Main], + outputs: [NodeConnectionTypes.Main], credentials: [], properties: [ getTemplateNoticeField(1951), @@ -327,7 +328,7 @@ export class ChainSummarizationV2 implements INodeType { for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { try { const model = (await this.getInputConnectionData( - NodeConnectionType.AiLanguageModel, + NodeConnectionTypes.AiLanguageModel, 0, )) as BaseLanguageModel; @@ -356,7 +357,7 @@ export class ChainSummarizationV2 implements INodeType { // Use dedicated document loader input to load documents if (operationMode === 'documentLoader') { const documentInput = (await this.getInputConnectionData( - NodeConnectionType.AiDocument, + NodeConnectionTypes.AiDocument, 0, )) as N8nJsonLoader | Array>>; @@ -390,7 +391,7 @@ export class ChainSummarizationV2 implements INodeType { // In advanced mode user can connect text splitter node so we just retrieve it case 'advanced': textSplitter = (await this.getInputConnectionData( - NodeConnectionType.AiTextSplitter, + NodeConnectionTypes.AiTextSplitter, 0, )) as TextSplitter | undefined; break; diff --git a/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/InformationExtractor.node.ts b/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/InformationExtractor.node.ts index 365a35ddd31..182e488782d 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/InformationExtractor.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/InformationExtractor.node.ts @@ -3,7 +3,7 @@ import { HumanMessage } from '@langchain/core/messages'; import { ChatPromptTemplate, SystemMessagePromptTemplate } from '@langchain/core/prompts'; import type { JSONSchema7 } from 'json-schema'; import { OutputFixingParser, StructuredOutputParser } from 'langchain/output_parsers'; -import { jsonParse, NodeConnectionType, NodeOperationError } from 'n8n-workflow'; +import { jsonParse, NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; import type { INodeType, INodeTypeDescription, @@ -51,15 +51,15 @@ export class InformationExtractor implements INodeType { name: 'Information Extractor', }, inputs: [ - { displayName: '', type: NodeConnectionType.Main }, + { displayName: '', type: NodeConnectionTypes.Main }, { displayName: 'Model', maxConnections: 1, - type: NodeConnectionType.AiLanguageModel, + type: NodeConnectionTypes.AiLanguageModel, required: true, }, ], - outputs: [NodeConnectionType.Main], + outputs: [NodeConnectionTypes.Main], properties: [ { displayName: 'Text', @@ -222,7 +222,7 @@ export class InformationExtractor implements INodeType { const items = this.getInputData(); const llm = (await this.getInputConnectionData( - NodeConnectionType.AiLanguageModel, + NodeConnectionTypes.AiLanguageModel, 0, )) as BaseLanguageModel; diff --git a/packages/@n8n/nodes-langchain/nodes/chains/SentimentAnalysis/SentimentAnalysis.node.ts b/packages/@n8n/nodes-langchain/nodes/chains/SentimentAnalysis/SentimentAnalysis.node.ts index e810b0f98a0..577634ca98f 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/SentimentAnalysis/SentimentAnalysis.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/SentimentAnalysis/SentimentAnalysis.node.ts @@ -2,7 +2,7 @@ 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 { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; +import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; import type { IDataObject, IExecuteFunctions, @@ -24,7 +24,7 @@ const configuredOutputs = (parameters: INodeParameters, defaultCategories: strin const categories = (options?.categories as string) ?? defaultCategories; const categoriesArray = categories.split(',').map((cat) => cat.trim()); - const ret = categoriesArray.map((cat) => ({ type: NodeConnectionType.Main, displayName: cat })); + const ret = categoriesArray.map((cat) => ({ type: NodeConnectionTypes.Main, displayName: cat })); return ret; }; @@ -54,11 +54,11 @@ export class SentimentAnalysis implements INodeType { name: 'Sentiment Analysis', }, inputs: [ - { displayName: '', type: NodeConnectionType.Main }, + { displayName: '', type: NodeConnectionTypes.Main }, { displayName: 'Model', maxConnections: 1, - type: NodeConnectionType.AiLanguageModel, + type: NodeConnectionTypes.AiLanguageModel, required: true, }, ], @@ -140,7 +140,7 @@ export class SentimentAnalysis implements INodeType { const items = this.getInputData(); const llm = (await this.getInputConnectionData( - NodeConnectionType.AiLanguageModel, + NodeConnectionTypes.AiLanguageModel, 0, )) as BaseLanguageModel; diff --git a/packages/@n8n/nodes-langchain/nodes/chains/TextClassifier/TextClassifier.node.ts b/packages/@n8n/nodes-langchain/nodes/chains/TextClassifier/TextClassifier.node.ts index 298c41572d6..8cc241c294e 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/TextClassifier/TextClassifier.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/TextClassifier/TextClassifier.node.ts @@ -2,7 +2,7 @@ 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 { NodeOperationError, NodeConnectionTypes } from 'n8n-workflow'; import type { IDataObject, IExecuteFunctions, @@ -22,9 +22,9 @@ const configuredOutputs = (parameters: INodeParameters) => { const categories = ((parameters.categories as IDataObject)?.categories as IDataObject[]) ?? []; const fallback = (parameters.options as IDataObject)?.fallback as string; const ret = categories.map((cat) => { - return { type: NodeConnectionType.Main, displayName: cat.category }; + return { type: NodeConnectionTypes.Main, displayName: cat.category }; }); - if (fallback === 'other') ret.push({ type: NodeConnectionType.Main, displayName: 'Other' }); + if (fallback === 'other') ret.push({ type: NodeConnectionTypes.Main, displayName: 'Other' }); return ret; }; @@ -54,11 +54,11 @@ export class TextClassifier implements INodeType { name: 'Text Classifier', }, inputs: [ - { displayName: '', type: NodeConnectionType.Main }, + { displayName: '', type: NodeConnectionTypes.Main }, { displayName: 'Model', maxConnections: 1, - type: NodeConnectionType.AiLanguageModel, + type: NodeConnectionTypes.AiLanguageModel, required: true, }, ], @@ -167,7 +167,7 @@ export class TextClassifier implements INodeType { const items = this.getInputData(); const llm = (await this.getInputConnectionData( - NodeConnectionType.AiLanguageModel, + NodeConnectionTypes.AiLanguageModel, 0, )) as BaseLanguageModel; diff --git a/packages/@n8n/nodes-langchain/nodes/code/Code.node.ts b/packages/@n8n/nodes-langchain/nodes/code/Code.node.ts index dda3f244145..716e74648f1 100644 --- a/packages/@n8n/nodes-langchain/nodes/code/Code.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/code/Code.node.ts @@ -4,7 +4,7 @@ import { makeResolverFromLegacyOptions } from '@n8n/vm2'; import { JavaScriptSandbox } from 'n8n-nodes-base/dist/nodes/Code/JavaScriptSandbox'; import { getSandboxContext } from 'n8n-nodes-base/dist/nodes/Code/Sandbox'; import { standardizeOutput } from 'n8n-nodes-base/dist/nodes/Code/utils'; -import { NodeOperationError, NodeConnectionType } from 'n8n-workflow'; +import { NodeOperationError, NodeConnectionTypes } from 'n8n-workflow'; import type { IExecuteFunctions, INodeExecutionData, @@ -24,16 +24,16 @@ const { NODE_FUNCTION_ALLOW_BUILTIN: builtIn, NODE_FUNCTION_ALLOW_EXTERNAL: exte // TODO: Replace const connectorTypes = { - [NodeConnectionType.AiChain]: 'Chain', - [NodeConnectionType.AiDocument]: 'Document', - [NodeConnectionType.AiEmbedding]: 'Embedding', - [NodeConnectionType.AiLanguageModel]: 'Language Model', - [NodeConnectionType.AiMemory]: 'Memory', - [NodeConnectionType.AiOutputParser]: 'Output Parser', - [NodeConnectionType.AiTextSplitter]: 'Text Splitter', - [NodeConnectionType.AiTool]: 'Tool', - [NodeConnectionType.AiVectorStore]: 'Vector Store', - [NodeConnectionType.Main]: 'Main', + [NodeConnectionTypes.AiChain]: 'Chain', + [NodeConnectionTypes.AiDocument]: 'Document', + [NodeConnectionTypes.AiEmbedding]: 'Embedding', + [NodeConnectionTypes.AiLanguageModel]: 'Language Model', + [NodeConnectionTypes.AiMemory]: 'Memory', + [NodeConnectionTypes.AiOutputParser]: 'Output Parser', + [NodeConnectionTypes.AiTextSplitter]: 'Text Splitter', + [NodeConnectionTypes.AiTool]: 'Tool', + [NodeConnectionTypes.AiVectorStore]: 'Vector Store', + [NodeConnectionTypes.Main]: 'Main', }; const defaultCodeExecute = `const { PromptTemplate } = require('@langchain/core/prompts'); @@ -304,7 +304,7 @@ export class Code implements INodeType { const outputs = this.getNodeOutputs(); const mainOutputs: INodeOutputConfiguration[] = outputs.filter( - (output) => output.type === NodeConnectionType.Main, + (output) => output.type === NodeConnectionTypes.Main, ); const options = { multiOutput: mainOutputs.length !== 1 }; diff --git a/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentBinaryInputLoader/DocumentBinaryInputLoader.node.ts b/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentBinaryInputLoader/DocumentBinaryInputLoader.node.ts index 5c9ebf08b00..52a1411d9e7 100644 --- a/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentBinaryInputLoader/DocumentBinaryInputLoader.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentBinaryInputLoader/DocumentBinaryInputLoader.node.ts @@ -1,7 +1,7 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ import type { TextSplitter } from '@langchain/textsplitters'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -51,15 +51,15 @@ export class DocumentBinaryInputLoader implements INodeType { { displayName: 'Text Splitter', maxConnections: 1, - type: NodeConnectionType.AiTextSplitter, + type: NodeConnectionTypes.AiTextSplitter, required: true, }, ], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiDocument], + outputs: [NodeConnectionTypes.AiDocument], outputNames: ['Document'], properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiVectorStore]), + getConnectionHintNoticeField([NodeConnectionTypes.AiVectorStore]), { displayName: 'Loader Type', name: 'loader', @@ -179,7 +179,7 @@ export class DocumentBinaryInputLoader implements INodeType { async supplyData(this: ISupplyDataFunctions): Promise { this.logger.debug('Supply Data for Binary Input Loader'); const textSplitter = (await this.getInputConnectionData( - NodeConnectionType.AiTextSplitter, + NodeConnectionTypes.AiTextSplitter, 0, )) as TextSplitter | undefined; diff --git a/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentDefaultDataLoader/DocumentDefaultDataLoader.node.ts b/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentDefaultDataLoader/DocumentDefaultDataLoader.node.ts index 46e4120764f..ca9b1e5053e 100644 --- a/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentDefaultDataLoader/DocumentDefaultDataLoader.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentDefaultDataLoader/DocumentDefaultDataLoader.node.ts @@ -1,7 +1,7 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ import type { TextSplitter } from '@langchain/textsplitters'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -49,12 +49,12 @@ export class DocumentDefaultDataLoader implements INodeType { { displayName: 'Text Splitter', maxConnections: 1, - type: NodeConnectionType.AiTextSplitter, + type: NodeConnectionTypes.AiTextSplitter, required: true, }, ], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiDocument], + outputs: [NodeConnectionTypes.AiDocument], outputNames: ['Document'], properties: [ { @@ -286,7 +286,7 @@ export class DocumentDefaultDataLoader implements INodeType { async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { const dataType = this.getNodeParameter('dataType', itemIndex, 'json') as 'json' | 'binary'; const textSplitter = (await this.getInputConnectionData( - NodeConnectionType.AiTextSplitter, + NodeConnectionTypes.AiTextSplitter, 0, )) as TextSplitter | undefined; const binaryDataKey = this.getNodeParameter('binaryDataKey', itemIndex, '') as string; diff --git a/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentGithubLoader/DocumentGithubLoader.node.ts b/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentGithubLoader/DocumentGithubLoader.node.ts index 7d63e32f0b5..49efeb0cb1a 100644 --- a/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentGithubLoader/DocumentGithubLoader.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentGithubLoader/DocumentGithubLoader.node.ts @@ -2,7 +2,7 @@ import { GithubRepoLoader } from '@langchain/community/document_loaders/web/github'; import type { CharacterTextSplitter } from '@langchain/textsplitters'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -47,15 +47,15 @@ export class DocumentGithubLoader implements INodeType { { displayName: 'Text Splitter', maxConnections: 1, - type: NodeConnectionType.AiTextSplitter, + type: NodeConnectionTypes.AiTextSplitter, }, ], inputNames: ['Text Splitter'], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiDocument], + outputs: [NodeConnectionTypes.AiDocument], outputNames: ['Document'], properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiVectorStore]), + getConnectionHintNoticeField([NodeConnectionTypes.AiVectorStore]), { displayName: 'Repository Link', name: 'repository', @@ -106,11 +106,11 @@ export class DocumentGithubLoader implements INodeType { }; const textSplitter = (await this.getInputConnectionData( - NodeConnectionType.AiTextSplitter, + NodeConnectionTypes.AiTextSplitter, 0, )) as CharacterTextSplitter | undefined; - const { index } = this.addInputData(NodeConnectionType.AiDocument, [ + const { index } = this.addInputData(NodeConnectionTypes.AiDocument, [ [{ json: { repository, branch, ignorePaths, recursive } }], ]); const docs = new GithubRepoLoader(repository, { @@ -125,7 +125,7 @@ export class DocumentGithubLoader implements INodeType { ? await textSplitter.splitDocuments(await docs.load()) : await docs.load(); - this.addOutputData(NodeConnectionType.AiDocument, index, [[{ json: { loadedDocs } }]]); + this.addOutputData(NodeConnectionTypes.AiDocument, index, [[{ json: { loadedDocs } }]]); return { response: logWrapper(loadedDocs, this), }; diff --git a/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentJSONInputLoader/DocumentJsonInputLoader.node.ts b/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentJSONInputLoader/DocumentJsonInputLoader.node.ts index 9c295ba144a..05de2f38847 100644 --- a/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentJSONInputLoader/DocumentJsonInputLoader.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentJSONInputLoader/DocumentJsonInputLoader.node.ts @@ -1,7 +1,7 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ import type { TextSplitter } from '@langchain/textsplitters'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -44,15 +44,15 @@ export class DocumentJsonInputLoader implements INodeType { { displayName: 'Text Splitter', maxConnections: 1, - type: NodeConnectionType.AiTextSplitter, + type: NodeConnectionTypes.AiTextSplitter, }, ], inputNames: ['Text Splitter'], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiDocument], + outputs: [NodeConnectionTypes.AiDocument], outputNames: ['Document'], properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiVectorStore]), + getConnectionHintNoticeField([NodeConnectionTypes.AiVectorStore]), { displayName: 'Pointers', name: 'pointers', @@ -82,7 +82,7 @@ export class DocumentJsonInputLoader implements INodeType { async supplyData(this: ISupplyDataFunctions): Promise { this.logger.debug('Supply Data for JSON Input Loader'); const textSplitter = (await this.getInputConnectionData( - NodeConnectionType.AiTextSplitter, + NodeConnectionTypes.AiTextSplitter, 0, )) as TextSplitter | undefined; diff --git a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAwsBedrock/EmbeddingsAwsBedrock.node.ts b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAwsBedrock/EmbeddingsAwsBedrock.node.ts index fdb2da5ce0e..e58fd24948e 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAwsBedrock/EmbeddingsAwsBedrock.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAwsBedrock/EmbeddingsAwsBedrock.node.ts @@ -1,7 +1,7 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ import { BedrockEmbeddings } from '@langchain/aws'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -45,14 +45,14 @@ export class EmbeddingsAwsBedrock implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiEmbedding], + outputs: [NodeConnectionTypes.AiEmbedding], outputNames: ['Embeddings'], requestDefaults: { ignoreHttpStatusErrors: true, baseURL: '=https://bedrock.{{$credentials?.region ?? "eu-central-1"}}.amazonaws.com', }, properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiVectorStore]), + getConnectionHintNoticeField([NodeConnectionTypes.AiVectorStore]), { displayName: 'Model', name: 'model', diff --git a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAzureOpenAi/EmbeddingsAzureOpenAi.node.ts b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAzureOpenAi/EmbeddingsAzureOpenAi.node.ts index 65f493d578f..fca450c16f8 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAzureOpenAi/EmbeddingsAzureOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAzureOpenAi/EmbeddingsAzureOpenAi.node.ts @@ -1,7 +1,7 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ import { OpenAIEmbeddings } from '@langchain/openai'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -45,10 +45,10 @@ export class EmbeddingsAzureOpenAi implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiEmbedding], + outputs: [NodeConnectionTypes.AiEmbedding], outputNames: ['Embeddings'], properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiVectorStore]), + getConnectionHintNoticeField([NodeConnectionTypes.AiVectorStore]), { displayName: 'Model (Deployment) Name', name: 'model', diff --git a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsCohere/EmbeddingsCohere.node.ts b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsCohere/EmbeddingsCohere.node.ts index ebab22ec558..c669349e5f0 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsCohere/EmbeddingsCohere.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsCohere/EmbeddingsCohere.node.ts @@ -1,7 +1,7 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ import { CohereEmbeddings } from '@langchain/cohere'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -48,10 +48,10 @@ export class EmbeddingsCohere implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiEmbedding], + outputs: [NodeConnectionTypes.AiEmbedding], outputNames: ['Embeddings'], properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiVectorStore]), + getConnectionHintNoticeField([NodeConnectionTypes.AiVectorStore]), { displayName: 'Each model is using different dimensional density for embeddings. Please make sure to use the same dimensionality for your vector store. The default model is using 768-dimensional embeddings.', diff --git a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsGoogleGemini/EmbeddingsGoogleGemini.node.ts b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsGoogleGemini/EmbeddingsGoogleGemini.node.ts index 949d6ee24e0..9324f379e15 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsGoogleGemini/EmbeddingsGoogleGemini.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsGoogleGemini/EmbeddingsGoogleGemini.node.ts @@ -1,7 +1,7 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ import { GoogleGenerativeAIEmbeddings } from '@langchain/google-genai'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -48,10 +48,10 @@ export class EmbeddingsGoogleGemini implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiEmbedding], + outputs: [NodeConnectionTypes.AiEmbedding], outputNames: ['Embeddings'], properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiVectorStore]), + getConnectionHintNoticeField([NodeConnectionTypes.AiVectorStore]), { displayName: 'Each model is using different dimensional density for embeddings. Please make sure to use the same dimensionality for your vector store. The default model is using 768-dimensional embeddings.', diff --git a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsHuggingFaceInference/EmbeddingsHuggingFaceInference.node.ts b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsHuggingFaceInference/EmbeddingsHuggingFaceInference.node.ts index c8023354ef1..710399aaea4 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsHuggingFaceInference/EmbeddingsHuggingFaceInference.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsHuggingFaceInference/EmbeddingsHuggingFaceInference.node.ts @@ -1,7 +1,7 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ import { HuggingFaceInferenceEmbeddings } from '@langchain/community/embeddings/hf'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -44,10 +44,10 @@ export class EmbeddingsHuggingFaceInference implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiEmbedding], + outputs: [NodeConnectionTypes.AiEmbedding], outputNames: ['Embeddings'], properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiVectorStore]), + getConnectionHintNoticeField([NodeConnectionTypes.AiVectorStore]), { displayName: 'Each model is using different dimensional density for embeddings. Please make sure to use the same dimensionality for your vector store. The default model is using 768-dimensional embeddings.', diff --git a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsMistralCloud/EmbeddingsMistralCloud.node.ts b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsMistralCloud/EmbeddingsMistralCloud.node.ts index 553abfa4062..392e66fb32e 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsMistralCloud/EmbeddingsMistralCloud.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsMistralCloud/EmbeddingsMistralCloud.node.ts @@ -2,7 +2,7 @@ import type { MistralAIEmbeddingsParams } from '@langchain/mistralai'; import { MistralAIEmbeddings } from '@langchain/mistralai'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -46,14 +46,14 @@ export class EmbeddingsMistralCloud implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiEmbedding], + outputs: [NodeConnectionTypes.AiEmbedding], outputNames: ['Embeddings'], requestDefaults: { ignoreHttpStatusErrors: true, baseURL: 'https://api.mistral.ai/v1', }, properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiVectorStore]), + getConnectionHintNoticeField([NodeConnectionTypes.AiVectorStore]), { displayName: 'Model', name: 'model', diff --git a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOllama/EmbeddingsOllama.node.ts b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOllama/EmbeddingsOllama.node.ts index 08feb90309e..9a4c4b2dada 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOllama/EmbeddingsOllama.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOllama/EmbeddingsOllama.node.ts @@ -1,7 +1,7 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ import { OllamaEmbeddings } from '@langchain/ollama'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -41,9 +41,9 @@ export class EmbeddingsOllama implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiEmbedding], + outputs: [NodeConnectionTypes.AiEmbedding], outputNames: ['Embeddings'], - properties: [getConnectionHintNoticeField([NodeConnectionType.AiVectorStore]), ollamaModel], + properties: [getConnectionHintNoticeField([NodeConnectionTypes.AiVectorStore]), ollamaModel], }; async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { diff --git a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts index 58d90ff90b6..8d9bb41bc04 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts @@ -1,7 +1,7 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ import { OpenAIEmbeddings } from '@langchain/openai'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type SupplyData, @@ -101,7 +101,7 @@ export class EmbeddingsOpenAi implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiEmbedding], + outputs: [NodeConnectionTypes.AiEmbedding], outputNames: ['Embeddings'], requestDefaults: { ignoreHttpStatusErrors: true, @@ -109,7 +109,7 @@ export class EmbeddingsOpenAi implements INodeType { '={{ $parameter.options?.baseURL?.split("/").slice(0,-1).join("/") || $credentials.url?.split("/").slice(0,-1).join("/") || "https://api.openai.com" }}', }, properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiVectorStore]), + getConnectionHintNoticeField([NodeConnectionTypes.AiVectorStore]), { ...modelParameter, default: 'text-embedding-ada-002', diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/LmChatAnthropic.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/LmChatAnthropic.node.ts index 7bf2e28bba8..46d6a3c2ee7 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/LmChatAnthropic.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/LmChatAnthropic.node.ts @@ -3,7 +3,7 @@ import { ChatAnthropic } from '@langchain/anthropic'; import type { LLMResult } from '@langchain/core/outputs'; import { - NodeConnectionType, + NodeConnectionTypes, type INodePropertyOptions, type INodeProperties, type ISupplyDataFunctions, @@ -109,7 +109,7 @@ export class LmChatAnthropic implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiLanguageModel], + outputs: [NodeConnectionTypes.AiLanguageModel], outputNames: ['Model'], credentials: [ { @@ -118,7 +118,7 @@ export class LmChatAnthropic implements INodeType { }, ], properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiChain, NodeConnectionType.AiChain]), + getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiChain]), { ...modelField, displayOptions: { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOllama/LmChatOllama.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOllama/LmChatOllama.node.ts index d4685fa802d..3bbe049a61b 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOllama/LmChatOllama.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOllama/LmChatOllama.node.ts @@ -3,7 +3,7 @@ import type { ChatOllamaInput } from '@langchain/ollama'; import { ChatOllama } from '@langchain/ollama'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -45,11 +45,11 @@ export class LmChatOllama implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiLanguageModel], + outputs: [NodeConnectionTypes.AiLanguageModel], outputNames: ['Model'], ...ollamaDescription, properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiChain, NodeConnectionType.AiAgent]), + getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]), ollamaModel, ollamaOptions, ], diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts index 005ee33808f..770a1c80b8b 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts @@ -2,7 +2,7 @@ import { ChatOpenAI, type ClientOptions } from '@langchain/openai'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -51,7 +51,7 @@ export class LmChatOpenAi implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiLanguageModel], + outputs: [NodeConnectionTypes.AiLanguageModel], outputNames: ['Model'], credentials: [ { @@ -65,7 +65,7 @@ export class LmChatOpenAi implements INodeType { '={{ $parameter.options?.baseURL?.split("/").slice(0,-1).join("/") || $credentials?.url?.split("/").slice(0,-1).join("/") || "https://api.openai.com" }}', }, properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiChain, NodeConnectionType.AiAgent]), + getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]), { displayName: 'If using JSON response format, you must include word "json" in the prompt in your chain or agent. Also, make sure to select latest models released post November 2023.', diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMCohere/LmCohere.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMCohere/LmCohere.node.ts index 6b9559104b5..e3bdd302f54 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMCohere/LmCohere.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMCohere/LmCohere.node.ts @@ -1,7 +1,7 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ import { Cohere } from '@langchain/cohere'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -42,7 +42,7 @@ export class LmCohere implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiLanguageModel], + outputs: [NodeConnectionTypes.AiLanguageModel], outputNames: ['Model'], credentials: [ { @@ -51,7 +51,7 @@ export class LmCohere implements INodeType { }, ], properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiChain, NodeConnectionType.AiAgent]), + getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]), { displayName: 'Options', name: 'options', diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMOllama/LmOllama.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMOllama/LmOllama.node.ts index 21a7a0c50f7..b27d9d5c0a2 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMOllama/LmOllama.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMOllama/LmOllama.node.ts @@ -2,7 +2,7 @@ import { Ollama } from '@langchain/community/llms/ollama'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -44,11 +44,11 @@ export class LmOllama implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiLanguageModel], + outputs: [NodeConnectionTypes.AiLanguageModel], outputNames: ['Model'], ...ollamaDescription, properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiChain, NodeConnectionType.AiAgent]), + getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]), ollamaModel, ollamaOptions, ], diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMOpenAi/LmOpenAi.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMOpenAi/LmOpenAi.node.ts index 1a64f07cca6..8f442f65551 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMOpenAi/LmOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMOpenAi/LmOpenAi.node.ts @@ -1,6 +1,6 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ import { OpenAI, type ClientOptions } from '@langchain/openai'; -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import type { INodeType, INodeTypeDescription, @@ -53,7 +53,7 @@ export class LmOpenAi implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiLanguageModel], + outputs: [NodeConnectionTypes.AiLanguageModel], outputNames: ['Model'], credentials: [ { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMOpenHuggingFaceInference/LmOpenHuggingFaceInference.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMOpenHuggingFaceInference/LmOpenHuggingFaceInference.node.ts index e393d86f8a3..6e0b40c7f29 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMOpenHuggingFaceInference/LmOpenHuggingFaceInference.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMOpenHuggingFaceInference/LmOpenHuggingFaceInference.node.ts @@ -1,7 +1,7 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ import { HuggingFaceInference } from '@langchain/community/llms/hf'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -42,7 +42,7 @@ export class LmOpenHuggingFaceInference implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiLanguageModel], + outputs: [NodeConnectionTypes.AiLanguageModel], outputNames: ['Model'], credentials: [ { @@ -51,7 +51,7 @@ export class LmOpenHuggingFaceInference implements INodeType { }, ], properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiChain, NodeConnectionType.AiAgent]), + getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]), { displayName: 'Model', name: 'model', diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.ts index ef15a531cfc..8db75531c8e 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.ts @@ -1,7 +1,7 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ import { ChatBedrockConverse } from '@langchain/aws'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -42,7 +42,7 @@ export class LmChatAwsBedrock implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiLanguageModel], + outputs: [NodeConnectionTypes.AiLanguageModel], outputNames: ['Model'], credentials: [ { @@ -56,7 +56,7 @@ export class LmChatAwsBedrock implements INodeType { baseURL: '=https://bedrock.{{$credentials?.region ?? "eu-central-1"}}.amazonaws.com', }, properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiChain, NodeConnectionType.AiChain]), + getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiChain]), { displayName: 'Model', name: 'model', diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/LmChatAzureOpenAi.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/LmChatAzureOpenAi.node.ts index 0618ceadc86..578e7a6f58b 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/LmChatAzureOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/LmChatAzureOpenAi.node.ts @@ -1,7 +1,7 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ import { AzureChatOpenAI } from '@langchain/openai'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -42,7 +42,7 @@ export class LmChatAzureOpenAi implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiLanguageModel], + outputs: [NodeConnectionTypes.AiLanguageModel], outputNames: ['Model'], credentials: [ { @@ -51,7 +51,7 @@ export class LmChatAzureOpenAi implements INodeType { }, ], properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiChain, NodeConnectionType.AiAgent]), + getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]), { displayName: 'If using JSON response format, you must include word "json" in the prompt in your chain or agent. Also, make sure to select latest models released post November 2023.', diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatDeepSeek/LmChatDeepSeek.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatDeepSeek/LmChatDeepSeek.node.ts index bf811ac2fe4..33d6eb8554b 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatDeepSeek/LmChatDeepSeek.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatDeepSeek/LmChatDeepSeek.node.ts @@ -2,7 +2,7 @@ import { ChatOpenAI, type ClientOptions } from '@langchain/openai'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -44,7 +44,7 @@ export class LmChatDeepSeek implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiLanguageModel], + outputs: [NodeConnectionTypes.AiLanguageModel], outputNames: ['Model'], credentials: [ { @@ -57,7 +57,7 @@ export class LmChatDeepSeek implements INodeType { baseURL: '={{ $credentials?.url }}', }, properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiChain, NodeConnectionType.AiAgent]), + getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]), { displayName: 'If using JSON response format, you must include word "json" in the prompt in your chain or agent. Also, make sure to select latest models released post November 2023.', diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleGemini/LmChatGoogleGemini.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleGemini/LmChatGoogleGemini.node.ts index f6ff0f50383..df696620310 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleGemini/LmChatGoogleGemini.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleGemini/LmChatGoogleGemini.node.ts @@ -1,7 +1,7 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ import type { SafetySetting } from '@google/generative-ai'; import { ChatGoogleGenerativeAI } from '@langchain/google-genai'; -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import type { NodeError, INodeType, @@ -52,7 +52,7 @@ export class LmChatGoogleGemini implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiLanguageModel], + outputs: [NodeConnectionTypes.AiLanguageModel], outputNames: ['Model'], credentials: [ { @@ -65,7 +65,7 @@ export class LmChatGoogleGemini implements INodeType { baseURL: '={{ $credentials.host }}', }, properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiChain, NodeConnectionType.AiAgent]), + getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]), { displayName: 'Model', name: 'modelName', diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.ts index ab3320c2bad..a68080fdacb 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.ts @@ -4,7 +4,7 @@ import { ProjectsClient } from '@google-cloud/resource-manager'; import { ChatVertexAI } from '@langchain/google-vertexai'; import { formatPrivateKey } from 'n8n-nodes-base/dist/utils/utilities'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -50,7 +50,7 @@ export class LmChatGoogleVertex implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiLanguageModel], + outputs: [NodeConnectionTypes.AiLanguageModel], outputNames: ['Model'], credentials: [ { @@ -59,7 +59,7 @@ export class LmChatGoogleVertex implements INodeType { }, ], properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiChain, NodeConnectionType.AiAgent]), + getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]), { displayName: 'Project ID', name: 'projectId', diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGroq/LmChatGroq.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGroq/LmChatGroq.node.ts index fb859e3fce0..14c94d7bee7 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGroq/LmChatGroq.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGroq/LmChatGroq.node.ts @@ -1,7 +1,7 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ import { ChatGroq } from '@langchain/groq'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -42,7 +42,7 @@ export class LmChatGroq implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiLanguageModel], + outputs: [NodeConnectionTypes.AiLanguageModel], outputNames: ['Model'], credentials: [ { @@ -54,7 +54,7 @@ export class LmChatGroq implements INodeType { baseURL: 'https://api.groq.com/openai/v1', }, properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiChain, NodeConnectionType.AiChain]), + getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiChain]), { displayName: 'Model', name: 'model', diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatMistralCloud/LmChatMistralCloud.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatMistralCloud/LmChatMistralCloud.node.ts index a23c2d4e9fa..d4ff63a8ee2 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatMistralCloud/LmChatMistralCloud.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatMistralCloud/LmChatMistralCloud.node.ts @@ -3,7 +3,7 @@ import type { ChatMistralAIInput } from '@langchain/mistralai'; import { ChatMistralAI } from '@langchain/mistralai'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -44,7 +44,7 @@ export class LmChatMistralCloud implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiLanguageModel], + outputs: [NodeConnectionTypes.AiLanguageModel], outputNames: ['Model'], credentials: [ { @@ -57,7 +57,7 @@ export class LmChatMistralCloud implements INodeType { baseURL: 'https://api.mistral.ai/v1', }, properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiChain, NodeConnectionType.AiAgent]), + getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]), { displayName: 'Model', name: 'model', diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatOpenRouter/LmChatOpenRouter.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatOpenRouter/LmChatOpenRouter.node.ts index 57a14028e71..005ee84ab58 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatOpenRouter/LmChatOpenRouter.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatOpenRouter/LmChatOpenRouter.node.ts @@ -2,7 +2,7 @@ import { ChatOpenAI, type ClientOptions } from '@langchain/openai'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -43,7 +43,7 @@ export class LmChatOpenRouter implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiLanguageModel], + outputs: [NodeConnectionTypes.AiLanguageModel], outputNames: ['Model'], credentials: [ { @@ -56,7 +56,7 @@ export class LmChatOpenRouter implements INodeType { baseURL: '={{ $credentials?.url }}', }, properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiChain, NodeConnectionType.AiAgent]), + getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]), { displayName: 'If using JSON response format, you must include word "json" in the prompt in your chain or agent. Also, make sure to select latest models released post November 2023.', diff --git a/packages/@n8n/nodes-langchain/nodes/llms/N8nLlmTracing.ts b/packages/@n8n/nodes-langchain/nodes/llms/N8nLlmTracing.ts index 7f7d70841e4..4ba3571f336 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/N8nLlmTracing.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/N8nLlmTracing.ts @@ -11,7 +11,7 @@ import type { LLMResult } from '@langchain/core/outputs'; import { encodingForModel } from '@langchain/core/utils/tiktoken'; import { pick } from 'lodash'; import type { IDataObject, ISupplyDataFunctions, JsonObject } from 'n8n-workflow'; -import { NodeConnectionType, NodeError, NodeOperationError } from 'n8n-workflow'; +import { NodeConnectionTypes, NodeError, NodeOperationError } from 'n8n-workflow'; import { logAiEvent } from '@utils/helpers'; @@ -35,7 +35,7 @@ export class N8nLlmTracing extends BaseCallbackHandler { // This is crucial for the handleLLMError handler to work correctly (it should be called before the error is propagated to the root node) awaitHandlers = true; - connectionType = NodeConnectionType.AiLanguageModel; + connectionType = NodeConnectionTypes.AiLanguageModel; promptTokensEstimate = 0; diff --git a/packages/@n8n/nodes-langchain/nodes/memory/MemoryBufferWindow/MemoryBufferWindow.node.ts b/packages/@n8n/nodes-langchain/nodes/memory/MemoryBufferWindow/MemoryBufferWindow.node.ts index 91282ce1442..e532032606c 100644 --- a/packages/@n8n/nodes-langchain/nodes/memory/MemoryBufferWindow/MemoryBufferWindow.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/memory/MemoryBufferWindow/MemoryBufferWindow.node.ts @@ -2,7 +2,7 @@ import type { BufferWindowMemoryInput } from 'langchain/memory'; import { BufferWindowMemory } from 'langchain/memory'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -101,10 +101,10 @@ export class MemoryBufferWindow implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiMemory], + outputs: [NodeConnectionTypes.AiMemory], outputNames: ['Memory'], properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiAgent]), + getConnectionHintNoticeField([NodeConnectionTypes.AiAgent]), { displayName: 'Session Key', name: 'sessionKey', diff --git a/packages/@n8n/nodes-langchain/nodes/memory/MemoryChatRetriever/MemoryChatRetriever.node.ts b/packages/@n8n/nodes-langchain/nodes/memory/MemoryChatRetriever/MemoryChatRetriever.node.ts index 82fcba22a66..62d1f30c13c 100644 --- a/packages/@n8n/nodes-langchain/nodes/memory/MemoryChatRetriever/MemoryChatRetriever.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/memory/MemoryChatRetriever/MemoryChatRetriever.node.ts @@ -2,7 +2,7 @@ import type { BaseChatMemory } from '@langchain/community/memory/chat_memory'; import type { BaseMessage } from '@langchain/core/messages'; import { - NodeConnectionType, + NodeConnectionTypes, type IDataObject, type IExecuteFunctions, type INodeExecutionData, @@ -61,16 +61,16 @@ export class MemoryChatRetriever implements INodeType { }, // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [ - NodeConnectionType.Main, + NodeConnectionTypes.Main, { displayName: 'Memory', maxConnections: 1, - type: NodeConnectionType.AiMemory, + type: NodeConnectionTypes.AiMemory, required: true, }, ], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.Main], + outputs: [NodeConnectionTypes.Main], properties: [ { displayName: "This node is deprecated. Use 'Chat Memory Manager' node instead.", @@ -91,7 +91,7 @@ export class MemoryChatRetriever implements INodeType { async execute(this: IExecuteFunctions): Promise { this.logger.debug('Executing Chat Memory Retriever'); - const memory = (await this.getInputConnectionData(NodeConnectionType.AiMemory, 0)) as + const memory = (await this.getInputConnectionData(NodeConnectionTypes.AiMemory, 0)) as | BaseChatMemory | undefined; const simplifyOutput = this.getNodeParameter('simplifyOutput', 0) as boolean; diff --git a/packages/@n8n/nodes-langchain/nodes/memory/MemoryManager/MemoryManager.node.ts b/packages/@n8n/nodes-langchain/nodes/memory/MemoryManager/MemoryManager.node.ts index 964da654756..6bfbe197797 100644 --- a/packages/@n8n/nodes-langchain/nodes/memory/MemoryManager/MemoryManager.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/memory/MemoryManager/MemoryManager.node.ts @@ -1,7 +1,7 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ import type { BaseChatMemory } from '@langchain/community/memory/chat_memory'; import { AIMessage, SystemMessage, HumanMessage, type BaseMessage } from '@langchain/core/messages'; -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import type { IDataObject, IExecuteFunctions, @@ -92,11 +92,11 @@ export class MemoryManager implements INodeType { inputs: [ { displayName: '', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, }, { displayName: 'Memory', - type: NodeConnectionType.AiMemory, + type: NodeConnectionTypes.AiMemory, required: true, maxConnections: 1, }, @@ -105,7 +105,7 @@ export class MemoryManager implements INodeType { outputs: [ { displayName: '', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, }, ], properties: [ @@ -297,7 +297,7 @@ export class MemoryManager implements INodeType { const items = this.getInputData(); const mode = this.getNodeParameter('mode', 0, 'load') as 'load' | 'insert' | 'delete'; const memory = (await this.getInputConnectionData( - NodeConnectionType.AiMemory, + NodeConnectionTypes.AiMemory, 0, )) as BaseChatMemory; diff --git a/packages/@n8n/nodes-langchain/nodes/memory/MemoryMotorhead/MemoryMotorhead.node.ts b/packages/@n8n/nodes-langchain/nodes/memory/MemoryMotorhead/MemoryMotorhead.node.ts index 06fa387ee65..08a617cfd54 100644 --- a/packages/@n8n/nodes-langchain/nodes/memory/MemoryMotorhead/MemoryMotorhead.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/memory/MemoryMotorhead/MemoryMotorhead.node.ts @@ -1,7 +1,7 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ import { MotorheadMemory } from '@langchain/community/memory/motorhead_memory'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -42,7 +42,7 @@ export class MemoryMotorhead implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiMemory], + outputs: [NodeConnectionTypes.AiMemory], outputNames: ['Memory'], credentials: [ { @@ -51,7 +51,7 @@ export class MemoryMotorhead implements INodeType { }, ], properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiAgent]), + getConnectionHintNoticeField([NodeConnectionTypes.AiAgent]), { displayName: 'Session ID', name: 'sessionId', diff --git a/packages/@n8n/nodes-langchain/nodes/memory/MemoryPostgresChat/MemoryPostgresChat.node.ts b/packages/@n8n/nodes-langchain/nodes/memory/MemoryPostgresChat/MemoryPostgresChat.node.ts index 81be577f799..a62d06d7afc 100644 --- a/packages/@n8n/nodes-langchain/nodes/memory/MemoryPostgresChat/MemoryPostgresChat.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/memory/MemoryPostgresChat/MemoryPostgresChat.node.ts @@ -10,7 +10,7 @@ import type { INodeTypeDescription, SupplyData, } from 'n8n-workflow'; -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import type pg from 'pg'; import { getSessionId } from '@utils/helpers'; @@ -58,10 +58,10 @@ export class MemoryPostgresChat implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiMemory], + outputs: [NodeConnectionTypes.AiMemory], outputNames: ['Memory'], properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiAgent]), + getConnectionHintNoticeField([NodeConnectionTypes.AiAgent]), sessionIdOption, expressionSessionKeyProperty(1.2), sessionKeyProperty, diff --git a/packages/@n8n/nodes-langchain/nodes/memory/MemoryRedisChat/MemoryRedisChat.node.ts b/packages/@n8n/nodes-langchain/nodes/memory/MemoryRedisChat/MemoryRedisChat.node.ts index 3d9a8c8b010..9a094b8ab32 100644 --- a/packages/@n8n/nodes-langchain/nodes/memory/MemoryRedisChat/MemoryRedisChat.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/memory/MemoryRedisChat/MemoryRedisChat.node.ts @@ -8,7 +8,7 @@ import { type INodeTypeDescription, type ISupplyDataFunctions, type SupplyData, - NodeConnectionType, + NodeConnectionTypes, } from 'n8n-workflow'; import type { RedisClientOptions } from 'redis'; import { createClient } from 'redis'; @@ -57,10 +57,10 @@ export class MemoryRedisChat implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiMemory], + outputs: [NodeConnectionTypes.AiMemory], outputNames: ['Memory'], properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiAgent]), + getConnectionHintNoticeField([NodeConnectionTypes.AiAgent]), { displayName: 'Session Key', name: 'sessionKey', diff --git a/packages/@n8n/nodes-langchain/nodes/memory/MemoryXata/MemoryXata.node.ts b/packages/@n8n/nodes-langchain/nodes/memory/MemoryXata/MemoryXata.node.ts index c1ad7b9539f..e9bc97404e1 100644 --- a/packages/@n8n/nodes-langchain/nodes/memory/MemoryXata/MemoryXata.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/memory/MemoryXata/MemoryXata.node.ts @@ -2,7 +2,7 @@ import { XataChatMessageHistory } from '@langchain/community/stores/message/xata'; import { BaseClient } from '@xata.io/client'; import { BufferMemory, BufferWindowMemory } from 'langchain/memory'; -import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; +import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; import type { ISupplyDataFunctions, INodeType, @@ -50,7 +50,7 @@ export class MemoryXata implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiMemory], + outputs: [NodeConnectionTypes.AiMemory], outputNames: ['Memory'], credentials: [ { @@ -59,7 +59,7 @@ export class MemoryXata implements INodeType { }, ], properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiAgent]), + getConnectionHintNoticeField([NodeConnectionTypes.AiAgent]), { displayName: 'Session ID', name: 'sessionId', diff --git a/packages/@n8n/nodes-langchain/nodes/memory/MemoryZep/MemoryZep.node.ts b/packages/@n8n/nodes-langchain/nodes/memory/MemoryZep/MemoryZep.node.ts index 1943f41c039..dea31c4f19b 100644 --- a/packages/@n8n/nodes-langchain/nodes/memory/MemoryZep/MemoryZep.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/memory/MemoryZep/MemoryZep.node.ts @@ -5,7 +5,7 @@ import { ZepCloudMemory } from '@langchain/community/memory/zep_cloud'; import type { InputValues, MemoryVariables } from '@langchain/core/memory'; import type { BaseMessage } from '@langchain/core/messages'; import { - NodeConnectionType, + NodeConnectionTypes, type ISupplyDataFunctions, type INodeType, type INodeTypeDescription, @@ -58,7 +58,7 @@ export class MemoryZep implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiMemory], + outputs: [NodeConnectionTypes.AiMemory], outputNames: ['Memory'], credentials: [ { @@ -67,7 +67,7 @@ export class MemoryZep implements INodeType { }, ], properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiAgent]), + getConnectionHintNoticeField([NodeConnectionTypes.AiAgent]), { displayName: 'Only works with Zep Cloud and Community edition <= v0.27.2', name: 'supportedVersions', diff --git a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserAutofixing/OutputParserAutofixing.node.ts b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserAutofixing/OutputParserAutofixing.node.ts index f9e6cd29685..39d8d00e7e6 100644 --- a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserAutofixing/OutputParserAutofixing.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserAutofixing/OutputParserAutofixing.node.ts @@ -1,6 +1,6 @@ import type { BaseLanguageModel } from '@langchain/core/language_models/base'; import { PromptTemplate } from '@langchain/core/prompts'; -import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; +import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; import type { ISupplyDataFunctions, INodeType, @@ -47,18 +47,18 @@ export class OutputParserAutofixing implements INodeType { { displayName: 'Model', maxConnections: 1, - type: NodeConnectionType.AiLanguageModel, + type: NodeConnectionTypes.AiLanguageModel, required: true, }, { displayName: 'Output Parser', maxConnections: 1, required: true, - type: NodeConnectionType.AiOutputParser, + type: NodeConnectionTypes.AiOutputParser, }, ], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiOutputParser], + outputs: [NodeConnectionTypes.AiOutputParser], outputNames: ['Output Parser'], properties: [ { @@ -68,7 +68,7 @@ export class OutputParserAutofixing implements INodeType { type: 'notice', default: '', }, - getConnectionHintNoticeField([NodeConnectionType.AiChain, NodeConnectionType.AiAgent]), + getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]), { displayName: 'Options', name: 'options', @@ -95,11 +95,11 @@ export class OutputParserAutofixing implements INodeType { async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { const model = (await this.getInputConnectionData( - NodeConnectionType.AiLanguageModel, + NodeConnectionTypes.AiLanguageModel, itemIndex, )) as BaseLanguageModel; const outputParser = (await this.getInputConnectionData( - NodeConnectionType.AiOutputParser, + NodeConnectionTypes.AiOutputParser, itemIndex, )) as N8nStructuredOutputParser; const prompt = this.getNodeParameter('options.prompt', itemIndex, NAIVE_FIX_PROMPT) as string; diff --git a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserAutofixing/test/OutputParserAutofixing.node.test.ts b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserAutofixing/test/OutputParserAutofixing.node.test.ts index 8c8e723c37b..a5dfce42192 100644 --- a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserAutofixing/test/OutputParserAutofixing.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserAutofixing/test/OutputParserAutofixing.node.test.ts @@ -5,8 +5,12 @@ import { OutputParserException } from '@langchain/core/output_parsers'; import type { MockProxy } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended'; import { normalizeItems } from 'n8n-core'; -import type { ISupplyDataFunctions, IWorkflowDataProxyData } from 'n8n-workflow'; -import { ApplicationError, NodeConnectionType, NodeOperationError } from 'n8n-workflow'; +import type { + ISupplyDataFunctions, + IWorkflowDataProxyData, + NodeConnectionType, +} from 'n8n-workflow'; +import { ApplicationError, NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; import type { N8nOutputFixingParser, @@ -34,8 +38,8 @@ describe('OutputParserAutofixing', () => { thisArg.addInputData.mockReturnValue({ index: 0 }); thisArg.addOutputData.mockReturnValue(); thisArg.getInputConnectionData.mockImplementation(async (type: NodeConnectionType) => { - if (type === NodeConnectionType.AiLanguageModel) return mockModel; - if (type === NodeConnectionType.AiOutputParser) return mockStructuredOutputParser; + if (type === NodeConnectionTypes.AiLanguageModel) return mockModel; + if (type === NodeConnectionTypes.AiOutputParser) return mockStructuredOutputParser; throw new ApplicationError('Unexpected connection type'); }); diff --git a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserItemList/OutputParserItemList.node.ts b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserItemList/OutputParserItemList.node.ts index b94b82fadac..774489bb889 100644 --- a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserItemList/OutputParserItemList.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserItemList/OutputParserItemList.node.ts @@ -1,6 +1,6 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -39,10 +39,10 @@ export class OutputParserItemList implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiOutputParser], + outputs: [NodeConnectionTypes.AiOutputParser], outputNames: ['Output Parser'], properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiChain, NodeConnectionType.AiAgent]), + getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]), { displayName: 'Options', name: 'options', diff --git a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/OutputParserStructured.node.ts b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/OutputParserStructured.node.ts index 08690209970..0367427a4ed 100644 --- a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/OutputParserStructured.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/OutputParserStructured.node.ts @@ -6,7 +6,7 @@ import { type ISupplyDataFunctions, type SupplyData, NodeOperationError, - NodeConnectionType, + NodeConnectionTypes, } from 'n8n-workflow'; import type { z } from 'zod'; @@ -46,10 +46,10 @@ export class OutputParserStructured implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiOutputParser], + outputs: [NodeConnectionTypes.AiOutputParser], outputNames: ['Output Parser'], properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiChain, NodeConnectionType.AiAgent]), + getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]), { ...schemaTypeField, displayOptions: { show: { '@version': [{ _cnd: { gte: 1.2 } }] } } }, { ...jsonSchemaExampleField, diff --git a/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverContextualCompression/RetrieverContextualCompression.node.ts b/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverContextualCompression/RetrieverContextualCompression.node.ts index feb70ecb43d..9ba7683a7d6 100644 --- a/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverContextualCompression/RetrieverContextualCompression.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverContextualCompression/RetrieverContextualCompression.node.ts @@ -5,7 +5,7 @@ import type { BaseRetriever } from '@langchain/core/retrievers'; import { ContextualCompressionRetriever } from 'langchain/retrievers/contextual_compression'; import { LLMChainExtractor } from 'langchain/retrievers/document_compressors/chain_extract'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -44,13 +44,13 @@ export class RetrieverContextualCompression implements INodeType { { displayName: 'Model', maxConnections: 1, - type: NodeConnectionType.AiLanguageModel, + type: NodeConnectionTypes.AiLanguageModel, required: true, }, { displayName: 'Retriever', maxConnections: 1, - type: NodeConnectionType.AiRetriever, + type: NodeConnectionTypes.AiRetriever, required: true, }, ], @@ -58,7 +58,7 @@ export class RetrieverContextualCompression implements INodeType { { displayName: 'Retriever', maxConnections: 1, - type: NodeConnectionType.AiRetriever, + type: NodeConnectionTypes.AiRetriever, }, ], properties: [], @@ -68,12 +68,12 @@ export class RetrieverContextualCompression implements INodeType { this.logger.debug('Supplying data for Contextual Compression Retriever'); const model = (await this.getInputConnectionData( - NodeConnectionType.AiLanguageModel, + NodeConnectionTypes.AiLanguageModel, itemIndex, )) as BaseLanguageModel; const baseRetriever = (await this.getInputConnectionData( - NodeConnectionType.AiRetriever, + NodeConnectionTypes.AiRetriever, itemIndex, )) as BaseRetriever; diff --git a/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverMultiQuery/RetrieverMultiQuery.node.ts b/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverMultiQuery/RetrieverMultiQuery.node.ts index 4bbc45f6d14..73d7b2ac1e2 100644 --- a/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverMultiQuery/RetrieverMultiQuery.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverMultiQuery/RetrieverMultiQuery.node.ts @@ -4,7 +4,7 @@ import type { BaseLanguageModel } from '@langchain/core/language_models/base'; import type { BaseRetriever } from '@langchain/core/retrievers'; import { MultiQueryRetriever } from 'langchain/retrievers/multi_query'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -44,13 +44,13 @@ export class RetrieverMultiQuery implements INodeType { { displayName: 'Model', maxConnections: 1, - type: NodeConnectionType.AiLanguageModel, + type: NodeConnectionTypes.AiLanguageModel, required: true, }, { displayName: 'Retriever', maxConnections: 1, - type: NodeConnectionType.AiRetriever, + type: NodeConnectionTypes.AiRetriever, required: true, }, ], @@ -58,7 +58,7 @@ export class RetrieverMultiQuery implements INodeType { { displayName: 'Retriever', maxConnections: 1, - type: NodeConnectionType.AiRetriever, + type: NodeConnectionTypes.AiRetriever, }, ], properties: [ @@ -89,12 +89,12 @@ export class RetrieverMultiQuery implements INodeType { const options = this.getNodeParameter('options', itemIndex, {}) as { queryCount?: number }; const model = (await this.getInputConnectionData( - NodeConnectionType.AiLanguageModel, + NodeConnectionTypes.AiLanguageModel, itemIndex, )) as BaseLanguageModel; const baseRetriever = (await this.getInputConnectionData( - NodeConnectionType.AiRetriever, + NodeConnectionTypes.AiRetriever, itemIndex, )) as BaseRetriever; diff --git a/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverVectorStore/RetrieverVectorStore.node.ts b/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverVectorStore/RetrieverVectorStore.node.ts index 915d9766dcc..7323eacb2d4 100644 --- a/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverVectorStore/RetrieverVectorStore.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverVectorStore/RetrieverVectorStore.node.ts @@ -1,7 +1,7 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ import type { VectorStore } from '@langchain/core/vectorstores'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -40,12 +40,12 @@ export class RetrieverVectorStore implements INodeType { { displayName: 'Vector Store', maxConnections: 1, - type: NodeConnectionType.AiVectorStore, + type: NodeConnectionTypes.AiVectorStore, required: true, }, ], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiRetriever], + outputs: [NodeConnectionTypes.AiRetriever], outputNames: ['Retriever'], properties: [ { @@ -63,7 +63,7 @@ export class RetrieverVectorStore implements INodeType { const topK = this.getNodeParameter('topK', itemIndex, 4) as number; const vectorStore = (await this.getInputConnectionData( - NodeConnectionType.AiVectorStore, + NodeConnectionTypes.AiVectorStore, itemIndex, )) as VectorStore; diff --git a/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverWorkflow/RetrieverWorkflow.node.ts b/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverWorkflow/RetrieverWorkflow.node.ts index 1291b92252f..b7026430da6 100644 --- a/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverWorkflow/RetrieverWorkflow.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverWorkflow/RetrieverWorkflow.node.ts @@ -4,7 +4,7 @@ import { Document } from '@langchain/core/documents'; import { BaseRetriever, type BaseRetrieverInput } from '@langchain/core/retrievers'; import type { SetField, SetNodeOptions } from 'n8n-nodes-base/dist/nodes/Set/v2/helpers/interfaces'; import * as manual from 'n8n-nodes-base/dist/nodes/Set/v2/manual.mode'; -import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; +import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; import type { IDataObject, IExecuteWorkflowInfo, @@ -66,7 +66,7 @@ export class RetrieverWorkflow implements INodeType { { displayName: 'Retriever', maxConnections: 1, - type: NodeConnectionType.AiRetriever, + type: NodeConnectionTypes.AiRetriever, }, ], properties: [ diff --git a/packages/@n8n/nodes-langchain/nodes/text_splitters/TextSplitterCharacterTextSplitter/TextSplitterCharacterTextSplitter.node.ts b/packages/@n8n/nodes-langchain/nodes/text_splitters/TextSplitterCharacterTextSplitter/TextSplitterCharacterTextSplitter.node.ts index 962af5bde23..87d0fae4753 100644 --- a/packages/@n8n/nodes-langchain/nodes/text_splitters/TextSplitterCharacterTextSplitter/TextSplitterCharacterTextSplitter.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/text_splitters/TextSplitterCharacterTextSplitter/TextSplitterCharacterTextSplitter.node.ts @@ -2,7 +2,7 @@ import type { CharacterTextSplitterParams } from '@langchain/textsplitters'; import { CharacterTextSplitter } from '@langchain/textsplitters'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -40,10 +40,10 @@ export class TextSplitterCharacterTextSplitter implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiTextSplitter], + outputs: [NodeConnectionTypes.AiTextSplitter], outputNames: ['Text Splitter'], properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiDocument]), + getConnectionHintNoticeField([NodeConnectionTypes.AiDocument]), { displayName: 'Separator', name: 'separator', diff --git a/packages/@n8n/nodes-langchain/nodes/text_splitters/TextSplitterRecursiveCharacterTextSplitter/TextSplitterRecursiveCharacterTextSplitter.node.ts b/packages/@n8n/nodes-langchain/nodes/text_splitters/TextSplitterRecursiveCharacterTextSplitter/TextSplitterRecursiveCharacterTextSplitter.node.ts index 4e376c39a37..e2a99402eb4 100644 --- a/packages/@n8n/nodes-langchain/nodes/text_splitters/TextSplitterRecursiveCharacterTextSplitter/TextSplitterRecursiveCharacterTextSplitter.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/text_splitters/TextSplitterRecursiveCharacterTextSplitter/TextSplitterRecursiveCharacterTextSplitter.node.ts @@ -5,7 +5,7 @@ import type { } from '@langchain/textsplitters'; import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -60,10 +60,10 @@ export class TextSplitterRecursiveCharacterTextSplitter implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiTextSplitter], + outputs: [NodeConnectionTypes.AiTextSplitter], outputNames: ['Text Splitter'], properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiDocument]), + getConnectionHintNoticeField([NodeConnectionTypes.AiDocument]), { displayName: 'Chunk Size', name: 'chunkSize', diff --git a/packages/@n8n/nodes-langchain/nodes/text_splitters/TextSplitterTokenSplitter/TextSplitterTokenSplitter.node.ts b/packages/@n8n/nodes-langchain/nodes/text_splitters/TextSplitterTokenSplitter/TextSplitterTokenSplitter.node.ts index b5dade396d7..c8dfc7b3ec2 100644 --- a/packages/@n8n/nodes-langchain/nodes/text_splitters/TextSplitterTokenSplitter/TextSplitterTokenSplitter.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/text_splitters/TextSplitterTokenSplitter/TextSplitterTokenSplitter.node.ts @@ -1,7 +1,7 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ import { TokenTextSplitter } from '@langchain/textsplitters'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -39,10 +39,10 @@ export class TextSplitterTokenSplitter implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiTextSplitter], + outputs: [NodeConnectionTypes.AiTextSplitter], outputNames: ['Text Splitter'], properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiDocument]), + getConnectionHintNoticeField([NodeConnectionTypes.AiDocument]), { displayName: 'Chunk Size', name: 'chunkSize', diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolCalculator/ToolCalculator.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolCalculator/ToolCalculator.node.ts index 6d67a045551..f60912112ed 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolCalculator/ToolCalculator.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolCalculator/ToolCalculator.node.ts @@ -1,7 +1,7 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ import { Calculator } from '@langchain/community/tools/calculator'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -40,9 +40,9 @@ export class ToolCalculator implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiTool], + outputs: [NodeConnectionTypes.AiTool], outputNames: ['Tool'], - properties: [getConnectionHintNoticeField([NodeConnectionType.AiAgent])], + properties: [getConnectionHintNoticeField([NodeConnectionTypes.AiAgent])], }; async supplyData(this: ISupplyDataFunctions): Promise { diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts index cf412ea5d36..02dec8cc710 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts @@ -13,7 +13,7 @@ import type { ExecutionError, IDataObject, } from 'n8n-workflow'; -import { jsonParse, NodeConnectionType, NodeOperationError } from 'n8n-workflow'; +import { jsonParse, NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; import { buildInputSchemaField, @@ -54,10 +54,10 @@ export class ToolCode implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiTool], + outputs: [NodeConnectionTypes.AiTool], outputNames: ['Tool'], properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiAgent]), + getConnectionHintNoticeField([NodeConnectionTypes.AiAgent]), { displayName: 'See an example of a conversational agent with custom tool written in JavaScript here.', @@ -221,7 +221,7 @@ export class ToolCode implements INodeType { }; const toolHandler = async (query: string | IDataObject): Promise => { - const { index } = this.addInputData(NodeConnectionType.AiTool, [[{ json: { query } }]]); + const { index } = this.addInputData(NodeConnectionTypes.AiTool, [[{ json: { query } }]]); let response: string = ''; let executionError: ExecutionError | undefined; @@ -245,9 +245,9 @@ export class ToolCode implements INodeType { } if (executionError) { - void this.addOutputData(NodeConnectionType.AiTool, index, executionError); + void this.addOutputData(NodeConnectionTypes.AiTool, index, executionError); } else { - void this.addOutputData(NodeConnectionType.AiTool, index, [[{ json: { response } }]]); + void this.addOutputData(NodeConnectionTypes.AiTool, index, [[{ json: { response } }]]); } return response; diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts index bfdd3e7ace4..1756abf549d 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts @@ -8,7 +8,11 @@ import type { IHttpRequestMethods, IHttpRequestOptions, } from 'n8n-workflow'; -import { NodeConnectionType, NodeOperationError, tryToParseAlphanumericString } from 'n8n-workflow'; +import { + NodeConnectionTypes, + NodeOperationError, + tryToParseAlphanumericString, +} from 'n8n-workflow'; import { N8nTool } from '@utils/N8nTool'; import { getConnectionHintNoticeField } from '@utils/sharedFields'; @@ -62,10 +66,10 @@ export class ToolHttpRequest implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiTool], + outputs: [NodeConnectionTypes.AiTool], outputNames: ['Tool'], properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiAgent]), + getConnectionHintNoticeField([NodeConnectionTypes.AiAgent]), { displayName: 'Description', name: 'toolDescription', diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts index 0bd1b1a8a6b..7602322de33 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts @@ -14,7 +14,7 @@ import type { NodeApiError, ISupplyDataFunctions, } from 'n8n-workflow'; -import { NodeConnectionType, NodeOperationError, jsonParse } from 'n8n-workflow'; +import { NodeConnectionTypes, NodeOperationError, jsonParse } from 'n8n-workflow'; import { z } from 'zod'; import type { @@ -585,7 +585,7 @@ export const configureToolFunction = ( optimizeResponse: (response: string) => string, ) => { return async (query: string | IDataObject): Promise => { - const { index } = ctx.addInputData(NodeConnectionType.AiTool, [[{ json: { query } }]]); + const { index } = ctx.addInputData(NodeConnectionTypes.AiTool, [[{ json: { query } }]]); // Clone options and rawRequestOptions to avoid mutating the original objects const options: IHttpRequestOptions | null = structuredClone(requestOptions); @@ -792,9 +792,9 @@ export const configureToolFunction = ( } if (executionError) { - void ctx.addOutputData(NodeConnectionType.AiTool, index, executionError as ExecutionError); + void ctx.addOutputData(NodeConnectionTypes.AiTool, index, executionError as ExecutionError); } else { - void ctx.addOutputData(NodeConnectionType.AiTool, index, [[{ json: { response } }]]); + void ctx.addOutputData(NodeConnectionTypes.AiTool, index, [[{ json: { response } }]]); } return response; diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolSerpApi/ToolSerpApi.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolSerpApi/ToolSerpApi.node.ts index 7a7a09b933a..514d84aeaa3 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolSerpApi/ToolSerpApi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolSerpApi/ToolSerpApi.node.ts @@ -1,7 +1,7 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ import { SerpAPI } from '@langchain/community/tools/serpapi'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -39,7 +39,7 @@ export class ToolSerpApi implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiTool], + outputs: [NodeConnectionTypes.AiTool], outputNames: ['Tool'], credentials: [ { @@ -48,7 +48,7 @@ export class ToolSerpApi implements INodeType { }, ], properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiAgent]), + getConnectionHintNoticeField([NodeConnectionTypes.AiAgent]), { displayName: 'Options', name: 'options', diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolVectorStore/ToolVectorStore.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolVectorStore/ToolVectorStore.node.ts index 97113643f3f..7a9e4a7f1aa 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolVectorStore/ToolVectorStore.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolVectorStore/ToolVectorStore.node.ts @@ -8,7 +8,7 @@ import type { ISupplyDataFunctions, SupplyData, } from 'n8n-workflow'; -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import { logWrapper } from '@utils/logWrapper'; import { getConnectionHintNoticeField } from '@utils/sharedFields'; @@ -44,21 +44,21 @@ export class ToolVectorStore implements INodeType { { displayName: 'Vector Store', maxConnections: 1, - type: NodeConnectionType.AiVectorStore, + type: NodeConnectionTypes.AiVectorStore, required: true, }, { displayName: 'Model', maxConnections: 1, - type: NodeConnectionType.AiLanguageModel, + type: NodeConnectionTypes.AiLanguageModel, required: true, }, ], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiTool], + outputs: [NodeConnectionTypes.AiTool], outputNames: ['Tool'], properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiAgent]), + getConnectionHintNoticeField([NodeConnectionTypes.AiAgent]), { displayName: 'Data Name', name: 'name', @@ -97,12 +97,12 @@ export class ToolVectorStore implements INodeType { const topK = this.getNodeParameter('topK', itemIndex, 4) as number; const vectorStore = (await this.getInputConnectionData( - NodeConnectionType.AiVectorStore, + NodeConnectionTypes.AiVectorStore, itemIndex, )) as VectorStore; const llm = (await this.getInputConnectionData( - NodeConnectionType.AiLanguageModel, + NodeConnectionTypes.AiLanguageModel, 0, )) as BaseLanguageModel; diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWikipedia/ToolWikipedia.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWikipedia/ToolWikipedia.node.ts index 4eef3a1b450..abf2e82dca1 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWikipedia/ToolWikipedia.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWikipedia/ToolWikipedia.node.ts @@ -1,7 +1,7 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ import { WikipediaQueryRun } from '@langchain/community/tools/wikipedia_query_run'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -39,9 +39,9 @@ export class ToolWikipedia implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiTool], + outputs: [NodeConnectionTypes.AiTool], outputNames: ['Tool'], - properties: [getConnectionHintNoticeField([NodeConnectionType.AiAgent])], + properties: [getConnectionHintNoticeField([NodeConnectionTypes.AiAgent])], }; async supplyData(this: ISupplyDataFunctions): Promise { diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWolframAlpha/ToolWolframAlpha.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWolframAlpha/ToolWolframAlpha.node.ts index 162b78ba8ec..572a678627f 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWolframAlpha/ToolWolframAlpha.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWolframAlpha/ToolWolframAlpha.node.ts @@ -1,7 +1,7 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ import { WolframAlphaTool } from '@langchain/community/tools/wolframalpha'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -45,9 +45,9 @@ export class ToolWolframAlpha implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiTool], + outputs: [NodeConnectionTypes.AiTool], outputNames: ['Tool'], - properties: [getConnectionHintNoticeField([NodeConnectionType.AiAgent])], + properties: [getConnectionHintNoticeField([NodeConnectionTypes.AiAgent])], }; async supplyData(this: ISupplyDataFunctions): Promise { diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/ToolWorkflowV1.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/ToolWorkflowV1.node.ts index 4c33c86b4e7..993f48e195e 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/ToolWorkflowV1.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/ToolWorkflowV1.node.ts @@ -20,7 +20,7 @@ import type { ITaskMetadata, INodeTypeBaseDescription, } from 'n8n-workflow'; -import { NodeConnectionType, NodeOperationError, jsonParse } from 'n8n-workflow'; +import { NodeConnectionTypes, NodeOperationError, jsonParse } from 'n8n-workflow'; import { versionDescription } from './versionDescription'; import type { DynamicZodObject } from '../../../../types/zod.types'; @@ -148,7 +148,7 @@ export class ToolWorkflowV1 implements INodeType { query: string | IDataObject, runManager?: CallbackManagerForToolRun, ): Promise => { - const { index } = this.addInputData(NodeConnectionType.AiTool, [[{ json: { query } }]]); + const { index } = this.addInputData(NodeConnectionTypes.AiTool, [[{ json: { query } }]]); let response: string = ''; let executionError: ExecutionError | undefined; @@ -189,12 +189,12 @@ export class ToolWorkflowV1 implements INodeType { } if (executionError) { - void this.addOutputData(NodeConnectionType.AiTool, index, executionError, metadata); + void this.addOutputData(NodeConnectionTypes.AiTool, index, executionError, metadata); } else { // Output always needs to be an object // so we try to parse the response as JSON and if it fails we just return the string wrapped in an object const json = jsonParse(response, { fallbackValue: { response } }); - void this.addOutputData(NodeConnectionType.AiTool, index, [[{ json }]], metadata); + void this.addOutputData(NodeConnectionTypes.AiTool, index, [[{ json }]], metadata); } return response; }; diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/versionDescription.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/versionDescription.ts index f46ef05c0d2..87c4c02748e 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/versionDescription.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/versionDescription.ts @@ -1,7 +1,7 @@ /* eslint-disable n8n-nodes-base/node-filename-against-convention */ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ import type { INodeTypeDescription } from 'n8n-workflow'; -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import { inputSchemaField, @@ -36,10 +36,10 @@ export const versionDescription: INodeTypeDescription = { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiTool], + outputs: [NodeConnectionTypes.AiTool], outputNames: ['Tool'], properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiAgent]), + getConnectionHintNoticeField([NodeConnectionTypes.AiAgent]), { displayName: 'See an example of a workflow to suggest meeting slots using AI here.', diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts index 57a6d1fc84a..2c7d34374bd 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts @@ -21,7 +21,7 @@ import type { import { generateZodSchema, jsonParse, - NodeConnectionType, + NodeConnectionTypes, NodeOperationError, parseErrorMetadata, traverseNodeParameters, @@ -113,7 +113,7 @@ export class WorkflowToolService { } void context.addOutputData( - NodeConnectionType.AiTool, + NodeConnectionTypes.AiTool, localRunIndex, [responseData], metadata, @@ -126,7 +126,7 @@ export class WorkflowToolService { const metadata = parseErrorMetadata(error); void context.addOutputData( - NodeConnectionType.AiTool, + NodeConnectionTypes.AiTool, localRunIndex, executionError, metadata, diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/versionDescription.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/versionDescription.ts index cd56a0f5d74..2004a9337f0 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/versionDescription.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/versionDescription.ts @@ -1,6 +1,6 @@ /* eslint-disable n8n-nodes-base/node-filename-against-convention */ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ -import { NodeConnectionType, type INodeTypeDescription } from 'n8n-workflow'; +import { NodeConnectionTypes, type INodeTypeDescription } from 'n8n-workflow'; import { getConnectionHintNoticeField } from '../../../../utils/sharedFields'; @@ -14,10 +14,10 @@ export const versionDescription: INodeTypeDescription = { }, version: [2, 2.1], inputs: [], - outputs: [NodeConnectionType.AiTool], + outputs: [NodeConnectionTypes.AiTool], outputNames: ['Tool'], properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiAgent]), + getConnectionHintNoticeField([NodeConnectionTypes.AiAgent]), { displayName: 'See an example of a workflow to suggest meeting slots using AI here.', diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts index fab05be8ece..3401bc0b18c 100644 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts @@ -1,6 +1,6 @@ import type { BaseChatMemory } from '@langchain/community/memory/chat_memory'; import { pick } from 'lodash'; -import { Node, NodeConnectionType } from 'n8n-workflow'; +import { Node, NodeConnectionTypes } from 'n8n-workflow'; import type { IDataObject, IWebhookFunctions, @@ -70,12 +70,12 @@ export class ChatTrigger extends Node { { displayName: 'Memory', maxConnections: 1, - type: '${NodeConnectionType.AiMemory}', + type: '${NodeConnectionTypes.AiMemory}', required: true, } ]; })() }}`, - outputs: [NodeConnectionType.Main], + outputs: [NodeConnectionTypes.Main], credentials: [ { // eslint-disable-next-line n8n-nodes-base/node-class-description-credentials-name-unsuffixed @@ -554,7 +554,7 @@ ${cssVariables} if (bodyData.action === 'loadPreviousSession') { if (options?.loadPreviousSession === 'memory') { - const memory = (await ctx.getInputConnectionData(NodeConnectionType.AiMemory, 0)) as + const memory = (await ctx.getInputConnectionData(NodeConnectionTypes.AiMemory, 0)) as | BaseChatMemory | undefined; const messages = ((await memory?.chatHistory.getMessages()) ?? []) diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ManualChatTrigger/ManualChatTrigger.node.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ManualChatTrigger/ManualChatTrigger.node.ts index 816b56b59ad..f746a32f025 100644 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ManualChatTrigger/ManualChatTrigger.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ManualChatTrigger/ManualChatTrigger.node.ts @@ -3,7 +3,7 @@ import { type INodeType, type INodeTypeDescription, type ITriggerResponse, - NodeConnectionType, + NodeConnectionTypes, } from 'n8n-workflow'; export class ManualChatTrigger implements INodeType { @@ -35,7 +35,7 @@ export class ManualChatTrigger implements INodeType { }, }, inputs: [], - outputs: [NodeConnectionType.Main], + outputs: [NodeConnectionTypes.Main], properties: [ { displayName: diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemory/VectorStoreInMemory.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemory/VectorStoreInMemory.node.ts index 1ba321ebce9..2f949db872c 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemory/VectorStoreInMemory.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemory/VectorStoreInMemory.node.ts @@ -2,12 +2,12 @@ import type { MemoryVectorStore } from 'langchain/vectorstores/memory'; import type { INodeProperties } from 'n8n-workflow'; import { createVectorStoreNode } from '../shared/createVectorStoreNode/createVectorStoreNode'; -import { MemoryVectorStoreManager } from '../shared/MemoryVectorStoreManager'; +import { MemoryVectorStoreManager } from '../shared/MemoryManager/MemoryVectorStoreManager'; const insertFields: INodeProperties[] = [ { displayName: - 'The embedded data are stored in the server memory, so they will be lost when the server is restarted. Additionally, if the amount of data is too large, it may cause the server to crash due to insufficient memory.', + 'For experimental use only: Data is stored in memory and will be lost if n8n restarts. Data may also be cleared if available memory gets low. More info', name: 'notice', type: 'notice', default: '', @@ -48,7 +48,7 @@ export class VectorStoreInMemory extends createVectorStoreNode { const items = this.getInputData(0); const embeddings = (await this.getInputConnectionData( - NodeConnectionType.AiEmbedding, + NodeConnectionTypes.AiEmbedding, 0, )) as Embeddings; const memoryKey = this.getNodeParameter('memoryKey', 0) as string; const clearStore = this.getNodeParameter('clearStore', 0) as boolean; - const documentInput = (await this.getInputConnectionData(NodeConnectionType.AiDocument, 0)) as + const documentInput = (await this.getInputConnectionData(NodeConnectionTypes.AiDocument, 0)) as | N8nJsonLoader | Array>>; @@ -103,7 +103,7 @@ export class VectorStoreInMemoryInsert implements INodeType { const workflowId = this.getWorkflow().id; - const vectorStoreInstance = MemoryVectorStoreManager.getInstance(embeddings); + const vectorStoreInstance = MemoryVectorStoreManager.getInstance(embeddings, this.logger); await vectorStoreInstance.addDocuments( `${workflowId}__${memoryKey}`, processedDocuments, diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemoryLoad/VectorStoreInMemoryLoad.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemoryLoad/VectorStoreInMemoryLoad.node.ts index dd2def31e36..4d175811369 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemoryLoad/VectorStoreInMemoryLoad.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemoryLoad/VectorStoreInMemoryLoad.node.ts @@ -1,7 +1,7 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ import type { Embeddings } from '@langchain/core/embeddings'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -10,7 +10,7 @@ import { import { logWrapper } from '@utils/logWrapper'; -import { MemoryVectorStoreManager } from '../shared/MemoryVectorStoreManager'; +import { MemoryVectorStoreManager } from '../shared/MemoryManager/MemoryVectorStoreManager'; // This node is deprecated. Use VectorStoreInMemory instead. export class VectorStoreInMemoryLoad implements INodeType { @@ -43,11 +43,11 @@ export class VectorStoreInMemoryLoad implements INodeType { { displayName: 'Embedding', maxConnections: 1, - type: NodeConnectionType.AiEmbedding, + type: NodeConnectionTypes.AiEmbedding, required: true, }, ], - outputs: [NodeConnectionType.AiVectorStore], + outputs: [NodeConnectionTypes.AiVectorStore], outputNames: ['Vector Store'], properties: [ { @@ -63,14 +63,14 @@ export class VectorStoreInMemoryLoad implements INodeType { async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { const embeddings = (await this.getInputConnectionData( - NodeConnectionType.AiEmbedding, + NodeConnectionTypes.AiEmbedding, itemIndex, )) as Embeddings; const workflowId = this.getWorkflow().id; const memoryKey = this.getNodeParameter('memoryKey', 0) as string; - const vectorStoreSingleton = MemoryVectorStoreManager.getInstance(embeddings); + const vectorStoreSingleton = MemoryVectorStoreManager.getInstance(embeddings, this.logger); const vectorStoreInstance = await vectorStoreSingleton.getVectorStore( `${workflowId}__${memoryKey}`, ); diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePineconeInsert/VectorStorePineconeInsert.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePineconeInsert/VectorStorePineconeInsert.node.ts index 7bf9b4d94d1..025d9868a8a 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePineconeInsert/VectorStorePineconeInsert.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePineconeInsert/VectorStorePineconeInsert.node.ts @@ -7,7 +7,7 @@ import { type INodeType, type INodeTypeDescription, type INodeExecutionData, - NodeConnectionType, + NodeConnectionTypes, } from 'n8n-workflow'; import type { N8nJsonLoader } from '@utils/N8nJsonLoader'; @@ -51,21 +51,21 @@ export class VectorStorePineconeInsert implements INodeType { }, ], inputs: [ - NodeConnectionType.Main, + NodeConnectionTypes.Main, { displayName: 'Document', maxConnections: 1, - type: NodeConnectionType.AiDocument, + type: NodeConnectionTypes.AiDocument, required: true, }, { displayName: 'Embedding', maxConnections: 1, - type: NodeConnectionType.AiEmbedding, + type: NodeConnectionTypes.AiEmbedding, required: true, }, ], - outputs: [NodeConnectionType.Main], + outputs: [NodeConnectionTypes.Main], properties: [ pineconeIndexRLC, { @@ -106,12 +106,12 @@ export class VectorStorePineconeInsert implements INodeType { const credentials = await this.getCredentials('pineconeApi'); - const documentInput = (await this.getInputConnectionData(NodeConnectionType.AiDocument, 0)) as + const documentInput = (await this.getInputConnectionData(NodeConnectionTypes.AiDocument, 0)) as | N8nJsonLoader | Array>>; const embeddings = (await this.getInputConnectionData( - NodeConnectionType.AiEmbedding, + NodeConnectionTypes.AiEmbedding, 0, )) as Embeddings; diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePineconeLoad/VectorStorePineconeLoad.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePineconeLoad/VectorStorePineconeLoad.node.ts index 8e0300380ab..a87a06c7cbb 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePineconeLoad/VectorStorePineconeLoad.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePineconeLoad/VectorStorePineconeLoad.node.ts @@ -3,7 +3,7 @@ import type { PineconeStoreParams } from '@langchain/pinecone'; import { PineconeStore } from '@langchain/pinecone'; import { Pinecone } from '@pinecone-database/pinecone'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -54,11 +54,11 @@ export class VectorStorePineconeLoad implements INodeType { { displayName: 'Embedding', maxConnections: 1, - type: NodeConnectionType.AiEmbedding, + type: NodeConnectionTypes.AiEmbedding, required: true, }, ], - outputs: [NodeConnectionType.AiVectorStore], + outputs: [NodeConnectionTypes.AiVectorStore], outputNames: ['Vector Store'], properties: [ pineconeIndexRLC, @@ -95,7 +95,7 @@ export class VectorStorePineconeLoad implements INodeType { const credentials = await this.getCredentials('pineconeApi'); const embeddings = (await this.getInputConnectionData( - NodeConnectionType.AiEmbedding, + NodeConnectionTypes.AiEmbedding, itemIndex, )) as Embeddings; diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabaseInsert/VectorStoreSupabaseInsert.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabaseInsert/VectorStoreSupabaseInsert.node.ts index 18906270fcd..cd1a579e32f 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabaseInsert/VectorStoreSupabaseInsert.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabaseInsert/VectorStoreSupabaseInsert.node.ts @@ -7,7 +7,7 @@ import { type INodeType, type INodeTypeDescription, type INodeExecutionData, - NodeConnectionType, + NodeConnectionTypes, } from 'n8n-workflow'; import type { N8nJsonLoader } from '@utils/N8nJsonLoader'; @@ -51,21 +51,21 @@ export class VectorStoreSupabaseInsert implements INodeType { }, ], inputs: [ - NodeConnectionType.Main, + NodeConnectionTypes.Main, { displayName: 'Document', maxConnections: 1, - type: NodeConnectionType.AiDocument, + type: NodeConnectionTypes.AiDocument, required: true, }, { displayName: 'Embedding', maxConnections: 1, - type: NodeConnectionType.AiEmbedding, + type: NodeConnectionTypes.AiEmbedding, required: true, }, ], - outputs: [NodeConnectionType.Main], + outputs: [NodeConnectionTypes.Main], properties: [ { displayName: @@ -102,12 +102,12 @@ export class VectorStoreSupabaseInsert implements INodeType { const queryName = this.getNodeParameter('queryName', 0) as string; const credentials = await this.getCredentials('supabaseApi'); - const documentInput = (await this.getInputConnectionData(NodeConnectionType.AiDocument, 0)) as + const documentInput = (await this.getInputConnectionData(NodeConnectionTypes.AiDocument, 0)) as | N8nJsonLoader | Array>>; const embeddings = (await this.getInputConnectionData( - NodeConnectionType.AiEmbedding, + NodeConnectionTypes.AiEmbedding, 0, )) as Embeddings; const client = createClient(credentials.host as string, credentials.serviceRole as string); diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabaseLoad/VectorStoreSupabaseLoad.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabaseLoad/VectorStoreSupabaseLoad.node.ts index 3f84f037824..b8fd7d78b3e 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabaseLoad/VectorStoreSupabaseLoad.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabaseLoad/VectorStoreSupabaseLoad.node.ts @@ -7,7 +7,7 @@ import { type INodeTypeDescription, type ISupplyDataFunctions, type SupplyData, - NodeConnectionType, + NodeConnectionTypes, } from 'n8n-workflow'; import { getMetadataFiltersValues } from '@utils/helpers'; @@ -54,11 +54,11 @@ export class VectorStoreSupabaseLoad implements INodeType { { displayName: 'Embedding', maxConnections: 1, - type: NodeConnectionType.AiEmbedding, + type: NodeConnectionTypes.AiEmbedding, required: true, }, ], - outputs: [NodeConnectionType.AiVectorStore], + outputs: [NodeConnectionTypes.AiVectorStore], outputNames: ['Vector Store'], properties: [ supabaseTableNameRLC, @@ -93,7 +93,7 @@ export class VectorStoreSupabaseLoad implements INodeType { const credentials = await this.getCredentials('supabaseApi'); const embeddings = (await this.getInputConnectionData( - NodeConnectionType.AiEmbedding, + NodeConnectionTypes.AiEmbedding, 0, )) as Embeddings; diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreZepInsert/VectorStoreZepInsert.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreZepInsert/VectorStoreZepInsert.node.ts index 4892d8ad85d..652025bd15e 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreZepInsert/VectorStoreZepInsert.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreZepInsert/VectorStoreZepInsert.node.ts @@ -6,7 +6,7 @@ import { type INodeType, type INodeTypeDescription, type INodeExecutionData, - NodeConnectionType, + NodeConnectionTypes, } from 'n8n-workflow'; import type { N8nJsonLoader } from '@utils/N8nJsonLoader'; @@ -47,21 +47,21 @@ export class VectorStoreZepInsert implements INodeType { }, ], inputs: [ - NodeConnectionType.Main, + NodeConnectionTypes.Main, { displayName: 'Document', maxConnections: 1, - type: NodeConnectionType.AiDocument, + type: NodeConnectionTypes.AiDocument, required: true, }, { displayName: 'Embedding', maxConnections: 1, - type: NodeConnectionType.AiEmbedding, + type: NodeConnectionTypes.AiEmbedding, required: true, }, ], - outputs: [NodeConnectionType.Main], + outputs: [NodeConnectionTypes.Main], properties: [ { displayName: 'Collection Name', @@ -117,12 +117,12 @@ export class VectorStoreZepInsert implements INodeType { apiUrl: string; }>('zepApi'); - const documentInput = (await this.getInputConnectionData(NodeConnectionType.AiDocument, 0)) as + const documentInput = (await this.getInputConnectionData(NodeConnectionTypes.AiDocument, 0)) as | N8nJsonLoader | Array>>; const embeddings = (await this.getInputConnectionData( - NodeConnectionType.AiEmbedding, + NodeConnectionTypes.AiEmbedding, 0, )) as Embeddings; diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreZepLoad/VectorStoreZepLoad.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreZepLoad/VectorStoreZepLoad.node.ts index 040b845e57c..81542ebc2bb 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreZepLoad/VectorStoreZepLoad.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreZepLoad/VectorStoreZepLoad.node.ts @@ -2,7 +2,7 @@ import type { IZepConfig } from '@langchain/community/vectorstores/zep'; import { ZepVectorStore } from '@langchain/community/vectorstores/zep'; import type { Embeddings } from '@langchain/core/embeddings'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -50,11 +50,11 @@ export class VectorStoreZepLoad implements INodeType { { displayName: 'Embedding', maxConnections: 1, - type: NodeConnectionType.AiEmbedding, + type: NodeConnectionTypes.AiEmbedding, required: true, }, ], - outputs: [NodeConnectionType.AiVectorStore], + outputs: [NodeConnectionTypes.AiVectorStore], outputNames: ['Vector Store'], properties: [ { @@ -99,7 +99,7 @@ export class VectorStoreZepLoad implements INodeType { apiUrl: string; }>('zepApi'); const embeddings = (await this.getInputConnectionData( - NodeConnectionType.AiEmbedding, + NodeConnectionTypes.AiEmbedding, 0, )) as Embeddings; diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/MemoryCalculator.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/MemoryCalculator.ts new file mode 100644 index 00000000000..41ba3689dab --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/MemoryCalculator.ts @@ -0,0 +1,89 @@ +import type { Document } from '@langchain/core/documents'; +import type { MemoryVectorStore } from 'langchain/vectorstores/memory'; + +import type { IMemoryCalculator } from './types'; + +// Memory estimation constants +const FLOAT_SIZE_BYTES = 8; // Size of a float64 in bytes +const CHAR_SIZE_BYTES = 2; // Size of a JavaScript character in bytes(2 bytes per character in UTF-16) +const VECTOR_OVERHEAD_BYTES = 200; // Estimated overhead per vector +const EMBEDDING_DIMENSIONS = 1536; // Fixed embedding dimensions +const EMBEDDING_SIZE_BYTES = EMBEDDING_DIMENSIONS * FLOAT_SIZE_BYTES; +const AVG_METADATA_SIZE_BYTES = 100; // Average size for simple metadata + +/** + * Calculates memory usage for vector stores and documents + */ +export class MemoryCalculator implements IMemoryCalculator { + /** + * Fast batch size estimation for multiple documents + */ + estimateBatchSize(documents: Document[]): number { + if (documents.length === 0) return 0; + + let totalContentSize = 0; + let totalMetadataSize = 0; + + // Single pass through documents for content and metadata estimation + for (const doc of documents) { + if (doc.pageContent) { + totalContentSize += doc.pageContent.length * CHAR_SIZE_BYTES; + } + + // Metadata size estimation + if (doc.metadata) { + // For simple objects, estimate based on key count + const metadataKeys = Object.keys(doc.metadata).length; + if (metadataKeys > 0) { + // For each key, estimate the key name plus a typical value + // plus some overhead for object structure + totalMetadataSize += metadataKeys * AVG_METADATA_SIZE_BYTES; + } + } + } + + // Fixed size components (embedding vectors and overhead) + // Each embedding is a fixed-size array of floating point numbers + const embeddingSize = documents.length * EMBEDDING_SIZE_BYTES; + + // Object overhead, each vector is stored with additional JS object structure + const overhead = documents.length * VECTOR_OVERHEAD_BYTES; + + // Calculate total batch size with a safety factor to avoid underestimation + const calculatedSize = totalContentSize + totalMetadataSize + embeddingSize + overhead; + + return Math.ceil(calculatedSize); + } + + /** + * Calculate the size of a vector store by examining its contents + */ + calculateVectorStoreSize(vectorStore: MemoryVectorStore): number { + if (!vectorStore.memoryVectors || vectorStore.memoryVectors.length === 0) { + return 0; + } + + let storeSize = 0; + + // Calculate size of each vector + for (const vector of vectorStore.memoryVectors) { + // Size of embedding (float64 array) + storeSize += vector.embedding.length * FLOAT_SIZE_BYTES; + + // Size of content string (2 bytes per character in JS) + storeSize += vector.content ? vector.content.length * CHAR_SIZE_BYTES : 0; + + // Estimate metadata size + if (vector.metadata) { + // Use a more accurate calculation for metadata + const metadataStr = JSON.stringify(vector.metadata); + storeSize += metadataStr.length * CHAR_SIZE_BYTES; + } + + // Add overhead for object structure + storeSize += VECTOR_OVERHEAD_BYTES; + } + + return Math.ceil(storeSize); + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/MemoryVectorStoreManager.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/MemoryVectorStoreManager.ts new file mode 100644 index 00000000000..7fad05bce7a --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/MemoryVectorStoreManager.ts @@ -0,0 +1,311 @@ +import type { Document } from '@langchain/core/documents'; +import type { Embeddings } from '@langchain/core/embeddings'; +import { MemoryVectorStore } from 'langchain/vectorstores/memory'; +import type { Logger } from 'n8n-workflow'; + +import { getConfig, mbToBytes, hoursToMs } from './config'; +import { MemoryCalculator } from './MemoryCalculator'; +import { StoreCleanupService } from './StoreCleanupService'; +import type { VectorStoreMetadata, VectorStoreStats } from './types'; + +/** + * Manages in-memory vector stores with memory limits and auto-cleanup + */ +export class MemoryVectorStoreManager { + private static instance: MemoryVectorStoreManager | null = null; + + // Storage + protected vectorStoreBuffer: Map; + + protected storeMetadata: Map; + + protected memoryUsageBytes: number = 0; + + // Dependencies + protected memoryCalculator: MemoryCalculator; + + protected cleanupService: StoreCleanupService; + + protected static logger: Logger; + + // Config values + protected maxMemorySizeBytes: number; + + protected inactiveTtlMs: number; + + // Inactive TTL cleanup timer + protected ttlCleanupIntervalId: NodeJS.Timeout | null = null; + + protected constructor( + protected embeddings: Embeddings, + protected logger: Logger, + ) { + // Initialize storage + this.vectorStoreBuffer = new Map(); + this.storeMetadata = new Map(); + this.logger = logger; + + const config = getConfig(); + this.maxMemorySizeBytes = mbToBytes(config.maxMemoryMB); + this.inactiveTtlMs = hoursToMs(config.ttlHours); + + // Initialize services + this.memoryCalculator = new MemoryCalculator(); + this.cleanupService = new StoreCleanupService( + this.maxMemorySizeBytes, + this.inactiveTtlMs, + this.vectorStoreBuffer, + this.storeMetadata, + this.handleCleanup.bind(this), + ); + + this.setupTtlCleanup(); + } + + /** + * Get singleton instance + */ + static getInstance(embeddings: Embeddings, logger: Logger): MemoryVectorStoreManager { + if (!MemoryVectorStoreManager.instance) { + MemoryVectorStoreManager.instance = new MemoryVectorStoreManager(embeddings, logger); + } else { + // We need to update the embeddings in the existing instance. + // This is important as embeddings instance is wrapped in a logWrapper, + // which relies on supplyDataFunctions context which changes on each workflow run + MemoryVectorStoreManager.instance.embeddings = embeddings; + MemoryVectorStoreManager.instance.vectorStoreBuffer.forEach((vectorStoreInstance) => { + vectorStoreInstance.embeddings = embeddings; + }); + } + + return MemoryVectorStoreManager.instance; + } + + /** + * Set up timer for TTL-based cleanup + */ + private setupTtlCleanup(): void { + // Skip setup if TTL is disabled + if (this.inactiveTtlMs <= 0) { + return; + } + + // Cleanup check interval (run every hour) + const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; + + // Clear any existing interval + if (this.ttlCleanupIntervalId) { + clearInterval(this.ttlCleanupIntervalId); + } + + // Setup new interval for TTL cleanup + this.ttlCleanupIntervalId = setInterval(() => { + this.cleanupService.cleanupInactiveStores(); + }, CLEANUP_INTERVAL_MS); + } + + /** + * Handle cleanup events from the cleanup service + */ + private handleCleanup(removedKeys: string[], freedBytes: number, reason: 'ttl' | 'memory'): void { + // Update total memory usage + this.memoryUsageBytes -= freedBytes; + + // Log cleanup event + if (reason === 'ttl') { + const ttlHours = Math.round(this.inactiveTtlMs / (60 * 60 * 1000)); + this.logger.info( + `TTL cleanup: removed ${removedKeys.length} inactive vector stores (${ttlHours}h TTL) to free ${Math.round(freedBytes / (1024 * 1024))}MB of memory`, + ); + } else { + this.logger.info( + `Memory cleanup: removed ${removedKeys.length} oldest vector stores to free ${Math.round(freedBytes / (1024 * 1024))}MB of memory`, + ); + } + } + + /** + * Get or create a vector store by key + */ + async getVectorStore(memoryKey: string): Promise { + let vectorStoreInstance = this.vectorStoreBuffer.get(memoryKey); + + if (!vectorStoreInstance) { + vectorStoreInstance = await MemoryVectorStore.fromExistingIndex(this.embeddings); + this.vectorStoreBuffer.set(memoryKey, vectorStoreInstance); + + this.storeMetadata.set(memoryKey, { + size: 0, + createdAt: new Date(), + lastAccessed: new Date(), + }); + } else { + const metadata = this.storeMetadata.get(memoryKey); + if (metadata) { + metadata.lastAccessed = new Date(); + } + } + + return vectorStoreInstance; + } + + /** + * Reset a store's metadata when it's cleared + */ + protected clearStoreMetadata(memoryKey: string): void { + const metadata = this.storeMetadata.get(memoryKey); + if (metadata) { + this.memoryUsageBytes -= metadata.size; + metadata.size = 0; + metadata.lastAccessed = new Date(); + } + } + + /** + * Get memory usage in bytes + */ + getMemoryUsage(): number { + return this.memoryUsageBytes; + } + + /** + * Get memory usage as a formatted string (MB) + */ + getMemoryUsageFormatted(): string { + return `${Math.round(this.memoryUsageBytes / (1024 * 1024))}MB`; + } + + /** + * Recalculate memory usage from actual vector store contents + * This ensures tracking accuracy for large stores + */ + recalculateMemoryUsage(): void { + this.memoryUsageBytes = 0; + + // Recalculate for each store + for (const [key, vectorStore] of this.vectorStoreBuffer.entries()) { + const storeSize = this.memoryCalculator.calculateVectorStoreSize(vectorStore); + + // Update metadata + const metadata = this.storeMetadata.get(key); + if (metadata) { + metadata.size = storeSize; + this.memoryUsageBytes += storeSize; + } + } + + this.logger.debug(`Recalculated vector store memory: ${this.getMemoryUsageFormatted()}`); + } + + /** + * Add documents to a vector store + */ + async addDocuments( + memoryKey: string, + documents: Document[], + clearStore?: boolean, + ): Promise { + if (clearStore) { + this.clearStoreMetadata(memoryKey); + this.vectorStoreBuffer.delete(memoryKey); + } + + // Fast batch estimation instead of per-document calculation + const estimatedAddedSize = this.memoryCalculator.estimateBatchSize(documents); + + // Clean up old stores if necessary + this.cleanupService.cleanupOldestStores(estimatedAddedSize); + + const vectorStoreInstance = await this.getVectorStore(memoryKey); + + // Get vector count before adding documents + const vectorCountBefore = vectorStoreInstance.memoryVectors?.length || 0; + + await vectorStoreInstance.addDocuments(documents); + + // Update store metadata and memory tracking + const metadata = this.storeMetadata.get(memoryKey); + if (metadata) { + metadata.size += estimatedAddedSize; + metadata.lastAccessed = new Date(); + this.memoryUsageBytes += estimatedAddedSize; + } + + // Get updated vector count + const vectorCount = vectorStoreInstance.memoryVectors?.length || 0; + + // Periodically recalculate actual memory usage to avoid drift + if ( + (vectorCount > 0 && vectorCount % 100 === 0) || + documents.length > 20 || + (vectorCountBefore === 0 && vectorCount > 0) + ) { + this.recalculateMemoryUsage(); + } + + // Logging memory usage + const maxMemoryMB = + this.maxMemorySizeBytes > 0 + ? (this.maxMemorySizeBytes / (1024 * 1024)).toFixed(0) + : 'unlimited'; + + this.logger.debug( + `Vector store memory: ${this.getMemoryUsageFormatted()}/${maxMemoryMB}MB (${vectorCount} vectors in ${this.vectorStoreBuffer.size} stores)`, + ); + } + + /** + * Get statistics about the vector store memory usage + */ + getStats(): VectorStoreStats { + const now = Date.now(); + let inactiveStoreCount = 0; + + // Always recalculate when getting stats to ensure accuracy + this.recalculateMemoryUsage(); + + const stats: VectorStoreStats = { + totalSizeBytes: this.memoryUsageBytes, + totalSizeMB: Math.round((this.memoryUsageBytes / (1024 * 1024)) * 100) / 100, + percentOfLimit: + this.maxMemorySizeBytes > 0 + ? Math.round((this.memoryUsageBytes / this.maxMemorySizeBytes) * 100) + : 0, + maxMemoryMB: this.maxMemorySizeBytes > 0 ? this.maxMemorySizeBytes / (1024 * 1024) : -1, // -1 indicates unlimited + storeCount: this.vectorStoreBuffer.size, + inactiveStoreCount: 0, + ttlHours: this.inactiveTtlMs > 0 ? this.inactiveTtlMs / (60 * 60 * 1000) : -1, // -1 indicates disabled + stores: {}, + }; + + // Add stats for each store + for (const [key, metadata] of this.storeMetadata.entries()) { + const store = this.vectorStoreBuffer.get(key); + + if (store) { + const lastAccessedTime = metadata.lastAccessed.getTime(); + const inactiveTimeMs = now - lastAccessedTime; + const isInactive = this.cleanupService.isStoreInactive(metadata); + + if (isInactive) { + inactiveStoreCount++; + } + + stats.stores[key] = { + sizeBytes: metadata.size, + sizeMB: Math.round((metadata.size / (1024 * 1024)) * 100) / 100, + percentOfTotal: Math.round((metadata.size / this.memoryUsageBytes) * 100) || 0, + vectors: store.memoryVectors?.length || 0, + createdAt: metadata.createdAt.toISOString(), + lastAccessed: metadata.lastAccessed.toISOString(), + inactive: isInactive, + inactiveForHours: Math.round(inactiveTimeMs / (60 * 60 * 1000)), + }; + } + } + + stats.inactiveStoreCount = inactiveStoreCount; + + return stats; + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/StoreCleanupService.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/StoreCleanupService.ts new file mode 100644 index 00000000000..3fb1d85e3a7 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/StoreCleanupService.ts @@ -0,0 +1,157 @@ +import type { MemoryVectorStore } from 'langchain/vectorstores/memory'; + +import type { VectorStoreMetadata, IStoreCleanupService } from './types'; + +/** + * Service for cleaning up vector stores based on inactivity or memory pressure + */ +export class StoreCleanupService implements IStoreCleanupService { + // Cache for oldest stores sorted by creation time + private oldestStoreKeys: string[] = []; + + private lastSortTime = 0; + + private readonly CACHE_TTL_MS = 5000; // 5 seconds + + constructor( + private readonly maxMemorySizeBytes: number, + private readonly inactiveTtlMs: number, + private readonly vectorStores: Map, + private readonly storeMetadata: Map, + private readonly onCleanup: ( + removedKeys: string[], + freedBytes: number, + reason: 'ttl' | 'memory', + ) => void, + ) {} + + /** + * Check if a store has been inactive for longer than the TTL + */ + isStoreInactive(metadata: VectorStoreMetadata): boolean { + // If TTL is disabled, nothing is considered inactive + if (this.inactiveTtlMs <= 0) { + return false; + } + + const now = Date.now(); + const lastAccessedTime = metadata.lastAccessed.getTime(); + return now - lastAccessedTime > this.inactiveTtlMs; + } + + /** + * Remove vector stores that haven't been accessed for longer than TTL + */ + cleanupInactiveStores(): void { + // Skip if TTL is disabled + if (this.inactiveTtlMs <= 0) { + return; + } + + let freedBytes = 0; + const removedStores: string[] = []; + + // Find and remove inactive stores + for (const [key, metadata] of this.storeMetadata.entries()) { + if (this.isStoreInactive(metadata)) { + // Remove this inactive store + this.vectorStores.delete(key); + freedBytes += metadata.size; + removedStores.push(key); + } + } + + // Remove from metadata after iteration to avoid concurrent modification + for (const key of removedStores) { + this.storeMetadata.delete(key); + } + + // Invalidate cache if we removed any stores + if (removedStores.length > 0) { + this.oldestStoreKeys = []; + this.onCleanup(removedStores, freedBytes, 'ttl'); + } + } + + /** + * Remove the oldest vector stores to free up memory + */ + cleanupOldestStores(requiredBytes: number): void { + // Skip if memory limit is disabled + if (this.maxMemorySizeBytes <= 0) { + return; + } + + // Calculate current total memory usage + let currentMemoryUsage = 0; + for (const metadata of this.storeMetadata.values()) { + currentMemoryUsage += metadata.size; + } + + // First, try to clean up inactive stores + this.cleanupInactiveStores(); + + // Recalculate memory usage after inactive cleanup + currentMemoryUsage = 0; + for (const metadata of this.storeMetadata.values()) { + currentMemoryUsage += metadata.size; + } + + // If no more cleanup needed, return early + if (currentMemoryUsage + requiredBytes <= this.maxMemorySizeBytes) { + return; + } + + const now = Date.now(); + + // Reuse cached ordering if available and not stale + if (this.oldestStoreKeys.length === 0 || now - this.lastSortTime > this.CACHE_TTL_MS) { + // Collect and sort store keys by age + const stores: Array<[string, number]> = []; + + for (const [key, metadata] of this.storeMetadata.entries()) { + stores.push([key, metadata.createdAt.getTime()]); + } + + // Sort by creation time (oldest first) + stores.sort((a, b) => a[1] - b[1]); + + // Extract just the keys + this.oldestStoreKeys = stores.map(([key]) => key); + this.lastSortTime = now; + } + + let freedBytes = 0; + const removedStores: string[] = []; + + // Remove stores in order until we have enough space + for (const key of this.oldestStoreKeys) { + // Skip if store no longer exists + if (!this.storeMetadata.has(key)) continue; + + // Stop if we've freed enough space + if (currentMemoryUsage - freedBytes + requiredBytes <= this.maxMemorySizeBytes) { + break; + } + + const metadata = this.storeMetadata.get(key); + if (metadata) { + this.vectorStores.delete(key); + freedBytes += metadata.size; + removedStores.push(key); + } + } + + // Remove from metadata after iteration to avoid concurrent modification + for (const key of removedStores) { + this.storeMetadata.delete(key); + } + + // Update our cache if we removed stores + if (removedStores.length > 0) { + // Filter out removed stores from cached keys + this.oldestStoreKeys = this.oldestStoreKeys.filter((key) => !removedStores.includes(key)); + this.onCleanup(removedStores, freedBytes, 'memory'); + } + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/config.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/config.ts new file mode 100644 index 00000000000..62af65b0fc1 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/config.ts @@ -0,0 +1,51 @@ +import type { MemoryVectorStoreConfig } from './types'; + +// Defaults +const DEFAULT_MAX_MEMORY_MB = -1; +const DEFAULT_INACTIVE_TTL_HOURS = -1; + +/** + * Helper function to get the configuration from environment variables + */ +export function getConfig(): MemoryVectorStoreConfig { + // Get memory limit from env var or use default + let maxMemoryMB = DEFAULT_MAX_MEMORY_MB; + if (process.env.N8N_VECTOR_STORE_MAX_MEMORY) { + const parsed = parseInt(process.env.N8N_VECTOR_STORE_MAX_MEMORY, 10); + if (!isNaN(parsed)) { + maxMemoryMB = parsed; + } + } + + // Get TTL from env var or use default + let ttlHours = DEFAULT_INACTIVE_TTL_HOURS; + if (process.env.N8N_VECTOR_STORE_TTL_HOURS) { + const parsed = parseInt(process.env.N8N_VECTOR_STORE_TTL_HOURS, 10); + if (!isNaN(parsed)) { + ttlHours = parsed; + } + } + + return { + maxMemoryMB, + ttlHours, + }; +} + +/** + * Convert memory size from MB to bytes + */ +export function mbToBytes(mb: number): number { + // -1 - "unlimited" + if (mb <= 0) return -1; + return mb * 1024 * 1024; +} + +/** + * Convert TTL from hours to milliseconds + */ +export function hoursToMs(hours: number): number { + // -1 - "disabled" + if (hours <= 0) return -1; + return hours * 60 * 60 * 1000; +} diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/test/MemoryCalculator.test.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/test/MemoryCalculator.test.ts new file mode 100644 index 00000000000..e8438a1d58b --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/test/MemoryCalculator.test.ts @@ -0,0 +1,202 @@ +import { Document } from '@langchain/core/documents'; +import { mock } from 'jest-mock-extended'; +import type { MemoryVectorStore } from 'langchain/vectorstores/memory'; + +import { MemoryCalculator } from '../MemoryCalculator'; + +function createTestEmbedding(dimensions = 1536, initialValue = 0.1, multiplier = 1): number[] { + return new Array(dimensions).fill(initialValue).map((value) => value * multiplier); +} + +describe('MemoryCalculator', () => { + let calculator: MemoryCalculator; + + beforeEach(() => { + calculator = new MemoryCalculator(); + }); + + describe('estimateBatchSize', () => { + it('should return 0 for empty document arrays', () => { + const size = calculator.estimateBatchSize([]); + expect(size).toBe(0); + }); + + it('should calculate size for simple documents', () => { + const documents = [ + new Document({ pageContent: 'Hello, world!', metadata: { simple: 'value' } }), + ]; + + const size = calculator.estimateBatchSize(documents); + + expect(size).toBeGreaterThan(0); + + // The size should account for the content, metadata, embedding size, and overhead + const simpleCase = calculator.estimateBatchSize([ + new Document({ pageContent: '', metadata: {} }), + ]); + const withContent = calculator.estimateBatchSize([ + new Document({ pageContent: 'test content', metadata: {} }), + ]); + const withMetadata = calculator.estimateBatchSize([ + new Document({ pageContent: '', metadata: { key: 'value' } }), + ]); + + // Content should increase size + expect(withContent).toBeGreaterThan(simpleCase); + + // Metadata should increase size + expect(withMetadata).toBeGreaterThan(simpleCase); + }); + + it('should account for content length in size calculation', () => { + const shortDoc = new Document({ + pageContent: 'Short content', + metadata: {}, + }); + + const longDoc = new Document({ + pageContent: 'A'.repeat(1000), + metadata: {}, + }); + + const shortSize = calculator.estimateBatchSize([shortDoc]); + const longSize = calculator.estimateBatchSize([longDoc]); + + // Long content should result in a larger size estimate + expect(longSize).toBeGreaterThan(shortSize); + expect(longSize - shortSize).toBeGreaterThan(1000); + }); + + it('should account for metadata complexity in size calculation', () => { + const simpleMetadata = new Document({ + pageContent: '', + metadata: { simple: 'value' }, + }); + + const complexMetadata = new Document({ + pageContent: '', + metadata: { + nested: { + objects: { + with: { + many: { + levels: [1, 2, 3, 4, 5], + andArray: ['a', 'b', 'c', 'd', 'e'], + }, + }, + }, + }, + moreKeys: 'moreValues', + evenMore: 'data', + }, + }); + + const simpleSize = calculator.estimateBatchSize([simpleMetadata]); + const complexSize = calculator.estimateBatchSize([complexMetadata]); + + // Complex metadata should result in a larger size estimate + expect(complexSize).toBeGreaterThan(simpleSize); + }); + + it('should scale with the number of documents', () => { + const doc = new Document({ pageContent: 'Sample content', metadata: { key: 'value' } }); + + const singleSize = calculator.estimateBatchSize([doc]); + const doubleSize = calculator.estimateBatchSize([doc, doc]); + const tripleSize = calculator.estimateBatchSize([doc, doc, doc]); + + // Size should scale roughly linearly with document count + expect(doubleSize).toBeGreaterThan(singleSize * 1.5); // Allow for some overhead + expect(tripleSize).toBeGreaterThan(doubleSize * 1.3); // Allow for some overhead + }); + }); + + describe('calculateVectorStoreSize', () => { + it('should return 0 for empty vector stores', () => { + const mockVectorStore = mock(); + + const size = calculator.calculateVectorStoreSize(mockVectorStore); + expect(size).toBe(0); + }); + + it('should calculate size for vector stores with content', () => { + const mockVectorStore = mock(); + mockVectorStore.memoryVectors = [ + { + embedding: createTestEmbedding(), // Using the helper function + content: 'Document content', + metadata: { simple: 'value' }, + }, + ]; + + const size = calculator.calculateVectorStoreSize(mockVectorStore); + + // Size should account for the embedding, content, metadata, and overhead + expect(size).toBeGreaterThan(1536 * 8); // At least the size of the embedding in bytes + }); + + it('should account for vector count in size calculation', () => { + const singleVector = mock(); + singleVector.memoryVectors = [ + { + embedding: createTestEmbedding(), + content: 'Content', + metadata: {}, + }, + ]; + + const multiVector = mock(); + multiVector.memoryVectors = [ + { + embedding: createTestEmbedding(), + content: 'Content', + metadata: {}, + }, + { + embedding: createTestEmbedding(), + content: 'Content', + metadata: {}, + }, + { + embedding: createTestEmbedding(), + content: 'Content', + metadata: {}, + }, + ]; + + const singleSize = calculator.calculateVectorStoreSize(singleVector); + const multiSize = calculator.calculateVectorStoreSize(multiVector); + + // Multi-vector store should be about 3x the size + expect(multiSize).toBeGreaterThan(singleSize * 2.5); + expect(multiSize).toBeLessThan(singleSize * 3.5); + }); + + it('should handle vectors with no content or metadata', () => { + const vectorStore = mock(); + vectorStore.memoryVectors = [ + { + embedding: createTestEmbedding(), + content: '', + metadata: {}, + }, + ]; + + const size = calculator.calculateVectorStoreSize(vectorStore); + + // Size should still be positive (at least the embedding size) + expect(size).toBeGreaterThan(1536 * 8); + }); + + it('should handle null or undefined vector arrays', () => { + const nullVectorStore = mock(); + nullVectorStore.memoryVectors = []; + + const undefinedVectorStore = mock(); + undefinedVectorStore.memoryVectors = []; + + expect(calculator.calculateVectorStoreSize(nullVectorStore)).toBe(0); + expect(calculator.calculateVectorStoreSize(undefinedVectorStore)).toBe(0); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/test/MemoryVectorStoreManager.test.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/test/MemoryVectorStoreManager.test.ts new file mode 100644 index 00000000000..f8e82217c0b --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/test/MemoryVectorStoreManager.test.ts @@ -0,0 +1,249 @@ +/* eslint-disable @typescript-eslint/dot-notation */ +import { Document } from '@langchain/core/documents'; +import type { OpenAIEmbeddings } from '@langchain/openai'; +import { mock } from 'jest-mock-extended'; +import type { MemoryVectorStore } from 'langchain/vectorstores/memory'; +import type { Logger } from 'n8n-workflow'; + +import * as configModule from '../config'; +import { MemoryVectorStoreManager } from '../MemoryVectorStoreManager'; + +function createTestEmbedding(dimensions = 1536, initialValue = 0.1, multiplier = 1): number[] { + return new Array(dimensions).fill(initialValue).map((value) => value * multiplier); +} + +jest.mock('langchain/vectorstores/memory', () => { + return { + MemoryVectorStore: { + fromExistingIndex: jest.fn().mockImplementation(() => { + return { + embeddings: null, + addDocuments: jest.fn(), + memoryVectors: [], + }; + }), + }, + }; +}); + +describe('MemoryVectorStoreManager', () => { + let logger: Logger; + // Reset the singleton instance before each test + beforeEach(() => { + jest.clearAllMocks(); + logger = mock(); + MemoryVectorStoreManager['instance'] = null; + + jest.useFakeTimers(); + + // Mock the config + jest.spyOn(configModule, 'getConfig').mockReturnValue({ + maxMemoryMB: 100, + ttlHours: 168, + }); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + it('should create an instance of MemoryVectorStoreManager', () => { + const embeddings = mock(); + + const instance = MemoryVectorStoreManager.getInstance(embeddings, logger); + expect(instance).toBeInstanceOf(MemoryVectorStoreManager); + }); + + it('should return existing instance', () => { + const embeddings = mock(); + + const instance1 = MemoryVectorStoreManager.getInstance(embeddings, logger); + const instance2 = MemoryVectorStoreManager.getInstance(embeddings, logger); + expect(instance1).toBe(instance2); + }); + + it('should update embeddings in existing instance', () => { + const embeddings1 = mock(); + const embeddings2 = mock(); + + const instance = MemoryVectorStoreManager.getInstance(embeddings1, logger); + MemoryVectorStoreManager.getInstance(embeddings2, logger); + + expect(instance['embeddings']).toBe(embeddings2); + }); + + it('should update embeddings in existing vector store instances', async () => { + const embeddings1 = mock(); + const embeddings2 = mock(); + + const instance1 = MemoryVectorStoreManager.getInstance(embeddings1, logger); + await instance1.getVectorStore('test'); + + const instance2 = MemoryVectorStoreManager.getInstance(embeddings2, logger); + const vectorStoreInstance2 = await instance2.getVectorStore('test'); + + expect(vectorStoreInstance2.embeddings).toBe(embeddings2); + }); + + it('should set up the TTL cleanup interval', () => { + jest.spyOn(global, 'setInterval'); + const embeddings = mock(); + + MemoryVectorStoreManager.getInstance(embeddings, logger); + + expect(setInterval).toHaveBeenCalled(); + }); + + it('should not set up the TTL cleanup interval when TTL is disabled', () => { + jest.spyOn(configModule, 'getConfig').mockReturnValue({ + maxMemoryMB: 100, + ttlHours: -1, // TTL disabled + }); + + jest.spyOn(global, 'setInterval'); + const embeddings = mock(); + + MemoryVectorStoreManager.getInstance(embeddings, logger); + + expect(setInterval).not.toHaveBeenCalled(); + }); + + it('should track memory usage when adding documents', async () => { + const embeddings = mock(); + const instance = MemoryVectorStoreManager.getInstance(embeddings, logger); + + const calculatorSpy = jest + .spyOn(instance['memoryCalculator'], 'estimateBatchSize') + .mockReturnValue(1024 * 1024); // Mock 1MB size + + const documents = [new Document({ pageContent: 'test document', metadata: { test: 'value' } })]; + + await instance.addDocuments('test-key', documents); + + expect(calculatorSpy).toHaveBeenCalledWith(documents); + expect(instance.getMemoryUsage()).toBe(1024 * 1024); // Should be 1MB + }); + + it('should clear store metadata when clearing store', async () => { + const embeddings = mock(); + const instance = MemoryVectorStoreManager.getInstance(embeddings, logger); + + // Directly set memory usage to 0 to start with a clean state + instance['memoryUsageBytes'] = 0; + + // Add documents to create a store + const docs = [new Document({ pageContent: 'test', metadata: {} })]; + jest.spyOn(instance['memoryCalculator'], 'estimateBatchSize').mockReturnValue(1000); + + await instance.addDocuments('test-key', docs); + expect(instance.getMemoryUsage()).toBe(1000); + + // Directly access the metadata to verify clearing works + const metadataSizeBefore = instance['storeMetadata'].get('test-key')?.size; + expect(metadataSizeBefore).toBe(1000); + + // Now clear the store by calling the private method directly + instance['clearStoreMetadata']('test-key'); + + // Verify metadata was reset + const metadataSizeAfter = instance['storeMetadata'].get('test-key')?.size; + expect(metadataSizeAfter).toBe(0); + + // The memory usage should be reduced + expect(instance.getMemoryUsage()).toBe(0); + }); + + it('should request cleanup when adding documents that would exceed memory limit', async () => { + const embeddings = mock(); + const instance = MemoryVectorStoreManager.getInstance(embeddings, logger); + + // Spy on the cleanup service + const cleanupSpy = jest.spyOn(instance['cleanupService'], 'cleanupOldestStores'); + + // Set up a large document batch + const documents = [new Document({ pageContent: 'test', metadata: {} })]; + jest.spyOn(instance['memoryCalculator'], 'estimateBatchSize').mockReturnValue(50 * 1024 * 1024); // 50MB + + await instance.addDocuments('test-key', documents); + + expect(cleanupSpy).toHaveBeenCalledWith(50 * 1024 * 1024); + }); + + it('should recalculate memory usage periodically', async () => { + const embeddings = mock(); + const instance = MemoryVectorStoreManager.getInstance(embeddings, logger); + + // Mock methods and spies + const recalcSpy = jest.spyOn(instance, 'recalculateMemoryUsage'); + const mockVectorStore = mock(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + mockVectorStore.memoryVectors = new Array(100).fill({ + embedding: createTestEmbedding(), + content: 'test', + metadata: {}, + }); + + // Mock the getVectorStore to return our mock + jest.spyOn(instance, 'getVectorStore').mockResolvedValue(mockVectorStore); + jest.spyOn(instance['memoryCalculator'], 'estimateBatchSize').mockReturnValue(1000); + + // Add a large batch of documents + const documents = new Array(21).fill(new Document({ pageContent: 'test', metadata: {} })); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + await instance.addDocuments('test-key', documents); + + expect(recalcSpy).toHaveBeenCalled(); + }); + + it('should provide accurate stats about vector stores', async () => { + const embeddings = mock(); + const instance = MemoryVectorStoreManager.getInstance(embeddings, logger); + + // Create mock vector stores + const mockVectorStore1 = mock(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + mockVectorStore1.memoryVectors = new Array(50).fill({ + embedding: createTestEmbedding(), + content: 'test1', + metadata: {}, + }); + + const mockVectorStore2 = mock(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + mockVectorStore2.memoryVectors = new Array(30).fill({ + embedding: createTestEmbedding(), + content: 'test2', + metadata: {}, + }); + + // Mock internal state + instance['vectorStoreBuffer'].set('store1', mockVectorStore1); + instance['vectorStoreBuffer'].set('store2', mockVectorStore2); + + // Set metadata for the stores + instance['storeMetadata'].set('store1', { + size: 1024 * 1024, // 1MB + createdAt: new Date(Date.now() - 3600000), // 1 hour ago + lastAccessed: new Date(Date.now() - 1800000), // 30 minutes ago + }); + + instance['storeMetadata'].set('store2', { + size: 512 * 1024, // 0.5MB + createdAt: new Date(Date.now() - 7200000), // 2 hours ago + lastAccessed: new Date(Date.now() - 3600000), // 1 hour ago + }); + + // Set memory usage + instance['memoryUsageBytes'] = 1024 * 1024 + 512 * 1024; + + const stats = instance.getStats(); + + expect(stats.storeCount).toBe(2); + expect(stats.totalSizeBytes).toBeGreaterThan(0); + expect(Object.keys(stats.stores)).toContain('store1'); + expect(Object.keys(stats.stores)).toContain('store2'); + expect(stats.stores.store1.vectors).toBe(50); + expect(stats.stores.store2.vectors).toBe(30); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/test/StoreCleanupService.test.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/test/StoreCleanupService.test.ts new file mode 100644 index 00000000000..206fa90296f --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/test/StoreCleanupService.test.ts @@ -0,0 +1,289 @@ +/* eslint-disable @typescript-eslint/dot-notation */ +import { mock } from 'jest-mock-extended'; +import type { MemoryVectorStore } from 'langchain/vectorstores/memory'; + +import { StoreCleanupService } from '../StoreCleanupService'; +import type { VectorStoreMetadata } from '../types'; + +describe('StoreCleanupService', () => { + // Setup test data + let vectorStores: Map; + let storeMetadata: Map; + let onCleanupMock: jest.Mock; + + // Utility to add a test store with given age + const addTestStore = ( + key: string, + sizeBytes: number, + createdHoursAgo: number, + accessedHoursAgo: number, + ) => { + const mockStore = mock(); + vectorStores.set(key, mockStore); + + const now = Date.now(); + storeMetadata.set(key, { + size: sizeBytes, + createdAt: new Date(now - createdHoursAgo * 3600000), + lastAccessed: new Date(now - accessedHoursAgo * 3600000), + }); + }; + + beforeEach(() => { + vectorStores = new Map(); + storeMetadata = new Map(); + onCleanupMock = jest.fn(); + }); + + describe('TTL-based cleanup', () => { + it('should identify inactive stores correctly', () => { + const service = new StoreCleanupService( + 100 * 1024 * 1024, // 100MB max + 24 * 3600 * 1000, // 24 hours TTL + vectorStores, + storeMetadata, + onCleanupMock, + ); + + // Create test metadata + const recentMetadata: VectorStoreMetadata = { + size: 1024, + createdAt: new Date(Date.now() - 48 * 3600 * 1000), // 48 hours ago + lastAccessed: new Date(Date.now() - 12 * 3600 * 1000), // 12 hours ago + }; + + const inactiveMetadata: VectorStoreMetadata = { + size: 1024, + createdAt: new Date(Date.now() - 48 * 3600 * 1000), // 48 hours ago + lastAccessed: new Date(Date.now() - 36 * 3600 * 1000), // 36 hours ago + }; + + // Test the inactive check + expect(service.isStoreInactive(recentMetadata)).toBe(false); + expect(service.isStoreInactive(inactiveMetadata)).toBe(true); + }); + + it('should never identify stores as inactive when TTL is disabled', () => { + const service = new StoreCleanupService( + 100 * 1024 * 1024, // 100MB max + -1, // TTL disabled + vectorStores, + storeMetadata, + onCleanupMock, + ); + + // Create very old metadata + const veryOldMetadata: VectorStoreMetadata = { + size: 1024, + createdAt: new Date(Date.now() - 365 * 24 * 3600 * 1000), // 1 year ago + lastAccessed: new Date(Date.now() - 365 * 24 * 3600 * 1000), // 1 year ago + }; + + // Should never be inactive when TTL is disabled + expect(service.isStoreInactive(veryOldMetadata)).toBe(false); + }); + + it('should clean up inactive stores', () => { + const service = new StoreCleanupService( + 100 * 1024 * 1024, // 100MB max + 24 * 3600 * 1000, // 24 hours TTL + vectorStores, + storeMetadata, + onCleanupMock, + ); + + // Add active and inactive stores + addTestStore('active1', 1024 * 1024, 48, 12); // 48 hours old, accessed 12 hours ago + addTestStore('active2', 2048 * 1024, 72, 20); // 72 hours old, accessed 20 hours ago + addTestStore('inactive1', 3072 * 1024, 100, 30); // 100 hours old, accessed 30 hours ago + addTestStore('inactive2', 4096 * 1024, 120, 48); // 120 hours old, accessed 48 hours ago + + // Run cleanup + service.cleanupInactiveStores(); + + // Check which stores were cleaned up + expect(vectorStores.has('active1')).toBe(true); + expect(vectorStores.has('active2')).toBe(true); + expect(vectorStores.has('inactive1')).toBe(false); + expect(vectorStores.has('inactive2')).toBe(false); + + // Metadata should also be cleaned up + expect(storeMetadata.has('active1')).toBe(true); + expect(storeMetadata.has('active2')).toBe(true); + expect(storeMetadata.has('inactive1')).toBe(false); + expect(storeMetadata.has('inactive2')).toBe(false); + + // Check callback was called correctly + expect(onCleanupMock).toHaveBeenCalledWith( + expect.arrayContaining(['inactive1', 'inactive2']), + 7168 * 1024, // sum of inactive store sizes + 'ttl', + ); + }); + + it('should not run TTL cleanup when disabled', () => { + const service = new StoreCleanupService( + 100 * 1024 * 1024, // 100MB max + -1, // TTL disabled + vectorStores, + storeMetadata, + onCleanupMock, + ); + + // Add all "inactive" stores + addTestStore('store1', 1024 * 1024, 48, 30); + addTestStore('store2', 2048 * 1024, 72, 48); + + // Run cleanup + service.cleanupInactiveStores(); + + // Nothing should be cleaned up + expect(vectorStores.size).toBe(2); + expect(storeMetadata.size).toBe(2); + expect(onCleanupMock).not.toHaveBeenCalled(); + }); + }); + + describe('Memory-based cleanup', () => { + it('should clean up oldest stores to make room for new data', () => { + const maxMemoryBytes = 10 * 1024 * 1024; // 10MB + const service = new StoreCleanupService( + maxMemoryBytes, + 24 * 3600 * 1000, // 24 hours TTL + vectorStores, + storeMetadata, + onCleanupMock, + ); + + // Add stores with different creation times + addTestStore('newest', 2 * 1024 * 1024, 1, 1); // 2MB, 1 hour old + addTestStore('newer', 3 * 1024 * 1024, 2, 1); // 3MB, 2 hours old + addTestStore('older', 3 * 1024 * 1024, 3, 1); // 3MB, 3 hours old + addTestStore('oldest', 2 * 1024 * 1024, 4, 1); // 2MB, 4 hours old + + // Current total: 10MB + + // Try to add 5MB more + service.cleanupOldestStores(5 * 1024 * 1024); + + // Should have removed oldest and older (5MB total) + expect(vectorStores.has('newest')).toBe(true); + expect(vectorStores.has('newer')).toBe(true); + expect(vectorStores.has('older')).toBe(false); + expect(vectorStores.has('oldest')).toBe(false); + + // Check callback + expect(onCleanupMock).toHaveBeenCalledWith( + expect.arrayContaining(['older', 'oldest']), + 5 * 1024 * 1024, + 'memory', + ); + }); + + it('should run TTL cleanup before memory cleanup', () => { + const maxMemoryBytes = 10 * 1024 * 1024; // 10MB + const service = new StoreCleanupService( + maxMemoryBytes, + 24 * 3600 * 1000, // 24 hours TTL + vectorStores, + storeMetadata, + onCleanupMock, + ); + + // Add a mix of active and inactive stores + addTestStore('active-newest', 2 * 1024 * 1024, 1, 1); // 2MB, active + addTestStore('active-older', 3 * 1024 * 1024, 3, 12); // 3MB, active + addTestStore('inactive', 3 * 1024 * 1024, 3, 30); // 3MB, inactive (30h) + addTestStore('active-oldest', 2 * 1024 * 1024, 4, 20); // 2MB, active + + // Total: 10MB, with 3MB inactive + + // Try to add 5MB more + service.cleanupOldestStores(5 * 1024 * 1024); + + // Should have removed inactive first, then active-oldest (5MB total) + expect(vectorStores.has('active-newest')).toBe(true); + expect(vectorStores.has('active-older')).toBe(true); + expect(vectorStores.has('inactive')).toBe(false); + expect(vectorStores.has('active-oldest')).toBe(false); + + // Check callbacks + expect(onCleanupMock).toHaveBeenCalledTimes(2); + // First call for TTL cleanup + expect(onCleanupMock).toHaveBeenNthCalledWith(1, ['inactive'], 3 * 1024 * 1024, 'ttl'); + // Second call for memory cleanup + expect(onCleanupMock).toHaveBeenNthCalledWith( + 2, + ['active-oldest'], + 2 * 1024 * 1024, + 'memory', + ); + }); + + it('should not perform memory cleanup when limit is disabled', () => { + const service = new StoreCleanupService( + -1, // Memory limit disabled + 24 * 3600 * 1000, // 24 hours TTL + vectorStores, + storeMetadata, + onCleanupMock, + ); + + // Add some stores + addTestStore('store1', 5 * 1024 * 1024, 1, 1); + addTestStore('store2', 10 * 1024 * 1024, 2, 1); + + // Try to add a lot more data + service.cleanupOldestStores(100 * 1024 * 1024); + + // Nothing should be cleaned up + expect(vectorStores.size).toBe(2); + expect(storeMetadata.size).toBe(2); + expect(onCleanupMock).not.toHaveBeenCalled(); + }); + + it('should handle empty stores during cleanup', () => { + const service = new StoreCleanupService( + 10 * 1024 * 1024, // 10MB + 24 * 3600 * 1000, // 24 hours TTL + vectorStores, + storeMetadata, + onCleanupMock, + ); + + service.cleanupOldestStores(5 * 1024 * 1024); + service.cleanupInactiveStores(); + + expect(onCleanupMock).not.toHaveBeenCalled(); + }); + + it('should update the cache when stores are removed', () => { + const service = new StoreCleanupService( + 10 * 1024 * 1024, // 10MB + 24 * 3600 * 1000, // 24 hours TTL + vectorStores, + storeMetadata, + onCleanupMock, + ); + + // Add test stores + addTestStore('newest', 2 * 1024 * 1024, 1, 1); + addTestStore('middle', 3 * 1024 * 1024, 3, 1); + addTestStore('oldest', 4 * 1024 * 1024, 5, 1); + + // Trigger a cleanup that will remove only the oldest store + service.cleanupOldestStores(4 * 1024 * 1024); // 4MB + + // Verify removal + expect(vectorStores.has('oldest')).toBe(false); + expect(vectorStores.has('middle')).toBe(true); + expect(vectorStores.has('newest')).toBe(true); + + // Check that the cache was updated correctly + const cacheKeys = service['oldestStoreKeys']; + expect(cacheKeys.includes('oldest')).toBe(false); + expect(cacheKeys.includes('middle')).toBe(true); + expect(cacheKeys.includes('newest')).toBe(true); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/test/config.test.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/test/config.test.ts new file mode 100644 index 00000000000..5ef82c31778 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/test/config.test.ts @@ -0,0 +1,74 @@ +import { getConfig, mbToBytes, hoursToMs } from '../config'; + +describe('Vector Store Config', () => { + // Store original environment + const originalEnv = { ...process.env }; + + // Restore original environment after each test + afterEach(() => { + process.env = { ...originalEnv }; + }); + + describe('getConfig', () => { + it('should return default values when no environment variables set', () => { + // Clear relevant environment variables + delete process.env.N8N_VECTOR_STORE_MAX_MEMORY; + delete process.env.N8N_VECTOR_STORE_TTL_HOURS; + + const config = getConfig(); + + expect(config.maxMemoryMB).toBe(-1); + expect(config.ttlHours).toBe(-1); + }); + + it('should use values from environment variables when set', () => { + process.env.N8N_VECTOR_STORE_MAX_MEMORY = '200'; + process.env.N8N_VECTOR_STORE_TTL_HOURS = '24'; + + const config = getConfig(); + + expect(config.maxMemoryMB).toBe(200); + expect(config.ttlHours).toBe(24); + }); + + it('should handle invalid environment variable values', () => { + // Set invalid values (non-numeric) + process.env.N8N_VECTOR_STORE_MAX_MEMORY = 'invalid'; + process.env.N8N_VECTOR_STORE_TTL_HOURS = 'notanumber'; + + const config = getConfig(); + + // Should use default values for invalid inputs + expect(config.maxMemoryMB).toBe(-1); + expect(config.ttlHours).toBe(-1); + }); + }); + + describe('mbToBytes', () => { + it('should convert MB to bytes', () => { + expect(mbToBytes(1)).toBe(1024 * 1024); + expect(mbToBytes(5)).toBe(5 * 1024 * 1024); + expect(mbToBytes(100)).toBe(100 * 1024 * 1024); + }); + + it('should handle zero and negative values', () => { + expect(mbToBytes(0)).toBe(-1); + expect(mbToBytes(-1)).toBe(-1); + expect(mbToBytes(-10)).toBe(-1); + }); + }); + + describe('hoursToMs', () => { + it('should convert hours to milliseconds', () => { + expect(hoursToMs(1)).toBe(60 * 60 * 1000); + expect(hoursToMs(24)).toBe(24 * 60 * 60 * 1000); + expect(hoursToMs(168)).toBe(168 * 60 * 60 * 1000); + }); + + it('should handle zero and negative values', () => { + expect(hoursToMs(0)).toBe(-1); + expect(hoursToMs(-1)).toBe(-1); + expect(hoursToMs(-24)).toBe(-1); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/types.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/types.ts new file mode 100644 index 00000000000..340cee889e6 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryManager/types.ts @@ -0,0 +1,70 @@ +import type { Document } from '@langchain/core/documents'; +import type { MemoryVectorStore } from 'langchain/vectorstores/memory'; + +/** + * Configuration options for the memory vector store + */ +export interface MemoryVectorStoreConfig { + /** + * Maximum memory size in MB, -1 to disable + */ + maxMemoryMB: number; + + /** + * TTL for inactive stores in hours, -1 to disable + */ + ttlHours: number; +} + +/** + * Vector store metadata for tracking usage + */ +export interface VectorStoreMetadata { + size: number; + createdAt: Date; + lastAccessed: Date; +} + +/** + * Per-store statistics for reporting + */ +export interface StoreStats { + sizeBytes: number; + sizeMB: number; + percentOfTotal: number; + vectors: number; + createdAt: string; + lastAccessed: string; + inactive?: boolean; + inactiveForHours?: number; +} + +/** + * Overall vector store statistics + */ +export interface VectorStoreStats { + totalSizeBytes: number; + totalSizeMB: number; + percentOfLimit: number; + maxMemoryMB: number; + storeCount: number; + inactiveStoreCount: number; + ttlHours: number; + stores: Record; +} + +/** + * Service for calculating memory usage + */ +export interface IMemoryCalculator { + estimateBatchSize(documents: Document[]): number; + calculateVectorStoreSize(vectorStore: MemoryVectorStore): number; +} + +/** + * Service for cleaning up vector stores + */ +export interface IStoreCleanupService { + cleanupInactiveStores(): void; + cleanupOldestStores(requiredBytes: number): void; +} diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryVectorStoreManager.test.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryVectorStoreManager.test.ts deleted file mode 100644 index 1088505a0a7..00000000000 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryVectorStoreManager.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { OpenAIEmbeddings } from '@langchain/openai'; -import { mock } from 'jest-mock-extended'; - -import { MemoryVectorStoreManager } from './MemoryVectorStoreManager'; - -describe('MemoryVectorStoreManager', () => { - it('should create an instance of MemoryVectorStoreManager', () => { - const embeddings = mock(); - - const instance = MemoryVectorStoreManager.getInstance(embeddings); - expect(instance).toBeInstanceOf(MemoryVectorStoreManager); - }); - - it('should return existing instance', () => { - const embeddings = mock(); - - const instance1 = MemoryVectorStoreManager.getInstance(embeddings); - const instance2 = MemoryVectorStoreManager.getInstance(embeddings); - expect(instance1).toBe(instance2); - }); - - it('should update embeddings in existing instance', () => { - const embeddings1 = mock(); - const embeddings2 = mock(); - - const instance = MemoryVectorStoreManager.getInstance(embeddings1); - MemoryVectorStoreManager.getInstance(embeddings2); - - expect((instance as any).embeddings).toBe(embeddings2); - }); - - it('should update embeddings in existing vector store instances', async () => { - const embeddings1 = mock(); - const embeddings2 = mock(); - - const instance1 = MemoryVectorStoreManager.getInstance(embeddings1); - await instance1.getVectorStore('test'); - - const instance2 = MemoryVectorStoreManager.getInstance(embeddings2); - const vectorStoreInstance2 = await instance2.getVectorStore('test'); - - expect((vectorStoreInstance2 as any).embeddings).toBe(embeddings2); - }); -}); diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryVectorStoreManager.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryVectorStoreManager.ts deleted file mode 100644 index f92c8abd411..00000000000 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryVectorStoreManager.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { Document } from '@langchain/core/documents'; -import type { Embeddings } from '@langchain/core/embeddings'; -import { MemoryVectorStore } from 'langchain/vectorstores/memory'; - -export class MemoryVectorStoreManager { - private static instance: MemoryVectorStoreManager | null = null; - - private vectorStoreBuffer: Map; - - private constructor(private embeddings: Embeddings) { - this.vectorStoreBuffer = new Map(); - } - - static getInstance(embeddings: Embeddings): MemoryVectorStoreManager { - if (!MemoryVectorStoreManager.instance) { - MemoryVectorStoreManager.instance = new MemoryVectorStoreManager(embeddings); - } else { - // We need to update the embeddings in the existing instance. - // This is important as embeddings instance is wrapped in a logWrapper, - // which relies on supplyDataFunctions context which changes on each workflow run - MemoryVectorStoreManager.instance.embeddings = embeddings; - MemoryVectorStoreManager.instance.vectorStoreBuffer.forEach((vectorStoreInstance) => { - vectorStoreInstance.embeddings = embeddings; - }); - } - - return MemoryVectorStoreManager.instance; - } - - async getVectorStore(memoryKey: string): Promise { - let vectorStoreInstance = this.vectorStoreBuffer.get(memoryKey); - - if (!vectorStoreInstance) { - vectorStoreInstance = await MemoryVectorStore.fromExistingIndex(this.embeddings); - this.vectorStoreBuffer.set(memoryKey, vectorStoreInstance); - } - - return vectorStoreInstance; - } - - async addDocuments( - memoryKey: string, - documents: Document[], - clearStore?: boolean, - ): Promise { - if (clearStore) { - this.vectorStoreBuffer.delete(memoryKey); - } - const vectorStoreInstance = await this.getVectorStore(memoryKey); - await vectorStoreInstance.addDocuments(documents); - } -} diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/__tests__/utils.test.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/__tests__/utils.test.ts index 1ddf704f1d6..424d1e14902 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/__tests__/utils.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/__tests__/utils.test.ts @@ -1,6 +1,6 @@ import type { VectorStore } from '@langchain/core/vectorstores'; import type { INodeProperties } from 'n8n-workflow'; -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import { DEFAULT_OPERATION_MODES } from '../constants'; import type { VectorStoreNodeConstructorArgs, NodeOperationMode } from '../types'; @@ -178,8 +178,8 @@ describe('Vector Store Utilities', () => { const retrieveOption = result.find((option) => option.value === 'retrieve'); const retrieveAsToolOption = result.find((option) => option.value === 'retrieve-as-tool'); - expect(retrieveOption?.outputConnectionType).toBe(NodeConnectionType.AiVectorStore); - expect(retrieveAsToolOption?.outputConnectionType).toBe(NodeConnectionType.AiTool); + expect(retrieveOption?.outputConnectionType).toBe(NodeConnectionTypes.AiVectorStore); + expect(retrieveAsToolOption?.outputConnectionType).toBe(NodeConnectionTypes.AiTool); }); }); }); diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/constants.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/constants.ts index 172c8d5d54a..30aceb2bc0d 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/constants.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/constants.ts @@ -1,4 +1,4 @@ -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import type { INodePropertyOptions } from 'n8n-workflow'; import type { NodeOperationMode } from './types'; @@ -28,14 +28,14 @@ export const OPERATION_MODE_DESCRIPTIONS: INodePropertyOptions[] = [ value: 'retrieve', description: 'Retrieve documents from vector store to be used as vector store with AI nodes', action: 'Retrieve documents for Chain/Tool as Vector Store', - outputConnectionType: NodeConnectionType.AiVectorStore, + outputConnectionType: NodeConnectionTypes.AiVectorStore, }, { name: 'Retrieve Documents (As Tool for AI Agent)', value: 'retrieve-as-tool', description: 'Retrieve documents from vector store to be used as tool with AI nodes', action: 'Retrieve documents for AI Agent as Tool', - outputConnectionType: NodeConnectionType.AiTool, + outputConnectionType: NodeConnectionTypes.AiTool, }, { name: 'Update Documents', diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/createVectorStoreNode.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/createVectorStoreNode.ts index 9afd6e6694f..31d0f8eb7a2 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/createVectorStoreNode.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/createVectorStoreNode.ts @@ -2,7 +2,7 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ import type { Embeddings } from '@langchain/core/embeddings'; import type { VectorStore } from '@langchain/core/vectorstores'; -import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; +import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; import type { IExecuteFunctions, INodeExecutionData, @@ -66,18 +66,18 @@ export const createVectorStoreNode = ( inputs: `={{ ((parameters) => { const mode = parameters?.mode; - const inputs = [{ displayName: "Embedding", type: "${NodeConnectionType.AiEmbedding}", required: true, maxConnections: 1}] + const inputs = [{ displayName: "Embedding", type: "${NodeConnectionTypes.AiEmbedding}", required: true, maxConnections: 1}] if (mode === 'retrieve-as-tool') { return inputs; } if (['insert', 'load', 'update'].includes(mode)) { - inputs.push({ displayName: "", type: "${NodeConnectionType.Main}"}) + inputs.push({ displayName: "", type: "${NodeConnectionTypes.Main}"}) } if (['insert'].includes(mode)) { - inputs.push({ displayName: "Document", type: "${NodeConnectionType.AiDocument}", required: true, maxConnections: 1}) + inputs.push({ displayName: "Document", type: "${NodeConnectionTypes.AiDocument}", required: true, maxConnections: 1}) } return inputs })($parameter) @@ -87,13 +87,13 @@ export const createVectorStoreNode = ( const mode = parameters?.mode ?? 'retrieve'; if (mode === 'retrieve-as-tool') { - return [{ displayName: "Tool", type: "${NodeConnectionType.AiTool}"}] + return [{ displayName: "Tool", type: "${NodeConnectionTypes.AiTool}"}] } if (mode === 'retrieve') { - return [{ displayName: "Vector Store", type: "${NodeConnectionType.AiVectorStore}"}] + return [{ displayName: "Vector Store", type: "${NodeConnectionTypes.AiVectorStore}"}] } - return [{ displayName: "", type: "${NodeConnectionType.Main}"}] + return [{ displayName: "", type: "${NodeConnectionTypes.Main}"}] })($parameter) }}`, properties: [ @@ -106,7 +106,7 @@ export const createVectorStoreNode = ( options: getOperationModeOptions(args), }, { - ...getConnectionHintNoticeField([NodeConnectionType.AiRetriever]), + ...getConnectionHintNoticeField([NodeConnectionTypes.AiRetriever]), displayOptions: { show: { mode: ['retrieve'], @@ -232,7 +232,7 @@ export const createVectorStoreNode = ( // Get the embeddings model connected to this node const embeddings = (await this.getInputConnectionData( - NodeConnectionType.AiEmbedding, + NodeConnectionTypes.AiEmbedding, 0, )) as Embeddings; @@ -274,7 +274,7 @@ export const createVectorStoreNode = ( // Get the embeddings model connected to this node const embeddings = (await this.getInputConnectionData( - NodeConnectionType.AiEmbedding, + NodeConnectionTypes.AiEmbedding, 0, )) as Embeddings; diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/operations/__tests__/insertOperation.test.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/operations/__tests__/insertOperation.test.ts index c4fa745b7e0..a37d73f46ad 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/operations/__tests__/insertOperation.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/operations/__tests__/insertOperation.test.ts @@ -6,7 +6,7 @@ import type { VectorStore } from '@langchain/core/vectorstores'; import type { MockProxy } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended'; import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import { logAiEvent } from '@utils/helpers'; import type { N8nBinaryLoader } from '@utils/N8nBinaryLoader'; @@ -137,7 +137,7 @@ describe('handleInsertOperation', () => { // Should get document input from connection expect(mockContext.getInputConnectionData).toHaveBeenCalledWith( - NodeConnectionType.AiDocument, + NodeConnectionTypes.AiDocument, 0, ); diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/operations/insertOperation.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/operations/insertOperation.ts index a80db041a38..5cb44ef8a57 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/operations/insertOperation.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/operations/insertOperation.ts @@ -2,7 +2,7 @@ import type { Document } from '@langchain/core/documents'; import type { Embeddings } from '@langchain/core/embeddings'; import type { VectorStore } from '@langchain/core/vectorstores'; import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import { logAiEvent } from '@utils/helpers'; import type { N8nBinaryLoader } from '@utils/N8nBinaryLoader'; @@ -23,7 +23,7 @@ export async function handleInsertOperation const nodeVersion = context.getNode().typeVersion; // Get the input items and document data const items = context.getInputData(); - const documentInput = (await context.getInputConnectionData(NodeConnectionType.AiDocument, 0)) as + const documentInput = (await context.getInputConnectionData(NodeConnectionTypes.AiDocument, 0)) as | N8nJsonLoader | N8nBinaryLoader | Array>>; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/message.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/message.operation.ts index e88c7e1013f..6ba3a93fd6a 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/message.operation.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/message.operation.ts @@ -12,7 +12,7 @@ import type { } from 'n8n-workflow'; import { ApplicationError, - NodeConnectionType, + NodeConnectionTypes, NodeOperationError, updateDisplayOptions, } from 'n8n-workflow'; @@ -235,7 +235,7 @@ export async function execute(this: IExecuteFunctions, i: number): Promise= 1.6 && this.getNodeParameter('memory', i) === 'connector'; const memory = useMemoryConnector || nodeVersion < 1.6 - ? ((await this.getInputConnectionData(NodeConnectionType.AiMemory, 0)) as + ? ((await this.getInputConnectionData(NodeConnectionTypes.AiMemory, 0)) as | BufferWindowMemory | undefined) : undefined; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/versionDescription.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/versionDescription.ts index 1ebd986f120..64d2c105971 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/versionDescription.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/versionDescription.ts @@ -1,6 +1,6 @@ /* eslint-disable n8n-nodes-base/node-filename-against-convention */ import type { INodeInputConfiguration, INodeTypeDescription } from 'n8n-workflow'; -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import * as assistant from './assistant'; import * as audio from './audio'; @@ -50,25 +50,25 @@ const configureNodeInputs = ( ) => { if (resource === 'assistant' && operation === 'message') { const inputs: INodeInputConfiguration[] = [ - { type: NodeConnectionType.Main }, - { type: NodeConnectionType.AiTool, displayName: 'Tools' }, + { type: NodeConnectionTypes.Main }, + { type: NodeConnectionTypes.AiTool, displayName: 'Tools' }, ]; if (memory !== 'threadId') { - inputs.push({ type: NodeConnectionType.AiMemory, displayName: 'Memory', maxConnections: 1 }); + inputs.push({ type: NodeConnectionTypes.AiMemory, displayName: 'Memory', maxConnections: 1 }); } return inputs; } if (resource === 'text' && operation === 'message') { if (hideTools === 'hide') { - return [NodeConnectionType.Main]; + return [NodeConnectionTypes.Main]; } return [ - { type: NodeConnectionType.Main }, - { type: NodeConnectionType.AiTool, displayName: 'Tools' }, + { type: NodeConnectionTypes.Main }, + { type: NodeConnectionTypes.AiTool, displayName: 'Tools' }, ]; } - return [NodeConnectionType.Main]; + return [NodeConnectionTypes.Main]; }; // eslint-disable-next-line n8n-nodes-base/node-class-description-missing-subtitle @@ -98,7 +98,7 @@ export const versionDescription: INodeTypeDescription = { }, }, inputs: `={{(${configureNodeInputs})($parameter.resource, $parameter.operation, $parameter.hideTools, $parameter.memory ?? undefined)}}`, - outputs: [NodeConnectionType.Main], + outputs: [NodeConnectionTypes.Main], credentials: [ { name: 'openAiApi', diff --git a/packages/@n8n/nodes-langchain/utils/N8nTool.ts b/packages/@n8n/nodes-langchain/utils/N8nTool.ts index f568955beb7..b7f885f6e78 100644 --- a/packages/@n8n/nodes-langchain/utils/N8nTool.ts +++ b/packages/@n8n/nodes-langchain/utils/N8nTool.ts @@ -2,7 +2,7 @@ import type { DynamicStructuredToolInput } from '@langchain/core/tools'; import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools'; import { StructuredOutputParser } from 'langchain/output_parsers'; import type { ISupplyDataFunctions, IDataObject } from 'n8n-workflow'; -import { NodeConnectionType, jsonParse, NodeOperationError } from 'n8n-workflow'; +import { NodeConnectionTypes, jsonParse, NodeOperationError } from 'n8n-workflow'; import type { ZodTypeAny } from 'zod'; import { ZodBoolean, ZodNullable, ZodNumber, ZodObject, ZodOptional } from 'zod'; @@ -96,8 +96,8 @@ export class N8nTool extends DynamicStructuredTool { return result; } catch (e) { - const { index } = context.addInputData(NodeConnectionType.AiTool, [[{ json: { query } }]]); - void context.addOutputData(NodeConnectionType.AiTool, index, e); + const { index } = context.addInputData(NodeConnectionTypes.AiTool, [[{ json: { query } }]]); + void context.addOutputData(NodeConnectionTypes.AiTool, index, e); return e.toString(); } diff --git a/packages/@n8n/nodes-langchain/utils/helpers.ts b/packages/@n8n/nodes-langchain/utils/helpers.ts index ba9551ed6af..99bb5415039 100644 --- a/packages/@n8n/nodes-langchain/utils/helpers.ts +++ b/packages/@n8n/nodes-langchain/utils/helpers.ts @@ -4,7 +4,7 @@ import type { BaseLLM } from '@langchain/core/language_models/llms'; import type { BaseMessage } from '@langchain/core/messages'; import type { Tool } from '@langchain/core/tools'; import type { BaseChatMemory } from 'langchain/memory'; -import { NodeConnectionType, NodeOperationError, jsonStringify } from 'n8n-workflow'; +import { NodeConnectionTypes, NodeOperationError, jsonStringify } from 'n8n-workflow'; import type { AiEvent, IDataObject, @@ -190,7 +190,7 @@ export const getConnectedTools = async ( escapeCurlyBrackets: boolean = false, ) => { const connectedTools = - ((await ctx.getInputConnectionData(NodeConnectionType.AiTool, 0)) as Tool[]) || []; + ((await ctx.getInputConnectionData(NodeConnectionTypes.AiTool, 0)) as Tool[]) || []; if (!enforceUniqueNames) return connectedTools; diff --git a/packages/@n8n/nodes-langchain/utils/logWrapper.ts b/packages/@n8n/nodes-langchain/utils/logWrapper.ts index 699bf393956..a58217f005b 100644 --- a/packages/@n8n/nodes-langchain/utils/logWrapper.ts +++ b/packages/@n8n/nodes-langchain/utils/logWrapper.ts @@ -15,8 +15,9 @@ import type { INodeExecutionData, ISupplyDataFunctions, ITaskMetadata, + NodeConnectionType, } from 'n8n-workflow'; -import { NodeOperationError, NodeConnectionType, parseErrorMetadata } from 'n8n-workflow'; +import { NodeOperationError, NodeConnectionTypes, parseErrorMetadata } from 'n8n-workflow'; import { logAiEvent, isToolsInstance, isBaseChatMemory, isBaseChatMessageHistory } from './helpers'; import { N8nBinaryLoader } from './N8nBinaryLoader'; @@ -116,7 +117,7 @@ export function logWrapper( if (isBaseChatMemory(originalInstance)) { if (prop === 'loadMemoryVariables' && 'loadMemoryVariables' in target) { return async (values: InputValues): Promise => { - connectionType = NodeConnectionType.AiMemory; + connectionType = NodeConnectionTypes.AiMemory; const { index } = executeFunctions.addInputData(connectionType, [ [{ json: { action: 'loadMemoryVariables', values } }], @@ -139,7 +140,7 @@ export function logWrapper( }; } else if (prop === 'saveContext' && 'saveContext' in target) { return async (input: InputValues, output: OutputValues): Promise => { - connectionType = NodeConnectionType.AiMemory; + connectionType = NodeConnectionTypes.AiMemory; const { index } = executeFunctions.addInputData(connectionType, [ [{ json: { action: 'saveContext', input, output } }], @@ -168,7 +169,7 @@ export function logWrapper( if (isBaseChatMessageHistory(originalInstance)) { if (prop === 'getMessages' && 'getMessages' in target) { return async (): Promise => { - connectionType = NodeConnectionType.AiMemory; + connectionType = NodeConnectionTypes.AiMemory; const { index } = executeFunctions.addInputData(connectionType, [ [{ json: { action: 'getMessages' } }], ]); @@ -189,7 +190,7 @@ export function logWrapper( }; } else if (prop === 'addMessage' && 'addMessage' in target) { return async (message: BaseMessage): Promise => { - connectionType = NodeConnectionType.AiMemory; + connectionType = NodeConnectionTypes.AiMemory; const payload = { action: 'addMessage', message }; const { index } = executeFunctions.addInputData(connectionType, [[{ json: payload }]]); @@ -214,7 +215,7 @@ export function logWrapper( query: string, config?: Callbacks | BaseCallbackConfig, ): Promise => { - connectionType = NodeConnectionType.AiRetriever; + connectionType = NodeConnectionTypes.AiRetriever; const { index } = executeFunctions.addInputData(connectionType, [ [{ json: { query, config } }], ]); @@ -255,7 +256,7 @@ export function logWrapper( // Docs -> Embeddings if (prop === 'embedDocuments' && 'embedDocuments' in target) { return async (documents: string[]): Promise => { - connectionType = NodeConnectionType.AiEmbedding; + connectionType = NodeConnectionTypes.AiEmbedding; const { index } = executeFunctions.addInputData(connectionType, [ [{ json: { documents } }], ]); @@ -276,7 +277,7 @@ export function logWrapper( // Query -> Embeddings if (prop === 'embedQuery' && 'embedQuery' in target) { return async (query: string): Promise => { - connectionType = NodeConnectionType.AiEmbedding; + connectionType = NodeConnectionTypes.AiEmbedding; const { index } = executeFunctions.addInputData(connectionType, [ [{ json: { query } }], ]); @@ -303,7 +304,7 @@ export function logWrapper( // Process All if (prop === 'processAll' && 'processAll' in target) { return async (items: INodeExecutionData[]): Promise => { - connectionType = NodeConnectionType.AiDocument; + connectionType = NodeConnectionTypes.AiDocument; const { index } = executeFunctions.addInputData(connectionType, [items]); const response = (await callMethodAsync.call(target, { @@ -322,7 +323,7 @@ export function logWrapper( // Process Each if (prop === 'processItem' && 'processItem' in target) { return async (item: INodeExecutionData, itemIndex: number): Promise => { - connectionType = NodeConnectionType.AiDocument; + connectionType = NodeConnectionTypes.AiDocument; const { index } = executeFunctions.addInputData(connectionType, [[item]]); const response = (await callMethodAsync.call(target, { @@ -346,7 +347,7 @@ export function logWrapper( if (originalInstance instanceof TextSplitter) { if (prop === 'splitText' && 'splitText' in target) { return async (text: string): Promise => { - connectionType = NodeConnectionType.AiTextSplitter; + connectionType = NodeConnectionTypes.AiTextSplitter; const { index } = executeFunctions.addInputData(connectionType, [ [{ json: { textSplitter: text } }], ]); @@ -370,7 +371,7 @@ export function logWrapper( if (isToolsInstance(originalInstance)) { if (prop === '_call' && '_call' in target) { return async (query: string): Promise => { - connectionType = NodeConnectionType.AiTool; + connectionType = NodeConnectionTypes.AiTool; const { index } = executeFunctions.addInputData(connectionType, [ [{ json: { query } }], ]); @@ -399,7 +400,7 @@ export function logWrapper( filter?: BiquadFilterType | undefined, _callbacks?: Callbacks | undefined, ): Promise => { - connectionType = NodeConnectionType.AiVectorStore; + connectionType = NodeConnectionTypes.AiVectorStore; const { index } = executeFunctions.addInputData(connectionType, [ [{ json: { query, k, filter } }], ]); diff --git a/packages/@n8n/nodes-langchain/utils/output_parsers/N8nOutputFixingParser.ts b/packages/@n8n/nodes-langchain/utils/output_parsers/N8nOutputFixingParser.ts index 2fb91bab476..fd58363cf09 100644 --- a/packages/@n8n/nodes-langchain/utils/output_parsers/N8nOutputFixingParser.ts +++ b/packages/@n8n/nodes-langchain/utils/output_parsers/N8nOutputFixingParser.ts @@ -4,7 +4,7 @@ import type { AIMessage } from '@langchain/core/messages'; import { BaseOutputParser, OutputParserException } from '@langchain/core/output_parsers'; import type { PromptTemplate } from '@langchain/core/prompts'; import type { ISupplyDataFunctions } from 'n8n-workflow'; -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import type { N8nStructuredOutputParser } from './N8nStructuredOutputParser'; import { logAiEvent } from '../helpers'; @@ -33,7 +33,7 @@ export class N8nOutputFixingParser extends BaseOutputParser { * @throws Error if both parsing attempts fail */ async parse(completion: string, callbacks?: Callbacks) { - const { index } = this.context.addInputData(NodeConnectionType.AiOutputParser, [ + const { index } = this.context.addInputData(NodeConnectionTypes.AiOutputParser, [ [{ json: { action: 'parse', text: completion } }], ]); @@ -47,7 +47,7 @@ export class N8nOutputFixingParser extends BaseOutputParser { }); logAiEvent(this.context, 'ai-output-parsed', { text: completion, response }); - this.context.addOutputData(NodeConnectionType.AiOutputParser, index, [ + this.context.addOutputData(NodeConnectionTypes.AiOutputParser, index, [ [{ json: { action: 'parse', response } }], ]); @@ -68,14 +68,14 @@ export class N8nOutputFixingParser extends BaseOutputParser { const parsed = await this.outputParser.parse(resultText, callbacks); // Add the successfully parsed output to the context - this.context.addOutputData(NodeConnectionType.AiOutputParser, index, [ + this.context.addOutputData(NodeConnectionTypes.AiOutputParser, index, [ [{ json: { action: 'parse', response: parsed } }], ]); return parsed; } catch (autoParseError) { // If both attempts fail, add the error to the output and throw - this.context.addOutputData(NodeConnectionType.AiOutputParser, index, autoParseError); + this.context.addOutputData(NodeConnectionTypes.AiOutputParser, index, autoParseError); throw autoParseError; } } diff --git a/packages/@n8n/nodes-langchain/utils/output_parsers/N8nOutputParser.ts b/packages/@n8n/nodes-langchain/utils/output_parsers/N8nOutputParser.ts index 74a346f1bee..51bd79591eb 100644 --- a/packages/@n8n/nodes-langchain/utils/output_parsers/N8nOutputParser.ts +++ b/packages/@n8n/nodes-langchain/utils/output_parsers/N8nOutputParser.ts @@ -1,5 +1,5 @@ import type { IExecuteFunctions } from 'n8n-workflow'; -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import { N8nItemListOutputParser } from './N8nItemListOutputParser'; import { N8nOutputFixingParser } from './N8nOutputFixingParser'; @@ -19,7 +19,7 @@ export async function getOptionalOutputParser( if (ctx.getNodeParameter('hasOutputParser', 0, true) === true) { outputParser = (await ctx.getInputConnectionData( - NodeConnectionType.AiOutputParser, + NodeConnectionTypes.AiOutputParser, 0, )) as N8nOutputParser; } diff --git a/packages/@n8n/nodes-langchain/utils/output_parsers/N8nStructuredOutputParser.ts b/packages/@n8n/nodes-langchain/utils/output_parsers/N8nStructuredOutputParser.ts index 08ba1735a2d..641f84e5d5f 100644 --- a/packages/@n8n/nodes-langchain/utils/output_parsers/N8nStructuredOutputParser.ts +++ b/packages/@n8n/nodes-langchain/utils/output_parsers/N8nStructuredOutputParser.ts @@ -2,7 +2,7 @@ import type { Callbacks } from '@langchain/core/callbacks/manager'; import { StructuredOutputParser } from 'langchain/output_parsers'; import get from 'lodash/get'; import type { ISupplyDataFunctions } from 'n8n-workflow'; -import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; +import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; import { z } from 'zod'; import { logAiEvent, unwrapNestedOutput } from '../helpers'; @@ -28,7 +28,7 @@ export class N8nStructuredOutputParser extends StructuredOutputParser< _callbacks?: Callbacks, errorMapper?: (error: Error) => Error, ): Promise { - const { index } = this.context.addInputData(NodeConnectionType.AiOutputParser, [ + const { index } = this.context.addInputData(NodeConnectionTypes.AiOutputParser, [ [{ json: { action: 'parse', text } }], ]); try { @@ -46,7 +46,7 @@ export class N8nStructuredOutputParser extends StructuredOutputParser< logAiEvent(this.context, 'ai-output-parsed', { text, response: result }); - this.context.addOutputData(NodeConnectionType.AiOutputParser, index, [ + this.context.addOutputData(NodeConnectionTypes.AiOutputParser, index, [ [{ json: { action: 'parse', response: result } }], ]); @@ -66,7 +66,7 @@ export class N8nStructuredOutputParser extends StructuredOutputParser< response: e.message ?? e, }); - this.context.addOutputData(NodeConnectionType.AiOutputParser, index, nodeError); + this.context.addOutputData(NodeConnectionTypes.AiOutputParser, index, nodeError); if (errorMapper) { throw errorMapper(e); } diff --git a/packages/@n8n/nodes-langchain/utils/sharedFields.ts b/packages/@n8n/nodes-langchain/utils/sharedFields.ts index ffc9640aafe..beb92b6f748 100644 --- a/packages/@n8n/nodes-langchain/utils/sharedFields.ts +++ b/packages/@n8n/nodes-langchain/utils/sharedFields.ts @@ -1,4 +1,4 @@ -import { NodeConnectionType, type INodeProperties } from 'n8n-workflow'; +import { NodeConnectionTypes, type INodeProperties } from 'n8n-workflow'; export const metadataFilterField: INodeProperties = { displayName: 'Metadata Filter', @@ -43,36 +43,36 @@ export function getTemplateNoticeField(templateId: number): INodeProperties { } const connectionsString = { - [NodeConnectionType.AiAgent]: { + [NodeConnectionTypes.AiAgent]: { // Root AI view connection: '', locale: 'AI Agent', }, - [NodeConnectionType.AiChain]: { + [NodeConnectionTypes.AiChain]: { // Root AI view connection: '', locale: 'AI Chain', }, - [NodeConnectionType.AiDocument]: { - connection: NodeConnectionType.AiDocument, + [NodeConnectionTypes.AiDocument]: { + connection: NodeConnectionTypes.AiDocument, locale: 'Document Loader', }, - [NodeConnectionType.AiVectorStore]: { - connection: NodeConnectionType.AiVectorStore, + [NodeConnectionTypes.AiVectorStore]: { + connection: NodeConnectionTypes.AiVectorStore, locale: 'Vector Store', }, - [NodeConnectionType.AiRetriever]: { - connection: NodeConnectionType.AiRetriever, + [NodeConnectionTypes.AiRetriever]: { + connection: NodeConnectionTypes.AiRetriever, locale: 'Vector Store Retriever', }, }; type AllowedConnectionTypes = - | NodeConnectionType.AiAgent - | NodeConnectionType.AiChain - | NodeConnectionType.AiDocument - | NodeConnectionType.AiVectorStore - | NodeConnectionType.AiRetriever; + | typeof NodeConnectionTypes.AiAgent + | typeof NodeConnectionTypes.AiChain + | typeof NodeConnectionTypes.AiDocument + | typeof NodeConnectionTypes.AiVectorStore + | typeof NodeConnectionTypes.AiRetriever; function determineArticle(nextWord: string): string { // check if the next word starts with a vowel sound diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index 70ad68c52c7..e850d124cbf 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -23,4 +23,5 @@ export const RESOURCES = { workersView: ['manage'] as const, workflow: ['share', 'execute', 'move', ...DEFAULT_OPERATIONS] as const, folder: [...DEFAULT_OPERATIONS] as const, + insights: ['list'] as const, } as const; diff --git a/packages/@n8n/task-runner/src/js-task-runner/__tests__/test-data.ts b/packages/@n8n/task-runner/src/js-task-runner/__tests__/test-data.ts index 85a1235dc66..1b630122ecc 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/__tests__/test-data.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/__tests__/test-data.ts @@ -1,5 +1,5 @@ import type { IDataObject, INode, INodeExecutionData, ITaskData } from 'n8n-workflow'; -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import { nanoid } from 'nanoid'; import type { JSExecSettings } from '@/js-task-runner/js-task-runner'; @@ -78,7 +78,7 @@ export const newDataRequestResponse = ( active: true, connections: { [manualTriggerNode.name]: { - main: [[{ node: codeNode.name, type: NodeConnectionType.Main, index: 0 }]], + main: [[{ node: codeNode.name, type: NodeConnectionTypes.Main, index: 0 }]], }, }, nodes: [manualTriggerNode, codeNode], diff --git a/packages/cli/.eslintrc.js b/packages/cli/.eslintrc.js index 8a62ade42c3..58c1429e237 100644 --- a/packages/cli/.eslintrc.js +++ b/packages/cli/.eslintrc.js @@ -47,7 +47,12 @@ module.exports = { }, }, { - files: ['./src/databases/**/*.ts', './test/**/*.ts', './src/**/__tests__/**/*.ts'], + files: [ + './src/databases/**/*.ts', + './src/modules/**/*.ts', + './test/**/*.ts', + './src/**/__tests__/**/*.ts', + ], rules: { 'n8n-local-rules/misplaced-n8n-typeorm-import': 'off', }, diff --git a/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts b/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts index 50d2d8f74f7..97b58f15ecd 100644 --- a/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts +++ b/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts @@ -1,7 +1,7 @@ import { mock } from 'jest-mock-extended'; import type { DirectoryLoader } from 'n8n-core'; import type { INodeProperties, INodeTypeDescription } from 'n8n-workflow'; -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import { LoadNodesAndCredentials } from '../load-nodes-and-credentials'; @@ -51,8 +51,8 @@ describe('LoadNodesAndCredentials', () => { description: 'A test node', version: 1, defaults: {}, - inputs: [NodeConnectionType.Main], - outputs: [NodeConnectionType.Main], + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], properties: [], }, }; @@ -67,7 +67,7 @@ describe('LoadNodesAndCredentials', () => { it('should update inputs and outputs', () => { const result = instance.convertNodeToAiTool(fullNodeWrapper); expect(result.description.inputs).toEqual([]); - expect(result.description.outputs).toEqual([NodeConnectionType.AiTool]); + expect(result.description.outputs).toEqual([NodeConnectionTypes.AiTool]); }); it('should remove the usableAsTool property', () => { diff --git a/packages/cli/src/commands/__tests__/community-node.test.ts b/packages/cli/src/commands/__tests__/community-node.test.ts new file mode 100644 index 00000000000..35d999393a1 --- /dev/null +++ b/packages/cli/src/commands/__tests__/community-node.test.ts @@ -0,0 +1,272 @@ +import { type Config } from '@oclif/core'; +import { mock } from 'jest-mock-extended'; + +import { type CredentialsEntity } from '@/databases/entities/credentials-entity'; +import { type InstalledNodes } from '@/databases/entities/installed-nodes'; +import { type User } from '@/databases/entities/user'; + +import { CommunityNode } from '../community-node'; + +describe('uninstallCredential', () => { + const userId = '1234'; + + const config: Config = mock(); + const communityNode = new CommunityNode(['--uninstall', '--credential', 'evolutionApi'], config); + + beforeEach(() => { + communityNode.deleteCredential = jest.fn(); + communityNode.findCredentialsByType = jest.fn(); + communityNode.findUserById = jest.fn(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should delete a credential', async () => { + const credentialType = 'evolutionApi'; + + const credential = mock(); + credential.id = '666'; + + const user = mock(); + const credentials = [credential]; + + communityNode.parseFlags = jest.fn().mockReturnValue({ + flags: { credential: credentialType, uninstall: true, userId }, + }); + communityNode.findCredentialsByType = jest.fn().mockReturnValue(credentials); + communityNode.findUserById = jest.fn().mockReturnValue(user); + + const deleteCredential = jest.spyOn(communityNode, 'deleteCredential'); + const findCredentialsByType = jest.spyOn(communityNode, 'findCredentialsByType'); + const findUserById = jest.spyOn(communityNode, 'findUserById'); + + await communityNode.run(); + + expect(findCredentialsByType).toHaveBeenCalledTimes(1); + expect(findCredentialsByType).toHaveBeenCalledWith(credentialType); + + expect(findUserById).toHaveBeenCalledTimes(1); + expect(findUserById).toHaveBeenCalledWith(userId); + + expect(deleteCredential).toHaveBeenCalledTimes(1); + expect(deleteCredential).toHaveBeenCalledWith(user, credential.id); + }); + + it('should return if the user is not found', async () => { + const credentialType = 'evolutionApi'; + + const credential = mock(); + credential.id = '666'; + + communityNode.parseFlags = jest.fn().mockReturnValue({ + flags: { credential: credentialType, uninstall: true, userId }, + }); + communityNode.findUserById = jest.fn().mockReturnValue(null); + + const deleteCredential = jest.spyOn(communityNode, 'deleteCredential'); + const findCredentialsByType = jest.spyOn(communityNode, 'findCredentialsByType'); + const findUserById = jest.spyOn(communityNode, 'findUserById'); + + await communityNode.run(); + + expect(findUserById).toHaveBeenCalledTimes(1); + expect(findUserById).toHaveBeenCalledWith(userId); + + expect(findCredentialsByType).toHaveBeenCalledTimes(0); + expect(deleteCredential).toHaveBeenCalledTimes(0); + }); + + it('should return if the credential is not found', async () => { + const credentialType = 'evolutionApi'; + + const credential = mock(); + credential.id = '666'; + + communityNode.parseFlags = jest.fn().mockReturnValue({ + flags: { credential: credentialType, uninstall: true, userId }, + }); + communityNode.findUserById = jest.fn().mockReturnValue(mock()); + communityNode.findCredentialsByType = jest.fn().mockReturnValue(null); + + const deleteCredential = jest.spyOn(communityNode, 'deleteCredential'); + const findCredentialsByType = jest.spyOn(communityNode, 'findCredentialsByType'); + const findUserById = jest.spyOn(communityNode, 'findUserById'); + + await communityNode.run(); + + expect(findUserById).toHaveBeenCalledTimes(1); + expect(findUserById).toHaveBeenCalledWith(userId); + + expect(findCredentialsByType).toHaveBeenCalledTimes(1); + expect(findCredentialsByType).toHaveBeenCalledWith(credentialType); + + expect(deleteCredential).toHaveBeenCalledTimes(0); + }); + + it('should delete multiple credentials', async () => { + const credentialType = 'evolutionApi'; + + const credential1 = mock(); + credential1.id = '666'; + + const credential2 = mock(); + credential2.id = '777'; + + const user = mock(); + const credentials = [credential1, credential2]; + + communityNode.parseFlags = jest.fn().mockReturnValue({ + flags: { credential: credentialType, uninstall: true, userId }, + }); + communityNode.findCredentialsByType = jest.fn().mockReturnValue(credentials); + communityNode.findUserById = jest.fn().mockReturnValue(user); + + const deleteCredential = jest.spyOn(communityNode, 'deleteCredential'); + const findCredentialsByType = jest.spyOn(communityNode, 'findCredentialsByType'); + const findUserById = jest.spyOn(communityNode, 'findUserById'); + + await communityNode.run(); + + expect(findCredentialsByType).toHaveBeenCalledTimes(1); + expect(findCredentialsByType).toHaveBeenCalledWith(credentialType); + + expect(findUserById).toHaveBeenCalledTimes(1); + expect(findUserById).toHaveBeenCalledWith(userId); + + expect(deleteCredential).toHaveBeenCalledTimes(2); + expect(deleteCredential).toHaveBeenCalledWith(user, credential1.id); + expect(deleteCredential).toHaveBeenCalledWith(user, credential2.id); + }); +}); + +describe('uninstallPackage', () => { + const config: Config = mock(); + const communityNode = new CommunityNode( + ['--uninstall', '--package', 'n8n-nodes-evolution-api.evolutionApi'], + config, + ); + + beforeEach(() => { + communityNode.removeCommunityPackage = jest.fn(); + communityNode.deleteCommunityNode = jest.fn(); + communityNode.pruneDependencies = jest.fn(); + communityNode.findCommunityPackage = jest.fn(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should uninstall the package', async () => { + const installedNode = mock(); + const communityPackage = { + installedNodes: [installedNode], + }; + + communityNode.parseFlags = jest.fn().mockReturnValue({ + flags: { package: 'n8n-nodes-evolution-api', uninstall: true }, + }); + communityNode.findCommunityPackage = jest.fn().mockReturnValue(communityPackage); + + const deleteCommunityNode = jest.spyOn(communityNode, 'deleteCommunityNode'); + const removeCommunityPackageSpy = jest.spyOn(communityNode, 'removeCommunityPackage'); + const findCommunityPackage = jest.spyOn(communityNode, 'findCommunityPackage'); + + await communityNode.run(); + + expect(findCommunityPackage).toHaveBeenCalledTimes(1); + expect(findCommunityPackage).toHaveBeenCalledWith('n8n-nodes-evolution-api'); + + expect(deleteCommunityNode).toHaveBeenCalledTimes(1); + expect(deleteCommunityNode).toHaveBeenCalledWith(installedNode); + + expect(removeCommunityPackageSpy).toHaveBeenCalledTimes(1); + expect(removeCommunityPackageSpy).toHaveBeenCalledWith( + 'n8n-nodes-evolution-api', + communityPackage, + ); + }); + + it('should uninstall all nodes from a package', async () => { + const installedNode0 = mock(); + const installedNode1 = mock(); + + const communityPackage = { + installedNodes: [installedNode0, installedNode1], + }; + + communityNode.parseFlags = jest.fn().mockReturnValue({ + flags: { package: 'n8n-nodes-evolution-api', uninstall: true }, + }); + communityNode.findCommunityPackage = jest.fn().mockReturnValue(communityPackage); + + const deleteCommunityNode = jest.spyOn(communityNode, 'deleteCommunityNode'); + const removeCommunityPackageSpy = jest.spyOn(communityNode, 'removeCommunityPackage'); + const findCommunityPackage = jest.spyOn(communityNode, 'findCommunityPackage'); + + await communityNode.run(); + + expect(findCommunityPackage).toHaveBeenCalledTimes(1); + expect(findCommunityPackage).toHaveBeenCalledWith('n8n-nodes-evolution-api'); + + expect(deleteCommunityNode).toHaveBeenCalledTimes(2); + expect(deleteCommunityNode).toHaveBeenCalledWith(installedNode0); + expect(deleteCommunityNode).toHaveBeenCalledWith(installedNode1); + + expect(removeCommunityPackageSpy).toHaveBeenCalledTimes(1); + expect(removeCommunityPackageSpy).toHaveBeenCalledWith( + 'n8n-nodes-evolution-api', + communityPackage, + ); + }); + + it('should return if a package is not found', async () => { + communityNode.parseFlags = jest.fn().mockReturnValue({ + flags: { package: 'n8n-nodes-evolution-api', uninstall: true }, + }); + communityNode.findCommunityPackage = jest.fn().mockReturnValue(null); + + const deleteCommunityNode = jest.spyOn(communityNode, 'deleteCommunityNode'); + const removeCommunityPackageSpy = jest.spyOn(communityNode, 'removeCommunityPackage'); + const findCommunityPackage = jest.spyOn(communityNode, 'findCommunityPackage'); + + await communityNode.run(); + + expect(findCommunityPackage).toHaveBeenCalledTimes(1); + expect(findCommunityPackage).toHaveBeenCalledWith('n8n-nodes-evolution-api'); + + expect(deleteCommunityNode).toHaveBeenCalledTimes(0); + + expect(removeCommunityPackageSpy).toHaveBeenCalledTimes(0); + }); + + it('should return if nodes are not found', async () => { + const communityPackage = { + installedNodes: [], + }; + + communityNode.parseFlags = jest.fn().mockReturnValue({ + flags: { package: 'n8n-nodes-evolution-api', uninstall: true }, + }); + communityNode.findCommunityPackage = jest.fn().mockReturnValue(communityPackage); + + const deleteCommunityNode = jest.spyOn(communityNode, 'deleteCommunityNode'); + const removeCommunityPackageSpy = jest.spyOn(communityNode, 'removeCommunityPackage'); + const findCommunityPackage = jest.spyOn(communityNode, 'findCommunityPackage'); + + await communityNode.run(); + + expect(findCommunityPackage).toHaveBeenCalledTimes(1); + expect(findCommunityPackage).toHaveBeenCalledWith('n8n-nodes-evolution-api'); + + expect(deleteCommunityNode).toHaveBeenCalledTimes(0); + + expect(removeCommunityPackageSpy).toHaveBeenCalledTimes(1); + expect(removeCommunityPackageSpy).toHaveBeenCalledWith( + 'n8n-nodes-evolution-api', + communityPackage, + ); + }); +}); diff --git a/packages/cli/src/commands/base-command.ts b/packages/cli/src/commands/base-command.ts index 87ba27a9032..6e6f2cdcb90 100644 --- a/packages/cli/src/commands/base-command.ts +++ b/packages/cli/src/commands/base-command.ts @@ -33,6 +33,7 @@ import { ExternalHooks } from '@/external-hooks'; import { ExternalSecretsManager } from '@/external-secrets.ee/external-secrets-manager.ee'; import { License } from '@/license'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; +import { ModulesConfig } from '@/modules/modules.config'; import { NodeTypes } from '@/node-types'; import { PostHogClient } from '@/posthog'; import { ShutdownService } from '@/shutdown/shutdown.service'; @@ -57,6 +58,8 @@ export abstract class BaseCommand extends Command { protected readonly globalConfig = Container.get(GlobalConfig); + protected readonly modulesConfig = Container.get(ModulesConfig); + /** * How long to wait for graceful shutdown before force killing the process. */ @@ -66,6 +69,13 @@ export abstract class BaseCommand extends Command { /** Whether to init community packages (if enabled) */ protected needsCommunityPackages = false; + protected async loadModules() { + for (const moduleName of this.modulesConfig.modules) { + await import(`../modules/${moduleName}/${moduleName}.module`); + this.logger.debug(`Loaded module "${moduleName}"`); + } + } + async init(): Promise { this.errorReporter = Container.get(ErrorReporter); diff --git a/packages/cli/src/commands/community-node.ts b/packages/cli/src/commands/community-node.ts new file mode 100644 index 00000000000..1c76d5515d0 --- /dev/null +++ b/packages/cli/src/commands/community-node.ts @@ -0,0 +1,166 @@ +import { Container } from '@n8n/di'; +import { Flags } from '@oclif/core'; + +import { CredentialsService } from '@/credentials/credentials.service'; +import { type InstalledNodes } from '@/databases/entities/installed-nodes'; +import { type InstalledPackages } from '@/databases/entities/installed-packages'; +import { type User } from '@/databases/entities/user'; +import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; +import { InstalledNodesRepository } from '@/databases/repositories/installed-nodes.repository'; +import { UserRepository } from '@/databases/repositories/user.repository'; +import { CommunityPackagesService } from '@/services/community-packages.service'; + +import { BaseCommand } from './base-command'; + +export class CommunityNode extends BaseCommand { + static description = '\nUninstall a community node and its credentials'; + + static examples = [ + '$ n8n community-node --uninstall --package n8n-nodes-evolution-api', + '$ n8n community-node --uninstall --credential evolutionApi --userId 1234', + ]; + + static flags = { + help: Flags.help({ char: 'h' }), + uninstall: Flags.boolean({ + description: 'Uninstalls the node', + }), + package: Flags.string({ + description: 'Package name of the community node.', + }), + credential: Flags.string({ + description: + "Type of the credential.\nGet this value by visiting the node's .credential.ts file and getting the value of `name`", + }), + userId: Flags.string({ + description: + 'The ID of the user who owns the credential.\nOn self-hosted, query the database.\nOn cloud, query the API with your API key', + }), + }; + + async init() { + await super.init(); + } + + async run() { + const { flags } = await this.parseFlags(); + + const packageName = flags.package; + const credentialType = flags.credential; + const userId = flags.userId; + + if (!flags) { + this.logger.info('Please set flags. See help for more information.'); + return; + } + + if (!flags.uninstall) { + this.logger.info('"--uninstall" has to be set!'); + return; + } + + if (!packageName && !credentialType) { + this.logger.info('"--package" or "--credential" has to be set!'); + return; + } + + if (packageName) { + await this.uninstallPackage(packageName); + return; + } + + if (credentialType && userId) { + await this.uninstallCredential(credentialType, userId); + } else { + this.logger.info('"--userId" has to be set!'); + } + } + + async catch(error: Error) { + this.logger.error('Error in node command:'); + this.logger.error(error.message); + } + + async uninstallCredential(credentialType: string, userId: string) { + const user = await this.findUserById(userId); + + if (user === null) { + this.logger.info(`User ${userId} not found`); + return; + } + + const credentials = await this.findCredentialsByType(credentialType); + + if (credentials === null) { + this.logger.info(`Credentials with type ${credentialType} not found`); + return; + } + + credentials.forEach(async (credential) => { + await this.deleteCredential(user, credential.id); + }); + + this.logger.info(`All credentials with type ${credentialType} successfully uninstalled`); + } + + async findUserById(userId: string) { + return await Container.get(UserRepository).findOneBy({ id: userId }); + } + + async findCredentialsByType(credentialType: string) { + return await Container.get(CredentialsRepository).findBy({ type: credentialType }); + } + + async deleteCredential(user: User, credentialId: string) { + return await Container.get(CredentialsService).delete(user, credentialId); + } + + async uninstallPackage(packageName: string) { + const communityPackage = await this.findCommunityPackage(packageName); + + if (communityPackage === null) { + this.logger.info(`Package ${packageName} not found`); + return; + } + + await this.removeCommunityPackage(packageName, communityPackage); + + const installedNodes = communityPackage?.installedNodes; + + if (!installedNodes) { + this.logger.info(`Nodes in ${packageName} not found`); + return; + } + + for (const node of installedNodes) { + await this.deleteCommunityNode(node); + } + + await this.pruneDependencies(); + } + + async pruneDependencies() { + await Container.get(CommunityPackagesService).executeNpmCommand('npm prune'); + } + + async parseFlags() { + return await this.parse(CommunityNode); + } + + async deleteCommunityNode(node: InstalledNodes) { + return await Container.get(InstalledNodesRepository).delete({ + type: node.type, + }); + } + + async removeCommunityPackage(packageName: string, communityPackage: InstalledPackages) { + return await Container.get(CommunityPackagesService).removePackage( + packageName, + communityPackage, + ); + } + + async findCommunityPackage(packageName: string) { + return await Container.get(CommunityPackagesService).findInstalledPackage(packageName); + } +} diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 887ba2a1b72..96a299038ff 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -239,6 +239,8 @@ export class Start extends BaseCommand { const taskRunnerModule = Container.get(TaskRunnerModule); await taskRunnerModule.start(); } + + await this.loadModules(); } async initOrchestration() { diff --git a/packages/cli/src/commands/webhook.ts b/packages/cli/src/commands/webhook.ts index 9eb91438e82..9ab1718b856 100644 --- a/packages/cli/src/commands/webhook.ts +++ b/packages/cli/src/commands/webhook.ts @@ -79,6 +79,8 @@ export class Webhook extends BaseCommand { this.logger.debug('External hooks init complete'); await this.initExternalSecrets(); this.logger.debug('External secrets init complete'); + + await this.loadModules(); } async run() { diff --git a/packages/cli/src/commands/worker.ts b/packages/cli/src/commands/worker.ts index c6046a77727..2fc69cb22ae 100644 --- a/packages/cli/src/commands/worker.ts +++ b/packages/cli/src/commands/worker.ts @@ -117,6 +117,8 @@ export class Worker extends BaseCommand { const taskRunnerModule = Container.get(TaskRunnerModule); await taskRunnerModule.start(); } + + await this.loadModules(); } async initEventBus() { diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 238cb807656..8cbd9b159fb 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -364,13 +364,4 @@ export const schema = { env: 'N8N_PROXY_HOPS', doc: 'Number of reverse-proxies n8n is running behind', }, - - folders: { - enabled: { - format: Boolean, - default: false, - env: 'N8N_FOLDERS_ENABLED', - doc: 'Temporary env variable to enable folders feature', - }, - }, }; diff --git a/packages/cli/src/databases/entities/index.ts b/packages/cli/src/databases/entities/index.ts index 3412f72a8ff..1066aa059c6 100644 --- a/packages/cli/src/databases/entities/index.ts +++ b/packages/cli/src/databases/entities/index.ts @@ -33,6 +33,9 @@ import { WorkflowEntity } from './workflow-entity'; import { WorkflowHistory } from './workflow-history'; import { WorkflowStatistics } from './workflow-statistics'; import { WorkflowTagMapping } from './workflow-tag-mapping'; +import { InsightsByPeriod } from '../../modules/insights/entities/insights-by-period'; +import { InsightsMetadata } from '../../modules/insights/entities/insights-metadata'; +import { InsightsRaw } from '../../modules/insights/entities/insights-raw'; export const entities = { AnnotationTagEntity, @@ -70,4 +73,7 @@ export const entities = { TestCaseExecution, Folder, FolderTagMapping, + InsightsRaw, + InsightsMetadata, + InsightsByPeriod, }; diff --git a/packages/cli/src/databases/migrations/common/1739549398681-CreateAnalyticsTables.ts b/packages/cli/src/databases/migrations/common/1739549398681-CreateAnalyticsTables.ts index ee1bdb4ccfc..02ae64ce4f2 100644 --- a/packages/cli/src/databases/migrations/common/1739549398681-CreateAnalyticsTables.ts +++ b/packages/cli/src/databases/migrations/common/1739549398681-CreateAnalyticsTables.ts @@ -99,8 +99,8 @@ export class CreateAnalyticsTables1739549398681 implements ReversibleMigration { } async down({ schemaBuilder: { dropTable } }: MigrationContext) { - await dropTable(names.t.analyticsMetadata); await dropTable(names.t.analyticsRaw); await dropTable(names.t.analyticsByPeriod); + await dropTable(names.t.analyticsMetadata); } } diff --git a/packages/cli/src/databases/migrations/common/1741167584277-RenameAnalyticsToInsights.ts b/packages/cli/src/databases/migrations/common/1741167584277-RenameAnalyticsToInsights.ts new file mode 100644 index 00000000000..7cda87fef10 --- /dev/null +++ b/packages/cli/src/databases/migrations/common/1741167584277-RenameAnalyticsToInsights.ts @@ -0,0 +1,112 @@ +import type { IrreversibleMigration, MigrationContext } from '@/databases/types'; + +const names = { + // table names + t: { + analyticsMetadata: 'analytics_metadata', + analyticsRaw: 'analytics_raw', + analyticsByPeriod: 'analytics_by_period', + + insightsMetadata: 'insights_metadata', + insightsRaw: 'insights_raw', + insightsByPeriod: 'insights_by_period', + + workflowEntity: 'workflow_entity', + project: 'project', + }, + // column names by table + c: { + insightsMetadata: { + metaId: 'metaId', + projectId: 'projectId', + workflowId: 'workflowId', + }, + insightsRaw: { + metaId: 'metaId', + }, + insightsByPeriod: { + metaId: 'metaId', + type: 'type', + periodUnit: 'periodUnit', + periodStart: 'periodStart', + }, + project: { + id: 'id', + }, + workflowEntity: { + id: 'id', + }, + }, +}; + +export class RenameAnalyticsToInsights1741167584277 implements IrreversibleMigration { + async up({ schemaBuilder: { createTable, column, dropTable } }: MigrationContext) { + // Until the insights feature is released we're dropping the tables instead + // of migrating them. + await dropTable(names.t.analyticsRaw); + await dropTable(names.t.analyticsByPeriod); + await dropTable(names.t.analyticsMetadata); + + await createTable(names.t.insightsMetadata) + .withColumns( + column(names.c.insightsMetadata.metaId).int.primary.autoGenerate2, + column(names.c.insightsMetadata.workflowId).varchar(16), + column(names.c.insightsMetadata.projectId).varchar(36), + column('workflowName').varchar(128).notNull, + column('projectName').varchar(255).notNull, + ) + .withForeignKey(names.c.insightsMetadata.workflowId, { + tableName: names.t.workflowEntity, + columnName: names.c.workflowEntity.id, + onDelete: 'SET NULL', + }) + .withForeignKey(names.c.insightsMetadata.projectId, { + tableName: names.t.project, + columnName: names.c.project.id, + onDelete: 'SET NULL', + }) + .withIndexOn(names.c.insightsMetadata.workflowId, true); + + const typeComment = '0: time_saved_minutes, 1: runtime_milliseconds, 2: success, 3: failure'; + + await createTable(names.t.insightsRaw) + .withColumns( + column('id').int.primary.autoGenerate2, + column(names.c.insightsRaw.metaId).int.notNull, + column('type').int.notNull.comment(typeComment), + column('value').int.notNull, + column('timestamp').timestampTimezone(0).default('CURRENT_TIMESTAMP').notNull, + ) + .withForeignKey(names.c.insightsRaw.metaId, { + tableName: names.t.insightsMetadata, + columnName: names.c.insightsMetadata.metaId, + onDelete: 'CASCADE', + }); + + await createTable(names.t.insightsByPeriod) + .withColumns( + column('id').int.primary.autoGenerate2, + column(names.c.insightsByPeriod.metaId).int.notNull, + column(names.c.insightsByPeriod.type).int.notNull.comment(typeComment), + column('value').int.notNull, + column(names.c.insightsByPeriod.periodUnit).int.notNull.comment('0: hour, 1: day, 2: week'), + column(names.c.insightsByPeriod.periodStart) + .default('CURRENT_TIMESTAMP') + .timestampTimezone(0), + ) + .withForeignKey(names.c.insightsByPeriod.metaId, { + tableName: names.t.insightsMetadata, + columnName: names.c.insightsMetadata.metaId, + onDelete: 'CASCADE', + }) + .withIndexOn( + [ + names.c.insightsByPeriod.periodStart, + names.c.insightsByPeriod.type, + names.c.insightsByPeriod.periodUnit, + names.c.insightsByPeriod.metaId, + ], + true, + ); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index 01cda1ef2e9..c19d8218c41 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -82,6 +82,7 @@ import { CreateTestCaseExecutionTable1736947513045 } from '../common/17369475130 import { AddErrorColumnsToTestRuns1737715421462 } from '../common/1737715421462-AddErrorColumnsToTestRuns'; import { CreateFolderTable1738709609940 } from '../common/1738709609940-CreateFolderTable'; import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-CreateAnalyticsTables'; +import { RenameAnalyticsToInsights1741167584277 } from '../common/1741167584277-RenameAnalyticsToInsights'; import { UpdateParentFolderIdColumn1740445074052 } from '../mysqldb/1740445074052-UpdateParentFolderIdColumn'; export const mysqlMigrations: Migration[] = [ @@ -168,4 +169,5 @@ export const mysqlMigrations: Migration[] = [ FixTestDefinitionPrimaryKey1739873751194, CreateAnalyticsTables1739549398681, UpdateParentFolderIdColumn1740445074052, + RenameAnalyticsToInsights1741167584277, ]; diff --git a/packages/cli/src/databases/migrations/postgresdb/index.ts b/packages/cli/src/databases/migrations/postgresdb/index.ts index 03d13680724..ddfc9c470cc 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -82,6 +82,7 @@ import { CreateTestCaseExecutionTable1736947513045 } from '../common/17369475130 import { AddErrorColumnsToTestRuns1737715421462 } from '../common/1737715421462-AddErrorColumnsToTestRuns'; import { CreateFolderTable1738709609940 } from '../common/1738709609940-CreateFolderTable'; import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-CreateAnalyticsTables'; +import { RenameAnalyticsToInsights1741167584277 } from '../common/1741167584277-RenameAnalyticsToInsights'; export const postgresMigrations: Migration[] = [ InitialMigration1587669153312, @@ -166,4 +167,5 @@ export const postgresMigrations: Migration[] = [ CreateFolderTable1738709609940, CreateAnalyticsTables1739549398681, UpdateParentFolderIdColumn1740445074052, + RenameAnalyticsToInsights1741167584277, ]; diff --git a/packages/cli/src/databases/migrations/sqlite/index.ts b/packages/cli/src/databases/migrations/sqlite/index.ts index c175d214c78..179c103aeb6 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -79,6 +79,7 @@ import { AddStatsColumnsToTestRun1736172058779 } from '../common/1736172058779-A import { CreateTestCaseExecutionTable1736947513045 } from '../common/1736947513045-CreateTestCaseExecutionTable'; import { AddErrorColumnsToTestRuns1737715421462 } from '../common/1737715421462-AddErrorColumnsToTestRuns'; import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-CreateAnalyticsTables'; +import { RenameAnalyticsToInsights1741167584277 } from '../common/1741167584277-RenameAnalyticsToInsights'; const sqliteMigrations: Migration[] = [ InitialMigration1588102412422, @@ -160,6 +161,7 @@ const sqliteMigrations: Migration[] = [ CreateFolderTable1738709609940, CreateAnalyticsTables1739549398681, UpdateParentFolderIdColumn1740445074052, + RenameAnalyticsToInsights1741167584277, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/decorators/__tests__/module.test.ts b/packages/cli/src/decorators/__tests__/module.test.ts new file mode 100644 index 00000000000..0cadfbe555c --- /dev/null +++ b/packages/cli/src/decorators/__tests__/module.test.ts @@ -0,0 +1,33 @@ +import { Container } from '@n8n/di'; +import { mock } from 'jest-mock-extended'; +import type { ExecutionLifecycleHooks } from 'n8n-core'; + +import type { BaseN8nModule } from '../module'; +import { ModuleRegistry, N8nModule } from '../module'; + +let moduleRegistry: ModuleRegistry; + +beforeEach(() => { + moduleRegistry = new ModuleRegistry(); +}); + +describe('registerLifecycleHooks', () => { + @N8nModule() + class TestModule implements BaseN8nModule { + registerLifecycleHooks() {} + } + + test('is called when ModuleRegistry.registerLifecycleHooks is called', () => { + // ARRANGE + const hooks = mock(); + const instance = Container.get(TestModule); + jest.spyOn(instance, 'registerLifecycleHooks'); + + // ACT + moduleRegistry.registerLifecycleHooks(hooks); + + // ASSERT + expect(instance.registerLifecycleHooks).toHaveBeenCalledTimes(1); + expect(instance.registerLifecycleHooks).toHaveBeenCalledWith(hooks); + }); +}); diff --git a/packages/cli/src/decorators/module.ts b/packages/cli/src/decorators/module.ts new file mode 100644 index 00000000000..d196c8d1e46 --- /dev/null +++ b/packages/cli/src/decorators/module.ts @@ -0,0 +1,29 @@ +import { Container, Service, type Constructable } from '@n8n/di'; +import type { ExecutionLifecycleHooks } from 'n8n-core'; + +export interface BaseN8nModule { + registerLifecycleHooks?(hooks: ExecutionLifecycleHooks): void; +} + +type Module = Constructable; + +export const registry = new Set(); + +export const N8nModule = (): ClassDecorator => (target) => { + registry.add(target as unknown as Module); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return Service()(target); +}; + +@Service() +export class ModuleRegistry { + registerLifecycleHooks(hooks: ExecutionLifecycleHooks) { + for (const ModuleClass of registry.keys()) { + const instance = Container.get(ModuleClass); + if (instance.registerLifecycleHooks) { + instance.registerLifecycleHooks(hooks); + } + } + } +} diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control-export.service.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control-export.service.test.ts index b36faf2475d..773980bd479 100644 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control-export.service.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control-export.service.test.ts @@ -6,6 +6,7 @@ import fsp from 'node:fs/promises'; import type { SharedCredentials } from '@/databases/entities/shared-credentials'; import type { SharedWorkflow } from '@/databases/entities/shared-workflow'; +import type { FolderRepository } from '@/databases/repositories/folder.repository'; import type { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; import type { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; import type { TagRepository } from '@/databases/repositories/tag.repository'; @@ -23,6 +24,7 @@ describe('SourceControlExportService', () => { const tagRepository = mock(); const workflowTagMappingRepository = mock(); const variablesService = mock(); + const folderRepository = mock(); const service = new SourceControlExportService( mock(), @@ -32,6 +34,7 @@ describe('SourceControlExportService', () => { sharedWorkflowRepository, workflowRepository, workflowTagMappingRepository, + folderRepository, mock({ n8nFolder: '/mock/n8n' }), ); @@ -190,6 +193,35 @@ describe('SourceControlExportService', () => { }); }); + describe('exportFoldersToWorkFolder', () => { + it('should export folders to work folder', async () => { + // Arrange + folderRepository.find.mockResolvedValue([ + mock({ updatedAt: new Date(), createdAt: new Date() }), + ]); + workflowRepository.find.mockResolvedValue([mock()]); + + // Act + const result = await service.exportFoldersToWorkFolder(); + + // Assert + expect(result.count).toBe(1); + expect(result.files).toHaveLength(1); + }); + + it('should not export empty folders', async () => { + // Arrange + folderRepository.find.mockResolvedValue([]); + + // Act + const result = await service.exportFoldersToWorkFolder(); + + // Assert + expect(result.count).toBe(0); + expect(result.files).toHaveLength(0); + }); + }); + describe('exportVariablesToWorkFolder', () => { it('should export variables to work folder', async () => { // Arrange diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts index cc817368cd2..9bb56d6f032 100644 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts @@ -4,14 +4,17 @@ import { type InstanceSettings } from 'n8n-core'; import fsp from 'node:fs/promises'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; +import type { FolderRepository } from '@/databases/repositories/folder.repository'; import type { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { SourceControlImportService } from '../source-control-import.service.ee'; +import type { ExportableFolder } from '../types/exportable-folders'; jest.mock('fast-glob'); describe('SourceControlImportService', () => { const workflowRepository = mock(); + const folderRepository = mock(); const service = new SourceControlImportService( mock(), mock(), @@ -29,6 +32,7 @@ describe('SourceControlImportService', () => { mock(), mock(), mock(), + folderRepository, mock({ n8nFolder: '/mock/n8n' }), ); @@ -160,6 +164,43 @@ describe('SourceControlImportService', () => { }); }); + describe('getRemoteFoldersAndMappingsFromFile', () => { + it('should parse folders and mappings file correctly', async () => { + globMock.mockResolvedValue(['/mock/folders.json']); + + const now = new Date(); + + const mockFoldersData: { + folders: ExportableFolder[]; + } = { + folders: [ + { + id: 'folder1', + name: 'folder 1', + parentFolderId: null, + homeProjectId: 'project1', + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + }, + ], + }; + + fsReadFile.mockResolvedValue(JSON.stringify(mockFoldersData)); + + const result = await service.getRemoteFoldersAndMappingsFromFile(); + + expect(result.folders).toEqual(mockFoldersData.folders); + }); + + it('should return empty folders and mappings if no file found', async () => { + globMock.mockResolvedValue([]); + + const result = await service.getRemoteFoldersAndMappingsFromFile(); + + expect(result.folders).toHaveLength(0); + }); + }); + describe('getLocalVersionIdsFromDb', () => { const now = new Date(); jest.useFakeTimers({ now }); @@ -180,4 +221,31 @@ describe('SourceControlImportService', () => { expect(result[0].updatedAt).toBe(now.toISOString()); }); }); + + describe('getLocalFoldersAndMappingsFromDb', () => { + it('should return data from DB', async () => { + // Arrange + + folderRepository.find.mockResolvedValue([ + mock({ createdAt: new Date(), updatedAt: new Date() }), + ]); + workflowRepository.find.mockResolvedValue([mock()]); + + // Act + + const result = await service.getLocalFoldersAndMappingsFromDb(); + + // Assert + + expect(result.folders).toHaveLength(1); + expect(result.folders[0]).toHaveProperty('id'); + expect(result.folders[0]).toHaveProperty('name'); + expect(result.folders[0]).toHaveProperty('parentFolderId'); + expect(result.folders[0]).toHaveProperty('homeProjectId'); + }); + }); + + describe('importFoldersFromWorkFolder', () => { + // add tests for this. + }); }); diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts index 43a13ed6bad..e975a3371b3 100644 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts @@ -3,9 +3,11 @@ import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import { InstanceSettings } from 'n8n-core'; +import type { FolderWithWorkflowAndSubFolderCount } from '@/databases/entities/folder'; import type { TagEntity } from '@/databases/entities/tag-entity'; import type { User } from '@/databases/entities/user'; import type { Variables } from '@/databases/entities/variables'; +import type { FolderRepository } from '@/databases/repositories/folder.repository'; import type { TagRepository } from '@/databases/repositories/tag.repository'; import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee'; import { SourceControlService } from '@/environments.ee/source-control/source-control.service.ee'; @@ -24,6 +26,7 @@ describe('SourceControlService', () => { ); const sourceControlImportService = mock(); const tagRepository = mock(); + const folderRepository = mock(); const sourceControlService = new SourceControlService( mock(), mock(), @@ -31,6 +34,7 @@ describe('SourceControlService', () => { mock(), sourceControlImportService, tagRepository, + folderRepository, mock(), ); @@ -171,6 +175,30 @@ describe('SourceControlService', () => { mappings: [], }); + // Define a folder that does only exist locally. + // Pulling this would delete it so it should be marked as a conflict. + // Pushing this is conflict free. + const folder = mock({ + updatedAt: new Date(), + createdAt: new Date(), + }); + folderRepository.find.mockResolvedValue([folder]); + sourceControlImportService.getRemoteFoldersAndMappingsFromFile.mockResolvedValue({ + folders: [], + }); + sourceControlImportService.getLocalFoldersAndMappingsFromDb.mockResolvedValue({ + folders: [ + { + id: folder.id, + name: folder.name, + parentFolderId: folder.parentFolder?.id ?? '', + homeProjectId: folder.homeProject.id, + createdAt: folder.createdAt.toISOString(), + updatedAt: folder.updatedAt.toISOString(), + }, + ], + }); + // ACT const pullResult = await sourceControlService.getStatus({ direction: 'pull', @@ -185,8 +213,6 @@ describe('SourceControlService', () => { }); // ASSERT - console.log(pullResult); - console.log(pushResult); if (!Array.isArray(pullResult)) { fail('Expected pullResult to be an array.'); @@ -195,8 +221,8 @@ describe('SourceControlService', () => { fail('Expected pushResult to be an array.'); } - expect(pullResult).toHaveLength(4); - expect(pushResult).toHaveLength(4); + expect(pullResult).toHaveLength(5); + expect(pushResult).toHaveLength(5); expect(pullResult.find((i) => i.type === 'workflow')).toHaveProperty('conflict', true); expect(pushResult.find((i) => i.type === 'workflow')).toHaveProperty('conflict', false); @@ -209,6 +235,9 @@ describe('SourceControlService', () => { expect(pullResult.find((i) => i.type === 'tags')).toHaveProperty('conflict', true); expect(pushResult.find((i) => i.type === 'tags')).toHaveProperty('conflict', false); + + expect(pullResult.find((i) => i.type === 'folders')).toHaveProperty('conflict', true); + expect(pushResult.find((i) => i.type === 'folders')).toHaveProperty('conflict', false); }); }); }); diff --git a/packages/cli/src/environments.ee/source-control/constants.ts b/packages/cli/src/environments.ee/source-control/constants.ts index 6580ce99793..d2524e26e87 100644 --- a/packages/cli/src/environments.ee/source-control/constants.ts +++ b/packages/cli/src/environments.ee/source-control/constants.ts @@ -5,6 +5,7 @@ export const SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER = 'workflows'; export const SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER = 'credential_stubs'; export const SOURCE_CONTROL_VARIABLES_EXPORT_FILE = 'variable_stubs.json'; export const SOURCE_CONTROL_TAGS_EXPORT_FILE = 'tags.json'; +export const SOURCE_CONTROL_FOLDERS_EXPORT_FILE = 'folders.json'; export const SOURCE_CONTROL_OWNERS_EXPORT_FILE = 'workflow_owners.json'; export const SOURCE_CONTROL_SSH_FOLDER = 'ssh'; export const SOURCE_CONTROL_SSH_KEY_NAME = 'key'; diff --git a/packages/cli/src/environments.ee/source-control/source-control-export.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-export.service.ee.ts index a46135d59e2..21a1dea4381 100644 --- a/packages/cli/src/environments.ee/source-control/source-control-export.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-export.service.ee.ts @@ -1,11 +1,14 @@ import type { SourceControlledFile } from '@n8n/api-types'; import { Service } from '@n8n/di'; +// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import +import { In } from '@n8n/typeorm'; import { rmSync } from 'fs'; import { Credentials, InstanceSettings, Logger } from 'n8n-core'; import { UnexpectedError, type ICredentialDataDecryptedObject } from 'n8n-workflow'; import { writeFile as fsWriteFile, rm as fsRm } from 'node:fs/promises'; import path from 'path'; +import { FolderRepository } from '@/databases/repositories/folder.repository'; import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; import { TagRepository } from '@/databases/repositories/tag.repository'; @@ -22,6 +25,7 @@ import { } from './constants'; import { getCredentialExportPath, + getFoldersPath, getVariablesPath, getWorkflowExportPath, sourceControlFoldersExistCheck, @@ -49,6 +53,7 @@ export class SourceControlExportService { private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly workflowRepository: WorkflowRepository, private readonly workflowTagMappingRepository: WorkflowTagMappingRepository, + private readonly folderRepository: FolderRepository, instanceSettings: InstanceSettings, ) { this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER); @@ -100,6 +105,7 @@ export class SourceControlExportService { triggerCount: e.triggerCount, versionId: e.versionId, owner: owners[e.id], + parentFolderId: e.parentFolder?.id ?? null, }; this.logger.debug(`Writing workflow ${e.id} to ${fileName}`); return await fsWriteFile(fileName, JSON.stringify(sanitizedWorkflow, null, 2)); @@ -112,7 +118,10 @@ export class SourceControlExportService { sourceControlFoldersExistCheck([this.workflowExportFolder]); const workflowIds = candidates.map((e) => e.id); const sharedWorkflows = await this.sharedWorkflowRepository.findByWorkflowIds(workflowIds); - const workflows = await this.workflowRepository.findByIds(workflowIds); + const workflows = await this.workflowRepository.find({ + where: { id: In(workflowIds) }, + relations: ['parentFolder'], + }); // determine owner of each workflow to be exported const owners: Record = {}; @@ -201,6 +210,66 @@ export class SourceControlExportService { } } + async exportFoldersToWorkFolder(): Promise { + try { + sourceControlFoldersExistCheck([this.gitFolder]); + const folders = await this.folderRepository.find({ + relations: ['parentFolder', 'homeProject'], + select: { + id: true, + name: true, + createdAt: true, + updatedAt: true, + parentFolder: { + id: true, + }, + homeProject: { + id: true, + }, + }, + }); + + if (folders.length === 0) { + return { + count: 0, + folder: this.gitFolder, + files: [], + }; + } + + const fileName = getFoldersPath(this.gitFolder); + await fsWriteFile( + fileName, + JSON.stringify( + { + folders: folders.map((f) => ({ + id: f.id, + name: f.name, + parentFolderId: f.parentFolder?.id ?? null, + homeProjectId: f.homeProject.id, + createdAt: f.createdAt.toISOString(), + updatedAt: f.updatedAt.toISOString(), + })), + }, + null, + 2, + ), + ); + return { + count: folders.length, + folder: this.gitFolder, + files: [ + { + id: '', + name: fileName, + }, + ], + }; + } catch (error) { + throw new UnexpectedError('Failed to export folders to work folder', { cause: error }); + } + } + async exportTagsToWorkFolder(): Promise { try { sourceControlFoldersExistCheck([this.gitFolder]); diff --git a/packages/cli/src/environments.ee/source-control/source-control-helper.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-helper.ee.ts index 8e98fbdc8e5..696a9ec5660 100644 --- a/packages/cli/src/environments.ee/source-control/source-control-helper.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-helper.ee.ts @@ -11,6 +11,7 @@ import { License } from '@/license'; import { isContainedWithin } from '@/utils/path-util'; import { + SOURCE_CONTROL_FOLDERS_EXPORT_FILE, SOURCE_CONTROL_GIT_KEY_COMMENT, SOURCE_CONTROL_TAGS_EXPORT_FILE, SOURCE_CONTROL_VARIABLES_EXPORT_FILE, @@ -41,6 +42,10 @@ export function getTagsPath(gitFolder: string): string { return path.join(gitFolder, SOURCE_CONTROL_TAGS_EXPORT_FILE); } +export function getFoldersPath(gitFolder: string): string { + return path.join(gitFolder, SOURCE_CONTROL_FOLDERS_EXPORT_FILE); +} + export function sourceControlFoldersExistCheck( folders: string[], createIfNotExists = true, diff --git a/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts index aba8d16eaa8..d3b263b0c51 100644 --- a/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts @@ -17,6 +17,7 @@ import type { User } from '@/databases/entities/user'; import type { Variables } from '@/databases/entities/variables'; import type { WorkflowTagMapping } from '@/databases/entities/workflow-tag-mapping'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; +import { FolderRepository } from '@/databases/repositories/folder.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository'; import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; @@ -33,6 +34,7 @@ import { WorkflowService } from '@/workflows/workflow.service'; import { SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER, + SOURCE_CONTROL_FOLDERS_EXPORT_FILE, SOURCE_CONTROL_GIT_FOLDER, SOURCE_CONTROL_TAGS_EXPORT_FILE, SOURCE_CONTROL_VARIABLES_EXPORT_FILE, @@ -40,6 +42,7 @@ import { } from './constants'; import { getCredentialExportPath, getWorkflowExportPath } from './source-control-helper.ee'; import type { ExportableCredential } from './types/exportable-credential'; +import type { ExportableFolder } from './types/exportable-folders'; import type { ResourceOwner } from './types/resource-owner'; import type { SourceControlWorkflowVersionId } from './types/source-control-workflow-version-id'; import { VariablesService } from '../variables/variables.service.ee'; @@ -69,6 +72,7 @@ export class SourceControlImportService { private readonly workflowService: WorkflowService, private readonly credentialsService: CredentialsService, private readonly tagService: TagService, + private readonly folderRepository: FolderRepository, instanceSettings: InstanceSettings, ) { this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER); @@ -88,6 +92,7 @@ export class SourceControlImportService { remoteWorkflowFiles.map(async (file) => { this.logger.debug(`Parsing workflow file ${file}`); const remote = jsonParse(await fsReadFile(file, { encoding: 'utf8' })); + if (!remote?.id) { return undefined; } @@ -95,6 +100,7 @@ export class SourceControlImportService { id: remote.id, versionId: remote.versionId, name: remote.name, + parentFolderId: remote.parentFolderId, remoteId: remote.id, filename: getWorkflowExportPath(remote.id, this.workflowExportFolder), } as SourceControlWorkflowVersionId; @@ -107,7 +113,16 @@ export class SourceControlImportService { async getLocalVersionIdsFromDb(): Promise { const localWorkflows = await this.workflowRepository.find({ - select: ['id', 'name', 'versionId', 'updatedAt'], + relations: ['parentFolder'], + select: { + id: true, + versionId: true, + name: true, + updatedAt: true, + parentFolder: { + id: true, + }, + }, }); return localWorkflows.map((local) => { let updatedAt: Date; @@ -127,6 +142,7 @@ export class SourceControlImportService { versionId: local.versionId, name: local.name, localId: local.id, + parentFolderId: local.parentFolder?.id ?? null, filename: getWorkflowExportPath(local.id, this.workflowExportFolder), updatedAt: updatedAt.toISOString(), }; @@ -190,6 +206,52 @@ export class SourceControlImportService { return await this.variablesService.getAllCached(); } + async getRemoteFoldersAndMappingsFromFile(): Promise<{ + folders: ExportableFolder[]; + }> { + const foldersFile = await glob(SOURCE_CONTROL_FOLDERS_EXPORT_FILE, { + cwd: this.gitFolder, + absolute: true, + }); + if (foldersFile.length > 0) { + this.logger.debug(`Importing folders from file ${foldersFile[0]}`); + const mappedFolders = jsonParse<{ + folders: ExportableFolder[]; + }>(await fsReadFile(foldersFile[0], { encoding: 'utf8' }), { + fallbackValue: { folders: [] }, + }); + return mappedFolders; + } + return { folders: [] }; + } + + async getLocalFoldersAndMappingsFromDb(): Promise<{ + folders: ExportableFolder[]; + }> { + const localFolders = await this.folderRepository.find({ + relations: ['parentFolder', 'homeProject'], + select: { + id: true, + name: true, + createdAt: true, + updatedAt: true, + parentFolder: { id: true }, + homeProject: { id: true }, + }, + }); + + return { + folders: localFolders.map((f) => ({ + id: f.id, + name: f.name, + parentFolderId: f.parentFolder?.id ?? null, + homeProjectId: f.homeProject.id, + createdAt: f.createdAt.toISOString(), + updatedAt: f.updatedAt.toISOString(), + })), + }; + } + async getRemoteTagsAndMappingsFromFile(): Promise<{ tags: TagEntity[]; mappings: WorkflowTagMapping[]; @@ -229,6 +291,10 @@ export class SourceControlImportService { const existingWorkflows = await this.workflowRepository.findByIds(candidateIds, { fields: ['id', 'name', 'versionId', 'active'], }); + + const folders = await this.folderRepository.find({ select: ['id'] }); + const existingFolderIds = folders.map((f) => f.id); + const allSharedWorkflows = await this.sharedWorkflowRepository.findWithFields(candidateIds, { select: ['workflowId', 'role', 'projectId'], }); @@ -239,7 +305,7 @@ export class SourceControlImportService { // We must iterate over the array and run the whole process workflow by workflow for (const candidate of candidates) { this.logger.debug(`Parsing workflow file ${candidate.file}`); - const importedWorkflow = jsonParse( + const importedWorkflow = jsonParse( await fsReadFile(candidate.file, { encoding: 'utf8' }), ); if (!importedWorkflow?.id) { @@ -247,8 +313,18 @@ export class SourceControlImportService { } const existingWorkflow = existingWorkflows.find((e) => e.id === importedWorkflow.id); importedWorkflow.active = existingWorkflow?.active ?? false; + + const parentFolderId = importedWorkflow.parentFolderId ?? ''; + this.logger.debug(`Updating workflow id ${importedWorkflow.id ?? 'new'}`); - const upsertResult = await this.workflowRepository.upsert({ ...importedWorkflow }, ['id']); + + const upsertResult = await this.workflowRepository.upsert( + { + ...importedWorkflow, + parentFolder: existingFolderIds.includes(parentFolderId) ? { id: parentFolderId } : null, + }, + ['id'], + ); if (upsertResult?.identifiers?.length !== 1) { throw new UnexpectedError('Failed to upsert workflow', { extra: { workflowId: importedWorkflow.id ?? 'new' }, @@ -440,6 +516,62 @@ export class SourceControlImportService { return mappedTags; } + async importFoldersFromWorkFolder(user: User, candidate: SourceControlledFile) { + let mappedFolders; + const projects = await this.projectRepository.find(); + const personalProject = await this.projectRepository.getPersonalProjectForUserOrFail(user.id); + + try { + this.logger.debug(`Importing folders from file ${candidate.file}`); + mappedFolders = jsonParse<{ + folders: ExportableFolder[]; + }>(await fsReadFile(candidate.file, { encoding: 'utf8' }), { + fallbackValue: { folders: [] }, + }); + } catch (e) { + const error = ensureError(e); + this.logger.error(`Failed to import folders from file ${candidate.file}`, { error }); + return; + } + + if (mappedFolders.folders.length === 0) { + return; + } + + await Promise.all( + mappedFolders.folders.map(async (folder) => { + const folderCopy = this.folderRepository.create({ + id: folder.id, + name: folder.name, + homeProject: { + id: projects.find((p) => p.id === folder.homeProjectId)?.id ?? personalProject.id, + }, + }); + + await this.folderRepository.upsert(folderCopy, { + skipUpdateIfNoValuesChanged: true, + conflictPaths: { id: true }, + }); + }), + ); + + // After folders are created, setup the parentFolder relationship + await Promise.all( + mappedFolders.folders.map(async (folder) => { + await this.folderRepository.update( + { id: folder.id }, + { + parentFolder: folder.parentFolderId ? { id: folder.parentFolderId } : null, + createdAt: folder.createdAt, + updatedAt: folder.updatedAt, + }, + ); + }), + ); + + return mappedFolders; + } + async importVariablesFromWorkFolder( candidate: SourceControlledFile, valueOverrides?: { @@ -531,6 +663,12 @@ export class SourceControlImportService { } } + async deleteFoldersNotInWorkfolder(candidates: SourceControlledFile[]) { + for (const candidate of candidates) { + await this.folderRepository.delete(candidate.id); + } + } + private async findOrCreateOwnerProject(owner: ResourceOwner): Promise { if (typeof owner === 'string' || owner.type === 'personal') { const email = typeof owner === 'string' ? owner : owner.personalEmail; diff --git a/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts index 2a04a583532..cce6ddc6c80 100644 --- a/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts @@ -13,6 +13,7 @@ import type { PushResult } from 'simple-git'; import type { TagEntity } from '@/databases/entities/tag-entity'; import type { User } from '@/databases/entities/user'; import type { Variables } from '@/databases/entities/variables'; +import { FolderRepository } from '@/databases/repositories/folder.repository'; import { TagRepository } from '@/databases/repositories/tag.repository'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { EventService } from '@/events/event.service'; @@ -25,6 +26,7 @@ import { import { SourceControlExportService } from './source-control-export.service.ee'; import { SourceControlGitService } from './source-control-git.service.ee'; import { + getFoldersPath, getTagsPath, getTrackingInformationFromPostPushResult, getTrackingInformationFromPrePushResult, @@ -36,6 +38,7 @@ import { import { SourceControlImportService } from './source-control-import.service.ee'; import { SourceControlPreferencesService } from './source-control-preferences.service.ee'; import type { ExportableCredential } from './types/exportable-credential'; +import type { ExportableFolder } from './types/exportable-folders'; import type { ImportResult } from './types/import-result'; import type { SourceControlGetStatus } from './types/source-control-get-status'; import type { SourceControlPreferences } from './types/source-control-preferences'; @@ -57,6 +60,7 @@ export class SourceControlService { private sourceControlExportService: SourceControlExportService, private sourceControlImportService: SourceControlImportService, private tagRepository: TagRepository, + private folderRepository: FolderRepository, private readonly eventService: EventService, ) { const { gitFolder, sshFolder, sshKeyName } = sourceControlPreferencesService; @@ -255,13 +259,20 @@ export class SourceControlService { const filesToBePushed = new Set(); const filesToBeDeleted = new Set(); - filesToPush.forEach((e) => { - if (e.status !== 'deleted') { - filesToBePushed.add(e.file); - } else { - filesToBeDeleted.add(e.file); - } - }); + + /* + Exclude tags, variables and folders JSON file from being deleted as + we keep track of them in a single file unlike workflows and credentials + */ + filesToPush + .filter((f) => ['workflow', 'credential'].includes(f.type)) + .forEach((e) => { + if (e.status !== 'deleted') { + filesToBePushed.add(e.file); + } else { + filesToBeDeleted.add(e.file); + } + }); this.sourceControlExportService.rmFilesFromExportFolder(filesToBeDeleted); @@ -284,11 +295,21 @@ export class SourceControlService { }); } - if (filesToPush.find((e) => e.type === 'tags')) { + const tagChanges = filesToPush.find((e) => e.type === 'tags'); + if (tagChanges) { + filesToBePushed.add(tagChanges.file); await this.sourceControlExportService.exportTagsToWorkFolder(); } - if (filesToPush.find((e) => e.type === 'variables')) { + const folderChanges = filesToPush.find((e) => e.type === 'folders'); + if (folderChanges) { + filesToBePushed.add(folderChanges.file); + await this.sourceControlExportService.exportFoldersToWorkFolder(); + } + + const variablesChanges = filesToPush.find((e) => e.type === 'variables'); + if (variablesChanges) { + filesToBePushed.add(variablesChanges.file); await this.sourceControlExportService.exportVariablesToWorkFolder(); } @@ -354,6 +375,14 @@ export class SourceControlService { return files.find((e) => e.type === 'variables' && e.status !== 'deleted'); } + private getFoldersToImport(files: SourceControlledFile[]): SourceControlledFile | undefined { + return files.find((e) => e.type === 'folders' && e.status !== 'deleted'); + } + + private getFoldersToDelete(files: SourceControlledFile[]): SourceControlledFile[] { + return files.filter((e) => e.type === 'folders' && e.status === 'deleted'); + } + private getVariablesToDelete(files: SourceControlledFile[]): SourceControlledFile[] { return files.filter((e) => e.type === 'variables' && e.status === 'deleted'); } @@ -381,6 +410,12 @@ export class SourceControlService { } } + // Make sure the folders get processed first as the workflows depend on them + const foldersToBeImported = this.getFoldersToImport(statusResult); + if (foldersToBeImported) { + await this.sourceControlImportService.importFoldersFromWorkFolder(user, foldersToBeImported); + } + const workflowsToBeImported = this.getWorkflowsToImport(statusResult); await this.sourceControlImportService.importWorkflowFromWorkFolder( workflowsToBeImported, @@ -416,6 +451,9 @@ export class SourceControlService { const variablesToBeDeleted = this.getVariablesToDelete(statusResult); await this.sourceControlImportService.deleteVariablesNotInWorkfolder(variablesToBeDeleted); + const foldersToBeDeleted = this.getFoldersToDelete(statusResult); + await this.sourceControlImportService.deleteFoldersNotInWorkfolder(foldersToBeDeleted); + // #region Tracking Information this.eventService.emit( 'source-control-user-finished-pull-ui', @@ -469,6 +507,9 @@ export class SourceControlService { mappingsMissingInRemote, } = await this.getStatusTagsMappings(options, sourceControlledFiles); + const { foldersMissingInLocal, foldersMissingInRemote, foldersModifiedInEither } = + await this.getStatusFoldersMapping(options, sourceControlledFiles); + // #region Tracking Information if (options.direction === 'push') { this.eventService.emit( @@ -501,6 +542,9 @@ export class SourceControlService { tagsModifiedInEither, mappingsMissingInLocal, mappingsMissingInRemote, + foldersMissingInLocal, + foldersMissingInRemote, + foldersModifiedInEither, sourceControlledFiles, }; } else { @@ -526,7 +570,9 @@ export class SourceControlService { const wfModifiedInEither: SourceControlWorkflowVersionId[] = []; wfLocalVersionIds.forEach((local) => { const mismatchingIds = wfRemoteVersionIds.find( - (remote) => remote.id === local.id && remote.versionId !== local.versionId, + (remote) => + remote.id === local.id && + (remote.versionId !== local.versionId || remote.parentFolderId !== local.parentFolderId), ); let name = (options?.preferLocalVersion ? local?.name : mismatchingIds?.name) ?? 'Workflow'; if (local.name && mismatchingIds?.name && local.name !== mismatchingIds.name) { @@ -840,6 +886,88 @@ export class SourceControlService { }; } + private async getStatusFoldersMapping( + options: SourceControlGetStatus, + sourceControlledFiles: SourceControlledFile[], + ) { + const lastUpdatedFolder = await this.folderRepository.find({ + order: { updatedAt: 'DESC' }, + take: 1, + select: ['updatedAt'], + }); + + const foldersMappingsRemote = + await this.sourceControlImportService.getRemoteFoldersAndMappingsFromFile(); + const foldersMappingsLocal = + await this.sourceControlImportService.getLocalFoldersAndMappingsFromDb(); + + const foldersMissingInLocal = foldersMappingsRemote.folders.filter( + (remote) => foldersMappingsLocal.folders.findIndex((local) => local.id === remote.id) === -1, + ); + + const foldersMissingInRemote = foldersMappingsLocal.folders.filter( + (local) => foldersMappingsRemote.folders.findIndex((remote) => remote.id === local.id) === -1, + ); + + const foldersModifiedInEither: ExportableFolder[] = []; + foldersMappingsLocal.folders.forEach((local) => { + const mismatchingIds = foldersMappingsRemote.folders.find( + (remote) => + remote.id === local.id && + (remote.name !== local.name || remote.parentFolderId !== local.parentFolderId), + ); + + if (!mismatchingIds) { + return; + } + foldersModifiedInEither.push(options.preferLocalVersion ? local : mismatchingIds); + }); + + foldersMissingInLocal.forEach((item) => { + sourceControlledFiles.push({ + id: item.id, + name: item.name, + type: 'folders', + status: options.direction === 'push' ? 'deleted' : 'created', + location: options.direction === 'push' ? 'local' : 'remote', + conflict: false, + file: getFoldersPath(this.gitFolder), + updatedAt: lastUpdatedFolder[0]?.updatedAt.toISOString(), + }); + }); + foldersMissingInRemote.forEach((item) => { + sourceControlledFiles.push({ + id: item.id, + name: item.name, + type: 'folders', + status: options.direction === 'push' ? 'created' : 'deleted', + location: options.direction === 'push' ? 'local' : 'remote', + conflict: options.direction === 'push' ? false : true, + file: getFoldersPath(this.gitFolder), + updatedAt: lastUpdatedFolder[0]?.updatedAt.toISOString(), + }); + }); + + foldersModifiedInEither.forEach((item) => { + sourceControlledFiles.push({ + id: item.id, + name: item.name, + type: 'folders', + status: 'modified', + location: options.direction === 'push' ? 'local' : 'remote', + conflict: true, + file: getFoldersPath(this.gitFolder), + updatedAt: lastUpdatedFolder[0]?.updatedAt.toISOString(), + }); + }); + + return { + foldersMissingInLocal, + foldersMissingInRemote, + foldersModifiedInEither, + }; + } + async setGitUserDetails( name = SOURCE_CONTROL_DEFAULT_NAME, email = SOURCE_CONTROL_DEFAULT_EMAIL, diff --git a/packages/cli/src/environments.ee/source-control/types/exportable-folders.ts b/packages/cli/src/environments.ee/source-control/types/exportable-folders.ts new file mode 100644 index 00000000000..cc6029f48db --- /dev/null +++ b/packages/cli/src/environments.ee/source-control/types/exportable-folders.ts @@ -0,0 +1,8 @@ +export type ExportableFolder = { + id: string; + name: string; + parentFolderId: string | null; + homeProjectId: string; + createdAt: string; + updatedAt: string; +}; diff --git a/packages/cli/src/environments.ee/source-control/types/exportable-workflow.ts b/packages/cli/src/environments.ee/source-control/types/exportable-workflow.ts index 9e8924cc47e..fd53a30d159 100644 --- a/packages/cli/src/environments.ee/source-control/types/exportable-workflow.ts +++ b/packages/cli/src/environments.ee/source-control/types/exportable-workflow.ts @@ -11,4 +11,5 @@ export interface ExportableWorkflow { triggerCount: number; versionId?: string; owner: ResourceOwner; + parentFolderId: string | null; } diff --git a/packages/cli/src/environments.ee/source-control/types/source-control-workflow-version-id.ts b/packages/cli/src/environments.ee/source-control/types/source-control-workflow-version-id.ts index 9afbf4e6fbb..ab8106ac29e 100644 --- a/packages/cli/src/environments.ee/source-control/types/source-control-workflow-version-id.ts +++ b/packages/cli/src/environments.ee/source-control/types/source-control-workflow-version-id.ts @@ -5,5 +5,6 @@ export interface SourceControlWorkflowVersionId { name?: string; localId?: string; remoteId?: string; + parentFolderId: string | null; updatedAt?: string; } diff --git a/packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts b/packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts index 38e38c9803b..628349bf85a 100644 --- a/packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts +++ b/packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts @@ -1,7 +1,7 @@ import { Service } from '@n8n/di'; import { parse } from 'flatted'; import { ErrorReporter, Logger } from 'n8n-core'; -import { ExecutionCancelledError, NodeConnectionType, Workflow } from 'n8n-workflow'; +import { ExecutionCancelledError, NodeConnectionTypes, Workflow } from 'n8n-workflow'; import type { IDataObject, IRun, @@ -128,7 +128,7 @@ export class TestRunnerService { // Start nodes are the nodes that are connected to the trigger node const startNodes = workflowInstance - .getChildNodes(triggerNode, NodeConnectionType.Main, 1) + .getChildNodes(triggerNode, NodeConnectionTypes.Main, 1) .map((nodeName) => ({ name: nodeName, sourceData: { previousNode: pastExecutionTriggerNode }, diff --git a/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts b/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts index fec17f2dec8..c7b9c638434 100644 --- a/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts +++ b/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts @@ -8,6 +8,7 @@ import type { } from 'n8n-workflow'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; +import { ModuleRegistry } from '@/decorators/module'; import { EventService } from '@/events/event.service'; import { ExternalHooks } from '@/external-hooks'; import { Push } from '@/push'; @@ -394,11 +395,13 @@ export function getLifecycleHooksForScalingWorker( hookFunctionsPush(hooks, optionalParameters); } + Container.get(ModuleRegistry).registerLifecycleHooks(hooks); + return hooks; } /** - * Returns ExecutionLifecycleHooks instance for main process if workflow runs via worker + * Returns ExecutionLifecycleHooks instance for main process in scaling mode. */ export function getLifecycleHooksForScalingMain( data: IWorkflowExecutionDataProcess, @@ -454,11 +457,13 @@ export function getLifecycleHooksForScalingMain( hooks.handlers.nodeExecuteBefore = []; hooks.handlers.nodeExecuteAfter = []; + Container.get(ModuleRegistry).registerLifecycleHooks(hooks); + return hooks; } /** - * Returns ExecutionLifecycleHooks instance for running the main workflow + * Returns ExecutionLifecycleHooks instance for the main process in regular mode */ export function getLifecycleHooksForRegularMain( data: IWorkflowExecutionDataProcess, @@ -476,5 +481,6 @@ export function getLifecycleHooksForRegularMain( hookFunctionsSaveProgress(hooks, optionalParameters); hookFunctionsStatistics(hooks); hookFunctionsExternalHooks(hooks); + Container.get(ModuleRegistry).registerLifecycleHooks(hooks); return hooks; } diff --git a/packages/cli/src/executions/__tests__/constants.ts b/packages/cli/src/executions/__tests__/constants.ts index aeba0f14753..808b9042399 100644 --- a/packages/cli/src/executions/__tests__/constants.ts +++ b/packages/cli/src/executions/__tests__/constants.ts @@ -1,5 +1,5 @@ import type { IWorkflowBase } from 'n8n-workflow'; -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; /** * Workflow producing an execution whose data will be truncated by an instance crash. @@ -32,7 +32,7 @@ export const OOM_WORKFLOW: Partial = { [ { node: 'DebugHelper', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], diff --git a/packages/cli/src/interfaces.ts b/packages/cli/src/interfaces.ts index d5a7cbf341a..dff47369ce7 100644 --- a/packages/cli/src/interfaces.ts +++ b/packages/cli/src/interfaces.ts @@ -96,14 +96,19 @@ export interface IWorkflowDb extends IWorkflowBase { parentFolder?: Folder | null; } -export interface IWorkflowToImport extends IWorkflowBase { - tags: ITagToImport[]; -} - export interface IWorkflowResponse extends IWorkflowBase { id: string; } +export interface IWorkflowToImport + extends Omit { + owner: { + type: 'personal'; + personalEmail: string; + }; + parentFolderId: string | null; +} + // ---------------------------------- // credentials // ---------------------------------- diff --git a/packages/cli/src/load-nodes-and-credentials.ts b/packages/cli/src/load-nodes-and-credentials.ts index 2158d47f083..1a7bd2a3551 100644 --- a/packages/cli/src/load-nodes-and-credentials.ts +++ b/packages/cli/src/load-nodes-and-credentials.ts @@ -26,7 +26,7 @@ import type { IVersionedNodeType, INodeProperties, } from 'n8n-workflow'; -import { deepCopy, NodeConnectionType, UnexpectedError, UserError } from 'n8n-workflow'; +import { deepCopy, NodeConnectionTypes, UnexpectedError, UserError } from 'n8n-workflow'; import path from 'path'; import picocolors from 'picocolors'; @@ -449,7 +449,7 @@ export class LoadNodesAndCredentials { if (isFullDescription(item.description)) { item.description.name += 'Tool'; item.description.inputs = []; - item.description.outputs = [NodeConnectionType.AiTool]; + item.description.outputs = [NodeConnectionTypes.AiTool]; item.description.displayName += ' Tool'; delete item.description.usableAsTool; diff --git a/packages/cli/src/modules/__tests__/modules.config.test.ts b/packages/cli/src/modules/__tests__/modules.config.test.ts new file mode 100644 index 00000000000..a0b2ffa1ee0 --- /dev/null +++ b/packages/cli/src/modules/__tests__/modules.config.test.ts @@ -0,0 +1,33 @@ +import { Container } from '@n8n/di'; +import { UnexpectedError } from 'n8n-workflow'; + +import { ModulesConfig } from '../modules.config'; + +describe('ModulesConfig', () => { + beforeEach(() => { + jest.resetAllMocks(); + process.env = {}; + Container.reset(); + }); + + it('should initialize with empty modules if no environment variable is set', () => { + const config = Container.get(ModulesConfig); + expect(config.modules).toEqual([]); + }); + + it('should parse valid module names from environment variable', () => { + process.env.N8N_ENABLED_MODULES = 'insights'; + const config = Container.get(ModulesConfig); + expect(config.modules).toEqual(['insights']); + }); + + it('should throw UnexpectedError for invalid module names', () => { + process.env.N8N_ENABLED_MODULES = 'invalidModule'; + expect(() => Container.get(ModulesConfig)).toThrow(UnexpectedError); + }); + + it('should throw UnexpectedError if any module name is invalid', () => { + process.env.N8N_ENABLED_MODULES = 'insights,invalidModule'; + expect(() => Container.get(ModulesConfig)).toThrow(UnexpectedError); + }); +}); diff --git a/packages/cli/src/modules/insights/__tests__/insights.service.test.ts b/packages/cli/src/modules/insights/__tests__/insights.service.test.ts new file mode 100644 index 00000000000..9b84cbea6ad --- /dev/null +++ b/packages/cli/src/modules/insights/__tests__/insights.service.test.ts @@ -0,0 +1,247 @@ +import { Container } from '@n8n/di'; +import { mock } from 'jest-mock-extended'; +import { DateTime } from 'luxon'; +import type { ExecutionLifecycleHooks } from 'n8n-core'; +import type { ExecutionStatus, IRun, WorkflowExecuteMode } from 'n8n-workflow'; + +import type { Project } from '@/databases/entities/project'; +import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; +import type { IWorkflowDb } from '@/interfaces'; +import type { TypeUnits } from '@/modules/insights/entities/insights-shared'; +import { InsightsMetadataRepository } from '@/modules/insights/repositories/insights-metadata.repository'; +import { InsightsRawRepository } from '@/modules/insights/repositories/insights-raw.repository'; +import { createTeamProject } from '@test-integration/db/projects'; +import { createWorkflow } from '@test-integration/db/workflows'; +import * as testDb from '@test-integration/test-db'; + +import { InsightsService } from '../insights.service'; +import { InsightsByPeriodRepository } from '../repositories/insights-by-period.repository'; + +async function truncateAll() { + const insightsRawRepository = Container.get(InsightsRawRepository); + const insightsMetadataRepository = Container.get(InsightsMetadataRepository); + const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository); + for (const repo of [ + insightsRawRepository, + insightsMetadataRepository, + insightsByPeriodRepository, + ]) { + await repo.delete({}); + } +} + +describe('workflowExecuteAfterHandler', () => { + let insightsService: InsightsService; + let insightsRawRepository: InsightsRawRepository; + let insightsMetadataRepository: InsightsMetadataRepository; + beforeAll(async () => { + await testDb.init(); + + insightsService = Container.get(InsightsService); + insightsRawRepository = Container.get(InsightsRawRepository); + insightsMetadataRepository = Container.get(InsightsMetadataRepository); + }); + + let project: Project; + let workflow: IWorkflowDb & WorkflowEntity; + + beforeEach(async () => { + await truncateAll(); + + project = await createTeamProject(); + workflow = await createWorkflow( + { + settings: { + timeSavedPerExecution: 3, + }, + }, + project, + ); + }); + + test.each<{ status: ExecutionStatus; type: TypeUnits }>([ + { status: 'success', type: 'success' }, + { status: 'error', type: 'failure' }, + { status: 'crashed', type: 'failure' }, + ])('stores events for executions with the status `$status`', async ({ status, type }) => { + // ARRANGE + const ctx = mock({ workflowData: workflow }); + const startedAt = DateTime.utc(); + const stoppedAt = startedAt.plus({ seconds: 5 }); + const run = mock({ + mode: 'webhook', + status, + startedAt: startedAt.toJSDate(), + stoppedAt: stoppedAt.toJSDate(), + }); + + // ACT + await insightsService.workflowExecuteAfterHandler(ctx, run); + + // ASSERT + const metadata = await insightsMetadataRepository.findOneBy({ workflowId: workflow.id }); + + if (!metadata) { + return fail('expected metadata to exist'); + } + + expect(metadata).toMatchObject({ + workflowId: workflow.id, + workflowName: workflow.name, + projectId: project.id, + projectName: project.name, + }); + + const allInsights = await insightsRawRepository.find(); + expect(allInsights).toHaveLength(status === 'success' ? 3 : 2); + expect(allInsights).toContainEqual( + expect.objectContaining({ metaId: metadata.metaId, type, value: 1 }), + ); + expect(allInsights).toContainEqual( + expect.objectContaining({ + metaId: metadata.metaId, + type: 'runtime_ms', + value: stoppedAt.diff(startedAt).toMillis(), + }), + ); + if (status === 'success') { + expect(allInsights).toContainEqual( + expect.objectContaining({ + metaId: metadata.metaId, + type: 'time_saved_min', + value: 3, + }), + ); + } + }); + + test.each<{ status: ExecutionStatus }>([ + { status: 'waiting' }, + { status: 'canceled' }, + { status: 'unknown' }, + { status: 'new' }, + { status: 'running' }, + ])('does not store events for executions with the status `$status`', async ({ status }) => { + // ARRANGE + const ctx = mock({ workflowData: workflow }); + const startedAt = DateTime.utc(); + const stoppedAt = startedAt.plus({ seconds: 5 }); + const run = mock({ + mode: 'webhook', + status, + startedAt: startedAt.toJSDate(), + stoppedAt: stoppedAt.toJSDate(), + }); + + // ACT + await insightsService.workflowExecuteAfterHandler(ctx, run); + + // ASSERT + const metadata = await insightsMetadataRepository.findOneBy({ workflowId: workflow.id }); + const allInsights = await insightsRawRepository.find(); + expect(metadata).toBeNull(); + expect(allInsights).toHaveLength(0); + }); + + test.each<{ mode: WorkflowExecuteMode }>([{ mode: 'internal' }, { mode: 'manual' }])( + 'does not store events for executions with the mode `$mode`', + async ({ mode }) => { + // ARRANGE + const ctx = mock({ workflowData: workflow }); + const startedAt = DateTime.utc(); + const stoppedAt = startedAt.plus({ seconds: 5 }); + const run = mock({ + mode, + status: 'success', + startedAt: startedAt.toJSDate(), + stoppedAt: stoppedAt.toJSDate(), + }); + + // ACT + await insightsService.workflowExecuteAfterHandler(ctx, run); + + // ASSERT + const metadata = await insightsMetadataRepository.findOneBy({ workflowId: workflow.id }); + const allInsights = await insightsRawRepository.find(); + expect(metadata).toBeNull(); + expect(allInsights).toHaveLength(0); + }, + ); + + test.each<{ mode: WorkflowExecuteMode }>([ + { mode: 'evaluation' }, + { mode: 'error' }, + { mode: 'cli' }, + { mode: 'retry' }, + { mode: 'trigger' }, + { mode: 'webhook' }, + { mode: 'integrated' }, + ])('stores events for executions with the mode `$mode`', async ({ mode }) => { + // ARRANGE + const ctx = mock({ workflowData: workflow }); + const startedAt = DateTime.utc(); + const stoppedAt = startedAt.plus({ seconds: 5 }); + const run = mock({ + mode, + status: 'success', + startedAt: startedAt.toJSDate(), + stoppedAt: stoppedAt.toJSDate(), + }); + + // ACT + await insightsService.workflowExecuteAfterHandler(ctx, run); + + // ASSERT + const metadata = await insightsMetadataRepository.findOneBy({ workflowId: workflow.id }); + + if (!metadata) { + return fail('expected metadata to exist'); + } + + expect(metadata).toMatchObject({ + workflowId: workflow.id, + workflowName: workflow.name, + projectId: project.id, + projectName: project.name, + }); + + const allInsights = await insightsRawRepository.find(); + expect(allInsights).toHaveLength(3); + expect(allInsights).toContainEqual( + expect.objectContaining({ metaId: metadata.metaId, type: 'success', value: 1 }), + ); + expect(allInsights).toContainEqual( + expect.objectContaining({ + metaId: metadata.metaId, + type: 'runtime_ms', + value: stoppedAt.diff(startedAt).toMillis(), + }), + ); + expect(allInsights).toContainEqual( + expect.objectContaining({ + metaId: metadata.metaId, + type: 'time_saved_min', + value: 3, + }), + ); + }); + + test("throws UnexpectedError if the execution's workflow has no owner", async () => { + // ARRANGE + const workflow = await createWorkflow({}); + const ctx = mock({ workflowData: workflow }); + const startedAt = DateTime.utc(); + const stoppedAt = startedAt.plus({ seconds: 5 }); + const run = mock({ + mode: 'webhook', + status: 'success', + startedAt: startedAt.toJSDate(), + stoppedAt: stoppedAt.toJSDate(), + }); + + // ACT & ASSERT + await expect(insightsService.workflowExecuteAfterHandler(ctx, run)).rejects.toThrowError( + `Could not find an owner for the workflow with the name '${workflow.name}' and the id '${workflow.id}'`, + ); + }); +}); diff --git a/packages/cli/src/modules/insights/entities/__tests__/db-utils.ts b/packages/cli/src/modules/insights/entities/__tests__/db-utils.ts new file mode 100644 index 00000000000..8781220b93a --- /dev/null +++ b/packages/cli/src/modules/insights/entities/__tests__/db-utils.ts @@ -0,0 +1,64 @@ +import { Container } from '@n8n/di'; +import type { DateTime } from 'luxon'; +import type { IWorkflowBase } from 'n8n-workflow'; + +import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; +import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; + +import { InsightsMetadata } from '../../entities/insights-metadata'; +import { InsightsRaw } from '../../entities/insights-raw'; +import { InsightsMetadataRepository } from '../../repositories/insights-metadata.repository'; +import { InsightsRawRepository } from '../../repositories/insights-raw.repository'; + +async function getWorkflowSharing(workflow: IWorkflowBase) { + return await Container.get(SharedWorkflowRepository).find({ + where: { workflowId: workflow.id }, + relations: { project: true }, + }); +} + +export async function createMetadata(workflow: WorkflowEntity) { + const insightsMetadataRepository = Container.get(InsightsMetadataRepository); + const alreadyExisting = await insightsMetadataRepository.findOneBy({ workflowId: workflow.id }); + + if (alreadyExisting) { + return alreadyExisting; + } + + const metadata = new InsightsMetadata(); + metadata.workflowName = workflow.name; + metadata.workflowId = workflow.id; + + const workflowSharing = (await getWorkflowSharing(workflow)).find( + (wfs) => wfs.role === 'workflow:owner', + ); + if (workflowSharing) { + metadata.projectName = workflowSharing.project.name; + metadata.projectId = workflowSharing.project.id; + } + + await insightsMetadataRepository.save(metadata); + + return metadata; +} + +export async function createRawInsightsEvent( + workflow: WorkflowEntity, + parameters: { + type: InsightsRaw['type']; + value: number; + timestamp?: DateTime; + }, +) { + const insightsRawRepository = Container.get(InsightsRawRepository); + const metadata = await createMetadata(workflow); + + const event = new InsightsRaw(); + event.metaId = metadata.metaId; + event.type = parameters.type; + event.value = parameters.value; + if (parameters.timestamp) { + event.timestamp = parameters.timestamp.toUTC().toJSDate(); + } + return await insightsRawRepository.save(event); +} diff --git a/packages/cli/src/modules/insights/entities/__tests__/insights-by-period.test.ts b/packages/cli/src/modules/insights/entities/__tests__/insights-by-period.test.ts new file mode 100644 index 00000000000..33af07ad561 --- /dev/null +++ b/packages/cli/src/modules/insights/entities/__tests__/insights-by-period.test.ts @@ -0,0 +1,51 @@ +import { Container } from '@n8n/di'; + +import * as testDb from '@test-integration/test-db'; + +import { InsightsRawRepository } from '../../repositories/insights-raw.repository'; +import { InsightsByPeriod } from '../insights-by-period'; +import type { PeriodUnits, TypeUnits } from '../insights-shared'; + +let insightsRawRepository: InsightsRawRepository; + +beforeAll(async () => { + await testDb.init(); + insightsRawRepository = Container.get(InsightsRawRepository); +}); + +beforeEach(async () => { + await insightsRawRepository.delete({}); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +describe('Insights By Period', () => { + test.each(['time_saved_min', 'runtime_ms', 'failure', 'success'] satisfies TypeUnits[])( + '`%s` can be serialized and deserialized correctly', + (typeUnit) => { + // ARRANGE + const insightByPeriod = new InsightsByPeriod(); + + // ACT + insightByPeriod.type = typeUnit; + + // ASSERT + expect(insightByPeriod.type).toBe(typeUnit); + }, + ); + test.each(['hour', 'day', 'week'] satisfies PeriodUnits[])( + '`%s` can be serialized and deserialized correctly', + (periodUnit) => { + // ARRANGE + const insightByPeriod = new InsightsByPeriod(); + + // ACT + insightByPeriod.periodUnit = periodUnit; + + // ASSERT + expect(insightByPeriod.periodUnit).toBe(periodUnit); + }, + ); +}); diff --git a/packages/cli/src/modules/insights/entities/__tests__/insights-raw.test.ts b/packages/cli/src/modules/insights/entities/__tests__/insights-raw.test.ts new file mode 100644 index 00000000000..848e0870e0d --- /dev/null +++ b/packages/cli/src/modules/insights/entities/__tests__/insights-raw.test.ts @@ -0,0 +1,80 @@ +import { Container } from '@n8n/di'; +import { DateTime } from 'luxon'; + +import { createTeamProject } from '@test-integration/db/projects'; +import { createWorkflow } from '@test-integration/db/workflows'; +import * as testDb from '@test-integration/test-db'; + +import { createMetadata, createRawInsightsEvent } from './db-utils'; +import { InsightsRawRepository } from '../../repositories/insights-raw.repository'; +import { InsightsRaw } from '../insights-raw'; +import type { TypeUnits } from '../insights-shared'; + +let insightsRawRepository: InsightsRawRepository; + +beforeAll(async () => { + await testDb.init(); + insightsRawRepository = Container.get(InsightsRawRepository); +}); + +beforeEach(async () => { + await insightsRawRepository.delete({}); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +describe('Insights Raw Entity', () => { + test.each(['success', 'failure', 'runtime_ms', 'time_saved_min'] satisfies TypeUnits[])( + '`%s` can be serialized and deserialized correctly', + (typeUnit) => { + // ARRANGE + const rawInsight = new InsightsRaw(); + + // ACT + rawInsight.type = typeUnit; + + // ASSERT + expect(rawInsight.type).toBe(typeUnit); + }, + ); + + test('`timestamp` can be serialized and deserialized correctly', () => { + // ARRANGE + const rawInsight = new InsightsRaw(); + const now = new Date(); + + // ACT + + rawInsight.timestamp = now; + + // ASSERT + now.setMilliseconds(0); + expect(rawInsight.timestamp).toEqual(now); + }); + + test('timestamp uses the correct default value', async () => { + // ARRANGE + const project = await createTeamProject(); + const workflow = await createWorkflow({}, project); + await createMetadata(workflow); + const rawInsight = await createRawInsightsEvent(workflow, { + type: 'success', + value: 1, + }); + + // ACT + const now = DateTime.utc().startOf('second'); + await insightsRawRepository.save(rawInsight); + + // ASSERT + const timestampValue = await insightsRawRepository.find(); + expect(timestampValue).toHaveLength(1); + const timestamp = timestampValue[0].timestamp; + + expect( + Math.abs(now.toSeconds() - DateTime.fromJSDate(timestamp).toUTC().toSeconds()), + ).toBeLessThan(2); + }); +}); diff --git a/packages/cli/src/modules/insights/entities/insights-by-period.ts b/packages/cli/src/modules/insights/entities/insights-by-period.ts new file mode 100644 index 00000000000..b2e532be33b --- /dev/null +++ b/packages/cli/src/modules/insights/entities/insights-by-period.ts @@ -0,0 +1,62 @@ +import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from '@n8n/typeorm'; +import { UnexpectedError } from 'n8n-workflow'; + +import type { PeriodUnits } from './insights-shared'; +import { + isValidPeriodNumber, + isValidTypeNumber, + NumberToPeriodUnit, + NumberToType, + PeriodUnitToNumber, + TypeToNumber, +} from './insights-shared'; +import { datetimeColumnType } from '../../../databases/entities/abstract-entity'; + +@Entity() +export class InsightsByPeriod extends BaseEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column() + metaId: number; + + @Column({ name: 'type', type: 'int' }) + private type_: number; + + get type() { + if (!isValidTypeNumber(this.type_)) { + throw new UnexpectedError( + `Type '${this.type_}' is not a valid type for 'InsightsByPeriod.type'`, + ); + } + + return NumberToType[this.type_]; + } + + set type(value: keyof typeof TypeToNumber) { + this.type_ = TypeToNumber[value]; + } + + @Column() + value: number; + + @Column({ name: 'periodUnit' }) + private periodUnit_: number; + + get periodUnit() { + if (!isValidPeriodNumber(this.periodUnit_)) { + throw new UnexpectedError( + `Period unit '${this.periodUnit_}' is not a valid unit for 'InsightsByPeriod.periodUnit'`, + ); + } + + return NumberToPeriodUnit[this.periodUnit_]; + } + + set periodUnit(value: PeriodUnits) { + this.periodUnit_ = PeriodUnitToNumber[value]; + } + + @Column({ type: datetimeColumnType }) + periodStart: Date; +} diff --git a/packages/cli/src/modules/insights/entities/insights-metadata.ts b/packages/cli/src/modules/insights/entities/insights-metadata.ts new file mode 100644 index 00000000000..2c050d3c0c1 --- /dev/null +++ b/packages/cli/src/modules/insights/entities/insights-metadata.ts @@ -0,0 +1,19 @@ +import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from '@n8n/typeorm'; + +@Entity() +export class InsightsMetadata extends BaseEntity { + @PrimaryGeneratedColumn() + metaId: number; + + @Column({ unique: true, type: 'varchar', length: 16 }) + workflowId: string; + + @Column({ type: 'varchar', length: 36 }) + projectId: string; + + @Column({ type: 'varchar', length: 128 }) + workflowName: string; + + @Column({ type: 'varchar', length: 255 }) + projectName: string; +} diff --git a/packages/cli/src/modules/insights/entities/insights-raw.ts b/packages/cli/src/modules/insights/entities/insights-raw.ts new file mode 100644 index 00000000000..ceff552a985 --- /dev/null +++ b/packages/cli/src/modules/insights/entities/insights-raw.ts @@ -0,0 +1,49 @@ +import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; +import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from '@n8n/typeorm'; +import { UnexpectedError } from 'n8n-workflow'; + +import { isValidTypeNumber, NumberToType, TypeToNumber } from './insights-shared'; +import { datetimeColumnType } from '../../../databases/entities/abstract-entity'; + +export const { type: dbType } = Container.get(GlobalConfig).database; + +@Entity() +export class InsightsRaw extends BaseEntity { + constructor() { + super(); + this.timestamp = new Date(); + } + + @PrimaryGeneratedColumn() + id: number; + + @Column() + metaId: number; + + @Column({ name: 'type', type: 'int' }) + private type_: number; + + get type() { + if (!isValidTypeNumber(this.type_)) { + throw new UnexpectedError( + `Type '${this.type_}' is not a valid type for 'InsightsByPeriod.type'`, + ); + } + + return NumberToType[this.type_]; + } + + set type(value: keyof typeof TypeToNumber) { + this.type_ = TypeToNumber[value]; + } + + @Column() + value: number; + + @Column({ + name: 'timestamp', + type: datetimeColumnType, + }) + timestamp: Date; +} diff --git a/packages/cli/src/modules/insights/entities/insights-shared.ts b/packages/cli/src/modules/insights/entities/insights-shared.ts new file mode 100644 index 00000000000..14260dd69e5 --- /dev/null +++ b/packages/cli/src/modules/insights/entities/insights-shared.ts @@ -0,0 +1,46 @@ +function isValid>( + value: number | string | symbol, + constant: T, +): value is keyof T { + return Object.keys(constant).includes(value.toString()); +} + +// Periods +export const PeriodUnitToNumber = { + hour: 0, + day: 1, + week: 2, +} as const; +export type PeriodUnits = keyof typeof PeriodUnitToNumber; +export type PeriodUnitNumbers = (typeof PeriodUnitToNumber)[PeriodUnits]; +export const NumberToPeriodUnit = Object.entries(PeriodUnitToNumber).reduce( + (acc, [key, value]: [PeriodUnits, PeriodUnitNumbers]) => { + acc[value] = key; + return acc; + }, + {} as Record, +); +export function isValidPeriodNumber(value: number) { + return isValid(value, NumberToPeriodUnit); +} + +// Types +export const TypeToNumber = { + time_saved_min: 0, + runtime_ms: 1, + success: 2, + failure: 3, +} as const; +export type TypeUnits = keyof typeof TypeToNumber; +export type TypeUnitNumbers = (typeof TypeToNumber)[TypeUnits]; +export const NumberToType = Object.entries(TypeToNumber).reduce( + (acc, [key, value]: [TypeUnits, TypeUnitNumbers]) => { + acc[value] = key; + return acc; + }, + {} as Record, +); + +export function isValidTypeNumber(value: number) { + return isValid(value, NumberToType); +} diff --git a/packages/cli/src/modules/insights/insights.module.ts b/packages/cli/src/modules/insights/insights.module.ts new file mode 100644 index 00000000000..0c7920cf3d4 --- /dev/null +++ b/packages/cli/src/modules/insights/insights.module.ts @@ -0,0 +1,29 @@ +import type { ExecutionLifecycleHooks } from 'n8n-core'; +import { InstanceSettings, Logger } from 'n8n-core'; + +import type { BaseN8nModule } from '@/decorators/module'; +import { N8nModule } from '@/decorators/module'; + +import { InsightsService } from './insights.service'; + +@N8nModule() +export class InsightsModule implements BaseN8nModule { + constructor( + private readonly logger: Logger, + private readonly insightsService: InsightsService, + private readonly instanceSettings: InstanceSettings, + ) { + this.logger = this.logger.scoped('insights'); + } + + registerLifecycleHooks(hooks: ExecutionLifecycleHooks) { + const insightsService = this.insightsService; + + // Workers should not be saving any insights + if (this.instanceSettings.instanceType !== 'worker') { + hooks.addHandler('workflowExecuteAfter', async function (fullRunData) { + await insightsService.workflowExecuteAfterHandler(this, fullRunData); + }); + } + } +} diff --git a/packages/cli/src/modules/insights/insights.service.ts b/packages/cli/src/modules/insights/insights.service.ts new file mode 100644 index 00000000000..3ccd92b9860 --- /dev/null +++ b/packages/cli/src/modules/insights/insights.service.ts @@ -0,0 +1,110 @@ +import { Service } from '@n8n/di'; +import type { ExecutionLifecycleHooks } from 'n8n-core'; +import { UnexpectedError } from 'n8n-workflow'; +import type { ExecutionStatus, IRun, WorkflowExecuteMode } from 'n8n-workflow'; + +import { SharedWorkflow } from '@/databases/entities/shared-workflow'; +import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; +import { InsightsMetadata } from '@/modules/insights/entities/insights-metadata'; +import { InsightsRaw } from '@/modules/insights/entities/insights-raw'; + +const shouldSkipStatus: Record = { + success: false, + crashed: false, + error: false, + + canceled: true, + new: true, + running: true, + unknown: true, + waiting: true, +}; + +const shouldSkipMode: Record = { + cli: false, + error: false, + integrated: false, + retry: false, + trigger: false, + webhook: false, + evaluation: false, + + internal: true, + manual: true, +}; + +@Service() +export class InsightsService { + constructor(private readonly sharedWorkflowRepository: SharedWorkflowRepository) {} + + async workflowExecuteAfterHandler(ctx: ExecutionLifecycleHooks, fullRunData: IRun) { + if (shouldSkipStatus[fullRunData.status] || shouldSkipMode[fullRunData.mode]) { + return; + } + + const status = fullRunData.status === 'success' ? 'success' : 'failure'; + + await this.sharedWorkflowRepository.manager.transaction(async (trx) => { + const sharedWorkflow = await trx.findOne(SharedWorkflow, { + where: { workflowId: ctx.workflowData.id, role: 'workflow:owner' }, + relations: { project: true }, + }); + + if (!sharedWorkflow) { + throw new UnexpectedError( + `Could not find an owner for the workflow with the name '${ctx.workflowData.name}' and the id '${ctx.workflowData.id}'`, + ); + } + + await trx.upsert( + InsightsMetadata, + { + workflowId: ctx.workflowData.id, + workflowName: ctx.workflowData.name, + projectId: sharedWorkflow.projectId, + projectName: sharedWorkflow.project.name, + }, + ['workflowId'], + ); + const metadata = await trx.findOneBy(InsightsMetadata, { + workflowId: ctx.workflowData.id, + }); + + if (!metadata) { + // This can't happen, we just wrote the metadata in the same + // transaction. + throw new UnexpectedError( + `Could not find metadata for the workflow with the id '${ctx.workflowData.id}'`, + ); + } + + // success or failure event + { + const event = new InsightsRaw(); + event.metaId = metadata.metaId; + event.type = status; + event.value = 1; + await trx.insert(InsightsRaw, event); + } + + // run time event + if (fullRunData.stoppedAt) { + const value = fullRunData.stoppedAt.getTime() - fullRunData.startedAt.getTime(); + const event = new InsightsRaw(); + event.metaId = metadata.metaId; + event.type = 'runtime_ms'; + event.value = value; + await trx.insert(InsightsRaw, event); + } + + // time saved event + if (status === 'success' && ctx.workflowData.settings?.timeSavedPerExecution) { + const event = new InsightsRaw(); + event.metaId = metadata.metaId; + event.type = 'time_saved_min'; + event.value = ctx.workflowData.settings.timeSavedPerExecution; + await trx.insert(InsightsRaw, event); + } + }); + } +} diff --git a/packages/cli/src/modules/insights/repositories/insights-by-period.repository.ts b/packages/cli/src/modules/insights/repositories/insights-by-period.repository.ts new file mode 100644 index 00000000000..94bc0572711 --- /dev/null +++ b/packages/cli/src/modules/insights/repositories/insights-by-period.repository.ts @@ -0,0 +1,11 @@ +import { Service } from '@n8n/di'; +import { DataSource, Repository } from '@n8n/typeorm'; + +import { InsightsByPeriod } from '../entities/insights-by-period'; + +@Service() +export class InsightsByPeriodRepository extends Repository { + constructor(dataSource: DataSource) { + super(InsightsByPeriod, dataSource.manager); + } +} diff --git a/packages/cli/src/modules/insights/repositories/insights-metadata.repository.ts b/packages/cli/src/modules/insights/repositories/insights-metadata.repository.ts new file mode 100644 index 00000000000..f21cd5ddc9b --- /dev/null +++ b/packages/cli/src/modules/insights/repositories/insights-metadata.repository.ts @@ -0,0 +1,11 @@ +import { Service } from '@n8n/di'; +import { DataSource, Repository } from '@n8n/typeorm'; + +import { InsightsMetadata } from '../entities/insights-metadata'; + +@Service() +export class InsightsMetadataRepository extends Repository { + constructor(dataSource: DataSource) { + super(InsightsMetadata, dataSource.manager); + } +} diff --git a/packages/cli/src/modules/insights/repositories/insights-raw.repository.ts b/packages/cli/src/modules/insights/repositories/insights-raw.repository.ts new file mode 100644 index 00000000000..9bad708eed8 --- /dev/null +++ b/packages/cli/src/modules/insights/repositories/insights-raw.repository.ts @@ -0,0 +1,11 @@ +import { Service } from '@n8n/di'; +import { DataSource, Repository } from '@n8n/typeorm'; + +import { InsightsRaw } from '../entities/insights-raw'; + +@Service() +export class InsightsRawRepository extends Repository { + constructor(dataSource: DataSource) { + super(InsightsRaw, dataSource.manager); + } +} diff --git a/packages/cli/src/modules/modules.config.ts b/packages/cli/src/modules/modules.config.ts new file mode 100644 index 00000000000..3550d2f5aee --- /dev/null +++ b/packages/cli/src/modules/modules.config.ts @@ -0,0 +1,24 @@ +import { CommaSeperatedStringArray, Config, Env } from '@n8n/config'; +import { UnexpectedError } from 'n8n-workflow'; + +const moduleNames = ['insights'] as const; +type ModuleName = (typeof moduleNames)[number]; + +class Modules extends CommaSeperatedStringArray { + constructor(str: string) { + super(str); + + for (const moduleName of this) { + if (!moduleNames.includes(moduleName)) { + throw new UnexpectedError(`Unknown module name ${moduleName}`, { level: 'fatal' }); + } + } + } +} + +@Config +export class ModulesConfig { + /** Comma-separated list of all enabled modules */ + @Env('N8N_ENABLED_MODULES') + modules: Modules = []; +} diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 7ceeb2a7581..7ec390039d9 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -362,8 +362,7 @@ export class FrontendService { this.settings.enterprise.projects.team.limit = this.license.getTeamProjectLimit(); - this.settings.folders.enabled = - config.get('folders.enabled') || this.license.isFoldersEnabled(); + this.settings.folders.enabled = this.license.isFoldersEnabled(); return this.settings; } diff --git a/packages/cli/src/utils/sql.ts b/packages/cli/src/utils/sql.ts new file mode 100644 index 00000000000..ce25b661e89 --- /dev/null +++ b/packages/cli/src/utils/sql.ts @@ -0,0 +1,17 @@ +/** + * Provides syntax highlighting for embedded SQL queries in template strings. + */ +export function sql(strings: TemplateStringsArray, ...values: string[]): string { + let result = ''; + + // Interleave the strings with the values + for (let i = 0; i < values.length; i++) { + result += strings[i]; + result += values[i]; + } + + // Add the last string + result += strings[strings.length - 1]; + + return result; +} diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index bf71261278e..f2a497279bd 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -9,7 +9,7 @@ import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPar import omit from 'lodash/omit'; import pick from 'lodash/pick'; import { BinaryDataService, Logger } from 'n8n-core'; -import { NodeApiError } from 'n8n-workflow'; +import { NodeApiError, PROJECT_ROOT } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; @@ -281,8 +281,10 @@ export class WorkflowService { if (parentFolderId) { const project = await this.sharedWorkflowRepository.getWorkflowOwningProject(workflow.id); - await this.folderService.findFolderInProjectOrFail(parentFolderId, project?.id ?? ''); - updatePayload.parentFolder = { id: parentFolderId }; + if (parentFolderId !== PROJECT_ROOT) { + await this.folderService.findFolderInProjectOrFail(parentFolderId, project?.id ?? ''); + } + updatePayload.parentFolder = parentFolderId === PROJECT_ROOT ? null : { id: parentFolderId }; } await this.workflowRepository.update(workflowId, updatePayload); diff --git a/packages/cli/test/integration/environments/source-control-import.service.test.ts b/packages/cli/test/integration/environments/source-control-import.service.test.ts index 5bfd5d0e792..34ab5a855b8 100644 --- a/packages/cli/test/integration/environments/source-control-import.service.test.ts +++ b/packages/cli/test/integration/environments/source-control-import.service.test.ts @@ -8,6 +8,7 @@ import { nanoid } from 'nanoid'; import fsp from 'node:fs/promises'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; +import { FolderRepository } from '@/databases/repositories/folder.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository'; import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; import { UserRepository } from '@/databases/repositories/user.repository'; @@ -26,7 +27,9 @@ describe('SourceControlImportService', () => { let projectRepository: ProjectRepository; let sharedCredentialsRepository: SharedCredentialsRepository; let userRepository: UserRepository; + let folderRepository: FolderRepository; let service: SourceControlImportService; + const cipher = mockInstance(Cipher); beforeAll(async () => { @@ -36,6 +39,7 @@ describe('SourceControlImportService', () => { projectRepository = Container.get(ProjectRepository); sharedCredentialsRepository = Container.get(SharedCredentialsRepository); userRepository = Container.get(UserRepository); + folderRepository = Container.get(FolderRepository); service = new SourceControlImportService( mock(), mock(), @@ -53,6 +57,7 @@ describe('SourceControlImportService', () => { mock(), mock(), mock(), + folderRepository, mock({ n8nFolder: '/some-path' }), ); }); diff --git a/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts b/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts index 05a6b2c31fe..caa6ac90368 100644 --- a/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts +++ b/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts @@ -1,7 +1,7 @@ import { GlobalConfig } from '@n8n/config'; import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; @@ -160,7 +160,7 @@ test('should not report webhooks validated by direct children', async () => { [ { node: 'My Node', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], diff --git a/packages/cli/test/integration/shared/db/workflows.ts b/packages/cli/test/integration/shared/db/workflows.ts index 87d7d21242f..b73e0a45c7f 100644 --- a/packages/cli/test/integration/shared/db/workflows.ts +++ b/packages/cli/test/integration/shared/db/workflows.ts @@ -1,7 +1,7 @@ import { Container } from '@n8n/di'; import type { DeepPartial } from '@n8n/typeorm'; import type { IWorkflowBase } from 'n8n-workflow'; -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; import { Project } from '@/databases/entities/project'; @@ -24,7 +24,7 @@ export async function createManyWorkflows( } export function newWorkflow(attributes: Partial = {}): IWorkflowDb { - const { active, name, nodes, connections, versionId } = attributes; + const { active, name, nodes, connections, versionId, settings } = attributes; const workflowEntity = Container.get(WorkflowRepository).create({ active: active ?? false, @@ -41,7 +41,7 @@ export function newWorkflow(attributes: Partial = {}): IWorkflowDb ], connections: connections ?? {}, versionId: versionId ?? uuid(), - settings: {}, + settings: settings ?? {}, ...attributes, }); @@ -119,8 +119,9 @@ export async function shareWorkflowWithProjects( } export async function getWorkflowSharing(workflow: IWorkflowBase) { - return await Container.get(SharedWorkflowRepository).findBy({ - workflowId: workflow.id, + return await Container.get(SharedWorkflowRepository).find({ + where: { workflowId: workflow.id }, + relations: { project: true }, }); } @@ -160,7 +161,9 @@ export async function createWorkflowWithTrigger( position: [780, 300], }, ], - connections: { Cron: { main: [[{ node: 'Set', type: NodeConnectionType.Main, index: 0 }]] } }, + connections: { + Cron: { main: [[{ node: 'Set', type: NodeConnectionTypes.Main, index: 0 }]] }, + }, ...attributes, }, user, diff --git a/packages/cli/test/integration/shared/test-db.ts b/packages/cli/test/integration/shared/test-db.ts index 495f64a15a0..8e50f384d76 100644 --- a/packages/cli/test/integration/shared/test-db.ts +++ b/packages/cli/test/integration/shared/test-db.ts @@ -86,6 +86,9 @@ const repositories = [ 'WorkflowTagMapping', 'ApiKey', 'Folder', + 'InsightsRaw', + 'InsightsMetadata', + 'InsightsByPeriod', ] as const; /** diff --git a/packages/cli/test/integration/task-runners/js-task-runner-execution.integration.test.ts b/packages/cli/test/integration/task-runners/js-task-runner-execution.integration.test.ts index a69f60d1986..5633df73407 100644 --- a/packages/cli/test/integration/task-runners/js-task-runner-execution.integration.test.ts +++ b/packages/cli/test/integration/task-runners/js-task-runner-execution.integration.test.ts @@ -12,7 +12,7 @@ import type { IWorkflowExecuteAdditionalData, WorkflowExecuteMode, } from 'n8n-workflow'; -import { createEnvProviderState, NodeConnectionType, Workflow } from 'n8n-workflow'; +import { createEnvProviderState, NodeConnectionTypes, Workflow } from 'n8n-workflow'; import { LocalTaskRequester } from '@/task-runners/task-managers/local-task-requester'; import { TaskRunnerModule } from '@/task-runners/task-runner-module'; @@ -78,7 +78,7 @@ describe('JS TaskRunner execution on internal mode', () => { [ { node: 'Code', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], diff --git a/packages/cli/test/integration/webhooks.api.test.ts b/packages/cli/test/integration/webhooks.api.test.ts index 8d1e7168f03..fa53ff239eb 100644 --- a/packages/cli/test/integration/webhooks.api.test.ts +++ b/packages/cli/test/integration/webhooks.api.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'fs'; import type { IWorkflowBase } from 'n8n-workflow'; import { - NodeConnectionType, + NodeConnectionTypes, type INodeType, type INodeTypeDescription, type IWebhookFunctions, @@ -189,7 +189,7 @@ describe('Webhook API', () => { description: '', defaults: {}, inputs: [], - outputs: [NodeConnectionType.Main], + outputs: [NodeConnectionTypes.Main], webhooks: [ { name: 'default', diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index f26c4c728de..043b958d4ab 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -1,7 +1,7 @@ import { Container } from '@n8n/di'; import type { Scope } from '@n8n/permissions'; import { DateTime } from 'luxon'; -import type { INode, IPinData, IWorkflowBase } from 'n8n-workflow'; +import { PROJECT_ROOT, type INode, type IPinData, type IWorkflowBase } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; @@ -2045,7 +2045,7 @@ describe('PATCH /workflows/:workflowId', () => { expect(updatedWorkflow.meta).toEqual(payload.meta); }); - test('should update workflow parent folder', async () => { + test('should move workflow to folder', async () => { const ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id); const folder1 = await createFolder(ownerPersonalProject, { name: 'folder1' }); @@ -2067,6 +2067,25 @@ describe('PATCH /workflows/:workflowId', () => { expect(updatedWorkflow.parentFolder?.id).toBe(folder1.id); }); + test('should move workflow to project root', async () => { + const workflow = await createWorkflow({}, owner); + const payload = { + versionId: workflow.versionId, + parentFolderId: PROJECT_ROOT, + }; + + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); + + expect(response.statusCode).toBe(200); + + const updatedWorkflow = await Container.get(WorkflowRepository).findOneOrFail({ + where: { id: workflow.id }, + relations: ['parentFolder'], + }); + + expect(updatedWorkflow.parentFolder).toBe(null); + }); + test('should fail if trying update workflow parent folder with a folder that does not belong to project', async () => { const ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id); const memberPersonalProject = await projectRepository.getPersonalProjectForUserOrFail( diff --git a/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts b/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts index 9a405205027..89b80d333f0 100644 --- a/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts +++ b/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts @@ -37,7 +37,7 @@ import type { import { ApplicationError, createDeferredPromise, - NodeConnectionType, + NodeConnectionTypes, NodeHelpers, Workflow, } from 'n8n-workflow'; @@ -805,10 +805,10 @@ describe('WorkflowExecute', () => { displayName: 'test', defaultVersion: 1, properties: [], - inputs: [{ type: NodeConnectionType.Main }], + inputs: [{ type: NodeConnectionTypes.Main }], outputs: [ - { type: NodeConnectionType.Main }, - { type: NodeConnectionType.Main, category: 'error' }, + { type: NodeConnectionTypes.Main }, + { type: NodeConnectionTypes.Main, category: 'error' }, ], }, }); @@ -836,7 +836,7 @@ describe('WorkflowExecute', () => { ], }, source: { - [NodeConnectionType.Main]: [ + [NodeConnectionTypes.Main]: [ { previousNode: 'previousNode', previousNodeOutput: 0, @@ -1138,8 +1138,8 @@ describe('WorkflowExecute', () => { }; const inputConnections: IConnection[] = [ - { node: 'node1', type: NodeConnectionType.Main, index: 0 }, - { node: 'node1', type: NodeConnectionType.Main, index: 1 }, + { node: 'node1', type: NodeConnectionTypes.Main, index: 0 }, + { node: 'node1', type: NodeConnectionTypes.Main, index: 1 }, ]; const result = workflowExecute.incomingConnectionIsEmpty(runData, inputConnections, 0); @@ -1149,7 +1149,7 @@ describe('WorkflowExecute', () => { test('should return true when input connection node does not exist in runData', () => { const runData: IRunData = {}; const inputConnections: IConnection[] = [ - { node: 'nonexistentNode', type: NodeConnectionType.Main, index: 0 }, + { node: 'nonexistentNode', type: NodeConnectionTypes.Main, index: 0 }, ]; const result = workflowExecute.incomingConnectionIsEmpty(runData, inputConnections, 0); @@ -1171,8 +1171,8 @@ describe('WorkflowExecute', () => { }; const inputConnections: IConnection[] = [ - { node: 'node1', type: NodeConnectionType.Main, index: 0 }, - { node: 'node1', type: NodeConnectionType.Main, index: 1 }, + { node: 'node1', type: NodeConnectionTypes.Main, index: 0 }, + { node: 'node1', type: NodeConnectionTypes.Main, index: 1 }, ]; const result = workflowExecute.incomingConnectionIsEmpty(runData, inputConnections, 0); @@ -1202,7 +1202,7 @@ describe('WorkflowExecute', () => { }; const inputConnections: IConnection[] = [ - { node: 'node1', type: NodeConnectionType.Main, index: 0 }, + { node: 'node1', type: NodeConnectionTypes.Main, index: 0 }, ]; expect(workflowExecute.incomingConnectionIsEmpty(runData, inputConnections, 0)).toBe(true); @@ -1221,7 +1221,7 @@ describe('WorkflowExecute', () => { }; const inputConnections: IConnection[] = [ - { node: 'node1', type: NodeConnectionType.Main, index: 0 }, + { node: 'node1', type: NodeConnectionTypes.Main, index: 0 }, ]; const result = workflowExecute.incomingConnectionIsEmpty(runData, inputConnections, 0); @@ -1600,7 +1600,7 @@ describe('WorkflowExecute', () => { workflow.connectionsByDestinationNode = { [node.name]: { - main: [[{ node: parentNode.name, type: NodeConnectionType.Main, index: 0 }]], + main: [[{ node: parentNode.name, type: NodeConnectionTypes.Main, index: 0 }]], }, }; @@ -1618,7 +1618,7 @@ describe('WorkflowExecute', () => { workflow.connectionsByDestinationNode = { [node.name]: { - main: [[{ node: parentNode.name, type: NodeConnectionType.Main, index: 0 }]], + main: [[{ node: parentNode.name, type: NodeConnectionTypes.Main, index: 0 }]], }, }; @@ -1637,7 +1637,7 @@ describe('WorkflowExecute', () => { workflow.connectionsByDestinationNode = { [node.name]: { - main: [[{ node: parentNode.name, type: NodeConnectionType.Main, index: 0 }]], + main: [[{ node: parentNode.name, type: NodeConnectionTypes.Main, index: 0 }]], }, }; diff --git a/packages/core/src/execution-engine/node-execution-context/__tests__/execute-context.test.ts b/packages/core/src/execution-engine/node-execution-context/__tests__/execute-context.test.ts index a888a5a7ffc..3f71e2fa6f9 100644 --- a/packages/core/src/execution-engine/node-execution-context/__tests__/execute-context.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/__tests__/execute-context.test.ts @@ -14,7 +14,7 @@ import type { INodeTypes, ICredentialDataDecryptedObject, } from 'n8n-workflow'; -import { ApplicationError, ExpressionError, NodeConnectionType } from 'n8n-workflow'; +import { ApplicationError, ExpressionError, NodeConnectionTypes } from 'n8n-workflow'; import { describeCommonTests } from './shared-tests'; import { ExecuteContext } from '../execute-context'; @@ -92,7 +92,7 @@ describe('ExecuteContext', () => { describe('getInputData', () => { const inputIndex = 0; - const connectionType = NodeConnectionType.Main; + const connectionType = NodeConnectionTypes.Main; afterEach(() => { inputData[connectionType] = [[{ json: { test: 'data' } }]]; @@ -105,10 +105,8 @@ describe('ExecuteContext', () => { }); it('should return an empty array if the input name does not exist', () => { - const connectionType = 'nonExistent'; - expect(executeContext.getInputData(inputIndex, connectionType as NodeConnectionType)).toEqual( - [], - ); + const connectionType = 'nonExistent' as typeof NodeConnectionTypes.Main; + expect(executeContext.getInputData(inputIndex, connectionType)).toEqual([]); }); it('should throw an error if the input index is out of range', () => { diff --git a/packages/core/src/execution-engine/node-execution-context/__tests__/execute-single-context.test.ts b/packages/core/src/execution-engine/node-execution-context/__tests__/execute-single-context.test.ts index 6c1b9f10895..daa83adb12e 100644 --- a/packages/core/src/execution-engine/node-execution-context/__tests__/execute-single-context.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/__tests__/execute-single-context.test.ts @@ -14,7 +14,7 @@ import type { INodeTypes, ICredentialDataDecryptedObject, } from 'n8n-workflow'; -import { ApplicationError, NodeConnectionType } from 'n8n-workflow'; +import { ApplicationError, NodeConnectionTypes } from 'n8n-workflow'; import { describeCommonTests } from './shared-tests'; import { ExecuteSingleContext } from '../execute-single-context'; @@ -91,7 +91,7 @@ describe('ExecuteSingleContext', () => { describe('getInputData', () => { const inputIndex = 0; - const connectionType = NodeConnectionType.Main; + const connectionType = NodeConnectionTypes.Main; afterEach(() => { inputData[connectionType] = [[{ json: { test: 'data' } }]]; @@ -104,12 +104,10 @@ describe('ExecuteSingleContext', () => { }); it('should return an empty object if the input name does not exist', () => { - const connectionType = 'nonExistent'; + const connectionType = 'nonExistent' as typeof NodeConnectionTypes.Main; const expectedData = { json: {} }; - expect( - executeSingleContext.getInputData(inputIndex, connectionType as NodeConnectionType), - ).toEqual(expectedData); + expect(executeSingleContext.getInputData(inputIndex, connectionType)).toEqual(expectedData); }); it('should throw an error if the input index is out of range', () => { diff --git a/packages/core/src/execution-engine/node-execution-context/__tests__/node-execution-context.test.ts b/packages/core/src/execution-engine/node-execution-context/__tests__/node-execution-context.test.ts index 6a10c2bba5b..7fe0f3093f3 100644 --- a/packages/core/src/execution-engine/node-execution-context/__tests__/node-execution-context.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/__tests__/node-execution-context.test.ts @@ -10,7 +10,7 @@ import type { Workflow, WorkflowExecuteMode, } from 'n8n-workflow'; -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import { InstanceSettings } from '@/instance-settings'; @@ -178,27 +178,27 @@ describe('NodeExecutionContext', () => { describe('getNodeInputs', () => { it('should return static inputs array when inputs is an array', () => { - nodeType.description.inputs = [NodeConnectionType.Main, NodeConnectionType.AiLanguageModel]; + nodeType.description.inputs = [NodeConnectionTypes.Main, NodeConnectionTypes.AiLanguageModel]; const result = testContext.getNodeInputs(); expect(result).toEqual([ - { type: NodeConnectionType.Main }, - { type: NodeConnectionType.AiLanguageModel }, + { type: NodeConnectionTypes.Main }, + { type: NodeConnectionTypes.AiLanguageModel }, ]); }); it('should return input objects when inputs contains configurations', () => { nodeType.description.inputs = [ - { type: NodeConnectionType.Main }, - { type: NodeConnectionType.AiLanguageModel, required: true }, + { type: NodeConnectionTypes.Main }, + { type: NodeConnectionTypes.AiLanguageModel, required: true }, ]; const result = testContext.getNodeInputs(); expect(result).toEqual([ - { type: NodeConnectionType.Main }, - { type: NodeConnectionType.AiLanguageModel, required: true }, + { type: NodeConnectionTypes.Main }, + { type: NodeConnectionTypes.AiLanguageModel, required: true }, ]); }); @@ -206,15 +206,15 @@ describe('NodeExecutionContext', () => { const inputsExpressions = '={{ ["main", "ai_languageModel"] }}'; nodeType.description.inputs = inputsExpressions; expression.getSimpleParameterValue.mockReturnValue([ - NodeConnectionType.Main, - NodeConnectionType.AiLanguageModel, + NodeConnectionTypes.Main, + NodeConnectionTypes.AiLanguageModel, ]); const result = testContext.getNodeInputs(); expect(result).toEqual([ - { type: NodeConnectionType.Main }, - { type: NodeConnectionType.AiLanguageModel }, + { type: NodeConnectionTypes.Main }, + { type: NodeConnectionTypes.AiLanguageModel }, ]); expect(expression.getSimpleParameterValue).toHaveBeenCalledWith( node, @@ -227,27 +227,30 @@ describe('NodeExecutionContext', () => { describe('getNodeOutputs', () => { it('should return static outputs array when outputs is an array', () => { - nodeType.description.outputs = [NodeConnectionType.Main, NodeConnectionType.AiLanguageModel]; - - const result = testContext.getNodeOutputs(); - - expect(result).toEqual([ - { type: NodeConnectionType.Main }, - { type: NodeConnectionType.AiLanguageModel }, - ]); - }); - - it('should return output objects when outputs contains configurations', () => { nodeType.description.outputs = [ - { type: NodeConnectionType.Main }, - { type: NodeConnectionType.AiLanguageModel, required: true }, + NodeConnectionTypes.Main, + NodeConnectionTypes.AiLanguageModel, ]; const result = testContext.getNodeOutputs(); expect(result).toEqual([ - { type: NodeConnectionType.Main }, - { type: NodeConnectionType.AiLanguageModel, required: true }, + { type: NodeConnectionTypes.Main }, + { type: NodeConnectionTypes.AiLanguageModel }, + ]); + }); + + it('should return output objects when outputs contains configurations', () => { + nodeType.description.outputs = [ + { type: NodeConnectionTypes.Main }, + { type: NodeConnectionTypes.AiLanguageModel, required: true }, + ]; + + const result = testContext.getNodeOutputs(); + + expect(result).toEqual([ + { type: NodeConnectionTypes.Main }, + { type: NodeConnectionTypes.AiLanguageModel, required: true }, ]); }); @@ -255,15 +258,15 @@ describe('NodeExecutionContext', () => { const outputsExpressions = '={{ ["main", "ai_languageModel"] }}'; nodeType.description.outputs = outputsExpressions; expression.getSimpleParameterValue.mockReturnValue([ - NodeConnectionType.Main, - NodeConnectionType.AiLanguageModel, + NodeConnectionTypes.Main, + NodeConnectionTypes.AiLanguageModel, ]); const result = testContext.getNodeOutputs(); expect(result).toEqual([ - { type: NodeConnectionType.Main }, - { type: NodeConnectionType.AiLanguageModel }, + { type: NodeConnectionTypes.Main }, + { type: NodeConnectionTypes.AiLanguageModel }, ]); expect(expression.getSimpleParameterValue).toHaveBeenCalledWith( node, @@ -276,13 +279,13 @@ describe('NodeExecutionContext', () => { it('should add error output when node has continueOnFail error handling', () => { const nodeWithError = mock({ onError: 'continueErrorOutput' }); const contextWithError = new TestContext(workflow, nodeWithError, additionalData, mode); - nodeType.description.outputs = [NodeConnectionType.Main]; + nodeType.description.outputs = [NodeConnectionTypes.Main]; const result = contextWithError.getNodeOutputs(); expect(result).toEqual([ - { type: NodeConnectionType.Main, displayName: 'Success' }, - { type: NodeConnectionType.Main, displayName: 'Error', category: 'error' }, + { type: NodeConnectionTypes.Main, displayName: 'Success' }, + { type: NodeConnectionTypes.Main, displayName: 'Error', category: 'error' }, ]); }); }); @@ -299,10 +302,10 @@ describe('NodeExecutionContext', () => { return null; }); - const result = testContext.getConnectedNodes(NodeConnectionType.Main); + const result = testContext.getConnectedNodes(NodeConnectionTypes.Main); expect(result).toEqual([node1, node2]); - expect(workflow.getParentNodes).toHaveBeenCalledWith(node.name, NodeConnectionType.Main, 1); + expect(workflow.getParentNodes).toHaveBeenCalledWith(node.name, NodeConnectionTypes.Main, 1); }); it('should filter out disabled nodes', () => { @@ -316,7 +319,7 @@ describe('NodeExecutionContext', () => { return null; }); - const result = testContext.getConnectedNodes(NodeConnectionType.Main); + const result = testContext.getConnectedNodes(NodeConnectionTypes.Main); expect(result).toEqual([node1]); }); @@ -330,7 +333,7 @@ describe('NodeExecutionContext', () => { return null; }); - const result = testContext.getConnectedNodes(NodeConnectionType.Main); + const result = testContext.getConnectedNodes(NodeConnectionTypes.Main); expect(result).toEqual([node1]); }); diff --git a/packages/core/src/execution-engine/node-execution-context/__tests__/supply-data-context.test.ts b/packages/core/src/execution-engine/node-execution-context/__tests__/supply-data-context.test.ts index 54edbb3df9c..c2bc248c731 100644 --- a/packages/core/src/execution-engine/node-execution-context/__tests__/supply-data-context.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/__tests__/supply-data-context.test.ts @@ -13,8 +13,9 @@ import type { INodeType, INodeTypes, ICredentialDataDecryptedObject, + NodeConnectionType, } from 'n8n-workflow'; -import { ApplicationError, NodeConnectionType } from 'n8n-workflow'; +import { ApplicationError, NodeConnectionTypes } from 'n8n-workflow'; import { describeCommonTests } from './shared-tests'; import { SupplyDataContext } from '../supply-data-context'; @@ -58,7 +59,7 @@ describe('SupplyDataContext', () => { resultData: { runData: {} }, }); const connectionInputData: INodeExecutionData[] = []; - const connectionType = NodeConnectionType.Main; + const connectionType = NodeConnectionTypes.Main; const inputData: ITaskDataConnections = { [connectionType]: [[{ json: { test: 'data' } }]] }; const executeData = mock(); const runIndex = 0; diff --git a/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts b/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts index f2d0aa86535..d413fb4db24 100644 --- a/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts @@ -20,11 +20,12 @@ import type { IWorkflowDataProxyData, ISourceData, AiEvent, + NodeConnectionType, } from 'n8n-workflow'; import { ApplicationError, NodeHelpers, - NodeConnectionType, + NodeConnectionTypes, WAIT_INDEFINITELY, WorkflowDataProxy, } from 'n8n-workflow'; @@ -159,7 +160,7 @@ export class BaseExecuteContext extends NodeExecutionContext { return allItems; } - getInputSourceData(inputIndex = 0, connectionType = NodeConnectionType.Main): ISourceData { + getInputSourceData(inputIndex = 0, connectionType = NodeConnectionTypes.Main): ISourceData { if (this.executeData?.source === null) { // Should never happen as n8n sets it automatically throw new ApplicationError('Source data is missing'); diff --git a/packages/core/src/execution-engine/node-execution-context/execute-context.ts b/packages/core/src/execution-engine/node-execution-context/execute-context.ts index 47d679bc606..0d4389b64b2 100644 --- a/packages/core/src/execution-engine/node-execution-context/execute-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/execute-context.ts @@ -20,7 +20,7 @@ import { ApplicationError, createDeferredPromise, createEnvProviderState, - NodeConnectionType, + NodeConnectionTypes, } from 'n8n-workflow'; import { BaseExecuteContext } from './base-execute-context'; @@ -173,7 +173,7 @@ export class ExecuteContext extends BaseExecuteContext implements IExecuteFuncti ); } - getInputData(inputIndex = 0, connectionType = NodeConnectionType.Main) { + getInputData(inputIndex = 0, connectionType = NodeConnectionTypes.Main) { if (!this.inputData.hasOwnProperty(connectionType)) { // Return empty array because else it would throw error when nothing is connected to input return []; diff --git a/packages/core/src/execution-engine/node-execution-context/execute-single-context.ts b/packages/core/src/execution-engine/node-execution-context/execute-single-context.ts index d9d0561824f..4282f534c61 100644 --- a/packages/core/src/execution-engine/node-execution-context/execute-single-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/execute-single-context.ts @@ -11,7 +11,7 @@ import type { ITaskDataConnections, IExecuteData, } from 'n8n-workflow'; -import { ApplicationError, createDeferredPromise, NodeConnectionType } from 'n8n-workflow'; +import { ApplicationError, createDeferredPromise, NodeConnectionTypes } from 'n8n-workflow'; import { BaseExecuteContext } from './base-execute-context'; import { @@ -76,7 +76,7 @@ export class ExecuteSingleContext extends BaseExecuteContext implements IExecute return super.evaluateExpression(expression, itemIndex); } - getInputData(inputIndex = 0, connectionType = NodeConnectionType.Main) { + getInputData(inputIndex = 0, connectionType = NodeConnectionTypes.Main) { if (!this.inputData.hasOwnProperty(connectionType)) { // Return empty array because else it would throw error when nothing is connected to input return { json: {} }; diff --git a/packages/core/src/execution-engine/node-execution-context/supply-data-context.ts b/packages/core/src/execution-engine/node-execution-context/supply-data-context.ts index 8484b1128d0..69b9bf346e8 100644 --- a/packages/core/src/execution-engine/node-execution-context/supply-data-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/supply-data-context.ts @@ -15,8 +15,9 @@ import type { IWorkflowExecuteAdditionalData, Workflow, WorkflowExecuteMode, + NodeConnectionType, } from 'n8n-workflow'; -import { createDeferredPromise, NodeConnectionType } from 'n8n-workflow'; +import { createDeferredPromise, NodeConnectionTypes } from 'n8n-workflow'; import { BaseExecuteContext } from './base-execute-context'; import { @@ -126,7 +127,7 @@ export class SupplyDataContext extends BaseExecuteContext implements ISupplyData this.closeFunctions, this.abortSignal, ); - context.addInputData(NodeConnectionType.AiTool, replacements.inputData); + context.addInputData(NodeConnectionTypes.AiTool, replacements.inputData); return context; } diff --git a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/get-input-connection-data.test.ts b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/get-input-connection-data.test.ts index 4e634a196e7..7492c4614f9 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/get-input-connection-data.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/get-input-connection-data.test.ts @@ -10,10 +10,12 @@ import type { Workflow, INodeType, INodeTypes, + IExecuteFunctions, } from 'n8n-workflow'; -import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; +import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; import { ExecuteContext } from '../../execute-context'; +import { makeHandleToolInvocation } from '../get-input-connection-data'; describe('getInputConnectionData', () => { const agentNode = mock({ @@ -67,16 +69,16 @@ describe('getInputConnectionData', () => { }); describe.each([ - NodeConnectionType.AiAgent, - NodeConnectionType.AiChain, - NodeConnectionType.AiDocument, - NodeConnectionType.AiEmbedding, - NodeConnectionType.AiLanguageModel, - NodeConnectionType.AiMemory, - NodeConnectionType.AiOutputParser, - NodeConnectionType.AiRetriever, - NodeConnectionType.AiTextSplitter, - NodeConnectionType.AiVectorStore, + NodeConnectionTypes.AiAgent, + NodeConnectionTypes.AiChain, + NodeConnectionTypes.AiDocument, + NodeConnectionTypes.AiEmbedding, + NodeConnectionTypes.AiLanguageModel, + NodeConnectionTypes.AiMemory, + NodeConnectionTypes.AiOutputParser, + NodeConnectionTypes.AiRetriever, + NodeConnectionTypes.AiTextSplitter, + NodeConnectionTypes.AiVectorStore, ] as const)('%s', (connectionType) => { const response = mock(); const node = mock({ @@ -231,7 +233,7 @@ describe('getInputConnectionData', () => { }); }); - describe(NodeConnectionType.AiTool, () => { + describe(NodeConnectionTypes.AiTool, () => { const mockTool = mock(); const toolNode = mock({ name: 'Test Tool', @@ -252,7 +254,7 @@ describe('getInputConnectionData', () => { .calledWith(toolNode.type, expect.anything()) .mockReturnValue(toolNodeType); workflow.getParentNodes - .calledWith(agentNode.name, NodeConnectionType.AiTool) + .calledWith(agentNode.name, NodeConnectionTypes.AiTool) .mockReturnValue([toolNode.name]); workflow.getNode.calledWith(toolNode.name).mockReturnValue(toolNode); workflow.getNode.calledWith(secondToolNode.name).mockReturnValue(secondToolNode); @@ -261,13 +263,13 @@ describe('getInputConnectionData', () => { it('should return empty array when no tools are connected and input is not required', async () => { agentNodeType.description.inputs = [ { - type: NodeConnectionType.AiTool, + type: NodeConnectionTypes.AiTool, required: false, }, ]; workflow.getParentNodes.mockReturnValueOnce([]); - const result = await executeContext.getInputConnectionData(NodeConnectionType.AiTool, 0); + const result = await executeContext.getInputConnectionData(NodeConnectionTypes.AiTool, 0); expect(result).toEqual([]); expect(supplyData).not.toHaveBeenCalled(); }); @@ -275,14 +277,14 @@ describe('getInputConnectionData', () => { it('should throw when required tool node is not connected', async () => { agentNodeType.description.inputs = [ { - type: NodeConnectionType.AiTool, + type: NodeConnectionTypes.AiTool, required: true, }, ]; workflow.getParentNodes.mockReturnValueOnce([]); await expect( - executeContext.getInputConnectionData(NodeConnectionType.AiTool, 0), + executeContext.getInputConnectionData(NodeConnectionTypes.AiTool, 0), ).rejects.toThrow('must be connected and enabled'); expect(supplyData).not.toHaveBeenCalled(); }); @@ -296,18 +298,18 @@ describe('getInputConnectionData', () => { agentNodeType.description.inputs = [ { - type: NodeConnectionType.AiTool, + type: NodeConnectionTypes.AiTool, required: true, }, ]; workflow.getParentNodes - .calledWith(agentNode.name, NodeConnectionType.AiTool) + .calledWith(agentNode.name, NodeConnectionTypes.AiTool) .mockReturnValue([disabledToolNode.name]); workflow.getNode.calledWith(disabledToolNode.name).mockReturnValue(disabledToolNode); await expect( - executeContext.getInputConnectionData(NodeConnectionType.AiTool, 0), + executeContext.getInputConnectionData(NodeConnectionTypes.AiTool, 0), ).rejects.toThrow('must be connected and enabled'); expect(supplyData).not.toHaveBeenCalled(); }); @@ -315,7 +317,7 @@ describe('getInputConnectionData', () => { it('should handle multiple connected tools', async () => { agentNodeType.description.inputs = [ { - type: NodeConnectionType.AiTool, + type: NodeConnectionTypes.AiTool, required: true, }, ]; @@ -325,10 +327,10 @@ describe('getInputConnectionData', () => { .mockReturnValue(secondToolNodeType); workflow.getParentNodes - .calledWith(agentNode.name, NodeConnectionType.AiTool) + .calledWith(agentNode.name, NodeConnectionTypes.AiTool) .mockReturnValue([toolNode.name, secondToolNode.name]); - const result = await executeContext.getInputConnectionData(NodeConnectionType.AiTool, 0); + const result = await executeContext.getInputConnectionData(NodeConnectionTypes.AiTool, 0); expect(result).toEqual([mockTool, secondMockTool]); expect(supplyData).toHaveBeenCalled(); expect(secondToolNodeType.supplyData).toHaveBeenCalled(); @@ -339,13 +341,13 @@ describe('getInputConnectionData', () => { agentNodeType.description.inputs = [ { - type: NodeConnectionType.AiTool, + type: NodeConnectionTypes.AiTool, required: true, }, ]; await expect( - executeContext.getInputConnectionData(NodeConnectionType.AiTool, 0), + executeContext.getInputConnectionData(NodeConnectionTypes.AiTool, 0), ).rejects.toThrow(`Error in sub-node ${toolNode.name}`); expect(supplyData).toHaveBeenCalled(); }); @@ -353,14 +355,146 @@ describe('getInputConnectionData', () => { it('should return the tool when there are no issues', async () => { agentNodeType.description.inputs = [ { - type: NodeConnectionType.AiTool, + type: NodeConnectionTypes.AiTool, required: true, }, ]; - const result = await executeContext.getInputConnectionData(NodeConnectionType.AiTool, 0); + const result = await executeContext.getInputConnectionData(NodeConnectionTypes.AiTool, 0); expect(result).toEqual([mockTool]); expect(supplyData).toHaveBeenCalled(); }); }); }); + +describe('makeHandleToolInvocation', () => { + const connectedNode = mock({ + name: 'Test Tool Node', + type: 'test.tool', + }); + const execute = jest.fn(); + const connectedNodeType = mock({ + execute, + }); + const contextFactory = jest.fn(); + const toolArgs = { key: 'value' }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should return stringified results when execution is successful', async () => { + const mockContext = mock(); + contextFactory.mockReturnValue(mockContext); + + const mockResult = [[{ json: { result: 'success' } }]]; + execute.mockResolvedValueOnce(mockResult); + + const handleToolInvocation = makeHandleToolInvocation( + contextFactory, + connectedNode, + connectedNodeType, + ); + const result = await handleToolInvocation(toolArgs); + + expect(result).toBe(JSON.stringify([{ result: 'success' }])); + expect(mockContext.addOutputData).toHaveBeenCalledWith(NodeConnectionType.AiTool, 0, [ + [{ json: { response: [{ result: 'success' }] } }], + ]); + }); + + it('should handle binary data and return a warning message', async () => { + const mockContext = mock(); + contextFactory.mockReturnValue(mockContext); + + const mockResult = [[{ json: {}, binary: { file: 'data' } }]]; + execute.mockResolvedValueOnce(mockResult); + + const handleToolInvocation = makeHandleToolInvocation( + contextFactory, + connectedNode, + connectedNodeType, + ); + const result = await handleToolInvocation(toolArgs); + + expect(result).toBe( + '"Error: The Tool attempted to return binary data, which is not supported in Agents"', + ); + expect(mockContext.addOutputData).toHaveBeenCalledWith(NodeConnectionType.AiTool, 0, [ + [ + { + json: { + response: + 'Error: The Tool attempted to return binary data, which is not supported in Agents', + }, + }, + ], + ]); + }); + + it('should continue if json and binary data exist', async () => { + const mockContext = mock(); + contextFactory.mockReturnValue(mockContext); + + const mockResult = [[{ json: { a: 3 }, binary: { file: 'data' } }]]; + execute.mockResolvedValueOnce(mockResult); + + const handleToolInvocation = makeHandleToolInvocation( + contextFactory, + connectedNode, + connectedNodeType, + ); + const result = await handleToolInvocation(toolArgs); + + expect(result).toBe('[{"a":3}]'); + expect(mockContext.addOutputData).toHaveBeenCalledWith(NodeConnectionType.AiTool, 0, [ + [ + { + json: { + response: [{ a: 3 }], + }, + }, + ], + ]); + }); + + it('should handle execution errors and return an error message', async () => { + const mockContext = mock(); + contextFactory.mockReturnValue(mockContext); + + const error = new Error('Execution failed'); + execute.mockRejectedValueOnce(error); + + const handleToolInvocation = makeHandleToolInvocation( + contextFactory, + connectedNode, + connectedNodeType, + ); + const result = await handleToolInvocation(toolArgs); + + expect(result).toBe('Error during node execution: Execution failed'); + expect(mockContext.addOutputData).toHaveBeenCalledWith( + NodeConnectionType.AiTool, + 0, + expect.any(NodeOperationError), + ); + }); + + it('should increment the toolRunIndex for each invocation', async () => { + const mockContext = mock(); + contextFactory.mockReturnValue(mockContext); + + const handleToolInvocation = makeHandleToolInvocation( + contextFactory, + connectedNode, + connectedNodeType, + ); + + await handleToolInvocation(toolArgs); + await handleToolInvocation(toolArgs); + await handleToolInvocation(toolArgs); + + expect(contextFactory).toHaveBeenCalledWith(0); + expect(contextFactory).toHaveBeenCalledWith(1); + expect(contextFactory).toHaveBeenCalledWith(2); + }); +}); diff --git a/packages/core/src/execution-engine/node-execution-context/utils/get-input-connection-data.ts b/packages/core/src/execution-engine/node-execution-context/utils/get-input-connection-data.ts index d599d728410..388c76edcd9 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/get-input-connection-data.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/get-input-connection-data.ts @@ -11,9 +11,13 @@ import type { WorkflowExecuteMode, SupplyData, AINodeConnectionType, + IDataObject, + ISupplyDataFunctions, + INodeType, + INode, } from 'n8n-workflow'; import { - NodeConnectionType, + NodeConnectionTypes, NodeOperationError, ExecutionBaseError, ApplicationError, @@ -24,6 +28,54 @@ import type { ExecuteContext, WebhookContext } from '../../node-execution-contex // eslint-disable-next-line import/no-cycle import { SupplyDataContext } from '../../node-execution-context/supply-data-context'; +export function makeHandleToolInvocation( + contextFactory: (runIndex: number) => ISupplyDataFunctions, + node: INode, + nodeType: INodeType, +) { + /** + * This keeps track of how many times this specific AI tool node has been invoked. + * It is incremented on every invocation of the tool to keep the output of each invocation separate from each other. + */ + let toolRunIndex = 0; + return async (toolArgs: IDataObject) => { + const runIndex = toolRunIndex++; + const context = contextFactory(runIndex); + context.addInputData(NodeConnectionTypes.AiTool, [[{ json: toolArgs }]]); + + try { + // Execute the sub-node with the proxied context + const result = await nodeType.execute?.call(context as unknown as IExecuteFunctions); + + // Process and map the results + const mappedResults = result?.[0]?.flatMap((item) => item.json); + let response: string | typeof mappedResults = mappedResults; + + // Warn if any (unusable) binary data was returned + if (result?.some((x) => x.some((y) => y.binary))) { + if (!mappedResults || mappedResults.flatMap((x) => Object.keys(x ?? {})).length === 0) { + response = + 'Error: The Tool attempted to return binary data, which is not supported in Agents'; + } else { + console.warn( + `Response from Tool '${node.name}' included binary data, which is not supported in Agents. The binary data was omitted from the response.`, + ); + } + } + + // Add output data to the context + context.addOutputData(NodeConnectionTypes.AiTool, runIndex, [[{ json: { response } }]]); + + // Return the stringified results + return JSON.stringify(response); + } catch (error) { + const nodeError = new NodeOperationError(node, error as Error); + context.addOutputData(NodeConnectionTypes.AiTool, runIndex, nodeError); + return 'Error during node execution: ' + (nodeError.description ?? nodeError.message); + } + }; +} + export async function getInputConnectionData( this: ExecuteContext | WebhookContext | SupplyDataContext, workflow: Workflow, @@ -92,42 +144,15 @@ export async function getInputConnectionData( ); if (!connectedNodeType.supplyData) { - if (connectedNodeType.description.outputs.includes(NodeConnectionType.AiTool)) { - /** - * This keeps track of how many times this specific AI tool node has been invoked. - * It is incremented on every invocation of the tool to keep the output of each invocation separate from each other. - */ - let toolRunIndex = 0; + if (connectedNodeType.description.outputs.includes(NodeConnectionTypes.AiTool)) { const supplyData = createNodeAsTool({ node: connectedNode, nodeType: connectedNodeType, - handleToolInvocation: async (toolArgs) => { - const runIndex = toolRunIndex++; - const context = contextFactory(runIndex, {}); - context.addInputData(NodeConnectionType.AiTool, [[{ json: toolArgs }]]); - - try { - // Execute the sub-node with the proxied context - const result = await connectedNodeType.execute?.call( - context as unknown as IExecuteFunctions, - ); - - // Process and map the results - const mappedResults = result?.[0]?.flatMap((item) => item.json); - - // Add output data to the context - context.addOutputData(NodeConnectionType.AiTool, runIndex, [ - [{ json: { response: mappedResults } }], - ]); - - // Return the stringified results - return JSON.stringify(mappedResults); - } catch (error) { - const nodeError = new NodeOperationError(connectedNode, error as Error); - context.addOutputData(NodeConnectionType.AiTool, runIndex, nodeError); - return 'Error during node execution: ' + nodeError.description; - } - }, + handleToolInvocation: makeHandleToolInvocation( + (i) => contextFactory(i, {}), + connectedNode, + connectedNodeType, + ), }); nodes.push(supplyData); } else { diff --git a/packages/core/src/execution-engine/partial-execution-utils/__tests__/clean-run-data.test.ts b/packages/core/src/execution-engine/partial-execution-utils/__tests__/clean-run-data.test.ts index 1e46e400709..552ec43103f 100644 --- a/packages/core/src/execution-engine/partial-execution-utils/__tests__/clean-run-data.test.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/__tests__/clean-run-data.test.ts @@ -9,7 +9,7 @@ // XX denotes that the node is disabled // PD denotes that the node has pinned data -import { NodeConnectionType, type IRunData } from 'n8n-workflow'; +import { NodeConnectionTypes, type IRunData } from 'n8n-workflow'; import { createNodeData, toITaskData } from './helpers'; import { cleanRunData } from '../clean-run-data'; @@ -140,7 +140,7 @@ describe('cleanRunData', () => { .addNodes(node1, rootNode, subNode) .addConnections( { from: node1, to: rootNode }, - { from: subNode, to: rootNode, type: NodeConnectionType.AiLanguageModel }, + { from: subNode, to: rootNode, type: NodeConnectionTypes.AiLanguageModel }, ); const runData: IRunData = { [node1.name]: [toITaskData([{ data: { value: 1 } }])], @@ -176,7 +176,7 @@ describe('cleanRunData', () => { .addConnections( { from: node1, to: node2 }, { from: node2, to: rootNode }, - { from: subNode, to: rootNode, type: NodeConnectionType.AiLanguageModel }, + { from: subNode, to: rootNode, type: NodeConnectionTypes.AiLanguageModel }, ); const runData: IRunData = { [node1.name]: [toITaskData([{ data: { value: 1 } }])], @@ -213,8 +213,8 @@ describe('cleanRunData', () => { .addConnections( { from: node1, to: rootNode1 }, { from: rootNode1, to: rootNode2 }, - { from: subNode, to: rootNode1, type: NodeConnectionType.AiLanguageModel }, - { from: subNode, to: rootNode2, type: NodeConnectionType.AiLanguageModel }, + { from: subNode, to: rootNode1, type: NodeConnectionTypes.AiLanguageModel }, + { from: subNode, to: rootNode2, type: NodeConnectionTypes.AiLanguageModel }, ); const runData: IRunData = { [node1.name]: [toITaskData([{ data: { value: 1 } }])], diff --git a/packages/core/src/execution-engine/partial-execution-utils/__tests__/directed-graph.test.ts b/packages/core/src/execution-engine/partial-execution-utils/__tests__/directed-graph.test.ts index 7b4e0907613..d4b53f93073 100644 --- a/packages/core/src/execution-engine/partial-execution-utils/__tests__/directed-graph.test.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/__tests__/directed-graph.test.ts @@ -10,7 +10,7 @@ // PD denotes that the node has pinned data import type { INode } from 'n8n-workflow'; -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import { createNodeData, defaultWorkflowParameter } from './helpers'; import { DirectedGraph } from '../directed-graph'; @@ -327,7 +327,7 @@ describe('DirectedGraph', () => { expect(newConnections[0]).toEqual({ from: node0, outputIndex: 0, - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, inputIndex: 0, to: node2, }); @@ -372,7 +372,7 @@ describe('DirectedGraph', () => { expect(newConnections[0]).toEqual({ from: node0, outputIndex: 1, - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, inputIndex: 4, to: node2, }); diff --git a/packages/core/src/execution-engine/partial-execution-utils/__tests__/filter-disabled-nodes.test.ts b/packages/core/src/execution-engine/partial-execution-utils/__tests__/filter-disabled-nodes.test.ts index 69348720f7b..f923d94382d 100644 --- a/packages/core/src/execution-engine/partial-execution-utils/__tests__/filter-disabled-nodes.test.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/__tests__/filter-disabled-nodes.test.ts @@ -9,7 +9,7 @@ // XX denotes that the node is disabled // PD denotes that the node has pinned data -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import { createNodeData } from './helpers'; import { DirectedGraph } from '../directed-graph'; @@ -104,7 +104,7 @@ describe('filterDisabledNodes', () => { .addNodes(trigger, root, aiModel, destination) .addConnections( { from: trigger, to: root }, - { from: aiModel, type: NodeConnectionType.AiLanguageModel, to: root }, + { from: aiModel, type: NodeConnectionTypes.AiLanguageModel, to: root }, { from: root, to: destination }, ); diff --git a/packages/core/src/execution-engine/partial-execution-utils/__tests__/find-subgraph.test.ts b/packages/core/src/execution-engine/partial-execution-utils/__tests__/find-subgraph.test.ts index 429f8b7c4fa..d22d6e2f50d 100644 --- a/packages/core/src/execution-engine/partial-execution-utils/__tests__/find-subgraph.test.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/__tests__/find-subgraph.test.ts @@ -9,7 +9,7 @@ // XX denotes that the node is disabled // PD denotes that the node has pinned data -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import { createNodeData } from './helpers'; import { DirectedGraph } from '../directed-graph'; @@ -191,7 +191,7 @@ describe('findSubgraph', () => { // ┌┴──────┐ // │aiModel│ // └───────┘ - test('always retain connections that have a different type than `NodeConnectionType.Main`', () => { + test('always retain connections that have a different type than `NodeConnectionTypes.Main`', () => { // ARRANGE const trigger = createNodeData({ name: 'trigger' }); const destination = createNodeData({ name: 'destination' }); @@ -201,7 +201,7 @@ describe('findSubgraph', () => { .addNodes(trigger, destination, aiModel) .addConnections( { from: trigger, to: destination }, - { from: aiModel, type: NodeConnectionType.AiLanguageModel, to: destination }, + { from: aiModel, type: NodeConnectionTypes.AiLanguageModel, to: destination }, ); // ACT @@ -236,7 +236,7 @@ describe('findSubgraph', () => { .addNodes(trigger, root, aiModel, destination) .addConnections( { from: trigger, to: aiModel }, - { from: aiModel, type: NodeConnectionType.AiLanguageModel, to: root }, + { from: aiModel, type: NodeConnectionTypes.AiLanguageModel, to: root }, { from: root, to: destination }, ); diff --git a/packages/core/src/execution-engine/partial-execution-utils/__tests__/get-source-data-groups.test.ts b/packages/core/src/execution-engine/partial-execution-utils/__tests__/get-source-data-groups.test.ts index 872a452aa7e..d2d293d103e 100644 --- a/packages/core/src/execution-engine/partial-execution-utils/__tests__/get-source-data-groups.test.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/__tests__/get-source-data-groups.test.ts @@ -8,7 +8,7 @@ // PD denotes that the node has pinned data import type { IPinData } from 'n8n-workflow'; -import { NodeConnectionType, type IRunData } from 'n8n-workflow'; +import { NodeConnectionTypes, type IRunData } from 'n8n-workflow'; import { createNodeData, toITaskData } from './helpers'; import { DirectedGraph } from '../directed-graph'; @@ -56,14 +56,14 @@ describe('getSourceDataGroups', () => { expect(group1.connections[0]).toEqual({ from: source1, outputIndex: 0, - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, inputIndex: 0, to: node, }); expect(group1.connections[1]).toEqual({ from: source3, outputIndex: 0, - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, inputIndex: 1, to: node, }); @@ -73,7 +73,7 @@ describe('getSourceDataGroups', () => { expect(group2.connections[0]).toEqual({ from: source2, outputIndex: 0, - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, inputIndex: 0, to: node, }); @@ -120,14 +120,14 @@ describe('getSourceDataGroups', () => { expect(group1.connections[0]).toEqual({ from: source1, outputIndex: 0, - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, inputIndex: 0, to: node, }); expect(group1.connections[1]).toEqual({ from: source3, outputIndex: 0, - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, inputIndex: 1, to: node, }); @@ -137,7 +137,7 @@ describe('getSourceDataGroups', () => { expect(group2.connections[0]).toEqual({ from: source2, outputIndex: 0, - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, inputIndex: 0, to: node, }); @@ -184,14 +184,14 @@ describe('getSourceDataGroups', () => { expect(group1.connections[0]).toEqual({ from: source2, outputIndex: 0, - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, inputIndex: 0, to: node, }); expect(group1.connections[1]).toEqual({ from: source3, outputIndex: 0, - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, inputIndex: 1, to: node, }); @@ -205,7 +205,7 @@ describe('getSourceDataGroups', () => { expect(group1.connections[0]).toEqual({ from: source1, outputIndex: 0, - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, inputIndex: 0, to: node, }); @@ -261,14 +261,14 @@ describe('getSourceDataGroups', () => { expect(group1.connections[0]).toEqual({ from: source2, outputIndex: 0, - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, inputIndex: 0, to: node, }); expect(group1.connections[1]).toEqual({ from: source3, outputIndex: 0, - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, inputIndex: 1, to: node, }); @@ -282,14 +282,14 @@ describe('getSourceDataGroups', () => { expect(group1.connections[0]).toEqual({ from: source1, outputIndex: 0, - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, inputIndex: 0, to: node, }); expect(group1.connections[1]).toEqual({ from: source4, outputIndex: 0, - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, inputIndex: 1, to: node, }); @@ -341,14 +341,14 @@ describe('getSourceDataGroups', () => { expect(group1.connections[0]).toEqual({ from: source1, outputIndex: 0, - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, inputIndex: 0, to: node, }); expect(group1.connections[1]).toEqual({ from: source3, outputIndex: 0, - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, inputIndex: 1, to: node, }); @@ -358,7 +358,7 @@ describe('getSourceDataGroups', () => { expect(group2.connections[0]).toEqual({ from: source2, outputIndex: 0, - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, inputIndex: 0, to: node, }); @@ -461,7 +461,7 @@ describe('getSourceDataGroups', () => { expect(group1.connections[0]).toEqual({ from: source1, outputIndex: 0, - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, inputIndex: -1, to: node, }); @@ -490,7 +490,7 @@ describe('getSourceDataGroups', () => { expect(group1.connections[0]).toEqual({ from: source1, outputIndex: 0, - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, inputIndex: 1, to: node, }); diff --git a/packages/core/src/execution-engine/partial-execution-utils/__tests__/helpers.ts b/packages/core/src/execution-engine/partial-execution-utils/__tests__/helpers.ts index 74976bba3e8..0cc8e116db8 100644 --- a/packages/core/src/execution-engine/partial-execution-utils/__tests__/helpers.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/__tests__/helpers.ts @@ -1,5 +1,12 @@ -import { NodeConnectionType } from 'n8n-workflow'; -import type { INodeParameters, INode, ITaskData, IDataObject, IConnections } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; +import type { + INodeParameters, + INode, + ITaskData, + IDataObject, + IConnections, + NodeConnectionType, +} from 'n8n-workflow'; interface StubNode { name: string; @@ -38,7 +45,7 @@ export function toITaskData(taskData: TaskData[]): ITaskData { // NOTE: Here to make TS happy. result.data = result.data ?? {}; for (const taskDatum of taskData) { - const type = taskDatum.nodeConnectionType ?? NodeConnectionType.Main; + const type = taskDatum.nodeConnectionType ?? NodeConnectionTypes.Main; const outputIndex = taskDatum.outputIndex ?? 0; result.data[type] = result.data[type] ?? []; @@ -78,7 +85,7 @@ export function toIConnections(connections: Connection[]): IConnections { const result: IConnections = {}; for (const connection of connections) { - const type = connection.type ?? NodeConnectionType.Main; + const type = connection.type ?? NodeConnectionTypes.Main; const outputIndex = connection.outputIndex ?? 0; const inputIndex = connection.inputIndex ?? 0; diff --git a/packages/core/src/execution-engine/partial-execution-utils/__tests__/recreate-node-execution-stack.test.ts b/packages/core/src/execution-engine/partial-execution-utils/__tests__/recreate-node-execution-stack.test.ts index 0f20896e218..f3652feacbd 100644 --- a/packages/core/src/execution-engine/partial-execution-utils/__tests__/recreate-node-execution-stack.test.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/__tests__/recreate-node-execution-stack.test.ts @@ -16,7 +16,7 @@ import type { IWaitingForExecution, IWaitingForExecutionSource, } from 'n8n-workflow'; -import { NodeConnectionType, type IPinData, type IRunData } from 'n8n-workflow'; +import { NodeConnectionTypes, type IPinData, type IRunData } from 'n8n-workflow'; import { createNodeData, toITaskData } from './helpers'; import { DirectedGraph } from '../directed-graph'; @@ -516,7 +516,7 @@ describe('addWaitingExecution', () => { waitingExecution, nodeName1, 1, // runIndex - NodeConnectionType.Main, + NodeConnectionTypes.Main, 1, // inputIndex executionData, ); @@ -524,7 +524,7 @@ describe('addWaitingExecution', () => { [nodeName1]: { // runIndex 1: { - [NodeConnectionType.Main]: [undefined, executionData], + [NodeConnectionTypes.Main]: [undefined, executionData], }, }, }); @@ -536,7 +536,7 @@ describe('addWaitingExecution', () => { waitingExecution, nodeName1, 1, // runIndex - NodeConnectionType.Main, + NodeConnectionTypes.Main, 0, // inputIndex executionData, ); @@ -544,7 +544,7 @@ describe('addWaitingExecution', () => { [nodeName1]: { // runIndex 1: { - [NodeConnectionType.Main]: [executionData, executionData], + [NodeConnectionTypes.Main]: [executionData, executionData], }, }, }); @@ -556,7 +556,7 @@ describe('addWaitingExecution', () => { waitingExecution, nodeName1, 1, // runIndex - NodeConnectionType.AiMemory, + NodeConnectionTypes.AiMemory, 0, // inputIndex executionData, ); @@ -564,8 +564,8 @@ describe('addWaitingExecution', () => { [nodeName1]: { // runIndex 1: { - [NodeConnectionType.Main]: [executionData, executionData], - [NodeConnectionType.AiMemory]: [executionData], + [NodeConnectionTypes.Main]: [executionData, executionData], + [NodeConnectionTypes.AiMemory]: [executionData], }, }, }); @@ -577,7 +577,7 @@ describe('addWaitingExecution', () => { waitingExecution, nodeName1, 0, // runIndex - NodeConnectionType.AiChain, + NodeConnectionTypes.AiChain, 0, // inputIndex executionData, ); @@ -585,11 +585,11 @@ describe('addWaitingExecution', () => { [nodeName1]: { // runIndex 0: { - [NodeConnectionType.AiChain]: [executionData], + [NodeConnectionTypes.AiChain]: [executionData], }, 1: { - [NodeConnectionType.Main]: [executionData, executionData], - [NodeConnectionType.AiMemory]: [executionData], + [NodeConnectionTypes.Main]: [executionData, executionData], + [NodeConnectionTypes.AiMemory]: [executionData], }, }, }); @@ -601,7 +601,7 @@ describe('addWaitingExecution', () => { waitingExecution, nodeName2, 0, // runIndex - NodeConnectionType.Main, + NodeConnectionTypes.Main, 2, // inputIndex executionData, ); @@ -609,17 +609,17 @@ describe('addWaitingExecution', () => { [nodeName1]: { // runIndex 0: { - [NodeConnectionType.AiChain]: [executionData], + [NodeConnectionTypes.AiChain]: [executionData], }, 1: { - [NodeConnectionType.Main]: [executionData, executionData], - [NodeConnectionType.AiMemory]: [executionData], + [NodeConnectionTypes.Main]: [executionData, executionData], + [NodeConnectionTypes.AiMemory]: [executionData], }, }, [nodeName2]: { // runIndex 0: { - [NodeConnectionType.Main]: [undefined, undefined, executionData], + [NodeConnectionTypes.Main]: [undefined, undefined, executionData], }, }, }); @@ -631,7 +631,7 @@ describe('addWaitingExecution', () => { waitingExecution, nodeName2, 0, // runIndex - NodeConnectionType.Main, + NodeConnectionTypes.Main, 0, // inputIndex null, ); @@ -639,17 +639,17 @@ describe('addWaitingExecution', () => { [nodeName2]: { // runIndex 0: { - [NodeConnectionType.Main]: [null, undefined, executionData], + [NodeConnectionTypes.Main]: [null, undefined, executionData], }, }, [nodeName1]: { // runIndex 0: { - [NodeConnectionType.AiChain]: [executionData], + [NodeConnectionTypes.AiChain]: [executionData], }, 1: { - [NodeConnectionType.Main]: [executionData, executionData], - [NodeConnectionType.AiMemory]: [executionData], + [NodeConnectionTypes.Main]: [executionData, executionData], + [NodeConnectionTypes.AiMemory]: [executionData], }, }, }); @@ -674,7 +674,7 @@ describe('addWaitingExecutionSource', () => { waitingExecutionSource, nodeName1, 1, // runIndex - NodeConnectionType.Main, + NodeConnectionTypes.Main, 1, // inputIndex sourceData, ); @@ -682,7 +682,7 @@ describe('addWaitingExecutionSource', () => { [nodeName1]: { // runIndex 1: { - [NodeConnectionType.Main]: [undefined, sourceData], + [NodeConnectionTypes.Main]: [undefined, sourceData], }, }, }); @@ -694,7 +694,7 @@ describe('addWaitingExecutionSource', () => { waitingExecutionSource, nodeName1, 1, // runIndex - NodeConnectionType.Main, + NodeConnectionTypes.Main, 0, // inputIndex sourceData, ); @@ -702,7 +702,7 @@ describe('addWaitingExecutionSource', () => { [nodeName1]: { // runIndex 1: { - [NodeConnectionType.Main]: [sourceData, sourceData], + [NodeConnectionTypes.Main]: [sourceData, sourceData], }, }, }); @@ -714,7 +714,7 @@ describe('addWaitingExecutionSource', () => { waitingExecutionSource, nodeName1, 1, // runIndex - NodeConnectionType.AiMemory, + NodeConnectionTypes.AiMemory, 0, // inputIndex sourceData, ); @@ -722,8 +722,8 @@ describe('addWaitingExecutionSource', () => { [nodeName1]: { // runIndex 1: { - [NodeConnectionType.Main]: [sourceData, sourceData], - [NodeConnectionType.AiMemory]: [sourceData], + [NodeConnectionTypes.Main]: [sourceData, sourceData], + [NodeConnectionTypes.AiMemory]: [sourceData], }, }, }); @@ -735,7 +735,7 @@ describe('addWaitingExecutionSource', () => { waitingExecutionSource, nodeName1, 0, // runIndex - NodeConnectionType.AiChain, + NodeConnectionTypes.AiChain, 0, // inputIndex sourceData, ); @@ -743,11 +743,11 @@ describe('addWaitingExecutionSource', () => { [nodeName1]: { // runIndex 0: { - [NodeConnectionType.AiChain]: [sourceData], + [NodeConnectionTypes.AiChain]: [sourceData], }, 1: { - [NodeConnectionType.Main]: [sourceData, sourceData], - [NodeConnectionType.AiMemory]: [sourceData], + [NodeConnectionTypes.Main]: [sourceData, sourceData], + [NodeConnectionTypes.AiMemory]: [sourceData], }, }, }); @@ -759,7 +759,7 @@ describe('addWaitingExecutionSource', () => { waitingExecutionSource, nodeName2, 0, // runIndex - NodeConnectionType.Main, + NodeConnectionTypes.Main, 2, // inputIndex sourceData, ); @@ -767,17 +767,17 @@ describe('addWaitingExecutionSource', () => { [nodeName1]: { // runIndex 0: { - [NodeConnectionType.AiChain]: [sourceData], + [NodeConnectionTypes.AiChain]: [sourceData], }, 1: { - [NodeConnectionType.Main]: [sourceData, sourceData], - [NodeConnectionType.AiMemory]: [sourceData], + [NodeConnectionTypes.Main]: [sourceData, sourceData], + [NodeConnectionTypes.AiMemory]: [sourceData], }, }, [nodeName2]: { // runIndex 0: { - [NodeConnectionType.Main]: [undefined, undefined, sourceData], + [NodeConnectionTypes.Main]: [undefined, undefined, sourceData], }, }, }); @@ -789,7 +789,7 @@ describe('addWaitingExecutionSource', () => { waitingExecutionSource, nodeName2, 0, // runIndex - NodeConnectionType.Main, + NodeConnectionTypes.Main, 0, // inputIndex null, ); @@ -797,17 +797,17 @@ describe('addWaitingExecutionSource', () => { [nodeName1]: { // runIndex 0: { - [NodeConnectionType.AiChain]: [sourceData], + [NodeConnectionTypes.AiChain]: [sourceData], }, 1: { - [NodeConnectionType.Main]: [sourceData, sourceData], - [NodeConnectionType.AiMemory]: [sourceData], + [NodeConnectionTypes.Main]: [sourceData, sourceData], + [NodeConnectionTypes.AiMemory]: [sourceData], }, }, [nodeName2]: { // runIndex 0: { - [NodeConnectionType.Main]: [null, undefined, sourceData], + [NodeConnectionTypes.Main]: [null, undefined, sourceData], }, }, }); diff --git a/packages/core/src/execution-engine/partial-execution-utils/__tests__/to-iconnections.test.ts b/packages/core/src/execution-engine/partial-execution-utils/__tests__/to-iconnections.test.ts index e5ea0e658a3..9519df8dc6a 100644 --- a/packages/core/src/execution-engine/partial-execution-utils/__tests__/to-iconnections.test.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/__tests__/to-iconnections.test.ts @@ -1,4 +1,4 @@ -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import { createNodeData, toIConnections } from './helpers'; @@ -7,7 +7,7 @@ test('toIConnections', () => { const node2 = createNodeData({ name: 'Basic Node 2' }); expect( - toIConnections([{ from: node1, to: node2, type: NodeConnectionType.Main, outputIndex: 0 }]), + toIConnections([{ from: node1, to: node2, type: NodeConnectionTypes.Main, outputIndex: 0 }]), ).toEqual({ [node1.name]: { // output group @@ -17,7 +17,7 @@ test('toIConnections', () => { // first connection { node: node2.name, - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], diff --git a/packages/core/src/execution-engine/partial-execution-utils/__tests__/to-itask-data.test.ts b/packages/core/src/execution-engine/partial-execution-utils/__tests__/to-itask-data.test.ts index fe9c3f132ac..13796bcd235 100644 --- a/packages/core/src/execution-engine/partial-execution-utils/__tests__/to-itask-data.test.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/__tests__/to-itask-data.test.ts @@ -1,4 +1,4 @@ -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import { toITaskData } from './helpers'; @@ -25,7 +25,7 @@ test('toITaskData', function () { expect( toITaskData([ - { data: { value: 1 }, outputIndex: 1, nodeConnectionType: NodeConnectionType.AiAgent }, + { data: { value: 1 }, outputIndex: 1, nodeConnectionType: NodeConnectionTypes.AiAgent }, ]), ).toEqual({ executionStatus: 'success', @@ -33,7 +33,7 @@ test('toITaskData', function () { source: [], startTime: 0, data: { - [NodeConnectionType.AiAgent]: [null, [{ json: { value: 1 } }]], + [NodeConnectionTypes.AiAgent]: [null, [{ json: { value: 1 } }]], }, }); diff --git a/packages/core/src/execution-engine/partial-execution-utils/clean-run-data.ts b/packages/core/src/execution-engine/partial-execution-utils/clean-run-data.ts index c7d0551e949..33b9d329004 100644 --- a/packages/core/src/execution-engine/partial-execution-utils/clean-run-data.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/clean-run-data.ts @@ -1,4 +1,4 @@ -import { NodeConnectionType, type INode, type IRunData } from 'n8n-workflow'; +import { NodeConnectionTypes, type INode, type IRunData } from 'n8n-workflow'; import type { DirectedGraph } from './directed-graph'; @@ -28,7 +28,7 @@ export function cleanRunData( for (const subNodeConnection of subNodeConnections) { // Sub nodes never use the Main connection type, so this filters out // the connection that goes upstream of the node to clean. - if (subNodeConnection.type === NodeConnectionType.Main) { + if (subNodeConnection.type === NodeConnectionTypes.Main) { continue; } diff --git a/packages/core/src/execution-engine/partial-execution-utils/directed-graph.ts b/packages/core/src/execution-engine/partial-execution-utils/directed-graph.ts index de4d55a6e75..d2302343ca5 100644 --- a/packages/core/src/execution-engine/partial-execution-utils/directed-graph.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/directed-graph.ts @@ -1,6 +1,6 @@ import * as a from 'assert'; -import type { IConnections, INode, WorkflowParameters } from 'n8n-workflow'; -import { NodeConnectionType, Workflow } from 'n8n-workflow'; +import type { IConnections, INode, WorkflowParameters, NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionTypes, Workflow } from 'n8n-workflow'; export type GraphConnection = { from: INode; @@ -186,7 +186,7 @@ export class DirectedGraph { const connection: GraphConnection = { ...connectionInput, - type: connectionInput.type ?? NodeConnectionType.Main, + type: connectionInput.type ?? NodeConnectionTypes.Main, outputIndex: connectionInput.outputIndex ?? 0, inputIndex: connectionInput.inputIndex ?? 0, }; diff --git a/packages/core/src/execution-engine/partial-execution-utils/filter-disabled-nodes.ts b/packages/core/src/execution-engine/partial-execution-utils/filter-disabled-nodes.ts index af9ffb35124..50bff6619d6 100644 --- a/packages/core/src/execution-engine/partial-execution-utils/filter-disabled-nodes.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/filter-disabled-nodes.ts @@ -1,4 +1,4 @@ -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import type { DirectedGraph } from './directed-graph'; @@ -9,7 +9,7 @@ export function filterDisabledNodes(graph: DirectedGraph): DirectedGraph { if (node.disabled) { filteredGraph.removeNode(node, { reconnectConnections: true, - skipConnectionFn: (c) => c.type !== NodeConnectionType.Main, + skipConnectionFn: (c) => c.type !== NodeConnectionTypes.Main, }); } } diff --git a/packages/core/src/execution-engine/partial-execution-utils/find-start-nodes.ts b/packages/core/src/execution-engine/partial-execution-utils/find-start-nodes.ts index a4aae17cdba..7b9a5f424ff 100644 --- a/packages/core/src/execution-engine/partial-execution-utils/find-start-nodes.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/find-start-nodes.ts @@ -1,4 +1,4 @@ -import { NodeConnectionType, type INode, type IPinData, type IRunData } from 'n8n-workflow'; +import { NodeConnectionTypes, type INode, type IPinData, type IRunData } from 'n8n-workflow'; import type { DirectedGraph } from './directed-graph'; import { getIncomingData, getIncomingDataFromAnyRun } from './get-incoming-data'; @@ -82,7 +82,7 @@ function findStartNodesRecursive( current.name, // last run -1, - NodeConnectionType.Main, + NodeConnectionTypes.Main, 0, ); diff --git a/packages/core/src/execution-engine/partial-execution-utils/find-subgraph.ts b/packages/core/src/execution-engine/partial-execution-utils/find-subgraph.ts index bbf5cd1e46a..35781f237de 100644 --- a/packages/core/src/execution-engine/partial-execution-utils/find-subgraph.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/find-subgraph.ts @@ -1,4 +1,4 @@ -import { NodeConnectionType, type INode } from 'n8n-workflow'; +import { NodeConnectionTypes, type INode } from 'n8n-workflow'; import type { GraphConnection } from './directed-graph'; import { DirectedGraph } from './directed-graph'; @@ -57,7 +57,7 @@ function findSubgraphRecursive( // Skip parents that are connected via non-Main connection types. They are // only utility nodes for AI and are not part of the data or control flow // and can never lead too the trigger. - if (parentConnection.type !== NodeConnectionType.Main) { + if (parentConnection.type !== NodeConnectionTypes.Main) { continue; } @@ -107,7 +107,7 @@ export function findSubgraph(options: { const parentConnections = graph.getParentConnections(node); for (const connection of parentConnections) { - if (connection.type === NodeConnectionType.Main) { + if (connection.type === NodeConnectionTypes.Main) { continue; } diff --git a/packages/core/src/execution-engine/partial-execution-utils/recreate-node-execution-stack.ts b/packages/core/src/execution-engine/partial-execution-utils/recreate-node-execution-stack.ts index 95aced25159..40ea217e033 100644 --- a/packages/core/src/execution-engine/partial-execution-utils/recreate-node-execution-stack.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/recreate-node-execution-stack.ts @@ -1,6 +1,7 @@ import * as a from 'assert/strict'; import { - NodeConnectionType, + NodeConnectionTypes, + type NodeConnectionType, type IExecuteData, type INode, type INodeExecutionData, @@ -99,7 +100,7 @@ export function recreateNodeExecutionStack( for (const startNode of startNodes) { const incomingStartNodeConnections = graph .getDirectParentConnections(startNode) - .filter((c) => c.type === NodeConnectionType.Main); + .filter((c) => c.type === NodeConnectionTypes.Main); let incomingData: INodeExecutionData[][] = []; let incomingSourceData: ITaskDataConnectionsSource | null = null; diff --git a/packages/core/src/execution-engine/routing-node.ts b/packages/core/src/execution-engine/routing-node.ts index e62bdfd34cc..22944ff5e6d 100644 --- a/packages/core/src/execution-engine/routing-node.ts +++ b/packages/core/src/execution-engine/routing-node.ts @@ -11,7 +11,7 @@ import { NodeApiError, NodeOperationError, sleep, - NodeConnectionType, + NodeConnectionTypes, } from 'n8n-workflow'; import type { ICredentialDataDecryptedObject, @@ -64,8 +64,8 @@ export class RoutingNode { } = context; const abortSignal = context.getExecutionCancelSignal(); - const items = (inputData[NodeConnectionType.Main] ?? - inputData[NodeConnectionType.AiTool])[0] as INodeExecutionData[]; + const items = (inputData[NodeConnectionTypes.Main] ?? + inputData[NodeConnectionTypes.AiTool])[0] as INodeExecutionData[]; const returnData: INodeExecutionData[] = []; let credentialDescription: INodeCredentialDescription | undefined; diff --git a/packages/core/src/execution-engine/workflow-execute.ts b/packages/core/src/execution-engine/workflow-execute.ts index 1d947390e66..244eb37ccf5 100644 --- a/packages/core/src/execution-engine/workflow-execute.ts +++ b/packages/core/src/execution-engine/workflow-execute.ts @@ -44,7 +44,7 @@ import type { import { LoggerProxy as Logger, NodeHelpers, - NodeConnectionType, + NodeConnectionTypes, ApplicationError, sleep, ExecutionCancelledError, @@ -786,7 +786,7 @@ export class WorkflowExecute { // would mean that it has to get added to the list of nodes to process. const parentNodes = workflow.getParentNodes( inputData.node, - NodeConnectionType.Main, + NodeConnectionTypes.Main, -1, ); let nodeToAdd: string | undefined = inputData.node; @@ -889,7 +889,7 @@ export class WorkflowExecute { 'waitingExecution', connectionData.node, waitingNodeIndex!, - NodeConnectionType.Main, + NodeConnectionTypes.Main, ], null, ); @@ -2219,7 +2219,7 @@ export class WorkflowExecute { ); const outputs = NodeHelpers.getNodeOutputs(workflow, executionData.node, nodeType.description); const outputTypes = NodeHelpers.getConnectionTypes(outputs); - const mainOutputTypes = outputTypes.filter((output) => output === NodeConnectionType.Main); + const mainOutputTypes = outputTypes.filter((output) => output === NodeConnectionTypes.Main); const errorItems: INodeExecutionData[] = []; const closeFunctions: CloseFunction[] = []; @@ -2276,7 +2276,7 @@ export class WorkflowExecute { } else { const pairedItemInputIndex = pairedItemData.input || 0; - const sourceData = executionData.source[NodeConnectionType.Main][pairedItemInputIndex]; + const sourceData = executionData.source[NodeConnectionTypes.Main][pairedItemInputIndex]; const constPairedItem = dataProxy.$getPairedItem( sourceData!.previousNode, diff --git a/packages/core/test/helpers/constants.ts b/packages/core/test/helpers/constants.ts index e67caadd1ed..6a7f28b1af2 100644 --- a/packages/core/test/helpers/constants.ts +++ b/packages/core/test/helpers/constants.ts @@ -4,7 +4,7 @@ import type { INodeTypeData, WorkflowTestData, } from 'n8n-workflow'; -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import { If } from '../../../nodes-base/dist/nodes/If/If.node'; import { ManualTrigger } from '../../../nodes-base/dist/nodes/ManualTrigger/ManualTrigger.node'; @@ -56,8 +56,8 @@ export const predefinedNodesTypes: INodeTypeData = { name: 'Version Test', color: '#0000FF', }, - inputs: [NodeConnectionType.Main], - outputs: [NodeConnectionType.Main], + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], properties: [ { displayName: 'Display V1', @@ -115,8 +115,8 @@ export const predefinedNodesTypes: INodeTypeData = { name: 'Set', color: '#0000FF', }, - inputs: [NodeConnectionType.Main], - outputs: [NodeConnectionType.Main], + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], properties: [ { displayName: 'Value1', @@ -147,8 +147,8 @@ export const predefinedNodesTypes: INodeTypeData = { name: 'Set Multi', color: '#0000FF', }, - inputs: [NodeConnectionType.Main], - outputs: [NodeConnectionType.Main], + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], properties: [ { displayName: 'Values', @@ -359,14 +359,14 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge3', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], [ { node: 'Merge6', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], @@ -377,7 +377,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge5', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], @@ -388,7 +388,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'NoOp2', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -399,14 +399,14 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge3', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], [ { node: 'IF4', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -417,7 +417,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge4', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], @@ -428,14 +428,14 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge2', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], [ { node: 'Merge2', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], @@ -446,7 +446,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge7', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], @@ -457,7 +457,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge6', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -468,7 +468,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge5', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -479,7 +479,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge4', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -490,14 +490,14 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], [ { node: 'Merge1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], @@ -508,17 +508,17 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'IF1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'IF2', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'IF3', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -529,7 +529,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Set1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -710,7 +710,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'IF', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -721,19 +721,19 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Set', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'Merge1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], [ { node: 'Merge', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -744,7 +744,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge2', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], @@ -755,7 +755,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'IF1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -766,12 +766,12 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Set1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'Merge1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -782,7 +782,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge2', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -793,17 +793,17 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, { node: 'Set1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'Set', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -985,7 +985,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Set', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -1063,12 +1063,12 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Set1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'Set2', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -1079,7 +1079,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Set2', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -1251,12 +1251,12 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, { node: 'Merge2', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], @@ -1267,7 +1267,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge3', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -1278,7 +1278,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Set4', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -1289,7 +1289,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge4', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -1300,7 +1300,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge3', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], @@ -1311,7 +1311,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge2', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -1322,12 +1322,12 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'Set3', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -1338,17 +1338,17 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Set1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'Set2', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'Merge4', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], @@ -1529,7 +1529,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Set1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -1540,14 +1540,14 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Set2', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], [ { node: 'Merge1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -1558,12 +1558,12 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'IF', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'Merge1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], @@ -1574,7 +1574,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -1733,7 +1733,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Set0', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -1744,7 +1744,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], @@ -1755,7 +1755,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], @@ -1766,7 +1766,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -1777,7 +1777,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -1788,7 +1788,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'IF', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -1908,7 +1908,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Set1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -1919,14 +1919,14 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], [ { node: 'Set2', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -1937,7 +1937,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], @@ -1948,7 +1948,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'IF', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -2054,7 +2054,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Set', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -2065,7 +2065,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'IF', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -2076,19 +2076,19 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'NoOpTrue', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'Merge', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], [ { node: 'NoOpFalse', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -2099,7 +2099,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -2179,7 +2179,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'VersionTest1a', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -2190,7 +2190,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'VersionTest1b', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -2201,7 +2201,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'VersionTest2a', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -2212,7 +2212,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'VersionTest2b', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -2555,7 +2555,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Set', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -2566,7 +2566,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Wait2', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -2577,32 +2577,32 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Wait', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'Wait1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'Wait6', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'Wait7', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'Wait8', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'Wait9', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -2613,7 +2613,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Wait4', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -2624,12 +2624,12 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Wait3', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'Merge', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], @@ -2640,12 +2640,12 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Wait10', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'Wait11', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -2656,7 +2656,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Wait5', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -2667,7 +2667,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -2678,7 +2678,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Wait13', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -2689,7 +2689,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Wait12', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -2700,7 +2700,7 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Wait14', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -2711,12 +2711,12 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'IF', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'IF1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -2727,14 +2727,14 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Wait15', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], [ { node: 'Wait16', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -2745,14 +2745,14 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Wait17', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], [ { node: 'Wait18', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -2850,12 +2850,12 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Set1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'Set2', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -2866,7 +2866,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Set2', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -3044,12 +3044,12 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, { node: 'Merge2', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], @@ -3060,7 +3060,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge3', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -3071,7 +3071,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Set4', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -3082,7 +3082,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge4', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -3093,7 +3093,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge3', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], @@ -3104,7 +3104,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge2', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -3115,12 +3115,12 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'Set3', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -3131,17 +3131,17 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Set1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'Set2', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'Merge4', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], @@ -3545,7 +3545,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Set', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -3556,7 +3556,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Wait2', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -3567,32 +3567,32 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Wait', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'Wait1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'Wait6', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'Wait7', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'Wait8', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'Wait9', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -3603,7 +3603,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Wait4', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -3614,12 +3614,12 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Wait3', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'Merge', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], @@ -3630,12 +3630,12 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Wait10', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'Wait11', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -3646,7 +3646,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Wait5', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -3657,7 +3657,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -3668,7 +3668,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Wait13', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -3679,7 +3679,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Wait12', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -3690,7 +3690,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Wait14', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -3701,12 +3701,12 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'IF', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'IF1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -3717,14 +3717,14 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Wait15', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], [ { node: 'Wait16', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -3735,14 +3735,14 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Wait17', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], [ { node: 'Wait18', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -3895,7 +3895,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'IF', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -3906,19 +3906,19 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Set', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'Merge1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], [ { node: 'Merge', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -3929,7 +3929,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge2', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], @@ -3940,7 +3940,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'IF1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -3951,12 +3951,12 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Set1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'Merge1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -3967,7 +3967,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge2', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -3978,17 +3978,17 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, { node: 'Set1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'Set', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -4240,7 +4240,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Set', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -4251,7 +4251,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'IF', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -4262,14 +4262,14 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'NoOp', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], [ { node: 'NoOp1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -4280,7 +4280,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -4291,7 +4291,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], @@ -4486,14 +4486,14 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge3', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], [ { node: 'Merge6', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], @@ -4504,7 +4504,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge5', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], @@ -4515,7 +4515,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'NoOp2', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -4526,14 +4526,14 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge3', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], [ { node: 'IF4', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -4544,7 +4544,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge4', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], @@ -4555,14 +4555,14 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge2', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], [ { node: 'Merge2', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], @@ -4573,7 +4573,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge7', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], @@ -4584,7 +4584,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge6', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -4595,7 +4595,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge5', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -4606,7 +4606,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge4', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -4617,14 +4617,14 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], [ { node: 'Merge1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], @@ -4635,17 +4635,17 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'IF1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'IF2', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'IF3', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -4656,7 +4656,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Set1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -4920,12 +4920,12 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Edit Fields', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, { node: 'Edit Fields1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -4936,7 +4936,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -4947,7 +4947,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Merge', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 1, }, ], @@ -4958,7 +4958,7 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'If1', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], @@ -4969,14 +4969,14 @@ export const v1WorkflowExecuteTests: WorkflowTestData[] = [ [ { node: 'Edit Fields2', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], [ { node: 'Edit Fields3', - type: NodeConnectionType.Main, + type: NodeConnectionTypes.Main, index: 0, }, ], diff --git a/packages/frontend/@n8n/design-system/src/components/N8nActionBox/ActionBox.vue b/packages/frontend/@n8n/design-system/src/components/N8nActionBox/ActionBox.vue index 75b5fa2607c..0c9c4e07735 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nActionBox/ActionBox.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nActionBox/ActionBox.vue @@ -37,7 +37,7 @@ withDefaults(defineProps(), { {{ heading }} -
+
diff --git a/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/Breadcrumbs.vue b/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/Breadcrumbs.vue index 1c82fe2cdc5..38a7bb9c89a 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/Breadcrumbs.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/Breadcrumbs.vue @@ -19,6 +19,7 @@ type Props = { loadingSkeletonRows?: number; separator?: string; highlightLastItem?: boolean; + hiddenItemsTrigger?: 'hover' | 'click'; // Setting this to true will show the ellipsis even if there are no hidden items pathTruncated?: boolean; }; @@ -40,6 +41,7 @@ const props = withDefaults(defineProps(), { separator: '/', highlightLastItem: true, isPathTruncated: false, + hiddenItemsTrigger: 'click', }); const loadedHiddenItems = ref([]); @@ -170,7 +172,8 @@ const handleTooltipClose = () => { v-else :popper-class="$style.tooltip" :disabled="dropdownDisabled" - trigger="click" + :trigger="hiddenItemsTrigger" + placement="bottom" @before-show="handleTooltipShow" @hide="handleTooltipClose" > @@ -313,6 +316,7 @@ const handleTooltipClose = () => { .tooltip { padding: var(--spacing-xs) var(--spacing-2xs); + text-align: center; & > div { color: var(--color-text-lighter); span { @@ -352,6 +356,7 @@ const handleTooltipClose = () => { color: var(--color-text-base); font-size: var(--font-size-2xs); font-weight: var(--font-weight-bold); + line-height: var(--font-line-heigh-xsmall); } .item a:hover * { diff --git a/packages/frontend/@n8n/design-system/src/components/N8nLoading/Loading.vue b/packages/frontend/@n8n/design-system/src/components/N8nLoading/Loading.vue index 50bcc94e82c..1955054588f 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nLoading/Loading.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nLoading/Loading.vue @@ -19,6 +19,7 @@ interface LoadingProps { animated?: boolean; loading?: boolean; rows?: number; + cols?: number; shrinkLast?: boolean; variant?: (typeof VARIANT)[number]; } @@ -27,6 +28,7 @@ withDefaults(defineProps(), { animated: true, loading: true, rows: 1, + cols: 0, shrinkLast: true, variant: 'p', }); @@ -38,7 +40,10 @@ withDefaults(defineProps(), { :animated="animated" :class="['n8n-loading', `n8n-loading-${variant}`]" > -