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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ellipsis/cli",
"version": "0.12.0",
"version": "0.13.0",
"description": "Ellipsis agent CLI: drive the Ellipsis cloud from your terminal",
"license": "MIT",
"type": "module",
Expand Down
164 changes: 163 additions & 1 deletion src/commands/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@ import { type Command } from 'commander'
import { readFileSync } from 'node:fs'
import { ApiClient } from '../lib/api'
import { formatTs, printJson, printTable, runAction } from '../lib/output'
import type { SandboxVariableInput, SandboxVariableSummary } from '../lib/types'
import type {
SandboxBuild,
SandboxVariableInput,
SandboxVariableSummary,
} from '../lib/types'

export function registerSandbox(program: Command): void {
const sandbox = program.command('sandbox').description('Manage sandbox resources')

const variable = sandbox
.command('variable')
// Resource sub-groups register their plural as an alias so the two
// spellings can never diverge into different surfaces.
.alias('variables')
.alias('var')
.description('Manage sandbox environment variables (values are write-only)')

Expand Down Expand Up @@ -53,6 +60,161 @@ export function registerSandbox(program: Command): void {
printVariables(variables, opts.json)
})
})

const build = sandbox
.command('build')
.alias('builds')
.description(
"Build an agent config's sandbox environment with no agent — docker build for the sandbox; a successful build pre-warms the image cache",
)

build
.command('start')
.description('Start a build (POST /v1/sandboxes/builds)')
.option('-f, --config-file <path>', 'agent config YAML file to build (test before merge)')
.option('-c, --config <configId>', 'saved agent config id to build')
.option('--hooks', 'also exercise post_start/post_clone in the built box')
.option('-w, --watch', 'stream the build log until it finishes (exit 0/1 with the result)')
.option('--json', 'output the raw build record')
.action(
async (opts: {
configFile?: string
config?: string
hooks?: boolean
watch?: boolean
json?: boolean
}) => {
await runAction(async () => {
if (!opts.configFile === !opts.config) {
throw new Error('provide exactly one of --config-file or --config')
}
const api = new ApiClient()
const build = await api.startSandboxBuild({
config_yaml: opts.configFile ? readFileSync(opts.configFile, 'utf8') : undefined,
config_id: opts.config,
hooks: opts.hooks ?? false,
})
if (!opts.watch) {
if (opts.json) printJson(build)
else {
console.log(`✓ build queued: ${build.id}`)
console.log(` follow it with: agent sandbox build logs ${build.id} --watch`)
}
return
}
const finished = await watchBuild(api, build.id, 0)
if (opts.json) printJson(finished)
else printBuildSummary(finished)
if (finished.status !== 'succeeded') process.exitCode = 1
})
},
)

build
.command('list')
.alias('ls')
.description('List recent builds (GET /v1/sandboxes/builds)')
.option('--limit <n>', 'max builds to return', '20')
.option('--json', 'output raw JSON')
.action(async (opts: { limit: string; json?: boolean }) => {
await runAction(async () => {
const builds = await new ApiClient().listSandboxBuilds(Number(opts.limit))
if (opts.json) {
printJson(builds)
return
}
if (builds.length === 0) {
console.log('No sandbox builds.')
return
}
printTable(
['ID', 'STATUS', 'PHASE', 'TIER', 'HOOKS', 'CREATED'],
builds.map((b) => [
b.id,
b.status,
b.phase ?? '-',
b.cache_tier ?? '-',
b.hooks_requested ? 'yes' : 'no',
formatTs(b.created_at),
]),
)
})
})

build
.command('get <buildId>')
.description("One build's summary (GET /v1/sandboxes/builds/{id})")
.option('--json', 'output raw JSON')
.action(async (buildId: string, opts: { json?: boolean }) => {
await runAction(async () => {
const build = await new ApiClient().getSandboxBuild(buildId)
if (opts.json) printJson(build)
else printBuildSummary(build)
})
})

build
.command('logs <buildId>')
.description("A build's log (GET /v1/sandboxes/builds/{id}/logs)")
.option('--after-seq <n>', 'resume after this line number', '0')
.option('-w, --watch', 'follow the log until the build finishes')
.option('--json', 'output raw JSON log lines')
.action(
async (buildId: string, opts: { afterSeq: string; watch?: boolean; json?: boolean }) => {
await runAction(async () => {
const api = new ApiClient()
const afterSeq = Number(opts.afterSeq)
if (!opts.watch) {
const lines = await api.getSandboxBuildLogs(buildId, afterSeq)
if (opts.json) printJson(lines)
else for (const line of lines) console.log(line.line)
return
}
const finished = await watchBuild(api, buildId, afterSeq)
if (!opts.json) printBuildSummary(finished)
if (finished.status !== 'succeeded') process.exitCode = 1
})
},
)
}

const WATCH_POLL_MS = 1500

