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
5 changes: 5 additions & 0 deletions .changeset/ai-autopilot-bootstrap-deploy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@gemstack/ai-autopilot": minor
---

Add bootstrap's deploy phase and the `DeployTarget` adapter seam. The final phase decides the rendering mode (SSR/SSG/SPA) and the deploy target (Dockploy vs Cloudflare), narrates the plan, and hands it to a `DeployTarget` — the same pattern as the runner seam. `agentDeploy` is the default step (an `ai-sdk` agent decides `{ render, target, reason }`, normalized against the allowed sets); `planOnlyTarget` is the v1 default that decides and narrates without shipping, and `FakeDeployTarget` backs tests. v1 decides + narrates only; real Dockploy / Cloudflare adapters implement `DeployTarget` and are infra-gated follow-ups, so bootstrap never does a blind deploy. The deploy step is optional and its outcome rides on `BootstrapResult.deploy`. Closes #123.
59 changes: 59 additions & 0 deletions packages/ai-autopilot/src/bootstrap/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,65 @@ describe('Bootstrap — prototype scope', () => {
})
})

describe('Bootstrap — deploy phase', () => {
const deployStub = (): NonNullable<BootstrapSteps['deploy']> => () => ({
plan: { render: 'ssr', target: 'dockploy', reason: 'per-request data' },
result: { deployed: false, detail: 'plan only' },
})

it('runs the deploy step last and carries its outcome on the result', async () => {
const events: BootstrapEvent[] = []
const boot = new Bootstrap({ steps: stubSteps({ deploy: deployStub() }), onEvent: e => events.push(e) })
const result = await boot.run()

assert.equal(result.deploy?.plan.render, 'ssr')
assert.equal(result.deploy?.plan.target, 'dockploy')
assert.equal(result.deploy?.result.deployed, false)
// deploy narration + event land after the checklist, before done
const types = events.map(e => e.type)
assert.deepEqual(types.slice(-3), ['narrate', 'deploy', 'done'])
const deployEvent = events.find(e => e.type === 'deploy')
assert.equal(deployEvent?.type === 'deploy' && deployEvent.plan.target, 'dockploy')
})

it('is optional — no deploy step means no deploy on the result', async () => {
const boot = new Bootstrap({ steps: stubSteps() })
const result = await boot.run()
assert.equal(result.deploy, undefined)
})

it('passes productionGrade into the deploy context', async () => {
let seenProductionGrade: boolean | undefined
const boot = new Bootstrap({
steps: stubSteps({
deploy: ctx => {
seenProductionGrade = ctx.productionGrade
return { plan: { render: 'ssg', target: 'cloudflare', reason: 'static' }, result: { deployed: false } }
},
}),
})
await boot.run()
assert.equal(seenProductionGrade, true) // full scope, passed the checklist
})

it('runs deploy for a prototype too (not loop-gated)', async () => {
let deployRan = false
const boot = new Bootstrap({
steps: stubSteps({
scope: () => ({ scope: 'prototype', intent: 'quick demo' }),
deploy: ctx => {
deployRan = true
assert.equal(ctx.productionGrade, false) // prototype never runs the loop
return { plan: { render: 'spa', target: 'cloudflare', reason: 'client demo' }, result: { deployed: false } }
},
}),
})
const result = await boot.run()
assert.equal(deployRan, true)
assert.equal(result.deploy?.plan.render, 'spa')
})
})

