diff --git a/Cargo.lock b/Cargo.lock index fa02742..92fa335 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -614,7 +614,7 @@ dependencies = [ [[package]] name = "guest-agent" -version = "1.0.0" +version = "1.1.0" dependencies = [ "libc", "serde", @@ -1437,7 +1437,7 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "sandboxd" -version = "1.0.0" +version = "1.1.0" dependencies = [ "aes-gcm", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 63cd5f1..f094d34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ members = [ ] [workspace.package] -version = "1.0.0" +version = "1.1.0" edition = "2021" license = "AGPL-3.0-only" rust-version = "1.80" diff --git a/README.md b/README.md index 78d098a..9b8d898 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,36 @@ box = wd.sandboxes.create( box.exec("opencode run 'add a regression test'") ``` +#### Templates and agent PR runs + +```ts +await wd.templates.create({ + name: "node-app", + create: { + image: "node-python", + resources: { cpu: 2, memory_mb: 4096, disk_gb: 16 }, + startup: { + git: { url: "https://github.com/acme/app.git", ref: "main" }, + commands: [{ name: "install", run: "pnpm install --frozen-lockfile" }], + }, + }, +}); +const boxes = await wd.templates.spawn("node-app", { count: 3 }); + +await wd.secrets.set("OPENAI_API_KEY", process.env.OPENAI_API_KEY!); +await wd.secrets.set("GITHUB_TOKEN", process.env.GITHUB_TOKEN!); +const run = await wd.agentRuns.create({ + template: "node-app", + repo: { url: "https://github.com/acme/app.git", ref: "main" }, + agent: "codex", + model: "gpt-5", + api_key_secret: "OPENAI_API_KEY", + prompt: "Fix the failing tests and open a small PR.", + github: { token_secret: "GITHUB_TOKEN", draft: true }, +}); +console.log(run.status_url); +``` + #### Network egress policy ```ts diff --git a/crates/sandboxd/src/agent.rs b/crates/sandboxd/src/agent.rs new file mode 100644 index 0000000..44e219f --- /dev/null +++ b/crates/sandboxd/src/agent.rs @@ -0,0 +1,613 @@ +//! Agent-run orchestration: create a sandbox, run a coding-agent CLI, collect a +//! diff, and optionally turn that diff into a GitHub pull request. + +use crate::auth::AuthContext; +use crate::error::{ApiError, ApiResult}; +use crate::ids; +use crate::model::{ + AgentGithubConfig, AgentKind, AgentRun, AgentRunState, CreateAgentRunRequest, + CreateSandboxRequest, Hardness, +}; +use crate::runtime::ExecRequest; +use crate::secrets; +use crate::service; +use crate::state::AppState; +use crate::templates::{create_request_from_value, merge_create}; +use anyhow::{anyhow, bail, Context, Result}; +use chrono::Utc; +use serde_json::{json, Map, Value}; +use std::collections::BTreeMap; +use std::path::Path; +use std::process::Stdio; +use std::time::Duration; +use tokio::process::Command; + +const MAX_LOG_BYTES: usize = 1024 * 1024; +const MAX_DIFF_BYTES: usize = 4 * 1024 * 1024; + +struct HardnessProfile { + create: Value, + timeout_seconds: u64, + loop_iterations: u32, +} + +pub fn start_agent_run( + state: AppState, + ctx: AuthContext, + req: CreateAgentRunRequest, +) -> ApiResult { + validate_secret_exists(&state, &ctx.org_id, &req.api_key_secret)?; + if let Some(github) = &req.github { + if let Some(token_secret) = &github.token_secret { + validate_secret_exists(&state, &ctx.org_id, token_secret)?; + } + } + if req.repo.url.trim().is_empty() { + return Err(ApiError::BadRequest("repo.url cannot be empty".into())); + } + if req.prompt.trim().is_empty() { + return Err(ApiError::BadRequest("prompt cannot be empty".into())); + } + if req.model.trim().is_empty() { + return Err(ApiError::BadRequest("model cannot be empty".into())); + } + if !secrets::valid_name(&req.api_key_secret) { + return Err(ApiError::BadRequest( + "api_key_secret must be an env-style secret name".into(), + )); + } + + let now = Utc::now(); + let run = AgentRun { + id: ids::agent_run_id(), + org_id: ctx.org_id.clone(), + state: AgentRunState::Queued, + sandbox_id: None, + template: req.template, + repo: req.repo, + prompt: req.prompt, + model: req.model, + agent: req.agent, + api_key_secret: req.api_key_secret, + hardness: req.hardness.unwrap_or_default(), + r#loop: req.r#loop.unwrap_or_default(), + github: req.github, + stdout: String::new(), + stderr: String::new(), + diff: String::new(), + logs_truncated: false, + verification_result: None, + branch: None, + commit: None, + pr_url: None, + error: None, + created_at: now, + updated_at: now, + finished_at: None, + }; + state + .store + .put_agent_run(&run) + .map_err(ApiError::Internal)?; + + let run_id = run.id.clone(); + tokio::spawn(async move { + if let Err(e) = run_agent_task(state.clone(), ctx, run_id.clone()).await { + tracing::error!(agent_run = %run_id, error = ?e, "agent run failed"); + if let Ok(Some(mut run)) = state.store.get_agent_run(&run_id) { + let now = Utc::now(); + run.state = AgentRunState::Failed; + run.error = Some(e.to_string()); + run.updated_at = now; + run.finished_at = Some(now); + let _ = state.store.put_agent_run(&run); + } + } + }); + + Ok(run) +} + +async fn run_agent_task(state: AppState, ctx: AuthContext, run_id: String) -> Result<()> { + let mut run = state + .store + .get_agent_run(&run_id)? + .ok_or_else(|| anyhow!("agent run {run_id} not found"))?; + run.state = AgentRunState::Running; + run.updated_at = Utc::now(); + state.store.put_agent_run(&run)?; + + let create_value = build_create_value(&state, &run)?; + let create_req: CreateSandboxRequest = create_request_from_value(create_value)?; + let sb = service::create_sandbox(&state, &ctx, create_req).await?; + run.sandbox_id = Some(sb.id.clone()); + run.updated_at = Utc::now(); + state.store.put_agent_run(&run)?; + + let handle = sb + .runtime_handle + .clone() + .ok_or_else(|| anyhow!("created sandbox has no runtime handle"))?; + let node = state.node_for(sb.node_id.as_deref().unwrap_or("")); + let api_secret_value = decrypt_secret(&state, &run.org_id, &run.api_key_secret).ok(); + + node.write_file( + &handle, + ".workdir-agent-prompt.txt", + agent_prompt(&run).as_bytes(), + ) + .await + .context("write agent prompt")?; + + let profile = hardness_profile(run.hardness); + let iterations = effective_iterations(&run, profile.loop_iterations); + let mut last_exit = 0; + let mut stdout = String::new(); + let mut stderr = String::new(); + for _ in 0..iterations { + let cmd = agent_command(&run); + let exec = tokio::time::timeout( + Duration::from_secs(profile.timeout_seconds), + node.exec( + &handle, + &ExecRequest { + cmd, + cwd: None, + env: BTreeMap::new(), + background: false, + }, + ), + ) + .await + .map_err(|_| anyhow!("agent timed out after {}s", profile.timeout_seconds))? + .context("run agent command")?; + last_exit = exec.exit_code; + stdout.push_str(&exec.stdout); + stderr.push_str(&exec.stderr); + if exec.exit_code == 0 { + break; + } + } + + let stdout = redact_secret(stdout, api_secret_value.as_deref()); + let stderr = redact_secret(stderr, api_secret_value.as_deref()); + let (stdout, stdout_truncated) = truncate(stdout, MAX_LOG_BYTES); + let (stderr, stderr_truncated) = truncate(stderr, MAX_LOG_BYTES); + run.stdout = stdout; + run.stderr = stderr; + run.logs_truncated = stdout_truncated || stderr_truncated; + run.updated_at = Utc::now(); + state.store.put_agent_run(&run)?; + + if last_exit != 0 { + bail!("agent exited with code {last_exit}"); + } + + let diff = node + .exec( + &handle, + &ExecRequest { + cmd: "git add -A && git diff --cached --binary".to_string(), + cwd: None, + env: BTreeMap::new(), + background: false, + }, + ) + .await + .context("collect git diff")?; + if diff.exit_code != 0 { + bail!("collect git diff failed: {}", diff.stderr); + } + if diff.stdout.trim().is_empty() { + bail!("agent produced no git diff"); + } + let diff_stdout = redact_secret(diff.stdout, api_secret_value.as_deref()); + let (stored_diff, diff_truncated) = truncate(diff_stdout, MAX_DIFF_BYTES); + run.diff = stored_diff.clone(); + run.logs_truncated = run.logs_truncated || diff_truncated; + run.verification_result = Some("not_configured".into()); + run.updated_at = Utc::now(); + state.store.put_agent_run(&run)?; + + if let Some(github) = run.github.clone().filter(|g| g.token_secret.is_some()) { + let token_secret = github.token_secret.as_deref().unwrap(); + let token = decrypt_secret(&state, &run.org_id, token_secret)?; + let pr = create_github_pr(&state, &run, &github, &stored_diff, &token).await?; + run.branch = Some(pr.branch); + run.commit = Some(pr.commit); + run.pr_url = Some(pr.url); + } + + let now = Utc::now(); + run.state = AgentRunState::Succeeded; + run.updated_at = now; + run.finished_at = Some(now); + state.store.put_agent_run(&run)?; + Ok(()) +} + +fn build_create_value(state: &AppState, run: &AgentRun) -> ApiResult { + let base = if let Some(template) = &run.template { + state + .store + .get_template_by_name(&run.org_id, template) + .map_err(ApiError::Internal)? + .ok_or_else(|| ApiError::NotFound(format!("template {template}")))? + .create + } else { + hardness_profile(run.hardness).create + }; + let mut create = merge_create(base, None)?; + set_repo_git(&mut create, run); + add_startup_secret(&mut create, &run.api_key_secret); + Ok(create) +} + +fn set_repo_git(create: &mut Value, run: &AgentRun) { + let startup = startup_object(create); + let mut git = json!({ + "url": run.repo.url, + "depth": 1, + }); + if let Some(r) = &run.repo.r#ref { + git["ref"] = Value::String(r.clone()); + } + startup.insert("git".into(), git); +} + +fn add_startup_secret(create: &mut Value, name: &str) { + let startup = startup_object(create); + let entry = startup.entry("secrets").or_insert_with(|| json!([])); + if !entry.is_array() { + *entry = json!([]); + } + let arr = entry.as_array_mut().expect("secrets is array"); + if !arr.iter().any(|v| v.as_str() == Some(name)) { + arr.push(Value::String(name.to_string())); + } +} + +fn startup_object(create: &mut Value) -> &mut Map { + if !create.is_object() { + *create = json!({}); + } + let root = create.as_object_mut().expect("create is object"); + let replace = !root.get("startup").map(|v| v.is_object()).unwrap_or(false); + if replace { + root.insert("startup".into(), json!({})); + } + root.get_mut("startup") + .and_then(Value::as_object_mut) + .expect("startup is object") +} + +fn hardness_profile(h: Hardness) -> HardnessProfile { + match h { + Hardness::Easy => HardnessProfile { + create: json!({ + "image": "node-python", + "resources": { "cpu": 1, "memory_mb": 2048, "disk_gb": 16 }, + "auto_stop_seconds": 600 + }), + timeout_seconds: 900, + loop_iterations: 1, + }, + Hardness::Medium => HardnessProfile { + create: json!({ + "image": "node-python", + "resources": { "cpu": 2, "memory_mb": 4096, "disk_gb": 16 }, + "auto_stop_seconds": 1200 + }), + timeout_seconds: 1800, + loop_iterations: 2, + }, + Hardness::Hard => HardnessProfile { + create: json!({ + "image": "heavy-build", + "resources": { "cpu": 4, "memory_mb": 8192, "disk_gb": 32 }, + "auto_stop_seconds": 3600 + }), + timeout_seconds: 3600, + loop_iterations: 3, + }, + } +} + +fn effective_iterations(run: &AgentRun, profile_default: u32) -> u32 { + match run.r#loop.max_iterations { + Some(v) => v.clamp(1, 5), + None if run.r#loop.goal.is_some() => profile_default.clamp(1, 5), + None => 1, + } +} + +fn agent_prompt(run: &AgentRun) -> String { + let mut prompt = String::new(); + if let Some(goal) = &run.r#loop.goal { + prompt.push_str("Goal:\n"); + prompt.push_str(goal); + prompt.push_str("\n\n"); + } + prompt.push_str(&run.prompt); + prompt +} + +fn agent_command(run: &AgentRun) -> String { + let model = shell_quote(&run.model); + match run.agent { + AgentKind::Codex => format!( + "set -e; export PATH=\"$PWD:$PATH\"; \ + if ! command -v codex >/dev/null 2>&1; then npm install -g @openai/codex; fi; \ + codex exec --model {model} --sandbox danger-full-access --ask-for-approval never \ + --cd \"$PWD\" --skip-git-repo-check --output-last-message .workdir-agent-final.txt - \ + < .workdir-agent-prompt.txt" + ), + AgentKind::ClaudeCode => format!( + "set -e; export PATH=\"$PWD:$PATH\"; \ + if ! command -v claude >/dev/null 2>&1; then npm install -g @anthropic-ai/claude-code; fi; \ + claude --print --model {model} --permission-mode bypassPermissions \ + --dangerously-skip-permissions < .workdir-agent-prompt.txt \ + | tee .workdir-agent-final.txt" + ), + } +} + +fn validate_secret_exists(state: &AppState, org_id: &str, name: &str) -> ApiResult<()> { + if !secrets::valid_name(name) { + return Err(ApiError::BadRequest(format!( + "secret '{name}' must be an env-style identifier" + ))); + } + state + .store + .get_secret(org_id, name) + .map_err(ApiError::Internal)? + .ok_or_else(|| ApiError::BadRequest(format!("secret '{name}' is not defined")))?; + Ok(()) +} + +fn decrypt_secret(state: &AppState, org_id: &str, name: &str) -> Result { + let rec = state + .store + .get_secret(org_id, name)? + .ok_or_else(|| anyhow!("secret '{name}' is not defined"))?; + crate::secrets::decrypt(&state.secret_key, &rec) +} + +fn truncate(mut value: String, max: usize) -> (String, bool) { + if value.len() <= max { + return (value, false); + } + let mut end = max; + while !value.is_char_boundary(end) { + end -= 1; + } + value.truncate(end); + (value, true) +} + +fn redact_secret(value: String, secret: Option<&str>) -> String { + match secret.filter(|s| !s.is_empty()) { + Some(secret) => value.replace(secret, "[redacted]"), + None => value, + } +} + +struct PullRequestResult { + branch: String, + commit: String, + url: String, +} + +async fn create_github_pr( + state: &AppState, + run: &AgentRun, + github: &AgentGithubConfig, + diff: &str, + token: &str, +) -> Result { + let repo = GithubRepo::parse(&run.repo.url)?; + let base = github + .base_branch + .clone() + .or_else(|| run.repo.r#ref.clone()) + .unwrap_or_else(|| "main".to_string()); + let branch = format!("workdir/{}", run.id.replace('_', "-")); + let root = std::env::temp_dir().join(format!("workdir-agent-{}", run.id)); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).context("create agent temp dir")?; + let repo_dir = root.join("repo"); + let patch_path = root.join("agent.patch"); + std::fs::write(&patch_path, diff).context("write agent patch")?; + let askpass = root.join("askpass.sh"); + std::fs::write( + &askpass, + "#!/bin/sh\ncase \"$1\" in *Username*) echo x-access-token ;; *) echo \"$GITHUB_TOKEN\" ;; esac\n", + ) + .context("write git askpass")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&askpass, std::fs::Permissions::from_mode(0o700)).ok(); + } + + let clone_url = format!("https://github.com/{}/{}.git", repo.owner, repo.name); + run_host_cmd( + &root, + &askpass, + token, + "git", + &[ + "clone", "--depth", "1", "--branch", &base, &clone_url, "repo", + ], + ) + .await + .context("clone repo")?; + run_host_cmd( + &repo_dir, + &askpass, + token, + "git", + &["checkout", "-b", &branch], + ) + .await + .context("create branch")?; + run_host_cmd( + &repo_dir, + &askpass, + token, + "git", + &["apply", "--index", "--binary", patch_path.to_str().unwrap()], + ) + .await + .context("apply agent patch")?; + run_host_cmd( + &repo_dir, + &askpass, + token, + "git", + &["config", "user.name", "workdir-agent"], + ) + .await?; + run_host_cmd( + &repo_dir, + &askpass, + token, + "git", + &["config", "user.email", "agent@workdir.dev"], + ) + .await?; + let message = format!("workdir agent run {}", run.id); + run_host_cmd( + &repo_dir, + &askpass, + token, + "git", + &["commit", "-m", &message], + ) + .await + .context("commit agent patch")?; + let commit = run_host_cmd(&repo_dir, &askpass, token, "git", &["rev-parse", "HEAD"]) + .await + .context("read commit")? + .trim() + .to_string(); + run_host_cmd( + &repo_dir, + &askpass, + token, + "git", + &["push", "origin", &format!("HEAD:refs/heads/{branch}")], + ) + .await + .context("push branch")?; + + let title = format!("Workdir agent run {}", run.id); + let body = format!( + "Created by workdir agent run `{}`.\n\nAgent: `{}`\nModel: `{}`\nHardness: `{}`", + run.id, + run.agent.as_str(), + run.model, + run.hardness.as_str() + ); + let res = state + .http + .post(format!( + "https://api.github.com/repos/{}/{}/pulls", + repo.owner, repo.name + )) + .bearer_auth(token) + .header("User-Agent", "workdir-agent") + .json(&json!({ + "title": title, + "head": branch, + "base": base, + "body": body, + "draft": github.draft.unwrap_or(true), + })) + .send() + .await + .context("create GitHub PR")?; + if !res.status().is_success() { + let status = res.status(); + let body = res.text().await.unwrap_or_default(); + bail!("create GitHub PR failed ({status}): {body}"); + } + let pr: Value = res.json().await.context("parse GitHub PR response")?; + let url = pr + .get("html_url") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("GitHub PR response did not include html_url"))? + .to_string(); + let _ = std::fs::remove_dir_all(&root); + Ok(PullRequestResult { + branch, + commit, + url, + }) +} + +async fn run_host_cmd( + cwd: &Path, + askpass: &Path, + token: &str, + program: &str, + args: &[&str], +) -> Result { + let out = Command::new(program) + .args(args) + .current_dir(cwd) + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_ASKPASS", askpass) + .env("GITHUB_TOKEN", token) + .stdin(Stdio::null()) + .output() + .await + .with_context(|| format!("run {program} {}", args.join(" ")))?; + if !out.status.success() { + bail!( + "{} {} failed: {}", + program, + args.join(" "), + String::from_utf8_lossy(&out.stderr) + ); + } + Ok(String::from_utf8_lossy(&out.stdout).to_string()) +} + +struct GithubRepo { + owner: String, + name: String, +} + +impl GithubRepo { + fn parse(url: &str) -> Result { + let mut s = url.trim().trim_end_matches('/').to_string(); + if let Some(rest) = s.strip_prefix("git@github.com:") { + s = format!("https://github.com/{rest}"); + } + let marker = "github.com/"; + let rest = s + .split_once(marker) + .map(|(_, rest)| rest) + .ok_or_else(|| anyhow!("only GitHub repositories are supported for PR creation"))?; + let mut parts = rest.split('/'); + let owner = parts + .next() + .filter(|v| !v.is_empty()) + .ok_or_else(|| anyhow!("GitHub repo owner missing"))?; + let name = parts + .next() + .filter(|v| !v.is_empty()) + .ok_or_else(|| anyhow!("GitHub repo name missing"))? + .trim_end_matches(".git"); + Ok(GithubRepo { + owner: owner.to_string(), + name: name.to_string(), + }) + } +} + +fn shell_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} diff --git a/crates/sandboxd/src/api/agent_runs.rs b/crates/sandboxd/src/api/agent_runs.rs new file mode 100644 index 0000000..8e9aa17 --- /dev/null +++ b/crates/sandboxd/src/api/agent_runs.rs @@ -0,0 +1,98 @@ +//! Agent-run endpoints. + +use crate::agent; +use crate::auth::AuthContext; +use crate::error::{ApiError, ApiResult}; +use crate::model::{AgentRun, CreateAgentRunRequest}; +use crate::state::AppState; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::{Extension, Json}; +use serde_json::{json, Value}; + +fn run_view(run: &AgentRun) -> Value { + json!({ + "id": run.id, + "state": run.state.as_str(), + "sandbox_id": run.sandbox_id, + "template": run.template, + "repo": run.repo, + "prompt": run.prompt, + "model": run.model, + "agent": run.agent.as_str(), + "api_key_secret": run.api_key_secret, + "hardness": run.hardness.as_str(), + "loop": run.r#loop, + "github": run.github, + "verification_result": run.verification_result, + "branch": run.branch, + "commit": run.commit, + "pr_url": run.pr_url, + "error": run.error, + "logs_truncated": run.logs_truncated, + "created_at": run.created_at, + "updated_at": run.updated_at, + "finished_at": run.finished_at, + "status_url": format!("/v1/agent-runs/{}", run.id), + "logs_url": format!("/v1/agent-runs/{}/logs", run.id), + }) +} + +fn load_owned(state: &AppState, ctx: &AuthContext, id: &str) -> ApiResult { + let run = state + .store + .get_agent_run(id) + .map_err(ApiError::Internal)? + .ok_or_else(|| ApiError::NotFound(format!("agent run {id}")))?; + if run.org_id != ctx.org_id && !ctx.admin { + return Err(ApiError::NotFound(format!("agent run {id}"))); + } + Ok(run) +} + +pub async fn create( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> ApiResult<(StatusCode, Json)> { + let run = agent::start_agent_run(state, ctx, req)?; + Ok((StatusCode::ACCEPTED, Json(run_view(&run)))) +} + +pub async fn list( + State(state): State, + Extension(ctx): Extension, +) -> ApiResult> { + let runs = state + .store + .list_agent_runs_for_org(&ctx.org_id) + .map_err(ApiError::Internal)?; + Ok(Json(json!({ + "agent_runs": runs.iter().map(run_view).collect::>() + }))) +} + +pub async fn get( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> ApiResult> { + let run = load_owned(&state, &ctx, &id)?; + Ok(Json(run_view(&run))) +} + +pub async fn logs( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> ApiResult> { + let run = load_owned(&state, &ctx, &id)?; + Ok(Json(json!({ + "id": run.id, + "state": run.state.as_str(), + "stdout": run.stdout, + "stderr": run.stderr, + "diff": run.diff, + "truncated": run.logs_truncated, + }))) +} diff --git a/crates/sandboxd/src/api/mod.rs b/crates/sandboxd/src/api/mod.rs index 55a24e0..3cc8b6d 100644 --- a/crates/sandboxd/src/api/mod.rs +++ b/crates/sandboxd/src/api/mod.rs @@ -3,6 +3,7 @@ //! fallback (spec §16.2). pub mod admin; +pub mod agent_runs; pub mod images; pub mod internal; pub mod nodes; @@ -10,6 +11,7 @@ pub mod preview; pub mod pty; pub mod sandboxes; pub mod secrets; +pub mod templates; pub mod usage; pub mod volumes; @@ -67,6 +69,17 @@ pub fn router(state: AppState) -> Router { .route("/nodes/:id/drain", post(nodes::drain)) .route("/secrets", get(secrets::list)) .route("/secrets/:name", put(secrets::put).delete(secrets::delete)) + .route("/templates", get(templates::list).post(templates::create)) + .route( + "/templates/:name", + get(templates::get) + .put(templates::update) + .delete(templates::delete), + ) + .route( + "/templates/:name/sandboxes", + post(templates::spawn_sandboxes), + ) .route("/volumes", get(volumes::list).post(volumes::create)) .route("/volumes/:id", get(volumes::get).delete(volumes::delete)) .route("/usage", get(usage::usage)) @@ -99,6 +112,12 @@ pub fn router(state: AppState) -> Router { .route("/admin/keys/:hash", delete(admin::revoke_key)) .route("/benchmarks", get(usage::benchmarks)) .route("/benchmarks/run", post(usage::run_benchmarks)) + .route( + "/agent-runs", + get(agent_runs::list).post(agent_runs::create), + ) + .route("/agent-runs/:id", get(agent_runs::get)) + .route("/agent-runs/:id/logs", get(agent_runs::logs)) .layer(middleware::from_fn_with_state(state.clone(), auth_mw)); Router::new() diff --git a/crates/sandboxd/src/api/templates.rs b/crates/sandboxd/src/api/templates.rs new file mode 100644 index 0000000..c4cb09f --- /dev/null +++ b/crates/sandboxd/src/api/templates.rs @@ -0,0 +1,154 @@ +//! Org-scoped named sandbox templates. + +use crate::auth::AuthContext; +use crate::error::{ApiError, ApiResult}; +use crate::ids; +use crate::model::{ + CreateTemplateRequest, SandboxTemplate, SpawnTemplateRequest, UpdateTemplateRequest, +}; +use crate::service; +use crate::state::AppState; +use crate::templates::{ + create_request_from_value, merge_create, normalize_create_value, valid_name, + MAX_TEMPLATE_SPAWN_COUNT, +}; +use crate::views::sandbox_view; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::{Extension, Json}; +use chrono::Utc; +use serde_json::{json, Value}; + +fn template_view(t: &SandboxTemplate) -> Value { + json!({ + "id": t.id, + "name": t.name, + "description": t.description, + "create": t.create, + "created_at": t.created_at, + "updated_at": t.updated_at, + }) +} + +fn load_owned_by_name( + state: &AppState, + ctx: &AuthContext, + name: &str, +) -> ApiResult { + state + .store + .get_template_by_name(&ctx.org_id, name) + .map_err(ApiError::Internal)? + .ok_or_else(|| ApiError::NotFound(format!("template {name}"))) +} + +pub async fn list( + State(state): State, + Extension(ctx): Extension, +) -> ApiResult> { + let templates = state + .store + .list_templates_for_org(&ctx.org_id) + .map_err(ApiError::Internal)?; + Ok(Json(json!({ + "templates": templates.iter().map(template_view).collect::>() + }))) +} + +pub async fn get( + State(state): State, + Extension(ctx): Extension, + Path(name): Path, +) -> ApiResult> { + let t = load_owned_by_name(&state, &ctx, &name)?; + Ok(Json(template_view(&t))) +} + +pub async fn create( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> ApiResult<(StatusCode, Json)> { + if !valid_name(&req.name) { + return Err(ApiError::BadRequest( + "template name must be 1-64 chars of letters, digits, '.', '-' or '_'".into(), + )); + } + if state + .store + .get_template_by_name(&ctx.org_id, &req.name) + .map_err(ApiError::Internal)? + .is_some() + { + return Err(ApiError::Conflict(format!( + "a template named '{}' already exists", + req.name + ))); + } + let create = normalize_create_value(req.create)?; + let now = Utc::now(); + let t = SandboxTemplate { + id: ids::template_id(), + org_id: ctx.org_id.clone(), + name: req.name, + description: req.description, + create, + created_at: now, + updated_at: now, + }; + state.store.put_template(&t).map_err(ApiError::Internal)?; + Ok((StatusCode::CREATED, Json(template_view(&t)))) +} + +pub async fn update( + State(state): State, + Extension(ctx): Extension, + Path(name): Path, + Json(req): Json, +) -> ApiResult> { + let mut t = load_owned_by_name(&state, &ctx, &name)?; + t.description = req.description; + t.create = normalize_create_value(req.create)?; + t.updated_at = Utc::now(); + state.store.put_template(&t).map_err(ApiError::Internal)?; + Ok(Json(template_view(&t))) +} + +pub async fn delete( + State(state): State, + Extension(ctx): Extension, + Path(name): Path, +) -> ApiResult> { + let t = load_owned_by_name(&state, &ctx, &name)?; + let deleted = state + .store + .delete_template(&t.id) + .map_err(ApiError::Internal)?; + if !deleted { + return Err(ApiError::NotFound(format!("template {name}"))); + } + Ok(Json(json!({ "name": name, "deleted": true }))) +} + +pub async fn spawn_sandboxes( + State(state): State, + Extension(ctx): Extension, + Path(name): Path, + Json(req): Json, +) -> ApiResult<(StatusCode, Json)> { + let t = load_owned_by_name(&state, &ctx, &name)?; + let count = req.count.unwrap_or(1); + if count == 0 || count > MAX_TEMPLATE_SPAWN_COUNT { + return Err(ApiError::BadRequest(format!( + "count must be 1..={MAX_TEMPLATE_SPAWN_COUNT}" + ))); + } + let create = merge_create(t.create.clone(), req.overrides)?; + let mut out = Vec::with_capacity(count as usize); + for _ in 0..count { + let create_req = create_request_from_value(create.clone())?; + let sb = service::create_sandbox(&state, &ctx, create_req).await?; + out.push(sandbox_view(&state, &sb)); + } + Ok((StatusCode::CREATED, Json(json!({ "sandboxes": out })))) +} diff --git a/crates/sandboxd/src/app.rs b/crates/sandboxd/src/app.rs index d9b2a12..3626d4b 100644 --- a/crates/sandboxd/src/app.rs +++ b/crates/sandboxd/src/app.rs @@ -211,6 +211,14 @@ pub async fn build_state(cfg: Config) -> Result { ), Err(e) => tracing::error!(error = %e, "exec job startup reconciliation failed"), } + match store.reconcile_interrupted_agent_runs(chrono::Utc::now()) { + Ok(0) => {} + Ok(n) => tracing::warn!( + count = n, + "reconciled interrupted agent runs from a prior run" + ), + Err(e) => tracing::error!(error = %e, "agent run startup reconciliation failed"), + } let secret_key = crate::secrets::load_or_create_key(&cfg.server.data_dir) .context("load/generate secret encryption key")?; diff --git a/crates/sandboxd/src/ids.rs b/crates/sandboxd/src/ids.rs index bbc70ab..3ee7977 100644 --- a/crates/sandboxd/src/ids.rs +++ b/crates/sandboxd/src/ids.rs @@ -40,6 +40,14 @@ pub fn volume_id() -> String { format!("vol_{}", rand_hex(6)) } +pub fn template_id() -> String { + format!("tmpl_{}", rand_hex(6)) +} + +pub fn agent_run_id() -> String { + format!("arun_{}", rand_hex(6)) +} + /// ext4 label for a volume's backing image (≤16 bytes), so the guest can mount /// it by `LABEL=` regardless of which `/dev/vdX` Firecracker assigns it. /// `vol_a1b2c3d4d5e6` → `wdvb2c3d4d5e6` (drop the `vol_` prefix, prefix `wdv`). diff --git a/crates/sandboxd/src/lib.rs b/crates/sandboxd/src/lib.rs index 997427a..8a88790 100644 --- a/crates/sandboxd/src/lib.rs +++ b/crates/sandboxd/src/lib.rs @@ -4,6 +4,7 @@ //! control plane API + scheduler + host agent + runtime, plus the multi-node //! primitives (node registry, join token, drain) from §6.3. +pub mod agent; pub mod api; pub mod app; pub mod auth; @@ -33,6 +34,7 @@ pub mod secrets; pub mod service; pub mod state; pub mod store; +pub mod templates; pub mod usage; pub mod views; diff --git a/crates/sandboxd/src/model.rs b/crates/sandboxd/src/model.rs index 9d75f3a..f252e16 100644 --- a/crates/sandboxd/src/model.rs +++ b/crates/sandboxd/src/model.rs @@ -5,6 +5,7 @@ use crate::knobs::{Resources, ResourcesRequest}; use crate::lifecycle::State; use chrono::{DateTime, Utc}; use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; use std::net::Ipv4Addr; /// Where a sandbox came from. The response MUST report this honestly so that @@ -620,6 +621,49 @@ pub struct VolumeAttach { pub mount_path: String, } +// --------------------------------------------------------------------------- +// Sandbox templates +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SandboxTemplate { + pub id: String, + pub org_id: String, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Validated `POST /v1/sandboxes` body. Stored as JSON so future create + /// fields can be templated without a store migration. + pub create: Value, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CreateTemplateRequest { + pub name: String, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub create: Value, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct UpdateTemplateRequest { + #[serde(default)] + pub description: Option, + #[serde(default)] + pub create: Value, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SpawnTemplateRequest { + #[serde(default)] + pub count: Option, + #[serde(default)] + pub overrides: Option, +} + // --------------------------------------------------------------------------- // Async exec jobs // --------------------------------------------------------------------------- @@ -668,6 +712,150 @@ pub struct ExecJob { pub finished_at: Option>, } +// --------------------------------------------------------------------------- +// Agent runs +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentRunState { + Queued, + Running, + Succeeded, + Failed, +} + +impl AgentRunState { + pub fn as_str(&self) -> &'static str { + match self { + AgentRunState::Queued => "queued", + AgentRunState::Running => "running", + AgentRunState::Succeeded => "succeeded", + AgentRunState::Failed => "failed", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentKind { + Codex, + ClaudeCode, +} + +impl AgentKind { + pub fn as_str(&self) -> &'static str { + match self { + AgentKind::Codex => "codex", + AgentKind::ClaudeCode => "claude_code", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum Hardness { + Easy, + #[default] + Medium, + Hard, +} + +impl Hardness { + pub fn as_str(&self) -> &'static str { + match self { + Hardness::Easy => "easy", + Hardness::Medium => "medium", + Hardness::Hard => "hard", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentRepoSpec { + pub url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub r#ref: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AgentLoopConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub goal: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_iterations: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AgentGithubConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token_secret: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_branch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub draft: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CreateAgentRunRequest { + #[serde(default)] + pub template: Option, + pub repo: AgentRepoSpec, + pub prompt: String, + pub model: String, + pub agent: AgentKind, + pub api_key_secret: String, + #[serde(default)] + pub hardness: Option, + #[serde(default)] + pub r#loop: Option, + #[serde(default)] + pub github: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentRun { + pub id: String, + pub org_id: String, + pub state: AgentRunState, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub template: Option, + pub repo: AgentRepoSpec, + pub prompt: String, + pub model: String, + pub agent: AgentKind, + pub api_key_secret: String, + pub hardness: Hardness, + #[serde(default)] + pub r#loop: AgentLoopConfig, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github: Option, + #[serde(default)] + pub stdout: String, + #[serde(default)] + pub stderr: String, + #[serde(default)] + pub diff: String, + #[serde(default)] + pub logs_truncated: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub verification_result: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub commit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pr_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub finished_at: Option>, +} + // --------------------------------------------------------------------------- // Create request (spec §3.3, §3.4, §19) // --------------------------------------------------------------------------- diff --git a/crates/sandboxd/src/store.rs b/crates/sandboxd/src/store.rs index 1a21fdb..2230816 100644 --- a/crates/sandboxd/src/store.rs +++ b/crates/sandboxd/src/store.rs @@ -6,7 +6,7 @@ use crate::images::{CustomImage, ImageStatus}; use crate::lifecycle::State; -use crate::model::{ExecJob, ExecJobState, Sandbox}; +use crate::model::{AgentRun, AgentRunState, ExecJob, ExecJobState, Sandbox, SandboxTemplate}; use crate::nodes::Node; use crate::usage::{ApiKey, Org, UsageInterval}; use anyhow::{Context, Result}; @@ -101,6 +101,25 @@ CREATE TABLE IF NOT EXISTS volumes ( ); CREATE INDEX IF NOT EXISTS idx_volumes_org ON volumes(org_id); CREATE UNIQUE INDEX IF NOT EXISTS idx_volumes_org_name ON volumes(org_id, name); +CREATE TABLE IF NOT EXISTS templates ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL, + name TEXT NOT NULL, + data TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_templates_org ON templates(org_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_templates_org_name ON templates(org_id, name); +CREATE TABLE IF NOT EXISTS agent_runs ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL, + sandbox_id TEXT, + state TEXT NOT NULL, + created_at TEXT NOT NULL, + data TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_agent_runs_org ON agent_runs(org_id); +CREATE INDEX IF NOT EXISTS idx_agent_runs_sandbox ON agent_runs(sandbox_id); +CREATE INDEX IF NOT EXISTS idx_agent_runs_state ON agent_runs(state); "#; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -726,6 +745,134 @@ impl Store { Ok(n > 0) } + // --- sandbox templates ---------------------------------------------- + + pub fn put_template(&self, t: &SandboxTemplate) -> Result<()> { + let conn = self.lock(); + conn.execute( + "INSERT INTO templates(id, org_id, name, data) VALUES(?1, ?2, ?3, ?4) + ON CONFLICT(id) DO UPDATE SET + org_id=excluded.org_id, name=excluded.name, data=excluded.data", + params![t.id, t.org_id, t.name, serde_json::to_string(t)?], + )?; + Ok(()) + } + + pub fn get_template(&self, id: &str) -> Result> { + let conn = self.lock(); + let row: Option = conn + .query_row( + "SELECT data FROM templates WHERE id = ?1", + params![id], + |r| r.get(0), + ) + .optional()?; + Ok(row.map(|d| serde_json::from_str(&d)).transpose()?) + } + + pub fn get_template_by_name( + &self, + org_id: &str, + name: &str, + ) -> Result> { + let conn = self.lock(); + let row: Option = conn + .query_row( + "SELECT data FROM templates WHERE org_id = ?1 AND name = ?2", + params![org_id, name], + |r| r.get(0), + ) + .optional()?; + Ok(row.map(|d| serde_json::from_str(&d)).transpose()?) + } + + pub fn list_templates_for_org(&self, org_id: &str) -> Result> { + let conn = self.lock(); + let mut stmt = + conn.prepare("SELECT data FROM templates WHERE org_id = ?1 ORDER BY name")?; + let rows = stmt.query_map(params![org_id], |r| r.get::<_, String>(0))?; + let mut out = vec![]; + for r in rows { + out.push(serde_json::from_str(&r?)?); + } + Ok(out) + } + + pub fn delete_template(&self, id: &str) -> Result { + let conn = self.lock(); + let n = conn.execute("DELETE FROM templates WHERE id = ?1", params![id])?; + Ok(n > 0) + } + + // --- agent runs ------------------------------------------------------ + + pub fn put_agent_run(&self, run: &AgentRun) -> Result<()> { + let conn = self.lock(); + conn.execute( + "INSERT INTO agent_runs(id, org_id, sandbox_id, state, created_at, data) + VALUES(?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(id) DO UPDATE SET + org_id=excluded.org_id, sandbox_id=excluded.sandbox_id, + state=excluded.state, data=excluded.data", + params![ + run.id, + run.org_id, + run.sandbox_id, + run.state.as_str(), + run.created_at.to_rfc3339(), + serde_json::to_string(run)?, + ], + )?; + Ok(()) + } + + pub fn get_agent_run(&self, id: &str) -> Result> { + let conn = self.lock(); + let row: Option = conn + .query_row( + "SELECT data FROM agent_runs WHERE id = ?1", + params![id], + |r| r.get(0), + ) + .optional()?; + Ok(row.map(|d| serde_json::from_str(&d)).transpose()?) + } + + pub fn list_agent_runs_for_org(&self, org_id: &str) -> Result> { + let conn = self.lock(); + let mut stmt = + conn.prepare("SELECT data FROM agent_runs WHERE org_id = ?1 ORDER BY created_at DESC")?; + let rows = stmt.query_map(params![org_id], |r| r.get::<_, String>(0))?; + let mut out = vec![]; + for r in rows { + out.push(serde_json::from_str(&r?)?); + } + Ok(out) + } + + pub fn reconcile_interrupted_agent_runs(&self, now: DateTime) -> Result { + let conn = self.lock(); + let mut stmt = + conn.prepare("SELECT id, data FROM agent_runs WHERE state IN ('queued','running')")?; + let rows: Vec<(String, String)> = stmt + .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))? + .collect::>()?; + drop(stmt); + let count = rows.len(); + for (id, data) in rows { + let mut run: AgentRun = serde_json::from_str(&data)?; + run.state = AgentRunState::Failed; + run.error = Some("interrupted by control-plane restart".to_string()); + run.updated_at = now; + run.finished_at = Some(now); + conn.execute( + "UPDATE agent_runs SET state = 'failed', data = ?2 WHERE id = ?1", + params![id, serde_json::to_string(&run)?], + )?; + } + Ok(count) + } + // --- benchmark samples (roadmap Phase 0) ---------------------------- pub fn put_benchmark_sample(&self, s: &crate::bench::BenchmarkSample) -> Result<()> { @@ -809,7 +956,9 @@ pub fn is_state_active(state: State) -> bool { #[cfg(test)] mod volume_tests { use super::*; - use crate::model::Volume; + use crate::model::{ + AgentKind, AgentRepoSpec, AgentRun, AgentRunState, Hardness, SandboxTemplate, Volume, + }; use chrono::Utc; fn vol(id: &str, org: &str, name: &str) -> Volume { @@ -870,4 +1019,81 @@ mod volume_tests { l.len() ); } + + #[test] + fn template_crud_and_org_scope() { + let s = Store::open_in_memory().unwrap(); + let now = Utc::now(); + let t = SandboxTemplate { + id: "tmpl_a".into(), + org_id: "org1".into(), + name: "node-app".into(), + description: Some("desc".into()), + create: serde_json::json!({"startup": "none"}), + created_at: now, + updated_at: now, + }; + s.put_template(&t).unwrap(); + assert_eq!(s.get_template("tmpl_a").unwrap().unwrap().name, "node-app"); + assert_eq!( + s.get_template_by_name("org1", "node-app") + .unwrap() + .unwrap() + .id, + "tmpl_a" + ); + assert!(s + .get_template_by_name("org2", "node-app") + .unwrap() + .is_none()); + assert_eq!(s.list_templates_for_org("org1").unwrap().len(), 1); + assert!(s.delete_template("tmpl_a").unwrap()); + assert!(s.get_template("tmpl_a").unwrap().is_none()); + } + + #[test] + fn agent_run_persistence_and_reconcile() { + let s = Store::open_in_memory().unwrap(); + let now = Utc::now(); + let run = AgentRun { + id: "arun_a".into(), + org_id: "org1".into(), + state: AgentRunState::Running, + sandbox_id: Some("sbx_a".into()), + template: Some("node-app".into()), + repo: AgentRepoSpec { + url: "https://github.com/acme/app.git".into(), + r#ref: Some("main".into()), + }, + prompt: "fix it".into(), + model: "gpt-5".into(), + agent: AgentKind::Codex, + api_key_secret: "OPENAI_API_KEY".into(), + hardness: Hardness::Medium, + r#loop: Default::default(), + github: None, + stdout: String::new(), + stderr: String::new(), + diff: String::new(), + logs_truncated: false, + verification_result: None, + branch: None, + commit: None, + pr_url: None, + error: None, + created_at: now, + updated_at: now, + finished_at: None, + }; + s.put_agent_run(&run).unwrap(); + assert_eq!(s.list_agent_runs_for_org("org1").unwrap().len(), 1); + assert_eq!( + s.get_agent_run("arun_a").unwrap().unwrap().state, + AgentRunState::Running + ); + assert_eq!(s.reconcile_interrupted_agent_runs(Utc::now()).unwrap(), 1); + let reconciled = s.get_agent_run("arun_a").unwrap().unwrap(); + assert_eq!(reconciled.state, AgentRunState::Failed); + assert!(reconciled.error.unwrap().contains("interrupted")); + } } diff --git a/crates/sandboxd/src/templates.rs b/crates/sandboxd/src/templates.rs new file mode 100644 index 0000000..dd36da0 --- /dev/null +++ b/crates/sandboxd/src/templates.rs @@ -0,0 +1,62 @@ +//! Shared helpers for named sandbox templates. + +use crate::error::{ApiError, ApiResult}; +use crate::model::CreateSandboxRequest; +use serde_json::{Map, Value}; + +pub const MAX_TEMPLATE_SPAWN_COUNT: u32 = 20; + +pub fn valid_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 64 + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') +} + +pub fn normalize_create_value(value: Value) -> ApiResult { + let value = match value { + Value::Null => Value::Object(Map::new()), + other => other, + }; + if !value.is_object() { + return Err(ApiError::BadRequest( + "template create must be a sandbox create object".into(), + )); + } + create_request_from_value(value.clone())?; + Ok(value) +} + +pub fn create_request_from_value(value: Value) -> ApiResult { + serde_json::from_value(value) + .map_err(|e| ApiError::BadRequest(format!("invalid sandbox create body: {e}"))) +} + +pub fn merge_create(mut base: Value, overrides: Option) -> ApiResult { + if let Some(overrides) = overrides { + if !overrides.is_object() { + return Err(ApiError::BadRequest( + "template spawn overrides must be an object".into(), + )); + } + merge_json(&mut base, overrides); + } + normalize_create_value(base) +} + +fn merge_json(base: &mut Value, overrides: Value) { + match (base, overrides) { + (Value::Object(base), Value::Object(overrides)) => { + for (k, v) in overrides { + match base.get_mut(&k) { + Some(existing) => merge_json(existing, v), + None => { + base.insert(k, v); + } + } + } + } + (base, overrides) => *base = overrides, + } +} diff --git a/crates/sandboxd/tests/integration.rs b/crates/sandboxd/tests/integration.rs index a2bac1a..a910000 100644 --- a/crates/sandboxd/tests/integration.rs +++ b/crates/sandboxd/tests/integration.rs @@ -549,6 +549,220 @@ async fn coding_agent_is_opt_in_and_validated() { assert_eq!(sb["coding_agent"], serde_json::json!("opencode")); } +#[tokio::test] +async fn templates_crud_and_batch_spawn() { + let (base, key, _tmp) = spawn_server().await; + let c = client(); + let auth = format!("Bearer {key}"); + + let create_body = serde_json::json!({ + "name": "node-app", + "description": "test template", + "create": { + "startup": { + "commands": [ + { "name": "mark", "run": "echo templated > template.txt" } + ] + } + } + }); + let resp = c + .post(format!("{base}/v1/templates")) + .header("authorization", &auth) + .json(&create_body) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201); + let template: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(template["name"], "node-app"); + assert_eq!(template["create"]["startup"]["commands"][0]["name"], "mark"); + + let duplicate = c + .post(format!("{base}/v1/templates")) + .header("authorization", &auth) + .json(&create_body) + .send() + .await + .unwrap(); + assert_eq!(duplicate.status(), 409); + + let listed: serde_json::Value = c + .get(format!("{base}/v1/templates")) + .header("authorization", &auth) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(listed["templates"].as_array().unwrap().len(), 1); + + let spawned: serde_json::Value = c + .post(format!("{base}/v1/templates/node-app/sandboxes")) + .header("authorization", &auth) + .json(&serde_json::json!({ + "count": 2, + "overrides": { "auto_stop_seconds": 300 } + })) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let sandboxes = spawned["sandboxes"].as_array().unwrap(); + assert_eq!(sandboxes.len(), 2); + for sb in sandboxes { + assert_eq!(sb["auto_stop_seconds"], 300); + let id = sb["id"].as_str().unwrap(); + let read: serde_json::Value = c + .get(format!("{base}/v1/sandboxes/{id}/files?path=template.txt")) + .header("authorization", &auth) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(read["content"], "templated\n"); + } + + let deleted: serde_json::Value = c + .delete(format!("{base}/v1/templates/node-app")) + .header("authorization", &auth) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(deleted["deleted"], true); +} + +#[tokio::test] +async fn agent_run_uses_template_collects_diff_and_redacts_secret() { + let (base, key, tmp) = spawn_server().await; + let c = client(); + let auth = format!("Bearer {key}"); + + let repo = tmp.path().join("fixture-repo"); + std::fs::create_dir_all(&repo).unwrap(); + std::fs::write(repo.join("README.md"), "hello\n").unwrap(); + std::process::Command::new("git") + .args(["init", "-b", "main"]) + .current_dir(&repo) + .status() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.email", "test@example.com"]) + .current_dir(&repo) + .status() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.name", "test"]) + .current_dir(&repo) + .status() + .unwrap(); + std::process::Command::new("git") + .args(["add", "."]) + .current_dir(&repo) + .status() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "-m", "init"]) + .current_dir(&repo) + .status() + .unwrap(); + + let secret_value = "sk-test-redact-me"; + let stored: serde_json::Value = c + .put(format!("{base}/v1/secrets/OPENAI_API_KEY")) + .header("authorization", &auth) + .json(&serde_json::json!({ "value": secret_value })) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(stored["stored"], true); + + let fake_codex = + "cat >/dev/null\nprintf \"secret=$OPENAI_API_KEY\\n\"\necho changed > agent.txt\n"; + let script_cmd = format!("cat > codex <<'EOF'\n#!/bin/sh\n{fake_codex}EOF\nchmod +x codex"); + let resp = c + .post(format!("{base}/v1/templates")) + .header("authorization", &auth) + .json(&serde_json::json!({ + "name": "fake-codex", + "create": { + "startup": { + "commands": [ + { "name": "fake-codex", "run": script_cmd } + ] + } + } + })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201); + + let launched: serde_json::Value = c + .post(format!("{base}/v1/agent-runs")) + .header("authorization", &auth) + .json(&serde_json::json!({ + "template": "fake-codex", + "repo": { "url": repo.to_string_lossy(), "ref": "main" }, + "prompt": "make a deterministic change", + "model": "test-model", + "agent": "codex", + "api_key_secret": "OPENAI_API_KEY" + })) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let run_id = launched["id"].as_str().unwrap().to_string(); + assert_eq!(launched["state"], "queued"); + + let mut status = launched; + for _ in 0..150 { + if status["state"] != "queued" && status["state"] != "running" { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + status = c + .get(format!("{base}/v1/agent-runs/{run_id}")) + .header("authorization", &auth) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + } + assert_eq!(status["state"], "succeeded", "agent run status: {status}"); + assert!(status["sandbox_id"].is_string()); + assert!(status["pr_url"].is_null()); + + let logs: serde_json::Value = c + .get(format!("{base}/v1/agent-runs/{run_id}/logs")) + .header("authorization", &auth) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(logs["stdout"].as_str().unwrap().contains("[redacted]")); + assert!(!logs["stdout"].as_str().unwrap().contains(secret_value)); + assert!(logs["diff"].as_str().unwrap().contains("agent.txt")); +} + #[tokio::test] async fn browser_requires_explicit_resources_and_image() { let (base, key, _tmp) = spawn_server().await; diff --git a/docs/API.md b/docs/API.md index 9588ff5..98e8746 100644 --- a/docs/API.md +++ b/docs/API.md @@ -29,6 +29,67 @@ The default create is one call with no body and yields the cheapest, fastest pat | `POST` | `/v1/sandboxes/:id/resume` | Resume from stopped disk/snapshot. | | `DELETE` | `/v1/sandboxes/:id` | Stop, delete ephemeral disk, remove routes. | +## Templates + +Org-scoped named sandbox create configs. A template's `create` field is the same +JSON body accepted by `POST /v1/sandboxes`. + +| Method | Path | Notes | +|---|---|---| +| `POST` | `/v1/templates` | `{name, description?, create}` → store a named create config. | +| `GET` | `/v1/templates` | List the caller's templates. | +| `GET` | `/v1/templates/:name` | Get one template. | +| `PUT` | `/v1/templates/:name` | Replace `description` + `create`. | +| `DELETE` | `/v1/templates/:name` | Delete one template. | +| `POST` | `/v1/templates/:name/sandboxes` | `{count?, overrides?}` → create 1–20 fresh sandboxes from the template. | + +```jsonc +{ + "name": "node-app", + "create": { + "image": "node-python", + "resources": { "cpu": 2, "memory_mb": 4096, "disk_gb": 16 }, + "startup": { + "git": { "url": "https://github.com/acme/app.git", "ref": "main" }, + "commands": [{ "name": "install", "run": "pnpm install --frozen-lockfile" }], + "ports": [3000] + } + } +} +``` + +## Agent runs + +Agent runs create a sandbox from a template or hardness profile, run Codex or +Claude Code inside it, collect a staged git diff, and optionally open a GitHub +PR from the control plane. Provider keys and GitHub tokens are referenced by +secret name; secret values are never returned. + +| Method | Path | Notes | +|---|---|---| +| `POST` | `/v1/agent-runs` | Start an async run → `202 {id, state, status_url, logs_url}`. | +| `GET` | `/v1/agent-runs` | List runs. | +| `GET` | `/v1/agent-runs/:id` | Status, sandbox id, branch/commit/PR URL, error. | +| `GET` | `/v1/agent-runs/:id/logs` | Captured stdout/stderr and staged diff. | + +```jsonc +{ + "template": "node-app", + "repo": { "url": "https://github.com/acme/app.git", "ref": "main" }, + "agent": "codex", // codex | claude_code + "model": "gpt-5", + "api_key_secret": "OPENAI_API_KEY", + "prompt": "Fix the failing tests and keep the change minimal.", + "hardness": "medium", // easy | medium | hard + "loop": { "goal": "Open a ready PR", "max_iterations": 2 }, + "github": { "token_secret": "GITHUB_TOKEN", "base_branch": "main", "draft": true } +} +``` + +If `github.token_secret` is omitted, the run stops after collecting the diff. +When present, the token is used only by the control plane to create the branch, +commit, push, and PR; it is not injected into the sandbox. + ### Lifecycle & perpetual standby States: `creating → running → stopping → {stopped|standby} → resuming → running`, diff --git a/docs/FEATURES.md b/docs/FEATURES.md index b350bbd..5311bd4 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -1,9 +1,10 @@ # Extended Features Capabilities added on top of the base spec: secret management, docker-in-docker, -S3 bucket mounts, ephemeral files/images, coding agent, persistent volumes, and -create-time network egress controls. All are opt-in and preserve the cheap -default path (a no-option `create()` is unchanged). +S3 bucket mounts, ephemeral files/images, coding agent, sandbox templates, +agent PR runs, persistent volumes, and create-time network egress controls. All +are opt-in and preserve the cheap default path (a no-option `create()` is +unchanged). --- @@ -197,7 +198,56 @@ with a `400`. Works on any curated image (all ship `curl` + default egress). --- -## 6. Persistent volumes +## 6. Sandbox templates and agent PR runs + +Templates are named, org-scoped sandbox create configs. Store the repo, image, +resources, startup commands, ports, network policy, and secret names once, then +spawn one or many fresh sandboxes by template name: + +```jsonc +POST /v1/templates +{ + "name": "node-app", + "create": { + "image": "node-python", + "resources": { "cpu": 2, "memory_mb": 4096, "disk_gb": 16 }, + "startup": { + "git": { "url": "https://github.com/acme/app.git", "ref": "main" }, + "commands": [{ "name": "install", "run": "pnpm install --frozen-lockfile" }] + } + } +} + +POST /v1/templates/node-app/sandboxes +{ "count": 3, "overrides": { "auto_stop_seconds": 600 } } +``` + +Agent runs build on templates. Workdir creates a sandbox, injects only the model +provider key by secret name, runs Codex or Claude Code, collects the staged git +diff, and can open a GitHub PR from the control plane: + +```jsonc +POST /v1/agent-runs +{ + "template": "node-app", + "repo": { "url": "https://github.com/acme/app.git", "ref": "main" }, + "agent": "codex", + "model": "gpt-5", + "api_key_secret": "OPENAI_API_KEY", + "prompt": "Fix the failing tests.", + "hardness": "medium", + "github": { "token_secret": "GITHUB_TOKEN", "draft": true } +} +``` + +`hardness` chooses default resources and timeout when no template is provided +(`easy`, `medium`, `hard`). Template values and explicit request fields win over +the profile defaults. GitHub write tokens are not injected into the sandbox; the +control plane uses them to create the branch, commit, push, and PR. + +--- + +## 7. Persistent volumes Attach org-scoped block storage that survives sandbox deletion, so workspace state can move from one sandbox session to the next. @@ -228,7 +278,7 @@ is configured before VM start, and fork is refused while volumes are attached. --- -## 7. Network egress controls +## 8. Network egress controls Attach a create-time egress policy to a sandbox under `startup.network`. Omitting it keeps the backward-compatible default internet egress. diff --git a/sdk/python/README.md b/sdk/python/README.md index 8cfcfa5..ec02ad7 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -24,4 +24,27 @@ print(box.exec_logs(job.cmd_id)) box.delete() ``` +```python +workdir.templates.create( + "node-app", + create={ + "image": "node-python", + "resources": {"cpu": 2, "memory_mb": 4096, "disk_gb": 16}, + "startup": {"git": {"url": "https://github.com/acme/app.git", "ref": "main"}}, + }, +) +boxes = workdir.templates.spawn("node-app", count=2) + +run = workdir.agent_runs.create( + template="node-app", + repo={"url": "https://github.com/acme/app.git", "ref": "main"}, + agent="codex", + model="gpt-5", + api_key_secret="OPENAI_API_KEY", + prompt="Fix the failing tests.", + github={"token_secret": "GITHUB_TOKEN", "draft": True}, +) +print(run.status_url) +``` + The SDK uses only the Python standard library. diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 11afb17..5a00916 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mv37-workdir" -version = "0.1.2" +version = "0.2.0" description = "Python SDK for workdir" readme = "README.md" requires-python = ">=3.9" diff --git a/sdk/python/src/workdir/__init__.py b/sdk/python/src/workdir/__init__.py index bc36710..5c0d4d8 100644 --- a/sdk/python/src/workdir/__init__.py +++ b/sdk/python/src/workdir/__init__.py @@ -43,8 +43,18 @@ from dataclasses import dataclass from typing import Any, Optional, Union -__version__ = "0.1.2" -__all__ = ["Client", "ExecJob", "ExecLogs", "ExecResult", "Sandbox", "SandboxError"] +__version__ = "0.2.0" +__all__ = [ + "AgentRun", + "AgentRunLogs", + "Client", + "ExecJob", + "ExecLogs", + "ExecResult", + "Sandbox", + "SandboxError", + "SandboxTemplate", +] class SandboxError(Exception): @@ -84,6 +94,53 @@ class ExecLogs: truncated: bool +@dataclass +class SandboxTemplate: + id: str + name: str + create: dict + created_at: str + updated_at: str + description: Optional[str] = None + + +@dataclass +class AgentRun: + id: str + state: str + repo: dict + prompt: str + model: str + agent: str + api_key_secret: str + hardness: str + created_at: str + updated_at: str + sandbox_id: Optional[str] = None + template: Optional[str] = None + loop: Optional[dict] = None + github: Optional[dict] = None + verification_result: Optional[str] = None + branch: Optional[str] = None + commit: Optional[str] = None + pr_url: Optional[str] = None + error: Optional[str] = None + logs_truncated: bool = False + finished_at: Optional[str] = None + status_url: Optional[str] = None + logs_url: Optional[str] = None + + +@dataclass +class AgentRunLogs: + id: str + state: str + stdout: str + stderr: str + diff: str + truncated: bool + + class _Http: def __init__(self, base_url: str, api_key: str, timeout: float = 60.0): self.base = base_url.rstrip("/") @@ -279,6 +336,117 @@ def delete(self, image_id: str) -> dict: return self._http.request("DELETE", f"/v1/images/{image_id}") +class _Templates: + def __init__(self, http: _Http): + self._http = http + + def create(self, name: str, create: Optional[dict] = None, + description: Optional[str] = None) -> SandboxTemplate: + r = self._http.request("POST", "/v1/templates", { + "name": name, + "description": description, + "create": create or {}, + }) + return _template(r) + + def get(self, name: str) -> SandboxTemplate: + q = urllib.parse.quote(name, safe="") + return _template(self._http.request("GET", f"/v1/templates/{q}")) + + def list(self) -> list[SandboxTemplate]: + data = self._http.request("GET", "/v1/templates") + return [_template(t) for t in data.get("templates", [])] + + def update(self, name: str, create: Optional[dict] = None, + description: Optional[str] = None) -> SandboxTemplate: + q = urllib.parse.quote(name, safe="") + r = self._http.request("PUT", f"/v1/templates/{q}", { + "description": description, + "create": create or {}, + }) + return _template(r) + + def delete(self, name: str) -> dict: + q = urllib.parse.quote(name, safe="") + return self._http.request("DELETE", f"/v1/templates/{q}") + + def spawn(self, name: str, count: Optional[int] = None, + overrides: Optional[dict] = None) -> list[Sandbox]: + q = urllib.parse.quote(name, safe="") + body = {} + if count is not None: + body["count"] = count + if overrides is not None: + body["overrides"] = overrides + data = self._http.request("POST", f"/v1/templates/{q}/sandboxes", body) + return [Sandbox(self._http, s) for s in data.get("sandboxes", [])] + + +class _AgentRuns: + def __init__(self, http: _Http): + self._http = http + + def create(self, **request) -> AgentRun: + return _agent_run(self._http.request("POST", "/v1/agent-runs", request)) + + def get(self, run_id: str) -> AgentRun: + return _agent_run(self._http.request("GET", f"/v1/agent-runs/{run_id}")) + + def list(self) -> list[AgentRun]: + data = self._http.request("GET", "/v1/agent-runs") + return [_agent_run(r) for r in data.get("agent_runs", [])] + + def logs(self, run_id: str) -> AgentRunLogs: + r = self._http.request("GET", f"/v1/agent-runs/{run_id}/logs") + return AgentRunLogs( + id=r["id"], + state=r["state"], + stdout=r["stdout"], + stderr=r["stderr"], + diff=r["diff"], + truncated=bool(r.get("truncated", False)), + ) + + +def _template(r: dict) -> SandboxTemplate: + return SandboxTemplate( + id=r["id"], + name=r["name"], + description=r.get("description"), + create=r.get("create", {}), + created_at=r["created_at"], + updated_at=r["updated_at"], + ) + + +def _agent_run(r: dict) -> AgentRun: + return AgentRun( + id=r["id"], + state=r["state"], + sandbox_id=r.get("sandbox_id"), + template=r.get("template"), + repo=r["repo"], + prompt=r["prompt"], + model=r["model"], + agent=r["agent"], + api_key_secret=r["api_key_secret"], + hardness=r["hardness"], + loop=r.get("loop"), + github=r.get("github"), + verification_result=r.get("verification_result"), + branch=r.get("branch"), + commit=r.get("commit"), + pr_url=r.get("pr_url"), + error=r.get("error"), + logs_truncated=bool(r.get("logs_truncated", False)), + created_at=r["created_at"], + updated_at=r["updated_at"], + finished_at=r.get("finished_at"), + status_url=r.get("status_url"), + logs_url=r.get("logs_url"), + ) + + class _Volumes: def __init__(self, http: _Http): self._http = http @@ -331,6 +499,8 @@ def __init__(self, base_url: str, api_key: str, timeout: float = 60.0): self._http = _Http(base_url, api_key, timeout) self.sandboxes = _Sandboxes(self._http) self.images = _Images(self._http) + self.templates = _Templates(self._http) + self.agent_runs = _AgentRuns(self._http) self.volumes = _Volumes(self._http) self.nodes = _Nodes(self._http) self.secrets = _Secrets(self._http) diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index e249bd4..fab8ac5 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -25,4 +25,27 @@ console.log(await box.execLogs(job.cmd_id)); await box.delete(); ``` +```ts +await workdir.templates.create({ + name: "node-app", + create: { + image: "node-python", + resources: { cpu: 2, memory_mb: 4096, disk_gb: 16 }, + startup: { git: { url: "https://github.com/acme/app.git", ref: "main" } }, + }, +}); +const boxes = await workdir.templates.spawn("node-app", { count: 2 }); + +const run = await workdir.agentRuns.create({ + template: "node-app", + repo: { url: "https://github.com/acme/app.git", ref: "main" }, + agent: "codex", + model: "gpt-5", + api_key_secret: "OPENAI_API_KEY", + prompt: "Fix the failing tests.", + github: { token_secret: "GITHUB_TOKEN", draft: true }, +}); +console.log(run.status_url); +``` + The SDK uses the global `fetch` API and supports Node.js 18+, Deno, Bun, and browsers. diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json index df9b804..39cf882 100644 --- a/sdk/typescript/package-lock.json +++ b/sdk/typescript/package-lock.json @@ -1,12 +1,12 @@ { "name": "@mv37/workdir", - "version": "0.1.2", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mv37/workdir", - "version": "0.1.2", + "version": "0.2.0", "license": "AGPL-3.0-only", "devDependencies": { "typescript": "^5.8.0" diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 0e88ccc..3b0d4dd 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@mv37/workdir", - "version": "0.1.2", + "version": "0.2.0", "description": "TypeScript SDK for workdir", "type": "module", "main": "./dist/index.js", diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 20021c2..3a60db3 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -109,6 +109,62 @@ export interface ExecLogs { truncated: boolean; } +export interface SandboxTemplate { + id: string; + name: string; + description?: string | null; + create: Record; + created_at: string; + updated_at: string; +} + +export interface AgentRunRequest { + template?: string; + repo: { url: string; ref?: string }; + prompt: string; + model: string; + agent: "codex" | "claude_code"; + api_key_secret: string; + hardness?: "easy" | "medium" | "hard"; + loop?: { goal?: string; max_iterations?: number }; + github?: { token_secret?: string; base_branch?: string; draft?: boolean }; +} + +export interface AgentRun { + id: string; + state: "queued" | "running" | "succeeded" | "failed" | string; + sandbox_id?: string | null; + template?: string | null; + repo: { url: string; ref?: string }; + prompt: string; + model: string; + agent: string; + api_key_secret: string; + hardness: string; + loop?: Record; + github?: Record | null; + verification_result?: string | null; + branch?: string | null; + commit?: string | null; + pr_url?: string | null; + error?: string | null; + logs_truncated?: boolean; + created_at: string; + updated_at: string; + finished_at?: string | null; + status_url?: string; + logs_url?: string; +} + +export interface AgentRunLogs { + id: string; + state: string; + stdout: string; + stderr: string; + diff: string; + truncated: boolean; +} + export class SandboxError extends Error { constructor(public status: number, public code: string, message: string) { super(`[${status} ${code}] ${message}`); @@ -273,6 +329,55 @@ class Images { delete(id: string) { return this.http.request("DELETE", `/v1/images/${id}`); } } +class Templates { + constructor(private http: Http) {} + create(input: { name: string; description?: string; create?: Record }) { + return this.http.request("POST", "/v1/templates", { + name: input.name, + description: input.description, + create: input.create ?? {}, + }); + } + get(name: string) { return this.http.request("GET", `/v1/templates/${encodeURIComponent(name)}`); } + list() { + return this.http.request<{ templates: SandboxTemplate[] }>("GET", "/v1/templates") + .then((r) => r.templates); + } + update(name: string, input: { description?: string; create?: Record }) { + return this.http.request("PUT", `/v1/templates/${encodeURIComponent(name)}`, { + description: input.description, + create: input.create ?? {}, + }); + } + delete(name: string) { + return this.http.request("DELETE", `/v1/templates/${encodeURIComponent(name)}`); + } + spawn(name: string, opts: { count?: number; overrides?: Record } = {}) { + return this.http.request<{ sandboxes: any[] }>( + "POST", + `/v1/templates/${encodeURIComponent(name)}/sandboxes`, + opts, + ).then((r) => r.sandboxes.map((s) => new Sandbox(this.http, s))); + } +} + +class AgentRuns { + constructor(private http: Http) {} + create(req: AgentRunRequest) { + return this.http.request("POST", "/v1/agent-runs", req); + } + get(id: string) { + return this.http.request("GET", `/v1/agent-runs/${id}`); + } + list() { + return this.http.request<{ agent_runs: AgentRun[] }>("GET", "/v1/agent-runs") + .then((r) => r.agent_runs); + } + logs(id: string) { + return this.http.request("GET", `/v1/agent-runs/${id}/logs`); + } +} + class Volumes { constructor(private http: Http) {} create(name: string, sizeGb: number) { @@ -301,6 +406,8 @@ class Secrets { export class Client { readonly sandboxes: Sandboxes; readonly images: Images; + readonly templates: Templates; + readonly agentRuns: AgentRuns; readonly volumes: Volumes; readonly nodes: Nodes; readonly secrets: Secrets; @@ -310,6 +417,8 @@ export class Client { this.http = new Http(baseUrl, apiKey); this.sandboxes = new Sandboxes(this.http); this.images = new Images(this.http); + this.templates = new Templates(this.http); + this.agentRuns = new AgentRuns(this.http); this.volumes = new Volumes(this.http); this.nodes = new Nodes(this.http); this.secrets = new Secrets(this.http);