use super::models::{NewUnifiedFleetAgent, UnifiedFleetAgent};
use super::DAL;
use crate::database::schema::unified::fleet_agents;
use crate::database::universal_types::UniversalTimestamp;
use crate::error::ValidationError;
use diesel::prelude::*;
#[derive(Debug, Clone)]
pub struct FleetAgent {
pub agent_id: String,
pub tenant_id: Option<String>,
pub target_triple: String,
pub capabilities: Vec<String>,
pub max_concurrency: u32,
pub in_flight: u32,
pub available_capacity: u32,
pub registered_at: UniversalTimestamp,
pub last_heartbeat_at: UniversalTimestamp,
}
#[derive(Debug, Clone)]
pub struct FleetAgentRegistration {
pub agent_id: String,
pub tenant_id: Option<String>,
pub target_triple: String,
pub capabilities: Vec<String>,
pub max_concurrency: u32,
}
fn to_domain(r: UnifiedFleetAgent) -> FleetAgent {
FleetAgent {
agent_id: r.agent_id,
tenant_id: r.tenant_id,
target_triple: r.target_triple,
capabilities: serde_json::from_str(&r.capabilities).unwrap_or_default(),
max_concurrency: r.max_concurrency.max(0) as u32,
in_flight: r.in_flight.max(0) as u32,
available_capacity: r.available_capacity.max(0) as u32,
registered_at: r.registered_at,
last_heartbeat_at: r.last_heartbeat_at,
}
}
fn cutoff_for(timeout: std::time::Duration) -> UniversalTimestamp {
let now = UniversalTimestamp::now();
UniversalTimestamp(
now.0
- chrono::Duration::from_std(timeout).unwrap_or_else(|_| chrono::Duration::seconds(0)),
)
}
#[derive(Clone)]
pub struct FleetAgentDAL<'a> {
dal: &'a DAL,
}
impl<'a> FleetAgentDAL<'a> {
pub fn new(dal: &'a DAL) -> Self {
Self { dal }
}
pub async fn upsert_registration(
&self,
reg: FleetAgentRegistration,
) -> Result<(), ValidationError> {
let now = UniversalTimestamp::now();
let row = NewUnifiedFleetAgent {
agent_id: reg.agent_id,
tenant_id: reg.tenant_id,
target_triple: reg.target_triple,
capabilities: serde_json::to_string(®.capabilities)
.unwrap_or_else(|_| "[]".to_string()),
max_concurrency: reg.max_concurrency.min(i32::MAX as u32) as i32,
in_flight: 0,
available_capacity: reg.max_concurrency.min(i32::MAX as u32) as i32,
registered_at: now,
last_heartbeat_at: now,
};
crate::interact_on_backend!(self.dal, |conn| {
diesel::insert_into(fleet_agents::table)
.values(&row)
.on_conflict(fleet_agents::agent_id)
.do_update()
.set(&row)
.execute(conn)
})
.map_err(ValidationError::from)?;
Ok(())
}
pub async fn record_heartbeat(
&self,
agent_id: &str,
in_flight: u32,
available_capacity: u32,
) -> Result<bool, ValidationError> {
let agent_id = agent_id.to_string();
let now = UniversalTimestamp::now();
let affected = crate::interact_on_backend!(self.dal, |conn| {
diesel::update(fleet_agents::table.filter(fleet_agents::agent_id.eq(&agent_id)))
.set((
fleet_agents::in_flight.eq(in_flight.min(i32::MAX as u32) as i32),
fleet_agents::available_capacity
.eq(available_capacity.min(i32::MAX as u32) as i32),
fleet_agents::last_heartbeat_at.eq(now),
))
.execute(conn)
})
.map_err(ValidationError::from)?;
Ok(affected == 1)
}
pub async fn agent_tenant(
&self,
agent_id: &str,
) -> Result<Option<Option<String>>, ValidationError> {
let agent_id = agent_id.to_string();
let row: Option<Option<String>> = crate::interact_on_backend!(self.dal, |conn| {
fleet_agents::table
.filter(fleet_agents::agent_id.eq(&agent_id))
.select(fleet_agents::tenant_id)
.first::<Option<String>>(conn)
.optional()
})
.map_err(ValidationError::from)?;
Ok(row)
}
pub async fn list_live(
&self,
timeout: std::time::Duration,
) -> Result<Vec<FleetAgent>, ValidationError> {
let cutoff = cutoff_for(timeout);
let rows: Vec<UnifiedFleetAgent> = crate::interact_on_backend!(self.dal, |conn| {
fleet_agents::table
.filter(fleet_agents::last_heartbeat_at.ge(cutoff))
.order(fleet_agents::agent_id.asc())
.load(conn)
})
.map_err(ValidationError::from)?;
Ok(rows.into_iter().map(to_domain).collect())
}
pub async fn sweep_dead(
&self,
timeout: std::time::Duration,
) -> Result<Vec<FleetAgent>, ValidationError> {
let cutoff = cutoff_for(timeout);
let rows: Vec<UnifiedFleetAgent> = crate::interact_on_backend!(self.dal, |conn| {
let stale: Vec<UnifiedFleetAgent> = fleet_agents::table
.filter(fleet_agents::last_heartbeat_at.lt(cutoff))
.load(conn)?;
let mut owned = Vec::with_capacity(stale.len());
for row in stale {
let affected = diesel::delete(
fleet_agents::table
.filter(fleet_agents::agent_id.eq(&row.agent_id))
.filter(fleet_agents::last_heartbeat_at.lt(cutoff)),
)
.execute(conn)?;
if affected == 1 {
owned.push(row);
}
}
Ok::<_, diesel::result::Error>(owned)
})
.map_err(ValidationError::from)?;
Ok(rows.into_iter().map(to_domain).collect())
}
pub async fn delete(&self, agent_id: &str) -> Result<(), ValidationError> {
let agent_id = agent_id.to_string();
crate::interact_on_backend!(self.dal, |conn| {
diesel::delete(fleet_agents::table.filter(fleet_agents::agent_id.eq(&agent_id)))
.execute(conn)
})
.map_err(ValidationError::from)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database::Database;
use std::time::Duration;
#[cfg(feature = "sqlite")]
fn shared_url() -> String {
format!(
"file:fleet_agents_test_{}?mode=memory&cache=shared",
uuid::Uuid::new_v4()
)
}
#[cfg(feature = "sqlite")]
async fn dal_for(url: &str) -> DAL {
let db = Database::new(url, "", 5);
db.run_migrations()
.await
.expect("migrations should succeed");
DAL::new(db)
}
#[cfg(feature = "sqlite")]
fn reg(id: &str, tenant: Option<&str>, cap: u32) -> FleetAgentRegistration {
FleetAgentRegistration {
agent_id: id.to_string(),
tenant_id: tenant.map(str::to_string),
target_triple: "aarch64-apple-darwin".to_string(),
capabilities: vec!["python".to_string()],
max_concurrency: cap,
}
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn roster_visible_across_dal_handles() {
let url = shared_url();
let dal_a = dal_for(&url).await;
let dal_b = DAL::new(Database::new(&url, "", 5));
dal_a
.fleet_agents()
.upsert_registration(reg("a1", Some("acme"), 4))
.await
.unwrap();
let live = dal_b
.fleet_agents()
.list_live(Duration::from_secs(60))
.await
.unwrap();
assert_eq!(live.len(), 1);
assert_eq!(live[0].agent_id, "a1");
assert_eq!(live[0].tenant_id.as_deref(), Some("acme"));
assert_eq!(live[0].available_capacity, 4);
assert_eq!(
dal_b.fleet_agents().agent_tenant("a1").await.unwrap(),
Some(Some("acme".to_string()))
);
assert!(dal_b
.fleet_agents()
.record_heartbeat("a1", 3, 1)
.await
.unwrap());
let live = dal_a
.fleet_agents()
.list_live(Duration::from_secs(60))
.await
.unwrap();
assert_eq!(live[0].in_flight, 3);
assert_eq!(live[0].available_capacity, 1);
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn heartbeat_on_unknown_agent_reports_not_registered() {
let dal = dal_for(&shared_url()).await;
assert!(!dal
.fleet_agents()
.record_heartbeat("ghost", 0, 0)
.await
.unwrap());
assert_eq!(
dal.fleet_agents().agent_tenant("ghost").await.unwrap(),
None
);
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn reregistration_overwrites() {
let dal = dal_for(&shared_url()).await;
dal.fleet_agents()
.upsert_registration(reg("a1", None, 1))
.await
.unwrap();
dal.fleet_agents()
.upsert_registration(reg("a1", Some("t1"), 8))
.await
.unwrap();
let live = dal
.fleet_agents()
.list_live(Duration::from_secs(60))
.await
.unwrap();
assert_eq!(live.len(), 1);
assert_eq!(live[0].max_concurrency, 8);
assert_eq!(live[0].tenant_id.as_deref(), Some("t1"));
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn sweep_dead_evicts_stale_rows_once() {
let url = shared_url();
let dal_a = dal_for(&url).await;
let dal_b = DAL::new(Database::new(&url, "", 5));
dal_a
.fleet_agents()
.upsert_registration(reg("stale", Some("t1"), 2))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
assert!(dal_b
.fleet_agents()
.sweep_dead(Duration::from_secs(60))
.await
.unwrap()
.is_empty());
let dead = dal_b
.fleet_agents()
.sweep_dead(Duration::ZERO)
.await
.unwrap();
assert_eq!(dead.len(), 1);
assert_eq!(dead[0].agent_id, "stale");
assert_eq!(dead[0].tenant_id.as_deref(), Some("t1"));
assert!(dal_a
.fleet_agents()
.sweep_dead(Duration::ZERO)
.await
.unwrap()
.is_empty());
assert!(dal_a
.fleet_agents()
.list_live(Duration::from_secs(60))
.await
.unwrap()
.is_empty());
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn delete_is_idempotent() {
let dal = dal_for(&shared_url()).await;
dal.fleet_agents()
.upsert_registration(reg("a1", None, 1))
.await
.unwrap();
dal.fleet_agents().delete("a1").await.unwrap();
dal.fleet_agents().delete("a1").await.unwrap();
assert!(dal
.fleet_agents()
.list_live(Duration::from_secs(60))
.await
.unwrap()
.is_empty());
}
}