{ "nodes": [ { "parameters": { "content": "## Main/Parent Workflow\n* This starts multiple executions of the sub-workflow in parallel and then loops until they all report back.", "height": 1164, "width": 3718 }, "id": "c0e7a48c-cc0a-484b-9adb-72982ce30042", "name": "Sticky Note3", "type": "n8n-nodes-base.stickyNote", "position": [ -220, 20 ], "typeVersion": 1 }, { "parameters": { "jsCode": "let processes_metadata = $('Next processes to trigger').first().json;\nlet results = $input.first().json.items; \n\n// Loop through the results array\nresults.forEach(item => {\n let id = item.id;\n let started_at = item.started_at;\n let finished_at = item.finished_at;\n let result = item.result;\n let status = item.status;\n\n // If this result ID is not already in processes_metadata.results, add it\n if (!processes_metadata.results.some(existingResult => existingResult.id === id)) {\n processes_metadata.results.push({ id, started_at, finished_at, status, result });\n }\n});\n\nreturn {\n json: {\n processes: processes_metadata.processes,\n results: processes_metadata.results\n }\n};\n" }, "id": "7302acf3-f836-48a3-bc99-442734fee0ad", "name": "Update results", "type": "n8n-nodes-base.code", "position": [ 3280, 940 ], "typeVersion": 2 }, { "parameters": { "assignments": { "assignments": [ { "id": "b2c3bf80-5130-4da6-906f-d512a57eac13", "name": "webhook_url", "value": "={{ $('Entrypoint').item.json.webhookUrl.split('/').slice(0, 3).join('/') }}", "type": "string" }, { "id": "e16769b3-fa71-4031-ba02-a18901b4be32", "name": "webhook_route", "value": "={{ '/' + $('Entrypoint').item.json.webhookUrl.split('/').slice(4).join('/') }}", "type": "string" }, { "id": "525bcf37-0aaf-49a3-a74d-68d3cb7a9874", "name": "nodes", "value": "={{ $input.first().json.body.nodes }}", "type": "array" }, { "id": "615cddac-e950-4780-9f6f-e3232a08fdb7", "name": "queue_name", "value": "={{ ['queue', $execution.id].join('_') }}", "type": "string" }, { "id": "1ccd0ac4-03fa-481c-b9b5-196060680b88", "name": "started_at", "value": "={{ new Date().toISOString() }}", "type": "string" } ] }, "options": {} }, "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [ 0, 200 ], "id": "101b99a5-8abe-483c-ba45-2c04741b19d3", "name": "Necessary data" }, { "parameters": { "method": "={{ $json.method }}", "url": "={{ $json.url }}", "sendBody": true, "bodyParameters": { "parameters": [ { "name": "request_id", "value": "={{ $json.id }}" }, { "name": "data", "value": "={{ $json.data || {} }}" }, { "name": "=nodes", "value": "={{ $json.nodes || {} }}" } ] }, "options": {} }, "id": "3a0b2b0a-a301-45b7-bcdd-a7a27a6c9ed2", "name": "Sub-workflow execution", "type": "n8n-nodes-base.httpRequest", "position": [ 260, 960 ], "typeVersion": 4.2, "alwaysOutputData": false, "onError": "continueRegularOutput" }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "3b74ff52-9157-4902-8e51-621911fdc123", "leftValue": "={{ $json.are_valid }}", "rightValue": "", "operator": { "type": "boolean", "operation": "false", "singleValue": true } } ], "combinator": "and" }, "options": {} }, "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 340, 200 ], "id": "4d15cd4c-50b0-4db7-8ff0-d001a6c2a75c", "name": "Has invalid node(s)?" }, { "parameters": { "jsCode": "let error_metadata = $input.first().json;\n\n// Now you can use the JSON string in your workflow expression\nreturn [{\n json: {\n errors: error_metadata.errors\n }\n}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1440, 180 ], "id": "bcd1e251-0199-4a54-87cd-8bc1d94686bb", "name": "Collect errors" }, { "parameters": { "jsCode": "let nodes = $('Has invalid node(s)?').first().json.nodes;\n\nconst graph_metadata = $input.first().json;\n\n// Augmenting nodes with metadata from graph\nconst augmentedGraphMetadata = {\n ...graph_metadata, // Keep the original graph_metadata structure\n graph: graph_metadata.graph.map(gNode => {\n // Find corresponding node in the 'nodes' array\n const node = nodes.find(n => String(n.id) === String(gNode.id));\n if (node) {\n return {\n ...gNode,\n ...node\n };\n }\n return gNode;\n })\n};\n\nreturn augmentedGraphMetadata;\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 980, 640 ], "id": "d457b040-72ab-4651-ae18-ef923b4d6f05", "name": "Augment graph nodes" }, { "parameters": { "inputSource": "passthrough" }, "type": "n8n-nodes-base.executeWorkflowTrigger", "typeVersion": 1.1, "position": [ -120, 960 ], "id": "f26e82cf-9805-4a60-9d28-f74698e6456f", "name": "Execute process node", "notesInFlow": false }, { "parameters": { "httpMethod": "POST", "path": "nodes", "responseMode": "responseNode", "options": {} }, "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [ -160, 200 ], "id": "7876790b-050d-4058-908e-e0c513947466", "name": "Entrypoint", "webhookId": "afe2f29e-c82c-446a-90c7-262e63ceb188" }, { "parameters": { "options": {} }, "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.1, "position": [ 1940, 320 ], "id": "0ed67d78-f126-4607-9b6f-85ee7ee8b142", "name": "Respond to Webhook" }, { "parameters": { "jsCode": "const graph_ = $('Build graph').first().json.graph;\nconst groups = $('Build groups').first().json.groups;\n\n// Function to detect cycles in the graph using DFS and track multiple cycles\nconst detect_cycles = (graph) => {\n const visited = new Set();\n const inRecursionStack = new Set(); // Track nodes in the current DFS path\n const allCycles = []; // To store all cycles\n\n // ✅ Step 1: Check for self-referencing nodes first\n for (const node in graph) {\n if (graph[node].dependents.includes(node)) {\n allCycles.push([node]); // Self-loop detected, only one node in the cycle\n }\n }\n\n // ✅ Step 2: DFS function to traverse the graph and detect all cycles\n const dfs = (node, path, pathSet) => {\n if (pathSet.has(node)) {\n // ✅ Found a cycle, extract it from the path\n const cycleIndex = path.indexOf(node);\n allCycles.push([...path.slice(cycleIndex), node]); // Ensure it loops back\n return;\n }\n\n if (visited.has(node)) return;\n\n visited.add(node);\n pathSet.add(node);\n path.push(node);\n\n if (graph[node] && Array.isArray(graph[node].dependents)) {\n for (const child of graph[node].dependents) {\n dfs(child, path, pathSet);\n }\n }\n\n pathSet.delete(node);\n path.pop();\n };\n\n // ✅ Step 3: Check for cycles starting from all unvisited nodes\n for (const node of Object.keys(graph)) {\n if (!visited.has(node)) {\n dfs(node, [], new Set());\n }\n }\n\n return allCycles;\n};\n\n// Function to extract a subgraph (group_graph) based on a group\nconst get_group_graph = (group, graph) => {\n let groupGraph = {};\n\n // Create a set for fast lookup\n const groupSet = new Set(group);\n\n // Include only nodes that are part of the current group\n group.forEach(nodeId => {\n const node = graph.find(n => n.id === nodeId);\n if (node) {\n groupGraph[nodeId] = {\n id: node.id,\n depends_on: (node.depends_on || []).filter(dep => groupSet.has(dep)),\n dependents: (node.dependents || []).filter(dep => groupSet.has(dep))\n };\n }\n });\n\n return groupGraph;\n};\n\n// Detect cycles in each group and return the results\nconst groups_with_cycles = groups.map(group => {\n const group_graph = get_group_graph(group, graph_);\n const cycles = detect_cycles(group_graph);\n \n return { \n nodes: group, \n has_cycles: cycles.length > 0,\n cycles: cycles // Always an array\n };\n});\n\n// Return the result with cycle detection for each group\nreturn [{\n json: {\n groups: groups_with_cycles\n }\n}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 360, 620 ], "id": "6ca2bb62-72d4-4167-a6b6-91983fa9e49e", "name": "Detect cycles", "alwaysOutputData": true }, { "parameters": { "jsCode": "// Extract nodes safely\nconst nodes = $input.first().json.nodes;\n\n// Create adjacency list representation\nlet graph = {};\n\n// Temporary storage for nodes with unresolved dependencies\nlet deferredNodes = [];\n\n// Build graph and handle dependents in a single pass\nnodes.forEach(node => {\n // Initialize node with its dependencies and dependents\n graph[node.id] = {\n id: node.id,\n depends_on: node.depends_on || [],\n dependents: [] // Initialize dependents as empty\n };\n\n // Ensure all dependencies are valid and populate dependents if the parent exists\n node.depends_on.forEach(parent_id => {\n if (graph[parent_id]) {\n // If parent exists, add the current node as a dependent\n graph[parent_id].dependents.push(node.id);\n } else {\n // If parent doesn't exist yet, store the current node for later processing\n deferredNodes.push({ node, parent_id });\n }\n });\n});\n\n// Handle deferred nodes after all other nodes are processed\ndeferredNodes.forEach(deferred => {\n const { node, parent_id } = deferred;\n // At this point, the parent should be in the graph, so we can safely add it\n if (graph[parent_id]) {\n graph[parent_id].dependents.push(node.id);\n } else {\n // Optionally handle this case where the parent still doesn't exist (shouldn't happen)\n console.error(`Error: Parent '${parent_id}' still not found when processing deferred node '${node.id}'`);\n }\n});\n\n// Ensure the graph is in array format for return\nconst graphArray = Object.values(graph);\n\n// Return the graph in the correct format\nreturn [{ json: { graph: graphArray } }];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 40, 620 ], "id": "1f99e598-dc3b-43bd-9da2-a6985137d937", "name": "Build graph" }, { "parameters": { "jsCode": "let graph_ = $input.first().json.graph;\n\n// Function to find connected components using BFS\nconst findConnectedComponentsBFS = (graph) => {\n let visited = new Set();\n let components = [];\n\n // BFS traversal starting from a given node\n const bfs = (startNodeId) => {\n let queue = [startNodeId];\n let component = [];\n visited.add(startNodeId);\n\n while (queue.length > 0) {\n let nodeId = queue.shift();\n component.push(nodeId);\n\n // Find the node by its ID in the graph\n const node = graph.find(n => n.id === nodeId);\n\n // Process 'depends_on' nodes (parents)\n if (node && Array.isArray(node.depends_on)) {\n node.depends_on.forEach(parent => {\n if (!visited.has(parent)) {\n visited.add(parent);\n queue.push(parent); // Add parent to the queue\n }\n });\n }\n\n // Process 'dependents' nodes (children)\n if (node && Array.isArray(node.dependents)) {\n node.dependents.forEach(child => {\n if (!visited.has(child)) {\n visited.add(child);\n queue.push(child); // Add child to the queue\n }\n });\n }\n }\n\n return component;\n };\n\n // Iterate through all nodes and perform BFS for each unvisited node\n graph.forEach(node => {\n if (!visited.has(node.id)) {\n let component = bfs(node.id);\n components.push(component);\n }\n });\n\n return components;\n};\n\n// Get the independent dependency groups (connected components)\nconst dependencyGroups = findConnectedComponentsBFS(graph_);\n\n// Return the result in the correct format\nreturn [{ json: { groups: dependencyGroups } }];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 200, 620 ], "id": "85411a86-4d52-40f8-b796-6f68bcb5994c", "name": "Build groups" }, { "parameters": { "jsCode": "// let nodes = $input.first().json.body.nodes;\nlet nodes = $input.first().json.nodes\n\n// Ensure requests exist and is an array\nif (!nodes || !Array.isArray(nodes)) {\n throw new Error(\"Invalid input: 'nodes' must be an array\");\n}\n\n// Aggregate all 'depends_on' IDs in a Set\nlet all_dependencies = new Set();\nnodes.forEach(node => {\n node.depends_on.forEach(parent_id => {\n all_dependencies.add(parent_id);\n });\n});\n\nlet node_ids = new Set();\nnodes.forEach(node => {\n node_ids.add(node.id);\n});\n\n// Perform the difference check\nlet invalid_dependencies = [...all_dependencies].filter(dep => !node_ids.has(dep));\n\n// Throw an error if there are any invalid dependencies\nif (invalid_dependencies.length > 0) {\n throw new Error(`Invalid dependencies found: ${invalid_dependencies.join(', ')}`);\n}\n\nreturn [{ json: { nodes: nodes } }]\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -120, 620 ], "id": "a2f5d875-e5ee-4e4e-b6e9-9e27aec1ca4d", "name": "Validate input" }, { "parameters": { "content": "## Processes listener\n\n", "height": 460, "width": 980, "color": 5 }, "type": "n8n-nodes-base.stickyNote", "position": [ 2460, 640 ], "typeVersion": 1, "id": "4019a346-5a32-4a16-b03e-9ecc21398bd2", "name": "Sticky Note5" }, { "parameters": { "content": "## Validate input", "height": 320, "width": 1620, "color": 6 }, "type": "n8n-nodes-base.stickyNote", "position": [ -40, 140 ], "typeVersion": 1, "id": "ce152dc4-3e78-4f22-bc2e-af6aab4124d6", "name": "Sticky Note6" }, { "parameters": { "content": "## Processes setup", "height": 340, "width": 420, "color": 5 }, "type": "n8n-nodes-base.stickyNote", "position": [ 900, 520 ], "typeVersion": 1, "id": "1b215320-92d5-42ed-bd75-c7098695b744", "name": "Sticky Note7" }, { "parameters": { "jsCode": "let next_processes = $input.first().json.next_processes; \n\nlet item;\nlet items = []; // Initialize the array to hold new items\n\n// Loop over next_processes and transform each item\nfor (let i = 0; i < next_processes.length; i++) {\n item={\n pairedItem: i,\n json: next_processes[i]\n };\n items.push(item);\n}\n\nreturn items;\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2040, 860 ], "id": "5eaac90d-12cf-4379-9d49-6aa015575ad3", "name": "Unwrap current processes" }, { "parameters": { "jsCode": "const results = $input.first().json.results;\nlet processes = $input.first().json.processes;\n\n// Step 1: Extract result IDs and status mapping\nconst result_ids = new Set(results.map(result => result.id));\n\nconst status_map = results.reduce((acc, item) => {\n acc[String(item.id)] = item.status;\n return acc;\n}, {});\n\nconst result_map = results.reduce((acc, item) => {\n acc[String(item.id)] = {\n status: item.status,\n result: item.result ? JSON.parse(Buffer.from(item.result, \"base64\").toString(\"utf-8\")) : null\n };\n return acc;\n}, {});\n\n// Step 2: Filter out processes already in results or triggered\nlet filtered_processes = processes.filter(\n process => !result_ids.has(process.id) && !process.triggered\n);\n\n// Step 3: Determine the next processes that can be triggered\nlet next_processes = filtered_processes.filter(process => {\n let dependencies = process.depends_on || []; // Ensure it's an array\n\n if (dependencies.length === 0) {\n return true;\n }\n\n // Check if ALL dependencies exist in results & are \"healthy\"\n let dependencies_met = dependencies.every(dep_id => {\n return result_ids.has(String(dep_id)) && status_map[dep_id] === \"healthy\";\n } \n );\n\n return dependencies_met;\n});\n\n// Step 4: Extend dependent processes' data with decoded results of dependencies\nnext_processes = next_processes.map(process => {\n let dependencies = process.depends_on || [];\n\n let merged_data = dependencies.reduce((acc, dep_id) => {\n if (result_map[dep_id]?.result) {\n acc = { ...acc, ...result_map[dep_id].result };\n }\n return acc;\n }, {});\n\n return {\n ...process,\n triggered: true,\n data: { ...(process.data || {}), ...merged_data }\n };\n});\n\n// Step 5: Mark selected processes as triggered\nprocesses = processes.map(process => ({\n ...process,\n triggered: next_processes.some(p => String(p.id) === String(process.id)) || process.triggered\n}));\n\nnext_processes = next_processes.map(process => ({\n ...process,\n triggered: true\n}));\n\nreturn {\n json: { processes, next_processes, results }\n};\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1640, 720 ], "id": "af02dde4-820f-49ac-8b1b-7c105faf2af1", "name": "Next processes to trigger" }, { "parameters": { "content": "## Processes triggerer\n\n", "height": 460, "width": 860, "color": 5 }, "type": "n8n-nodes-base.stickyNote", "position": [ 1560, 640 ], "typeVersion": 1, "id": "1dcc6636-269d-45b2-accf-35c73a2272f2", "name": "Sticky Note9", "disabled": true }, { "parameters": { "content": "## Sub-workflow \"HTTP requests calls back parent workflow\"\"\n\nThis subworkflow trigger calls back the parent workflow to allow request management.", "height": 300, "width": 1020, "color": 5 }, "type": "n8n-nodes-base.stickyNote", "position": [ -180, 840 ], "typeVersion": 1, "id": "7910adcf-eb57-40a8-a875-86ffbb2feb1c", "name": "Sticky Note10" }, { "parameters": { "content": "", "height": 640, "width": 2600, "color": 4 }, "type": "n8n-nodes-base.stickyNote", "position": [ 880, 500 ], "typeVersion": 1, "id": "0789d9f4-eebb-4682-be09-a1a9b19de9e7", "name": "Sticky Note" }, { "parameters": { "conditions": { "options": { "version": 1, "leftValue": "", "caseSensitive": true, "typeValidation": "strict" }, "conditions": [ { "id": "385c3149-3623-4dd2-9022-770c32f82421", "operator": { "type": "boolean", "operation": "true", "singleValue": true }, "leftValue": "={{ !!$json.groups.some(group => group.cycles && group.cycles.length > 0) }}", "rightValue": "=true" } ], "combinator": "or" }, "options": {} }, "id": "bb1c0de0-df73-4a84-831b-c6d2776768f6", "name": "Has cycles?", "type": "n8n-nodes-base.if", "position": [ 900, 300 ], "typeVersion": 2 }, { "parameters": { "jsCode": "const webhook_url = $('Necessary data').first().json.webhook_url;\nconst webhook_route = $('Necessary data').first().json.webhook_route;\nconst webhook_uri = `${webhook_url}/webhook${webhook_route}`;\nconst queue_name = $('Necessary data').first().json.queue_name;\n\nconst graph = $input.first().json.graph;\nconst groups = $input.first().json.groups;\n\nfunction random_str(length = 10) {\n if (typeof length !== 'number' || length <= 0) {\n throw new Error(\"Length must be a positive integer.\");\n }\n\n // Generate a random string with the specified length\n return Math.random().toString(36).substring(2, 2 + length).padEnd(length, '0');\n}\n\nfunction augment_node(node_id) {\n const node_data = graph.find(node => String(node.id) === String(node_id));\n return {\n ...node_data,\n triggered: false,\n queue_name: queue_name,\n };\n}\n\n// Step 1: Extract full node data for a given group\nfunction get_group_nodes(group) { \n return group.nodes.map(augment_node);\n}\n\nfunction cast_group_to_process(group) {\n let process;\n \n const nodes_data = get_group_nodes(group);\n\n if (nodes_data.length === 1) {\n // Single-node group → Treat it as a standalone process\n process = {\n ...nodes_data[0]\n };\n } else {\n // Multi-node group → Wrap in a process object\n process = {\n id: `group_${random_str()}`,\n url: webhook_uri,\n method: \"POST\",\n nodes: nodes_data,\n queue_name: queue_name,\n depends_on: [],\n triggered: false,\n };\n }\n\n return process;\n}\n\nfunction cast_groups_to_processes(groups) {\n let processes = [];\n \n if (groups.length === 1) {\n const group = groups[0];\n processes = get_group_nodes(group); // For single group, directly extract nodes\n } else {\n processes = groups.map(cast_group_to_process); \n }\n\n return processes; // Return the processes array\n}\n\nreturn {\n json: {\n processes: cast_groups_to_processes(groups),\n results: [],\n }\n};\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1160, 640 ], "id": "440c4bc8-2733-4a80-8f63-7c211b58e255", "name": "Build processes and results" }, { "parameters": { "jsCode": "let graph = $('Build graph').first().json.graph;\nlet groups = $('Identify sources, sinks and trails').first().json.groups;\n\n\n// Topological Sort Function\nconst topological_sort = (group, graph) => {\n if (!Array.isArray(group)) {\n console.error('Expected an array for group, but got:', typeof group);\n return null; // Return null if group is not an array\n }\n\n let inDegree = {}; // Track incoming edges for each node\n let queue = []; // Queue to hold nodes with no incoming edges\n let sorted = []; // Array to store the topologically sorted nodes\n\n // Initialize in-degree for each node in the group\n group.forEach(node => {\n const nodeData = graph.find(n => n.id === node);\n if (nodeData && Array.isArray(nodeData.depends_on)) {\n inDegree[node] = nodeData.depends_on.length;\n } else {\n inDegree[node] = 0; // No dependencies, so no incoming edges\n }\n\n if (inDegree[node] === 0) queue.push(node); // If in-degree is 0, add to the queue\n });\n\n while (queue.length > 0) {\n let node = queue.shift(); // Get node from the front of the queue\n sorted.push(node); // Add to the sorted order\n\n const nodeData = graph.find(n => n.id === node);\n if (nodeData && Array.isArray(nodeData.dependents)) {\n nodeData.dependents.forEach(child => {\n inDegree[child]--; // Reduce in-degree of the dependent node\n if (inDegree[child] === 0) queue.push(child); // Add to queue if in-degree becomes 0\n });\n }\n }\n\n // If the sorted array does not contain all nodes, it means there was a cycle\n if (sorted.length !== group.length) {\n return null; // Return null if there's a cycle\n }\n\n return sorted;\n};\n\n// Apply sorting per group and skip groups with cycles\nconst sortedGroups = groups.map(group => {\n const sorted = topological_sort(group, graph);\n return sorted ? sorted : group; // Return sorted nodes or original group if there's a cycle\n});\n\n// Return the result with sorted groups\nreturn [{\n json: {\n graph: graph,\n groups: sortedGroups\n }\n}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 700, 620 ], "id": "5516ac69-d8c0-40ef-8ceb-fd47f97baad4", "name": "Sort topologically" }, { "parameters": {}, "type": "n8n-nodes-base.noOp", "typeVersion": 1, "position": [ 2160, 720 ], "id": "6998b219-609f-4531-9c41-2767505dd8a6", "name": "No Operation, do nothing" }, { "parameters": { "jsCode": "const groups = $input.first().json.groups;\n\n// Function to describe errors based on detected cycles and group messages by group\nconst describeErrors = (groups) => {\n const errors = {};\n\n groups.forEach(group => {\n if (group.has_cycles) {\n group.cycles.forEach(cycle => {\n // Determine the cycle message\n const cyclePath = cycle.join(\" → \");\n const message = new Set(cycle).size === 1\n ? `Self-loop detected on node: ${cycle[0]}`\n : `Cycle detected among nodes: ${cyclePath}`;\n\n // Group errors by group of nodes\n const groupKey = JSON.stringify(group.nodes);\n if (!errors[groupKey]) {\n errors[groupKey] = { group: group.nodes, messages: [] };\n }\n errors[groupKey].messages.push(message);\n });\n }\n });\n\n // Convert the grouped errors object into an array\n return { errors: Object.values(errors) };\n};\n\n// Generate error report based on the detected cycles\nconst errorReport = describeErrors(groups);\n\n// Return the result with grouped errors\nreturn [{\n json: {\n errors: errorReport.errors\n }\n}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1160, 300 ], "id": "aca6b9f7-057c-48ec-9868-3e1c5c54f707", "name": "Build graph errors" }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "237c5879-d2f7-4c9f-bc02-160689bec635", "leftValue": "={{ $json.next_processes.length }}", "rightValue": 0, "operator": { "type": "number", "operation": "equals" } } ], "combinator": "and" }, "options": {} }, "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 1860, 720 ], "id": "c30f61b0-4460-40ca-887f-fd76ec5f3fd4", "name": "Has processes to trigger?" }, { "parameters": { "amount": 0.1 }, "id": "f170b789-f57e-4818-8bb8-435dc66d08b2", "name": "Wait 1s to check results", "type": "n8n-nodes-base.wait", "position": [ 2520, 720 ], "webhookId": "5cd058b4-48c8-449a-9c09-959a5b8a2b48", "typeVersion": 1.1 }, { "parameters": { "operation": "pop", "list": "={{ $('Necessary data').first().json.queue_name }}", "propertyName": "data", "options": {} }, "type": "n8n-nodes-base.redis", "typeVersion": 1, "position": [ 3120, 720 ], "id": "76c8df3c-0e0c-444d-87fd-f394afc85d6f", "name": "Retrieve results", "credentials": { "redis": { "id": "HbfVHhSpqgEREfAR", "name": "Redis account" } } }, { "parameters": { "jsCode": "// Get the existing items array\nlet items = $('Has queue items?').first().json.items || [];\n\n// Get the new item\nlet item = $input.first().json.data;\n\n// Check if item is not null or undefined\nif (item !== null) {\n items.push(item);\n}\n\nreturn {\n json: {\n items,\n data: item\n } \n};\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 3280, 720 ], "id": "ce12d6d4-20b2-45d9-a080-d8ccb910d098", "name": "Update items" }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "loose", "version": 2 }, "conditions": [ { "id": "826a6087-6461-42e3-9ff0-96dd6d03250e", "leftValue": "={{ $json.data }}", "rightValue": "", "operator": { "type": "string", "operation": "exists", "singleValue": true } } ], "combinator": "or" }, "looseTypeValidation": true, "options": {} }, "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 2940, 720 ], "id": "322fc02b-3818-41b2-bd89-c22207428919", "name": "Has queue items?" }, { "parameters": { "assignments": { "assignments": [ { "id": "effae367-9aa0-4c6b-ad52-46b50b9dd1d8", "name": "items", "value": "={{ $json.items }}", "type": "array" } ] }, "options": {} }, "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [ 3120, 940 ], "id": "cb5c039b-e97d-4cc3-a765-c7de8f5fe7a9", "name": "Get items" }, { "parameters": { "workflowId": { "__rl": true, "value": "={{ $workflow.id }}", "mode": "id" }, "workflowInputs": { "mappingMode": "defineBelow", "value": {}, "matchingColumns": [], "schema": [], "attemptToConvertTypes": false, "convertFieldsToString": true }, "mode": "each", "options": { "waitForSubWorkflow": false } }, "type": "n8n-nodes-base.executeWorkflow", "typeVersion": 1.2, "position": [ 2260, 860 ], "id": "e263b8d6-685f-4670-9a80-e11ab6747c4a", "name": "Execute Workflow" }, { "parameters": { "jsCode": "const resultHash = Buffer.from(JSON.stringify($json)).toString('base64');\n\nconst serialized_object = {\n \"id\": $('Execute process node').item.json.id,\n \"started_at\": $('Add timestamp started_at').item.json.started_at,\n \"finished_at\": new Date().toISOString(),\n \"result\": resultHash,\n \"status\": $json.error ? 'unhealthy' : 'healthy'\n};\n\nreturn serialized_object;\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 460, 960 ], "id": "caf02587-26cd-4bd7-9b4f-0e17c3776021", "name": "Prepare result object" }, { "parameters": { "content": "## Nodes into Graphs \n\nThis workflow converts nodes into graphs. ", "height": 300, "width": 1020, "color": 5 }, "type": "n8n-nodes-base.stickyNote", "position": [ -180, 500 ], "typeVersion": 1, "id": "bdec6fa1-bb37-4ed0-97e6-0870632e21b3", "name": "Sticky Note8" }, { "parameters": { "jsCode": "const nodes = $input.first().json.nodes;\n\n// Helper function to validate HTTP/HTTPS URLs using regex\nfunction isValidURL(url) {\n const pattern = /^(https?:\\/\\/)([\\w.-]+)(:\\d+)?(\\/[^\\s]*)?$/i;\n return typeof url === \"string\" && pattern.test(url);\n}\n\n// Regular expression to detect IDs starting with 'group_' followed by one or more digits\nconst groupIdPattern = /^group_[a-zA-Z0-9]+$/;\n\n// Set to track unique IDs\nconst uniqueIds = new Set();\nconst validMethods = new Set(['GET', 'POST', 'PUT', 'DELETE', 'PATCH']);\n\nlet errors = [];\n\nconst validatedNodes = nodes.map((node, index) => {\n const { id, method, url, depends_on } = node;\n let nodeErrors = [];\n\n // Validate ID\n if (id == null) {\n nodeErrors.push(\"Missing or invalid ID\");\n } else if (uniqueIds.has(id)) {\n nodeErrors.push(`Duplicate ID found: ${id}`);\n } else if (groupIdPattern.test(String(id))) {\n nodeErrors.push(`ID matches forbidden pattern 'group_\\\\d+': ${id}`);\n } else {\n uniqueIds.add(id);\n }\n\n // Validate method\n if (!method || !validMethods.has(method.toUpperCase())) {\n nodeErrors.push(\"Invalid or missing HTTP method\");\n }\n\n // Validate URL\n if (!url || typeof url !== 'string') {\n nodeErrors.push(\"Missing URL\");\n } else if (!isValidURL(url)) {\n nodeErrors.push(`Invalid URL: ${url}`);\n }\n\n // Ensure `depends_on` is an array\n if (depends_on !== undefined && !Array.isArray(depends_on)) {\n nodeErrors.push(\"depends_on must be an array\");\n }\n\n // Collect errors for the current node\n if (nodeErrors.length > 0) {\n errors.push({ node_id: id ?? `unknown-${index}`, issues: nodeErrors });\n }\n\n return {\n ...node,\n id: id ?? `auto-${index}`, // Assign automatic ID if missing\n method,\n url,\n depends_on: Array.isArray(depends_on) ? depends_on : []\n };\n});\n\n// Determine if all nodes are valid\nconst are_valid = errors.length === 0;\n\nreturn {\n are_valid,\n nodes: validatedNodes,\n errors\n};\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 180, 200 ], "id": "5001de6f-4638-4feb-a668-5746e135a117", "name": "Validation node" }, { "parameters": { "jsCode": "return {\n json: {\n items: [],\n data: {}\n }\n};" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2740, 720 ], "id": "2841e875-0412-4823-a455-d32937033656", "name": "Initialize items and data" }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "7230b151-8c25-4ceb-8e98-11aa1e51612b", "leftValue": "={{ $json.results.some(result => result.status === \"unhealthy\")\n }}", "rightValue": "", "operator": { "type": "boolean", "operation": "true", "singleValue": true } }, { "id": "f751b342-898e-48d6-ad37-ffffb6b33e23", "leftValue": "={{ $json.results.length }}", "rightValue": "={{ $json.processes.length }}", "operator": { "type": "number", "operation": "gte" } } ], "combinator": "or" }, "options": {} }, "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 1300, 920 ], "id": "06b7ba85-0429-4359-916d-7bbb41458b41", "name": "Are results healthy or fully collected?" }, { "parameters": { "jsCode": "let metadata = $input.first();\nlet nodes = metadata.json.nodes.map(node => ({\n ...node,\n id: String(node.id), // Ensure 'id' is a string\n depends_on: node.depends_on ? node.depends_on.map(dep => String(dep)) : [] // Cast each 'depends_on' entry to string\n}));\n\nreturn {\n json: {\n ...metadata,\n nodes: nodes\n }\n};\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 520, 300 ], "id": "c3008da7-8704-484d-9da8-af372d0b5544", "name": "Cast ids to string" }, { "parameters": { "jsCode": "// Retrieve JSON data safely\nlet detectCyclesJson = $('Detect cycles').first().json || {};\nlet groups = detectCyclesJson.groups || [];\n\nconst graphArray = $('Build graph').first().json.graph;\n\n// Ensure graphArray is an array before calling reduce\nif (!Array.isArray(graphArray)) {\n console.error(\"Expected an array for graph, but got:\", typeof graphArray);\n return [{ json: { error: \"Graph data is not an array\" } }];\n}\n\n// Convert graph array to an adjacency list representation using arrays\nconst graph_ = graphArray.reduce((acc, node) => {\n if (!node.id) {\n console.warn(\"Node missing 'id' property:\", node);\n return acc; // Skip invalid nodes\n }\n // Store as arrays\n acc[node.id] = {\n depends_on: node.depends_on || [], // Store as array\n dependents: node.dependents || [] // Store as array\n };\n return acc;\n}, {});\n\n// Function to extract a subgraph (group_graph) based on a group's nodes\nconst get_group_graph = (group_nodes, graph) => {\n let groupGraph = {};\n\n // Create an array for fast lookup\n const groupArray = group_nodes;\n\n // Include only nodes that are part of the current group\n group_nodes.forEach(nodeId => {\n if (graph[nodeId]) {\n groupGraph[nodeId] = {\n depends_on: (graph[nodeId].depends_on || []).filter(dep => groupArray.includes(dep)),\n dependents: (graph[nodeId].dependents || []).filter(dep => groupArray.includes(dep))\n };\n } else {\n console.warn(`Node ${nodeId} is missing from graph`);\n groupGraph[nodeId] = { depends_on: [], dependents: [] }; // Ensure structure exists\n }\n });\n\n return groupGraph;\n};\n\n// Function to perform DFS to find trails from source to sink\nconst find_trails = (graph, sources, sinks) => {\n const trails = [];\n \n // Helper function for DFS\n const dfs = (node, visited, path) => {\n // If we've reached a sink node, store the path as a trail\n if (sinks.includes(node)) {\n trails.push([...path, node]);\n return;\n }\n\n visited.add(node); // Mark the node as visited\n path.push(node); // Add node to the current path\n\n // Explore all dependent nodes\n for (const dep of graph[node].dependents) {\n if (!visited.has(dep)) {\n dfs(dep, visited, path);\n }\n }\n\n path.pop(); // Backtrack to explore other possible paths\n visited.delete(node); // Unmark the node\n };\n\n // Start DFS from each source node\n sources.forEach(source => {\n const visited = new Set();\n dfs(source, visited, []);\n });\n\n return trails;\n};\n\n// Function to identify source and sink nodes in a graph\nconst find_sources_and_sinks = (graph) => {\n const sources = [];\n const sinks = [];\n\n for (const node in graph) {\n console.log(`Finding source and sink of node ${node}`);\n console.log(`Node ${node} object: ${graph[node]}`);\n \n // If the node has no dependencies, it is a source\n if ((graph[node].depends_on || []).length === 0) {\n sources.push(node);\n }\n\n // If the node has no dependents, it is a sink\n if ((graph[node].dependents || []).length === 0) {\n sinks.push(node);\n }\n }\n\n return { sources, sinks };\n};\n\n// Function to analyze each group and label source and sink nodes\ngroups = groups.map(group => {\n if (!group.nodes) {\n return { ...group, sources: [], sinks: [] };\n }\n\n const group_graph = get_group_graph(group.nodes, graph_);\n const { sources, sinks } = find_sources_and_sinks(group_graph);\n\n return {\n ...group,\n sources: sources,\n sinks: sinks\n };\n});\n\n\n// Function to process each group and find the trails from sources to sinks\ngroups = groups.map(group => {\n if (!group.nodes) {\n return { ...group, trails: [] }; // No nodes in group, no trails\n }\n\n const group_graph = get_group_graph(group.nodes, graph_);\n const { sources, sinks } = find_sources_and_sinks(group_graph);\n \n // Find the trails from sources to sinks within the group\n const trails = find_trails(group_graph, sources, sinks);\n\n return {\n ...group,\n sources: sources,\n sinks: sinks,\n trails: trails\n };\n});\n\n// Return the result with source and sink node information for each group\nreturn [{\n json: {\n groups\n }\n}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 520, 620 ], "id": "07147d9d-18b9-4b6e-b98d-54deea6a6c65", "name": "Identify sources, sinks and trails" }, { "parameters": { "jsCode": "let process_info = $input.first().json;\n\nreturn {\n json: {\n ...process_info,\n started_at: new Date().toISOString(),\n }\n};" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 60, 960 ], "id": "11daed45-1942-4a93-b476-7a648dc7944f", "name": "Add timestamp started_at" }, { "parameters": { "operation": "push", "list": "={{ $('Execute process node').item.json.queue_name }}", "messageData": "={\n \"id\": \"{{ $json.id }}\",\n \"started_at\": \"{{ $json.started_at }}\",\n \"finished_at\": \"{{ $json.finished_at }}\",\n \"result\": \"{{ $json.result }}\",\n \"status\": \"{{ $json.status }}\"\n}\n" }, "type": "n8n-nodes-base.redis", "typeVersion": 1, "position": [ 660, 960 ], "id": "9c5c8099-a4f4-4f28-86b0-5dde802d283a", "name": "Redis1", "credentials": { "redis": { "id": "HbfVHhSpqgEREfAR", "name": "Redis account" } } }, { "parameters": { "jsCode": "return {\n json: {\n results: $input.first().json.results,\n },\n};\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1440, 540 ], "id": "23c99cd4-2d5b-4f36-9bca-6042de56f821", "name": "Collect results" }, { "parameters": { "jsCode": "let started_at = $('Necessary data').first()?.json?.started_at ?? null;\nlet finished_at = new Date().toISOString();\nlet results = [];\nlet errors = [];\n\ntry {\n // Retrieve the results\n const rawResults = $('Collect results').first()?.json?.results ?? [];\n\n rawResults.forEach(node => {\n if (/^group_[a-zA-Z0-9]+$/.test(node.id) && typeof node.result === 'string') {\n // Decode Base64 if it's a group\n try {\n node.result = JSON.parse(Buffer.from(node.result, 'base64').toString('utf-8'));\n\n console.log(node.result);\n\n // If the result is a group, introspect and get the results array\n if (Array.isArray(node.result?.results)) {\n // Start a new array instead of appending\n results.push(...node.result.results);\n }\n } catch (decodeError) {\n console.error(`Failed to decode Base64 for group ${node.id}:`, decodeError);\n }\n } else {\n // For non-group nodes, add them directly to the results array\n results.push(node);\n }\n });\n} catch (e) {\n console.error(\"Error processing results:\", e);\n}\n\ntry {\n // Retrieve any errors\n errors = $('Collect errors').first()?.json?.errors ?? [];\n} catch (e) {\n console.error(\"Error processing errors:\", e);\n}\n\nreturn {\n json: {\n started_at,\n finished_at,\n results,\n errors\n }\n};\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1700, 320 ], "id": "4ff84764-6d62-4a2d-9884-20228d75b436", "name": "Consolidate output" } ], "connections": { "Update results": { "main": [ [ { "node": "Are results healthy or fully collected?", "type": "main", "index": 0 } ] ] }, "Necessary data": { "main": [ [ { "node": "Validation node", "type": "main", "index": 0 } ] ] }, "Sub-workflow execution": { "main": [ [ { "node": "Prepare result object", "type": "main", "index": 0 } ] ] }, "Has invalid node(s)?": { "main": [ [ { "node": "Collect errors", "type": "main", "index": 0 } ], [ { "node": "Cast ids to string", "type": "main", "index": 0 } ] ] }, "Collect errors": { "main": [ [ { "node": "Consolidate output", "type": "main", "index": 0 } ] ] }, "Augment graph nodes": { "main": [ [ { "node": "Build processes and results", "type": "main", "index": 0 } ] ] }, "Execute process node": { "main": [ [ { "node": "Add timestamp started_at", "type": "main", "index": 0 } ] ] }, "Entrypoint": { "main": [ [ { "node": "Necessary data", "type": "main", "index": 0 } ] ] }, "Respond to Webhook": { "main": [ [] ] }, "Detect cycles": { "main": [ [ { "node": "Identify sources, sinks and trails", "type": "main", "index": 0 } ] ] }, "Build graph": { "main": [ [ { "node": "Build groups", "type": "main", "index": 0 } ] ] }, "Build groups": { "main": [ [ { "node": "Detect cycles", "type": "main", "index": 0 } ] ] }, "Validate input": { "main": [ [ { "node": "Build graph", "type": "main", "index": 0 } ] ] }, "Unwrap current processes": { "main": [ [ { "node": "Execute Workflow", "type": "main", "index": 0 } ] ] }, "Next processes to trigger": { "main": [ [ { "node": "Has processes to trigger?", "type": "main", "index": 0 } ] ] }, "Has cycles?": { "main": [ [ { "node": "Build graph errors", "type": "main", "index": 0 } ], [ { "node": "Augment graph nodes", "type": "main", "index": 0 } ] ] }, "Build processes and results": { "main": [ [ { "node": "Are results healthy or fully collected?", "type": "main", "index": 0 } ] ] }, "Sort topologically": { "main": [ [ { "node": "Has cycles?", "type": "main", "index": 0 } ] ] }, "No Operation, do nothing": { "main": [ [ { "node": "Wait 1s to check results", "type": "main", "index": 0 } ] ] }, "Build graph errors": { "main": [ [ { "node": "Collect errors", "type": "main", "index": 0 } ] ] }, "Has processes to trigger?": { "main": [ [ { "node": "No Operation, do nothing", "type": "main", "index": 0 } ], [ { "node": "Unwrap current processes", "type": "main", "index": 0 } ] ] }, "Wait 1s to check results": { "main": [ [ { "node": "Initialize items and data", "type": "main", "index": 0 } ] ] }, "Retrieve results": { "main": [ [ { "node": "Update items", "type": "main", "index": 0 } ] ] }, "Update items": { "main": [ [ { "node": "Has queue items?", "type": "main", "index": 0 } ] ] }, "Has queue items?": { "main": [ [ { "node": "Retrieve results", "type": "main", "index": 0 } ], [ { "node": "Get items", "type": "main", "index": 0 } ] ] }, "Get items": { "main": [ [ { "node": "Update results", "type": "main", "index": 0 } ] ] }, "Execute Workflow": { "main": [ [ { "node": "Wait 1s to check results", "type": "main", "index": 0 } ] ] }, "Prepare result object": { "main": [ [ { "node": "Redis1", "type": "main", "index": 0 } ] ] }, "Validation node": { "main": [ [ { "node": "Has invalid node(s)?", "type": "main", "index": 0 } ] ] }, "Initialize items and data": { "main": [ [ { "node": "Has queue items?", "type": "main", "index": 0 } ] ] }, "Are results healthy or fully collected?": { "main": [ [ { "node": "Collect results", "type": "main", "index": 0 } ], [ { "node": "Next processes to trigger", "type": "main", "index": 0 } ] ] }, "Cast ids to string": { "main": [ [ { "node": "Validate input", "type": "main", "index": 0 } ] ] }, "Identify sources, sinks and trails": { "main": [ [ { "node": "Sort topologically", "type": "main", "index": 0 } ] ] }, "Add timestamp started_at": { "main": [ [ { "node": "Sub-workflow execution", "type": "main", "index": 0 } ] ] }, "Collect results": { "main": [ [ { "node": "Consolidate output", "type": "main", "index": 0 } ] ] }, "Consolidate output": { "main": [ [ { "node": "Respond to Webhook", "type": "main", "index": 0 } ] ] } }, "pinData": { "Entrypoint": [ { "headers": { "host": "n8n.webhook.persev.info", "user-agent": "python-requests/2.32.3", "content-length": "682", "accept": "*/*", "accept-encoding": "gzip, deflate, br", "content-type": "application/json", "x-forwarded-for": "187.17.152.98", "x-forwarded-host": "n8n.webhook.persev.info", "x-forwarded-port": "443", "x-forwarded-proto": "https", "x-forwarded-server": "a1985e00fe4c", "x-real-ip": "187.17.152.98" }, "params": {}, "query": {}, "body": { "nodes": [ { "id": 1, "method": "GET", "url": "https://n8n.webhook.persev.info/webhook/get-subworkflow", "depends_on": [] }, { "id": 2, "method": "POST", "url": "https://n8n.webhook.persev.info/webhook/post-subworkflow", "data": { "a": 1, "b": 2, "c": 3 }, "depends_on": [ 1 ] }, { "id": 3, "method": "GET", "url": "https://n8n.webhook.persev.info/webhook/get-subworkflow", "depends_on": [] }, { "id": 4, "method": "POST", "url": "https://n8n.webhook.persev.info/webhook/post-subworkflow", "data": { "a": 1, "b": 2, "c": 3 }, "depends_on": [ 3 ] }, { "id": 5, "method": "POST", "url": "https://n8n.webhook.persev.info/webhook/post-subworkflow", "data": { "a": 1, "b": 2, "c": 3 }, "depends_on": [ 3 ] } ] }, "webhookUrl": "https://n8n.webhook.persev.info/webhook/nodes", "executionMode": "production" } ] }, "meta": { "templateCredsSetupCompleted": true, "instanceId": "2e4f8121945025cdf9238c79aefe23511c144e8abb0729066dfb54ab1f405578" } }