//! The build gate / verifier: runs the company's `[verify]` command (e.g. the //! judge harness) and feeds every FAIL back to the file's owner by email. //! //! [verify] //! command = "python3 work/project/judge.py" //! timeout_secs = 120 //! //! Contract for the command's stdout: lines starting with `FAIL ` mark a //! failure (the rest of the line should name a file or problem slug); the //! full output is saved to work/project/verify_report.md so agents can read //! it in the work browser, and one email per failing owner is delivered. use crate::company::{CompanyCfg, Workspace}; use crate::tasks::TaskBoard; use bevy::prelude::*; use std::collections::BTreeMap; use std::path::Path; use std::process::Command; pub struct VerifyReport { pub ran: bool, pub ok: bool, /// Full stdout+stderr of the command. pub output: String, /// Lines that began with `FAIL `. pub fails: Vec, } impl VerifyReport { pub fn skipped() -> Self { Self { ran: false, ok: true, output: String::new(), fails: Vec::new() } } pub fn summary(&self) -> String { if !self.ran { "verify: no [verify] command configured".into() } else if self.fails.is_empty() && self.ok { format!("verify: all green") } else { format!("verify: {} failing marker(s), report at work/project/verify_report.md", self.fails.len()) } } } /// Run the company verify command from the company dir (work/'s parent). /// Guarded by `timeout` so a wedged judge cannot freeze the sim forever. pub fn run_full(cfg: &CompanyCfg, workspace: &Workspace) -> VerifyReport { run_with(cfg.verify.as_ref(), workspace, "verify_report.md") } /// Cheap per-commit check (engine calls it the moment a commit lands). pub fn run_fast(cfg: &CompanyCfg, workspace: &Workspace) -> VerifyReport { run_with(cfg.verify_fast.as_ref(), workspace, "check_report.md") } fn run_with( cfg_verify: Option<&crate::company::VerifyCfg>, workspace: &Workspace, report_name: &str, ) -> VerifyReport { let cfg_verify = match cfg_verify { Some(v) => v, None => return VerifyReport::skipped(), }; let cwd = workspace .root .parent() .unwrap_or(Path::new(".")) .to_path_buf(); let output = Command::new("timeout") .arg(format!("{}s", cfg_verify.timeout_secs.max(5))) .arg("sh") .arg("-c") .arg(&cfg_verify.command) .current_dir(&cwd) .output(); let mut output = match output { Ok(o) => o, Err(e) => { warn!("[verify] could not run {:?}: {e}", cfg_verify.command); return VerifyReport { ran: true, ok: false, output: format!("could not run: {e}"), fails: vec!["FAIL (verifier could not run)".into()] }; } }; let mut text = String::from_utf8_lossy(&output.stdout).into_owned(); if !output.stderr.is_empty() { text.push_str("\n--- stderr ---\n"); text.push_str(&String::from_utf8_lossy(&output.stderr)); } let fails: Vec = text .lines() .filter(|l| l.trim_start().starts_with("FAIL ")) .map(|l| l.trim_start().to_string()) .collect(); let ok = output.status.success() && fails.is_empty(); let report = VerifyReport { ran: true, ok, output: text.clone(), fails }; // Publish the report where agents can read it (work browser → project). let report_path = workspace.project_root().join(report_name); let stamp = format!("# verify report\n\n{}", text); std::fs::write(report_path, stamp).ok(); report } /// Email every failing file's owner (and note it on any task the FAIL line /// mentions, e.g. "FAIL ... T3"). Returns number of mails sent. pub fn apply( report: &VerifyReport, board: &mut TaskBoard, workspace: &Workspace, day: u32, manager_name: &str, ) -> usize { if !report.ran || report.fails.is_empty() { return 0; } let mut sent = 0usize; let mut per_owner: BTreeMap> = BTreeMap::new(); let mut seen: Vec = Vec::new(); for fail in &report.fails { let owner = owner_for(fail, board).unwrap_or_else(|| manager_name.to_string()); // One ticket per owner+file even if three cases failed in the same run. if !seen.iter().any(|s| s == &format!("{owner}|{fail}")) { seen.push(format!("{owner}|{fail}")); per_owner.entry(owner).or_default().push(fail.clone()); } // Record on the task the line names (if any task id appears). for t in &mut board.tasks { if fail.contains(&t.id) && !t.notes.iter().any(|n| n == fail) { t.notes.push(format!("day {day} verifier: {fail}")); } } } for (owner, lines) in per_owner { let file = format!("day{:02}_VERIFY-FAILURES.md", day); let body = format!( "From: automated verifier\nTo: {owner}\nSubject: build gate: {} failing check(s), day {day}\nDay {day}\n\nThe automation red-flagged work you own. The full log is in\nwork/project/verify_report.md. Fix your files so these pass:\n\n{}\n", lines.len(), lines.iter().map(|l| format!("- {l}")).collect::>().join("\n") ); std::fs::write(workspace.mail_inbox(&owner).join(&file), body).ok(); info!("[verify] mailed {owner}: {} failing check(s)", lines.len()); sent += 1; } sent } /// Find whose file a FAIL line names: any tracked repo path that appears in /// the line (case-insensitive full path or just the file name match). fn owner_for(fail: &str, board: &TaskBoard) -> Option { let hay = fail.to_lowercase(); // Longest tracked path wins (solutions/x/y.py beats bare y.py). let mut best: Option<(&String, &String)> = None; for (path, owner) in &board.file_owners { let pl = path.to_lowercase(); let hit = hay.contains(&pl) || Path::new(&pl) .file_name() .and_then(|f| f.to_str()) .map(|f| hay.contains(f)) .unwrap_or(false); if hit && best.map(|(b, _)| pl.len() > b.len()).unwrap_or(true) { best = Some((path, owner)); } } best.map(|(_, o)| o.clone()) }