diff --git a/.agents/skills/public-api/SKILL.md b/.agents/skills/public-api/SKILL.md index 8f526dcd3fe..1c33a19650a 100644 --- a/.agents/skills/public-api/SKILL.md +++ b/.agents/skills/public-api/SKILL.md @@ -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 diff --git a/.github/workflows/test-unit-reusable.yml b/.github/workflows/test-unit-reusable.yml index 01009defeb8..b36888225ed 100644 --- a/.github/workflows/test-unit-reusable.yml +++ b/.github/workflows/test-unit-reusable.yml @@ -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() }} diff --git a/packages/@n8n/agents/src/__tests__/integration/cassettes/runtime-skills/loaded-skill-drives-a-domain-tool-call.json b/packages/@n8n/agents/src/__tests__/integration/cassettes/runtime-skills/loaded-skill-drives-a-domain-tool-call.json index 7bc9463d6b5..171f11c0c03 100644 --- a/packages/@n8n/agents/src/__tests__/integration/cassettes/runtime-skills/loaded-skill-drives-a-domain-tool-call.json +++ b/packages/@n8n/agents/src/__tests__/integration/cassettes/runtime-skills/loaded-skill-drives-a-domain-tool-call.json @@ -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\": \"\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"\", \"filePath\": \"\" }`.\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\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" + "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\": \"\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"\", \"filePath\": \"\" }`.\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\": \"\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"\", \"filePath\": \"\" }`.\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\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" + "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\": \"\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"\", \"filePath\": \"\" }`.\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" }, diff --git a/packages/@n8n/agents/src/__tests__/integration/cassettes/runtime-skills/loads-a-matching-skill-and-follows-its-instructions-with-generate.json b/packages/@n8n/agents/src/__tests__/integration/cassettes/runtime-skills/loads-a-matching-skill-and-follows-its-instructions-with-generate.json index 4382b1f71e1..d473789a631 100644 --- a/packages/@n8n/agents/src/__tests__/integration/cassettes/runtime-skills/loads-a-matching-skill-and-follows-its-instructions-with-generate.json +++ b/packages/@n8n/agents/src/__tests__/integration/cassettes/runtime-skills/loads-a-matching-skill-and-follows-its-instructions-with-generate.json @@ -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\": \"\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"\", \"filePath\": \"\" }`.\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\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" + "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\": \"\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"\", \"filePath\": \"\" }`.\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" }, diff --git a/packages/@n8n/agents/src/__tests__/integration/cassettes/runtime-skills/loads-and-follows-a-skill-in-stream.json b/packages/@n8n/agents/src/__tests__/integration/cassettes/runtime-skills/loads-and-follows-a-skill-in-stream.json index 481a5c37b3f..4aa66be67b9 100644 --- a/packages/@n8n/agents/src/__tests__/integration/cassettes/runtime-skills/loads-and-follows-a-skill-in-stream.json +++ b/packages/@n8n/agents/src/__tests__/integration/cassettes/runtime-skills/loads-and-follows-a-skill-in-stream.json @@ -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\": \"\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"\", \"filePath\": \"\" }`.\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\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" + "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\": \"\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"\", \"filePath\": \"\" }`.\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" }, diff --git a/packages/@n8n/agents/src/runtime/__tests__/active-skills.test.ts b/packages/@n8n/agents/src/runtime/__tests__/active-skills.test.ts index 4a972d77cf6..7e357c0fe50 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/active-skills.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/active-skills.test.ts @@ -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 + * `` 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'); diff --git a/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts b/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts index e1ac01fca3e..3c2742b1f25 100644 --- a/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts +++ b/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts @@ -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; diff --git a/packages/@n8n/agents/src/runtime/model/message-list.ts b/packages/@n8n/agents/src/runtime/model/message-list.ts index 7741f935377..59b448a3346 100644 --- a/packages/@n8n/agents/src/runtime/model/message-list.ts +++ b/packages/@n8n/agents/src/runtime/model/message-list.ts @@ -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 `` 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--) { diff --git a/packages/@n8n/agents/src/runtime/skills/active-skills.ts b/packages/@n8n/agents/src/runtime/skills/active-skills.ts index 61e5e0aae1d..6e292302bb1 100644 --- a/packages/@n8n/agents/src/runtime/skills/active-skills.ts +++ b/packages/@n8n/agents/src/runtime/skills/active-skills.ts @@ -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 `` 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(); 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 { + async load( + skillId: string, + anchor?: { toolCallId: string }, + ): Promise { 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 [ '', '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(); + 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 `` 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): { + anchors: Map; + inBlock: string[]; + } { + const anchors = new Map(); + 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 { + const ids = new Set(); + 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 { const loads = new Map(); 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; + } +} diff --git a/packages/@n8n/agents/src/runtime/tools/tool-adapter.ts b/packages/@n8n/agents/src/runtime/tools/tool-adapter.ts index 6197c7f8538..41fe09bac1f 100644 --- a/packages/@n8n/agents/src/runtime/tools/tool-adapter.ts +++ b/packages/@n8n/agents/src/runtime/tools/tool-adapter.ts @@ -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, diff --git a/packages/@n8n/agents/src/skills/types.ts b/packages/@n8n/agents/src/skills/types.ts index e8d872153e7..b1ce440105c 100644 --- a/packages/@n8n/agents/src/skills/types.ts +++ b/packages/@n8n/agents/src/skills/types.ts @@ -137,7 +137,15 @@ export interface RuntimeSkillContent extends RuntimeSkillIndexEntry { linkedFiles?: RuntimeSkillLinkedFiles; } -export type RuntimeSkillLoader = (skillId: string) => Promise; +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; export interface RuntimeSkillFileContent { skillId: string; diff --git a/packages/@n8n/agents/src/types/sdk/message.ts b/packages/@n8n/agents/src/types/sdk/message.ts index c8e6510c3d5..71eeaebd081 100644 --- a/packages/@n8n/agents/src/types/sdk/message.ts +++ b/packages/@n8n/agents/src/types/sdk/message.ts @@ -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 } diff --git a/packages/@n8n/api-types/package.json b/packages/@n8n/api-types/package.json index a7850a52b5f..9a89f5667e1 100644 --- a/packages/@n8n/api-types/package.json +++ b/packages/@n8n/api-types/package.json @@ -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:*" }, diff --git a/packages/@n8n/api-types/src/agents/__tests__/agent-integration.schema.test.ts b/packages/@n8n/api-types/src/agents/__tests__/agent-integration.schema.test.ts index c52a3bbf160..9b89c367417 100644 --- a/packages/@n8n/api-types/src/agents/__tests__/agent-integration.schema.test.ts +++ b/packages/@n8n/api-types/src/agents/__tests__/agent-integration.schema.test.ts @@ -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); + }); + }); }); diff --git a/packages/@n8n/api-types/src/agents/agent-integration.schema.ts b/packages/@n8n/api-types/src/agents/agent-integration.schema.ts index d15b67e5242..8da7a1f187b 100644 --- a/packages/@n8n/api-types/src/agents/agent-integration.schema.ts +++ b/packages/@n8n/api-types/src/agents/agent-integration.schema.ts @@ -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; + +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, @@ -11,6 +32,7 @@ const createCredIntegrationSchema = < type: z.literal(typeName), credentialId: z.string().min(1), settings: settingsSchema, + approval, }); const createDraftCredIntegrationSchema = < @@ -24,6 +46,7 @@ const createDraftCredIntegrationSchema = < type: z.literal(typeName), credentialId: z.string(), settings: settingsSchema, + approval, }); export const AGENT_TELEGRAM_ACCESS_MODES = ['private', 'public'] as const; diff --git a/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts b/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts index 654175ad91c..e5101e3659b 100644 --- a/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts +++ b/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts @@ -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() diff --git a/packages/@n8n/api-types/src/agents/dto.ts b/packages/@n8n/api-types/src/agents/dto.ts index bdff7f62087..2668207bfe9 100644 --- a/packages/@n8n/api-types/src/agents/dto.ts +++ b/packages/@n8n/api-types/src/agents/dto.ts @@ -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({ diff --git a/packages/@n8n/api-types/src/agents/types.ts b/packages/@n8n/api-types/src/agents/types.ts index 1bf89f91112..61981679056 100644 --- a/packages/@n8n/api-types/src/agents/types.ts +++ b/packages/@n8n/api-types/src/agents/types.ts @@ -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`. */ diff --git a/packages/@n8n/api-types/src/dto/discover/discover-public.dto.ts b/packages/@n8n/api-types/src/dto/discover/discover-public.dto.ts new file mode 100644 index 00000000000..06860cc0679 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/discover/discover-public.dto.ts @@ -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; + +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 }, +) {} diff --git a/packages/@n8n/api-types/src/dto/discover/discover-public.openapi.ts b/packages/@n8n/api-types/src/dto/discover/discover-public.openapi.ts new file mode 100644 index 00000000000..2acdcf9c18b --- /dev/null +++ b/packages/@n8n/api-types/src/dto/discover/discover-public.openapi.ts @@ -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; + +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; diff --git a/packages/@n8n/api-types/src/dto/evaluations/__tests__/test-run-public.dto.test.ts b/packages/@n8n/api-types/src/dto/evaluations/__tests__/test-run-public.dto.test.ts new file mode 100644 index 00000000000..a2ab87145d6 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/evaluations/__tests__/test-run-public.dto.test.ts @@ -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'); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/evaluations/index.ts b/packages/@n8n/api-types/src/dto/evaluations/index.ts index 619ee6452a6..eb9852100e1 100644 --- a/packages/@n8n/api-types/src/dto/evaluations/index.ts +++ b/packages/@n8n/api-types/src/dto/evaluations/index.ts @@ -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'; diff --git a/packages/@n8n/api-types/src/dto/evaluations/test-run-public.dto.ts b/packages/@n8n/api-types/src/dto/evaluations/test-run-public.dto.ts new file mode 100644 index 00000000000..ebb3180b07c --- /dev/null +++ b/packages/@n8n/api-types/src/dto/evaluations/test-run-public.dto.ts @@ -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; + +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>().openapi( + testRunFieldDocs.metrics, + ), + errorCode: z.string().nullable(), + errorDetails: nullableObjectGuardSchema>().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; + +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(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/evaluations/test-run-public.openapi.ts b/packages/@n8n/api-types/src/dto/evaluations/test-run-public.openapi.ts new file mode 100644 index 00000000000..95a4635c185 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/evaluations/test-run-public.openapi.ts @@ -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; + +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; + +export const listTestRunsQueryFieldDocs = { + status: { description: 'Status to filter the test runs by.' }, +} as const satisfies Record; diff --git a/packages/@n8n/api-types/src/dto/folders/folder-public.dto.ts b/packages/@n8n/api-types/src/dto/folders/folder-public.dto.ts index 5de0a1e0bd8..990ebd83b5c 100644 --- a/packages/@n8n/api-types/src/dto/folders/folder-public.dto.ts +++ b/packages/@n8n/api-types/src/dto/folders/folder-public.dto.ts @@ -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 }, +) {} diff --git a/packages/@n8n/api-types/src/dto/folders/folder-public.openapi.ts b/packages/@n8n/api-types/src/dto/folders/folder-public.openapi.ts index 00bcefcb8c7..d07645e236a 100644 --- a/packages/@n8n/api-types/src/dto/folders/folder-public.openapi.ts +++ b/packages/@n8n/api-types/src/dto/folders/folder-public.openapi.ts @@ -23,10 +23,6 @@ export const folderProjectFieldDocs = { type: { readOnly: true, example: 'team' }, } as const satisfies Record; -/** - * `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; -/** - * 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; + +export const deleteFolderQueryFieldDocs = { + transferToFolderId: { + description: + 'Optional target folder ID to move workflows and sub-folders into before deleting.', + }, +} as const satisfies Record; diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index e56970dcdbb..f04eda3c84e 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -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, diff --git a/packages/@n8n/api-types/src/dto/invitation/invite-users-request.dto.ts b/packages/@n8n/api-types/src/dto/invitation/invite-users-request.dto.ts index e25e95c5e2d..6c25edadfdf 100644 --- a/packages/@n8n/api-types/src/dto/invitation/invite-users-request.dto.ts +++ b/packages/@n8n/api-types/src/dto/invitation/invite-users-request.dto.ts @@ -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'), }); diff --git a/packages/@n8n/api-types/src/dto/project/__tests__/project-public.dto.test.ts b/packages/@n8n/api-types/src/dto/project/__tests__/project-public.dto.test.ts index fe642e77eee..47067886c88 100644 --- a/packages/@n8n/api-types/src/dto/project/__tests__/project-public.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/project/__tests__/project-public.dto.test.ts @@ -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); diff --git a/packages/@n8n/api-types/src/dto/project/project-public.dto.ts b/packages/@n8n/api-types/src/dto/project/project-public.dto.ts index b70f1c83f92..ec93cae779e 100644 --- a/packages/@n8n/api-types/src/dto/project/project-public.dto.ts +++ b/packages/@n8n/api-types/src/dto/project/project-public.dto.ts @@ -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 }, diff --git a/packages/@n8n/api-types/src/dto/project/update-project.dto.ts b/packages/@n8n/api-types/src/dto/project/update-project.dto.ts index 96f8dbc3a4c..4a933537e6c 100644 --- a/packages/@n8n/api-types/src/dto/project/update-project.dto.ts +++ b/packages/@n8n/api-types/src/dto/project/update-project.dto.ts @@ -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) {} diff --git a/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-operations.dto.test.ts b/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-operations.dto.test.ts index 005ecada230..5bd0337432b 100644 --- a/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-operations.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-operations.dto.test.ts @@ -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) => { diff --git a/packages/@n8n/api-types/src/dto/promotions/promotion-binding-preflight.dto.ts b/packages/@n8n/api-types/src/dto/promotions/promotion-binding-preflight.dto.ts index 77e99f44c4c..8910b813992 100644 --- a/packages/@n8n/api-types/src/dto/promotions/promotion-binding-preflight.dto.ts +++ b/packages/@n8n/api-types/src/dto/promotions/promotion-binding-preflight.dto.ts @@ -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, diff --git a/packages/@n8n/api-types/src/dto/user/__tests__/create-users-public.dto.test.ts b/packages/@n8n/api-types/src/dto/user/__tests__/create-users-public.dto.test.ts new file mode 100644 index 00000000000..885a39e0edb --- /dev/null +++ b/packages/@n8n/api-types/src/dto/user/__tests__/create-users-public.dto.test.ts @@ -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); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/user/__tests__/delete-user-query-public.dto.test.ts b/packages/@n8n/api-types/src/dto/user/__tests__/delete-user-query-public.dto.test.ts new file mode 100644 index 00000000000..213e3884b09 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/user/__tests__/delete-user-query-public.dto.test.ts @@ -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'); + } + }); +}); diff --git a/packages/@n8n/api-types/src/dto/user/__tests__/get-user-query.dto.test.ts b/packages/@n8n/api-types/src/dto/user/__tests__/get-user-query.dto.test.ts new file mode 100644 index 00000000000..dbe6aaecc76 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/user/__tests__/get-user-query.dto.test.ts @@ -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); + } + }); +}); diff --git a/packages/@n8n/api-types/src/dto/user/__tests__/list-users-query.dto.test.ts b/packages/@n8n/api-types/src/dto/user/__tests__/list-users-query.dto.test.ts new file mode 100644 index 00000000000..a441e5cd5df --- /dev/null +++ b/packages/@n8n/api-types/src/dto/user/__tests__/list-users-query.dto.test.ts @@ -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'); + } + }); +}); diff --git a/packages/@n8n/api-types/src/dto/user/__tests__/user-public.dto.test.ts b/packages/@n8n/api-types/src/dto/user/__tests__/user-public.dto.test.ts new file mode 100644 index 00000000000..bac2785f8b5 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/user/__tests__/user-public.dto.test.ts @@ -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'); + } + }); +}); diff --git a/packages/@n8n/api-types/src/dto/user/create-users-public.dto.ts b/packages/@n8n/api-types/src/dto/user/create-users-public.dto.ts new file mode 100644 index 00000000000..beddc566f23 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/user/create-users-public.dto.ts @@ -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) {} diff --git a/packages/@n8n/api-types/src/dto/user/delete-user-query-public.dto.ts b/packages/@n8n/api-types/src/dto/user/delete-user-query-public.dto.ts new file mode 100644 index 00000000000..c2f2f2644e1 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/user/delete-user-query-public.dto.ts @@ -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.", + }), +}) {} diff --git a/packages/@n8n/api-types/src/dto/user/get-user-query.dto.ts b/packages/@n8n/api-types/src/dto/user/get-user-query.dto.ts new file mode 100644 index 00000000000..7d2ce274349 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/user/get-user-query.dto.ts @@ -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'), +}) {} diff --git a/packages/@n8n/api-types/src/dto/user/list-users-query.dto.ts b/packages/@n8n/api-types/src/dto/user/list-users-query.dto.ts new file mode 100644 index 00000000000..8f27d348133 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/user/list-users-query.dto.ts @@ -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(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/user/user-public.dto.ts b/packages/@n8n/api-types/src/dto/user/user-public.dto.ts new file mode 100644 index 00000000000..eb5ff17ed9a --- /dev/null +++ b/packages/@n8n/api-types/src/dto/user/user-public.dto.ts @@ -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', + }), +}) {} diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 5931319a1c6..b642ad2ce43 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -164,7 +164,10 @@ export { roleMappingRuleIdParamSchema, roleSlugParamSchema, tagIdParamSchema, + testRunIdParamSchema, userIdParamSchema, + userIdentifierParamSchema, + userUuidParamSchema, variableIdParamSchema, workflowIdParamSchema, workflowVersionIdParamSchema, diff --git a/packages/@n8n/api-types/src/schemas/project.schema.ts b/packages/@n8n/api-types/src/schemas/project.schema.ts index d1a3144a1c2..b6e120e3c23 100644 --- a/packages/@n8n/api-types/src/schemas/project.schema.ts +++ b/packages/@n8n/api-types/src/schemas/project.schema.ts @@ -20,3 +20,16 @@ export const projectRelationSchema = z.object({ role: assignableProjectRoleSchema, }); export type ProjectRelation = z.infer; + +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' }, +); diff --git a/packages/@n8n/api-types/src/schemas/public-api-path-params.schema.ts b/packages/@n8n/api-types/src/schemas/public-api-path-params.schema.ts index 04376c00c57..1c47d03c39b 100644 --- a/packages/@n8n/api-types/src/schemas/public-api-path-params.schema.ts +++ b/packages/@n8n/api-types/src/schemas/public-api-path-params.schema.ts @@ -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( diff --git a/packages/@n8n/backend-services/eslint.config.mjs b/packages/@n8n/backend-services/eslint.config.mjs index c9c5ad26bf3..766d97365e7 100644 --- a/packages/@n8n/backend-services/eslint.config.mjs +++ b/packages/@n8n/backend-services/eslint.config.mjs @@ -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.', + }, ], }, ], diff --git a/packages/@n8n/backend-services/oxlint.config.mts b/packages/@n8n/backend-services/oxlint.config.mts index c9ade75b8f7..a6060889413 100644 --- a/packages/@n8n/backend-services/oxlint.config.mts +++ b/packages/@n8n/backend-services/oxlint.config.mts @@ -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.', + }, ], }, ], diff --git a/packages/@n8n/constants/src/index.ts b/packages/@n8n/constants/src/index.ts index b3c5c30dd72..c27cf1589d2 100644 --- a/packages/@n8n/constants/src/index.ts +++ b/packages/@n8n/constants/src/index.ts @@ -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', diff --git a/packages/@n8n/db/src/repositories/__tests__/execution.repository.test.ts b/packages/@n8n/db/src/repositories/__tests__/execution.repository.test.ts index 5701203d96f..3c09676d07d 100644 --- a/packages/@n8n/db/src/repositories/__tests__/execution.repository.test.ts +++ b/packages/@n8n/db/src/repositories/__tests__/execution.repository.test.ts @@ -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(); 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({ + const crashedStartedAt = new Date('2025-01-01T00:00:00.000Z'); + + const crashableRow = (id: string, overrides: Partial = {}) => + Object.assign(new ExecutionEntity(), { id, workflowId: `workflow-${id}`, mode: 'trigger', + startedAt: crashedStartedAt, workflow: mock({ 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({ 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', () => { diff --git a/packages/@n8n/db/src/repositories/execution.repository.ts b/packages/@n8n/db/src/repositories/execution.repository.ts index fe815ec824f..a7daac1f73d 100644 --- a/packages/@n8n/db/src/repositories/execution.repository.ts +++ b/packages/@n8n/db/src/repositories/execution.repository.ts @@ -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 { 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 { /** Set the workflow's in-progress executions to `crashed`. */ async markWorkflowExecutionsAsCrashed(workflowId: string): Promise { - 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 { 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 { + 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(); diff --git a/packages/@n8n/db/src/repositories/index.ts b/packages/@n8n/db/src/repositories/index.ts index 9be493b5c3e..f4b95e4a518 100644 --- a/packages/@n8n/db/src/repositories/index.ts +++ b/packages/@n8n/db/src/repositories/index.ts @@ -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'; diff --git a/packages/@n8n/db/src/repositories/project.repository.ts b/packages/@n8n/db/src/repositories/project.repository.ts index fc3d41ae1ec..becb7a81971 100644 --- a/packages/@n8n/db/src/repositories/project.repository.ts +++ b/packages/@n8n/db/src/repositories/project.repository.ts @@ -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 { - constructor(dataSource: DataSource) { - super(Project, dataSource.manager); +export class ProjectRepository extends BaseRepository { + 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 & + Partial>, + creatorId: string, + limit: number, + ): Promise { + // 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) { diff --git a/packages/@n8n/eslint-config/src/plugin.ts b/packages/@n8n/eslint-config/src/plugin.ts index 0bb5f8b8c58..785572340b8 100644 --- a/packages/@n8n/eslint-config/src/plugin.ts +++ b/packages/@n8n/eslint-config/src/plugin.ts @@ -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', diff --git a/packages/@n8n/eslint-config/src/rules/index.ts b/packages/@n8n/eslint-config/src/rules/index.ts index 9c61a2eaeda..923b82624ed 100644 --- a/packages/@n8n/eslint-config/src/rules/index.ts +++ b/packages/@n8n/eslint-config/src/rules/index.ts @@ -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, diff --git a/packages/@n8n/eslint-config/src/rules/no-unsealed-credentials-entity-write.test.ts b/packages/@n8n/eslint-config/src/rules/no-unsealed-credentials-entity-write.test.ts new file mode 100644 index 00000000000..d619cdd0cce --- /dev/null +++ b/packages/@n8n/eslint-config/src/rules/no-unsealed-credentials-entity-write.test.ts @@ -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(id, { type: 'slackApi' });", errors }, + // A call in the receiver must not hide the type argument. + { code: "dataSource.manager.update(id, { type: 'slackApi' });", errors }, + { + code: "container.get(DataSource).manager.update(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 }, + ], +}); diff --git a/packages/@n8n/eslint-config/src/rules/no-unsealed-credentials-entity-write.ts b/packages/@n8n/eslint-config/src/rules/no-unsealed-credentials-entity-write.ts new file mode 100644 index 00000000000..29f5aeda152 --- /dev/null +++ b/packages/@n8n/eslint-config/src/rules/no-unsealed-credentials-entity-write.ts @@ -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.', +}); diff --git a/packages/@n8n/eslint-config/src/rules/no-unsealed-workflow-entity-write.test.ts b/packages/@n8n/eslint-config/src/rules/no-unsealed-workflow-entity-write.test.ts index d3015285282..c3560a9d42b 100644 --- a/packages/@n8n/eslint-config/src/rules/no-unsealed-workflow-entity-write.test.ts +++ b/packages/@n8n/eslint-config/src/rules/no-unsealed-workflow-entity-write.test.ts @@ -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(id, { nodes: [] });', errors }, + // A call in the receiver must not hide the type argument. + { + code: 'container.get(DataSource).manager.update(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, + }, ], }); diff --git a/packages/@n8n/eslint-config/src/rules/no-unsealed-workflow-entity-write.ts b/packages/@n8n/eslint-config/src/rules/no-unsealed-workflow-entity-write.ts index 8a34c3600b0..5b8557aeb66 100644 --- a/packages/@n8n/eslint-config/src/rules/no-unsealed-workflow-entity-write.ts +++ b/packages/@n8n/eslint-config/src/rules/no-unsealed-workflow-entity-write.ts @@ -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.', }); diff --git a/packages/@n8n/eslint-config/src/rules/unsealed-entity-write.factory.ts b/packages/@n8n/eslint-config/src/rules/unsealed-entity-write.factory.ts new file mode 100644 index 00000000000..1b36a9b33d1 --- /dev/null +++ b/packages/@n8n/eslint-config/src/rules/unsealed-entity-write.factory.ts @@ -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' }); + } + } + }, + }; + }, + }); +}; diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/V2/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/V2/execute.ts index 93efa944e89..af3c47ad695 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/V2/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/V2/execute.ts @@ -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, diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/V3/helpers/runAgent.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/V3/helpers/runAgent.ts index 854e55b9921..b4688944ec6 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/V3/helpers/runAgent.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/V3/helpers/runAgent.ts @@ -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; diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/V3/helpers/tests/runAgent.test.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/V3/helpers/tests/runAgent.test.ts index 60a0883b5c6..449a7072026 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/V3/helpers/tests/runAgent.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/V3/helpers/tests/runAgent.test.ts @@ -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({ diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/utils.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/utils.ts index 7b340caf759..5b3b2f44ee9 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/utils.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/utils.ts @@ -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( diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/test/ToolsAgent/ToolsAgentV2.test.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/test/ToolsAgent/ToolsAgentV2.test.ts index 4cdb14d2128..178d0b41be2 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/test/ToolsAgent/ToolsAgentV2.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/test/ToolsAgent/ToolsAgentV2.test.ts @@ -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(), diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/test/ToolsAgent/ToolsAgentV3.test.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/test/ToolsAgent/ToolsAgentV3.test.ts index 58c64a7e427..644ae73740c 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/test/ToolsAgent/ToolsAgentV3.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/test/ToolsAgent/ToolsAgentV3.test.ts @@ -58,29 +58,20 @@ function withCommonFields(ctx: MockProxy, 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 { const ctx = mock(); 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); diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/test/utils.test.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/test/utils.test.ts index 2c264d6f322..35585be60df 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/test/utils.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/test/utils.test.ts @@ -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); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreDatabricks/DatabricksVectorStore.test.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreDatabricks/DatabricksVectorStore.test.ts index d1e8f9ebaed..cf2bf247779 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreDatabricks/DatabricksVectorStore.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreDatabricks/DatabricksVectorStore.test.ts @@ -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(); diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreDatabricks/DatabricksVectorStore.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreDatabricks/DatabricksVectorStore.ts index 3554ad36023..897f9ebc6cb 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreDatabricks/DatabricksVectorStore.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreDatabricks/DatabricksVectorStore.ts @@ -311,6 +311,12 @@ export class DatabricksVectorStore extends VectorStore { ): Promise { 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()); diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreDatabricks/VectorStoreDatabricks.node.test.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreDatabricks/VectorStoreDatabricks.node.test.ts new file mode 100644 index 00000000000..1dd4e6b867c --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreDatabricks/VectorStoreDatabricks.node.test.ts @@ -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 + >; + let embeddings: ReturnType>; + + const setupContext = ( + params: IDataObject, + credentialOverrides: Partial = {}, + ) => { + const ctx = createMockExecuteFunction(params, nodeDef) as Mocked; + 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(); + 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({ + ...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({ ...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({ ...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( + { ...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({ ...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({ ...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({ + ...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({ + ...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({ + ...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({ ...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 = { + 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; + let ctx: ILoadOptionsFunctions; + + const setupSearchContext = (host: string) => { + httpRequestWithAuthentication = vi.fn( + async (_type: string, options: { url: string; qs: Record }) => + 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( + {}, + nodeDef, + ) as Mocked; + 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(); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreDatabricks/VectorStoreDatabricks.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreDatabricks/VectorStoreDatabricks.node.ts new file mode 100644 index 00000000000..53c12f34e6a --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreDatabricks/VectorStoreDatabricks.node.ts @@ -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 expression.', + }, + { + 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 expression.', + }, + { + 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>; +} + +// 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 { + const credential = await ctx.getCredentials( + 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>(); + +async function databricksFetch(ctx: DatabricksFetchContext): Promise { + 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, +): Promise { + 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 { + const credentials = await this.getCredentials( + 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 { + 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({ + 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); + }, +}) {} diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index 472ad885a21..f6195124164 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -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", diff --git a/packages/@n8n/nodes-langchain/utils/databricks/auth-fetch.ts b/packages/@n8n/nodes-langchain/utils/databricks/auth-fetch.ts new file mode 100644 index 00000000000..4b7a92d7f6d --- /dev/null +++ b/packages/@n8n/nodes-langchain/utils/databricks/auth-fetch.ts @@ -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 }; +} diff --git a/packages/@n8n/nodes-langchain/utils/databricks/constants.ts b/packages/@n8n/nodes-langchain/utils/databricks/constants.ts index d99fa4bfa85..8de196ee060 100644 --- a/packages/@n8n/nodes-langchain/utils/databricks/constants.ts +++ b/packages/@n8n/nodes-langchain/utils/databricks/constants.ts @@ -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'); } diff --git a/packages/@n8n/nodes-langchain/utils/databricks/gateway-config.ts b/packages/@n8n/nodes-langchain/utils/databricks/gateway-config.ts index ae14d23c4e8..fb90606b195 100644 --- a/packages/@n8n/nodes-langchain/utils/databricks/gateway-config.ts +++ b/packages/@n8n/nodes-langchain/utils/databricks/gateway-config.ts @@ -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, diff --git a/packages/@n8n/nodes-langchain/utils/databricks/list-pages.ts b/packages/@n8n/nodes-langchain/utils/databricks/list-pages.ts new file mode 100644 index 00000000000..87ad1a62d74 --- /dev/null +++ b/packages/@n8n/nodes-langchain/utils/databricks/list-pages.ts @@ -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

( + ctx: ILoadOptionsFunctions, + url: string, + qs: Record, + pick: (page: P) => T[] | undefined, +): Promise { + 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; +} diff --git a/packages/@n8n/nodes-langchain/utils/databricks/model-services.ts b/packages/@n8n/nodes-langchain/utils/databricks/model-services.ts index 669b5077e69..77e9ed93cb7 100644 --- a/packages/@n8n/nodes-langchain/utils/databricks/model-services.ts +++ b/packages/@n8n/nodes-langchain/utils/databricks/model-services.ts @@ -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 => { - 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 => + await listDatabricksPages( + 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 { diff --git a/packages/@n8n/nodes-langchain/utils/databricks/token-provider.ts b/packages/@n8n/nodes-langchain/utils/databricks/token-provider.ts index 8bb43fd87fe..e40dc9b6062 100644 --- a/packages/@n8n/nodes-langchain/utils/databricks/token-provider.ts +++ b/packages/@n8n/nodes-langchain/utils/databricks/token-provider.ts @@ -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 { diff --git a/packages/@n8n/oxlint-config/package.json b/packages/@n8n/oxlint-config/package.json index fa85f0c6d3d..0d8b33ed50e 100644 --- a/packages/@n8n/oxlint-config/package.json +++ b/packages/@n8n/oxlint-config/package.json @@ -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:" diff --git a/packages/@n8n/oxlint-config/src/configs/base.ts b/packages/@n8n/oxlint-config/src/configs/base.ts index 027c73efdfb..87d45f17ae3 100644 --- a/packages/@n8n/oxlint-config/src/configs/base.ts +++ b/packages/@n8n/oxlint-config/src/configs/base.ts @@ -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. diff --git a/packages/@n8n/oxlint-config/src/import-x-alias.ts b/packages/@n8n/oxlint-config/src/import-x-alias.ts new file mode 100644 index 00000000000..89d7bc6155d --- /dev/null +++ b/packages/@n8n/oxlint-config/src/import-x-alias.ts @@ -0,0 +1,19 @@ +import importXPlugin from 'eslint-plugin-import-x'; + +interface OxlintJsPlugin { + meta: { + name: string; + version?: string; + }; + rules: Record; +} + +const importXAlias: OxlintJsPlugin = { + ...importXPlugin, + meta: { + ...importXPlugin.meta, + name: 'import-x-alias', + }, +}; + +export default importXAlias; diff --git a/packages/@n8n/telemetry/src/events/node-type-policies.ts b/packages/@n8n/telemetry/src/events/type-availability-policies.ts similarity index 71% rename from packages/@n8n/telemetry/src/events/node-type-policies.ts rename to packages/@n8n/telemetry/src/events/type-availability-policies.ts index 654ea5d0b8e..f7b8c3ee444 100644 --- a/packages/@n8n/telemetry/src/events/node-type-policies.ts +++ b/packages/@n8n/telemetry/src/events/type-availability-policies.ts @@ -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({ diff --git a/packages/@n8n/telemetry/src/index.ts b/packages/@n8n/telemetry/src/index.ts index d1fac8dced0..34e7fa9e6d1 100644 --- a/packages/@n8n/telemetry/src/index.ts +++ b/packages/@n8n/telemetry/src/index.ts @@ -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'; diff --git a/packages/@n8n/telemetry/src/telemetry-events.ts b/packages/@n8n/telemetry/src/telemetry-events.ts index 4a71dd1bc9e..b53e27eec1f 100644 --- a/packages/@n8n/telemetry/src/telemetry-events.ts +++ b/packages/@n8n/telemetry/src/telemetry-events.ts @@ -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; diff --git a/packages/cli/eslint.config.mjs b/packages/cli/eslint.config.mjs index f925c739187..fba891470fa 100644 --- a/packages/cli/eslint.config.mjs +++ b/packages/cli/eslint.config.mjs @@ -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: { diff --git a/packages/cli/eslint.guardrails.config.mjs b/packages/cli/eslint.guardrails.config.mjs deleted file mode 100644 index 805beea2253..00000000000 --- a/packages/cli/eslint.guardrails.config.mjs +++ /dev/null @@ -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, -); diff --git a/packages/cli/oxlint.config.mts b/packages/cli/oxlint.config.mts index 2159eb59688..434380ca284 100644 --- a/packages/cli/oxlint.config.mts +++ b/packages/cli/oxlint.config.mts @@ -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'], diff --git a/packages/cli/package.json b/packages/cli/package.json index 915d5bfad5a..13b3553eddb 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/cli/src/__tests__/credentials-helper.test.ts b/packages/cli/src/__tests__/credentials-helper.test.ts index 563df922186..46da2ed569c 100644 --- a/packages/cli/src/__tests__/credentials-helper.test.ts +++ b/packages/cli/src/__tests__/credentials-helper.test.ts @@ -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 diff --git a/packages/cli/src/__tests__/workflow-runner.test.ts b/packages/cli/src/__tests__/workflow-runner.test.ts index 860e88a4ea7..7659955fe81 100644 --- a/packages/cli/src/__tests__/workflow-runner.test.ts +++ b/packages/cli/src/__tests__/workflow-runner.test.ts @@ -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); }, diff --git a/packages/cli/src/constants/__tests__/credential-sharing.test.ts b/packages/cli/src/constants/__tests__/credential-sharing.test.ts new file mode 100644 index 00000000000..079694939fd --- /dev/null +++ b/packages/cli/src/constants/__tests__/credential-sharing.test.ts @@ -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); + }, + ); +}); diff --git a/packages/cli/src/constants/credential-sharing.ts b/packages/cli/src/constants/credential-sharing.ts new file mode 100644 index 00000000000..46e3e8b5363 --- /dev/null +++ b/packages/cli/src/constants/credential-sharing.ts @@ -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'); +} diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index b926a24d872..3a180043add 100644 --- a/packages/cli/src/controllers/e2e.controller.ts +++ b/packages/cli/src/controllers/e2e.controller.ts @@ -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, diff --git a/packages/cli/src/credentials-helper.ts b/packages/cli/src/credentials-helper.ts index c89e4b34176..b673a0753e5 100644 --- a/packages/cli/src/credentials-helper.ts +++ b/packages/cli/src/credentials-helper.ts @@ -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 = { + 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 = { + data: credentials.getDataToSave().data, + updatedAt: new Date(), + }; // Save the credentials in DB const findQuery = { diff --git a/packages/cli/src/eventbus/message-event-bus/message-event-bus.ts b/packages/cli/src/eventbus/message-event-bus/message-event-bus.ts index 7200a25d3a6..2652712e2a1 100644 --- a/packages/cli/src/eventbus/message-event-bus/message-event-bus.ts +++ b/packages/cli/src/eventbus/message-event-bus/message-event-bus.ts @@ -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 diff --git a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts index bd6e2622c24..eeecd61b482 100644 --- a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts @@ -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(); const dynamicCredentialsProxy = mock(); const dbConnection = mock(); + const loadNodesAndCredentials = mock(); 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; + 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' }), ); }); diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index 6f350918b2b..166d447aee3 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -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[]; diff --git a/packages/cli/src/events/relays/telemetry.event-relay.ts b/packages/cli/src/events/relays/telemetry.event-relay.ts index e4a3f359bb7..56ffff3ad31 100644 --- a/packages/cli/src/events/relays/telemetry.event-relay.ts +++ b/packages/cli/src/events/relays/telemetry.event-relay.ts @@ -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, diff --git a/packages/cli/src/executions/__tests__/enqueued-execution-recovery.service.test.ts b/packages/cli/src/executions/__tests__/enqueued-execution-recovery.service.test.ts index da599208c43..ce10732f402 100644 --- a/packages/cli/src/executions/__tests__/enqueued-execution-recovery.service.test.ts +++ b/packages/cli/src/executions/__tests__/enqueued-execution-recovery.service.test.ts @@ -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, diff --git a/packages/cli/src/executions/__tests__/execution-crash.service.test.ts b/packages/cli/src/executions/__tests__/execution-crash.service.test.ts index ee3c7727fca..09d657741f7 100644 --- a/packages/cli/src/executions/__tests__/execution-crash.service.test.ts +++ b/packages/cli/src/executions/__tests__/execution-crash.service.test.ts @@ -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(); const workflowStatisticsService = mock(); + const eventService = mock(); + const instanceSettings = mock({ hostId: 'main-1' }); - const crashService = new ExecutionCrashService(executionRepository, workflowStatisticsService); + const crashService = new ExecutionCrashService( + executionRepository, + workflowStatisticsService, + eventService, + instanceSettings, + ); - const crashedExecution = (id: string, overrides: Partial = {}) => ({ + 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 => ({ 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(); }); }); diff --git a/packages/cli/src/executions/__tests__/execution-recovery.service.integration.test.ts b/packages/cli/src/executions/__tests__/execution-recovery.service.integration.test.ts index 4f974d4d847..7337f6b811c 100644 --- a/packages/cli/src/executions/__tests__/execution-recovery.service.integration.test.ts +++ b/packages/cli/src/executions/__tests__/execution-recovery.service.integration.test.ts @@ -78,7 +78,7 @@ describe('ExecutionRecoveryService', () => { ownershipService, projectRelationRepository, workflowPushNotifier, - new ExecutionCrashService(executionRepository, mock()), + new ExecutionCrashService(executionRepository, mock(), mock(), instanceSettings), ); }); diff --git a/packages/cli/src/executions/enqueued-execution-recovery.service.ts b/packages/cli/src/executions/enqueued-execution-recovery.service.ts index 0b4dea17935..3d77429834a 100644 --- a/packages/cli/src/executions/enqueued-execution-recovery.service.ts +++ b/packages/cli/src/executions/enqueued-execution-recovery.service.ts @@ -54,7 +54,7 @@ export class EnqueuedExecutionRecoveryService { this.logger.warn('Crashing enqueued executions with unreadable data', { executionIds: unreadableIds, }); - await this.executionCrashService.markAsCrashed(unreadableIds); + await this.executionCrashService.markAsCrashed(unreadableIds, 'start-failure'); } if (executions.length === 0) return; @@ -101,6 +101,6 @@ export class EnqueuedExecutionRecoveryService { this.errorReporter.error(error, { executionId, shouldBeLogged: false }); this.logger.error('Failed to run enqueued execution', { executionId, error }); - await this.executionCrashService.markAsCrashed(executionId); + await this.executionCrashService.markAsCrashed(executionId, 'start-failure'); } } diff --git a/packages/cli/src/executions/execution-crash.service.ts b/packages/cli/src/executions/execution-crash.service.ts index 93fd8143467..e1260d628ff 100644 --- a/packages/cli/src/executions/execution-crash.service.ts +++ b/packages/cli/src/executions/execution-crash.service.ts @@ -1,36 +1,54 @@ import type { CrashedExecution } from '@n8n/db'; import { ExecutionRepository } from '@n8n/db'; import { Service } from '@n8n/di'; +import { InstanceSettings } from 'n8n-core'; +import { EventService } from '@/events/event.service'; +import type { CrashDetector } from '@/events/maps/relay.event-map'; import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; /** * Marks executions as `crashed`. A crash transition runs no execution lifecycle - * hooks, so the workflow statistics are counted from here instead. + * hooks, so the workflow statistics are counted from here instead, and every + * claimed row is announced as `execution-crashed` from here. */ @Service() export class ExecutionCrashService { constructor( private readonly executionRepository: ExecutionRepository, private readonly workflowStatisticsService: WorkflowStatisticsService, + private readonly eventService: EventService, + private readonly instanceSettings: InstanceSettings, ) {} - async markAsCrashed(executionIds: string | string[]): Promise { - return await this.executionRepository.markAsCrashed(executionIds, (batch) => this.count(batch)); + async markAsCrashed( + executionIds: string | string[], + detector: CrashDetector, + ): Promise { + return await this.executionRepository.markAsCrashed(executionIds, (batch) => { + this.count(batch); + this.announce(batch, detector); + }); } /** - * Mark executions as `crashed` without counting them. For a caller that fires - * `workflowExecuteAfter` for the same executions, which counts them itself. + * Claim and announce without counting, for a caller that runs `workflowExecuteAfter` + * for the same executions, which counts them itself. */ - async markAsCrashedWithoutCounting(executionIds: string | string[]): Promise { - await this.executionRepository.markAsCrashed(executionIds); + async markAsCrashedWithoutCounting( + executionIds: string | string[], + detector: CrashDetector, + ): Promise { + return await this.executionRepository.markAsCrashed(executionIds, (batch) => + this.announce(batch, detector), + ); } async markWorkflowExecutionsAsCrashed(workflowId: string): Promise { const crashed = await this.executionRepository.markWorkflowExecutionsAsCrashed(workflowId); this.count(crashed); + this.announce(crashed, 'workflow-deactivation'); return crashed; } @@ -40,4 +58,36 @@ export class ExecutionCrashService { this.workflowStatisticsService.emit('executionsCrashed', { executions }); } + + private announce(executions: CrashedExecution[], detector: CrashDetector) { + for (const { + id, + workflowId, + workflowName, + workflowVersionId, + mode, + retryOf, + startedAt, + stoppedAt, + tracingContext, + workflowCustomTelemetryTags, + project, + } of executions) { + this.eventService.emit('execution-crashed', { + executionId: id, + workflowId, + workflowName, + workflowVersionId, + mode, + retryOf, + startedAt: startedAt ?? undefined, + stoppedAt, + detector, + hostId: this.instanceSettings.hostId, + tracingContext, + workflowCustomTelemetryTags, + project, + }); + } + } } diff --git a/packages/cli/src/executions/execution-recovery.service.ts b/packages/cli/src/executions/execution-recovery.service.ts index b80a0cd37d0..69e711de4b8 100644 --- a/packages/cli/src/executions/execution-recovery.service.ts +++ b/packages/cli/src/executions/execution-recovery.service.ts @@ -236,7 +236,7 @@ export class ExecutionRecoveryService { if (!exists) return null; - await this.executionCrashService.markAsCrashedWithoutCounting(executionId); + await this.executionCrashService.markAsCrashedWithoutCounting(executionId, 'startup-recovery'); const execution = await this.executionPersistence.findSingleExecution(executionId, { includeData: true, diff --git a/packages/cli/src/modules/agent-evals/__tests__/agent-eval-case-generation.service.test.ts b/packages/cli/src/modules/agent-evals/__tests__/agent-eval-case-generation.service.test.ts index c80abe9035f..7f10ff1ab0f 100644 --- a/packages/cli/src/modules/agent-evals/__tests__/agent-eval-case-generation.service.test.ts +++ b/packages/cli/src/modules/agent-evals/__tests__/agent-eval-case-generation.service.test.ts @@ -19,7 +19,9 @@ import type { AgentEvalsFlagGate } from '../agent-evals-flag-gate'; // Stub the @n8n/agents SDK: fluent builder is a no-op; `generate` is a // controllable mock so tests drive the model's (in)valid structured output. const { generateMock } = vi.hoisted(() => ({ generateMock: vi.fn() })); -vi.mock('@n8n/agents', () => ({ +vi.mock('@n8n/agents', async (importOriginal) => ({ + // Channel action tools import APPROVAL_* schemas from the SDK; keep those real. + ...(await importOriginal()), Agent: class { model() { return this; diff --git a/packages/cli/src/modules/agents/__tests__/agent-chat.controller.test.ts b/packages/cli/src/modules/agents/__tests__/agent-chat.controller.test.ts index 453355bf670..02382ba63c6 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-chat.controller.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-chat.controller.test.ts @@ -19,6 +19,7 @@ import type { AgentChatExecutionService } from '../agent-chat-execution.service' import type { AgentValidationService } from '../agent-validation.service'; import type { AgentBackgroundJobService } from '../background/agent-background-job.service'; import type { AgentExecutionThread } from '../entities/agent-execution-thread.entity'; +import type { AgentChatAttachment } from '../entities/agent-chat-attachment.entity'; import type { FlushableResponse } from '../agent-sse-stream'; import type { AgentTestChatService } from '../agent-test-chat.service'; import { AgentTestRunService } from '../agent-test-run.service'; @@ -36,6 +37,15 @@ function makeController() { const agentExecutionOrchestratorService = mock(); const agentsBuilderService = mock(); const agentChatAttachmentService = mock(); + agentChatAttachmentService.deleteByIds.mockResolvedValue(undefined); + agentChatAttachmentService.storeInbound.mockResolvedValue( + mock({ + id: 'att-1', + fileName: 'notes.txt', + mimeType: 'text/plain', + fileSizeBytes: 5, + }), + ); const agentExecutionService = mock(); const agentValidationService = mock(); const backgroundJobService = mock(); @@ -429,7 +439,18 @@ describe('AgentChatController SSE done payload', () => { { params: { projectId: 'project-1' }, user: { id: 'user-1' } } as never, res, 'agent-1', - { message: 'hi', sessionId: 'thread-1' } as never, + { + message: 'hi', + sessionId: 'thread-1', + newSession: true, + attachments: [ + { + fileName: 'notes.txt', + mimeType: 'text/plain', + data: Buffer.from('hello').toString('base64'), + }, + ], + } as never, ), done: { type: 'done', sessionId: 'thread-1', executionId: 'exec-99' }, }, @@ -449,7 +470,11 @@ describe('AgentChatController SSE done payload', () => { it('does not start a chat after the response closes during preparation', async () => { const { controller, agentTestRunService, agentExecutionOrchestratorService } = makeController(); - let resolvePreparation = (_value: { status: 'ready'; sessionId: string }) => {}; + let resolvePreparation = (_value: { + status: 'ready'; + sessionId: string; + sessionMode: 'new'; + }) => {}; const prepareDraftRun = vi.spyOn(agentTestRunService, 'prepareDraftRun').mockReturnValue( new Promise((resolve) => { resolvePreparation = resolve; @@ -466,14 +491,15 @@ describe('AgentChatController SSE done payload', () => { await vi.waitFor(() => expect(prepareDraftRun).toHaveBeenCalled()); (res as unknown as EventEmitter).emit('close'); - resolvePreparation({ status: 'ready', sessionId: 'thread-1' }); + resolvePreparation({ status: 'ready', sessionId: 'thread-1', sessionMode: 'new' }); await request; expect(agentExecutionOrchestratorService.executeForChat).not.toHaveBeenCalled(); }); it.each(operations)('sends done after the $name settles', async ({ method, start, done }) => { - const { controller, agentExecutionOrchestratorService } = makeController(); + const { controller, agentExecutionService, agentExecutionOrchestratorService } = + makeController(); const finalization = createDeferredPromise(); const finalizationStarted = createDeferredPromise(); let receivedSignal: AbortSignal | undefined; @@ -505,6 +531,21 @@ describe('AgentChatController SSE done payload', () => { sessionId: 'thread-1', }); expect(events).toContainEqual(done); + if (method === 'executeForChat') { + expect(agentExecutionService.canUseDraftThread).toHaveBeenCalledWith( + 'thread-1', + 'project-1', + 'agent-1', + 'user-1', + { previewChat: true, sessionMode: 'new' }, + ); + expect(agentExecutionOrchestratorService.executeForChat).toHaveBeenCalledWith( + expect.objectContaining({ + sessionMode: 'new', + attachments: [expect.objectContaining({ id: 'att-1' })], + }), + ); + } expect(res.end).toHaveBeenCalledOnce(); res.emit('close'); expect(receivedSignal?.aborted).toBe(false); @@ -778,27 +819,35 @@ describe('AgentChatController attachment cleanup on failed turns', () => { expect(agentExecutionOrchestratorService.executeForChat).not.toHaveBeenCalled(); }); - it('rejects an ineligible Preview session before saving an upload or starting a runtime', async () => { - const { - controller, - agentExecutionService, - agentChatAttachmentService, - agentExecutionOrchestratorService, - } = makeController(); - agentExecutionService.canUseDraftThread.mockImplementation( - async (_threadId, _projectId, _agentId, _userId, options) => !options?.previewChat, - ); - const { res, events } = makeCleanupSseResponse(); - await controller.chat( - { params: { projectId: 'project-1' }, user: { id: 'user-2' } } as never, - res, - 'agent-1', - { sessionId: 'thread-1', message: 'hi', attachments: [textAttachment('notes.txt')] } as never, - ); - expect(events()).toContainEqual(expect.objectContaining({ type: 'error' })); - expect(agentChatAttachmentService.storeInbound).not.toHaveBeenCalled(); - expect(agentExecutionOrchestratorService.executeForChat).not.toHaveBeenCalled(); - }); + it.each([undefined, true])( + 'rejects an ineligible Preview session before uploading with newSession=%s', + async (newSession) => { + const { + controller, + agentExecutionService, + agentChatAttachmentService, + agentExecutionOrchestratorService, + } = makeController(); + agentExecutionService.canUseDraftThread.mockImplementation( + async (_threadId, _projectId, _agentId, _userId, options) => !options?.previewChat, + ); + const { res, events } = makeCleanupSseResponse(); + await controller.chat( + { params: { projectId: 'project-1' }, user: { id: 'user-2' } } as never, + res, + 'agent-1', + { + sessionId: 'thread-1', + newSession, + message: 'hi', + attachments: [textAttachment('notes.txt')], + } as never, + ); + expect(events()).toContainEqual(expect.objectContaining({ type: 'error' })); + expect(agentChatAttachmentService.storeInbound).not.toHaveBeenCalled(); + expect(agentExecutionOrchestratorService.executeForChat).not.toHaveBeenCalled(); + }, + ); it('rejects an empty attachment with a dedicated error message', async () => { const { controller, agentChatAttachmentService } = makeController(); const { res, events } = makeCleanupSseResponse(); diff --git a/packages/cli/src/modules/agents/__tests__/agent-checkpoint-pruning.task.test.ts b/packages/cli/src/modules/agents/__tests__/agent-checkpoint-pruning.task.test.ts index f6d047dd26f..9a9f9e055c5 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-checkpoint-pruning.task.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-checkpoint-pruning.task.test.ts @@ -11,7 +11,7 @@ describe('AgentCheckpointPruningTask', () => { expect(task.name).toBe('agent-checkpoint-pruning'); expect(task.schedule).toEqual({ kind: 'interval', intervalSeconds: 3600 }); expect(task.effects).toBe('idempotent'); - expect(task.durable).toBe(false); + expect(task.durable).toBe(true); expect(task.runOnTakeover).toBe(true); expect(task.retryDelaySeconds).toBe(30); }); diff --git a/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts index 06d079bb41e..00227f94c65 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts @@ -42,6 +42,7 @@ import { AgentTestRunService } from '../agent-test-run.service'; import { AgentTurnExecutionService } from '../agent-turn-execution.service'; import type { AgentValidationService } from '../agent-validation.service'; import type { Agent } from '../entities/agent.entity'; +import type { AgentExecutionThread } from '../entities/agent-execution-thread.entity'; import type { AgentRepository } from '../repositories/agent.repository'; import { encodeAgentSandboxHostMetadata, @@ -217,6 +218,13 @@ function makeService(sandboxEnabled = false) { Container.set(AgentWakeService, wakeService); executionService.canUseDraftThread.mockResolvedValue(true); + executionService.findThreadById.mockResolvedValue({ + id: 'thread-1', + projectId, + agentId, + accessScope: 'project', + ownerId: null, + } as never); executionService.startExecutionRecording.mockResolvedValue('execution-1'); executionService.finalizeExecution.mockResolvedValue('execution-1'); agentRunTracingService.build.mockResolvedValue(undefined); @@ -331,6 +339,7 @@ describe('AgentExecutionOrchestratorService', () => { user, message: 'hello', memory: { threadId: 'thread-1', resourceId: 'draft-chat:user-1' }, + sessionMode: 'existing', onExecutionRecorded, ...(announceExecution ? { onExecutionStarted } : {}), previewChat, @@ -518,6 +527,10 @@ describe('AgentExecutionOrchestratorService', () => { ); } expect(executionService.startExecutionRecording).toHaveBeenCalledOnce(); + expect(executionService.startExecutionRecording).toHaveBeenCalledWith( + expect.objectContaining({ sessionMode: 'existing' }), + expect.any(Date), + ); }, ); @@ -1569,54 +1582,59 @@ describe('AgentExecutionOrchestratorService', () => { }, ); - it('records a failed session and rethrows when the published runtime cannot be built', async () => { - const { service, runtimeCacheService, executionService, agentRepository } = makeService(); - const buildError = new UserError('Credential "OpenAI" not found'); - runtimeCacheService.getRuntime.mockRejectedValue(buildError); - // A plain object: `mock()` proxies nested fields, which breaks the - // telemetry builder's array handling of `schema`. - agentRepository.findByIdAndProjectId.mockResolvedValue({ - id: agentId, - name: 'Support Agent (draft)', - schema: { ...schema, name: 'Support Agent (draft)' }, - activeVersion: { schema }, - integrations: [], - } as unknown as Agent); + it.each(['new', 'existing'] as const)( + 'preserves %s intent when recording a failed runtime build', + async (sessionMode) => { + const { service, runtimeCacheService, executionService, agentRepository } = makeService(); + const buildError = new UserError('Credential "OpenAI" not found'); + runtimeCacheService.getRuntime.mockRejectedValue(buildError); + // A plain object: `mock()` proxies nested fields, which breaks the + // telemetry builder's array handling of `schema`. + agentRepository.findByIdAndProjectId.mockResolvedValue({ + id: agentId, + name: 'Support Agent (draft)', + schema: { ...schema, name: 'Support Agent (draft)' }, + activeVersion: { schema }, + integrations: [], + } as unknown as Agent); - await expect( - collect( - service.executeForChatPublished({ + await expect( + collect( + service.executeForChatPublished({ + agentId, + projectId, + message: 'from slack', + memory: { threadId: 'thread-1', resourceId: 'platform-user-1' }, + sessionMode, + integrationType: 'slack', + sandboxPrincipalHash: integrationPrincipalHash, + }), + ), + ).rejects.toBe(buildError); + + expect(executionService.startExecutionRecording).toHaveBeenCalledWith( + expect.objectContaining({ agentId, - projectId, - message: 'from slack', - memory: { threadId: 'thread-1', resourceId: 'platform-user-1' }, - integrationType: 'slack', - sandboxPrincipalHash: integrationPrincipalHash, + agentName: 'Support Agent', + threadId: 'thread-1', + userMessage: 'from slack', + source: 'slack', + sessionMode, + telemetry: expect.objectContaining({ runType: 'production' }), }), - ), - ).rejects.toBe(buildError); - - expect(executionService.startExecutionRecording).toHaveBeenCalledWith( - expect.objectContaining({ - agentId, - agentName: 'Support Agent', - threadId: 'thread-1', - userMessage: 'from slack', - source: 'slack', - telemetry: expect.objectContaining({ runType: 'production' }), - }), - expect.any(Date), - ); - expect(executionService.finalizeExecution).toHaveBeenCalledWith( - 'execution-1', - expect.objectContaining({ - record: expect.objectContaining({ - finishReason: 'error', - error: 'Credential "OpenAI" not found', + expect.any(Date), + ); + expect(executionService.finalizeExecution).toHaveBeenCalledWith( + 'execution-1', + expect.objectContaining({ + record: expect.objectContaining({ + finishReason: 'error', + error: 'Credential "OpenAI" not found', + }), }), - }), - ); - }); + ); + }, + ); it('rethrows the build error without recording when the agent no longer exists', async () => { const { service, runtimeCacheService, executionService, agentRepository } = makeService(); @@ -1900,7 +1918,7 @@ describe('AgentExecutionOrchestratorService', () => { expect.objectContaining({ abortSignal }), ); expect(executionService.startExecutionRecording).toHaveBeenCalledWith( - expect.objectContaining({ userMessage: null }), + expect.objectContaining({ userMessage: null, sessionMode: 'existing' }), expect.any(Date), ); expect(executionService.finalizeExecution).toHaveBeenCalledWith( @@ -2329,37 +2347,44 @@ describe('AgentExecutionOrchestratorService', () => { ); }); - it('rejects a private thread on the project checkpoint path', async () => { - const { service, checkpointStorage, executionService, runtimeCacheService } = makeService(); - checkpointStorage.getStatus.mockResolvedValue({ - status: 'active', - checkpoint: { - persistence: { threadId: 'thread-1', resourceId: 'task:task-1' }, - }, - } as never); - executionService.findThreadById.mockResolvedValue({ - projectId, - agentId, - accessScope: 'user', - ownerId: userId, - } as never); + it.each(['private', 'deleted'] as const)( + 'rejects a %s thread on the project checkpoint path', + async (state) => { + const { service, checkpointStorage, executionService, runtimeCacheService } = makeService(); + checkpointStorage.getStatus.mockResolvedValue({ + status: 'active', + checkpoint: { + persistence: { threadId: 'thread-1', resourceId: 'task:task-1' }, + }, + } as never); + executionService.findThreadById.mockResolvedValue( + state === 'deleted' + ? null + : mock({ + projectId, + agentId, + accessScope: 'user', + ownerId: userId, + }), + ); - await expect( - collect( - service.resumeForChat({ - agentId, - projectId, - runId: 'run-1', - toolCallId: 'tc-1', - resumeData: { approved: true }, - usePublishedVersion: true, - integrationType: 'task', - }), - ), - ).rejects.toThrow('does not belong to this chat'); - expect(runtimeCacheService.getRuntime).not.toHaveBeenCalled(); - expect(executionService.startExecutionRecording).not.toHaveBeenCalled(); - }); + await expect( + collect( + service.resumeForChat({ + agentId, + projectId, + runId: 'run-1', + toolCallId: 'tc-1', + resumeData: { approved: true }, + usePublishedVersion: true, + integrationType: 'task', + }), + ), + ).rejects.toThrow('does not belong to this chat'); + expect(runtimeCacheService.getRuntime).not.toHaveBeenCalled(); + expect(executionService.startExecutionRecording).not.toHaveBeenCalled(); + }, + ); it.each([false, true])( 'installs an interaction on the checkpoint memory only after a successful claim: %s', diff --git a/packages/cli/src/modules/agents/__tests__/agent-execution-thread.repository.test.ts b/packages/cli/src/modules/agents/__tests__/agent-execution-thread.repository.test.ts index 0c61a4793ff..7d5a154ad3a 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-execution-thread.repository.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-execution-thread.repository.test.ts @@ -17,6 +17,7 @@ describe('AgentExecutionThreadRepository', () => { beforeEach(() => { vi.clearAllMocks(); + Object.assign(entityManager.connection, { options: { type: 'sqlite-pooled' } }); repository = new AgentExecutionThreadRepository( mockDataSource as never, mock(), @@ -26,22 +27,23 @@ describe('AgentExecutionThreadRepository', () => { describe('findOrCreate', () => { const makeScopedRepository = (saved: AgentExecutionThread, max = 7) => ({ findOneBy: vi.fn().mockResolvedValue(null), + findOne: vi.fn().mockResolvedValueOnce(null).mockResolvedValue(saved), createQueryBuilder: vi.fn().mockReturnValue({ select: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), getRawOne: vi.fn().mockResolvedValue({ max }), + insert: vi.fn().mockReturnThis(), + values: vi.fn().mockReturnThis(), + orIgnore: vi.fn().mockReturnThis(), + updateEntity: vi.fn().mockReturnThis(), + execute: vi.fn().mockResolvedValue({ raw: [] }), }), - create: vi.fn().mockReturnValue(saved), - save: vi.fn().mockResolvedValue(saved), }); - it('assigns the project-scoped session number inside a serializable transaction', async () => { + it('assigns the project-scoped session number with the supplied context', async () => { const saved = mock({ id: 'thread-1', sessionNumber: 8 }); const scopedRepository = makeScopedRepository(saved); - const trx = { getRepository: vi.fn().mockReturnValue(scopedRepository) }; - entityManager.transaction.mockImplementationOnce(async (_isolation, callback) => { - return await callback(trx as never); - }); + entityManager.getRepository.mockReturnValue(scopedRepository as never); const result = await repository.findOrCreate( 'thread-1', @@ -49,11 +51,11 @@ describe('AgentExecutionThreadRepository', () => { 'Support agent', 'project-1', access, + {}, ); - expect(entityManager.transaction).toHaveBeenCalledWith('SERIALIZABLE', expect.any(Function)); - expect(trx.getRepository).toHaveBeenCalledWith(AgentExecutionThread); - expect(scopedRepository.create).toHaveBeenCalledWith({ + expect(entityManager.getRepository).toHaveBeenCalledWith(AgentExecutionThread); + expect(scopedRepository.createQueryBuilder().values).toHaveBeenCalledWith({ id: 'thread-1', agentId: 'agent-1', agentName: 'Support agent', @@ -71,22 +73,35 @@ describe('AgentExecutionThreadRepository', () => { it('stores subagent origin metadata when creating a thread', async () => { const saved = mock({ id: 'thread-1', sessionNumber: 8 }); const scopedRepository = makeScopedRepository(saved); - const trx = { getRepository: vi.fn().mockReturnValue(scopedRepository) }; - entityManager.transaction.mockImplementationOnce(async (_isolation, callback) => { - return await callback(trx as never); + const parentAccess = { accessScope: 'user' as const, ownerId: 'parent-owner' }; + const parent = mock({ + id: 'parent-thread-1', + projectId: 'project-1', + agentId: 'parent-agent-1', + ...parentAccess, }); + scopedRepository.findOneBy.mockResolvedValueOnce(parent).mockResolvedValueOnce(null); + entityManager.getRepository.mockReturnValue(scopedRepository as never); - await repository.findOrCreate('thread-1', 'agent-1', 'Support agent', 'project-1', access, { - parentThreadId: 'parent-thread-1', - parentAgentId: 'parent-agent-1', - }); + await repository.findOrCreate( + 'thread-1', + 'agent-1', + 'Support agent', + 'project-1', + access, + {}, + { + parentThreadId: 'parent-thread-1', + parentAgentId: 'parent-agent-1', + }, + ); - expect(scopedRepository.create).toHaveBeenCalledWith({ + expect(scopedRepository.createQueryBuilder().values).toHaveBeenCalledWith({ id: 'thread-1', agentId: 'agent-1', agentName: 'Support agent', projectId: 'project-1', - ...access, + ...parentAccess, taskId: null, taskVersionId: null, sessionNumber: 8, @@ -98,10 +113,7 @@ describe('AgentExecutionThreadRepository', () => { it('stores the published task snapshot version when supplied', async () => { const saved = mock({ id: 'thread-1', sessionNumber: 8 }); const scopedRepository = makeScopedRepository(saved); - const trx = { getRepository: vi.fn().mockReturnValue(scopedRepository) }; - entityManager.transaction.mockImplementationOnce(async (_isolation, callback) => { - return await callback(trx as never); - }); + entityManager.getRepository.mockReturnValue(scopedRepository as never); await repository.findOrCreate( 'thread-1', @@ -109,42 +121,18 @@ describe('AgentExecutionThreadRepository', () => { 'Support agent', 'project-1', access, + {}, undefined, 'task-1', 'version-1', ); - expect(scopedRepository.create).toHaveBeenCalledWith( + expect(scopedRepository.createQueryBuilder().values).toHaveBeenCalledWith( expect.objectContaining({ taskId: 'task-1', taskVersionId: 'version-1', }), ); }); - - it('retries transient serialization failures before assigning a session number', async () => { - const saved = mock({ id: 'thread-1', sessionNumber: 8 }); - const scopedRepository = makeScopedRepository(saved); - const trx = { getRepository: vi.fn().mockReturnValue(scopedRepository) }; - const serializationError = Object.assign(new Error('serialization failure'), { - driverError: { code: '40001' }, - }); - entityManager.transaction - .mockRejectedValueOnce(serializationError) - .mockImplementationOnce(async (_isolation, callback) => { - return await callback(trx as never); - }); - - const result = await repository.findOrCreate( - 'thread-1', - 'agent-1', - 'Support agent', - 'project-1', - access, - ); - - expect(entityManager.transaction).toHaveBeenCalledTimes(2); - expect(result).toEqual({ thread: saved, created: true }); - }); }); }); diff --git a/packages/cli/src/modules/agents/__tests__/agent-execution.repository.test.ts b/packages/cli/src/modules/agents/__tests__/agent-execution.repository.test.ts index 31e321b3f07..3409a43c27b 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-execution.repository.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-execution.repository.test.ts @@ -1,5 +1,7 @@ /* eslint-disable @typescript-eslint/unbound-method -- mock-based tests intentionally reference unbound methods */ import { mockEntityManager } from '@test/mocking'; +import type { TransactionRunner } from '@n8n/db'; +import { mock } from 'vitest-mock-extended'; import { AgentExecution } from '../entities/agent-execution.entity'; import { AgentExecutionRepository } from '../repositories/agent-execution.repository'; @@ -12,7 +14,7 @@ describe('AgentExecutionRepository', () => { beforeEach(() => { vi.clearAllMocks(); - repository = new AgentExecutionRepository(mockDataSource as never); + repository = new AgentExecutionRepository(mockDataSource as never, mock()); }); describe('findLatestSuspendedByThreadId', () => { diff --git a/packages/cli/src/modules/agents/__tests__/agent-execution.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-execution.service.test.ts index 8fd2ca68cd4..080aeddaa77 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-execution.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-execution.service.test.ts @@ -133,7 +133,7 @@ describe('AgentExecutionService', () => { }); const execution = mock({ id: 'execution-1' }); agentExecutionRepository.create.mockReturnValue(execution); - agentExecutionRepository.save.mockResolvedValue(execution); + agentExecutionRepository.saveInContext.mockResolvedValue(execution); const initialTimeline: TimelineEvent[] = [ { type: 'background-task-signal', @@ -153,7 +153,7 @@ describe('AgentExecutionService', () => { initialTimeline, }; executionUpdateBroadcaster.notify.mockImplementation(() => { - expect(agentExecutionRepository.save).toHaveBeenCalled(); + expect(agentExecutionRepository.saveInContext).toHaveBeenCalled(); expect(agentExecutionRepository.create).toHaveBeenCalledWith( expect.objectContaining({ timeline: initialTimeline, @@ -174,21 +174,28 @@ describe('AgentExecutionService', () => { ); }); - it('keeps a running execution alive until it is finalized', async () => { + it('keeps a running execution alive during title lookup and until finalization', async () => { vi.useFakeTimers(); + const titleLookupStarted = createDeferredPromise(); + const titleLookup = + createDeferredPromise>>(); try { agentExecutionThreadRepository.findOrCreate.mockResolvedValue({ thread: makeThread(), - created: true, + created: false, + }); + memoryBackend.getThread.mockImplementation(async () => { + titleLookupStarted.resolve(); + return await titleLookup.promise; }); agentExecutionRepository.create.mockImplementation((data) => data as AgentExecution); - agentExecutionRepository.save.mockResolvedValue({ + agentExecutionRepository.saveInContext.mockResolvedValue({ id: 'execution-1', } as AgentExecution); agentExecutionRepository.touchRunning.mockResolvedValue(); agentExecutionRepository.updateIfRunning.mockResolvedValue(true); - const executionId = await service.startExecutionRecording( + const recording = service.startExecutionRecording( { access: previewAccess, threadId: 'thread-1', @@ -199,7 +206,11 @@ describe('AgentExecutionService', () => { }, new Date(), ); + await titleLookupStarted.promise; await vi.advanceTimersByTimeAsync(30_000); + expect(agentExecutionRepository.touchRunning).toHaveBeenCalledWith('execution-1'); + titleLookup.resolve(null); + const executionId = await recording; expect(executionUpdateBroadcaster.notify).toHaveBeenCalledWith({ projectId: 'project-1', @@ -224,6 +235,7 @@ describe('AgentExecutionService', () => { expect(agentExecutionRepository.touchRunning).toHaveBeenCalledOnce(); } finally { + titleLookup.resolve(null); vi.useRealTimers(); } }); @@ -234,7 +246,9 @@ describe('AgentExecutionService', () => { thread: makeThread(), created: false, }); - agentExecutionRepository.save.mockResolvedValue(mock({ id: 'execution-1' })); + agentExecutionRepository.saveInContext.mockResolvedValue( + mock({ id: 'execution-1' }), + ); const params = { access: previewAccess, threadId: 'thread-1', @@ -476,7 +490,9 @@ describe('AgentExecutionService', () => { created: true, }); agentExecutionRepository.create.mockImplementation((data) => data as AgentExecution); - agentExecutionRepository.save.mockResolvedValue({ id: 'execution-1' } as AgentExecution); + agentExecutionRepository.saveInContext.mockResolvedValue({ + id: 'execution-1', + } as AgentExecution); await recordExecution({ threadId: 'thread-1', @@ -565,7 +581,9 @@ describe('AgentExecutionService', () => { created: true, }); agentExecutionRepository.create.mockImplementation((data) => data as AgentExecution); - agentExecutionRepository.save.mockResolvedValue({ id: 'execution-1' } as AgentExecution); + agentExecutionRepository.saveInContext.mockResolvedValue({ + id: 'execution-1', + } as AgentExecution); const error = new Error('storage unavailable'); if (failure === 'blob write') { agentExecutionLogStore.write.mockRejectedValue(error); @@ -623,7 +641,9 @@ describe('AgentExecutionService', () => { }; agentExecutionThreadRepository.findOrCreate.mockResolvedValue({ thread, created: true }); agentExecutionRepository.create.mockImplementation((entity) => entity as AgentExecution); - agentExecutionRepository.save.mockResolvedValue({ id: 'execution-1' } as AgentExecution); + agentExecutionRepository.saveInContext.mockResolvedValue({ + id: 'execution-1', + } as AgentExecution); await recordExecution({ threadId: 'thread-1', @@ -645,12 +665,14 @@ describe('AgentExecutionService', () => { 'Agent', 'project-1', previewAccess, + {}, { parentThreadId: 'parent-thread-1', parentAgentId: 'parent-agent-1', }, undefined, undefined, + undefined, ); }); @@ -661,7 +683,9 @@ describe('AgentExecutionService', () => { created: false, }); agentExecutionRepository.create.mockImplementation((data) => data as AgentExecution); - agentExecutionRepository.save.mockResolvedValue({ id: 'execution-1' } as AgentExecution); + agentExecutionRepository.saveInContext.mockResolvedValue({ + id: 'execution-1', + } as AgentExecution); await recordExecution( { @@ -684,9 +708,11 @@ describe('AgentExecutionService', () => { 'Agent', 'project-1', access, + {}, undefined, 'task-1', 'version-1', + undefined, ); }); @@ -696,7 +722,9 @@ describe('AgentExecutionService', () => { created: false, }); agentExecutionRepository.create.mockImplementation((data) => data as AgentExecution); - agentExecutionRepository.save.mockResolvedValue({ id: 'execution-1' } as AgentExecution); + agentExecutionRepository.saveInContext.mockResolvedValue({ + id: 'execution-1', + } as AgentExecution); memoryBackend.getThread.mockResolvedValue({ id: 'thread-1', resourceId: 'user-1', @@ -725,7 +753,9 @@ describe('AgentExecutionService', () => { created: false, }); agentExecutionRepository.create.mockImplementation((data) => data as AgentExecution); - agentExecutionRepository.save.mockResolvedValue({ id: 'execution-1' } as AgentExecution); + agentExecutionRepository.saveInContext.mockResolvedValue({ + id: 'execution-1', + } as AgentExecution); await recordExecution({ threadId: 'thread-1', @@ -746,7 +776,9 @@ describe('AgentExecutionService', () => { created: false, }); agentExecutionRepository.create.mockImplementation((data) => data as AgentExecution); - agentExecutionRepository.save.mockResolvedValue({ id: 'execution-1' } as AgentExecution); + agentExecutionRepository.saveInContext.mockResolvedValue({ + id: 'execution-1', + } as AgentExecution); await recordExecution({ threadId: 'thread-1', @@ -811,7 +843,9 @@ describe('AgentExecutionService', () => { created: false, }); agentExecutionRepository.create.mockImplementation((data) => data as AgentExecution); - agentExecutionRepository.save.mockResolvedValue({ id: 'execution-1' } as AgentExecution); + agentExecutionRepository.saveInContext.mockResolvedValue({ + id: 'execution-1', + } as AgentExecution); telemetry.trackAgentTurnFinished.mockImplementation(() => { throw new Error('telemetry failed'); }); @@ -857,7 +891,9 @@ describe('AgentExecutionService', () => { created: false, }); agentExecutionRepository.create.mockImplementation((data) => data as AgentExecution); - agentExecutionRepository.save.mockResolvedValue({ id: 'execution-1' } as AgentExecution); + agentExecutionRepository.saveInContext.mockResolvedValue({ + id: 'execution-1', + } as AgentExecution); await recordExecution({ threadId: 'thread-1', @@ -898,7 +934,9 @@ describe('AgentExecutionService', () => { created: false, }); agentExecutionRepository.create.mockImplementation((data) => data as AgentExecution); - agentExecutionRepository.save.mockResolvedValue({ id: 'execution-1' } as AgentExecution); + agentExecutionRepository.saveInContext.mockResolvedValue({ + id: 'execution-1', + } as AgentExecution); await recordExecution({ threadId: 'thread-1', @@ -1282,8 +1320,8 @@ describe('AgentExecutionService', () => { describe('deleteThread', () => { it('deletes thread memory, attachments, and the execution thread', async () => { agentExecutionThreadRepository.deleteSession.mockResolvedValue({ - attachmentBinaryDataIds: ['binary-1'], - executionLogs: [], + status: 'deleted', + refs: { attachmentBinaryDataIds: ['binary-1'], executionLogs: [] }, }); const result = await service.deleteThread('project-1', 'agent-1', 'thread-1', 'user-1'); @@ -1305,8 +1343,11 @@ describe('AgentExecutionService', () => { it('deletes blob-stored logs when deleting a thread', async () => { agentExecutionThreadRepository.deleteSession.mockResolvedValue({ - attachmentBinaryDataIds: [], - executionLogs: [{ id: 'execution-1', storedAt: 'fs' }], + status: 'deleted', + refs: { + attachmentBinaryDataIds: [], + executionLogs: [{ id: 'execution-1', storedAt: 'fs' }], + }, }); const result = await service.deleteThread('project-1', 'agent-1', 'thread-1', 'user-1'); diff --git a/packages/cli/src/modules/agents/__tests__/agent-integration-persistence.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-integration-persistence.service.test.ts index b504ae531b4..39a67366737 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-integration-persistence.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-integration-persistence.service.test.ts @@ -136,6 +136,10 @@ describe('AgentIntegrationPersistenceService', () => { displayIcon: 'slack', credentialTypes: ['slackApi'], builderGuidance: { capabilities: ['respond'] }, + actionToolDefinitions: [ + { name: 'respond' }, + { name: 'send_channel_message', sensitive: true }, + ], }, ] as never); @@ -148,6 +152,12 @@ describe('AgentIntegrationPersistenceService', () => { capabilities: ['respond'], useIntegrationWhen: undefined, useNodeToolWhen: undefined, + // The channel approval control offers these, pre-selecting the + // sensitive ones, so it never duplicates the action list. + approvableActions: [ + { name: 'respond', sensitive: false }, + { name: 'send_channel_message', sensitive: true }, + ], }, ]); expect(chatIntegrationRegistry.listPublic).toHaveBeenCalled(); diff --git a/packages/cli/src/modules/agents/__tests__/agent-workflow-execution.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-workflow-execution.service.test.ts index d77be285179..dc005aa9f5b 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-workflow-execution.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-workflow-execution.service.test.ts @@ -2,6 +2,7 @@ import type { Agent as RuntimeAgent, StreamChunk } from '@n8n/agents'; import type { AgentJsonConfig } from '@n8n/api-types'; import { mockLogger } from '@n8n/backend-test-utils'; import type { AiConfig } from '@n8n/config'; +import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise'; import type { JSONSchema7 } from 'json-schema'; import { OperationalError, UserError } from 'n8n-workflow'; import type { ExecuteAgentWorkflowContext, IRunExecutionData } from 'n8n-workflow'; @@ -413,6 +414,49 @@ describe('AgentWorkflowExecutionService', () => { ); }); + it.each(['successful', 'unsuccessful'])( + 'keeps existing-session intent through agent lookup when compilation is %s', + async (compilation) => { + const { service, agentRepository, reconstructionService, executionService } = makeService(); + const lookupStarted = createDeferredPromise(); + const agentLookup = createDeferredPromise(); + const runtime = makeRuntime(); + const cause = new UserError('Session not found'); + executionService.getSessionMode.mockResolvedValue('existing'); + agentRepository.findByIdAndProjectId.mockImplementation(async () => { + lookupStarted.resolve(); + return await agentLookup.promise; + }); + executionService.startExecutionRecording.mockImplementation(async ({ sessionMode }) => { + if (sessionMode === 'existing') throw cause; + return 'execution-1'; + }); + if (compilation === 'successful') { + reconstructionService.reconstructFromAgentEntity.mockResolvedValue(runtime); + } else { + reconstructionService.reconstructFromAgentEntity.mockRejectedValue( + new Error('runtime setup failed'), + ); + } + + const execution = service.executeForWorkflow( + agentId, + 'hello', + 'execution-1', + 'thread-1', + projectId, + ); + await lookupStarted.promise; + // Deletion finishes while the agent lookup is pending. + executionService.getSessionMode.mockResolvedValue('new'); + agentLookup.resolve(makeAgent()); + + await expect(execution).rejects.toMatchObject({ phase: 'create', cause }); + expect(runtime.agent.stream).not.toHaveBeenCalled(); + expect(executionService.finalizeExecution).not.toHaveBeenCalled(); + }, + ); + it('records a null input for a tool-result with no matching tool-call', async () => { const { service, agentRepository, reconstructionService } = makeService(); const runtime = makeRuntime([ diff --git a/packages/cli/src/modules/agents/agent-chat.controller.ts b/packages/cli/src/modules/agents/agent-chat.controller.ts index 0283ad59dc9..a61cedb4fa7 100644 --- a/packages/cli/src/modules/agents/agent-chat.controller.ts +++ b/packages/cli/src/modules/agents/agent-chat.controller.ts @@ -148,7 +148,7 @@ export class AgentChatController { ) { const { projectId } = req.params; // The text-or-attachment invariant is enforced by the DTO schema. - const { message, sessionId, attachments } = payload; + const { message, sessionId, newSession, attachments } = payload; const credentialProvider = new AgentsCredentialProvider( this.credentialsService, @@ -168,6 +168,7 @@ export class AgentChatController { user: req.user, sessionId, previewChat: true, + newSession, credentialProvider, }); if (abortSignal.aborted) return; @@ -202,6 +203,7 @@ export class AgentChatController { attachments: storedAttachments, user: req.user, sessionId: threadId, + sessionMode: prepared.sessionMode, previewChat: true, errorMode: 'forward', onChunk, diff --git a/packages/cli/src/modules/agents/agent-checkpoint-pruning.task.ts b/packages/cli/src/modules/agents/agent-checkpoint-pruning.task.ts index 572ffa3bd1a..b6578f9039e 100644 --- a/packages/cli/src/modules/agents/agent-checkpoint-pruning.task.ts +++ b/packages/cli/src/modules/agents/agent-checkpoint-pruning.task.ts @@ -19,7 +19,7 @@ export class AgentCheckpointPruningTask implements SystemTask { readonly effects: SystemTaskEffects = 'idempotent'; - readonly durable = false; + readonly durable = true; readonly runOnTakeover = true; diff --git a/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts b/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts index 741eba26493..8d3d1783ed9 100644 --- a/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts +++ b/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts @@ -22,6 +22,7 @@ import { Telemetry } from '@/telemetry'; import { AgentExecutionService, type StartExecutionParams } from './agent-execution.service'; import { AgentChatExecutionService } from './agent-chat-execution.service'; import type { AgentThreadAccess } from './entities/agent-execution-thread.entity'; +import type { AgentSessionMode } from './utils/agent-thread-access'; import { draftChatMemoryResourceId, isTaskRunMemoryResourceId, @@ -81,6 +82,7 @@ export interface ExecuteForChatConfig { user: User; /** Memory scope — resourceId is the chat platform user (e.g. Slack / Telegram user ID). */ memory: AgentMemoryScope; + sessionMode?: AgentSessionMode; /** Stored attachments to include as file parts on the user turn. */ attachments?: StoredAttachmentRef[]; /** Identifies the surface that started the draft test run. */ @@ -111,6 +113,7 @@ export interface ExecuteForChatPublishedConfig { author?: AgentMessageAuthor; /** Memory scope — resourceId is the chat platform user (e.g. Slack / Telegram user ID). */ memory: AgentMemoryScope; + sessionMode?: AgentSessionMode; attachments?: StoredAttachmentRef[]; integrationType?: string; sandboxPrincipalHash: AgentSandboxPrincipalHash; @@ -248,6 +251,7 @@ export interface StreamChatResponseConfig { /** Prevent this wake run from triggering another wake. */ isWakeRun?: boolean; backgroundJobSignal?: AgentBackgroundJobSignal; + sessionMode?: AgentSessionMode; } /** @@ -275,6 +279,10 @@ export class AgentExecutionOrchestratorService { private readonly chatExecutionService: AgentChatExecutionService, ) {} + async getSessionMode(threadId: string): Promise { + return await this.agentExecutionService.getSessionMode(threadId); + } + /** * Return user-visible conversation history for a persisted chat thread. * @@ -361,7 +369,7 @@ export class AgentExecutionOrchestratorService { projectId, agentId, user.id, - { previewChat }, + { previewChat, sessionMode: 'existing' }, )) ) { throw new UserError(`Checkpoint ${runId} does not belong to this chat`); @@ -371,10 +379,10 @@ export class AgentExecutionOrchestratorService { const thread = await this.agentExecutionService.findThreadById(memoryScope.threadId); if ( userIdFromDraftChatMemoryResourceId(memoryScope.resourceId) || - (thread && - (thread.projectId !== projectId || - thread.agentId !== agentId || - thread.accessScope !== 'project')) + !thread || + thread.projectId !== projectId || + thread.agentId !== agentId || + thread.accessScope !== 'project' ) { throw new UserError(`Checkpoint ${runId} does not belong to this chat`); } @@ -452,6 +460,7 @@ export class AgentExecutionOrchestratorService { access, automaticContinuationRunId: config.previewChat && config.automaticPreviewContinuation ? runId : undefined, + sessionMode: 'existing', }, ), (runtime) => @@ -530,6 +539,7 @@ export class AgentExecutionOrchestratorService { agentName: runtime.agent.name, projectId, userMessage: null, + sessionMode: 'existing', ...(executionSource !== undefined ? { source: executionSource } : {}), telemetry: { userId: user?.id, @@ -556,6 +566,7 @@ export class AgentExecutionOrchestratorService { attachments, source, previewChat, + sessionMode = 'new', onExecutionRecorded, abortSignal, } = config; @@ -573,7 +584,7 @@ export class AgentExecutionOrchestratorService { projectId, agentId, user.id, - { previewChat }, + { previewChat, sessionMode }, )) ) { throw new UserError('Session not found'); @@ -598,6 +609,7 @@ export class AgentExecutionOrchestratorService { source, onExecutionRecorded, abortSignal, + sessionMode, }, ), async (runtime) => { @@ -637,6 +649,7 @@ export class AgentExecutionOrchestratorService { previewChat, onExecutionStarted: config.onExecutionStarted, sandboxPrincipalHash, + sessionMode, }); }, ); @@ -660,6 +673,7 @@ export class AgentExecutionOrchestratorService { integrationType, attachments, sandboxPrincipalHash, + sessionMode = 'new', } = config; await this.externalHooks.run('agent.preExecute', [agentId]); @@ -682,6 +696,7 @@ export class AgentExecutionOrchestratorService { attachments, source: integrationType, access: { accessScope: 'project', ownerId: null }, + sessionMode, }, ), async (runtime) => { @@ -716,6 +731,7 @@ export class AgentExecutionOrchestratorService { configuration: runtime.telemetryConfiguration, }, sandboxPrincipalHash, + sessionMode, }); }, ); @@ -843,6 +859,7 @@ export class AgentExecutionOrchestratorService { projectId, agentId, identity.user.id, + { sessionMode: 'existing' }, ))) ) { throw new UserError('Session not found'); @@ -873,6 +890,7 @@ export class AgentExecutionOrchestratorService { source: integrationType, abortSignal, access, + sessionMode: 'existing', }, ), (runtime) => @@ -898,6 +916,7 @@ export class AgentExecutionOrchestratorService { sandboxPrincipalHash: identity.principalHash, hideUserMessageFromTranscript: true, isWakeRun: true, + sessionMode: 'existing', backgroundJobSignal: config.backgroundJobSignal, }), abortSignal, @@ -986,6 +1005,7 @@ export class AgentExecutionOrchestratorService { hideUserMessageFromTranscript, isWakeRun, backgroundJobSignal, + sessionMode, } = config; const { threadId, resourceId } = memory; @@ -1047,6 +1067,7 @@ export class AgentExecutionOrchestratorService { agentName: agentInstance.name, projectId, userMessage: hideUserMessageFromTranscript ? null : message, + sessionMode, author, attachments, source, @@ -1095,6 +1116,7 @@ export class AgentExecutionOrchestratorService { | 'taskId' | 'taskVersionId' | 'access' + | 'sessionMode' > & { onExecutionRecorded?: (executionId: string) => void; abortSignal?: AbortSignal; diff --git a/packages/cli/src/modules/agents/agent-execution.service.ts b/packages/cli/src/modules/agents/agent-execution.service.ts index bebc0cc5b99..8481daf4ff6 100644 --- a/packages/cli/src/modules/agents/agent-execution.service.ts +++ b/packages/cli/src/modules/agents/agent-execution.service.ts @@ -6,12 +6,13 @@ import type { } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; import type { StorageLocation } from '@n8n/blob-storage'; -import { TransactionRunner } from '@n8n/db'; +import { TransactionRunner, type OperationContext } from '@n8n/db'; import { Service } from '@n8n/di'; import chunk from 'lodash/chunk'; import { ErrorReporter, StorageConfig } from 'n8n-core'; import { OperationalError, UnexpectedError } from 'n8n-workflow'; +import { ConflictError } from '@/errors/response-errors/conflict.error'; import type { AgentRunTelemetryType, IAgentConfigurationTelemetryProperties } from '@/interfaces'; import { Telemetry } from '@/telemetry'; @@ -34,6 +35,7 @@ import { canContinueThreadInPreview, canUseTopLevelDraftThread, threadBelongsTo, + type AgentSessionMode, } from './utils/agent-thread-access'; import { AgentExecutionThreadRepository } from './repositories/agent-execution-thread.repository'; import type { AgentExecutionThreadMetadata } from './repositories/agent-execution-thread.repository'; @@ -77,6 +79,7 @@ export interface RecordMessageParams { export interface StartExecutionParams extends Omit { access: AgentThreadAccess; + sessionMode?: AgentSessionMode; initialTimeline?: TimelineEvent[]; } @@ -142,15 +145,15 @@ export class AgentExecutionService { ) {} async startExecutionRecording(params: StartExecutionParams, startedAt: Date): Promise { - const { userMessage, created } = await this.prepareThread(params); - const inserted = await this.agentExecutionRepository.save( - this.agentExecutionRepository.create({ + const { inserted, created, needsTitleSync } = await this.txRunner.run({}, async (ctx) => { + const prepared = await this.prepareThread(params, ctx); + const execution = this.agentExecutionRepository.create({ threadId: params.threadId, status: 'running', startedAt, stoppedAt: null, duration: 0, - userMessage, + userMessage: prepared.userMessage, author: params.author ?? null, model: null, promptTokens: null, @@ -165,10 +168,16 @@ export class AgentExecutionService { hitlStatus: null, source: params.source ?? null, attachments: params.attachments?.length ? params.attachments : null, - }), - ); - if (created) this.executionsNeedingTitleSync.add(inserted.id); + }); + return { + inserted: await this.agentExecutionRepository.saveInContext(execution, ctx), + created: prepared.created, + needsTitleSync: !prepared.created && !prepared.thread.title, + }; + }); this.startHeartbeat(inserted.id); + if (created) this.executionsNeedingTitleSync.add(inserted.id); + if (needsTitleSync) await this.syncTitleFromMemory(params.threadId, params.agentId); this.executionUpdateBroadcaster.notify({ projectId: params.projectId, agentId: params.agentId, @@ -387,22 +396,22 @@ export class AgentExecutionService { private async prepareThread( params: StartExecutionParams, - ): Promise<{ userMessage: string | null; created: boolean }> { + ctx: OperationContext, + ): Promise<{ userMessage: string | null; created: boolean; thread: AgentExecutionThread }> { const { thread, created } = await this.agentExecutionThreadRepository.findOrCreate( params.threadId, params.agentId, params.agentName, params.projectId, params.access, + ctx, params.threadMetadata, params.taskId, params.taskVersionId, + params.sessionMode, ); - if (!created) { - await this.agentExecutionThreadRepository.bumpUpdatedAt(params.threadId); - if (!thread.title) await this.syncTitleFromMemory(params.threadId, params.agentId); - } - return { userMessage: cleanUserMessage(params.userMessage, params.agentName), created }; + if (!created) await this.agentExecutionThreadRepository.bumpUpdatedAt(params.threadId, ctx); + return { userMessage: cleanUserMessage(params.userMessage, params.agentName), created, thread }; } private async completeRecordedExecution( @@ -526,7 +535,7 @@ export class AgentExecutionService { threadId: string, userId: string, ): Promise { - const refs = await this.txRunner.run({}, async (ctx) => { + const result = await this.txRunner.run({}, async (ctx) => { const deletion = await this.agentExecutionThreadRepository.deleteSession( projectId, agentId, @@ -535,11 +544,16 @@ export class AgentExecutionService { ctx, ); if (!deletion) return null; + if (deletion.status === 'busy') return deletion; await this.n8nMemory.getImplementation(agentId).deleteThread(threadId, ctx); return deletion; }); - if (!refs) return false; + if (!result) return false; + if (result.status === 'busy') { + throw new ConflictError('The session has active work and cannot be deleted'); + } + const { refs } = result; await Promise.all([ this.agentChatAttachmentService.deleteStoredData(refs.attachmentBinaryDataIds, { threadId }), @@ -703,7 +717,7 @@ export class AgentExecutionService { projectId: string, agentId: string, userId: string, - options: { previewChat?: boolean } = {}, + options: { previewChat?: boolean; sessionMode?: AgentSessionMode } = {}, ): Promise { const thread = await this.findThreadById(threadId); if (thread) { @@ -713,6 +727,7 @@ export class AgentExecutionService { const sources = await this.agentExecutionRepository.findFirstSourceByThreadIds([threadId]); return canContinueThreadInPreview(thread, userId, sources.get(threadId)); } + if (options.sessionMode === 'existing') return false; return await this.canUseUnrecordedDraftThread(threadId, agentId, userId); } @@ -731,6 +746,10 @@ export class AgentExecutionService { ); } + async getSessionMode(threadId: string): Promise { + return (await this.findThreadById(threadId)) ? 'existing' : 'new'; + } + /** Narrow refs to those whose data lives in a blob store, i.e. all but `db`. */ private toBlobRefs(refs: T[]) { return refs.filter((r): r is T & { storedAt: StorageLocation } => r.storedAt !== 'db'); diff --git a/packages/cli/src/modules/agents/agent-integration-persistence.service.ts b/packages/cli/src/modules/agents/agent-integration-persistence.service.ts index 991bdb7ebd2..059b418d675 100644 --- a/packages/cli/src/modules/agents/agent-integration-persistence.service.ts +++ b/packages/cli/src/modules/agents/agent-integration-persistence.service.ts @@ -116,6 +116,10 @@ export class AgentIntegrationPersistenceService { label: i.displayLabel, icon: i.displayIcon, credentialTypes: i.credentialTypes, + approvableActions: i.actionToolDefinitions.map(({ name, sensitive }) => ({ + name, + sensitive: sensitive === true, + })), ...(i.builderGuidance ? { capabilities: i.builderGuidance.capabilities, diff --git a/packages/cli/src/modules/agents/agent-test-run.service.ts b/packages/cli/src/modules/agents/agent-test-run.service.ts index ebaefa90d16..7137f850ae1 100644 --- a/packages/cli/src/modules/agents/agent-test-run.service.ts +++ b/packages/cli/src/modules/agents/agent-test-run.service.ts @@ -26,6 +26,7 @@ import { AgentExecutionService } from './agent-execution.service'; import { AgentValidationService } from './agent-validation.service'; import { N8NCheckpointStorage } from './integrations/n8n-checkpoint-storage'; import { draftChatMemoryResourceId } from './utils/agent-memory-scope'; +import type { AgentSessionMode } from './utils/agent-thread-access'; interface PrepareDraftRunInput { agentId: string; @@ -33,11 +34,12 @@ interface PrepareDraftRunInput { user: User; sessionId?: string; previewChat?: boolean; + newSession?: boolean; credentialProvider: CredentialProvider; } export type PrepareDraftRunResult = - | { status: 'ready'; sessionId: string } + | { status: 'ready'; sessionId: string; sessionMode: AgentSessionMode } | { status: 'session_not_found' } | { status: 'agent_misconfigured'; missing: string[] }; @@ -163,8 +165,10 @@ export class AgentTestRunService { user, sessionId, previewChat, + newSession, credentialProvider, }: PrepareDraftRunInput): Promise { + const sessionMode: AgentSessionMode = !sessionId || newSession ? 'new' : 'existing'; if (sessionId) { if ( !(await this.agentExecutionService.canUseDraftThread( @@ -172,7 +176,7 @@ export class AgentTestRunService { projectId, agentId, user.id, - { previewChat }, + { previewChat, sessionMode }, )) ) { return { status: 'session_not_found' }; @@ -186,7 +190,11 @@ export class AgentTestRunService { ); if (missing.length > 0) return { status: 'agent_misconfigured', missing }; - return { status: 'ready', sessionId: sessionId ?? randomUUID() }; + return { + status: 'ready', + sessionId: sessionId ?? randomUUID(), + sessionMode, + }; } async executePreparedDraftRun({ @@ -246,6 +254,7 @@ export class AgentTestRunService { const result = await this.executePreparedDraftRun({ ...input, sessionId: prepared.sessionId, + sessionMode: prepared.sessionMode, }); return { ...result, sessionId: prepared.sessionId }; } @@ -261,7 +270,7 @@ export class AgentTestRunService { input.projectId, input.agentId, input.user.id, - { previewChat: input.previewChat }, + { previewChat: input.previewChat, sessionMode: 'existing' }, )) ) { return { status: 'session_not_found' }; diff --git a/packages/cli/src/modules/agents/agent-turn-execution.service.ts b/packages/cli/src/modules/agents/agent-turn-execution.service.ts index e01e7442735..20f26eece41 100644 --- a/packages/cli/src/modules/agents/agent-turn-execution.service.ts +++ b/packages/cli/src/modules/agents/agent-turn-execution.service.ts @@ -103,6 +103,10 @@ export class AgentTurnExecutionService { private readonly chatExecutionService: AgentChatExecutionService, ) {} + async getSessionMode(threadId: string): Promise<'new' | 'existing'> { + return await this.agentExecutionService.getSessionMode(threadId); + } + async *execute(config: ExecuteTurnConfig): AsyncGenerator { let executionId: string | undefined; let turn: AgentTurnRequest | undefined; diff --git a/packages/cli/src/modules/agents/agent-workflow-execution.service.ts b/packages/cli/src/modules/agents/agent-workflow-execution.service.ts index d07613f396f..bf844409b5d 100644 --- a/packages/cli/src/modules/agents/agent-workflow-execution.service.ts +++ b/packages/cli/src/modules/agents/agent-workflow-execution.service.ts @@ -556,6 +556,8 @@ export class AgentWorkflowExecutionService { sandboxScope?: WorkflowSandboxScope, streamObserver?: WorkflowAgentStreamObserver, ): Promise { + // Keep the original intent if deletion happens during preparation. + const sessionMode = await this.turnExecutionService.getSessionMode(threadId); const agentEntity = await this.agentRepository.findByIdAndProjectId(agentId, projectId); if (!agentEntity) { throw new OperationalError('Agent not found or not accessible.'); @@ -583,6 +585,7 @@ export class AgentWorkflowExecutionService { agentName: agentData.schema?.name ?? agentData.name, projectId, userMessage: message, + sessionMode, source: AGENT_WORKFLOW_TRIGGER_TYPE, telemetry: { userId: telemetryUserId, runType, configuration: telemetryConfiguration }, }; diff --git a/packages/cli/src/modules/agents/builder/__tests__/agents-builder.service.session.test.ts b/packages/cli/src/modules/agents/builder/__tests__/agents-builder.service.session.test.ts index a5e62bc0d2d..f2be2cce18c 100644 --- a/packages/cli/src/modules/agents/builder/__tests__/agents-builder.service.session.test.ts +++ b/packages/cli/src/modules/agents/builder/__tests__/agents-builder.service.session.test.ts @@ -30,6 +30,7 @@ const agentsSdkMocks = vi.hoisted(() => { const instructionsCalls: string[] = []; const registeredToolNames: string[] = []; const modelCalls: unknown[] = []; + const configurationCalls: Array<{ maxIterations?: number }> = []; const promptCachingCalls: unknown[] = []; const reasoningCalls: string[] = []; const telemetryCalls: unknown[] = []; @@ -74,7 +75,8 @@ const agentsSdkMocks = vi.hoisted(() => { checkpoint() { return this; } - configuration() { + configuration(config: { maxIterations?: number }) { + configurationCalls.push(config); return this; } telemetry(t: unknown) { @@ -129,6 +131,7 @@ const agentsSdkMocks = vi.hoisted(() => { instructionsCalls, registeredToolNames, modelCalls, + configurationCalls, promptCachingCalls, reasoningCalls, telemetryCalls, @@ -237,6 +240,7 @@ describe('AgentsBuilderService session isolation', () => { agentsSdkMocks.instructionsCalls.length = 0; agentsSdkMocks.registeredToolNames.length = 0; agentsSdkMocks.modelCalls.length = 0; + agentsSdkMocks.configurationCalls.length = 0; agentsSdkMocks.promptCachingCalls.length = 0; agentsSdkMocks.reasoningCalls.length = 0; agentsSdkMocks.telemetryCalls.length = 0; @@ -502,6 +506,24 @@ describe('AgentsBuilderService session isolation', () => { expect(agentsSdkMocks.modelCalls).toEqual(['anthropic/claude-sonnet-host-resolved']); }); + it('configures the builder agent with a maximum of 100 iterations', async () => { + const { service, user, credentialProvider, credentialService } = setup(); + + await drain( + service.buildAgent( + 'agent-1', + 'project-1', + 'hi', + credentialProvider, + credentialService, + user, + baseSession, + ), + ); + + expect(agentsSdkMocks.configurationCalls).toEqual([{ maxIterations: 100 }]); + }); + it('enables prompt caching with a 5m Anthropic TTL for the builder agent', async () => { const { service, user, credentialProvider, credentialService } = setup(); diff --git a/packages/cli/src/modules/agents/builder/agents-builder.service.ts b/packages/cli/src/modules/agents/builder/agents-builder.service.ts index 1780bf07ffa..b3c4e4e7b3c 100644 --- a/packages/cli/src/modules/agents/builder/agents-builder.service.ts +++ b/packages/cli/src/modules/agents/builder/agents-builder.service.ts @@ -302,7 +302,7 @@ export class AgentsBuilderService { .skills(runtimeSkills) .memory(builderMemory) .checkpoint(this.n8nCheckpointStorage.getStorage(agentId)) - .configuration({ maxIterations: 30 }); + .configuration({ maxIterations: 100 }); const promptCaching = resolveAIAPromptCaching(modelConfig); if (promptCaching) { builder.promptCaching(promptCaching); diff --git a/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-suspension-cards.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-suspension-cards.test.ts new file mode 100644 index 00000000000..7c21379adf8 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-suspension-cards.test.ts @@ -0,0 +1,36 @@ +import { buildSuspendCardPayload } from '../agent-chat-suspension-cards'; + +describe('buildSuspendCardPayload', () => { + it('turns the action tool’s approval payload into an Approve/Deny card', () => { + const card = buildSuspendCardPayload({ + type: 'approval', + toolName: 'send_channel_message', + displayName: 'send_channel_message → slack:C999', + args: { channelId: 'slack:C999', message: { text: 'Hi' } }, + }); + + expect(card).toEqual({ + title: 'Approval required', + components: [ + { + type: 'section', + text: 'The agent wants to run this tool: send_channel_message → slack:C999', + }, + { type: 'fields', fields: [{ label: 'Tool', value: 'send_channel_message → slack:C999' }] }, + { type: 'button', label: 'Approve', value: 'true', style: 'primary' }, + { type: 'button', label: 'Deny', value: 'false', style: 'danger' }, + ], + }); + }); + + it('leaves an interactive card suspension to the component mapper', () => { + const card = buildSuspendCardPayload({ + type: 'integration_action', + action: 'respond', + integrationConnectionId: 'slack:cred-a', + messageContext: null, + }); + + expect(card).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/modules/agents/integrations/__tests__/integration-tools.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/integration-tools.test.ts index 038d31bdb1a..8514ec864be 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/integration-tools.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/integration-tools.test.ts @@ -17,6 +17,7 @@ import { type IntegrationMessageContextStore, type IntegrationMessageContext, } from '../integration-tools'; +import { INTEGRATION_ACTION_RESUME_SCHEMA } from '../integration-tool-execution'; const slackA: AgentIntegrationConfig = { type: 'slack', @@ -1222,17 +1223,21 @@ describe('integration tools', () => { messageId: '123.456', }), ); + expect(readIntegrationMessageContext(ctx.persistence)?.messageId).toBe('123.456'); - expect(ctx.suspend).toHaveBeenCalledWith({ - type: 'integration_action', - action: 'send_channel_message', - integrationConnectionId: 'slack:cred-a', - messageContext: expect.objectContaining({ + expect(ctx.suspend).toHaveBeenCalledWith( + { + type: 'integration_action', + action: 'send_channel_message', integrationConnectionId: 'slack:cred-a', - platform: 'slack', - messageId: '123.456', - }), - }); + messageContext: expect.objectContaining({ + integrationConnectionId: 'slack:cred-a', + platform: 'slack', + messageId: '123.456', + }), + }, + { resumeSchema: INTEGRATION_ACTION_RESUME_SCHEMA }, + ); }); it('keeps the turn context and propagates an action context write failure', async () => { @@ -1342,16 +1347,19 @@ describe('integration tools', () => { messageId: '123.789', }), ); - expect(ctx.suspend).toHaveBeenCalledWith({ - type: 'integration_action', - action: 'respond', - integrationConnectionId: 'slack:cred-a', - messageContext: expect.objectContaining({ + expect(ctx.suspend).toHaveBeenCalledWith( + { + type: 'integration_action', + action: 'respond', integrationConnectionId: 'slack:cred-a', - platform: 'slack', - messageId: '123.789', - }), - }); + messageContext: expect.objectContaining({ + integrationConnectionId: 'slack:cred-a', + platform: 'slack', + messageId: '123.789', + }), + }, + { resumeSchema: INTEGRATION_ACTION_RESUME_SCHEMA }, + ); }); it('action tool description forbids claiming an action succeeded before the tool call returns', () => { @@ -1575,4 +1583,281 @@ describe('integration tools', () => { }), ); }); + + describe('action approval', () => { + const slackWithApproval: AgentIntegrationConfig = { + ...slackA, + approval: { mode: 'selected', tools: ['send_channel_message'] }, + }; + + function approvalTool(integration: AgentIntegrationConfig) { + const actionExecutor = mock(); + actionExecutor.execute.mockResolvedValue({ ok: true }); + const messageContextStore = mock(); + messageContextStore.getLatest.mockResolvedValue(null); + + const tool = createIntegrationActionTool({ + descriptor: getIntegrationToolConnectionDescriptors([integration], 'agent-1', () => ({ + actions: ['respond', 'send_channel_message', 'send_dm'], + }))[0], + messageContextStore, + actionExecutor, + }).build(); + + return { tool, actionExecutor }; + } + + const sendToChannel = { + action: 'send_channel_message' as const, + input: { channelId: 'slack:C999', message: { text: 'Hi' } }, + }; + + it('suspends for approval instead of running a gated action', async () => { + const { tool, actionExecutor } = approvalTool(slackWithApproval); + const ctx = makeInterruptibleCtx(); + + await tool.handler!(sendToChannel, ctx); + + expect(actionExecutor.execute).not.toHaveBeenCalled(); + expect(ctx.suspend).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'approval', + toolName: 'send_channel_message', + displayName: 'send_channel_message → slack:C999', + }), + expect.objectContaining({ resumeSchema: expect.anything() }), + ); + }); + + it('runs an action the channel does not gate', async () => { + const { tool, actionExecutor } = approvalTool(slackWithApproval); + const ctx = makeInterruptibleCtx(); + + await tool.handler!( + { action: 'send_dm', input: { userId: 'slack:U1', message: { text: 'Hi' } } }, + ctx, + ); + + expect(ctx.suspend).not.toHaveBeenCalled(); + expect(actionExecutor.execute).toHaveBeenCalledWith( + expect.objectContaining({ action: 'send_dm' }), + ); + }); + + it('runs nothing when the channel has no approval config', async () => { + const { tool, actionExecutor } = approvalTool(slackA); + const ctx = makeInterruptibleCtx(); + + await tool.handler!(sendToChannel, ctx); + + expect(ctx.suspend).not.toHaveBeenCalled(); + expect(actionExecutor.execute).toHaveBeenCalledWith( + expect.objectContaining({ action: 'send_channel_message' }), + ); + }); + + it('gates every action when the mode is global', async () => { + const { tool, actionExecutor } = approvalTool({ + ...slackA, + approval: { mode: 'global' }, + }); + const ctx = makeInterruptibleCtx(); + + await tool.handler!({ action: 'respond', input: { message: { text: 'Hi' } } }, ctx); + + expect(actionExecutor.execute).not.toHaveBeenCalled(); + expect(ctx.suspend).toHaveBeenCalledWith( + expect.objectContaining({ type: 'approval', toolName: 'respond' }), + expect.anything(), + ); + }); + + it('never asks before staying silent, even in global mode', async () => { + const actionExecutor = mock(); + actionExecutor.execute.mockResolvedValue({ ok: true, silent: true }); + const messageContextStore = mock(); + messageContextStore.getLatest.mockResolvedValue(null); + const tool = createIntegrationActionTool({ + descriptor: getIntegrationToolConnectionDescriptors( + [{ ...slackA, approval: { mode: 'global' } }], + 'agent-1', + () => ({ actions: ['respond', 'do_not_respond'] }), + )[0], + messageContextStore, + actionExecutor, + }).build(); + const ctx = makeInterruptibleCtx(); + + await tool.handler!({ action: 'do_not_respond', input: {} }, ctx); + + expect(ctx.suspend).not.toHaveBeenCalled(); + expect(actionExecutor.execute).toHaveBeenCalledWith( + expect.objectContaining({ action: 'do_not_respond' }), + ); + }); + + it('runs the action once the user approves it', async () => { + const { tool, actionExecutor } = approvalTool(slackWithApproval); + + await tool.handler!( + sendToChannel, + makeInterruptibleCtx({ + suspendPayload: { + type: 'approval', + toolName: 'send_channel_message', + args: sendToChannel.input, + }, + resumeData: { approved: true }, + }), + ); + + expect(actionExecutor.execute).toHaveBeenCalledWith( + expect.objectContaining({ action: 'send_channel_message' }), + ); + }); + + it('reports the refusal without running the action when the user denies it', async () => { + const { tool, actionExecutor } = approvalTool(slackWithApproval); + + const result = await tool.handler!( + sendToChannel, + makeInterruptibleCtx({ + suspendPayload: { + type: 'approval', + toolName: 'send_channel_message', + args: sendToChannel.input, + }, + resumeData: { approved: false }, + }), + ); + + expect(actionExecutor.execute).not.toHaveBeenCalled(); + expect(result).toEqual({ + ok: false, + error: { + code: 'ACTION_DECLINED', + message: 'The action "send_channel_message" was not approved.', + }, + }); + }); + + it('treats an unreadable approval resume as a refusal', async () => { + const { tool, actionExecutor } = approvalTool(slackWithApproval); + + const result = await tool.handler!( + sendToChannel, + makeInterruptibleCtx({ + suspendPayload: { + type: 'approval', + toolName: 'send_channel_message', + args: sendToChannel.input, + }, + resumeData: { clicked: 'yes' }, + }), + ); + + expect(actionExecutor.execute).not.toHaveBeenCalled(); + expect(result).toMatchObject({ error: { code: 'ACTION_DECLINED' } }); + }); + + it('does not leave an interactive follow-up card expecting an approval resume', async () => { + const { tool, actionExecutor } = approvalTool(slackWithApproval); + actionExecutor.execute.mockResolvedValue({ + ok: true, + messageContext: { + integrationConnectionId: 'slack:cred-a', + platform: 'slack', + target: { type: 'channel', channelId: 'slack:C999', threadId: 'slack:C999:1' }, + messageId: '1', + updatedAt: '2026-05-18T10:00:00.000Z', + }, + }); + const ctx = makeInterruptibleCtx({ + suspendPayload: { + type: 'approval', + toolName: 'send_channel_message', + args: sendToChannel.input, + }, + resumeData: { approved: true }, + }); + + await tool.handler!( + { + action: 'send_channel_message', + input: { + channelId: 'slack:C999', + message: { + text: 'Choose', + card: { + components: [{ type: 'button', label: 'Go', value: 'go' }], + }, + }, + }, + }, + ctx, + ); + + expect(ctx.suspend).toHaveBeenCalledWith( + expect.objectContaining({ type: 'integration_action', action: 'send_channel_message' }), + { resumeSchema: INTEGRATION_ACTION_RESUME_SCHEMA }, + ); + }); + + it('still hands a card resume straight back to the model', async () => { + const { tool } = approvalTool(slackWithApproval); + + const result = await tool.handler!( + sendToChannel, + makeInterruptibleCtx({ + suspendPayload: { + type: 'integration_action', + action: 'respond', + integrationConnectionId: 'slack:cred-a', + messageContext: null, + }, + resumeData: { type: 'button', value: 'go' }, + }), + ); + + expect(result).toEqual({ type: 'button', value: 'go' }); + }); + + // A batch cannot suspend, so without this the model could put a gated + // action in a batch and skip the gate. + it('refuses a gated action inside a batch rather than running it', async () => { + const { tool, actionExecutor } = approvalTool(slackWithApproval); + + const result = await tool.handler!( + { + actions: [ + { action: 'send_dm', input: { userId: 'slack:U1', message: { text: 'Hi' } } }, + sendToChannel, + ], + }, + makeInterruptibleCtx(), + ); + + expect(actionExecutor.execute).toHaveBeenCalledTimes(1); + expect(actionExecutor.execute).toHaveBeenCalledWith( + expect.objectContaining({ action: 'send_dm' }), + ); + expect(result).toMatchObject({ + ok: true, + results: [ + { action: 'send_dm', result: { ok: true } }, + { + action: 'send_channel_message', + result: { + ok: false, + error: { + code: 'ACTION_NEEDS_APPROVAL', + message: + 'The action "send_channel_message" needs approval, which cannot be asked for here. Send that action on its own.', + }, + }, + }, + ], + }); + }); + }); }); diff --git a/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts b/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts index ade221d8e71..c7f09451311 100644 --- a/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts +++ b/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts @@ -27,6 +27,7 @@ import type { } from '../agent-execution-orchestrator.service'; import { hashAgentSandboxPrincipal } from '../agent-sandbox-principal'; import { integrationMemoryResourceId } from '../utils/agent-memory-scope'; +import type { AgentSessionMode } from '../utils/agent-thread-access'; import { resolveInboundMimeType } from '../utils/inbound-attachments'; import type { AgentChatIntegration, @@ -104,6 +105,8 @@ function stillWaitingNotice(suspendPayload: unknown): string { } interface AgentExecutor extends Pick { + getSessionMode?(threadId: string): Promise; + executeForChatPublished( config: Omit & { memory: { threadId: InternalThread; resourceId: string }; @@ -259,6 +262,7 @@ export class AgentChatBridge { integration: AgentIntegrationConfig, ): AgentChatBridge { const agentExecutor: AgentExecutor = { + getSessionMode: async (threadId) => await agentService.getSessionMode(threadId), async *executeForChatPublished({ memory, agentId: aid, @@ -270,6 +274,7 @@ export class AgentChatBridge { sandboxPrincipalHash, messageContext, contextConversation, + sessionMode, }) { yield* agentService.executeForChatPublished({ agentId: aid, @@ -289,6 +294,7 @@ export class AgentChatBridge { sandboxPrincipalHash, messageContext, contextConversation, + sessionMode, }); }, async *resumeForChat(config) { @@ -649,6 +655,9 @@ export class AgentChatBridge { const sessionOrigin = await this.messageContextBridge.resolveSession(this.baseThreadId(thread)); const memoryThreadId = sessionOrigin ? toInternalThreadId(sessionOrigin.threadId) : threadId; const memoryResourceId = sessionOrigin?.resourceId ?? resourceId; + const sessionMode = sessionOrigin + ? 'existing' + : ((await this.agentService.getSessionMode?.(memoryThreadId.id)) ?? 'new'); // The run parks against the session it executes in, which for a bound reply // is the task's thread rather than the platform one — so this has to come // after the binding is resolved, and before anything is stored for a turn @@ -728,6 +737,7 @@ export class AgentChatBridge { threadId: memoryThreadId, resourceId: memoryResourceId, }, + sessionMode, integrationType: this.integration.type, sandboxPrincipalHash: hashAgentSandboxPrincipal({ type: 'integration-thread', diff --git a/packages/cli/src/modules/agents/integrations/channel-status-report.ts b/packages/cli/src/modules/agents/integrations/channel-status-report.ts index ef8f8437201..6c1518ac9c9 100644 --- a/packages/cli/src/modules/agents/integrations/channel-status-report.ts +++ b/packages/cli/src/modules/agents/integrations/channel-status-report.ts @@ -59,6 +59,7 @@ export function buildChannelStatusReport( type: integration.type, credentialId: integration.credentialId, ...('settings' in integration ? { settings: integration.settings } : {}), + ...(integration.approval ? { approval: integration.approval } : {}), status, ...(failure?.errorMessage ? { errorMessage: failure.errorMessage } : {}), }; diff --git a/packages/cli/src/modules/agents/integrations/integration-error-codes.ts b/packages/cli/src/modules/agents/integrations/integration-error-codes.ts index ca9794ccedd..3b331605947 100644 --- a/packages/cli/src/modules/agents/integrations/integration-error-codes.ts +++ b/packages/cli/src/modules/agents/integrations/integration-error-codes.ts @@ -10,6 +10,8 @@ export const INTEGRATION_ERROR_CODES = { UNSUPPORTED_ACTION: 'UNSUPPORTED_ACTION', CONTEXT_QUERY_FAILED: 'CONTEXT_QUERY_FAILED', ACTION_FAILED: 'ACTION_FAILED', + ACTION_DECLINED: 'ACTION_DECLINED', + ACTION_NEEDS_APPROVAL: 'ACTION_NEEDS_APPROVAL', RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED', } as const; diff --git a/packages/cli/src/modules/agents/integrations/integration-tool-definitions.ts b/packages/cli/src/modules/agents/integrations/integration-tool-definitions.ts index d4b8b5ab302..a04711fb599 100644 --- a/packages/cli/src/modules/agents/integrations/integration-tool-definitions.ts +++ b/packages/cli/src/modules/agents/integrations/integration-tool-definitions.ts @@ -1,5 +1,6 @@ import { richMessageSchema, + type AgentApproval, type RichCardComponent, type RICH_CARD_BUTTON_STYLES, } from '@n8n/api-types'; @@ -271,12 +272,14 @@ export const GENERIC_ACTION_TOOL_DEFINITIONS = [ { name: 'send_dm', inputSchema: sendDmActionInputSchema, + sensitive: true, description: 'send_dm: input.userId and input.message are required. userId must be a platform user ID, not a name, handle, or email.', }, { name: 'send_channel_message', inputSchema: sendChannelMessageActionInputSchema, + sensitive: true, description: 'send_channel_message: input.channelId and input.message are required. channelId must be a platform channel ID, not a channel name.', }, @@ -286,6 +289,7 @@ const EDIT_MESSAGE_ACTION_TOOL_DEFINITIONS = [ { name: 'edit_message', inputSchema: editMessageActionInputSchema, + sensitive: true, description: "edit_message: input.messageId and input.message are required. Uses the latest message context to choose the conversation, so input.threadId isn't accepted.", }, @@ -350,6 +354,19 @@ export function resolveIntegrationActionDefinitions( return actions.map((action) => requireDefinition(actionDefinitionsByName, action)); } +/** Whether a channel's approval config gates this action. */ +export function actionNeedsApproval( + approval: AgentApproval | undefined, + action: IntegrationAction, +): boolean { + if (!approval) return false; + // Staying silent has no effect to approve; asking would post a card instead + // of the silence the model chose. + if (action === 'do_not_respond') return false; + if (approval.mode === 'global') return true; + return approval.tools.includes(action); +} + function toDefinitionMap< TName extends string, TDefinition extends IntegrationToolDefinition, diff --git a/packages/cli/src/modules/agents/integrations/integration-tool-execution.ts b/packages/cli/src/modules/agents/integrations/integration-tool-execution.ts index 0c6607c069f..1d073856901 100644 --- a/packages/cli/src/modules/agents/integrations/integration-tool-execution.ts +++ b/packages/cli/src/modules/agents/integrations/integration-tool-execution.ts @@ -1,9 +1,17 @@ -import type { InterruptibleToolContext, ToolContext } from '@n8n/agents'; +import { + APPROVAL_RESUME_SCHEMA, + type InterruptibleToolContext, + type ToolContext, +} from '@n8n/agents'; import { isRecord } from '@n8n/utils/is-record'; -import type { z } from 'zod'; +import { z } from 'zod'; import { isTaskRunMemoryResourceId } from '../utils/agent-memory-scope'; -import { messageSchema, type IntegrationCardComponent } from './integration-tool-definitions'; +import { + actionNeedsApproval, + messageSchema, + type IntegrationCardComponent, +} from './integration-tool-definitions'; import { INTEGRATION_ERROR_CODES } from './integration-error-codes'; import { isIntegrationMessageContext, @@ -22,6 +30,9 @@ import type { } from './integration-tool-types'; import type { RawActionToolOperation, RawContextToolOperation } from './integration-tool-schema'; +/** Resume shape for the action tool, including a follow-up interactive card. */ +export const INTEGRATION_ACTION_RESUME_SCHEMA = z.record(z.string(), z.unknown()); + export async function executeContextToolOperation(params: { operation: RawContextToolOperation; descriptor: IntegrationToolConnectionDescriptor; @@ -149,6 +160,8 @@ export async function executeActionToolOperation(params: { interruptCtx?: InterruptibleToolContext; currentMessageContext?: IntegrationMessageContext; allowSuspend: boolean; + /** Action the user just approved, so the gate below lets it through once. */ + approvedAction?: string; }): Promise { const { operation, @@ -158,6 +171,7 @@ export async function executeActionToolOperation(params: { ctx, interruptCtx, allowSuspend, + approvedAction, } = params; const persistence = ctx.persistence; const message = parseMessage(operation.input.message); @@ -175,6 +189,36 @@ export async function executeActionToolOperation(params: { }; } + const needsApproval = + operation.action !== approvedAction && + actionNeedsApproval(descriptor.approval, operation.action); + + if (needsApproval) { + // A batch cannot suspend, so a gated action inside one has to be refused + // rather than run — otherwise batching would be a way around the gate. + if (!allowSuspend || !interruptCtx) { + return { + ok: false, + error: { + code: INTEGRATION_ERROR_CODES.ACTION_NEEDS_APPROVAL, + message: `The action "${operation.action}" needs approval, which cannot be asked for here. Send that action on its own.`, + }, + }; + } + + return await interruptCtx.suspend( + { + type: 'approval', + toolName: operation.action, + displayName: describeActionForApproval(operation), + args: actionInput, + }, + // The card's buttons are shaped from this schema, so the decision comes + // back as `{ approved }` rather than the tool's own resume shape. + { resumeSchema: APPROVAL_RESUME_SCHEMA }, + ); + } + let currentMessageContext = params.currentMessageContext; if (operation.action === 'respond') { if (!currentMessageContext) { @@ -228,12 +272,31 @@ export async function executeActionToolOperation(params: { if (!awaitsResponse) return actionResult; - return await interruptCtx?.suspend({ - type: 'integration_action', - action: operation.action, - integrationConnectionId: descriptor.integrationConnectionId, - messageContext: actionResult.messageContext, - }); + return await interruptCtx?.suspend( + { + type: 'integration_action', + action: operation.action, + integrationConnectionId: descriptor.integrationConnectionId, + messageContext: actionResult.messageContext, + }, + // An approval resume leaves APPROVAL_RESUME_SCHEMA on this tool call. Name + // the action-tool schema here so a follow-up card is not still expecting + // `{ approved }`. + { resumeSchema: INTEGRATION_ACTION_RESUME_SCHEMA }, + ); +} + +/** + * Label for the approval card. The card only shows this line, so it has to name + * the destination — approving a bare `send_channel_message` tells the user + * nothing about where the message goes. + */ +function describeActionForApproval(operation: RawActionToolOperation): string { + const { channelId, userId, messageId, issueId } = operation.input; + const target = [channelId, userId, messageId, issueId].find( + (value) => typeof value === 'string' && value.length > 0, + ); + return target ? `${operation.action} → ${String(target)}` : operation.action; } function isCurrentContextQuery(query: IntegrationContextQuery): boolean { diff --git a/packages/cli/src/modules/agents/integrations/integration-tool-factory.ts b/packages/cli/src/modules/agents/integrations/integration-tool-factory.ts index c9fd4049735..a34881f0e60 100644 --- a/packages/cli/src/modules/agents/integrations/integration-tool-factory.ts +++ b/packages/cli/src/modules/agents/integrations/integration-tool-factory.ts @@ -1,4 +1,9 @@ -import { Tool, type InterruptibleToolContext } from '@n8n/agents'; +import { + APPROVAL_RESUME_SCHEMA, + APPROVAL_SUSPEND_SCHEMA, + Tool, + type InterruptibleToolContext, +} from '@n8n/agents'; import type { AgentIntegrationConfig } from '@n8n/api-types'; import { z } from 'zod'; @@ -16,7 +21,9 @@ import { executeActionToolBatch, executeActionToolOperation, executeContextToolOperation, + INTEGRATION_ACTION_RESUME_SCHEMA, } from './integration-tool-execution'; +import { INTEGRATION_ERROR_CODES } from './integration-error-codes'; import { buildActionInputSchema, buildContextInputSchema, @@ -47,14 +54,19 @@ export interface IntegrationToolCapabilities { // Suspend payload is intentionally looser than tool input: the action name has // already been validated by the generated action schema before suspension. -const actionSuspendSchema = z.object({ +const integrationActionSuspendSchema = z.object({ type: z.literal('integration_action'), action: z.string(), integrationConnectionId: z.string(), messageContext: z.unknown(), }); -const actionResumeSchema = z.record(z.string(), z.unknown()); +/** + * The tool suspends for two unrelated reasons: an interactive card waiting on a + * user response, and an action held for approval. The approval branch reuses the + * SDK payload so the chat bridge renders its Approve/Deny card unchanged. + */ +const actionSuspendSchema = z.union([integrationActionSuspendSchema, APPROVAL_SUSPEND_SCHEMA]); export function getIntegrationToolConnectionDescriptors( integrations: AgentIntegrationConfig[], @@ -93,6 +105,7 @@ export function getIntegrationToolConnectionDescriptors( actionToolDefinitions, contextToolGuidance: capabilities?.contextToolGuidance, actionToolGuidance: capabilities?.actionToolGuidance, + ...(integration.approval ? { approval: integration.approval } : {}), }; }); } @@ -153,13 +166,27 @@ export function createIntegrationActionTool(params: { .description(buildActionToolDescription(descriptor)) .input(buildActionInputSchema(descriptor.actionToolDefinitions)) .suspend(actionSuspendSchema) - .resume(actionResumeSchema) + .resume(INTEGRATION_ACTION_RESUME_SCHEMA) .handler(async (input, ctx) => { - if (ctx.resumeData) { + const interruptCtx = ctx as InterruptibleToolContext; + const approvalDecision = readApprovalDecision(interruptCtx); + + // A card resume carries the user's answer straight back to the model. An + // approval resume is a decision about work that has not run yet, so it + // either falls through to execution below or stops here. + if (approvalDecision === undefined && ctx.resumeData) { return ctx.resumeData; } + if (approvalDecision?.approved === false) { + return { + ok: false, + error: { + code: INTEGRATION_ERROR_CODES.ACTION_DECLINED, + message: `The action "${approvalDecision.action}" was not approved.`, + }, + }; + } - const interruptCtx = ctx as InterruptibleToolContext; const toolInput = input; if (toolInput.actions !== undefined) { @@ -180,10 +207,30 @@ export function createIntegrationActionTool(params: { ctx, interruptCtx, allowSuspend: true, + ...(approvalDecision ? { approvedAction: approvalDecision.action } : {}), }); }); } +/** + * The decision a user made on an approval card, or undefined when this is not + * an approval resume. `suspendPayload` is what the tool suspended with, so it + * tells the two suspend reasons apart without guessing from the resume shape. + */ +function readApprovalDecision( + ctx: InterruptibleToolContext, +): { action: string; approved: boolean } | undefined { + const payload = APPROVAL_SUSPEND_SCHEMA.safeParse(ctx.suspendPayload); + if (!payload.success) return undefined; + + const resume = APPROVAL_RESUME_SCHEMA.safeParse(ctx.resumeData); + return { + action: payload.data.toolName, + // An unreadable resume payload is not consent. + approved: resume.success && resume.data.approved, + }; +} + function getContextToolDefinitions( capabilities: IntegrationToolCapabilities | undefined, ): IntegrationContextQueryDefinition[] { diff --git a/packages/cli/src/modules/agents/integrations/integration-tool-types.ts b/packages/cli/src/modules/agents/integrations/integration-tool-types.ts index d5d17f31e9a..0b21e91de4b 100644 --- a/packages/cli/src/modules/agents/integrations/integration-tool-types.ts +++ b/packages/cli/src/modules/agents/integrations/integration-tool-types.ts @@ -1,4 +1,8 @@ -import type { AgentIntegrationConfig, N8N_CHAT_INTEGRATION_TYPE } from '@n8n/api-types'; +import type { + AgentApproval, + AgentIntegrationConfig, + N8N_CHAT_INTEGRATION_TYPE, +} from '@n8n/api-types'; import type { z } from 'zod'; import type { IntegrationErrorCode } from './integration-error-codes'; @@ -124,7 +128,13 @@ export interface IntegrationToolOperationDefinition; -export type IntegrationActionDefinition = IntegrationToolOperationDefinition; +export type IntegrationActionDefinition = IntegrationToolOperationDefinition & { + /** + * The action reaches outside the conversation the agent was addressed in. + * These are the actions a channel pre-selects when approval is turned on. + */ + sensitive?: boolean; +}; export interface IntegrationToolConnectionDescriptor { agentId?: string; @@ -138,6 +148,8 @@ export interface IntegrationToolConnectionDescriptor { actionToolDefinitions: IntegrationActionDefinition[]; contextToolGuidance?: string[]; actionToolGuidance?: string[]; + /** Actions this channel gates behind human approval. Absent = none. */ + approval?: AgentApproval; } export interface IntegrationMessageContextStore { diff --git a/packages/cli/src/modules/agents/integrations/platforms/linear-tool-definitions.ts b/packages/cli/src/modules/agents/integrations/platforms/linear-tool-definitions.ts index 891e8692c79..8661764c68d 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/linear-tool-definitions.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/linear-tool-definitions.ts @@ -284,18 +284,21 @@ export const LINEAR_ACTION_TOOL_DEFINITIONS = [ { name: 'create_issue', inputSchema: createIssueActionInputSchema, + sensitive: true, description: 'create_issue: input.teamId and input.title are required. For Linear, optional input.description, input.assigneeId, input.projectId, input.labelIds, input.priority, input.stateId, and input.parentId configure the issue.', }, { name: 'update_issue', inputSchema: updateIssueActionInputSchema, + sensitive: true, description: 'update_issue: input.issueId and at least one field are required. For Linear, optional input.title, input.description, input.teamId, input.assigneeId, input.projectId, input.labelIds, input.priority, input.stateId, and input.parentId update the issue. Some fields accept null to clear them.', }, { name: 'create_comment', inputSchema: createCommentActionInputSchema, + sensitive: true, description: 'create_comment: input.issueId and input.body are required. For Linear, optional input.parentCommentId creates a threaded reply.', }, diff --git a/packages/cli/src/modules/agents/repositories/agent-execution-thread.repository.ts b/packages/cli/src/modules/agents/repositories/agent-execution-thread.repository.ts index 51ea22ec43d..379194e7387 100644 --- a/packages/cli/src/modules/agents/repositories/agent-execution-thread.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent-execution-thread.repository.ts @@ -2,7 +2,7 @@ import type { AgentSessionOrigin, AgentSessionQueryFilters } from '@n8n/api-type import type { SerializableAgentState } from '@n8n/agents'; import { BaseRepository, TransactionRunner, type OperationContext } from '@n8n/db'; import { Service } from '@n8n/di'; -import { DataSource, Not, type EntityManager, type SelectQueryBuilder } from '@n8n/typeorm'; +import { DataSource, IsNull, Not, type EntityManager, type SelectQueryBuilder } from '@n8n/typeorm'; import chunk from 'lodash/chunk'; import { jsonParse, UserError } from 'n8n-workflow'; @@ -17,9 +17,8 @@ import { getDelegatedChildCheckpoints, type DelegatedChildCheckpoint, } from '../utils/delegated-child-checkpoints'; -import { PREVIEW_THREAD_SOURCES } from '../utils/agent-thread-access'; +import { PREVIEW_THREAD_SOURCES, type AgentSessionMode } from '../utils/agent-thread-access'; -const SESSION_NUMBER_RETRY_ATTEMPTS = 3; const CHECKPOINT_BATCH_SIZE = 400; export interface AgentExecutionThreadMetadata { @@ -45,7 +44,7 @@ export class AgentExecutionThreadRepository extends BaseRepository { - for (let attempt = 0; ; attempt++) { - try { - return await this.findOrCreateInSerializableTransaction( - threadId, - agentId, - agentName, - projectId, - access, - metadata, - taskId, - taskVersionId, - ); - } catch (error) { - if (attempt >= SESSION_NUMBER_RETRY_ATTEMPTS - 1 || !isRetriableWriteError(error)) { - throw error; - } - } - } - } - - private async findOrCreateInSerializableTransaction( - threadId: string, - agentId: string, - agentName: string, - projectId: string, - access: AgentThreadAccess, - metadata?: AgentExecutionThreadMetadata, - taskId?: string | null, - taskVersionId?: string | null, - ): Promise<{ thread: AgentExecutionThread; created: boolean }> { - return await this.manager.transaction('SERIALIZABLE', async (entityManager) => { - const repository = entityManager.getRepository(AgentExecutionThread); - if (metadata?.parentThreadId) { - const parent = await repository.findOneBy({ id: metadata.parentThreadId }); - if (parent) { - if (parent.projectId !== projectId || parent.agentId !== metadata.parentAgentId) { - throw new UserError('Session not found'); - } - access = { accessScope: parent.accessScope, ownerId: parent.ownerId }; - } - } - if (access.accessScope === 'user' && !access.ownerId) { + const manager = this.managerFor(ctx); + const repository = manager.getRepository(AgentExecutionThread); + if (metadata?.parentThreadId) { + const parent = await repository.findOneBy({ + id: metadata.parentThreadId, + projectId, + agentId: metadata.parentAgentId ?? IsNull(), + }); + if (parent) { + access = { accessScope: parent.accessScope, ownerId: parent.ownerId }; + } else if (await repository.existsBy({ id: metadata.parentThreadId })) { throw new UserError('Session not found'); } - const existing = await repository.findOneBy({ id: threadId }); - if (existing) { - if ( - existing.projectId !== projectId || - existing.agentId !== agentId || - existing.accessScope !== access.accessScope || - existing.ownerId !== access.ownerId - ) { - throw new UserError('Session not found'); - } - return { thread: existing, created: false }; - } + } + if (access.accessScope === 'user' && !access.ownerId) throw new UserError('Session not found'); - const maxResult = await repository - .createQueryBuilder('t') - .select('MAX(t.sessionNumber)', 'max') - .where('t.projectId = :projectId', { projectId }) - .getRawOne<{ max: number | null }>(); - - const sessionNumber = (maxResult?.max ?? 0) + 1; - - const thread = repository.create({ - id: threadId, - agentId, - agentName, - projectId, - ...access, - taskId: taskId ?? null, - taskVersionId: taskVersionId ?? null, - sessionNumber, - parentThreadId: metadata?.parentThreadId ?? null, - parentAgentId: metadata?.parentAgentId ?? null, + const isPostgres = manager.connection.options.type === 'postgres'; + const findSession = async () => + await repository.findOne({ + where: { + id: threadId, + agentId, + projectId, + ...access, + ownerId: access.ownerId ?? IsNull(), + }, + lock: isPostgres ? { mode: 'pessimistic_write' } : undefined, }); - const saved = await repository.save(thread); - return { thread: saved, created: true }; - }); + const existing = await findSession(); + if (existing) return { thread: existing, created: false }; + if (sessionMode === 'existing') throw new UserError('Session not found'); + + // ponytail: display numbers can repeat; add allocation coordination only if labels need uniqueness. + const maxResult = await repository + .createQueryBuilder('t') + .select('MAX(t.sessionNumber)', 'max') + .where('t.projectId = :projectId', { projectId }) + .getRawOne<{ max: number | null }>(); + const thread = { + id: threadId, + agentId, + agentName, + projectId, + ...access, + taskId: taskId ?? null, + taskVersionId: taskVersionId ?? null, + sessionNumber: (maxResult?.max ?? 0) + 1, + parentThreadId: metadata?.parentThreadId ?? null, + parentAgentId: metadata?.parentAgentId ?? null, + }; + const insert = repository + .createQueryBuilder() + .insert() + .values(thread) + .orIgnore() + .updateEntity(false); + if (isPostgres) insert.returning('id'); + const result = await insert.execute(); + const persisted = await findSession(); + if (!persisted) throw new UserError('Session not found'); + return { + thread: persisted, + created: !isPostgres || (Array.isArray(result.raw) && result.raw.length > 0), + }; } /** @@ -301,8 +286,8 @@ export class AgentExecutionThreadRepository extends BaseRepository { - await this.update(threadId, { updatedAt: new Date() }); + async bumpUpdatedAt(threadId: string, ctx: OperationContext = {}): Promise { + await this.managerFor(ctx).update(AgentExecutionThread, threadId, { updatedAt: new Date() }); } /** Atomically increment token and cost counters on a thread in a single UPDATE. */ @@ -344,15 +329,21 @@ export class AgentExecutionThreadRepository extends BaseRepository { + ): Promise<{ status: 'deleted'; refs: AgentSessionDeletionRefs } | { status: 'busy' } | null> { const manager = this.managerFor(ctx); const thread = await manager.findOne(AgentExecutionThread, { where: [ { id: threadId, projectId, agentId, accessScope: 'project' }, { id: threadId, projectId, agentId, accessScope: 'user', ownerId: userId }, ], + lock: + manager.connection.options.type === 'postgres' ? { mode: 'pessimistic_write' } : undefined, }); if (!thread) return null; + const hasRunningWork = await manager.exists(AgentExecution, { + where: { threadId, status: 'running' }, + }); + if (hasRunningWork) return { status: 'busy' }; const { attachments, executionLogs } = await this.findExternalRefs( manager, @@ -371,8 +362,11 @@ export class AgentExecutionThreadRepository extends BaseRepository binaryDataId), + status: 'deleted', + refs: { + executionLogs, + attachmentBinaryDataIds: attachments.map(({ binaryDataId }) => binaryDataId), + }, }; } @@ -469,17 +463,3 @@ function collectUnvisitedChildCheckpoints( function checkpointKey(agentId: string, runId: string): string { return `${agentId}\0${runId}`; } - -function isRetriableWriteError(error: unknown): boolean { - if (!(error instanceof Error) || !('driverError' in error)) return false; - const { driverError } = error; - if (typeof driverError !== 'object' || driverError === null || !('code' in driverError)) { - return false; - } - - const { code } = driverError; - return ( - typeof code === 'string' && - (code === '40001' || code === '40P01' || code === 'SQLITE_BUSY' || code === 'SQLITE_LOCKED') - ); -} diff --git a/packages/cli/src/modules/agents/repositories/agent-execution.repository.ts b/packages/cli/src/modules/agents/repositories/agent-execution.repository.ts index 0ce822884c8..2c24ae5df67 100644 --- a/packages/cli/src/modules/agents/repositories/agent-execution.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent-execution.repository.ts @@ -1,5 +1,6 @@ +import { BaseRepository, TransactionRunner, type OperationContext } from '@n8n/db'; import { Service } from '@n8n/di'; -import { DataSource, IsNull, Not, Repository } from '@n8n/typeorm'; +import { DataSource, IsNull, Not } from '@n8n/typeorm'; import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity'; import { AgentExecution, type AgentExecutionStatus } from '../entities/agent-execution.entity'; @@ -22,9 +23,13 @@ type AgentExecutionFinalizationValues = Pick< >; @Service() -export class AgentExecutionRepository extends Repository { - constructor(dataSource: DataSource) { - super(AgentExecution, dataSource.manager); +export class AgentExecutionRepository extends BaseRepository { + constructor(dataSource: DataSource, transactionRunner: TransactionRunner) { + super(AgentExecution, dataSource.manager, transactionRunner); + } + + async saveInContext(execution: AgentExecution, ctx: OperationContext): Promise { + return await this.managerFor(ctx).save(execution); } /** All executions in a thread, oldest first — used by the timeline view. */ diff --git a/packages/cli/src/modules/agents/sub-agents/sub-agent-runner.ts b/packages/cli/src/modules/agents/sub-agents/sub-agent-runner.ts index b3108d89bcc..8de802120b7 100644 --- a/packages/cli/src/modules/agents/sub-agents/sub-agent-runner.ts +++ b/packages/cli/src/modules/agents/sub-agents/sub-agent-runner.ts @@ -218,6 +218,7 @@ export class SubAgentRunner { agentName: runtimeSource.source.config.name, projectId: context.projectId, userMessage, + sessionMode: operation.type === 'resume' ? 'existing' : 'new', source: 'subagent', threadMetadata: { parentThreadId: operation.request.parentThreadId, diff --git a/packages/cli/src/modules/agents/utils/agent-thread-access.ts b/packages/cli/src/modules/agents/utils/agent-thread-access.ts index 3f0d2623c5d..48d64f1015c 100644 --- a/packages/cli/src/modules/agents/utils/agent-thread-access.ts +++ b/packages/cli/src/modules/agents/utils/agent-thread-access.ts @@ -1,5 +1,7 @@ import type { AgentExecutionThread } from '../entities/agent-execution-thread.entity'; +export type AgentSessionMode = 'new' | 'existing'; + export const PREVIEW_THREAD_SOURCES = ['', 'chat', 'n8n_chat'] as const; export function threadBelongsTo( diff --git a/packages/cli/src/modules/insights/__tests__/insights-pruning.task.test.ts b/packages/cli/src/modules/insights/__tests__/insights-pruning.task.test.ts index 2f7e6bd4c09..df00a101c6c 100644 --- a/packages/cli/src/modules/insights/__tests__/insights-pruning.task.test.ts +++ b/packages/cli/src/modules/insights/__tests__/insights-pruning.task.test.ts @@ -16,7 +16,7 @@ describe('InsightsPruningTask', () => { intervalSeconds: insightsConfig.pruneCheckIntervalHours * 3600, }); expect(task.effects).toBe('idempotent'); - expect(task.durable).toBe(false); + expect(task.durable).toBe(true); expect(task.retryDelaySeconds).toBe(1); }); diff --git a/packages/cli/src/modules/insights/database/repositories/__tests__/insights-by-period.repository.integration.test.ts b/packages/cli/src/modules/insights/database/repositories/__tests__/insights-by-period.repository.integration.test.ts index 3b2e46ea087..d1256c06cbf 100644 --- a/packages/cli/src/modules/insights/database/repositories/__tests__/insights-by-period.repository.integration.test.ts +++ b/packages/cli/src/modules/insights/database/repositories/__tests__/insights-by-period.repository.integration.test.ts @@ -9,7 +9,7 @@ import { import type { Project, User, WorkflowEntity } from '@n8n/db'; import { DbConnectionOptions, DbLockService, SharedWorkflowRepository } from '@n8n/db'; import { Container } from '@n8n/di'; -import type { EntityManager } from '@n8n/typeorm'; +import type { EntityManager, QueryRunner } from '@n8n/typeorm'; import { DataSource } from '@n8n/typeorm'; import { sleep } from '@n8n/utils/sleep'; import { DateTime } from 'luxon'; @@ -22,7 +22,7 @@ import { createMetadata, createRawInsightsEvent, } from '../../entities/__tests__/db-utils'; -import type { InsightsByPeriod } from '../../entities/insights-by-period'; +import { InsightsByPeriod } from '../../entities/insights-by-period'; import { TypeToNumber } from '../../entities/insights-shared'; import type { InsightsAccessFilter } from '../insights-by-period.repository'; import { InsightsByPeriodRepository } from '../insights-by-period.repository'; @@ -386,6 +386,145 @@ describe('InsightsByPeriodRepository', () => { ); }); + describe('concurrent pruning across processes', () => { + let repository: InsightsByPeriodRepository; + let otherInstance: DataSource; + let table: string; + + beforeAll(async () => { + if (!isPostgres) return; + repository = Container.get(InsightsByPeriodRepository); + const { schema, tableName } = repository.metadata; + const esc = (name: string): string => repository.manager.connection.driver.escape(name); + table = schema ? `${esc(schema)}.${esc(tableName)}` : esc(tableName); + otherInstance = new DataSource(Container.get(DbConnectionOptions).getOptions()); + await otherInstance.initialize(); + }); + + afterAll(async () => { + if (otherInstance?.isInitialized) await otherInstance.destroy(); + // Later blocks query a recent window and do not truncate, so leave none of these rows. + if (isPostgres) { + await truncateInsights(); + } + }); + + async function truncateInsights(): Promise { + await testDb.truncate([ + 'InsightsRaw', + 'InsightsByPeriod', + 'InsightsMetadata', + 'WorkflowEntity', + 'Project', + ]); + } + + async function seedRows(): Promise<{ old: number; fresh: number }> { + await truncateInsights(); + const project = await createTeamProject(); + const workflow = await createWorkflow({ nodes: [] }, project); + const { metaId } = await createMetadata(workflow); + const now = DateTime.utc(); + const row = (periodStart: DateTime): InsightsByPeriod => { + const event = new InsightsByPeriod(); + event.metaId = metaId; + event.type = 'success'; + event.value = 1; + event.periodUnit = 'hour'; + event.periodStart = periodStart.startOf('hour').toJSDate(); + return event; + }; + const old = Array.from({ length: 200 }, (_, hour) => + row(now.minus({ days: 11, hours: hour })), + ); + const fresh = Array.from({ length: 24 }, (_, hour) => + row(now.minus({ days: 1, hours: hour })), + ); + await repository.save([...old, ...fresh]); + return { old: old.length, fresh: fresh.length }; + } + + async function holdTransaction(sql: string): Promise { + const holder = otherInstance.createQueryRunner(); + await holder.connect(); + await holder.startTransaction(); + try { + await holder.query(sql); + } catch (error) { + await releaseHolder(holder); + throw error; + } + return holder; + } + + /** `release()` alone returns an open transaction to the pool, whose locks outlive the test. */ + async function releaseHolder(holder: QueryRunner): Promise { + try { + if (holder.isTransactionActive) { + await holder.rollbackTransaction(); + } + } finally { + await holder.release(); + } + } + + test.skipIf(!isPostgres)( + 'waits for the rows another transaction is deleting and then finds nothing left', + async () => { + // ARRANGE + const seeded = await seedRows(); + const holder = await holdTransaction( + `DELETE FROM ${table} WHERE "periodStart" <= now() - interval '10 days'`, + ); + + // ACT + let affected: number | null | undefined; + try { + const pending = repository.pruneOldData(10); + const outcome = await Promise.race([ + pending.then(() => 'finished'), + sleep(300).then(() => 'blocked'), + ]); + expect(outcome).toBe('blocked'); + await holder.commitTransaction(); + ({ affected } = await pending); + } finally { + await releaseHolder(holder); + } + + // ASSERT + expect(affected).toBe(0); + await expect(repository.count()).resolves.toBe(seeded.fresh); + }, + ); + + test.skipIf(!isPostgres)( + 'deletes only the old rows the other transaction left behind', + async () => { + // ARRANGE + const seeded = await seedRows(); + const holder = await holdTransaction( + `DELETE FROM ${table} WHERE id IN (SELECT id FROM ${table} WHERE "periodStart" <= now() - interval '10 days' LIMIT 100)`, + ); + + // ACT + let affected: number | null | undefined; + try { + const pending = repository.pruneOldData(10); + await sleep(300); + await holder.commitTransaction(); + ({ affected } = await pending); + } finally { + await releaseHolder(holder); + } + + // ASSERT + expect(affected).toBe(seeded.old - 100); + await expect(repository.count()).resolves.toBe(seeded.fresh); + }, + ); + }); + describe('access filter', () => { let member: User; let accessibleProject: Project; diff --git a/packages/cli/src/modules/insights/insights-pruning.task.ts b/packages/cli/src/modules/insights/insights-pruning.task.ts index 26c237b6348..15f63ad800e 100644 --- a/packages/cli/src/modules/insights/insights-pruning.task.ts +++ b/packages/cli/src/modules/insights/insights-pruning.task.ts @@ -20,8 +20,9 @@ export class InsightsPruningTask implements SystemTask { readonly effects: SystemTaskEffects = 'idempotent'; - readonly durable = false; + readonly durable = true; + /** Only the in-memory timer, which runs whenever the task does not run durably, honors this. */ readonly retryDelaySeconds = 1; constructor( diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.security.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.security.test.ts index e147e1b6464..2197b543ec5 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.security.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.security.test.ts @@ -572,7 +572,7 @@ describe('credentialService.list — eval allowlist', () => { { id: 'c3', name: 'Slack #2', type: 'slackApi', description: null }, ] as never); - const ctx = service.createContext(user, { credentialIdAllowlist: ['c1', 'c3'] }); + const ctx = service.createContext(user, { getCredentialIdAllowlist: () => ['c1', 'c3'] }); const result = await ctx.credentialService.list(); expect(result).toEqual([ @@ -581,8 +581,23 @@ describe('credentialService.list — eval allowlist', () => { ]); }); + it('reads the allowlist on every call, so a credential allowlisted mid-run is listed', async () => { + credentialsService.getMany.mockResolvedValue([ + { id: 'c1', name: 'Slack', type: 'slackApi', description: null }, + { id: 'c2', name: 'OpenAI', type: 'openAiApi', description: null }, + ] as never); + const allowlist = ['c1']; + + const ctx = service.createContext(user, { getCredentialIdAllowlist: () => allowlist }); + expect((await ctx.credentialService.list()).map((c) => c.id)).toEqual(['c1']); + + // The harness creates a credential on a setup card and re-sends the whole list. + allowlist.push('c2'); + expect((await ctx.credentialService.list()).map((c) => c.id)).toEqual(['c1', 'c2']); + }); + it('returns an empty list without querying the credentials service when the allowlist is empty', async () => { - const ctx = service.createContext(user, { credentialIdAllowlist: [] }); + const ctx = service.createContext(user, { getCredentialIdAllowlist: () => [] }); const result = await ctx.credentialService.list(); expect(result).toEqual([]); diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts index 027cefb167b..bebce8c8556 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts @@ -2286,6 +2286,7 @@ describe('createDataTableAdapter', () => { function createWorkflowAdapterForTests(overrides?: { namedVersionsLicensed?: boolean; foldersLicensed?: boolean; + teamProjectsLicensed?: boolean; branchReadOnly?: boolean; sharingEnabled?: boolean; folderExploration?: boolean; @@ -2371,6 +2372,7 @@ function createWorkflowAdapterForTests(overrides?: { }; const mockProjectService = { getAccessibleProjects: vi.fn().mockResolvedValue([{ id: 'team-project-id' }, { id: 'p2' }]), + getPersonalProject: vi.fn().mockResolvedValue({ id: 'personal-project-id' }), }; const mockLogger = { error: vi.fn(), @@ -2454,6 +2456,11 @@ function createWorkflowAdapterForTests(overrides?: { : (mockFolderFinderService as unknown as ConstructorParameters< typeof InstanceAiAdapterService >[41]), + undefined, + undefined, + { + isTeamProjectsLicensed: vi.fn().mockReturnValue(overrides?.teamProjectsLicensed ?? true), + } as unknown as ConstructorParameters[44], ); const boundProjectId = @@ -4253,6 +4260,49 @@ describe('license-gated features', () => { }); }); + describe('projects (quota:maxTeamProjects)', () => { + // An admin reads every project on the instance, peer personal ones included. + const accessibleProjects = [ + { id: 'personal-project-id', name: 'Personal', type: 'personal' }, + { id: 'team-project-id', name: 'Team', type: 'team' }, + { id: 'p2', name: 'Other team', type: 'team' }, + { id: 'peer-personal-id', name: 'Peer', type: 'personal' }, + ]; + + it('lists every accessible project when team projects are licensed', async () => { + const { context, mockProjectService } = createWorkflowAdapterForTests({ + teamProjectsLicensed: true, + }); + mockProjectService.getAccessibleProjects.mockResolvedValue(accessibleProjects); + + await expect(context.workspaceService!.listProjects()).resolves.toEqual(accessibleProjects); + }); + + it('lists only the personal and the bound project when team projects are not licensed', async () => { + const { context, mockProjectService } = createWorkflowAdapterForTests({ + teamProjectsLicensed: false, + }); + mockProjectService.getAccessibleProjects.mockResolvedValue(accessibleProjects); + + await expect(context.workspaceService!.listProjects()).resolves.toEqual([ + { id: 'personal-project-id', name: 'Personal', type: 'personal' }, + { id: 'team-project-id', name: 'Team', type: 'team' }, + ]); + }); + + it('lists only the personal project when team projects are not licensed and no project is bound', async () => { + const { context, mockProjectService } = createWorkflowAdapterForTests({ + teamProjectsLicensed: false, + projectId: null, + }); + mockProjectService.getAccessibleProjects.mockResolvedValue(accessibleProjects); + + await expect(context.workspaceService!.listProjects()).resolves.toEqual([ + { id: 'personal-project-id', name: 'Personal', type: 'personal' }, + ]); + }); + }); + describe('licenseHints', () => { it('includes hints for unlicensed features', () => { const { context } = createWorkflowAdapterForTests({ @@ -4288,6 +4338,16 @@ describe('license-gated features', () => { expect.arrayContaining([expect.stringContaining('Named workflow versions')]), ); }); + + it('includes a hint when team projects are not licensed', () => { + const { context } = createWorkflowAdapterForTests({ + namedVersionsLicensed: true, + foldersLicensed: true, + teamProjectsLicensed: false, + }); + + expect(context.licenseHints).toEqual([expect.stringContaining('Team projects')]); + }); }); }); @@ -6501,7 +6561,7 @@ describe('createContext — builder delegate wiring', () => { service.createContext(mockUser, { threadId: 'thread-1', projectId: 'proj-1', - credentialIdAllowlist: [], + getCredentialIdAllowlist: () => [], }); expect(builderDelegateAdapter.createDelegate).toHaveBeenCalledWith( diff --git a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts index 1c2e61963fa..4eba352f4cc 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts @@ -17,7 +17,7 @@ import { INSTANCE_AI_PROGRESSIVE_BUILDING_ENABLED_VARIANT, } from '@n8n/api-types'; import type { AiGatewayConfigDto } from '@n8n/api-types'; -import { Logger, ModuleRegistry } from '@n8n/backend-common'; +import { LicenseState, Logger, ModuleRegistry } from '@n8n/backend-common'; import { OutboundHttp } from '@n8n/backend-network'; import { GlobalConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; @@ -410,6 +410,9 @@ export class InstanceAiAdapterService { private readonly folderFinderService?: FolderFinderService, private readonly instanceContext?: InstanceContextService, private readonly executeNodeService?: ExecuteNodeService, + // Optional for the same positional-construction reason as above. + // See `teamProjectsLicensed()` for the absent case. + private readonly licenseState?: LicenseState, ) { this.logger = logger.scoped('instance-ai'); this.allowSendingParameterValues = globalConfig.ai.allowSendingParameterValues; @@ -425,8 +428,10 @@ export class InstanceAiAdapterService { pushRef?: string; threadId?: string; projectId?: string; - /** Eval-only: restrict the credential `list()` view to these IDs. */ - credentialIdAllowlist?: string[]; + /** Eval-only: restrict the credential `list()` view to these IDs. A getter, + * read on every `list()`: the harness appends credentials it creates + * mid-run, after this context is built. */ + getCredentialIdAllowlist?: () => string[] | undefined; /** Eval-only: resolve a credential's connection test as successful without * contacting the provider. A predicate rather than a list because the * harness registers bypasses mid-run, after this context is built. */ @@ -461,7 +466,7 @@ export class InstanceAiAdapterService { pushRef, threadId, projectId, - credentialIdAllowlist, + getCredentialIdAllowlist, shouldBypassCredentialTest, agentId, configEvalsEnabled, @@ -481,7 +486,7 @@ export class InstanceAiAdapterService { const credentialService = this.createCredentialAdapter( user, projectId, - credentialIdAllowlist, + getCredentialIdAllowlist, shouldBypassCredentialTest, ); return { @@ -515,7 +520,7 @@ export class InstanceAiAdapterService { ? { activityService: this.createActivityAdapter(user, projectId) } : {}), webResearchService: this.createWebResearchAdapter(user, searchProxyConfig), - workspaceService: this.createWorkspaceAdapter(user), + workspaceService: this.createWorkspaceAdapter(user, projectId), templatesService: this.getTemplatesService(), workflowTemplateService: this.createWorkflowTemplateAdapter(), licenseHints: this.buildLicenseHints(), @@ -544,7 +549,7 @@ export class InstanceAiAdapterService { targetAgentId, ), credentialService, - { useEvalModelCatalog: credentialIdAllowlist !== undefined }, + { useEvalModelCatalog: getCredentialIdAllowlist?.() !== undefined }, ), } : {}), @@ -784,6 +789,11 @@ export class InstanceAiAdapterService { }; } + /** Team projects are a quota, not a feature flag, so the read goes through `LicenseState`. */ + private teamProjectsLicensed(): boolean { + return this.licenseState?.isTeamProjectsLicensed() ?? true; + } + private buildLicenseHints(): string[] { const hints: string[] = []; if (!this.license.isLicensed('feat:namedVersions')) { @@ -796,6 +806,11 @@ export class InstanceAiAdapterService { '**Folders** — organizing workflows into folders (list-folders, create-folder, delete-folder, move-workflow-to-folder) is available on registered Community Edition or paid plans.', ); } + if (!this.teamProjectsLicensed()) { + hints.push( + '**Team projects** — this instance has no team-project license. `list-projects` returns only the user personal project and the project of this conversation, even when the instance holds more. Do not offer to create or move resources into another project. Team projects need a plan upgrade.', + ); + } return hints; } @@ -2413,7 +2428,7 @@ export class InstanceAiAdapterService { private createCredentialAdapter( user: User, boundProjectId?: string, - credentialIdAllowlist?: string[], + getCredentialIdAllowlist?: () => string[] | undefined, shouldBypassCredentialTest?: (credentialId: string) => boolean, ): InstanceAiCredentialService { const { @@ -2846,16 +2861,21 @@ export class InstanceAiAdapterService { }, }; - if (!credentialIdAllowlist) return adapter; + if (!getCredentialIdAllowlist?.()) return adapter; // Eval runs pin each build thread to a declared credential set so // concurrent test cases can't observe each other's credentials. Discovery // only: get/test/delete still resolve explicit IDs the caller already has. - const allowed = new Set(credentialIdAllowlist); + // Read per call, not snapshotted: a credential the harness creates on a + // setup card must show on the next card of the same run. return { ...adapter, - list: async (options) => - allowed.size === 0 ? [] : (await adapter.list(options)).filter((c) => allowed.has(c.id)), + list: async (options) => { + const allowed = new Set(getCredentialIdAllowlist()); + return allowed.size === 0 + ? [] + : (await adapter.list(options)).filter((c) => allowed.has(c.id)); + }, }; } @@ -3763,7 +3783,7 @@ export class InstanceAiAdapterService { }; } - private createWorkspaceAdapter(user: User): InstanceAiWorkspaceService { + private createWorkspaceAdapter(user: User, boundProjectId?: string): InstanceAiWorkspaceService { const { projectService, folderService, @@ -3776,6 +3796,7 @@ export class InstanceAiAdapterService { } = this; const assertNotReadOnly = (resource: string) => this.assertInstanceNotReadOnly(resource); const { assertProjectScope } = this.createProjectScopeHelpers(user); + const teamProjectsLicensed = this.teamProjectsLicensed(); const adapter: InstanceAiWorkspaceService = { async getProject(projectId: string): Promise { @@ -3786,11 +3807,18 @@ export class InstanceAiAdapterService { async listProjects(): Promise { const projects = await projectService.getAccessibleProjects(user); - return projects.map((p) => ({ + const summaries = projects.map((p) => ({ id: p.id, name: p.name, type: p.type, })); + if (teamProjectsLicensed) return summaries; + // An instance that loses its team-project license keeps its projects, and + // an admin still reads all of them. The user cannot work in those + // projects, so list only their own personal project and the project this + // conversation is bound to. + const personalProjectId = (await projectService.getPersonalProject(user))?.id; + return summaries.filter((p) => p.id === personalProjectId || p.id === boundProjectId); }, ...(this.license.isLicensed('feat:folders') diff --git a/packages/cli/src/modules/instance-ai/instance-ai.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.service.ts index 24f95314c15..043bc5889e4 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.service.ts @@ -2494,7 +2494,7 @@ export class InstanceAiService { pushRef, threadId, projectId: boundProjectId, - credentialIdAllowlist: this.evalCredentialAllowlists.get(threadId), + getCredentialIdAllowlist: () => this.evalCredentialAllowlists.get(threadId), shouldBypassCredentialTest: (credentialId: string) => this.evalCredentialAllowlists.shouldBypassTest(threadId, credentialId), configEvalsEnabled, diff --git a/packages/cli/src/modules/n8n-packages/entities/project/__tests__/project-importer.test.ts b/packages/cli/src/modules/n8n-packages/entities/project/__tests__/project-importer.test.ts index 29ed10c7dd4..113d0dc92a9 100644 --- a/packages/cli/src/modules/n8n-packages/entities/project/__tests__/project-importer.test.ts +++ b/packages/cli/src/modules/n8n-packages/entities/project/__tests__/project-importer.test.ts @@ -42,7 +42,7 @@ describe('ProjectImporter.apply — custom span attributes', () => { ); }); - it('passes customTelemetryTags to updateProject on update', async () => { + it('preserves package tags on update', async () => { const { importer, projectService } = makeImporter(); const item: ProjectPlanItem = { action: 'update', @@ -57,6 +57,7 @@ describe('ProjectImporter.apply — custom span attributes', () => { user, 'proj-1', expect.objectContaining({ customTelemetryTags: tags }), + { preserveCustomTelemetryTags: true }, ); }); @@ -74,6 +75,7 @@ describe('ProjectImporter.apply — custom span attributes', () => { user, 'proj-1', expect.objectContaining({ customTelemetryTags: undefined }), + { preserveCustomTelemetryTags: true }, ); }); }); diff --git a/packages/cli/src/modules/n8n-packages/entities/project/project-importer.ts b/packages/cli/src/modules/n8n-packages/entities/project/project-importer.ts index 1988cd6b5b9..16cfbc6b287 100644 --- a/packages/cli/src/modules/n8n-packages/entities/project/project-importer.ts +++ b/packages/cli/src/modules/n8n-packages/entities/project/project-importer.ts @@ -114,12 +114,17 @@ export class ProjectImporter { } case 'update': - await this.projectService.updateProject(user, item.sourceProjectId, { - name: item.name, - icon: item.icon, - description: item.description, - customTelemetryTags: item.customTelemetryTags, - }); + await this.projectService.updateProject( + user, + item.sourceProjectId, + { + name: item.name, + icon: item.icon, + description: item.description, + customTelemetryTags: item.customTelemetryTags, + }, + { preserveCustomTelemetryTags: true }, + ); summaries.push({ sourceProjectId: item.sourceProjectId, localId: item.sourceProjectId, diff --git a/packages/cli/src/modules/n8n-packages/io/__tests__/package-directory-inventory-reader.test.ts b/packages/cli/src/modules/n8n-packages/io/__tests__/package-directory-inventory-reader.test.ts index 3ba1bc4ad50..cd54a07a635 100644 --- a/packages/cli/src/modules/n8n-packages/io/__tests__/package-directory-inventory-reader.test.ts +++ b/packages/cli/src/modules/n8n-packages/io/__tests__/package-directory-inventory-reader.test.ts @@ -88,6 +88,17 @@ describe('PackageDirectoryInventoryReader', () => { ]); }); + it('retains project metadata from the package', async () => { + const metadata = { + ...project('p1'), + icon: { type: 'icon', value: '' }, + description: '', + customTelemetryTags: [{ key: ' team ', value: ' Sales ' }], + }; + const inventory = await reader.read(sourceOf({ 'projects/p1/project.json': metadata })); + expect(inventory.projects).toEqual([{ path: 'projects/p1', ...metadata }]); + }); + it('returns an empty inventory for a directory without entity files', async () => { expect(await reader.read(sourceOf({ 'manifest.json': '{}' }))).toEqual({ projects: [], diff --git a/packages/cli/src/modules/n8n-packages/io/directory/package-directory-inventory-reader.ts b/packages/cli/src/modules/n8n-packages/io/directory/package-directory-inventory-reader.ts index 961050acca3..f34c9c15240 100644 --- a/packages/cli/src/modules/n8n-packages/io/directory/package-directory-inventory-reader.ts +++ b/packages/cli/src/modules/n8n-packages/io/directory/package-directory-inventory-reader.ts @@ -11,7 +11,10 @@ import { serializedCredentialSchema, type SerializedCredential, } from '../../spec/serialized/credential.schema'; -import { serializedProjectSchema } from '../../spec/serialized/project.schema'; +import { + serializedProjectSchema, + type SerializedProject, +} from '../../spec/serialized/project.schema'; import { serializedVariableSchema, type SerializedVariable, @@ -23,11 +26,9 @@ import type { PackageReader } from '../package-reader'; /** Listing and reads only. The inventory never needs a manifest. */ export type PackageFileSource = Pick; -export interface InventoryProject { +export interface InventoryProject extends SerializedProject { /** Directory that holds `project.json`. */ path: string; - id: string; - name: string; } export interface InventoryWorkflow { @@ -132,7 +133,7 @@ export class PackageDirectoryInventoryReader { } const project = await this.readEntity(source, file, serializedProjectSchema); assertUnseen(seenIds, project.id, 'project id'); - projects.push({ path: file.projectDir, id: project.id, name: project.name }); + projects.push({ path: file.projectDir, ...project }); } return projects; diff --git a/packages/cli/src/modules/otel/__tests__/execution-level-tracer.test.ts b/packages/cli/src/modules/otel/__tests__/execution-level-tracer.test.ts index e08039a3bbd..c53d3ffb1da 100644 --- a/packages/cli/src/modules/otel/__tests__/execution-level-tracer.test.ts +++ b/packages/cli/src/modules/otel/__tests__/execution-level-tracer.test.ts @@ -1,5 +1,6 @@ import type { Logger } from '@n8n/backend-common'; import { SpanStatusCode, trace } from '@opentelemetry/api'; +import { hrTimeToMilliseconds } from '@opentelemetry/core'; import { mock } from 'vitest-mock-extended'; import { ExecutionLevelTracer } from '../execution-level-tracer'; @@ -318,6 +319,123 @@ describe('ExecutionLevelTracer', () => { }); }); + describe('endCrashedWorkflow', () => { + it('should end a tracked workflow span as crashed and stop tracking it', () => { + tracer.startWorkflow({ + executionId: 'exec-crashed', + tracingContext: inboundTracingContext, + workflow: defaultWorkflow, + }); + tracer.startNode({ + executionId: 'exec-crashed', + node: { id: 'n1', name: 'Node1', type: 'n8n-nodes-base.set', typeVersion: 1 }, + }); + + tracer.endCrashedWorkflow({ + executionId: 'exec-crashed', + workflowId: 'wf-1', + workflowName: 'Test', + mode: 'trigger', + detector: 'stall', + stoppedAt: new Date(), + }); + + const spans = otel.getFinishedSpans(); + expect(spans).toHaveLength(2); + + const nodeSpan = spans.find((s) => s.name === 'node.execute')!; + expect(nodeSpan.attributes['n8n.node.termination_reason']).toBe('workflow_crashed'); + + const span = spans.find((s) => s.name === 'workflow.execute')!; + expect(span.name).toBe('workflow.execute'); + expect(span.attributes['n8n.execution.status']).toBe('crashed'); + expect(span.attributes['n8n.execution.error_type']).toBe('WorkflowCrashedError'); + expect(span.attributes['n8n.execution.crash.detector']).toBe('stall'); + expect(span.attributes['n8n.execution.reconstructed']).toBe(false); + expect(span.status.code).toBe(SpanStatusCode.ERROR); + + const headers: Record = {}; + tracer.injectTraceHeaders('exec-crashed', undefined, headers); + expect(headers.traceparent).toBeUndefined(); + }); + + it('should reconstruct the workflow span for an untracked execution', () => { + const startedAt = new Date('2025-01-01T00:00:00.000Z'); + const stoppedAt = new Date('2025-01-01T00:01:00.000Z'); + + tracer.endCrashedWorkflow({ + executionId: 'exec-untracked', + workflowId: 'wf-1', + workflowName: 'Test', + workflowVersionId: 'v1', + mode: 'trigger', + detector: 'queue-recovery', + startedAt, + stoppedAt, + tracingContext: inboundTracingContext, + workflow: { customAttributes: { workflowTag: 'checkout' } }, + project: { id: 'project-1', customAttributes: { team: 'platform' } }, + }); + + const spans = otel.getFinishedSpans(); + expect(spans).toHaveLength(1); + + const span = spans[0]; + expect(span.name).toBe('workflow.execute'); + expect(span.attributes['n8n.workflow.id']).toBe('wf-1'); + expect(span.attributes['n8n.workflow.name']).toBe('Test'); + expect(span.attributes['n8n.execution.id']).toBe('exec-untracked'); + expect(span.attributes['n8n.execution.status']).toBe('crashed'); + expect(span.attributes['n8n.execution.crash.detector']).toBe('queue-recovery'); + expect(span.attributes['n8n.execution.reconstructed']).toBe(true); + expect(span.attributes['n8n.workflow.version_id']).toBe('v1'); + expect(span.attributes['n8n.project.id']).toBe('project-1'); + expect(span.attributes['n8n.workflow.custom.workflowTag']).toBe('checkout'); + expect(span.attributes['n8n.project.custom.team']).toBe('platform'); + expect(hrTimeToMilliseconds(span.startTime)).toBe(startedAt.getTime()); + expect(hrTimeToMilliseconds(span.endTime)).toBe(stoppedAt.getTime()); + expect(span.spanContext().traceId).toBe('abcdef1234567890abcdef1234567890'); + }); + + it.each([ + ['tracked', true], + ['reconstructed', false], + ])('should attach the retry attributes to a %s span', (_kind, isTracked) => { + if (isTracked) { + tracer.startWorkflow({ executionId: 'exec-crashed-retry', workflow: defaultWorkflow }); + } + + tracer.endCrashedWorkflow({ + executionId: 'exec-crashed-retry', + workflowId: 'wf-1', + mode: 'retry', + retryOf: 'exec-original', + detector: 'stall', + stoppedAt: new Date(), + }); + + const span = otel.getFinishedSpans()[0]; + expect(span.attributes['n8n.execution.is_retry']).toBe(true); + expect(span.attributes['n8n.execution.retry_of']).toBe('exec-original'); + }); + + it('should omit the version id, project and retry source a reconstructed span has no value for', () => { + tracer.endCrashedWorkflow({ + executionId: 'exec-untracked-bare', + workflowId: 'wf-1', + mode: 'trigger', + detector: 'queue-recovery', + stoppedAt: new Date(), + }); + + const span = otel.getFinishedSpans()[0]; + expect(span.attributes).not.toHaveProperty('n8n.workflow.version_id'); + expect(span.attributes).not.toHaveProperty('n8n.project.id'); + expect(span.attributes).not.toHaveProperty('n8n.execution.retry_of'); + expect(span.attributes['n8n.execution.is_retry']).toBe(false); + }); + }); + describe('startNode / endNode', () => { it('should create node span as child of workflow span', () => { tracer.startWorkflow({ diff --git a/packages/cli/src/modules/otel/__tests__/otel-lifecycle-handler.test.ts b/packages/cli/src/modules/otel/__tests__/otel-lifecycle-handler.test.ts index d6aa082c125..60273728b8e 100644 --- a/packages/cli/src/modules/otel/__tests__/otel-lifecycle-handler.test.ts +++ b/packages/cli/src/modules/otel/__tests__/otel-lifecycle-handler.test.ts @@ -9,6 +9,8 @@ import { mock } from 'vitest-mock-extended'; import { Workflow } from 'n8n-workflow'; import type { INodeTypes, IRun, IRunExecutionData, WorkflowExecuteMode } from 'n8n-workflow'; +import { EventService } from '@/events/event.service'; +import type { RelayEventMap } from '@/events/maps/relay.event-map'; import type { OwnershipService } from '@/services/ownership.service'; import type { ExecutionLevelTracer } from '../execution-level-tracer'; @@ -109,6 +111,7 @@ describe('OtelLifecycleHandler', () => { ownershipService, logger, licenseState, + mock(), ); licenseState.isOtelCustomSpanAttributesLicensed.mockReturnValue(true); tracer.startWorkflow.mockReturnValue(generatedSpanContext); @@ -425,6 +428,7 @@ describe('OtelLifecycleHandler', () => { ownershipService, logger, licenseState, + mock(), ); licenseState.isOtelCustomSpanAttributesLicensed.mockReturnValue(true); tracer.startWorkflow.mockReturnValue(resumedSpanContext); @@ -592,6 +596,7 @@ describe('OtelLifecycleHandler', () => { ownershipService, logger, licenseState, + mock(), ); }); @@ -650,6 +655,148 @@ describe('OtelLifecycleHandler', () => { }); }); + describe('onExecutionCrashed', () => { + const tracer = mock(); + const traceContextService = mock(); + const ownershipService = mock(); + const logger = mock(); + const licenseState = mock(); + + const eventTracingContext: TracingContext = { + traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', + }; + + const startedAt = new Date('2025-01-01T00:00:00.000Z'); + const stoppedAt = new Date('2025-01-01T00:01:00.000Z'); + + const makeEvent = ( + overrides: Partial = {}, + ): RelayEventMap['execution-crashed'] => ({ + executionId: 'exec-1', + workflowId: 'wf-1', + workflowName: 'Test', + mode: 'trigger', + startedAt, + stoppedAt, + detector: 'queue-recovery', + hostId: 'main-1', + ...overrides, + }); + + const makeHandler = (overrides: Partial = {}, eventService?: EventService) => + new OtelLifecycleHandler( + tracer, + traceContextService, + mock(), + makeOtelSettingsService(overrides), + ownershipService, + logger, + licenseState, + eventService ?? mock(), + ); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should handle the `execution-crashed` event once initialised', async () => { + const eventService = new EventService(); + makeHandler({}, eventService).init(); + + eventService.emit('execution-crashed', makeEvent()); + await new Promise(setImmediate); + + expect(tracer.endCrashedWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ executionId: 'exec-1', detector: 'queue-recovery' }), + ); + }); + + it('should end the crashed workflow span when the event carries no trace context', async () => { + await makeHandler().onExecutionCrashed(makeEvent()); + + expect(traceContextService.get).not.toHaveBeenCalled(); + expect(tracer.endCrashedWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ executionId: 'exec-1', tracingContext: undefined }), + ); + }); + + it('should do nothing when tracing is disabled', async () => { + await makeHandler({ enabled: false }).onExecutionCrashed(makeEvent()); + + expect(traceContextService.get).not.toHaveBeenCalled(); + expect(tracer.endCrashedWorkflow).not.toHaveBeenCalled(); + }); + + it('should do nothing for a manual execution when productionExecutionsOnly is true', async () => { + await makeHandler({ productionExecutionsOnly: true }).onExecutionCrashed( + makeEvent({ mode: 'manual' }), + ); + + expect(traceContextService.get).not.toHaveBeenCalled(); + expect(tracer.endCrashedWorkflow).not.toHaveBeenCalled(); + }); + + it('should end the crashed workflow span with the trace context on the event', async () => { + await makeHandler().onExecutionCrashed(makeEvent({ tracingContext: eventTracingContext })); + + expect(traceContextService.get).not.toHaveBeenCalled(); + expect(tracer.endCrashedWorkflow).toHaveBeenCalledWith({ + executionId: 'exec-1', + workflowId: 'wf-1', + workflowName: 'Test', + workflowVersionId: undefined, + mode: 'trigger', + retryOf: undefined, + detector: 'queue-recovery', + startedAt, + stoppedAt, + tracingContext: eventTracingContext, + workflow: { customAttributes: undefined }, + project: undefined, + }); + }); + + it('should pass the version id, retry source, project and custom attributes on the event', async () => { + licenseState.isOtelCustomSpanAttributesLicensed.mockReturnValue(true); + + await makeHandler().onExecutionCrashed( + makeEvent({ + workflowVersionId: 'version-1', + retryOf: 'exec-original', + workflowCustomTelemetryTags: [{ key: 'workflowTag', value: 'checkout' }], + project: { id: 'project-1', customTelemetryTags: [{ key: 'team', value: 'platform' }] }, + }), + ); + + expect(tracer.endCrashedWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + workflowVersionId: 'version-1', + retryOf: 'exec-original', + workflow: { customAttributes: { workflowTag: 'checkout' } }, + project: { id: 'project-1', customAttributes: { team: 'platform' } }, + }), + ); + }); + + it('should omit the custom attributes but keep the project id when they are not licensed', async () => { + licenseState.isOtelCustomSpanAttributesLicensed.mockReturnValue(false); + + await makeHandler().onExecutionCrashed( + makeEvent({ + workflowCustomTelemetryTags: [{ key: 'workflowTag', value: 'checkout' }], + project: { id: 'project-1', customTelemetryTags: [{ key: 'team', value: 'platform' }] }, + }), + ); + + expect(tracer.endCrashedWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + workflow: { customAttributes: undefined }, + project: { id: 'project-1', customAttributes: undefined }, + }), + ); + }); + }); + describe('onNodeStart / onNodeEnd', () => { const tracer = mock(); const traceContextService = mock(); @@ -703,6 +850,7 @@ describe('OtelLifecycleHandler', () => { ownershipService, logger, licenseState, + mock(), ); licenseState.isOtelCustomSpanAttributesLicensed.mockReturnValue(true); }); @@ -717,6 +865,7 @@ describe('OtelLifecycleHandler', () => { ownershipService, logger, licenseState, + mock(), ); handler.onNodeStart(makeStartCtx()); @@ -920,6 +1069,7 @@ describe('productionExecutionsOnly filter', () => { ownershipService, logger, licenseState, + mock(), ); licenseState.isOtelCustomSpanAttributesLicensed.mockReturnValue(true); }); @@ -1012,6 +1162,7 @@ describe('onReloadOtelConfig', () => { mock(), mock(), licenseState, + mock(), ); } diff --git a/packages/cli/src/modules/otel/execution-level-tracer.ts b/packages/cli/src/modules/otel/execution-level-tracer.ts index 4f7d10e9ed1..50dbaa878db 100644 --- a/packages/cli/src/modules/otel/execution-level-tracer.ts +++ b/packages/cli/src/modules/otel/execution-level-tracer.ts @@ -4,9 +4,12 @@ import type { Context, Exception, Span } from '@opentelemetry/api'; import { context, propagation, SpanStatusCode, trace } from '@opentelemetry/api'; import type { ExecutionStatus } from 'n8n-workflow'; +import { WorkflowCrashedError } from '@/errors/workflow-crashed.error'; + import { type StartWorkflowParams, type EndWorkflowParams, + type EndCrashedWorkflowParams, type StartNodeParams, type EndNodeParams, isEndNodeError, @@ -120,6 +123,54 @@ export class ExecutionLevelTracer { } } + endCrashedWorkflow(params: EndCrashedWorkflowParams): void { + try { + const tracked = this.activeWorkflowSpans.get(params.executionId); + const span = tracked?.span ?? this.reconstructWorkflowSpan(params); + span.setAttributes({ + [ATTR.EXECUTION_MODE]: params.mode, + [ATTR.EXECUTION_STATUS]: 'crashed', + [ATTR.EXECUTION_ERROR_TYPE]: WorkflowCrashedError.name, + [ATTR.EXECUTION_CRASH_DETECTOR]: params.detector, + [ATTR.EXECUTION_RECONSTRUCTED]: tracked === undefined, + [ATTR.EXECUTION_IS_RETRY]: params.mode === 'retry', + ...(params.retryOf ? { [ATTR.EXECUTION_RETRY_OF]: params.retryOf } : {}), + }); + span.setStatus({ code: SpanStatusCode.ERROR }); + span.recordException(new WorkflowCrashedError()); + this.endDanglingNodeSpans(params.executionId, 'workflow_crashed'); + span.end(params.stoppedAt); + } catch (error) { + this.logger.warn('Failed to end crashed workflow span', { + executionId: params.executionId, + error: error instanceof Error ? error.message : String(error), + }); + } finally { + this.activeWorkflowSpans.delete(params.executionId); + } + } + + private reconstructWorkflowSpan(params: EndCrashedWorkflowParams) { + return this.tracer.startSpan( + 'workflow.execute', + { + startTime: params.startedAt, + attributes: { + [ATTR.WORKFLOW_ID]: params.workflowId, + ...(params.workflowName && { [ATTR.WORKFLOW_NAME]: params.workflowName }), + ...(params.workflowVersionId && { + [ATTR.WORKFLOW_VERSION_ID]: params.workflowVersionId, + }), + [ATTR.EXECUTION_ID]: params.executionId, + ...(params.project?.id && { [ATTR.PROJECT_ID]: params.project.id }), + ...buildCustomAttributes(ATTR.WORKFLOW_CUSTOM_PREFIX, params.workflow?.customAttributes), + ...buildCustomAttributes(ATTR.PROJECT_CUSTOM_PREFIX, params.project?.customAttributes), + }, + }, + this.parseTraceParentHeaders(params.tracingContext), + ); + } + startNode(params: StartNodeParams): void { try { // We should always have the node running in a workflow so parentCtx should never be null @@ -262,12 +313,12 @@ export class ExecutionLevelTracer { ); } - private endDanglingNodeSpans(executionId: string): void { + private endDanglingNodeSpans(executionId: string, reason = 'workflow_cancelled'): void { const executionNodes = this.activeNodeSpansByExecutionId.get(executionId); if (!executionNodes) return; for (const tracked of executionNodes.values()) { - terminateSpan(tracked.span, 'workflow_cancelled'); + terminateSpan(tracked.span, reason); } this.activeNodeSpansByExecutionId.delete(executionId); diff --git a/packages/cli/src/modules/otel/execution-level-tracer.types.ts b/packages/cli/src/modules/otel/execution-level-tracer.types.ts index b7c71fe02f8..254f3158d58 100644 --- a/packages/cli/src/modules/otel/execution-level-tracer.types.ts +++ b/packages/cli/src/modules/otel/execution-level-tracer.types.ts @@ -1,9 +1,11 @@ import type { ExecutionStatus, WorkflowExecuteMode, INode } from 'n8n-workflow'; +import type { CrashDetector } from '@/events/maps/relay.event-map'; + import type { TracingContext } from './tracing-context'; export type CustomAttributes = Record; -type ProjectContext = { +export type ProjectContext = { id: string; customAttributes?: CustomAttributes; }; @@ -34,6 +36,21 @@ export type EndWorkflowParams = { retryOf?: string; }; +export type EndCrashedWorkflowParams = { + executionId: string; + workflowId: string; + workflowName?: string; + workflowVersionId?: string; + mode: WorkflowExecuteMode; + retryOf?: string; + detector: CrashDetector; + startedAt?: Date; + stoppedAt: Date; + tracingContext?: TracingContext; + workflow?: { customAttributes?: CustomAttributes }; + project?: ProjectContext; +}; + type NodeTracingParams = Pick; export type StartNodeParams = { diff --git a/packages/cli/src/modules/otel/otel-lifecycle-handler.ts b/packages/cli/src/modules/otel/otel-lifecycle-handler.ts index 301cf2ad216..fc03843cfe3 100644 --- a/packages/cli/src/modules/otel/otel-lifecycle-handler.ts +++ b/packages/cli/src/modules/otel/otel-lifecycle-handler.ts @@ -10,6 +10,9 @@ import type { import { Service } from '@n8n/di'; import type { ICustomTelemetryTag, WorkflowExecuteMode } from 'n8n-workflow'; +import { EventService } from '@/events/event.service'; +import type { RelayEventMap } from '@/events/maps/relay.event-map'; + import { ExecutionLevelTracer } from './execution-level-tracer'; import type { CustomAttributes } from './execution-level-tracer.types'; import { OtelSettingsService } from './otel-settings.service'; @@ -46,8 +49,16 @@ export class OtelLifecycleHandler { private readonly ownershipService: OwnershipService, private readonly logger: Logger, private readonly licenseState: LicenseState, + private readonly eventService: EventService, ) {} + init() { + this.eventService.on( + 'execution-crashed', + async (event) => await this.onExecutionCrashed(event), + ); + } + @OnPubSubEvent('reload-otel-config') async onReloadOtelConfig(): Promise { await this.otelService.restart(); @@ -100,7 +111,9 @@ export class OtelLifecycleHandler { name: ctx.workflow.name, versionId: ctx.workflow.versionId, nodeCount: ctx.workflow.nodes.length, - customAttributes: this.buildWorkflowCustomAttributes(ctx), + customAttributes: this.buildWorkflowCustomAttributes( + ctx.workflow.settings?.customTelemetryTags, + ), }, }); @@ -143,7 +156,9 @@ export class OtelLifecycleHandler { name: ctx.workflow.name, versionId: ctx.workflow.versionId, nodeCount: ctx.workflow.nodes.length, - customAttributes: this.buildWorkflowCustomAttributes(ctx), + customAttributes: this.buildWorkflowCustomAttributes( + ctx.workflow.settings?.customTelemetryTags, + ), }, }); @@ -163,6 +178,31 @@ export class OtelLifecycleHandler { }); } + async onExecutionCrashed(event: RelayEventMap['execution-crashed']): Promise { + if (!this.shouldTrace({ type: 'executionCrashed', mode: event.mode })) return; + this.tracer.endCrashedWorkflow({ + executionId: event.executionId, + workflowId: event.workflowId, + workflowName: event.workflowName, + workflowVersionId: event.workflowVersionId, + mode: event.mode, + retryOf: event.retryOf, + detector: event.detector, + startedAt: event.startedAt, + stoppedAt: event.stoppedAt, + tracingContext: event.tracingContext, + workflow: { + customAttributes: this.buildWorkflowCustomAttributes(event.workflowCustomTelemetryTags), + }, + project: event.project + ? { + id: event.project.id, + customAttributes: this.buildProjectCustomAttributes(event.project.customTelemetryTags), + } + : undefined, + }); + } + @OnLifecycleEvent('nodeExecuteBefore') onNodeStart(ctx: NodeExecuteBeforeContext): void { if (!this.shouldTrace(ctx)) return; @@ -198,9 +238,9 @@ export class OtelLifecycleHandler { } private buildWorkflowCustomAttributes( - ctx: WorkflowExecuteBeforeContext | WorkflowExecuteResumeContext, + customTelemetryTags: unknown, ): CustomAttributes | undefined { - const tags = getCustomTelemetryTags(ctx.workflow.settings?.customTelemetryTags); + const tags = getCustomTelemetryTags(customTelemetryTags); if (!tags?.length) return; if (!this.areCustomSpanAttributesLicensed()) return; diff --git a/packages/cli/src/modules/otel/otel.constants.ts b/packages/cli/src/modules/otel/otel.constants.ts index c500ee4fd6b..4321efa761d 100644 --- a/packages/cli/src/modules/otel/otel.constants.ts +++ b/packages/cli/src/modules/otel/otel.constants.ts @@ -42,6 +42,8 @@ export const ATTR = { EXECUTION_IS_RETRY: 'n8n.execution.is_retry', EXECUTION_RETRY_OF: 'n8n.execution.retry_of', EXECUTION_ERROR_TYPE: 'n8n.execution.error_type', + EXECUTION_CRASH_DETECTOR: 'n8n.execution.crash.detector', + EXECUTION_RECONSTRUCTED: 'n8n.execution.reconstructed', NODE_ID: 'n8n.node.id', NODE_NAME: 'n8n.node.name', diff --git a/packages/cli/src/modules/otel/otel.module.ts b/packages/cli/src/modules/otel/otel.module.ts index dd133343f4d..24265faaa3d 100644 --- a/packages/cli/src/modules/otel/otel.module.ts +++ b/packages/cli/src/modules/otel/otel.module.ts @@ -9,7 +9,8 @@ import { InstanceSettings } from 'n8n-core'; }) export class OtelModule implements ModuleInterface { async init() { - await import('./otel-lifecycle-handler.js'); + const { OtelLifecycleHandler } = await import('./otel-lifecycle-handler.js'); + Container.get(OtelLifecycleHandler).init(); const { OtelService } = await import('./otel.service.js'); await Container.get(OtelService).init(); diff --git a/packages/cli/src/modules/promotions.ee/__tests__/promotion-binding-preflight.integration.test.ts b/packages/cli/src/modules/promotions.ee/__tests__/promotion-binding-preflight.integration.test.ts index 587843ca8e4..64e4fd59878 100644 --- a/packages/cli/src/modules/promotions.ee/__tests__/promotion-binding-preflight.integration.test.ts +++ b/packages/cli/src/modules/promotions.ee/__tests__/promotion-binding-preflight.integration.test.ts @@ -291,6 +291,7 @@ describe('PromotionBindingPreflightService (directory + database)', () => { await createProjectVariable('REGION', 'eu', projectA); expect(await service.checkDirectory({ sourceDir })).toEqual({ + missingProjects: [], missingBindings: [], accessRequirements: [], conflicts: [], @@ -328,6 +329,7 @@ describe('PromotionBindingPreflightService (directory + database)', () => { }); const before = await snapshot(); const expected = { + missingProjects: [], missingBindings: [], conflicts: [], warnings: [], @@ -393,6 +395,7 @@ describe('PromotionBindingPreflightService (directory + database)', () => { const before = await snapshot(); const result = await service.checkDirectory({ sourceDir }); expect(result).toEqual({ + missingProjects: [{ id: 'proj-new', name: 'New' }], missingBindings: [], warnings: [], accessRequirements: [ @@ -464,6 +467,7 @@ describe('PromotionBindingPreflightService (directory + database)', () => { await createVariable('REGION', 'target-global-value'); const before = await snapshot(); expect(await service.checkDirectory({ sourceDir })).toEqual({ + missingProjects: [], missingBindings: [], accessRequirements: [], conflicts: [], diff --git a/packages/cli/src/modules/promotions.ee/__tests__/promotion-binding-preflight.service.test.ts b/packages/cli/src/modules/promotions.ee/__tests__/promotion-binding-preflight.service.test.ts index fb8ea33ba4a..92192f3d245 100644 --- a/packages/cli/src/modules/promotions.ee/__tests__/promotion-binding-preflight.service.test.ts +++ b/packages/cli/src/modules/promotions.ee/__tests__/promotion-binding-preflight.service.test.ts @@ -123,7 +123,13 @@ function useInventory(inventory: Partial) { const workflowRef = (id: string) => ({ id, name: `Workflow ${id}` }); -const emptyResult = { missingBindings: [], accessRequirements: [], conflicts: [], warnings: [] }; +const emptyResult = { + missingProjects: [], + missingBindings: [], + accessRequirements: [], + conflicts: [], + warnings: [], +}; const check = async () => { const result = await service.checkDirectory({ sourceDir: '/checkout' }); expect(promotionBindingPreflightResultSchema.parse(result)).toStrictEqual(result); @@ -154,7 +160,33 @@ describe('PromotionBindingPreflightService', () => { credentialTypes.recognizes.mockReturnValue(true); credentialsRepository.findPromotionBindingAccess.mockResolvedValue([]); variablesRepository.findKeysInProjectsOrGlobal.mockResolvedValue([]); - projectRepository.findTypesByIds.mockResolvedValue([]); + projectRepository.findTypesByIds.mockResolvedValue( + [PROJECT_A, PROJECT_B, PROJECT_C].map(({ id }) => ({ id, type: 'team' })), + ); + }); + + it('returns all missing projects in ID order with their package fields', async () => { + const metadata = { + icon: { type: 'icon' as const, value: '' }, + description: 'a'.repeat(513), + customTelemetryTags: [ + { key: ' team ', value: '' }, + { key: 'team', value: 'Sales' }, + ], + }; + useInventory({ + projects: [ + { path: 'projects/c', ...PROJECT_C }, + { path: 'projects/b', ...PROJECT_B }, + { path: 'projects/a', ...PROJECT_A, ...metadata }, + ], + }); + projectRepository.findTypesByIds.mockResolvedValue([{ id: PROJECT_B.id, type: 'team' }]); + + expect(await check()).toEqual({ + ...emptyResult, + missingProjects: [{ ...PROJECT_A, ...metadata }, PROJECT_C], + }); }); it('returns a missing credential and variable with owner context, consumers and expression data', async () => { @@ -248,6 +280,7 @@ describe('PromotionBindingPreflightService', () => { projectRepository.findTypesByIds.mockResolvedValue([ { id: PROJECT_A.id, type: 'team' }, { id: PROJECT_B.id, type: 'team' }, + { id: PROJECT_C.id, type: 'team' }, ]); expect(await check()).toEqual({ ...emptyResult, @@ -478,6 +511,7 @@ describe('PromotionBindingPreflightService', () => { targetCredential('cred-existing'), ]); expect(await check()).toEqual({ + missingProjects: [PROJECT_A, PROJECT_C], missingBindings: [ expect.objectContaining({ kind: 'variable', @@ -774,6 +808,7 @@ describe('PromotionBindingPreflightService', () => { }); expect(await check()).toEqual({ ...emptyResult, + missingProjects: [project], missingBindings: [ expect.objectContaining({ name: 'REGION', diff --git a/packages/cli/src/modules/promotions.ee/__tests__/promotions-package.integration.test.ts b/packages/cli/src/modules/promotions.ee/__tests__/promotions-package.integration.test.ts index db9bb1f007f..086574b440f 100644 --- a/packages/cli/src/modules/promotions.ee/__tests__/promotions-package.integration.test.ts +++ b/packages/cli/src/modules/promotions.ee/__tests__/promotions-package.integration.test.ts @@ -293,7 +293,14 @@ async function prepareBindingApply() { const connection = await createInstanceConnection(remote.bareDir); await service.clone(connection.id, 'promote'); await service.clone(connection.id, 'apply'); - const project = await createTeamProject('Orders', owner); + const project = await projectService.createTeamProject( + owner, + { + name: 'Orders', + icon: { type: 'icon', value: 'briefcase' }, + }, + { description: 'Order workflows', customTelemetryTags: [{ key: ' team ', value: 'Sales' }] }, + ); const credential = await saveCredential( { name: 'Header credential', type: 'httpHeaderAuth', data: {} }, { project, role: 'credential:owner' }, @@ -685,13 +692,14 @@ describe('Promote and Apply', () => { }); it.each([ - { targetValue: '', shadow: false }, - { targetValue: 'configured target value', shadow: true }, + { targetValue: '', shadow: false, missingProject: true }, + { targetValue: 'configured target value', shadow: true, missingProject: false }, ])( - 'blocks without writes and continues with target value %j', - async ({ targetValue, shadow }) => { + 'continues after binding setup with missing project: $missingProject', + async ({ targetValue, shadow, missingProject }) => { const { connection, credential, variable, project, workflow, removedProject } = await prepareBindingApply(); + if (missingProject) await projectService.deleteProject(owner, project.id); const before = await snapshotApplyState(); const agent = testServer.publicApiAgentFor(owner); const applyResponse = await agent @@ -723,6 +731,31 @@ describe('Promote and Apply', () => { expect(stillBlocked).toEqual(blocked); expect(await snapshotApplyState()).toEqual(before); + if (missingProject) { + expect(blocked.preflight.missingProjects).toEqual([ + { + id: project.id, + name: project.name, + icon: project.icon, + description: project.description, + customTelemetryTags: project.customTelemetryTags, + }, + ]); + await agent.post('/projects').send(blocked.preflight.missingProjects[0]).expect(201); + // A failed binding request must leave the created project in place. + await agent.post('/credentials').send({ projectId: project.id }).expect(400); + expect(await projectRepository.findOneBy({ id: project.id })).toMatchObject( + blocked.preflight.missingProjects[0], + ); + } else { + await projectRepository.update(project.id, { + name: 'Outdated', + description: 'Outdated', + icon: null, + customTelemetryTags: [], + }); + } + const targetData = { name: 'Authorization', value: 'target-secret' }; const createBody = { id: credential.id, @@ -765,6 +798,15 @@ describe('Promote and Apply', () => { expect(result.warnings).toEqual( shadow ? [expect.objectContaining({ code: 'variable-shadowed', name: variable.key })] : [], ); + expect(await projectRepository.findOneBy({ id: project.id })).toMatchObject({ + id: project.id, + name: project.name, + icon: project.icon, + description: project.description, + customTelemetryTags: project.customTelemetryTags, + }); + expect(result.counts.projects.created).toBe(0); + expect(result.counts.credentials).toEqual({ matched: 1, stubbed: 0 }); expect(result.counts.variables).toMatchObject({ created: 0, updated: 0, stubbed: 0 }); expect( @@ -792,9 +834,15 @@ describe('Promote and Apply', () => { ); it('stops Continue when the remote commit changes and permits a new review', async () => { - const { connection, remote } = await prepareBindingApply(); + const { connection, remote, project } = await prepareBindingApply(); + await projectService.deleteProject(owner, project.id); const blocked = await service.apply(connection.id, owner); assert(blocked.status === 'blocked'); + await testServer + .publicApiAgentFor(owner) + .post('/projects') + .send(blocked.preflight.missingProjects[0]) + .expect(201); const before = await snapshotApplyState(); await remote.git.pull('origin', 'main'); await writeRemoteFile(remote, 'README.md', 'Updated description'); diff --git a/packages/cli/src/modules/promotions.ee/__tests__/promotions.service.test.ts b/packages/cli/src/modules/promotions.ee/__tests__/promotions.service.test.ts index db42540dabe..d9fec1b14de 100644 --- a/packages/cli/src/modules/promotions.ee/__tests__/promotions.service.test.ts +++ b/packages/cli/src/modules/promotions.ee/__tests__/promotions.service.test.ts @@ -1005,6 +1005,7 @@ describe('PromotionsService', () => { beforeEach(async () => { bindingPreflight.checkDirectory.mockResolvedValue({ + missingProjects: [], missingBindings: [], accessRequirements: [], conflicts: [], @@ -1083,6 +1084,7 @@ describe('PromotionsService', () => { { project: { id: 'p1', name: 'Orders' }, workflows: [{ id: 'w1', name: 'Process order' }] }, ]; const unresolved: PromotionBindingPreflightResult = { + missingProjects: [], missingBindings: [ { kind: 'variable', @@ -1126,11 +1128,25 @@ describe('PromotionsService', () => { expectedSource: { configId: CONFIG_ID, branchName: 'dev', commitSha: 'remotesha' }, }; + it('imports when only projects are missing', async () => { + await mkdir(packageFolder, { recursive: true }); + bindingPreflight.checkDirectory.mockResolvedValueOnce({ + missingProjects: [{ id: 'p1', name: 'Orders' }], + missingBindings: [], + accessRequirements: [], + conflicts: [], + warnings: [], + }); + expect(await service.apply('conn1', actor)).toMatchObject({ status: 'applied' }); + expect(n8nPackagesService.importPackageFromDirectory).toHaveBeenCalled(); + }); + it.each(['missingBindings', 'accessRequirements', 'conflicts'] as const)( 'blocks on %s before import or reconciliation', async (group) => { await mkdir(packageFolder, { recursive: true }); const preflight = { + missingProjects: [], missingBindings: [], accessRequirements: [], conflicts: [], @@ -1169,6 +1185,7 @@ describe('PromotionsService', () => { }); expect(n8nPackagesService.importPackageFromDirectory).not.toHaveBeenCalled(); bindingPreflight.checkDirectory.mockResolvedValueOnce({ + missingProjects: [], missingBindings: [], accessRequirements: [], conflicts: [], @@ -1192,6 +1209,7 @@ describe('PromotionsService', () => { it('imports with warnings on initial Apply', async () => { await mkdir(packageFolder, { recursive: true }); bindingPreflight.checkDirectory.mockResolvedValueOnce({ + missingProjects: [], missingBindings: [], accessRequirements: [], conflicts: [], diff --git a/packages/cli/src/modules/promotions.ee/promotion-binding-preflight.service.ts b/packages/cli/src/modules/promotions.ee/promotion-binding-preflight.service.ts index 24c8f7f9978..a9d8f4fc45a 100644 --- a/packages/cli/src/modules/promotions.ee/promotion-binding-preflight.service.ts +++ b/packages/cli/src/modules/promotions.ee/promotion-binding-preflight.service.ts @@ -78,7 +78,18 @@ export class PromotionBindingPreflightService { unique(variables.flatMap(({ workflows }) => workflows.map(({ projectId }) => projectId))), ), ]); + const targetProjectIds = new Set(targetProjects.map(({ id }) => id)); const result: PromotionBindingPreflightResult = { + missingProjects: inventory.projects + .filter(({ id }) => !targetProjectIds.has(id)) + .sort((a, b) => compare(a.id, b.id)) + .map(({ id, name, icon, description, customTelemetryTags }) => ({ + id, + name, + ...(icon !== undefined ? { icon } : {}), + ...(description !== undefined ? { description } : {}), + ...(customTelemetryTags !== undefined ? { customTelemetryTags } : {}), + })), missingBindings: [], accessRequirements: [], conflicts: [], diff --git a/packages/cli/src/modules/type-availability-policies/README.md b/packages/cli/src/modules/type-availability-policies/README.md index 3d5b845c19b..4f18225c428 100644 --- a/packages/cli/src/modules/type-availability-policies/README.md +++ b/packages/cli/src/modules/type-availability-policies/README.md @@ -13,7 +13,7 @@ Notion for why the substrate looks the way it does. Two gates, both required: -1. The license feature `feat:nodeTypePolicies`. Without it, `init()` never runs, so the +1. The license feature `feat:typeAvailabilityPolicies`. Without it, `init()` never runs, so the controllers are not mounted and no check is registered. 2. `N8N_ENABLED_MODULES=type-availability-policies`. This module is not a default module yet. diff --git a/packages/cli/src/modules/type-availability-policies/__tests__/available-types.controller.test.ts b/packages/cli/src/modules/type-availability-policies/__tests__/available-types.controller.test.ts index 65241a66dc7..bce0969d358 100644 --- a/packages/cli/src/modules/type-availability-policies/__tests__/available-types.controller.test.ts +++ b/packages/cli/src/modules/type-availability-policies/__tests__/available-types.controller.test.ts @@ -38,9 +38,9 @@ describe('AvailableTypesController route access scopes', () => { }, ); - it('is gated by the node type policies license feature', () => { + it('is gated by the type availability policies license feature', () => { for (const { route } of routeCases) { - expect(route.licenseFeature).toBe(LICENSE_FEATURES.NODE_TYPE_POLICIES); + expect(route.licenseFeature).toBe(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES); } }); }); diff --git a/packages/cli/src/modules/type-availability-policies/__tests__/credential-type-policy-instance.controller.test.ts b/packages/cli/src/modules/type-availability-policies/__tests__/credential-type-policy-instance.controller.test.ts index b63a4427d69..9fcb200ef51 100644 --- a/packages/cli/src/modules/type-availability-policies/__tests__/credential-type-policy-instance.controller.test.ts +++ b/packages/cli/src/modules/type-availability-policies/__tests__/credential-type-policy-instance.controller.test.ts @@ -32,9 +32,9 @@ describe('CredentialTypePolicyInstanceController route access scopes', () => { }, ); - it('is gated by the node type policies license feature', () => { + it('is gated by the type availability policies license feature', () => { for (const { route } of routeCases) { - expect(route.licenseFeature).toBe(LICENSE_FEATURES.NODE_TYPE_POLICIES); + expect(route.licenseFeature).toBe(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES); } }); }); diff --git a/packages/cli/src/modules/type-availability-policies/__tests__/credential-type-policy-project.controller.test.ts b/packages/cli/src/modules/type-availability-policies/__tests__/credential-type-policy-project.controller.test.ts index e3ba1f41aad..120ba2b4e42 100644 --- a/packages/cli/src/modules/type-availability-policies/__tests__/credential-type-policy-project.controller.test.ts +++ b/packages/cli/src/modules/type-availability-policies/__tests__/credential-type-policy-project.controller.test.ts @@ -8,7 +8,7 @@ import { CredentialTypePolicyProjectController } from '../credential-type-policy * Every route on this controller must be project-scoped (`@ProjectScope`) on * `credentialTypePolicy:manage`, never `@GlobalScope` and never left ungated — a project admin * self-governs their own project's row, same as the sibling node-types controller. Every route - * must also require the node type policies license feature. + * must also require the type availability policies license feature. */ describe('CredentialTypePolicyProjectController route access scopes', () => { const metadata = Container.get(ControllerRegistryMetadata).getControllerMetadata( @@ -32,9 +32,9 @@ describe('CredentialTypePolicyProjectController route access scopes', () => { }, ); - it('is gated by the node type policies license feature', () => { + it('is gated by the type availability policies license feature', () => { for (const { route } of routeCases) { - expect(route.licenseFeature).toBe(LICENSE_FEATURES.NODE_TYPE_POLICIES); + expect(route.licenseFeature).toBe(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES); } }); }); diff --git a/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policies.module.test.ts b/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policies.module.test.ts index 82afc978746..7363cfc1e48 100644 --- a/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policies.module.test.ts +++ b/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policies.module.test.ts @@ -12,10 +12,10 @@ describe('TypeAvailabilityPoliciesModule', () => { expect(entry).toBeDefined(); }); - it('is gated by the node type policies license feature, so an unlicensed instance skips init', () => { + it('is gated by the type availability policies license feature, so an unlicensed instance skips init', () => { const entry = Container.get(ModuleMetadata).get('type-availability-policies'); - expect(entry?.licenseFlag).toBe(LICENSE_FEATURES.NODE_TYPE_POLICIES); + expect(entry?.licenseFlag).toBe(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES); }); // The available-types controller injects the node registry, whose import chain takes diff --git a/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policy-instance.controller.test.ts b/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policy-instance.controller.test.ts index 0ca9234344e..f0c79150ef2 100644 --- a/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policy-instance.controller.test.ts +++ b/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policy-instance.controller.test.ts @@ -8,7 +8,7 @@ import { TypeAvailabilityPolicyInstanceController } from '../type-availability-p * Every route on this controller must be owner-only (`@GlobalScope`), never * `@ProjectScope` and never left ungated — node type availability policy is an * instance-wide setting, per IAM-1327's `nodeTypePolicy:manage` scope. Every route - * must also require the node type policies license feature (IAM-1141's licensing + * must also require the type availability policies license feature (IAM-1141's licensing * decision — see IAM-1332 for the pending flag/SKU rename). */ describe('TypeAvailabilityPolicyInstanceController route access scopes', () => { @@ -33,9 +33,9 @@ describe('TypeAvailabilityPolicyInstanceController route access scopes', () => { }, ); - it('is gated by the node type policies license feature', () => { + it('is gated by the type availability policies license feature', () => { for (const { route } of routeCases) { - expect(route.licenseFeature).toBe(LICENSE_FEATURES.NODE_TYPE_POLICIES); + expect(route.licenseFeature).toBe(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES); } }); }); diff --git a/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policy-project.controller.test.ts b/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policy-project.controller.test.ts index 5187d5f71a3..ab3b29f8948 100644 --- a/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policy-project.controller.test.ts +++ b/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policy-project.controller.test.ts @@ -32,9 +32,9 @@ describe('TypeAvailabilityPolicyProjectController route access scopes', () => { }, ); - it('is gated by the node type policies license feature', () => { + it('is gated by the type availability policies license feature', () => { for (const { route } of routeCases) { - expect(route.licenseFeature).toBe(LICENSE_FEATURES.NODE_TYPE_POLICIES); + expect(route.licenseFeature).toBe(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES); } }); }); diff --git a/packages/cli/src/modules/type-availability-policies/available-types.controller.ts b/packages/cli/src/modules/type-availability-policies/available-types.controller.ts index abfdb290d36..2768d333a39 100644 --- a/packages/cli/src/modules/type-availability-policies/available-types.controller.ts +++ b/packages/cli/src/modules/type-availability-policies/available-types.controller.ts @@ -43,7 +43,7 @@ export class AvailableTypesController { ) {} @Get('/') - @Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES) + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @ProjectScope('project:read') async getAvailableTypes( _req: Request, diff --git a/packages/cli/src/modules/type-availability-policies/credential-type-policy-instance.controller.ts b/packages/cli/src/modules/type-availability-policies/credential-type-policy-instance.controller.ts index dc5066e941b..8dbcb5226ec 100644 --- a/packages/cli/src/modules/type-availability-policies/credential-type-policy-instance.controller.ts +++ b/packages/cli/src/modules/type-availability-policies/credential-type-policy-instance.controller.ts @@ -28,7 +28,7 @@ import { TypeAvailabilityPolicyService } from './type-availability-policy.servic /** * Instance-scope REST surface for credential type availability policies. Same shape as * `TypeAvailabilityPolicyInstanceController`, mounted on its own path and kind: every route - * requires `LICENSE_FEATURES.NODE_TYPE_POLICIES` and `credentialTypePolicy:manage`, a separate + * requires `LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES` and `credentialTypePolicy:manage`, a separate * permission from `nodeTypePolicy:manage` (see the scope's own ticket for why). */ @RestController('/credential-type-policies') @@ -36,7 +36,7 @@ export class CredentialTypePolicyInstanceController { constructor(private readonly service: TypeAvailabilityPolicyService) {} @Get('/instance') - @Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES) + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @GlobalScope('credentialTypePolicy:manage') async getInstancePolicy() { const effective = await this.service.getEffectivePolicy(CREDENTIAL_TYPES_KIND, null); @@ -50,7 +50,7 @@ export class CredentialTypePolicyInstanceController { } @Put('/instance') - @Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES) + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @GlobalScope('credentialTypePolicy:manage') async putInstancePolicy( req: AuthenticatedRequest, @@ -75,7 +75,7 @@ export class CredentialTypePolicyInstanceController { } @Post('/policies') - @Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES) + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @GlobalScope('credentialTypePolicy:manage') async createPolicy( req: AuthenticatedRequest, @@ -92,14 +92,14 @@ export class CredentialTypePolicyInstanceController { } @Get('/policies') - @Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES) + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @GlobalScope('credentialTypePolicy:manage') async listPolicies() { return await this.service.listPolicyDocuments(CREDENTIAL_TYPES_KIND); } @Get('/policies/:policyId') - @Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES) + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @GlobalScope('credentialTypePolicy:manage') async getPolicy(_req: AuthenticatedRequest, _res: Response, @Param('policyId') policyId: string) { const policy = await this.service.getPolicyDocument(CREDENTIAL_TYPES_KIND, policyId); @@ -111,7 +111,7 @@ export class CredentialTypePolicyInstanceController { } @Patch('/policies/:policyId') - @Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES) + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @GlobalScope('credentialTypePolicy:manage') async updatePolicy( req: AuthenticatedRequest, @@ -131,7 +131,7 @@ export class CredentialTypePolicyInstanceController { } @Delete('/policies/:policyId') - @Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES) + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @GlobalScope('credentialTypePolicy:manage') async deletePolicy( req: AuthenticatedRequest, @@ -144,7 +144,7 @@ export class CredentialTypePolicyInstanceController { } @Put('/scopes/:scopeId/attachments') - @Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES) + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @GlobalScope('credentialTypePolicy:manage') async replaceAttachments( req: AuthenticatedRequest, diff --git a/packages/cli/src/modules/type-availability-policies/credential-type-policy-project.controller.ts b/packages/cli/src/modules/type-availability-policies/credential-type-policy-project.controller.ts index b694448d43e..6af0f866dbf 100644 --- a/packages/cli/src/modules/type-availability-policies/credential-type-policy-project.controller.ts +++ b/packages/cli/src/modules/type-availability-policies/credential-type-policy-project.controller.ts @@ -20,7 +20,7 @@ export class CredentialTypePolicyProjectController { constructor(private readonly service: TypeAvailabilityPolicyService) {} @Get('/project') - @Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES) + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @ProjectScope('credentialTypePolicy:manage') async getProjectPolicy(req: AuthenticatedRequest<{ projectId: string }>) { const effective = await this.service.getEffectivePolicy( @@ -37,7 +37,7 @@ export class CredentialTypePolicyProjectController { } @Put('/project') - @Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES) + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @ProjectScope('credentialTypePolicy:manage') async putProjectPolicy( req: AuthenticatedRequest<{ projectId: string }>, diff --git a/packages/cli/src/modules/type-availability-policies/node-type-policy.check.ts b/packages/cli/src/modules/type-availability-policies/node-type-policy.check.ts index cf1f68853de..079a50526e5 100644 --- a/packages/cli/src/modules/type-availability-policies/node-type-policy.check.ts +++ b/packages/cli/src/modules/type-availability-policies/node-type-policy.check.ts @@ -139,7 +139,8 @@ export class NodeTypePolicyCheck implements RegisteredPolicyCheck { private async checkTypes(types: string[], projectId: string | null): Promise { // An expired license stops enforcing, matching the `@Licensed` routes that author the // policy: a customer who cannot edit the policy must not keep being blocked by it. - if (!this.licenseState.isLicensed(LICENSE_FEATURES.NODE_TYPE_POLICIES)) return NO_VIOLATIONS; + if (!this.licenseState.isLicensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES)) + return NO_VIOLATIONS; if (types.length === 0) return NO_VIOLATIONS; diff --git a/packages/cli/src/modules/type-availability-policies/type-availability-policies.module.ts b/packages/cli/src/modules/type-availability-policies/type-availability-policies.module.ts index 0b2292bf4ce..db6e3bcb1be 100644 --- a/packages/cli/src/modules/type-availability-policies/type-availability-policies.module.ts +++ b/packages/cli/src/modules/type-availability-policies/type-availability-policies.module.ts @@ -10,7 +10,7 @@ import { BackendModule } from '@n8n/decorators'; */ @BackendModule({ name: 'type-availability-policies', - licenseFlag: LICENSE_FEATURES.NODE_TYPE_POLICIES, + licenseFlag: LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES, }) export class TypeAvailabilityPoliciesModule implements ModuleInterface { async init() { diff --git a/packages/cli/src/modules/type-availability-policies/type-availability-policy-instance.controller.ts b/packages/cli/src/modules/type-availability-policies/type-availability-policy-instance.controller.ts index 8e1611b4d40..a3a93540a29 100644 --- a/packages/cli/src/modules/type-availability-policies/type-availability-policy-instance.controller.ts +++ b/packages/cli/src/modules/type-availability-policies/type-availability-policy-instance.controller.ts @@ -27,16 +27,16 @@ import { TypeAvailabilityPolicyService } from './type-availability-policy.servic /** * Instance-scope REST surface for node type availability policies. Every route requires - * `LICENSE_FEATURES.NODE_TYPE_POLICIES` and `nodeTypePolicy:manage` (owner-only, per IAM-1327). - * The flag name is a placeholder pending a final SKU decision (see IAM-1332); renaming it later - * is a one-line change here. + * `LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES` and `nodeTypePolicy:manage` (owner-only, per + * IAM-1327). The flag name is a placeholder pending a final SKU decision (see IAM-1332); + * renaming it later is a one-line change here. */ @RestController('/node-type-policies') export class TypeAvailabilityPolicyInstanceController { constructor(private readonly service: TypeAvailabilityPolicyService) {} @Get('/instance') - @Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES) + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @GlobalScope('nodeTypePolicy:manage') async getInstancePolicy() { const effective = await this.service.getEffectivePolicy(NODE_TYPES_KIND, null); @@ -50,7 +50,7 @@ export class TypeAvailabilityPolicyInstanceController { } @Put('/instance') - @Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES) + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @GlobalScope('nodeTypePolicy:manage') async putInstancePolicy( req: AuthenticatedRequest, @@ -75,7 +75,7 @@ export class TypeAvailabilityPolicyInstanceController { } @Post('/policies') - @Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES) + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @GlobalScope('nodeTypePolicy:manage') async createPolicy( req: AuthenticatedRequest, @@ -92,14 +92,14 @@ export class TypeAvailabilityPolicyInstanceController { } @Get('/policies') - @Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES) + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @GlobalScope('nodeTypePolicy:manage') async listPolicies() { return await this.service.listPolicyDocuments(NODE_TYPES_KIND); } @Get('/policies/:policyId') - @Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES) + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @GlobalScope('nodeTypePolicy:manage') async getPolicy(_req: AuthenticatedRequest, _res: Response, @Param('policyId') policyId: string) { const policy = await this.service.getPolicyDocument(NODE_TYPES_KIND, policyId); @@ -111,7 +111,7 @@ export class TypeAvailabilityPolicyInstanceController { } @Patch('/policies/:policyId') - @Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES) + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @GlobalScope('nodeTypePolicy:manage') async updatePolicy( req: AuthenticatedRequest, @@ -131,7 +131,7 @@ export class TypeAvailabilityPolicyInstanceController { } @Delete('/policies/:policyId') - @Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES) + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @GlobalScope('nodeTypePolicy:manage') async deletePolicy( req: AuthenticatedRequest, @@ -144,7 +144,7 @@ export class TypeAvailabilityPolicyInstanceController { } @Put('/scopes/:scopeId/attachments') - @Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES) + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @GlobalScope('nodeTypePolicy:manage') async replaceAttachments( req: AuthenticatedRequest, diff --git a/packages/cli/src/modules/type-availability-policies/type-availability-policy-project.controller.ts b/packages/cli/src/modules/type-availability-policies/type-availability-policy-project.controller.ts index 68b4ebb90ab..1d9b54120b5 100644 --- a/packages/cli/src/modules/type-availability-policies/type-availability-policy-project.controller.ts +++ b/packages/cli/src/modules/type-availability-policies/type-availability-policy-project.controller.ts @@ -9,8 +9,9 @@ import { TypeAvailabilityPolicyService } from './type-availability-policy.servic /** * Project-scope REST surface for a project's own node type availability policy row. Every - * route requires `LICENSE_FEATURES.NODE_TYPE_POLICIES` and `nodeTypePolicy:manage`, granted to - * project admins so they self-govern their own project's row (per IAM-1142's RFC decision). + * route requires `LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES` and `nodeTypePolicy:manage`, + * granted to project admins so they self-govern their own project's row (per IAM-1142's RFC + * decision). * * Deliberately narrower than the instance controller: only the composed read/write, mirroring * `GET`/`PUT /instance`. Policy-document CRUD and multi-attachment management stay @@ -21,7 +22,7 @@ export class TypeAvailabilityPolicyProjectController { constructor(private readonly service: TypeAvailabilityPolicyService) {} @Get('/project') - @Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES) + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @ProjectScope('nodeTypePolicy:manage') async getProjectPolicy(req: AuthenticatedRequest<{ projectId: string }>) { const effective = await this.service.getEffectivePolicy(NODE_TYPES_KIND, req.params.projectId); @@ -35,7 +36,7 @@ export class TypeAvailabilityPolicyProjectController { } @Put('/project') - @Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES) + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @ProjectScope('nodeTypePolicy:manage') async putProjectPolicy( req: AuthenticatedRequest<{ projectId: string }>, diff --git a/packages/cli/src/oauth/__tests__/oauth.service.test.ts b/packages/cli/src/oauth/__tests__/oauth.service.test.ts index 5420b1c751b..89feea5b77c 100644 --- a/packages/cli/src/oauth/__tests__/oauth.service.test.ts +++ b/packages/cli/src/oauth/__tests__/oauth.service.test.ts @@ -451,9 +451,6 @@ describe('OauthService', () => { await service.encryptAndSaveData(credential, toUpdate, toDelete); expect(credentialsRepository.update).toHaveBeenCalledWith('1', { - id: '1', - name: expect.anything(), - type: 'test', data: expect.any(String), updatedAt: expect.any(Date), }); @@ -472,9 +469,6 @@ describe('OauthService', () => { await service.encryptAndSaveData(credential, toUpdate); expect(credentialsRepository.update).toHaveBeenCalledWith('1', { - id: '1', - name: expect.anything(), - type: 'test', data: expect.any(String), updatedAt: expect.any(Date), }); diff --git a/packages/cli/src/oauth/oauth.service.ts b/packages/cli/src/oauth/oauth.service.ts index cd0ff3d2692..bf23692438d 100644 --- a/packages/cli/src/oauth/oauth.service.ts +++ b/packages/cli/src/oauth/oauth.service.ts @@ -415,10 +415,13 @@ export class OauthService { const credentials = new Credentials(credential, credential.type, credential.data); await credentials.updateData(toUpdate, toDelete); - await this.credentialsRepository.update(credential.id, { - ...credentials.getDataToSave(), + // 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 update: Pick = { + data: credentials.getDataToSave().data, updatedAt: new Date(), - }); + }; + await this.credentialsRepository.update(credential.id, update); } /** Get a credential without user check */ diff --git a/packages/cli/src/public-api/types.ts b/packages/cli/src/public-api/types.ts index 54b113f0aa6..4b4e69f9439 100644 --- a/packages/cli/src/public-api/types.ts +++ b/packages/cli/src/public-api/types.ts @@ -2,7 +2,6 @@ import type { AddDataTableColumnDto, AddDataTableRowsDto, PublicApiCreateDataTableDto, - PublicTestRunStatus, UpdateDataTableDto, UpdateDataTableColumnDto, UpdateDataTableRowDto, @@ -35,18 +34,6 @@ export type PaginatedRequest = AuthenticatedRequest< export declare namespace TestRunRequest { // `id` is the workflow id (named `id` so `projectScope(..., 'workflow')` // resolves it from `req.params.id`); `runId` is the test run id. - type GetMany = AuthenticatedRequest< - { id: string }, - {}, - {}, - { - status?: PublicTestRunStatus; - limit?: number; - cursor?: string; - offset?: number; - lastId?: string; - } - >; type GetOne = AuthenticatedRequest<{ id: string; runId: string }>; type GetCases = AuthenticatedRequest< { id: string; runId: string }, @@ -59,7 +46,6 @@ export declare namespace TestRunRequest { lastId?: string; } >; - type Create = AuthenticatedRequest<{ id: string }>; type Cancel = AuthenticatedRequest<{ id: string; runId: string }>; } @@ -104,13 +90,6 @@ export declare namespace UserRequest { { transferId?: string; includeRole: boolean } >; - export type Get = AuthenticatedRequest< - { id: string; email: string }, - {}, - {}, - { limit?: number; offset?: number; cursor?: string; includeRole?: boolean } - >; - export type Reinvite = AuthenticatedRequest<{ id: string }>; export type Update = AuthlessRequest< @@ -125,8 +104,6 @@ export declare namespace UserRequest { >; } -export type OperationID = 'getUsers' | 'getUser'; - type PaginationBase = { limit: number }; export type PaginationOffsetDecoded = PaginationBase & { offset: number }; diff --git a/packages/cli/src/public-api/v1/controllers/__tests__/node-type-policies.public.controller.test.ts b/packages/cli/src/public-api/v1/controllers/__tests__/node-type-policies.public.controller.test.ts index 59682952914..eb9196b76ff 100644 --- a/packages/cli/src/public-api/v1/controllers/__tests__/node-type-policies.public.controller.test.ts +++ b/packages/cli/src/public-api/v1/controllers/__tests__/node-type-policies.public.controller.test.ts @@ -20,7 +20,7 @@ import { NodeTypePoliciesPublicController } from '../node-type-policies.public.c /** * Every public route must carry the same guards as the internal controllers it mirrors: the - * node type policies license feature, the `nodeTypePolicy:manage` API-key scope, and a + * type availability policies license feature, the `nodeTypePolicy:manage` API-key scope, and a * `nodeTypePolicy:manage` RBAC check that is global-only everywhere except on the two * project-scoped routes. */ @@ -73,9 +73,9 @@ describe('NodeTypePoliciesPublicController route metadata', () => { ); it.each(routeCases)( - '$handlerName is gated by the node type policies license feature', + '$handlerName is gated by the type availability policies license feature', ({ route }) => { - expect(route.licenseFeature).toBe(LICENSE_FEATURES.NODE_TYPE_POLICIES); + expect(route.licenseFeature).toBe(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES); }, ); diff --git a/packages/cli/src/public-api/v1/controllers/__tests__/users.public.controller.test.ts b/packages/cli/src/public-api/v1/controllers/__tests__/users.public.controller.test.ts new file mode 100644 index 00000000000..1055103c237 --- /dev/null +++ b/packages/cli/src/public-api/v1/controllers/__tests__/users.public.controller.test.ts @@ -0,0 +1,128 @@ +import type { GetUserQueryDto, ListUsersQueryDto } from '@n8n/api-types'; +import type { AuthenticatedRequest, User } from '@n8n/db'; +import type { Response } from 'express'; +import { mock } from 'vitest-mock-extended'; + +import type { EventService } from '@/events/event.service'; +import type { ProjectService } from '@/services/project.service.ee'; +import type { UserService } from '@/services/user.service'; + +import { UsersPublicController } from '../users.public.controller'; + +describe('UsersPublicController', () => { + let controller: UsersPublicController; + const userService = mock(); + const projectService = mock(); + const eventService = mock(); + + const caller = mock({ user: mock({ id: 'caller-id' }) }); + + const buildUser = (overrides: Partial = {}): User => + mock({ + id: 'user-id', + email: 'member@example.com', + firstName: 'Nathan', + lastName: 'Doe', + isPending: false, + mfaEnabled: false, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-02T00:00:00.000Z'), + role: { slug: 'global:member' }, + password: 'secret-hash', + mfaSecret: 'secret-mfa-key', + disabled: false, + ...overrides, + }); + + beforeEach(() => { + vi.clearAllMocks(); + controller = new UsersPublicController(userService, projectService, eventService); + }); + + describe('getUsers', () => { + it('never leaks sensitive user fields', async () => { + const users = [buildUser()]; + userService.getUsersAndCount.mockResolvedValue({ users, count: 1 }); + + const result = await controller.getUsers( + caller, + mock(), + mock({ + limit: 100, + cursor: undefined, + includeRole: false, + projectId: undefined, + }), + ); + + expect(result.data[0]).toMatchObject({ + id: 'user-id', + email: 'member@example.com', + mfaEnabled: false, + }); + expect(result.data[0]).not.toHaveProperty('role'); + expect(result.data[0]).not.toHaveProperty('password'); + expect(result.data[0]).not.toHaveProperty('mfaSecret'); + expect(result.data[0]).not.toHaveProperty('disabled'); + }); + + it('emits user-retrieved-all-users telemetry', async () => { + userService.getUsersAndCount.mockResolvedValue({ users: [buildUser()], count: 1 }); + + await controller.getUsers( + caller, + mock(), + mock({ + limit: 100, + cursor: undefined, + includeRole: false, + projectId: undefined, + }), + ); + + expect(eventService.emit).toHaveBeenCalledWith('user-retrieved-all-users', { + userId: caller.user.id, + publicApi: true, + }); + }); + }); + + describe('getUser', () => { + it('never leaks sensitive user fields', async () => { + userService.getUser.mockResolvedValue(buildUser()); + + const result = await controller.getUser( + caller, + mock(), + 'user-id', + mock({ includeRole: false }), + ); + + expect(result).toMatchObject({ + id: 'user-id', + email: 'member@example.com', + mfaEnabled: false, + }); + expect(result).not.toHaveProperty('role'); + expect(result).not.toHaveProperty('password'); + expect(result).not.toHaveProperty('mfaSecret'); + expect(result).not.toHaveProperty('disabled'); + }); + + it('emits user-retrieved-user telemetry', async () => { + userService.getUser.mockResolvedValue(buildUser()); + + await controller.getUser( + caller, + mock(), + 'user-id', + mock({ includeRole: false }), + ); + + expect(eventService.emit).toHaveBeenCalledWith('user-retrieved-user', { + userId: caller.user.id, + publicApi: true, + }); + }); + }); +}); diff --git a/packages/cli/src/public-api/v1/controllers/discover.public.controller.ts b/packages/cli/src/public-api/v1/controllers/discover.public.controller.ts new file mode 100644 index 00000000000..fe2c9a02d85 --- /dev/null +++ b/packages/cli/src/public-api/v1/controllers/discover.public.controller.ts @@ -0,0 +1,44 @@ +import { DiscoverPublicDto, DiscoverQueryPublicDto } from '@n8n/api-types'; +import type { AuthenticatedRequest } from '@n8n/db'; +import { + ApiDescription, + ApiResponse, + ApiSummary, + ApiTags, + Get, + PublicApiController, + Query, +} from '@n8n/decorators'; +import type { Response } from 'express'; + +import { UnauthenticatedError } from '@/errors/response-errors/unauthenticated.error'; +import { buildDiscoverResponse } from '@/public-api/v1/handlers/discover/discover.service'; + +@PublicApiController('/discover') +export class DiscoverPublicController { + @Get('/') + @ApiSummary('Discover available API capabilities') + @ApiDescription( + "Returns a filtered capability map based on the caller's API key scopes. Each resource includes the operations and endpoints accessible to the authenticated API key. Use query parameters to narrow the response.", + ) + @ApiTags(['Discover']) + @ApiResponse(200, DiscoverPublicDto) + async getDiscover( + req: AuthenticatedRequest, + _res: Response, + @Query query: DiscoverQueryPublicDto, + ): Promise { + const scopes = req.tokenGrant?.apiKeyScopes; + if (!scopes) { + throw new UnauthenticatedError('Unauthorized'); + } + + return { + data: await buildDiscoverResponse(scopes, { + includeSchemas: query.include === 'schemas', + resource: query.resource, + operation: query.operation, + }), + }; + } +} diff --git a/packages/cli/src/public-api/v1/controllers/evaluations.public.controller.ts b/packages/cli/src/public-api/v1/controllers/evaluations.public.controller.ts new file mode 100644 index 00000000000..312a42d0af0 --- /dev/null +++ b/packages/cli/src/public-api/v1/controllers/evaluations.public.controller.ts @@ -0,0 +1,139 @@ +import { + CreatedTestRunPublicDto, + ListTestRunsQueryPublicDto, + TestRunListPublicDto, + workflowIdParamSchema, +} from '@n8n/api-types'; +import { LicenseState } from '@n8n/backend-common'; +import type { AuthenticatedRequest } from '@n8n/db'; +import { + ApiDescription, + ApiErrorResponse, + ApiKeyScope, + ApiResponse, + ApiSummary, + ApiTags, + Get, + Param, + Post, + ProjectScope, + PublicApiController, + Query, +} from '@n8n/decorators'; +import type { Response } from 'express'; +import { ErrorReporter } from 'n8n-core'; +import { EVALUATION_TRIGGER_NODE_TYPE } from 'n8n-workflow'; + +import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { EvaluationTestRunService } from '@/evaluation.ee/evaluation-test-run.service'; +import { TestRunnerService } from '@/evaluation.ee/test-runner/test-runner.service.ee'; +import { toTestRunSummaryDto } from '@/public-api/v1/handlers/evaluations/evaluations.mapper'; +import { + encodeNextCursor, + resolveOffsetPagination, +} from '@/public-api/v1/shared/services/pagination.service'; +import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; + +const tags = ['Evaluation']; + +@PublicApiController('/workflows/:workflowId/test-runs') +export class EvaluationsPublicController { + constructor( + private readonly evaluationTestRunService: EvaluationTestRunService, + private readonly testRunnerService: TestRunnerService, + private readonly workflowFinderService: WorkflowFinderService, + private readonly licenseState: LicenseState, + private readonly errorReporter: ErrorReporter, + ) {} + + @Get('/') + @ApiKeyScope('testRun:list') + @ProjectScope('workflow:read') + @ApiSummary('Retrieve test runs') + @ApiDescription('Retrieve the evaluation test runs of a workflow.') + @ApiTags(tags) + @ApiResponse(200, TestRunListPublicDto) + @ApiErrorResponse(400) + @ApiErrorResponse(404) + async getTestRuns( + _req: AuthenticatedRequest, + _res: Response, + @Param('workflowId', workflowIdParamSchema) workflowId: string, + @Query query: ListTestRunsQueryPublicDto, + ): Promise { + const { offset, limit } = resolveOffsetPagination(query); + + const { testRuns, count } = await this.evaluationTestRunService.findManyAndCount( + workflowId, + { offset, limit }, + query.status, + ); + + return { + data: testRuns.map(toTestRunSummaryDto), + nextCursor: encodeNextCursor({ offset, limit, numberOfTotalRecords: count }), + }; + } + + @Post('/') + @ApiKeyScope('testRun:create') + @ProjectScope('workflow:execute') + @ApiSummary('Trigger a test run') + @ApiDescription( + 'Start a new evaluation test run for a workflow. The workflow must contain a configured ' + + 'evaluation trigger. Requires the `workflow:execute` project scope in addition to the ' + + '`testRun:create` API key scope.', + ) + @ApiTags(tags) + @ApiResponse(201, CreatedTestRunPublicDto) + @ApiErrorResponse(402) + @ApiErrorResponse(404) + @ApiErrorResponse(409) + async createTestRun( + req: AuthenticatedRequest, + _res: Response, + @Param('workflowId', workflowIdParamSchema) workflowId: string, + ): Promise { + this.assertEvaluationsEnabled(); + + // Reject a workflow with no evaluation trigger up-front (409). Deeper validation still + // happens asynchronously in the run, matching the internal path. + const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, req.user, [ + 'workflow:execute', + ]); + if (!workflow) throw new NotFoundError('Workflow not found'); + + const hasTrigger = workflow.nodes.some((node) => node.type === EVALUATION_TRIGGER_NODE_TYPE); + if (!hasTrigger) { + throw new ConflictError('Workflow has no evaluation trigger'); + } + + // The count query runs last, after the cheaper 403/404/409 checks. + await this.evaluationTestRunService.assertEvaluationQuotaAvailable(workflowId); + + // Case execution runs detached; guard `finished` so an unexpected rejection is not left + // unhandled (the server has no global handler). + const { testRun, finished } = await this.testRunnerService.startTestRun( + req.user, + workflowId, + 1, + { via: 'public-api' }, + ); + void finished.catch((error) => this.errorReporter.error(error)); + + return { + id: testRun.id, + status: testRun.status, + createdAt: testRun.createdAt.toISOString(), + }; + } + + // The quota doubles as the feature flag: 0 = disabled. Cheap in-memory gate. + private assertEvaluationsEnabled() { + if (this.licenseState.getMaxWorkflowsWithEvaluations() === 0) { + throw new ForbiddenError('Evaluations are not available on your plan'); + } + } +} diff --git a/packages/cli/src/public-api/v1/controllers/folders.public.controller.ts b/packages/cli/src/public-api/v1/controllers/folders.public.controller.ts index beb7176f6ba..64ccc9627e1 100644 --- a/packages/cli/src/public-api/v1/controllers/folders.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/folders.public.controller.ts @@ -1,4 +1,5 @@ import { + DeleteFolderQueryPublicDto, FolderDetailsPublicDto, FolderListPublicDto, type FolderPublic, @@ -22,6 +23,7 @@ import { ApiSummary, ApiTags, Body, + Delete, Get, Licensed, Param, @@ -180,4 +182,33 @@ export class FoldersPublicController { return handleError(error); } } + + @Delete('/:folderId') + @Licensed(LICENSE_FEATURES.FOLDERS) + @ApiKeyScope('folder:delete') + @ApiSummary('Delete a folder') + @ApiDescription( + 'Delete a folder within a project. When `transferToFolderId` is provided, workflows and ' + + 'sub-folders are moved to the target folder before deletion. When omitted, workflows are ' + + 'moved to the project root and archived, and child folders are deleted.', + ) + @ApiTags(tags) + @ApiResponse(204) + @ApiErrorResponse(404) + async deleteFolder( + req: AuthenticatedRequest, + _res: Response, + @Param('projectId', projectIdParamSchema) projectId: string, + @Param('folderId', folderIdParamSchema) folderId: string, + @Query query: DeleteFolderQueryPublicDto, + ): Promise { + // Stays a manual check: an unknown project answers 404 here, where `@ProjectScope` answers 403. + await assertProjectScope(req.user, projectId, ['folder:delete']); + + try { + await this.folderService.deleteFolder(req.user, folderId, projectId, query); + } catch (error) { + return handleError(error); + } + } } diff --git a/packages/cli/src/public-api/v1/controllers/index.ts b/packages/cli/src/public-api/v1/controllers/index.ts index 33c4cb8fd09..ceb71d4c667 100644 --- a/packages/cli/src/public-api/v1/controllers/index.ts +++ b/packages/cli/src/public-api/v1/controllers/index.ts @@ -4,6 +4,8 @@ * scope-parity / discover run. */ import './credentials.public.controller'; +import './discover.public.controller'; +import './evaluations.public.controller'; import './executions.public.controller'; import './folders.public.controller'; import './insights.public.controller'; @@ -14,5 +16,6 @@ import './role-mapping-rules.public.controller'; import './roles.public.controller'; import './source-control.public.controller'; import './tags.public.controller'; +import './users.public.controller'; import './variables.public.controller'; import './workflows.public.controller'; diff --git a/packages/cli/src/public-api/v1/controllers/node-type-policies.public.controller.ts b/packages/cli/src/public-api/v1/controllers/node-type-policies.public.controller.ts index 5b5013aae25..8fb5d6eab6f 100644 --- a/packages/cli/src/public-api/v1/controllers/node-type-policies.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/node-type-policies.public.controller.ts @@ -84,7 +84,7 @@ export class NodeTypePoliciesPublicController { } @Get('/instance') - @Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES) + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @ApiKeyScope('nodeTypePolicy:manage') @GlobalScope('nodeTypePolicy:manage') @ApiSummary('Retrieve the instance node type policy') @@ -106,7 +106,7 @@ export class NodeTypePoliciesPublicController { } @Put('/instance') - @Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES) + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @ApiKeyScope('nodeTypePolicy:manage') @GlobalScope('nodeTypePolicy:manage') @ApiSummary('Replace the instance node type policy') @@ -140,7 +140,7 @@ export class NodeTypePoliciesPublicController { } @Get('/projects/:projectId') - @Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES) + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @ApiKeyScope('nodeTypePolicy:manage') @ProjectScope('nodeTypePolicy:manage') @ApiSummary("Retrieve a project's node type policy") @@ -166,7 +166,7 @@ export class NodeTypePoliciesPublicController { } @Put('/projects/:projectId') - @Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES) + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @ApiKeyScope('nodeTypePolicy:manage') @ProjectScope('nodeTypePolicy:manage') @ApiSummary("Replace a project's node type policy") @@ -201,7 +201,7 @@ export class NodeTypePoliciesPublicController { } @Get('/policies') - @Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES) + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @ApiKeyScope('nodeTypePolicy:manage') @GlobalScope('nodeTypePolicy:manage') @ApiSummary('List node type policy documents') @@ -229,7 +229,7 @@ export class NodeTypePoliciesPublicController { } @Post('/policies') - @Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES) + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @ApiKeyScope('nodeTypePolicy:manage') @GlobalScope('nodeTypePolicy:manage') @ApiSummary('Create a node type policy document') @@ -254,7 +254,7 @@ export class NodeTypePoliciesPublicController { } @Get('/policies/:policyId') - @Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES) + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @ApiKeyScope('nodeTypePolicy:manage') @GlobalScope('nodeTypePolicy:manage') @ApiSummary('Retrieve a node type policy document') @@ -276,7 +276,7 @@ export class NodeTypePoliciesPublicController { } @Put('/policies/:policyId') - @Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES) + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @ApiKeyScope('nodeTypePolicy:manage') @GlobalScope('nodeTypePolicy:manage') @ApiSummary('Replace the rules of a node type policy document') @@ -306,7 +306,7 @@ export class NodeTypePoliciesPublicController { } @Delete('/policies/:policyId') - @Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES) + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @ApiKeyScope('nodeTypePolicy:manage') @GlobalScope('nodeTypePolicy:manage') @ApiSummary('Delete a node type policy document') @@ -327,7 +327,7 @@ export class NodeTypePoliciesPublicController { } @Put('/scopes/:scopeId/attachments') - @Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES) + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @ApiKeyScope('nodeTypePolicy:manage') @GlobalScope('nodeTypePolicy:manage') @ApiSummary("Replace a scope's attached policy documents") diff --git a/packages/cli/src/public-api/v1/controllers/projects.public.controller.ts b/packages/cli/src/public-api/v1/controllers/projects.public.controller.ts index 7eb91e34f12..f33fb0a736c 100644 --- a/packages/cli/src/public-api/v1/controllers/projects.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/projects.public.controller.ts @@ -26,6 +26,7 @@ import { Body, Delete, Get, + GlobalScope, Licensed, Param, Patch, @@ -99,19 +100,28 @@ export class ProjectsPublicController { } @Post('/') + @GlobalScope('project:create') @Licensed(LICENSE_FEATURES.PROJECT_ROLE_ADMIN) @ApiKeyScope('project:create') @ApiSummary('Create a project') - @ApiDescription('Create a project on your instance.') + @ApiDescription('Create a team project on your instance.') @ApiTags(tags) @ApiResponse(201, CreatedProjectPublicDto) + @ApiErrorResponse(409, { + description: + 'A project with this ID exists already. The request does not change the existing project or its members.', + }) @ApiErrorResponse(400) async createProject( req: AuthenticatedRequest, _res: Response, @Body body: CreateProjectPublicDto, ): Promise { - const project = await this.projectService.createTeamProject(req.user, { name: body.name }); + const project = await this.projectService.createTeamProject( + req.user, + { name: body.name, icon: body.icon ?? undefined }, + { id: body.id, description: body.description, customTelemetryTags: body.customTelemetryTags }, + ); const scopes = await this.projectService.getProjectScopesForUser(req.user, project.id); diff --git a/packages/cli/src/public-api/v1/controllers/users.public.controller.ts b/packages/cli/src/public-api/v1/controllers/users.public.controller.ts new file mode 100644 index 00000000000..3989dc8e929 --- /dev/null +++ b/packages/cli/src/public-api/v1/controllers/users.public.controller.ts @@ -0,0 +1,180 @@ +import { + CreateUsersPublicDto, + DeleteUserQueryPublicDto, + GetUserQueryDto, + InvitedUsersPublicDto, + ListUsersQueryDto, + RoleChangeRequestDto, + UserListPublicDto, + UserPublicDto, + userIdentifierParamSchema, + userUuidParamSchema, +} from '@n8n/api-types'; +import type { AuthenticatedRequest, User } from '@n8n/db'; +import { + ApiDescription, + ApiErrorResponse, + ApiKeyScope, + ApiResponse, + ApiSummary, + ApiTags, + Body, + Delete, + Get, + Licensed, + Param, + Patch, + Post, + PublicApiController, + Query, + RequiresUserQuota, +} from '@n8n/decorators'; +import type { Response } from 'express'; +import pick from 'lodash/pick'; + +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { EventService } from '@/events/event.service'; +import { + encodeNextCursor, + resolveOffsetPagination, +} from '@/public-api/v1/shared/services/pagination.service'; +import { ProjectService } from '@/services/project.service.ee'; +import { UserService } from '@/services/user.service'; + +const tags = ['User']; + +const userProperties = ['id', 'email', 'firstName', 'lastName', 'isPending', 'mfaEnabled'] as const; + +function toPublicApiUser(user: User, options?: { includeRole: boolean }): UserPublicDto { + const publicApiUser = { + ...pick(user, ...userProperties), + createdAt: user.createdAt.toISOString(), + updatedAt: user.updatedAt.toISOString(), + }; + + return options?.includeRole ? { ...publicApiUser, role: user.role?.slug } : publicApiUser; +} + +@PublicApiController('/users') +export class UsersPublicController { + constructor( + private readonly userService: UserService, + private readonly projectService: ProjectService, + private readonly eventService: EventService, + ) {} + + @Get('/') + @ApiKeyScope('user:list') + @RequiresUserQuota() + @ApiSummary('Retrieve all users') + @ApiDescription('Retrieve all users from your instance. Only available for the instance owner.') + @ApiTags(tags) + @ApiResponse(200, UserListPublicDto) + @ApiErrorResponse(404, { description: 'The given `projectId` does not exist.' }) + async getUsers( + req: AuthenticatedRequest, + _res: Response, + @Query query: ListUsersQueryDto, + ): Promise { + const { includeRole, projectId } = query; + const { offset, limit } = resolveOffsetPagination(query); + + await this.userService.assertGetUsersAccess(req.user, projectId); + + const userIdsInProject = projectId + ? await this.projectService.findUserIdsByProjectId(projectId) + : undefined; + + const { users, count } = await this.userService.getUsersAndCount({ + ids: userIdsInProject, + limit, + offset, + }); + + this.eventService.emit('user-retrieved-all-users', { + userId: req.user.id, + publicApi: true, + }); + + return { + data: users.map((user) => toPublicApiUser(user, { includeRole })), + nextCursor: encodeNextCursor({ offset, limit, numberOfTotalRecords: count }), + }; + } + + @Get('/:userId') + @ApiKeyScope('user:read') + @RequiresUserQuota() + @ApiSummary('Get user by ID/Email') + @ApiDescription('Retrieve a user from your instance. Only available for the instance owner.') + @ApiTags(tags) + @ApiResponse(200, UserPublicDto) + @ApiErrorResponse(404, { description: 'No user with that ID or email.' }) + async getUser( + req: AuthenticatedRequest, + _res: Response, + @Param('userId', userIdentifierParamSchema) userId: string, + @Query query: GetUserQueryDto, + ): Promise { + const { includeRole } = query; + + const user = await this.userService.getUser(userId); + if (!user) { + throw new NotFoundError(`Could not find user with id: ${userId}`); + } + + this.eventService.emit('user-retrieved-user', { + userId: req.user.id, + publicApi: true, + }); + + return toPublicApiUser(user, { includeRole }); + } + + @Post('/') + @ApiKeyScope('user:create') + @ApiSummary('Create multiple users') + @ApiDescription('Create one or more users.') + @ApiTags(tags) + @ApiResponse(201, InvitedUsersPublicDto) + async createUser( + req: AuthenticatedRequest, + _res: Response, + @Body body: CreateUsersPublicDto, + ): Promise { + return await this.userService.inviteUser(req.user, body); + } + + @Delete('/:userId') + @ApiKeyScope('user:delete') + @ApiSummary('Delete user') + @ApiDescription('Delete a user from your instance.') + @ApiTags(tags) + @ApiResponse(204) + @ApiErrorResponse(404) + async deleteUser( + req: AuthenticatedRequest, + _res: Response, + @Param('userId', userUuidParamSchema) userId: string, + @Query query: DeleteUserQueryPublicDto, + ): Promise { + await this.userService.deleteUser(req.user, userId, query.transferId); + } + + @Patch('/:userId/role') + @Licensed('feat:advancedPermissions') + @ApiKeyScope('user:changeRole') + @ApiSummary("Change a user's global role") + @ApiDescription("Change a user's global role") + @ApiTags(tags) + @ApiResponse(204) + @ApiErrorResponse(404) + async changeRole( + req: AuthenticatedRequest, + _res: Response, + @Param('userId', userUuidParamSchema) userId: string, + @Body body: RoleChangeRequestDto, + ): Promise { + await this.userService.changeGlobalRole(req.user, userId, body); + } +} diff --git a/packages/cli/src/public-api/v1/handlers/discover/__tests__/discover.handler.test.ts b/packages/cli/src/public-api/v1/handlers/discover/__tests__/discover.handler.test.ts deleted file mode 100644 index 12b1b02a39a..00000000000 --- a/packages/cli/src/public-api/v1/handlers/discover/__tests__/discover.handler.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { AuthenticatedRequest } from '@n8n/db'; -import type { Response } from 'express'; - -import { UnauthenticatedError } from '@/errors/response-errors/unauthenticated.error'; - -import * as discoverService from '../discover.service'; - -// Loaded dynamically (handler routes are arrays of middleware + handler) and -// typed loosely so the suite can invoke individual entries by index. -let handler: Record unknown>>; - -beforeAll(async () => { - handler = (await import('../discover.handler.js')) as unknown as typeof handler; -}); - -describe('Discover Handler', () => { - let mockResponse: Partial; - - beforeEach(() => { - vi.clearAllMocks(); - - mockResponse = { - json: vi.fn().mockReturnThis(), - }; - }); - - function makeRequest( - query: { include?: string; resource?: string; operation?: string } = {}, - apiKeyScopes?: string[], - ) { - return { - query, - tokenGrant: apiKeyScopes ? { apiKeyScopes } : undefined, - } as unknown as AuthenticatedRequest; - } - - it('should throw UnauthenticatedError when tokenGrant is missing', async () => { - const req = makeRequest(); - - const handlerFn = handler.getDiscover[0]; - let caught: unknown; - try { - await handlerFn(req, mockResponse as Response); - } catch (error) { - caught = error; - } - expect(caught).toBeInstanceOf(UnauthenticatedError); - expect(caught).toMatchObject({ message: 'Unauthorized', httpStatusCode: 401 }); - }); - - it('should return discover data when tokenGrant has scopes', async () => { - const scopes = ['tag:list', 'tag:create']; - - const mockDiscoverResponse = { - scopes, - resources: { - tags: { - operations: ['list', 'create'], - endpoints: [ - { method: 'GET', path: '/api/v1/tags', operationId: 'getTags' }, - { method: 'POST', path: '/api/v1/tags', operationId: 'createTag' }, - ], - }, - }, - filters: {}, - specUrl: '/api/v1/openapi.yml', - }; - - vi.spyOn(discoverService, 'buildDiscoverResponse').mockResolvedValue(mockDiscoverResponse); - - const req = makeRequest({}, scopes); - - const handlerFn = handler.getDiscover[0]; - await handlerFn(req, mockResponse); - - expect(mockResponse.json).toHaveBeenCalledWith({ data: mockDiscoverResponse }); - expect(discoverService.buildDiscoverResponse).toHaveBeenCalledWith(scopes, { - includeSchemas: false, - resource: undefined, - operation: undefined, - }); - }); - - it('should pass includeSchemas true when query include is schemas', async () => { - const scopes = ['tag:list']; - - vi.spyOn(discoverService, 'buildDiscoverResponse').mockResolvedValue({ - scopes, - resources: {}, - filters: {}, - specUrl: '/api/v1/openapi.yml', - }); - - const req = makeRequest({ include: 'schemas' }, scopes); - - const handlerFn = handler.getDiscover[0]; - await handlerFn(req, mockResponse); - - expect(discoverService.buildDiscoverResponse).toHaveBeenCalledWith(scopes, { - includeSchemas: true, - resource: undefined, - operation: undefined, - }); - }); - - it('should pass resource and operation query params to service', async () => { - const scopes = ['workflow:create']; - - vi.spyOn(discoverService, 'buildDiscoverResponse').mockResolvedValue({ - scopes, - resources: {}, - filters: {}, - specUrl: '/api/v1/openapi.yml', - }); - - const req = makeRequest({ resource: 'workflow', operation: 'create' }, scopes); - - const handlerFn = handler.getDiscover[0]; - await handlerFn(req, mockResponse); - - expect(discoverService.buildDiscoverResponse).toHaveBeenCalledWith(scopes, { - includeSchemas: false, - resource: 'workflow', - operation: 'create', - }); - }); -}); diff --git a/packages/cli/src/public-api/v1/handlers/discover/discover.handler.ts b/packages/cli/src/public-api/v1/handlers/discover/discover.handler.ts deleted file mode 100644 index 4c07e8dafdd..00000000000 --- a/packages/cli/src/public-api/v1/handlers/discover/discover.handler.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { AuthenticatedRequest } from '@n8n/db'; - -import { UnauthenticatedError } from '@/errors/response-errors/unauthenticated.error'; - -import { buildDiscoverResponse } from './discover.service'; -import type { PublicAPIEndpoint } from '../../shared/handler.types'; - -type GetDiscoverRequest = AuthenticatedRequest< - {}, - {}, - {}, - { include?: string; resource?: string; operation?: string } ->; - -function firstString(value: unknown): string | undefined { - if (typeof value === 'string') return value; - if (Array.isArray(value) && typeof value[0] === 'string') return value[0]; - return undefined; -} - -type DiscoverHandlers = { - getDiscover: PublicAPIEndpoint; -}; - -const discoverHandlers: DiscoverHandlers = { - getDiscover: [ - async (req, res) => { - const scopes = req.tokenGrant?.apiKeyScopes; - if (!scopes) { - throw new UnauthenticatedError('Unauthorized'); - } - - const includeSchemas = req.query.include === 'schemas'; - const response = await buildDiscoverResponse(scopes, { - includeSchemas, - resource: firstString(req.query.resource), - operation: firstString(req.query.operation), - }); - return res.json({ data: response }); - }, - ], -}; - -export = discoverHandlers; diff --git a/packages/cli/src/public-api/v1/handlers/discover/discover.service.ts b/packages/cli/src/public-api/v1/handlers/discover/discover.service.ts index 8b8018b2740..c36d371e9db 100644 --- a/packages/cli/src/public-api/v1/handlers/discover/discover.service.ts +++ b/packages/cli/src/public-api/v1/handlers/discover/discover.service.ts @@ -1,4 +1,5 @@ import RefParser from '@apidevtools/json-schema-ref-parser'; +import type { DiscoverDataPublic } from '@n8n/api-types'; import type { ApiKeyScopeRequirement } from '@n8n/decorators'; import { isRecord } from '@n8n/utils/is-record'; import path from 'path'; @@ -37,19 +38,6 @@ interface ResourceInfo { endpoints: EndpointEntry[]; } -interface FilterInfo { - description: string; - example?: string; - values?: string[]; -} - -export interface DiscoverResponse { - scopes: readonly string[]; - resources: Record; - filters: Record; - specUrl: string; -} - export interface DiscoverOptions { includeSchemas?: boolean; resource?: string; @@ -158,7 +146,7 @@ function buildDecoratorEndpoints(): EndpointInfo[] { export async function buildDiscoverResponse( callerScopes: readonly string[], options?: DiscoverOptions, -): Promise { +): Promise { const allEndpoints = await parseEndpointsFromSpec(); const includeSchemas = options?.includeSchemas === true; @@ -239,7 +227,7 @@ export async function buildDiscoverResponse( const allOperations = [...new Set(Object.values(resources).flatMap((r) => r.operations))]; return { - scopes: callerScopes, + scopes: [...callerScopes], resources: filteredResources, filters: { resource: { diff --git a/packages/cli/src/public-api/v1/handlers/discover/spec/paths/discover.yml b/packages/cli/src/public-api/v1/handlers/discover/spec/paths/discover.yml deleted file mode 100644 index 6edac69b8a7..00000000000 --- a/packages/cli/src/public-api/v1/handlers/discover/spec/paths/discover.yml +++ /dev/null @@ -1,97 +0,0 @@ -get: - x-eov-operation-id: getDiscover - x-required-scope: none - x-eov-operation-handler: v1/handlers/discover/discover.handler - tags: - - Discover - summary: Discover available API capabilities - description: > - Returns a filtered capability map based on the caller's API key scopes. - Each resource includes the operations and endpoints accessible to the - authenticated API key. Use query parameters to narrow the response. - parameters: - - name: include - in: query - required: false - schema: - type: string - enum: - - schemas - description: > - Include additional data. Use "schemas" to inline request body schemas - per endpoint, eliminating the need to fetch the full OpenAPI spec. - - name: resource - in: query - required: false - schema: - type: string - description: > - Filter to a specific resource (e.g. "workflow", "tags", "credential"). - - name: operation - in: query - required: false - schema: - type: string - description: > - Filter to endpoints with a specific operation (e.g. "read", "create", "list"). - responses: - '200': - description: Discovery response with available resources and endpoints. - content: - application/json: - schema: - type: object - properties: - data: - type: object - properties: - scopes: - type: array - items: - type: string - description: The API key's active scopes - resources: - type: object - additionalProperties: - type: object - properties: - operations: - type: array - items: - type: string - endpoints: - type: array - items: - type: object - properties: - method: - type: string - path: - type: string - operationId: - type: string - requestSchema: - type: object - description: > - Request body schema (only present when - include=schemas and the endpoint accepts a - request body). - filters: - type: object - description: > - Available query parameter filters. The values arrays - reflect what the caller's scopes permit. - additionalProperties: - type: object - properties: - description: - type: string - values: - type: array - items: - type: string - specUrl: - type: string - description: URL to the full OpenAPI specification - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' diff --git a/packages/cli/src/public-api/v1/handlers/discover/spec/paths/getDiscover.generated.yml b/packages/cli/src/public-api/v1/handlers/discover/spec/paths/getDiscover.generated.yml new file mode 100644 index 00000000000..283f33487e3 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/discover/spec/paths/getDiscover.generated.yml @@ -0,0 +1,104 @@ +operationId: getDiscover +tags: + - Discover +summary: Discover available API capabilities +description: Returns a filtered capability map based on the caller's API key scopes. Each resource includes the operations and endpoints accessible to the authenticated API key. Use query parameters to narrow the response. +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +parameters: + - schema: + type: string + enum: + - schemas + description: Include additional data. Use "schemas" to inline request body schemas per endpoint, eliminating the need to fetch the full OpenAPI spec. + required: false + name: include + in: query + - schema: + type: string + description: Filter to a specific resource (e.g. "workflow", "tags", "credential"). + required: false + name: resource + in: query + - schema: + type: string + description: Filter to endpoints with a specific operation (e.g. "read", "create", "list"). + required: false + name: operation + in: query +responses: + '200': + description: Operation successful. + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + scopes: + type: array + items: + type: string + description: The API key's active scopes + resources: + type: object + additionalProperties: + type: object + properties: + operations: + type: array + items: + type: string + endpoints: + type: array + items: + type: object + properties: + method: + type: string + path: + type: string + operationId: + type: string + requestSchema: + type: object + description: Request body schema (only present when include=schemas and the endpoint accepts a request body). + required: + - method + - path + - operationId + required: + - operations + - endpoints + filters: + type: object + additionalProperties: + type: object + properties: + description: + type: string + values: + type: array + items: + type: string + required: + - description + - values + description: Available query parameter filters. The values arrays reflect what the caller's scopes permit. + specUrl: + type: string + description: URL to the full OpenAPI specification + required: + - scopes + - resources + - filters + - specUrl + required: + - data + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml diff --git a/packages/cli/src/public-api/v1/handlers/evaluations/evaluations.handler.ts b/packages/cli/src/public-api/v1/handlers/evaluations/evaluations.handler.ts index 01167de683a..dde4b7cdddd 100644 --- a/packages/cli/src/public-api/v1/handlers/evaluations/evaluations.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/evaluations/evaluations.handler.ts @@ -1,8 +1,6 @@ -import type { TestRunCancelDto, TestRunDto } from '@n8n/api-types'; +import type { TestRunCancelDto } from '@n8n/api-types'; import { LicenseState } from '@n8n/backend-common'; import { Container } from '@n8n/di'; -import { ErrorReporter } from 'n8n-core'; -import { EVALUATION_TRIGGER_NODE_TYPE } from 'n8n-workflow'; import { toTestCaseExecutionDto, toTestRunSummaryDto } from './evaluations.mapper'; import type { TestRunRequest } from '../../../types'; @@ -19,13 +17,10 @@ import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { EvaluationTestRunService } from '@/evaluation.ee/evaluation-test-run.service'; import { TestRunnerService } from '@/evaluation.ee/test-runner/test-runner.service.ee'; -import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; type EvaluationsHandlers = { - getTestRuns: PublicAPIEndpoint; getTestRun: PublicAPIEndpoint; getTestCases: PublicAPIEndpoint; - createTestRun: PublicAPIEndpoint; cancelTestRun: PublicAPIEndpoint; }; @@ -37,26 +32,6 @@ function assertEvaluationsEnabled() { } const evaluationsHandlers: EvaluationsHandlers = { - getTestRuns: [ - publicApiScope('testRun:list'), - projectScope('workflow:read', 'workflow'), - validCursor, - async (req, res) => { - const { id: workflowId } = req.params; - const { offset = 0, limit = 100, status } = req.query; - - const { testRuns, count } = await Container.get(EvaluationTestRunService).findManyAndCount( - workflowId, - { offset, limit }, - status, - ); - - return res.json({ - data: testRuns.map(toTestRunSummaryDto), - nextCursor: encodeNextCursor({ offset, limit, numberOfTotalRecords: count }), - }); - }, - ], getTestRun: [ publicApiScope('testRun:read'), projectScope('workflow:read', 'workflow'), @@ -103,48 +78,6 @@ const evaluationsHandlers: EvaluationsHandlers = { }); }, ], - createTestRun: [ - publicApiScope('testRun:create'), - // Starting a run triggers real executions — require workflow:execute. - projectScope('workflow:execute', 'workflow'), - async (req, res) => { - const { id: workflowId } = req.params; - const testRunnerService = Container.get(TestRunnerService); - - assertEvaluationsEnabled(); - - // Reject a workflow with no evaluation trigger up-front (409). Deeper - // validation still happens async in the run, matching the internal path. - const workflow = await Container.get(WorkflowFinderService).findWorkflowForUser( - workflowId, - req.user, - ['workflow:execute'], - ); - if (!workflow) throw new NotFoundError('Workflow not found'); - - const hasTrigger = workflow.nodes.some((node) => node.type === EVALUATION_TRIGGER_NODE_TYPE); - if (!hasTrigger) { - throw new ConflictError('Workflow has no evaluation trigger'); - } - - // Count query runs last, after the cheaper 403/404/409 checks. - await Container.get(EvaluationTestRunService).assertEvaluationQuotaAvailable(workflowId); - - // Case execution runs detached; guard `finished` so an unexpected - // rejection isn't left unhandled (the server has no global handler). - const { testRun, finished } = await testRunnerService.startTestRun(req.user, workflowId, 1, { - via: 'public-api', - }); - void finished.catch((error) => Container.get(ErrorReporter).error(error)); - - const body: TestRunDto = { - id: testRun.id, - status: testRun.status, - createdAt: testRun.createdAt.toISOString(), - }; - return res.status(201).json(body); - }, - ], cancelTestRun: [ publicApiScope('testRun:cancel'), // Cancelling mutates execution state — require workflow:execute. diff --git a/packages/cli/src/public-api/v1/handlers/evaluations/spec/paths/workflows.id.test-runs.yml b/packages/cli/src/public-api/v1/handlers/evaluations/spec/paths/workflows.id.test-runs.yml deleted file mode 100644 index cd9701c332a..00000000000 --- a/packages/cli/src/public-api/v1/handlers/evaluations/spec/paths/workflows.id.test-runs.yml +++ /dev/null @@ -1,59 +0,0 @@ -get: - x-eov-operation-id: getTestRuns - x-required-scope: testRun:list - x-eov-operation-handler: v1/handlers/evaluations/evaluations.handler - tags: - - Evaluation - summary: Retrieve test runs - description: Retrieve the evaluation test runs of a workflow. - parameters: - - $ref: '../schemas/parameters/testRunWorkflowId.yml' - - name: status - in: query - description: Status to filter the test runs by. - required: false - schema: - type: string - enum: ['new', 'running', 'completed', 'error', 'cancelled'] - - $ref: '../../../../shared/spec/parameters/limit.yml' - - $ref: '../../../../shared/spec/parameters/cursor.yml' - responses: - '200': - description: Operation successful. - content: - application/json: - schema: - $ref: '../schemas/testRunList.yml' - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' - '403': - $ref: '../../../../shared/spec/responses/forbidden.yml' - '404': - $ref: '../../../../shared/spec/responses/notFound.yml' -post: - x-eov-operation-id: createTestRun - x-required-scope: testRun:create - x-eov-operation-handler: v1/handlers/evaluations/evaluations.handler - tags: - - Evaluation - summary: Trigger a test run - description: Start a new evaluation test run for a workflow. The workflow must contain a configured evaluation trigger. Requires the `workflow:execute` project scope in addition to the `testRun:create` API key scope. - parameters: - - $ref: '../schemas/parameters/testRunWorkflowId.yml' - responses: - '201': - description: Test run triggered. - content: - application/json: - schema: - $ref: '../schemas/testRun.yml' - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' - '402': - $ref: '../../../../shared/spec/responses/paymentRequired.yml' - '403': - $ref: '../../../../shared/spec/responses/forbidden.yml' - '404': - $ref: '../../../../shared/spec/responses/notFound.yml' - '409': - $ref: '../../../../shared/spec/responses/conflict.yml' diff --git a/packages/cli/src/public-api/v1/handlers/evaluations/spec/schemas/testRun.yml b/packages/cli/src/public-api/v1/handlers/evaluations/spec/schemas/testRun.yml deleted file mode 100644 index 3c40906f0a7..00000000000 --- a/packages/cli/src/public-api/v1/handlers/evaluations/spec/schemas/testRun.yml +++ /dev/null @@ -1,11 +0,0 @@ -type: object -properties: - id: - type: string - example: 9f8e7d6c5b4a3210 - status: - type: string - enum: ['new', 'running', 'completed', 'error', 'cancelled'] - createdAt: - type: string - format: date-time diff --git a/packages/cli/src/public-api/v1/handlers/evaluations/spec/schemas/testRunList.yml b/packages/cli/src/public-api/v1/handlers/evaluations/spec/schemas/testRunList.yml deleted file mode 100644 index 89a1d70ed4b..00000000000 --- a/packages/cli/src/public-api/v1/handlers/evaluations/spec/schemas/testRunList.yml +++ /dev/null @@ -1,11 +0,0 @@ -type: object -properties: - data: - type: array - items: - $ref: './testRunSummary.yml' - nextCursor: - type: string - nullable: true - 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 diff --git a/packages/cli/src/public-api/v1/handlers/folders/folders.handler.ts b/packages/cli/src/public-api/v1/handlers/folders/folders.handler.ts index 6c0f4d5ebcc..085a6c188b2 100644 --- a/packages/cli/src/public-api/v1/handlers/folders/folders.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/folders/folders.handler.ts @@ -1,4 +1,4 @@ -import { CreateFolderDto, DeleteFolderDto } from '@n8n/api-types'; +import { CreateFolderDto } from '@n8n/api-types'; import type { AuthenticatedRequest } from '@n8n/db'; import { Container } from '@n8n/di'; import { UserError } from 'n8n-workflow'; @@ -31,7 +31,6 @@ const handleError = (error: unknown) => { type FolderHandlers = { createFolder: PublicAPIEndpoint>; - deleteFolder: PublicAPIEndpoint>; }; const folderHandlers: FolderHandlers = { @@ -63,26 +62,6 @@ const folderHandlers: FolderHandlers = { } }, ], - deleteFolder: [ - isLicensed('feat:folders'), - apiKeyHasScopeWithGlobalScopeFallback({ scope: 'folder:delete' }), - async (req, res) => { - const { projectId, folderId } = req.params; - await assertProjectScope(req.user, projectId, ['folder:delete']); - - const query = DeleteFolderDto.safeParse(req.query); - if (query.error) { - throw new BadRequestError(query.error.errors[0].message); - } - - try { - await Container.get(FolderService).deleteFolder(req.user, folderId, projectId, query.data); - return res.status(204).send(); - } catch (error) { - return handleError(error); - } - }, - ], }; export = folderHandlers; diff --git a/packages/cli/src/public-api/v1/handlers/folders/spec/paths/folders.folderId.yml b/packages/cli/src/public-api/v1/handlers/folders/spec/paths/folders.folderId.yml deleted file mode 100644 index 122ab581619..00000000000 --- a/packages/cli/src/public-api/v1/handlers/folders/spec/paths/folders.folderId.yml +++ /dev/null @@ -1,38 +0,0 @@ -delete: - x-eov-operation-id: deleteFolder - x-required-scope: folder:delete - x-eov-operation-handler: v1/handlers/folders/folders.handler - tags: - - Folders - summary: Delete a folder - description: Delete a folder within a project. When `transferToFolderId` is provided, workflows and sub-folders are moved to the target folder before deletion. When omitted, workflows are moved to the project root and archived, and child folders are deleted. - parameters: - - name: projectId - in: path - description: The ID of the project. - required: true - schema: - type: string - - name: folderId - in: path - description: The ID of the folder. - required: true - schema: - type: string - - name: transferToFolderId - in: query - description: Optional target folder ID to move workflows and sub-folders into before deleting. - required: false - schema: - type: string - responses: - '204': - description: Operation successful. - '400': - $ref: '../../../../shared/spec/responses/badRequest.yml' - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' - '403': - $ref: '../../../../shared/spec/responses/forbidden.yml' - '404': - $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/public-api/v1/handlers/projects/spec/paths/createProject.generated.yml b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/createProject.generated.yml index 790e33047bf..2fa14c5ac95 100644 --- a/packages/cli/src/public-api/v1/handlers/projects/spec/paths/createProject.generated.yml +++ b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/createProject.generated.yml @@ -2,7 +2,7 @@ operationId: createProject tags: - Projects summary: Create a project -description: Create a project on your instance. +description: Create a team project on your instance. x-required-scope: project:create x-eov-operation-id: unreachable x-eov-operation-handler: v1/handlers/decorator-routed.handler @@ -21,7 +21,44 @@ requestBody: example: Marketing id: type: string - readOnly: true + minLength: 1 + maxLength: 36 + description: Project ID. The server generates an ID when omitted. An ID that is in use returns HTTP 409. + example: VmwOO9HeTEj20kxM + icon: + type: object + nullable: true + properties: + type: + type: string + enum: + - emoji + - icon + value: + type: string + minLength: 1 + color: + type: string + required: + - type + - value + description: + type: string + nullable: true + maxLength: 512 + example: Workflows the marketing team owns. + customTelemetryTags: + type: array + items: + type: object + properties: + key: + type: string + value: + type: string + required: + - key + - value type: type: string readOnly: true @@ -122,5 +159,7 @@ responses: $ref: ../../../../shared/spec/responses/unauthorized.yml '403': $ref: ../../../../shared/spec/responses/forbidden.yml + '409': + description: A project with this ID exists already. The request does not change the existing project or its members. '415': $ref: ../../../../shared/spec/responses/unsupportedMediaType.yml diff --git a/packages/cli/src/public-api/v1/handlers/projects/spec/paths/deleteFolder.generated.yml b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/deleteFolder.generated.yml new file mode 100644 index 00000000000..e247ff78f9c --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/deleteFolder.generated.yml @@ -0,0 +1,41 @@ +operationId: deleteFolder +tags: + - Folders +summary: Delete a folder +description: Delete a folder within a project. When `transferToFolderId` is provided, workflows and sub-folders are moved to the target folder before deletion. When omitted, workflows are moved to the project root and archived, and child folders are deleted. +x-required-scope: folder:delete +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +parameters: + - schema: + type: string + required: true + description: The ID of the project. + name: projectId + in: path + - schema: + type: string + required: true + description: The ID of the folder. + name: folderId + in: path + - schema: + type: string + minLength: 1 + maxLength: 36 + description: Optional target folder ID to move workflows and sub-folders into before deleting. + required: false + name: transferToFolderId + in: query +responses: + '204': + description: Operation successful. + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '404': + $ref: ../../../../shared/spec/responses/notFound.yml diff --git a/packages/cli/src/public-api/v1/handlers/users/spec/paths/changeRole.generated.yml b/packages/cli/src/public-api/v1/handlers/users/spec/paths/changeRole.generated.yml new file mode 100644 index 00000000000..6a8106653f2 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/users/spec/paths/changeRole.generated.yml @@ -0,0 +1,47 @@ +operationId: changeRole +tags: + - User +summary: Change a user's global role +description: Change a user's global role +x-required-scope: user:changeRole +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +parameters: + - schema: + type: string + required: true + description: The ID of the user. + name: userId + in: path +requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + newRoleName: + anyOf: + - type: string + enum: + - global:admin + - global:member + - global:chatUser + - type: string + minLength: 1 + required: + - newRoleName +responses: + '204': + description: Operation successful. + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '404': + $ref: ../../../../shared/spec/responses/notFound.yml + '415': + $ref: ../../../../shared/spec/responses/unsupportedMediaType.yml diff --git a/packages/cli/src/public-api/v1/handlers/users/spec/paths/createUser.generated.yml b/packages/cli/src/public-api/v1/handlers/users/spec/paths/createUser.generated.yml new file mode 100644 index 00000000000..f21b0bfadc7 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/users/spec/paths/createUser.generated.yml @@ -0,0 +1,85 @@ +operationId: createUser +tags: + - User +summary: Create multiple users +description: Create one or more users. +x-required-scope: user:create +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +requestBody: + required: true + content: + application/json: + schema: + type: array + items: + type: object + properties: + email: + type: string + format: email + role: + anyOf: + - type: string + enum: + - global:admin + - global:member + - global:chatUser + - type: string + minLength: 1 + default: global:member + required: + - email +responses: + '201': + description: Operation successful. + content: + application/json: + schema: + type: array + items: + type: object + properties: + user: + type: object + properties: + id: + type: string + readOnly: true + example: 123e4567-e89b-12d3-a456-426614174000 + email: + type: string + format: email + example: john.doe@company.com + inviteAcceptUrl: + type: string + description: Link the invited user can open to finish setting up the account. Present only when the instance does not send invite emails. + readOnly: true + emailSent: + type: boolean + description: Whether the invite email was sent to the user. + readOnly: true + role: + type: string + readOnly: true + example: global:member + required: + - id + - email + - emailSent + - role + error: + type: string + description: The reason the invite failed for this user. Empty when the invite succeeded. + readOnly: true + required: + - user + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '415': + $ref: ../../../../shared/spec/responses/unsupportedMediaType.yml diff --git a/packages/cli/src/public-api/v1/handlers/users/spec/paths/deleteUser.generated.yml b/packages/cli/src/public-api/v1/handlers/users/spec/paths/deleteUser.generated.yml new file mode 100644 index 00000000000..e92478c5849 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/users/spec/paths/deleteUser.generated.yml @@ -0,0 +1,33 @@ +operationId: deleteUser +tags: + - User +summary: Delete user +description: Delete a user from your instance. +x-required-scope: user:delete +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +parameters: + - schema: + type: string + required: true + description: The ID of the user. + name: userId + in: path + - schema: + type: string + description: ID of the project to transfer the deleted user's workflows and credentials to. Omit to delete those resources instead of transferring them. + required: false + name: transferId + in: query +responses: + '204': + description: Operation successful. + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '404': + $ref: ../../../../shared/spec/responses/notFound.yml diff --git a/packages/cli/src/public-api/v1/handlers/users/spec/paths/getUser.generated.yml b/packages/cli/src/public-api/v1/handlers/users/spec/paths/getUser.generated.yml new file mode 100644 index 00000000000..5ba9b6ebe32 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/users/spec/paths/getUser.generated.yml @@ -0,0 +1,90 @@ +operationId: getUser +tags: + - User +summary: Get user by ID/Email +description: Retrieve a user from your instance. Only available for the instance owner. +x-required-scope: user:read +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +parameters: + - schema: + type: string + required: true + description: The ID or email of the user. + name: userId + in: path + - schema: + type: string + enum: + - 'true' + - 'false' + default: 'false' + required: false + name: includeRole + in: query +responses: + '200': + description: Operation successful. + content: + application/json: + schema: + type: object + properties: + id: + type: string + readOnly: true + example: 123e4567-e89b-12d3-a456-426614174000 + email: + type: string + nullable: true + format: email + example: john.doe@company.com + firstName: + type: string + nullable: true + readOnly: true + example: john + lastName: + type: string + nullable: true + readOnly: true + example: Doe + isPending: + type: boolean + description: Whether the user finished setting up their account in response to the invitation (true) or not (false). + readOnly: true + createdAt: + type: string + format: date-time + readOnly: true + updatedAt: + type: string + format: date-time + readOnly: true + mfaEnabled: + type: boolean + description: Whether the user has multi-factor authentication (MFA/2FA) enabled. + readOnly: true + example: false + role: + type: string + readOnly: true + example: global:owner + required: + - id + - email + - firstName + - lastName + - isPending + - createdAt + - updatedAt + - mfaEnabled + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '404': + description: No user with that ID or email. diff --git a/packages/cli/src/public-api/v1/handlers/users/spec/paths/getUsers.generated.yml b/packages/cli/src/public-api/v1/handlers/users/spec/paths/getUsers.generated.yml new file mode 100644 index 00000000000..26e6277e1d6 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/users/spec/paths/getUsers.generated.yml @@ -0,0 +1,104 @@ +operationId: getUsers +tags: + - User +summary: Retrieve all users +description: Retrieve all users from your instance. Only available for the instance owner. +x-required-scope: user:list +parameters: + - $ref: ../../../../shared/spec/parameters/limit.yml + - $ref: ../../../../shared/spec/parameters/cursor.yml + - schema: + type: string + enum: + - 'true' + - 'false' + default: 'false' + required: false + name: includeRole + in: query + - schema: + type: string + required: false + name: projectId + in: query +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +responses: + '200': + description: Operation successful. + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: object + properties: + id: + type: string + readOnly: true + example: 123e4567-e89b-12d3-a456-426614174000 + email: + type: string + nullable: true + format: email + example: john.doe@company.com + firstName: + type: string + nullable: true + readOnly: true + example: john + lastName: + type: string + nullable: true + readOnly: true + example: Doe + isPending: + type: boolean + description: Whether the user finished setting up their account in response to the invitation (true) or not (false). + readOnly: true + createdAt: + type: string + format: date-time + readOnly: true + updatedAt: + type: string + format: date-time + readOnly: true + mfaEnabled: + type: boolean + description: Whether the user has multi-factor authentication (MFA/2FA) enabled. + readOnly: true + example: false + role: + type: string + readOnly: true + example: global:owner + required: + - id + - email + - firstName + - lastName + - isPending + - createdAt + - updatedAt + - mfaEnabled + nextCursor: + type: string + nullable: true + 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 + required: + - data + - nextCursor + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '404': + description: The given `projectId` does not exist. diff --git a/packages/cli/src/public-api/v1/handlers/users/spec/paths/users.id.role.yml b/packages/cli/src/public-api/v1/handlers/users/spec/paths/users.id.role.yml deleted file mode 100644 index eff9f20cdd5..00000000000 --- a/packages/cli/src/public-api/v1/handlers/users/spec/paths/users.id.role.yml +++ /dev/null @@ -1,32 +0,0 @@ -patch: - x-eov-operation-id: changeRole - x-required-scope: user:changeRole - x-eov-operation-handler: v1/handlers/users/users.handler.ee - tags: - - User - summary: Change a user's global role - description: Change a user's global role - parameters: - - $ref: '../schemas/parameters/userIdentifier.yml' - requestBody: - description: New role for the user - required: true - content: - application/json: - schema: - type: object - properties: - newRoleName: - type: string - example: global:member - required: - - newRoleName - responses: - '200': - description: Operation successful. - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' - '403': - $ref: '../../../../shared/spec/responses/forbidden.yml' - '404': - $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/public-api/v1/handlers/users/spec/paths/users.id.yml b/packages/cli/src/public-api/v1/handlers/users/spec/paths/users.id.yml deleted file mode 100644 index 69f8d45b1e4..00000000000 --- a/packages/cli/src/public-api/v1/handlers/users/spec/paths/users.id.yml +++ /dev/null @@ -1,39 +0,0 @@ -get: - x-eov-operation-id: getUser - x-required-scope: user:read - x-eov-operation-handler: v1/handlers/users/users.handler.ee - tags: - - User - summary: Get user by ID/Email - description: Retrieve a user from your instance. Only available for the instance owner. - parameters: - - $ref: '../schemas/parameters/userIdentifier.yml' - - $ref: '../schemas/parameters/includeRole.yml' - responses: - '200': - description: Operation successful. - content: - application/json: - schema: - $ref: '../schemas/user.yml' - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' -delete: - x-eov-operation-id: deleteUser - x-required-scope: user:delete - x-eov-operation-handler: v1/handlers/users/users.handler.ee - tags: - - User - summary: Delete a user - description: Delete a user from your instance. - parameters: - - $ref: '../schemas/parameters/userIdentifier.yml' - responses: - '204': - description: Operation successful. - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' - '403': - $ref: '../../../../shared/spec/responses/forbidden.yml' - '404': - $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/public-api/v1/handlers/users/spec/paths/users.yml b/packages/cli/src/public-api/v1/handlers/users/spec/paths/users.yml deleted file mode 100644 index 09ef93f416b..00000000000 --- a/packages/cli/src/public-api/v1/handlers/users/spec/paths/users.yml +++ /dev/null @@ -1,81 +0,0 @@ -get: - x-eov-operation-id: getUsers - x-required-scope: user:list - x-eov-operation-handler: v1/handlers/users/users.handler.ee - tags: - - User - summary: Retrieve all users - description: Retrieve all users from your instance. Only available for the instance owner. - parameters: - - $ref: '../../../../shared/spec/parameters/limit.yml' - - $ref: '../../../../shared/spec/parameters/offset.yml' - - $ref: '../../../../shared/spec/parameters/cursor.yml' - - $ref: '../schemas/parameters/includeRole.yml' - - name: projectId - in: query - required: false - explode: false - allowReserved: true - schema: - type: string - example: VmwOO9HeTEj20kxM - responses: - '200': - description: Operation successful. - content: - application/json: - schema: - $ref: '../schemas/userList.yml' - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' -post: - x-eov-operation-id: createUser - x-required-scope: user:create - x-eov-operation-handler: v1/handlers/users/users.handler.ee - tags: - - User - summary: Create multiple users - description: Create one or more users. - requestBody: - description: Array of users to be created. - required: true - content: - application/json: - schema: - type: array - items: - type: object - properties: - email: - type: string - format: email - role: - type: string - example: global:member - required: - - email - responses: - '200': - description: Operation successful. - content: - application/json: - schema: - type: object - properties: - user: - type: object - properties: - id: - type: string - email: - type: string - inviteAcceptUrl: - type: string - emailSent: - type: boolean - error: - type: string - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' - '403': - $ref: '../../../../shared/spec/responses/forbidden.yml' diff --git a/packages/cli/src/public-api/v1/handlers/users/spec/schemas/parameters/includeRole.yml b/packages/cli/src/public-api/v1/handlers/users/spec/schemas/parameters/includeRole.yml deleted file mode 100644 index 5c560b8f671..00000000000 --- a/packages/cli/src/public-api/v1/handlers/users/spec/schemas/parameters/includeRole.yml +++ /dev/null @@ -1,8 +0,0 @@ -name: includeRole -in: query -description: Whether to include the user's role or not. -required: false -schema: - type: boolean - example: true - default: false diff --git a/packages/cli/src/public-api/v1/handlers/users/spec/schemas/parameters/userIdentifier.yml b/packages/cli/src/public-api/v1/handlers/users/spec/schemas/parameters/userIdentifier.yml deleted file mode 100644 index 2292813a137..00000000000 --- a/packages/cli/src/public-api/v1/handlers/users/spec/schemas/parameters/userIdentifier.yml +++ /dev/null @@ -1,7 +0,0 @@ -name: id -in: path -description: The ID or email of the user. -required: true -schema: - type: string - format: identifier diff --git a/packages/cli/src/public-api/v1/handlers/users/spec/schemas/role.yml b/packages/cli/src/public-api/v1/handlers/users/spec/schemas/role.yml deleted file mode 100644 index 4bd1faaa810..00000000000 --- a/packages/cli/src/public-api/v1/handlers/users/spec/schemas/role.yml +++ /dev/null @@ -1,25 +0,0 @@ -readOnly: true -type: object -properties: - id: - type: number - readOnly: true - example: 1 - name: - type: string - example: owner - readOnly: true - scope: - type: string - readOnly: true - example: global - createdAt: - type: string - description: Time the role was created. - format: date-time - readOnly: true - updatedAt: - type: string - description: Last time the role was updated. - format: date-time - readOnly: true diff --git a/packages/cli/src/public-api/v1/handlers/users/spec/schemas/user.yml b/packages/cli/src/public-api/v1/handlers/users/spec/schemas/user.yml deleted file mode 100644 index bc67f236d33..00000000000 --- a/packages/cli/src/public-api/v1/handlers/users/spec/schemas/user.yml +++ /dev/null @@ -1,47 +0,0 @@ -required: - - email -type: object -properties: - id: - type: string - readOnly: true - example: 123e4567-e89b-12d3-a456-426614174000 - email: - type: string - format: email - example: john.doe@company.com - firstName: - maxLength: 32 - type: string - description: User's first name - readOnly: true - example: john - lastName: - maxLength: 32 - type: string - description: User's last name - readOnly: true - example: Doe - isPending: - type: boolean - description: Whether the user finished setting up their account in response to the invitation (true) or not (false). - readOnly: true - createdAt: - type: string - description: Time the user was created. - format: date-time - readOnly: true - updatedAt: - type: string - description: Last time the user was updated. - format: date-time - readOnly: true - role: - type: string - example: global:owner - readOnly: true - mfaEnabled: - type: boolean - description: Whether the user has multi-factor authentication (MFA/2FA) enabled. - readOnly: true - example: false diff --git a/packages/cli/src/public-api/v1/handlers/users/spec/schemas/userList.yml b/packages/cli/src/public-api/v1/handlers/users/spec/schemas/userList.yml deleted file mode 100644 index 212b26d64ac..00000000000 --- a/packages/cli/src/public-api/v1/handlers/users/spec/schemas/userList.yml +++ /dev/null @@ -1,11 +0,0 @@ -type: object -properties: - data: - type: array - items: - $ref: './user.yml' - nextCursor: - type: string - 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. - nullable: true - example: MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA diff --git a/packages/cli/src/public-api/v1/handlers/users/users.handler.ee.ts b/packages/cli/src/public-api/v1/handlers/users/users.handler.ee.ts deleted file mode 100644 index 8ab3cf18a1a..00000000000 --- a/packages/cli/src/public-api/v1/handlers/users/users.handler.ee.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { InviteUsersRequestDto, RoleChangeRequestDto } from '@n8n/api-types'; -import { type AuthenticatedRequest } from '@n8n/db'; -import { Container } from '@n8n/di'; - -import { toPublicApiUser } from './users.mapper'; -import type { PublicAPIEndpoint } from '../../shared/handler.types'; -import { - apiKeyHasScopeWithGlobalScopeFallback, - isLicensed, - validCursor, - validLicenseWithUserQuota, -} from '../../shared/middlewares/global.middleware'; -import { encodeNextCursor } from '../../shared/services/pagination.service'; - -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { EventService } from '@/events/event.service'; -import type { UserRequest } from '@/requests'; -import { ProjectService } from '@/services/project.service.ee'; -import { UserService } from '@/services/user.service'; - -type Create = AuthenticatedRequest<{}, {}, InviteUsersRequestDto>; -type Delete = UserRequest.Delete; -type ChangeRole = AuthenticatedRequest<{ id: string }, {}, RoleChangeRequestDto, {}>; - -type UsersHandlers = { - getUser: PublicAPIEndpoint; - getUsers: PublicAPIEndpoint; - createUser: PublicAPIEndpoint; - deleteUser: PublicAPIEndpoint; - changeRole: PublicAPIEndpoint; -}; - -const usersHandlers: UsersHandlers = { - getUser: [ - validLicenseWithUserQuota, - apiKeyHasScopeWithGlobalScopeFallback({ scope: 'user:read' }), - async (req, res) => { - const { includeRole = false } = req.query; - const { id } = req.params; - - const user = await Container.get(UserService).getUser(id); - - if (!user) { - throw new NotFoundError(`Could not find user with id: ${id}`); - } - - Container.get(EventService).emit('user-retrieved-user', { - userId: req.user.id, - publicApi: true, - }); - - return res.json(toPublicApiUser(user, { includeRole })); - }, - ], - getUsers: [ - apiKeyHasScopeWithGlobalScopeFallback({ scope: 'user:list' }), - validLicenseWithUserQuota, - validCursor, - async (req, res) => { - const { offset = 0, limit = 100, includeRole = false, projectId } = req.query; - const userService = Container.get(UserService); - - await userService.assertGetUsersAccess(req.user, projectId); - - const userIdsInProject = projectId - ? await Container.get(ProjectService).findUserIdsByProjectId(projectId) - : undefined; - - const { users, count } = await userService.getUsersAndCount({ - ids: userIdsInProject, - limit, - offset, - }); - - Container.get(EventService).emit('user-retrieved-all-users', { - userId: req.user.id, - publicApi: true, - }); - - return res.json({ - data: toPublicApiUser(users, { includeRole }), - nextCursor: encodeNextCursor({ - offset, - limit, - numberOfTotalRecords: count, - }), - }); - }, - ], - createUser: [ - apiKeyHasScopeWithGlobalScopeFallback({ scope: 'user:create' }), - async (req, res) => { - const { data, error } = InviteUsersRequestDto.safeParse(req.body); - if (error) { - throw new BadRequestError(error.errors[0]?.message ?? 'Invalid request body'); - } - - const usersInvited = await Container.get(UserService).inviteUser(req.user, data); - return res.status(201).json(usersInvited); - }, - ], - deleteUser: [ - apiKeyHasScopeWithGlobalScopeFallback({ scope: 'user:delete' }), - async (req, res) => { - await Container.get(UserService).deleteUser(req.user, req.params.id, req.query.transferId); - - return res.status(204).send(); - }, - ], - changeRole: [ - isLicensed('feat:advancedPermissions'), - apiKeyHasScopeWithGlobalScopeFallback({ scope: 'user:changeRole' }), - async (req, res) => { - const validation = RoleChangeRequestDto.safeParse(req.body); - if (validation.error) { - throw new BadRequestError(validation.error.errors[0]?.message ?? 'Invalid request body'); - } - - await Container.get(UserService).changeGlobalRole(req.user, req.params.id, validation.data); - - return res.status(204).send(); - }, - ], -}; - -export = usersHandlers; diff --git a/packages/cli/src/public-api/v1/handlers/users/users.mapper.ts b/packages/cli/src/public-api/v1/handlers/users/users.mapper.ts deleted file mode 100644 index 4092a47ddce..00000000000 --- a/packages/cli/src/public-api/v1/handlers/users/users.mapper.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { User } from '@n8n/db'; -import pick from 'lodash/pick'; - -const userProperties = [ - 'id', - 'email', - 'firstName', - 'lastName', - 'createdAt', - 'updatedAt', - 'isPending', - 'mfaEnabled', -] as const; - -type PublicApiUser = Pick & { role?: string }; - -function mapToPublicApiUser(user: User, options?: { includeRole: boolean }): PublicApiUser { - const publicApiUser = pick(user, ...userProperties); - - return options?.includeRole ? { ...publicApiUser, role: user.role?.slug } : publicApiUser; -} - -export function toPublicApiUser(user: User, options?: { includeRole: boolean }): PublicApiUser; -export function toPublicApiUser(users: User[], options?: { includeRole: boolean }): PublicApiUser[]; -export function toPublicApiUser( - users: User[] | User, - options?: { includeRole: boolean }, -): PublicApiUser[] | PublicApiUser { - return Array.isArray(users) - ? users.map((user) => mapToPublicApiUser(user, options)) - : mapToPublicApiUser(users, options); -} diff --git a/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/createTestRun.generated.yml b/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/createTestRun.generated.yml new file mode 100644 index 00000000000..774ef72b049 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/createTestRun.generated.yml @@ -0,0 +1,52 @@ +operationId: createTestRun +tags: + - Evaluation +summary: Trigger a test run +description: Start a new evaluation test run for a workflow. The workflow must contain a configured evaluation trigger. Requires the `workflow:execute` project scope in addition to the `testRun:create` API key scope. +x-required-scope: testRun:create +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +parameters: + - schema: + type: string + required: true + description: The ID of the workflow. + name: workflowId + in: path +responses: + '201': + description: Operation successful. + content: + application/json: + schema: + type: object + properties: + id: + type: string + example: 9f8e7d6c5b4a3210 + status: + type: string + enum: + - new + - running + - completed + - error + - cancelled + createdAt: + type: string + format: date-time + required: + - id + - status + - createdAt + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '402': + $ref: ../../../../shared/spec/responses/paymentRequired.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '404': + $ref: ../../../../shared/spec/responses/notFound.yml + '409': + $ref: ../../../../shared/spec/responses/conflict.yml diff --git a/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getTestRuns.generated.yml b/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getTestRuns.generated.yml new file mode 100644 index 00000000000..da42197e3d1 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getTestRuns.generated.yml @@ -0,0 +1,119 @@ +operationId: getTestRuns +tags: + - Evaluation +summary: Retrieve test runs +description: Retrieve the evaluation test runs of a workflow. +x-required-scope: testRun:list +parameters: + - $ref: ../../../../shared/spec/parameters/limit.yml + - $ref: ../../../../shared/spec/parameters/cursor.yml + - schema: + type: string + required: true + description: The ID of the workflow. + name: workflowId + in: path + - schema: + type: string + enum: + - new + - running + - completed + - error + - cancelled + description: Status to filter the test runs by. + required: false + name: status + in: query +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +responses: + '200': + description: Operation successful. + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: object + properties: + id: + type: string + example: 9f8e7d6c5b4a3210 + status: + type: string + enum: + - new + - running + - completed + - error + - cancelled + runAt: + type: string + nullable: true + format: date-time + completedAt: + type: string + nullable: true + format: date-time + metrics: + type: object + description: Aggregated metrics collected across the run's test cases. + additionalProperties: true + nullable: true + errorCode: + type: string + nullable: true + errorDetails: + type: object + additionalProperties: true + nullable: true + finalResult: + type: string + nullable: true + enum: + - success + - error + - warning + description: Overall result of the run, derived from its test cases once completed. + testCaseCount: + type: integer + example: 42 + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + required: + - id + - status + - runAt + - completedAt + - metrics + - errorCode + - errorDetails + - finalResult + - testCaseCount + - createdAt + - updatedAt + nextCursor: + type: string + nullable: true + 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 + required: + - data + - nextCursor + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '404': + $ref: ../../../../shared/spec/responses/notFound.yml diff --git a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml index d4474bc5964..dd315d0db2b 100644 --- a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml +++ b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml @@ -24,6 +24,14 @@ paths: /credentials/schema/{credentialTypeName}: get: $ref: ./handlers/credentials/spec/paths/getCredentialType.generated.yml + /discover: + get: + $ref: ./handlers/discover/spec/paths/getDiscover.generated.yml + /workflows/{workflowId}/test-runs: + get: + $ref: ./handlers/workflows/spec/paths/getTestRuns.generated.yml + post: + $ref: ./handlers/workflows/spec/paths/createTestRun.generated.yml /executions: get: $ref: ./handlers/executions/spec/paths/getExecutions.generated.yml @@ -54,6 +62,8 @@ paths: $ref: ./handlers/projects/spec/paths/getFolder.generated.yml patch: $ref: ./handlers/projects/spec/paths/updateFolder.generated.yml + delete: + $ref: ./handlers/projects/spec/paths/deleteFolder.generated.yml /insights/summary: get: $ref: ./handlers/insights/spec/paths/getInsightsSummary.generated.yml @@ -207,6 +217,19 @@ paths: $ref: ./handlers/tags/spec/paths/updateTag.generated.yml delete: $ref: ./handlers/tags/spec/paths/deleteTag.generated.yml + /users: + get: + $ref: ./handlers/users/spec/paths/getUsers.generated.yml + post: + $ref: ./handlers/users/spec/paths/createUser.generated.yml + /users/{userId}: + get: + $ref: ./handlers/users/spec/paths/getUser.generated.yml + delete: + $ref: ./handlers/users/spec/paths/deleteUser.generated.yml + /users/{userId}/role: + patch: + $ref: ./handlers/users/spec/paths/changeRole.generated.yml /variables: get: $ref: ./handlers/variables/spec/paths/getVariables.generated.yml diff --git a/packages/cli/src/public-api/v1/openapi.yml b/packages/cli/src/public-api/v1/openapi.yml index 1700d395dcc..56ebf8ecdb0 100644 --- a/packages/cli/src/public-api/v1/openapi.yml +++ b/packages/cli/src/public-api/v1/openapi.yml @@ -95,20 +95,12 @@ paths: $ref: './handlers/sso-saml/spec/paths/settings.sso.saml.yml' /workflows/{id}/{versionId}: $ref: './handlers/workflows/spec/paths/workflows.id.versionId.yml' - /workflows/{id}/test-runs: - $ref: './handlers/evaluations/spec/paths/workflows.id.test-runs.yml' /workflows/{id}/test-runs/{runId}: $ref: './handlers/evaluations/spec/paths/workflows.id.test-runs.runId.yml' /workflows/{id}/test-runs/{runId}/cancel: $ref: './handlers/evaluations/spec/paths/workflows.id.test-runs.runId.cancel.yml' /workflows/{id}/test-runs/{runId}/test-cases: $ref: './handlers/evaluations/spec/paths/workflows.id.test-runs.runId.test-cases.yml' - /users: - $ref: './handlers/users/spec/paths/users.yml' - /users/{id}: - $ref: './handlers/users/spec/paths/users.id.yml' - /users/{id}/role: - $ref: './handlers/users/spec/paths/users.id.role.yml' /data-tables: $ref: './handlers/data-tables/spec/paths/data-tables.yml' /data-tables/{dataTableId}: @@ -131,12 +123,8 @@ paths: $ref: './handlers/community-packages/spec/paths/community-packages.yml' /community-packages/{name}: $ref: './handlers/community-packages/spec/paths/community-packages.packageName.yml' - /discover: - $ref: './handlers/discover/spec/paths/discover.yml' /projects/{projectId}/folders: $ref: './handlers/folders/spec/paths/folders.yml' - /projects/{projectId}/folders/{folderId}: - $ref: './handlers/folders/spec/paths/folders.folderId.yml' /n8n-packages/export: $ref: './handlers/n8n-packages/spec/paths/n8n-packages.export.yml' /n8n-packages/import: diff --git a/packages/cli/src/public-api/v1/shared/spec/parameters/_index.yml b/packages/cli/src/public-api/v1/shared/spec/parameters/_index.yml index b84832bb163..cf32c6338c0 100644 --- a/packages/cli/src/public-api/v1/shared/spec/parameters/_index.yml +++ b/packages/cli/src/public-api/v1/shared/spec/parameters/_index.yml @@ -10,10 +10,6 @@ TestRunWorkflowId: $ref: '../../../handlers/evaluations/spec/schemas/parameters/testRunWorkflowId.yml' TestRunId: $ref: '../../../handlers/evaluations/spec/schemas/parameters/testRunId.yml' -UserIdentifier: - $ref: '../../../handlers/users/spec/schemas/parameters/userIdentifier.yml' -IncludeRole: - $ref: '../../../handlers/users/spec/schemas/parameters/includeRole.yml' dataTableId: $ref: '../../../handlers/data-tables/spec/schemas/parameters/dataTableId.yml' columnId: diff --git a/packages/cli/src/public-api/v1/shared/spec/schemas/_index.yml b/packages/cli/src/public-api/v1/shared/spec/schemas/_index.yml index 1dded081d8a..151d64476bf 100644 --- a/packages/cli/src/public-api/v1/shared/spec/schemas/_index.yml +++ b/packages/cli/src/public-api/v1/shared/spec/schemas/_index.yml @@ -1,7 +1,5 @@ Error: $ref: './error.yml' -Role: - $ref: './../../../handlers/users/spec/schemas/role.yml' Node: $ref: './../../../handlers/workflows/spec/schemas/node.yml' WorkflowNodeGroup: @@ -16,7 +14,3 @@ WorkflowList: $ref: './../../../handlers/workflows/spec/schemas/workflowList.yml' Audit: $ref: './../../../handlers/audit/spec/schemas/audit.yml' -UserList: - $ref: './../../../handlers/users/spec/schemas/userList.yml' -User: - $ref: './../../../handlers/users/spec/schemas/user.yml' diff --git a/packages/cli/src/public-api/v1/shared/spec/schemas/applyPackageResultDto.generated.yml b/packages/cli/src/public-api/v1/shared/spec/schemas/applyPackageResultDto.generated.yml index c6bbc6541cf..6f2dbcdc31c 100644 --- a/packages/cli/src/public-api/v1/shared/spec/schemas/applyPackageResultDto.generated.yml +++ b/packages/cli/src/public-api/v1/shared/spec/schemas/applyPackageResultDto.generated.yml @@ -290,6 +290,47 @@ oneOf: preflight: type: object properties: + missingProjects: + type: array + items: + type: object + properties: + id: + type: string + minLength: 1 + name: + type: string + icon: + type: object + properties: + type: + type: string + enum: + - emoji + - icon + value: + type: string + required: + - type + - value + description: + type: string + customTelemetryTags: + type: array + items: + type: object + properties: + key: + type: string + value: + type: string + required: + - key + - value + required: + - id + - name + 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: type: array items: @@ -1109,6 +1150,7 @@ oneOf: - scope - consumers required: + - missingProjects - missingBindings - accessRequirements - conflicts diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 8bccef7700b..7e164809854 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -149,13 +149,6 @@ export declare namespace UserRequest { { transferId?: string; includeRole: boolean } >; - export type Get = AuthenticatedRequest< - { id: string; email: string; identifier: string }, - {}, - {}, - { limit?: number; offset?: number; cursor?: string; includeRole?: boolean; projectId?: string } - >; - export type PasswordResetLink = AuthenticatedRequest<{ id: string }, {}, {}, {}>; } diff --git a/packages/cli/src/scaling/__tests__/job-processor.service.test.ts b/packages/cli/src/scaling/__tests__/job-processor.service.test.ts index 91035088d83..a9bdbdcb8d3 100644 --- a/packages/cli/src/scaling/__tests__/job-processor.service.test.ts +++ b/packages/cli/src/scaling/__tests__/job-processor.service.test.ts @@ -9,7 +9,7 @@ import type { BinaryDataService, InstanceSettings, } from 'n8n-core'; -import { ExternalSecretsProxy, StructuredToolkit } from 'n8n-core'; +import { ENCODED_BUFFER_KEY, ExternalSecretsProxy, StructuredToolkit } from 'n8n-core'; import { mockInstance } from 'n8n-core/test/utils'; import { type IPinData, @@ -52,7 +52,7 @@ import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.serv import { JobProcessor } from '../job-processor'; import type { Job } from '../scaling.types'; -import { ENCODED_BUFFER_KEY, WebhookResponseRelay } from '../webhook-response-relay'; +import { WebhookResponseRelay } from '../webhook-response-relay'; mockInstance(WorkflowPublishHistoryRepository); mockInstance(VariablesService, { diff --git a/packages/cli/src/scaling/__tests__/scaling.service.test.ts b/packages/cli/src/scaling/__tests__/scaling.service.test.ts index c6f520b396f..4f44c0234a5 100644 --- a/packages/cli/src/scaling/__tests__/scaling.service.test.ts +++ b/packages/cli/src/scaling/__tests__/scaling.service.test.ts @@ -4,7 +4,7 @@ import { GlobalConfig, WorkerPoolConfig } from '@n8n/config'; import type { ExecutionRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import * as BullModule from 'bull'; -import { InstanceSettings } from 'n8n-core'; +import { ENCODED_BUFFER_KEY, InstanceSettings } from 'n8n-core'; import type { ErrorReporter } from 'n8n-core'; import { UnexpectedError } from 'n8n-workflow'; import type { MockInstance } from 'vitest'; @@ -18,7 +18,7 @@ import { JOB_TYPE_NAME } from '../constants'; import type { JobProcessor } from '../job-processor'; import { ScalingService } from '../scaling.service'; import type { Job, JobData, JobId, JobQueue } from '../scaling.types'; -import { ENCODED_BUFFER_KEY, type WebhookResponseRelay } from '../webhook-response-relay'; +import type { WebhookResponseRelay } from '../webhook-response-relay'; const queue = mock({ client: { ping: vi.fn() }, @@ -898,7 +898,7 @@ describe('ScalingService', () => { await scalingService.recoverFromQueue(); - expect(executionCrashService.markAsCrashed).toHaveBeenCalledWith(['123']); + expect(executionCrashService.markAsCrashed).toHaveBeenCalledWith(['123'], 'queue-recovery'); }); it('should mark running executions as crashed if they are missing from the queue and queue is not empty', async () => { @@ -908,7 +908,7 @@ describe('ScalingService', () => { await scalingService.recoverFromQueue(); - expect(executionCrashService.markAsCrashed).toHaveBeenCalledWith(['123']); + expect(executionCrashService.markAsCrashed).toHaveBeenCalledWith(['123'], 'queue-recovery'); }); it('should not mark running executions as crashed if they are present in the queue', async () => { diff --git a/packages/cli/src/scaling/__tests__/webhook-response-relay.test.ts b/packages/cli/src/scaling/__tests__/webhook-response-relay.test.ts index f32d3253547..a82a559243c 100644 --- a/packages/cli/src/scaling/__tests__/webhook-response-relay.test.ts +++ b/packages/cli/src/scaling/__tests__/webhook-response-relay.test.ts @@ -1,6 +1,6 @@ import type { Logger } from '@n8n/backend-common'; import type { ExecutionsConfig } from '@n8n/config'; -import { FileLocation, FileTooLargeError } from 'n8n-core'; +import { decodeBufferBody, ENCODED_BUFFER_KEY, FileLocation, FileTooLargeError } from 'n8n-core'; import type { BinaryDataConfig, BinaryDataService } from 'n8n-core'; import { OperationalError } from 'n8n-workflow'; import type { IBinaryData, IExecuteResponsePromiseData } from 'n8n-workflow'; @@ -9,12 +9,7 @@ import { mock } from 'vitest-mock-extended'; import { WebhookResponseTooLargeError } from '@/errors/webhook-response-too-large.error'; -import { - decodeRelayedWebhookResponse, - ENCODED_BUFFER_KEY, - OFFLOADED_BODY_KIND_KEY, - WebhookResponseRelay, -} from '../webhook-response-relay'; +import { OFFLOADED_BODY_KIND_KEY, WebhookResponseRelay } from '../webhook-response-relay'; const ONE_MIB = 1024 * 1024; const SIZE_MAX_IN_MIB = 2; @@ -751,11 +746,11 @@ describe('WebhookResponseRelay', () => { }); }); -describe('decodeRelayedWebhookResponse', () => { +describe('decodeBufferBody on a relayed response', () => { it('restores a base64 envelope to a Buffer', () => { const response = fullResponse({ [ENCODED_BUFFER_KEY]: 'aGVsbG8=' }); - const decoded = decodeRelayedWebhookResponse(response); + const decoded = decodeBufferBody(response); expect(decoded).toBe(response); expect(bodyOf(decoded)).toEqual(Buffer.from('hello')); @@ -765,7 +760,7 @@ describe('decodeRelayedWebhookResponse', () => { const { relay } = buildRelay(); const body = Buffer.from([0x00, 0xff, 0x10]); - const decoded = decodeRelayedWebhookResponse(await relay.prepare(fullResponse(body), ctx)); + const decoded = decodeBufferBody(await relay.prepare(fullResponse(body), ctx)); expect(bodyOf(decoded)).toEqual(body); }); @@ -780,7 +775,7 @@ describe('decodeRelayedWebhookResponse', () => { ], ['an envelope whose payload is not a string', { [ENCODED_BUFFER_KEY]: 42 }], ])('leaves %s untouched', (_label, body) => { - const decoded = decodeRelayedWebhookResponse(fullResponse(body)); + const decoded = decodeBufferBody(fullResponse(body)); expect(bodyOf(decoded)).toEqual(body); }); @@ -788,6 +783,6 @@ describe('decodeRelayedWebhookResponse', () => { it('leaves a payload that is not a full response untouched', () => { const payload = { toolResult: 'done' }; - expect(decodeRelayedWebhookResponse(payload)).toBe(payload); + expect(decodeBufferBody(payload)).toBe(payload); }); }); diff --git a/packages/cli/src/scaling/scaling.service.ts b/packages/cli/src/scaling/scaling.service.ts index b089355e7ae..04e9cf33670 100644 --- a/packages/cli/src/scaling/scaling.service.ts +++ b/packages/cli/src/scaling/scaling.service.ts @@ -4,7 +4,7 @@ import { Time } from '@n8n/constants'; import { ExecutionRepository } from '@n8n/db'; import { OnLeaderStepdown, OnLeaderTakeover, OnShutdown } from '@n8n/decorators'; import { Container, Service } from '@n8n/di'; -import { ErrorReporter, InstanceSettings } from 'n8n-core'; +import { decodeBufferBody, ErrorReporter, InstanceSettings } from 'n8n-core'; import { ensureError } from '@n8n/utils/errors/ensure-error'; import { sleep } from '@n8n/utils/sleep'; import { jsonStringify, UnexpectedError } from 'n8n-workflow'; @@ -33,7 +33,7 @@ import type { JobMessage, JobFailedMessage, } from './scaling.types'; -import { decodeRelayedWebhookResponse, WebhookResponseRelay } from './webhook-response-relay'; +import { WebhookResponseRelay } from './webhook-response-relay'; const DRAIN_POLL_INTERVAL_MS = 500; @@ -502,7 +502,7 @@ export class ScalingService { this.activeExecutions.sendChunk(msg.executionId, msg.chunkText); break; case 'respond-to-webhook': { - const decodedResponse = decodeRelayedWebhookResponse(msg.response); + const decodedResponse = decodeBufferBody(msg.response); this.activeExecutions.resolveResponsePromise(msg.executionId, decodedResponse); break; } @@ -642,7 +642,7 @@ export class ScalingService { // it. So the stored body is left in place, and execution pruning // reclaims it. Restoring under this guard only spares the mains that // would discard the body a read of the whole thing. - const decoded = decodeRelayedWebhookResponse(response); + const decoded = decodeBufferBody(response); const toolResult = await this.webhookResponseRelay.restoreOffloadedBody(decoded, { reclaim: false, context: { executionId }, @@ -780,7 +780,7 @@ export class ScalingService { return waitMs; } - await this.executionCrashService.markAsCrashed(danglingIds); + await this.executionCrashService.markAsCrashed(danglingIds, 'queue-recovery'); this.logger.info('Completed queue recovery check, recovered dangling executions', { danglingIds, diff --git a/packages/cli/src/scaling/webhook-response-relay.ts b/packages/cli/src/scaling/webhook-response-relay.ts index a9c929091ee..1c9443385a7 100644 --- a/packages/cli/src/scaling/webhook-response-relay.ts +++ b/packages/cli/src/scaling/webhook-response-relay.ts @@ -13,9 +13,15 @@ import { Logger } from '@n8n/backend-common'; import { ExecutionsConfig } from '@n8n/config'; import { Service } from '@n8n/di'; import { jsonSizeExceeds } from '@n8n/utils/json/json-size-exceeds'; -import { BinaryDataConfig, BinaryDataService, FileLocation, FileTooLargeError } from 'n8n-core'; +import { + BinaryDataConfig, + BinaryDataService, + encodeBufferBody, + FileLocation, + FileTooLargeError, +} from 'n8n-core'; import type { BinaryData } from 'n8n-core'; -import { BINARY_ENCODING, jsonParse, OperationalError } from 'n8n-workflow'; +import { jsonParse, OperationalError } from 'n8n-workflow'; import type { IBinaryData, IDataObject, @@ -28,9 +34,6 @@ import { WebhookResponseTooLargeError } from '@/errors/webhook-response-too-larg const MIB = 1024 * 1024; -/** Sentinel key marking a base64-encoded Buffer body relayed inline through the queue. */ -export const ENCODED_BUFFER_KEY = '__@N8nEncodedBuffer@__'; - /** * Sentinel key recording an offloaded body's original form, so it can be restored. * It sits on the relayed response, beside `body`, never inside it: @@ -405,27 +408,6 @@ export class WebhookResponseRelay { } } -/** - * Reverses the inline base64 envelope on main, restoring a Buffer body. Every - * other body passes through, an offloaded one included: main streams that from - * storage rather than materializing it. - * - * @param response Relayed response. Mutated and returned. - * @returns The same `response`. - */ -export function decodeRelayedWebhookResponse(response: T): T { - if (!hasResponseBody(response)) { - return response; - } - - const encoded = encodedBufferIn(response.body); - if (encoded !== undefined) { - response.body = Buffer.from(encoded, BINARY_ENCODING); - } - - return response; -} - function withInlineLimit(guidance: string, limitInMib: number): string { return `The limit is ${limitInMib} MiB. ${guidance}`; } @@ -443,14 +425,6 @@ function base64Size(byteLength: number): number { return Math.ceil(byteLength / 3) * 4; } -function encodeBufferBody(response: IN8nHttpFullResponse): IN8nHttpFullResponse { - if (Buffer.isBuffer(response.body)) { - response.body = { [ENCODED_BUFFER_KEY]: response.body.toString(BINARY_ENCODING) }; - } - - return response; -} - /** * Whether `payload` would serialize to more than `maxBytes` inside a queue * message. Everything is measured, offloadable or not — a binary-data-shaped @@ -548,16 +522,6 @@ function hasResponseBody(response: unknown): response is IN8nHttpFullResponse { return typeof response === 'object' && response !== null && 'body' in response; } -/** The base64 payload of an {@link ENCODED_BUFFER_KEY} envelope, if the body is one. */ -function encodedBufferIn(body: IN8nHttpFullResponse['body']): string | undefined { - if (typeof body !== 'object' || body === null || !(ENCODED_BUFFER_KEY in body)) { - return undefined; - } - - const encoded = body[ENCODED_BUFFER_KEY]; - return typeof encoded === 'string' ? encoded : undefined; -} - function isPlainJson(payload: unknown): payload is IDataObject { return ( typeof payload === 'object' && diff --git a/packages/cli/src/services/project.service.ee.ts b/packages/cli/src/services/project.service.ee.ts index 7c59c922009..6742814ab39 100644 --- a/packages/cli/src/services/project.service.ee.ts +++ b/packages/cli/src/services/project.service.ee.ts @@ -1,6 +1,5 @@ import type { CreateProjectDto, ProjectType, UpdateProjectDto } from '@n8n/api-types'; import { LicenseState, Logger, ModuleRegistry } from '@n8n/backend-common'; -import { UNLIMITED_LICENSE_QUOTA } from '@n8n/constants'; import { type User, FolderRepository, @@ -8,6 +7,7 @@ import { ProjectRelation, ProjectRelationRepository, ProjectRepository, + ProjectIdConflictError, SharedCredentialsRepository, SharedWorkflowRepository, type ProjectListOptions, @@ -30,6 +30,7 @@ import { In } from '@n8n/typeorm'; import { UserError } from 'n8n-workflow'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { ConflictError } from '@/errors/response-errors/conflict.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { EventService } from '@/events/event.service'; @@ -480,49 +481,30 @@ export class ProjectService { return results; } - private async createTeamProjectWithEntityManager( - adminUser: User, - data: CreateProjectDto, - trx: EntityManager, - overrides: ProjectCreateOverrides = {}, - ) { - const limit = this.licenseState.getMaxTeamProjects(); - if (limit !== UNLIMITED_LICENSE_QUOTA) { - const teamProjectCount = await trx.count(Project, { where: { type: 'team' } }); - if (teamProjectCount >= limit) { - throw new TeamProjectOverQuotaError(limit); - } - } - - const project = await trx.save( - Project, - this.projectRepository.create({ - ...data, - ...overrides, - type: 'team', - creatorId: adminUser.id, - }), - ); - - // Link admin - await this.addUser(project.id, { userId: adminUser.id, role: 'project:admin' }, trx); - - return project; - } - async createTeamProject( adminUser: User, data: CreateProjectDto, overrides: ProjectCreateOverrides = {}, ): Promise { - // This needs to be SERIALIZABLE otherwise the count would not block a - // concurrent transaction and we could insert multiple projects. - const project = await this.projectRepository.manager.transaction( - 'SERIALIZABLE', - async (trx) => { - return await this.createTeamProjectWithEntityManager(adminUser, data, trx, overrides); - }, - ); + const limit = this.licenseState.getMaxTeamProjects(); + let project: Project | null; + try { + project = await this.projectRepository.insertTeamProjectWithAdmin( + { + name: data.name, + icon: data.icon ?? null, + id: overrides.id, + description: overrides.description ?? null, + customTelemetryTags: overrides.customTelemetryTags ?? [], + }, + adminUser.id, + limit, + ); + } catch (error) { + if (error instanceof ProjectIdConflictError) throw new ConflictError(error.message); + throw error; + } + if (!project) throw new TeamProjectOverQuotaError(limit); this.eventService.emit('team-project-created', { userId: adminUser.id, @@ -537,14 +519,17 @@ export class ProjectService { user: User, projectId: string, { name, icon, description, customTelemetryTags }: UpdateProjectDto, + { preserveCustomTelemetryTags = false }: { preserveCustomTelemetryTags?: boolean } = {}, ): Promise { - const trimmedTags = customTelemetryTags - ?.map(({ key, value }) => ({ key: key.trim(), value })) - .filter(({ key }) => key !== ''); + const tags = preserveCustomTelemetryTags + ? customTelemetryTags + : customTelemetryTags + ?.map(({ key, value }) => ({ key: key.trim(), value })) + .filter(({ key }) => key !== ''); const result = await this.projectRepository.update( { id: projectId, type: 'team' }, - { name, icon, description, customTelemetryTags: trimmedTags }, + { name, icon, description, customTelemetryTags: tags }, ); if (!result.affected) { throw new ProjectNotFoundError(projectId); diff --git a/packages/cli/src/workflow-runner.ts b/packages/cli/src/workflow-runner.ts index aec182cb805..9a5e51f9028 100644 --- a/packages/cli/src/workflow-runner.ts +++ b/packages/cli/src/workflow-runner.ts @@ -52,6 +52,7 @@ import { getLifecycleHooksForScalingMain, } from '@/execution-lifecycle/execution-lifecycle-hooks'; import { toSaveSettings } from '@/execution-lifecycle/to-save-settings'; +import { ExecutionCrashService } from '@/executions/execution-crash.service'; import { ExecutionPersistence } from '@/executions/execution-persistence'; import { FailedRunFactory } from '@/executions/failed-run-factory'; import { @@ -129,6 +130,7 @@ export class WorkflowRunner { private readonly externalHooks: ExternalHooks, private readonly engineV2Dispatcher: EngineV2Dispatcher, private readonly workflowPreExecute: WorkflowPreExecute, + private readonly executionCrashService: ExecutionCrashService, ) {} /** The process did error */ @@ -285,11 +287,23 @@ export class WorkflowRunner { this.logger.error(`Problem with execution ${executionId}: ${error.message}. Aborting.`); this.errorReporter.error(error, { executionId }); + if (error instanceof MaxStalledCountError) { + const claimed = await this.executionCrashService.markAsCrashedWithoutCounting( + executionId, + 'stall', + ); + if (claimed.length === 0) { + this.activeExecutions.finalizeExecution(executionId); + return; + } + } + const fullRunData: IRun = { data: createRunExecutionData({ resultData: { error: { ...error, + name: error.constructor.name, message: error.message, stack: error.stack, }, @@ -300,7 +314,7 @@ export class WorkflowRunner { mode: executionMode, startedAt, stoppedAt: new Date(), - status: 'error', + status: error instanceof MaxStalledCountError ? 'crashed' : 'error', storedAt: this.storageConfig.modeTag, }; diff --git a/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts b/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts index 440a203ad38..6cab671bf0f 100644 --- a/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts +++ b/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts @@ -405,7 +405,7 @@ describe('WorkflowExecutionService', () => { const returned = await runPolledWorkflow(); expect(returned).toBe('exec-9'); - expect(executionCrashService.markAsCrashed).toHaveBeenCalledWith('exec-9'); + expect(executionCrashService.markAsCrashed).toHaveBeenCalledWith('exec-9', 'start-failure'); expect(responsePromise.reject).toHaveBeenCalledWith(runError); expect(errorReporter.error).toHaveBeenCalledWith(runError, expect.anything()); }); diff --git a/packages/cli/src/workflows/workflow-execution.service.ts b/packages/cli/src/workflows/workflow-execution.service.ts index 0802fd98dfc..cc695e0ad32 100644 --- a/packages/cli/src/workflows/workflow-execution.service.ts +++ b/packages/cli/src/workflows/workflow-execution.service.ts @@ -326,7 +326,7 @@ export class WorkflowExecutionService { responsePromise?.reject(ensureError(error)); - await this.executionCrashService.markAsCrashed(executionId); + await this.executionCrashService.markAsCrashed(executionId, 'start-failure'); } private isDestinationNodeATrigger(destinationNode: string, workflow: IWorkflowBase) { diff --git a/packages/cli/test/integration/agents/agent-checkpoint-pruning.task.integration.test.ts b/packages/cli/test/integration/agents/agent-checkpoint-pruning.task.integration.test.ts new file mode 100644 index 00000000000..e48b692b136 --- /dev/null +++ b/packages/cli/test/integration/agents/agent-checkpoint-pruning.task.integration.test.ts @@ -0,0 +1,197 @@ +import type { SerializableAgentState } from '@n8n/agents'; +import type { Logger } from '@n8n/backend-common'; +import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils'; +import { AgentsConfig } from '@n8n/config'; +import { Time } from '@n8n/constants'; +import { DbConnectionOptions } from '@n8n/db'; +import { Container } from '@n8n/di'; +import type { QueryRunner } from '@n8n/typeorm'; +import { DataSource, IsNull, Not } from '@n8n/typeorm'; +import { sleep } from '@n8n/utils/sleep'; +import { v4 as uuid } from 'uuid'; +import { mock } from 'vitest-mock-extended'; + +import { AgentCheckpointPruningTask } from '@/modules/agents/agent-checkpoint-pruning.task'; +import type { Agent } from '@/modules/agents/entities/agent.entity'; +import { N8NCheckpointStorage } from '@/modules/agents/integrations/n8n-checkpoint-storage'; +import { AgentCheckpointRepository } from '@/modules/agents/repositories/agent-checkpoint.repository'; +import { AgentRepository } from '@/modules/agents/repositories/agent.repository'; + +const suspendedState: SerializableAgentState = { + status: 'suspended', + persistence: { threadId: 'thread-1', resourceId: 'resource-1' }, + messageList: { messages: [], historyIds: [], inputIds: [], responseIds: [] }, + pendingToolCalls: {}, +}; +const suspendedJson = JSON.stringify(suspendedState); +const runningJson = JSON.stringify({ ...suspendedState, status: 'running' }); + +const RESUMED = { expired: false, state: runningJson }; +const PRUNED = { expired: true, state: null }; + +const isPostgres = process.env.DB_TYPE === 'postgresdb'; + +describe('AgentCheckpointPruningTask', () => { + const logger = mock({ scoped: vi.fn().mockReturnThis() }); + let agentRepository: AgentRepository; + let checkpointRepository: AgentCheckpointRepository; + let config: AgentsConfig; + let storage: N8NCheckpointStorage; + let task: AgentCheckpointPruningTask; + let agentId: string; + let stale: Date; + + beforeAll(async () => { + await testModules.loadModules(['agents']); + await testDb.init(); + agentRepository = Container.get(AgentRepository); + checkpointRepository = Container.get(AgentCheckpointRepository); + config = Container.get(AgentsConfig); + storage = new N8NCheckpointStorage(checkpointRepository, logger, config); + task = new AgentCheckpointPruningTask(storage); + stale = new Date(Date.now() - (config.checkpointTtlSeconds + Time.hours.toSeconds) * 1000); + }); + + beforeEach(async () => { + logger.info.mockClear(); + const project = await createTeamProject(); + const agent = agentRepository.create({ + id: uuid(), + name: 'Test Agent', + projectId: project.id, + schema: { name: 'Test Agent', model: 'm', instructions: 'i' }, + integrations: [], + tools: {}, + skills: {}, + versionId: 'version-1', + activeVersionId: null, + } as Partial); + agentId = (await agentRepository.save(agent)).id; + }); + + afterEach(async () => { + await checkpointRepository.delete({}); + await agentRepository.delete({}); + }); + + afterAll(async () => { + await testDb.terminate(); + }); + + async function insertCheckpoints( + prefix: string, + count: number, + updatedAt: Date, + ): Promise { + const runIds = Array.from({ length: count }, (_, i) => `${prefix}-${i}`); + for (let start = 0; start < runIds.length; start += 500) { + await checkpointRepository.insert( + runIds.slice(start, start + 500).map((runId) => ({ + runId, + agentId, + state: suspendedJson, + expired: false, + createdAt: updatedAt, + updatedAt, + })), + ); + } + return runIds; + } + + async function counts(): Promise<{ expired: number; open: number }> { + return { + expired: await checkpointRepository.count({ where: { expired: true, state: IsNull() } }), + open: await checkpointRepository.count({ where: { expired: false, state: Not(IsNull()) } }), + }; + } + + function expiredTotal(): number { + return logger.info.mock.calls + .filter(([message]) => message === 'Marked stale agent checkpoints as expired') + .reduce((sum, [, meta]) => sum + (meta as { count: number }).count, 0); + } + + it('should expire each stale checkpoint once across repeated runs', async () => { + // Arrange: 3000 checkpoints past the TTL and 200 inside it. + await insertCheckpoints('stale', 3000, stale); + await insertCheckpoints('fresh', 200, new Date()); + + // Act: four prune runs at once. The pool may serialize them, so this is + // the idempotency check; the two-connection tests below cover the race. + await Promise.all(Array.from({ length: 4 }, async () => await task.run())); + + // Assert: the stale rows are expired, the fresh rows are untouched, and the + // logged counts sum to the stale rows, so no run expired a row twice. + expect(await counts()).toEqual({ expired: 3000, open: 200 }); + expect(expiredTotal()).toBe(3000); + }); + + // A second connection holds one side of the race open in a transaction, so the + // other side has to wait on the row lock like it would across two mains. + describe.skipIf(!isPostgres)('across two connections', () => { + let otherConnection: DataSource; + let otherRunner: QueryRunner; + let otherStorage: N8NCheckpointStorage; + + beforeAll(async () => { + otherConnection = new DataSource(Container.get(DbConnectionOptions).getOptions()); + await otherConnection.initialize(); + }); + + beforeEach(async () => { + otherRunner = otherConnection.createQueryRunner(); + await otherRunner.startTransaction(); + const repository = new AgentCheckpointRepository({ + manager: otherRunner.manager, + } as DataSource); + otherStorage = new N8NCheckpointStorage(repository, logger, config); + }); + + afterEach(async () => { + if (otherRunner.isTransactionActive) await otherRunner.rollbackTransaction(); + await otherRunner.release(); + }); + + afterAll(async () => { + await otherConnection.destroy(); + }); + + async function isPending(promise: Promise): Promise { + return await Promise.race([promise.then(() => false), sleep(250).then(() => true)]); + } + + it('should block a resume behind an uncommitted prune and then reject it', async () => { + // Arrange: the other connection prunes the row but does not commit. + const [runId] = await insertCheckpoints('stale', 1, stale); + await new AgentCheckpointPruningTask(otherStorage).run(); + + // Act: a resume on this connection waits on the row lock until the commit. + const claim = storage.claimForResume(runId, suspendedState, agentId); + expect(await isPending(claim)).toBe(true); + await otherRunner.commitTransaction(); + + // Assert: the resume sees the expired row and loses. + expect(await claim).toBe(false); + expect(await checkpointRepository.findByRunId(runId)).toMatchObject(PRUNED); + }); + + it('should block a prune behind an uncommitted resume and then skip the resumed row', async () => { + // Arrange: the other connection resumes one of two stale rows but does not commit. + const [resumed, untouched] = await insertCheckpoints('stale', 2, stale); + expect(await otherStorage.claimForResume(resumed, suspendedState, agentId)).toBe(true); + + // Act: a prune on this connection waits on the row lock until the commit. + const prune = task.run(); + expect(await isPending(prune)).toBe(true); + await otherRunner.commitTransaction(); + await prune; + + // Assert: the prune re-reads the row after the lock, sees the fresh + // `updatedAt`, and expires only the other row. + expect(expiredTotal()).toBe(1); + expect(await checkpointRepository.findByRunId(resumed)).toMatchObject(RESUMED); + expect(await checkpointRepository.findByRunId(untouched)).toMatchObject(PRUNED); + }); + }); +}); diff --git a/packages/cli/test/integration/database/repositories/agent-execution.repository.test.ts b/packages/cli/test/integration/database/repositories/agent-execution.repository.test.ts index 58bb1da2fbf..803cf15ea07 100644 --- a/packages/cli/test/integration/database/repositories/agent-execution.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/agent-execution.repository.test.ts @@ -11,7 +11,7 @@ import { } from '@n8n/agents'; import { createTeamProject, mockLogger, testDb, testModules } from '@n8n/backend-test-utils'; import { AgentsConfig, AiConfig } from '@n8n/config'; -import { TransactionRunner, type User } from '@n8n/db'; +import type { OperationContext, User } from '@n8n/db'; import { Container } from '@n8n/di'; import { DataSource, EntityManager } from '@n8n/typeorm'; import { generateNanoId } from '@n8n/utils/generate-nano-id'; @@ -20,6 +20,7 @@ import { convertArrayToReadableStream, MockLanguageModelV3 } from 'ai/test'; import chunk from 'lodash/chunk'; import type { ErrorReporter, StorageConfig } from 'n8n-core'; import { jsonParse } from 'n8n-workflow'; +import { createRequire } from 'node:module'; import { v4 as uuid } from 'uuid'; import { mock } from 'vitest-mock-extended'; import { z } from 'zod'; @@ -59,11 +60,17 @@ import { AgentRepository } from '@/modules/agents/repositories/agent.repository' import { createMember, createAdmin } from '../../shared/db/users'; +// Share the transaction class loaded by the built BaseRepository. +const { TypeOrmTransaction, TypeOrmTransactionRunner } = createRequire(__filename)( + '@n8n/db/dist/services/typeorm-transaction', +) as typeof import('@n8n/db/dist/services/typeorm-transaction'); + describe('AgentExecutionRepository', () => { let repository: AgentExecutionRepository; let threadRepo: AgentExecutionThreadRepository; let agentRepo: AgentRepository; let attachmentRepo: AgentChatAttachmentRepository; + let peer: DataSource; let projectId: string; let agentId: string; @@ -74,6 +81,14 @@ describe('AgentExecutionRepository', () => { threadRepo = Container.get(AgentExecutionThreadRepository); agentRepo = Container.get(AgentRepository); attachmentRepo = Container.get(AgentChatAttachmentRepository); + peer = await new DataSource({ + ...repository.manager.connection.options, + synchronize: false, + migrationsRun: false, + dropSchema: false, + logger: 'simple-console', + logging: false, + }).initialize(); }); beforeEach(async () => { @@ -99,18 +114,30 @@ describe('AgentExecutionRepository', () => { }); afterAll(async () => { + if (peer?.isInitialized) await peer.destroy(); await testDb.terminate(); }); - function recordingServices(memoryBackend: ReturnType = mock()) { + function recordingServices( + memoryBackend: ReturnType = mock(), + connection?: DataSource, + ) { + const txRunner = new TypeOrmTransactionRunner( + connection ?? repository.manager.connection, + mockLogger(), + ); + const executions = connection ? new AgentExecutionRepository(connection, txRunner) : repository; + const threads = connection + ? new AgentExecutionThreadRepository(connection, txRunner) + : threadRepo; const memory = mock(); memory.getImplementation.mockReturnValue(memoryBackend); const attachmentService = mock(); const executionLogStore = mock(); const executionService = new AgentExecutionService( mockLogger(), - repository, - threadRepo, + executions, + threads, memory, mock(), attachmentService, @@ -119,9 +146,11 @@ describe('AgentExecutionRepository', () => { mock(), mock(), Container.get(N8NCheckpointStorage), - Container.get(TransactionRunner), + txRunner, ); return { + txRunner, + threads, executionService, attachmentService, executionLogStore, @@ -133,6 +162,31 @@ describe('AgentExecutionRepository', () => { }; } + function observePeerTransaction() { + const started = createDeferredPromise(); + const querySpy = vi.spyOn(peer.logger, 'logQuery').mockImplementation((query) => { + if (query === 'START TRANSACTION' || query === 'BEGIN IMMEDIATE TRANSACTION') { + started.resolve(); + } + }); + return { started: started.promise, restore: () => querySpy.mockRestore() }; + } + + async function waitForPeerLock(ctx: OperationContext) { + if (peer.options.type !== 'postgres') return; + const transaction = ctx.trx; + if (!(transaction instanceof TypeOrmTransaction)) throw new Error('Expected a transaction'); + await vi.waitFor(async () => { + const rows = await transaction.getEntityManager().query>( + `SELECT EXISTS ( + SELECT 1 FROM pg_stat_activity + WHERE pg_backend_pid() = ANY(pg_blocking_pids(pid)) + ) AS blocked`, + ); + expect(rows).toEqual([{ blocked: true }]); + }); + } + function buildAttachment(threadId: string) { return attachmentRepo.create({ id: generateNanoId(), @@ -386,7 +440,7 @@ describe('AgentExecutionRepository', () => { toolCallId: suspension.toolCallId, onResumeClaimed, }, - recording: { ...recording, userMessage: null }, + recording: { ...recording, userMessage: null, sessionMode: 'existing' }, }), }), ); @@ -574,6 +628,188 @@ describe('AgentExecutionRepository', () => { expect(executionLogStore.delete).not.toHaveBeenCalled(); }); + it('keeps session data when admission wins deletion on another connection', async () => { + const thread = await createThread(); + const attachment = await attachmentRepo.save(buildAttachment(thread.id)); + const checkpointRepo = Container.get(AgentCheckpointRepository); + const checkpoint = await checkpointRepo.save({ + runId: uuid(), + agentId, + threadId: thread.id, + state: '{}', + expired: false, + }); + const { turns } = recordingServices(); + const memoryBackend = mock>(); + const { executionService, attachmentService, executionLogStore } = recordingServices( + memoryBackend, + peer, + ); + const params = { + access: { accessScope: 'project' as const, ownerId: null }, + threadId: thread.id, + agentId, + agentName: 'Test Agent', + projectId, + userMessage: 'Continue', + sessionMode: 'existing' as const, + }; + const recorder = new ExecutionRecorder(); + const admitted = createDeferredPromise(); + const releaseAdmission = createDeferredPromise(); + const saveInContext = repository.saveInContext.bind(repository); + const saveSpy = vi + .spyOn(repository, 'saveInContext') + .mockImplementationOnce(async (execution, ctx) => { + const saved = await saveInContext(execution, ctx); + admitted.resolve(ctx); + await releaseAdmission.promise; + return saved; + }); + const competing = observePeerTransaction(); + try { + const start = turns.startExecution(params, recorder.startedAt); + const ctx = await admitted.promise; + const deletion = executionService.deleteThread(projectId, agentId, thread.id, uuid()); + const rejected = expect(deletion).rejects.toMatchObject({ httpStatusCode: 409 }); + await competing.started; + await waitForPeerLock(ctx); + releaseAdmission.resolve(); + const executionId = await start; + await rejected; + + expect(await threadRepo.findOneBy({ id: thread.id })).not.toBeNull(); + expect(await repository.findByThreadIdOrdered(thread.id)).toEqual([ + expect.objectContaining({ id: executionId, status: 'running' }), + ]); + expect(await checkpointRepo.findByRunId(checkpoint.runId)).toEqual(checkpoint); + expect(await attachmentRepo.findOneBy({ id: attachment.id })).toEqual(attachment); + expect(memoryBackend.deleteThread).not.toHaveBeenCalled(); + expect(attachmentService.deleteStoredData).not.toHaveBeenCalled(); + expect(executionLogStore.delete).not.toHaveBeenCalled(); + + recorder.record({ type: 'finish', finishReason: 'stop' }); + await turns.finalizeExecution({ + executionId, + executionStarted: true, + params: { ...params, record: recorder.getMessageRecord() }, + }); + } finally { + releaseAdmission.resolve(); + saveSpy.mockRestore(); + competing.restore(); + } + }); + + it('rejects a continuation before SDK execution when deletion on another connection wins', async () => { + const thread = await createThread(); + const { executionService } = recordingServices(); + const { turns } = recordingServices(mock(), peer); + const agent = mock(); + const deleted = createDeferredPromise(); + const releaseDeletion = createDeferredPromise(); + const deleteSession = threadRepo.deleteSession.bind(threadRepo); + const deleteSpy = vi + .spyOn(threadRepo, 'deleteSession') + .mockImplementationOnce(async (...args) => { + const result = await deleteSession(...args); + deleted.resolve(args[4]); + await releaseDeletion.promise; + return result; + }); + const competing = observePeerTransaction(); + try { + const deletion = executionService.deleteThread(projectId, agentId, thread.id, uuid()); + const ctx = await deleted.promise; + const continuation = collect( + turns.execute({ + agentInstance: agent, + toolRegistry: new Map(), + mcpServerAttributions: new Map(), + context: { projectId, agentId, threadId: thread.id }, + prepare: async () => ({ + type: 'start', + input: 'Continue', + options: {}, + recording: { + access: { accessScope: 'project', ownerId: null }, + threadId: thread.id, + agentId, + agentName: 'Test Agent', + projectId, + userMessage: 'Continue', + sessionMode: 'existing', + }, + }), + }), + ); + const rejected = expect(continuation).rejects.toMatchObject({ + phase: 'create', + cause: expect.objectContaining({ message: 'Session not found' }), + }); + await competing.started; + await waitForPeerLock(ctx); + releaseDeletion.resolve(); + expect(await deletion).toBe(true); + await rejected; + expect(agent.stream).not.toHaveBeenCalled(); + expect(await threadRepo.findOneBy({ id: thread.id })).toBeNull(); + expect(await repository.findByThreadIdOrdered(thread.id)).toEqual([]); + } finally { + releaseDeletion.resolve(); + deleteSpy.mockRestore(); + competing.restore(); + } + }); + + it('converges concurrent creation of the same session without changing ownership', async () => { + const { txRunner } = recordingServices(); + const { txRunner: peerRunner, threads: peerThreads } = recordingServices(mock(), peer); + const threadId = uuid(); + const access = { accessScope: 'project' as const, ownerId: null }; + const inserted = createDeferredPromise(); + const releaseInsert = createDeferredPromise(); + const competing = observePeerTransaction(); + try { + const first = txRunner.run({}, async (ctx) => { + const result = await threadRepo.findOrCreate( + threadId, + agentId, + 'Test Agent', + projectId, + access, + ctx, + ); + inserted.resolve(ctx); + await releaseInsert.promise; + return result; + }); + const ctx = await inserted.promise; + const second = peerRunner.run( + {}, + async (peerCtx) => + await peerThreads.findOrCreate( + threadId, + agentId, + 'Other request name', + projectId, + access, + peerCtx, + ), + ); + await competing.started; + await waitForPeerLock(ctx); + releaseInsert.resolve(); + const results = await Promise.all([first, second]); + expect(results.map(({ created }) => created)).toEqual([true, false]); + expect(results[1].thread).toEqual(results[0].thread); + expect(await threadRepo.countBy({ id: threadId })).toBe(1); + } finally { + releaseInsert.resolve(); + competing.restore(); + } + }); + it('records one accepted resume and one failed attempt when separate connections claim the same checkpoint', async () => { const fixture = await startSuspendedApprovalRun(); const { outcomes, onResumeClaimed } = await resumeApprovalFromSeparateConnections(fixture); @@ -669,27 +905,14 @@ describe('AgentExecutionRepository', () => { expect(await repository.findByThreadIdOrdered(threadId)).toEqual([]); expect(await threadRepo.findOneBy({ id: threadId })).toBeNull(); expect( - await executionService.canUseDraftThread(threadId, projectId, agentId, owner.id), - ).toBe(true); - - const resumed = await resume(owner); - const next = resumed.find((chunk) => chunk.type === 'tool-call-suspended'); - if (!next || next.type !== 'tool-call-suspended') - throw new Error('Expected next suspension'); - await resume(owner, next.runId, next.toolCallId); - expect(fixture.action).toHaveBeenCalledTimes(2); - expect(await threadRepo.findOneByOrFail({ id: threadId })).toMatchObject({ - accessScope: 'user', - ownerId: owner.id, - }); - const continued = await repository.findByThreadIdOrdered(threadId); - expect(continued).toHaveLength(2); - for (const execution of continued) { - expect(execution.status).toBe('success'); - expect(execution.timeline).toContainEqual( - expect.objectContaining({ type: 'hitl-response' }), - ); - } + await executionService.canUseDraftThread(threadId, projectId, agentId, owner.id, { + previewChat: true, + sessionMode: 'existing', + }), + ).toBe(false); + await expect(resume(owner)).rejects.toThrow('does not belong to this chat'); + expect(fixture.action).not.toHaveBeenCalled(); + expect(await threadRepo.findOneBy({ id: threadId })).toBeNull(); } finally { await runtime.close(); } @@ -784,14 +1007,23 @@ describe('AgentExecutionRepository', () => { it('filters private sessions before pagination and preserves their owner on reuse', async () => { const owner = await createMember(); const other = await createAdmin(); - const { executionService } = recordingServices(); + const otherProject = await createTeamProject(); + const otherAgent = await agentRepo.save( + agentRepo.create({ + id: uuid(), + name: 'Other agent', + projectId, + integrations: [], + tools: {}, + skills: {}, + }), + ); + const { executionService, txRunner } = recordingServices(); const access = { accessScope: 'user' as const, ownerId: owner.id }; - const { thread } = await threadRepo.findOrCreate( - uuid(), - agentId, - 'Test Agent', - projectId, - access, + const { thread } = await txRunner.run( + {}, + async (ctx) => + await threadRepo.findOrCreate(uuid(), agentId, 'Test Agent', projectId, access, ctx), ); await threadRepo.update(thread.id, { updatedAt: new Date('2026-01-03T00:00:00Z') }); const shared = await createThread({ @@ -837,35 +1069,62 @@ describe('AgentExecutionRepository', () => { false, ); for (const incompatible of [ - { accessScope: 'user' as const, ownerId: other.id }, - { accessScope: 'project' as const, ownerId: null }, + { agentId, projectId, access: { accessScope: 'user' as const, ownerId: other.id } }, + { agentId, projectId, access: { accessScope: 'project' as const, ownerId: null } }, + { agentId: otherAgent.id, projectId, access }, + { agentId, projectId: otherProject.id, access }, ]) { await expect( - threadRepo.findOrCreate(thread.id, agentId, 'Test Agent', projectId, incompatible), + txRunner.run( + {}, + async (ctx) => + await threadRepo.findOrCreate( + thread.id, + incompatible.agentId, + 'Test Agent', + incompatible.projectId, + incompatible.access, + ctx, + ), + ), ).rejects.toThrow('Session not found'); } expect( - await threadRepo.findOrCreate(thread.id, agentId, 'Test Agent', projectId, access), + await txRunner.run( + {}, + async (ctx) => + await threadRepo.findOrCreate(thread.id, agentId, 'Test Agent', projectId, access, ctx), + ), ).toMatchObject({ created: false, thread: access }); - const child = await threadRepo.findOrCreate( - uuid(), - agentId, - 'Test Agent', - projectId, - { accessScope: 'user', ownerId: null }, - { parentThreadId: thread.id, parentAgentId: agentId }, + const child = await txRunner.run( + {}, + async (ctx) => + await threadRepo.findOrCreate( + uuid(), + agentId, + 'Test Agent', + projectId, + { accessScope: 'user', ownerId: null }, + ctx, + { parentThreadId: thread.id, parentAgentId: agentId }, + ), ); expect(child.thread).toMatchObject(access); expect( await executionService.canUseDraftThread(child.thread.id, projectId, agentId, owner.id), ).toBe(false); - const sharedChild = await threadRepo.findOrCreate( - uuid(), - agentId, - 'Test Agent', - projectId, - { accessScope: 'user', ownerId: null }, - { parentThreadId: shared.id, parentAgentId: agentId }, + const sharedChild = await txRunner.run( + {}, + async (ctx) => + await threadRepo.findOrCreate( + uuid(), + agentId, + 'Test Agent', + projectId, + { accessScope: 'user', ownerId: null }, + ctx, + { parentThreadId: shared.id, parentAgentId: agentId }, + ), ); expect(sharedChild.thread).toMatchObject({ accessScope: 'project', ownerId: null }); }); diff --git a/packages/cli/test/integration/database/repositories/execution.repository.test.ts b/packages/cli/test/integration/database/repositories/execution.repository.test.ts index 5e3c0d3297b..58879b1bec3 100644 --- a/packages/cli/test/integration/database/repositories/execution.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/execution.repository.test.ts @@ -1,5 +1,6 @@ -import { createWorkflow, testDb } from '@n8n/backend-test-utils'; -import { ExecutionDataRepository, ExecutionRepository } from '@n8n/db'; +import { createTeamProject, createWorkflow, testDb } from '@n8n/backend-test-utils'; +import type { WorkflowEntity } from '@n8n/db'; +import { ExecutionDataRepository, ExecutionRepository, ProjectRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import { stringify } from 'flatted'; import type { ExecutionStatus, IRunExecutionData, IRunExecutionDataAll } from 'n8n-workflow'; @@ -11,7 +12,7 @@ describe('ExecutionRepository', () => { }); beforeEach(async () => { - await testDb.truncate(['WorkflowEntity', 'ExecutionEntity']); + await testDb.truncate(['SharedWorkflow', 'WorkflowEntity', 'ExecutionEntity']); }); afterAll(async () => { @@ -187,19 +188,29 @@ describe('ExecutionRepository', () => { }); describe('markAsCrashed', () => { - const createExecution = async (status: ExecutionStatus, extra: { waitTill?: Date } = {}) => { - const workflow = await createWorkflow(); + const createExecution = async ( + status: ExecutionStatus, + extra: { + waitTill?: Date; + tracingContext?: { traceparent: string }; + workflowVersionId?: string; + retryOf?: string; + } = {}, + existingWorkflow?: WorkflowEntity, + ) => { + const workflow = existingWorkflow ?? (await createWorkflow()); + const startedAt = new Date(); const { identifiers } = await Container.get(ExecutionRepository).insert({ workflowId: workflow.id, mode: 'manual', - startedAt: new Date(), + startedAt, status, finished: status === 'success', createdAt: new Date(), ...extra, }); // Postgres returns the inserted id as a number, SQLite as a string. - return { id: String(identifiers[0].id), workflow }; + return { id: String(identifiers[0].id), workflow, startedAt }; }; it('should crash in-progress and indeterminate executions', async () => { @@ -283,7 +294,7 @@ describe('ExecutionRepository', () => { it('should crash a soft-deleted in-progress execution', async () => { const executionRepo = Container.get(ExecutionRepository); - const { id: runningId, workflow } = await createExecution('running'); + const { id: runningId, workflow, startedAt } = await createExecution('running'); await executionRepo.softDelete(runningId); const crashed = await executionRepo.markAsCrashed([runningId]); @@ -299,9 +310,53 @@ describe('ExecutionRepository', () => { workflowId: workflow.id, workflowName: workflow.name, mode: 'manual', + startedAt, + stoppedAt: expect.any(Date), }, ]); }); + + it('should report the trace context stored on the execution', async () => { + const executionRepo = Container.get(ExecutionRepository); + const tracingContext = { + traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', + }; + const { id: runningId } = await createExecution('running', { tracingContext }); + + const crashed = await executionRepo.markAsCrashed([runningId]); + + expect(crashed).toEqual([expect.objectContaining({ id: runningId, tracingContext })]); + }); + + it('should report the version id, retry source, workflow tags and owner project', async () => { + const executionRepo = Container.get(ExecutionRepository); + const projectTags = [{ key: 'team', value: 'platform' }]; + const project = await createTeamProject(); + await Container.get(ProjectRepository).update(project.id, { + customTelemetryTags: projectTags, + }); + const customTelemetryTags = [{ key: 'workflowTag', value: 'checkout' }]; + const workflow = await createWorkflow({ settings: { customTelemetryTags } }, project); + const { id: runningId } = await createExecution( + 'running', + { workflowVersionId: 'version-1', retryOf: '9' }, + workflow, + ); + + const crashed = await executionRepo.markAsCrashed([runningId]); + + expect(crashed).toEqual([ + expect.objectContaining({ + id: runningId, + workflowId: workflow.id, + workflowName: workflow.name, + workflowVersionId: 'version-1', + retryOf: '9', + workflowCustomTelemetryTags: customTelemetryTags, + project: { id: project.id, customTelemetryTags: projectTags }, + }), + ]); + }); }); describe('cancelManyRunning', () => { diff --git a/packages/cli/test/integration/policy/available-types.controller.test.ts b/packages/cli/test/integration/policy/available-types.controller.test.ts index 8170f04aa3b..bd0eb93bab6 100644 --- a/packages/cli/test/integration/policy/available-types.controller.test.ts +++ b/packages/cli/test/integration/policy/available-types.controller.test.ts @@ -18,7 +18,7 @@ const nodeTypes = mockInstance(NodeTypes); const testServer = utils.setupTestServer({ endpointGroups: ['type-availability-policies'], modules: ['type-availability-policies'], - enabledFeatures: [LICENSE_FEATURES.NODE_TYPE_POLICIES], + enabledFeatures: [LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES], }); let owner: User; @@ -122,11 +122,11 @@ describe('available types endpoint RBAC', () => { describe('available types endpoint license gating', () => { afterEach(() => { - testServer.license.enable(LICENSE_FEATURES.NODE_TYPE_POLICIES); + testServer.license.enable(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES); }); test('rejects a member with 403 when the license feature is disabled', async () => { - testServer.license.disable(LICENSE_FEATURES.NODE_TYPE_POLICIES); + testServer.license.disable(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES); const response = await testServer .authAgentFor(projectEditor) diff --git a/packages/cli/test/integration/policy/credential-type-policy-instance.controller.test.ts b/packages/cli/test/integration/policy/credential-type-policy-instance.controller.test.ts index 2aca67b8a80..199346fe9c7 100644 --- a/packages/cli/test/integration/policy/credential-type-policy-instance.controller.test.ts +++ b/packages/cli/test/integration/policy/credential-type-policy-instance.controller.test.ts @@ -11,7 +11,7 @@ import * as utils from '@test-integration/utils'; const testServer = utils.setupTestServer({ endpointGroups: ['type-availability-policies'], modules: ['type-availability-policies'], - enabledFeatures: [LICENSE_FEATURES.NODE_TYPE_POLICIES], + enabledFeatures: [LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES], }); let owner: User; @@ -68,11 +68,11 @@ describe('credential type availability policy instance controller RBAC', () => { describe('credential type availability policy instance controller license gating', () => { afterEach(() => { - testServer.license.enable(LICENSE_FEATURES.NODE_TYPE_POLICIES); + testServer.license.enable(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES); }); test('rejects an owner with 403 when the license feature is disabled', async () => { - testServer.license.disable(LICENSE_FEATURES.NODE_TYPE_POLICIES); + testServer.license.disable(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES); const response = await testServer.authAgentFor(owner).get('/credential-type-policies/instance'); diff --git a/packages/cli/test/integration/policy/credential-type-policy-project.controller.test.ts b/packages/cli/test/integration/policy/credential-type-policy-project.controller.test.ts index 4655b8d2873..d334537ec7b 100644 --- a/packages/cli/test/integration/policy/credential-type-policy-project.controller.test.ts +++ b/packages/cli/test/integration/policy/credential-type-policy-project.controller.test.ts @@ -12,7 +12,7 @@ import * as utils from '@test-integration/utils'; const testServer = utils.setupTestServer({ endpointGroups: ['type-availability-policies'], modules: ['type-availability-policies'], - enabledFeatures: [LICENSE_FEATURES.NODE_TYPE_POLICIES], + enabledFeatures: [LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES], }); let owner: User; @@ -65,11 +65,11 @@ describe('credential type availability policy project controller RBAC', () => { describe('credential type availability policy project controller license gating', () => { afterEach(() => { - testServer.license.enable(LICENSE_FEATURES.NODE_TYPE_POLICIES); + testServer.license.enable(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES); }); test('rejects a project admin with 403 when the license feature is disabled', async () => { - testServer.license.disable(LICENSE_FEATURES.NODE_TYPE_POLICIES); + testServer.license.disable(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES); const response = await testServer.authAgentFor(projectAdmin).get(projectRoute(project.id)); diff --git a/packages/cli/test/integration/policy/node-type-policy.credential-decrypt.test.ts b/packages/cli/test/integration/policy/node-type-policy.credential-decrypt.test.ts index 889d87bde99..3681505bd17 100644 --- a/packages/cli/test/integration/policy/node-type-policy.credential-decrypt.test.ts +++ b/packages/cli/test/integration/policy/node-type-policy.credential-decrypt.test.ts @@ -26,7 +26,7 @@ const HTTP_REQUEST = 'n8n-nodes-base.httpRequest'; const testServer = utils.setupTestServer({ endpointGroups: ['type-availability-policies'], modules: ['policy-infrastructure', 'type-availability-policies'], - enabledFeatures: [LICENSE_FEATURES.NODE_TYPE_POLICIES], + enabledFeatures: [LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES], }); let owner: User; diff --git a/packages/cli/test/integration/policy/node-type-policy.publish-and-start.test.ts b/packages/cli/test/integration/policy/node-type-policy.publish-and-start.test.ts index d7c601b2ad4..4884b917798 100644 --- a/packages/cli/test/integration/policy/node-type-policy.publish-and-start.test.ts +++ b/packages/cli/test/integration/policy/node-type-policy.publish-and-start.test.ts @@ -33,7 +33,7 @@ const SET = 'n8n-nodes-base.set'; const testServer = utils.setupTestServer({ endpointGroups: ['workflows', 'activeWorkflows', 'type-availability-policies'], modules: ['policy-infrastructure', 'type-availability-policies'], - enabledFeatures: [LICENSE_FEATURES.NODE_TYPE_POLICIES], + enabledFeatures: [LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES], }); let owner: User; diff --git a/packages/cli/test/integration/policy/node-type-policy.save.test.ts b/packages/cli/test/integration/policy/node-type-policy.save.test.ts index 514d371fd84..610540934af 100644 --- a/packages/cli/test/integration/policy/node-type-policy.save.test.ts +++ b/packages/cli/test/integration/policy/node-type-policy.save.test.ts @@ -35,7 +35,7 @@ const testServer = utils.setupTestServer({ endpointGroups: ['workflows', 'type-availability-policies'], modules: ['policy-infrastructure', 'type-availability-policies'], enabledFeatures: [ - LICENSE_FEATURES.NODE_TYPE_POLICIES, + LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES, LICENSE_FEATURES.SHARING, LICENSE_FEATURES.ADVANCED_PERMISSIONS, ], diff --git a/packages/cli/test/integration/policy/type-availability-policy-instance.controller.test.ts b/packages/cli/test/integration/policy/type-availability-policy-instance.controller.test.ts index 41f322ff65f..8751968769d 100644 --- a/packages/cli/test/integration/policy/type-availability-policy-instance.controller.test.ts +++ b/packages/cli/test/integration/policy/type-availability-policy-instance.controller.test.ts @@ -15,7 +15,7 @@ import * as utils from '@test-integration/utils'; const testServer = utils.setupTestServer({ endpointGroups: ['type-availability-policies'], modules: ['type-availability-policies'], - enabledFeatures: [LICENSE_FEATURES.NODE_TYPE_POLICIES], + enabledFeatures: [LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES], }); let owner: User; @@ -126,11 +126,11 @@ describe('node type availability policy instance controller RBAC', () => { describe('node type availability policy instance controller license gating', () => { afterEach(() => { - testServer.license.enable(LICENSE_FEATURES.NODE_TYPE_POLICIES); + testServer.license.enable(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES); }); test('rejects an owner with 403 when the license feature is disabled', async () => { - testServer.license.disable(LICENSE_FEATURES.NODE_TYPE_POLICIES); + testServer.license.disable(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES); const response = await testServer.authAgentFor(owner).get('/node-type-policies/instance'); diff --git a/packages/cli/test/integration/policy/type-availability-policy-project.controller.test.ts b/packages/cli/test/integration/policy/type-availability-policy-project.controller.test.ts index 7da44d13ad1..84d083421e9 100644 --- a/packages/cli/test/integration/policy/type-availability-policy-project.controller.test.ts +++ b/packages/cli/test/integration/policy/type-availability-policy-project.controller.test.ts @@ -10,7 +10,7 @@ import * as utils from '@test-integration/utils'; const testServer = utils.setupTestServer({ endpointGroups: ['type-availability-policies'], modules: ['type-availability-policies'], - enabledFeatures: [LICENSE_FEATURES.NODE_TYPE_POLICIES], + enabledFeatures: [LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES], }); let owner: User; @@ -113,11 +113,11 @@ describe('node type availability policy project controller RBAC', () => { describe('node type availability policy project controller license gating', () => { afterEach(() => { - testServer.license.enable(LICENSE_FEATURES.NODE_TYPE_POLICIES); + testServer.license.enable(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES); }); test('rejects a project admin with 403 when the license feature is disabled', async () => { - testServer.license.disable(LICENSE_FEATURES.NODE_TYPE_POLICIES); + testServer.license.disable(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES); const response = await testServer.authAgentFor(projectAdmin).get(projectRoute(project.id)); diff --git a/packages/cli/test/integration/public-api/discover.test.ts b/packages/cli/test/integration/public-api/discover.test.ts index 7088e0f4b6f..8f29533f8bb 100644 --- a/packages/cli/test/integration/public-api/discover.test.ts +++ b/packages/cli/test/integration/public-api/discover.test.ts @@ -70,6 +70,24 @@ describe('GET /discover', () => { expect(response.body.data.specUrl).toBe('/api/v1/openapi.yml'); }); + test('should filter discover data by the API key scopes', async () => { + const scopedOwner = await createOwnerWithApiKey({ scopes: ['tag:list'] }); + const response = await testServer.publicApiAgentFor(scopedOwner).get('/discover'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.scopes).toEqual(['tag:list']); + expect( + response.body.data.resources.tags.endpoints.some( + (endpoint: { operationId: string }) => endpoint.operationId === 'getTags', + ), + ).toBe(true); + expect( + response.body.data.resources.tags.endpoints.some( + (endpoint: { operationId: string }) => endpoint.operationId === 'createTag', + ), + ).toBe(false); + }); + test('should return discover data for member', async () => { const response = await authMemberAgent.get('/discover'); @@ -150,4 +168,46 @@ describe('GET /discover', () => { expect(response.statusCode).toBe(200); expect(response.body.data.resources).toEqual({}); }); + + test('should keep only create endpoints with ?operation=create', async () => { + const response = await authOwnerAgent.get('/discover?operation=create'); + expect(response.statusCode).toBe(200); + + const resources = Object.values(response.body.data.resources) as Array<{ + operations: string[]; + }>; + expect(resources.length).toBeGreaterThan(0); + for (const resource of resources) { + expect(resource.operations.map((o) => o.toLowerCase())).toEqual(['create']); + } + }); + + // The legacy validator also answered 400 here: a repeated parameter arrives as an array, + // and the parameter is declared as a string. + test('should return 400 for a repeated query parameter', async () => { + const response = await authOwnerAgent.get('/discover?resource=tags&resource=workflow'); + + expect(response.statusCode).toBe(400); + expect(response.body).toEqual({ + message: 'request/query/resource Expected string, received array', + }); + }); + + test.each([ + [ + 'an invalid include value', + { include: 'invalid' }, + 'request/query/include must be equal to one of the allowed values: schemas', + ], + [ + 'an unknown query parameter', + { unknown: 'value' }, + "request/query Unrecognized key(s) in object: 'unknown'", + ], + ])('should return 400 for %s', async (_name, query, message) => { + const response = await authOwnerAgent.get('/discover').query(query); + + expect(response.statusCode).toBe(400); + expect(response.body).toEqual({ message }); + }); }); diff --git a/packages/cli/test/integration/public-api/evaluations.test.ts b/packages/cli/test/integration/public-api/evaluations.test.ts index 3c980601521..9659a35e7ac 100644 --- a/packages/cli/test/integration/public-api/evaluations.test.ts +++ b/packages/cli/test/integration/public-api/evaluations.test.ts @@ -48,7 +48,7 @@ beforeEach(async () => { await testDb.truncate(['WorkflowEntity', 'SharedWorkflow', 'TestRun', 'TestCaseExecution']); }); -describe('GET /workflows/:id/test-runs', () => { +describe('GET /workflows/:workflowId/test-runs', () => { test('should return the test runs of a workflow', async () => { const workflow = await createWorkflow(undefined, owner); const first = await createTestRun(workflow.id, { status: 'completed' }); @@ -101,6 +101,61 @@ describe('GET /workflows/:id/test-runs', () => { await restrictedAgent.get(`/workflows/${workflow.id}/test-runs`).expect(403); }); + + test('should return 400 for an invalid cursor', async () => { + const workflow = await createWorkflow(undefined, owner); + + const response = await authOwnerAgent + .get(`/workflows/${workflow.id}/test-runs`) + .query({ cursor: 'not-a-cursor' }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toBe('An invalid cursor was provided'); + }); + + test('should return 400 for a status outside the set', async () => { + const workflow = await createWorkflow(undefined, owner); + + const response = await authOwnerAgent + .get(`/workflows/${workflow.id}/test-runs`) + .query({ status: 'paused' }); + + expect(response.statusCode).toBe(400); + }); + + test('should return 404 for an unknown workflow', async () => { + // A member holds no global workflow scope, so the scope check has to look the workflow up. + const member = await createMemberWithApiKey(); + const memberAgent = testServer.publicApiAgentFor(member); + + const response = await memberAgent.get('/workflows/does-not-exist/test-runs'); + + expect(response.statusCode).toBe(404); + expect(response.body.message).toBe('Workflow with ID "does-not-exist" not found.'); + }); + + test('should paginate test runs via cursor', async () => { + const workflow = await createWorkflow(undefined, owner); + await createTestRun(workflow.id, { status: 'completed' }); + await createTestRun(workflow.id, { status: 'completed' }); + await createTestRun(workflow.id, { status: 'completed' }); + + const firstPage = await authOwnerAgent + .get(`/workflows/${workflow.id}/test-runs`) + .query({ limit: 2 }); + + expect(firstPage.statusCode).toBe(200); + expect(firstPage.body.data).toHaveLength(2); + expect(firstPage.body.nextCursor).toEqual(expect.any(String)); + + const secondPage = await authOwnerAgent + .get(`/workflows/${workflow.id}/test-runs`) + .query({ cursor: firstPage.body.nextCursor }); + + expect(secondPage.statusCode).toBe(200); + expect(secondPage.body.data).toHaveLength(1); + expect(secondPage.body.nextCursor).toBeNull(); + }); }); describe('GET /workflows/:id/test-runs/:runId', () => { @@ -225,7 +280,7 @@ describe('scope enforcement on read endpoints', () => { }); }); -describe('POST /workflows/:id/test-runs', () => { +describe('POST /workflows/:workflowId/test-runs', () => { beforeEach(() => { testRunner.startTestRun.mockReset(); // Evaluations licensed and unlimited by default; individual tests override. diff --git a/packages/cli/test/integration/public-api/folders.test.ts b/packages/cli/test/integration/public-api/folders.test.ts index 6f9a2260dd2..7fefdfc5a44 100644 --- a/packages/cli/test/integration/public-api/folders.test.ts +++ b/packages/cli/test/integration/public-api/folders.test.ts @@ -4,6 +4,7 @@ import { ProjectRelationRepository, ProjectRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import type { ApiKeyScope } from '@n8n/permissions'; +import { FolderNotFoundError } from '@/errors/folder-not-found.error'; import { FolderService } from '@/services/folder.service'; import { ProjectService } from '@/services/project.service.ee'; @@ -560,6 +561,28 @@ describe('DELETE /projects/:projectId/folders/:folderId', () => { expect(response.statusCode).toBe(404); }); + test('should return 400 when transferToFolderId is empty', async () => { + testServer.license.enable('feat:folders'); + const { agent, personalProject } = await createDeleteScopedAgent(); + + const folder = await createFolder(personalProject, { name: 'Folder' }); + + const response = await agent + .delete(`/projects/${personalProject.id}/folders/${folder.id}`) + .query({ transferToFolderId: '' }); + + expect(response.statusCode).toBe(400); + expect(response.body).toEqual({ + message: 'request/query/transferToFolderId must not be empty', + }); + + const stillThere = await Container.get(FolderService).findFolderInProjectOrFail( + folder.id, + personalProject.id, + ); + expect(stillThere.id).toBe(folder.id); + }); + test('should return 400 for invalid transferToFolderId query format', async () => { testServer.license.enable('feat:folders'); const { agent, personalProject } = await createDeleteScopedAgent(); @@ -573,6 +596,19 @@ describe('DELETE /projects/:projectId/folders/:folderId', () => { expect(response.statusCode).toBe(400); }); + test('should return 400 for an undocumented query parameter', async () => { + testServer.license.enable('feat:folders'); + const { agent, personalProject } = await createDeleteScopedAgent(); + + const folder = await createFolder(personalProject, { name: 'Folder' }); + + const response = await agent + .delete(`/projects/${personalProject.id}/folders/${folder.id}`) + .query({ unknownParam: 'x' }); + + expect(response.statusCode).toBe(400); + }); + test('should return 400 when transferToFolderId points to the folder being deleted', async () => { testServer.license.enable('feat:folders'); const { agent, personalProject } = await createDeleteScopedAgent(); @@ -586,7 +622,7 @@ describe('DELETE /projects/:projectId/folders/:folderId', () => { expect(response.statusCode).toBe(400); }); - test('should delete a folder in personal project', async () => { + test('should delete a folder in personal project and send no response body', async () => { testServer.license.enable('feat:folders'); const { agent, personalProject } = await createDeleteScopedAgent(); @@ -595,6 +631,12 @@ describe('DELETE /projects/:projectId/folders/:folderId', () => { const response = await agent.delete(`/projects/${personalProject.id}/folders/${folder.id}`); expect(response.statusCode).toBe(204); + expect(response.text).toBe(''); + expect(response.body).toEqual({}); + + await expect( + Container.get(FolderService).findFolderInProjectOrFail(folder.id, personalProject.id), + ).rejects.toThrow(FolderNotFoundError); }); test('should delete folder and transfer child folders to transferToFolderId', async () => { diff --git a/packages/cli/test/integration/public-api/node-type-policies.test.ts b/packages/cli/test/integration/public-api/node-type-policies.test.ts index 4de96954852..954ecdb2725 100644 --- a/packages/cli/test/integration/public-api/node-type-policies.test.ts +++ b/packages/cli/test/integration/public-api/node-type-policies.test.ts @@ -11,7 +11,7 @@ import * as utils from '@test-integration/utils'; const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'], modules: ['type-availability-policies'], - enabledFeatures: [LICENSE_FEATURES.NODE_TYPE_POLICIES], + enabledFeatures: [LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES], }); let owner: User; @@ -156,11 +156,11 @@ describe('node type policies public API authorization', () => { describe('node type policies public API license gating', () => { afterEach(() => { - testServer.license.enable(LICENSE_FEATURES.NODE_TYPE_POLICIES); + testServer.license.enable(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES); }); test('rejects an owner with 403 when the license feature is disabled', async () => { - testServer.license.disable(LICENSE_FEATURES.NODE_TYPE_POLICIES); + testServer.license.disable(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES); const instance = await testServer.publicApiAgentFor(owner).get('/node-type-policies/instance'); expect(instance.statusCode).toBe(403); diff --git a/packages/cli/test/integration/public-api/projects.test.ts b/packages/cli/test/integration/public-api/projects.test.ts index dd680e16cf4..fc2357d2079 100644 --- a/packages/cli/test/integration/public-api/projects.test.ts +++ b/packages/cli/test/integration/public-api/projects.test.ts @@ -7,7 +7,13 @@ import { testDb, mockInstance, } from '@n8n/backend-test-utils'; -import { GLOBAL_MEMBER_ROLE } from '@n8n/db'; +import { EntityManager } from '@n8n/typeorm'; +import { + GLOBAL_MEMBER_ROLE, + ProjectRepository, + ProjectRelationRepository, + ProjectRelation, +} from '@n8n/db'; import { Container } from '@n8n/di'; import type { MockInstance } from 'vitest'; @@ -229,6 +235,134 @@ describe('Projects in Public API', () => { }); }); + it.each([-1, 1])('preserves fields and rejects an occupied ID with quota %s', async (limit) => { + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const owner = await createOwnerWithApiKey(); + const payload = { + id: 'source-project', + name: ' Sales ', + icon: { type: 'icon', value: 'briefcase', color: 'red' }, + description: '', + customTelemetryTags: [ + { key: ' team ', value: ' Sales ' }, + { key: 'region', value: '' }, + ], + }; + const created = await testServer + .publicApiAgentFor(owner) + .post('/projects') + .send(payload) + .expect(201); + expect(created.body).toMatchObject(payload); + const repository = Container.get(ProjectRepository); + const relations = Container.get(ProjectRelationRepository); + const before = await repository.findOneByOrFail({ id: payload.id }); + const members = await relations.find({ + where: { projectId: payload.id }, + relations: ['role'], + }); + expect(members).toMatchObject([{ userId: owner.id, role: { slug: 'project:admin' } }]); + const otherOwner = await createOwnerWithApiKey(); + testServer.license.setQuota('quota:maxTeamProjects', limit); + emitSpy.mockClear(); + + const response = await testServer + .publicApiAgentFor(otherOwner) + .post('/projects') + .send({ ...payload, name: 'Replacement' }) + .expect(409); + + expect(response.body.message).toBe('A project with this ID already exists'); + expect(await repository.findOneByOrFail({ id: payload.id })).toEqual(before); + expect( + await relations.find({ where: { projectId: payload.id }, relations: ['role'] }), + ).toEqual(members); + expect(emitSpy).not.toHaveBeenCalledWith('team-project-created', expect.anything()); + }); + + it('leaves a personal project unchanged when its ID is occupied', async () => { + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const owner = await createOwnerWithApiKey(); + const member = await createMember(); + const repository = Container.get(ProjectRepository); + const project = await repository.getPersonalProjectForUserOrFail(member.id); + const relations = Container.get(ProjectRelationRepository); + const members = await relations.findBy({ projectId: project.id }); + + await testServer + .publicApiAgentFor(owner) + .post('/projects') + .send({ id: project.id, name: 'Replacement' }) + .expect(409); + + expect(await repository.findOneByOrFail({ id: project.id })).toEqual(project); + expect(await relations.findBy({ projectId: project.id })).toEqual(members); + }); + + it('rolls back the project when the admin insert fails', async () => { + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const owner = await createOwnerWithApiKey(); + const insert = EntityManager.prototype.insert; + const spy = vi.spyOn(EntityManager.prototype, 'insert').mockImplementation(async function ( + this: EntityManager, + target, + data, + ) { + if (target === ProjectRelation) throw new Error('Admin insert failed'); + return await insert.call(this, target, data); + }); + try { + await testServer + .publicApiAgentFor(owner) + .post('/projects') + .send({ id: 'rolled-back', name: 'Sales' }) + .expect(500); + expect(await Container.get(ProjectRepository).findOneBy({ id: 'rolled-back' })).toBeNull(); + expect( + await Container.get(ProjectRelationRepository).findBy({ projectId: 'rolled-back' }), + ).toEqual([]); + expect(emitSpy).not.toHaveBeenCalledWith('team-project-created', expect.anything()); + } finally { + spy.mockRestore(); + } + }); + + it('checks user scope for session callers', async () => { + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const member = await createMember(); + await testServer + .publicApiAgentWithCookie(member) + .post('/projects') + .send({ name: 'Sales' }) + .expect(403); + const owner = await createOwnerWithApiKey(); + const response = await testServer + .publicApiAgentWithCookie(owner) + .post('/projects') + .send({ name: 'Sales', icon: null, description: null, customTelemetryTags: [] }) + .expect(201); + expect(response.body).toMatchObject({ + icon: null, + description: null, + customTelemetryTags: [], + }); + }); + + it('requires the API key scope even when the user can create projects', async () => { + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const owner = await createOwnerWithApiKey({ scopes: ['project:list'] }); + await testServer + .publicApiAgentFor(owner) + .post('/projects') + .send({ name: 'Sales' }) + .expect(403); + }); + it('if not authenticated, should reject', async () => { /** * Arrange @@ -321,16 +455,16 @@ describe('Projects in Public API', () => { const response = await testServer .publicApiAgentFor(owner) .post('/projects') - .send({ name: 'some-project', icon: { type: 'icon', value: 'layers' } }); + .send({ name: 'some-project', extra: true }); expect(response.status).toBe(400); expect(response.body).toHaveProperty( 'message', - "request/body Unrecognized key(s) in object: 'icon'", + "request/body Unrecognized key(s) in object: 'extra'", ); }); - it.each(['id', 'type'])('should reject the read-only field %s', async (key) => { + it.each(['type'])('should reject the read-only field %s', async (key) => { testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.enable('feat:projectRole:admin'); const owner = await createOwnerWithApiKey(); diff --git a/packages/cli/test/integration/public-api/promotions.test.ts b/packages/cli/test/integration/public-api/promotions.test.ts index 94dccd5df1a..fdbdd7af152 100644 --- a/packages/cli/test/integration/public-api/promotions.test.ts +++ b/packages/cli/test/integration/public-api/promotions.test.ts @@ -870,6 +870,7 @@ describe('Promotions in Public API', () => { configId: continueBody.expectedSource.configId, git: { branchName: 'main', commitSha: continueBody.expectedSource.commitSha }, preflight: { + missingProjects: [], missingBindings: [ { kind: 'variable', diff --git a/packages/cli/test/integration/public-api/users.ee.test.ts b/packages/cli/test/integration/public-api/users.ee.test.ts index e98f4cbab33..15f50847621 100644 --- a/packages/cli/test/integration/public-api/users.ee.test.ts +++ b/packages/cli/test/integration/public-api/users.ee.test.ts @@ -1,14 +1,9 @@ -import { - createTeamProject, - linkUserToProject, - testDb, - mockInstance, -} from '@n8n/backend-test-utils'; +import { createTeamProject, linkUserToProject, testDb } from '@n8n/backend-test-utils'; import { GLOBAL_MEMBER_ROLE, type User } from '@n8n/db'; import { v4 as uuid } from 'uuid'; import validator from 'validator'; -import { License } from '@/license'; +import { USER_QUOTA_FORBIDDEN_MESSAGE } from '@/public-api/constants'; import { createMember, @@ -20,10 +15,6 @@ import { import type { SuperAgentTest } from '../shared/types'; import * as utils from '../shared/utils/'; -mockInstance(License, { - getUsersLimit: vi.fn().mockReturnValue(-1), -}); - const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] }); beforeEach(async () => { @@ -210,7 +201,11 @@ describe('With license unlimited quota:users', () => { test('should return 404 for non-existing id ', async () => { const owner = await createOwnerWithApiKey(); const authOwnerAgent = testServer.publicApiAgentFor(owner); - await authOwnerAgent.get(`/users/${uuid()}`).expect(404); + const missingId = uuid(); + + const response = await authOwnerAgent.get(`/users/${missingId}`).expect(404); + + expect(response.body).toStrictEqual({ message: `Could not find user with id: ${missingId}` }); }); test('should return a pending user', async () => { @@ -291,17 +286,30 @@ describe('With license without quota:users', () => { let authOwnerAgent: SuperAgentTest; beforeEach(async () => { - mockInstance(License, { getUsersLimit: vi.fn().mockReturnValue(null) }); + testServer.license.setQuota('quota:users', 0); const owner = await createOwnerWithApiKey(); authOwnerAgent = testServer.publicApiAgentFor(owner); }); test('GET /users should fail due to invalid license', async () => { - await authOwnerAgent.get('/users').expect(403); + const response = await authOwnerAgent.get('/users').expect(403); + + expect(response.body).toHaveProperty('message', USER_QUOTA_FORBIDDEN_MESSAGE); }); test('GET /users/:id should fail due to invalid license', async () => { - await authOwnerAgent.get(`/users/${uuid()}`).expect(403); + const response = await authOwnerAgent.get(`/users/${uuid()}`).expect(403); + + expect(response.body).toHaveProperty('message', USER_QUOTA_FORBIDDEN_MESSAGE); + }); + + test('GET /users/:id answers the generic Forbidden message when scope and license both fail', async () => { + const member = await createMemberWithApiKey(); + + const response = await testServer.publicApiAgentFor(member).get(`/users/${member.id}`); + + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('message', 'Forbidden'); }); }); diff --git a/packages/cli/test/integration/public-api/users.test.ts b/packages/cli/test/integration/public-api/users.test.ts index a9b7a21ce16..7d0f1441b0f 100644 --- a/packages/cli/test/integration/public-api/users.test.ts +++ b/packages/cli/test/integration/public-api/users.test.ts @@ -26,21 +26,62 @@ describe('Users in Public API', () => { describe('GET /users', () => { it('if not authenticated, should reject', async () => { - /** - * Act - */ const response = await testServer.publicApiAgentWithApiKey('').get('/users'); - /** - * Assert - */ expect(response.status).toBe(401); }); + it('if missing scope, should reject', async () => { + const memberWithoutScope = await createMemberWithApiKey({ scopes: ['user:read'] }); + + const response = await testServer.publicApiAgentFor(memberWithoutScope).get('/users'); + + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('message', 'Forbidden'); + }); + + it('with a non-numeric limit, should reject', async () => { + const owner = await createOwnerWithApiKey(); + + const response = await testServer + .publicApiAgentFor(owner) + .get('/users') + .query({ limit: 'abc' }); + + expect(response.status).toBe(400); + expect(response.body).toStrictEqual({ + message: 'request/query/limit Param `limit` must be a valid integer', + }); + }); + + it('with an invalid includeRole, should reject', async () => { + const owner = await createOwnerWithApiKey(); + + const response = await testServer + .publicApiAgentFor(owner) + .get('/users') + .query({ includeRole: 'not-a-boolean' }); + + expect(response.status).toBe(400); + expect(response.body).toStrictEqual({ + message: + "request/query/includeRole Invalid enum value. Expected 'true' | 'false', received 'not-a-boolean'", + }); + }); + + it('should reject an invalid cursor', async () => { + const owner = await createOwnerWithApiKey(); + + const response = await testServer + .publicApiAgentFor(owner) + .get('/users') + .query({ cursor: 'not-a-cursor' }); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('message', 'An invalid cursor was provided'); + }); + it('should return users with roles', async () => { - /** - * Arrange - */ const owner = await createOwnerWithApiKey(); const includeRole = true; @@ -48,17 +89,11 @@ describe('Users in Public API', () => { await createMember(); await createMember(); - /** - * Act - */ const response = await testServer .publicApiAgentFor(owner) .get('/users') .query({ includeRole }); - /** - * Assert - */ expect(response.status).toBe(200); const { data: users } = response.body; @@ -82,9 +117,6 @@ describe('Users in Public API', () => { }); it('should return mfaEnabled status for users', async () => { - /** - * Arrange - */ const owner = await createOwnerWithApiKey(); const memberWithMfa = await createMember(); // Manually enable MFA for this member @@ -94,14 +126,8 @@ describe('Users in Public API', () => { const memberWithoutMfa = await createMember(); - /** - * Act - */ const response = await testServer.publicApiAgentFor(owner).get('/users'); - /** - * Assert - */ expect(response.status).toBe(200); const { data: users } = response.body; @@ -115,41 +141,23 @@ describe('Users in Public API', () => { describe('GET /users/:id', () => { it('if not authenticated, should reject', async () => { - /** - * Arrange - */ const member = await createMember(); - /** - * Act - */ const response = await testServer.publicApiAgentWithApiKey('').get(`/users/${member.id}`); - /** - * Assert - */ expect(response.status).toBe(401); }); it('should return a user with role', async () => { - /** - * Arrange - */ const owner = await createOwnerWithApiKey(); const member = await createMember(); const includeRole = true; - /** - * Act - */ const response = await testServer .publicApiAgentFor(owner) .get(`/users/${member.id}`) .query({ includeRole }); - /** - * Assert - */ expect(response.status).toBe(200); const returnedUser = response.body; @@ -165,9 +173,6 @@ describe('Users in Public API', () => { }); it('should return mfaEnabled status for a single user', async () => { - /** - * Arrange - */ const owner = await createOwnerWithApiKey(); const member = await createMember(); // Enable MFA for this member @@ -175,95 +180,64 @@ describe('Users in Public API', () => { const { Container } = await import('@n8n/di'); await Container.get(userRepository).update(member.id, { mfaEnabled: true }); - /** - * Act - */ const response = await testServer.publicApiAgentFor(owner).get(`/users/${member.id}`); - /** - * Assert - */ expect(response.status).toBe(200); const returnedUser = response.body; expect(returnedUser).toHaveProperty('mfaEnabled', true); }); + + it('if the identifier is neither a valid ID nor a valid email, should reject', async () => { + const owner = await createOwnerWithApiKey(); + + const response = await testServer.publicApiAgentFor(owner).get('/users/not-an-id'); + + expect(response.status).toBe(400); + expect(response.body).toStrictEqual({ + message: 'request/params/userId must be a valid ID or email', + }); + }); }); describe('POST /users', () => { it('if not authenticated, should reject', async () => { - /** - * Arrange - */ const payload = { email: 'test@test.com', role: 'global:admin' }; - /** - * Act - */ const response = await testServer.publicApiAgentWithApiKey('').post('/users').send(payload); - /** - * Assert - */ expect(response.status).toBe(401); }); it('if missing scope, should reject', async () => { - /** - * Arrange - */ testServer.license.enable('feat:advancedPermissions'); const member = await createMemberWithApiKey(); const payload = [{ email: 'test@test.com', role: 'global:admin' }]; - /** - * Act - */ const response = await testServer.publicApiAgentFor(member).post('/users').send(payload); - /** - * Assert - */ expect(response.status).toBe(403); expect(response.body).toHaveProperty('message', 'Forbidden'); }); it('should fail if role does not exist', async () => { - /** - * Arrange - */ testServer.license.enable('feat:advancedPermissions'); const owner = await createOwnerWithApiKey(); const payload = [{ email: 'test@test.com', role: 'non-existing-role' }]; - /** - * Act - */ const response = await testServer.publicApiAgentFor(owner).post('/users').send(payload); - /** - * Assert - */ expect(response.status).toBe(400); expect(response.body).toHaveProperty('message', 'Role non-existing-role does not exist'); }); it('should create a user', async () => { - /** - * Arrange - */ testServer.license.enable('feat:advancedPermissions'); const owner = await createOwnerWithApiKey(); const payload = [{ email: 'test@test.com', role: 'global:admin' }]; - /** - * Act - */ const response = await testServer.publicApiAgentFor(owner).post('/users').send(payload); - /** - * Assert - */ expect(response.status).toBe(201); expect(response.body).toHaveLength(1); @@ -285,127 +259,84 @@ describe('Users in Public API', () => { }); it('should create a user with an existing custom role', async () => { - /** - * Arrange - */ testServer.license.enable('feat:advancedPermissions'); const owner = await createOwnerWithApiKey(); const customRole = 'custom:role'; await createRole({ slug: customRole, displayName: 'Custom role', roleType: 'global' }); const payload = [{ email: 'test@test.com', role: customRole }]; - /** - * Act - */ const response = await testServer.publicApiAgentFor(owner).post('/users').send(payload); - /** - * Assert - */ expect(response.status).toBe(201); }); }); describe('DELETE /users/:id', () => { it('if not authenticated, should reject', async () => { - /** - * Arrange - */ const member = await createMember(); - /** - * Act - */ const response = await testServer.publicApiAgentWithApiKey('').delete(`/users/${member.id}`); - /** - * Assert - */ expect(response.status).toBe(401); }); it('if missing scope, should reject', async () => { - /** - * Arrange - */ testServer.license.enable('feat:advancedPermissions'); const member = await createMemberWithApiKey(); const secondMember = await createMember(); - /** - * Act - */ const response = await testServer .publicApiAgentFor(member) .delete(`/users/${secondMember.id}`); - /** - * Assert - */ expect(response.status).toBe(403); expect(response.body).toHaveProperty('message', 'Forbidden'); }); it('should delete a user', async () => { - /** - * Arrange - */ testServer.license.enable('feat:advancedPermissions'); const owner = await createOwnerWithApiKey(); const member = await createMember(); - /** - * Act - */ const response = await testServer.publicApiAgentFor(owner).delete(`/users/${member.id}`); - /** - * Assert - */ expect(response.status).toBe(204); await expect(getUserById(member.id)).rejects.toThrow(); }); + + it('if the id is not a valid UUID, should reject', async () => { + const owner = await createOwnerWithApiKey(); + + const response = await testServer.publicApiAgentFor(owner).delete('/users/not-an-id'); + + expect(response.status).toBe(400); + expect(response.body).toStrictEqual({ + message: 'request/params/userId must be a valid ID', + }); + }); }); describe('PATCH /users/:id/role', () => { it('if not authenticated, should reject', async () => { - /** - * Arrange - */ const member = await createMember(); - /** - * Act - */ const response = await testServer .publicApiAgentWithApiKey('') .patch(`/users/${member.id}/role`); - /** - * Assert - */ expect(response.status).toBe(401); }); it('if not licensed, should reject', async () => { - /** - * Arrange - */ const owner = await createOwnerWithApiKey(); const member = await createMember(); const payload = { newRoleName: 'global:admin' }; - /** - * Act - */ const response = await testServer .publicApiAgentFor(owner) .patch(`/users/${member.id}/role`) .send(payload); - /** - * Assert - */ expect(response.status).toBe(403); expect(response.body).toHaveProperty( 'message', @@ -414,81 +345,67 @@ describe('Users in Public API', () => { }); it('if missing scope, should reject', async () => { - /** - * Arrange - */ testServer.license.enable('feat:advancedPermissions'); const member = await createMemberWithApiKey(); const secondMember = await createMember(); const payload = { newRoleName: 'global:admin' }; - /** - * Act - */ const response = await testServer .publicApiAgentFor(member) .patch(`/users/${secondMember.id}/role`) .send(payload); - /** - * Assert - */ expect(response.status).toBe(403); expect(response.body).toHaveProperty('message', 'Forbidden'); }); it('should return a 400 on invalid payload', async () => { - /** - * Arrange - */ testServer.license.enable('feat:advancedPermissions'); const owner = await createOwnerWithApiKey(); const member = await createMember(); const payload = { newRoleName: 'invalid' }; - /** - * Act - */ const response = await testServer .publicApiAgentFor(owner) .patch(`/users/${member.id}/role`) .send(payload); - /** - * Assert - */ expect(response.status).toBe(400); }); + it('if the id is not a valid UUID, should reject', async () => { + testServer.license.enable('feat:advancedPermissions'); + const owner = await createOwnerWithApiKey(); + const payload = { newRoleName: 'global:admin' }; + + const response = await testServer + .publicApiAgentFor(owner) + .patch('/users/not-an-id/role') + .send(payload); + + expect(response.status).toBe(400); + expect(response.body).toStrictEqual({ + message: 'request/params/userId must be a valid ID', + }); + }); + it("should change a user's role", async () => { - /** - * Arrange - */ testServer.license.enable('feat:advancedPermissions'); const owner = await createOwnerWithApiKey(); const member = await createMember(); const payload = { newRoleName: 'global:admin' }; - /** - * Act - */ const response = await testServer .publicApiAgentFor(owner) .patch(`/users/${member.id}/role`) .send(payload); - /** - * Assert - */ expect(response.status).toBe(204); const storedUser = await getUserById(member.id); expect(storedUser.role.slug).toBe(payload.newRoleName); }); it('should change a user role to an existing custom role', async () => { - /** - * Arrange - */ testServer.license.enable('feat:advancedPermissions'); testServer.license.enable('feat:customRoles'); const owner = await createOwnerWithApiKey(); @@ -497,17 +414,11 @@ describe('Users in Public API', () => { await createRole({ slug: customRole, displayName: 'Custom role', roleType: 'global' }); const payload = { newRoleName: customRole }; - /** - * Act - */ const response = await testServer .publicApiAgentFor(owner) .patch(`/users/${member.id}/role`) .send(payload); - /** - * Assert - */ expect(response.status).toBe(204); const storedUser = await getUserById(member.id); expect(storedUser.role.slug).toBe(payload.newRoleName); diff --git a/packages/cli/test/integration/scaling/queue-recovery.test.ts b/packages/cli/test/integration/scaling/queue-recovery.test.ts index 02fd24b00fc..5a41d4d2e85 100644 --- a/packages/cli/test/integration/scaling/queue-recovery.test.ts +++ b/packages/cli/test/integration/scaling/queue-recovery.test.ts @@ -119,7 +119,7 @@ describe('ScalingService queue recovery', () => { }); await scalingService.recoverFromQueue(); - await Container.get(ExecutionCrashService).markAsCrashed(execution.id); + await Container.get(ExecutionCrashService).markAsCrashed(execution.id, 'queue-recovery'); const laterWorkflow = await createWorkflow(); await createDanglingExecution('trigger', laterWorkflow); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 80d600141fc..e28779c1006 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,6 +13,7 @@ export * from './html-sandbox'; export * from './instance-settings'; export * from './nodes-loader'; export * from './utils'; +export * from './webhook-response'; export { WorkflowHasIssuesError } from './errors/workflow-has-issues.error'; export * from './observability'; diff --git a/packages/core/src/webhook-response/__tests__/buffer-body-codec.test.ts b/packages/core/src/webhook-response/__tests__/buffer-body-codec.test.ts new file mode 100644 index 00000000000..af0a2a0be04 --- /dev/null +++ b/packages/core/src/webhook-response/__tests__/buffer-body-codec.test.ts @@ -0,0 +1,90 @@ +import type { IN8nHttpFullResponse } from 'n8n-workflow'; +import { deepCopy } from 'n8n-workflow'; + +import { decodeBufferBody, ENCODED_BUFFER_KEY, encodeBufferBody } from '../buffer-body-codec'; + +const fullResponse = (body: unknown): IN8nHttpFullResponse => ({ + body: body as IN8nHttpFullResponse['body'], + headers: { 'content-type': 'application/octet-stream' }, + statusCode: 200, +}); + +describe('encodeBufferBody', () => { + it('replaces a Buffer body with a base64 envelope', () => { + const response = fullResponse(Buffer.from('hello')); + + const encoded = encodeBufferBody(response); + + expect(encoded).toBe(response); + expect(encoded.body).toEqual({ [ENCODED_BUFFER_KEY]: 'aGVsbG8=' }); + }); + + it('encodes an empty Buffer', () => { + const encoded = encodeBufferBody(fullResponse(Buffer.alloc(0))); + + expect(encoded.body).toEqual({ [ENCODED_BUFFER_KEY]: '' }); + }); + + it.each([ + ['a JSON body', { hello: 'world' }], + ['a string body', 'hello'], + ['a null body', null], + ['a binary-data reference', { binaryData: { id: 'filesystem-v2:abc' } }], + ])('leaves %s untouched', (_label, body) => { + const encoded = encodeBufferBody(fullResponse(body)); + + expect(encoded.body).toEqual(body); + }); + + it('keeps the headers and the status code', () => { + const encoded = encodeBufferBody(fullResponse(Buffer.from('hello'))); + + expect(encoded.headers).toEqual({ 'content-type': 'application/octet-stream' }); + expect(encoded.statusCode).toBe(200); + }); +}); + +describe('decodeBufferBody', () => { + it('restores a base64 envelope to a Buffer', () => { + const response = fullResponse({ [ENCODED_BUFFER_KEY]: 'aGVsbG8=' }); + + const decoded = decodeBufferBody(response); + + expect(decoded).toBe(response); + expect(decoded.body).toEqual(Buffer.from('hello')); + }); + + it('round-trips arbitrary bytes through JSON', () => { + const bytes = Buffer.from([0x00, 0xff, 0x10, 0x80]); + + // `deepCopy` is a JSON round trip, the same transform a JSON transport applies. + const relayed: unknown = deepCopy(encodeBufferBody(fullResponse(bytes))); + const decoded = decodeBufferBody(relayed) as IN8nHttpFullResponse; + + expect(decoded.body).toEqual(bytes); + }); + + it('round-trips an empty Buffer', () => { + const decoded = decodeBufferBody(encodeBufferBody(fullResponse(Buffer.alloc(0)))); + + expect(decoded.body).toEqual(Buffer.alloc(0)); + }); + + it.each([ + ['a JSON body', { hello: 'world' }], + ['a string body', 'hello'], + ['a null body', null], + ['a binary-data reference', { binaryData: { id: 'filesystem-v2:abc' } }], + ['an envelope whose payload is not a string', { [ENCODED_BUFFER_KEY]: 42 }], + ])('leaves %s untouched', (_label, body) => { + const decoded = decodeBufferBody(fullResponse(body)); + + expect(decoded.body).toEqual(body); + }); + + it('leaves a payload that is not a full response untouched', () => { + const payload = { toolResult: 'done' }; + + expect(decodeBufferBody(payload)).toBe(payload); + }); +}); diff --git a/packages/core/src/webhook-response/buffer-body-codec.ts b/packages/core/src/webhook-response/buffer-body-codec.ts new file mode 100644 index 00000000000..131991fcd5d --- /dev/null +++ b/packages/core/src/webhook-response/buffer-body-codec.ts @@ -0,0 +1,58 @@ +import type { IN8nHttpFullResponse } from 'n8n-workflow'; +import { BINARY_ENCODING } from 'n8n-workflow'; + +/** + * Sentinel key that marks a base64-encoded Buffer body sent inline through a + * JSON transport. `JSON.stringify` turns a bare Buffer into + * `{ type: 'Buffer', data: [...] }`, so a Buffer body has to be wrapped before + * it is serialized and unwrapped after it is parsed. + */ +export const ENCODED_BUFFER_KEY = '__@N8nEncodedBuffer@__'; + +/** + * Replaces a Buffer body with its base64 envelope. Any other body passes through. + * + * @param response Full response. Mutated and returned. + * @returns The same `response`. + */ +export function encodeBufferBody(response: T): T { + if (Buffer.isBuffer(response.body)) { + response.body = { [ENCODED_BUFFER_KEY]: response.body.toString(BINARY_ENCODING) }; + } + + return response; +} + +/** + * Reverses the base64 envelope, restoring a Buffer body. Every other value + * passes through, a payload that is not a full response included. + * + * @param response Parsed payload. Mutated and returned. + * @returns The same `response`. + */ +export function decodeBufferBody(response: T): T { + if (!hasResponseBody(response)) { + return response; + } + + const encoded = encodedBufferIn(response.body); + if (encoded !== undefined) { + response.body = Buffer.from(encoded, BINARY_ENCODING); + } + + return response; +} + +function hasResponseBody(response: unknown): response is IN8nHttpFullResponse { + return typeof response === 'object' && response !== null && 'body' in response; +} + +/** The base64 payload of an {@link ENCODED_BUFFER_KEY} envelope, if the body is one. */ +function encodedBufferIn(body: IN8nHttpFullResponse['body']): string | undefined { + if (typeof body !== 'object' || body === null || !(ENCODED_BUFFER_KEY in body)) { + return undefined; + } + + const encoded = body[ENCODED_BUFFER_KEY]; + return typeof encoded === 'string' ? encoded : undefined; +} diff --git a/packages/core/src/webhook-response/index.ts b/packages/core/src/webhook-response/index.ts new file mode 100644 index 00000000000..8fb3c3af30e --- /dev/null +++ b/packages/core/src/webhook-response/index.ts @@ -0,0 +1 @@ +export { ENCODED_BUFFER_KEY, encodeBufferBody, decodeBufferBody } from './buffer-body-codec'; diff --git a/packages/frontend/@n8n/design-system/package.json b/packages/frontend/@n8n/design-system/package.json index 9aaae34a07f..c9735660e0d 100644 --- a/packages/frontend/@n8n/design-system/package.json +++ b/packages/frontend/@n8n/design-system/package.json @@ -62,6 +62,7 @@ "@testing-library/jest-dom": "catalog:frontend", "@testing-library/user-event": "catalog:frontend", "@testing-library/vue": "catalog:frontend", + "@types/dateformat": "^3.0.0", "@types/lodash": "catalog:", "@types/markdown-it": "catalog:", "@types/markdown-it-emoji": "^2.0.2", @@ -110,6 +111,7 @@ "@tiptap/vue-3": "catalog:", "@vueuse/core": "catalog:frontend", "clsx": "^2.1.1", + "dateformat": "^3.0.3", "element-plus": "catalog:frontend", "highlight.js": "catalog:frontend", "is-emoji-supported": "^0.0.5", @@ -121,6 +123,7 @@ "parse-diff": "^0.11.1", "reka-ui": "^2.5.0", "sanitize-html": "catalog:", + "timeago.js": "^4.0.2", "vue-boring-avatars": "^1.3.0", "xss": "catalog:" }, diff --git a/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenu.constants.ts b/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenu.constants.ts new file mode 100644 index 00000000000..85dca8b3a45 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenu.constants.ts @@ -0,0 +1,8 @@ +export const COLLISION_PADDING_PX = 8; + +export const ITEMS_PADDING_PX = 4; + +/** Shift the submenu up so its first row lines up with the trigger. */ +export const SUBMENU_ALIGN_OFFSET_PX = -ITEMS_PADDING_PX; + +export const SUBMENU_SIDE_OFFSET_PX = 1; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenu.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenu.stories.ts new file mode 100644 index 00000000000..f1fb95d1505 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenu.stories.ts @@ -0,0 +1,1022 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite'; +import { computed, onMounted, ref } from 'vue'; + +import type { ContextMenuLeaf, ContextMenuNode, ContextMenuProps } from './ContextMenu.types'; +import ContextMenu from './ContextMenu.vue'; +import N8nAvatar from '../N8nAvatar/Avatar.vue'; +import N8nBadge from '../N8nBadge/Badge.vue'; +import N8nButton from '../N8nButton/Button.vue'; +import N8nIcon from '../N8nIcon/Icon.vue'; +import N8nKeyboardShortcut from '../N8nKeyboardShortcut/N8nKeyboardShortcut.vue'; + +type GenericMeta = Omit, 'component'> & { + component: Record; +}; + +const exampleItems: Array> = [ + { + type: 'group', + id: 'edit', + children: [ + { + type: 'item', + id: 'open', + label: 'Open', + icon: { type: 'icon', value: 'external-link' }, + }, + { + type: 'item', + id: 'rename', + label: 'Rename', + shortcut: { keys: ['Space'] }, + }, + ], + }, + { + type: 'submenu', + id: 'export', + label: 'Export as…', + icon: { type: 'icon', value: 'download' }, + children: [ + { type: 'item', id: 'export-json', label: 'JSON' }, + { + type: 'submenu', + id: 'export-csv-options', + label: 'CSV', + children: [ + { type: 'item', id: 'export-csv-comma', label: 'Comma' }, + { type: 'item', id: 'export-csv-tab', label: 'Tab' }, + ], + }, + ], + }, + { + type: 'radio-group', + id: 'snap', + label: 'Snap', + children: [ + { type: 'radio', id: 'snap-off', label: 'Off' }, + { type: 'radio', id: 'snap-grid', label: 'Grid' }, + ], + }, + { + type: 'group', + id: 'view', + children: [ + { type: 'checkbox', id: 'show-grid', label: 'Show grid' }, + { type: 'checkbox', id: 'show-minimap', label: 'Show minimap' }, + ], + }, + { + type: 'group', + id: 'danger', + children: [ + { + type: 'item', + id: 'delete', + label: 'Delete', + icon: { type: 'icon', value: 'trash' }, + variant: 'destructive', + }, + ], + }, +]; + +const checkboxItems: Array> = [ + { + type: 'group', + id: 'view', + label: 'View', + children: [ + { type: 'checkbox', id: 'show-grid', label: 'Show grid' }, + { type: 'checkbox', id: 'show-minimap', label: 'Show minimap' }, + { type: 'checkbox', id: 'show-rulers', label: 'Show rulers' }, + { type: 'checkbox', id: 'snap-to-objects', label: 'Snap to objects', disabled: true }, + ], + }, +]; + +function buildNestedSubmenus(levels: number): ContextMenuNode { + let children: ContextMenuNode[] = [ + { type: 'item', id: `action-${levels}`, label: `Action at level ${levels}` }, + ]; + + for (let level = levels; level >= 2; level--) { + children = [ + { type: 'item', id: `peek-${level}`, label: `Item at level ${level}` }, + { + type: 'submenu', + id: `level-${level}`, + label: `Level ${level}…`, + children, + }, + ]; + } + + return { + type: 'submenu', + id: 'level-1', + label: 'Level 1…', + icon: { type: 'icon', value: 'layers' }, + children, + }; +} + +const nestedItems: Array> = [buildNestedSubmenus(12)]; + +const alignedSubmenuItems: Array> = [ + { + type: 'submenu', + id: 'file', + label: 'File', + icon: { type: 'icon', value: 'file' }, + children: [ + { + type: 'submenu', + id: 'file-new', + label: 'New', + icon: { type: 'icon', value: 'plus' }, + children: [ + { + type: 'submenu', + id: 'file-new-from', + label: 'From template', + children: [ + { type: 'item', id: 'file-new-http', label: 'HTTP Request' }, + { type: 'item', id: 'file-new-ai', label: 'AI Agent' }, + ], + }, + { type: 'item', id: 'file-new-workflow', label: 'Workflow' }, + { type: 'item', id: 'file-new-credential', label: 'Credential' }, + ], + }, + { type: 'item', id: 'file-open', label: 'Open' }, + { type: 'item', id: 'file-save', label: 'Save' }, + ], + }, + { + type: 'submenu', + id: 'edit', + label: 'Edit', + icon: { type: 'icon', value: 'pen' }, + children: [ + { + type: 'submenu', + id: 'edit-copy', + label: 'Copy as…', + icon: { type: 'icon', value: 'copy' }, + children: [ + { + type: 'submenu', + id: 'edit-copy-format', + label: 'Format', + children: [ + { type: 'item', id: 'edit-copy-json', label: 'JSON' }, + { type: 'item', id: 'edit-copy-yaml', label: 'YAML' }, + ], + }, + { type: 'item', id: 'edit-copy-path', label: 'Path' }, + { type: 'item', id: 'edit-copy-id', label: 'ID' }, + ], + }, + { type: 'item', id: 'edit-paste', label: 'Paste' }, + { type: 'item', id: 'edit-duplicate', label: 'Duplicate' }, + ], + }, + { + type: 'submenu', + id: 'view', + label: 'View', + icon: { type: 'icon', value: 'eye' }, + children: [ + { + type: 'submenu', + id: 'view-zoom', + label: 'Zoom', + children: [ + { + type: 'submenu', + id: 'view-zoom-preset', + label: 'Preset', + children: [ + { type: 'item', id: 'view-zoom-50', label: '50%' }, + { type: 'item', id: 'view-zoom-100', label: '100%' }, + { type: 'item', id: 'view-zoom-200', label: '200%' }, + ], + }, + { type: 'item', id: 'view-zoom-in', label: 'Zoom in' }, + { type: 'item', id: 'view-zoom-out', label: 'Zoom out' }, + ], + }, + { type: 'item', id: 'view-grid', label: 'Show grid' }, + { type: 'item', id: 'view-minimap', label: 'Show minimap' }, + ], + }, + { + type: 'submenu', + id: 'share', + label: 'Share', + icon: { type: 'icon', value: 'share' }, + children: [ + { + type: 'submenu', + id: 'share-with', + label: 'Share with…', + icon: { type: 'icon', value: 'user' }, + children: [ + { + type: 'submenu', + id: 'share-with-team', + label: 'Team', + children: [ + { type: 'item', id: 'share-team-design', label: 'Design' }, + { type: 'item', id: 'share-team-engineering', label: 'Engineering' }, + ], + }, + { type: 'item', id: 'share-ada', label: 'Ada Lovelace' }, + { type: 'item', id: 'share-grace', label: 'Grace Hopper' }, + ], + }, + { type: 'item', id: 'share-link', label: 'Copy link' }, + { type: 'item', id: 'share-embed', label: 'Embed' }, + ], + }, + { + type: 'submenu', + id: 'export', + label: 'Export', + icon: { type: 'icon', value: 'download' }, + children: [ + { + type: 'submenu', + id: 'export-as', + label: 'Export as…', + children: [ + { + type: 'submenu', + id: 'export-spreadsheet', + label: 'Spreadsheet', + children: [ + { type: 'item', id: 'export-xlsx', label: 'Excel' }, + { type: 'item', id: 'export-csv', label: 'CSV' }, + ], + }, + { type: 'item', id: 'export-json', label: 'JSON' }, + { type: 'item', id: 'export-png', label: 'PNG' }, + ], + }, + { type: 'item', id: 'export-print', label: 'Print' }, + ], + }, +]; + +const teamUserNames = [ + 'Ada Lovelace', + 'Alan Turing', + 'Grace Hopper', + 'Katherine Johnson', + 'Margaret Hamilton', + 'Dorothy Vaughan', + 'Mary Jackson', + 'Hedy Lamarr', + 'Annie Easley', + 'Radia Perlman', + 'Barbara Liskov', + 'Frances Allen', + 'Jean Bartik', + 'Betty Holberton', + 'Marlyn Meltzer', + 'Kathleen Antonelli', + 'Ruth Teitelbaum', + 'Shafi Goldwasser', + 'Fei-Fei Li', + 'Timnit Gebru', + 'Joy Buolamwini', + 'Yoshua Bengio', + 'Geoffrey Hinton', + 'Yann LeCun', + 'Andrew Ng', + 'Demis Hassabis', + 'Satya Nadella', + 'Reshma Saujani', + 'Limor Fried', + 'Lynn Conway', + 'Sophie Wilson', + 'Steve Wozniak', + 'Vint Cerf', + 'Tim Berners-Lee', + 'Guido van Rossum', + 'Bjarne Stroustrup', + 'Ken Thompson', + 'Dennis Ritchie', + 'Brian Kernighan', + 'Donald Knuth', +]; + +const customWidthItems: Array> = [ + { + type: 'group', + id: 'actions', + children: [ + { + type: 'item', + id: 'duplicate', + label: 'Duplicate this workflow to another project', + icon: { type: 'icon', value: 'copy' }, + }, + { + type: 'item', + id: 'rename', + label: 'Rename this workflow to something more descriptive', + }, + ], + }, + { + type: 'submenu', + id: 'export', + label: 'Export this workflow as another format…', + icon: { type: 'icon', value: 'download' }, + children: [ + { type: 'item', id: 'export-json', label: 'JSON with pretty-printed indentation' }, + { type: 'item', id: 'export-csv', label: 'CSV with comma-separated values' }, + ], + }, + { + type: 'group', + id: 'danger', + children: [ + { + type: 'item', + id: 'delete', + label: 'Delete this workflow from the project', + icon: { type: 'icon', value: 'trash' }, + variant: 'destructive', + }, + ], + }, +]; + +const heightConstrainedItems: Array> = teamUserNames.map( + (name): ContextMenuNode => ({ + type: 'item', + id: `item-${name.toLowerCase().replaceAll(' ', '-')}`, + label: name, + }), +); + +function splitPersonName(fullName: string) { + const lastSpace = fullName.lastIndexOf(' '); + if (lastSpace === -1) { + return { firstName: fullName, lastName: '' }; + } + return { + firstName: fullName.slice(0, lastSpace), + lastName: fullName.slice(lastSpace + 1), + }; +} + +const radioItems: Array> = [ + { + type: 'radio-group', + id: 'snap', + label: 'Snap', + children: [ + { type: 'radio', id: 'snap-off', label: 'Off' }, + { type: 'radio', id: 'snap-grid', label: 'Grid' }, + { type: 'radio', id: 'snap-guides', label: 'Guides', disabled: true }, + ], + }, + { + type: 'radio-group', + id: 'theme', + label: 'Theme', + children: [ + { type: 'radio', id: 'theme-system', label: 'System' }, + { type: 'radio', id: 'theme-light', label: 'Light' }, + { type: 'radio', id: 'theme-dark', label: 'Dark' }, + ], + }, +]; + +const storyTriggerStyle = ` +.context-menu-story { + width: 100%; +} +.context-menu-story-trigger { + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: 100%; + height: 12rem; + flex-shrink: 0; + padding: var(--spacing--lg); + border: var(--border-width) dashed var(--border-color); + border-radius: var(--radius--lg); + color: var(--text-color--subtler); + text-align: center; + background-color: transparent; + cursor: context-menu; + transition: + background-color var(--duration--snappy) var(--easing--ease-out), + border-color var(--duration--snappy) var(--easing--ease-out); +} +.context-menu-story-trigger:hover:not(.context-menu-story-trigger--disabled):not([data-disabled]) { + background-color: var(--background--hover); + border-color: var(--border-color--strong); +} +.context-menu-story-trigger--disabled, +.context-menu-story-trigger[data-disabled] { + color: var(--text-color--disabled); + border-color: var(--border-color--subtle); + background-color: var(--background--disabled); + cursor: not-allowed; +} +`; + +function leadingIconColor(item: ContextMenuLeaf) { + return item.type === 'item' && item.variant === 'destructive' + ? '--icon-color--danger' + : '--icon-color'; +} + +const meta = { + title: 'Core/ContextMenu', + component: ContextMenu, + tags: ['autodocs'], + parameters: { + docs: { + source: { type: 'dynamic' }, + description: { + component: 'A menu that opens at the pointer on right-click or long-press.', + }, + }, + }, + argTypes: { + items: { + control: 'object', + description: 'Menu tree to render', + }, + selectedValues: { + control: 'object', + description: + 'Controlled selected ids for checkbox and radio items. Supports v-model:selectedValues.', + }, + defaultSelectedValues: { + control: 'object', + description: 'Initial selected ids when uncontrolled', + }, + disabled: { + control: 'boolean', + description: 'Disable the trigger', + }, + loading: { + control: 'boolean', + description: 'Show skeleton rows instead of items', + }, + loadingItemCount: { + control: 'number', + description: 'Number of skeleton rows while loading', + }, + contentClass: { + control: 'text', + description: + 'Extra CSS class for every panel. Set --context-menu--width for a fixed panel width.', + }, + modal: { + control: 'boolean', + description: 'Block pointer events on the rest of the page while open', + }, + }, + decorators: [ + () => ({ + setup() { + return { storyTriggerStyle }; + }, + template: ` +

+ {{ storyTriggerStyle }} + +
+ `, + }), + ], +} satisfies GenericMeta>; + +export default meta; + +type Story = Omit>, 'render'> & { + render?: (args: ContextMenuProps) => unknown; +}; + +function logSelect(action: string) { + console.log('Selected:', action); +} + +function renderMenuStory(args: ContextMenuProps) { + return { + components: { ContextMenu }, + setup() { + return { args, logSelect }; + }, + template: ` + + + + `, + }; +} + +const defaultStoryArgs = { + items: exampleItems, + disabled: false, + loading: false, + loadingItemCount: 3, + modal: true, +}; + +export const Default: Story = { + render: (args) => renderMenuStory(args), + args: defaultStoryArgs, +}; + +export const ControlledUncontrolled: Story = { + name: 'Controlled/Uncontrolled', + render: (args) => ({ + components: { ContextMenu, N8nButton }, + setup() { + const selectedValues = ref(['show-grid']); + const presets: Array<{ label: string; values: string[] }> = [ + { label: 'Grid only', values: ['show-grid'] }, + { label: 'Grid + minimap', values: ['show-grid', 'show-minimap'] }, + { label: 'Clear', values: [] }, + ]; + return { args, checkboxItems, selectedValues, presets, logSelect }; + }, + template: ` +
+
+

+ Controlled +

+

+ Selected: {{ selectedValues.length ? selectedValues.join(', ') : '(empty)' }} +

+ + + +
+ + {{ preset.label }} + +
+
+
+

+ Uncontrolled +

+ + + +
+
+ `, + }), + args: { + items: checkboxItems, + }, +}; + +export const WithRadios: Story = { + render: (args) => renderMenuStory(args), + args: { + items: radioItems, + defaultSelectedValues: ['snap-grid', 'theme-system'], + }, +}; + +export const WithCheckboxes: Story = { + render: (args) => renderMenuStory(args), + args: { + items: checkboxItems, + defaultSelectedValues: ['show-grid'], + }, +}; + +export const WithSlots: Story = { + name: 'With Slots', + render: () => ({ + components: { ContextMenu, N8nBadge, N8nIcon, N8nKeyboardShortcut }, + setup() { + const items: Array> = [ + { + type: 'group', + id: 'file', + children: [ + { + type: 'item', + id: 'open', + label: 'Open', + icon: { type: 'icon', value: 'external-link' }, + shortcut: { metaKey: true, keys: ['O'] }, + }, + { + type: 'item', + id: 'share', + label: 'Share', + icon: { type: 'icon', value: 'share-2' }, + }, + { + type: 'item', + id: 'color', + label: 'Set color', + keepOpen: true, + }, + ], + }, + { + type: 'submenu', + id: 'assign', + label: 'Assign to…', + icon: { type: 'icon', value: 'user' }, + children: [ + { type: 'item', id: 'user-ada', label: 'Ada Lovelace' }, + { type: 'item', id: 'user-grace', label: 'Grace Hopper' }, + { type: 'item', id: 'user-alan', label: 'Alan Turing' }, + ], + }, + { + type: 'group', + id: 'danger', + children: [ + { + type: 'item', + id: 'delete', + label: 'Delete', + icon: { type: 'icon', value: 'trash' }, + variant: 'destructive', + }, + ], + }, + ]; + + return { items, logSelect, leadingIconColor }; + }, + template: ` + + + + + + + + + + `, + }), + args: { + items: [], + }, +}; + +export const AsyncLoading: Story = { + name: 'Async Loading', + render: () => ({ + components: { ContextMenu, N8nButton }, + setup() { + const items = ref>>([]); + const loading = ref(false); + + async function loadItems() { + loading.value = true; + items.value = []; + await new Promise((resolve) => setTimeout(resolve, 1500)); + items.value = exampleItems; + loading.value = false; + } + + onMounted(() => { + void loadItems(); + }); + + return { items, loading, loadItems, logSelect }; + }, + template: ` +
+ + + + +
+ `, + }), + args: { + items: exampleItems, + }, +}; + +export const MoreLevels: Story = { + name: 'More Levels', + render: (args) => ({ + components: { ContextMenu }, + setup() { + return { args, logSelect }; + }, + template: ` + + + + `, + }), + args: { + items: nestedItems, + }, +}; + +export const AlignedSubmenus: Story = { + name: 'Aligned Submenus', + render: (args) => ({ + components: { ContextMenu }, + setup() { + return { args, logSelect }; + }, + template: ` + + + + `, + }), + args: { + items: alignedSubmenuItems, + }, +}; + +export const HeightConstrained: Story = { + name: 'Height Constrained', + render: (args) => ({ + components: { ContextMenu }, + setup() { + const panelStyle = + '.context-menu-height-constrained { max-height: var(--spacing--5xl) !important; }'; + return { args, logSelect, panelStyle }; + }, + template: ` +
+ {{ panelStyle }} + + + +
+ `, + }), + args: { + items: heightConstrainedItems, + }, +}; + +export const CustomWidth: Story = { + name: 'Custom Width', + render: (args) => ({ + components: { ContextMenu }, + setup() { + const customWidthStyle = '.context-menu-custom-width { --context-menu--width: 20rem; }'; + return { args, logSelect, customWidthStyle }; + }, + template: ` +
+ {{ customWidthStyle }} + + + +
+ `, + }), + args: { + items: customWidthItems, + }, +}; + +export const Loading: Story = { + render: (args) => renderMenuStory(args), + args: { + items: exampleItems, + loading: true, + loadingItemCount: 10, + }, +}; + +export const Disabled: Story = { + render: (args) => renderMenuStory(args), + args: { + items: exampleItems, + disabled: true, + }, +}; + +export const DisabledItems: Story = { + render: (args) => ({ + components: { ContextMenu }, + setup() { + return { args, logSelect }; + }, + template: ` + + + + `, + }), + args: { + items: [ + { + type: 'group', + id: 'actions', + children: [ + { type: 'item', id: 'edit', label: 'Edit', icon: { type: 'icon', value: 'pen' } }, + { + type: 'item', + id: 'duplicate', + label: 'Duplicate', + icon: { type: 'icon', value: 'copy' }, + disabled: true, + }, + { + type: 'item', + id: 'delete', + label: 'Delete', + icon: { type: 'icon', value: 'trash' }, + variant: 'destructive', + }, + ], + }, + ] satisfies Array>, + }, +}; + +export const LazySubmenu: Story = { + render: () => ({ + components: { ContextMenu, N8nAvatar, N8nIcon }, + setup() { + const users = ref>([]); + const usersLoading = ref(true); + + const items = computed>>(() => [ + { + type: 'submenu', + id: 'assign', + label: 'Assign to…', + icon: { type: 'icon', value: 'user' }, + loading: usersLoading.value, + loadingItemCount: 4, + children: users.value, + }, + ]); + + function isUserItem(item: ContextMenuNode) { + return item.type === 'item' && item.id.startsWith('user-'); + } + + function onSubmenuToggle(id: string, open: boolean) { + if (id !== 'assign' || !open || !usersLoading.value) return; + window.setTimeout(() => { + users.value = [ + { type: 'item', id: 'user-ada', label: 'Ada Lovelace' }, + { type: 'item', id: 'user-grace', label: 'Grace Hopper' }, + { type: 'item', id: 'user-alan', label: 'Alan Turing' }, + ]; + usersLoading.value = false; + }, 800); + } + + return { items, onSubmenuToggle, logSelect, isUserItem, splitPersonName, leadingIconColor }; + }, + template: ` + + + + + `, + }), + args: { + items: exampleItems, + }, +}; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenu.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenu.test.ts new file mode 100644 index 00000000000..d216b344479 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenu.test.ts @@ -0,0 +1,784 @@ +import userEvent from '@testing-library/user-event'; +import { fireEvent, render, waitFor } from '@testing-library/vue'; + +import type { ContextMenuId, ContextMenuNode, ContextMenuProps } from './ContextMenu.types'; +import ContextMenu from './ContextMenu.vue'; + +vi.mock('../N8nKeyboardShortcut', () => ({ + N8nKeyboardShortcut: { + name: 'N8nKeyboardShortcut', + props: ['keys', 'metaKey', 'shiftKey', 'altKey'], + template: '{{ keys?.join("") }}', + }, +})); + +const createItems = (count: number): Array> => { + return Array.from({ length: count }, (_, i) => ({ + type: 'item', + id: `item-${i}`, + label: `Item ${i}`, + })); +}; + +const defaultSlots = { + trigger: '', +}; + +const keyboardShortcutStub = { + props: ['keys', 'metaKey', 'shiftKey', 'altKey'], + template: '{{ keys?.join("") }}', +}; + +async function getContextMenu() { + const menu = await waitFor(() => { + const el = document.querySelector('[data-test-id="context-menu"]'); + if (!el) throw new Error('Context menu not found'); + return el as HTMLElement; + }); + + return { menu }; +} + +async function openWithRightClick(element: HTMLElement) { + await fireEvent.contextMenu(element); +} + +function renderMenu(options: { + props: ContextMenuProps; + slots?: Record; +}) { + return render(ContextMenu, { + ...options, + global: { + stubs: { N8nKeyboardShortcut: keyboardShortcutStub }, + }, + }); +} + +async function renderOpen( + props: ContextMenuProps, + slots: Record = defaultSlots, +) { + const wrapper = renderMenu({ props, slots }); + await openWithRightClick(wrapper.getByTestId('custom-trigger')); + await getContextMenu(); + return wrapper; +} + +describe('N8nContextMenu', () => { + describe('rendering', () => { + it('should render custom trigger via slot', () => { + const wrapper = renderMenu({ + props: { + items: createItems(3), + }, + slots: defaultSlots, + }); + + expect(wrapper.getByTestId('custom-trigger')).toBeInTheDocument(); + }); + + it('should not show menu content initially', () => { + renderMenu({ + props: { + items: createItems(3), + }, + slots: defaultSlots, + }); + + expect(document.querySelector('[data-test-id="context-menu"]')).not.toBeInTheDocument(); + }); + }); + + describe('opening and closing', () => { + it('should open on right-click and emit update:open', async () => { + const wrapper = renderMenu({ + props: { + items: createItems(3), + }, + slots: defaultSlots, + }); + + await openWithRightClick(wrapper.getByTestId('custom-trigger')); + + const { menu } = await getContextMenu(); + expect(menu).toBeInTheDocument(); + expect(wrapper.emitted('update:open')?.[0]).toEqual([true]); + }); + + it('should close on Escape and emit update:open false', async () => { + const wrapper = renderMenu({ + props: { + items: createItems(3), + }, + slots: defaultSlots, + }); + + await openWithRightClick(wrapper.getByTestId('custom-trigger')); + await getContextMenu(); + + await userEvent.keyboard('{Escape}'); + await waitFor(() => { + expect(document.querySelector('[data-test-id="context-menu"]')).not.toBeInTheDocument(); + }); + const emits = wrapper.emitted('update:open'); + expect(emits?.[emits.length - 1]).toEqual([false]); + }); + }); + + describe('items rendering', () => { + it('should render item labels', async () => { + const items: Array> = [ + { type: 'item', id: '1', label: 'First Option' }, + { type: 'item', id: '2', label: 'Second Option' }, + ]; + + await renderOpen({ items }); + + expect(document.querySelectorAll('[role="menuitem"]')).toHaveLength(2); + expect(document.querySelector('[data-test-id="context-menu"]')?.textContent).toContain( + 'First Option', + ); + expect(document.querySelector('[data-test-id="context-menu"]')?.textContent).toContain( + 'Second Option', + ); + }); + + it('should apply an explicit icon color when the item supplies one', async () => { + const items: Array> = [ + { + type: 'item', + id: 'folder', + label: 'Folder', + icon: { type: 'icon', value: 'folder', color: '--node--icon--color--blue' }, + }, + ]; + + await renderOpen({ items }); + + expect(document.querySelector('[data-icon="folder"]')).toHaveStyle({ + color: 'var(--node--icon--color--blue)', + }); + }); + + it('should use the tone-derived icon color when the item has no icon color', async () => { + const items: Array> = [ + { + type: 'item', + id: 'open', + label: 'Open', + icon: { type: 'icon', value: 'external-link' }, + }, + { + type: 'item', + id: 'locked', + label: 'Locked', + disabled: true, + icon: { type: 'icon', value: 'lock' }, + }, + { + type: 'item', + id: 'delete', + label: 'Delete', + variant: 'destructive', + icon: { type: 'icon', value: 'trash' }, + }, + ]; + + await renderOpen({ items }); + + expect(document.querySelector('[data-icon="external-link"]')).toHaveStyle({ + color: 'var(--icon-color)', + }); + expect(document.querySelector('[data-icon="lock"]')).toHaveStyle({ + color: 'var(--icon-color--subtle)', + }); + expect(document.querySelector('[data-icon="trash"]')).toHaveStyle({ + color: 'var(--icon-color--danger)', + }); + }); + + it('should render disabled items', async () => { + const items: Array> = [ + { type: 'item', id: '1', label: 'Enabled', disabled: false }, + { type: 'item', id: '2', label: 'Disabled', disabled: true }, + ]; + + await renderOpen({ items }); + + await waitFor(() => { + const menuItems = document.querySelectorAll('[role="menuitem"]'); + expect(menuItems[1]).toHaveAttribute('data-disabled'); + }); + }); + + it('should render a group label as a non-interactive header', async () => { + const items: Array> = [ + { + type: 'group', + id: 'section', + label: 'Section title', + children: [{ type: 'item', id: 'option-1', label: 'Option 1' }], + }, + ]; + + await renderOpen({ items }); + + expect(document.body.textContent).toContain('Section title'); + expect(document.querySelectorAll('[role="menuitem"]')).toHaveLength(1); + expect(document.querySelector('[class*="separated"]')).not.toBeInTheDocument(); + }); + + it('should render a separator before a section that follows another node', async () => { + const items: Array> = [ + { type: 'item', id: 'open', label: 'Open' }, + { + type: 'group', + id: 'view', + children: [{ type: 'item', id: 'grid', label: 'Grid' }], + }, + ]; + + await renderOpen({ items }); + + expect(document.querySelector('[class*="separated"]')).toBeInTheDocument(); + }); + + it('should render mixed item, checkbox, and radio rows', async () => { + const items: Array> = [ + { type: 'item', id: 'open', label: 'Open' }, + { type: 'checkbox', id: 'show-grid', label: 'Show grid' }, + { + type: 'radio-group', + id: 'snap', + label: 'Snap', + children: [ + { type: 'radio', id: 'snap-off', label: 'Off' }, + { type: 'radio', id: 'snap-grid', label: 'Grid' }, + ], + }, + ]; + + await renderOpen({ items }); + + await waitFor(() => { + expect(document.querySelectorAll('[role="menuitem"]')).toHaveLength(1); + expect(document.querySelectorAll('[role="menuitemcheckbox"]')).toHaveLength(1); + expect(document.querySelectorAll('[role="menuitemradio"]')).toHaveLength(2); + expect(document.body.textContent).toContain('Snap'); + }); + }); + + it('should apply a custom class on a group', async () => { + const items: Array> = [ + { + type: 'group', + id: 'edit', + class: 'custom-group', + children: [{ type: 'item', id: 'open', label: 'Open' }], + }, + ]; + + await renderOpen({ items }); + + expect(document.querySelector('.custom-group')).toBeInTheDocument(); + }); + }); + + describe('select event', () => { + it('should emit select and close the menu when an item is clicked', async () => { + const items: Array> = [ + { type: 'item', id: 'option-1', label: 'Option 1' }, + { type: 'item', id: 'option-2', label: 'Option 2' }, + ]; + + const wrapper = await renderOpen({ items }); + + const menuItems = document.querySelectorAll('[role="menuitem"]'); + await userEvent.click(menuItems[0]); + + await waitFor(() => { + expect(wrapper.emitted('select')?.[0]).toEqual(['option-1']); + }); + await waitFor(() => { + expect(document.querySelector('[data-test-id="context-menu"]')).not.toBeInTheDocument(); + }); + }); + + it('should keep the menu open after selecting a keepOpen item', async () => { + const items: Array> = [ + { type: 'item', id: 'toggle', label: 'Toggle', keepOpen: true }, + ]; + + const wrapper = await renderOpen({ items }); + + const menuItem = document.querySelector('[role="menuitem"]')!; + await userEvent.click(menuItem); + + await waitFor(() => { + expect(wrapper.emitted('select')?.[0]).toEqual(['toggle']); + }); + expect(document.querySelector('[data-test-id="context-menu"]')).toBeInTheDocument(); + }); + + it('should not emit select event for disabled items', async () => { + const items: Array> = [ + { type: 'item', id: 'disabled-item', label: 'Disabled', disabled: true }, + ]; + + const wrapper = await renderOpen({ items }); + + const menuItem = document.querySelector('[role="menuitem"]')!; + await userEvent.click(menuItem); + + expect(wrapper.emitted('select')).toBeFalsy(); + }); + + it('should not emit select when a checkbox is toggled', async () => { + const items: Array> = [ + { type: 'checkbox', id: 'show-grid', label: 'Show grid' }, + ]; + + const wrapper = await renderOpen({ items }); + + const checkbox = document.querySelector('[role="menuitemcheckbox"]') as HTMLElement; + await userEvent.click(checkbox); + + await waitFor(() => { + expect(wrapper.emitted('update:selectedValues')?.[0]).toEqual([['show-grid']]); + }); + expect(wrapper.emitted('select')).toBeFalsy(); + expect(document.querySelector('[data-test-id="context-menu"]')).toBeInTheDocument(); + }); + + it('should not emit select when a radio is selected', async () => { + const items: Array> = [ + { + type: 'radio-group', + id: 'snap', + children: [ + { type: 'radio', id: 'snap-off', label: 'Off' }, + { type: 'radio', id: 'snap-grid', label: 'Grid' }, + ], + }, + ]; + + const wrapper = await renderOpen({ items }); + + const radios = document.querySelectorAll('[role="menuitemradio"]'); + await userEvent.click(radios[1]); + + await waitFor(() => { + expect(wrapper.emitted('update:selectedValues')?.[0]).toEqual([['snap-grid']]); + }); + expect(wrapper.emitted('select')).toBeFalsy(); + expect(document.querySelector('[data-test-id="context-menu"]')).toBeInTheDocument(); + }); + }); + + describe('checkboxes', () => { + it('should toggle a checkbox off when it is already selected', async () => { + const items: Array> = [ + { type: 'checkbox', id: 'show-grid', label: 'Show grid' }, + ]; + + const wrapper = await renderOpen({ + items, + defaultSelectedValues: ['show-grid'], + }); + + const checkbox = document.querySelector('[role="menuitemcheckbox"]') as HTMLElement; + expect(checkbox).toHaveAttribute('aria-checked', 'true'); + + await userEvent.click(checkbox); + + await waitFor(() => { + expect(wrapper.emitted('update:selectedValues')?.[0]).toEqual([[]]); + }); + }); + + it('should not toggle a disabled checkbox', async () => { + const items: Array> = [ + { type: 'checkbox', id: 'locked', label: 'Locked', disabled: true }, + ]; + + const wrapper = await renderOpen({ items }); + + const checkbox = document.querySelector('[role="menuitemcheckbox"]') as HTMLElement; + await userEvent.click(checkbox); + + expect(wrapper.emitted('update:selectedValues')).toBeFalsy(); + }); + + it('should respect controlled selectedValues', async () => { + const items: Array> = [ + { type: 'checkbox', id: 'show-grid', label: 'Show grid' }, + { type: 'checkbox', id: 'show-minimap', label: 'Show minimap' }, + ]; + + const wrapper = await renderOpen({ + items, + selectedValues: ['show-grid'], + }); + + await waitFor(() => { + const checkboxes = document.querySelectorAll('[role="menuitemcheckbox"]'); + expect(checkboxes[0]).toHaveAttribute('aria-checked', 'true'); + expect(checkboxes[1]).toHaveAttribute('aria-checked', 'false'); + }); + + await wrapper.rerender({ + items, + selectedValues: ['show-grid', 'show-minimap'], + }); + + await waitFor(() => { + const checkboxes = document.querySelectorAll('[role="menuitemcheckbox"]'); + expect(checkboxes[1]).toHaveAttribute('aria-checked', 'true'); + }); + }); + }); + + describe('radios', () => { + const radioItems: Array> = [ + { + type: 'radio-group', + id: 'snap', + label: 'Snap', + children: [ + { type: 'radio', id: 'snap-off', label: 'Off' }, + { type: 'radio', id: 'snap-grid', label: 'Grid' }, + ], + }, + { + type: 'radio-group', + id: 'theme', + label: 'Theme', + children: [ + { type: 'radio', id: 'theme-light', label: 'Light' }, + { type: 'radio', id: 'theme-dark', label: 'Dark' }, + ], + }, + ]; + + it('should replace the selected radio in the same group', async () => { + const wrapper = await renderOpen({ + items: radioItems, + defaultSelectedValues: ['snap-off', 'theme-light'], + }); + + const grid = document.querySelector( + '[data-test-id="context-menu-item-snap-grid"]', + ) as HTMLElement; + await userEvent.click(grid); + + await waitFor(() => { + const selected = ( + wrapper.emitted('update:selectedValues') as unknown as unknown[][] | undefined + )?.[0]?.[0]; + expect(selected).toEqual(expect.arrayContaining(['snap-grid', 'theme-light'])); + expect(selected).not.toContain('snap-off'); + }); + }); + + it('should select a radio nested in a group', async () => { + const items: Array> = [ + { + type: 'group', + id: 'view', + children: [ + { + type: 'radio-group', + id: 'snap', + children: [ + { type: 'radio', id: 'snap-off', label: 'Off' }, + { type: 'radio', id: 'snap-grid', label: 'Grid' }, + ], + }, + ], + }, + ]; + + const wrapper = await renderOpen({ items }); + + const grid = document.querySelector( + '[data-test-id="context-menu-item-snap-grid"]', + ) as HTMLElement; + await userEvent.click(grid); + + await waitFor(() => { + expect(wrapper.emitted('update:selectedValues')?.[0]).toEqual([['snap-grid']]); + }); + }); + + it('should not select a disabled radio', async () => { + const items: Array> = [ + { + type: 'radio-group', + id: 'snap', + children: [ + { type: 'radio', id: 'snap-off', label: 'Off' }, + { type: 'radio', id: 'snap-guides', label: 'Guides', disabled: true }, + ], + }, + ]; + + const wrapper = await renderOpen({ items }); + + const disabled = document.querySelector( + '[data-test-id="context-menu-item-snap-guides"]', + ) as HTMLElement; + await userEvent.click(disabled); + + expect(wrapper.emitted('update:selectedValues')).toBeFalsy(); + }); + }); + + describe('loading state', () => { + it('should show a loading skeleton instead of items', async () => { + await renderOpen({ + items: createItems(3), + loading: true, + }); + + expect(document.querySelectorAll('[role="menuitem"]')).toHaveLength(0); + expect(document.querySelectorAll('[class*="loading"]').length).toBeGreaterThan(0); + }); + + it('should render a custom loading slot', async () => { + await renderOpen( + { + items: [], + loading: true, + }, + { + ...defaultSlots, + loading: '
Loading…
', + }, + ); + + expect(document.querySelector('[data-test-id="custom-loading"]')).toBeInTheDocument(); + }); + }); + + describe('empty state', () => { + it('should show empty state when items array is empty', async () => { + await renderOpen({ items: [] }); + + const { menu } = await getContextMenu(); + expect(menu.textContent).toContain('No items'); + }); + + it('should render custom empty slot', async () => { + await renderOpen( + { items: [] }, + { + ...defaultSlots, + empty: '
Custom empty message
', + }, + ); + + expect(document.querySelector('[data-test-id="custom-empty"]')).toBeInTheDocument(); + }); + }); + + describe('disabled state', () => { + it('should not open when disabled', async () => { + const wrapper = renderMenu({ + props: { + items: createItems(3), + disabled: true, + }, + slots: defaultSlots, + }); + + await openWithRightClick(wrapper.getByTestId('custom-trigger')); + + expect(document.querySelector('[data-test-id="context-menu"]')).not.toBeInTheDocument(); + }); + }); + + describe('contentClass and id props', () => { + it('should apply contentClass to the menu content', async () => { + await renderOpen({ + items: createItems(3), + contentClass: 'custom-menu-class', + }); + + const { menu } = await getContextMenu(); + expect(menu).toHaveClass('custom-menu-class'); + }); + + it('should apply contentClass to submenu panels', async () => { + await renderOpen({ + items: [ + { + type: 'submenu', + id: 'export', + label: 'Export', + children: [{ type: 'item', id: 'export-json', label: 'JSON' }], + }, + ], + contentClass: 'custom-menu-class', + }); + + const trigger = document.querySelector( + '[data-test-id="context-menu-item-export"]', + ) as HTMLElement; + await userEvent.hover(trigger); + + await waitFor(() => { + const panels = document.querySelectorAll('[role="menu"]'); + expect(panels.length).toBe(2); + panels.forEach((panel) => { + expect(panel).toHaveClass('custom-menu-class'); + }); + }); + }); + + it('should apply id to the menu content', async () => { + await renderOpen({ + id: 'node-context-menu', + items: createItems(3), + }); + + const { menu } = await getContextMenu(); + expect(menu).toHaveAttribute('id', 'node-context-menu'); + }); + }); + + describe('submenus', () => { + const submenuItems: Array> = [ + { + type: 'submenu', + id: 'export', + label: 'Export as…', + children: [ + { type: 'item', id: 'export-json', label: 'JSON' }, + { + type: 'submenu', + id: 'export-csv', + label: 'CSV', + children: [ + { type: 'item', id: 'export-csv-comma', label: 'Comma' }, + { type: 'item', id: 'export-csv-tab', label: 'Tab' }, + ], + }, + ], + }, + ]; + + it('should render a submenu trigger with a chevron', async () => { + await renderOpen({ items: submenuItems }); + + expect(document.querySelector('[data-icon="chevron-right"]')).toBeInTheDocument(); + expect(document.body.textContent).toContain('Export as…'); + }); + + it('should open a submenu on hover and show children', async () => { + await renderOpen({ items: submenuItems }); + + const trigger = document.querySelector( + '[data-test-id="context-menu-item-export"]', + ) as HTMLElement; + await userEvent.hover(trigger); + + await waitFor(() => { + expect(document.body.textContent).toContain('JSON'); + expect(document.body.textContent).toContain('CSV'); + }); + }); + + it('should emit submenu:toggle when a submenu opens', async () => { + const wrapper = await renderOpen({ items: submenuItems }); + + const trigger = document.querySelector( + '[data-test-id="context-menu-item-export"]', + ) as HTMLElement; + await userEvent.hover(trigger); + + await waitFor(() => { + expect(wrapper.emitted('submenu:toggle')).toBeTruthy(); + expect(wrapper.emitted('submenu:toggle')?.[0]).toEqual(['export', true]); + }); + }); + + it('should emit select for a nested submenu item and close the menu', async () => { + const wrapper = await renderOpen({ items: submenuItems }); + + const trigger = document.querySelector( + '[data-test-id="context-menu-item-export"]', + ) as HTMLElement; + await userEvent.hover(trigger); + + const json = await waitFor(() => { + const el = document.querySelector('[data-test-id="context-menu-item-export-json"]'); + if (!el) throw new Error('Nested item not found'); + return el as HTMLElement; + }); + await userEvent.click(json); + + await waitFor(() => { + expect(wrapper.emitted('select')?.[0]).toEqual(['export-json']); + }); + await waitFor(() => { + expect(document.querySelector('[data-test-id="context-menu"]')).not.toBeInTheDocument(); + }); + }); + + it('should show loading state in a lazy submenu', async () => { + const items: Array> = [ + { + type: 'submenu', + id: 'assign', + label: 'Assign to…', + loading: true, + loadingItemCount: 4, + children: [], + }, + ]; + + await renderOpen({ items }); + + const trigger = document.querySelector( + '[data-test-id="context-menu-item-assign"]', + ) as HTMLElement; + await userEvent.hover(trigger); + + await waitFor(() => { + const loadingElements = document.querySelectorAll('[class*="loading"]'); + expect(loadingElements.length).toBeGreaterThan(0); + }); + }); + }); + + describe('custom item slots', () => { + it('should render custom item-leading, item-label, and item-trailing slots', async () => { + await renderOpen( + { items: createItems(1) }, + { + ...defaultSlots, + 'item-leading': 'Custom', + 'item-label': 'Overridden', + 'item-trailing': 'Badge', + }, + ); + + expect(document.querySelector('[data-test-id="custom-leading"]')).toBeInTheDocument(); + expect(document.querySelector('[data-test-id="custom-label"]')).toBeInTheDocument(); + expect(document.querySelector('[data-test-id="custom-trailing"]')).toBeInTheDocument(); + }); + + it('should render a custom item slot', async () => { + await renderOpen( + { items: createItems(1) }, + { + ...defaultSlots, + item: '
Custom row
', + }, + ); + + expect(document.querySelector('[data-test-id="custom-item"]')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenu.types.ts b/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenu.types.ts new file mode 100644 index 00000000000..6ca46c3e146 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenu.types.ts @@ -0,0 +1,159 @@ +import type { ClassValue } from 'clsx'; +import type { ComputedRef, InjectionKey } from 'vue'; + +import type { KeyboardShortcut } from '../../types/keyboardshortcut'; +import type { IconOrEmoji } from '../N8nIconPicker/types'; + +export type ContextMenuId = string; + +type ContextMenuLeafBase = { + id: T; + label: string; + icon?: IconOrEmoji; + shortcut?: KeyboardShortcut; + disabled?: boolean; + class?: ClassValue; +}; + +type ContextMenuSectionBase = { + id: T; + label?: string; + class?: ClassValue; +}; + +export type ContextMenuItem = ContextMenuLeafBase & { + type: 'item'; + keepOpen?: boolean; + variant?: 'default' | 'destructive'; +}; + +export type ContextMenuRadio = ContextMenuLeafBase & { + type: 'radio'; +}; + +export type ContextMenuCheckbox = + ContextMenuLeafBase & { + type: 'checkbox'; + }; + +export type ContextMenuGroup = + ContextMenuSectionBase & { + type: 'group'; + children: Array>; + }; + +export type ContextMenuSubmenu = ContextMenuLeafBase & { + type: 'submenu'; + children: Array>; + loading?: boolean; + loadingItemCount?: number; +}; + +export type ContextMenuRadioGroup = + ContextMenuSectionBase & { + type: 'radio-group'; + children: Array>; + }; + +export type ContextMenuNode = + | ContextMenuItem + | ContextMenuCheckbox + | ContextMenuGroup + | ContextMenuSubmenu + | ContextMenuRadioGroup; + +export type ContextMenuLeaf = + | ContextMenuItem + | ContextMenuRadio + | ContextMenuCheckbox + | ContextMenuSubmenu; + +export type ContextMenuProps = { + /** HTML id for the menu content element. */ + id?: string; + /** Menu tree to render. */ + items: Array>; + /** + * Controlled selected ids for checkbox and radio items. Bind with `v-model:selectedValues`. + * @defaultValue uncontrolled + */ + selectedValues?: T[]; + /** + * Selected ids on first render when `selectedValues` is not set. + * @defaultValue [] + */ + defaultSelectedValues?: T[]; + /** + * When true, the trigger cannot open the menu. + * @defaultValue false + */ + disabled?: boolean; + /** + * When true, shows skeleton rows instead of items. + * @defaultValue false + */ + loading?: boolean; + /** + * Number of skeleton rows to show while loading. + * @defaultValue 3 + */ + loadingItemCount?: number; + /** + * Extra CSS class for every panel (root and submenus). Use it for max-height + * and to set `--context-menu--width` when the panel must use a fixed width + * instead of hugging its content. + */ + contentClass?: ClassValue; + /** + * When true, blocks interaction with the rest of the page while open. + * Set to false when the rest of the page must stay interactive. + * @defaultValue true + */ + modal?: boolean; +}; + +export type ContextMenuEmits = { + (e: 'update:open', open: boolean): void; + (e: 'update:selectedValues', value: T[]): void; + (e: 'select', value: T): void; + (e: 'submenu:toggle', itemId: T, open: boolean): void; + (e: 'close-auto-focus', event: Event): void; +}; + +type SlotUiProps = { class: string }; + +export type ContextMenuSlots = { + trigger: () => void; + item?: (props: { item: ContextMenuLeaf }) => void; + ['item-leading']?: (props: { item: ContextMenuLeaf; ui: SlotUiProps }) => void; + ['item-label']?: (props: { item: ContextMenuLeaf; ui: SlotUiProps }) => void; + ['item-trailing']?: (props: { item: ContextMenuLeaf; ui: SlotUiProps }) => void; + loading?: () => void; + empty?: () => void; +}; + +export type ContextMenuItemProps = { + item: ContextMenuLeaf; +}; + +export type ContextMenuItemEmits = { + (e: 'select', value: T): void; + (e: 'toggle-checkbox', id: T): void; + (e: 'submenu:toggle', open: boolean): void; +}; + +export type ContextMenuItemSlots = Pick< + ContextMenuSlots, + 'item' | 'item-leading' | 'item-label' | 'item-trailing' | 'empty' +>; + +export type ContextMenuState = { + selectedValues: Pick, 'value'>; + contentClass: Pick, 'value'>; + onSelect(id: string): void; + onToggleCheckbox(id: string): void; + onSelectRadio(groupId: string, radioId: string): void; + onSubmenuToggle(itemId: string, open: boolean): void; +}; + +export const contextMenuStateKey: InjectionKey = Symbol('N8nContextMenuState'); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenu.utils.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenu.utils.test.ts new file mode 100644 index 00000000000..9907b280f6b --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenu.utils.test.ts @@ -0,0 +1,139 @@ +import type { ContextMenuNode, ContextMenuRadioGroup } from './ContextMenu.types'; +import { + applyCheckboxToggle, + applyRadioSelection, + findRadioGroup, + radioGroupValue, +} from './ContextMenu.utils'; + +const snapGroup: ContextMenuRadioGroup = { + type: 'radio-group', + id: 'snap', + children: [ + { type: 'radio', id: 'snap-off', label: 'Off' }, + { type: 'radio', id: 'snap-grid', label: 'Grid' }, + ], +}; + +const themeGroup: ContextMenuRadioGroup = { + type: 'radio-group', + id: 'theme', + children: [ + { type: 'radio', id: 'theme-light', label: 'Light' }, + { type: 'radio', id: 'theme-dark', label: 'Dark' }, + ], +}; + +describe('ContextMenu utils', () => { + describe('findRadioGroup', () => { + it('should find a radio group among other node types', () => { + const nodes: Array> = [ + { type: 'item', id: 'open', label: 'Open' }, + { type: 'checkbox', id: 'show-grid', label: 'Show grid' }, + snapGroup, + themeGroup, + ]; + + expect(findRadioGroup(nodes, 'theme')).toEqual(themeGroup); + }); + + it('should find a radio group nested in a group', () => { + const nodes: Array> = [ + { + type: 'group', + id: 'view', + children: [snapGroup], + }, + ]; + + expect(findRadioGroup(nodes, 'snap')).toEqual(snapGroup); + }); + + it('should find a radio group nested in a submenu', () => { + const nodes: Array> = [ + { + type: 'submenu', + id: 'more', + label: 'More', + children: [themeGroup], + }, + ]; + + expect(findRadioGroup(nodes, 'theme')).toEqual(themeGroup); + }); + + it('should not treat a group with the same id as a radio group', () => { + const nodes: Array> = [{ type: 'group', id: 'snap', children: [] }]; + + expect(findRadioGroup(nodes, 'snap')).toBeUndefined(); + }); + + it('should return undefined when the group is missing', () => { + expect(findRadioGroup([snapGroup], 'missing')).toBeUndefined(); + expect(findRadioGroup([], 'snap')).toBeUndefined(); + }); + }); + + describe('applyRadioSelection', () => { + const radioIds = ['snap-off', 'snap-grid']; + + it('should select a radio when nothing in the group is selected', () => { + expect(applyRadioSelection(['show-grid'], radioIds, 'snap-grid')).toEqual([ + 'show-grid', + 'snap-grid', + ]); + }); + + it('should replace the selected radio in the same group', () => { + expect(applyRadioSelection(['snap-off', 'show-grid'], radioIds, 'snap-grid')).toEqual([ + 'show-grid', + 'snap-grid', + ]); + }); + + it('should keep the same radio when it is already selected', () => { + expect(applyRadioSelection(['snap-grid'], radioIds, 'snap-grid')).toEqual(['snap-grid']); + }); + + it('should not remove ids from another radio group', () => { + expect(applyRadioSelection(['theme-light', 'snap-off'], radioIds, 'snap-grid')).toEqual([ + 'theme-light', + 'snap-grid', + ]); + }); + }); + + describe('applyCheckboxToggle', () => { + it('should add an id that is not selected', () => { + expect(applyCheckboxToggle(['show-grid'], 'show-minimap')).toEqual([ + 'show-grid', + 'show-minimap', + ]); + }); + + it('should remove an id that is already selected', () => { + expect(applyCheckboxToggle(['show-grid', 'show-minimap'], 'show-grid')).toEqual([ + 'show-minimap', + ]); + }); + + it('should toggle from an empty selection', () => { + expect(applyCheckboxToggle([], 'show-grid')).toEqual(['show-grid']); + }); + }); + + describe('radioGroupValue', () => { + it('should return the selected radio id in the group', () => { + expect(radioGroupValue(snapGroup, ['show-grid', 'snap-grid'])).toBe('snap-grid'); + }); + + it('should return undefined when no radio in the group is selected', () => { + expect(radioGroupValue(snapGroup, ['show-grid'])).toBeUndefined(); + expect(radioGroupValue(snapGroup, [])).toBeUndefined(); + }); + + it('should return the first matching radio when more than one is selected', () => { + expect(radioGroupValue(snapGroup, ['snap-grid', 'snap-off'])).toBe('snap-off'); + }); + }); +}); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenu.utils.ts b/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenu.utils.ts new file mode 100644 index 00000000000..8e4594f82f0 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenu.utils.ts @@ -0,0 +1,36 @@ +import type { ContextMenuId, ContextMenuNode, ContextMenuRadioGroup } from './ContextMenu.types'; + +export function findRadioGroup( + nodes: Array>, + groupId: T, +): ContextMenuRadioGroup | undefined { + for (const node of nodes) { + if (node.type === 'radio-group' && node.id === groupId) { + return node; + } + if (node.type === 'group' || node.type === 'submenu') { + const found = findRadioGroup(node.children, groupId); + if (found) return found; + } + } + return undefined; +} + +export function applyRadioSelection( + selected: T[], + radioIdsInGroup: T[], + radioId: T, +): T[] { + return [...selected.filter((id) => !radioIdsInGroup.includes(id)), radioId]; +} + +export function applyCheckboxToggle(selected: T[], id: T): T[] { + return selected.includes(id) ? selected.filter((value) => value !== id) : [...selected, id]; +} + +export function radioGroupValue( + group: ContextMenuRadioGroup, + selected: readonly string[], +): T | undefined { + return group.children.find((radio) => selected.includes(radio.id))?.id; +} diff --git a/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenu.vue b/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenu.vue new file mode 100644 index 00000000000..72b72834fba --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenu.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenuBody.vue b/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenuBody.vue new file mode 100644 index 00000000000..e5cc8750fbb --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenuBody.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenuItem.vue b/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenuItem.vue new file mode 100644 index 00000000000..a5352b333e4 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenuItem.vue @@ -0,0 +1,314 @@ + + + + + diff --git a/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenuItemContent.vue b/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenuItemContent.vue new file mode 100644 index 00000000000..31c4a507425 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenuItemContent.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenuNodes.vue b/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenuNodes.vue new file mode 100644 index 00000000000..053b896dc0d --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/ContextMenuNodes.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/component-context-menu.md b/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/component-context-menu.md new file mode 100644 index 00000000000..690f94b26df --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/component-context-menu.md @@ -0,0 +1,363 @@ +# Component specification + +A menu that opens at the pointer on right-click or long-press. Built on Reka UI `ContextMenu`. + +- **Component Name:** N8nContextMenu +- **Reka UI Component:** [Context Menu](https://reka-ui.com/docs/components/context-menu) +- **Item id:** `T extends string`. Callers can pass a string literal union. + +## Public API Definition + +**Props** + +- `id?: string` HTML id for the menu content element. +- `items: Array>` Root list. May mix rows (`item`, `checkbox`) and sections (`group`, `submenu`, `radio-group`). `radio` is not a node; nest it in `radio-group`. A separator renders before each section that follows another node. +- `selectedValues?: T[]` Controlled selected ids for checkbox and radio items. Bind with `v-model:selectedValues`. A radio selection replaces other radios in the same `radio-group`. Checkbox ids toggle on their own. +- `defaultSelectedValues?: T[]` Initial selected ids when `selectedValues` is omitted | `default: []` +- `disabled?: boolean` Disables the trigger. | `default: false` +- `loading?: boolean` Show skeleton rows instead of items. | `default: false` +- `loadingItemCount?: number` Number of skeleton rows while loading. The same default applies to `submenu.loadingItemCount`. | `default: 3` +- `contentClass?: ClassValue` Class on the root panel and every submenu panel (max-height and other constraints). Set `--context-menu--width` here when the panel must use a fixed width instead of hugging its content. +- `modal?: boolean` When `true`, blocks pointer events on the rest of the page while the menu is open. Canvas menus set this to `false`. | `default: true` + +Put extra HTML attributes, including `class`, on the `#trigger` element. They are not forwarded from the root. + +**Empty copy** + +The default empty state uses i18n key `contextMenu.noItems` (`No items`). It shows when a panel has no nodes, including an empty submenu. Pass `#empty` to replace it. + +**Icon color** + +The default leading icon uses the row tone: `--icon-color`, `--icon-color--subtle` when disabled, `--icon-color--danger` when `variant` is `destructive`. If `icon.type` is `icon` and `icon.color` is set, that value wins. `#item-leading` replaces this default, including emoji and tone handling. + +**Labels and indicators** + +Default labels ellipsize. A label of 20 characters or more gets a native `title` tooltip. A `group` or `radio-group` `label` renders as a non-interactive header. + +Checkbox and radio rows show a check on the trailing edge when selected. Submenu rows show a chevron. These sit outside `#item-trailing`. + +**Panel tokens** + +- `--context-menu--width` is unset by default. Panels then use `fit-content`, with a min of `--spacing--4xl` (8rem) and a max of `24rem`. Set it from `contentClass` to give the root panel and every submenu the same fixed width. +- Default `max-height` is `--reka-context-menu-content-available-height`. Override it from `contentClass`. +- `--context-menu--padding` is an internal variable on the item list. It aliases `--spacing--4xs` (4px). Submenu `alignOffset` is `-4` so the first submenu row lines up with the trigger. Reka `alignOffset` is a pixel number, so `ITEMS_PADDING_PX` in `ContextMenu.constants.ts` must stay equal to `--spacing--4xs`. +- Submenu `sideOffset` is `1`, which matches the 1px inset outline (`--shadow--outline`). +- Root and submenu panels set Reka `collisionPadding` to `--spacing--2xs` (8px) so the menu stays inset from the viewport edge. Reka `collisionPadding` is a pixel number, so `COLLISION_PADDING_PX` in `ContextMenu.constants.ts` must stay equal to `--spacing--2xs`. + +**Events** + +- `update:open(open: boolean)` Open state. Notification only; right-click on the trigger opens the menu. +- `update:selectedValues(value: T[])` Full selected-id list after a radio or checkbox change +- `select(value: T)` A command (`type: 'item'`) was chosen. Radio and checkbox do not emit this. Menu closes unless `keepOpen`. +- `submenu:toggle(itemId: T, open: boolean)` A `submenu` flyout opened or closed. +- `close-auto-focus(event: Event)` Focus is about to return to the trigger as the menu closes. `preventDefault` to hand focus to another layer (popover, inline rename). + +**Slots** + +- `trigger` Target element. Required. +- `item` `{ item: ContextMenuLeaf }` Replaces default `N8nContextMenuItem`. Re-render `N8nContextMenuItem` so the row keeps selection, keyboard behaviour, and submenu flyouts. +- `item-leading` `{ item: ContextMenuLeaf, ui: { class: string } }` Replaces the default icon or emoji. Bind `ui.class`. +- `item-label` `{ item: ContextMenuLeaf, ui: { class: string } }` Replaces the default label. Bind `ui.class`. +- `item-trailing` `{ item: ContextMenuLeaf, ui: { class: string } }` Replaces the default shortcut. Bind `ui.class`. Does not replace the check or submenu chevron. +- `loading` Replaces the skeleton rows on the **root** panel. A submenu with `loading: true` always uses the default skeleton. +- `empty` Empty state for a panel with no nodes (root or submenu) + +**Types** + +```typescript +import type { ClassValue } from 'clsx' +import type { IconOrEmoji, KeyboardShortcut } from '@n8n/design-system' + +type ContextMenuId = string + +type ContextMenuLeafBase = { + id: T; + label: string; + icon?: IconOrEmoji; + shortcut?: KeyboardShortcut; + disabled?: boolean; + class?: ClassValue; +}; + +type ContextMenuItem = ContextMenuLeafBase & { + type: 'item'; + keepOpen?: boolean; + variant?: 'default' | 'destructive'; +}; + +type ContextMenuRadio = ContextMenuLeafBase & { + type: 'radio'; +}; + +type ContextMenuCheckbox = ContextMenuLeafBase & { + type: 'checkbox'; +}; + +type ContextMenuGroup = { + type: 'group'; + id: T; + label?: string; + class?: ClassValue; + children: Array>; +}; + +type ContextMenuSubmenu = ContextMenuLeafBase & { + type: 'submenu'; + children: Array>; + loading?: boolean; + loadingItemCount?: number; +}; + +type ContextMenuRadioGroup = { + type: 'radio-group'; + id: T; + label?: string; + class?: ClassValue; + children: Array>; +}; + +type ContextMenuNode = + | ContextMenuItem + | ContextMenuCheckbox + | ContextMenuGroup + | ContextMenuSubmenu + | ContextMenuRadioGroup; + +type ContextMenuLeaf = + | ContextMenuItem + | ContextMenuRadio + | ContextMenuCheckbox + | ContextMenuSubmenu; +``` + +### Template usage example + +```vue + + + + + +``` diff --git a/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/index.ts b/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/index.ts new file mode 100644 index 00000000000..8cfce26d1a4 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nContextMenu/index.ts @@ -0,0 +1,19 @@ +export { default as N8nContextMenu } from './ContextMenu.vue'; +export { default as N8nContextMenuItem } from './ContextMenuItem.vue'; +export type { + ContextMenuId, + ContextMenuItem, + ContextMenuRadio, + ContextMenuCheckbox, + ContextMenuGroup, + ContextMenuSubmenu, + ContextMenuRadioGroup, + ContextMenuNode, + ContextMenuLeaf, + ContextMenuProps, + ContextMenuEmits, + ContextMenuSlots, + ContextMenuItemProps, + ContextMenuItemEmits, + ContextMenuItemSlots, +} from './ContextMenu.types'; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSelectedItemsInfo/SelectedItemsInfo.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nSelectedItemsInfo/SelectedItemsInfo.stories.ts new file mode 100644 index 00000000000..04363e13279 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSelectedItemsInfo/SelectedItemsInfo.stories.ts @@ -0,0 +1,72 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite'; +import { action } from 'storybook/actions'; + +import N8nSelectedItemsInfo from './SelectedItemsInfo.vue'; +import N8nButton from '../N8nButton'; + +const meta = { + title: 'Core/SelectedItemsInfo', + component: N8nSelectedItemsInfo, + argTypes: { + selectedCount: { control: 'number' }, + }, + parameters: { + docs: { + description: { + component: + 'Floating bar that shows how many list rows are selected, with bulk actions and a clear-selection button. It is positioned absolutely at the bottom of its closest positioned ancestor.', + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const methods = { + onDeleteSelected: action('deleteSelected'), + onClearSelection: action('clearSelection'), +}; + +export const Default: Story = { + render: (args) => ({ + components: { N8nSelectedItemsInfo }, + setup() { + return { args, ...methods }; + }, + template: ` +
+ +
+ `, + }), + args: { + selectedCount: 3, + }, +}; + +export const CustomActions: Story = { + render: (args) => ({ + components: { N8nSelectedItemsInfo, N8nButton }, + setup() { + return { args, ...methods }; + }, + template: ` +
+ + + +
+ `, + }), + args: { + selectedCount: 1, + }, +}; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSelectedItemsInfo/SelectedItemsInfo.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nSelectedItemsInfo/SelectedItemsInfo.test.ts new file mode 100644 index 00000000000..51f3b0ce225 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSelectedItemsInfo/SelectedItemsInfo.test.ts @@ -0,0 +1,61 @@ +import { fireEvent, render, screen } from '@testing-library/vue'; + +import N8nSelectedItemsInfo from './SelectedItemsInfo.vue'; + +describe('N8nSelectedItemsInfo', () => { + it('matches snapshot', () => { + const { html } = render(N8nSelectedItemsInfo, { + props: { selectedCount: 2 }, + global: { stubs: ['N8nButton'] }, + }); + + expect(html()).toMatchSnapshot(); + }); + + it('renders nothing when selectedCount is 0', () => { + render(N8nSelectedItemsInfo, { props: { selectedCount: 0 } }); + + expect(screen.queryByTestId('selected-items-info')).not.toBeInTheDocument(); + }); + + it('renders the count and both buttons when items are selected', () => { + render(N8nSelectedItemsInfo, { props: { selectedCount: 3 } }); + + expect(screen.getByTestId('selected-items-info')).toHaveTextContent('3 rows selected'); + expect(screen.getByTestId('delete-selected-button')).toHaveTextContent('Delete'); + expect(screen.getByTestId('clear-selection-button')).toHaveTextContent('Clear selection'); + }); + + it('uses the singular text for one selected item', () => { + render(N8nSelectedItemsInfo, { props: { selectedCount: 1 } }); + + expect(screen.getByTestId('selected-items-info')).toHaveTextContent('1 row selected'); + }); + + it('emits deleteSelected when the delete button is clicked', async () => { + const { emitted } = render(N8nSelectedItemsInfo, { props: { selectedCount: 1 } }); + + await fireEvent.click(screen.getByTestId('delete-selected-button')); + + expect(emitted().deleteSelected).toHaveLength(1); + }); + + it('emits clearSelection when the clear button is clicked', async () => { + const { emitted } = render(N8nSelectedItemsInfo, { props: { selectedCount: 5 } }); + + await fireEvent.click(screen.getByTestId('clear-selection-button')); + + expect(emitted().clearSelection).toHaveLength(1); + }); + + it('renders the actions slot instead of the default delete button', () => { + render(N8nSelectedItemsInfo, { + props: { selectedCount: 2 }, + slots: { actions: '' }, + }); + + expect(screen.getByTestId('custom-action')).toBeInTheDocument(); + expect(screen.queryByTestId('delete-selected-button')).not.toBeInTheDocument(); + expect(screen.getByTestId('clear-selection-button')).toBeInTheDocument(); + }); +}); diff --git a/packages/frontend/editor-ui/src/app/components/common/SelectedItemsInfo.vue b/packages/frontend/@n8n/design-system/src/components/N8nSelectedItemsInfo/SelectedItemsInfo.vue similarity index 64% rename from packages/frontend/editor-ui/src/app/components/common/SelectedItemsInfo.vue rename to packages/frontend/@n8n/design-system/src/components/N8nSelectedItemsInfo/SelectedItemsInfo.vue index 1aaeddb9801..c7a9fb1dd28 100644 --- a/packages/frontend/editor-ui/src/app/components/common/SelectedItemsInfo.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nSelectedItemsInfo/SelectedItemsInfo.vue @@ -1,28 +1,27 @@