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 {
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 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) }
}