From 047330b38446437d315a5da8c7847203b83afd2e Mon Sep 17 00:00:00 2001 From: Matteo Cherubini Date: Wed, 1 Jul 2026 18:24:19 +0200 Subject: [PATCH] feat: Introduce `run-one-ingest` sub-workflow --- deploy/n8n/genome-run-one-ingest.json | 266 ++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 deploy/n8n/genome-run-one-ingest.json diff --git a/deploy/n8n/genome-run-one-ingest.json b/deploy/n8n/genome-run-one-ingest.json new file mode 100644 index 0000000..be8dd15 --- /dev/null +++ b/deploy/n8n/genome-run-one-ingest.json @@ -0,0 +1,266 @@ +{ + "name": "Genome: run-one-ingest", + "nodes": [ + { + "parameters": { + "inputSource": "passthrough" + }, + "id": "70da9144-1147-4cb5-9868-1f5ee2425d4c", + "name": "On ingest request", + "type": "n8n-nodes-base.executeWorkflowTrigger", + "typeVersion": 1.1, + "position": [ + -32, + 416 + ] + }, + { + "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 || '' };" + }, + "id": "551ec0f1-450c-41ce-88a1-8690bc2c1c0b", + "name": "Guard & build cmd", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 192, + 416 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "4507e3a8b9714c7e", + "leftValue": "={{ $json._ok }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "ea4c36ed-c452-406b-9c94-c58fdc69ed20", + "name": "Input valido?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [ + 416, + 416 + ] + }, + { + "parameters": { + "authentication": "privateKey", + "command": "={{ $json.ssh_cmd }}" + }, + "id": "a5ea3f08-df3b-4433-a04e-b69ce742575f", + "name": "SSH: ingest", + "type": "n8n-nodes-base.ssh", + "typeVersion": 1, + "position": [ + 624, + 336 + ], + "credentials": { + "sshPrivateKey": { + "id": "GJQjKzte7Hjdfz89", + "name": "n8n container -> n8n-runner@nexus" + } + } + }, + { + "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;" + }, + "id": "0ee5e6c2-111a-4458-aab7-20a683f027ee", + "name": "Parse result", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 848, + 336 + ] + }, + { + "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;" + }, + "id": "458318e5-7b26-4695-b564-f58c357d37d0", + "name": "Build ntfy", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1072, + 416 + ] + }, + { + "parameters": { + "method": "POST", + "url": "=http://ntfy/{{ $json.topic }}", + "authentication": "genericCredentialType", + "genericAuthType": "httpBearerAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Title", + "value": "={{ $json.title }}" + }, + { + "name": "Priority", + "value": "={{ $json.priority }}" + }, + { + "name": "Tags", + "value": "={{ $json.tags }}" + }, + { + "name": "Click", + "value": "={{ $json.click }}" + }, + { + "name": "Actions", + "value": "={{ $json.actions }}" + }, + { + "name": "Markdown", + "value": "yes" + } + ] + }, + "sendBody": true, + "contentType": "raw", + "rawContentType": "Raw / Text", + "body": "={{ $json.body }}", + "options": { + "timeout": 15000 + } + }, + "id": "4a1f0a89-1a56-4e1c-8fbc-173cba4ce97b", + "name": "ntfy: send", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 1296, + 416 + ], + "credentials": { + "httpHeaderAuth": { + "id": "TBPXSWOF63k9mvm8", + "name": "ntfy-token" + }, + "httpBearerAuth": { + "id": "nCv4CUN7Ef086Ewj", + "name": "Bearer Auth account" + } + } + } + ], + "pinData": {}, + "connections": { + "On ingest request": { + "main": [ + [ + { + "node": "Guard & build cmd", + "type": "main", + "index": 0 + } + ] + ] + }, + "Guard & build cmd": { + "main": [ + [ + { + "node": "Input valido?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Input valido?": { + "main": [ + [ + { + "node": "SSH: ingest", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Build ntfy", + "type": "main", + "index": 0 + } + ] + ] + }, + "SSH: ingest": { + "main": [ + [ + { + "node": "Parse result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse result": { + "main": [ + [ + { + "node": "Build ntfy", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build ntfy": { + "main": [ + [ + { + "node": "ntfy: send", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": true, + "settings": { + "executionOrder": "v1", + "binaryMode": "separate", + "timeSavedMode": "fixed", + "errorWorkflow": "7Vws3gCX3QnjM3oD", + "callerPolicy": "workflowsFromSameOwner", + "availableInMCP": false + }, + "versionId": "5d2cf4bd-f2c6-41fc-98a5-eaa797e31417", + "meta": { + "instanceId": "96b2f0ec76a4400bbd481c617b24b3b87024cc7a913efacccaf9fc85722e7417" + }, + "id": "VIi2ovb5gJxNJLbg", + "tags": [] +} \ No newline at end of file