From 66b3d18d066331fa506a5c3429fedead9744e4a5 Mon Sep 17 00:00:00 2001 From: Declan Carroll Date: Wed, 12 Aug 2026 16:42:48 +0100 Subject: [PATCH] feat: Drive codespace agent sessions from n8n via an outbound poll worker (no-changelog) (#36141) Co-authored-by: Claude Opus 4.8 --- .devcontainer/codespaces/README.md | 108 +++++++++---- .devcontainer/codespaces/agent-broker.mjs | 159 -------------------- .devcontainer/codespaces/agent-worker.mjs | 148 ++++++++++++++++++ .devcontainer/codespaces/devcontainer.json | 27 +++- .devcontainer/codespaces/docker-compose.yml | 2 +- .devcontainer/codespaces/post-start.mjs | 36 +++++ package.json | 1 + scripts/dev-up.mjs | 86 +++++++++++ 8 files changed, 368 insertions(+), 199 deletions(-) delete mode 100644 .devcontainer/codespaces/agent-broker.mjs create mode 100644 .devcontainer/codespaces/agent-worker.mjs create mode 100644 .devcontainer/codespaces/post-start.mjs create mode 100644 scripts/dev-up.mjs diff --git a/.devcontainer/codespaces/README.md b/.devcontainer/codespaces/README.md index ba9457b2b15..a49ba7fd5eb 100644 --- a/.devcontainer/codespaces/README.md +++ b/.devcontainer/codespaces/README.md @@ -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 -curl https://-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://-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://-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. diff --git a/.devcontainer/codespaces/agent-broker.mjs b/.devcontainer/codespaces/agent-broker.mjs deleted file mode 100644 index f479008cc32..00000000000 --- a/.devcontainer/codespaces/agent-broker.mjs +++ /dev/null @@ -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/ 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/. - 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}`)); diff --git a/.devcontainer/codespaces/agent-worker.mjs b/.devcontainer/codespaces/agent-worker.mjs new file mode 100644 index 00000000000..30713714ae0 --- /dev/null +++ b/.devcontainer/codespaces/agent-worker.mjs @@ -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); +} diff --git a/.devcontainer/codespaces/devcontainer.json b/.devcontainer/codespaces/devcontainer.json index f5d73c2ce57..d2d2d587020 100644 --- a/.devcontainer/codespaces/devcontainer.json +++ b/.devcontainer/codespaces/devcontainer.json @@ -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)" diff --git a/.devcontainer/codespaces/docker-compose.yml b/.devcontainer/codespaces/docker-compose.yml index 2c830fe7390..8a792dcf35b 100644 --- a/.devcontainer/codespaces/docker-compose.yml +++ b/.devcontainer/codespaces/docker-compose.yml @@ -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" diff --git a/.devcontainer/codespaces/post-start.mjs b/.devcontainer/codespaces/post-start.mjs new file mode 100644 index 00000000000..fdb1d09c6ac --- /dev/null +++ b/.devcontainer/codespaces/post-start.mjs @@ -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"', +]); diff --git a/package.json b/package.json index 2cb397556db..d5a35599e7e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/dev-up.mjs b/scripts/dev-up.mjs new file mode 100644 index 00000000000..cdd97f42d34 --- /dev/null +++ b/scripts/dev-up.mjs @@ -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)`, + );