use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectAdvertisement {
pub project_id: String,
pub agent: String,
#[serde(default)]
pub epic_head: Option<String>,
#[serde(default)]
pub host: Option<String>,
}
impl ProjectAdvertisement {
pub fn from_verdict(
content: &serde_json::Value,
agent: &str,
host: Option<String>,
) -> Option<Self> {
let project_id = content.get("project_id")?.as_str()?.to_string();
let epic_head = content
.get("epic_head")
.and_then(|v| v.as_str())
.map(str::to_string);
Some(Self {
project_id,
agent: agent.to_string(),
epic_head,
host,
})
}
}
pub fn advert_subject(subject_prefix: &str) -> String {
format!("{subject_prefix}.project.advert")
}
pub fn advanced_notification(
content: &serde_json::Value,
subject_prefix: &str,
) -> Option<(String, Vec<u8>)> {
let adv = content.get("project_advanced")?;
let project_id = adv.get("project_id").and_then(|v| v.as_str())?;
if project_id.is_empty() {
return None;
}
let subject = format!("{subject_prefix}.project.{project_id}.advanced");
let payload = serde_json::to_vec(adv).ok()?;
Some((subject, payload))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectAgent {
pub agent: String,
pub epic_head: Option<String>,
pub host: Option<String>,
pub last_seen: DateTime<Utc>,
}
#[derive(Debug, Default)]
pub struct ProjectRegistry {
by_project: HashMap<String, HashMap<String, ProjectAgent>>,
}
impl ProjectRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn record(&mut self, adv: &ProjectAdvertisement, now: DateTime<Utc>) {
self.by_project
.entry(adv.project_id.clone())
.or_default()
.insert(
adv.agent.clone(),
ProjectAgent {
agent: adv.agent.clone(),
epic_head: adv.epic_head.clone(),
host: adv.host.clone(),
last_seen: now,
},
);
}
pub fn agents_for(&self, project_id: &str) -> Vec<ProjectAgent> {
self.by_project
.get(project_id)
.map(|m| m.values().cloned().collect())
.unwrap_or_default()
}
pub fn projects(&self) -> Vec<String> {
self.by_project.keys().cloned().collect()
}
pub fn prune(&mut self, now: DateTime<Utc>, ttl: Duration) {
for agents in self.by_project.values_mut() {
agents.retain(|_, a| now.signed_duration_since(a.last_seen) <= ttl);
}
self.by_project.retain(|_, m| !m.is_empty());
}
}
#[cfg(test)]
mod tests {
use super::*;
fn adv(project: &str, agent: &str, head: Option<&str>) -> ProjectAdvertisement {
ProjectAdvertisement {
project_id: project.to_string(),
agent: agent.to_string(),
epic_head: head.map(str::to_string),
host: Some("hostA".to_string()),
}
}
fn at(secs: i64) -> DateTime<Utc> {
DateTime::from_timestamp(1_000_000 + secs, 0).unwrap()
}
#[test]
fn groups_agents_by_project_and_separates_distinct_projects() {
let mut reg = ProjectRegistry::new();
reg.record(&adv("root-epic", "A", Some("h1")), at(0));
reg.record(&adv("root-epic", "B", Some("h1")), at(1));
reg.record(&adv("other-epic", "C", None), at(2));
let mut epic: Vec<_> = reg
.agents_for("root-epic")
.into_iter()
.map(|a| a.agent)
.collect();
epic.sort();
assert_eq!(
epic,
vec!["A", "B"],
"both agents grouped under the shared epic"
);
assert_eq!(
reg.agents_for("other-epic")
.into_iter()
.map(|a| a.agent)
.collect::<Vec<_>>(),
vec!["C"],
"distinct project is separate"
);
assert!(
reg.agents_for("unknown").is_empty(),
"unknown project → no agents"
);
let mut projs = reg.projects();
projs.sort();
assert_eq!(projs, vec!["other-epic", "root-epic"]);
}
#[test]
fn re_advertise_refreshes_in_place_and_updates_head() {
let mut reg = ProjectRegistry::new();
reg.record(&adv("epic", "A", Some("h1")), at(0));
reg.record(&adv("epic", "A", Some("h2")), at(10));
let agents = reg.agents_for("epic");
assert_eq!(agents.len(), 1, "same agent is not duplicated");
assert_eq!(agents[0].epic_head.as_deref(), Some("h2"), "head refreshed");
assert_eq!(agents[0].last_seen, at(10), "last_seen refreshed");
}
#[test]
fn prune_drops_stale_agents_and_empty_projects() {
let mut reg = ProjectRegistry::new();
reg.record(&adv("epic", "stale", Some("h1")), at(0));
reg.record(&adv("epic", "fresh", Some("h1")), at(100));
reg.record(&adv("gone", "only", None), at(0));
reg.prune(at(120), Duration::seconds(60));
let live: Vec<_> = reg
.agents_for("epic")
.into_iter()
.map(|a| a.agent)
.collect();
assert_eq!(live, vec!["fresh"], "only the fresh agent survives");
assert!(reg.agents_for("gone").is_empty(), "empty project pruned");
assert_eq!(
reg.projects(),
vec!["epic"],
"emptied project removed entirely"
);
}
#[test]
fn advertisement_from_verdict_extracts_project_and_head() {
let content = serde_json::json!({
"task_description": "…", "project_id": "root-sha", "epic_head": "head-sha"
});
let a = ProjectAdvertisement::from_verdict(&content, "AgentA", Some("h1".into())).unwrap();
assert_eq!(a.project_id, "root-sha");
assert_eq!(a.agent, "AgentA");
assert_eq!(a.epic_head.as_deref(), Some("head-sha"));
assert_eq!(a.host.as_deref(), Some("h1"));
let no_head = serde_json::json!({"project_id": "root-sha"});
assert_eq!(
ProjectAdvertisement::from_verdict(&no_head, "A", None)
.unwrap()
.epic_head,
None
);
let none = serde_json::json!({"task_description": "just a prompt"});
assert!(ProjectAdvertisement::from_verdict(&none, "A", None).is_none());
}
#[test]
fn advert_subject_is_stable() {
assert_eq!(advert_subject("nsed"), "nsed.project.advert");
}
#[test]
fn advanced_notification_builds_subject_and_payload() {
let content = serde_json::json!({
"project_advanced": { "project_id": "root-sha", "head": "head-sha" }
});
let (subject, payload) = advanced_notification(&content, "nsed").unwrap();
assert_eq!(subject, "nsed.project.root-sha.advanced");
let back: serde_json::Value = serde_json::from_slice(&payload).unwrap();
assert_eq!(back["head"], "head-sha");
assert_eq!(back["project_id"], "root-sha");
assert!(advanced_notification(&serde_json::json!({"note": "ok"}), "nsed").is_none());
assert!(
advanced_notification(
&serde_json::json!({"project_advanced": {"project_id": ""}}),
"nsed"
)
.is_none()
);
}
#[test]
fn advertisement_round_trips_json() {
let a = adv("root-epic", "A", Some("h1"));
let json = serde_json::to_string(&a).unwrap();
let back: ProjectAdvertisement = serde_json::from_str(&json).unwrap();
assert_eq!(a, back);
let minimal: ProjectAdvertisement =
serde_json::from_str(r#"{"project_id":"p","agent":"A"}"#).unwrap();
assert_eq!(minimal.epic_head, None);
assert_eq!(minimal.host, None);
}
}