use exo_core::{DeterministicMap, Did, Timestamp};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
error::{CatapultError, Result},
oda::OdaSlot,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum AgentStatus {
Recruiting,
Onboarding,
Active,
Suspended,
Released,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CatapultAgent {
pub did: Did,
pub slot: OdaSlot,
pub display_name: String,
pub capabilities: Vec<String>,
pub status: AgentStatus,
pub last_heartbeat: Timestamp,
pub budget_spent_cents: u64,
pub budget_limit_cents: u64,
pub hired_at: Timestamp,
pub hired_by: Did,
pub commandbase_profile: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CatapultAgentInput {
pub did: Did,
pub slot: OdaSlot,
pub display_name: String,
pub capabilities: Vec<String>,
pub status: AgentStatus,
pub last_heartbeat: Timestamp,
pub budget_spent_cents: u64,
pub budget_limit_cents: u64,
pub hired_at: Timestamp,
pub hired_by: Did,
pub commandbase_profile: Option<String>,
}
impl CatapultAgent {
pub fn new(input: CatapultAgentInput) -> Result<Self> {
validate_agent_input(&input)?;
Ok(Self {
did: input.did,
slot: input.slot,
display_name: input.display_name,
capabilities: input.capabilities,
status: input.status,
last_heartbeat: input.last_heartbeat,
budget_spent_cents: input.budget_spent_cents,
budget_limit_cents: input.budget_limit_cents,
hired_at: input.hired_at,
hired_by: input.hired_by,
commandbase_profile: input.commandbase_profile,
})
}
pub fn validate(&self) -> Result<()> {
validate_agent_input(&CatapultAgentInput {
did: self.did.clone(),
slot: self.slot,
display_name: self.display_name.clone(),
capabilities: self.capabilities.clone(),
status: self.status,
last_heartbeat: self.last_heartbeat,
budget_spent_cents: self.budget_spent_cents,
budget_limit_cents: self.budget_limit_cents,
hired_at: self.hired_at,
hired_by: self.hired_by.clone(),
commandbase_profile: self.commandbase_profile.clone(),
})
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AgentRoster {
agents: DeterministicMap<OdaSlot, CatapultAgent>,
}
impl AgentRoster {
#[must_use]
pub fn new() -> Self {
Self {
agents: DeterministicMap::new(),
}
}
pub fn fill_slot(&mut self, agent: CatapultAgent) -> Result<()> {
agent.validate()?;
let slot = agent.slot;
if self.agents.contains_key(&slot) {
return Err(CatapultError::SlotAlreadyFilled(slot));
}
self.agents.insert(slot, agent);
Ok(())
}
pub fn release_slot(&mut self, slot: &OdaSlot) -> Result<CatapultAgent> {
self.agents
.remove(slot)
.ok_or(CatapultError::SlotEmpty(*slot))
}
#[must_use]
pub fn get(&self, slot: &OdaSlot) -> Option<&CatapultAgent> {
self.agents.get(slot)
}
#[must_use]
pub fn get_by_did(&self, did: &Did) -> Option<&CatapultAgent> {
self.agents.values().find(|a| a.did == *did)
}
#[must_use]
pub fn founding_agents(&self) -> Vec<&CatapultAgent> {
OdaSlot::FOUNDERS
.iter()
.filter_map(|slot| self.agents.get(slot))
.collect()
}
#[must_use]
pub fn is_complete(&self) -> bool {
OdaSlot::ALL
.iter()
.all(|slot| self.agents.contains_key(slot))
}
#[must_use]
pub fn filled_count(&self) -> usize {
self.agents.len()
}
#[must_use]
pub fn vacancy_count(&self) -> usize {
12_usize.saturating_sub(self.agents.len())
}
#[must_use]
pub fn active_count(&self) -> usize {
self.agents
.values()
.filter(|a| a.status == AgentStatus::Active)
.count()
}
#[must_use]
pub fn has_slots(&self, required: &[OdaSlot]) -> bool {
required.iter().all(|slot| self.agents.contains_key(slot))
}
pub fn iter(&self) -> impl Iterator<Item = (&OdaSlot, &CatapultAgent)> {
self.agents.iter()
}
pub fn validate(&self) -> Result<()> {
for (slot, agent) in &self.agents {
if *slot != agent.slot {
return Err(CatapultError::InvalidAgent {
reason: format!(
"agent {} stored under slot {} but declares slot {}",
agent.did,
slot.slug(),
agent.slot.slug()
),
});
}
agent.validate()?;
}
Ok(())
}
pub fn generate_did(newco_id: &Uuid, slot: &OdaSlot) -> exo_core::Result<Did> {
Did::new(&format!("did:exo:catapult:{newco_id}:{}", slot.slug()))
}
}
fn validate_agent_input(input: &CatapultAgentInput) -> Result<()> {
if input.display_name.trim().is_empty() {
return Err(CatapultError::InvalidAgent {
reason: "agent display name must not be empty".into(),
});
}
if input.last_heartbeat == Timestamp::ZERO {
return Err(CatapultError::InvalidAgent {
reason: "agent last heartbeat must be caller-supplied HLC".into(),
});
}
if input.hired_at == Timestamp::ZERO {
return Err(CatapultError::InvalidAgent {
reason: "agent hired_at must be caller-supplied HLC".into(),
});
}
if input.last_heartbeat < input.hired_at {
return Err(CatapultError::InvalidAgent {
reason: "agent last heartbeat must not precede hired_at".into(),
});
}
if input.budget_limit_cents == 0 {
return Err(CatapultError::InvalidAgent {
reason: "agent budget limit must be nonzero".into(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn test_did(name: &str) -> Did {
Did::new(&format!("did:exo:test-{name}")).unwrap()
}
fn make_agent(slot: OdaSlot, name: &str) -> CatapultAgent {
CatapultAgent {
did: test_did(name),
slot,
display_name: name.into(),
capabilities: vec!["test".into()],
status: AgentStatus::Active,
last_heartbeat: Timestamp::new(1_765_000_000_100, 0),
budget_spent_cents: 0,
budget_limit_cents: 100_000,
hired_at: Timestamp::new(1_765_000_000_000, 0),
hired_by: test_did("hr"),
commandbase_profile: None,
}
}
#[test]
fn agent_new_requires_caller_supplied_lifecycle_metadata() {
let agent = CatapultAgent::new(CatapultAgentInput {
did: test_did("valid"),
slot: OdaSlot::DeepResearcher,
display_name: "valid".into(),
capabilities: vec!["research".into()],
status: AgentStatus::Active,
last_heartbeat: Timestamp::new(1_765_000_000_100, 0),
budget_spent_cents: 0,
budget_limit_cents: 100_000,
hired_at: Timestamp::new(1_765_000_000_000, 0),
hired_by: test_did("hr"),
commandbase_profile: None,
})
.unwrap();
assert_eq!(agent.slot, OdaSlot::DeepResearcher);
assert_ne!(agent.last_heartbeat, Timestamp::ZERO);
assert_ne!(agent.hired_at, Timestamp::ZERO);
}
#[test]
fn roster_rejects_placeholder_agent_metadata() {
let mut roster = AgentRoster::new();
let mut agent = make_agent(OdaSlot::DeepResearcher, "dr1");
agent.last_heartbeat = Timestamp::ZERO;
assert!(roster.fill_slot(agent).is_err());
let mut agent = make_agent(OdaSlot::DeepResearcher, "dr1");
agent.hired_at = Timestamp::ZERO;
assert!(roster.fill_slot(agent).is_err());
let mut agent = make_agent(OdaSlot::DeepResearcher, "dr1");
agent.budget_limit_cents = 0;
assert!(roster.fill_slot(agent).is_err());
}
#[test]
fn agent_validation_rejects_empty_name_and_regressive_heartbeat() {
let mut agent = make_agent(OdaSlot::DeepResearcher, "dr1");
agent.display_name = " ".into();
assert!(agent.validate().is_err());
let mut agent = make_agent(OdaSlot::DeepResearcher, "dr1");
agent.last_heartbeat = Timestamp::new(1, 0);
assert!(agent.validate().is_err());
}
#[test]
fn roster_validate_detects_deserialized_slot_key_mismatch() {
let mut roster = AgentRoster::new();
let agent = make_agent(OdaSlot::DeepResearcher, "dr1");
roster.agents.insert(OdaSlot::HrPeopleOps1, agent);
let err = roster.validate().unwrap_err().to_string();
assert!(err.contains("hrpeopleops1"));
assert!(err.contains("deepresearcher"));
assert!(!err.contains("HrPeopleOps1"));
assert!(!err.contains("DeepResearcher"));
}
#[test]
fn active_count_ignores_non_active_agents() {
let mut roster = AgentRoster::new();
roster
.fill_slot(make_agent(OdaSlot::DeepResearcher, "dr1"))
.unwrap();
let mut suspended = make_agent(OdaSlot::HrPeopleOps1, "hr1");
suspended.status = AgentStatus::Suspended;
roster.fill_slot(suspended).unwrap();
assert_eq!(roster.active_count(), 1);
assert_eq!(roster.iter().count(), 2);
}
#[test]
fn fill_and_get() {
let mut roster = AgentRoster::new();
let agent = make_agent(OdaSlot::HrPeopleOps1, "hr1");
roster.fill_slot(agent).unwrap();
assert_eq!(roster.filled_count(), 1);
assert_eq!(roster.vacancy_count(), 11);
assert!(roster.get(&OdaSlot::HrPeopleOps1).is_some());
}
#[test]
fn duplicate_slot_rejected() {
let mut roster = AgentRoster::new();
roster
.fill_slot(make_agent(OdaSlot::DeepResearcher, "dr1"))
.unwrap();
let result = roster.fill_slot(make_agent(OdaSlot::DeepResearcher, "dr2"));
assert!(result.is_err());
}
#[test]
fn release_slot() {
let mut roster = AgentRoster::new();
roster
.fill_slot(make_agent(OdaSlot::VentureCommander, "vc"))
.unwrap();
let released = roster.release_slot(&OdaSlot::VentureCommander).unwrap();
assert_eq!(released.display_name, "vc");
assert_eq!(roster.filled_count(), 0);
}
#[test]
fn release_empty_slot() {
let mut roster = AgentRoster::new();
assert!(roster.release_slot(&OdaSlot::VentureCommander).is_err());
}
#[test]
fn founding_agents() {
let mut roster = AgentRoster::new();
roster
.fill_slot(make_agent(OdaSlot::HrPeopleOps1, "hr"))
.unwrap();
roster
.fill_slot(make_agent(OdaSlot::DeepResearcher, "dr"))
.unwrap();
assert_eq!(roster.founding_agents().len(), 2);
}
#[test]
fn complete_roster() {
let mut roster = AgentRoster::new();
for (i, slot) in OdaSlot::ALL.iter().enumerate() {
roster
.fill_slot(make_agent(*slot, &format!("agent-{i}")))
.unwrap();
}
assert!(roster.is_complete());
assert_eq!(roster.filled_count(), 12);
assert_eq!(roster.vacancy_count(), 0);
assert_eq!(roster.active_count(), 12);
}
#[test]
fn has_slots() {
let mut roster = AgentRoster::new();
roster
.fill_slot(make_agent(OdaSlot::HrPeopleOps1, "hr"))
.unwrap();
roster
.fill_slot(make_agent(OdaSlot::DeepResearcher, "dr"))
.unwrap();
assert!(roster.has_slots(&OdaSlot::FOUNDERS));
assert!(!roster.has_slots(&[OdaSlot::VentureCommander]));
}
#[test]
fn get_by_did() {
let mut roster = AgentRoster::new();
let agent = make_agent(OdaSlot::VentureCommander, "vc");
let did = agent.did.clone();
roster.fill_slot(agent).unwrap();
assert!(roster.get_by_did(&did).is_some());
assert!(roster.get_by_did(&test_did("nonexistent")).is_none());
}
#[test]
fn generate_did_format() {
let id = Uuid::nil();
let did = AgentRoster::generate_did(&id, &OdaSlot::VentureCommander).unwrap();
assert_eq!(
did.as_str(),
"did:exo:catapult:00000000-0000-0000-0000-000000000000:venturecommander"
);
}
#[test]
fn agent_slot_boundary_labels_do_not_depend_on_debug_formatting() {
let source = include_str!("agent.rs");
let production = source
.split("#[cfg(test)]")
.next()
.expect("production section");
assert!(
!production.contains("format!(\"{slot:?}\")"),
"agent DID generation must use explicit slot labels"
);
assert!(
!production.contains("{slot:?}"),
"agent validation errors must use explicit slot labels"
);
}
#[test]
fn agent_status_serde() {
let statuses = [
AgentStatus::Recruiting,
AgentStatus::Onboarding,
AgentStatus::Active,
AgentStatus::Suspended,
AgentStatus::Released,
];
for s in &statuses {
let j = serde_json::to_string(s).unwrap();
let rt: AgentStatus = serde_json::from_str(&j).unwrap();
assert_eq!(&rt, s);
}
}
}