diff --git a/.github/scripts/check-tests.mjs b/.github/scripts/check-tests.mjs deleted file mode 100644 index 1e2e38e05b064..0000000000000 --- a/.github/scripts/check-tests.mjs +++ /dev/null @@ -1,103 +0,0 @@ -import { readFile } from 'fs/promises'; -import path from 'path'; -import util from 'util'; -import { exec } from 'child_process'; -import { glob } from 'glob'; -import ts from 'typescript'; - -const execAsync = util.promisify(exec); - -const filterAsync = async (asyncPredicate, arr) => { - const filterResults = await Promise.all( - arr.map(async (item) => ({ - item, - shouldKeep: await asyncPredicate(item), - })), - ); - - return filterResults.filter(({ shouldKeep }) => shouldKeep).map(({ item }) => item); -}; - -const isAbstractClass = (node) => { - if (ts.isClassDeclaration(node)) { - return ( - node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AbstractKeyword) || false - ); - } - return false; -}; - -const isAbstractMethod = (node) => { - return ( - ts.isMethodDeclaration(node) && - Boolean(node.modifiers?.find((modifier) => modifier.kind === ts.SyntaxKind.AbstractKeyword)) - ); -}; - -// Function to check if a file has a function declaration, function expression, object method or class -const hasFunctionOrClass = async (filePath) => { - const fileContent = await readFile(filePath, 'utf-8'); - const sourceFile = ts.createSourceFile(filePath, fileContent, ts.ScriptTarget.Latest, true); - - let hasFunctionOrClass = false; - const visit = (node) => { - if ( - ts.isFunctionDeclaration(node) || - ts.isFunctionExpression(node) || - ts.isArrowFunction(node) || - (ts.isMethodDeclaration(node) && !isAbstractMethod(node)) || - (ts.isClassDeclaration(node) && !isAbstractClass(node)) - ) { - hasFunctionOrClass = true; - } - node.forEachChild(visit); - }; - - visit(sourceFile); - - return hasFunctionOrClass; -}; - -const main = async () => { - // Run a git command to get a list of all changed files in the branch (branch has to be up to date with master) - const changedFiles = await execAsync( - 'git diff --name-only --diff-filter=d origin/master..HEAD', - ).then(({ stdout }) => stdout.trim().split('\n').filter(Boolean)); - - // Get all .spec.ts and .test.ts files from the packages - const specAndTestTsFiles = await glob('packages/*/**/{test,__tests__}/**/*.{spec,test}.ts'); - const specAndTestTsFilesNames = specAndTestTsFiles.map((file) => - path.parse(file).name.replace(/\.(test|spec)/, ''), - ); - - // Filter out the .ts and .vue files from the changed files - const changedVueFiles = changedFiles.filter((file) => file.endsWith('.vue')); - // .ts files with any kind of function declaration or class and not in any of the test folders - const changedTsFilesWithFunction = await filterAsync( - async (filePath) => - filePath.endsWith('.ts') && - !(await glob('packages/*/**/{test,__tests__}/*.ts')).includes(filePath) && - (await hasFunctionOrClass(filePath)), - changedFiles, - ); - - // For each .ts or .vue file, check if there's a corresponding .test.ts or .spec.ts file in the repository - const missingTests = changedVueFiles - .concat(changedTsFilesWithFunction) - .reduce((filesList, nextFile) => { - const fileName = path.parse(nextFile).name; - - if (!specAndTestTsFilesNames.includes(fileName)) { - filesList.push(nextFile); - } - - return filesList; - }, []); - - if (missingTests.length) { - console.error(`Missing tests for:\n${missingTests.join('\n')}`); - process.exit(1); - } -}; - -main(); diff --git a/.github/scripts/package.json b/.github/scripts/package.json index 80bf0baf11043..7640790f2d59f 100644 --- a/.github/scripts/package.json +++ b/.github/scripts/package.json @@ -7,7 +7,6 @@ "p-limit": "3.1.0", "picocolors": "1.0.1", "semver": "7.5.4", - "tempfile": "5.0.0", - "typescript": "*" + "tempfile": "5.0.0" } } diff --git a/.github/workflows/check-tests.yml b/.github/workflows/check-tests.yml deleted file mode 100644 index 97f380974a172..0000000000000 --- a/.github/workflows/check-tests.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Check Test Files - -on: - pull_request: - branches: - - '**' - - '!release/*' - pull_request_target: - branches: - - master - -jobs: - check-tests: - runs-on: ubuntu-latest - continue-on-error: true - steps: - - name: Checkout code - uses: actions/checkout@v4.1.1 - with: - fetch-depth: 0 - - - name: Use Node.js - uses: actions/setup-node@v4.0.2 - with: - node-version: 20.x - - - run: npm install --prefix=.github/scripts --no-package-lock - - - name: Check for test files - run: node .github/scripts/check-tests.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index e7d455c95d251..9e5ffd4e1b12a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,96 @@ +# [1.73.0](https://github.com/n8n-io/n8n/compare/n8n@1.72.0...n8n@1.73.0) (2024-12-19) + + +### Bug Fixes + +* **core:** Ensure runners do not throw on unsupported console methods ([#12167](https://github.com/n8n-io/n8n/issues/12167)) ([57c6a61](https://github.com/n8n-io/n8n/commit/57c6a6167dd2b30f0082a416daefce994ecad33a)) +* **core:** Fix `$getWorkflowStaticData` on task runners ([#12153](https://github.com/n8n-io/n8n/issues/12153)) ([b479f14](https://github.com/n8n-io/n8n/commit/b479f14ef5012551b823bea5d2ffbddedfd50a77)) +* **core:** Fix binary data helpers (like `prepareBinaryData`) with task runner ([#12259](https://github.com/n8n-io/n8n/issues/12259)) ([0f1461f](https://github.com/n8n-io/n8n/commit/0f1461f2d5d7ec34236ed7fcec3e2f9ee7eb73c4)) +* **core:** Fix race condition in AI tool invocation with multiple items from the parent ([#12169](https://github.com/n8n-io/n8n/issues/12169)) ([dce0c58](https://github.com/n8n-io/n8n/commit/dce0c58f8605c33fc50ec8aa422f0fb5eee07637)) +* **core:** Fix serialization of circular json with task runner ([#12288](https://github.com/n8n-io/n8n/issues/12288)) ([a99d726](https://github.com/n8n-io/n8n/commit/a99d726f42d027b64f94eda0d385b597c5d5be2e)) +* **core:** Upgrade nanoid to address CVE-2024-55565 ([#12171](https://github.com/n8n-io/n8n/issues/12171)) ([8c0bd02](https://github.com/n8n-io/n8n/commit/8c0bd0200c386b122f495c453ccc97a001e4729c)) +* **editor:** Add new create first project CTA ([#12189](https://github.com/n8n-io/n8n/issues/12189)) ([878b419](https://github.com/n8n-io/n8n/commit/878b41904d76eda3ee230f850127b4d56993de24)) +* **editor:** Fix canvas ready opacity transition on new canvas ([#12264](https://github.com/n8n-io/n8n/issues/12264)) ([5d33a6b](https://github.com/n8n-io/n8n/commit/5d33a6ba8a2bccea097402fd04c0e2b00e423e76)) +* **editor:** Fix rendering of code-blocks in sticky notes ([#12227](https://github.com/n8n-io/n8n/issues/12227)) ([9b59035](https://github.com/n8n-io/n8n/commit/9b5903524b95bd21d5915908780942790cf88d27)) +* **editor:** Fix sticky color picker getting covered by nodes on new canvas ([#12263](https://github.com/n8n-io/n8n/issues/12263)) ([27bd3c8](https://github.com/n8n-io/n8n/commit/27bd3c85b3a4ddcf763a543b232069bb108130cf)) +* **editor:** Improve commit modal user facing messaging ([#12161](https://github.com/n8n-io/n8n/issues/12161)) ([ad39243](https://github.com/n8n-io/n8n/commit/ad392439826b17bd0b84f981e0958d88f09e7fe9)) +* **editor:** Prevent connection line from showing when clicking the plus button of a node ([#12265](https://github.com/n8n-io/n8n/issues/12265)) ([9180b46](https://github.com/n8n-io/n8n/commit/9180b46b52302b203eecf3bb81c3f2132527a1e6)) +* **editor:** Prevent stickies from being edited in preview mode in the new canvas ([#12222](https://github.com/n8n-io/n8n/issues/12222)) ([6706dcd](https://github.com/n8n-io/n8n/commit/6706dcdf72d54f33c1cf4956602c3a64a1578826)) +* **editor:** Reduce cases for Auto-Add of ChatTrigger for AI Agents ([#12154](https://github.com/n8n-io/n8n/issues/12154)) ([365e82d](https://github.com/n8n-io/n8n/commit/365e82d2008dff2f9c91664ee04d7a78363a8b30)) +* **editor:** Remove invalid connections after node handles change ([#12247](https://github.com/n8n-io/n8n/issues/12247)) ([6330bec](https://github.com/n8n-io/n8n/commit/6330bec4db0175b558f2747837323fdbb25b634a)) +* **editor:** Set dangerouslyUseHTMLString in composable ([#12280](https://github.com/n8n-io/n8n/issues/12280)) ([6ba91b5](https://github.com/n8n-io/n8n/commit/6ba91b5e1ed197c67146347a6f6e663ecdf3de48)) +* **editor:** Set RunData outputIndex based on incoming data ([#12182](https://github.com/n8n-io/n8n/issues/12182)) ([dc4261a](https://github.com/n8n-io/n8n/commit/dc4261ae7eca6cf277404cd514c90fad42f14ae0)) +* **editor:** Update the universal create button interaction ([#12105](https://github.com/n8n-io/n8n/issues/12105)) ([5300e0a](https://github.com/n8n-io/n8n/commit/5300e0ac45bf832b3d2957198a49a1c687f3fe1f)) +* **Elasticsearch Node:** Fix issue stopping search queries being sent ([#11464](https://github.com/n8n-io/n8n/issues/11464)) ([388a83d](https://github.com/n8n-io/n8n/commit/388a83dfbdc6ac301e4df704666df9f09fb7d0b3)) +* **Extract from File Node:** Detect file encoding ([#12081](https://github.com/n8n-io/n8n/issues/12081)) ([92af245](https://github.com/n8n-io/n8n/commit/92af245d1aab5bfad8618fda69b2405f5206875d)) +* **Github Node:** Fix fetch of file names with ? character ([#12206](https://github.com/n8n-io/n8n/issues/12206)) ([39462ab](https://github.com/n8n-io/n8n/commit/39462abe1fde7e82b5e5b8f3ceebfcadbfd7c925)) +* **Invoice Ninja Node:** Fix actions for bank transactions ([#11511](https://github.com/n8n-io/n8n/issues/11511)) ([80eea49](https://github.com/n8n-io/n8n/commit/80eea49cf0bf9db438eb85af7cd22aeb11fbfed2)) +* **Linear Node:** Fix issue with error handling ([#12191](https://github.com/n8n-io/n8n/issues/12191)) ([b8eae5f](https://github.com/n8n-io/n8n/commit/b8eae5f28a7d523195f4715cd8da77b3a884ae4c)) +* **MongoDB Node:** Fix checks on projection feature call ([#10563](https://github.com/n8n-io/n8n/issues/10563)) ([58bab46](https://github.com/n8n-io/n8n/commit/58bab461c4c5026b2ca5ea143cbcf98bf3a4ced8)) +* **Postgres Node:** Allow users to wrap strings with $$ ([#12034](https://github.com/n8n-io/n8n/issues/12034)) ([0c15e30](https://github.com/n8n-io/n8n/commit/0c15e30778cc5cb10ed368df144d6fbb2504ec70)) +* **Redis Node:** Add support for username auth ([#12274](https://github.com/n8n-io/n8n/issues/12274)) ([64c0414](https://github.com/n8n-io/n8n/commit/64c0414ef28acf0f7ec42b4b0bb21cbf2921ebe7)) + + +### Features + +* Add solarwinds ipam credentials ([#12005](https://github.com/n8n-io/n8n/issues/12005)) ([882484e](https://github.com/n8n-io/n8n/commit/882484e8ee7d1841d5d600414ca48e9915abcfa8)) +* Add SolarWinds Observability node credentials ([#11805](https://github.com/n8n-io/n8n/issues/11805)) ([e8a5db5](https://github.com/n8n-io/n8n/commit/e8a5db5beb572edbb61dd9100b70827ccc4cca58)) +* **AI Agent Node:** Update descriptions and titles for Chat Trigger options in AI Agents and Memory ([#12155](https://github.com/n8n-io/n8n/issues/12155)) ([07a6ae1](https://github.com/n8n-io/n8n/commit/07a6ae11b3291c1805553d55ba089fe8dd919fd8)) +* **API:** Exclude pinned data from workflows ([#12261](https://github.com/n8n-io/n8n/issues/12261)) ([e0dc385](https://github.com/n8n-io/n8n/commit/e0dc385f8bc8ee13fbc5bbf35e07654e52b193e9)) +* **editor:** Params pane collection improvements ([#11607](https://github.com/n8n-io/n8n/issues/11607)) ([6e44c71](https://github.com/n8n-io/n8n/commit/6e44c71c9ca82cce20eb55bb9003930bbf66a16c)) +* **editor:** Support adding nodes via drag and drop from node creator on new canvas ([#12197](https://github.com/n8n-io/n8n/issues/12197)) ([1bfd9c0](https://github.com/n8n-io/n8n/commit/1bfd9c0e913f3eefc4593f6c344db1ae1f6e4df4)) +* **Facebook Graph API Node:** Update node to support API v21.0 ([#12116](https://github.com/n8n-io/n8n/issues/12116)) ([14c33f6](https://github.com/n8n-io/n8n/commit/14c33f666fe92f7173e4f471fb478e629e775c62)) +* **Linear Trigger Node:** Add support for admin scope ([#12211](https://github.com/n8n-io/n8n/issues/12211)) ([410ea9a](https://github.com/n8n-io/n8n/commit/410ea9a2ef2e14b5e8e4493e5db66cfc2290d8f6)) +* **MailerLite Node:** Update node to support new api ([#11933](https://github.com/n8n-io/n8n/issues/11933)) ([d6b8e65](https://github.com/n8n-io/n8n/commit/d6b8e65abeb411f86538c1630dcce832ee0846a9)) +* Send and wait operation - freeText and customForm response types ([#12106](https://github.com/n8n-io/n8n/issues/12106)) ([e98c7f1](https://github.com/n8n-io/n8n/commit/e98c7f160b018243dc88490d46fb1047a4d7fcdc)) + + +### Performance Improvements + +* **editor:** SchemaView performance improvement by ≈90% 🚀 ([#12180](https://github.com/n8n-io/n8n/issues/12180)) ([6a58309](https://github.com/n8n-io/n8n/commit/6a5830959f5fb493a4119869b8298d8ed702c84a)) + + + +# [1.72.0](https://github.com/n8n-io/n8n/compare/n8n@1.71.0...n8n@1.72.0) (2024-12-11) + + +### Bug Fixes + +* Allow disabling MFA with recovery codes ([#12014](https://github.com/n8n-io/n8n/issues/12014)) ([95d56fe](https://github.com/n8n-io/n8n/commit/95d56fee8d0168b75fca6dcf41702d2f10c930a8)) +* Chat triggers don't work with the new partial execution flow ([#11952](https://github.com/n8n-io/n8n/issues/11952)) ([2b6a72f](https://github.com/n8n-io/n8n/commit/2b6a72f1289c01145edf2b88e5027d2b9b2ed624)) +* **core:** Execute nodes after loops correctly with the new partial execution flow ([#11978](https://github.com/n8n-io/n8n/issues/11978)) ([891dd7f](https://github.com/n8n-io/n8n/commit/891dd7f995c78a2355a049b7ced981a5f6b1c40c)) +* **core:** Fix support for multiple invocation of AI tools ([#12141](https://github.com/n8n-io/n8n/issues/12141)) ([c572c06](https://github.com/n8n-io/n8n/commit/c572c0648ca5b644b222157b3cabac9c05704a84)) +* **core:** Make sure task runner exits ([#12123](https://github.com/n8n-io/n8n/issues/12123)) ([c5effca](https://github.com/n8n-io/n8n/commit/c5effca7d47a713f157eea21d7892002e9ab7283)) +* **core:** Remove run data of nodes unrelated to the current partial execution ([#12099](https://github.com/n8n-io/n8n/issues/12099)) ([c4e4d37](https://github.com/n8n-io/n8n/commit/c4e4d37a8785d1a4bcd376cb1c49b82a80aa4391)) +* **core:** Return homeProject when filtering workflows by project id ([#12077](https://github.com/n8n-io/n8n/issues/12077)) ([efafeed](https://github.com/n8n-io/n8n/commit/efafeed33482100a23fa0163a53b9ce93cd6b2c3)) +* **editor:** Don't reset all Parameter Inputs when switched to read-only ([#12063](https://github.com/n8n-io/n8n/issues/12063)) ([706702d](https://github.com/n8n-io/n8n/commit/706702dff8da3c2e949e2c98dd5b34b299a1f17c)) +* **editor:** Fix canvas panning using `Control` + `Left Mouse Button` on Windows ([#12104](https://github.com/n8n-io/n8n/issues/12104)) ([43009b6](https://github.com/n8n-io/n8n/commit/43009b6aa820f24b9e6f519e7a45592aa21db03e)) +* **editor:** Fix Nodeview.v2 reinitialise based on route changes ([#12062](https://github.com/n8n-io/n8n/issues/12062)) ([b1f8663](https://github.com/n8n-io/n8n/commit/b1f866326574974eb2936e6b02771346e83e7137)) +* **editor:** Fix svg background pattern rendering on safari ([#12079](https://github.com/n8n-io/n8n/issues/12079)) ([596f221](https://github.com/n8n-io/n8n/commit/596f22103c01e14063ebb2388c4dabf4714d37c6)) +* **editor:** Fix switching from v2 to v1 ([#12050](https://github.com/n8n-io/n8n/issues/12050)) ([5c76de3](https://github.com/n8n-io/n8n/commit/5c76de324c2e25b0d8b74cdab79f04aa616d8c4f)) +* **editor:** Improvements to the commit modal ([#12031](https://github.com/n8n-io/n8n/issues/12031)) ([4fe1952](https://github.com/n8n-io/n8n/commit/4fe1952e2fb3379d95da42a7bb531851af6d0094)) +* **editor:** Load node types in demo and preview modes ([#12048](https://github.com/n8n-io/n8n/issues/12048)) ([4ac5f95](https://github.com/n8n-io/n8n/commit/4ac5f9527bbec382a65ed3f1d9c41d6948c154e3)) +* **editor:** Polyfill crypto.randomUUID ([#12052](https://github.com/n8n-io/n8n/issues/12052)) ([0537524](https://github.com/n8n-io/n8n/commit/0537524c3e45d7633415c7a9175a3857ad52cd58)) +* **editor:** Redirect Settings to the proper sub page depending on the instance type (cloud or not) ([#12053](https://github.com/n8n-io/n8n/issues/12053)) ([a16d006](https://github.com/n8n-io/n8n/commit/a16d006f893cac927d674fa447b08c1205b67c54)) +* **editor:** Render sanitized HTML content in toast messages ([#12139](https://github.com/n8n-io/n8n/issues/12139)) ([0468945](https://github.com/n8n-io/n8n/commit/0468945c99f083577c4cc71f671b4b950f6aeb86)) +* **editor:** Universal button snags ([#11974](https://github.com/n8n-io/n8n/issues/11974)) ([956b11a](https://github.com/n8n-io/n8n/commit/956b11a560528336a74be40f722fa05bf3cca94d)) +* **editor:** Update concurrency UI considering different types of instances ([#12068](https://github.com/n8n-io/n8n/issues/12068)) ([fa572bb](https://github.com/n8n-io/n8n/commit/fa572bbca4397b1cc42668530497444630ed17eb)) +* **FTP Node:** Fix issue with creating folders on rename ([#9340](https://github.com/n8n-io/n8n/issues/9340)) ([eb7d593](https://github.com/n8n-io/n8n/commit/eb7d5934ef8bc6e999d6de4c0b8025ce175df5dd)) +* **n8n Form Node:** Completion page display if EXECUTIONS_DATA_SAVE_ON_SUCCESS=none ([#11869](https://github.com/n8n-io/n8n/issues/11869)) ([f4c2523](https://github.com/n8n-io/n8n/commit/f4c252341985fe03927a2fd5d60ba846ec3dfc77)) +* **OpenAI Node:** Allow updating assistant files ([#12042](https://github.com/n8n-io/n8n/issues/12042)) ([7b20f8a](https://github.com/n8n-io/n8n/commit/7b20f8aaa8befd19dbad0af3bf1b881342c1fca5)) + + +### Features + +* **AI Transform Node:** Reduce payload size ([#11965](https://github.com/n8n-io/n8n/issues/11965)) ([d8ca8de](https://github.com/n8n-io/n8n/commit/d8ca8de13a4cbb856696873bdb56c66b12a5b027)) +* **core:** Add option to filter for empty variables ([#12112](https://github.com/n8n-io/n8n/issues/12112)) ([a63f0e8](https://github.com/n8n-io/n8n/commit/a63f0e878e21da9924451e2679939209b34b6583)) +* **core:** Cancel runner task on timeout in external mode ([#12101](https://github.com/n8n-io/n8n/issues/12101)) ([addb4fa](https://github.com/n8n-io/n8n/commit/addb4fa352c88d856e463bb2b7001173c4fd6a7d)) +* **core:** Parent workflows should wait for sub-workflows to finish ([#11985](https://github.com/n8n-io/n8n/issues/11985)) ([60b3dcc](https://github.com/n8n-io/n8n/commit/60b3dccf9317da6f3013be35a78ce21d0416ad80)) +* **editor:** Implementing the `Easy AI Workflow` experiment ([#12043](https://github.com/n8n-io/n8n/issues/12043)) ([67ed1d2](https://github.com/n8n-io/n8n/commit/67ed1d2c3c2e69d5a96daf7de2795c02f5d8f15b)) +* **Redis Node:** Add support for continue on fail / error output branch ([#11714](https://github.com/n8n-io/n8n/issues/11714)) ([ed35958](https://github.com/n8n-io/n8n/commit/ed359586c88a7662f4d94d58c5a87cf91d027ab9)) + + + # [1.71.0](https://github.com/n8n-io/n8n/compare/n8n@1.70.0...n8n@1.71.0) (2024-12-04) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3f19be15e9595..9ed101af7d57b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,6 +19,7 @@ Great that you are here and you want to contribute to n8n - [Actual n8n setup](#actual-n8n-setup) - [Start](#start) - [Development cycle](#development-cycle) + - [Community PR Guidelines](#community-pr-guidelines) - [Test suite](#test-suite) - [Unit tests](#unit-tests) - [E2E tests](#e2e-tests) @@ -191,6 +192,51 @@ automatically build your code, restart the backend and refresh the frontend ``` 1. Commit code and [create a pull request](https://docs.github.com/en/github/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork) +--- + +### Community PR Guidelines + +#### **1. Change Request/Comment** + +Please address the requested changes or provide feedback within 14 days. If there is no response or updates to the pull request during this time, it will be automatically closed. The PR can be reopened once the requested changes are applied. + +#### **2. General Requirements** + +- **Follow the Style Guide:** + - Ensure your code adheres to n8n's coding standards and conventions (e.g., formatting, naming, indentation). Use linting tools where applicable. +- **TypeScript Compliance:** + - Do not use `ts-ignore` . + - Ensure code adheres to TypeScript rules. +- **Avoid Repetitive Code:** + - Reuse existing components, parameters, and logic wherever possible instead of redefining or duplicating them. + - For nodes: Use the same parameter across multiple operations rather than defining a new parameter for each operation (if applicable). +- **Testing Requirements:** + - PRs **must include tests**: + - Unit tests + - Workflow tests for nodes (example [here](https://github.com/n8n-io/n8n/tree/master/packages/nodes-base/nodes/Switch/V3/test)) + - UI tests (if applicable) +- **Typos:** + - Use a spell-checking tool, such as [**Code Spell Checker**](https://marketplace.visualstudio.com/items?itemName=streetsidesoftware.code-spell-checker), to avoid typos. + +#### **3. PR Specific Requirements** + +- **Small PRs Only:** + - Focus on a single feature or fix per PR. +- **Naming Convention:** + - Follow [n8n's PR Title Conventions](https://github.com/n8n-io/n8n/blob/master/.github/pull_request_title_conventions.md#L36). +- **New Nodes:** + - PRs that introduce new nodes will be **auto-closed** unless they are explicitly requested by the n8n team and aligned with an agreed project scope. However, you can still explore [building your own nodes](https://docs.n8n.io/integrations/creating-nodes/) , as n8n offers the flexibility to create your own custom nodes. +- **Typo-Only PRs:** + - Typos are not sufficient justification for a PR and will be rejected. + +#### **4. Workflow Summary for Non-Compliant PRs** + +- **No Tests:** If tests are not provided, the PR will be auto-closed after **14 days**. +- **Non-Small PRs:** Large or multifaceted PRs will be returned for segmentation. +- **New Nodes/Typo PRs:** Automatically rejected if not aligned with project scope or guidelines. + +--- + ### Test suite #### Unit tests diff --git a/README.md b/README.md index d51ac596cad65..c41a5e5ac863d 100644 --- a/README.md +++ b/README.md @@ -1,104 +1,67 @@ -![n8n.io - Workflow Automation](https://user-images.githubusercontent.com/65276001/173571060-9f2f6d7b-bac0-43b6-bdb2-001da9694058.png) +![Banner image](https://user-images.githubusercontent.com/10284570/173569848-c624317f-42b1-45a6-ab09-f0ea3c247648.png) -# n8n - Workflow automation tool +# n8n - Secure Workflow Automation for Technical Teams -n8n is an extendable workflow automation tool. With a [fair-code](https://faircode.io) distribution model, n8n -will always have visible source code, be available to self-host, and allow you to add your own custom -functions, logic and apps. n8n's node-based approach makes it highly versatile, enabling you to connect -anything to everything. +n8n is a workflow automation platform that gives technical teams the flexibility of code with the speed of no-code. With 400+ integrations, native AI capabilities, and a fair-code license, n8n lets you build powerful automations while maintaining full control over your data and deployments. -![n8n.io - Screenshot](https://raw.githubusercontent.com/n8n-io/n8n/master/assets/n8n-screenshot.png) +![n8n.io - Screenshot](https://raw.githubusercontent.com/n8n-io/n8n/master/assets/n8n-screenshot-readme.png) -## Demo +## Key Capabilities -[:tv: A short video (< 5 min)](https://www.youtube.com/watch?v=1MwSoB0gnM4) that goes over key concepts of -creating workflows in n8n. +- **Code When You Need It**: Write JavaScript/Python, add npm packages, or use the visual interface +- **AI-Native Platform**: Build AI agent workflows based on LangChain with your own data and models +- **Full Control**: Self-host with our fair-code license or use our [cloud offering](https://app.n8n.cloud/login) +- **Enterprise-Ready**: Advanced permissions, SSO, and air-gapped deployments +- **Active Community**: 400+ integrations and 900+ ready-to-use [templates](https://n8n.io/workflows) -## Available integrations +## Quick Start -n8n has 200+ different nodes to automate workflows. The list can be found on: -[https://n8n.io/integrations](https://n8n.io/integrations) - -## Documentation - -The official n8n documentation can be found on our [documentation website](https://docs.n8n.io) - -Additional information and example workflows on the [n8n.io website](https://n8n.io) - -The release notes can be found [here](https://docs.n8n.io/release-notes/) and the list of breaking -changes [here](https://github.com/n8n-io/n8n/blob/master/packages/cli/BREAKING-CHANGES.md). - -## Usage - -- :books: Learn - [how to **use** it from the command line](https://docs.n8n.io/reference/cli-commands/) -- :whale: Learn - [how to run n8n in **Docker**](https://docs.n8n.io/hosting/installation/docker/) - -## Start - -You can try n8n without installing it using npx. You must have [Node.js](https://nodejs.org/en/) installed. -From the terminal, run: +Try n8n instantly with [npx](https://docs.n8n.io/hosting/installation/npm/) (requires [Node.js](https://nodejs.org/en/)): `npx n8n` -This command will download everything that is needed to start n8n. You can then access n8n and start building workflows by opening [http://localhost:5678](http://localhost:5678). - -## n8n cloud - -Sign-up for an [n8n cloud](https://www.n8n.io/cloud/) account. - -While n8n cloud and n8n are the same in terms of features, n8n cloud provides certain conveniences such as: +Or deploy with [Docker](https://docs.n8n.io/hosting/installation/docker/): -- Not having to set up and maintain your n8n instance -- Managed OAuth for authentication -- Easily upgrading to the newer n8n versions +`docker run -it --rm --name n8n -p 5678:5678 docker.n8n.io/n8n-io/n8n` -## Build with LangChain and AI in n8n (beta) +Access the editor at http://localhost:5678 -With n8n's LangChain nodes you can build AI-powered functionality within your workflows. The LangChain nodes are configurable, meaning you can choose your preferred agent, LLM, memory, and so on. Alongside the LangChain nodes, you can connect any n8n node as normal: this means you can integrate your LangChain logic with other data sources and services. +## Resources -Learn more in the [documentation](https://docs.n8n.io/langchain/). - -- [LangChain nodes package](https://www.npmjs.com/package/@n8n/n8n-nodes-langchain) -- [Chatbot package](https://www.npmjs.com/package/@n8n/chat) +- 📚 [Documentation](https://docs.n8n.io) +- 🔧 [400+ Integrations](https://n8n.io/integrations) +- 💡 [Example Workflows](https://n8n.io/workflows) +- 🤖 [AI & LangChain Guide](https://docs.n8n.io/langchain/) +- 👥 [Community Forum](https://community.n8n.io) +- 📖 [Community Tutorials](https://community.n8n.io/c/tutorials/28) ## Support -If you have problems or questions go to our forum, we will then try to help you asap: +Need help? Our community forum is the place to get support and connect with other users: +[community.n8n.io](https://community.n8n.io) -[https://community.n8n.io](https://community.n8n.io) +## License -## Jobs +n8n is [fair-code](https://faircode.io) distributed under the [Sustainable Use License](https://github.com/n8n-io/n8n/blob/master/LICENSE.md) and [n8n Enterprise License](https://github.com/n8n-io/n8n/blob/master/LICENSE_EE.md). -If you are interested in working for n8n and so shape the future of the project check out our -[job posts](https://apply.workable.com/n8n/) +- **Source Available**: Always visible source code +- **Self-Hostable**: Deploy anywhere +- **Extensible**: Add your own nodes and functionality -## What does n8n mean and how do you pronounce it? +[Enterprise licenses](mailto:license@n8n.io) available for additional features and support. -**Short answer:** It means "nodemation" and it is pronounced as n-eight-n. +Additional information about the license model can be found in the [docs](https://docs.n8n.io/reference/license/). -**Long answer:** "I get that question quite often (more often than I expected) so I decided it is probably -best to answer it here. While looking for a good name for the project with a free domain I realized very -quickly that all the good ones I could think of were already taken. So, in the end, I chose nodemation. -'node-' in the sense that it uses a Node-View and that it uses Node.js and '-mation' for 'automation' which is -what the project is supposed to help with. However, I did not like how long the name was and I could not -imagine writing something that long every time in the CLI. That is when I then ended up on 'n8n'." - **Jan -Oberhauser, Founder and CEO, n8n.io** +## Contributing -## Development setup +Found a bug 🐛 or have a feature idea ✨? Check our [Contributing Guide](https://github.com/n8n-io/n8n/blob/master/CONTRIBUTING.md) to get started. -Have you found a bug :bug: ? Or maybe you have a nice feature :sparkles: to contribute ? The -[CONTRIBUTING guide](https://github.com/n8n-io/n8n/blob/master/CONTRIBUTING.md) will help you get your -development environment ready in minutes. +## Join the Team -## License +Want to shape the future of automation? Check out our [job posts](https://n8n.io/careers) and join our team! -n8n is [fair-code](https://faircode.io) distributed under the -[**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/LICENSE.md) and the -[**n8n Enterprise License**](https://github.com/n8n-io/n8n/blob/master/LICENSE_EE.md). +## What does n8n mean? -Proprietary licenses are available for enterprise customers. [Get in touch](mailto:license@n8n.io) +**Short answer:** It means "nodemation" and is pronounced as n-eight-n. -Additional information about the license model can be found in the -[docs](https://docs.n8n.io/reference/license/). +**Long answer:** "I get that question quite often (more often than I expected) so I decided it is probably best to answer it here. While looking for a good name for the project with a free domain I realized very quickly that all the good ones I could think of were already taken. So, in the end, I chose nodemation. 'node-' in the sense that it uses a Node-View and that it uses Node.js and '-mation' for 'automation' which is what the project is supposed to help with. However, I did not like how long the name was and I could not imagine writing something that long every time in the CLI. That is when I then ended up on 'n8n'." - **Jan Oberhauser, Founder and CEO, n8n.io** diff --git a/assets/n8n-screenshot-readme.png b/assets/n8n-screenshot-readme.png new file mode 100644 index 0000000000000..d6b5faef71e55 Binary files /dev/null and b/assets/n8n-screenshot-readme.png differ diff --git a/cypress/e2e/11-inline-expression-editor.cy.ts b/cypress/e2e/11-inline-expression-editor.cy.ts index 945c62821b7a7..a762135a65fc6 100644 --- a/cypress/e2e/11-inline-expression-editor.cy.ts +++ b/cypress/e2e/11-inline-expression-editor.cy.ts @@ -129,7 +129,7 @@ describe('Inline expression editor', () => { // Run workflow ndv.actions.close(); - WorkflowPage.actions.executeNode('No Operation'); + WorkflowPage.actions.executeNode('No Operation', { anchor: 'topLeft' }); WorkflowPage.actions.openNode('Hacker News'); WorkflowPage.actions.openInlineExpressionEditor(); diff --git a/cypress/e2e/13-pinning.cy.ts b/cypress/e2e/13-pinning.cy.ts index 4f48fa45290dc..2d3351f8aad3b 100644 --- a/cypress/e2e/13-pinning.cy.ts +++ b/cypress/e2e/13-pinning.cy.ts @@ -112,7 +112,7 @@ describe('Data pinning', () => { it('Should be able to pin data from canvas (context menu or shortcut)', () => { workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger'); workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME); - workflowPage.actions.openContextMenu(EDIT_FIELDS_SET_NODE_NAME, 'overflow-button'); + workflowPage.actions.openContextMenu(EDIT_FIELDS_SET_NODE_NAME, { method: 'overflow-button' }); workflowPage.getters .contextMenuAction('toggle_pin') .parent() diff --git a/cypress/e2e/14-mapping.cy.ts b/cypress/e2e/14-mapping.cy.ts index 3bbbd0b293459..e19959453f847 100644 --- a/cypress/e2e/14-mapping.cy.ts +++ b/cypress/e2e/14-mapping.cy.ts @@ -185,7 +185,6 @@ describe('Data mapping', () => { workflowPage.actions.openNode('Set1'); ndv.actions.executePrevious(); - ndv.actions.expandSchemaViewNode(SCHEDULE_TRIGGER_NODE_NAME); const dataPill = ndv.getters .inputDataContainer() diff --git a/cypress/e2e/16-form-trigger-node.cy.ts b/cypress/e2e/16-form-trigger-node.cy.ts index 60fbd7c419586..ed901107ea60e 100644 --- a/cypress/e2e/16-form-trigger-node.cy.ts +++ b/cypress/e2e/16-form-trigger-node.cy.ts @@ -44,8 +44,7 @@ describe('n8n Form Trigger', () => { ':nth-child(3) > .border-top-dashed > .parameter-input-list-wrapper > :nth-child(1) > .parameter-item', ) .find('input[placeholder*="e.g. What is your name?"]') - .type('Test Field 3') - .blur(); + .type('Test Field 3'); cy.get( ':nth-child(3) > .border-top-dashed > .parameter-input-list-wrapper > :nth-child(2) > .parameter-item', ).click(); @@ -56,27 +55,24 @@ describe('n8n Form Trigger', () => { ':nth-child(4) > .border-top-dashed > .parameter-input-list-wrapper > :nth-child(1) > .parameter-item', ) .find('input[placeholder*="e.g. What is your name?"]') - .type('Test Field 4') - .blur(); + .type('Test Field 4'); cy.get( ':nth-child(4) > .border-top-dashed > .parameter-input-list-wrapper > :nth-child(2) > .parameter-item', ).click(); getVisibleSelect().contains('Dropdown').click(); - cy.get( - '.border-top-dashed > :nth-child(2) > :nth-child(3) > .multi-parameter > .fixed-collection-parameter > :nth-child(2) > .button', - ).click(); - cy.get( - ':nth-child(4) > :nth-child(1) > :nth-child(2) > :nth-child(3) > .multi-parameter > .fixed-collection-parameter > .fixed-collection-parameter-property > :nth-child(1) > :nth-child(1)', - ) - .find('input') - .type('Option 1') - .blur(); - cy.get( - ':nth-child(4) > :nth-child(1) > :nth-child(2) > :nth-child(3) > .multi-parameter > .fixed-collection-parameter > .fixed-collection-parameter-property > :nth-child(1) > :nth-child(2)', - ) - .find('input') - .type('Option 2') - .blur(); + cy.contains('button', 'Add Field Option').click(); + cy.contains('label', 'Field Options') + .parent() + .nextAll() + .find('[data-test-id="parameter-input-field"]') + .eq(0) + .type('Option 1'); + cy.contains('label', 'Field Options') + .parent() + .nextAll() + .find('[data-test-id="parameter-input-field"]') + .eq(1) + .type('Option 2'); //add optional submitted message cy.get('.param-options').click(); @@ -94,7 +90,6 @@ describe('n8n Form Trigger', () => { .children() .children() .first() - .clear() .type('Your test form was successfully submitted'); ndv.getters.backToCanvas().click(); diff --git a/cypress/e2e/18-user-management.cy.ts b/cypress/e2e/18-user-management.cy.ts index fe91a7293555e..d4eb5841cf591 100644 --- a/cypress/e2e/18-user-management.cy.ts +++ b/cypress/e2e/18-user-management.cy.ts @@ -148,24 +148,9 @@ describe('User Management', { disableAutoLogin: true }, () => { personalSettingsPage.actions.changeTheme('Dark'); cy.get('body').should('have.attr', 'data-theme', 'dark'); - settingsSidebar.actions.back(); - mainSidebar.getters - .logo() - .should('have.attr', 'src') - .then((src) => { - expect(src).to.include('/static/logo/channel/dev-dark.svg'); - }); - - cy.visit(personalSettingsPage.url); + personalSettingsPage.actions.changeTheme('Light'); cy.get('body').should('have.attr', 'data-theme', 'light'); - settingsSidebar.actions.back(); - mainSidebar.getters - .logo() - .should('have.attr', 'src') - .then((src) => { - expect(src).to.include('/static/logo/channel/dev.svg'); - }); }); it('should delete user and their data', () => { diff --git a/cypress/e2e/28-debug.cy.ts b/cypress/e2e/28-debug.cy.ts index b5159951a746e..9149e2e47827f 100644 --- a/cypress/e2e/28-debug.cy.ts +++ b/cypress/e2e/28-debug.cy.ts @@ -87,11 +87,28 @@ describe('Debug', () => { confirmDialog.get('.btn--confirm').click(); cy.url().should('include', '/debug'); - workflowPage.getters.canvasNodes().first().should('have.descendants', '.node-pin-data-icon'); - workflowPage.getters - .canvasNodes() - .not(':first') - .should('not.have.descendants', '.node-pin-data-icon'); + cy.ifCanvasVersion( + () => { + workflowPage.getters + .canvasNodes() + .first() + .should('have.descendants', '.node-pin-data-icon'); + workflowPage.getters + .canvasNodes() + .not(':first') + .should('not.have.descendants', '.node-pin-data-icon'); + }, + () => { + workflowPage.getters + .canvasNodes() + .first() + .should('have.descendants', '[data-test-id="canvas-node-status-pinned"]'); + workflowPage.getters + .canvasNodes() + .not(':first') + .should('not.have.descendants', '[data-test-id="canvas-node-status-pinned"]'); + }, + ); cy.reload(true); cy.wait(['@getExecution']); @@ -114,7 +131,18 @@ describe('Debug', () => { confirmDialog.get('.btn--confirm').click(); cy.url().should('include', '/debug'); - workflowPage.getters.canvasNodes().last().find('.node-info-icon').should('be.empty'); + cy.ifCanvasVersion( + () => { + workflowPage.getters.canvasNodes().last().find('.node-info-icon').should('be.empty'); + }, + () => { + workflowPage.getters + .canvasNodes() + .last() + .find('[class*="statusIcons"]') + .should('not.exist'); + }, + ); workflowPage.getters.canvasNodes().first().dblclick(); ndv.actions.unPinData(); diff --git a/cypress/e2e/29-templates.cy.ts b/cypress/e2e/29-templates.cy.ts index 5b52889c94d10..727b0cbe3fe3f 100644 --- a/cypress/e2e/29-templates.cy.ts +++ b/cypress/e2e/29-templates.cy.ts @@ -129,7 +129,6 @@ describe('Workflow templates', () => { workflowPage.actions.shouldHaveWorkflowName('Demo: ' + OnboardingWorkflow.name); workflowPage.getters.canvasNodes().should('have.length', 4); workflowPage.getters.stickies().should('have.length', 1); - workflowPage.getters.canvasNodes().first().should('have.descendants', '.node-pin-data-icon'); }); it('can import template', () => { @@ -142,6 +141,7 @@ describe('Workflow templates', () => { }); it('should save template id with the workflow', () => { + cy.intercept('POST', '/rest/workflows').as('saveWorkflow'); templatesPage.actions.importTemplate(); cy.visit(templatesPage.url); @@ -159,10 +159,8 @@ describe('Workflow templates', () => { workflowPage.actions.hitSelectAll(); workflowPage.actions.hitCopy(); - cy.grantBrowserPermissions('clipboardReadWrite', 'clipboardSanitizedWrite'); - // Check workflow JSON by copying it to clipboard - cy.readClipboard().then((workflowJSON) => { - expect(workflowJSON).to.contain('"templateId": "1"'); + cy.wait('@saveWorkflow').then((interception) => { + expect(interception.request.body.meta.templateId).to.equal('1'); }); }); diff --git a/cypress/e2e/39-projects.cy.ts b/cypress/e2e/39-projects.cy.ts index cc1ab5ca36d56..327bff4f93589 100644 --- a/cypress/e2e/39-projects.cy.ts +++ b/cypress/e2e/39-projects.cy.ts @@ -498,7 +498,7 @@ describe('Projects', { disableAutoLogin: true }, () => { projects .getResourceMoveModal() .should('be.visible') - .find('button:contains("Move workflow")') + .contains('button', 'Move workflow') .should('be.disabled'); projects.getProjectMoveSelect().click(); getVisibleSelect() @@ -506,7 +506,7 @@ describe('Projects', { disableAutoLogin: true }, () => { .should('have.length', 5) .filter(':contains("Project 1")') .click(); - projects.getResourceMoveModal().find('button:contains("Move workflow")').click(); + projects.getResourceMoveModal().contains('button', 'Move workflow').click(); clearNotifications(); workflowsPage.getters @@ -524,7 +524,7 @@ describe('Projects', { disableAutoLogin: true }, () => { projects .getResourceMoveModal() .should('be.visible') - .find('button:contains("Move workflow")') + .contains('button', 'Move workflow') .should('be.disabled'); projects.getProjectMoveSelect().click(); getVisibleSelect() @@ -532,7 +532,7 @@ describe('Projects', { disableAutoLogin: true }, () => { .should('have.length', 5) .filter(':contains("Project 2")') .click(); - projects.getResourceMoveModal().find('button:contains("Move workflow")').click(); + projects.getResourceMoveModal().contains('button', 'Move workflow').click(); // Move the workflow from Project 2 to a member user projects.getMenuItems().last().click(); @@ -544,7 +544,7 @@ describe('Projects', { disableAutoLogin: true }, () => { projects .getResourceMoveModal() .should('be.visible') - .find('button:contains("Move workflow")') + .contains('button', 'Move workflow') .should('be.disabled'); projects.getProjectMoveSelect().click(); getVisibleSelect() @@ -553,7 +553,7 @@ describe('Projects', { disableAutoLogin: true }, () => { .filter(`:contains("${INSTANCE_MEMBERS[0].email}")`) .click(); - projects.getResourceMoveModal().find('button:contains("Move workflow")').click(); + projects.getResourceMoveModal().contains('button', 'Move workflow').click(); workflowsPage.getters.workflowCards().should('have.length', 1); // Move the workflow from member user back to Home @@ -569,7 +569,7 @@ describe('Projects', { disableAutoLogin: true }, () => { projects .getResourceMoveModal() .should('be.visible') - .find('button:contains("Move workflow")') + .contains('button', 'Move workflow') .should('be.disabled'); projects.getProjectMoveSelect().click(); getVisibleSelect() @@ -578,7 +578,7 @@ describe('Projects', { disableAutoLogin: true }, () => { .filter(`:contains("${INSTANCE_OWNER.email}")`) .click(); - projects.getResourceMoveModal().find('button:contains("Move workflow")').click(); + projects.getResourceMoveModal().contains('button', 'Move workflow').click(); clearNotifications(); workflowsPage.getters .workflowCards() @@ -596,7 +596,7 @@ describe('Projects', { disableAutoLogin: true }, () => { projects .getResourceMoveModal() .should('be.visible') - .find('button:contains("Move credential")') + .contains('button', 'Move credential') .should('be.disabled'); projects.getProjectMoveSelect().click(); getVisibleSelect() @@ -604,7 +604,7 @@ describe('Projects', { disableAutoLogin: true }, () => { .should('have.length', 5) .filter(':contains("Project 2")') .click(); - projects.getResourceMoveModal().find('button:contains("Move credential")').click(); + projects.getResourceMoveModal().contains('button', 'Move credential').click(); clearNotifications(); credentialsPage.getters.credentialCards().should('not.have.length'); @@ -619,7 +619,7 @@ describe('Projects', { disableAutoLogin: true }, () => { projects .getResourceMoveModal() .should('be.visible') - .find('button:contains("Move credential")') + .contains('button', 'Move credential') .should('be.disabled'); projects.getProjectMoveSelect().click(); getVisibleSelect() @@ -627,7 +627,7 @@ describe('Projects', { disableAutoLogin: true }, () => { .should('have.length', 5) .filter(`:contains("${INSTANCE_ADMIN.email}")`) .click(); - projects.getResourceMoveModal().find('button:contains("Move credential")').click(); + projects.getResourceMoveModal().contains('button', 'Move credential').click(); credentialsPage.getters.credentialCards().should('have.length', 1); // Move the credential from admin user back to instance owner @@ -641,7 +641,7 @@ describe('Projects', { disableAutoLogin: true }, () => { projects .getResourceMoveModal() .should('be.visible') - .find('button:contains("Move credential")') + .contains('button', 'Move credential') .should('be.disabled'); projects.getProjectMoveSelect().click(); getVisibleSelect() @@ -649,7 +649,7 @@ describe('Projects', { disableAutoLogin: true }, () => { .should('have.length', 5) .filter(`:contains("${INSTANCE_OWNER.email}")`) .click(); - projects.getResourceMoveModal().find('button:contains("Move credential")').click(); + projects.getResourceMoveModal().contains('button', 'Move credential').click(); clearNotifications(); @@ -666,7 +666,7 @@ describe('Projects', { disableAutoLogin: true }, () => { projects .getResourceMoveModal() .should('be.visible') - .find('button:contains("Move credential")') + .contains('button', 'Move credential') .should('be.disabled'); projects.getProjectMoveSelect().click(); getVisibleSelect() @@ -674,7 +674,7 @@ describe('Projects', { disableAutoLogin: true }, () => { .should('have.length', 5) .filter(':contains("Project 1")') .click(); - projects.getResourceMoveModal().find('button:contains("Move credential")').click(); + projects.getResourceMoveModal().contains('button', 'Move credential').click(); projects.getMenuItems().first().click(); projects.getProjectTabCredentials().click(); @@ -721,7 +721,7 @@ describe('Projects', { disableAutoLogin: true }, () => { projects .getResourceMoveModal() .should('be.visible') - .find('button:contains("Move workflow")') + .contains('button', 'Move workflow') .should('be.disabled'); projects.getProjectMoveSelect().click(); getVisibleSelect() @@ -729,7 +729,7 @@ describe('Projects', { disableAutoLogin: true }, () => { .should('have.length', 4) .filter(':contains("Project 1")') .click(); - projects.getResourceMoveModal().find('button:contains("Move workflow")').click(); + projects.getResourceMoveModal().contains('button', 'Move workflow').click(); workflowsPage.getters .workflowCards() diff --git a/cypress/e2e/48-subworkflow-inputs.cy.ts b/cypress/e2e/48-subworkflow-inputs.cy.ts new file mode 100644 index 0000000000000..0e2755b9f08ba --- /dev/null +++ b/cypress/e2e/48-subworkflow-inputs.cy.ts @@ -0,0 +1,288 @@ +import { clickGetBackToCanvas, getOutputTableHeaders } from '../composables/ndv'; +import { + clickZoomToFit, + navigateToNewWorkflowPage, + openNode, + pasteWorkflow, + saveWorkflowOnButtonClick, +} from '../composables/workflow'; +import SUB_WORKFLOW_INPUTS from '../fixtures/Test_Subworkflow-Inputs.json'; +import { NDV, WorkflowsPage, WorkflowPage } from '../pages'; +import { errorToast, successToast } from '../pages/notifications'; +import { getVisiblePopper } from '../utils'; + +const ndv = new NDV(); +const workflowsPage = new WorkflowsPage(); +const workflow = new WorkflowPage(); + +const DEFAULT_WORKFLOW_NAME = 'My workflow'; +const DEFAULT_SUBWORKFLOW_NAME_1 = 'My Sub-Workflow 1'; +const DEFAULT_SUBWORKFLOW_NAME_2 = 'My Sub-Workflow 2'; + +type FieldRow = readonly string[]; + +const exampleFields = [ + ['aNumber', 'Number'], + ['aString', 'String'], + ['aArray', 'Array'], + ['aObject', 'Object'], + ['aAny', 'Allow Any Type'], + // bool last since it's not an inputField so we'll skip it for some cases + ['aBool', 'Boolean'], +] as const; + +/** + * Populate multiValue fixedCollections. Only supports fixedCollections for which all fields can be defined via keyboard typing + * + * @param items - 2D array of items to populate, i.e. [["myField1", "String"], [""] + * @param collectionName - name of the fixedCollection to populate + * @param offset - amount of 'parameter-input's before the fixedCollection under test + * @returns + */ +function populateFixedCollection( + items: readonly FieldRow[], + collectionName: string, + offset: number, +) { + if (items.length === 0) return; + const n = items[0].length; + for (const [i, params] of items.entries()) { + ndv.actions.addItemToFixedCollection(collectionName); + for (const [j, param] of params.entries()) { + ndv.getters + .fixedCollectionParameter(collectionName) + .getByTestId('parameter-input') + .eq(offset + i * n + j) + .type(`${param}{downArrow}{enter}`); + } + } +} + +function makeExample(type: TypeField) { + switch (type) { + case 'String': + return '"example"'; + case 'Number': + return '42'; + case 'Boolean': + return 'true'; + case 'Array': + return '["example", 123, null]'; + case 'Object': + return '{{}"example": [123]}'; + case 'Allow Any Type': + return 'null'; + } +} + +type TypeField = 'Allow Any Type' | 'String' | 'Number' | 'Boolean' | 'Array' | 'Object'; +function populateFields(items: ReadonlyArray) { + populateFixedCollection(items, 'workflowInputs', 1); +} + +function navigateWorkflowSelectionDropdown(index: number, expectedText: string) { + ndv.getters.resourceLocator('workflowId').should('be.visible'); + ndv.getters.resourceLocatorInput('workflowId').click(); + + getVisiblePopper().findChildByTestId('rlc-item').eq(0).should('exist'); + getVisiblePopper() + .findChildByTestId('rlc-item') + .eq(index) + .find('span') + .should('have.text', expectedText) + .click(); +} + +function populateMapperFields(values: readonly string[], offset: number) { + for (const [i, value] of values.entries()) { + cy.getByTestId('parameter-input') + .eq(offset + i) + .type(value); + + // Click on a parent to dismiss the pop up hiding the field below. + cy.getByTestId('parameter-input') + .eq(offset + i) + .parent() + .parent() + .click('topLeft'); + } +} + +// This function starts off in the Child Workflow Input Trigger, assuming we just defined the input fields +// It then navigates back to the parent and validates output +function validateAndReturnToParent(targetChild: string, offset: number, fields: string[]) { + ndv.actions.execute(); + + // + 1 to account for formatting-only column + getOutputTableHeaders().should('have.length', fields.length + 1); + for (const [i, name] of fields.entries()) { + getOutputTableHeaders().eq(i).should('have.text', name); + } + + clickGetBackToCanvas(); + saveWorkflowOnButtonClick(); + + cy.visit(workflowsPage.url); + + workflowsPage.getters.workflowCardContent(DEFAULT_WORKFLOW_NAME).click(); + + openNode('Execute Workflow'); + + // Note that outside of e2e tests this will be pre-selected correctly. + // Due to our workaround to remain in the same tab we need to select the correct tab manually + navigateWorkflowSelectionDropdown(offset, targetChild); + + // This fails, pointing to `usePushConnection` `const triggerNode = subWorkflow?.nodes.find` being `undefined.find()`I + ndv.actions.execute(); + + getOutputTableHeaders().should('have.length', fields.length + 1); + for (const [i, name] of fields.entries()) { + getOutputTableHeaders().eq(i).should('have.text', name); + } + + // todo: verify the fields appear and show the correct types + + // todo: fill in the input fields (and mock previous node data in the json fixture to match) + + // todo: validate the actual output data +} + +function setWorkflowInputFieldValue(index: number, value: string) { + ndv.actions.addItemToFixedCollection('workflowInputs'); + ndv.actions.typeIntoFixedCollectionItem('workflowInputs', index, value); +} + +describe('Sub-workflow creation and typed usage', () => { + beforeEach(() => { + navigateToNewWorkflowPage(); + pasteWorkflow(SUB_WORKFLOW_INPUTS); + saveWorkflowOnButtonClick(); + clickZoomToFit(); + + openNode('Execute Workflow'); + + // Prevent sub-workflow from opening in new window + cy.window().then((win) => { + cy.stub(win, 'open').callsFake((url) => { + cy.visit(url); + }); + }); + navigateWorkflowSelectionDropdown(0, 'Create a new sub-workflow'); + // ************************** + // NAVIGATE TO CHILD WORKFLOW + // ************************** + + openNode('Workflow Input Trigger'); + }); + + it('works with type-checked values', () => { + populateFields(exampleFields); + + validateAndReturnToParent( + DEFAULT_SUBWORKFLOW_NAME_1, + 1, + exampleFields.map((f) => f[0]), + ); + + const values = [ + '-1', // number fields don't support `=` switch to expression, so let's test the Fixed case with it + ...exampleFields.slice(1).map((x) => `={{}{{} $json.a${x[0]}`), // }} are added automatically + ]; + + // this matches with the pinned data provided in the fixture + populateMapperFields(values, 2); + + ndv.actions.execute(); + + // todo: + // - validate output lines up + // - change input to need casts + // - run + // - confirm error + // - switch `attemptToConvertTypes` flag + // - confirm success and changed output + // - change input to be invalid despite cast + // - run + // - confirm error + // - switch type option flags + // - run + // - confirm success + // - turn off attempt to cast flag + // - confirm a value was not cast + }); + + it('works with Fields input source into JSON input source', () => { + ndv.getters.nodeOutputHint().should('exist'); + + populateFields(exampleFields); + + validateAndReturnToParent( + DEFAULT_SUBWORKFLOW_NAME_1, + 1, + exampleFields.map((f) => f[0]), + ); + + cy.window().then((win) => { + cy.stub(win, 'open').callsFake((url) => { + cy.visit(url); + }); + }); + navigateWorkflowSelectionDropdown(0, 'Create a new sub-workflow'); + + openNode('Workflow Input Trigger'); + + cy.getByTestId('parameter-input').eq(0).click(); + + // Todo: Check if there's a better way to interact with option dropdowns + // This PR would add this child testId + getVisiblePopper() + .getByTestId('parameter-input') + .eq(0) + .type('Using JSON Example{downArrow}{enter}'); + + const exampleJson = + '{{}' + exampleFields.map((x) => `"${x[0]}": ${makeExample(x[1])}`).join(',') + '}'; + cy.getByTestId('parameter-input-jsonExample') + .find('.cm-line') + .eq(0) + .type(`{selectAll}{backspace}${exampleJson}{enter}`); + + // first one doesn't work for some reason, might need to wait for something? + ndv.actions.execute(); + + validateAndReturnToParent( + DEFAULT_SUBWORKFLOW_NAME_2, + 2, + exampleFields.map((f) => f[0]), + ); + + // test for either InputSource mode and options combinations: + // + we're showing the notice in the output panel + // + we start with no fields + // + Test Step works and we create the fields + // + create field of each type (string, number, boolean, object, array, any) + // + exit ndv + // + save + // + go back to parent workflow + // - verify fields appear [needs Ivan's PR] + // - link fields [needs Ivan's PR] + // + run parent + // - verify output with `null` defaults exists + // + }); + + it('should show node issue when no fields are defined in manual mode', () => { + ndv.getters.nodeExecuteButton().should('be.disabled'); + ndv.actions.close(); + // Executing the workflow should show an error toast + workflow.actions.executeWorkflow(); + errorToast().should('contain', 'The workflow has issues'); + openNode('Workflow Input Trigger'); + // Add a field to the workflowInputs fixedCollection + setWorkflowInputFieldValue(0, 'test'); + // Executing the workflow should not show error now + ndv.actions.close(); + workflow.actions.executeWorkflow(); + successToast().should('contain', 'Workflow executed successfully'); + }); +}); diff --git a/cypress/e2e/5-ndv.cy.ts b/cypress/e2e/5-ndv.cy.ts index 96b917d4c3d47..8bad4245544c0 100644 --- a/cypress/e2e/5-ndv.cy.ts +++ b/cypress/e2e/5-ndv.cy.ts @@ -65,26 +65,6 @@ describe('NDV', () => { cy.shouldNotHaveConsoleErrors(); }); - it('should disconect Switch outputs if rules order was changed', () => { - cy.createFixtureWorkflow('NDV-test-switch_reorder.json', 'NDV test switch reorder'); - workflowPage.actions.zoomToFit(); - - workflowPage.actions.executeWorkflow(); - workflowPage.actions.openNode('Merge'); - ndv.getters.outputPanel().contains('2 items').should('exist'); - cy.contains('span', 'first').should('exist'); - ndv.getters.backToCanvas().click(); - - workflowPage.actions.openNode('Switch'); - cy.get('.cm-line').realMouseMove(100, 100); - cy.get('.fa-angle-down').click(); - ndv.getters.backToCanvas().click(); - workflowPage.actions.executeWorkflow(); - workflowPage.actions.openNode('Merge'); - ndv.getters.outputPanel().contains('2 items').should('exist'); - cy.contains('span', 'zero').should('exist'); - }); - it('should show correct validation state for resource locator params', () => { workflowPage.actions.addNodeToCanvas('Typeform', true, true); ndv.getters.container().should('be.visible'); @@ -111,6 +91,7 @@ describe('NDV', () => { cy.get('[class*=hasIssues]').should('have.length', 1); }); + // Correctly failing in V2 - node issues are only shows after execution it('should show all validation errors when opening pasted node', () => { cy.createFixtureWorkflow('Test_workflow_ndv_errors.json', 'Validation errors'); workflowPage.getters.canvasNodes().should('have.have.length', 1); @@ -204,7 +185,7 @@ describe('NDV', () => { .contains(key) .should('be.visible'); }); - getObjectValueItem().find('label').click({ force: true }); + getObjectValueItem().find('.toggle').click({ force: true }); expandedObjectProps.forEach((key) => { ndv.getters .outputPanel() @@ -213,9 +194,11 @@ describe('NDV', () => { .should('not.be.visible'); }); }); + it('should not display pagination for schema', () => { setupSchemaWorkflow(); ndv.getters.backToCanvas().click(); + workflowPage.actions.deselectAll(); workflowPage.getters.canvasNodeByName('Set').click(); workflowPage.actions.addNodeToCanvas( 'Customer Datastore (n8n training)', @@ -245,8 +228,8 @@ describe('NDV', () => { ndv.getters.outputPanel().find('[class*=_pagination]').should('not.exist'); ndv.getters .outputPanel() - .find('[data-test-id=run-data-schema-item] [data-test-id=run-data-schema-item]') - .should('have.length', 20); + .find('[data-test-id=run-data-schema-item]') + .should('have.length.above', 10); }); }); @@ -407,8 +390,18 @@ describe('NDV', () => { return cy.get(`[data-node-placement=${position}]`); } + // Correctly failing in V2 - due to floating navigation not updating the selected node it('should traverse floating nodes with mouse', () => { cy.createFixtureWorkflow('Floating_Nodes.json', 'Floating Nodes'); + + cy.ifCanvasVersion( + () => {}, + () => { + // Needed in V2 as all nodes remain selected when clicking on a selected node + workflowPage.actions.deselectAll(); + }, + ); + workflowPage.getters.canvasNodes().first().dblclick(); getFloatingNodeByPosition('inputMain').should('not.exist'); getFloatingNodeByPosition('outputMain').should('exist'); @@ -419,6 +412,7 @@ describe('NDV', () => { getFloatingNodeByPosition('inputMain').should('exist'); getFloatingNodeByPosition('outputMain').should('exist'); ndv.actions.close(); + // These two lines are broken in V2 workflowPage.getters.selectedNodes().should('have.length', 1); workflowPage.getters .selectedNodes() @@ -426,10 +420,8 @@ describe('NDV', () => { .should('contain', `Node ${i + 1}`); workflowPage.getters.selectedNodes().first().dblclick(); }); - getFloatingNodeByPosition('outputMain').click({ force: true }); ndv.getters.nodeNameContainer().should('contain', 'Chain'); - // Traverse 4 connected node backwards Array.from(Array(4).keys()).forEach((i) => { getFloatingNodeByPosition('inputMain').click({ force: true }); @@ -453,8 +445,17 @@ describe('NDV', () => { .should('contain', MANUAL_TRIGGER_NODE_DISPLAY_NAME); }); + // Correctly failing in V2 - due to floating navigation not updating the selected node it('should traverse floating nodes with keyboard', () => { cy.createFixtureWorkflow('Floating_Nodes.json', 'Floating Nodes'); + cy.ifCanvasVersion( + () => {}, + () => { + // Needed in V2 as all nodes remain selected when clicking on a selected node + workflowPage.actions.deselectAll(); + }, + ); + workflowPage.getters.canvasNodes().first().dblclick(); getFloatingNodeByPosition('inputMain').should('not.exist'); getFloatingNodeByPosition('outputMain').should('exist'); @@ -465,6 +466,7 @@ describe('NDV', () => { getFloatingNodeByPosition('inputMain').should('exist'); getFloatingNodeByPosition('outputMain').should('exist'); ndv.actions.close(); + // These two lines are broken in V2 workflowPage.getters.selectedNodes().should('have.length', 1); workflowPage.getters .selectedNodes() @@ -492,6 +494,7 @@ describe('NDV', () => { getFloatingNodeByPosition('inputSub').should('not.exist'); getFloatingNodeByPosition('outputSub').should('not.exist'); ndv.actions.close(); + // These two lines are broken in V2 workflowPage.getters.selectedNodes().should('have.length', 1); workflowPage.getters .selectedNodes() @@ -717,6 +720,7 @@ describe('NDV', () => { .should('have.value', 'Error fetching options from Notion'); }); + // Correctly failing in V2 - NodeCreator is not opened after clicking on the link it('Should open appropriate node creator after clicking on connection hint link', () => { const nodeCreator = new NodeCreator(); const hintMapper = { @@ -734,6 +738,7 @@ describe('NDV', () => { Object.entries(hintMapper).forEach(([node, group]) => { workflowPage.actions.openNode(node); + // This fails to open the NodeCreator cy.get('[data-action=openSelectiveNodeCreator]').contains('Insert one').click(); nodeCreator.getters.activeSubcategory().should('contain', group); cy.realPress('Escape'); @@ -837,4 +842,18 @@ describe('NDV', () => { .contains('To search field contents rather than just names, use Table or JSON view') .should('exist'); }); + + it('ADO-2931 - should handle multiple branches of the same input with the first branch empty correctly', () => { + cy.createFixtureWorkflow('Test_ndv_two_branches_of_same_parent_false_populated.json'); + workflowPage.actions.zoomToFit(); + workflowPage.actions.openNode('DebugHelper'); + ndv.getters.inputPanel().should('be.visible'); + ndv.getters.outputPanel().should('be.visible'); + ndv.actions.execute(); + // This ensures we rendered the inputPanel + ndv.getters + .inputPanel() + .find('[data-test-id=run-data-schema-item]') + .should('contain.text', 'a1'); + }); }); diff --git a/cypress/fixtures/Test_Subworkflow-Inputs.json b/cypress/fixtures/Test_Subworkflow-Inputs.json new file mode 100644 index 0000000000000..aeb4d601fdeba --- /dev/null +++ b/cypress/fixtures/Test_Subworkflow-Inputs.json @@ -0,0 +1,70 @@ +{ + "meta": { + "instanceId": "4d0676b62208d810ef035130bbfc9fd3afdc78d963ea8ccb9514dc89066efc94" + }, + "nodes": [ + { + "parameters": {}, + "id": "bb7f8bb3-840a-464c-a7de-d3a80538c2be", + "name": "When clicking ‘Test workflow’", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0] + }, + { + "parameters": { + "workflowId": {}, + "workflowInputs": { + "mappingMode": "defineBelow", + "value": {}, + "matchingColumns": [], + "schema": [], + "ignoreTypeMismatchErrors": false, + "attemptToConvertTypes": false, + "convertFieldsToString": true + }, + "options": {} + }, + "type": "n8n-nodes-base.executeWorkflow", + "typeVersion": 1.2, + "position": [500, 240], + "id": "6b6e2e34-c6ab-4083-b8e3-6b0d56be5453", + "name": "Execute Workflow" + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "Execute Workflow", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": { + "When clicking ‘Test workflow’": [ + { + "aaString": "A String", + "aaNumber": 1, + "aaArray": [1, true, "3"], + "aaObject": { + "aKey": -1 + }, + "aaAny": {} + }, + { + "aaString": "Another String", + "aaNumber": 2, + "aaArray": [], + "aaObject": { + "aDifferentKey": -1 + }, + "aaAny": [] + } + ] + } +} diff --git a/cypress/fixtures/Test_ndv_two_branches_of_same_parent_false_populated.json b/cypress/fixtures/Test_ndv_two_branches_of_same_parent_false_populated.json new file mode 100644 index 0000000000000..056a35a786430 --- /dev/null +++ b/cypress/fixtures/Test_ndv_two_branches_of_same_parent_false_populated.json @@ -0,0 +1,94 @@ +{ + "nodes": [ + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 2 + }, + "conditions": [ + { + "id": "6f0cf983-824b-4339-a5de-6b374a23b4b0", + "leftValue": "={{ $json.a }}", + "rightValue": 3, + "operator": { + "type": "number", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [220, 0], + "id": "1755282a-ec4a-4d02-a833-0316ca413cc4", + "name": "If" + }, + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0], + "id": "de1e7acf-12d8-4e56-ba42-709ffb397db2", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "category": "randomData" + }, + "type": "n8n-nodes-base.debugHelper", + "typeVersion": 1, + "position": [580, 0], + "id": "86440d33-f833-453c-bcaa-fff7e0083501", + "name": "DebugHelper", + "alwaysOutputData": true + } + ], + "connections": { + "If": { + "main": [ + [ + { + "node": "DebugHelper", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "DebugHelper", + "type": "main", + "index": 0 + } + ] + ] + }, + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "If", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": { + "When clicking ‘Test workflow’": [ + { + "a": 1 + }, + { + "a": 2 + } + ] + } +} diff --git a/cypress/package.json b/cypress/package.json index 832abd9ef58d8..26b585408b84a 100644 --- a/cypress/package.json +++ b/cypress/package.json @@ -6,6 +6,7 @@ "cypress:install": "cypress install", "test:e2e:ui": "scripts/run-e2e.js ui", "test:e2e:dev": "scripts/run-e2e.js dev", + "test:e2e:dev:v2": "scripts/run-e2e.js dev:v2", "test:e2e:all": "scripts/run-e2e.js all", "format": "biome format --write .", "format:check": "biome ci .", diff --git a/cypress/pages/credentials.ts b/cypress/pages/credentials.ts index d5fa9cc0b1a54..08b2fee9c7dbc 100644 --- a/cypress/pages/credentials.ts +++ b/cypress/pages/credentials.ts @@ -6,48 +6,10 @@ export class CredentialsPage extends BasePage { getters = { emptyListCreateCredentialButton: () => cy.getByTestId('empty-resources-list').find('button'), createCredentialButton: () => { - cy.getByTestId('resource-add').should('be.visible').click(); - cy.getByTestId('resource-add') - .find('.el-sub-menu__title') - .as('menuitem') - .should('have.attr', 'aria-describedby'); - - cy.get('@menuitem') - .should('be.visible') - .invoke('attr', 'aria-describedby') - .then((el) => cy.get(`[id="${el}"]`)) - .as('submenu'); - - cy.get('@submenu') - .should('be.visible') - .within((submenu) => { - // If submenu has another submenu - if (submenu.find('[data-test-id="navigation-submenu"]').length) { - cy.wrap(submenu) - .find('[data-test-id="navigation-submenu"]') - .should('be.visible') - .filter(':contains("Credential")') - .as('child') - .click(); - - cy.get('@child') - .should('be.visible') - .find('[data-test-id="navigation-submenu-item"]') - .should('be.visible') - .filter(':contains("Personal")') - .as('button'); - } else { - cy.wrap(submenu) - .find('[data-test-id="navigation-menu-item"]') - .filter(':contains("Credential")') - .as('button'); - } - }); - - return cy.get('@button').should('be.visible'); + cy.getByTestId('add-resource').should('be.visible').click(); + cy.getByTestId('add-resource').getByTestId('action-credential').should('be.visible'); + return cy.getByTestId('add-resource').getByTestId('action-credential'); }, - - // cy.getByTestId('resources-list-add'), searchInput: () => cy.getByTestId('resources-list-search'), emptyList: () => cy.getByTestId('resources-list-empty'), credentialCards: () => cy.getByTestId('resources-list-item'), diff --git a/cypress/pages/ndv.ts b/cypress/pages/ndv.ts index 516a0a1ea862c..1926ef0ad17bf 100644 --- a/cypress/pages/ndv.ts +++ b/cypress/pages/ndv.ts @@ -227,9 +227,6 @@ export class NDV extends BasePage { this.getters.inputSelect().find('.el-select').click(); this.getters.inputOption().contains(nodeName).click(); }, - expandSchemaViewNode: (nodeName: string) => { - this.getters.schemaViewNodeName().contains(nodeName).click(); - }, addDefaultPinnedData: () => { this.actions.editPinnedData(); this.actions.savePinnedData(); @@ -323,6 +320,11 @@ export class NDV extends BasePage { addItemToFixedCollection: (paramName: string) => { this.getters.fixedCollectionParameter(paramName).getByTestId('fixed-collection-add').click(); }, + typeIntoFixedCollectionItem: (fixedCollectionName: string, index: number, content: string) => { + this.getters.fixedCollectionParameter(fixedCollectionName).within(() => { + cy.getByTestId('parameter-input').eq(index).type(content); + }); + }, dragMainPanelToLeft: () => { cy.drag('[data-test-id=panel-drag-button]', [-1000, 0], { moveTwice: true }); }, diff --git a/cypress/pages/workflow-executions-tab.ts b/cypress/pages/workflow-executions-tab.ts index 5e8c36c055552..be022e6cdf789 100644 --- a/cypress/pages/workflow-executions-tab.ts +++ b/cypress/pages/workflow-executions-tab.ts @@ -30,6 +30,12 @@ export class WorkflowExecutionsTab extends BasePage { actions = { toggleNodeEnabled: (nodeName: string) => { + cy.ifCanvasVersion( + () => {}, + () => { + cy.get('body').click(); // Cancel selection if it exists + }, + ); workflowPage.getters.canvasNodeByName(nodeName).click(); cy.get('body').type('d', { force: true }); }, diff --git a/cypress/pages/workflow.ts b/cypress/pages/workflow.ts index ee90fa55e8043..e99b01aa4612c 100644 --- a/cypress/pages/workflow.ts +++ b/cypress/pages/workflow.ts @@ -1,6 +1,7 @@ import { BasePage } from './base'; import { NodeCreator } from './features/node-creator'; import { META_KEY } from '../constants'; +import type { OpenContextMenuOptions } from '../types'; import { getVisibleSelect } from '../utils'; import { getUniqueWorkflowName, isCanvasV2 } from '../utils/workflowUtils'; @@ -31,7 +32,11 @@ export class WorkflowPage extends BasePage { canvasNodes: () => cy.ifCanvasVersion( () => cy.getByTestId('canvas-node'), - () => cy.getByTestId('canvas-node').not('[data-node-type="n8n-nodes-internal.addNodes"]'), + () => + cy + .getByTestId('canvas-node') + .not('[data-node-type="n8n-nodes-internal.addNodes"]') + .not('[data-node-type="n8n-nodes-base.stickyNote"]'), ), canvasNodeByName: (nodeName: string) => this.getters.canvasNodes().filter(`:contains(${nodeName})`), @@ -96,7 +101,7 @@ export class WorkflowPage extends BasePage { disabledNodes: () => cy.ifCanvasVersion( () => cy.get('.node-box.disabled'), - () => cy.get('[data-test-id="canvas-trigger-node"][class*="disabled"]'), + () => cy.get('[data-test-id*="node"][class*="disabled"]'), ), selectedNodes: () => cy.ifCanvasVersion( @@ -272,14 +277,14 @@ export class WorkflowPage extends BasePage { }, openContextMenu: ( nodeTypeName?: string, - method: 'right-click' | 'overflow-button' = 'right-click', + { method = 'right-click', anchor = 'center' }: OpenContextMenuOptions = {}, ) => { const target = nodeTypeName ? this.getters.canvasNodeByName(nodeTypeName) : this.getters.nodeViewBackground(); if (method === 'right-click') { - target.rightclick(nodeTypeName ? 'center' : 'topLeft', { force: true }); + target.rightclick(nodeTypeName ? anchor : 'topLeft', { force: true }); } else { target.realHover(); target.find('[data-test-id="overflow-node-button"]').click({ force: true }); @@ -296,8 +301,8 @@ export class WorkflowPage extends BasePage { this.actions.openContextMenu(nodeTypeName); this.actions.contextMenuAction('delete'); }, - executeNode: (nodeTypeName: string) => { - this.actions.openContextMenu(nodeTypeName); + executeNode: (nodeTypeName: string, options?: OpenContextMenuOptions) => { + this.actions.openContextMenu(nodeTypeName, options); this.actions.contextMenuAction('execute'); }, addStickyFromContextMenu: () => { @@ -324,7 +329,7 @@ export class WorkflowPage extends BasePage { this.actions.contextMenuAction('toggle_pin'); }, openNodeFromContextMenu: (nodeTypeName: string) => { - this.actions.openContextMenu(nodeTypeName, 'overflow-button'); + this.actions.openContextMenu(nodeTypeName, { method: 'overflow-button' }); this.actions.contextMenuAction('open'); }, selectAllFromContextMenu: () => { @@ -332,8 +337,14 @@ export class WorkflowPage extends BasePage { this.actions.contextMenuAction('select_all'); }, deselectAll: () => { - this.actions.openContextMenu(); - this.actions.contextMenuAction('deselect_all'); + cy.ifCanvasVersion( + () => { + this.actions.openContextMenu(); + this.actions.contextMenuAction('deselect_all'); + }, + // rightclick doesn't work with vueFlow canvas + () => this.getters.nodeViewBackground().click('topLeft'), + ); }, openExpressionEditorModal: () => { cy.contains('Expression').invoke('show').click(); diff --git a/cypress/pages/workflows.ts b/cypress/pages/workflows.ts index 41f62e8bc3a89..a58911a3550f5 100644 --- a/cypress/pages/workflows.ts +++ b/cypress/pages/workflows.ts @@ -8,45 +8,8 @@ export class WorkflowsPage extends BasePage { newWorkflowTemplateCard: () => cy.getByTestId('new-workflow-template-card'), searchBar: () => cy.getByTestId('resources-list-search'), createWorkflowButton: () => { - cy.getByTestId('resource-add').should('be.visible').click(); - cy.getByTestId('resource-add') - .find('.el-sub-menu__title') - .as('menuitem') - .should('have.attr', 'aria-describedby'); - - cy.get('@menuitem') - .should('be.visible') - .invoke('attr', 'aria-describedby') - .then((el) => cy.get(`[id="${el}"]`)) - .as('submenu'); - - cy.get('@submenu') - .should('be.visible') - .within((submenu) => { - // If submenu has another submenu - if (submenu.find('[data-test-id="navigation-submenu"]').length) { - cy.wrap(submenu) - .find('[data-test-id="navigation-submenu"]') - .should('be.visible') - .filter(':contains("Workflow")') - .as('child') - .click(); - - cy.get('@child') - .should('be.visible') - .find('[data-test-id="navigation-submenu-item"]') - .should('be.visible') - .filter(':contains("Personal")') - .as('button'); - } else { - cy.wrap(submenu) - .find('[data-test-id="navigation-menu-item"]') - .filter(':contains("Workflow")') - .as('button'); - } - }); - - return cy.get('@button').should('be.visible'); + cy.getByTestId('add-resource-workflow').should('be.visible'); + return cy.getByTestId('add-resource-workflow'); }, workflowCards: () => cy.getByTestId('resources-list-item'), workflowCard: (workflowName: string) => diff --git a/cypress/scripts/run-e2e.js b/cypress/scripts/run-e2e.js index 8096a70caf4f4..6819d6c824c8d 100755 --- a/cypress/scripts/run-e2e.js +++ b/cypress/scripts/run-e2e.js @@ -57,6 +57,17 @@ switch (scenario) { }, }); break; + case 'dev:v2': + runTests({ + startCommand: 'develop', + url: 'http://localhost:8080/favicon.ico', + testCommand: 'cypress open', + customEnv: { + CYPRESS_NODE_VIEW_VERSION: 2, + CYPRESS_BASE_URL: 'http://localhost:8080', + }, + }); + break; case 'all': const specSuiteFilter = process.argv[3]; const specParam = specSuiteFilter ? ` --spec **/*${specSuiteFilter}*` : ''; diff --git a/cypress/types.ts b/cypress/types.ts index 6186c4201d405..63f2ddb99e703 100644 --- a/cypress/types.ts +++ b/cypress/types.ts @@ -22,3 +22,8 @@ export interface ExecutionResponse { results: Execution[]; }; } + +export type OpenContextMenuOptions = { + method?: 'right-click' | 'overflow-button'; + anchor?: 'topRight' | 'topLeft' | 'center' | 'bottomRight' | 'bottomLeft'; +}; diff --git a/docker/images/n8n-custom/Dockerfile b/docker/images/n8n-custom/Dockerfile index 2b72365eb82f3..13592140a4e07 100644 --- a/docker/images/n8n-custom/Dockerfile +++ b/docker/images/n8n-custom/Dockerfile @@ -33,7 +33,7 @@ COPY docker/images/n8n/docker-entrypoint.sh / # Setup the Task Runner Launcher ARG TARGETPLATFORM -ARG LAUNCHER_VERSION=1.0.0 +ARG LAUNCHER_VERSION=1.1.0 COPY docker/images/n8n/n8n-task-runners.json /etc/n8n-task-runners.json # Download, verify, then extract the launcher binary RUN \ diff --git a/docker/images/n8n/Dockerfile b/docker/images/n8n/Dockerfile index 740773618536e..10720c63f20e3 100644 --- a/docker/images/n8n/Dockerfile +++ b/docker/images/n8n/Dockerfile @@ -24,7 +24,7 @@ RUN set -eux; \ # Setup the Task Runner Launcher ARG TARGETPLATFORM -ARG LAUNCHER_VERSION=1.0.0 +ARG LAUNCHER_VERSION=1.1.0 COPY n8n-task-runners.json /etc/n8n-task-runners.json # Download, verify, then extract the launcher binary RUN \ diff --git a/docker/images/n8n/n8n-task-runners.json b/docker/images/n8n/n8n-task-runners.json index d9575997c0995..c64d0ecdd04a5 100644 --- a/docker/images/n8n/n8n-task-runners.json +++ b/docker/images/n8n/n8n-task-runners.json @@ -12,6 +12,7 @@ "N8N_RUNNERS_TASK_BROKER_URI", "N8N_RUNNERS_MAX_PAYLOAD", "N8N_RUNNERS_MAX_CONCURRENCY", + "N8N_RUNNERS_TASK_TIMEOUT", "N8N_RUNNERS_HEALTH_CHECK_SERVER_ENABLED", "N8N_RUNNERS_HEALTH_CHECK_SERVER_HOST", "N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT", diff --git a/package.json b/package.json index e2a06287736b6..063accd8558ca 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "n8n-monorepo", - "version": "1.71.0", + "version": "1.73.0", "private": true, "engines": { "node": ">=20.15", - "pnpm": ">=9.5" + "pnpm": ">=9.15" }, - "packageManager": "pnpm@9.6.0", + "packageManager": "pnpm@9.15.1", "scripts": { "prepare": "node scripts/prepare.mjs", "preinstall": "node scripts/block-npm-install.js", diff --git a/packages/@n8n/api-types/package.json b/packages/@n8n/api-types/package.json index 9f045e31d4926..c14e18992236e 100644 --- a/packages/@n8n/api-types/package.json +++ b/packages/@n8n/api-types/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/api-types", - "version": "0.9.0", + "version": "0.11.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 41a55f050a358..97d5d38459b4d 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -3,3 +3,4 @@ export { RoleChangeRequestDto } from './user/role-change-request.dto'; export { SettingsUpdateRequestDto } from './user/settings-update-request.dto'; export { UserUpdateRequestDto } from './user/user-update-request.dto'; export { CommunityRegisteredRequestDto } from './license/community-registered-request.dto'; +export { VariableListRequestDto } from './variables/variables-list-request.dto'; diff --git a/packages/@n8n/api-types/src/dto/variables/variables-list-request.dto.ts b/packages/@n8n/api-types/src/dto/variables/variables-list-request.dto.ts new file mode 100644 index 0000000000000..804bcb27865e9 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/variables/variables-list-request.dto.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class VariableListRequestDto extends Z.class({ + state: z.literal('empty').optional(), +}) {} diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index 00b87b968eeb9..1fe0fcd85798c 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -172,6 +172,5 @@ export interface FrontendSettings { blockFileAccessToN8nFiles: boolean; }; betaFeatures: FrontendBetaFeatures[]; - virtualSchemaView: boolean; easyAIWorkflowOnboarded: boolean; } diff --git a/packages/@n8n/benchmark/src/n8n-api-client/n8n-api-client.ts b/packages/@n8n/benchmark/src/n8n-api-client/n8n-api-client.ts index dd81fa9cfbd50..aa3ce82a965f0 100644 --- a/packages/@n8n/benchmark/src/n8n-api-client/n8n-api-client.ts +++ b/packages/@n8n/benchmark/src/n8n-api-client/n8n-api-client.ts @@ -2,7 +2,7 @@ import type { AxiosError, AxiosRequestConfig } from 'axios'; import axios from 'axios'; export class N8nApiClient { - constructor(public readonly apiBaseUrl: string) {} + constructor(readonly apiBaseUrl: string) {} async waitForInstanceToBecomeOnline(): Promise { const HEALTH_ENDPOINT = 'healthz'; diff --git a/packages/@n8n/chat/package.json b/packages/@n8n/chat/package.json index 4cc32dc8fe6bc..1db1c50f9a107 100644 --- a/packages/@n8n/chat/package.json +++ b/packages/@n8n/chat/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/chat", - "version": "0.32.0", + "version": "0.33.0", "scripts": { "dev": "pnpm run storybook", "build": "pnpm build:vite && pnpm build:bundle", diff --git a/packages/@n8n/config/package.json b/packages/@n8n/config/package.json index 8ba9cc43bf33a..c4368a75c555b 100644 --- a/packages/@n8n/config/package.json +++ b/packages/@n8n/config/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/config", - "version": "1.21.0", + "version": "1.23.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/config/src/configs/executions.config.ts b/packages/@n8n/config/src/configs/executions.config.ts index 8c5d91b3c8f9c..977d53992004d 100644 --- a/packages/@n8n/config/src/configs/executions.config.ts +++ b/packages/@n8n/config/src/configs/executions.config.ts @@ -6,7 +6,7 @@ class PruningIntervalsConfig { @Env('EXECUTIONS_DATA_PRUNE_HARD_DELETE_INTERVAL') hardDelete: number = 15; - /** How often (minutes) execution data should be soft-deleted */ + /** How often (minutes) execution data should be soft-deleted. */ @Env('EXECUTIONS_DATA_PRUNE_SOFT_DELETE_INTERVAL') softDelete: number = 60; } diff --git a/packages/@n8n/config/src/configs/runners.config.ts b/packages/@n8n/config/src/configs/runners.config.ts index 06e262fe49249..733e72440826f 100644 --- a/packages/@n8n/config/src/configs/runners.config.ts +++ b/packages/@n8n/config/src/configs/runners.config.ts @@ -43,11 +43,11 @@ export class TaskRunnersConfig { @Env('N8N_RUNNERS_MAX_CONCURRENCY') maxConcurrency: number = 5; - /** How long (in seconds) a task is allowed to take for completion, else the task will be aborted and the runner restarted. Must be greater than 0. */ + /** How long (in seconds) a task is allowed to take for completion, else the task will be aborted. (In internal mode, the runner will also be restarted.) Must be greater than 0. */ @Env('N8N_RUNNERS_TASK_TIMEOUT') taskTimeout: number = 60; - /** How often (in seconds) the runner must send a heartbeat to the broker, else the task will be aborted and the runner restarted. Must be greater than 0. */ + /** How often (in seconds) the runner must send a heartbeat to the broker, else the task will be aborted. (In internal mode, the runner will also be restarted.) Must be greater than 0. */ @Env('N8N_RUNNERS_HEARTBEAT_INTERVAL') heartbeatInterval: number = 30; } diff --git a/packages/@n8n/nodes-langchain/.eslintrc.js b/packages/@n8n/nodes-langchain/.eslintrc.js index 7ea76b12a9673..510b970755d4f 100644 --- a/packages/@n8n/nodes-langchain/.eslintrc.js +++ b/packages/@n8n/nodes-langchain/.eslintrc.js @@ -15,7 +15,6 @@ module.exports = { eqeqeq: 'warn', 'id-denylist': 'warn', 'import/extensions': 'warn', - 'import/order': 'warn', 'prefer-spread': 'warn', '@typescript-eslint/naming-convention': ['error', { selector: 'memberLike', format: null }], 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 80e5da9cfacce..230a6ddc6ed37 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts @@ -9,6 +9,8 @@ import type { INodeProperties, } from 'n8n-workflow'; +import { promptTypeOptions, textFromPreviousNode, textInput } from '@utils/descriptions'; + import { conversationalAgentProperties } from './agents/ConversationalAgent/description'; import { conversationalAgentExecute } from './agents/ConversationalAgent/execute'; import { openAiFunctionsAgentProperties } from './agents/OpenAiFunctionsAgent/description'; @@ -21,7 +23,6 @@ import { sqlAgentAgentProperties } from './agents/SqlAgent/description'; import { sqlAgentAgentExecute } from './agents/SqlAgent/execute'; import { toolsAgentProperties } from './agents/ToolsAgent/description'; import { toolsAgentExecute } from './agents/ToolsAgent/execute'; -import { promptTypeOptions, textFromPreviousNode, textInput } from '../../../utils/descriptions'; // Function used in the inputs expression to figure out which inputs to // display based on the agent type diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/description.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/description.ts index c3507b328c9a7..0a65f4919c19a 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/description.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/description.ts @@ -1,4 +1,5 @@ import type { INodeProperties } from 'n8n-workflow'; + import { SYSTEM_MESSAGE, HUMAN_MESSAGE } from './prompt'; export const conversationalAgentProperties: INodeProperties[] = [ 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 09e04c0b76326..04a565971c057 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 @@ -6,14 +6,11 @@ import { CombiningOutputParser } from 'langchain/output_parsers'; import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; -import { - isChatInstance, - getPromptInputByType, - getConnectedTools, -} from '../../../../../utils/helpers'; -import { getOptionalOutputParsers } from '../../../../../utils/output_parsers/N8nOutputParser'; -import { throwIfToolSchema } from '../../../../../utils/schemaParsing'; -import { getTracingConfig } from '../../../../../utils/tracing'; +import { isChatInstance, getPromptInputByType, getConnectedTools } from '@utils/helpers'; +import { getOptionalOutputParsers } from '@utils/output_parsers/N8nOutputParser'; +import { throwIfToolSchema } from '@utils/schemaParsing'; +import { getTracingConfig } from '@utils/tracing'; + import { checkForStructuredTools, extractParsedOutput } from '../utils'; export async function conversationalAgentExecute( diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/description.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/description.ts index 40f9ad1945b74..0a0f6ac55edd4 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/description.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/description.ts @@ -1,4 +1,5 @@ import type { INodeProperties } from 'n8n-workflow'; + import { SYSTEM_MESSAGE } from './prompt'; export const openAiFunctionsAgentProperties: INodeProperties[] = [ 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 a9b324678c911..17a2d43590a10 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 @@ -12,9 +12,10 @@ import { NodeOperationError, } from 'n8n-workflow'; -import { getConnectedTools, getPromptInputByType } from '../../../../../utils/helpers'; -import { getOptionalOutputParsers } from '../../../../../utils/output_parsers/N8nOutputParser'; -import { getTracingConfig } from '../../../../../utils/tracing'; +import { getConnectedTools, getPromptInputByType } from '@utils/helpers'; +import { getOptionalOutputParsers } from '@utils/output_parsers/N8nOutputParser'; +import { getTracingConfig } from '@utils/tracing'; + import { extractParsedOutput } from '../utils'; export async function openAiFunctionsAgentExecute( diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/PlanAndExecuteAgent/description.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/PlanAndExecuteAgent/description.ts index 608f9e9def135..8dcb8f838560d 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/PlanAndExecuteAgent/description.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/PlanAndExecuteAgent/description.ts @@ -1,4 +1,5 @@ import type { INodeProperties } from 'n8n-workflow'; + import { DEFAULT_STEP_EXECUTOR_HUMAN_CHAT_MESSAGE_TEMPLATE } from './prompt'; export const planAndExecuteAgentProperties: INodeProperties[] = [ 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 d2dc152ebbf8d..379475f9235a0 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 @@ -10,10 +10,11 @@ import { NodeOperationError, } from 'n8n-workflow'; -import { getConnectedTools, getPromptInputByType } from '../../../../../utils/helpers'; -import { getOptionalOutputParsers } from '../../../../../utils/output_parsers/N8nOutputParser'; -import { throwIfToolSchema } from '../../../../../utils/schemaParsing'; -import { getTracingConfig } from '../../../../../utils/tracing'; +import { getConnectedTools, getPromptInputByType } from '@utils/helpers'; +import { getOptionalOutputParsers } from '@utils/output_parsers/N8nOutputParser'; +import { throwIfToolSchema } from '@utils/schemaParsing'; +import { getTracingConfig } from '@utils/tracing'; + import { checkForStructuredTools, extractParsedOutput } from '../utils'; export async function planAndExecuteAgentExecute( diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/description.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/description.ts index f95026f7a42e3..890d83dc4a419 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/description.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/description.ts @@ -1,4 +1,5 @@ import type { INodeProperties } from 'n8n-workflow'; + import { HUMAN_MESSAGE_TEMPLATE, PREFIX, SUFFIX, SUFFIX_CHAT } from './prompt'; export const reActAgentAgentProperties: INodeProperties[] = [ 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 b671a8189ced4..4db35634d6c96 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 @@ -11,14 +11,11 @@ import { NodeOperationError, } from 'n8n-workflow'; -import { - getConnectedTools, - getPromptInputByType, - isChatInstance, -} from '../../../../../utils/helpers'; -import { getOptionalOutputParsers } from '../../../../../utils/output_parsers/N8nOutputParser'; -import { throwIfToolSchema } from '../../../../../utils/schemaParsing'; -import { getTracingConfig } from '../../../../../utils/tracing'; +import { getConnectedTools, getPromptInputByType, isChatInstance } from '@utils/helpers'; +import { getOptionalOutputParsers } from '@utils/output_parsers/N8nOutputParser'; +import { throwIfToolSchema } from '@utils/schemaParsing'; +import { getTracingConfig } from '@utils/tracing'; + import { checkForStructuredTools, extractParsedOutput } from '../utils'; export async function reActAgentAgentExecute( diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/description.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/description.ts index bed547ba6da2f..919f501d17314 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/description.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/description.ts @@ -1,11 +1,8 @@ import type { INodeProperties } from 'n8n-workflow'; +import { promptTypeOptions, textFromPreviousNode, textInput } from '@utils/descriptions'; + import { SQL_PREFIX, SQL_SUFFIX } from './other/prompts'; -import { - promptTypeOptions, - textFromPreviousNode, - textInput, -} from '../../../../../utils/descriptions'; const dataSourceOptions: INodeProperties = { displayName: 'Data Source', 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 b9c0f3db8ee9b..369ca109af497 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 @@ -12,12 +12,13 @@ import { type IDataObject, } from 'n8n-workflow'; +import { getPromptInputByType, serializeChatHistory } from '@utils/helpers'; +import { getTracingConfig } from '@utils/tracing'; + import { getMysqlDataSource } from './other/handlers/mysql'; import { getPostgresDataSource } from './other/handlers/postgres'; import { getSqliteDataSource } from './other/handlers/sqlite'; import { SQL_PREFIX, SQL_SUFFIX } from './other/prompts'; -import { getPromptInputByType, serializeChatHistory } from '../../../../../utils/helpers'; -import { getTracingConfig } from '../../../../../utils/tracing'; const parseTablesString = (tablesString: string) => tablesString diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/other/handlers/mysql.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/other/handlers/mysql.ts index ea1b360f046fe..dd56f93d6ca74 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/other/handlers/mysql.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/other/handlers/mysql.ts @@ -1,5 +1,5 @@ -import { type IExecuteFunctions } from 'n8n-workflow'; import { DataSource } from '@n8n/typeorm'; +import { type IExecuteFunctions } from 'n8n-workflow'; export async function getMysqlDataSource(this: IExecuteFunctions): Promise { const credentials = await this.getCredentials('mySql'); diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/other/handlers/postgres.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/other/handlers/postgres.ts index 6971d9119f27a..31dda9ed72821 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/other/handlers/postgres.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/other/handlers/postgres.ts @@ -1,5 +1,5 @@ -import { type IExecuteFunctions } from 'n8n-workflow'; import { DataSource } from '@n8n/typeorm'; +import { type IExecuteFunctions } from 'n8n-workflow'; export async function getPostgresDataSource(this: IExecuteFunctions): Promise { const credentials = await this.getCredentials('postgres'); diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/other/handlers/sqlite.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/other/handlers/sqlite.ts index 9240feb280b19..31db7b0dcae4f 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/other/handlers/sqlite.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/other/handlers/sqlite.ts @@ -1,9 +1,9 @@ +import { DataSource } from '@n8n/typeorm'; import * as fs from 'fs'; import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; import { BINARY_ENCODING, NodeOperationError } from 'n8n-workflow'; -import * as temp from 'temp'; import * as sqlite3 from 'sqlite3'; -import { DataSource } from '@n8n/typeorm'; +import * as temp from 'temp'; export async function getSqliteDataSource( this: IExecuteFunctions, diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/description.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/description.ts index cb33560cc6755..06b64a91de61c 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/description.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/description.ts @@ -1,4 +1,5 @@ import type { INodeProperties } from 'n8n-workflow'; + import { SYSTEM_MESSAGE } from './prompt'; export const toolsAgentProperties: INodeProperties[] = [ 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 74d68199618f2..b0e36d0d8b327 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 @@ -14,16 +14,13 @@ import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; import type { ZodObject } from 'zod'; import { z } from 'zod'; -import { SYSTEM_MESSAGE } from './prompt'; -import { - isChatInstance, - getPromptInputByType, - getConnectedTools, -} from '../../../../../utils/helpers'; +import { isChatInstance, getPromptInputByType, getConnectedTools } from '@utils/helpers'; import { getOptionalOutputParsers, type N8nOutputParser, -} from '../../../../../utils/output_parsers/N8nOutputParser'; +} from '@utils/output_parsers/N8nOutputParser'; + +import { SYSTEM_MESSAGE } from './prompt'; function getOutputParserSchema(outputParser: N8nOutputParser): ZodObject { const schema = @@ -33,7 +30,7 @@ function getOutputParserSchema(outputParser: N8nOutputParser): ZodObject data.mimeType.startsWith('image/')) @@ -260,7 +257,7 @@ export async function toolsAgentExecute(this: IExecuteFunctions): Promise; 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 ca42323fc336c..e44ad8f9d231d 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts @@ -10,9 +10,10 @@ import type { } from 'n8n-workflow'; import { OpenAI as OpenAIClient } from 'openai'; +import { getConnectedTools } from '@utils/helpers'; +import { getTracingConfig } from '@utils/tracing'; + import { formatToOpenAIAssistantTool } from './utils'; -import { getConnectedTools } from '../../../utils/helpers'; -import { getTracingConfig } from '../../../utils/tracing'; export class OpenAiAssistant implements INodeType { description: INodeTypeDescription = { diff --git a/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/utils.ts b/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/utils.ts index 294fc47847f0f..d0db590bc3f41 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/utils.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/utils.ts @@ -1,6 +1,6 @@ -import { zodToJsonSchema } from 'zod-to-json-schema'; -import type { OpenAIClient } from '@langchain/openai'; import type { StructuredTool } from '@langchain/core/tools'; +import type { OpenAIClient } from '@langchain/openai'; +import { zodToJsonSchema } from 'zod-to-json-schema'; // Copied from langchain(`langchain/src/tools/convert_to_openai.ts`) // since these functions are not exported 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 32d70f2d3208d..4b2ddf5db98fb 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/ChainLlm.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/ChainLlm.node.ts @@ -27,16 +27,17 @@ import { NodeOperationError, } from 'n8n-workflow'; -import { getPromptInputByType, isChatInstance } from '../../../utils/helpers'; -import type { N8nOutputParser } from '../../../utils/output_parsers/N8nOutputParser'; -import { getOptionalOutputParsers } from '../../../utils/output_parsers/N8nOutputParser'; -import { getTemplateNoticeField } from '../../../utils/sharedFields'; -import { getTracingConfig } from '../../../utils/tracing'; +import { promptTypeOptions, textFromPreviousNode } from '@utils/descriptions'; +import { getPromptInputByType, isChatInstance } from '@utils/helpers'; +import type { N8nOutputParser } from '@utils/output_parsers/N8nOutputParser'; +import { getOptionalOutputParsers } from '@utils/output_parsers/N8nOutputParser'; +import { getTemplateNoticeField } from '@utils/sharedFields'; +import { getTracingConfig } from '@utils/tracing'; + import { getCustomErrorMessage as getCustomOpenAiErrorMessage, isOpenAiError, } from '../../vendors/OpenAi/helpers/error-handling'; -import { promptTypeOptions, textFromPreviousNode } from '../../../utils/descriptions'; interface MessagesTemplate { type: string; @@ -253,6 +254,7 @@ export class ChainLlm implements INodeType { displayName: 'Basic LLM Chain', name: 'chainLlm', icon: 'fa:link', + iconColor: 'black', group: ['transform'], version: [1, 1.1, 1.2, 1.3, 1.4, 1.5], description: 'A simple chain to prompt a large language model', 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 75f7458438440..7829bc7813cb2 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/ChainRetrievalQA/ChainRetrievalQa.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/ChainRetrievalQA/ChainRetrievalQa.node.ts @@ -16,10 +16,10 @@ import { NodeOperationError, } from 'n8n-workflow'; -import { promptTypeOptions, textFromPreviousNode } from '../../../utils/descriptions'; -import { getPromptInputByType, isChatInstance } from '../../../utils/helpers'; -import { getTemplateNoticeField } from '../../../utils/sharedFields'; -import { getTracingConfig } from '../../../utils/tracing'; +import { promptTypeOptions, textFromPreviousNode } from '@utils/descriptions'; +import { getPromptInputByType, isChatInstance } from '@utils/helpers'; +import { getTemplateNoticeField } from '@utils/sharedFields'; +import { getTracingConfig } from '@utils/tracing'; const SYSTEM_PROMPT_TEMPLATE = `Use the following pieces of context to answer the users question. If you don't know the answer, just say that you don't know, don't try to make up an answer. @@ -31,6 +31,7 @@ export class ChainRetrievalQa implements INodeType { displayName: 'Question and Answer Chain', name: 'chainRetrievalQa', icon: 'fa:link', + iconColor: 'black', group: ['transform'], version: [1, 1.1, 1.2, 1.3, 1.4], description: 'Answer questions about retrieved documents', diff --git a/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/ChainSummarization.node.ts b/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/ChainSummarization.node.ts index cd47eb6a1531f..9c97190952167 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/ChainSummarization.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/ChainSummarization.node.ts @@ -10,6 +10,7 @@ export class ChainSummarization extends VersionedNodeType { displayName: 'Summarization Chain', name: 'chainSummarization', icon: 'fa:link', + iconColor: 'black', group: ['transform'], description: 'Transforms text into a concise summary', codex: { 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 3e7b6997d6ccf..fedf9790829cd 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 @@ -1,3 +1,8 @@ +import type { Document } from '@langchain/core/documents'; +import type { BaseLanguageModel } from '@langchain/core/language_models/base'; +import { PromptTemplate } from '@langchain/core/prompts'; +import type { SummarizationChainParams } from 'langchain/chains'; +import { loadSummarizationChain } from 'langchain/chains'; import { NodeConnectionType, type INodeTypeBaseDescription, @@ -7,14 +12,10 @@ import { type INodeTypeDescription, } from 'n8n-workflow'; -import type { SummarizationChainParams } from 'langchain/chains'; -import { loadSummarizationChain } from 'langchain/chains'; -import type { BaseLanguageModel } from '@langchain/core/language_models/base'; -import type { Document } from '@langchain/core/documents'; -import { PromptTemplate } from '@langchain/core/prompts'; -import { N8nJsonLoader } from '../../../../utils/N8nJsonLoader'; -import { N8nBinaryLoader } from '../../../../utils/N8nBinaryLoader'; -import { getTemplateNoticeField } from '../../../../utils/sharedFields'; +import { N8nBinaryLoader } from '@utils/N8nBinaryLoader'; +import { N8nJsonLoader } from '@utils/N8nJsonLoader'; +import { getTemplateNoticeField } from '@utils/sharedFields'; + import { REFINE_PROMPT_TEMPLATE, DEFAULT_PROMPT_TEMPLATE } from '../prompt'; export class ChainSummarizationV1 implements INodeType { 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 76964e99f14a2..ff6dadde59e76 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 @@ -1,4 +1,8 @@ -import { NodeConnectionType } from 'n8n-workflow'; +import type { Document } from '@langchain/core/documents'; +import type { BaseLanguageModel } from '@langchain/core/language_models/base'; +import type { TextSplitter } from '@langchain/textsplitters'; +import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'; +import { loadSummarizationChain } from 'langchain/chains'; import type { INodeTypeBaseDescription, IExecuteFunctions, @@ -7,18 +11,15 @@ import type { INodeTypeDescription, IDataObject, } from 'n8n-workflow'; +import { NodeConnectionType } from 'n8n-workflow'; + +import { N8nBinaryLoader } from '@utils/N8nBinaryLoader'; +import { N8nJsonLoader } from '@utils/N8nJsonLoader'; +import { getTemplateNoticeField } from '@utils/sharedFields'; +import { getTracingConfig } from '@utils/tracing'; -import { loadSummarizationChain } from 'langchain/chains'; -import type { BaseLanguageModel } from '@langchain/core/language_models/base'; -import type { Document } from '@langchain/core/documents'; -import type { TextSplitter } from '@langchain/textsplitters'; -import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'; -import { N8nJsonLoader } from '../../../../utils/N8nJsonLoader'; -import { N8nBinaryLoader } from '../../../../utils/N8nBinaryLoader'; -import { getTemplateNoticeField } from '../../../../utils/sharedFields'; -import { REFINE_PROMPT_TEMPLATE, DEFAULT_PROMPT_TEMPLATE } from '../prompt'; import { getChainPromptsArgs } from '../helpers'; -import { getTracingConfig } from '../../../../utils/tracing'; +import { REFINE_PROMPT_TEMPLATE, DEFAULT_PROMPT_TEMPLATE } from '../prompt'; function getInputs(parameters: IDataObject) { const chunkingMode = parameters?.chunkingMode; diff --git a/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/helpers.ts b/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/helpers.ts index 2a00d836e8f87..2da507ed00977 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/helpers.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/helpers.ts @@ -1,5 +1,5 @@ -import type { SummarizationChainParams } from 'langchain/chains'; import { PromptTemplate } from '@langchain/core/prompts'; +import type { SummarizationChainParams } from 'langchain/chains'; interface ChainTypeOptions { combineMapPrompt?: string; prompt?: string; 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 ab6cd8f20117e..365a35ddd317e 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/InformationExtractor.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/InformationExtractor.node.ts @@ -13,15 +13,12 @@ import type { } from 'n8n-workflow'; import type { z } from 'zod'; +import { inputSchemaField, jsonSchemaExampleField, schemaTypeField } from '@utils/descriptions'; +import { convertJsonSchemaToZod, generateSchema } from '@utils/schemaParsing'; +import { getTracingConfig } from '@utils/tracing'; + import { makeZodSchemaFromAttributes } from './helpers'; import type { AttributeDefinition } from './types'; -import { - inputSchemaField, - jsonSchemaExampleField, - schemaTypeField, -} from '../../../utils/descriptions'; -import { convertJsonSchemaToZod, generateSchema } from '../../../utils/schemaParsing'; -import { getTracingConfig } from '../../../utils/tracing'; const SYSTEM_PROMPT_TEMPLATE = `You are an expert extraction algorithm. Only extract relevant information from the text. diff --git a/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/helpers.ts b/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/helpers.ts index acde52765ecc4..22f39f25a9b68 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/helpers.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/helpers.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; + import type { AttributeDefinition } from './types'; function makeAttributeSchema(attributeDefinition: AttributeDefinition, required: boolean = true) { diff --git a/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/test/InformationExtraction.node.test.ts b/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/test/InformationExtraction.node.test.ts index b4e4672dacc8e..725af39a60460 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/test/InformationExtraction.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/test/InformationExtraction.node.test.ts @@ -1,10 +1,10 @@ -import type { IDataObject, IExecuteFunctions } from 'n8n-workflow/src'; +import type { BaseLanguageModel } from '@langchain/core/language_models/base'; +import { FakeLLM, FakeListChatModel } from '@langchain/core/utils/testing'; import get from 'lodash/get'; +import type { IDataObject, IExecuteFunctions } from 'n8n-workflow/src'; -import { FakeLLM, FakeListChatModel } from '@langchain/core/utils/testing'; -import type { BaseLanguageModel } from '@langchain/core/language_models/base'; -import { InformationExtractor } from '../InformationExtractor.node'; import { makeZodSchemaFromAttributes } from '../helpers'; +import { InformationExtractor } from '../InformationExtractor.node'; import type { AttributeDefinition } from '../types'; const mockPersonAttributes: AttributeDefinition[] = [ 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 5f5c6f19dbd88..e810b0f98a05b 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/SentimentAnalysis/SentimentAnalysis.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/SentimentAnalysis/SentimentAnalysis.node.ts @@ -1,3 +1,8 @@ +import type { BaseLanguageModel } from '@langchain/core/language_models/base'; +import { HumanMessage } from '@langchain/core/messages'; +import { SystemMessagePromptTemplate, ChatPromptTemplate } from '@langchain/core/prompts'; +import { OutputFixingParser, StructuredOutputParser } from 'langchain/output_parsers'; +import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; import type { IDataObject, IExecuteFunctions, @@ -6,15 +11,9 @@ import type { INodeType, INodeTypeDescription, } from 'n8n-workflow'; - -import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; - -import type { BaseLanguageModel } from '@langchain/core/language_models/base'; -import { HumanMessage } from '@langchain/core/messages'; -import { SystemMessagePromptTemplate, ChatPromptTemplate } from '@langchain/core/prompts'; -import { OutputFixingParser, StructuredOutputParser } from 'langchain/output_parsers'; import { z } from 'zod'; -import { getTracingConfig } from '../../../utils/tracing'; + +import { getTracingConfig } from '@utils/tracing'; const DEFAULT_SYSTEM_PROMPT_TEMPLATE = 'You are highly intelligent and accurate sentiment analyzer. Analyze the sentiment of the provided text. Categorize it into one of the following: {categories}. Use the provided formatting instructions. Only output the JSON.'; 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 7afc317c37259..298c41572d6bc 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/TextClassifier/TextClassifier.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/TextClassifier/TextClassifier.node.ts @@ -13,7 +13,7 @@ import type { } from 'n8n-workflow'; import { z } from 'zod'; -import { getTracingConfig } from '../../../utils/tracing'; +import { getTracingConfig } from '@utils/tracing'; const SYSTEM_PROMPT_TEMPLATE = "Please classify the text provided by the user into one of the following categories: {categories}, and use the provided formatting instructions below. Don't explain, and only output the json."; diff --git a/packages/@n8n/nodes-langchain/nodes/code/Code.node.ts b/packages/@n8n/nodes-langchain/nodes/code/Code.node.ts index d73d6d3268d5a..dda3f2441456b 100644 --- a/packages/@n8n/nodes-langchain/nodes/code/Code.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/code/Code.node.ts @@ -1,4 +1,9 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import type { Tool } from '@langchain/core/tools'; +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 type { IExecuteFunctions, @@ -12,12 +17,7 @@ import type { // TODO: Add support for execute function. Got already started but got commented out -import { getSandboxContext } from 'n8n-nodes-base/dist/nodes/Code/Sandbox'; -import { JavaScriptSandbox } from 'n8n-nodes-base/dist/nodes/Code/JavaScriptSandbox'; -import { standardizeOutput } from 'n8n-nodes-base/dist/nodes/Code/utils'; -import type { Tool } from '@langchain/core/tools'; -import { makeResolverFromLegacyOptions } from '@n8n/vm2'; -import { logWrapper } from '../../utils/logWrapper'; +import { logWrapper } from '@utils/logWrapper'; const { NODE_FUNCTION_ALLOW_BUILTIN: builtIn, NODE_FUNCTION_ALLOW_EXTERNAL: external } = process.env; 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 2e68db4e693a9..5c9ebf08b0009 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,4 +1,5 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import type { TextSplitter } from '@langchain/textsplitters'; import { NodeConnectionType, type INodeType, @@ -7,11 +8,9 @@ import { type SupplyData, } from 'n8n-workflow'; -import type { TextSplitter } from '@langchain/textsplitters'; - -import { logWrapper } from '../../../utils/logWrapper'; -import { N8nBinaryLoader } from '../../../utils/N8nBinaryLoader'; -import { getConnectionHintNoticeField, metadataFilterField } from '../../../utils/sharedFields'; +import { logWrapper } from '@utils/logWrapper'; +import { N8nBinaryLoader } from '@utils/N8nBinaryLoader'; +import { getConnectionHintNoticeField, metadataFilterField } from '@utils/sharedFields'; // Dependencies needed underneath the hood for the loaders. We add them // here only to track where what dependency is sued 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 5e6457951e463..46e4120764f4b 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,4 +1,5 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import type { TextSplitter } from '@langchain/textsplitters'; import { NodeConnectionType, type INodeType, @@ -7,10 +8,10 @@ import { type SupplyData, } from 'n8n-workflow'; -import type { TextSplitter } from '@langchain/textsplitters'; -import { logWrapper } from '../../../utils/logWrapper'; -import { N8nBinaryLoader } from '../../../utils/N8nBinaryLoader'; -import { metadataFilterField } from '../../../utils/sharedFields'; +import { logWrapper } from '@utils/logWrapper'; +import { N8nBinaryLoader } from '@utils/N8nBinaryLoader'; +import { N8nJsonLoader } from '@utils/N8nJsonLoader'; +import { metadataFilterField } from '@utils/sharedFields'; // Dependencies needed underneath the hood for the loaders. We add them // here only to track where what dependency is sued @@ -18,7 +19,6 @@ import { metadataFilterField } from '../../../utils/sharedFields'; import 'mammoth'; // for docx import 'epub2'; // for epub import 'pdf-parse'; // for pdf -import { N8nJsonLoader } from '../../../utils/N8nJsonLoader'; export class DocumentDefaultDataLoader implements INodeType { description: INodeTypeDescription = { 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 71a77f013c162..7d63e32f0b5c3 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 @@ -1,4 +1,6 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import { GithubRepoLoader } from '@langchain/community/document_loaders/web/github'; +import type { CharacterTextSplitter } from '@langchain/textsplitters'; import { NodeConnectionType, type INodeType, @@ -6,10 +8,9 @@ import { type ISupplyDataFunctions, type SupplyData, } from 'n8n-workflow'; -import { GithubRepoLoader } from '@langchain/community/document_loaders/web/github'; -import type { CharacterTextSplitter } from '@langchain/textsplitters'; -import { logWrapper } from '../../../utils/logWrapper'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; + +import { logWrapper } from '@utils/logWrapper'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; export class DocumentGithubLoader implements INodeType { description: INodeTypeDescription = { 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 2e8cb95a11cb7..9c295ba144a96 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,4 +1,5 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import type { TextSplitter } from '@langchain/textsplitters'; import { NodeConnectionType, type INodeType, @@ -7,10 +8,9 @@ import { type SupplyData, } from 'n8n-workflow'; -import type { TextSplitter } from '@langchain/textsplitters'; -import { logWrapper } from '../../../utils/logWrapper'; -import { N8nJsonLoader } from '../../../utils/N8nJsonLoader'; -import { getConnectionHintNoticeField, metadataFilterField } from '../../../utils/sharedFields'; +import { logWrapper } from '@utils/logWrapper'; +import { N8nJsonLoader } from '@utils/N8nJsonLoader'; +import { getConnectionHintNoticeField, metadataFilterField } from '@utils/sharedFields'; export class DocumentJsonInputLoader implements INodeType { description: INodeTypeDescription = { 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 6e0782f1c1ac1..fdb2da5ce0e3b 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAwsBedrock/EmbeddingsAwsBedrock.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAwsBedrock/EmbeddingsAwsBedrock.node.ts @@ -8,8 +8,8 @@ import { type SupplyData, } from 'n8n-workflow'; -import { logWrapper } from '../../../utils/logWrapper'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; +import { logWrapper } from '@utils/logWrapper'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; export class EmbeddingsAwsBedrock implements INodeType { description: INodeTypeDescription = { 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 bf101292f2e05..65f493d578f28 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAzureOpenAi/EmbeddingsAzureOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAzureOpenAi/EmbeddingsAzureOpenAi.node.ts @@ -1,4 +1,5 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import { OpenAIEmbeddings } from '@langchain/openai'; import { NodeConnectionType, type INodeType, @@ -7,9 +8,8 @@ import { type SupplyData, } from 'n8n-workflow'; -import { OpenAIEmbeddings } from '@langchain/openai'; -import { logWrapper } from '../../../utils/logWrapper'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; +import { logWrapper } from '@utils/logWrapper'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; export class EmbeddingsAzureOpenAi implements INodeType { description: INodeTypeDescription = { 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 26e5d39b7075e..ebab22ec558e2 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsCohere/EmbeddingsCohere.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsCohere/EmbeddingsCohere.node.ts @@ -1,4 +1,5 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import { CohereEmbeddings } from '@langchain/cohere'; import { NodeConnectionType, type INodeType, @@ -6,9 +7,9 @@ import { type ISupplyDataFunctions, type SupplyData, } from 'n8n-workflow'; -import { CohereEmbeddings } from '@langchain/cohere'; -import { logWrapper } from '../../../utils/logWrapper'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; + +import { logWrapper } from '@utils/logWrapper'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; export class EmbeddingsCohere implements INodeType { description: INodeTypeDescription = { 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 2a455e4574bc1..949d6ee24e03b 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsGoogleGemini/EmbeddingsGoogleGemini.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsGoogleGemini/EmbeddingsGoogleGemini.node.ts @@ -1,4 +1,5 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import { GoogleGenerativeAIEmbeddings } from '@langchain/google-genai'; import { NodeConnectionType, type INodeType, @@ -6,10 +7,9 @@ import { type ISupplyDataFunctions, type SupplyData, } from 'n8n-workflow'; -import { GoogleGenerativeAIEmbeddings } from '@langchain/google-genai'; -import { logWrapper } from '../../../utils/logWrapper'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; +import { logWrapper } from '@utils/logWrapper'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; export class EmbeddingsGoogleGemini implements INodeType { description: INodeTypeDescription = { 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 c8317630c36c2..c8023354ef185 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsHuggingFaceInference/EmbeddingsHuggingFaceInference.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsHuggingFaceInference/EmbeddingsHuggingFaceInference.node.ts @@ -1,4 +1,5 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import { HuggingFaceInferenceEmbeddings } from '@langchain/community/embeddings/hf'; import { NodeConnectionType, type INodeType, @@ -6,9 +7,9 @@ import { type ISupplyDataFunctions, type SupplyData, } from 'n8n-workflow'; -import { HuggingFaceInferenceEmbeddings } from '@langchain/community/embeddings/hf'; -import { logWrapper } from '../../../utils/logWrapper'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; + +import { logWrapper } from '@utils/logWrapper'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; export class EmbeddingsHuggingFaceInference implements INodeType { description: INodeTypeDescription = { 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 dbfb93b82e080..553abfa406247 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsMistralCloud/EmbeddingsMistralCloud.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsMistralCloud/EmbeddingsMistralCloud.node.ts @@ -1,4 +1,6 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import type { MistralAIEmbeddingsParams } from '@langchain/mistralai'; +import { MistralAIEmbeddings } from '@langchain/mistralai'; import { NodeConnectionType, type INodeType, @@ -6,10 +8,9 @@ import { type ISupplyDataFunctions, type SupplyData, } from 'n8n-workflow'; -import type { MistralAIEmbeddingsParams } from '@langchain/mistralai'; -import { MistralAIEmbeddings } from '@langchain/mistralai'; -import { logWrapper } from '../../../utils/logWrapper'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; + +import { logWrapper } from '@utils/logWrapper'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; export class EmbeddingsMistralCloud implements INodeType { description: INodeTypeDescription = { 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 d84aa537ec5e5..08feb90309e0f 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOllama/EmbeddingsOllama.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOllama/EmbeddingsOllama.node.ts @@ -1,4 +1,5 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import { OllamaEmbeddings } from '@langchain/ollama'; import { NodeConnectionType, type INodeType, @@ -6,9 +7,10 @@ import { type ISupplyDataFunctions, type SupplyData, } from 'n8n-workflow'; -import { OllamaEmbeddings } from '@langchain/ollama'; -import { logWrapper } from '../../../utils/logWrapper'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; + +import { logWrapper } from '@utils/logWrapper'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; + import { ollamaDescription, ollamaModel } from '../../llms/LMOllama/description'; export class EmbeddingsOllama implements INodeType { 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 aececc09aed74..cd44cb114b870 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts @@ -1,4 +1,5 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import { OpenAIEmbeddings } from '@langchain/openai'; import { NodeConnectionType, type INodeType, @@ -7,11 +8,10 @@ import { type ISupplyDataFunctions, type INodeProperties, } from 'n8n-workflow'; - import type { ClientOptions } from 'openai'; -import { OpenAIEmbeddings } from '@langchain/openai'; -import { logWrapper } from '../../../utils/logWrapper'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; + +import { logWrapper } from '@utils/logWrapper'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; const modelParameter: INodeProperties = { displayName: 'Model', @@ -79,7 +79,7 @@ export class EmbeddingsOpenAi implements INodeType { }, ], group: ['transform'], - version: [1, 1.1], + version: [1, 1.1, 1.2], description: 'Use Embeddings OpenAI', defaults: { name: 'Embeddings OpenAI', @@ -106,7 +106,7 @@ export class EmbeddingsOpenAi implements INodeType { requestDefaults: { ignoreHttpStatusErrors: true, baseURL: - '={{ $parameter.options?.baseURL?.split("/").slice(0,-1).join("/") || "https://api.openai.com" }}', + '={{ $parameter.options?.baseURL?.split("/").slice(0,-1).join("/") || $credentials.url?.split("/").slice(0,-1).join("/") || "https://api.openai.com" }}', }, properties: [ getConnectionHintNoticeField([NodeConnectionType.AiVectorStore]), @@ -171,6 +171,11 @@ export class EmbeddingsOpenAi implements INodeType { default: 'https://api.openai.com/v1', description: 'Override the default base URL for the API', type: 'string', + displayOptions: { + hide: { + '@version': [{ _cnd: { gte: 1.2 } }], + }, + }, }, { displayName: 'Batch Size', @@ -219,6 +224,8 @@ export class EmbeddingsOpenAi implements INodeType { const configuration: ClientOptions = {}; if (options.baseURL) { configuration.baseURL = options.baseURL; + } else if (credentials.url) { + configuration.baseURL = credentials.url as string; } const embeddings = new OpenAIEmbeddings( 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 c575b59aa87c0..3a38ce3a313d2 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/LmChatAnthropic.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/LmChatAnthropic.node.ts @@ -12,9 +12,10 @@ import { type SupplyData, } from 'n8n-workflow'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; -import { N8nLlmTracing } from '../N8nLlmTracing'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; + import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; +import { N8nLlmTracing } from '../N8nLlmTracing'; const modelField: INodeProperties = { displayName: 'Model', 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 354e54e27ad34..d4685fa802d47 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOllama/LmChatOllama.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOllama/LmChatOllama.node.ts @@ -1,4 +1,7 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ + +import type { ChatOllamaInput } from '@langchain/ollama'; +import { ChatOllama } from '@langchain/ollama'; import { NodeConnectionType, type INodeType, @@ -7,12 +10,11 @@ import { type SupplyData, } from 'n8n-workflow'; -import type { ChatOllamaInput } from '@langchain/ollama'; -import { ChatOllama } from '@langchain/ollama'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; + import { ollamaModel, ollamaOptions, ollamaDescription } from '../LMOllama/description'; -import { N8nLlmTracing } from '../N8nLlmTracing'; import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; +import { N8nLlmTracing } from '../N8nLlmTracing'; export class LmChatOllama implements INodeType { description: INodeTypeDescription = { 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 2e55e567222c5..cf24d944dea44 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts @@ -9,7 +9,8 @@ import { type SupplyData, } from 'n8n-workflow'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; + import { openAiFailedAttemptHandler } from '../../vendors/OpenAi/helpers/error-handling'; import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; import { N8nLlmTracing } from '../N8nLlmTracing'; @@ -21,7 +22,7 @@ export class LmChatOpenAi implements INodeType { name: 'lmChatOpenAi', icon: { light: 'file:openAiLight.svg', dark: 'file:openAiLight.dark.svg' }, group: ['transform'], - version: 1, + version: [1, 1.1], description: 'For advanced usage with an AI chain', defaults: { name: 'OpenAI Chat Model', @@ -54,7 +55,7 @@ export class LmChatOpenAi implements INodeType { requestDefaults: { ignoreHttpStatusErrors: true, baseURL: - '={{ $parameter.options?.baseURL?.split("/").slice(0,-1).join("/") || "https://api.openai.com" }}', + '={{ $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]), @@ -81,7 +82,7 @@ export class LmChatOpenAi implements INodeType { routing: { request: { method: 'GET', - url: '={{ $parameter.options?.baseURL?.split("/").slice(-1).pop() || "v1" }}/models', + url: '={{ $parameter.options?.baseURL?.split("/").slice(-1).pop() || $credentials?.url?.split("/").slice(-1).pop() || "v1" }}/models', }, output: { postReceive: [ @@ -97,6 +98,7 @@ export class LmChatOpenAi implements INodeType { // If the baseURL is not set or is set to api.openai.com, include only chat models pass: `={{ ($parameter.options?.baseURL && !$parameter.options?.baseURL?.includes('api.openai.com')) || + ($credentials?.url && !$credentials.url.includes('api.openai.com')) || $responseItem.id.startsWith('ft:') || $responseItem.id.startsWith('o1') || ($responseItem.id.startsWith('gpt-') && !$responseItem.id.includes('instruct')) @@ -155,6 +157,11 @@ export class LmChatOpenAi implements INodeType { default: 'https://api.openai.com/v1', description: 'Override the default base URL for the API', type: 'string', + displayOptions: { + hide: { + '@version': [{ _cnd: { gte: 1.1 } }], + }, + }, }, { displayName: 'Frequency Penalty', @@ -260,6 +267,8 @@ export class LmChatOpenAi implements INodeType { const configuration: ClientOptions = {}; if (options.baseURL) { configuration.baseURL = options.baseURL; + } else if (credentials.url) { + configuration.baseURL = credentials.url as string; } const model = new ChatOpenAI({ 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 4b5f85f915a3a..6b9559104b560 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMCohere/LmCohere.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMCohere/LmCohere.node.ts @@ -1,4 +1,5 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import { Cohere } from '@langchain/cohere'; import { NodeConnectionType, type INodeType, @@ -7,10 +8,10 @@ import { type SupplyData, } from 'n8n-workflow'; -import { Cohere } from '@langchain/cohere'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; -import { N8nLlmTracing } from '../N8nLlmTracing'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; + import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; +import { N8nLlmTracing } from '../N8nLlmTracing'; export class LmCohere implements INodeType { description: INodeTypeDescription = { 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 ddd565e3a9321..21a7a0c50f73c 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMOllama/LmOllama.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMOllama/LmOllama.node.ts @@ -1,4 +1,6 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ + +import { Ollama } from '@langchain/community/llms/ollama'; import { NodeConnectionType, type INodeType, @@ -7,11 +9,11 @@ import { type SupplyData, } from 'n8n-workflow'; -import { Ollama } from '@langchain/community/llms/ollama'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; -import { N8nLlmTracing } from '../N8nLlmTracing'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; + import { ollamaDescription, ollamaModel, ollamaOptions } from './description'; import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; +import { N8nLlmTracing } from '../N8nLlmTracing'; export class LmOllama implements INodeType { description: INodeTypeDescription = { 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 41cb6222949cb..1a64f07cca644 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMOpenAi/LmOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMOpenAi/LmOpenAi.node.ts @@ -1,4 +1,5 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import { OpenAI, type ClientOptions } from '@langchain/openai'; import { NodeConnectionType } from 'n8n-workflow'; import type { INodeType, @@ -8,9 +9,8 @@ import type { ILoadOptionsFunctions, } from 'n8n-workflow'; -import { OpenAI, type ClientOptions } from '@langchain/openai'; -import { N8nLlmTracing } from '../N8nLlmTracing'; import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; +import { N8nLlmTracing } from '../N8nLlmTracing'; type LmOpenAiOptions = { baseURL?: string; 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 7823c91b52759..e393d86f8a394 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMOpenHuggingFaceInference/LmOpenHuggingFaceInference.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMOpenHuggingFaceInference/LmOpenHuggingFaceInference.node.ts @@ -1,4 +1,5 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import { HuggingFaceInference } from '@langchain/community/llms/hf'; import { NodeConnectionType, type INodeType, @@ -7,10 +8,10 @@ import { type SupplyData, } from 'n8n-workflow'; -import { HuggingFaceInference } from '@langchain/community/llms/hf'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; -import { N8nLlmTracing } from '../N8nLlmTracing'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; + import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; +import { N8nLlmTracing } from '../N8nLlmTracing'; export class LmOpenHuggingFaceInference implements INodeType { description: INodeTypeDescription = { 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 3d928ce801e8e..ef15a531cfce4 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.ts @@ -8,9 +8,10 @@ import { type SupplyData, } from 'n8n-workflow'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; -import { N8nLlmTracing } from '../N8nLlmTracing'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; + import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; +import { N8nLlmTracing } from '../N8nLlmTracing'; export class LmChatAwsBedrock implements INodeType { description: INodeTypeDescription = { 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 e2292abc772aa..5fc562153d4f6 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/LmChatAzureOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/LmChatAzureOpenAi.node.ts @@ -1,4 +1,5 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import { ChatOpenAI } from '@langchain/openai'; import { NodeConnectionType, type INodeType, @@ -7,10 +8,10 @@ import { type SupplyData, } from 'n8n-workflow'; -import { ChatOpenAI } from '@langchain/openai'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; -import { N8nLlmTracing } from '../N8nLlmTracing'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; + import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; +import { N8nLlmTracing } from '../N8nLlmTracing'; export class LmChatAzureOpenAi implements INodeType { description: INodeTypeDescription = { 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 9bade1e26afa9..f8b7d2bb3ea37 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleGemini/LmChatGoogleGemini.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleGemini/LmChatGoogleGemini.node.ts @@ -1,4 +1,6 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import type { SafetySetting } from '@google/generative-ai'; +import { ChatGoogleGenerativeAI } from '@langchain/google-genai'; import { NodeConnectionType, type INodeType, @@ -6,12 +8,12 @@ import { type ISupplyDataFunctions, type SupplyData, } from 'n8n-workflow'; -import { ChatGoogleGenerativeAI } from '@langchain/google-genai'; -import type { SafetySetting } from '@google/generative-ai'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; -import { N8nLlmTracing } from '../N8nLlmTracing'; + +import { getConnectionHintNoticeField } from '@utils/sharedFields'; + import { additionalOptions } from '../gemini-common/additional-options'; import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; +import { N8nLlmTracing } from '../N8nLlmTracing'; export class LmChatGoogleGemini implements INodeType { description: INodeTypeDescription = { 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 92b51e534f36a..5ca6091378423 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.ts @@ -1,4 +1,8 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import type { SafetySetting } from '@google/generative-ai'; +import { ProjectsClient } from '@google-cloud/resource-manager'; +import { ChatVertexAI } from '@langchain/google-vertexai'; +import { formatPrivateKey } from 'n8n-nodes-base/dist/utils/utilities'; import { NodeConnectionType, type INodeType, @@ -9,15 +13,13 @@ import { type JsonObject, NodeOperationError, } from 'n8n-workflow'; -import { ChatVertexAI } from '@langchain/google-vertexai'; -import type { SafetySetting } from '@google/generative-ai'; -import { ProjectsClient } from '@google-cloud/resource-manager'; -import { formatPrivateKey } from 'n8n-nodes-base/dist/utils/utilities'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; -import { N8nLlmTracing } from '../N8nLlmTracing'; -import { additionalOptions } from '../gemini-common/additional-options'; + +import { getConnectionHintNoticeField } from '@utils/sharedFields'; + import { makeErrorFromStatus } from './error-handling'; +import { additionalOptions } from '../gemini-common/additional-options'; import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; +import { N8nLlmTracing } from '../N8nLlmTracing'; export class LmChatGoogleVertex implements INodeType { description: INodeTypeDescription = { 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 1494dbcf553e6..fb859e3fce077 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGroq/LmChatGroq.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGroq/LmChatGroq.node.ts @@ -1,4 +1,5 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import { ChatGroq } from '@langchain/groq'; import { NodeConnectionType, type INodeType, @@ -7,10 +8,10 @@ import { type SupplyData, } from 'n8n-workflow'; -import { ChatGroq } from '@langchain/groq'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; -import { N8nLlmTracing } from '../N8nLlmTracing'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; + import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; +import { N8nLlmTracing } from '../N8nLlmTracing'; export class LmChatGroq implements INodeType { description: INodeTypeDescription = { 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 edd533b6bae57..a23c2d4e9fa85 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatMistralCloud/LmChatMistralCloud.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatMistralCloud/LmChatMistralCloud.node.ts @@ -1,4 +1,7 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ + +import type { ChatMistralAIInput } from '@langchain/mistralai'; +import { ChatMistralAI } from '@langchain/mistralai'; import { NodeConnectionType, type INodeType, @@ -7,11 +10,10 @@ import { type SupplyData, } from 'n8n-workflow'; -import type { ChatMistralAIInput } from '@langchain/mistralai'; -import { ChatMistralAI } from '@langchain/mistralai'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; -import { N8nLlmTracing } from '../N8nLlmTracing'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; + import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; +import { N8nLlmTracing } from '../N8nLlmTracing'; export class LmChatMistralCloud implements INodeType { description: INodeTypeDescription = { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/N8nLlmTracing.ts b/packages/@n8n/nodes-langchain/nodes/llms/N8nLlmTracing.ts index af60b72982814..3d426309b7391 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/N8nLlmTracing.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/N8nLlmTracing.ts @@ -9,11 +9,11 @@ import type { import type { BaseMessage } from '@langchain/core/messages'; import type { LLMResult } from '@langchain/core/outputs'; import { encodingForModel } from '@langchain/core/utils/tiktoken'; -import type { IDataObject, ISupplyDataFunctions, JsonObject } from 'n8n-workflow'; import { pick } from 'lodash'; +import type { IDataObject, ISupplyDataFunctions, JsonObject } from 'n8n-workflow'; import { NodeConnectionType, NodeError, NodeOperationError } from 'n8n-workflow'; -import { logAiEvent } from '../../utils/helpers'; +import { logAiEvent } from '@utils/helpers'; type TokensUsageParser = (llmOutput: LLMResult['llmOutput']) => { completionTokens: number; diff --git a/packages/@n8n/nodes-langchain/nodes/llms/gemini-common/additional-options.ts b/packages/@n8n/nodes-langchain/nodes/llms/gemini-common/additional-options.ts index 3fc1900b8a56a..f154b676c5c8d 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/gemini-common/additional-options.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/gemini-common/additional-options.ts @@ -1,5 +1,6 @@ import type { HarmBlockThreshold, HarmCategory } from '@google/generative-ai'; import type { INodeProperties } from 'n8n-workflow'; + import { harmCategories, harmThresholds } from './safety-options'; export const additionalOptions: INodeProperties = { 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 28025f28845e9..ab023398169ac 100644 --- a/packages/@n8n/nodes-langchain/nodes/memory/MemoryBufferWindow/MemoryBufferWindow.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/memory/MemoryBufferWindow/MemoryBufferWindow.node.ts @@ -9,9 +9,10 @@ import { type SupplyData, } from 'n8n-workflow'; -import { getSessionId } from '../../../utils/helpers'; -import { logWrapper } from '../../../utils/logWrapper'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; +import { getSessionId } from '@utils/helpers'; +import { logWrapper } from '@utils/logWrapper'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; + import { sessionIdOption, sessionKeyProperty, @@ -31,14 +32,14 @@ class MemoryChatBufferSingleton { this.memoryBuffer = new Map(); } - public static getInstance(): MemoryChatBufferSingleton { + static getInstance(): MemoryChatBufferSingleton { if (!MemoryChatBufferSingleton.instance) { MemoryChatBufferSingleton.instance = new MemoryChatBufferSingleton(); } return MemoryChatBufferSingleton.instance; } - public async getMemory( + async getMemory( sessionKey: string, memoryParams: BufferWindowMemoryInput, ): Promise { @@ -77,6 +78,7 @@ export class MemoryBufferWindow implements INodeType { displayName: 'Window Buffer Memory (easiest)', name: 'memoryBufferWindow', icon: 'fa:database', + iconColor: 'black', group: ['transform'], version: [1, 1.1, 1.2, 1.3], description: 'Stores in n8n memory, so no credentials required', 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 f2bb0f43b0100..82fcba22a66ce 100644 --- a/packages/@n8n/nodes-langchain/nodes/memory/MemoryChatRetriever/MemoryChatRetriever.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/memory/MemoryChatRetriever/MemoryChatRetriever.node.ts @@ -1,4 +1,6 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import type { BaseChatMemory } from '@langchain/community/memory/chat_memory'; +import type { BaseMessage } from '@langchain/core/messages'; import { NodeConnectionType, type IDataObject, @@ -7,8 +9,6 @@ import { type INodeType, type INodeTypeDescription, } from 'n8n-workflow'; -import type { BaseChatMemory } from '@langchain/community/memory/chat_memory'; -import type { BaseMessage } from '@langchain/core/messages'; function simplifyMessages(messages: BaseMessage[]) { const chunkedMessages = []; @@ -38,6 +38,7 @@ export class MemoryChatRetriever implements INodeType { displayName: 'Chat Messages Retriever', name: 'memoryChatRetriever', icon: 'fa:database', + iconColor: 'black', group: ['transform'], hidden: true, version: 1, 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 04d6035e7fbf9..964da654756f6 100644 --- a/packages/@n8n/nodes-langchain/nodes/memory/MemoryManager/MemoryManager.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/memory/MemoryManager/MemoryManager.node.ts @@ -1,4 +1,6 @@ /* 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 type { IDataObject, @@ -7,8 +9,6 @@ import type { INodeType, INodeTypeDescription, } from 'n8n-workflow'; -import type { BaseChatMemory } from '@langchain/community/memory/chat_memory'; -import { AIMessage, SystemMessage, HumanMessage, type BaseMessage } from '@langchain/core/messages'; type MessageRole = 'ai' | 'system' | 'user'; interface MessageRecord { 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 a326f4c1bea4c..06fa387ee65a1 100644 --- a/packages/@n8n/nodes-langchain/nodes/memory/MemoryMotorhead/MemoryMotorhead.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/memory/MemoryMotorhead/MemoryMotorhead.node.ts @@ -8,9 +8,10 @@ import { type SupplyData, } from 'n8n-workflow'; -import { getSessionId } from '../../../utils/helpers'; -import { logWrapper } from '../../../utils/logWrapper'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; +import { getSessionId } from '@utils/helpers'; +import { logWrapper } from '@utils/logWrapper'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; + import { expressionSessionKeyProperty, sessionIdOption, sessionKeyProperty } from '../descriptions'; export class MemoryMotorhead implements INodeType { @@ -18,6 +19,7 @@ export class MemoryMotorhead implements INodeType { displayName: 'Motorhead', name: 'memoryMotorhead', icon: 'fa:file-export', + iconColor: 'black', group: ['transform'], version: [1, 1.1, 1.2, 1.3], description: 'Use Motorhead Memory', 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 b3d5f2f409128..18fd76e3c5463 100644 --- a/packages/@n8n/nodes-langchain/nodes/memory/MemoryPostgresChat/MemoryPostgresChat.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/memory/MemoryPostgresChat/MemoryPostgresChat.node.ts @@ -13,9 +13,10 @@ import type { import { NodeConnectionType } from 'n8n-workflow'; import type pg from 'pg'; -import { getSessionId } from '../../../utils/helpers'; -import { logWrapper } from '../../../utils/logWrapper'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; +import { getSessionId } from '@utils/helpers'; +import { logWrapper } from '@utils/logWrapper'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; + import { sessionIdOption, 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 09208d96f7f6d..ab7d02e2c4f9f 100644 --- a/packages/@n8n/nodes-langchain/nodes/memory/MemoryRedisChat/MemoryRedisChat.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/memory/MemoryRedisChat/MemoryRedisChat.node.ts @@ -13,9 +13,10 @@ import { import type { RedisClientOptions } from 'redis'; import { createClient } from 'redis'; -import { getSessionId } from '../../../utils/helpers'; -import { logWrapper } from '../../../utils/logWrapper'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; +import { getSessionId } from '@utils/helpers'; +import { logWrapper } from '@utils/logWrapper'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; + import { sessionIdOption, sessionKeyProperty, 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 c48f32976ba6a..c1ad7b9539f89 100644 --- a/packages/@n8n/nodes-langchain/nodes/memory/MemoryXata/MemoryXata.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/memory/MemoryXata/MemoryXata.node.ts @@ -10,9 +10,10 @@ import type { SupplyData, } from 'n8n-workflow'; -import { getSessionId } from '../../../utils/helpers'; -import { logWrapper } from '../../../utils/logWrapper'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; +import { getSessionId } from '@utils/helpers'; +import { logWrapper } from '@utils/logWrapper'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; + import { sessionIdOption, sessionKeyProperty, 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 3c9ab307e2733..1943f41c03960 100644 --- a/packages/@n8n/nodes-langchain/nodes/memory/MemoryZep/MemoryZep.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/memory/MemoryZep/MemoryZep.node.ts @@ -1,4 +1,9 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import type { BaseChatMemory } from '@langchain/community/dist/memory/chat_memory'; +import { ZepMemory } from '@langchain/community/memory/zep'; +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, type ISupplyDataFunctions, @@ -7,16 +12,12 @@ import { type SupplyData, NodeOperationError, } from 'n8n-workflow'; -import { ZepMemory } from '@langchain/community/memory/zep'; -import { ZepCloudMemory } from '@langchain/community/memory/zep_cloud'; -import { logWrapper } from '../../../utils/logWrapper'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; +import { getSessionId } from '@utils/helpers'; +import { logWrapper } from '@utils/logWrapper'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; + import { expressionSessionKeyProperty, sessionIdOption, sessionKeyProperty } from '../descriptions'; -import { getSessionId } from '../../../utils/helpers'; -import type { BaseChatMemory } from '@langchain/community/dist/memory/chat_memory'; -import type { InputValues, MemoryVariables } from '@langchain/core/memory'; -import type { BaseMessage } from '@langchain/core/messages'; // Extend ZepCloudMemory to trim white space in messages. class WhiteSpaceTrimmedZepCloudMemory extends ZepCloudMemory { diff --git a/packages/@n8n/nodes-langchain/nodes/memory/descriptions.ts b/packages/@n8n/nodes-langchain/nodes/memory/descriptions.ts index 4627671a9b5ae..fd0a022015129 100644 --- a/packages/@n8n/nodes-langchain/nodes/memory/descriptions.ts +++ b/packages/@n8n/nodes-langchain/nodes/memory/descriptions.ts @@ -6,10 +6,10 @@ export const sessionIdOption: INodeProperties = { type: 'options', options: [ { - // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased - name: 'Take from previous node automatically', + name: 'Connected Chat Trigger Node', value: 'fromInput', - description: 'Looks for an input field called sessionId', + description: + "Looks for an input field called 'sessionId' that is coming from a directly connected Chat Trigger", }, { // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased 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 4f385e1770014..f9e6cd2968516 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 @@ -8,18 +8,20 @@ import type { SupplyData, } from 'n8n-workflow'; -import { NAIVE_FIX_PROMPT } from './prompt'; import { N8nOutputFixingParser, type N8nStructuredOutputParser, -} from '../../../utils/output_parsers/N8nOutputParser'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; +} from '@utils/output_parsers/N8nOutputParser'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; + +import { NAIVE_FIX_PROMPT } from './prompt'; export class OutputParserAutofixing implements INodeType { description: INodeTypeDescription = { displayName: 'Auto-fixing Output Parser', name: 'outputParserAutofixing', icon: 'fa:tools', + iconColor: 'black', group: ['transform'], version: 1, description: 'Automatically fix the output if it is not in the correct format', 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 9fcae1a8fa3d3..45f054a34b1af 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 @@ -11,7 +11,8 @@ import { ApplicationError, NodeConnectionType, NodeOperationError } from 'n8n-wo import type { N8nOutputFixingParser, N8nStructuredOutputParser, -} from '../../../../utils/output_parsers/N8nOutputParser'; +} from '@utils/output_parsers/N8nOutputParser'; + import { OutputParserAutofixing } from '../OutputParserAutofixing.node'; import { NAIVE_FIX_PROMPT } from '../prompt'; 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 b613c14775ed9..b94b82fadac49 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 @@ -7,14 +7,15 @@ import { type SupplyData, } from 'n8n-workflow'; -import { N8nItemListOutputParser } from '../../../utils/output_parsers/N8nItemListOutputParser'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; +import { N8nItemListOutputParser } from '@utils/output_parsers/N8nItemListOutputParser'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; export class OutputParserItemList implements INodeType { description: INodeTypeDescription = { displayName: 'Item List Output Parser', name: 'outputParserItemList', icon: 'fa:bars', + iconColor: 'black', group: ['transform'], version: 1, description: 'Return the results as separate items', diff --git a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserItemList/test/OutputParserItemList.node.test.ts b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserItemList/test/OutputParserItemList.node.test.ts index c8ac869169e43..ae31e88353334 100644 --- a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserItemList/test/OutputParserItemList.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserItemList/test/OutputParserItemList.node.test.ts @@ -6,7 +6,8 @@ import { type IWorkflowDataProxyData, } from 'n8n-workflow'; -import { N8nItemListOutputParser } from '../../../../utils/output_parsers/N8nItemListOutputParser'; +import { N8nItemListOutputParser } from '@utils/output_parsers/N8nItemListOutputParser'; + import { OutputParserItemList } from '../OutputParserItemList.node'; describe('OutputParserItemList', () => { 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 c35cb1d1453b3..08690209970c3 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 @@ -10,20 +10,17 @@ import { } from 'n8n-workflow'; import type { z } from 'zod'; -import { - inputSchemaField, - jsonSchemaExampleField, - schemaTypeField, -} from '../../../utils/descriptions'; -import { N8nStructuredOutputParser } from '../../../utils/output_parsers/N8nOutputParser'; -import { convertJsonSchemaToZod, generateSchema } from '../../../utils/schemaParsing'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; +import { inputSchemaField, jsonSchemaExampleField, schemaTypeField } from '@utils/descriptions'; +import { N8nStructuredOutputParser } from '@utils/output_parsers/N8nOutputParser'; +import { convertJsonSchemaToZod, generateSchema } from '@utils/schemaParsing'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; export class OutputParserStructured implements INodeType { description: INodeTypeDescription = { displayName: 'Structured Output Parser', name: 'outputParserStructured', icon: 'fa:code', + iconColor: 'black', group: ['transform'], version: [1, 1.1, 1.2], defaultVersion: 1.2, diff --git a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/test/OutputParserStructured.node.test.ts b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/test/OutputParserStructured.node.test.ts index af72c49d7e66d..e07b012ec6232 100644 --- a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/test/OutputParserStructured.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/test/OutputParserStructured.node.test.ts @@ -7,7 +7,8 @@ import { type IWorkflowDataProxyData, } from 'n8n-workflow'; -import type { N8nStructuredOutputParser } from '../../../../utils/output_parsers/N8nStructuredOutputParser'; +import type { N8nStructuredOutputParser } from '@utils/output_parsers/N8nStructuredOutputParser'; + import { OutputParserStructured } from '../OutputParserStructured.node'; describe('OutputParserStructured', () => { 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 8017caa1ad4b7..feb70ecb43d31 100644 --- a/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverContextualCompression/RetrieverContextualCompression.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverContextualCompression/RetrieverContextualCompression.node.ts @@ -1,4 +1,9 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ + +import type { BaseLanguageModel } from '@langchain/core/language_models/base'; +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, type INodeType, @@ -7,18 +12,14 @@ import { type SupplyData, } from 'n8n-workflow'; -import { ContextualCompressionRetriever } from 'langchain/retrievers/contextual_compression'; -import { LLMChainExtractor } from 'langchain/retrievers/document_compressors/chain_extract'; -import type { BaseLanguageModel } from '@langchain/core/language_models/base'; -import type { BaseRetriever } from '@langchain/core/retrievers'; - -import { logWrapper } from '../../../utils/logWrapper'; +import { logWrapper } from '@utils/logWrapper'; export class RetrieverContextualCompression implements INodeType { description: INodeTypeDescription = { displayName: 'Contextual Compression Retriever', name: 'retrieverContextualCompression', icon: 'fa:box-open', + iconColor: 'black', group: ['transform'], version: 1, description: 'Enhances document similarity search by contextual compression.', 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 f814ba875ea06..4bbc45f6d14aa 100644 --- a/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverMultiQuery/RetrieverMultiQuery.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverMultiQuery/RetrieverMultiQuery.node.ts @@ -1,4 +1,8 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ + +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, type INodeType, @@ -7,17 +11,14 @@ import { type SupplyData, } from 'n8n-workflow'; -import { MultiQueryRetriever } from 'langchain/retrievers/multi_query'; -import type { BaseLanguageModel } from '@langchain/core/language_models/base'; -import type { BaseRetriever } from '@langchain/core/retrievers'; - -import { logWrapper } from '../../../utils/logWrapper'; +import { logWrapper } from '@utils/logWrapper'; export class RetrieverMultiQuery implements INodeType { description: INodeTypeDescription = { displayName: 'MultiQuery Retriever', name: 'retrieverMultiQuery', icon: 'fa:box-open', + iconColor: 'black', group: ['transform'], version: 1, description: 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 5e79a6a754152..915d9766dccec 100644 --- a/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverVectorStore/RetrieverVectorStore.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverVectorStore/RetrieverVectorStore.node.ts @@ -1,4 +1,5 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import type { VectorStore } from '@langchain/core/vectorstores'; import { NodeConnectionType, type INodeType, @@ -6,14 +7,15 @@ import { type ISupplyDataFunctions, type SupplyData, } from 'n8n-workflow'; -import type { VectorStore } from '@langchain/core/vectorstores'; -import { logWrapper } from '../../../utils/logWrapper'; + +import { logWrapper } from '@utils/logWrapper'; export class RetrieverVectorStore implements INodeType { description: INodeTypeDescription = { displayName: 'Vector Store Retriever', name: 'retrieverVectorStore', icon: 'fa:box-open', + iconColor: 'black', group: ['transform'], version: 1, description: 'Use a Vector Store as Retriever', 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 bbee55399946e..1291b92252f36 100644 --- a/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverWorkflow/RetrieverWorkflow.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverWorkflow/RetrieverWorkflow.node.ts @@ -1,4 +1,9 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import type { CallbackManagerForRetrieverRun } from '@langchain/core/callbacks/manager'; +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 type { IDataObject, @@ -13,13 +18,7 @@ import type { ExecuteWorkflowData, } from 'n8n-workflow'; -import { BaseRetriever, type BaseRetrieverInput } from '@langchain/core/retrievers'; -import { Document } from '@langchain/core/documents'; - -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 type { CallbackManagerForRetrieverRun } from '@langchain/core/callbacks/manager'; -import { logWrapper } from '../../../utils/logWrapper'; +import { logWrapper } from '@utils/logWrapper'; function objectToString(obj: Record | IDataObject, level = 0) { let result = ''; @@ -42,6 +41,7 @@ export class RetrieverWorkflow implements INodeType { displayName: 'Workflow Retriever', name: 'retrieverWorkflow', icon: 'fa:box-open', + iconColor: 'black', group: ['transform'], version: [1, 1.1], description: 'Use an n8n Workflow as Retriever', 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 f62e8f01f1741..962af5bde2321 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 @@ -1,4 +1,6 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import type { CharacterTextSplitterParams } from '@langchain/textsplitters'; +import { CharacterTextSplitter } from '@langchain/textsplitters'; import { NodeConnectionType, type INodeType, @@ -6,16 +8,16 @@ import { type ISupplyDataFunctions, type SupplyData, } from 'n8n-workflow'; -import type { CharacterTextSplitterParams } from '@langchain/textsplitters'; -import { CharacterTextSplitter } from '@langchain/textsplitters'; -import { logWrapper } from '../../../utils/logWrapper'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; + +import { logWrapper } from '@utils/logWrapper'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; export class TextSplitterCharacterTextSplitter implements INodeType { description: INodeTypeDescription = { displayName: 'Character Text Splitter', name: 'textSplitterCharacterTextSplitter', icon: 'fa:grip-lines-vertical', + iconColor: 'black', group: ['transform'], version: 1, description: 'Split text into chunks by characters', 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 21a0520766555..4e376c39a37d2 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 @@ -1,4 +1,9 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import type { + RecursiveCharacterTextSplitterParams, + SupportedTextSplitterLanguage, +} from '@langchain/textsplitters'; +import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'; import { NodeConnectionType, type INodeType, @@ -6,13 +11,9 @@ import { type ISupplyDataFunctions, type SupplyData, } from 'n8n-workflow'; -import type { - RecursiveCharacterTextSplitterParams, - SupportedTextSplitterLanguage, -} from '@langchain/textsplitters'; -import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'; -import { logWrapper } from '../../../utils/logWrapper'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; + +import { logWrapper } from '@utils/logWrapper'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; const supportedLanguages: SupportedTextSplitterLanguage[] = [ 'cpp', @@ -36,6 +37,7 @@ export class TextSplitterRecursiveCharacterTextSplitter implements INodeType { displayName: 'Recursive Character Text Splitter', name: 'textSplitterRecursiveCharacterTextSplitter', icon: 'fa:grip-lines-vertical', + iconColor: 'black', group: ['transform'], version: 1, description: 'Split text into chunks by characters recursively, recommended for most use cases', 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 247d142fa8c93..b5dade396d767 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,4 +1,5 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import { TokenTextSplitter } from '@langchain/textsplitters'; import { NodeConnectionType, type INodeType, @@ -6,15 +7,16 @@ import { type ISupplyDataFunctions, type SupplyData, } from 'n8n-workflow'; -import { TokenTextSplitter } from '@langchain/textsplitters'; -import { logWrapper } from '../../../utils/logWrapper'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; + +import { logWrapper } from '@utils/logWrapper'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; export class TextSplitterTokenSplitter implements INodeType { description: INodeTypeDescription = { displayName: 'Token Splitter', name: 'textSplitterTokenSplitter', icon: 'fa:grip-lines-vertical', + iconColor: 'black', group: ['transform'], version: 1, description: 'Split text into chunks by tokens', 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 f50a6216c044c..6d67a04555138 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolCalculator/ToolCalculator.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolCalculator/ToolCalculator.node.ts @@ -1,4 +1,5 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import { Calculator } from '@langchain/community/tools/calculator'; import { NodeConnectionType, type INodeType, @@ -6,15 +7,16 @@ import { type ISupplyDataFunctions, type SupplyData, } from 'n8n-workflow'; -import { Calculator } from '@langchain/community/tools/calculator'; -import { logWrapper } from '../../../utils/logWrapper'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; + +import { logWrapper } from '@utils/logWrapper'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; export class ToolCalculator implements INodeType { description: INodeTypeDescription = { displayName: 'Calculator', name: 'toolCalculator', icon: 'fa:calculator', + iconColor: 'black', group: ['transform'], version: 1, description: 'Make it easier for AI agents to perform arithmetic', 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 1491662e610f3..029bce48f68f7 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts @@ -15,20 +15,18 @@ import type { } from 'n8n-workflow'; import { jsonParse, NodeConnectionType, NodeOperationError } from 'n8n-workflow'; +import { inputSchemaField, jsonSchemaExampleField, schemaTypeField } from '@utils/descriptions'; +import { convertJsonSchemaToZod, generateSchema } from '@utils/schemaParsing'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; + import type { DynamicZodObject } from '../../../types/zod.types'; -import { - inputSchemaField, - jsonSchemaExampleField, - schemaTypeField, -} from '../../../utils/descriptions'; -import { convertJsonSchemaToZod, generateSchema } from '../../../utils/schemaParsing'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; export class ToolCode implements INodeType { description: INodeTypeDescription = { displayName: 'Code Tool', name: 'toolCode', icon: 'fa:code', + iconColor: 'black', group: ['transform'], version: [1, 1.1], description: 'Write a tool in JS or Python', 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 f279c1e751635..bfdd3e7ace4df 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts @@ -1,4 +1,5 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import { DynamicTool } from '@langchain/core/tools'; import type { INodeType, INodeTypeDescription, @@ -9,19 +10,8 @@ import type { } from 'n8n-workflow'; import { NodeConnectionType, NodeOperationError, tryToParseAlphanumericString } from 'n8n-workflow'; -import { DynamicTool } from '@langchain/core/tools'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; - -import { N8nTool } from '../../../utils/N8nTool'; -import { - configureHttpRequestFunction, - configureResponseOptimizer, - extractParametersFromText, - prepareToolDescription, - configureToolFunction, - updateParametersAndOptions, - makeToolInputSchema, -} from './utils'; +import { N8nTool } from '@utils/N8nTool'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; import { authenticationProperties, @@ -31,8 +21,16 @@ import { placeholderDefinitionsCollection, specifyBySelector, } from './descriptions'; - import type { PlaceholderDefinition, ToolParameter } from './interfaces'; +import { + configureHttpRequestFunction, + configureResponseOptimizer, + extractParametersFromText, + prepareToolDescription, + configureToolFunction, + updateParametersAndOptions, + makeToolInputSchema, +} from './utils'; export class ToolHttpRequest implements INodeType { description: INodeTypeDescription = { diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/test/ToolHttpRequest.node.test.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/test/ToolHttpRequest.node.test.ts index 1a99896fff4b1..05ed1e619c2ac 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/test/ToolHttpRequest.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/test/ToolHttpRequest.node.test.ts @@ -2,7 +2,8 @@ import { mock } from 'jest-mock-extended'; import type { IExecuteFunctions, INode } from 'n8n-workflow'; import { jsonParse } from 'n8n-workflow'; -import type { N8nTool } from '../../../../utils/N8nTool'; +import type { N8nTool } from '@utils/N8nTool'; + import { ToolHttpRequest } from '../ToolHttpRequest.node'; describe('ToolHttpRequest', () => { 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 709b06b7ac02e..7a7a09b933a99 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolSerpApi/ToolSerpApi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolSerpApi/ToolSerpApi.node.ts @@ -1,4 +1,5 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import { SerpAPI } from '@langchain/community/tools/serpapi'; import { NodeConnectionType, type INodeType, @@ -6,9 +7,9 @@ import { type ISupplyDataFunctions, type SupplyData, } from 'n8n-workflow'; -import { SerpAPI } from '@langchain/community/tools/serpapi'; -import { logWrapper } from '../../../utils/logWrapper'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; + +import { logWrapper } from '@utils/logWrapper'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; export class ToolSerpApi implements INodeType { description: INodeTypeDescription = { 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 6f4aa19fb3bad..aaa2ca37d96aa 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolVectorStore/ToolVectorStore.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolVectorStore/ToolVectorStore.node.ts @@ -1,3 +1,7 @@ +import type { BaseLanguageModel } from '@langchain/core/language_models/base'; +import type { VectorStore } from '@langchain/core/vectorstores'; +import { VectorDBQAChain } from 'langchain/chains'; +import { VectorStoreQATool } from 'langchain/tools'; import type { INodeType, INodeTypeDescription, @@ -6,18 +10,15 @@ import type { } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow'; -import { VectorStoreQATool } from 'langchain/tools'; -import type { VectorStore } from '@langchain/core/vectorstores'; -import type { BaseLanguageModel } from '@langchain/core/language_models/base'; -import { VectorDBQAChain } from 'langchain/chains'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; -import { logWrapper } from '../../../utils/logWrapper'; +import { logWrapper } from '@utils/logWrapper'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; export class ToolVectorStore implements INodeType { description: INodeTypeDescription = { displayName: 'Vector Store Tool', name: 'toolVectorStore', icon: 'fa:database', + iconColor: 'black', group: ['transform'], version: [1], description: 'Retrieve context from vector store', 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 e462e38febd0c..4eef3a1b450cb 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWikipedia/ToolWikipedia.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWikipedia/ToolWikipedia.node.ts @@ -1,4 +1,5 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import { WikipediaQueryRun } from '@langchain/community/tools/wikipedia_query_run'; import { NodeConnectionType, type INodeType, @@ -6,9 +7,9 @@ import { type ISupplyDataFunctions, type SupplyData, } from 'n8n-workflow'; -import { WikipediaQueryRun } from '@langchain/community/tools/wikipedia_query_run'; -import { logWrapper } from '../../../utils/logWrapper'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; + +import { logWrapper } from '@utils/logWrapper'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; export class ToolWikipedia implements INodeType { description: INodeTypeDescription = { 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 93290e63ad765..162b78ba8ec72 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWolframAlpha/ToolWolframAlpha.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWolframAlpha/ToolWolframAlpha.node.ts @@ -1,4 +1,5 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import { WolframAlphaTool } from '@langchain/community/tools/wolframalpha'; import { NodeConnectionType, type INodeType, @@ -6,9 +7,9 @@ import { type ISupplyDataFunctions, type SupplyData, } from 'n8n-workflow'; -import { WolframAlphaTool } from '@langchain/community/tools/wolframalpha'; -import { logWrapper } from '../../../utils/logWrapper'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; + +import { logWrapper } from '@utils/logWrapper'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; export class ToolWolframAlpha implements INodeType { description: INodeTypeDescription = { diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts index d85ff722712b2..de7abf6a8b6ea 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts @@ -1,569 +1,42 @@ -import type { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager'; -import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools'; -import type { JSONSchema7 } from 'json-schema'; -import get from 'lodash/get'; -import isObject from 'lodash/isObject'; -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 type { - IExecuteWorkflowInfo, - INodeExecutionData, - INodeType, - INodeTypeDescription, - IWorkflowBase, - ISupplyDataFunctions, - SupplyData, - ExecutionError, - ExecuteWorkflowData, - IDataObject, - INodeParameterResourceLocator, - ITaskMetadata, -} from 'n8n-workflow'; -import { NodeConnectionType, NodeOperationError, jsonParse } from 'n8n-workflow'; - -import type { DynamicZodObject } from '../../../types/zod.types'; -import { - jsonSchemaExampleField, - schemaTypeField, - inputSchemaField, -} from '../../../utils/descriptions'; -import { convertJsonSchemaToZod, generateSchema } from '../../../utils/schemaParsing'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; - -export class ToolWorkflow implements INodeType { - description: INodeTypeDescription = { - displayName: 'Call n8n Workflow Tool', - name: 'toolWorkflow', - icon: 'fa:network-wired', - group: ['transform'], - version: [1, 1.1, 1.2, 1.3], - description: 'Uses another n8n workflow as a tool. Allows packaging any n8n node(s) as a tool.', - defaults: { - name: 'Call n8n Workflow Tool', - }, - codex: { - categories: ['AI'], - subcategories: { - AI: ['Tools'], - Tools: ['Recommended Tools'], - }, - resources: { - primaryDocumentation: [ - { - url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolworkflow/', - }, - ], - }, - }, - // 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], - outputNames: ['Tool'], - properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiAgent]), - { - displayName: - 'See an example of a workflow to suggest meeting slots using AI here.', - name: 'noticeTemplateExample', - type: 'notice', - default: '', - }, - { - displayName: 'Name', - name: 'name', - type: 'string', - default: '', - placeholder: 'My_Color_Tool', - displayOptions: { - show: { - '@version': [1], - }, - }, - }, - { - displayName: 'Name', - name: 'name', - type: 'string', - default: '', - placeholder: 'e.g. My_Color_Tool', - validateType: 'string-alphanumeric', - description: - 'The name of the function to be called, could contain letters, numbers, and underscores only', - displayOptions: { - show: { - '@version': [{ _cnd: { gte: 1.1 } }], - }, - }, - }, - { - displayName: 'Description', - name: 'description', - type: 'string', - default: '', - placeholder: - 'Call this tool to get a random color. The input should be a string with comma separted names of colors to exclude.', - typeOptions: { - rows: 3, - }, - }, - - { - displayName: - 'This tool will call the workflow you define below, and look in the last node for the response. The workflow needs to start with an Execute Workflow trigger', - name: 'executeNotice', - type: 'notice', - default: '', - }, - - { - displayName: 'Source', - name: 'source', - type: 'options', - options: [ - { - name: 'Database', - value: 'database', - description: 'Load the workflow from the database by ID', - }, - { - name: 'Define Below', - value: 'parameter', - description: 'Pass the JSON code of a workflow', - }, - ], - default: 'database', - description: 'Where to get the workflow to execute from', - }, - - // ---------------------------------- - // source:database - // ---------------------------------- - { - displayName: 'Workflow ID', - name: 'workflowId', - type: 'string', - displayOptions: { - show: { - source: ['database'], - '@version': [{ _cnd: { lte: 1.1 } }], - }, - }, - default: '', - required: true, - description: 'The workflow to execute', - hint: 'Can be found in the URL of the workflow', - }, - - { - displayName: 'Workflow', - name: 'workflowId', - type: 'workflowSelector', - displayOptions: { - show: { - source: ['database'], - '@version': [{ _cnd: { gte: 1.2 } }], - }, - }, - default: '', - required: true, - }, - - // ---------------------------------- - // source:parameter - // ---------------------------------- - { - displayName: 'Workflow JSON', - name: 'workflowJson', - type: 'json', - typeOptions: { - rows: 10, - }, - displayOptions: { - show: { - source: ['parameter'], - }, - }, - default: '\n\n\n\n\n\n\n\n\n', - required: true, - description: 'The workflow JSON code to execute', - }, - // ---------------------------------- - // For all - // ---------------------------------- - { - displayName: 'Field to Return', - name: 'responsePropertyName', - type: 'string', - default: 'response', - required: true, - hint: 'The field in the last-executed node of the workflow that contains the response', - description: - 'Where to find the data that this tool should return. n8n will look in the output of the last-executed node of the workflow for a field with this name, and return its value.', - displayOptions: { - show: { - '@version': [{ _cnd: { lt: 1.3 } }], - }, - }, - }, - { - displayName: 'Extra Workflow Inputs', - name: 'fields', - placeholder: 'Add Value', - type: 'fixedCollection', - description: - "These will be output by the 'execute workflow' trigger of the workflow being called", - typeOptions: { - multipleValues: true, - sortable: true, - }, - default: {}, - options: [ - { - name: 'values', - displayName: 'Values', - values: [ - { - displayName: 'Name', - name: 'name', - type: 'string', - default: '', - placeholder: 'e.g. fieldName', - description: - 'Name of the field to set the value of. Supports dot-notation. Example: data.person[0].name.', - requiresDataPath: 'single', - }, - { - displayName: 'Type', - name: 'type', - type: 'options', - description: 'The field value type', - // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items - options: [ - { - name: 'String', - value: 'stringValue', - }, - { - name: 'Number', - value: 'numberValue', - }, - { - name: 'Boolean', - value: 'booleanValue', - }, - { - name: 'Array', - value: 'arrayValue', - }, - { - name: 'Object', - value: 'objectValue', - }, - ], - default: 'stringValue', - }, - { - displayName: 'Value', - name: 'stringValue', - type: 'string', - default: '', - displayOptions: { - show: { - type: ['stringValue'], - }, - }, - validateType: 'string', - ignoreValidationDuringExecution: true, - }, - { - displayName: 'Value', - name: 'numberValue', - type: 'string', - default: '', - displayOptions: { - show: { - type: ['numberValue'], - }, - }, - validateType: 'number', - ignoreValidationDuringExecution: true, - }, - { - displayName: 'Value', - name: 'booleanValue', - type: 'options', - default: 'true', - options: [ - { - name: 'True', - value: 'true', - }, - { - name: 'False', - value: 'false', - }, - ], - displayOptions: { - show: { - type: ['booleanValue'], - }, - }, - validateType: 'boolean', - ignoreValidationDuringExecution: true, - }, - { - displayName: 'Value', - name: 'arrayValue', - type: 'string', - default: '', - placeholder: 'e.g. [ arrayItem1, arrayItem2, arrayItem3 ]', - displayOptions: { - show: { - type: ['arrayValue'], - }, - }, - validateType: 'array', - ignoreValidationDuringExecution: true, - }, - { - displayName: 'Value', - name: 'objectValue', - type: 'json', - default: '={}', - typeOptions: { - rows: 2, - }, - displayOptions: { - show: { - type: ['objectValue'], - }, - }, - validateType: 'object', - ignoreValidationDuringExecution: true, - }, - ], - }, - ], - }, - // ---------------------------------- - // Output Parsing - // ---------------------------------- - { - displayName: 'Specify Input Schema', - name: 'specifyInputSchema', - type: 'boolean', - description: - 'Whether to specify the schema for the function. This would require the LLM to provide the input in the correct format and would validate it against the schema.', - noDataExpression: true, - default: false, - }, - { ...schemaTypeField, displayOptions: { show: { specifyInputSchema: [true] } } }, - jsonSchemaExampleField, - inputSchemaField, - ], - }; - - async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { - const workflowProxy = this.getWorkflowDataProxy(0); - - const name = this.getNodeParameter('name', itemIndex) as string; - const description = this.getNodeParameter('description', itemIndex) as string; - - let subExecutionId: string | undefined; - let subWorkflowId: string | undefined; - - const useSchema = this.getNodeParameter('specifyInputSchema', itemIndex) as boolean; - let tool: DynamicTool | DynamicStructuredTool | undefined = undefined; - - const runFunction = async ( - query: string | IDataObject, - runManager?: CallbackManagerForToolRun, - ): Promise => { - const source = this.getNodeParameter('source', itemIndex) as string; - const workflowInfo: IExecuteWorkflowInfo = {}; - if (source === 'database') { - // Read workflow from database - const nodeVersion = this.getNode().typeVersion; - if (nodeVersion <= 1.1) { - workflowInfo.id = this.getNodeParameter('workflowId', itemIndex) as string; - } else { - const { value } = this.getNodeParameter( - 'workflowId', - itemIndex, - {}, - ) as INodeParameterResourceLocator; - workflowInfo.id = value as string; - } - - subWorkflowId = workflowInfo.id; - } else if (source === 'parameter') { - // Read workflow from parameter - const workflowJson = this.getNodeParameter('workflowJson', itemIndex) as string; - try { - workflowInfo.code = JSON.parse(workflowJson) as IWorkflowBase; - - // subworkflow is same as parent workflow - subWorkflowId = workflowProxy.$workflow.id; - } catch (error) { - throw new NodeOperationError( - this.getNode(), - `The provided workflow is not valid JSON: "${(error as Error).message}"`, +import type { IVersionedNodeType, INodeTypeBaseDescription } from 'n8n-workflow'; +import { VersionedNodeType } from 'n8n-workflow'; + +import { ToolWorkflowV1 } from './v1/ToolWorkflowV1.node'; +import { ToolWorkflowV2 } from './v2/ToolWorkflowV2.node'; + +export class ToolWorkflow extends VersionedNodeType { + constructor() { + const baseDescription: INodeTypeBaseDescription = { + displayName: 'Call n8n Sub-Workflow Tool', + name: 'toolWorkflow', + icon: 'fa:network-wired', + group: ['transform'], + description: + 'Uses another n8n workflow as a tool. Allows packaging any n8n node(s) as a tool.', + codex: { + categories: ['AI'], + subcategories: { + AI: ['Tools'], + Tools: ['Recommended Tools'], + }, + resources: { + primaryDocumentation: [ { - itemIndex, + url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolworkflow/', }, - ); - } - } - - const rawData: IDataObject = { query }; - - const workflowFieldsJson = this.getNodeParameter('fields.values', itemIndex, [], { - rawExpressions: true, - }) as SetField[]; - - // Copied from Set Node v2 - for (const entry of workflowFieldsJson) { - if (entry.type === 'objectValue' && (entry.objectValue as string).startsWith('=')) { - rawData[entry.name] = (entry.objectValue as string).replace(/^=+/, ''); - } - } - - const options: SetNodeOptions = { - include: 'all', - }; - - const newItem = await manual.execute.call( - this, - { json: { query } }, - itemIndex, - options, - rawData, - this.getNode(), - ); - - const items = [newItem] as INodeExecutionData[]; - - let receivedData: ExecuteWorkflowData; - try { - receivedData = await this.executeWorkflow(workflowInfo, items, runManager?.getChild(), { - parentExecution: { - executionId: workflowProxy.$execution.id, - workflowId: workflowProxy.$workflow.id, - }, - }); - subExecutionId = receivedData.executionId; - } catch (error) { - // Make sure a valid error gets returned that can by json-serialized else it will - // not show up in the frontend - throw new NodeOperationError(this.getNode(), error as Error); - } - - const response: string | undefined = get(receivedData, 'data[0][0].json') as - | string - | undefined; - if (response === undefined) { - throw new NodeOperationError( - this.getNode(), - 'There was an error: "The workflow did not return a response"', - ); - } - - return response; - }; - - const toolHandler = async ( - query: string | IDataObject, - runManager?: CallbackManagerForToolRun, - ): Promise => { - const { index } = this.addInputData(NodeConnectionType.AiTool, [[{ json: { query } }]]); - - let response: string = ''; - let executionError: ExecutionError | undefined; - try { - response = await runFunction(query, runManager); - } catch (error) { - // TODO: Do some more testing. Issues here should actually fail the workflow - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - executionError = error; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - response = `There was an error: "${error.message}"`; - } - - if (typeof response === 'number') { - response = (response as number).toString(); - } - - if (isObject(response)) { - response = JSON.stringify(response, null, 2); - } - - if (typeof response !== 'string') { - // TODO: Do some more testing. Issues here should actually fail the workflow - executionError = new NodeOperationError(this.getNode(), 'Wrong output type returned', { - description: `The response property should be a string, but it is an ${typeof response}`, - }); - response = `There was an error: "${executionError.message}"`; - } - - let metadata: ITaskMetadata | undefined; - if (subExecutionId && subWorkflowId) { - metadata = { - subExecution: { - executionId: subExecutionId, - workflowId: subWorkflowId, - }, - }; - } - - if (executionError) { - void this.addOutputData(NodeConnectionType.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); - } - return response; - }; - - const functionBase = { - name, - description, - func: toolHandler, + ], + }, + }, + defaultVersion: 2, }; - if (useSchema) { - try { - // We initialize these even though one of them will always be empty - // it makes it easier to navigate the ternary operator - const jsonExample = this.getNodeParameter('jsonSchemaExample', itemIndex, '') as string; - const inputSchema = this.getNodeParameter('inputSchema', itemIndex, '') as string; - - const schemaType = this.getNodeParameter('schemaType', itemIndex) as 'fromJson' | 'manual'; - const jsonSchema = - schemaType === 'fromJson' - ? generateSchema(jsonExample) - : jsonParse(inputSchema); - - const zodSchema = convertJsonSchemaToZod(jsonSchema); - - tool = new DynamicStructuredTool({ - schema: zodSchema, - ...functionBase, - }); - } catch (error) { - throw new NodeOperationError( - this.getNode(), - 'Error during parsing of JSON Schema. \n ' + error, - ); - } - } else { - tool = new DynamicTool(functionBase); - } - - return { - response: tool, + const nodeVersions: IVersionedNodeType['nodeVersions'] = { + 1: new ToolWorkflowV1(baseDescription), + 1.1: new ToolWorkflowV1(baseDescription), + 1.2: new ToolWorkflowV1(baseDescription), + 1.3: new ToolWorkflowV1(baseDescription), + 2: new ToolWorkflowV2(baseDescription), }; + super(nodeVersions, baseDescription); } } 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 new file mode 100644 index 0000000000000..4c33c86b4e744 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/ToolWorkflowV1.node.ts @@ -0,0 +1,241 @@ +import type { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager'; +import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools'; +import type { JSONSchema7 } from 'json-schema'; +import get from 'lodash/get'; +import isObject from 'lodash/isObject'; +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 type { + IExecuteWorkflowInfo, + INodeExecutionData, + INodeType, + INodeTypeDescription, + IWorkflowBase, + ISupplyDataFunctions, + SupplyData, + ExecutionError, + ExecuteWorkflowData, + IDataObject, + INodeParameterResourceLocator, + ITaskMetadata, + INodeTypeBaseDescription, +} from 'n8n-workflow'; +import { NodeConnectionType, NodeOperationError, jsonParse } from 'n8n-workflow'; + +import { versionDescription } from './versionDescription'; +import type { DynamicZodObject } from '../../../../types/zod.types'; +import { convertJsonSchemaToZod, generateSchema } from '../../../../utils/schemaParsing'; + +export class ToolWorkflowV1 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + ...versionDescription, + }; + } + + async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { + const workflowProxy = this.getWorkflowDataProxy(0); + + const name = this.getNodeParameter('name', itemIndex) as string; + const description = this.getNodeParameter('description', itemIndex) as string; + + let subExecutionId: string | undefined; + let subWorkflowId: string | undefined; + + const useSchema = this.getNodeParameter('specifyInputSchema', itemIndex) as boolean; + let tool: DynamicTool | DynamicStructuredTool | undefined = undefined; + + const runFunction = async ( + query: string | IDataObject, + runManager?: CallbackManagerForToolRun, + ): Promise => { + const source = this.getNodeParameter('source', itemIndex) as string; + const workflowInfo: IExecuteWorkflowInfo = {}; + if (source === 'database') { + // Read workflow from database + const nodeVersion = this.getNode().typeVersion; + if (nodeVersion <= 1.1) { + workflowInfo.id = this.getNodeParameter('workflowId', itemIndex) as string; + } else { + const { value } = this.getNodeParameter( + 'workflowId', + itemIndex, + {}, + ) as INodeParameterResourceLocator; + workflowInfo.id = value as string; + } + + subWorkflowId = workflowInfo.id; + } else if (source === 'parameter') { + // Read workflow from parameter + const workflowJson = this.getNodeParameter('workflowJson', itemIndex) as string; + try { + workflowInfo.code = JSON.parse(workflowJson) as IWorkflowBase; + + // subworkflow is same as parent workflow + subWorkflowId = workflowProxy.$workflow.id; + } catch (error) { + throw new NodeOperationError( + this.getNode(), + `The provided workflow is not valid JSON: "${(error as Error).message}"`, + { + itemIndex, + }, + ); + } + } + + const rawData: IDataObject = { query }; + + const workflowFieldsJson = this.getNodeParameter('fields.values', itemIndex, [], { + rawExpressions: true, + }) as SetField[]; + + // Copied from Set Node v2 + for (const entry of workflowFieldsJson) { + if (entry.type === 'objectValue' && (entry.objectValue as string).startsWith('=')) { + rawData[entry.name] = (entry.objectValue as string).replace(/^=+/, ''); + } + } + + const options: SetNodeOptions = { + include: 'all', + }; + + const newItem = await manual.execute.call( + this, + { json: { query } }, + itemIndex, + options, + rawData, + this.getNode(), + ); + + const items = [newItem] as INodeExecutionData[]; + + let receivedData: ExecuteWorkflowData; + try { + receivedData = await this.executeWorkflow(workflowInfo, items, runManager?.getChild(), { + parentExecution: { + executionId: workflowProxy.$execution.id, + workflowId: workflowProxy.$workflow.id, + }, + }); + subExecutionId = receivedData.executionId; + } catch (error) { + // Make sure a valid error gets returned that can by json-serialized else it will + // not show up in the frontend + throw new NodeOperationError(this.getNode(), error as Error); + } + + const response: string | undefined = get(receivedData, 'data[0][0].json') as + | string + | undefined; + if (response === undefined) { + throw new NodeOperationError( + this.getNode(), + 'There was an error: "The workflow did not return a response"', + ); + } + + return response; + }; + + const toolHandler = async ( + query: string | IDataObject, + runManager?: CallbackManagerForToolRun, + ): Promise => { + const { index } = this.addInputData(NodeConnectionType.AiTool, [[{ json: { query } }]]); + + let response: string = ''; + let executionError: ExecutionError | undefined; + try { + response = await runFunction(query, runManager); + } catch (error) { + // TODO: Do some more testing. Issues here should actually fail the workflow + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + executionError = error; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + response = `There was an error: "${error.message}"`; + } + + if (typeof response === 'number') { + response = (response as number).toString(); + } + + if (isObject(response)) { + response = JSON.stringify(response, null, 2); + } + + if (typeof response !== 'string') { + // TODO: Do some more testing. Issues here should actually fail the workflow + executionError = new NodeOperationError(this.getNode(), 'Wrong output type returned', { + description: `The response property should be a string, but it is an ${typeof response}`, + }); + response = `There was an error: "${executionError.message}"`; + } + + let metadata: ITaskMetadata | undefined; + if (subExecutionId && subWorkflowId) { + metadata = { + subExecution: { + executionId: subExecutionId, + workflowId: subWorkflowId, + }, + }; + } + + if (executionError) { + void this.addOutputData(NodeConnectionType.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); + } + return response; + }; + + const functionBase = { + name, + description, + func: toolHandler, + }; + + if (useSchema) { + try { + // We initialize these even though one of them will always be empty + // it makes it easier to navigate the ternary operator + const jsonExample = this.getNodeParameter('jsonSchemaExample', itemIndex, '') as string; + const inputSchema = this.getNodeParameter('inputSchema', itemIndex, '') as string; + + const schemaType = this.getNodeParameter('schemaType', itemIndex) as 'fromJson' | 'manual'; + const jsonSchema = + schemaType === 'fromJson' + ? generateSchema(jsonExample) + : jsonParse(inputSchema); + + const zodSchema = convertJsonSchemaToZod(jsonSchema); + + tool = new DynamicStructuredTool({ + schema: zodSchema, + ...functionBase, + }); + } catch (error) { + throw new NodeOperationError( + this.getNode(), + 'Error during parsing of JSON Schema. \n ' + error, + ); + } + } else { + tool = new DynamicTool(functionBase); + } + + return { + response: tool, + }; + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/versionDescription.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/versionDescription.ts new file mode 100644 index 0000000000000..da7a0e9815cea --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/versionDescription.ts @@ -0,0 +1,345 @@ +/* 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 { + inputSchemaField, + jsonSchemaExampleField, + schemaTypeField, +} from '../../../../utils/descriptions'; +import { getConnectionHintNoticeField } from '../../../../utils/sharedFields'; + +export const versionDescription: INodeTypeDescription = { + displayName: 'Call n8n Workflow Tool', + name: 'toolWorkflow', + icon: 'fa:network-wired', + iconColor: 'black', + group: ['transform'], + version: [1, 1.1, 1.2, 1.3], + description: 'Uses another n8n workflow as a tool. Allows packaging any n8n node(s) as a tool.', + defaults: { + name: 'Call n8n Workflow Tool', + }, + codex: { + categories: ['AI'], + subcategories: { + AI: ['Tools'], + Tools: ['Recommended Tools'], + }, + resources: { + primaryDocumentation: [ + { + url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolworkflow/', + }, + ], + }, + }, + // 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], + outputNames: ['Tool'], + properties: [ + getConnectionHintNoticeField([NodeConnectionType.AiAgent]), + { + displayName: + 'See an example of a workflow to suggest meeting slots using AI here.', + name: 'noticeTemplateExample', + type: 'notice', + default: '', + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + placeholder: 'My_Color_Tool', + displayOptions: { + show: { + '@version': [1], + }, + }, + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + placeholder: 'e.g. My_Color_Tool', + validateType: 'string-alphanumeric', + description: + 'The name of the function to be called, could contain letters, numbers, and underscores only', + displayOptions: { + show: { + '@version': [{ _cnd: { gte: 1.1 } }], + }, + }, + }, + { + displayName: 'Description', + name: 'description', + type: 'string', + default: '', + placeholder: + 'Call this tool to get a random color. The input should be a string with comma separted names of colors to exclude.', + typeOptions: { + rows: 3, + }, + }, + + { + displayName: + 'This tool will call the workflow you define below, and look in the last node for the response. The workflow needs to start with an Execute Workflow trigger', + name: 'executeNotice', + type: 'notice', + default: '', + }, + + { + displayName: 'Source', + name: 'source', + type: 'options', + options: [ + { + name: 'Database', + value: 'database', + description: 'Load the workflow from the database by ID', + }, + { + name: 'Define Below', + value: 'parameter', + description: 'Pass the JSON code of a workflow', + }, + ], + default: 'database', + description: 'Where to get the workflow to execute from', + }, + + // ---------------------------------- + // source:database + // ---------------------------------- + { + displayName: 'Workflow ID', + name: 'workflowId', + type: 'string', + displayOptions: { + show: { + source: ['database'], + '@version': [{ _cnd: { lte: 1.1 } }], + }, + }, + default: '', + required: true, + description: 'The workflow to execute', + hint: 'Can be found in the URL of the workflow', + }, + + { + displayName: 'Workflow', + name: 'workflowId', + type: 'workflowSelector', + displayOptions: { + show: { + source: ['database'], + '@version': [{ _cnd: { gte: 1.2 } }], + }, + }, + default: '', + required: true, + }, + + // ---------------------------------- + // source:parameter + // ---------------------------------- + { + displayName: 'Workflow JSON', + name: 'workflowJson', + type: 'json', + typeOptions: { + rows: 10, + }, + displayOptions: { + show: { + source: ['parameter'], + }, + }, + default: '\n\n\n\n\n\n\n\n\n', + required: true, + description: 'The workflow JSON code to execute', + }, + // ---------------------------------- + // For all + // ---------------------------------- + { + displayName: 'Field to Return', + name: 'responsePropertyName', + type: 'string', + default: 'response', + required: true, + hint: 'The field in the last-executed node of the workflow that contains the response', + description: + 'Where to find the data that this tool should return. n8n will look in the output of the last-executed node of the workflow for a field with this name, and return its value.', + displayOptions: { + show: { + '@version': [{ _cnd: { lt: 1.3 } }], + }, + }, + }, + { + displayName: 'Extra Workflow Inputs', + name: 'fields', + placeholder: 'Add Value', + type: 'fixedCollection', + description: + "These will be output by the 'execute workflow' trigger of the workflow being called", + typeOptions: { + multipleValues: true, + sortable: true, + }, + default: {}, + options: [ + { + name: 'values', + displayName: 'Values', + values: [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + placeholder: 'e.g. fieldName', + description: + 'Name of the field to set the value of. Supports dot-notation. Example: data.person[0].name.', + requiresDataPath: 'single', + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + description: 'The field value type', + // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items + options: [ + { + name: 'String', + value: 'stringValue', + }, + { + name: 'Number', + value: 'numberValue', + }, + { + name: 'Boolean', + value: 'booleanValue', + }, + { + name: 'Array', + value: 'arrayValue', + }, + { + name: 'Object', + value: 'objectValue', + }, + ], + default: 'stringValue', + }, + { + displayName: 'Value', + name: 'stringValue', + type: 'string', + default: '', + displayOptions: { + show: { + type: ['stringValue'], + }, + }, + validateType: 'string', + ignoreValidationDuringExecution: true, + }, + { + displayName: 'Value', + name: 'numberValue', + type: 'string', + default: '', + displayOptions: { + show: { + type: ['numberValue'], + }, + }, + validateType: 'number', + ignoreValidationDuringExecution: true, + }, + { + displayName: 'Value', + name: 'booleanValue', + type: 'options', + default: 'true', + options: [ + { + name: 'True', + value: 'true', + }, + { + name: 'False', + value: 'false', + }, + ], + displayOptions: { + show: { + type: ['booleanValue'], + }, + }, + validateType: 'boolean', + ignoreValidationDuringExecution: true, + }, + { + displayName: 'Value', + name: 'arrayValue', + type: 'string', + default: '', + placeholder: 'e.g. [ arrayItem1, arrayItem2, arrayItem3 ]', + displayOptions: { + show: { + type: ['arrayValue'], + }, + }, + validateType: 'array', + ignoreValidationDuringExecution: true, + }, + { + displayName: 'Value', + name: 'objectValue', + type: 'json', + default: '={}', + typeOptions: { + rows: 2, + }, + displayOptions: { + show: { + type: ['objectValue'], + }, + }, + validateType: 'object', + ignoreValidationDuringExecution: true, + }, + ], + }, + ], + }, + // ---------------------------------- + // Output Parsing + // ---------------------------------- + { + displayName: 'Specify Input Schema', + name: 'specifyInputSchema', + type: 'boolean', + description: + 'Whether to specify the schema for the function. This would require the LLM to provide the input in the correct format and would validate it against the schema.', + noDataExpression: true, + default: false, + }, + { ...schemaTypeField, displayOptions: { show: { specifyInputSchema: [true] } } }, + jsonSchemaExampleField, + inputSchemaField, + ], +}; diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.node.ts new file mode 100644 index 0000000000000..22ca31e4da2b1 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.node.ts @@ -0,0 +1,42 @@ +import { loadWorkflowInputMappings } from 'n8n-nodes-base/dist/utils/workflowInputsResourceMapping/GenericFunctions'; +import type { + INodeTypeBaseDescription, + ISupplyDataFunctions, + SupplyData, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { WorkflowToolService } from './utils/WorkflowToolService'; +import { versionDescription } from './versionDescription'; + +export class ToolWorkflowV2 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + ...versionDescription, + }; + } + + methods = { + localResourceMapping: { + loadWorkflowInputMappings, + }, + }; + + async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { + const workflowToolService = new WorkflowToolService(this); + const name = this.getNodeParameter('name', itemIndex) as string; + const description = this.getNodeParameter('description', itemIndex) as string; + + const tool = await workflowToolService.createTool({ + name, + description, + itemIndex, + }); + + return { response: tool }; + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.test.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.test.ts new file mode 100644 index 0000000000000..73aa24c6b7fef --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.test.ts @@ -0,0 +1,235 @@ +/* eslint-disable @typescript-eslint/dot-notation */ // Disabled to allow access to private methods +import { DynamicTool } from '@langchain/core/tools'; +import { NodeOperationError } from 'n8n-workflow'; +import type { + ISupplyDataFunctions, + INodeExecutionData, + IWorkflowDataProxyData, + ExecuteWorkflowData, + INode, +} from 'n8n-workflow'; + +import { WorkflowToolService } from './utils/WorkflowToolService'; + +// Mock ISupplyDataFunctions interface +function createMockContext(overrides?: Partial): ISupplyDataFunctions { + return { + getNodeParameter: jest.fn(), + getWorkflowDataProxy: jest.fn(), + getNode: jest.fn(), + executeWorkflow: jest.fn(), + addInputData: jest.fn(), + addOutputData: jest.fn(), + getCredentials: jest.fn(), + getCredentialsProperties: jest.fn(), + getInputData: jest.fn(), + getMode: jest.fn(), + getRestApiUrl: jest.fn(), + getTimezone: jest.fn(), + getWorkflow: jest.fn(), + getWorkflowStaticData: jest.fn(), + logger: { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + }, + ...overrides, + } as ISupplyDataFunctions; +} + +describe('WorkflowTool::WorkflowToolService', () => { + let context: ISupplyDataFunctions; + let service: WorkflowToolService; + + beforeEach(() => { + // Prepare essential mocks + context = createMockContext(); + jest.spyOn(context, 'getNode').mockReturnValue({ + parameters: { workflowInputs: { schema: [] } }, + } as unknown as INode); + service = new WorkflowToolService(context); + }); + + describe('createTool', () => { + it('should create a basic dynamic tool when schema is not used', async () => { + const toolParams = { + name: 'TestTool', + description: 'Test Description', + itemIndex: 0, + }; + + const result = await service.createTool(toolParams); + + expect(result).toBeInstanceOf(DynamicTool); + expect(result).toHaveProperty('name', 'TestTool'); + expect(result).toHaveProperty('description', 'Test Description'); + }); + + it('should create a tool that can handle successful execution', async () => { + const toolParams = { + name: 'TestTool', + description: 'Test Description', + itemIndex: 0, + }; + + const TEST_RESPONSE = { msg: 'test response' }; + + const mockExecuteWorkflowResponse: ExecuteWorkflowData = { + data: [[{ json: TEST_RESPONSE }]], + executionId: 'test-execution', + }; + + jest.spyOn(context, 'executeWorkflow').mockResolvedValueOnce(mockExecuteWorkflowResponse); + jest.spyOn(context, 'addInputData').mockReturnValue({ index: 0 }); + jest.spyOn(context, 'getNodeParameter').mockReturnValue('database'); + jest.spyOn(context, 'getWorkflowDataProxy').mockReturnValue({ + $execution: { id: 'exec-id' }, + $workflow: { id: 'workflow-id' }, + } as unknown as IWorkflowDataProxyData); + + const tool = await service.createTool(toolParams); + const result = await tool.func('test query'); + + expect(result).toBe(JSON.stringify(TEST_RESPONSE, null, 2)); + expect(context.addOutputData).toHaveBeenCalled(); + }); + + it('should handle errors during tool execution', async () => { + const toolParams = { + name: 'TestTool', + description: 'Test Description', + itemIndex: 0, + }; + + jest + .spyOn(context, 'executeWorkflow') + .mockRejectedValueOnce(new Error('Workflow execution failed')); + jest.spyOn(context, 'addInputData').mockReturnValue({ index: 0 }); + jest.spyOn(context, 'getNodeParameter').mockReturnValue('database'); + + const tool = await service.createTool(toolParams); + const result = await tool.func('test query'); + + expect(result).toContain('There was an error'); + expect(context.addOutputData).toHaveBeenCalled(); + }); + }); + + describe('handleToolResponse', () => { + it('should handle number response', () => { + const result = service['handleToolResponse'](42); + + expect(result).toBe('42'); + }); + + it('should handle object response', () => { + const obj = { test: 'value' }; + + const result = service['handleToolResponse'](obj); + + expect(result).toBe(JSON.stringify(obj, null, 2)); + }); + + it('should handle string response', () => { + const result = service['handleToolResponse']('test response'); + + expect(result).toBe('test response'); + }); + + it('should throw error for invalid response type', () => { + expect(() => service['handleToolResponse'](undefined)).toThrow(NodeOperationError); + }); + }); + + describe('executeSubWorkflow', () => { + it('should successfully execute workflow and return response', async () => { + const workflowInfo = { id: 'test-workflow' }; + const items: INodeExecutionData[] = []; + const workflowProxyMock = { + $execution: { id: 'exec-id' }, + $workflow: { id: 'workflow-id' }, + } as unknown as IWorkflowDataProxyData; + + const TEST_RESPONSE = { msg: 'test response' }; + + const mockResponse: ExecuteWorkflowData = { + data: [[{ json: TEST_RESPONSE }]], + executionId: 'test-execution', + }; + + jest.spyOn(context, 'executeWorkflow').mockResolvedValueOnce(mockResponse); + + const result = await service['executeSubWorkflow'](workflowInfo, items, workflowProxyMock); + + expect(result.response).toBe(TEST_RESPONSE); + expect(result.subExecutionId).toBe('test-execution'); + }); + + it('should throw error when workflow execution fails', async () => { + jest.spyOn(context, 'executeWorkflow').mockRejectedValueOnce(new Error('Execution failed')); + + await expect(service['executeSubWorkflow']({}, [], {} as never)).rejects.toThrow( + NodeOperationError, + ); + }); + + it('should throw error when workflow returns no response', async () => { + const mockResponse: ExecuteWorkflowData = { + data: [], + executionId: 'test-execution', + }; + + jest.spyOn(context, 'executeWorkflow').mockResolvedValueOnce(mockResponse); + + await expect(service['executeSubWorkflow']({}, [], {} as never)).rejects.toThrow(); + }); + }); + + describe('getSubWorkflowInfo', () => { + it('should handle database source correctly', async () => { + const source = 'database'; + const itemIndex = 0; + const workflowProxyMock = { + $workflow: { id: 'proxy-id' }, + } as unknown as IWorkflowDataProxyData; + + jest.spyOn(context, 'getNodeParameter').mockReturnValueOnce({ value: 'workflow-id' }); + + const result = await service['getSubWorkflowInfo'](source, itemIndex, workflowProxyMock); + + expect(result.workflowInfo).toHaveProperty('id', 'workflow-id'); + expect(result.subWorkflowId).toBe('workflow-id'); + }); + + it('should handle parameter source correctly', async () => { + const source = 'parameter'; + const itemIndex = 0; + const workflowProxyMock = { + $workflow: { id: 'proxy-id' }, + } as unknown as IWorkflowDataProxyData; + const mockWorkflow = { id: 'test-workflow' }; + + jest.spyOn(context, 'getNodeParameter').mockReturnValueOnce(JSON.stringify(mockWorkflow)); + + const result = await service['getSubWorkflowInfo'](source, itemIndex, workflowProxyMock); + + expect(result.workflowInfo.code).toEqual(mockWorkflow); + expect(result.subWorkflowId).toBe('proxy-id'); + }); + + it('should throw error for invalid JSON in parameter source', async () => { + const source = 'parameter'; + const itemIndex = 0; + const workflowProxyMock = { + $workflow: { id: 'proxy-id' }, + } as unknown as IWorkflowDataProxyData; + + jest.spyOn(context, 'getNodeParameter').mockReturnValueOnce('invalid json'); + + await expect( + service['getSubWorkflowInfo'](source, itemIndex, workflowProxyMock), + ).rejects.toThrow(NodeOperationError); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/FromAIParser.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/FromAIParser.ts new file mode 100644 index 0000000000000..4b9b6ed58ed4e --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/FromAIParser.ts @@ -0,0 +1,284 @@ +import type { ISupplyDataFunctions } from 'n8n-workflow'; +import { jsonParse, NodeOperationError } from 'n8n-workflow'; +import { z } from 'zod'; + +type AllowedTypes = 'string' | 'number' | 'boolean' | 'json'; +export interface FromAIArgument { + key: string; + description?: string; + type?: AllowedTypes; + defaultValue?: string | number | boolean | Record; +} + +// TODO: We copied this class from the core package, once the new nodes context work is merged, this should be available in root node context and this file can be removed. +// Please apply any changes to both files + +/** + * AIParametersParser + * + * This class encapsulates the logic for parsing node parameters, extracting $fromAI calls, + * generating Zod schemas, and creating LangChain tools. + */ +export class AIParametersParser { + private ctx: ISupplyDataFunctions; + + /** + * Constructs an instance of AIParametersParser. + * @param ctx The execution context. + */ + constructor(ctx: ISupplyDataFunctions) { + this.ctx = ctx; + } + + /** + * Generates a Zod schema based on the provided FromAIArgument placeholder. + * @param placeholder The FromAIArgument object containing key, type, description, and defaultValue. + * @returns A Zod schema corresponding to the placeholder's type and constraints. + */ + generateZodSchema(placeholder: FromAIArgument): z.ZodTypeAny { + let schema: z.ZodTypeAny; + + switch (placeholder.type?.toLowerCase()) { + case 'string': + schema = z.string(); + break; + case 'number': + schema = z.number(); + break; + case 'boolean': + schema = z.boolean(); + break; + case 'json': + schema = z.record(z.any()); + break; + default: + schema = z.string(); + } + + if (placeholder.description) { + schema = schema.describe(`${schema.description ?? ''} ${placeholder.description}`.trim()); + } + + if (placeholder.defaultValue !== undefined) { + schema = schema.default(placeholder.defaultValue); + } + + return schema; + } + + /** + * Recursively traverses the nodeParameters object to find all $fromAI calls. + * @param payload The current object or value being traversed. + * @param collectedArgs The array collecting FromAIArgument objects. + */ + traverseNodeParameters(payload: unknown, collectedArgs: FromAIArgument[]) { + if (typeof payload === 'string') { + const fromAICalls = this.extractFromAICalls(payload); + fromAICalls.forEach((call) => collectedArgs.push(call)); + } else if (Array.isArray(payload)) { + payload.forEach((item: unknown) => this.traverseNodeParameters(item, collectedArgs)); + } else if (typeof payload === 'object' && payload !== null) { + Object.values(payload).forEach((value) => this.traverseNodeParameters(value, collectedArgs)); + } + } + + /** + * Extracts all $fromAI calls from a given string + * @param str The string to search for $fromAI calls. + * @returns An array of FromAIArgument objects. + * + * This method uses a regular expression to find the start of each $fromAI function call + * in the input string. It then employs a character-by-character parsing approach to + * accurately extract the arguments of each call, handling nested parentheses and quoted strings. + * + * The parsing process: + * 1. Finds the starting position of a $fromAI call using regex. + * 2. Iterates through characters, keeping track of parentheses depth and quote status. + * 3. Handles escaped characters within quotes to avoid premature quote closing. + * 4. Builds the argument string until the matching closing parenthesis is found. + * 5. Parses the extracted argument string into a FromAIArgument object. + * 6. Repeats the process for all $fromAI calls in the input string. + * + */ + extractFromAICalls(str: string): FromAIArgument[] { + const args: FromAIArgument[] = []; + // Regular expression to match the start of a $fromAI function call + const pattern = /\$fromAI\s*\(\s*/gi; + let match: RegExpExecArray | null; + + while ((match = pattern.exec(str)) !== null) { + const startIndex = match.index + match[0].length; + let current = startIndex; + let inQuotes = false; + let quoteChar = ''; + let parenthesesCount = 1; + let argsString = ''; + + // Parse the arguments string, handling nested parentheses and quotes + while (current < str.length && parenthesesCount > 0) { + const char = str[current]; + + if (inQuotes) { + // Handle characters inside quotes, including escaped characters + if (char === '\\' && current + 1 < str.length) { + argsString += char + str[current + 1]; + current += 2; + continue; + } + + if (char === quoteChar) { + inQuotes = false; + quoteChar = ''; + } + argsString += char; + } else { + // Handle characters outside quotes + if (['"', "'", '`'].includes(char)) { + inQuotes = true; + quoteChar = char; + } else if (char === '(') { + parenthesesCount++; + } else if (char === ')') { + parenthesesCount--; + } + + // Only add characters if we're still inside the main parentheses + if (parenthesesCount > 0 || char !== ')') { + argsString += char; + } + } + + current++; + } + + // If parentheses are balanced, parse the arguments + if (parenthesesCount === 0) { + try { + const parsedArgs = this.parseArguments(argsString); + args.push(parsedArgs); + } catch (error) { + // If parsing fails, throw an ApplicationError with details + throw new NodeOperationError( + this.ctx.getNode(), + `Failed to parse $fromAI arguments: ${argsString}: ${error}`, + ); + } + } else { + // Log an error if parentheses are unbalanced + throw new NodeOperationError( + this.ctx.getNode(), + `Unbalanced parentheses while parsing $fromAI call: ${str.slice(startIndex)}`, + ); + } + } + + return args; + } + + /** + * Parses the arguments of a single $fromAI function call. + * @param argsString The string containing the function arguments. + * @returns A FromAIArgument object. + */ + parseArguments(argsString: string): FromAIArgument { + // Split arguments by commas not inside quotes + const args: string[] = []; + let currentArg = ''; + let inQuotes = false; + let quoteChar = ''; + let escapeNext = false; + + for (let i = 0; i < argsString.length; i++) { + const char = argsString[i]; + + if (escapeNext) { + currentArg += char; + escapeNext = false; + continue; + } + + if (char === '\\') { + escapeNext = true; + continue; + } + + if (['"', "'", '`'].includes(char)) { + if (!inQuotes) { + inQuotes = true; + quoteChar = char; + currentArg += char; + } else if (char === quoteChar) { + inQuotes = false; + quoteChar = ''; + currentArg += char; + } else { + currentArg += char; + } + continue; + } + + if (char === ',' && !inQuotes) { + args.push(currentArg.trim()); + currentArg = ''; + continue; + } + + currentArg += char; + } + + if (currentArg) { + args.push(currentArg.trim()); + } + + // Remove surrounding quotes if present + const cleanArgs = args.map((arg) => { + const trimmed = arg.trim(); + if ( + (trimmed.startsWith("'") && trimmed.endsWith("'")) || + (trimmed.startsWith('`') && trimmed.endsWith('`')) || + (trimmed.startsWith('"') && trimmed.endsWith('"')) + ) { + return trimmed + .slice(1, -1) + .replace(/\\'/g, "'") + .replace(/\\`/g, '`') + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\'); + } + return trimmed; + }); + + const type = cleanArgs?.[2] || 'string'; + + if (!['string', 'number', 'boolean', 'json'].includes(type.toLowerCase())) { + throw new NodeOperationError(this.ctx.getNode(), `Invalid type: ${type}`); + } + + return { + key: cleanArgs[0] || '', + description: cleanArgs[1], + type: (cleanArgs?.[2] ?? 'string') as AllowedTypes, + defaultValue: this.parseDefaultValue(cleanArgs[3]), + }; + } + + /** + * Parses the default value, preserving its original type. + * @param value The default value as a string. + * @returns The parsed default value in its appropriate type. + */ + parseDefaultValue( + value: string | undefined, + ): string | number | boolean | Record | undefined { + if (value === undefined || value === '') return undefined; + const lowerValue = value.toLowerCase(); + if (lowerValue === 'true') return true; + if (lowerValue === 'false') return false; + if (!isNaN(Number(value))) return Number(value); + try { + return jsonParse(value); + } catch { + return value; + } + } +} 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 new file mode 100644 index 0000000000000..2ce3c435562be --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts @@ -0,0 +1,313 @@ +import type { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager'; +import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools'; +import get from 'lodash/get'; +import isObject from 'lodash/isObject'; +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 { getCurrentWorkflowInputData } from 'n8n-nodes-base/dist/utils/workflowInputsResourceMapping/GenericFunctions'; +import type { + ExecuteWorkflowData, + ExecutionError, + IDataObject, + IExecuteWorkflowInfo, + INodeExecutionData, + INodeParameterResourceLocator, + ISupplyDataFunctions, + ITaskMetadata, + IWorkflowBase, + IWorkflowDataProxyData, + ResourceMapperValue, +} from 'n8n-workflow'; +import { jsonParse, NodeConnectionType, NodeOperationError } from 'n8n-workflow'; +import { z } from 'zod'; + +import type { FromAIArgument } from './FromAIParser'; +import { AIParametersParser } from './FromAIParser'; + +/** + Main class for creating the Workflow tool + Processes the node parameters and creates AI Agent tool capable of executing n8n workflows +*/ +export class WorkflowToolService { + // Determines if we should use input schema when creating the tool + private useSchema: boolean; + + // Sub-workflow id, pulled from referenced sub-workflow + private subWorkflowId: string | undefined; + + // Sub-workflow execution id, will be set after the sub-workflow is executed + private subExecutionId: string | undefined; + + constructor(private context: ISupplyDataFunctions) { + const subWorkflowInputs = this.context.getNode().parameters + .workflowInputs as ResourceMapperValue; + this.useSchema = (subWorkflowInputs?.schema ?? []).length > 0; + } + + // Creates the tool based on the provided parameters + async createTool({ + name, + description, + itemIndex, + }: { + name: string; + description: string; + itemIndex: number; + }): Promise { + // Handler for the tool execution, will be called when the tool is executed + // This function will execute the sub-workflow and return the response + const toolHandler = async ( + query: string | IDataObject, + runManager?: CallbackManagerForToolRun, + ): Promise => { + const { index } = this.context.addInputData(NodeConnectionType.AiTool, [ + [{ json: { query } }], + ]); + + try { + const response = await this.runFunction(query, itemIndex, runManager); + const processedResponse = this.handleToolResponse(response); + + // Once the sub-workflow is executed, add the output data to the context + // This will be used to link the sub-workflow execution in the parent workflow + let metadata: ITaskMetadata | undefined; + if (this.subExecutionId && this.subWorkflowId) { + metadata = { + subExecution: { + executionId: this.subExecutionId, + workflowId: this.subWorkflowId, + }, + }; + } + const json = jsonParse(processedResponse, { + fallbackValue: { response: processedResponse }, + }); + void this.context.addOutputData(NodeConnectionType.AiTool, index, [[{ json }]], metadata); + + return processedResponse; + } catch (error) { + const executionError = error as ExecutionError; + const errorResponse = `There was an error: "${executionError.message}"`; + void this.context.addOutputData(NodeConnectionType.AiTool, index, executionError); + return errorResponse; + } + }; + + // Create structured tool if input schema is provided + return this.useSchema + ? await this.createStructuredTool(name, description, toolHandler) + : new DynamicTool({ name, description, func: toolHandler }); + } + + private handleToolResponse(response: unknown): string { + if (typeof response === 'number') { + return response.toString(); + } + + if (isObject(response)) { + return JSON.stringify(response, null, 2); + } + + if (typeof response !== 'string') { + throw new NodeOperationError(this.context.getNode(), 'Wrong output type returned', { + description: `The response property should be a string, but it is an ${typeof response}`, + }); + } + + return response; + } + + /** + * Executes specified sub-workflow with provided inputs + */ + private async executeSubWorkflow( + workflowInfo: IExecuteWorkflowInfo, + items: INodeExecutionData[], + workflowProxy: IWorkflowDataProxyData, + runManager?: CallbackManagerForToolRun, + ): Promise<{ response: string; subExecutionId: string }> { + let receivedData: ExecuteWorkflowData; + try { + receivedData = await this.context.executeWorkflow( + workflowInfo, + items, + runManager?.getChild(), + { + parentExecution: { + executionId: workflowProxy.$execution.id, + workflowId: workflowProxy.$workflow.id, + }, + }, + ); + // Set sub-workflow execution id so it can be used in other places + this.subExecutionId = receivedData.executionId; + } catch (error) { + throw new NodeOperationError(this.context.getNode(), error as Error); + } + + const response: string | undefined = get(receivedData, 'data[0][0].json') as string | undefined; + if (response === undefined) { + throw new NodeOperationError( + this.context.getNode(), + 'There was an error: "The workflow did not return a response"', + ); + } + + return { response, subExecutionId: receivedData.executionId }; + } + + /** + * Gets the sub-workflow info based on the source and executes it. + * This function will be called as part of the tool execution (from the toolHandler) + */ + private async runFunction( + query: string | IDataObject, + itemIndex: number, + runManager?: CallbackManagerForToolRun, + ): Promise { + const source = this.context.getNodeParameter('source', itemIndex) as string; + const workflowProxy = this.context.getWorkflowDataProxy(0); + + const { workflowInfo } = await this.getSubWorkflowInfo(source, itemIndex, workflowProxy); + const rawData = this.prepareRawData(query, itemIndex); + const items = await this.prepareWorkflowItems(query, itemIndex, rawData); + + this.subWorkflowId = workflowInfo.id; + + const { response } = await this.executeSubWorkflow( + workflowInfo, + items, + workflowProxy, + runManager, + ); + return response; + } + + /** + * Gets the sub-workflow info based on the source (database or parameter) + */ + private async getSubWorkflowInfo( + source: string, + itemIndex: number, + workflowProxy: IWorkflowDataProxyData, + ): Promise<{ + workflowInfo: IExecuteWorkflowInfo; + subWorkflowId: string; + }> { + const workflowInfo: IExecuteWorkflowInfo = {}; + let subWorkflowId: string; + + if (source === 'database') { + const { value } = this.context.getNodeParameter( + 'workflowId', + itemIndex, + {}, + ) as INodeParameterResourceLocator; + workflowInfo.id = value as string; + subWorkflowId = workflowInfo.id; + } else if (source === 'parameter') { + const workflowJson = this.context.getNodeParameter('workflowJson', itemIndex) as string; + try { + workflowInfo.code = JSON.parse(workflowJson) as IWorkflowBase; + // subworkflow is same as parent workflow + subWorkflowId = workflowProxy.$workflow.id; + } catch (error) { + throw new NodeOperationError( + this.context.getNode(), + `The provided workflow is not valid JSON: "${(error as Error).message}"`, + { itemIndex }, + ); + } + } + + return { workflowInfo, subWorkflowId: subWorkflowId! }; + } + + private prepareRawData(query: string | IDataObject, itemIndex: number): IDataObject { + const rawData: IDataObject = { query }; + const workflowFieldsJson = this.context.getNodeParameter('fields.values', itemIndex, [], { + rawExpressions: true, + }) as SetField[]; + + // Copied from Set Node v2 + for (const entry of workflowFieldsJson) { + if (entry.type === 'objectValue' && (entry.objectValue as string).startsWith('=')) { + rawData[entry.name] = (entry.objectValue as string).replace(/^=+/, ''); + } + } + + return rawData; + } + + /** + * Prepares the sub-workflow items for execution + */ + private async prepareWorkflowItems( + query: string | IDataObject, + itemIndex: number, + rawData: IDataObject, + ): Promise { + const options: SetNodeOptions = { include: 'all' }; + let jsonData = typeof query === 'object' ? query : { query }; + + if (this.useSchema) { + const currentWorkflowInputs = getCurrentWorkflowInputData.call(this.context); + jsonData = currentWorkflowInputs[itemIndex].json; + } + + const newItem = await manual.execute.call( + this.context, + { json: jsonData }, + itemIndex, + options, + rawData, + this.context.getNode(), + ); + + return [newItem] as INodeExecutionData[]; + } + + /** + * Create structured tool by parsing the sub-workflow input schema + */ + private async createStructuredTool( + name: string, + description: string, + func: (query: string | IDataObject, runManager?: CallbackManagerForToolRun) => Promise, + ): Promise { + const fromAIParser = new AIParametersParser(this.context); + const collectedArguments = await this.extractFromAIParameters(fromAIParser); + + // If there are no `fromAI` arguments, fallback to creating a simple tool + if (collectedArguments.length === 0) { + return new DynamicTool({ name, description, func }); + } + + // Otherwise, prepare Zod schema and create a structured tool + const schema = this.createZodSchema(collectedArguments, fromAIParser); + return new DynamicStructuredTool({ schema, name, description, func }); + } + + private async extractFromAIParameters( + fromAIParser: AIParametersParser, + ): Promise { + const collectedArguments: FromAIArgument[] = []; + fromAIParser.traverseNodeParameters(this.context.getNode().parameters, collectedArguments); + + const uniqueArgsMap = new Map(); + for (const arg of collectedArguments) { + uniqueArgsMap.set(arg.key, arg); + } + + return Array.from(uniqueArgsMap.values()); + } + + private createZodSchema(args: FromAIArgument[], parser: AIParametersParser): z.ZodObject { + const schemaObj = args.reduce((acc: Record, placeholder) => { + acc[placeholder.key] = parser.generateZodSchema(placeholder); + return acc; + }, {}); + + return z.object(schemaObj).required(); + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/versionDescription.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/versionDescription.ts new file mode 100644 index 0000000000000..469a7d6d4cb34 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/versionDescription.ts @@ -0,0 +1,151 @@ +/* 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 { getConnectionHintNoticeField } from '../../../../utils/sharedFields'; + +export const versionDescription: INodeTypeDescription = { + displayName: 'Call n8n Workflow Tool', + name: 'toolWorkflow', + icon: 'fa:network-wired', + group: ['transform'], + description: 'Uses another n8n workflow as a tool. Allows packaging any n8n node(s) as a tool.', + defaults: { + name: 'Call n8n Workflow Tool', + }, + version: [2], + inputs: [], + outputs: [NodeConnectionType.AiTool], + outputNames: ['Tool'], + properties: [ + getConnectionHintNoticeField([NodeConnectionType.AiAgent]), + { + displayName: + 'See an example of a workflow to suggest meeting slots using AI here.', + name: 'noticeTemplateExample', + type: 'notice', + default: '', + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + placeholder: 'e.g. My_Color_Tool', + validateType: 'string-alphanumeric', + description: + 'The name of the function to be called, could contain letters, numbers, and underscores only', + }, + { + displayName: 'Description', + name: 'description', + type: 'string', + default: '', + placeholder: + 'Call this tool to get a random color. The input should be a string with comma separated names of colors to exclude.', + typeOptions: { + rows: 3, + }, + }, + + { + displayName: + 'This tool will call the workflow you define below, and look in the last node for the response. The workflow needs to start with an Execute Workflow trigger', + name: 'executeNotice', + type: 'notice', + default: '', + }, + + { + displayName: 'Source', + name: 'source', + type: 'options', + options: [ + { + name: 'Database', + value: 'database', + description: 'Load the workflow from the database by ID', + }, + { + name: 'Define Below', + value: 'parameter', + description: 'Pass the JSON code of a workflow', + }, + ], + default: 'database', + description: 'Where to get the workflow to execute from', + }, + + // ---------------------------------- + // source:database + // ---------------------------------- + { + displayName: 'Workflow', + name: 'workflowId', + type: 'workflowSelector', + displayOptions: { + show: { + source: ['database'], + }, + }, + default: '', + required: true, + }, + // ----------------------------------------------- + // Resource mapper for workflow inputs + // ----------------------------------------------- + { + displayName: 'Workflow Inputs', + name: 'workflowInputs', + type: 'resourceMapper', + noDataExpression: true, + default: { + mappingMode: 'defineBelow', + value: null, + }, + required: true, + typeOptions: { + loadOptionsDependsOn: ['workflowId.value'], + resourceMapper: { + localResourceMapperMethod: 'loadWorkflowInputMappings', + valuesLabel: 'Workflow Inputs', + mode: 'map', + fieldWords: { + singular: 'workflow input', + plural: 'workflow inputs', + }, + addAllFields: true, + multiKeyMatch: false, + supportAutoMap: false, + }, + }, + displayOptions: { + show: { + source: ['database'], + }, + hide: { + workflowId: [''], + }, + }, + }, + // ---------------------------------- + // source:parameter + // ---------------------------------- + { + displayName: 'Workflow JSON', + name: 'workflowJson', + type: 'json', + typeOptions: { + rows: 10, + }, + displayOptions: { + show: { + source: ['parameter'], + }, + }, + default: '\n\n\n\n\n\n\n\n\n', + required: true, + description: 'The workflow JSON code to execute', + }, + ], +}; 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 489b4fe28beed..27fb1bcd359a4 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, commonCORSParameters } from 'n8n-workflow'; +import { Node, NodeConnectionType } from 'n8n-workflow'; import type { IDataObject, IWebhookFunctions, @@ -241,14 +241,19 @@ export class ChatTrigger extends Node { default: {}, options: [ // CORS parameters are only valid for when chat is used in hosted or webhook mode - ...commonCORSParameters.map((p) => ({ - ...p, + { + displayName: 'Allowed Origins (CORS)', + name: 'allowedOrigins', + type: 'string', + default: '*', + description: + 'Comma-separated list of URLs allowed for cross-origin non-preflight requests. Use * (default) to allow all origins.', displayOptions: { show: { '/mode': ['hostedChat', 'webhook'], }, }, - })), + }, { ...allowFileUploadsOption, displayOptions: { diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/GenericFunctions.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/GenericFunctions.ts index 02620ab797cf6..c065569eb2916 100644 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/GenericFunctions.ts +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/GenericFunctions.ts @@ -1,5 +1,6 @@ -import type { ICredentialDataDecryptedObject, IWebhookFunctions } from 'n8n-workflow'; import basicAuth from 'basic-auth'; +import type { ICredentialDataDecryptedObject, IWebhookFunctions } from 'n8n-workflow'; + import { ChatTriggerAuthorizationError } from './error'; import type { AuthenticationChatOption } from './types'; 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 5508f957f8ff5..0323478ee8c58 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 @@ -1,4 +1,5 @@ import type { INodeProperties } from 'n8n-workflow'; + import { createVectorStoreNode } from '../shared/createVectorStoreNode'; import { MemoryVectorStoreManager } from '../shared/MemoryVectorStoreManager'; @@ -25,6 +26,7 @@ export class VectorStoreInMemory extends createVectorStoreNode({ name: 'vectorStoreInMemory', description: 'Work with your data in In-Memory Vector Store', icon: 'fa:database', + iconColor: 'black', docsUrl: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.vectorstoreinmemory/', }, diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemoryInsert/VectorStoreInMemoryInsert.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemoryInsert/VectorStoreInMemoryInsert.node.ts index 225201a5e1265..34d48150343c6 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemoryInsert/VectorStoreInMemoryInsert.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemoryInsert/VectorStoreInMemoryInsert.node.ts @@ -1,4 +1,6 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import type { Embeddings } from '@langchain/core/embeddings'; +import type { Document } from 'langchain/document'; import { NodeConnectionType, type INodeExecutionData, @@ -6,11 +8,11 @@ import { type INodeType, type INodeTypeDescription, } from 'n8n-workflow'; -import type { Document } from 'langchain/document'; -import type { Embeddings } from '@langchain/core/embeddings'; -import type { N8nJsonLoader } from '../../../utils/N8nJsonLoader'; -import { processDocuments } from '../shared/processDocuments'; + +import type { N8nJsonLoader } from '@utils/N8nJsonLoader'; + import { MemoryVectorStoreManager } from '../shared/MemoryVectorStoreManager'; +import { processDocuments } from '../shared/processDocuments'; // This node is deprecated. Use VectorStoreInMemory instead. export class VectorStoreInMemoryInsert implements INodeType { 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 7bf48c3d8cfda..dd2def31e36fe 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,4 +1,5 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import type { Embeddings } from '@langchain/core/embeddings'; import { NodeConnectionType, type INodeType, @@ -6,9 +7,10 @@ import { type ISupplyDataFunctions, type SupplyData, } from 'n8n-workflow'; -import type { Embeddings } from '@langchain/core/embeddings'; + +import { logWrapper } from '@utils/logWrapper'; + import { MemoryVectorStoreManager } from '../shared/MemoryVectorStoreManager'; -import { logWrapper } from '../../../utils/logWrapper'; // This node is deprecated. Use VectorStoreInMemory instead. export class VectorStoreInMemoryLoad implements INodeType { diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePGVector/VectorStorePGVector.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePGVector/VectorStorePGVector.node.ts index 8336958cc5dfc..6d5da1615b036 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePGVector/VectorStorePGVector.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePGVector/VectorStorePGVector.node.ts @@ -9,7 +9,8 @@ import { configurePostgres } from 'n8n-nodes-base/dist/nodes/Postgres/v2/transpo import type { INodeProperties } from 'n8n-workflow'; import type pg from 'pg'; -import { metadataFilterField } from '../../../utils/sharedFields'; +import { metadataFilterField } from '@utils/sharedFields'; + import { createVectorStoreNode } from '../shared/createVectorStoreNode'; type CollectionOptions = { diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePinecone/VectorStorePinecone.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePinecone/VectorStorePinecone.node.ts index d153979ef4c1c..711425df55edf 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePinecone/VectorStorePinecone.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePinecone/VectorStorePinecone.node.ts @@ -1,9 +1,11 @@ -import { NodeOperationError, type INodeProperties } from 'n8n-workflow'; import type { PineconeStoreParams } from '@langchain/pinecone'; import { PineconeStore } from '@langchain/pinecone'; import { Pinecone } from '@pinecone-database/pinecone'; +import { NodeOperationError, type INodeProperties } from 'n8n-workflow'; + +import { metadataFilterField } from '@utils/sharedFields'; + import { createVectorStoreNode } from '../shared/createVectorStoreNode'; -import { metadataFilterField } from '../../../utils/sharedFields'; import { pineconeIndexRLC } from '../shared/descriptions'; import { pineconeIndexSearch } from '../shared/methods/listSearch'; @@ -54,7 +56,7 @@ export class VectorStorePinecone extends createVectorStoreNode({ displayName: 'Pinecone Vector Store', name: 'vectorStorePinecone', description: 'Work with your data in Pinecone Vector Store', - icon: 'file:pinecone.svg', + icon: { light: 'file:pinecone.svg', dark: 'file:pinecone.dark.svg' }, docsUrl: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.vectorstorepinecone/', credentials: [ diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePinecone/pinecone.dark.svg b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePinecone/pinecone.dark.svg new file mode 100644 index 0000000000000..4d163c6784eb6 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePinecone/pinecone.dark.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePinecone/pinecone.svg b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePinecone/pinecone.svg index b94b8b3af6696..e9884a4249f2c 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePinecone/pinecone.svg +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePinecone/pinecone.svg @@ -1 +1,21 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + 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 023b65be84493..6c10ff1427b91 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 @@ -1,3 +1,7 @@ +import type { Document } from '@langchain/core/documents'; +import type { Embeddings } from '@langchain/core/embeddings'; +import { PineconeStore } from '@langchain/pinecone'; +import { Pinecone } from '@pinecone-database/pinecone'; import { type IExecuteFunctions, type INodeType, @@ -5,15 +9,12 @@ import { type INodeExecutionData, NodeConnectionType, } from 'n8n-workflow'; -import type { Embeddings } from '@langchain/core/embeddings'; -import type { Document } from '@langchain/core/documents'; -import { PineconeStore } from '@langchain/pinecone'; -import { Pinecone } from '@pinecone-database/pinecone'; -import type { N8nJsonLoader } from '../../../utils/N8nJsonLoader'; -import { processDocuments } from '../shared/processDocuments'; +import type { N8nJsonLoader } from '@utils/N8nJsonLoader'; + import { pineconeIndexRLC } from '../shared/descriptions'; import { pineconeIndexSearch } from '../shared/methods/listSearch'; +import { processDocuments } from '../shared/processDocuments'; // This node is deprecated. Use VectorStorePinecone instead. export class VectorStorePineconeInsert implements INodeType { 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 d46bccd9f793f..54eea2e902ebb 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 @@ -1,3 +1,7 @@ +import type { Embeddings } from '@langchain/core/embeddings'; +import type { PineconeStoreParams } from '@langchain/pinecone'; +import { PineconeStore } from '@langchain/pinecone'; +import { Pinecone } from '@pinecone-database/pinecone'; import { NodeConnectionType, type INodeType, @@ -5,14 +9,11 @@ import { type ISupplyDataFunctions, type SupplyData, } from 'n8n-workflow'; -import type { PineconeStoreParams } from '@langchain/pinecone'; -import { PineconeStore } from '@langchain/pinecone'; -import { Pinecone } from '@pinecone-database/pinecone'; -import type { Embeddings } from '@langchain/core/embeddings'; -import { logWrapper } from '../../../utils/logWrapper'; -import { metadataFilterField } from '../../../utils/sharedFields'; -import { getMetadataFiltersValues } from '../../../utils/helpers'; +import { getMetadataFiltersValues } from '@utils/helpers'; +import { logWrapper } from '@utils/logWrapper'; +import { metadataFilterField } from '@utils/sharedFields'; + import { pineconeIndexRLC } from '../shared/descriptions'; import { pineconeIndexSearch } from '../shared/methods/listSearch'; diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreQdrant/VectorStoreQdrant.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreQdrant/VectorStoreQdrant.node.ts index 0b5859e0bc191..988f607ad75ef 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreQdrant/VectorStoreQdrant.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreQdrant/VectorStoreQdrant.node.ts @@ -1,12 +1,13 @@ -import type { IDataObject, INodeProperties } from 'n8n-workflow'; +import type { Callbacks } from '@langchain/core/callbacks/manager'; +import type { Embeddings } from '@langchain/core/embeddings'; import type { QdrantLibArgs } from '@langchain/qdrant'; import { QdrantVectorStore } from '@langchain/qdrant'; import type { Schemas as QdrantSchemas } from '@qdrant/js-client-rest'; +import type { IDataObject, INodeProperties } from 'n8n-workflow'; + import { createVectorStoreNode } from '../shared/createVectorStoreNode'; import { qdrantCollectionRLC } from '../shared/descriptions'; import { qdrantCollectionsSearch } from '../shared/methods/listSearch'; -import type { Embeddings } from '@langchain/core/embeddings'; -import type { Callbacks } from '@langchain/core/callbacks/manager'; class ExtendedQdrantVectorStore extends QdrantVectorStore { private static defaultFilter: IDataObject = {}; diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabase/VectorStoreSupabase.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabase/VectorStoreSupabase.node.ts index 549fcd5e7f364..b1b80fea5a153 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabase/VectorStoreSupabase.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabase/VectorStoreSupabase.node.ts @@ -1,8 +1,10 @@ -import { NodeOperationError, type INodeProperties } from 'n8n-workflow'; -import { createClient } from '@supabase/supabase-js'; import { SupabaseVectorStore } from '@langchain/community/vectorstores/supabase'; +import { createClient } from '@supabase/supabase-js'; +import { NodeOperationError, type INodeProperties } from 'n8n-workflow'; + +import { metadataFilterField } from '@utils/sharedFields'; + import { createVectorStoreNode } from '../shared/createVectorStoreNode'; -import { metadataFilterField } from '../../../utils/sharedFields'; import { supabaseTableNameRLC } from '../shared/descriptions'; import { supabaseTableNameSearch } from '../shared/methods/listSearch'; 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 44b3a6a39741c..332f534fe6b67 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 @@ -1,3 +1,7 @@ +import { SupabaseVectorStore } from '@langchain/community/vectorstores/supabase'; +import type { Document } from '@langchain/core/documents'; +import type { Embeddings } from '@langchain/core/embeddings'; +import { createClient } from '@supabase/supabase-js'; import { type IExecuteFunctions, type INodeType, @@ -5,15 +9,12 @@ import { type INodeExecutionData, NodeConnectionType, } from 'n8n-workflow'; -import type { Embeddings } from '@langchain/core/embeddings'; -import type { Document } from '@langchain/core/documents'; -import { createClient } from '@supabase/supabase-js'; -import { SupabaseVectorStore } from '@langchain/community/vectorstores/supabase'; -import type { N8nJsonLoader } from '../../../utils/N8nJsonLoader'; -import { processDocuments } from '../shared/processDocuments'; +import type { N8nJsonLoader } from '@utils/N8nJsonLoader'; + import { supabaseTableNameRLC } from '../shared/descriptions'; import { supabaseTableNameSearch } from '../shared/methods/listSearch'; +import { processDocuments } from '../shared/processDocuments'; // This node is deprecated. Use VectorStoreSupabase instead. export class VectorStoreSupabaseInsert implements INodeType { 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 f4bdc49e44325..eae056adb97ce 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 @@ -1,3 +1,7 @@ +import type { SupabaseLibArgs } from '@langchain/community/vectorstores/supabase'; +import { SupabaseVectorStore } from '@langchain/community/vectorstores/supabase'; +import type { Embeddings } from '@langchain/core/embeddings'; +import { createClient } from '@supabase/supabase-js'; import { type INodeType, type INodeTypeDescription, @@ -5,13 +9,11 @@ import { type SupplyData, NodeConnectionType, } from 'n8n-workflow'; -import type { Embeddings } from '@langchain/core/embeddings'; -import { createClient } from '@supabase/supabase-js'; -import type { SupabaseLibArgs } from '@langchain/community/vectorstores/supabase'; -import { SupabaseVectorStore } from '@langchain/community/vectorstores/supabase'; -import { logWrapper } from '../../../utils/logWrapper'; -import { metadataFilterField } from '../../../utils/sharedFields'; -import { getMetadataFiltersValues } from '../../../utils/helpers'; + +import { getMetadataFiltersValues } from '@utils/helpers'; +import { logWrapper } from '@utils/logWrapper'; +import { metadataFilterField } from '@utils/sharedFields'; + import { supabaseTableNameRLC } from '../shared/descriptions'; import { supabaseTableNameSearch } from '../shared/methods/listSearch'; diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreZep/VectorStoreZep.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreZep/VectorStoreZep.node.ts index 184b720d31242..d6e8914ae54b8 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreZep/VectorStoreZep.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreZep/VectorStoreZep.node.ts @@ -1,9 +1,11 @@ -import type { IDataObject, INodeProperties } from 'n8n-workflow'; -import { NodeOperationError } from 'n8n-workflow'; import type { IZepConfig } from '@langchain/community/vectorstores/zep'; import { ZepVectorStore } from '@langchain/community/vectorstores/zep'; +import type { IDataObject, INodeProperties } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +import { metadataFilterField } from '@utils/sharedFields'; + import { createVectorStoreNode } from '../shared/createVectorStoreNode'; -import { metadataFilterField } from '../../../utils/sharedFields'; const embeddingDimensions: INodeProperties = { displayName: 'Embedding Dimensions', 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 3b40e07d650ed..4892d8ad85d00 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 @@ -1,3 +1,6 @@ +import { ZepVectorStore } from '@langchain/community/vectorstores/zep'; +import type { Document } from '@langchain/core/documents'; +import type { Embeddings } from '@langchain/core/embeddings'; import { type IExecuteFunctions, type INodeType, @@ -5,10 +8,9 @@ import { type INodeExecutionData, NodeConnectionType, } from 'n8n-workflow'; -import { ZepVectorStore } from '@langchain/community/vectorstores/zep'; -import type { Embeddings } from '@langchain/core/embeddings'; -import type { Document } from '@langchain/core/documents'; -import type { N8nJsonLoader } from '../../../utils/N8nJsonLoader'; + +import type { N8nJsonLoader } from '@utils/N8nJsonLoader'; + import { processDocuments } from '../shared/processDocuments'; // This node is deprecated. Use VectorStoreZep instead. 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 dd30a0808e189..040b845e57c27 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 @@ -1,3 +1,6 @@ +import type { IZepConfig } from '@langchain/community/vectorstores/zep'; +import { ZepVectorStore } from '@langchain/community/vectorstores/zep'; +import type { Embeddings } from '@langchain/core/embeddings'; import { NodeConnectionType, type INodeType, @@ -5,12 +8,10 @@ import { type ISupplyDataFunctions, type SupplyData, } from 'n8n-workflow'; -import type { IZepConfig } from '@langchain/community/vectorstores/zep'; -import { ZepVectorStore } from '@langchain/community/vectorstores/zep'; -import type { Embeddings } from '@langchain/core/embeddings'; -import { metadataFilterField } from '../../../utils/sharedFields'; -import { getMetadataFiltersValues } from '../../../utils/helpers'; -import { logWrapper } from '../../../utils/logWrapper'; + +import { getMetadataFiltersValues } from '@utils/helpers'; +import { logWrapper } from '@utils/logWrapper'; +import { metadataFilterField } from '@utils/sharedFields'; // This node is deprecated. Use VectorStoreZep instead. export class VectorStoreZepLoad implements INodeType { 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 index 6229868f320ad..1088505a0a714 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryVectorStoreManager.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryVectorStoreManager.test.ts @@ -1,7 +1,7 @@ import type { OpenAIEmbeddings } from '@langchain/openai'; +import { mock } from 'jest-mock-extended'; import { MemoryVectorStoreManager } from './MemoryVectorStoreManager'; -import { mock } from 'jest-mock-extended'; describe('MemoryVectorStoreManager', () => { it('should create an instance of MemoryVectorStoreManager', () => { diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryVectorStoreManager.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryVectorStoreManager.ts index 5c507a5196f17..f92c8abd41166 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryVectorStoreManager.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/MemoryVectorStoreManager.ts @@ -11,7 +11,7 @@ export class MemoryVectorStoreManager { this.vectorStoreBuffer = new Map(); } - public static getInstance(embeddings: Embeddings): MemoryVectorStoreManager { + static getInstance(embeddings: Embeddings): MemoryVectorStoreManager { if (!MemoryVectorStoreManager.instance) { MemoryVectorStoreManager.instance = new MemoryVectorStoreManager(embeddings); } else { @@ -27,7 +27,7 @@ export class MemoryVectorStoreManager { return MemoryVectorStoreManager.instance; } - public async getVectorStore(memoryKey: string): Promise { + async getVectorStore(memoryKey: string): Promise { let vectorStoreInstance = this.vectorStoreBuffer.get(memoryKey); if (!vectorStoreInstance) { @@ -38,7 +38,7 @@ export class MemoryVectorStoreManager { return vectorStoreInstance; } - public async addDocuments( + async addDocuments( memoryKey: string, documents: Document[], clearStore?: boolean, diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode.ts index 2de9304fc57f9..84f1d550e5032 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode.ts @@ -17,14 +17,16 @@ import type { INodeListSearchResult, Icon, INodePropertyOptions, + ThemeIconColor, } from 'n8n-workflow'; +import { getMetadataFiltersValues, logAiEvent } from '@utils/helpers'; +import { logWrapper } from '@utils/logWrapper'; +import type { N8nBinaryLoader } from '@utils/N8nBinaryLoader'; +import { N8nJsonLoader } from '@utils/N8nJsonLoader'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; + import { processDocument } from './processDocuments'; -import { getMetadataFiltersValues, logAiEvent } from '../../../utils/helpers'; -import { logWrapper } from '../../../utils/logWrapper'; -import type { N8nBinaryLoader } from '../../../utils/N8nBinaryLoader'; -import { N8nJsonLoader } from '../../../utils/N8nJsonLoader'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; type NodeOperationMode = 'insert' | 'load' | 'retrieve' | 'update'; @@ -36,6 +38,7 @@ interface NodeMeta { description: string; docsUrl: string; icon: Icon; + iconColor?: ThemeIconColor; credentials?: INodeCredentialDescription[]; operationModes?: NodeOperationMode[]; } @@ -124,6 +127,7 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) => name: args.meta.name, description: args.meta.description, icon: args.meta.icon, + iconColor: args.meta.iconColor, group: ['transform'], version: 1, defaults: { diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/methods/listSearch.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/methods/listSearch.ts index f12ff5d5cf0b9..278d879f90aad 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/methods/listSearch.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/methods/listSearch.ts @@ -1,6 +1,6 @@ -import { ApplicationError, type IDataObject, type ILoadOptionsFunctions } from 'n8n-workflow'; import { Pinecone } from '@pinecone-database/pinecone'; import { QdrantClient } from '@qdrant/js-client-rest'; +import { ApplicationError, type IDataObject, type ILoadOptionsFunctions } from 'n8n-workflow'; export async function pineconeIndexSearch(this: ILoadOptionsFunctions) { const credentials = await this.getCredentials('pineconeApi'); diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/processDocuments.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/processDocuments.ts index 5a3847d6911a1..0c28d8db2578f 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/processDocuments.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/processDocuments.ts @@ -1,7 +1,8 @@ import type { Document } from '@langchain/core/documents'; import type { INodeExecutionData } from 'n8n-workflow'; -import { N8nJsonLoader } from '../../../utils/N8nJsonLoader'; -import { N8nBinaryLoader } from '../../../utils/N8nBinaryLoader'; + +import { N8nBinaryLoader } from '@utils/N8nBinaryLoader'; +import { N8nJsonLoader } from '@utils/N8nJsonLoader'; export async function processDocuments( documentInput: N8nJsonLoader | N8nBinaryLoader | Array>>, diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/OpenAi.node.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/OpenAi.node.ts index 1743c6961859f..251618c01e3b2 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/OpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/OpenAi.node.ts @@ -1,4 +1,5 @@ import type { IExecuteFunctions, INodeType } from 'n8n-workflow'; + import { router } from './actions/router'; import { versionDescription } from './actions/versionDescription'; import { listSearch, loadOptions } from './methods'; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/create.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/create.operation.ts index b9a0dee535907..3ac5e453527e2 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/create.operation.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/create.operation.ts @@ -5,6 +5,7 @@ import type { IDataObject, } from 'n8n-workflow'; import { NodeOperationError, updateDisplayOptions } from 'n8n-workflow'; + import { apiRequest } from '../../transport'; import { modelRLC } from '../descriptions'; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/deleteAssistant.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/deleteAssistant.operation.ts index 099e5fd3e6402..7287d8ebfdb61 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/deleteAssistant.operation.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/deleteAssistant.operation.ts @@ -1,5 +1,6 @@ import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; import { updateDisplayOptions } from 'n8n-workflow'; + import { apiRequest } from '../../transport'; import { assistantRLC } from '../descriptions'; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/index.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/index.ts index 3f869ffcc877c..5973319492f6f 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/index.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/index.ts @@ -2,8 +2,8 @@ import type { INodeProperties } from 'n8n-workflow'; import * as create from './create.operation'; import * as deleteAssistant from './deleteAssistant.operation'; -import * as message from './message.operation'; import * as list from './list.operation'; +import * as message from './message.operation'; import * as update from './update.operation'; export { create, deleteAssistant, message, list, update }; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/list.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/list.operation.ts index c029af4d4836e..ec75be1cfd812 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/list.operation.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/list.operation.ts @@ -1,5 +1,6 @@ import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; import { updateDisplayOptions } from 'n8n-workflow'; + import { apiRequest } from '../../transport'; const properties: INodeProperties[] = [ 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 959d487abe2a4..bc22ac948fe01 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 @@ -18,9 +18,10 @@ import { } from 'n8n-workflow'; import { OpenAI as OpenAIClient } from 'openai'; -import { promptTypeOptions } from '../../../../../utils/descriptions'; -import { getConnectedTools } from '../../../../../utils/helpers'; -import { getTracingConfig } from '../../../../../utils/tracing'; +import { promptTypeOptions } from '@utils/descriptions'; +import { getConnectedTools } from '@utils/helpers'; +import { getTracingConfig } from '@utils/tracing'; + import { formatToOpenAIAssistantTool } from '../../helpers/utils'; import { assistantRLC } from '../descriptions'; @@ -105,6 +106,11 @@ const properties: INodeProperties[] = [ default: 'https://api.openai.com/v1', description: 'Override the default base URL for the API', type: 'string', + displayOptions: { + hide: { + '@version': [{ _cnd: { gte: 1.8 } }], + }, + }, }, { displayName: 'Max Retries', @@ -181,11 +187,13 @@ export async function execute(this: IExecuteFunctions, i: number): Promise { diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/OpenAi.node.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/OpenAi.node.test.ts index a78260844c87a..b8a32c45bfdc6 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/OpenAi.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/OpenAi.node.test.ts @@ -1,11 +1,11 @@ -import type { IDataObject, IExecuteFunctions } from 'n8n-workflow'; import get from 'lodash/get'; +import type { IDataObject, IExecuteFunctions } from 'n8n-workflow'; + import * as assistant from '../actions/assistant'; import * as audio from '../actions/audio'; import * as file from '../actions/file'; import * as image from '../actions/image'; import * as text from '../actions/text'; - import * as transport from '../transport'; const createExecuteFunctionsMock = (parameters: IDataObject) => { diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index f086f2e82f617..8945d9ba43308 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -1,19 +1,18 @@ { "name": "@n8n/n8n-nodes-langchain", - "version": "1.71.0", + "version": "1.73.0", "description": "", "main": "index.js", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm run watch", "typecheck": "tsc --noEmit", - "build": "tsc -p tsconfig.build.json && pnpm n8n-copy-icons && pnpm build:metadata", - "build:metadata": "pnpm n8n-generate-known && pnpm n8n-generate-ui-types", + "build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && pnpm n8n-copy-icons && pnpm n8n-generate-metadata", "format": "biome format --write .", "format:check": "biome ci .", - "lint": "eslint nodes credentials --quiet", - "lintfix": "eslint nodes credentials --fix", - "watch": "tsc-watch -p tsconfig.build.json --onCompilationComplete \"tsc-alias -p tsconfig.build.json\" --onSuccess \"pnpm n8n-generate-ui-types\"", + "lint": "eslint nodes credentials utils --quiet", + "lintfix": "eslint nodes credentials utils --fix", + "watch": "tsc-watch -p tsconfig.build.json --onCompilationComplete \"tsc-alias -p tsconfig.build.json\" --onSuccess \"pnpm n8n-generate-metadata\"", "test": "jest", "test:dev": "jest --watch" }, diff --git a/packages/@n8n/nodes-langchain/tsconfig.json b/packages/@n8n/nodes-langchain/tsconfig.json index a0bd21149ff1c..d72fd76a4af4d 100644 --- a/packages/@n8n/nodes-langchain/tsconfig.json +++ b/packages/@n8n/nodes-langchain/tsconfig.json @@ -1,6 +1,9 @@ { "extends": ["../../../tsconfig.json", "../../../tsconfig.backend.json"], "compilerOptions": { + "paths": { + "@utils/*": ["./utils/*"] + }, "tsBuildInfoFile": "dist/typecheck.tsbuildinfo", // TODO: remove all options below this line "useUnknownInCatchVariables": false diff --git a/packages/@n8n/nodes-langchain/utils/N8nBinaryLoader.ts b/packages/@n8n/nodes-langchain/utils/N8nBinaryLoader.ts index 53f4f95a74013..cca2244793815 100644 --- a/packages/@n8n/nodes-langchain/utils/N8nBinaryLoader.ts +++ b/packages/@n8n/nodes-langchain/utils/N8nBinaryLoader.ts @@ -1,5 +1,12 @@ -import { pipeline } from 'stream/promises'; +import { CSVLoader } from '@langchain/community/document_loaders/fs/csv'; +import { DocxLoader } from '@langchain/community/document_loaders/fs/docx'; +import { EPubLoader } from '@langchain/community/document_loaders/fs/epub'; +import { PDFLoader } from '@langchain/community/document_loaders/fs/pdf'; +import type { Document } from '@langchain/core/documents'; +import type { TextSplitter } from '@langchain/textsplitters'; import { createWriteStream } from 'fs'; +import { JSONLoader } from 'langchain/document_loaders/fs/json'; +import { TextLoader } from 'langchain/document_loaders/fs/text'; import type { IBinaryData, IExecuteFunctions, @@ -7,15 +14,7 @@ import type { ISupplyDataFunctions, } from 'n8n-workflow'; import { NodeOperationError, BINARY_ENCODING } from 'n8n-workflow'; - -import type { TextSplitter } from '@langchain/textsplitters'; -import type { Document } from '@langchain/core/documents'; -import { CSVLoader } from '@langchain/community/document_loaders/fs/csv'; -import { DocxLoader } from '@langchain/community/document_loaders/fs/docx'; -import { JSONLoader } from 'langchain/document_loaders/fs/json'; -import { PDFLoader } from '@langchain/community/document_loaders/fs/pdf'; -import { TextLoader } from 'langchain/document_loaders/fs/text'; -import { EPubLoader } from '@langchain/community/document_loaders/fs/epub'; +import { pipeline } from 'stream/promises'; import { file as tmpFile, type DirectoryResult } from 'tmp-promise'; import { getMetadataFiltersValues } from './helpers'; diff --git a/packages/@n8n/nodes-langchain/utils/N8nJsonLoader.ts b/packages/@n8n/nodes-langchain/utils/N8nJsonLoader.ts index 7c44d8a8f9c02..de5add3e2664c 100644 --- a/packages/@n8n/nodes-langchain/utils/N8nJsonLoader.ts +++ b/packages/@n8n/nodes-langchain/utils/N8nJsonLoader.ts @@ -1,3 +1,7 @@ +import type { Document } from '@langchain/core/documents'; +import type { TextSplitter } from '@langchain/textsplitters'; +import { JSONLoader } from 'langchain/document_loaders/fs/json'; +import { TextLoader } from 'langchain/document_loaders/fs/text'; import { type IExecuteFunctions, type INodeExecutionData, @@ -5,10 +9,6 @@ import { NodeOperationError, } from 'n8n-workflow'; -import type { TextSplitter } from '@langchain/textsplitters'; -import type { Document } from '@langchain/core/documents'; -import { JSONLoader } from 'langchain/document_loaders/fs/json'; -import { TextLoader } from 'langchain/document_loaders/fs/text'; import { getMetadataFiltersValues } from './helpers'; export class N8nJsonLoader { diff --git a/packages/@n8n/nodes-langchain/utils/N8nTool.test.ts b/packages/@n8n/nodes-langchain/utils/N8nTool.test.ts index 6f12b18079551..40a1ca70d3526 100644 --- a/packages/@n8n/nodes-langchain/utils/N8nTool.test.ts +++ b/packages/@n8n/nodes-langchain/utils/N8nTool.test.ts @@ -1,8 +1,9 @@ -import { N8nTool } from './N8nTool'; +import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools'; import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers'; -import { z } from 'zod'; import type { INode } from 'n8n-workflow'; -import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +import { N8nTool } from './N8nTool'; const mockNode: INode = { id: '1', diff --git a/packages/@n8n/nodes-langchain/utils/N8nTool.ts b/packages/@n8n/nodes-langchain/utils/N8nTool.ts index 2cb89630f0113..f568955beb7f8 100644 --- a/packages/@n8n/nodes-langchain/utils/N8nTool.ts +++ b/packages/@n8n/nodes-langchain/utils/N8nTool.ts @@ -1,8 +1,8 @@ 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 { StructuredOutputParser } from 'langchain/output_parsers'; import type { ZodTypeAny } from 'zod'; import { ZodBoolean, ZodNullable, ZodNumber, ZodObject, ZodOptional } from 'zod'; diff --git a/packages/@n8n/nodes-langchain/utils/descriptions.ts b/packages/@n8n/nodes-langchain/utils/descriptions.ts index ef683df3e9539..e629bab812729 100644 --- a/packages/@n8n/nodes-langchain/utils/descriptions.ts +++ b/packages/@n8n/nodes-langchain/utils/descriptions.ts @@ -71,13 +71,12 @@ export const promptTypeOptions: INodeProperties = { type: 'options', options: [ { - // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased - name: 'Take from previous node automatically', + name: 'Connected Chat Trigger Node', value: 'auto', - description: 'Looks for an input field called chatInput', + description: + "Looks for an input field called 'chatInput' that is coming from a directly connected Chat Trigger", }, { - // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased name: 'Define below', value: 'define', description: 'Use an expression to reference data in previous nodes or enter static text', diff --git a/packages/@n8n/nodes-langchain/utils/helpers.ts b/packages/@n8n/nodes-langchain/utils/helpers.ts index f19cf67153526..6b5816e7b8af1 100644 --- a/packages/@n8n/nodes-langchain/utils/helpers.ts +++ b/packages/@n8n/nodes-langchain/utils/helpers.ts @@ -130,7 +130,7 @@ export function getSessionId( if (sessionId === '' || sessionId === undefined) { throw new NodeOperationError(ctx.getNode(), 'Key parameter is empty', { description: - "Provide a key to use as session ID in the 'Key' parameter or use the 'Take from previous node automatically' option to use the session ID from the previous node, e.t. chat trigger node", + "Provide a key to use as session ID in the 'Key' parameter or use the 'Connected Chat Trigger Node' option to use the session ID from your Chat Trigger", itemIndex, }); } diff --git a/packages/@n8n/nodes-langchain/utils/logWrapper.ts b/packages/@n8n/nodes-langchain/utils/logWrapper.ts index fa1a38b31a5e7..073b00516290a 100644 --- a/packages/@n8n/nodes-langchain/utils/logWrapper.ts +++ b/packages/@n8n/nodes-langchain/utils/logWrapper.ts @@ -394,7 +394,6 @@ export function logWrapper( return async ( query: string, k?: number, - // @ts-ignore filter?: BiquadFilterType | undefined, _callbacks?: Callbacks | undefined, ): Promise => { diff --git a/packages/@n8n/task-runner/jest.config.js b/packages/@n8n/task-runner/jest.config.js index 5c3abe1ef788c..2a3a7a1e02559 100644 --- a/packages/@n8n/task-runner/jest.config.js +++ b/packages/@n8n/task-runner/jest.config.js @@ -1,5 +1,6 @@ /** @type {import('jest').Config} */ module.exports = { ...require('../../../jest.config'), + setupFilesAfterEnv: ['n8n-workflow/test/setup.ts'], testTimeout: 10_000, }; diff --git a/packages/@n8n/task-runner/package.json b/packages/@n8n/task-runner/package.json index daec02c8e98f9..212909990e5da 100644 --- a/packages/@n8n/task-runner/package.json +++ b/packages/@n8n/task-runner/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/task-runner", - "version": "1.9.0", + "version": "1.11.0", "scripts": { "clean": "rimraf dist .turbo", "start": "node dist/start.js", @@ -35,17 +35,18 @@ }, "dependencies": { "@n8n/config": "workspace:*", - "@sentry/integrations": "catalog:", "@sentry/node": "catalog:", "acorn": "8.14.0", "acorn-walk": "8.3.4", + "lodash": "catalog:", "n8n-core": "workspace:*", "n8n-workflow": "workspace:*", - "nanoid": "^3.3.6", + "nanoid": "catalog:", "typedi": "catalog:", "ws": "^8.18.0" }, "devDependencies": { + "@types/lodash": "catalog:", "luxon": "catalog:" } } diff --git a/packages/@n8n/task-runner/src/__tests__/error-reporter.test.ts b/packages/@n8n/task-runner/src/__tests__/error-reporter.test.ts deleted file mode 100644 index 9345819329b2c..0000000000000 --- a/packages/@n8n/task-runner/src/__tests__/error-reporter.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { mock } from 'jest-mock-extended'; -import { ApplicationError } from 'n8n-workflow'; - -import { ErrorReporter } from '../error-reporter'; - -describe('ErrorReporter', () => { - const errorReporting = new ErrorReporter(mock()); - - describe('beforeSend', () => { - it('should return null if originalException is an ApplicationError with level warning', () => { - const hint = { originalException: new ApplicationError('Test error', { level: 'warning' }) }; - expect(errorReporting.beforeSend(mock(), hint)).toBeNull(); - }); - - it('should return event if originalException is an ApplicationError with level error', () => { - const hint = { originalException: new ApplicationError('Test error', { level: 'error' }) }; - expect(errorReporting.beforeSend(mock(), hint)).not.toBeNull(); - }); - - it('should return null if originalException is an Error with a non-unique stack', () => { - const hint = { originalException: new Error('Test error') }; - errorReporting.beforeSend(mock(), hint); - expect(errorReporting.beforeSend(mock(), hint)).toBeNull(); - }); - - it('should return event if originalException is an Error with a unique stack', () => { - const hint = { originalException: new Error('Test error') }; - expect(errorReporting.beforeSend(mock(), hint)).not.toBeNull(); - }); - }); -}); diff --git a/packages/@n8n/task-runner/src/config/base-runner-config.ts b/packages/@n8n/task-runner/src/config/base-runner-config.ts index a1059adf4b8c1..d08056c5ae32a 100644 --- a/packages/@n8n/task-runner/src/config/base-runner-config.ts +++ b/packages/@n8n/task-runner/src/config/base-runner-config.ts @@ -37,6 +37,9 @@ export class BaseRunnerConfig { @Env('GENERIC_TIMEZONE') timezone: string = 'America/New_York'; + @Env('N8N_RUNNERS_TASK_TIMEOUT') + taskTimeout: number = 60; + @Nested healthcheckServer!: HealthcheckServerConfig; } diff --git a/packages/@n8n/task-runner/src/error-reporter.ts b/packages/@n8n/task-runner/src/error-reporter.ts deleted file mode 100644 index 167cc37c924b1..0000000000000 --- a/packages/@n8n/task-runner/src/error-reporter.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { RewriteFrames } from '@sentry/integrations'; -import { init, setTag, captureException, close } from '@sentry/node'; -import type { ErrorEvent, EventHint } from '@sentry/types'; -import * as a from 'assert/strict'; -import { createHash } from 'crypto'; -import { ApplicationError } from 'n8n-workflow'; - -import type { SentryConfig } from '@/config/sentry-config'; - -/** - * Handles error reporting using Sentry - */ -export class ErrorReporter { - private isInitialized = false; - - /** Hashes of error stack traces, to deduplicate error reports. */ - private readonly seenErrors = new Set(); - - private get dsn() { - return this.sentryConfig.sentryDsn; - } - - constructor(private readonly sentryConfig: SentryConfig) { - a.ok(this.dsn, 'Sentry DSN is required to initialize Sentry'); - } - - async start() { - if (this.isInitialized) return; - - // Collect longer stacktraces - Error.stackTraceLimit = 50; - - process.on('uncaughtException', captureException); - - const ENABLED_INTEGRATIONS = [ - 'InboundFilters', - 'FunctionToString', - 'LinkedErrors', - 'OnUnhandledRejection', - 'ContextLines', - ]; - - setTag('server_type', 'task_runner'); - - init({ - dsn: this.dsn, - release: this.sentryConfig.n8nVersion, - environment: this.sentryConfig.environment, - enableTracing: false, - serverName: this.sentryConfig.deploymentName, - beforeBreadcrumb: () => null, - beforeSend: async (event, hint) => await this.beforeSend(event, hint), - integrations: (integrations) => [ - ...integrations.filter(({ name }) => ENABLED_INTEGRATIONS.includes(name)), - new RewriteFrames({ root: process.cwd() }), - ], - }); - - this.isInitialized = true; - } - - async stop() { - if (!this.isInitialized) { - return; - } - - await close(1000); - } - - async beforeSend(event: ErrorEvent, { originalException }: EventHint) { - if (!originalException) return null; - - if (originalException instanceof Promise) { - originalException = await originalException.catch((error) => error as Error); - } - - if (originalException instanceof ApplicationError) { - const { level, extra, tags } = originalException; - if (level === 'warning') return null; - event.level = level; - if (extra) event.extra = { ...event.extra, ...extra }; - if (tags) event.tags = { ...event.tags, ...tags }; - } - - if (originalException instanceof Error && originalException.stack) { - const eventHash = createHash('sha1').update(originalException.stack).digest('base64'); - if (this.seenErrors.has(eventHash)) return null; - this.seenErrors.add(eventHash); - } - - return event; - } -} diff --git a/packages/@n8n/task-runner/src/healthcheck-server.ts b/packages/@n8n/task-runner/src/health-check-server.ts similarity index 88% rename from packages/@n8n/task-runner/src/healthcheck-server.ts rename to packages/@n8n/task-runner/src/health-check-server.ts index c6d8965a86b94..9cb4cae6a0440 100644 --- a/packages/@n8n/task-runner/src/healthcheck-server.ts +++ b/packages/@n8n/task-runner/src/health-check-server.ts @@ -1,7 +1,7 @@ import { ApplicationError } from 'n8n-workflow'; import { createServer } from 'node:http'; -export class HealthcheckServer { +export class HealthCheckServer { private server = createServer((_, res) => { res.writeHead(200); res.end('OK'); @@ -21,7 +21,7 @@ export class HealthcheckServer { this.server.listen(port, host, () => { this.server.removeListener('error', portInUseErrorHandler); - console.log(`Healthcheck server listening on ${host}, port ${port}`); + console.log(`Health check server listening on ${host}, port ${port}`); resolve(); }); }); diff --git a/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts b/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts index 439de19eac95a..dbb94038946fd 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts @@ -1,4 +1,6 @@ +import { mock } from 'jest-mock-extended'; import { DateTime } from 'luxon'; +import type { IBinaryData } from 'n8n-workflow'; import { setGlobalState, type CodeExecutionMode, type IDataObject } from 'n8n-workflow'; import fs from 'node:fs'; import { builtinModules } from 'node:module'; @@ -7,10 +9,15 @@ import type { BaseRunnerConfig } from '@/config/base-runner-config'; import type { JsRunnerConfig } from '@/config/js-runner-config'; import { MainConfig } from '@/config/main-config'; import { ExecutionError } from '@/js-task-runner/errors/execution-error'; +import { UnsupportedFunctionError } from '@/js-task-runner/errors/unsupported-function.error'; import { ValidationError } from '@/js-task-runner/errors/validation-error'; import type { JSExecSettings } from '@/js-task-runner/js-task-runner'; import { JsTaskRunner } from '@/js-task-runner/js-task-runner'; -import type { DataRequestResponse, InputDataChunkDefinition } from '@/runner-types'; +import { + UNSUPPORTED_HELPER_FUNCTIONS, + type DataRequestResponse, + type InputDataChunkDefinition, +} from '@/runner-types'; import type { Task } from '@/task-runner'; import { @@ -35,6 +42,7 @@ describe('JsTaskRunner', () => { grantToken: 'grantToken', maxConcurrency: 1, taskBrokerUri: 'http://localhost', + taskTimeout: 60, ...baseRunnerOpts, }, jsRunnerConfig: { @@ -61,7 +69,7 @@ describe('JsTaskRunner', () => { runner?: JsTaskRunner; }) => { jest.spyOn(runner, 'requestData').mockResolvedValue(taskData); - return await runner.executeTask(task); + return await runner.executeTask(task, mock()); }; afterEach(() => { @@ -135,6 +143,36 @@ describe('JsTaskRunner', () => { ]); }, ); + + it('should not throw when using unsupported console methods', async () => { + const task = newTaskWithSettings({ + code: ` + console.warn('test'); + console.error('test'); + console.info('test'); + console.debug('test'); + console.trace('test'); + console.dir({}); + console.time('test'); + console.timeEnd('test'); + console.timeLog('test'); + console.assert(true); + console.clear(); + console.group('test'); + console.groupEnd(); + console.table([]); + return {json: {}} + `, + nodeMode: 'runOnceForAllItems', + }); + + await expect( + execTaskWithParams({ + task, + taskData: newDataRequestResponse([wrapIntoJson({})]), + }), + ).resolves.toBeDefined(); + }); }); describe('built-in methods and variables available in the context', () => { @@ -213,6 +251,7 @@ describe('JsTaskRunner', () => { ['$runIndex', 0], ['{ wf: $workflow }', { wf: { active: true, id: '1', name: 'Test Workflow' } }], ['$vars', { var: 'value' }], + ['$getWorkflowStaticData("global")', {}], ], 'Node.js internal functions': [ ['typeof Function', 'function'], @@ -363,6 +402,291 @@ describe('JsTaskRunner', () => { }); }); + describe("$getWorkflowStaticData('global')", () => { + it('should have the global workflow static data available in runOnceForAllItems', async () => { + const outcome = await execTaskWithParams({ + task: newTaskWithSettings({ + code: 'return { val: $getWorkflowStaticData("global") }', + nodeMode: 'runOnceForAllItems', + }), + taskData: newDataRequestResponse(inputItems.map(wrapIntoJson), { + staticData: { + global: { key: 'value' }, + }, + }), + }); + + expect(outcome.result).toEqual([wrapIntoJson({ val: { key: 'value' } })]); + }); + + it('should have the global workflow static data available in runOnceForEachItem', async () => { + const outcome = await execTaskWithParams({ + task: newTaskWithSettings({ + code: 'return { val: $getWorkflowStaticData("global") }', + nodeMode: 'runOnceForEachItem', + }), + taskData: newDataRequestResponse(inputItems.map(wrapIntoJson), { + staticData: { + global: { key: 'value' }, + }, + }), + }); + + expect(outcome.result).toEqual([ + withPairedItem(0, wrapIntoJson({ val: { key: 'value' } })), + ]); + }); + + test.each<[CodeExecutionMode]>([['runOnceForAllItems'], ['runOnceForEachItem']])( + "does not return static data if it hasn't been modified in %s", + async (mode) => { + const outcome = await execTaskWithParams({ + task: newTaskWithSettings({ + code: ` + const staticData = $getWorkflowStaticData("global"); + return { val: staticData }; + `, + nodeMode: mode, + }), + taskData: newDataRequestResponse(inputItems.map(wrapIntoJson), { + staticData: { + global: { key: 'value' }, + }, + }), + }); + + expect(outcome.staticData).toBeUndefined(); + }, + ); + + test.each<[CodeExecutionMode]>([['runOnceForAllItems'], ['runOnceForEachItem']])( + 'returns the updated static data in %s', + async (mode) => { + const outcome = await execTaskWithParams({ + task: newTaskWithSettings({ + code: ` + const staticData = $getWorkflowStaticData("global"); + staticData.newKey = 'newValue'; + return { val: staticData }; + `, + nodeMode: mode, + }), + taskData: newDataRequestResponse(inputItems.map(wrapIntoJson), { + staticData: { + global: { key: 'value' }, + 'node:OtherNode': { some: 'data' }, + }, + }), + }); + + expect(outcome.staticData).toEqual({ + global: { key: 'value', newKey: 'newValue' }, + 'node:OtherNode': { some: 'data' }, + }); + }, + ); + }); + + describe("$getWorkflowStaticData('node')", () => { + const createTaskDataWithNodeStaticData = (nodeStaticData: IDataObject) => { + const taskData = newDataRequestResponse(inputItems.map(wrapIntoJson)); + const taskDataKey = `node:${taskData.node.name}`; + taskData.workflow.staticData = { + global: { 'global-key': 'global-value' }, + 'node:OtherNode': { 'other-key': 'other-value' }, + [taskDataKey]: nodeStaticData, + }; + + return taskData; + }; + + it('should have the node workflow static data available in runOnceForAllItems', async () => { + const outcome = await execTaskWithParams({ + task: newTaskWithSettings({ + code: 'return { val: $getWorkflowStaticData("node") }', + nodeMode: 'runOnceForAllItems', + }), + taskData: createTaskDataWithNodeStaticData({ key: 'value' }), + }); + + expect(outcome.result).toEqual([wrapIntoJson({ val: { key: 'value' } })]); + }); + + it('should have the node workflow static data available in runOnceForEachItem', async () => { + const outcome = await execTaskWithParams({ + task: newTaskWithSettings({ + code: 'return { val: $getWorkflowStaticData("node") }', + nodeMode: 'runOnceForEachItem', + }), + taskData: createTaskDataWithNodeStaticData({ key: 'value' }), + }); + + expect(outcome.result).toEqual([ + withPairedItem(0, wrapIntoJson({ val: { key: 'value' } })), + ]); + }); + + test.each<[CodeExecutionMode]>([['runOnceForAllItems'], ['runOnceForEachItem']])( + "does not return static data if it hasn't been modified in %s", + async (mode) => { + const outcome = await execTaskWithParams({ + task: newTaskWithSettings({ + code: ` + const staticData = $getWorkflowStaticData("node"); + return { val: staticData }; + `, + nodeMode: mode, + }), + taskData: createTaskDataWithNodeStaticData({ key: 'value' }), + }); + + expect(outcome.staticData).toBeUndefined(); + }, + ); + + test.each<[CodeExecutionMode]>([['runOnceForAllItems'], ['runOnceForEachItem']])( + 'returns the updated static data in %s', + async (mode) => { + const outcome = await execTaskWithParams({ + task: newTaskWithSettings({ + code: ` + const staticData = $getWorkflowStaticData("node"); + staticData.newKey = 'newValue'; + return { val: staticData }; + `, + nodeMode: mode, + }), + taskData: createTaskDataWithNodeStaticData({ key: 'value' }), + }); + + expect(outcome.staticData).toEqual({ + global: { 'global-key': 'global-value' }, + 'node:JsCode': { + key: 'value', + newKey: 'newValue', + }, + 'node:OtherNode': { + 'other-key': 'other-value', + }, + }); + }, + ); + }); + + describe('helpers', () => { + const binaryDataFile: IBinaryData = { + data: 'data', + fileName: 'file.txt', + mimeType: 'text/plain', + }; + + const groups = [ + { + method: 'helpers.assertBinaryData', + invocation: "helpers.assertBinaryData(0, 'binaryFile')", + expectedParams: [0, 'binaryFile'], + }, + { + method: 'helpers.getBinaryDataBuffer', + invocation: "helpers.getBinaryDataBuffer(0, 'binaryFile')", + expectedParams: [0, 'binaryFile'], + }, + { + method: 'helpers.prepareBinaryData', + invocation: "helpers.prepareBinaryData(Buffer.from('123'), 'file.txt', 'text/plain')", + expectedParams: [Buffer.from('123'), 'file.txt', 'text/plain'], + }, + { + method: 'helpers.setBinaryDataBuffer', + invocation: + "helpers.setBinaryDataBuffer({ data: '123', mimeType: 'text/plain' }, Buffer.from('321'))", + expectedParams: [{ data: '123', mimeType: 'text/plain' }, Buffer.from('321')], + }, + { + method: 'helpers.binaryToString', + invocation: "helpers.binaryToString(Buffer.from('123'), 'utf8')", + expectedParams: [Buffer.from('123'), 'utf8'], + }, + { + method: 'helpers.httpRequest', + invocation: "helpers.httpRequest({ method: 'GET', url: 'http://localhost' })", + expectedParams: [{ method: 'GET', url: 'http://localhost' }], + }, + ]; + + for (const group of groups) { + it(`${group.method} for runOnceForAllItems`, async () => { + // Arrange + const rpcCallSpy = jest + .spyOn(defaultTaskRunner, 'makeRpcCall') + .mockResolvedValue(undefined); + + // Act + await execTaskWithParams({ + task: newTaskWithSettings({ + code: `await ${group.invocation}; return []`, + nodeMode: 'runOnceForAllItems', + }), + taskData: newDataRequestResponse( + [{ json: {}, binary: { binaryFile: binaryDataFile } }], + {}, + ), + }); + + expect(rpcCallSpy).toHaveBeenCalledWith('1', group.method, group.expectedParams); + }); + + it(`${group.method} for runOnceForEachItem`, async () => { + // Arrange + const rpcCallSpy = jest + .spyOn(defaultTaskRunner, 'makeRpcCall') + .mockResolvedValue(undefined); + + // Act + await execTaskWithParams({ + task: newTaskWithSettings({ + code: `await ${group.invocation}; return {}`, + nodeMode: 'runOnceForEachItem', + }), + taskData: newDataRequestResponse( + [{ json: {}, binary: { binaryFile: binaryDataFile } }], + {}, + ), + }); + + expect(rpcCallSpy).toHaveBeenCalledWith('1', group.method, group.expectedParams); + }); + } + + describe('unsupported methods', () => { + for (const unsupportedFunction of UNSUPPORTED_HELPER_FUNCTIONS) { + it(`should throw an error if ${unsupportedFunction} is used in runOnceForAllItems`, async () => { + // Act + + await expect( + async () => + await executeForAllItems({ + code: `${unsupportedFunction}()`, + inputItems, + }), + ).rejects.toThrow(UnsupportedFunctionError); + }); + + it(`should throw an error if ${unsupportedFunction} is used in runOnceForEachItem`, async () => { + // Act + + await expect( + async () => + await executeForEachItem({ + code: `${unsupportedFunction}()`, + inputItems, + }), + ).rejects.toThrow(UnsupportedFunctionError); + }); + } + }); + }); + it('should allow access to Node.js Buffers', async () => { const outcomeAll = await execTaskWithParams({ task: newTaskWithSettings({ diff --git a/packages/@n8n/task-runner/src/js-task-runner/__tests__/task-runner.test.ts b/packages/@n8n/task-runner/src/js-task-runner/__tests__/task-runner.test.ts index c633e9568806d..e12770f7705b3 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/__tests__/task-runner.test.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/__tests__/task-runner.test.ts @@ -21,6 +21,7 @@ describe('TestRunner', () => { maxPayloadSize: 1024, taskBrokerUri: 'http://localhost:8080', timezone: 'America/New_York', + taskTimeout: 60, healthcheckServer: { enabled: false, host: 'localhost', @@ -37,6 +38,8 @@ describe('TestRunner', () => { maxPayload: 1024, }), ); + + runner.clearIdleTimer(); }); it('should handle different taskBrokerUri formats correctly', () => { @@ -48,6 +51,7 @@ describe('TestRunner', () => { maxPayloadSize: 1024, taskBrokerUri: 'https://example.com:3000/path', timezone: 'America/New_York', + taskTimeout: 60, healthcheckServer: { enabled: false, host: 'localhost', @@ -64,6 +68,8 @@ describe('TestRunner', () => { maxPayload: 1024, }), ); + + runner.clearIdleTimer(); }); it('should throw an error if taskBrokerUri is invalid', () => { @@ -77,6 +83,7 @@ describe('TestRunner', () => { maxPayloadSize: 1024, taskBrokerUri: 'not-a-valid-uri', timezone: 'America/New_York', + taskTimeout: 60, healthcheckServer: { enabled: false, host: 'localhost', @@ -86,4 +93,65 @@ describe('TestRunner', () => { ).toThrowError(/Invalid URL/); }); }); + + describe('taskCancelled', () => { + it('should reject pending requests when task is cancelled', () => { + const runner = new TestRunner({ + taskType: 'test-task', + maxConcurrency: 5, + idleTimeout: 60, + grantToken: 'test-token', + maxPayloadSize: 1024, + taskBrokerUri: 'http://localhost:8080', + timezone: 'America/New_York', + taskTimeout: 60, + healthcheckServer: { + enabled: false, + host: 'localhost', + port: 8081, + }, + }); + + const taskId = 'test-task'; + runner.runningTasks.set(taskId, { + taskId, + active: false, + cancelled: false, + }); + + const dataRequestReject = jest.fn(); + const nodeTypesRequestReject = jest.fn(); + + runner.dataRequests.set('data-req', { + taskId, + requestId: 'data-req', + resolve: jest.fn(), + reject: dataRequestReject, + }); + + runner.nodeTypesRequests.set('node-req', { + taskId, + requestId: 'node-req', + resolve: jest.fn(), + reject: nodeTypesRequestReject, + }); + + runner.taskCancelled(taskId, 'test-reason'); + + expect(dataRequestReject).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Task cancelled: test-reason', + }), + ); + + expect(nodeTypesRequestReject).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Task cancelled: test-reason', + }), + ); + + expect(runner.dataRequests.size).toBe(0); + expect(runner.nodeTypesRequests.size).toBe(0); + }); + }); }); 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 ef910e838f973..f13939e51e81e 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 @@ -50,7 +50,9 @@ export const newTaskData = (opts: Partial & Pick */ export const newDataRequestResponse = ( inputData: INodeExecutionData[], - opts: Partial = {}, + opts: Partial & { + staticData?: IDataObject; + } = {}, ): DataRequestResponse => { const codeNode = newNode({ name: 'JsCode', @@ -81,6 +83,7 @@ export const newDataRequestResponse = ( }, }, nodes: [manualTriggerNode, codeNode], + staticData: opts.staticData, }, inputData: { main: [inputData], diff --git a/packages/@n8n/task-runner/src/js-task-runner/built-ins-parser/built-ins-parser.ts b/packages/@n8n/task-runner/src/js-task-runner/built-ins-parser/built-ins-parser.ts index 7a5c7baf4631d..fea0d9446911d 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/built-ins-parser/built-ins-parser.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/built-ins-parser/built-ins-parser.ts @@ -21,7 +21,7 @@ export class BuiltInsParser { /** * Parses which built-in variables are accessed in the given code */ - public parseUsedBuiltIns(code: string): Result { + parseUsedBuiltIns(code: string): Result { return toResult(() => { const wrappedCode = `async function VmCodeWrapper() { ${code} }`; const ast = parse(wrappedCode, { ecmaVersion: 2025, sourceType: 'module' }); diff --git a/packages/@n8n/task-runner/src/js-task-runner/errors/__tests__/execution-error.test.ts b/packages/@n8n/task-runner/src/js-task-runner/errors/__tests__/execution-error.test.ts index c85e52f9772bd..53270c25993fb 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/errors/__tests__/execution-error.test.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/errors/__tests__/execution-error.test.ts @@ -36,7 +36,11 @@ describe('ExecutionError', () => { it('should serialize correctly', () => { const error = new Error('a.unknown is not a function'); - error.stack = defaultStack; + Object.defineProperty(error, 'stack', { + value: defaultStack, + enumerable: true, + }); + // error.stack = defaultStack; const executionError = new ExecutionError(error, 1); diff --git a/packages/@n8n/task-runner/src/js-task-runner/errors/execution-error.ts b/packages/@n8n/task-runner/src/js-task-runner/errors/execution-error.ts index 63a2dd5e0b231..ef593d95894d2 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/errors/execution-error.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/errors/execution-error.ts @@ -10,8 +10,6 @@ export class ExecutionError extends SerializableError { context: { itemIndex: number } | undefined = undefined; - stack = ''; - lineNumber: number | undefined = undefined; constructor(error: ErrorLike, itemIndex?: number) { @@ -22,7 +20,12 @@ export class ExecutionError extends SerializableError { this.context = { itemIndex: this.itemIndex }; } - this.stack = error.stack ?? ''; + // Override the stack trace with the given error's stack trace. Since + // node v22 it's not writable, so we can't assign it directly + Object.defineProperty(this, 'stack', { + value: error.stack, + enumerable: true, + }); this.populateFromStack(); } @@ -31,7 +34,7 @@ export class ExecutionError extends SerializableError { * Populate error `message` and `description` from error `stack`. */ private populateFromStack() { - const stackRows = this.stack.split('\n'); + const stackRows = (this.stack ?? '').split('\n'); if (stackRows.length === 0) { this.message = 'Unknown error'; diff --git a/packages/@n8n/task-runner/src/js-task-runner/errors/task-cancelled-error.ts b/packages/@n8n/task-runner/src/js-task-runner/errors/task-cancelled-error.ts new file mode 100644 index 0000000000000..1970c11fcd9ef --- /dev/null +++ b/packages/@n8n/task-runner/src/js-task-runner/errors/task-cancelled-error.ts @@ -0,0 +1,7 @@ +import { ApplicationError } from 'n8n-workflow'; + +export class TaskCancelledError extends ApplicationError { + constructor(reason: string) { + super(`Task cancelled: ${reason}`, { level: 'warning' }); + } +} diff --git a/packages/@n8n/task-runner/src/js-task-runner/errors/timeout-error.ts b/packages/@n8n/task-runner/src/js-task-runner/errors/timeout-error.ts new file mode 100644 index 0000000000000..ef3cc89751862 --- /dev/null +++ b/packages/@n8n/task-runner/src/js-task-runner/errors/timeout-error.ts @@ -0,0 +1,30 @@ +import { ApplicationError } from 'n8n-workflow'; + +export class TimeoutError extends ApplicationError { + description: string; + + constructor(taskTimeout: number) { + super( + `Task execution timed out after ${taskTimeout} ${taskTimeout === 1 ? 'second' : 'seconds'}`, + ); + + const subtitle = 'The task runner was taking too long on this task, so the task was aborted.'; + + const fixes = { + optimizeScript: + 'Optimize your script to prevent long-running tasks, e.g. by processing data in smaller batches.', + ensureTermination: + 'Ensure that all paths in your script are able to terminate, i.e. no infinite loops.', + }; + + const suggestions = [fixes.optimizeScript, fixes.ensureTermination]; + + const suggestionsText = suggestions + .map((suggestion, index) => `${index + 1}. ${suggestion}`) + .join('
'); + + const description = `${subtitle} You can try the following:

${suggestionsText}`; + + this.description = description; + } +} diff --git a/packages/@n8n/task-runner/src/js-task-runner/errors/unsupported-function.error.ts b/packages/@n8n/task-runner/src/js-task-runner/errors/unsupported-function.error.ts new file mode 100644 index 0000000000000..ad55ee0bbfa26 --- /dev/null +++ b/packages/@n8n/task-runner/src/js-task-runner/errors/unsupported-function.error.ts @@ -0,0 +1,13 @@ +import { ApplicationError } from 'n8n-workflow'; + +/** + * Error that indicates that a specific function is not available in the + * Code Node. + */ +export class UnsupportedFunctionError extends ApplicationError { + constructor(functionName: string) { + super(`The function "${functionName}" is not supported in the Code Node`, { + level: 'info', + }); + } +} diff --git a/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts b/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts index 005267862ee27..04e05fb30aed6 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts @@ -1,5 +1,6 @@ +import set from 'lodash/set'; import { getAdditionalKeys } from 'n8n-core'; -import { WorkflowDataProxy, Workflow } from 'n8n-workflow'; +import { WorkflowDataProxy, Workflow, ObservableObject } from 'n8n-workflow'; import type { CodeExecutionMode, IWorkflowExecuteAdditionalData, @@ -19,11 +20,14 @@ import * as a from 'node:assert'; import { runInNewContext, type Context } from 'node:vm'; import type { MainConfig } from '@/config/main-config'; -import type { - DataRequestResponse, - InputDataChunkDefinition, - PartialAdditionalData, - TaskResultData, +import { UnsupportedFunctionError } from '@/js-task-runner/errors/unsupported-function.error'; +import { + EXPOSED_RPC_METHODS, + UNSUPPORTED_HELPER_FUNCTIONS, + type DataRequestResponse, + type InputDataChunkDefinition, + type PartialAdditionalData, + type TaskResultData, } from '@/runner-types'; import { type Task, TaskRunner } from '@/task-runner'; @@ -32,11 +36,16 @@ import { BuiltInsParserState } from './built-ins-parser/built-ins-parser-state'; import { isErrorLike } from './errors/error-like'; import { ExecutionError } from './errors/execution-error'; import { makeSerializable } from './errors/serializable-error'; +import { TimeoutError } from './errors/timeout-error'; import type { RequireResolver } from './require-resolver'; import { createRequireResolver } from './require-resolver'; import { validateRunForAllItemsOutput, validateRunForEachItemOutput } from './result-validation'; import { DataRequestResponseReconstruct } from '../data-request/data-request-response-reconstruct'; +export interface RPCCallObject { + [name: string]: ((...args: unknown[]) => Promise) | RPCCallObject; +} + export interface JSExecSettings { code: string; nodeMode: CodeExecutionMode; @@ -94,7 +103,7 @@ export class JsTaskRunner extends TaskRunner { }); } - async executeTask(task: Task): Promise { + async executeTask(task: Task, signal: AbortSignal): Promise { const settings = task.settings; a.ok(settings, 'JS Code not sent to runner'); @@ -120,7 +129,13 @@ export class JsTaskRunner extends TaskRunner { nodeTypes: this.nodeTypes, }); + const noOp = () => {}; const customConsole = { + // all except `log` are dummy methods that disregard without throwing, following existing Code node behavior + ...Object.keys(console).reduce void>>((acc, name) => { + acc[name] = noOp; + return acc; + }, {}), // Send log output back to the main process. It will take care of forwarding // it to the UI or printing to console. log: (...args: unknown[]) => { @@ -131,14 +146,17 @@ export class JsTaskRunner extends TaskRunner { }, }; + workflow.staticData = ObservableObject.create(workflow.staticData); + const result = settings.nodeMode === 'runOnceForAllItems' - ? await this.runForAllItems(task.taskId, settings, data, workflow, customConsole) - : await this.runForEachItem(task.taskId, settings, data, workflow, customConsole); + ? await this.runForAllItems(task.taskId, settings, data, workflow, customConsole, signal) + : await this.runForEachItem(task.taskId, settings, data, workflow, customConsole, signal); return { result, customData: data.runExecutionData.resultData.metadata, + staticData: workflow.staticData.__dataChanged ? workflow.staticData : undefined, }; } @@ -183,6 +201,7 @@ export class JsTaskRunner extends TaskRunner { data: JsTaskData, workflow: Workflow, customConsole: CustomConsole, + signal: AbortSignal, ): Promise { const dataProxy = this.createDataProxy(data, workflow, data.itemIndex); const inputItems = data.connectionInputData; @@ -192,17 +211,33 @@ export class JsTaskRunner extends TaskRunner { module: {}, console: customConsole, items: inputItems, - + $getWorkflowStaticData: (type: 'global' | 'node') => workflow.getStaticData(type, data.node), ...this.getNativeVariables(), ...dataProxy, ...this.buildRpcCallObject(taskId), }; try { - const result = (await runInNewContext( - `globalThis.global = globalThis; module.exports = async function VmCodeWrapper() {${settings.code}\n}()`, - context, - )) as TaskResultData['result']; + const result = await new Promise((resolve, reject) => { + const abortHandler = () => { + reject(new TimeoutError(this.taskTimeout)); + }; + + signal.addEventListener('abort', abortHandler, { once: true }); + + const taskResult = runInNewContext( + `globalThis.global = globalThis; module.exports = async function VmCodeWrapper() {${settings.code}\n}()`, + context, + { timeout: this.taskTimeout * 1000 }, + ) as Promise; + + void taskResult + .then(resolve) + .catch(reject) + .finally(() => { + signal.removeEventListener('abort', abortHandler); + }); + }); if (result === null) { return []; @@ -230,6 +265,7 @@ export class JsTaskRunner extends TaskRunner { data: JsTaskData, workflow: Workflow, customConsole: CustomConsole, + signal: AbortSignal, ): Promise { const inputItems = data.connectionInputData; const returnData: INodeExecutionData[] = []; @@ -248,17 +284,34 @@ export class JsTaskRunner extends TaskRunner { module: {}, console: customConsole, item, - + $getWorkflowStaticData: (type: 'global' | 'node') => + workflow.getStaticData(type, data.node), ...this.getNativeVariables(), ...dataProxy, ...this.buildRpcCallObject(taskId), }; try { - let result = (await runInNewContext( - `module.exports = async function VmCodeWrapper() {${settings.code}\n}()`, - context, - )) as INodeExecutionData | undefined; + let result = await new Promise((resolve, reject) => { + const abortHandler = () => { + reject(new TimeoutError(this.taskTimeout)); + }; + + signal.addEventListener('abort', abortHandler); + + const taskResult = runInNewContext( + `module.exports = async function VmCodeWrapper() {${settings.code}\n}()`, + context, + { timeout: this.taskTimeout * 1000 }, + ) as Promise; + + void taskResult + .then(resolve) + .catch(reject) + .finally(() => { + signal.removeEventListener('abort', abortHandler); + }); + }); // Filter out null values if (result === null) { @@ -394,4 +447,24 @@ export class JsTaskRunner extends TaskRunner { this.nodeTypes.addNodeTypeDescriptions(nodeTypes); } } + + private buildRpcCallObject(taskId: string) { + const rpcObject: RPCCallObject = {}; + + for (const rpcMethod of EXPOSED_RPC_METHODS) { + set( + rpcObject, + rpcMethod.split('.'), + async (...args: unknown[]) => await this.makeRpcCall(taskId, rpcMethod, args), + ); + } + + for (const rpcMethod of UNSUPPORTED_HELPER_FUNCTIONS) { + set(rpcObject, rpcMethod.split('.'), () => { + throw new UnsupportedFunctionError(rpcMethod); + }); + } + + return rpcObject; + } } diff --git a/packages/@n8n/task-runner/src/message-types.ts b/packages/@n8n/task-runner/src/message-types.ts index 71f236b52a3df..40f7aeca77a33 100644 --- a/packages/@n8n/task-runner/src/message-types.ts +++ b/packages/@n8n/task-runner/src/message-types.ts @@ -2,7 +2,7 @@ import type { INodeTypeBaseDescription } from 'n8n-workflow'; import type { NeededNodeType, - RPC_ALLOW_LIST, + AVAILABLE_RPC_METHODS, TaskDataRequestParams, TaskResultData, } from './runner-types'; @@ -105,7 +105,7 @@ export namespace BrokerMessage { type: 'broker:rpc'; callId: string; taskId: string; - name: (typeof RPC_ALLOW_LIST)[number]; + name: (typeof AVAILABLE_RPC_METHODS)[number]; params: unknown[]; } @@ -239,7 +239,7 @@ export namespace RunnerMessage { type: 'runner:rpc'; callId: string; taskId: string; - name: (typeof RPC_ALLOW_LIST)[number]; + name: (typeof AVAILABLE_RPC_METHODS)[number]; params: unknown[]; } diff --git a/packages/@n8n/task-runner/src/polyfills.ts b/packages/@n8n/task-runner/src/polyfills.ts new file mode 100644 index 0000000000000..7d8a83dd0ed8f --- /dev/null +++ b/packages/@n8n/task-runner/src/polyfills.ts @@ -0,0 +1,5 @@ +// WebCrypto Polyfill for older versions of Node.js 18 +if (!globalThis.crypto?.getRandomValues) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-member-access + globalThis.crypto = require('node:crypto').webcrypto; +} diff --git a/packages/@n8n/task-runner/src/runner-types.ts b/packages/@n8n/task-runner/src/runner-types.ts index 174d652e7fed5..e4e76189e2ae3 100644 --- a/packages/@n8n/task-runner/src/runner-types.ts +++ b/packages/@n8n/task-runner/src/runner-types.ts @@ -61,6 +61,7 @@ export interface DataRequestResponse { export interface TaskResultData { result: INodeExecutionData[]; customData?: Record; + staticData?: IDataObject; } export interface TaskData { @@ -99,31 +100,73 @@ export interface PartialAdditionalData { variables: IDataObject; } -export const RPC_ALLOW_LIST = [ - 'helpers.httpRequestWithAuthentication', - 'helpers.requestWithAuthenticationPaginated', - // "helpers.normalizeItems" - // "helpers.constructExecutionMetaData" - // "helpers.assertBinaryData" +/** RPC methods that are exposed directly to the Code Node */ +export const EXPOSED_RPC_METHODS = [ + // assertBinaryData(itemIndex: number, propertyName: string): Promise + 'helpers.assertBinaryData', + + // getBinaryDataBuffer(itemIndex: number, propertyName: string): Promise 'helpers.getBinaryDataBuffer', - // "helpers.copyInputItems" - // "helpers.returnJsonArray" - 'helpers.getSSHClient', - 'helpers.createReadStream', - // "helpers.getStoragePath" - 'helpers.writeContentToFile', + + // prepareBinaryData(binaryData: Buffer, fileName?: string, mimeType?: string): Promise 'helpers.prepareBinaryData', + + // setBinaryDataBuffer(metadata: IBinaryData, buffer: Buffer): Promise 'helpers.setBinaryDataBuffer', + + // binaryToString(body: Buffer, encoding?: string): string + 'helpers.binaryToString', + + // httpRequest(opts: IHttpRequestOptions): Promise + 'helpers.httpRequest', +]; + +/** Helpers that exist but that we are not exposing to the Code Node */ +export const UNSUPPORTED_HELPER_FUNCTIONS = [ + // These rely on checking the credentials from the current node type (Code Node) + // and hence they can't even work (Code Node doesn't have credentials) + 'helpers.httpRequestWithAuthentication', + 'helpers.requestWithAuthenticationPaginated', + + // This has been removed 'helpers.copyBinaryFile', - 'helpers.binaryToBuffer', - // "helpers.binaryToString" - // "helpers.getBinaryPath" + + // We can't support streams over RPC without implementing it ourselves + 'helpers.createReadStream', 'helpers.getBinaryStream', + + // Makes no sense to support this, as it returns either a stream or a buffer + // and we can't support streams over RPC + 'helpers.binaryToBuffer', + + // These are pretty low-level, so we shouldn't expose them + // (require binary data id, which we don't expose) 'helpers.getBinaryMetadata', + 'helpers.getStoragePath', + 'helpers.getBinaryPath', + + // We shouldn't allow arbitrary FS writes + 'helpers.writeContentToFile', + + // Not something we need to expose. Can be done in the node itself + // copyInputItems(items: INodeExecutionData[], properties: string[]): IDataObject[] + 'helpers.copyInputItems', + + // Code Node does these automatically already + 'helpers.returnJsonArray', + 'helpers.normalizeItems', + + // The client is instantiated and lives on the n8n instance, so we can't + // expose it over RPC without implementing object marshalling + 'helpers.getSSHClient', + + // Doesn't make sense to expose 'helpers.createDeferredPromise', - 'helpers.httpRequest', - 'logNodeOutput', -] as const; + 'helpers.constructExecutionMetaData', +]; + +/** List of all RPC methods that task runner supports */ +export const AVAILABLE_RPC_METHODS = [...EXPOSED_RPC_METHODS, 'logNodeOutput'] as const; /** Node types needed for the runner to execute a task. */ export type NeededNodeType = { name: string; version: number }; diff --git a/packages/@n8n/task-runner/src/start.ts b/packages/@n8n/task-runner/src/start.ts index f68779f38d135..391b6ba156c66 100644 --- a/packages/@n8n/task-runner/src/start.ts +++ b/packages/@n8n/task-runner/src/start.ts @@ -1,17 +1,18 @@ +import './polyfills'; +import type { ErrorReporter } from 'n8n-core'; import { ensureError, setGlobalState } from 'n8n-workflow'; import Container from 'typedi'; import { MainConfig } from './config/main-config'; -import type { ErrorReporter } from './error-reporter'; -import type { HealthcheckServer } from './healthcheck-server'; +import type { HealthCheckServer } from './health-check-server'; import { JsTaskRunner } from './js-task-runner/js-task-runner'; -let healthcheckServer: HealthcheckServer | undefined; +let healthCheckServer: HealthCheckServer | undefined; let runner: JsTaskRunner | undefined; let isShuttingDown = false; let errorReporter: ErrorReporter | undefined; -function createSignalHandler(signal: string) { +function createSignalHandler(signal: string, timeoutInS = 10) { return async function onSignal() { if (isShuttingDown) { return; @@ -19,16 +20,21 @@ function createSignalHandler(signal: string) { console.log(`Received ${signal} signal, shutting down...`); + setTimeout(() => { + console.error('Shutdown timeout reached, forcing shutdown...'); + process.exit(1); + }, timeoutInS * 1000).unref(); + isShuttingDown = true; try { if (runner) { await runner.stop(); runner = undefined; - void healthcheckServer?.stop(); + void healthCheckServer?.stop(); } if (errorReporter) { - await errorReporter.stop(); + await errorReporter.shutdown(); errorReporter = undefined; } } catch (e) { @@ -49,22 +55,23 @@ void (async function start() { }); if (config.sentryConfig.sentryDsn) { - const { ErrorReporter } = await import('@/error-reporter'); - errorReporter = new ErrorReporter(config.sentryConfig); - await errorReporter.start(); + const { ErrorReporter } = await import('n8n-core'); + errorReporter = new ErrorReporter(); + await errorReporter.init('task_runner', config.sentryConfig.sentryDsn); } runner = new JsTaskRunner(config); runner.on('runner:reached-idle-timeout', () => { - void createSignalHandler('IDLE_TIMEOUT')(); + // Use shorter timeout since we know we don't have any tasks running + void createSignalHandler('IDLE_TIMEOUT', 1)(); }); const { enabled, host, port } = config.baseRunnerConfig.healthcheckServer; if (enabled) { - const { HealthcheckServer } = await import('./healthcheck-server'); - healthcheckServer = new HealthcheckServer(); - await healthcheckServer.start(host, port); + const { HealthCheckServer } = await import('./health-check-server'); + healthCheckServer = new HealthCheckServer(); + await healthCheckServer.start(host, port); } process.on('SIGINT', createSignalHandler('SIGINT')); diff --git a/packages/@n8n/task-runner/src/task-runner.ts b/packages/@n8n/task-runner/src/task-runner.ts index f0af115b5a792..4254aad99c29f 100644 --- a/packages/@n8n/task-runner/src/task-runner.ts +++ b/packages/@n8n/task-runner/src/task-runner.ts @@ -1,3 +1,4 @@ +import { isSerializedBuffer, toBuffer } from 'n8n-core'; import { ApplicationError, ensureError, randomInt } from 'n8n-workflow'; import { nanoid } from 'nanoid'; import { EventEmitter } from 'node:events'; @@ -6,7 +7,9 @@ import { type MessageEvent, WebSocket } from 'ws'; import type { BaseRunnerConfig } from '@/config/base-runner-config'; import type { BrokerMessage, RunnerMessage } from '@/message-types'; import { TaskRunnerNodeTypes } from '@/node-types'; -import { RPC_ALLOW_LIST, type TaskResultData } from '@/runner-types'; +import type { TaskResultData } from '@/runner-types'; + +import { TaskCancelledError } from './js-task-runner/errors/task-cancelled-error'; export interface Task { taskId: string; @@ -21,12 +24,14 @@ export interface TaskOffer { } interface DataRequest { + taskId: string; requestId: string; resolve: (data: unknown) => void; reject: (error: unknown) => void; } interface NodeTypesRequest { + taskId: string; requestId: string; resolve: (data: unknown) => void; reject: (error: unknown) => void; @@ -38,10 +43,6 @@ interface RPCCall { reject: (error: unknown) => void; } -export interface RPCCallObject { - [name: string]: ((...args: unknown[]) => Promise) | RPCCallObject; -} - const OFFER_VALID_TIME_MS = 5000; const OFFER_VALID_EXTRA_MS = 100; @@ -82,14 +83,20 @@ export abstract class TaskRunner extends EventEmitter { private idleTimer: NodeJS.Timeout | undefined; + /** How long (in seconds) a task is allowed to take for completion, else the task will be aborted. */ + protected readonly taskTimeout: number; + /** How long (in seconds) a runner may be idle for before exit. */ private readonly idleTimeout: number; + protected taskCancellations = new Map(); + constructor(opts: TaskRunnerOpts) { super(); this.taskType = opts.taskType; this.name = opts.name ?? 'Node.js Task Runner SDK'; this.maxConcurrency = opts.maxConcurrency; + this.taskTimeout = opts.taskTimeout; this.idleTimeout = opts.idleTimeout; const { host: taskBrokerHost } = new URL(opts.taskBrokerUri); @@ -210,7 +217,7 @@ export abstract class TaskRunner extends EventEmitter { this.offerAccepted(message.offerId, message.taskId); break; case 'broker:taskcancel': - this.taskCancelled(message.taskId); + this.taskCancelled(message.taskId, message.reason); break; case 'broker:tasksettings': void this.receivedSettings(message.taskId, message.settings); @@ -285,17 +292,35 @@ export abstract class TaskRunner extends EventEmitter { }); } - taskCancelled(taskId: string) { + taskCancelled(taskId: string, reason: string) { const task = this.runningTasks.get(taskId); if (!task) { return; } task.cancelled = true; - if (task.active) { - // TODO - } else { - this.runningTasks.delete(taskId); + + for (const [requestId, request] of this.dataRequests.entries()) { + if (request.taskId === taskId) { + request.reject(new TaskCancelledError(reason)); + this.dataRequests.delete(requestId); + } } + + for (const [requestId, request] of this.nodeTypesRequests.entries()) { + if (request.taskId === taskId) { + request.reject(new TaskCancelledError(reason)); + this.nodeTypesRequests.delete(requestId); + } + } + + const controller = this.taskCancellations.get(taskId); + if (controller) { + controller.abort(); + this.taskCancellations.delete(taskId); + } + + if (!task.active) this.runningTasks.delete(taskId); + this.sendOffers(); } @@ -328,20 +353,33 @@ export abstract class TaskRunner extends EventEmitter { this.runningTasks.delete(taskId); return; } + + const controller = new AbortController(); + this.taskCancellations.set(taskId, controller); + + const taskTimeout = setTimeout(() => { + if (!task.cancelled) { + controller.abort(); + this.taskCancellations.delete(taskId); + } + }, this.taskTimeout * 1_000); + task.settings = settings; task.active = true; try { - const data = await this.executeTask(task); + const data = await this.executeTask(task, controller.signal); this.taskDone(taskId, data); } catch (error) { - this.taskErrored(taskId, error); + if (!task.cancelled) this.taskErrored(taskId, error); } finally { + clearTimeout(taskTimeout); + this.taskCancellations.delete(taskId); this.resetIdleTimer(); } } // eslint-disable-next-line @typescript-eslint/naming-convention - async executeTask(_task: Task): Promise { + async executeTask(_task: Task, _signal: AbortSignal): Promise { throw new ApplicationError('Unimplemented'); } @@ -354,6 +392,7 @@ export abstract class TaskRunner extends EventEmitter { const nodeTypesPromise = new Promise((resolve, reject) => { this.nodeTypesRequests.set(requestId, { requestId, + taskId, resolve: resolve as (data: unknown) => void, reject, }); @@ -382,6 +421,7 @@ export abstract class TaskRunner extends EventEmitter { const p = new Promise((resolve, reject) => { this.dataRequests.set(requestId, { requestId, + taskId, resolve: resolve as (data: unknown) => void, reject, }); @@ -421,7 +461,9 @@ export abstract class TaskRunner extends EventEmitter { }); try { - return await dataPromise; + const returnValue = await dataPromise; + + return isSerializedBuffer(returnValue) ? toBuffer(returnValue) : returnValue; } finally { this.rpcCalls.delete(callId); } @@ -443,24 +485,6 @@ export abstract class TaskRunner extends EventEmitter { } } - buildRpcCallObject(taskId: string) { - const rpcObject: RPCCallObject = {}; - for (const r of RPC_ALLOW_LIST) { - const splitPath = r.split('.'); - let obj = rpcObject; - - splitPath.forEach((s, index) => { - if (index !== splitPath.length - 1) { - obj[s] = {}; - obj = obj[s]; - return; - } - obj[s] = async (...args: unknown[]) => await this.makeRpcCall(taskId, r, args); - }); - } - return rpcObject; - } - /** Close the connection gracefully and wait until has been closed */ async stop() { this.clearIdleTimer(); diff --git a/packages/@n8n_io/eslint-config/base.js b/packages/@n8n_io/eslint-config/base.js index 629749289a6d5..0864a20a7b786 100644 --- a/packages/@n8n_io/eslint-config/base.js +++ b/packages/@n8n_io/eslint-config/base.js @@ -316,6 +316,11 @@ const config = (module.exports = { */ '@typescript-eslint/return-await': ['error', 'always'], + /** + * https://typescript-eslint.io/rules/explicit-member-accessibility/ + */ + '@typescript-eslint/explicit-member-accessibility': ['error', { accessibility: 'no-public' }], + // ---------------------------------- // eslint-plugin-import // ---------------------------------- diff --git a/packages/cli/package.json b/packages/cli/package.json index 87f0c65122733..8e9ff0f7ca483 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.71.0", + "version": "1.73.0", "description": "n8n Workflow Automation Tool", "main": "dist/index", "types": "dist/index.d.ts", @@ -98,7 +98,6 @@ "@n8n_io/license-sdk": "2.13.1", "@oclif/core": "4.0.7", "@rudderstack/rudder-sdk-node": "2.0.9", - "@sentry/integrations": "catalog:", "@sentry/node": "catalog:", "aws4": "1.11.0", "axios": "catalog:", diff --git a/packages/cli/src/__tests__/active-workflow-manager.test.ts b/packages/cli/src/__tests__/active-workflow-manager.test.ts new file mode 100644 index 0000000000000..a167f1e5a5cf7 --- /dev/null +++ b/packages/cli/src/__tests__/active-workflow-manager.test.ts @@ -0,0 +1,125 @@ +import { mock } from 'jest-mock-extended'; +import type { InstanceSettings } from 'n8n-core'; +import type { + WorkflowParameters, + INode, + INodeType, + INodeTypeDescription, + WorkflowActivateMode, +} from 'n8n-workflow'; +import { Workflow } from 'n8n-workflow'; + +import { ActiveWorkflowManager } from '@/active-workflow-manager'; +import type { NodeTypes } from '@/node-types'; + +describe('ActiveWorkflowManager', () => { + let activeWorkflowManager: ActiveWorkflowManager; + const instanceSettings = mock(); + const nodeTypes = mock(); + + beforeEach(() => { + jest.clearAllMocks(); + activeWorkflowManager = new ActiveWorkflowManager( + mock(), + mock(), + mock(), + mock(), + mock(), + nodeTypes, + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + instanceSettings, + mock(), + ); + }); + + describe('checkIfWorkflowCanBeActivated', () => { + const disabledNode = mock({ type: 'triggerNode', disabled: true }); + const unknownNode = mock({ type: 'unknownNode' }); + const noTriggersNode = mock({ type: 'noTriggersNode' }); + const pollNode = mock({ type: 'pollNode' }); + const triggerNode = mock({ type: 'triggerNode' }); + const webhookNode = mock({ type: 'webhookNode' }); + + nodeTypes.getByNameAndVersion.mockImplementation((type) => { + // TODO: getByNameAndVersion signature needs to be updated to allow returning undefined + if (type === 'unknownNode') return undefined as unknown as INodeType; + const partial: Partial = { + poll: undefined, + trigger: undefined, + webhook: undefined, + description: mock({ + properties: [], + }), + }; + if (type === 'pollNode') partial.poll = jest.fn(); + if (type === 'triggerNode') partial.trigger = jest.fn(); + if (type === 'webhookNode') partial.webhook = jest.fn(); + return mock(partial); + }); + + test.each([ + ['should skip disabled nodes', disabledNode, [], false], + ['should skip nodes marked as ignored', triggerNode, ['triggerNode'], false], + ['should skip unknown nodes', unknownNode, [], false], + ['should skip nodes with no trigger method', noTriggersNode, [], false], + ['should activate if poll method exists', pollNode, [], true], + ['should activate if trigger method exists', triggerNode, [], true], + ['should activate if webhook method exists', webhookNode, [], true], + ])('%s', async (_, node, ignoredNodes, expected) => { + const workflow = new Workflow(mock({ nodeTypes, nodes: [node] })); + const canBeActivated = activeWorkflowManager.checkIfWorkflowCanBeActivated( + workflow, + ignoredNodes, + ); + expect(canBeActivated).toBe(expected); + }); + }); + + describe('shouldAddWebhooks', () => { + describe('if leader', () => { + beforeAll(() => { + Object.assign(instanceSettings, { isLeader: true, isFollower: false }); + }); + + test('should return `true` for `init`', () => { + // ensure webhooks are populated on init: https://github.com/n8n-io/n8n/pull/8830 + const result = activeWorkflowManager.shouldAddWebhooks('init'); + expect(result).toBe(true); + }); + + test('should return `false` for `leadershipChange`', () => { + const result = activeWorkflowManager.shouldAddWebhooks('leadershipChange'); + expect(result).toBe(false); + }); + + test('should return `true` for `update` or `activate`', () => { + const modes = ['update', 'activate'] as WorkflowActivateMode[]; + for (const mode of modes) { + const result = activeWorkflowManager.shouldAddWebhooks(mode); + expect(result).toBe(true); + } + }); + }); + + describe('if follower', () => { + beforeAll(() => { + Object.assign(instanceSettings, { isLeader: false, isFollower: true }); + }); + + test('should return `false` for `update` or `activate`', () => { + const modes = ['update', 'activate'] as WorkflowActivateMode[]; + for (const mode of modes) { + const result = activeWorkflowManager.shouldAddWebhooks(mode); + expect(result).toBe(false); + } + }); + }); + }); +}); diff --git a/packages/cli/src/__tests__/credential-types.test.ts b/packages/cli/src/__tests__/credential-types.test.ts index 0ccb5ab771838..82780d114cac4 100644 --- a/packages/cli/src/__tests__/credential-types.test.ts +++ b/packages/cli/src/__tests__/credential-types.test.ts @@ -1,41 +1,121 @@ -import { Container } from 'typedi'; +import { mock } from 'jest-mock-extended'; +import { UnrecognizedCredentialTypeError } from 'n8n-core'; +import type { ICredentialType, LoadedClass } from 'n8n-workflow'; import { CredentialTypes } from '@/credential-types'; -import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; -import { mockInstance } from '@test/mocking'; +import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; describe('CredentialTypes', () => { - const mockNodesAndCredentials = mockInstance(LoadNodesAndCredentials, { - loadedCredentials: { - fakeFirstCredential: { - type: { - name: 'fakeFirstCredential', - displayName: 'Fake First Credential', - properties: [], - }, - sourcePath: '', - }, - fakeSecondCredential: { - type: { - name: 'fakeSecondCredential', - displayName: 'Fake Second Credential', - properties: [], - }, - sourcePath: '', - }, - }, + const loadNodesAndCredentials = mock(); + + const credentialTypes = new CredentialTypes(loadNodesAndCredentials); + + const testCredential: LoadedClass = { + sourcePath: '', + type: mock(), + }; + + loadNodesAndCredentials.getCredential.mockImplementation((credentialType) => { + if (credentialType === 'testCredential') return testCredential; + throw new UnrecognizedCredentialTypeError(credentialType); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getByName', () => { + test('Should throw error when calling invalid credential name', () => { + expect(() => credentialTypes.getByName('unknownCredential')).toThrowError('c'); + }); + + test('Should return correct credential type for valid name', () => { + expect(credentialTypes.getByName('testCredential')).toStrictEqual(testCredential.type); + }); + }); + + describe('recognizes', () => { + test('Should recognize credential type that exists in knownCredentials', () => { + const credentialTypes = new CredentialTypes( + mock({ + loadedCredentials: {}, + knownCredentials: { testCredential: mock({ supportedNodes: [] }) }, + }), + ); + + expect(credentialTypes.recognizes('testCredential')).toBe(true); + }); + + test('Should recognize credential type that exists in loadedCredentials', () => { + const credentialTypes = new CredentialTypes( + mock({ + loadedCredentials: { testCredential }, + knownCredentials: {}, + }), + ); + + expect(credentialTypes.recognizes('testCredential')).toBe(true); + }); + + test('Should not recognize unknown credential type', () => { + expect(credentialTypes.recognizes('unknownCredential')).toBe(false); + }); }); - const credentialTypes = Container.get(CredentialTypes); + describe('getSupportedNodes', () => { + test('Should return supported nodes for known credential type', () => { + const supportedNodes = ['node1', 'node2']; + const credentialTypes = new CredentialTypes( + mock({ + knownCredentials: { testCredential: mock({ supportedNodes }) }, + }), + ); + + expect(credentialTypes.getSupportedNodes('testCredential')).toEqual(supportedNodes); + }); - test('Should throw error when calling invalid credential name', () => { - expect(() => credentialTypes.getByName('fakeThirdCredential')).toThrowError(); + test('Should return empty array for unknown credential type supported nodes', () => { + expect(credentialTypes.getSupportedNodes('unknownCredential')).toBeEmptyArray(); + }); }); - test('Should return correct credential type for valid name', () => { - const mockedCredentialTypes = mockNodesAndCredentials.loadedCredentials; - expect(credentialTypes.getByName('fakeFirstCredential')).toStrictEqual( - mockedCredentialTypes.fakeFirstCredential.type, - ); + describe('getParentTypes', () => { + test('Should return parent types for credential type with extends', () => { + const credentialTypes = new CredentialTypes( + mock({ + knownCredentials: { + childType: { extends: ['parentType1', 'parentType2'] }, + parentType1: { extends: ['grandparentType'] }, + parentType2: { extends: [] }, + grandparentType: { extends: [] }, + }, + }), + ); + + const parentTypes = credentialTypes.getParentTypes('childType'); + expect(parentTypes).toContain('parentType1'); + expect(parentTypes).toContain('parentType2'); + expect(parentTypes).toContain('grandparentType'); + }); + + test('Should return empty array for credential type without extends', () => { + const credentialTypes = new CredentialTypes( + mock({ + knownCredentials: { testCredential: { extends: [] } }, + }), + ); + + expect(credentialTypes.getParentTypes('testCredential')).toBeEmptyArray(); + }); + + test('Should return empty array for unknown credential type parent types', () => { + const credentialTypes = new CredentialTypes( + mock({ + knownCredentials: {}, + }), + ); + + expect(credentialTypes.getParentTypes('unknownCredential')).toBeEmptyArray(); + }); }); }); diff --git a/packages/cli/src/__tests__/credentials-helper.test.ts b/packages/cli/src/__tests__/credentials-helper.test.ts index 62cab968e4570..7deffcd2295e1 100644 --- a/packages/cli/src/__tests__/credentials-helper.test.ts +++ b/packages/cli/src/__tests__/credentials-helper.test.ts @@ -1,3 +1,4 @@ +import { mock } from 'jest-mock-extended'; import type { IAuthenticateGeneric, ICredentialDataDecryptedObject, @@ -5,59 +6,25 @@ import type { IHttpRequestOptions, INode, INodeProperties, + INodeTypes, } from 'n8n-workflow'; -import { NodeConnectionType, deepCopy } from 'n8n-workflow'; -import { Workflow } from 'n8n-workflow'; -import Container from 'typedi'; +import { deepCopy, Workflow } from 'n8n-workflow'; +import { CredentialTypes } from '@/credential-types'; import { CredentialsHelper } from '@/credentials-helper'; -import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; -import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; -import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; -import { NodeTypes } from '@/node-types'; -import { mockInstance } from '@test/mocking'; +import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; describe('CredentialsHelper', () => { - mockInstance(CredentialsRepository); - mockInstance(SharedCredentialsRepository); - const mockNodesAndCredentials = mockInstance(LoadNodesAndCredentials, { - loadedNodes: { - 'test.set': { - sourcePath: '', - type: { - description: { - displayName: 'Set', - name: 'set', - group: ['input'], - version: 1, - description: 'Sets a value', - defaults: { - name: 'Set', - color: '#0000FF', - }, - inputs: [NodeConnectionType.Main], - outputs: [NodeConnectionType.Main], - properties: [ - { - displayName: 'Value1', - name: 'value1', - type: 'string', - default: 'default-value1', - }, - { - displayName: 'Value2', - name: 'value2', - type: 'string', - default: 'default-value2', - }, - ], - }, - }, - }, - }, - }); + const nodeTypes = mock(); + const mockNodesAndCredentials = mock(); - const nodeTypes = mockInstance(NodeTypes); + const credentialsHelper = new CredentialsHelper( + new CredentialTypes(mockNodesAndCredentials), + mock(), + mock(), + mock(), + mock(), + ); describe('authenticate', () => { const tests: Array<{ @@ -272,19 +239,16 @@ describe('CredentialsHelper', () => { for (const testData of tests) { test(testData.description, async () => { - //@ts-expect-error `loadedCredentials` is a getter and we are replacing it here with a property - mockNodesAndCredentials.loadedCredentials = { - [testData.input.credentialType.name]: { - type: testData.input.credentialType, - sourcePath: '', - }, - }; + const { credentialType } = testData.input; - const credentialsHelper = Container.get(CredentialsHelper); + mockNodesAndCredentials.getCredential.calledWith(credentialType.name).mockReturnValue({ + type: credentialType, + sourcePath: '', + }); const result = await credentialsHelper.authenticate( testData.input.credentials, - testData.input.credentialType.name, + credentialType.name, deepCopy(incomingRequestOptions), workflow, node, diff --git a/packages/cli/src/__tests__/error-reporting.test.ts b/packages/cli/src/__tests__/error-reporting.test.ts deleted file mode 100644 index 5e472b8b99c12..0000000000000 --- a/packages/cli/src/__tests__/error-reporting.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { GlobalConfig } from '@n8n/config'; -import type { ClientOptions, ErrorEvent } from '@sentry/types'; -import { strict as assert } from 'node:assert'; -import { Container } from 'typedi'; - -import { InternalServerError } from '@/errors/response-errors/internal-server.error'; - -const init = jest.fn(); - -jest.mock('@sentry/integrations'); -jest.mock('@sentry/node', () => ({ - init, - setTag: jest.fn(), - captureException: jest.fn(), - Integrations: {}, -})); - -jest.spyOn(process, 'on'); - -describe('initErrorHandling', () => { - let beforeSend: ClientOptions['beforeSend']; - - beforeAll(async () => { - Container.get(GlobalConfig).sentry.backendDsn = 'backend-dsn'; - const errorReporting = require('@/error-reporting'); - await errorReporting.initErrorHandling(); - const options = (init.mock.calls[0] as [ClientOptions])[0]; - beforeSend = options.beforeSend; - }); - - it('ignores errors with level warning', async () => { - const originalException = new InternalServerError('test'); - originalException.level = 'warning'; - - const event = {} as ErrorEvent; - - assert(beforeSend); - expect(await beforeSend(event, { originalException })).toEqual(null); - }); - - it('keeps events with a cause with error level', async () => { - const cause = new Error('cause-error'); - - const originalException = new InternalServerError('test', cause); - const event = {} as ErrorEvent; - - assert(beforeSend); - expect(await beforeSend(event, { originalException })).toEqual(event); - }); - - it('ignores events with error cause with warning level', async () => { - const cause: Error & { level?: 'warning' } = new Error('cause-error'); - cause.level = 'warning'; - - const originalException = new InternalServerError('test', cause); - const event = {} as ErrorEvent; - - assert(beforeSend); - expect(await beforeSend(event, { originalException })).toEqual(null); - }); -}); diff --git a/packages/cli/src/__tests__/license.test.ts b/packages/cli/src/__tests__/license.test.ts index d33d7c37cff48..aa0aba1d5380d 100644 --- a/packages/cli/src/__tests__/license.test.ts +++ b/packages/cli/src/__tests__/license.test.ts @@ -38,7 +38,7 @@ describe('License', () => { license: licenseConfig, multiMainSetup: { enabled: false }, }); - license = new License(mockLogger(), instanceSettings, mock(), mock(), mock(), globalConfig); + license = new License(mockLogger(), instanceSettings, mock(), mock(), globalConfig); await license.init(); }); @@ -70,7 +70,6 @@ describe('License', () => { mock({ instanceType: 'worker' }), mock(), mock(), - mock(), mock({ license: licenseConfig }), ); await license.init(); @@ -211,7 +210,6 @@ describe('License', () => { mock({ instanceType: 'main' }), mock(), mock(), - mock(), globalConfig, ).init(); @@ -229,7 +227,6 @@ describe('License', () => { mock(), mock(), mock(), - mock(), ).init(); expect(LicenseManager).toHaveBeenCalledWith( @@ -250,7 +247,7 @@ describe('License', () => { }); config.set('multiMainSetup.instanceType', status); - await new License(mockLogger(), mock(), mock(), mock(), mock(), globalConfig).init(); + await new License(mockLogger(), mock(), mock(), mock(), globalConfig).init(); expect(LicenseManager).toHaveBeenCalledWith( expect.objectContaining({ autoRenewEnabled: false, renewOnInit: false }), @@ -267,7 +264,7 @@ describe('License', () => { }); config.set('multiMainSetup.instanceType', status); - await new License(mockLogger(), mock(), mock(), mock(), mock(), globalConfig).init(); + await new License(mockLogger(), mock(), mock(), mock(), globalConfig).init(); expect(LicenseManager).toHaveBeenCalledWith( expect.objectContaining({ autoRenewEnabled: false, renewOnInit: false }), @@ -281,7 +278,7 @@ describe('License', () => { }); config.set('multiMainSetup.instanceType', 'leader'); - await new License(mockLogger(), mock(), mock(), mock(), mock(), globalConfig).init(); + await new License(mockLogger(), mock(), mock(), mock(), globalConfig).init(); expect(LicenseManager).toHaveBeenCalledWith( expect.objectContaining({ autoRenewEnabled: true, renewOnInit: true }), @@ -293,7 +290,7 @@ describe('License', () => { describe('reinit', () => { it('should reinitialize license manager', async () => { - const license = new License(mockLogger(), mock(), mock(), mock(), mock(), mock()); + const license = new License(mockLogger(), mock(), mock(), mock(), mock()); await license.init(); const initSpy = jest.spyOn(license, 'init'); 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 bcf485445fea5..75aa6023019c6 100644 --- a/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts +++ b/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts @@ -1,5 +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 { LoadNodesAndCredentials } from '../load-nodes-and-credentials'; @@ -8,7 +10,7 @@ describe('LoadNodesAndCredentials', () => { let instance: LoadNodesAndCredentials; beforeEach(() => { - instance = new LoadNodesAndCredentials(mock(), mock(), mock()); + instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock()); instance.loaders.package1 = mock({ directory: '/icons/package1', }); @@ -34,4 +36,179 @@ describe('LoadNodesAndCredentials', () => { expect(result).toBeUndefined(); }); }); + + describe('convertNodeToAiTool', () => { + const instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock()); + + let fullNodeWrapper: { description: INodeTypeDescription }; + + beforeEach(() => { + fullNodeWrapper = { + description: { + displayName: 'Test Node', + name: 'testNode', + group: ['test'], + description: 'A test node', + version: 1, + defaults: {}, + inputs: [NodeConnectionType.Main], + outputs: [NodeConnectionType.Main], + properties: [], + }, + }; + }); + + it('should modify the name and displayName correctly', () => { + const result = instance.convertNodeToAiTool(fullNodeWrapper); + expect(result.description.name).toBe('testNodeTool'); + expect(result.description.displayName).toBe('Test Node Tool'); + }); + + it('should update inputs and outputs', () => { + const result = instance.convertNodeToAiTool(fullNodeWrapper); + expect(result.description.inputs).toEqual([]); + expect(result.description.outputs).toEqual([NodeConnectionType.AiTool]); + }); + + it('should remove the usableAsTool property', () => { + fullNodeWrapper.description.usableAsTool = true; + const result = instance.convertNodeToAiTool(fullNodeWrapper); + expect(result.description.usableAsTool).toBeUndefined(); + }); + + it("should add toolDescription property if it doesn't exist", () => { + const result = instance.convertNodeToAiTool(fullNodeWrapper); + const toolDescriptionProp = result.description.properties.find( + (prop) => prop.name === 'toolDescription', + ); + expect(toolDescriptionProp).toBeDefined(); + expect(toolDescriptionProp?.type).toBe('string'); + expect(toolDescriptionProp?.default).toBe(fullNodeWrapper.description.description); + }); + + it('should set codex categories correctly', () => { + const result = instance.convertNodeToAiTool(fullNodeWrapper); + expect(result.description.codex).toEqual({ + categories: ['AI'], + subcategories: { + AI: ['Tools'], + Tools: ['Other Tools'], + }, + resources: {}, + }); + }); + + it('should preserve existing properties', () => { + const existingProp: INodeProperties = { + displayName: 'Existing Prop', + name: 'existingProp', + type: 'string', + default: 'test', + }; + fullNodeWrapper.description.properties = [existingProp]; + const result = instance.convertNodeToAiTool(fullNodeWrapper); + expect(result.description.properties).toHaveLength(3); // Existing prop + toolDescription + notice + expect(result.description.properties).toContainEqual(existingProp); + }); + + it('should handle nodes with resource property', () => { + const resourceProp: INodeProperties = { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [{ name: 'User', value: 'user' }], + default: 'user', + }; + fullNodeWrapper.description.properties = [resourceProp]; + const result = instance.convertNodeToAiTool(fullNodeWrapper); + expect(result.description.properties[1].name).toBe('descriptionType'); + expect(result.description.properties[2].name).toBe('toolDescription'); + expect(result.description.properties[3]).toEqual(resourceProp); + }); + + it('should handle nodes with operation property', () => { + const operationProp: INodeProperties = { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [{ name: 'Create', value: 'create' }], + default: 'create', + }; + fullNodeWrapper.description.properties = [operationProp]; + const result = instance.convertNodeToAiTool(fullNodeWrapper); + expect(result.description.properties[1].name).toBe('descriptionType'); + expect(result.description.properties[2].name).toBe('toolDescription'); + expect(result.description.properties[3]).toEqual(operationProp); + }); + + it('should handle nodes with both resource and operation properties', () => { + const resourceProp: INodeProperties = { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [{ name: 'User', value: 'user' }], + default: 'user', + }; + const operationProp: INodeProperties = { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [{ name: 'Create', value: 'create' }], + default: 'create', + }; + fullNodeWrapper.description.properties = [resourceProp, operationProp]; + const result = instance.convertNodeToAiTool(fullNodeWrapper); + expect(result.description.properties[1].name).toBe('descriptionType'); + expect(result.description.properties[2].name).toBe('toolDescription'); + expect(result.description.properties[3]).toEqual(resourceProp); + expect(result.description.properties[4]).toEqual(operationProp); + }); + + it('should handle nodes with empty properties', () => { + fullNodeWrapper.description.properties = []; + const result = instance.convertNodeToAiTool(fullNodeWrapper); + expect(result.description.properties).toHaveLength(2); + expect(result.description.properties[1].name).toBe('toolDescription'); + }); + + it('should handle nodes with existing codex property', () => { + fullNodeWrapper.description.codex = { + categories: ['Existing'], + subcategories: { + Existing: ['Category'], + }, + resources: { + primaryDocumentation: [{ url: 'https://example.com' }], + }, + }; + const result = instance.convertNodeToAiTool(fullNodeWrapper); + expect(result.description.codex).toEqual({ + categories: ['AI'], + subcategories: { + AI: ['Tools'], + Tools: ['Other Tools'], + }, + resources: { + primaryDocumentation: [{ url: 'https://example.com' }], + }, + }); + }); + + it('should handle nodes with very long names', () => { + fullNodeWrapper.description.name = 'veryLongNodeNameThatExceedsNormalLimits'.repeat(10); + fullNodeWrapper.description.displayName = + 'Very Long Node Name That Exceeds Normal Limits'.repeat(10); + const result = instance.convertNodeToAiTool(fullNodeWrapper); + expect(result.description.name.endsWith('Tool')).toBe(true); + expect(result.description.displayName.endsWith('Tool')).toBe(true); + }); + + it('should handle nodes with special characters in name and displayName', () => { + fullNodeWrapper.description.name = 'special@#$%Node'; + fullNodeWrapper.description.displayName = 'Special @#$% Node'; + const result = instance.convertNodeToAiTool(fullNodeWrapper); + expect(result.description.name).toBe('special@#$%NodeTool'); + expect(result.description.displayName).toBe('Special @#$% Node Tool'); + }); + }); }); diff --git a/packages/cli/src/__tests__/workflow-helpers.test.ts b/packages/cli/src/__tests__/manual-execution.service.test.ts similarity index 69% rename from packages/cli/src/__tests__/workflow-helpers.test.ts rename to packages/cli/src/__tests__/manual-execution.service.test.ts index e24cfa1f68284..383a8dc87cc0c 100644 --- a/packages/cli/src/__tests__/workflow-helpers.test.ts +++ b/packages/cli/src/__tests__/manual-execution.service.test.ts @@ -1,8 +1,11 @@ +import { mock } from 'jest-mock-extended'; import type { Workflow, IWorkflowExecutionDataProcess } from 'n8n-workflow'; -import { getExecutionStartNode } from '@/workflow-helpers'; +import { ManualExecutionService } from '@/manual-execution.service'; + +describe('ManualExecutionService', () => { + const manualExecutionService = new ManualExecutionService(mock()); -describe('WorkflowHelpers', () => { describe('getExecutionStartNode', () => { it('Should return undefined', () => { const data = { @@ -16,9 +19,10 @@ describe('WorkflowHelpers', () => { }; }, } as unknown as Workflow; - const executionStartNode = getExecutionStartNode(data, workflow); + const executionStartNode = manualExecutionService.getExecutionStartNode(data, workflow); expect(executionStartNode).toBeUndefined(); }); + it('Should return startNode', () => { const data = { pinData: { @@ -37,7 +41,7 @@ describe('WorkflowHelpers', () => { return undefined; }, } as unknown as Workflow; - const executionStartNode = getExecutionStartNode(data, workflow); + const executionStartNode = manualExecutionService.getExecutionStartNode(data, workflow); expect(executionStartNode).toEqual({ name: 'node2', }); diff --git a/packages/cli/src/__tests__/node-types.test.ts b/packages/cli/src/__tests__/node-types.test.ts index 11e2c5ba2b5f8..78d0c5e18a31c 100644 --- a/packages/cli/src/__tests__/node-types.test.ts +++ b/packages/cli/src/__tests__/node-types.test.ts @@ -1,95 +1,114 @@ import { mock } from 'jest-mock-extended'; -import type { INodeType, IVersionedNodeType } from 'n8n-workflow'; +import { UnrecognizedNodeTypeError } from 'n8n-core'; +import type { + LoadedClass, + INodeType, + IVersionedNodeType, + INodeTypeDescription, +} from 'n8n-workflow'; -import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; - -import { NodeTypes } from '../node-types'; +import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; +import { NodeTypes } from '@/node-types'; describe('NodeTypes', () => { - let nodeTypes: NodeTypes; const loadNodesAndCredentials = mock(); + const nodeTypes: NodeTypes = new NodeTypes(loadNodesAndCredentials); + + const nonVersionedNode: LoadedClass = { + sourcePath: '', + type: { + description: mock({ + name: 'n8n-nodes-base.nonVersioned', + usableAsTool: undefined, + }), + }, + }; + const v1Node = mock(); + const v2Node = mock(); + const versionedNode: LoadedClass = { + sourcePath: '', + type: { + description: mock({ + name: 'n8n-nodes-base.versioned', + }), + currentVersion: 2, + nodeVersions: { + 1: v1Node, + 2: v2Node, + }, + getNodeType(version) { + if (version === 1) return v1Node; + return v2Node; + }, + }, + }; + const toolSupportingNode: LoadedClass = { + sourcePath: '', + type: { + description: mock({ + name: 'n8n-nodes-base.testNode', + displayName: 'TestNode', + usableAsTool: true, + properties: [], + }), + }, + }; + + loadNodesAndCredentials.getNode.mockImplementation((fullNodeType) => { + const [packageName, nodeType] = fullNodeType.split('.'); + if (nodeType === 'nonVersioned') return nonVersionedNode; + if (nodeType === 'versioned') return versionedNode; + if (nodeType === 'testNode') return toolSupportingNode; + throw new UnrecognizedNodeTypeError(packageName, nodeType); + }); + beforeEach(() => { jest.clearAllMocks(); - nodeTypes = new NodeTypes(loadNodesAndCredentials); + }); + + describe('getByName', () => { + it('should return node type when it exists', () => { + const result = nodeTypes.getByName('n8n-nodes-base.nonVersioned'); + expect(result).toBe(nonVersionedNode.type); + }); }); describe('getByNameAndVersion', () => { - const nodeTypeName = 'n8n-nodes-base.testNode'; + it('should throw an error if the package does not exist', () => { + expect(() => nodeTypes.getByNameAndVersion('invalid-package.unknownNode')).toThrow( + 'Unrecognized node type: invalid-package.unknownNode', + ); + }); it('should throw an error if the node-type does not exist', () => { - const nodeTypeName = 'unknownNode'; - - // @ts-expect-error overwriting a readonly property - loadNodesAndCredentials.loadedNodes = {}; - // @ts-expect-error overwriting a readonly property - loadNodesAndCredentials.knownNodes = {}; - - expect(() => nodeTypes.getByNameAndVersion(nodeTypeName)).toThrow( - 'Unrecognized node type: unknownNode', + expect(() => nodeTypes.getByNameAndVersion('n8n-nodes-base.unknownNode')).toThrow( + 'Unrecognized node type: n8n-nodes-base.unknownNode', ); }); it('should return a regular node-type without version', () => { - const nodeType = mock(); - - // @ts-expect-error overwriting a readonly property - loadNodesAndCredentials.loadedNodes = { - [nodeTypeName]: { type: nodeType }, - }; - - const result = nodeTypes.getByNameAndVersion(nodeTypeName); - - expect(result).toEqual(nodeType); + const result = nodeTypes.getByNameAndVersion('n8n-nodes-base.nonVersioned'); + expect(result).toBe(nonVersionedNode.type); }); it('should return a regular node-type with version', () => { - const nodeTypeV1 = mock(); - const nodeType = mock({ - nodeVersions: { 1: nodeTypeV1 }, - getNodeType: () => nodeTypeV1, - }); - - // @ts-expect-error overwriting a readonly property - loadNodesAndCredentials.loadedNodes = { - [nodeTypeName]: { type: nodeType }, - }; - - const result = nodeTypes.getByNameAndVersion(nodeTypeName); - - expect(result).toEqual(nodeTypeV1); + const result = nodeTypes.getByNameAndVersion('n8n-nodes-base.versioned'); + expect(result).toBe(v2Node); }); it('should throw when a node-type is requested as tool, but does not support being used as one', () => { - const nodeType = mock(); - - // @ts-expect-error overwriting a readonly property - loadNodesAndCredentials.loadedNodes = { - [nodeTypeName]: { type: nodeType }, - }; - - expect(() => nodeTypes.getByNameAndVersion(`${nodeTypeName}Tool`)).toThrow( + expect(() => nodeTypes.getByNameAndVersion('n8n-nodes-base.nonVersionedTool')).toThrow( 'Node cannot be used as a tool', ); }); it('should return the tool node-type when requested as tool', () => { - const nodeType = mock(); - // @ts-expect-error can't use a mock here - nodeType.description = { - name: nodeTypeName, - displayName: 'TestNode', - usableAsTool: true, - properties: [], - }; - - // @ts-expect-error overwriting a readonly property - loadNodesAndCredentials.loadedNodes = { - [nodeTypeName]: { type: nodeType }, - }; - - const result = nodeTypes.getByNameAndVersion(`${nodeTypeName}Tool`); - expect(result).not.toEqual(nodeType); + // @ts-expect-error don't mock convertNodeToAiTool for now + loadNodesAndCredentials.convertNodeToAiTool = + LoadNodesAndCredentials.prototype.convertNodeToAiTool; + const result = nodeTypes.getByNameAndVersion('n8n-nodes-base.testNodeTool'); + expect(result).not.toEqual(toolSupportingNode); expect(result.description.name).toEqual('n8n-nodes-base.testNodeTool'); expect(result.description.displayName).toEqual('TestNode Tool'); expect(result.description.codex?.categories).toContain('AI'); @@ -97,4 +116,47 @@ describe('NodeTypes', () => { expect(result.description.outputs).toEqual(['ai_tool']); }); }); + + describe('getWithSourcePath', () => { + it('should return description and source path for existing node', () => { + const result = nodeTypes.getWithSourcePath('n8n-nodes-base.nonVersioned', 1); + expect(result).toHaveProperty('description'); + expect(result).toHaveProperty('sourcePath'); + expect(result.sourcePath).toBe(nonVersionedNode.sourcePath); + }); + + it('should throw error for non-existent node', () => { + expect(() => nodeTypes.getWithSourcePath('n8n-nodes-base.nonExistent', 1)).toThrow( + 'Unrecognized node type: n8n-nodes-base.nonExistent', + ); + }); + }); + + describe('getKnownTypes', () => { + it('should return known node types', () => { + // @ts-expect-error readonly property + loadNodesAndCredentials.knownNodes = ['n8n-nodes-base.nonVersioned']; + const result = nodeTypes.getKnownTypes(); + expect(result).toEqual(['n8n-nodes-base.nonVersioned']); + }); + }); + + describe('getNodeTypeDescriptions', () => { + it('should return descriptions for valid node types', () => { + const nodeTypes = new NodeTypes(loadNodesAndCredentials); + const result = nodeTypes.getNodeTypeDescriptions([ + { name: 'n8n-nodes-base.nonVersioned', version: 1 }, + ]); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('n8n-nodes-base.nonVersioned'); + }); + + it('should throw error for invalid node type', () => { + const nodeTypes = new NodeTypes(loadNodesAndCredentials); + expect(() => + nodeTypes.getNodeTypeDescriptions([{ name: 'n8n-nodes-base.nonExistent', version: 1 }]), + ).toThrow('Unrecognized node type: n8n-nodes-base.nonExistent'); + }); + }); }); diff --git a/packages/cli/src/__tests__/wait-tracker.test.ts b/packages/cli/src/__tests__/wait-tracker.test.ts index ef420e7d7848c..6721c31bae09f 100644 --- a/packages/cli/src/__tests__/wait-tracker.test.ts +++ b/packages/cli/src/__tests__/wait-tracker.test.ts @@ -23,7 +23,7 @@ describe('WaitTracker', () => { const executionRepository = mock(); const multiMainSetup = mock(); const orchestrationService = new OrchestrationService(mock(), multiMainSetup, mock()); - const instanceSettings = mock({ isLeader: true }); + const instanceSettings = mock({ isLeader: true, isMultiMain: false }); const project = mock({ id: 'projectId' }); const execution = mock({ @@ -221,8 +221,6 @@ describe('WaitTracker', () => { describe('multi-main setup', () => { it('should start tracking if leader', () => { - jest.spyOn(orchestrationService, 'isSingleMainSetup', 'get').mockReturnValue(false); - executionRepository.getWaitingExecutions.mockResolvedValue([]); waitTracker.init(); @@ -238,9 +236,8 @@ describe('WaitTracker', () => { activeExecutions, workflowRunner, orchestrationService, - mock({ isLeader: false }), + mock({ isLeader: false, isMultiMain: false }), ); - jest.spyOn(orchestrationService, 'isSingleMainSetup', 'get').mockReturnValue(false); executionRepository.getWaitingExecutions.mockResolvedValue([]); diff --git a/packages/cli/src/abstract-server.ts b/packages/cli/src/abstract-server.ts index f440b2879acc5..f4a8a5b2ccd8d 100644 --- a/packages/cli/src/abstract-server.ts +++ b/packages/cli/src/abstract-server.ts @@ -94,11 +94,8 @@ export abstract class AbstractServer { const { app } = this; // Augment errors sent to Sentry - const { - Handlers: { requestHandler, errorHandler }, - } = await import('@sentry/node'); - app.use(requestHandler()); - app.use(errorHandler()); + const { setupExpressErrorHandler } = await import('@sentry/node'); + setupExpressErrorHandler(app); } private setupCommonMiddlewares() { diff --git a/packages/cli/src/active-workflow-manager.ts b/packages/cli/src/active-workflow-manager.ts index 22cc0f5700974..6ef3753af74be 100644 --- a/packages/cli/src/active-workflow-manager.ts +++ b/packages/cli/src/active-workflow-manager.ts @@ -1,9 +1,8 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ - import { ActiveWorkflows, + ErrorReporter, InstanceSettings, - NodeExecuteFunctions, PollContext, TriggerContext, } from 'n8n-core'; @@ -25,7 +24,6 @@ import type { import { Workflow, WorkflowActivationError, - ErrorReporterProxy as ErrorReporter, WebhookPathTakenError, ApplicationError, } from 'n8n-workflow'; @@ -41,10 +39,12 @@ import { import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { OnShutdown } from '@/decorators/on-shutdown'; +import { ExecutionService } from '@/executions/execution.service'; import { ExternalHooks } from '@/external-hooks'; import type { IWorkflowDb } from '@/interfaces'; import { Logger } from '@/logging/logger.service'; import { NodeTypes } from '@/node-types'; +import { Publisher } from '@/scaling/pubsub/publisher.service'; import { ActiveWorkflowsService } from '@/services/active-workflows.service'; import { OrchestrationService } from '@/services/orchestration.service'; import * as WebhookHelpers from '@/webhooks/webhook-helpers'; @@ -53,9 +53,6 @@ import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-da import { WorkflowExecutionService } from '@/workflows/workflow-execution.service'; import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service'; -import { ExecutionService } from './executions/execution.service'; -import { Publisher } from './scaling/pubsub/publisher.service'; - interface QueuedActivation { activationMode: WorkflowActivateMode; lastTimeout: number; @@ -69,6 +66,7 @@ export class ActiveWorkflowManager { constructor( private readonly logger: Logger, + private readonly errorReporter: ErrorReporter, private readonly activeWorkflows: ActiveWorkflows, private readonly activeExecutions: ActiveExecutions, private readonly externalHooks: ExternalHooks, @@ -186,12 +184,7 @@ export class ActiveWorkflowManager { try { // TODO: this should happen in a transaction, that way we don't need to manually remove this in `catch` await this.webhookService.storeWebhook(webhook); - await workflow.createWebhookIfNotExists( - webhookData, - NodeExecuteFunctions, - mode, - activation, - ); + await this.webhookService.createWebhookIfNotExists(workflow, webhookData, mode, activation); } catch (error) { if (activation === 'init' && error.name === 'QueryFailedError') { // n8n does not remove the registered webhooks on exit. @@ -205,7 +198,7 @@ export class ActiveWorkflowManager { try { await this.clearWebhooks(workflow.id); } catch (error1) { - ErrorReporter.error(error1); + this.errorReporter.error(error1); this.logger.error( `Could not remove webhooks of workflow "${workflow.id}" because of error: "${error1.message}"`, ); @@ -261,7 +254,7 @@ export class ActiveWorkflowManager { const webhooks = WebhookHelpers.getWorkflowWebhooks(workflow, additionalData, undefined, true); for (const webhookData of webhooks) { - await workflow.deleteWebhook(webhookData, NodeExecuteFunctions, mode, 'update'); + await this.webhookService.deleteWebhook(workflow, webhookData, mode, 'update'); } await this.workflowStaticDataService.saveStaticData(workflow); @@ -439,7 +432,7 @@ export class ActiveWorkflowManager { this.logger.info(' => Started'); } } catch (error) { - ErrorReporter.error(error); + this.errorReporter.error(error); this.logger.info( ' => ERROR: Workflow could not be activated on first try, keep on trying if not an auth issue', ); @@ -511,7 +504,7 @@ export class ActiveWorkflowManager { existingWorkflow?: WorkflowEntity, { shouldPublish } = { shouldPublish: true }, ) { - if (this.orchestrationService.isMultiMainSetupEnabled && shouldPublish) { + if (this.instanceSettings.isMultiMain && shouldPublish) { void this.publisher.publishCommand({ command: 'add-webhooks-triggers-and-pollers', payload: { workflowId }, @@ -557,7 +550,7 @@ export class ActiveWorkflowManager { settings: dbWorkflow.settings, }); - const canBeActivated = workflow.checkIfWorkflowCanBeActivated(STARTING_NODES); + const canBeActivated = this.checkIfWorkflowCanBeActivated(workflow, STARTING_NODES); if (!canBeActivated) { throw new WorkflowActivationError( @@ -601,6 +594,48 @@ export class ActiveWorkflowManager { return shouldDisplayActivationMessage; } + /** + * A workflow can only be activated if it has a node which has either triggers + * or webhooks defined. + * + * @param {string[]} [ignoreNodeTypes] Node-types to ignore in the check + */ + checkIfWorkflowCanBeActivated(workflow: Workflow, ignoreNodeTypes?: string[]): boolean { + let node: INode; + let nodeType: INodeType | undefined; + + for (const nodeName of Object.keys(workflow.nodes)) { + node = workflow.nodes[nodeName]; + + if (node.disabled === true) { + // Deactivated nodes can not trigger a run so ignore + continue; + } + + if (ignoreNodeTypes !== undefined && ignoreNodeTypes.includes(node.type)) { + continue; + } + + nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); + + if (nodeType === undefined) { + // Type is not known so check is not possible + continue; + } + + if ( + nodeType.poll !== undefined || + nodeType.trigger !== undefined || + nodeType.webhook !== undefined + ) { + // Is a trigger node. So workflow can be activated. + return true; + } + } + + return false; + } + /** * Count all triggers in the workflow, excluding Manual Trigger. */ @@ -635,7 +670,7 @@ export class ActiveWorkflowManager { try { await this.add(workflowId, activationMode, workflowData); } catch (error) { - ErrorReporter.error(error); + this.errorReporter.error(error); let lastTimeout = this.queuedActivations[workflowId].lastTimeout; if (lastTimeout < WORKFLOW_REACTIVATE_MAX_TIMEOUT) { lastTimeout = Math.min(lastTimeout * 2, WORKFLOW_REACTIVATE_MAX_TIMEOUT); @@ -703,11 +738,11 @@ export class ActiveWorkflowManager { // TODO: this should happen in a transaction // maybe, see: https://github.com/n8n-io/n8n/pull/8904#discussion_r1530150510 async remove(workflowId: string) { - if (this.orchestrationService.isMultiMainSetupEnabled) { + if (this.instanceSettings.isMultiMain) { try { await this.clearWebhooks(workflowId); } catch (error) { - ErrorReporter.error(error); + this.errorReporter.error(error); this.logger.error( `Could not remove webhooks of workflow "${workflowId}" because of error: "${error.message}"`, ); @@ -724,7 +759,7 @@ export class ActiveWorkflowManager { try { await this.clearWebhooks(workflowId); } catch (error) { - ErrorReporter.error(error); + this.errorReporter.error(error); this.logger.error( `Could not remove webhooks of workflow "${workflowId}" because of error: "${error.message}"`, ); diff --git a/packages/cli/src/collaboration/collaboration.service.ts b/packages/cli/src/collaboration/collaboration.service.ts index cb2ca0d77a905..a6e957f510be2 100644 --- a/packages/cli/src/collaboration/collaboration.service.ts +++ b/packages/cli/src/collaboration/collaboration.service.ts @@ -1,6 +1,7 @@ import type { PushPayload } from '@n8n/api-types'; +import { ErrorReporter } from 'n8n-core'; import type { Workflow } from 'n8n-workflow'; -import { ApplicationError, ErrorReporterProxy } from 'n8n-workflow'; +import { ApplicationError } from 'n8n-workflow'; import { Service } from 'typedi'; import { CollaborationState } from '@/collaboration/collaboration.state'; @@ -20,6 +21,7 @@ import { parseWorkflowMessage } from './collaboration.message'; @Service() export class CollaborationService { constructor( + private readonly errorReporter: ErrorReporter, private readonly push: Push, private readonly state: CollaborationState, private readonly userRepository: UserRepository, @@ -31,7 +33,7 @@ export class CollaborationService { try { await this.handleUserMessage(event.userId, event.msg); } catch (error) { - ErrorReporterProxy.error( + this.errorReporter.error( new ApplicationError('Error handling CollaborationService push message', { extra: { msg: event.msg, @@ -97,6 +99,6 @@ export class CollaborationService { collaborators: activeCollaborators, }; - this.push.sendToUsers('collaboratorsChanged', msgData, userIds); + this.push.sendToUsers({ type: 'collaboratorsChanged', data: msgData }, userIds); } } diff --git a/packages/cli/src/collaboration/collaboration.state.ts b/packages/cli/src/collaboration/collaboration.state.ts index f8f606a2adc43..556dee2ace4e7 100644 --- a/packages/cli/src/collaboration/collaboration.state.ts +++ b/packages/cli/src/collaboration/collaboration.state.ts @@ -27,7 +27,7 @@ export class CollaborationState { * After how many minutes of inactivity a user should be removed * as being an active user of a workflow. */ - public readonly inactivityCleanUpTime = 15 * Time.minutes.toMilliseconds; + readonly inactivityCleanUpTime = 15 * Time.minutes.toMilliseconds; constructor(private readonly cache: CacheService) {} diff --git a/packages/cli/src/commands/base-command.ts b/packages/cli/src/commands/base-command.ts index b8f15d9f33776..286fec1de6bec 100644 --- a/packages/cli/src/commands/base-command.ts +++ b/packages/cli/src/commands/base-command.ts @@ -6,13 +6,9 @@ import { InstanceSettings, ObjectStoreService, DataDeduplicationService, + ErrorReporter, } from 'n8n-core'; -import { - ApplicationError, - ensureError, - ErrorReporterProxy as ErrorReporter, - sleep, -} from 'n8n-workflow'; +import { ApplicationError, ensureError, sleep } from 'n8n-workflow'; import { Container } from 'typedi'; import type { AbstractServer } from '@/abstract-server'; @@ -22,7 +18,6 @@ import * as CrashJournal from '@/crash-journal'; import * as Db from '@/db'; import { getDataDeduplicationService } from '@/deduplication'; import { DeprecationService } from '@/deprecation/deprecation.service'; -import { initErrorHandling } from '@/error-reporting'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; import { TelemetryEventRelay } from '@/events/relays/telemetry.event-relay'; import { initExpressionEvaluator } from '@/expression-evaluator'; @@ -39,6 +34,8 @@ import { WorkflowHistoryManager } from '@/workflows/workflow-history/workflow-hi export abstract class BaseCommand extends Command { protected logger = Container.get(Logger); + protected errorReporter: ErrorReporter; + protected externalHooks?: ExternalHooks; protected nodeTypes: NodeTypes; @@ -63,7 +60,11 @@ export abstract class BaseCommand extends Command { protected needsCommunityPackages = false; async init(): Promise { - await initErrorHandling(); + this.errorReporter = Container.get(ErrorReporter); + await this.errorReporter.init( + this.instanceSettings.instanceType, + this.globalConfig.sentry.backendDsn, + ); initExpressionEvaluator(); process.once('SIGTERM', this.onTerminationSignal('SIGTERM')); @@ -130,7 +131,7 @@ export abstract class BaseCommand extends Command { } protected async exitWithCrash(message: string, error: unknown) { - ErrorReporter.error(new Error(message, { cause: error }), { level: 'fatal' }); + this.errorReporter.error(new Error(message, { cause: error }), { level: 'fatal' }); await sleep(2000); process.exit(1); } diff --git a/packages/cli/src/commands/execute-batch.ts b/packages/cli/src/commands/execute-batch.ts index a70717c40b3df..0b19e256525e5 100644 --- a/packages/cli/src/commands/execute-batch.ts +++ b/packages/cli/src/commands/execute-batch.ts @@ -4,7 +4,7 @@ import fs from 'fs'; import { diff } from 'json-diff'; import pick from 'lodash/pick'; import type { IRun, ITaskData, IWorkflowExecutionDataProcess } from 'n8n-workflow'; -import { ApplicationError, jsonParse, ErrorReporterProxy } from 'n8n-workflow'; +import { ApplicationError, jsonParse } from 'n8n-workflow'; import os from 'os'; import { sep } from 'path'; import { Container } from 'typedi'; @@ -822,7 +822,7 @@ export class ExecuteBatch extends BaseCommand { } } } catch (e) { - ErrorReporterProxy.error(e, { + this.errorReporter.error(e, { extra: { workflowId: workflowData.id, }, diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 42b5df13e6a3c..63ec3d9240dfa 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -100,7 +100,7 @@ export class Start extends BaseCommand { await this.activeWorkflowManager.removeAllTriggerAndPollerBasedWorkflows(); - if (Container.get(OrchestrationService).isMultiMainSetupEnabled) { + if (this.instanceSettings.isMultiMain) { await Container.get(OrchestrationService).shutdown(); } @@ -192,6 +192,9 @@ export class Start extends BaseCommand { await super.init(); this.activeWorkflowManager = Container.get(ActiveWorkflowManager); + this.instanceSettings.setMultiMainEnabled( + config.getEnv('executions.mode') === 'queue' && this.globalConfig.multiMainSetup.enabled, + ); await this.initLicense(); await this.initOrchestration(); @@ -253,7 +256,7 @@ export class Start extends BaseCommand { this.logger.scoped(['scaling', 'pubsub']).debug('Pubsub setup completed'); - if (!orchestrationService.isMultiMainSetupEnabled) return; + if (this.instanceSettings.isSingleMain) return; orchestrationService.multiMainSetup .on('leader-stepdown', async () => { diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 1891d8193d7f0..54fa07e7f5dcb 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -405,11 +405,4 @@ export const schema = { doc: 'Set this to 1 to enable the new partial execution logic by default.', }, }, - - virtualSchemaView: { - doc: 'Whether to display the virtualized schema view', - format: Boolean, - default: false, - env: 'N8N_VIRTUAL_SCHEMA_VIEW', - }, }; diff --git a/packages/cli/src/controllers/community-packages.controller.ts b/packages/cli/src/controllers/community-packages.controller.ts index 918f1cdf74598..ab2134b7e010c 100644 --- a/packages/cli/src/controllers/community-packages.controller.ts +++ b/packages/cli/src/controllers/community-packages.controller.ts @@ -115,9 +115,12 @@ export class CommunityPackagesController { // broadcast to connected frontends that node list has been updated installedPackage.installedNodes.forEach((node) => { - this.push.broadcast('reloadNodeType', { - name: node.type, - version: node.latestVersion, + this.push.broadcast({ + type: 'reloadNodeType', + data: { + name: node.type, + version: node.latestVersion, + }, }); }); @@ -206,9 +209,12 @@ export class CommunityPackagesController { // broadcast to connected frontends that node list has been updated installedPackage.installedNodes.forEach((node) => { - this.push.broadcast('removeNodeType', { - name: node.type, - version: node.latestVersion, + this.push.broadcast({ + type: 'removeNodeType', + data: { + name: node.type, + version: node.latestVersion, + }, }); }); @@ -246,16 +252,22 @@ export class CommunityPackagesController { // broadcast to connected frontends that node list has been updated previouslyInstalledPackage.installedNodes.forEach((node) => { - this.push.broadcast('removeNodeType', { - name: node.type, - version: node.latestVersion, + this.push.broadcast({ + type: 'removeNodeType', + data: { + name: node.type, + version: node.latestVersion, + }, }); }); newInstalledPackage.installedNodes.forEach((node) => { - this.push.broadcast('reloadNodeType', { - name: node.name, - version: node.latestVersion, + this.push.broadcast({ + type: 'reloadNodeType', + data: { + name: node.name, + version: node.latestVersion, + }, }); }); @@ -272,9 +284,12 @@ export class CommunityPackagesController { return newInstalledPackage; } catch (error) { previouslyInstalledPackage.installedNodes.forEach((node) => { - this.push.broadcast('removeNodeType', { - name: node.type, - version: node.latestVersion, + this.push.broadcast({ + type: 'removeNodeType', + data: { + name: node.type, + version: node.latestVersion, + }, }); }); diff --git a/packages/cli/src/controllers/dynamic-node-parameters.controller.ts b/packages/cli/src/controllers/dynamic-node-parameters.controller.ts index f3df53d95b0e1..2858fd99cae9d 100644 --- a/packages/cli/src/controllers/dynamic-node-parameters.controller.ts +++ b/packages/cli/src/controllers/dynamic-node-parameters.controller.ts @@ -93,6 +93,22 @@ export class DynamicNodeParametersController { ); } + @Post('/local-resource-mapper-fields') + async getLocalResourceMappingFields(req: DynamicNodeParametersRequest.ResourceMapperFields) { + const { path, methodName, currentNodeParameters, nodeTypeAndVersion } = req.body; + + if (!methodName) throw new BadRequestError('Missing `methodName` in request body'); + + const additionalData = await getBase(req.user.id, currentNodeParameters); + + return await this.service.getLocalResourceMappingFields( + methodName, + path, + additionalData, + nodeTypeAndVersion, + ); + } + @Post('/action-result') async getActionResult( req: DynamicNodeParametersRequest.ActionResult, diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index 9c5a1ff36d386..a61342320dd04 100644 --- a/packages/cli/src/controllers/e2e.controller.ts +++ b/packages/cli/src/controllers/e2e.controller.ts @@ -1,4 +1,4 @@ -import type { PushPayload, PushType } from '@n8n/api-types'; +import type { PushMessage } from '@n8n/api-types'; import { Request } from 'express'; import Container from 'typedi'; import { v4 as uuid } from 'uuid'; @@ -58,14 +58,12 @@ type ResetRequest = Request< } >; -type PushRequest = Request< +type PushRequest = Request< {}, {}, { - type: T; pushRef: string; - data: PushPayload; - } + } & PushMessage >; @RestController('/e2e') @@ -144,8 +142,9 @@ export class E2EController { } @Post('/push', { skipAuth: true }) - async pushSend(req: PushRequest) { - this.push.broadcast(req.body.type, req.body.data); + async pushSend(req: PushRequest) { + const { pushRef: _, ...pushMsg } = req.body; + this.push.broadcast(pushMsg); } @Patch('/feature', { skipAuth: true }) diff --git a/packages/cli/src/credential-types.ts b/packages/cli/src/credential-types.ts index 24e2d9f2bac4f..a6d3f29eb0035 100644 --- a/packages/cli/src/credential-types.ts +++ b/packages/cli/src/credential-types.ts @@ -1,13 +1,6 @@ -import { loadClassInIsolation } from 'n8n-core'; -import { - ApplicationError, - type ICredentialType, - type ICredentialTypes, - type LoadedClass, -} from 'n8n-workflow'; +import type { ICredentialType, ICredentialTypes } from 'n8n-workflow'; import { Service } from 'typedi'; -import { RESPONSE_ERROR_MESSAGES } from '@/constants'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; @Service() @@ -20,7 +13,7 @@ export class CredentialTypes implements ICredentialTypes { } getByName(credentialType: string): ICredentialType { - return this.getCredential(credentialType).type; + return this.loadNodesAndCredentials.getCredential(credentialType).type; } getSupportedNodes(type: string): string[] { @@ -39,21 +32,4 @@ export class CredentialTypes implements ICredentialTypes { } return extendsArr; } - - private getCredential(type: string): LoadedClass { - const { loadedCredentials, knownCredentials } = this.loadNodesAndCredentials; - if (type in loadedCredentials) { - return loadedCredentials[type]; - } - - if (type in knownCredentials) { - const { className, sourcePath } = knownCredentials[type]; - const loaded: ICredentialType = loadClassInIsolation(sourcePath, className); - loadedCredentials[type] = { sourcePath, type: loaded }; - return loadedCredentials[type]; - } - throw new ApplicationError(RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL, { - tags: { credentialType: type }, - }); - } } diff --git a/packages/cli/src/databases/entities/credentials-entity.ts b/packages/cli/src/databases/entities/credentials-entity.ts index bbfc137f85f36..b4f1a10c3872b 100644 --- a/packages/cli/src/databases/entities/credentials-entity.ts +++ b/packages/cli/src/databases/entities/credentials-entity.ts @@ -29,6 +29,14 @@ export class CredentialsEntity extends WithTimestampsAndStringId implements ICre @OneToMany('SharedCredentials', 'credentials') shared: SharedCredentials[]; + /** + * Whether the credential is managed by n8n. We currently use this flag + * to provide OpenAI free credits on cloud. Managed credentials cannot be + * edited by the user. + */ + @Column({ default: false }) + isManaged: boolean; + toJSON() { const { shared, ...rest } = this; return rest; diff --git a/packages/cli/src/databases/entities/workflow-entity.ts b/packages/cli/src/databases/entities/workflow-entity.ts index b03cf2c28d9cb..67d0f0e345cc3 100644 --- a/packages/cli/src/databases/entities/workflow-entity.ts +++ b/packages/cli/src/databases/entities/workflow-entity.ts @@ -80,7 +80,7 @@ export class WorkflowEntity extends WithTimestampsAndStringId implements IWorkfl nullable: true, transformer: sqlite.jsonColumn, }) - pinData: ISimplifiedPinData; + pinData?: ISimplifiedPinData; @Column({ length: 36 }) versionId: string; diff --git a/packages/cli/src/databases/migrations/common/1659888469333-AddJsonKeyPinData.ts b/packages/cli/src/databases/migrations/common/1659888469333-AddJsonKeyPinData.ts index 84d3040a10d7b..75ce65ad6bffe 100644 --- a/packages/cli/src/databases/migrations/common/1659888469333-AddJsonKeyPinData.ts +++ b/packages/cli/src/databases/migrations/common/1659888469333-AddJsonKeyPinData.ts @@ -1,7 +1,7 @@ +import { isObjectLiteral } from 'n8n-core'; import type { IDataObject, INodeExecutionData } from 'n8n-workflow'; import type { MigrationContext, IrreversibleMigration } from '@/databases/types'; -import { isObjectLiteral } from '@/utils'; type OldPinnedData = { [nodeName: string]: IDataObject[] }; type NewPinnedData = { [nodeName: string]: INodeExecutionData[] }; diff --git a/packages/cli/src/databases/migrations/common/1733133775640-AddMockedNodesColumnToTestDefinition.ts b/packages/cli/src/databases/migrations/common/1733133775640-AddMockedNodesColumnToTestDefinition.ts index 09ce45722c028..53f650ebcf9ca 100644 --- a/packages/cli/src/databases/migrations/common/1733133775640-AddMockedNodesColumnToTestDefinition.ts +++ b/packages/cli/src/databases/migrations/common/1733133775640-AddMockedNodesColumnToTestDefinition.ts @@ -9,7 +9,7 @@ export class AddMockedNodesColumnToTestDefinition1733133775640 implements Revers const mockedNodesColumnName = escape.columnName('mockedNodes'); await runQuery( - `ALTER TABLE ${tableName} ADD COLUMN ${mockedNodesColumnName} JSON DEFAULT '[]' NOT NULL`, + `ALTER TABLE ${tableName} ADD COLUMN ${mockedNodesColumnName} JSON DEFAULT ('[]') NOT NULL`, ); } diff --git a/packages/cli/src/databases/migrations/common/1734479635324-AddManagedColumnToCredentialsTable.ts b/packages/cli/src/databases/migrations/common/1734479635324-AddManagedColumnToCredentialsTable.ts new file mode 100644 index 0000000000000..00fc2d16e7e47 --- /dev/null +++ b/packages/cli/src/databases/migrations/common/1734479635324-AddManagedColumnToCredentialsTable.ts @@ -0,0 +1,21 @@ +import type { MigrationContext, ReversibleMigration } from '@/databases/types'; + +export class AddManagedColumnToCredentialsTable1734479635324 implements ReversibleMigration { + async up({ escape, runQuery, isSqlite }: MigrationContext) { + const tableName = escape.tableName('credentials_entity'); + const columnName = escape.columnName('isManaged'); + + const defaultValue = isSqlite ? 0 : 'FALSE'; + + await runQuery( + `ALTER TABLE ${tableName} ADD COLUMN ${columnName} BOOLEAN NOT NULL DEFAULT ${defaultValue}`, + ); + } + + async down({ escape, runQuery }: MigrationContext) { + const tableName = escape.tableName('credentials_entity'); + const columnName = escape.columnName('isManaged'); + + await runQuery(`ALTER TABLE ${tableName} DROP COLUMN ${columnName}`); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index b977f6b013faa..2fc39079d42c0 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -74,6 +74,7 @@ import { AddDescriptionToTestDefinition1731404028106 } from '../common/173140402 import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-CreateTestMetricTable'; import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable'; import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition'; +import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable'; export const mysqlMigrations: Migration[] = [ InitialMigration1588157391238, @@ -150,4 +151,5 @@ export const mysqlMigrations: Migration[] = [ CreateTestMetricTable1732271325258, CreateTestRun1732549866705, AddMockedNodesColumnToTestDefinition1733133775640, + AddManagedColumnToCredentialsTable1734479635324, ]; diff --git a/packages/cli/src/databases/migrations/postgresdb/index.ts b/packages/cli/src/databases/migrations/postgresdb/index.ts index 985e6964e10f1..605c156003b51 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -74,6 +74,7 @@ import { AddDescriptionToTestDefinition1731404028106 } from '../common/173140402 import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-CreateTestMetricTable'; import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable'; import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition'; +import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable'; export const postgresMigrations: Migration[] = [ InitialMigration1587669153312, @@ -150,4 +151,5 @@ export const postgresMigrations: Migration[] = [ CreateTestMetricTable1732271325258, CreateTestRun1732549866705, AddMockedNodesColumnToTestDefinition1733133775640, + AddManagedColumnToCredentialsTable1734479635324, ]; diff --git a/packages/cli/src/databases/migrations/sqlite/index.ts b/packages/cli/src/databases/migrations/sqlite/index.ts index 34d548b6841fa..0981ece99b343 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -71,6 +71,7 @@ import { CreateTestDefinitionTable1730386903556 } from '../common/1730386903556- import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-CreateTestMetricTable'; import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable'; import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition'; +import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable'; const sqliteMigrations: Migration[] = [ InitialMigration1588102412422, @@ -144,6 +145,7 @@ const sqliteMigrations: Migration[] = [ CreateTestMetricTable1732271325258, CreateTestRun1732549866705, AddMockedNodesColumnToTestDefinition1733133775640, + AddManagedColumnToCredentialsTable1734479635324, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 5bef675a7995b..fbcb7de445463 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -21,12 +21,8 @@ import { import { DateUtils } from '@n8n/typeorm/util/DateUtils'; import { parse, stringify } from 'flatted'; import pick from 'lodash/pick'; -import { BinaryDataService } from 'n8n-core'; -import { - ExecutionCancelledError, - ErrorReporterProxy as ErrorReporter, - ApplicationError, -} from 'n8n-workflow'; +import { BinaryDataService, ErrorReporter } from 'n8n-core'; +import { ExecutionCancelledError, ApplicationError } from 'n8n-workflow'; import type { AnnotationVote, ExecutionStatus, @@ -125,6 +121,7 @@ export class ExecutionRepository extends Repository { dataSource: DataSource, private readonly globalConfig: GlobalConfig, private readonly logger: Logger, + private readonly errorReporter: ErrorReporter, private readonly executionDataRepository: ExecutionDataRepository, private readonly binaryDataService: BinaryDataService, ) { @@ -209,7 +206,7 @@ export class ExecutionRepository extends Repository { reportInvalidExecutions(executions: ExecutionEntity[]) { if (executions.length === 0) return; - ErrorReporter.error( + this.errorReporter.error( new ApplicationError('Found executions without executionData', { extra: { executionIds: executions.map(({ id }) => id) }, }), diff --git a/packages/cli/src/databases/repositories/installed-packages.repository.ts b/packages/cli/src/databases/repositories/installed-packages.repository.ts index 50f9e7ad4fa2c..77faf9681747b 100644 --- a/packages/cli/src/databases/repositories/installed-packages.repository.ts +++ b/packages/cli/src/databases/repositories/installed-packages.repository.ts @@ -35,7 +35,7 @@ export class InstalledPackagesRepository extends Repository { for (const loadedNode of loadedNodes) { const installedNode = this.installedNodesRepository.create({ name: nodeTypes[loadedNode.name].type.description.displayName, - type: loadedNode.name, + type: `${packageName}.${loadedNode.name}`, latestVersion: loadedNode.version, package: { packageName }, }); diff --git a/packages/cli/src/databases/repositories/settings.repository.ts b/packages/cli/src/databases/repositories/settings.repository.ts index 8d87fcffffb7b..aa4410d6d48fe 100644 --- a/packages/cli/src/databases/repositories/settings.repository.ts +++ b/packages/cli/src/databases/repositories/settings.repository.ts @@ -1,5 +1,5 @@ import { DataSource, Repository } from '@n8n/typeorm'; -import { ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; +import { ErrorReporter } from 'n8n-core'; import { Service } from 'typedi'; import config from '@/config'; @@ -9,7 +9,10 @@ import { Settings } from '../entities/settings'; @Service() export class SettingsRepository extends Repository { - constructor(dataSource: DataSource) { + constructor( + dataSource: DataSource, + private readonly errorReporter: ErrorReporter, + ) { super(Settings, dataSource.manager); } @@ -49,7 +52,7 @@ export class SettingsRepository extends Repository { config.set(key, value); return { success: true }; } catch (error) { - ErrorReporter.error(error); + this.errorReporter.error(error); } return { success: false }; } diff --git a/packages/cli/src/databases/repositories/test-run.repository.ee.ts b/packages/cli/src/databases/repositories/test-run.repository.ee.ts index 3be1eb379067c..1e889aadc758f 100644 --- a/packages/cli/src/databases/repositories/test-run.repository.ee.ts +++ b/packages/cli/src/databases/repositories/test-run.repository.ee.ts @@ -12,7 +12,7 @@ export class TestRunRepository extends Repository { super(TestRun, dataSource.manager); } - public async createTestRun(testDefinitionId: string) { + async createTestRun(testDefinitionId: string) { const testRun = this.create({ status: 'new', testDefinition: { id: testDefinitionId }, @@ -21,15 +21,15 @@ export class TestRunRepository extends Repository { return await this.save(testRun); } - public async markAsRunning(id: string) { + async markAsRunning(id: string) { return await this.update(id, { status: 'running', runAt: new Date() }); } - public async markAsCompleted(id: string, metrics: AggregatedTestRunMetrics) { + async markAsCompleted(id: string, metrics: AggregatedTestRunMetrics) { return await this.update(id, { status: 'completed', completedAt: new Date(), metrics }); } - public async getMany(testDefinitionId: string, options: ListQuery.Options) { + async getMany(testDefinitionId: string, options: ListQuery.Options) { const findManyOptions: FindManyOptions = { where: { testDefinition: { id: testDefinitionId } }, order: { createdAt: 'DESC' }, diff --git a/packages/cli/src/databases/subscribers/user-subscriber.ts b/packages/cli/src/databases/subscribers/user-subscriber.ts index 2f9e698890eea..1c55572b14e01 100644 --- a/packages/cli/src/databases/subscribers/user-subscriber.ts +++ b/packages/cli/src/databases/subscribers/user-subscriber.ts @@ -1,6 +1,7 @@ import type { EntitySubscriberInterface, UpdateEvent } from '@n8n/typeorm'; import { EventSubscriber } from '@n8n/typeorm'; -import { ApplicationError, ErrorReporterProxy } from 'n8n-workflow'; +import { ErrorReporter } from 'n8n-core'; +import { ApplicationError } from 'n8n-workflow'; import { Container } from 'typedi'; import { Logger } from '@/logging/logger.service'; @@ -11,6 +12,8 @@ import { UserRepository } from '../repositories/user.repository'; @EventSubscriber() export class UserSubscriber implements EntitySubscriberInterface { + private readonly eventReporter = Container.get(ErrorReporter); + listenTo() { return User; } @@ -47,7 +50,7 @@ export class UserSubscriber implements EntitySubscriberInterface { const message = "Could not update the personal project's name"; Container.get(Logger).warn(message, event.entity); const exception = new ApplicationError(message); - ErrorReporterProxy.warn(exception, event.entity); + this.eventReporter.warn(exception, event.entity); return; } @@ -69,7 +72,7 @@ export class UserSubscriber implements EntitySubscriberInterface { const message = "Could not update the personal project's name"; Container.get(Logger).warn(message, event.entity); const exception = new ApplicationError(message); - ErrorReporterProxy.warn(exception, event.entity); + this.eventReporter.warn(exception, event.entity); } } } diff --git a/packages/cli/src/db.ts b/packages/cli/src/db.ts index 13147b4106ac1..e1c2b0e402ccd 100644 --- a/packages/cli/src/db.ts +++ b/packages/cli/src/db.ts @@ -2,11 +2,8 @@ import type { EntityManager } from '@n8n/typeorm'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { DataSource as Connection } from '@n8n/typeorm'; -import { - DbConnectionTimeoutError, - ensureError, - ErrorReporterProxy as ErrorReporter, -} from 'n8n-workflow'; +import { ErrorReporter } from 'n8n-core'; +import { DbConnectionTimeoutError, ensureError } from 'n8n-workflow'; import { Container } from 'typedi'; import { inTest } from '@/constants'; @@ -38,7 +35,7 @@ if (!inTest) { connectionState.connected = true; return; } catch (error) { - ErrorReporter.error(error); + Container.get(ErrorReporter).error(error); } finally { pingTimer = setTimeout(pingDBFn, 2000); } diff --git a/packages/cli/src/decorators/__tests__/on-shutdown.test.ts b/packages/cli/src/decorators/__tests__/on-shutdown.test.ts index 28e70dac47328..774ae2ef4889b 100644 --- a/packages/cli/src/decorators/__tests__/on-shutdown.test.ts +++ b/packages/cli/src/decorators/__tests__/on-shutdown.test.ts @@ -8,7 +8,7 @@ describe('OnShutdown', () => { let shutdownService: ShutdownService; beforeEach(() => { - shutdownService = new ShutdownService(mock()); + shutdownService = new ShutdownService(mock(), mock()); Container.set(ShutdownService, shutdownService); jest.spyOn(shutdownService, 'register'); }); diff --git a/packages/cli/src/environments/source-control/source-control-export.service.ee.ts b/packages/cli/src/environments/source-control/source-control-export.service.ee.ts index 9c495bbb8d21b..03352410f435f 100644 --- a/packages/cli/src/environments/source-control/source-control-export.service.ee.ts +++ b/packages/cli/src/environments/source-control/source-control-export.service.ee.ts @@ -71,7 +71,7 @@ export class SourceControlExportService { } } - public rmFilesFromExportFolder(filesToBeDeleted: Set): Set { + rmFilesFromExportFolder(filesToBeDeleted: Set): Set { try { filesToBeDeleted.forEach((e) => rmSync(e)); } catch (error) { diff --git a/packages/cli/src/environments/source-control/source-control-import.service.ee.ts b/packages/cli/src/environments/source-control/source-control-import.service.ee.ts index b5012d2762ff6..2e7da80c13312 100644 --- a/packages/cli/src/environments/source-control/source-control-import.service.ee.ts +++ b/packages/cli/src/environments/source-control/source-control-import.service.ee.ts @@ -1,13 +1,8 @@ // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In } from '@n8n/typeorm'; import glob from 'fast-glob'; -import { Credentials, InstanceSettings } from 'n8n-core'; -import { - ApplicationError, - jsonParse, - ErrorReporterProxy as ErrorReporter, - ensureError, -} from 'n8n-workflow'; +import { Credentials, ErrorReporter, InstanceSettings } from 'n8n-core'; +import { ApplicationError, jsonParse, ensureError } from 'n8n-workflow'; import { readFile as fsReadFile } from 'node:fs/promises'; import path from 'path'; import { Container, Service } from 'typedi'; @@ -56,6 +51,7 @@ export class SourceControlImportService { constructor( private readonly logger: Logger, + private readonly errorReporter: ErrorReporter, private readonly variablesService: VariablesService, private readonly activeWorkflowManager: ActiveWorkflowManager, private readonly tagRepository: TagRepository, @@ -69,7 +65,7 @@ export class SourceControlImportService { ); } - public async getRemoteVersionIdsFromFiles(): Promise { + async getRemoteVersionIdsFromFiles(): Promise { const remoteWorkflowFiles = await glob('*.json', { cwd: this.workflowExportFolder, absolute: true, @@ -95,7 +91,7 @@ export class SourceControlImportService { ); } - public async getLocalVersionIdsFromDb(): Promise { + async getLocalVersionIdsFromDb(): Promise { const localWorkflows = await Container.get(WorkflowRepository).find({ select: ['id', 'name', 'versionId', 'updatedAt'], }); @@ -104,7 +100,7 @@ export class SourceControlImportService { if (local.updatedAt instanceof Date) { updatedAt = local.updatedAt; } else { - ErrorReporter.warn('updatedAt is not a Date', { + this.errorReporter.warn('updatedAt is not a Date', { extra: { type: typeof local.updatedAt, value: local.updatedAt, @@ -123,7 +119,7 @@ export class SourceControlImportService { }) as SourceControlWorkflowVersionId[]; } - public async getRemoteCredentialsFromFiles(): Promise< + async getRemoteCredentialsFromFiles(): Promise< Array > { const remoteCredentialFiles = await glob('*.json', { @@ -150,9 +146,7 @@ export class SourceControlImportService { >; } - public async getLocalCredentialsFromDb(): Promise< - Array - > { + async getLocalCredentialsFromDb(): Promise> { const localCredentials = await Container.get(CredentialsRepository).find({ select: ['id', 'name', 'type'], }); @@ -164,7 +158,7 @@ export class SourceControlImportService { })) as Array; } - public async getRemoteVariablesFromFile(): Promise { + async getRemoteVariablesFromFile(): Promise { const variablesFile = await glob(SOURCE_CONTROL_VARIABLES_EXPORT_FILE, { cwd: this.gitFolder, absolute: true, @@ -178,11 +172,11 @@ export class SourceControlImportService { return []; } - public async getLocalVariablesFromDb(): Promise { + async getLocalVariablesFromDb(): Promise { return await this.variablesService.getAllCached(); } - public async getRemoteTagsAndMappingsFromFile(): Promise<{ + async getRemoteTagsAndMappingsFromFile(): Promise<{ tags: TagEntity[]; mappings: WorkflowTagMapping[]; }> { @@ -201,7 +195,7 @@ export class SourceControlImportService { return { tags: [], mappings: [] }; } - public async getLocalTagsAndMappingsFromDb(): Promise<{ + async getLocalTagsAndMappingsFromDb(): Promise<{ tags: TagEntity[]; mappings: WorkflowTagMapping[]; }> { @@ -214,7 +208,7 @@ export class SourceControlImportService { return { tags: localTags, mappings: localMappings }; } - public async importWorkflowFromWorkFolder(candidates: SourceControlledFile[], userId: string) { + async importWorkflowFromWorkFolder(candidates: SourceControlledFile[], userId: string) { const personalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(userId); const workflowManager = this.activeWorkflowManager; @@ -301,7 +295,7 @@ export class SourceControlImportService { }>; } - public async importCredentialsFromWorkFolder(candidates: SourceControlledFile[], userId: string) { + async importCredentialsFromWorkFolder(candidates: SourceControlledFile[], userId: string) { const personalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(userId); const candidateIds = candidates.map((c) => c.id); @@ -375,7 +369,7 @@ export class SourceControlImportService { return importCredentialsResult.filter((e) => e !== undefined); } - public async importTagsFromWorkFolder(candidate: SourceControlledFile) { + async importTagsFromWorkFolder(candidate: SourceControlledFile) { let mappedTags; try { this.logger.debug(`Importing tags from file ${candidate.file}`); @@ -437,7 +431,7 @@ export class SourceControlImportService { return mappedTags; } - public async importVariablesFromWorkFolder( + async importVariablesFromWorkFolder( candidate: SourceControlledFile, valueOverrides?: { [key: string]: string; diff --git a/packages/cli/src/environments/source-control/source-control-preferences.service.ee.ts b/packages/cli/src/environments/source-control/source-control-preferences.service.ee.ts index d3a34d784f28b..7c061b6c3c5be 100644 --- a/packages/cli/src/environments/source-control/source-control-preferences.service.ee.ts +++ b/packages/cli/src/environments/source-control/source-control-preferences.service.ee.ts @@ -41,7 +41,7 @@ export class SourceControlPreferencesService { this.sshKeyName = path.join(this.sshFolder, SOURCE_CONTROL_SSH_KEY_NAME); } - public get sourceControlPreferences(): SourceControlPreferences { + get sourceControlPreferences(): SourceControlPreferences { return { ...this._sourceControlPreferences, connected: this._sourceControlPreferences.connected ?? false, @@ -49,14 +49,14 @@ export class SourceControlPreferencesService { } // merge the new preferences with the existing preferences when setting - public set sourceControlPreferences(preferences: Partial) { + set sourceControlPreferences(preferences: Partial) { this._sourceControlPreferences = SourceControlPreferences.merge( preferences, this._sourceControlPreferences, ); } - public isSourceControlSetup() { + isSourceControlSetup() { return ( this.isSourceControlLicensedAndEnabled() && this.getPreferences().repositoryUrl && diff --git a/packages/cli/src/environments/source-control/source-control.service.ee.ts b/packages/cli/src/environments/source-control/source-control.service.ee.ts index 58c213f03c1b7..e010210262f62 100644 --- a/packages/cli/src/environments/source-control/source-control.service.ee.ts +++ b/packages/cli/src/environments/source-control/source-control.service.ee.ts @@ -81,7 +81,7 @@ export class SourceControlService { }); } - public async sanityCheck(): Promise { + async sanityCheck(): Promise { try { const foldersExisted = sourceControlFoldersExistCheck( [this.gitFolder, this.sshFolder], diff --git a/packages/cli/src/environments/variables/variables.controller.ee.ts b/packages/cli/src/environments/variables/variables.controller.ee.ts index 5da4221a3d56d..a38906b800c26 100644 --- a/packages/cli/src/environments/variables/variables.controller.ee.ts +++ b/packages/cli/src/environments/variables/variables.controller.ee.ts @@ -1,4 +1,7 @@ +import { VariableListRequestDto } from '@n8n/api-types'; + import { Delete, Get, GlobalScope, Licensed, Patch, Post, RestController } from '@/decorators'; +import { Query } from '@/decorators/args'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { VariableCountLimitReachedError } from '@/errors/variable-count-limit-reached.error'; @@ -13,8 +16,8 @@ export class VariablesController { @Get('/') @GlobalScope('variable:list') - async getVariables() { - return await this.variablesService.getAllCached(); + async getVariables(_req: unknown, _res: unknown, @Query query: VariableListRequestDto) { + return await this.variablesService.getAllCached(query.state); } @Post('/') diff --git a/packages/cli/src/environments/variables/variables.service.ee.ts b/packages/cli/src/environments/variables/variables.service.ee.ts index 31cf725099b90..38ad5703ea91e 100644 --- a/packages/cli/src/environments/variables/variables.service.ee.ts +++ b/packages/cli/src/environments/variables/variables.service.ee.ts @@ -18,13 +18,22 @@ export class VariablesService { private readonly eventService: EventService, ) {} - async getAllCached(): Promise { - const variables = await this.cacheService.get('variables', { + async getAllCached(state?: 'empty'): Promise { + let variables = await this.cacheService.get('variables', { async refreshFn() { return await Container.get(VariablesService).findAll(); }, }); - return (variables as Array>).map((v) => this.variablesRepository.create(v)); + + if (variables === undefined) { + return []; + } + + if (state === 'empty') { + variables = variables.filter((v) => v.value === ''); + } + + return variables.map((v) => this.variablesRepository.create(v)); } async getCount(): Promise { diff --git a/packages/cli/src/error-reporting.ts b/packages/cli/src/error-reporting.ts deleted file mode 100644 index fd2ce078cd583..0000000000000 --- a/packages/cli/src/error-reporting.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { GlobalConfig } from '@n8n/config'; -// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import -import { QueryFailedError } from '@n8n/typeorm'; -import { AxiosError } from 'axios'; -import { createHash } from 'crypto'; -import { InstanceSettings } from 'n8n-core'; -import { ErrorReporterProxy, ApplicationError } from 'n8n-workflow'; -import Container from 'typedi'; - -let initialized = false; - -export const initErrorHandling = async () => { - if (initialized) return; - - process.on('uncaughtException', (error) => { - ErrorReporterProxy.error(error); - }); - - const dsn = Container.get(GlobalConfig).sentry.backendDsn; - if (!dsn) { - initialized = true; - return; - } - - // Collect longer stacktraces - Error.stackTraceLimit = 50; - - const { - N8N_VERSION: release, - ENVIRONMENT: environment, - DEPLOYMENT_NAME: serverName, - } = process.env; - - const { init, captureException, setTag } = await import('@sentry/node'); - - const { RewriteFrames } = await import('@sentry/integrations'); - const { Integrations } = await import('@sentry/node'); - - const enabledIntegrations = [ - 'InboundFilters', - 'FunctionToString', - 'LinkedErrors', - 'OnUnhandledRejection', - 'ContextLines', - ]; - const seenErrors = new Set(); - - init({ - dsn, - release, - environment, - enableTracing: false, - serverName, - beforeBreadcrumb: () => null, - integrations: (integrations) => [ - ...integrations.filter(({ name }) => enabledIntegrations.includes(name)), - new RewriteFrames({ root: process.cwd() }), - new Integrations.RequestData({ - include: { - cookies: false, - data: false, - headers: false, - query_string: false, - url: true, - user: false, - }, - }), - ], - async beforeSend(event, { originalException }) { - if (!originalException) return null; - - if (originalException instanceof Promise) { - originalException = await originalException.catch((error) => error as Error); - } - - if (originalException instanceof AxiosError) return null; - - if ( - originalException instanceof QueryFailedError && - ['SQLITE_FULL', 'SQLITE_IOERR'].some((errMsg) => originalException.message.includes(errMsg)) - ) { - return null; - } - - if (originalException instanceof ApplicationError) { - const { level, extra, tags } = originalException; - if (level === 'warning') return null; - event.level = level; - if (extra) event.extra = { ...event.extra, ...extra }; - if (tags) event.tags = { ...event.tags, ...tags }; - } - - if ( - originalException instanceof Error && - 'cause' in originalException && - originalException.cause instanceof Error && - 'level' in originalException.cause && - originalException.cause.level === 'warning' - ) { - // handle underlying errors propagating from dependencies like ai-assistant-sdk - return null; - } - - if (originalException instanceof Error && originalException.stack) { - const eventHash = createHash('sha1').update(originalException.stack).digest('base64'); - if (seenErrors.has(eventHash)) return null; - seenErrors.add(eventHash); - } - - return event; - }, - }); - - setTag('server_type', Container.get(InstanceSettings).instanceType); - - ErrorReporterProxy.init({ - report: (error, options) => captureException(error, options), - }); - - initialized = true; -}; diff --git a/packages/cli/src/evaluation/test-runner/__tests__/create-pin-data.ee.test.ts b/packages/cli/src/evaluation/test-runner/__tests__/create-pin-data.ee.test.ts index 6da88f9c202d9..685c15552badc 100644 --- a/packages/cli/src/evaluation/test-runner/__tests__/create-pin-data.ee.test.ts +++ b/packages/cli/src/evaluation/test-runner/__tests__/create-pin-data.ee.test.ts @@ -13,7 +13,9 @@ const executionDataJson = JSON.parse( describe('createPinData', () => { test('should create pin data from past execution data', () => { - const pinData = createPinData(wfUnderTestJson, executionDataJson); + const mockedNodes = ['When clicking ‘Test workflow’'].map((name) => ({ name })); + + const pinData = createPinData(wfUnderTestJson, mockedNodes, executionDataJson); expect(pinData).toEqual( expect.objectContaining({ @@ -21,4 +23,34 @@ describe('createPinData', () => { }), ); }); + + test('should not create pin data for non-existing mocked nodes', () => { + const mockedNodes = ['Non-existing node'].map((name) => ({ name })); + + const pinData = createPinData(wfUnderTestJson, mockedNodes, executionDataJson); + + expect(pinData).toEqual({}); + }); + + test('should create pin data for all mocked nodes', () => { + const mockedNodes = ['When clicking ‘Test workflow’', 'Edit Fields', 'Code'].map((name) => ({ + name, + })); + + const pinData = createPinData(wfUnderTestJson, mockedNodes, executionDataJson); + + expect(pinData).toEqual( + expect.objectContaining({ + 'When clicking ‘Test workflow’': expect.anything(), + 'Edit Fields': expect.anything(), + Code: expect.anything(), + }), + ); + }); + + test('should return empty object if no mocked nodes are provided', () => { + const pinData = createPinData(wfUnderTestJson, [], executionDataJson); + + expect(pinData).toEqual({}); + }); }); diff --git a/packages/cli/src/evaluation/test-runner/__tests__/test-runner.service.ee.test.ts b/packages/cli/src/evaluation/test-runner/__tests__/test-runner.service.ee.test.ts index cdb8e848d96c8..f923cb4b73863 100644 --- a/packages/cli/src/evaluation/test-runner/__tests__/test-runner.service.ee.test.ts +++ b/packages/cli/src/evaluation/test-runner/__tests__/test-runner.service.ee.test.ts @@ -15,7 +15,11 @@ import type { ExecutionRepository } from '@/databases/repositories/execution.rep import type { TestMetricRepository } from '@/databases/repositories/test-metric.repository.ee'; import type { TestRunRepository } from '@/databases/repositories/test-run.repository.ee'; import type { WorkflowRepository } from '@/databases/repositories/workflow.repository'; +import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; +import { NodeTypes } from '@/node-types'; import type { WorkflowRunner } from '@/workflow-runner'; +import { mockInstance } from '@test/mocking'; +import { mockNodeTypesData } from '@test-integration/utils/node-types-data'; import { TestRunnerService } from '../test-runner.service.ee'; @@ -27,10 +31,28 @@ const wfEvaluationJson = JSON.parse( readFileSync(path.join(__dirname, './mock-data/workflow.evaluation.json'), { encoding: 'utf-8' }), ); +const wfMultipleTriggersJson = JSON.parse( + readFileSync(path.join(__dirname, './mock-data/workflow.multiple-triggers.json'), { + encoding: 'utf-8', + }), +); + const executionDataJson = JSON.parse( readFileSync(path.join(__dirname, './mock-data/execution-data.json'), { encoding: 'utf-8' }), ); +const executionDataMultipleTriggersJson = JSON.parse( + readFileSync(path.join(__dirname, './mock-data/execution-data.multiple-triggers.json'), { + encoding: 'utf-8', + }), +); + +const executionDataMultipleTriggersJson2 = JSON.parse( + readFileSync(path.join(__dirname, './mock-data/execution-data.multiple-triggers-2.json'), { + encoding: 'utf-8', + }), +); + const executionMocks = [ mock({ id: 'some-execution-id', @@ -93,6 +115,11 @@ describe('TestRunnerService', () => { const testRunRepository = mock(); const testMetricRepository = mock(); + const mockNodeTypes = mockInstance(NodeTypes); + mockInstance(LoadNodesAndCredentials, { + loadedNodes: mockNodeTypesData(['manualTrigger', 'set', 'if', 'code']), + }); + beforeEach(() => { const executionsQbMock = mockDeep>({ fallbackMockImplementation: jest.fn().mockReturnThis(), @@ -131,6 +158,7 @@ describe('TestRunnerService', () => { activeExecutions, testRunRepository, testMetricRepository, + mockNodeTypes, ); expect(testRunnerService).toBeInstanceOf(TestRunnerService); @@ -144,6 +172,7 @@ describe('TestRunnerService', () => { activeExecutions, testRunRepository, testMetricRepository, + mockNodeTypes, ); workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({ @@ -163,6 +192,7 @@ describe('TestRunnerService', () => { mock({ workflowId: 'workflow-under-test-id', evaluationWorkflowId: 'evaluation-workflow-id', + mockedNodes: [], }), ); @@ -179,6 +209,7 @@ describe('TestRunnerService', () => { activeExecutions, testRunRepository, testMetricRepository, + mockNodeTypes, ); workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({ @@ -219,6 +250,7 @@ describe('TestRunnerService', () => { mock({ workflowId: 'workflow-under-test-id', evaluationWorkflowId: 'evaluation-workflow-id', + mockedNodes: [{ name: 'When clicking ‘Test workflow’' }], }), ); @@ -265,4 +297,125 @@ describe('TestRunnerService', () => { metric2: 0, }); }); + + test('should specify correct start nodes when running workflow under test', async () => { + const testRunnerService = new TestRunnerService( + workflowRepository, + workflowRunner, + executionRepository, + activeExecutions, + testRunRepository, + testMetricRepository, + mockNodeTypes, + ); + + workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({ + id: 'workflow-under-test-id', + ...wfUnderTestJson, + }); + + workflowRepository.findById.calledWith('evaluation-workflow-id').mockResolvedValueOnce({ + id: 'evaluation-workflow-id', + ...wfEvaluationJson, + }); + + workflowRunner.run.mockResolvedValueOnce('some-execution-id'); + workflowRunner.run.mockResolvedValueOnce('some-execution-id-2'); + workflowRunner.run.mockResolvedValueOnce('some-execution-id-3'); + workflowRunner.run.mockResolvedValueOnce('some-execution-id-4'); + + // Mock executions of workflow under test + activeExecutions.getPostExecutePromise + .calledWith('some-execution-id') + .mockResolvedValue(mockExecutionData()); + + activeExecutions.getPostExecutePromise + .calledWith('some-execution-id-3') + .mockResolvedValue(mockExecutionData()); + + // Mock executions of evaluation workflow + activeExecutions.getPostExecutePromise + .calledWith('some-execution-id-2') + .mockResolvedValue(mockEvaluationExecutionData({ metric1: 1, metric2: 0 })); + + activeExecutions.getPostExecutePromise + .calledWith('some-execution-id-4') + .mockResolvedValue(mockEvaluationExecutionData({ metric1: 0.5 })); + + await testRunnerService.runTest( + mock(), + mock({ + workflowId: 'workflow-under-test-id', + evaluationWorkflowId: 'evaluation-workflow-id', + mockedNodes: [{ name: 'When clicking ‘Test workflow’' }], + }), + ); + + expect(workflowRunner.run).toHaveBeenCalledTimes(4); + + // Check workflow under test was executed + expect(workflowRunner.run).toHaveBeenCalledWith( + expect.objectContaining({ + executionMode: 'evaluation', + pinData: { + 'When clicking ‘Test workflow’': + executionDataJson.resultData.runData['When clicking ‘Test workflow’'][0].data.main[0], + }, + workflowData: expect.objectContaining({ + id: 'workflow-under-test-id', + }), + triggerToStartFrom: expect.objectContaining({ + name: 'When clicking ‘Test workflow’', + }), + }), + ); + }); + + test('should properly choose trigger and start nodes', async () => { + const testRunnerService = new TestRunnerService( + workflowRepository, + workflowRunner, + executionRepository, + activeExecutions, + testRunRepository, + testMetricRepository, + mockNodeTypes, + ); + + const startNodesData = (testRunnerService as any).getStartNodesData( + wfMultipleTriggersJson, + executionDataMultipleTriggersJson, + ); + + expect(startNodesData).toEqual({ + startNodes: expect.arrayContaining([expect.objectContaining({ name: 'NoOp' })]), + triggerToStartFrom: expect.objectContaining({ + name: 'When clicking ‘Test workflow’', + }), + }); + }); + + test('should properly choose trigger and start nodes 2', async () => { + const testRunnerService = new TestRunnerService( + workflowRepository, + workflowRunner, + executionRepository, + activeExecutions, + testRunRepository, + testMetricRepository, + mockNodeTypes, + ); + + const startNodesData = (testRunnerService as any).getStartNodesData( + wfMultipleTriggersJson, + executionDataMultipleTriggersJson2, + ); + + expect(startNodesData).toEqual({ + startNodes: expect.arrayContaining([expect.objectContaining({ name: 'NoOp' })]), + triggerToStartFrom: expect.objectContaining({ + name: 'When chat message received', + }), + }); + }); }); diff --git a/packages/cli/src/evaluation/test-runner/test-runner.service.ee.ts b/packages/cli/src/evaluation/test-runner/test-runner.service.ee.ts index 92f5e4394e275..9fcbe1de7c5bc 100644 --- a/packages/cli/src/evaluation/test-runner/test-runner.service.ee.ts +++ b/packages/cli/src/evaluation/test-runner/test-runner.service.ee.ts @@ -6,18 +6,20 @@ import type { IRunExecutionData, IWorkflowExecutionDataProcess, } from 'n8n-workflow'; +import { NodeConnectionType, Workflow } from 'n8n-workflow'; import assert from 'node:assert'; import { Service } from 'typedi'; import { ActiveExecutions } from '@/active-executions'; import type { ExecutionEntity } from '@/databases/entities/execution-entity'; -import type { TestDefinition } from '@/databases/entities/test-definition.ee'; +import type { MockedNodeItem, TestDefinition } from '@/databases/entities/test-definition.ee'; import type { User } from '@/databases/entities/user'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { TestMetricRepository } from '@/databases/repositories/test-metric.repository.ee'; import { TestRunRepository } from '@/databases/repositories/test-run.repository.ee'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; +import { NodeTypes } from '@/node-types'; import { getRunData } from '@/workflow-execute-additional-data'; import { WorkflowRunner } from '@/workflow-runner'; @@ -30,9 +32,7 @@ import { createPinData, getPastExecutionTriggerNode } from './utils.ee'; * past executions, creates pin data from them, * and runs the workflow-under-test with the pin data. * After the workflow-under-test finishes, it runs the evaluation workflow - * with the original and new run data. - * TODO: Node pinning - * TODO: Collect metrics + * with the original and new run data, and collects the metrics. */ @Service() export class TestRunnerService { @@ -43,8 +43,50 @@ export class TestRunnerService { private readonly activeExecutions: ActiveExecutions, private readonly testRunRepository: TestRunRepository, private readonly testMetricRepository: TestMetricRepository, + private readonly nodeTypes: NodeTypes, ) {} + /** + * Prepares the start nodes and trigger node data props for the `workflowRunner.run` method input. + */ + private getStartNodesData( + workflow: WorkflowEntity, + pastExecutionData: IRunExecutionData, + ): Pick { + // Create a new workflow instance to use the helper functions (getChildNodes) + const workflowInstance = new Workflow({ + nodes: workflow.nodes, + connections: workflow.connections, + active: false, + nodeTypes: this.nodeTypes, + }); + + // Determine the trigger node of the past execution + const pastExecutionTriggerNode = getPastExecutionTriggerNode(pastExecutionData); + assert(pastExecutionTriggerNode, 'Could not find the trigger node of the past execution'); + + const triggerNodeData = pastExecutionData.resultData.runData[pastExecutionTriggerNode][0]; + assert(triggerNodeData, 'Trigger node data not found'); + + const triggerToStartFrom = { + name: pastExecutionTriggerNode, + data: triggerNodeData, + }; + + // Start nodes are the nodes that are connected to the trigger node + const startNodes = workflowInstance + .getChildNodes(pastExecutionTriggerNode, NodeConnectionType.Main, 1) + .map((nodeName) => ({ + name: nodeName, + sourceData: { previousNode: pastExecutionTriggerNode }, + })); + + return { + startNodes, + triggerToStartFrom, + }; + } + /** * Runs a test case with the given pin data. * Waits for the workflow under test to finish execution. @@ -52,25 +94,19 @@ export class TestRunnerService { private async runTestCase( workflow: WorkflowEntity, pastExecutionData: IRunExecutionData, + mockedNodes: MockedNodeItem[], userId: string, ): Promise { // Create pin data from the past execution data - const pinData = createPinData(workflow, pastExecutionData); - - // Determine the start node of the past execution - const pastExecutionStartNode = getPastExecutionTriggerNode(pastExecutionData); + const pinData = createPinData(workflow, mockedNodes, pastExecutionData); // Prepare the data to run the workflow const data: IWorkflowExecutionDataProcess = { - destinationNode: pastExecutionData.startData?.destinationNode, - startNodes: pastExecutionStartNode - ? [{ name: pastExecutionStartNode, sourceData: null }] - : undefined, + ...this.getStartNodesData(workflow, pastExecutionData), executionMode: 'evaluation', runData: {}, pinData, workflowData: workflow, - partialExecutionVersion: '-1', userId, }; @@ -150,7 +186,7 @@ export class TestRunnerService { /** * Creates a new test run for the given test definition. */ - public async runTest(user: User, test: TestDefinition): Promise { + async runTest(user: User, test: TestDefinition): Promise { const workflow = await this.workflowRepository.findById(test.workflowId); assert(workflow, 'Workflow not found'); @@ -196,7 +232,12 @@ export class TestRunnerService { const executionData = parse(pastExecution.executionData.data) as IRunExecutionData; // Run the test case and wait for it to finish - const testCaseExecution = await this.runTestCase(workflow, executionData, user.id); + const testCaseExecution = await this.runTestCase( + workflow, + executionData, + test.mockedNodes, + user.id, + ); // In case of a permission check issue, the test case execution will be undefined. // Skip them and continue with the next test case diff --git a/packages/cli/src/evaluation/test-runner/utils.ee.ts b/packages/cli/src/evaluation/test-runner/utils.ee.ts index c2ef68a5dce2f..9cb516b4309ac 100644 --- a/packages/cli/src/evaluation/test-runner/utils.ee.ts +++ b/packages/cli/src/evaluation/test-runner/utils.ee.ts @@ -1,21 +1,29 @@ import type { IRunExecutionData, IPinData } from 'n8n-workflow'; +import type { MockedNodeItem } from '@/databases/entities/test-definition.ee'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; /** * Extracts the execution data from the past execution * and creates a pin data object from it for the given workflow. - * For now, it only pins trigger nodes. + * It uses a list of mocked nodes defined in a test definition + * to decide which nodes to pin. */ -export function createPinData(workflow: WorkflowEntity, executionData: IRunExecutionData) { - const triggerNodes = workflow.nodes.filter((node) => /trigger$/i.test(node.type)); - +export function createPinData( + workflow: WorkflowEntity, + mockedNodes: MockedNodeItem[], + executionData: IRunExecutionData, +) { const pinData = {} as IPinData; - for (const triggerNode of triggerNodes) { - const triggerData = executionData.resultData.runData[triggerNode.name]; - if (triggerData?.[0]?.data?.main?.[0]) { - pinData[triggerNode.name] = triggerData[0]?.data?.main?.[0]; + const workflowNodeNames = new Set(workflow.nodes.map((node) => node.name)); + + for (const mockedNode of mockedNodes) { + if (workflowNodeNames.has(mockedNode.name)) { + const nodeData = executionData.resultData.runData[mockedNode.name]; + if (nodeData?.[0]?.data?.main?.[0]) { + pinData[mockedNode.name] = nodeData[0]?.data?.main?.[0]; + } } } @@ -23,8 +31,8 @@ export function createPinData(workflow: WorkflowEntity, executionData: IRunExecu } /** - * Returns the start node of the past execution. - * The start node is the node that has no source and has run data. + * Returns the trigger node of the past execution. + * The trigger node is the node that has no source and has run data. */ export function getPastExecutionTriggerNode(executionData: IRunExecutionData) { return Object.keys(executionData.resultData.runData).find((nodeName) => { diff --git a/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-sentry.ee.ts b/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-sentry.ee.ts index 5678cbb59c4f4..35aef63cb1aee 100644 --- a/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-sentry.ee.ts +++ b/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-sentry.ee.ts @@ -48,7 +48,7 @@ export class MessageEventBusDestinationSentry environment, release: N8N_VERSION, transport: Sentry.makeNodeTransport, - integrations: Sentry.defaultIntegrations, + integrations: Sentry.getDefaultIntegrations({}), stackParser: Sentry.defaultStackParser, }); } diff --git a/packages/cli/src/eventbus/message-event-bus-writer/message-event-bus-log-writer.ts b/packages/cli/src/eventbus/message-event-bus-writer/message-event-bus-log-writer.ts index 6c6a928a676b2..3f3cb50b185dd 100644 --- a/packages/cli/src/eventbus/message-event-bus-writer/message-event-bus-log-writer.ts +++ b/packages/cli/src/eventbus/message-event-bus-writer/message-event-bus-log-writer.ts @@ -70,7 +70,7 @@ export class MessageEventBusLogWriter { this.globalConfig = Container.get(GlobalConfig); } - public get worker(): Worker | undefined { + get worker(): Worker | undefined { return this._worker; } diff --git a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts index 58d694e5560ec..4448dbc41ee55 100644 --- a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts @@ -1,5 +1,6 @@ import type { GlobalConfig } from '@n8n/config'; import { mock } from 'jest-mock-extended'; +import { InstanceSettings } from 'n8n-core'; import type { IWorkflowBase } from 'n8n-workflow'; import { N8N_VERSION } from '@/constants'; @@ -14,6 +15,7 @@ import type { IWorkflowDb } from '@/interfaces'; import type { License } from '@/license'; import type { NodeTypes } from '@/node-types'; import type { Telemetry } from '@/telemetry'; +import { mockInstance } from '@test/mocking'; const flushPromises = async () => await new Promise((resolve) => setImmediate(resolve)); @@ -41,6 +43,7 @@ describe('TelemetryEventRelay', () => { outputs: ['console'], }, }); + const instanceSettings = mockInstance(InstanceSettings, { isDocker: false, n8nFolder: '/test' }); const workflowRepository = mock(); const nodeTypes = mock(); const sharedWorkflowRepository = mock(); @@ -55,6 +58,7 @@ describe('TelemetryEventRelay', () => { telemetry, license, globalConfig, + instanceSettings, workflowRepository, nodeTypes, sharedWorkflowRepository, @@ -65,11 +69,8 @@ describe('TelemetryEventRelay', () => { }); beforeEach(() => { - globalConfig.diagnostics.enabled = true; - }); - - afterEach(() => { jest.clearAllMocks(); + globalConfig.diagnostics.enabled = true; }); describe('init', () => { @@ -80,6 +81,7 @@ describe('TelemetryEventRelay', () => { telemetry, license, globalConfig, + instanceSettings, workflowRepository, nodeTypes, sharedWorkflowRepository, @@ -101,6 +103,7 @@ describe('TelemetryEventRelay', () => { telemetry, license, globalConfig, + instanceSettings, workflowRepository, nodeTypes, sharedWorkflowRepository, @@ -942,7 +945,36 @@ describe('TelemetryEventRelay', () => { await flushPromises(); - // expect(telemetry.identify).toHaveBeenCalled(); + expect(telemetry.identify).toHaveBeenCalledWith( + expect.objectContaining({ + version_cli: N8N_VERSION, + metrics: { + metrics_category_cache: false, + metrics_category_default: true, + metrics_category_logs: false, + metrics_category_queue: false, + metrics_category_routes: false, + metrics_enabled: true, + }, + n8n_binary_data_mode: 'default', + n8n_deployment_type: 'default', + saml_enabled: false, + smtp_set_up: true, + system_info: { + is_docker: false, + cpus: expect.objectContaining({ + count: expect.any(Number), + model: expect.any(String), + speed: expect.any(Number), + }), + memory: expect.any(Number), + os: expect.objectContaining({ + type: expect.any(String), + version: expect.any(String), + }), + }, + }), + ); expect(telemetry.track).toHaveBeenCalledWith( 'Instance started', expect.objectContaining({ diff --git a/packages/cli/src/events/maps/pub-sub.event-map.ts b/packages/cli/src/events/maps/pub-sub.event-map.ts index ff27741b9b772..0d71fcff91510 100644 --- a/packages/cli/src/events/maps/pub-sub.event-map.ts +++ b/packages/cli/src/events/maps/pub-sub.event-map.ts @@ -1,4 +1,4 @@ -import type { PushType, WorkerStatus } from '@n8n/api-types'; +import type { PushMessage, WorkerStatus } from '@n8n/api-types'; import type { IWorkflowDb } from '@/interfaces'; @@ -64,9 +64,7 @@ export type PubSubCommandMap = { errorMessage: string; }; - 'relay-execution-lifecycle-event': { - type: PushType; - args: Record; + 'relay-execution-lifecycle-event': PushMessage & { pushRef: string; }; diff --git a/packages/cli/src/events/relays/telemetry.event-relay.ts b/packages/cli/src/events/relays/telemetry.event-relay.ts index 0a352087e5e41..a34646f100f5f 100644 --- a/packages/cli/src/events/relays/telemetry.event-relay.ts +++ b/packages/cli/src/events/relays/telemetry.event-relay.ts @@ -1,5 +1,6 @@ import { GlobalConfig } from '@n8n/config'; import { snakeCase } from 'change-case'; +import { InstanceSettings } from 'n8n-core'; import type { ExecutionStatus, INodesGraphResult, ITelemetryTrackProperties } from 'n8n-workflow'; import { TelemetryHelpers } from 'n8n-workflow'; import os from 'node:os'; @@ -28,6 +29,7 @@ export class TelemetryEventRelay extends EventRelay { private readonly telemetry: Telemetry, private readonly license: License, private readonly globalConfig: GlobalConfig, + private readonly instanceSettings: InstanceSettings, private readonly workflowRepository: WorkflowRepository, private readonly nodeTypes: NodeTypes, private readonly sharedWorkflowRepository: SharedWorkflowRepository, @@ -760,6 +762,7 @@ export class TelemetryEventRelay extends EventRelay { model: cpus[0].model, speed: cpus[0].speed, }, + is_docker: this.instanceSettings.isDocker, }, execution_variables: { executions_mode: config.getEnv('executions.mode'), diff --git a/packages/cli/src/execution-lifecycle-hooks/__tests__/save-execution-progress.test.ts b/packages/cli/src/execution-lifecycle-hooks/__tests__/save-execution-progress.test.ts index d89f2fb734d2f..eedbf27c9e6eb 100644 --- a/packages/cli/src/execution-lifecycle-hooks/__tests__/save-execution-progress.test.ts +++ b/packages/cli/src/execution-lifecycle-hooks/__tests__/save-execution-progress.test.ts @@ -1,9 +1,5 @@ -import { - ErrorReporterProxy, - type IRunExecutionData, - type ITaskData, - type IWorkflowBase, -} from 'n8n-workflow'; +import { ErrorReporter } from 'n8n-core'; +import type { IRunExecutionData, ITaskData, IWorkflowBase } from 'n8n-workflow'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { saveExecutionProgress } from '@/execution-lifecycle-hooks/save-execution-progress'; @@ -13,7 +9,7 @@ import { Logger } from '@/logging/logger.service'; import { mockInstance } from '@test/mocking'; mockInstance(Logger); - +const errorReporter = mockInstance(ErrorReporter); const executionRepository = mockInstance(ExecutionRepository); afterEach(() => { @@ -63,8 +59,6 @@ test('should update execution when saving progress is enabled', async () => { progress: true, }); - const reporterSpy = jest.spyOn(ErrorReporterProxy, 'error'); - executionRepository.findSingleExecution.mockResolvedValue({} as IExecutionResponse); await saveExecutionProgress(...commonArgs); @@ -83,7 +77,7 @@ test('should update execution when saving progress is enabled', async () => { status: 'running', }); - expect(reporterSpy).not.toHaveBeenCalled(); + expect(errorReporter.error).not.toHaveBeenCalled(); }); test('should report error on failure', async () => { @@ -92,8 +86,6 @@ test('should report error on failure', async () => { progress: true, }); - const reporterSpy = jest.spyOn(ErrorReporterProxy, 'error'); - const error = new Error('Something went wrong'); executionRepository.findSingleExecution.mockImplementation(() => { @@ -103,5 +95,5 @@ test('should report error on failure', async () => { await saveExecutionProgress(...commonArgs); expect(executionRepository.updateExistingExecution).not.toHaveBeenCalled(); - expect(reporterSpy).toHaveBeenCalledWith(error); + expect(errorReporter.error).toHaveBeenCalledWith(error); }); diff --git a/packages/cli/src/execution-lifecycle-hooks/save-execution-progress.ts b/packages/cli/src/execution-lifecycle-hooks/save-execution-progress.ts index ca9899e1ec1a2..c1de2646c0269 100644 --- a/packages/cli/src/execution-lifecycle-hooks/save-execution-progress.ts +++ b/packages/cli/src/execution-lifecycle-hooks/save-execution-progress.ts @@ -1,5 +1,5 @@ +import { ErrorReporter } from 'n8n-core'; import type { IRunExecutionData, ITaskData, IWorkflowBase } from 'n8n-workflow'; -import { ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; import { Container } from 'typedi'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; @@ -85,7 +85,7 @@ export async function saveExecutionProgress( } catch (e) { const error = e instanceof Error ? e : new Error(`${e}`); - ErrorReporter.error(error); + Container.get(ErrorReporter).error(error); // TODO: Improve in the future! // Errors here might happen because of database access // For busy machines, we may get "Database is locked" errors. diff --git a/packages/cli/src/executions/execution-recovery.service.ts b/packages/cli/src/executions/execution-recovery.service.ts index 33576d1368d0a..a10fc995a472d 100644 --- a/packages/cli/src/executions/execution-recovery.service.ts +++ b/packages/cli/src/executions/execution-recovery.service.ts @@ -49,7 +49,7 @@ export class ExecutionRecoveryService { this.push.once('editorUiConnected', async () => { await sleep(1000); - this.push.broadcast('executionRecovered', { executionId }); + this.push.broadcast({ type: 'executionRecovered', data: { executionId } }); }); return amendedExecution; diff --git a/packages/cli/src/executions/execution.service.ts b/packages/cli/src/executions/execution.service.ts index 67eb145b19204..433955254f698 100644 --- a/packages/cli/src/executions/execution.service.ts +++ b/packages/cli/src/executions/execution.service.ts @@ -504,7 +504,7 @@ export class ExecutionService { } } - public async annotate( + async annotate( executionId: string, updateData: ExecutionRequest.ExecutionUpdatePayload, sharedWorkflowIds: string[], diff --git a/packages/cli/src/expression-evaluator.ts b/packages/cli/src/expression-evaluator.ts index 9a91b4864f52d..434c78e114bbf 100644 --- a/packages/cli/src/expression-evaluator.ts +++ b/packages/cli/src/expression-evaluator.ts @@ -1,4 +1,6 @@ -import { ErrorReporterProxy, ExpressionEvaluatorProxy } from 'n8n-workflow'; +import { ErrorReporter } from 'n8n-core'; +import { ExpressionEvaluatorProxy } from 'n8n-workflow'; +import Container from 'typedi'; import config from '@/config'; @@ -6,7 +8,7 @@ export const initExpressionEvaluator = () => { ExpressionEvaluatorProxy.setEvaluator(config.getEnv('expression.evaluator')); ExpressionEvaluatorProxy.setDifferEnabled(config.getEnv('expression.reportDifference')); ExpressionEvaluatorProxy.setDiffReporter((expr) => { - ErrorReporterProxy.warn('Expression difference', { + Container.get(ErrorReporter).warn('Expression difference', { extra: { expression: expr, }, diff --git a/packages/cli/src/license.ts b/packages/cli/src/license.ts index 8f1bd26e64b53..2a3ae6fd6dee6 100644 --- a/packages/cli/src/license.ts +++ b/packages/cli/src/license.ts @@ -9,7 +9,6 @@ import { SettingsRepository } from '@/databases/repositories/settings.repository import { OnShutdown } from '@/decorators/on-shutdown'; import { Logger } from '@/logging/logger.service'; import { LicenseMetricsService } from '@/metrics/license-metrics.service'; -import { OrchestrationService } from '@/services/orchestration.service'; import { LICENSE_FEATURES, @@ -35,7 +34,6 @@ export class License { constructor( private readonly logger: Logger, private readonly instanceSettings: InstanceSettings, - private readonly orchestrationService: OrchestrationService, private readonly settingsRepository: SettingsRepository, private readonly licenseMetricsService: LicenseMetricsService, private readonly globalConfig: GlobalConfig, @@ -138,23 +136,24 @@ export class License { this.logger.debug('License feature change detected', _features); if (config.getEnv('executions.mode') === 'queue' && this.globalConfig.multiMainSetup.enabled) { - const isMultiMainLicensed = _features[LICENSE_FEATURES.MULTIPLE_MAIN_INSTANCES] as - | boolean - | undefined; + const isMultiMainLicensed = + (_features[LICENSE_FEATURES.MULTIPLE_MAIN_INSTANCES] as boolean | undefined) ?? false; - this.orchestrationService.setMultiMainSetupLicensed(isMultiMainLicensed ?? false); + this.instanceSettings.setMultiMainLicensed(isMultiMainLicensed); - if (this.orchestrationService.isMultiMainSetupEnabled && this.instanceSettings.isFollower) { - this.logger.debug( - '[Multi-main setup] Instance is follower, skipping sending of "reload-license" command...', - ); + if (this.instanceSettings.isMultiMain && !this.instanceSettings.isLeader) { + this.logger + .scoped(['scaling', 'multi-main-setup', 'license']) + .debug('Instance is not leader, skipping sending of "reload-license" command...'); return; } - if (this.orchestrationService.isMultiMainSetupEnabled && !isMultiMainLicensed) { - this.logger.debug( - '[Multi-main setup] License changed with no support for multi-main setup - no new followers will be allowed to init. To restore multi-main setup, please upgrade to a license that supports this feature.', - ); + if (this.globalConfig.multiMainSetup.enabled && !isMultiMainLicensed) { + this.logger + .scoped(['scaling', 'multi-main-setup', 'license']) + .debug( + 'License changed with no support for multi-main setup - no new followers will be allowed to init. To restore multi-main setup, please upgrade to a license that supports this feature.', + ); } } diff --git a/packages/cli/src/load-nodes-and-credentials.ts b/packages/cli/src/load-nodes-and-credentials.ts index 22273fb89462d..db62e8415ea71 100644 --- a/packages/cli/src/load-nodes-and-credentials.ts +++ b/packages/cli/src/load-nodes-and-credentials.ts @@ -4,10 +4,13 @@ import fsPromises from 'fs/promises'; import type { Class, DirectoryLoader, Types } from 'n8n-core'; import { CUSTOM_EXTENSION_ENV, + ErrorReporter, InstanceSettings, CustomDirectoryLoader, PackageDirectoryLoader, LazyPackageDirectoryLoader, + UnrecognizedCredentialTypeError, + UnrecognizedNodeTypeError, } from 'n8n-core'; import type { KnownNodesAndCredentials, @@ -15,8 +18,13 @@ import type { INodeTypeDescription, INodeTypeData, ICredentialTypeData, + LoadedClass, + ICredentialType, + INodeType, + IVersionedNodeType, + INodeProperties, } from 'n8n-workflow'; -import { NodeHelpers, ApplicationError, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; +import { ApplicationError, NodeConnectionType } from 'n8n-workflow'; import path from 'path'; import picocolors from 'picocolors'; import { Container, Service } from 'typedi'; @@ -57,6 +65,7 @@ export class LoadNodesAndCredentials { constructor( private readonly logger: Logger, + private readonly errorReporter: ErrorReporter, private readonly instanceSettings: InstanceSettings, private readonly globalConfig: GlobalConfig, ) {} @@ -149,7 +158,7 @@ export class LoadNodesAndCredentials { ); } catch (error) { this.logger.error((error as Error).message); - ErrorReporter.error(error); + this.errorReporter.error(error); } } } @@ -285,7 +294,7 @@ export class LoadNodesAndCredentials { for (const usableNode of usableNodes) { const description: INodeTypeBaseDescription | INodeTypeDescription = structuredClone(usableNode); - const wrapped = NodeHelpers.convertNodeToAiTool({ description }).description; + const wrapped = this.convertNodeToAiTool({ description }).description; this.types.nodes.push(wrapped); this.known.nodes[wrapped.name] = structuredClone(this.known.nodes[usableNode.name]); @@ -307,13 +316,18 @@ export class LoadNodesAndCredentials { for (const loader of Object.values(this.loaders)) { // list of node & credential types that will be sent to the frontend - const { known, types, directory } = loader; - this.types.nodes = this.types.nodes.concat(types.nodes); + const { known, types, directory, packageName } = loader; + this.types.nodes = this.types.nodes.concat( + types.nodes.map(({ name, ...rest }) => ({ + ...rest, + name: `${packageName}.${name}`, + })), + ); this.types.credentials = this.types.credentials.concat(types.credentials); // Nodes and credentials that have been loaded immediately for (const nodeTypeName in loader.nodeTypes) { - this.loaded.nodes[nodeTypeName] = loader.nodeTypes[nodeTypeName]; + this.loaded.nodes[`${packageName}.${nodeTypeName}`] = loader.nodeTypes[nodeTypeName]; } for (const credentialTypeName in loader.credentialTypes) { @@ -322,7 +336,7 @@ export class LoadNodesAndCredentials { for (const type in known.nodes) { const { className, sourcePath } = known.nodes[type]; - this.known.nodes[type] = { + this.known.nodes[`${packageName}.${type}`] = { className, sourcePath: path.join(directory, sourcePath), }; @@ -356,6 +370,128 @@ export class LoadNodesAndCredentials { } } + getNode(fullNodeType: string): LoadedClass { + const [packageName, nodeType] = fullNodeType.split('.'); + const { loaders } = this; + const loader = loaders[packageName]; + if (!loader) { + throw new UnrecognizedNodeTypeError(packageName, nodeType); + } + return loader.getNode(nodeType); + } + + getCredential(credentialType: string): LoadedClass { + const { loadedCredentials } = this; + + for (const loader of Object.values(this.loaders)) { + if (credentialType in loader.known.credentials) { + const loaded = loader.getCredential(credentialType); + loadedCredentials[credentialType] = loaded; + } + } + + if (credentialType in loadedCredentials) { + return loadedCredentials[credentialType]; + } + + throw new UnrecognizedCredentialTypeError(credentialType); + } + + /** + * Modifies the description of the passed in object, such that it can be used + * as an AI Agent Tool. + * Returns the modified item (not copied) + */ + convertNodeToAiTool< + T extends object & { description: INodeTypeDescription | INodeTypeBaseDescription }, + >(item: T): T { + // quick helper function for type-guard down below + function isFullDescription(obj: unknown): obj is INodeTypeDescription { + return typeof obj === 'object' && obj !== null && 'properties' in obj; + } + + if (isFullDescription(item.description)) { + item.description.name += 'Tool'; + item.description.inputs = []; + item.description.outputs = [NodeConnectionType.AiTool]; + item.description.displayName += ' Tool'; + delete item.description.usableAsTool; + + const hasResource = item.description.properties.some((prop) => prop.name === 'resource'); + const hasOperation = item.description.properties.some((prop) => prop.name === 'operation'); + + if (!item.description.properties.map((prop) => prop.name).includes('toolDescription')) { + const descriptionType: INodeProperties = { + displayName: 'Tool Description', + name: 'descriptionType', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Set Automatically', + value: 'auto', + description: 'Automatically set based on resource and operation', + }, + { + name: 'Set Manually', + value: 'manual', + description: 'Manually set the description', + }, + ], + default: 'auto', + }; + + const descProp: INodeProperties = { + displayName: 'Description', + name: 'toolDescription', + type: 'string', + default: item.description.description, + required: true, + typeOptions: { rows: 2 }, + description: + 'Explain to the LLM what this tool does, a good, specific description would allow LLMs to produce expected results much more often', + placeholder: `e.g. ${item.description.description}`, + }; + + const noticeProp: INodeProperties = { + displayName: + "Use the expression {{ $fromAI('placeholder_name') }} for any data to be filled by the model", + name: 'notice', + type: 'notice', + default: '', + }; + + item.description.properties.unshift(descProp); + + // If node has resource or operation we can determine pre-populate tool description based on it + // so we add the descriptionType property as the first property + if (hasResource || hasOperation) { + item.description.properties.unshift(descriptionType); + + descProp.displayOptions = { + show: { + descriptionType: ['manual'], + }, + }; + } + + item.description.properties.unshift(noticeProp); + } + } + + const resources = item.description.codex?.resources ?? {}; + + item.description.codex = { + categories: ['AI'], + subcategories: { + AI: ['Tools'], + Tools: ['Other Tools'], + }, + resources, + }; + return item; + } + async setupHotReload() { const { default: debounce } = await import('lodash/debounce'); // eslint-disable-next-line import/no-extraneous-dependencies @@ -384,7 +520,7 @@ export class LoadNodesAndCredentials { loader.reset(); await loader.loadAll(); await this.postProcessLoaders(); - push.broadcast('nodeDescriptionUpdated', {}); + push.broadcast({ type: 'nodeDescriptionUpdated', data: {} }); }, 100); const toWatch = loader.isLazyLoaded diff --git a/packages/cli/src/logging/logger.service.ts b/packages/cli/src/logging/logger.service.ts index 46471c06112f5..46441e5a339ca 100644 --- a/packages/cli/src/logging/logger.service.ts +++ b/packages/cli/src/logging/logger.service.ts @@ -2,7 +2,7 @@ import type { LogScope } from '@n8n/config'; import { GlobalConfig } from '@n8n/config'; import callsites from 'callsites'; import type { TransformableInfo } from 'logform'; -import { InstanceSettings } from 'n8n-core'; +import { InstanceSettings, isObjectLiteral } from 'n8n-core'; import { LoggerProxy, LOG_LEVELS } from 'n8n-workflow'; import path, { basename } from 'node:path'; import pc from 'picocolors'; @@ -10,7 +10,6 @@ import { Service } from 'typedi'; import winston from 'winston'; import { inDevelopment, inProduction } from '@/constants'; -import { isObjectLiteral } from '@/utils'; import { noOp } from './constants'; import type { LogLocationMetadata, LogLevel, LogMetadata } from './types'; diff --git a/packages/cli/src/manual-execution.service.ts b/packages/cli/src/manual-execution.service.ts new file mode 100644 index 0000000000000..65174d20b50f8 --- /dev/null +++ b/packages/cli/src/manual-execution.service.ts @@ -0,0 +1,124 @@ +import * as a from 'assert/strict'; +import { + DirectedGraph, + filterDisabledNodes, + recreateNodeExecutionStack, + WorkflowExecute, +} from 'n8n-core'; +import type { + IPinData, + IRun, + IRunExecutionData, + IWorkflowExecuteAdditionalData, + IWorkflowExecutionDataProcess, + Workflow, +} from 'n8n-workflow'; +import type PCancelable from 'p-cancelable'; +import { Service } from 'typedi'; + +import { Logger } from '@/logging/logger.service'; + +@Service() +export class ManualExecutionService { + constructor(private readonly logger: Logger) {} + + getExecutionStartNode(data: IWorkflowExecutionDataProcess, workflow: Workflow) { + let startNode; + if ( + data.startNodes?.length === 1 && + Object.keys(data.pinData ?? {}).includes(data.startNodes[0].name) + ) { + startNode = workflow.getNode(data.startNodes[0].name) ?? undefined; + } + + return startNode; + } + + // eslint-disable-next-line @typescript-eslint/promise-function-async + runManually( + data: IWorkflowExecutionDataProcess, + workflow: Workflow, + additionalData: IWorkflowExecuteAdditionalData, + executionId: string, + pinData?: IPinData, + ): PCancelable { + if (data.triggerToStartFrom?.data && data.startNodes && !data.destinationNode) { + this.logger.debug( + `Execution ID ${executionId} had triggerToStartFrom. Starting from that trigger.`, + { executionId }, + ); + const startNodes = data.startNodes.map((startNode) => { + const node = workflow.getNode(startNode.name); + a.ok(node, `Could not find a node named "${startNode.name}" in the workflow.`); + return node; + }); + const runData = { [data.triggerToStartFrom.name]: [data.triggerToStartFrom.data] }; + + const { nodeExecutionStack, waitingExecution, waitingExecutionSource } = + recreateNodeExecutionStack( + filterDisabledNodes(DirectedGraph.fromWorkflow(workflow)), + new Set(startNodes), + runData, + data.pinData ?? {}, + ); + const executionData: IRunExecutionData = { + resultData: { runData, pinData }, + executionData: { + contextData: {}, + metadata: {}, + nodeExecutionStack, + waitingExecution, + waitingExecutionSource, + }, + }; + + const workflowExecute = new WorkflowExecute(additionalData, 'manual', executionData); + return workflowExecute.processRunExecutionData(workflow); + } else if ( + data.runData === undefined || + data.startNodes === undefined || + data.startNodes.length === 0 + ) { + // Full Execution + // TODO: When the old partial execution logic is removed this block can + // be removed and the previous one can be merged into + // `workflowExecute.runPartialWorkflow2`. + // Partial executions then require either a destination node from which + // everything else can be derived, or a triggerToStartFrom with + // triggerData. + this.logger.debug(`Execution ID ${executionId} will run executing all nodes.`, { + executionId, + }); + // Execute all nodes + + const startNode = this.getExecutionStartNode(data, workflow); + + // Can execute without webhook so go on + const workflowExecute = new WorkflowExecute(additionalData, data.executionMode); + return workflowExecute.run(workflow, startNode, data.destinationNode, data.pinData); + } else { + // Partial Execution + this.logger.debug(`Execution ID ${executionId} is a partial execution.`, { executionId }); + // Execute only the nodes between start and destination nodes + const workflowExecute = new WorkflowExecute(additionalData, data.executionMode); + + if (data.partialExecutionVersion === '1') { + return workflowExecute.runPartialWorkflow2( + workflow, + data.runData, + data.pinData, + data.dirtyNodeNames, + data.destinationNode, + ); + } else { + return workflowExecute.runPartialWorkflow( + workflow, + data.runData, + data.startNodes, + data.destinationNode, + data.pinData, + ); + } + } + } +} diff --git a/packages/cli/src/middlewares/list-query/dtos/base.filter.dto.ts b/packages/cli/src/middlewares/list-query/dtos/base.filter.dto.ts index 678cb86981ccb..9fa5c216774b3 100644 --- a/packages/cli/src/middlewares/list-query/dtos/base.filter.dto.ts +++ b/packages/cli/src/middlewares/list-query/dtos/base.filter.dto.ts @@ -1,9 +1,8 @@ import { plainToInstance, instanceToPlain } from 'class-transformer'; import { validate } from 'class-validator'; +import { isObjectLiteral } from 'n8n-core'; import { ApplicationError, jsonParse } from 'n8n-workflow'; -import { isObjectLiteral } from '@/utils'; - export class BaseFilter { protected static async toFilter(rawFilter: string, Filter: typeof BaseFilter) { const dto = jsonParse(rawFilter, { errorMessage: 'Failed to parse filter JSON' }); diff --git a/packages/cli/src/node-types.ts b/packages/cli/src/node-types.ts index 553aedd620e2e..1837cd7bcc4cb 100644 --- a/packages/cli/src/node-types.ts +++ b/packages/cli/src/node-types.ts @@ -1,26 +1,16 @@ import type { NeededNodeType } from '@n8n/task-runner'; import type { Dirent } from 'fs'; import { readdir } from 'fs/promises'; -import { loadClassInIsolation } from 'n8n-core'; -import type { - INodeType, - INodeTypeDescription, - INodeTypes, - IVersionedNodeType, - LoadedClass, -} from 'n8n-workflow'; +import type { INodeType, INodeTypeDescription, INodeTypes, IVersionedNodeType } from 'n8n-workflow'; import { ApplicationError, NodeHelpers } from 'n8n-workflow'; import { join, dirname } from 'path'; import { Service } from 'typedi'; -import { UnrecognizedNodeTypeError } from './errors/unrecognized-node-type.error'; import { LoadNodesAndCredentials } from './load-nodes-and-credentials'; @Service() export class NodeTypes implements INodeTypes { - constructor(private loadNodesAndCredentials: LoadNodesAndCredentials) { - loadNodesAndCredentials.addPostProcessor(async () => this.applySpecialNodeParameters()); - } + constructor(private loadNodesAndCredentials: LoadNodesAndCredentials) {} /** * Variant of `getByNameAndVersion` that includes the node's source path, used to locate a node's translations. @@ -29,19 +19,14 @@ export class NodeTypes implements INodeTypes { nodeTypeName: string, version: number, ): { description: INodeTypeDescription } & { sourcePath: string } { - const nodeType = this.getNode(nodeTypeName); - - if (!nodeType) { - throw new ApplicationError('Unknown node type', { tags: { nodeTypeName } }); - } - + const nodeType = this.loadNodesAndCredentials.getNode(nodeTypeName); const { description } = NodeHelpers.getVersionedNodeType(nodeType.type, version); return { description: { ...description }, sourcePath: nodeType.sourcePath }; } getByName(nodeType: string): INodeType | IVersionedNodeType { - return this.getNode(nodeType).type; + return this.loadNodesAndCredentials.getNode(nodeType).type; } getByNameAndVersion(nodeType: string, version?: number): INodeType { @@ -52,7 +37,7 @@ export class NodeTypes implements INodeTypes { nodeType = nodeType.replace(/Tool$/, ''); } - const node = this.getNode(nodeType); + const node = this.loadNodesAndCredentials.getNode(nodeType); const versionedNodeType = NodeHelpers.getVersionedNodeType(node.type, version); if (!toolRequested) return versionedNodeType; @@ -74,41 +59,15 @@ export class NodeTypes implements INodeTypes { const clonedNode = Object.create(versionedNodeType, { description: { value: clonedDescription }, }) as INodeType; - const tool = NodeHelpers.convertNodeToAiTool(clonedNode); + const tool = this.loadNodesAndCredentials.convertNodeToAiTool(clonedNode); loadedNodes[nodeType + 'Tool'] = { sourcePath: '', type: tool }; return tool; } - /* Some nodeTypes need to get special parameters applied like the polling nodes the polling times */ - applySpecialNodeParameters() { - for (const nodeTypeData of Object.values(this.loadNodesAndCredentials.loadedNodes)) { - const nodeType = NodeHelpers.getVersionedNodeType(nodeTypeData.type); - NodeHelpers.applySpecialNodeParameters(nodeType); - } - } - getKnownTypes() { return this.loadNodesAndCredentials.knownNodes; } - private getNode(type: string): LoadedClass { - const { loadedNodes, knownNodes } = this.loadNodesAndCredentials; - if (type in loadedNodes) { - return loadedNodes[type]; - } - - if (type in knownNodes) { - const { className, sourcePath } = knownNodes[type]; - const loaded: INodeType = loadClassInIsolation(sourcePath, className); - NodeHelpers.applySpecialNodeParameters(loaded); - - loadedNodes[type] = { sourcePath, type: loaded }; - return loadedNodes[type]; - } - - throw new UnrecognizedNodeTypeError(type); - } - async getNodeTranslationPath({ nodeSourcePath, longNodeType, @@ -153,14 +112,12 @@ export class NodeTypes implements INodeTypes { getNodeTypeDescriptions(nodeTypes: NeededNodeType[]): INodeTypeDescription[] { return nodeTypes.map(({ name: nodeTypeName, version: nodeTypeVersion }) => { - const nodeType = this.getNode(nodeTypeName); - - if (!nodeType) throw new ApplicationError(`Unknown node type: ${nodeTypeName}`); - + const nodeType = this.loadNodesAndCredentials.getNode(nodeTypeName); const { description } = NodeHelpers.getVersionedNodeType(nodeType.type, nodeTypeVersion); const descriptionCopy = { ...description }; + // TODO: do we still need this? descriptionCopy.name = descriptionCopy.name.startsWith('n8n-nodes') ? descriptionCopy.name : `n8n-nodes-base.${descriptionCopy.name}`; // nodes-base nodes are unprefixed diff --git a/packages/cli/src/public-api/types.ts b/packages/cli/src/public-api/types.ts index 327d3630739b1..b10d2f81bda8b 100644 --- a/packages/cli/src/public-api/types.ts +++ b/packages/cli/src/public-api/types.ts @@ -74,11 +74,12 @@ export declare namespace WorkflowRequest { active: boolean; name?: string; projectId?: string; + excludePinnedData?: boolean; } >; type Create = AuthenticatedRequest<{}, {}, WorkflowEntity, {}>; - type Get = AuthenticatedRequest<{ id: string }, {}, {}, {}>; + type Get = AuthenticatedRequest<{ id: string }, {}, {}, { excludePinnedData?: boolean }>; type Delete = Get; type Update = AuthenticatedRequest<{ id: string }, {}, WorkflowEntity, {}>; type Activate = Get; diff --git a/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/workflows.id.yml b/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/workflows.id.yml index 37cad74c86e4d..c8b2bf51cdc29 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/workflows.id.yml +++ b/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/workflows.id.yml @@ -6,6 +6,13 @@ get: summary: Retrieves a workflow description: Retrieves a workflow. parameters: + - name: excludePinnedData + in: query + required: false + description: Set this to avoid retrieving pinned data + schema: + type: boolean + example: true - $ref: '../schemas/parameters/workflowId.yml' responses: '200': diff --git a/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/workflows.yml b/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/workflows.yml index 1024e36cb5948..4b3bc5e069efd 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/workflows.yml +++ b/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/workflows.yml @@ -60,6 +60,13 @@ get: schema: type: string example: VmwOO9HeTEj20kxM + - name: excludePinnedData + in: query + required: false + description: Set this to avoid retrieving pinned data + schema: + type: boolean + example: true - $ref: '../../../../shared/spec/parameters/limit.yml' - $ref: '../../../../shared/spec/parameters/cursor.yml' responses: diff --git a/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts b/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts index b0956a15c1bd4..7a9003dc284c6 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts @@ -105,6 +105,7 @@ export = { projectScope('workflow:read', 'workflow'), async (req: WorkflowRequest.Get, res: express.Response): Promise => { const { id } = req.params; + const { excludePinnedData = false } = req.query; const workflow = await Container.get(SharedWorkflowRepository).findWorkflowForUser( id, @@ -120,6 +121,10 @@ export = { return res.status(404).json({ message: 'Not Found' }); } + if (excludePinnedData) { + delete workflow.pinData; + } + Container.get(EventService).emit('user-retrieved-workflow', { userId: req.user.id, publicApi: true, @@ -131,7 +136,15 @@ export = { getWorkflows: [ validCursor, async (req: WorkflowRequest.GetAll, res: express.Response): Promise => { - const { offset = 0, limit = 100, active, tags, name, projectId } = req.query; + const { + offset = 0, + limit = 100, + excludePinnedData = false, + active, + tags, + name, + projectId, + } = req.query; const where: FindOptionsWhere = { ...(active !== undefined && { active }), @@ -199,6 +212,12 @@ export = { ...(!config.getEnv('workflowTagsDisabled') && { relations: ['tags'] }), }); + if (excludePinnedData) { + workflows.forEach((workflow) => { + delete workflow.pinData; + }); + } + Container.get(EventService).emit('user-retrieved-all-workflows', { userId: req.user.id, publicApi: true, diff --git a/packages/cli/src/push/__tests__/websocket.push.test.ts b/packages/cli/src/push/__tests__/websocket.push.test.ts index 209f91b17e4ed..fd1e2f27a0b52 100644 --- a/packages/cli/src/push/__tests__/websocket.push.test.ts +++ b/packages/cli/src/push/__tests__/websocket.push.test.ts @@ -11,15 +11,15 @@ import { mockInstance } from '@test/mocking'; jest.useFakeTimers(); class MockWebSocket extends EventEmitter { - public isAlive = true; + isAlive = true; - public ping = jest.fn(); + ping = jest.fn(); - public send = jest.fn(); + send = jest.fn(); - public terminate = jest.fn(); + terminate = jest.fn(); - public close = jest.fn(); + close = jest.fn(); } const createMockWebSocket = () => new MockWebSocket() as unknown as jest.Mocked; @@ -73,7 +73,7 @@ describe('WebSocketPush', () => { it('sends data to one connection', () => { webSocketPush.add(pushRef1, userId, mockWebSocket1); webSocketPush.add(pushRef2, userId, mockWebSocket2); - webSocketPush.sendToOne(pushMessage.type, pushMessage.data, pushRef1); + webSocketPush.sendToOne(pushMessage, pushRef1); expect(mockWebSocket1.send).toHaveBeenCalledWith(expectedMsg); expect(mockWebSocket2.send).not.toHaveBeenCalled(); @@ -82,7 +82,7 @@ describe('WebSocketPush', () => { it('sends data to all connections', () => { webSocketPush.add(pushRef1, userId, mockWebSocket1); webSocketPush.add(pushRef2, userId, mockWebSocket2); - webSocketPush.sendToAll(pushMessage.type, pushMessage.data); + webSocketPush.sendToAll(pushMessage); expect(mockWebSocket1.send).toHaveBeenCalledWith(expectedMsg); expect(mockWebSocket2.send).toHaveBeenCalledWith(expectedMsg); @@ -101,7 +101,7 @@ describe('WebSocketPush', () => { it('sends data to all users connections', () => { webSocketPush.add(pushRef1, userId, mockWebSocket1); webSocketPush.add(pushRef2, userId, mockWebSocket2); - webSocketPush.sendToUsers(pushMessage.type, pushMessage.data, [userId]); + webSocketPush.sendToUsers(pushMessage, [userId]); expect(mockWebSocket1.send).toHaveBeenCalledWith(expectedMsg); expect(mockWebSocket2.send).toHaveBeenCalledWith(expectedMsg); diff --git a/packages/cli/src/push/abstract.push.ts b/packages/cli/src/push/abstract.push.ts index 24cafa81219d3..574f8a0def056 100644 --- a/packages/cli/src/push/abstract.push.ts +++ b/packages/cli/src/push/abstract.push.ts @@ -1,4 +1,5 @@ -import type { PushPayload, PushType } from '@n8n/api-types'; +import type { PushMessage } from '@n8n/api-types'; +import { ErrorReporter } from 'n8n-core'; import { assert, jsonStringify } from 'n8n-workflow'; import { Service } from 'typedi'; @@ -27,7 +28,10 @@ export abstract class AbstractPush extends TypedEmitter this.pingAll(), 60 * 1000); @@ -65,8 +69,8 @@ export abstract class AbstractPush extends TypedEmitter(type: Type, data: PushPayload, pushRefs: string[]) { - this.logger.debug(`Send data of type "${type}" to editor-UI`, { + private sendTo({ type, data }: PushMessage, pushRefs: string[]) { + this.logger.debug(`Pushed to frontend: ${type}`, { dataType: type, pushRefs: pushRefs.join(', '), }); @@ -86,30 +90,26 @@ export abstract class AbstractPush extends TypedEmitter(type: Type, data: PushPayload) { - this.sendTo(type, data, Object.keys(this.connections)); + sendToAll(pushMsg: PushMessage) { + this.sendTo(pushMsg, Object.keys(this.connections)); } - sendToOne(type: Type, data: PushPayload, pushRef: string) { + sendToOne(pushMsg: PushMessage, pushRef: string) { if (this.connections[pushRef] === undefined) { this.logger.error(`The session "${pushRef}" is not registered.`, { pushRef }); return; } - this.sendTo(type, data, [pushRef]); + this.sendTo(pushMsg, [pushRef]); } - sendToUsers( - type: Type, - data: PushPayload, - userIds: Array, - ) { + sendToUsers(pushMsg: PushMessage, userIds: Array) { const { connections } = this; const userPushRefs = Object.keys(connections).filter((pushRef) => userIds.includes(this.userIdByPushRef[pushRef]), ); - this.sendTo(type, data, userPushRefs); + this.sendTo(pushMsg, userPushRefs); } closeAllConnections() { diff --git a/packages/cli/src/push/index.ts b/packages/cli/src/push/index.ts index bfbfb43a51d22..7325981d0bba5 100644 --- a/packages/cli/src/push/index.ts +++ b/packages/cli/src/push/index.ts @@ -1,7 +1,8 @@ -import type { PushPayload, PushType } from '@n8n/api-types'; +import type { PushMessage } from '@n8n/api-types'; import type { Application } from 'express'; import { ServerResponse } from 'http'; import type { Server } from 'http'; +import { InstanceSettings } from 'n8n-core'; import type { Socket } from 'net'; import { Container, Service } from 'typedi'; import { parse as parseUrl } from 'url'; @@ -13,7 +14,6 @@ import type { User } from '@/databases/entities/user'; import { OnShutdown } from '@/decorators/on-shutdown'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { Publisher } from '@/scaling/pubsub/publisher.service'; -import { OrchestrationService } from '@/services/orchestration.service'; import { TypedEmitter } from '@/typed-emitter'; import { SSEPush } from './sse.push'; @@ -36,12 +36,12 @@ const useWebSockets = config.getEnv('push.backend') === 'websocket'; */ @Service() export class Push extends TypedEmitter { - public isBidirectional = useWebSockets; + isBidirectional = useWebSockets; private backend = useWebSockets ? Container.get(WebSocketPush) : Container.get(SSEPush); constructor( - private readonly orchestrationService: OrchestrationService, + private readonly instanceSettings: InstanceSettings, private readonly publisher: Publisher, ) { super(); @@ -81,34 +81,30 @@ export class Push extends TypedEmitter { this.emit('editorUiConnected', pushRef); } - broadcast(type: Type, data: PushPayload) { - this.backend.sendToAll(type, data); + broadcast(pushMsg: PushMessage) { + this.backend.sendToAll(pushMsg); } - send(type: Type, data: PushPayload, pushRef: string) { + send(pushMsg: PushMessage, pushRef: string) { /** * Multi-main setup: In a manual webhook execution, the main process that * handles a webhook might not be the same as the main process that created * the webhook. If so, the handler process commands the creator process to * relay the former's execution lifecycle events to the creator's frontend. */ - if (this.orchestrationService.isMultiMainSetupEnabled && !this.backend.hasPushRef(pushRef)) { + if (this.instanceSettings.isMultiMain && !this.backend.hasPushRef(pushRef)) { void this.publisher.publishCommand({ command: 'relay-execution-lifecycle-event', - payload: { type, args: data, pushRef }, + payload: { ...pushMsg, pushRef }, }); return; } - this.backend.sendToOne(type, data, pushRef); + this.backend.sendToOne(pushMsg, pushRef); } - sendToUsers( - type: Type, - data: PushPayload, - userIds: Array, - ) { - this.backend.sendToUsers(type, data, userIds); + sendToUsers(pushMsg: PushMessage, userIds: Array) { + this.backend.sendToUsers(pushMsg, userIds); } @OnShutdown() diff --git a/packages/cli/src/push/websocket.push.ts b/packages/cli/src/push/websocket.push.ts index a2ea39c5004b3..97e45028b2bb7 100644 --- a/packages/cli/src/push/websocket.push.ts +++ b/packages/cli/src/push/websocket.push.ts @@ -1,4 +1,4 @@ -import { ApplicationError, ErrorReporterProxy } from 'n8n-workflow'; +import { ApplicationError } from 'n8n-workflow'; import { Service } from 'typedi'; import type WebSocket from 'ws'; @@ -24,7 +24,7 @@ export class WebSocketPush extends AbstractPush { this.onMessageReceived(pushRef, JSON.parse(buffer.toString('utf8'))); } catch (error) { - ErrorReporterProxy.error( + this.errorReporter.error( new ApplicationError('Error parsing push message', { extra: { userId, diff --git a/packages/cli/src/response-helper.ts b/packages/cli/src/response-helper.ts index 2b993c266c409..0e70aa312f72c 100644 --- a/packages/cli/src/response-helper.ts +++ b/packages/cli/src/response-helper.ts @@ -1,10 +1,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import type { Request, Response } from 'express'; -import { - ErrorReporterProxy as ErrorReporter, - FORM_TRIGGER_PATH_IDENTIFIER, - NodeApiError, -} from 'n8n-workflow'; +import { ErrorReporter } from 'n8n-core'; +import { FORM_TRIGGER_PATH_IDENTIFIER, NodeApiError } from 'n8n-workflow'; import { Readable } from 'node:stream'; import picocolors from 'picocolors'; import Container from 'typedi'; @@ -141,7 +138,7 @@ export const isUniqueConstraintError = (error: Error) => export function reportError(error: Error) { if (!(error instanceof ResponseError) || error.httpStatusCode > 404) { - ErrorReporter.error(error); + Container.get(ErrorReporter).error(error); } } diff --git a/packages/cli/src/runners/__tests__/task-broker.test.ts b/packages/cli/src/runners/__tests__/task-broker.test.ts index 8e86f189e8fea..1f5030ada8e93 100644 --- a/packages/cli/src/runners/__tests__/task-broker.test.ts +++ b/packages/cli/src/runners/__tests__/task-broker.test.ts @@ -6,6 +6,7 @@ import { ApplicationError, type INodeTypeBaseDescription } from 'n8n-workflow'; import { Time } from '@/constants'; import { TaskRejectError } from '../errors'; +import { TaskRunnerTimeoutError } from '../errors/task-runner-timeout.error'; import type { RunnerLifecycleEvents } from '../runner-lifecycle-events'; import { TaskBroker } from '../task-broker.service'; import type { TaskOffer, TaskRequest, TaskRunner } from '../task-broker.service'; @@ -721,7 +722,7 @@ describe('TaskBroker', () => { beforeAll(() => { jest.useFakeTimers(); - config = mock({ taskTimeout: 30 }); + config = mock({ taskTimeout: 30, mode: 'internal' }); taskBroker = new TaskBroker(mock(), config, runnerLifecycleEvents); }); @@ -800,7 +801,7 @@ describe('TaskBroker', () => { expect(taskBroker.getTasks().get(taskId)).toBeUndefined(); }); - it('on timeout, we should emit `runner:timed-out-during-task` event and send error to requester', async () => { + it('[internal mode] on timeout, we should emit `runner:timed-out-during-task` event and send error to requester', async () => { jest.spyOn(global, 'clearTimeout'); const taskId = 'task1'; @@ -839,5 +840,50 @@ describe('TaskBroker', () => { expect(taskBroker.getTasks().get(taskId)).toBeUndefined(); }); + + it('[external mode] on timeout, we should instruct the runner to cancel and send error to requester', async () => { + const config = mock({ taskTimeout: 30, mode: 'external' }); + taskBroker = new TaskBroker(mock(), config, runnerLifecycleEvents); + + jest.spyOn(global, 'clearTimeout'); + + const taskId = 'task1'; + const runnerId = 'runner1'; + const requesterId = 'requester1'; + const runner = mock({ id: runnerId }); + const runnerCallback = jest.fn(); + const requesterCallback = jest.fn(); + + taskBroker.registerRunner(runner, runnerCallback); + taskBroker.registerRequester(requesterId, requesterCallback); + + taskBroker.setTasks({ + [taskId]: { id: taskId, runnerId, requesterId, taskType: 'test' }, + }); + + await taskBroker.sendTaskSettings(taskId, {}); + runnerCallback.mockClear(); + + jest.runAllTimers(); + + await Promise.resolve(); // for timeout callback + await Promise.resolve(); // for sending messages to runner and requester + await Promise.resolve(); // for task cleanup and removal + + expect(runnerCallback).toHaveBeenLastCalledWith({ + type: 'broker:taskcancel', + taskId, + reason: 'Task execution timed out', + }); + + expect(requesterCallback).toHaveBeenCalledWith({ + type: 'broker:taskerror', + taskId, + error: expect.any(TaskRunnerTimeoutError), + }); + + expect(clearTimeout).toHaveBeenCalled(); + expect(taskBroker.getTasks().get(taskId)).toBeUndefined(); + }); }); }); diff --git a/packages/cli/src/runners/__tests__/task-runner-ws-server.test.ts b/packages/cli/src/runners/__tests__/task-runner-ws-server.test.ts index b092e08fedccc..24b12fa1906c1 100644 --- a/packages/cli/src/runners/__tests__/task-runner-ws-server.test.ts +++ b/packages/cli/src/runners/__tests__/task-runner-ws-server.test.ts @@ -58,4 +58,28 @@ describe('TaskRunnerWsServer', () => { expect(clearIntervalSpy).toHaveBeenCalled(); }); }); + + describe('sendMessage', () => { + it('should work with a message containing circular references', () => { + const server = new TaskRunnerWsServer(mock(), mock(), mock(), mock(), mock()); + const ws = mock(); + server.runnerConnections.set('test-runner', ws); + + const messageData: Record = {}; + messageData.circular = messageData; + + expect(() => + server.sendMessage('test-runner', { + type: 'broker:taskdataresponse', + taskId: 'taskId', + requestId: 'requestId', + data: messageData, + }), + ).not.toThrow(); + + expect(ws.send).toHaveBeenCalledWith( + '{"type":"broker:taskdataresponse","taskId":"taskId","requestId":"requestId","data":{"circular":"[Circular Reference]"}}', + ); + }); + }); }); diff --git a/packages/cli/src/runners/errors/task-runner-disconnected-error.ts b/packages/cli/src/runners/errors/task-runner-disconnected-error.ts index 3f4f468b1a658..e29958adfba79 100644 --- a/packages/cli/src/runners/errors/task-runner-disconnected-error.ts +++ b/packages/cli/src/runners/errors/task-runner-disconnected-error.ts @@ -2,10 +2,10 @@ import type { TaskRunner } from '@n8n/task-runner'; import { ApplicationError } from 'n8n-workflow'; export class TaskRunnerDisconnectedError extends ApplicationError { - public description: string; + description: string; constructor( - public readonly runnerId: TaskRunner['id'], + readonly runnerId: TaskRunner['id'], isCloudDeployment: boolean, ) { super('Node execution failed'); diff --git a/packages/cli/src/runners/errors/task-runner-oom-error.ts b/packages/cli/src/runners/errors/task-runner-oom-error.ts index f846d98768f8b..5c78bef816dbc 100644 --- a/packages/cli/src/runners/errors/task-runner-oom-error.ts +++ b/packages/cli/src/runners/errors/task-runner-oom-error.ts @@ -3,10 +3,10 @@ import { ApplicationError } from 'n8n-workflow'; import type { TaskRunner } from '../task-broker.service'; export class TaskRunnerOomError extends ApplicationError { - public description: string; + description: string; constructor( - public readonly runnerId: TaskRunner['id'], + readonly runnerId: TaskRunner['id'], isCloudDeployment: boolean, ) { super('Node ran out of memory.', { level: 'error' }); diff --git a/packages/cli/src/runners/errors/task-runner-restart-loop-error.ts b/packages/cli/src/runners/errors/task-runner-restart-loop-error.ts index b788d83808c80..fa02430adeb0b 100644 --- a/packages/cli/src/runners/errors/task-runner-restart-loop-error.ts +++ b/packages/cli/src/runners/errors/task-runner-restart-loop-error.ts @@ -2,8 +2,8 @@ import { ApplicationError } from 'n8n-workflow'; export class TaskRunnerRestartLoopError extends ApplicationError { constructor( - public readonly howManyTimes: number, - public readonly timePeriodMs: number, + readonly howManyTimes: number, + readonly timePeriodMs: number, ) { const message = `Task runner has restarted ${howManyTimes} times within ${timePeriodMs / 1000} seconds. This is an abnormally high restart rate that suggests a bug or other issue is preventing your runner process from starting up. If this issues persists, please file a report at: https://github.com/n8n-io/n8n/issues`; diff --git a/packages/cli/src/runners/errors/task-runner-timeout.error.ts b/packages/cli/src/runners/errors/task-runner-timeout.error.ts index 88f3533028725..1d9d463e3a5c1 100644 --- a/packages/cli/src/runners/errors/task-runner-timeout.error.ts +++ b/packages/cli/src/runners/errors/task-runner-timeout.error.ts @@ -1,15 +1,23 @@ +import type { TaskRunnerMode } from '@n8n/config/src/configs/runners.config'; import { ApplicationError } from 'n8n-workflow'; export class TaskRunnerTimeoutError extends ApplicationError { description: string; - constructor(taskTimeout: number, isSelfHosted: boolean) { + constructor({ + taskTimeout, + isSelfHosted, + mode, + }: { taskTimeout: number; isSelfHosted: boolean; mode: TaskRunnerMode }) { super( `Task execution timed out after ${taskTimeout} ${taskTimeout === 1 ? 'second' : 'seconds'}`, ); - const subtitle = - 'The task runner was taking too long on this task, so it was suspected of being unresponsive and restarted, and the task was aborted. You can try the following:'; + const subtitles = { + internal: + 'The task runner was taking too long on this task, so it was suspected of being unresponsive and restarted, and the task was aborted.', + external: 'The task runner was taking too long on this task, so the task was aborted.', + }; const fixes = { optimizeScript: @@ -27,7 +35,7 @@ export class TaskRunnerTimeoutError extends ApplicationError { .map((suggestion, index) => `${index + 1}. ${suggestion}`) .join('
'); - const description = `${subtitle}

${suggestionsText}`; + const description = `${mode === 'internal' ? subtitles.internal : subtitles.external} You can try the following:

${suggestionsText}`; this.description = description; } diff --git a/packages/cli/src/runners/node-process-oom-detector.ts b/packages/cli/src/runners/node-process-oom-detector.ts index e6debb8551c39..a97df32974146 100644 --- a/packages/cli/src/runners/node-process-oom-detector.ts +++ b/packages/cli/src/runners/node-process-oom-detector.ts @@ -6,7 +6,7 @@ import type { ChildProcess } from 'node:child_process'; * memory (OOMs). */ export class NodeProcessOomDetector { - public get didProcessOom() { + get didProcessOom() { return this._didProcessOom; } diff --git a/packages/cli/src/runners/runner-ws-server.ts b/packages/cli/src/runners/runner-ws-server.ts index 3a5fa53029697..8ea3a7edbe547 100644 --- a/packages/cli/src/runners/runner-ws-server.ts +++ b/packages/cli/src/runners/runner-ws-server.ts @@ -1,6 +1,6 @@ import { TaskRunnersConfig } from '@n8n/config'; import type { BrokerMessage, RunnerMessage } from '@n8n/task-runner'; -import { ApplicationError } from 'n8n-workflow'; +import { ApplicationError, jsonStringify } from 'n8n-workflow'; import { Service } from 'typedi'; import type WebSocket from 'ws'; @@ -83,7 +83,7 @@ export class TaskRunnerWsServer { } sendMessage(id: TaskRunner['id'], message: BrokerMessage.ToRunner.All) { - this.runnerConnections.get(id)?.send(JSON.stringify(message)); + this.runnerConnections.get(id)?.send(jsonStringify(message, { replaceCircularRefs: true })); } add(id: TaskRunner['id'], connection: WebSocket) { diff --git a/packages/cli/src/runners/sliding-window-signal.ts b/packages/cli/src/runners/sliding-window-signal.ts index 5954f7bade2b1..3e88f0df95752 100644 --- a/packages/cli/src/runners/sliding-window-signal.ts +++ b/packages/cli/src/runners/sliding-window-signal.ts @@ -36,7 +36,7 @@ export class SlidingWindowSignal { + async getSignal(): Promise { const timeSinceLastEvent = Date.now() - this.lastSignalTime; if (timeSinceLastEvent <= this.windowSizeInMs) return this.lastSignal; diff --git a/packages/cli/src/runners/task-broker.service.ts b/packages/cli/src/runners/task-broker.service.ts index 80e918b47a718..e52992d38eed8 100644 --- a/packages/cli/src/runners/task-broker.service.ts +++ b/packages/cli/src/runners/task-broker.service.ts @@ -459,14 +459,25 @@ export class TaskBroker { const task = this.tasks.get(taskId); if (!task) return; - this.runnerLifecycleEvents.emit('runner:timed-out-during-task'); + if (this.taskRunnersConfig.mode === 'internal') { + this.runnerLifecycleEvents.emit('runner:timed-out-during-task'); + } else if (this.taskRunnersConfig.mode === 'external') { + await this.messageRunner(task.runnerId, { + type: 'broker:taskcancel', + taskId, + reason: 'Task execution timed out', + }); + } + + const { taskTimeout, mode } = this.taskRunnersConfig; await this.taskErrorHandler( taskId, - new TaskRunnerTimeoutError( - this.taskRunnersConfig.taskTimeout, - config.getEnv('deployment.type') !== 'cloud', - ), + new TaskRunnerTimeoutError({ + taskTimeout, + isSelfHosted: config.getEnv('deployment.type') !== 'cloud', + mode, + }), ); } diff --git a/packages/cli/src/runners/task-managers/__tests__/task-manager.test.ts b/packages/cli/src/runners/task-managers/__tests__/task-manager.test.ts new file mode 100644 index 0000000000000..84584e05df27b --- /dev/null +++ b/packages/cli/src/runners/task-managers/__tests__/task-manager.test.ts @@ -0,0 +1,136 @@ +import { mock } from 'jest-mock-extended'; +import { get, set } from 'lodash'; + +import type { NodeTypes } from '@/node-types'; +import type { Task } from '@/runners/task-managers/task-manager'; +import { TaskManager } from '@/runners/task-managers/task-manager'; + +class TestTaskManager extends TaskManager { + sentMessages: unknown[] = []; + + sendMessage(message: unknown) { + this.sentMessages.push(message); + } +} + +describe('TaskManager', () => { + let instance: TestTaskManager; + const mockNodeTypes = mock(); + + beforeEach(() => { + instance = new TestTaskManager(mockNodeTypes); + }); + + describe('handleRpc', () => { + test.each([ + ['logNodeOutput', ['hello world']], + ['helpers.assertBinaryData', [0, 'propertyName']], + ['helpers.getBinaryDataBuffer', [0, 'propertyName']], + ['helpers.prepareBinaryData', [Buffer.from('data').toJSON(), 'filename', 'mimetype']], + ['helpers.setBinaryDataBuffer', [{ data: '123' }, Buffer.from('data').toJSON()]], + ['helpers.binaryToString', [Buffer.from('data').toJSON(), 'utf8']], + ['helpers.httpRequest', [{ url: 'http://localhost' }]], + ])('should handle %s rpc call', async (methodName, args) => { + const executeFunctions = set({}, methodName.split('.'), jest.fn()); + + const mockTask = mock({ + taskId: 'taskId', + data: { + executeFunctions, + }, + }); + instance.tasks.set('taskId', mockTask); + + await instance.handleRpc('taskId', 'callId', methodName, args); + + expect(instance.sentMessages).toEqual([ + { + callId: 'callId', + data: undefined, + status: 'success', + taskId: 'taskId', + type: 'requester:rpcresponse', + }, + ]); + expect(get(executeFunctions, methodName.split('.'))).toHaveBeenCalledWith(...args); + }); + + it('converts any serialized buffer arguments into buffers', async () => { + const mockPrepareBinaryData = jest.fn().mockResolvedValue(undefined); + const mockTask = mock({ + taskId: 'taskId', + data: { + executeFunctions: { + helpers: { + prepareBinaryData: mockPrepareBinaryData, + }, + }, + }, + }); + instance.tasks.set('taskId', mockTask); + + await instance.handleRpc('taskId', 'callId', 'helpers.prepareBinaryData', [ + Buffer.from('data').toJSON(), + 'filename', + 'mimetype', + ]); + + expect(mockPrepareBinaryData).toHaveBeenCalledWith( + Buffer.from('data'), + 'filename', + 'mimetype', + ); + }); + + describe('errors', () => { + it('sends method not allowed error if method is not in the allow list', async () => { + const mockTask = mock({ + taskId: 'taskId', + data: { + executeFunctions: {}, + }, + }); + instance.tasks.set('taskId', mockTask); + + await instance.handleRpc('taskId', 'callId', 'notAllowedMethod', []); + + expect(instance.sentMessages).toEqual([ + { + callId: 'callId', + data: 'Method not allowed', + status: 'error', + taskId: 'taskId', + type: 'requester:rpcresponse', + }, + ]); + }); + + it('sends error if method throws', async () => { + const error = new Error('Test error'); + const mockTask = mock({ + taskId: 'taskId', + data: { + executeFunctions: { + helpers: { + assertBinaryData: jest.fn().mockRejectedValue(error), + }, + }, + }, + }); + instance.tasks.set('taskId', mockTask); + + await instance.handleRpc('taskId', 'callId', 'helpers.assertBinaryData', []); + + expect(instance.sentMessages).toEqual([ + { + callId: 'callId', + data: error, + status: 'error', + taskId: 'taskId', + type: 'requester:rpcresponse', + }, + ]); + }); + }); + }); +}); diff --git a/packages/cli/src/runners/task-managers/task-manager.ts b/packages/cli/src/runners/task-managers/task-manager.ts index 66f07f7b0a817..44193f9377a17 100644 --- a/packages/cli/src/runners/task-managers/task-manager.ts +++ b/packages/cli/src/runners/task-managers/task-manager.ts @@ -1,5 +1,7 @@ import type { TaskResultData, RequesterMessage, BrokerMessage, TaskData } from '@n8n/task-runner'; -import { RPC_ALLOW_LIST } from '@n8n/task-runner'; +import { AVAILABLE_RPC_METHODS } from '@n8n/task-runner'; +import { isSerializedBuffer, toBuffer } from 'n8n-core'; +import { createResultOk, createResultError } from 'n8n-workflow'; import type { EnvProviderState, IExecuteFunctions, @@ -15,7 +17,6 @@ import type { IWorkflowExecuteAdditionalData, Result, } from 'n8n-workflow'; -import { createResultOk, createResultError } from 'n8n-workflow'; import { nanoid } from 'nanoid'; import { Service } from 'typedi'; @@ -158,6 +159,11 @@ export abstract class TaskManager { }); } + const { staticData: incomingStaticData } = resultData; + + // if the runner sent back static data, then it changed, so update it + if (incomingStaticData) workflow.overrideStaticData(incomingStaticData); + return createResultOk(resultData.result as TData); } catch (e: unknown) { return createResultError(e as TError); @@ -283,7 +289,7 @@ export abstract class TaskManager { } try { - if (!RPC_ALLOW_LIST.includes(name)) { + if (!AVAILABLE_RPC_METHODS.includes(name)) { this.sendMessage({ type: 'requester:rpcresponse', taskId, @@ -317,6 +323,15 @@ export abstract class TaskManager { }); return; } + + // Convert any serialized buffers back to buffers + for (let i = 0; i < params.length; i++) { + const paramValue = params[i]; + if (isSerializedBuffer(paramValue)) { + params[i] = toBuffer(paramValue); + } + } + const data = (await func.call(funcs, ...params)) as unknown; this.sendMessage({ diff --git a/packages/cli/src/runners/task-runner-module.ts b/packages/cli/src/runners/task-runner-module.ts index 97631e07639b0..434daa066a35c 100644 --- a/packages/cli/src/runners/task-runner-module.ts +++ b/packages/cli/src/runners/task-runner-module.ts @@ -1,5 +1,6 @@ import { TaskRunnersConfig } from '@n8n/config'; -import { ErrorReporterProxy, sleep } from 'n8n-workflow'; +import { ErrorReporter } from 'n8n-core'; +import { sleep } from 'n8n-workflow'; import * as a from 'node:assert/strict'; import Container, { Service } from 'typedi'; @@ -33,6 +34,7 @@ export class TaskRunnerModule { constructor( private readonly logger: Logger, + private readonly errorReporter: ErrorReporter, private readonly runnerConfig: TaskRunnersConfig, ) { this.logger = this.logger.scoped('task-runner'); @@ -114,7 +116,7 @@ export class TaskRunnerModule { private onRunnerRestartLoopDetected = async (error: TaskRunnerRestartLoopError) => { this.logger.error(error.message); - ErrorReporterProxy.error(error); + this.errorReporter.error(error); // Allow some time for the error to be flushed await sleep(1000); diff --git a/packages/cli/src/runners/task-runner-process.ts b/packages/cli/src/runners/task-runner-process.ts index d989107718b1f..2716383f17430 100644 --- a/packages/cli/src/runners/task-runner-process.ts +++ b/packages/cli/src/runners/task-runner-process.ts @@ -28,17 +28,17 @@ export type TaskRunnerProcessEventMap = { */ @Service() export class TaskRunnerProcess extends TypedEmitter { - public get isRunning() { + get isRunning() { return this.process !== null; } /** The process ID of the task runner process */ - public get pid() { + get pid() { return this.process?.pid; } /** Promise that resolves when the process has exited */ - public get runPromise() { + get runPromise() { return this._runPromise; } diff --git a/packages/cli/src/runners/task-runner-server.ts b/packages/cli/src/runners/task-runner-server.ts index 18a48cf39dead..2b1f481b0e338 100644 --- a/packages/cli/src/runners/task-runner-server.ts +++ b/packages/cli/src/runners/task-runner-server.ts @@ -31,7 +31,7 @@ export class TaskRunnerServer { readonly app: express.Application; - public get port() { + get port() { return (this.server?.address() as AddressInfo)?.port; } @@ -133,11 +133,8 @@ export class TaskRunnerServer { // Augment errors sent to Sentry if (this.globalConfig.sentry.backendDsn) { - const { - Handlers: { requestHandler, errorHandler }, - } = await import('@sentry/node'); - app.use(requestHandler()); - app.use(errorHandler()); + const { setupExpressErrorHandler } = await import('@sentry/node'); + setupExpressErrorHandler(app); } } diff --git a/packages/cli/src/scaling/__tests__/job-processor.service.test.ts b/packages/cli/src/scaling/__tests__/job-processor.service.test.ts index 6a3fa5caa4d1f..73264e638269e 100644 --- a/packages/cli/src/scaling/__tests__/job-processor.service.test.ts +++ b/packages/cli/src/scaling/__tests__/job-processor.service.test.ts @@ -12,7 +12,14 @@ describe('JobProcessor', () => { executionRepository.findSingleExecution.mockResolvedValue( mock({ status: 'crashed' }), ); - const jobProcessor = new JobProcessor(mock(), executionRepository, mock(), mock(), mock()); + const jobProcessor = new JobProcessor( + mock(), + mock(), + executionRepository, + mock(), + mock(), + mock(), + ); const result = await jobProcessor.processJob(mock()); diff --git a/packages/cli/src/scaling/__tests__/pubsub-handler.test.ts b/packages/cli/src/scaling/__tests__/pubsub-handler.test.ts index 314ded0b8b953..4f8c8af85956f 100644 --- a/packages/cli/src/scaling/__tests__/pubsub-handler.test.ts +++ b/packages/cli/src/scaling/__tests__/pubsub-handler.test.ts @@ -620,7 +620,10 @@ describe('PubSubHandler', () => { expect(activeWorkflowManager.add).toHaveBeenCalledWith(workflowId, 'activate', undefined, { shouldPublish: false, }); - expect(push.broadcast).toHaveBeenCalledWith('workflowActivated', { workflowId }); + expect(push.broadcast).toHaveBeenCalledWith({ + type: 'workflowActivated', + data: { workflowId }, + }); expect(publisher.publishCommand).toHaveBeenCalledWith({ command: 'display-workflow-activation', payload: { workflowId }, @@ -680,7 +683,10 @@ describe('PubSubHandler', () => { expect(activeWorkflowManager.removeWorkflowTriggersAndPollers).toHaveBeenCalledWith( workflowId, ); - expect(push.broadcast).toHaveBeenCalledWith('workflowDeactivated', { workflowId }); + expect(push.broadcast).toHaveBeenCalledWith({ + type: 'workflowDeactivated', + data: { workflowId }, + }); expect(publisher.publishCommand).toHaveBeenCalledWith({ command: 'display-workflow-deactivation', payload: { workflowId }, @@ -735,7 +741,10 @@ describe('PubSubHandler', () => { eventService.emit('display-workflow-activation', { workflowId }); - expect(push.broadcast).toHaveBeenCalledWith('workflowActivated', { workflowId }); + expect(push.broadcast).toHaveBeenCalledWith({ + type: 'workflowActivated', + data: { workflowId }, + }); }); it('should handle `display-workflow-deactivation` event', () => { @@ -758,7 +767,10 @@ describe('PubSubHandler', () => { eventService.emit('display-workflow-deactivation', { workflowId }); - expect(push.broadcast).toHaveBeenCalledWith('workflowDeactivated', { workflowId }); + expect(push.broadcast).toHaveBeenCalledWith({ + type: 'workflowDeactivated', + data: { workflowId }, + }); }); it('should handle `display-workflow-activation-error` event', () => { @@ -782,9 +794,12 @@ describe('PubSubHandler', () => { eventService.emit('display-workflow-activation-error', { workflowId, errorMessage }); - expect(push.broadcast).toHaveBeenCalledWith('workflowFailedToActivate', { - workflowId, - errorMessage, + expect(push.broadcast).toHaveBeenCalledWith({ + type: 'workflowFailedToActivate', + data: { + workflowId, + errorMessage, + }, }); }); @@ -806,15 +821,21 @@ describe('PubSubHandler', () => { const pushRef = 'test-push-ref'; const type = 'executionStarted'; - const args = { testArg: 'value' }; + const data = { + executionId: '123', + mode: 'webhook' as const, + startedAt: new Date(), + workflowId: '456', + flattedRunData: '[]', + }; push.getBackend.mockReturnValue( mock({ hasPushRef: jest.fn().mockReturnValue(true) }), ); - eventService.emit('relay-execution-lifecycle-event', { type, args, pushRef }); + eventService.emit('relay-execution-lifecycle-event', { type, data, pushRef }); - expect(push.send).toHaveBeenCalledWith(type, args, pushRef); + expect(push.send).toHaveBeenCalledWith({ type, data }, pushRef); }); it('should handle `clear-test-webhooks` event', () => { @@ -868,9 +889,12 @@ describe('PubSubHandler', () => { eventService.emit('response-to-get-worker-status', workerStatus); - expect(push.broadcast).toHaveBeenCalledWith('sendWorkerStatusMessage', { - workerId: workerStatus.senderId, - status: workerStatus, + expect(push.broadcast).toHaveBeenCalledWith({ + type: 'sendWorkerStatusMessage', + data: { + workerId: workerStatus.senderId, + status: workerStatus, + }, }); }); }); diff --git a/packages/cli/src/scaling/__tests__/scaling.service.test.ts b/packages/cli/src/scaling/__tests__/scaling.service.test.ts index 0b5f80da483b3..b400bf6dfbbb7 100644 --- a/packages/cli/src/scaling/__tests__/scaling.service.test.ts +++ b/packages/cli/src/scaling/__tests__/scaling.service.test.ts @@ -5,7 +5,6 @@ import { InstanceSettings } from 'n8n-core'; import { ApplicationError } from 'n8n-workflow'; import Container from 'typedi'; -import type { OrchestrationService } from '@/services/orchestration.service'; import { mockInstance, mockLogger } from '@test/mocking'; import { JOB_TYPE_NAME, QUEUE_NAME } from '../constants'; @@ -47,7 +46,6 @@ describe('ScalingService', () => { }); const instanceSettings = Container.get(InstanceSettings); - const orchestrationService = mock({ isMultiMainSetupEnabled: false }); const jobProcessor = mock(); let scalingService: ScalingService; @@ -77,11 +75,12 @@ describe('ScalingService', () => { scalingService = new ScalingService( mockLogger(), mock(), + mock(), jobProcessor, globalConfig, mock(), instanceSettings, - orchestrationService, + mock(), mock(), ); diff --git a/packages/cli/src/scaling/job-processor.ts b/packages/cli/src/scaling/job-processor.ts index 6bf2524304fa5..51b86c3922301 100644 --- a/packages/cli/src/scaling/job-processor.ts +++ b/packages/cli/src/scaling/job-processor.ts @@ -1,12 +1,7 @@ import type { RunningJobSummary } from '@n8n/api-types'; -import { InstanceSettings, WorkflowExecute } from 'n8n-core'; +import { ErrorReporter, InstanceSettings, WorkflowExecute } from 'n8n-core'; import type { ExecutionStatus, IExecuteResponsePromiseData, IRun } from 'n8n-workflow'; -import { - BINARY_ENCODING, - ApplicationError, - Workflow, - ErrorReporterProxy as ErrorReporter, -} from 'n8n-workflow'; +import { BINARY_ENCODING, ApplicationError, Workflow } from 'n8n-workflow'; import type PCancelable from 'p-cancelable'; import { Service } from 'typedi'; @@ -35,6 +30,7 @@ export class JobProcessor { constructor( private readonly logger: Logger, + private readonly errorReporter: ErrorReporter, private readonly executionRepository: ExecutionRepository, private readonly workflowRepository: WorkflowRepository, private readonly nodeTypes: NodeTypes, @@ -155,7 +151,7 @@ export class JobProcessor { workflowExecute = new WorkflowExecute(additionalData, execution.mode, execution.data); workflowRun = workflowExecute.processRunExecutionData(workflow); } else { - ErrorReporter.info(`Worker found execution ${executionId} without data`); + this.errorReporter.info(`Worker found execution ${executionId} without data`); // Execute all nodes // Can execute without webhook so go on workflowExecute = new WorkflowExecute(additionalData, execution.mode); diff --git a/packages/cli/src/scaling/pubsub/publisher.service.ts b/packages/cli/src/scaling/pubsub/publisher.service.ts index 248a455e3ef55..4723b1d37dea7 100644 --- a/packages/cli/src/scaling/pubsub/publisher.service.ts +++ b/packages/cli/src/scaling/pubsub/publisher.service.ts @@ -4,6 +4,7 @@ import { Service } from 'typedi'; import config from '@/config'; import { Logger } from '@/logging/logger.service'; +import type { LogMetadata } from '@/logging/types'; import { RedisClientService } from '@/services/redis-client.service'; import type { PubSub } from './pubsub.types'; @@ -45,7 +46,7 @@ export class Publisher { // #region Publishing /** Publish a command into the `n8n.commands` channel. */ - async publishCommand(msg: Omit) { + async publishCommand(msg: PubSub.Command) { // @TODO: Once this class is only ever used in scaling mode, remove next line. if (config.getEnv('executions.mode') !== 'queue') return; @@ -59,7 +60,18 @@ export class Publisher { }), ); - this.logger.debug(`Published ${msg.command} to command channel`); + let msgName = msg.command; + + const metadata: LogMetadata = { msg: msg.command, channel: 'n8n.commands' }; + + if (msg.command === 'relay-execution-lifecycle-event') { + const { data, type } = msg.payload; + msgName += ` (${type})`; + metadata.type = type; + if ('executionId' in data) metadata.executionId = data.executionId; + } + + this.logger.debug(`Published pubsub msg: ${msgName}`, metadata); } /** Publish a response to a command into the `n8n.worker-response` channel. */ diff --git a/packages/cli/src/scaling/pubsub/pubsub-handler.ts b/packages/cli/src/scaling/pubsub/pubsub-handler.ts index deeed5b584dbb..70b5f67f72c37 100644 --- a/packages/cli/src/scaling/pubsub/pubsub-handler.ts +++ b/packages/cli/src/scaling/pubsub/pubsub-handler.ts @@ -59,9 +59,12 @@ export class PubSubHandler { ...this.commonHandlers, ...this.multiMainHandlers, 'response-to-get-worker-status': async (payload) => - this.push.broadcast('sendWorkerStatusMessage', { - workerId: payload.senderId, - status: payload, + this.push.broadcast({ + type: 'sendWorkerStatusMessage', + data: { + workerId: payload.senderId, + status: payload, + }, }), }); @@ -113,7 +116,7 @@ export class PubSubHandler { shouldPublish: false, // prevent leader from re-publishing message }); - this.push.broadcast('workflowActivated', { workflowId }); + this.push.broadcast({ type: 'workflowActivated', data: { workflowId } }); await this.publisher.publishCommand({ command: 'display-workflow-activation', @@ -125,7 +128,10 @@ export class PubSubHandler { await this.workflowRepository.update(workflowId, { active: false }); - this.push.broadcast('workflowFailedToActivate', { workflowId, errorMessage: message }); + this.push.broadcast({ + type: 'workflowFailedToActivate', + data: { workflowId, errorMessage: message }, + }); await this.publisher.publishCommand({ command: 'display-workflow-activation-error', @@ -139,7 +145,7 @@ export class PubSubHandler { await this.activeWorkflowManager.removeActivationError(workflowId); await this.activeWorkflowManager.removeWorkflowTriggersAndPollers(workflowId); - this.push.broadcast('workflowDeactivated', { workflowId }); + this.push.broadcast({ type: 'workflowDeactivated', data: { workflowId } }); // instruct followers to show workflow deactivation in UI await this.publisher.publishCommand({ @@ -148,15 +154,15 @@ export class PubSubHandler { }); }, 'display-workflow-activation': async ({ workflowId }) => - this.push.broadcast('workflowActivated', { workflowId }), + this.push.broadcast({ type: 'workflowActivated', data: { workflowId } }), 'display-workflow-deactivation': async ({ workflowId }) => - this.push.broadcast('workflowDeactivated', { workflowId }), + this.push.broadcast({ type: 'workflowDeactivated', data: { workflowId } }), 'display-workflow-activation-error': async ({ workflowId, errorMessage }) => - this.push.broadcast('workflowFailedToActivate', { workflowId, errorMessage }), - 'relay-execution-lifecycle-event': async ({ type, args, pushRef }) => { + this.push.broadcast({ type: 'workflowFailedToActivate', data: { workflowId, errorMessage } }), + 'relay-execution-lifecycle-event': async ({ pushRef, ...pushMsg }) => { if (!this.push.getBackend().hasPushRef(pushRef)) return; - this.push.send(type, args, pushRef); + this.push.send(pushMsg, pushRef); }, 'clear-test-webhooks': async ({ webhookKey, workflowEntity, pushRef }) => { if (!this.push.getBackend().hasPushRef(pushRef)) return; diff --git a/packages/cli/src/scaling/pubsub/pubsub.types.ts b/packages/cli/src/scaling/pubsub/pubsub.types.ts index eec01102015b9..501185d07eaa1 100644 --- a/packages/cli/src/scaling/pubsub/pubsub.types.ts +++ b/packages/cli/src/scaling/pubsub/pubsub.types.ts @@ -23,10 +23,14 @@ export namespace PubSub { // ---------------------------------- type _ToCommand = { - senderId: string; - targets?: string[]; command: CommandKey; + /** Host ID of the sender, added during publishing. */ + senderId?: string; + + /** Host IDs of the receivers. */ + targets?: string[]; + /** Whether the command should be sent to the sender as well. */ selfSend?: boolean; diff --git a/packages/cli/src/scaling/pubsub/subscriber.service.ts b/packages/cli/src/scaling/pubsub/subscriber.service.ts index ed673fc4e4075..0ce343c139b0a 100644 --- a/packages/cli/src/scaling/pubsub/subscriber.service.ts +++ b/packages/cli/src/scaling/pubsub/subscriber.service.ts @@ -7,6 +7,7 @@ import { Service } from 'typedi'; import config from '@/config'; import { EventService } from '@/events/event.service'; import { Logger } from '@/logging/logger.service'; +import type { LogMetadata } from '@/logging/types'; import { RedisClientService } from '@/services/redis-client.service'; import type { PubSub } from './pubsub.types'; @@ -72,7 +73,7 @@ export class Subscriber { }); if (!msg) { - this.logger.error(`Received malformed message via channel ${channel}`, { + this.logger.error('Received malformed pubsub message', { msg: str, channel, }); @@ -89,12 +90,18 @@ export class Subscriber { return null; } - const msgName = 'command' in msg ? msg.command : msg.response; + let msgName = 'command' in msg ? msg.command : msg.response; - this.logger.debug(`Received message ${msgName} via channel ${channel}`, { - msg, - channel, - }); + const metadata: LogMetadata = { msg: msgName, channel }; + + if ('command' in msg && msg.command === 'relay-execution-lifecycle-event') { + const { data, type } = msg.payload; + msgName += ` (${type})`; + metadata.type = type; + if ('executionId' in data) metadata.executionId = data.executionId; + } + + this.logger.debug(`Received pubsub msg: ${msgName}`, metadata); return msg; } diff --git a/packages/cli/src/scaling/scaling.service.ts b/packages/cli/src/scaling/scaling.service.ts index f7731e26c21e7..ebc8e4499ca90 100644 --- a/packages/cli/src/scaling/scaling.service.ts +++ b/packages/cli/src/scaling/scaling.service.ts @@ -1,13 +1,6 @@ import { GlobalConfig } from '@n8n/config'; -import { InstanceSettings } from 'n8n-core'; -import { - ApplicationError, - BINARY_ENCODING, - sleep, - jsonStringify, - ErrorReporterProxy, - ensureError, -} from 'n8n-workflow'; +import { ErrorReporter, InstanceSettings } from 'n8n-core'; +import { ApplicationError, BINARY_ENCODING, sleep, jsonStringify, ensureError } from 'n8n-workflow'; import type { IExecuteResponsePromiseData } from 'n8n-workflow'; import { strict } from 'node:assert'; import Container, { Service } from 'typedi'; @@ -43,6 +36,7 @@ export class ScalingService { constructor( private readonly logger: Logger, + private readonly errorReporter: ErrorReporter, private readonly activeExecutions: ActiveExecutions, private readonly jobProcessor: JobProcessor, private readonly globalConfig: GlobalConfig, @@ -72,9 +66,11 @@ export class ScalingService { this.registerListeners(); - if (this.instanceSettings.isLeader) this.scheduleQueueRecovery(); + const { isLeader, isMultiMain } = this.instanceSettings; - if (this.orchestrationService.isMultiMainSetupEnabled) { + if (isLeader) this.scheduleQueueRecovery(); + + if (isMultiMain) { this.orchestrationService.multiMainSetup .on('leader-takeover', () => this.scheduleQueueRecovery()) .on('leader-stepdown', () => this.stopQueueRecovery()); @@ -119,7 +115,7 @@ export class ScalingService { await job.progress(msg); - ErrorReporterProxy.error(error, { executionId }); + this.errorReporter.error(error, { executionId }); throw error; } @@ -133,7 +129,7 @@ export class ScalingService { } private async stopMain() { - if (this.orchestrationService.isSingleMainSetup) { + if (this.instanceSettings.isSingleMain) { await this.queue.pause(true, true); // no more jobs will be picked up this.logger.debug('Queue paused'); } @@ -379,7 +375,7 @@ export class ScalingService { return ( this.globalConfig.endpoints.metrics.includeQueueMetrics && this.instanceSettings.instanceType === 'main' && - !this.orchestrationService.isMultiMainSetupEnabled + this.instanceSettings.isSingleMain ); } diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index 06d2dbe4f8c84..74a13114448aa 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -32,7 +32,6 @@ import { setupPushServer, setupPushHandler, Push } from '@/push'; import type { APIRequest } from '@/requests'; import * as ResponseHelper from '@/response-helper'; import type { FrontendService } from '@/services/frontend.service'; -import { OrchestrationService } from '@/services/orchestration.service'; import '@/controllers/active-workflows.controller'; import '@/controllers/annotation-tags.controller.ee'; @@ -79,7 +78,6 @@ export class Server extends AbstractServer { constructor( private readonly loadNodesAndCredentials: LoadNodesAndCredentials, - private readonly orchestrationService: OrchestrationService, private readonly postHogClient: PostHogClient, private readonly eventService: EventService, private readonly instanceSettings: InstanceSettings, @@ -111,7 +109,7 @@ export class Server extends AbstractServer { } private async registerAdditionalControllers() { - if (!inProduction && this.orchestrationService.isMultiMainSetupEnabled) { + if (!inProduction && this.instanceSettings.isMultiMain) { await import('@/controllers/debug.controller'); } diff --git a/packages/cli/src/services/__tests__/credentials-tester.service.test.ts b/packages/cli/src/services/__tests__/credentials-tester.service.test.ts index 4da925c532d2d..60c49b7773857 100644 --- a/packages/cli/src/services/__tests__/credentials-tester.service.test.ts +++ b/packages/cli/src/services/__tests__/credentials-tester.service.test.ts @@ -8,7 +8,13 @@ import { CredentialsTester } from '@/services/credentials-tester.service'; describe('CredentialsTester', () => { const credentialTypes = mock(); const nodeTypes = mock(); - const credentialsTester = new CredentialsTester(mock(), credentialTypes, nodeTypes, mock()); + const credentialsTester = new CredentialsTester( + mock(), + mock(), + credentialTypes, + nodeTypes, + mock(), + ); beforeEach(() => { jest.clearAllMocks(); diff --git a/packages/cli/src/services/credentials-tester.service.ts b/packages/cli/src/services/credentials-tester.service.ts index 30504e464bcc1..6ae7201ac0558 100644 --- a/packages/cli/src/services/credentials-tester.service.ts +++ b/packages/cli/src/services/credentials-tester.service.ts @@ -4,7 +4,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-return */ /* eslint-disable @typescript-eslint/no-unsafe-call */ import get from 'lodash/get'; -import { NodeExecuteFunctions } from 'n8n-core'; +import { ErrorReporter, NodeExecuteFunctions, RoutingNode, isObjectLiteral } from 'n8n-core'; import type { ICredentialsDecrypted, ICredentialTestFunction, @@ -23,14 +23,7 @@ import type { ICredentialTestFunctions, IDataObject, } from 'n8n-workflow'; -import { - VersionedNodeType, - NodeHelpers, - RoutingNode, - Workflow, - ErrorReporterProxy as ErrorReporter, - ApplicationError, -} from 'n8n-workflow'; +import { VersionedNodeType, NodeHelpers, Workflow, ApplicationError } from 'n8n-workflow'; import { Service } from 'typedi'; import { CredentialTypes } from '@/credential-types'; @@ -41,7 +34,6 @@ import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-da import { RESPONSE_ERROR_MESSAGES } from '../constants'; import { CredentialsHelper } from '../credentials-helper'; -import { isObjectLiteral } from '../utils'; const { OAUTH2_CREDENTIAL_TEST_SUCCEEDED, OAUTH2_CREDENTIAL_TEST_FAILED } = RESPONSE_ERROR_MESSAGES; @@ -75,6 +67,7 @@ const mockNodeTypes: INodeTypes = { export class CredentialsTester { constructor( private readonly logger: Logger, + private readonly errorReporter: ErrorReporter, private readonly credentialTypes: CredentialTypes, private readonly nodeTypes: NodeTypes, private readonly credentialsHelper: CredentialsHelper, @@ -312,11 +305,10 @@ export class CredentialsTester { runIndex, nodeTypeCopy, { node, data: {}, source: null }, - NodeExecuteFunctions, credentialsDecrypted, ); } catch (error) { - ErrorReporter.error(error); + this.errorReporter.error(error); // Do not fail any requests to allow custom error messages and // make logic easier if (error.cause?.response) { diff --git a/packages/cli/src/services/dynamic-node-parameters.service.ts b/packages/cli/src/services/dynamic-node-parameters.service.ts index eb6ecc5f67380..65c40ef0b647e 100644 --- a/packages/cli/src/services/dynamic-node-parameters.service.ts +++ b/packages/cli/src/services/dynamic-node-parameters.service.ts @@ -1,4 +1,4 @@ -import { LoadOptionsContext, NodeExecuteFunctions } from 'n8n-core'; +import { LoadOptionsContext, RoutingNode, LocalLoadOptionsContext } from 'n8n-core'; import type { ILoadOptions, ILoadOptionsFunctions, @@ -17,15 +17,43 @@ import type { INodeTypeNameVersion, NodeParameterValueType, IDataObject, + ILocalLoadOptionsFunctions, } from 'n8n-workflow'; -import { Workflow, RoutingNode, ApplicationError } from 'n8n-workflow'; +import { Workflow, ApplicationError } from 'n8n-workflow'; import { Service } from 'typedi'; import { NodeTypes } from '@/node-types'; +import { WorkflowLoaderService } from './workflow-loader.service'; + +type LocalResourceMappingMethod = ( + this: ILocalLoadOptionsFunctions, +) => Promise; +type ListSearchMethod = ( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +) => Promise; +type LoadOptionsMethod = (this: ILoadOptionsFunctions) => Promise; +type ActionHandlerMethod = ( + this: ILoadOptionsFunctions, + payload?: string, +) => Promise; +type ResourceMappingMethod = (this: ILoadOptionsFunctions) => Promise; + +type NodeMethod = + | LocalResourceMappingMethod + | ListSearchMethod + | LoadOptionsMethod + | ActionHandlerMethod + | ResourceMappingMethod; + @Service() export class DynamicNodeParametersService { - constructor(private nodeTypes: NodeTypes) {} + constructor( + private nodeTypes: NodeTypes, + private workflowLoaderService: WorkflowLoaderService, + ) {} /** Returns the available options via a predefined method */ async getOptionsViaMethodName( @@ -40,6 +68,8 @@ export class DynamicNodeParametersService { const method = this.getMethod('loadOptions', methodName, nodeType); const workflow = this.getWorkflow(nodeTypeAndVersion, currentNodeParameters, credentials); const thisArgs = this.getThisArg(path, additionalData, workflow); + // Need to use untyped call since `this` usage is widespread and we don't have `strictBindCallApply` + // enabled in `tsconfig.json` // eslint-disable-next-line @typescript-eslint/no-unsafe-return return method.call(thisArgs); } @@ -105,13 +135,11 @@ export class DynamicNodeParametersService { main: [[{ json: {} }]], }; - const optionsData = await routingNode.runNode( - inputData, - runIndex, - tempNode, - { node, source: null, data: {} }, - NodeExecuteFunctions, - ); + const optionsData = await routingNode.runNode(inputData, runIndex, tempNode, { + node, + source: null, + data: {}, + }); if (optionsData?.length === 0) { return []; @@ -159,6 +187,20 @@ export class DynamicNodeParametersService { return method.call(thisArgs); } + /** Returns the available workflow input mapping fields for the ResourceMapper component */ + async getLocalResourceMappingFields( + methodName: string, + path: string, + additionalData: IWorkflowExecuteAdditionalData, + nodeTypeAndVersion: INodeTypeNameVersion, + ): Promise { + const nodeType = this.getNodeType(nodeTypeAndVersion); + const method = this.getMethod('localResourceMapping', methodName, nodeType); + const thisArgs = this.getLocalLoadOptionsContext(path, additionalData); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return method.call(thisArgs); + } + /** Returns the result of the action handler */ async getActionResult( handler: string, @@ -181,33 +223,34 @@ export class DynamicNodeParametersService { type: 'resourceMapping', methodName: string, nodeType: INodeType, - ): (this: ILoadOptionsFunctions) => Promise; + ): ResourceMappingMethod; private getMethod( - type: 'listSearch', + type: 'localResourceMapping', methodName: string, nodeType: INodeType, - ): ( - this: ILoadOptionsFunctions, - filter?: string | undefined, - paginationToken?: string | undefined, - ) => Promise; + ): LocalResourceMappingMethod; + private getMethod(type: 'listSearch', methodName: string, nodeType: INodeType): ListSearchMethod; private getMethod( type: 'loadOptions', methodName: string, nodeType: INodeType, - ): (this: ILoadOptionsFunctions) => Promise; + ): LoadOptionsMethod; private getMethod( type: 'actionHandler', methodName: string, nodeType: INodeType, - ): (this: ILoadOptionsFunctions, payload?: string) => Promise; - + ): ActionHandlerMethod; private getMethod( - type: 'resourceMapping' | 'listSearch' | 'loadOptions' | 'actionHandler', + type: + | 'resourceMapping' + | 'localResourceMapping' + | 'listSearch' + | 'loadOptions' + | 'actionHandler', methodName: string, nodeType: INodeType, - ) { - const method = nodeType.methods?.[type]?.[methodName]; + ): NodeMethod { + const method = nodeType.methods?.[type]?.[methodName] as NodeMethod; if (typeof method !== 'function') { throw new ApplicationError('Node type does not have method defined', { tags: { nodeType: nodeType.description.name }, @@ -255,4 +298,16 @@ export class DynamicNodeParametersService { const node = workflow.nodes['Temp-Node']; return new LoadOptionsContext(workflow, node, additionalData, path); } + + private getLocalLoadOptionsContext( + path: string, + additionalData: IWorkflowExecuteAdditionalData, + ): ILocalLoadOptionsFunctions { + return new LocalLoadOptionsContext( + this.nodeTypes, + additionalData, + path, + this.workflowLoaderService, + ); + } } diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 535239d5a46bc..1645e98304d56 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -5,7 +5,6 @@ import { mkdir } from 'fs/promises'; import uniq from 'lodash/uniq'; import { InstanceSettings } from 'n8n-core'; import type { ICredentialType, INodeTypeBaseDescription } from 'n8n-workflow'; -import fs from 'node:fs'; import path from 'path'; import { Container, Service } from 'typedi'; @@ -83,7 +82,7 @@ export class FrontendService { this.settings = { inE2ETests, - isDocker: this.isDocker(), + isDocker: this.instanceSettings.isDocker, databaseType: this.globalConfig.database.type, previewMode: process.env.N8N_PREVIEW_MODE === 'true', endpointForm: this.globalConfig.endpoints.form, @@ -231,7 +230,6 @@ export class FrontendService { blockFileAccessToN8nFiles: this.securityConfig.blockFileAccessToN8nFiles, }, betaFeatures: this.frontendConfig.betaFeatures, - virtualSchemaView: config.getEnv('virtualSchemaView'), easyAIWorkflowOnboarded: false, }; } @@ -393,20 +391,4 @@ export class FrontendService { } } } - - /** - * Whether this instance is running inside a Docker container. - * - * Based on: https://github.com/sindresorhus/is-docker - */ - private isDocker() { - try { - return ( - fs.existsSync('/.dockerenv') || - fs.readFileSync('/proc/self/cgroup', 'utf8').includes('docker') - ); - } catch { - return false; - } - } } diff --git a/packages/cli/src/services/orchestration.service.ts b/packages/cli/src/services/orchestration.service.ts index 19da88e412f64..adf03d97bf230 100644 --- a/packages/cli/src/services/orchestration.service.ts +++ b/packages/cli/src/services/orchestration.service.ts @@ -22,29 +22,6 @@ export class OrchestrationService { isInitialized = false; - private isMultiMainSetupLicensed = false; - - setMultiMainSetupLicensed(newState: boolean) { - this.isMultiMainSetupLicensed = newState; - } - - get isMultiMainSetupEnabled() { - return ( - config.getEnv('executions.mode') === 'queue' && - this.globalConfig.multiMainSetup.enabled && - this.instanceSettings.instanceType === 'main' && - this.isMultiMainSetupLicensed - ); - } - - get isSingleMainSetup() { - return !this.isMultiMainSetupEnabled; - } - - sanityCheck() { - return this.isInitialized && config.get('executions.mode') === 'queue'; - } - async init() { if (this.isInitialized) return; @@ -56,7 +33,7 @@ export class OrchestrationService { this.subscriber = Container.get(Subscriber); } - if (this.isMultiMainSetupEnabled) { + if (this.instanceSettings.isMultiMain) { await this.multiMainSetup.init(); } else { this.instanceSettings.markAsLeader(); @@ -69,7 +46,7 @@ export class OrchestrationService { async shutdown() { if (!this.isInitialized) return; - if (this.isMultiMainSetupEnabled) await this.multiMainSetup.shutdown(); + if (this.instanceSettings.isMultiMain) await this.multiMainSetup.shutdown(); this.publisher.shutdown(); this.subscriber.shutdown(); diff --git a/packages/cli/src/services/pruning/__tests__/pruning.service.test.ts b/packages/cli/src/services/pruning/__tests__/pruning.service.test.ts index 42fe73dd5eb24..050d1b82d9a6f 100644 --- a/packages/cli/src/services/pruning/__tests__/pruning.service.test.ts +++ b/packages/cli/src/services/pruning/__tests__/pruning.service.test.ts @@ -17,11 +17,10 @@ describe('PruningService', () => { it('should start pruning on main instance that is the leader', () => { const pruningService = new PruningService( mockLogger(), - mock({ isLeader: true }), + mock({ isLeader: true, isMultiMain: true }), mock(), mock(), mock({ - isMultiMainSetupEnabled: true, multiMainSetup: mock(), }), mock(), @@ -36,11 +35,10 @@ describe('PruningService', () => { it('should not start pruning on main instance that is a follower', () => { const pruningService = new PruningService( mockLogger(), - mock({ isLeader: false }), + mock({ isLeader: false, isMultiMain: true }), mock(), mock(), mock({ - isMultiMainSetupEnabled: true, multiMainSetup: mock(), }), mock(), @@ -55,11 +53,10 @@ describe('PruningService', () => { it('should register leadership events if main on multi-main setup', () => { const pruningService = new PruningService( mockLogger(), - mock({ isLeader: true }), + mock({ isLeader: true, isMultiMain: true }), mock(), mock(), mock({ - isMultiMainSetupEnabled: true, multiMainSetup: mock({ on: jest.fn() }), }), mock(), @@ -85,11 +82,10 @@ describe('PruningService', () => { it('should return `true` based on config if leader main', () => { const pruningService = new PruningService( mockLogger(), - mock({ isLeader: true, instanceType: 'main' }), + mock({ isLeader: true, instanceType: 'main', isMultiMain: true }), mock(), mock(), mock({ - isMultiMainSetupEnabled: true, multiMainSetup: mock(), }), mock({ pruneData: true }), @@ -101,11 +97,10 @@ describe('PruningService', () => { it('should return `false` based on config if leader main', () => { const pruningService = new PruningService( mockLogger(), - mock({ isLeader: true, instanceType: 'main' }), + mock({ isLeader: true, instanceType: 'main', isMultiMain: true }), mock(), mock(), mock({ - isMultiMainSetupEnabled: true, multiMainSetup: mock(), }), mock({ pruneData: false }), @@ -117,11 +112,10 @@ describe('PruningService', () => { it('should return `false` if non-main even if config is enabled', () => { const pruningService = new PruningService( mockLogger(), - mock({ isLeader: false, instanceType: 'worker' }), + mock({ isLeader: false, instanceType: 'worker', isMultiMain: true }), mock(), mock(), mock({ - isMultiMainSetupEnabled: true, multiMainSetup: mock(), }), mock({ pruneData: true }), @@ -133,11 +127,15 @@ describe('PruningService', () => { it('should return `false` if follower main even if config is enabled', () => { const pruningService = new PruningService( mockLogger(), - mock({ isLeader: false, isFollower: true, instanceType: 'main' }), + mock({ + isLeader: false, + isFollower: true, + instanceType: 'main', + isMultiMain: true, + }), mock(), mock(), mock({ - isMultiMainSetupEnabled: true, multiMainSetup: mock(), }), mock({ pruneData: true }), @@ -151,11 +149,10 @@ describe('PruningService', () => { it('should not start pruning if service is disabled', () => { const pruningService = new PruningService( mockLogger(), - mock({ isLeader: true, instanceType: 'main' }), + mock({ isLeader: true, instanceType: 'main', isMultiMain: true }), mock(), mock(), mock({ - isMultiMainSetupEnabled: true, multiMainSetup: mock(), }), mock({ pruneData: false }), @@ -179,11 +176,10 @@ describe('PruningService', () => { it('should start pruning if service is enabled and DB is migrated', () => { const pruningService = new PruningService( mockLogger(), - mock({ isLeader: true, instanceType: 'main' }), + mock({ isLeader: true, instanceType: 'main', isMultiMain: true }), mock(), mock(), mock({ - isMultiMainSetupEnabled: true, multiMainSetup: mock(), }), mock({ pruneData: true }), diff --git a/packages/cli/src/services/pruning/pruning.service.ts b/packages/cli/src/services/pruning/pruning.service.ts index 3006d3fbd91de..aad8c5490f340 100644 --- a/packages/cli/src/services/pruning/pruning.service.ts +++ b/packages/cli/src/services/pruning/pruning.service.ts @@ -13,9 +13,17 @@ import { Logger } from '@/logging/logger.service'; import { OrchestrationService } from '../orchestration.service'; /** - * Responsible for pruning executions from the database and their associated binary data - * from the filesystem, on a rolling basis. By default we soft-delete execution rows - * every cycle and hard-delete them and their binary data every 4th cycle. + * Responsible for deleting old executions from the database and deleting their + * associated binary data from the filesystem, on a rolling basis. + * + * By default: + * + * - Soft deletion (every 60m) identifies all prunable executions based on max + * age and/or max count, exempting annotated executions. + * - Hard deletion (every 15m) processes prunable executions in batches of 100, + * switching to 1s intervals until the total to prune is back down low enough, + * or in case the hard deletion fails. + * - Once mostly caught up, hard deletion goes back to the 15m schedule. */ @Service() export class PruningService { @@ -51,7 +59,7 @@ export class PruningService { if (this.instanceSettings.isLeader) this.startPruning(); - if (this.orchestrationService.isMultiMainSetupEnabled) { + if (this.instanceSettings.isMultiMain) { this.orchestrationService.multiMainSetup.on('leader-takeover', () => this.startPruning()); this.orchestrationService.multiMainSetup.on('leader-stepdown', () => this.stopPruning()); } diff --git a/packages/cli/src/services/workflow-loader.service.ts b/packages/cli/src/services/workflow-loader.service.ts new file mode 100644 index 0000000000000..ca1a9ff48c0b7 --- /dev/null +++ b/packages/cli/src/services/workflow-loader.service.ts @@ -0,0 +1,19 @@ +import { ApplicationError, type IWorkflowBase, type IWorkflowLoader } from 'n8n-workflow'; +import { Service } from 'typedi'; + +import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; + +@Service() +export class WorkflowLoaderService implements IWorkflowLoader { + constructor(private readonly workflowRepository: WorkflowRepository) {} + + async get(workflowId: string): Promise { + const workflow = await this.workflowRepository.findById(workflowId); + + if (!workflow) { + throw new ApplicationError(`Failed to find workflow with ID "${workflowId}"`); + } + + return workflow; + } +} diff --git a/packages/cli/src/shutdown/__tests__/shutdown.service.test.ts b/packages/cli/src/shutdown/__tests__/shutdown.service.test.ts index 9c2f5b4887fbe..26d6471584573 100644 --- a/packages/cli/src/shutdown/__tests__/shutdown.service.test.ts +++ b/packages/cli/src/shutdown/__tests__/shutdown.service.test.ts @@ -1,5 +1,6 @@ import { mock } from 'jest-mock-extended'; -import { ApplicationError, ErrorReporterProxy } from 'n8n-workflow'; +import type { ErrorReporter } from 'n8n-core'; +import { ApplicationError } from 'n8n-workflow'; import Container from 'typedi'; import type { ServiceClass } from '@/shutdown/shutdown.service'; @@ -13,14 +14,13 @@ describe('ShutdownService', () => { let shutdownService: ShutdownService; let mockComponent: MockComponent; let onShutdownSpy: jest.SpyInstance; - let mockErrorReporterProxy: jest.SpyInstance; + const errorReporter = mock(); beforeEach(() => { - shutdownService = new ShutdownService(mock()); + shutdownService = new ShutdownService(mock(), errorReporter); mockComponent = new MockComponent(); Container.set(MockComponent, mockComponent); onShutdownSpy = jest.spyOn(mockComponent, 'onShutdown'); - mockErrorReporterProxy = jest.spyOn(ErrorReporterProxy, 'error').mockImplementation(() => {}); }); describe('shutdown', () => { @@ -83,8 +83,8 @@ describe('ShutdownService', () => { shutdownService.shutdown(); await shutdownService.waitForShutdown(); - expect(mockErrorReporterProxy).toHaveBeenCalledTimes(1); - const error = mockErrorReporterProxy.mock.calls[0][0]; + expect(errorReporter.error).toHaveBeenCalledTimes(1); + const error = errorReporter.error.mock.calls[0][0] as ApplicationError; expect(error).toBeInstanceOf(ApplicationError); expect(error.message).toBe('Failed to shutdown gracefully'); expect(error.extra).toEqual({ diff --git a/packages/cli/src/shutdown/shutdown.service.ts b/packages/cli/src/shutdown/shutdown.service.ts index 1bedc3a7d4b68..8ff8570757b86 100644 --- a/packages/cli/src/shutdown/shutdown.service.ts +++ b/packages/cli/src/shutdown/shutdown.service.ts @@ -1,5 +1,5 @@ -import type { Class } from 'n8n-core'; -import { ApplicationError, ErrorReporterProxy, assert } from 'n8n-workflow'; +import { type Class, ErrorReporter } from 'n8n-core'; +import { ApplicationError, assert } from 'n8n-workflow'; import { Container, Service } from 'typedi'; import { LOWEST_SHUTDOWN_PRIORITY, HIGHEST_SHUTDOWN_PRIORITY } from '@/constants'; @@ -31,7 +31,10 @@ export class ShutdownService { private shutdownPromise: Promise | undefined; - constructor(private readonly logger: Logger) {} + constructor( + private readonly logger: Logger, + private readonly errorReporter: ErrorReporter, + ) {} /** Registers given listener to be notified when the application is shutting down */ register(priority: number, handler: ShutdownHandler) { @@ -108,7 +111,7 @@ export class ShutdownService { await method.call(service); } catch (error) { assert(error instanceof Error); - ErrorReporterProxy.error(new ComponentShutdownError(name, error)); + this.errorReporter.error(new ComponentShutdownError(name, error)); } } } diff --git a/packages/cli/src/sso/saml/saml.service.ee.ts b/packages/cli/src/sso/saml/saml.service.ee.ts index ce69a39e6d0ad..3672c8fe6ff23 100644 --- a/packages/cli/src/sso/saml/saml.service.ee.ts +++ b/packages/cli/src/sso/saml/saml.service.ee.ts @@ -68,7 +68,7 @@ export class SamlService { }, }; - public get samlPreferences(): SamlPreferences { + get samlPreferences(): SamlPreferences { return { ...this._samlPreferences, loginEnabled: isSamlLoginEnabled(), diff --git a/packages/cli/src/user-management/email/node-mailer.ts b/packages/cli/src/user-management/email/node-mailer.ts index 661c3fed7f79d..a35ab77318456 100644 --- a/packages/cli/src/user-management/email/node-mailer.ts +++ b/packages/cli/src/user-management/email/node-mailer.ts @@ -1,6 +1,6 @@ import { GlobalConfig } from '@n8n/config'; import { pick } from 'lodash'; -import { ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; +import { ErrorReporter } from 'n8n-core'; import path from 'node:path'; import type { Transporter } from 'nodemailer'; import { createTransport } from 'nodemailer'; @@ -20,6 +20,7 @@ export class NodeMailer { constructor( globalConfig: GlobalConfig, private readonly logger: Logger, + private readonly errorReporter: ErrorReporter, ) { const smtpConfig = globalConfig.userManagement.emails.smtp; const transportConfig: SMTPConnection.Options = pick(smtpConfig, ['host', 'port', 'secure']); @@ -66,7 +67,7 @@ export class NodeMailer { `Email sent successfully to the following recipients: ${mailData.emailRecipients.toString()}`, ); } catch (error) { - ErrorReporter.error(error); + this.errorReporter.error(error); this.logger.error('Failed to send email', { recipients: mailData.emailRecipients, error: error as Error, diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index 700f74f9d068a..d701707a11506 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -58,10 +58,6 @@ export function isStringArray(value: unknown): value is string[] { export const isIntegerString = (value: string) => /^\d+$/.test(value); -export function isObjectLiteral(item: unknown): item is { [key: string]: unknown } { - return typeof item === 'object' && item !== null && !Array.isArray(item); -} - export function removeTrailingSlash(path: string) { return path.endsWith('/') ? path.slice(0, -1) : path; } diff --git a/packages/cli/src/wait-tracker.ts b/packages/cli/src/wait-tracker.ts index a80ae8f259510..f42905ace1978 100644 --- a/packages/cli/src/wait-tracker.ts +++ b/packages/cli/src/wait-tracker.ts @@ -40,12 +40,11 @@ export class WaitTracker { * @important Requires `OrchestrationService` to be initialized. */ init() { - const { isLeader } = this.instanceSettings; - const { isMultiMainSetupEnabled } = this.orchestrationService; + const { isLeader, isMultiMain } = this.instanceSettings; if (isLeader) this.startTracking(); - if (isMultiMainSetupEnabled) { + if (isMultiMain) { this.orchestrationService.multiMainSetup .on('leader-takeover', () => this.startTracking()) .on('leader-stepdown', () => this.stopTracking()); diff --git a/packages/cli/src/webhooks/__tests__/test-webhook-registrations.service.test.ts b/packages/cli/src/webhooks/__tests__/test-webhook-registrations.service.test.ts index 0642a5eaa54fb..b3b4515d68ecf 100644 --- a/packages/cli/src/webhooks/__tests__/test-webhook-registrations.service.test.ts +++ b/packages/cli/src/webhooks/__tests__/test-webhook-registrations.service.test.ts @@ -1,7 +1,7 @@ import { mock } from 'jest-mock-extended'; +import type { InstanceSettings } from 'n8n-core'; import type { CacheService } from '@/services/cache/cache.service'; -import type { OrchestrationService } from '@/services/orchestration.service'; import type { TestWebhookRegistration } from '@/webhooks/test-webhook-registrations.service'; import { TestWebhookRegistrationsService } from '@/webhooks/test-webhook-registrations.service'; @@ -9,7 +9,7 @@ describe('TestWebhookRegistrationsService', () => { const cacheService = mock(); const registrations = new TestWebhookRegistrationsService( cacheService, - mock({ isMultiMainSetupEnabled: false }), + mock({ isMultiMain: false }), ); const registration = mock({ diff --git a/packages/cli/src/webhooks/__tests__/test-webhooks.test.ts b/packages/cli/src/webhooks/__tests__/test-webhooks.test.ts index 50f5bc2f1237d..a07d4fc0fda25 100644 --- a/packages/cli/src/webhooks/__tests__/test-webhooks.test.ts +++ b/packages/cli/src/webhooks/__tests__/test-webhooks.test.ts @@ -18,6 +18,7 @@ import type { } from '@/webhooks/test-webhook-registrations.service'; import { TestWebhooks } from '@/webhooks/test-webhooks'; import * as WebhookHelpers from '@/webhooks/webhook-helpers'; +import type { WebhookService } from '@/webhooks/webhook.service'; import type { WebhookRequest } from '@/webhooks/webhook.types'; import * as AdditionalData from '@/workflow-execute-additional-data'; @@ -38,13 +39,20 @@ const webhook = mock({ userId, }); -const registrations = mock(); - -let testWebhooks: TestWebhooks; - describe('TestWebhooks', () => { + const registrations = mock(); + const webhookService = mock(); + + const testWebhooks = new TestWebhooks( + mock(), + mock(), + registrations, + mock(), + mock(), + webhookService, + ); + beforeAll(() => { - testWebhooks = new TestWebhooks(mock(), mock(), registrations, mock(), mock()); jest.useFakeTimers(); }); @@ -68,7 +76,7 @@ describe('TestWebhooks', () => { const needsWebhook = await testWebhooks.needsWebhook(args); const [registerOrder] = registrations.register.mock.invocationCallOrder; - const [createOrder] = workflow.createWebhookIfNotExists.mock.invocationCallOrder; + const [createOrder] = webhookService.createWebhookIfNotExists.mock.invocationCallOrder; expect(registerOrder).toBeLessThan(createOrder); expect(needsWebhook).toBe(true); @@ -132,11 +140,11 @@ describe('TestWebhooks', () => { // ASSERT const [registerOrder] = registrations.register.mock.invocationCallOrder; - const [createOrder] = workflow.createWebhookIfNotExists.mock.invocationCallOrder; + const [createOrder] = webhookService.createWebhookIfNotExists.mock.invocationCallOrder; expect(registerOrder).toBeLessThan(createOrder); expect(registrations.register.mock.calls[0][0].webhook.node).toBe(webhook2.node); - expect(workflow.createWebhookIfNotExists.mock.calls[0][0].node).toBe(webhook2.node); + expect(webhookService.createWebhookIfNotExists.mock.calls[0][1].node).toBe(webhook2.node); expect(needsWebhook).toBe(true); }); }); diff --git a/packages/cli/src/webhooks/__tests__/waiting-forms.test.ts b/packages/cli/src/webhooks/__tests__/waiting-forms.test.ts index bec6f95d7fad5..f342095b77feb 100644 --- a/packages/cli/src/webhooks/__tests__/waiting-forms.test.ts +++ b/packages/cli/src/webhooks/__tests__/waiting-forms.test.ts @@ -6,7 +6,7 @@ import { WaitingForms } from '@/webhooks/waiting-forms'; describe('WaitingForms', () => { const executionRepository = mock(); - const waitingWebhooks = new WaitingForms(mock(), mock(), executionRepository); + const waitingWebhooks = new WaitingForms(mock(), mock(), executionRepository, mock()); beforeEach(() => { jest.restoreAllMocks(); diff --git a/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts b/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts index 892d87e773ce3..72fe654c55b95 100644 --- a/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts +++ b/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts @@ -10,7 +10,7 @@ import type { WaitingWebhookRequest } from '@/webhooks/webhook.types'; describe('WaitingWebhooks', () => { const executionRepository = mock(); - const waitingWebhooks = new WaitingWebhooks(mock(), mock(), executionRepository); + const waitingWebhooks = new WaitingWebhooks(mock(), mock(), executionRepository, mock()); beforeEach(() => { jest.restoreAllMocks(); diff --git a/packages/cli/src/webhooks/__tests__/webhook-form-data.test.ts b/packages/cli/src/webhooks/__tests__/webhook-form-data.test.ts index 1b43143d382d1..8eb308f7e45c0 100644 --- a/packages/cli/src/webhooks/__tests__/webhook-form-data.test.ts +++ b/packages/cli/src/webhooks/__tests__/webhook-form-data.test.ts @@ -14,7 +14,7 @@ jest.unmock('node:fs'); /** Test server for testing the form data parsing */ class TestServer { - public agent: TestAgent; + agent: TestAgent; private app: express.Application; diff --git a/packages/cli/src/webhooks/__tests__/webhook.service.test.ts b/packages/cli/src/webhooks/__tests__/webhook.service.test.ts index 534c36bca0878..46ffb82dc6e7e 100644 --- a/packages/cli/src/webhooks/__tests__/webhook.service.test.ts +++ b/packages/cli/src/webhooks/__tests__/webhook.service.test.ts @@ -1,11 +1,14 @@ +import { mock } from 'jest-mock-extended'; +import type { INode, INodeType, IWebhookData, IWorkflowExecuteAdditionalData } from 'n8n-workflow'; +import { Workflow } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; import config from '@/config'; import { WebhookEntity } from '@/databases/entities/webhook-entity'; -import { WebhookRepository } from '@/databases/repositories/webhook.repository'; -import { CacheService } from '@/services/cache/cache.service'; +import type { WebhookRepository } from '@/databases/repositories/webhook.repository'; +import type { NodeTypes } from '@/node-types'; +import type { CacheService } from '@/services/cache/cache.service'; import { WebhookService } from '@/webhooks/webhook.service'; -import { mockInstance } from '@test/mocking'; const createWebhook = (method: string, path: string, webhookId?: string, pathSegments?: number) => Object.assign(new WebhookEntity(), { @@ -16,9 +19,11 @@ const createWebhook = (method: string, path: string, webhookId?: string, pathSeg }) as WebhookEntity; describe('WebhookService', () => { - const webhookRepository = mockInstance(WebhookRepository); - const cacheService = mockInstance(CacheService); - const webhookService = new WebhookService(webhookRepository, cacheService); + const webhookRepository = mock(); + const cacheService = mock(); + const nodeTypes = mock(); + const webhookService = new WebhookService(mock(), webhookRepository, cacheService, nodeTypes); + const additionalData = mock(); beforeEach(() => { config.load(config.default); @@ -188,4 +193,171 @@ describe('WebhookService', () => { expect(webhookRepository.upsert).toHaveBeenCalledWith(mockWebhook, ['method', 'webhookPath']); }); }); + + describe('getNodeWebhooks()', () => { + const workflow = new Workflow({ + id: 'test-workflow', + nodes: [], + connections: {}, + active: true, + nodeTypes, + }); + + test('should return empty array if node is disabled', async () => { + const node = { disabled: true } as INode; + + const webhooks = webhookService.getNodeWebhooks(workflow, node, additionalData); + + expect(webhooks).toEqual([]); + }); + + test('should return webhooks for node with webhook definitions', async () => { + const node = { + name: 'Webhook', + type: 'n8n-nodes-base.webhook', + disabled: false, + } as INode; + + const nodeType = { + description: { + webhooks: [ + { + name: 'default', + httpMethod: 'GET', + path: '/webhook', + isFullPath: false, + restartWebhook: false, + }, + ], + }, + } as INodeType; + + nodeTypes.getByNameAndVersion.mockReturnValue(nodeType); + + const webhooks = webhookService.getNodeWebhooks(workflow, node, additionalData); + + expect(webhooks).toHaveLength(1); + expect(webhooks[0]).toMatchObject({ + httpMethod: 'GET', + node: 'Webhook', + workflowId: 'test-workflow', + }); + }); + }); + + describe('createWebhookIfNotExists()', () => { + const workflow = new Workflow({ + id: 'test-workflow', + nodes: [ + mock({ + name: 'Webhook', + type: 'n8n-nodes-base.webhook', + typeVersion: 1, + parameters: {}, + }), + ], + connections: {}, + active: false, + nodeTypes, + }); + + const webhookData = mock({ + node: 'Webhook', + webhookDescription: { + name: 'default', + httpMethod: 'GET', + path: '/webhook', + }, + }); + + const defaultWebhookMethods = { + checkExists: jest.fn(), + create: jest.fn(), + }; + + const nodeType = mock({ + webhookMethods: { default: defaultWebhookMethods }, + }); + + test('should create webhook if it does not exist', async () => { + defaultWebhookMethods.checkExists.mockResolvedValue(false); + defaultWebhookMethods.create.mockResolvedValue(true); + nodeTypes.getByNameAndVersion.mockReturnValue(nodeType); + + await webhookService.createWebhookIfNotExists(workflow, webhookData, 'trigger', 'init'); + + expect(defaultWebhookMethods.checkExists).toHaveBeenCalled(); + expect(defaultWebhookMethods.create).toHaveBeenCalled(); + }); + + test('should not create webhook if it already exists', async () => { + defaultWebhookMethods.checkExists.mockResolvedValue(true); + nodeTypes.getByNameAndVersion.mockReturnValue(nodeType); + + await webhookService.createWebhookIfNotExists(workflow, webhookData, 'trigger', 'init'); + + expect(defaultWebhookMethods.checkExists).toHaveBeenCalled(); + expect(defaultWebhookMethods.create).not.toHaveBeenCalled(); + }); + + test('should handle case when webhook methods are not defined', async () => { + nodeTypes.getByNameAndVersion.mockReturnValue({} as INodeType); + + await webhookService.createWebhookIfNotExists(workflow, webhookData, 'trigger', 'init'); + // Test passes if no error is thrown when webhook methods are undefined + }); + }); + + describe('deleteWebhook()', () => { + test('should call runWebhookMethod with delete', async () => { + const workflow = mock(); + const webhookData = mock(); + const runWebhookMethodSpy = jest.spyOn(webhookService as any, 'runWebhookMethod'); + + await webhookService.deleteWebhook(workflow, webhookData, 'trigger', 'init'); + + expect(runWebhookMethodSpy).toHaveBeenCalledWith( + 'delete', + workflow, + webhookData, + 'trigger', + 'init', + ); + }); + }); + + describe('runWebhook()', () => { + const workflow = mock(); + const webhookData = mock(); + const node = mock(); + const responseData = { workflowData: [] }; + + test('should throw error if node does not have webhooks', async () => { + const nodeType = {} as INodeType; + nodeTypes.getByNameAndVersion.mockReturnValue(nodeType); + + await expect( + webhookService.runWebhook(workflow, webhookData, node, additionalData, 'trigger', null), + ).rejects.toThrow('Node does not have any webhooks defined'); + }); + + test('should execute webhook and return response data', async () => { + const nodeType = mock({ + webhook: jest.fn().mockResolvedValue(responseData), + }); + nodeTypes.getByNameAndVersion.mockReturnValue(nodeType); + + const result = await webhookService.runWebhook( + workflow, + webhookData, + node, + additionalData, + 'trigger', + null, + ); + + expect(result).toEqual(responseData); + expect(nodeType.webhook).toHaveBeenCalled(); + }); + }); }); diff --git a/packages/cli/src/webhooks/live-webhooks.ts b/packages/cli/src/webhooks/live-webhooks.ts index 458701caee061..6d6fc9161de2c 100644 --- a/packages/cli/src/webhooks/live-webhooks.ts +++ b/packages/cli/src/webhooks/live-webhooks.ts @@ -1,5 +1,5 @@ import type { Response } from 'express'; -import { Workflow, NodeHelpers, CHAT_TRIGGER_NODE_TYPE } from 'n8n-workflow'; +import { Workflow, CHAT_TRIGGER_NODE_TYPE } from 'n8n-workflow'; import type { INode, IWebhookData, IHttpRequestMethods } from 'n8n-workflow'; import { Service } from 'typedi'; @@ -114,11 +114,9 @@ export class LiveWebhooks implements IWebhookManager { const additionalData = await WorkflowExecuteAdditionalData.getBase(); - const webhookData = NodeHelpers.getNodeWebhooks( - workflow, - workflow.getNode(webhook.node) as INode, - additionalData, - ).find((w) => w.httpMethod === httpMethod && w.path === webhook.webhookPath) as IWebhookData; + const webhookData = this.webhookService + .getNodeWebhooks(workflow, workflow.getNode(webhook.node) as INode, additionalData) + .find((w) => w.httpMethod === httpMethod && w.path === webhook.webhookPath) as IWebhookData; // Get the node which has the webhook defined to know where to start from and to // get additional data diff --git a/packages/cli/src/webhooks/test-webhook-registrations.service.ts b/packages/cli/src/webhooks/test-webhook-registrations.service.ts index 6a3e205f58964..e25b3102db3bc 100644 --- a/packages/cli/src/webhooks/test-webhook-registrations.service.ts +++ b/packages/cli/src/webhooks/test-webhook-registrations.service.ts @@ -1,10 +1,10 @@ +import { InstanceSettings } from 'n8n-core'; import type { IWebhookData } from 'n8n-workflow'; import { Service } from 'typedi'; import { TEST_WEBHOOK_TIMEOUT, TEST_WEBHOOK_TIMEOUT_BUFFER } from '@/constants'; import type { IWorkflowDb } from '@/interfaces'; import { CacheService } from '@/services/cache/cache.service'; -import { OrchestrationService } from '@/services/orchestration.service'; export type TestWebhookRegistration = { pushRef?: string; @@ -17,7 +17,7 @@ export type TestWebhookRegistration = { export class TestWebhookRegistrationsService { constructor( private readonly cacheService: CacheService, - private readonly orchestrationService: OrchestrationService, + private readonly instanceSettings: InstanceSettings, ) {} private readonly cacheKey = 'test-webhooks'; @@ -27,7 +27,7 @@ export class TestWebhookRegistrationsService { await this.cacheService.setHash(this.cacheKey, { [hashKey]: registration }); - if (!this.orchestrationService.isMultiMainSetupEnabled) return; + if (this.instanceSettings.isSingleMain) return; /** * Multi-main setup: In a manual webhook execution, the main process that diff --git a/packages/cli/src/webhooks/test-webhooks.ts b/packages/cli/src/webhooks/test-webhooks.ts index 2bdf94b312f58..b90b1db59d985 100644 --- a/packages/cli/src/webhooks/test-webhooks.ts +++ b/packages/cli/src/webhooks/test-webhooks.ts @@ -1,5 +1,5 @@ import type express from 'express'; -import * as NodeExecuteFunctions from 'n8n-core'; +import { InstanceSettings } from 'n8n-core'; import { WebhookPathTakenError, Workflow } from 'n8n-workflow'; import type { IWebhookData, @@ -17,7 +17,6 @@ import type { IWorkflowDb } from '@/interfaces'; import { NodeTypes } from '@/node-types'; import { Push } from '@/push'; import { Publisher } from '@/scaling/pubsub/publisher.service'; -import { OrchestrationService } from '@/services/orchestration.service'; import { removeTrailingSlash } from '@/utils'; import type { TestWebhookRegistration } from '@/webhooks/test-webhook-registrations.service'; import { TestWebhookRegistrationsService } from '@/webhooks/test-webhook-registrations.service'; @@ -25,6 +24,7 @@ import * as WebhookHelpers from '@/webhooks/webhook-helpers'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; import type { WorkflowRequest } from '@/workflows/workflow.request'; +import { WebhookService } from './webhook.service'; import type { IWebhookResponseCallbackData, IWebhookManager, @@ -42,8 +42,9 @@ export class TestWebhooks implements IWebhookManager { private readonly push: Push, private readonly nodeTypes: NodeTypes, private readonly registrations: TestWebhookRegistrationsService, - private readonly orchestrationService: OrchestrationService, + private readonly instanceSettings: InstanceSettings, private readonly publisher: Publisher, + private readonly webhookService: WebhookService, ) {} private timeouts: { [webhookKey: string]: NodeJS.Timeout } = {}; @@ -141,8 +142,7 @@ export class TestWebhooks implements IWebhookManager { // Inform editor-ui that webhook got received if (pushRef !== undefined) { this.push.send( - 'testWebhookReceived', - { workflowId: webhook?.workflowId, executionId }, + { type: 'testWebhookReceived', data: { workflowId: webhook?.workflowId, executionId } }, pushRef, ); } @@ -155,7 +155,7 @@ export class TestWebhooks implements IWebhookManager { * the handler process commands the creator process to clear its test webhooks. */ if ( - this.orchestrationService.isMultiMainSetupEnabled && + this.instanceSettings.isMultiMain && pushRef && !this.push.getBackend().hasPushRef(pushRef) ) { @@ -314,7 +314,7 @@ export class TestWebhooks implements IWebhookManager { */ await this.registrations.register(registration); - await workflow.createWebhookIfNotExists(webhook, NodeExecuteFunctions, 'manual', 'manual'); + await this.webhookService.createWebhookIfNotExists(workflow, webhook, 'manual', 'manual'); cacheableWebhook.staticData = workflow.staticData; @@ -353,7 +353,7 @@ export class TestWebhooks implements IWebhookManager { if (pushRef !== undefined) { try { - this.push.send('testWebhookDeleted', { workflowId }, pushRef); + this.push.send({ type: 'testWebhookDeleted', data: { workflowId } }, pushRef); } catch { // Could not inform editor, probably is not connected anymore. So simply go on. } @@ -431,7 +431,7 @@ export class TestWebhooks implements IWebhookManager { if (staticData) workflow.staticData = staticData; - await workflow.deleteWebhook(webhook, NodeExecuteFunctions, 'internal', 'update'); + await this.webhookService.deleteWebhook(workflow, webhook, 'internal', 'update'); } await this.registrations.deregisterAll(); diff --git a/packages/cli/src/webhooks/waiting-webhooks.ts b/packages/cli/src/webhooks/waiting-webhooks.ts index 3176bbdf2d950..63557091893b0 100644 --- a/packages/cli/src/webhooks/waiting-webhooks.ts +++ b/packages/cli/src/webhooks/waiting-webhooks.ts @@ -3,7 +3,6 @@ import { FORM_NODE_TYPE, type INodes, type IWorkflowBase, - NodeHelpers, SEND_AND_WAIT_OPERATION, WAIT_NODE_TYPE, Workflow, @@ -19,6 +18,7 @@ import { NodeTypes } from '@/node-types'; import * as WebhookHelpers from '@/webhooks/webhook-helpers'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; +import { WebhookService } from './webhook.service'; import type { IWebhookResponseCallbackData, IWebhookManager, @@ -38,6 +38,7 @@ export class WaitingWebhooks implements IWebhookManager { protected readonly logger: Logger, protected readonly nodeTypes: NodeTypes, private readonly executionRepository: ExecutionRepository, + private readonly webhookService: WebhookService, ) {} // TODO: implement `getWebhookMethods` for CORS support @@ -164,17 +165,15 @@ export class WaitingWebhooks implements IWebhookManager { } const additionalData = await WorkflowExecuteAdditionalData.getBase(); - const webhookData = NodeHelpers.getNodeWebhooks( - workflow, - workflowStartNode, - additionalData, - ).find( - (webhook) => - webhook.httpMethod === req.method && - webhook.path === (suffix ?? '') && - webhook.webhookDescription.restartWebhook === true && - (webhook.webhookDescription.isForm || false) === this.includeForms, - ); + const webhookData = this.webhookService + .getNodeWebhooks(workflow, workflowStartNode, additionalData) + .find( + (webhook) => + webhook.httpMethod === req.method && + webhook.path === (suffix ?? '') && + webhook.webhookDescription.restartWebhook === true && + (webhook.webhookDescription.isForm || false) === this.includeForms, + ); if (webhookData === undefined) { // If no data got found it means that the execution can not be started via a webhook. diff --git a/packages/cli/src/webhooks/webhook-helpers.ts b/packages/cli/src/webhooks/webhook-helpers.ts index 259142561a4f1..665708988171c 100644 --- a/packages/cli/src/webhooks/webhook-helpers.ts +++ b/packages/cli/src/webhooks/webhook-helpers.ts @@ -9,7 +9,7 @@ import { GlobalConfig } from '@n8n/config'; import type express from 'express'; import get from 'lodash/get'; -import { BinaryDataService, NodeExecuteFunctions } from 'n8n-core'; +import { BinaryDataService, ErrorReporter } from 'n8n-core'; import type { IBinaryData, IBinaryKeyData, @@ -33,11 +33,8 @@ import { ApplicationError, BINARY_ENCODING, createDeferredPromise, - ErrorReporterProxy as ErrorReporter, - ErrorReporterProxy, ExecutionCancelledError, FORM_NODE_TYPE, - NodeHelpers, NodeOperationError, } from 'n8n-workflow'; import { finished } from 'stream/promises'; @@ -59,6 +56,7 @@ import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-da import * as WorkflowHelpers from '@/workflow-helpers'; import { WorkflowRunner } from '@/workflow-runner'; +import { WebhookService } from './webhook.service'; import type { IWebhookResponseCallbackData, WebhookRequest } from './webhook.types'; /** @@ -90,7 +88,12 @@ export function getWorkflowWebhooks( } returnData.push.apply( returnData, - NodeHelpers.getNodeWebhooks(workflow, node, additionalData, ignoreRestartWebhooks), + Container.get(WebhookService).getNodeWebhooks( + workflow, + node, + additionalData, + ignoreRestartWebhooks, + ), ); } @@ -256,11 +259,11 @@ export async function executeWebhook( } try { - webhookResultData = await workflow.runWebhook( + webhookResultData = await Container.get(WebhookService).runWebhook( + workflow, webhookData, workflowStartNode, additionalData, - NodeExecuteFunctions, executionMode, runExecutionData ?? null, ); @@ -280,7 +283,7 @@ export async function executeWebhook( errorMessage = err.message; } - ErrorReporterProxy.error(err, { + Container.get(ErrorReporter).error(err, { extra: { nodeName: workflowStartNode.name, nodeType: workflowStartNode.type, @@ -521,7 +524,7 @@ export async function executeWebhook( didSendResponse = true; }) .catch(async (error) => { - ErrorReporter.error(error); + Container.get(ErrorReporter).error(error); Container.get(Logger).error( `Error with Webhook-Response for execution "${executionId}": "${error.message}"`, { executionId, workflowId: workflow.id }, diff --git a/packages/cli/src/webhooks/webhook.service.ts b/packages/cli/src/webhooks/webhook.service.ts index 8e72f0abf969e..80b12b04cd897 100644 --- a/packages/cli/src/webhooks/webhook.service.ts +++ b/packages/cli/src/webhooks/webhook.service.ts @@ -1,8 +1,23 @@ -import type { IHttpRequestMethods } from 'n8n-workflow'; +import { HookContext, WebhookContext } from 'n8n-core'; +import { ApplicationError, Node, NodeHelpers } from 'n8n-workflow'; +import type { + IHttpRequestMethods, + INode, + IRunExecutionData, + IWebhookData, + IWebhookResponseData, + IWorkflowExecuteAdditionalData, + WebhookSetupMethodNames, + Workflow, + WorkflowActivateMode, + WorkflowExecuteMode, +} from 'n8n-workflow'; import { Service } from 'typedi'; import type { WebhookEntity } from '@/databases/entities/webhook-entity'; import { WebhookRepository } from '@/databases/repositories/webhook.repository'; +import { Logger } from '@/logging/logger.service'; +import { NodeTypes } from '@/node-types'; import { CacheService } from '@/services/cache/cache.service'; type Method = NonNullable; @@ -10,8 +25,10 @@ type Method = NonNullable; @Service() export class WebhookService { constructor( - private webhookRepository: WebhookRepository, - private cacheService: CacheService, + private readonly logger: Logger, + private readonly webhookRepository: WebhookRepository, + private readonly cacheService: CacheService, + private readonly nodeTypes: NodeTypes, ) {} async populateCache() { @@ -118,4 +135,210 @@ export class WebhookService { .find({ select: ['method'], where: { webhookPath: path } }) .then((rows) => rows.map((r) => r.method)); } + + /** + * Returns all the webhooks which should be created for the give node + */ + getNodeWebhooks( + workflow: Workflow, + node: INode, + additionalData: IWorkflowExecuteAdditionalData, + ignoreRestartWebhooks = false, + ): IWebhookData[] { + if (node.disabled === true) { + // Node is disabled so webhooks will also not be enabled + return []; + } + + const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); + + if (nodeType.description.webhooks === undefined) { + // Node does not have any webhooks so return + return []; + } + + const workflowId = workflow.id || '__UNSAVED__'; + const mode = 'internal'; + + const returnData: IWebhookData[] = []; + for (const webhookDescription of nodeType.description.webhooks) { + if (ignoreRestartWebhooks && webhookDescription.restartWebhook === true) { + continue; + } + + let nodeWebhookPath = workflow.expression.getSimpleParameterValue( + node, + webhookDescription.path, + mode, + {}, + ); + if (nodeWebhookPath === undefined) { + this.logger.error( + `No webhook path could be found for node "${node.name}" in workflow "${workflowId}".`, + ); + continue; + } + + nodeWebhookPath = nodeWebhookPath.toString(); + + if (nodeWebhookPath.startsWith('/')) { + nodeWebhookPath = nodeWebhookPath.slice(1); + } + if (nodeWebhookPath.endsWith('/')) { + nodeWebhookPath = nodeWebhookPath.slice(0, -1); + } + + const isFullPath: boolean = workflow.expression.getSimpleParameterValue( + node, + webhookDescription.isFullPath, + 'internal', + {}, + undefined, + false, + ) as boolean; + const restartWebhook: boolean = workflow.expression.getSimpleParameterValue( + node, + webhookDescription.restartWebhook, + 'internal', + {}, + undefined, + false, + ) as boolean; + const path = NodeHelpers.getNodeWebhookPath( + workflowId, + node, + nodeWebhookPath, + isFullPath, + restartWebhook, + ); + + const webhookMethods = workflow.expression.getSimpleParameterValue( + node, + webhookDescription.httpMethod, + mode, + {}, + undefined, + 'GET', + ); + + if (webhookMethods === undefined) { + this.logger.error( + `The webhook "${path}" for node "${node.name}" in workflow "${workflowId}" could not be added because the httpMethod is not defined.`, + ); + continue; + } + + let webhookId: string | undefined; + if ((path.startsWith(':') || path.includes('/:')) && node.webhookId) { + webhookId = node.webhookId; + } + + String(webhookMethods) + .split(',') + .forEach((httpMethod) => { + if (!httpMethod) return; + returnData.push({ + httpMethod: httpMethod.trim() as IHttpRequestMethods, + node: node.name, + path, + webhookDescription, + workflowId, + workflowExecuteAdditionalData: additionalData, + webhookId, + }); + }); + } + + return returnData; + } + + async createWebhookIfNotExists( + workflow: Workflow, + webhookData: IWebhookData, + mode: WorkflowExecuteMode, + activation: WorkflowActivateMode, + ): Promise { + const webhookExists = await this.runWebhookMethod( + 'checkExists', + workflow, + webhookData, + mode, + activation, + ); + if (!webhookExists) { + // If webhook does not exist yet create it + await this.runWebhookMethod('create', workflow, webhookData, mode, activation); + } + } + + async deleteWebhook( + workflow: Workflow, + webhookData: IWebhookData, + mode: WorkflowExecuteMode, + activation: WorkflowActivateMode, + ) { + await this.runWebhookMethod('delete', workflow, webhookData, mode, activation); + } + + private async runWebhookMethod( + method: WebhookSetupMethodNames, + workflow: Workflow, + webhookData: IWebhookData, + mode: WorkflowExecuteMode, + activation: WorkflowActivateMode, + ): Promise { + const node = workflow.getNode(webhookData.node); + + if (!node) return; + + const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); + + const webhookFn = nodeType.webhookMethods?.[webhookData.webhookDescription.name]?.[method]; + if (webhookFn === undefined) return; + + const context = new HookContext( + workflow, + node, + webhookData.workflowExecuteAdditionalData, + mode, + activation, + webhookData, + ); + + return (await webhookFn.call(context)) as boolean; + } + + /** + * Executes the webhook data to see what it should return and if the + * workflow should be started or not + */ + async runWebhook( + workflow: Workflow, + webhookData: IWebhookData, + node: INode, + additionalData: IWorkflowExecuteAdditionalData, + mode: WorkflowExecuteMode, + runExecutionData: IRunExecutionData | null, + ): Promise { + const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); + if (nodeType.webhook === undefined) { + throw new ApplicationError('Node does not have any webhooks defined', { + extra: { nodeName: node.name }, + }); + } + + const context = new WebhookContext( + workflow, + node, + additionalData, + mode, + webhookData, + [], + runExecutionData ?? null, + ); + + return nodeType instanceof Node + ? await nodeType.webhook(context) + : ((await nodeType.webhook.call(context)) as IWebhookResponseData); + } } diff --git a/packages/cli/src/workflow-execute-additional-data.ts b/packages/cli/src/workflow-execute-additional-data.ts index e3e65bcb4df25..29c8d67502f80 100644 --- a/packages/cli/src/workflow-execute-additional-data.ts +++ b/packages/cli/src/workflow-execute-additional-data.ts @@ -1,19 +1,12 @@ /* eslint-disable @typescript-eslint/no-unsafe-argument */ /* eslint-disable @typescript-eslint/no-use-before-define */ - /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import type { PushType } from '@n8n/api-types'; +import type { PushMessage, PushType } from '@n8n/api-types'; import { GlobalConfig } from '@n8n/config'; import { stringify } from 'flatted'; -import { WorkflowExecute } from 'n8n-core'; -import { - ApplicationError, - ErrorReporterProxy as ErrorReporter, - NodeOperationError, - Workflow, - WorkflowHooks, -} from 'n8n-workflow'; +import { ErrorReporter, WorkflowExecute, isObjectLiteral } from 'n8n-core'; +import { ApplicationError, NodeOperationError, Workflow, WorkflowHooks } from 'n8n-workflow'; import type { IDataObject, IExecuteData, @@ -52,7 +45,7 @@ import type { IWorkflowErrorData, UpdateExecutionPayload } from '@/interfaces'; import { NodeTypes } from '@/node-types'; import { Push } from '@/push'; import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; -import { findSubworkflowStart, isObjectLiteral, isWorkflowIdValid } from '@/utils'; +import { findSubworkflowStart, isWorkflowIdValid } from '@/utils'; import * as WorkflowHelpers from '@/workflow-helpers'; import { WorkflowRepository } from './databases/repositories/workflow.repository'; @@ -215,7 +208,7 @@ export function executeErrorWorkflow( ); }) .catch((error: Error) => { - ErrorReporter.error(error); + Container.get(ErrorReporter).error(error); logger.error( `Could not execute ErrorWorkflow for execution ID ${this.executionId} because of error querying the workflow owner`, { @@ -269,7 +262,7 @@ function hookFunctionsPush(): IWorkflowExecuteHooks { workflowId: this.workflowData.id, }); - pushInstance.send('nodeExecuteBefore', { executionId, nodeName }, pushRef); + pushInstance.send({ type: 'nodeExecuteBefore', data: { executionId, nodeName } }, pushRef); }, ], nodeExecuteAfter: [ @@ -286,7 +279,10 @@ function hookFunctionsPush(): IWorkflowExecuteHooks { workflowId: this.workflowData.id, }); - pushInstance.send('nodeExecuteAfter', { executionId, nodeName, data }, pushRef); + pushInstance.send( + { type: 'nodeExecuteAfter', data: { executionId, nodeName, data } }, + pushRef, + ); }, ], workflowExecuteBefore: [ @@ -303,17 +299,19 @@ function hookFunctionsPush(): IWorkflowExecuteHooks { return; } pushInstance.send( - 'executionStarted', { - executionId, - mode: this.mode, - startedAt: new Date(), - retryOf: this.retryOf, - workflowId, - workflowName, - flattedRunData: data?.resultData.runData - ? stringify(data.resultData.runData) - : stringify({}), + type: 'executionStarted', + data: { + executionId, + mode: this.mode, + startedAt: new Date(), + retryOf: this.retryOf, + workflowId, + workflowName, + flattedRunData: data?.resultData.runData + ? stringify(data.resultData.runData) + : stringify({}), + }, }, pushRef, ); @@ -333,12 +331,11 @@ function hookFunctionsPush(): IWorkflowExecuteHooks { const { status } = fullRunData; if (status === 'waiting') { - pushInstance.send('executionWaiting', { executionId }, pushRef); + pushInstance.send({ type: 'executionWaiting', data: { executionId } }, pushRef); } else { const rawData = stringify(fullRunData.data); pushInstance.send( - 'executionFinished', - { executionId, workflowId, status, rawData }, + { type: 'executionFinished', data: { executionId, workflowId, status, rawData } }, pushRef, ); } @@ -423,7 +420,7 @@ function hookFunctionsSave(): IWorkflowExecuteHooks { newStaticData, ); } catch (e) { - ErrorReporter.error(e); + Container.get(ErrorReporter).error(e); logger.error( `There was a problem saving the workflow with id "${this.workflowData.id}" to save changed staticData: "${e.message}" (hookFunctionsSave)`, { executionId: this.executionId, workflowId: this.workflowData.id }, @@ -502,7 +499,7 @@ function hookFunctionsSave(): IWorkflowExecuteHooks { ); } } catch (error) { - ErrorReporter.error(error); + Container.get(ErrorReporter).error(error); logger.error(`Failed saving execution data to DB on execution ID ${this.executionId}`, { executionId: this.executionId, workflowId: this.workflowData.id, @@ -584,7 +581,7 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { newStaticData, ); } catch (e) { - ErrorReporter.error(e); + Container.get(ErrorReporter).error(e); logger.error( `There was a problem saving the workflow with id "${this.workflowData.id}" to save changed staticData: "${e.message}" (workflowExecuteAfter)`, { pushRef: this.pushRef, workflowId: this.workflowData.id }, @@ -653,7 +650,7 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { this.executionId, ]); } catch (error) { - ErrorReporter.error(error); + Container.get(ErrorReporter).error(error); Container.get(Logger).error( 'There was a problem running hook "workflow.postExecute"', error, @@ -981,7 +978,7 @@ export function sendDataToUI(type: PushType, data: IDataObject | IDataObject[]) // Push data to session which started workflow try { const pushInstance = Container.get(Push); - pushInstance.send(type, data, pushRef); + pushInstance.send({ type, data } as PushMessage, pushRef); } catch (error) { const logger = Container.get(Logger); logger.warn(`There was a problem sending message to UI: ${error.message}`); @@ -1036,9 +1033,6 @@ export async function getBase( mode: WorkflowExecuteMode, envProviderState: EnvProviderState, executeData?: IExecuteData, - defaultReturnRunIndex?: number, - selfData?: IDataObject, - contextNodeName?: string, ) { return await Container.get(TaskManager).startTask( additionalData, @@ -1057,9 +1051,6 @@ export async function getBase( mode, envProviderState, executeData, - defaultReturnRunIndex, - selfData, - contextNodeName, ); }, logAiEvent: (eventName: keyof AiEventMap, payload: AiEventPayload) => diff --git a/packages/cli/src/workflow-helpers.ts b/packages/cli/src/workflow-helpers.ts index 7cbce6b8a2751..addae4e290b7e 100644 --- a/packages/cli/src/workflow-helpers.ts +++ b/packages/cli/src/workflow-helpers.ts @@ -7,9 +7,7 @@ import type { NodeApiError, WorkflowExecuteMode, WorkflowOperationError, - Workflow, NodeOperationError, - IWorkflowExecutionDataProcess, } from 'n8n-workflow'; import { Container } from 'typedi'; import { v4 as uuid } from 'uuid'; @@ -223,18 +221,6 @@ export async function replaceInvalidCredentials(workflow: WorkflowEntity): Promi return workflow; } -export function getExecutionStartNode(data: IWorkflowExecutionDataProcess, workflow: Workflow) { - let startNode; - if ( - data.startNodes?.length === 1 && - Object.keys(data.pinData ?? {}).includes(data.startNodes[0].name) - ) { - startNode = workflow.getNode(data.startNodes[0].name) ?? undefined; - } - - return startNode; -} - export async function getVariables(): Promise { const variables = await Container.get(VariablesService).getAllCached(); return Object.freeze( diff --git a/packages/cli/src/workflow-runner.ts b/packages/cli/src/workflow-runner.ts index 19ae201a8da3a..973d512e62079 100644 --- a/packages/cli/src/workflow-runner.ts +++ b/packages/cli/src/workflow-runner.ts @@ -2,14 +2,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-shadow */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import * as a from 'assert/strict'; -import { - DirectedGraph, - InstanceSettings, - WorkflowExecute, - filterDisabledNodes, - recreateNodeExecutionStack, -} from 'n8n-core'; +import { ErrorReporter, InstanceSettings, WorkflowExecute } from 'n8n-core'; import type { ExecutionError, IDeferredPromise, @@ -19,13 +12,8 @@ import type { WorkflowExecuteMode, WorkflowHooks, IWorkflowExecutionDataProcess, - IRunExecutionData, -} from 'n8n-workflow'; -import { - ErrorReporterProxy as ErrorReporter, - ExecutionCancelledError, - Workflow, } from 'n8n-workflow'; +import { ExecutionCancelledError, Workflow } from 'n8n-workflow'; import PCancelable from 'p-cancelable'; import { Container, Service } from 'typedi'; @@ -39,12 +27,12 @@ import type { ScalingService } from '@/scaling/scaling.service'; import type { Job, JobData } from '@/scaling/scaling.types'; import { PermissionChecker } from '@/user-management/permission-checker'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; -import * as WorkflowHelpers from '@/workflow-helpers'; import { generateFailedExecutionFromError } from '@/workflow-helpers'; import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service'; import { ExecutionNotFoundError } from './errors/execution-not-found-error'; import { EventService } from './events/event.service'; +import { ManualExecutionService } from './manual-execution.service'; @Service() export class WorkflowRunner { @@ -54,6 +42,7 @@ export class WorkflowRunner { constructor( private readonly logger: Logger, + private readonly errorReporter: ErrorReporter, private readonly activeExecutions: ActiveExecutions, private readonly executionRepository: ExecutionRepository, private readonly externalHooks: ExternalHooks, @@ -62,6 +51,7 @@ export class WorkflowRunner { private readonly permissionChecker: PermissionChecker, private readonly eventService: EventService, private readonly instanceSettings: InstanceSettings, + private readonly manualExecutionService: ManualExecutionService, ) {} /** The process did error */ @@ -81,7 +71,7 @@ export class WorkflowRunner { return; } - ErrorReporter.error(error, { executionId }); + this.errorReporter.error(error, { executionId }); const isQueueMode = config.getEnv('executions.mode') === 'queue'; @@ -192,14 +182,14 @@ export class WorkflowRunner { executionId, ]); } catch (error) { - ErrorReporter.error(error); + this.errorReporter.error(error); this.logger.error('There was a problem running hook "workflow.postExecute"', error); } } }) .catch((error) => { if (error instanceof ExecutionCancelledError) return; - ErrorReporter.error(error); + this.errorReporter.error(error); this.logger.error( 'There was a problem running internal hook "onWorkflowPostExecute"', error, @@ -295,88 +285,14 @@ export class WorkflowRunner { data.executionData, ); workflowExecution = workflowExecute.processRunExecutionData(workflow); - } else if (data.triggerToStartFrom?.data && data.startNodes && !data.destinationNode) { - this.logger.debug( - `Execution ID ${executionId} had triggerToStartFrom. Starting from that trigger.`, - { executionId }, - ); - const startNodes = data.startNodes.map((data) => { - const node = workflow.getNode(data.name); - a.ok(node, `Could not find a node named "${data.name}" in the workflow.`); - return node; - }); - const runData = { [data.triggerToStartFrom.name]: [data.triggerToStartFrom.data] }; - - const { nodeExecutionStack, waitingExecution, waitingExecutionSource } = - recreateNodeExecutionStack( - filterDisabledNodes(DirectedGraph.fromWorkflow(workflow)), - new Set(startNodes), - runData, - data.pinData ?? {}, - ); - const executionData: IRunExecutionData = { - resultData: { runData, pinData }, - executionData: { - contextData: {}, - metadata: {}, - nodeExecutionStack, - waitingExecution, - waitingExecutionSource, - }, - }; - - const workflowExecute = new WorkflowExecute(additionalData, 'manual', executionData); - workflowExecution = workflowExecute.processRunExecutionData(workflow); - } else if ( - data.runData === undefined || - data.startNodes === undefined || - data.startNodes.length === 0 - ) { - // Full Execution - // TODO: When the old partial execution logic is removed this block can - // be removed and the previous one can be merged into - // `workflowExecute.runPartialWorkflow2`. - // Partial executions then require either a destination node from which - // everything else can be derived, or a triggerToStartFrom with - // triggerData. - this.logger.debug(`Execution ID ${executionId} will run executing all nodes.`, { - executionId, - }); - // Execute all nodes - - const startNode = WorkflowHelpers.getExecutionStartNode(data, workflow); - - // Can execute without webhook so go on - const workflowExecute = new WorkflowExecute(additionalData, data.executionMode); - workflowExecution = workflowExecute.run( + } else { + workflowExecution = this.manualExecutionService.runManually( + data, workflow, - startNode, - data.destinationNode, - data.pinData, + additionalData, + executionId, + pinData, ); - } else { - // Partial Execution - this.logger.debug(`Execution ID ${executionId} is a partial execution.`, { executionId }); - // Execute only the nodes between start and destination nodes - const workflowExecute = new WorkflowExecute(additionalData, data.executionMode); - - if (data.partialExecutionVersion === '1') { - workflowExecution = workflowExecute.runPartialWorkflow2( - workflow, - data.runData, - data.pinData, - data.dirtyNodeNames, - data.destinationNode, - ); - } else { - workflowExecution = workflowExecute.runPartialWorkflow( - workflow, - data.runData, - data.startNodes, - data.destinationNode, - data.pinData, - ); - } } this.activeExecutions.attachWorkflowExecution(executionId, workflowExecution); diff --git a/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts b/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts index 35228dcfd4206..3d0bec39de3b4 100644 --- a/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts +++ b/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts @@ -57,6 +57,7 @@ describe('WorkflowExecutionService', () => { mock(), mock(), mock(), + mock(), workflowRunner, mock(), mock(), diff --git a/packages/cli/src/workflows/workflow-execution.service.ts b/packages/cli/src/workflows/workflow-execution.service.ts index 1df4af2f76268..27b673c245f66 100644 --- a/packages/cli/src/workflows/workflow-execution.service.ts +++ b/packages/cli/src/workflows/workflow-execution.service.ts @@ -1,4 +1,5 @@ import { GlobalConfig } from '@n8n/config'; +import { ErrorReporter } from 'n8n-core'; import type { IDeferredPromise, IExecuteData, @@ -11,11 +12,7 @@ import type { WorkflowExecuteMode, IWorkflowExecutionDataProcess, } from 'n8n-workflow'; -import { - SubworkflowOperationError, - Workflow, - ErrorReporterProxy as ErrorReporter, -} from 'n8n-workflow'; +import { SubworkflowOperationError, Workflow } from 'n8n-workflow'; import { Service } from 'typedi'; import type { Project } from '@/databases/entities/project'; @@ -36,6 +33,7 @@ import type { WorkflowRequest } from '@/workflows/workflow.request'; export class WorkflowExecutionService { constructor( private readonly logger: Logger, + private readonly errorReporter: ErrorReporter, private readonly executionRepository: ExecutionRepository, private readonly workflowRepository: WorkflowRepository, private readonly nodeTypes: NodeTypes, @@ -293,7 +291,7 @@ export class WorkflowExecutionService { await this.workflowRunner.run(runData); } catch (error) { - ErrorReporter.error(error); + this.errorReporter.error(error); this.logger.error( // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access `Calling Error Workflow for "${workflowErrorData.workflow.id}": "${error.message}"`, diff --git a/packages/cli/src/workflows/workflow-static-data.service.ts b/packages/cli/src/workflows/workflow-static-data.service.ts index 10655b77c7a02..3e5159dc9a438 100644 --- a/packages/cli/src/workflows/workflow-static-data.service.ts +++ b/packages/cli/src/workflows/workflow-static-data.service.ts @@ -1,5 +1,6 @@ import { GlobalConfig } from '@n8n/config'; -import { type IDataObject, type Workflow, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; +import { ErrorReporter } from 'n8n-core'; +import type { IDataObject, Workflow } from 'n8n-workflow'; import { Service } from 'typedi'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; @@ -11,6 +12,7 @@ export class WorkflowStaticDataService { constructor( private readonly globalConfig: GlobalConfig, private readonly logger: Logger, + private readonly errorReporter: ErrorReporter, private readonly workflowRepository: WorkflowRepository, ) {} @@ -33,7 +35,7 @@ export class WorkflowStaticDataService { await this.saveStaticDataById(workflow.id, workflow.staticData); workflow.staticData.__dataChanged = false; } catch (error) { - ErrorReporter.error(error); + this.errorReporter.error(error); this.logger.error( // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access `There was a problem saving the workflow with id "${workflow.id}" to save changed Data: "${error.message}"`, diff --git a/packages/cli/templates/form-trigger-completion.handlebars b/packages/cli/templates/form-trigger-completion.handlebars index 49520fd14c1f5..a15855d371752 100644 --- a/packages/cli/templates/form-trigger-completion.handlebars +++ b/packages/cli/templates/form-trigger-completion.handlebars @@ -69,6 +69,9 @@ {{/if}} + diff --git a/packages/cli/templates/form-trigger.handlebars b/packages/cli/templates/form-trigger.handlebars index 8d2b13e5a793a..ee868b072fd05 100644 --- a/packages/cli/templates/form-trigger.handlebars +++ b/packages/cli/templates/form-trigger.handlebars @@ -450,7 +450,11 @@