From 3c29d36656e38eb04ef61f8dcd398b34e040fb43 Mon Sep 17 00:00:00 2001 From: Matteo Cherubini Date: Thu, 2 Jul 2026 12:16:54 +0200 Subject: [PATCH 1/2] feat(n8n): Harden all genome workflow JS nodes with defensive coding --- deploy/n8n/genome-PR-review.json | 90 +++++++++++++-------------- deploy/n8n/genome-ingest.json | 44 ++++++------- deploy/n8n/genome-on-error.json | 22 +++---- deploy/n8n/genome-prune.json | 70 +++++++++++---------- deploy/n8n/genome-run-one-ingest.json | 50 +++++++-------- 5 files changed, 140 insertions(+), 136 deletions(-) diff --git a/deploy/n8n/genome-PR-review.json b/deploy/n8n/genome-PR-review.json index 86c32b7..eb80422 100644 --- a/deploy/n8n/genome-PR-review.json +++ b/deploy/n8n/genome-PR-review.json @@ -4,31 +4,31 @@ { "parameters": { "httpMethod": "POST", - "path": "forgejo-pr-review-forgejo-pr-review-23319ab8687b16f10e0f278fb920c112", + "path": "forgejo-pr-review-23319ab8687b16f10e0f278fb920c112", "options": {} }, - "id": "edf8e431-3637-477d-83bd-1f077843f740", + "id": "58df1ca9-e48e-4834-b231-d97c974cd01b", "name": "Webhook PR Review", "type": "n8n-nodes-base.webhook", "typeVersion": 2.1, "position": [ - -144, - 304 + 2272, + 1344 ], "webhookId": "61ff3a5baa304571" }, { "parameters": { "mode": "runOnceForEachItem", - "jsCode": "// THE only parser of the review side: parse the directive, VALIDATE, prepare the rework payload.\n// Security: only allow-listed maintainers may drive the gate; destructive directives require a\n// feat/ai-ingest-* branch on the expected base; raw_source is recovered from a machine-readable\n// marker that run-ingest.sh writes into the PR body.\nconst ALLOWED_SENDERS = ['Keru']; // <-- maintainers allowed to issue directives\nconst BASE = 'develop';\nconst j = $json.body || $json;\nconst review = j.review || null, comment = j.comment || null;\nconst pr = j.pull_request || j.issue || null;\nconst body = ((review && review.content) || (comment && comment.body) || '').toString();\nconst sender = (j.sender && j.sender.login) || 'unknown';\n\nconst m = body.match(/^\\s*(REWORK|RESTART|REVERT\\s+\\d+|SPLIT|REJECT|MERGE)\\s*:?/i);\nif (!m) return { directive: 'NONE' };\nconst headTok = m[1].toUpperCase().replace(/\\s+/g, ' ');\nlet directive = headTok.startsWith('REVERT') ? 'REVERT' : headTok;\nconst feedback = body.slice(m[0].length).trim() || '(nessun dettaglio fornito)';\n\nconst prNumber = (pr && pr.number) || null;\nconst branch = (pr && pr.head && pr.head.ref) || null;\nconst base = (pr && pr.base && pr.base.ref) || null;\nconst repo = (pr && pr.base && pr.base.repo && pr.base.repo.name) || (j.repository && j.repository.name) || null;\nconst owner = (pr && pr.base && pr.base.repo && pr.base.repo.owner && pr.base.repo.owner.login)\n || (j.repository && j.repository.owner && j.repository.owner.login) || null;\nconst prBody = (pr && pr.body) || (j.issue && j.issue.body) || '';\nconst rawMatch = prBody.match(//);\nconst raw = rawMatch ? rawMatch[1] : null;\n\nif (directive === 'REVERT') return { directive: 'NONE', note: 'REVERT reserved for Step 7' };\nif (!ALLOWED_SENDERS.includes(sender))\n return { directive: 'UNAUTHORIZED', attempted: directive, sender, prNumber, owner, repo };\n\nconst okGenome = !!repo && /^[a-z0-9][a-z0-9-]{0,63}$/.test(repo);\nconst okPr = !!prNumber && /^[0-9]+$/.test(String(prNumber));\nconst okBranch = !!branch && /^feat\\/ai-ingest-[a-z0-9-]+$/.test(branch);\nconst okBase = base === BASE;\nconst okRaw = (directive === 'MERGE') ? true\n : (!!raw && raw.startsWith('raw/') && !raw.includes('..') && /^[A-Za-z0-9._\\/-]+$/.test(raw));\nif (!okGenome || !okPr || !okBase || (directive !== 'MERGE' && !okBranch) || !okRaw)\n return { directive: 'INVALID', attempted: directive, prNumber, owner, repo,\n why: { okGenome, okPr, okBranch, okBase, okRaw } };\n\nconst feedback_b64 = Buffer.from(feedback, 'utf8').toString('base64');\nreturn { directive, prNumber, branch, base, repo, owner, sender, raw, feedback, feedback_b64 };" + "jsCode": "// THE only parser of the review side: parse the directive, VALIDATE, prepare the rework payload.\n// Security: only allow-listed maintainers may drive the gate; destructive directives require a\n// feat/ai-ingest-* branch on the expected base; raw_source is recovered from a machine-readable\n// marker that run-ingest.sh writes into the PR body.\nconst ALLOWED_SENDERS = ['Keru']; // <-- maintainers allowed to issue directives\nconst BASE = 'develop';\n\n// n8n Run Once for Each Item: $json is the current webhook payload\nconst j = $json.body || $json;\nif (!j || typeof j !== 'object') {\n return { directive: 'INVALID', reason: 'malformed webhook payload' };\n}\n\nconst review = j.review || null;\nconst comment = j.comment || null;\nconst pr = j.pull_request || j.issue || null;\n\n// Extract directive text from review content or comment body\nconst body = String(\n (review && review.content) ||\n (comment && comment.body) ||\n ''\n);\nconst sender = String((j.sender && j.sender.login) || 'unknown');\n\n// Match directive at the start of the text (case-insensitive)\nconst m = body.match(/^\\s*(REWORK|RESTART|REVERT\\s+\\d+|SPLIT|REJECT|MERGE)\\s*:?/i);\nif (!m) return { directive: 'NONE' };\n\nconst headTok = m[1].toUpperCase().replace(/\\s+/g, ' ');\nconst directive = headTok.startsWith('REVERT') ? 'REVERT' : headTok;\nconst feedback = body.slice(m[0].length).trim() || '(nessun dettaglio fornito)';\n\n// Extract PR metadata safely\nconst prNumber = (pr && pr.number) || null;\nconst branch = (pr && pr.head && pr.head.ref) || null;\nconst base = (pr && pr.base && pr.base.ref) || null;\nconst repo = (pr && pr.base && pr.base.repo && pr.base.repo.name) ||\n (j.repository && j.repository.name) || null;\nconst owner = (pr && pr.base && pr.base.repo && pr.base.repo.owner && pr.base.repo.owner.login) ||\n (j.repository && j.repository.owner && j.repository.owner.login) || null;\nconst prBody = (pr && pr.body) || (j.issue && j.issue.body) || '';\n\n// Recover raw_source from machine-readable marker: \n// Restricted to valid path characters, no spaces, no HTML breaking\nconst rawMatch = prBody.match(//);\nconst raw = rawMatch ? rawMatch[1] : null;\n\n// REVERT is reserved for future Step 7 implementation\nif (directive === 'REVERT') {\n return { directive: 'NONE', note: 'REVERT reserved for Step 7' };\n}\n\n// Authorization gate\nif (!ALLOWED_SENDERS.includes(sender)) {\n return {\n directive: 'UNAUTHORIZED',\n attempted: directive,\n sender,\n prNumber,\n owner,\n repo\n };\n}\n\n// Validation rules\nconst okGenome = !!repo && /^[a-z0-9][a-z0-9-]{0,63}$/.test(repo);\nconst okPr = !!prNumber && /^[0-9]+$/.test(String(prNumber));\nconst okBranch = !!branch && /^feat\\/ai-ingest-[a-z0-9-]+$/.test(branch);\nconst okBase = base === BASE;\nconst okRaw = (directive === 'MERGE')\n ? true\n : (!!raw && raw.startsWith('raw/') && !raw.includes('..') && /^[A-Za-z0-9._\\/-]+$/.test(raw));\n\nif (!okGenome || !okPr || !okBase || (directive !== 'MERGE' && !okBranch) || !okRaw) {\n return {\n directive: 'INVALID',\n attempted: directive,\n prNumber,\n owner,\n repo,\n why: { okGenome, okPr, okBranch, okBase, okRaw }\n };\n}\n\n// Encode feedback for safe transport through SSH/scripts\nconst feedback_b64 = Buffer.from(feedback, 'utf8').toString('base64');\n\nreturn {\n directive,\n prNumber,\n branch,\n base,\n repo,\n owner,\n sender,\n raw,\n feedback,\n feedback_b64\n};" }, - "id": "39977823-cbc1-45bb-b479-a57052b482e9", + "id": "c668f595-0a28-4bd3-9125-22fee9350d78", "name": "Parse & validate", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 80, - 304 + 2496, + 1344 ] }, { @@ -188,13 +188,13 @@ "fallbackOutput": "none" } }, - "id": "bc8aff39-a6bf-4e7c-8069-505d5855fb62", + "id": "489736cc-bab6-4664-8087-91b6d9ff31ad", "name": "Switch", "type": "n8n-nodes-base.switch", "typeVersion": 3.4, "position": [ - 320, - 304 + 2736, + 1344 ] }, { @@ -210,13 +210,13 @@ "timeout": 15000 } }, - "id": "2cea722f-42f1-475e-8060-7bac7cf4d245", + "id": "3440cb8d-ae4c-4523-ae13-ee5667d24252", "name": "Forgejo Merge PR", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ - 560, - 64 + 2976, + 1104 ], "credentials": { "httpHeaderAuth": { @@ -248,13 +248,13 @@ }, "options": {} }, - "id": "aff153d5-48c8-4a31-bd37-5bce49e60fa9", + "id": "e6d45fce-83d0-44ca-9fa4-86558fec1a0f", "name": "Guardia feat/", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ - 560, - 288 + 2976, + 1328 ] }, { @@ -270,13 +270,13 @@ "timeout": 15000 } }, - "id": "1745f043-9dc3-44e2-8654-4cc88114d636", + "id": "1601f705-c758-4df6-a3bd-e3ac2e202c94", "name": "Forgejo Close PR", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ - 784, - 256 + 3200, + 1296 ], "credentials": { "httpHeaderAuth": { @@ -295,13 +295,13 @@ "timeout": 15000 } }, - "id": "de59c610-c671-4a48-bca4-61ba9988bc65", + "id": "c2ff2247-efe1-4809-a435-9973188d61bb", "name": "Forgejo Delete Branch", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ - 1008, - 256 + 3424, + 1296 ], "credentials": { "httpHeaderAuth": { @@ -334,13 +334,13 @@ }, "options": {} }, - "id": "5c149f65-1ce2-4a39-9b86-aa05a993735c", + "id": "a1dbbc06-555d-4a1d-8fbf-ee75f617e98a", "name": "E' REJECT?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ - 1232, - 256 + 3648, + 1296 ] }, { @@ -377,13 +377,13 @@ }, "options": {} }, - "id": "24900f7e-959e-4398-8630-721a38443aa4", + "id": "7fc3e648-4712-4eef-a6f3-12c8805ade1f", "name": "Power Manager - ensure-on", "type": "n8n-nodes-base.executeWorkflow", "typeVersion": 1.3, "position": [ - 1232, - 128 + 3648, + 1168 ] }, { @@ -403,7 +403,7 @@ "mode": "rework", "feedback_b64": "={{ $('Parse & validate').first().json.feedback_b64 }}", "reason": "={{ $('Parse & validate').first().json.directive }}", - "prevPr": "={{ $('Parse & validate').first().json.prNumber }}" + "prevPr": "={{ String($('Parse & validate').first().json.prNumber || '') }}" }, "matchingColumns": [], "schema": [ @@ -475,41 +475,41 @@ "waitForSubWorkflow": false } }, - "id": "16774a78-b4eb-491f-9508-040aa3d4dc12", + "id": "9704c050-5c63-49fd-a26d-efbae9d92175", "name": "Run one ingest (rework)", "type": "n8n-nodes-base.executeWorkflow", "typeVersion": 1.3, "position": [ - 1440, - 128 + 3856, + 1168 ] }, { "parameters": { "mode": "runOnceForEachItem", - "jsCode": "// merged (MERGE) / closed (REJECT). The HTTP node replaced $json with the API response, so read\n// context from the parser (single review -> .first() is correct and pairedItem-proof).\nconst p = $('Parse & validate').first().json;\nconst repoUrl = `https://git.keruhomelab.com/${p.owner}/${p.repo}`;\nconst prUrl = `${repoUrl}/pulls/${p.prNumber}`;\nlet n;\nif (p.directive === 'MERGE') {\n n = { topic: 'genome-ingest', title: `${p.repo} \\u00b7 PR #${p.prNumber} mergiata`,\n priority: 'default', tags: 'twisted_rightwards_arrows', click: prUrl,\n actions: `view, Vedi la PR, ${prUrl}`,\n body: `PR #${p.prNumber} mergiata su \\`${p.base}\\` da **${p.sender}**.` };\n} else {\n n = { topic: 'genome-ingest', title: `${p.repo} \\u00b7 PR #${p.prNumber} chiusa`,\n priority: 'default', tags: 'wastebasket', click: repoUrl, actions: '',\n body: `**REJECT** di **${p.sender}**: PR #${p.prNumber} chiusa e branch \\`${p.branch}\\` rimosso. Nessun nuovo tentativo.\\n> ${p.feedback}` };\n}\nreturn n;" + "jsCode": "// merged (MERGE) / closed (REJECT). The HTTP node replaced $json with the API response,\n// so we read context from the parser via node reference (single review -> .first() is safe).\n// Fallback values prevent crashes if the parser node is unreachable.\nconst p = $('Parse & validate').first().json || {};\nconst repo = p.repo || 'unknown';\nconst owner = p.owner || 'unknown';\nconst prNumber = p.prNumber || '?';\nconst base = p.base || 'develop';\nconst branch = p.branch || 'unknown';\nconst sender = p.sender || 'unknown';\nconst directive = p.directive || 'UNKNOWN';\nconst feedback = p.feedback || '';\n\nconst repoUrl = (owner && repo && repo !== 'unknown')\n ? `https://git.keruhomelab.com/${owner}/${repo}`\n : '';\nconst prUrl = (repoUrl && prNumber !== '?')\n ? `${repoUrl}/pulls/${prNumber}`\n : '';\n\nlet n;\nif (directive === 'MERGE') {\n n = {\n topic: 'genome-ingest',\n title: `${repo} · PR #${prNumber} mergiata`,\n priority: 'default',\n tags: 'twisted_rightwards_arrows',\n click: prUrl,\n actions: `view, Vedi la PR, ${prUrl}`,\n body: `PR #${prNumber} mergiata su \\`${base}\\` da **${sender}**.`\n };\n} else {\n n = {\n topic: 'genome-ingest',\n title: `${repo} · PR #${prNumber} chiusa`,\n priority: 'default',\n tags: 'wastebasket',\n click: repoUrl,\n actions: '',\n body: `**REJECT** di **${sender}**: PR #${prNumber} chiusa e branch \\`${branch}\\` rimosso. Nessun nuovo tentativo.\\n> ${feedback}`\n };\n}\n\nreturn n;" }, - "id": "f3c339cb-91e3-4436-b56a-b97c81d4a58f", + "id": "1ce634fd-d402-4a84-9ba1-04673ddffce9", "name": "Build ntfy action", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 1440, - 304 + 3856, + 1344 ] }, { "parameters": { "mode": "runOnceForEachItem", - "jsCode": "// Security / near-miss: unauthorized sender, invalid directive, or the feat/ guard. On all three\n// paths Switch/Guardia pass the parser output through, so $json carries the directive + context.\nconst d = $json;\nconst repoUrl = (d.owner && d.repo) ? `https://git.keruhomelab.com/${d.owner}/${d.repo}` : '';\nlet n;\nif (d.directive === 'UNAUTHORIZED') {\n n = { topic: 'genome-ingest', title: `Sicurezza \\u00b7 direttiva non autorizzata`,\n priority: 'high', tags: 'no_entry', click: repoUrl, actions: '',\n body: `**${d.sender}** ha tentato \\`${d.attempted}\\` su PR #${d.prNumber}, ma non \\u00e8 tra i maintainer autorizzati. **Nessuna azione** eseguita.` };\n} else if (d.directive === 'INVALID') {\n n = { topic: 'genome-ingest', title: `Direttiva non applicata`,\n priority: 'low', tags: 'information_source', click: repoUrl, actions: '',\n body: `\\`${d.attempted}\\` su PR #${d.prNumber} ignorata: precondizioni non soddisfatte (branch / base / marker raw).` };\n} else {\n n = { topic: 'genome-ingest', title: `Sicurezza \\u00b7 branch protetto`,\n priority: 'high', tags: 'no_entry', click: repoUrl, actions: '',\n body: `Rifiutata azione distruttiva (\\`${d.directive}\\`) sul branch \\`${d.branch}\\`: non \\u00e8 un \\`feat/ai-ingest-*\\`. **Nessuna modifica.**` };\n}\nreturn n;" + "jsCode": "// Security / near-miss: unauthorized sender, invalid directive, or the feat/ guard.\n// On all three paths Switch/Guardia pass the parser output through, so $json carries the directive + context.\nconst d = $json || {};\nconst directive = d.directive || 'UNKNOWN';\nconst attempted = d.attempted || directive;\nconst sender = d.sender || 'unknown';\nconst prNumber = d.prNumber || '?';\nconst branch = d.branch || 'unknown';\nconst owner = d.owner || '';\nconst repo = d.repo || '';\n\nconst repoUrl = (owner && repo) ? `https://git.keruhomelab.com/${owner}/${repo}` : '';\n\nlet n;\nif (directive === 'UNAUTHORIZED') {\n n = {\n topic: 'genome-ingest',\n title: `Sicurezza · direttiva non autorizzata`,\n priority: 'high',\n tags: 'no_entry',\n click: repoUrl,\n actions: '',\n body: `**${sender}** ha tentato \\`${attempted}\\` su PR #${prNumber}, ma non è tra i maintainer autorizzati. **Nessuna azione** eseguita.`\n };\n} else if (directive === 'INVALID') {\n n = {\n topic: 'genome-ingest',\n title: `Direttiva non applicata`,\n priority: 'low',\n tags: 'information_source',\n click: repoUrl,\n actions: '',\n body: `\\`${attempted}\\` su PR #${prNumber} ignorata: precondizioni non soddisfatte (branch / base / marker raw).`\n };\n} else {\n // Guardia feat/ false branch: destructive action on a non-feat/ai-ingest-* branch\n n = {\n topic: 'genome-ingest',\n title: `Sicurezza · branch protetto`,\n priority: 'high',\n tags: 'no_entry',\n click: repoUrl,\n actions: '',\n body: `Rifiutata azione distruttiva (\\`${attempted || directive}\\`) sul branch \\`${branch}\\`: non è un \\`feat/ai-ingest-*\\`. **Nessuna modifica.**`\n };\n}\n\nreturn n;" }, - "id": "ba552761-b8cb-43c8-a6b1-ac93ca2b17b1", + "id": "32b16592-5126-4cc2-a3f2-d1bda58ac724", "name": "Build ntfy sicurezza", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 784, - 496 + 3200, + 1536 ] }, { @@ -555,13 +555,13 @@ "timeout": 15000 } }, - "id": "156f41ad-e1e1-4a7b-b91c-ceb2043ab147", + "id": "4d45b486-de42-4c7f-be21-b5bfbc05fd44", "name": "ntfy: send", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ - 1664, - 384 + 4080, + 1424 ], "credentials": { "httpHeaderAuth": { @@ -764,7 +764,7 @@ "callerPolicy": "workflowsFromSameOwner", "availableInMCP": false }, - "versionId": "8c92ff1a-672a-4d15-9aa0-10d5fe11e472", + "versionId": "22998a54-cd9a-4b57-9c80-df97085a997c", "meta": { "instanceId": "96b2f0ec76a4400bbd481c617b24b3b87024cc7a913efacccaf9fc85722e7417" }, diff --git a/deploy/n8n/genome-ingest.json b/deploy/n8n/genome-ingest.json index e5d445c..c54e57a 100644 --- a/deploy/n8n/genome-ingest.json +++ b/deploy/n8n/genome-ingest.json @@ -7,26 +7,26 @@ "path": "forgejo-push", "options": {} }, - "id": "eb4abf4a-d26d-4aea-85a2-fc356b81385f", + "id": "8c44b478-1a95-4c3b-8ac1-d7c57e228414", "name": "Webhook", "type": "n8n-nodes-base.webhook", "typeVersion": 2.1, "position": [ - 1920, + 1520, 1728 ], "webhookId": "cf215f5d31e04dd2" }, { "parameters": { - "jsCode": "// Bell filter: proceed ONLY on develop pushes that actually touch raw/. Returning [] stops the\n// flow (no node needed). Performance: never wake vm101 for wiki-only pushes (e.g. an ingest PR\n// merged back to develop). pending-raw remains the source of truth.\nconst b = $json.body || $json;\nconst ref = b.ref || '';\nconst genome = (b.repository && b.repository.name) || '';\nif (ref !== 'refs/heads/develop') return [];\nif (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(genome)) return [];\nconst commits = b.commits || [];\nconst touched = [];\nfor (const c of commits) {\n for (const p of (c.added || [])) touched.push(p);\n for (const p of (c.modified || [])) touched.push(p);\n for (const p of (c.removed || [])) touched.push(p);\n}\nif (!touched.some(p => p.startsWith('raw/'))) return [];\nreturn [{ json: { genome } }];" + "jsCode": "// Bell filter: proceed ONLY on develop pushes that actually touch raw/.\n// Returning [] stops the flow (no node needed).\n// Performance: never wake vm101 for wiki-only pushes (e.g. an ingest PR merged back to develop).\n// pending-raw remains the source of truth.\nconst item = $input.first().json;\nconst b = item.body || item;\nconst ref = String(b.ref || '');\nconst genome = String((b.repository && b.repository.name) || '').toLowerCase().trim();\n\n// Branch filter\nif (ref !== 'refs/heads/develop') return [];\n\n// Genome name validation (DNS-like: lowercase alphanum + hyphen, 1-64 chars)\nif (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(genome)) return [];\n\n// Collect all touched paths safely (added, modified, removed)\nconst commits = Array.isArray(b.commits) ? b.commits : [];\nconst touched = [];\nfor (const c of commits) {\n if (!c || typeof c !== 'object') continue;\n for (const key of ['added', 'modified', 'removed']) {\n const list = c[key];\n if (!Array.isArray(list)) continue;\n for (const p of list) {\n if (typeof p === 'string' && p.startsWith('raw/')) {\n touched.push(p);\n }\n }\n }\n}\n\n// Gate: stop if nothing under raw/ was touched\nif (touched.length === 0) return [];\n\nreturn [{ json: { genome, touchedCount: touched.length } }];" }, - "id": "190d44ea-4f6f-4cff-91aa-3e65ef44cb21", + "id": "604787c7-4e83-468e-9a98-3ac084203040", "name": "Gate push", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 2144, + 1744, 1728 ] }, @@ -64,12 +64,12 @@ }, "options": {} }, - "id": "40af578c-eac4-47ac-9c30-5596eceaf9df", + "id": "f93073a3-7753-4ce1-9ef1-2a0c16386543", "name": "Power Manager - ensure-on", "type": "n8n-nodes-base.executeWorkflow", "typeVersion": 1.3, "position": [ - 2352, + 1952, 1728 ] }, @@ -78,12 +78,12 @@ "authentication": "privateKey", "command": "=ssh vm101 'pi pending-raw {{ $('Gate push').first().json.genome }}'" }, - "id": "f8861a50-aaf1-46fb-95a9-b9b200d4d6ae", + "id": "876dbdaf-3620-4c2c-a65b-336f0b11198c", "name": "SSH: pending-raw", "type": "n8n-nodes-base.ssh", "typeVersion": 1, "position": [ - 2576, + 2176, 1728 ], "credentials": { @@ -95,14 +95,14 @@ }, { "parameters": { - "jsCode": "// Parse pending-raw -> one item per raw, carrying everything run-one-ingest needs. Unsafe\n// filenames (spaces / odd chars) are NOT ingested -> a 'badname' item -> ntfy.\nconst out = ($input.first().json.stdout || '').toString().trim();\nlet d;\ntry { d = JSON.parse(out); }\ncatch (e) { return [{ json: { _kind: 'error', reason: 'pending-raw non parsabile', raw: out } }]; }\nif (!d.files || d.files.length === 0) return [];\nconst why = {};\nfor (const it of (d.detail || [])) why[it.path] = it.reason;\nconst SAFE = /^[A-Za-z0-9._\\\\/-]+$/;\nconst items = [];\nfor (const raw of d.files) {\n if (SAFE.test(raw)) items.push({ json: { _kind: 'ingest', genome: d.genome, raw,\n mode: 'ingest', feedback_b64: '', reason: why[raw] || 'new', prevPr: '' } });\n else items.push({ json: { _kind: 'badname', genome: d.genome, raw,\n hint: raw.replace(/[^A-Za-z0-9._\\\\/-]+/g, '-').toLowerCase() } });\n}\nreturn items;" + "jsCode": "// Parse pending-raw -> one item per raw, carrying everything run-one-ingest needs.\n// Unsafe filenames (spaces / odd chars) are NOT ingested -> a 'badname' item -> ntfy.\nconst out = String($input.first().json.stdout || '').trim();\nlet d;\ntry {\n d = JSON.parse(out);\n} catch (e) {\n return [{ json: { _kind: 'error', reason: 'pending-raw non parsabile', raw: out.substring(0, 500) } }];\n}\n\nif (!d || typeof d !== 'object') {\n return [{ json: { _kind: 'error', reason: 'pending-raw non è un oggetto JSON', raw: out.substring(0, 500) } }];\n}\n\nconst files = Array.isArray(d.files) ? d.files : [];\nif (files.length === 0) return [];\n\n// Build reason map from detail array\nconst why = {};\nfor (const it of (Array.isArray(d.detail) ? d.detail : [])) {\n if (it && typeof it.path === 'string' && typeof it.reason === 'string') {\n why[it.path] = it.reason;\n }\n}\n\nconst SAFE = /^[A-Za-z0-9._\\/-]+$/;\nconst items = [];\nfor (const raw of files) {\n if (typeof raw !== 'string') {\n items.push({ json: { _kind: 'badname', genome: d.genome, raw: String(raw),\n hint: String(raw).replace(/[^A-Za-z0-9._\\/-]+/g, '-').toLowerCase() || 'invalid' } });\n continue;\n }\n if (SAFE.test(raw)) {\n items.push({ json: { _kind: 'ingest', genome: d.genome, raw,\n mode: 'ingest', feedback_b64: '', reason: why[raw] || 'new', prevPr: '' } });\n } else {\n const hint = raw.replace(/[^A-Za-z0-9._\\/-]+/g, '-').toLowerCase() || 'invalid';\n items.push({ json: { _kind: 'badname', genome: d.genome, raw, hint } });\n }\n}\nreturn items;" }, - "id": "e1f1e251-1565-4092-b8e7-b97c9c0bb18d", + "id": "f5bbbed3-222e-4129-a764-7cf47d69c5ce", "name": "Split raw files", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 2800, + 2400, 1728 ] }, @@ -130,12 +130,12 @@ }, "options": {} }, - "id": "3339ce75-3ec9-4ed0-8faa-2433e9616c43", + "id": "5398e2c4-c7ca-4ca4-a2d7-e75077453b7c", "name": "Nome valido?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ - 3024, + 2624, 1728 ] }, @@ -228,26 +228,26 @@ "waitForSubWorkflow": false } }, - "id": "fda796f5-588b-4502-a653-5d27c3f72ac6", + "id": "0f274662-62bb-448b-ae4b-47e4bbcfd35a", "name": "Run one ingest", "type": "n8n-nodes-base.executeWorkflow", "typeVersion": 1.3, "position": [ - 3232, + 2832, 1616 ] }, { "parameters": { "mode": "runOnceForEachItem", - "jsCode": "const d = $json;\nreturn { topic: 'genome-ingest', title: `${d.genome} \\u00b7 file da rinominare`,\n priority: 'high', tags: 'warning', click: '', actions: '',\n body: `Il file \\`${d.raw}\\` ha spazi o caratteri non ammessi e **non** \\u00e8 stato ingerito.\\nRinominalo in: \\`${d.hint}\\`` };" + "jsCode": "// Build ntfy notification for files with invalid names.\n// Run Once for Each Item: $json is the current badname item.\nconst d = $json || {};\nconst genome = d.genome || 'unknown';\nconst raw = String(d.raw || 'unknown');\nconst hint = String(d.hint || 'unknown');\n\n// Escape backticks to avoid breaking markdown\nconst rawEsc = raw.replace(/`/g, '\\`');\nconst hintEsc = hint.replace(/`/g, '\\`');\n\nreturn {\n topic: 'genome-ingest',\n title: `${genome} · file da rinominare`,\n priority: 'high',\n tags: 'warning',\n click: '',\n actions: '',\n body: `Il file \\`${rawEsc}\\` ha spazi o caratteri non ammessi e **non** è stato ingerito.\\nRinominalo in: \\`${hintEsc}\\``\n};" }, - "id": "6820f3fb-97bb-45bf-8e7f-00eb68d7f313", + "id": "0f785bcd-cdc6-4dac-9ced-1c5cfa3453dc", "name": "Build ntfy badname", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 3232, + 2832, 1840 ] }, @@ -294,12 +294,12 @@ "timeout": 15000 } }, - "id": "55cc42b2-3170-4884-bb5d-58e3af97bfea", + "id": "9cd2bde3-6846-4855-ad01-e3a4cdbce208", "name": "ntfy: send", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ - 3456, + 3056, 1840 ], "credentials": { @@ -410,7 +410,7 @@ "callerPolicy": "workflowsFromSameOwner", "availableInMCP": false }, - "versionId": "d58601e7-b752-4c9f-9438-d91be663c82e", + "versionId": "63863925-606f-4200-824c-52f1919f2bb1", "meta": { "instanceId": "96b2f0ec76a4400bbd481c617b24b3b87024cc7a913efacccaf9fc85722e7417" }, diff --git a/deploy/n8n/genome-on-error.json b/deploy/n8n/genome-on-error.json index 2c40c9c..aaa0390 100644 --- a/deploy/n8n/genome-on-error.json +++ b/deploy/n8n/genome-on-error.json @@ -3,27 +3,27 @@ "nodes": [ { "parameters": {}, - "id": "eee467d7-5f8b-4abf-8923-1c70a29dafb2", + "id": "f715ed51-95e6-475f-8aa5-d0df531cc7cf", "name": "Error Trigger", "type": "n8n-nodes-base.errorTrigger", "typeVersion": 1, "position": [ - 0, - 0 + 688, + -32 ] }, { "parameters": { "mode": "runOnceForEachItem", - "jsCode": "// Global error handler: set this workflow as the \"Error Workflow\" in each genome workflow's\n// Settings. Catches ANY node failure (SSH down, Forgejo 4xx/5xx, etc.) and notifies once.\nconst e = $json.execution || {};\nconst w = $json.workflow || {};\nconst msg = (e.error && (e.error.message || e.error.description)) || 'errore sconosciuto';\nconst lastNode = (e.lastNodeExecuted) ? ` (nodo: ${e.lastNodeExecuted})` : '';\nreturn { topic: 'genome-ingest', title: `Workflow KO \\u00b7 ${w.name || 'n8n'}`,\n priority: 'high', tags: 'rotating_light',\n click: e.url || '', actions: e.url ? `view, Apri l'esecuzione, ${e.url}` : '',\n body: `**${w.name || 'workflow'}** \\u00e8 fallito${lastNode}.\\n${msg}` };" + "jsCode": "// Global error handler: set this workflow as the \"Error Workflow\" in each genome workflow's\n// Settings. Catches ANY node failure (SSH down, Forgejo 4xx/5xx, etc.) and notifies once.\n// Run Once for Each Item: $json is the error trigger payload.\nconst e = $json.execution || {};\nconst w = $json.workflow || {};\n\n// Safely extract error message from various shapes\nconst rawMsg = (e.error && (e.error.message || e.error.description)) || 'errore sconosciuto';\nconst msg = String(rawMsg).trim();\n\nconst lastNode = e.lastNodeExecuted ? ` (nodo: ${e.lastNodeExecuted})` : '';\nconst workflowName = w.name || 'n8n';\nconst executionUrl = e.url || '';\n\n// Escape markdown to avoid breaking the notification body\nconst msgEsc = msg.replace(/`/g, '\\`').replace(/\\n/g, '\\n');\n\nreturn {\n topic: 'genome-ingest',\n title: `Workflow KO · ${workflowName}`,\n priority: 'high',\n tags: 'rotating_light',\n click: executionUrl,\n actions: executionUrl ? `view, Apri l'esecuzione, ${executionUrl}` : '',\n body: `**${workflowName}** è fallito${lastNode}.\\n\\n${msgEsc}`\n};" }, - "id": "bdbf5186-143d-4482-b873-5760fbdabab0", + "id": "dd39bc0f-918a-4645-8f04-540ac9089311", "name": "Build ntfy", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 240, - 0 + 928, + -32 ] }, { @@ -69,13 +69,13 @@ "timeout": 15000 } }, - "id": "16e9a3af-6acc-46f2-bc56-79e185fddf53", + "id": "a9ee90f3-d7fe-445d-96af-12caef46473f", "name": "ntfy: send", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ - 464, - 0 + 1152, + -32 ], "credentials": { "httpHeaderAuth": { @@ -119,7 +119,7 @@ "executionOrder": "v1", "binaryMode": "separate" }, - "versionId": "95bfb02a-7122-43d7-bec6-3a2e5b77a469", + "versionId": "036161c9-c934-474e-9b4f-634259f2a866", "meta": { "instanceId": "96b2f0ec76a4400bbd481c617b24b3b87024cc7a913efacccaf9fc85722e7417" }, diff --git a/deploy/n8n/genome-prune.json b/deploy/n8n/genome-prune.json index 670676b..bab1e07 100644 --- a/deploy/n8n/genome-prune.json +++ b/deploy/n8n/genome-prune.json @@ -7,27 +7,27 @@ "path": "forgejo-push-prune", "options": {} }, - "id": "050b318b-d3bb-4c1a-baa4-a1e2bd1babd8", + "id": "d31388b9-c6d6-4f28-9a6c-b381922bf5e0", "name": "Webhook prune", "type": "n8n-nodes-base.webhook", "typeVersion": 2.1, "position": [ - 0, - 0 + 1232, + -64 ], "webhookId": "d6ac11900058434e" }, { "parameters": { - "jsCode": "// Bell filter for PRUNING: proceed only on develop pushes that REMOVED a raw/ file.\n// Adds/modifications are the ingest flow's job; this flow reacts to deletions only.\nconst b = $json.body || $json;\nconst ref = b.ref || '';\nconst genome = (b.repository && b.repository.name) || '';\nif (ref !== 'refs/heads/develop') return [];\nif (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(genome)) return [];\nconst removed = [];\nfor (const c of (b.commits || [])) for (const p of (c.removed || [])) removed.push(p);\nif (!removed.some(p => p.startsWith('raw/'))) return []; // nothing under raw/ removed -> ignore\nreturn [{ json: { genome } }];" + "jsCode": "// Gate: proceed ONLY on develop pushes that REMOVED at least one file under raw/.\n// Additions/modifications are handled by the ingest flow; this flow reacts to deletions only.\nconst item = $input.first().json;\nconst b = item.body || item;\nconst ref = String(b.ref || '');\nconst genome = String((b.repository?.name) || '').toLowerCase().trim();\n\n// Branch filter\nif (ref !== 'refs/heads/develop') return [];\n\n// Genome name validation (DNS-like: lowercase alphanum + hyphen, 1-64 chars)\nif (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(genome)) return [];\n\n// Collect removed paths safely\nconst removed = [];\nfor (const c of (b.commits || [])) {\n if (!c || !Array.isArray(c.removed)) continue;\n for (const p of c.removed) {\n if (typeof p === 'string' && p.startsWith('raw/')) {\n removed.push(p);\n }\n }\n}\n\n// Gate: stop if nothing under raw/ was removed\nif (removed.length === 0) return [];\n\nreturn [{ json: { genome, removedCount: removed.length } }];" }, - "id": "deac740a-2046-4de8-91eb-812114edeb7b", + "id": "84848a31-d099-459e-bd03-67abc2cf2b77", "name": "Gate prune", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 224, - 0 + 1456, + -64 ] }, { @@ -64,13 +64,13 @@ }, "options": {} }, - "id": "554737ea-09f3-4d4b-9a66-c983cc72e655", + "id": "175e4191-eb1b-4e5d-8d82-c39205753152", "name": "Power Manager - ensure-on", "type": "n8n-nodes-base.executeWorkflow", "typeVersion": 1.3, "position": [ - 448, - 0 + 1680, + -64 ] }, { @@ -78,13 +78,13 @@ "authentication": "privateKey", "command": "=ssh vm101 'pi orphan-wiki {{ $('Gate prune').first().json.genome }}'" }, - "id": "311005a4-16fd-4752-92d3-e3bbb9cdf19f", + "id": "598f20f8-d668-48da-90e3-1bfada3ace92", "name": "SSH: orphan-wiki", "type": "n8n-nodes-base.ssh", "typeVersion": 1, "position": [ - 672, - 0 + 1904, + -64 ], "credentials": { "sshPrivateKey": { @@ -95,15 +95,15 @@ }, { "parameters": { - "jsCode": "// Gate: prune only if orphan-wiki found orphans. run-prune re-derives independently anyway\n// (no detected-vs-pruned race) — this just avoids taking the lock for nothing.\nconst out = ($input.first().json.stdout || '').toString().trim();\nlet d; try { d = JSON.parse(out); } catch (e) { return []; }\nif (!d || !d.count) return []; // 0 orphans -> stop silently\nreturn [{ json: { genome: d.genome, count: d.count } }];" + "jsCode": "// Gate: proceed to prune only if orphan-wiki actually found orphans.\n// run-prune re-derives independently anyway (no detected-vs-pruned race);\n// this gate just avoids taking the lock for nothing.\nconst out = String($input.first().json.stdout || '').trim();\nlet d;\n\ntry {\n d = JSON.parse(out);\n} catch (e) {\n // Malformed JSON from orphan-wiki — log and stop\n return [{ json: { _gate: 'parse-error', raw: out.substring(0, 500) } }];\n}\n\n// Strict validation: d must be object with numeric count > 0\nif (!d || typeof d !== 'object' || typeof d.count !== 'number' || d.count <= 0) {\n return []; // 0 orphans or missing count -> stop silently\n}\n\nreturn [{ json: { genome: d.genome, count: d.count } }];" }, - "id": "2374b63c-f0db-4ae9-b350-3fec70687384", + "id": "3b644d61-26d8-4024-baed-bcb4ad169a6a", "name": "Orfani?", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 880, - 0 + 2112, + -64 ] }, { @@ -111,13 +111,13 @@ "authentication": "privateKey", "command": "=ssh vm101 'pi prune {{ $json.genome }}'" }, - "id": "e4130173-ff62-4e11-b3d1-ee7870803663", + "id": "a8cae2c2-6f2f-4ef6-add9-287195aa84b5", "name": "SSH: prune", "type": "n8n-nodes-base.ssh", "typeVersion": 1, "position": [ - 1104, - 0 + 2336, + -64 ], "credentials": { "sshPrivateKey": { @@ -129,29 +129,29 @@ { "parameters": { "mode": "runOnceForEachItem", - "jsCode": "const out = ($json.stdout || '').toString().trim();\nconst line = out.split('\\n').filter(l => l.trim().startsWith('{')).pop();\nlet r; try { r = line ? JSON.parse(line) : { status:'error', reason:'nessuna riga JSON' }; }\ncatch (e) { r = { status:'error', reason:'JSON non parsabile' }; }\nreturn r;" + "jsCode": "// Extract the last JSON line from SSH stdout (the command may print logs before/after).\n// Run Once for Each Item: $json is the current SSH result item.\nconst out = String($json.stdout || '').trim();\nconst jsonLines = out\n .split('\\n')\n .map(l => l.trim())\n .filter(l => l.startsWith('{') && l.endsWith('}'));\n\nconst line = jsonLines.pop(); // last JSON object line (command prints JSON last)\n\nlet r;\ntry {\n r = line ? JSON.parse(line) : { status: 'error', reason: 'nessuna riga JSON trovata in stdout' };\n} catch (e) {\n r = { status: 'error', reason: 'JSON non parsabile', rawLine: line?.substring(0, 1000) };\n}\n\n// Ensure consistent shape for downstream nodes\nreturn {\n status: r.status || 'error',\n reason: r.reason || 'errore sconosciuto',\n count: r.count,\n pr_url: r.pr_url,\n genome: r.genome,\n _raw: line?.substring(0, 500)\n};" }, - "id": "b649df9a-1e64-49cc-9e7f-6f78a1190382", + "id": "da1ab42c-32e1-4c4d-82a1-925fcee1a098", "name": "Parse prune", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 1328, - 0 + 2560, + -64 ] }, { "parameters": { "mode": "runOnceForEachItem", - "jsCode": "// Pruning notification on genome-ingest (the \"system produced a PR to judge\" topic), broom icon.\nconst d = $json;\nconst g = $('Orfani?').first().json;\nlet n;\nif (d.status === 'ok') {\n const pm = (d.pr_url || '').match(/\\/pulls\\/(\\d+)/); const num = pm ? `#${pm[1]}` : '';\n n = { topic:'genome-ingest', title:`${g.genome} \\u00b7 potatura ${num}`.replace(/\\s+/g,' ').trim(),\n priority:'default', tags:'broom', click:d.pr_url || '', actions:d.pr_url ? `view, Apri la PR, ${d.pr_url}` : '',\n body:`${d.count} sorgente/i orfane proposte per la rimozione. **Approva la PR** per potare, oppure chiudila da Forgejo per annullare.` };\n} else {\n n = { topic:'genome-ingest', title:`${g ? g.genome : ''} \\u00b7 errore potatura`.trim(), priority:'high',\n tags:'rotating_light', click:'', actions:'', body:`${d.reason || 'errore'}.` };\n}\nreturn n;" + "jsCode": "// Build ntfy notification for genome pruning.\n// Run Once for Each Item: $json is the parsed prune result.\nconst d = $json;\nconst genome = d.genome || 'unknown';\n\nlet n;\nif (d.status === 'ok') {\n const pm = (d.pr_url || '').match(/\\/pulls\\/(\\d+)/);\n const num = pm ? `#${pm[1]}` : '';\n n = {\n topic: 'genome-ingest',\n title: `${genome} \\u00b7 potatura ${num}`.replace(/\\s+/g, ' ').trim(),\n priority: 'default',\n tags: 'broom',\n click: d.pr_url || '',\n actions: d.pr_url ? `view, Apri la PR, ${d.pr_url}` : '',\n body: `${d.count} sorgente/i orfane proposte per la rimozione. **Approva la PR** per potare, oppure chiudila da Forgejo per annullare.`\n };\n} else {\n n = {\n topic: 'genome-ingest',\n title: `${genome} \\u00b7 errore potatura`.trim(),\n priority: 'high',\n tags: 'rotating_light',\n click: '',\n actions: '',\n body: `${d.reason || 'errore sconosciuto durante la potatura'}.`\n };\n}\n\nreturn n;" }, - "id": "00be2a1f-6097-40e2-81f5-9dcba40b66ae", + "id": "ebe99407-6038-4f8f-a73f-7dc7b0a011e0", "name": "Build ntfy", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 1552, - 0 + 2784, + -64 ] }, { @@ -197,13 +197,13 @@ "timeout": 15000 } }, - "id": "50a36840-be50-43ec-bea9-44819a88a923", + "id": "0bd3654e-a73d-4c3a-83ed-9f57ca4aad24", "name": "ntfy: send", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ - 1760, - 0 + 2992, + -64 ], "credentials": { "httpHeaderAuth": { @@ -311,9 +311,13 @@ "active": true, "settings": { "executionOrder": "v1", - "binaryMode": "separate" + "binaryMode": "separate", + "timeSavedMode": "fixed", + "errorWorkflow": "7Vws3gCX3QnjM3oD", + "callerPolicy": "workflowsFromSameOwner", + "availableInMCP": false }, - "versionId": "ff0be89b-7930-4171-a547-5dc7bffc9472", + "versionId": "999f640c-aae6-42aa-9a95-aba26987e9d0", "meta": { "instanceId": "96b2f0ec76a4400bbd481c617b24b3b87024cc7a913efacccaf9fc85722e7417" }, diff --git a/deploy/n8n/genome-run-one-ingest.json b/deploy/n8n/genome-run-one-ingest.json index be8dd15..ee6c177 100644 --- a/deploy/n8n/genome-run-one-ingest.json +++ b/deploy/n8n/genome-run-one-ingest.json @@ -5,27 +5,27 @@ "parameters": { "inputSource": "passthrough" }, - "id": "70da9144-1147-4cb5-9868-1f5ee2425d4c", + "id": "b1b7ba8e-1e45-4f76-adc0-089180715975", "name": "On ingest request", "type": "n8n-nodes-base.executeWorkflowTrigger", "typeVersion": 1.1, "position": [ - -32, - 416 + 224, + 624 ] }, { "parameters": { "mode": "runOnceForEachItem", - "jsCode": "// SECURITY chokepoint: every ingest to vm101 passes here. Re-validate inputs (defense in depth:\n// callers + the SSH wrapper also validate) and assemble the exact command. Charset-validated\n// fields are safe inside the single-quoted remote command -> no shell injection.\nconst d = $json;\nconst genome = (d.genome || '').toString();\nconst raw = (d.raw || '').toString();\nconst mode = (d.mode || 'ingest').toString();\nconst fb = (d.feedback_b64 || '').toString();\n\nconst okGenome = /^[a-z0-9][a-z0-9-]{0,63}$/.test(genome);\nconst okMode = (mode === 'ingest' || mode === 'rework');\nconst okRaw = raw.startsWith('raw/') && !raw.includes('..') && /^[A-Za-z0-9._\\/-]+$/.test(raw);\nconst okFb = (mode === 'ingest') || /^[A-Za-z0-9+/=]+$/.test(fb);\n\nif (!okGenome || !okMode || !okRaw || !okFb) {\n return { _ok: false, genome, mode,\n _reason: `bad input (genome:${okGenome} mode:${okMode} raw:${okRaw} fb:${okFb})` };\n}\nconst ssh_cmd = (mode === 'rework')\n ? `ssh vm101 'pi ingest-rework ${genome} ${raw} ${fb}'`\n : `ssh vm101 'pi ingest ${genome} ${raw}'`;\nreturn { _ok: true, ssh_cmd, genome, raw, mode, reason: d.reason || '', prevPr: d.prevPr || '' };" + "jsCode": "// SECURITY chokepoint: every ingest to vm101 passes here. Re-validate inputs (defense in depth:\n// callers + the SSH wrapper also validate) and assemble the exact command. Charset-validated\n// fields are safe inside the single-quoted remote command -> no shell injection.\n// Run Once for Each Item: $json is the current ingest request.\nconst d = $json || {};\nconst genome = String(d.genome || '').toLowerCase().trim();\nconst raw = String(d.raw || '');\nconst mode = String(d.mode || 'ingest');\nconst fb = String(d.feedback_b64 || '');\n\nconst okGenome = /^[a-z0-9][a-z0-9-]{0,63}$/.test(genome);\nconst okMode = (mode === 'ingest' || mode === 'rework');\nconst okRaw = raw.startsWith('raw/') && !raw.includes('..') && /^[A-Za-z0-9._\\/-]+$/.test(raw);\n// feedback_b64 is required only for rework mode; for ingest it can be empty\nconst okFb = (mode === 'ingest') || /^[A-Za-z0-9+/=]+$/.test(fb);\n\nif (!okGenome || !okMode || !okRaw || !okFb) {\n return {\n _ok: false,\n genome,\n mode,\n _reason: `bad input (genome:${okGenome} mode:${okMode} raw:${okRaw} fb:${okFb})`\n };\n}\n\n// Build SSH command: single-quoted remote command prevents shell injection\nconst ssh_cmd = (mode === 'rework')\n ? `ssh vm101 'pi ingest-rework ${genome} ${raw} ${fb}'`\n : `ssh vm101 'pi ingest ${genome} ${raw}'`;\n\nreturn {\n _ok: true,\n ssh_cmd,\n genome,\n raw,\n mode,\n reason: String(d.reason || ''),\n prevPr: String(d.prevPr || '')\n};" }, - "id": "551ec0f1-450c-41ce-88a1-8690bc2c1c0b", + "id": "8e538237-0e0e-4308-b2c8-631a52b31185", "name": "Guard & build cmd", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 192, - 416 + 448, + 624 ] }, { @@ -52,13 +52,13 @@ }, "options": {} }, - "id": "ea4c36ed-c452-406b-9c94-c58fdc69ed20", + "id": "4b249e76-7ab6-4aa3-886d-06b865931cf6", "name": "Input valido?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ - 416, - 416 + 672, + 624 ] }, { @@ -66,13 +66,13 @@ "authentication": "privateKey", "command": "={{ $json.ssh_cmd }}" }, - "id": "a5ea3f08-df3b-4433-a04e-b69ce742575f", + "id": "8740ae9a-4094-48b2-a9a4-d40d501e09f6", "name": "SSH: ingest", "type": "n8n-nodes-base.ssh", "typeVersion": 1, "position": [ - 624, - 336 + 880, + 544 ], "credentials": { "sshPrivateKey": { @@ -84,29 +84,29 @@ { "parameters": { "mode": "runOnceForEachItem", - "jsCode": "// run-ingest.sh prints one JSON line; the wrapper may instead print {status:busy|error,...}.\n// Take the last {...} line from stdout.\nconst out = ($json.stdout || '').toString().trim();\nconst line = out.split('\\n').filter(l => l.trim().startsWith('{')).pop();\nlet r;\ntry { r = line ? JSON.parse(line) : { status: 'error', reason: 'nessuna riga JSON', raw: out }; }\ncatch (e) { r = { status: 'error', reason: 'JSON non parsabile', raw: line }; }\nreturn r;" + "jsCode": "// run-ingest.sh prints one JSON line; the wrapper may instead print {status:busy|error,...}.\n// Take the last {...} line from stdout (logs may precede/follow).\n// Run Once for Each Item: $json is the current SSH result item.\nconst out = String($json.stdout || '').trim();\nconst jsonLines = out\n .split('\\n')\n .map(l => l.trim())\n .filter(l => l.startsWith('{') && l.endsWith('}'));\n\nconst line = jsonLines.pop(); // last JSON object line (command prints JSON last)\n\nlet r;\ntry {\n r = line ? JSON.parse(line) : { status: 'error', reason: 'nessuna riga JSON trovata in stdout', raw: out.substring(0, 500) };\n} catch (e) {\n r = { status: 'error', reason: 'JSON non parsabile', rawLine: line?.substring(0, 1000) };\n}\n\n// Ensure consistent shape for downstream Build ntfy\nreturn {\n status: r.status || 'error',\n reason: r.reason || 'errore sconosciuto',\n pr_url: r.pr_url || '',\n slug: r.slug || '',\n lint_clean: r.lint_clean || false,\n conflict: r.conflict || false,\n stage: r.stage || '',\n detail: r.detail || '',\n log: r.log || '',\n _raw: line?.substring(0, 500)\n};" }, - "id": "0ee5e6c2-111a-4458-aab7-20a683f027ee", + "id": "928344e3-0712-42e0-b1a8-f5caff489746", "name": "Parse result", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 848, - 336 + 1104, + 544 ] }, { "parameters": { "mode": "runOnceForEachItem", - "jsCode": "// One builder for ingest + rework outcomes. Title is plain ASCII; the icon comes from Tags\n// (ntfy shortcodes); navigation is via Click (tap) + Actions (button) so it works on every\n// client. $('Guard...').item is reliable here: no executeWorkflow sits between Guard and here.\nconst g = $('Guard & build cmd').item.json;\nconst verb = (g.mode === 'rework') ? 'rework' : 'ingest';\nconst d = $json;\nlet n;\nif (g._ok === false) {\n n = { title: `Errore ${verb}: input non valido`, priority: 'high', tags: 'rotating_light',\n click: '', actions: '', body: `Richiesta di ${verb} rifiutata.\\n${g._reason}` };\n} else if (d.status === 'ok') {\n const pm = (d.pr_url || '').match(/\\/pulls\\/(\\d+)/);\n const num = pm ? `#${pm[1]}` : '';\n const lint = d.lint_clean ? 'lint pulito' : 'lint con avvisi';\n const conflict = d.conflict ? ' \\u00b7 \\u26a0\\ufe0f conflitto da risolvere' : '';\n n = { title: `${g.genome} \\u00b7 ${verb} ${d.slug} ${num}`.replace(/\\s+/g,' ').trim(),\n priority: d.conflict ? 'high' : 'default',\n tags: d.conflict ? 'warning' : 'white_check_mark',\n click: d.pr_url || '', actions: d.pr_url ? `view, Apri la PR, ${d.pr_url}` : '',\n body: `**${d.slug}** ${verb === 'rework' ? 'rilavorata' : 'ingerita'}`\n + (g.reason && verb === 'ingest' ? ` (${g.reason})` : '')\n + (g.prevPr ? ` \\u00b7 sostituisce #${g.prevPr}` : '')\n + `.\\n${lint}${conflict}.` };\n} else if (d.status === 'busy') {\n n = { title: `${g.genome} \\u00b7 ${verb} in coda`, priority: 'min', tags: 'hourglass_flowing_sand',\n click: '', actions: '',\n body: `Un altro ingest era in corso su questo genoma. La fonte resta pendente e verr\\u00e0 ripresa al prossimo campanello.` };\n} else if (d.status === 'pr_failed') {\n n = { title: `${g.genome} \\u00b7 ${d.slug}: PR non aperta`, priority: 'high', tags: 'warning',\n click: '', actions: '',\n body: `Semantic e lint ok, ma la PR non si \\u00e8 aperta.\\n${(d.detail || '').split('\\n')[0]}` };\n} else {\n const stage = d.stage ? ` (stage: ${d.stage})` : '';\n n = { title: `${g.genome} \\u00b7 errore ${verb}`, priority: 'high', tags: 'rotating_light',\n click: '', actions: '',\n body: `${(d.reason || 'errore')}${stage}.` + (d.log ? `\\nLog: ${d.log}` : '') };\n}\nn.topic = 'genome-ingest';\nreturn n;" + "jsCode": "// One builder for ingest + rework outcomes. Title is plain ASCII; the icon comes from Tags\n// (ntfy shortcodes); navigation is via Click (tap) + Actions (button) so it works on every\n// client.\n// Run Once for Each Item: $json is the current parsed result.\n// We read the original request context from the Guard node (same execution, no executeWorkflow in between).\nconst g = $('Guard & build cmd').item.json || {};\nconst verb = (g.mode === 'rework') ? 'rework' : 'ingest';\nconst d = $json || {};\nconst genome = g.genome || 'unknown';\n\n// Build notification based on status\nlet n;\n\nif (g._ok === false) {\n // Input validation failed (Guard & build cmd rejected it)\n n = {\n title: `Errore ${verb}: input non valido`,\n priority: 'high',\n tags: 'rotating_light',\n click: '',\n actions: '',\n body: `Richiesta di ${verb} rifiutata.\\n${g._reason || 'motivo sconosciuto'}`\n };\n} else if (d.status === 'ok') {\n // Success: PR opened\n const pm = (d.pr_url || '').match(/\\/pulls\\/(\\d+)/);\n const num = pm ? `#${pm[1]}` : '';\n const lint = d.lint_clean ? 'lint pulito' : 'lint con avvisi';\n const conflict = d.conflict ? ' · ⚠️ conflitto da risolvere' : '';\n const prevPr = g.prevPr ? ` · sostituisce #${g.prevPr}` : '';\n const reason = (g.reason && verb === 'ingest') ? ` (${g.reason})` : '';\n\n n = {\n title: `${genome} · ${verb} ${d.slug || ''} ${num}`.replace(/\\s+/g, ' ').trim(),\n priority: d.conflict ? 'high' : 'default',\n tags: d.conflict ? 'warning' : 'white_check_mark',\n click: d.pr_url || '',\n actions: d.pr_url ? `view, Apri la PR, ${d.pr_url}` : '',\n body: `**${d.slug || 'sorgente'}** ${verb === 'rework' ? 'rilavorata' : 'ingerita'}`\n + reason + prevPr\n + `.\\n${lint}${conflict}.`\n };\n} else if (d.status === 'busy') {\n // Another ingest is already running on this genome\n n = {\n title: `${genome} · ${verb} in coda`,\n priority: 'min',\n tags: 'hourglass_flowing_sand',\n click: '',\n actions: '',\n body: `Un altro ingest era in corso su questo genoma. La fonte resta pendente e verrà ripresa al prossimo campanello.`\n };\n} else if (d.status === 'pr_failed') {\n // Semantic/lint ok but PR could not be opened\n const detailLine = String(d.detail || '').split('\\n')[0] || 'dettaglio non disponibile';\n n = {\n title: `${genome} · ${d.slug || ''}: PR non aperta`,\n priority: 'high',\n tags: 'warning',\n click: '',\n actions: '',\n body: `Semantic e lint ok, ma la PR non si è aperta.\\n${detailLine}`\n };\n} else {\n // Generic error (including parse errors)\n const stage = d.stage ? ` (stage: ${d.stage})` : '';\n const log = d.log ? `\\nLog: ${d.log}` : '';\n n = {\n title: `${genome} · errore ${verb}`,\n priority: 'high',\n tags: 'rotating_light',\n click: '',\n actions: '',\n body: `${d.reason || 'errore sconosciuto'}${stage}.${log}`\n };\n}\n\nn.topic = 'genome-ingest';\nreturn n;" }, - "id": "458318e5-7b26-4695-b564-f58c357d37d0", + "id": "9062dfba-02ba-4abc-8be6-828c0b353114", "name": "Build ntfy", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 1072, - 416 + 1328, + 624 ] }, { @@ -152,13 +152,13 @@ "timeout": 15000 } }, - "id": "4a1f0a89-1a56-4e1c-8fbc-173cba4ce97b", + "id": "0c2b4d9b-2700-4815-b47c-8523bc4eb2ff", "name": "ntfy: send", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ - 1296, - 416 + 1552, + 624 ], "credentials": { "httpHeaderAuth": { @@ -257,7 +257,7 @@ "callerPolicy": "workflowsFromSameOwner", "availableInMCP": false }, - "versionId": "5d2cf4bd-f2c6-41fc-98a5-eaa797e31417", + "versionId": "fd8c1cf6-c5df-4074-b777-113349e32a03", "meta": { "instanceId": "96b2f0ec76a4400bbd481c617b24b3b87024cc7a913efacccaf9fc85722e7417" }, From a1f521d43f6818be0178dd791a29f7eaf2153be1 Mon Sep 17 00:00:00 2001 From: Matteo Cherubini Date: Thu, 2 Jul 2026 17:55:32 +0200 Subject: [PATCH 2/2] Update version --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 74192cb..b967630 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ # ============================================================================= -# Knowledge Genome - Makefile v. 1.12.0 +# Knowledge Genome - Makefile v. 1.13.0 # Orchestrates the setup and management of the knowledge base. # =============================================================================