{
  "name": "EMT Compute — GPU experiments and calculation reports",
  "nodes": [
    {
      "id": "manual-start",
      "name": "Manual start",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        0
      ],
      "parameters": {}
    },
    {
      "id": "configuration",
      "name": "Configuration",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        220,
        0
      ],
      "parameters": {
        "jsCode": "// Start in quote-only mode. Select the same EMT credential on every HTTP node.\n// Persist your business idempotency key BEFORE enabling a paid run. Use a new key only for a new intended rental.\nreturn [{ json: {\n  allowPaidRun: false,\n  maxRentalUSD: 5,\n  idempotencyKey: \"CHANGE-TO-A-PERSISTED-UNIQUE-KEY\",\n  existingJob: \"\", // Resume monitoring a known job without creating another rental.\n  quoteRequest: { count: 1, nodes: 1, minVram: 8, maxMinutes: 15, fp64: false, environment: \"emt-lab\" },\n  experiment: { name: \"n8n scientific experiment\", experiment: { kind: \"preset\", preset: \"galaxy-collision\" }, level: \"student\", runs: [{ params: {} }], seed: 7, files: [] },\n  maxPolls: 120, // 30-second intervals; timeout requests cancellation, then shows actual release state.\n} }];"
      }
    },
    {
      "id": "existing-job",
      "name": "Existing job",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        440,
        0
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "Existing job",
              "leftValue": "={{ typeof $json.existingJob === 'string' && $json.existingJob.length > 0 }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        }
      }
    },
    {
      "id": "fresh-quote",
      "name": "Fresh quote",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        660,
        160
      ],
      "parameters": {
        "method": "POST",
        "url": "=https://emtcompute.com/api/v1/experiment-quotes",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ $json.quoteRequest }}",
        "options": {
          "timeout": 60000,
          "redirect": {
            "redirect": {
              "followRedirects": false
            }
          }
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000,
      "notes": "Select an HTTP Header Auth credential: name Authorization, value Bearer <your EMT API key>. Never place the key inside the workflow."
    },
    {
      "id": "check-cost-and-consent",
      "name": "Check cost and consent",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        160
      ],
      "parameters": {
        "jsCode": "const cfg = $('Configuration').first().json;\nconst q = $input.first().json;\nconst uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;\nif (!Array.isArray(q.ids) || q.ids.length !== cfg.quoteRequest.nodes || !q.ids.every(id => uuid.test(id))) throw new Error('Invalid quote node IDs');\nif (!Number.isSafeInteger(q.hourly) || q.hourly <= 0 || !Number.isInteger(q.hours) || q.hours < 1 || q.hours > 24 || q.maxMinutes !== cfg.quoteRequest.maxMinutes || q.count !== cfg.quoteRequest.count || q.nodes !== cfg.quoteRequest.nodes || q.hours !== Math.ceil(q.maxMinutes / 60)) throw new Error('Invalid quote duration or price');\nif (!Number.isFinite(q.expires) || q.expires < Date.now() + 5000) throw new Error('Quote expired; do not launch');\nif (!Number.isFinite(cfg.maxRentalUSD) || cfg.maxRentalUSD <= 0) throw new Error('Set a positive rental budget');\nconst ceilingMicro = q.hourly * q.hours;\nif (!Number.isSafeInteger(ceilingMicro) || ceilingMicro > Math.floor(cfg.maxRentalUSD * 1000000)) throw new Error('Rental exceeds configured budget');\nif (cfg.experiment.auto) throw new Error('This bounded template does not enable automatic paid continuation');\nif (cfg.allowPaidRun === true && (!/^[A-Za-z0-9_.:-]{16,120}$/.test(cfg.idempotencyKey) || cfg.idempotencyKey.startsWith('CHANGE-'))) throw new Error('Set and persist a unique business idempotency key first');\nconst launch = { ...cfg.experiment, quotes: q.ids };\nreturn [{ json: { mode: cfg.allowPaidRun === true ? 'paid' : 'quote-only', quote: q, rentalCeilingUSD: ceilingMicro / 1000000, launch, idempotencyKey: cfg.idempotencyKey, note: 'GPU rental ceiling only; retained storage or other services are separate. Quote reserve is an initial hold, not the total cost. Save this exact payload and key for retries.' } }];"
      }
    },
    {
      "id": "paid-run-authorized",
      "name": "Paid run authorized",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1100,
        160
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "Paid run authorized",
              "leftValue": "={{ $json.mode === 'paid' }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        }
      }
    },
    {
      "id": "start-experiment",
      "name": "Start experiment",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1320,
        80
      ],
      "parameters": {
        "method": "POST",
        "url": "=https://emtcompute.com/api/v1/experiment-jobs",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Idempotency-Key",
              "value": "={{ $json.idempotencyKey }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ $json.launch }}",
        "options": {
          "timeout": 60000,
          "redirect": {
            "redirect": {
              "followRedirects": false
            }
          }
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000,
      "notes": "Select an HTTP Header Auth credential: name Authorization, value Bearer <your EMT API key>. Never place the key inside the workflow."
    },
    {
      "id": "quote-only",
      "name": "Quote only",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1320,
        300
      ],
      "parameters": {
        "jsCode": "return $input.all();"
      }
    },
    {
      "id": "job-reference",
      "name": "Job reference",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1540,
        0
      ],
      "parameters": {
        "jsCode": "const cfg = $('Configuration').first().json;\nconst jobId = cfg.existingJob || $input.first().json.job?.id;\nif (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(jobId || '')) throw new Error('Missing valid job ID; inspect the start response before retrying');\nif (!Number.isInteger(cfg.maxPolls) || cfg.maxPolls < 1 || cfg.maxPolls > 3000) throw new Error('Invalid polling limit');\nreturn [{ json: { jobId } }];"
      }
    },
    {
      "id": "wait-before-polling",
      "name": "Wait before polling",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [
        1760,
        0
      ],
      "parameters": {
        "amount": 30,
        "unit": "seconds"
      }
    },
    {
      "id": "read-experiment",
      "name": "Read experiment",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1980,
        0
      ],
      "parameters": {
        "method": "GET",
        "url": "=https://emtcompute.com/api/v1/experiment-jobs/{{ $json.jobId }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "timeout": 60000,
          "redirect": {
            "redirect": {
              "followRedirects": false
            }
          }
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000,
      "notes": "Select an HTTP Header Auth credential: name Authorization, value Bearer <your EMT API key>. Never place the key inside the workflow."
    },
    {
      "id": "inspect-progress",
      "name": "Inspect progress",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2200,
        0
      ],
      "parameters": {
        "jsCode": "const job = $input.first().json;\nconst jobId = $('Job reference').first().json.jobId;\nif (job.id !== jobId || !['active', 'done', 'failed', 'cancelled'].includes(job.status) || typeof job.releasing !== 'boolean') throw new Error('Invalid job response; inspect the console before retrying');\nconst released = job.status !== 'active' && job.releasing === false;\nreturn [{ json: { jobId, job, released, timeout: $runIndex + 1 >= $('Configuration').first().json.maxPolls,\n  reports: (job.reports || []).map(index => ({ index, url: 'https://emtcompute.com/api/v1/experiment-jobs/' + jobId + '/reports/' + index })),\n  results: (job.results || []).map(index => ({ index, url: 'https://emtcompute.com/api/v1/experiment-jobs/' + jobId + '/results/' + index })),\n  downloadAuthentication: 'Use the same HTTP Header Auth credential on your download node. Never put the key in URLs.' } }];"
      }
    },
    {
      "id": "released",
      "name": "Released",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2420,
        0
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "Released",
              "leftValue": "={{ $json.released }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        }
      }
    },
    {
      "id": "results-and-reports",
      "name": "Results and reports",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2640,
        -160
      ],
      "parameters": {
        "jsCode": "return $input.all();"
      }
    },
    {
      "id": "polling-limit-reached",
      "name": "Polling limit reached",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2640,
        100
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "Polling limit reached",
              "leftValue": "={{ $json.timeout }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        }
      }
    },
    {
      "id": "cancel-timed-out-experiment",
      "name": "Cancel timed-out experiment",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2860,
        100
      ],
      "parameters": {
        "method": "POST",
        "url": "=https://emtcompute.com/api/v1/experiment-jobs/{{ $json.jobId }}/cancel",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ {} }}",
        "options": {
          "timeout": 60000,
          "redirect": {
            "redirect": {
              "followRedirects": false
            }
          }
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000,
      "notes": "Select an HTTP Header Auth credential: name Authorization, value Bearer <your EMT API key>. Never place the key inside the workflow."
    },
    {
      "id": "read-release-state",
      "name": "Read release state",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3080,
        100
      ],
      "parameters": {
        "method": "GET",
        "url": "=https://emtcompute.com/api/v1/experiment-jobs/{{ $('Job reference').first().json.jobId }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "timeout": 60000,
          "redirect": {
            "redirect": {
              "followRedirects": false
            }
          }
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000,
      "notes": "Select an HTTP Header Auth credential: name Authorization, value Bearer <your EMT API key>. Never place the key inside the workflow."
    },
    {
      "id": "cancellation-outcome",
      "name": "Cancellation outcome",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3300,
        100
      ],
      "parameters": {
        "jsCode": "const job = $input.first().json;\nreturn [{ json: { job, action: 'Cancellation requested after the polling limit', needsAttention: job.status === 'active' || job.releasing !== false, message: job.releasing === false && job.status !== 'active' ? 'Release confirmed; final settlement can still be pending.' : 'Release is still pending: inspect the EMT console. Do not assume GPU billing has stopped.' } }];"
      }
    },
    {
      "id": "setup-and-recovery",
      "name": "Setup and recovery",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        220,
        -500
      ],
      "parameters": {
        "width": 940,
        "height": 330,
        "content": "## EMT Compute · GPU experiments with calculation reports\n1. Create an EMT account, accept terms, fund only your intended budget, and create a scoped API key (read + rent + manage).\n2. Create an n8n HTTP Header Auth credential: Authorization = Bearer <key>. Select it on EVERY HTTP node.\n3. Edit Configuration. Default is quote-only: no GPU is rented. Set a budget and a persistent unique business key before enabling a paid run.\n4. Retries inside Start experiment preserve the exact body and key. After a lost response, inspect EMT jobs or retry ONLY that failed node using saved execution data. A fresh workflow run obtains different quotes: do not invent a new key to bypass an idempotency conflict.\n5. To monitor an existing job, set existingJob. Cancellation stops all job nodes. Results and reports are private; download them using your credential.\nGPU rental limit excludes separately retained storage. A parameter sweep spreads across nodes; arbitrary code is not automatically made distributed. Read https://emtcompute.com/integrations"
      }
    }
  ],
  "connections": {
    "Manual start": {
      "main": [
        [
          {
            "node": "Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Configuration": {
      "main": [
        [
          {
            "node": "Existing job",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Existing job": {
      "main": [
        [
          {
            "node": "Job reference",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Fresh quote",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fresh quote": {
      "main": [
        [
          {
            "node": "Check cost and consent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check cost and consent": {
      "main": [
        [
          {
            "node": "Paid run authorized",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Paid run authorized": {
      "main": [
        [
          {
            "node": "Start experiment",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Quote only",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Start experiment": {
      "main": [
        [
          {
            "node": "Job reference",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Job reference": {
      "main": [
        [
          {
            "node": "Wait before polling",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait before polling": {
      "main": [
        [
          {
            "node": "Read experiment",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read experiment": {
      "main": [
        [
          {
            "node": "Inspect progress",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Inspect progress": {
      "main": [
        [
          {
            "node": "Released",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Released": {
      "main": [
        [
          {
            "node": "Results and reports",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Polling limit reached",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Polling limit reached": {
      "main": [
        [
          {
            "node": "Cancel timed-out experiment",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Wait before polling",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Cancel timed-out experiment": {
      "main": [
        [
          {
            "node": "Read release state",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read release state": {
      "main": [
        [
          {
            "node": "Cancellation outcome",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "saveDataErrorExecution": "all",
    "saveDataSuccessExecution": "all",
    "saveExecutionProgress": true
  },
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
