use actix_web::{web, HttpResponse};
use bamboo_subagent::proto::AgentRecord;
use chrono::{DateTime, Duration, Utc};
use serde::Deserialize;
use crate::error::AppError;
use crate::AppState;
const DEFAULT_LEASE_TTL_SECS: i64 = 30;
const MAX_LEASE_TTL_SECS: i64 = 3600;
#[derive(Default)]
pub struct AgentRegistry {
agents: dashmap::DashMap<String, AgentRecord>,
}
impl AgentRegistry {
pub fn new() -> Self {
Self::default()
}
fn upsert(&self, rec: AgentRecord) -> AgentRecord {
let now = Utc::now();
self.agents
.retain(|id, r| id == &rec.agent_id || r.lease_expires_at > now);
self.agents.insert(rec.agent_id.clone(), rec.clone());
rec
}
fn resolve_as_of(&self, agent_id: &str, now: DateTime<Utc>) -> Option<AgentRecord> {
match self.agents.get(agent_id).map(|r| r.clone()) {
Some(rec) if rec.lease_expires_at > now => Some(rec),
Some(_) => {
self.agents.remove(agent_id);
None
}
None => None,
}
}
fn list_as_of(&self, now: DateTime<Utc>, role: Option<&str>) -> Vec<AgentRecord> {
self.agents.retain(|_id, rec| rec.lease_expires_at > now);
let mut out: Vec<AgentRecord> = self
.agents
.iter()
.filter(|r| role.is_none_or(|want| r.role == want))
.map(|r| r.clone())
.collect();
out.sort_by(|a, b| a.agent_id.cmp(&b.agent_id));
out
}
fn remove(&self, agent_id: &str) {
self.agents.remove(agent_id);
}
pub fn insert_for_test(&self, rec: AgentRecord) {
self.agents.insert(rec.agent_id.clone(), rec);
}
}
#[derive(Debug, Deserialize)]
pub struct RegisterAgentRequest {
#[serde(default)]
pub agent_id: Option<String>,
pub role: String,
#[serde(default)]
pub labels: Vec<String>,
pub endpoint: String,
#[serde(default)]
pub pid: u32,
#[serde(default)]
pub version: String,
#[serde(default)]
pub lease_ttl_secs: Option<i64>,
#[serde(default)]
pub started_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Deserialize)]
pub struct ListAgentsQuery {
#[serde(default)]
pub role: Option<String>,
}
pub async fn register_agent(
app_state: web::Data<AppState>,
body: web::Json<RegisterAgentRequest>,
) -> Result<HttpResponse, AppError> {
let req = body.into_inner();
let now = Utc::now();
let ttl_secs = req
.lease_ttl_secs
.unwrap_or(DEFAULT_LEASE_TTL_SECS)
.clamp(1, MAX_LEASE_TTL_SECS);
let rec = AgentRecord {
agent_id: req
.agent_id
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
role: req.role,
labels: req.labels,
endpoint: req.endpoint,
pid: req.pid,
version: req.version,
started_at: req.started_at.unwrap_or(now),
lease_expires_at: now + Duration::seconds(ttl_secs),
};
let stored = app_state.agent_registry.upsert(rec);
Ok(HttpResponse::Ok().json(stored))
}
pub async fn list_agents(
app_state: web::Data<AppState>,
query: web::Query<ListAgentsQuery>,
) -> Result<HttpResponse, AppError> {
let recs = app_state
.agent_registry
.list_as_of(Utc::now(), query.role.as_deref());
Ok(HttpResponse::Ok().json(recs))
}
pub async fn get_agent(
app_state: web::Data<AppState>,
path: web::Path<String>,
) -> Result<HttpResponse, AppError> {
let agent_id = path.into_inner();
match app_state
.agent_registry
.resolve_as_of(&agent_id, Utc::now())
{
Some(rec) => Ok(HttpResponse::Ok().json(rec)),
None => Err(AppError::NotFound(format!("agent {agent_id}"))),
}
}
pub async fn withdraw_agent(
app_state: web::Data<AppState>,
path: web::Path<String>,
) -> Result<HttpResponse, AppError> {
let agent_id = path.into_inner();
app_state.agent_registry.remove(&agent_id);
Ok(HttpResponse::Ok().json(serde_json::json!({ "agent_id": agent_id, "withdrawn": true })))
}
#[cfg(test)]
mod tests {
use super::*;
fn rec(id: &str, role: &str, expires: DateTime<Utc>) -> AgentRecord {
AgentRecord {
agent_id: id.into(),
role: role.into(),
labels: vec![],
endpoint: "ws://127.0.0.1:1".into(),
pid: 1,
version: "0".into(),
started_at: Utc::now(),
lease_expires_at: expires,
}
}
#[test]
fn upsert_resolve_list_withdraw() {
let reg = AgentRegistry::new();
let now = Utc::now();
reg.upsert(rec("a", "service", now + Duration::seconds(30)));
reg.upsert(rec("b", "gpu", now + Duration::seconds(30)));
assert!(reg.resolve_as_of("a", now).is_some());
assert_eq!(reg.list_as_of(now, None).len(), 2);
let gpu = reg.list_as_of(now, Some("gpu"));
assert_eq!(gpu.len(), 1);
assert_eq!(gpu[0].agent_id, "b");
reg.remove("a");
assert!(reg.resolve_as_of("a", now).is_none());
assert_eq!(reg.list_as_of(now, None).len(), 1);
}
#[test]
fn expired_lease_is_not_returned_and_is_pruned() {
let reg = AgentRegistry::new();
let now = Utc::now();
reg.upsert(rec("fresh", "service", now + Duration::seconds(30)));
reg.upsert(rec("stale", "service", now - Duration::seconds(1)));
assert!(reg.resolve_as_of("stale", now).is_none());
assert!(reg.resolve_as_of("fresh", now).is_some());
let live = reg.list_as_of(now, None);
assert_eq!(live.len(), 1);
assert_eq!(live[0].agent_id, "fresh");
assert!(!reg.agents.contains_key("stale"));
}
#[test]
fn reregister_refreshes_lease() {
let reg = AgentRegistry::new();
let now = Utc::now();
reg.upsert(rec("a", "service", now + Duration::seconds(1)));
let before = reg.resolve_as_of("a", now).unwrap().lease_expires_at;
reg.upsert(rec("a", "service", now + Duration::seconds(60)));
let after = reg.resolve_as_of("a", now).unwrap().lease_expires_at;
assert!(after > before, "re-register must extend the lease");
assert_eq!(reg.list_as_of(now, None).len(), 1);
}
#[test]
fn upsert_prunes_expired_rows_on_the_write_path() {
let reg = AgentRegistry::new();
let now = Utc::now();
reg.insert_for_test(rec("ghost", "service", now - Duration::seconds(1)));
reg.upsert(rec("live", "service", now + Duration::seconds(30)));
reg.upsert(rec("trigger", "service", now + Duration::seconds(30)));
assert!(
!reg.agents.contains_key("ghost"),
"an expired row must be pruned on the write path"
);
assert!(reg.agents.contains_key("live"));
assert!(reg.agents.contains_key("trigger"));
}
}