diff --git a/.changeset/framework-serve-gate.md b/.changeset/framework-serve-gate.md new file mode 100644 index 0000000..7f17e24 --- /dev/null +++ b/.changeset/framework-serve-gate.md @@ -0,0 +1,12 @@ +--- +"@gemstack/framework": minor +--- + +feat: `--serve` gates the loop on the app actually running + +When `--serve ` is set, the production-grade checklist no longer trusts only +the agent's review: it adopts the agent's workspace, installs/builds/starts the +app, and fetches it. A boot failure or a 5xx becomes a blocker the loop hands +back to the agent to fix, so "production-grade" means it really serves. Adds +`--serve-install`, `--serve-build`, `--serve-port`, `--serve-path`, the +`serve` option on `runFramework`, and streams serve progress to the dashboard. diff --git a/.changeset/localrunner-adopt.md b/.changeset/localrunner-adopt.md new file mode 100644 index 0000000..5c541b2 --- /dev/null +++ b/.changeset/localrunner-adopt.md @@ -0,0 +1,11 @@ +--- +"@gemstack/ai-autopilot": minor +--- + +feat(runner): `LocalRunner.adopt(dir)` binds an existing directory as the workspace + +Adopt a directory that already exists instead of booting a fresh temp one. The +session reads, execs, starts, and previews inside it exactly like a booted +session, but `dispose` does not delete it (the directory belongs to the caller). +Fills a real gap in the runner seam: running or verifying code that another tool +already wrote to disk. diff --git a/packages/ai-autopilot/src/runner/local.test.ts b/packages/ai-autopilot/src/runner/local.test.ts index f6b7df1..11611ee 100644 --- a/packages/ai-autopilot/src/runner/local.test.ts +++ b/packages/ai-autopilot/src/runner/local.test.ts @@ -1,6 +1,9 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' import { existsSync } from 'node:fs' +import { mkdtemp, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { createServer } from 'node:net' import { LocalRunner } from './local.js' import { RunnerError } from './types.js' @@ -211,3 +214,32 @@ describe('LocalRunnerSession.start (boot and serve)', () => { assert.notEqual(result.exitCode, 0) // killed, not a clean exit }) }) + +describe('LocalRunner.adopt', () => { + it('runs inside an existing directory and does NOT delete it on dispose', async () => { + const dir = await mkdtemp(join(tmpdir(), 'adopt-')) + try { + await writeFile(join(dir, 'greeting.txt'), 'hello from an existing dir') + const s = await new LocalRunner().adopt(dir) + assert.equal(await s.fs.read('greeting.txt'), 'hello from an existing dir') + const { stdout } = await s.exec('node -e "process.stdout.write(String(1+1))"') + assert.equal(stdout, '2') + await s.dispose() + assert.equal(existsSync(dir), true) // the caller's directory survives dispose + assert.equal(existsSync(join(dir, 'greeting.txt')), true) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('writes seed files into the adopted directory', async () => { + const dir = await mkdtemp(join(tmpdir(), 'adopt-')) + try { + const s = await new LocalRunner().adopt(dir, { files: { 'pkg/config.json': '{"ok":true}' } }) + assert.equal(await s.fs.read('pkg/config.json'), '{"ok":true}') + await s.dispose() + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/ai-autopilot/src/runner/local.ts b/packages/ai-autopilot/src/runner/local.ts index 5a684c0..2309e73 100644 --- a/packages/ai-autopilot/src/runner/local.ts +++ b/packages/ai-autopilot/src/runner/local.ts @@ -131,14 +131,19 @@ export class LocalRunnerSession implements RunnerSession { /** Long-running processes started with {@link start}, so `dispose` can stop them. */ private readonly procs = new Set() + /** When true, `dispose` leaves the workspace on disk (an adopted directory). */ + private readonly keep: boolean + constructor( id: string, root: string, boot: BootOptions, opts: Required>, + keep = false, ) { this.id = id this.root = root + this.keep = keep this.fs = new LocalFs(root) this.cwd = boot.cwd ? norm(boot.cwd) : '' this.env = { ...(boot.env ?? {}) } @@ -248,7 +253,8 @@ export class LocalRunnerSession implements RunnerSession { 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 }) + // An adopted workspace is not ours to delete; only remove one we created. + if (!this.keep) await rm(this.root, { recursive: true, force: true }) } } @@ -297,4 +303,22 @@ export class LocalRunner implements Runner { } return new LocalRunnerSession(root.split(sep).pop()!, root, opts, this.opts) } + + /** + * Adopt an **existing** directory as the workspace instead of creating a fresh + * temp one. The returned session reads, runs, starts, and previews inside + * `dir` exactly like a booted one, but `dispose` does NOT delete it, since the + * directory belongs to the caller. Use this to run or verify code that already + * lives on disk, e.g. an app another tool (a wrapped coding agent) just wrote. + */ + async adopt(dir: string, opts: BootOptions = {}): Promise { + const root = resolve(dir) + await mkdir(root, { recursive: true }) + 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, root, opts, this.opts, true) + } } diff --git a/packages/framework/src/cli.ts b/packages/framework/src/cli.ts index d803cce..f7b268c 100644 --- a/packages/framework/src/cli.ts +++ b/packages/framework/src/cli.ts @@ -5,7 +5,7 @@ import { ClaudeCodeDriver, type ClaudeCodeDriverOptions, type Driver, type Permi import { hostExecutor } from './host-exec.js' import { startDashboard, type Dashboard } from './dashboard/index.js' import { formatFrameworkEvent, type FrameworkEvent } from './events.js' -import { runFramework, type DeployDecision, type RunFrameworkOptions } from './run.js' +import { runFramework, type DeployDecision, type RunFrameworkOptions, type ServeConfig } from './run.js' import { FAKE_DEPLOY, FAKE_INTENT, FAKE_SIGNALS, fakeDriver } from './fake-script.js' /** Where the CLI writes. Injectable so tests capture output. */ @@ -36,6 +36,11 @@ Options: --permission-mode Claude Code permission mode: default | acceptEdits | bypassPermissions | plan (default: acceptEdits). --dangerously-skip-permissions Bypass all agent permission checks (sandboxes only). + --serve Gate the loop on the app actually running (e.g. "npm run dev"). + --serve-install Install command before serving (e.g. "npm install"). + --serve-build Build command before serving (e.g. "npm run build"). + --serve-port Port the app listens on (default: 3000). + --serve-path Path to health-check once it is up (default: /). --deploy Deploy to this target (cloudflare, dokploy) or narrate any other. --cf-project Cloudflare Pages project name (for a Pages deploy). --dokploy-url Dokploy instance URL (required for --deploy dokploy). @@ -65,6 +70,11 @@ export interface CliOptions { cfProject?: string | undefined dokployUrl?: string | undefined dokployApp?: string | undefined + serve?: string | undefined + serveInstall?: string | undefined + serveBuild?: string | undefined + servePort?: number + servePath?: string | undefined port?: number dashboard: boolean sessionLink?: string | undefined @@ -133,6 +143,24 @@ export function parseArgs(argv: string[]): CliOptions { case '--dokploy-app': opts.dokployApp = argv[++i] break + case '--serve': + opts.serve = argv[++i] + break + case '--serve-install': + opts.serveInstall = argv[++i] + break + case '--serve-build': + opts.serveBuild = argv[++i] + break + case '--serve-path': + opts.servePath = argv[++i] + break + case '--serve-port': { + const n = Number(argv[++i]) + if (!Number.isInteger(n) || n < 1) opts.error = `invalid --serve-port: must be a positive integer` + else opts.servePort = n + break + } case '--session-link': opts.sessionLink = argv[++i] break @@ -219,6 +247,16 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise emit({ kind: 'driver', event }), }) + // Boot-and-serve gate: adopt the agent's workspace so the checklist can gate + // on the app actually running (mergeChecklists unions the agent review with a + // real serveCheck). The runner adopts, never deletes, the driver's cwd. + let runner: LocalRunnerSession | undefined + if (opts.serve) runner = await new LocalRunner().adopt(opts.cwd) + const s = opts.serve + const checklist = + runner && s + ? mergeChecklists( + driverChecklist(session), + serveCheck(runner, { + serve: s.command, + ...(s.install ? { install: s.install } : {}), + ...(s.build ? { build: s.build } : {}), + ...(s.port !== undefined ? { port: s.port } : {}), + ...(s.waitMs !== undefined ? { waitMs: s.waitMs } : {}), + ...(s.healthPath ? { healthPath: s.healthPath } : {}), + onProgress: message => emit({ kind: 'log', message: `serve: ${message}` }), + }), + ) + : driverChecklist(session) + const ledger = new DecisionLedger() try { const bootstrap = new Bootstrap({ @@ -120,7 +172,7 @@ export async function runFramework(opts: RunFrameworkOptions): Promise ({ scope: opts.scope ?? 'full', intent: opts.intent }), architect: driverArchitect(session), build: driverBuild(session), - checklist: driverChecklist(session), + checklist, improve: driverImprove(session), ...(opts.deploy && opts.deployTarget ? { deploy: deployWith(opts.deploy, opts.deployTarget) } @@ -137,5 +189,6 @@ export async function runFramework(opts: RunFrameworkOptions): Promise { + return new Promise((resolvePromise, rejectPromise) => { + const srv = createServer() + srv.on('error', rejectPromise) + srv.listen(0, '127.0.0.1', () => { + const addr = srv.address() + const port = typeof addr === 'object' && addr ? addr.port : 0 + srv.close(() => resolvePromise(port)) + }) + }) +} + +const CLEAN_REVIEW = [ + { text: '```json\n{"stack":"Node HTTP","narration":"n","decisions":[]}\n```' }, + { text: 'built the app' }, + { text: 'reviewed\n```json\n{"blockers":[]}\n```' }, +] + +test('serve gate: production-grade only when the agent review AND the real server pass', async () => { + const dir = await mkdtemp(join(tmpdir(), 'fw-serve-')) + const port = await freePort() + try { + // The "app the agent built": a tiny server that boots on `port`. + await writeFile(join(dir, 'server.js'), `require('http').createServer((_,res)=>res.end('ok')).listen(${port})\n`) + const events: FrameworkEvent[] = [] + const { result } = await runFramework({ + intent: 'a tiny http service', + driver: new FakeDriver({ turns: CLEAN_REVIEW }), + cwd: dir, + signals: FAKE_SIGNALS, + serve: { command: 'node server.js', port, waitMs: 5000 }, + onEvent: e => events.push(e), + }) + assert.equal(result.productionGrade, true) + assert.equal(result.passes, 1) + assert.ok(events.some(e => e.kind === 'log' && e.message.startsWith('serve:'))) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('serve gate: a server that never boots blocks, even when the agent review is clean', async () => { + const dir = await mkdtemp(join(tmpdir(), 'fw-serve-')) + const port = await freePort() + try { + // A "server" that exits immediately, so nothing ever serves. + await writeFile(join(dir, 'server.js'), `process.exit(0)\n`) + const { result } = await runFramework({ + intent: 'a broken service', + driver: new FakeDriver({ turns: CLEAN_REVIEW }), + cwd: dir, + signals: FAKE_SIGNALS, + maxPasses: 1, + serve: { command: 'node server.js', port, waitMs: 1500 }, + }) + assert.equal(result.productionGrade, false) + assert.ok(result.blockers.length > 0) + } finally { + await rm(dir, { recursive: true, force: true }) + } +})