//! 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<String>,
}
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<String> = 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<String, Vec<String>> = BTreeMap::new();
let mut seen: Vec<String> = 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::<Vec<_>>().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<String> {
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())
}