//! A check command per project folder (tests, build, lint) that runs after every turn that changed //! files. The result is a green/red badge on the turn; a failed run can be handed to the agent. use crate::app::App; use crate::types::{Event, Role, Session, now_ms}; use anyhow::{Result, anyhow, bail}; use std::process::Stdio; use std::sync::Arc; use std::time::Duration; const TIMEOUT: Duration = Duration::from_secs(10 * 60); const MAX_OUTPUT: usize = 12_000; /// Last `max` bytes of the output (failures show at the end). fn tail(s: &str, max: usize) -> String { if s.len() <= max { return s.to_string(); } let mut start = s.len() - max; while !s.is_char_boundary(start) { start += 1; } format!("…\n{}", &s[start..]) } fn command(app: &App, session: &Session, script: &str) -> Result { let script = format!("exec 2>&1\n{script}"); let cwd = std::path::Path::new(&session.cwd); if let Some(spec) = crate::sandbox::spec_for(app, session.owner_id.as_deref()) { if !crate::sandbox::available() { bail!("the sandbox (bubblewrap) is not available"); } return Ok(crate::sandbox::command(&spec, cwd, "bash", &["-c", &script])); } let mut c = tokio::process::Command::new("bash"); c.arg("-c").arg(&script).current_dir(cwd).env("CODECRAFT", "1").env("CI", "1").env("GIT_TERMINAL_PROMPT", "0").env("PAGER", "cat"); Ok(c) } /// Run the project's check for the turn that ended with `message_id` and publish the result. pub async fn run(app: &Arc, session: &Session, message_id: &str) -> Result<()> { let (cmd, _) = app.store.project_check(&session.cwd)?.ok_or_else(|| anyhow!("no check command set for this project"))?; let sid = session.id.clone(); let emit = |status: &str, code: Option, ms: u64, out: &str| { let _ = app.store.set_turn_check(&sid, message_id, status, code, ms, out); app.emit(Event::CheckResult { session_id: sid.clone(), message_id: message_id.into(), status: status.into(), exit_code: code, duration_ms: ms, output: out.into() }); }; emit("running", None, 0, ""); let started = now_ms(); let mut c = match command(app, session, &cmd) { Ok(c) => c, Err(e) => { emit("failed", None, 0, &format!("{e:#}")); return Ok(()); } }; let res = tokio::time::timeout(TIMEOUT, c.stdin(Stdio::null()).kill_on_drop(true).output()).await; let ms = (now_ms() - started).max(0) as u64; match res { Err(_) => emit("timeout", None, ms, &format!("Stopped after {} minutes.", TIMEOUT.as_secs() / 60)), Ok(Err(e)) => emit("failed", None, ms, &format!("could not run the command: {e}")), Ok(Ok(o)) => { let mut text = String::from_utf8_lossy(&o.stdout).to_string(); text.push_str(&String::from_utf8_lossy(&o.stderr)); emit(if o.status.success() { "passed" } else { "failed" }, o.status.code(), ms, &tail(text.trim(), MAX_OUTPUT)); } } Ok(()) } /// After a turn: run the check if the project has one and the turn changed files. pub fn spawn_after_turn(app: Arc, session_id: String, turn_started: i64) { tokio::spawn(async move { let Ok(session) = app.session(&session_id) else { return }; if session.parent_id.is_some() { return; } let Ok(Some((_, true))) = app.store.project_check(&session.cwd) else { return }; if app.store.checkpoints_since(&session_id, turn_started).map(|c| c.is_empty()).unwrap_or(true) { return; } let Some(last) = app.store.messages(&session_id).ok().and_then(|m| m.into_iter().rev().find(|m| m.role == Role::Assistant)) else { return }; if let Err(e) = run(&app, &session, &last.id).await { tracing::debug!("check failed to start: {e:#}"); } }); }