Merge remote-tracking branch 'origin/master' into reattach-chat-owner-on-reload

# Conflicts:
#	packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts
#	packages/frontend/editor-ui/src/features/agents/composables/useAgentChatStream.ts
This commit is contained in:
Robin Braumann 2026-09-22 19:42:11 +02:00
commit b048286161
No known key found for this signature in database
338 changed files with 13305 additions and 2869 deletions

View File

@ -41,6 +41,7 @@ add to it.
## Team defaults
- Write code that acts as its own documentation. The schema, the decorator, and the test should make the rule clear on their own without a comment.
- List endpoints: cursor-based pagination (internal API uses both cursor- and
page-based — don't copy an internal endpoint's model).
- Pagination args are always `offset` and `limit` — on service methods, handler

View File

@ -299,12 +299,12 @@ jobs:
files: ./packages/**/coverage/lcov.info
unit-test-frontend:
name: Frontend (${{ matrix.shard }}/2)
name: Frontend (${{ matrix.shard }}/4)
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}
strategy:
fail-fast: false
matrix:
shard: [1, 2]
shard: [1, 2, 3, 4]
env:
COVERAGE_ENABLED: ${{ inputs.collectCoverage }}
AFFECTED_PACKAGES: ${{ inputs.affectedPackages }}
@ -320,9 +320,9 @@ jobs:
node-version: ${{ inputs.nodeVersion }}
- name: Test
run: pnpm test:ci:frontend:changed --summarize -- --shard=${{ matrix.shard }}/2
run: pnpm test:ci:frontend:changed --summarize -- --shard=${{ matrix.shard }}/4
env:
VITEST_SHARD: ${{ matrix.shard }}/2
VITEST_SHARD: ${{ matrix.shard }}/4
- name: Send Test Stats
if: ${{ !cancelled() }}

View File

@ -67,29 +67,31 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CeuLqshPA7QoqLUtLRhcA\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01Krds9KGve8gJNTNa9jn6N7\",\"name\":\"load_skill\",\"input\":{\"skillId\":\"engineering-status\"},\"caller\":{\"type\":\"direct\"}}],\"container\":null,\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1045,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":57,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CfJCd2zmwW8c3i9MDr6fG\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I'll load the engineering-status skill first, then use it to format the status token.\"},{\"type\":\"tool_use\",\"id\":\"toolu_019R6LssCYfGrz8ddVcPvYQC\",\"name\":\"load_skill\",\"input\":{\"skillId\":\"engineering-status\"},\"caller\":{\"type\":\"direct\"}}],\"container\":null,\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1045,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":76,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-09-10T07:12:04Z",
"anthropic-ratelimit-input-tokens-reset": "2026-09-22T08:57:55Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-09-10T07:12:04Z",
"anthropic-ratelimit-output-tokens-reset": "2026-09-22T08:57:55Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-09-10T07:12:04Z",
"anthropic-ratelimit-requests-reset": "2026-09-22T08:57:55Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-09-10T07:12:04Z",
"anthropic-ratelimit-tokens-reset": "2026-09-22T08:57:55Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a38c9f485dd8bc7b-PRG",
"cf-ray": "a3f01ad7ea6cbbdd-FRA",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Thu, 10 Sep 2026 07:12:04 GMT",
"request-id": "req_011CeuLqs6fxp1AkSuMSRWYb",
"date": "Tue, 22 Sep 2026 08:57:55 GMT",
"request-id": "req_011CfJCd2nsYW1GzkhCoPFhw",
"server": "cloudflare",
"server-timing": "x-originResponse;dur=667",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-77c37e49f4c62f7623d8cc66c4f3a26a-6bf7325e3f3f69d3-01",
"traceresponse": "00-234e70eda24ff35d6c847bca1695098f-c9010c0bb2f634e5-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -105,7 +107,7 @@
"system": [
{
"type": "text",
"text": "Skill loading protocol:\nSkills are optional instruction packs, not execution tools. Use them to get extra guidance only when they are relevant to the user's current request.\n\nAvailable skills:\n- name: \"Engineering status formatter\"\n description: \"Use for formatting engineering workflow status identifiers.\"\n id: \"engineering-status\"\n category: \"engineering\"\n\nWhen deciding whether to load a skill:\n- Match the user's request against the skill name and description.\n- If one skill clearly matches, call load_skill once with `{ \"skillId\": \"<id>\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"<id>\", \"filePath\": \"<relative path>\" }`.\n- If the relevant skill was already loaded for this request, do not call load_skill again.\n- If no skill clearly matches, do not call load_skill.\n- Do not load a skill just because it is listed here.\n\nYou are testing runtime skill loading. Use the skill catalog in your instructions to pick a matching skillId. Call load_skill with the exact matching skillId before answering when a skill applies. After loading a skill, strictly follow its returned instructions. Do not answer from memory when a relevant skill applies.\n\n<active_skills>\n\nUse these current skill versions when relevant to the task. Earlier tool results may describe older versions.\n\n[Skill: \"Engineering status formatter\"]\n[Skill category: \"engineering\"]\n[Skill hash: \"f62e1e0bd274\"]\n\nFor status-formatting requests, call the format_status tool with the exact status token from the user. Use the returned formatted value verbatim in the final answer and include STATUS_FORMATTED_BRAVO. Do not format the status manually.\n\n</active_skills>"
"text": "Skill loading protocol:\nSkills are optional instruction packs, not execution tools. Use them to get extra guidance only when they are relevant to the user's current request.\n\nAvailable skills:\n- name: \"Engineering status formatter\"\n description: \"Use for formatting engineering workflow status identifiers.\"\n id: \"engineering-status\"\n category: \"engineering\"\n\nWhen deciding whether to load a skill:\n- Match the user's request against the skill name and description.\n- If one skill clearly matches, call load_skill once with `{ \"skillId\": \"<id>\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"<id>\", \"filePath\": \"<relative path>\" }`.\n- If the relevant skill was already loaded for this request, do not call load_skill again.\n- If no skill clearly matches, do not call load_skill.\n- Do not load a skill just because it is listed here.\n\nYou are testing runtime skill loading. Use the skill catalog in your instructions to pick a matching skillId. Call load_skill with the exact matching skillId before answering when a skill applies. After loading a skill, strictly follow its returned instructions. Do not answer from memory when a relevant skill applies."
}
],
"messages": [
@ -121,9 +123,13 @@
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "I'll load the engineering-status skill first, then use it to format the status token."
},
{
"type": "tool_use",
"id": "toolu_01Krds9KGve8gJNTNa9jn6N7",
"id": "toolu_019R6LssCYfGrz8ddVcPvYQC",
"name": "load_skill",
"input": {
"skillId": "engineering-status"
@ -139,8 +145,17 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01Krds9KGve8gJNTNa9jn6N7",
"content": "{\"skillId\":\"engineering-status\",\"active\":true}"
"tool_use_id": "toolu_019R6LssCYfGrz8ddVcPvYQC",
"content": [
{
"type": "text",
"text": "{\"skillId\":\"engineering-status\",\"active\":true}"
},
{
"type": "text",
"text": "[Skill: \"Engineering status formatter\"]\n[Skill category: \"engineering\"]\n[Skill hash: \"f62e1e0bd274\"]\n\nFor status-formatting requests, call the format_status tool with the exact status token from the user. Use the returned formatted value verbatim in the final answer and include STATUS_FORMATTED_BRAVO. Do not format the status manually."
}
]
}
]
}
@ -189,29 +204,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CeuLqxFjGLPD3QE1zh7Ef\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Now I'll format the status token:\"},{\"type\":\"tool_use\",\"id\":\"toolu_01CKRsNPiekmX9TWf3ooQdHU\",\"name\":\"format_status\",\"input\":{\"status\":\"waiting_on_vendor_info\"},\"caller\":{\"type\":\"direct\"}}],\"container\":null,\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1244,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":68,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CfJCd6hmu4KWXr78SxvJK\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Now I'll format the status token using the format_status tool:\"},{\"type\":\"tool_use\",\"id\":\"toolu_011UF3b8wMRddXy2WYPGRYZe\",\"name\":\"format_status\",\"input\":{\"status\":\"waiting_on_vendor_info\"},\"caller\":{\"type\":\"direct\"}}],\"container\":null,\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1230,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":74,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-09-10T07:12:05Z",
"anthropic-ratelimit-input-tokens-reset": "2026-09-22T08:57:57Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-09-10T07:12:05Z",
"anthropic-ratelimit-output-tokens-reset": "2026-09-22T08:57:57Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-09-10T07:12:05Z",
"anthropic-ratelimit-requests-reset": "2026-09-22T08:57:56Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-09-10T07:12:05Z",
"anthropic-ratelimit-tokens-reset": "2026-09-22T08:57:57Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a38c9f4f38babc7b-PRG",
"cf-ray": "a3f01add2e2bbbdd-FRA",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Thu, 10 Sep 2026 07:12:06 GMT",
"request-id": "req_011CeuLqwpw3fPBk6xt6nrrm",
"date": "Tue, 22 Sep 2026 08:57:57 GMT",
"request-id": "req_011CfJCd6Vcovu2SLcUWAPC4",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-d17795404368021a8e502b8755cc4b35-c750063766d5eb87-01",
"traceresponse": "00-e98096358e475d79d338f7d1cf690dbb-aa6a8d0521eacc76-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -227,7 +243,7 @@
"system": [
{
"type": "text",
"text": "Skill loading protocol:\nSkills are optional instruction packs, not execution tools. Use them to get extra guidance only when they are relevant to the user's current request.\n\nAvailable skills:\n- name: \"Engineering status formatter\"\n description: \"Use for formatting engineering workflow status identifiers.\"\n id: \"engineering-status\"\n category: \"engineering\"\n\nWhen deciding whether to load a skill:\n- Match the user's request against the skill name and description.\n- If one skill clearly matches, call load_skill once with `{ \"skillId\": \"<id>\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"<id>\", \"filePath\": \"<relative path>\" }`.\n- If the relevant skill was already loaded for this request, do not call load_skill again.\n- If no skill clearly matches, do not call load_skill.\n- Do not load a skill just because it is listed here.\n\nYou are testing runtime skill loading. Use the skill catalog in your instructions to pick a matching skillId. Call load_skill with the exact matching skillId before answering when a skill applies. After loading a skill, strictly follow its returned instructions. Do not answer from memory when a relevant skill applies.\n\n<active_skills>\n\nUse these current skill versions when relevant to the task. Earlier tool results may describe older versions.\n\n[Skill: \"Engineering status formatter\"]\n[Skill category: \"engineering\"]\n[Skill hash: \"f62e1e0bd274\"]\n\nFor status-formatting requests, call the format_status tool with the exact status token from the user. Use the returned formatted value verbatim in the final answer and include STATUS_FORMATTED_BRAVO. Do not format the status manually.\n\n</active_skills>"
"text": "Skill loading protocol:\nSkills are optional instruction packs, not execution tools. Use them to get extra guidance only when they are relevant to the user's current request.\n\nAvailable skills:\n- name: \"Engineering status formatter\"\n description: \"Use for formatting engineering workflow status identifiers.\"\n id: \"engineering-status\"\n category: \"engineering\"\n\nWhen deciding whether to load a skill:\n- Match the user's request against the skill name and description.\n- If one skill clearly matches, call load_skill once with `{ \"skillId\": \"<id>\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"<id>\", \"filePath\": \"<relative path>\" }`.\n- If the relevant skill was already loaded for this request, do not call load_skill again.\n- If no skill clearly matches, do not call load_skill.\n- Do not load a skill just because it is listed here.\n\nYou are testing runtime skill loading. Use the skill catalog in your instructions to pick a matching skillId. Call load_skill with the exact matching skillId before answering when a skill applies. After loading a skill, strictly follow its returned instructions. Do not answer from memory when a relevant skill applies."
}
],
"messages": [
@ -243,9 +259,13 @@
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "I'll load the engineering-status skill first, then use it to format the status token."
},
{
"type": "tool_use",
"id": "toolu_01Krds9KGve8gJNTNa9jn6N7",
"id": "toolu_019R6LssCYfGrz8ddVcPvYQC",
"name": "load_skill",
"input": {
"skillId": "engineering-status"
@ -261,8 +281,17 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01Krds9KGve8gJNTNa9jn6N7",
"content": "{\"skillId\":\"engineering-status\",\"active\":true}"
"tool_use_id": "toolu_019R6LssCYfGrz8ddVcPvYQC",
"content": [
{
"type": "text",
"text": "{\"skillId\":\"engineering-status\",\"active\":true}"
},
{
"type": "text",
"text": "[Skill: \"Engineering status formatter\"]\n[Skill category: \"engineering\"]\n[Skill hash: \"f62e1e0bd274\"]\n\nFor status-formatting requests, call the format_status tool with the exact status token from the user. Use the returned formatted value verbatim in the final answer and include STATUS_FORMATTED_BRAVO. Do not format the status manually."
}
]
}
]
},
@ -271,11 +300,11 @@
"content": [
{
"type": "text",
"text": "Now I'll format the status token:"
"text": "Now I'll format the status token using the format_status tool:"
},
{
"type": "tool_use",
"id": "toolu_01CKRsNPiekmX9TWf3ooQdHU",
"id": "toolu_011UF3b8wMRddXy2WYPGRYZe",
"name": "format_status",
"input": {
"status": "waiting_on_vendor_info"
@ -291,7 +320,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01CKRsNPiekmX9TWf3ooQdHU",
"tool_use_id": "toolu_011UF3b8wMRddXy2WYPGRYZe",
"content": "{\"formatted\":\"STATUS_FORMATTED_BRAVO: WAITING_ON_VENDOR_INFO\"}"
}
]
@ -341,29 +370,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CeuLr3AQ92UTrYgeFRerd\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"The status token has been formatted successfully:\\n\\n**STATUS_FORMATTED_BRAVO: WAITING_ON_VENDOR_INFO**\"}],\"container\":null,\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1346,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":32,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CfJCdCtqry5T2NJGAMCjV\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Perfect! The engineering-status skill has been loaded and the status token has been formatted.\\n\\n**Formatted Status:**\\nSTATUS_FORMATTED_BRAVO: WAITING_ON_VENDOR_INFO\"}],\"container\":null,\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1338,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":46,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-09-10T07:12:06Z",
"anthropic-ratelimit-input-tokens-reset": "2026-09-22T08:57:57Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-09-10T07:12:06Z",
"anthropic-ratelimit-output-tokens-reset": "2026-09-22T08:57:58Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-09-10T07:12:06Z",
"anthropic-ratelimit-requests-reset": "2026-09-22T08:57:57Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-09-10T07:12:06Z",
"anthropic-ratelimit-tokens-reset": "2026-09-22T08:57:57Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a38c9f561a95bc7b-PRG",
"cf-ray": "a3f01ae61be4bbdd-FRA",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Thu, 10 Sep 2026 07:12:07 GMT",
"request-id": "req_011CeuLr2Whz42pkAm7AzGhs",
"date": "Tue, 22 Sep 2026 08:57:58 GMT",
"request-id": "req_011CfJCdCcii1dW1VhpqXjuj",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-80319e2d05f81fe007810b9d0f60ed01-c7956b0a3a60554e-01",
"traceresponse": "00-4d1157be05f9b0ab551736730e6ba0e2-b17f94e742ef7628-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},

View File

@ -51,29 +51,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CeuLqj5DrEFutdjSD8ETs\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01Eq5aFLJhGriLQF4UBg7tyT\",\"name\":\"load_skill\",\"input\":{\"skillId\":\"summary-marker\"},\"caller\":{\"type\":\"direct\"}}],\"container\":null,\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":957,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":57,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CfJCcuKuoVBeB7Ake3FNv\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01Rhg9DwH7n3MzSh9iz6TFzh\",\"name\":\"load_skill\",\"input\":{\"skillId\":\"summary-marker\"},\"caller\":{\"type\":\"direct\"}}],\"container\":null,\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":957,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":57,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-09-10T07:12:02Z",
"anthropic-ratelimit-input-tokens-reset": "2026-09-22T08:57:53Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-09-10T07:12:02Z",
"anthropic-ratelimit-output-tokens-reset": "2026-09-22T08:57:54Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-09-10T07:12:02Z",
"anthropic-ratelimit-requests-reset": "2026-09-22T08:57:53Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-09-10T07:12:02Z",
"anthropic-ratelimit-tokens-reset": "2026-09-22T08:57:53Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a38c9f3bd9d2bc7b-PRG",
"cf-ray": "a3f01acbda36bbdd-FRA",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Thu, 10 Sep 2026 07:12:02 GMT",
"request-id": "req_011CeuLqiWV7tDxhVGbXmda8",
"date": "Tue, 22 Sep 2026 08:57:54 GMT",
"request-id": "req_011CfJCcteyXnhh5BmvQdDTp",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-7cf7d7c3cfa9378a22214da3c62a1ea0-1ad2b8e7d42a1569-01",
"traceresponse": "00-66ff12c75cd9a23f50952244bb3cba2c-133f511c645be506-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -89,7 +90,7 @@
"system": [
{
"type": "text",
"text": "Skill loading protocol:\nSkills are optional instruction packs, not execution tools. Use them to get extra guidance only when they are relevant to the user's current request.\n\nAvailable skills:\n- name: \"Summary marker\"\n description: \"Use for summarizing notes, updates, or short text while preserving a marker.\"\n id: \"summary-marker\"\n category: \"productivity\"\n\nWhen deciding whether to load a skill:\n- Match the user's request against the skill name and description.\n- If one skill clearly matches, call load_skill once with `{ \"skillId\": \"<id>\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"<id>\", \"filePath\": \"<relative path>\" }`.\n- If the relevant skill was already loaded for this request, do not call load_skill again.\n- If no skill clearly matches, do not call load_skill.\n- Do not load a skill just because it is listed here.\n\nYou are testing runtime skill loading. Use the skill catalog in your instructions to pick a matching skillId. Call load_skill with the exact matching skillId before answering when a skill applies. After loading a skill, strictly follow its returned instructions. Do not answer from memory when a relevant skill applies.\n\n<active_skills>\n\nUse these current skill versions when relevant to the task. Earlier tool results may describe older versions.\n\n[Skill: \"Summary marker\"]\n[Skill category: \"productivity\"]\n[Skill hash: \"e3433b77c9e3\"]\n\nEvery final answer after loading this skill must include the exact marker SKILL_MARKER_ALPHA. For summaries, be concise and keep the marker unchanged.\n\n</active_skills>"
"text": "Skill loading protocol:\nSkills are optional instruction packs, not execution tools. Use them to get extra guidance only when they are relevant to the user's current request.\n\nAvailable skills:\n- name: \"Summary marker\"\n description: \"Use for summarizing notes, updates, or short text while preserving a marker.\"\n id: \"summary-marker\"\n category: \"productivity\"\n\nWhen deciding whether to load a skill:\n- Match the user's request against the skill name and description.\n- If one skill clearly matches, call load_skill once with `{ \"skillId\": \"<id>\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"<id>\", \"filePath\": \"<relative path>\" }`.\n- If the relevant skill was already loaded for this request, do not call load_skill again.\n- If no skill clearly matches, do not call load_skill.\n- Do not load a skill just because it is listed here.\n\nYou are testing runtime skill loading. Use the skill catalog in your instructions to pick a matching skillId. Call load_skill with the exact matching skillId before answering when a skill applies. After loading a skill, strictly follow its returned instructions. Do not answer from memory when a relevant skill applies."
}
],
"messages": [
@ -107,7 +108,7 @@
"content": [
{
"type": "tool_use",
"id": "toolu_01Eq5aFLJhGriLQF4UBg7tyT",
"id": "toolu_01Rhg9DwH7n3MzSh9iz6TFzh",
"name": "load_skill",
"input": {
"skillId": "summary-marker"
@ -123,8 +124,17 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01Eq5aFLJhGriLQF4UBg7tyT",
"content": "{\"skillId\":\"summary-marker\",\"active\":true}"
"tool_use_id": "toolu_01Rhg9DwH7n3MzSh9iz6TFzh",
"content": [
{
"type": "text",
"text": "{\"skillId\":\"summary-marker\",\"active\":true}"
},
{
"type": "text",
"text": "[Skill: \"Summary marker\"]\n[Skill category: \"productivity\"]\n[Skill hash: \"e3433b77c9e3\"]\n\nEvery final answer after loading this skill must include the exact marker SKILL_MARKER_ALPHA. For summaries, be concise and keep the marker unchanged."
}
]
}
]
}
@ -157,29 +167,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CeuLqo1c45tLcGPFM5dEA\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"**Summary:** n8n agents maintain efficient workflow operations by keeping context compact, reducing overhead and improving performance.\\n\\nSKILL_MARKER_ALPHA\"}],\"container\":null,\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1139,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":34,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CfJCcxfLB1kLZMwT9hmcx\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Based on the Summary marker skill instructions, here's the summary:\\n\\n**Summary:** n8n agents maintain workflow context efficiency by keeping data structures and variables compact, reducing memory overhead and improving performance during workflow execution.\\n\\nSKILL_MARKER_ALPHA\"}],\"container\":null,\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1106,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":55,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-09-10T07:12:03Z",
"anthropic-ratelimit-input-tokens-reset": "2026-09-22T08:57:54Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-09-10T07:12:03Z",
"anthropic-ratelimit-output-tokens-reset": "2026-09-22T08:57:55Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-09-10T07:12:02Z",
"anthropic-ratelimit-requests-reset": "2026-09-22T08:57:54Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-09-10T07:12:03Z",
"anthropic-ratelimit-tokens-reset": "2026-09-22T08:57:54Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a38c9f417db9bc7b-PRG",
"cf-ray": "a3f01ad17e0abbdd-FRA",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Thu, 10 Sep 2026 07:12:03 GMT",
"request-id": "req_011CeuLqnRP3vkdg9opi7gNC",
"date": "Tue, 22 Sep 2026 08:57:55 GMT",
"request-id": "req_011CfJCcxUAmJjy1fhrHrwCP",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-86beb3374d817d1d427aabf5c903ca34-f5a5fa52f1cb2789-01",
"traceresponse": "00-7063877d53ab0eae4686a22ce533a4ec-69260e5b962e00c7-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},

View File

@ -53,32 +53,34 @@
},
"status": 200,
"response": [
"1f8b0800000000000013c5554d8bdb3010bde757189d1348c2ba21be9585d2427ae852e8a12942b1a6896259f2ea23ec12f4df3b72e2f823d96d2f253e18346fa479ef69d0c00194cb9212ac655ba0d631e3469c39962547e25e2b2019e98164dcac497624a5e620312597cc7398ec9828fce461924ee6d3793a9b4e67982e783cc36ee974367b04bf328ba765fa8def3fa63f5e162bf3b9c09c7e250c182d6380592bb0ac8a6573ad1c9225d9cf5fa705130a0cc99497724cacd31535c0ac56bd9085670f2a875e90036e96b689f9468e509577d4e9021482cbf4010bb17c0734c7939dd08af633a60d8e307f0b6bf6c60250eda004c3244dcbebfc169ded86681813ed5d3734ff806ac01c440ed489e8048956716678745dfd061385d32d68849476941d5035dba0b3212449188de074fb6767e946eabcb8dd033752ea221c5e4e52bb78547adee7b496d45b68fa20ae3d76c222cfbf2f3f6ded53517eddad9ee71b2b166a85598a95719fd468a82d84947519d48d878668a99451eaa500170672570b3a7fadae4aa8ed4048426290bca51dbbd9b1f7b5d7293deda7484be974737becc44b72857e09bcd77ddd9e84843ed1bb9038ae892561e8d95da8c47bfec2d7b52f77a69225688b2fb1a1eecba364e67582bf8284ebc6be0b23306b125a36ef528aafecdf9e105d7508b50735d3e6a6aa1e483a2a7acf7ff7d5f9e721309828e1ff8d85c13b9e2ec2c0cb76e05ebbd8c5c8e512fe00c6a9d291c7070000"
"1f8b0800000000000013cd544d6fd34010bdf757587376243b6a5a756f10a440c501845490085a4dbdd364f17ad7ec47441bf9bfa3b5ebc63629f482826f3b6fc6f3dedbd9a11d69cf928a9cc30d71e7d1fa33811e59b2077f5f1330188190f667607ba88c20050c0a8541d06c8bb20cb3f3d96236cfe68b3ccb7248418af80fb7e1599e2fefae9762e5de5cac1e56e2cb03bef5b95c423ae9042958a362009d93cea38e6d0ba33d690fecebb7ee80529305a6835229386f6a6e099dd1a390a31f817441a3a0208f52b93e167a3952d7c1736f4ad20ed8d5e23c85028b2df1c2127a69341f67643d6e09c573585f1b1b50bda58a2c2abea87ecf3fa0f9768a362998e087a1f9450a8eec4e16c4bd8c4e40b44aa015d1757d47360ae71b32c0401bcf718752e1ad22689a2469cecea8bbfd4767f9ad3245797c068ea4b44d04fdeca40ef1a8f4b1ce1ba37870d4cf413c079ee5d757bbf2e6f2755d7d78f5193fdd6cdf7f9ccf439c048d55ac53060577a554aa6d53070f6cdf444b958a529f1a0869a9f0ada0f63ba8aaa5de4c64241083f09c7241cae39f95b72923e55de440a8bbb7efcee8a7e41aad97a8da2030809eeb98ef49b8ecd7d0b9dcfc0764de8935b0640dae65736232a1aad0decf0e9775623e15da92ec1a9a178d4f5c737f7bc3a61e509afcaddff947c58d401888192de1e1db7ff12a9eecf5e6df2de7c9365d5c0e6d3de2c431478718b445bf00ccc6474350070000"
],
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "10000000",
"anthropic-ratelimit-input-tokens-reset": "2026-09-10T07:12:07Z",
"anthropic-ratelimit-input-tokens-reset": "2026-09-22T08:57:58Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-09-10T07:12:07Z",
"anthropic-ratelimit-output-tokens-reset": "2026-09-22T08:57:58Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-09-10T07:12:07Z",
"anthropic-ratelimit-requests-reset": "2026-09-22T08:57:58Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "12000000",
"anthropic-ratelimit-tokens-reset": "2026-09-10T07:12:07Z",
"anthropic-ratelimit-tokens-reset": "2026-09-22T08:57:58Z",
"cache-control": "no-cache",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a38c9f5c5a57bc7b-PRG",
"cf-ray": "a3f01aebf8c4bbdd-FRA",
"connection": "keep-alive",
"content-encoding": "gzip",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "text/event-stream; charset=utf-8",
"date": "Thu, 10 Sep 2026 07:12:08 GMT",
"request-id": "req_011CeuLr6og4ahrpyfnPec45",
"date": "Tue, 22 Sep 2026 08:57:59 GMT",
"request-id": "req_011CfJCdGeJvd8FiNh3bikfH",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-db154d560f530266545ecc9baf7015e2-c0470cb196446bc0-01",
"traceresponse": "00-5b8f08c4391de2116ab063ddaa4cdd99-24fa357e98008a81-01",
"transfer-encoding": "chunked",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -94,7 +96,7 @@
"system": [
{
"type": "text",
"text": "Skill loading protocol:\nSkills are optional instruction packs, not execution tools. Use them to get extra guidance only when they are relevant to the user's current request.\n\nAvailable skills:\n- name: \"Summary marker\"\n description: \"Use for summarizing notes, updates, or short text while preserving a marker.\"\n id: \"summary-marker\"\n category: \"productivity\"\n\nWhen deciding whether to load a skill:\n- Match the user's request against the skill name and description.\n- If one skill clearly matches, call load_skill once with `{ \"skillId\": \"<id>\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"<id>\", \"filePath\": \"<relative path>\" }`.\n- If the relevant skill was already loaded for this request, do not call load_skill again.\n- If no skill clearly matches, do not call load_skill.\n- Do not load a skill just because it is listed here.\n\nYou are testing runtime skill loading. Use the skill catalog in your instructions to pick a matching skillId. Call load_skill with the exact matching skillId before answering when a skill applies. After loading a skill, strictly follow its returned instructions. Do not answer from memory when a relevant skill applies.\n\n<active_skills>\n\nUse these current skill versions when relevant to the task. Earlier tool results may describe older versions.\n\n[Skill: \"Summary marker\"]\n[Skill category: \"productivity\"]\n[Skill hash: \"e3433b77c9e3\"]\n\nEvery final answer after loading this skill must include the exact marker SKILL_MARKER_ALPHA. For summaries, be concise and keep the marker unchanged.\n\n</active_skills>"
"text": "Skill loading protocol:\nSkills are optional instruction packs, not execution tools. Use them to get extra guidance only when they are relevant to the user's current request.\n\nAvailable skills:\n- name: \"Summary marker\"\n description: \"Use for summarizing notes, updates, or short text while preserving a marker.\"\n id: \"summary-marker\"\n category: \"productivity\"\n\nWhen deciding whether to load a skill:\n- Match the user's request against the skill name and description.\n- If one skill clearly matches, call load_skill once with `{ \"skillId\": \"<id>\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"<id>\", \"filePath\": \"<relative path>\" }`.\n- If the relevant skill was already loaded for this request, do not call load_skill again.\n- If no skill clearly matches, do not call load_skill.\n- Do not load a skill just because it is listed here.\n\nYou are testing runtime skill loading. Use the skill catalog in your instructions to pick a matching skillId. Call load_skill with the exact matching skillId before answering when a skill applies. After loading a skill, strictly follow its returned instructions. Do not answer from memory when a relevant skill applies."
}
],
"messages": [
@ -112,7 +114,7 @@
"content": [
{
"type": "tool_use",
"id": "toolu_017ccT9FgsRkmMhLq2bsi7nL",
"id": "toolu_01J9vkV7BpmPAWaSVhLQ22uC",
"name": "load_skill",
"input": {
"skillId": "summary-marker"
@ -128,8 +130,17 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_017ccT9FgsRkmMhLq2bsi7nL",
"content": "{\"skillId\":\"summary-marker\",\"active\":true}"
"tool_use_id": "toolu_01J9vkV7BpmPAWaSVhLQ22uC",
"content": [
{
"type": "text",
"text": "{\"skillId\":\"summary-marker\",\"active\":true}"
},
{
"type": "text",
"text": "[Skill: \"Summary marker\"]\n[Skill category: \"productivity\"]\n[Skill hash: \"e3433b77c9e3\"]\n\nEvery final answer after loading this skill must include the exact marker SKILL_MARKER_ALPHA. For summaries, be concise and keep the marker unchanged."
}
]
}
]
}
@ -164,32 +175,34 @@
},
"status": 200,
"response": [
"1f8b0800000000000013c555cb8e1a3110bcf315968f082266b3ec616e681569a32551b428a76c6419bb99b1f0d8133f106885948fc817e64bd21e98c0f0489443122e305d6557755334b00213725281f7bc00e60377a12779e03979a1615303cd6907a483f699e62fb4b212345284e651c2b0e46a1987b7c3f1f0667433ce46a30ce94aa63b7cc14659760f71eaee65b40f73fbf1bd288a30dddc21a7ab840567752a70ef15ca9a242bac096896e69f3eef1eb832e0686ea2d603ea83ad9903eeade9943c7c896004748a12f0b0f66d2db6ed2853c7c0825d824130cb5edfa112172530815707650deb52462d8eb0bc86b5679302d42554e0b866e3ea9c7f40b3f214dd0ea88da1e30fbb01b7520258506912348d4a7227d3d4cd025c6a9c156011323630bec2aef91c27bbdd1242b6bd1eecbefefd68d95c5bb1bc1c820b944645c27ad7ea319e3add9f0bb04ec4e62da734e936af8378ad4c71a246682ad26b06317381ffda6043e918dc55bac67ed2f6f6fafdbdc17fae3c8b55c5dd26eff7c92c605e2aec9f602a4df0042a1548b05693c693fffef51b274bd8e0af5694dc285f918575043f4add1cf31b234a678d8d9e28f4e6b848f9f30419a47676a564e2a18c1e0655015900c839174b22a34bc84e6c0d2236b9fd4f3379f56c9ecdecf1ed74cade4d9e1edf3cb1c9f4c3c3e490a1b3289d86d4d6bf8bb1ad8f6c5db9b5dd7f171bed80f4a8b1ce42a260240bd119fa076be964c76dffe2a23ad92cb7d9f5191ffe0dcea77b8cd1c3c91f64c432c465060000"
"1f8b0800000000000013c598514fdb3010c7dff914d6bd4c4201356c4c53de109a36a09b26d8db98a2233e1aab8e9dda97b608e5bb4f4e5b9ab4c0b60792bc44b9bbe47ef78fedf84273329c8882bcc709a59ed1f18144c6443c023f940409749c106dae217984c24ad29040a6b1927494a39a56471f8e4e8f4e4627a7f1681443044a8667f8493a8ae3f3fbcb73399ebae5a74b9fcf5c7c15cfe4178876324104ceea6040ef956734216d660d9361487efd5e5da032e4203195d61178b665ea08bd351d93a7594526a38e5112a3d27e63ab36e52853569cb29d92f190c4f1e87d04196639a5992364654dda0d196dfc8e50bee4dbdc1b32509953410e757a5aecc76fbd71beebad23b01577f822f0e4e62aa3945550028254129d0caa9b7b72a1f0744216123096539ca3d278a709ea5aac8ffae080566360ad6f7aa76d367d7e243c13d2a492b45cd5dbf68772d7f7312d4360734a00ea76da5299c94e1e01c1082fa149d28cafa335211db495a58bf414b606fb6e1750bf26ca9b651617efb416a5b37325a961e81f81f355e6f6d13f85af8a02dd835828cebb388328221ccd2ae548b658fa0729d04dc925b7e6d61c1e6e487ac7b859bd9a648b30941e37ec080b6526022764d80b2a140bb6568b86c41f0f358f7fe6e4879ac0e868b0b1b1d6bd3d30fa6798902187bc9dacfd23dc3dac87e47008b60c22341fd0810894197a81f04f0b44d8a146629193a36681189a4c9939d4fde7b559b305f502cd80b3c391af34fbb05289cc1645655416e6ab400f75ff3852f9cc11ef6d7b865a3d8543cec909ced18805aad6241e84eadebaf09a4abdafd100122d29ab9a36aa1e8ae1b8dbb9f49435ecfa6eae2ec6e3f4dbd9f5d5e7ebf46cfce3ebd9bf7c6943affbb716ce962da01777ff9b5f00cf16d97142aba84e4f0e6464ca9533f01f9df94e9b5fbf61afbed35c7f3cdd5b25b7ff41f6656dfb605fc63f07b52da767110000"
],
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "10000000",
"anthropic-ratelimit-input-tokens-reset": "2026-09-10T07:12:08Z",
"anthropic-ratelimit-input-tokens-reset": "2026-09-22T08:57:59Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-09-10T07:12:08Z",
"anthropic-ratelimit-output-tokens-reset": "2026-09-22T08:57:59Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-09-10T07:12:08Z",
"anthropic-ratelimit-requests-reset": "2026-09-22T08:57:59Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "12000000",
"anthropic-ratelimit-tokens-reset": "2026-09-10T07:12:08Z",
"anthropic-ratelimit-tokens-reset": "2026-09-22T08:57:59Z",
"cache-control": "no-cache",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a38c9f63ce57bc7b-PRG",
"cf-ray": "a3f01af1acffbbdd-FRA",
"connection": "keep-alive",
"content-encoding": "gzip",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "text/event-stream; charset=utf-8",
"date": "Thu, 10 Sep 2026 07:12:08 GMT",
"request-id": "req_011CeuLrBuGK4E7uxpMM6NMq",
"date": "Tue, 22 Sep 2026 08:57:59 GMT",
"request-id": "req_011CfJCdLVz3EfLeiVHjxihN",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-ae497abd3e1ae1a20e06ccaeba57c754-f8de5c4ed0f0dac4-01",
"traceresponse": "00-0dcb0fbb57a4e0ed3374476fe9b454e3-795c47a4b1e4f26c-01",
"transfer-encoding": "chunked",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},

