{
  "name": "Triple Pendulum Bot",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "seconds",
              "secondsInterval": 30
            }
          ]
        }
      },
      "id": "b0000001-0001-0001-0001-000000000001",
      "name": "Every 30s",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        240,
        300
      ]
    },
    {
      "parameters": {
        "language": "javaScript",
        "jsCode": "// ---- CONFIG (placeholders, replaced by sync to live n8n) ----\nconst TELEGRAM_BOT_TOKEN = 'YOUR_TELEGRAM_BOT_TOKEN';\nconst ALLOWED_CHAT_IDS  = [935389847];\nconst LAUNCHER_URL      = 'http://10.1.4.232:8765';\nconst LAUNCHER_SECRET   = 'YOUR_LAUNCHER_SECRET';\nconst MLFLOW_URL        = 'http://10.1.4.230:5000';\nconst RUNPOD_API_KEY    = 'YOUR_RUNPOD_API_KEY';\nconst RUNPOD_POD_ID     = 'YOUR_RUNPOD_POD_ID';\n\n// HTML escape \u2014 Telegram parse_mode HTML treats <,>,& specially\nfunction esc(s) {\n  if (s == null) return '';\n  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');\n}\n\nasync function tgSend(chat_id, text) {\n  return await this.helpers.httpRequest({\n    method: 'POST',\n    url: `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`,\n    body: { chat_id, text, parse_mode: 'HTML', disable_web_page_preview: true },\n    json: true,\n    timeout: 10000,\n    failOnNotOk: false,\n  });\n}\n\n// ---- POLL TELEGRAM getUpdates (long-poll) ----\nconst staticData = $getWorkflowStaticData('global');\nconst lastOffset = staticData.lastOffset || 0;\n\nlet updates = [];\ntry {\n  const r = await this.helpers.httpRequest({\n    method: 'GET',\n    url: `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getUpdates`,\n    qs: { offset: lastOffset + 1, timeout: 25, allowed_updates: 'message' },\n    json: true,\n    timeout: 30000,\n  });\n  if (r.ok && Array.isArray(r.result)) updates = r.result;\n} catch (e) {\n  return [];\n}\n\nif (updates.length === 0) return [];\nstaticData.lastOffset = Math.max(...updates.map(u => u.update_id));\n\n// ---- DATA HELPERS ----\nasync function gqlRunPod(query) {\n  return await this.helpers.httpRequest({\n    method: 'POST',\n    url: 'https://api.runpod.io/graphql',\n    headers: { 'Authorization': `Bearer ${RUNPOD_API_KEY}`, 'Content-Type': 'application/json' },\n    body: { query },\n    json: true,\n    timeout: 8000,\n    failOnNotOk: false,\n  });\n}\n\nasync function getRunPodStatus() {\n  try {\n    const q = `query { pod(input: {podId: \"${RUNPOD_POD_ID}\"}) { id name desiredStatus costPerHr runtime { uptimeInSeconds gpus { gpuUtilPercent memoryUtilPercent } } } }`;\n    const r = await gqlRunPod.call(this, q);\n    return r.data?.pod;\n  } catch (e) { return { _error: String(e).slice(0,200) }; }\n}\n\nasync function getRunPodBalance() {\n  try {\n    const r = await gqlRunPod.call(this, `query { myself { clientBalance } }`);\n    return r.data?.myself?.clientBalance;\n  } catch (e) { return null; }\n}\n\nasync function getLauncherStatus() {\n  try {\n    return await this.helpers.httpRequest({ url: `${LAUNCHER_URL}/status`, json: true, timeout: 5000, failOnNotOk: false });\n  } catch (e) { return { _error: 'launcher unreachable' }; }\n}\n\nasync function getMLflowExperimentIds() {\n  try {\n    const r = await this.helpers.httpRequest({\n      url: `${MLFLOW_URL}/api/2.0/mlflow/experiments/search`,\n      method: 'POST',\n      body: { max_results: 50, view_type: 'ALL' },\n      json: true,\n      timeout: 5000,\n      failOnNotOk: false,\n    });\n    return (r.experiments || []).map(e => e.experiment_id);\n  } catch (e) { return ['1','2']; }\n}\n\nasync function getMLflowRecent(n=3) {\n  try {\n    const ids = await getMLflowExperimentIds.call(this);\n    if (!ids.length) return [];\n    const r = await this.helpers.httpRequest({\n      method: 'POST',\n      url: `${MLFLOW_URL}/api/2.0/mlflow/runs/search`,\n      body: { experiment_ids: ids, max_results: n, order_by: ['start_time DESC'] },\n      json: true,\n      timeout: 5000,\n      failOnNotOk: false,\n    });\n    return r.runs || [];\n  } catch (e) { return []; }\n}\n\n// ---- FORMATTERS (HTML) ----\nfunction fmtUptime(s) {\n  if (!s) return '0s';\n  const h = Math.floor(s/3600), m = Math.floor((s%3600)/60), x = s%60;\n  return h ? `${h}h${String(m).padStart(2,'0')}m` : (m ? `${m}m${x}s` : `${x}s`);\n}\n\nfunction fmtETA(seconds) {\n  if (!seconds || seconds < 0 || !isFinite(seconds)) return '?';\n  if (seconds < 60) return `${Math.round(seconds)}s`;\n  const h = Math.floor(seconds/3600);\n  const m = Math.round((seconds%3600)/60);\n  return h ? `${h}h${m}m` : `${m}m`;\n}\n\nfunction fmtRunPod(pod) {\n  if (!pod) return 'pod not found';\n  if (pod._error) return `error: ${esc(pod._error)}`;\n  const rt = pod.runtime;\n  if (!rt) return `pod <b>${esc(pod.desiredStatus)}</b> (no runtime)`;\n  const g = rt.gpus?.[0] || {};\n  const cost = (rt.uptimeInSeconds * pod.costPerHr / 3600).toFixed(2);\n  return `<b>${esc(pod.name)}</b> ${esc(pod.desiredStatus)}\\n` +\n         `  uptime: ${fmtUptime(rt.uptimeInSeconds)}  cost: $${cost}\\n` +\n         `  gpu: ${g.gpuUtilPercent ?? '?'}%  mem: ${g.memoryUtilPercent ?? '?'}%`;\n}\n\nfunction fmtMLflowRuns(runs) {\n  if (!runs.length) return '<i>no recent runs</i>';\n  return runs.map(r => {\n    const m = (r.data?.metrics || []).reduce((a,x) => (a[x.key]=x.value, a), {});\n    const p = (r.data?.params || []).reduce((a,x) => (a[x.key]=x.value, a), {});\n    const name = (r.info.run_name || '?').slice(0,50);\n    const sts = r.info.status;\n    const ts = m.timesteps;\n    const total = parseFloat(p.total_timesteps);\n    const sps = m.steps_per_s;\n    const rew = m.rollout_ep_rew_mean;\n    const succ = m.final_overall_success_rate;\n    const dev = p.device;\n    let line = `<code>${esc(name)}</code> ${esc(sts)}`;\n    if (ts != null && total > 0) {\n      const pct = (ts / total * 100).toFixed(0);\n      line += `  ${(ts/1000).toFixed(0)}K/${(total/1e6).toFixed(1)}M (${pct}%)`;\n      if (sts === 'RUNNING' && sps > 0) line += ` ETA ${fmtETA((total - ts) / sps)}`;\n    } else if (ts != null) {\n      line += `  ${(ts/1000).toFixed(0)}K steps`;\n    }\n    if (rew != null) line += `  rew=${rew.toFixed(0)}`;\n    if (succ != null) line += `  <b>succ=${(succ*100).toFixed(0)}%</b>`;\n    if (dev && dev !== '?') line += `  [${esc(dev)}]`;\n    return '  ' + line;\n  }).join('\\n');\n}\n\n// ---- COMMAND DISPATCH ----\nasync function handleCommand(message) {\n  const chat_id = message.chat?.id;\n  // Silent drop on unauthorized \u2014 do NOT echo (avoids leaking bot identity)\n  if (!ALLOWED_CHAT_IDS.includes(chat_id)) return null;\n\n  const text = (message.text || '').trim();\n  const tokens = text.split(/\\s+/);\n  const cmd = tokens[0].toLowerCase();\n  const args = tokens.slice(1);\n  let reply = '';\n\n  if (cmd === '/start' || cmd === '/help') {\n    reply = `<b>Triple Pendulum Bot</b> \u2014 commands:\\n\\n` +\n      `<code>/status</code> \u2014 overall pipeline\\n` +\n      `<code>/runpod</code> \u2014 pod state + GPU + cost\\n` +\n      `<code>/ct1018</code> \u2014 local launcher sessions\\n` +\n      `<code>/mlflow [n]</code> \u2014 last n MLflow runs\\n` +\n      `<code>/gpu</code> \u2014 GPU one-liner\\n` +\n      `<code>/cost</code> \u2014 current cost + RunPod balance\\n` +\n      `<code>/launch &lt;m2|m3b|m3c|m4&gt;</code> \u2014 launch on CT 1018\\n` +\n      `<code>/kill ct1018|runpod</code> \u2014 kill running training\\n` +\n      `<code>/pod_start</code> \u2014 resume RunPod pod\\n` +\n      `<code>/pod_stop confirm</code> \u2014 stop RunPod pod (requires <code>confirm</code>)\\n` +\n      `<code>/help</code> \u2014 this message`;\n  }\n  else if (cmd === '/status') {\n    const [pod, launcher, runs] = await Promise.all([\n      getRunPodStatus.call(this), getLauncherStatus.call(this), getMLflowRecent.call(this, 3),\n    ]);\n    reply = `<b>Triple Pendulum status</b>\\n\\n` +\n      `\ud83c\udf10 <b>RunPod</b>\\n${fmtRunPod(pod)}\\n\\n` +\n      `\ud83d\udda5 <b>CT 1018 launcher</b>\\n  ${launcher._error ? esc(launcher._error) : `${launcher.count || 0} active processes`}\\n\\n` +\n      `\ud83d\udcca <b>MLflow</b> (latest)\\n${fmtMLflowRuns(runs)}`;\n  }\n  else if (cmd === '/runpod') {\n    const pod = await getRunPodStatus.call(this);\n    reply = `\ud83c\udf10 <b>RunPod</b>\\n${fmtRunPod(pod)}`;\n  }\n  else if (cmd === '/ct1018') {\n    const launcher = await getLauncherStatus.call(this);\n    if (launcher._error) reply = `\ud83d\udda5 <b>CT 1018</b> \u2014 ${esc(launcher._error)}`;\n    else {\n      const sessions = (launcher.sessions || []).slice(0,3).map(s => `  \u2022 <code>${esc(s.slice(0,80))}</code>`).join('\\n');\n      reply = `\ud83d\udda5 <b>CT 1018</b> \u2014 ${launcher.count} active\\n${sessions || '  <i>none</i>'}`;\n    }\n  }\n  else if (cmd === '/mlflow') {\n    const n = parseInt(args[0]) || 3;\n    const runs = await getMLflowRecent.call(this, Math.min(Math.max(n, 1), 10));\n    reply = `\ud83d\udcca <b>MLflow</b> \u2014 last ${runs.length}\\n${fmtMLflowRuns(runs)}`;\n  }\n  else if (cmd === '/gpu') {\n    const pod = await getRunPodStatus.call(this);\n    if (!pod || pod._error) reply = `RunPod: ${esc(pod?._error || 'not reachable')}`;\n    else {\n      const g = pod.runtime?.gpus?.[0];\n      reply = g ? `GPU ${g.gpuUtilPercent}% util / ${g.memoryUtilPercent}% mem` : 'no runtime';\n    }\n  }\n  else if (cmd === '/cost') {\n    const [pod, balance] = await Promise.all([getRunPodStatus.call(this), getRunPodBalance.call(this)]);\n    let podPart = 'RunPod: not reachable';\n    if (pod && !pod._error) {\n      const cost = pod.runtime ? (pod.runtime.uptimeInSeconds * pod.costPerHr / 3600) : 0;\n      podPart = `<b>${esc(pod.name)}</b> ${esc(pod.desiredStatus)}\\n  current run cost: $${cost.toFixed(2)} (\\$${pod.costPerHr}/hr)`;\n    }\n    const balPart = balance != null ? `\\n\\n\ud83d\udcb3 RunPod balance: <b>$${balance.toFixed(2)}</b>` : '';\n    reply = `\ud83d\udcb0 <b>Cost</b>\\n${podPart}${balPart}`;\n  }\n  else if (cmd === '/launch') {\n    const stage = (args[0] || '').toLowerCase();\n    const stageMap = {\n      'm2':  { module: 'training.train_m2_upright',     config: 'training/configs/m2_upright_tqc.yaml' },\n      'm3b': { module: 'training.train_m3_all_eps',     config: 'training/configs/m3b_all_eps_tqc.yaml' },\n      'm3c': { module: 'training.train_m3_all_eps',     config: 'training/configs/m3c_all_eps_tqc.yaml' },\n      'm4':  { module: 'training.train_m4_transitions', config: 'training/configs/m4_transitions_tqc.yaml' },\n    };\n    if (!stageMap[stage]) {\n      reply = `Unknown stage: <code>${esc(stage)}</code>\\nValid: m2, m3b, m3c, m4`;\n    } else {\n      // Ack immediately so user knows the request was received\n      await tgSend.call(this, chat_id, `\u23f3 Launching <b>${stage.toUpperCase()}</b> on CT 1018...`);\n      try {\n        const r = await this.helpers.httpRequest({\n          method: 'POST',\n          url: `${LAUNCHER_URL}/launch`,\n          body: { secret: LAUNCHER_SECRET, ...stageMap[stage] },\n          json: true,\n          timeout: 15000,\n          failOnNotOk: false,\n          returnFullResponse: true,\n        });\n        const body = r.body || r;\n        if (body.ok) {\n          reply = `\u2705 Launched <b>${esc(stage.toUpperCase())}</b>\\nsession: <code>${esc(body.session)}</code>`;\n        } else {\n          const code = r.statusCode || 'err';\n          reply = `\u274c Launch failed (HTTP ${code})\\n${esc(body.error || JSON.stringify(body))}`;\n          if (body.sessions) reply += `\\n<i>active:</i>\\n` + body.sessions.slice(0,2).map(s => `  \u2022 <code>${esc(s.slice(0,80))}</code>`).join('\\n');\n        }\n      } catch (e) {\n        reply = `\u274c Launcher error: ${esc(String(e).slice(0,200))}`;\n      }\n    }\n  }\n  else if (cmd === '/kill') {\n    const target = (args[0] || '').toLowerCase();\n    if (target === 'ct1018') {\n      await tgSend.call(this, chat_id, `\u26a0\ufe0f Killing CT 1018 training...`);\n      try {\n        const r = await this.helpers.httpRequest({\n          method: 'POST',\n          url: `${LAUNCHER_URL}/kill`,\n          body: { secret: LAUNCHER_SECRET },\n          json: true,\n          timeout: 10000,\n          failOnNotOk: false,\n          returnFullResponse: true,\n        });\n        const body = r.body || r;\n        if (body.ok) {\n          reply = `\ud83d\uded1 CT 1018 killed\\n  sessions: ${(body.killed_sessions||[]).join(', ') || '<i>none</i>'}\\n  pids: ${(body.killed_pids||[]).join(', ') || '<i>none</i>'}`;\n        } else {\n          reply = `\u274c Kill failed: ${esc(body.error || JSON.stringify(body))}`;\n        }\n      } catch (e) {\n        reply = `\u274c Kill error: ${esc(String(e).slice(0,200))}`;\n      }\n    } else if (target === 'runpod') {\n      await tgSend.call(this, chat_id, `\u26a0\ufe0f Stopping RunPod pod (kills training)...`);\n      try {\n        const r = await gqlRunPod.call(this, `mutation { podStop(input: {podId: \"${RUNPOD_POD_ID}\"}) { id desiredStatus } }`);\n        const s = r.data?.podStop?.desiredStatus;\n        reply = s ? `\ud83d\uded1 RunPod stopped: <b>${esc(s)}</b>` : `\u274c stop failed: ${esc(JSON.stringify(r).slice(0,200))}`;\n      } catch (e) { reply = `\u274c ${esc(String(e).slice(0,200))}`; }\n    } else {\n      reply = `<code>/kill ct1018</code> or <code>/kill runpod</code>\\n` +\n              `<i>ct1018 sends TERM to local tmux + python; runpod stops the pod.</i>`;\n    }\n  }\n  else if (cmd === '/pod_start') {\n    try {\n      const r = await gqlRunPod.call(this, `mutation { podResume(input: {podId: \"${RUNPOD_POD_ID}\", gpuCount: 1}) { id desiredStatus } }`);\n      const s = r.data?.podResume?.desiredStatus;\n      reply = s ? `\ud83c\udf10 RunPod resumed: <b>${esc(s)}</b>` : `\u274c resume failed: ${esc(JSON.stringify(r).slice(0,200))}`;\n    } catch (e) { reply = `\u274c ${esc(String(e).slice(0,200))}`; }\n  }\n  else if (cmd === '/pod_stop') {\n    if (args[0] !== 'confirm') {\n      reply = `\u26a0\ufe0f <b>/pod_stop</b> stops the RunPod pod and KILLS any running training.\\n\\n` +\n              `To confirm: <code>/pod_stop confirm</code>`;\n    } else {\n      try {\n        const r = await gqlRunPod.call(this, `mutation { podStop(input: {podId: \"${RUNPOD_POD_ID}\"}) { id desiredStatus } }`);\n        const s = r.data?.podStop?.desiredStatus;\n        reply = s ? `\ud83d\uded1 RunPod stopped: <b>${esc(s)}</b>` : `\u274c stop failed: ${esc(JSON.stringify(r).slice(0,200))}`;\n      } catch (e) { reply = `\u274c ${esc(String(e).slice(0,200))}`; }\n    }\n  }\n  else {\n    reply = `Unknown command: <code>${esc(cmd)}</code>\\nTry <code>/help</code>`;\n  }\n  return { chat_id, text: reply };\n}\n\nconst items = [];\nfor (const upd of updates) {\n  const msg = upd.message;\n  if (!msg || !msg.text) continue;\n  const r = await handleCommand.call(this, msg);\n  if (!r) continue;\n  items.push({ json: { ...r, parse_mode: 'HTML', disable_web_page_preview: true, telegramBotUrl: `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage` } });\n}\nreturn items;\n"
      },
      "id": "b0000002-0002-0002-0002-000000000002",
      "name": "Poll + Dispatch",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        480,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $json.telegramBotUrl }}",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ chat_id: $json.chat_id, text: $json.text, parse_mode: $json.parse_mode, disable_web_page_preview: true }) }}",
        "options": {
          "timeout": 10000
        }
      },
      "id": "b0000003-0003-0003-0003-000000000003",
      "name": "Send Reply",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        720,
        300
      ]
    }
  ],
  "connections": {
    "Every 30s": {
      "main": [
        [
          {
            "node": "Poll + Dispatch",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Poll + Dispatch": {
      "main": [
        [
          {
            "node": "Send Reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "callerPolicy": "workflowsFromSameOwner",
    "availableInMCP": false
  }
}