diff --git a/.devcontainer/codespaces/README.md b/.devcontainer/codespaces/README.md index 564d16704c4..91a51a2b5be 100644 --- a/.devcontainer/codespaces/README.md +++ b/.devcontainer/codespaces/README.md @@ -72,8 +72,8 @@ OpenCode server, repository, tools, and builds run in the Codespace. Browser mode opens the remote web interface through a local connection. ```bash -pnpm session:opencode fix-flaky # resume the saved conversation -pnpm session:opencode fix-flaky --web # open that conversation in a browser +pnpm session:opencode fix-flaky # resume that workspace's latest conversation +pnpm session:opencode fix-flaky --web # open it in a browser pnpm session:opencode fix-flaky --new # start a new conversation in that worktree pnpm session:opencode --web --port 4100 # override the default browser port pnpm session:opencode --help @@ -98,11 +98,12 @@ post-start command installs. The local harness setup above is not required for these commands. The command prepares the worktree, starts or reuses one server, opens an SSH -tunnel, and connects the client. Each workspace name has a saved conversation -ID on the Codespace. TUI and browser modes open the same saved conversation. -`--new` replaces that saved ID. It preserves the worktree and the old conversation. -If you switch conversations inside a client, the next launcher run still opens -the ID saved for that workspace name. +tunnel, and connects the client. Each workspace opens its own conversation: the +most recently updated one in that worktree. A conversation moves to the front +when it receives a message. Switching to a conversation without sending a +message does not move it. Switching to another workspace's conversation does +not change what this workspace opens next. `--new` starts a new conversation +instead. It preserves the worktree and the old conversation. - **Exit the TUI** with `/exit` or its quit shortcut. The launcher closes its tunnel. The remote server stays running. @@ -110,7 +111,8 @@ the ID saved for that workspace name. the browser tab does not close the tunnel. Keep the launcher running while you use the browser. - **Reconnect** with the same command after a network interruption. After a - Codespace stop, the command restarts the server and opens the saved conversation. + Codespace stop, the command restarts the server and opens the workspace's + most recently updated conversation. A stop terminates running tools. It does not resume interrupted commands. - **Browser mode uses local port 4096 by default.** Use `--port` to override it. TUI mode selects an available port unless you specify one. @@ -129,14 +131,14 @@ your laptop can access the local browser proxy while it runs. Do not forward this proxy or the OpenCode server port to other machines. The server enables only OpenRouter. It reads `OPENROUTER_API_KEY` when it starts. -Browser mode opens the selected conversation directly. +Browser mode opens the workspace's most recently updated conversation directly. The web UI stores opened projects in browser storage. If a new-session page shows **New project**, open `/workspaces/n8n` there once. Keep the same browser port when you reconnect to preserve this selection. The server runs in the detached tmux session `n8n-opencode-server`. Its log is `/workspaces/.n8n-opencode/server.log`. That directory also holds the server -credentials and saved conversation IDs. It is readable only by its owner. +credentials. It is readable only by its owner. An unhealthy server produces an error without stopping active work. Inspect the log through `pnpm session:shell`. To restart it after checking active work, run `tmux kill-session -t '=n8n-opencode-server'` in that shell. Then reconnect. diff --git a/.devcontainer/codespaces/opencode-client.test.mjs b/.devcontainer/codespaces/opencode-client.test.mjs index 804b1777cc4..be700f17411 100644 --- a/.devcontainer/codespaces/opencode-client.test.mjs +++ b/.devcontainer/codespaces/opencode-client.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; -import { rmSync } from 'node:fs'; +import { rmSync, readFileSync } from 'node:fs'; import { test } from 'node:test'; import { setTimeout as delay } from 'node:timers/promises'; import { fileURLToPath } from 'node:url'; @@ -43,8 +43,12 @@ if (args[0] === 'codespace' && args[1] === 'list') { process.stdin.on('end', () => { log({ event: 'bootstrap', hasSource: source.includes('prepareOpenCode') }); if (process.env.TEST_BOOTSTRAP_FAIL) process.exit(1); - const name = args.at(-1).split(' ').at(-2); - console.log(JSON.stringify({ port: 4242, password: '${secret}', sessionID: 'ses_saved', + const parts = args.at(-1).split(' '); + const name = parts.at(-3); + const fresh = parts.at(-2) === 'true'; + const web = parts.at(-1) === 'true'; + console.log(JSON.stringify({ port: 4242, password: '${secret}', + ...(fresh && !web ? {} : { sessionID: 'ses_saved' }), directory: name === 'agent' ? '/workspaces/n8n' : '/workspaces/wt-' + name })); }); } @@ -93,6 +97,22 @@ async function waitFor(check) { throw new Error('Fixture did not become ready.'); } +// An orphaned fixture process can linger as a zombie where PID 1 does not reap +// it, and kill(pid, 0) still succeeds for one. Read the process state where the +// kernel exposes it; without /proc only the signal error can tell. +function stopped(pid) { + try { + process.kill(pid, 0); + } catch { + return true; + } + try { + return readFileSync(`/proc/${pid}/stat`, 'utf8').split(' ')[2] === 'Z'; + } catch { + return false; + } +} + test('parses options before or after the workspace and rejects unsupported flags', () => { assert.equal(parseOpenCodeArgs(['--web']).port, 4096); assert.equal(parseOpenCodeArgs([]).port, 0); @@ -142,20 +162,21 @@ test( assert.ok(!result.output.includes(secret)); assert.ok(calls.some((call) => call.event === 'tunnel-stopped')); const pid = calls.find((call) => call.event === 'ssh-child').pid; - await waitFor(() => { - try { - process.kill(pid, 0); - return false; - } catch { - return true; - } - }); + await waitFor(() => stopped(pid)); }, ); +test('starts a new conversation for --new instead of continuing', { timeout: 15000 }, async (t) => { + const f = fixture(t); + const result = await f.start(['fix-flaky', '--new']).done; + assert.equal(result.code, 0, result.output); + const client = f.calls().find((call) => call.command === 'opencode'); + assert.deepEqual(client.args.slice(2), ['--dir', '/workspaces/wt-fix-flaky']); +}); + for (const signal of ['SIGINT', 'SIGHUP']) { test( - `opens the saved web conversation and cleans up on ${signal}`, + `opens the newest web conversation and cleans up on ${signal}`, { timeout: 15000 }, async (t) => { const f = fixture(t, { TEST_NO_CLIENT: '1' }); diff --git a/.devcontainer/codespaces/opencode-server.mjs b/.devcontainer/codespaces/opencode-server.mjs index 41027490368..f89cf11b33e 100644 --- a/.devcontainer/codespaces/opencode-server.mjs +++ b/.devcontainer/codespaces/opencode-server.mjs @@ -162,35 +162,35 @@ function prepareWorkspace({ name, directory, mainDirectory, stateDir }) { } } -async function ensureSession({ name, fresh, directory, stateDir, server }) { - const sessionFile = join(stateDir, `${name}.session.json`); - const saved = fresh ? undefined : readJson(sessionFile); - let session; - if (saved) { - const response = await request(server, `/session/${encodeURIComponent(saved.id)}`, directory); - if (response.ok) session = await response.json(); - else if (response.status !== 404) - throw new Error(`Cannot resume OpenCode session (${response.status}).`); - if (session && session.directory !== directory) - throw new Error('The saved OpenCode session belongs to another directory. Use --new.'); - } - if (!session) { - const response = await request(server, '/session', directory, { - method: 'POST', - body: JSON.stringify({ title: `n8n: ${name}` }), - }); - if (!response.ok) throw new Error(`Cannot create OpenCode session (${response.status}).`); - session = await response.json(); - if (typeof session.id !== 'string' || session.directory !== directory) - throw new Error('OpenCode returned an invalid session.'); - saveJson(sessionFile, { id: session.id }); +// OpenCode's own resume is scoped to the repository project, so all worktrees +// share one pool. Scope the selection to the workspace directory instead, so +// each worktree resumes its own most recently updated root conversation. +async function resolveSession({ name, fresh, directory, server }) { + if (!fresh) { + const query = new URLSearchParams({ directory, roots: 'true' }); + const response = await request(server, `/session?${query}`, directory); + if (!response.ok) throw new Error(`Cannot list OpenCode sessions (${response.status}).`); + const sessions = await response.json(); + const latest = Array.isArray(sessions) + ? sessions.find((entry) => typeof entry?.id === 'string' && entry.id.length > 0) + : undefined; + if (latest) return latest.id; } + const response = await request(server, '/session', directory, { + method: 'POST', + body: JSON.stringify({ title: `n8n: ${name}` }), + }); + if (!response.ok) throw new Error(`Cannot create OpenCode session (${response.status}).`); + const session = await response.json(); + if (typeof session.id !== 'string' || session.directory !== directory) + throw new Error('OpenCode returned an invalid session.'); return session.id; } export async function prepareOpenCode({ name = 'agent', fresh = false, + web = false, workspaces = '/workspaces', } = {}) { const stateDir = join(workspaces, '.n8n-opencode'); @@ -203,15 +203,19 @@ export async function prepareOpenCode({ if (!server) execFileSync('opencode', ['--version'], { stdio: ['ignore', 'pipe', 'inherit'] }); prepareWorkspace({ name, directory, mainDirectory, stateDir }); server ??= await startServer({ stateDir, mainDirectory, workspaces }); - const sessionID = await ensureSession({ name, fresh, directory, stateDir, server }); + const state = { ...server, directory }; + // A fresh TUI conversation needs no record. The TUI creates it on the first message. + if (fresh && !web) return state; // Only the parent process reads stdout. Never send this record to terminal output. - return { ...server, directory, sessionID }; + return { ...state, sessionID: await resolveSession({ name, fresh, directory, server }) }; } if (process.argv[1] === '-') { try { - const [name, fresh] = process.argv.slice(2); - console.log(JSON.stringify(await prepareOpenCode({ name, fresh: fresh === 'true' }))); + const [name, fresh, web] = process.argv.slice(2); + console.log( + JSON.stringify(await prepareOpenCode({ name, fresh: fresh === 'true', web: web === 'true' })), + ); } catch (error) { console.error(error.message); process.exitCode = 1; diff --git a/.devcontainer/codespaces/opencode-server.test.mjs b/.devcontainer/codespaces/opencode-server.test.mjs index bdc1065d58e..d1ea4df283b 100644 --- a/.devcontainer/codespaces/opencode-server.test.mjs +++ b/.devcontainer/codespaces/opencode-server.test.mjs @@ -7,6 +7,22 @@ import { setTimeout as delay } from 'node:timers/promises'; import { fakeBinaries } from './fake-bin.mjs'; import { prepareOpenCode } from './opencode-server.mjs'; +// An orphaned fixture process can linger as a zombie where PID 1 does not reap +// it, and kill(pid, 0) still succeeds for one. Read the process state where the +// kernel exposes it; without /proc only the signal error can tell. +function stopped(pid) { + try { + process.kill(pid, 0); + } catch { + return true; + } + try { + return readFileSync(`/proc/${pid}/stat`, 'utf8').split(' ')[2] === 'Z'; + } catch { + return false; + } +} + function fixture(t) { const fake = fakeBinaries('opencode-server-'); const dir = fake.root; @@ -38,7 +54,13 @@ if (process.env.TEST_INSTALL_FAIL) process.exit(1); 'tmux', ` if (args[0] === 'has-session') { - try { process.kill(+fs.readFileSync(file('pid'), 'utf8'), 0); } catch { process.exit(1); } + const pid = +fs.readFileSync(file('pid'), 'utf8'); + let gone = false; + try { process.kill(pid, 0); } catch { gone = true; } + if (!gone) { + try { gone = fs.readFileSync('/proc/' + pid + '/stat', 'utf8').split(' ')[2] === 'Z'; } catch {} + } + if (gone) process.exit(1); } else { const child = require('node:child_process').spawn('bash', ['-c', args.at(-1)], { detached:true, stdio:'ignore' }); fs.writeFileSync(file('pid'), String(child.pid)); child.unref(); @@ -59,15 +81,35 @@ const server = require('node:http').createServer(async (req, res) => { res.setHeader('content-type', 'application/json'); const expected = 'Basic ' + Buffer.from('opencode:' + process.env.OPENCODE_SERVER_PASSWORD).toString('base64'); if (req.headers.authorization !== expected) { res.writeHead(401).end('{}'); return; } + const directory = decodeURIComponent(req.headers['x-opencode-directory']); if (req.url === '/global/health') { res.end(JSON.stringify({ healthy:true, version:'1.2.3' })); return; } if (req.method === 'POST' && req.url === '/session') { - for await (const chunk of req) {} + if (fs.existsSync(file('reject-create'))) { res.writeHead(503).end('{}'); return; } + let body = ''; + for await (const chunk of req) body += chunk; const id = 'ses_' + (Object.keys(sessions).length + 1); - sessions[id] = { id, directory:decodeURIComponent(req.headers['x-opencode-directory']) }; + sessions[id] = { id, directory, title: JSON.parse(body).title }; fs.writeFileSync(file('sessions.json'), JSON.stringify(sessions)); res.end(JSON.stringify(sessions[id])); return; } if (fs.existsSync(file('reject-session'))) { res.writeHead(503).end('{}'); return; } + const url = new URL(req.url, 'http://localhost'); + if (url.pathname === '/session') { + // Newest first, like the real server. Filter on the query, not the header, + // so the test fails when the launcher omits either parameter. The + // child-session file models a subagent conversation that the roots query + // flag must exclude. + const directory = url.searchParams.get('directory'); + const roots = url.searchParams.get('roots') === 'true'; + const stored = Object.values(sessions); + if (fs.existsSync(file('child-session'))) + stored.push({ id: 'ses_child', directory, title: 'child', parentID: 'ses_1' }); + const list = stored + .filter(s => s.directory === directory && (!roots || !s.parentID)) + .reverse(); + if (fs.existsSync(file('extra-session'))) list.unshift({ id: 'ses_extra', directory, title: 'TUI session' }); + res.end(JSON.stringify(list)); return; + } const session = sessions[req.url.split('/').at(-1)]; if (!session) { res.writeHead(404).end('{}'); return; } res.end(JSON.stringify(session)); @@ -80,11 +122,7 @@ server.listen(+args[args.indexOf('--port') + 1], '127.0.0.1'); const pid = +readFileSync(join(dir, 'pid'), 'utf8'); process.kill(-pid, 'SIGTERM'); for (let count = 0; count < 100; count++) { - try { - process.kill(pid, 0); - } catch { - return; - } + if (stopped(pid)) return; await delay(20); } throw new Error('Fixture server did not stop.'); @@ -107,19 +145,33 @@ server.listen(+args[args.indexOf('--port') + 1], '127.0.0.1'); } test( - 'reuses the server and saved conversation, creates separate workspaces, and resumes after restart', + 'reuses the server, serves each workspace its newest conversation, and resumes after restart', { timeout: 15000 }, async (t) => { const f = fixture(t); const first = await f.prepare({ name: 'fix-flaky' }); assert.equal(first.directory, join(f.dir, 'wt-fix-flaky')); + assert.equal(first.sessionID, 'ses_1'); assert.deepEqual(await f.prepare({ name: 'fix-flaky' }), first); const second = await f.prepare({ name: 'another-task' }); assert.equal(second.port, first.port); - assert.notEqual(second.sessionID, first.sessionID); - const fresh = await f.prepare({ name: 'fix-flaky', fresh: true }); - assert.notEqual(fresh.sessionID, first.sessionID); - assert.equal((await f.prepare({ name: 'fix-flaky' })).sessionID, fresh.sessionID); + assert.equal(second.sessionID, 'ses_2'); + const webbed = await f.prepare({ name: 'fix-flaky', web: true }); + assert.equal(webbed.sessionID, 'ses_1'); + const created = JSON.parse(readFileSync(join(f.dir, 'sessions.json'), 'utf8')); + assert.equal(created.ses_1.title, 'n8n: fix-flaky'); + assert.equal(created.ses_1.directory, join(f.dir, 'wt-fix-flaky')); + assert.equal((await f.prepare({ name: 'fix-flaky', web: true })).sessionID, 'ses_1'); + assert.equal((await f.prepare({ name: 'another-task', web: true })).sessionID, 'ses_2'); + writeFileSync(join(f.dir, 'extra-session'), ''); + assert.equal((await f.prepare({ name: 'fix-flaky', web: true })).sessionID, 'ses_extra'); + rmSync(join(f.dir, 'extra-session')); + writeFileSync(join(f.dir, 'child-session'), ''); + assert.equal((await f.prepare({ name: 'fix-flaky', web: true })).sessionID, 'ses_1'); + rmSync(join(f.dir, 'child-session')); + const fresh = await f.prepare({ name: 'fix-flaky', web: true, fresh: true }); + assert.notEqual(fresh.sessionID, 'ses_1'); + assert.equal((await f.prepare({ name: 'fix-flaky', web: true })).sessionID, fresh.sessionID); const env = JSON.parse(readFileSync(join(f.dir, 'server-env.json'), 'utf8')); assert.deepEqual([env.worker, env.queue, env.slack], [false, false, false]); assert.equal(env.cache, join(f.dir, '.turbo-cache')); @@ -127,18 +179,18 @@ test( assert.deepEqual(env.config.enabled_providers, ['openrouter']); assert.deepEqual([env.runtime, env.profile], ['sandbox', null]); assert.equal(statSync(join(f.dir, '.n8n-opencode')).mode & 0o777, 0o700); - for (const file of ['serve.sh', 'server.json', 'fix-flaky.session.json']) { + for (const file of ['serve.sh', 'server.json']) { assert.equal(statSync(join(f.dir, '.n8n-opencode', file)).mode & 0o777, 0o600); } assert.ok(!f.commands().some((entry) => JSON.stringify(entry.args).includes(first.password))); assert.equal(f.commands().filter((entry) => entry.command === 'pnpm').length, 2); await f.stop(); - const restarted = await f.prepare({ name: 'fix-flaky' }); + const restarted = await f.prepare({ name: 'fix-flaky', web: true }); assert.equal(restarted.sessionID, fresh.sessionID); assert.notEqual(restarted.password, first.password); rmSync(first.directory, { recursive: true }); writeFileSync(join(f.dir, 'branch-exists'), ''); - assert.equal((await f.prepare({ name: 'fix-flaky' })).sessionID, fresh.sessionID); + assert.equal((await f.prepare({ name: 'fix-flaky' })).directory, join(f.dir, 'wt-fix-flaky')); const worktree = f .commands() .filter((entry) => entry.command === 'git' && entry.args.includes('add')) @@ -183,19 +235,23 @@ test( ); test( - 'preserves saved sessions on API errors and replaces only missing sessions', + 'reports session API errors and creates conversations only when none exist', { timeout: 10000 }, async (t) => { const f = fixture(t); - const first = await f.prepare(); + const first = await f.prepare({ web: true }); + assert.equal(first.sessionID, 'ses_1'); writeFileSync(join(f.dir, 'reject-session'), ''); - await assert.rejects(f.prepare(), /Cannot resume OpenCode session \(503\)/); - const saved = join(f.dir, '.n8n-opencode', 'agent.session.json'); - assert.equal(JSON.parse(readFileSync(saved, 'utf8')).id, first.sessionID); + await assert.rejects(f.prepare({ web: true }), /Cannot list OpenCode sessions \(503\)/); + assert.ok(JSON.parse(readFileSync(join(f.dir, 'sessions.json'), 'utf8')).ses_1); rmSync(join(f.dir, 'reject-session')); - writeFileSync(saved, JSON.stringify({ id: 'ses_missing' })); - assert.notEqual((await f.prepare()).sessionID, first.sessionID); - writeFileSync(saved, '{'); - await assert.rejects(f.prepare(), /Cannot read/); + assert.equal((await f.prepare({ web: true })).sessionID, first.sessionID); + writeFileSync(join(f.dir, 'reject-create'), ''); + await assert.rejects( + f.prepare({ name: 'fresh-task', web: true }), + /Cannot create OpenCode session \(503\)/, + ); + rmSync(join(f.dir, 'reject-create')); + assert.equal((await f.prepare({ name: 'fresh-task', web: true })).sessionID, 'ses_2'); }, ); diff --git a/scripts/cloud-session-opencode.mjs b/scripts/cloud-session-opencode.mjs index 35639a0cd52..c5182bde5a5 100644 --- a/scripts/cloud-session-opencode.mjs +++ b/scripts/cloud-session-opencode.mjs @@ -94,7 +94,7 @@ function startChild(command, args, options = {}) { async function bootstrap(codespace, options, signal) { signal.throwIfAborted(); console.log(`Preparing OpenCode workspace '${options.name}' on ${codespace}…`); - const command = `umask 077; mkdir -p /workspaces/.n8n-opencode && flock --close -w 600 /workspaces/.n8n-opencode/launch.lock node --input-type=module - ${options.name} ${options.fresh}`; + const command = `umask 077; mkdir -p /workspaces/.n8n-opencode && flock --close -w 600 /workspaces/.n8n-opencode/launch.lock node --input-type=module - ${options.name} ${options.fresh} ${options.web}`; const remote = startChild('gh', ['codespace', 'ssh', '-c', codespace, '--', command], { stdio: ['pipe', 'pipe', 'inherit'], detached: true, @@ -129,8 +129,8 @@ async function bootstrap(codespace, options, signal) { !state || !Number.isInteger(state.port) || !text(state.password) || - !text(state.sessionID) || - !text(state.directory) + !text(state.directory) || + (options.web && !text(state.sessionID)) ) { throw new Error('Invalid OpenCode server response.'); } @@ -236,9 +236,18 @@ export async function connectOpenCode(options, ensureCodespace) { openBrowser(webUrl); } else { console.log(`Connecting to OpenCode ${health.version} in ${state.directory}…`); + // OpenCode's own resume spans all worktrees of the repository. The + // server resolved the workspace's latest conversation instead, so + // target it directly. A fresh TUI conversation needs no target. client = startChild( 'opencode', - ['attach', url, '--dir', state.directory, '--session', state.sessionID], + [ + 'attach', + url, + '--dir', + state.directory, + ...(state.sessionID ? ['--session', state.sessionID] : []), + ], { stdio: 'inherit', env: {