View File

@ -5,6 +5,19 @@ import { AgentMessageList } from '../model/message-list';
import { ActiveSkills } from '../skills/active-skills';
const scope = { threadId: 'thread-1', resourceId: 'user-1' };
/**
* Mark the list as observation-masked, so recovered skills render in the
* `<active_skills>` system block. Without a mask the block stays empty and
* skills ride on their still-visible activating tool result.
*/
function mask(list: AgentMessageList): AgentMessageList {
list.maskObservedMessages({
lastObservedAt: new Date(Date.now() + 60_000),
lastObservedMessageId: 'observation-cursor',
});
return list;
}
const source = createRuntimeSkillSource([
{
id: 'planning',
@ -87,12 +100,46 @@ describe('active skills', () => {
await active.restore(list, scope);
expect(active.instructions()).toContain('Build one workflow.');
// The load_skill result is still visible, so the body rides on it and the
// system block stays empty — no cached-prefix rewrite.
expect(active.instructions()).toBeUndefined();
expect(JSON.stringify(active.modelMessages(list.forLlm('').messages, list))).toContain(
'Build one workflow.',
);
await expect(memory.skillState.load({ ...scope, agentName: 'assistant' })).resolves.toEqual([
'builder',
]);
});
it('recovers a skill into the system block once observation masks its load', async () => {
const memory = new InMemoryMemory();
const list = new AgentMessageList();
list.addHistory([
{
role: 'assistant',
content: [
{
type: 'tool-call',
toolName: 'load_skill',
toolCallId: 'old-load',
input: { skillId: 'builder' },
state: 'resolved',
output: { type: 'content', value: [{ type: 'text', text: 'Build one workflow.' }] },
},
],
},
]);
const active = new ActiveSkills(source, 'assistant', memory.skillState);
await active.restore(list, scope);
// Visible load → block empty.
expect(active.instructions()).toBeUndefined();
// Observation hides the load result → the skill moves into the block.
mask(list);
expect(active.instructions()).toContain('Build one workflow.');
});
it('persists checkpoint skills when they differ from stored state', async () => {
const memory = new InMemoryMemory();
await memory.skillState.save({ ...scope, agentName: 'assistant' }, ['planning']);
@ -120,7 +167,15 @@ describe('active skills', () => {
{
type: 'tool-call',
toolName: 'load_skill',
toolCallId: 'old-load',
toolCallId: 'builder-load',
input: { skillId: 'builder' },
state: 'resolved',
output: { type: 'content', value: [{ type: 'text', text: 'Build one workflow.' }] },
},
{
type: 'tool-call',
toolName: 'load_skill',
toolCallId: 'planning-load',
input: { skillId: 'planning' },
state: 'resolved',
output: { type: 'content', value: [{ type: 'text', text: 'Create a task plan.' }] },
@ -135,16 +190,132 @@ describe('active skills', () => {
);
await active.restore(list, scope);
expect(active.instructions()).toContain('Build one workflow.');
expect(active.instructions()).not.toContain('Create a task plan.');
expect(JSON.stringify(active.modelMessages(list.forLlm('').messages, list))).not.toContain(
'Create a task plan.',
);
// Both loads are still visible, so the block stays empty; the enabled skill
// rides on its result while the disabled one is collapsed out of it.
expect(active.instructions()).toBeUndefined();
const rendered = JSON.stringify(active.modelMessages(list.forLlm('').messages, list));
expect(rendered).toContain('Build one workflow.');
expect(rendered).not.toContain('Create a task plan.');
await expect(memory.skillState.load({ ...scope, agentName: 'assistant' })).resolves.toEqual([
'builder',
]);
});
it('re-anchors a stamped tool activation on a later turn instead of the block', async () => {
const memory = new InMemoryMemory();
await memory.skillState.save({ ...scope, agentName: 'assistant' }, ['builder']);
const list = new AgentMessageList();
// Turn 1 activated the skill from a tool and stamped its result. On the
// next turn the body must ride on that result again, not the system block.
list.addHistory([
{
role: 'assistant',
content: [
{
type: 'tool-call',
toolName: 'build_workflow',
toolCallId: 'build-1',
input: {},
state: 'resolved',
output: { type: 'content', value: [{ type: 'text', text: 'built' }] },
activatedSkillIds: ['builder'],
},
],
},
]);
const active = new ActiveSkills(source, 'assistant', memory.skillState);
await active.restore(list, scope);
expect(active.instructions()).toBeUndefined();
expect(JSON.stringify(active.modelMessages(list.forLlm('').messages, list))).toContain(
'Build one workflow.',
);
});
it('persists a tool activation stamp across serialization', async () => {
const memory = new InMemoryMemory();
const active = new ActiveSkills(source, 'assistant', memory.skillState);
const list = new AgentMessageList();
list.addResponse([
{
role: 'assistant',
content: [
{
type: 'tool-call',
toolName: 'build_workflow',
toolCallId: 'build-1',
input: {},
state: 'resolved',
output: { type: 'content', value: [{ type: 'text', text: 'built' }] },
},
],
},
]);
await active.restore(list, scope);
await active.load('builder', { toolCallId: 'build-1' });
const roundTripped = AgentMessageList.deserialize(structuredClone(list.serialize()));
const stamped = roundTripped
.messages()
.flatMap((message) => ('content' in message ? message.content : []))
.find((part) => part.type === 'tool-call' && part.toolCallId === 'build-1');
expect(stamped).toMatchObject({ activatedSkillIds: ['builder'] });
});
it('recovers a restored programmatic skill through the block when no stamp survives', async () => {
const memory = new InMemoryMemory();
await memory.skillState.save({ ...scope, agentName: 'assistant' }, ['builder']);
const list = new AgentMessageList();
// Legacy data: a prior programmatic activation left no stamp and no
// `load_skill` record. The skill must still reach the model via the block.
list.addHistory([
{
role: 'assistant',
content: [
{
type: 'tool-call',
toolName: 'build_workflow',
toolCallId: 'build-1',
input: {},
state: 'resolved',
output: { type: 'content', value: [{ type: 'text', text: 'built' }] },
},
],
},
]);
const active = new ActiveSkills(source, 'assistant', memory.skillState);
await active.restore(list, scope);
expect(active.instructions()).toContain('Build one workflow.');
});
it('recovers a skill through the block when its activating tool call failed', async () => {
const memory = new InMemoryMemory();
const active = new ActiveSkills(source, 'assistant', memory.skillState);
const list = new AgentMessageList();
list.addResponse([
{
role: 'assistant',
content: [
{
type: 'tool-call',
toolName: 'build_workflow',
toolCallId: 'build-fail',
input: {},
state: 'rejected',
error: 'boom',
},
],
},
]);
await active.restore(list, scope);
// The tool activated the skill, then failed — its error result cannot
// carry the body, so the skill belongs in the block, not silently dropped.
await active.load('builder', { toolCallId: 'build-fail' });
expect(active.instructions()).toContain('Build one workflow.');
});
it('keeps threads, resources, and agents isolated', async () => {
const memory = new InMemoryMemory();
const active = new ActiveSkills(source, 'assistant', memory.skillState);
@ -165,6 +336,40 @@ describe('active skills', () => {
).resolves.toBeUndefined();
});
it('keeps the system block empty for an anchored activation until observation masks it', async () => {
const memory = new InMemoryMemory();
const active = new ActiveSkills(source, 'assistant', memory.skillState);
const list = new AgentMessageList();
list.addResponse([
{
role: 'assistant',
content: [
{
type: 'tool-call',
toolName: 'build_workflow',
toolCallId: 'build-1',
input: {},
state: 'resolved',
output: { type: 'content', value: [{ type: 'text', text: 'built' }] },
},
],
},
]);
await active.restore(list, scope);
// A programmatic activation anchored to the calling tool result stays out
// of the system prompt — the body rides on that visible result instead.
await active.load('builder', { toolCallId: 'build-1' });
expect(active.instructions()).toBeUndefined();
expect(JSON.stringify(active.modelMessages(list.forLlm('').messages, list))).toContain(
'Build one workflow.',
);
// Only once observation hides the anchor does the skill move into the block.
mask(list);
expect(active.instructions()).toContain('Build one workflow.');
});
it('does not activate a missing skill or a failed load', async () => {
const missing: RuntimeSkillSource = { ...source, loadSkill: vi.fn().mockResolvedValue(null) };
const active = new ActiveSkills(missing, 'assistant');

View File

@ -154,7 +154,7 @@ export interface AgentRuntimeConfig {
volatileInstructionsProvider?: VolatileInstructionsProvider;
}
const MAX_LOOP_ITERATIONS = 30;
const MAX_LOOP_ITERATIONS = 100;
/** Retries for a `stop` turn that produced no output at all (see isEmptyModelTurn). */
const MAX_EMPTY_TURN_RETRIES = 2;

View File

@ -295,6 +295,24 @@ export class AgentMessageList {
block.suspension = suspension;
}
/**
* Record that a tool programmatically activated a skill on its own result, so
* `ActiveSkills` can re-anchor the skill body there on a later turn instead
* of falling back to the `<active_skills>` system prompt. The stamp rides on
* the persisted message; it is metadata only and never reaches the model
* (`toAiMessages` ignores it). No-op when the tool call is unknown.
*/
stampActivatedSkill(toolCallId: string, skillId: string): void {
const host = this.findToolCallHost(toolCallId);
if (!host) return;
const block = this.findToolCallBlock(host, toolCallId);
if (!block) return;
const current = block.activatedSkillIds ?? [];
if (current.includes(skillId)) return;
block.activatedSkillIds = [...current, skillId];
this.responseSet.add(host);
}
private findToolCallHost(toolCallId: string): AgentDbMessage | undefined {
// Start from the last message and go backwards to find the host message
for (let i = this.all.length - 1; i >= 0; i--) {

View File

@ -1,4 +1,4 @@
import type { ModelMessage } from 'ai';
import type { ModelMessage, ToolResultPart } from 'ai';
import { formatActiveSkill } from '../../skills/tools';
import {
@ -11,7 +11,30 @@ import {
import type { AgentPersistenceOptions } from '../../types/sdk/agent';
import type { AgentMessageList } from '../model/message-list';
/** Keeps trusted skill instructions outside the conversation's compaction window. */
/**
* Keeps trusted skill instructions available without invalidating the cached
* prompt prefix.
*
* A skill body rides on the tool result that activated it (the recorded result
* is collapsed first, so an obsolete persisted body is never replayed). The
* top-level system prompt does not mention the skill, so activating and
* carrying a skill never rewrites the cached prefix — within a run or across
* runs.
*
* The `<active_skills>` block of the system prompt is a recovery path only.
* Anthropic renders the prompt as `tools → system → messages`, so any edit to
* `system` invalidates the tool block and every message after it. The block
* therefore holds only the skills the anchored path cannot deliver on this
* call — their result masked by observation memory, their activating call
* failed, or the activation predates this window with no `load_skill` record:
*
* - Anchor still deliverable: the skill rides on its resolved tool result, so
* `system` is unchanged and the cached prefix stays warm.
* - Anchor gone or unusable: the skill moves into the block so its guidance is
* never lost. When observation memory replaced the history it already rewrote
* the prefix, so the move is free; otherwise it costs one rewrite, the price
* of not dropping an active skill.
*/
export class ActiveSkills {
private readonly loaded = new Map<string, RuntimeSkillContent>();
private list?: AgentMessageList;
@ -38,7 +61,11 @@ export class ActiveSkills {
list.activeSkillIds === undefined && this.scope
? await this.store?.load(this.scope)
: undefined;
const ids = list.activeSkillIds ?? stored ?? [...this.recordedLoads(list).values()];
const ids = list.activeSkillIds ??
stored ?? [
...this.recordedLoads(list).values(),
...this.stampedActivations(list).map(([, skillId]) => skillId),
];
const registered = new Set(this.source.registry.skills.map(({ id }) => id));
for (const id of new Set(ids)) {
if (!registered.has(id)) continue;
@ -53,7 +80,10 @@ export class ActiveSkills {
if (!storeIsCurrent && (ids.length > 0 || stored !== undefined)) await this.persist();
}
async load(skillId: string): Promise<RuntimeSkillContent | null> {
async load(
skillId: string,
anchor?: { toolCallId: string },
): Promise<RuntimeSkillContent | null> {
if (!this.source.registry.skills.some(({ id }) => id === skillId)) return null;
const skill = this.loaded.get(skillId) ?? (await this.source.loadSkill(skillId));
if (!skill) return null;
@ -61,6 +91,9 @@ export class ActiveSkills {
if (!this.loaded.has(skillId)) {
this.loaded.set(skillId, skill);
this.list.activeSkillIds = [...this.loaded.keys()];
// Stamp the activating tool result so the body re-anchors there on a
// later turn instead of falling back into the system prompt.
if (anchor) this.list.stampActivatedSkill(anchor.toolCallId, skillId);
await this.persist();
}
return skill;
@ -72,12 +105,19 @@ export class ActiveSkills {
];
}
/**
* The active skills the anchored path cannot deliver on this call, as one
* block for the top-level system prompt. A skill enters the block only when
* it has no successfully resolved, visible tool result to ride on, so a
* still-anchored skill never edits `system` or breaks a warm cache.
*/
instructions(): string | undefined {
if (this.loaded.size === 0) return undefined;
const sections = [...this.loaded].flatMap(([id, skill]) => {
const entry = this.source.registry.skills.find((candidate) => candidate.id === id);
return entry ? [formatActiveSkill(skill, entry)] : [];
const { inBlock } = this.placement();
const sections = inBlock.flatMap((id) => {
const text = this.formatSkill(id);
return text ? [text] : [];
});
if (sections.length === 0) return undefined;
return [
'<active_skills>',
'Use these current skill versions when relevant to the task. Earlier tool results may describe older versions.',
@ -86,9 +126,23 @@ export class ActiveSkills {
].join('\n\n');
}
/** Old conversations can contain full skill bodies. Do not replay an obsolete version. */
/**
* Per-call view of the conversation for the model.
*
* Collapses recorded `load_skill` results to `{ skillId, active }`: old
* conversations can contain full skill bodies, and an obsolete version must
* not be replayed. Skills anchored to a visible tool call then get their
* current body appended to that result, keeping them out of the system
* block for as long as observational memory keeps the result visible.
*/
modelMessages(messages: ModelMessage[], list: AgentMessageList): ModelMessage[] {
const loads = this.recordedLoads(list);
const { anchors } = this.placement(loads);
const appendices = new Map<string, string[]>();
for (const [skillId, toolCallId] of anchors) {
const text = this.formatSkill(skillId);
if (text) appendices.set(toolCallId, [...(appendices.get(toolCallId) ?? []), text]);
}
return messages.map((message) => {
if (message.role !== 'tool') return message;
return {
@ -96,19 +150,105 @@ export class ActiveSkills {
content: message.content.map((part) => {
if (part.type !== 'tool-result') return part;
const skillId = loads.get(part.toolCallId);
if (!skillId) return part;
return {
...part,
output: {
type: 'json' as const,
value: { skillId, active: this.loaded.has(skillId) },
},
};
const collapsed: ToolResultPart = skillId
? {
...part,
output: {
type: 'json' as const,
value: { skillId, active: this.loaded.has(skillId) },
},
}
: part;
const texts = appendices.get(part.toolCallId);
return texts ? appendToolResultText(collapsed, texts) : collapsed;
}),
};
});
}
/**
* Where each active skill is delivered on this call: anchored to a visible
* tool result, or in the system block. The anchor is the first visible
* recorded `load_skill` call for the skill, else this run's activating
* call. `instructions()` and `modelMessages()` share this so a skill is
* always delivered exactly once.
*
* An anchor must be a **successfully resolved** tool result: the skill body
* is appended to it (see `appendToolResultText`, which skips error outputs),
* so a pending, canceled, or failed call cannot deliver it. A skill with no
* such anchor — its result masked by observation memory, or its activating
* call failed, or the activation predates this window with no `load_skill`
* record — falls back to the `<active_skills>` block, which never drops it.
* The block only edits `system` when the anchored path cannot deliver, so an
* unchanged, still-anchored skill never rewrites the cached prefix.
*/
private placement(loads?: Map<string, string>): {
anchors: Map<string, string>;
inBlock: string[];
} {
const anchors = new Map<string, string>();
if (this.list) {
const deliverable = this.deliverableToolResults();
// `load_skill` results and tool results that stamped a programmatic
// activation are both valid anchors; the stamp is what survives a turn.
const sources: Array<[string, string]> = [
...(loads ?? this.recordedLoads(this.list)),
...this.stampedActivations(this.list),
];
for (const [toolCallId, skillId] of sources) {
if (!anchors.has(skillId) && this.loaded.has(skillId) && deliverable.has(toolCallId)) {
anchors.set(skillId, toolCallId);
}
}
}
const inBlock = [...this.loaded.keys()].filter((id) => !anchors.has(id));
return { anchors, inBlock };
}
/**
* `[toolCallId, skillId]` pairs for tool results that stamped a programmatic
* skill activation (see `stampActivatedSkill`). Unlike `load_skill` records,
* these carry activations a tool started itself, so a post-build skill
* re-anchors to its tool result on the next turn instead of the system block.
*/
private stampedActivations(list: AgentMessageList): Array<[string, string]> {
const pairs: Array<[string, string]> = [];
for (const message of list.messages()) {
if (!('content' in message)) continue;
for (const part of message.content) {
if (part.type === 'tool-call' && Array.isArray(part.activatedSkillIds)) {
for (const skillId of part.activatedSkillIds) pairs.push([part.toolCallId, skillId]);
}
}
}
return pairs;
}
/**
* Tool-call ids whose result is visible and successfully resolved, so a skill
* body can ride on it. Pending, canceled, and failed calls are excluded — a
* skill anchored there would silently vanish, so it belongs in the block.
*/
private deliverableToolResults(): Set<string> {
const ids = new Set<string>();
if (!this.list) return ids;
for (const message of this.list.llmVisibleMessages()) {
if (!('content' in message)) continue;
for (const part of message.content) {
if (part.type === 'tool-call' && part.state === 'resolved' && !part.canceled) {
ids.add(part.toolCallId);
}
}
}
return ids;
}
private formatSkill(skillId: string): string | undefined {
const skill = this.loaded.get(skillId);
const entry = this.source.registry.skills.find((candidate) => candidate.id === skillId);
return skill && entry ? formatActiveSkill(skill, entry) : undefined;
}
private recordedLoads(list: AgentMessageList): Map<string, string> {
const loads = new Map<string, string>();
for (const message of list.messages()) {
@ -154,3 +294,32 @@ export class ActiveSkills {
await save;
}
}
/**
* Append trusted text parts after a tool result's own output. Text and JSON
* outputs become content parts so the skill text is a separate block from the
* (possibly untrusted-wrapped) tool output. Error outputs are left alone.
*/
function appendToolResultText(part: ToolResultPart, texts: string[]): ToolResultPart {
const extra = texts.map((text) => ({ type: 'text' as const, text }));
const { output } = part;
switch (output.type) {
case 'content':
return { ...part, output: { type: 'content', value: [...output.value, ...extra] } };
case 'text':
return {
...part,
output: { type: 'content', value: [{ type: 'text', text: output.value }, ...extra] },
};
case 'json':
return {
...part,
output: {
type: 'content',
value: [{ type: 'text', text: JSON.stringify(output.value) }, ...extra],
},
};
default:
return part;
}
}

View File

@ -117,6 +117,15 @@ export async function executeTool(
throw new Error(`No handler found for tool "${builtTool.name}"`);
}
// Anchor programmatic skill activations to the calling tool's result by
// default, so the skill body rides on that result (kept out of `system`)
// instead of forcing a full prompt-prefix rewrite. An explicit anchor wins.
const baseLoadSkill = executionContext.loadSkill;
const loadSkill: ToolExecutionContext['loadSkill'] = baseLoadSkill
? async (skillId, anchor) =>
await baseLoadSkill(skillId, anchor ?? (toolCallId ? { toolCallId } : undefined))
: undefined;
if (builtTool.suspendSchema) {
const isCancelled = isCancellation(resumeData);
const ctx: InterruptibleToolContext = {
@ -139,7 +148,7 @@ export async function executeTool(
toolCallId,
toolName: builtTool.name,
runId: executionContext.runId,
...(executionContext.loadSkill ? { loadSkill: executionContext.loadSkill } : {}),
...(loadSkill ? { loadSkill } : {}),
persistence: executionContext.persistence,
emitEvent: executionContext.emitEvent,
abortSignal: executionContext.abortSignal,
@ -156,7 +165,7 @@ export async function executeTool(
toolCallId,
toolName: builtTool.name,
runId: executionContext.runId,
...(executionContext.loadSkill ? { loadSkill: executionContext.loadSkill } : {}),
...(loadSkill ? { loadSkill } : {}),
persistence: executionContext.persistence,
emitEvent: executionContext.emitEvent,
abortSignal: executionContext.abortSignal,

View File

@ -137,7 +137,15 @@ export interface RuntimeSkillContent extends RuntimeSkillIndexEntry {
linkedFiles?: RuntimeSkillLinkedFiles;
}
export type RuntimeSkillLoader = (skillId: string) => Promise<RuntimeSkillContent | null>;
export type RuntimeSkillLoader = (
skillId: string,
/**
* Tool result the activation rides on. The skill body is appended to this
* result so the top-level system prompt stays byte-identical (no cache
* invalidation). Defaults to the calling tool's own result when omitted.
*/
anchor?: { toolCallId: string },
) => Promise<RuntimeSkillContent | null>;
export interface RuntimeSkillFileContent {
skillId: string;

View File

@ -133,6 +133,13 @@ export type ContentToolCall = ContentMetadata & {
input: JSONValue;
providerExecuted?: boolean;
/**
* Skills this tool activated programmatically (via `ctx.loadSkill`), so the
* skill body can re-anchor to this result on a later turn instead of falling
* back to the system prompt. Metadata only — never sent to the model.
*/
activatedSkillIds?: string[];
} & (
| { state: 'pending'; suspension?: ToolCallSuspensionInfo }
| { state: 'resolved'; output: JSONValue; canceled?: boolean }

View File

@ -25,6 +25,7 @@
"devDependencies": {
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@types/validator": "catalog:",
"@vitest/coverage-v8": "catalog:",
"typescript": "catalog:typescript",
"vite": "catalog:",
@ -36,6 +37,7 @@
"@asteasolutions/zod-to-openapi": "catalog:",
"@n8n_io/ai-assistant-sdk": "catalog:",
"n8n-workflow": "workspace:*",
"validator": "catalog:",
"xss": "catalog:",
"@n8n/permissions": "workspace:*"
},

View File

@ -79,4 +79,53 @@ describe('AgentIntegrationSchema', () => {
});
expect(result.success).toBe(false);
});
describe('action approval', () => {
it.each(['slack', 'telegram', 'discord', 'linear'])(
'accepts selected actions on a %s integration',
(type) => {
const result = AgentIntegrationSchema.safeParse({
type,
credentialId: 'cred-123',
approval: { mode: 'selected', tools: ['send_channel_message'] },
});
expect(result.success).toBe(true);
},
);
it('leaves approval undefined when it is absent', () => {
const result = AgentIntegrationSchema.safeParse({
type: 'slack',
credentialId: 'cred-123',
});
expect(result.success && result.data.approval).toBeUndefined();
});
it('accepts the global mode without a list', () => {
const result = AgentIntegrationSchema.safeParse({
type: 'slack',
credentialId: 'cred-123',
approval: { mode: 'global' },
});
expect(result.success).toBe(true);
});
it('rejects selected approval with nothing selected', () => {
const result = AgentIntegrationSchema.safeParse({
type: 'slack',
credentialId: 'cred-123',
approval: { mode: 'selected', tools: [] },
});
expect(result.success).toBe(false);
});
it('rejects an unknown approval mode', () => {
const result = AgentIntegrationSchema.safeParse({
type: 'slack',
credentialId: 'cred-123',
approval: { mode: 'always' },
});
expect(result.success).toBe(false);
});
});
});

View File

@ -1,5 +1,26 @@
import { z } from 'zod';
/**
* Human-in-the-loop approval selection. Absent means no approval required.
* Shared by MCP servers, where `tools` holds tool names, and channels, where
* it holds action names such as `send_channel_message`.
*/
export const AgentApprovalSchema = z.discriminatedUnion('mode', [
z.object({ mode: z.literal('global') }).strict(),
z
.object({
mode: z.literal('selected'),
tools: z.array(z.string().min(1)).min(1),
})
.strict(),
]);
export type AgentApproval = z.infer<typeof AgentApprovalSchema>;
const approval = AgentApprovalSchema.optional().describe(
'Channel actions that need approval before they run. Absent = no approval required',
);
const createCredIntegrationSchema = <
Value extends string,
Settings extends z.ZodTypeAny | z.ZodEffects<z.ZodTypeAny>,
@ -11,6 +32,7 @@ const createCredIntegrationSchema = <
type: z.literal<Value>(typeName),
credentialId: z.string().min(1),
settings: settingsSchema,
approval,
});
const createDraftCredIntegrationSchema = <
@ -24,6 +46,7 @@ const createDraftCredIntegrationSchema = <
type: z.literal<Value>(typeName),
credentialId: z.string(),
settings: settingsSchema,
approval,
});
export const AGENT_TELEGRAM_ACCESS_MODES = ['private', 'public'] as const;

View File

@ -1,7 +1,7 @@
import { z, type ZodError } from 'zod';
import { isDraftAgentConfig } from './agent-config-lifecycle';
import { AgentIntegrationConfigSchema } from './agent-integration.schema';
import { AgentApprovalSchema, AgentIntegrationConfigSchema } from './agent-integration.schema';
import { AGENT_MODEL_STRING_REGEX } from './model-providers';
import { AGENT_REASONING_LEVELS } from './reasoning';
/**
@ -280,18 +280,9 @@ export const McpServerConfigSchema = z
])
.optional()
.describe('Restricts which tools are surfaced. Tools matched by original un-prefixed name'),
approval: z
.discriminatedUnion('mode', [
z.object({ mode: z.literal('global') }).strict(),
z
.object({
mode: z.literal('selected'),
tools: z.array(z.string().min(1)).min(1),
})
.strict(),
])
.optional()
.describe('Human-in-the-loop approval. Absent = no approval required'),
approval: AgentApprovalSchema.optional().describe(
'Human-in-the-loop approval. Absent = no approval required',
),
connectionTimeoutMs: z
.number()
.int()

View File

@ -8,6 +8,7 @@ import {
MAX_AGENT_CHAT_ATTACHMENT_MIMETYPE_LENGTH,
MAX_AGENT_CHAT_ATTACHMENTS_PER_MESSAGE,
} from './agent-chat-attachments.constants';
import { AgentApprovalSchema } from './agent-integration.schema';
import { AgentVectorStoreConfigSchema, AgentJsonConfigSchema } from './agent-json-config.schema';
import { agentSkillSchema, agentSkillShape } from './agent-skill.schema';
import { agentTaskSchema } from './agent-task.schema';
@ -224,6 +225,7 @@ const agentChatMessageShape = {
// (attachment-only sends) — see the schema-level refinement below.
message: z.string(),
sessionId: z.string().min(1).optional(),
newSession: z.literal(true).optional(),
attachments: z
.array(agentChatAttachmentSchema)
.max(MAX_AGENT_CHAT_ATTACHMENTS_PER_MESSAGE)
@ -281,6 +283,8 @@ export class AgentConnectIntegrationDto extends Z.class({
* request keeps the agent from ever holding two live channels or none.
*/
replaces: z.object({ credentialId: z.string().min(1) }).optional(),
/** Channel actions that need approval before they run. */
approval: AgentApprovalSchema.optional(),
}) {}
export class AgentDisconnectIntegrationDto extends Z.class({

View File

@ -1,6 +1,6 @@
import { EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE, getChildNodes, type IConnections } from 'n8n-workflow';
import type { AgentIntegrationSettings } from './agent-integration.schema';
import type { AgentApproval, AgentIntegrationSettings } from './agent-integration.schema';
import type { AgentJsonConfig } from './agent-json-config.schema';
import type { AgentBackgroundJobSignal } from './background-job';
@ -122,6 +122,14 @@ export interface ChatIntegrationDescriptor {
capabilities?: string[];
useIntegrationWhen?: string[];
useNodeToolWhen?: string[];
/** Actions a user can hold for approval, in the order they should be listed. */
approvableActions?: ChatIntegrationApprovableAction[];
}
export interface ChatIntegrationApprovableAction {
name: string;
/** Pre-selected when a user turns approval on for this channel. */
sensitive: boolean;
}
/**
@ -139,6 +147,8 @@ export interface AgentIntegrationStatusEntry {
type: string;
credentialId?: string;
settings?: AgentIntegrationSettings;
/** Channel actions that need approval before they run. */
approval?: AgentApproval;
/** Authoritative per-channel state; prefer this over the response rollup. */
status: AgentChannelRuntimeStatus;
/** Present only when `status` is `error`. */

View File

@ -0,0 +1,59 @@
import '../../openapi-extend';
import { z } from 'zod';
import { discoverQueryFieldDocs, discoverResponseFieldDocs } from './discover-public.openapi';
import { Z } from '../../zod-class';
const discoverEndpointPublicSchema = z.object({
method: z.string(),
path: z.string(),
operationId: z.string(),
requestSchema: z
.record(z.string(), z.unknown())
.optional()
.openapi(discoverResponseFieldDocs.requestSchema),
});
const discoverResourcePublicSchema = z.object({
operations: z.array(z.string()),
endpoints: z.array(discoverEndpointPublicSchema),
});
const discoverFilterPublicSchema = z.object({
description: z.string(),
values: z.array(z.string()),
});
export const discoverDataPublicSchema = z.object({
scopes: z.array(z.string()).openapi(discoverResponseFieldDocs.scopes),
resources: z.record(z.string(), discoverResourcePublicSchema),
filters: z
.record(z.string(), discoverFilterPublicSchema)
.openapi(discoverResponseFieldDocs.filters),
specUrl: z.string().openapi(discoverResponseFieldDocs.specUrl),
});
export type DiscoverDataPublic = z.infer<typeof discoverDataPublicSchema>;
export class DiscoverPublicDto extends Z.class({
data: discoverDataPublicSchema,
}) {}
const DISCOVER_INCLUDE_VALUES = ['schemas'] as const;
// Strict on purpose: the legacy validator answered 400 to an unknown query parameter on this
// route, and a client that relies on that must keep getting it. The list DTOs are not strict.
export class DiscoverQueryPublicDto extends Z.class(
{
include: z
.enum(DISCOVER_INCLUDE_VALUES, {
message: `must be equal to one of the allowed values: ${DISCOVER_INCLUDE_VALUES.join(', ')}`,
})
.optional()
.openapi(discoverQueryFieldDocs.include),
resource: z.string().optional().openapi(discoverQueryFieldDocs.resource),
operation: z.string().optional().openapi(discoverQueryFieldDocs.operation),
},
{ strict: true },
) {}

View File

@ -0,0 +1,28 @@
import type { ZodOpenAPIMetadata } from '@asteasolutions/zod-to-openapi';
export const discoverQueryFieldDocs = {
include: {
description:
'Include additional data. Use "schemas" to inline request body schemas per endpoint, eliminating the need to fetch the full OpenAPI spec.',
},
resource: {
description: 'Filter to a specific resource (e.g. "workflow", "tags", "credential").',
},
operation: {
description: 'Filter to endpoints with a specific operation (e.g. "read", "create", "list").',
},
} as const satisfies Record<string, ZodOpenAPIMetadata>;
export const discoverResponseFieldDocs = {
scopes: { description: "The API key's active scopes" },
requestSchema: {
type: 'object',
description:
'Request body schema (only present when include=schemas and the endpoint accepts a request body).',
},
filters: {
description:
"Available query parameter filters. The values arrays reflect what the caller's scopes permit.",
},
specUrl: { description: 'URL to the full OpenAPI specification' },
} as const satisfies Record<string, ZodOpenAPIMetadata>;

View File

@ -0,0 +1,129 @@
import {
CreatedTestRunPublicDto,
ListTestRunsQueryPublicDto,
TestRunListPublicDto,
TestRunSummaryPublicDto,
} from '../test-run-public.dto';
const testRun = {
id: '9f8e7d6c5b4a3210',
status: 'completed',
runAt: '2026-01-01T00:00:00.000Z',
completedAt: '2026-01-01T00:05:00.000Z',
metrics: { accuracy: 0.9, passed: true },
errorCode: null,
errorDetails: null,
finalResult: 'success',
testCaseCount: 42,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:05:00.000Z',
};
describe('TestRunSummaryPublicDto', () => {
test('accepts all the expected fields', () => {
expect(TestRunSummaryPublicDto.safeParse(testRun).success).toBe(true);
});
test('accepts a run that has not started', () => {
const result = TestRunSummaryPublicDto.safeParse({
...testRun,
status: 'new',
runAt: null,
completedAt: null,
metrics: null,
finalResult: null,
testCaseCount: 0,
});
expect(result.success).toBe(true);
});
test('accepts a failed run with an error code and details', () => {
const result = TestRunSummaryPublicDto.safeParse({
...testRun,
status: 'error',
errorCode: 'TEST_CASES_NOT_FOUND',
errorDetails: { node: 'Evaluation', nested: { deep: ['shape'] } },
finalResult: 'error',
});
expect(result.success).toBe(true);
});
test.each([
['a status outside the set', { status: 'paused' }],
['a final result outside the set', { finalResult: 'unknown' }],
['metrics that are not an object', { metrics: 'fast' }],
['a Date for createdAt', { createdAt: new Date() }],
['a non-integer testCaseCount', { testCaseCount: 1.5 }],
])('rejects %s', (_label, override) => {
expect(TestRunSummaryPublicDto.safeParse({ ...testRun, ...override }).success).toBe(false);
});
});
describe('TestRunListPublicDto', () => {
test('accepts a page with a next cursor', () => {
expect(TestRunListPublicDto.safeParse({ data: [testRun], nextCursor: 'abc' }).success).toBe(
true,
);
});
test('accepts the last page', () => {
expect(TestRunListPublicDto.safeParse({ data: [], nextCursor: null }).success).toBe(true);
});
});
describe('ListTestRunsQueryPublicDto', () => {
test('applies the default limit', () => {
const result = ListTestRunsQueryPublicDto.safeParse({});
expect(result.success).toBe(true);
expect(result.data).toEqual({ limit: 100 });
});
test('caps the limit at 250', () => {
const result = ListTestRunsQueryPublicDto.safeParse({ limit: '300' });
expect(result.success).toBe(true);
expect(result.data?.limit).toBe(250);
});
test('accepts a status filter and a cursor', () => {
const result = ListTestRunsQueryPublicDto.safeParse({ status: 'running', cursor: 'abc' });
expect(result.success).toBe(true);
expect(result.data).toEqual({ limit: 100, status: 'running', cursor: 'abc' });
});
test.each([
['a status outside the set', { status: 'paused' }],
['a non-numeric limit', { limit: 'ten' }],
['a negative limit', { limit: '-1' }],
])('rejects %s', (_label, query) => {
expect(ListTestRunsQueryPublicDto.safeParse(query).success).toBe(false);
});
});
describe('CreatedTestRunPublicDto', () => {
test('accepts the created run', () => {
const result = CreatedTestRunPublicDto.safeParse({
id: testRun.id,
status: 'new',
createdAt: testRun.createdAt,
});
expect(result.success).toBe(true);
});
test('strips fields the route does not publish', () => {
const result = CreatedTestRunPublicDto.safeParse({
id: testRun.id,
status: 'new',
createdAt: testRun.createdAt,
workflowId: 'abc',
});
expect(result.success).toBe(true);
expect(result.data).not.toHaveProperty('workflowId');
});
});

View File

@ -3,3 +3,4 @@ export * from './evaluation-config.dto';
export * from './evaluation-error-code';
export * from './llm-judge-providers';
export type * from './public-api-test-run.dto';
export * from './test-run-public.dto';

View File

@ -0,0 +1,56 @@
import '../../openapi-extend';
import { z } from 'zod';
import {
listTestRunsQueryFieldDocs,
testRunFieldDocs,
testRunListFieldDocs,
} from './test-run-public.openapi';
import { nullableObjectGuardSchema } from '../../schemas/object-guard.schema';
import { Z } from '../../zod-class';
import { publicApiPaginationSchema } from '../pagination/pagination.dto';
export const testRunStatusSchema = z.enum(['new', 'running', 'completed', 'error', 'cancelled']);
export type TestRunStatusPublic = z.infer<typeof testRunStatusSchema>;
export const testRunFinalResultSchema = z.enum(['success', 'error', 'warning']);
export const testRunSummaryPublicSchema = z.object({
id: z.string().openapi(testRunFieldDocs.id),
status: testRunStatusSchema,
runAt: z.string().datetime().nullable(),
completedAt: z.string().datetime().nullable(),
metrics: nullableObjectGuardSchema<Record<string, number | boolean>>().openapi(
testRunFieldDocs.metrics,
),
errorCode: z.string().nullable(),
errorDetails: nullableObjectGuardSchema<Record<string, unknown>>().openapi(
testRunFieldDocs.errorDetails,
),
finalResult: testRunFinalResultSchema.nullable().openapi(testRunFieldDocs.finalResult),
testCaseCount: z.number().int().openapi(testRunFieldDocs.testCaseCount),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
export type TestRunSummaryPublic = z.infer<typeof testRunSummaryPublicSchema>;
export class TestRunSummaryPublicDto extends Z.class(testRunSummaryPublicSchema.shape) {}
export class TestRunListPublicDto extends Z.class({
data: z.array(testRunSummaryPublicSchema),
nextCursor: z.string().nullable().openapi(testRunListFieldDocs.nextCursor),
}) {}
export class ListTestRunsQueryPublicDto extends Z.class({
limit: publicApiPaginationSchema.limit,
cursor: z.string().optional(),
status: testRunStatusSchema.optional().openapi(listTestRunsQueryFieldDocs.status),
}) {}
export class CreatedTestRunPublicDto extends Z.class({
id: z.string().openapi(testRunFieldDocs.id),
status: testRunStatusSchema,
createdAt: z.string().datetime(),
}) {}

View File

@ -0,0 +1,30 @@
import type { ZodOpenAPIMetadata } from '@asteasolutions/zod-to-openapi';
import { alsoNullable } from '../openapi-nullable';
export const testRunFieldDocs = {
id: { example: '9f8e7d6c5b4a3210' },
metrics: alsoNullable({
type: 'object',
additionalProperties: true,
description: "Aggregated metrics collected across the run's test cases.",
}),
errorDetails: alsoNullable({ type: 'object', additionalProperties: true }),
finalResult: {
description: 'Overall result of the run, derived from its test cases once completed.',
},
testCaseCount: { example: 42 },
} as const satisfies Record<string, ZodOpenAPIMetadata>;
export const testRunListFieldDocs = {
nextCursor: {
description:
'Paginate through test runs by setting the cursor parameter to the nextCursor attribute ' +
'returned by a previous request. Default value fetches the first "page" of the collection.',
example: 'MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA',
},
} as const satisfies Record<string, ZodOpenAPIMetadata>;
export const listTestRunsQueryFieldDocs = {
status: { description: 'Status to filter the test runs by.' },
} as const satisfies Record<string, ZodOpenAPIMetadata>;

View File

@ -3,6 +3,7 @@ import '../../openapi-extend';
import { z } from 'zod';
import {
deleteFolderQueryFieldDocs,
folderContentCountFieldDocs,
folderFieldDocs,
folderListFieldDocs,
@ -23,11 +24,9 @@ import { nullableObjectGuardSchema } from '../../schemas/object-guard.schema';
import { projectTypeSchema, type ProjectIcon } from '../../schemas/project.schema';
import { Z } from '../../zod-class';
/** The project a folder lives in, reduced to the columns the list query loads. */
const folderProjectPublicSchema = z.object({
id: z.string().openapi(folderProjectFieldDocs.id),
name: z.string().openapi(folderProjectFieldDocs.name),
// Legacy rows may hold a type the current enum no longer lists, and a response mismatch is a 500.
type: z
.string()
.openapi({ ...folderProjectFieldDocs.type, enum: [...projectTypeSchema.options] }),
@ -45,10 +44,6 @@ const folderTagPublicSchema = z.object({
name: z.string(),
});
/**
* Every field is optional because the `select` query parameter decides which columns the route
* loads: an unselected field is absent from the response, not null.
*/
export const folderPublicSchema = z.object({
id: z.string().openapi(folderFieldDocs.id),
name: z.string().optional().openapi(folderFieldDocs.name),
@ -78,7 +73,6 @@ export class ListFoldersQueryPublicDto extends Z.class(
select: selectValidator.openapi(folderListQueryFieldDocs.select),
sortBy: z
.enum(VALID_SORT_OPTIONS, {
// Keep the wording the legacy validator produced, so the 400 body does not change.
message: `must be equal to one of the allowed values: ${VALID_SORT_OPTIONS.join(', ')}`,
})
.optional()
@ -97,7 +91,6 @@ const folderCorePublicShape = {
updatedAt: z.string().datetime().openapi(folderFieldDocs.updatedAt),
};
/** The folder as PATCH publishes it. Allowlist of the fields `folder.yml` documented. */
export class UpdatedFolderPublicDto extends Z.class(folderCorePublicShape, { strict: true }) {}
export class FolderDetailsPublicDto extends Z.class({
@ -106,11 +99,6 @@ export class FolderDetailsPublicDto extends Z.class({
totalWorkflows: z.number().int().openapi(folderContentCountFieldDocs.totalWorkflows),
}) {}
/**
* `name` and `parentFolderId` keep the rules the legacy handler applied after
* express-openapi-validator, so a body the old route rejected is still rejected. `tagIds` stays
* out: the hand-written schema set `additionalProperties: false`, so the route never took it.
*/
const updateFolderPublicSchema = z
.object({
name: folderNameSchema.optional().openapi(updateFolderFieldDocs.name),
@ -143,3 +131,13 @@ export class UpdateFolderPublicDto implements UpdateFolderPublic {
return updateFolderPublicSchema.parse(data);
}
}
export class DeleteFolderQueryPublicDto extends Z.class(
{
transferToFolderId: folderIdSchema
.min(1, 'must not be empty')
.optional()
.openapi(deleteFolderQueryFieldDocs.transferToFolderId),
},
{ strict: true },
) {}

View File

@ -23,10 +23,6 @@ export const folderProjectFieldDocs = {
type: { readOnly: true, example: 'team' },
} as const satisfies Record<string, ZodOpenAPIMetadata>;
/**
* `icon` is a JSON column, so the schema only guards the basic type and this metadata carries the
* shape. Same reasoning as `projectIconOpenApi`, but the folder route returns a reduced project.
*/
export const folderProjectIconOpenApi: ZodOpenAPIMetadata = alsoNullable({
type: 'object',
description: 'Icon of the project, or null when the project has none',
@ -65,10 +61,6 @@ export const folderContentCountFieldDocs = {
totalWorkflows: { description: 'Total number of workflows (recursive).' },
} as const satisfies Record<string, ZodOpenAPIMetadata>;
/**
* Copied from the hand-written `folder.update.yml`. Both fields carry `type` because the runtime
* schemas behind them are refinements, which alone would not document as a plain `string`.
*/
export const updateFolderFieldDocs = {
name: { type: 'string', example: 'Renamed Folder' },
parentFolderId: {
@ -78,3 +70,10 @@ export const updateFolderFieldDocs = {
example: 'abc123',
},
} as const satisfies Record<string, ZodOpenAPIMetadata>;
export const deleteFolderQueryFieldDocs = {
transferToFolderId: {
description:
'Optional target folder ID to move workflows and sub-folders into before deleting.',
},
} as const satisfies Record<string, ZodOpenAPIMetadata>;

View File

@ -40,6 +40,13 @@ export { InstanceAiMcpUpdateConnectionRequestDto } from './instance-ai/instance-
export { BinaryDataQueryDto } from './binary-data/binary-data-query.dto';
export { BinaryDataSignedQueryDto } from './binary-data/binary-data-signed-query.dto';
export {
discoverDataPublicSchema,
DiscoverPublicDto,
DiscoverQueryPublicDto,
} from './discover/discover-public.dto';
export type { DiscoverDataPublic } from './discover/discover-public.dto';
export { BreakingChangeReportQueryDto } from './breaking-changes/breaking-change-report-query.dto';
export { LoginRequestDto } from './auth/login-request.dto';
@ -111,6 +118,11 @@ export { UserUpdateRequestDto } from './user/user-update-request.dto';
export { ChangeEmailRequestDto } from './user/change-email-request.dto';
export { ResolveChangeEmailTokenQueryDto } from './user/resolve-change-email-token-query.dto';
export { ConfirmEmailChangeRequestDto } from './user/confirm-email-change-request.dto';
export { ListUsersQueryDto } from './user/list-users-query.dto';
export { GetUserQueryDto } from './user/get-user-query.dto';
export { UserPublicDto, UserListPublicDto, userPublicSchema } from './user/user-public.dto';
export { CreateUsersPublicDto, InvitedUsersPublicDto } from './user/create-users-public.dto';
export { DeleteUserQueryPublicDto } from './user/delete-user-query-public.dto';
export { CommunityRegisteredRequestDto } from './license/community-registered-request.dto';
@ -257,6 +269,7 @@ export { DeleteFolderDto } from './folders/delete-folder.dto';
export { ListFolderQueryDto } from './folders/list-folder-query.dto';
export { TransferFolderBodyDto } from './folders/transfer-folder.dto';
export {
DeleteFolderQueryPublicDto,
FolderListPublicDto,
FolderPublicDto,
FolderDetailsPublicDto,

View File

@ -1,7 +1,7 @@
import { assignableGlobalRoleSchema } from '@n8n/permissions';
import { z } from 'zod';
const invitedUserSchema = z.object({
export const invitedUserSchema = z.object({
email: z.string().email(),
role: assignableGlobalRoleSchema.default('global:member'),
});

View File

@ -105,9 +105,46 @@ describe('CreateProjectPublicDto', () => {
expect(CreateProjectPublicDto.safeParse({ name: 'Marketing' }).success).toBe(true);
});
test.each([
{
id: 'source-id',
icon: { type: 'icon', value: 'briefcase', color: 'red' },
description: '',
customTelemetryTags: [
{ key: ' team ', value: ' Sales ' },
{ key: 'region', value: '' },
],
},
{ icon: null, description: null, customTelemetryTags: [] },
])('preserves supplied fields: %j', (fields) => {
const input = { name: ' Sales ', ...fields };
expect(CreateProjectPublicDto.parse(input)).toEqual(input);
});
test.each([
['an empty ID', { id: '' }],
['an ID over 36 characters', { id: 'a'.repeat(37) }],
['an empty name', { name: '' }],
['a name over 255 characters', { name: 'a'.repeat(256) }],
['a description over 512 characters', { description: 'a'.repeat(513) }],
['an empty icon value', { icon: { type: 'icon', value: '' } }],
['an empty tag key', { customTelemetryTags: [{ key: ' ', value: 'x' }] }],
[
'duplicate tag keys',
{
customTelemetryTags: [
{ key: 'team', value: 'a' },
{ key: ' team ', value: 'b' },
],
},
],
])('rejects %s', (_label, fields) => {
expect(CreateProjectPublicDto.safeParse({ name: 'Sales', ...fields }).success).toBe(false);
});
test.each([
['a missing name', {}, ['name']],
['an unknown key', { name: 'Marketing', icon: { type: 'icon', value: 'layers' } }, []],
['an unknown key', { name: 'Marketing', extra: true }, []],
])('rejects %s', (_label, payload, path) => {
const result = CreateProjectPublicDto.safeParse(payload);
@ -115,7 +152,7 @@ describe('CreateProjectPublicDto', () => {
expect(result.error?.issues[0].path).toEqual(path);
});
test.each(['id', 'type'])('rejects %s as read-only', (key) => {
test.each(['type'])('rejects %s as read-only', (key) => {
const result = CreateProjectPublicDto.safeParse({ name: 'Marketing', [key]: 'x' });
expect(result.success).toBe(false);

View File

@ -9,9 +9,13 @@ import {
projectIconOpenApi,
projectListFieldDocs,
} from './project-public.openapi';
import { n8nIdSchema } from '../../schemas/id.schema';
import { nullableObjectGuardSchema } from '../../schemas/object-guard.schema';
import {
projectNameSchema,
projectIconSchema,
projectDescriptionSchema,
projectCustomTelemetryTagsSchema,
projectTypeSchema,
type ProjectIcon,
} from '../../schemas/project.schema';
@ -60,7 +64,17 @@ export class CreatedProjectPublicDto extends Z.class({
export class CreateProjectPublicDto extends Z.class(
{
name: projectNameSchema.openapi(projectFieldDocs.name),
id: readOnlyPublicSchema(createProjectReadOnlyFieldDocs.id),
id: n8nIdSchema.optional().openapi({
description:
'Project ID. The server generates an ID when omitted. An ID that is in use returns HTTP 409.',
example: projectFieldDocs.id.example,
}),
icon: projectIconSchema.nullable().optional(),
description: projectDescriptionSchema
.nullable()
.optional()
.openapi(projectFieldDocs.description),
customTelemetryTags: projectCustomTelemetryTagsSchema.optional(),
type: readOnlyPublicSchema(createProjectReadOnlyFieldDocs.type),
},
{ strict: true },

View File

@ -1,6 +1,7 @@
import { z } from 'zod';
import {
projectCustomTelemetryTagsSchema,
projectDescriptionSchema,
projectIconSchema,
projectNameSchema,
@ -8,25 +9,11 @@ import {
} from '../../schemas/project.schema';
import { Z } from '../../zod-class';
const customTelemetryTagSchema = z.object({
key: z.string().refine((k) => k.trim().length > 0, { message: 'Key must not be empty' }),
value: z.string(),
});
const updateProjectShape = {
name: projectNameSchema.optional(),
icon: projectIconSchema.optional(),
description: projectDescriptionSchema.optional(),
customTelemetryTags: z
.array(customTelemetryTagSchema)
.refine(
(tags) => {
const trimmedKeys = tags.map((t) => t.key.trim());
return trimmedKeys.length === new Set(trimmedKeys).size;
},
{ message: 'Duplicate keys are not allowed in custom span attributes' },
)
.optional(),
customTelemetryTags: projectCustomTelemetryTagsSchema.optional(),
};
export class UpdateProjectDto extends Z.class(updateProjectShape) {}

View File

@ -91,7 +91,13 @@ describe('ApplyPackageResultDto', () => {
configId: 'config1',
git: { branchName: 'main', commitSha: 'a'.repeat(40) },
};
const preflight = { missingBindings: [], accessRequirements: [], conflicts: [], warnings: [] };
const preflight = {
missingProjects: [],
missingBindings: [],
accessRequirements: [],
conflicts: [],
warnings: [],
};
it('retains the named response schema and parses each stopped outcome', () => {
expect(ApplyPackageResultDto.name).toBe('ApplyPackageResultDto');
@ -108,6 +114,7 @@ describe('ApplyPackageResultDto', () => {
{ ...identity },
{ ...identity, status: 'unknown' },
{ ...identity, status: 'blocked' },
{ ...identity, status: 'blocked', preflight: { ...preflight, missingProjects: undefined } },
{ ...identity, status: 'applied', warnings: [] },
{ ...identity, status: 'applied', counts: {} },
])('requires the fields for each outcome: %j', (value) => {

View File

@ -2,12 +2,19 @@ import '../../openapi-extend';
import { z } from 'zod';
import { projectIconSchema } from '../../schemas/project.schema';
import { Z } from '../../zod-class';
import { variableTypeSchema, variableValueSchema } from '../variables/base.dto';
const sourceId = z.string().min(1);
export const promotionBindingProjectSchema = z.object({ id: sourceId, name: z.string() });
// Report package values unchanged. The create API validates them when used.
export const promotionMissingProjectSchema = promotionBindingProjectSchema.extend({
icon: z.object({ type: projectIconSchema.shape.type, value: z.string() }).optional(),
description: z.string().optional(),
customTelemetryTags: z.array(z.object({ key: z.string(), value: z.string() })).optional(),
});
const workflowSchema = z.object({ id: sourceId, name: z.string() });
export const promotionBindingConsumerSchema = z.object({
@ -135,6 +142,12 @@ export const promotionBindingWarningSchema = z.object({
* Callers must enforce the permissions for their endpoint.
*/
export const promotionBindingPreflightResultSchema = z.object({
missingProjects: z.array(promotionMissingProjectSchema).openapi({
description:
'Projects in the package that do not exist on this instance. ' +
'Preflight preserves their metadata without checking project creation rules. ' +
'The project creation API can reject invalid values.',
}),
missingBindings: z.array(
z.discriminatedUnion('kind', [
promotionMissingCredentialBindingSchema,

View File

@ -0,0 +1,159 @@
import { CreateUsersPublicDto, InvitedUsersPublicDto } from '../create-users-public.dto';
describe('CreateUsersPublicDto', () => {
test('accepts a list of email/role pairs', () => {
const result = CreateUsersPublicDto.safeParse([
{ email: 'user@example.com', role: 'global:admin' },
]);
expect(result.success).toBe(true);
});
test('defaults role to global:member', () => {
const result = CreateUsersPublicDto.safeParse([{ email: 'user@example.com' }]);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data[0].role).toBe('global:member');
}
});
test('rejects an invalid email', () => {
const result = CreateUsersPublicDto.safeParse([{ email: 'not-an-email' }]);
expect(result.success).toBe(false);
});
test('rejects the owner role', () => {
const result = CreateUsersPublicDto.safeParse([
{ email: 'user@example.com', role: 'global:owner' },
]);
expect(result.success).toBe(false);
});
test('rejects an empty role', () => {
const result = CreateUsersPublicDto.safeParse([{ email: 'user@example.com', role: '' }]);
expect(result.success).toBe(false);
});
test('accepts a custom role', () => {
const result = CreateUsersPublicDto.safeParse([
{ email: 'user@example.com', role: 'custom:role' },
]);
expect(result.success).toBe(true);
});
});
describe('InvitedUsersPublicDto', () => {
test('accepts an array of invite results', () => {
const result = InvitedUsersPublicDto.safeParse([
{
user: {
id: 'user-id',
email: 'user@example.com',
emailSent: true,
role: 'global:member',
},
error: '',
},
]);
expect(result.success).toBe(true);
});
test('accepts an optional inviteAcceptUrl', () => {
const result = InvitedUsersPublicDto.safeParse([
{
user: {
id: 'user-id',
email: 'user@example.com',
inviteAcceptUrl: 'https://n8n.example.com/signup?token=abc',
emailSent: false,
role: 'global:member',
},
error: '',
},
]);
expect(result.success).toBe(true);
});
test('accepts an optional error field', () => {
const result = InvitedUsersPublicDto.safeParse([
{
user: {
id: 'user-id',
email: 'user@example.com',
emailSent: true,
role: 'global:member',
},
},
]);
expect(result.success).toBe(true);
});
test('rejects a missing id', () => {
const result = InvitedUsersPublicDto.safeParse([
{
user: {
email: 'user@example.com',
emailSent: true,
role: 'global:member',
},
error: '',
},
]);
expect(result.success).toBe(false);
});
test('rejects a missing emailSent', () => {
const result = InvitedUsersPublicDto.safeParse([
{
user: {
id: 'user-id',
email: 'user@example.com',
role: 'global:member',
},
error: '',
},
]);
expect(result.success).toBe(false);
});
test('rejects a missing role', () => {
const result = InvitedUsersPublicDto.safeParse([
{
user: {
id: 'user-id',
email: 'user@example.com',
emailSent: true,
},
error: '',
},
]);
expect(result.success).toBe(false);
});
test('rejects an invalid email', () => {
const result = InvitedUsersPublicDto.safeParse([
{
user: {
id: 'user-id',
email: 'not-an-email',
emailSent: true,
role: 'global:member',
},
error: '',
},
]);
expect(result.success).toBe(false);
});
});

View File

@ -0,0 +1,21 @@
import { DeleteUserQueryPublicDto } from '../delete-user-query-public.dto';
describe('DeleteUserQueryPublicDto', () => {
test('allows an empty query', () => {
const result = DeleteUserQueryPublicDto.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.transferId).toBeUndefined();
}
});
test('accepts a transferId', () => {
const result = DeleteUserQueryPublicDto.safeParse({ transferId: 'project-1' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.transferId).toBe('project-1');
}
});
});

View File

@ -0,0 +1,21 @@
import { GetUserQueryDto } from '../get-user-query.dto';
describe('GetUserQueryDto', () => {
test('defaults includeRole to false', () => {
const result = GetUserQueryDto.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.includeRole).toBe(false);
}
});
test('parses includeRole from the query string', () => {
const result = GetUserQueryDto.safeParse({ includeRole: 'true' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.includeRole).toBe(true);
}
});
});

View File

@ -0,0 +1,35 @@
import { ListUsersQueryDto } from '../list-users-query.dto';
describe('ListUsersQueryDto', () => {
test('accepts an empty query and applies defaults', () => {
const result = ListUsersQueryDto.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.limit).toBe(100);
expect(result.data.includeRole).toBe(false);
expect(result.data.cursor).toBeUndefined();
expect(result.data.projectId).toBeUndefined();
}
});
test('parses includeRole and projectId from the query string', () => {
const result = ListUsersQueryDto.safeParse({ includeRole: 'true', projectId: 'project-1' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.includeRole).toBe(true);
expect(result.data.projectId).toBe('project-1');
}
});
test('accepts limit and cursor and drops offset', () => {
const result = ListUsersQueryDto.safeParse({ limit: '5', cursor: 'abc', offset: '3' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toMatchObject({ limit: 5, cursor: 'abc' });
expect(result.data).not.toHaveProperty('offset');
}
});
});

View File

@ -0,0 +1,39 @@
import { UserPublicDto } from '../user-public.dto';
describe('UserPublicDto', () => {
const baseUser = {
id: 'user-id',
email: 'member@example.com',
firstName: null,
lastName: null,
isPending: true,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-02T00:00:00.000Z',
mfaEnabled: false,
};
// A shell user (invited, not yet accepted) has no name yet.
test('allows a null firstName and lastName', () => {
const result = UserPublicDto.safeParse(baseUser);
expect(result.success).toBe(true);
});
test('role is absent unless the caller asks for it', () => {
const result = UserPublicDto.safeParse(baseUser);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).not.toHaveProperty('role');
}
});
test('accepts a role when included', () => {
const result = UserPublicDto.safeParse({ ...baseUser, role: 'global:owner' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.role).toBe('global:owner');
}
});
});

View File

@ -0,0 +1,32 @@
import '../../openapi-extend';
import { z } from 'zod';
import { invitedUserSchema } from '../invitation/invite-users-request.dto';
import { Z } from '../../zod-class';
/** Request body of `POST /users`: one or more users to invite. */
export class CreateUsersPublicDto extends Z.array(invitedUserSchema) {}
const invitedUserResultSchema = z.object({
user: z.object({
id: z.string().openapi({ readOnly: true, example: '123e4567-e89b-12d3-a456-426614174000' }),
email: z.string().email().openapi({ example: 'john.doe@company.com' }),
inviteAcceptUrl: z.string().optional().openapi({
readOnly: true,
description:
'Link the invited user can open to finish setting up the account. Present only when the instance does not send invite emails.',
}),
emailSent: z.boolean().openapi({
readOnly: true,
description: 'Whether the invite email was sent to the user.',
}),
role: z.string().openapi({ readOnly: true, example: 'global:member' }),
}),
error: z.string().optional().openapi({
readOnly: true,
description: 'The reason the invite failed for this user. Empty when the invite succeeded.',
}),
});
/** Response body of `POST /users`: one result per requested invite, in the same order. */
export class InvitedUsersPublicDto extends Z.array(invitedUserResultSchema) {}

View File

@ -0,0 +1,11 @@
import '../../openapi-extend';
import { z } from 'zod';
import { Z } from '../../zod-class';
export class DeleteUserQueryPublicDto extends Z.class({
transferId: z.string().optional().openapi({
description:
"ID of the project to transfer the deleted user's workflows and credentials to. Omit to delete those resources instead of transferring them.",
}),
}) {}

View File

@ -0,0 +1,6 @@
import { Z } from '../../zod-class';
import { booleanFromString } from '../../schemas/boolean-from-string';
export class GetUserQueryDto extends Z.class({
includeRole: booleanFromString.optional().default('false'),
}) {}

View File

@ -0,0 +1,12 @@
import { z } from 'zod';
import { Z } from '../../zod-class';
import { booleanFromString } from '../../schemas/boolean-from-string';
import { publicApiPaginationSchema } from '../pagination/pagination.dto';
export class ListUsersQueryDto extends Z.class({
limit: publicApiPaginationSchema.limit,
cursor: z.string().optional(),
includeRole: booleanFromString.optional().default('false'),
projectId: z.string().optional(),
}) {}

View File

@ -0,0 +1,36 @@
import '../../openapi-extend';
import { z } from 'zod';
import { Z } from '../../zod-class';
/** The user as the Public API publishes it. `role` is present only when the caller asks for it. */
export const userPublicSchema = z.object({
id: z.string().openapi({ readOnly: true, example: '123e4567-e89b-12d3-a456-426614174000' }),
email: z.string().email().nullable().openapi({ example: 'john.doe@company.com' }),
firstName: z.string().nullable().openapi({ readOnly: true, example: 'john' }),
lastName: z.string().nullable().openapi({ readOnly: true, example: 'Doe' }),
isPending: z.boolean().openapi({
readOnly: true,
description:
'Whether the user finished setting up their account in response to the invitation (true) or not (false).',
}),
createdAt: z.string().datetime().openapi({ readOnly: true }),
updatedAt: z.string().datetime().openapi({ readOnly: true }),
mfaEnabled: z.boolean().openapi({
readOnly: true,
description: 'Whether the user has multi-factor authentication (MFA/2FA) enabled.',
example: false,
}),
role: z.string().optional().openapi({ readOnly: true, example: 'global:owner' }),
});
export class UserPublicDto extends Z.class(userPublicSchema.shape) {}
export class UserListPublicDto extends Z.class({
data: z.array(userPublicSchema),
nextCursor: z.string().nullable().openapi({
description:
'Paginate through users by setting the cursor parameter to a nextCursor attribute returned by a previous request. Default value fetches the first page of the collection.',
example: 'MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA',
}),
}) {}

View File

@ -164,7 +164,10 @@ export {
roleMappingRuleIdParamSchema,
roleSlugParamSchema,
tagIdParamSchema,
testRunIdParamSchema,
userIdParamSchema,
userIdentifierParamSchema,
userUuidParamSchema,
variableIdParamSchema,
workflowIdParamSchema,
workflowVersionIdParamSchema,

View File

@ -20,3 +20,16 @@ export const projectRelationSchema = z.object({
role: assignableProjectRoleSchema,
});
export type ProjectRelation = z.infer<typeof projectRelationSchema>;
const customTelemetryTagSchema = z.object({
key: z.string().refine((k) => k.trim().length > 0, { message: 'Key must not be empty' }),
value: z.string(),
});
export const projectCustomTelemetryTagsSchema = z.array(customTelemetryTagSchema).refine(
(tags) => {
const trimmedKeys = tags.map((t) => t.key.trim());
return trimmedKeys.length === new Set(trimmedKeys).size;
},
{ message: 'Duplicate keys are not allowed in custom span attributes' },
);

View File

@ -1,4 +1,5 @@
import '../openapi-extend';
import validator from 'validator';
import { z } from 'zod';
import { promotionDirectionSchema } from '../dto/promotions/promotion-config.dto';
@ -19,10 +20,21 @@ export const executionIdParamSchema = numericIdParamSchema('The ID of the execut
export const workflowIdParamSchema = stringIdParamSchema('The ID of the workflow.');
export const workflowVersionIdParamSchema = stringIdParamSchema('The ID of the workflow version.');
export const testRunIdParamSchema = stringIdParamSchema('The ID of the test run.');
export const projectIdParamSchema = stringIdParamSchema('The ID of the project.');
export const tagIdParamSchema = stringIdParamSchema('The ID of the tag.');
export const folderIdParamSchema = stringIdParamSchema('The ID of the folder.');
export const userIdParamSchema = stringIdParamSchema('The ID of the user.');
export const userIdentifierParamSchema = z
.string()
.refine((value) => validator.isUUID(value) || validator.isEmail(value), {
message: 'must be a valid ID or email',
})
.openapi({ param: { description: 'The ID or email of the user.' } });
export const userUuidParamSchema = z
.string()
.refine((value) => validator.isUUID(value), { message: 'must be a valid ID' })
.openapi({ param: { description: 'The ID of the user.' } });
export const roleSlugParamSchema = stringIdParamSchema('The slug of the role.');
export const roleMappingRuleIdParamSchema = stringIdParamSchema('The ID of the role mapping rule.');
export const promotionConnectionIdParamSchema = stringIdParamSchema(

View File

@ -21,6 +21,10 @@ export default defineConfig(
rule: 'no-unsealed-workflow-entity-write',
message: 'Route the write through a token-gated `WorkflowRepository` method.',
},
{
rule: 'no-unsealed-credentials-entity-write',
message: 'Route the write through a token-gated `CredentialsRepository` method.',
},
],
},
],

View File

@ -21,6 +21,10 @@ export default defineConfig({
rule: 'no-unsealed-workflow-entity-write',
message: 'Route the write through a token-gated `WorkflowRepository` method.',
},
{
rule: 'no-unsealed-credentials-entity-write',
message: 'Route the write through a token-gated `CredentialsRepository` method.',
},
],
},
],

View File

@ -12,7 +12,7 @@ export * from './uuid';
export const LICENSE_FEATURES = {
SHARING: 'feat:sharing',
LDAP: 'feat:ldap',
NODE_TYPE_POLICIES: 'feat:nodeTypePolicies',
TYPE_AVAILABILITY_POLICIES: 'feat:typeAvailabilityPolicies',
SAML: 'feat:saml',
OIDC: 'feat:oidc',
MFA_ENFORCEMENT: 'feat:mfaEnforcement',

View File

@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import type { SqliteConfig } from '@n8n/config';
import { Container } from '@n8n/di';
@ -10,12 +11,13 @@ import type { IRunExecutionData, IWorkflowBase } from 'n8n-workflow';
import { nanoid } from 'nanoid';
import { mock } from 'vitest-mock-extended';
import { ExecutionEntity, type WorkflowEntity } from '../../entities';
import { ExecutionEntity, Project, type WorkflowEntity } from '../../entities';
import type { IExecutionResponse } from '../../entities/types-db';
import { TransactionRunner } from '../../services/transaction';
import { mockEntityManager } from '../../utils/test-utils/mock-entity-manager';
import { mockInstance } from '../../utils/test-utils/mock-instance';
import { ExecutionRepository } from '../execution.repository';
import { SharedWorkflowRepository } from '../shared-workflow.repository';
const GREATER_THAN_MAX_UPDATE_THRESHOLD = 901;
@ -28,6 +30,8 @@ describe('ExecutionRepository', () => {
logging: { outputs: ['console'], scopes: [] },
});
mockInstance(BinaryDataService);
const logger = mockInstance(Logger);
const sharedWorkflowRepository = mockInstance(SharedWorkflowRepository);
const transactionRunner = mock<TransactionRunner>();
Container.set(TransactionRunner, transactionRunner);
const executionRepository = Container.get(ExecutionRepository);
@ -35,6 +39,7 @@ describe('ExecutionRepository', () => {
beforeEach(() => {
vi.resetAllMocks();
transactionRunner.run.mockImplementation(async (ctx, fn) => await fn(ctx));
sharedWorkflowRepository.findOwnerProjectsByWorkflowIds.mockResolvedValue(new Map());
});
describe('countInWorkflows', () => {
@ -249,14 +254,23 @@ describe('ExecutionRepository', () => {
});
});
const crashableRow = (id: string) =>
mock<ExecutionEntity>({
const crashedStartedAt = new Date('2025-01-01T00:00:00.000Z');
const crashableRow = (id: string, overrides: Partial<ExecutionEntity> = {}) =>
Object.assign(new ExecutionEntity(), {
id,
workflowId: `workflow-${id}`,
mode: 'trigger',
startedAt: crashedStartedAt,
workflow: mock<WorkflowEntity>({ id: `workflow-${id}`, name: `Workflow ${id}` }),
...overrides,
});
const ownerProject = (
id: string,
customTelemetryTags: Array<{ key: string; value: string }> = [],
) => Object.assign(new Project(), { id, customTelemetryTags });
const executionIdsOfLength = (length: number) =>
Array(length)
.fill(undefined)
@ -280,7 +294,7 @@ describe('ExecutionRepository', () => {
test('should crash only in-progress rows and clear their `waitTill`', async () => {
const executionIds = ['1', '2'];
entityManager.find.mockResolvedValue(executionIds.map(crashableRow));
entityManager.find.mockResolvedValue(executionIds.map((id) => crashableRow(id)));
await executionRepository.markAsCrashed(executionIds);
@ -291,7 +305,7 @@ describe('ExecutionRepository', () => {
);
});
test('should report the workflow, name and mode of each execution it transitioned', async () => {
test('should report the workflow, name, mode and start time of each execution it transitioned', async () => {
entityManager.find.mockResolvedValue([crashableRow('1')]);
const crashed = await executionRepository.markAsCrashed(['1', '2']);
@ -302,6 +316,8 @@ describe('ExecutionRepository', () => {
workflowId: 'workflow-1',
workflowName: 'Workflow 1',
mode: 'trigger',
startedAt: crashedStartedAt,
stoppedAt: expect.any(Date),
},
]);
});
@ -315,11 +331,100 @@ describe('ExecutionRepository', () => {
const findOptions = entityManager.find.mock.calls[0][1];
expect(findOptions).toMatchObject({
select: {
workflowVersionId: true,
retryOf: true,
workflow: { settings: { customTelemetryTags: true } },
},
where: { id: In(['1']), status: 'crashed', stoppedAt: updateValues.stoppedAt },
withDeleted: true,
});
});
test('should read back and report the trace context of each execution', async () => {
const tracingContext = {
traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01',
};
entityManager.find.mockResolvedValue([
crashableRow('1', { tracingContext }),
crashableRow('2', { tracingContext: null }),
]);
const crashed = await executionRepository.markAsCrashed(['1', '2']);
const findOptions = entityManager.find.mock.calls[0][1];
expect(findOptions).toMatchObject({ select: { tracingContext: { traceparent: true } } });
expect(crashed[0].tracingContext).toEqual(tracingContext);
expect(crashed[1].tracingContext).toBeUndefined();
});
test('should read back and report the version id, retry source and workflow tags', async () => {
const customTelemetryTags = [{ key: 'env', value: 'production' }];
entityManager.find.mockResolvedValue([
crashableRow('1', {
workflowVersionId: 'version-1',
retryOf: '9',
workflow: mock<WorkflowEntity>({ settings: { customTelemetryTags } }),
}),
crashableRow('2', { workflowVersionId: null }),
]);
const crashed = await executionRepository.markAsCrashed(['1', '2']);
expect(crashed[0]).toMatchObject({
workflowVersionId: 'version-1',
retryOf: '9',
workflowCustomTelemetryTags: customTelemetryTags,
});
expect(crashed[1].workflowVersionId).toBeUndefined();
expect(crashed[1].retryOf).toBeUndefined();
});
test('should report the owner project of each execution', async () => {
sharedWorkflowRepository.findOwnerProjectsByWorkflowIds.mockResolvedValue(
new Map([['workflow-1', ownerProject('project-1', [{ key: 'team', value: 'platform' }])]]),
);
entityManager.find.mockResolvedValue([crashableRow('1'), crashableRow('2')]);
const crashed = await executionRepository.markAsCrashed(['1', '2']);
expect(crashed[0].project).toEqual({
id: 'project-1',
customTelemetryTags: [{ key: 'team', value: 'platform' }],
});
expect(crashed[1].project).toBeUndefined();
});
test('should report without a project when the owner lookup fails', async () => {
sharedWorkflowRepository.findOwnerProjectsByWorkflowIds.mockRejectedValue(
new Error('connection reset'),
);
entityManager.find.mockResolvedValue([crashableRow('1')]);
const crashed = await executionRepository.markAsCrashed(['1']);
expect(crashed[0].project).toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(
'Failed to load owner projects for crashed executions',
expect.objectContaining({ error: expect.any(Error) }),
);
});
test('should look up the owner projects once for each batch, on deduplicated workflow ids', async () => {
entityManager.find.mockResolvedValue([
crashableRow('1', { workflowId: 'workflow-1' }),
crashableRow('2', { workflowId: 'workflow-1' }),
]);
await executionRepository.markAsCrashed(['1', '2']);
expect(sharedWorkflowRepository.findOwnerProjectsByWorkflowIds).toHaveBeenCalledTimes(1);
expect(sharedWorkflowRepository.findOwnerProjectsByWorkflowIds).toHaveBeenCalledWith([
'workflow-1',
]);
});
test('should collapse duplicated ids before batching', async () => {
const oneBatchWorthOfIds = executionIdsOfLength(GREATER_THAN_MAX_UPDATE_THRESHOLD - 1);
entityManager.find.mockResolvedValue([crashableRow('1')]);
@ -335,6 +440,7 @@ describe('ExecutionRepository', () => {
const crashed = await executionRepository.markAsCrashed(['1']);
expect(entityManager.find).not.toHaveBeenCalled();
expect(sharedWorkflowRepository.findOwnerProjectsByWorkflowIds).not.toHaveBeenCalled();
expect(crashed).toEqual([]);
});
});
@ -352,9 +458,27 @@ describe('ExecutionRepository', () => {
expect.objectContaining({ status: 'crashed', waitTill: null }),
);
expect(crashed).toEqual([
{ id: '1', workflowId: 'workflow-1', workflowName: 'Workflow 1', mode: 'trigger' },
{
id: '1',
workflowId: 'workflow-1',
workflowName: 'Workflow 1',
mode: 'trigger',
startedAt: crashedStartedAt,
stoppedAt: expect.any(Date),
},
]);
});
test('should report the owner project of each execution it transitioned', async () => {
sharedWorkflowRepository.findOwnerProjectsByWorkflowIds.mockResolvedValue(
new Map([['workflow-1', ownerProject('project-1')]]),
);
entityManager.find.mockResolvedValue([crashableRow('1')]);
const crashed = await executionRepository.markWorkflowExecutionsAsCrashed('workflow-1');
expect(crashed[0].project).toEqual({ id: 'project-1', customTelemetryTags: [] });
});
});
describe('setRunning', () => {

View File

@ -32,6 +32,7 @@ import type {
ExecutionSummary,
IRunExecutionData,
IRunExecutionDataAll,
IWorkflowSettings,
WorkflowExecuteMode,
} from 'n8n-workflow';
import {
@ -78,6 +79,13 @@ export type CrashedExecution = {
workflowId: string;
workflowName?: string;
mode: WorkflowExecuteMode;
startedAt: Date | null;
stoppedAt: Date;
tracingContext?: { traceparent: string; tracestate?: string };
workflowVersionId?: string;
retryOf?: string;
workflowCustomTelemetryTags?: IWorkflowSettings['customTelemetryTags'];
project?: { id: string; customTelemetryTags: Array<{ key: string; value: string }> };
};
export interface UpdateExecutionConditions {
@ -400,7 +408,9 @@ export class ExecutionRepository extends BaseRepository<ExecutionEntity> {
const crashed: CrashedExecution[] = [];
for (const batch of chunk(ids, MAX_UPDATE_BATCH_SIZE)) {
const transitioned = await this.transitionToCrashed({ id: In(batch) });
const transitioned = await this.withOwnerProjects(
await this.transitionToCrashed({ id: In(batch) }),
);
crashed.push(...transitioned);
// Report each batch as it commits, so a later batch that throws keeps the earlier reports.
@ -413,7 +423,9 @@ export class ExecutionRepository extends BaseRepository<ExecutionEntity> {
/** Set the workflow's in-progress executions to `crashed`. */
async markWorkflowExecutionsAsCrashed(workflowId: string): Promise<CrashedExecution[]> {
const transitioned = await this.transitionToCrashed({ workflowId });
const transitioned = await this.withOwnerProjects(
await this.transitionToCrashed({ workflowId }),
);
if (transitioned.length > 0) {
this.logger.info('Marked executions as `crashed`', {
@ -450,22 +462,76 @@ export class ExecutionRepository extends BaseRepository<ExecutionEntity> {
if (updateResult?.affected === 0) return [];
const rows = await tx.find(ExecutionEntity, {
select: { id: true, workflowId: true, mode: true, workflow: { id: true, name: true } },
select: {
id: true,
workflowId: true,
workflowVersionId: true,
mode: true,
retryOf: true,
startedAt: true,
// TypeORM types a JSON column's select as a nested select, but any truthy
// value here selects the whole column.
tracingContext: { traceparent: true, tracestate: true },
workflow: { id: true, name: true, settings: { customTelemetryTags: true } },
},
relations: { workflow: true },
where: { ...where, status: 'crashed', stoppedAt },
// The UPDATE above also crashes soft-deleted rows, so keep them in the read.
withDeleted: true,
});
return rows.map(({ id, workflowId, mode, workflow }) => ({
id,
workflowId,
workflowName: workflow?.name,
mode,
}));
return rows.map(
({
id,
workflowId,
workflowVersionId,
mode,
retryOf,
startedAt,
tracingContext,
workflow,
}) => ({
id,
workflowId,
workflowName: workflow?.name,
workflowVersionId: workflowVersionId ?? undefined,
mode,
retryOf: retryOf ?? undefined,
startedAt,
stoppedAt,
tracingContext: tracingContext ?? undefined,
workflowCustomTelemetryTags: workflow?.settings?.customTelemetryTags,
}),
);
});
}
/** Add the owner project of each execution's workflow. */
private async withOwnerProjects(executions: CrashedExecution[]): Promise<CrashedExecution[]> {
if (executions.length === 0) return executions;
try {
const projects = await this.sharedWorkflowRepository.findOwnerProjectsByWorkflowIds([
...new Set(executions.map(({ workflowId }) => workflowId)),
]);
return executions.map((execution) => {
const project = projects.get(execution.workflowId);
if (!project) return execution;
return {
...execution,
project: { id: project.id, customTelemetryTags: project.customTelemetryTags },
};
});
} catch (error) {
// The project only decorates the report, so a failed lookup must still let the
// crash be counted and announced.
this.logger.warn('Failed to load owner projects for crashed executions', { error });
return executions;
}
}
async setRunning(executionId: string) {
const startedAt = new Date();

View File

@ -55,7 +55,11 @@ export { InstanceCredentialAssignmentRepository } from './instance-credential-as
export { LicenseMetricsRepository } from './license-metrics.repository';
export { ProjectPoolSettingsRepository } from './project-pool-settings.repository.ee';
export { ProjectRelationRepository } from './project-relation.repository';
export { ProjectRepository, type ProjectListOptions } from './project.repository';
export {
ProjectRepository,
ProjectIdConflictError,
type ProjectListOptions,
} from './project.repository';
export { RoleRepository } from './role.repository';
export { RoleMappingRuleRepository } from './role-mapping-rule.repository';
export { ScheduledJobRepository } from './scheduled-job.repository';

View File

@ -1,14 +1,68 @@
import { UNLIMITED_LICENSE_QUOTA } from '@n8n/constants';
import { Service } from '@n8n/di';
import type { EntityManager, SelectQueryBuilder } from '@n8n/typeorm';
import { Brackets, DataSource, In, Not, Repository } from '@n8n/typeorm';
import { Brackets, DataSource, In, Not } from '@n8n/typeorm';
import { UserError } from 'n8n-workflow';
import { Project } from '../entities';
import { BaseRepository } from './base-repository';
import { Project, ProjectRelation } from '../entities';
import { TransactionRunner } from '../services/transaction';
import { chunkIds } from '../utils/chunk-ids';
import { isUniqueConstraintError } from '../utils/is-unique-constraint-error';
export class ProjectIdConflictError extends UserError {
constructor() {
super('A project with this ID already exists');
}
}
@Service()
export class ProjectRepository extends Repository<Project> {
constructor(dataSource: DataSource) {
super(Project, dataSource.manager);
export class ProjectRepository extends BaseRepository<Project> {
constructor(
dataSource: DataSource,
private readonly txRunner: TransactionRunner,
) {
super(Project, dataSource.manager, txRunner);
}
/** Returns null when the team project quota is full. */
async insertTeamProjectWithAdmin(
data: Pick<Project, 'name'> &
Partial<Pick<Project, 'id' | 'icon' | 'description' | 'customTelemetryTags'>>,
creatorId: string,
limit: number,
): Promise<Project | null> {
// Keep the quota check and both inserts in one serializable transaction.
return await this.txRunner.run(
{},
async (ctx) => {
const manager = this.managerFor(ctx);
if (limit !== UNLIMITED_LICENSE_QUOTA) {
const count = await manager.count(Project, { where: { type: 'team' } });
if (count >= limit) {
if (data.id && (await manager.existsBy(Project, { id: data.id }))) {
throw new ProjectIdConflictError();
}
return null;
}
}
const project = this.create({ ...data, type: 'team', creatorId });
try {
await manager.insert(Project, project);
} catch (error) {
if (isUniqueConstraintError(error)) throw new ProjectIdConflictError();
throw error;
}
await manager.insert(ProjectRelation, {
projectId: project.id,
userId: creatorId,
role: { slug: 'project:admin' },
});
return await manager.findOneByOrFail(Project, { id: project.id });
},
{ isolationLevel: 'SERIALIZABLE' },
);
}
async getPersonalProjectForUser(userId: string, entityManager?: EntityManager) {

View File

@ -18,8 +18,9 @@ export const localRulesPlugin = {
'n8n-local-rules': plugin,
},
rules: {
// Seals WorkflowEntity node writes to the token-gated repository methods.
// Seal entity content writes to the token-gated repository methods.
'n8n-local-rules/no-unsealed-workflow-entity-write': 'error',
'n8n-local-rules/no-unsealed-credentials-entity-write': 'error',
'n8n-local-rules/no-uncaught-json-parse': 'error',
'n8n-local-rules/no-json-parse-json-stringify': 'error',
'n8n-local-rules/no-unneeded-backticks': 'error',

View File

@ -28,6 +28,7 @@ import { NoRestrictedSleepImportRule } from './no-restricted-sleep-import.js';
import { NoRepositoryInPublicApiHandlerRule } from './no-repository-in-public-api-handler.js';
import { RequirePublicApiControllerRule } from './require-public-api-controller.js';
import { NoUnsealedWorkflowEntityWriteRule } from './no-unsealed-workflow-entity-write.js';
import { NoUnsealedCredentialsEntityWriteRule } from './no-unsealed-credentials-entity-write.js';
import { RequireEscapedQueryValuesRule } from './require-escaped-query-values.js';
import { NoOnLeaderTakeoverRule } from './no-on-leader-takeover.js';
import { NoEncryptionGuardrailDisableRule } from './no-encryption-guardrail-disable.js';
@ -62,6 +63,7 @@ export const rules = {
'no-repository-in-public-api-handler': NoRepositoryInPublicApiHandlerRule,
'require-public-api-controller': RequirePublicApiControllerRule,
'no-unsealed-workflow-entity-write': NoUnsealedWorkflowEntityWriteRule,
'no-unsealed-credentials-entity-write': NoUnsealedCredentialsEntityWriteRule,
'require-escaped-query-values': RequireEscapedQueryValuesRule,
'no-on-leader-takeover': NoOnLeaderTakeoverRule,
'no-encryption-guardrail-disable': NoEncryptionGuardrailDisableRule,

View File

@ -0,0 +1,95 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { NoUnsealedCredentialsEntityWriteRule } from './no-unsealed-credentials-entity-write.js';
const ruleTester = new RuleTester();
const errors = [{ messageId: 'unsealedWrite' as const }];
ruleTester.run('no-unsealed-credentials-entity-write', NoUnsealedCredentialsEntityWriteRule, {
valid: [
// An OAuth token refresh writes ciphertext, never the type.
'credentialsRepository.update(id, { data });',
'credentialsRepository.update(id, { data, updatedAt });',
'credentialsRepository.update(id);',
// The sanctioned, token-gated write methods.
'credentialsRepository.updateContent(id, { type }, ctx);',
'credentialsRepository.createContent(credential, ctx);',
'credentialsRepository.saveInstanceCredential(credential, ctx);',
// Reads and existence changes are out of the type seal's scope.
'credentialsRepository.delete(id);',
'credentialsRepository.existsBy({ id });',
"manager.query('DELETE FROM credentials_entity WHERE id = ?');",
"manager.query('SELECT data FROM credentials_entity WHERE id = ?');",
// Anchored, so a sibling repository stays out.
'sharedCredentialsRepository.save(sharedCredential);',
'credentialDependencyRepository.save(dependency);',
// A different entity.
'workflowRepository.save(workflow);',
'manager.save(WorkflowEntity, workflow);',
"manager.query('UPDATE workflow_entity SET nodes = ?');",
{ code: 'credentialsRepository.save(credential);', filename: '/src/example.test.ts' },
{
code: 'credentialsRepository.save(credential);',
filename: '/repositories/credentials.repository.ts',
},
{
code: 'credentialsRepository.save(credential);',
filename: '/packages/@n8n/backend-test-utils/src/db/credentials.ts',
},
// A database migration owns its table.
{
code: 'credentialsRepository.save(credential);',
filename: '/packages/@n8n/db/src/migrations/common/1700000000000-Foo.ts',
},
{
code: 'credentialsRepository.save(credential);',
filename: '/packages/@n8n/engine/src/database/migrations/1700000000000-Foo.ts',
},
],
invalid: [
{ code: 'credentialsRepository.save(credential);', errors },
{ code: 'this.credentialsRepository.save(credential);', errors },
{ code: "this.credentialsRepo.update(id, { type: 'slackApi' });", errors },
{ code: "credentialsRepository['save'](credential);", errors },
{ code: 'credentialRepo.insert(credential);', errors },
{ code: 'Container.get(CredentialsRepository).save(credential);', errors },
{ code: 'dataSource.getRepository(CredentialsEntity).save(credential);', errors },
{ code: 'manager.getRepository(CredentialsEntity).insert(credential);', errors },
{ code: "manager.getRepository(CredentialsEntity).upsert(credential, ['id']);", errors },
{ code: "manager.getRepository(CredentialsEntity).update(id, { type: 'slackApi' });", errors },
{ code: "manager.getRepository('credentials_entity').save(credential);", errors },
{ code: 'credentialsRepository?.save(credential);', errors },
{ code: "credentialsRepository.update(id, { type: 'slackApi' });", errors },
{ code: "credentialsRepository.update(id, { 'type': 'slackApi' });", errors },
{ code: 'manager.save(CredentialsEntity, credential);', errors },
{ code: "manager.upsert(CredentialsEntity, credential, ['id']);", errors },
{ code: "manager.update(CredentialsEntity, id, { type: 'slackApi' });", errors },
{ code: "manager.update<CredentialsEntity>(id, { type: 'slackApi' });", errors },
// A call in the receiver must not hide the type argument.
{ code: "dataSource.manager.update<CredentialsEntity>(id, { type: 'slackApi' });", errors },
{
code: "container.get(DataSource).manager.update<CredentialsEntity>(id, { type: 'slackApi' });",
errors,
},
{ code: 'queryBuilder.insert().into(CredentialsEntity);', errors },
{
code: "manager.createQueryBuilder().update(CredentialsEntity).set({ type: 'slackApi' });",
errors,
},
{
code: "credentialsRepository.createQueryBuilder().update().set({ type: 'slackApi' });",
errors,
},
{ code: "manager.query('UPDATE credentials_entity SET type = ?');", errors },
{ code: 'manager.query(`INSERT INTO credentials_entity (type) VALUES (?)`);', errors },
{ code: 'manager.query(`UPDATE ${prefix}credentials_entity SET type = ?`);', errors },
{ code: "manager.query('UPDATE public.credentials_entity SET type = ?');", errors },
// A folder merely named `migrations` is not a database migration.
{
code: 'credentialsRepository.save(credential);',
filename: '/packages/cli/src/modules/breaking-changes/migrations/foo.migration.ts',
errors,
},
{ code: 'manager.query(`UPDATE "public"."credentials_entity" SET type = ?`);', errors },
],
});

View File

@ -0,0 +1,15 @@
import { createUnsealedEntityWriteRule } from './unsealed-entity-write.factory.js';
export const NoUnsealedCredentialsEntityWriteRule = createUnsealedEntityWriteRule({
entityName: 'CredentialsEntity',
tableName: 'credentials_entity',
repositoryName: 'CredentialsRepository',
// A sibling like `sharedCredentialsRepository` stays out because the match is anchored.
repositoryReceiver: /^(?:credential|credentials)Repo(?:sitory)?$/i,
repositoryFile: /[\\/]repositories[\\/]credentials\.repository\.ts$/,
// `type` is the only field the credential policy check reads, so a data-only write
// (an OAuth token refresh) stays clean.
policedKey: 'type',
description: 'Route credential type writes through policy-cleared repository methods.',
message: 'Route CredentialsEntity type writes through a policy-cleared repository method.',
});

View File

@ -21,6 +21,11 @@ ruleTester.run('no-unsealed-workflow-entity-write', NoUnsealedWorkflowEntityWrit
code: 'workflowRepository.save(workflow);',
filename: '/packages/@n8n/backend-test-utils/src/db/workflows.ts',
},
// A database migration owns its table.
{
code: 'workflowRepository.save(workflow);',
filename: '/packages/@n8n/db/src/migrations/common/1700000000000-Foo.ts',
},
],
invalid: [
{ code: 'workflowRepository.save(workflow);', errors },
@ -41,6 +46,11 @@ ruleTester.run('no-unsealed-workflow-entity-write', NoUnsealedWorkflowEntityWrit
{ code: "manager.upsert(WorkflowEntity, workflow, ['id']);", errors },
{ code: 'manager.update(WorkflowEntity, id, { nodes: [] });', errors },
{ code: 'manager.update<WorkflowEntity>(id, { nodes: [] });', errors },
// A call in the receiver must not hide the type argument.
{
code: 'container.get(DataSource).manager.update<WorkflowEntity>(id, { nodes: [] });',
errors,
},
{ code: 'queryBuilder.insert().into(WorkflowEntity);', errors },
{
code: 'manager.createQueryBuilder().update(WorkflowEntity).set({ nodes: [] });',
@ -56,5 +66,12 @@ ruleTester.run('no-unsealed-workflow-entity-write', NoUnsealedWorkflowEntityWrit
{ code: 'manager.query(`UPDATE\\x20workflow_entity SET nodes = ?`);', errors },
{ code: "manager.query('UPDATE public.workflow_entity SET nodes = ?');", errors },
{ code: 'manager.query(`UPDATE "public"."workflow_entity" SET nodes = ?`);', errors },
// A folder merely named `migrations` is not a database migration: the node
// migrations under breaking-changes rewrite workflow content at runtime.
{
code: 'workflowRepository.save(workflow);',
filename: '/packages/cli/src/modules/breaking-changes/migrations/foo.migration.ts',
errors,
},
],
});

View File

@ -1,204 +1,12 @@
import { ESLintUtils } from '@typescript-eslint/utils';
import { createUnsealedEntityWriteRule } from './unsealed-entity-write.factory.js';
const NON_RUNTIME_FILE =
/(\.(test|spec)\.ts$)|([\\/]__tests__[\\/])|([\\/]test[\\/])|([\\/]migrations[\\/])|([\\/]backend-test-utils[\\/])/;
const WORKFLOW_REPOSITORY_FILE = /[\\/]repositories[\\/]workflow\.repository\.ts$/;
const WRITE_METHODS = new Set(['save', 'insert', 'upsert']);
const RAW_WORKFLOW_WRITE =
/\b(?:UPDATE|INSERT\s+INTO)\s+(?:(?:"?\w+"?)\s*\.\s*)?["'`]?\w*workflow_entity["'`]?(?=\s|\(|$)/i;
const WORKFLOW_REPOSITORY_NAME = /^(?:workflow|workflows)Repo(?:sitory)?$/i;
type SyntaxNode = {
type: string;
name?: string;
value?: unknown;
computed?: boolean;
property?: SyntaxNode;
object?: SyntaxNode;
expression?: SyntaxNode;
callee?: SyntaxNode;
arguments?: SyntaxNode[];
properties?: Array<{
type: string;
computed?: boolean;
key?: SyntaxNode;
}>;
};
const unwrapChain = (node: SyntaxNode): SyntaxNode =>
node.type === 'ChainExpression' && node.expression ? node.expression : node;
const memberName = (node: SyntaxNode) => {
if (!node.property) return undefined;
if (!node.computed && node.property.type === 'Identifier') return node.property.name;
if (
node.computed &&
node.property.type === 'Literal' &&
typeof node.property.value === 'string'
) {
return node.property.value;
}
return undefined;
};
const targetsWorkflowEntity = (node: SyntaxNode | undefined) =>
node !== undefined &&
((node.type === 'Identifier' && node.name === 'WorkflowEntity') ||
(node.type === 'Literal' &&
(node.value === 'WorkflowEntity' || node.value === 'workflow_entity')));
const targetsWorkflowRepositoryClass = (node: SyntaxNode | undefined) =>
node?.type === 'Identifier' && node.name === 'WorkflowRepository';
const isWorkflowRepository = (node: SyntaxNode): boolean => {
const target = unwrapChain(node);
if (target.type === 'Identifier') return WORKFLOW_REPOSITORY_NAME.test(target.name ?? '');
if (target.type === 'MemberExpression') {
return WORKFLOW_REPOSITORY_NAME.test(memberName(target) ?? '');
}
if (target.type !== 'CallExpression' || !target.callee) return false;
const callee = unwrapChain(target.callee);
return (
callee.type === 'MemberExpression' &&
((memberName(callee) === 'get' && targetsWorkflowRepositoryClass(target.arguments?.[0])) ||
(memberName(callee) === 'getRepository' && targetsWorkflowEntity(target.arguments?.[0])))
);
};
const hasNodes = (node: SyntaxNode) =>
node.type === 'ObjectExpression' &&
(node.properties ?? []).some((property) => {
if (property.type === 'SpreadElement' || !property.key) return false;
if (!property.computed && property.key.type === 'Identifier') {
return property.key.name === 'nodes';
}
return property.key.type === 'Literal' && property.key.value === 'nodes';
});
const isWorkflowQueryBuilder = (node: SyntaxNode): boolean => {
const call = unwrapChain(node);
if (call.type !== 'CallExpression' || !call.callee) return false;
const callee = unwrapChain(call.callee);
if (callee.type !== 'MemberExpression' || !callee.object) return false;
const method = memberName(callee);
if (method === 'createQueryBuilder') return isWorkflowRepository(callee.object);
if (method !== 'update') return false;
return (
targetsWorkflowEntity(call.arguments?.[0]) ||
(call.arguments?.length === 0 && isWorkflowQueryBuilder(callee.object))
);
};
export const NoUnsealedWorkflowEntityWriteRule = ESLintUtils.RuleCreator.withoutDocs({
meta: {
type: 'problem',
docs: {
description: 'Route workflow content writes through policy-cleared repository methods.',
},
messages: {
unsealedWrite:
'Route WorkflowEntity content writes through a policy-cleared repository method.',
},
schema: [],
},
defaultOptions: [],
create(context) {
if (
NON_RUNTIME_FILE.test(context.filename) ||
WORKFLOW_REPOSITORY_FILE.test(context.filename)
) {
return {};
}
return {
CallExpression(node) {
const callee = unwrapChain(node.callee as never);
if (callee.type !== 'MemberExpression' || !callee.object) return;
const method = memberName(callee);
if (!method) return;
if (WRITE_METHODS.has(method) && isWorkflowRepository(callee.object)) {
context.report({ node, messageId: 'unsealedWrite' });
return;
}
const secondArgument = node.arguments[1];
if (
method === 'update' &&
isWorkflowRepository(callee.object) &&
secondArgument !== undefined &&
secondArgument.type !== 'SpreadElement' &&
hasNodes(secondArgument as never)
) {
context.report({ node, messageId: 'unsealedWrite' });
return;
}
const callPrefix = context.sourceCode.getText(node).split('(', 1)[0];
const hasWorkflowTypeArgument = /<[^>]*\bWorkflowEntity\b[^>]*>/.test(callPrefix);
const explicitWorkflowTarget = targetsWorkflowEntity(node.arguments[0] as never);
const updatePayload = explicitWorkflowTarget ? node.arguments[2] : node.arguments[1];
if (
method === 'update' &&
(explicitWorkflowTarget || hasWorkflowTypeArgument) &&
updatePayload !== undefined &&
updatePayload.type !== 'SpreadElement' &&
hasNodes(updatePayload as never)
) {
context.report({ node, messageId: 'unsealedWrite' });
return;
}
const queryBuilderText = callee.object
? context.sourceCode.getText(callee.object as never)
: '';
if (
method === 'set' &&
node.arguments[0]?.type !== 'SpreadElement' &&
hasNodes(node.arguments[0] as never) &&
(isWorkflowQueryBuilder(callee.object) ||
/update\s*<[^>]*\bWorkflowEntity\b[^>]*>/.test(queryBuilderText))
) {
context.report({ node, messageId: 'unsealedWrite' });
return;
}
if (
['save', 'insert', 'upsert'].includes(method) &&
node.arguments[0]?.type !== 'SpreadElement' &&
targetsWorkflowEntity(node.arguments[0] as never)
) {
context.report({ node, messageId: 'unsealedWrite' });
return;
}
if (
method === 'into' &&
node.arguments[0]?.type !== 'SpreadElement' &&
targetsWorkflowEntity(node.arguments[0] as never)
) {
context.report({ node, messageId: 'unsealedWrite' });
return;
}
for (const argument of node.arguments) {
if (
argument.type === 'Literal' &&
typeof argument.value === 'string' &&
RAW_WORKFLOW_WRITE.test(argument.value)
) {
context.report({ node: argument, messageId: 'unsealedWrite' });
}
if (
argument.type === 'TemplateLiteral' &&
RAW_WORKFLOW_WRITE.test(
argument.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw).join(''),
)
) {
context.report({ node: argument, messageId: 'unsealedWrite' });
}
}
},
};
},
export const NoUnsealedWorkflowEntityWriteRule = createUnsealedEntityWriteRule({
entityName: 'WorkflowEntity',
tableName: 'workflow_entity',
repositoryName: 'WorkflowRepository',
repositoryReceiver: /^(?:workflow|workflows)Repo(?:sitory)?$/i,
repositoryFile: /[\\/]repositories[\\/]workflow\.repository\.ts$/,
policedKey: 'nodes',
description: 'Route workflow content writes through policy-cleared repository methods.',
message: 'Route WorkflowEntity content writes through a policy-cleared repository method.',
});

View File

@ -0,0 +1,252 @@
import { ESLintUtils } from '@typescript-eslint/utils';
const NON_RUNTIME_FILE =
/(\.(test|spec)\.ts$)|([\\/]__tests__[\\/])|([\\/]test[\\/])|([\\/]backend-test-utils[\\/])/;
// A database migration owns its table, so it writes entities directly. Only those
// directories are exempt: `cli/src/modules/breaking-changes/migrations` holds node
// migrations, which are runtime code that rewrites workflow content.
const DATABASE_MIGRATION_FILE = /[\\/](?:db[\\/]src|database)[\\/]migrations[\\/]/;
const WRITE_METHODS = new Set(['save', 'insert', 'upsert']);
export type SealedEntityWriteConfig = {
/** The entity class the seal governs, e.g. `WorkflowEntity`. */
entityName: string;
/** The table behind it, to catch a write built as raw SQL. */
tableName: string;
/** The repository that owns the token-gated write methods, e.g. `WorkflowRepository`. */
repositoryName: string;
/** The repository under any injected name. Anchor it so siblings stay out. */
repositoryReceiver: RegExp;
/** The one file allowed to write the table directly. */
repositoryFile: RegExp;
/** The one payload key the policy check reads, e.g. `nodes`. */
policedKey: string;
/** `meta.docs.description`. */
description: string;
/** The report message. */
message: string;
};
type SyntaxNode = {
type: string;
name?: string;
value?: unknown;
computed?: boolean;
property?: SyntaxNode;
object?: SyntaxNode;
expression?: SyntaxNode;
callee?: SyntaxNode;
arguments?: SyntaxNode[];
properties?: Array<{
type: string;
computed?: boolean;
key?: SyntaxNode;
}>;
};
const unwrapChain = (node: SyntaxNode): SyntaxNode =>
node.type === 'ChainExpression' && node.expression ? node.expression : node;
const memberName = (node: SyntaxNode) => {
if (!node.property) return undefined;
if (!node.computed && node.property.type === 'Identifier') return node.property.name;
if (
node.computed &&
node.property.type === 'Literal' &&
typeof node.property.value === 'string'
) {
return node.property.value;
}
return undefined;
};
/**
* Builds a rule that routes one entity's content writes through its policy-cleared
* repository methods. Purely syntactic, so Oxlint can execute it as a jsPlugin rule:
* a hoisted payload, an aliased receiver and a target built at runtime all stay out of
* reach. The `assertClearedFor` gate in the repository is the enforcing half.
*/
export const createUnsealedEntityWriteRule = (config: SealedEntityWriteConfig) => {
const {
entityName,
tableName,
repositoryName,
repositoryReceiver,
repositoryFile,
policedKey,
description,
message,
} = config;
const rawWrite = new RegExp(
`\\b(?:UPDATE|INSERT\\s+INTO)\\s+(?:(?:"?\\w+"?)\\s*\\.\\s*)?["'\`]?\\w*${tableName}["'\`]?(?=\\s|\\(|$)`,
'i',
);
const targetsEntity = (node: SyntaxNode | undefined) =>
node !== undefined &&
((node.type === 'Identifier' && node.name === entityName) ||
(node.type === 'Literal' && (node.value === entityName || node.value === tableName)));
const targetsRepositoryClass = (node: SyntaxNode | undefined) =>
node?.type === 'Identifier' && node.name === repositoryName;
const isRepository = (node: SyntaxNode): boolean => {
const target = unwrapChain(node);
if (target.type === 'Identifier') return repositoryReceiver.test(target.name ?? '');
if (target.type === 'MemberExpression') {
return repositoryReceiver.test(memberName(target) ?? '');
}
if (target.type !== 'CallExpression' || !target.callee) return false;
const callee = unwrapChain(target.callee);
return (
callee.type === 'MemberExpression' &&
((memberName(callee) === 'get' && targetsRepositoryClass(target.arguments?.[0])) ||
(memberName(callee) === 'getRepository' && targetsEntity(target.arguments?.[0])))
);
};
const hasPolicedKey = (node: SyntaxNode) =>
node.type === 'ObjectExpression' &&
(node.properties ?? []).some((property) => {
if (property.type === 'SpreadElement' || !property.key) return false;
if (!property.computed && property.key.type === 'Identifier') {
return property.key.name === policedKey;
}
return property.key.type === 'Literal' && property.key.value === policedKey;
});
const isEntityQueryBuilder = (node: SyntaxNode): boolean => {
const call = unwrapChain(node);
if (call.type !== 'CallExpression' || !call.callee) return false;
const callee = unwrapChain(call.callee);
if (callee.type !== 'MemberExpression' || !callee.object) return false;
const method = memberName(callee);
if (method === 'createQueryBuilder') return isRepository(callee.object);
if (method !== 'update') return false;
return (
targetsEntity(call.arguments?.[0]) ||
(call.arguments?.length === 0 && isEntityQueryBuilder(callee.object))
);
};
const entityTypeArgument = new RegExp(`<[^>]*\\b${entityName}\\b[^>]*>`);
const updateTypeArgument = new RegExp(`update\\s*<[^>]*\\b${entityName}\\b[^>]*>`);
return ESLintUtils.RuleCreator.withoutDocs({
meta: {
type: 'problem',
docs: { description },
messages: { unsealedWrite: message },
schema: [],
},
defaultOptions: [],
create(context) {
if (
NON_RUNTIME_FILE.test(context.filename) ||
DATABASE_MIGRATION_FILE.test(context.filename) ||
repositoryFile.test(context.filename)
) {
return {};
}
return {
CallExpression(node) {
const callee = unwrapChain(node.callee as never);
if (callee.type !== 'MemberExpression' || !callee.object) return;
const method = memberName(callee);
if (!method) return;
if (WRITE_METHODS.has(method) && isRepository(callee.object)) {
context.report({ node, messageId: 'unsealedWrite' });
return;
}
const secondArgument = node.arguments[1];
if (
method === 'update' &&
isRepository(callee.object) &&
secondArgument !== undefined &&
secondArgument.type !== 'SpreadElement' &&
hasPolicedKey(secondArgument as never)
) {
context.report({ node, messageId: 'unsealedWrite' });
return;
}
// Only the text between the callee and its arguments is the type argument list.
// Cutting the whole call at the first `(` stops inside a receiver such as
// `container.get(dataSource)`, which hides the type argument.
const callText = context.sourceCode.getText(node);
const calleeText = context.sourceCode.getText(callee as never);
const afterCallee = callText.startsWith(calleeText)
? callText.slice(calleeText.length)
: callText;
const hasEntityTypeArgument = entityTypeArgument.test(afterCallee.split('(', 1)[0]);
const explicitEntityTarget = targetsEntity(node.arguments[0] as never);
const updatePayload = explicitEntityTarget ? node.arguments[2] : node.arguments[1];
if (
method === 'update' &&
(explicitEntityTarget || hasEntityTypeArgument) &&
updatePayload !== undefined &&
updatePayload.type !== 'SpreadElement' &&
hasPolicedKey(updatePayload as never)
) {
context.report({ node, messageId: 'unsealedWrite' });
return;
}
const queryBuilderText = callee.object
? context.sourceCode.getText(callee.object as never)
: '';
if (
method === 'set' &&
node.arguments[0]?.type !== 'SpreadElement' &&
hasPolicedKey(node.arguments[0] as never) &&
(isEntityQueryBuilder(callee.object) || updateTypeArgument.test(queryBuilderText))
) {
context.report({ node, messageId: 'unsealedWrite' });
return;
}
if (
WRITE_METHODS.has(method) &&
node.arguments[0]?.type !== 'SpreadElement' &&
targetsEntity(node.arguments[0] as never)
) {
context.report({ node, messageId: 'unsealedWrite' });
return;
}
if (
method === 'into' &&
node.arguments[0]?.type !== 'SpreadElement' &&
targetsEntity(node.arguments[0] as never)
) {
context.report({ node, messageId: 'unsealedWrite' });
return;
}
for (const argument of node.arguments) {
if (
argument.type === 'Literal' &&
typeof argument.value === 'string' &&
rawWrite.test(argument.value)
) {
context.report({ node: argument, messageId: 'unsealedWrite' });
}
if (
argument.type === 'TemplateLiteral' &&
rawWrite.test(
argument.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw).join(''),
)
) {
context.report({ node: argument, messageId: 'unsealedWrite' });
}
}
},
};
},
});
};

View File

@ -354,10 +354,11 @@ export async function toolsAgentExecute(
if (Object.keys(additionalMetadata).length > 0) {
this.logger.debug('Tracing metadata', { additionalMetadata });
}
const tracingConfig = isExecuteFunctions(this)
? getTracingConfig(this, { additionalMetadata })
: undefined;
const executorWithTracing = tracingConfig ? executor.withConfig(tracingConfig) : executor;
// `getTracingConfig` supports both context types, so sub-agents keep their
// LangSmith run name and execution id.
const executorWithTracing = executor.withConfig(
getTracingConfig(this, { additionalMetadata }),
);
// Invoke with fallback logic
const invokeParams = {
input,

View File

@ -19,7 +19,6 @@ import type {
} from 'n8n-workflow';
import type { ItemContext } from './prepareItemContext';
import { isExecuteFunctions } from '../../../utils';
import { SYSTEM_MESSAGE } from '../../prompt';
import type { AgentResult } from '../types';
@ -94,10 +93,9 @@ export async function runAgent(
if (Object.keys(additionalMetadata).length > 0 && logger) {
ctx.logger.debug('Tracing metadata', { additionalMetadata });
}
const tracingConfig = isExecuteFunctions(ctx)
? getTracingConfig(ctx, { additionalMetadata })
: undefined;
const executorWithTracing = tracingConfig ? executor.withConfig(tracingConfig) : executor;
// `getTracingConfig` supports both context types, so sub-agents keep their LangSmith
// run name and execution id.
const executorWithTracing = executor.withConfig(getTracingConfig(ctx, { additionalMetadata }));
// Check if streaming is actually available
const isStreamingAvailable = 'isStreaming' in ctx ? ctx.isStreaming?.() : undefined;

View File

@ -39,7 +39,6 @@ beforeEach(() => {
vi.clearAllMocks();
mockContext.getNode.mockReturnValue(mockNode);
mockNode.typeVersion = 3;
mockContext.getExecuteData = vi.fn() as any;
(tracing.getTracingConfig as Mock).mockReturnValue({
runName: '[Test Workflow] Test Node',
metadata: { execution_id: 'test-123', workflow: {}, node: 'Test Node' },
@ -416,8 +415,6 @@ describe('runAgent - tracing configuration', () => {
isStreaming: vi.fn().mockReturnValue(true),
getExecutionCancelSignal: vi.fn().mockReturnValue(new AbortController().signal),
});
streamingContext.getExecuteData = vi.fn() as any;
vi.spyOn(agentExecution, 'loadMemory').mockResolvedValue([]);
vi.spyOn(agentExecution, 'processEventStream').mockResolvedValue({
output: 'Streamed answer',
@ -568,8 +565,6 @@ describe('runAgent - intermediate steps', () => {
isStreaming: vi.fn().mockReturnValue(true),
getExecutionCancelSignal: vi.fn().mockReturnValue(new AbortController().signal),
});
streamingContext.getExecuteData = vi.fn() as any;
vi.spyOn(agentExecution, 'loadMemory').mockResolvedValue([]);
vi.spyOn(agentExecution, 'saveToMemory').mockResolvedValue();
vi.spyOn(agentExecution, 'processEventStream').mockResolvedValue({

View File

@ -9,11 +9,16 @@ import {
import type { ZodObjectAny } from '../../../../types/types';
/** `IExecuteFunctions` vs `ISupplyDataFunctions` (e.g. agent as a tool); only the former has `getExecuteData`. */
/**
* `IExecuteFunctions` vs `ISupplyDataFunctions` (e.g. agent as a tool). Discriminate on
* `cloneWith`, which only the sub-node context adds: `SupplyDataContext` extends the same
* base class as `ExecuteContext`, so every member of that base (`getExecuteData`, …) is on
* both and cannot tell them apart.
*/
export function isExecuteFunctions(
context: IExecuteFunctions | ISupplyDataFunctions,
): context is IExecuteFunctions {
return 'getExecuteData' in context;
return !('cloneWith' in context);
}
export async function extractParsedOutput(

View File

@ -45,7 +45,6 @@ describe('toolsAgentExecute', () => {
};
mockContext.getWorkflow.mockReturnValue({ name: 'Test Workflow' } as any);
mockContext.getExecutionId.mockReturnValue('exec-123');
mockContext.getExecuteData.mockReturnValue({} as any);
});
it('should process items sequentially when batchSize is not set', async () => {
@ -1185,6 +1184,13 @@ describe('toolsAgentExecute', () => {
// @ts-expect-error isStreaming is not supported by SupplyDataFunctions, but mock object still resolves it
mockSupplyDataContext.isStreaming = undefined;
// Sub-node contexts carry `cloneWith`; `isExecuteFunctions` discriminates on it.
Object.assign(mockSupplyDataContext, { cloneWith: vi.fn() });
// Sub-agents are traced too, so the context must answer the same calls as a top-level one.
mockSupplyDataContext.getWorkflow.mockReturnValue({ name: 'Test Workflow' } as any);
mockSupplyDataContext.getExecutionId.mockReturnValue('exec-123');
mockSupplyDataContext.logger = {
debug: vi.fn(),
info: vi.fn(),

View File

@ -58,29 +58,20 @@ function withCommonFields(ctx: MockProxy<IExecuteFunctions>, node: INode): void
}
/**
* Sub-agent execution context — `isExecuteFunctions(this)` returns false.
*
* Intentionally does NOT set `getExecuteData`, so the V3 executor takes the
* inline-resolution branch (delegates to `resolveSubAgentRequest`).
* The shared top-level `mockContext` defined above gets `getExecuteData`
* stamped in `beforeEach`; use this helper to deliberately opt out.
* Sub-agent execution context, shaped like a real `SupplyDataContext`: it inherits
* `getExecuteData` from `BaseExecuteContext` and adds `cloneWith`, which is what
* `isExecuteFunctions` discriminates on.
*/
function mockSubAgentContext(): MockProxy<IExecuteFunctions> {
const ctx = mock<IExecuteFunctions>();
withCommonFields(ctx, mockNode);
Object.assign(ctx, { getExecuteData: vi.fn(), cloneWith: vi.fn() });
return ctx;
}
beforeEach(() => {
vi.clearAllMocks();
withCommonFields(mockContext, mockNode);
// `isExecuteFunctions` checks `'getExecuteData' in context`. vitest-mock-extended
// proxies property access but does not register own properties, so the `in`
// check is false by default. Stamp `getExecuteData` here so the shared
// `mockContext` exercises the top-level (engine-routed) request path;
// sub-agent inline resolution is covered by `mockSubAgentContext()` below
// and by `resolveSubAgentRequest.test.ts`.
mockContext.getExecuteData = vi.fn() as never;
});
describe('toolsAgentExecute V3 - Execute Function Logic', () => {
@ -226,7 +217,10 @@ describe('toolsAgentExecute V3 - Execute Function Logic', () => {
]);
});
it('should delegate to resolveSubAgentRequest when running as a sub-agent (no getExecuteData)', async () => {
it('should delegate to resolveSubAgentRequest when running as a sub-agent', async () => {
// Regression (AI-2788): a nested AgentToolV3 runs on a real `SupplyDataContext`,
// which inherits `getExecuteData`. The sub-agent must still resolve its own
// EngineRequest instead of handing it to the engine.
const subAgentContext = mockSubAgentContext();
const mockExecutionContext = {
@ -272,6 +266,8 @@ describe('toolsAgentExecute V3 - Execute Function Logic', () => {
expect.objectContaining({ runAgentBatch: expect.any(Function) }),
);
expect(result).toBe(resolvedOutput);
// Tracing metadata belongs to the top-level node, not to a sub-agent run.
expect(subAgentContext.setMetadata).not.toHaveBeenCalled();
});
it('should return request when batch returns tool call request', async () => {
@ -568,10 +564,6 @@ describe('toolsAgentExecute V3 - Execute Function Logic', () => {
if (param === 'options.autoSaveHighlightedData') return false;
return defaultValue;
});
// V3 only emits tracing metadata when invoked from an IExecuteFunctions-shaped
// context (detected by the presence of `getExecuteData`).
mockContext.getExecuteData.mockReturnValue({} as any);
vi.spyOn(helpers, 'buildExecutionContext').mockResolvedValue(mockExecutionContext);
vi.spyOn(helpers, 'executeBatch').mockResolvedValue(mockBatchResult);

View File

@ -1,11 +1,12 @@
import type { Tool } from '@langchain/classic/tools';
import { DynamicStructuredTool } from '@langchain/classic/tools';
import { ExecuteContext, SupplyDataContext } from 'n8n-core';
import { NodeOperationError } from 'n8n-workflow';
import type { INode } from 'n8n-workflow';
import type { IExecuteFunctions, INode, ISupplyDataFunctions } from 'n8n-workflow';
import { z } from 'zod';
import type { ZodObjectAny } from '../../../../types/types';
import { checkForStructuredTools } from '../agents/utils';
import { checkForStructuredTools, isExecuteFunctions } from '../agents/utils';
import { getInputs } from '../utils';
describe('checkForStructuredTools', () => {
@ -275,3 +276,22 @@ describe('getInputs', () => {
]);
});
});
describe('isExecuteFunctions', () => {
// Asserted against the real context classes, not stand-ins: the predicate is duck-typed,
// so it silently inverts if `cloneWith` is ever hoisted onto a shared base class. These
// two cases fail if that happens. Constructing either class needs the whole execution
// engine, so only the prototype chain (all `in` looks at) is built.
it('should return false for a sub-node context', () => {
const context = Object.create(SupplyDataContext.prototype) as ISupplyDataFunctions;
expect(isExecuteFunctions(context)).toBe(false);
});
it('should return true for a top-level execute context', () => {
const context = Object.create(ExecuteContext.prototype) as IExecuteFunctions;
expect(isExecuteFunctions(context)).toBe(true);
});
});

View File

@ -460,6 +460,18 @@ describe('DatabricksVectorStore', () => {
).rejects.toThrow('Failed primary keys: row-7');
});
it('rejects the primary key as the content column without a request', async () => {
const store = await createStore(directDescribe, { contentColumn: 'id' });
await expect(store.addDocuments([{ pageContent: 'hello', metadata: {} }])).rejects.toThrow(
'primary key',
);
expect(fetchMock).not.toHaveBeenCalledWith(
expect.stringContaining('upsert-data'),
expect.anything(),
);
});
it('rejects a managed Delta Sync index without a request', async () => {
const store = await managedStore();

View File

@ -311,6 +311,12 @@ export class DatabricksVectorStore extends VectorStore {
): Promise<string[]> {
const vectorColumn = this.assertDirectAccess();
const { primaryKey, name } = this.index;
// The row below would write the document text over its own primary key
if (this.contentColumn === primaryKey) {
throw new UserError(
`Column ${primaryKey} is the primary key of ${name}. Select another content column`,
);
}
const schemaColumns = new Set(this.index.schemaColumns ?? []);
const ids = documents.map((doc, i) => options?.ids?.[i] ?? doc.id ?? randomUUID());

View File

@ -0,0 +1,509 @@
/* eslint-disable n8n-nodes-base/node-param-display-name-miscased */
/* eslint-disable n8n-nodes-base/node-param-display-name-miscased-id */
import type { Embeddings } from '@langchain/core/embeddings';
import { proxyFetch } from '@n8n/ai-utilities';
import { DATABRICKS_PARTNER_USER_AGENT } from 'n8n-nodes-base/dist/nodes/Databricks/constants';
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
import type {
IDataObject,
IExecuteFunctions,
ILoadOptionsFunctions,
INode,
INodeProperties,
INodeType,
ISupplyDataFunctions,
} from 'n8n-workflow';
import type { Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { getDatabricksTokenProvider } from '@utils/databricks/token-provider';
import { DatabricksVectorStore } from './DatabricksVectorStore';
import { VectorStoreDatabricks } from './VectorStoreDatabricks.node';
vi.mock('./DatabricksVectorStore', () => ({
DatabricksVectorStore: { fromExistingIndex: vi.fn(), describeIndex: vi.fn() },
}));
vi.mock('@utils/databricks/token-provider');
vi.mock('@n8n/ai-utilities', async (importActual) => ({
...(await importActual()),
proxyFetch: vi.fn(),
}));
const mockedFromExistingIndex = vi.mocked(DatabricksVectorStore.fromExistingIndex);
const mockedDescribeIndex = vi.mocked(DatabricksVectorStore.describeIndex);
const mockCredential = {
host: 'https://ws.example.com/',
grantType: 'clientCredentials',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
allowedHttpRequestDomains: 'all',
allowedDomains: '',
};
const nodeDef: INode = {
id: '1',
name: 'Databricks Vector Store',
typeVersion: 1.3,
type: '@n8n/n8n-nodes-langchain.vectorStoreDatabricks',
position: [0, 0],
parameters: {},
};
const baseParams = {
databricksIndex: { mode: 'list', value: 'cat.sch.idx' },
contentColumn: 'text',
options: {},
};
const indexInfo = {
name: 'cat.sch.idx',
primaryKey: 'id',
indexType: 'DELTA_SYNC' as const,
embedding: { kind: 'managed' as const, sourceColumn: 'text' },
schemaColumns: ['id', 'text', 'source'],
};
describe('VectorStoreDatabricks', () => {
let node: VectorStoreDatabricks;
const methods = new VectorStoreDatabricks().methods as Required<
NonNullable<INodeType['methods']>
>;
let embeddings: ReturnType<typeof mock<Embeddings>>;
const setupContext = <T extends IExecuteFunctions | ISupplyDataFunctions>(
params: IDataObject,
credentialOverrides: Partial<typeof mockCredential> = {},
) => {
const ctx = createMockExecuteFunction<T>(params, nodeDef) as Mocked<T>;
ctx.getCredentials = vi.fn().mockResolvedValue({ ...mockCredential, ...credentialOverrides });
ctx.getInputData = vi.fn().mockReturnValue([{ json: {} }]);
ctx.getInputConnectionData = vi.fn().mockResolvedValue(embeddings);
ctx.logAiEvent = vi.fn();
return ctx;
};
beforeEach(() => {
vi.resetAllMocks();
node = new VectorStoreDatabricks();
embeddings = mock<Embeddings>();
mockedDescribeIndex.mockResolvedValue(indexInfo);
vi.mocked(getDatabricksTokenProvider).mockReturnValue({
getToken: vi.fn(async () => 'test-token'),
expiredStatus: 403,
});
});
describe('description', () => {
it('is a hidden vector store node with the four default modes', () => {
expect(node.description).toMatchObject({
name: 'vectorStoreDatabricks',
hidden: true,
credentials: [{ name: 'databricksOAuth2Api', required: true }],
});
const mode = node.description.properties.find((p) => p.name === 'mode');
expect(mode?.options?.map((option) => 'value' in option && option.value)).toEqual([
'load',
'insert',
'retrieve',
'retrieve-as-tool',
]);
});
it('declares the shared fields', () => {
const { properties } = node.description;
const index = properties.find((p) => p.name === 'databricksIndex');
expect(index?.type).toBe('resourceLocator');
expect(index?.modes?.[0].typeOptions?.searchListMethod).toBe('searchIndexes');
const contentColumn = properties.find((p) => p.name === 'contentColumn');
expect(contentColumn?.typeOptions).toEqual({
loadOptionsMethod: 'getIndexColumns',
loadOptionsDependsOn: ['databricksIndex.value'],
});
const options = properties.find((p) => p.name === 'options');
const metadataColumns = (options?.options as INodeProperties[]).find(
(o) => o.name === 'metadataColumns',
);
expect(metadataColumns?.type).toBe('multiOptions');
expect(metadataColumns?.typeOptions?.loadOptionsMethod).toBe('getIndexColumns');
expect(options?.displayOptions?.show?.mode).toEqual(['load', 'retrieve', 'retrieve-as-tool']);
const searchMode = (options?.options as INodeProperties[]).find(
(o) => o.name === 'searchMode',
);
expect(searchMode?.type).toBe('options');
expect(searchMode?.default).toBe('ANN');
expect(searchMode?.options?.map((o) => 'value' in o && o.value)).toEqual(['ANN', 'HYBRID']);
const searchFilterJson = (options?.options as INodeProperties[]).find(
(o) => o.name === 'searchFilterJson',
);
expect(searchFilterJson?.type).toBe('json');
});
});
describe('supplyData in retrieve mode', () => {
it('creates the store from the credential host and the shared fields', async () => {
const store = {};
mockedFromExistingIndex.mockResolvedValue(store as DatabricksVectorStore);
mockedDescribeIndex.mockResolvedValue(indexInfo);
const ctx = setupContext<ISupplyDataFunctions>({
...baseParams,
mode: 'retrieve',
options: {
metadataColumns: ['source'],
metadata: { metadataValues: [{ name: 'source', value: 'hr' }] },
},
});
const result = await node.supplyData.call(ctx, 0);
expect(mockedFromExistingIndex).toHaveBeenCalledWith(embeddings, {
fetch: expect.any(Function),
host: 'https://ws.example.com',
indexName: 'cat.sch.idx',
contentColumn: 'text',
metadataColumns: ['source'],
queryType: 'ANN',
filter: { source: 'hr' },
index: indexInfo,
});
expect(mockedDescribeIndex).toHaveBeenCalledWith(
expect.any(Function),
'https://ws.example.com',
'cat.sch.idx',
);
expect(result.response).toBeDefined();
});
it('describes the index again after a failed describe in the same run', async () => {
mockedFromExistingIndex.mockResolvedValue({} as DatabricksVectorStore);
mockedDescribeIndex
.mockRejectedValueOnce(new Error('503 Service Unavailable'))
.mockResolvedValueOnce(indexInfo);
const ctx = setupContext<ISupplyDataFunctions>({ ...baseParams, mode: 'retrieve' });
await expect(node.supplyData.call(ctx, 0)).rejects.toThrow('503');
await node.supplyData.call(ctx, 0);
expect(mockedDescribeIndex).toHaveBeenCalledTimes(2);
expect(mockedFromExistingIndex).toHaveBeenCalledTimes(1);
expect(mockedFromExistingIndex).toHaveBeenCalledWith(
embeddings,
expect.objectContaining({ index: indexInfo }),
);
});
it('opens the run again after a failed credential lookup in the same run', async () => {
mockedFromExistingIndex.mockResolvedValue({} as DatabricksVectorStore);
mockedDescribeIndex.mockResolvedValue(indexInfo);
const ctx = setupContext<ISupplyDataFunctions>({ ...baseParams, mode: 'retrieve' });
ctx.getCredentials = vi
.fn()
.mockRejectedValueOnce(new Error('credential store unavailable'))
.mockResolvedValue(mockCredential);
await expect(node.supplyData.call(ctx, 0)).rejects.toThrow('credential store unavailable');
await node.supplyData.call(ctx, 0);
expect(ctx.getCredentials).toHaveBeenCalledTimes(2);
expect(mockedFromExistingIndex).toHaveBeenCalledTimes(1);
});
it('rejects an http host before describing the index', async () => {
const ctx = setupContext<ISupplyDataFunctions>(
{ ...baseParams, mode: 'retrieve' },
{ host: 'http://ws.example.com' },
);
await expect(node.supplyData.call(ctx, 0)).rejects.toThrow('must use https');
expect(mockedFromExistingIndex).not.toHaveBeenCalled();
});
it('sends the bearer, the partner User-Agent and a cancellable signal through the shared pool', async () => {
mockedFromExistingIndex.mockResolvedValue({} as DatabricksVectorStore);
vi.mocked(proxyFetch).mockResolvedValue(new Response('{}'));
const cancel = new AbortController();
const ctx = setupContext<ISupplyDataFunctions>({ ...baseParams, mode: 'retrieve' });
ctx.getExecutionCancelSignal = vi.fn().mockReturnValue(cancel.signal);
await node.supplyData.call(ctx, 0);
const { fetch } = mockedFromExistingIndex.mock.calls[0][1];
await fetch('https://ws.example.com/x');
const [{ input, init, timeoutOptions }] = vi.mocked(proxyFetch).mock.calls[0];
expect(String(input)).toBe('https://ws.example.com/x');
const headers = new Headers(init?.headers);
expect(headers.get('authorization')).toBe('Bearer test-token');
expect(headers.get('user-agent')).toBe(DATABRICKS_PARTNER_USER_AGENT);
// No timeoutOptions: they would force a fresh undici Agent for every request
expect(timeoutOptions).toBeUndefined();
expect(init?.signal).toBeInstanceOf(AbortSignal);
expect(init?.signal?.aborted).toBe(false);
cancel.abort();
expect(init?.signal?.aborted).toBe(true);
});
it('aborts on a signal the caller already set', async () => {
mockedFromExistingIndex.mockResolvedValue({} as DatabricksVectorStore);
vi.mocked(proxyFetch).mockResolvedValue(new Response('{}'));
const ctx = setupContext<ISupplyDataFunctions>({ ...baseParams, mode: 'retrieve' });
ctx.getExecutionCancelSignal = vi.fn().mockReturnValue(new AbortController().signal);
await node.supplyData.call(ctx, 0);
const { fetch } = mockedFromExistingIndex.mock.calls[0][1];
const caller = new AbortController();
await fetch('https://ws.example.com/x', { signal: caller.signal });
const signal = vi.mocked(proxyFetch).mock.calls[0][0].init?.signal;
expect(signal?.aborted).toBe(false);
caller.abort();
expect(signal?.aborted).toBe(true);
});
});
describe('execute in load mode', () => {
it('searches by text and never embeds the prompt', async () => {
const doc = { pageContent: 'hello', metadata: { source: 'hr' } };
const store = { similaritySearchWithScore: vi.fn().mockResolvedValue([[doc, 0.9]]) };
mockedFromExistingIndex.mockResolvedValue(store as unknown as DatabricksVectorStore);
const ctx = setupContext<IExecuteFunctions>({
...baseParams,
mode: 'load',
prompt: 'what is up',
topK: 2,
});
const result = await node.execute.call(ctx);
expect(store.similaritySearchWithScore).toHaveBeenCalledWith('what is up', 2, undefined);
expect(embeddings.embedQuery).not.toHaveBeenCalled();
expect(result).toEqual([[{ json: { document: doc, score: 0.9 }, pairedItem: { item: 0 } }]]);
});
it('mints the token and describes the index once for every item of the run', async () => {
const store = { similaritySearchWithScore: vi.fn().mockResolvedValue([]) };
mockedFromExistingIndex.mockResolvedValue(store as unknown as DatabricksVectorStore);
mockedDescribeIndex.mockResolvedValue(indexInfo);
const ctx = setupContext<IExecuteFunctions>({
...baseParams,
mode: 'load',
prompt: 'what is up',
topK: 2,
});
ctx.getInputData = vi.fn().mockReturnValue([{ json: {} }, { json: {} }]);
await node.execute.call(ctx);
expect(store.similaritySearchWithScore).toHaveBeenCalledTimes(2);
expect(mockedFromExistingIndex).toHaveBeenCalledTimes(2);
expect(mockedDescribeIndex).toHaveBeenCalledTimes(1);
expect(getDatabricksTokenProvider).toHaveBeenCalledTimes(1);
expect(ctx.getCredentials).toHaveBeenCalledTimes(1);
});
it('reaches the class with the configured search mode, metadata filter and limit', async () => {
const store = { similaritySearchWithScore: vi.fn().mockResolvedValue([]) };
mockedFromExistingIndex.mockResolvedValue(store as unknown as DatabricksVectorStore);
const ctx = setupContext<IExecuteFunctions>({
...baseParams,
mode: 'load',
prompt: 'what is up',
topK: 3,
options: { searchMode: 'HYBRID', searchFilterJson: { source: 'hr' } },
});
await node.execute.call(ctx);
expect(mockedFromExistingIndex).toHaveBeenCalledWith(
embeddings,
expect.objectContaining({ queryType: 'HYBRID' }),
);
expect(store.similaritySearchWithScore).toHaveBeenCalledWith('what is up', 3, {
source: 'hr',
});
});
});
describe('execute in insert mode', () => {
it('adds the loaded documents through the store', async () => {
const documents = [
{ pageContent: 'hello', metadata: { source: 'hr' } },
{ pageContent: 'world', metadata: {} },
];
const store = {
addDocuments: vi.fn(async (docs: unknown[]) => docs.map((_, i) => String(i))),
};
mockedFromExistingIndex.mockResolvedValue(store as unknown as DatabricksVectorStore);
const ctx = setupContext<IExecuteFunctions>({ ...baseParams, mode: 'insert' });
ctx.getInputConnectionData = vi
.fn()
.mockResolvedValueOnce(embeddings)
.mockResolvedValueOnce(documents);
ctx.getExecutionCancelSignal = vi.fn().mockReturnValue(undefined);
const result = await node.execute.call(ctx);
expect(mockedFromExistingIndex).toHaveBeenCalledWith(
embeddings,
expect.objectContaining({ indexName: 'cat.sch.idx', filter: undefined }),
);
expect(store.addDocuments).toHaveBeenCalledWith(documents);
expect(result).toEqual([documents.map((doc) => ({ json: doc, pairedItem: { item: 0 } }))]);
});
});
describe('searchIndexes', () => {
const pages: Record<string, unknown> = {
endpoints: { endpoints: [{ name: 'ep1' }, { name: 'ep2' }] },
'ep1:': { vector_indexes: [{ name: 'cat.sch.zeta', index_type: 'DIRECT_ACCESS' }] },
'ep2:': {
vector_indexes: [{ name: 'cat.sch.beta', index_type: 'DELTA_SYNC' }],
next_page_token: 'p2',
},
'ep2:p2': { vector_indexes: [{ name: 'cat.sch.alpha', index_type: 'DELTA_SYNC' }] },
};
let httpRequestWithAuthentication: ReturnType<typeof vi.fn>;
let ctx: ILoadOptionsFunctions;
const setupSearchContext = (host: string) => {
httpRequestWithAuthentication = vi.fn(
async (_type: string, options: { url: string; qs: Record<string, string> }) =>
options.url.endsWith('/endpoints')
? pages.endpoints
: pages[`${options.qs.endpoint_name}:${options.qs.page_token ?? ''}`],
);
ctx = {
getCredentials: vi.fn().mockResolvedValue({ ...mockCredential, host }),
getNode: vi.fn().mockReturnValue(nodeDef),
helpers: { httpRequestWithAuthentication },
} as unknown as ILoadOptionsFunctions;
};
it('lists every index of every endpoint, sorted, with the type and endpoint', async () => {
setupSearchContext('https://ws.example.com/');
const result = await methods.listSearch.searchIndexes.call(ctx);
expect(result.results).toEqual([
{ name: 'cat.sch.alpha', value: 'cat.sch.alpha', description: 'DELTA_SYNC - ep2' },
{ name: 'cat.sch.beta', value: 'cat.sch.beta', description: 'DELTA_SYNC - ep2' },
{ name: 'cat.sch.zeta', value: 'cat.sch.zeta', description: 'DIRECT_ACCESS - ep1' },
]);
expect(httpRequestWithAuthentication).toHaveBeenCalledTimes(4);
for (const [type, options] of httpRequestWithAuthentication.mock.calls) {
expect(type).toBe('databricksOAuth2Api');
expect(options.url).toMatch(/^https:\/\/ws\.example\.com\/api\/2\.0\/vector-search\//);
expect(options.headers).toMatchObject({ 'User-Agent': DATABRICKS_PARTNER_USER_AGENT });
}
});
it('does not let the bearer follow a cross-origin redirect', async () => {
setupSearchContext('https://ws.example.com');
await methods.listSearch.searchIndexes.call(ctx);
for (const [, options] of httpRequestWithAuthentication.mock.calls) {
expect(options.sendCredentialsOnCrossOriginRedirect).toBe(false);
}
});
it('applies the substring filter to the name', async () => {
setupSearchContext('https://ws.example.com');
const result = await methods.listSearch.searchIndexes.call(ctx, 'ZETA');
expect(result.results.map((r) => r.value)).toEqual(['cat.sch.zeta']);
});
it('stops after 50 pages when a page echoes its own token', async () => {
setupSearchContext('https://ws.example.com');
httpRequestWithAuthentication.mockResolvedValue({ endpoints: [], next_page_token: 'loop' });
await expect(methods.listSearch.searchIndexes.call(ctx)).rejects.toThrow('50 pages');
expect(httpRequestWithAuthentication).toHaveBeenCalledTimes(50);
});
it('rejects an http host before any request', async () => {
setupSearchContext('http://ws.example.com');
await expect(methods.listSearch.searchIndexes.call(ctx)).rejects.toThrow('must use https');
expect(httpRequestWithAuthentication).not.toHaveBeenCalled();
});
});
describe('getIndexColumns', () => {
const setupLoadOptionsContext = (indexName: string) => {
const ctx = createMockExecuteFunction<ILoadOptionsFunctions>(
{},
nodeDef,
) as Mocked<ILoadOptionsFunctions>;
ctx.getCredentials = vi.fn().mockResolvedValue(mockCredential);
ctx.getCurrentNodeParameter = vi.fn().mockReturnValue(indexName);
return ctx;
};
it('lists the embedding source column first and hides the primary key', async () => {
mockedDescribeIndex.mockResolvedValue({
name: 'cat.sch.idx',
primaryKey: 'id',
indexType: 'DELTA_SYNC',
embedding: { kind: 'managed', sourceColumn: 'text' },
schemaColumns: ['id', 'text', 'source'],
});
const ctx = setupLoadOptionsContext('cat.sch.idx');
const result = await methods.loadOptions.getIndexColumns.call(ctx);
expect(mockedDescribeIndex).toHaveBeenCalledWith(
expect.any(Function),
'https://ws.example.com',
'cat.sch.idx',
);
expect(ctx.getCurrentNodeParameter).toHaveBeenCalledWith('databricksIndex', {
extractValue: true,
});
expect(result).toEqual([
{ name: 'text', value: 'text', description: 'Embedding source column' },
{ name: 'source', value: 'source' },
]);
});
it('hides the vector column of a Direct Access index', async () => {
mockedDescribeIndex.mockResolvedValue({
name: 'cat.sch.idx',
primaryKey: 'id',
indexType: 'DIRECT_ACCESS',
embedding: { kind: 'self', vectorColumn: 'embedding' },
schemaColumns: ['id', 'text', 'embedding'],
});
const ctx = setupLoadOptionsContext('cat.sch.idx');
await expect(methods.loadOptions.getIndexColumns.call(ctx)).resolves.toEqual([
{ name: 'text', value: 'text' },
]);
});
it('returns no columns when the index schema is unknown', async () => {
mockedDescribeIndex.mockResolvedValue({
name: 'cat.sch.idx',
primaryKey: 'id',
indexType: 'DELTA_SYNC',
embedding: { kind: 'self', vectorColumn: 'embedding' },
});
const ctx = setupLoadOptionsContext('cat.sch.idx');
await expect(methods.loadOptions.getIndexColumns.call(ctx)).resolves.toEqual([]);
});
it('returns no columns before an index is chosen', async () => {
const ctx = setupLoadOptionsContext('');
await expect(methods.loadOptions.getIndexColumns.call(ctx)).resolves.toEqual([]);
expect(mockedDescribeIndex).not.toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,322 @@
import type { Embeddings } from '@langchain/core/embeddings';
import { createVectorStoreNode, proxyFetch } from '@n8n/ai-utilities';
import {
assertParamIsArray,
assertParamIsString,
type IExecuteFunctions,
type ILoadOptionsFunctions,
type INodeListSearchResult,
type INodeProperties,
type INodePropertyOptions,
type ISupplyDataFunctions,
} from 'n8n-workflow';
import {
createDatabricksAuthFetch,
type DatabricksFetchContext,
} from '@utils/databricks/auth-fetch';
import { assertHttpsHost } from '@utils/databricks/constants';
import { listDatabricksPages } from '@utils/databricks/list-pages';
import {
DATABRICKS_CREDENTIAL_TYPE,
type DatabricksOAuth2Credential,
} from '@utils/databricks/token-provider';
import { DatabricksVectorStore, type DatabricksIndexInfo } from './DatabricksVectorStore';
const REQUEST_TIMEOUT_MS = 60_000;
const databricksIndexRLC: INodeProperties = {
displayName: 'Index',
name: 'databricksIndex',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
placeholder: 'Select an index...',
typeOptions: {
searchListMethod: 'searchIndexes',
searchable: true,
},
},
{
displayName: 'Name',
name: 'id',
type: 'string',
placeholder: 'catalog.schema.index',
validation: [
{
type: 'regex',
properties: {
regex: '^[^.\\s]+\\.[^.\\s]+\\.[^.\\s]+$',
errorMessage: 'Use the full name: catalog.schema.index',
},
},
],
},
],
};
const columnTypeOptions = {
loadOptionsMethod: 'getIndexColumns',
loadOptionsDependsOn: ['databricksIndex.value'],
};
const sharedFields: INodeProperties[] = [
databricksIndexRLC,
{
displayName: 'Content Column',
name: 'contentColumn',
type: 'options',
default: '',
typeOptions: columnTypeOptions,
description:
'Column that becomes the document text. Leave empty on a managed-embedding index to use its embedding source column. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
// Metadata Columns shapes the documents a read returns; the write path ignores it
displayOptions: { show: { mode: ['load', 'retrieve', 'retrieve-as-tool'] } },
options: [
{
displayName: 'Metadata Columns',
name: 'metadataColumns',
type: 'multiOptions',
default: [],
typeOptions: columnTypeOptions,
description:
'Columns to return in document metadata. Defaults to all columns except the key, content and vector columns. Choose from the list, or specify IDs using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
},
{
displayName: 'Search Mode',
name: 'searchMode',
type: 'options',
default: 'ANN',
options: [
{ name: 'Vector', value: 'ANN' },
{ name: 'Hybrid', value: 'HYBRID' },
],
description:
'Vector ranks purely by semantic similarity. Hybrid adds keyword matching on top, using the search text. Sent as query_type ANN or HYBRID.',
},
{
displayName: 'Metadata Filter',
name: 'searchFilterJson',
type: 'json',
typeOptions: { rows: 5 },
default: '{}',
validateType: 'object',
description:
'Restrict results to rows matching this metadata condition, for example {"source": "handbook"}. Sent as filters_json.',
},
],
},
];
interface Run {
fetch: typeof fetch;
host: string;
/** Describe results of this run, keyed by index name */
indexes: Map<string, Promise<DatabricksIndexInfo>>;
}
// The class owns its HTTP calls, so the runtime and the column dropdown share
// the token-refreshing fetch; the transport goes through the proxy-aware fetch
async function openRun(ctx: DatabricksFetchContext): Promise<Run> {
const credential = await ctx.getCredentials<DatabricksOAuth2Credential>(
DATABRICKS_CREDENTIAL_TYPE,
);
assertHttpsHost(ctx, credential.host);
const host = credential.host.replace(/\/$/, '');
const egressFilter = ctx.helpers.getSecureEgressFilter();
// ILoadOptionsFunctions has no cancel signal
const cancelSignal =
'getExecutionCancelSignal' in ctx ? ctx.getExecutionCancelSignal() : undefined;
const { fetch } = createDatabricksAuthFetch(ctx, credential, {
endpointUrl: host,
egressFilter,
baseFetch: async (input, init) => {
// A whole-request deadline on the signal keeps the shared undici pool; per-request
// timeoutOptions would build a new Agent for every hop. The responses are small JSON
// bodies, so the deadline covers headers and body alike
const signals = [AbortSignal.timeout(REQUEST_TIMEOUT_MS)];
if (init?.signal) signals.push(init.signal);
if (cancelSignal) signals.push(cancelSignal);
return await proxyFetch({
input,
init: { ...init, signal: AbortSignal.any(signals) },
egressFilter,
});
},
});
return { fetch, host, indexes: new Map() };
}
// One context object serves every item of an execute call and every tool call of a
// supplyData closure, so the token mint and the describe GETs happen once per run
const runs = new WeakMap<DatabricksFetchContext, Promise<Run>>();
async function databricksFetch(ctx: DatabricksFetchContext): Promise<Run> {
let run = runs.get(ctx);
if (!run) {
// Evict a rejected run so a failed credential lookup does not poison the rest of the run
run = openRun(ctx).catch((error) => {
runs.delete(ctx);
throw error;
});
runs.set(ctx, run);
}
return await run;
}
async function createStore(
ctx: IExecuteFunctions | ISupplyDataFunctions,
embeddings: Embeddings,
itemIndex: number,
filter?: Record<string, unknown>,
): Promise<DatabricksVectorStore> {
const { fetch, host, indexes } = await databricksFetch(ctx);
const node = ctx.getNode();
const indexName = ctx.getNodeParameter('databricksIndex', itemIndex, '', { extractValue: true });
assertParamIsString('databricksIndex', indexName, node);
const contentColumn = ctx.getNodeParameter('contentColumn', itemIndex, '');
assertParamIsString('contentColumn', contentColumn, node);
const metadataColumns = ctx.getNodeParameter('options.metadataColumns', itemIndex, []);
assertParamIsArray(
'metadataColumns',
metadataColumns,
(value): value is string => typeof value === 'string',
node,
);
const searchMode = ctx.getNodeParameter('options.searchMode', itemIndex, 'ANN');
assertParamIsString('options.searchMode', searchMode, node);
const queryType = searchMode === 'HYBRID' ? 'HYBRID' : 'ANN';
let index = indexes.get(indexName);
if (!index) {
// Evict a rejected describe so one transient failure does not poison the rest of the run
index = DatabricksVectorStore.describeIndex(fetch, host, indexName).catch((error) => {
indexes.delete(indexName);
throw error;
});
indexes.set(indexName, index);
}
return await DatabricksVectorStore.fromExistingIndex(embeddings, {
fetch,
host,
indexName,
contentColumn,
metadataColumns,
queryType,
filter,
index: await index,
});
}
interface ListPage {
endpoints?: Array<{ name: string }>;
vector_indexes?: Array<{ name: string; index_type?: string }>;
next_page_token?: string;
}
async function searchIndexes(
this: ILoadOptionsFunctions,
filter?: string,
): Promise<INodeListSearchResult> {
const credentials = await this.getCredentials<DatabricksOAuth2Credential>(
DATABRICKS_CREDENTIAL_TYPE,
);
assertHttpsHost(this, credentials.host);
const host = credentials.host.replace(/\/$/, '');
const endpoints = await listDatabricksPages(
this,
`${host}/api/2.0/vector-search/endpoints`,
{},
(page: ListPage) => page.endpoints,
);
const results = (
await Promise.all(
endpoints.map(async (endpoint) => {
const indexes = await listDatabricksPages(
this,
`${host}/api/2.0/vector-search/indexes`,
{ endpoint_name: endpoint.name },
(page: ListPage) => page.vector_indexes,
);
// The type tells the user which indexes accept inserts
return indexes.map((index) => ({
name: index.name,
value: index.name,
description: `${index.index_type} - ${endpoint.name}`,
}));
}),
)
)
.flat()
.sort((a, b) => a.name.localeCompare(b.name));
const filterLower = filter?.toLowerCase();
return {
results: filterLower
? results.filter((result) => result.name.toLowerCase().includes(filterLower))
: results,
};
}
async function getIndexColumns(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const indexName = this.getCurrentNodeParameter('databricksIndex', { extractValue: true });
if (typeof indexName !== 'string' || indexName === '') return [];
const { fetch, host } = await databricksFetch(this);
const info = await DatabricksVectorStore.describeIndex(fetch, host, indexName);
const { embedding } = info;
const source = embedding.kind === 'managed' ? embedding.sourceColumn : undefined;
const vector = embedding.kind === 'self' ? embedding.vectorColumn : undefined;
const columns = (info.schemaColumns ?? [])
.filter((column) => column !== info.primaryKey && column !== source && column !== vector)
.map((column) => ({ name: column, value: column }));
return source
? [{ name: source, value: source, description: 'Embedding source column' }, ...columns]
: columns;
}
export class VectorStoreDatabricks extends createVectorStoreNode<DatabricksVectorStore>({
meta: {
displayName: 'Databricks Vector Store',
name: 'vectorStoreDatabricks',
description: 'Work with your data in Databricks Vector Search',
icon: {
light: 'file:../../shared/icons/databricks.svg',
dark: 'file:../../shared/icons/databricks.dark.svg',
},
docsUrl:
'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.vectorstoredatabricks/',
credentials: [
{
name: 'databricksOAuth2Api',
required: true,
},
],
},
hidden: true,
// The index decides between query_text and query_vector inside the class
searchByText: true,
methods: { listSearch: { searchIndexes }, loadOptions: { getIndexColumns } },
sharedFields,
async getVectorStoreClient(context, filter, embeddings, itemIndex) {
return await createStore(context, embeddings, itemIndex, filter);
},
async populateVectorStore(context, embeddings, documents, itemIndex) {
const store = await createStore(context, embeddings, itemIndex);
await store.addDocuments(documents);
},
}) {}

View File

@ -183,6 +183,7 @@
"dist/nodes/trigger/ChatTrigger/ChatTrigger.node.js",
"dist/nodes/trigger/ChatTrigger/Chat.node.js",
"dist/nodes/vector_store/VectorStoreAzureAISearch/VectorStoreAzureAISearch.node.js",
"dist/nodes/vector_store/VectorStoreDatabricks/VectorStoreDatabricks.node.js",
"dist/nodes/vector_store/VectorStoreInMemory/VectorStoreInMemory.node.js",
"dist/nodes/vector_store/VectorStoreInMemoryInsert/VectorStoreInMemoryInsert.node.js",
"dist/nodes/vector_store/VectorStoreInMemoryLoad/VectorStoreInMemoryLoad.node.js",

View File

@ -0,0 +1,66 @@
import { createRefreshingAuthFetch } from '@n8n/ai-utilities';
import {
assertUrlAllowed,
getCredentialAllowedDomains,
type IExecuteFunctions,
type ILoadOptionsFunctions,
type ISupplyDataFunctions,
type NodeEgressFilter,
} from 'n8n-workflow';
import type { RefreshingTokenSource } from '../oauth2-token-provider';
import { databricksAuthHeaders } from './constants';
import type { DatabricksOAuth2Credential } from './token-provider';
import { getDatabricksTokenProvider } from './token-provider';
export type DatabricksFetchContext =
| IExecuteFunctions
| ISupplyDataFunctions
| ILoadOptionsFunctions;
/**
* The token-refreshing fetch every Databricks AI node sends its requests through.
* The caller owns the transport (`baseFetch`) and the egress filter; the token
* source comes back so the caller can read `expiredStatus`.
*/
export function createDatabricksAuthFetch(
ctx: DatabricksFetchContext,
credential: DatabricksOAuth2Credential,
options: { endpointUrl: string; egressFilter: NodeEgressFilter; baseFetch: typeof fetch },
): { fetch: typeof fetch; tokenSource: RefreshingTokenSource } {
const { endpointUrl, egressFilter } = options;
const node = ctx.getNode();
// endpointUrl derives from the credential's own host, so credentialOwnedSurface joins it to the allowlist
const allowedDomains = getCredentialAllowedDomains({
node,
credentialData: credential,
credentialOwnedSurface: true,
nodeEndpointUrl: endpointUrl,
});
const tokenSource = getDatabricksTokenProvider(ctx, credential, egressFilter);
const { refreshAfterRejection } = tokenSource;
// The caller builds its own transport, so it never reaches the
// request helpers: `resolveHeaders` runs the expiry clock before every
// request, and `refreshHeaders` covers the rejection the clock missed -
// revoked server-side, or clock skew
const authFetch = createRefreshingAuthFetch({
baseFetch: options.baseFetch,
expiredStatus: tokenSource.expiredStatus,
resolveHeaders: async () => databricksAuthHeaders(await tokenSource.getToken()),
...(refreshAfterRejection && {
refreshHeaders: async () => {
const refreshed = await refreshAfterRejection();
return refreshed ? databricksAuthHeaders(refreshed) : null;
},
}),
assertAllowedUrl: async (hopUrl) => {
assertUrlAllowed({ url: hopUrl, allowedDomains, node });
if (!egressFilter) return;
const result = await egressFilter.validateUrl(hopUrl);
if (!result.ok) throw result.error;
},
});
return { fetch: authFetch, tokenSource };
}

View File

@ -1,6 +1,5 @@
import { DATABRICKS_PARTNER_USER_AGENT } from 'n8n-nodes-base/dist/nodes/Databricks/constants';
import type { ILoadOptionsFunctions, ISupplyDataFunctions } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { NodeOperationError, type INode } from 'n8n-workflow';
/**
* The bearer plus the partner User-Agent, set on every request and every retry.
@ -16,10 +15,7 @@ export const databricksAuthHeaders = (token: string): HeadersInit => ({
* Every request carries a secret (bearer token, or the client secret on the
* mint path), so an http host would ship it in cleartext.
*/
export function assertHttpsHost(
ctx: ILoadOptionsFunctions | ISupplyDataFunctions,
host: string,
): void {
export function assertHttpsHost(ctx: { getNode(): INode }, host: string): void {
if (!URL.canParse(host) || new URL(host).protocol !== 'https:') {
throw new NodeOperationError(ctx.getNode(), 'Databricks host must use https');
}

View File

@ -1,15 +1,12 @@
import type { ClientOptions } from '@langchain/openai';
import { createRefreshingAuthFetch, getProxyAgent } from '@n8n/ai-utilities';
import {
assertUrlAllowed,
getCredentialAllowedDomains,
type ISupplyDataFunctions,
} from 'n8n-workflow';
import { getProxyAgent } from '@n8n/ai-utilities';
import type { ISupplyDataFunctions } from 'n8n-workflow';
import { assertHttpsHost, databricksAuthHeaders, gatewayBaseUrl } from './constants';
import { wrapDatabricksErrorFetch } from './error-handling';
import type { RefreshingTokenSource } from '../oauth2-token-provider';
import { getDatabricksTokenProvider, type DatabricksOAuth2Credential } from './token-provider';
import { createDatabricksAuthFetch } from './auth-fetch';
import { assertHttpsHost, gatewayBaseUrl } from './constants';
import { wrapDatabricksErrorFetch } from './error-handling';
import type { DatabricksOAuth2Credential } from './token-provider';
/**
* Builds the OpenAI-client transport for the Unity AI Gateway: the gateway
@ -28,44 +25,16 @@ export function createDatabricksGatewayConfig(
assertHttpsHost(ctx, credential.host);
const baseURL = gatewayBaseUrl(credential.host);
const node = ctx.getNode();
// baseURL derives from the credential's own host, so credentialOwnedSurface joins it to the allowlist
const allowedDomains = getCredentialAllowedDomains({
node,
credentialData: credential,
credentialOwnedSurface: true,
nodeEndpointUrl: baseURL,
});
const egressFilter = ctx.helpers.getSecureEgressFilter();
const tokenSource = getDatabricksTokenProvider(ctx, credential, egressFilter);
const { refreshAfterRejection } = tokenSource;
const { fetch: authFetch, tokenSource } = createDatabricksAuthFetch(ctx, credential, {
endpointUrl: baseURL,
egressFilter,
baseFetch: fetch,
});
const configuration: ClientOptions = {
baseURL,
// The model client builds its own transport, so it never reaches the
// request helpers: `resolveHeaders` runs the expiry clock before every
// request, and `refreshHeaders` covers the rejection the clock missed -
// revoked server-side, or clock skew
fetch: wrapDatabricksErrorFetch(
createRefreshingAuthFetch({
baseFetch: fetch,
expiredStatus: tokenSource.expiredStatus,
resolveHeaders: async () => databricksAuthHeaders(await tokenSource.getToken()),
...(refreshAfterRejection && {
refreshHeaders: async () => {
const refreshed = await refreshAfterRejection();
return refreshed ? databricksAuthHeaders(refreshed) : null;
},
}),
assertAllowedUrl: async (hopUrl) => {
assertUrlAllowed({ url: hopUrl, allowedDomains, node });
if (!egressFilter) return;
const result = await egressFilter.validateUrl(hopUrl);
if (!result.ok) throw result.error;
},
}),
),
fetch: wrapDatabricksErrorFetch(authFetch),
fetchOptions: {
dispatcher: getProxyAgent(
baseURL,

View File

@ -0,0 +1,48 @@
import { DATABRICKS_PARTNER_USER_AGENT } from 'n8n-nodes-base/dist/nodes/Databricks/constants';
import { NodeOperationError, type ILoadOptionsFunctions } from 'n8n-workflow';
import { DATABRICKS_CREDENTIAL_TYPE } from './token-provider';
const MAX_PAGES = 50;
interface Page {
next_page_token?: string;
}
/**
* Follows `next_page_token` through a Databricks list endpoint and concatenates
* what `pick` keeps from each page. Every picker pages through this one function
* so the request shape, and the redirect guard, cannot drift between them.
*/
export async function listDatabricksPages<P extends Page, T>(
ctx: ILoadOptionsFunctions,
url: string,
qs: Record<string, string | undefined>,
pick: (page: P) => T[] | undefined,
): Promise<T[]> {
let items: T[] = [];
let pageToken: string | undefined;
let pages = 0;
do {
// Guard against a host or proxy that echoes the same next_page_token back
if (++pages > MAX_PAGES) {
throw new NodeOperationError(ctx.getNode(), `Databricks list exceeded ${MAX_PAGES} pages`);
}
const page: P = await ctx.helpers.httpRequestWithAuthentication.call(
ctx,
DATABRICKS_CREDENTIAL_TYPE,
{
method: 'GET',
url,
qs: { ...qs, page_token: pageToken },
headers: { Accept: 'application/json', 'User-Agent': DATABRICKS_PARTNER_USER_AGENT },
json: true,
// The bearer must not follow a redirect off the workspace host
sendCredentialsOnCrossOriginRedirect: false,
},
);
items = items.concat(pick(page) ?? []);
pageToken = page.next_page_token;
} while (pageToken);
return items;
}

View File

@ -1,4 +1,3 @@
import { DATABRICKS_PARTNER_USER_AGENT } from 'n8n-nodes-base/dist/nodes/Databricks/constants';
import {
NodeApiError,
NodeOperationError,
@ -7,6 +6,7 @@ import {
} from 'n8n-workflow';
import { assertHttpsHost } from './constants';
import { listDatabricksPages } from './list-pages';
import { DATABRICKS_CREDENTIAL_TYPE, type DatabricksOAuth2Credential } from './token-provider';
interface ModelService {
@ -57,34 +57,14 @@ export function makeModelSearch(capability: ModelCapability) {
assertHttpsHost(this, credentials.host);
const host = credentials.host.replace(/\/$/, '');
const listModelServices = async (parent?: string): Promise<ModelService[]> => {
let services: ModelService[] = [];
let pageToken: string | undefined;
let pages = 0;
do {
// Guard against a host or proxy that echoes the same next_page_token back
if (++pages > 50) {
throw new NodeOperationError(this.getNode(), 'Model service list exceeded 50 pages');
}
const page: ModelServicesResponse = await this.helpers.httpRequestWithAuthentication.call(
this,
DATABRICKS_CREDENTIAL_TYPE,
{
method: 'GET',
url: `${host}/api/2.1/unity-catalog/model-services`,
// FULL view is needed for supported_api_types
qs: { view: 'FULL', parent, page_token: pageToken },
headers: { Accept: 'application/json', 'User-Agent': DATABRICKS_PARTNER_USER_AGENT },
json: true,
// The bearer must not follow a redirect off the workspace host
sendCredentialsOnCrossOriginRedirect: false,
},
);
services = services.concat(page.model_services ?? []);
pageToken = page.next_page_token;
} while (pageToken);
return services;
};
const listModelServices = async (parent?: string): Promise<ModelService[]> =>
await listDatabricksPages<ModelServicesResponse, ModelService>(
this,
`${host}/api/2.1/unity-catalog/model-services`,
// FULL view is needed for supported_api_types
{ view: 'FULL', parent },
(page) => page.model_services,
);
let services: ModelService[];
try {

View File

@ -1,6 +1,7 @@
import { ClientOAuth2 } from '@n8n/client-oauth2';
import { DATABRICKS_PARTNER_USER_AGENT } from 'n8n-nodes-base/dist/nodes/Databricks/constants';
import type {
IExecuteFunctions,
INode,
ISupplyDataFunctions,
ILoadOptionsFunctions,
@ -29,7 +30,7 @@ export type DatabricksOAuth2Credential = {
* one-time-use refresh token, so it has to go through core to persist the rotation.
*/
export function getDatabricksTokenProvider(
ctx: ISupplyDataFunctions | ILoadOptionsFunctions,
ctx: IExecuteFunctions | ISupplyDataFunctions | ILoadOptionsFunctions,
credential: DatabricksOAuth2Credential,
egressFilter?: NodeEgressFilter,
): RefreshingTokenSource {

View File

@ -19,6 +19,10 @@
"./nodes": {
"types": "./dist/configs/nodes.d.ts",
"default": "./dist/configs/nodes.js"
},
"./import-x-alias": {
"types": "./dist/import-x-alias.d.ts",
"default": "./dist/import-x-alias.js"
}
},
"scripts": {
@ -41,6 +45,7 @@
"dependencies": {
"@n8n/eslint-config": "workspace:*",
"@stylistic/eslint-plugin": "^5.0.0",
"eslint-plugin-import-x": "^4.15.2",
"eslint-plugin-lodash": "^8.0.0",
"eslint-plugin-unused-imports": "^4.1.4",
"oxlint": "catalog:"

View File

@ -19,6 +19,7 @@ export const baseConfig = defineConfig({
plugins: ['typescript', 'import', 'unicorn', 'oxc'],
jsPlugins: [
'@n8n/eslint-config/plugin',
'@n8n/oxlint-config/import-x-alias',
'@stylistic/eslint-plugin',
'eslint-plugin-unused-imports',
'eslint-plugin-lodash',
@ -173,6 +174,26 @@ export const baseConfig = defineConfig({
// ----------------------------------
'import/no-cycle': ['error', { ignoreExternal: false, maxDepth: 3 }],
'import/no-duplicates': 'error',
'import-x-alias/no-extraneous-dependencies': [
'error',
{
devDependencies: [
'**/test/**',
'**/__tests__/**',
'**/*.test.ts',
'**/*.test.utils.ts',
'**/*.spec.ts',
'**/integration-tests/**',
'**/test-utils/**',
'**/*.config.ts',
'**/*.config.js',
'**/scripts/*.ts',
'**/scripts/*.js',
'**/*.stories.ts',
],
optionalDependencies: false,
},
],
// ----------------------------------
// unicorn
@ -203,12 +224,20 @@ export const baseConfig = defineConfig({
'n8n-local-rules/no-restricted-sleep-definition': 'error',
'n8n-local-rules/no-restricted-sleep-import': 'error',
'n8n-local-rules/no-type-only-import-in-di': 'error',
'n8n-local-rules/no-unsealed-credentials-entity-write': 'error',
'n8n-local-rules/no-unsealed-workflow-entity-write': 'error',
'n8n-local-rules/no-unneeded-backticks': 'error',
'n8n-local-rules/no-unused-param-in-catch-clause': 'error',
'n8n-local-rules/no-useless-catch-throw': 'error',
},
overrides: [
{
files: ['eslint.config.mjs', 'eslint.*.config.mjs', 'oxlint.config.mts'],
jsPlugins: ['@n8n/oxlint-config/import-x-alias'],
rules: {
'import-x-alias/no-extraneous-dependencies': 'off',
},
},
{
files: ['test/**/*.ts', '**/__tests__/*.ts', '**/*.test.ts'],
// An override that names a jsPlugin rule must re-declare the plugin.

View File

@ -0,0 +1,19 @@
import importXPlugin from 'eslint-plugin-import-x';
interface OxlintJsPlugin {
meta: {
name: string;
version?: string;
};
rules: Record<string, unknown>;
}
const importXAlias: OxlintJsPlugin = {
...importXPlugin,
meta: {
...importXPlugin.meta,
name: 'import-x-alias',
},
};
export default importXAlias;

View File

@ -41,11 +41,11 @@ const ruleCounts = {
.describe('Always 0 at project scope, which rejects the delegate action'),
};
export const NODE_TYPE_POLICIES_TELEMETRY = defineTelemetryEvents({
USER_SAVED_NODE_TYPE_POLICY: {
name: 'User saved node type policy',
export const TYPE_AVAILABILITY_POLICIES_TELEMETRY = defineTelemetryEvents({
USER_SAVED_TYPE_AVAILABILITY_POLICY: {
name: 'User saved type availability policy',
description:
'A node type availability policy was saved for one scope through the composed write that the policy UI uses. One event per save, covering both the scope default action and its rules. Rule-level edits through the advanced document and attachment APIs report separately. The type counts and list report what this scope decides on its own, so at project scope they are the project layer before it composes with the instance policy.',
'A type availability policy (node types or credential types, per `kind`) was saved for one scope through the composed write that the policy UI uses. One event per save, covering both the scope default action and its rules. Rule-level edits through the advanced document and attachment APIs report separately. The type counts and list report what this scope decides on its own, so at project scope they are the project layer before it composes with the instance policy.',
properties: z.object({
user_id: userId,
source,
@ -63,11 +63,13 @@ export const NODE_TYPE_POLICIES_TELEMETRY = defineTelemetryEvents({
.boolean()
.describe('Whether this write created the scope; an unwritten scope allows every type'),
...ruleCounts,
name_selector_count: z.number().describe('Rules that target one exact node type name'),
package_selector_count: z.number().describe('Rules that target a whole node package'),
name_selector_count: z.number().describe('Rules that target one exact type name'),
package_selector_count: z.number().describe('Rules that target a whole package'),
evaluated_type_count: z
.number()
.describe('Node types this instance knows, and so the denominator for the counts below'),
.describe(
"Types this instance knows for the policy's kind, and so the denominator for the counts below",
),
blocked_type_count: z.number(),
allowed_type_count: z.number(),
delegated_type_count: z
@ -76,11 +78,11 @@ export const NODE_TYPE_POLICIES_TELEMETRY = defineTelemetryEvents({
blocked_types: z
.array(z.string())
.describe(
'The node types this policy makes unavailable, capped at 100. Node type names, not user data. Compare the length against blocked_type_count to see whether the cap dropped any',
'The types this policy makes unavailable, capped at 100. Type names, not user data. Compare the length against blocked_type_count to see whether the cap dropped any',
),
allowed_types: z
.array(z.string())
.describe('The node types this policy leaves available, capped at 100 the same way'),
.describe('The types this policy leaves available, capped at 100 the same way'),
previous_rule_count: z.number().nullable().describe('Null on the first write to this scope'),
shadow_warning_count: z
.number()
@ -88,10 +90,10 @@ export const NODE_TYPE_POLICIES_TELEMETRY = defineTelemetryEvents({
version: z.number().describe('The scope version after the write'),
}),
},
USER_UPDATED_NODE_TYPE_POLICY_DOCUMENT: {
name: 'User updated node type policy document',
USER_UPDATED_TYPE_AVAILABILITY_POLICY_DOCUMENT: {
name: 'User updated type availability policy document',
description:
'A policy document was created, edited, or deleted through the advanced instance-only document API. The composed save that the policy UI uses reports as "User saved node type policy" instead. Rule counts describe the document after the operation, so a delete reports 0 and carries the deleted size in previous_rule_count.',
'A policy document was created, edited, or deleted through the advanced instance-only document API. The composed save that the policy UI uses reports as "User saved type availability policy" instead. Rule counts describe the document after the operation, so a delete reports 0 and carries the deleted size in previous_rule_count.',
properties: z.object({
user_id: userId,
source,
@ -102,8 +104,8 @@ export const NODE_TYPE_POLICIES_TELEMETRY = defineTelemetryEvents({
previous_rule_count: z.number().nullable().describe('Null on a created document'),
}),
},
USER_UPDATED_NODE_TYPE_POLICY_ATTACHMENTS: {
name: 'User updated node type policy attachments',
USER_UPDATED_TYPE_AVAILABILITY_POLICY_ATTACHMENTS: {
name: 'User updated type availability policy attachments',
description:
'The set of policy documents attached to one scope was replaced through the advanced instance-only attachment API. Reports the resulting set, not the individual add or remove.',
properties: z.object({

View File

@ -8,8 +8,8 @@ export type {
TelemetryEventInput,
TelemetryEventRegistry,
} from './define';
export { POLICY_KINDS } from './events/node-type-policies';
export type { PolicyKind } from './events/node-type-policies';
export { POLICY_KINDS } from './events/type-availability-policies';
export type { PolicyKind } from './events/type-availability-policies';
export { redactTelemetryProperties, redactTelemetryText } from './redaction';
export type { TelemetryTextOptions } from './redaction';
export { collectDuplicateNames, validateEntrySchemas } from './registry-checks';

View File

@ -5,8 +5,8 @@ import { CREDENTIALS_TELEMETRY } from './events/credentials';
import { INSTANCE_TELEMETRY } from './events/instance';
import { INSTANCE_AI_TELEMETRY } from './events/instance-ai';
import { MCP_TELEMETRY } from './events/mcp';
import { NODE_TYPE_POLICIES_TELEMETRY } from './events/node-type-policies';
import { PLATFORM_TELEMETRY } from './events/platform';
import { TYPE_AVAILABILITY_POLICIES_TELEMETRY } from './events/type-availability-policies';
import { WORKFLOW_TELEMETRY } from './events/workflow';
import { WORKFLOW_REVIEWS_TELEMETRY } from './events/workflow-reviews';
@ -18,7 +18,7 @@ export const TELEMETRY_EVENT = {
INSTANCE: INSTANCE_TELEMETRY,
INSTANCE_AI: INSTANCE_AI_TELEMETRY,
MCP: MCP_TELEMETRY,
NODE_TYPE_POLICIES: NODE_TYPE_POLICIES_TELEMETRY,
TYPE_AVAILABILITY_POLICIES: TYPE_AVAILABILITY_POLICIES_TELEMETRY,
WORKFLOW: WORKFLOW_TELEMETRY,
WORKFLOW_REVIEWS: WORKFLOW_REVIEWS_TELEMETRY,
} satisfies TelemetryEventRegistry;

View File

@ -67,6 +67,10 @@ export default defineConfig(
rule: 'no-unsealed-workflow-entity-write',
message: 'Route the write through a token-gated `WorkflowRepository` method.',
},
{
rule: 'no-unsealed-credentials-entity-write',
message: 'Route the write through a token-gated `CredentialsRepository` method.',
},
],
},
],
@ -151,7 +155,6 @@ export default defineConfig(
'./src/public-api/v1/handlers/data-tables/data-tables.columns.handler.ts',
'./src/public-api/v1/handlers/data-tables/data-tables.handler.ts',
'./src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts',
'./src/public-api/v1/handlers/discover/discover.handler.ts',
'./src/public-api/v1/handlers/evaluations/evaluations.handler.ts',
'./src/public-api/v1/handlers/folders/folders.handler.ts',
'./src/public-api/v1/handlers/ldap/ldap.handler.ts',
@ -161,7 +164,6 @@ export default defineConfig(
'./src/public-api/v1/handlers/security-policy/security-policy.handler.ts',
'./src/public-api/v1/handlers/sso-oidc/sso-oidc.handler.ts',
'./src/public-api/v1/handlers/sso-saml/sso-saml.handler.ts',
'./src/public-api/v1/handlers/users/users.handler.ee.ts',
'./src/public-api/v1/handlers/workflows/workflows.handler.ts',
],
rules: {

View File

@ -1,44 +0,0 @@
import { defineConfig, globalIgnores } from 'eslint/config';
import { functionalGuardrailsConfig } from '@n8n/eslint-config/functional-guardrails';
import { localRulesPlugin } from '@n8n/eslint-config/plugin';
import cliConfig from './eslint.config.mjs';
const directivePluginConfigs = cliConfig.flatMap(({ plugins }) => {
const externalPlugins = Object.fromEntries(
Object.entries(plugins ?? {}).filter(([name]) => name !== 'n8n-local-rules'),
);
return Object.keys(externalPlugins).length === 0 ? [] : [{ plugins: externalPlugins }];
});
const dependencyRuleConfigs = cliConfig.flatMap(({ files, ignores, settings, rules }) => {
const rule = rules?.['import-x/no-extraneous-dependencies'];
if (!rule) return [];
return [
{
...(files ? { files } : {}),
...(ignores ? { ignores } : {}),
...(settings ? { settings } : {}),
rules: { 'import-x/no-extraneous-dependencies': rule },
},
];
});
export default defineConfig(
globalIgnores([
'dist/**',
'coverage/**',
'eslint.config.mjs',
'eslint.guardrails.config.mjs',
'oxlint.config.mts',
'scripts/**/*.mjs',
'vitest.*.ts',
]),
{
plugins: { 'n8n-local-rules': localRulesPlugin },
},
functionalGuardrailsConfig,
...directivePluginConfigs,
...dependencyRuleConfigs,
);

View File

@ -44,8 +44,7 @@ export default defineConfig({
rules: {
'n8n-local-rules/no-dynamic-import-template': 'error',
'n8n-local-rules/misplaced-n8n-typeorm-import': 'error',
// Ratchets: the allowlists below only shrink, so an inline disable is the one way to add a
// violation. The narrow ESLint guardrail config owns no-unsealed-workflow-entity-write.
// Ratchets: the allowlists below only shrink, so an inline disable is the one way to add a violation.
'n8n-local-rules/no-guardrail-disable': [
'error',
{
@ -67,6 +66,10 @@ export default defineConfig({
rule: 'no-unsealed-workflow-entity-write',
message: 'Route the write through a token-gated `WorkflowRepository` method.',
},
{
rule: 'no-unsealed-credentials-entity-write',
message: 'Route the write through a token-gated `CredentialsRepository` method.',
},
],
},
],
@ -104,6 +107,17 @@ export default defineConfig({
'typescript/no-duplicate-type-constituents': 'warn',
},
overrides: [
{
// Shrink-only ratchet: the two import paths that upsert a whole credential row,
// including `type`. There is no sealed credential import method yet.
// NEVER add to this list — the override is per file, so a second write here
// goes unreported.
files: [
'./src/commands/import/credentials.ts',
'./src/modules/source-control.ee/source-control-import.service.ee.ts',
],
rules: { 'n8n-local-rules/no-unsealed-credentials-entity-write': 'off' },
},
{
// Public API guardrail: handlers/controllers must go through a service, never a repository.
files: ['./src/public-api/v1/handlers/**/*.ts', './src/public-api/v1/controllers/**/*.ts'],

View File

@ -17,10 +17,8 @@
"dev:webhook": "concurrently -k -n \"TypeScript,Node\" -c \"yellow.bold,cyan.bold\" \"pnpm run watch\" \"nodemon webhook\"",
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "pnpm run lint:base && pnpm run lint:guardrails",
"lint:base": "oxlint --type-aware . --quiet",
"lint:fix": "oxlint --type-aware . --fix && pnpm run lint:guardrails",
"lint:guardrails": "cross-env NODE_OPTIONS=\"--max-old-space-size=6144\" eslint --config eslint.guardrails.config.mjs . --quiet",
"lint": "oxlint --type-aware . --quiet",
"lint:fix": "oxlint --type-aware . --fix",
"recording:list": "node scripts/agent-integration-recordings.mjs --list",
"recording:export": "node scripts/agent-integration-recordings.mjs",
"start": "node ../../scripts/os-normalize.mjs --dir bin n8n",
@ -102,7 +100,7 @@
"@types/swagger-ui-express": "^4.1.8",
"@types/turndown": "catalog:",
"@types/uuid": "catalog:",
"@types/validator": "^13.7.0",
"@types/validator": "catalog:",
"@types/ws": "catalog:",
"@types/xml2js": "catalog:",
"@types/yargs-parser": "21.0.0",
@ -279,7 +277,7 @@
"tar": "catalog:",
"turndown": "catalog:",
"uuid": "catalog:",
"validator": "13.15.22",
"validator": "catalog:",
"ws": "8.20.1",
"xml2js": "catalog:",
"xmllint-wasm": "3.0.1",

View File

@ -1047,13 +1047,10 @@ describe('CredentialsHelper', () => {
expect(credentialsRepository.update).toHaveBeenCalledWith(
{ id: 'cred-123', type: 'oAuth2Api' },
expect.objectContaining({
id: 'cred-123',
name: 'Test OAuth2 Credential',
type: 'oAuth2Api',
{
data: expect.any(String),
updatedAt: expect.any(Date),
}),
},
);
const updateCall = credentialsRepository.update.mock.calls[0];
@ -1277,11 +1274,10 @@ describe('CredentialsHelper', () => {
expect(storeOAuthTokenDataSpy).not.toHaveBeenCalled();
expect(credentialsRepository.update).toHaveBeenCalledWith(
{ id: 'cred-789', type: 'oAuth2Api' },
expect.objectContaining({
id: 'cred-789',
{
data: expect.any(String),
updatedAt: expect.any(Date),
}),
},
);
// Verify OAuth token was updated in database
@ -1324,11 +1320,10 @@ describe('CredentialsHelper', () => {
expect(storeOAuthTokenDataSpy).not.toHaveBeenCalled();
expect(credentialsRepository.update).toHaveBeenCalledWith(
{ id: 'cred-789', type: 'oAuth2Api' },
expect.objectContaining({
id: 'cred-789',
{
data: expect.any(String),
updatedAt: expect.any(Date),
}),
},
);
// Verify OAuth token was updated in database

View File

@ -44,6 +44,7 @@ import { ActiveExecutions } from '@/active-executions';
import { ExecutionNotFoundError } from '@/errors/execution-not-found-error';
import { MaxStalledCountError } from '@/errors/max-stalled-count.error';
import * as ExecutionLifecycleHooks from '@/execution-lifecycle/execution-lifecycle-hooks';
import { ExecutionCrashService } from '@/executions/execution-crash.service';
import { ExecutionPersistence } from '@/executions/execution-persistence';
import {
CredentialsPermissionChecker,
@ -479,11 +480,75 @@ describe('processError', () => {
expect(findSingleExecution.mock.calls.length).toBeLessThan(10);
expect(finalizeExecution).toHaveBeenCalledWith(
execution.id,
expect.objectContaining({ status: 'error' }),
expect.objectContaining({ status: 'crashed' }),
);
expect(watcher.workflowExecuteAfter).toHaveBeenCalledTimes(1);
});
test('processError runs the after hook with a crashed run when a stalled-count error claims the execution', async () => {
const workflow = await createWorkflow({}, owner);
const execution = await createExecution({ status: 'new', finished: false }, workflow);
await Container.get(ActiveExecutions).add(
{ executionMode: 'webhook', workflowData: workflow },
{ executionId: execution.id, expectedStatus: 'new' },
);
const claim = vi
.spyOn(Container.get(ExecutionCrashService), 'markAsCrashedWithoutCounting')
.mockResolvedValue([
{
id: execution.id,
workflowId: workflow.id,
mode: 'webhook',
startedAt: null,
stoppedAt: new Date(),
},
]);
globalConfig.executions.mode = 'regular';
await runner.processError(
new MaxStalledCountError(new Error('job stalled more than maxStalledCount')),
new Date(),
'webhook',
execution.id,
hooks,
);
expect(claim).toHaveBeenCalledExactlyOnceWith(execution.id, 'stall');
expect(watcher.workflowExecuteAfter).toHaveBeenCalledTimes(1);
expect(watcher.workflowExecuteAfter).toHaveBeenCalledWith(
expect.objectContaining({
status: 'crashed',
data: expect.objectContaining({
resultData: expect.objectContaining({
error: expect.objectContaining({ name: 'MaxStalledCountError' }),
}),
}),
}),
);
});
test('processError finalizes without running the after hook when a stalled-count error claims nothing', async () => {
const workflow = await createWorkflow({}, owner);
const execution = await createExecution({ status: 'crashed', finished: false }, workflow);
const finalizeExecution = vi.spyOn(Container.get(ActiveExecutions), 'finalizeExecution');
vi.spyOn(
Container.get(ExecutionCrashService),
'markAsCrashedWithoutCounting',
).mockResolvedValue([]);
globalConfig.executions.mode = 'regular';
await runner.processError(
new MaxStalledCountError(new Error('job stalled more than maxStalledCount')),
new Date(),
'webhook',
execution.id,
hooks,
);
expect(finalizeExecution).toHaveBeenCalledExactlyOnceWith(execution.id);
expect(watcher.workflowExecuteAfter).not.toHaveBeenCalled();
});
test('processError leaves a paused execution to the wait tracker on a stalled-count error', async () => {
const workflow = await createWorkflow({}, owner);
const execution = await createExecution({ status: 'waiting', finished: false }, workflow);
@ -597,7 +662,7 @@ describe('processError', () => {
expect(findSingleExecution).toHaveBeenCalledTimes(1);
expect(finalizeExecution).toHaveBeenCalledWith(
execution.id,
expect.objectContaining({ status: 'error' }),
expect.objectContaining({ status: 'crashed' }),
);
expect(watcher.workflowExecuteAfter).toHaveBeenCalledTimes(1);
},

View File

@ -0,0 +1,34 @@
import { isCredSharingEnabled } from '@/constants/credential-sharing';
describe('isCredSharingEnabled', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
});
afterAll(() => {
process.env = originalEnv;
});
it('should return true when N8N_ENV_FEAT_CRED_SHARING is "true"', () => {
process.env.N8N_ENV_FEAT_CRED_SHARING = 'true';
expect(isCredSharingEnabled()).toBe(true);
});
it('should return false when N8N_ENV_FEAT_CRED_SHARING is unset', () => {
delete process.env.N8N_ENV_FEAT_CRED_SHARING;
expect(isCredSharingEnabled()).toBe(false);
});
it.each(['false', 'TRUE', '1', ''])(
'should return false when N8N_ENV_FEAT_CRED_SHARING is %p',
(value) => {
process.env.N8N_ENV_FEAT_CRED_SHARING = value;
expect(isCredSharingEnabled()).toBe(false);
},
);
});

View File

@ -0,0 +1,10 @@
import { isEnvFeatureEnabled } from '@n8n/backend-common';
/**
* More granular credential sharing (personal-space credentials usable in any
* project their owner works in, the can-see/can-use split, and related
* behavior) is opt-in while it lands on master in incremental pieces.
*/
export function isCredSharingEnabled(): boolean {
return isEnvFeatureEnabled('N8N_ENV_FEAT_CRED_SHARING');
}

View File

@ -101,7 +101,7 @@ export class E2EController {
[LICENSE_FEATURES.DYNAMIC_CREDENTIALS]: false,
[LICENSE_FEATURES.SHARING]: false,
[LICENSE_FEATURES.LDAP]: false,
[LICENSE_FEATURES.NODE_TYPE_POLICIES]: false,
[LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES]: false,
[LICENSE_FEATURES.SAML]: false,
[LICENSE_FEATURES.LOG_STREAMING]: false,
[LICENSE_FEATURES.ADVANCED_EXECUTION_FILTERS]: false,

View File

@ -808,10 +808,12 @@ export class CredentialsHelper extends ICredentialsHelper {
const credentials = await this.getCredentials(nodeCredentials, type);
await credentials.setData(data);
const newCredentialsData = credentials.getDataToSave() as ICredentialsDb;
// Add special database related data
newCredentialsData.updatedAt = new Date();
// Ciphertext only. `name` and `type` would be written back unchanged, and a payload
// that cannot carry `type` keeps this off the sealed `credentialSave` path.
const newCredentialsData: Pick<ICredentialsDb, 'data' | 'updatedAt'> = {
data: credentials.getDataToSave().data,
updatedAt: new Date(),
};
// Save the credentials in DB
const findQuery = {
@ -865,10 +867,12 @@ export class CredentialsHelper extends ICredentialsHelper {
const credentials = await this.getCredentials(nodeCredentials, type);
await credentials.updateData({ oauthTokenData: data.oauthTokenData });
const newCredentialsData = credentials.getDataToSave() as ICredentialsDb;
// Add special database related data
newCredentialsData.updatedAt = new Date();
// Ciphertext only. `name` and `type` would be written back unchanged, and a payload
// that cannot carry `type` keeps this off the sealed `credentialSave` path.
const newCredentialsData: Pick<ICredentialsDb, 'data' | 'updatedAt'> = {
data: credentials.getDataToSave().data,
updatedAt: new Date(),
};
// Save the credentials in DB
const findQuery = {

View File

@ -309,7 +309,7 @@ export class MessageEventBus extends EventEmitter {
const recoveryAlreadyAttempted = this.logWriter?.isRecoveryProcessRunning();
if (recoveryAlreadyAttempted || this.globalConfig.eventBus.crashRecoveryMode === 'simple') {
await this.executionCrashService.markAsCrashed(unfinishedExecutionIds);
await this.executionCrashService.markAsCrashed(unfinishedExecutionIds, 'startup-recovery');
// if we end up here, it means that the previous recovery process did not finish
// a possible reason would be that recreating the workflow data itself caused e.g an OOM error
// in that case, we do not want to retry the recovery process, but rather mark the executions as crashed

View File

@ -33,6 +33,7 @@ import { EventService } from '@/events/event.service';
import type { RelayEventMap } from '@/events/maps/relay.event-map';
import { TelemetryEventRelay, getSemanticVersioning } from '@/events/relays/telemetry.event-relay';
import type { License } from '@/license';
import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { OtelConfig } from '@/modules/otel/otel.config';
import type { PolicyRule } from '@/modules/type-availability-policies/policy-rule.types';
import type { NodeTypes } from '@/node-types';
@ -159,6 +160,7 @@ describe('TelemetryEventRelay', () => {
const credentialsRepository = mock<CredentialsRepository>();
const dynamicCredentialsProxy = mock<DynamicCredentialsProxy>();
const dbConnection = mock<DbConnection>();
const loadNodesAndCredentials = mock<LoadNodesAndCredentials>();
const eventService = new EventService();
let telemetryEventRelay: TelemetryEventRelay;
@ -179,6 +181,7 @@ describe('TelemetryEventRelay', () => {
credentialsRepository,
dynamicCredentialsProxy,
dbConnection,
loadNodesAndCredentials,
);
await telemetryEventRelay.init();
@ -211,6 +214,7 @@ describe('TelemetryEventRelay', () => {
credentialsRepository,
dynamicCredentialsProxy,
dbConnection,
loadNodesAndCredentials,
);
// @ts-expect-error Private method
const setupListenersSpy = vi.spyOn(telemetryEventRelay, 'setupListeners');
@ -238,6 +242,7 @@ describe('TelemetryEventRelay', () => {
credentialsRepository,
dynamicCredentialsProxy,
dbConnection,
loadNodesAndCredentials,
);
// @ts-expect-error Private method
const setupListenersSpy = vi.spyOn(telemetryEventRelay, 'setupListeners');
@ -682,6 +687,20 @@ describe('TelemetryEventRelay', () => {
const knownTypes = (...names: string[]) =>
Object.fromEntries(names.map((name) => [name, {}])) as ReturnType<NodeTypes['getKnownTypes']>;
const knownCredentials = (...names: string[]) =>
Object.fromEntries(
names.map((name) => [name, {}]),
) as LoadNodesAndCredentials['knownCredentials'];
const makeLoader = (packageName: string, credentialNames: string[]) =>
({
packageName,
known: {
nodes: {},
credentials: Object.fromEntries(credentialNames.map((name) => [name, {}])),
},
}) as unknown as LoadNodesAndCredentials['loaders'][string];
beforeEach(() => {
nodeTypes.getKnownTypes.mockReturnValue(
knownTypes(
@ -690,6 +709,14 @@ describe('TelemetryEventRelay', () => {
'@acme/n8n-nodes-acme.thing',
),
);
Object.defineProperty(loadNodesAndCredentials, 'knownCredentials', {
configurable: true,
value: knownCredentials('slackApi', 'notionApi', 'httpBasicAuth'),
});
loadNodesAndCredentials.loaders = {
'n8n-nodes-base': makeLoader('n8n-nodes-base', ['slackApi', 'notionApi']),
'@acme/n8n-nodes-acme': makeLoader('@acme/n8n-nodes-acme', ['httpBasicAuth']),
};
});
it('should track a first instance-scope save', () => {
@ -708,7 +735,7 @@ describe('TelemetryEventRelay', () => {
eventService.emit('node-type-policy-saved', event);
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_SAVED_NODE_TYPE_POLICY,
TELEMETRY_EVENT.TYPE_AVAILABILITY_POLICIES.USER_SAVED_TYPE_AVAILABILITY_POLICY,
{
user_id: 'user123',
source: 'user',
@ -736,24 +763,70 @@ describe('TelemetryEventRelay', () => {
);
});
it('should report a credential type policy save with its own kind', () => {
it('should summarize a credential type policy save against known credentials, not known node types', () => {
const credentialDenyRule: PolicyRule = {
id: 'rule-1',
action: 'deny',
selector: { kind: 'name', value: 'notionApi' },
};
const event: RelayEventMap['node-type-policy-saved'] = {
updatedBy: 'user123',
kind: 'credential-types',
projectId: null,
scopeId: 'scope-1',
before: null,
after: { defaultAction: 'deny', version: 1 },
after: { defaultAction: 'allow', version: 1 },
rulesBefore: null,
rulesAfter: [],
rulesAfter: [credentialDenyRule],
warningCount: 0,
};
eventService.emit('node-type-policy-saved', event);
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_SAVED_NODE_TYPE_POLICY,
expect.objectContaining({ kind: 'credential-types' }),
TELEMETRY_EVENT.TYPE_AVAILABILITY_POLICIES.USER_SAVED_TYPE_AVAILABILITY_POLICY,
expect.objectContaining({
kind: 'credential-types',
evaluated_type_count: 3,
blocked_type_count: 1,
allowed_type_count: 2,
blocked_types: ['notionApi'],
allowed_types: ['slackApi', 'httpBasicAuth'],
}),
);
});
it('should expand a credential type package rule using the credential loader package, not a dotted-type prefix', () => {
const credentialPackageDenyRule: PolicyRule = {
id: 'rule-1',
action: 'deny',
selector: { kind: 'package', value: 'n8n-nodes-base' },
};
const event: RelayEventMap['node-type-policy-saved'] = {
updatedBy: 'user123',
kind: 'credential-types',
projectId: null,
scopeId: 'scope-1',
before: null,
after: { defaultAction: 'allow', version: 1 },
rulesBefore: null,
rulesAfter: [credentialPackageDenyRule],
warningCount: 0,
};
eventService.emit('node-type-policy-saved', event);
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.TYPE_AVAILABILITY_POLICIES.USER_SAVED_TYPE_AVAILABILITY_POLICY,
expect.objectContaining({
kind: 'credential-types',
blocked_type_count: 2,
blocked_types: ['slackApi', 'notionApi'],
allowed_type_count: 1,
allowed_types: ['httpBasicAuth'],
}),
);
});
@ -791,7 +864,7 @@ describe('TelemetryEventRelay', () => {
eventService.emit('node-type-policy-saved', event);
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_SAVED_NODE_TYPE_POLICY,
TELEMETRY_EVENT.TYPE_AVAILABILITY_POLICIES.USER_SAVED_TYPE_AVAILABILITY_POLICY,
expect.objectContaining({
scope: 'project',
project_id: 'project-1',
@ -837,7 +910,7 @@ describe('TelemetryEventRelay', () => {
eventService.emit('node-type-policy-document-created', event);
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_UPDATED_NODE_TYPE_POLICY_DOCUMENT,
TELEMETRY_EVENT.TYPE_AVAILABILITY_POLICIES.USER_UPDATED_TYPE_AVAILABILITY_POLICY_DOCUMENT,
{
user_id: 'user123',
source: 'user',
@ -865,7 +938,7 @@ describe('TelemetryEventRelay', () => {
eventService.emit('node-type-policy-document-created', event);
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_UPDATED_NODE_TYPE_POLICY_DOCUMENT,
TELEMETRY_EVENT.TYPE_AVAILABILITY_POLICIES.USER_UPDATED_TYPE_AVAILABILITY_POLICY_DOCUMENT,
expect.objectContaining({ kind: 'credential-types' }),
);
});
@ -897,7 +970,7 @@ describe('TelemetryEventRelay', () => {
eventService.emit('node-type-policy-document-updated', event);
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_UPDATED_NODE_TYPE_POLICY_DOCUMENT,
TELEMETRY_EVENT.TYPE_AVAILABILITY_POLICIES.USER_UPDATED_TYPE_AVAILABILITY_POLICY_DOCUMENT,
expect.objectContaining({
operation: 'updated',
rule_count: 2,
@ -917,7 +990,7 @@ describe('TelemetryEventRelay', () => {
eventService.emit('node-type-policy-document-deleted', event);
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_UPDATED_NODE_TYPE_POLICY_DOCUMENT,
TELEMETRY_EVENT.TYPE_AVAILABILITY_POLICIES.USER_UPDATED_TYPE_AVAILABILITY_POLICY_DOCUMENT,
expect.objectContaining({
operation: 'deleted',
rule_count: 0,
@ -941,7 +1014,7 @@ describe('TelemetryEventRelay', () => {
});
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_SAVED_NODE_TYPE_POLICY,
TELEMETRY_EVENT.TYPE_AVAILABILITY_POLICIES.USER_SAVED_TYPE_AVAILABILITY_POLICY,
expect.objectContaining({
blocked_type_count: 1,
allowed_type_count: 2,
@ -965,7 +1038,7 @@ describe('TelemetryEventRelay', () => {
});
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_SAVED_NODE_TYPE_POLICY,
TELEMETRY_EVENT.TYPE_AVAILABILITY_POLICIES.USER_SAVED_TYPE_AVAILABILITY_POLICY,
expect.objectContaining({
rule_count: 1,
blocked_type_count: 2,
@ -1018,7 +1091,7 @@ describe('TelemetryEventRelay', () => {
});
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_SAVED_NODE_TYPE_POLICY,
TELEMETRY_EVENT.TYPE_AVAILABILITY_POLICIES.USER_SAVED_TYPE_AVAILABILITY_POLICY,
expect.objectContaining({
blocked_type_count: 3,
allowed_type_count: 0,
@ -1045,7 +1118,7 @@ describe('TelemetryEventRelay', () => {
});
expect(telemetry.track).not.toHaveBeenCalledWith(
TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_UPDATED_NODE_TYPE_POLICY_DOCUMENT,
TELEMETRY_EVENT.TYPE_AVAILABILITY_POLICIES.USER_UPDATED_TYPE_AVAILABILITY_POLICY_DOCUMENT,
expect.anything(),
);
},
@ -1073,7 +1146,8 @@ describe('TelemetryEventRelay', () => {
eventService.emit('node-type-policy-attachments-updated', event);
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_UPDATED_NODE_TYPE_POLICY_ATTACHMENTS,
TELEMETRY_EVENT.TYPE_AVAILABILITY_POLICIES
.USER_UPDATED_TYPE_AVAILABILITY_POLICY_ATTACHMENTS,
{
user_id: 'user123',
source: 'user',
@ -1101,7 +1175,8 @@ describe('TelemetryEventRelay', () => {
eventService.emit('node-type-policy-attachments-updated', event);
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_UPDATED_NODE_TYPE_POLICY_ATTACHMENTS,
TELEMETRY_EVENT.TYPE_AVAILABILITY_POLICIES
.USER_UPDATED_TYPE_AVAILABILITY_POLICY_ATTACHMENTS,
expect.objectContaining({ kind: 'credential-types' }),
);
});

View File

@ -7,6 +7,7 @@ import type {
IRun,
IWorkflowBase,
IWorkflowExecutionDataProcess,
IWorkflowSettings,
JsonValue,
WorkflowExecuteMode,
WorkflowSettings,
@ -58,6 +59,13 @@ export type UserLike = {
*/
export type PolicyWriteOrigin = 'composed-save' | 'document-api';
export type CrashDetector =
| 'stall'
| 'queue-recovery'
| 'startup-recovery'
| 'start-failure'
| 'workflow-deactivation';
export type ProjectSummary = {
id: string;
name: string;
@ -641,6 +649,22 @@ export type RelayEventMap = {
reason: CancellationReason;
};
'execution-crashed': {
executionId: string;
workflowId: string;
workflowName?: string;
mode: WorkflowExecuteMode;
startedAt?: Date;
stoppedAt: Date;
detector: CrashDetector;
hostId: string;
tracingContext?: { traceparent: string; tracestate?: string };
workflowVersionId?: string;
retryOf?: string;
workflowCustomTelemetryTags?: IWorkflowSettings['customTelemetryTags'];
project?: { id: string; customTelemetryTags: Array<{ key: string; value: string }> };
};
'execution-deleted': {
user: UserLike;
executionIds: string[];

View File

@ -39,7 +39,13 @@ import type { RelayEventMap } from '@/events/maps/relay.event-map';
import { determineFinalExecutionStatus } from '@/execution-lifecycle/shared/shared-hook-functions';
import type { IExecutionTrackProperties } from '@/interfaces';
import { License } from '@/license';
import { partitionTypesByAction } from '@/modules/type-availability-policies/policy-evaluator';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { CREDENTIAL_TYPES_KIND } from '@/modules/type-availability-policies/constants';
import { packageResolverFor } from '@/modules/type-availability-policies/package-resolver';
import {
partitionTypesByAction,
type PackageResolver,
} from '@/modules/type-availability-policies/policy-evaluator';
import type {
PolicyAction,
PolicyRule,
@ -109,15 +115,17 @@ function countRuleActions(rules: readonly PolicyRule[]) {
const MAX_LISTED_POLICY_TYPES = 100;
/**
* What a saved policy makes of every node type this instance knows. Runs the same evaluation
* the node panel runs, once per save rather than once per workflow open.
* What a saved policy makes of every type this instance knows, within the policy's own kind
* (node types or credential types). Runs the same evaluation the node panel runs, once per
* save rather than once per workflow open.
*/
function summarizeTypeAvailability(
rules: readonly PolicyRule[],
defaultAction: PolicyAction,
typeNames: readonly string[],
resolvePackage: PackageResolver,
) {
const partition = partitionTypesByAction(rules, defaultAction, typeNames);
const partition = partitionTypesByAction(rules, defaultAction, typeNames, resolvePackage);
return {
evaluated_type_count: typeNames.length,
@ -175,6 +183,7 @@ export class TelemetryEventRelay extends EventRelay {
private readonly credentialsRepository: CredentialsRepository,
private readonly dynamicCredentialsProxy: DynamicCredentialsProxy,
private readonly dbConnection: DbConnection,
private readonly loadNodesAndCredentials: LoadNodesAndCredentials,
) {
super(eventService);
}
@ -550,24 +559,29 @@ export class TelemetryEventRelay extends EventRelay {
}: RelayEventMap['node-type-policy-saved']) {
if (!isPolicyKind(kind)) return;
this.telemetry.track(TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_SAVED_NODE_TYPE_POLICY, {
...policyActor(updatedBy),
kind,
...policyScope(projectId),
default_action: after.defaultAction,
previous_default_action: before?.defaultAction ?? null,
is_first_write: before === null,
...countRuleActions(rulesAfter),
...countSelectorKinds(rulesAfter),
...summarizeTypeAvailability(
rulesAfter,
after.defaultAction,
Object.keys(this.nodeTypes.getKnownTypes()),
),
previous_rule_count: rulesBefore?.length ?? null,
shadow_warning_count: warningCount,
version: after.version,
});
const typeNames =
kind === CREDENTIAL_TYPES_KIND
? Object.keys(this.loadNodesAndCredentials.knownCredentials)
: Object.keys(this.nodeTypes.getKnownTypes());
const resolvePackage = packageResolverFor(kind, this.loadNodesAndCredentials);
this.telemetry.track(
TELEMETRY_EVENT.TYPE_AVAILABILITY_POLICIES.USER_SAVED_TYPE_AVAILABILITY_POLICY,
{
...policyActor(updatedBy),
kind,
...policyScope(projectId),
default_action: after.defaultAction,
previous_default_action: before?.defaultAction ?? null,
is_first_write: before === null,
...countRuleActions(rulesAfter),
...countSelectorKinds(rulesAfter),
...summarizeTypeAvailability(rulesAfter, after.defaultAction, typeNames, resolvePackage),
previous_rule_count: rulesBefore?.length ?? null,
shadow_warning_count: warningCount,
version: after.version,
},
);
}
/**
@ -619,7 +633,7 @@ export class TelemetryEventRelay extends EventRelay {
if (!isPolicyKind(kind)) return;
this.telemetry.track(
TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_UPDATED_NODE_TYPE_POLICY_DOCUMENT,
TELEMETRY_EVENT.TYPE_AVAILABILITY_POLICIES.USER_UPDATED_TYPE_AVAILABILITY_POLICY_DOCUMENT,
{
...policyActor(updatedBy),
kind,
@ -642,7 +656,7 @@ export class TelemetryEventRelay extends EventRelay {
if (!isPolicyKind(kind)) return;
this.telemetry.track(
TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_UPDATED_NODE_TYPE_POLICY_ATTACHMENTS,
TELEMETRY_EVENT.TYPE_AVAILABILITY_POLICIES.USER_UPDATED_TYPE_AVAILABILITY_POLICY_ATTACHMENTS,
{
...policyActor(updatedBy),
kind,

View File

@ -84,7 +84,10 @@ describe('EnqueuedExecutionRecoveryService', () => {
await createService().recoverEnqueuedExecutions();
await new Promise(setImmediate); // `run` is not awaited, let the rejection settle
expect(executionCrashService.markAsCrashed).toHaveBeenCalledExactlyOnceWith('1');
expect(executionCrashService.markAsCrashed).toHaveBeenCalledExactlyOnceWith(
'1',
'start-failure',
);
expect(errorReporter.error).toHaveBeenCalledTimes(1);
expect(workflowRunner.run).toHaveBeenCalledTimes(2);
});
@ -107,7 +110,10 @@ describe('EnqueuedExecutionRecoveryService', () => {
await createService().recoverEnqueuedExecutions();
expect(executionCrashService.markAsCrashed).toHaveBeenCalledExactlyOnceWith(['2', '3']);
expect(executionCrashService.markAsCrashed).toHaveBeenCalledExactlyOnceWith(
['2', '3'],
'start-failure',
);
expect(workflowRunner.run).toHaveBeenCalledExactlyOnceWith(
expect.anything(),
undefined,
@ -121,7 +127,10 @@ describe('EnqueuedExecutionRecoveryService', () => {
await createService().recoverEnqueuedExecutions();
expect(executionCrashService.markAsCrashed).toHaveBeenCalledExactlyOnceWith(['1']);
expect(executionCrashService.markAsCrashed).toHaveBeenCalledExactlyOnceWith(
['1'],
'start-failure',
);
expect(workflowRunner.run).not.toHaveBeenCalled();
});
@ -142,7 +151,10 @@ describe('EnqueuedExecutionRecoveryService', () => {
await createService().recoverEnqueuedExecutions();
expect(executionCrashService.markAsCrashed).toHaveBeenCalledExactlyOnceWith('1');
expect(executionCrashService.markAsCrashed).toHaveBeenCalledExactlyOnceWith(
'1',
'start-failure',
);
expect(workflowRunner.run).toHaveBeenCalledExactlyOnceWith(
expect.anything(),
undefined,

View File

@ -1,23 +1,74 @@
import type { CrashedExecution, ExecutionRepository } from '@n8n/db';
import type { InstanceSettings } from 'n8n-core';
import { mock } from 'vitest-mock-extended';
import type { EventService } from '@/events/event.service';
import { ExecutionCrashService } from '@/executions/execution-crash.service';
import type { WorkflowStatisticsService } from '@/services/workflow-statistics.service';
describe('ExecutionCrashService', () => {
const executionRepository = mock<ExecutionRepository>();
const workflowStatisticsService = mock<WorkflowStatisticsService>();
const eventService = mock<EventService>();
const instanceSettings = mock<InstanceSettings>({ hostId: 'main-1' });
const crashService = new ExecutionCrashService(executionRepository, workflowStatisticsService);
const crashService = new ExecutionCrashService(
executionRepository,
workflowStatisticsService,
eventService,
instanceSettings,
);
const crashedExecution = (id: string, overrides: Partial<CrashedExecution> = {}) => ({
const startedAt = new Date('2025-01-01T00:00:00.000Z');
const stoppedAt = new Date('2025-01-01T00:01:00.000Z');
const crashedExecution = (
id: string,
overrides: Partial<CrashedExecution> = {},
): CrashedExecution => ({
id,
workflowId: `workflow-${id}`,
workflowName: `Workflow ${id}`,
mode: 'trigger' as const,
mode: 'trigger',
startedAt,
stoppedAt,
...overrides,
});
const announcementOf = (
{
id,
workflowId,
workflowName,
workflowVersionId,
mode,
retryOf,
startedAt: rowStartedAt,
stoppedAt: rowStoppedAt,
tracingContext,
workflowCustomTelemetryTags,
project,
}: CrashedExecution,
detector: string,
) => [
'execution-crashed',
{
executionId: id,
workflowId,
workflowName,
workflowVersionId,
mode,
retryOf,
startedAt: rowStartedAt ?? undefined,
stoppedAt: rowStoppedAt,
detector,
hostId: 'main-1',
tracingContext,
workflowCustomTelemetryTags,
project,
},
];
const transitions = (batch: CrashedExecution[]) =>
executionRepository.markAsCrashed.mockImplementation(async (_ids, onBatchTransitioned) => {
onBatchTransitioned?.(batch);
@ -26,6 +77,7 @@ describe('ExecutionCrashService', () => {
});
const emitted = () => workflowStatisticsService.emit.mock.calls;
const announced = () => eventService.emit.mock.calls;
beforeEach(() => {
vi.clearAllMocks();
@ -36,12 +88,31 @@ describe('ExecutionCrashService', () => {
const second = crashedExecution('2', { mode: 'manual' });
transitions([first, second]);
const crashed = await crashService.markAsCrashed(['1', '2']);
const crashed = await crashService.markAsCrashed(['1', '2'], 'queue-recovery');
expect(crashed).toEqual([first, second]);
expect(emitted()).toEqual([['executionsCrashed', { executions: [first, second] }]]);
});
test('announces each execution it transitioned with the detector, host and trace context', async () => {
const first = crashedExecution('1', {
tracingContext: { traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01' },
workflowVersionId: 'version-1',
retryOf: '9',
workflowCustomTelemetryTags: [{ key: 'env', value: 'production' }],
project: { id: 'project-1', customTelemetryTags: [{ key: 'team', value: 'platform' }] },
});
const second = crashedExecution('2', { mode: 'manual', startedAt: null });
transitions([first, second]);
await crashService.markAsCrashed(['1', '2'], 'queue-recovery');
expect(announced()).toEqual([
announcementOf(first, 'queue-recovery'),
announcementOf(second, 'queue-recovery'),
]);
});
test('reports each batch as it transitions, so a later failure keeps earlier ones counted', async () => {
const first = crashedExecution('1');
const second = crashedExecution('2');
@ -52,7 +123,9 @@ describe('ExecutionCrashService', () => {
return await Promise.reject(new Error('connection reset'));
});
await expect(crashService.markAsCrashed(['1', '2', '3'])).rejects.toThrow('connection reset');
await expect(crashService.markAsCrashed(['1', '2', '3'], 'start-failure')).rejects.toThrow(
'connection reset',
);
expect(emitted()).toEqual([
['executionsCrashed', { executions: [first] }],
@ -60,22 +133,46 @@ describe('ExecutionCrashService', () => {
]);
});
test('announces each batch as it transitions, so a later failure keeps earlier announcements', async () => {
const first = crashedExecution('1');
const second = crashedExecution('2');
executionRepository.markAsCrashed.mockImplementation(async (_ids, onBatchTransitioned) => {
onBatchTransitioned?.([first]);
onBatchTransitioned?.([second]);
return await Promise.reject(new Error('connection reset'));
});
await expect(crashService.markAsCrashed(['1', '2', '3'], 'start-failure')).rejects.toThrow(
'connection reset',
);
expect(announced()).toEqual([
announcementOf(first, 'start-failure'),
announcementOf(second, 'start-failure'),
]);
});
test('reports nothing when no execution transitioned', async () => {
transitions([]);
const crashed = await crashService.markAsCrashed(['1']);
const crashed = await crashService.markAsCrashed(['1'], 'queue-recovery');
expect(crashed).toEqual([]);
expect(workflowStatisticsService.emit).not.toHaveBeenCalled();
expect(eventService.emit).not.toHaveBeenCalled();
});
test('counts nothing when the caller counts the executions itself', async () => {
transitions([crashedExecution('1')]);
test('announces but counts nothing when the caller counts the executions itself', async () => {
const first = crashedExecution('1');
transitions([first]);
await crashService.markAsCrashedWithoutCounting('1');
const crashed = await crashService.markAsCrashedWithoutCounting('1', 'stall');
expect(executionRepository.markAsCrashed).toHaveBeenCalledWith('1');
expect(crashed).toEqual([first]);
expect(executionRepository.markAsCrashed).toHaveBeenCalledWith('1', expect.any(Function));
expect(workflowStatisticsService.emit).not.toHaveBeenCalled();
expect(announced()).toEqual([announcementOf(first, 'stall')]);
});
test('reports the executions it transitioned for a whole workflow', async () => {
@ -89,6 +186,19 @@ describe('ExecutionCrashService', () => {
expect(emitted()).toEqual([['executionsCrashed', { executions: [first, second] }]]);
});
test('announces the executions it transitioned for a whole workflow as a deactivation', async () => {
const first = crashedExecution('1');
const second = crashedExecution('2');
executionRepository.markWorkflowExecutionsAsCrashed.mockResolvedValue([first, second]);
await crashService.markWorkflowExecutionsAsCrashed('workflow-1');
expect(announced()).toEqual([
announcementOf(first, 'workflow-deactivation'),
announcementOf(second, 'workflow-deactivation'),
]);
});
test('reports nothing when a workflow has no execution to transition', async () => {
executionRepository.markWorkflowExecutionsAsCrashed.mockResolvedValue([]);
@ -96,5 +206,6 @@ describe('ExecutionCrashService', () => {
expect(crashed).toEqual([]);
expect(workflowStatisticsService.emit).not.toHaveBeenCalled();
expect(eventService.emit).not.toHaveBeenCalled();
});
});

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