uira-orchestration 0.1.1

Agent definitions, SDK, tool registry, and hook implementations for Uira
Documentation
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::Duration;
use thiserror::Error;
use tokio::process::Command;
use tokio::time::timeout;
use uira_core::schema::GoalConfig;

#[derive(Error, Debug)]
pub enum GoalError {
    #[error("Command failed with exit code {0}")]
    CommandFailed(i32),

    #[error("Command timed out after {0} seconds")]
    Timeout(u64),

    #[error("Failed to parse score from output: {0}")]
    ParseError(String),

    #[error("Score {0} is out of valid range (0-100)")]
    ScoreOutOfRange(f64),

    #[error("IO error: {0}")]
    IoError(#[from] std::io::Error),

    #[error("Goal '{0}' not found")]
    GoalNotFound(String),
}

pub type GoalResult<T> = Result<T, GoalError>;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GoalCheckResult {
    pub name: String,
    pub score: f64,
    pub target: f64,
    pub passed: bool,
    pub checked_at: DateTime<Utc>,
    pub duration_ms: u64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
}

impl GoalCheckResult {
    pub fn success(name: String, score: f64, target: f64, duration_ms: u64) -> Self {
        Self {
            name,
            score,
            target,
            passed: score >= target,
            checked_at: Utc::now(),
            duration_ms,
            error: None,
        }
    }

