feat: Drive codespace agent sessions from n8n via an outbound poll worker (no-changelog) (#36141)

Co-authored-by: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Declan Carroll
2026-08-12 15:42:48 +00:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 870099f6f1
commit 66b3d18d06
8 changed files with 368 additions and 199 deletions
+77 -31
View File
@@ -41,40 +41,62 @@ pnpm session rm # delete the codespace
- First codespace creation takes ~20 min uncached (image + full build). After
that, sessions attach instantly; new worktrees cost a `pnpm install` (~1–2 min).
## Agent broker (call the codespace over HTTP)
## Agent worker (drive a session from n8n)
`agent-broker.mjs` runs one Claude turn per HTTP request, so an external caller
(an n8n workflow, a script) can drive a session on the codespace. Each turn is a
headless `claude -p`. State persists on disk, so `--resume` continues a
conversation across turns and across a stop/start.
`agent-worker.mjs` lets an n8n workflow drive a Claude Code session on the
codespace. This is how Slack (the Flaky bot) steers a session that runs here.
Each turn is one headless `claude -p`. The session state is on disk. So
`--resume` continues a conversation across turns, and across a stop/start.
The broker starts on every container start (`postStartCommand`). It needs the
`AGENT_BROKER_TOKEN` secret and refuses to start without it. Add the token at
[github.com/settings/codespaces](https://github.com/settings/codespaces) the
same way as `ANTHROPIC_API_KEY`. Logs are at `/tmp/agent-broker.log`; the tmux
session is `broker`.
**The worker polls outward. Nothing inbound is exposed.** GitHub sets every
forwarded port to private on start. It gives no API to make a port public. So
you cannot reach a codespace from outside reliably. The worker calls out
instead. It asks n8n for a turn addressed to this box's owner (`$GITHUB_USER`).
It runs the turn. It sends the result to the turn's resume URL. It uses no
tunnel, no open port, and no domain.
The port is private by default. Make it reachable, then health-check it:
The worker starts on each container start (`postStartCommand`). It needs two
secrets. Add them at
[github.com/settings/codespaces](https://github.com/settings/codespaces), the
same way as `ANTHROPIC_API_KEY`:
```bash
gh codespace ports visibility 8787:public -c <codespace-name>
curl https://<codespace-name>-8787.app.github.dev/healthz # {"ok":true}
```
- `AGENT_WORKER_TOKEN` — the shared bearer token. The worker sends it on each poll.
- `N8N_DEQUEUE_URL` — the n8n webhook that returns a pending turn.
A public port has no auth in front of it, so the bearer token is the only
guard. Keep it secret.
The log is at `/tmp/agent-worker.log`. The tmux session is `agent-worker`. To
watch it, run `tmux attach -t agent-worker`. The worker does not start if a
secret or `$GITHUB_USER` is missing.
Send a turn (omit `sessionId` to start one; pass it back to continue):
A turn stops after about 25 minutes (`TURN_TIMEOUT_MS`). This limit is below the
n8n Wait limit. So the worker reports a clear message before n8n reports a
generic timeout. Keep the worker limit below the n8n limit if you change either.
```bash
curl -s https://<codespace-name>-8787.app.github.dev/turns \
-H "Authorization: Bearer $AGENT_BROKER_TOKEN" -H 'content-type: application/json' \
-d '{"message":"what branch is this?"}'
# reply carries .result.session_id — send it back as "sessionId" next turn
```
### Build and run the app in a session
Turns take minutes. Pass a `callbackUrl` to get `202 {turnId}` at once and the
result by POST when the turn ends. See the file header for the full contract.
The prebuild already installed the dependencies and warmed the build. So a
session rarely needs a cold `pnpm install` or a full `pnpm build`. Both are slow
(often 10–20 minutes cold). Both can outlast a turn's limit.
- **Bring the app up with one command: `pnpm dev:up`.** It installs missing
dependencies, starts the backend, waits for health, and prints the URL. Add
`--build` only when a frontend change must appear (see below).
- **Open the app** at `https://<codespace-name>-5678.app.github.dev`. The port
is private. It opens for you in a browser that is signed in to GitHub. You do
not need a tunnel. An anonymous or server caller gets a 302. That is why the
worker polls outward instead.
- **`pnpm dev` no longer exists.** Use `pnpm dev:be` for the backend (on 5678).
Use `pnpm dev:fe:editor` for the editor UI with hot reload (on 8080).
- **`dev:be` serves the editor from the `dist` build.** So a frontend edit does
not hot-reload there. Run `pnpm dev:up --build` (or `pnpm build`) and restart
to show it. For live frontend hot reload, use `pnpm dev:fe:editor`. That path
needs `pnpm session tunnel 5678 8080` from your laptop. Its API base is set to
`localhost:5678`, so the `-8080.app.github.dev` URL does not work on its own.
- Run `pnpm install` only when the dependencies change. `pnpm build` reuses the
turbo cache and is fast when warm.
- To clear stale build outputs after a branch switch, run `pnpm reset`. Add
`--full` if that does not clear it.
- Give a long build its own turn. Do not chain an install and a full build
behind other work in one turn.
## Flaky tools (MCP)
@@ -85,6 +107,24 @@ and repo investigation. Login registers it from the repo-level
it. Tell the agent to call `get_flaky_context` first — it returns the rules
the tools assume.
## Quality skills (Claude plugin)
Claude sessions can also load the private quality skills from the
`n8n-io/n8n-claude-skills` plugin marketplace (bug insights, defect attribution,
mutation testing, and more). `post-start.mjs` installs the `quality` plugin on
each container start, so every session gets the skills with no per-session step.
The private marketplace uses the codespace's own GitHub auth — no extra token.
`devcontainer.json` grants the codespace read access to
`n8n-io/n8n-claude-skills` via `customizations.codespaces.repositories`, and
**each user authorizes that access once when they create the codespace** (GitHub
prompts for it, then remembers). Both repos are in the same org, which is what
lets this work.
If a user does not authorize it, the clone fails and the skills step is skipped
(the worker still starts). Existing codespaces created before this change need a
recreate to get the prompt. The log is at `/tmp/post-start.log`.
## Viewing the dev UI locally
Two terminal windows:
@@ -94,14 +134,20 @@ pnpm session # window 1: attach the agent session
pnpm session tunnel # window 2: forward 5678 + 8080 to localhost
```
Attaching lands you in the agent (Claude), not a shell — open a shell in a
new tmux window with `Ctrl-b c` (or ask the agent) and run `pnpm dev` there.
Then open http://localhost:8080.
Attaching lands you in the agent (Claude), not a shell — open a shell in a new
tmux window with `Ctrl-b c` (or ask the agent), then pick a dev server and open
the matching port:
- `pnpm dev:up` — backend on **5678** (editor served from the `dist` build).
Open http://localhost:5678.
- `pnpm dev:fe:editor` — editor UI with hot reload on **8080**. Open
http://localhost:8080. It needs the backend on 5678 too, so run `pnpm dev:up`
alongside it.
The tunnel prints nothing while forwarding — that's normal. `Connection
refused` means nothing is listening on that port in the codespace yet; it
starts serving as soon as `pnpm dev` is up, no restart needed. Pass ports to
override the defaults (`pnpm session tunnel 5678 8080 5679`), but always
starts serving as soon as the dev server is up, no restart needed. Pass ports
to override the defaults (`pnpm session tunnel 5678 8080 5679`), but always
forward the pair together with matching numbers: the Vite dev UI points its
API base at `localhost:5678` (the `N8N_PORT` default), so an asymmetric or
partial mapping breaks it.
-159
View File
@@ -1,159 +0,0 @@
#!/usr/bin/env node
// HTTP broker for per-turn Claude Code conversations on a codespace.
// Each turn is one `claude -p` exec. State persists on disk via --resume, so the
// broker holds nothing between turns and survives a codespace stop/start.
//
// AGENT_BROKER_TOKEN=… node agent-broker.mjs
//
// GET /healthz no auth
// POST /turns { message, sessionId?, cwd?, callbackUrl? } Bearer auth
// with callbackUrl: reply 202 { turnId }, then POST the result to callbackUrl.
// without callbackUrl: hold the connection, reply with the result. Short turns only.
// GET /turns/<id> Bearer auth. Poll for a held result.
import { execFile } from 'node:child_process';
import { randomUUID, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
import { resolve as resolvePath, sep } from 'node:path';
const PORT = Number(process.env.AGENT_BROKER_PORT ?? 8787);
const TOKEN = process.env.AGENT_BROKER_TOKEN;
// Confine workdirs to ROOT. Override for local tests outside a codespace.
const ROOT = resolvePath(process.env.AGENT_BROKER_ROOT ?? '/workspaces');
const DEFAULT_CWD = process.env.AGENT_BROKER_CWD ?? `${ROOT}/n8n`;
const TURN_TIMEOUT_MS = 15 * 60_000;
const MAX_TURNS_KEPT = 100;
if (!TOKEN) {
console.error('Refusing to start: set AGENT_BROKER_TOKEN (the bearer token n8n will send).');
process.exit(1);
}
const turns = new Map();
// Serialize turns on the same session. Turns on other sessions run in parallel.
const queues = new Map();
function enqueue(key, task) {
const tail = (queues.get(key) ?? Promise.resolve()).then(task, task);
const settled = tail.then(
() => {},
() => {},
);
queues.set(key, settled);
// Drop the key once the chain drains, unless a newer turn already replaced it.
settled.then(() => {
if (queues.get(key) === settled) queues.delete(key);
});
return tail;
}
function runClaude({ message, sessionId, cwd }) {
const args = ['-p', '--output-format', 'json', '--dangerously-skip-permissions'];
if (sessionId) args.push('--resume', sessionId);
args.push(message);
return new Promise((resolve, reject) => {
execFile(
'claude',
args,
{ cwd, timeout: TURN_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 },
(err, stdout, stderr) => {
try {
resolve(JSON.parse(stdout));
} catch {
reject(new Error(stderr?.trim() || err?.message || 'claude produced no output'));
}
},
);
});
}
async function executeTurn(id, { message, sessionId, cwd, callbackUrl }) {
const turn = turns.get(id);
turn.status = 'running';
try {
const result = await enqueue(sessionId ?? id, () => runClaude({ message, sessionId, cwd }));
Object.assign(turn, { status: 'done', result });
} catch (error) {
Object.assign(turn, { status: 'error', error: error.message });
}
if (turns.size > MAX_TURNS_KEPT) {
for (const key of turns.keys()) {
if (turns.size <= MAX_TURNS_KEPT) break;
if (turns.get(key).status !== 'running') turns.delete(key);
}
}
if (callbackUrl) {
try {
await fetch(callbackUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ turnId: id, ...turn }),
});
} catch (error) {
// The result is still available from GET /turns/<id>.
console.error(`turn ${id}: callback to ${callbackUrl} failed: ${error.message}`);
}
}
}
function authorized(req) {
const sent = req.headers.authorization?.replace(/^Bearer /, '') ?? '';
const a = Buffer.from(sent);
const b = Buffer.from(TOKEN);
return a.length === b.length && timingSafeEqual(a, b);
}
function json(res, status, body) {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
}
createServer(async (req, res) => {
try {
const { pathname } = new URL(req.url, 'http://localhost');
if (req.method === 'GET' && pathname === '/healthz') return json(res, 200, { ok: true });
if (!authorized(req)) return json(res, 401, { error: 'unauthorized' });
const turnMatch = pathname.match(/^\/turns\/([\w-]+)$/);
if (req.method === 'GET' && turnMatch) {
const turn = turns.get(turnMatch[1]);
return turn ? json(res, 200, turn) : json(res, 404, { error: 'unknown turn' });
}
if (req.method === 'POST' && pathname === '/turns') {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
let body;
try {
body = JSON.parse(Buffer.concat(chunks).toString());
} catch {
return json(res, 400, { error: 'body must be JSON' });
}
if (body === null || typeof body !== 'object' || Array.isArray(body))
return json(res, 400, { error: 'body must be a JSON object' });
const { message, sessionId, callbackUrl } = body;
if (typeof message !== 'string' || !message.trim())
return json(res, 400, { error: 'message (string) is required' });
for (const [k, v] of Object.entries({ sessionId, callbackUrl, cwd: body.cwd }))
if (v !== undefined && typeof v !== 'string')
return json(res, 400, { error: `${k} must be a string` });
// Resolve before the prefix check so `..` segments can't escape ROOT.
const cwd = resolvePath(typeof body.cwd === 'string' ? body.cwd : DEFAULT_CWD);
if (cwd !== ROOT && !cwd.startsWith(ROOT + sep))
return json(res, 400, { error: `cwd must be under ${ROOT}` });
const id = randomUUID();
// Hold the turn object across the await: eviction may delete the map entry,
// but this reference still reflects the status executeTurn assigns to it.
const turn = { status: 'queued' };
turns.set(id, turn);
const running = executeTurn(id, { message, sessionId, cwd, callbackUrl });
if (callbackUrl) return json(res, 202, { turnId: id });
await running;
return json(res, turn.status === 'done' ? 200 : 500, { turnId: id, ...turn });
}
json(res, 404, { error: 'not found' });
} catch (error) {
if (!res.headersSent) json(res, 500, { error: error.message });
}
}).listen(PORT, () => console.log(`agent-broker listening on :${PORT}`));
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env node
// Poll worker for per-turn Claude Code conversations on a codespace.
// You cannot reach a codespace from outside. GitHub keeps forwarded ports
// private. So this worker calls out. It polls n8n for a turn addressed to this
// box's owner. It runs one `claude -p`. It sends the result to the turn's resume
// URL. Every call is outbound HTTPS to n8n. It opens no inbound port.
//
// AGENT_WORKER_TOKEN=… N8N_DEQUEUE_URL=… node agent-worker.mjs
//
// Env:
// N8N_DEQUEUE_URL n8n webhook that hands back one pending turn (required)
// AGENT_WORKER_TOKEN shared bearer sent on every dequeue (required)
// GITHUB_USER box owner's login; turns are addressed to it (codespaces set this)
// AGENT_WORKER_ROOT confine turn cwd under here (default /workspaces)
// POLL_INTERVAL_MS delay between empty polls (default 3000)
import { execFile } from 'node:child_process';
import { resolve as resolvePath, sep } from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
const DEQUEUE_URL = process.env.N8N_DEQUEUE_URL;
const TOKEN = process.env.AGENT_WORKER_TOKEN;
const GITHUB_USER = process.env.GITHUB_USER;
const ROOT = resolvePath(process.env.AGENT_WORKER_ROOT ?? '/workspaces');
// Read a positive-number env var. Fall back to the default (and warn) on a bad
// value, so a config mistake cannot change the poll rate or the turn limit.
function posNum(name, fallback) {
const raw = process.env[name];
if (raw === undefined) return fallback;
const n = Number(raw);
if (Number.isFinite(n) && n > 0) return n;
console.error(`${name} is not a positive number ("${raw}"); using ${fallback}.`);
return fallback;
}
const POLL_INTERVAL_MS = posNum('POLL_INTERVAL_MS', 3000);
// Keep this below the n8n Wait-node limit. Then the worker reports a slow turn
// before n8n's Wait ends with a generic message.
const TURN_TIMEOUT_MS = posNum('TURN_TIMEOUT_MS', 25 * 60_000);
for (const [k, v] of Object.entries({
N8N_DEQUEUE_URL: DEQUEUE_URL,
AGENT_WORKER_TOKEN: TOKEN,
GITHUB_USER,
})) {
if (!v) {
console.error(`Refusing to start: ${k} is not set.`);
process.exit(1);
}
}
function runClaude({ message, sessionId, cwd }) {
const safeCwd = resolvePath(typeof cwd === 'string' && cwd ? cwd : `${ROOT}/n8n`);
if (safeCwd !== ROOT && !safeCwd.startsWith(ROOT + sep))
throw new Error(`cwd must be under ${ROOT}`);
const args = ['-p', '--output-format', 'json', '--dangerously-skip-permissions'];
if (sessionId) args.push('--resume', sessionId);
args.push(message);
return new Promise((res, rej) => {
execFile(
'claude',
args,
{ cwd: safeCwd, timeout: TURN_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 },
(err, stdout, stderr) => {
try {
res(JSON.parse(stdout));
} catch {
if (err?.killed)
rej(
new Error(
`The turn passed the ${Math.round(TURN_TIMEOUT_MS / 60_000)}-minute limit and stopped. It may have been in a build. Do a smaller step, or run a long build in its own turn.`,
),
);
else rej(new Error(stderr?.trim() || err?.message || 'claude produced no output'));
}
},
);
});
}
async function post(url, body) {
return fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(20_000),
});
}
async function dequeue() {
const res = await post(DEQUEUE_URL, { githubUser: GITHUB_USER, token: TOKEN });
if (!res.ok) throw new Error(`dequeue HTTP ${res.status}`);
const text = await res.text();
if (!text.trim()) return null; // no pending turn
const turn = JSON.parse(text);
return turn?.turnId ? turn : null;
}
async function handle(turn) {
let result;
try {
const r = await runClaude(turn);
result = {
turnId: turn.turnId,
status: 'done',
output: r.result ?? '',
sessionId: r.session_id ?? turn.sessionId ?? '',
};
} catch (error) {
result = {
turnId: turn.turnId,
status: 'error',
output: error.message,
sessionId: turn.sessionId ?? '',
};
}
// Send the result to the turn's resume URL. This continues the waiting n8n
// execution. Retry on a failed status or a network error, so a transient
// failure does not drop the result and leave the turn to time out.
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const res = await post(turn.resumeUrl, result);
if (res.ok) return;
console.error(`turn ${turn.turnId}: result POST got HTTP ${res.status} (attempt ${attempt})`);
} catch (error) {
console.error(
`turn ${turn.turnId}: result POST failed (attempt ${attempt}): ${error.message}`,
);
}
await sleep(2000 * attempt);
}
console.error(`turn ${turn.turnId}: result not delivered after 3 attempts`);
}
console.log(`agent-worker polling as ${GITHUB_USER} every ${POLL_INTERVAL_MS}ms`);
for (;;) {
try {
const turn = await dequeue();
if (turn) {
console.log(`turn ${turn.turnId}: ${turn.sessionId ? 'resume' : 'new'}`);
await handle(turn);
continue; // get the next turn now, with no delay
}
} catch (error) {
console.error(`poll error: ${error.message}`);
}
await sleep(POLL_INTERVAL_MS);
}
+19 -8
View File
@@ -17,21 +17,32 @@
// needs `privileged: true` on the n8n service (compose-based config)
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
},
"forwardPorts": [8080, 5678, 8787],
"portsAttributes": {
"8787": { "label": "agent-broker" }
"forwardPorts": [8080, 5678],
// Grant the codespace's own GitHub token read access to the private skills
// marketplace (same org), so Claude can install it with the codespace's normal
// git auth — no extra PAT. Each user authorizes this once at codespace creation.
"customizations": {
"codespaces": {
"repositories": {
"n8n-io/n8n-claude-skills": { "permissions": { "contents": "read" } }
}
}
},
// Runs in prebuilds, so a fresh codespace starts with deps installed and the build warm
"onCreateCommand": "corepack install && pnpm install && (pnpm build > /tmp/build.log 2>&1 || tail -n 50 /tmp/build.log) && pnpm --filter=n8n-playwright exec playwright install chromium",
// Start the broker on each container start. `bash -lc` loads the secrets shim, so the
// broker gets AGENT_BROKER_TOKEN. tmux detaches it, so it does not block startup.
"postStartCommand": "tmux new-session -d -s broker 'bash -lc \"node /workspaces/n8n/.devcontainer/codespaces/agent-broker.mjs >> /tmp/agent-broker.log 2>&1\"' || true",
// On each container start: install the private skills marketplace into Claude
// (best-effort) and start the poll worker. `bash -lc` loads the secrets shim,
// so the worker gets its token. Logs: /tmp/post-start.log, /tmp/agent-worker.log.
"postStartCommand": "bash -lc 'node /workspaces/n8n/.devcontainer/codespaces/post-start.mjs >> /tmp/post-start.log 2>&1' || true",
"secrets": {
"ANTHROPIC_API_KEY": {
"description": "Anthropic API key for Claude Code / OpenCode sessions"
},
"AGENT_BROKER_TOKEN": {
"description": "Bearer token the agent broker requires on every request (see .devcontainer/codespaces/agent-broker.mjs)"
"AGENT_WORKER_TOKEN": {
"description": "The shared bearer token. The poll worker sends it to n8n on each dequeue. See .devcontainer/codespaces/agent-worker.mjs."
},
"N8N_DEQUEUE_URL": {
"description": "n8n webhook the poll worker calls to claim a pending turn"
},
"CLAUDE_CODE_OAUTH_TOKEN": {
"description": "Alternative to an API key: token from `claude setup-token` (Max subscription)"
+1 -1
View File
@@ -26,5 +26,5 @@ services:
DB_POSTGRESDB_HOST: postgres
DB_TYPE: postgresdb
DB_POSTGRESDB_PASSWORD: password
# Prompt-cache TTL in seconds. 1 hour keeps the cache warm between broker turns.
# Prompt-cache TTL in seconds. 1 hour keeps the cache warm between turns.
ANTHROPIC_PROMPT_CACHE_TTL: "3600"
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env node
// Runs on each codespace start. It installs the private skills marketplace into
// Claude Code (best-effort), then starts the agent worker.
//
// The codespace's own GitHub token is granted read access to the marketplace
// repo via customizations.codespaces.repositories in devcontainer.json (each
// user authorizes it once at codespace creation). So git — and Claude Code's
// clone — read it with the codespace's normal auth, no extra token. If a user
// did not authorize it, the clone fails and the skills step is skipped. The
// worker always starts.
import { execFileSync } from 'node:child_process';
const MARKETPLACE = 'n8n-io/n8n-claude-skills';
const PLUGIN = 'quality@n8n-claude-skills';
function tryRun(label, cmd, args) {
try {
execFileSync(cmd, args, { stdio: 'inherit' });
return true;
} catch (error) {
console.error(`${label}: ${error.message}`);
return false;
}
}
if (tryRun('marketplace add', 'claude', ['plugin', 'marketplace', 'add', MARKETPLACE]))
tryRun('plugin install', 'claude', ['plugin', 'install', PLUGIN]);
// Start the poll worker, detached, so it survives this script exiting.
tryRun('worker start', 'tmux', [
'new-session',
'-d',
'-s',
'agent-worker',
'bash -lc "node /workspaces/n8n/.devcontainer/codespaces/agent-worker.mjs >> /tmp/agent-worker.log 2>&1"',
]);
+1
View File
@@ -22,6 +22,7 @@
"build:docker:test": "node scripts/build-n8n.mjs && node scripts/dockerize-n8n.mjs && turbo run test:container:standard --filter=n8n-playwright",
"typecheck": "turbo typecheck",
"dev": "node scripts/dev-command-removed-notice.mjs",
"dev:up": "node scripts/dev-up.mjs",
"dev:be": "turbo run dev --env-mode=loose --log-prefix=none --filter=n8n",
"dev:ai": "turbo run dev --concurrency=150 --env-mode=loose --filter=@n8n/n8n-nodes-langchain --filter=n8n --filter=n8n-core",
"dev:fe": "run-p start dev:fe:editor",
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env node
// Bring the app up with one command. This script installs missing dependencies,
// builds if you ask, starts the backend, waits for health, and prints the URL.
//
// pnpm dev:up install if needed, start dev:be, print the URL
// pnpm dev:up --build also run `pnpm build` first
//
// dev:be serves the editor from the `dist` build. A frontend change appears only
// after `--build` and this restart. For live frontend hot reload, use
// `pnpm dev:fe:editor` on 8080. That path needs `pnpm session tunnel 5678 8080`.
import { execFileSync, spawn } from 'node:child_process';
import { existsSync, openSync } from 'node:fs';
import { setTimeout as sleep } from 'node:timers/promises';
const build = process.argv.includes('--build');
const port = process.env.N8N_PORT ?? '5678';
// Probe the same health path the backend serves (defaults to /healthz).
const healthPath = process.env.N8N_ENDPOINT_HEALTH
? `/${process.env.N8N_ENDPOINT_HEALTH.replace(/^\//, '')}`
: '/healthz';
const LOG = '/tmp/n8n-dev-be.log';
const BUILD_LOG = '/tmp/dev-up-build.log';
if (!existsSync('node_modules') || !existsSync('packages/cli/node_modules')) {
console.log('Installing dependencies…');
execFileSync('pnpm', ['install'], { stdio: 'inherit' });
}
if (build) {
console.log(`Building (turbo cache — fast when warm; log: ${BUILD_LOG})…`);
const fd = openSync(BUILD_LOG, 'w');
try {
execFileSync('pnpm', ['build'], { stdio: ['ignore', fd, fd] });
} catch {
console.error(`Build failed — see ${BUILD_LOG}:`);
execFileSync('tail', ['-n', '30', BUILD_LOG], { stdio: 'inherit' });
process.exit(1);
}
}
console.log(`Starting backend (pnpm dev:be; log: ${LOG})…`);
const fd = openSync(LOG, 'a');
spawn('pnpm', ['dev:be'], { detached: true, stdio: ['ignore', fd, fd] }).unref();
const deadline = Date.now() + 120_000;
let healthy = false;
while (Date.now() < deadline) {
try {
const res = await fetch(`http://127.0.0.1:${port}${healthPath}`, {
signal: AbortSignal.timeout(5000),
});
if (res.ok) {
healthy = true;
break;
}
} catch {}
await sleep(3000);
}
const name = process.env.CODESPACE_NAME;
const url = name ? `https://${name}-${port}.app.github.dev` : `http://localhost:${port}`;
if (!healthy) {
console.error(`\nBackend did not answer ${healthPath} within 2 min — check ${LOG}`);
process.exit(1);
}
// In a codespace, share the port with the org so any n8n member can open the
// URL. Visibility resets to private on each start, so re-apply it here. gh needs
// the codespace scope; skip with a note if the call fails, never block the run.
let orgShared = false;
if (name) {
try {
execFileSync('gh', ['codespace', 'ports', 'visibility', `${port}:org`, '-c', name], {
stdio: 'ignore',
});
orgShared = true;
} catch {}
}
console.log(`\nUp: ${url}`);
if (name)
console.log(
orgShared
? '(org-visible — any n8n member signed into GitHub can open it)'
: `(still private — run \`gh codespace ports visibility ${port}:org -c $CODESPACE_NAME\` to share with the org)`,
);