Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/runner-start-preview.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/ai-autopilot/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
34 changes: 34 additions & 0 deletions packages/ai-autopilot/src/runner/fake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
42 changes: 40 additions & 2 deletions packages/ai-autopilot/src/runner/fake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
Runner,
RunnerSession,
RunnerFs,
RunnerProcess,
BootOptions,
ExecOptions,
ExecResult,
Expand All @@ -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. */
Expand Down Expand Up @@ -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
Expand All @@ -76,12 +85,19 @@ export class FakeRunnerSession implements RunnerSession {
*/
readonly preview?: (opts?: PreviewOptions) => Promise<Preview>

/** Present only when the runner supports background processes (capability signal). */
readonly start?: (command: string, opts?: ExecOptions) => Promise<RunnerProcess>
/** 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<string, string>()

constructor(
id: string,
boot: BootOptions,
private readonly opts: Required<Pick<FakeRunnerOptions, 'onExec' | 'preview' | 'previewUrl'>>,
private readonly opts: Required<Pick<FakeRunnerOptions, 'onExec' | 'preview' | 'previewUrl' | 'background'>>,
) {
this.id = id
for (const [path, contents] of Object.entries(boot.files ?? {})) {
Expand All @@ -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<RunnerProcess> => {
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<ExecResult>(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. */
Expand All @@ -110,6 +146,7 @@ export class FakeRunnerSession implements RunnerSession {

async dispose(): Promise<void> {
this.disposed = true
await Promise.all(this.processes.map(p => p.stop().catch(() => {})))
}
}

Expand All @@ -132,14 +169,15 @@ export class FakeRunner implements Runner {
/** Every session this runner has booted. */
readonly sessions: FakeRunnerSession[] = []

private readonly opts: Required<Pick<FakeRunnerOptions, 'onExec' | 'preview' | 'previewUrl'>>
private readonly opts: Required<Pick<FakeRunnerOptions, 'onExec' | 'preview' | 'previewUrl' | 'background'>>
private counter = 0

constructor(options: FakeRunnerOptions = {}) {
this.opts = {
onExec: options.onExec ?? (async () => ({ stdout: '', stderr: '', exitCode: 0 })),
preview: options.preview ?? true,
previewUrl: options.previewUrl ?? 'https://preview.fake.local',
background: options.background ?? true,
}
}

Expand Down
2 changes: 2 additions & 0 deletions packages/ai-autopilot/src/runner/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type {
Runner,
RunnerSession,
RunnerFs,
RunnerProcess,
FileTree,
BootOptions,
ExecOptions,
Expand All @@ -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'
64 changes: 64 additions & 0 deletions packages/ai-autopilot/src/runner/local.test.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
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' } })
Expand Down Expand Up @@ -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
})
})
Loading
Loading