swarm / office/src/verify.rs

raw file 6343 bytes
//! 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())
}