From 7c7ea7f42951eacd8806de7be4beee856dbb3c99 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Fri, 17 Jul 2026 15:18:06 -0400 Subject: [PATCH] agent sandbox build start|list|get|logs: test a config's sandbox environment, no agent - New resource sub-group under sandbox (plural 'builds' alias; 'variables' alias added to variable) per the amended CLI grammar - start --config-file|--config [--hooks] [--watch]: POST /v1/sandboxes/builds, --watch streams the log docker-build-style with exit 0/1 - logs [--watch], list, get: read the persisted build log + records - v0.13.0 --- package.json | 2 +- src/commands/sandbox.ts | 164 +++++++++++++++++++++++++++++++++++++++- src/lib/api.ts | 41 ++++++++++ src/lib/types.ts | 52 +++++++++++++ 4 files changed, 257 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index a446cf6..387cac5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/commands/sandbox.ts b/src/commands/sandbox.ts index 61921ad..2e2dace 100644 --- a/src/commands/sandbox.ts +++ b/src/commands/sandbox.ts @@ -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)') @@ -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 ', 'agent config YAML file to build (test before merge)') + .option('-c, --config ', '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 ', '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 ') + .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 ') + .description("A build's log (GET /v1/sandboxes/builds/{id}/logs)") + .option('--after-seq ', '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 { + 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 diff --git a/src/lib/api.ts b/src/lib/api.ts index b2a5597..99a4fdf 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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 @@ -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 { + return this.request('POST', '/v1/sandboxes/builds', request) + } + + async listSandboxBuilds(limit?: number): Promise { + const res = await this.request( + 'GET', + '/v1/sandboxes/builds', + undefined, + { limit }, + ) + return res.builds + } + + getSandboxBuild(buildId: string): Promise { + return this.request('GET', `/v1/sandboxes/builds/${encodeURIComponent(buildId)}`) + } + + async getSandboxBuildLogs( + buildId: string, + afterSeq?: number, + limit?: number, + ): Promise { + const res = await this.request( + 'GET', + `/v1/sandboxes/builds/${encodeURIComponent(buildId)}/logs`, + undefined, + { after_seq: afterSeq, limit }, + ) + return res.lines + } + // ---------------------------- agent templates --------------------------- async listAgentTemplates(): Promise { diff --git a/src/lib/types.ts b/src/lib/types.ts index c0ec963..7b95624 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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 + 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