From dcb7eb3fd3dff6c49292b8f17c22bb02ec8489df Mon Sep 17 00:00:00 2001 From: Charlie Kolb Date: Wed, 5 Aug 2026 14:08:41 +0200 Subject: [PATCH] chore: Let nathan deploys proceed without a reply tunnel When the localtunnel service is unreachable, a deploy with an explicit fresh instance name is still confirmable by polling the instance URL, so send the webhook anyway instead of aborting. Commands whose result only arrives via the tunnel still fail fast. Co-Authored-By: Claude Fable 5 --- .agents/skills/nathan/SKILL.md | 6 +++-- scripts/nathan.mjs | 40 +++++++++++++++++++++++----------- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/.agents/skills/nathan/SKILL.md b/.agents/skills/nathan/SKILL.md index c339123fcb8..c379ad77378 100644 --- a/.agents/skills/nathan/SKILL.md +++ b/.agents/skills/nathan/SKILL.md @@ -15,8 +15,10 @@ the same commands from the repo via `pnpm nathan`. - **No tunnel setup needed.** Nathan replies asynchronously through a short-lived public tunnel that the script opens for you (`npx localtunnel`). A `deploy` to a **new** `test-` also polls that instance URL directly, so it still reports - success even if the tunnel drops. (Redeploying a name that's *already up* skips - the poll — it can't tell the old instance from the new — so it falls back to the + success even if the tunnel drops — or if the tunnel service (loca.lt) is down + entirely, in which case the deploy proceeds tunnel-less and only the poll + reports completion. (Redeploying a name that's *already up* skips + the poll — it can't tell the old instance from the new — so it needs the tunnel; prefer a fresh name when you need the reliable report.) - **A token in `~/.n8n/dev/nathan-token`.** If a command reports no token, **ask the user for one** — point them at the form diff --git a/scripts/nathan.mjs b/scripts/nathan.mjs index eba857beb5f..ad56c817b47 100644 --- a/scripts/nathan.mjs +++ b/scripts/nathan.mjs @@ -5,6 +5,8 @@ // `response_url` we hand it — the same mechanism Slack uses. Since the internal // instance can't reach localhost, we open a throwaway public tunnel to a local // server, use that as the response_url, print whatever Nathan sends, and clean up. +// If the tunnel service is unreachable, a deploy with an explicit fresh name still +// works: the webhook is sent anyway and the instance URL is polled for completion. // // Usage: node scripts/nathan.mjs (or: pnpm nathan -- ) // node scripts/nathan.mjs help @@ -224,17 +226,30 @@ async function waitReachable(url) { return false; } -let tunnel; +// Reliable backstop: if the instance URL is predictable, poll it directly so a +// dropped tunnel doesn't cost us the result — and if the tunnel service is down +// entirely (loca.lt outages happen), a pollable deploy can proceed without it. +const pollUrl = predictDeployUrl(text); +const up = (u) => fetch(`${u}/healthz`, { signal: AbortSignal.timeout(8000) }).then((r) => r.ok).catch(() => false); + +let tunnel = null; try { console.error(`Opening tunnel + sending: /nathan ${text}`); tunnel = await startTunnel(); + if (!(await waitReachable(tunnel.url))) throw new Error(`${tunnel.url} did not become reachable in time`); } catch (e) { console.error('Could not open a public tunnel:', e.message); - await cleanup(1); -} -if (!(await waitReachable(tunnel.url))) { - console.error(`Tunnel ${tunnel.url} did not become reachable in time.`); - await cleanup(1); + try { tunnel?.proc.kill(); } catch {} + tunnel = null; + if (!pollUrl) { + console.error("This command's result only arrives via the tunnel — cannot continue without one."); + await cleanup(1); + } + if (await up(pollUrl)) { + console.error(`${pollUrl} already responds, so a redeploy can't be confirmed by polling. Retry, or deploy to a fresh test-.`); + await cleanup(1); + } + console.error(`Continuing without the tunnel: Nathan's replies will be lost; polling ${pollUrl} to confirm the deploy instead.\n`); } // --- fire the command -------------------------------------------------------- @@ -248,7 +263,8 @@ try { token, text, command: '/nathan', - response_url: tunnel.url + CALLBACK_PATH, + // .invalid never resolves (RFC 2606) — replies go nowhere in tunnel-less mode + response_url: tunnel ? tunnel.url + CALLBACK_PATH : 'https://nathan-reply.invalid/', user_id: user, user_name: user, channel_id: process.env.NATHAN_SLACK_CHANNEL || DEFAULT_SLACK_CHANNEL, @@ -265,13 +281,11 @@ if (!res.ok) { } console.error('Sent. Waiting for Nathan to reply (Ctrl-C to stop)…\n'); -// Reliable backstop: if the instance URL is predictable, poll it directly so a -// dropped tunnel doesn't cost us the result. Skip if it already responds — that's -// the previous deployment still up mid-redeploy, indistinguishable from the new one. -const pollUrl = predictDeployUrl(text); +// Skip polling if the instance already responds — that's the previous deployment +// still up mid-redeploy, indistinguishable from the new one (tunnel-less mode +// already ruled this out above, before sending the webhook). if (pollUrl) { - const up = (u) => fetch(`${u}/healthz`, { signal: AbortSignal.timeout(8000) }).then((r) => r.ok).catch(() => false); - if (await up(pollUrl)) { + if (tunnel && (await up(pollUrl))) { console.error(`(${pollUrl} already responds — redeploy in progress; relying on the tunnel for completion.)`); } else { (async () => {