feat(core): Add optional Error-Output (#7460)

Add an additional optional error output to which all items get sent that
could not be processed.
![Screenshot from 2023-10-18
17-29-15](https://github.com/n8n-io/n8n/assets/6249596/e9732807-ab2b-4662-a5f6-bdff24f7ad55)

Github issue / Community forum post (link here to close automatically):
https://community.n8n.io/t/error-connector-for-nodes/3094

https://community.n8n.io/t/error-handling-at-node-level-detect-node-execution-status/26791

---------

Co-authored-by: OlegIvaniv <me@olegivaniv.com>
This commit is contained in:
Jan Oberhauser 2023-10-30 18:42:47 +01:00 committed by GitHub
parent 442c73e63b
commit 655efeaf66
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 1090 additions and 61 deletions

View file

@ -34,9 +34,9 @@ properties:
type: number
waitBetweenTries:
type: number
continueOnFail:
type: boolean
example: false
onError:
type: string
example: 'stopWorkflow'
position:
type: array
items:

View file

@ -2289,7 +2289,13 @@ export function getNodeParameter(
*
*/
export function continueOnFail(node: INode): boolean {
return get(node, 'continueOnFail', false);
const onError = get(node, 'onError', undefined);
if (onError === undefined) {
return get(node, 'continueOnFail', false);
}
return ['continueRegularOutput', 'continueErrorOutput'].includes(onError);
}
/**

View file

@ -9,6 +9,7 @@ import PCancelable from 'p-cancelable';
import type {
ExecutionError,
ExecutionStatus,
GenericValue,
IConnection,
IDataObject,
IExecuteData,
@ -36,6 +37,8 @@ import {
IRunExecutionData,
IWorkflowExecuteAdditionalData,
WorkflowExecuteMode,
NodeHelpers,
NodeConnectionType,
} from 'n8n-workflow';
import get from 'lodash/get';
import * as NodeExecuteFunctions from './NodeExecuteFunctions';
@ -1041,6 +1044,7 @@ export class WorkflowExecute {
node: executionNode.name,
workflowId: workflow.id,
});
const runNodeData = await workflow.runNode(
executionData,
this.runExecutionData,
@ -1051,6 +1055,112 @@ export class WorkflowExecute {
);
nodeSuccessData = runNodeData.data;
if (nodeSuccessData && executionData.node.onError === 'continueErrorOutput') {
// If errorOutput is activated check all the output items for error data.
// If any is found, route them to the last output as that will be the
// error output.
const nodeType = workflow.nodeTypes.getByNameAndVersion(
executionData.node.type,
executionData.node.typeVersion,
);
const outputs = NodeHelpers.getNodeOutputs(
workflow,
executionData.node,
nodeType.description,
);
const outputTypes = NodeHelpers.getConnectionTypes(outputs);
const mainOutputTypes = outputTypes.filter(
(output) => output === NodeConnectionType.Main,
);
const errorItems: INodeExecutionData[] = [];
const successItems: INodeExecutionData[] = [];
// Create a WorkflowDataProxy instance that we can get the data of the
// item which did error
const executeFunctions = NodeExecuteFunctions.getExecuteFunctions(
workflow,
this.runExecutionData,
runIndex,
[],
executionData.data,
executionData.node,
this.additionalData,
executionData,
this.mode,
);
const dataProxy = executeFunctions.getWorkflowDataProxy(0);
// Loop over all outputs except the error output as it would not contain data by default
for (
let outputIndex = 0;
outputIndex < mainOutputTypes.length - 1;
outputIndex++
) {
successItems.length = 0;
const items = nodeSuccessData.length ? nodeSuccessData[0] : [];
while (items.length) {
const item = items.pop();
if (item === undefined) {
continue;
}
let errorData: GenericValue | undefined;
if (item.error) {
errorData = item.error;
item.error = undefined;
} else if (item.json.error && Object.keys(item.json).length === 1) {
errorData = item.json.error;
}
if (errorData) {
const pairedItemData =
item.pairedItem && typeof item.pairedItem === 'object'
? Array.isArray(item.pairedItem)
? item.pairedItem[0]
: item.pairedItem
: undefined;
if (executionData!.source === null || pairedItemData === undefined) {
// Source data is missing for some reason so we can not figure out the item
errorItems.push(item);
} else {
const pairedItemInputIndex = pairedItemData.input || 0;
const sourceData =
executionData!.source[NodeConnectionType.Main][pairedItemInputIndex];
const constPairedItem = dataProxy.$getPairedItem(
sourceData!.previousNode,
sourceData,
pairedItemData,
);
if (constPairedItem === null) {
errorItems.push(item);
} else {
errorItems.push({
...item,
json: {
...constPairedItem.json,
...item.json,
},
});
}
}
} else {
successItems.push(item);
}
}
nodeSuccessData[outputIndex] = successItems;
}
nodeSuccessData[mainOutputTypes.length - 1] = errorItems;
}
if (runNodeData.closeFunction) {
// Explanation why we do this can be found in n8n-workflow/Workflow.ts -> runNode
@ -1180,7 +1290,12 @@ export class WorkflowExecute {
taskData.error = executionError;
taskData.executionStatus = 'error';
if (executionData.node.continueOnFail === true) {
if (
executionData.node.continueOnFail === true ||
['continueRegularOutput', 'continueErrorOutput'].includes(
executionData.node.onError || '',
)
) {
// Workflow should continue running even if node errors
if (executionData.data.hasOwnProperty('main') && executionData.data.main.length > 0) {
// Simply get the input data of the node if it has any and pass it through

View file

@ -0,0 +1,734 @@
{
"name": "Error Output - Test Workflow",
"nodes": [
{
"parameters": {},
"id": "c41b46f0-3e76-4655-b5ea-4d15af58c138",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [-680, 460]
},
{
"parameters": {
"fields": {
"values": [
{
"name": "originalName",
"stringValue": "={{ $('Mock Data').item.json.name }}"
}
]
},
"options": {}
},
"id": "247f4118-d80f-49ab-8d9a-0cdbbb9271df",
"name": "Success",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [200, 860]
},
{
"parameters": {
"fields": {
"values": [
{
"name": "originalName",
"stringValue": "={{ $('Mock Data').item.json.name }}"
}
]
},
"options": {}
},
"id": "311e3650-d89c-405a-9c8d-c238f48a8a5a",
"name": "Error",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [200, 1040]
},
{
"parameters": {
"jsCode": "return [\n {\n \"id\": \"23423532\",\n \"name\": \"Jay Gatsby\",\n \"email\": \"gatsby@west-egg.com\",\n \"notes\": \"Keeps asking about a green light??\",\n \"country\": \"US\",\n \"created\": \"1925-04-10\"\n },\n {\n \"id\": \"23423533\",\n \"name\": \"José Arcadio Buendía\",\n \"email\": \"jab@macondo.co\",\n \"notes\": \"Lots of people named after him. Very confusing\",\n \"country\": \"CO\",\n \"created\": \"1967-05-05\"\n },\n {\n \"id\": \"23423534\",\n \"name\": \"Max Sendak\",\n \"email\": \"info@in-and-out-of-weeks.org\",\n \"notes\": \"Keeps rolling his terrible eyes\",\n \"country\": \"US\",\n \"created\": \"1963-04-09\"\n },\n {\n \"id\": \"23423535\",\n \"name\": \"Zaphod Beeblebrox\",\n \"email\": \"captain@heartofgold.com\",\n \"notes\": \"Felt like I was talking to more than one person\",\n \"country\": null,\n \"created\": \"1979-10-12\"\n },\n {\n \"id\": \"23423536\",\n \"name\": \"Edmund Pevensie\",\n \"email\": \"edmund@narnia.gov\",\n \"notes\": \"Passionate sailor\",\n \"country\": \"UK\",\n \"created\": \"1950-10-16\"\n }\n]"
},
"id": "179d4fe7-1ae7-4957-a77d-12c3ca6d141b",
"name": "Mock Data",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [-460, 460]
},
{
"parameters": {
"content": "## On Error: Continue (using error output)",
"height": 414,
"width": 564
},
"id": "1ec2a8b6-54e2-4319-90b3-30b387855b36",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [-160, 780]
},
{
"parameters": {
"content": "## Continue On Fail (deprecated)",
"height": 279,
"width": 564
},
"id": "49a2b7d9-8bd1-4cdf-9649-2d93668b0f8f",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [-160, 140]
},
{
"parameters": {
"fields": {
"values": [
{
"name": "originalName",
"stringValue": "={{ $('Mock Data').item.json.name }}"
}
]
},
"options": {}
},
"id": "9852f1d9-95b4-4ef7-bb18-8f0bab81a0bc",
"name": "Combined",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [180, 240]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Add a new field called 'myNewField' to the JSON of the item\n$input.item.json.myNewField = 1;\n\nif ($input.item.json.country === 'US') {\n throw new Error('This is an error');\n}\n\nreturn $input.item;"
},
"id": "40d4dba3-3db7-4eb5-aa27-e76f955a5e09",
"name": "Throw Error",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [-140, 960],
"errorOutput": true,
"onError": "continueErrorOutput"
},
{
"parameters": {
"content": "## On Error: Continue",
"height": 279,
"width": 564
},
"id": "8eb3dd54-c1dd-4167-abfa-c06d044c63f3",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [-160, 460]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Add a new field called 'myNewField' to the JSON of the item\n$input.item.json.myNewField = 1;\n\nif ($input.item.json.country === 'US') {\n throw new Error('This is an error');\n}\n\nreturn $input.item;"
},
"id": "19a3d6ac-e610-4296-9b7a-9ed19d242bdb",
"name": "Throw Error2",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [-120, 560],
"onError": "continueRegularOutput"
},
{
"parameters": {
"fields": {
"values": [
{
"name": "originalName",
"stringValue": "={{ $('Mock Data').item.json.name }}"
}
]
},
"options": {}
},
"id": "5f803fdc-7d88-4c12-8886-6092cfbc03c6",
"name": "Combined1",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [180, 560]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Add a new field called 'myNewField' to the JSON of the item\n$input.item.json.myNewField = 1;\n\nif ($input.item.json.country === 'US') {\n throw new Error('This is an error');\n}\n\nreturn $input.item;"
},
"id": "c2696c1f-1abd-4549-9ad9-e62017dc14b8",
"name": "Throw Error1",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [-120, 240],
"continueOnFail": true
},
{
"parameters": {
"fields": {
"values": [
{
"name": "originalName",
"stringValue": "={{ $('Mock Data').item.json.name }}"
}
]
},
"options": {}
},
"id": "01740d7e-e572-408a-9fae-729068803113",
"name": "Success1",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [200, 1320]
},
{
"parameters": {
"content": "## On Error: Continue (using error output) + Make sure error data gets removed",
"height": 509.71047006830065,
"width": 1183.725293692246
},
"id": "ed409181-4847-4d65-af45-f45078a6343e",
"name": "Sticky Note3",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [-160, 1240]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Add a new field called 'myNewField' to the JSON of the item\n$input.item.json.myNewField = 1;\n\nif ($input.item.json.country === 'US') {\n throw new Error('This is an error');\n}\n\nreturn $input.item;"
},
"id": "93d03f38-b928-4b4b-832a-3f1a5deebb2d",
"name": "Throw Error3",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [-140, 1420],
"errorOutput": true,
"onError": "continueErrorOutput"
},
{
"parameters": {
"options": {}
},
"id": "c92a6ce5-41ea-4fb9-a07b-c4e98f905b12",
"name": "Edit Fields",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [420, 1500],
"onError": "continueErrorOutput"
},
{
"parameters": {
"fields": {
"values": [
{
"name": "originalName",
"stringValue": "={{ $('Mock Data').item.json.name }}"
}
]
},
"options": {}
},
"id": "ab838cc1-0987-4b41-bdc5-fe17f38e0691",
"name": "Success2",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [700, 1360]
},
{
"parameters": {
"fields": {
"values": [
{
"name": "originalName",
"stringValue": "={{ $('Mock Data').item.json.name }}"
}
]
},
"options": {}
},
"id": "22e04172-19b9-4735-9dd0-a3e2fa3bf000",
"name": "Error2",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [700, 1580]
},
{
"parameters": {
"fields": {
"values": [
{
"name": "originalName",
"stringValue": "={{ $('Mock Data').item.json.name }}"
}
]
},
"options": {}
},
"id": "69e7257a-1ba8-46ba-9394-d38d65b2e567",
"name": "Error1",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [200, 1500]
}
],
"pinData": {
"Error": [
{
"json": {
"id": "23423534",
"name": "Max Sendak",
"email": "info@in-and-out-of-weeks.org",
"notes": "Keeps rolling his terrible eyes",
"country": "US",
"created": "1963-04-09",
"error": "This is an error [line 5, for item 2]",
"originalName": "Max Sendak"
},
"pairedItem": {
"item": 0
}
},
{
"json": {
"id": "23423532",
"name": "Jay Gatsby",
"email": "gatsby@west-egg.com",
"notes": "Keeps asking about a green light??",
"country": "US",
"created": "1925-04-10",
"error": "This is an error [line 5, for item 0]",
"originalName": "Jay Gatsby"
},
"pairedItem": {
"item": 1
}
}
],
"Success": [
{
"json": {
"id": "23423536",
"name": "Edmund Pevensie",
"email": "edmund@narnia.gov",
"notes": "Passionate sailor",
"country": "UK",
"created": "1950-10-16",
"myNewField": 1,
"originalName": "Edmund Pevensie"
},
"pairedItem": {
"item": 0
}
},
{
"json": {
"id": "23423535",
"name": "Zaphod Beeblebrox",
"email": "captain@heartofgold.com",
"notes": "Felt like I was talking to more than one person",
"country": null,
"created": "1979-10-12",
"myNewField": 1,
"originalName": "Zaphod Beeblebrox"
},
"pairedItem": {
"item": 1
}
},
{
"json": {
"id": "23423533",
"name": "José Arcadio Buendía",
"email": "jab@macondo.co",
"notes": "Lots of people named after him. Very confusing",
"country": "CO",
"created": "1967-05-05",
"myNewField": 1,
"originalName": "José Arcadio Buendía"
},
"pairedItem": {
"item": 2
}
}
],
"Combined": [
{
"json": {
"error": "This is an error [line 5, for item 0]",
"originalName": "Jay Gatsby"
},
"pairedItem": {
"item": 0
}
},
{
"json": {
"id": "23423533",
"name": "José Arcadio Buendía",
"email": "jab@macondo.co",
"notes": "Lots of people named after him. Very confusing",
"country": "CO",
"created": "1967-05-05",
"myNewField": 1,
"originalName": "José Arcadio Buendía"
},
"pairedItem": {
"item": 1
}
},
{
"json": {
"error": "This is an error [line 5, for item 2]",
"originalName": "Max Sendak"
},
"pairedItem": {
"item": 2
}
},
{
"json": {
"id": "23423535",
"name": "Zaphod Beeblebrox",
"email": "captain@heartofgold.com",
"notes": "Felt like I was talking to more than one person",
"country": null,
"created": "1979-10-12",
"myNewField": 1,
"originalName": "Zaphod Beeblebrox"
},
"pairedItem": {
"item": 3
}
},
{
"json": {
"id": "23423536",
"name": "Edmund Pevensie",
"email": "edmund@narnia.gov",
"notes": "Passionate sailor",
"country": "UK",
"created": "1950-10-16",
"myNewField": 1,
"originalName": "Edmund Pevensie"
},
"pairedItem": {
"item": 4
}
}
],
"Combined1": [
{
"json": {
"error": "This is an error [line 5, for item 0]",
"originalName": "Jay Gatsby"
},
"pairedItem": {
"item": 0
}
},
{
"json": {
"id": "23423533",
"name": "José Arcadio Buendía",
"email": "jab@macondo.co",
"notes": "Lots of people named after him. Very confusing",
"country": "CO",
"created": "1967-05-05",
"myNewField": 1,
"originalName": "José Arcadio Buendía"
},
"pairedItem": {
"item": 1
}
},
{
"json": {
"error": "This is an error [line 5, for item 2]",
"originalName": "Max Sendak"
},
"pairedItem": {
"item": 2
}
},
{
"json": {
"id": "23423535",
"name": "Zaphod Beeblebrox",
"email": "captain@heartofgold.com",
"notes": "Felt like I was talking to more than one person",
"country": null,
"created": "1979-10-12",
"myNewField": 1,
"originalName": "Zaphod Beeblebrox"
},
"pairedItem": {
"item": 3
}
},
{
"json": {
"id": "23423536",
"name": "Edmund Pevensie",
"email": "edmund@narnia.gov",
"notes": "Passionate sailor",
"country": "UK",
"created": "1950-10-16",
"myNewField": 1,
"originalName": "Edmund Pevensie"
},
"pairedItem": {
"item": 4
}
}
],
"Success1": [
{
"json": {
"id": "23423536",
"name": "Edmund Pevensie",
"email": "edmund@narnia.gov",
"notes": "Passionate sailor",
"country": "UK",
"created": "1950-10-16",
"myNewField": 1,
"originalName": "Edmund Pevensie"
},
"pairedItem": {
"item": 0
}
},
{
"json": {
"id": "23423535",
"name": "Zaphod Beeblebrox",
"email": "captain@heartofgold.com",
"notes": "Felt like I was talking to more than one person",
"country": null,
"created": "1979-10-12",
"myNewField": 1,
"originalName": "Zaphod Beeblebrox"
},
"pairedItem": {
"item": 1
}
},
{
"json": {
"id": "23423533",
"name": "José Arcadio Buendía",
"email": "jab@macondo.co",
"notes": "Lots of people named after him. Very confusing",
"country": "CO",
"created": "1967-05-05",
"myNewField": 1,
"originalName": "José Arcadio Buendía"
},
"pairedItem": {
"item": 2
}
}
],
"Error1": [
{
"json": {
"id": "23423534",
"name": "Max Sendak",
"email": "info@in-and-out-of-weeks.org",
"notes": "Keeps rolling his terrible eyes",
"country": "US",
"created": "1963-04-09",
"error": "This is an error [line 5, for item 2]",
"originalName": "Max Sendak"
},
"pairedItem": {
"item": 0
}
},
{
"json": {
"id": "23423532",
"name": "Jay Gatsby",
"email": "gatsby@west-egg.com",
"notes": "Keeps asking about a green light??",
"country": "US",
"created": "1925-04-10",
"error": "This is an error [line 5, for item 0]",
"originalName": "Jay Gatsby"
},
"pairedItem": {
"item": 1
}
}
],
"Success2": [
{
"json": {
"id": "23423532",
"name": "Jay Gatsby",
"email": "gatsby@west-egg.com",
"notes": "Keeps asking about a green light??",
"country": "US",
"created": "1925-04-10",
"error": "This is an error [line 5, for item 0]",
"originalName": "Jay Gatsby"
},
"pairedItem": {
"item": 0
}
},
{
"json": {
"id": "23423534",
"name": "Max Sendak",
"email": "info@in-and-out-of-weeks.org",
"notes": "Keeps rolling his terrible eyes",
"country": "US",
"created": "1963-04-09",
"error": "This is an error [line 5, for item 2]",
"originalName": "Max Sendak"
},
"pairedItem": {
"item": 1
}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "Mock Data",
"type": "main",
"index": 0
}
]
]
},
"Mock Data": {
"main": [
[
{
"node": "Throw Error",
"type": "main",
"index": 0
},
{
"node": "Throw Error2",
"type": "main",
"index": 0
},
{
"node": "Throw Error1",
"type": "main",
"index": 0
},
{
"node": "Throw Error3",
"type": "main",
"index": 0
}
]
]
},
"Throw Error": {
"main": [
[
{
"node": "Success",
"type": "main",
"index": 0
}
],
[
{
"node": "Error",
"type": "main",
"index": 0
}
]
]
},
"Throw Error2": {
"main": [
[
{
"node": "Combined1",
"type": "main",
"index": 0
}
]
]
},
"Throw Error1": {
"main": [
[
{
"node": "Combined",
"type": "main",
"index": 0
}
]
]
},
"Throw Error3": {
"main": [
[
{
"node": "Success1",
"type": "main",
"index": 0
}
],
[
{
"node": "Error1",
"type": "main",
"index": 0
}
]
]
},
"Edit Fields": {
"main": [
[
{
"node": "Success2",
"type": "main",
"index": 0
}
],
[
{
"node": "Error2",
"type": "main",
"index": 0
}
]
]
},
"Error1": {
"main": [
[
{
"node": "Edit Fields",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "e73e1eda-293c-4ee2-87b9-923873241774",
"id": "UgoluWRMeg7fPLCB",
"meta": {
"instanceId": "021d3c82ba2d3bc090cbf4fc81c9312668bcc34297e022bb3438c5c88a43a5ff"
},
"tags": []
}

View file

@ -1297,6 +1297,15 @@ export default defineComponent({
stroke: var(--color-foreground-xdark);
}
&.error {
path {
fill: var(--node-error-output-color);
}
rect {
stroke: var(--node-error-output-color);
}
}
&.small {
margin-left: calc((var(--stalk-size) + var(--plus-endpoint-box-size-small) / 2));
g {
@ -1427,6 +1436,10 @@ export default defineComponent({
}
}
.node-output-endpoint-label.node-connection-category-error {
color: var(--node-error-output-color);
}
.node-output-endpoint-label {
margin-left: calc(var(--endpoint-size-small) + var(--spacing-2xs));
@ -1436,9 +1449,9 @@ export default defineComponent({
margin-left: 0;
}
// Switch node allows for dynamic connection labels
// Some nodes allow for dynamic connection labels
// so we need to make sure the label does not overflow
&[data-endpoint-node-type='n8n-nodes-base.switch'] {
&[data-endpoint-label-length='medium'] {
max-width: calc(var(--stalk-size) - (var(--endpoint-size-small)));
overflow: hidden;
text-overflow: ellipsis;
@ -1495,7 +1508,8 @@ export default defineComponent({
.ep-success--without-label {
--stalk-size: var(--stalk-success-size-without-label);
}
[data-endpoint-node-type='n8n-nodes-base.switch'] {
[data-endpoint-label-length='medium'] {
--stalk-size: var(--stalk-switch-size);
}
</style>

View file

@ -106,6 +106,7 @@
@valueChanged="valueChanged"
@execute="onNodeExecute"
@stopExecution="onStopExecution"
@redrawRequired="redrawRequired = true"
@activate="onWorkflowActivate"
/>
<a
@ -199,6 +200,7 @@ export default defineComponent({
data() {
return {
settingsEventBus: createEventBus(),
redrawRequired: false,
runInputIndex: -1,
runOutputIndex: -1,
isLinkingEnabled: true,
@ -633,7 +635,8 @@ export default defineComponent({
if (
typeof this.activeNodeType?.outputs === 'string' ||
typeof this.activeNodeType?.inputs === 'string'
typeof this.activeNodeType?.inputs === 'string' ||
this.redrawRequired
) {
// TODO: We should keep track of if it actually changed and only do if required
// Whenever a node with custom inputs and outputs gets closed redraw it in case

View file

@ -371,7 +371,7 @@ export default defineComponent({
alwaysOutputData: false,
executeOnce: false,
notesInFlow: false,
continueOnFail: false,
onError: 'stopWorkflow',
retryOnFail: false,
maxTries: 3,
waitBetweenTries: 1000,
@ -440,12 +440,39 @@ export default defineComponent({
description: this.$locale.baseText('nodeSettings.waitBetweenTries.description'),
},
{
displayName: this.$locale.baseText('nodeSettings.continueOnFail.displayName'),
name: 'continueOnFail',
type: 'boolean',
default: false,
displayName: this.$locale.baseText('nodeSettings.onError.displayName'),
name: 'onError',
type: 'options',
options: [
{
name: this.$locale.baseText('nodeSettings.onError.options.stopWorkflow.displayName'),
value: 'stopWorkflow',
description: this.$locale.baseText(
'nodeSettings.onError.options.stopWorkflow.description',
),
},
{
name: this.$locale.baseText(
'nodeSettings.onError.options.continueRegularOutput.displayName',
),
value: 'continueRegularOutput',
description: this.$locale.baseText(
'nodeSettings.onError.options.continueRegularOutput.description',
),
},
{
name: this.$locale.baseText(
'nodeSettings.onError.options.continueErrorOutput.displayName',
),
value: 'continueErrorOutput',
description: this.$locale.baseText(
'nodeSettings.onError.options.continueErrorOutput.description',
),
},
],
default: 'stopWorkflow',
noDataExpression: true,
description: this.$locale.baseText('nodeSettings.continueOnFail.description'),
description: this.$locale.baseText('nodeSettings.onError.description'),
},
{
displayName: this.$locale.baseText('nodeSettings.notes.displayName'),
@ -633,6 +660,11 @@ export default defineComponent({
return;
}
if (parameterData.name === 'onError') {
// If that parameter changes, we need to redraw the connections, as the error output may need to be added or removed
this.$emit('redrawRequired');
}
if (parameterData.name === 'name') {
// Name of node changed so we have to set also the new node name as active
@ -880,10 +912,18 @@ export default defineComponent({
}
if (this.node.continueOnFail) {
foundNodeSettings.push('continueOnFail');
foundNodeSettings.push('onError');
this.nodeValues = {
...this.nodeValues,
continueOnFail: this.node.continueOnFail,
onError: 'continueRegularOutput',
};
}
if (this.node.onError) {
foundNodeSettings.push('onError');
this.nodeValues = {
...this.nodeValues,
onError: this.node.onError,
};
}

View file

@ -27,6 +27,7 @@ import * as NodeViewUtils from '@/utils/nodeViewUtils';
import { useHistoryStore } from '@/stores/history.store';
import { useCanvasStore } from '@/stores/canvas.store';
import type { EndpointSpec } from '@jsplumb/common';
import { getStyleTokenValue } from '@/utils';
const createAddInputEndpointSpec = (
connectionName: NodeConnectionType,
@ -339,13 +340,13 @@ export const nodeBase = defineComponent({
} = {};
const workflow = this.workflowsStore.getCurrentWorkflow();
const outputs = NodeHelpers.getNodeOutputs(workflow, this.data, nodeTypeData) || [];
this.outputs = outputs;
this.outputs = NodeHelpers.getNodeOutputs(workflow, this.data, nodeTypeData) || [];
// TODO: There are still a lot of references of "main" in NodesView and
// other locations. So assume there will be more problems
outputs.forEach((value, i) => {
let maxLabelLength = 0;
const outputConfigurations: INodeOutputConfiguration[] = [];
this.outputs.forEach((value, i) => {
let outputConfiguration: INodeOutputConfiguration;
if (typeof value === 'string') {
outputConfiguration = {
@ -354,6 +355,24 @@ export const nodeBase = defineComponent({
} else {
outputConfiguration = value;
}
if (nodeTypeData.outputNames?.[i]) {
outputConfiguration.displayName = nodeTypeData.outputNames[i];
}
if (outputConfiguration.displayName) {
maxLabelLength =
outputConfiguration.displayName.length > maxLabelLength
? outputConfiguration.displayName.length
: maxLabelLength;
}
outputConfigurations.push(outputConfiguration);
});
const endpointLabelLength = maxLabelLength < 4 ? 'short' : 'medium';
this.outputs.forEach((value, i) => {
const outputConfiguration = outputConfigurations[i];
const outputName: ConnectionTypes = outputConfiguration.type;
@ -376,7 +395,7 @@ export const nodeBase = defineComponent({
const rootTypeIndex = rootTypeIndexData[rootCategoryOutputName];
const typeIndex = typeIndexData[outputName];
const outputsOfSameRootType = outputs.filter((outputData) => {
const outputsOfSameRootType = this.outputs.filter((outputData) => {
const thisOutputName: string =
typeof outputData === 'string' ? outputData : outputData.type;
return outputName === NodeConnectionType.Main
@ -417,7 +436,7 @@ export const nodeBase = defineComponent({
hoverClass: 'dot-output-endpoint-hover',
connectionsDirected: true,
dragAllowedWhenFull: false,
...this.__getOutputConnectionStyle(outputName, nodeTypeData),
...this.__getOutputConnectionStyle(outputName, outputConfiguration, nodeTypeData),
};
const endpoint = this.instance.addEndpoint(
@ -426,11 +445,12 @@ export const nodeBase = defineComponent({
);
this.__addEndpointTestingData(endpoint, 'output', typeIndex);
if (outputConfiguration.displayName || nodeTypeData.outputNames?.[i]) {
if (outputConfiguration.displayName) {
// Apply output names if they got set
const overlaySpec = NodeViewUtils.getOutputNameOverlay(
outputConfiguration.displayName || nodeTypeData.outputNames[i],
outputConfiguration.displayName,
outputName,
outputConfiguration?.category,
);
endpoint.addOverlay(overlaySpec);
}
@ -441,7 +461,7 @@ export const nodeBase = defineComponent({
nodeId: this.nodeId,
index: typeIndex,
totalEndpoints: outputsOfSameRootType.length,
nodeType: node.type,
endpointLabelLength,
};
}
@ -455,9 +475,9 @@ export const nodeBase = defineComponent({
options: {
dimensions: 24,
connectedEndpoint: endpoint,
showOutputLabel: outputs.length === 1,
size: outputs.length >= 3 ? 'small' : 'medium',
nodeType: node.type,
showOutputLabel: this.outputs.length === 1,
size: this.outputs.length >= 3 ? 'small' : 'medium',
endpointLabelLength,
hoverMessage: this.$locale.baseText('nodeBase.clickToAddNodeOrDragToConnect'),
},
},
@ -475,10 +495,16 @@ export const nodeBase = defineComponent({
nodeId: this.nodeId,
type: outputName,
index: typeIndex,
category: outputConfiguration?.category,
},
cssClass: 'plus-draggable-endpoint',
dragAllowedWhenFull: false,
};
if (outputConfiguration?.category) {
plusEndpointData.cssClass = `${plusEndpointData.cssClass} ${outputConfiguration?.category}`;
}
const plusEndpoint = this.instance.addEndpoint(
this.$refs[this.data.name] as Element,
plusEndpointData,
@ -538,6 +564,7 @@ export const nodeBase = defineComponent({
},
__getOutputConnectionStyle(
connectionType: ConnectionTypes,
outputConfiguration: INodeOutputConfiguration,
nodeTypeData: INodeTypeDescription,
): EndpointOptions {
const type = 'output';
@ -557,6 +584,18 @@ export const nodeBase = defineComponent({
});
if (connectionType === NodeConnectionType.Main) {
if (outputConfiguration.category === 'error') {
return {
paintStyle: {
...NodeViewUtils.getOutputEndpointStyle(
nodeTypeData,
this.__getEndpointColor(NodeConnectionType.Main),
),
fill: getStyleTokenValue('--node-error-output-color', true),
},
cssClass: `dot-${type}-endpoint`,
};
}
return {
paintStyle: NodeViewUtils.getOutputEndpointStyle(
nodeTypeData,

View file

@ -649,6 +649,7 @@ export const workflowHelpers = defineComponent({
'credentials',
'disabled',
'issues',
'onError',
'notes',
'parameters',
'status',
@ -725,14 +726,16 @@ export const workflowHelpers = defineComponent({
}
}
// Save the disabled property and continueOnFail only when is set
// Save the disabled property, continueOnFail and onError only when is set
if (node.disabled === true) {
nodeData.disabled = true;
}
if (node.continueOnFail === true) {
nodeData.continueOnFail = true;
}
if (node.onError !== 'stopWorkflow') {
nodeData.onError = node.onError;
}
// Save the notes only if when they contain data
if (![undefined, ''].includes(node.notes)) {
nodeData.notes = node.notes;

View file

@ -174,6 +174,7 @@
var(--node-type-ai_vectorStore-color-s),
var(--node-type-background-l)
);
--node-error-output-color: #991818;
}
.clickable {

View file

@ -941,8 +941,14 @@
"nodeSettings.alwaysOutputData.description": "If active, will output a single, empty item when the output would have been empty. Use to prevent the workflow finishing on this node.",
"nodeSettings.alwaysOutputData.displayName": "Always Output Data",
"nodeSettings.clickOnTheQuestionMarkIcon": "Click the '?' icon to open this node on n8n.io",
"nodeSettings.continueOnFail.description": "If active, the workflow continues even if this node's execution fails. When this occurs, the node passes along input data from previous nodes - so your workflow should account for unexpected output data.",
"nodeSettings.continueOnFail.displayName": "Continue On Fail",
"nodeSettings.onError.description": "Action to take when the node execution fails",
"nodeSettings.onError.displayName": "On Error",
"nodeSettings.onError.options.continueRegularOutput.description": "Pass error message as item in regular output",
"nodeSettings.onError.options.continueRegularOutput.displayName": "Continue",
"nodeSettings.onError.options.continueErrorOutput.description": "Pass item to an extra `error` output",
"nodeSettings.onError.options.continueErrorOutput.displayName": "Continue (using error output)",
"nodeSettings.onError.options.stopWorkflow.description": "Halt execution and fail workflow",
"nodeSettings.onError.options.stopWorkflow.displayName": "Stop Workflow",
"nodeSettings.docs": "Docs",
"nodeSettings.executeOnce.description": "If active, the node executes only once, with data from the first item it receives",
"nodeSettings.executeOnce.displayName": "Execute Once",

View file

@ -14,7 +14,7 @@ interface N8nPlusEndpointParams extends EndpointRepresentationParams {
dimensions: number;
connectedEndpoint: Endpoint;
hoverMessage: string;
nodeType: string;
endpointLabelLength: 'small' | 'medium';
size: 'small' | 'medium';
showOutputLabel: boolean;
}
@ -55,7 +55,7 @@ export class N8nPlusEndpoint extends EndpointRepresentation<ComputedN8nPlusEndpo
options: {
id: PlusStalkOverlay,
attributes: {
'data-endpoint-node-type': this.params.nodeType,
'data-endpoint-label-length': this.params.endpointLabelLength,
},
create: () => {
const stalk = createElement('div', {}, `${PlusStalkOverlay} ${this.params.size}`);
@ -69,7 +69,7 @@ export class N8nPlusEndpoint extends EndpointRepresentation<ComputedN8nPlusEndpo
id: HoverMessageOverlay,
location: 0.5,
attributes: {
'data-endpoint-node-type': this.params.nodeType,
'data-endpoint-label-length': this.params.endpointLabelLength,
},
create: () => {
const hoverMessage = createElement('p', {}, `${HoverMessageOverlay} ${this.params.size}`);
@ -199,7 +199,8 @@ export const N8nPlusEndpointHandler: EndpointHandler<N8nPlusEndpoint, ComputedN8
ep.w = w;
ep.h = h;
ep.canvas?.setAttribute('data-endpoint-node-type', ep.params.nodeType);
ep.canvas?.setAttribute('data-endpoint-label-length', ep.params.endpointLabelLength);
ep.addClass('plus-endpoint');
return [x, y, w, h, ep.params.dimensions];
},

View file

@ -112,7 +112,11 @@ export const CONNECTOR_PAINT_STYLE_DATA: PaintStyle = {
stroke: getStyleTokenValue('--color-foreground-dark', true),
};
export const getConnectorColor = (type: ConnectionTypes): string => {
export const getConnectorColor = (type: ConnectionTypes, category?: string): string => {
if (category === 'error') {
return '--node-error-output-color';
}
if (type === NodeConnectionType.Main) {
return '--node-type-main-color';
}
@ -121,7 +125,10 @@ export const getConnectorColor = (type: ConnectionTypes): string => {
};
export const getConnectorPaintStylePull = (connection: Connection): PaintStyle => {
const connectorColor = getConnectorColor(connection.parameters.type as ConnectionTypes);
const connectorColor = getConnectorColor(
connection.parameters.type as ConnectionTypes,
connection.parameters.category,
);
const additionalStyles: PaintStyle = {};
if (connection.parameters.type !== NodeConnectionType.Main) {
additionalStyles.dashstyle = '5 3';
@ -134,15 +141,21 @@ export const getConnectorPaintStylePull = (connection: Connection): PaintStyle =
};
export const getConnectorPaintStyleDefault = (connection: Connection): PaintStyle => {
const connectorColor = getConnectorColor(connection.parameters.type as ConnectionTypes);
const connectorColor = getConnectorColor(
connection.parameters.type as ConnectionTypes,
connection.parameters.category,
);
return {
...CONNECTOR_PAINT_STYLE_DEFAULT,
...(connectorColor ? { stroke: getStyleTokenValue(connectorColor, true) } : {}),
};
};
export const getConnectorPaintStyleData = (connection: Connection): PaintStyle => {
const connectorColor = getConnectorColor(connection.parameters.type as ConnectionTypes);
export const getConnectorPaintStyleData = (
connection: Connection,
category?: string,
): PaintStyle => {
const connectorColor = getConnectorColor(connection.parameters.type as ConnectionTypes, category);
return {
...CONNECTOR_PAINT_STYLE_DATA,
...(connectorColor ? { stroke: getStyleTokenValue(connectorColor, true) } : {}),
@ -292,6 +305,7 @@ export const getOutputEndpointStyle = (
export const getOutputNameOverlay = (
labelText: string,
outputName: ConnectionTypes,
category?: string,
): OverlaySpec => ({
type: 'Custom',
options: {
@ -302,11 +316,16 @@ export const getOutputNameOverlay = (
label.innerHTML = labelText;
label.classList.add('node-output-endpoint-label');
label.setAttribute('data-endpoint-node-type', ep?.__meta?.nodeType);
if (ep?.__meta?.endpointLabelLength) {
label.setAttribute('data-endpoint-label-length', ep?.__meta?.endpointLabelLength);
}
if (outputName !== NodeConnectionType.Main) {
label.classList.add('node-output-endpoint-label--data');
label.classList.add(`node-connection-type-${getScope(outputName)}`);
}
if (category) {
label.classList.add(`node-connection-category-${category}`);
}
return label;
},
},

View file

@ -1978,21 +1978,25 @@ export default defineComponent({
lastSelectedNode.type,
lastSelectedNode.typeVersion,
);
if (sourceNodeType) {
const offsets = [
[-100, 100],
[-140, 0, 140],
[-240, -100, 100, 240],
];
const sourceNodeOutputs = NodeHelpers.getNodeOutputs(
workflow,
lastSelectedNode,
sourceNodeType,
);
const sourceNodeOutputTypes = NodeHelpers.getConnectionTypes(sourceNodeOutputs);
const sourceNodeOutputMainOutputs = sourceNodeOutputTypes.filter(
(output) => output === NodeConnectionType.Main,
);
if (sourceNodeOutputMainOutputs.length > 1) {
const offset = offsets[sourceNodeOutputMainOutputs.length - 2];
const sourceOutputIndex = lastSelectedConnection.__meta
@ -2005,9 +2009,9 @@ export default defineComponent({
let outputs: Array<ConnectionTypes | INodeOutputConfiguration> = [];
try {
// It fails when the outputs are an expression. As those node have
// It fails when the outputs are an expression. As those nodes have
// normally no outputs by default and the only reason we need the
// outputs here is to calculate the position it is fine to assume
// outputs here is to calculate the position, it is fine to assume
// that they have no outputs and are so treated as a regular node
// with only "main" outputs.
outputs = NodeHelpers.getNodeOutputs(workflow, newNodeData, nodeTypeData);
@ -2571,7 +2575,9 @@ export default defineComponent({
);
if (sourceInfo.type !== NodeConnectionType.Main) {
// Not "main" connections get a different connection style
info.connection.setPaintStyle(getConnectorPaintStyleData(info.connection));
info.connection.setPaintStyle(
getConnectorPaintStyleData(info.connection, info.sourceEndpoint.parameters.category),
);
endpointArrow?.setVisible(false);
}
}

View file

@ -160,7 +160,12 @@ export class Code implements INodeType {
result = await sandbox.runCodeEachItem();
} catch (error) {
if (!this.continueOnFail()) throw error;
returnData.push({ json: { error: error.message } });
returnData.push({
json: { error: error.message },
pairedItem: {
item: index,
},
});
}
if (result) {

View file

@ -16,7 +16,7 @@ export interface SandboxContext extends IWorkflowDataProxyData {
helpers: IExecuteFunctions['helpers'];
}
export const REQUIRED_N8N_ITEM_KEYS = new Set(['json', 'binary', 'pairedItem']);
export const REQUIRED_N8N_ITEM_KEYS = new Set(['json', 'binary', 'pairedItem', 'error']);
export function getSandboxContext(this: IExecuteFunctions, index: number): SandboxContext {
return {

View file

@ -920,6 +920,7 @@ export interface INodeCredentials {
[key: string]: INodeCredentialsDetails;
}
export type OnError = 'continueErrorOutput' | 'continueRegularOutput' | 'stopWorkflow';
export interface INode {
id: string;
name: string;
@ -934,6 +935,7 @@ export interface INode {
waitBetweenTries?: number;
alwaysOutputData?: boolean;
executeOnce?: boolean;
onError?: OnError;
continueOnFail?: boolean;
parameters: INodeParameters;
credentials?: INodeCredentials;
@ -1546,6 +1548,7 @@ export interface INodeInputConfiguration {
}
export interface INodeOutputConfiguration {
category?: string;
displayName?: string;
required?: boolean;
type: ConnectionTypes;
@ -1653,6 +1656,11 @@ export interface IWorkflowDataProxyData {
$thisItemIndex: number;
$now: any;
$today: any;
$getPairedItem: (
destinationNodeName: string,
incomingSourceData: ISourceData | null,
pairedItem: IPairedItemData,
) => INodeExecutionData | null;
constructor: any;
}

View file

@ -1045,21 +1045,48 @@ export function getNodeOutputs(
node: INode,
nodeTypeData: INodeTypeDescription,
): Array<ConnectionTypes | INodeOutputConfiguration> {
let outputs: Array<ConnectionTypes | INodeOutputConfiguration> = [];
if (Array.isArray(nodeTypeData.outputs)) {
return nodeTypeData.outputs;
outputs = nodeTypeData.outputs;
} else {
// Calculate the outputs dynamically
try {
outputs = (workflow.expression.getSimpleParameterValue(
node,
nodeTypeData.outputs,
'internal',
{},
) || []) as ConnectionTypes[];
} catch (e) {
throw new Error(`Could not calculate outputs dynamically for node "${node.name}"`);
}
}
// Calculate the outputs dynamically
try {
return (workflow.expression.getSimpleParameterValue(
node,
nodeTypeData.outputs,
'internal',
{},
) || []) as ConnectionTypes[];
} catch (e) {
throw new Error(`Could not calculate outputs dynamically for node "${node.name}"`);
if (node.onError === 'continueErrorOutput') {
// Copy the data to make sure that we do not change the data of the
// node type and so change the displayNames for all nodes in the flow
outputs = deepCopy(outputs);
if (outputs.length === 1) {
// Set the displayName to "Success"
if (typeof outputs[0] === 'string') {
outputs[0] = {
type: outputs[0],
};
}
outputs[0].displayName = 'Success';
}
return [
...outputs,
{
category: 'error',
type: 'main',
displayName: 'Error',
},
];
}
return outputs;
}
/**

View file

@ -121,8 +121,9 @@ export class RoutingNode {
// TODO: Think about how batching could be handled for REST APIs which support it
for (let i = 0; i < items.length; i++) {
let thisArgs: IExecuteSingleFunctions | undefined;
try {
const thisArgs = nodeExecuteFunctions.getExecuteSingleFunctions(
thisArgs = nodeExecuteFunctions.getExecuteSingleFunctions(
this.workflow,
this.runExecutionData,
runIndex,
@ -209,7 +210,7 @@ export class RoutingNode {
returnData.push(...responseData);
} catch (error) {
if (get(this.node, 'continueOnFail', false)) {
if (thisArgs !== undefined && thisArgs.continueOnFail()) {
returnData.push({ json: {}, error: error.message });
continue;
}

View file

@ -1211,6 +1211,7 @@ export class WorkflowDataProxy {
// eslint-disable-next-line @typescript-eslint/naming-convention
Duration,
...that.additionalKeys,
$getPairedItem: getPairedItem,
// deprecated
$jmespath: jmespathWrapper,