diff --git a/.changeset/runner-start-preview.md b/.changeset/runner-start-preview.md new file mode 100644 index 0000000..74c0bf9 --- /dev/null +++ b/.changeset/runner-start-preview.md @@ -0,0 +1,9 @@ +--- +'@gemstack/ai-autopilot': minor +--- + +Runner seam: long-running processes + reachable previews (boot-and-serve). + +`RunnerSession.start(command)` launches a long-running command (a dev server) in the background and returns a `RunnerProcess` handle (`{ command, exit, stop() }`) — unlike `exec`, which awaits the command to finish. `preview({ waitMs })` now waits for the port to accept connections before resolving, so the URL is live on return. A `start_server` runner tool exposes this to agents. + +`LocalRunner` implements it for real: `start` spawns in its own process group so `stop()` (and `dispose()`) kill the whole tree; `preview` polls the port. `FakeRunner` mirrors it for tests. This is the contract every sandboxed adapter (Docker / WebContainer / Flue) must satisfy, and it's what makes "produce a running app" reachable end to end. diff --git a/packages/ai-autopilot/src/index.ts b/packages/ai-autopilot/src/index.ts index 1e49a23..e1c5760 100644 --- a/packages/ai-autopilot/src/index.ts +++ b/packages/ai-autopilot/src/index.ts @@ -127,11 +127,13 @@ export { type BootOptions, type ExecOptions, type ExecResult, + type RunnerProcess, type Preview, type PreviewOptions, type FakeRunnerOptions, type FakeExec, type RecordedExec, + type RecordedStart, type LocalRunnerOptions, type RunnerToolsOptions, } from './runner/index.js' diff --git a/packages/ai-autopilot/src/runner/fake.test.ts b/packages/ai-autopilot/src/runner/fake.test.ts index dda5cee..5bdc62b 100644 --- a/packages/ai-autopilot/src/runner/fake.test.ts +++ b/packages/ai-autopilot/src/runner/fake.test.ts @@ -76,6 +76,40 @@ describe('FakeRunnerSession.preview', () => { }) }) +describe('FakeRunnerSession.start', () => { + it('records start calls and returns a controllable process handle', async () => { + const s = await new FakeRunner().boot() + const proc = await s.start!('npm run dev', { cwd: 'app' }) + assert.deepEqual(s.startCalls, [{ command: 'npm run dev', opts: { cwd: 'app' } }]) + assert.equal(s.processes.length, 1) + assert.equal(proc.command, 'npm run dev') + + let exited = false + void proc.exit.then(() => (exited = true)) + assert.equal(exited, false) // still "running" until stopped + await proc.stop() + assert.equal((await proc.exit).exitCode, 0) + }) + + it('omits start when background is disabled (capability signal)', async () => { + const s = await new FakeRunner({ background: false }).boot() + assert.equal(s.start, undefined) + }) + + it('dispose stops still-running processes', async () => { + const s = await new FakeRunner().boot() + const proc = await s.start!('node server.js') + await s.dispose() + assert.equal((await proc.exit).exitCode, 0) // resolved by dispose + }) + + it('rejects start on a disposed session', async () => { + const s = await new FakeRunner().boot() + await s.dispose() + await assert.rejects(() => s.start!('node server.js'), RunnerError) + }) +}) + describe('FakeRunnerSession.dispose', () => { it('marks disposed and blocks further exec/preview', async () => { const s = await new FakeRunner().boot() diff --git a/packages/ai-autopilot/src/runner/fake.ts b/packages/ai-autopilot/src/runner/fake.ts index e883026..4827faa 100644 --- a/packages/ai-autopilot/src/runner/fake.ts +++ b/packages/ai-autopilot/src/runner/fake.ts @@ -2,6 +2,7 @@ import type { Runner, RunnerSession, RunnerFs, + RunnerProcess, BootOptions, ExecOptions, ExecResult, @@ -20,6 +21,8 @@ export interface FakeRunnerOptions { preview?: boolean /** Base URL returned by `preview()`. Default `https://preview.fake.local`. */ previewUrl?: string + /** Whether booted sessions can `start` background processes. Default `true`. */ + background?: boolean } /** Normalize a workspace path to a canonical relative form. */ @@ -60,6 +63,12 @@ export interface RecordedExec { opts: ExecOptions } +/** A recorded `start` invocation, for test assertions. */ +export interface RecordedStart { + command: string + opts: ExecOptions +} + /** The session a {@link FakeRunner} boots — exposes its state for assertions. */ export class FakeRunnerSession implements RunnerSession { readonly id: string @@ -76,12 +85,19 @@ export class FakeRunnerSession implements RunnerSession { */ readonly preview?: (opts?: PreviewOptions) => Promise + /** Present only when the runner supports background processes (capability signal). */ + readonly start?: (command: string, opts?: ExecOptions) => Promise + /** Every `start` call in order, so tests can assert what autopilot launched. */ + readonly startCalls: RecordedStart[] = [] + /** The background processes started this session, in order. */ + readonly processes: RunnerProcess[] = [] + private readonly files = new Map() constructor( id: string, boot: BootOptions, - private readonly opts: Required>, + private readonly opts: Required>, ) { this.id = id for (const [path, contents] of Object.entries(boot.files ?? {})) { @@ -95,6 +111,26 @@ export class FakeRunnerSession implements RunnerSession { return { url: `${this.opts.previewUrl}:${port}`, port } } } + if (opts.background) { + this.start = async (command: string, startOpts: ExecOptions = {}): Promise => { + if (this.disposed) throw new RunnerError('start on a disposed session') + this.startCalls.push({ command, opts: startOpts }) + let resolveExit!: (r: ExecResult) => void + const exit = new Promise(res => (resolveExit = res)) + let settled = false + const proc: RunnerProcess = { + command, + exit, + stop: async () => { + if (settled) return + settled = true + resolveExit({ stdout: '', stderr: '', exitCode: 0 }) + }, + } + this.processes.push(proc) + return proc + } + } } /** A snapshot of the workspace files, for assertions. */ @@ -110,6 +146,7 @@ export class FakeRunnerSession implements RunnerSession { async dispose(): Promise { this.disposed = true + await Promise.all(this.processes.map(p => p.stop().catch(() => {}))) } } @@ -132,7 +169,7 @@ export class FakeRunner implements Runner { /** Every session this runner has booted. */ readonly sessions: FakeRunnerSession[] = [] - private readonly opts: Required> + private readonly opts: Required> private counter = 0 constructor(options: FakeRunnerOptions = {}) { @@ -140,6 +177,7 @@ export class FakeRunner implements Runner { onExec: options.onExec ?? (async () => ({ stdout: '', stderr: '', exitCode: 0 })), preview: options.preview ?? true, previewUrl: options.previewUrl ?? 'https://preview.fake.local', + background: options.background ?? true, } } diff --git a/packages/ai-autopilot/src/runner/index.ts b/packages/ai-autopilot/src/runner/index.ts index b3dd1da..f576bad 100644 --- a/packages/ai-autopilot/src/runner/index.ts +++ b/packages/ai-autopilot/src/runner/index.ts @@ -15,6 +15,7 @@ export type { Runner, RunnerSession, RunnerFs, + RunnerProcess, FileTree, BootOptions, ExecOptions, @@ -29,6 +30,7 @@ export { type FakeRunnerOptions, type FakeExec, type RecordedExec, + type RecordedStart, } from './fake.js' export { LocalRunner, LocalRunnerSession, type LocalRunnerOptions } from './local.js' export { runnerTools, type RunnerToolsOptions } from './tools.js' diff --git a/packages/ai-autopilot/src/runner/local.test.ts b/packages/ai-autopilot/src/runner/local.test.ts index 17c9139..f6b7df1 100644 --- a/packages/ai-autopilot/src/runner/local.test.ts +++ b/packages/ai-autopilot/src/runner/local.test.ts @@ -1,9 +1,26 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' import { existsSync } from 'node:fs' +import { createServer } from 'node:net' import { LocalRunner } from './local.js' import { RunnerError } from './types.js' +/** Grab an ephemeral free port so the boot-and-serve tests don't collide in CI. */ +function freePort(): Promise { + return new Promise((resolve, reject) => { + const srv = createServer() + srv.on('error', reject) + srv.listen(0, '127.0.0.1', () => { + const addr = srv.address() + const port = typeof addr === 'object' && addr ? addr.port : 0 + srv.close(() => resolve(port)) + }) + }) +} + +const httpServer = (port: number, body: string): string => + `const http=require('http');http.createServer((_,res)=>{res.writeHead(200);res.end(${JSON.stringify(body)})}).listen(${port},'127.0.0.1')` + describe('LocalRunner.boot', () => { it('seeds a real workspace with files, including nested paths', async () => { const s = await new LocalRunner().boot({ files: { './pages/+Page.jsx': 'PAGE', 'app.ts': 'APP' } }) @@ -147,3 +164,50 @@ describe('LocalRunnerSession.dispose', () => { await assert.rejects(() => s.preview!(), RunnerError) }) }) + +describe('LocalRunnerSession.start (boot and serve)', () => { + it('runs a real background server that preview can reach, then stop kills it', async () => { + const port = await freePort() + const s = await new LocalRunner().boot({ files: { 'server.js': httpServer(port, 'hello from runner') } }) + try { + const proc = await s.start('node server.js') + assert.equal(proc.command, 'node server.js') + + const preview = await s.preview!({ port, waitMs: 5000 }) + assert.equal(preview.port, port) + + const res = await fetch(preview.url) + assert.equal(await res.text(), 'hello from runner') // the app is actually serving + + await proc.stop() + await assert.rejects(fetch(preview.url)) // port no longer listening + } finally { + await s.dispose() + } + }) + + it('start does not block on a long-running process', async () => { + const port = await freePort() + const s = await new LocalRunner().boot({ files: { 'server.js': httpServer(port, 'x') } }) + try { + // Would hang forever with exec(); start() must resolve immediately. + const proc = await s.start('node server.js') + let exited = false + void proc.exit.then(() => (exited = true)) + assert.equal(exited, false) // still running right after start + await proc.stop() + } finally { + await s.dispose() + } + }) + + it('dispose stops a still-running process', async () => { + const port = await freePort() + const s = await new LocalRunner().boot({ files: { 'server.js': httpServer(port, 'x') } }) + const proc = await s.start('node server.js') + await s.preview!({ port, waitMs: 5000 }) + await s.dispose() + const result = await proc.exit // resolves because dispose stopped it + assert.notEqual(result.exitCode, 0) // killed, not a clean exit + }) +}) diff --git a/packages/ai-autopilot/src/runner/local.ts b/packages/ai-autopilot/src/runner/local.ts index 97c222b..5a684c0 100644 --- a/packages/ai-autopilot/src/runner/local.ts +++ b/packages/ai-autopilot/src/runner/local.ts @@ -1,4 +1,5 @@ import { spawn } from 'node:child_process' +import { connect } from 'node:net' import { mkdtemp, mkdir, readFile, writeFile, rm, readdir, access } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, dirname, resolve, relative, sep } from 'node:path' @@ -6,6 +7,7 @@ import type { Runner, RunnerSession, RunnerFs, + RunnerProcess, BootOptions, ExecOptions, ExecResult, @@ -14,6 +16,27 @@ import type { } from './types.js' import { RunnerError } from './types.js' +const delay = (ms: number): Promise => new Promise(res => setTimeout(res, ms)) + +/** Resolve once something is accepting TCP connections on `host:port`, or after `timeoutMs`. */ +async function waitForPort(host: string, port: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + for (;;) { + const ok = await new Promise(res => { + const socket = connect({ host, port }, () => { + socket.destroy() + res(true) + }) + socket.on('error', () => { + socket.destroy() + res(false) + }) + }) + if (ok || Date.now() >= deadline) return + await delay(100) + } +} + export interface LocalRunnerOptions { /** Base directory to create workspaces under. Default: the OS temp dir. */ root?: string @@ -105,6 +128,8 @@ export class LocalRunnerSession implements RunnerSession { private readonly cwd: string private readonly env: Record + /** Long-running processes started with {@link start}, so `dispose` can stop them. */ + private readonly procs = new Set() constructor( id: string, @@ -121,11 +146,66 @@ export class LocalRunnerSession implements RunnerSession { this.preview = async (previewOpts: PreviewOptions = {}): Promise => { if (this.disposed) throw new RunnerError('preview on a disposed session') const port = previewOpts.port ?? 3000 + // Wait for the started server to accept connections, so the URL is live on return. + if (previewOpts.waitMs && previewOpts.waitMs > 0) await waitForPort('127.0.0.1', port, previewOpts.waitMs) return { url: `${opts.previewHost}:${port}`, port } } } } + /** + * Start a long-running command in the background. Spawns it in its own process + * group (`detached`) so `stop` can kill the whole tree (the shell AND the server + * it launched), and returns immediately with a handle — it does NOT await exit. + */ + async start(command: string, opts: ExecOptions = {}): Promise { + if (this.disposed) throw new RunnerError('start on a disposed session') + const cwd = within(this.root, opts.cwd ?? (this.cwd || '.')) + const env = { ...process.env, ...this.env, ...(opts.env ?? {}) } + const child = spawn(command, { cwd, env, shell: true, detached: true }) + let stdout = '' + let stderr = '' + child.stdout?.on('data', d => (stdout += d)) + child.stderr?.on('data', d => (stderr += d)) + + let resolveExit!: (r: ExecResult) => void + const exit = new Promise(res => (resolveExit = res)) + let settled = false + const settle = (r: ExecResult): void => { + if (settled) return + settled = true + resolveExit(r) + } + child.on('close', (code, signal) => settle({ stdout, stderr, exitCode: code ?? (signal ? 137 : 0) })) + child.on('error', err => settle({ stdout, stderr: stderr + `\n[ai-autopilot] failed to spawn: ${(err as Error).message}`, exitCode: 1 })) + + // Kill the whole process group (negative pid), escalating SIGTERM → SIGKILL. + const signal = (sig: NodeJS.Signals): void => { + if (settled || child.pid == null) return + try { + process.kill(-child.pid, sig) + } catch { + // group gone already + } + } + const proc: RunnerProcess = { + command, + exit, + stop: async () => { + if (!settled) { + signal('SIGTERM') + const raced = await Promise.race([exit, delay(2000).then(() => 'timeout' as const)]) + if (raced === 'timeout') signal('SIGKILL') + await exit + } + this.procs.delete(proc) + }, + } + exit.then(() => this.procs.delete(proc)) + this.procs.add(proc) + return proc + } + async exec(command: string, opts: ExecOptions = {}): Promise { if (this.disposed) throw new RunnerError('exec on a disposed session') const cwd = within(this.root, opts.cwd ?? (this.cwd || '.')) @@ -166,6 +246,8 @@ export class LocalRunnerSession implements RunnerSession { async dispose(): Promise { if (this.disposed) return this.disposed = true + // Stop any still-running background processes before removing the workspace. + await Promise.all([...this.procs].map(p => p.stop().catch(() => {}))) await rm(this.root, { recursive: true, force: true }) } } @@ -183,8 +265,12 @@ export class LocalRunnerSession implements RunnerSession { * ```ts * const runner = new LocalRunner() * const s = await runner.boot({ files: { 'app.js': "console.log('hi')" } }) - * await s.exec('node app.js') // → { stdout: 'hi\n', stderr: '', exitCode: 0 } - * await s.dispose() // removes the temp workspace + * await s.exec('node app.js') // one-shot → { stdout: 'hi\n', … } + * + * const dev = await s.start('npm run dev') // long-running, returns at once + * const { url } = await s.preview({ port: 3000, waitMs: 5000 }) // waits until reachable + * await dev.stop() // kills the server (process group) + * await s.dispose() // stops leftovers + removes the workspace * ``` */ export class LocalRunner implements Runner { diff --git a/packages/ai-autopilot/src/runner/tools.test.ts b/packages/ai-autopilot/src/runner/tools.test.ts index 1e380e1..f4eb955 100644 --- a/packages/ai-autopilot/src/runner/tools.test.ts +++ b/packages/ai-autopilot/src/runner/tools.test.ts @@ -40,6 +40,18 @@ describe('runnerTools', () => { assert.ok(!without.includes('preview')) }) + it('includes start_server only when the session supports background processes', async () => { + const withStart = runnerTools(await new FakeRunner().boot()).map(t => t.definition.name) + assert.ok(withStart.includes('start_server')) + const without = runnerTools(await new FakeRunner({ background: false }).boot()).map(t => t.definition.name) + assert.ok(!without.includes('start_server')) + }) + + it('start_server rides the exec toggle (dropped from a read-only surface)', async () => { + const names = runnerTools(await new FakeRunner().boot(), { exec: false }).map(t => t.definition.name) + assert.ok(!names.includes('start_server')) + }) + it('honors the write/exec toggles and the name prefix', async () => { const s = await new FakeRunner().boot() const names = runnerTools(s, { write: false, exec: false, prefix: 'sandbox' }).map( diff --git a/packages/ai-autopilot/src/runner/tools.ts b/packages/ai-autopilot/src/runner/tools.ts index b6aea9e..7371fe1 100644 --- a/packages/ai-autopilot/src/runner/tools.ts +++ b/packages/ai-autopilot/src/runner/tools.ts @@ -92,6 +92,25 @@ export function runnerTools(session: RunnerSession, opts: RunnerToolsOptions = { ) } + if (opts.exec !== false && session.start) { + const start = session.start.bind(session) + tools.push( + toolDefinition({ + name: name('start_server'), + description: 'Start a long-running command (e.g. a dev server) in the background. Returns immediately; use preview to get its URL.', + inputSchema: z.object({ + command: z.string().describe('The shell command to start (e.g. "npm run dev").'), + cwd: z.string().optional().describe('Working directory, relative to the workspace root.'), + }), + }) + .server(async ({ command, cwd }) => { + await start(command, cwd ? { cwd } : {}) + return { started: command } + }) + .modelOutput(r => `started ${r.started}`) as unknown as AnyTool, + ) + } + if (session.preview) { const preview = session.preview.bind(session) tools.push( diff --git a/packages/ai-autopilot/src/runner/types.ts b/packages/ai-autopilot/src/runner/types.ts index 8f67a45..b8a6e3b 100644 --- a/packages/ai-autopilot/src/runner/types.ts +++ b/packages/ai-autopilot/src/runner/types.ts @@ -42,6 +42,21 @@ export interface ExecResult { exitCode: number } +/** + * A handle to a long-running process started with {@link RunnerSession.start} — + * a dev server, a watcher, anything that does not exit on its own. Unlike `exec` + * (which resolves when the command finishes), `start` returns immediately with + * this handle so the caller can serve a {@link RunnerSession.preview} against it. + */ +export interface RunnerProcess { + /** The command that was started. */ + readonly command: string + /** Resolves when the process exits — on its own, or after {@link stop}. */ + readonly exit: Promise + /** Stop the process. Idempotent; resolves once it has exited. */ + stop(): Promise +} + /** A virtual filesystem scoped to a single session's workspace. */ export interface RunnerFs { read(path: string): Promise @@ -56,6 +71,12 @@ export interface RunnerFs { export interface PreviewOptions { /** Port the server listens on inside the sandbox. */ port?: number + /** + * Wait up to this many milliseconds for the port to accept connections before + * returning, so the URL is reachable the moment `preview` resolves. Default `0` + * (return immediately — a runner that can't probe readiness ignores it). + */ + waitMs?: number } /** A reachable URL for the running app. */ @@ -76,6 +97,12 @@ export interface RunnerSession { readonly id: string readonly fs: RunnerFs exec(command: string, opts?: ExecOptions): Promise + /** + * Start a long-running command (a dev server) in the background and return a + * handle to it, without waiting for it to exit. Absent when the runner only + * supports one-shot `exec` — presence is the capability signal, like `preview`. + */ + start?(command: string, opts?: ExecOptions): Promise /** Expose a running dev server and return its URL. Absent when unsupported. */ preview?(opts?: PreviewOptions): Promise /** Tear down the workspace and release its resources. Idempotent. */