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
12 changes: 12 additions & 0 deletions .changeset/framework-serve-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@gemstack/framework": minor
---

feat: `--serve` gates the loop on the app actually running

When `--serve <cmd>` 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.
11 changes: 11 additions & 0 deletions .changeset/localrunner-adopt.md
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 32 additions & 0 deletions packages/ai-autopilot/src/runner/local.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 })
}
})
})
26 changes: 25 additions & 1 deletion packages/ai-autopilot/src/runner/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RunnerProcess>()

/** When true, `dispose` leaves the workspace on disk (an adopted directory). */
private readonly keep: boolean

constructor(
id: string,
root: string,
boot: BootOptions,
opts: Required<Pick<LocalRunnerOptions, 'preview' | 'previewHost'>>,
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 ?? {}) }
Expand Down Expand Up @@ -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 })
}
}

Expand Down Expand Up @@ -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<LocalRunnerSession> {
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)
}
}
41 changes: 40 additions & 1 deletion packages/framework/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -36,6 +36,11 @@ Options:
--permission-mode <mode> Claude Code permission mode: default | acceptEdits |
bypassPermissions | plan (default: acceptEdits).
--dangerously-skip-permissions Bypass all agent permission checks (sandboxes only).
--serve <cmd> Gate the loop on the app actually running (e.g. "npm run dev").
--serve-install <cmd> Install command before serving (e.g. "npm install").
--serve-build <cmd> Build command before serving (e.g. "npm run build").
--serve-port <n> Port the app listens on (default: 3000).
--serve-path <path> Path to health-check once it is up (default: /).
--deploy <target> Deploy to this target (cloudflare, dokploy) or narrate any other.
--cf-project <name> Cloudflare Pages project name (for a Pages deploy).
--dokploy-url <url> Dokploy instance URL (required for --deploy dokploy).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -219,6 +247,16 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
deployTarget = built.target
}

const serve: ServeConfig | undefined = opts.serve
? {
command: opts.serve,
...(opts.serveInstall ? { install: opts.serveInstall } : {}),
...(opts.serveBuild ? { build: opts.serveBuild } : {}),
...(opts.servePort !== undefined ? { port: opts.servePort } : {}),
...(opts.servePath ? { healthPath: opts.servePath } : {}),
}
: undefined

let dashboard: Dashboard | undefined
if (opts.dashboard) {
try {
Expand All @@ -244,6 +282,7 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
...(opts.maxPasses ? { maxPasses: opts.maxPasses } : {}),
...(deploy ? { deploy } : {}),
...(deployTarget ? { deployTarget } : {}),
...(serve ? { serve } : {}),
...(fake ? { signals: FAKE_SIGNALS } : {}),
...(opts.sessionLink ? { sessionLink: opts.sessionLink } : {}),
}
Expand Down
8 changes: 7 additions & 1 deletion packages/framework/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,13 @@ export {
PRODUCTION_GRADE_PROMPT,
type DriverStepOptions,
} from './steps.js'
export { runFramework, type RunFrameworkOptions, type RunFrameworkResult, type DeployDecision } from './run.js'
export {
runFramework,
type RunFrameworkOptions,
type RunFrameworkResult,
type DeployDecision,
type ServeConfig,
} from './run.js'
export { hostExecutor, type HostExecutorOptions } from './host-exec.js'
export { type FrameworkEvent, formatFrameworkEvent } from './events.js'
export { startDashboard, dashboardHtml, type Dashboard, type DashboardOptions } from './dashboard/index.js'
Expand Down
55 changes: 54 additions & 1 deletion packages/framework/src/run.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import {
Bootstrap,
DecisionLedger,
LocalRunner,
builtinPresetRegistry,
mergeChecklists,
personaInstructions,
presetPersonas,
serveCheck,
type BootstrapEvent,
type BootstrapResult,
type BootstrapScope,
type DeployTarget,
type FrameworkDetection,
type FrameworkSignals,
type LocalRunnerSession,
} from '@gemstack/ai-autopilot'
import type { Driver, DriverSession } from './driver/index.js'
import { decideDeploy, deployWith, driverArchitect, driverBuild, driverChecklist, driverImprove } from './steps.js'
Expand All @@ -22,6 +26,27 @@ export interface DeployDecision {
reason: string
}

/**
* How to actually boot and serve the generated app so the loop can gate on it
* *running*, not just on an agent's review. When set, the production-grade
* checklist also installs, (builds,) starts the app, and fetches it; a failure
* becomes a blocker the loop hands back to the agent to fix.
*/
export interface ServeConfig {
/** The command that starts the app (e.g. `npm run dev`). */
command: string
/** Install command run first (e.g. `npm install`). */
install?: string
/** Build command run after install (e.g. `npm run build`). */
build?: string
/** Port the app listens on. Default 3000. */
port?: number
/** How long to wait for it to accept connections. Default 15000ms. */
waitMs?: number
/** Path to fetch once it is up. Default `/`. */
healthPath?: string
}

/** Options for {@link runFramework}. */
export interface RunFrameworkOptions {
/** What the user wants built (the one scope question's answer). */
Expand All @@ -46,6 +71,11 @@ export interface RunFrameworkOptions {
* narrate a plan-only decision.
*/
deployTarget?: DeployTarget
/**
* Boot-and-serve verification for the full-fledged loop: when set, the
* checklist gates on the app actually running, not just an agent review.
*/
serve?: ServeConfig
/** A claude.ai/code (or other) link to the live agent session, for the dashboard. */
sessionLink?: string
/** Interrupt the run between phases. */
Expand Down Expand Up @@ -109,6 +139,28 @@ export async function runFramework(opts: RunFrameworkOptions): Promise<RunFramew
onEvent: event => 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({
Expand All @@ -120,7 +172,7 @@ export async function runFramework(opts: RunFrameworkOptions): Promise<RunFramew
scope: () => ({ 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) }
Expand All @@ -137,5 +189,6 @@ export async function runFramework(opts: RunFrameworkOptions): Promise<RunFramew
throw err
} finally {
await session.dispose()
if (runner) await runner.dispose()
}
}
73 changes: 73 additions & 0 deletions packages/framework/src/serve-gate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { mkdtemp, writeFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createServer } from 'node:net'
import { FakeDriver } from './driver/index.js'
import { runFramework } from './run.js'
import { FAKE_SIGNALS } from './fake-script.js'
import type { FrameworkEvent } from './events.js'

/** An ephemeral free port so parallel test runs do not collide. */
function freePort(): Promise<number> {
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 })
}
})
Loading