// Follow a build docker-build-style: print new log lines as they land, until
// the build reaches a terminal status. Polls the historical /logs read (the
// same seq sequence the WS serves); a final drain after the terminal status
// catches lines flushed with the finalize.
async function watchBuild(
api: ApiClient,
buildId: string,
afterSeq: number,
): Promise<SandboxBuild> {
let seq = afterSeq
for (;;) {
const build = await api.getSandboxBuild(buildId)
for (;;) {
const lines = await api.getSandboxBuildLogs(buildId, seq)
if (lines.length === 0) break
for (const line of lines) {
console.log(line.line)
seq = line.seq
}
}
if (build.status !== 'queued' && build.status !== 'running') return build
await new Promise((resolve) => setTimeout(resolve, WATCH_POLL_MS))
}
}

function printBuildSummary(build: SandboxBuild): void {
console.log(`${build.id}: ${build.status}`)
if (build.cache_tier) console.log(` cache tier ${build.cache_tier}`)
const timings = Object.entries(build.phase_timings)
if (timings.length > 0) {
console.log(` phases ${timings.map(([p, s]) => `${p} ${s}s`).join(' · ')}`)
}
if (build.result_image_id) console.log(` image ${build.result_image_id} (cache pre-warmed)`)
if (build.status_reason) console.log(` reason ${build.status_reason}`)
if (build.failing_phase) console.log(` failed in ${build.failing_phase}`)
}

// Build the upsert batch from inline `KEY=VALUE` arguments and/or a file. File
Expand Down
41 changes: 41 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ import type {
StartAgentSessionRequest,
UsageDashboard,
WhoAmI,
GetSandboxBuildLogsResponse,
ListSandboxBuildsResponse,
SandboxBuild,
SandboxBuildLogLine,
StartSandboxBuildRequest,
} from './types'

// Thin REST client over the public `/v1` API. The typed request/response
Expand Down Expand Up @@ -352,6 +357,42 @@ export class ApiClient {
return res.variables
}

// ----------------------------- sandbox builds ---------------------------
// "docker build" for the Ellipsis sandbox (test a config's environment
// definition, streamed logs, cache pre-warm on success).

startSandboxBuild(request: StartSandboxBuildRequest): Promise<SandboxBuild> {
return this.request('POST', '/v1/sandboxes/builds', request)
}

async listSandboxBuilds(limit?: number): Promise<SandboxBuild[]> {
const res = await this.request<ListSandboxBuildsResponse>(
'GET',
'/v1/sandboxes/builds',
undefined,
{ limit },
)
return res.builds
}

getSandboxBuild(buildId: string): Promise<SandboxBuild> {
return this.request('GET', `/v1/sandboxes/builds/${encodeURIComponent(buildId)}`)
}

async getSandboxBuildLogs(
buildId: string,
afterSeq?: number,
limit?: number,
): Promise<SandboxBuildLogLine[]> {
const res = await this.request<GetSandboxBuildLogsResponse>(
'GET',
`/v1/sandboxes/builds/${encodeURIComponent(buildId)}/logs`,
undefined,
{ after_seq: afterSeq, limit },
)
return res.lines
}

// ---------------------------- agent templates ---------------------------

async listAgentTemplates(): Promise<AgentTemplate[]> {
Expand Down
52 changes: 52 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,58 @@ export interface ListSentryOrganizationsResponse {
organizations: SentryOrganizationSummary[]
}

// ---------------------------- sandbox builds -----------------------------
// "docker build" for the Ellipsis sandbox: run a config's environment
// definition (dockerfile_append + clone + image.setup [+ hooks]) with
// streamed logs and no agent, pre-warming the image cache on success.

export type SandboxBuildStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'
export type SandboxBuildPhase = 'image' | 'clone' | 'setup' | 'snapshot' | 'hooks'
export type SandboxBuildCacheTier = 'exact' | 'incremental' | 'full'

export interface SandboxBuild {
id: string
created_at: string
updated_at: string
config_id: string | null
config_sha: string
hooks_requested: boolean
status: SandboxBuildStatus
phase: SandboxBuildPhase | null
cache_tier: SandboxBuildCacheTier | null
phase_timings: Record<string, number>
failing_phase: SandboxBuildPhase | null
exit_code: number | null
status_reason: string | null
sandbox_id: string | null
result_image_id: string | null
started_at: string | null
finished_at: string | null
}

export interface StartSandboxBuildRequest {
config_yaml?: string
config_id?: string
hooks?: boolean
}

export interface ListSandboxBuildsResponse {
builds: SandboxBuild[]
}

export interface SandboxBuildLogLine {
build_id: string
seq: number
ts: string
phase: SandboxBuildPhase
line: string
}

export interface GetSandboxBuildLogsResponse {
build_id: string
lines: SandboxBuildLogLine[]
}

// -------------------------- sandbox variables ---------------------------
// Customer-scoped environment variables injected into a sandbox when an agent
// config names them. Values are write-only: the API accepts them but never
Expand Down