From 7b76a2069cbb7ce562e8a01a35aba1ceb3752ccb Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Thu, 2 Jul 2026 11:45:14 +0300 Subject: [PATCH 1/3] docs(ai-autopilot): end-to-end quickstart example (personas + supervisor + runner + surfaces) A runnable, offline (AiFake) example that composes all four epic layers into one build-a-feature flow: a planner routes subtasks to stack personas, the Supervisor dispatches them, each worker writes Vike/ORM files inside a runner sandbox via runnerTools, and the surfaces render live progress + a background handle. Doubles as the integration contract a real FlueRunner must satisfy. Private workspace example (@gemstack/example-autopilot-quickstart), in turbo typecheck+test. Capstone for epic #97. --- examples/autopilot-quickstart/README.md | 36 +++++ examples/autopilot-quickstart/package.json | 23 +++ .../src/autopilot.test.ts | 41 +++++ .../autopilot-quickstart/src/autopilot.ts | 150 ++++++++++++++++++ examples/autopilot-quickstart/src/main.ts | 23 +++ examples/autopilot-quickstart/tsconfig.json | 5 + .../autopilot-quickstart/tsconfig.test.json | 5 + pnpm-lock.yaml | 22 +++ 8 files changed, 305 insertions(+) create mode 100644 examples/autopilot-quickstart/README.md create mode 100644 examples/autopilot-quickstart/package.json create mode 100644 examples/autopilot-quickstart/src/autopilot.test.ts create mode 100644 examples/autopilot-quickstart/src/autopilot.ts create mode 100644 examples/autopilot-quickstart/src/main.ts create mode 100644 examples/autopilot-quickstart/tsconfig.json create mode 100644 examples/autopilot-quickstart/tsconfig.test.json diff --git a/examples/autopilot-quickstart/README.md b/examples/autopilot-quickstart/README.md new file mode 100644 index 0000000..0a27288 --- /dev/null +++ b/examples/autopilot-quickstart/README.md @@ -0,0 +1,36 @@ +# @gemstack/example-autopilot-quickstart + +A runnable, end-to-end quickstart for [`@gemstack/ai-autopilot`](../../packages/ai-autopilot) — the four layers of the epic composed into one "build a feature" flow: + +``` +personas → Supervisor → runner (sandbox) → surfaces +``` + +A lead planner decomposes the task **"Add a paginated Orders page backed by an orders table"** and routes each subtask to a stack-aware **persona** (`universal-orm-modeler`, `vike-page-builder`, `ui-intent-designer`). The **Supervisor** dispatches them; each persona worker acts inside a **runner** sandbox — writing Vike/ORM files through `runnerTools` — and progress is rendered through the **surfaces** (a terminal sink for live output, plus a background handle exposing events + result). + +It runs **offline**: `AiFake` scripts the model, so there's no API key and the output is deterministic. + +## Run it + +```bash +pnpm install && pnpm build # from the repo root, to build the packages first +pnpm --filter @gemstack/example-autopilot-quickstart start +``` + +You'll see the live plan, the files written into the sandbox, a build + preview URL, and the synthesized result. + +## Test it + +```bash +pnpm --filter @gemstack/example-autopilot-quickstart test +``` + +## Going real + +The only fakes here are `AiFake` (the model) and `FakeRunner` (the sandbox). To run it for real, drop `AiFake` and give the personas real model strings, and swap `FakeRunner` for a real runner adapter (a `FlueRunner`, WebContainer, or Docker sandbox) — the `Runner` interface is the same, so nothing else in the flow changes. + +## What each file shows + +- **`src/autopilot.ts`** — the composition: `personaWorkers` + `runnerTools`, `agentPlanner` fed the `personaRoster`, a `Supervisor`, and `launchAutopilot` + `terminalSink` for the surfaces. +- **`src/main.ts`** — the runnable demo. +- **`src/autopilot.test.ts`** — asserts the four layers actually compose (plan routes by persona, files land in the sandbox, surfaces capture events). diff --git a/examples/autopilot-quickstart/package.json b/examples/autopilot-quickstart/package.json new file mode 100644 index 0000000..bff0539 --- /dev/null +++ b/examples/autopilot-quickstart/package.json @@ -0,0 +1,23 @@ +{ + "name": "@gemstack/example-autopilot-quickstart", + "version": "0.0.0", + "private": true, + "description": "Runnable end-to-end quickstart for @gemstack/ai-autopilot: personas + Supervisor + runner + surfaces composed into one build-a-feature flow, offline via AiFake.", + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "test": "tsc -p tsconfig.test.json && cd dist-test && node --test", + "clean": "rm -rf dist-test", + "start": "tsx src/main.ts" + }, + "dependencies": { + "@gemstack/ai-autopilot": "workspace:^", + "@gemstack/ai-sdk": "workspace:^", + "zod": "^4.0.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "tsx": "^4.19.0", + "typescript": "^5.4.0" + } +} diff --git a/examples/autopilot-quickstart/src/autopilot.test.ts b/examples/autopilot-quickstart/src/autopilot.test.ts new file mode 100644 index 0000000..daf7d82 --- /dev/null +++ b/examples/autopilot-quickstart/src/autopilot.test.ts @@ -0,0 +1,41 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { runQuickstart, TASK } from './autopilot.js' + +describe('autopilot quickstart: the four layers compose end-to-end', () => { + it('plans by persona, dispatches, acts in the sandbox, and surfaces progress', async () => { + const lines: string[] = [] + const result = await runQuickstart(line => lines.push(line)) + + // Supervisor: three subtasks, each routed to a stack persona, all succeeded. + assert.equal(result.run.plan.length, 3) + assert.deepEqual( + result.run.plan.map(s => s.worker).sort(), + ['ui-intent-designer', 'universal-orm-modeler', 'vike-page-builder'], + ) + assert.ok(result.run.results.every(r => r.ok), 'every subtask succeeded') + + // Runner: each persona worker wrote its file into the sandbox via runnerTools. + assert.ok('database/schema.ts' in result.files) + assert.ok('pages/orders/+Page.jsx' in result.files) + assert.match(result.files['database/schema.ts']!, /orders/) + // the seed file is still there + assert.ok('package.json' in result.files) + + // Runner: the post-build exec ran and a preview URL was exposed. + assert.equal(result.build.exitCode, 0) + assert.match(result.build.stdout, /built/) + assert.match(result.previewUrl, /:5173$/) + + // Surfaces: the terminal sink printed a plan line and per-subtask lines; + // the background handle captured the same events. + assert.ok(lines.some(l => l.includes('plan:')), 'terminal printed the plan') + assert.ok(lines.some(l => l.includes('✓')), 'terminal printed a completed subtask') + assert.equal(result.events[0]!.type, 'plan') + assert.equal(result.events.at(-1)!.type, 'synthesize') + }) + + it('exposes the task constant for the runnable demo', () => { + assert.match(TASK, /Orders/) + }) +}) diff --git a/examples/autopilot-quickstart/src/autopilot.ts b/examples/autopilot-quickstart/src/autopilot.ts new file mode 100644 index 0000000..b1a4294 --- /dev/null +++ b/examples/autopilot-quickstart/src/autopilot.ts @@ -0,0 +1,150 @@ +import { AiFake, agent, type ToolCall } from '@gemstack/ai-sdk' +import { + Supervisor, + agentPlanner, + stackPersonas, + personaRoster, + personaInstructions, + personaTools, + FakeRunner, + runnerTools, + terminalSink, + launchAutopilot, + type RunnerSession, + type SupervisorEvent, + type SupervisorRun, +} from '@gemstack/ai-autopilot' + +/** + * The end-to-end shape of the ai-autopilot epic, in one flow: + * + * personas → Supervisor → runner (sandbox) → surfaces + * + * A lead planner decomposes a build task and routes each subtask to a + * stack-aware **persona**; the **Supervisor** dispatches them; each persona + * worker acts inside a **runner** sandbox (writing Vike/ORM files via + * `runnerTools`); progress is rendered through the **surfaces** (a terminal + * sink plus a background handle with a live stream). + * + * It runs offline: `AiFake` scripts the model, so there is no API key and the + * output is deterministic. Swapping `FakeRunner` for a real runner and dropping + * the fake is the only change needed to run it for real. + */ + +/** The feature we ask autopilot to build. */ +export const TASK = 'Add a paginated Orders page backed by an orders table' + +/** Each subtask, the persona that should own it, and the file it writes. */ +const WORK = [ + { + worker: 'universal-orm-modeler', + description: 'Define the orders schema and a migration', + file: 'database/schema.ts', + contents: "export const orders = table('orders', { id: id(), total: integer(), createdAt: timestamp() })\n", + }, + { + worker: 'vike-page-builder', + description: 'Build the /orders page that lists orders, paginated', + file: 'pages/orders/+Page.jsx', + contents: "export default function Page({ orders }) { return }\n", + }, + { + worker: 'ui-intent-designer', + description: 'Express the orders list as intent, not hardcoded markup', + file: 'pages/orders/+config.js', + contents: "export default { meta: { OrderList: { env: { server: true, client: true } } } }\n", + }, +] as const + +/** Script the fake: step 0 is the planner's JSON; then each worker writes its file. */ +function scriptModel(fake: AiFake): void { + const plannerOutput = JSON.stringify(WORK.map(w => ({ description: w.description, worker: w.worker }))) + const workerSteps = WORK.flatMap((w, i) => { + const toolCalls: ToolCall[] = [ + { id: `write-${i}`, name: 'write_file', arguments: { path: w.file, contents: w.contents } }, + ] + return [{ toolCalls }, { text: `Wrote ${w.file}` }] + }) + // concurrency: 1 makes the provider-call order deterministic: + // 0 = planner, then each worker's (tool-call, final-text) pair in plan order. + fake.respondWithSequence([{ text: plannerOutput }, ...workerSteps]) +} + +/** Build one worker agent per persona, each with hands inside the sandbox. */ +function personaWorkersWithSandbox(session: RunnerSession): Record> { + const sandbox = runnerTools(session) + return Object.fromEntries( + stackPersonas.map(p => [ + p.name, + agent({ instructions: personaInstructions(p), tools: [...personaTools(p), ...sandbox] }), + ]), + ) +} + +export interface QuickstartResult { + run: SupervisorRun + /** Every event, in order (from the background handle). */ + events: SupervisorEvent[] + /** Files the workers wrote into the sandbox. */ + files: Record + /** Output of the post-build `exec`. */ + build: { stdout: string; exitCode: number } + /** The preview URL the sandbox exposed. */ + previewUrl: string +} + +/** + * Run the whole flow once and return everything the surfaces exposed. + * + * @param write where terminal-surface lines go (default: no-op; `main.ts` prints). + */ +export async function runQuickstart(write: (line: string) => void = () => {}): Promise { + const fake = AiFake.fake() + scriptModel(fake) + try { + // Runner: an in-memory sandbox seeded with a minimal Vike project. + const runner = new FakeRunner({ + onExec: cmd => + cmd.includes('build') + ? { stdout: 'orders page built', stderr: '', exitCode: 0 } + : { stdout: '', stderr: '', exitCode: 0 }, + }) + const session = await runner.boot({ files: { 'package.json': '{ "name": "shop" }\n' } }) + + // Personas → Supervisor. The planner is told the roster so it routes by role. + const planner = agentPlanner( + agent(`You are the lead engineer. Decompose the task and route each subtask to a persona.\n\n${personaRoster(stackPersonas)}`), + ) + const start = (onEvent: (e: SupervisorEvent) => void) => + new Supervisor({ + plan: planner, + workers: personaWorkersWithSandbox(session), + concurrency: 1, + onEvent, + }).run(TASK) + + // Surfaces: one run feeds both the terminal (live) and a background handle. + const terminal = terminalSink({ write }) + const handle = launchAutopilot(onEvent => + start(e => { + terminal(e) + onEvent(e) + }), + ) + const run = await handle.result() + + // Runner again: build the app and grab a preview URL. + const build = await session.exec('pnpm build') + const preview = session.preview ? await session.preview({ port: 5173 }) : { url: '' } + + return { + run, + events: handle.events(), + files: session.snapshot(), + build: { stdout: build.stdout, exitCode: build.exitCode }, + previewUrl: preview.url, + } + } finally { + fake.restore() + } +} diff --git a/examples/autopilot-quickstart/src/main.ts b/examples/autopilot-quickstart/src/main.ts new file mode 100644 index 0000000..7c300bc --- /dev/null +++ b/examples/autopilot-quickstart/src/main.ts @@ -0,0 +1,23 @@ +import { runQuickstart, TASK } from './autopilot.js' + +/** Run the quickstart and print what each surface exposed. Offline (AiFake). */ +async function main(): Promise { + console.log(`Task: ${TASK}\n`) + console.log('--- live progress (terminal surface) ---') + const result = await runQuickstart(line => process.stdout.write(line + '\n')) + + console.log('\n--- files written into the sandbox (runner) ---') + for (const path of Object.keys(result.files).sort()) console.log(` ${path}`) + + console.log('\n--- build + preview (runner) ---') + console.log(` build: exit ${result.build.exitCode} — ${result.build.stdout}`) + console.log(` preview: ${result.previewUrl}`) + + console.log('\n--- synthesized result (Supervisor) ---') + console.log(result.run.text.replace(/^/gm, ' ')) +} + +main().catch(err => { + console.error(err) + process.exitCode = 1 +}) diff --git a/examples/autopilot-quickstart/tsconfig.json b/examples/autopilot-quickstart/tsconfig.json new file mode 100644 index 0000000..404aab4 --- /dev/null +++ b/examples/autopilot-quickstart/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true, "rootDir": "src" }, + "include": ["src"] +} diff --git a/examples/autopilot-quickstart/tsconfig.test.json b/examples/autopilot-quickstart/tsconfig.test.json new file mode 100644 index 0000000..eebda2f --- /dev/null +++ b/examples/autopilot-quickstart/tsconfig.test.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist-test", "rootDir": "src" }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39f1e17..2eb1ca6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,28 @@ importers: specifier: ^3.5.29 version: 3.5.39(typescript@5.9.3) + examples/autopilot-quickstart: + dependencies: + '@gemstack/ai-autopilot': + specifier: workspace:^ + version: link:../../packages/ai-autopilot + '@gemstack/ai-sdk': + specifier: workspace:^ + version: link:../../packages/ai-sdk + zod: + specifier: ^4.0.0 + version: 4.4.3 + devDependencies: + '@types/node': + specifier: ^20.0.0 + version: 20.19.43 + tsx: + specifier: ^4.19.0 + version: 4.22.4 + typescript: + specifier: ^5.4.0 + version: 5.9.3 + examples/connectors-quickstart: dependencies: '@gemstack/connectors': From be3592f5abf3405fcbeff5b80cb4667c8a6f5f72 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Thu, 2 Jul 2026 13:32:33 +0300 Subject: [PATCH 2/3] feat(ai-autopilot): LocalRunner, the first real runner adapter Boots each workspace as a real temp dir on the host: real files (node:fs, path-traversal guarded), real commands (child_process with cwd/env/timeout), a localhost preview, and a dispose() that cleans up. It is the reference the sandboxed adapters (WebContainer/Docker/Flue) mirror. Runs unsandboxed on the host, so documented as trusted/CI-only. Closes #106. Part of #97. --- .changeset/ai-autopilot-local-runner.md | 5 + packages/ai-autopilot/README.md | 21 +- packages/ai-autopilot/src/index.ts | 4 + packages/ai-autopilot/src/runner/index.ts | 2 + .../ai-autopilot/src/runner/local.test.ts | 149 ++++++++++++ packages/ai-autopilot/src/runner/local.ts | 214 ++++++++++++++++++ 6 files changed, 392 insertions(+), 3 deletions(-) create mode 100644 .changeset/ai-autopilot-local-runner.md create mode 100644 packages/ai-autopilot/src/runner/local.test.ts create mode 100644 packages/ai-autopilot/src/runner/local.ts diff --git a/.changeset/ai-autopilot-local-runner.md b/.changeset/ai-autopilot-local-runner.md new file mode 100644 index 0000000..77972eb --- /dev/null +++ b/.changeset/ai-autopilot-local-runner.md @@ -0,0 +1,5 @@ +--- +"@gemstack/ai-autopilot": minor +--- + +Add `LocalRunner`, the first real adapter behind the runner seam. Where `FakeRunner` simulates a workspace in memory, `LocalRunner` boots each workspace as a real temp directory on the host: real files via `node:fs` (path-traversal guarded to the workspace root), real commands via `child_process` (shell, per-command `cwd`/`env`/`timeoutMs`), a localhost `preview`, and a `dispose()` that removes the workspace. It is the reference the sandboxed adapters (WebContainer, Docker, Flue) mirror. It runs commands unsandboxed on the host, so it is documented for trusted/CI use only, not untrusted agent-authored code. Part of the ai-autopilot epic (#97), issue #106. diff --git a/packages/ai-autopilot/README.md b/packages/ai-autopilot/README.md index d95928b..2dbaf3a 100644 --- a/packages/ai-autopilot/README.md +++ b/packages/ai-autopilot/README.md @@ -79,9 +79,24 @@ interface: **WebContainer** (instant in-browser Vike preview), a **Docker** sandbox on our servers, or a **Flue** sandbox (in-memory / edge / container). We sit on those harnesses rather than competing with them. -This package ships the interface plus a **`FakeRunner`** (the runner analog of -`ai-sdk`'s `AiFake`) so autopilot can be driven and tested without any sandbox -infra. Real adapters land as separate packages. +This package ships the interface, a **`FakeRunner`** (the runner analog of +`ai-sdk`'s `AiFake`) so autopilot can be driven and tested without any infra, +and a **`LocalRunner`** — the first real adapter: each workspace is a real temp +directory on the host, with real files and real child processes. The sandboxed +adapters (WebContainer, Docker, Flue) land as separate packages and mirror it. + +`LocalRunner` runs commands **unsandboxed on the host**, so reach for it only +where execution is already trusted (local dev, or a CI job that is itself the +sandbox) — not to run untrusted, agent-authored code. + +```ts +import { LocalRunner } from '@gemstack/ai-autopilot' + +const runner = new LocalRunner() +const session = await runner.boot({ files: { 'app.js': "console.log('hi')" } }) +await session.exec('node app.js') // → { stdout: 'hi\n', stderr: '', exitCode: 0 } +await session.dispose() // removes the temp workspace +``` ```ts import { FakeRunner, runnerTools } from '@gemstack/ai-autopilot' diff --git a/packages/ai-autopilot/src/index.ts b/packages/ai-autopilot/src/index.ts index 846c6e9..1951e63 100644 --- a/packages/ai-autopilot/src/index.ts +++ b/packages/ai-autopilot/src/index.ts @@ -25,6 +25,7 @@ * `sandbox` so WebContainer / Docker / Flue drop in behind one interface. * * - {@link FakeRunner} — in-memory runner for tests + * - {@link LocalRunner} — real host workspace (fs + child processes); the first real adapter * - {@link runnerTools} — expose a booted session to an agent as sandbox tools * * Surfaces run the same autopilot in the terminal, an in-page UI, or a @@ -56,6 +57,8 @@ export { export { FakeRunner, FakeRunnerSession, + LocalRunner, + LocalRunnerSession, RunnerError, runnerTools, type Runner, @@ -70,6 +73,7 @@ export { type FakeRunnerOptions, type FakeExec, type RecordedExec, + type LocalRunnerOptions, type RunnerToolsOptions, } from './runner/index.js' export { diff --git a/packages/ai-autopilot/src/runner/index.ts b/packages/ai-autopilot/src/runner/index.ts index 6a9e758..b3dd1da 100644 --- a/packages/ai-autopilot/src/runner/index.ts +++ b/packages/ai-autopilot/src/runner/index.ts @@ -8,6 +8,7 @@ * tests; real adapters land separately. * * - {@link FakeRunner} — in-memory runner for tests (the runner analog of `AiFake`) + * - {@link LocalRunner} — real host workspace (fs + child processes); the first real adapter * - {@link runnerTools} — expose a session to an agent as sandbox tools */ export type { @@ -29,4 +30,5 @@ export { type FakeExec, type RecordedExec, } 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 new file mode 100644 index 0000000..17c9139 --- /dev/null +++ b/packages/ai-autopilot/src/runner/local.test.ts @@ -0,0 +1,149 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +import { LocalRunner } from './local.js' +import { RunnerError } from './types.js' + +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' } }) + try { + assert.equal(await s.fs.read('pages/+Page.jsx'), 'PAGE') + assert.equal(await s.fs.read('/app.ts'), 'APP') // leading slash normalized + assert.ok(existsSync(s.root)) + } finally { + await s.dispose() + } + }) + + it('gives each session a distinct id and workspace', async () => { + const runner = new LocalRunner() + const a = await runner.boot() + const b = await runner.boot() + try { + assert.notEqual(a.id, b.id) + assert.notEqual(a.root, b.root) + } finally { + await a.dispose() + await b.dispose() + } + }) +}) + +describe('LocalRunnerSession.fs', () => { + it('writes, reads, checks existence, lists recursively, and removes', async () => { + const s = await new LocalRunner().boot() + try { + assert.equal(await s.fs.exists('a.txt'), false) + await s.fs.write('src/a.txt', 'A') + await s.fs.write('src/b.txt', 'B') + await s.fs.write('root.txt', 'R') + assert.equal(await s.fs.exists('src/a.txt'), true) + assert.deepEqual(await s.fs.list('src'), ['src/a.txt', 'src/b.txt']) + assert.deepEqual(await s.fs.list(), ['root.txt', 'src/a.txt', 'src/b.txt']) + await s.fs.remove('src/a.txt') + assert.equal(await s.fs.exists('src/a.txt'), false) + } finally { + await s.dispose() + } + }) + + it('throws reading a missing file', async () => { + const s = await new LocalRunner().boot() + try { + await assert.rejects(() => s.fs.read('nope.txt'), RunnerError) + } finally { + await s.dispose() + } + }) + + it('refuses paths that escape the workspace', async () => { + const s = await new LocalRunner().boot() + try { + await assert.rejects(() => s.fs.read('../../etc/passwd'), RunnerError) + await assert.rejects(() => s.fs.write('../evil.txt', 'x'), RunnerError) + } finally { + await s.dispose() + } + }) +}) + +describe('LocalRunnerSession.exec', () => { + it('runs a real command and captures stdout + exit code', async () => { + const s = await new LocalRunner().boot({ files: { 'app.js': "process.stdout.write('hi')" } }) + try { + const r = await s.exec('node app.js') + assert.equal(r.stdout, 'hi') + assert.equal(r.exitCode, 0) + } finally { + await s.dispose() + } + }) + + it('reports a non-zero exit code', async () => { + const s = await new LocalRunner().boot() + try { + const r = await s.exec('node -e "process.exit(3)"') + assert.equal(r.exitCode, 3) + } finally { + await s.dispose() + } + }) + + it('honors cwd and env overrides', async () => { + const s = await new LocalRunner().boot({ files: { 'sub/probe.js': 'process.stdout.write(process.env.FOO || "")' } }) + try { + const r = await s.exec('node probe.js', { cwd: 'sub', env: { FOO: 'bar' } }) + assert.equal(r.stdout, 'bar') + } finally { + await s.dispose() + } + }) + + it('kills a command that exceeds its timeout', async () => { + const s = await new LocalRunner().boot() + try { + const r = await s.exec('node -e "setTimeout(()=>{}, 10000)"', { timeoutMs: 200 }) + assert.equal(r.exitCode, 124) + assert.match(r.stderr, /timed out/) + } finally { + await s.dispose() + } + }) +}) + +describe('LocalRunnerSession.preview', () => { + it('returns a localhost url on the requested port when supported', async () => { + const s = await new LocalRunner({ previewHost: 'http://127.0.0.1' }).boot() + try { + assert.equal(typeof s.preview, 'function') + assert.deepEqual(await s.preview!({ port: 5173 }), { url: 'http://127.0.0.1:5173', port: 5173 }) + assert.equal((await s.preview!()).port, 3000) // default port + } finally { + await s.dispose() + } + }) + + it('omits the preview method when previews are disabled', async () => { + const s = await new LocalRunner({ preview: false }).boot() + try { + assert.equal(s.preview, undefined) + } finally { + await s.dispose() + } + }) +}) + +describe('LocalRunnerSession.dispose', () => { + it('removes the workspace and blocks further exec/preview (idempotent)', async () => { + const s = await new LocalRunner().boot({ files: { 'a.txt': 'A' } }) + const root = s.root + assert.ok(existsSync(root)) + await s.dispose() + assert.equal(s.disposed, true) + assert.equal(existsSync(root), false) + await s.dispose() // idempotent + await assert.rejects(() => s.exec('ls'), RunnerError) + await assert.rejects(() => s.preview!(), RunnerError) + }) +}) diff --git a/packages/ai-autopilot/src/runner/local.ts b/packages/ai-autopilot/src/runner/local.ts new file mode 100644 index 0000000..97c222b --- /dev/null +++ b/packages/ai-autopilot/src/runner/local.ts @@ -0,0 +1,214 @@ +import { spawn } from 'node:child_process' +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' +import type { + Runner, + RunnerSession, + RunnerFs, + BootOptions, + ExecOptions, + ExecResult, + Preview, + PreviewOptions, +} from './types.js' +import { RunnerError } from './types.js' + +export interface LocalRunnerOptions { + /** Base directory to create workspaces under. Default: the OS temp dir. */ + root?: string + /** Whether booted sessions expose a `preview`. Default `true`. */ + preview?: boolean + /** Origin returned by `preview()`, joined with the port. Default `http://localhost`. */ + previewHost?: string +} + +/** Normalize a workspace path to a canonical relative form (matches FakeFs). */ +function norm(path: string): string { + return path.replace(/^\.?\/+/, '').replace(/\/+$/, '') +} + +/** Resolve `path` inside `root`, rejecting anything that escapes the workspace. */ +function within(root: string, path: string): string { + const abs = resolve(root, norm(path)) + const rel = relative(root, abs) + if (rel === '..' || rel.startsWith('..' + sep)) { + throw new RunnerError(`path escapes the workspace: ${path}`) + } + return abs +} + +class LocalFs implements RunnerFs { + constructor(private readonly root: string) {} + + async read(path: string): Promise { + try { + return await readFile(within(this.root, path), 'utf8') + } catch (err) { + if (err instanceof RunnerError) throw err + throw new RunnerError(`no such file: ${path}`) + } + } + + async write(path: string, contents: string): Promise { + const abs = within(this.root, path) + await mkdir(dirname(abs), { recursive: true }) + await writeFile(abs, contents) + } + + async remove(path: string): Promise { + await rm(within(this.root, path), { recursive: true, force: true }) + } + + async list(dir?: string): Promise { + const base = dir ? within(this.root, dir) : this.root + const out: string[] = [] + const walk = async (abs: string): Promise => { + let entries + try { + entries = await readdir(abs, { withFileTypes: true }) + } catch { + return // missing dir → empty, mirroring the fake's prefix filter + } + for (const e of entries) { + const child = join(abs, e.name) + if (e.isDirectory()) await walk(child) + else out.push(relative(this.root, child).split(sep).join('/')) + } + } + await walk(base) + return out.sort() + } + + async exists(path: string): Promise { + try { + await access(within(this.root, path)) + return true + } catch { + return false + } + } +} + +/** + * A {@link RunnerSession} backed by a real directory on the host: a real + * filesystem, real child processes, and (optionally) a localhost preview. + */ +export class LocalRunnerSession implements RunnerSession { + readonly id: string + readonly fs: LocalFs + /** The absolute workspace directory on disk. */ + readonly root: string + disposed = false + + readonly preview?: (opts?: PreviewOptions) => Promise + + private readonly cwd: string + private readonly env: Record + + constructor( + id: string, + root: string, + boot: BootOptions, + opts: Required>, + ) { + this.id = id + this.root = root + this.fs = new LocalFs(root) + this.cwd = boot.cwd ? norm(boot.cwd) : '' + this.env = { ...(boot.env ?? {}) } + if (opts.preview) { + this.preview = async (previewOpts: PreviewOptions = {}): Promise => { + if (this.disposed) throw new RunnerError('preview on a disposed session') + const port = previewOpts.port ?? 3000 + return { url: `${opts.previewHost}:${port}`, port } + } + } + } + + 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 || '.')) + const env = { ...process.env, ...this.env, ...(opts.env ?? {}) } + return await new Promise((resolvePromise, reject) => { + const child = spawn(command, { cwd, env, shell: true }) + let stdout = '' + let stderr = '' + let timedOut = false + const timer = + opts.timeoutMs != null + ? setTimeout(() => { + timedOut = true + child.kill('SIGKILL') + }, opts.timeoutMs) + : undefined + child.stdout?.on('data', d => (stdout += d)) + child.stderr?.on('data', d => (stderr += d)) + child.on('error', err => { + if (timer) clearTimeout(timer) + reject(new RunnerError(`failed to spawn: ${(err as Error).message}`)) + }) + child.on('close', (code, signal) => { + if (timer) clearTimeout(timer) + if (timedOut) { + resolvePromise({ + stdout, + stderr: stderr + `\n[ai-autopilot] command timed out after ${opts.timeoutMs}ms`, + exitCode: 124, + }) + return + } + resolvePromise({ stdout, stderr, exitCode: code ?? (signal ? 137 : 1) }) + }) + }) + } + + async dispose(): Promise { + if (this.disposed) return + this.disposed = true + await rm(this.root, { recursive: true, force: true }) + } +} + +/** + * A {@link Runner} that boots each workspace as a real temp directory on the + * host — real files (`node:fs`), real commands (`child_process` with a shell), + * and a localhost `preview`. The first *real* adapter behind the runner seam, + * and the reference the sandboxed ones (WebContainer, Docker, Flue) mirror. + * + * It runs commands **unsandboxed on the host**, so use it only where execution + * is already trusted — local dev or a CI job that is itself the sandbox — not + * to run untrusted, agent-authored code. Reach for a sandboxed runner there. + * + * ```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 + * ``` + */ +export class LocalRunner implements Runner { + readonly kind = 'local' + + private readonly base: string + private readonly opts: Required> + + constructor(options: LocalRunnerOptions = {}) { + this.base = options.root ?? tmpdir() + this.opts = { + preview: options.preview ?? true, + previewHost: options.previewHost ?? 'http://localhost', + } + } + + async boot(opts: BootOptions = {}): Promise { + await mkdir(this.base, { recursive: true }) + const root = await mkdtemp(join(this.base, 'ai-autopilot-')) + for (const [path, contents] of Object.entries(opts.files ?? {})) { + const abs = within(root, path) + await mkdir(dirname(abs), { recursive: true }) + await writeFile(abs, contents) + } + return new LocalRunnerSession(root.split(sep).pop()!, root, opts, this.opts) + } +} From 54a1e59b5f7173bfa0b64ecfd1901c79faf57a97 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Thu, 2 Jul 2026 13:37:23 +0300 Subject: [PATCH 3/3] docs(site): categorized product grid for the family overview Replace the flat package table with a PackageGrid component grouping the user-facing packages into AI runtime and Protocol & connectors. Curation rule: only packages an app author would npm i directly. Closes #76. --- docs/.vitepress/theme/PackageGrid.vue | 167 ++++++++++++++++++++++++++ docs/.vitepress/theme/index.ts | 4 + docs/packages/index.md | 13 +- 3 files changed, 174 insertions(+), 10 deletions(-) create mode 100644 docs/.vitepress/theme/PackageGrid.vue diff --git a/docs/.vitepress/theme/PackageGrid.vue b/docs/.vitepress/theme/PackageGrid.vue new file mode 100644 index 0000000..939659a --- /dev/null +++ b/docs/.vitepress/theme/PackageGrid.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index d360e0b..c2b37cd 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -1,9 +1,13 @@ import DefaultTheme from 'vitepress/theme' import './custom.css' import type { Theme } from 'vitepress' +import PackageGrid from './PackageGrid.vue' const theme: Theme = { extends: DefaultTheme, + enhanceApp({ app }) { + app.component('PackageGrid', PackageGrid) + }, } export default theme diff --git a/docs/packages/index.md b/docs/packages/index.md index d911f19..6b06b97 100644 --- a/docs/packages/index.md +++ b/docs/packages/index.md @@ -1,15 +1,8 @@ # The GemStack family -All packages publish under the **`@gemstack/`** scope (e.g. `npm install @gemstack/ai-sdk`). Each is standalone and framework-agnostic; they compose, but you adopt only what you need. - -| Package | Description | -|---|---| -| [`ai-sdk`](/packages/ai-sdk/) | The agent runtime: providers, the agent loop, tools, streaming, middleware, structured output, memory, and evals. The engine the rest of the AI family builds on. | -| [`ai-skills`](/packages/ai-skills) | Portable capability bundles: load `SKILL.md` skills (instructions + tools + resources) and compose them onto an agent on demand. | -| [`ai-autopilot`](/packages/ai-autopilot) | Orchestration: a Supervisor that plans, dispatches subagents (bounded concurrency + budget guardrails), and synthesizes the result. | -| [`ai-mcp`](/packages/ai-mcp) | The agent/MCP bridge: consume a remote MCP server's tools as agent tools, and expose an agent as an MCP server. | -| [`mcp`](/packages/mcp) | A standalone framework for *authoring* MCP servers: tools, resources, prompts, decorators, OAuth 2.1, a framework-neutral HTTP handler, and a test client. Agent-agnostic. | -| [`connectors`](/packages/connectors) | The connector contract: `defineConnector` declares a tool connector to an external service; `mountConnectors` composes any number into one `@gemstack/mcp` server. First-party connectors: [GitHub](/packages/connector-github), [Google Drive](/packages/connector-google-drive). | +All packages publish under the **`@gemstack/`** scope (e.g. `npm install @gemstack/ai-sdk`). Each is standalone and framework-agnostic; they compose, but you adopt only what you need. The grid below lists the packages an app author installs directly, grouped by what they do. + + ## How they fit together