use std::sync::Arc;
use std::time::Instant;
use anyhow::Result;
use tokio::sync::RwLock;
use crate::communication::{AgentMessage, CommunicationHub};
use crate::file_locks::{FileLockManager, LockGuard, LockType};
use crate::validation_loop::{
ValidationConfig, ValidationResult, format_validation_feedback, run_validation,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidatorAgentStatus {
Idle,
AcquiringLocks,
Validating,
Passed,
Failed(usize),
Error(String),
}
impl std::fmt::Display for ValidatorAgentStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Idle => write!(f, "Idle"),
Self::AcquiringLocks => write!(f, "Acquiring locks"),
Self::Validating => write!(f, "Validating"),
Self::Passed => write!(f, "Passed"),
Self::Failed(n) => write!(f, "Failed ({} issues)", n),
Self::Error(e) => write!(f, "Error: {}", e),
}
}
}
#[derive(Debug, Clone)]
pub struct ValidatorAgentConfig {
pub validation_config: ValidationConfig,
pub timeout_secs: u64,
}
impl Default for ValidatorAgentConfig {
fn default() -> Self {
Self {
validation_config: ValidationConfig::default(),
timeout_secs: 120,
}
}
}
impl ValidatorAgentConfig {
pub fn new(validation_config: ValidationConfig) -> Self {
Self {
validation_config,
..Default::default()
}
}
pub fn with_timeout(mut self, secs: u64) -> Self {
self.timeout_secs = secs;
self
}
}
#[derive(Debug, Clone)]
pub struct ValidatorAgentResult {
pub agent_id: String,
pub success: bool,
pub validation_result: ValidationResult,
pub feedback: String,
pub duration: std::time::Duration,
pub files_checked: usize,
pub locks_acquired: usize,
}
pub struct ValidatorAgent {
pub id: String,
pub config: ValidatorAgentConfig,
pub communication_hub: Arc<CommunicationHub>,
pub file_lock_manager: Arc<FileLockManager>,
pub status: Arc<RwLock<ValidatorAgentStatus>>,
}
impl ValidatorAgent {
pub fn new(
id: impl Into<String>,
config: ValidatorAgentConfig,
communication_hub: Arc<CommunicationHub>,
file_lock_manager: Arc<FileLockManager>,
) -> Self {
Self {
id: id.into(),
config,
communication_hub,
file_lock_manager,
status: Arc::new(RwLock::new(ValidatorAgentStatus::Idle)),
}
}
#[tracing::instrument(name = "validator_agent.validate", skip(self), fields(agent_id = %self.id))]
pub async fn validate(&self) -> Result<ValidatorAgentResult> {
let start = Instant::now();
self.communication_hub
.register_agent(self.id.clone())
.await?;
if let Err(e) = self
.communication_hub
.broadcast(
self.id.clone(),
AgentMessage::AgentSpawned {
agent_id: self.id.clone(),
task_id: format!("validation-{}", self.id),
},
)
.await
{
tracing::warn!(agent_id = %self.id, "Failed to broadcast validator spawn: {}", e);
}
self.set_status(ValidatorAgentStatus::AcquiringLocks).await;
let mut lock_guards: Vec<LockGuard> = Vec::new();
for file in &self.config.validation_config.working_set_files {
let path = std::path::PathBuf::from(&self.config.validation_config.working_directory)
.join(file);
match self
.file_lock_manager
.acquire_lock(&self.id, &path, LockType::Read)
.await
{
Ok(guard) => {
lock_guards.push(guard);
}
Err(e) => {
tracing::warn!(
agent_id = %self.id,
file = %file,
"Failed to acquire read lock (best-effort, continuing): {}",
e
);
}
}
}
let locks_acquired = lock_guards.len();
self.set_status(ValidatorAgentStatus::Validating).await;
let timeout = tokio::time::Duration::from_secs(self.config.timeout_secs);
let validation_result =
match tokio::time::timeout(timeout, run_validation(&self.config.validation_config))
.await
{
Ok(Ok(result)) => result,
Ok(Err(e)) => {
let err_msg = format!("Validation error: {}", e);
self.set_status(ValidatorAgentStatus::Error(err_msg.clone()))
.await;
self.cleanup(&lock_guards, false, &err_msg, start).await;
return Err(e);
}
Err(_elapsed) => {
let err_msg =
format!("Validation timed out after {}s", self.config.timeout_secs);
self.set_status(ValidatorAgentStatus::Error(err_msg.clone()))
.await;
self.cleanup(&lock_guards, false, &err_msg, start).await;
return Err(anyhow::anyhow!("{}", err_msg));
}
};
let success = validation_result.passed;
let issue_count = validation_result.issues.len();
let files_checked = self.config.validation_config.working_set_files.len();
let feedback = format_validation_feedback(&validation_result);
if success {
self.set_status(ValidatorAgentStatus::Passed).await;
} else {
self.set_status(ValidatorAgentStatus::Failed(issue_count))
.await;
}
let summary = if success {
"All validation checks passed".to_string()
} else {
format!("Validation failed with {} issues", issue_count)
};
self.cleanup(&lock_guards, success, &summary, start).await;
Ok(ValidatorAgentResult {
agent_id: self.id.clone(),
success,
validation_result,
feedback,
duration: start.elapsed(),
files_checked,
locks_acquired,
})
}
async fn set_status(&self, status: ValidatorAgentStatus) {
*self.status.write().await = status;
}
async fn cleanup(
&self,
_lock_guards: &[LockGuard],
_success: bool,
summary: &str,
_start: Instant,
) {
self.file_lock_manager.release_all_locks(&self.id).await;
if let Err(e) = self
.communication_hub
.broadcast(
self.id.clone(),
AgentMessage::AgentCompleted {
agent_id: self.id.clone(),
task_id: format!("validation-{}", self.id),
summary: summary.to_string(),
},
)
.await
{
tracing::warn!(agent_id = %self.id, "Failed to broadcast validator completion: {}", e);
}
if let Err(e) = self.communication_hub.unregister_agent(&self.id).await {
tracing::warn!(agent_id = %self.id, "Failed to unregister validator agent: {}", e);
}
}
}
pub fn spawn_validator_agent(
agent: Arc<ValidatorAgent>,
) -> tokio::task::JoinHandle<Result<ValidatorAgentResult>> {
tokio::spawn(async move { agent.validate().await })
}
#[cfg(test)]
mod tests {
use super::*;
fn make_hub_and_locks() -> (Arc<CommunicationHub>, Arc<FileLockManager>) {
(
Arc::new(CommunicationHub::new()),
Arc::new(FileLockManager::new()),
)
}
#[tokio::test]
async fn test_validator_disabled() {
let (hub, locks) = make_hub_and_locks();
let config = ValidatorAgentConfig::new(ValidationConfig::disabled());
let agent = ValidatorAgent::new("val-disabled", config, hub, locks);
let result = agent.validate().await.unwrap();
assert!(result.success);
assert!(result.validation_result.issues.is_empty());
}
#[tokio::test]
async fn test_validator_detects_missing_file() {
let (hub, locks) = make_hub_and_locks();
let dir = tempfile::tempdir().unwrap();
let mut vc = ValidationConfig::default();
vc.working_directory = dir.path().to_string_lossy().to_string();
vc.working_set_files = vec!["nonexistent.rs".to_string()];
let config = ValidatorAgentConfig::new(vc);
let agent = ValidatorAgent::new("val-missing", config, hub, locks);
let result = agent.validate().await.unwrap();
assert!(!result.success);
assert!(
result
.validation_result
.issues
.iter()
.any(|i| i.check == "file_existence")
);
}
#[tokio::test]
async fn test_validator_registers_and_unregisters() {
let (hub, locks) = make_hub_and_locks();
let config = ValidatorAgentConfig::new(ValidationConfig::disabled());
let agent = ValidatorAgent::new("val-hub", config, Arc::clone(&hub), locks);
assert!(!hub.is_registered("val-hub").await);
let _result = agent.validate().await.unwrap();
assert!(!hub.is_registered("val-hub").await);
}
#[tokio::test]
async fn test_validator_acquires_read_locks() {
let (hub, locks) = make_hub_and_locks();
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("locked.rs");
std::fs::write(&file_path, "fn main() {}").unwrap();
let _write_guard = locks
.acquire_lock("other-agent", &file_path, LockType::Write)
.await
.unwrap();
let mut vc = ValidationConfig::disabled();
vc.working_directory = dir.path().to_string_lossy().to_string();
vc.working_set_files = vec!["locked.rs".to_string()];
let config = ValidatorAgentConfig::new(vc);
let agent = ValidatorAgent::new("val-lock", config, hub, locks);
let result = agent.validate().await.unwrap();
assert!(result.success);
assert_eq!(result.locks_acquired, 0);
}
#[tokio::test]
async fn test_validator_concurrent_read_locks() {
let (hub, locks) = make_hub_and_locks();
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("shared.rs");
std::fs::write(&file_path, "fn main() {}").unwrap();
let _read_guard = locks
.acquire_lock("reader-agent", &file_path, LockType::Read)
.await
.unwrap();
let mut vc = ValidationConfig::disabled();
vc.working_directory = dir.path().to_string_lossy().to_string();
vc.working_set_files = vec!["shared.rs".to_string()];
let config = ValidatorAgentConfig::new(vc);
let agent = ValidatorAgent::new("val-read", config, hub, locks);
let result = agent.validate().await.unwrap();
assert!(result.success);
assert_eq!(result.locks_acquired, 1);
}
#[tokio::test]
async fn test_spawn_validator_agent() {
let (hub, locks) = make_hub_and_locks();
let config = ValidatorAgentConfig::new(ValidationConfig::disabled());
let agent = Arc::new(ValidatorAgent::new("val-spawn", config, hub, locks));
let handle = spawn_validator_agent(agent);
let result = handle.await.unwrap().unwrap();
assert!(result.success);
assert_eq!(result.agent_id, "val-spawn");
}
#[tokio::test]
async fn test_result_metadata() {
let (hub, locks) = make_hub_and_locks();
let dir = tempfile::tempdir().unwrap();
let file1 = dir.path().join("a.rs");
let file2 = dir.path().join("b.rs");
std::fs::write(&file1, "fn a() {}").unwrap();
std::fs::write(&file2, "fn b() {}").unwrap();
let mut vc = ValidationConfig::disabled();
vc.working_directory = dir.path().to_string_lossy().to_string();
vc.working_set_files = vec!["a.rs".to_string(), "b.rs".to_string()];
let config = ValidatorAgentConfig::new(vc);
let agent = ValidatorAgent::new("val-meta", config, hub, locks);
let result = agent.validate().await.unwrap();
assert_eq!(result.agent_id, "val-meta");
assert_eq!(result.files_checked, 2);
assert!(result.duration.as_nanos() > 0);
assert!(result.success);
}
}