Merge branch 'master' into tomi/update-typeorm-split-versions

This commit is contained in:
Tomi Turtiainen 2026-05-12 11:31:05 +03:00 committed by GitHub
commit e9bb4dff6f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
101 changed files with 2622 additions and 373 deletions

View File

@ -69,6 +69,7 @@ jobs:
N8N_LICENSE_ACTIVATION_KEY: ${{ secrets.N8N_LICENSE_ACTIVATION_KEY }}
N8N_LICENSE_CERT: ${{ secrets.N8N_LICENSE_CERT }}
N8N_ENCRYPTION_KEY: ${{ secrets.N8N_ENCRYPTION_KEY }}
DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }}
run: |
IFS=',' read -ra PORTS <<< "$LANE_PORTS"
for i in "${!PORTS[@]}"; do
@ -79,6 +80,10 @@ jobs:
-e N8N_AI_ENABLED=true \
-e N8N_INSTANCE_AI_MODEL_API_KEY="$EVALS_ANTHROPIC_KEY" \
-e N8N_AI_ASSISTANT_BASE_URL="" \
-e N8N_INSTANCE_AI_SANDBOX_ENABLED=true \
-e N8N_INSTANCE_AI_SANDBOX_PROVIDER=daytona \
-e DAYTONA_API_URL=https://app.daytona.io/api \
-e DAYTONA_API_KEY="$DAYTONA_API_KEY" \
-e N8N_LICENSE_ACTIVATION_KEY="$N8N_LICENSE_ACTIVATION_KEY" \
-e N8N_LICENSE_CERT="$N8N_LICENSE_CERT" \
-e N8N_ENCRYPTION_KEY="$N8N_ENCRYPTION_KEY" \
@ -122,6 +127,36 @@ jobs:
}'
done
# Belt-and-suspenders: env vars set sandbox config but persisted admin
# settings can override. Per-lane assertion catches env-injection hiccups
# or unexpected DB-side state. A single misconfigured lane would
# silently route some builds through tool mode and pollute results.
- name: Assert sandbox is enabled on every lane
run: |
IFS=',' read -ra PORTS <<< "$LANE_PORTS"
bad=0
for i in "${!PORTS[@]}"; do
port="${PORTS[$i]}"
lane="$((i+1))"
curl -sf -X POST "http://localhost:$port/rest/login" \
-H "Content-Type: application/json" \
-d '{"emailOrLdapLoginId":"nathan@n8n.io","password":"PlaywrightTest123"}' \
-c "/tmp/cookies-$port.txt" -o /dev/null
cfg=$(curl -sf -b "/tmp/cookies-$port.txt" \
"http://localhost:$port/rest/instance-ai/settings" \
| jq -r '.data | "\(.sandboxEnabled) \(.sandboxProvider)"')
if [ "$cfg" != "true daytona" ]; then
echo "::error::lane $lane (port $port): expected 'true daytona', got '$cfg'"
bad=$((bad+1))
else
echo " lane $lane: sandboxEnabled=true sandboxProvider=daytona ok"
fi
done
if [ "$bad" -gt 0 ]; then
echo "::error::$bad lane(s) misconfigured - eval would mix sandbox + tool-mode builds"
exit 1
fi
- name: Run Instance AI Evals
continue-on-error: true
working-directory: packages/@n8n/instance-ai
@ -146,6 +181,60 @@ jobs:
--iterations 5 \
${{ inputs.filter && format('--filter "{0}"', inputs.filter) || '' }}
# Captures sandbox/builder/Daytona signals that surface during the eval
# (after migrations finish). Two layers of secret-leak defense:
#
# 1. Filter to specific diagnostic patterns — never tail raw output.
# The grep allowlist scopes the log surface to lines we care
# about for debugging (sandbox lifecycle, builder, errors).
#
# 2. Re-register secrets via ::add-mask:: so any line that does
# match the allowlist has the secret values replaced with ***
# before reaching the GH Actions log. GitHub auto-masks
# ${{ secrets.X }} references, but the masking is fragile
# against transformed or split values; explicit registration
# reinforces it.
#
# Runs even on eval failure so we have the post-mortem regardless.
- name: Capture n8n container logs (debug)
if: ${{ always() }}
env:
EVALS_ANTHROPIC_KEY: ${{ secrets.EVALS_ANTHROPIC_KEY }}
DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }}
N8N_LICENSE_ACTIVATION_KEY: ${{ secrets.N8N_LICENSE_ACTIVATION_KEY }}
N8N_LICENSE_CERT: ${{ secrets.N8N_LICENSE_CERT }}
N8N_ENCRYPTION_KEY: ${{ secrets.N8N_ENCRYPTION_KEY }}
run: |
# Layer 2 — defense in depth: explicitly mask each secret's value.
# ::add-mask:: is a single-line workflow command. Multi-line secrets
# (e.g. N8N_LICENSE_CERT is PEM-encoded) must be masked one line at
# a time, otherwise only the first line is registered.
for v in "$EVALS_ANTHROPIC_KEY" "$DAYTONA_API_KEY" \
"$N8N_LICENSE_ACTIVATION_KEY" "$N8N_LICENSE_CERT" \
"$N8N_ENCRYPTION_KEY"; do
[ -z "$v" ] && continue
while IFS= read -r line; do
[ -n "$line" ] && echo "::add-mask::$line"
done <<< "$v"
done
# Layer 1 — accuracy filter: only surface diagnostic signals.
# `tail -100` after the filter so we get the LATEST matching lines
# (post-eval failure signal), not the earliest startup-time ones.
SIGNALS='sandbox|builder|daytona|instance.?ai|error|warn|reject|exception|fail'
for c in $(docker ps -aq --filter "name=n8n-eval-"); do
name=$(docker inspect --format '{{.Name}}' "$c" | sed 's|^/||')
echo ""
echo "============================================================"
echo "=== $name (filtered diagnostic signals, last 100 lines) ==="
echo "============================================================"
docker logs "$c" 2>&1 \
| grep -ivE 'migration' \
| grep -iE "$SIGNALS" \
| tail -100 \
|| true
done
- name: Stop n8n containers
if: ${{ always() }}
run: |

View File

