1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use roder_api::events::{ThreadId, TurnId};
5use roder_api::policy_mode::PolicyMode;
6use roder_api::teams::{
7 AgentTeamDisplayMode, TeamId, TeamMailboxMessage, TeamMailboxMessageKind, TeamMemberDescriptor,
8 TeamMemberId, TeamMemberRole, TeamMemberStatus, TeamTaskDescriptor,
9};
10use serde::{Deserialize, Serialize};
11use time::OffsetDateTime;
12use tokio::sync::RwLock;
13
14#[derive(Debug, Clone)]
15pub struct TeamStartRequest {
16 pub lead_thread_id: Option<ThreadId>,
17 pub display_mode: AgentTeamDisplayMode,
18 pub members: Vec<TeamMemberStartRequest>,
19}
20
21#[derive(Debug, Clone)]
22pub struct TeamMemberStartRequest {
23 pub name: String,
24 pub model_provider: Option<String>,
25 pub model: Option<String>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29#[serde(rename_all = "camelCase")]
30pub struct TeamState {
31 pub id: TeamId,
32 pub lead_thread_id: ThreadId,
33 pub display_mode: AgentTeamDisplayMode,
34 pub members: Vec<TeamMemberDescriptor>,
35 pub mailbox: Vec<TeamMailboxMessage>,
36 pub tasks: Vec<TeamTaskDescriptor>,
37 #[serde(with = "time::serde::rfc3339")]
38 pub created_at: OffsetDateTime,
39 #[serde(with = "time::serde::rfc3339")]
40 pub updated_at: OffsetDateTime,
41}
42
43#[derive(Debug)]
44pub struct TeamManager {
45 teams: RwLock<HashMap<TeamId, TeamState>>,
46 mailbox_reservations: RwLock<HashMap<String, TurnId>>,
47 data_dir: PathBuf,
48}
49
50impl Default for TeamManager {
51 fn default() -> Self {
52 Self::new(default_team_data_dir())
53 }
54}
55
56impl TeamManager {
57 pub fn new(data_dir: PathBuf) -> Self {
58 Self {
59 teams: RwLock::new(HashMap::new()),
60 mailbox_reservations: RwLock::new(HashMap::new()),
61 data_dir,
62 }
63 }
64
65 pub async fn insert(&self, team: TeamState) -> anyhow::Result<TeamState> {
66 let mut teams = self.teams.write().await;
67 self.persist(&team).await?;
68 teams.insert(team.id.clone(), team.clone());
69 Ok(team)
70 }
71
72 pub async fn get(&self, team_id: &str) -> Option<TeamState> {
73 if let Some(team) = self.teams.read().await.get(team_id).cloned() {
74 return Some(team);
75 }
76 let mut teams = self.teams.write().await;
77 self.load_locked(&mut teams, team_id).await.ok().flatten()
78 }
79
80 pub async fn list(&self) -> Vec<TeamState> {
81 let mut teams = self.teams.write().await;
82 let _ = self.load_all_locked(&mut teams).await;
83 let mut listed = teams.values().cloned().collect::<Vec<_>>();
84 listed.sort_by_key(|team| std::cmp::Reverse(team.updated_at));
85 listed
86 }
87
88 pub async fn update_member(
89 &self,
90 team_id: &str,
91 member_id: &str,
92 update: impl FnOnce(&mut TeamMemberDescriptor),
93 ) -> anyhow::Result<TeamState> {
94 let mut teams = self.teams.write().await;
95 let mut team = self
96 .load_locked(&mut teams, team_id)
97 .await?
98 .ok_or_else(|| anyhow::anyhow!("unknown team {team_id:?}"))?;
99 let member = team
100 .members
101 .iter_mut()
102 .find(|member| member.id == member_id)
103 .ok_or_else(|| anyhow::anyhow!("unknown team member {member_id:?}"))?;
104 update(member);
105 team.updated_at = OffsetDateTime::now_utc();
106 self.persist(&team).await?;
107 teams.insert(team.id.clone(), team.clone());
108 Ok(team)
109 }
110
111 pub async fn add_member(
112 &self,
113 team_id: &str,
114 member: TeamMemberDescriptor,
115 ) -> anyhow::Result<TeamState> {
116 let mut teams = self.teams.write().await;
117 let mut team = self
118 .load_locked(&mut teams, team_id)
119 .await?
120 .ok_or_else(|| anyhow::anyhow!("unknown team {team_id:?}"))?;
121 anyhow::ensure!(
122 !team.members.iter().any(|existing| existing.id == member.id),
123 "team member id {:?} already exists",
124 member.id
125 );
126 if let Some(agent_path) = member.agent_path.as_deref() {
127 anyhow::ensure!(
128 !team
129 .members
130 .iter()
131 .any(|existing| existing.agent_path.as_deref() == Some(agent_path)),
132 "agent path {agent_path:?} already exists"
133 );
134 }
135 team.members.push(member);
136 team.updated_at = OffsetDateTime::now_utc();
137 self.persist(&team).await?;
138 teams.insert(team.id.clone(), team.clone());
139 Ok(team)
140 }
141
142 pub async fn append_mailbox_message(
143 &self,
144 team_id: &str,
145 from_member_id: Option<TeamMemberId>,
146 to_member_id: TeamMemberId,
147 kind: TeamMailboxMessageKind,
148 text: String,
149 ) -> anyhow::Result<TeamState> {
150 let mut teams = self.teams.write().await;
151 let mut team = self
152 .load_locked(&mut teams, team_id)
153 .await?
154 .ok_or_else(|| anyhow::anyhow!("unknown team {team_id:?}"))?;
155 if !team.members.iter().any(|member| member.id == to_member_id) {
156 anyhow::bail!("unknown team member {to_member_id:?}");
157 }
158 team.mailbox.push(TeamMailboxMessage {
159 id: uuid::Uuid::new_v4().to_string(),
160 team_id: team_id.to_string(),
161 from_member_id,
162 to_member_id,
163 kind,
164 text,
165 delivered: false,
166 timestamp: OffsetDateTime::now_utc(),
167 });
168 team.updated_at = OffsetDateTime::now_utc();
169 self.persist(&team).await?;
170 teams.insert(team.id.clone(), team.clone());
171 Ok(team)
172 }
173
174 pub async fn reserve_pending_mailbox_messages(
178 &self,
179 team_id: &str,
180 member_id: &str,
181 turn_id: &TurnId,
182 ) -> anyhow::Result<Vec<TeamMailboxMessage>> {
183 let mut teams = self.teams.write().await;
184 let team = self
185 .load_locked(&mut teams, team_id)
186 .await?
187 .ok_or_else(|| anyhow::anyhow!("unknown team {team_id:?}"))?;
188 anyhow::ensure!(
189 team.members.iter().any(|member| member.id == member_id),
190 "unknown team member {member_id:?}"
191 );
192 let mut reservations = self.mailbox_reservations.write().await;
193 let pending = team
194 .mailbox
195 .into_iter()
196 .filter(|message| {
197 message.to_member_id == member_id
198 && !message.delivered
199 && !reservations.contains_key(&message.id)
200 })
201 .collect::<Vec<_>>();
202 for message in &pending {
203 reservations.insert(message.id.clone(), turn_id.clone());
204 }
205 Ok(pending)
206 }
207
208 pub async fn release_mailbox_reservations_for_turn(&self, turn_id: &TurnId) {
209 self.mailbox_reservations
210 .write()
211 .await
212 .retain(|_, reserved_turn_id| reserved_turn_id != turn_id);
213 }
214
215 pub async fn release_mailbox_reservations(&self, turn_id: &TurnId, message_ids: &[String]) {
216 let message_ids = message_ids.iter().collect::<std::collections::HashSet<_>>();
217 self.mailbox_reservations
218 .write()
219 .await
220 .retain(|message_id, reserved_turn_id| {
221 reserved_turn_id != turn_id || !message_ids.contains(message_id)
222 });
223 }
224
225 pub async fn mark_mailbox_messages_delivered(
226 &self,
227 team_id: &str,
228 turn_id: &TurnId,
229 message_ids: &[String],
230 ) -> anyhow::Result<()> {
231 if message_ids.is_empty() {
232 return Ok(());
233 }
234 let mut teams = self.teams.write().await;
235 let mut team = self
236 .load_locked(&mut teams, team_id)
237 .await?
238 .ok_or_else(|| anyhow::anyhow!("unknown team {team_id:?}"))?;
239 let mut reservations = self.mailbox_reservations.write().await;
240 let message_ids = message_ids
241 .iter()
242 .filter(|message_id| reservations.get(*message_id) == Some(turn_id))
243 .collect::<std::collections::HashSet<_>>();
244 let mut changed = false;
245 for message in &mut team.mailbox {
246 if message_ids.contains(&message.id) && !message.delivered {
247 message.delivered = true;
248 changed = true;
249 }
250 }
251 if changed {
252 team.updated_at = OffsetDateTime::now_utc();
253 self.persist(&team).await?;
254 teams.insert(team.id.clone(), team);
255 }
256 reservations.retain(|message_id, _| !message_ids.contains(message_id));
257 Ok(())
258 }
259
260 pub async fn set_member_policy_mode(
261 &self,
262 team_id: &str,
263 member_id: &str,
264 policy_mode: PolicyMode,
265 ) -> anyhow::Result<TeamState> {
266 self.update_member(team_id, member_id, |member| {
267 member.policy_mode = policy_mode;
268 })
269 .await
270 }
271
272 pub async fn policy_mode_for_thread(&self, thread_id: &str) -> Option<PolicyMode> {
273 self.list()
274 .await
275 .into_iter()
276 .flat_map(|team| team.members.into_iter())
277 .find(|member| member.thread_id == thread_id)
278 .map(|member| member.policy_mode)
279 }
280
281 pub async fn member_for_thread(
282 &self,
283 thread_id: &str,
284 ) -> Option<(TeamId, TeamMemberDescriptor)> {
285 self.list().await.into_iter().find_map(|team| {
286 let team_id = team.id;
287 team.members
288 .into_iter()
289 .find(|member| member.thread_id == thread_id)
290 .map(|member| (team_id, member))
291 })
292 }
293
294 pub async fn complete_member_turn(
295 &self,
296 thread_id: &str,
297 turn_id: &str,
298 status: TeamMemberStatus,
299 final_message: Option<String>,
300 terminal_error: Option<String>,
301 ) -> anyhow::Result<Option<(TeamId, TeamMemberDescriptor)>> {
302 let mut teams = self.teams.write().await;
303 self.load_all_locked(&mut teams).await?;
304 let Some(team_id) = teams.iter().find_map(|(team_id, team)| {
305 team.members
306 .iter()
307 .any(|member| {
308 member.thread_id == thread_id
309 && member.current_turn_id.as_deref() == Some(turn_id)
310 })
311 .then(|| team_id.clone())
312 }) else {
313 return Ok(None);
314 };
315 let mut team = teams
316 .get(&team_id)
317 .cloned()
318 .expect("team found by member turn");
319 let member = team
320 .members
321 .iter_mut()
322 .find(|member| {
323 member.thread_id == thread_id && member.current_turn_id.as_deref() == Some(turn_id)
324 })
325 .expect("member located by team_for_member_turn");
326 member.status = status;
327 member.current_turn_id = None;
328 member.final_message = final_message;
329 member.terminal_error = terminal_error;
330 let completed = member.clone();
331 team.updated_at = OffsetDateTime::now_utc();
332 self.persist(&team).await?;
333 teams.insert(team_id.clone(), team);
334 Ok(Some((team_id, completed)))
335 }
336
337 pub async fn remove(&self, team_id: &str) -> anyhow::Result<Option<TeamState>> {
338 let mut teams = self.teams.write().await;
339 let _ = self.load_locked(&mut teams, team_id).await?;
340 let path = self.team_file(team_id);
341 if tokio::fs::try_exists(&path).await.unwrap_or(false) {
342 tokio::fs::remove_file(path).await?;
343 }
344 Ok(teams.remove(team_id))
345 }
346
347 async fn persist(&self, team: &TeamState) -> anyhow::Result<()> {
348 tokio::fs::create_dir_all(&self.data_dir).await?;
349 let data = serde_json::to_vec_pretty(team)?;
350 let temp_path = self
351 .data_dir
352 .join(format!(".team-{}.tmp", uuid::Uuid::new_v4()));
353 tokio::fs::write(&temp_path, data).await?;
354 if let Err(err) = tokio::fs::rename(&temp_path, self.team_file(&team.id)).await {
355 let _ = tokio::fs::remove_file(&temp_path).await;
356 return Err(err.into());
357 }
358 Ok(())
359 }
360
361 async fn load_locked(
362 &self,
363 teams: &mut HashMap<TeamId, TeamState>,
364 team_id: &str,
365 ) -> anyhow::Result<Option<TeamState>> {
366 if let Some(team) = teams.get(team_id).cloned() {
367 return Ok(Some(team));
368 }
369 let path = self.team_file(team_id);
370 if !tokio::fs::try_exists(&path).await.unwrap_or(false) {
371 return Ok(None);
372 }
373 let data = tokio::fs::read(path).await?;
374 let team = serde_json::from_slice::<TeamState>(&data)?;
375 anyhow::ensure!(
376 team.id == team_id,
377 "persisted team id {:?} does not match file id {team_id:?}",
378 team.id
379 );
380 teams.insert(team.id.clone(), team.clone());
381 Ok(Some(team))
382 }
383
384 async fn load_all_locked(&self, teams: &mut HashMap<TeamId, TeamState>) -> anyhow::Result<()> {
385 if !tokio::fs::try_exists(&self.data_dir).await.unwrap_or(false) {
386 return Ok(());
387 }
388 let mut entries = tokio::fs::read_dir(&self.data_dir).await?;
389 while let Some(entry) = entries.next_entry().await? {
390 let path = entry.path();
391 if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
392 continue;
393 }
394 let Ok(data) = tokio::fs::read(&path).await else {
395 continue;
396 };
397 let Ok(team) = serde_json::from_slice::<TeamState>(&data) else {
398 continue;
399 };
400 if teams.contains_key(&team.id) {
401 continue;
402 }
403 teams.insert(team.id.clone(), team);
404 }
405 Ok(())
406 }
407
408 fn team_file(&self, team_id: &str) -> PathBuf {
409 self.data_dir.join(format!("{team_id}.json"))
410 }
411}
412
413pub(crate) fn lead_member(
414 thread_id: ThreadId,
415 model_provider: Option<String>,
416 model: Option<String>,
417 policy_mode: PolicyMode,
418) -> TeamMemberDescriptor {
419 TeamMemberDescriptor {
420 id: "lead".to_string(),
421 role: TeamMemberRole::Lead,
422 name: "Lead".to_string(),
423 task_name: Some("root".to_string()),
424 agent_path: Some("/root".to_string()),
425 thread_id,
426 parent_thread_id: None,
427 current_turn_id: None,
428 model_provider,
429 model,
430 policy_mode,
431 status: TeamMemberStatus::Idle,
432 final_message: None,
433 terminal_error: None,
434 pane_id: None,
435 }
436}
437
438pub(crate) fn teammate_member(
439 id: TeamMemberId,
440 name: String,
441 thread_id: ThreadId,
442 model_provider: Option<String>,
443 model: Option<String>,
444 policy_mode: PolicyMode,
445) -> TeamMemberDescriptor {
446 TeamMemberDescriptor {
447 id,
448 role: TeamMemberRole::Teammate,
449 name,
450 task_name: None,
451 agent_path: None,
452 thread_id,
453 parent_thread_id: None,
454 current_turn_id: None,
455 model_provider,
456 model,
457 policy_mode,
458 status: TeamMemberStatus::Idle,
459 final_message: None,
460 terminal_error: None,
461 pane_id: None,
462 }
463}
464
465pub(crate) fn default_team_data_dir() -> PathBuf {
466 std::env::var_os("RODER_DATA_DIR")
467 .map(PathBuf::from)
468 .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".roder")))
469 .unwrap_or_else(|| PathBuf::from(".roder"))
470 .join("teams")
471}