    pub fn failure(name: String, target: f64, error: String) -> Self {
        Self {
            name,
            score: 0.0,
            target,
            passed: false,
            checked_at: Utc::now(),
            duration_ms: 0,
            error: Some(error),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerificationResult {
    pub all_passed: bool,
    pub results: Vec<GoalCheckResult>,
    pub checked_at: DateTime<Utc>,
    pub iteration: u32,
}

impl VerificationResult {
    pub fn summary(&self) -> String {
        let passed = self.results.iter().filter(|r| r.passed).count();
        let total = self.results.len();
        let details: Vec<String> = self
            .results
            .iter()
            .map(|r| {
                let status = if r.passed { "✓" } else { "✗" };
                format!("{} {}: {:.1}/{:.1}", status, r.name, r.score, r.target)
            })
            .collect();
        format!("Goals: {}/{} passed\n{}", passed, total, details.join("\n"))
    }
}

pub struct GoalRunner {
    project_root: PathBuf,
}

impl GoalRunner {
    pub fn new(project_root: impl AsRef<Path>) -> Self {
        Self {
            project_root: project_root.as_ref().to_path_buf(),
        }
    }

    pub async fn check_goal(&self, goal: &GoalConfig) -> GoalCheckResult {
        if !goal.enabled {
            return GoalCheckResult::success(goal.name.clone(), goal.target, goal.target, 0);
        }

        let start = std::time::Instant::now();

        match self.run_goal_command(goal).await {
            Ok(score) => {
                let duration_ms = start.elapsed().as_millis() as u64;
                GoalCheckResult::success(goal.name.clone(), score, goal.target, duration_ms)
            }
            Err(e) => GoalCheckResult::failure(goal.name.clone(), goal.target, e.to_string()),
        }
    }

    async fn run_goal_command(&self, goal: &GoalConfig) -> GoalResult<f64> {
        let working_dir = match &goal.workspace {
            Some(ws) => self.project_root.join(ws),
            None => self.project_root.clone(),
        };

        let timeout_duration = Duration::from_secs(goal.timeout_secs);

        let output = timeout(timeout_duration, async {
            Command::new("sh")
                .arg("-c")
                .arg(&goal.command)
                .current_dir(&working_dir)
                .output()
                .await
        })
        .await
        .map_err(|_| GoalError::Timeout(goal.timeout_secs))?
        .map_err(GoalError::IoError)?;

        if !output.status.success() {
            let code = output.status.code().unwrap_or(-1);
            return Err(GoalError::CommandFailed(code));
        }

        let stdout = String::from_utf8_lossy(&output.stdout);
        self.parse_score(&stdout)
    }

    fn parse_score(&self, output: &str) -> GoalResult<f64> {
        let trimmed = output.trim();

        if trimmed.is_empty() {
            return Err(GoalError::ParseError("Empty output".to_string()));
        }

        for line in trimmed.lines().rev() {
            let line = line.trim();
            if let Ok(score) = line.parse::<f64>() {
                if !(0.0..=100.0).contains(&score) {
                    return Err(GoalError::ScoreOutOfRange(score));
                }
                return Ok(score);
            }
        }

        let truncated: String = trimmed.chars().take(100).collect();
        Err(GoalError::ParseError(format!(
            "Could not parse score from: {}",
            truncated
        )))
    }

    pub async fn check_all(&self, goals: &[GoalConfig]) -> VerificationResult {
        let mut results = Vec::with_capacity(goals.len());

        for goal in goals {
            let result = self.check_goal(goal).await;
            results.push(result);
        }

        let all_passed = results.iter().all(|r| r.passed);

        VerificationResult {
            all_passed,
            results,
            checked_at: Utc::now(),
            iteration: 0,
        }
    }

    pub async fn verify_until_complete(
        &self,
        goals: &[GoalConfig],
        options: VerifyOptions,
    ) -> VerificationResult {
        let mut iteration = 0;
        let start = std::time::Instant::now();

        loop {
            iteration += 1;

            let mut result = self.check_all(goals).await;
            result.iteration = iteration;

            if let Some(callback) = &options.on_progress {
                callback(&result);
            }

            if result.all_passed {
                return result;
            }

            if iteration >= options.max_iterations {
                return result;
            }

            if let Some(max_duration) = options.max_duration {
                if start.elapsed() >= max_duration {
                    return result;
                }
            }

            tokio::time::sleep(Duration::from_secs(options.check_interval_secs)).await;
        }
    }
}

pub struct VerifyOptions {
    pub check_interval_secs: u64,
    pub max_iterations: u32,
    pub max_duration: Option<Duration>,
    #[allow(clippy::type_complexity)]
    pub on_progress: Option<Box<dyn Fn(&VerificationResult) + Send + Sync>>,
}

impl Default for VerifyOptions {
    fn default() -> Self {
        Self {
            check_interval_secs: 30,
            max_iterations: 100,
            max_duration: None,
            on_progress: None,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn make_goal(name: &str, command: &str, target: f64) -> GoalConfig {
        GoalConfig {
            name: name.to_string(),
            workspace: None,
            command: command.to_string(),
            target,
            timeout_secs: 10,
            enabled: true,
            description: None,
        }
    }

    #[tokio::test]
    async fn test_simple_score_command() {
        let runner = GoalRunner::new(".");
        let goal = make_goal("test", "echo 85.5", 80.0);
        let result = runner.check_goal(&goal).await;
        assert!(result.passed);
        assert!((result.score - 85.5).abs() < 0.01);
    }

    #[tokio::test]
    async fn test_failing_score() {
        let runner = GoalRunner::new(".");
        let goal = make_goal("test", "echo 50", 80.0);
        let result = runner.check_goal(&goal).await;
        assert!(!result.passed);
        assert!((result.score - 50.0).abs() < 0.01);
    }

    #[tokio::test]
    async fn test_command_failure() {
        let runner = GoalRunner::new(".");
        let goal = make_goal("test", "exit 1", 80.0);
        let result = runner.check_goal(&goal).await;
        assert!(!result.passed);
        assert!(result.error.is_some());
    }

    #[tokio::test]
    async fn test_invalid_output() {
        let runner = GoalRunner::new(".");
        let goal = make_goal("test", "echo 'not a number'", 80.0);
        let result = runner.check_goal(&goal).await;
        assert!(!result.passed);
        assert!(result.error.is_some());
    }

    #[tokio::test]
    async fn test_multiline_output() {
        let runner = GoalRunner::new(".");
        let goal = make_goal("test", "echo 'debug info'; echo 92.5", 80.0);
        let result = runner.check_goal(&goal).await;
        assert!(result.passed);
        assert!((result.score - 92.5).abs() < 0.01);
    }

    #[tokio::test]
    async fn test_disabled_goal() {
        let runner = GoalRunner::new(".");
        let mut goal = make_goal("test", "exit 1", 80.0);
        goal.enabled = false;
        let result = runner.check_goal(&goal).await;
        assert!(result.passed);
    }

    #[tokio::test]
    async fn test_score_out_of_range() {
        let runner = GoalRunner::new(".");
        let goal = make_goal("test", "echo 150", 80.0);
        let result = runner.check_goal(&goal).await;
        assert!(!result.passed);
        assert!(result.error.as_ref().unwrap().contains("out of"));
    }

    #[tokio::test]
    async fn test_check_all() {
        let runner = GoalRunner::new(".");
        let goals = vec![
            make_goal("pass1", "echo 90", 80.0),
            make_goal("pass2", "echo 100", 90.0),
            make_goal("fail1", "echo 50", 80.0),
        ];
        let result = runner.check_all(&goals).await;
        assert!(!result.all_passed);
        assert_eq!(result.results.len(), 3);
        assert_eq!(result.results.iter().filter(|r| r.passed).count(), 2);
    }

    #[test]
    fn test_parse_score_valid() {
        let runner = GoalRunner::new(".");
        assert!((runner.parse_score("85.5\n").unwrap() - 85.5).abs() < 0.01);
        assert!((runner.parse_score("100").unwrap() - 100.0).abs() < 0.01);
        assert!((runner.parse_score("0").unwrap() - 0.0).abs() < 0.01);
        assert!((runner.parse_score("debug\n92.5").unwrap() - 92.5).abs() < 0.01);
    }

    #[test]
    fn test_parse_score_invalid() {
        let runner = GoalRunner::new(".");
        assert!(runner.parse_score("").is_err());
        assert!(runner.parse_score("not a number").is_err());
        assert!(runner.parse_score("150").is_err());
        assert!(runner.parse_score("-10").is_err());
    }

    #[test]
    fn test_goal_check_result_success() {
        let result = GoalCheckResult::success("test".to_string(), 90.0, 80.0, 100);
        assert!(result.passed);
        assert!(result.error.is_none());
    }

    #[test]
    fn test_goal_check_result_failure() {
        let result = GoalCheckResult::failure("test".to_string(), 80.0, "timeout".to_string());
        assert!(!result.passed);
        assert!(result.error.is_some());
    }

    #[test]
    fn test_verification_summary() {
        let result = VerificationResult {
            all_passed: false,
            results: vec![
                GoalCheckResult::success("pass".to_string(), 90.0, 80.0, 100),
                GoalCheckResult::success("fail".to_string(), 50.0, 80.0, 100),
            ],
            checked_at: Utc::now(),
            iteration: 1,
        };
        let summary = result.summary();
        assert!(summary.contains("1/2 passed"));
        assert!(summary.contains("pass"));
        assert!(summary.contains("fail"));
    }
}