ai-dispatch 8.92.0

Multi-AI CLI team orchestrator
// Project runtime state (.aid/state.toml) — computed from task history.
// Exports: ProjectState IO helpers, compute_state, and prompt summary formatting.
// Deps: anyhow, chrono, serde, toml, std, crate::{project, store, types}.
use anyhow::{Context, Result, bail};
use chrono::{DateTime, Local};
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::project::detect_project;
use crate::store::Store;
use crate::types::{Task, TaskFilter, TaskStatus};
const STATE_HEADER: &str = "# Auto-generated by aid. Do not edit manually.";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProjectState {
    pub last_updated: String,
    pub health: HealthState,
    pub performance: PerformanceState,
    pub context: ContextState,
    pub learned: LearnedState,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HealthState {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_verify_status: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_verify_time: Option<String>,
    pub recent_success_rate: f64,
    pub total_tasks: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PerformanceState {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub best_agent: Option<String>,
    pub agent_success_rates: BTreeMap<String, f64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub avg_task_duration_secs: Option<f64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub avg_task_cost_usd: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ContextState {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_task_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_task_agent: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub active_branch: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LearnedState {
    #[serde(default)]
    pub effective_tools: Vec<String>,
    #[serde(default)]
    pub common_failure_patterns: Vec<String>,
}
pub fn state_path() -> Option<PathBuf> {
    let cwd = std::env::current_dir().ok()?;
    state_path_from(&cwd)
}
pub fn load_state() -> Result<Option<ProjectState>> {
    let Some(path) = state_path() else {
        return Ok(None);
    };
    if !path.is_file() {
        return Ok(None);
    }
    let contents = fs::read_to_string(&path)
        .with_context(|| format!("Failed to read {}", path.display()))?;
    let state = toml::from_str(&contents)
        .with_context(|| format!("Failed to parse {}", path.display()))?;
    Ok(Some(state))
}
pub fn save_state(state: &ProjectState) -> Result<()> {
    let Some(path) = state_path() else {
        bail!("Could not find project .aid directory for state.toml");
    };
    let body = toml::to_string_pretty(state).context("Failed to serialize project state")?;
    fs::write(&path, format!("{STATE_HEADER}\n\n{body}"))
        .with_context(|| format!("Failed to write {}", path.display()))?;
    Ok(())
}
pub(crate) fn refresh_project_state(store: &Store, task_id: &crate::types::TaskId) {
    let Ok(Some(task)) = store.get_task(task_id.as_str()) else { return };
    let Some(repo_path) = task.repo_path.as_deref() else { return };
    match compute_state(store, repo_path) {
        Ok(state) => {
            if let Err(err) = save_state(&state) {
                aid_warn!("[aid] Failed to save project state: {err}");
            }
        }
        Err(err) => aid_warn!("[aid] Failed to compute project state: {err}"),
    }
}
pub fn compute_state(store: &Store, repo_path: &str) -> Result<ProjectState> {
    let recent_tasks = store.recent_tasks_for_project(repo_path, 50)?;
    let all_tasks = all_tasks_for_project(store, repo_path)?;
    let verify = store.last_verify_event(repo_path)?;
    let agent_rates = store.project_agent_success_rates(repo_path)?;
    let agent_success_rates = agent_rates
        .iter()
        .map(|(agent, rate, _)| (agent.clone(), *rate))
        .collect::<BTreeMap<_, _>>();
    let best_agent = agent_success_rates
        .iter()
        .max_by(|left, right| {
            left.1
                .partial_cmp(right.1)
                .unwrap_or(Ordering::Equal)
                .then_with(|| right.0.cmp(left.0))
        })
        .map(|(agent, _)| agent.clone());
    let avg_task_duration_secs = average(
        all_tasks
            .iter()
            .filter_map(|task| task.duration_ms.map(|ms| ms as f64 / 1000.0))
            .collect(),
    );
    let total_tasks = u64::try_from(all_tasks.len()).unwrap_or(u64::MAX);
    let recent_success_rate = if recent_tasks.is_empty() {
        0.0
    } else {
        let successes = recent_tasks
            .iter()
            .filter(|task| matches!(task.status, TaskStatus::Done | TaskStatus::Merged))
            .count();
        successes as f64 / recent_tasks.len() as f64
    };
    let last_task = all_tasks.first();
    Ok(ProjectState {
        last_updated: Local::now().to_rfc3339(),
        health: HealthState {
            last_verify_status: verify.as_ref().map(|(detail, _)| detail.clone()),
            last_verify_time: verify.map(|(_, timestamp)| timestamp),
            recent_success_rate,
            total_tasks,
        },
        performance: PerformanceState {
            best_agent,
            agent_success_rates,
            avg_task_duration_secs,
            avg_task_cost_usd: store.project_avg_cost(repo_path)?,
        },
        context: ContextState {
            last_task_id: last_task.map(|task| task.id.to_string()),
            last_task_agent: last_task.map(|task| task.agent_display_name().to_string()),
            active_branch: git_branch(repo_path),
        },
        learned: LearnedState {
            effective_tools: Vec::new(),
            common_failure_patterns: Vec::new(),
        },
    })
}
pub fn format_state_summary(state: &ProjectState) -> String {
    let recent_total = usize::min(state.health.total_tasks as usize, 50);
    let recent_successes = (state.health.recent_success_rate * recent_total as f64).round() as usize;
    let verify = state.health.last_verify_status.as_deref().unwrap_or("unknown");
    let best_agents = top_agents(&state.performance.agent_success_rates);
    let task = match (&state.context.last_task_id, &state.context.last_task_agent) {
        (Some(id), Some(agent)) => format!("{id} ({agent})"),
        (Some(id), None) => id.clone(),
        _ => "none".to_string(),
    };
    let branch = state.context.active_branch.as_deref().unwrap_or("unknown");
    let updated = format_relative_time(&state.last_updated);
    format!(
        "[Project State: {}]\nHealth: {:.0}% success ({recent_successes}/{recent_total} recent), verify: {verify}\nBest agents: {best_agents}\nLast task: {task}\nBranch: {branch}, updated {updated}",
        project_label(),
        state.health.recent_success_rate * 100.0,
    )
}
fn state_path_from(start_dir: &Path) -> Option<PathBuf> {
    let mut dir = start_dir.to_path_buf();
    loop {
        let aid_dir = dir.join(".aid");
        if aid_dir.is_dir() {
            return Some(aid_dir.join("state.toml"));
        }
        if !dir.pop() {
            return None;
        }
    }
}
fn all_tasks_for_project(store: &Store, repo_path: &str) -> Result<Vec<Task>> {
    Ok(store
        .list_tasks(TaskFilter::All)?
        .into_iter()
        .filter(|task| task.repo_path.as_deref() == Some(repo_path))
        .collect())
}

fn average(values: Vec<f64>) -> Option<f64> {
    (!values.is_empty()).then(|| values.iter().sum::<f64>() / values.len() as f64)
}

fn top_agents(rates: &BTreeMap<String, f64>) -> String {
    let mut items: Vec<_> = rates.iter().collect();
    items.sort_by(|left, right| {
        right.1
            .partial_cmp(left.1)
            .unwrap_or(Ordering::Equal)
            .then_with(|| left.0.cmp(right.0))
    });
    if items.is_empty() {
        return "n/a".to_string();
    }
    items
        .into_iter()
        .take(2)
        .map(|(agent, rate)| format!("{agent} ({:.0}%)", rate * 100.0))
        .collect::<Vec<_>>()
        .join(", ")
}

fn git_branch(repo_path: &str) -> Option<String> {
    let output = Command::new("git")
        .args(["rev-parse", "--abbrev-ref", "HEAD"])
        .current_dir(repo_path)
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
    (!branch.is_empty()).then_some(branch)
}

fn project_label() -> String {
    project_id_from_aid_dir()
        .or_else(|| detect_project().map(|config| config.id))
        .or_else(|| {
            std::env::current_dir()
                .ok()
                .and_then(|dir| dir.file_name().map(|name| name.to_string_lossy().to_string()))
        })
        .unwrap_or_else(|| "unknown".to_string())
}

fn project_id_from_aid_dir() -> Option<String> {
    let cwd = std::env::current_dir().ok()?;
    let state_path = state_path_from(&cwd)?;
    let project_path = state_path.parent()?.join("project.toml");
    crate::project::load_project(&project_path).ok().map(|config| config.id)
}

fn format_relative_time(value: &str) -> String {
    let Ok(parsed) = DateTime::parse_from_rfc3339(value) else {
        return value.to_string();
    };
    let seconds = Local::now().signed_duration_since(parsed.with_timezone(&Local)).num_seconds();
    if seconds <= 0 { "just now".to_string() }
    else if seconds < 60 { format!("{seconds}s ago") }
    else if seconds < 3600 { format!("{}m ago", seconds / 60) }
    else if seconds < 86_400 { format!("{}h ago", seconds / 3600) }
    else { format!("{}d ago", seconds / 86_400) }
}