mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
Merge branch 'master' into tomi/update-typeorm-split-versions
This commit is contained in:
commit
e9bb4dff6f
89
.github/workflows/test-evals-instance-ai.yml
vendored
89
.github/workflows/test-evals-instance-ai.yml
vendored
|
|
@ -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: |
|
||||
|
|
|
|||
134
CHANGELOG.md
134
CHANGELOG.md
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-monorepo",
|
||||
"version": "2.20.0",
|
||||
"version": "2.21.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.16",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/api-types",
|
||||
"version": "1.20.0",
|
||||
"version": "1.21.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -415,6 +415,7 @@ export type {
|
|||
InstanceAiEvalInterceptedRequest,
|
||||
InstanceAiEvalNodeResult,
|
||||
InstanceAiEvalMockHints,
|
||||
InstanceAiEvalMockedCredential,
|
||||
InstanceAiEvalExecutionResult,
|
||||
InstanceAiEvalToolCall,
|
||||
InstanceAiEvalToolResult,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-common",
|
||||
"version": "1.20.0",
|
||||
"version": "1.21.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ export class ModuleRegistry {
|
|||
'instance-version-history',
|
||||
'encryption-key-manager',
|
||||
'oauth-jwe',
|
||||
'inbound-secrets',
|
||||
];
|
||||
|
||||
private readonly activeModules: string[] = [];
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/chat-hub",
|
||||
"version": "1.13.0",
|
||||
"version": "1.14.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/client-oauth2",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/config",
|
||||
"version": "2.19.0",
|
||||
"version": "2.20.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/db",
|
||||
"version": "1.20.0",
|
||||
"version": "1.21.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/decorators",
|
||||
"version": "1.20.0",
|
||||
"version": "1.21.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/imap",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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[]>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/n8n-nodes-langchain",
|
||||
"version": "2.20.0",
|
||||
"version": "2.21.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"exports": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/permissions",
|
||||
"version": "0.58.0",
|
||||
"version": "0.59.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
".": {
|
||||
|
|
|
|||
|
|
@ -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('<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('<patterns>');
|
||||
expect(result).not.toContain('<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');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -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, '<')
|
||||
.replace(/>/g, '>');
|
||||
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, '<')
|
||||
.replace(/>/g, '>');
|
||||
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, '<')
|
||||
.replace(/>/g, '>');
|
||||
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, '<')
|
||||
.replace(/>/g, '>');
|
||||
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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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.`;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import { Config } from '@n8n/config';
|
||||
|
||||
@Config
|
||||
export class InboundSecretsConfig {}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@n8n/i18n",
|
||||
"type": "module",
|
||||
"version": "2.20.0",
|
||||
"version": "2.21.0",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@n8n/rest-api-client",
|
||||
"type": "module",
|
||||
"version": "2.20.0",
|
||||
"version": "2.21.0",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@n8n/stores",
|
||||
"type": "module",
|
||||
"version": "2.20.0",
|
||||
"version": "2.21.0",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ?? []"
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
};
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue
Block a user