1use exo_core::{DeterministicMap, Did, Timestamp};
20use serde::{Deserialize, Serialize};
21use uuid::Uuid;
22
23use crate::{
24 error::{CatapultError, Result},
25 oda::OdaSlot,
26};
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
30pub enum AgentStatus {
31 Recruiting,
33 Onboarding,
35 Active,
37 Suspended,
39 Released,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct CatapultAgent {
46 pub did: Did,
48 pub slot: OdaSlot,
50 pub display_name: String,
52 pub capabilities: Vec<String>,
54 pub status: AgentStatus,
56 pub last_heartbeat: Timestamp,
58 pub budget_spent_cents: u64,
60 pub budget_limit_cents: u64,
62 pub hired_at: Timestamp,
64 pub hired_by: Did,
66 pub commandbase_profile: Option<String>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct CatapultAgentInput {
73 pub did: Did,
74 pub slot: OdaSlot,
75 pub display_name: String,
76 pub capabilities: Vec<String>,
77 pub status: AgentStatus,
78 pub last_heartbeat: Timestamp,
79 pub budget_spent_cents: u64,
80 pub budget_limit_cents: u64,
81 pub hired_at: Timestamp,
82 pub hired_by: Did,
83 pub commandbase_profile: Option<String>,
84}
85
86impl CatapultAgent {
87 pub fn new(input: CatapultAgentInput) -> Result<Self> {
93 validate_agent_input(&input)?;
94 Ok(Self {
95 did: input.did,
96 slot: input.slot,
97 display_name: input.display_name,
98 capabilities: input.capabilities,
99 status: input.status,
100 last_heartbeat: input.last_heartbeat,
101 budget_spent_cents: input.budget_spent_cents,
102 budget_limit_cents: input.budget_limit_cents,
103 hired_at: input.hired_at,
104 hired_by: input.hired_by,
105 commandbase_profile: input.commandbase_profile,
106 })
107 }
108
109 pub fn validate(&self) -> Result<()> {
115 validate_agent_input(&CatapultAgentInput {
116 did: self.did.clone(),
117 slot: self.slot,
118 display_name: self.display_name.clone(),
119 capabilities: self.capabilities.clone(),
120 status: self.status,
121 last_heartbeat: self.last_heartbeat,
122 budget_spent_cents: self.budget_spent_cents,
123 budget_limit_cents: self.budget_limit_cents,
124 hired_at: self.hired_at,
125 hired_by: self.hired_by.clone(),
126 commandbase_profile: self.commandbase_profile.clone(),
127 })
128 }
129}
130
131#[derive(Debug, Clone, Default, Serialize, Deserialize)]
133pub struct AgentRoster {
134 agents: DeterministicMap<OdaSlot, CatapultAgent>,
135}
136
137impl AgentRoster {
138 #[must_use]
140 pub fn new() -> Self {
141 Self {
142 agents: DeterministicMap::new(),
143 }
144 }
145
146 pub fn fill_slot(&mut self, agent: CatapultAgent) -> Result<()> {
148 agent.validate()?;
149 let slot = agent.slot;
150 if self.agents.contains_key(&slot) {
151 return Err(CatapultError::SlotAlreadyFilled(slot));
152 }
153 self.agents.insert(slot, agent);
154 Ok(())
155 }
156
157 pub fn release_slot(&mut self, slot: &OdaSlot) -> Result<CatapultAgent> {
159 self.agents
160 .remove(slot)
161 .ok_or(CatapultError::SlotEmpty(*slot))
162 }
163
164 #[must_use]
166 pub fn get(&self, slot: &OdaSlot) -> Option<&CatapultAgent> {
167 self.agents.get(slot)
168 }
169
170 #[must_use]
172 pub fn get_by_did(&self, did: &Did) -> Option<&CatapultAgent> {
173 self.agents.values().find(|a| a.did == *did)
174 }
175
176 #[must_use]
178 pub fn founding_agents(&self) -> Vec<&CatapultAgent> {
179 OdaSlot::FOUNDERS
180 .iter()
181 .filter_map(|slot| self.agents.get(slot))
182 .collect()
183 }
184
185 #[must_use]
187 pub fn is_complete(&self) -> bool {
188 OdaSlot::ALL
189 .iter()
190 .all(|slot| self.agents.contains_key(slot))
191 }
192
193 #[must_use]
195 pub fn filled_count(&self) -> usize {
196 self.agents.len()
197 }
198
199 #[must_use]
201 pub fn vacancy_count(&self) -> usize {
202 12_usize.saturating_sub(self.agents.len())
203 }
204
205 #[must_use]
207 pub fn active_count(&self) -> usize {
208 self.agents
209 .values()
210 .filter(|a| a.status == AgentStatus::Active)
211 .count()
212 }
213
214 #[must_use]
216 pub fn has_slots(&self, required: &[OdaSlot]) -> bool {
217 required.iter().all(|slot| self.agents.contains_key(slot))
218 }
219
220 pub fn iter(&self) -> impl Iterator<Item = (&OdaSlot, &CatapultAgent)> {
222 self.agents.iter()
223 }
224
225 pub fn validate(&self) -> Result<()> {
231 for (slot, agent) in &self.agents {
232 if *slot != agent.slot {
233 return Err(CatapultError::InvalidAgent {
234 reason: format!(
235 "agent {} stored under slot {} but declares slot {}",
236 agent.did,
237 slot.slug(),
238 agent.slot.slug()
239 ),
240 });
241 }
242 agent.validate()?;
243 }
244 Ok(())
245 }
246
247 pub fn generate_did(newco_id: &Uuid, slot: &OdaSlot) -> exo_core::Result<Did> {
251 Did::new(&format!("did:exo:catapult:{newco_id}:{}", slot.slug()))
252 }
253}
254
255fn validate_agent_input(input: &CatapultAgentInput) -> Result<()> {
256 if input.display_name.trim().is_empty() {
257 return Err(CatapultError::InvalidAgent {
258 reason: "agent display name must not be empty".into(),
259 });
260 }
261 if input.last_heartbeat == Timestamp::ZERO {
262 return Err(CatapultError::InvalidAgent {
263 reason: "agent last heartbeat must be caller-supplied HLC".into(),
264 });
265 }
266 if input.hired_at == Timestamp::ZERO {
267 return Err(CatapultError::InvalidAgent {
268 reason: "agent hired_at must be caller-supplied HLC".into(),
269 });
270 }
271 if input.last_heartbeat < input.hired_at {
272 return Err(CatapultError::InvalidAgent {
273 reason: "agent last heartbeat must not precede hired_at".into(),
274 });
275 }
276 if input.budget_limit_cents == 0 {
277 return Err(CatapultError::InvalidAgent {
278 reason: "agent budget limit must be nonzero".into(),
279 });
280 }
281 Ok(())
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 fn test_did(name: &str) -> Did {
289 Did::new(&format!("did:exo:test-{name}")).unwrap()
290 }
291
292 fn make_agent(slot: OdaSlot, name: &str) -> CatapultAgent {
293 CatapultAgent {
294 did: test_did(name),
295 slot,
296 display_name: name.into(),
297 capabilities: vec!["test".into()],
298 status: AgentStatus::Active,
299 last_heartbeat: Timestamp::new(1_765_000_000_100, 0),
300 budget_spent_cents: 0,
301 budget_limit_cents: 100_000,
302 hired_at: Timestamp::new(1_765_000_000_000, 0),
303 hired_by: test_did("hr"),
304 commandbase_profile: None,
305 }
306 }
307
308 #[test]
309 fn agent_new_requires_caller_supplied_lifecycle_metadata() {
310 let agent = CatapultAgent::new(CatapultAgentInput {
311 did: test_did("valid"),
312 slot: OdaSlot::DeepResearcher,
313 display_name: "valid".into(),
314 capabilities: vec!["research".into()],
315 status: AgentStatus::Active,
316 last_heartbeat: Timestamp::new(1_765_000_000_100, 0),
317 budget_spent_cents: 0,
318 budget_limit_cents: 100_000,
319 hired_at: Timestamp::new(1_765_000_000_000, 0),
320 hired_by: test_did("hr"),
321 commandbase_profile: None,
322 })
323 .unwrap();
324
325 assert_eq!(agent.slot, OdaSlot::DeepResearcher);
326 assert_ne!(agent.last_heartbeat, Timestamp::ZERO);
327 assert_ne!(agent.hired_at, Timestamp::ZERO);
328 }
329
330 #[test]
331 fn roster_rejects_placeholder_agent_metadata() {
332 let mut roster = AgentRoster::new();
333 let mut agent = make_agent(OdaSlot::DeepResearcher, "dr1");
334 agent.last_heartbeat = Timestamp::ZERO;
335 assert!(roster.fill_slot(agent).is_err());
336
337 let mut agent = make_agent(OdaSlot::DeepResearcher, "dr1");
338 agent.hired_at = Timestamp::ZERO;
339 assert!(roster.fill_slot(agent).is_err());
340
341 let mut agent = make_agent(OdaSlot::DeepResearcher, "dr1");
342 agent.budget_limit_cents = 0;
343 assert!(roster.fill_slot(agent).is_err());
344 }
345
346 #[test]
347 fn agent_validation_rejects_empty_name_and_regressive_heartbeat() {
348 let mut agent = make_agent(OdaSlot::DeepResearcher, "dr1");
349 agent.display_name = " ".into();
350 assert!(agent.validate().is_err());
351
352 let mut agent = make_agent(OdaSlot::DeepResearcher, "dr1");
353 agent.last_heartbeat = Timestamp::new(1, 0);
354 assert!(agent.validate().is_err());
355 }
356
357 #[test]
358 fn roster_validate_detects_deserialized_slot_key_mismatch() {
359 let mut roster = AgentRoster::new();
360 let agent = make_agent(OdaSlot::DeepResearcher, "dr1");
361 roster.agents.insert(OdaSlot::HrPeopleOps1, agent);
362
363 let err = roster.validate().unwrap_err().to_string();
364 assert!(err.contains("hrpeopleops1"));
365 assert!(err.contains("deepresearcher"));
366 assert!(!err.contains("HrPeopleOps1"));
367 assert!(!err.contains("DeepResearcher"));
368 }
369
370 #[test]
371 fn active_count_ignores_non_active_agents() {
372 let mut roster = AgentRoster::new();
373 roster
374 .fill_slot(make_agent(OdaSlot::DeepResearcher, "dr1"))
375 .unwrap();
376 let mut suspended = make_agent(OdaSlot::HrPeopleOps1, "hr1");
377 suspended.status = AgentStatus::Suspended;
378 roster.fill_slot(suspended).unwrap();
379
380 assert_eq!(roster.active_count(), 1);
381 assert_eq!(roster.iter().count(), 2);
382 }
383
384 #[test]
385 fn fill_and_get() {
386 let mut roster = AgentRoster::new();
387 let agent = make_agent(OdaSlot::HrPeopleOps1, "hr1");
388 roster.fill_slot(agent).unwrap();
389 assert_eq!(roster.filled_count(), 1);
390 assert_eq!(roster.vacancy_count(), 11);
391 assert!(roster.get(&OdaSlot::HrPeopleOps1).is_some());
392 }
393
394 #[test]
395 fn duplicate_slot_rejected() {
396 let mut roster = AgentRoster::new();
397 roster
398 .fill_slot(make_agent(OdaSlot::DeepResearcher, "dr1"))
399 .unwrap();
400 let result = roster.fill_slot(make_agent(OdaSlot::DeepResearcher, "dr2"));
401 assert!(result.is_err());
402 }
403
404 #[test]
405 fn release_slot() {
406 let mut roster = AgentRoster::new();
407 roster
408 .fill_slot(make_agent(OdaSlot::VentureCommander, "vc"))
409 .unwrap();
410 let released = roster.release_slot(&OdaSlot::VentureCommander).unwrap();
411 assert_eq!(released.display_name, "vc");
412 assert_eq!(roster.filled_count(), 0);
413 }
414
415 #[test]
416 fn release_empty_slot() {
417 let mut roster = AgentRoster::new();
418 assert!(roster.release_slot(&OdaSlot::VentureCommander).is_err());
419 }
420
421 #[test]
422 fn founding_agents() {
423 let mut roster = AgentRoster::new();
424 roster
425 .fill_slot(make_agent(OdaSlot::HrPeopleOps1, "hr"))
426 .unwrap();
427 roster
428 .fill_slot(make_agent(OdaSlot::DeepResearcher, "dr"))
429 .unwrap();
430 assert_eq!(roster.founding_agents().len(), 2);
431 }
432
433 #[test]
434 fn complete_roster() {
435 let mut roster = AgentRoster::new();
436 for (i, slot) in OdaSlot::ALL.iter().enumerate() {
437 roster
438 .fill_slot(make_agent(*slot, &format!("agent-{i}")))
439 .unwrap();
440 }
441 assert!(roster.is_complete());
442 assert_eq!(roster.filled_count(), 12);
443 assert_eq!(roster.vacancy_count(), 0);
444 assert_eq!(roster.active_count(), 12);
445 }
446
447 #[test]
448 fn has_slots() {
449 let mut roster = AgentRoster::new();
450 roster
451 .fill_slot(make_agent(OdaSlot::HrPeopleOps1, "hr"))
452 .unwrap();
453 roster
454 .fill_slot(make_agent(OdaSlot::DeepResearcher, "dr"))
455 .unwrap();
456 assert!(roster.has_slots(&OdaSlot::FOUNDERS));
457 assert!(!roster.has_slots(&[OdaSlot::VentureCommander]));
458 }
459
460 #[test]
461 fn get_by_did() {
462 let mut roster = AgentRoster::new();
463 let agent = make_agent(OdaSlot::VentureCommander, "vc");
464 let did = agent.did.clone();
465 roster.fill_slot(agent).unwrap();
466 assert!(roster.get_by_did(&did).is_some());
467 assert!(roster.get_by_did(&test_did("nonexistent")).is_none());
468 }
469
470 #[test]
471 fn generate_did_format() {
472 let id = Uuid::nil();
473 let did = AgentRoster::generate_did(&id, &OdaSlot::VentureCommander).unwrap();
474 assert_eq!(
475 did.as_str(),
476 "did:exo:catapult:00000000-0000-0000-0000-000000000000:venturecommander"
477 );
478 }
479
480 #[test]
481 fn agent_slot_boundary_labels_do_not_depend_on_debug_formatting() {
482 let source = include_str!("agent.rs");
483 let production = source
484 .split("#[cfg(test)]")
485 .next()
486 .expect("production section");
487 assert!(
488 !production.contains("format!(\"{slot:?}\")"),
489 "agent DID generation must use explicit slot labels"
490 );
491 assert!(
492 !production.contains("{slot:?}"),
493 "agent validation errors must use explicit slot labels"
494 );
495 }
496
497 #[test]
498 fn agent_status_serde() {
499 let statuses = [
500 AgentStatus::Recruiting,
501 AgentStatus::Onboarding,
502 AgentStatus::Active,
503 AgentStatus::Suspended,
504 AgentStatus::Released,
505 ];
506 for s in &statuses {
507 let j = serde_json::to_string(s).unwrap();
508 let rt: AgentStatus = serde_json::from_str(&j).unwrap();
509 assert_eq!(&rt, s);
510 }
511 }
512}