describe('Bootstrap — interrupt + isolation', () => {
it('aborts between phases when the signal fires', async () => {
const controller = new AbortController()
Expand Down
22 changes: 21 additions & 1 deletion packages/ai-autopilot/src/bootstrap/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
BootstrapOptions,
BootstrapResult,
BootstrapSteps,
DeployOutcome,
} from './types.js'

/** Thrown when a run is aborted via the `AbortSignal`. */
Expand Down Expand Up @@ -132,15 +133,34 @@ export class Bootstrap {
}
}

const productionGrade = passes > 0 && blockers.length === 0

// 5. Deploy — the final phase: decide SSR/SSG/SPA + target, narrate, and hand
// the plan to a DeployTarget. v1 targets are plan-only (they do not ship).
let deploy: DeployOutcome | undefined
if (this.steps.deploy) {
this.throwIfAborted()
this.emit({ type: 'narrate', phase: 'deploy', message: 'Deciding how and where to deploy' })
deploy = await this.steps.deploy({
plan,
scope,
intent,
productionGrade,
...(this.signal ? { signal: this.signal } : {}),
})
this.emit({ type: 'deploy', plan: deploy.plan, result: deploy.result })
}

const result: BootstrapResult = {
scope,
intent,
plan,
run,
passes,
blockers,
productionGrade: passes > 0 && blockers.length === 0,
productionGrade,
stoppedEarly,
...(deploy ? { deploy } : {}),
}
this.emit({ type: 'done', result })
return result
Expand Down
89 changes: 89 additions & 0 deletions packages/ai-autopilot/src/bootstrap/deploy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { AiFake, agent } from '@gemstack/ai-sdk'
import { agentDeploy, planOnlyTarget, FakeDeployTarget, DEFAULT_DEPLOY_TARGETS } from './deploy.js'
import type { DeployContext } from './types.js'

const ctx = (over: Partial<DeployContext> = {}): DeployContext => ({
plan: { stack: 'Vike + universal-orm', narration: '', decisions: [] },
scope: 'full',
intent: 'a shop',
productionGrade: true,
...over,
})

describe('planOnlyTarget (v1 default seam)', () => {
it('decides + narrates only — never reports a deploy', async () => {
const target = planOnlyTarget()
const result = await target.deploy({ plan: { render: 'ssr', target: 'dockploy', reason: 'x' }, intent: 'a shop' })
assert.equal(result.deployed, false)
assert.match(result.detail ?? '', /infra-gated/)
})
})

describe('FakeDeployTarget', () => {
it('records the plans it received and returns a canned result', () => {
const target = new FakeDeployTarget({ result: { deployed: true, url: 'https://x.example' } })
const plan = { render: 'ssg' as const, target: 'cloudflare', reason: 'static' }
const result = target.deploy({ plan, intent: 'a shop' })
assert.equal(result.url, 'https://x.example')
assert.deepEqual(target.deployed, [plan])
})
})

describe('agentDeploy (default step over an ai-sdk agent)', () => {
it('decides { render, target, reason } and hands the plan to the target', async () => {
const fake = AiFake.fake()
try {
fake.respondWithSequence([
{ text: JSON.stringify({ render: 'ssr', target: 'dockploy', reason: 'auth + per-request data' }) },
])
const target = new FakeDeployTarget()
const step = agentDeploy(agent({ instructions: 'deployer' }), { target })
const outcome = await step(ctx())

assert.deepEqual(outcome.plan, { render: 'ssr', target: 'dockploy', reason: 'auth + per-request data' })
assert.equal(outcome.result.deployed, true)
assert.equal(target.deployed.length, 1) // the plan reached the target
} finally {
fake.restore()
}
})

it('defaults to a plan-only target when none is wired (v1 decides, does not ship)', async () => {
const fake = AiFake.fake()
try {
fake.respondWithSequence([{ text: JSON.stringify({ render: 'ssg', target: 'cloudflare', reason: 'static' }) }])
const outcome = await agentDeploy(agent({ instructions: 'deployer' }))(ctx())
assert.equal(outcome.plan.target, 'cloudflare')
assert.equal(outcome.result.deployed, false)
} finally {
fake.restore()
}
})

it('normalizes an out-of-set target to the first allowed one', async () => {
const fake = AiFake.fake()
try {
fake.respondWithSequence([{ text: JSON.stringify({ render: 'ssr', target: 'heroku', reason: 'habit' }) }])
const outcome = await agentDeploy(agent({ instructions: 'deployer' }))(ctx())
assert.equal(outcome.plan.target, DEFAULT_DEPLOY_TARGETS[0]) // 'heroku' is not allowed → 'dockploy'
} finally {
fake.restore()
}
})

it('steers the decision with a custom target list', async () => {
const fake = AiFake.fake()
try {
fake.respondWithSequence([{ text: JSON.stringify({ render: 'spa', target: 'fly', reason: 'edge' }) }])
const outcome = await agentDeploy(agent({ instructions: 'deployer' }), { targets: ['fly', 'render'] })(ctx())
assert.equal(outcome.plan.target, 'fly')
// the allowed targets reached the model
const sent = JSON.stringify(fake.getCalls()[0])
assert.match(sent, /fly/)
} finally {
fake.restore()
}
})
})
125 changes: 125 additions & 0 deletions packages/ai-autopilot/src/bootstrap/deploy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { Output } from '@gemstack/ai-sdk'
import type { Agent } from '@gemstack/ai-sdk'
import { z } from 'zod'
import type {
BootstrapSteps,
DeployPlan,
DeployResult,
DeployTarget,
DeployTargetContext,
RenderMode,
} from './types.js'

/**
* The deploy phase — decide SSR/SSG/SPA + a target, narrate the plan, and hand it
* to a {@link DeployTarget}. Deciding is this module's job; *executing* the plan
* is the target's, behind the adapter seam (the same shape as the runner seam).
*
* v1 ships only {@link planOnlyTarget} (decides + narrates, does not ship) and
* {@link FakeDeployTarget} (tests). Real Dockploy / Cloudflare adapters implement
* {@link DeployTarget} and are infra-gated follow-ups — so bootstrap never does a
* blind deploy now.
*/

/** The target names the deploy step steers toward by default. */
export const DEFAULT_DEPLOY_TARGETS = ['dockploy', 'cloudflare'] as const

const RENDER_MODES: readonly RenderMode[] = ['ssr', 'ssg', 'spa']

/**
* A plan-only {@link DeployTarget}: it reports that nothing was shipped, so the
* flow decides + narrates without a blind deploy. This is the v1 default and the
* reference implementation of the seam.
*/
export function planOnlyTarget(name = 'plan-only'): DeployTarget {
return {
name,
deploy: () => ({
deployed: false,
detail: 'Decided and narrated only — no deploy adapter is wired (infra-gated).',
}),
}
}

/** Options for {@link FakeDeployTarget}. */
export interface FakeDeployTargetOptions {
name?: string
/** The result to return; defaults to a shipped result with a fake URL. */
result?: DeployResult
}

/** An in-memory {@link DeployTarget} for tests: records the plan and returns a canned result. */
export class FakeDeployTarget implements DeployTarget {
readonly name: string
private readonly result: DeployResult
/** Every plan handed to {@link deploy}, in order. */
readonly deployed: DeployPlan[] = []

constructor(opts: FakeDeployTargetOptions = {}) {
this.name = opts.name ?? 'fake'
this.result = opts.result ?? { deployed: true, url: 'https://fake.deploy.example', detail: 'fake deploy' }
}

deploy(ctx: DeployTargetContext): DeployResult {
this.deployed.push(ctx.plan)
return this.result
}
}

/** Options for {@link agentDeploy}. */
export interface AgentDeployOptions {
/** The target that executes the plan. Defaults to {@link planOnlyTarget} (v1). */
target?: DeployTarget
/** Allowed target names to steer the decision. Default {@link DEFAULT_DEPLOY_TARGETS}. */
targets?: readonly string[]
/** Override the deploy instruction prepended to the app context. */
instructions?: string
}

const DEFAULT_DEPLOY_INSTRUCTIONS = `You are deciding how to ship this app. Choose the rendering mode and the deploy
target that fit what was built, and explain the choice in one line — act like an
engineer who decides, not one who asks. Pick SSR when pages need per-request data
or auth, SSG when content is mostly static and can be prebuilt, and SPA only when
the app is a client-side dashboard behind a login.`

/**
* A deploy step backed by an `ai-sdk` agent: it asks for a structured
* `{ render, target, reason }` decision (validated against the allowed render
* modes + target names), then hands the plan to the {@link DeployTarget}. With no
* target wired it uses {@link planOnlyTarget}, so v1 decides and narrates without
* shipping.
*/
export function agentDeploy(deployer: Agent, opts: AgentDeployOptions = {}): NonNullable<BootstrapSteps['deploy']> {
const targets = opts.targets && opts.targets.length ? opts.targets : DEFAULT_DEPLOY_TARGETS
const target = opts.target ?? planOnlyTarget()
const instructions = opts.instructions ?? DEFAULT_DEPLOY_INSTRUCTIONS
const schema = z.object({
render: z.enum(['ssr', 'ssg', 'spa']).describe('How the app is rendered/served'),
target: z.string().describe(`One of: ${targets.join(', ')}`),
reason: z.string().describe('One-line rationale'),
})
const output = Output.object({ schema })

return async ({ plan: architecture, scope, intent, productionGrade, signal }) => {
const context = [
`# What the user wanted (${scope})`,
intent,
`# Stack`,
architecture.stack,
`# Production-grade`,
productionGrade ? 'the app passed the production-grade checklist' : 'not yet fully production-grade',
`# Allowed targets`,
targets.join(', '),
].join('\n')
const response = await deployer.prompt(`${instructions}\n\n${context}\n\n${output.toSystemPrompt()}`)
const decided = output.parse(response.text ?? '')

// Normalize the decision against the allowed sets so a stray value cannot slip through.
const render: RenderMode = RENDER_MODES.includes(decided.render) ? decided.render : 'ssr'
const targetName = targets.includes(decided.target) ? decided.target : targets[0]!
const deployPlan: DeployPlan = { render, target: targetName, reason: decided.reason }

const result = await target.deploy({ plan: deployPlan, intent, ...(signal ? { signal } : {}) })
return { plan: deployPlan, result }
}
}
15 changes: 15 additions & 0 deletions packages/ai-autopilot/src/bootstrap/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,32 @@ export {
type LoopChecklistOptions,
type LoopImproveOptions,
} from './steps.js'
export {
agentDeploy,
planOnlyTarget,
FakeDeployTarget,
DEFAULT_DEPLOY_TARGETS,
type AgentDeployOptions,
type FakeDeployTargetOptions,
} from './deploy.js'
export type {
BootstrapScope,
BootstrapPhase,
ScopeAnswer,
ArchitectDecision,
ArchitectPlan,
RenderMode,
DeployPlan,
DeployResult,
DeployOutcome,
DeployTarget,
DeployTargetContext,
BootstrapEvent,
BootstrapResult,
BootstrapSteps,
BootstrapOptions,
BuildContext,
ArchitectContext,
DeployContext,
LoopPassContext,
} from './types.js'
Loading
Loading