telos_agent/orchestration/team/
mod.rs1use serde::{Deserialize, Serialize};
21use std::path::PathBuf;
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct TeamConfig {
26 pub name: String,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub description: Option<String>,
31 pub created_at: u64,
33 pub lead_agent_id: String,
35 pub lead_session_id: String,
37 #[serde(default, skip_serializing_if = "Vec::is_empty")]
39 pub members: Vec<TeamMember>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct TeamMember {
45 pub agent_id: String,
47 pub name: String,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub agent_type: Option<String>,
52 pub joined_at: u64,
54 #[serde(default = "default_true")]
56 pub is_active: bool,
57}
58
59fn default_true() -> bool {
60 true
61}
62
63pub fn teams_root() -> Option<PathBuf> {
65 std::env::var("HOME")
66 .ok()
67 .map(std::path::PathBuf::from)
68 .map(|home| home.join(".telos").join("teams"))
69}
70
71pub fn team_config_path(team_name: &str) -> Option<PathBuf> {
73 teams_root().map(|root| root.join(team_name).join("config.json"))
74}
75
76pub fn team_tasks_dir(team_name: &str) -> Option<PathBuf> {
78 std::env::var("HOME")
79 .ok()
80 .map(std::path::PathBuf::from)
81 .map(|home| home.join(".telos").join("tasks").join(sanitize_name(team_name)))
82}
83
84fn sanitize_name(name: &str) -> String {
86 name.chars()
87 .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
88 .collect()
89}
90
91pub fn lead_agent_id(team_name: &str) -> String {
93 format!("team-lead@{}", team_name)
94}
95
96pub fn load_team_config(team_name: &str) -> Result<TeamConfig, crate::error::AgentError> {
98 let path = team_config_path(team_name).ok_or_else(|| {
99 crate::error::AgentError::Config("cannot determine home directory".into())
100 })?;
101
102 let content = std::fs::read_to_string(&path).map_err(|e| {
103 crate::error::AgentError::Config(format!(
104 "team '{}' not found at {}: {}",
105 team_name,
106 path.display(),
107 e
108 ))
109 })?;
110
111 serde_json::from_str(&content)
112 .map_err(|e| crate::error::AgentError::Config(format!("invalid team config: {e}")))
113}
114
115pub fn save_team_config(config: &TeamConfig) -> Result<(), crate::error::AgentError> {
117 let path = team_config_path(&config.name).ok_or_else(|| {
118 crate::error::AgentError::Config("cannot determine home directory".into())
119 })?;
120
121 if let Some(parent) = path.parent() {
122 std::fs::create_dir_all(parent).map_err(|e| {
123 crate::error::AgentError::Config(format!("cannot create team directory: {e}"))
124 })?;
125 }
126
127 let json = serde_json::to_string_pretty(config).map_err(|e| {
128 crate::error::AgentError::Config(format!("cannot serialize team config: {e}"))
129 })?;
130
131 std::fs::write(&path, json).map_err(|e| {
132 crate::error::AgentError::Config(format!(
133 "cannot write team config to {}: {}",
134 path.display(),
135 e
136 ))
137 })?;
138
139 Ok(())
140}
141
142pub fn cleanup_team(team_name: &str) -> Result<(), crate::error::AgentError> {
144 if let Some(config_path) = team_config_path(team_name)
146 && let Some(parent) = config_path.parent()
147 && parent.exists()
148 {
149 let _ = std::fs::remove_dir_all(parent);
150 }
151
152 if let Some(tasks_dir) = team_tasks_dir(team_name)
153 && tasks_dir.exists()
154 {
155 let _ = std::fs::remove_dir_all(&tasks_dir);
156 }
157
158 Ok(())
159}
160
161pub fn has_active_members(config: &TeamConfig) -> bool {
163 config.members.iter().any(|m| m.is_active && m.agent_id != config.lead_agent_id)
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
171 fn lead_agent_id_is_deterministic() {
172 assert_eq!(lead_agent_id("my-project"), "team-lead@my-project");
173 assert_eq!(lead_agent_id("my-project"), lead_agent_id("my-project"));
175 }
176
177 #[test]
178 fn sanitize_name_replaces_special_chars() {
179 assert_eq!(sanitize_name("hello world!"), "hello_world_");
180 assert_eq!(sanitize_name("foo/bar"), "foo_bar");
181 assert_eq!(sanitize_name("abc-123"), "abc-123");
182 }
183
184 #[test]
185 fn has_active_members_detects_lead_ignored() {
186 let config = TeamConfig {
187 name: "test".into(),
188 description: None,
189 created_at: 0,
190 lead_agent_id: "team-lead@test".into(),
191 lead_session_id: "s1".into(),
192 members: vec![TeamMember {
193 agent_id: "team-lead@test".into(),
194 name: "lead".into(),
195 agent_type: None,
196 joined_at: 0,
197 is_active: true,
198 }],
199 };
200 assert!(!has_active_members(&config));
201
202 let config_with_active = TeamConfig {
203 members: vec![
204 TeamMember {
205 agent_id: "team-lead@test".into(),
206 name: "lead".into(),
207 agent_type: None,
208 joined_at: 0,
209 is_active: true,
210 },
211 TeamMember {
212 agent_id: "worker@test".into(),
213 name: "worker".into(),
214 agent_type: None,
215 joined_at: 0,
216 is_active: true,
217 },
218 ],
219 ..config
220 };
221 assert!(has_active_members(&config_with_active));
222 }
223}