From 17b64da0f00f7a3c8cda32b6adb75c6c67eec261 Mon Sep 17 00:00:00 2001 From: Albert Alises Date: Fri, 15 May 2026 09:33:25 +0200 Subject: [PATCH] fix: Add Switch fallback output guidance for workflow builder (#30449) --- .../parameter-updater/examples/switch-node.ts | 47 ++++++ .../src/tools/connect-nodes.tool.ts | 5 +- .../evaluations/binaryChecks/checks/index.ts | 2 + .../switch-fallback-output-enabled.test.ts | 92 +++++++++++ .../checks/switch-fallback-output-enabled.ts | 42 +++++ .../evaluations/clients/n8n-client.ts | 1 + .../subagent/switch-fallback-routing.json | 4 + .../build-workflow-agent.prompt.ts | 6 +- .../prompts/best-practices/guides/triage.ts | 8 +- .../sdk-reference/workflow-patterns.test.ts | 7 + .../sdk-reference/workflow-patterns.ts | 9 +- .../@n8n/workflow-sdk/src/validation/index.ts | 81 ++++++++++ .../src/validation/validation.test.ts | 150 +++++++++++++++++- .../nodes/Switch/V3/SwitchV3.node.ts | 20 ++- .../nodes/Switch/V3/test/switch.node.test.ts | 24 +++ 15 files changed, 480 insertions(+), 18 deletions(-) create mode 100644 packages/@n8n/instance-ai/evaluations/binaryChecks/checks/switch-fallback-output-enabled.test.ts create mode 100644 packages/@n8n/instance-ai/evaluations/binaryChecks/checks/switch-fallback-output-enabled.ts create mode 100644 packages/@n8n/instance-ai/evaluations/data/subagent/switch-fallback-routing.json diff --git a/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/examples/switch-node.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/examples/switch-node.ts index 378a852cef2..6cbb3e6f193 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/examples/switch-node.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/examples/switch-node.ts @@ -125,5 +125,52 @@ Expected Output: ] } } + +#### Example 3: Add Catch-All Branch +Current Parameters: { "mode": "rules" } +Requested Changes: Route urgent and normal priorities, and send everything else to a fallback branch + +Expected Output: +{ + "mode": "rules", + "rules": { + "values": [ + { + "conditions": { + "options": { "caseSensitive": false, "leftValue": "", "typeValidation": "strict" }, + "conditions": [ + { + "leftValue": "={{ $json.priority }}", + "rightValue": "urgent", + "operator": { "type": "string", "operation": "equals" } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "Urgent" + }, + { + "conditions": { + "options": { "caseSensitive": false, "leftValue": "", "typeValidation": "strict" }, + "conditions": [ + { + "leftValue": "={{ $json.priority }}", + "rightValue": "normal", + "operator": { "type": "string", "operation": "equals" } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "Normal" + } + ] + }, + "options": { + "fallbackOutput": "extra", + "renameFallbackOutput": "Fallback" + } +} `, }; diff --git a/packages/@n8n/ai-workflow-builder.ee/src/tools/connect-nodes.tool.ts b/packages/@n8n/ai-workflow-builder.ee/src/tools/connect-nodes.tool.ts index 16eb87cb092..ad20df088c3 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/tools/connect-nodes.tool.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/tools/connect-nodes.tool.ts @@ -330,13 +330,14 @@ CONNECTION EXAMPLES: MULTI-OUTPUT NODES (sourceOutputIndex): - IF node: output 0 = true branch, output 1 = false branch -- Switch node: outputs 0 to N-1 based on configured rules, output N = default/fallback +- Switch node: outputs 0 to N-1 based on configured rules. Output N exists as the default/fallback branch only when parameters.options.fallbackOutput is set to 'extra'. Without that option, unmatched items are dropped unless fallbackOutput routes them to an existing rule output. ERROR OUTPUT CONNECTIONS (onError: 'continueErrorOutput'): When a node has nodeSettings.onError = 'continueErrorOutput', it gains an ADDITIONAL error output appended as the LAST index: - Single-output node (HTTP Request): output 0 = success, output 1 = error - IF node (2 outputs) + error handling: output 0 = true, output 1 = false, output 2 = error -- Switch node (N outputs) + error handling: outputs 0 to N-1 = branches, output N = error +- Switch node without extra fallback + error handling: outputs 0 to N-1 = branches, output N = error +- Switch node with extra fallback + error handling: outputs 0 to N-1 = branches, output N = fallback, output N+1 = error Example: HTTP Request with continueErrorOutput → success at index 0, error at index 1 Example: IF with continueErrorOutput → true at 0, false at 1, error at 2`, diff --git a/packages/@n8n/instance-ai/evaluations/binaryChecks/checks/index.ts b/packages/@n8n/instance-ai/evaluations/binaryChecks/checks/index.ts index a03943c9ff2..edbe4289650 100644 --- a/packages/@n8n/instance-ai/evaluations/binaryChecks/checks/index.ts +++ b/packages/@n8n/instance-ai/evaluations/binaryChecks/checks/index.ts @@ -25,6 +25,7 @@ import { noInvalidFromAi } from './no-invalid-from-ai'; import { noUnnecessaryCodeNodes } from './no-unnecessary-code-nodes'; import { noUnreachableNodes } from './no-unreachable-nodes'; import { responseMatchesWorkflowChanges } from './response-matches-workflow-changes'; +import { switchFallbackOutputEnabled } from './switch-fallback-output-enabled'; import { toolsHaveParameters } from './tools-have-parameters'; import { validDataFlow } from './valid-data-flow'; import { validFieldReferences } from './valid-field-references'; @@ -52,6 +53,7 @@ export const DETERMINISTIC_CHECKS: BinaryCheck[] = [ noUnreachableNodes, inboundTriggerAuthDefaults, httpGenericAuthTypeMatchesPrompt, + switchFallbackOutputEnabled, validNodeConfig, ]; diff --git a/packages/@n8n/instance-ai/evaluations/binaryChecks/checks/switch-fallback-output-enabled.test.ts b/packages/@n8n/instance-ai/evaluations/binaryChecks/checks/switch-fallback-output-enabled.test.ts new file mode 100644 index 00000000000..0ffa5858c7f --- /dev/null +++ b/packages/@n8n/instance-ai/evaluations/binaryChecks/checks/switch-fallback-output-enabled.test.ts @@ -0,0 +1,92 @@ +import { switchFallbackOutputEnabled } from './switch-fallback-output-enabled'; +import type { WorkflowResponse } from '../../clients/n8n-client'; + +const switchRules = { + values: [ + { + outputKey: 'Urgent', + conditions: { + options: { caseSensitive: false, leftValue: '', typeValidation: 'strict' }, + conditions: [ + { + leftValue: '={{ $json.priority }}', + rightValue: 'urgent', + operator: { type: 'string', operation: 'equals' }, + }, + ], + combinator: 'and', + }, + }, + { + outputKey: 'Normal', + conditions: { + options: { caseSensitive: false, leftValue: '', typeValidation: 'strict' }, + conditions: [ + { + leftValue: '={{ $json.priority }}', + rightValue: 'normal', + operator: { type: 'string', operation: 'equals' }, + }, + ], + combinator: 'and', + }, + }, + ], +}; + +function workflowWithSwitch(options?: Record): WorkflowResponse { + return { + id: 'wf-1', + name: 'Switch fallback test', + active: false, + versionId: 'version-1', + nodes: [ + { + name: 'Manual Trigger', + type: 'n8n-nodes-base.manualTrigger', + parameters: {}, + }, + { + name: 'Route Priority', + type: 'n8n-nodes-base.switch', + typeVersion: 3.4, + parameters: { + mode: 'rules', + rules: switchRules, + ...(options ? { options } : {}), + }, + }, + { + name: 'Fallback', + type: 'n8n-nodes-base.noOp', + parameters: {}, + }, + ], + connections: { + 'Manual Trigger': { + main: [[{ node: 'Route Priority', type: 'main', index: 0 }]], + }, + 'Route Priority': { + main: [[], [], [{ node: 'Fallback', type: 'main', index: 0 }]], + }, + }, + }; +} + +describe('switchFallbackOutputEnabled', () => { + it('fails when a Switch fallback branch is wired without fallbackOutput extra', async () => { + const result = await switchFallbackOutputEnabled.run(workflowWithSwitch(), { prompt: '' }); + + expect(result.pass).toBe(false); + expect(result.comment).toContain("options.fallbackOutput is set to 'extra'"); + }); + + it('passes when fallbackOutput extra creates the fallback branch', async () => { + const result = await switchFallbackOutputEnabled.run( + workflowWithSwitch({ fallbackOutput: 'extra' }), + { prompt: '' }, + ); + + expect(result).toEqual({ pass: true }); + }); +}); diff --git a/packages/@n8n/instance-ai/evaluations/binaryChecks/checks/switch-fallback-output-enabled.ts b/packages/@n8n/instance-ai/evaluations/binaryChecks/checks/switch-fallback-output-enabled.ts new file mode 100644 index 00000000000..162047a7b37 --- /dev/null +++ b/packages/@n8n/instance-ai/evaluations/binaryChecks/checks/switch-fallback-output-enabled.ts @@ -0,0 +1,42 @@ +import { validateWorkflow } from '@n8n/workflow-sdk'; +import type { WorkflowJSON } from '@n8n/workflow-sdk'; + +import type { WorkflowResponse } from '../../clients/n8n-client'; +import type { BinaryCheck } from '../types'; + +function toWorkflowJson(workflow: WorkflowResponse): WorkflowJSON { + return { + name: workflow.name, + nodes: (workflow.nodes ?? []).map((n, i) => ({ + id: String(i), + name: n.name, + type: n.type, + typeVersion: n.typeVersion ?? 1, + parameters: n.parameters ?? {}, + position: [0, 0], + ...(n.onError ? { onError: n.onError } : {}), + })), + connections: workflow.connections, + } as unknown as WorkflowJSON; +} + +export const switchFallbackOutputEnabled: BinaryCheck = { + name: 'switch_fallback_output_enabled', + description: 'Switch fallback branches are only wired when the extra fallback output exists', + kind: 'deterministic', + run(workflow: WorkflowResponse) { + const result = validateWorkflow(toWorkflowJson(workflow), { + allowNoTrigger: true, + allowDisconnectedNodes: true, + validateSchema: false, + }); + + const warnings = result.warnings.filter((w) => w.code === 'SWITCH_FALLBACK_OUTPUT_DISABLED'); + if (warnings.length === 0) return { pass: true }; + + return { + pass: false, + comment: warnings.map((w) => w.message).join('; '), + }; + }, +}; diff --git a/packages/@n8n/instance-ai/evaluations/clients/n8n-client.ts b/packages/@n8n/instance-ai/evaluations/clients/n8n-client.ts index 70586ce5615..7ce432a0d63 100644 --- a/packages/@n8n/instance-ai/evaluations/clients/n8n-client.ts +++ b/packages/@n8n/instance-ai/evaluations/clients/n8n-client.ts @@ -50,6 +50,7 @@ export interface WorkflowNodeResponse { type: string; typeVersion?: number; parameters?: Record; + onError?: 'stopWorkflow' | 'continueRegularOutput' | 'continueErrorOutput'; disabled?: boolean; credentials?: Record; } diff --git a/packages/@n8n/instance-ai/evaluations/data/subagent/switch-fallback-routing.json b/packages/@n8n/instance-ai/evaluations/data/subagent/switch-fallback-routing.json new file mode 100644 index 00000000000..424ff55cd03 --- /dev/null +++ b/packages/@n8n/instance-ai/evaluations/data/subagent/switch-fallback-routing.json @@ -0,0 +1,4 @@ +{ + "id": "switch-fallback-routing", + "prompt": "Build a workflow that receives a webhook POST with a JSON body containing 'priority' and 'message'. Route priority 'urgent' items to Slack channel #incidents, priority 'normal' items to Gmail at ops@example.com, and send every unmatched priority to Slack channel #triage as the default fallback branch. Configure all nodes completely and don't ask for credentials." +} diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.prompt.ts b/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.prompt.ts index 97eba62d351..181cd8dac33 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.prompt.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.prompt.ts @@ -128,7 +128,7 @@ ${PLACEHOLDERS_RULE} ## Mandatory Process 1. **Research**: If the workflow fits a known category (notification, chatbot, scheduling, data_transformation, etc.), call \`nodes(action="suggested")\` first for curated recommendations. Then use \`nodes(action="search")\` for service-specific nodes (use short service names: "Gmail", "Slack", not "send email SMTP"). The results include \`discriminators\` (available resources and operations) for nodes that need them. Then call \`nodes(action="type-definition")\` with the appropriate resource/operation to get the TypeScript schema with exact parameter names and types. **Pay attention to @builderHint annotations** in search results and type definitions — they prevent common configuration mistakes. 2. **Build**: Write TypeScript SDK code and call \`build-workflow\`. Follow the SDK patterns below exactly. -3. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target — confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch \`outputKey\` has a matching \`.onCase('')\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria. +3. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target — confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch rule output is wired by zero-based \`.onCase(index, target)\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria. 4. **Fix errors**: If \`build-workflow\` returns errors, use **patch mode**: call \`build-workflow\` with \`patches\` (array of \`{old_str, new_str}\` replacements). Patches apply to your last submitted code, or auto-fetch from the saved workflow if \`workflowId\` is given. Much faster than resending full code. 5. **Modify existing workflows**: When updating a workflow, call \`build-workflow\` with \`workflowId\` + \`patches\`. The tool fetches the current code and applies your patches. Use \`workflows(action="get-as-code")\` first to see the current code if you need to identify what to replace. 6. **Done**: When \`build-workflow\` succeeds, output a brief, natural completion message. @@ -419,7 +419,7 @@ n8n normalizes column names to snake_case (e.g., \`dayName\` → \`day_name\`). 5. **Write workflow code** to \`${workspaceRoot}/src/workflow.ts\`. -6. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target — confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch \`outputKey\` has a matching \`.onCase('')\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria. +6. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target — confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch rule output is wired by zero-based \`.onCase(index, target)\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria. 7. **Validate with tsc**: Run the TypeScript compiler for real type checking: \`\`\` @@ -449,7 +449,7 @@ Follow the **Compositional Workflow Pattern** above. The process becomes: c. Submit the chunk: \`submit-workflow\` with \`filePath\` pointing to the chunk file. Test via \`executions(action="run")\`. d. Fix if needed (max 2 submission fix attempts per chunk). 6. **Write the main workflow** in \`${workspaceRoot}/src/workflow.ts\` that composes chunks via \`executeWorkflow\` nodes, referencing each chunk's workflow ID. -7. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target — confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch \`outputKey\` has a matching \`.onCase('')\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria. +7. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target — confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch rule output is wired by zero-based \`.onCase(index, target)\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria. 8. **Submit** the main workflow. 9. **Done**: Output ONE sentence summarizing what was built, including the workflow ID and any known issues. diff --git a/packages/@n8n/workflow-sdk/src/prompts/best-practices/guides/triage.ts b/packages/@n8n/workflow-sdk/src/prompts/best-practices/guides/triage.ts index e6c5862f5de..7c3bcd6b202 100644 --- a/packages/@n8n/workflow-sdk/src/prompts/best-practices/guides/triage.ts +++ b/packages/@n8n/workflow-sdk/src/prompts/best-practices/guides/triage.ts @@ -87,7 +87,7 @@ flowchart LR Use Switch node as primary traffic controller: - Configure cases for each classification value -- Always define Default case for unexpected values +- Always configure an extra fallback output for unexpected values - Each item follows exactly one branch Avoid parallel IF nodes that could match multiple conditions - use Switch node. @@ -102,7 +102,7 @@ Avoid parallel IF nodes that could match multiple conditions - use Switch node. **Switch** (n8n-nodes-base.switch): - Purpose: Multi-way branching based on field values - Use when: Multiple categories (3+ outcomes) -- Configure Default output for unmatched items +- Configure options.fallbackOutput: 'extra' for unmatched items **Merge** (n8n-nodes-base.merge): - Purpose: Consolidate branches for unified logging @@ -125,9 +125,9 @@ For all AI nodes (Text Classifier, AI Agent): ## Common Pitfalls to Avoid ### No Default Path -**Problem**: Every Switch must have a Default output. Unmatched items should go to manual review or logging, never drop silently. +**Problem**: Switch nodes do not create a default output unless options.fallbackOutput is set to 'extra'. Unmatched items should go to manual review or logging, never drop silently. -**Solution**: Always configure Default case to route unclassified items to a fallback action (e.g., manual review queue, admin notification) +**Solution**: Configure options.fallbackOutput: 'extra' and wire that fallback output to a fallback action (e.g., manual review queue, admin notification). ### No "Other" Branch in Text Classifier **Problem**: Items that don't match any category get dropped if "When No Clear Match" isn't set. diff --git a/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/workflow-patterns.test.ts b/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/workflow-patterns.test.ts index eb0de8afe3d..68f3c75e6b1 100644 --- a/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/workflow-patterns.test.ts +++ b/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/workflow-patterns.test.ts @@ -13,4 +13,11 @@ describe('WORKFLOW_SDK_PATTERNS', () => { expect.stringContaining('parameters: {}'), ); }); + + it('uses current Switch fallback syntax', () => { + expect(WORKFLOW_SDK_PATTERNS).not.toContain('.onDefault('); + expect(WORKFLOW_SDK_PATTERNS).not.toMatch(/\.onCase\(['"]/); + expect(WORKFLOW_SDK_PATTERNS).toContain("fallbackOutput: 'extra'"); + expect(WORKFLOW_SDK_PATTERNS).toContain('.onCase(2, archive)'); + }); }); diff --git a/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/workflow-patterns.ts b/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/workflow-patterns.ts index a55b27f6516..7289a1f2f14 100644 --- a/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/workflow-patterns.ts +++ b/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/workflow-patterns.ts @@ -210,7 +210,8 @@ const routeByPriority = switchCase({ { outputKey: 'urgent', conditions: { options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' }, conditions: [{ leftValue: expr('{{ $json.priority }}'), operator: { type: 'string', operation: 'equals' }, rightValue: 'urgent' }], combinator: 'and' } }, { outputKey: 'normal', conditions: { options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' }, conditions: [{ leftValue: expr('{{ $json.priority }}'), operator: { type: 'string', operation: 'equals' }, rightValue: 'normal' }], combinator: 'and' } }, ] - } + }, + options: { fallbackOutput: 'extra', renameFallbackOutput: 'Fallback' } } } }); @@ -218,9 +219,9 @@ const routeByPriority = switchCase({ export default workflow('id', 'name') .add(startTrigger) .to(routeByPriority - .onCase('urgent', processUrgent.to(notifyTeam.to(escalate))) - .onCase('normal', processNormal) - .onDefault(archive)); + .onCase(0, processUrgent.to(notifyTeam.to(escalate))) + .onCase(1, processNormal) + .onCase(2, archive)); \`\`\` diff --git a/packages/@n8n/workflow-sdk/src/validation/index.ts b/packages/@n8n/workflow-sdk/src/validation/index.ts index 4a7a7100478..2633dd49511 100644 --- a/packages/@n8n/workflow-sdk/src/validation/index.ts +++ b/packages/@n8n/workflow-sdk/src/validation/index.ts @@ -42,6 +42,7 @@ export type ValidationErrorCode = | 'UNSUPPORTED_SUBNODE_INPUT' | 'MISSING_REQUIRED_INPUT' | 'INVALID_OUTPUT_FOR_MODE' + | 'SWITCH_FALLBACK_OUTPUT_DISABLED' | 'MAX_NODES_EXCEEDED' | 'INVALID_EXPRESSION_PATH' | 'PARTIAL_EXPRESSION_PATH' @@ -190,6 +191,7 @@ interface NodeJSON { typeVersion?: number | string; position?: [number, number]; parameters?: Record; + onError?: string; } /** @@ -507,6 +509,10 @@ export function validateWorkflow( validatePlaceholderSlots(json, options.nodeTypesProvider, errors); } + // Switch fallback output validation does not need node metadata. It is derived from + // the Switch node's dynamic output contract in rules mode. + validateSwitchFallbackOutputConnections(json, warnings); + // Merge node input-count consistency checkMergeNodeInputCount(json, warnings); @@ -1019,6 +1025,81 @@ function validateOutputUsage( } } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function getSwitchRulesCount(parameters: Record | undefined): number { + const rules = parameters?.rules; + if (!isRecord(rules)) return 0; + + const values = rules.values; + if (Array.isArray(values)) return values.length; + + const legacyRules = rules.rules; + if (Array.isArray(legacyRules)) return legacyRules.length; + + return 0; +} + +function getSwitchFallbackOutput(parameters: Record | undefined): unknown { + const options = parameters?.options; + if (!isRecord(options)) return undefined; + + return options.fallbackOutput; +} + +function hasOutputConnections( + outputs: Array | null>, + outputIndex: number, +): boolean { + const output = outputs[outputIndex]; + return Array.isArray(output) && output.length > 0; +} + +/** + * Validate that Switch fallback branches are only connected when the node + * actually exposes an extra fallback output. + */ +function validateSwitchFallbackOutputConnections( + json: WorkflowJSON, + warnings: ValidationWarning[], +): void { + for (const sourceNode of json.nodes) { + if (!sourceNode.name || sourceNode.type !== 'n8n-nodes-base.switch') continue; + + const mode = sourceNode.parameters?.mode; + if (mode !== undefined && mode !== 'rules') continue; + + const outgoing = json.connections[sourceNode.name]; + const mainOutputs = outgoing?.main; + if (!Array.isArray(mainOutputs)) continue; + + const rulesCount = getSwitchRulesCount(sourceNode.parameters); + const fallbackOutput = getSwitchFallbackOutput(sourceNode.parameters); + if (fallbackOutput === 'extra') continue; + + for (let outputIndex = rulesCount; outputIndex < mainOutputs.length; outputIndex++) { + if (!hasOutputConnections(mainOutputs, outputIndex)) continue; + + const isErrorOutput = + sourceNode.onError === 'continueErrorOutput' && outputIndex === rulesCount; + if (isErrorOutput) continue; + + warnings.push( + new ValidationWarning( + 'SWITCH_FALLBACK_OUTPUT_DISABLED', + `Switch node '${sourceNode.name}' has a connection from output ${outputIndex}, but rules mode only creates fallback output ${rulesCount} when options.fallbackOutput is set to 'extra'. Set options.fallbackOutput to 'extra' before wiring a catch-all branch, or route unmatched items to an existing rule output with a numeric fallbackOutput value.`, + sourceNode.name, + 'options.fallbackOutput', + undefined, + 'major', + ), + ); + } + } +} + /** * Reject `placeholder()` markers found in parameter slots whose property * description carries `builderHint.placeholderSupported === false`. diff --git a/packages/@n8n/workflow-sdk/src/validation/validation.test.ts b/packages/@n8n/workflow-sdk/src/validation/validation.test.ts index cc16c65c7f5..cba3ad1cfcb 100644 --- a/packages/@n8n/workflow-sdk/src/validation/validation.test.ts +++ b/packages/@n8n/workflow-sdk/src/validation/validation.test.ts @@ -1,6 +1,6 @@ import { validateWorkflow, ValidationError } from '.'; import { setupTestSchemas, teardownTestSchemas } from './test-schema-setup'; -import type { NodeInstance } from '../types/base'; +import type { NodeInstance, WorkflowJSON } from '../types/base'; import { workflow } from '../workflow-builder'; import { node, trigger, sticky } from '../workflow-builder/node-builders/node-builder'; import { languageModel, tool } from '../workflow-builder/node-builders/subnode-builders'; @@ -2988,6 +2988,154 @@ describe('Validation', () => { }); }); + describe('SWITCH_FALLBACK_OUTPUT_DISABLED validation', () => { + const switchRules = { + values: [ + { + outputKey: 'Urgent', + conditions: { + options: { caseSensitive: false, leftValue: '', typeValidation: 'strict' }, + conditions: [ + { + leftValue: '={{ $json.priority }}', + rightValue: 'urgent', + operator: { type: 'string', operation: 'equals' }, + }, + ], + combinator: 'and', + }, + }, + { + outputKey: 'Normal', + conditions: { + options: { caseSensitive: false, leftValue: '', typeValidation: 'strict' }, + conditions: [ + { + leftValue: '={{ $json.priority }}', + rightValue: 'normal', + operator: { type: 'string', operation: 'equals' }, + }, + ], + combinator: 'and', + }, + }, + ], + }; + + function createSwitchWorkflow(args: { + switchOptions?: Record; + switchOnError?: 'continueErrorOutput'; + fallbackOutputIndex?: number; + }): WorkflowJSON { + const fallbackOutputIndex = args.fallbackOutputIndex ?? 2; + + return { + id: 'test', + name: 'Test', + nodes: [ + { + id: 'trigger-1', + name: 'Manual Trigger', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + { + id: 'switch-1', + name: 'Route Priority', + type: 'n8n-nodes-base.switch', + typeVersion: 3.4, + position: [200, 0], + parameters: { + mode: 'rules', + rules: switchRules, + ...(args.switchOptions ? { options: args.switchOptions } : {}), + }, + ...(args.switchOnError ? { onError: args.switchOnError } : {}), + }, + { + id: 'case-1', + name: 'Handle Urgent', + type: 'n8n-nodes-base.noOp', + typeVersion: 1, + position: [400, -100], + parameters: {}, + }, + { + id: 'case-2', + name: 'Handle Normal', + type: 'n8n-nodes-base.noOp', + typeVersion: 1, + position: [400, 0], + parameters: {}, + }, + { + id: 'fallback-1', + name: 'Handle Fallback', + type: 'n8n-nodes-base.noOp', + typeVersion: 1, + position: [400, 100], + parameters: {}, + }, + ], + connections: { + 'Manual Trigger': { + main: [[{ node: 'Route Priority', type: 'main', index: 0 }]], + }, + 'Route Priority': { + main: [ + [{ node: 'Handle Urgent', type: 'main', index: 0 }], + [{ node: 'Handle Normal', type: 'main', index: 0 }], + ...Array.from({ length: Math.max(0, fallbackOutputIndex - 2) }, () => []), + [{ node: 'Handle Fallback', type: 'main', index: 0 }], + ], + }, + }, + }; + } + + function getSwitchFallbackWarnings(workflowJson: WorkflowJSON) { + return validateWorkflow(workflowJson).warnings.filter( + (w) => w.code === 'SWITCH_FALLBACK_OUTPUT_DISABLED', + ); + } + + it('warns when the fallback output is connected without fallbackOutput extra', () => { + const warnings = getSwitchFallbackWarnings(createSwitchWorkflow({})); + + expect(warnings).toHaveLength(1); + expect(warnings[0].nodeName).toBe('Route Priority'); + expect(warnings[0].parameterPath).toBe('options.fallbackOutput'); + expect(warnings[0].message).toContain("options.fallbackOutput is set to 'extra'"); + expect(warnings[0].violationLevel).toBe('major'); + }); + + it('does not warn when fallbackOutput extra creates the fallback output', () => { + const warnings = getSwitchFallbackWarnings( + createSwitchWorkflow({ switchOptions: { fallbackOutput: 'extra' } }), + ); + + expect(warnings).toHaveLength(0); + }); + + it('warns when numeric fallbackOutput is used with an extra fallback connection', () => { + const warnings = getSwitchFallbackWarnings( + createSwitchWorkflow({ switchOptions: { fallbackOutput: 1 } }), + ); + + expect(warnings).toHaveLength(1); + }); + + it('does not mistake the error output for a fallback branch', () => { + const warnings = getSwitchFallbackWarnings( + createSwitchWorkflow({ switchOnError: 'continueErrorOutput' }), + ); + + expect(warnings).toHaveLength(0); + }); + }); + describe('validatePlaceholderSlots (builderHint.placeholderSupported=false)', () => { const mockNodeTypesProviderWithPlaceholderOptOut = { getByNameAndVersion: (_type: string, _version?: number) => ({ diff --git a/packages/nodes-base/nodes/Switch/V3/SwitchV3.node.ts b/packages/nodes-base/nodes/Switch/V3/SwitchV3.node.ts index 791c24d8d08..f751a2a3206 100644 --- a/packages/nodes-base/nodes/Switch/V3/SwitchV3.node.ts +++ b/packages/nodes-base/nodes/Switch/V3/SwitchV3.node.ts @@ -89,6 +89,10 @@ const routeByPriority = switchCase({ } } ] + }, + options: { + fallbackOutput: 'extra', + renameFallbackOutput: 'Fallback' } } } @@ -97,9 +101,9 @@ const routeByPriority = switchCase({ export default workflow('id', 'name') .add(startTrigger) .to(routeByPriority - .onCase('urgent', processUrgent.to(notifyTeam)) - .onCase('normal', processNormal) - .onDefault(archive)); + .onCase(0, processUrgent.to(notifyTeam)) + .onCase(1, processNormal) + .onCase(2, archive)); `, }, @@ -176,7 +180,7 @@ export default workflow('id', 'name') type: 'fixedCollection', builderHint: { propertyHint: - "Use `rules.values` (NOT `rules.rules`). Each rule needs `outputKey` and a complete `conditions` object with these three sibling keys: `combinator` ('and' | 'or'), `conditions` (array of condition objects), `options` (`{ caseSensitive, leftValue, typeValidation }`). Same shape as IF. Each `outputKey` you define must be wired via `.onCase('')` to the intended downstream node — unwired cases silently drop their items.", + "Use `rules.values` (NOT `rules.rules`). Each rule needs `outputKey` and a complete `conditions` object with these three sibling keys: `combinator` ('and' | 'or'), `conditions` (array of condition objects), `options` (`{ caseSensitive, leftValue, typeValidation }`). Same shape as IF. Wire rule outputs by zero-based index with `.onCase(index, target)`; `outputKey` is the visible output label, not the `.onCase()` argument. Unwired cases silently drop their items.", }, typeOptions: { multipleValues: true, @@ -283,6 +287,10 @@ export default workflow('id', 'name') loadOptionsDependsOn: ['rules.values', '/rules', '/rules.values'], loadOptionsMethod: 'getFallbackOutputOptions', }, + builderHint: { + propertyHint: + "Set this to `'extra'` before wiring a catch-all/default branch. In rules mode, `'extra'` creates a fallback output at index `rules.values.length`; default `'none'` creates no fallback output and drops unmatched items. Numeric values route unmatched items to an existing rule output and do not create a new port.", + }, default: 'none', // eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options description: @@ -309,6 +317,10 @@ export default workflow('id', 'name') type: 'string', placeholder: 'e.g. Fallback', default: '', + builderHint: { + propertyHint: + "Only labels the extra fallback output. Use it together with `fallbackOutput: 'extra'`; it does not create a fallback output by itself.", + }, displayOptions: { show: { fallbackOutput: ['extra'], diff --git a/packages/nodes-base/nodes/Switch/V3/test/switch.node.test.ts b/packages/nodes-base/nodes/Switch/V3/test/switch.node.test.ts index 2c489fdac59..e802052b40d 100644 --- a/packages/nodes-base/nodes/Switch/V3/test/switch.node.test.ts +++ b/packages/nodes-base/nodes/Switch/V3/test/switch.node.test.ts @@ -67,6 +67,30 @@ describe('SwitchV3 Node', () => { const switchNode = new SwitchV3(baseDescription); expect(switchNode.description.version).toContain(3.3); }); + + it('should document SDK-discoverable fallback output metadata', () => { + const switchNode = new SwitchV3(baseDescription); + const sdkExample = switchNode.description.builderHint?.extraTypeDefContent?.[0].content ?? ''; + const optionsProperty = switchNode.description.properties.find( + (prop) => prop.name === 'options', + ); + const optionParameters = (optionsProperty?.options ?? []) as Array<{ + name: string; + builderHint?: { propertyHint?: string }; + }>; + const fallbackOutput = optionParameters.find((option) => option.name === 'fallbackOutput'); + const renameFallbackOutput = optionParameters.find( + (option) => option.name === 'renameFallbackOutput', + ); + + expect(sdkExample).toContain("fallbackOutput: 'extra'"); + expect(sdkExample).toContain('.onCase(2, archive)'); + expect(sdkExample).not.toContain('.onDefault('); + expect(sdkExample).not.toMatch(/\.onCase\(['"]/); + expect(fallbackOutput?.builderHint?.propertyHint).toContain('rules.values.length'); + expect(fallbackOutput?.builderHint?.propertyHint).toContain('Numeric values'); + expect(renameFallbackOutput?.builderHint?.propertyHint).toContain("fallbackOutput: 'extra'"); + }); }); describe('Expression Mode Execution', () => {