{ "name": "Genome: run-one-ingest", "nodes": [ { "parameters": { "inputSource": "passthrough" }, "id": "b1b7ba8e-1e45-4f76-adc0-089180715975", "name": "On ingest request", "type": "n8n-nodes-base.executeWorkflowTrigger", "typeVersion": 1.1, "position": [ 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.\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": "8e538237-0e0e-4308-b2c8-631a52b31185", "name": "Guard & build cmd", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 448, 624 ] }, { "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": "4b249e76-7ab6-4aa3-886d-06b865931cf6", "name": "Input valido?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 672, 624 ] }, { "parameters": { "authentication": "privateKey", "command": "={{ $json.ssh_cmd }}" }, "id": "8740ae9a-4094-48b2-a9a4-d40d501e09f6", "name": "SSH: ingest", "type": "n8n-nodes-base.ssh", "typeVersion": 1, "position": [ 880, 544 ], "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 (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": "928344e3-0712-42e0-b1a8-f5caff489746", "name": "Parse result", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 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.\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": "9062dfba-02ba-4abc-8be6-828c0b353114", "name": "Build ntfy", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1328, 624 ] }, { "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": "0c2b4d9b-2700-4815-b47c-8523bc4eb2ff", "name": "ntfy: send", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ 1552, 624 ], "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": "fd8c1cf6-c5df-4074-b777-113349e32a03", "meta": { "instanceId": "96b2f0ec76a4400bbd481c617b24b3b87024cc7a913efacccaf9fc85722e7417" }, "id": "VIi2ovb5gJxNJLbg", "tags": [] }