@ -1,3 +1,137 @@
# [2.21.0](https://github.com/n8n-io/n8n/compare/n8n@2.20.0...n8n@2.21.0) (2026-05-12)
### Bug Fixes
* Add warning to Computer Use install modal ([#30094](https://github.com/n8n-io/n8n/issues/30094)) ([ecf96ad](https://github.com/n8n-io/n8n/commit/ecf96ad30c8d29641db07cd78885ea28aff26199))
* **ai-builder:** Allow restoring archived workflows from Instance AI ([#29813](https://github.com/n8n-io/n8n/issues/29813)) ([a33a89a](https://github.com/n8n-io/n8n/commit/a33a89a215d6cef39895858bf36c00c15abfdd9d))
* **ai-builder:** Preserve collected planning context ([#29916](https://github.com/n8n-io/n8n/issues/29916)) ([5e3aa1a](https://github.com/n8n-io/n8n/commit/5e3aa1a726e903387344d3a4ed51e97811e4ff02))
* **ai-builder:** Resolve HitlTool variants to base node in get_node_types ([#29731](https://github.com/n8n-io/n8n/issues/29731)) ([ed9471a](https://github.com/n8n-io/n8n/commit/ed9471a5321747bbca003bee7d6a37d54bb79cb2))
* **Airtable Node:** Fix typecast option dropping attachment field updates ([#29556](https://github.com/n8n-io/n8n/issues/29556)) ([0cafc71](https://github.com/n8n-io/n8n/commit/0cafc717a274053f698e988d6f44a27a8b936e83))
* Align undici override across major versions ([#30028](https://github.com/n8n-io/n8n/issues/30028)) ([6b893b4](https://github.com/n8n-io/n8n/commit/6b893b45a0d05dfb08ea7b732f775c28b6ccf801))
* **Calendly Trigger Node:** Use API v2 for webhook subscriptions ([#29771](https://github.com/n8n-io/n8n/issues/29771)) ([0edcdcf](https://github.com/n8n-io/n8n/commit/0edcdcfe8529b6296f1a1f0d8b8af3841a14a466))
* **core:** Activate agent chat integrations on every main ([#30029](https://github.com/n8n-io/n8n/issues/30029)) ([6f4f0a0](https://github.com/n8n-io/n8n/commit/6f4f0a0303e1f0f0cd57a5b0dab08347010b7241))
* **core:** Add configurable retries and error details to S3 ([#28309](https://github.com/n8n-io/n8n/issues/28309)) ([e2576ca](https://github.com/n8n-io/n8n/commit/e2576ca25bc973b315bdcbff1a1b2d3309bc647d))
* **core:** Add ESLint rule to prevent error instances in toThrow assertions ([#29889](https://github.com/n8n-io/n8n/issues/29889)) ([75ed71c](https://github.com/n8n-io/n8n/commit/75ed71c00142e8bbdfb851691d5fc3de3cfada36))
* **core:** Add liveness timeouts for Instance AI ([#30145](https://github.com/n8n-io/n8n/issues/30145)) ([52a4bcb](https://github.com/n8n-io/n8n/commit/52a4bcb23a9398b1327acd0ec39df7a9e00b48b6))
* **core:** Add support for context establishment hooks in webhook mode ([#29893](https://github.com/n8n-io/n8n/issues/29893)) ([04e9b25](https://github.com/n8n-io/n8n/commit/04e9b258a887c07b62774f09e3921932038a3984))
* **core:** Add workflow structure validation ([#29699](https://github.com/n8n-io/n8n/issues/29699)) ([bec74ae](https://github.com/n8n-io/n8n/commit/bec74aeb4fda198853b3ea82ed135a1db3ba4988))
* **core:** Advance Postgres IDENTITY sequences after entity import ([#29762](https://github.com/n8n-io/n8n/issues/29762)) ([ca33060](https://github.com/n8n-io/n8n/commit/ca33060e0bd30c6d077f8dd18ca8492d50c06a92))
* **core:** Agent sessions correctly quoting columns in queries for Postgres ([#29999](https://github.com/n8n-io/n8n/issues/29999)) ([9f92005](https://github.com/n8n-io/n8n/commit/9f92005938a1b481b89558b4e82a198da6ec4e8c))
* **core:** Agents called from workflows use the workflows owner/user ID for calling further workflows through the agent ([#30242](https://github.com/n8n-io/n8n/issues/30242)) ([9072ee3](https://github.com/n8n-io/n8n/commit/9072ee3beb1789f34008cb0f85f361dcac8cae26))
* **core:** Allow GIT_SSH_COMMAND in simple-git after 3.36.0 upgrade ([#29894](https://github.com/n8n-io/n8n/issues/29894)) ([f42be90](https://github.com/n8n-io/n8n/commit/f42be9030e7f549da5ed6dc3902d058c2ebbadcb))
* **core:** Allow profile edits when SSO is no longer active ([#29765](https://github.com/n8n-io/n8n/issues/29765)) ([2714f00](https://github.com/n8n-io/n8n/commit/2714f001218d1323233c1920c94ed02a5ce8dcf1))
* **core:** Allow same-domain redirects in instance-ai web research (TRUST-73) ([#30107](https://github.com/n8n-io/n8n/issues/30107)) ([3123f25](https://github.com/n8n-io/n8n/commit/3123f2551be75fb282628b9106b060975fb983fc))
* **core:** Always create instance-ai sandbox workspace dirs (TRUST-79) ([#30106](https://github.com/n8n-io/n8n/issues/30106)) ([5e88748](https://github.com/n8n-io/n8n/commit/5e887483344daad5e11bee97d3315a9b2b38d0c9))
* **core:** Avoid MCP get_execution hang on circular references ([#30051](https://github.com/n8n-io/n8n/issues/30051)) ([60e23e1](https://github.com/n8n-io/n8n/commit/60e23e10e01f20f73fb1c61d74b5ca44a4c677f6))
* **core:** Check npm provenance in community package scanner ([#29667](https://github.com/n8n-io/n8n/issues/29667)) ([804f51c](https://github.com/n8n-io/n8n/commit/804f51cf0d8411b4d4df6f593fdea787b97fad51))
* **core:** Clarify 0-based indexing in workflow SDK prompts and JSDoc ([#29734](https://github.com/n8n-io/n8n/issues/29734)) ([fba873c](https://github.com/n8n-io/n8n/commit/fba873c37e76f01d28443c5276b2d92bd333602a))
* **core:** Clarify agent builder prompt guidance ([#30127](https://github.com/n8n-io/n8n/issues/30127)) ([75646c4](https://github.com/n8n-io/n8n/commit/75646c45271831bf8d03653baf024d201d5fae6d))
* **core:** Defer credential setup during workflow builds ([#30181](https://github.com/n8n-io/n8n/issues/30181)) ([bb73952](https://github.com/n8n-io/n8n/commit/bb73952fcc9aff4eed0af6bb99fb10f65d48df3d))
* **core:** Emit missing auth audit events for OIDC and SSO-restricted login ([#29856](https://github.com/n8n-io/n8n/issues/29856)) ([dd812c5](https://github.com/n8n-io/n8n/commit/dd812c5010ca28ca38c238bfa8c57fe39ac816d5))
* **core:** Export boolean CSV values as true/false for Data Tables ([#30007](https://github.com/n8n-io/n8n/issues/30007)) ([94d91e1](https://github.com/n8n-io/n8n/commit/94d91e13bfcaf360099a0a3816b0025502b145f4))
* **core:** Filter WaitTracker to only poll waiting executions ([#29898](https://github.com/n8n-io/n8n/issues/29898)) ([5c7921f](https://github.com/n8n-io/n8n/commit/5c7921f71c95d97f6730e6b28b06947b1cfbaa23))
* **core:** Fix duplicate task request on runner defer ([#28315](https://github.com/n8n-io/n8n/issues/28315)) ([80c8a6c](https://github.com/n8n-io/n8n/commit/80c8a6c2fdc97624c9b4b3e97b8ff20aca641552))
* **core:** Harden axios error handling against non-string error stack ([#29100](https://github.com/n8n-io/n8n/issues/29100)) ([2dbf02e](https://github.com/n8n-io/n8n/commit/2dbf02e63e5ddee8d9e4a94f2ad3cd1f5321f2a7))
* **core:** Improve AI chat file upload handling and error states ([#29701](https://github.com/n8n-io/n8n/issues/29701)) ([afe119b](https://github.com/n8n-io/n8n/commit/afe119be1409ac2cb198f7a41dc12ed25f5cf106))
* **core:** Improve documentation usage in mcp tools ([#30210](https://github.com/n8n-io/n8n/issues/30210)) ([e8827cd](https://github.com/n8n-io/n8n/commit/e8827cd6e8ff3eb03ceab6965574bacf10c719d0))
* **core:** Initialise encryption key proxy on worker and webhook instances ([#29912](https://github.com/n8n-io/n8n/issues/29912)) ([ae57e60](https://github.com/n8n-io/n8n/commit/ae57e606b4f5cf691bceb01489e5991cf31911ef))
* **core:** Inline AI_NODE_SDK_VERSION to save memory by not loading @n8n/ai-utilities on boot ([#30113](https://github.com/n8n-io/n8n/issues/30113)) ([f709e53](https://github.com/n8n-io/n8n/commit/f709e5382448926e15e36571aa9fd32db238e36d))
* **core:** Persist agent chat draft across modes and hide unfinished tool-approval toggle ([#30123](https://github.com/n8n-io/n8n/issues/30123)) ([7094b48](https://github.com/n8n-io/n8n/commit/7094b48c9444024af6c14b72b49b47b555db52ef))
* **core:** Preserve node positions on AI workflow updates ([#29850](https://github.com/n8n-io/n8n/issues/29850)) ([f2764f0](https://github.com/n8n-io/n8n/commit/f2764f04c0e663268fe40737c55c8c1a0f33173b))
* **core:** Prevent proxy layer accumulation in ObservableObject ([#30129](https://github.com/n8n-io/n8n/issues/30129)) ([0a76135](https://github.com/n8n-io/n8n/commit/0a761355c4836433c379ee8933c0198621879ae0))
* **core:** Propagate waitTill from worker to main in scaling mode ([#30099](https://github.com/n8n-io/n8n/issues/30099)) ([3702ff8](https://github.com/n8n-io/n8n/commit/3702ff8eb31547d51e3b56b484bf6a731296f9cf))
* **core:** Scope credential resolution ([#30156](https://github.com/n8n-io/n8n/issues/30156)) ([174f0f8](https://github.com/n8n-io/n8n/commit/174f0f805e0d5715d2d80e5c0282a94b79e9a390))
* **core:** Simple-git update broke https connection ([#29998](https://github.com/n8n-io/n8n/issues/29998)) ([01300e9](https://github.com/n8n-io/n8n/commit/01300e9b9b7e0f80f1852c5e1e4b3df9a42404c4))
* **core:** Simplify Slack redirect URL verification process for agents ([#30033](https://github.com/n8n-io/n8n/issues/30033)) ([8201281](https://github.com/n8n-io/n8n/commit/820128196cf550ab8cf371fbebb3457b9fd35d22))
* **core:** Skip disabled tool nodes when mapping AI Agent tool sources ([#29460](https://github.com/n8n-io/n8n/issues/29460)) ([bd7eeb7](https://github.com/n8n-io/n8n/commit/bd7eeb7bc89032b9a0db467cb53f37bfef71647e))
* **core:** Skip unknown fixedCollection keys instead of throwing ([#29689](https://github.com/n8n-io/n8n/issues/29689)) ([a30772c](https://github.com/n8n-io/n8n/commit/a30772c933544d06b560a3c66ec69cd4f7b8574f))
* **core:** Stop applying node-defined sensitive output fields to runtime data ([#30198](https://github.com/n8n-io/n8n/issues/30198)) ([f4e8088](https://github.com/n8n-io/n8n/commit/f4e8088cb8df24443eec0482e2c58346c1e30016))
* **core:** Stop logging password reset token values ([#29405](https://github.com/n8n-io/n8n/issues/29405)) ([bc8d196](https://github.com/n8n-io/n8n/commit/bc8d196931b35118ca6078a5845e8549bbba7e6b))
* **core:** Support type filters on global credential lookups ([#30002](https://github.com/n8n-io/n8n/issues/30002)) ([8e0f37d](https://github.com/n8n-io/n8n/commit/8e0f37d100b45d4105ca168bb8f62ec2c1328cf2))
* **core:** Throw on bare OutputSelector passed to .add()/.to() ([#29736](https://github.com/n8n-io/n8n/issues/29736)) ([60a5122](https://github.com/n8n-io/n8n/commit/60a51229e0db92a00788eb12586ea6376276645d))
* **core:** Validate AI builder credential IDs before save ([#30070](https://github.com/n8n-io/n8n/issues/30070)) ([ceaebc6](https://github.com/n8n-io/n8n/commit/ceaebc6cbe7cde2269aee4be6966d021f136f9c6))
* Correct connect.html path in browser extension ([#29714](https://github.com/n8n-io/n8n/issues/29714)) ([9b3b29b](https://github.com/n8n-io/n8n/commit/9b3b29b5058da42ec736c14cc8af5726b2a64e4b))
* **EditImage Node:** Fix composite operation failing with stream empty buffer ([#30088](https://github.com/n8n-io/n8n/issues/30088)) ([0cc163b](https://github.com/n8n-io/n8n/commit/0cc163b7dcccbfa68c065faa466b2b50f21c4a97))
* **editor:** Add expand/collapse to chat panel in Agents ([#30069](https://github.com/n8n-io/n8n/issues/30069)) ([f87094c](https://github.com/n8n-io/n8n/commit/f87094cf6e5efe7c89ef16c4253525091479b356))
* **editor:** Disable chat during interactive agent choices ([#30111](https://github.com/n8n-io/n8n/issues/30111)) ([8171cf0](https://github.com/n8n-io/n8n/commit/8171cf0b32ee5aa74dd240bb8f99a3250e428217))
* **editor:** Fix Agents styling issues from merge regression ([#30032](https://github.com/n8n-io/n8n/issues/30032)) ([478d499](https://github.com/n8n-io/n8n/commit/478d4998a8055a3d5f81b93120d67282546f125a))
* **editor:** Fix collapse/expand for Chat sidebar ([#29378](https://github.com/n8n-io/n8n/issues/29378)) ([ee847d1](https://github.com/n8n-io/n8n/commit/ee847d1624636914323b8b06f145ae811101528f))
* **editor:** Improve sidebar new resource menu UX ([#29597](https://github.com/n8n-io/n8n/issues/29597)) ([d5af542](https://github.com/n8n-io/n8n/commit/d5af542f254ba4846f3f393404e24bc5ec998283))
* **editor:** Make sure trimmed placeholder never reaches backend ([#29842](https://github.com/n8n-io/n8n/issues/29842)) ([f7c7acc](https://github.com/n8n-io/n8n/commit/f7c7acc2441481235d81a38ea14ed637546d3b40))
* **editor:** Match input height with mode selector in resource locator ([#30075](https://github.com/n8n-io/n8n/issues/30075)) ([277431b](https://github.com/n8n-io/n8n/commit/277431b88b195d92a32e35a7df7f8df907d9cb44))
* **editor:** Polish encryption keys settings page ([#30008](https://github.com/n8n-io/n8n/issues/30008)) ([5cbd2dd](https://github.com/n8n-io/n8n/commit/5cbd2dd1e9a66cb1d00d89191395f2b417c7a08b))
* **editor:** Preserve decimal suffix when duplicating a node ([#29541](https://github.com/n8n-io/n8n/issues/29541)) ([08a36d7](https://github.com/n8n-io/n8n/commit/08a36d7515eda29acd6c5e03f7968d4896465b3d))
* **editor:** Refresh node icon when diff sidebar selection changes ([#29816](https://github.com/n8n-io/n8n/issues/29816)) ([ff41613](https://github.com/n8n-io/n8n/commit/ff41613533980f8f2a0ff7baef5fd2a63d981636))
* **editor:** Rename canvas header dropdown action to Description ([#29719](https://github.com/n8n-io/n8n/issues/29719)) ([49e7b05](https://github.com/n8n-io/n8n/commit/49e7b056b4a21b6341ce1811a597476d37dfa42f))
* **editor:** Rename encryption keys "Type" column to "Status" ([#29966](https://github.com/n8n-io/n8n/issues/29966)) ([e71afed](https://github.com/n8n-io/n8n/commit/e71afedfab84b3b7b88fe9c4e2a36cd31ac6206b))
* **editor:** Render tooltips above popovers ([#29997](https://github.com/n8n-io/n8n/issues/29997)) ([ba5b3d1](https://github.com/n8n-io/n8n/commit/ba5b3d13b116d8e055fe3a4dce1b5349545ff540))
* **editor:** Resolve expressions in 'Go to Sub-workflow' navigation ([#29843](https://github.com/n8n-io/n8n/issues/29843)) ([d6bae35](https://github.com/n8n-io/n8n/commit/d6bae35e8f8f0399cd722606d911ae2c67b60431))
* Fix 15 security issues in fast-xml-builder, basic-ftp, fast-uri and 5 more ([#30169](https://github.com/n8n-io/n8n/issues/30169)) ([267fe49](https://github.com/n8n-io/n8n/commit/267fe49d51b7b8bcc80489b0f9f1a585986bc525))
* **Git Node:** Restore Clone and other operations on simple-git 3.36+ ([#30223](https://github.com/n8n-io/n8n/issues/30223)) ([a8aa955](https://github.com/n8n-io/n8n/commit/a8aa95551e5950fd1920c2cce21cd2739b464266))
* **Google Chat Node:** Clarify message resource name field ([#29964](https://github.com/n8n-io/n8n/issues/29964)) ([55df7cb](https://github.com/n8n-io/n8n/commit/55df7cbd0619e483e7e02207bc5084c715dcb53a))
* **Google Sheets Node:** Reduce duplicate API calls in append operation to avoid quota limits ([#29444](https://github.com/n8n-io/n8n/issues/29444)) ([d63e1ae](https://github.com/n8n-io/n8n/commit/d63e1ae84e767df33c1fc394f646e8ca093aa4a3))
* Handle IMAP fetch errors to prevent instance crash and stuck workflows ([#29469](https://github.com/n8n-io/n8n/issues/29469)) ([46d52ff](https://github.com/n8n-io/n8n/commit/46d52ffc7e719f17db56c433ee97a0b48861ba36))
* **HTTP Request Node:** Validate URL type in older node versions ([#29886](https://github.com/n8n-io/n8n/issues/29886)) ([29a864c](https://github.com/n8n-io/n8n/commit/29a864ca9bcd88e82cf5f998c9ea36d2f81a5dee))
* **MongoDB Node:** Resolve collection parameter per item in write operations ([#29956](https://github.com/n8n-io/n8n/issues/29956)) ([582b6ae](https://github.com/n8n-io/n8n/commit/582b6ae9eaaef6a616233e9bd4eda7230c36eb0a))
* **Notion Node:** Paginate Get Many operations beyond 100-item API cap ([#29690](https://github.com/n8n-io/n8n/issues/29690)) ([d318bc1](https://github.com/n8n-io/n8n/commit/d318bc1e330eeb92d84bc35a2ad9cf6931eccfdf))
* **Notion Node:** Serialize staticData as ISO string in NotionTrigger ([#29688](https://github.com/n8n-io/n8n/issues/29688)) ([d2e1eb3](https://github.com/n8n-io/n8n/commit/d2e1eb30f15c1e2380b815f4d1f62b2b98b23e9a))
* **Notion Node:** Update UI URLs from notion.so to notion.com ahead of domain migration ([#29861](https://github.com/n8n-io/n8n/issues/29861)) ([3593131](https://github.com/n8n-io/n8n/commit/35931319b5b987b7cdd7104accea407fd5390582))
* **Oracle DB Node:** Handle the test failures ([#28341](https://github.com/n8n-io/n8n/issues/28341)) ([0697562](https://github.com/n8n-io/n8n/commit/0697562ac9f1507ca0230d02f462889259a5bdcf))
* Restore broken stdlib calls in Python Code node ([#29776](https://github.com/n8n-io/n8n/issues/29776)) ([a786476](https://github.com/n8n-io/n8n/commit/a7864762ca656c8e636df1ea33750dff604b60ab))
* **RSS Feed Read Node:** Respect proxy settings ([#30059](https://github.com/n8n-io/n8n/issues/30059)) ([2e046d5](https://github.com/n8n-io/n8n/commit/2e046d5b7f2ec4a6fbf00107ee088239f87ce8c5))
* **Salesforce Node:** Fix trigger not firing on repeated record updates ([#29107](https://github.com/n8n-io/n8n/issues/29107)) ([f871d44](https://github.com/n8n-io/n8n/commit/f871d44cabc95fb102af8ba1a9e5d2e314205297))
* **Schedule Node:** Fix hourly intervals that don't divide evenly into 24h ([#29778](https://github.com/n8n-io/n8n/issues/29778)) ([1a22c76](https://github.com/n8n-io/n8n/commit/1a22c762703bed75a18de868a7bfb7c60eacc516))
* **Snowflake Node:** Fix issue with Insert and Update operations not working ([#29339](https://github.com/n8n-io/n8n/issues/29339)) ([4c369e8](https://github.com/n8n-io/n8n/commit/4c369e83f26450395a5a28b6c39a04b2c7650f1f))
* **Supabase Node:** Don't display RPCs in an RLC for the table ([#28146](https://github.com/n8n-io/n8n/issues/28146)) ([78aa0e7](https://github.com/n8n-io/n8n/commit/78aa0e70f21df2533a494c02a3e35ca3ab6ca7b0))
* **Wait Node:** Resolve expressions inside Custom HTML form fields ([#30060](https://github.com/n8n-io/n8n/issues/30060)) ([7c1a771](https://github.com/n8n-io/n8n/commit/7c1a77154ccf1a5f2a11da3cdf0949b2883c85fb))
* **YouTube Node:** Fix misspelled "unlisted" privacy status value in Video Update operation ([#30203](https://github.com/n8n-io/n8n/issues/30203)) ([96b018d](https://github.com/n8n-io/n8n/commit/96b018d3569623e1696a28981b24120a3ceb46d0))
### Features
* **Acuity Scheduling Trigger Node:** Add webhook request verification ([#29261](https://github.com/n8n-io/n8n/issues/29261)) ([da41470](https://github.com/n8n-io/n8n/commit/da41470311a03a15beb5d7361c0385b7dd9acc12))
* Add fully dynamic disclaimer to Quick Connect offer ([#29852](https://github.com/n8n-io/n8n/issues/29852)) ([b6127d8](https://github.com/n8n-io/n8n/commit/b6127d8722ff1bddd9eb5786a6cbd90ce2f98ac1))
* **ai-builder:** Add per-PR eval regression detection vs LangSmith baseline ([#29456](https://github.com/n8n-io/n8n/issues/29456)) ([bbe3e2d](https://github.com/n8n-io/n8n/commit/bbe3e2d1487e06df1e58057ec8c47edb5ad19aa7))
* **ai-builder:** Guarantee user-visible output on terminal states ([#29636](https://github.com/n8n-io/n8n/issues/29636)) ([4d9e624](https://github.com/n8n-io/n8n/commit/4d9e624b4113d06a4cc7a632aed357806349abcb))
* **Asana Trigger Node:** Add webhook request verification ([#29258](https://github.com/n8n-io/n8n/issues/29258)) ([94e4033](https://github.com/n8n-io/n8n/commit/94e403300b44d2f25f4d88dd3d9d1300adfea3bc))
* **Cal Trigger Node:** Add webhook request verification ([#29484](https://github.com/n8n-io/n8n/issues/29484)) ([3276edc](https://github.com/n8n-io/n8n/commit/3276edce10dfc7e59aa12e43fd7fc566f91723c4))
* **Calendly Trigger Node:** Add webhook request verification ([#29482](https://github.com/n8n-io/n8n/issues/29482)) ([e929f9f](https://github.com/n8n-io/n8n/commit/e929f9fbe751742da7f27658ded1ff0101af19d2))
* **core:** Accept merge.input(n) inside ifElse/switch branch targets in workflow-sdk ([#29716](https://github.com/n8n-io/n8n/issues/29716)) ([34f2107](https://github.com/n8n-io/n8n/commit/34f2107071478591a1c98b65576262c40408a157))
* **core:** Add flag to import workflow cli to activate workflow on import ([#29770](https://github.com/n8n-io/n8n/issues/29770)) ([283071e](https://github.com/n8n-io/n8n/commit/283071e6114fd8e8b5063e1ba38daf158bd762d2))
* **core:** Add IP rate limiting to dynamic credential authentication endpoints ([#30199](https://github.com/n8n-io/n8n/issues/30199)) ([515ae7c](https://github.com/n8n-io/n8n/commit/515ae7ced4b109880306788cb16977c15de92279))
* **core:** Add MCP tool to list credentials ([#29438](https://github.com/n8n-io/n8n/issues/29438)) ([d6cc3be](https://github.com/n8n-io/n8n/commit/d6cc3bedd1c4e7a2849eb5cf2acf538fb3a8f3da))
* **core:** Add multi-config evaluations backend ([#29784](https://github.com/n8n-io/n8n/issues/29784)) ([8116e0a](https://github.com/n8n-io/n8n/commit/8116e0a4858044712e45c078e06e0a36103d141c))
* **core:** Add n8n-object-validation ESLint rule for community nodes ([#29698](https://github.com/n8n-io/n8n/issues/29698)) ([701f9a4](https://github.com/n8n-io/n8n/commit/701f9a462773c204a6dc8bd15c533f9c07cd6e08))
* **core:** Add no-template-placeholders ESLint rule for community nodes ([#29796](https://github.com/n8n-io/n8n/issues/29796)) ([c4056b2](https://github.com/n8n-io/n8n/commit/c4056b255edd4420fde6cb5e1028b61f10b2bcf7))
* **core:** Add observational memory storage foundation ([#29814](https://github.com/n8n-io/n8n/issues/29814)) ([be4ef22](https://github.com/n8n-io/n8n/commit/be4ef225336166937a8847c2f2615bfd29e40765))
* **core:** Define community packages with environment variables ([#29961](https://github.com/n8n-io/n8n/issues/29961)) ([730c3e1](https://github.com/n8n-io/n8n/commit/730c3e12a55a38cdbe9090eabef508cd56d67a9e))
* **core:** Generate service-specific OAuth2 credentials for dedicated MCP tools ([#29884](https://github.com/n8n-io/n8n/issues/29884)) ([8617067](https://github.com/n8n-io/n8n/commit/86170674b72acc16d781eafd08cd762c55a7672f))
* **core:** Server-side pagination, sorting, and filtering for encryption keys ([#29708](https://github.com/n8n-io/n8n/issues/29708)) ([9afbe13](https://github.com/n8n-io/n8n/commit/9afbe13b81f00f0ea7730541b4909e31b1080249))
* **core:** Transform MCP server configs into dedicated MCP tools ([#29493](https://github.com/n8n-io/n8n/issues/29493)) ([4dce41f](https://github.com/n8n-io/n8n/commit/4dce41f79573f864fde16df622c028134d743f03))
* **core:** Use McpManagerClient and enforce whether MCP server connections are allowed ([#29694](https://github.com/n8n-io/n8n/issues/29694)) ([8235474](https://github.com/n8n-io/n8n/commit/82354742d348850d8cb6efc6ffe490c53ff0a8a0))
* **Customer.io Trigger Node:** Add webhook request verification ([#29480](https://github.com/n8n-io/n8n/issues/29480)) ([a772016](https://github.com/n8n-io/n8n/commit/a772016e36a87d1fbbacbee59ebcd80dbe3b9150))
* **editor:** Add envFeatureFlag and copyButton property options ([#29733](https://github.com/n8n-io/n8n/issues/29733)) ([75053fe](https://github.com/n8n-io/n8n/commit/75053fec9373076abfba3db01a967f54f8274e83))
* **editor:** Cap eval concurrency slider at admin-set limit ([#29807](https://github.com/n8n-io/n8n/issues/29807)) ([6232de4](https://github.com/n8n-io/n8n/commit/6232de4d477ffa56e0082d87a5b63d1c9ef00d4c))
* **editor:** Eval run detail loading + error states (TRUST-70 follow-up) ([#29817](https://github.com/n8n-io/n8n/issues/29817)) ([6f9b99a](https://github.com/n8n-io/n8n/commit/6f9b99a3cf1207ece10a6bd6239a5005c6a10540))
* **editor:** Redesign evaluation run detail page ([#29592](https://github.com/n8n-io/n8n/issues/29592)) ([9014bae](https://github.com/n8n-io/n8n/commit/9014baea7ea952aaf782c53bce03d3a8f0ae5ddf))
* **editor:** Show locked state and permission notice on data redaction workflow settings ([#30022](https://github.com/n8n-io/n8n/issues/30022)) ([7635131](https://github.com/n8n-io/n8n/commit/7635131bd396252f51d29e7407099eafa92a304f))
* **Figma Trigger Node:** Add OAuth2 authentication support ([#30079](https://github.com/n8n-io/n8n/issues/30079)) ([e3e70d6](https://github.com/n8n-io/n8n/commit/e3e70d6068a3d543b29b1bd24682101ecb2e641f))
* **Figma Trigger Node:** Add webhook request verification ([#29262](https://github.com/n8n-io/n8n/issues/29262)) ([910822f](https://github.com/n8n-io/n8n/commit/910822fb0951f6ead55fc000e7743a8ee13e82e9))
* **Formstack Trigger Node:** Add webhook request verification ([#29495](https://github.com/n8n-io/n8n/issues/29495)) ([4e28652](https://github.com/n8n-io/n8n/commit/4e2865206c72833d9fe585ed941ecc83c1bec699))
* **GitLab Trigger Node:** Add webhook request verification ([#29260](https://github.com/n8n-io/n8n/issues/29260)) ([fbf89bd](https://github.com/n8n-io/n8n/commit/fbf89bde1164a19365fe4418405ddec7108543d9))
* **Jira Node:** Add OAuth2 (3LO) support ([#29414](https://github.com/n8n-io/n8n/issues/29414)) ([4d5bafc](https://github.com/n8n-io/n8n/commit/4d5bafc146125fa22d05cf924c5e68bc51263722))
* **MailerLite Trigger Node:** Add webhook request verification ([#29491](https://github.com/n8n-io/n8n/issues/29491)) ([12b7cc6](https://github.com/n8n-io/n8n/commit/12b7cc67395bf1991235ae0f00739d9f2803cb9c))
* **Mautic Trigger Node:** Add webhook request verification ([#29658](https://github.com/n8n-io/n8n/issues/29658)) ([eaadf19](https://github.com/n8n-io/n8n/commit/eaadf190b89f21f74bc3a25b16803576f91e9618))
* **Microsoft Outlook Node:** Add location and attendees fields to calendar events ([#29844](https://github.com/n8n-io/n8n/issues/29844)) ([2e21c5f](https://github.com/n8n-io/n8n/commit/2e21c5fcf83a2fc86659c7464b2bc6672230389f))
* **Microsoft Outlook Node:** Add support for recurring event instances ([#29802](https://github.com/n8n-io/n8n/issues/29802)) ([dab3653](https://github.com/n8n-io/n8n/commit/dab3653f8016b7f9187559658ea6ef58220df2d1))
* **Onfleet Trigger Node:** Add webhook request verification ([#29485](https://github.com/n8n-io/n8n/issues/29485)) ([133a5aa](https://github.com/n8n-io/n8n/commit/133a5aa0adae69f86f1603bd9ad85c852c0ccdf5))
* **Strava Node:** Allow custom OAuth2 scopes ([#29972](https://github.com/n8n-io/n8n/issues/29972)) ([5abcae6](https://github.com/n8n-io/n8n/commit/5abcae686cf1b64e06bbbd6f62b6871bc4feec56))
* **Taiga Trigger Node:** Add webhook request verification ([#29487](https://github.com/n8n-io/n8n/issues/29487)) ([3c97c49](https://github.com/n8n-io/n8n/commit/3c97c49d63c824c2a3b4284beecf8957c44c1c16))
* **Trello Trigger Node:** Add webhook request verification ([#29252](https://github.com/n8n-io/n8n/issues/29252)) ([8f1f42d](https://github.com/n8n-io/n8n/commit/8f1f42d18056ba51e450ba90ba3be65cbf9745aa))
* **Twilio Trigger Node:** Add webhook request verification ([#29259](https://github.com/n8n-io/n8n/issues/29259)) ([acc9643](https://github.com/n8n-io/n8n/commit/acc964381189aaacbeb584a16c0155ba6f96ffa1))
# [2.20.0](https://github.com/n8n-io/n8n/compare/n8n@2.19.0...n8n@2.20.0) (2026-05-05)

View File

@ -1,6 +1,6 @@
{
"name": "n8n-monorepo",
"version": "2.20.0",
"version": "2.21.0",
"private": true,
"engines": {
"node": ">=22.16",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/agents",
"version": "0.6.0",
"version": "0.7.0",
"description": "AI agent SDK for n8n's code-first execution engine",
"main": "dist/index.js",
"module": "dist/index.js",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/ai-node-sdk",
"version": "0.11.0",
"version": "0.12.0",
"description": "SDK for building AI nodes in n8n",
"types": "dist/esm/index.d.ts",
"module": "dist/esm/index.js",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/ai-utilities",
"version": "0.14.0",
"version": "0.15.0",
"description": "Utilities for building AI nodes in n8n",
"types": "dist/esm/index.d.ts",
"module": "dist/esm/index.js",

View File

@ -3,6 +3,154 @@
exports[`createVectorStoreNode retrieve mode supplies vector store as data 1`] = `
{
"builderHint": {
"extraTypeDefContent": [
{
"content": "Sits on the main flow — pipe the documents you want to embed into this node. Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\` and \`documentLoader\`. If the goal is letting an LLM query the store, use \`mode: 'retrieve-as-tool'\` instead.
<patterns>
<pattern title="insert mode — upsert documents (generic, works for any vectorStore* node)">
// Substitute the type literal and provider-specific parameters (e.g. pineconeIndex,
// qdrantCollection, supabaseTableName) — see the rest of this file for the exact shape.
const store = vectorStore({
type: '@n8n/n8n-nodes-langchain.vectorStoreXxx',
config: {
name: 'Knowledge Base',
parameters: {
mode: 'insert',
// ...provider-specific parameters
},
subnodes: { embedding: embeddingsOpenAi, documentLoader: defaultDataLoader }
}
});
</pattern>
</patterns>",
"displayOptions": {
"show": {
"mode": [
"insert",
],
},
},
},
{
"content": "Canonical RAG mode — declare with the \`tool({...})\` factory (NOT \`vectorStore\`) and plug into an AI Agent's \`subnodes.tools\`. Required subnodes: \`embedding\`. Set \`toolDescription\` so the agent knows when to call it.
<patterns>
<pattern title="retrieve-as-tool mode — RAG via AI Agent (generic, works for any vectorStore* node)">
// Substitute the type literal and provider-specific parameters — see the rest of this file
// for the exact shape (e.g. pineconeIndex, qdrantCollection, supabaseTableName).
const knowledgeBase = tool({
type: '@n8n/n8n-nodes-langchain.vectorStoreXxx',
config: {
name: 'Knowledge Base',
parameters: {
mode: 'retrieve-as-tool',
toolDescription: 'Search the product knowledge base',
// ...provider-specific parameters
},
subnodes: { embedding: embeddingsOpenAi }
}
});
const agent = node({
type: '@n8n/n8n-nodes-langchain.agent',
config: {
name: 'Support Agent',
parameters: { promptType: 'define', text: expr('{{ $json.question }}') },
subnodes: { model: openAiModel, tools: [knowledgeBase] }
}
});
</pattern>
</patterns>",
"displayOptions": {
"show": {
"mode": [
"retrieve-as-tool",
],
},
},
},
{
"content": "One-shot similarity search on the main flow using the \`prompt\` parameter. Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\`. For LLM-driven querying (RAG), use \`mode: 'retrieve-as-tool'\` instead.
<patterns>
<pattern title="load mode — one-shot similarity search (generic)">
// Substitute the type literal and provider-specific parameters — see the rest of this file.
const lookup = vectorStore({
type: '@n8n/n8n-nodes-langchain.vectorStoreXxx',
config: {
name: 'Knowledge Base',
parameters: {
mode: 'load',
prompt: expr('{{ $json.query }}'),
// ...provider-specific parameters
},
subnodes: { embedding: embeddingsOpenAi }
}
});
</pattern>
</patterns>",
"displayOptions": {
"show": {
"mode": [
"load",
],
},
},
},
{
"content": "Exposes the store as an \`ai_vectorStore\` subnode for another node (e.g. \`toolVectorStore\`). Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\`. For RAG with an AI Agent directly, prefer \`mode: 'retrieve-as-tool'\`.
<patterns>
<pattern title="retrieve mode — feed another node as a subnode (generic)">
// Substitute the type literal and provider-specific parameters — see the rest of this file.
const store = vectorStore({
type: '@n8n/n8n-nodes-langchain.vectorStoreXxx',
config: {
name: 'Knowledge Base',
parameters: { mode: 'retrieve' /* + provider-specific parameters */ },
subnodes: { embedding: embeddingsOpenAi }
}
});
const retrieverTool = tool({
type: '@n8n/n8n-nodes-langchain.toolVectorStore',
config: {
name: 'KB Retriever',
parameters: { description: 'Search the product knowledge base' },
subnodes: { vectorStore: store, model: openAiModel }
}
});
</pattern>
</patterns>",
"displayOptions": {
"show": {
"mode": [
"retrieve",
],
},
},
},
{
"content": "Updates a single document by \`id\`. Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\`. Only available on stores whose \`operationModes\` enables it — most providers omit this mode.
<patterns>
<pattern title="update mode — update document by ID (generic)">
// Substitute the type literal and provider-specific parameters — see the rest of this file.
const store = vectorStore({
type: '@n8n/n8n-nodes-langchain.vectorStoreXxx',
config: {
name: 'Knowledge Base',
parameters: { mode: 'update', id: expr('{{ $json.docId }}') },
subnodes: { embedding: embeddingsOpenAi }
}
});
</pattern>
</patterns>",
"displayOptions": {
"show": {
"mode": [
"update",
],
},
},
},
],
"inputs": {
"ai_document": {
"displayOptions": {
@ -66,6 +214,7 @@ exports[`createVectorStoreNode retrieve mode supplies vector store as data 1`] =
},
},
},
"searchHint": "Pick mode by where data flows: \`insert\` upserts documents into the store on the main flow; \`load\` runs a one-shot similarity search on the main flow; \`retrieve-as-tool\` is the canonical RAG mode — plug into an AI Agent's \`subnodes.tools\`; \`retrieve\` exposes the store as a subnode for another node's \`subnodes.vectorStore\`; \`update\` updates a single document by ID.",
},
"codex": {
"categories": [

View File

@ -10,6 +10,10 @@ export const DEFAULT_OPERATION_MODES: NodeOperationMode[] = [
'retrieve-as-tool',
];
// `mode` is a discriminator field, so per-option `builderHint`s here would never
// surface in the generated `.d.ts` (discriminator props are dropped from narrowed
// types). Per-mode guidance lives as node-level `extraTypeDefContent` variations
// in `createVectorStoreNode.ts`, which the codegen routes per-combo.
export const OPERATION_MODE_DESCRIPTIONS: INodePropertyOptions[] = [
{
name: 'Get Many',

View File

@ -77,7 +77,127 @@ export const createVectorStoreNode = <T extends VectorStore = VectorStore>(
},
},
builderHint: {
searchHint:
"Pick mode by where data flows: `insert` upserts documents into the store on the main flow; `load` runs a one-shot similarity search on the main flow; `retrieve-as-tool` is the canonical RAG mode — plug into an AI Agent's `subnodes.tools`; `retrieve` exposes the store as a subnode for another node's `subnodes.vectorStore`; `update` updates a single document by ID.",
...args.meta.builderHint,
extraTypeDefContent: [
{
displayOptions: { show: { mode: ['insert'] } },
content: `Sits on the main flow — pipe the documents you want to embed into this node. Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\` and \`documentLoader\`. If the goal is letting an LLM query the store, use \`mode: 'retrieve-as-tool'\` instead.
<patterns>
<pattern title="insert mode — upsert documents (generic, works for any vectorStore* node)">
// Substitute the type literal and provider-specific parameters (e.g. pineconeIndex,
// qdrantCollection, supabaseTableName) — see the rest of this file for the exact shape.
const store = vectorStore({
type: '@n8n/n8n-nodes-langchain.vectorStoreXxx',
config: {
name: 'Knowledge Base',
parameters: {
mode: 'insert',
// ...provider-specific parameters
},
subnodes: { embedding: embeddingsOpenAi, documentLoader: defaultDataLoader }
}
});
</pattern>
</patterns>`,
},
{
displayOptions: { show: { mode: ['retrieve-as-tool'] } },
content: `Canonical RAG mode — declare with the \`tool({...})\` factory (NOT \`vectorStore\`) and plug into an AI Agent's \`subnodes.tools\`. Required subnodes: \`embedding\`. Set \`toolDescription\` so the agent knows when to call it.
<patterns>
<pattern title="retrieve-as-tool mode — RAG via AI Agent (generic, works for any vectorStore* node)">
// Substitute the type literal and provider-specific parameters — see the rest of this file
// for the exact shape (e.g. pineconeIndex, qdrantCollection, supabaseTableName).
const knowledgeBase = tool({
type: '@n8n/n8n-nodes-langchain.vectorStoreXxx',
config: {
name: 'Knowledge Base',
parameters: {
mode: 'retrieve-as-tool',
toolDescription: 'Search the product knowledge base',
// ...provider-specific parameters
},
subnodes: { embedding: embeddingsOpenAi }
}
});
const agent = node({
type: '@n8n/n8n-nodes-langchain.agent',
config: {
name: 'Support Agent',
parameters: { promptType: 'define', text: expr('{{ $json.question }}') },
subnodes: { model: openAiModel, tools: [knowledgeBase] }
}
});
</pattern>
</patterns>`,
},
{
displayOptions: { show: { mode: ['load'] } },
content: `One-shot similarity search on the main flow using the \`prompt\` parameter. Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\`. For LLM-driven querying (RAG), use \`mode: 'retrieve-as-tool'\` instead.
<patterns>
<pattern title="load mode — one-shot similarity search (generic)">
// Substitute the type literal and provider-specific parameters — see the rest of this file.
const lookup = vectorStore({
type: '@n8n/n8n-nodes-langchain.vectorStoreXxx',
config: {
name: 'Knowledge Base',
parameters: {
mode: 'load',
prompt: expr('{{ $json.query }}'),
// ...provider-specific parameters
},
subnodes: { embedding: embeddingsOpenAi }
}
});
</pattern>
</patterns>`,
},
{
displayOptions: { show: { mode: ['retrieve'] } },
content: `Exposes the store as an \`ai_vectorStore\` subnode for another node (e.g. \`toolVectorStore\`). Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\`. For RAG with an AI Agent directly, prefer \`mode: 'retrieve-as-tool'\`.
<patterns>
<pattern title="retrieve mode — feed another node as a subnode (generic)">
// Substitute the type literal and provider-specific parameters — see the rest of this file.
const store = vectorStore({
type: '@n8n/n8n-nodes-langchain.vectorStoreXxx',
config: {
name: 'Knowledge Base',
parameters: { mode: 'retrieve' /* + provider-specific parameters */ },
subnodes: { embedding: embeddingsOpenAi }
}
});
const retrieverTool = tool({
type: '@n8n/n8n-nodes-langchain.toolVectorStore',
config: {
name: 'KB Retriever',
parameters: { description: 'Search the product knowledge base' },
subnodes: { vectorStore: store, model: openAiModel }
}
});
</pattern>
</patterns>`,
},
{
displayOptions: { show: { mode: ['update'] } },
content: `Updates a single document by \`id\`. Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\`. Only available on stores whose \`operationModes\` enables it — most providers omit this mode.
<patterns>
<pattern title="update mode — update document by ID (generic)">
// Substitute the type literal and provider-specific parameters — see the rest of this file.
const store = vectorStore({
type: '@n8n/n8n-nodes-langchain.vectorStoreXxx',
config: {
name: 'Knowledge Base',
parameters: { mode: 'update', id: expr('{{ $json.docId }}') },
subnodes: { embedding: embeddingsOpenAi }
}
});
</pattern>
</patterns>`,
},
],
inputs: {
ai_embedding: { required: true },
ai_document: {

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/ai-workflow-builder",
"version": "1.20.0",
"version": "1.21.0",
"scripts": {
"clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit",

View File

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

View File

@ -415,6 +415,7 @@ export type {
InstanceAiEvalInterceptedRequest,
InstanceAiEvalNodeResult,
InstanceAiEvalMockHints,
InstanceAiEvalMockedCredential,
InstanceAiEvalExecutionResult,
InstanceAiEvalToolCall,
InstanceAiEvalToolResult,

View File

@ -1103,12 +1103,19 @@ export interface InstanceAiEvalMockHints {
bypassPinData: Record<string, Array<{ json: Record<string, unknown> }>>;
}
export interface InstanceAiEvalMockedCredential {
nodeName: string;
credentialType: string;
credentialId?: string;
}
export interface InstanceAiEvalExecutionResult {
executionId: string;
success: boolean;
nodeResults: Record<string, InstanceAiEvalNodeResult>;
errors: string[];
hints: InstanceAiEvalMockHints;
mockedCredentials: InstanceAiEvalMockedCredential[];
}
export class InstanceAiEvalExecutionRequest extends Z.class({

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/backend-common",
"version": "1.20.0",
"version": "1.21.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -44,6 +44,7 @@ describe('eligibleModules', () => {
'instance-version-history',
'encryption-key-manager',
'oauth-jwe',
'inbound-secrets',
]);
});
@ -74,6 +75,7 @@ describe('eligibleModules', () => {
'instance-version-history',
'encryption-key-manager',
'oauth-jwe',
'inbound-secrets',
'instance-ai',
]);
});

View File

@ -55,6 +55,7 @@ export class ModuleRegistry {
'instance-version-history',
'encryption-key-manager',
'oauth-jwe',
'inbound-secrets',
];
private readonly activeModules: string[] = [];

View File

@ -30,6 +30,7 @@ export const MODULE_NAMES = [
'instance-version-history',
'encryption-key-manager',
'oauth-jwe',
'inbound-secrets',
] as const;
export type ModuleName = (typeof MODULE_NAMES)[number];

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/backend-test-utils",
"version": "1.20.0",
"version": "1.21.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/client-oauth2",
"version": "1.4.0",
"version": "1.5.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/computer-use",
"version": "0.5.0",
"version": "0.6.0",
"description": "Local AI gateway for n8n AI Assistant — filesystem, shell, screenshots, mouse/keyboard, and browser automation",
"publishConfig": {
"bin": {

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/create-node",
"version": "0.29.0",
"version": "0.30.0",
"description": "Official CLI to create new community nodes for n8n",
"bin": {
"create-node": "bin/create-node.cjs"

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/db",
"version": "1.20.0",
"version": "1.21.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/decorators",
"version": "1.20.0",
"version": "1.21.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/engine",
"version": "0.1.0",
"version": "0.2.0",
"description": "n8n workflow execution engine (v2)",
"scripts": {
"clean": "rimraf dist .turbo compiled",

View File

@ -1,7 +1,7 @@
{
"name": "@n8n/eslint-plugin-community-nodes",
"type": "module",
"version": "0.15.0",
"version": "0.16.0",
"main": "./dist/plugin.js",
"types": "./dist/plugin.d.ts",
"exports": {

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/expression-runtime",
"version": "0.12.0",
"version": "0.13.0",
"description": "Secure, isolated expression evaluation runtime for n8n",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/imap",
"version": "0.18.0",
"version": "0.19.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -10,6 +10,8 @@ This is a test environment. No real credentials or API connections exist. ALL HT
IMPORTANT: Nodes receiving mock responses instead of real API responses is EXPECTED. Missing or mock credentials is EXPECTED. Don't flag these as issues they are the testing mechanism itself.
Credential ID values in the workflow JSON (real, placeholder strings, or stale references) never cause execution failures. When a credential ID cannot be resolved, the framework substitutes a mock credential and execution proceeds. Do not cite credential ID values as a root cause of failure under any circumstance.
## What you receive
The verification artifact contains:
@ -53,6 +55,7 @@ NOT failure categories:
- Nodes using mock credentials instead of real ones this is expected
- HTTP responses coming from the LLM mock instead of real APIs this is expected
- Trigger nodes having pinned/generated data instead of real events this is expected
- Placeholder or unresolved credential ID values in node configs these are auto-substituted by the framework and never the cause of a failure
## Output format

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/instance-ai",
"version": "1.5.0",
"version": "1.6.0",
"scripts": {
"clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit",
@ -39,7 +39,9 @@
},
"typesVersions": {
"*": {
"parsers": ["dist/parsers/index.d.ts"]
"parsers": [
"dist/parsers/index.d.ts"
]
}
},
"dependencies": {

View File

@ -75,7 +75,7 @@ function collectAgents(): AgentEntry[] {
researchMode: true,
webhookBaseUrl: 'https://your-instance.example.com',
filesystemAccess: true,
localGateway: { status: 'connected' },
localGateway: { status: 'connected', capabilities: ['filesystem', 'browser'] },
toolSearchEnabled: true,
licenseHints: ['<sample license hint — replace with real hint at runtime>'],
timeZone: 'UTC',
@ -101,10 +101,7 @@ function collectAgents(): AgentEntry[] {
"localGateway disconnected with filesystem + browser capabilities — renders the 'install Computer Use' pitch and 'Browser Automation (Unavailable)' note",
body: getSystemPrompt({
webhookBaseUrl: 'https://your-instance.example.com',
localGateway: {
status: 'disconnected',
capabilities: ['filesystem', 'browser'],
},
localGateway: { status: 'disconnected' },
browserAvailable: false,
}),
},
@ -115,7 +112,7 @@ function collectAgents(): AgentEntry[] {
body: getSystemPrompt({
webhookBaseUrl: 'https://your-instance.example.com',
filesystemAccess: true,
localGateway: { status: 'connected' },
localGateway: { status: 'connected', capabilities: ['filesystem'] },
browserAvailable: false,
}),
},

View File

@ -50,6 +50,12 @@ const agentNode = makeNode({
ai_memory: { required: false },
ai_tool: { required: false, displayOptions: { show: { hasTools: [true] } } },
},
extraTypeDefContent: [
{
content:
'<patterns>\n<pattern title="basic">\nconst agent = node({ ... })\n</pattern>\n</patterns>',
},
],
},
});
@ -171,6 +177,21 @@ describe('NodeSearchEngine', () => {
expect(agentResult?.builderHintMessage).toBe('Use an AI Agent for autonomous task execution');
});
it('should NOT surface builderHint.extraTypeDefContent in search results', () => {
const results = engine.searchByName('AI Agent');
const agentResult = results.find((r) => r.name === '@n8n/n8n-nodes-langchain.agent');
expect(agentResult).toBeDefined();
// Result type has no extraTypeDefContent field; assert it never leaks in
// via untyped assignment either.
expect(agentResult).not.toHaveProperty('extraTypeDefContent');
expect(JSON.stringify(agentResult)).not.toContain('<patterns>');
expect(JSON.stringify(agentResult)).not.toContain('basic');
// The formatted XML the LLM actually sees must not contain the example.
const xml = engine.formatResult(agentResult!);
expect(xml).not.toContain('<patterns>');
expect(xml).not.toContain('const agent = node');
});
it('should include subnode requirements when present', () => {
const results = engine.searchByName('AI Agent');
const agentResult = results.find((r) => r.name === '@n8n/n8n-nodes-langchain.agent');

View File

@ -67,6 +67,17 @@ export interface SearchableNodeType {
builderHint?: {
message?: string;
inputs?: BuilderHintInputs;
/**
* Multi-line content variations emitted into generated `.d.ts` only;
* intentionally ignored by the search engine to keep results lightweight.
*/
extraTypeDefContent?: Array<{
content: string;
displayOptions?: {
show?: Record<string, unknown[]>;
hide?: Record<string, unknown[]>;
};
}>;
};
}

View File

@ -6,11 +6,6 @@
* - createSandboxBuilderAgentPrompt(): Sandbox-based builder with real files + tsc
*/
import {
AI_TOOL_PATTERNS,
CONNECTION_CHANGING_PARAMETERS,
BASELINE_FLOW_CONTROL,
} from '@n8n/workflow-sdk/prompts/node-selection';
import {
EXPRESSION_REFERENCE,
ADDITIONAL_FUNCTIONS,
@ -60,219 +55,12 @@ const NODE_CONFIGURATION_SAFETY_RULES = `## Node Configuration Safety Rules
- Use live \`nodes(action="explore-resources")\` for resource locator, list, and model fields when credentials are available.
- If a configuration is unclear after reading the definition, ask for clarification or use placeholders do not guess.`;
// The AI Agent subnode example uses `newCredential()` in both modes. In sandbox
// mode the submit runner preserves unresolved credential slots for
// `submit-workflow`, so the same outlet works there too.
function buildBuilderSpecificPatterns(): string {
const openAiCredExample = "newCredential('OpenAI')";
return `## Critical Patterns (Common Mistakes)
// Node-specific configuration examples used to live here. They have moved
// onto the nodes themselves as `@builderHint` annotations and `<patterns>...</patterns>`
// blocks in the generated `.d.ts` — fetch them on-demand via `nodes(action="type-definition")`.
const BUILDER_SPECIFIC_PATTERNS = `## Critical Patterns (Common Mistakes)
**Pay attention to @builderHint annotations in search results and type definitions** these provide critical guidance on how to correctly configure node parameters. Write them out as notes when reviewing they prevent common configuration mistakes.
### Self-check: conditional nodes and routing
After writing any workflow with IF, Switch, or Filter nodes, verify:
1. **Every \`conditions\` object has \`options\`, \`conditions\` array, and \`combinator\`** — missing any of these crashes the node at runtime.
2. **Switch uses \`rules.values\`** (not \`rules.rules\`) — the wrong key crashes during workflow loading.
3. **Each branch reaches the correct destination** trace the data flow from the condition through \`.onTrue()\`/\`.onFalse()\`/\`.onCase()\` to the target node. Verify the routing matches the user's requirements.
4. **Condition expressions reference the right fields** check that \`leftValue\` expressions use fields that actually exist in the upstream node's output.
5. **Merge nodes use the correct mode** \`append\` to concatenate items from branches, \`combineBySql\` or \`combineByPosition\` only when matching items across inputs. Wrong mode silently drops or duplicates data.
### AI Agent with Subnodes use factory functions in subnodes config
\`\`\`javascript
const chatTrigger = trigger({
type: '@n8n/n8n-nodes-langchain.chatTrigger',
version: 1.3,
config: {
name: 'Chat Trigger',
parameters: { public: false },
output: [{ sessionId: 'chat-session-id', chatInput: 'Hello' }]
}
});
const model = languageModel({
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
version: 1.3,
config: {
name: 'OpenAI Chat Model',
parameters: { model: { __rl: true, mode: 'list', value: 'gpt-5.4' } },
credentials: { openAiApi: ${openAiCredExample} }
}
});
const parser = outputParser({
type: '@n8n/n8n-nodes-langchain.outputParserStructured',
version: 1.3,
config: {
name: 'Output Parser',
parameters: {
schemaType: 'fromJson',
jsonSchemaExample: '{ "score": 75, "tier": "hot" }'
}
}
});
const memoryNode = memory({
type: '@n8n/n8n-nodes-langchain.memoryBufferWindow',
version: 1.3,
config: {
name: 'Conversation Memory',
parameters: {
sessionIdType: 'customKey',
sessionKey: nodeJson(chatTrigger, 'sessionId'),
contextWindowLength: 10
}
}
});
const agent = node({
type: '@n8n/n8n-nodes-langchain.agent',
version: 3.1,
config: {
name: 'AI Agent',
parameters: {
promptType: 'define',
text: '={{ $json.prompt }}',
hasOutputParser: true,
options: { systemMessage: 'You are an expert...' }
},
subnodes: { model: model, memory: memoryNode, outputParser: parser }
}
});
\`\`\`
WRONG: \`.to(agent, { connectionType: 'ai_languageModel' })\` — subnodes MUST be in the config object.
For values inside AI subnodes, use explicit references such as \`nodeJson(triggerNode, 'sessionId')\` instead of \`$json.sessionId\`. For Chat Trigger memory specifically, \`sessionIdType: 'fromInput'\` is also valid.
### Code Node
\`\`\`javascript
const codeNode = node({
type: 'n8n-nodes-base.code',
version: 2,
config: {
name: 'Process Data',
parameters: {
mode: 'runOnceForAllItems',
jsCode: \\\`
const items = $input.all();
return items.map(item => ({
json: { ...item.json, processed: true }
}));
\\\`.trim()
}
}
});
\`\`\`
### Data Table (built-in n8n storage)
\`\`\`javascript
const storeData = node({
type: 'n8n-nodes-base.dataTable',
version: 1.1,
config: {
name: 'Store Data',
parameters: {
resource: 'row',
operation: 'insert',
dataTableId: { __rl: true, mode: 'name', value: 'my-table' },
columns: {
mappingMode: 'defineBelow',
value: {
name: '={{ $json.name }}',
email: '={{ $json.email }}'
},
schema: [
{ id: 'name', displayName: 'name', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true },
{ id: 'email', displayName: 'email', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true }
]
}
}
}
});
\`\`\`
**Data Table rules**
- Row IDs are auto-generated by Data Tables. Do NOT create a custom \`id\` column and do NOT seed an \`id\` value on insert.
- To fetch many rows, use \`operation: 'get'\` with \`returnAll: true\`. Do NOT invent \`getAll\`.
- When filtering rows for update/delete, it is valid to match on the built-in row \`id\`, but that is not part of the user-defined table schema.
### Google Sheets Column Mapping
The \`columns\` parameter requires a schema object, never a string:
\`\`\`javascript
// autoMapInputData — maps $json fields to sheet columns automatically
columns: {
mappingMode: 'autoMapInputData',
value: {},
schema: [
{ id: 'Name', displayName: 'Name', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true },
{ id: 'Email', displayName: 'Email', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: false },
]
}
// defineBelow — explicit expression mapping
columns: {
mappingMode: 'defineBelow',
value: { name: '={{ $json.name }}', email: '={{ $json.email }}' },
schema: [
{ id: 'name', displayName: 'name', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true },
{ id: 'email', displayName: 'email', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true }
]
}
\`\`\`
WRONG: \`columns: 'autoMapInputData'\` — this is a string, not a schema object. Will fail validation.
### Parallel Branches + Merge
When multiple paths must converge, include the full downstream chain in EACH branch.
There is NO fan-in primitive shared nodes must be duplicated or use sub-workflows.
### Batch Processing splitInBatches with loop
\`\`\`javascript
const batch = node({
type: 'n8n-nodes-base.splitInBatches',
version: 3,
config: { name: 'Batch', parameters: { batchSize: 50 } }
});
// Connect: trigger -> batch -> processNode -> batch (loop back)
// The batch node automatically outputs to "done" when all items are processed.
\`\`\`
### Multiple Triggers
Independent entry points can feed into shared downstream nodes. Each trigger starts its own branch:
\`\`\`javascript
export default workflow('id', 'name')
.add(webhookTrigger).to(processNode).to(storeNode)
.add(scheduleTrigger).to(processNode);
\`\`\`
### Google Sheets documentId and sheetName (RLC fields)
These are Resource Locator fields that require the \`__rl\` object format:
\`\`\`typescript
// CORRECT — RLC object with discovered ID
documentId: { __rl: true, mode: 'id', value: '1abc123...' },
sheetName: { __rl: true, mode: 'name', value: 'Sheet1' },
// CORRECT — RLC with name-based lookup
documentId: { __rl: true, mode: 'name', value: 'Sales Pipeline' },
// WRONG — plain string
documentId: 'YOUR_SPREADSHEET_ID', // Not an RLC object
// WRONG — expr() wrapper
documentId: expr('{{ "spreadsheetId" }}'), // RLC fields don't use expressions
\`\`\`
Always use the IDs from \`nodes(action="explore-resources")\` results inside the RLC \`value\` field.
### AI Tool Connection Patterns
${AI_TOOL_PATTERNS}
### Connection-Changing Parameters
${CONNECTION_CHANGING_PARAMETERS}
### Baseline Flow Control Nodes
${BASELINE_FLOW_CONTROL}`;
}
const BUILDER_SPECIFIC_PATTERNS = buildBuilderSpecificPatterns();
**Pay attention to @builderHint annotations in search results and type definitions** they contain node-specific configuration rules and code examples. Read them carefully when configuring any node they prevent common mistakes.`;
// ── Composed SDK rules from shared + local sources ───────────────────────────
@ -340,11 +128,12 @@ ${PLACEHOLDERS_RULE}
## Mandatory Process
1. **Research**: If the workflow fits a known category (notification, chatbot, scheduling, data_transformation, etc.), call \`nodes(action="suggested")\` first for curated recommendations. Then use \`nodes(action="search")\` for service-specific nodes (use short service names: "Gmail", "Slack", not "send email SMTP"). The results include \`discriminators\` (available resources and operations) for nodes that need them. Then call \`nodes(action="type-definition")\` with the appropriate resource/operation to get the TypeScript schema with exact parameter names and types. **Pay attention to @builderHint annotations** in search results and type definitions — they prevent common configuration mistakes.
2. **Build**: Write TypeScript SDK code and call \`build-workflow\`. Follow the SDK patterns below exactly.
3. **Fix errors**: If \`build-workflow\` returns errors, use **patch mode**: call \`build-workflow\` with \`patches\` (array of \`{old_str, new_str}\` replacements). Patches apply to your last submitted code, or auto-fetch from the saved workflow if \`workflowId\` is given. Much faster than resending full code.
4. **Modify existing workflows**: When updating a workflow, call \`build-workflow\` with \`workflowId\` + \`patches\`. The tool fetches the current code and applies your patches. Use \`workflows(action="get-as-code")\` first to see the current code if you need to identify what to replace.
5. **Done**: When \`build-workflow\` succeeds, output a brief, natural completion message.
3. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch \`outputKey\` has a matching \`.onCase('<outputKey>')\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria.
4. **Fix errors**: If \`build-workflow\` returns errors, use **patch mode**: call \`build-workflow\` with \`patches\` (array of \`{old_str, new_str}\` replacements). Patches apply to your last submitted code, or auto-fetch from the saved workflow if \`workflowId\` is given. Much faster than resending full code.
5. **Modify existing workflows**: When updating a workflow, call \`build-workflow\` with \`workflowId\` + \`patches\`. The tool fetches the current code and applies your patches. Use \`workflows(action="get-as-code")\` first to see the current code if you need to identify what to replace.
6. **Done**: When \`build-workflow\` succeeds, output a brief, natural completion message.
Do NOT produce visible output until step 5. All reasoning happens internally.
Do NOT produce visible output until step 6. All reasoning happens internally.
## Credential Rules (tool mode)
- Use \`newCredential('Credential Name', 'credential-id')\` only when the user selected a specific existing credential or the workflow already has one.
@ -448,8 +237,8 @@ const fetchWeather = node({
name: 'Fetch Weather',
parameters: {
locationSelection: 'cityName',
cityName: '={{ $json.city }}',
format: '={{ $json.units }}'
cityName: expr('{{ $json.city }}'),
format: expr('{{ $json.units }}')
},
credentials: { openWeatherMapApi: { id: 'credId', name: 'OpenWeatherMap account' } }
}
@ -617,18 +406,20 @@ n8n normalizes column names to snake_case (e.g., \`dayName\` → \`day_name\`).
5. **Write workflow code** to \`${workspaceRoot}/src/workflow.ts\`.
6. **Validate with tsc**: Run the TypeScript compiler for real type checking:
6. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch \`outputKey\` has a matching \`.onCase('<outputKey>')\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria.
7. **Validate with tsc**: Run the TypeScript compiler for real type checking:
\`\`\`
execute_command: cd ~/workspace && npx tsc --noEmit 2>&1
\`\`\`
Fix any errors using \`edit_file\` (with absolute path) to update the code, then re-run tsc. Iterate until clean.
**Important**: If tsc reports errors you cannot resolve after 2 attempts, skip tsc and proceed to submit-workflow. The submit tool has its own validation.
7. **Submit**: When tsc passes cleanly, call \`submit-workflow\` to validate the workflow graph and save it to n8n.
8. **Submit**: When tsc passes cleanly, call \`submit-workflow\` to validate the workflow graph and save it to n8n.
8. **Fix submission errors**: If \`submit-workflow\` returns errors, edit the file and submit again immediately. Skip tsc for validation-only errors. **Never end your turn on a file edit — always re-submit first.** The system compares file hashes: if the file changed since the last submit, all your work is discarded. End only on a successful re-submit or after you explicitly report the blocking error.
9. **Fix submission errors**: If \`submit-workflow\` returns errors, edit the file and submit again immediately. Skip tsc for validation-only errors. **Never end your turn on a file edit — always re-submit first.** The system compares file hashes: if the file changed since the last submit, all your work is discarded. End only on a successful re-submit or after you explicitly report the blocking error.
9. **Done**: Output ONE sentence summarizing what was built, including the workflow ID and any known issues.
10. **Done**: Output ONE sentence summarizing what was built, including the workflow ID and any known issues.
### For complex workflows (5+ nodes, multiple integrations):
@ -644,8 +435,9 @@ Follow the **Compositional Workflow Pattern** above. The process becomes:
c. Submit the chunk: \`submit-workflow\` with \`filePath\` pointing to the chunk file. Test via \`executions(action="run")\`.
d. Fix if needed (max 2 submission fix attempts per chunk).
6. **Write the main workflow** in \`${workspaceRoot}/src/workflow.ts\` that composes chunks via \`executeWorkflow\` nodes, referencing each chunk's workflow ID.
7. **Submit** the main workflow.
8. **Done**: Output ONE sentence summarizing what was built, including the workflow ID and any known issues.
7. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch \`outputKey\` has a matching \`.onCase('<outputKey>')\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria.
8. **Submit** the main workflow.
9. **Done**: Output ONE sentence summarizing what was built, including the workflow ID and any known issues.
Do NOT produce visible output until the final step. All reasoning happens internally.

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/mcp-browser",
"version": "0.4.0",
"version": "0.5.0",
"description": "Browser automation MCP tools built on Playwright, WebDriver BiDi, and safaridriver",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/node-cli",
"version": "0.30.0",
"version": "0.31.0",
"description": "Official CLI for developing community nodes for n8n",
"bin": {
"n8n-node": "bin/n8n-node.mjs"

View File

@ -52,6 +52,58 @@ export class GuardrailsV2 implements INodeType {
},
searchHint:
'Classify operation has two outputs: output 0 (Pass) for items that passed all guardrail checks, output 1 (Fail) for items that failed. Use .output(index).to() to connect from a specific output. @example guardrails.output(0).to(passNode) and guardrails.output(1).to(failNode). Sanitize operation has only one output.',
extraTypeDefContent: [
{
displayOptions: {
show: {
operation: ['classify'],
},
},
content: `<patterns>
<pattern title="Guardrails classify with separate Pass and Fail outputs">
const model = languageModel({
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
version: 1.3,
config: {
name: 'OpenAI Chat Model',
parameters: { model: { __rl: true, mode: 'list', value: 'gpt-5.4' } },
credentials: { openAiApi: { id: 'credId', name: 'OpenAI account' } }
}
});
const guardrailsCheck = node({
type: '@n8n/n8n-nodes-langchain.guardrails',
version: 2,
config: {
name: 'Guardrails',
parameters: {
operation: 'classify',
text: expr('{{ $json.input }}'),
guardrails: { jailbreak: { value: { threshold: 0.7 } } }
},
subnodes: { model }
}
});
const passHandler = node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: { name: 'Handle Pass', parameters: {} }
});
const failHandler = node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: { name: 'Handle Fail', parameters: {} }
});
// output 0 = Pass, output 1 = Fail
guardrailsCheck.output(0).to(passHandler);
guardrailsCheck.output(1).to(failHandler);
</pattern>
</patterns>`,
},
],
},
};
}

View File

@ -30,6 +30,8 @@ export class Agent extends VersionedNodeType {
},
defaultVersion: 3.1,
builderHint: {
searchHint:
"Wire model/memory/tools/outputParser via the SDK `subnodes` config object using factory functions (`languageModel()`, `memory()`, `tool()`, `outputParser()`). Inside subnodes, reference upstream data with `nodeJson(triggerNode, 'path')`, not `$json` — subnodes do not share the main predecessor's item context.",
relatedNodes: [
{
nodeType: 'n8n-nodes-base.aggregate',
@ -50,6 +52,73 @@ export class Agent extends VersionedNodeType {
'Required for conversational workflows - connect memory to every agent that needs to recall previous messages in the conversation',
},
],
extraTypeDefContent: [
{
content: `<patterns>
<pattern title="Agent with model, memory, structured output parser">
const chatTrigger = trigger({
type: '@n8n/n8n-nodes-langchain.chatTrigger',
version: 1.3,
config: {
name: 'Chat Trigger',
parameters: { public: false },
output: [{ sessionId: 'chat-session-id', chatInput: 'Hello' }]
}
});
const model = languageModel({
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
version: 1.3,
config: {
name: 'OpenAI Chat Model',
parameters: { model: { __rl: true, mode: 'list', value: 'gpt-5.4' } },
credentials: { openAiApi: { id: 'credId', name: 'OpenAI account' } }
}
});
const parser = outputParser({
type: '@n8n/n8n-nodes-langchain.outputParserStructured',
version: 1.3,
config: {
name: 'Output Parser',
parameters: {
schemaType: 'fromJson',
jsonSchemaExample: '{ "score": 75, "tier": "hot" }'
}
}
});
const memoryNode = memory({
type: '@n8n/n8n-nodes-langchain.memoryBufferWindow',
version: 1.3,
config: {
name: 'Conversation Memory',
parameters: {
sessionIdType: 'customKey',
sessionKey: nodeJson(chatTrigger, 'sessionId'),
contextWindowLength: 10
}
}
});
const agent = node({
type: '@n8n/n8n-nodes-langchain.agent',
version: 3.1,
config: {
name: 'AI Agent',
parameters: {
promptType: 'define',
text: expr('{{ $json.prompt }}'),
hasOutputParser: true,
options: { systemMessage: 'You are an expert...' }
},
subnodes: { model, memory: memoryNode, outputParser: parser }
}
});
</pattern>
</patterns>`,
},
],
},
};

View File

@ -57,6 +57,10 @@ export class AgentToolV3 implements INodeType {
type: 'boolean',
default: false,
noDataExpression: true,
builderHint: {
propertyHint:
'Set to `true` when you need structured JSON output. The agent then requires an `outputParser` entry in its `subnodes` config (typically an `outputParserStructured` node defined via the `outputParser({...})` SDK factory). With `hasOutputParser: false` the agent returns a plain string in `$json.output`.',
},
},
{
displayName: `Connect an <a data-action='openSelectiveNodeCreator' data-action-parameter-connectiontype='${NodeConnectionTypes.AiOutputParser}'>output parser</a> on the canvas to specify the output format you require`,

View File

@ -98,6 +98,10 @@ export class AgentV3 implements INodeType {
type: 'boolean',
default: false,
noDataExpression: true,
builderHint: {
propertyHint:
'Set to `true` when you need structured JSON output. The agent then requires an `outputParser` entry in its `subnodes` config (typically an `outputParserStructured` node defined via the `outputParser({...})` SDK factory). With `hasOutputParser: false` the agent returns a plain string in `$json.output`.',
},
},
{
displayName: `Connect an <a data-action='openSelectiveNodeCreator' data-action-parameter-connectiontype='${NodeConnectionTypes.AiOutputParser}'>output parser</a> on the canvas to specify the output format you require`,

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/n8n-nodes-langchain",
"version": "2.20.0",
"version": "2.21.0",
"description": "",
"main": "index.js",
"exports": {

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/permissions",
"version": "0.58.0",
"version": "0.59.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,19 +1,16 @@
{
"name": "@n8n/scan-community-package",
"version": "0.17.0",
"version": "0.18.0",
"description": "Static code analyser for n8n community packages",
"license": "none",
"bin": "scanner/cli.mjs",
"scripts": {
"test": "node --test test/*.test.mjs"
"test": "vitest run",
"test:dev": "vitest"
},
"files": [
"scanner"
],
"scripts": {
"test": "vitest run",
"test:dev": "vitest"
},
"dependencies": {
"eslint": "catalog:",
"fast-glob": "catalog:",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/task-runner",
"version": "2.20.0",
"version": "2.21.0",
"scripts": {
"clean": "rimraf dist .turbo",
"start": "node dist/start.js",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/workflow-sdk",
"version": "0.13.0",
"version": "0.14.0",
"description": "TypeScript SDK for programmatically creating n8n workflows",
"exports": {
".": {

View File

@ -18,9 +18,18 @@ import type * as GenerateTypesModule from '../generate-types/generate-types';
// Type Definitions (Expected interfaces from the implementation)
// =============================================================================
interface BuilderHintVariation {
content: string;
displayOptions?: {
show?: Record<string, unknown[]>;
hide?: Record<string, unknown[]>;
};
}
interface ParameterBuilderHint {
propertyHint: string;
placeholderSupported?: boolean;
extraTypeDefContent?: BuilderHintVariation[];
}
interface NestedOption {
@ -76,6 +85,7 @@ interface NodeTypeDescription {
hidden?: boolean;
schemaPath?: string;
builderHint?: {
message?: string;
inputs?: Record<
string,
{
@ -86,6 +96,7 @@ interface NodeTypeDescription {
};
}
>;
extraTypeDefContent?: BuilderHintVariation[];
};
}
@ -1393,6 +1404,156 @@ describe('generate-types', () => {
expect(result).not.toContain('GmailV21Params');
});
it('should route node-level extraTypeDefContent variations into matching narrowed types only', () => {
const vectorStoreLikeNode: NodeTypeDescription & { builderHint?: unknown } = {
name: 'n8n-nodes-test.vectorStoreLike',
displayName: 'Vector Store Like',
group: ['transform'],
version: 1,
inputs: ['main'],
outputs: ['main'],
builderHint: {
extraTypeDefContent: [
{
displayOptions: { show: { mode: ['insert'] } },
content: '<patterns>\n<pattern>insert example</pattern>\n</patterns>',
},
{
displayOptions: { show: { mode: ['retrieve-as-tool'] } },
content: '<patterns>\n<pattern>RAG example</pattern>\n</patterns>',
},
],
},
properties: [
{
displayName: 'Operation Mode',
name: 'mode',
type: 'options',
options: [
{ name: 'Insert', value: 'insert' },
{ name: 'Retrieve as Tool', value: 'retrieve-as-tool' },
],
default: 'insert',
},
{
displayName: 'Insert Field',
name: 'insertField',
type: 'string',
default: '',
displayOptions: { show: { mode: ['insert'] } },
},
{
displayName: 'Tool Description',
name: 'toolDescription',
type: 'string',
default: '',
displayOptions: { show: { mode: ['retrieve-as-tool'] } },
},
],
};
const result = generateTypes.generateDiscriminatedUnion(vectorStoreLikeNode);
// Each variation must land in its own narrowed type body — no cross-bleed.
const sections = result.split(/export type /);
const insertSection = sections.find((s) => s.startsWith('VectorStoreLikeInsertParams'));
const retrieveSection = sections.find((s) =>
s.startsWith('VectorStoreLikeRetrieveAsToolParams'),
);
expect(insertSection).toBeDefined();
expect(retrieveSection).toBeDefined();
expect(insertSection!).toContain('<pattern>insert example</pattern>');
expect(insertSection!).not.toContain('RAG example');
expect(retrieveSection!).toContain('<pattern>RAG example</pattern>');
expect(retrieveSection!).not.toContain('insert example');
});
it('should not duplicate an unconditional node-level variation across narrowed types (file header only)', () => {
const node: NodeTypeDescription & { builderHint?: unknown } = {
name: 'n8n-nodes-test.unconditional',
displayName: 'Unconditional',
group: ['transform'],
version: 1,
inputs: ['main'],
outputs: ['main'],
builderHint: {
extraTypeDefContent: [{ content: 'unconditional only' }],
},
properties: [
{
displayName: 'Operation Mode',
name: 'mode',
type: 'options',
options: [
{ name: 'Insert', value: 'insert' },
{ name: 'Retrieve', value: 'retrieve' },
],
default: 'insert',
},
{
displayName: 'Insert Field',
name: 'insertField',
type: 'string',
default: '',
displayOptions: { show: { mode: ['insert'] } },
},
],
};
// Unconditional content does NOT appear inside narrowed type bodies —
// it's reserved for the file-level node header.
const result = generateTypes.generateDiscriminatedUnion(node);
expect(result).not.toContain('unconditional only');
// File header emits it exactly once.
const header = generateTypes.generateNodeJSDoc(node);
expect(header).toContain('unconditional only');
expect(header.match(/unconditional only/g)?.length).toBe(1);
});
it('should skip variations whose displayOptions do not match the combo', () => {
const node: NodeTypeDescription & { builderHint?: unknown } = {
name: 'n8n-nodes-test.partialMatch',
displayName: 'Partial Match',
group: ['transform'],
version: 1,
inputs: ['main'],
outputs: ['main'],
builderHint: {
extraTypeDefContent: [
{
displayOptions: { show: { mode: ['unknownMode'] } },
content: 'should NOT appear',
},
],
},
properties: [
{
displayName: 'Operation Mode',
name: 'mode',
type: 'options',
options: [
{ name: 'Insert', value: 'insert' },
{ name: 'Retrieve', value: 'retrieve' },
],
default: 'insert',
},
{
displayName: 'Insert Field',
name: 'insertField',
type: 'string',
default: '',
displayOptions: { show: { mode: ['insert'] } },
},
],
};
const result = generateTypes.generateDiscriminatedUnion(node);
expect(result).not.toContain('should NOT appear');
});
it('should generate simple interface for HTTP Request (no discriminators)', () => {
const result = generateTypes.generateDiscriminatedUnion(mockHttpRequestNode);
@ -1776,6 +1937,77 @@ describe('generate-types', () => {
expect(result).toContain('@builderHint');
expect(result).toContain('&lt;a href=');
});
it('should include unconditional extraTypeDefContent variation below @builderHint, preserving line breaks and tags verbatim', () => {
const prop: NodeProperty = {
name: 'columns',
displayName: 'Columns',
type: 'resourceMapper',
description: 'Column mapping',
builderHint: {
propertyHint: 'Pass the full resourceMapper object',
extraTypeDefContent: [
{
content:
'<patterns>\n<pattern title="autoMap">\ncolumns: { mappingMode: \'autoMapInputData\' }\n</pattern>\n</patterns>',
},
],
},
default: {},
};
const result = generateTypes.generatePropertyJSDoc(prop);
expect(result).toContain('@builderHint Pass the full resourceMapper object');
expect(result).toContain(' * <patterns>');
expect(result).toContain(' * <pattern title="autoMap">');
expect(result).toContain(" * columns: { mappingMode: 'autoMapInputData' }");
expect(result).toContain(' * </pattern>');
expect(result).toContain(' * </patterns>');
// Angle brackets in variation content must NOT be HTML-escaped — the LLM
// must see the tags verbatim so it can use them as structural cues.
expect(result).not.toContain('&lt;patterns&gt;');
expect(result).not.toContain('&lt;pattern title=');
});
it('should escape closing JSDoc sequences inside variation content', () => {
const prop: NodeProperty = {
name: 'foo',
displayName: 'Foo',
type: 'string',
description: 'Foo',
builderHint: {
propertyHint: 'msg',
extraTypeDefContent: [{ content: 'block end */ inside example' }],
},
default: '',
};
const result = generateTypes.generatePropertyJSDoc(prop);
// The literal "*/" would terminate the JSDoc block early; it must be escaped.
expect(result).not.toContain('block end */ inside');
expect(result).toContain('block end *\\/ inside');
});
it('should skip param-level variations whose displayOptions cannot be evaluated at file/property scope', () => {
// Param-level emission (generatePropertyJSDoc, generateNestedPropertyJSDoc) has
// no discriminator combo, so any gated variation is dropped — those belong on the
// node-level builderHint where the codegen can route them per narrowed type.
const prop: NodeProperty = {
name: 'foo',
displayName: 'Foo',
type: 'string',
description: 'Foo',
builderHint: {
propertyHint: 'msg',
extraTypeDefContent: [
{ displayOptions: { show: { mode: ['insert'] } }, content: 'gated content' },
{ content: 'always shown' },
],
},
default: '',
};
const result = generateTypes.generatePropertyJSDoc(prop);
expect(result).toContain('always shown');
expect(result).not.toContain('gated content');
});
});
describe('generateNodeJSDoc', () => {
@ -1789,6 +2021,37 @@ describe('generate-types', () => {
const result = generateTypes.generateNodeJSDoc(mockGmailNode);
expect(result).toContain('Node Types');
});
it('should emit node-level @builderHint searchHint at the file header', () => {
const node = {
...mockGmailNode,
builderHint: {
searchHint: 'AI Agent — wire subnodes via the config object',
},
};
const result = generateTypes.generateNodeJSDoc(node);
expect(result).toContain('@builderHint AI Agent — wire subnodes via the config object');
});
it('should emit unconditional extraTypeDefContent variations at the file header but skip gated ones', () => {
const node = {
...mockGmailNode,
builderHint: {
extraTypeDefContent: [
{ content: '<patterns>\n<pattern>always</pattern>\n</patterns>' },
{
displayOptions: { show: { mode: ['insert'] } },
content: '<patterns>\n<pattern>insert-only</pattern>\n</patterns>',
},
],
},
};
const result = generateTypes.generateNodeJSDoc(node);
// Unconditional variation lands at file header.
expect(result).toContain(' * <pattern>always</pattern>');
// Gated variation does NOT — it's emitted per-combo via emitNodeHintForCombo.
expect(result).not.toContain('insert-only');
});
});
// =========================================================================

View File

@ -250,6 +250,20 @@ const AI_TYPE_TO_SUBNODE_FIELD: Record<
// Type Definitions
// =============================================================================
/**
* One variation of `extraTypeDefContent`, optionally gated by `displayOptions`.
* Variations with `displayOptions` are emitted only in narrowed discriminator
* types (e.g. per-mode or per-resource/operation files) whose combo matches.
* Variations without `displayOptions` are emitted unconditionally.
*/
export interface BuilderHintVariation {
content: string;
displayOptions?: {
show?: Record<string, unknown[]>;
hide?: Record<string, unknown[]>;
};
}
export interface ParameterBuilderHint {
propertyHint: string;
placeholderSupported?: boolean;
@ -350,6 +364,117 @@ export interface JsonSchema {
$ref?: string;
}
// =============================================================================
// JSDoc emission helpers
// =============================================================================
/**
* Emit `@builderHint` JSDoc plus optional multi-line `extraTypeDefContent` lines.
*
* `message` is HTML-escaped (`<` / `>` entities) because it round-trips through
* the search engine's `<builder_hint>...</builder_hint>` XML envelope, where bare
* angle brackets would corrupt the wrapping tag.
*
* `extraTypeDefContent` does NOT escape angle brackets author-written tags such
* as `<patterns>` must round-trip into the `.d.ts` verbatim so the LLM sees them
* as structural cues. Only `*\/` is escaped to keep the JSDoc block well-formed.
*/
/**
* Determines whether a variation should be emitted in the current scope.
*
* The two scopes are mutually exclusive so unconditional variations are
* NOT duplicated across narrowed types:
*
* - File-level header (no `combo`): emit ONLY unconditional variations
* (those with no `displayOptions`). They appear once at the top of the
* generated `.d.ts` and cover every narrowed type.
*
* - Narrowed config block (per `combo`): emit ONLY gated variations
* whose `displayOptions` match the combo. Unconditional variations are
* skipped here they were already emitted at the file header.
*/
function variationApplies(
variation: BuilderHintVariation,
combo: DiscriminatorCombination | undefined,
): boolean {
const opts = variation.displayOptions;
// File-level scope: only unconditional variations.
if (!combo) return !opts;
// Narrowed scope: only gated variations whose displayOptions match.
if (!opts) return false;
if (opts.show) {
for (const [key, conditions] of Object.entries(opts.show)) {
const value = combo[key];
if (value === undefined) return false;
if (!checkConditions(conditions, [value])) return false;
}
}
if (opts.hide) {
for (const [key, conditions] of Object.entries(opts.hide)) {
const value = combo[key];
if (value === undefined) continue;
if (checkConditions(conditions, [value])) return false;
}
}
return true;
}
function emitBuilderHint(
lines: string[],
indent: string,
hint: { propertyHint?: string; extraTypeDefContent?: BuilderHintVariation[] },
combo?: DiscriminatorCombination,
): void {
if (hint.propertyHint) {
const safePropertyHint = hint.propertyHint
.replace(/\*\//g, '*\\/')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
lines.push(`${indent} * @builderHint ${safePropertyHint}`);
}
if (!hint.extraTypeDefContent) return;
for (const variation of hint.extraTypeDefContent) {
if (!variationApplies(variation, combo)) continue;
const safe = variation.content.replace(/\*\//g, '*\\/');
for (const line of safe.split('\n')) {
lines.push(`${indent} * ${line}`);
}
}
}
/**
* `builderHint` is an extended n8n property not part of the upstream
* `NodeTypeDescription`. Centralized cast keeps the rest of the file clean.
*/
function getNodeBuilderHint(node: NodeTypeDescription): NodeBuilderHint | undefined {
return (node as NodeTypeDescription & { builderHint?: NodeBuilderHint }).builderHint;
}
/**
* Emit a JSDoc block for the node-level builderHint scoped to a single
* discriminator combination only variations whose `displayOptions` match the
* combo are rendered. The `propertyHint` is intentionally not re-emitted per combo
* (it already lands in the file-level node header).
*/
function emitNodeHintForCombo(
lines: string[],
node: NodeTypeDescription,
combo: DiscriminatorCombination,
): void {
const hint = getNodeBuilderHint(node);
if (!hint?.extraTypeDefContent?.some((v) => variationApplies(v, combo))) return;
const hintLines: string[] = [`${INDENT}/**`];
emitBuilderHint(hintLines, INDENT, { extraTypeDefContent: hint.extraTypeDefContent }, combo);
hintLines.push(`${INDENT} */`);
lines.push(...hintLines);
}
// =============================================================================
// Schema Discovery & JSON Schema to TypeScript Conversion
// =============================================================================
@ -883,11 +1008,7 @@ function generateNestedPropertyJSDoc(
// Builder hint - guidance for AI/workflow builders
if (prop.builderHint) {
const safeBuilderHint = prop.builderHint.propertyHint
.replace(/\*\//g, '*\\/')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
lines.push(`${indent} * @builderHint ${safeBuilderHint}`);
emitBuilderHint(lines, indent, prop.builderHint);
}
// Placeholder support flag — signals to the builder agent (and the runtime
@ -1055,14 +1176,10 @@ function generateFixedCollectionType(
groupJsDocLines.push(`${INDENT.repeat(2)}/** ${desc}`);
}
if (group.builderHint) {
const safeBuilderHint = group.builderHint.propertyHint
.replace(/\*\//g, '*\\/')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
if (groupJsDocLines.length === 0) {
groupJsDocLines.push(`${INDENT.repeat(2)}/**`);
}
groupJsDocLines.push(`${INDENT.repeat(2)} * @builderHint ${safeBuilderHint}`);
emitBuilderHint(groupJsDocLines, INDENT.repeat(2), group.builderHint);
}
if (isMultipleValues && hasMinRequired) {
if (groupJsDocLines.length === 0) {
@ -1859,7 +1976,9 @@ export function generateDiscriminatedUnion(node: NodeTypeDescription): string {
lines.push(`export type ${configName} = {`);
// Add discriminator fields
emitNodeHintForCombo(lines, node, combo);
// Discriminator literal fields for this combo.
for (const [key, value] of Object.entries(combo)) {
if (value !== undefined) {
lines.push(`${INDENT}${key}: '${value}';`);
@ -1914,11 +2033,7 @@ export function generatePropertyJSDoc(
// Builder hint - guidance for AI/workflow builders
if (prop.builderHint) {
const safeBuilderHint = prop.builderHint.propertyHint
.replace(/\*\//g, '*\\/')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
lines.push(` * @builderHint ${safeBuilderHint}`);
emitBuilderHint(lines, '', prop.builderHint);
}
// Placeholder support flag — signals to the builder agent (and the runtime
@ -2025,6 +2140,18 @@ export function generateNodeJSDoc(node: NodeTypeDescription): string {
lines.push(` * @subnodeType ${subnodeType}`);
}
// Node-level builder hint — searchHint and unconditional extraTypeDefContent.
// `relatedNodes` and `inputs` are consumed elsewhere (search engine, subnode
// extraction). Variations with `displayOptions` are skipped here — they're
// emitted per-combo in narrowed config types via `emitNodeHintForCombo`.
const nodeHint = getNodeBuilderHint(node);
if (nodeHint && (nodeHint.searchHint || nodeHint.extraTypeDefContent?.length)) {
emitBuilderHint(lines, '', {
propertyHint: nodeHint.searchHint,
extraTypeDefContent: nodeHint.extraTypeDefContent,
});
}
lines.push(' */');
return lines.join('\n');
@ -2533,7 +2660,9 @@ export function generateDiscriminatorFile(
}
lines.push(`export type ${configName} = {`);
// Add discriminator fields
emitNodeHintForCombo(lines, node, combo);
// Discriminator literal fields for this combo.
for (const [key, value] of Object.entries(combo)) {
if (value !== undefined) {
lines.push(`${INDENT}${key}: '${value}';`);
@ -3398,7 +3527,9 @@ function generateDiscriminatedUnionForEntry(
lines.push(`export type ${configName} = {`);
// Add discriminator fields
emitNodeHintForCombo(lines, node, combo);
// Discriminator literal fields for this combo.
for (const [key, value] of Object.entries(combo)) {
if (value !== undefined) {
lines.push(`${INDENT}${key}: '${value}';`);
@ -3568,7 +3699,18 @@ interface BuilderHintInput {
}
interface NodeBuilderHint {
searchHint?: string;
relatedNodes?: Array<{ nodeType: string; relationHint: string }>;
inputs?: Record<string, BuilderHintInput>;
/**
* Multi-line content (typically code examples wrapped in `<patterns>...</patterns>`)
* emitted into the generated `.d.ts` but NOT surfaced in
* `nodes(action="search")` results. Each variation may carry `displayOptions`
* so per-mode / per-resource / per-operation examples land only in their
* corresponding narrowed type. Variations with no `displayOptions` emit
* once at the file-level node header.
*/
extraTypeDefContent?: BuilderHintVariation[];
}
/**

View File

@ -10,23 +10,3 @@ Structured Output Parser: Prefer this over manually extracting/parsing AI output
Multi-agent systems:
AI Agent Tool (@n8n/n8n-nodes-langchain.agentTool) contains an embedded AI Agent it's a complete sub-agent that the main agent can call through tool(). Each AgentTool needs its own Chat Model. Node selection: 1 AI Agent + N AgentTools + (N+1) Chat Models.`;
export const AI_TOOL_PATTERNS = `AI Agent tool connection patterns:
When AI Agent needs external capabilities, use TOOL nodes (not regular nodes):
- Research: SerpAPI Tool, Perplexity Tool -> AI Agent [tool()]
- Calendar: Google Calendar Tool -> AI Agent [tool()]
- Messaging: Slack Tool, Gmail Tool -> AI Agent [tool()]
- HTTP calls: HTTP Request Tool -> AI Agent [tool()]
- Calculations: Calculator Tool -> AI Agent [tool()]
- Sub-agents: AI Agent Tool -> AI Agent [tool()] (for multi-agent systems)
Tool nodes: AI Agent decides when/if to use them based on reasoning.
Regular nodes: Execute at that workflow step regardless of context.
Vector Store patterns:
- Insert documents: Document Loader -> Vector Store (mode='insert') [documentLoader()]
- RAG with AI Agent: Vector Store (mode='retrieve-as-tool') -> AI Agent [tool()]
The retrieve-as-tool mode makes the Vector Store act as a tool the Agent can call.
Structured Output Parser: Connect to AI Agent when structured JSON output is required.`;

View File

@ -1,5 +1,4 @@
export { AI_NODE_SELECTION, AI_TOOL_PATTERNS } from './ai-nodes';
export { NODE_SELECTION_PATTERNS, BASELINE_FLOW_CONTROL } from './use-case-patterns';
export { AI_NODE_SELECTION } from './ai-nodes';
export { NODE_SELECTION_PATTERNS } from './use-case-patterns';
export { TRIGGER_SELECTION } from './trigger-selection';
export { NATIVE_NODE_PREFERENCE } from './native-preference';
export { CONNECTION_CHANGING_PARAMETERS } from './connection-parameters';

View File

@ -44,12 +44,3 @@ CHATBOTS:
MEDIA:
- OpenAI: DALL-E image generation, Sora video, Whisper transcription
- Google Gemini: Imagen image generation`;
export const BASELINE_FLOW_CONTROL = `Baseline flow control nodes (used in most workflows):
- n8n-nodes-base.aggregate: Combines multiple items into one item
- n8n-nodes-base.if: Routes items based on true/false condition
- n8n-nodes-base.switch: Routes items to different paths based on rules or expressions
- n8n-nodes-base.splitOut: Expands a single item containing an array into multiple individual items
- n8n-nodes-base.merge: Combines data from multiple parallel branches (for 3+ inputs: mode="append" + numberInputs)
- n8n-nodes-base.set: Transforms and restructures data fields`;

View File

@ -1,6 +1,6 @@
{
"name": "n8n",
"version": "2.20.0",
"version": "2.21.0",
"description": "n8n Workflow Automation Tool",
"main": "dist/index",
"types": "dist/index.d.ts",

View File

@ -0,0 +1,27 @@
import { Container } from '@n8n/di';
import { InboundSecretsModule } from '../inbound-secrets.module';
describe('InboundSecretsModule', () => {
let module: InboundSecretsModule;
beforeEach(() => {
Container.reset();
module = new InboundSecretsModule();
});
afterEach(() => {
delete process.env.N8N_ENV_FEAT_INBOUND_SECRETS;
});
describe('init', () => {
it('is a no-op when the feature flag is off', async () => {
await expect(module.init()).resolves.toBeUndefined();
});
it('loads without error when the feature flag is on', async () => {
process.env.N8N_ENV_FEAT_INBOUND_SECRETS = 'true';
await expect(module.init()).resolves.toBeUndefined();
});
});
});

View File

@ -0,0 +1,4 @@
import { Config } from '@n8n/config';
@Config
export class InboundSecretsConfig {}

View File

@ -0,0 +1,15 @@
import type { ModuleInterface } from '@n8n/decorators';
import { BackendModule } from '@n8n/decorators';
function isFeatureFlagEnabled(): boolean {
return process.env.N8N_ENV_FEAT_INBOUND_SECRETS === 'true';
}
@BackendModule({ name: 'inbound-secrets' })
export class InboundSecretsModule implements ModuleInterface {
async init() {
if (!isFeatureFlagEnabled()) return;
await import('./inbound-secrets.config');
}
}

View File

@ -513,6 +513,30 @@ describe('InsightsService (Integration)', () => {
expect(byWorkflow.data[0].workflowId).toEqual(workflow2.id);
});
test('returns total count when page is past the end', async () => {
const now = DateTime.utc();
for (const workflow of [workflow1, workflow2, workflow3]) {
await createCompactedInsightsEvent(workflow, {
type: 'success',
value: 1,
periodUnit: 'day',
periodStart: now,
});
}
const startDate = now.minus({ days: 14 }).startOf('day').toJSDate();
const byWorkflow = await insightsService.getInsightsByWorkflow({
startDate,
endDate: today,
skip: 10,
take: 10,
});
expect(byWorkflow.count).toEqual(3);
expect(byWorkflow.data).toHaveLength(0);
});
test('compacted data are grouped by workflow correctly with projectId filter', async () => {
// ARRANGE
const now = DateTime.utc();

View File

@ -307,6 +307,19 @@ export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
return [column, order.toUpperCase() as 'ASC' | 'DESC'];
}
private async countInsightsByWorkflowGroups(
rawRowsQuery: SelectQueryBuilder<InsightsByPeriod>,
): Promise<number> {
const resultRow = await this.manager
.createQueryBuilder()
.select('COUNT(*)', 'count')
.from(`(${rawRowsQuery.getQuery()})`, 'workflow_groups')
.setParameters(rawRowsQuery.getParameters())
.getRawOne<{ count: string | number }>();
return Number(resultRow?.count ?? 0);
}
async getInsightsByWorkflow({
startDate,
endDate,
@ -356,15 +369,22 @@ export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
.groupBy('metadata.workflowId')
.addGroupBy('metadata.workflowName')
.addGroupBy('metadata.projectId')
.addGroupBy('metadata.projectName')
.orderBy(this.escapeField(sortField), sortOrder);
.addGroupBy('metadata.projectName');
if (projectId) {
rawRowsQuery.andWhere('metadata.projectId = :projectId', { projectId });
}
const count = (await rawRowsQuery.getRawMany()).length;
const rawRows = await rawRowsQuery.offset(skip).limit(take).getRawMany();
const paginatedQuery = rawRowsQuery
.clone()
.orderBy(this.escapeField(sortField), sortOrder)
.offset(skip)
.limit(take);
const [count, rawRows] = await Promise.all([
this.countInsightsByWorkflowGroups(rawRowsQuery),
paginatedQuery.getRawMany(),
]);
return { count, rows: aggregatedInsightsByWorkflowParser.parse(rawRows) };
}

View File

@ -0,0 +1,218 @@
import type {
ICredentialDataDecryptedObject,
ICredentials,
ICredentialsHelper,
IExecuteData,
IHttpRequestHelper,
IHttpRequestOptions,
INode,
INodeCredentialsDetails,
IWorkflowExecuteAdditionalData,
Workflow,
} from 'n8n-workflow';
import { CredentialNotFoundError } from '@/errors/credential-not-found.error';
import { EvalMockedCredentialsHelper } from '../eval-mocked-credentials-helper';
const fakeAdditionalData = {} as IWorkflowExecuteAdditionalData;
const fakeWorkflow = {} as Workflow;
const fakeHttpHelper = {} as IHttpRequestHelper;
const fakeNode = { name: 'Telegram', id: 'node-1' } as INode;
const fakeNodeCreds: INodeCredentialsDetails = { id: 'missing-id', name: 'Telegram cred' };
function makeInner(overrides: Partial<ICredentialsHelper> = {}): ICredentialsHelper {
return {
getParentTypes: jest.fn().mockReturnValue([]),
authenticate: jest.fn().mockResolvedValue({ url: 'http://signed' }),
preAuthentication: jest.fn().mockResolvedValue({ token: 'real' }),
runPreAuthentication: jest.fn().mockResolvedValue({ token: 'real' }),
getCredentials: jest.fn().mockResolvedValue({} as ICredentials),
getDecrypted: jest.fn().mockResolvedValue({ accessToken: 'real-token' }),
updateCredentials: jest.fn().mockResolvedValue(undefined),
updateCredentialsOauthTokenData: jest.fn().mockResolvedValue(undefined),
getCredentialsProperties: jest.fn().mockReturnValue([]),
...overrides,
} as ICredentialsHelper;
}
describe('EvalMockedCredentialsHelper', () => {
describe('getDecrypted', () => {
it('delegates to inner when credential resolves', async () => {
const inner = makeInner();
const helper = new EvalMockedCredentialsHelper(inner);
const result = await helper.getDecrypted(
fakeAdditionalData,
fakeNodeCreds,
'telegramApi',
'manual',
);
expect(result).toEqual({ accessToken: 'real-token' });
expect(helper.mockedCredentials).toEqual([]);
});
it('returns marker stub on CredentialNotFoundError and tracks the entry', async () => {
const inner = makeInner({
getDecrypted: jest
.fn()
.mockRejectedValue(new CredentialNotFoundError('missing-id', 'telegramApi')),
});
const helper = new EvalMockedCredentialsHelper(inner);
const result = await helper.getDecrypted(
fakeAdditionalData,
fakeNodeCreds,
'telegramApi',
'manual',
{ node: fakeNode } as IExecuteData,
);
expect(result).toEqual({ __evalMockedCredential: true });
expect(helper.mockedCredentials).toEqual([
{ nodeName: 'Telegram', credentialType: 'telegramApi', credentialId: 'missing-id' },
]);
});
it('rethrows non-CredentialNotFoundError errors', async () => {
const inner = makeInner({
getDecrypted: jest.fn().mockRejectedValue(new Error('database is down')),
});
const helper = new EvalMockedCredentialsHelper(inner);
await expect(
helper.getDecrypted(fakeAdditionalData, fakeNodeCreds, 'telegramApi', 'manual'),
).rejects.toThrow('database is down');
expect(helper.mockedCredentials).toEqual([]);
});
it('records "unknown" nodeName when executeData is missing', async () => {
const inner = makeInner({
getDecrypted: jest.fn().mockRejectedValue(new CredentialNotFoundError('id', 'telegramApi')),
});
const helper = new EvalMockedCredentialsHelper(inner);
await helper.getDecrypted(fakeAdditionalData, fakeNodeCreds, 'telegramApi', 'manual');
expect(helper.mockedCredentials[0].nodeName).toBe('unknown');
});
});
describe('authenticate', () => {
it('passes the request through unchanged for marker payloads', async () => {
const inner = makeInner();
const helper = new EvalMockedCredentialsHelper(inner);
const requestOptions: IHttpRequestOptions = { url: 'http://example.com' };
const result = await helper.authenticate(
{ __evalMockedCredential: true },
'telegramApi',
requestOptions,
fakeWorkflow,
fakeNode,
);
expect(result).toBe(requestOptions);
expect(inner.authenticate).not.toHaveBeenCalled();
});
it('delegates to inner for real credentials', async () => {
const inner = makeInner();
const helper = new EvalMockedCredentialsHelper(inner);
const requestOptions: IHttpRequestOptions = { url: 'http://example.com' };
const result = await helper.authenticate(
{ accessToken: 'real-token' },
'telegramApi',
requestOptions,
fakeWorkflow,
fakeNode,
);
expect(result).toEqual({ url: 'http://signed' });
expect(inner.authenticate).toHaveBeenCalledWith(
{ accessToken: 'real-token' },
'telegramApi',
requestOptions,
fakeWorkflow,
fakeNode,
);
});
});
describe('preAuthentication / runPreAuthentication', () => {
it('returns marker payload unchanged from preAuthentication', async () => {
const inner = makeInner();
const helper = new EvalMockedCredentialsHelper(inner);
const stub: ICredentialDataDecryptedObject = { __evalMockedCredential: true };
const result = await helper.preAuthentication(
fakeHttpHelper,
stub,
'telegramApi',
fakeNode,
false,
);
expect(result).toBe(stub);
expect(inner.preAuthentication).not.toHaveBeenCalled();
});
it('returns marker payload unchanged from runPreAuthentication', async () => {
const inner = makeInner();
const helper = new EvalMockedCredentialsHelper(inner);
const stub: ICredentialDataDecryptedObject = { __evalMockedCredential: true };
const result = await helper.runPreAuthentication(fakeHttpHelper, stub, 'telegramApi');
expect(result).toBe(stub);
expect(inner.runPreAuthentication).not.toHaveBeenCalled();
});
it('delegates preAuthentication for real credentials', async () => {
const inner = makeInner();
const helper = new EvalMockedCredentialsHelper(inner);
const real: ICredentialDataDecryptedObject = { accessToken: 'real-token' };
await helper.preAuthentication(fakeHttpHelper, real, 'telegramApi', fakeNode, false);
expect(inner.preAuthentication).toHaveBeenCalledWith(
fakeHttpHelper,
real,
'telegramApi',
fakeNode,
false,
);
});
});
describe('passthrough methods', () => {
it('delegates passthrough methods to inner', async () => {
const inner = makeInner();
const helper = new EvalMockedCredentialsHelper(inner);
helper.getParentTypes('telegramApi');
helper.getCredentialsProperties('telegramApi');
await helper.getCredentials(fakeNodeCreds, 'telegramApi');
await helper.updateCredentials(fakeNodeCreds, 'telegramApi', { x: 1 });
await helper.updateCredentialsOauthTokenData(
fakeNodeCreds,
'telegramApi',
{ x: 1 },
fakeAdditionalData,
);
expect(inner.getParentTypes).toHaveBeenCalledWith('telegramApi');
expect(inner.getCredentialsProperties).toHaveBeenCalledWith('telegramApi');
expect(inner.getCredentials).toHaveBeenCalledWith(fakeNodeCreds, 'telegramApi');
expect(inner.updateCredentials).toHaveBeenCalledWith(fakeNodeCreds, 'telegramApi', { x: 1 });
expect(inner.updateCredentialsOauthTokenData).toHaveBeenCalledWith(
fakeNodeCreds,
'telegramApi',
{ x: 1 },
fakeAdditionalData,
);
});
});
});

View File

@ -0,0 +1,154 @@
import type { InstanceAiEvalMockedCredential } from '@n8n/api-types';
import type {
ICredentialDataDecryptedObject,
ICredentials,
ICredentialsExpressionResolveValues,
IExecuteData,
IHttpRequestHelper,
IHttpRequestOptions,
INode,
INodeCredentialsDetails,
INodeProperties,
IRequestOptionsSimplified,
IWorkflowExecuteAdditionalData,
Workflow,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { ICredentialsHelper } from 'n8n-workflow';
import { CredentialNotFoundError } from '@/errors/credential-not-found.error';
const MOCK_MARKER = '__evalMockedCredential' as const;
/**
* CredentialsHelper proxy for evaluation runs. Delegates everything to the
* wrapped real helper, except:
*
* - `getDecrypted`: when a credential ID cannot be resolved, returns a
* marker-only payload instead of throwing. This stops the credential
* lookup from halting the workflow before the LLM mock layer can run.
*
* - `authenticate` / `preAuthentication` / `runPreAuthentication`: when
* called with a marker payload, return the input unchanged so the
* unauthed request flows into `helpers.httpRequest`, where the LLM
* mock handler intercepts and synthesizes a response.
*
* Eval-mode HTTP never reaches real services, so credential data shape is
* irrelevant the only contract we preserve is that the auth path doesn't
* throw on missing data.
*/
export class EvalMockedCredentialsHelper extends ICredentialsHelper {
readonly mockedCredentials: InstanceAiEvalMockedCredential[] = [];
constructor(private readonly inner: ICredentialsHelper) {
super();
}
getParentTypes(name: string): string[] {
return this.inner.getParentTypes(name);
}
async authenticate(
credentials: ICredentialDataDecryptedObject,
typeName: string,
requestOptions: IHttpRequestOptions | IRequestOptionsSimplified,
workflow: Workflow,
node: INode,
): Promise<IHttpRequestOptions> {
if (credentials[MOCK_MARKER] === true) {
return requestOptions as IHttpRequestOptions;
}
return await this.inner.authenticate(credentials, typeName, requestOptions, workflow, node);
}
async preAuthentication(
helpers: IHttpRequestHelper,
credentials: ICredentialDataDecryptedObject,
typeName: string,
node: INode,
credentialsExpired: boolean,
): Promise<ICredentialDataDecryptedObject | undefined> {
if (credentials[MOCK_MARKER] === true) return credentials;
return await this.inner.preAuthentication(
helpers,
credentials,
typeName,
node,
credentialsExpired,
);
}
async runPreAuthentication(
helpers: IHttpRequestHelper,
credentials: ICredentialDataDecryptedObject,
typeName: string,
): Promise<ICredentialDataDecryptedObject | undefined> {
if (credentials[MOCK_MARKER] === true) return credentials;
return await this.inner.runPreAuthentication(helpers, credentials, typeName);
}
async getCredentials(
nodeCredentials: INodeCredentialsDetails,
type: string,
): Promise<ICredentials> {
return await this.inner.getCredentials(nodeCredentials, type);
}
async getDecrypted(
additionalData: IWorkflowExecuteAdditionalData,
nodeCredentials: INodeCredentialsDetails,
type: string,
mode: WorkflowExecuteMode,
executeData?: IExecuteData,
raw?: boolean,
expressionResolveValues?: ICredentialsExpressionResolveValues,
): Promise<ICredentialDataDecryptedObject> {
try {
return await this.inner.getDecrypted(
additionalData,
nodeCredentials,
type,
mode,
executeData,
raw,
expressionResolveValues,
);
} catch (error) {
if (!(error instanceof CredentialNotFoundError)) throw error;
this.mockedCredentials.push({
nodeName: executeData?.node?.name ?? 'unknown',
credentialType: type,
credentialId: nodeCredentials.id ?? undefined,
});
return { [MOCK_MARKER]: true };
}
}
async updateCredentials(
nodeCredentials: INodeCredentialsDetails,
type: string,
data: ICredentialDataDecryptedObject,
): Promise<void> {
return await this.inner.updateCredentials(nodeCredentials, type, data);
}
async updateCredentialsOauthTokenData(
nodeCredentials: INodeCredentialsDetails,
type: string,
data: ICredentialDataDecryptedObject,
additionalData: IWorkflowExecuteAdditionalData,
): Promise<void> {
return await this.inner.updateCredentialsOauthTokenData(
nodeCredentials,
type,
data,
additionalData,
);
}
getCredentialsProperties(type: string): INodeProperties[] {
return this.inner.getCredentialsProperties(type);
}
}

View File

@ -43,6 +43,7 @@ import {
type MockHints,
} from './workflow-analysis';
import { createLlmMockHandler } from './mock-handler';
import { EvalMockedCredentialsHelper } from './eval-mocked-credentials-helper';
// ---------------------------------------------------------------------------
// Constants
@ -211,6 +212,8 @@ export class EvalExecutionService {
workflowId: workflowEntity.id,
workflowSettings: workflowEntity.settings ?? {},
});
const credentialsHelper = new EvalMockedCredentialsHelper(additionalData.credentialsHelper);
additionalData.credentialsHelper = credentialsHelper;
additionalData.evalLlmMockHandler = this.createInterceptingHandler(mockHandler, nodeResults);
additionalData.hooks = new ExecutionLifecycleHooks('evaluation', executionId, workflowEntity);
@ -247,7 +250,7 @@ export class EvalExecutionService {
try {
const result = await this.runWorkflow(workflow, additionalData, executionData);
return this.buildResult(executionId, result, nodeResults, hints);
return this.buildResult(executionId, result, nodeResults, hints, credentialsHelper);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`[EvalMock] Workflow execution failed: ${message}`);
@ -257,6 +260,7 @@ export class EvalExecutionService {
nodeResults,
errors: [`Execution failed: ${message}`],
hints,
mockedCredentials: credentialsHelper.mockedCredentials,
};
}
}
@ -420,6 +424,7 @@ export class EvalExecutionService {
result: IRun,
nodeResults: Record<string, InstanceAiEvalNodeResult>,
hints: MockHints,
credentialsHelper: EvalMockedCredentialsHelper,
): InstanceAiEvalExecutionResult {
const errors: string[] = [];
@ -461,6 +466,7 @@ export class EvalExecutionService {
nodeResults,
errors,
hints,
mockedCredentials: credentialsHelper.mockedCredentials,
};
}
@ -477,6 +483,7 @@ export class EvalExecutionService {
warnings: [],
bypassPinData: {},
},
mockedCredentials: [],
};
}
}

View File

@ -7,11 +7,12 @@ import type {
InstanceAiModelCredential,
InstanceAiPermissions,
} from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import type { InstanceAiConfig, DeploymentConfig } from '@n8n/config';
import { SettingsRepository, UserRepository } from '@n8n/db';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import { Container, Service } from '@n8n/di';
import type { ModelConfig } from '@n8n/instance-ai';
import type { IUserSettings } from 'n8n-workflow';
import { jsonParse } from 'n8n-workflow';
@ -125,6 +126,11 @@ export class InstanceAiSettingsService {
/** Load persisted settings from DB and apply to the singleton config. Call on module init. */
async loadFromDb(): Promise<void> {
const envSnapshot = {
sandboxEnabled: this.config.sandboxEnabled,
sandboxProvider: this.config.sandboxProvider,
};
const row = await this.settingsRepository.findByKey(ADMIN_SETTINGS_KEY);
if (row) {
const persisted = jsonParse<PersistedAdminSettings>(row.value, {
@ -132,6 +138,21 @@ export class InstanceAiSettingsService {
});
this.applyAdminSettings(persisted);
}
// Surface the effective sandbox config so operators (and CI) can tell whether env vars
// or a persisted DB setting are in effect — these can silently disagree.
const c = this.config;
const overridden =
c.sandboxEnabled !== envSnapshot.sandboxEnabled ||
c.sandboxProvider !== envSnapshot.sandboxProvider;
Container.get(Logger)
.scoped('instance-ai')
.info(
`Sandbox: enabled=${c.sandboxEnabled} provider=${c.sandboxProvider}` +
(overridden
? ` (DB override; env was enabled=${envSnapshot.sandboxEnabled} provider=${envSnapshot.sandboxProvider})`
: ' (from env)'),
);
}
// ── Admin settings ────────────────────────────────────────────────────

View File

@ -1,6 +1,6 @@
{
"name": "n8n-core",
"version": "2.20.0",
"version": "2.21.0",
"description": "Core functionality of n8n",
"main": "dist/index",
"types": "dist/index.d.ts",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/chat",
"version": "1.20.0",
"version": "1.21.0",
"scripts": {
"dev": "pnpm run --dir=../storybook dev --initial-path=/docs/chat-chat--docs",
"build": "pnpm build:vite && pnpm build:bundle",

View File

@ -1,7 +1,7 @@
{
"type": "module",
"name": "@n8n/design-system",
"version": "2.20.0",
"version": "2.21.0",
"main": "src/index.ts",
"import": "src/index.ts",
"scripts": {

View File

@ -1,7 +1,7 @@
{
"name": "@n8n/i18n",
"type": "module",
"version": "2.20.0",
"version": "2.21.0",
"files": [
"dist"
],

View File

@ -5840,6 +5840,7 @@
"agents.list.actions.publish": "Publish",
"agents.list.actions.unpublish": "Unpublish",
"agents.list.actions.delete": "Delete",
"agents.list.readonly": "Read only",
"agents.publish.button.publish": "Publish",
"agents.publish.button.published": "Published",
"agents.publish.dropdown.publish": "Publish",
@ -5906,6 +5907,7 @@
"agents.new.headingWithName": "What should we build, {name}?",
"agents.new.description.placeholder": "Describe your agent...",
"agents.new.templates.label": "Or try a template",
"agents.builder.readonly.placeholder": "You don't have permission to edit this agent",
"agents.builder.sections.agent": "Agent",
"agents.builder.sections.advanced": "Advanced",
"agents.builder.sections.configJson": "Raw",

View File

@ -1,7 +1,7 @@
{
"name": "@n8n/rest-api-client",
"type": "module",
"version": "2.20.0",
"version": "2.21.0",
"files": [
"dist"
],

View File

@ -1,7 +1,7 @@
{
"name": "@n8n/stores",
"type": "module",
"version": "2.20.0",
"version": "2.21.0",
"files": [
"dist"
],

View File

@ -1,6 +1,6 @@
{
"name": "n8n-editor-ui",
"version": "2.20.0",
"version": "2.21.0",
"description": "Workflow Editor UI for n8n",
"main": "index.js",
"type": "module",

View File

@ -0,0 +1,223 @@
/* eslint-disable import-x/no-extraneous-dependencies, @typescript-eslint/no-unsafe-assignment -- test-only patterns */
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { ref } from 'vue';
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({ baseText: (key: string) => key }),
i18n: { baseText: (key: string) => key },
}));
// First mount of these SFCs eats the Vite transform cost; give them headroom.
vi.setConfig({ testTimeout: 30_000 });
describe('AgentBuilderEditorColumn — childrenDisabled composes streaming and canEditAgent', () => {
it('child panels see disabled=true when canEditAgent is false', async () => {
const { default: AgentBuilderEditorColumn } = await import(
'../components/AgentBuilderEditorColumn.vue'
);
const wrapper = mount(AgentBuilderEditorColumn, {
props: {
activeMainTab: 'agent',
mainTabOptions: [{ label: 'Agent', value: 'agent' }],
localConfig: {} as never,
agent: null,
projectId: 'p1',
agentId: 'a1',
appliedSkills: [],
connectedTriggers: [],
isBuildChatStreaming: false,
canEditAgent: false, // <<< Agent is read only
executionsDescription: '',
},
global: {
stubs: {
N8nCard: { template: '<div><slot /></div>' },
N8nHeading: { template: '<div><slot /></div>' },
N8nRadioButtons: { template: '<div />' },
N8nText: { template: '<span><slot /></span>' },
AgentIdentityHeader: {
name: 'AgentIdentityHeader',
template: '<div data-testid="stub-identity" />',
props: ['config', 'disabled'],
},
AgentInfoPanel: {
name: 'AgentInfoPanel',
template: '<div data-testid="stub-info" />',
props: ['config', 'disabled', 'embedded'],
},
AgentMemoryPanel: {
name: 'AgentMemoryPanel',
template: '<div data-testid="stub-memory" />',
props: ['config', 'disabled', 'embedded'],
},
AgentAdvancedPanel: {
name: 'AgentAdvancedPanel',
template: '<div data-testid="stub-advanced" />',
props: ['config', 'disabled', 'collapsible'],
},
AgentCapabilitiesSection: {
name: 'AgentCapabilitiesSection',
template: '<div data-testid="stub-capabilities" />',
props: [
'config',
'tools',
'customTools',
'skills',
'connectedTriggers',
'disabled',
'projectId',
'agentId',
'isPublished',
],
},
AgentJsonEditor: {
name: 'AgentJsonEditor',
template: '<div data-testid="stub-json" />',
props: ['value', 'readOnly', 'copyButtonTestId'],
},
AgentSessionsListView: { template: '<div />' },
AgentPanelHeader: { template: '<div />', props: ['title', 'description'] },
},
},
});
expect(wrapper.findComponent({ name: 'AgentIdentityHeader' }).props('disabled')).toBe(true);
expect(wrapper.findComponent({ name: 'AgentInfoPanel' }).props('disabled')).toBe(true);
expect(wrapper.findComponent({ name: 'AgentMemoryPanel' }).props('disabled')).toBe(true);
expect(wrapper.findComponent({ name: 'AgentAdvancedPanel' }).props('disabled')).toBe(true);
expect(wrapper.findComponent({ name: 'AgentCapabilitiesSection' }).props('disabled')).toBe(
true,
);
});
it('JSON editor receives readOnly=true when canEditAgent is false', async () => {
const { default: AgentBuilderEditorColumn } = await import(
'../components/AgentBuilderEditorColumn.vue'
);
const wrapper = mount(AgentBuilderEditorColumn, {
props: {
activeMainTab: 'raw',
mainTabOptions: [{ label: 'Raw', value: 'raw' }],
localConfig: {} as never,
agent: null,
projectId: 'p1',
agentId: 'a1',
appliedSkills: [],
connectedTriggers: [],
isBuildChatStreaming: false,
canEditAgent: false,
executionsDescription: '',
},
global: {
stubs: {
N8nCard: { template: '<div><slot /></div>' },
N8nHeading: { template: '<div><slot /></div>' },
N8nRadioButtons: { template: '<div />' },
N8nText: { template: '<span><slot /></span>' },
AgentJsonEditor: {
name: 'AgentJsonEditor',
template: '<div data-testid="stub-json" />',
props: ['value', 'readOnly', 'copyButtonTestId'],
},
AgentPanelHeader: { template: '<div />', props: ['title', 'description'] },
},
},
});
expect(wrapper.findComponent({ name: 'AgentJsonEditor' }).props('readOnly')).toBe(true);
});
});
describe('AgentChatPanel — read-only build chat input', () => {
it('disables ChatInputBase when endpoint=build and canEditAgent=false', async () => {
vi.doMock('../composables/useAgentChatStream', () => ({
useAgentChatStream: () => ({
messages: ref([]),
isStreaming: ref(false),
messagingState: ref('idle'),
fatalError: ref(null),
loadHistory: vi.fn(),
sendMessage: vi.fn(),
stopGenerating: vi.fn(),
resume: vi.fn(),
dismissFatalError: vi.fn(),
}),
}));
vi.doMock('../composables/useAgentTelemetry', () => ({
useAgentTelemetry: () => ({ trackSubmittedMessage: vi.fn() }),
}));
vi.doMock('../composables/agentTelemetry.utils', () => ({
buildAgentConfigFingerprint: vi.fn().mockResolvedValue({}),
}));
const { default: AgentChatPanel } = await import('../components/AgentChatPanel.vue');
const wrapper = mount(AgentChatPanel, {
props: {
projectId: 'p1',
agentId: 'a1',
endpoint: 'build',
agentConfig: null,
agentStatus: 'draft',
connectedTriggers: [],
canEditAgent: false,
},
global: {
stubs: {
N8nButton: { template: '<button><slot /></button>' },
N8nCallout: { template: '<div><slot /></div>' },
N8nIconButton: { template: '<button />' },
AgentChatEmptyState: { template: '<div />' },
AgentChatMessageList: { template: '<div />' },
ChatInputBase: {
name: 'ChatInputBase',
template: '<div data-testid="stub-chat-input" />',
props: ['modelValue', 'placeholder', 'isStreaming', 'canSubmit', 'disabled'],
},
},
},
});
const chatInput = wrapper.findComponent({ name: 'ChatInputBase' });
expect(chatInput.props('disabled')).toBe(true);
expect(chatInput.props('canSubmit')).toBe(false);
expect(chatInput.props('placeholder')).toBe('agents.builder.readonly.placeholder');
});
it('does not disable ChatInputBase for endpoint=chat (test mode) regardless of canEditAgent', async () => {
const { default: AgentChatPanel } = await import('../components/AgentChatPanel.vue');
const wrapper = mount(AgentChatPanel, {
props: {
projectId: 'p1',
agentId: 'a1',
endpoint: 'chat',
agentConfig: null,
agentStatus: 'production',
connectedTriggers: [],
canEditAgent: false,
},
global: {
stubs: {
N8nButton: { template: '<button><slot /></button>' },
N8nCallout: { template: '<div><slot /></div>' },
N8nIconButton: { template: '<button />' },
AgentChatEmptyState: { template: '<div />' },
AgentChatMessageList: { template: '<div />' },
ChatInputBase: {
name: 'ChatInputBase',
template: '<div data-testid="stub-chat-input" />',
props: ['modelValue', 'placeholder', 'isStreaming', 'canSubmit', 'disabled'],
},
},
},
});
const chatInput = wrapper.findComponent({ name: 'ChatInputBase' });
expect(chatInput.props('disabled')).toBe(false);
expect(chatInput.props('placeholder')).toBe('agents.chat.input.placeholder');
});
});

View File

@ -117,18 +117,23 @@ describe('AgentBuilderHeader', () => {
});
it('renders breadcrumbs, publish and action dropdown', () => {
const wrapper = mountHeader();
const wrapper = mountHeader({ headerActions: [{ id: 'delete', label: 'Delete' }] });
expect(wrapper.find('[data-testid="stub-breadcrumbs"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="stub-publish"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="agent-header-actions"]').exists()).toBe(true);
});
it('uses the horizontal dots action menu icon', () => {
const wrapper = mountHeader();
const wrapper = mountHeader({ headerActions: [{ id: 'delete', label: 'Delete' }] });
const action = wrapper.findComponent({ name: 'ActionDropdown' });
expect(action.props('activatorIcon')).toBe('ellipsis');
});
it('hides the action dropdown when no header actions are available', () => {
const wrapper = mountHeader({ headerActions: [] });
expect(wrapper.find('[data-testid="agent-header-actions"]').exists()).toBe(false);
});
it('passes a single project breadcrumb (agent rendered as switcher button)', () => {
const wrapper = mountHeader();
const bc = wrapper.findComponent({ name: 'N8nBreadcrumbs' });

View File

@ -0,0 +1,166 @@
/* eslint-disable import-x/no-extraneous-dependencies, @typescript-eslint/no-unsafe-assignment -- test-only patterns: @vue/test-utils is a transitive devDep, mock reads */
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { ref } from 'vue';
import type { AgentResource } from '../types';
import type { AgentPublishedVersion } from '../agent.types';
vi.mock('../composables/useAgentApi', () => ({
deleteAgent: vi.fn(),
}));
vi.mock('../composables/useAgentConfirmationModal', () => ({
useAgentConfirmationModal: () => ({ openAgentConfirmationModal: vi.fn() }),
}));
vi.mock('../composables/useAgentPublish', () => ({
useAgentPublish: () => ({ publish: vi.fn(), unpublish: vi.fn() }),
}));
vi.mock('@n8n/stores/useRootStore', () => ({
useRootStore: () => ({ restApiContext: {} }),
}));
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({ baseText: (key: string) => key }),
}));
const agentPermissionsMock = {
canCreate: ref(true),
canUpdate: ref(true),
canDelete: ref(true),
canPublish: ref(true),
canUnpublish: ref(true),
};
vi.mock('../composables/useAgentPermissions', () => ({
useAgentPermissions: () => agentPermissionsMock,
}));
// First mount eats the SFC transform cost for AgentCard + deps; give the
// whole suite headroom.
vi.setConfig({ testTimeout: 30_000 });
const STUBS = {
N8nCard: {
template:
'<div data-test-id="agent-card"><slot name="header" /><slot /><slot name="append" /></div>',
},
N8nText: { template: '<div :data-test-id="$attrs[\'data-test-id\']"><slot /></div>' },
N8nBadge: {
template: '<span :data-test-id="$attrs[\'data-test-id\']"><slot /></span>',
props: ['theme', 'bold'],
},
N8nActionToggle: {
name: 'N8nActionToggle',
template:
'<div :data-test-id="$attrs[\'data-test-id\']"><button v-for="a in actions" :key="a.value" :data-action="a.value">{{ a.label }}</button></div>',
props: ['actions', 'theme'],
},
TimeAgo: { template: '<span />' },
};
const publishedVersion: AgentPublishedVersion = {
schema: null,
skills: null,
publishedFromVersionId: 'v1',
model: null,
provider: null,
credentialId: null,
publishedById: null,
};
function createAgent(overrides: Partial<AgentResource> = {}): AgentResource {
return {
resourceType: 'agent',
id: 'agent-1',
name: 'My Agent',
description: null,
projectId: 'project-1',
credentialId: null,
provider: null,
model: null,
isCompiled: false,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
versionId: 'v1',
tools: {},
skills: {},
publishedVersion: null,
...overrides,
};
}
async function renderComponent(agent: AgentResource = createAgent()) {
const { default: AgentCard } = await import('../components/AgentCard.vue');
return mount(AgentCard, {
props: { agent, projectId: 'project-1' },
global: { stubs: STUBS },
});
}
describe('AgentCard', () => {
beforeEach(() => {
agentPermissionsMock.canUpdate.value = true;
agentPermissionsMock.canDelete.value = true;
agentPermissionsMock.canPublish.value = true;
agentPermissionsMock.canUnpublish.value = true;
});
it('hides the read-only badge when canUpdate is true', async () => {
const wrapper = await renderComponent();
expect(wrapper.find('[data-test-id="agent-card-readonly-badge"]').exists()).toBe(false);
});
it('shows the read-only badge when canUpdate is false', async () => {
agentPermissionsMock.canUpdate.value = false;
const wrapper = await renderComponent();
const badge = wrapper.find('[data-test-id="agent-card-readonly-badge"]');
expect(badge.exists()).toBe(true);
expect(badge.text()).toBe('agents.list.readonly');
});
it('hides the action toggle when no scopes grant any action', async () => {
agentPermissionsMock.canDelete.value = false;
agentPermissionsMock.canPublish.value = false;
agentPermissionsMock.canUnpublish.value = false;
const wrapper = await renderComponent();
expect(wrapper.find('[data-test-id="agent-card-actions"]').exists()).toBe(false);
});
it('shows Publish + Delete on an unpublished agent with full scopes', async () => {
const wrapper = await renderComponent(createAgent({ publishedVersion: null }));
expect(wrapper.find('[data-action="publish"]').exists()).toBe(true);
expect(wrapper.find('[data-action="delete"]').exists()).toBe(true);
expect(wrapper.find('[data-action="unpublish"]').exists()).toBe(false);
});
it('shows Unpublish + Delete on a published agent with full scopes', async () => {
const wrapper = await renderComponent(createAgent({ publishedVersion }));
expect(wrapper.find('[data-action="unpublish"]').exists()).toBe(true);
expect(wrapper.find('[data-action="delete"]').exists()).toBe(true);
expect(wrapper.find('[data-action="publish"]').exists()).toBe(false);
});
it('shows only Delete (no leading divider) when only canDelete is granted', async () => {
agentPermissionsMock.canPublish.value = false;
agentPermissionsMock.canUnpublish.value = false;
const wrapper = await renderComponent();
expect(wrapper.find('[data-action="delete"]').exists()).toBe(true);
expect(wrapper.find('[data-action="publish"]').exists()).toBe(false);
expect(wrapper.find('[data-action="unpublish"]').exists()).toBe(false);
});
it('hides Publish action when canPublish is false on an unpublished agent', async () => {
agentPermissionsMock.canPublish.value = false;
const wrapper = await renderComponent(createAgent({ publishedVersion: null }));
expect(wrapper.find('[data-action="publish"]').exists()).toBe(false);
expect(wrapper.find('[data-action="delete"]').exists()).toBe(true);
});
});

View File

@ -1,6 +1,7 @@
/* eslint-disable import-x/no-extraneous-dependencies, @typescript-eslint/require-await, @typescript-eslint/no-unsafe-assignment -- test-only patterns: @vue/test-utils is a transitive devDep, async stubs, and any-based mock reads */
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { ref } from 'vue';
import type { AgentResource } from '../types';
import type { AgentPublishedVersion } from '../agent.types';
@ -17,6 +18,18 @@ vi.mock('../composables/useAgentTelemetry', () => ({
}),
}));
const agentPermissionsMock = {
canCreate: ref(true),
canUpdate: ref(true),
canDelete: ref(true),
canPublish: ref(true),
canUnpublish: ref(true),
};
vi.mock('../composables/useAgentPermissions', () => ({
useAgentPermissions: () => agentPermissionsMock,
}));
vi.mock('../composables/agentTelemetry.utils', () => ({
buildAgentConfigFingerprint: vi.fn().mockResolvedValue({ config_version: 'v-test' }),
}));
@ -367,4 +380,64 @@ describe('AgentPublishButton', () => {
expect(dot.classes().some((c) => c.includes('indicatorPublished'))).toBe(false);
});
});
// Permission gating
describe('permission gating', () => {
beforeEach(() => {
agentPermissionsMock.canUpdate.value = true;
agentPermissionsMock.canPublish.value = true;
agentPermissionsMock.canUnpublish.value = true;
});
it('disables Publish main button and dropdown item when canPublish is false', async () => {
agentPermissionsMock.canPublish.value = false;
const wrapper = await renderComponent({ agent: createAgent({ publishedVersion: null }) });
expect(
wrapper.find('[data-testid="publish-agent-button"]').attributes('disabled'),
).toBeDefined();
expect(wrapper.find('[data-action="publish"]').attributes('disabled')).toBeDefined();
});
it('disables Unpublish dropdown item when canUnpublish is false', async () => {
agentPermissionsMock.canUnpublish.value = false;
const wrapper = await renderComponent({
agent: createAgent({ versionId: 'v1', publishedVersion }),
});
expect(wrapper.find('[data-action="unpublish"]').attributes('disabled')).toBeDefined();
});
it('disables Revert dropdown item when canUpdate is false', async () => {
agentPermissionsMock.canUpdate.value = false;
const wrapper = await renderComponent({
agent: createAgent({ versionId: 'v2', publishedVersion }),
});
expect(
wrapper.find('[data-action="revert-to-published"]').attributes('disabled'),
).toBeDefined();
});
it('does not call publishAgent when Publish is clicked without canPublish', async () => {
const { publishAgent } = await import('../composables/useAgentApi');
agentPermissionsMock.canPublish.value = false;
const wrapper = await renderComponent({ agent: createAgent({ publishedVersion: null }) });
await wrapper.find('[data-testid="publish-agent-button"]').trigger('click');
await flushPromises();
expect(publishAgent).not.toHaveBeenCalled();
});
it('keeps publish independent from unpublish — granting only canPublish enables Publish but disables Unpublish', async () => {
agentPermissionsMock.canUnpublish.value = false;
const wrapper = await renderComponent({
agent: createAgent({ versionId: 'v2', publishedVersion }),
});
expect(wrapper.find('[data-action="publish"]').attributes('disabled')).toBeUndefined();
expect(wrapper.find('[data-action="unpublish"]').attributes('disabled')).toBeDefined();
});
});
});

View File

@ -33,6 +33,7 @@ const props = defineProps<{
isBuildChatStreaming: boolean;
isPublished: boolean;
isFullWidth: boolean;
canEditAgent: boolean;
beforeBuildSend?: () => Promise<void> | void;
}>();
@ -193,11 +194,12 @@ const sharedInputDraft = ref('');
:agent-config="localConfig"
:agent-status="deriveAgentStatus(agent)"
:connected-triggers="connectedTriggers"
:can-edit-agent="canEditAgent"
:before-send="beforeBuildSend"
@config-updated="emit('config-updated')"
@update:streaming="emit('update:streaming', $event)"
>
<template #above-input>
<template v-if="canEditAgent" #above-input>
<div :class="$style.quickActionsRow">
<AgentChatQuickActions
:tools="localConfig?.tools ?? []"

View File

@ -1,4 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue';
import { N8nCard, N8nRadioButtons } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
@ -14,7 +15,7 @@ import AgentJsonEditor from './AgentJsonEditor.vue';
import AgentMemoryPanel from './AgentMemoryPanel.vue';
import AgentPanelHeader from './AgentPanelHeader.vue';
defineProps<{
const props = defineProps<{
activeMainTab: AgentBuilderMainTab;
mainTabOptions: Array<{ label: string; value: AgentBuilderMainTab }>;
localConfig: AgentJsonConfig | null;
@ -24,9 +25,12 @@ defineProps<{
appliedSkills: Array<{ id: string; skill: AgentSkill }>;
connectedTriggers: string[];
isBuildChatStreaming: boolean;
canEditAgent: boolean;
executionsDescription: string;
}>();
const childrenDisabled = computed(() => props.isBuildChatStreaming || !props.canEditAgent);
const emit = defineEmits<{
'update:activeMainTab': [tab: AgentBuilderMainTab];
'update:config': [updates: Partial<AgentJsonConfig>];
@ -57,7 +61,7 @@ const i18n = useI18n();
<AgentIdentityHeader
v-if="activeMainTab === 'agent'"
:config="localConfig"
:disabled="isBuildChatStreaming"
:disabled="childrenDisabled"
@update:config="emit('update:config', $event)"
/>
<AgentPanelHeader
@ -97,7 +101,7 @@ const i18n = useI18n();
:custom-tools="agent?.tools ?? {}"
:skills="appliedSkills"
:connected-triggers="connectedTriggers"
:disabled="isBuildChatStreaming"
:disabled="childrenDisabled"
:project-id="projectId"
:agent-id="agentId"
:is-published="Boolean(agent?.publishedVersion)"
@ -116,7 +120,7 @@ const i18n = useI18n();
<N8nCard variant="outlined" :class="$style.card">
<AgentInfoPanel
:config="localConfig"
:disabled="isBuildChatStreaming"
:disabled="childrenDisabled"
embedded
@update:config="emit('update:config', $event)"
/>
@ -125,7 +129,7 @@ const i18n = useI18n();
<N8nCard variant="outlined" :class="$style.card">
<AgentMemoryPanel
:config="localConfig"
:disabled="isBuildChatStreaming"
:disabled="childrenDisabled"
embedded
@update:config="emit('update:config', $event)"
/>
@ -134,7 +138,7 @@ const i18n = useI18n();
<N8nCard variant="outlined" :class="$style.card">
<AgentAdvancedPanel
:config="localConfig"
:disabled="isBuildChatStreaming"
:disabled="childrenDisabled"
collapsible
@update:config="emit('update:config', $event)"
/>
@ -149,7 +153,7 @@ const i18n = useI18n();
<div v-else-if="activeMainTab === 'raw'" :class="$style.rawPanel">
<AgentJsonEditor
:value="localConfig"
:read-only="isBuildChatStreaming"
:read-only="childrenDisabled"
copy-button-test-id="agent-config-json-copy"
@update:value="emit('update:config', $event)"
/>

View File

@ -146,6 +146,7 @@ function onBreadcrumbSelect(item: PathItem) {
@reverted="(a: AgentResource) => emit('reverted', a)"
/>
<N8nActionDropdown
v-if="headerActions.length > 0"
:items="headerActions"
activator-icon="ellipsis"
activator-size="medium"

View File

@ -1,13 +1,14 @@
<script setup lang="ts">
import { computed } from 'vue';
import dateformat from 'dateformat';
import { N8nActionToggle, N8nCard, N8nText } from '@n8n/design-system';
import { N8nActionToggle, N8nBadge, N8nCard, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import { MODAL_CONFIRM } from '@/app/constants';
import TimeAgo from '@/app/components/TimeAgo.vue';
import { deleteAgent } from '../composables/useAgentApi';
import { useAgentConfirmationModal } from '../composables/useAgentConfirmationModal';
import { useAgentPermissions } from '../composables/useAgentPermissions';
import { useAgentPublish } from '../composables/useAgentPublish';
import type { AgentResource } from '../types';
@ -27,18 +28,34 @@ const locale = useI18n();
const rootStore = useRootStore();
const { openAgentConfirmationModal } = useAgentConfirmationModal();
const { publish, unpublish } = useAgentPublish();
const { canUpdate, canDelete, canPublish, canUnpublish } = useAgentPermissions(
() => props.projectId,
);
const isPublished = computed(() => props.agent.publishedVersion !== null);
const actions = computed(() => {
return [
isPublished.value
? { value: 'unpublish', label: locale.baseText('agents.list.actions.unpublish') }
: { value: 'publish', label: locale.baseText('agents.list.actions.publish') },
{ value: 'delete', label: locale.baseText('agents.list.actions.delete'), divided: true },
];
const items: Array<{ value: string; label: string; divided?: boolean }> = [];
if (isPublished.value && canUnpublish.value) {
items.push({ value: 'unpublish', label: locale.baseText('agents.list.actions.unpublish') });
} else if (!isPublished.value && canPublish.value) {
items.push({ value: 'publish', label: locale.baseText('agents.list.actions.publish') });
}
if (canDelete.value) {
items.push({
value: 'delete',
label: locale.baseText('agents.list.actions.delete'),
divided: items.length > 0,
});
}
return items;
});
const showActions = computed(() => actions.value.length > 0);
const formattedCreatedAtDate = computed(() => {
const currentYear = new Date().getFullYear().toString();
@ -78,6 +95,15 @@ async function onAction(action: string) {
<template #header>
<N8nText tag="h2" bold :class="$style.cardHeading" data-test-id="agent-card-name">
{{ agent.name }}
<N8nBadge
v-if="!canUpdate"
:class="$style.readonlyBadge"
theme="tertiary"
bold
data-test-id="agent-card-readonly-badge"
>
{{ locale.baseText('agents.list.readonly') }}
</N8nBadge>
</N8nText>
</template>
<div :class="$style.cardDescription">
@ -100,6 +126,7 @@ async function onAction(action: string) {
</N8nText>
</div>
<N8nActionToggle
v-if="showActions"
:actions="actions"
theme="dark"
data-test-id="agent-card-actions"
@ -130,6 +157,10 @@ async function onAction(action: string) {
padding: var(--spacing--sm) 0 0 var(--spacing--sm);
}
.readonlyBadge {
margin-left: var(--spacing--3xs);
}
.cardDescription {
min-height: var(--spacing--xl);
display: flex;

View File

@ -22,6 +22,7 @@ const props = withDefaults(
agentConfig: AgentJsonConfig | null;
agentStatus: 'draft' | 'production';
connectedTriggers: string[];
canEditAgent?: boolean;
beforeSend?: () => Promise<void> | void;
inputDraft?: string;
}>(),
@ -31,6 +32,7 @@ const props = withDefaults(
endpoint: 'chat',
initialMessage: undefined,
continueSessionId: undefined,
canEditAgent: true,
beforeSend: undefined,
inputDraft: undefined,
},
@ -111,10 +113,14 @@ const hasOpenInteractiveQuestion = computed(() =>
messages.value.some((message) => message.interactive && !message.interactive.resolvedAt),
);
const isBuilderReadOnly = computed(() => props.endpoint === 'build' && !props.canEditAgent);
const chatPlaceholder = computed(() =>
hasOpenInteractiveQuestion.value
? locale.baseText('agents.chat.answerQuestionPlaceholder')
: locale.baseText('agents.chat.input.placeholder'),
isBuilderReadOnly.value
? locale.baseText('agents.builder.readonly.placeholder')
: hasOpenInteractiveQuestion.value
? locale.baseText('agents.chat.answerQuestionPlaceholder')
: locale.baseText('agents.chat.input.placeholder'),
);
function onOpenBuild() {
@ -126,9 +132,14 @@ watch(isStreaming, (v) => emit('update:streaming', v));
async function onSubmit() {
const text = inputText.value.trim();
if (!text || isStreaming.value || isPreparingToSend.value || hasOpenInteractiveQuestion.value) {
if (
!text ||
isStreaming.value ||
isPreparingToSend.value ||
isBuilderReadOnly.value ||
hasOpenInteractiveQuestion.value
)
return;
}
isPreparingToSend.value = true;
try {
@ -276,9 +287,11 @@ onBeforeUnmount(() => {
!hasOpenInteractiveQuestion &&
!isStreaming &&
!isPreparingToSend &&
!isBuilderReadOnly &&
inputText.trim().length > 0
"
:disabled="
isBuilderReadOnly ||
hasOpenInteractiveQuestion ||
isPreparingToSend ||
(isStreaming && messagingState !== 'receiving')

View File

@ -3,6 +3,7 @@ import { computed } from 'vue';
import { N8nActionDropdown, N8nButton, N8nIconButton } from '@n8n/design-system';
import type { ActionDropdownItem } from '@n8n/design-system/types/action-dropdown';
import { useI18n } from '@n8n/i18n';
import { useAgentPermissions } from '../composables/useAgentPermissions';
import { useAgentPublish } from '../composables/useAgentPublish';
import type { AgentResource } from '../types';
@ -14,6 +15,8 @@ const props = defineProps<{
beforeRevertToPublished?: () => Promise<void> | void;
}>();
const { canUpdate, canPublish, canUnpublish } = useAgentPermissions(() => props.projectId);
const emit = defineEmits<{
published: [agent: AgentResource];
unpublished: [agent: AgentResource];
@ -64,7 +67,8 @@ const dropdownActions = computed(() => {
{
id: 'publish',
label: locale.baseText('agents.publish.dropdown.publish'),
disabled: !buttonConfig.value.enabled || publishing.value || props.isSaving,
disabled:
!buttonConfig.value.enabled || publishing.value || props.isSaving || !canPublish.value,
},
];
@ -72,14 +76,15 @@ const dropdownActions = computed(() => {
actions.push({
id: 'revert-to-published',
label: locale.baseText('agents.publish.dropdown.revertToPublished'),
disabled: publishing.value || props.isSaving,
disabled: publishing.value || props.isSaving || !canUpdate.value,
});
}
actions.push({
id: 'unpublish',
label: locale.baseText('agents.publish.dropdown.unpublish'),
disabled: !props.agent?.publishedVersion || publishing.value || props.isSaving,
disabled:
!props.agent?.publishedVersion || publishing.value || props.isSaving || !canUnpublish.value,
divided: true,
});
@ -87,7 +92,7 @@ const dropdownActions = computed(() => {
});
async function onPublishClick() {
if (!buttonConfig.value.enabled || props.isSaving) return;
if (!buttonConfig.value.enabled || props.isSaving || !canPublish.value) return;
const updated = await publish(props.projectId, props.agentId);
if (updated) emit('published', updated);
}
@ -98,13 +103,13 @@ async function onDropdownSelect(action: string) {
return;
}
if (action === 'revert-to-published') {
if (!props.agent?.publishedVersion || props.isSaving) return;
if (!props.agent?.publishedVersion || props.isSaving || !canUpdate.value) return;
await props.beforeRevertToPublished?.();
const updated = await revertToPublished(props.projectId, props.agentId);
if (updated) emit('reverted', updated);
return;
}
if (action !== 'unpublish') return;
if (action !== 'unpublish' || !canUnpublish.value) return;
const updated = await unpublish(props.projectId, props.agentId, props.agent?.name);
if (updated) emit('unpublished', updated);
}
@ -115,7 +120,7 @@ async function onDropdownSelect(action: string) {
<N8nButton
:class="$style.groupButtonLeft"
:loading="publishing"
:disabled="!buttonConfig.enabled || isSaving"
:disabled="!buttonConfig.enabled || isSaving || !canPublish"
variant="ghost"
data-testid="publish-agent-button"
@click="onPublishClick"

View File

@ -0,0 +1,36 @@
import { computed, toValue, type ComputedRef, type MaybeRefOrGetter } from 'vue';
import { getResourcePermissions } from '@n8n/permissions';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { useUsersStore } from '@/features/settings/users/users.store';
type AgentPermissionKey = 'create' | 'update' | 'delete' | 'publish' | 'unpublish';
export type AgentPermissions = Record<`can${Capitalize<AgentPermissionKey>}`, ComputedRef<boolean>>;
export function useAgentPermissions(
projectId: MaybeRefOrGetter<string | undefined>,
): AgentPermissions {
const projectsStore = useProjectsStore();
const usersStore = useUsersStore();
const projectScopes = computed(
() =>
getResourcePermissions(
projectsStore.myProjects?.find((p) => p.id === toValue(projectId))?.scopes,
).agent,
);
const globalScopes = computed(
() => getResourcePermissions(usersStore.currentUser?.globalScopes).agent,
);
const pick = (key: AgentPermissionKey): ComputedRef<boolean> =>
computed(() => Boolean(globalScopes.value[key] ?? projectScopes.value[key]));
return {
canCreate: pick('create'),
canUpdate: pick('update'),
canDelete: pick('delete'),
canPublish: pick('publish'),
canUnpublish: pick('unpublish'),
};
}

View File

@ -26,6 +26,7 @@ import { useAgentBuilderTelemetry } from '../composables/useAgentBuilderTelemetr
import { useAgentConfirmationModal } from '../composables/useAgentConfirmationModal';
import { useAgentConfig } from '../composables/useAgentConfig';
import { useAgentBuilderStatus } from '../composables/useAgentBuilderStatus';
import { useAgentPermissions } from '../composables/useAgentPermissions';
import { useAgentSessionsStore } from '../agentSessions.store';
import { useAgentBuilderSession } from '../composables/useAgentBuilderSession';
import { useAgentChatMode, type ChatMode } from '../composables/useAgentChatMode';
@ -68,6 +69,8 @@ const projectId = computed(
);
const agentId = computed(() => route.params.agentId as string);
const { canUpdate: canEditAgent, canDelete: canDeleteAgent } = useAgentPermissions(projectId);
// UI state
const {
chatMode,
@ -457,7 +460,11 @@ async function onConfigUpdated() {
builderTelemetry.trackSkillsAdded();
}
const headerActions = [{ id: 'delete', label: locale.baseText('agents.builder.deleteAgent') }];
const headerActions = computed(() =>
canDeleteAgent.value
? [{ id: 'delete', label: locale.baseText('agents.builder.deleteAgent') }]
: [],
);
async function onHeaderAction(action: string) {
if (action === 'delete') {
@ -894,6 +901,7 @@ function onSwitchAgent(nextAgentId: string) {
:is-build-chat-streaming="isBuildChatStreaming"
:is-published="Boolean(agent?.publishedVersion)"
:is-full-width="isChatFullWidth"
:can-edit-agent="canEditAgent"
:before-build-send="flushAutosave"
@session-select="onSessionPick"
@new-chat="onNewChat"
@ -921,6 +929,7 @@ function onSwitchAgent(nextAgentId: string) {
:applied-skills="appliedSkills"
:connected-triggers="connectedTriggers"
:is-build-chat-streaming="isBuildChatStreaming"
:can-edit-agent="canEditAgent"
:main-tab-options="mainTabOptions"
:executions-description="executionsDescription"
@update:config="onConfigFieldUpdate"

View File

@ -306,7 +306,9 @@ describe('ProjectHeader', () => {
describe('new agent telemetry', () => {
beforeEach(() => {
settingsStore.isModuleActive = vi.fn().mockImplementation((mod) => mod === 'agents');
projectsStore.currentProject = createTestProject({ scopes: ['workflow:create'] });
projectsStore.currentProject = createTestProject({
scopes: ['workflow:create', 'agent:create'],
});
});
it('tracks source=button when the agent main button is clicked', async () => {
@ -632,5 +634,26 @@ describe('ProjectHeader', () => {
expect(queryByTestId('add-resource-variable')).toBeDisabled();
});
it('should enable agent create button when project scope allows it', () => {
settingsStore.isModuleActive = vi.fn().mockImplementation((mod) => mod === 'agents');
const project = createTestProject({ scopes: ['agent:create'] });
projectsStore.currentProject = project;
const { getByTestId } = renderComponent({ props: { mainButton: 'agent' } });
expect(getByTestId('add-resource-agent')).toBeInTheDocument();
expect(getByTestId('add-resource-agent')).toBeEnabled();
});
it('should disable agent create button when no scope allows it', () => {
settingsStore.isModuleActive = vi.fn().mockImplementation((mod) => mod === 'agents');
const project = createTestProject({ scopes: [] });
projectsStore.currentProject = project;
const { getByTestId } = renderComponent({ props: { mainButton: 'agent' } });
expect(getByTestId('add-resource-agent')).toBeDisabled();
});
});
});

View File

@ -200,7 +200,9 @@ const createAgentButton = computed(() => ({
value: ACTION_TYPES.AGENT,
label: i18n.baseText('projects.header.create.agent'),
size: 'mini' as const,
disabled: sourceControlStore.preferences.branchReadOnly,
disabled:
sourceControlStore.preferences.branchReadOnly ||
!getResourcePermissions(homeProject.value?.scopes).agent.create,
}));
const selectedMainButtonType = computed(() => {
@ -295,7 +297,9 @@ const menu = computed(() => {
items.push({
value: ACTION_TYPES.AGENT,
label: i18n.baseText('projects.header.create.agent'),
disabled: sourceControlStore.preferences.branchReadOnly,
disabled:
sourceControlStore.preferences.branchReadOnly ||
!getResourcePermissions(homeProject.value?.scopes).agent.create,
});
}

View File

@ -97,6 +97,30 @@ export class Code implements INodeType {
relationHint: 'Use this instead for creating html pages',
},
],
extraTypeDefContent: [
{
content: `<patterns>
<pattern title="runOnceForAllItems with $input.all()">
const codeNode = node({
type: 'n8n-nodes-base.code',
version: 2,
config: {
name: 'Process Data',
parameters: {
mode: 'runOnceForAllItems',
jsCode: \`
const items = $input.all();
return items.map(item => ({
json: { ...item.json, processed: true }
}));
\`.trim()
}
}
});
</pattern>
</patterns>`,
},
],
},
parameterPane: 'wide',
properties: [

View File

@ -27,6 +27,40 @@ export class DataTable implements INodeType {
usableAsTool: true,
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
builderHint: {
extraTypeDefContent: [
{
displayOptions: { show: { resource: ['row'], operation: ['insert'] } },
content: `<patterns>
<pattern title="Insert with explicit schema">
const storeData = node({
type: 'n8n-nodes-base.dataTable',
version: 1.1,
config: {
name: 'Store Data',
parameters: {
resource: 'row',
operation: 'insert',
dataTableId: { __rl: true, mode: 'name', value: 'my-table' },
columns: {
mappingMode: 'defineBelow',
value: {
name: expr('{{ $json.name }}'),
email: expr('{{ $json.email }}')
},
schema: [
{ id: 'name', displayName: 'name', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true },
{ id: 'email', displayName: 'email', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true }
]
}
}
}
});
</pattern>
</patterns>`,
},
],
},
hints: [
{
message: 'The selected data table has no columns.',

View File

@ -34,6 +34,10 @@ export const description: INodeProperties[] = [
value: get.FIELD,
description: 'Get row(s)',
action: 'Get row(s)',
builderHint: {
propertyHint:
"There is no `getAll` operation. To fetch many rows, use `operation: 'get'` with `returnAll: true`.",
},
},
{
name: 'If Row Exists',
@ -52,6 +56,10 @@ export const description: INodeProperties[] = [
value: insert.FIELD,
description: 'Insert a new row',
action: 'Insert row',
builderHint: {
propertyHint:
'Row IDs are auto-generated. Do NOT define a custom `id` column or seed `id` on insert. The built-in row `id` is valid for filtering update/delete but is not part of the user-defined table schema.',
},
},
{
name: 'Update',

View File

@ -29,6 +29,10 @@ export class FilterV2 implements INodeType {
outputs: [NodeConnectionTypes.Main],
outputNames: ['Kept', 'Discarded'],
parameterPane: 'wide',
builderHint: {
searchHint:
'Filter emits 0 items when nothing matches and the chain stops cleanly — no IF gate needed before downstream loops.',
},
properties: [
{
displayName: 'Conditions',
@ -43,6 +47,13 @@ export class FilterV2 implements INodeType {
version: '={{ $nodeVersion >= 2.3 ? 3 : $nodeVersion >= 2.2 ? 2 : 1 }}',
},
},
builderHint: {
propertyHint: `Must always contain these three sibling keys:
- combinator: 'and' or 'or', default to 'and'
- conditions: [ a list of condition objects ]
- options: { caseSensitive: true, leftValue: '', typeValidation: 'strict', version: 1 }
e.g.: { combinator: 'and', options: { caseSensitive: true, leftValue: '', typeValidation: 'strict', version: 2 }, conditions: [{ leftValue: expr('{{ $json.field }}'), rightValue: 'value', operator: { type: 'string', operation: 'equals' } }] }`,
},
},
{
...looseTypeValidationProperty,

View File

@ -23,6 +23,38 @@ export class GoogleSheets extends VersionedNodeType {
relationHint: 'Prefer for workflow data storage with upsert',
},
],
extraTypeDefContent: [
{
displayOptions: {
show: {
resource: ['sheet'],
operation: ['append', 'appendOrUpdate', 'update'],
},
},
content: `<patterns>
<pattern title="autoMapInputData — maps $json fields to sheet columns automatically">
columns: {
mappingMode: 'autoMapInputData',
value: {},
schema: [
{ id: 'Name', displayName: 'Name', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true },
{ id: 'Email', displayName: 'Email', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: false }
]
}
</pattern>
<pattern title="defineBelow — explicit expression mapping">
columns: {
mappingMode: 'defineBelow',
value: { name: expr('{{ $json.name }}'), email: expr('{{ $json.email }}') },
schema: [
{ id: 'name', displayName: 'name', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true },
{ id: 'email', displayName: 'email', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true }
]
}
</pattern>
</patterns>`,
},
],
},
};

View File

@ -81,7 +81,10 @@ export const descriptions: INodeProperties[] = [
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
builderHint: { propertyHint: "Default to mode: 'list' which is easier for users to set up" },
builderHint: {
propertyHint:
"Default to mode: 'list' which is easier for users to set up. Resource locator value must be `{ __rl: true, mode, value }` — never a plain string or `expr()` wrapper.",
},
modes: [
{
displayName: 'From List',
@ -139,7 +142,10 @@ export const descriptions: INodeProperties[] = [
default: { mode: 'list', value: '' },
// default: '', //empty string set to progresivly reveal fields
required: true,
builderHint: { propertyHint: "Default to mode: 'list' which is easier for users to set up" },
builderHint: {
propertyHint:
"Default to mode: 'list' which is easier for users to set up. Resource locator value must be `{ __rl: true, mode, value }` — never a plain string or `expr()` wrapper.",
},
typeOptions: {
loadOptionsDependsOn: ['documentId.value'],
},

View File

@ -6,7 +6,12 @@ import {
type ResourceMapperField,
} from 'n8n-workflow';
import { cellFormat, handlingExtraData, useAppendOption } from './commonDescription';
import {
cellFormat,
columnsResourceMapperBuilderHint,
handlingExtraData,
useAppendOption,
} from './commonDescription';
import type { GoogleSheet } from '../../helpers/GoogleSheet';
import type { SheetProperties, ValueInputOption } from '../../helpers/GoogleSheets.types';
import {
@ -127,6 +132,7 @@ export const description: SheetProperties = [
value: null,
},
required: true,
builderHint: columnsResourceMapperBuilderHint,
typeOptions: {
loadOptionsDependsOn: ['sheetName.value'],
resourceMapper: {

View File

@ -8,6 +8,7 @@ import { NodeOperationError } from 'n8n-workflow';
import {
cellFormat,
columnsResourceMapperBuilderHint,
handlingExtraData,
locationDefine,
useAppendOption,
@ -171,6 +172,7 @@ export const description: SheetProperties = [
value: null,
},
required: true,
builderHint: columnsResourceMapperBuilderHint,
typeOptions: {
loadOptionsDependsOn: ['sheetName.value'],
resourceMapper: {
@ -206,6 +208,7 @@ export const description: SheetProperties = [
value: null,
},
required: true,
builderHint: columnsResourceMapperBuilderHint,
typeOptions: {
loadOptionsDependsOn: ['sheetName.value'],
resourceMapper: {

View File

@ -1,4 +1,17 @@
import type { INodeProperties } from 'n8n-workflow';
import type { INodeProperties, IParameterBuilderHint } from 'n8n-workflow';
/**
* Builder hint shared by every Google Sheets `columns` resourceMapper parameter
* (append, appendOrUpdate, update). The full resourceMapper object shape is
* non-obvious, and a bare string like `'autoMapInputData'` silently fails
* validation. The matching `<patterns>` example lives on the node-level
* `builderHint.extraTypeDefContent` in `Google/Sheet/GoogleSheets.node.ts`,
* gated by `displayOptions: { show: { resource: ['sheet'], operation: [...] } }`.
*/
export const columnsResourceMapperBuilderHint: IParameterBuilderHint = {
propertyHint:
"Pass the full resourceMapper object: { mappingMode, value, schema }. A bare string like 'autoMapInputData' fails validation.",
};
export const dataLocationOnSheet: INodeProperties = {
displayName: 'Data Location on Sheet',

View File

@ -1,7 +1,12 @@
import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow';
import { NodeOperationError, UserError } from 'n8n-workflow';
import { cellFormat, handlingExtraData, locationDefine } from './commonDescription';
import {
cellFormat,
columnsResourceMapperBuilderHint,
handlingExtraData,
locationDefine,
} from './commonDescription';
import type { GoogleSheet } from '../../helpers/GoogleSheet';
import {
ROW_NUMBER,
@ -157,6 +162,7 @@ export const description: SheetProperties = [
value: null,
},
required: true,
builderHint: columnsResourceMapperBuilderHint,
typeOptions: {
loadOptionsDependsOn: ['sheetName.value'],
resourceMapper: {
@ -192,6 +198,7 @@ export const description: SheetProperties = [
value: null,
},
required: true,
builderHint: columnsResourceMapperBuilderHint,
typeOptions: {
loadOptionsDependsOn: ['sheetName.value'],
resourceMapper: {

View File

@ -29,6 +29,10 @@ export class IfV2 implements INodeType {
outputs: [NodeConnectionTypes.Main, NodeConnectionTypes.Main],
outputNames: ['true', 'false'],
parameterPane: 'wide',
builderHint: {
searchHint:
'After configuring, confirm the workflow wires both `.onTrue()` and `.onFalse()` (or only the relevant one) to the correct downstream node — IF has two named outputs and silently drops items routed to an unwired branch.',
},
properties: [
{
displayName: 'Conditions',

View File

@ -13,6 +13,93 @@ export const versionDescription: INodeTypeDescription = {
defaults: {
name: 'Merge',
},
builderHint: {
searchHint:
'Mode selection is the single most consequential decision on this node — the wrong mode silently drops or duplicates items rather than erroring. Pick by data shape: `append` to concatenate items from parallel branches; `combineByPosition` only when both branches emit the same number of items in the same order; `combineByFields` to join by a matching key (default; usually correct for "merge by ID"); `combineBySql` for >2 inputs or aggregation; `chooseBranch` to discard all but one input. Read each mode\'s @builderHint before picking.',
extraTypeDefContent: [
{
content: `<patterns>
<pattern title="Combine two branches by matching key (combineByFields, default)">
const usersApi = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: { name: 'Fetch Users', parameters: { url: 'https://api.example.com/users' } }
});
const ordersApi = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: { name: 'Fetch Orders', parameters: { url: 'https://api.example.com/orders' } }
});
const mergeNode = merge({
type: 'n8n-nodes-base.merge',
version: 3.2,
config: {
name: 'Merge by ID',
parameters: {
mode: 'combine',
combineBy: 'combineByFields',
fieldsToMatchString: 'id',
joinMode: 'keepMatches',
outputDataFrom: 'both'
}
}
});
// Wire each upstream branch to a specific merge input slot.
usersApi.to(mergeNode.input(0));
ordersApi.to(mergeNode.input(1));
</pattern>
<pattern title="Append items from parallel branches (append)">
const branchA = node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: { name: 'Branch A', parameters: {} }
});
const branchB = node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: { name: 'Branch B', parameters: {} }
});
const mergeNode = merge({
type: 'n8n-nodes-base.merge',
version: 3.2,
config: {
name: 'Append',
parameters: { mode: 'append', numberInputs: 2 }
}
});
branchA.to(mergeNode.input(0));
branchB.to(mergeNode.input(1));
</pattern>
<pattern title="Three or more inputs with SQL (combineBySql)">
const mergeNode = merge({
type: 'n8n-nodes-base.merge',
version: 3.2,
config: {
name: 'SQL Merge',
parameters: {
mode: 'combineBySql',
numberInputs: 3,
query: 'SELECT * FROM input1 LEFT JOIN input2 ON input1.id = input2.userId LEFT JOIN input3 ON input1.id = input3.userId'
}
}
});
input1Node.to(mergeNode.input(0));
input2Node.to(mergeNode.input(1));
input3Node.to(mergeNode.input(2));
</pattern>
</patterns>`,
},
],
},
inputs: `={{(${configuredInputs})($parameter)}}`,
outputs: [NodeConnectionTypes.Main],
// If mode is chooseBranch data from both branches is required

View File

@ -23,6 +23,30 @@ export class SplitInBatchesV3 implements INodeType {
outputs: [NodeConnectionTypes.Main, NodeConnectionTypes.Main],
outputNames: ['done', 'loop'],
builderHint: {
searchHint:
"Loop pattern: connect splitInBatches → per-item work → back to splitInBatches via `nextBatch(splitInBatches)`. The `done` output fires automatically after all items are processed. Already no-ops on empty input — do NOT add an IF gate before it to check 'has items?'.",
extraTypeDefContent: [
{
content: `<patterns>
<pattern title="Per-item loop using splitInBatches with nextBatch">
const sibNode = splitInBatches({
version: 3,
config: { name: 'Batch Process', parameters: { batchSize: 1 } }
});
export default workflow('id', 'name')
.add(startTrigger)
.to(fetchRecords)
.to(sibNode
.onDone(finalizeResults)
.onEachBatch(processRecord.to(nextBatch(sibNode)))
);
</pattern>
</patterns>`,
},
],
},
properties: [
{
displayName:

View File

@ -59,6 +59,52 @@ export class SwitchV3 implements INodeType {
},
inputs: [NodeConnectionTypes.Main],
outputs: `={{(${configuredOutputs})($parameter)}}`,
builderHint: {
extraTypeDefContent: [
{
displayOptions: { show: { mode: ['rules'] } },
content: `<patterns>
<pattern title="Switch with two cases plus a default branch">
const routeByPriority = switchCase({
version: 3.2,
config: {
name: 'Route by Priority',
parameters: {
rules: {
values: [
{
outputKey: 'urgent',
conditions: {
options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' },
conditions: [{ leftValue: expr('{{ $json.priority }}'), operator: { type: 'string', operation: 'equals' }, rightValue: 'urgent' }],
combinator: 'and'
}
},
{
outputKey: 'normal',
conditions: {
options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' },
conditions: [{ leftValue: expr('{{ $json.priority }}'), operator: { type: 'string', operation: 'equals' }, rightValue: 'normal' }],
combinator: 'and'
}
}
]
}
}
}
});
export default workflow('id', 'name')
.add(startTrigger)
.to(routeByPriority
.onCase('urgent', processUrgent.to(notifyTeam))
.onCase('normal', processNormal)
.onDefault(archive));
</pattern>
</patterns>`,
},
],
},
properties: [
{
displayName: 'Mode',
@ -128,6 +174,10 @@ export class SwitchV3 implements INodeType {
name: 'rules',
placeholder: 'Add Routing Rule',
type: 'fixedCollection',
builderHint: {
propertyHint:
"Use `rules.values` (NOT `rules.rules`). Each rule needs `outputKey` and a complete `conditions` object with these three sibling keys: `combinator` ('and' | 'or'), `conditions` (array of condition objects), `options` (`{ caseSensitive, leftValue, typeValidation }`). Same shape as IF. Each `outputKey` you define must be wired via `.onCase('<outputKey>')` to the intended downstream node — unwired cases silently drop their items.",
},
typeOptions: {
multipleValues: true,
sortable: true,

View File

@ -1,6 +1,6 @@
{
"name": "n8n-nodes-base",
"version": "2.20.0",
"version": "2.21.0",
"description": "Base nodes of n8n",
"main": "index.js",
"scripts": {

View File

@ -1,6 +1,6 @@
{
"name": "n8n-workflow",
"version": "2.20.0",
"version": "2.21.0",
"description": "Workflow base code of n8n",
"types": "dist/esm/index.d.ts",
"module": "dist/esm/index.js",

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